@cohortapp/agent-sdk 2.17.0 → 2.18.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.json +18 -0
- package/.env.example +18 -5
- package/README.md +1 -0
- package/bin/maestro.mjs +62 -0
- package/docs/guides/billing-console-keys.md +60 -0
- package/docs/guides/front-door-session.md +54 -9
- package/docs/guides/mac-mini.md +20 -25
- package/docs/guides/setup-wizard.md +1 -1
- package/docs/runbooks/fleet-rollout.md +156 -0
- package/docs/runbooks/mac-mini-bootstrap.md +12 -14
- package/lib/action-executor.js +19 -3
- package/lib/budget-guard.mjs +279 -3
- package/lib/channels/base-adapter.mjs +3 -1
- package/lib/channels/contract.mjs +2 -1
- package/lib/channels/inbox-item.mjs +8 -0
- package/lib/claude-bin.mjs +5 -6
- package/lib/cli/doctor-checks.mjs +141 -10
- package/lib/cli/global-setup-extras.mjs +5 -1
- package/lib/cli/inbox.mjs +100 -15
- package/lib/cli/seat-auth.mjs +463 -0
- package/lib/cli/session.mjs +80 -12
- package/lib/collective/capture.mjs +8 -6
- package/lib/collective/global-config.mjs +63 -1
- package/lib/collective/presence.mjs +142 -5
- package/lib/comms/send-gate.mjs +559 -1
- package/lib/diagnostics/alerts.mjs +49 -0
- package/lib/diagnostics/cadence-output-freshness.mjs +288 -0
- package/lib/engine/agents/definitions.mjs +343 -0
- package/lib/engine/agents/persist.mjs +275 -0
- package/lib/engine/agents/runtime.mjs +748 -0
- package/lib/engine/agents/usage.mjs +95 -0
- package/lib/engine/auth-status.mjs +139 -0
- package/lib/engine/budget.mjs +194 -0
- package/lib/engine/cli.mjs +1204 -0
- package/lib/engine/commands/index.mjs +269 -0
- package/lib/engine/context/budget.mjs +219 -0
- package/lib/engine/context/cache.mjs +125 -0
- package/lib/engine/context/child-env.mjs +215 -0
- package/lib/engine/context/compaction.mjs +342 -0
- package/lib/engine/context/images.mjs +90 -0
- package/lib/engine/context/instructions.mjs +327 -0
- package/lib/engine/context/lazy-instructions.mjs +169 -0
- package/lib/engine/context/manager.mjs +182 -0
- package/lib/engine/context/real-path.mjs +91 -0
- package/lib/engine/context/secret-values.mjs +163 -0
- package/lib/engine/context/settings.mjs +274 -0
- package/lib/engine/context/stream-input.mjs +159 -0
- package/lib/engine/guard.mjs +152 -0
- package/lib/engine/hooks.mjs +713 -0
- package/lib/engine/loop.mjs +560 -0
- package/lib/engine/mcp/client.mjs +254 -0
- package/lib/engine/mcp/config.mjs +301 -0
- package/lib/engine/mcp/http.mjs +201 -0
- package/lib/engine/mcp/index.mjs +146 -0
- package/lib/engine/mcp/jsonrpc.mjs +147 -0
- package/lib/engine/mcp/naming.mjs +66 -0
- package/lib/engine/mcp/resources.mjs +89 -0
- package/lib/engine/mcp/results.mjs +133 -0
- package/lib/engine/mcp/stdio.mjs +137 -0
- package/lib/engine/mcp/supervisor.mjs +116 -0
- package/lib/engine/messages.mjs +104 -0
- package/lib/engine/output/json.mjs +164 -0
- package/lib/engine/output/stream-json.mjs +266 -0
- package/lib/engine/permissions.mjs +845 -0
- package/lib/engine/process-identity.mjs +164 -0
- package/lib/engine/process-tree.mjs +551 -0
- package/lib/engine/prompt.mjs +60 -0
- package/lib/engine/session/store.mjs +299 -0
- package/lib/engine/session-runtime/args.mjs +97 -0
- package/lib/engine/session-runtime/host.mjs +143 -0
- package/lib/engine/session-runtime/inbox.mjs +122 -0
- package/lib/engine/session-runtime/notifications.mjs +129 -0
- package/lib/engine/session-runtime/registry.mjs +328 -0
- package/lib/engine/session-runtime/runner.mjs +344 -0
- package/lib/engine/session-runtime/socket.mjs +212 -0
- package/lib/engine/session-runtime/wakeup.mjs +115 -0
- package/lib/engine/skills/index.mjs +321 -0
- package/lib/engine/tools/bash-background.mjs +533 -0
- package/lib/engine/tools/bash.mjs +216 -0
- package/lib/engine/tools/edit.mjs +97 -0
- package/lib/engine/tools/glob.mjs +81 -0
- package/lib/engine/tools/grep.mjs +224 -0
- package/lib/engine/tools/index.mjs +84 -0
- package/lib/engine/tools/list-agents.mjs +32 -0
- package/lib/engine/tools/ls.mjs +127 -0
- package/lib/engine/tools/monitor.mjs +82 -0
- package/lib/engine/tools/notebook-edit.mjs +218 -0
- package/lib/engine/tools/read.mjs +103 -0
- package/lib/engine/tools/schedule-wakeup.mjs +45 -0
- package/lib/engine/tools/schema.mjs +144 -0
- package/lib/engine/tools/send-message.mjs +77 -0
- package/lib/engine/tools/session.mjs +70 -0
- package/lib/engine/tools/todo.mjs +144 -0
- package/lib/engine/tools/toolsearch.mjs +217 -0
- package/lib/engine/tools/walk.mjs +193 -0
- package/lib/engine/tools/web-switch.mjs +31 -0
- package/lib/engine/tools/webfetch-html.mjs +387 -0
- package/lib/engine/tools/webfetch-net.mjs +340 -0
- package/lib/engine/tools/webfetch.mjs +198 -0
- package/lib/engine/tools/websearch.mjs +91 -0
- package/lib/engine/tools/workflow.mjs +95 -0
- package/lib/engine/tools/write.mjs +76 -0
- package/lib/engine/tui/line-editor.mjs +137 -0
- package/lib/engine/tui/render.mjs +86 -0
- package/lib/engine/tui/tui.mjs +274 -0
- package/lib/engine/wire/anthropic-messages.mjs +263 -0
- package/lib/engine/wire/effort.mjs +36 -0
- package/lib/engine/wire/errors.mjs +496 -0
- package/lib/engine/wire/http.mjs +441 -0
- package/lib/engine/wire/index.mjs +76 -0
- package/lib/engine/wire/openai-chat.mjs +332 -0
- package/lib/engine/wire/prompt-cache.mjs +79 -0
- package/lib/engine/wire/search.mjs +140 -0
- package/lib/engine/wire/sse.mjs +114 -0
- package/lib/engine/wire/stall.mjs +349 -0
- package/lib/engine/wire/token-provider.mjs +175 -0
- package/lib/engine/wire/usage.mjs +192 -0
- package/lib/engine/workflow/host.mjs +524 -0
- package/lib/engine/workflow/journal.mjs +188 -0
- package/lib/engine/workflow/json-schema.mjs +171 -0
- package/lib/engine/workflow/meta.mjs +329 -0
- package/lib/engine/workflow/notifications.mjs +52 -0
- package/lib/engine/workflow/runtime.mjs +447 -0
- package/lib/engine/workflow/sandbox.mjs +534 -0
- package/lib/engine/workflow/worker.mjs +141 -0
- package/lib/engine/workflow/worktree.mjs +74 -0
- package/lib/execution/disposition.mjs +1 -1
- package/lib/execution/intake.mjs +10 -0
- package/lib/execution/surface-policy.mjs +15 -0
- package/lib/learning/curator.mjs +8 -6
- package/lib/learning/reflect.mjs +8 -6
- package/lib/model-router/catalog/cohort.yaml +137 -0
- package/lib/model-router/catalog.mjs +118 -1
- package/lib/model-router/failover.mjs +67 -16
- package/lib/model-router/llm-task.mjs +39 -3
- package/lib/model-router/resolve.mjs +89 -3
- package/lib/model-router/spawn.mjs +46 -47
- package/lib/model-router/taxonomy.mjs +126 -4
- package/lib/org/cost-sync.mjs +141 -11
- package/lib/org/inbound/broadcast.mjs +289 -0
- package/lib/org/inbound/collective.mjs +375 -0
- package/lib/org/inbound/directedness.mjs +96 -8
- package/lib/org/inbound/facts.mjs +78 -2
- package/lib/org/inbound/project.mjs +22 -0
- package/lib/org/inbound/surfaces.mjs +14 -0
- package/lib/org/llm-token.mjs +879 -0
- package/lib/org/mesh.mjs +61 -0
- package/lib/org/messaging.mjs +3 -1
- package/lib/org/protocol.checksum +1 -1
- package/lib/org/protocol.mjs +15 -0
- package/lib/org/quota.mjs +520 -0
- package/lib/org/tool-surface.mjs +104 -16
- package/lib/org/ui-parity.mjs +16 -1
- package/lib/org/work-ledger.mjs +37 -6
- package/lib/rate-guard.mjs +114 -1
- package/lib/resource-governor.mjs +41 -6
- package/lib/runtime/adapter.mjs +823 -0
- package/lib/runtime/child-env.mjs +191 -0
- package/lib/runtime/legacy-shell-guard.mjs +97 -0
- package/lib/runtime/seat-engine.mjs +162 -0
- package/lib/session/ask-ledger.mjs +271 -0
- package/lib/session/current-work.mjs +676 -0
- package/lib/session/feed-core.mjs +40 -3
- package/lib/session/launch-args.mjs +56 -4
- package/lib/session/status-summary.mjs +26 -9
- package/lib/session/upgrade-notice.mjs +42 -0
- package/lib/setup/claude-probe.mjs +117 -13
- package/lib/setup/enrich.mjs +13 -10
- package/lib/setup/sections/model.mjs +39 -13
- package/lib/telemetry/collect.mjs +208 -9
- package/lib/upgrade/ignored-drift.mjs +105 -0
- package/lib/voice/post-call-brief.mjs +30 -17
- package/package.json +13 -3
- package/plugins/maestro-skills/skills/board-work.md +5 -0
- package/plugins/maestro-skills/skills/inbound-triage.md +56 -15
- package/plugins/maestro-skills/skills/main-session.md +18 -7
- package/scripts/ci/check-tarball-fidelity.mjs +126 -2
- package/scripts/ci/run-tests.mjs +47 -19
- package/scripts/cohort-llm/api-key-helper.mjs +92 -0
- package/scripts/collective/hook-runner.mjs +29 -2
- package/scripts/continuous-monitor.sh +13 -0
- package/scripts/cost/track-claude-usage.mjs +15 -0
- package/scripts/daemon/agent-daemon.mjs +408 -20
- package/scripts/daemon/assurance.mjs +48 -12
- package/scripts/daemon/cadence-consumer.mjs +218 -68
- package/scripts/daemon/cadence-handlers.mjs +73 -4
- package/scripts/daemon/classifier.mjs +75 -26
- package/scripts/daemon/context-compiler.mjs +51 -37
- package/scripts/daemon/deliver.mjs +30 -1
- package/scripts/daemon/dispatcher.mjs +595 -149
- package/scripts/daemon/health.mjs +14 -1
- package/scripts/daemon/maestro-daemon.mjs +11 -0
- package/scripts/daemon/prompt-builder.mjs +24 -0
- package/scripts/daemon/responder.mjs +246 -79
- package/scripts/daemon/sdk-version.mjs +98 -16
- package/scripts/eval/probe-gateway.mjs +635 -0
- package/scripts/eval/replay/extract.mjs +270 -0
- package/scripts/eval/replay/grade.mjs +260 -0
- package/scripts/eval/replay/lib/config.mjs +50 -0
- package/scripts/eval/replay/lib/effects.mjs +65 -0
- package/scripts/eval/replay/lib/fixture.mjs +188 -0
- package/scripts/eval/replay/lib/judge.mjs +72 -0
- package/scripts/eval/replay/lib/redact.mjs +136 -0
- package/scripts/eval/replay/lib/sandbox.mjs +170 -0
- package/scripts/eval/replay/lib/schema-check.mjs +63 -0
- package/scripts/eval/replay/lib/transcript.mjs +76 -0
- package/scripts/eval/replay/mcp-replay-stub.mjs +101 -0
- package/scripts/eval/replay/report.mjs +185 -0
- package/scripts/eval/replay/run.mjs +404 -0
- package/scripts/fleet/rollout.mjs +1094 -0
- package/scripts/hooks/pre-send-audit.sh +36 -245
- package/scripts/hooks/pre-write-yaml-validate.mjs +275 -0
- package/scripts/hooks/validate-state-yaml.sh +190 -0
- package/scripts/huddle/huddle-llm.mjs +361 -0
- package/scripts/huddle/huddle-server.mjs +46 -121
- package/scripts/local-triggers/autoupdate.sh +448 -78
- package/scripts/local-triggers/run-trigger.sh +13 -0
- package/scripts/maintenance/pin-integrity.mjs +364 -0
- package/scripts/poll-slack-events.sh +41 -9
- package/scripts/poller/slack-socket-mode.mjs +28 -3
- package/scripts/session/supervisor.mjs +80 -13
- package/scripts/spawn-session.sh +13 -0
- package/bin/maestro.test.mjs +0 -1574
- package/lib/action-executor.test.mjs +0 -871
- package/lib/archetype.test.mjs +0 -132
- package/lib/assurance/plan-note.test.mjs +0 -234
- package/lib/assurance/room-budget.test.mjs +0 -486
- package/lib/assurance/tier.test.mjs +0 -174
- package/lib/autonomy.test.mjs +0 -66
- package/lib/backlog.test.mjs +0 -302
- package/lib/backup/policy.test.mjs +0 -305
- package/lib/budget-escalate.test.mjs +0 -232
- package/lib/budget-guard.envelope.test.mjs +0 -476
- package/lib/budget-guard.test.mjs +0 -427
- package/lib/cadence-bus-requeue.test.mjs +0 -83
- package/lib/cadence-bus-schedule.test.mjs +0 -194
- package/lib/cadence-bus.test.mjs +0 -720
- package/lib/cadences.test.mjs +0 -230
- package/lib/capability/inventory.test.mjs +0 -232
- package/lib/capability.test.mjs +0 -78
- package/lib/channels/base-adapter.test.mjs +0 -590
- package/lib/channels/channels.test.mjs +0 -371
- package/lib/channels/contract.test.mjs +0 -162
- package/lib/channels/inbox-item.test.mjs +0 -368
- package/lib/channels/orgmail/adapter.test.mjs +0 -448
- package/lib/channels/pairing.test.mjs +0 -270
- package/lib/channels/repeat-suppressor.test.mjs +0 -134
- package/lib/channels/slack-adapter.test.mjs +0 -212
- package/lib/channels/telegram-adapter.test.mjs +0 -306
- package/lib/channels/voice/adapter.test.mjs +0 -278
- package/lib/channels/whatsapp/adapter-baileys.test.mjs +0 -359
- package/lib/channels/whatsapp/baileys-typing.test.mjs +0 -154
- package/lib/charter.test.mjs +0 -89
- package/lib/claude-bin.test.mjs +0 -131
- package/lib/cli/board.test.mjs +0 -227
- package/lib/cli/design.test.mjs +0 -270
- package/lib/cli/doctor-checks.test.mjs +0 -336
- package/lib/cli/global-setup-extras.test.mjs +0 -462
- package/lib/cli/inbox.test.mjs +0 -230
- package/lib/cli/session-ack.test.mjs +0 -63
- package/lib/cli/session.test.mjs +0 -613
- package/lib/collective/capture.test.mjs +0 -121
- package/lib/collective/cards.test.mjs +0 -114
- package/lib/collective/config.test.mjs +0 -123
- package/lib/collective/global-config.test.mjs +0 -220
- package/lib/collective/global-skills.test.mjs +0 -126
- package/lib/collective/presence.test.mjs +0 -95
- package/lib/collective/recall.test.mjs +0 -116
- package/lib/collective/vendor-skills.test.mjs +0 -306
- package/lib/comms/send-gate.test.mjs +0 -770
- package/lib/comms.test.mjs +0 -41
- package/lib/context/budget.test.mjs +0 -252
- package/lib/context/history-scope.test.mjs +0 -79
- package/lib/cost/ledger-row.test.mjs +0 -183
- package/lib/design/design-md.test.mjs +0 -318
- package/lib/design/fixtures/DESIGN.golden.md +0 -238
- package/lib/design/fixtures/PRODUCT.golden.md +0 -67
- package/lib/design/fixtures/foundation.json +0 -133
- package/lib/design/refresh-gate.test.mjs +0 -144
- package/lib/design/write.test.mjs +0 -241
- package/lib/diagnostics/alerts.test.mjs +0 -318
- package/lib/diagnostics/backup-freshness.test.mjs +0 -185
- package/lib/diagnostics/counters.test.mjs +0 -206
- package/lib/diagnostics/events.test.mjs +0 -290
- package/lib/diagnostics/otel.test.mjs +0 -196
- package/lib/diagnostics/trace.test.mjs +0 -251
- package/lib/env-compat.test.mjs +0 -104
- package/lib/execution/disposition.test.mjs +0 -553
- package/lib/execution/drive.test.mjs +0 -270
- package/lib/execution/effects.test.mjs +0 -344
- package/lib/execution/intake.test.mjs +0 -389
- package/lib/execution/journal.test.mjs +0 -261
- package/lib/execution/match.test.mjs +0 -235
- package/lib/execution/pipeline.test.mjs +0 -392
- package/lib/execution/route.test.mjs +0 -186
- package/lib/execution/surface-policy.test.mjs +0 -162
- package/lib/fs-atomic.test.mjs +0 -72
- package/lib/fs-ownership.test.mjs +0 -158
- package/lib/goals/admission.test.mjs +0 -164
- package/lib/goals/classify.test.mjs +0 -167
- package/lib/goals/collaborate.test.mjs +0 -336
- package/lib/goals/gaps.test.mjs +0 -284
- package/lib/goals/loop.test.mjs +0 -845
- package/lib/hooks/bus.test.mjs +0 -387
- package/lib/identity/persona.test.mjs +0 -142
- package/lib/kpi-sensors.test.mjs +0 -278
- package/lib/kpi.test.mjs +0 -244
- package/lib/learning/config.test.mjs +0 -75
- package/lib/learning/counters.test.mjs +0 -69
- package/lib/learning/curator-consolidate.test.mjs +0 -238
- package/lib/learning/curator.test.mjs +0 -106
- package/lib/learning/reflect.test.mjs +0 -0
- package/lib/learning/session-index.test.mjs +0 -125
- package/lib/learning/skill-writer.test.mjs +0 -210
- package/lib/mandate/audit.test.mjs +0 -195
- package/lib/mandate/contract.test.mjs +0 -185
- package/lib/mandate/derive.test.mjs +0 -274
- package/lib/mandate/model.test.mjs +0 -164
- package/lib/mandate/refresh.test.mjs +0 -389
- package/lib/mcp/server.test.mjs +0 -426
- package/lib/model-router/auth-profiles.test.mjs +0 -580
- package/lib/model-router/catalog.test.mjs +0 -385
- package/lib/model-router/economics.test.mjs +0 -438
- package/lib/model-router/failover.test.mjs +0 -439
- package/lib/model-router/health.test.mjs +0 -338
- package/lib/model-router/integration-coverage.test.mjs +0 -831
- package/lib/model-router/integration.test.mjs +0 -564
- package/lib/model-router/ledger.test.mjs +0 -415
- package/lib/model-router/llm-task.test.mjs +0 -392
- package/lib/model-router/org-credentials.test.mjs +0 -265
- package/lib/model-router/pricing-refresh.test.mjs +0 -286
- package/lib/model-router/reconcile.test.mjs +0 -316
- package/lib/model-router/repair.test.mjs +0 -180
- package/lib/model-router/spawn.test.mjs +0 -446
- package/lib/model-router/taxonomy.test.mjs +0 -410
- package/lib/model-router.test.mjs +0 -1207
- package/lib/org/activity.test.mjs +0 -134
- package/lib/org/approvals.test.mjs +0 -216
- package/lib/org/awareness.test.mjs +0 -159
- package/lib/org/board-mine-cache.test.mjs +0 -53
- package/lib/org/board.test.mjs +0 -187
- package/lib/org/bootstrap-context.test.mjs +0 -153
- package/lib/org/client.test.mjs +0 -1206
- package/lib/org/cohort-client.test.mjs +0 -126
- package/lib/org/cost-sync.test.mjs +0 -153
- package/lib/org/doctor.test.mjs +0 -346
- package/lib/org/engagement-ledger.test.mjs +0 -112
- package/lib/org/engagement.test.mjs +0 -739
- package/lib/org/handoff.test.mjs +0 -269
- package/lib/org/inbound/directedness.test.mjs +0 -668
- package/lib/org/inbound/facts.test.mjs +0 -471
- package/lib/org/inbound/hydrate.test.mjs +0 -908
- package/lib/org/inbound/index.test.mjs +0 -429
- package/lib/org/inbound/project.test.mjs +0 -287
- package/lib/org/integration-tools.test.mjs +0 -160
- package/lib/org/keys.test.mjs +0 -92
- package/lib/org/knowledge.test.mjs +0 -326
- package/lib/org/leases.test.mjs +0 -235
- package/lib/org/mesh-directives.test.mjs +0 -110
- package/lib/org/mesh-integration.test.mjs +0 -127
- package/lib/org/mesh.test.mjs +0 -400
- package/lib/org/messaging.test.mjs +0 -471
- package/lib/org/param-contract.test.mjs +0 -477
- package/lib/org/policy.test.mjs +0 -237
- package/lib/org/protocol.checksum.test.mjs +0 -90
- package/lib/org/protocol.test.mjs +0 -323
- package/lib/org/push.test.mjs +0 -792
- package/lib/org/registry.test.mjs +0 -100
- package/lib/org/resource-tools.test.mjs +0 -361
- package/lib/org/tool-access.test.mjs +0 -144
- package/lib/org/tool-surface-integration.test.mjs +0 -120
- package/lib/org/tool-surface.test.mjs +0 -1268
- package/lib/org/typing.test.mjs +0 -291
- package/lib/org/ui-parity.test.mjs +0 -560
- package/lib/org/verify.test.mjs +0 -194
- package/lib/org/work-ledger.test.mjs +0 -273
- package/lib/plan/adoption-e2e.test.mjs +0 -366
- package/lib/plan/budget-enforcement.test.mjs +0 -400
- package/lib/plan/compile.test.mjs +0 -382
- package/lib/plan/emit.test.mjs +0 -269
- package/lib/plan/explain.test.mjs +0 -188
- package/lib/prompts/parallelism.test.mjs +0 -177
- package/lib/rag/rag.test.mjs +0 -505
- package/lib/rate-guard.test.mjs +0 -272
- package/lib/reactive-gate.test.mjs +0 -57
- package/lib/render.test.mjs +0 -68
- package/lib/resource-governor.test.mjs +0 -488
- package/lib/scheduling/dynamic-jobs.test.mjs +0 -344
- package/lib/scheduling/jitter.test.mjs +0 -140
- package/lib/secrets/broker.test.mjs +0 -280
- package/lib/secrets/providers.test.mjs +0 -274
- package/lib/security/audit-engine.test.mjs +0 -424
- package/lib/security/coerce-args.test.mjs +0 -281
- package/lib/security/dangerous-tools.test.mjs +0 -68
- package/lib/security/external-content.test.mjs +0 -84
- package/lib/security/redact.test.mjs +0 -441
- package/lib/security/secret-equal.test.mjs +0 -55
- package/lib/session/config.test.mjs +0 -92
- package/lib/session/feed-core.test.mjs +0 -198
- package/lib/session/first-run.test.mjs +0 -121
- package/lib/session/frontdoor.test.mjs +0 -205
- package/lib/session/handoffs.test.mjs +0 -183
- package/lib/session/identity.test.mjs +0 -180
- package/lib/session/inbox-claims.test.mjs +0 -286
- package/lib/session/launch-args.test.mjs +0 -157
- package/lib/session/liveness.test.mjs +0 -100
- package/lib/session/status-summary.test.mjs +0 -118
- package/lib/session-permissions.test.mjs +0 -120
- package/lib/setup/claude-probe.test.mjs +0 -187
- package/lib/setup/completeness.test.mjs +0 -110
- package/lib/setup/context-pack.test.mjs +0 -89
- package/lib/setup/enrich.test.mjs +0 -115
- package/lib/setup/enroll-from-cohort.test.mjs +0 -300
- package/lib/setup/integration.test.mjs +0 -162
- package/lib/setup/io.test.mjs +0 -77
- package/lib/setup/runner.test.mjs +0 -132
- package/lib/setup/sections/identity.test.mjs +0 -234
- package/lib/setup/sections/inventory.test.mjs +0 -198
- package/lib/setup/sections/learning.test.mjs +0 -81
- package/lib/setup/sections/mandate.test.mjs +0 -388
- package/lib/setup/sections/messaging.test.mjs +0 -127
- package/lib/setup/sections/model.test.mjs +0 -240
- package/lib/setup/sections/org.test.mjs +0 -346
- package/lib/setup/sections/orgmail.test.mjs +0 -118
- package/lib/setup/sections/recovery.test.mjs +0 -98
- package/lib/setup/sections/subagents.test.mjs +0 -429
- package/lib/setup/sections/verify.test.mjs +0 -175
- package/lib/setup/sot.test.mjs +0 -81
- package/lib/setup/state.test.mjs +0 -115
- package/lib/singleton.test.mjs +0 -151
- package/lib/subagents/cli.test.mjs +0 -389
- package/lib/subagents/client.test.mjs +0 -309
- package/lib/subagents/gap.test.mjs +0 -234
- package/lib/subagents/lock.test.mjs +0 -248
- package/lib/subagents/manifest.test.mjs +0 -175
- package/lib/subagents/refs.test.mjs +0 -204
- package/lib/subagents/resolve.test.mjs +0 -422
- package/lib/subagents/schema.test.mjs +0 -328
- package/lib/telemetry/alerts.test.mjs +0 -109
- package/lib/telemetry/collect.test.mjs +0 -1274
- package/lib/tool-definitions-integration.test.mjs +0 -83
- package/lib/tool-definitions.test.mjs +0 -437
- package/lib/upgrade/global-refresh.test.mjs +0 -65
- package/lib/upgrade/launchd-reconcile.test.mjs +0 -272
- package/lib/upgrade/post-steps.test.mjs +0 -200
- package/lib/upgrade/verify.test.mjs +0 -164
- package/lib/util/fetch-timeout.test.mjs +0 -202
- package/lib/util/reconnect.test.mjs +0 -369
- package/lib/util/unhandled.test.mjs +0 -216
- package/lib/voice/outbound.test.mjs +0 -69
- package/lib/voice/session-rotation.test.mjs +0 -114
- package/lib/voice/stt.test.mjs +0 -226
- package/lib/voice/voice.test.mjs +0 -990
- package/scripts/cadence/enqueue-cadence-tick.test.mjs +0 -187
- package/scripts/ci/check-docs-accuracy.test.mjs +0 -409
- package/scripts/ci/check-durable-write-seam.test.mjs +0 -90
- package/scripts/ci/check-no-build-artifacts.test.mjs +0 -71
- package/scripts/ci/check-no-residual-identity.test.mjs +0 -202
- package/scripts/ci/check-skill-packs.test.mjs +0 -495
- package/scripts/ci/check-subagent-frontmatter.test.mjs +0 -124
- package/scripts/ci/check.test.mjs +0 -194
- package/scripts/ci/conformance-org-api.test.mjs +0 -425
- package/scripts/cloud-relay/voice/relay-identity.test.mjs +0 -96
- package/scripts/collective/hook-runner.test.mjs +0 -173
- package/scripts/cost/fleet-digest.test.mjs +0 -207
- package/scripts/cost/track-claude-usage-pricing.test.mjs +0 -183
- package/scripts/cost/track-claude-usage.test.mjs +0 -148
- package/scripts/daemon/agent-daemon-board-mine.test.mjs +0 -96
- package/scripts/daemon/agent-daemon-design.test.mjs +0 -238
- package/scripts/daemon/agent-daemon-frontdoor.test.mjs +0 -60
- package/scripts/daemon/agent-daemon.test.mjs +0 -995
- package/scripts/daemon/assurance-e2e.test.mjs +0 -613
- package/scripts/daemon/assurance.test.mjs +0 -1791
- package/scripts/daemon/board-mirror.test.mjs +0 -165
- package/scripts/daemon/cadence-consumer-frontdoor.test.mjs +0 -393
- package/scripts/daemon/cadence-consumer-governance.test.mjs +0 -276
- package/scripts/daemon/cadence-consumer.test.mjs +0 -776
- package/scripts/daemon/cadence-handlers.test.mjs +0 -837
- package/scripts/daemon/classifier-identity.test.mjs +0 -137
- package/scripts/daemon/classifier.test.mjs +0 -266
- package/scripts/daemon/classify-kind.test.mjs +0 -40
- package/scripts/daemon/context-compiler.test.mjs +0 -406
- package/scripts/daemon/deliver.test.mjs +0 -564
- package/scripts/daemon/dispatcher-cooldown.test.mjs +0 -122
- package/scripts/daemon/dispatcher-governance.test.mjs +0 -1013
- package/scripts/daemon/dispatcher-resume.test.mjs +0 -166
- package/scripts/daemon/dispatcher-session-continuity.test.mjs +0 -365
- package/scripts/daemon/execution-ladder.test.mjs +0 -470
- package/scripts/daemon/goal-steward-cadence.test.mjs +0 -312
- package/scripts/daemon/inbox-deferral-session.test.mjs +0 -49
- package/scripts/daemon/inbox-deferral.test.mjs +0 -336
- package/scripts/daemon/inbox-wake.test.mjs +0 -199
- package/scripts/daemon/integration.test.mjs +0 -149
- package/scripts/daemon/lib/self-echo.test.mjs +0 -153
- package/scripts/daemon/lib/session-router.test.mjs +0 -554
- package/scripts/daemon/prompt-builder-preamble.test.mjs +0 -210
- package/scripts/daemon/prompt-builder.test.mjs +0 -556
- package/scripts/daemon/responder-cost.test.mjs +0 -68
- package/scripts/daemon/responder-history.test.mjs +0 -221
- package/scripts/daemon/sdk-version.test.mjs +0 -31
- package/scripts/daemon/session-lock.test.mjs +0 -252
- package/scripts/daemon/session-outcomes.test.mjs +0 -533
- package/scripts/daemon/typing-registry.test.mjs +0 -102
- package/scripts/hooks/pre-send-audit.test.mjs +0 -354
- package/scripts/huddle/huddle-prompt.test.mjs +0 -176
- package/scripts/local-triggers/autoupdate.test.mjs +0 -518
- package/scripts/local-triggers/generate-plists.test.mjs +0 -456
- package/scripts/media-generation/brand-clause.test.mjs +0 -135
- package/scripts/org/send-orgmail.first-contact.test.mjs +0 -102
- package/scripts/poller/inbox-privilege-injection.test.mjs +0 -167
- package/scripts/poller/inbox-scan-poller.test.mjs +0 -295
- package/scripts/poller/lib/cloud-relay-dedup.test.mjs +0 -133
- package/scripts/poller/slack-socket-mode.test.mjs +0 -805
- package/scripts/poller-launchd/install.test.mjs +0 -243
- package/scripts/restore-from-backup.test.mjs +0 -181
- package/scripts/session/feed.test.mjs +0 -196
- package/scripts/session/supervisor-sh.test.mjs +0 -218
- package/scripts/session/supervisor.test.mjs +0 -482
- package/scripts/setup/configure-macos.test.mjs +0 -306
- package/scripts/setup/gen-subagent-manifest.test.mjs +0 -124
- package/scripts/setup/generate-agent-package-json.test.mjs +0 -143
- package/scripts/setup/generate-capability.test.mjs +0 -134
- package/scripts/setup/init-agent.test.mjs +0 -370
- package/scripts/setup/init-skill-marketplace.test.mjs +0 -193
- package/scripts/vendor/sync-skill-packs.test.mjs +0 -103
- package/scripts/watchdog/memory-watchdog.test.mjs +0 -64
|
@@ -1,1791 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* assurance.test.mjs — the four ways an ask used to end in silence, and the
|
|
3
|
-
* message the human now gets instead of each of them.
|
|
4
|
-
*
|
|
5
|
-
* Run: node --test scripts/daemon/assurance.test.mjs
|
|
6
|
-
*
|
|
7
|
-
* Every test here uses an INJECTED FAKE for transport. Nothing in this file
|
|
8
|
-
* touches Slack, Gmail, hq or the live workspace, and no test spawns a model.
|
|
9
|
-
* That is not only hygiene — it is the point being proven: the whole
|
|
10
|
-
* acknowledgement path is now deterministic string work, so a test can assert
|
|
11
|
-
* the exact words a human would read.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import { test, describe, before, beforeEach, after } from "node:test";
|
|
15
|
-
import assert from "node:assert/strict";
|
|
16
|
-
import { mkdtempSync, rmSync, mkdirSync } from "node:fs";
|
|
17
|
-
import { tmpdir } from "node:os";
|
|
18
|
-
import { join } from "node:path";
|
|
19
|
-
|
|
20
|
-
// Point the whole module tree at a scratch root BEFORE importing anything that
|
|
21
|
-
// resolves AGENT_DIR at module load.
|
|
22
|
-
const ROOT = mkdtempSync(join(tmpdir(), "assurance-test-"));
|
|
23
|
-
process.env.AGENT_DIR = ROOT;
|
|
24
|
-
process.env.AGENT_ROOT = ROOT;
|
|
25
|
-
mkdirSync(join(ROOT, "state"), { recursive: true });
|
|
26
|
-
|
|
27
|
-
const assurance = await import("./assurance.mjs");
|
|
28
|
-
const receipts = await import("../../lib/comms/receipts.mjs");
|
|
29
|
-
|
|
30
|
-
const {
|
|
31
|
-
shouldAcknowledge,
|
|
32
|
-
generateAck,
|
|
33
|
-
sanitiseAckText,
|
|
34
|
-
composeFailure,
|
|
35
|
-
composeSilentSuccess,
|
|
36
|
-
isOwnNarratorText,
|
|
37
|
-
SILENT_SUCCESS_EXCERPT_LIMIT,
|
|
38
|
-
classifyFailure,
|
|
39
|
-
openAndAcknowledge,
|
|
40
|
-
settleSession,
|
|
41
|
-
sweepObligations,
|
|
42
|
-
readObligation,
|
|
43
|
-
openObligations,
|
|
44
|
-
_resetObligations,
|
|
45
|
-
ACK_AFTER_MS,
|
|
46
|
-
ACK_MIN_CHARS,
|
|
47
|
-
RETIRED_NARRATOR_TEMPLATES,
|
|
48
|
-
notePlan,
|
|
49
|
-
NOTICE,
|
|
50
|
-
STALE_AFTER_MS,
|
|
51
|
-
INTERRUPT_GRACE_MS,
|
|
52
|
-
ACK_MAX_ATTEMPTS,
|
|
53
|
-
ACK_GEN_MAX_FAILURES,
|
|
54
|
-
RETRY_MAX,
|
|
55
|
-
} = assurance;
|
|
56
|
-
|
|
57
|
-
after(() => { try { rmSync(ROOT, { recursive: true, force: true }); } catch { /* */ } });
|
|
58
|
-
|
|
59
|
-
// ---------------------------------------------------------------------------
|
|
60
|
-
// Fakes
|
|
61
|
-
// ---------------------------------------------------------------------------
|
|
62
|
-
|
|
63
|
-
/** A transport that records what a human would have read, and can be told to fail.
|
|
64
|
-
* The ackSender mirrors responder.sendHoldingMessage's NEW contract exactly:
|
|
65
|
-
* a generated bespoke line on success, and `{sent:false, holdingText:null,
|
|
66
|
-
* genFailed:true}` — i.e. deliberate silence — when generation fails. */
|
|
67
|
-
const FAKE_ACK_LINE = "Give me a few minutes on the git fixes.";
|
|
68
|
-
function fakeTransport(opts = {}) {
|
|
69
|
-
const sent = [];
|
|
70
|
-
let failures = opts.failTimes || 0;
|
|
71
|
-
const impl = async (item, text, o = {}) => {
|
|
72
|
-
if (failures > 0) { failures--; return { sent: false, via: null, error: opts.error || "network unreachable" }; }
|
|
73
|
-
sent.push({ to: item.sender, channel: item.channel_id || item.channel, kind: o.kind || "reply", text });
|
|
74
|
-
return { sent: true, via: "fake", channel: item.channel_id || item.channel };
|
|
75
|
-
};
|
|
76
|
-
return { sent, impl, ackSender: async (item, cr) => {
|
|
77
|
-
if (opts.genFail) return { sent: false, holdingText: null, error: "ack_generation_failed", genFailed: true };
|
|
78
|
-
const text = opts.ackText || FAKE_ACK_LINE;
|
|
79
|
-
const r = await impl(item, text, { kind: "ack" });
|
|
80
|
-
return { sent: r.sent, holdingText: text, error: r.error };
|
|
81
|
-
} };
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
const ITEM = {
|
|
85
|
-
id: "msg-1001",
|
|
86
|
-
raw_ref: "cohort:cmql18026:1001",
|
|
87
|
-
message_id: "1001",
|
|
88
|
-
service: "cohort",
|
|
89
|
-
channel_id: "cmql1802601iihhzpf774vwbk",
|
|
90
|
-
sender: "Dana Whitfield",
|
|
91
|
-
content: "with that in mind, fix these issues you just noted please and push their fixes to git",
|
|
92
|
-
};
|
|
93
|
-
|
|
94
|
-
const CLASS_COMPLEX = { action: "respond", priority: "critical", model: "opus", summary: "Fix the noted issues and push the fixes to git" };
|
|
95
|
-
|
|
96
|
-
beforeEach(() => { _resetObligations(); });
|
|
97
|
-
|
|
98
|
-
// ---------------------------------------------------------------------------
|
|
99
|
-
// 1. THE JUDGEMENT — simple asks must NOT get "I'll look into it"
|
|
100
|
-
// ---------------------------------------------------------------------------
|
|
101
|
-
|
|
102
|
-
describe("shouldAcknowledge — the simple/complex distinction", () => {
|
|
103
|
-
test("a fast ask answered in this turn gets NO acknowledgement", () => {
|
|
104
|
-
// The quick path already sent the answer. An ack here is pure noise.
|
|
105
|
-
const v = shouldAcknowledge({ willSpawnSession: false, item: ITEM, source: "inbox" });
|
|
106
|
-
assert.equal(v.ack, false);
|
|
107
|
-
assert.equal(v.reason, "answered-in-turn");
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
test("a slow ask that spawns a session DOES get one", () => {
|
|
111
|
-
const v = shouldAcknowledge({ willSpawnSession: true, item: ITEM, source: "inbox" });
|
|
112
|
-
assert.equal(v.ack, true);
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
test("REGRESSION: a `queue` action still gets one", () => {
|
|
116
|
-
// classifier.mjs's heuristic fallback (used when the LLM classifier itself
|
|
117
|
-
// fails) emits action:"queue". The old gate was
|
|
118
|
-
// `respond || draft || research`, so exactly when classification degraded, a
|
|
119
|
-
// directed DM got a 45-minute session and total silence. The gate no longer
|
|
120
|
-
// consults the classifier at all.
|
|
121
|
-
const v = shouldAcknowledge({ willSpawnSession: true, item: ITEM, source: "inbox", classResult: { action: "queue" } });
|
|
122
|
-
assert.equal(v.ack, true, "a degraded classification must not cost the human their acknowledgement");
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
test("backlog work, which nobody is waiting on, gets none", () => {
|
|
126
|
-
assert.equal(shouldAcknowledge({ willSpawnSession: true, item: ITEM, source: "backlog" }).ack, false);
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
test("an item with no resolvable reply target gets none (nowhere to send it)", () => {
|
|
130
|
-
const orphan = { ...ITEM, channel_id: null, channel: null };
|
|
131
|
-
assert.equal(shouldAcknowledge({ willSpawnSession: true, item: orphan, source: "inbox" }).ack, false);
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
test("REGRESSION: the agent's OWN echoed message is never acknowledged (self-echo guard #2)", async () => {
|
|
135
|
-
// 25/27/30 Aug 2026: the poll lane re-ingested the agent's own outbound
|
|
136
|
-
// sends and the daemon said "Looking now." to itself — in shared DMs two
|
|
137
|
-
// agents traded canned acks at each other. Ingestion now drops echoes
|
|
138
|
-
// (agent-daemon + self-echo.mjs); this is the second, independent guard on
|
|
139
|
-
// the ack decision itself.
|
|
140
|
-
const { writeFileSync } = await import("node:fs");
|
|
141
|
-
const selfEcho = await import("./lib/self-echo.mjs");
|
|
142
|
-
mkdirSync(join(ROOT, "config"), { recursive: true });
|
|
143
|
-
writeFileSync(join(ROOT, "config", "agent.json"), JSON.stringify({
|
|
144
|
-
firstName: "Isla", fullName: "Isla Roselli", memberId: "cmqh0tcml004ihhl6zev6ocb5",
|
|
145
|
-
}));
|
|
146
|
-
selfEcho._resetSelfIdentity();
|
|
147
|
-
try {
|
|
148
|
-
// The REAL echoed shape: display name verbatim, spaces and all.
|
|
149
|
-
const echo = { ...ITEM, sender: "Isla Roselli" };
|
|
150
|
-
const v = shouldAcknowledge({ willSpawnSession: true, item: echo, source: "inbox" });
|
|
151
|
-
assert.equal(v.ack, false);
|
|
152
|
-
assert.equal(v.reason, "self-sender");
|
|
153
|
-
// The old filter's normalised form must ALSO match — it is how the echo
|
|
154
|
-
// slipped through three times.
|
|
155
|
-
assert.equal(shouldAcknowledge({ willSpawnSession: true, item: { ...ITEM, sender: "isla-roselli" }, source: "inbox" }).ack, false);
|
|
156
|
-
// And a genuine human sender still gets their acknowledgement.
|
|
157
|
-
assert.equal(shouldAcknowledge({ willSpawnSession: true, item: ITEM, source: "inbox" }).ack, true);
|
|
158
|
-
} finally {
|
|
159
|
-
rmSync(join(ROOT, "config", "agent.json"), { force: true });
|
|
160
|
-
selfEcho._resetSelfIdentity();
|
|
161
|
-
}
|
|
162
|
-
});
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
// ---------------------------------------------------------------------------
|
|
166
|
-
// 1b. THE DISPATCH GATE READS THE INBOUND — a seat must not ack (and its
|
|
167
|
-
// caller must not spawn) on output its own emitter class produces.
|
|
168
|
-
// ---------------------------------------------------------------------------
|
|
169
|
-
|
|
170
|
-
describe("shouldAcknowledge — the dispatch gate reads item.content", () => {
|
|
171
|
-
// A peer AGENT seat, identified structurally (seat-slug id / agent↔agent DM),
|
|
172
|
-
// never by name.
|
|
173
|
-
const PEER_ACK = {
|
|
174
|
-
...ITEM,
|
|
175
|
-
sender: "Piotr Novak",
|
|
176
|
-
sender_id: "A039",
|
|
177
|
-
content: "On it — taking a look now.",
|
|
178
|
-
};
|
|
179
|
-
|
|
180
|
-
test("REGRESSION: an ack-shaped one-liner from a peer agent seat gets NO ack and signals NO spawn", () => {
|
|
181
|
-
const v = shouldAcknowledge({ willSpawnSession: true, item: PEER_ACK, source: "inbox" });
|
|
182
|
-
assert.equal(v.ack, false);
|
|
183
|
-
assert.equal(v.reason, "peer-ack-shaped");
|
|
184
|
-
assert.equal(v.emitterClass, true, "the caller reads this to skip the session spawn too");
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
test("the SHAPE matches, not a phrase list — every 'Looking now' variant from a seat is refused", () => {
|
|
188
|
-
for (const content of ["On it.", "Looking now", "Taking the git fixes now — push coming.", "Still digging into this."]) {
|
|
189
|
-
const v = shouldAcknowledge({ willSpawnSession: true, item: { ...PEER_ACK, content }, source: "inbox" });
|
|
190
|
-
assert.equal(v.ack, false, `"${content}" is ack-shaped`);
|
|
191
|
-
assert.equal(v.emitterClass, true);
|
|
192
|
-
}
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
test("an agent↔agent DM channel is an agent-seat signal too", () => {
|
|
196
|
-
const v = shouldAcknowledge({
|
|
197
|
-
willSpawnSession: true,
|
|
198
|
-
item: { ...ITEM, sender: "Piotr Novak", channel: "dm-a016-a039", content: "On it." },
|
|
199
|
-
source: "inbox",
|
|
200
|
-
});
|
|
201
|
-
assert.equal(v.ack, false);
|
|
202
|
-
assert.equal(v.reason, "peer-ack-shaped");
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
test("the SAME short line from a HUMAN sender passes — shape alone is not enough", () => {
|
|
206
|
-
// No seat-slug id, no agent DM channel: treated as human, and a human's
|
|
207
|
-
// terse "On it" must never be met with silence.
|
|
208
|
-
const v = shouldAcknowledge({ willSpawnSession: true, item: { ...ITEM, content: "On it — will do." }, source: "inbox" });
|
|
209
|
-
assert.equal(v.ack, true);
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
test("a QUESTION or a REQUEST from a peer agent seat still passes the gate", () => {
|
|
213
|
-
for (const content of [
|
|
214
|
-
"did the migration land cleanly?",
|
|
215
|
-
"please push the fix to git",
|
|
216
|
-
"can you rerun the failing suite",
|
|
217
|
-
"fix the flaky poller test and push it",
|
|
218
|
-
]) {
|
|
219
|
-
const v = shouldAcknowledge({ willSpawnSession: true, item: { ...PEER_ACK, content }, source: "inbox" });
|
|
220
|
-
assert.equal(v.ack, true, `"${content}" is substantive and must pass`);
|
|
221
|
-
}
|
|
222
|
-
});
|
|
223
|
-
|
|
224
|
-
test("an INFORMATION-BEARING terse line from a peer agent seat passes — facts, links, decisions and completions are not acks", () => {
|
|
225
|
-
// Re-review finding on d5abda3: the shape test alone consumed essentially
|
|
226
|
-
// every terse peer utterance — including decision grants and direct
|
|
227
|
-
// answers to questions this seat asked — and a false consume is silent to
|
|
228
|
-
// the sender and unrecoverable. Only commitment-shaped acks may be eaten.
|
|
229
|
-
for (const content of [
|
|
230
|
-
"Runway is 14 months.", // carries a fact (digit)
|
|
231
|
-
"Yes.", // direct answer
|
|
232
|
-
"No.", // direct answer
|
|
233
|
-
"Approved.", // decision grant
|
|
234
|
-
"Go ahead.", // decision grant
|
|
235
|
-
"LGTM.", // review verdict
|
|
236
|
-
"Ship it.", // decision grant
|
|
237
|
-
"Done.", // completion report
|
|
238
|
-
"Merged.", // completion report
|
|
239
|
-
"Deployed, all green.", // completion report
|
|
240
|
-
"Spec is at https://example.com/doc", // carries a link
|
|
241
|
-
]) {
|
|
242
|
-
const v = shouldAcknowledge({ willSpawnSession: true, item: { ...PEER_ACK, content }, source: "inbox" });
|
|
243
|
-
assert.equal(v.ack, true, `"${content}" is information-bearing and must pass`);
|
|
244
|
-
}
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
test("REGRESSION: the daemon's OWN narrator lines are refused — built from the real compose* strings, so the list cannot drift", () => {
|
|
248
|
-
const narrations = [
|
|
249
|
-
// The two RETIRED progress lines. Nothing composes them any more, but an
|
|
250
|
-
// older seat on the same fleet still emits them until its autoupdate
|
|
251
|
-
// lands, and acking a peer's progress ping spawns a 45-minute session
|
|
252
|
-
// behind it — the 25/27/30 Aug loop.
|
|
253
|
-
...RETIRED_NARRATOR_TEMPLATES,
|
|
254
|
-
"Still on this — 7 minutes in. I'll come back as soon as I've got something.",
|
|
255
|
-
"Still going — 22 minutes in. I'll follow up the moment it's done.",
|
|
256
|
-
composeFailure({}, { failure: { human: "the working session ended unexpectedly" }, willRetry: true }),
|
|
257
|
-
composeFailure({}, { failure: { human: "the work never came back with a result and has now outrun its time limit" }, willRetry: false }),
|
|
258
|
-
assurance.composeInterrupted(),
|
|
259
|
-
composeSilentSuccess({}, {}),
|
|
260
|
-
composeSilentSuccess({}, { finalText: "Here is what I found before it died." }),
|
|
261
|
-
];
|
|
262
|
-
for (const content of narrations) {
|
|
263
|
-
const v = shouldAcknowledge({ willSpawnSession: true, item: { ...ITEM, content }, source: "inbox" });
|
|
264
|
-
assert.equal(v.ack, false, `narrator line must be refused: "${content.slice(0, 50)}…"`);
|
|
265
|
-
assert.equal(v.reason, "own-narrator-pattern");
|
|
266
|
-
assert.equal(v.emitterClass, true);
|
|
267
|
-
}
|
|
268
|
-
});
|
|
269
|
-
|
|
270
|
-
test("a human QUOTING a narrator line inside a longer message still passes (anchored full match)", () => {
|
|
271
|
-
const content = `why did you send me this?\n\n${composeSilentSuccess({}, {})}`;
|
|
272
|
-
const v = shouldAcknowledge({ willSpawnSession: true, item: { ...ITEM, content }, source: "inbox" });
|
|
273
|
-
assert.equal(v.ack, true);
|
|
274
|
-
});
|
|
275
|
-
|
|
276
|
-
test("a genuine substantive ask is untouched by the gate", () => {
|
|
277
|
-
const v = shouldAcknowledge({ willSpawnSession: true, item: ITEM, source: "inbox" });
|
|
278
|
-
assert.equal(v.ack, true);
|
|
279
|
-
assert.equal(v.reason, "session-dispatched");
|
|
280
|
-
});
|
|
281
|
-
|
|
282
|
-
test("every refusal is AUDITED — one JSONL row, self-echo-drop style", async () => {
|
|
283
|
-
const { readFileSync, rmSync: rm } = await import("node:fs");
|
|
284
|
-
const auditPath = join(ROOT, "logs", "daemon", "dispatch-gate-drops.jsonl");
|
|
285
|
-
try { rm(auditPath, { force: true }); } catch { /* */ }
|
|
286
|
-
shouldAcknowledge({ willSpawnSession: true, item: PEER_ACK, source: "inbox" });
|
|
287
|
-
shouldAcknowledge({ willSpawnSession: true, item: { ...ITEM, content: composeSilentSuccess({}, {}) }, source: "inbox" });
|
|
288
|
-
const rows = readFileSync(auditPath, "utf-8").split("\n").filter(Boolean).map((l) => JSON.parse(l));
|
|
289
|
-
assert.equal(rows.length, 2);
|
|
290
|
-
assert.equal(rows[0].stage, "dispatch-gate");
|
|
291
|
-
assert.equal(rows[0].reason, "peer-ack-shaped");
|
|
292
|
-
assert.equal(rows[0].sender, "Piotr Novak");
|
|
293
|
-
assert.equal(rows[1].reason, "own-narrator-pattern");
|
|
294
|
-
});
|
|
295
|
-
});
|
|
296
|
-
|
|
297
|
-
// ---------------------------------------------------------------------------
|
|
298
|
-
// 2. THE ACKNOWLEDGEMENT — one bespoke generated line, or SILENCE. Never a
|
|
299
|
-
// canned rotation (CEO directive, 2026-08-30).
|
|
300
|
-
// ---------------------------------------------------------------------------
|
|
301
|
-
|
|
302
|
-
describe("generateAck — bespoke line or silence, never boilerplate", () => {
|
|
303
|
-
test("the model's line is used verbatim once stripped to one clean line", async () => {
|
|
304
|
-
const a = await generateAck(ITEM, CLASS_COMPLEX, {
|
|
305
|
-
runImpl: async () => '```\n"Taking the poller fixes now — push coming."\n```',
|
|
306
|
-
});
|
|
307
|
-
assert.equal(a, "Taking the poller fixes now — push coming.");
|
|
308
|
-
});
|
|
309
|
-
|
|
310
|
-
test("REGRESSION: a failing generator yields NULL — the caller sends NOTHING, no canned fallback", async () => {
|
|
311
|
-
// The retired behaviour rotated "On it." / "Looking now." / "On it —
|
|
312
|
-
// digging in now." when convenient. The CEO's verdict: a reflex string is
|
|
313
|
-
// worse than no ack. Null is the contract for "say nothing".
|
|
314
|
-
const a = await generateAck(ITEM, CLASS_COMPLEX, {
|
|
315
|
-
runImpl: async () => { throw new Error("claude CLI spawn error: ENOENT"); },
|
|
316
|
-
});
|
|
317
|
-
assert.equal(a, null);
|
|
318
|
-
});
|
|
319
|
-
|
|
320
|
-
test("a HANGING generator is cut off at the budget and yields NULL", async () => {
|
|
321
|
-
const a = await generateAck(ITEM, CLASS_COMPLEX, {
|
|
322
|
-
timeoutMs: 25,
|
|
323
|
-
runImpl: () => new Promise(() => { /* never settles */ }),
|
|
324
|
-
});
|
|
325
|
-
assert.equal(a, null);
|
|
326
|
-
});
|
|
327
|
-
|
|
328
|
-
test("unusable output — empty, essay-length, refusals, assistant filler — yields NULL", () => {
|
|
329
|
-
assert.equal(sanitiseAckText(""), null);
|
|
330
|
-
assert.equal(sanitiseAckText(" \n\n "), null);
|
|
331
|
-
assert.equal(sanitiseAckText("x".repeat(300)), null, "a paragraph is a reply, not an ack");
|
|
332
|
-
assert.equal(sanitiseAckText("I cannot help with that request."), null);
|
|
333
|
-
assert.equal(sanitiseAckText("I'm here to help! Let me know if you need anything else."), null);
|
|
334
|
-
});
|
|
335
|
-
|
|
336
|
-
test("multi-line output is reduced to the FIRST usable line", () => {
|
|
337
|
-
assert.equal(
|
|
338
|
-
sanitiseAckText("Taking the poller fixes now.\nsecond line that must not leak"),
|
|
339
|
-
"Taking the poller fixes now.",
|
|
340
|
-
);
|
|
341
|
-
});
|
|
342
|
-
|
|
343
|
-
test("REGRESSION: assistant-y openers are rejected — 'Sure — here it is:' is a chatbot, not a colleague", () => {
|
|
344
|
-
for (const t of [
|
|
345
|
-
"Sure — here it is:",
|
|
346
|
-
"Sure, taking a look at the git fixes.",
|
|
347
|
-
"Certainly! On the deck now.",
|
|
348
|
-
"Absolutely — picking the poller fix up.",
|
|
349
|
-
"Happy to — starting on the memo.",
|
|
350
|
-
"Of course, I'll dig into the runway numbers.",
|
|
351
|
-
]) {
|
|
352
|
-
assert.equal(sanitiseAckText(t), null, `"${t}" must be rejected`);
|
|
353
|
-
}
|
|
354
|
-
// …while a plain colleague line still passes untouched.
|
|
355
|
-
assert.equal(sanitiseAckText("Taking the git fixes now — push coming."), "Taking the git fixes now — push coming.");
|
|
356
|
-
});
|
|
357
|
-
|
|
358
|
-
test("REGRESSION: the canned rotation is GONE from the module, not merely bypassed", async () => {
|
|
359
|
-
const { readFileSync } = await import("node:fs");
|
|
360
|
-
const src = readFileSync(new URL("./assurance.mjs", import.meta.url), "utf-8");
|
|
361
|
-
// The three retired strings may survive in comments as history, but never
|
|
362
|
-
// as a string-literal ARRAY a code path could pick from.
|
|
363
|
-
assert.doesNotMatch(src, /\[\s*"On it\.",/, "the canned rotation array must not exist");
|
|
364
|
-
});
|
|
365
|
-
});
|
|
366
|
-
|
|
367
|
-
describe("composeSilentSuccess — a finished answer is delivered, not silently clipped", () => {
|
|
368
|
-
const LIMIT = SILENT_SUCCESS_EXCERPT_LIMIT;
|
|
369
|
-
|
|
370
|
-
test("a normal multi-point answer (over the old 900 cap) comes through WHOLE", () => {
|
|
371
|
-
// The exact failure mode the old cap produced: a substantive multi-point
|
|
372
|
-
// answer, ~1200 chars — comfortably over the old 900 cap, well under the new
|
|
373
|
-
// budget. The reader must get every word, with no ellipsis and no marker.
|
|
374
|
-
const answer =
|
|
375
|
-
"Queue placement. A discrete task I own lands cleanest in action-stack.yaml, " +
|
|
376
|
-
"not follow-ups.yaml — the latter is external commitments with due dates, and " +
|
|
377
|
-
"this is a capture I own, not a promise to a counterparty. ".repeat(16).trim();
|
|
378
|
-
assert.ok(answer.length > 900 && answer.length < LIMIT, "fixture must straddle old cap / new budget");
|
|
379
|
-
const out = composeSilentSuccess({}, { finalText: answer });
|
|
380
|
-
assert.ok(out.includes(answer), "the full answer must be present verbatim");
|
|
381
|
-
assert.ok(!out.includes("…"), "a whole answer carries no truncation ellipsis");
|
|
382
|
-
});
|
|
383
|
-
|
|
384
|
-
test("a genuinely huge output is bounded but truncated HONESTLY — count + resend offer, never a bare ellipsis", () => {
|
|
385
|
-
const huge = "x".repeat(LIMIT + 2000);
|
|
386
|
-
const out = composeSilentSuccess({}, { finalText: huge });
|
|
387
|
-
assert.ok(out.length < huge.length, "oversized output is bounded, not dumped whole");
|
|
388
|
-
assert.ok(out.includes(String(huge.length)), "must state the true full length so nothing is lost silently");
|
|
389
|
-
assert.match(out, /resend|full/i, "must offer a path to the dropped remainder");
|
|
390
|
-
});
|
|
391
|
-
|
|
392
|
-
test("REGRESSION: a truncated recovery note is STILL recognised as the daemon's own narrator line (self-echo gate holds)", () => {
|
|
393
|
-
// If truncation broke narrator-recognition, the daemon would re-ingest and
|
|
394
|
-
// re-ack its own rescue message — the exact self-echo failure the gate exists
|
|
395
|
-
// to prevent. The honesty marker must live inside the variable excerpt slot.
|
|
396
|
-
const huge = "y".repeat(LIMIT + 2000);
|
|
397
|
-
const truncated = composeSilentSuccess({}, { finalText: huge });
|
|
398
|
-
assert.equal(isOwnNarratorText(truncated), true, "truncated silent-success must match a narrator pattern");
|
|
399
|
-
});
|
|
400
|
-
});
|
|
401
|
-
|
|
402
|
-
// ---------------------------------------------------------------------------
|
|
403
|
-
// 3. FAILURE CLASSIFICATION — retry the transient, stop on the permanent
|
|
404
|
-
// ---------------------------------------------------------------------------
|
|
405
|
-
|
|
406
|
-
describe("classifyFailure", () => {
|
|
407
|
-
const cases = [
|
|
408
|
-
[{ code: 143, error: "SIGTERM" }, true, "timeout"],
|
|
409
|
-
[{ code: 137 }, true, "killed"],
|
|
410
|
-
[{ code: 1, error: "rate limit exceeded (429)" }, true, "rate_limited"],
|
|
411
|
-
[{ code: null, error: "claude CLI spawn error: ENOENT" }, false, "spawn_failed"],
|
|
412
|
-
[{ code: 1, error: "essential-only: daily budget cap reached" }, false, "budget_refused"],
|
|
413
|
-
[{ code: 1, error: "low free memory (need >=3686MB)" }, true, "resource_starved"],
|
|
414
|
-
[{ code: 0 }, false, "silent_success"],
|
|
415
|
-
];
|
|
416
|
-
for (const [input, transient, label] of cases) {
|
|
417
|
-
test(`${label} → ${transient ? "retry" : "stop"}`, () => {
|
|
418
|
-
const f = classifyFailure(input);
|
|
419
|
-
assert.equal(f.label, label);
|
|
420
|
-
assert.equal(f.transient, transient);
|
|
421
|
-
assert.ok(f.human.length > 10, "every cause must have plain-English wording for the human");
|
|
422
|
-
assert.doesNotMatch(f.human, /error occurred|something went wrong/i, "no content-free apologies");
|
|
423
|
-
});
|
|
424
|
-
}
|
|
425
|
-
});
|
|
426
|
-
|
|
427
|
-
// ---------------------------------------------------------------------------
|
|
428
|
-
// 4. THE LEDGER — a debt survives the send failing
|
|
429
|
-
// ---------------------------------------------------------------------------
|
|
430
|
-
|
|
431
|
-
describe("openAndAcknowledge", () => {
|
|
432
|
-
test("POLICY: opening a debt says NOTHING — the interim belongs to the sweep", async () => {
|
|
433
|
-
// 3,069 opening acknowledgements in fourteen days were exactly this call
|
|
434
|
-
// speaking at t=0. The record is still written first (a crash between here
|
|
435
|
-
// and the session's first token must leave a debt the sweep finds); what is
|
|
436
|
-
// gone is the reflex in front of it.
|
|
437
|
-
const t = fakeTransport();
|
|
438
|
-
const r = await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX, service: "cohort", tier: "work", deps: { ackSender: t.ackSender } });
|
|
439
|
-
assert.equal(t.sent.length, 0, "not one byte at open time");
|
|
440
|
-
assert.equal(r.acked, false);
|
|
441
|
-
assert.equal(r.ackText, null);
|
|
442
|
-
assert.equal(r.reason, "tier-work-deferred");
|
|
443
|
-
const rec = readObligation(r.key);
|
|
444
|
-
assert.equal(rec.state, "open", "the debt is open and owed");
|
|
445
|
-
assert.equal(rec.tier, "work");
|
|
446
|
-
assert.equal(rec.interimSaid, false, "and the one-interim latch is still unspent");
|
|
447
|
-
});
|
|
448
|
-
|
|
449
|
-
test("the answer tier opens the debt and forbids any interim, ever", async () => {
|
|
450
|
-
const t = fakeTransport();
|
|
451
|
-
const r = await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX, tier: "answer", ack: false, deps: { ackSender: t.ackSender } });
|
|
452
|
-
assert.equal(t.sent.length, 0);
|
|
453
|
-
const rec = readObligation(r.key);
|
|
454
|
-
assert.equal(rec.interimForbidden, true, "durable on the record — the sweep runs in another tick, maybe another process");
|
|
455
|
-
const after = await sweepObligations({
|
|
456
|
-
now: Date.now() + ACK_AFTER_MS + 60_000,
|
|
457
|
-
deps: { deliverImpl: t.impl, spokeSinceImpl: () => false, generateAckImpl: async () => FAKE_ACK_LINE },
|
|
458
|
-
});
|
|
459
|
-
assert.equal(after.acked, 0);
|
|
460
|
-
assert.equal(t.sent.length, 0, "the answer tier is silence, at open and at every sweep after it");
|
|
461
|
-
});
|
|
462
|
-
|
|
463
|
-
test("REGRESSION: an un-acknowledged debt is still visible to the sweep", async () => {
|
|
464
|
-
// The original production case: 20 of 32 acknowledgements died and the old
|
|
465
|
-
// code did `logResponse(...); return {sent:false}` — the human was never
|
|
466
|
-
// told, and nothing ever retried. Nothing is sent at open time any more, so
|
|
467
|
-
// what this now pins is the half that still matters: the debt exists.
|
|
468
|
-
const t = fakeTransport({ failTimes: 99, error: "claude CLI timed out after 60000ms" });
|
|
469
|
-
const r = await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX, tier: "work", deps: { ackSender: t.ackSender } });
|
|
470
|
-
assert.equal(r.acked, false);
|
|
471
|
-
const rec = readObligation(r.key);
|
|
472
|
-
assert.equal(rec.acknowledged, false);
|
|
473
|
-
assert.equal(rec.state, "open");
|
|
474
|
-
assert.equal(openObligations().length, 1, "the debt must be visible to the sweep");
|
|
475
|
-
});
|
|
476
|
-
|
|
477
|
-
test("REGRESSION: a generation failure in the interim branch sends NOTHING and records why", async () => {
|
|
478
|
-
// The ack generator's contract on failure is silence, never a placeholder.
|
|
479
|
-
// The branch moved from open time to the sweep; the contract did not.
|
|
480
|
-
const t = fakeTransport();
|
|
481
|
-
const r = await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX, tier: "work" });
|
|
482
|
-
await sweepObligations({
|
|
483
|
-
now: Date.now() + ACK_AFTER_MS + 1000,
|
|
484
|
-
deps: { deliverImpl: t.impl, spokeSinceImpl: () => false, generateAckImpl: async () => null },
|
|
485
|
-
});
|
|
486
|
-
assert.equal(t.sent.length, 0, "not one byte reached the human");
|
|
487
|
-
const rec = readObligation(r.key);
|
|
488
|
-
assert.equal(rec.acknowledged, false);
|
|
489
|
-
assert.equal(rec.ackGenFailures, 1, "the failure is counted, so the retry is bounded");
|
|
490
|
-
assert.equal(rec.state, "open", "the debt survives — the failure and stale notices still owe the human");
|
|
491
|
-
});
|
|
492
|
-
|
|
493
|
-
test("the stored summary keeps the classifier's casing — the board row and escalations read from it", async () => {
|
|
494
|
-
// `rec.summary` feeds board-mirror's row title and the needs-attention
|
|
495
|
-
// escalation record. A summary mangled at open time would resurface there.
|
|
496
|
-
// It must NOT, however, appear in any message the human reads.
|
|
497
|
-
const t = fakeTransport();
|
|
498
|
-
const r = await openAndAcknowledge({
|
|
499
|
-
item: ITEM,
|
|
500
|
-
classResult: { ...CLASS_COMPLEX, summary: "CEO checking in on work progress" },
|
|
501
|
-
deps: { ackSender: t.ackSender },
|
|
502
|
-
});
|
|
503
|
-
const rec = readObligation(r.key);
|
|
504
|
-
assert.equal(rec.summary, "CEO checking in on work progress");
|
|
505
|
-
// It must not leak into anything a human reads. Every deterministic notice
|
|
506
|
-
// the module can still compose is checked, so a future composer that starts
|
|
507
|
-
// interpolating `rec.summary` fails here.
|
|
508
|
-
for (const text of [
|
|
509
|
-
composeFailure(rec, { failure: { human: "the work ran past its time limit and got cut off" }, willRetry: true }),
|
|
510
|
-
composeFailure(rec, { failure: { human: "the work never came back with a result" }, willRetry: false }),
|
|
511
|
-
assurance.composeInterrupted(rec),
|
|
512
|
-
composeSilentSuccess(rec, {}),
|
|
513
|
-
]) {
|
|
514
|
-
assert.doesNotMatch(text, /CEO checking in on work progress/, "the summary is internal framing, never copy");
|
|
515
|
-
}
|
|
516
|
-
});
|
|
517
|
-
|
|
518
|
-
test("speaks ONCE — a re-delivered item, and a RETRY that reopens the key, do not re-announce", async () => {
|
|
519
|
-
// The retry half is R4: a failed obligation is CLOSED, the item is
|
|
520
|
-
// re-delivered by the next poll, and a brand-new record opens under the
|
|
521
|
-
// same key. Without inheritance that record starts clean and acks from
|
|
522
|
-
// scratch — which is `On it.` arriving immediately after a failure notice,
|
|
523
|
-
// as sampled live.
|
|
524
|
-
const t = fakeTransport();
|
|
525
|
-
const r = await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX, tier: "work" });
|
|
526
|
-
await sweepObligations({
|
|
527
|
-
now: Date.now() + ACK_AFTER_MS + 1000,
|
|
528
|
-
deps: { deliverImpl: t.impl, spokeSinceImpl: () => false, generateAckImpl: async () => FAKE_ACK_LINE },
|
|
529
|
-
});
|
|
530
|
-
assert.equal(t.sent.filter((m) => m.kind === "ack").length, 1);
|
|
531
|
-
assert.equal(readObligation(r.key).interimSaid, true);
|
|
532
|
-
|
|
533
|
-
assurance.closeObligation(r.key, { outcome: "failed" });
|
|
534
|
-
const again = await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX, tier: "work" });
|
|
535
|
-
assert.equal(readObligation(again.key).interimSaid, true, "the latch is INHERITED across the reopen");
|
|
536
|
-
await sweepObligations({
|
|
537
|
-
now: Date.now() + ACK_AFTER_MS + 10 * 60_000,
|
|
538
|
-
deps: { deliverImpl: t.impl, spokeSinceImpl: () => false, generateAckImpl: async () => FAKE_ACK_LINE },
|
|
539
|
-
});
|
|
540
|
-
assert.equal(t.sent.filter((m) => m.kind === "ack").length, 1, "one interim per ask, across every attempt at it");
|
|
541
|
-
});
|
|
542
|
-
|
|
543
|
-
test("opens NO board row of its own — there is exactly one writer per ask", async () => {
|
|
544
|
-
// It used to mirror the obligation onto the board here, through
|
|
545
|
-
// `board.create`, while agent-daemon tracked the SAME item forty lines
|
|
546
|
-
// later through the shared ledger. One DM, two cards on one board, and the
|
|
547
|
-
// one the "On now" banner rendered was the copy with no requester, no
|
|
548
|
-
// comments and no provenance. The open belongs to the caller, which holds
|
|
549
|
-
// the full inbox item; this function only owes the acknowledgement.
|
|
550
|
-
const mirror = { calls: [], closeBoardItem: async (a) => { mirror.calls.push(a); return { mirrored: true }; } };
|
|
551
|
-
assurance._setBoardMirror(mirror);
|
|
552
|
-
try {
|
|
553
|
-
const t = fakeTransport();
|
|
554
|
-
await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX, deps: { ackSender: t.ackSender } });
|
|
555
|
-
await new Promise((r) => setTimeout(r, 5));
|
|
556
|
-
assert.equal(mirror.calls.length, 0);
|
|
557
|
-
assert.equal(typeof mirror.openBoardItem, "undefined", "there is no open hook to call");
|
|
558
|
-
} finally {
|
|
559
|
-
assurance._setBoardMirror(null);
|
|
560
|
-
}
|
|
561
|
-
});
|
|
562
|
-
|
|
563
|
-
test("closing the debt retires the board row, carrying the obligation's own verdict", async () => {
|
|
564
|
-
// `closeObligation` is the funnel EVERY terminal path goes through, and only
|
|
565
|
-
// two of them reach the daemon's onClose. A row nothing retires sits in
|
|
566
|
-
// `running`, which is the one column the "On now" banner reads.
|
|
567
|
-
const mirror = { calls: [], closeBoardItem: async (a) => { mirror.calls.push(a); return { mirrored: true }; } };
|
|
568
|
-
assurance._setBoardMirror(mirror);
|
|
569
|
-
try {
|
|
570
|
-
const t = fakeTransport();
|
|
571
|
-
const r = await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX, deps: { ackSender: t.ackSender } });
|
|
572
|
-
assurance.closeObligation(r.key, { outcome: "failed", note: "stale-no-outcome" });
|
|
573
|
-
await new Promise((res) => setTimeout(res, 5));
|
|
574
|
-
assert.equal(mirror.calls.length, 1);
|
|
575
|
-
assert.equal(mirror.calls[0].outcome, "failed");
|
|
576
|
-
assert.equal(mirror.calls[0].rec.key, r.key);
|
|
577
|
-
assert.ok(mirror.calls[0].rec.item, "the close needs the item to identify the ask");
|
|
578
|
-
} finally {
|
|
579
|
-
assurance._setBoardMirror(null);
|
|
580
|
-
}
|
|
581
|
-
});
|
|
582
|
-
});
|
|
583
|
-
|
|
584
|
-
// ---------------------------------------------------------------------------
|
|
585
|
-
// 5. SESSION SETTLEMENT — the four terminal states
|
|
586
|
-
// ---------------------------------------------------------------------------
|
|
587
|
-
|
|
588
|
-
describe("settleSession", () => {
|
|
589
|
-
async function openOne(t) {
|
|
590
|
-
const r = await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX, deps: { ackSender: t.ackSender } });
|
|
591
|
-
return r.key;
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
test("exit 0 AND the session spoke → discharge quietly, add nothing", async () => {
|
|
595
|
-
const t = fakeTransport();
|
|
596
|
-
const key = await openOne(t);
|
|
597
|
-
const before = t.sent.length;
|
|
598
|
-
const r = await settleSession({ key, ok: true, code: 0, deps: { deliverImpl: t.impl, spokeSinceImpl: () => true } });
|
|
599
|
-
assert.equal(r.verdict, "answered-by-session");
|
|
600
|
-
assert.equal(t.sent.length, before, "the human already has their answer — saying more is spam");
|
|
601
|
-
assert.equal(readObligation(key).state, "answered");
|
|
602
|
-
});
|
|
603
|
-
|
|
604
|
-
test("REGRESSION: exit 0 and the session said NOTHING → the daemon speaks", async () => {
|
|
605
|
-
// Observed live: session s-1786600074079-52, exit 0, 241s, $2.36, its own
|
|
606
|
-
// result text ending "Nothing was sent." Marked processed forever, emitted
|
|
607
|
-
// as `sent`, never re-delivered. This is that case.
|
|
608
|
-
const t = fakeTransport();
|
|
609
|
-
const key = await openOne(t);
|
|
610
|
-
const stdout = JSON.stringify({ type: "result", result: "I reviewed the three failing checks and pushed fixes for two. The third needs a decision from you. Nothing was sent." });
|
|
611
|
-
const r = await settleSession({ key, ok: true, code: 0, stdout, deps: { deliverImpl: t.impl, spokeSinceImpl: () => false } });
|
|
612
|
-
assert.equal(r.verdict, "silent-success");
|
|
613
|
-
assert.equal(r.spoke, true);
|
|
614
|
-
const msg = t.sent.at(-1).text;
|
|
615
|
-
assert.match(msg, /didn't get a reply out to you/i);
|
|
616
|
-
assert.match(msg, /pushed fixes for two/, "the work the session actually did must reach the human");
|
|
617
|
-
assert.equal(readObligation(key).state, "answered");
|
|
618
|
-
});
|
|
619
|
-
|
|
620
|
-
test("an acknowledgement does NOT count as the session having answered", async () => {
|
|
621
|
-
const t = fakeTransport();
|
|
622
|
-
const key = await openOne(t);
|
|
623
|
-
// A truthful receipt store that only ever saw the ack.
|
|
624
|
-
const spokeSinceImpl = ({ excludeKinds }) => !(excludeKinds || []).includes("ack") ;
|
|
625
|
-
const r = await settleSession({ key, ok: true, code: 0, stdout: "", deps: { deliverImpl: t.impl, spokeSinceImpl } });
|
|
626
|
-
assert.equal(r.verdict, "silent-success", "'I'm on it' is not an answer");
|
|
627
|
-
});
|
|
628
|
-
|
|
629
|
-
test("transient failure with retries left → tell them, keep the debt, retry", async () => {
|
|
630
|
-
const t = fakeTransport();
|
|
631
|
-
const key = await openOne(t);
|
|
632
|
-
const r = await settleSession({ key, ok: false, code: 143, error: "SIGTERM", deps: { deliverImpl: t.impl, spokeSinceImpl: () => false } });
|
|
633
|
-
assert.equal(r.willRetry, true);
|
|
634
|
-
assert.match(r.verdict, /^retrying:timeout$/);
|
|
635
|
-
const msg = t.sent.at(-1).text;
|
|
636
|
-
assert.match(msg, /ran past its time limit/i);
|
|
637
|
-
assert.match(msg, /retrying now/i);
|
|
638
|
-
assert.equal(readObligation(key).state, "open", "still owed — the retry has to be covered too");
|
|
639
|
-
});
|
|
640
|
-
|
|
641
|
-
test("permanent failure → tell them, stop, escalate durably", async () => {
|
|
642
|
-
const t = fakeTransport();
|
|
643
|
-
const key = await openOne(t);
|
|
644
|
-
const r = await settleSession({ key, ok: false, code: 1, error: "claude CLI spawn error: ENOENT", deps: { deliverImpl: t.impl, spokeSinceImpl: () => false } });
|
|
645
|
-
assert.equal(r.willRetry, false);
|
|
646
|
-
assert.match(r.verdict, /^failed:spawn_failed$/);
|
|
647
|
-
const msg = t.sent.at(-1).text;
|
|
648
|
-
assert.match(msg, /couldn't finish this/i);
|
|
649
|
-
assert.match(msg, /stopped retrying/i);
|
|
650
|
-
assert.match(msg, /\?$/, "a dead end must end in a question, not a shrug");
|
|
651
|
-
assert.equal(readObligation(key).state, "failed");
|
|
652
|
-
});
|
|
653
|
-
|
|
654
|
-
test("retries are BOUNDED — the second timeout does not promise a third attempt", async () => {
|
|
655
|
-
const t = fakeTransport();
|
|
656
|
-
const key = await openOne(t);
|
|
657
|
-
// Simulate having already burned the allowed attempts.
|
|
658
|
-
const rec = readObligation(key);
|
|
659
|
-
rec.attempts = RETRY_MAX + 1;
|
|
660
|
-
const { writeFileSync } = await import("node:fs");
|
|
661
|
-
writeFileSync(join(ROOT, "state", "obligations", `${assurance.sanitiseKey(key)}.json`), JSON.stringify(rec));
|
|
662
|
-
const r = await settleSession({ key, ok: false, code: 143, error: "SIGTERM", deps: { deliverImpl: t.impl, spokeSinceImpl: () => false } });
|
|
663
|
-
assert.equal(r.willRetry, false, "an unbounded silent retry loop is itself the defect");
|
|
664
|
-
assert.match(t.sent.at(-1).text, /stopped retrying/i);
|
|
665
|
-
});
|
|
666
|
-
});
|
|
667
|
-
|
|
668
|
-
// ---------------------------------------------------------------------------
|
|
669
|
-
// 6. THE SWEEP — the safety net under everything above
|
|
670
|
-
// ---------------------------------------------------------------------------
|
|
671
|
-
|
|
672
|
-
describe("sweepObligations", () => {
|
|
673
|
-
test("THE ONE INTERIM: nothing before ACK_AFTER_MS, then exactly one bespoke line", async () => {
|
|
674
|
-
const t = fakeTransport();
|
|
675
|
-
const r = await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX, tier: "work" });
|
|
676
|
-
const deps = { deliverImpl: t.impl, spokeSinceImpl: () => false, generateAckImpl: async () => "Back on the git fixes now." };
|
|
677
|
-
|
|
678
|
-
const early = await sweepObligations({ now: Date.now() + ACK_AFTER_MS - 1000, deps });
|
|
679
|
-
assert.equal(early.acked, 0);
|
|
680
|
-
assert.equal(t.sent.length, 0, "below ninety seconds the typing indicator IS the acknowledgement");
|
|
681
|
-
|
|
682
|
-
const stats = await sweepObligations({ now: Date.now() + ACK_AFTER_MS + 1000, deps });
|
|
683
|
-
assert.equal(stats.acked, 1);
|
|
684
|
-
assert.equal(t.sent[0].kind, "ack");
|
|
685
|
-
assert.equal(t.sent[0].text, "Back on the git fixes now.");
|
|
686
|
-
assert.doesNotMatch(t.sent[0].text, /^(on it|looking now)/i, "never the retired rotation");
|
|
687
|
-
assert.equal(readObligation(r.key).interimSaid, true);
|
|
688
|
-
|
|
689
|
-
// …and never a second one, however long the work runs.
|
|
690
|
-
for (let m = 2; m < 40; m++) {
|
|
691
|
-
const now = Date.now() + ACK_AFTER_MS + m * 60_000;
|
|
692
|
-
if (now - Date.now() >= STALE_AFTER_MS) break;
|
|
693
|
-
await sweepObligations({ now, deps });
|
|
694
|
-
}
|
|
695
|
-
assert.equal(t.sent.filter((m) => m.kind === "ack").length, 1, "one interim per obligation, full stop");
|
|
696
|
-
});
|
|
697
|
-
|
|
698
|
-
test("ONE SEAT, eight concurrent obligations in ONE room, exactly ONE interim", async () => {
|
|
699
|
-
// WHAT THIS IS AND IS NOT. It is the per-seat guarantee: one daemon holding
|
|
700
|
-
// eight concurrent debts in one channel emits one holding line, not eight.
|
|
701
|
-
//
|
|
702
|
-
// It is NOT `capital-formation` reproduced, and the title used to say it
|
|
703
|
-
// was. That room's 9,187 messages in 21 days came from EIGHT SEPARATE
|
|
704
|
-
// SEATS, each with its own AGENT_DIR on its own machine and therefore its
|
|
705
|
-
// own ledger — see room-budget.test.mjs, "the budget is per (SEAT, service,
|
|
706
|
-
// channel)". This gate's ceiling in that room is 8 x (60/15) = 32/h against
|
|
707
|
-
// a measured content-free rate of ~14/h, so it would not have been the
|
|
708
|
-
// binding constraint there. What removed the measured traffic is the
|
|
709
|
-
// deleted progress branch (961 -> 0), the sanitiser, and TIME+ONCE; this
|
|
710
|
-
// gate is the backstop that bounds what survives all three.
|
|
711
|
-
const t = fakeTransport();
|
|
712
|
-
for (let i = 0; i < 8; i++) {
|
|
713
|
-
await openAndAcknowledge({
|
|
714
|
-
item: { ...ITEM, id: `msg-${i}`, raw_ref: `cohort:capform:${i}`, message_id: `${i}` },
|
|
715
|
-
classResult: CLASS_COMPLEX,
|
|
716
|
-
tier: "work",
|
|
717
|
-
});
|
|
718
|
-
}
|
|
719
|
-
assert.equal(openObligations().length, 8, "every ask is still opened and tracked");
|
|
720
|
-
|
|
721
|
-
const stats = await sweepObligations({
|
|
722
|
-
now: Date.now() + ACK_AFTER_MS + 1000,
|
|
723
|
-
deps: { deliverImpl: t.impl, spokeSinceImpl: () => false, generateAckImpl: async () => FAKE_ACK_LINE },
|
|
724
|
-
});
|
|
725
|
-
assert.equal(t.sent.filter((m) => m.kind === "ack").length, 1, `eight obligations, one message — got ${t.sent.length}`);
|
|
726
|
-
assert.equal(stats.acked, 1);
|
|
727
|
-
assert.equal(stats.suppressed, 7, "…and the seven suppressions are COUNTED, so a quiet sweep can still prove it decided");
|
|
728
|
-
|
|
729
|
-
// Over budget is not a drop: every debt is still open and still owed.
|
|
730
|
-
assert.equal(openObligations().length, 8);
|
|
731
|
-
});
|
|
732
|
-
|
|
733
|
-
test("…and the suppressed debts stay silent afterwards rather than queueing up a burst", async () => {
|
|
734
|
-
const t = fakeTransport();
|
|
735
|
-
for (let i = 0; i < 8; i++) {
|
|
736
|
-
await openAndAcknowledge({ item: { ...ITEM, id: `m-${i}`, raw_ref: `cohort:capform2:${i}` }, classResult: CLASS_COMPLEX, tier: "work" });
|
|
737
|
-
}
|
|
738
|
-
const deps = { deliverImpl: t.impl, spokeSinceImpl: () => false, generateAckImpl: async () => FAKE_ACK_LINE };
|
|
739
|
-
await sweepObligations({ now: Date.now() + ACK_AFTER_MS + 1000, deps });
|
|
740
|
-
await sweepObligations({ now: Date.now() + 20 * 60_000, deps });
|
|
741
|
-
await sweepObligations({ now: Date.now() + 40 * 60_000, deps });
|
|
742
|
-
assert.equal(t.sent.filter((m) => m.kind === "ack").length, 1, "the window reopening must not release a backlog");
|
|
743
|
-
});
|
|
744
|
-
|
|
745
|
-
test("a DIFFERENT room is never gagged by a busy one", async () => {
|
|
746
|
-
const t = fakeTransport();
|
|
747
|
-
for (let i = 0; i < 5; i++) {
|
|
748
|
-
await openAndAcknowledge({ item: { ...ITEM, id: `b-${i}`, raw_ref: `cohort:busy:${i}` }, classResult: CLASS_COMPLEX, tier: "work" });
|
|
749
|
-
}
|
|
750
|
-
await openAndAcknowledge({
|
|
751
|
-
item: { ...ITEM, id: "quiet-1", raw_ref: "cohort:quiet:1", channel_id: "C-QUIET" },
|
|
752
|
-
classResult: CLASS_COMPLEX,
|
|
753
|
-
tier: "work",
|
|
754
|
-
});
|
|
755
|
-
const stats = await sweepObligations({
|
|
756
|
-
now: Date.now() + ACK_AFTER_MS + 1000,
|
|
757
|
-
deps: { deliverImpl: t.impl, spokeSinceImpl: () => false, generateAckImpl: async () => FAKE_ACK_LINE },
|
|
758
|
-
});
|
|
759
|
-
assert.equal(stats.acked, 2, "one per room");
|
|
760
|
-
assert.equal(new Set(t.sent.map((m) => m.channel)).size, 2);
|
|
761
|
-
});
|
|
762
|
-
|
|
763
|
-
test("REGRESSION: a failed generation sends NOTHING — bounded, then given up on", async () => {
|
|
764
|
-
const t = fakeTransport();
|
|
765
|
-
const r = await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX, tier: "work" });
|
|
766
|
-
|
|
767
|
-
let genCalls = 0;
|
|
768
|
-
const t0 = Date.now();
|
|
769
|
-
for (let m = 1; m <= 8; m++) {
|
|
770
|
-
await sweepObligations({ now: t0 + ACK_AFTER_MS + m * 60_000, deps: {
|
|
771
|
-
deliverImpl: t.impl,
|
|
772
|
-
spokeSinceImpl: () => false,
|
|
773
|
-
generateAckImpl: async () => { genCalls++; return null; },
|
|
774
|
-
} });
|
|
775
|
-
}
|
|
776
|
-
assert.equal(t.sent.filter((s) => s.kind === "ack").length, 0, "no canned line went out in place of the failed generation");
|
|
777
|
-
assert.ok(genCalls <= ACK_GEN_MAX_FAILURES, `generation retries are bounded, not once-a-tick forever (saw ${genCalls})`);
|
|
778
|
-
const rec = readObligation(r.key);
|
|
779
|
-
assert.equal(rec.ackSuppressed, true, "the interim is given up on…");
|
|
780
|
-
assert.equal(rec.state, "open", "…but the DEBT is not — the stale check still owes the human");
|
|
781
|
-
});
|
|
782
|
-
|
|
783
|
-
test("a failed generation does NOT spend the room's budget", async () => {
|
|
784
|
-
// Ask, compose, THEN spend. A budget burned on a message that was never
|
|
785
|
-
// composed gags the room for fifteen minutes in exchange for nothing.
|
|
786
|
-
const t = fakeTransport();
|
|
787
|
-
await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX, tier: "work" });
|
|
788
|
-
await openAndAcknowledge({ item: { ...ITEM, id: "x2", raw_ref: "cohort:same-room:2" }, classResult: CLASS_COMPLEX, tier: "work" });
|
|
789
|
-
let calls = 0;
|
|
790
|
-
const stats = await sweepObligations({
|
|
791
|
-
now: Date.now() + ACK_AFTER_MS + 1000,
|
|
792
|
-
deps: {
|
|
793
|
-
deliverImpl: t.impl,
|
|
794
|
-
spokeSinceImpl: () => false,
|
|
795
|
-
// The first obligation's generation fails; the second's succeeds.
|
|
796
|
-
generateAckImpl: async () => (++calls === 1 ? null : "Back on the git fixes now."),
|
|
797
|
-
},
|
|
798
|
-
});
|
|
799
|
-
assert.equal(stats.acked, 1, "the room's one interim went to the ask that could actually compose one");
|
|
800
|
-
assert.equal(t.sent.filter((m) => m.kind === "ack").length, 1);
|
|
801
|
-
});
|
|
802
|
-
|
|
803
|
-
test("work that outruns every session timeout is declared dead, not left hanging", async () => {
|
|
804
|
-
const t = fakeTransport();
|
|
805
|
-
const r = await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX, deps: { ackSender: t.ackSender } });
|
|
806
|
-
const now = Date.now() + STALE_AFTER_MS + 1000;
|
|
807
|
-
const stats = await sweepObligations({ now, deps: { deliverImpl: t.impl, spokeSinceImpl: () => false } });
|
|
808
|
-
assert.equal(stats.staled, 1);
|
|
809
|
-
assert.match(t.sent.at(-1).text, /outrun its time limit/i);
|
|
810
|
-
assert.equal(readObligation(r.key).state, "failed");
|
|
811
|
-
});
|
|
812
|
-
|
|
813
|
-
test("a debt the session has since answered is discharged silently", async () => {
|
|
814
|
-
const t = fakeTransport();
|
|
815
|
-
const r = await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX, deps: { ackSender: t.ackSender } });
|
|
816
|
-
const before = t.sent.length;
|
|
817
|
-
const now = Date.now() + STALE_AFTER_MS + 1000;
|
|
818
|
-
const stats = await sweepObligations({ now, deps: { deliverImpl: t.impl, spokeSinceImpl: () => true } });
|
|
819
|
-
assert.equal(stats.closed, 1);
|
|
820
|
-
assert.equal(t.sent.length, before, "no message when the human already has their answer");
|
|
821
|
-
assert.equal(readObligation(r.key).state, "answered");
|
|
822
|
-
});
|
|
823
|
-
|
|
824
|
-
test("a debt orphaned by a DEAD daemon is spoken to", async () => {
|
|
825
|
-
const t = fakeTransport();
|
|
826
|
-
const r = await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX, deps: { ackSender: t.ackSender } });
|
|
827
|
-
// Rewrite the record as if a now-dead process had opened it.
|
|
828
|
-
const rec = readObligation(r.key);
|
|
829
|
-
rec.daemonPid = process.pid + 99999;
|
|
830
|
-
const { writeFileSync } = await import("node:fs");
|
|
831
|
-
writeFileSync(join(ROOT, "state", "obligations", `${assurance.sanitiseKey(r.key)}.json`), JSON.stringify(rec));
|
|
832
|
-
|
|
833
|
-
const stats = await sweepObligations({
|
|
834
|
-
now: Date.now() + INTERRUPT_GRACE_MS + 1000,
|
|
835
|
-
deps: { deliverImpl: t.impl, spokeSinceImpl: () => false, isPidAliveImpl: () => false, generateAckImpl: async () => FAKE_ACK_LINE },
|
|
836
|
-
});
|
|
837
|
-
assert.equal(stats.interrupted, 1);
|
|
838
|
-
assert.match(t.sent.at(-1).text, /was interrupted before it finished/i);
|
|
839
|
-
assert.equal(t.sent.at(-1).kind, "notice", "an interrupted notice is an OUTCOME, not a progress ping — the kind it used to borrow no longer exists");
|
|
840
|
-
});
|
|
841
|
-
|
|
842
|
-
test("…and a SECOND interruption of the same ask says nothing more", async () => {
|
|
843
|
-
// Said once per (channel, obligationKey). Two daemon restarts over one long
|
|
844
|
-
// ask are one apology, not two — and a retry that reopens the same key
|
|
845
|
-
// inherits the notice ledger, so it is not two either.
|
|
846
|
-
const t = fakeTransport();
|
|
847
|
-
const r = await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX, tier: "work" });
|
|
848
|
-
const { writeFileSync } = await import("node:fs");
|
|
849
|
-
const orphan = (extra = {}) => {
|
|
850
|
-
const rec = { ...readObligation(r.key), daemonPid: process.pid + 99999, ...extra };
|
|
851
|
-
writeFileSync(join(ROOT, "state", "obligations", `${assurance.sanitiseKey(r.key)}.json`), JSON.stringify(rec));
|
|
852
|
-
};
|
|
853
|
-
orphan();
|
|
854
|
-
const deps = { deliverImpl: t.impl, spokeSinceImpl: () => false, isPidAliveImpl: () => false, generateAckImpl: async () => FAKE_ACK_LINE };
|
|
855
|
-
await sweepObligations({ now: Date.now() + INTERRUPT_GRACE_MS + 1000, deps });
|
|
856
|
-
assert.equal(t.sent.filter((m) => /interrupted/i.test(m.text)).length, 1);
|
|
857
|
-
|
|
858
|
-
orphan({ interruptedNotifiedAt: null });
|
|
859
|
-
const second = await sweepObligations({ now: Date.now() + INTERRUPT_GRACE_MS + 120_000, deps });
|
|
860
|
-
assert.equal(second.interrupted, 0);
|
|
861
|
-
assert.equal(t.sent.filter((m) => /interrupted/i.test(m.text)).length, 1, "one apology per ask, per room");
|
|
862
|
-
});
|
|
863
|
-
|
|
864
|
-
test("REGRESSION: a LIVE daemon's obligation is never declared interrupted", async () => {
|
|
865
|
-
// Two daemons on one AGENT_DIR is what an operator creates the moment they
|
|
866
|
-
// run one by hand next to the launchd one. Each sees the other's healthy,
|
|
867
|
-
// actively-running debts as foreign. With only a pid-mismatch test — which
|
|
868
|
-
// is what shipped — both then tell those requesters "my session was
|
|
869
|
-
// interrupted" while the work runs to completion behind the apology.
|
|
870
|
-
const t = fakeTransport();
|
|
871
|
-
const r = await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX, deps: { ackSender: t.ackSender } });
|
|
872
|
-
const rec = readObligation(r.key);
|
|
873
|
-
rec.daemonPid = process.pid + 99999;
|
|
874
|
-
const { writeFileSync } = await import("node:fs");
|
|
875
|
-
writeFileSync(join(ROOT, "state", "obligations", `${assurance.sanitiseKey(r.key)}.json`), JSON.stringify(rec));
|
|
876
|
-
|
|
877
|
-
const before = t.sent.length;
|
|
878
|
-
const stats = await sweepObligations({
|
|
879
|
-
now: Date.now() + INTERRUPT_GRACE_MS + 1000,
|
|
880
|
-
deps: { deliverImpl: t.impl, spokeSinceImpl: () => false, isPidAliveImpl: () => true, generateAckImpl: async () => FAKE_ACK_LINE },
|
|
881
|
-
});
|
|
882
|
-
assert.equal(stats.interrupted, 0, "the owning daemon is alive — its work is not interrupted");
|
|
883
|
-
assert.equal(t.sent.length, before, "and nobody is told a lie about it");
|
|
884
|
-
});
|
|
885
|
-
|
|
886
|
-
test("REGRESSION: a FRESH obligation is never declared interrupted, dead pid or not", async () => {
|
|
887
|
-
// The age guard is the backstop for a recycled pid. One second after the ask
|
|
888
|
-
// arrived, no reading of the evidence supports "your session was interrupted".
|
|
889
|
-
const t = fakeTransport();
|
|
890
|
-
const r = await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX, deps: { ackSender: t.ackSender } });
|
|
891
|
-
const rec = readObligation(r.key);
|
|
892
|
-
rec.daemonPid = process.pid + 99999;
|
|
893
|
-
const { writeFileSync } = await import("node:fs");
|
|
894
|
-
writeFileSync(join(ROOT, "state", "obligations", `${assurance.sanitiseKey(r.key)}.json`), JSON.stringify(rec));
|
|
895
|
-
|
|
896
|
-
const stats = await sweepObligations({
|
|
897
|
-
now: Date.now() + 1000,
|
|
898
|
-
deps: { deliverImpl: t.impl, spokeSinceImpl: () => false, isPidAliveImpl: () => false, generateAckImpl: async () => FAKE_ACK_LINE },
|
|
899
|
-
});
|
|
900
|
-
assert.equal(stats.interrupted, 0);
|
|
901
|
-
});
|
|
902
|
-
|
|
903
|
-
test("REGRESSION: a debt OWNED by another LIVE daemon is not ack-retried by this one (two-daemon narration)", async () => {
|
|
904
|
-
// Two live daemons on one AGENT_DIR both sweep the same ledger. The one
|
|
905
|
-
// that did NOT open this debt must not compensate its acknowledgement —
|
|
906
|
-
// the owner is alive and will; two daemons acking is one human hearing
|
|
907
|
-
// double.
|
|
908
|
-
const failing = fakeTransport({ failTimes: 99 });
|
|
909
|
-
const r = await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX, tier: "work", deps: { ackSender: failing.ackSender } });
|
|
910
|
-
assert.equal(r.acked, false);
|
|
911
|
-
const rec = readObligation(r.key);
|
|
912
|
-
rec.daemonPid = process.pid + 99999;
|
|
913
|
-
const { writeFileSync } = await import("node:fs");
|
|
914
|
-
writeFileSync(join(ROOT, "state", "obligations", `${assurance.sanitiseKey(r.key)}.json`), JSON.stringify(rec));
|
|
915
|
-
|
|
916
|
-
const working = fakeTransport();
|
|
917
|
-
const stats = await sweepObligations({
|
|
918
|
-
now: Date.now() + ACK_AFTER_MS + 1000,
|
|
919
|
-
deps: {
|
|
920
|
-
deliverImpl: working.impl,
|
|
921
|
-
spokeSinceImpl: () => false,
|
|
922
|
-
generateAckImpl: async () => "Back on this now.",
|
|
923
|
-
isPidAliveImpl: () => true, // the owner is provably alive
|
|
924
|
-
},
|
|
925
|
-
});
|
|
926
|
-
assert.equal(stats.acked, 0, "the live owner narrates its own debt");
|
|
927
|
-
assert.equal(working.sent.length, 0, "not one byte from the non-owner");
|
|
928
|
-
assert.equal(readObligation(r.key).acknowledged, false);
|
|
929
|
-
assert.equal(readObligation(r.key).interimSaid, false, "and the non-owner does not spend the latch either");
|
|
930
|
-
});
|
|
931
|
-
|
|
932
|
-
test("…but once the owning daemon is DEAD, this daemon takes the interim over", async () => {
|
|
933
|
-
const failing = fakeTransport({ failTimes: 99 });
|
|
934
|
-
const r = await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX, tier: "work", deps: { ackSender: failing.ackSender } });
|
|
935
|
-
const rec = readObligation(r.key);
|
|
936
|
-
rec.daemonPid = process.pid + 99999;
|
|
937
|
-
const { writeFileSync } = await import("node:fs");
|
|
938
|
-
writeFileSync(join(ROOT, "state", "obligations", `${assurance.sanitiseKey(r.key)}.json`), JSON.stringify(rec));
|
|
939
|
-
|
|
940
|
-
const working = fakeTransport();
|
|
941
|
-
const stats = await sweepObligations({
|
|
942
|
-
now: Date.now() + ACK_AFTER_MS + 1000,
|
|
943
|
-
deps: {
|
|
944
|
-
deliverImpl: working.impl,
|
|
945
|
-
spokeSinceImpl: () => false,
|
|
946
|
-
generateAckImpl: async () => "Picking your ask up now.",
|
|
947
|
-
isPidAliveImpl: () => false, // the owner is gone
|
|
948
|
-
},
|
|
949
|
-
});
|
|
950
|
-
assert.equal(stats.acked, 1, "an orphaned debt is this daemon's to speak to");
|
|
951
|
-
assert.equal(readObligation(r.key).acknowledged, true);
|
|
952
|
-
});
|
|
953
|
-
|
|
954
|
-
test("REGRESSION: a debt OWNED by another LIVE daemon gets no interim from this one", async () => {
|
|
955
|
-
const t = fakeTransport();
|
|
956
|
-
const r = await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX, tier: "work" });
|
|
957
|
-
const rec = readObligation(r.key);
|
|
958
|
-
rec.daemonPid = process.pid + 99999;
|
|
959
|
-
const { writeFileSync } = await import("node:fs");
|
|
960
|
-
writeFileSync(join(ROOT, "state", "obligations", `${assurance.sanitiseKey(r.key)}.json`), JSON.stringify(rec));
|
|
961
|
-
|
|
962
|
-
const stats = await sweepObligations({
|
|
963
|
-
now: Date.now() + ACK_AFTER_MS + 1000,
|
|
964
|
-
deps: { deliverImpl: t.impl, spokeSinceImpl: () => false, isPidAliveImpl: () => true, generateAckImpl: async () => FAKE_ACK_LINE },
|
|
965
|
-
});
|
|
966
|
-
assert.equal(stats.acked, 0, "the live owner speaks for its own debt");
|
|
967
|
-
assert.equal(stats.interrupted, 0, "and a live owner is certainly not 'interrupted'");
|
|
968
|
-
assert.equal(t.sent.length, 0);
|
|
969
|
-
});
|
|
970
|
-
|
|
971
|
-
test("…and a dead owner's debt DOES get this daemon's interim (after the interrupted notice is done)", async () => {
|
|
972
|
-
const t = fakeTransport();
|
|
973
|
-
const r = await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX, tier: "work" });
|
|
974
|
-
const rec = readObligation(r.key);
|
|
975
|
-
rec.daemonPid = process.pid + 99999;
|
|
976
|
-
rec.interruptedNotifiedAt = Date.now(); // the orphan apology already went out
|
|
977
|
-
const { writeFileSync } = await import("node:fs");
|
|
978
|
-
writeFileSync(join(ROOT, "state", "obligations", `${assurance.sanitiseKey(r.key)}.json`), JSON.stringify(rec));
|
|
979
|
-
|
|
980
|
-
const stats = await sweepObligations({
|
|
981
|
-
now: Date.now() + ACK_AFTER_MS + 1000,
|
|
982
|
-
deps: { deliverImpl: t.impl, spokeSinceImpl: () => false, isPidAliveImpl: () => false, generateAckImpl: async () => "Back on the git fixes now." },
|
|
983
|
-
});
|
|
984
|
-
assert.equal(stats.acked, 1);
|
|
985
|
-
assert.equal(t.sent.at(-1).text, "Back on the git fixes now.");
|
|
986
|
-
});
|
|
987
|
-
|
|
988
|
-
test("each distinct message carries a distinct idempotency suffix", async () => {
|
|
989
|
-
// hq de-dups server-side on the client message id, so two messages that key
|
|
990
|
-
// on the same suffix are ONE message as far as hq is concerned and the
|
|
991
|
-
// second is silently swallowed — "never silent" degrading into "spoke once,
|
|
992
|
-
// then silent forever". Invisible to any test that stubs transport without
|
|
993
|
-
// inspecting the options, hence this one.
|
|
994
|
-
const seen = [];
|
|
995
|
-
const impl = async (item, text, o = {}) => { seen.push(o.idempotencySuffix); return { sent: true, via: "fake", channel: "c" }; };
|
|
996
|
-
const r = await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX, tier: "work" });
|
|
997
|
-
|
|
998
|
-
const deps = { deliverImpl: impl, spokeSinceImpl: () => false, generateAckImpl: async () => FAKE_ACK_LINE };
|
|
999
|
-
await sweepObligations({ now: Date.now() + ACK_AFTER_MS + 1000, deps }); // the interim
|
|
1000
|
-
await settleSession({ key: r.key, ok: false, code: 143, error: "SIGTERM", deps }); // the failure notice
|
|
1001
|
-
await sweepObligations({ now: Date.now() + STALE_AFTER_MS + 1000, deps }); // the stale notice
|
|
1002
|
-
|
|
1003
|
-
assert.ok(seen.length >= 2, `expected several distinct sends, saw ${seen.length}`);
|
|
1004
|
-
assert.equal(new Set(seen).size, seen.length, `suffixes must be unique per message, got ${JSON.stringify(seen)}`);
|
|
1005
|
-
});
|
|
1006
|
-
|
|
1007
|
-
test("emits at most ONE message per obligation per tick", async () => {
|
|
1008
|
-
const t = fakeTransport();
|
|
1009
|
-
const r = await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX, tier: "work" });
|
|
1010
|
-
// Old enough to qualify for BOTH the interrupted notice and the interim.
|
|
1011
|
-
const rec = readObligation(r.key);
|
|
1012
|
-
rec.daemonPid = process.pid + 99999;
|
|
1013
|
-
const { writeFileSync } = await import("node:fs");
|
|
1014
|
-
writeFileSync(join(ROOT, "state", "obligations", `${assurance.sanitiseKey(r.key)}.json`), JSON.stringify(rec));
|
|
1015
|
-
const now = Date.now() + INTERRUPT_GRACE_MS + 1000;
|
|
1016
|
-
await sweepObligations({ now, deps: { deliverImpl: t.impl, spokeSinceImpl: () => false, isPidAliveImpl: () => false, generateAckImpl: async () => FAKE_ACK_LINE } });
|
|
1017
|
-
assert.equal(t.sent.length, 1, "two messages in one tick reads as a malfunction");
|
|
1018
|
-
});
|
|
1019
|
-
});
|
|
1020
|
-
|
|
1021
|
-
// ---------------------------------------------------------------------------
|
|
1022
|
-
// 7. RECEIPTS — the evidence the whole guarantee rests on
|
|
1023
|
-
// ---------------------------------------------------------------------------
|
|
1024
|
-
|
|
1025
|
-
describe("delivery receipts", () => {
|
|
1026
|
-
test("a recorded send is visible to a later spokeSince query", () => {
|
|
1027
|
-
const now = Date.now();
|
|
1028
|
-
receipts.recordOutbound({ service: "cohort", channel: "C-RECEIPT-1", kind: "reply", chars: 12, agentRoot: ROOT, now });
|
|
1029
|
-
assert.equal(receipts.spokeSince({ channel: "C-RECEIPT-1", sinceMs: now - 1000, agentRoot: ROOT }), true);
|
|
1030
|
-
});
|
|
1031
|
-
|
|
1032
|
-
test("acks and progress pings are excludable — they are not answers", () => {
|
|
1033
|
-
const now = Date.now();
|
|
1034
|
-
receipts.recordOutbound({ service: "cohort", channel: "C-RECEIPT-2", kind: "ack", agentRoot: ROOT, now });
|
|
1035
|
-
receipts.recordOutbound({ service: "cohort", channel: "C-RECEIPT-2", kind: "progress", agentRoot: ROOT, now });
|
|
1036
|
-
assert.equal(
|
|
1037
|
-
receipts.spokeSince({ channel: "C-RECEIPT-2", sinceMs: now - 1000, excludeKinds: ["ack", "progress", "failure"], agentRoot: ROOT }),
|
|
1038
|
-
false,
|
|
1039
|
-
);
|
|
1040
|
-
});
|
|
1041
|
-
|
|
1042
|
-
test("REGRESSION: an 'interrupted' notice is not an answer either", async () => {
|
|
1043
|
-
// The interrupted apology moved from kind "progress" to kind "notice" when
|
|
1044
|
-
// progress pings were deleted. A kind that is not in the non-answer set
|
|
1045
|
-
// reads to `spokeFor` as "the human was spoken to", which would discharge
|
|
1046
|
-
// an unanswered debt the moment a daemon restarted — the exact class of bug
|
|
1047
|
-
// the receipt ledger exists to prevent.
|
|
1048
|
-
const { NON_ANSWER_KINDS } = await import("../../lib/comms/receipts.mjs");
|
|
1049
|
-
assert.ok(NON_ANSWER_KINDS.includes("notice"), "every courtesy kind the daemon can emit belongs in the non-answer set");
|
|
1050
|
-
const now = Date.now();
|
|
1051
|
-
receipts.recordOutbound({ service: "cohort", channel: "C-NOTICE", kind: "notice", agentRoot: ROOT, now });
|
|
1052
|
-
assert.equal(
|
|
1053
|
-
receipts.spokeFor({ obligation: { key: "k", channel: "C-NOTICE", openedAt: now - 1000 }, now, siblingOpenCount: 1, agentRoot: ROOT }).heard,
|
|
1054
|
-
false,
|
|
1055
|
-
);
|
|
1056
|
-
});
|
|
1057
|
-
|
|
1058
|
-
test("every kind the daemon actually sends is classified as answer or courtesy — none left unclassified", async () => {
|
|
1059
|
-
// A new kind that nobody adds to the set silently becomes "this discharged
|
|
1060
|
-
// the debt" — the discharge-an-unanswered-debt bug the receipt ledger
|
|
1061
|
-
// exists to prevent.
|
|
1062
|
-
//
|
|
1063
|
-
// DERIVED FROM THE EMITTERS, not hand-listed. The hand-written literal this
|
|
1064
|
-
// replaces could not fail when a kind was added, and had ALREADY missed one:
|
|
1065
|
-
// `plan` was being emitted by notePlan and was absent from the list. Same
|
|
1066
|
-
// shape as narratorPatterns(), which derives its strings from the composers
|
|
1067
|
-
// rather than restating them.
|
|
1068
|
-
const { NON_ANSWER_KINDS } = await import("../../lib/comms/receipts.mjs");
|
|
1069
|
-
const { readFileSync } = await import("node:fs");
|
|
1070
|
-
const here = new URL(".", import.meta.url).pathname;
|
|
1071
|
-
// Line-scoped, and `counters.bump` lines are skipped: a counter LABEL is
|
|
1072
|
-
// not a delivery kind (responder.mjs tags its dedup/generation counters
|
|
1073
|
-
// `kind: "holding"`, which never reaches a receipt). Erring toward a false
|
|
1074
|
-
// alarm is deliberate — a spurious failure sends someone to look, a silent
|
|
1075
|
-
// miss is the bug this test is for.
|
|
1076
|
-
const emitted = new Set();
|
|
1077
|
-
for (const f of ["assurance.mjs", "responder.mjs"]) {
|
|
1078
|
-
for (const line of readFileSync(join(here, f), "utf-8").split("\n")) {
|
|
1079
|
-
if (line.includes("counters.bump")) continue;
|
|
1080
|
-
const m = /\bkind:\s*"([a-z-]+)"/.exec(line);
|
|
1081
|
-
if (m) emitted.add(m[1]);
|
|
1082
|
-
}
|
|
1083
|
-
}
|
|
1084
|
-
assert.ok(emitted.size >= 5, `the scan must find the emitters — found ${[...emitted].join(", ")}`);
|
|
1085
|
-
// The ONE kind that is an answer. Everything else the daemon emits about an
|
|
1086
|
-
// ask is a courtesy and must not discharge the debt.
|
|
1087
|
-
const ANSWERS = new Set(["reply"]);
|
|
1088
|
-
for (const kind of emitted) {
|
|
1089
|
-
assert.equal(
|
|
1090
|
-
NON_ANSWER_KINDS.includes(kind),
|
|
1091
|
-
!ANSWERS.has(kind),
|
|
1092
|
-
`"${kind}" is emitted by the daemon and is ${ANSWERS.has(kind) ? "an answer" : "a courtesy"} — lib/comms/receipts.mjs NON_ANSWER_KINDS must say so`,
|
|
1093
|
-
);
|
|
1094
|
-
}
|
|
1095
|
-
// Pinned explicitly as well, so a scan that silently stops matching is also
|
|
1096
|
-
// a failure rather than a green run over an empty set.
|
|
1097
|
-
for (const kind of ["ack", "notice", "failure", "plan"]) {
|
|
1098
|
-
assert.ok(emitted.has(kind), `"${kind}" must still be found by the scan`);
|
|
1099
|
-
assert.ok(NON_ANSWER_KINDS.includes(kind), `"${kind}" is a courtesy`);
|
|
1100
|
-
}
|
|
1101
|
-
});
|
|
1102
|
-
|
|
1103
|
-
test("an unreadable ledger reads as 'the human heard nothing', never as 'fine'", () => {
|
|
1104
|
-
// The fail-open direction is deliberate: the cost of guessing wrong this way
|
|
1105
|
-
// is one redundant message; guessing the other way is the bug being fixed.
|
|
1106
|
-
assert.equal(receipts.spokeSince({ channel: "never-used-channel", sinceMs: Date.now(), agentRoot: ROOT }), false);
|
|
1107
|
-
});
|
|
1108
|
-
});
|
|
1109
|
-
|
|
1110
|
-
// ---------------------------------------------------------------------------
|
|
1111
|
-
// 7. ATTRIBUTION — a room is shared; a debt is not
|
|
1112
|
-
// ---------------------------------------------------------------------------
|
|
1113
|
-
|
|
1114
|
-
describe("attribution: which debt did that message actually discharge", () => {
|
|
1115
|
-
const mk = (id, content) => ({
|
|
1116
|
-
id, raw_ref: `cohort:C-SHARED:${id}`, message_id: id, service: "cohort",
|
|
1117
|
-
channel: "C-SHARED", channel_id: "C-SHARED", sender: "Dana Whitfield", content,
|
|
1118
|
-
});
|
|
1119
|
-
|
|
1120
|
-
test("REGRESSION: one session's answer does NOT discharge a different ask in the same room", async () => {
|
|
1121
|
-
// The owner sends two things a minute apart in one DM. Two debts, one room.
|
|
1122
|
-
// Keyed on the room alone — which is what shipped — the first session's
|
|
1123
|
-
// reply closed BOTH, and the second ask, which nobody answered, was recorded
|
|
1124
|
-
// as "answered" with not one word sent about it. This is that scenario.
|
|
1125
|
-
const t = fakeTransport();
|
|
1126
|
-
const a = mk("ASK-A", "what's our runway?");
|
|
1127
|
-
const b = mk("ASK-B", "also please fix the flaky poller test and push it");
|
|
1128
|
-
const ra = await openAndAcknowledge({ item: a, classResult: CLASS_COMPLEX, deps: { ackSender: t.ackSender } });
|
|
1129
|
-
const rb = await openAndAcknowledge({ item: b, classResult: CLASS_COMPLEX, deps: { ackSender: t.ackSender } });
|
|
1130
|
-
assurance.noteSession(ra.key, "s-A");
|
|
1131
|
-
assurance.noteSession(rb.key, "s-B");
|
|
1132
|
-
|
|
1133
|
-
// Session A answers, stamping the receipt with the debt it was spawned for.
|
|
1134
|
-
receipts.recordOutbound({
|
|
1135
|
-
service: "cohort", channel: "C-SHARED", kind: "session",
|
|
1136
|
-
obligationKey: ra.key, sessionId: "s-A", agentRoot: ROOT,
|
|
1137
|
-
});
|
|
1138
|
-
|
|
1139
|
-
const before = t.sent.length;
|
|
1140
|
-
const settledA = await settleSession({ key: ra.key, ok: true, code: 0, deps: { deliverImpl: t.impl } });
|
|
1141
|
-
assert.equal(settledA.verdict, "answered-by-session");
|
|
1142
|
-
assert.equal(settledA.basis, "obligation-keyed");
|
|
1143
|
-
assert.equal(t.sent.length, before, "A was answered — saying more would be spam");
|
|
1144
|
-
|
|
1145
|
-
// B exited 0 having said nothing. It must NOT ride on A's receipt.
|
|
1146
|
-
const settledB = await settleSession({
|
|
1147
|
-
key: rb.key, ok: true, code: 0,
|
|
1148
|
-
stdout: JSON.stringify({ type: "result", result: "Could not reproduce the flake." }),
|
|
1149
|
-
deps: { deliverImpl: t.impl },
|
|
1150
|
-
});
|
|
1151
|
-
assert.equal(settledB.verdict, "silent-success", "B was never answered and must be rescued");
|
|
1152
|
-
assert.match(t.sent.at(-1).text, /didn't get a reply out to you/i);
|
|
1153
|
-
assert.match(t.sent.at(-1).text, /Could not reproduce the flake/, "and B's actual work reaches the human");
|
|
1154
|
-
});
|
|
1155
|
-
|
|
1156
|
-
test("an UNATTRIBUTED message in a room holding two open debts discharges neither", async () => {
|
|
1157
|
-
const t = fakeTransport();
|
|
1158
|
-
const a = mk("AMB-A", "first ask");
|
|
1159
|
-
const b = mk("AMB-B", "second ask");
|
|
1160
|
-
const ra = await openAndAcknowledge({ item: a, classResult: CLASS_COMPLEX, deps: { ackSender: t.ackSender } });
|
|
1161
|
-
await openAndAcknowledge({ item: b, classResult: CLASS_COMPLEX, deps: { ackSender: t.ackSender } });
|
|
1162
|
-
assurance.noteSession(ra.key, "s-amb");
|
|
1163
|
-
receipts.recordOutbound({ service: "cohort", channel: "C-SHARED", kind: "session", agentRoot: ROOT });
|
|
1164
|
-
|
|
1165
|
-
const r = await settleSession({ key: ra.key, ok: true, code: 0, stdout: "", deps: { deliverImpl: t.impl } });
|
|
1166
|
-
assert.equal(r.verdict, "silent-success", "ambiguous evidence is not evidence");
|
|
1167
|
-
});
|
|
1168
|
-
|
|
1169
|
-
test("an unattributed message DOES discharge the only open debt in a quiet room", async () => {
|
|
1170
|
-
// The tolerant rung. A send lane that loses the dispatcher's env still leaves
|
|
1171
|
-
// a receipt; treating that as silence would make every such reply produce a
|
|
1172
|
-
// redundant apology. Safe only because the room holds one debt and a session
|
|
1173
|
-
// of ours has actually run.
|
|
1174
|
-
const t = fakeTransport();
|
|
1175
|
-
const solo = { ...ITEM, id: "SOLO-1", raw_ref: "cohort:C-QUIET:SOLO-1", channel: "C-QUIET", channel_id: "C-QUIET" };
|
|
1176
|
-
const r = await openAndAcknowledge({ item: solo, classResult: CLASS_COMPLEX, deps: { ackSender: t.ackSender } });
|
|
1177
|
-
assurance.noteSession(r.key, "s-solo");
|
|
1178
|
-
receipts.recordOutbound({ service: "cohort", channel: "C-QUIET", kind: "session", agentRoot: ROOT });
|
|
1179
|
-
|
|
1180
|
-
const before = t.sent.length;
|
|
1181
|
-
const settled = await settleSession({ key: r.key, ok: true, code: 0, deps: { deliverImpl: t.impl } });
|
|
1182
|
-
assert.equal(settled.verdict, "answered-by-session");
|
|
1183
|
-
assert.equal(settled.basis, "sole-debt-in-room");
|
|
1184
|
-
assert.equal(t.sent.length, before);
|
|
1185
|
-
});
|
|
1186
|
-
|
|
1187
|
-
test("REGRESSION: a message that arrives before any session of ours ran is not our answer", async () => {
|
|
1188
|
-
// A debt whose acknowledgement failed, and something unrelated posts into the
|
|
1189
|
-
// room. Closing on that leaves a human never acknowledged, never answered,
|
|
1190
|
-
// and the debt recorded as discharged.
|
|
1191
|
-
const t = fakeTransport();
|
|
1192
|
-
const solo = { ...ITEM, id: "PRE-1", raw_ref: "cohort:C-PRE:PRE-1", channel: "C-PRE", channel_id: "C-PRE" };
|
|
1193
|
-
const r = await openAndAcknowledge({ item: solo, classResult: CLASS_COMPLEX, tier: "work" });
|
|
1194
|
-
assert.equal(readObligation(r.key).acknowledged, false, "nothing is said at open time any more");
|
|
1195
|
-
receipts.recordOutbound({ service: "cohort", channel: "C-PRE", kind: "reply", agentRoot: ROOT });
|
|
1196
|
-
|
|
1197
|
-
const stats = await sweepObligations({ now: Date.now() + ACK_AFTER_MS + 1000, deps: { deliverImpl: t.impl, generateAckImpl: async () => "Finally picking your ask up now." } });
|
|
1198
|
-
assert.equal(stats.closed, 0, "no session of ours had run — that message was not our answer");
|
|
1199
|
-
assert.equal(stats.acked, 1, "instead the interim is finally sent");
|
|
1200
|
-
assert.equal(t.sent.at(-1).text, "Finally picking your ask up now.");
|
|
1201
|
-
});
|
|
1202
|
-
});
|
|
1203
|
-
|
|
1204
|
-
// ---------------------------------------------------------------------------
|
|
1205
|
-
// 8. UNREACHABLE CHANNELS — an impossible send must not become a loop
|
|
1206
|
-
// ---------------------------------------------------------------------------
|
|
1207
|
-
|
|
1208
|
-
describe("channels with no transport", () => {
|
|
1209
|
-
const TELEGRAM = {
|
|
1210
|
-
id: "TG-1", raw_ref: "telegram:12345:9", message_id: "TG-1",
|
|
1211
|
-
service: "telegram", channel_id: "12345", sender: "Dana Whitfield",
|
|
1212
|
-
content: "please redo the deck and send it over",
|
|
1213
|
-
};
|
|
1214
|
-
|
|
1215
|
-
test("no acknowledgement is promised on a service we cannot write to", () => {
|
|
1216
|
-
const v = shouldAcknowledge({ willSpawnSession: true, item: TELEGRAM, source: "inbox" });
|
|
1217
|
-
assert.equal(v.ack, false);
|
|
1218
|
-
assert.equal(v.reason, "no-transport");
|
|
1219
|
-
});
|
|
1220
|
-
|
|
1221
|
-
test("REGRESSION: the debt is still opened, and the sweep escalates it ONCE instead of retrying forever", async () => {
|
|
1222
|
-
// What shipped: the ack branch's `continue` sat above the stale branch, so an
|
|
1223
|
-
// obligation whose send could never succeed was retried once per tick —
|
|
1224
|
-
// 1,440 impossible sends a day, an immortal debt, and a human told nothing.
|
|
1225
|
-
const t = fakeTransport();
|
|
1226
|
-
const opened = await openAndAcknowledge({ item: TELEGRAM, classResult: CLASS_COMPLEX, ack: false });
|
|
1227
|
-
assert.equal(opened.opened, true, "the debt exists even though we cannot speak to it");
|
|
1228
|
-
assert.equal(readObligation(opened.key).deliverable, false);
|
|
1229
|
-
|
|
1230
|
-
let attempts = 0;
|
|
1231
|
-
const counting = async (...a) => { attempts++; return t.impl(...a); };
|
|
1232
|
-
const t0 = Date.now();
|
|
1233
|
-
for (let m = 1; m <= 240; m++) {
|
|
1234
|
-
await sweepObligations({ now: t0 + m * 60_000, deps: { deliverImpl: counting } });
|
|
1235
|
-
}
|
|
1236
|
-
assert.equal(attempts, 0, "not one impossible send is attempted");
|
|
1237
|
-
const rec = readObligation(opened.key);
|
|
1238
|
-
assert.equal(rec.state, "undeliverable", "the debt is resolved, not immortal");
|
|
1239
|
-
assert.equal(openObligations().length, 0);
|
|
1240
|
-
});
|
|
1241
|
-
|
|
1242
|
-
test("an acknowledgement that keeps failing is counted out and escalated", async () => {
|
|
1243
|
-
const t = fakeTransport({ failTimes: 999 });
|
|
1244
|
-
const r = await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX, deps: { ackSender: t.ackSender } });
|
|
1245
|
-
const t0 = Date.now();
|
|
1246
|
-
let ticks = 0;
|
|
1247
|
-
for (let m = 1; m <= 60; m++) {
|
|
1248
|
-
const s = await sweepObligations({ now: t0 + m * 60_000, deps: { deliverImpl: t.impl, generateAckImpl: async () => FAKE_ACK_LINE } });
|
|
1249
|
-
ticks++;
|
|
1250
|
-
if (s.unreachable) break;
|
|
1251
|
-
}
|
|
1252
|
-
assert.ok(ticks <= ACK_MAX_ATTEMPTS + 2, `gave up after ${ticks} ticks, not forever`);
|
|
1253
|
-
assert.equal(readObligation(r.key).state, "undeliverable");
|
|
1254
|
-
});
|
|
1255
|
-
});
|
|
1256
|
-
|
|
1257
|
-
// ---------------------------------------------------------------------------
|
|
1258
|
-
// 9. THE RETRY CLOCK — never contradict a message already sent
|
|
1259
|
-
// ---------------------------------------------------------------------------
|
|
1260
|
-
|
|
1261
|
-
describe("a running retry is not declared dead", () => {
|
|
1262
|
-
test("REGRESSION: 'I'm retrying it now' is not followed by 'I've stopped retrying' five minutes later", async () => {
|
|
1263
|
-
// Attempt 1 is SIGTERMed at the dispatcher's 45-minute cap. STALE_AFTER_MS is
|
|
1264
|
-
// 50 minutes and the clock used to run from the ORIGINAL arrival, so at
|
|
1265
|
-
// minute 50 the sweep told the same human the exact opposite of what they had
|
|
1266
|
-
// been told at minute 45 — while the retry was still running.
|
|
1267
|
-
const t = fakeTransport();
|
|
1268
|
-
const r = await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX, deps: { ackSender: t.ackSender } });
|
|
1269
|
-
assurance.noteSession(r.key, "s-1");
|
|
1270
|
-
const t0 = readObligation(r.key).openedAt;
|
|
1271
|
-
|
|
1272
|
-
const settled = await settleSession({
|
|
1273
|
-
key: r.key, ok: false, code: 143, error: "session_close exit 143",
|
|
1274
|
-
now: t0 + 45 * 60_000, deps: { deliverImpl: t.impl, spokeSinceImpl: () => false },
|
|
1275
|
-
});
|
|
1276
|
-
assert.equal(settled.willRetry, true);
|
|
1277
|
-
assert.match(t.sent.at(-1).text, /retrying now/i);
|
|
1278
|
-
|
|
1279
|
-
const stats = await sweepObligations({ now: t0 + 51 * 60_000, deps: { deliverImpl: t.impl, spokeSinceImpl: () => false } });
|
|
1280
|
-
assert.equal(stats.staled, 0, "the retry is minutes old, not fifty");
|
|
1281
|
-
assert.equal(readObligation(r.key).state, "open");
|
|
1282
|
-
assert.doesNotMatch(t.sent.at(-1).text, /stopped retrying/i);
|
|
1283
|
-
});
|
|
1284
|
-
|
|
1285
|
-
test("but a retry that itself goes nowhere DOES eventually stale out", async () => {
|
|
1286
|
-
const t = fakeTransport();
|
|
1287
|
-
const r = await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX, deps: { ackSender: t.ackSender } });
|
|
1288
|
-
assurance.noteSession(r.key, "s-1");
|
|
1289
|
-
const t0 = readObligation(r.key).openedAt;
|
|
1290
|
-
await settleSession({
|
|
1291
|
-
key: r.key, ok: false, code: 143, error: "session_close exit 143",
|
|
1292
|
-
now: t0 + 45 * 60_000, deps: { deliverImpl: t.impl, spokeSinceImpl: () => false },
|
|
1293
|
-
});
|
|
1294
|
-
const stats = await sweepObligations({
|
|
1295
|
-
now: t0 + 45 * 60_000 + STALE_AFTER_MS + 1000,
|
|
1296
|
-
deps: { deliverImpl: t.impl, spokeSinceImpl: () => false },
|
|
1297
|
-
});
|
|
1298
|
-
assert.equal(stats.staled, 1, "the promise still has a floor under it");
|
|
1299
|
-
assert.match(t.sent.at(-1).text, /outrun its time limit/i);
|
|
1300
|
-
});
|
|
1301
|
-
});
|
|
1302
|
-
|
|
1303
|
-
// ---------------------------------------------------------------------------
|
|
1304
|
-
// 10. ACKNOWLEDGEMENT DISCIPLINE — the 2026-09-12 policy, against the measured
|
|
1305
|
-
// production strings that made it necessary.
|
|
1306
|
-
// ---------------------------------------------------------------------------
|
|
1307
|
-
|
|
1308
|
-
describe("sanitiseAckText enforces the prompt the model was already given", () => {
|
|
1309
|
-
// The ack system prompt has said "Never a generic 'on it' or 'looking into
|
|
1310
|
-
// it'" since it was written. Nothing checked. 3,069 generic acknowledgements
|
|
1311
|
-
// shipped in fourteen days — 28.8% of every message this fleet sent — and 272
|
|
1312
|
-
// of them were never followed by a substantive reply within the hour.
|
|
1313
|
-
//
|
|
1314
|
-
// These are the REAL strings, as observed in org_default_adaptic.
|
|
1315
|
-
const OBSERVED_IN_PRODUCTION = [
|
|
1316
|
-
"On it.",
|
|
1317
|
-
"On it",
|
|
1318
|
-
"On it — digging in now.",
|
|
1319
|
-
"Looking now.",
|
|
1320
|
-
"Looking into it.",
|
|
1321
|
-
"Looking into this now.",
|
|
1322
|
-
"Checking.",
|
|
1323
|
-
"Checking now.",
|
|
1324
|
-
"One moment.",
|
|
1325
|
-
"Got it.",
|
|
1326
|
-
"Got it — will do.",
|
|
1327
|
-
"Will do.",
|
|
1328
|
-
"Working on it.",
|
|
1329
|
-
"Taking a look.",
|
|
1330
|
-
"Digging in now.",
|
|
1331
|
-
];
|
|
1332
|
-
|
|
1333
|
-
for (const line of OBSERVED_IN_PRODUCTION) {
|
|
1334
|
-
test(`rejects the measured generic ack: ${JSON.stringify(line)}`, () => {
|
|
1335
|
-
assert.equal(sanitiseAckText(line), null, "rejection ⇒ null ⇒ send nothing, which is the existing correct failure mode");
|
|
1336
|
-
});
|
|
1337
|
-
}
|
|
1338
|
-
|
|
1339
|
-
test("rejects anything under the minimum length, whatever its phrasing", () => {
|
|
1340
|
-
// The second, phrasing-independent net. A line too short to name the thing
|
|
1341
|
-
// that was asked cannot be specific to it, which is the one thing the
|
|
1342
|
-
// prompt requires.
|
|
1343
|
-
assert.equal(ACK_MIN_CHARS, 20);
|
|
1344
|
-
assert.equal(sanitiseAckText("Sorted, starting."), null);
|
|
1345
|
-
assert.equal(sanitiseAckText("Yep."), null);
|
|
1346
|
-
assert.equal(sanitiseAckText("Onto it now, boss."), null);
|
|
1347
|
-
});
|
|
1348
|
-
|
|
1349
|
-
test("…and still accepts a line that actually names the ask", () => {
|
|
1350
|
-
// The discipline must not become a gag. A bespoke line is exactly what the
|
|
1351
|
-
// prompt asks for, and it survives.
|
|
1352
|
-
for (const good of [
|
|
1353
|
-
"Give me a few minutes on the git fixes.",
|
|
1354
|
-
"Pulling the July ledger against the forecast now.",
|
|
1355
|
-
"Starting on the regulator gap analysis — the Thursday one.",
|
|
1356
|
-
]) {
|
|
1357
|
-
assert.equal(sanitiseAckText(good), good, `"${good}" is specific and must pass`);
|
|
1358
|
-
}
|
|
1359
|
-
});
|
|
1360
|
-
|
|
1361
|
-
test("REGRESSION: the ack prompt and the filter read the same pattern, so they cannot drift again", async () => {
|
|
1362
|
-
// The whole defect was an instruction with no enforcement behind it. The
|
|
1363
|
-
// filter now imports GENERIC_OPENER from the copy-rule module; this pins
|
|
1364
|
-
// that every opener the prompt names is in fact refused.
|
|
1365
|
-
const { GENERIC_OPENER } = await import("../../lib/assurance/plan-note.mjs");
|
|
1366
|
-
for (const opener of ["on it", "looking now", "looking into", "checking", "one moment", "got it", "will do", "working on it", "taking a look", "digging in"]) {
|
|
1367
|
-
assert.match(opener, GENERIC_OPENER, `the prompt forbids "${opener}" — the filter must too`);
|
|
1368
|
-
}
|
|
1369
|
-
});
|
|
1370
|
-
});
|
|
1371
|
-
|
|
1372
|
-
describe("failure notices are said ONCE per (channel, obligationKey)", () => {
|
|
1373
|
-
test("REGRESSION: the same failure sentence does not reach one room three times in a minute", async () => {
|
|
1374
|
-
// Sampled live at 06:32Z on 2026-09-12: the same failure sentence three
|
|
1375
|
-
// times inside one minute, and `On it.` again immediately after. The cause
|
|
1376
|
-
// was a retry loop that narrated every attempt — notice, retry, a FRESH
|
|
1377
|
-
// obligation under the same key, notice again — with nothing suppressing
|
|
1378
|
-
// the repeat.
|
|
1379
|
-
const t = fakeTransport();
|
|
1380
|
-
const deps = { deliverImpl: t.impl, spokeSinceImpl: () => false, generateAckImpl: async () => FAKE_ACK_LINE };
|
|
1381
|
-
|
|
1382
|
-
for (let attempt = 0; attempt < 3; attempt++) {
|
|
1383
|
-
const r = await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX, tier: "work" });
|
|
1384
|
-
assurance.noteSession(r.key, `s-${attempt}`);
|
|
1385
|
-
await settleSession({ key: r.key, ok: false, code: 143, error: "SIGTERM", deps });
|
|
1386
|
-
// Whatever the outcome, the item is re-delivered and the key reopens.
|
|
1387
|
-
assurance.closeObligation(r.key, { outcome: "failed" });
|
|
1388
|
-
}
|
|
1389
|
-
|
|
1390
|
-
const failures = t.sent.filter((m) => m.kind === "failure");
|
|
1391
|
-
assert.equal(failures.length, 1, `the same sentence went out ${failures.length} times; the whole point is once`);
|
|
1392
|
-
assert.match(failures[0].text, /hit a problem/i);
|
|
1393
|
-
assert.equal(t.sent.filter((m) => m.kind === "ack").length, 0, "and no `On it.` behind it either");
|
|
1394
|
-
});
|
|
1395
|
-
|
|
1396
|
-
test("…but 'a retry is running' and 'I have stopped' remain two separate, sayable facts", async () => {
|
|
1397
|
-
// Collapsing them would leave a human who was told a retry was in flight
|
|
1398
|
-
// never learning it had been abandoned. What is suppressed is the REPEAT of
|
|
1399
|
-
// one fact, not the second fact.
|
|
1400
|
-
const t = fakeTransport();
|
|
1401
|
-
const deps = { deliverImpl: t.impl, spokeSinceImpl: () => false };
|
|
1402
|
-
const r = await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX, tier: "work" });
|
|
1403
|
-
assurance.noteSession(r.key, "s-1");
|
|
1404
|
-
const first = await settleSession({ key: r.key, ok: false, code: 143, error: "SIGTERM", deps });
|
|
1405
|
-
assert.equal(first.willRetry, true);
|
|
1406
|
-
|
|
1407
|
-
// The retry runs and fails terminally.
|
|
1408
|
-
assurance.noteSession(r.key, "s-2");
|
|
1409
|
-
const second = await settleSession({ key: r.key, ok: false, code: null, error: "claude CLI spawn error: ENOENT", deps });
|
|
1410
|
-
assert.equal(second.willRetry, false);
|
|
1411
|
-
|
|
1412
|
-
const texts = t.sent.filter((m) => m.kind === "failure").map((m) => m.text);
|
|
1413
|
-
assert.equal(texts.length, 2);
|
|
1414
|
-
assert.match(texts[0], /retrying now/i);
|
|
1415
|
-
assert.match(texts[1], /stopped retrying/i);
|
|
1416
|
-
});
|
|
1417
|
-
|
|
1418
|
-
test("the notice ledger records WHICH notice reached WHICH room", async () => {
|
|
1419
|
-
const t = fakeTransport();
|
|
1420
|
-
const r = await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX, tier: "work" });
|
|
1421
|
-
await settleSession({ key: r.key, ok: false, code: 143, error: "SIGTERM", deps: { deliverImpl: t.impl, spokeSinceImpl: () => false } });
|
|
1422
|
-
const rec = readObligation(r.key);
|
|
1423
|
-
const ids = Object.keys(rec.notices || {});
|
|
1424
|
-
assert.equal(ids.length, 1);
|
|
1425
|
-
assert.ok(ids[0].startsWith(NOTICE.FAILURE_RETRYING), `expected a ${NOTICE.FAILURE_RETRYING} stamp, got ${ids[0]}`);
|
|
1426
|
-
assert.ok(ids[0].includes(String(ITEM.channel_id).toLowerCase()), "the channel is part of the key — the same ask re-routed elsewhere genuinely has not been answered there");
|
|
1427
|
-
});
|
|
1428
|
-
|
|
1429
|
-
test("a stale notice is not repeated for a key that already heard one", async () => {
|
|
1430
|
-
const t = fakeTransport();
|
|
1431
|
-
const deps = { deliverImpl: t.impl, spokeSinceImpl: () => false, generateAckImpl: async () => FAKE_ACK_LINE };
|
|
1432
|
-
for (let attempt = 0; attempt < 3; attempt++) {
|
|
1433
|
-
await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX, tier: "work" });
|
|
1434
|
-
await sweepObligations({ now: Date.now() + STALE_AFTER_MS + 1000, deps });
|
|
1435
|
-
}
|
|
1436
|
-
assert.equal(t.sent.filter((m) => m.kind === "failure").length, 1);
|
|
1437
|
-
});
|
|
1438
|
-
|
|
1439
|
-
// ── THE SIBLING SHAPE, which the per-record ledger structurally cannot reach ─
|
|
1440
|
-
//
|
|
1441
|
-
// The dedupe above is keyed on the obligation RECORD, so it covers the RETRY
|
|
1442
|
-
// loop: notice → retry → fresh record under the same key → notice again. It
|
|
1443
|
-
// does NOT cover SIBLINGS. Twenty separate inbound asks in one room whose
|
|
1444
|
-
// sessions all die the same way are twenty separate records, and the stale
|
|
1445
|
-
// branch's sentence contains nothing about which ask it is — so the room gets
|
|
1446
|
-
// the identical line twenty times and the reader cannot tell any of them
|
|
1447
|
-
// apart. That is the commoner shape whenever a dispatcher or model path is
|
|
1448
|
-
// down, and it is the one the sampled "same failure sentence three times in
|
|
1449
|
-
// one minute" is most likely to have actually been.
|
|
1450
|
-
|
|
1451
|
-
test("REGRESSION: twenty SIBLING debts staling together say the identical sentence ONCE", async () => {
|
|
1452
|
-
const t = fakeTransport();
|
|
1453
|
-
const deps = { deliverImpl: t.impl, spokeSinceImpl: () => false, generateAckImpl: async () => FAKE_ACK_LINE };
|
|
1454
|
-
for (let i = 0; i < 20; i++) {
|
|
1455
|
-
await openAndAcknowledge({
|
|
1456
|
-
item: { ...ITEM, id: `sib-${i}`, raw_ref: `cohort:sibling:${i}`, message_id: `${i}` },
|
|
1457
|
-
classResult: CLASS_COMPLEX,
|
|
1458
|
-
tier: "work",
|
|
1459
|
-
});
|
|
1460
|
-
}
|
|
1461
|
-
assert.equal(openObligations().length, 20, "every ask is still opened and tracked");
|
|
1462
|
-
|
|
1463
|
-
await sweepObligations({ now: Date.now() + STALE_AFTER_MS + 60_000, deps });
|
|
1464
|
-
|
|
1465
|
-
const failures = t.sent.filter((m) => m.kind === "failure");
|
|
1466
|
-
assert.equal(failures.length, 1, `twenty byte-identical failure sentences reached one room ${failures.length} times`);
|
|
1467
|
-
// Nothing is LOST: every dead ask is still escalated for an operator, and
|
|
1468
|
-
// every debt is still closed. Only the duplicate sentence is withheld.
|
|
1469
|
-
assert.equal(openObligations().length, 0, "all twenty debts were still discharged");
|
|
1470
|
-
const { readdirSync } = await import("node:fs");
|
|
1471
|
-
assert.equal(readdirSync(join(ROOT, "state", "obligations", "needs-attention")).length, 20,
|
|
1472
|
-
"…and an operator still sees all twenty in needs-attention");
|
|
1473
|
-
});
|
|
1474
|
-
|
|
1475
|
-
test("…but twenty siblings that fail DIFFERENTLY are all said, because each is a fact the reader lacks", async () => {
|
|
1476
|
-
// The safety argument for the suppression above. Only an IDENTICAL sentence
|
|
1477
|
-
// is withheld; suppressing information a person does not have re-creates
|
|
1478
|
-
// the original bug — someone never told their work died.
|
|
1479
|
-
const t = fakeTransport();
|
|
1480
|
-
const deps = { deliverImpl: t.impl, spokeSinceImpl: () => false };
|
|
1481
|
-
const CAUSES = [
|
|
1482
|
-
{ code: null, error: "claude CLI spawn error: ENOENT" },
|
|
1483
|
-
{ code: 0, error: "" },
|
|
1484
|
-
];
|
|
1485
|
-
let sent = 0;
|
|
1486
|
-
for (let i = 0; i < CAUSES.length; i++) {
|
|
1487
|
-
const r = await openAndAcknowledge({
|
|
1488
|
-
item: { ...ITEM, id: `diff-${i}`, raw_ref: `cohort:different:${i}` },
|
|
1489
|
-
classResult: CLASS_COMPLEX,
|
|
1490
|
-
tier: "work",
|
|
1491
|
-
});
|
|
1492
|
-
assurance.noteSession(r.key, `s-${i}`);
|
|
1493
|
-
const res = await settleSession({ key: r.key, ok: false, ...CAUSES[i], deps });
|
|
1494
|
-
if (res.spoke) sent++;
|
|
1495
|
-
}
|
|
1496
|
-
assert.equal(sent, CAUSES.length, "distinct causes are distinct sentences and every one is said");
|
|
1497
|
-
const texts = new Set(t.sent.filter((m) => m.kind === "failure").map((m) => m.text));
|
|
1498
|
-
assert.equal(texts.size, CAUSES.length);
|
|
1499
|
-
});
|
|
1500
|
-
|
|
1501
|
-
test("a sibling in a DIFFERENT room still hears it — the coalescing is per room", async () => {
|
|
1502
|
-
const t = fakeTransport();
|
|
1503
|
-
const deps = { deliverImpl: t.impl, spokeSinceImpl: () => false, generateAckImpl: async () => FAKE_ACK_LINE };
|
|
1504
|
-
await openAndAcknowledge({ item: { ...ITEM, id: "r1-a", raw_ref: "cohort:r1:a" }, classResult: CLASS_COMPLEX, tier: "work" });
|
|
1505
|
-
await openAndAcknowledge({ item: { ...ITEM, id: "r1-b", raw_ref: "cohort:r1:b" }, classResult: CLASS_COMPLEX, tier: "work" });
|
|
1506
|
-
await openAndAcknowledge({
|
|
1507
|
-
item: { ...ITEM, id: "r2-a", raw_ref: "cohort:r2:a", channel_id: "C-OTHER-ROOM" },
|
|
1508
|
-
classResult: CLASS_COMPLEX, tier: "work",
|
|
1509
|
-
});
|
|
1510
|
-
await sweepObligations({ now: Date.now() + STALE_AFTER_MS + 60_000, deps });
|
|
1511
|
-
const failures = t.sent.filter((m) => m.kind === "failure");
|
|
1512
|
-
assert.equal(failures.length, 2, "one per room");
|
|
1513
|
-
assert.equal(new Set(failures.map((m) => m.channel)).size, 2);
|
|
1514
|
-
});
|
|
1515
|
-
|
|
1516
|
-
test("a suppressed duplicate is still stamped on its own record, so it cannot leak out later", async () => {
|
|
1517
|
-
// If the record's own ledger did not learn the fact had been told in this
|
|
1518
|
-
// room, the next tick would re-say it the moment the room window rolled
|
|
1519
|
-
// over — turning suppression into a delay.
|
|
1520
|
-
const t = fakeTransport();
|
|
1521
|
-
const deps = { deliverImpl: t.impl, spokeSinceImpl: () => false };
|
|
1522
|
-
const a = await openAndAcknowledge({ item: { ...ITEM, id: "st-a", raw_ref: "cohort:stamp:a" }, classResult: CLASS_COMPLEX, tier: "work" });
|
|
1523
|
-
const b = await openAndAcknowledge({ item: { ...ITEM, id: "st-b", raw_ref: "cohort:stamp:b" }, classResult: CLASS_COMPLEX, tier: "work" });
|
|
1524
|
-
assurance.noteSession(a.key, "s-a");
|
|
1525
|
-
assurance.noteSession(b.key, "s-b");
|
|
1526
|
-
await settleSession({ key: a.key, ok: false, code: 143, error: "SIGTERM", deps });
|
|
1527
|
-
await settleSession({ key: b.key, ok: false, code: 143, error: "SIGTERM", deps });
|
|
1528
|
-
assert.equal(t.sent.filter((m) => m.kind === "failure").length, 1);
|
|
1529
|
-
const recB = readObligation(b.key);
|
|
1530
|
-
assert.ok(Object.keys(recB.notices || {}).some((k) => k.startsWith(NOTICE.FAILURE_RETRYING)),
|
|
1531
|
-
"the suppressed record carries the stamp, so a later tick does not re-say it");
|
|
1532
|
-
});
|
|
1533
|
-
});
|
|
1534
|
-
|
|
1535
|
-
describe("the sweep is not re-entrant", () => {
|
|
1536
|
-
// agent-daemon.mjs fires sweepObligations() on a 60-second setInterval with no
|
|
1537
|
-
// in-flight guard of its own, and ONE TICK IS NOT BOUNDED BY 60 SECONDS:
|
|
1538
|
-
// branch (e) peeks the room budget with commit:false, then awaits a cold
|
|
1539
|
-
// `claude --print` spawn, serialised per obligation. A daemon with a dozen-plus
|
|
1540
|
-
// eligible obligations in distinct rooms spends more than a tick in generation
|
|
1541
|
-
// alone, so tick N+1 begins while tick N is still composing — and BOTH peek the
|
|
1542
|
-
// same room's budget and read "allowed", because the spend is only committed
|
|
1543
|
-
// after a successful send.
|
|
1544
|
-
|
|
1545
|
-
test("an overlapping sweep is refused rather than allowed to double-spend a room", async () => {
|
|
1546
|
-
const t = fakeTransport();
|
|
1547
|
-
await openAndAcknowledge({ item: { ...ITEM, id: "re-1", raw_ref: "cohort:reentry:1" }, classResult: CLASS_COMPLEX, tier: "work" });
|
|
1548
|
-
await openAndAcknowledge({ item: { ...ITEM, id: "re-2", raw_ref: "cohort:reentry:2" }, classResult: CLASS_COMPLEX, tier: "work" });
|
|
1549
|
-
|
|
1550
|
-
const now = Date.now() + ACK_AFTER_MS + 1000;
|
|
1551
|
-
let overlapped = null;
|
|
1552
|
-
// A generator that starts a SECOND sweep while the first is still awaiting
|
|
1553
|
-
// it — exactly the shape a slow `claude --print` produces on the timer.
|
|
1554
|
-
const deps = {
|
|
1555
|
-
deliverImpl: t.impl,
|
|
1556
|
-
spokeSinceImpl: () => false,
|
|
1557
|
-
generateAckImpl: async () => {
|
|
1558
|
-
if (overlapped === null) overlapped = await sweepObligations({ now, deps });
|
|
1559
|
-
return FAKE_ACK_LINE;
|
|
1560
|
-
},
|
|
1561
|
-
};
|
|
1562
|
-
|
|
1563
|
-
const first = await sweepObligations({ now, deps });
|
|
1564
|
-
assert.equal(overlapped && overlapped.skipped, true, "the re-entrant call was refused");
|
|
1565
|
-
assert.equal(overlapped.acked, 0, "…and it spoke to nobody");
|
|
1566
|
-
assert.equal(first.acked, 1, "the outer sweep still did its one room's work");
|
|
1567
|
-
assert.equal(t.sent.filter((m) => m.kind === "ack").length, 1, "one room, one interim, even across overlapping ticks");
|
|
1568
|
-
});
|
|
1569
|
-
|
|
1570
|
-
test("the guard is released even when the sweep throws, so it cannot disable the daemon", async () => {
|
|
1571
|
-
await openAndAcknowledge({ item: { ...ITEM, id: "thr-1", raw_ref: "cohort:throw:1" }, classResult: CLASS_COMPLEX, tier: "work" });
|
|
1572
|
-
const boom = {
|
|
1573
|
-
deliverImpl: async () => { throw new Error("transport exploded"); },
|
|
1574
|
-
spokeSinceImpl: () => false,
|
|
1575
|
-
generateAckImpl: async () => FAKE_ACK_LINE,
|
|
1576
|
-
};
|
|
1577
|
-
await assert.rejects(() => sweepObligations({ now: Date.now() + ACK_AFTER_MS + 1000, deps: boom }));
|
|
1578
|
-
// The very next tick must run. Before the `finally`, this returned skipped.
|
|
1579
|
-
const after = await sweepObligations({
|
|
1580
|
-
now: Date.now() + ACK_AFTER_MS + 2000,
|
|
1581
|
-
deps: { deliverImpl: async () => ({ sent: true, via: "fake" }), spokeSinceImpl: () => false, generateAckImpl: async () => FAKE_ACK_LINE },
|
|
1582
|
-
});
|
|
1583
|
-
assert.notEqual(after.skipped, true, "a throw must not leave the sweep disabled for the life of the daemon");
|
|
1584
|
-
});
|
|
1585
|
-
});
|
|
1586
|
-
|
|
1587
|
-
describe("the room-budget ledger is not an obligation", () => {
|
|
1588
|
-
// It lives at state/obligations/room-budget.json — the path the design names
|
|
1589
|
-
// — inside a directory whose reader JSON-parses every *.json as a debt record.
|
|
1590
|
-
// Parsed as one it was a phantom with no `key` and no `state`: it inflated
|
|
1591
|
-
// every count derived from listObligations(), and pruneObligations() called
|
|
1592
|
-
// unlinkSync(pathFor(undefined)) -> state/obligations/unknown.json on every
|
|
1593
|
-
// tick, swallowed by a catch. Harmless only because nothing can key to
|
|
1594
|
-
// "unknown" today.
|
|
1595
|
-
test("a sweep that spends a room's budget does not add an obligation to the ledger", async () => {
|
|
1596
|
-
const t = fakeTransport();
|
|
1597
|
-
await openAndAcknowledge({ item: { ...ITEM, id: "led-1", raw_ref: "cohort:ledger:1" }, classResult: CLASS_COMPLEX, tier: "work" });
|
|
1598
|
-
await sweepObligations({
|
|
1599
|
-
now: Date.now() + ACK_AFTER_MS + 1000,
|
|
1600
|
-
deps: { deliverImpl: t.impl, spokeSinceImpl: () => false, generateAckImpl: async () => FAKE_ACK_LINE },
|
|
1601
|
-
});
|
|
1602
|
-
const { existsSync } = await import("node:fs");
|
|
1603
|
-
assert.equal(existsSync(join(ROOT, "state", "obligations", "room-budget.json")), true, "the budget really was written there");
|
|
1604
|
-
const all = assurance.listObligations();
|
|
1605
|
-
assert.equal(all.length, 1, `one ask, one record — got ${all.length}`);
|
|
1606
|
-
assert.equal(all.every((r) => typeof r.key === "string" && r.key), true, "every record the reader returns is addressable");
|
|
1607
|
-
});
|
|
1608
|
-
|
|
1609
|
-
test("pruning never unlinks state/obligations/unknown.json", async () => {
|
|
1610
|
-
const { writeFileSync, existsSync } = await import("node:fs");
|
|
1611
|
-
const decoy = join(ROOT, "state", "obligations", "unknown.json");
|
|
1612
|
-
writeFileSync(decoy, JSON.stringify({ not: "an obligation" }));
|
|
1613
|
-
assurance.pruneObligations({ now: Date.now() + 10 * 24 * 3600_000 });
|
|
1614
|
-
assert.equal(existsSync(decoy), true, "a keyless record must never be resolved to a path and deleted");
|
|
1615
|
-
try { (await import("node:fs")).unlinkSync(decoy); } catch { /* cleanup only */ }
|
|
1616
|
-
});
|
|
1617
|
-
});
|
|
1618
|
-
|
|
1619
|
-
describe("notePlan — the plan tier says the plan, or says nothing", () => {
|
|
1620
|
-
const FIRST_TURN = {
|
|
1621
|
-
type: "assistant",
|
|
1622
|
-
message: { content: [{ type: "text", text: "1. Pull the July ledger rows and the signed forecast.\n2. Reconcile them line by line and mark every variance over 5%.\n3. Write up the three biggest gaps with the numbers behind each." }] },
|
|
1623
|
-
};
|
|
1624
|
-
|
|
1625
|
-
test("posts the plan the SESSION stated, once", async () => {
|
|
1626
|
-
const t = fakeTransport();
|
|
1627
|
-
const r = await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX, tier: "plan" });
|
|
1628
|
-
const res = await notePlan({ key: r.key, firstTurn: FIRST_TURN, now: Date.now(), deps: { deliverImpl: t.impl } });
|
|
1629
|
-
assert.equal(res.spoke, true);
|
|
1630
|
-
assert.equal(t.sent.length, 1);
|
|
1631
|
-
assert.match(t.sent[0].text, /Pull the July ledger rows/);
|
|
1632
|
-
assert.match(t.sent[0].text, /three biggest gaps/);
|
|
1633
|
-
assert.equal(readObligation(r.key).interimSaid, true);
|
|
1634
|
-
|
|
1635
|
-
const again = await notePlan({ key: r.key, firstTurn: FIRST_TURN, now: Date.now(), deps: { deliverImpl: t.impl } });
|
|
1636
|
-
assert.equal(again.spoke, false);
|
|
1637
|
-
assert.equal(again.reason, "interim-already-said");
|
|
1638
|
-
assert.equal(t.sent.length, 1, "one message, and it is the plan");
|
|
1639
|
-
});
|
|
1640
|
-
|
|
1641
|
-
test("NEVER FABRICATES: no stated plan ⇒ nothing is posted", async () => {
|
|
1642
|
-
const t = fakeTransport();
|
|
1643
|
-
const r = await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX, tier: "plan" });
|
|
1644
|
-
for (const turn of [null, undefined, "", "Sure, happy to look at that.", { type: "result" }, "{truncated"]) {
|
|
1645
|
-
const res = await notePlan({ key: r.key, firstTurn: turn, now: Date.now(), deps: { deliverImpl: t.impl } });
|
|
1646
|
-
assert.equal(res.spoke, false, `${JSON.stringify(turn)} must post nothing`);
|
|
1647
|
-
assert.equal(res.reason, "no-plan-stated");
|
|
1648
|
-
}
|
|
1649
|
-
assert.equal(t.sent.length, 0);
|
|
1650
|
-
assert.equal(readObligation(r.key).interimSaid, false, "and the latch is left unspent, so the sweep's one interim is still available");
|
|
1651
|
-
});
|
|
1652
|
-
|
|
1653
|
-
test("the ROOM budget outranks the plan tier too", async () => {
|
|
1654
|
-
const t = fakeTransport();
|
|
1655
|
-
const a = await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX, tier: "plan" });
|
|
1656
|
-
const b = await openAndAcknowledge({ item: { ...ITEM, id: "p2", raw_ref: "cohort:same-room:p2" }, classResult: CLASS_COMPLEX, tier: "plan" });
|
|
1657
|
-
assert.equal((await notePlan({ key: a.key, firstTurn: FIRST_TURN, now: Date.now(), deps: { deliverImpl: t.impl } })).spoke, true);
|
|
1658
|
-
const second = await notePlan({ key: b.key, firstTurn: FIRST_TURN, now: Date.now(), deps: { deliverImpl: t.impl } });
|
|
1659
|
-
assert.equal(second.spoke, false);
|
|
1660
|
-
assert.equal(second.reason, "room-budget-spent");
|
|
1661
|
-
assert.equal(t.sent.length, 1);
|
|
1662
|
-
});
|
|
1663
|
-
|
|
1664
|
-
test("a plan-tier debt is NEVER silent, even though no caller supplies a first turn yet", async () => {
|
|
1665
|
-
// `notePlan` is exported and tested but not called by the daemon:
|
|
1666
|
-
// dispatcher.mjs spawns with `--output-format json` (not `stream-json`), so
|
|
1667
|
-
// the session's stdout arrives as one object at exit — after the answer has
|
|
1668
|
-
// gone out. Streaming is a dispatcher change and belongs with WP-2.
|
|
1669
|
-
//
|
|
1670
|
-
// What must hold in the meantime is that the plan tier degrades to the WORK
|
|
1671
|
-
// path rather than to silence. The sweep gates on `tier !== "answer"`, so it
|
|
1672
|
-
// does; this pins it, because a tier that mutes a 15-45 minute session is
|
|
1673
|
-
// the one failure this whole package exists to end.
|
|
1674
|
-
const t = fakeTransport();
|
|
1675
|
-
const r = await openAndAcknowledge({ item: { ...ITEM, id: "plan-fb", raw_ref: "cohort:planfb:1" }, classResult: CLASS_COMPLEX, tier: "plan" });
|
|
1676
|
-
const stats = await sweepObligations({
|
|
1677
|
-
now: Date.now() + ACK_AFTER_MS + 1000,
|
|
1678
|
-
deps: { deliverImpl: t.impl, spokeSinceImpl: () => false, generateAckImpl: async () => FAKE_ACK_LINE },
|
|
1679
|
-
});
|
|
1680
|
-
assert.equal(stats.acked, 1, "the plan tier falls back to the one interim, not to nothing");
|
|
1681
|
-
assert.equal(t.sent.filter((m) => m.kind === "ack").length, 1);
|
|
1682
|
-
assert.equal(readObligation(r.key).interimSaid, true, "…and it is still only one");
|
|
1683
|
-
});
|
|
1684
|
-
|
|
1685
|
-
test("a work-tier or answer-tier debt is never given a plan", async () => {
|
|
1686
|
-
const t = fakeTransport();
|
|
1687
|
-
for (const tier of ["work", "answer"]) {
|
|
1688
|
-
const r = await openAndAcknowledge({ item: { ...ITEM, id: `t-${tier}`, raw_ref: `cohort:tier:${tier}` }, classResult: CLASS_COMPLEX, tier });
|
|
1689
|
-
const res = await notePlan({ key: r.key, firstTurn: FIRST_TURN, now: Date.now(), deps: { deliverImpl: t.impl } });
|
|
1690
|
-
assert.equal(res.spoke, false);
|
|
1691
|
-
assert.equal(res.reason, `tier-${tier}`);
|
|
1692
|
-
}
|
|
1693
|
-
assert.equal(t.sent.length, 0);
|
|
1694
|
-
});
|
|
1695
|
-
|
|
1696
|
-
test("a plan posted, then the session answers — the debt discharges with no extra word", async () => {
|
|
1697
|
-
const t = fakeTransport();
|
|
1698
|
-
const r = await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX, tier: "plan" });
|
|
1699
|
-
await notePlan({ key: r.key, firstTurn: FIRST_TURN, now: Date.now(), deps: { deliverImpl: t.impl } });
|
|
1700
|
-
const before = t.sent.length;
|
|
1701
|
-
const settled = await settleSession({ key: r.key, ok: true, code: 0, deps: { deliverImpl: t.impl, spokeForImpl: () => ({ heard: true, basis: "receipt" }) } });
|
|
1702
|
-
assert.equal(settled.verdict, "answered-by-session");
|
|
1703
|
-
assert.equal(t.sent.length, before, "the daemon adds nothing on top of the session's own answer");
|
|
1704
|
-
});
|
|
1705
|
-
});
|
|
1706
|
-
|
|
1707
|
-
describe("COPY: no promise without a mechanism", async () => {
|
|
1708
|
-
// §5.1.5. An interim may not contain a future-tense commitment unless a
|
|
1709
|
-
// DURABLE obligation exists and the sweep can close it. The two deleted
|
|
1710
|
-
// progress lines broke this twice over: they promised ("I'll come back as
|
|
1711
|
-
// soon as I've got something") from a timer that had no idea whether anything
|
|
1712
|
-
// would ever come back, and 272 acknowledgements proved the promise empty.
|
|
1713
|
-
// IMPORTED, never re-typed. The sibling test one describe-block up exists to
|
|
1714
|
-
// stop the ack prompt and its filter drifting apart; a copy test that
|
|
1715
|
-
// re-declares the pattern it is checking has the identical defect. The local
|
|
1716
|
-
// copy this replaces had already drifted: it was missing the `revert`
|
|
1717
|
-
// alternative and the whole `i will (come back|...)` branch, so
|
|
1718
|
-
// "I will report back once the reconciliation is done" was dropped by the
|
|
1719
|
-
// implementation and would have PASSED the test's own check.
|
|
1720
|
-
const { FORWARD_PROMISE } = await import("../../lib/assurance/plan-note.mjs");
|
|
1721
|
-
|
|
1722
|
-
test("the deleted progress lines are gone from the module entirely", () => {
|
|
1723
|
-
assert.equal(typeof assurance.composeProgress, "undefined", "composeProgress may not be reintroduced");
|
|
1724
|
-
assert.equal(typeof assurance.PROGRESS_AFTER_MS, "undefined");
|
|
1725
|
-
assert.equal(typeof assurance.PROGRESS_EVERY_MS, "undefined");
|
|
1726
|
-
assert.equal(typeof assurance.PROGRESS_MAX, "undefined");
|
|
1727
|
-
});
|
|
1728
|
-
|
|
1729
|
-
test("…and the retired strings are still RECOGNISED, so a peer running older code cannot restart the loop", () => {
|
|
1730
|
-
for (const line of RETIRED_NARRATOR_TEMPLATES) {
|
|
1731
|
-
assert.equal(isOwnNarratorText(line), true, `"${line}" must still be recognised as machinery talking`);
|
|
1732
|
-
}
|
|
1733
|
-
});
|
|
1734
|
-
|
|
1735
|
-
test("every interim the system can emit is promise-free", async () => {
|
|
1736
|
-
// The plan note is the only composed interim left; the other is the
|
|
1737
|
-
// model-generated ack, which the sanitiser governs. Both are checked.
|
|
1738
|
-
const { renderPlanNote } = await import("../../lib/assurance/plan-note.mjs");
|
|
1739
|
-
const note = renderPlanNote("1. Pull the ledger rows for July.\n2. Reconcile them against the signed forecast.\n3. I'll come back as soon as I've got something.");
|
|
1740
|
-
assert.ok(note);
|
|
1741
|
-
assert.doesNotMatch(note, FORWARD_PROMISE, "a plan bullet carries no durable backing and may not promise a return");
|
|
1742
|
-
|
|
1743
|
-
// A generated ack that promises is still admissible ONLY because a durable
|
|
1744
|
-
// obligation is what carries it — pinned here so the coupling is explicit.
|
|
1745
|
-
const promising = "Starting on the July reconciliation now; I'll come back with the variances.";
|
|
1746
|
-
assert.equal(sanitiseAckText(promising), promising);
|
|
1747
|
-
const t = fakeTransport();
|
|
1748
|
-
const r = await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX, tier: "work" });
|
|
1749
|
-
await sweepObligations({
|
|
1750
|
-
now: Date.now() + ACK_AFTER_MS + 1000,
|
|
1751
|
-
deps: { deliverImpl: t.impl, spokeSinceImpl: () => false, generateAckImpl: async () => promising },
|
|
1752
|
-
});
|
|
1753
|
-
assert.equal(t.sent.length, 1);
|
|
1754
|
-
const rec = readObligation(r.key);
|
|
1755
|
-
assert.equal(rec.state, "open", "the promise is backed: the debt is durable…");
|
|
1756
|
-
assert.equal(openObligations().some((o) => o.key === r.key), true, "…and the sweep can still find it and close it");
|
|
1757
|
-
});
|
|
1758
|
-
|
|
1759
|
-
test("an obligation the sweep CANNOT close never gets to promise anything", async () => {
|
|
1760
|
-
// No transport ⇒ no interim, at open time or ever. A promise into a channel
|
|
1761
|
-
// that does not exist is the purest form of the broken-promise failure.
|
|
1762
|
-
const t = fakeTransport();
|
|
1763
|
-
const TELEGRAM_ITEM = { ...ITEM, id: "TG-9", raw_ref: "telegram:9", service: "telegram", channel: null, channel_id: null };
|
|
1764
|
-
const r = await openAndAcknowledge({ item: TELEGRAM_ITEM, classResult: CLASS_COMPLEX, tier: "work", ack: false });
|
|
1765
|
-
assert.equal(readObligation(r.key).interimForbidden, true);
|
|
1766
|
-
await sweepObligations({
|
|
1767
|
-
now: Date.now() + ACK_AFTER_MS + 1000,
|
|
1768
|
-
deps: { deliverImpl: t.impl, spokeSinceImpl: () => false, generateAckImpl: async () => "I'll come back as soon as I've got something." },
|
|
1769
|
-
});
|
|
1770
|
-
assert.equal(t.sent.length, 0);
|
|
1771
|
-
});
|
|
1772
|
-
});
|
|
1773
|
-
|
|
1774
|
-
describe("the answer tier is silent even when a caller gets the flag wrong", () => {
|
|
1775
|
-
test("a record whose TIER is answer never produces an interim, ack:true or not", async () => {
|
|
1776
|
-
// Defence in depth. `shouldAcknowledge` returns ack:false for the answer
|
|
1777
|
-
// tier and the daemon turns that into `interimForbidden`, but the tier is
|
|
1778
|
-
// the durable fact on the record and is what "never speak about this one"
|
|
1779
|
-
// actually means. A caller that passes the tier and forgets the flag must
|
|
1780
|
-
// not be able to re-open the flood.
|
|
1781
|
-
const t = fakeTransport();
|
|
1782
|
-
await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX, tier: "answer", ack: true });
|
|
1783
|
-
assert.equal(readObligation(ITEM.raw_ref).interimForbidden, undefined, "the flag was NOT set — this is the caller bug being defended against");
|
|
1784
|
-
const stats = await sweepObligations({
|
|
1785
|
-
now: Date.now() + ACK_AFTER_MS + 60_000,
|
|
1786
|
-
deps: { deliverImpl: t.impl, spokeSinceImpl: () => false, generateAckImpl: async () => FAKE_ACK_LINE },
|
|
1787
|
-
});
|
|
1788
|
-
assert.equal(stats.acked, 0);
|
|
1789
|
-
assert.equal(t.sent.length, 0, "the tier alone is enough to keep it quiet");
|
|
1790
|
-
});
|
|
1791
|
-
});
|