@cohortapp/agent-sdk 2.14.0 → 2.16.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/docs/guides/front-door-session.md +16 -5
- package/docs/guides/poller-daemon-setup.md +49 -1
- package/lib/assurance/plan-note.mjs +251 -0
- package/lib/assurance/plan-note.test.mjs +234 -0
- package/lib/assurance/room-budget.mjs +497 -0
- package/lib/assurance/room-budget.test.mjs +486 -0
- package/lib/assurance/tier.mjs +166 -0
- package/lib/assurance/tier.test.mjs +174 -0
- package/lib/comms/receipts.mjs +17 -1
- package/lib/telemetry/collect.mjs +21 -1
- package/lib/telemetry/collect.test.mjs +54 -0
- package/package.json +1 -1
- package/plugins/maestro-skills/skills/inbound-triage.md +52 -24
- package/plugins/maestro-skills/skills/main-session.md +6 -4
- package/scripts/daemon/agent-daemon.mjs +35 -7
- package/scripts/daemon/agent-daemon.test.mjs +23 -6
- package/scripts/daemon/assurance-e2e.test.mjs +75 -19
- package/scripts/daemon/assurance.mjs +663 -159
- package/scripts/daemon/assurance.test.mjs +820 -140
- package/scripts/daemon/deliver.mjs +7 -4
- package/scripts/daemon/prompt-builder.mjs +63 -4
- package/scripts/daemon/prompt-builder.test.mjs +68 -0
- package/scripts/daemon/responder.mjs +8 -3
|
@@ -31,7 +31,6 @@ const {
|
|
|
31
31
|
shouldAcknowledge,
|
|
32
32
|
generateAck,
|
|
33
33
|
sanitiseAckText,
|
|
34
|
-
composeProgress,
|
|
35
34
|
composeFailure,
|
|
36
35
|
composeSilentSuccess,
|
|
37
36
|
isOwnNarratorText,
|
|
@@ -43,9 +42,11 @@ const {
|
|
|
43
42
|
readObligation,
|
|
44
43
|
openObligations,
|
|
45
44
|
_resetObligations,
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
45
|
+
ACK_AFTER_MS,
|
|
46
|
+
ACK_MIN_CHARS,
|
|
47
|
+
RETIRED_NARRATOR_TEMPLATES,
|
|
48
|
+
notePlan,
|
|
49
|
+
NOTICE,
|
|
49
50
|
STALE_AFTER_MS,
|
|
50
51
|
INTERRUPT_GRACE_MS,
|
|
51
52
|
ACK_MAX_ATTEMPTS,
|
|
@@ -244,10 +245,14 @@ describe("shouldAcknowledge — the dispatch gate reads item.content", () => {
|
|
|
244
245
|
});
|
|
245
246
|
|
|
246
247
|
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
248
|
const narrations = [
|
|
249
|
-
|
|
250
|
-
|
|
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.",
|
|
251
256
|
composeFailure({}, { failure: { human: "the working session ended unexpectedly" }, willRetry: true }),
|
|
252
257
|
composeFailure({}, { failure: { human: "the work never came back with a result and has now outrun its time limit" }, willRetry: false }),
|
|
253
258
|
assurance.composeInterrupted(),
|
|
@@ -424,23 +429,44 @@ describe("classifyFailure", () => {
|
|
|
424
429
|
// ---------------------------------------------------------------------------
|
|
425
430
|
|
|
426
431
|
describe("openAndAcknowledge", () => {
|
|
427
|
-
test("
|
|
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.
|
|
428
437
|
const t = fakeTransport();
|
|
429
|
-
const r = await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX, service: "cohort", deps: { ackSender: t.ackSender } });
|
|
430
|
-
assert.equal(
|
|
431
|
-
assert.equal(
|
|
432
|
-
assert.equal(
|
|
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");
|
|
433
443
|
const rec = readObligation(r.key);
|
|
434
|
-
assert.equal(rec.
|
|
435
|
-
assert.equal(rec.
|
|
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");
|
|
436
447
|
});
|
|
437
448
|
|
|
438
|
-
test("
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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.
|
|
442
468
|
const t = fakeTransport({ failTimes: 99, error: "claude CLI timed out after 60000ms" });
|
|
443
|
-
const r = await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX, deps: { ackSender: t.ackSender } });
|
|
469
|
+
const r = await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX, tier: "work", deps: { ackSender: t.ackSender } });
|
|
444
470
|
assert.equal(r.acked, false);
|
|
445
471
|
const rec = readObligation(r.key);
|
|
446
472
|
assert.equal(rec.acknowledged, false);
|
|
@@ -448,32 +474,20 @@ describe("openAndAcknowledge", () => {
|
|
|
448
474
|
assert.equal(openObligations().length, 1, "the debt must be visible to the sweep");
|
|
449
475
|
});
|
|
450
476
|
|
|
451
|
-
test("REGRESSION: generation failure
|
|
452
|
-
//
|
|
453
|
-
//
|
|
454
|
-
const t = fakeTransport(
|
|
455
|
-
const r = await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX,
|
|
456
|
-
|
|
457
|
-
|
|
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
|
+
});
|
|
458
486
|
assert.equal(t.sent.length, 0, "not one byte reached the human");
|
|
459
487
|
const rec = readObligation(r.key);
|
|
460
488
|
assert.equal(rec.acknowledged, false);
|
|
461
|
-
assert.equal(rec.ackGenFailures, 1, "the failure is counted, so the
|
|
462
|
-
assert.equal(rec.state, "open", "the debt survives —
|
|
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");
|
|
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");
|
|
477
491
|
});
|
|
478
492
|
|
|
479
493
|
test("the stored summary keeps the classifier's casing — the board row and escalations read from it", async () => {
|
|
@@ -488,14 +502,42 @@ describe("openAndAcknowledge", () => {
|
|
|
488
502
|
});
|
|
489
503
|
const rec = readObligation(r.key);
|
|
490
504
|
assert.equal(rec.summary, "CEO checking in on work progress");
|
|
491
|
-
|
|
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
|
+
}
|
|
492
516
|
});
|
|
493
517
|
|
|
494
|
-
test("
|
|
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.
|
|
495
524
|
const t = fakeTransport();
|
|
496
|
-
await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX,
|
|
497
|
-
await
|
|
498
|
-
|
|
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");
|
|
499
541
|
});
|
|
500
542
|
|
|
501
543
|
test("opens NO board row of its own — there is exactly one writer per ask", async () => {
|
|
@@ -628,72 +670,134 @@ describe("settleSession", () => {
|
|
|
628
670
|
// ---------------------------------------------------------------------------
|
|
629
671
|
|
|
630
672
|
describe("sweepObligations", () => {
|
|
631
|
-
test("
|
|
632
|
-
const
|
|
633
|
-
const r = await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX,
|
|
634
|
-
|
|
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." };
|
|
635
677
|
|
|
636
|
-
const
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
generateAckImpl: async () => "Back on the git fixes now.",
|
|
642
|
-
} });
|
|
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 });
|
|
643
683
|
assert.equal(stats.acked, 1);
|
|
644
|
-
assert.equal(
|
|
645
|
-
assert.
|
|
646
|
-
assert.
|
|
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);
|
|
647
731
|
});
|
|
648
732
|
|
|
649
|
-
test("
|
|
650
|
-
const t = fakeTransport(
|
|
651
|
-
|
|
652
|
-
|
|
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" });
|
|
653
766
|
|
|
654
767
|
let genCalls = 0;
|
|
655
768
|
const t0 = Date.now();
|
|
656
769
|
for (let m = 1; m <= 8; m++) {
|
|
657
|
-
await sweepObligations({ now: t0 +
|
|
770
|
+
await sweepObligations({ now: t0 + ACK_AFTER_MS + m * 60_000, deps: {
|
|
658
771
|
deliverImpl: t.impl,
|
|
659
772
|
spokeSinceImpl: () => false,
|
|
660
773
|
generateAckImpl: async () => { genCalls++; return null; },
|
|
661
774
|
} });
|
|
662
775
|
}
|
|
663
776
|
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
|
|
777
|
+
assert.ok(genCalls <= ACK_GEN_MAX_FAILURES, `generation retries are bounded, not once-a-tick forever (saw ${genCalls})`);
|
|
665
778
|
const rec = readObligation(r.key);
|
|
666
|
-
assert.equal(rec.ackSuppressed, true, "the
|
|
667
|
-
assert.equal(rec.state, "open", "…but the DEBT is not —
|
|
668
|
-
assert.ok(t.sent.some((s) => s.kind === "progress"), "and indeed the human still hears about the long-running work");
|
|
669
|
-
});
|
|
670
|
-
|
|
671
|
-
test("long work gets an interim update, not silence", async () => {
|
|
672
|
-
const t = fakeTransport();
|
|
673
|
-
const r = await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX, deps: { ackSender: t.ackSender } });
|
|
674
|
-
const now = Date.now() + PROGRESS_AFTER_MS + 1000;
|
|
675
|
-
const stats = await sweepObligations({ now, deps: { deliverImpl: t.impl, spokeSinceImpl: () => false } });
|
|
676
|
-
assert.equal(stats.progressed, 1);
|
|
677
|
-
const msg = t.sent.at(-1).text;
|
|
678
|
-
assert.match(msg, /still on this/i);
|
|
679
|
-
assert.match(msg, /minutes in/i, "an interim update that omits how long it has been is not much of an update");
|
|
680
|
-
// Six minutes in is INSIDE the 10-20 minute window the ack just promised
|
|
681
|
-
// for opus work; claiming it here contradicts a message the same human read.
|
|
682
|
-
assert.doesNotMatch(msg, /longer than I expected/i);
|
|
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");
|
|
683
781
|
});
|
|
684
782
|
|
|
685
|
-
test("
|
|
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.
|
|
686
786
|
const t = fakeTransport();
|
|
687
|
-
await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX,
|
|
688
|
-
|
|
689
|
-
let
|
|
690
|
-
|
|
691
|
-
now
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
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);
|
|
697
801
|
});
|
|
698
802
|
|
|
699
803
|
test("work that outruns every session timeout is declared dead, not left hanging", async () => {
|
|
@@ -728,10 +832,33 @@ describe("sweepObligations", () => {
|
|
|
728
832
|
|
|
729
833
|
const stats = await sweepObligations({
|
|
730
834
|
now: Date.now() + INTERRUPT_GRACE_MS + 1000,
|
|
731
|
-
deps: { deliverImpl: t.impl, spokeSinceImpl: () => false, isPidAliveImpl: () => false },
|
|
835
|
+
deps: { deliverImpl: t.impl, spokeSinceImpl: () => false, isPidAliveImpl: () => false, generateAckImpl: async () => FAKE_ACK_LINE },
|
|
732
836
|
});
|
|
733
837
|
assert.equal(stats.interrupted, 1);
|
|
734
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");
|
|
735
862
|
});
|
|
736
863
|
|
|
737
864
|
test("REGRESSION: a LIVE daemon's obligation is never declared interrupted", async () => {
|
|
@@ -750,7 +877,7 @@ describe("sweepObligations", () => {
|
|
|
750
877
|
const before = t.sent.length;
|
|
751
878
|
const stats = await sweepObligations({
|
|
752
879
|
now: Date.now() + INTERRUPT_GRACE_MS + 1000,
|
|
753
|
-
deps: { deliverImpl: t.impl, spokeSinceImpl: () => false, isPidAliveImpl: () => true },
|
|
880
|
+
deps: { deliverImpl: t.impl, spokeSinceImpl: () => false, isPidAliveImpl: () => true, generateAckImpl: async () => FAKE_ACK_LINE },
|
|
754
881
|
});
|
|
755
882
|
assert.equal(stats.interrupted, 0, "the owning daemon is alive — its work is not interrupted");
|
|
756
883
|
assert.equal(t.sent.length, before, "and nobody is told a lie about it");
|
|
@@ -768,7 +895,7 @@ describe("sweepObligations", () => {
|
|
|
768
895
|
|
|
769
896
|
const stats = await sweepObligations({
|
|
770
897
|
now: Date.now() + 1000,
|
|
771
|
-
deps: { deliverImpl: t.impl, spokeSinceImpl: () => false, isPidAliveImpl: () => false },
|
|
898
|
+
deps: { deliverImpl: t.impl, spokeSinceImpl: () => false, isPidAliveImpl: () => false, generateAckImpl: async () => FAKE_ACK_LINE },
|
|
772
899
|
});
|
|
773
900
|
assert.equal(stats.interrupted, 0);
|
|
774
901
|
});
|
|
@@ -779,7 +906,7 @@ describe("sweepObligations", () => {
|
|
|
779
906
|
// the owner is alive and will; two daemons acking is one human hearing
|
|
780
907
|
// double.
|
|
781
908
|
const failing = fakeTransport({ failTimes: 99 });
|
|
782
|
-
const r = await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX, deps: { ackSender: failing.ackSender } });
|
|
909
|
+
const r = await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX, tier: "work", deps: { ackSender: failing.ackSender } });
|
|
783
910
|
assert.equal(r.acked, false);
|
|
784
911
|
const rec = readObligation(r.key);
|
|
785
912
|
rec.daemonPid = process.pid + 99999;
|
|
@@ -788,7 +915,7 @@ describe("sweepObligations", () => {
|
|
|
788
915
|
|
|
789
916
|
const working = fakeTransport();
|
|
790
917
|
const stats = await sweepObligations({
|
|
791
|
-
now: Date.now() +
|
|
918
|
+
now: Date.now() + ACK_AFTER_MS + 1000,
|
|
792
919
|
deps: {
|
|
793
920
|
deliverImpl: working.impl,
|
|
794
921
|
spokeSinceImpl: () => false,
|
|
@@ -799,11 +926,12 @@ describe("sweepObligations", () => {
|
|
|
799
926
|
assert.equal(stats.acked, 0, "the live owner narrates its own debt");
|
|
800
927
|
assert.equal(working.sent.length, 0, "not one byte from the non-owner");
|
|
801
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");
|
|
802
930
|
});
|
|
803
931
|
|
|
804
|
-
test("…but once the owning daemon is DEAD, this daemon takes the
|
|
932
|
+
test("…but once the owning daemon is DEAD, this daemon takes the interim over", async () => {
|
|
805
933
|
const failing = fakeTransport({ failTimes: 99 });
|
|
806
|
-
const r = await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX, deps: { ackSender: failing.ackSender } });
|
|
934
|
+
const r = await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX, tier: "work", deps: { ackSender: failing.ackSender } });
|
|
807
935
|
const rec = readObligation(r.key);
|
|
808
936
|
rec.daemonPid = process.pid + 99999;
|
|
809
937
|
const { writeFileSync } = await import("node:fs");
|
|
@@ -811,7 +939,7 @@ describe("sweepObligations", () => {
|
|
|
811
939
|
|
|
812
940
|
const working = fakeTransport();
|
|
813
941
|
const stats = await sweepObligations({
|
|
814
|
-
now: Date.now() +
|
|
942
|
+
now: Date.now() + ACK_AFTER_MS + 1000,
|
|
815
943
|
deps: {
|
|
816
944
|
deliverImpl: working.impl,
|
|
817
945
|
spokeSinceImpl: () => false,
|
|
@@ -823,27 +951,26 @@ describe("sweepObligations", () => {
|
|
|
823
951
|
assert.equal(readObligation(r.key).acknowledged, true);
|
|
824
952
|
});
|
|
825
953
|
|
|
826
|
-
test("REGRESSION: a debt OWNED by another LIVE daemon gets no
|
|
954
|
+
test("REGRESSION: a debt OWNED by another LIVE daemon gets no interim from this one", async () => {
|
|
827
955
|
const t = fakeTransport();
|
|
828
|
-
const r = await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX,
|
|
956
|
+
const r = await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX, tier: "work" });
|
|
829
957
|
const rec = readObligation(r.key);
|
|
830
958
|
rec.daemonPid = process.pid + 99999;
|
|
831
959
|
const { writeFileSync } = await import("node:fs");
|
|
832
960
|
writeFileSync(join(ROOT, "state", "obligations", `${assurance.sanitiseKey(r.key)}.json`), JSON.stringify(rec));
|
|
833
961
|
|
|
834
|
-
const before = t.sent.length;
|
|
835
962
|
const stats = await sweepObligations({
|
|
836
|
-
now: Date.now() +
|
|
837
|
-
deps: { deliverImpl: t.impl, spokeSinceImpl: () => false, isPidAliveImpl: () => true },
|
|
963
|
+
now: Date.now() + ACK_AFTER_MS + 1000,
|
|
964
|
+
deps: { deliverImpl: t.impl, spokeSinceImpl: () => false, isPidAliveImpl: () => true, generateAckImpl: async () => FAKE_ACK_LINE },
|
|
838
965
|
});
|
|
839
|
-
assert.equal(stats.
|
|
966
|
+
assert.equal(stats.acked, 0, "the live owner speaks for its own debt");
|
|
840
967
|
assert.equal(stats.interrupted, 0, "and a live owner is certainly not 'interrupted'");
|
|
841
|
-
assert.equal(t.sent.length,
|
|
968
|
+
assert.equal(t.sent.length, 0);
|
|
842
969
|
});
|
|
843
970
|
|
|
844
|
-
test("…and a dead owner's debt DOES get this daemon's
|
|
971
|
+
test("…and a dead owner's debt DOES get this daemon's interim (after the interrupted notice is done)", async () => {
|
|
845
972
|
const t = fakeTransport();
|
|
846
|
-
const r = await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX,
|
|
973
|
+
const r = await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX, tier: "work" });
|
|
847
974
|
const rec = readObligation(r.key);
|
|
848
975
|
rec.daemonPid = process.pid + 99999;
|
|
849
976
|
rec.interruptedNotifiedAt = Date.now(); // the orphan apology already went out
|
|
@@ -851,41 +978,43 @@ describe("sweepObligations", () => {
|
|
|
851
978
|
writeFileSync(join(ROOT, "state", "obligations", `${assurance.sanitiseKey(r.key)}.json`), JSON.stringify(rec));
|
|
852
979
|
|
|
853
980
|
const stats = await sweepObligations({
|
|
854
|
-
now: Date.now() +
|
|
855
|
-
deps: { deliverImpl: t.impl, spokeSinceImpl: () => false, isPidAliveImpl: () => false },
|
|
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." },
|
|
856
983
|
});
|
|
857
|
-
assert.equal(stats.
|
|
858
|
-
assert.
|
|
984
|
+
assert.equal(stats.acked, 1);
|
|
985
|
+
assert.equal(t.sent.at(-1).text, "Back on the git fixes now.");
|
|
859
986
|
});
|
|
860
987
|
|
|
861
988
|
test("each distinct message carries a distinct idempotency suffix", async () => {
|
|
862
|
-
// hq de-dups server-side on the client message id
|
|
863
|
-
//
|
|
864
|
-
// second
|
|
865
|
-
//
|
|
866
|
-
//
|
|
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.
|
|
867
994
|
const seen = [];
|
|
868
995
|
const impl = async (item, text, o = {}) => { seen.push(o.idempotencySuffix); return { sent: true, via: "fake", channel: "c" }; };
|
|
869
|
-
const
|
|
870
|
-
await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX, deps: { ackSender: t.ackSender } });
|
|
996
|
+
const r = await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX, tier: "work" });
|
|
871
997
|
|
|
872
|
-
const deps = { deliverImpl: impl, spokeSinceImpl: () => false };
|
|
873
|
-
await sweepObligations({ now: Date.now() +
|
|
874
|
-
await
|
|
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
|
|
875
1002
|
|
|
876
|
-
assert.ok(seen.length >= 2, `expected
|
|
1003
|
+
assert.ok(seen.length >= 2, `expected several distinct sends, saw ${seen.length}`);
|
|
877
1004
|
assert.equal(new Set(seen).size, seen.length, `suffixes must be unique per message, got ${JSON.stringify(seen)}`);
|
|
878
1005
|
});
|
|
879
1006
|
|
|
880
1007
|
test("emits at most ONE message per obligation per tick", async () => {
|
|
881
|
-
const t = fakeTransport(
|
|
882
|
-
const r = await openAndAcknowledge({ item: ITEM, classResult: CLASS_COMPLEX,
|
|
883
|
-
|
|
884
|
-
const
|
|
885
|
-
|
|
886
|
-
const
|
|
887
|
-
|
|
888
|
-
|
|
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");
|
|
889
1018
|
});
|
|
890
1019
|
});
|
|
891
1020
|
|
|
@@ -910,6 +1039,67 @@ describe("delivery receipts", () => {
|
|
|
910
1039
|
);
|
|
911
1040
|
});
|
|
912
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
|
+
|
|
913
1103
|
test("an unreadable ledger reads as 'the human heard nothing', never as 'fine'", () => {
|
|
914
1104
|
// The fail-open direction is deliberate: the cost of guessing wrong this way
|
|
915
1105
|
// is one redundant message; guessing the other way is the bug being fixed.
|
|
@@ -998,15 +1188,15 @@ describe("attribution: which debt did that message actually discharge", () => {
|
|
|
998
1188
|
// A debt whose acknowledgement failed, and something unrelated posts into the
|
|
999
1189
|
// room. Closing on that leaves a human never acknowledged, never answered,
|
|
1000
1190
|
// and the debt recorded as discharged.
|
|
1001
|
-
const t = fakeTransport(
|
|
1191
|
+
const t = fakeTransport();
|
|
1002
1192
|
const solo = { ...ITEM, id: "PRE-1", raw_ref: "cohort:C-PRE:PRE-1", channel: "C-PRE", channel_id: "C-PRE" };
|
|
1003
|
-
const r = await openAndAcknowledge({ item: solo, classResult: CLASS_COMPLEX,
|
|
1004
|
-
assert.equal(readObligation(r.key).acknowledged, false);
|
|
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");
|
|
1005
1195
|
receipts.recordOutbound({ service: "cohort", channel: "C-PRE", kind: "reply", agentRoot: ROOT });
|
|
1006
1196
|
|
|
1007
|
-
const stats = await sweepObligations({ now: Date.now() +
|
|
1197
|
+
const stats = await sweepObligations({ now: Date.now() + ACK_AFTER_MS + 1000, deps: { deliverImpl: t.impl, generateAckImpl: async () => "Finally picking your ask up now." } });
|
|
1008
1198
|
assert.equal(stats.closed, 0, "no session of ours had run — that message was not our answer");
|
|
1009
|
-
assert.equal(stats.acked, 1, "instead the
|
|
1199
|
+
assert.equal(stats.acked, 1, "instead the interim is finally sent");
|
|
1010
1200
|
assert.equal(t.sent.at(-1).text, "Finally picking your ask up now.");
|
|
1011
1201
|
});
|
|
1012
1202
|
});
|
|
@@ -1109,3 +1299,493 @@ describe("a running retry is not declared dead", () => {
|
|
|
1109
1299
|
assert.match(t.sent.at(-1).text, /outrun its time limit/i);
|
|
1110
1300
|
});
|
|
1111
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
|
+
});
|