@cohortapp/agent-sdk 2.11.15 → 2.13.0
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/.env.example +37 -22
- package/README.md +2 -0
- package/bin/maestro.mjs +117 -39
- package/bin/maestro.test.mjs +175 -5
- package/docs/guides/front-door-session.md +313 -0
- package/docs/guides/mac-mini.md +100 -28
- package/docs/guides/org-onboarding.md +1 -1
- package/docs/guides/setup-wizard.md +9 -5
- package/docs/runbooks/cohort-cutover.md +11 -1
- package/docs/runbooks/mac-mini-bootstrap.md +38 -63
- package/lib/cadence-bus-requeue.test.mjs +83 -0
- package/lib/cadence-bus.mjs +43 -7
- package/lib/channels/inbox-item.mjs +59 -2
- package/lib/cli/board.mjs +285 -0
- package/lib/cli/board.test.mjs +227 -0
- package/lib/cli/design.mjs +185 -0
- package/lib/cli/design.test.mjs +270 -0
- package/lib/cli/doctor-checks.mjs +441 -0
- package/lib/cli/doctor-checks.test.mjs +336 -0
- package/lib/cli/global-setup-extras.mjs +454 -0
- package/lib/cli/global-setup-extras.test.mjs +462 -0
- package/lib/cli/inbox.mjs +304 -0
- package/lib/cli/inbox.test.mjs +230 -0
- package/lib/cli/session-ack.mjs +63 -0
- package/lib/cli/session-ack.test.mjs +63 -0
- package/lib/cli/session.mjs +760 -0
- package/lib/cli/session.test.mjs +613 -0
- package/lib/collective/global-config.mjs +209 -6
- package/lib/collective/global-config.test.mjs +145 -0
- package/lib/collective/global-skills.mjs +145 -0
- package/lib/collective/global-skills.test.mjs +126 -0
- package/lib/collective/presence.mjs +4 -3
- package/lib/collective/vendor-skills.mjs +305 -0
- package/lib/collective/vendor-skills.test.mjs +306 -0
- package/lib/comms/send-gate.mjs +115 -0
- package/lib/comms/send-gate.test.mjs +113 -0
- package/lib/design/design-md.mjs +793 -0
- package/lib/design/design-md.test.mjs +318 -0
- package/lib/design/fixtures/DESIGN.golden.md +238 -0
- package/lib/design/fixtures/PRODUCT.golden.md +67 -0
- package/lib/design/fixtures/foundation.json +133 -0
- package/lib/design/refresh-gate.mjs +154 -0
- package/lib/design/refresh-gate.test.mjs +144 -0
- package/lib/design/write.mjs +275 -0
- package/lib/design/write.test.mjs +241 -0
- package/lib/feature-init.mjs +2 -2
- package/lib/mcp/server.test.mjs +9 -4
- package/lib/model-router/spawn.test.mjs +21 -0
- package/lib/org/board-mine-cache.mjs +99 -0
- package/lib/org/board-mine-cache.test.mjs +53 -0
- package/lib/org/board.mjs +11 -0
- package/lib/org/board.test.mjs +11 -1
- package/lib/org/client.mjs +36 -0
- package/lib/org/client.test.mjs +46 -0
- package/lib/org/inbound/directedness.mjs +18 -2
- package/lib/org/inbound/directedness.test.mjs +58 -0
- package/lib/org/inbound/index.mjs +8 -1
- package/lib/org/inbound/index.test.mjs +22 -0
- package/lib/org/mesh-directives.test.mjs +110 -0
- package/lib/org/mesh.mjs +61 -1
- package/lib/org/protocol.checksum +1 -1
- package/lib/org/protocol.mjs +52 -0
- package/lib/org/protocol.test.mjs +12 -1
- package/lib/org/registry.mjs +3 -2
- package/lib/org/tool-surface.mjs +120 -0
- package/lib/org/tool-surface.test.mjs +118 -5
- package/lib/prompts/parallelism.mjs +79 -0
- package/lib/prompts/parallelism.test.mjs +177 -0
- package/lib/security/external-content.mjs +1 -1
- package/lib/security/external-content.test.mjs +17 -0
- package/lib/session/config.mjs +137 -0
- package/lib/session/config.test.mjs +92 -0
- package/lib/session/feed-core.mjs +229 -0
- package/lib/session/feed-core.test.mjs +198 -0
- package/lib/session/first-run.mjs +126 -0
- package/lib/session/first-run.test.mjs +121 -0
- package/lib/session/frontdoor.mjs +266 -0
- package/lib/session/frontdoor.test.mjs +205 -0
- package/lib/session/handoffs.mjs +295 -0
- package/lib/session/handoffs.test.mjs +183 -0
- package/lib/session/identity.mjs +220 -0
- package/lib/session/identity.test.mjs +180 -0
- package/lib/session/inbox-claims.mjs +434 -0
- package/lib/session/inbox-claims.test.mjs +286 -0
- package/lib/session/launch-args.mjs +161 -0
- package/lib/session/launch-args.test.mjs +157 -0
- package/lib/session/liveness.mjs +174 -0
- package/lib/session/liveness.test.mjs +100 -0
- package/lib/session/status-summary.mjs +172 -0
- package/lib/session/status-summary.test.mjs +118 -0
- package/lib/session-permissions.mjs +39 -3
- package/lib/session-permissions.test.mjs +20 -0
- package/lib/setup/claude-probe.mjs +161 -24
- package/lib/setup/claude-probe.test.mjs +187 -0
- package/lib/setup/sections/learning.mjs +2 -1
- package/lib/setup/sections/model.mjs +104 -24
- package/lib/setup/sections/model.test.mjs +240 -0
- package/lib/setup/sections/org.mjs +27 -2
- package/lib/setup/sections/org.test.mjs +35 -2
- package/lib/setup/sections/verify.mjs +5 -0
- package/lib/setup/state.mjs +30 -10
- package/lib/setup/state.test.mjs +24 -1
- package/lib/singleton.js +11 -3
- package/lib/singleton.test.mjs +16 -0
- package/lib/subagents/lock.mjs +1 -1
- package/lib/telemetry/collect.mjs +270 -6
- package/lib/telemetry/collect.test.mjs +196 -1
- package/lib/upgrade/global-refresh.mjs +108 -0
- package/lib/upgrade/global-refresh.test.mjs +65 -0
- package/lib/upgrade/launchd-reconcile.mjs +327 -0
- package/lib/upgrade/launchd-reconcile.test.mjs +272 -0
- package/lib/upgrade/post-steps.mjs +151 -0
- package/lib/upgrade/post-steps.test.mjs +200 -0
- package/lib/upgrade/verify.mjs +215 -0
- package/lib/upgrade/verify.test.mjs +164 -0
- package/lib/voice/outbound.mjs +3 -2
- package/lib/voice/post-call-brief.mjs +2 -1
- package/lib/voice/session-rotation.mjs +6 -1
- package/lib/voice/session-rotation.test.mjs +114 -0
- package/package.json +3 -3
- package/plugins/maestro-skills/plugin.json +25 -1
- package/plugins/maestro-skills/skills/board-work.md +63 -0
- package/plugins/maestro-skills/skills/cohort-design.md +153 -0
- package/plugins/maestro-skills/skills/inbound-triage.md +80 -0
- package/plugins/maestro-skills/skills/main-session.md +102 -0
- package/plugins/maestro-skills/skills/peer-sessions.md +65 -0
- package/plugins/maestro-skills/skills/persona-discipline.md +75 -0
- package/plugins/maestro-skills/vendor/emilkowalski/LICENSE +21 -0
- package/plugins/maestro-skills/vendor/emilkowalski/UPSTREAM.json +70 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/animate/RECIPES.md +324 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/animate/SKILL.md +199 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/animation-vocabulary/SKILL.md +173 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/apple-design/SKILL.md +282 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/emil-design-eng/SKILL.md +674 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/find-animation-opportunities/SKILL.md +132 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/AUDIT.md +115 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/PLAN-TEMPLATE.md +73 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/SKILL.md +101 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/prototype/PICKER.md +197 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/prototype/SKILL.md +90 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/review-animations/SKILL.md +112 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/review-animations/STANDARDS.md +187 -0
- package/plugins/maestro-skills/vendor/impeccable/LICENSE +191 -0
- package/plugins/maestro-skills/vendor/impeccable/NOTICE.md +11 -0
- package/plugins/maestro-skills/vendor/impeccable/SKILL.md +86 -0
- package/plugins/maestro-skills/vendor/impeccable/UPSTREAM.json +201 -0
- package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-asset-producer.md +42 -0
- package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-documenter.md +29 -0
- package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-finish-reviewer.md +43 -0
- package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-manual-edit-applier.md +97 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/adapt.md +312 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/adapt.native.md +58 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/android.md +46 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/animate.md +89 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/audit.md +136 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/audit.native.md +139 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/bolder.md +33 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/clarify.md +94 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/colorize.md +86 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/craft-floor.md +44 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/craft.md +5 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/critique.md +806 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/degraded/asset-producer.md +37 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/degraded/documenter.md +24 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/degraded/finish-reviewer.md +38 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/degraded/manual-edit-applier.md +92 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/delight.md +70 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/distill.md +111 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/doctor.md +54 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/document.md +416 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/extract.md +69 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/harden.md +336 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/hooks.md +111 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/init.md +131 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/ios.md +51 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/layout.md +84 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/live-setup.md +104 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/live.md +325 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/new-work.md +147 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/onboard.md +234 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/operate.md +61 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/optimize.md +258 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/overdrive.md +127 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/polish.md +105 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/quieter.md +99 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/routing.md +24 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/shape.md +59 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/typeset.md +80 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/visualize.md +46 -0
- package/plugins/maestro-skills/vendor/taste-skill/LICENSE +21 -0
- package/plugins/maestro-skills/vendor/taste-skill/UPSTREAM.json +37 -0
- package/plugins/maestro-skills/vendor/taste-skill/skills/minimalist-skill/SKILL.md +85 -0
- package/plugins/maestro-skills/vendor/taste-skill/skills/redesign-skill/SKILL.md +178 -0
- package/plugins/maestro-skills/vendor/taste-skill/skills/soft-skill/SKILL.md +98 -0
- package/plugins/maestro-skills/vendor/taste-skill/skills/taste-skill/SKILL.md +1206 -0
- package/plugins/maestro-skills/vendor/unlazy/LICENSE +21 -0
- package/plugins/maestro-skills/vendor/unlazy/SECURITY.md +72 -0
- package/plugins/maestro-skills/vendor/unlazy/SKILL.md +104 -0
- package/plugins/maestro-skills/vendor/unlazy/UPSTREAM.json +94 -0
- package/plugins/maestro-skills/vendor/unlazy/references/dispatch.md +82 -0
- package/plugins/maestro-skills/vendor/unlazy/references/gates.md +149 -0
- package/plugins/maestro-skills/vendor/unlazy/references/method.md +49 -0
- package/plugins/maestro-skills/vendor/unlazy/references/orchestration.md +107 -0
- package/plugins/maestro-skills/vendor/unlazy/references/parallel.md +133 -0
- package/plugins/maestro-skills/vendor/unlazy/references/token-economy.md +48 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/dispatch-check.mjs +139 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/gate-check.mjs +960 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/gate-lint.mjs +245 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/lib/check-supervisor.mjs +46 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/lib/dispatch.mjs +293 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/lib/gates.mjs +953 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/lib/process-tree.mjs +161 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/lib/regex-worker.mjs +9 -0
- package/plugins/maestro-skills/vendor/unlazy/templates/PLAN.md +116 -0
- package/plugins/maestro-skills/vendor/unlazy/templates/gates-leaf.md +51 -0
- package/plugins/maestro-skills/vendor/unlazy/templates/gates-node.md +51 -0
- package/scaffold/CLAUDE.md +24 -0
- package/scripts/ci/check-durable-write-seam.mjs +147 -0
- package/scripts/ci/check-durable-write-seam.test.mjs +90 -0
- package/scripts/ci/check-skill-packs.mjs +388 -0
- package/scripts/ci/check-skill-packs.test.mjs +495 -0
- package/scripts/ci/check.mjs +6 -0
- package/scripts/collective/hook-runner.mjs +39 -4
- package/scripts/collective/hook-runner.test.mjs +85 -2
- package/scripts/daemon/agent-daemon-board-mine.test.mjs +96 -0
- package/scripts/daemon/agent-daemon-design.test.mjs +238 -0
- package/scripts/daemon/agent-daemon-frontdoor.test.mjs +60 -0
- package/scripts/daemon/agent-daemon.mjs +249 -10
- package/scripts/daemon/agent-daemon.test.mjs +73 -0
- package/scripts/daemon/assurance-e2e.test.mjs +141 -6
- package/scripts/daemon/assurance.mjs +461 -37
- package/scripts/daemon/assurance.test.mjs +408 -43
- package/scripts/daemon/cadence-consumer-frontdoor.test.mjs +393 -0
- package/scripts/daemon/cadence-consumer.mjs +289 -89
- package/scripts/daemon/cadence-handlers.mjs +53 -0
- package/scripts/daemon/classifier.mjs +1 -1
- package/scripts/daemon/dispatcher-resume.test.mjs +166 -0
- package/scripts/daemon/dispatcher.mjs +127 -19
- package/scripts/daemon/health.mjs +12 -1
- package/scripts/daemon/inbox-deferral-session.test.mjs +49 -0
- package/scripts/daemon/inbox-deferral.mjs +6 -0
- package/scripts/daemon/lib/self-echo.mjs +201 -0
- package/scripts/daemon/lib/self-echo.test.mjs +153 -0
- package/scripts/daemon/maestro-daemon.mjs +3 -0
- package/scripts/daemon/prompt-builder.mjs +19 -3
- package/scripts/daemon/responder.mjs +51 -40
- package/scripts/daemon/sdk-version.mjs +51 -0
- package/scripts/daemon/sdk-version.test.mjs +31 -0
- package/scripts/hooks/pre-send-audit.sh +97 -4
- package/scripts/hooks/pre-send-audit.test.mjs +140 -1
- package/scripts/local-triggers/autoupdate.sh +243 -19
- package/scripts/local-triggers/autoupdate.test.mjs +518 -0
- package/scripts/local-triggers/generate-plists.sh +24 -1
- package/scripts/local-triggers/generate-plists.test.mjs +49 -11
- package/scripts/org/send-orgmail.first-contact.test.mjs +102 -0
- package/scripts/org/send-orgmail.mjs +27 -3
- package/scripts/poller/inbox-privilege-injection.test.mjs +167 -0
- package/scripts/poller/slack-poller.mjs +13 -1
- package/scripts/poller/utils.mjs +46 -1
- package/scripts/poller-launchd/install.sh +19 -11
- package/scripts/poller-launchd/install.test.mjs +243 -0
- package/scripts/poller-launchd/launchd-poller-wrapper.sh +92 -0
- package/scripts/poller-launchd/migrate.sh +66 -0
- package/scripts/poller-launchd/poller.plist.template +4 -2
- package/scripts/session/feed.mjs +237 -0
- package/scripts/session/feed.test.mjs +196 -0
- package/scripts/session/supervisor-sh.test.mjs +218 -0
- package/scripts/session/supervisor.mjs +328 -0
- package/scripts/session/supervisor.sh +141 -0
- package/scripts/session/supervisor.test.mjs +482 -0
- package/scripts/setup/configure-macos.sh +250 -55
- package/scripts/setup/configure-macos.test.mjs +306 -0
- package/scripts/setup/init-agent.sh +112 -7
- package/scripts/setup/init-agent.test.mjs +220 -1
- package/scripts/vendor/skill-packs.mjs +354 -0
- package/scripts/vendor/sync-skill-packs.mjs +242 -0
- package/scripts/vendor/sync-skill-packs.test.mjs +103 -0
- package/scripts/watchdog/memory-watchdog.sh +37 -1
- package/scripts/watchdog/memory-watchdog.test.mjs +64 -0
- package/scripts/setup/boot-claude-session.sh +0 -94
|
@@ -29,10 +29,13 @@ const receipts = await import("../../lib/comms/receipts.mjs");
|
|
|
29
29
|
|
|
30
30
|
const {
|
|
31
31
|
shouldAcknowledge,
|
|
32
|
-
|
|
32
|
+
generateAck,
|
|
33
|
+
sanitiseAckText,
|
|
33
34
|
composeProgress,
|
|
34
35
|
composeFailure,
|
|
35
36
|
composeSilentSuccess,
|
|
37
|
+
isOwnNarratorText,
|
|
38
|
+
SILENT_SUCCESS_EXCERPT_LIMIT,
|
|
36
39
|
classifyFailure,
|
|
37
40
|
openAndAcknowledge,
|
|
38
41
|
settleSession,
|
|
@@ -46,6 +49,7 @@ const {
|
|
|
46
49
|
STALE_AFTER_MS,
|
|
47
50
|
INTERRUPT_GRACE_MS,
|
|
48
51
|
ACK_MAX_ATTEMPTS,
|
|
52
|
+
ACK_GEN_MAX_FAILURES,
|
|
49
53
|
RETRY_MAX,
|
|
50
54
|
} = assurance;
|
|
51
55
|
|
|
@@ -55,7 +59,11 @@ after(() => { try { rmSync(ROOT, { recursive: true, force: true }); } catch { /*
|
|
|
55
59
|
// Fakes
|
|
56
60
|
// ---------------------------------------------------------------------------
|
|
57
61
|
|
|
58
|
-
/** A transport that records what a human would have read, and can be told to fail.
|
|
62
|
+
/** A transport that records what a human would have read, and can be told to fail.
|
|
63
|
+
* The ackSender mirrors responder.sendHoldingMessage's NEW contract exactly:
|
|
64
|
+
* a generated bespoke line on success, and `{sent:false, holdingText:null,
|
|
65
|
+
* genFailed:true}` — i.e. deliberate silence — when generation fails. */
|
|
66
|
+
const FAKE_ACK_LINE = "Give me a few minutes on the git fixes.";
|
|
59
67
|
function fakeTransport(opts = {}) {
|
|
60
68
|
const sent = [];
|
|
61
69
|
let failures = opts.failTimes || 0;
|
|
@@ -65,7 +73,8 @@ function fakeTransport(opts = {}) {
|
|
|
65
73
|
return { sent: true, via: "fake", channel: item.channel_id || item.channel };
|
|
66
74
|
};
|
|
67
75
|
return { sent, impl, ackSender: async (item, cr) => {
|
|
68
|
-
|
|
76
|
+
if (opts.genFail) return { sent: false, holdingText: null, error: "ack_generation_failed", genFailed: true };
|
|
77
|
+
const text = opts.ackText || FAKE_ACK_LINE;
|
|
69
78
|
const r = await impl(item, text, { kind: "ack" });
|
|
70
79
|
return { sent: r.sent, holdingText: text, error: r.error };
|
|
71
80
|
} };
|
|
@@ -120,52 +129,268 @@ describe("shouldAcknowledge — the simple/complex distinction", () => {
|
|
|
120
129
|
const orphan = { ...ITEM, channel_id: null, channel: null };
|
|
121
130
|
assert.equal(shouldAcknowledge({ willSpawnSession: true, item: orphan, source: "inbox" }).ack, false);
|
|
122
131
|
});
|
|
132
|
+
|
|
133
|
+
test("REGRESSION: the agent's OWN echoed message is never acknowledged (self-echo guard #2)", async () => {
|
|
134
|
+
// 25/27/30 Aug 2026: the poll lane re-ingested the agent's own outbound
|
|
135
|
+
// sends and the daemon said "Looking now." to itself — in shared DMs two
|
|
136
|
+
// agents traded canned acks at each other. Ingestion now drops echoes
|
|
137
|
+
// (agent-daemon + self-echo.mjs); this is the second, independent guard on
|
|
138
|
+
// the ack decision itself.
|
|
139
|
+
const { writeFileSync } = await import("node:fs");
|
|
140
|
+
const selfEcho = await import("./lib/self-echo.mjs");
|
|
141
|
+
mkdirSync(join(ROOT, "config"), { recursive: true });
|
|
142
|
+
writeFileSync(join(ROOT, "config", "agent.json"), JSON.stringify({
|
|
143
|
+
firstName: "Isla", fullName: "Isla Roselli", memberId: "cmqh0tcml004ihhl6zev6ocb5",
|
|
144
|
+
}));
|
|
145
|
+
selfEcho._resetSelfIdentity();
|
|
146
|
+
try {
|
|
147
|
+
// The REAL echoed shape: display name verbatim, spaces and all.
|
|
148
|
+
const echo = { ...ITEM, sender: "Isla Roselli" };
|
|
149
|
+
const v = shouldAcknowledge({ willSpawnSession: true, item: echo, source: "inbox" });
|
|
150
|
+
assert.equal(v.ack, false);
|
|
151
|
+
assert.equal(v.reason, "self-sender");
|
|
152
|
+
// The old filter's normalised form must ALSO match — it is how the echo
|
|
153
|
+
// slipped through three times.
|
|
154
|
+
assert.equal(shouldAcknowledge({ willSpawnSession: true, item: { ...ITEM, sender: "isla-roselli" }, source: "inbox" }).ack, false);
|
|
155
|
+
// And a genuine human sender still gets their acknowledgement.
|
|
156
|
+
assert.equal(shouldAcknowledge({ willSpawnSession: true, item: ITEM, source: "inbox" }).ack, true);
|
|
157
|
+
} finally {
|
|
158
|
+
rmSync(join(ROOT, "config", "agent.json"), { force: true });
|
|
159
|
+
selfEcho._resetSelfIdentity();
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
// 1b. THE DISPATCH GATE READS THE INBOUND — a seat must not ack (and its
|
|
166
|
+
// caller must not spawn) on output its own emitter class produces.
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
describe("shouldAcknowledge — the dispatch gate reads item.content", () => {
|
|
170
|
+
// A peer AGENT seat, identified structurally (seat-slug id / agent↔agent DM),
|
|
171
|
+
// never by name.
|
|
172
|
+
const PEER_ACK = {
|
|
173
|
+
...ITEM,
|
|
174
|
+
sender: "Piotr Novak",
|
|
175
|
+
sender_id: "A039",
|
|
176
|
+
content: "On it — taking a look now.",
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
test("REGRESSION: an ack-shaped one-liner from a peer agent seat gets NO ack and signals NO spawn", () => {
|
|
180
|
+
const v = shouldAcknowledge({ willSpawnSession: true, item: PEER_ACK, source: "inbox" });
|
|
181
|
+
assert.equal(v.ack, false);
|
|
182
|
+
assert.equal(v.reason, "peer-ack-shaped");
|
|
183
|
+
assert.equal(v.emitterClass, true, "the caller reads this to skip the session spawn too");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("the SHAPE matches, not a phrase list — every 'Looking now' variant from a seat is refused", () => {
|
|
187
|
+
for (const content of ["On it.", "Looking now", "Taking the git fixes now — push coming.", "Still digging into this."]) {
|
|
188
|
+
const v = shouldAcknowledge({ willSpawnSession: true, item: { ...PEER_ACK, content }, source: "inbox" });
|
|
189
|
+
assert.equal(v.ack, false, `"${content}" is ack-shaped`);
|
|
190
|
+
assert.equal(v.emitterClass, true);
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("an agent↔agent DM channel is an agent-seat signal too", () => {
|
|
195
|
+
const v = shouldAcknowledge({
|
|
196
|
+
willSpawnSession: true,
|
|
197
|
+
item: { ...ITEM, sender: "Piotr Novak", channel: "dm-a016-a039", content: "On it." },
|
|
198
|
+
source: "inbox",
|
|
199
|
+
});
|
|
200
|
+
assert.equal(v.ack, false);
|
|
201
|
+
assert.equal(v.reason, "peer-ack-shaped");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("the SAME short line from a HUMAN sender passes — shape alone is not enough", () => {
|
|
205
|
+
// No seat-slug id, no agent DM channel: treated as human, and a human's
|
|
206
|
+
// terse "On it" must never be met with silence.
|
|
207
|
+
const v = shouldAcknowledge({ willSpawnSession: true, item: { ...ITEM, content: "On it — will do." }, source: "inbox" });
|
|
208
|
+
assert.equal(v.ack, true);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("a QUESTION or a REQUEST from a peer agent seat still passes the gate", () => {
|
|
212
|
+
for (const content of [
|
|
213
|
+
"did the migration land cleanly?",
|
|
214
|
+
"please push the fix to git",
|
|
215
|
+
"can you rerun the failing suite",
|
|
216
|
+
"fix the flaky poller test and push it",
|
|
217
|
+
]) {
|
|
218
|
+
const v = shouldAcknowledge({ willSpawnSession: true, item: { ...PEER_ACK, content }, source: "inbox" });
|
|
219
|
+
assert.equal(v.ack, true, `"${content}" is substantive and must pass`);
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("an INFORMATION-BEARING terse line from a peer agent seat passes — facts, links, decisions and completions are not acks", () => {
|
|
224
|
+
// Re-review finding on d5abda3: the shape test alone consumed essentially
|
|
225
|
+
// every terse peer utterance — including decision grants and direct
|
|
226
|
+
// answers to questions this seat asked — and a false consume is silent to
|
|
227
|
+
// the sender and unrecoverable. Only commitment-shaped acks may be eaten.
|
|
228
|
+
for (const content of [
|
|
229
|
+
"Runway is 14 months.", // carries a fact (digit)
|
|
230
|
+
"Yes.", // direct answer
|
|
231
|
+
"No.", // direct answer
|
|
232
|
+
"Approved.", // decision grant
|
|
233
|
+
"Go ahead.", // decision grant
|
|
234
|
+
"LGTM.", // review verdict
|
|
235
|
+
"Ship it.", // decision grant
|
|
236
|
+
"Done.", // completion report
|
|
237
|
+
"Merged.", // completion report
|
|
238
|
+
"Deployed, all green.", // completion report
|
|
239
|
+
"Spec is at https://example.com/doc", // carries a link
|
|
240
|
+
]) {
|
|
241
|
+
const v = shouldAcknowledge({ willSpawnSession: true, item: { ...PEER_ACK, content }, source: "inbox" });
|
|
242
|
+
assert.equal(v.ack, true, `"${content}" is information-bearing and must pass`);
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("REGRESSION: the daemon's OWN narrator lines are refused — built from the real compose* strings, so the list cannot drift", () => {
|
|
247
|
+
const rec = { openedAt: Date.now() - 6 * 60_000, progressSent: 0 };
|
|
248
|
+
const narrations = [
|
|
249
|
+
composeProgress(rec, {}), // "Still on this — N minutes in. …"
|
|
250
|
+
composeProgress({ ...rec, progressSent: 1 }, {}), // "Still going — N minutes in. …"
|
|
251
|
+
composeFailure({}, { failure: { human: "the working session ended unexpectedly" }, willRetry: true }),
|
|
252
|
+
composeFailure({}, { failure: { human: "the work never came back with a result and has now outrun its time limit" }, willRetry: false }),
|
|
253
|
+
assurance.composeInterrupted(),
|
|
254
|
+
composeSilentSuccess({}, {}),
|
|
255
|
+
composeSilentSuccess({}, { finalText: "Here is what I found before it died." }),
|
|
256
|
+
];
|
|
257
|
+
for (const content of narrations) {
|
|
258
|
+
const v = shouldAcknowledge({ willSpawnSession: true, item: { ...ITEM, content }, source: "inbox" });
|
|
259
|
+
assert.equal(v.ack, false, `narrator line must be refused: "${content.slice(0, 50)}…"`);
|
|
260
|
+
assert.equal(v.reason, "own-narrator-pattern");
|
|
261
|
+
assert.equal(v.emitterClass, true);
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("a human QUOTING a narrator line inside a longer message still passes (anchored full match)", () => {
|
|
266
|
+
const content = `why did you send me this?\n\n${composeSilentSuccess({}, {})}`;
|
|
267
|
+
const v = shouldAcknowledge({ willSpawnSession: true, item: { ...ITEM, content }, source: "inbox" });
|
|
268
|
+
assert.equal(v.ack, true);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("a genuine substantive ask is untouched by the gate", () => {
|
|
272
|
+
const v = shouldAcknowledge({ willSpawnSession: true, item: ITEM, source: "inbox" });
|
|
273
|
+
assert.equal(v.ack, true);
|
|
274
|
+
assert.equal(v.reason, "session-dispatched");
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test("every refusal is AUDITED — one JSONL row, self-echo-drop style", async () => {
|
|
278
|
+
const { readFileSync, rmSync: rm } = await import("node:fs");
|
|
279
|
+
const auditPath = join(ROOT, "logs", "daemon", "dispatch-gate-drops.jsonl");
|
|
280
|
+
try { rm(auditPath, { force: true }); } catch { /* */ }
|
|
281
|
+
shouldAcknowledge({ willSpawnSession: true, item: PEER_ACK, source: "inbox" });
|
|
282
|
+
shouldAcknowledge({ willSpawnSession: true, item: { ...ITEM, content: composeSilentSuccess({}, {}) }, source: "inbox" });
|
|
283
|
+
const rows = readFileSync(auditPath, "utf-8").split("\n").filter(Boolean).map((l) => JSON.parse(l));
|
|
284
|
+
assert.equal(rows.length, 2);
|
|
285
|
+
assert.equal(rows[0].stage, "dispatch-gate");
|
|
286
|
+
assert.equal(rows[0].reason, "peer-ack-shaped");
|
|
287
|
+
assert.equal(rows[0].sender, "Piotr Novak");
|
|
288
|
+
assert.equal(rows[1].reason, "own-narrator-pattern");
|
|
289
|
+
});
|
|
123
290
|
});
|
|
124
291
|
|
|
125
292
|
// ---------------------------------------------------------------------------
|
|
126
|
-
// 2. THE ACKNOWLEDGEMENT —
|
|
293
|
+
// 2. THE ACKNOWLEDGEMENT — one bespoke generated line, or SILENCE. Never a
|
|
294
|
+
// canned rotation (CEO directive, 2026-08-30).
|
|
127
295
|
// ---------------------------------------------------------------------------
|
|
128
296
|
|
|
129
|
-
describe("
|
|
130
|
-
test("
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
assert.
|
|
135
|
-
|
|
297
|
+
describe("generateAck — bespoke line or silence, never boilerplate", () => {
|
|
298
|
+
test("the model's line is used verbatim once stripped to one clean line", async () => {
|
|
299
|
+
const a = await generateAck(ITEM, CLASS_COMPLEX, {
|
|
300
|
+
runImpl: async () => '```\n"Taking the poller fixes now — push coming."\n```',
|
|
301
|
+
});
|
|
302
|
+
assert.equal(a, "Taking the poller fixes now — push coming.");
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("REGRESSION: a failing generator yields NULL — the caller sends NOTHING, no canned fallback", async () => {
|
|
306
|
+
// The retired behaviour rotated "On it." / "Looking now." / "On it —
|
|
307
|
+
// digging in now." when convenient. The CEO's verdict: a reflex string is
|
|
308
|
+
// worse than no ack. Null is the contract for "say nothing".
|
|
309
|
+
const a = await generateAck(ITEM, CLASS_COMPLEX, {
|
|
310
|
+
runImpl: async () => { throw new Error("claude CLI spawn error: ENOENT"); },
|
|
311
|
+
});
|
|
312
|
+
assert.equal(a, null);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test("a HANGING generator is cut off at the budget and yields NULL", async () => {
|
|
316
|
+
const a = await generateAck(ITEM, CLASS_COMPLEX, {
|
|
317
|
+
timeoutMs: 25,
|
|
318
|
+
runImpl: () => new Promise(() => { /* never settles */ }),
|
|
319
|
+
});
|
|
320
|
+
assert.equal(a, null);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("unusable output — empty, essay-length, refusals, assistant filler — yields NULL", () => {
|
|
324
|
+
assert.equal(sanitiseAckText(""), null);
|
|
325
|
+
assert.equal(sanitiseAckText(" \n\n "), null);
|
|
326
|
+
assert.equal(sanitiseAckText("x".repeat(300)), null, "a paragraph is a reply, not an ack");
|
|
327
|
+
assert.equal(sanitiseAckText("I cannot help with that request."), null);
|
|
328
|
+
assert.equal(sanitiseAckText("I'm here to help! Let me know if you need anything else."), null);
|
|
136
329
|
});
|
|
137
330
|
|
|
138
|
-
test("
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
331
|
+
test("multi-line output is reduced to the FIRST usable line", () => {
|
|
332
|
+
assert.equal(
|
|
333
|
+
sanitiseAckText("Taking the poller fixes now.\nsecond line that must not leak"),
|
|
334
|
+
"Taking the poller fixes now.",
|
|
335
|
+
);
|
|
143
336
|
});
|
|
144
337
|
|
|
145
|
-
test("
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
338
|
+
test("REGRESSION: assistant-y openers are rejected — 'Sure — here it is:' is a chatbot, not a colleague", () => {
|
|
339
|
+
for (const t of [
|
|
340
|
+
"Sure — here it is:",
|
|
341
|
+
"Sure, taking a look at the git fixes.",
|
|
342
|
+
"Certainly! On the deck now.",
|
|
343
|
+
"Absolutely — picking the poller fix up.",
|
|
344
|
+
"Happy to — starting on the memo.",
|
|
345
|
+
"Of course, I'll dig into the runway numbers.",
|
|
346
|
+
]) {
|
|
347
|
+
assert.equal(sanitiseAckText(t), null, `"${t}" must be rejected`);
|
|
348
|
+
}
|
|
349
|
+
// …while a plain colleague line still passes untouched.
|
|
350
|
+
assert.equal(sanitiseAckText("Taking the git fixes now — push coming."), "Taking the git fixes now — push coming.");
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
test("REGRESSION: the canned rotation is GONE from the module, not merely bypassed", async () => {
|
|
354
|
+
const { readFileSync } = await import("node:fs");
|
|
355
|
+
const src = readFileSync(new URL("./assurance.mjs", import.meta.url), "utf-8");
|
|
356
|
+
// The three retired strings may survive in comments as history, but never
|
|
357
|
+
// as a string-literal ARRAY a code path could pick from.
|
|
358
|
+
assert.doesNotMatch(src, /\[\s*"On it\.",/, "the canned rotation array must not exist");
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
describe("composeSilentSuccess — a finished answer is delivered, not silently clipped", () => {
|
|
363
|
+
const LIMIT = SILENT_SUCCESS_EXCERPT_LIMIT;
|
|
364
|
+
|
|
365
|
+
test("a normal multi-point answer (over the old 900 cap) comes through WHOLE", () => {
|
|
366
|
+
// The exact failure mode the old cap produced: a substantive multi-point
|
|
367
|
+
// answer, ~1200 chars — comfortably over the old 900 cap, well under the new
|
|
368
|
+
// budget. The reader must get every word, with no ellipsis and no marker.
|
|
369
|
+
const answer =
|
|
370
|
+
"Queue placement. A discrete task I own lands cleanest in action-stack.yaml, " +
|
|
371
|
+
"not follow-ups.yaml — the latter is external commitments with due dates, and " +
|
|
372
|
+
"this is a capture I own, not a promise to a counterparty. ".repeat(16).trim();
|
|
373
|
+
assert.ok(answer.length > 900 && answer.length < LIMIT, "fixture must straddle old cap / new budget");
|
|
374
|
+
const out = composeSilentSuccess({}, { finalText: answer });
|
|
375
|
+
assert.ok(out.includes(answer), "the full answer must be present verbatim");
|
|
376
|
+
assert.ok(!out.includes("…"), "a whole answer carries no truncation ellipsis");
|
|
149
377
|
});
|
|
150
378
|
|
|
151
|
-
test("
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
assert.doesNotMatch(a, /CEO asking/i);
|
|
158
|
-
assert.doesNotMatch(a, /machine\/infrastructure/i);
|
|
159
|
-
assert.doesNotMatch(a, /\(.*\)/, "no parenthetical aside at all");
|
|
379
|
+
test("a genuinely huge output is bounded but truncated HONESTLY — count + resend offer, never a bare ellipsis", () => {
|
|
380
|
+
const huge = "x".repeat(LIMIT + 2000);
|
|
381
|
+
const out = composeSilentSuccess({}, { finalText: huge });
|
|
382
|
+
assert.ok(out.length < huge.length, "oversized output is bounded, not dumped whole");
|
|
383
|
+
assert.ok(out.includes(String(huge.length)), "must state the true full length so nothing is lost silently");
|
|
384
|
+
assert.match(out, /resend|full/i, "must offer a path to the dropped remainder");
|
|
160
385
|
});
|
|
161
386
|
|
|
162
|
-
test("
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
// the
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
assert.
|
|
387
|
+
test("REGRESSION: a truncated recovery note is STILL recognised as the daemon's own narrator line (self-echo gate holds)", () => {
|
|
388
|
+
// If truncation broke narrator-recognition, the daemon would re-ingest and
|
|
389
|
+
// re-ack its own rescue message — the exact self-echo failure the gate exists
|
|
390
|
+
// to prevent. The honesty marker must live inside the variable excerpt slot.
|
|
391
|
+
const huge = "y".repeat(LIMIT + 2000);
|
|
392
|
+
const truncated = composeSilentSuccess({}, { finalText: huge });
|
|
393
|
+
assert.equal(isOwnNarratorText(truncated), true, "truncated silent-success must match a narrator pattern");
|
|
169
394
|
});
|
|
170
395
|
});
|
|
171
396
|
|
|
@@ -223,6 +448,34 @@ describe("openAndAcknowledge", () => {
|
|
|
223
448
|
assert.equal(openObligations().length, 1, "the debt must be visible to the sweep");
|
|
224
449
|
});
|
|
225
450
|
|
|
451
|
+
test("REGRESSION: generation failure at open ⇒ NOTHING is sent, and the debt records why", async () => {
|
|
452
|
+
// sendHoldingMessage's new contract on a failed generation: {sent:false,
|
|
453
|
+
// holdingText:null, genFailed:true}. Silence — never a placeholder.
|
|
454
|
+
const t = fakeTransport({ genFail: true });
|
|
455
|
+
const r = await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX, deps: { ackSender: t.ackSender } });
|
|
456
|
+
assert.equal(r.acked, false);
|
|
457
|
+
assert.equal(r.ackText, null, "no text was composed — there is nothing canned to fall back to");
|
|
458
|
+
assert.equal(t.sent.length, 0, "not one byte reached the human");
|
|
459
|
+
const rec = readObligation(r.key);
|
|
460
|
+
assert.equal(rec.acknowledged, false);
|
|
461
|
+
assert.equal(rec.ackGenFailures, 1, "the failure is counted, so the sweep's retry is bounded");
|
|
462
|
+
assert.equal(rec.state, "open", "the debt survives — progress/failure notices still owe the human");
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
test("the DEFAULT ack path generates before sending — generation failure short-circuits transport entirely", async () => {
|
|
466
|
+
// No injected ackSender here: this drives openAndAcknowledge's own default,
|
|
467
|
+
// which must consult generateAck FIRST and never touch transport on null.
|
|
468
|
+
const r = await openAndAcknowledge({
|
|
469
|
+
item: ITEM,
|
|
470
|
+
classResult: CLASS_COMPLEX,
|
|
471
|
+
deps: { ackGen: { runImpl: async () => { throw new Error("model down"); } } },
|
|
472
|
+
});
|
|
473
|
+
assert.equal(r.acked, false);
|
|
474
|
+
assert.equal(r.reason, "ack-generation-failed");
|
|
475
|
+
assert.equal(r.ackText, null);
|
|
476
|
+
assert.equal(readObligation(r.key).state, "open");
|
|
477
|
+
});
|
|
478
|
+
|
|
226
479
|
test("the stored summary keeps the classifier's casing — the board row and escalations read from it", async () => {
|
|
227
480
|
// `rec.summary` feeds board-mirror's row title and the needs-attention
|
|
228
481
|
// escalation record. A summary mangled at open time would resurface there.
|
|
@@ -375,19 +628,46 @@ describe("settleSession", () => {
|
|
|
375
628
|
// ---------------------------------------------------------------------------
|
|
376
629
|
|
|
377
630
|
describe("sweepObligations", () => {
|
|
378
|
-
test("retries an acknowledgement whose send failed", async () => {
|
|
631
|
+
test("retries an acknowledgement whose send failed — with a freshly GENERATED line, not a canned one", async () => {
|
|
379
632
|
const failing = fakeTransport({ failTimes: 99 });
|
|
380
633
|
const r = await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX, deps: { ackSender: failing.ackSender } });
|
|
381
634
|
assert.equal(r.acked, false);
|
|
382
635
|
|
|
383
636
|
const working = fakeTransport();
|
|
384
637
|
const now = Date.now() + ACK_GRACE_MS + 1000;
|
|
385
|
-
const stats = await sweepObligations({ now, deps: {
|
|
638
|
+
const stats = await sweepObligations({ now, deps: {
|
|
639
|
+
deliverImpl: working.impl,
|
|
640
|
+
spokeSinceImpl: () => false,
|
|
641
|
+
generateAckImpl: async () => "Back on the git fixes now.",
|
|
642
|
+
} });
|
|
386
643
|
assert.equal(stats.acked, 1);
|
|
387
|
-
assert.
|
|
644
|
+
assert.equal(working.sent[0].text, "Back on the git fixes now.");
|
|
645
|
+
assert.doesNotMatch(working.sent[0].text, /^(on it|looking now)/i, "never the retired rotation");
|
|
388
646
|
assert.equal(readObligation(r.key).acknowledged, true);
|
|
389
647
|
});
|
|
390
648
|
|
|
649
|
+
test("REGRESSION: a failed generation in the sweep sends NOTHING — bounded, then given up on", async () => {
|
|
650
|
+
const t = fakeTransport({ genFail: true }); // the open-time generation already failed once
|
|
651
|
+
const r = await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX, deps: { ackSender: t.ackSender } });
|
|
652
|
+
assert.equal(r.acked, false);
|
|
653
|
+
|
|
654
|
+
let genCalls = 0;
|
|
655
|
+
const t0 = Date.now();
|
|
656
|
+
for (let m = 1; m <= 8; m++) {
|
|
657
|
+
await sweepObligations({ now: t0 + ACK_GRACE_MS + m * 60_000, deps: {
|
|
658
|
+
deliverImpl: t.impl,
|
|
659
|
+
spokeSinceImpl: () => false,
|
|
660
|
+
generateAckImpl: async () => { genCalls++; return null; },
|
|
661
|
+
} });
|
|
662
|
+
}
|
|
663
|
+
assert.equal(t.sent.filter((s) => s.kind === "ack").length, 0, "no canned line went out in place of the failed generation");
|
|
664
|
+
assert.ok(genCalls < ACK_GEN_MAX_FAILURES + 1, `generation retries are bounded, not once-a-tick forever (saw ${genCalls})`);
|
|
665
|
+
const rec = readObligation(r.key);
|
|
666
|
+
assert.equal(rec.ackSuppressed, true, "the ack is given up on…");
|
|
667
|
+
assert.equal(rec.state, "open", "…but the DEBT is not — progress and failure notices still speak");
|
|
668
|
+
assert.ok(t.sent.some((s) => s.kind === "progress"), "and indeed the human still hears about the long-running work");
|
|
669
|
+
});
|
|
670
|
+
|
|
391
671
|
test("long work gets an interim update, not silence", async () => {
|
|
392
672
|
const t = fakeTransport();
|
|
393
673
|
const r = await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX, deps: { ackSender: t.ackSender } });
|
|
@@ -493,6 +773,91 @@ describe("sweepObligations", () => {
|
|
|
493
773
|
assert.equal(stats.interrupted, 0);
|
|
494
774
|
});
|
|
495
775
|
|
|
776
|
+
test("REGRESSION: a debt OWNED by another LIVE daemon is not ack-retried by this one (two-daemon narration)", async () => {
|
|
777
|
+
// Two live daemons on one AGENT_DIR both sweep the same ledger. The one
|
|
778
|
+
// that did NOT open this debt must not compensate its acknowledgement —
|
|
779
|
+
// the owner is alive and will; two daemons acking is one human hearing
|
|
780
|
+
// double.
|
|
781
|
+
const failing = fakeTransport({ failTimes: 99 });
|
|
782
|
+
const r = await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX, deps: { ackSender: failing.ackSender } });
|
|
783
|
+
assert.equal(r.acked, false);
|
|
784
|
+
const rec = readObligation(r.key);
|
|
785
|
+
rec.daemonPid = process.pid + 99999;
|
|
786
|
+
const { writeFileSync } = await import("node:fs");
|
|
787
|
+
writeFileSync(join(ROOT, "state", "obligations", `${assurance.sanitiseKey(r.key)}.json`), JSON.stringify(rec));
|
|
788
|
+
|
|
789
|
+
const working = fakeTransport();
|
|
790
|
+
const stats = await sweepObligations({
|
|
791
|
+
now: Date.now() + ACK_GRACE_MS + 1000,
|
|
792
|
+
deps: {
|
|
793
|
+
deliverImpl: working.impl,
|
|
794
|
+
spokeSinceImpl: () => false,
|
|
795
|
+
generateAckImpl: async () => "Back on this now.",
|
|
796
|
+
isPidAliveImpl: () => true, // the owner is provably alive
|
|
797
|
+
},
|
|
798
|
+
});
|
|
799
|
+
assert.equal(stats.acked, 0, "the live owner narrates its own debt");
|
|
800
|
+
assert.equal(working.sent.length, 0, "not one byte from the non-owner");
|
|
801
|
+
assert.equal(readObligation(r.key).acknowledged, false);
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
test("…but once the owning daemon is DEAD, this daemon takes the ack over", async () => {
|
|
805
|
+
const failing = fakeTransport({ failTimes: 99 });
|
|
806
|
+
const r = await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX, deps: { ackSender: failing.ackSender } });
|
|
807
|
+
const rec = readObligation(r.key);
|
|
808
|
+
rec.daemonPid = process.pid + 99999;
|
|
809
|
+
const { writeFileSync } = await import("node:fs");
|
|
810
|
+
writeFileSync(join(ROOT, "state", "obligations", `${assurance.sanitiseKey(r.key)}.json`), JSON.stringify(rec));
|
|
811
|
+
|
|
812
|
+
const working = fakeTransport();
|
|
813
|
+
const stats = await sweepObligations({
|
|
814
|
+
now: Date.now() + ACK_GRACE_MS + 1000,
|
|
815
|
+
deps: {
|
|
816
|
+
deliverImpl: working.impl,
|
|
817
|
+
spokeSinceImpl: () => false,
|
|
818
|
+
generateAckImpl: async () => "Picking your ask up now.",
|
|
819
|
+
isPidAliveImpl: () => false, // the owner is gone
|
|
820
|
+
},
|
|
821
|
+
});
|
|
822
|
+
assert.equal(stats.acked, 1, "an orphaned debt is this daemon's to speak to");
|
|
823
|
+
assert.equal(readObligation(r.key).acknowledged, true);
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
test("REGRESSION: a debt OWNED by another LIVE daemon gets no progress ping from this one", async () => {
|
|
827
|
+
const t = fakeTransport();
|
|
828
|
+
const r = await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX, deps: { ackSender: t.ackSender } });
|
|
829
|
+
const rec = readObligation(r.key);
|
|
830
|
+
rec.daemonPid = process.pid + 99999;
|
|
831
|
+
const { writeFileSync } = await import("node:fs");
|
|
832
|
+
writeFileSync(join(ROOT, "state", "obligations", `${assurance.sanitiseKey(r.key)}.json`), JSON.stringify(rec));
|
|
833
|
+
|
|
834
|
+
const before = t.sent.length;
|
|
835
|
+
const stats = await sweepObligations({
|
|
836
|
+
now: Date.now() + PROGRESS_AFTER_MS + 1000,
|
|
837
|
+
deps: { deliverImpl: t.impl, spokeSinceImpl: () => false, isPidAliveImpl: () => true },
|
|
838
|
+
});
|
|
839
|
+
assert.equal(stats.progressed, 0, "the live owner sends its own progress updates");
|
|
840
|
+
assert.equal(stats.interrupted, 0, "and a live owner is certainly not 'interrupted'");
|
|
841
|
+
assert.equal(t.sent.length, before);
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
test("…and a dead owner's debt DOES get this daemon's progress ping (after the interrupted notice is done)", async () => {
|
|
845
|
+
const t = fakeTransport();
|
|
846
|
+
const r = await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX, deps: { ackSender: t.ackSender } });
|
|
847
|
+
const rec = readObligation(r.key);
|
|
848
|
+
rec.daemonPid = process.pid + 99999;
|
|
849
|
+
rec.interruptedNotifiedAt = Date.now(); // the orphan apology already went out
|
|
850
|
+
const { writeFileSync } = await import("node:fs");
|
|
851
|
+
writeFileSync(join(ROOT, "state", "obligations", `${assurance.sanitiseKey(r.key)}.json`), JSON.stringify(rec));
|
|
852
|
+
|
|
853
|
+
const stats = await sweepObligations({
|
|
854
|
+
now: Date.now() + PROGRESS_AFTER_MS + 1000,
|
|
855
|
+
deps: { deliverImpl: t.impl, spokeSinceImpl: () => false, isPidAliveImpl: () => false },
|
|
856
|
+
});
|
|
857
|
+
assert.equal(stats.progressed, 1);
|
|
858
|
+
assert.match(t.sent.at(-1).text, /still on this/i);
|
|
859
|
+
});
|
|
860
|
+
|
|
496
861
|
test("each distinct message carries a distinct idempotency suffix", async () => {
|
|
497
862
|
// hq de-dups server-side on the client message id. Two progress updates that
|
|
498
863
|
// both key on "progress" are ONE message as far as hq is concerned, so the
|
|
@@ -519,7 +884,7 @@ describe("sweepObligations", () => {
|
|
|
519
884
|
const before = t.sent.length;
|
|
520
885
|
// Old enough to qualify for BOTH the ack retry and a progress update.
|
|
521
886
|
const now = Date.now() + PROGRESS_AFTER_MS + 1000;
|
|
522
|
-
await sweepObligations({ now, deps: { deliverImpl: t.impl, spokeSinceImpl: () => false } });
|
|
887
|
+
await sweepObligations({ now, deps: { deliverImpl: t.impl, spokeSinceImpl: () => false, generateAckImpl: async () => FAKE_ACK_LINE } });
|
|
523
888
|
assert.equal(t.sent.length - before, 1, "two messages in one tick reads as a malfunction");
|
|
524
889
|
});
|
|
525
890
|
});
|
|
@@ -639,10 +1004,10 @@ describe("attribution: which debt did that message actually discharge", () => {
|
|
|
639
1004
|
assert.equal(readObligation(r.key).acknowledged, false);
|
|
640
1005
|
receipts.recordOutbound({ service: "cohort", channel: "C-PRE", kind: "reply", agentRoot: ROOT });
|
|
641
1006
|
|
|
642
|
-
const stats = await sweepObligations({ now: Date.now() + ACK_GRACE_MS + 1000, deps: { deliverImpl: t.impl } });
|
|
1007
|
+
const stats = await sweepObligations({ now: Date.now() + ACK_GRACE_MS + 1000, deps: { deliverImpl: t.impl, generateAckImpl: async () => "Finally picking your ask up now." } });
|
|
643
1008
|
assert.equal(stats.closed, 0, "no session of ours had run — that message was not our answer");
|
|
644
1009
|
assert.equal(stats.acked, 1, "instead the missing acknowledgement is finally sent");
|
|
645
|
-
assert.
|
|
1010
|
+
assert.equal(t.sent.at(-1).text, "Finally picking your ask up now.");
|
|
646
1011
|
});
|
|
647
1012
|
});
|
|
648
1013
|
|
|
@@ -690,7 +1055,7 @@ describe("channels with no transport", () => {
|
|
|
690
1055
|
const t0 = Date.now();
|
|
691
1056
|
let ticks = 0;
|
|
692
1057
|
for (let m = 1; m <= 60; m++) {
|
|
693
|
-
const s = await sweepObligations({ now: t0 + m * 60_000, deps: { deliverImpl: t.impl } });
|
|
1058
|
+
const s = await sweepObligations({ now: t0 + m * 60_000, deps: { deliverImpl: t.impl, generateAckImpl: async () => FAKE_ACK_LINE } });
|
|
694
1059
|
ticks++;
|
|
695
1060
|
if (s.unreachable) break;
|
|
696
1061
|
}
|