@cohortapp/agent-sdk 2.18.14 → 2.18.16

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.
@@ -54,8 +54,8 @@
54
54
  import { appendFileSync, mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "fs";
55
55
  import { spawn } from "child_process";
56
56
  import { join } from "path";
57
- import { deliver, deliverWithRetry, replyTargetOf, canDeliverTo } from "./deliver.mjs";
58
- import { spokeFor, NON_ANSWER_KINDS } from "../../lib/comms/receipts.mjs";
57
+ import { deliver, deliverWithRetry, replyTargetOf, canDeliverTo, canReactTo, deliverReaction } from "./deliver.mjs";
58
+ import { spokeFor, spokeSince, 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
@@ -78,14 +78,38 @@ import * as counters from "../../lib/diagnostics/counters.mjs";
78
78
  // the only composer of interim copy left in the system, and carries the two
79
79
  // copy rules (GENERIC_OPENER, FORWARD_PROMISE) every interim must satisfy.
80
80
  import { replyTier } from "../../lib/assurance/tier.mjs";
81
- import { isRollCallItem } from "../../lib/org/inbound/broadcast.mjs";
81
+ import { isRollCallItem, BROADCAST_REASON } from "../../lib/org/inbound/broadcast.mjs";
82
+ import { isMembershipReason } from "../../lib/org/inbound/directedness.mjs";
82
83
  import {
83
84
  claimRoomInterim,
84
85
  claimRoomNotice,
85
86
  ledgerPath as roomBudgetPath,
86
87
  LEDGER_BASENAME as ROOM_BUDGET_BASENAME,
87
88
  } from "../../lib/assurance/room-budget.mjs";
88
- import { renderPlanNote, GENERIC_OPENER } from "../../lib/assurance/plan-note.mjs";
89
+ import { renderPlanNote, GENERIC_OPENER, MESSAGE_TALLY } from "../../lib/assurance/plan-note.mjs";
90
+ // The first thing said is WRITTEN, not stamped: `firstReplyShape` carries the
91
+ // model-authored opener's shape rules and `isRetiredFirstReply` rejects the
92
+ // canned rotation this work retired. `notice-voice` does the same job for the
93
+ // progress/failure notices, which used to be fixed strings.
94
+ import {
95
+ buildNoticeSystemPrompt,
96
+ buildNoticeUserPrompt,
97
+ noticeSource,
98
+ noticeRejection,
99
+ fallbackNotice,
100
+ } from "../../lib/assurance/notice-voice.mjs";
101
+ import { isRetiredFirstReply, firstReplyShape } from "../../lib/assurance/first-reply.mjs";
102
+ // The unit of acknowledgement is the CONVERSATION, not the message: four
103
+ // messages in a row earn ONE first response that was written with all four in
104
+ // front of it. `groupIntoBatches` + `batchVerdict` decide WHEN and WHICH;
105
+ // `batchMessages` is what the composer is handed. All pure — see batch.mjs.
106
+ import {
107
+ groupIntoBatches,
108
+ batchVerdict,
109
+ batchMessages,
110
+ batchQuietMs,
111
+ batchMaxSpanMs,
112
+ } from "../../lib/assurance/batch.mjs";
89
113
 
90
114
  // ── Board mirror seams ───────────────────────────────────────────────────────
91
115
  // Imported lazily so a board module problem can never stop the daemon booting,
@@ -230,6 +254,36 @@ export const ACK_GEN_MAX_FAILURES = num(process.env.ASSURANCE_ACK_GEN_MAX_FAILUR
230
254
  */
231
255
  export const ACK_MIN_CHARS = num(process.env.ASSURANCE_ACK_MIN_CHARS, 20);
232
256
 
257
+ /**
258
+ * How long a NOTICE may spend in generation.
259
+ *
260
+ * Twice the ack's budget, and deliberately not the same number. The ack races a
261
+ * typing indicator in front of someone who is about to wait minutes anyway, so
262
+ * ~5s is the right aggression there and silence is an acceptable loss. A notice
263
+ * races nothing — the work is already dead — and its loss is not silence but
264
+ * `fallbackNotice`, which is a worse message rather than no message. Spending a
265
+ * few more seconds to avoid the worse message is the right trade in exactly the
266
+ * direction the ack's is not.
267
+ */
268
+ export const NOTICE_GEN_TIMEOUT_MS = num(process.env.ASSURANCE_NOTICE_GEN_TIMEOUT_MS, 10_000);
269
+
270
+ /**
271
+ * `ASSURANCE_NOTICE_GEN=0` turns notice generation off and goes straight to the
272
+ * plain wording.
273
+ *
274
+ * An operator switch, not a feature flag: a seat whose model access is broken
275
+ * would otherwise pay `NOTICE_GEN_TIMEOUT_MS` per dead obligation per sweep to
276
+ * arrive at the same fallback it could have reached instantly. Nothing is lost
277
+ * when it is off — the person still hears that their work died, in a plainer
278
+ * sentence — which is the property that makes it safe to have a switch at all.
279
+ *
280
+ * Read at the EDGE, not captured at import, so exporting it in a shell that is
281
+ * already running takes effect on the next notice.
282
+ */
283
+ export function noticeGenEnabled(env = process.env) {
284
+ return String((env && env.ASSURANCE_NOTICE_GEN) ?? "1") !== "0";
285
+ }
286
+
233
287
  /** The tiers `lib/assurance/tier.mjs` can return; anything else is a caller bug
234
288
  * and is treated as `work` — the tier that can still speak once. */
235
289
  const TIER_SET = new Set(["answer", "work", "plan"]);
@@ -344,6 +398,16 @@ export function itemSnapshot(item) {
344
398
  // reads belongs in this snapshot.
345
399
  scope_id: item.scope_id || null,
346
400
  thread_id: item.thread_id || null,
401
+ // WHY THIS ADDRESS PROVENANCE IS A DEBT FIELD, not decoration. The sweep and
402
+ // the silent-success path SPEAK from `rec.item`, long after the live item is
403
+ // gone, and one decision they make is whether a rescued result belongs in
404
+ // this room at all: a membership/broadcast-proved surface (a doc comment
405
+ // shared to a channel, a room this seat merely belongs to) is ambient, and
406
+ // dumping a finished session's result text into it is narration nobody is
407
+ // waiting on 1:1 — the exact leak doc-comment surfaces close silently to
408
+ // avoid. Without `direct_reason` in the snapshot the DM/ambient distinction
409
+ // is unrecoverable at sweep time; with it, the guard fails open to a DM.
410
+ direct_reason: item.direct_reason || null,
347
411
  sender: item.sender || null,
348
412
  sender_email: item.sender_email || null,
349
413
  subject: item.subject || null,
@@ -405,6 +469,41 @@ const IMPERATIVE_OPENER =
405
469
  /^(please\s+|pls\s+)?(fix|update|send|review|check|draft|push|share|add|remove|create|write|run|make|schedule|book|reply|investigate|look|pull|get|give|tell|let|help|find|prepare|post|email|ping|follow|stop|start|deploy|merge|revert|open|close|read|summari[sz]e|redo|rebuild|retry)\b/i;
406
470
  const REQUEST_MARKER = /\b(please|pls|can you|could you|would you|need you to)\b/i;
407
471
 
472
+ /**
473
+ * Is this inbound the SHAPE of another seat's OUTCOME NOTICE?
474
+ *
475
+ * WHY A SHAPE TEST IS NOW NEEDED AT ALL. `isOwnNarratorText` matches the
476
+ * daemon's notices verbatim, and it could, because they were deterministic
477
+ * strings composed by this very file — `narratorPatterns()` derives its regexes
478
+ * from the composers so the two cannot drift. `generateFailureNotice` ends
479
+ * that: a notice is now written per ask, in the seat's own voice, so no
480
+ * verbatim pattern can recognise one. Without this, a peer's generated "that
481
+ * export died on me, I've flagged it" reaches the dispatch gate as an ordinary
482
+ * message, earns an ack, and spawns a 15-45 minute session behind it — the
483
+ * 25/27/30 Aug loop, reopened by the very change that made the notices humane.
484
+ *
485
+ * Narrow on purpose, and narrower than `looksLikePeerAckShape`: the sender must
486
+ * be an agent seat (a human can never match — see `senderLooksLikeAgentSeat`),
487
+ * the text must be short, and it must carry BOTH a first-person subject and a
488
+ * failure predicate. A peer reporting a fact ("the export is 400 rows short")
489
+ * or asking for help ("can you take this one?") still passes the gate, because
490
+ * a false consume is silent to the sender and unrecoverable while a false pass
491
+ * costs one audited redundant session.
492
+ *
493
+ * @param {object} item
494
+ * @returns {boolean}
495
+ */
496
+ export function looksLikePeerOutcomeNotice(item) {
497
+ const content = String((item && item.content) || "").trim();
498
+ if (!content) return false;
499
+ if (content.length > 400) return false;
500
+ if (!senderLooksLikeAgentSeat(item)) return false;
501
+ if (REQUEST_MARKER.test(content)) return false;
502
+ if (IMPERATIVE_OPENER.test(content)) return false;
503
+ if (!/\b(i|my|i'?ve|i'?m)\b/i.test(content)) return false;
504
+ return /\b(didn'?t (get through|finish|complete)|never came back|ran out of time|outrun its time limit|broke before it finished|was cut off|stopped (trying|retrying)|flagged it|ended (with an error|unexpectedly)|my (machine|end) restarted)\b/i.test(content);
505
+ }
506
+
408
507
  /**
409
508
  * Is this inbound the SHAPE of an acknowledgement from a peer agent seat?
410
509
  *
@@ -462,6 +561,17 @@ export function looksLikePeerAckShape(item) {
462
561
  export const RETIRED_NARRATOR_TEMPLATES = Object.freeze([
463
562
  "Still on this — 1 minutes in. I'll come back as soon as I've got something.",
464
563
  "Still going — 1 minutes in. I'll follow up the moment it's done.",
564
+ // The two failure sentences `composeFailure` stamped until 2026-09-25, with
565
+ // the sentinel standing in for the interpolated cause exactly as the derived
566
+ // templates below do. They MUST stay here now that nothing composes them:
567
+ // two seats in the fleet were still running a preserved local copy of this
568
+ // daemon on 2026-09-25 (Ravi Patel and Daniel Connors, both reporting SDK
569
+ // 2.18.13 while executing an older `$AGENT_ROOT/scripts/daemon` tree), so a
570
+ // seat that has updated goes on RECEIVING these from seats that have not.
571
+ // Dropping them with the emitter would mean this seat acks a peer's failure
572
+ // notice and spawns a 15-45 minute session behind it.
573
+ "Hit a problem — @@CAUSE@@. Retrying now; if it fails again I'll come straight back rather than leave you waiting.",
574
+ "Couldn't finish this — @@CAUSE@@. I've stopped retrying and flagged it so it isn't lost. Want me to try a narrower version, hand it off, or leave it with you?",
465
575
  ]);
466
576
 
467
577
  let _narratorPatterns = null;
@@ -571,6 +681,15 @@ export function shouldAcknowledge(o = {}) {
571
681
  willSpawnSession: o.willSpawnSession === true,
572
682
  });
573
683
  if (!o.willSpawnSession) return { ack: false, reason: "answered-in-turn", tier };
684
+ // ONE FIRST REPLY PER FLURRY. Stamped by the poll loop, which is the only
685
+ // place that sees more than one item at a time (`agent-daemon.pollService`).
686
+ // `ack:false` here is what the daemon turns into `interimForbidden` on the
687
+ // record, so the silence is durable: no later sweep in any process speaks for
688
+ // a message whose flurry has already been answered. The DEBT is still opened
689
+ // by the caller — every item in the batch keeps its own obligation and its
690
+ // own answer.
691
+ const batched = o.item || {};
692
+ if (batched.covered_by_batch === true) return { ack: false, reason: "covered-by-batch", tier };
574
693
  if (o.source && o.source !== "inbox") return { ack: false, reason: "no-waiting-human", tier };
575
694
  const item = o.item || {};
576
695
  if (!item.service) return { ack: false, reason: "no-service", tier };
@@ -605,6 +724,10 @@ export function shouldAcknowledge(o = {}) {
605
724
  logDispatchGateRefusal(item, "peer-ack-shaped");
606
725
  return { ack: false, reason: "peer-ack-shaped", emitterClass: true, tier };
607
726
  }
727
+ if (looksLikePeerOutcomeNotice(item)) {
728
+ logDispatchGateRefusal(item, "peer-outcome-notice");
729
+ return { ack: false, reason: "peer-outcome-notice", emitterClass: true, tier };
730
+ }
608
731
  // ── A ROLL-CALL NEVER GETS AN INTERIM ─────────────────────────────────────
609
732
  // A human asked the whole room for a line each. Thirteen "checking now"
610
733
  // lines followed by thirteen answers is twice the traffic and half the
@@ -738,9 +861,15 @@ function runAckModelWith(seat, systemPrompt, userPrompt, o = {}) {
738
861
  * Null is a first-class answer here — it means "send nothing". Rejected:
739
862
  * empty output, fenced/quoted wrappers that survive stripping into nothing,
740
863
  * anything over 200 chars (that is a reply, not an ack), refusal/meta text,
741
- * and the assistant-y boilerplate the communication policy bans.
864
+ * the assistant-y boilerplate the communication policy bans, and — when the
865
+ * line is standing in for a FLURRY — a tally of it.
866
+ *
867
+ * @param {unknown} raw
868
+ * @param {{batched?: boolean}} [opts] `batched:true` when this line answers
869
+ * more than one message, which is the only case where the prompt bans
870
+ * counting and therefore the only case where the filter may.
742
871
  */
743
- export function sanitiseAckText(raw) {
872
+ export function sanitiseAckText(raw, opts = {}) {
744
873
  if (raw == null) return null;
745
874
  let t = String(raw).replace(/```[a-z]*\n?|```/gi, "").trim();
746
875
  // First non-empty line only — an ack is one line by definition.
@@ -755,6 +884,15 @@ export function sanitiseAckText(raw) {
755
884
  // pattern is imported from the copy-rule home rather than re-typed here, so
756
885
  // the instruction and the filter cannot disagree again.
757
886
  if (GENERIC_OPENER.test(t)) return null;
887
+ // THE BATCHED PROMPT, ENFORCED, and for the same reason one line up. The
888
+ // batched close instruction says "never count them, never say how many there
889
+ // are"; nothing checked the output, and the real path returned "Four
890
+ // messages received." and "I have 4 messages from you." verbatim. A tally is
891
+ // a report about an inbox — a colleague replies to what was asked.
892
+ if (opts && opts.batched === true && MESSAGE_TALLY.test(t)) {
893
+ counters.bump("ack.generation_failed", { cause: "message_tally" });
894
+ return null;
895
+ }
758
896
  // Anything this short cannot be specific to what was asked, which is the one
759
897
  // thing the prompt requires of it. "On it." is 6 characters; so is every
760
898
  // variant that would slip past the pattern above by rephrasing.
@@ -788,18 +926,65 @@ function buildAckSystemPrompt() {
788
926
  ].join("\n");
789
927
  }
790
928
 
791
- function buildAckUserPrompt(item, classResult) {
929
+ /**
930
+ * @param {object} item the inbound item the reply is addressed to
931
+ * @param {object} classResult
932
+ * @param {{sender:string|null,text:string}[]} [batch] EVERY message this one
933
+ * line has to answer, oldest first (see lib/assurance/batch.mjs). One
934
+ * entry — the ordinary case — renders exactly as it always did.
935
+ */
936
+ /**
937
+ * The messages a batched ack actually speaks for, oldest first.
938
+ *
939
+ * ONE PREDICATE, TWO CONSUMERS, deliberately. `buildAckUserPrompt` uses it to
940
+ * decide whether to emit the "never count them" instruction, and `generateAck`
941
+ * uses it to decide whether `sanitiseAckText` may enforce that instruction. If
942
+ * the two ever computed "is this a flurry" separately they could disagree, and
943
+ * a filter stricter (or laxer) than its own prompt is the exact failure this
944
+ * module keeps rediscovering.
945
+ *
946
+ * @param {{sender?:string|null,text?:string}[]|undefined} batch
947
+ * @returns {{sender?:string|null,text:string}[]}
948
+ */
949
+ function ackBatchMessages(batch) {
950
+ return Array.isArray(batch) ? batch.filter((m) => m && m.text) : [];
951
+ }
952
+
953
+ function buildAckUserPrompt(item, classResult, batch) {
792
954
  const parts = [];
793
- if (item && item.sender) parts.push(`From: ${String(item.sender).slice(0, 80)}`);
794
- const content = String((item && (item.content || item.subject)) || "").trim();
795
- if (content) parts.push(`Message:\n${content.slice(0, 600)}`);
955
+ const msgs = ackBatchMessages(batch);
956
+ if (msgs.length > 1) {
957
+ // A FLURRY. The line that goes out is a reply to the conversation, so the
958
+ // composer sees the conversation — not the last message with the rest
959
+ // thrown away, and not a count of them. "I have four messages" is a report
960
+ // about an inbox; a colleague answers what was asked.
961
+ parts.push(
962
+ `These arrived together, oldest first. They are one conversation, and you are replying to all of them at once:\n`
963
+ + msgs.map((m) => `- ${m.sender ? `${String(m.sender).slice(0, 60)}: ` : ""}${m.text}`).join("\n"),
964
+ );
965
+ } else {
966
+ if (item && item.sender) parts.push(`From: ${String(item.sender).slice(0, 80)}`);
967
+ const content = String((item && (item.content || item.subject)) || "").trim();
968
+ if (content) parts.push(`Message:\n${content.slice(0, 600)}`);
969
+ }
796
970
  const thread = String((item && item.thread_context) || "").trim();
797
971
  if (thread) {
798
972
  const tail = thread.split("\n").filter(Boolean).slice(-6).join("\n");
799
973
  parts.push(`Recent thread:\n${tail.slice(0, 600)}`);
800
974
  }
975
+ // A FLURRY GETS ONE LINE THAT ANSWERS ALL OF IT. The poll loop stamps
976
+ // `batch_size` on the leader; without this the model writes a line about one
977
+ // message while four are on screen, which reads as the agent having noticed
978
+ // only the first.
979
+ const batchSize = Number(item && item.batch_size);
980
+ if (Number.isFinite(batchSize) && batchSize > 1) {
981
+ parts.push(`(They sent ${batchSize} messages in a row just now. Your one line should cover all of them, not just the one above.)`);
982
+ }
801
983
  if (classResult && classResult.summary) parts.push(`(Your own read of the ask: ${String(classResult.summary).slice(0, 120)})`);
802
- return `${parts.join("\n\n")}\n\nWrite the one-line acknowledgement now.`;
984
+ const close = msgs.length > 1
985
+ ? `Write the one-line acknowledgement now. It answers ALL of the above together — never count them, never say how many there are, never take them one at a time.`
986
+ : `Write the one-line acknowledgement now.`;
987
+ return `${parts.join("\n\n")}\n\n${close}`;
803
988
  }
804
989
 
805
990
  /**
@@ -822,7 +1007,10 @@ function buildAckUserPrompt(item, classResult) {
822
1007
  *
823
1008
  * @param {object} item the inbound item ({sender, content, thread_context, …})
824
1009
  * @param {object} [classResult]
825
- * @param {object} [opts] {runImpl, timeoutMs} — runImpl is the test seam
1010
+ * @param {object} [opts] {runImpl, timeoutMs, batch} — runImpl is the test seam;
1011
+ * `batch` is every message this one line has to answer, oldest first
1012
+ * (lib/assurance/batch.mjs). Absent or single-entry is the ordinary case
1013
+ * and composes exactly as before.
826
1014
  * @returns {Promise<string|null>}
827
1015
  */
828
1016
  export async function generateAck(item, classResult = {}, opts = {}) {
@@ -830,10 +1018,10 @@ export async function generateAck(item, classResult = {}, opts = {}) {
830
1018
  const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : ACK_GEN_TIMEOUT_MS;
831
1019
  try {
832
1020
  const raw = await withTimeout(
833
- run(buildAckSystemPrompt(), buildAckUserPrompt(item, classResult), { timeoutMs }),
1021
+ run(buildAckSystemPrompt(), buildAckUserPrompt(item, classResult, opts.batch), { timeoutMs }),
834
1022
  timeoutMs,
835
1023
  );
836
- const text = sanitiseAckText(raw);
1024
+ const text = sanitiseAckText(raw, { batched: ackBatchMessages(opts.batch).length > 1 });
837
1025
  if (text == null) {
838
1026
  console.warn(`[assurance] ack generation produced nothing usable — staying silent (no canned fallback, by design)`);
839
1027
  counters.bump("ack.generation_failed", { cause: "unusable_output", timeout_ms: timeoutMs });
@@ -887,18 +1075,191 @@ function withTimeout(promise, ms) {
887
1075
  */
888
1076
 
889
1077
  /**
890
- * The failure notice. Says WHAT HAPPENED and WHAT HAPPENS NEXT, in that order,
891
- * because those are the two things the human is missing when a session dies.
892
- * Terse and human: never blames the human, never hides behind "an error
893
- * occurred", never ends without a next step, and never shows internal framing.
1078
+ * The failure notice's DEGRADED path — what is said when generation is
1079
+ * unavailable. `generateFailureNotice` is the primary; this is its floor.
1080
+ *
1081
+ * WHAT THIS FUNCTION USED TO BE, AND WHY IT IS NOT THAT ANY MORE. It returned
1082
+ * two fixed sentences:
1083
+ *
1084
+ * "Hit a problem — ${cause}. Retrying now; if it fails again I'll come
1085
+ * straight back rather than leave you waiting."
1086
+ * "Couldn't finish this — ${cause}. I've stopped retrying and flagged it so
1087
+ * it isn't lost. Want me to try a narrower version, hand it off, or leave it
1088
+ * with you?"
1089
+ *
1090
+ * Both are in `first-reply.RETIRED_FIRST_REPLIES` now, which means this file
1091
+ * can no longer produce either of them and a test fails if it starts to. They
1092
+ * were screenshotted by the owner on 2026-09-25 out of three different channels
1093
+ * from two different seats on the same day, which is the whole complaint: every
1094
+ * dead session in the fleet said the same sentence, so a reader could not tell
1095
+ * which of their asks had died. Five byte-identical copies landed in one
1096
+ * channel inside two hours on 2026-09-23 (message ids cmuegc8ox…, cmuegc9fw…,
1097
+ * cmuekxzl3…, cmuekz7il…, cmuel0jlb…, all `failure-stale`, all channel
1098
+ * cmql16b7c01d2hhzpjk51b8xn).
1099
+ *
1100
+ * `fallbackNotice` interpolates the TOPIC as well as the cause, which is the
1101
+ * structural fix: two sibling failures are now two distinguishable sentences.
1102
+ * The rest of the contract is unchanged — says what happened, then what happens
1103
+ * next, never blames the human, never hides behind "an error occurred", never
1104
+ * ends without a next step, never shows internal framing.
1105
+ *
1106
+ * @param {object} rec the obligation record; its item supplies the topic
1107
+ * @param {object} o {failure, willRetry, topic}
894
1108
  */
895
1109
  export function composeFailure(rec, o = {}) {
1110
+ const f = o.failure || {};
1111
+ return fallbackNotice({
1112
+ kind: o.willRetry ? NOTICE.FAILURE_RETRYING : NOTICE.FAILURE_FINAL,
1113
+ cause: f.human || "the working session ended unexpectedly",
1114
+ topic: o.topic != null ? o.topic : noticeTopic(rec),
1115
+ willRetry: !!o.willRetry,
1116
+ });
1117
+ }
1118
+
1119
+ /**
1120
+ * The topic clause a notice may name — from the HUMAN'S OWN WORDS or not at all.
1121
+ *
1122
+ * `rec.summary` IS DELIBERATELY NOT CONSULTED, and this is the one line in this
1123
+ * change that a reviewer should check hardest. The classifier's summary is
1124
+ * internal framing: it is written ABOUT the sender in the third person ("The
1125
+ * CEO checking in on work progress"), and interpolating it into a message the
1126
+ * sender reads produces the "(CEO asking what machine…)" bleed that
1127
+ * `topicClause` was rewritten to end — its header says so in as many words, and
1128
+ * `assurance.test.mjs` pins it ("the summary is internal framing, never copy").
1129
+ * A first draft of this function read `rec.summary` for the topic and walked
1130
+ * straight back into it; the test caught it, which is why the test exists.
1131
+ *
1132
+ * So: the item's SUBJECT, which is a line the sender typed, or nothing. An
1133
+ * empty topic yields "The work you asked for" — honest, and not a guess.
1134
+ *
1135
+ * Sibling distinctness does NOT depend on this being populated: that guarantee
1136
+ * is structural and lives in the work-keyed room claim (`room-budget.noticeKey`),
1137
+ * which refuses a second notice about the same ask whatever words it wears. The
1138
+ * topic is the nicety on top, taken only when a human authored one.
1139
+ */
1140
+ function noticeTopic(rec) {
1141
+ const r = rec && typeof rec === "object" ? rec : {};
1142
+ const raw = String((r.item && r.item.subject) || "").trim();
1143
+ if (!raw) return "";
1144
+ const t = raw.replace(/\s+/g, " ").replace(/[.!?]+$/, "");
1145
+ return t.length > 0 && t.length <= 80 ? t : "";
1146
+ }
1147
+
1148
+ /**
1149
+ * THE FAILURE NOTICE, WRITTEN RATHER THAN STAMPED.
1150
+ *
1151
+ * Same cheapest-model path as the holding ack (`ackSpawn`), the real cause and
1152
+ * the real ask in hand, the agent's own voice, about THIS piece of work. Every
1153
+ * guarantee the deterministic version bought is kept — see
1154
+ * `lib/assurance/notice-voice.mjs`, which owns the checks:
1155
+ *
1156
+ * it cannot claim success `claimsSuccess`
1157
+ * it cannot invent a cause `inventsFacts`
1158
+ * it cannot reissue a retired
1159
+ * sentence `isRetiredFirstReply`
1160
+ * it always produces SOMETHING this function, which falls back rather than
1161
+ * returning null
1162
+ *
1163
+ * THAT LAST ONE IS THE DELIBERATE DIVERGENCE FROM `generateAck`, and it is the
1164
+ * decision this change is really making. `generateAck` returns null and the
1165
+ * caller says NOTHING, because a holding line nobody composed costs the reader
1166
+ * nothing they will not have in minutes. A failure notice nobody composed costs
1167
+ * them the one fact they cannot get any other way: that the thing they are
1168
+ * waiting for is dead. Never-told is strictly worse than plainly-told, so this
1169
+ * path speaks — and stamps `degraded` so the condition is countable instead of
1170
+ * invisible.
1171
+ *
1172
+ * @param {object} rec
1173
+ * @param {object} o {failure, willRetry, kind}
1174
+ * @param {object} [opts] {runImpl, timeoutMs} test seams
1175
+ * @returns {Promise<{text:string, generated:boolean, degraded:boolean, cause:string}>}
1176
+ */
1177
+ /**
1178
+ * ONE WRITTEN NOTICE PER ROOM PER WINDOW; the rest get the plain sentence.
1179
+ *
1180
+ * The guarantee this protects is the measured one: twenty sibling asks dying
1181
+ * together in one channel must not become twenty messages. Byte-identity used
1182
+ * to do that for free, because every notice in the fleet was the same string —
1183
+ * and generating a bespoke notice per ask is precisely what destroys that for
1184
+ * free. So the FIRST dead ask in a room gets the written, specific message and
1185
+ * its siblings inside the window compose the plain one, which is byte-identical
1186
+ * across them and therefore coalesced to nothing by the text claim in
1187
+ * `sayOutcomeNotice`.
1188
+ *
1189
+ * Nobody loses a fact: every sibling still writes its own escalation record, so
1190
+ * an operator sees every dead ask in needs-attention exactly as before. What is
1191
+ * withheld is the nineteenth rendering of the same news into one room.
1192
+ *
1193
+ * Claimed with `commit: true` up front, unlike the notice itself. The cost of a
1194
+ * claim spent on a generation that then fails is one sibling reading the plain
1195
+ * sentence instead of a written one — which is the outcome this function exists
1196
+ * to hand out anyway.
1197
+ */
1198
+ function claimWrittenVoice(rec, notice, now, impl) {
1199
+ const claim = impl || claimRoomNotice;
1200
+ const v = claim({
1201
+ service: rec && rec.service, channel: rec && rec.channel,
1202
+ notice: `voice:${notice}`, text: "one-written-notice-per-room",
1203
+ now, agentRoot: AGENT_REPO_DIR, commit: true,
1204
+ });
1205
+ return !(v && v.allowed === false);
1206
+ }
1207
+
1208
+ export async function generateFailureNotice(rec, o = {}, opts = {}) {
896
1209
  const f = o.failure || {};
897
1210
  const cause = f.human || "the working session ended unexpectedly";
898
- if (o.willRetry) {
899
- return `Hit a problem — ${cause}. Retrying now; if it fails again I'll come straight back rather than leave you waiting.`;
1211
+ const kind = o.kind || (o.willRetry ? NOTICE.FAILURE_RETRYING : NOTICE.FAILURE_FINAL);
1212
+ const topic = noticeTopic(rec);
1213
+ const item = (rec && rec.item) || {};
1214
+ const promptArgs = {
1215
+ kind,
1216
+ cause,
1217
+ topic,
1218
+ askText: String(item.content || item.subject || "").trim(),
1219
+ sender: item.sender || "",
1220
+ willRetry: !!o.willRetry,
1221
+ };
1222
+ const degradedText = () =>
1223
+ fallbackNotice({ kind, cause, topic, willRetry: !!o.willRetry });
1224
+
1225
+ const run = typeof opts.runImpl === "function" ? opts.runImpl : runAckModel;
1226
+ const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : NOTICE_GEN_TIMEOUT_MS;
1227
+ if (typeof opts.runImpl !== "function" && !noticeGenEnabled()) {
1228
+ counters.bump("notice.generation_skipped", { notice: kind, reason: "disabled" });
1229
+ return { text: degradedText(), generated: false, degraded: true, cause };
1230
+ }
1231
+ if (o.mayWrite === false) {
1232
+ // A sibling. See `claimWrittenVoice`: one written notice per room per
1233
+ // window, the rest plain — and `degraded` is deliberately FALSE here,
1234
+ // because nothing is broken. This is the design working.
1235
+ counters.bump("notice.generation_skipped", { notice: kind, reason: "room-voice-claimed" });
1236
+ return { text: degradedText(), generated: false, degraded: false, cause };
1237
+ }
1238
+ try {
1239
+ const raw = await withTimeout(
1240
+ run(buildNoticeSystemPrompt({ kind, agentName: ackAgentName() }), buildNoticeUserPrompt(promptArgs), { timeoutMs }),
1241
+ timeoutMs,
1242
+ );
1243
+ // THE REASON IS RECORDED, not just the failure. `unusable_output` for all
1244
+ // eleven rejections could not tell "the model is unavailable" from "the
1245
+ // model is fine and `inventsFacts` is too strict" — and the second is a
1246
+ // live possibility, since any digit absent from the cause is refused. The
1247
+ // degraded line is meant to be the rare path; `notice.generation_failed`
1248
+ // broken out by reason is how anyone finds out whether it still is.
1249
+ const verdict = noticeRejection(raw, { source: noticeSource(promptArgs) });
1250
+ if (verdict.ok) return { text: verdict.text, generated: true, degraded: false, cause };
1251
+ counters.bump("notice.generation_failed", { cause: `unusable:${verdict.reason}`, notice: kind });
1252
+ console.warn(`[assurance] ${kind} notice generation produced nothing usable [${verdict.reason}] — sending the plain version (a dead ask is never left unsaid)`);
1253
+ return { text: degradedText(), generated: false, degraded: true, cause, rejected: verdict.reason };
1254
+ } catch (err) {
1255
+ const msg = String((err && err.message) || "");
1256
+ const why = /timed out/i.test(msg) ? "timeout"
1257
+ : /spawn error|ENOENT|EACCES/i.test(msg) ? "spawn_failed"
1258
+ : "error";
1259
+ counters.bump("notice.generation_failed", { cause: why, notice: kind });
1260
+ console.warn(`[assurance] ${kind} notice generation failed (${msg}) — sending the plain version [cause:${why}]`);
1261
+ return { text: degradedText(), generated: false, degraded: true, cause };
900
1262
  }
901
- return `Couldn't finish this — ${cause}. I've stopped retrying and flagged it so it isn't lost. Want me to try a narrower version, hand it off, or leave it with you?`;
902
1263
  }
903
1264
 
904
1265
  /**
@@ -930,6 +1291,32 @@ export function composeSilentSuccess(rec, o = {}) {
930
1291
  return `${head} I don't have a clean result to show you, though. Want me to run it again?`;
931
1292
  }
932
1293
 
1294
+ /**
1295
+ * Is this obligation's surface AMBIENT rather than a 1:1 the requester waits on?
1296
+ *
1297
+ * The signal is `direct_reason` — WHY the inbound join decided this event was
1298
+ * this seat's — carried into the snapshot by `itemSnapshot`. A membership or
1299
+ * broadcast reason (`channel`/`participant`/`shared`, or `collective`) is
1300
+ * access-shaped: the seat was addressed because it can SEE the thing, not
1301
+ * because anything named it, so any number of seats in the room hold the same
1302
+ * event. An identity-shaped reason (a DM, `owner`, `named`, an assignee) is one
1303
+ * person waiting on one reply.
1304
+ *
1305
+ * FAILS OPEN TO "NOT AMBIENT" (i.e. narrate). An item whose provenance was
1306
+ * never stamped — the historical default, and every pre-existing silent-success
1307
+ * test — is treated as a waiting DM, because the cost of narrating into a real
1308
+ * DM is nothing and the cost of silencing one is the very defect this module
1309
+ * exists to fix.
1310
+ *
1311
+ * @param {object} rec the obligation record; its `item.direct_reason` is read
1312
+ * @returns {boolean}
1313
+ */
1314
+ function isAmbientSurface(rec) {
1315
+ const reason = String((rec && rec.item && rec.item.direct_reason) || "");
1316
+ if (!reason) return false; // unknown provenance → treat as a DM, narrate
1317
+ return isMembershipReason(reason) || reason === BROADCAST_REASON;
1318
+ }
1319
+
933
1320
  /** A session interrupted by the daemon itself dying/restarting. */
934
1321
  export function composeInterrupted() {
935
1322
  return `Heads up — my session on this was interrupted before it finished (my end restarted). I've picked it back up; I'll come back with the answer.`;
@@ -1076,10 +1463,42 @@ async function sayOutcomeNotice(o) {
1076
1463
  return { sent: false, reason: "notice-already-said" };
1077
1464
  }
1078
1465
  const claim = o.roomNoticeImpl || claimRoomNotice;
1079
- const peek = claim({
1080
- service: rec.service, channel: rec.channel, notice, text, now,
1081
- agentRoot: AGENT_REPO_DIR, commit: false,
1082
- });
1466
+ // ── TWO CLAIMS, BECAUSE THEY ANSWER DIFFERENT QUESTIONS ──────────────────
1467
+ //
1468
+ // BY WORK — "has this piece of work already been declared dead in this room?"
1469
+ // Added 2026-09-25 with generated notices, and required by them. The
1470
+ // text-keyed claim below was load-bearing only while every failure notice in
1471
+ // the fleet was the same deterministic string; `generateFailureNotice` writes
1472
+ // one per ask, so two notices about the SAME dead ask composed a minute apart
1473
+ // by two sweeps are two different sentences saying the same thing to the same
1474
+ // person, and byte-identity no longer finds them. Keyed on the obligation
1475
+ // key, the second is refused whatever words it wears — across a retry that
1476
+ // reopens the obligation and across a daemon restart that rebuilds it,
1477
+ // because the ledger is on disk and the work-keyed window is a day.
1478
+ //
1479
+ // BY TEXT — "would a second copy of this exact sentence add anything?" This
1480
+ // is the ORIGINAL claim and it is deliberately still here. It is what keeps
1481
+ // twenty sibling debts staling together from putting twenty indistinguishable
1482
+ // sentences in one room (`assurance.test.mjs` pins that at 20 → 1), and it is
1483
+ // still reachable in the two cases that matter: the degraded path and the
1484
+ // `ASSURANCE_NOTICE_GEN=0` path both compose the plain sentence, which IS
1485
+ // byte-identical across siblings. Deleting it while adding the work claim
1486
+ // would have swapped one guarantee for another rather than adding one — the
1487
+ // sibling test caught exactly that, which is why it is worth the two calls.
1488
+ //
1489
+ // BOTH must allow. Either refusal suppresses, and a send that lands commits
1490
+ // both; a send that fails commits neither, so the room that heard nothing is
1491
+ // not charged for it.
1492
+ const work = String((rec && rec.key) || "").trim();
1493
+ const claimArgs = [
1494
+ { service: rec.service, channel: rec.channel, notice, text, work, now, agentRoot: AGENT_REPO_DIR },
1495
+ { service: rec.service, channel: rec.channel, notice, text, now, agentRoot: AGENT_REPO_DIR },
1496
+ ];
1497
+ let peek = null;
1498
+ for (const args of claimArgs) {
1499
+ const v = claim({ ...args, commit: false });
1500
+ if (v && v.allowed === false) { peek = v; break; }
1501
+ }
1083
1502
  if (peek && peek.allowed === false) {
1084
1503
  // This room already has this exact sentence on screen. Stamp the record
1085
1504
  // too: the fact HAS been told here, so a later tick of this same debt must
@@ -1095,10 +1514,7 @@ async function sayOutcomeNotice(o) {
1095
1514
  // SPEND ONLY ON A DELIVERED MESSAGE, exactly as the interim budget does: a
1096
1515
  // send that failed is a room that heard nothing, and recording it would
1097
1516
  // suppress the one message that still needed to go out.
1098
- claim({
1099
- service: rec.service, channel: rec.channel, notice, text, now,
1100
- agentRoot: AGENT_REPO_DIR, commit: true,
1101
- });
1517
+ for (const args of claimArgs) claim({ ...args, commit: true });
1102
1518
  }
1103
1519
  return { sent: !!(res && res.sent), reason: res && res.sent ? "sent" : ((res && res.error) || "send-failed") };
1104
1520
  }
@@ -1464,6 +1880,24 @@ export async function settleSession(a = {}) {
1464
1880
  }
1465
1881
 
1466
1882
  if (a.ok && !heard) {
1883
+ // A CLEAN SILENT EXIT OUTSIDE A DM CLOSES QUIETLY, LIKE A DOC COMMENT.
1884
+ // The rescue message exists to hand a waiting requester the result their
1885
+ // session produced but never sent. On an ambient/membership-proved surface
1886
+ // there is no such requester — the seat was addressed because it can see
1887
+ // the room, not because anyone named it — so the same message is result
1888
+ // text NARRATED into a shared room, the leak doc-comment surfaces already
1889
+ // close silently to avoid. The debt is still discharged (silent is not
1890
+ // un-closed); only the narration is withheld. `direct_reason` reaches here
1891
+ // through `itemSnapshot`, and the guard fails open to narrating.
1892
+ if (isAmbientSurface(rec)) {
1893
+ closeObligation(a.key, {
1894
+ outcome: "answered",
1895
+ now,
1896
+ note: "session exited 0 without speaking; ambient surface closed without narration",
1897
+ });
1898
+ counters.bump("assurance.silent_success_ambient", { service: rec.service });
1899
+ return { spoke: false, text: null, verdict: "silent-success", willRetry: false };
1900
+ }
1467
1901
  const finalText = safeResultText(a.stdout);
1468
1902
  const text = composeSilentSuccess(rec, { finalText });
1469
1903
  const res = await send(item, text, { kind: "reply", idempotencySuffix: `rescue-${rec.attempts || 0}` });
@@ -1484,7 +1918,17 @@ export async function settleSession(a = {}) {
1484
1918
  rec.lastFailureAt = now;
1485
1919
  writeRecord(rec);
1486
1920
 
1487
- const text = composeFailure(rec, { failure, willRetry });
1921
+ // GENERATED, not stamped. `generateFailureNotice` always returns text — the
1922
+ // degraded path is a plainer sentence, never silence — so the guarantee the
1923
+ // deterministic composer bought is intact while the words are this ask's own.
1924
+ const noticeId0 = willRetry ? NOTICE.FAILURE_RETRYING : NOTICE.FAILURE_FINAL;
1925
+ const notice0 = await generateFailureNotice(
1926
+ rec,
1927
+ { failure, willRetry, mayWrite: claimWrittenVoice(rec, noticeId0, now, a.deps && a.deps.roomNoticeImpl) },
1928
+ a.deps && a.deps.noticeGen,
1929
+ );
1930
+ const text = notice0.text;
1931
+ if (notice0.degraded) counters.bump("assurance.notice_degraded", { notice: willRetry ? NOTICE.FAILURE_RETRYING : NOTICE.FAILURE_FINAL });
1488
1932
  // SAID ONCE PER (channel, key, notice). The retry loop is what made this
1489
1933
  // necessary: a transient failure posts a notice, the item is retried, a fresh
1490
1934
  // obligation opens under the same key, it fails the same way, and the same
@@ -1632,14 +2076,111 @@ export async function sweepObligations(a = {}) {
1632
2076
  }
1633
2077
  }
1634
2078
 
2079
+ /**
2080
+ * PURE. May this debt still produce a courtesy interim at all?
2081
+ *
2082
+ * The four durable latches, in one place, because branch (e)'s entry condition
2083
+ * and the batch membership test MUST be the same predicate. When they were the
2084
+ * same expression written twice, a batch could count a member that the branch
2085
+ * would never have spoken for — inflating the batch, moving `lastAt`, and
2086
+ * delaying an acknowledgement on behalf of a record that was already silent.
2087
+ *
2088
+ * @param {object} rec
2089
+ * @returns {boolean}
2090
+ */
2091
+ export function interimEligible(rec) {
2092
+ if (!rec || typeof rec !== "object") return false;
2093
+ return !rec.interimSaid && !rec.interimForbidden && !rec.ackSuppressed && rec.tier !== "answer";
2094
+ }
2095
+
2096
+ /**
2097
+ * The batch `rec` belongs to this tick.
2098
+ *
2099
+ * Membership is every OPEN, interim-eligible, deliverable debt in the same
2100
+ * conversation that this tick has not already consumed — plus `rec` itself
2101
+ * unconditionally, since it is the record asking. `consumed` holds the keys
2102
+ * branches (a)–(d) have already closed or spoken to in this pass: a debt that
2103
+ * has just been declared stale is not waiting for an acknowledgement, and
2104
+ * counting it would let a dead ask hold a live one's batch open.
2105
+ *
2106
+ * Not pure (it reads nothing, but it takes the sweep's live arrays); the
2107
+ * decisions it delegates to — `groupIntoBatches`, `batchVerdict` — are.
2108
+ */
2109
+ function batchFor(rec, open, consumed, now, isForeignAlive = () => false) {
2110
+ const records = open.filter(
2111
+ (r) => r === rec || (
2112
+ !consumed.has(r.key)
2113
+ && interimEligible(r)
2114
+ && r.deliverable !== false
2115
+ // A SIBLING ANOTHER LIVE DAEMON OWNS IS NOT OURS TO SILENCE. The sweep
2116
+ // already refuses to NARRATE a debt whose `daemonPid` belongs to a live
2117
+ // foreign process (see `foreignOwnerAlive` at the top of the loop), for
2118
+ // the obvious reason that two daemons on one AGENT_DIR would otherwise
2119
+ // say the same thing twice. Batching reaches further than narrating
2120
+ // did: the carrier LATCHES every member it speaks for, so without this
2121
+ // the carrier would set `interimSaid` on a debt the other process was
2122
+ // about to acknowledge, and that acknowledgement would never be sent by
2123
+ // anybody. Only reachable with two daemons sharing one obligations
2124
+ // directory — already pathological — but the previous per-record code
2125
+ // could not reach it at all, because it never touched another record.
2126
+ && !isForeignAlive(r)
2127
+ ),
2128
+ );
2129
+ const batches = groupIntoBatches({ records, now });
2130
+ return batches.find((b) => b.records.includes(rec)) || { conversation: null, urgent: false, records: [rec] };
2131
+ }
2132
+
2133
+ /**
2134
+ * Is this debt owned by a DIFFERENT, provably live process?
2135
+ *
2136
+ * One definition, two callers — the sweep's per-record narrating gate and
2137
+ * `batchFor`'s membership filter — because a batch that admits a record the
2138
+ * sweep would refuse to narrate can latch it silent on that owner's behalf.
2139
+ *
2140
+ * @param {object} rec
2141
+ * @param {(pid:number)=>boolean} alive
2142
+ */
2143
+ function ownedByLiveForeign(rec, alive) {
2144
+ return !!(rec && rec.daemonPid && rec.daemonPid !== process.pid && alive(rec.daemonPid));
2145
+ }
2146
+
2147
+ /**
2148
+ * Latch a whole batch silent without saying anything.
2149
+ *
2150
+ * SILENT IS NOT DROPPED, and the distinction is the one this module was built
2151
+ * on: `state` is untouched, so every debt stays open, swept, and covered by
2152
+ * the stale and failure notices. What is withheld is the courtesy — the half
2153
+ * that carries no information the reader cannot get from the answer itself.
2154
+ */
2155
+ function latchBatchSilent(verdict, now, reason) {
2156
+ const all = verdict.carrier ? [verdict.carrier, ...verdict.speaksFor] : verdict.speaksFor;
2157
+ for (const r of all) {
2158
+ if (!r) continue;
2159
+ r.interimSaid = true;
2160
+ r.interimSuppressedAt = now;
2161
+ r.interimSuppressedReason = reason;
2162
+ writeRecord(r);
2163
+ }
2164
+ }
2165
+
1635
2166
  async function _sweepObligations(a = {}) {
1636
2167
  const now = Number.isFinite(a.now) ? a.now : Date.now();
1637
2168
  const send = (a.deps && a.deps.deliverImpl) || deliver;
1638
2169
  const heardBy = (a.deps && (a.deps.spokeForImpl || a.deps.spokeSinceImpl)) || spokeFor;
1639
2170
  const alive = (a.deps && a.deps.isPidAliveImpl) || isPidAlive;
1640
- const stats = { swept: 0, acked: 0, staled: 0, closed: 0, interrupted: 0, unreachable: 0, suppressed: 0 };
2171
+ const stats = {
2172
+ swept: 0, acked: 0, staled: 0, closed: 0, interrupted: 0, unreachable: 0, suppressed: 0,
2173
+ // How many debts this tick's acknowledgements spoke FOR rather than to —
2174
+ // the flurry that drew one line instead of four — and how many batches are
2175
+ // deliberately still assembling. Both are ordinary operation, not faults;
2176
+ // they are counted because a batching window nobody can measure is a
2177
+ // window nobody can size.
2178
+ batched: 0, batchWaiting: 0,
2179
+ };
1641
2180
 
1642
2181
  const open = openObligations();
2182
+ // Keys this pass has already closed or spoken to. See `batchFor`.
2183
+ const consumed = new Set();
1643
2184
  for (const rec of open) {
1644
2185
  stats.swept++;
1645
2186
  // Two clocks, deliberately. `age` is how long the HUMAN has been waiting and
@@ -1659,7 +2200,7 @@ async function _sweepObligations(a = {}) {
1659
2200
  // gone. The terminal branches above and below — answered, undeliverable,
1660
2201
  // stale — stay shared: closing a discharged or dead debt is safe from
1661
2202
  // either process, and branch (e) already proves the owner dead itself.
1662
- const foreignOwnerAlive = !!(rec.daemonPid && rec.daemonPid !== process.pid && alive(rec.daemonPid));
2203
+ const foreignOwnerAlive = ownedByLiveForeign(rec, alive);
1663
2204
 
1664
2205
  // (a) The session already answered — discharge without saying anything.
1665
2206
  // Attributed, not room-wide: see spokeFor. An unattributed message in a room
@@ -1675,6 +2216,7 @@ async function _sweepObligations(a = {}) {
1675
2216
  });
1676
2217
  if (v && v.heard) {
1677
2218
  closeObligation(rec.key, { outcome: "answered", now, note: `receipt:${v.basis}` });
2219
+ consumed.add(rec.key);
1678
2220
  stats.closed++;
1679
2221
  continue;
1680
2222
  }
@@ -1692,6 +2234,7 @@ async function _sweepObligations(a = {}) {
1692
2234
  };
1693
2235
  escalate(rec, { failure, now, told: false });
1694
2236
  closeObligation(rec.key, { outcome: "undeliverable", now, note: failure.label });
2237
+ consumed.add(rec.key);
1695
2238
  stats.unreachable++;
1696
2239
  continue;
1697
2240
  }
@@ -1705,7 +2248,13 @@ async function _sweepObligations(a = {}) {
1705
2248
  // exactly the obligations that most need one.
1706
2249
  if (workAge >= STALE_AFTER_MS) {
1707
2250
  const failure = { transient: false, label: "no_outcome", human: "the work never came back with a result and has now outrun its time limit" };
1708
- const text = composeFailure(rec, { failure, willRetry: false });
2251
+ const notice = await generateFailureNotice(
2252
+ rec,
2253
+ { failure, willRetry: false, mayWrite: claimWrittenVoice(rec, NOTICE.FAILURE_FINAL, now, a.deps && a.deps.roomNoticeImpl) },
2254
+ a.deps && a.deps.noticeGen,
2255
+ );
2256
+ const text = notice.text;
2257
+ if (notice.degraded) counters.bump("assurance.notice_degraded", { notice: NOTICE.FAILURE_FINAL });
1709
2258
  // SAID ONCE PER (channel, key) — AND once per identical sentence per
1710
2259
  // room. The retry loop used to narrate every attempt (notice → retry →
1711
2260
  // fresh obligation → notice), and the notice ledger inherited across the
@@ -1726,6 +2275,7 @@ async function _sweepObligations(a = {}) {
1726
2275
  writeRecord(rec);
1727
2276
  escalate(rec, { failure, now, told: !!(res && res.sent) });
1728
2277
  closeObligation(rec.key, { outcome: "failed", now, note: "stale-no-outcome" });
2278
+ consumed.add(rec.key);
1729
2279
  stats.staled++;
1730
2280
  continue;
1731
2281
  }
@@ -1754,7 +2304,18 @@ async function _sweepObligations(a = {}) {
1754
2304
  counters.bump("assurance.notice_deduped", { notice: NOTICE.INTERRUPTED });
1755
2305
  continue;
1756
2306
  }
1757
- const text = composeInterrupted(rec);
2307
+ const interruptedNotice = await generateFailureNotice(
2308
+ rec,
2309
+ {
2310
+ failure: { human: "my end restarted while it was running" },
2311
+ willRetry: false,
2312
+ kind: NOTICE.INTERRUPTED,
2313
+ mayWrite: claimWrittenVoice(rec, NOTICE.INTERRUPTED, now, a.deps && a.deps.roomNoticeImpl),
2314
+ },
2315
+ a.deps && a.deps.noticeGen,
2316
+ );
2317
+ const text = interruptedNotice.text;
2318
+ if (interruptedNotice.degraded) counters.bump("assurance.notice_degraded", { notice: NOTICE.INTERRUPTED });
1758
2319
  const res = await sayOutcomeNotice({
1759
2320
  rec, notice: NOTICE.INTERRUPTED, text, item, kind: "notice",
1760
2321
  idempotencySuffix: `interrupted-${rec.attempts || 0}`, now, send,
@@ -1763,12 +2324,26 @@ async function _sweepObligations(a = {}) {
1763
2324
  rec.interruptedNotifiedAt = now;
1764
2325
  rec.daemonPid = process.pid;
1765
2326
  writeRecord(rec);
2327
+ consumed.add(rec.key);
1766
2328
  if (res.sent) stats.interrupted++;
1767
2329
  continue;
1768
2330
  }
1769
2331
 
1770
2332
  // (e) THE ONE INTERIM. This is the only branch in the system that emits a
1771
- // courtesy message, and it is gated four ways before it does:
2333
+ // courtesy message, and it is gated six ways before it does:
2334
+ //
2335
+ // BATCH — the unit is the CONVERSATION, not the message. Every open,
2336
+ // eligible debt in this room forms one batch; the batch is not
2337
+ // ready until it has been QUIET (20 s) after its newest member,
2338
+ // and when it is ready exactly ONE of them — the newest — writes
2339
+ // the line, having been handed all of their messages. The others
2340
+ // are latched `interimSaid` with `spokenForBy` naming the
2341
+ // carrier. This is what the room budget could not do: the budget
2342
+ // caps HOW MANY, it cannot choose WHICH, and "one arbitrary
2343
+ // reply out of four" is not "one reply to the four".
2344
+ // ANSWER — and before any of it, `spokeSince`: if something substantive
2345
+ // has already reached this room since the batch opened, a
2346
+ // holding line behind it is a contradiction. See PIN 2.
1772
2347
  //
1773
2348
  // TIME — nothing at all before ACK_AFTER_MS. Below ninety seconds the
1774
2349
  // typing indicator is the acknowledgement; a message there is
@@ -1805,11 +2380,89 @@ async function _sweepObligations(a = {}) {
1805
2380
  // generation that yields nothing usable still sends NOTHING rather than a
1806
2381
  // canned line. The debt stays open either way: silence about timing is not
1807
2382
  // silence about outcome, and the failure/stale notices still speak.
1808
- if (
1809
- !rec.interimSaid && !rec.interimForbidden && !rec.ackSuppressed
1810
- && rec.tier !== "answer"
1811
- && !foreignOwnerAlive && age >= ACK_AFTER_MS
1812
- ) {
2383
+ // THE AGE GATE MOVED INTO THE BATCH VERDICT, AND HAD TO.
2384
+ //
2385
+ // It used to live here as `age >= ACK_AFTER_MS`, per record. With a batch
2386
+ // that deadlocks: in a four-message flurry only the OLDEST record is past
2387
+ // ninety seconds, and the record that must speak is the NEWEST — so the
2388
+ // oldest entered the branch and deferred to a carrier that the branch
2389
+ // itself would not admit for another nine seconds, every tick, forever.
2390
+ // `batchVerdict` measures the same ninety seconds from the batch's FIRST
2391
+ // arrival, which is the same promise to the same waiting human, made once
2392
+ // for the conversation instead of once per message.
2393
+ if (interimEligible(rec) && !foreignOwnerAlive) {
2394
+ // ── THE UNIT OF ACKNOWLEDGEMENT IS THE CONVERSATION ────────────────────
2395
+ // Four messages in a row must not draw four acknowledgements, and — the
2396
+ // half the room budget alone could never give — the ONE that does go out
2397
+ // must have been written with all four in front of it. The budget caps
2398
+ // HOW MANY; this decides WHICH and WHEN. See lib/assurance/batch.mjs.
2399
+ const mine = batchFor(rec, open, consumed, now, (r) => ownedByLiveForeign(r, alive));
2400
+ const verdict = batchVerdict({
2401
+ batch: mine,
2402
+ now,
2403
+ ackAfterMs: ACK_AFTER_MS,
2404
+ quietMs: batchQuietMs(),
2405
+ maxSpanMs: batchMaxSpanMs(),
2406
+ });
2407
+
2408
+ // ONE DECISION PER BATCH PER TICK. Only the carrier decides; every
2409
+ // other member of the batch defers to it in this pass and is latched by
2410
+ // it below if it speaks. Checked BEFORE readiness so the counters,
2411
+ // the receipt question and the budget peek each happen once per
2412
+ // conversation rather than once per message in it.
2413
+ if (verdict.carrier !== rec) continue;
2414
+
2415
+ // BOUNDARY PIN 1 — A BATCH THAT IS NOT READY CHANGES NOTHING.
2416
+ // `ready:false` means "still assembling", never "refused": nothing is
2417
+ // latched, nothing is spent, no counter moves. That is the only way the
2418
+ // LAST message of a flurry can join the batch it belongs to — if an
2419
+ // unready tick latched its older siblings silent, message four would
2420
+ // arrive into a room that had already decided not to speak.
2421
+ if (!verdict.ready) {
2422
+ stats.batchWaiting++;
2423
+ continue;
2424
+ }
2425
+
2426
+ // BOUNDARY PIN 2 — AN ANSWER OUTRANKS THE COURTESY THAT PRECEDES IT.
2427
+ // A batch still open when the work finishes must not post a holding line
2428
+ // behind the answer. Branch (a) cannot catch this: `spokeFor` refuses to
2429
+ // close a debt on an unattributed receipt while siblings are open
2430
+ // (`ambiguous-room`), which is right for CLOSING — guessing which debt a
2431
+ // message answered is how an unanswered ask gets marked answered — and
2432
+ // wrong for SPEAKING. The question an interim has to pass is weaker and
2433
+ // room-shaped: has this person already heard something substantive from
2434
+ // me since they asked? If they have, "I'm looking at this" is false
2435
+ // whichever debt it belonged to. The debts stay open and tracked; only
2436
+ // the courtesy is withheld, exactly as over-budget is treated.
2437
+ // NOT `spokeSinceImpl`: branch (a) already claims that name as an alias
2438
+ // for its `spokeFor` seam, and one injected fake driving two different
2439
+ // questions is how a test proves the wrong thing.
2440
+ const spokeImpl = (a.deps && a.deps.roomSpokeImpl) || spokeSince;
2441
+ // AND IT ASKS ONLY ONCE A SESSION HAS ACTUALLY RUN. This is the same
2442
+ // condition `spokeFor`'s rung 3 imposes, and for the same reason: before
2443
+ // any session of ours has run, a message in this room came from
2444
+ // somewhere else — a cadence post, a broadcast, a different debt
2445
+ // entirely — and treating it as the answer to a flurry nobody has
2446
+ // started work on would leave a person never acknowledged and never
2447
+ // answered. Once a session HAS run, a substantive message in the room is
2448
+ // overwhelmingly that work landing, whichever debt carries its receipt.
2449
+ const anyRan = mine.records.some((r) => (r.attempts || 0) > 0);
2450
+ let answered = false;
2451
+ try {
2452
+ answered = anyRan && spokeImpl({
2453
+ channel: rec.channel,
2454
+ sinceMs: verdict.firstAt,
2455
+ excludeKinds: NON_ANSWER_KINDS,
2456
+ agentRoot: AGENT_REPO_DIR,
2457
+ }) === true;
2458
+ } catch { answered = false; } // a receipt-ledger fault must not gag the room
2459
+ if (answered) {
2460
+ latchBatchSilent(verdict, now, "answer-already-landed");
2461
+ counters.bump("assurance.interim_suppressed", { reason: "answer-already-landed" });
2462
+ stats.suppressed += verdict.size;
2463
+ continue;
2464
+ }
2465
+
1813
2466
  const budgetOf = (a.deps && a.deps.roomBudgetImpl) || claimRoomInterim;
1814
2467
  // ASK, then compose, then SPEND. The interim's text comes from a model
1815
2468
  // call that can fail; claiming the room's fifteen minutes before the
@@ -1825,15 +2478,62 @@ async function _sweepObligations(a = {}) {
1825
2478
  // OVER BUDGET IS NOT A DROP. The debt stays open and tracked exactly as
1826
2479
  // it was; what is withheld is the content-free half. The room has heard
1827
2480
  // from this agent inside the window and does not need to hear it again.
1828
- rec.interimSaid = true;
1829
- rec.interimSuppressedAt = now;
1830
- writeRecord(rec);
2481
+ // The whole batch is latched, not just the carrier: its siblings are in
2482
+ // the same room and would each re-ask the same refused question.
2483
+ latchBatchSilent(verdict, now, (budget && budget.reason) || "room-budget");
1831
2484
  counters.bump("assurance.interim_suppressed", { reason: (budget && budget.reason) || "room-budget" });
1832
- stats.suppressed++;
2485
+ stats.suppressed += verdict.size;
1833
2486
  continue;
1834
2487
  }
2488
+ // ── SOMETIMES A REACTION IS THE WHOLE REPLY ────────────────────────
2489
+ // The owner's third case, and the narrowest: "sometimes the initial
2490
+ // response can be an emoji response". `firstReplyShape` decides, not a
2491
+ // prompt — the distinction it draws is that an emoji acknowledges an FYI
2492
+ // and never answers a QUESTION, and a rule stated only in a prompt is a
2493
+ // rule nothing checks (the ack prompt banned "on it" from the day it was
2494
+ // written and 3,069 of them shipped anyway).
2495
+ //
2496
+ // Reached only here, inside every gate the interim already passes — time,
2497
+ // tier, once, room budget — so a reaction can never be louder than the
2498
+ // line it replaces. It is cheaper than a line in the one way that matters
2499
+ // to a reader: it adds no row to the channel.
2500
+ const shape = firstReplyShape({
2501
+ tier: rec.tier,
2502
+ action: rec.action,
2503
+ directed: rec.directed !== false,
2504
+ text: item.content || item.subject || "",
2505
+ reactionsAvailable: canReactTo(item),
2506
+ reaction: "seen",
2507
+ });
2508
+ if (shape.shape === "react") {
2509
+ const react = (a.deps && a.deps.reactImpl) || deliverReaction;
2510
+ const rres = await react(item, shape.emoji);
2511
+ if (rres && rres.sent) {
2512
+ budgetOf({ service: rec.service, channel: rec.channel, now, agentRoot: AGENT_REPO_DIR, commit: true });
2513
+ rec.acknowledged = true;
2514
+ rec.ackAt = now;
2515
+ rec.interimSaid = true;
2516
+ rec.interimAt = now;
2517
+ rec.ackShape = "react";
2518
+ writeRecord(rec);
2519
+ counters.bump("assurance.interim_reaction", { emoji: shape.emoji });
2520
+ stats.acked++;
2521
+ continue;
2522
+ }
2523
+ // A reaction that would not go out is not a reason to stay silent —
2524
+ // fall through and write the line instead.
2525
+ counters.bump("assurance.interim_reaction_failed", { reason: (rres && rres.error) || "unknown" });
2526
+ }
2527
+
1835
2528
  const gen = (a.deps && a.deps.generateAckImpl) || generateAck;
1836
- const text = await gen(item, { summary: rec.summary, model: rec.model, priority: rec.priority });
2529
+ // THE BATCH, oldest first, is what the line is composed from. With one
2530
+ // member this is the message itself and the prompt is unchanged.
2531
+ const batchText = batchMessages(mine.records);
2532
+ const text = await gen(
2533
+ item,
2534
+ { summary: rec.summary, model: rec.model, priority: rec.priority },
2535
+ { batch: batchText },
2536
+ );
1837
2537
  if (text == null) {
1838
2538
  rec.ackGenFailures = (rec.ackGenFailures || 0) + 1;
1839
2539
  if (rec.ackGenFailures >= ACK_GEN_MAX_FAILURES) {
@@ -1855,8 +2555,23 @@ async function _sweepObligations(a = {}) {
1855
2555
  rec.ackAt = now;
1856
2556
  rec.interimSaid = true;
1857
2557
  rec.interimAt = now;
2558
+ rec.batchSize = verdict.size;
2559
+ rec.batchReason = verdict.reason;
1858
2560
  writeRecord(rec);
2561
+ // THE SIBLINGS THIS LINE SPOKE FOR. Latched here and not left to the
2562
+ // room budget, for a reason that is not cosmetic: the budget expires
2563
+ // after fifteen minutes, and a sibling still open at minute sixteen
2564
+ // would then post a SECOND first-contact line about a conversation
2565
+ // that was acknowledged a quarter of an hour ago. `interimSaid` never
2566
+ // expires; the budget does.
2567
+ for (const sib of verdict.speaksFor) {
2568
+ sib.interimSaid = true;
2569
+ sib.interimAt = now;
2570
+ sib.spokenForBy = rec.key;
2571
+ writeRecord(sib);
2572
+ }
1859
2573
  stats.acked++;
2574
+ stats.batched += verdict.speaksFor.length;
1860
2575
  } else {
1861
2576
  // A permanent refusal (no transport, policy block) is worth five ticks
1862
2577
  // of nobody's time; count it out at once.
@@ -1932,11 +2647,16 @@ export default {
1932
2647
  generateAck,
1933
2648
  sanitiseAckText,
1934
2649
  composeFailure,
2650
+ generateFailureNotice,
2651
+ noticeGenEnabled,
2652
+ _buildAckUserPromptForTest: buildAckUserPrompt,
1935
2653
  composeSilentSuccess,
1936
2654
  composeInterrupted,
2655
+ looksLikePeerOutcomeNotice,
1937
2656
  classifyFailure,
1938
2657
  openAndAcknowledge,
1939
2658
  notePlan,
2659
+ interimEligible,
1940
2660
  _resetSweepGuard,
1941
2661
  noticeAlreadySaid,
1942
2662
  markNoticeSaid,