@cohortapp/agent-sdk 2.15.0 → 2.17.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.
Files changed (38) hide show
  1. package/.env.example +5 -2
  2. package/docs/guides/front-door-session.md +16 -5
  3. package/docs/guides/poller-daemon-setup.md +53 -2
  4. package/lib/assurance/plan-note.mjs +251 -0
  5. package/lib/assurance/plan-note.test.mjs +234 -0
  6. package/lib/assurance/room-budget.mjs +497 -0
  7. package/lib/assurance/room-budget.test.mjs +486 -0
  8. package/lib/assurance/tier.mjs +166 -0
  9. package/lib/assurance/tier.test.mjs +174 -0
  10. package/lib/comms/receipts.mjs +17 -1
  11. package/lib/context/budget.mjs +327 -0
  12. package/lib/context/budget.test.mjs +252 -0
  13. package/lib/context/history-scope.mjs +138 -0
  14. package/lib/context/history-scope.test.mjs +79 -0
  15. package/lib/model-router/economics.mjs +9 -0
  16. package/lib/model-router/resolve.mjs +6 -0
  17. package/lib/org/inbound/facts.mjs +4 -2
  18. package/lib/org/inbound/hydrate.mjs +555 -51
  19. package/lib/org/inbound/hydrate.test.mjs +456 -1
  20. package/package.json +3 -1
  21. package/plugins/maestro-skills/skills/inbound-triage.md +52 -24
  22. package/plugins/maestro-skills/skills/main-session.md +6 -4
  23. package/scripts/daemon/agent-daemon.mjs +35 -7
  24. package/scripts/daemon/agent-daemon.test.mjs +23 -6
  25. package/scripts/daemon/assurance-e2e.test.mjs +75 -19
  26. package/scripts/daemon/assurance.mjs +663 -159
  27. package/scripts/daemon/assurance.test.mjs +820 -140
  28. package/scripts/daemon/context-compiler.mjs +52 -21
  29. package/scripts/daemon/context-compiler.test.mjs +106 -0
  30. package/scripts/daemon/deliver.mjs +7 -4
  31. package/scripts/daemon/dispatcher-session-continuity.test.mjs +365 -0
  32. package/scripts/daemon/dispatcher.mjs +210 -9
  33. package/scripts/daemon/lib/session-router.mjs +310 -42
  34. package/scripts/daemon/lib/session-router.test.mjs +260 -1
  35. package/scripts/daemon/prompt-builder.mjs +160 -16
  36. package/scripts/daemon/prompt-builder.test.mjs +287 -7
  37. package/scripts/daemon/responder-history.test.mjs +37 -1
  38. package/scripts/daemon/responder.mjs +79 -72
@@ -55,7 +55,7 @@ import { appendFileSync, mkdirSync, readdirSync, readFileSync, renameSync, unlin
55
55
  import { spawn } from "child_process";
56
56
  import { join } from "path";
57
57
  import { deliver, deliverWithRetry, replyTargetOf, canDeliverTo } from "./deliver.mjs";
58
- import { spokeFor } from "../../lib/comms/receipts.mjs";
58
+ import { spokeFor, NON_ANSWER_KINDS } from "../../lib/comms/receipts.mjs";
59
59
  import { resultTextFromStdout } from "./session-outcomes.mjs";
60
60
  // The ack generator funnels through `claude --print` like every other daemon
61
61
  // model call (CEO directive 2026-04-27: no direct Anthropic API from the
@@ -68,6 +68,21 @@ import { isSelfSender, loadSelfIdentity } from "./lib/self-echo.mjs";
68
68
  // Fail-open observability (never throws): the ack-generation failure counter
69
69
  // below is what makes the ACK_GEN_TIMEOUT_MS operating point measurable.
70
70
  import * as counters from "../../lib/diagnostics/counters.mjs";
71
+ // ── The acknowledgement-discipline decision modules (all PURE) ───────────────
72
+ // `replyTier` decides whether this ask may make the agent speak at all;
73
+ // `claimRoomInterim` outranks it with a per-ROOM, per-SEAT budget and
74
+ // `claimRoomNotice` stops a byte-identical outcome notice repeating in one
75
+ // room; `renderPlanNote` is
76
+ // the only composer of interim copy left in the system, and carries the two
77
+ // copy rules (GENERIC_OPENER, FORWARD_PROMISE) every interim must satisfy.
78
+ import { replyTier } from "../../lib/assurance/tier.mjs";
79
+ import {
80
+ claimRoomInterim,
81
+ claimRoomNotice,
82
+ ledgerPath as roomBudgetPath,
83
+ LEDGER_BASENAME as ROOM_BUDGET_BASENAME,
84
+ } from "../../lib/assurance/room-budget.mjs";
85
+ import { renderPlanNote, GENERIC_OPENER } from "../../lib/assurance/plan-note.mjs";
71
86
 
72
87
  // ── Board mirror seams ───────────────────────────────────────────────────────
73
88
  // Imported lazily so a board module problem can never stop the daemon booting,
@@ -107,22 +122,37 @@ const AGENT_REPO_DIR = process.env.AGENT_DIR || join(new URL(".", import.meta.ur
107
122
 
108
123
  const num = (v, d) => { const n = parseInt(v, 10); return Number.isFinite(n) ? n : d; };
109
124
 
110
- /** How long after opening we expect the ack to already be out. The sweep
111
- * compensates anything still un-acked past this — the fix for "the holding
112
- * message failed and nothing ever retried it". */
113
- export const ACK_GRACE_MS = num(process.env.ASSURANCE_ACK_GRACE_MS, 45_000);
114
-
115
- /** Work that outruns this gets an interim update rather than silence. Set just
116
- * under the measured p50 session (14.7 min) so the common case is covered,
117
- * and well above the quick path so a fast answer never triggers one. */
118
- export const PROGRESS_AFTER_MS = num(process.env.ASSURANCE_PROGRESS_AFTER_MS, 5 * 60_000);
119
-
120
- /** Gap between interim updates. */
121
- export const PROGRESS_EVERY_MS = num(process.env.ASSURANCE_PROGRESS_EVERY_MS, 10 * 60_000);
125
+ /*
126
+ * `ACK_GRACE_MS` (`ASSURANCE_ACK_GRACE_MS`, 45 s) WAS HERE AND IS DELETED.
127
+ *
128
+ * It meant "how long after opening we expect the ack to already be out", which
129
+ * presupposed an ack going out AT open. Nothing is said at open any more, so
130
+ * the knob tuned a mechanism that no longer exists — and a live env var that
131
+ * silently does nothing is worse than no knob at all, because an operator sets
132
+ * it and believes they have changed something. `ACK_AFTER_MS` below is the
133
+ * threshold that replaces it, and it means the opposite thing: not "the ack is
134
+ * late" but "the ask has waited long enough to be worth a word".
135
+ */
122
136
 
123
- /** Hard cap on interim updates per obligation. "Do not spam" is a requirement,
124
- * not a nicety: two updates over 45 minutes is attentive, six is noise. */
125
- export const PROGRESS_MAX = num(process.env.ASSURANCE_PROGRESS_MAX, 2);
137
+ /**
138
+ * How long an ask waits before the agent may say ANYTHING about it.
139
+ *
140
+ * This replaces `PROGRESS_AFTER_MS` / `PROGRESS_EVERY_MS` / `PROGRESS_MAX` and
141
+ * the `composeProgress` they drove. Those emitted a message that was a pure
142
+ * function of elapsed minutes — 961 of them in fourteen days, telling a reader
143
+ * nothing the timestamp did not already say, and twice narrating the same work
144
+ * from two independent timers at the same instant.
145
+ *
146
+ * Ninety seconds is chosen against the two facts that bound it: Nielsen's
147
+ * ten-second threshold, past which a person wants evidence of work, and the
148
+ * measured session distribution (p50 14.7 min), which says most work will still
149
+ * be running when this elapses. Below it, silence is not rudeness — it is the
150
+ * typing indicator doing its job.
151
+ *
152
+ * Note the sweep runs on HEALTH_INTERVAL (60 s), so the first interim lands on
153
+ * the second tick after this elapses, not the instant it does.
154
+ */
155
+ export const ACK_AFTER_MS = num(process.env.ASSURANCE_ACK_AFTER_MS, 90_000);
126
156
 
127
157
  /** An obligation still open this long after admission has outlived every
128
158
  * session timeout in the dispatcher (45 min for opus/inbox). Past this the
@@ -171,7 +201,7 @@ export const INTERRUPT_GRACE_MS = num(process.env.ASSURANCE_INTERRUPT_GRACE_MS,
171
201
  * Budget for generating the ONE-LINE contextual acknowledgement. Tight on
172
202
  * purpose: an ack that arrives a minute late has already failed at its job,
173
203
  * and the fallback is deliberate silence (the sweep retries generation once
174
- * within ACK_GRACE_MS, then stops). NOTE: this is knowingly aggressive for a
204
+ * once past ACK_AFTER_MS, then stops). NOTE: this is knowingly aggressive for a
175
205
  * cold `claude --print` spawn — the CEO chose "occasionally silent" over
176
206
  * "reliably canned". Operators can widen it via env.
177
207
  */
@@ -187,6 +217,20 @@ export const ACK_GEN_TIMEOUT_MS = num(process.env.ASSURANCE_ACK_GEN_TIMEOUT_MS,
187
217
  */
188
218
  export const ACK_GEN_MAX_FAILURES = num(process.env.ASSURANCE_ACK_GEN_MAX_FAILURES, 2);
189
219
 
220
+ /**
221
+ * Shortest line that can still be an acknowledgement.
222
+ *
223
+ * An ack must name the thing that was asked; twenty characters is the floor
224
+ * below which it demonstrably does not. Every one of the generic openers
225
+ * measured in production — "On it.", "Checking.", "Will do.", "Got it." — is
226
+ * under it, so this is a second, phrasing-independent net under GENERIC_OPENER.
227
+ */
228
+ export const ACK_MIN_CHARS = num(process.env.ASSURANCE_ACK_MIN_CHARS, 20);
229
+
230
+ /** The tiers `lib/assurance/tier.mjs` can return; anything else is a caller bug
231
+ * and is treated as `work` — the tier that can still speak once. */
232
+ const TIER_SET = new Set(["answer", "work", "plan"]);
233
+
190
234
  // ---------------------------------------------------------------------------
191
235
  // Ledger
192
236
  // ---------------------------------------------------------------------------
@@ -235,14 +279,32 @@ export function readObligation(key) {
235
279
  catch { return null; }
236
280
  }
237
281
 
238
- /** Every obligation on disk, open and closed. */
282
+ /**
283
+ * Every obligation on disk, open and closed.
284
+ *
285
+ * NOT every `*.json` in the directory. The room-budget ledger lives at
286
+ * `state/obligations/room-budget.json` — the path the design names — and it is
287
+ * not a debt: it has no `key` and no `state`. Parsed as one it became a phantom
288
+ * record that inflated every count derived from this function and that
289
+ * `pruneObligations` tried to unlink once a minute as `unknown.json` (harmless
290
+ * only by accident, because nothing can key to "unknown"). Two guards, because
291
+ * the first is a name and names get changed: skip the ledger by basename, and
292
+ * require a usable `key` of anything that survives.
293
+ */
239
294
  export function listObligations() {
240
295
  const out = [];
241
296
  let files = [];
242
- try { files = readdirSync(obligationDir()).filter((f) => f.endsWith(".json")); }
297
+ try {
298
+ files = readdirSync(obligationDir())
299
+ .filter((f) => f.endsWith(".json") && f !== ROOM_BUDGET_BASENAME);
300
+ }
243
301
  catch { return out; }
244
302
  for (const f of files) {
245
- try { out.push(JSON.parse(readFileSync(join(obligationDir(), f), "utf-8"))); }
303
+ try {
304
+ const rec = JSON.parse(readFileSync(join(obligationDir(), f), "utf-8"));
305
+ if (!rec || typeof rec !== "object" || typeof rec.key !== "string" || !rec.key) continue;
306
+ out.push(rec);
307
+ }
246
308
  catch { /* a corrupt record must not hide the rest */ }
247
309
  }
248
310
  return out;
@@ -380,14 +442,32 @@ export function looksLikePeerAckShape(item) {
380
442
  *
381
443
  * @returns {RegExp[]}
382
444
  */
445
+ /**
446
+ * Narrator lines this daemon NO LONGER EMITS, kept verbatim so the dispatch
447
+ * gate still recognises them.
448
+ *
449
+ * `composeProgress` is deleted (see ACK_AFTER_MS), but the fleet updates one
450
+ * machine at a time and an older seat next to this one goes on emitting these
451
+ * two sentences for as long as it takes its autoupdate to land. Dropping the
452
+ * patterns with the emitter would mean this seat ACKS a peer's progress ping
453
+ * and spawns a 15-45 minute session behind it — the exact 25/27/30 Aug loop the
454
+ * gate was built to stop. They are frozen literals rather than derived strings
455
+ * precisely because nothing composes them any more.
456
+ *
457
+ * @type {readonly string[]}
458
+ */
459
+ export const RETIRED_NARRATOR_TEMPLATES = Object.freeze([
460
+ "Still on this — 1 minutes in. I'll come back as soon as I've got something.",
461
+ "Still going — 1 minutes in. I'll follow up the moment it's done.",
462
+ ]);
463
+
383
464
  let _narratorPatterns = null;
384
465
  export function narratorPatterns() {
385
466
  if (_narratorPatterns) return _narratorPatterns;
386
467
  const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
387
468
  const CAUSE = "@@CAUSE@@"; // sentinel, regex-safe, never in real output
388
469
  const templates = [
389
- composeProgress({ openedAt: 0, progressSent: 0 }, { now: 60_000 }), // "Still on this — N minutes in. …"
390
- composeProgress({ openedAt: 0, progressSent: 1 }, { now: 60_000 }), // "Still going — N minutes in. …"
470
+ ...RETIRED_NARRATOR_TEMPLATES, // no longer emitted; still recognised
391
471
  composeFailure({}, { failure: { human: CAUSE }, willRetry: true }), // "Hit a problem — …. Retrying now; …"
392
472
  composeFailure({}, { failure: { human: CAUSE }, willRetry: false }), // "Couldn't finish this — …. I've stopped retrying …"
393
473
  composeInterrupted(), // "Heads up — my session on this was interrupted …"
@@ -456,32 +536,56 @@ function logDispatchGateRefusal(item, reason) {
456
536
  * FALLBACK emits when the LLM classifier itself fails. So exactly when the
457
537
  * system was degraded, a directed DM got a 15-minute session and no ack at all.
458
538
  *
539
+ * WHAT CHANGED, 2026-09-12. "A session is spawning" is still NECESSARY for an
540
+ * interim, but it is no longer SUFFICIENT, because it was the interim rate
541
+ * being equal to the session rate that produced the flood. The verdict now
542
+ * also carries a TIER (`lib/assurance/tier.mjs`), and the tier decides whether
543
+ * anything may be said at all:
544
+ *
545
+ * answer — nothing, ever. The reply is the acknowledgement.
546
+ * work — nothing now; the sweep may say ONE line after ACK_AFTER_MS, if the
547
+ * room's budget allows it.
548
+ * plan — one message, and it is the plan the session itself stated.
549
+ *
550
+ * Note what `ack: true` now MEANS: "an interim is permitted later", never "say
551
+ * something at once". No message leaves this module at open time any more.
552
+ *
459
553
  * @param {object} o
460
554
  * @param {boolean} o.willSpawnSession is a session being dispatched for this item
461
555
  * @param {object} [o.item] the inbox item
462
556
  * @param {string} [o.source] "inbox" | "backlog"
463
- * @returns {{ack:boolean, reason:string}}
557
+ * @param {object} [o.classResult] classifier verdict (answerable/action/priority)
558
+ * @param {number|null} [o.rung] routed execution rung, when one is known
559
+ * @returns {{ack:boolean, reason:string, tier:string, emitterClass?:boolean}}
464
560
  */
465
561
  export function shouldAcknowledge(o = {}) {
466
- if (!o.willSpawnSession) return { ack: false, reason: "answered-in-turn" };
467
- if (o.source && o.source !== "inbox") return { ack: false, reason: "no-waiting-human" };
562
+ const cr = o.classResult || {};
563
+ const tier = replyTier({
564
+ answerable: cr.answerable,
565
+ action: cr.action,
566
+ priority: cr.priority,
567
+ rung: Number.isFinite(o.rung) ? o.rung : (Number.isFinite(cr.rung) ? cr.rung : null),
568
+ willSpawnSession: o.willSpawnSession === true,
569
+ });
570
+ if (!o.willSpawnSession) return { ack: false, reason: "answered-in-turn", tier };
571
+ if (o.source && o.source !== "inbox") return { ack: false, reason: "no-waiting-human", tier };
468
572
  const item = o.item || {};
469
- if (!item.service) return { ack: false, reason: "no-service" };
470
- if (!replyTargetOf(item)) return { ack: false, reason: "no-reply-target" };
573
+ if (!item.service) return { ack: false, reason: "no-service", tier };
574
+ if (!replyTargetOf(item)) return { ack: false, reason: "no-reply-target", tier };
471
575
  // A service this agent has no transport for. Saying "I'm on it" into a
472
576
  // channel we cannot write to is not an acknowledgement, it is an exception
473
577
  // with a nicer name — and the sweep would then retry that impossible send
474
578
  // once a minute forever. The DEBT is still opened (see openAndAcknowledge):
475
579
  // an ask we cannot answer in public is exactly the one an operator must be
476
580
  // told about, and that is what needs-attention is for.
477
- if (!canDeliverTo(item)) return { ack: false, reason: "no-transport" };
581
+ if (!canDeliverTo(item)) return { ack: false, reason: "no-transport", tier };
478
582
  // Nobody is waiting on work the agent gave itself.
479
- if (item.self_originated === true) return { ack: false, reason: "self-originated" };
583
+ if (item.self_originated === true) return { ack: false, reason: "self-originated", tier };
480
584
  // SECOND SELF-ECHO GUARD. Ingestion (agent-daemon pollService/processItem)
481
585
  // already drops the agent's own outbound echoes; this is the belt to that
482
586
  // brace, so even an echo that arrives by a path the poll filter never saw
483
587
  // cannot make the agent say "on it" to itself (the 25/27/30 Aug loop).
484
- if (isSelfSender(item)) return { ack: false, reason: "self-sender" };
588
+ if (isSelfSender(item)) return { ack: false, reason: "self-sender", tier };
485
589
  // ── THE DISPATCH GATE READS THE INBOUND ───────────────────────────────────
486
590
  // Output of this seat's own EMITTER CLASS earns neither an ack nor a session:
487
591
  // (a) a daemon narrator line — progress / failure / interrupted / terminal —
@@ -492,13 +596,19 @@ export function shouldAcknowledge(o = {}) {
492
596
  // too. Every refusal is audited (logs/daemon/dispatch-gate-drops.jsonl).
493
597
  if (isOwnNarratorText(item.content)) {
494
598
  logDispatchGateRefusal(item, "own-narrator-pattern");
495
- return { ack: false, reason: "own-narrator-pattern", emitterClass: true };
599
+ return { ack: false, reason: "own-narrator-pattern", emitterClass: true, tier };
496
600
  }
497
601
  if (looksLikePeerAckShape(item)) {
498
602
  logDispatchGateRefusal(item, "peer-ack-shaped");
499
- return { ack: false, reason: "peer-ack-shaped", emitterClass: true };
603
+ return { ack: false, reason: "peer-ack-shaped", emitterClass: true, tier };
500
604
  }
501
- return { ack: true, reason: "session-dispatched" };
605
+ // ── THE TIER ──────────────────────────────────────────────────────────────
606
+ // The answer tier is silence by construction: the reply arrives in this turn,
607
+ // so an interim in front of it is the content-free traffic this whole change
608
+ // exists to delete. The DEBT is still opened by the caller — silence about
609
+ // timing is not silence about outcome.
610
+ if (tier === "answer") return { ack: false, reason: "tier-answer", tier };
611
+ return { ack: true, reason: "session-dispatched", tier };
502
612
  }
503
613
 
504
614
  // ---------------------------------------------------------------------------
@@ -603,6 +713,16 @@ export function sanitiseAckText(raw) {
603
713
  t = t.replace(/^["'“”](.*)["'“”]$/s, "$1").trim();
604
714
  if (!t) return null;
605
715
  if (t.length > 200) return null;
716
+ // THE PROMPT, ENFORCED. The ack system prompt has said "Never a generic 'on
717
+ // it' or 'looking into it'" since it was written; nothing checked, and 3,069
718
+ // generic acks shipped in fourteen days (28.8% of all agent messages). The
719
+ // pattern is imported from the copy-rule home rather than re-typed here, so
720
+ // the instruction and the filter cannot disagree again.
721
+ if (GENERIC_OPENER.test(t)) return null;
722
+ // Anything this short cannot be specific to what was asked, which is the one
723
+ // thing the prompt requires of it. "On it." is 6 characters; so is every
724
+ // variant that would slip past the pattern above by rephrasing.
725
+ if (t.length < ACK_MIN_CHARS) return null;
606
726
  if (/^(i can'?t|i cannot|i'?m sorry|sorry[, ]|as an ai|i am an ai|error\b)/i.test(t)) return null;
607
727
  if (/let me know if|i'?m here to help|happy to help|great question/i.test(t)) return null;
608
728
  // Assistant-y openers. "Sure — here it is:" is a chatbot clearing its throat,
@@ -711,16 +831,24 @@ function withTimeout(promise, ms) {
711
831
  ]).finally(() => clearTimeout(timer));
712
832
  }
713
833
 
714
- /** An interim update. Says how long it has been and that the work is live —
715
- * short, no internal topic aside, no filler. */
716
- export function composeProgress(rec, o = {}) {
717
- const now = Number.isFinite(o.now) ? o.now : Date.now();
718
- const mins = Math.max(1, Math.round((now - (rec.openedAt || now)) / 60_000));
719
- if ((rec.progressSent || 0) === 0) {
720
- return `Still on this — ${mins} minutes in. I'll come back as soon as I've got something.`;
721
- }
722
- return `Still going — ${mins} minutes in. I'll follow up the moment it's done.`;
723
- }
834
+ /*
835
+ * `composeProgress` WAS HERE, AND IS DELETED.
836
+ *
837
+ * It rendered "Still on this — N minutes in. I'll come back as soon as I've got
838
+ * something." from nothing but `now - openedAt`. A message that is a pure
839
+ * function of elapsed minutes cannot carry progress: it tells a reader exactly
840
+ * what the timestamp on the acknowledgement already told them, and it makes a
841
+ * promise ("I'll come back as soon as…") that 272 measured acknowledgements
842
+ * never kept. Nothing read the running session's stdout, its tool calls or its
843
+ * partial findings, so the message COULD not say anything a clock could not.
844
+ *
845
+ * 961 of them shipped in fourteen days. Two independent timers over overlapping
846
+ * work put "Still on this — 5 minutes in" beside "Still going — 15 minutes in"
847
+ * at the same timestamp in a live channel.
848
+ *
849
+ * If a genuinely long job must report, it reports CONTENT — see
850
+ * `lib/assurance/plan-note.mjs` and `notePlan` below — or it stays quiet.
851
+ */
724
852
 
725
853
  /**
726
854
  * The failure notice. Says WHAT HAPPENED and WHAT HAPPENS NEXT, in that order,
@@ -818,16 +946,152 @@ export function classifyFailure(o = {}) {
818
946
  return { transient: true, label: `exit_${code == null ? "unknown" : code}`, human: "the working session ended with an error" };
819
947
  }
820
948
 
949
+ // ---------------------------------------------------------------------------
950
+ // Notices: said once per (channel, obligationKey)
951
+ // ---------------------------------------------------------------------------
952
+
953
+ /**
954
+ * The outcome notices, as distinct things a human may be told.
955
+ *
956
+ * Note that `FAILURE_RETRYING` and `FAILURE_FINAL` are SEPARATE ids and both
957
+ * may be said. They carry different facts — "a retry is running" and "I have
958
+ * stopped" — and collapsing them into one "failure notice" would have meant a
959
+ * human who was told a retry was in flight never learned it had been abandoned.
960
+ * What is being suppressed is the REPEAT of the same fact, which is what the
961
+ * retry loop produced: the same sentence three times in one minute.
962
+ */
963
+ export const NOTICE = Object.freeze({
964
+ FAILURE_RETRYING: "failure:retrying",
965
+ FAILURE_FINAL: "failure:final",
966
+ INTERRUPTED: "interrupted",
967
+ SILENT_SUCCESS: "silent-success",
968
+ });
969
+
970
+ /**
971
+ * The dedupe key. `(channel, obligationKey, notice)` — the channel is in the
972
+ * key because the same ask re-routed to a different room genuinely has not been
973
+ * answered there.
974
+ */
975
+ function noticeId(rec, notice) {
976
+ const ch = String((rec && rec.channel) || "").trim().toLowerCase() || "unknown";
977
+ return `${notice}|${ch}`;
978
+ }
979
+
980
+ /** Has this exact notice already reached this room for this debt? */
981
+ export function noticeAlreadySaid(rec, notice) {
982
+ const map = rec && rec.notices && typeof rec.notices === "object" ? rec.notices : null;
983
+ if (!map) return false;
984
+ return Number.isFinite(map[noticeId(rec, notice)]);
985
+ }
986
+
987
+ /** Record that it has. Mutates `rec`; the caller persists. */
988
+ export function markNoticeSaid(rec, notice, now) {
989
+ if (!rec) return;
990
+ if (!rec.notices || typeof rec.notices !== "object") rec.notices = {};
991
+ rec.notices[noticeId(rec, notice)] = Number.isFinite(now) ? now : Date.now();
992
+ }
993
+
994
+ /**
995
+ * Say an outcome notice — once per debt, and never as a byte-identical repeat
996
+ * of one already on this room's screen.
997
+ *
998
+ * TWO DEDUPES, BECAUSE THEY ANSWER DIFFERENT QUESTIONS.
999
+ *
1000
+ * per (channel, obligationKey, notice) — `noticeAlreadySaid`, inherited
1001
+ * across a retry that reopens the same key. This is R4: notice → retry →
1002
+ * fresh record under the same key → notice again.
1003
+ *
1004
+ * per (room, notice, exact sentence) — `claimRoomNotice`. This is the SIBLING
1005
+ * case the first one structurally cannot reach. Twenty separate asks in one
1006
+ * channel whose sessions all die the same way are twenty separate records,
1007
+ * and `composeFailure`'s stale text contains nothing about which ask it is
1008
+ * ("Couldn't finish this — the work never came back with a result and has now
1009
+ * outrun its time limit…"), so the reader gets the same sentence twenty times
1010
+ * and cannot tell any of them apart. Verified as still-broken after the first
1011
+ * dedupe shipped: 20 obligations in one room, 20 byte-identical messages.
1012
+ * That is the sampled defect this package cites — "the same failure sentence
1013
+ * three times in one minute" — in the shape the retry-loop fix does not cover.
1014
+ *
1015
+ * WHAT IS NOT SUPPRESSED, and this is the whole safety argument. Only an
1016
+ * IDENTICAL sentence is. A different cause, a different excerpt, "a retry is
1017
+ * running" versus "I have stopped" — all distinct text, all said, at any
1018
+ * volume, because each carries a fact the reader does not have. And the
1019
+ * escalation record is written per obligation by the caller regardless, so an
1020
+ * operator still sees every dead ask in needs-attention even when the room only
1021
+ * heard about the first.
1022
+ *
1023
+ * @param {object} o
1024
+ * @param {object} o.rec the obligation record; MUTATED, caller persists
1025
+ * @param {string} o.notice a NOTICE id
1026
+ * @param {string} o.text the exact composed sentence
1027
+ * @param {object} o.item the item snapshot to deliver against
1028
+ * @param {string} o.kind delivery kind ("failure" | "notice")
1029
+ * @param {string} o.idempotencySuffix
1030
+ * @param {number} o.now
1031
+ * @param {function} o.send the delivery impl (injected by the caller)
1032
+ * @param {function} [o.roomNoticeImpl] test seam for the room-scoped dedupe
1033
+ * @returns {Promise<{sent:boolean, reason:string}>}
1034
+ */
1035
+ async function sayOutcomeNotice(o) {
1036
+ const { rec, notice, text, item, kind, now } = o;
1037
+ if (noticeAlreadySaid(rec, notice)) {
1038
+ console.log(`[assurance] ${notice} notice for ${rec.key} already delivered to ${rec.channel} — not repeating it`);
1039
+ counters.bump("assurance.notice_deduped", { notice, scope: "obligation" });
1040
+ return { sent: false, reason: "notice-already-said" };
1041
+ }
1042
+ const claim = o.roomNoticeImpl || claimRoomNotice;
1043
+ const peek = claim({
1044
+ service: rec.service, channel: rec.channel, notice, text, now,
1045
+ agentRoot: AGENT_REPO_DIR, commit: false,
1046
+ });
1047
+ if (peek && peek.allowed === false) {
1048
+ // This room already has this exact sentence on screen. Stamp the record
1049
+ // too: the fact HAS been told here, so a later tick of this same debt must
1050
+ // not re-say it once the room window rolls over.
1051
+ markNoticeSaid(rec, notice, now);
1052
+ console.log(`[assurance] ${notice} for ${rec.key} is byte-identical to one already in ${rec.channel} — not repeating it`);
1053
+ counters.bump("assurance.notice_deduped", { notice, scope: "room" });
1054
+ return { sent: false, reason: peek.reason || "room-notice-duplicate" };
1055
+ }
1056
+ const res = await o.send(item, text, { kind, idempotencySuffix: o.idempotencySuffix });
1057
+ if (res && res.sent) {
1058
+ markNoticeSaid(rec, notice, now);
1059
+ // SPEND ONLY ON A DELIVERED MESSAGE, exactly as the interim budget does: a
1060
+ // send that failed is a room that heard nothing, and recording it would
1061
+ // suppress the one message that still needed to go out.
1062
+ claim({
1063
+ service: rec.service, channel: rec.channel, notice, text, now,
1064
+ agentRoot: AGENT_REPO_DIR, commit: true,
1065
+ });
1066
+ }
1067
+ return { sent: !!(res && res.sent), reason: res && res.sent ? "sent" : ((res && res.error) || "send-failed") };
1068
+ }
1069
+
821
1070
  // ---------------------------------------------------------------------------
822
1071
  // Lifecycle
823
1072
  // ---------------------------------------------------------------------------
824
1073
 
825
1074
  /**
826
- * Open a debt and acknowledge it — in that order.
1075
+ * Open the debt. SAY NOTHING.
1076
+ *
1077
+ * This function used to be named for what it did: open a debt and acknowledge
1078
+ * it, in that order. It no longer acknowledges anything at open time, and the
1079
+ * name is kept only because every caller and every injected test seam in the
1080
+ * fleet uses it.
1081
+ *
1082
+ * WHY NOTHING IS SAID HERE ANY MORE
827
1083
  *
828
- * The record is written BEFORE the send, so a crash between the two leaves an
829
- * un-acked obligation the sweep will notice and compensate. Written after, a
830
- * crash would leave a human waiting on a debt that nothing knows exists.
1084
+ * Speaking at open time made the interim rate equal to the session rate, which
1085
+ * is what 3,069 opening acknowledgements in fourteen days actually were. An ask
1086
+ * that is answered in ninety seconds needed no holding line; an ask that takes
1087
+ * fifteen minutes is better served by ONE line at ninety seconds than by a
1088
+ * reflex at zero. So the decision moves to the sweep (branch (d)), where two
1089
+ * facts exist that do not exist here: how long the human has actually waited,
1090
+ * and how much this ROOM has already been told.
1091
+ *
1092
+ * What still happens here, unchanged, is the part that was never the problem:
1093
+ * the durable record is written before anything else, so a crash between this
1094
+ * line and the session's first token leaves a debt the sweep will find.
831
1095
  *
832
1096
  * @param {object} a
833
1097
  * @param {object} a.item
@@ -835,18 +1099,17 @@ export function classifyFailure(o = {}) {
835
1099
  * @param {string} [a.service]
836
1100
  * @param {string} [a.traceId]
837
1101
  * @param {number} [a.now]
838
- * @param {object} [a.deps] {ackSender} — `(item, classResult) => {sent, holdingText,
839
- * error}`, i.e. exactly responder.sendHoldingMessage's contract, so the
840
- * daemon's existing injected-fake seam keeps working unchanged. Defaults
841
- * to generating the line (generateAck; null ⇒ send nothing) and handing
842
- * it to transport.
843
- * @param {boolean} [a.ack=true] open the debt but say nothing. Used when there
844
- * is no transport to the requester: the debt is REAL — a session is
845
- * about to run and may die — but the compensating action is an operator
846
- * escalation, not a message into a channel that does not exist. Opening
847
- * it anyway is what turned "one failed session silently deletes the ask"
848
- * into a bounded retry with a durable trace.
849
- * @returns {Promise<{key:string|null, opened:boolean, acked:boolean, ackText:string|null, error?:string}>}
1102
+ * @param {string} [a.tier] "answer" | "work" | "plan" — from `shouldAcknowledge`.
1103
+ * Recorded on the debt; it is what the sweep consults when deciding
1104
+ * whether this obligation may ever produce an interim.
1105
+ * @param {object} [a.deps] {ackSender} — retained as a seam for callers that
1106
+ * still inject one; it is NOT called from this function any more.
1107
+ * @param {boolean} [a.ack=true] false opens the debt and forbids any interim
1108
+ * ever. Used when there is no transport to the requester: the debt is
1109
+ * REAL — a session is about to run and may die — but the compensating
1110
+ * action is an operator escalation, not a message into a channel that
1111
+ * does not exist.
1112
+ * @returns {Promise<{key:string|null, opened:boolean, acked:boolean, ackText:string|null, reason?:string, tier?:string, error?:string}>}
850
1113
  */
851
1114
  export async function openAndAcknowledge(a = {}) {
852
1115
  const item = a.item || {};
@@ -855,11 +1118,12 @@ export async function openAndAcknowledge(a = {}) {
855
1118
  const now = Number.isFinite(a.now) ? a.now : Date.now();
856
1119
  const classResult = a.classResult || {};
857
1120
  const wantAck = a.ack !== false;
1121
+ const tier = TIER_SET.has(a.tier) ? a.tier : "work";
858
1122
 
859
1123
  const existing = readObligation(key);
860
- if (existing && existing.state === "open" && existing.acknowledged) {
861
- // Already acknowledged (a re-delivery of the same item). Acknowledge ONCE.
862
- return { key, opened: false, acked: true, ackText: null, reason: "already-acknowledged" };
1124
+ if (existing && existing.state === "open" && existing.interimSaid) {
1125
+ // A re-delivery of an item this room has already been spoken to about.
1126
+ return { key, opened: false, acked: true, ackText: null, reason: "already-acknowledged", tier: existing.tier || tier };
863
1127
  }
864
1128
 
865
1129
  const rec = existing && existing.state === "open" ? existing : {
@@ -882,8 +1146,16 @@ export async function openAndAcknowledge(a = {}) {
882
1146
  ackAt: null,
883
1147
  ackAttempts: 0,
884
1148
  deliverable: canDeliverTo(item),
885
- progressSent: 0,
886
- lastProgressAt: null,
1149
+ // THE ONE-INTERIM LATCH. True once anything courtesy-shaped has reached
1150
+ // this human about this ask — an ack or a plan note — and never false
1151
+ // again. It is INHERITED below across a retry that reopens the same key,
1152
+ // which is the half R4 was missing: the old code opened a fresh record per
1153
+ // attempt and each one acked from scratch.
1154
+ interimSaid: false,
1155
+ interimAt: null,
1156
+ // Which outcome notices have already been delivered for this (channel,
1157
+ // key). Also inherited, for the same reason.
1158
+ notices: {},
887
1159
  attempts: 0,
888
1160
  lastError: null,
889
1161
  sessionId: null,
@@ -892,8 +1164,23 @@ export async function openAndAcknowledge(a = {}) {
892
1164
  state: "open",
893
1165
  item: itemSnapshot(item),
894
1166
  };
1167
+ // INHERITANCE ACROSS A RETRY. A stale/failed obligation is CLOSED, and the
1168
+ // item is then re-delivered by the next poll, which opens a brand-new record
1169
+ // under the same key. Without this the new record starts with a clean slate
1170
+ // and narrates the whole lifecycle again — the measured "same failure
1171
+ // sentence three times in one minute", and `On it.` immediately after it.
1172
+ if (existing && existing !== rec) {
1173
+ rec.interimSaid = existing.interimSaid === true;
1174
+ rec.interimAt = existing.interimAt || null;
1175
+ rec.notices = (existing.notices && typeof existing.notices === "object") ? { ...existing.notices } : {};
1176
+ }
1177
+ rec.tier = tier;
895
1178
  rec.daemonPid = process.pid;
896
1179
  if (rec.deliverable === undefined) rec.deliverable = canDeliverTo(item);
1180
+ // `ack:false` is durable on the record, not merely an argument: the sweep runs
1181
+ // in a different tick (and possibly a different process) and must be able to
1182
+ // see that this debt was never allowed to speak.
1183
+ if (!wantAck) rec.interimForbidden = true;
897
1184
  writeRecord(rec);
898
1185
 
899
1186
  // NOTE: opening the board row is NOT done here. It belongs to the caller
@@ -905,51 +1192,101 @@ export async function openAndAcknowledge(a = {}) {
905
1192
  // a private message.
906
1193
 
907
1194
  if (!wantAck || !rec.deliverable) {
908
- // The debt is on the books and the sweep will not try to speak into a
909
- // channel that does not exist (see sweepObligations branch (b)). What it
910
- // WILL do is escalate if the work then fails — which is the whole point of
911
- // opening a record we cannot acknowledge.
912
- return { key, opened: true, acked: false, ackText: null, reason: rec.deliverable ? "ack-suppressed" : "no-transport" };
1195
+ return { key, opened: true, acked: false, ackText: null, tier, reason: rec.deliverable ? "ack-suppressed" : "no-transport" };
913
1196
  }
1197
+ if (rec.interimSaid) {
1198
+ return { key, opened: true, acked: true, ackText: null, tier, reason: "interim-already-said" };
1199
+ }
1200
+ // The debt is open and an interim is PERMITTED — later, by the sweep, if the
1201
+ // human is still waiting after ACK_AFTER_MS and the room can afford it.
1202
+ return { key, opened: true, acked: false, ackText: null, tier, reason: `tier-${tier}-deferred` };
1203
+ }
914
1204
 
915
- const ackSender = (a.deps && a.deps.ackSender)
916
- || (async (i, cr) => {
917
- // GENERATE, then send — and if generation yields nothing, send NOTHING.
918
- // The canned-string fallback is gone by design; see generateAck.
919
- const t = await generateAck(i, cr, a.deps && a.deps.ackGen);
920
- if (t == null) return { sent: false, holdingText: null, error: "ack_generation_failed", genFailed: true };
921
- const r = await deliverWithRetry(i, t, { kind: "ack", ...(a.deps && a.deps.sleep ? { sleep: a.deps.sleep } : {}) });
922
- return { sent: r.sent, holdingText: t, error: r.error };
923
- });
924
- const res = await ackSender(item, classResult);
925
- const text = (res && res.holdingText) || null;
1205
+ /**
1206
+ * PLAN TIER: post the plan the session itself stated — once, or not at all.
1207
+ *
1208
+ * The only interim that is allowed to exist at full volume. The daemon calls
1209
+ * this as soon as it has the session's first assistant turn; `renderPlanNote`
1210
+ * turns that turn's stated intent into at most three bullets IN THE MODEL'S OWN
1211
+ * WORDS, or returns null. Null means post NOTHING — a rendered template over an
1212
+ * absent first turn would be a fabricated plan, which is strictly worse than
1213
+ * the generic ack it replaced, because it would be a specific lie rather than a
1214
+ * vague reflex.
1215
+ *
1216
+ * Same three gates as the sweep's interim: the tier must permit it, the debt
1217
+ * must not already have spoken (`interimSaid`), and the ROOM must be able to
1218
+ * afford it. Unlike the sweep's interim this one is not time-gated — a plan is
1219
+ * the first step of the work, not a stand-in for it, so it is worth hearing at
1220
+ * once.
1221
+ *
1222
+ * If no plan can be derived, nothing is posted here and the obligation falls
1223
+ * back to the sweep's single interim at ACK_AFTER_MS. That is not a silent
1224
+ * downgrade to a template: the fallback is the bespoke generated line, which
1225
+ * never claims to be a plan.
1226
+ *
1227
+ * NOT YET CALLED BY THE DAEMON, AND THAT IS A ROUTING FACT, NOT AN OVERSIGHT.
1228
+ * The only place a first assistant turn could come from is the dispatched
1229
+ * session's stdout, and `dispatcher.mjs` spawns with `--output-format json`
1230
+ * (not `stream-json`) precisely so the run's stdout carries the cost ledger's
1231
+ * token usage. That format yields ONE result object when the process exits — by
1232
+ * which time the session has already delivered its answer, and a "plan" posted
1233
+ * behind it would be the machine narrating work it has finished. Streaming the
1234
+ * session is a dispatcher change and belongs with WP-2's dispatcher wiring.
1235
+ *
1236
+ * Until then a plan-tier debt degrades to the WORK path — the sweep's single
1237
+ * interim at ACK_AFTER_MS, inside the room budget — because the sweep gates on
1238
+ * `tier !== "answer"`, not on `tier === "work"`. Pinned by a test, because the
1239
+ * failure that would matter is the other one: a tier that produces silence.
1240
+ *
1241
+ * @param {object} a
1242
+ * @param {string} a.key obligation key
1243
+ * @param {unknown} a.firstTurn the session's first assistant turn, in any of
1244
+ * the shapes `firstAssistantText` understands
1245
+ * @param {number} [a.now]
1246
+ * @param {object} [a.deps] {deliverImpl, roomBudgetImpl, renderImpl}
1247
+ * @returns {Promise<{spoke:boolean, text:string|null, reason:string}>}
1248
+ */
1249
+ export async function notePlan(a = {}) {
1250
+ const rec = readObligation(a.key);
1251
+ if (!rec) return { spoke: false, text: null, reason: "no-obligation" };
1252
+ if (rec.state !== "open") return { spoke: false, text: null, reason: "not-open" };
1253
+ if (rec.tier !== "plan") return { spoke: false, text: null, reason: `tier-${rec.tier || "unknown"}` };
1254
+ if (rec.interimSaid) return { spoke: false, text: null, reason: "interim-already-said" };
1255
+ if (rec.interimForbidden || rec.deliverable === false) return { spoke: false, text: null, reason: "no-transport" };
926
1256
 
927
- if (res && res.sent) {
928
- rec.acknowledged = true;
929
- rec.ackAt = Number.isFinite(a.now) ? a.now : Date.now();
1257
+ const now = Number.isFinite(a.now) ? a.now : Date.now();
1258
+ const render = (a.deps && a.deps.renderImpl) || renderPlanNote;
1259
+ const text = render(a.firstTurn);
1260
+ // NEVER FABRICATE A PLAN. No derivable plan is not a reason to say something
1261
+ // else; it is a reason to say nothing.
1262
+ if (!text) return { spoke: false, text: null, reason: "no-plan-stated" };
1263
+
1264
+ const budget = (a.deps && a.deps.roomBudgetImpl ? a.deps.roomBudgetImpl : claimRoomInterim)({
1265
+ service: rec.service, channel: rec.channel, now, agentRoot: AGENT_REPO_DIR,
1266
+ });
1267
+ if (!budget || !budget.allowed) {
1268
+ rec.interimSaid = true;
1269
+ rec.interimSuppressedAt = now;
930
1270
  writeRecord(rec);
931
- return { key, opened: true, acked: true, ackText: text };
1271
+ counters.bump("assurance.interim_suppressed", { reason: (budget && budget.reason) || "room-budget" });
1272
+ return { spoke: false, text: null, reason: (budget && budget.reason) || "room-budget-spent" };
932
1273
  }
933
- if (res && res.genFailed) {
934
- // The MODEL failed, not the channel. Deliberate silence: no message went
935
- // out and none will be faked. The debt stays open; the sweep gives
936
- // generation one more chance (bounded by ACK_GEN_MAX_FAILURES), and the
937
- // progress/failure notices still cover the human either way.
938
- rec.ackGenFailures = (rec.ackGenFailures || 0) + 1;
939
- if (rec.ackGenFailures >= ACK_GEN_MAX_FAILURES) rec.ackSuppressed = true;
940
- rec.lastError = "ack_generation_failed";
1274
+
1275
+ const send = (a.deps && a.deps.deliverImpl) || deliver;
1276
+ const res = await send(rec.item || {}, text, { kind: "plan", idempotencySuffix: "plan" });
1277
+ if (res && res.sent) {
1278
+ rec.acknowledged = true;
1279
+ rec.ackAt = now;
1280
+ rec.interimSaid = true;
1281
+ rec.interimAt = now;
941
1282
  writeRecord(rec);
942
- console.warn(`[assurance] ack for ${key} not generated — deliberate silence; debt open${rec.ackSuppressed ? ", further ack attempts suppressed" : " for one sweep retry"}`);
943
- return { key, opened: true, acked: false, ackText: null, error: rec.lastError, reason: "ack-generation-failed" };
1283
+ return { spoke: true, text, reason: "plan-posted" };
944
1284
  }
945
- // NOT fatal, and NOT forgotten: the debt stays open and un-acked, and the
946
- // sweep retries it within ACK_GRACE_MS. This is precisely the case the old
947
- // code logged and dropped.
948
- rec.lastError = (res && res.error) || "ack send failed";
949
- rec.ackAttempts = (rec.ackAttempts || 0) + 1;
1285
+ // A failed send leaves the latch OPEN: the sweep's interim branch is the
1286
+ // backstop, exactly as it is for a failed ack.
1287
+ rec.lastError = (res && res.error) || "plan send failed";
950
1288
  writeRecord(rec);
951
- console.warn(`[assurance] ack not delivered for ${key} (${rec.lastError}) — obligation left open for sweep`);
952
- return { key, opened: true, acked: false, ackText: text, error: rec.lastError };
1289
+ return { spoke: false, text, reason: rec.lastError };
953
1290
  }
954
1291
 
955
1292
  /**
@@ -1077,7 +1414,7 @@ export async function settleSession(a = {}) {
1077
1414
  obligation: rec,
1078
1415
  now,
1079
1416
  siblingOpenCount: siblingsInRoom(rec, openObligations()),
1080
- excludeKinds: ["ack", "progress", "failure"],
1417
+ excludeKinds: NON_ANSWER_KINDS,
1081
1418
  agentRoot: AGENT_REPO_DIR,
1082
1419
  // Legacy shape, for a seam injected as the older channel-scoped predicate.
1083
1420
  channel: rec.channel,
@@ -1112,7 +1449,25 @@ export async function settleSession(a = {}) {
1112
1449
  writeRecord(rec);
1113
1450
 
1114
1451
  const text = composeFailure(rec, { failure, willRetry });
1115
- const res = await send(item, text, { kind: "failure", idempotencySuffix: `failure-${attempts}` });
1452
+ // SAID ONCE PER (channel, key, notice). The retry loop is what made this
1453
+ // necessary: a transient failure posts a notice, the item is retried, a fresh
1454
+ // obligation opens under the same key, it fails the same way, and the same
1455
+ // sentence lands again — three times in one minute, as measured. The notice
1456
+ // ledger is inherited across that reopen (see openAndAcknowledge), so the
1457
+ // repeat is suppressed at the source. The two DISTINCT facts — "a retry is
1458
+ // running" and "I have stopped" — remain separately sayable.
1459
+ //
1460
+ // The sibling case — twenty separate asks in one room whose sessions all die
1461
+ // the same way, each composing the identical sentence — is covered by the
1462
+ // room-scoped half of `sayOutcomeNotice`, which the per-record ledger
1463
+ // structurally cannot reach.
1464
+ const notice = willRetry ? NOTICE.FAILURE_RETRYING : NOTICE.FAILURE_FINAL;
1465
+ const res = await sayOutcomeNotice({
1466
+ rec, notice, text, item, kind: "failure",
1467
+ idempotencySuffix: `failure-${attempts}`, now, send,
1468
+ roomNoticeImpl: a.deps && a.deps.roomNoticeImpl,
1469
+ });
1470
+ writeRecord(rec);
1116
1471
 
1117
1472
  if (willRetry) {
1118
1473
  // The debt stays OPEN and the inbox item stays un-`.processed`, so the next
@@ -1127,12 +1482,12 @@ export async function settleSession(a = {}) {
1127
1482
  // message they received minutes earlier, while the retry runs.
1128
1483
  rec.lastAttemptAt = now;
1129
1484
  writeRecord(rec);
1130
- return { spoke: !!(res && res.sent), text, verdict: `retrying:${failure.label}`, willRetry: true };
1485
+ return { spoke: res.sent, text, verdict: `retrying:${failure.label}`, willRetry: true };
1131
1486
  }
1132
1487
 
1133
- escalate(rec, { failure, now, told: !!(res && res.sent) });
1488
+ escalate(rec, { failure, now, told: res.sent });
1134
1489
  closeObligation(a.key, { outcome: "failed", now, note: failure.label });
1135
- return { spoke: !!(res && res.sent), text, verdict: `failed:${failure.label}`, willRetry: false };
1490
+ return { spoke: res.sent, text, verdict: `failed:${failure.label}`, willRetry: false };
1136
1491
  }
1137
1492
 
1138
1493
  function safeResultText(stdout) {
@@ -1191,17 +1546,57 @@ export function escalate(rec, o = {}) {
1191
1546
  * needs is on disk, so the first tick after a restart is as effective as the
1192
1547
  * hundredth tick of a healthy process.
1193
1548
  *
1549
+ * NOT RE-ENTRANT, AND ENFORCED. `agent-daemon.mjs` fires this on a 60-second
1550
+ * `setInterval` with no in-flight guard of its own, and one tick is NOT bounded
1551
+ * by 60 seconds: branch (e) peeks the room budget, then awaits `generateAck` —
1552
+ * a cold `claude --print` spawn bounded at ACK_GEN_TIMEOUT_MS — serialised per
1553
+ * obligation. A daemon holding a dozen-plus eligible obligations in distinct
1554
+ * rooms spends more than a tick in generation alone, so tick N+1 would begin
1555
+ * while tick N was still composing. Both would then peek the SAME room's budget
1556
+ * and read "allowed", because the spend is only committed after a successful
1557
+ * send. That is a read-compose-write race, and the `commit:false` peek is what
1558
+ * widened it from a read-then-write one.
1559
+ *
1560
+ * A module-scoped flag rather than a lock file: the overlap is within ONE
1561
+ * process, a lock file would add a way to wedge the sweep permanently, and the
1562
+ * cross-process case (two daemons on one AGENT_DIR) is a different exposure
1563
+ * already covered by `foreignOwnerAlive`. Reset in a `finally`, so a throw
1564
+ * cannot leave the sweep disabled for the life of the daemon; `_resetSweepGuard`
1565
+ * is the test seam.
1566
+ *
1567
+ * Skipping is NOT a lost tick. Every decision here is derived from disk and
1568
+ * from `now`, so the next tick 60 seconds later reaches exactly the same
1569
+ * conclusions about anything still owed.
1570
+ *
1194
1571
  * @param {object} [a]
1195
1572
  * @param {number} [a.now]
1196
1573
  * @param {object} [a.deps] {deliverImpl, spokeSinceImpl}
1197
- * @returns {Promise<{swept:number, acked:number, progressed:number, staled:number, closed:number}>}
1574
+ * @returns {Promise<{swept:number, acked:number, staled:number, closed:number, interrupted:number, unreachable:number, suppressed:number, skipped?:boolean}>}
1198
1575
  */
1576
+ let _sweepInFlight = false;
1577
+
1578
+ /** Test seam for the re-entrancy guard above. */
1579
+ export function _resetSweepGuard() { _sweepInFlight = false; }
1580
+
1199
1581
  export async function sweepObligations(a = {}) {
1582
+ if (_sweepInFlight) {
1583
+ counters.bump("assurance.sweep_overlap", {});
1584
+ return { swept: 0, acked: 0, staled: 0, closed: 0, interrupted: 0, unreachable: 0, suppressed: 0, skipped: true };
1585
+ }
1586
+ _sweepInFlight = true;
1587
+ try {
1588
+ return await _sweepObligations(a);
1589
+ } finally {
1590
+ _sweepInFlight = false;
1591
+ }
1592
+ }
1593
+
1594
+ async function _sweepObligations(a = {}) {
1200
1595
  const now = Number.isFinite(a.now) ? a.now : Date.now();
1201
1596
  const send = (a.deps && a.deps.deliverImpl) || deliver;
1202
1597
  const heardBy = (a.deps && (a.deps.spokeForImpl || a.deps.spokeSinceImpl)) || spokeFor;
1203
1598
  const alive = (a.deps && a.deps.isPidAliveImpl) || isPidAlive;
1204
- const stats = { swept: 0, acked: 0, progressed: 0, staled: 0, closed: 0, interrupted: 0, unreachable: 0 };
1599
+ const stats = { swept: 0, acked: 0, staled: 0, closed: 0, interrupted: 0, unreachable: 0, suppressed: 0 };
1205
1600
 
1206
1601
  const open = openObligations();
1207
1602
  for (const rec of open) {
@@ -1232,7 +1627,7 @@ export async function sweepObligations(a = {}) {
1232
1627
  obligation: rec,
1233
1628
  now,
1234
1629
  siblingOpenCount: siblingsInRoom(rec, open),
1235
- excludeKinds: ["ack", "progress", "failure"],
1630
+ excludeKinds: NON_ANSWER_KINDS,
1236
1631
  agentRoot: AGENT_REPO_DIR,
1237
1632
  channel: rec.channel,
1238
1633
  sinceMs: rec.openedAt || now,
@@ -1270,36 +1665,155 @@ export async function sweepObligations(a = {}) {
1270
1665
  if (workAge >= STALE_AFTER_MS) {
1271
1666
  const failure = { transient: false, label: "no_outcome", human: "the work never came back with a result and has now outrun its time limit" };
1272
1667
  const text = composeFailure(rec, { failure, willRetry: false });
1273
- const res = await send(item, text, { kind: "failure", idempotencySuffix: "failure-stale" });
1668
+ // SAID ONCE PER (channel, key) — AND once per identical sentence per
1669
+ // room. The retry loop used to narrate every attempt (notice → retry →
1670
+ // fresh obligation → notice), and the notice ledger inherited across the
1671
+ // reopen closes that. The OTHER shape reaches the same screen: this
1672
+ // branch's `human` text is a fixed string with nothing in it about which
1673
+ // ask died, so twenty sibling debts staling together composed twenty
1674
+ // byte-identical sentences into one room. `sayOutcomeNotice` carries both
1675
+ // dedupes; see its header for why suppressing the identical copy loses no
1676
+ // information and why a DIFFERENT notice is never suppressed.
1677
+ const res = await sayOutcomeNotice({
1678
+ rec, notice: NOTICE.FAILURE_FINAL, text, item, kind: "failure",
1679
+ idempotencySuffix: "failure-stale", now, send,
1680
+ roomNoticeImpl: a.deps && a.deps.roomNoticeImpl,
1681
+ });
1682
+ // Persist BEFORE closing: `closeObligation` re-reads the record from
1683
+ // disk, so an in-memory notice stamp that is not written is a stamp the
1684
+ // next retry will not inherit.
1685
+ writeRecord(rec);
1274
1686
  escalate(rec, { failure, now, told: !!(res && res.sent) });
1275
1687
  closeObligation(rec.key, { outcome: "failed", now, note: "stale-no-outcome" });
1276
1688
  stats.staled++;
1277
1689
  continue;
1278
1690
  }
1279
1691
 
1280
- // (d) An acknowledgement that never made it out. Compensate it — this is
1281
- // the 66%-of-the-time case that used to be logged and dropped. Bounded
1282
- // twice: ACK_MAX_ATTEMPTS above bounds failed SENDS, and
1283
- // ACK_GEN_MAX_FAILURES here bounds failed GENERATIONS — a model path that
1284
- // cannot produce the line stops being asked, the ack is given up on
1285
- // (deliberate silence, never a canned line), and the debt itself stays
1286
- // open so progress/failure notices still speak.
1287
- if (!rec.acknowledged && !rec.ackSuppressed && !foreignOwnerAlive && age >= ACK_GRACE_MS) {
1692
+ // (d) Interrupted: the debt was opened by a daemon that is no longer this
1693
+ // process, and nothing has closed it. The work died with that process.
1694
+ //
1695
+ // A pid MISMATCH alone does not establish that. Two daemons on one AGENT_DIR
1696
+ // — a manual run next to the launchd one, which is what an operator does
1697
+ // while debugging — each see the other's healthy, actively-running debts as
1698
+ // foreign. Unguarded, both tell those requesters "my session was
1699
+ // interrupted" seconds after the ask arrived, while the work runs fine
1700
+ // behind the apology. So: the owning process must be provably GONE, and the
1701
+ // debt must have sat still long enough that a live handover would have shown
1702
+ // up by now.
1703
+ if (
1704
+ rec.daemonPid && rec.daemonPid !== process.pid && !rec.interruptedNotifiedAt
1705
+ && workAge >= INTERRUPT_GRACE_MS && !alive(rec.daemonPid)
1706
+ ) {
1707
+ if (noticeAlreadySaid(rec, NOTICE.INTERRUPTED)) {
1708
+ // Said once per (channel, key), inherited across a retry that reopens
1709
+ // the same key. Two daemon restarts over one long ask are one apology.
1710
+ rec.interruptedNotifiedAt = now;
1711
+ rec.daemonPid = process.pid;
1712
+ writeRecord(rec);
1713
+ counters.bump("assurance.notice_deduped", { notice: NOTICE.INTERRUPTED });
1714
+ continue;
1715
+ }
1716
+ const text = composeInterrupted(rec);
1717
+ const res = await sayOutcomeNotice({
1718
+ rec, notice: NOTICE.INTERRUPTED, text, item, kind: "notice",
1719
+ idempotencySuffix: `interrupted-${rec.attempts || 0}`, now, send,
1720
+ roomNoticeImpl: a.deps && a.deps.roomNoticeImpl,
1721
+ });
1722
+ rec.interruptedNotifiedAt = now;
1723
+ rec.daemonPid = process.pid;
1724
+ writeRecord(rec);
1725
+ if (res.sent) stats.interrupted++;
1726
+ continue;
1727
+ }
1728
+
1729
+ // (e) THE ONE INTERIM. This is the only branch in the system that emits a
1730
+ // courtesy message, and it is gated four ways before it does:
1731
+ //
1732
+ // TIME — nothing at all before ACK_AFTER_MS. Below ninety seconds the
1733
+ // typing indicator is the acknowledgement; a message there is
1734
+ // the reflex that produced 3,069 of them in a fortnight.
1735
+ // TIER — the `answer` tier is checked HERE as well as at the call site.
1736
+ // `shouldAcknowledge` already returns ack:false for it, which the
1737
+ // daemon turns into `interimForbidden`, but the tier is durable on
1738
+ // the record and is the thing that actually means "never speak
1739
+ // about this one"; a caller that passes the tier and forgets the
1740
+ // flag must not be able to re-open the flood.
1741
+ // ONCE — `interimSaid` latches true and is inherited across a retry that
1742
+ // reopens the same key, so a retry loop cannot re-introduce
1743
+ // itself as a first contact.
1744
+ // ROOM — and above all of those, the per-(service, channel) budget:
1745
+ // at most ONE interim per room per window, however many
1746
+ // obligations are open in it — FOR THIS SEAT. The ledger lives
1747
+ // under AGENT_DIR, and there is no shared store between seats,
1748
+ // so an 8-seat room's ceiling from this gate alone is 8 × (60/15)
1749
+ // = 32/h, against a measured content-free rate of ~14/h in that
1750
+ // room. It is therefore the BACKSTOP, not the thing that removes
1751
+ // the measured flood: the deleted progress branch (961 → 0), the
1752
+ // sanitiser, and TIME+ONCE above are what do that. What this gate
1753
+ // uniquely prevents is one seat holding twenty concurrent asks in
1754
+ // one channel emitting twenty individually-defensible holding
1755
+ // lines. See room-budget.mjs's header for the full arithmetic.
1756
+ //
1757
+ // ORDERED AFTER the interrupted notice, deliberately. An OUTCOME outranks a
1758
+ // courtesy: "my session was interrupted" is a fact the human cannot get any
1759
+ // other way, and a holding line posted immediately in front of it ("I'm on
1760
+ // the git fixes") reads as the machine contradicting itself.
1761
+ //
1762
+ // Generation is still bounded twice — ACK_MAX_ATTEMPTS above bounds failed
1763
+ // SENDS, ACK_GEN_MAX_FAILURES here bounds failed GENERATIONS — and a
1764
+ // generation that yields nothing usable still sends NOTHING rather than a
1765
+ // canned line. The debt stays open either way: silence about timing is not
1766
+ // silence about outcome, and the failure/stale notices still speak.
1767
+ if (
1768
+ !rec.interimSaid && !rec.interimForbidden && !rec.ackSuppressed
1769
+ && rec.tier !== "answer"
1770
+ && !foreignOwnerAlive && age >= ACK_AFTER_MS
1771
+ ) {
1772
+ const budgetOf = (a.deps && a.deps.roomBudgetImpl) || claimRoomInterim;
1773
+ // ASK, then compose, then SPEND. The interim's text comes from a model
1774
+ // call that can fail; claiming the room's fifteen minutes before the
1775
+ // message exists would gag the room in exchange for nothing.
1776
+ const budget = budgetOf({
1777
+ service: rec.service,
1778
+ channel: rec.channel,
1779
+ now,
1780
+ agentRoot: AGENT_REPO_DIR,
1781
+ commit: false,
1782
+ });
1783
+ if (!budget || !budget.allowed) {
1784
+ // OVER BUDGET IS NOT A DROP. The debt stays open and tracked exactly as
1785
+ // it was; what is withheld is the content-free half. The room has heard
1786
+ // from this agent inside the window and does not need to hear it again.
1787
+ rec.interimSaid = true;
1788
+ rec.interimSuppressedAt = now;
1789
+ writeRecord(rec);
1790
+ counters.bump("assurance.interim_suppressed", { reason: (budget && budget.reason) || "room-budget" });
1791
+ stats.suppressed++;
1792
+ continue;
1793
+ }
1288
1794
  const gen = (a.deps && a.deps.generateAckImpl) || generateAck;
1289
1795
  const text = await gen(item, { summary: rec.summary, model: rec.model, priority: rec.priority });
1290
1796
  if (text == null) {
1291
1797
  rec.ackGenFailures = (rec.ackGenFailures || 0) + 1;
1292
1798
  if (rec.ackGenFailures >= ACK_GEN_MAX_FAILURES) {
1293
1799
  rec.ackSuppressed = true;
1294
- console.warn(`[assurance] ack for ${rec.key} suppressed after ${rec.ackGenFailures} failed generations — deliberate silence, never a canned line`);
1800
+ console.warn(`[assurance] interim for ${rec.key} suppressed after ${rec.ackGenFailures} failed generations — deliberate silence, never a canned line`);
1295
1801
  }
1296
1802
  writeRecord(rec);
1297
1803
  continue; // nothing to send; one decision per obligation per tick
1298
1804
  }
1299
1805
  const res = await send(item, text, { kind: "ack", idempotencySuffix: "ack" });
1300
1806
  if (res && res.sent) {
1807
+ // SPEND ONLY ON A DELIVERED MESSAGE. A send that failed is a room that
1808
+ // heard nothing; charging it anyway would gag the room for fifteen
1809
+ // minutes AND latch this debt shut, so the one case the sweep exists to
1810
+ // compensate — an interim that would not go out — would be the one case
1811
+ // it stopped compensating.
1812
+ budgetOf({ service: rec.service, channel: rec.channel, now, agentRoot: AGENT_REPO_DIR, commit: true });
1301
1813
  rec.acknowledged = true;
1302
1814
  rec.ackAt = now;
1815
+ rec.interimSaid = true;
1816
+ rec.interimAt = now;
1303
1817
  writeRecord(rec);
1304
1818
  stats.acked++;
1305
1819
  } else {
@@ -1312,45 +1826,19 @@ export async function sweepObligations(a = {}) {
1312
1826
  continue; // one message per obligation per tick, always
1313
1827
  }
1314
1828
 
1315
- // (e) Interrupted: the debt was opened by a daemon that is no longer this
1316
- // process, and nothing has closed it. The work died with that process.
1829
+ // (f) WAS "long work gets progress, not silence — capped". DELETED.
1317
1830
  //
1318
- // A pid MISMATCH alone does not establish that. Two daemons on one AGENT_DIR
1319
- // — a manual run next to the launchd one, which is what an operator does
1320
- // while debugging — each see the other's healthy, actively-running debts as
1321
- // foreign. Unguarded, both tell those requesters "my session was
1322
- // interrupted" seconds after the ask arrived, while the work runs fine
1323
- // behind the apology. So: the owning process must be provably GONE, and the
1324
- // debt must have sat still long enough that a live handover would have shown
1325
- // up by now.
1326
- if (
1327
- rec.daemonPid && rec.daemonPid !== process.pid && !rec.interruptedNotifiedAt
1328
- && workAge >= INTERRUPT_GRACE_MS && !alive(rec.daemonPid)
1329
- ) {
1330
- const text = composeInterrupted(rec);
1331
- const res = await send(item, text, { kind: "progress", idempotencySuffix: `interrupted-${rec.attempts || 0}` });
1332
- rec.interruptedNotifiedAt = now;
1333
- rec.daemonPid = process.pid;
1334
- writeRecord(rec);
1335
- if (res && res.sent) stats.interrupted++;
1336
- continue;
1337
- }
1338
-
1339
- // (f) Long work gets progress, not silence — capped.
1340
- const sinceUpdate = now - (rec.lastProgressAt || rec.ackAt || rec.openedAt || now);
1341
- const due = (rec.progressSent || 0) === 0
1342
- ? age >= PROGRESS_AFTER_MS
1343
- : sinceUpdate >= PROGRESS_EVERY_MS;
1344
- if (due && !foreignOwnerAlive && (rec.progressSent || 0) < PROGRESS_MAX) {
1345
- const text = composeProgress(rec, { now });
1346
- const res = await send(item, text, { kind: "progress", idempotencySuffix: `progress-${(rec.progressSent || 0) + 1}` });
1347
- if (res && res.sent) {
1348
- rec.progressSent = (rec.progressSent || 0) + 1;
1349
- rec.lastProgressAt = now;
1350
- writeRecord(rec);
1351
- stats.progressed++;
1352
- }
1353
- }
1831
+ // It emitted `composeProgress`, whose entire content was `now - openedAt`
1832
+ // rendered as minutes. 961 of those shipped in fourteen days. Nothing read
1833
+ // the running session's stdout, its tool calls or its partial findings, so
1834
+ // the branch could not have said anything a reader could not already see on
1835
+ // the clock — and it said it from a per-obligation timer, so two
1836
+ // overlapping debts in one room narrated each other at the same instant.
1837
+ //
1838
+ // Long work now reports CONTENT or it stays quiet: `notePlan` posts the
1839
+ // plan the session itself stated, once, subject to the same room budget.
1840
+ // The human is not left in the dark — the stale check (c) and the failure
1841
+ // path in `settleSession` still speak, and they carry facts.
1354
1842
  }
1355
1843
 
1356
1844
  pruneObligations({ now });
@@ -1364,17 +1852,30 @@ export function pruneObligations(o = {}) {
1364
1852
  let removed = 0;
1365
1853
  for (const rec of listObligations()) {
1366
1854
  if (!rec || rec.state === "open") continue;
1855
+ // A record with no key cannot be addressed, so `pathFor` would resolve to
1856
+ // `unknown.json` and delete whatever happens to be there. `listObligations`
1857
+ // already refuses those; this is the belt to that brace, because the cost
1858
+ // of the two disagreeing is silent data loss rather than a bad count.
1859
+ if (typeof rec.key !== "string" || !rec.key) continue;
1860
+ // A closed record missing its stamp is still due for pruning — `|| 0` keeps
1861
+ // that, deliberately. The phantom this guard is really about is the one
1862
+ // with no key at all, handled above.
1367
1863
  if (now - (rec.closedAt || 0) < CLOSED_RETENTION_MS) continue;
1368
1864
  try { unlinkSync(pathFor(rec.key)); removed++; } catch { /* best-effort */ }
1369
1865
  }
1370
1866
  return removed;
1371
1867
  }
1372
1868
 
1373
- /** Test seam: wipe the ledger. */
1869
+ /** Test seam: wipe the ledger — obligations, escalations AND the room budget.
1870
+ * The room budget lives in the same directory and is part of the same state:
1871
+ * a reset that leaves it behind makes the next test's first interim depend on
1872
+ * which tests ran before it. */
1374
1873
  export function _resetObligations() {
1874
+ _sweepInFlight = false; // a test that threw mid-sweep must not disable the next one
1375
1875
  for (const rec of listObligations()) {
1376
1876
  try { unlinkSync(pathFor(rec.key)); } catch { /* */ }
1377
1877
  }
1878
+ try { unlinkSync(roomBudgetPath(AGENT_REPO_DIR)); } catch { /* absent is the normal case */ }
1378
1879
  try {
1379
1880
  const dir = join(obligationDir(), "needs-attention");
1380
1881
  for (const f of readdirSync(dir)) { try { unlinkSync(join(dir, f)); } catch { /* */ } }
@@ -1389,12 +1890,15 @@ export default {
1389
1890
  narratorPatterns,
1390
1891
  generateAck,
1391
1892
  sanitiseAckText,
1392
- composeProgress,
1393
1893
  composeFailure,
1394
1894
  composeSilentSuccess,
1395
1895
  composeInterrupted,
1396
1896
  classifyFailure,
1397
1897
  openAndAcknowledge,
1898
+ notePlan,
1899
+ _resetSweepGuard,
1900
+ noticeAlreadySaid,
1901
+ markNoticeSaid,
1398
1902
  noteSession,
1399
1903
  settleSession,
1400
1904
  sweepObligations,