@cohortapp/agent-sdk 2.18.14 → 2.18.15

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
@@ -85,7 +85,30 @@ import {
85
85
  ledgerPath as roomBudgetPath,
86
86
  LEDGER_BASENAME as ROOM_BUDGET_BASENAME,
87
87
  } from "../../lib/assurance/room-budget.mjs";
88
- import { renderPlanNote, GENERIC_OPENER } from "../../lib/assurance/plan-note.mjs";
88
+ import { renderPlanNote, GENERIC_OPENER, MESSAGE_TALLY } from "../../lib/assurance/plan-note.mjs";
89
+ // The first thing said is WRITTEN, not stamped: `firstReplyShape` carries the
90
+ // model-authored opener's shape rules and `isRetiredFirstReply` rejects the
91
+ // canned rotation this work retired. `notice-voice` does the same job for the
92
+ // progress/failure notices, which used to be fixed strings.
93
+ import {
94
+ buildNoticeSystemPrompt,
95
+ buildNoticeUserPrompt,
96
+ noticeSource,
97
+ noticeRejection,
98
+ fallbackNotice,
99
+ } from "../../lib/assurance/notice-voice.mjs";
100
+ import { isRetiredFirstReply, firstReplyShape } from "../../lib/assurance/first-reply.mjs";
101
+ // The unit of acknowledgement is the CONVERSATION, not the message: four
102
+ // messages in a row earn ONE first response that was written with all four in
103
+ // front of it. `groupIntoBatches` + `batchVerdict` decide WHEN and WHICH;
104
+ // `batchMessages` is what the composer is handed. All pure — see batch.mjs.
105
+ import {
106
+ groupIntoBatches,
107
+ batchVerdict,
108
+ batchMessages,
109
+ batchQuietMs,
110
+ batchMaxSpanMs,
111
+ } from "../../lib/assurance/batch.mjs";
89
112
 
90
113
  // ── Board mirror seams ───────────────────────────────────────────────────────
91
114
  // Imported lazily so a board module problem can never stop the daemon booting,
@@ -230,6 +253,36 @@ export const ACK_GEN_MAX_FAILURES = num(process.env.ASSURANCE_ACK_GEN_MAX_FAILUR
230
253
  */
231
254
  export const ACK_MIN_CHARS = num(process.env.ASSURANCE_ACK_MIN_CHARS, 20);
232
255
 
256
+ /**
257
+ * How long a NOTICE may spend in generation.
258
+ *
259
+ * Twice the ack's budget, and deliberately not the same number. The ack races a
260
+ * typing indicator in front of someone who is about to wait minutes anyway, so
261
+ * ~5s is the right aggression there and silence is an acceptable loss. A notice
262
+ * races nothing — the work is already dead — and its loss is not silence but
263
+ * `fallbackNotice`, which is a worse message rather than no message. Spending a
264
+ * few more seconds to avoid the worse message is the right trade in exactly the
265
+ * direction the ack's is not.
266
+ */
267
+ export const NOTICE_GEN_TIMEOUT_MS = num(process.env.ASSURANCE_NOTICE_GEN_TIMEOUT_MS, 10_000);
268
+
269
+ /**
270
+ * `ASSURANCE_NOTICE_GEN=0` turns notice generation off and goes straight to the
271
+ * plain wording.
272
+ *
273
+ * An operator switch, not a feature flag: a seat whose model access is broken
274
+ * would otherwise pay `NOTICE_GEN_TIMEOUT_MS` per dead obligation per sweep to
275
+ * arrive at the same fallback it could have reached instantly. Nothing is lost
276
+ * when it is off — the person still hears that their work died, in a plainer
277
+ * sentence — which is the property that makes it safe to have a switch at all.
278
+ *
279
+ * Read at the EDGE, not captured at import, so exporting it in a shell that is
280
+ * already running takes effect on the next notice.
281
+ */
282
+ export function noticeGenEnabled(env = process.env) {
283
+ return String((env && env.ASSURANCE_NOTICE_GEN) ?? "1") !== "0";
284
+ }
285
+
233
286
  /** The tiers `lib/assurance/tier.mjs` can return; anything else is a caller bug
234
287
  * and is treated as `work` — the tier that can still speak once. */
235
288
  const TIER_SET = new Set(["answer", "work", "plan"]);
@@ -405,6 +458,41 @@ const IMPERATIVE_OPENER =
405
458
  /^(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
459
  const REQUEST_MARKER = /\b(please|pls|can you|could you|would you|need you to)\b/i;
407
460
 
461
+ /**
462
+ * Is this inbound the SHAPE of another seat's OUTCOME NOTICE?
463
+ *
464
+ * WHY A SHAPE TEST IS NOW NEEDED AT ALL. `isOwnNarratorText` matches the
465
+ * daemon's notices verbatim, and it could, because they were deterministic
466
+ * strings composed by this very file — `narratorPatterns()` derives its regexes
467
+ * from the composers so the two cannot drift. `generateFailureNotice` ends
468
+ * that: a notice is now written per ask, in the seat's own voice, so no
469
+ * verbatim pattern can recognise one. Without this, a peer's generated "that
470
+ * export died on me, I've flagged it" reaches the dispatch gate as an ordinary
471
+ * message, earns an ack, and spawns a 15-45 minute session behind it — the
472
+ * 25/27/30 Aug loop, reopened by the very change that made the notices humane.
473
+ *
474
+ * Narrow on purpose, and narrower than `looksLikePeerAckShape`: the sender must
475
+ * be an agent seat (a human can never match — see `senderLooksLikeAgentSeat`),
476
+ * the text must be short, and it must carry BOTH a first-person subject and a
477
+ * failure predicate. A peer reporting a fact ("the export is 400 rows short")
478
+ * or asking for help ("can you take this one?") still passes the gate, because
479
+ * a false consume is silent to the sender and unrecoverable while a false pass
480
+ * costs one audited redundant session.
481
+ *
482
+ * @param {object} item
483
+ * @returns {boolean}
484
+ */
485
+ export function looksLikePeerOutcomeNotice(item) {
486
+ const content = String((item && item.content) || "").trim();
487
+ if (!content) return false;
488
+ if (content.length > 400) return false;
489
+ if (!senderLooksLikeAgentSeat(item)) return false;
490
+ if (REQUEST_MARKER.test(content)) return false;
491
+ if (IMPERATIVE_OPENER.test(content)) return false;
492
+ if (!/\b(i|my|i'?ve|i'?m)\b/i.test(content)) return false;
493
+ 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);
494
+ }
495
+
408
496
  /**
409
497
  * Is this inbound the SHAPE of an acknowledgement from a peer agent seat?
410
498
  *
@@ -462,6 +550,17 @@ export function looksLikePeerAckShape(item) {
462
550
  export const RETIRED_NARRATOR_TEMPLATES = Object.freeze([
463
551
  "Still on this — 1 minutes in. I'll come back as soon as I've got something.",
464
552
  "Still going — 1 minutes in. I'll follow up the moment it's done.",
553
+ // The two failure sentences `composeFailure` stamped until 2026-09-25, with
554
+ // the sentinel standing in for the interpolated cause exactly as the derived
555
+ // templates below do. They MUST stay here now that nothing composes them:
556
+ // two seats in the fleet were still running a preserved local copy of this
557
+ // daemon on 2026-09-25 (Ravi Patel and Daniel Connors, both reporting SDK
558
+ // 2.18.13 while executing an older `$AGENT_ROOT/scripts/daemon` tree), so a
559
+ // seat that has updated goes on RECEIVING these from seats that have not.
560
+ // Dropping them with the emitter would mean this seat acks a peer's failure
561
+ // notice and spawns a 15-45 minute session behind it.
562
+ "Hit a problem — @@CAUSE@@. Retrying now; if it fails again I'll come straight back rather than leave you waiting.",
563
+ "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
564
  ]);
466
565
 
467
566
  let _narratorPatterns = null;
@@ -571,6 +670,15 @@ export function shouldAcknowledge(o = {}) {
571
670
  willSpawnSession: o.willSpawnSession === true,
572
671
  });
573
672
  if (!o.willSpawnSession) return { ack: false, reason: "answered-in-turn", tier };
673
+ // ONE FIRST REPLY PER FLURRY. Stamped by the poll loop, which is the only
674
+ // place that sees more than one item at a time (`agent-daemon.pollService`).
675
+ // `ack:false` here is what the daemon turns into `interimForbidden` on the
676
+ // record, so the silence is durable: no later sweep in any process speaks for
677
+ // a message whose flurry has already been answered. The DEBT is still opened
678
+ // by the caller — every item in the batch keeps its own obligation and its
679
+ // own answer.
680
+ const batched = o.item || {};
681
+ if (batched.covered_by_batch === true) return { ack: false, reason: "covered-by-batch", tier };
574
682
  if (o.source && o.source !== "inbox") return { ack: false, reason: "no-waiting-human", tier };
575
683
  const item = o.item || {};
576
684
  if (!item.service) return { ack: false, reason: "no-service", tier };
@@ -605,6 +713,10 @@ export function shouldAcknowledge(o = {}) {
605
713
  logDispatchGateRefusal(item, "peer-ack-shaped");
606
714
  return { ack: false, reason: "peer-ack-shaped", emitterClass: true, tier };
607
715
  }
716
+ if (looksLikePeerOutcomeNotice(item)) {
717
+ logDispatchGateRefusal(item, "peer-outcome-notice");
718
+ return { ack: false, reason: "peer-outcome-notice", emitterClass: true, tier };
719
+ }
608
720
  // ── A ROLL-CALL NEVER GETS AN INTERIM ─────────────────────────────────────
609
721
  // A human asked the whole room for a line each. Thirteen "checking now"
610
722
  // lines followed by thirteen answers is twice the traffic and half the
@@ -738,9 +850,15 @@ function runAckModelWith(seat, systemPrompt, userPrompt, o = {}) {
738
850
  * Null is a first-class answer here — it means "send nothing". Rejected:
739
851
  * empty output, fenced/quoted wrappers that survive stripping into nothing,
740
852
  * anything over 200 chars (that is a reply, not an ack), refusal/meta text,
741
- * and the assistant-y boilerplate the communication policy bans.
853
+ * the assistant-y boilerplate the communication policy bans, and — when the
854
+ * line is standing in for a FLURRY — a tally of it.
855
+ *
856
+ * @param {unknown} raw
857
+ * @param {{batched?: boolean}} [opts] `batched:true` when this line answers
858
+ * more than one message, which is the only case where the prompt bans
859
+ * counting and therefore the only case where the filter may.
742
860
  */
743
- export function sanitiseAckText(raw) {
861
+ export function sanitiseAckText(raw, opts = {}) {
744
862
  if (raw == null) return null;
745
863
  let t = String(raw).replace(/```[a-z]*\n?|```/gi, "").trim();
746
864
  // First non-empty line only — an ack is one line by definition.
@@ -755,6 +873,15 @@ export function sanitiseAckText(raw) {
755
873
  // pattern is imported from the copy-rule home rather than re-typed here, so
756
874
  // the instruction and the filter cannot disagree again.
757
875
  if (GENERIC_OPENER.test(t)) return null;
876
+ // THE BATCHED PROMPT, ENFORCED, and for the same reason one line up. The
877
+ // batched close instruction says "never count them, never say how many there
878
+ // are"; nothing checked the output, and the real path returned "Four
879
+ // messages received." and "I have 4 messages from you." verbatim. A tally is
880
+ // a report about an inbox — a colleague replies to what was asked.
881
+ if (opts && opts.batched === true && MESSAGE_TALLY.test(t)) {
882
+ counters.bump("ack.generation_failed", { cause: "message_tally" });
883
+ return null;
884
+ }
758
885
  // Anything this short cannot be specific to what was asked, which is the one
759
886
  // thing the prompt requires of it. "On it." is 6 characters; so is every
760
887
  // variant that would slip past the pattern above by rephrasing.
@@ -788,18 +915,65 @@ function buildAckSystemPrompt() {
788
915
  ].join("\n");
789
916
  }
790
917
 
791
- function buildAckUserPrompt(item, classResult) {
918
+ /**
919
+ * @param {object} item the inbound item the reply is addressed to
920
+ * @param {object} classResult
921
+ * @param {{sender:string|null,text:string}[]} [batch] EVERY message this one
922
+ * line has to answer, oldest first (see lib/assurance/batch.mjs). One
923
+ * entry — the ordinary case — renders exactly as it always did.
924
+ */
925
+ /**
926
+ * The messages a batched ack actually speaks for, oldest first.
927
+ *
928
+ * ONE PREDICATE, TWO CONSUMERS, deliberately. `buildAckUserPrompt` uses it to
929
+ * decide whether to emit the "never count them" instruction, and `generateAck`
930
+ * uses it to decide whether `sanitiseAckText` may enforce that instruction. If
931
+ * the two ever computed "is this a flurry" separately they could disagree, and
932
+ * a filter stricter (or laxer) than its own prompt is the exact failure this
933
+ * module keeps rediscovering.
934
+ *
935
+ * @param {{sender?:string|null,text?:string}[]|undefined} batch
936
+ * @returns {{sender?:string|null,text:string}[]}
937
+ */
938
+ function ackBatchMessages(batch) {
939
+ return Array.isArray(batch) ? batch.filter((m) => m && m.text) : [];
940
+ }
941
+
942
+ function buildAckUserPrompt(item, classResult, batch) {
792
943
  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)}`);
944
+ const msgs = ackBatchMessages(batch);
945
+ if (msgs.length > 1) {
946
+ // A FLURRY. The line that goes out is a reply to the conversation, so the
947
+ // composer sees the conversation — not the last message with the rest
948
+ // thrown away, and not a count of them. "I have four messages" is a report
949
+ // about an inbox; a colleague answers what was asked.
950
+ parts.push(
951
+ `These arrived together, oldest first. They are one conversation, and you are replying to all of them at once:\n`
952
+ + msgs.map((m) => `- ${m.sender ? `${String(m.sender).slice(0, 60)}: ` : ""}${m.text}`).join("\n"),
953
+ );
954
+ } else {
955
+ if (item && item.sender) parts.push(`From: ${String(item.sender).slice(0, 80)}`);
956
+ const content = String((item && (item.content || item.subject)) || "").trim();
957
+ if (content) parts.push(`Message:\n${content.slice(0, 600)}`);
958
+ }
796
959
  const thread = String((item && item.thread_context) || "").trim();
797
960
  if (thread) {
798
961
  const tail = thread.split("\n").filter(Boolean).slice(-6).join("\n");
799
962
  parts.push(`Recent thread:\n${tail.slice(0, 600)}`);
800
963
  }
964
+ // A FLURRY GETS ONE LINE THAT ANSWERS ALL OF IT. The poll loop stamps
965
+ // `batch_size` on the leader; without this the model writes a line about one
966
+ // message while four are on screen, which reads as the agent having noticed
967
+ // only the first.
968
+ const batchSize = Number(item && item.batch_size);
969
+ if (Number.isFinite(batchSize) && batchSize > 1) {
970
+ parts.push(`(They sent ${batchSize} messages in a row just now. Your one line should cover all of them, not just the one above.)`);
971
+ }
801
972
  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.`;
973
+ const close = msgs.length > 1
974
+ ? `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.`
975
+ : `Write the one-line acknowledgement now.`;
976
+ return `${parts.join("\n\n")}\n\n${close}`;
803
977
  }
804
978
 
805
979
  /**
@@ -822,7 +996,10 @@ function buildAckUserPrompt(item, classResult) {
822
996
  *
823
997
  * @param {object} item the inbound item ({sender, content, thread_context, …})
824
998
  * @param {object} [classResult]
825
- * @param {object} [opts] {runImpl, timeoutMs} — runImpl is the test seam
999
+ * @param {object} [opts] {runImpl, timeoutMs, batch} — runImpl is the test seam;
1000
+ * `batch` is every message this one line has to answer, oldest first
1001
+ * (lib/assurance/batch.mjs). Absent or single-entry is the ordinary case
1002
+ * and composes exactly as before.
826
1003
  * @returns {Promise<string|null>}
827
1004
  */
828
1005
  export async function generateAck(item, classResult = {}, opts = {}) {
@@ -830,10 +1007,10 @@ export async function generateAck(item, classResult = {}, opts = {}) {
830
1007
  const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : ACK_GEN_TIMEOUT_MS;
831
1008
  try {
832
1009
  const raw = await withTimeout(
833
- run(buildAckSystemPrompt(), buildAckUserPrompt(item, classResult), { timeoutMs }),
1010
+ run(buildAckSystemPrompt(), buildAckUserPrompt(item, classResult, opts.batch), { timeoutMs }),
834
1011
  timeoutMs,
835
1012
  );
836
- const text = sanitiseAckText(raw);
1013
+ const text = sanitiseAckText(raw, { batched: ackBatchMessages(opts.batch).length > 1 });
837
1014
  if (text == null) {
838
1015
  console.warn(`[assurance] ack generation produced nothing usable — staying silent (no canned fallback, by design)`);
839
1016
  counters.bump("ack.generation_failed", { cause: "unusable_output", timeout_ms: timeoutMs });
@@ -887,18 +1064,191 @@ function withTimeout(promise, ms) {
887
1064
  */
888
1065
 
889
1066
  /**
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.
1067
+ * The failure notice's DEGRADED path — what is said when generation is
1068
+ * unavailable. `generateFailureNotice` is the primary; this is its floor.
1069
+ *
1070
+ * WHAT THIS FUNCTION USED TO BE, AND WHY IT IS NOT THAT ANY MORE. It returned
1071
+ * two fixed sentences:
1072
+ *
1073
+ * "Hit a problem — ${cause}. Retrying now; if it fails again I'll come
1074
+ * straight back rather than leave you waiting."
1075
+ * "Couldn't finish this — ${cause}. I've stopped retrying and flagged it so
1076
+ * it isn't lost. Want me to try a narrower version, hand it off, or leave it
1077
+ * with you?"
1078
+ *
1079
+ * Both are in `first-reply.RETIRED_FIRST_REPLIES` now, which means this file
1080
+ * can no longer produce either of them and a test fails if it starts to. They
1081
+ * were screenshotted by the owner on 2026-09-25 out of three different channels
1082
+ * from two different seats on the same day, which is the whole complaint: every
1083
+ * dead session in the fleet said the same sentence, so a reader could not tell
1084
+ * which of their asks had died. Five byte-identical copies landed in one
1085
+ * channel inside two hours on 2026-09-23 (message ids cmuegc8ox…, cmuegc9fw…,
1086
+ * cmuekxzl3…, cmuekz7il…, cmuel0jlb…, all `failure-stale`, all channel
1087
+ * cmql16b7c01d2hhzpjk51b8xn).
1088
+ *
1089
+ * `fallbackNotice` interpolates the TOPIC as well as the cause, which is the
1090
+ * structural fix: two sibling failures are now two distinguishable sentences.
1091
+ * The rest of the contract is unchanged — says what happened, then what happens
1092
+ * next, never blames the human, never hides behind "an error occurred", never
1093
+ * ends without a next step, never shows internal framing.
1094
+ *
1095
+ * @param {object} rec the obligation record; its item supplies the topic
1096
+ * @param {object} o {failure, willRetry, topic}
894
1097
  */
895
1098
  export function composeFailure(rec, o = {}) {
1099
+ const f = o.failure || {};
1100
+ return fallbackNotice({
1101
+ kind: o.willRetry ? NOTICE.FAILURE_RETRYING : NOTICE.FAILURE_FINAL,
1102
+ cause: f.human || "the working session ended unexpectedly",
1103
+ topic: o.topic != null ? o.topic : noticeTopic(rec),
1104
+ willRetry: !!o.willRetry,
1105
+ });
1106
+ }
1107
+
1108
+ /**
1109
+ * The topic clause a notice may name — from the HUMAN'S OWN WORDS or not at all.
1110
+ *
1111
+ * `rec.summary` IS DELIBERATELY NOT CONSULTED, and this is the one line in this
1112
+ * change that a reviewer should check hardest. The classifier's summary is
1113
+ * internal framing: it is written ABOUT the sender in the third person ("The
1114
+ * CEO checking in on work progress"), and interpolating it into a message the
1115
+ * sender reads produces the "(CEO asking what machine…)" bleed that
1116
+ * `topicClause` was rewritten to end — its header says so in as many words, and
1117
+ * `assurance.test.mjs` pins it ("the summary is internal framing, never copy").
1118
+ * A first draft of this function read `rec.summary` for the topic and walked
1119
+ * straight back into it; the test caught it, which is why the test exists.
1120
+ *
1121
+ * So: the item's SUBJECT, which is a line the sender typed, or nothing. An
1122
+ * empty topic yields "The work you asked for" — honest, and not a guess.
1123
+ *
1124
+ * Sibling distinctness does NOT depend on this being populated: that guarantee
1125
+ * is structural and lives in the work-keyed room claim (`room-budget.noticeKey`),
1126
+ * which refuses a second notice about the same ask whatever words it wears. The
1127
+ * topic is the nicety on top, taken only when a human authored one.
1128
+ */
1129
+ function noticeTopic(rec) {
1130
+ const r = rec && typeof rec === "object" ? rec : {};
1131
+ const raw = String((r.item && r.item.subject) || "").trim();
1132
+ if (!raw) return "";
1133
+ const t = raw.replace(/\s+/g, " ").replace(/[.!?]+$/, "");
1134
+ return t.length > 0 && t.length <= 80 ? t : "";
1135
+ }
1136
+
1137
+ /**
1138
+ * THE FAILURE NOTICE, WRITTEN RATHER THAN STAMPED.
1139
+ *
1140
+ * Same cheapest-model path as the holding ack (`ackSpawn`), the real cause and
1141
+ * the real ask in hand, the agent's own voice, about THIS piece of work. Every
1142
+ * guarantee the deterministic version bought is kept — see
1143
+ * `lib/assurance/notice-voice.mjs`, which owns the checks:
1144
+ *
1145
+ * it cannot claim success `claimsSuccess`
1146
+ * it cannot invent a cause `inventsFacts`
1147
+ * it cannot reissue a retired
1148
+ * sentence `isRetiredFirstReply`
1149
+ * it always produces SOMETHING this function, which falls back rather than
1150
+ * returning null
1151
+ *
1152
+ * THAT LAST ONE IS THE DELIBERATE DIVERGENCE FROM `generateAck`, and it is the
1153
+ * decision this change is really making. `generateAck` returns null and the
1154
+ * caller says NOTHING, because a holding line nobody composed costs the reader
1155
+ * nothing they will not have in minutes. A failure notice nobody composed costs
1156
+ * them the one fact they cannot get any other way: that the thing they are
1157
+ * waiting for is dead. Never-told is strictly worse than plainly-told, so this
1158
+ * path speaks — and stamps `degraded` so the condition is countable instead of
1159
+ * invisible.
1160
+ *
1161
+ * @param {object} rec
1162
+ * @param {object} o {failure, willRetry, kind}
1163
+ * @param {object} [opts] {runImpl, timeoutMs} test seams
1164
+ * @returns {Promise<{text:string, generated:boolean, degraded:boolean, cause:string}>}
1165
+ */
1166
+ /**
1167
+ * ONE WRITTEN NOTICE PER ROOM PER WINDOW; the rest get the plain sentence.
1168
+ *
1169
+ * The guarantee this protects is the measured one: twenty sibling asks dying
1170
+ * together in one channel must not become twenty messages. Byte-identity used
1171
+ * to do that for free, because every notice in the fleet was the same string —
1172
+ * and generating a bespoke notice per ask is precisely what destroys that for
1173
+ * free. So the FIRST dead ask in a room gets the written, specific message and
1174
+ * its siblings inside the window compose the plain one, which is byte-identical
1175
+ * across them and therefore coalesced to nothing by the text claim in
1176
+ * `sayOutcomeNotice`.
1177
+ *
1178
+ * Nobody loses a fact: every sibling still writes its own escalation record, so
1179
+ * an operator sees every dead ask in needs-attention exactly as before. What is
1180
+ * withheld is the nineteenth rendering of the same news into one room.
1181
+ *
1182
+ * Claimed with `commit: true` up front, unlike the notice itself. The cost of a
1183
+ * claim spent on a generation that then fails is one sibling reading the plain
1184
+ * sentence instead of a written one — which is the outcome this function exists
1185
+ * to hand out anyway.
1186
+ */
1187
+ function claimWrittenVoice(rec, notice, now, impl) {
1188
+ const claim = impl || claimRoomNotice;
1189
+ const v = claim({
1190
+ service: rec && rec.service, channel: rec && rec.channel,
1191
+ notice: `voice:${notice}`, text: "one-written-notice-per-room",
1192
+ now, agentRoot: AGENT_REPO_DIR, commit: true,
1193
+ });
1194
+ return !(v && v.allowed === false);
1195
+ }
1196
+
1197
+ export async function generateFailureNotice(rec, o = {}, opts = {}) {
896
1198
  const f = o.failure || {};
897
1199
  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.`;
1200
+ const kind = o.kind || (o.willRetry ? NOTICE.FAILURE_RETRYING : NOTICE.FAILURE_FINAL);
1201
+ const topic = noticeTopic(rec);
1202
+ const item = (rec && rec.item) || {};
1203
+ const promptArgs = {
1204
+ kind,
1205
+ cause,
1206
+ topic,
1207
+ askText: String(item.content || item.subject || "").trim(),
1208
+ sender: item.sender || "",
1209
+ willRetry: !!o.willRetry,
1210
+ };
1211
+ const degradedText = () =>
1212
+ fallbackNotice({ kind, cause, topic, willRetry: !!o.willRetry });
1213
+
1214
+ const run = typeof opts.runImpl === "function" ? opts.runImpl : runAckModel;
1215
+ const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : NOTICE_GEN_TIMEOUT_MS;
1216
+ if (typeof opts.runImpl !== "function" && !noticeGenEnabled()) {
1217
+ counters.bump("notice.generation_skipped", { notice: kind, reason: "disabled" });
1218
+ return { text: degradedText(), generated: false, degraded: true, cause };
1219
+ }
1220
+ if (o.mayWrite === false) {
1221
+ // A sibling. See `claimWrittenVoice`: one written notice per room per
1222
+ // window, the rest plain — and `degraded` is deliberately FALSE here,
1223
+ // because nothing is broken. This is the design working.
1224
+ counters.bump("notice.generation_skipped", { notice: kind, reason: "room-voice-claimed" });
1225
+ return { text: degradedText(), generated: false, degraded: false, cause };
1226
+ }
1227
+ try {
1228
+ const raw = await withTimeout(
1229
+ run(buildNoticeSystemPrompt({ kind, agentName: ackAgentName() }), buildNoticeUserPrompt(promptArgs), { timeoutMs }),
1230
+ timeoutMs,
1231
+ );
1232
+ // THE REASON IS RECORDED, not just the failure. `unusable_output` for all
1233
+ // eleven rejections could not tell "the model is unavailable" from "the
1234
+ // model is fine and `inventsFacts` is too strict" — and the second is a
1235
+ // live possibility, since any digit absent from the cause is refused. The
1236
+ // degraded line is meant to be the rare path; `notice.generation_failed`
1237
+ // broken out by reason is how anyone finds out whether it still is.
1238
+ const verdict = noticeRejection(raw, { source: noticeSource(promptArgs) });
1239
+ if (verdict.ok) return { text: verdict.text, generated: true, degraded: false, cause };
1240
+ counters.bump("notice.generation_failed", { cause: `unusable:${verdict.reason}`, notice: kind });
1241
+ console.warn(`[assurance] ${kind} notice generation produced nothing usable [${verdict.reason}] — sending the plain version (a dead ask is never left unsaid)`);
1242
+ return { text: degradedText(), generated: false, degraded: true, cause, rejected: verdict.reason };
1243
+ } catch (err) {
1244
+ const msg = String((err && err.message) || "");
1245
+ const why = /timed out/i.test(msg) ? "timeout"
1246
+ : /spawn error|ENOENT|EACCES/i.test(msg) ? "spawn_failed"
1247
+ : "error";
1248
+ counters.bump("notice.generation_failed", { cause: why, notice: kind });
1249
+ console.warn(`[assurance] ${kind} notice generation failed (${msg}) — sending the plain version [cause:${why}]`);
1250
+ return { text: degradedText(), generated: false, degraded: true, cause };
900
1251
  }
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
1252
  }
903
1253
 
904
1254
  /**
@@ -1076,10 +1426,42 @@ async function sayOutcomeNotice(o) {
1076
1426
  return { sent: false, reason: "notice-already-said" };
1077
1427
  }
1078
1428
  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
- });
1429
+ // ── TWO CLAIMS, BECAUSE THEY ANSWER DIFFERENT QUESTIONS ──────────────────
1430
+ //
1431
+ // BY WORK — "has this piece of work already been declared dead in this room?"
1432
+ // Added 2026-09-25 with generated notices, and required by them. The
1433
+ // text-keyed claim below was load-bearing only while every failure notice in
1434
+ // the fleet was the same deterministic string; `generateFailureNotice` writes
1435
+ // one per ask, so two notices about the SAME dead ask composed a minute apart
1436
+ // by two sweeps are two different sentences saying the same thing to the same
1437
+ // person, and byte-identity no longer finds them. Keyed on the obligation
1438
+ // key, the second is refused whatever words it wears — across a retry that
1439
+ // reopens the obligation and across a daemon restart that rebuilds it,
1440
+ // because the ledger is on disk and the work-keyed window is a day.
1441
+ //
1442
+ // BY TEXT — "would a second copy of this exact sentence add anything?" This
1443
+ // is the ORIGINAL claim and it is deliberately still here. It is what keeps
1444
+ // twenty sibling debts staling together from putting twenty indistinguishable
1445
+ // sentences in one room (`assurance.test.mjs` pins that at 20 → 1), and it is
1446
+ // still reachable in the two cases that matter: the degraded path and the
1447
+ // `ASSURANCE_NOTICE_GEN=0` path both compose the plain sentence, which IS
1448
+ // byte-identical across siblings. Deleting it while adding the work claim
1449
+ // would have swapped one guarantee for another rather than adding one — the
1450
+ // sibling test caught exactly that, which is why it is worth the two calls.
1451
+ //
1452
+ // BOTH must allow. Either refusal suppresses, and a send that lands commits
1453
+ // both; a send that fails commits neither, so the room that heard nothing is
1454
+ // not charged for it.
1455
+ const work = String((rec && rec.key) || "").trim();
1456
+ const claimArgs = [
1457
+ { service: rec.service, channel: rec.channel, notice, text, work, now, agentRoot: AGENT_REPO_DIR },
1458
+ { service: rec.service, channel: rec.channel, notice, text, now, agentRoot: AGENT_REPO_DIR },
1459
+ ];
1460
+ let peek = null;
1461
+ for (const args of claimArgs) {
1462
+ const v = claim({ ...args, commit: false });
1463
+ if (v && v.allowed === false) { peek = v; break; }
1464
+ }
1083
1465
  if (peek && peek.allowed === false) {
1084
1466
  // This room already has this exact sentence on screen. Stamp the record
1085
1467
  // too: the fact HAS been told here, so a later tick of this same debt must
@@ -1095,10 +1477,7 @@ async function sayOutcomeNotice(o) {
1095
1477
  // SPEND ONLY ON A DELIVERED MESSAGE, exactly as the interim budget does: a
1096
1478
  // send that failed is a room that heard nothing, and recording it would
1097
1479
  // 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
- });
1480
+ for (const args of claimArgs) claim({ ...args, commit: true });
1102
1481
  }
1103
1482
  return { sent: !!(res && res.sent), reason: res && res.sent ? "sent" : ((res && res.error) || "send-failed") };
1104
1483
  }
@@ -1484,7 +1863,17 @@ export async function settleSession(a = {}) {
1484
1863
  rec.lastFailureAt = now;
1485
1864
  writeRecord(rec);
1486
1865
 
1487
- const text = composeFailure(rec, { failure, willRetry });
1866
+ // GENERATED, not stamped. `generateFailureNotice` always returns text — the
1867
+ // degraded path is a plainer sentence, never silence — so the guarantee the
1868
+ // deterministic composer bought is intact while the words are this ask's own.
1869
+ const noticeId0 = willRetry ? NOTICE.FAILURE_RETRYING : NOTICE.FAILURE_FINAL;
1870
+ const notice0 = await generateFailureNotice(
1871
+ rec,
1872
+ { failure, willRetry, mayWrite: claimWrittenVoice(rec, noticeId0, now, a.deps && a.deps.roomNoticeImpl) },
1873
+ a.deps && a.deps.noticeGen,
1874
+ );
1875
+ const text = notice0.text;
1876
+ if (notice0.degraded) counters.bump("assurance.notice_degraded", { notice: willRetry ? NOTICE.FAILURE_RETRYING : NOTICE.FAILURE_FINAL });
1488
1877
  // SAID ONCE PER (channel, key, notice). The retry loop is what made this
1489
1878
  // necessary: a transient failure posts a notice, the item is retried, a fresh
1490
1879
  // obligation opens under the same key, it fails the same way, and the same
@@ -1632,14 +2021,111 @@ export async function sweepObligations(a = {}) {
1632
2021
  }
1633
2022
  }
1634
2023
 
2024
+ /**
2025
+ * PURE. May this debt still produce a courtesy interim at all?
2026
+ *
2027
+ * The four durable latches, in one place, because branch (e)'s entry condition
2028
+ * and the batch membership test MUST be the same predicate. When they were the
2029
+ * same expression written twice, a batch could count a member that the branch
2030
+ * would never have spoken for — inflating the batch, moving `lastAt`, and
2031
+ * delaying an acknowledgement on behalf of a record that was already silent.
2032
+ *
2033
+ * @param {object} rec
2034
+ * @returns {boolean}
2035
+ */
2036
+ export function interimEligible(rec) {
2037
+ if (!rec || typeof rec !== "object") return false;
2038
+ return !rec.interimSaid && !rec.interimForbidden && !rec.ackSuppressed && rec.tier !== "answer";
2039
+ }
2040
+
2041
+ /**
2042
+ * The batch `rec` belongs to this tick.
2043
+ *
2044
+ * Membership is every OPEN, interim-eligible, deliverable debt in the same
2045
+ * conversation that this tick has not already consumed — plus `rec` itself
2046
+ * unconditionally, since it is the record asking. `consumed` holds the keys
2047
+ * branches (a)–(d) have already closed or spoken to in this pass: a debt that
2048
+ * has just been declared stale is not waiting for an acknowledgement, and
2049
+ * counting it would let a dead ask hold a live one's batch open.
2050
+ *
2051
+ * Not pure (it reads nothing, but it takes the sweep's live arrays); the
2052
+ * decisions it delegates to — `groupIntoBatches`, `batchVerdict` — are.
2053
+ */
2054
+ function batchFor(rec, open, consumed, now, isForeignAlive = () => false) {
2055
+ const records = open.filter(
2056
+ (r) => r === rec || (
2057
+ !consumed.has(r.key)
2058
+ && interimEligible(r)
2059
+ && r.deliverable !== false
2060
+ // A SIBLING ANOTHER LIVE DAEMON OWNS IS NOT OURS TO SILENCE. The sweep
2061
+ // already refuses to NARRATE a debt whose `daemonPid` belongs to a live
2062
+ // foreign process (see `foreignOwnerAlive` at the top of the loop), for
2063
+ // the obvious reason that two daemons on one AGENT_DIR would otherwise
2064
+ // say the same thing twice. Batching reaches further than narrating
2065
+ // did: the carrier LATCHES every member it speaks for, so without this
2066
+ // the carrier would set `interimSaid` on a debt the other process was
2067
+ // about to acknowledge, and that acknowledgement would never be sent by
2068
+ // anybody. Only reachable with two daemons sharing one obligations
2069
+ // directory — already pathological — but the previous per-record code
2070
+ // could not reach it at all, because it never touched another record.
2071
+ && !isForeignAlive(r)
2072
+ ),
2073
+ );
2074
+ const batches = groupIntoBatches({ records, now });
2075
+ return batches.find((b) => b.records.includes(rec)) || { conversation: null, urgent: false, records: [rec] };
2076
+ }
2077
+
2078
+ /**
2079
+ * Is this debt owned by a DIFFERENT, provably live process?
2080
+ *
2081
+ * One definition, two callers — the sweep's per-record narrating gate and
2082
+ * `batchFor`'s membership filter — because a batch that admits a record the
2083
+ * sweep would refuse to narrate can latch it silent on that owner's behalf.
2084
+ *
2085
+ * @param {object} rec
2086
+ * @param {(pid:number)=>boolean} alive
2087
+ */
2088
+ function ownedByLiveForeign(rec, alive) {
2089
+ return !!(rec && rec.daemonPid && rec.daemonPid !== process.pid && alive(rec.daemonPid));
2090
+ }
2091
+
2092
+ /**
2093
+ * Latch a whole batch silent without saying anything.
2094
+ *
2095
+ * SILENT IS NOT DROPPED, and the distinction is the one this module was built
2096
+ * on: `state` is untouched, so every debt stays open, swept, and covered by
2097
+ * the stale and failure notices. What is withheld is the courtesy — the half
2098
+ * that carries no information the reader cannot get from the answer itself.
2099
+ */
2100
+ function latchBatchSilent(verdict, now, reason) {
2101
+ const all = verdict.carrier ? [verdict.carrier, ...verdict.speaksFor] : verdict.speaksFor;
2102
+ for (const r of all) {
2103
+ if (!r) continue;
2104
+ r.interimSaid = true;
2105
+ r.interimSuppressedAt = now;
2106
+ r.interimSuppressedReason = reason;
2107
+ writeRecord(r);
2108
+ }
2109
+ }
2110
+
1635
2111
  async function _sweepObligations(a = {}) {
1636
2112
  const now = Number.isFinite(a.now) ? a.now : Date.now();
1637
2113
  const send = (a.deps && a.deps.deliverImpl) || deliver;
1638
2114
  const heardBy = (a.deps && (a.deps.spokeForImpl || a.deps.spokeSinceImpl)) || spokeFor;
1639
2115
  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 };
2116
+ const stats = {
2117
+ swept: 0, acked: 0, staled: 0, closed: 0, interrupted: 0, unreachable: 0, suppressed: 0,
2118
+ // How many debts this tick's acknowledgements spoke FOR rather than to —
2119
+ // the flurry that drew one line instead of four — and how many batches are
2120
+ // deliberately still assembling. Both are ordinary operation, not faults;
2121
+ // they are counted because a batching window nobody can measure is a
2122
+ // window nobody can size.
2123
+ batched: 0, batchWaiting: 0,
2124
+ };
1641
2125
 
1642
2126
  const open = openObligations();
2127
+ // Keys this pass has already closed or spoken to. See `batchFor`.
2128
+ const consumed = new Set();
1643
2129
  for (const rec of open) {
1644
2130
  stats.swept++;
1645
2131
  // Two clocks, deliberately. `age` is how long the HUMAN has been waiting and
@@ -1659,7 +2145,7 @@ async function _sweepObligations(a = {}) {
1659
2145
  // gone. The terminal branches above and below — answered, undeliverable,
1660
2146
  // stale — stay shared: closing a discharged or dead debt is safe from
1661
2147
  // either process, and branch (e) already proves the owner dead itself.
1662
- const foreignOwnerAlive = !!(rec.daemonPid && rec.daemonPid !== process.pid && alive(rec.daemonPid));
2148
+ const foreignOwnerAlive = ownedByLiveForeign(rec, alive);
1663
2149
 
1664
2150
  // (a) The session already answered — discharge without saying anything.
1665
2151
  // Attributed, not room-wide: see spokeFor. An unattributed message in a room
@@ -1675,6 +2161,7 @@ async function _sweepObligations(a = {}) {
1675
2161
  });
1676
2162
  if (v && v.heard) {
1677
2163
  closeObligation(rec.key, { outcome: "answered", now, note: `receipt:${v.basis}` });
2164
+ consumed.add(rec.key);
1678
2165
  stats.closed++;
1679
2166
  continue;
1680
2167
  }
@@ -1692,6 +2179,7 @@ async function _sweepObligations(a = {}) {
1692
2179
  };
1693
2180
  escalate(rec, { failure, now, told: false });
1694
2181
  closeObligation(rec.key, { outcome: "undeliverable", now, note: failure.label });
2182
+ consumed.add(rec.key);
1695
2183
  stats.unreachable++;
1696
2184
  continue;
1697
2185
  }
@@ -1705,7 +2193,13 @@ async function _sweepObligations(a = {}) {
1705
2193
  // exactly the obligations that most need one.
1706
2194
  if (workAge >= STALE_AFTER_MS) {
1707
2195
  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 });
2196
+ const notice = await generateFailureNotice(
2197
+ rec,
2198
+ { failure, willRetry: false, mayWrite: claimWrittenVoice(rec, NOTICE.FAILURE_FINAL, now, a.deps && a.deps.roomNoticeImpl) },
2199
+ a.deps && a.deps.noticeGen,
2200
+ );
2201
+ const text = notice.text;
2202
+ if (notice.degraded) counters.bump("assurance.notice_degraded", { notice: NOTICE.FAILURE_FINAL });
1709
2203
  // SAID ONCE PER (channel, key) — AND once per identical sentence per
1710
2204
  // room. The retry loop used to narrate every attempt (notice → retry →
1711
2205
  // fresh obligation → notice), and the notice ledger inherited across the
@@ -1726,6 +2220,7 @@ async function _sweepObligations(a = {}) {
1726
2220
  writeRecord(rec);
1727
2221
  escalate(rec, { failure, now, told: !!(res && res.sent) });
1728
2222
  closeObligation(rec.key, { outcome: "failed", now, note: "stale-no-outcome" });
2223
+ consumed.add(rec.key);
1729
2224
  stats.staled++;
1730
2225
  continue;
1731
2226
  }
@@ -1754,7 +2249,18 @@ async function _sweepObligations(a = {}) {
1754
2249
  counters.bump("assurance.notice_deduped", { notice: NOTICE.INTERRUPTED });
1755
2250
  continue;
1756
2251
  }
1757
- const text = composeInterrupted(rec);
2252
+ const interruptedNotice = await generateFailureNotice(
2253
+ rec,
2254
+ {
2255
+ failure: { human: "my end restarted while it was running" },
2256
+ willRetry: false,
2257
+ kind: NOTICE.INTERRUPTED,
2258
+ mayWrite: claimWrittenVoice(rec, NOTICE.INTERRUPTED, now, a.deps && a.deps.roomNoticeImpl),
2259
+ },
2260
+ a.deps && a.deps.noticeGen,
2261
+ );
2262
+ const text = interruptedNotice.text;
2263
+ if (interruptedNotice.degraded) counters.bump("assurance.notice_degraded", { notice: NOTICE.INTERRUPTED });
1758
2264
  const res = await sayOutcomeNotice({
1759
2265
  rec, notice: NOTICE.INTERRUPTED, text, item, kind: "notice",
1760
2266
  idempotencySuffix: `interrupted-${rec.attempts || 0}`, now, send,
@@ -1763,12 +2269,26 @@ async function _sweepObligations(a = {}) {
1763
2269
  rec.interruptedNotifiedAt = now;
1764
2270
  rec.daemonPid = process.pid;
1765
2271
  writeRecord(rec);
2272
+ consumed.add(rec.key);
1766
2273
  if (res.sent) stats.interrupted++;
1767
2274
  continue;
1768
2275
  }
1769
2276
 
1770
2277
  // (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:
2278
+ // courtesy message, and it is gated six ways before it does:
2279
+ //
2280
+ // BATCH — the unit is the CONVERSATION, not the message. Every open,
2281
+ // eligible debt in this room forms one batch; the batch is not
2282
+ // ready until it has been QUIET (20 s) after its newest member,
2283
+ // and when it is ready exactly ONE of them — the newest — writes
2284
+ // the line, having been handed all of their messages. The others
2285
+ // are latched `interimSaid` with `spokenForBy` naming the
2286
+ // carrier. This is what the room budget could not do: the budget
2287
+ // caps HOW MANY, it cannot choose WHICH, and "one arbitrary
2288
+ // reply out of four" is not "one reply to the four".
2289
+ // ANSWER — and before any of it, `spokeSince`: if something substantive
2290
+ // has already reached this room since the batch opened, a
2291
+ // holding line behind it is a contradiction. See PIN 2.
1772
2292
  //
1773
2293
  // TIME — nothing at all before ACK_AFTER_MS. Below ninety seconds the
1774
2294
  // typing indicator is the acknowledgement; a message there is
@@ -1805,11 +2325,89 @@ async function _sweepObligations(a = {}) {
1805
2325
  // generation that yields nothing usable still sends NOTHING rather than a
1806
2326
  // canned line. The debt stays open either way: silence about timing is not
1807
2327
  // 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
- ) {
2328
+ // THE AGE GATE MOVED INTO THE BATCH VERDICT, AND HAD TO.
2329
+ //
2330
+ // It used to live here as `age >= ACK_AFTER_MS`, per record. With a batch
2331
+ // that deadlocks: in a four-message flurry only the OLDEST record is past
2332
+ // ninety seconds, and the record that must speak is the NEWEST — so the
2333
+ // oldest entered the branch and deferred to a carrier that the branch
2334
+ // itself would not admit for another nine seconds, every tick, forever.
2335
+ // `batchVerdict` measures the same ninety seconds from the batch's FIRST
2336
+ // arrival, which is the same promise to the same waiting human, made once
2337
+ // for the conversation instead of once per message.
2338
+ if (interimEligible(rec) && !foreignOwnerAlive) {
2339
+ // ── THE UNIT OF ACKNOWLEDGEMENT IS THE CONVERSATION ────────────────────
2340
+ // Four messages in a row must not draw four acknowledgements, and — the
2341
+ // half the room budget alone could never give — the ONE that does go out
2342
+ // must have been written with all four in front of it. The budget caps
2343
+ // HOW MANY; this decides WHICH and WHEN. See lib/assurance/batch.mjs.
2344
+ const mine = batchFor(rec, open, consumed, now, (r) => ownedByLiveForeign(r, alive));
2345
+ const verdict = batchVerdict({
2346
+ batch: mine,
2347
+ now,
2348
+ ackAfterMs: ACK_AFTER_MS,
2349
+ quietMs: batchQuietMs(),
2350
+ maxSpanMs: batchMaxSpanMs(),
2351
+ });
2352
+
2353
+ // ONE DECISION PER BATCH PER TICK. Only the carrier decides; every
2354
+ // other member of the batch defers to it in this pass and is latched by
2355
+ // it below if it speaks. Checked BEFORE readiness so the counters,
2356
+ // the receipt question and the budget peek each happen once per
2357
+ // conversation rather than once per message in it.
2358
+ if (verdict.carrier !== rec) continue;
2359
+
2360
+ // BOUNDARY PIN 1 — A BATCH THAT IS NOT READY CHANGES NOTHING.
2361
+ // `ready:false` means "still assembling", never "refused": nothing is
2362
+ // latched, nothing is spent, no counter moves. That is the only way the
2363
+ // LAST message of a flurry can join the batch it belongs to — if an
2364
+ // unready tick latched its older siblings silent, message four would
2365
+ // arrive into a room that had already decided not to speak.
2366
+ if (!verdict.ready) {
2367
+ stats.batchWaiting++;
2368
+ continue;
2369
+ }
2370
+
2371
+ // BOUNDARY PIN 2 — AN ANSWER OUTRANKS THE COURTESY THAT PRECEDES IT.
2372
+ // A batch still open when the work finishes must not post a holding line
2373
+ // behind the answer. Branch (a) cannot catch this: `spokeFor` refuses to
2374
+ // close a debt on an unattributed receipt while siblings are open
2375
+ // (`ambiguous-room`), which is right for CLOSING — guessing which debt a
2376
+ // message answered is how an unanswered ask gets marked answered — and
2377
+ // wrong for SPEAKING. The question an interim has to pass is weaker and
2378
+ // room-shaped: has this person already heard something substantive from
2379
+ // me since they asked? If they have, "I'm looking at this" is false
2380
+ // whichever debt it belonged to. The debts stay open and tracked; only
2381
+ // the courtesy is withheld, exactly as over-budget is treated.
2382
+ // NOT `spokeSinceImpl`: branch (a) already claims that name as an alias
2383
+ // for its `spokeFor` seam, and one injected fake driving two different
2384
+ // questions is how a test proves the wrong thing.
2385
+ const spokeImpl = (a.deps && a.deps.roomSpokeImpl) || spokeSince;
2386
+ // AND IT ASKS ONLY ONCE A SESSION HAS ACTUALLY RUN. This is the same
2387
+ // condition `spokeFor`'s rung 3 imposes, and for the same reason: before
2388
+ // any session of ours has run, a message in this room came from
2389
+ // somewhere else — a cadence post, a broadcast, a different debt
2390
+ // entirely — and treating it as the answer to a flurry nobody has
2391
+ // started work on would leave a person never acknowledged and never
2392
+ // answered. Once a session HAS run, a substantive message in the room is
2393
+ // overwhelmingly that work landing, whichever debt carries its receipt.
2394
+ const anyRan = mine.records.some((r) => (r.attempts || 0) > 0);
2395
+ let answered = false;
2396
+ try {
2397
+ answered = anyRan && spokeImpl({
2398
+ channel: rec.channel,
2399
+ sinceMs: verdict.firstAt,
2400
+ excludeKinds: NON_ANSWER_KINDS,
2401
+ agentRoot: AGENT_REPO_DIR,
2402
+ }) === true;
2403
+ } catch { answered = false; } // a receipt-ledger fault must not gag the room
2404
+ if (answered) {
2405
+ latchBatchSilent(verdict, now, "answer-already-landed");
2406
+ counters.bump("assurance.interim_suppressed", { reason: "answer-already-landed" });
2407
+ stats.suppressed += verdict.size;
2408
+ continue;
2409
+ }
2410
+
1813
2411
  const budgetOf = (a.deps && a.deps.roomBudgetImpl) || claimRoomInterim;
1814
2412
  // ASK, then compose, then SPEND. The interim's text comes from a model
1815
2413
  // call that can fail; claiming the room's fifteen minutes before the
@@ -1825,15 +2423,62 @@ async function _sweepObligations(a = {}) {
1825
2423
  // OVER BUDGET IS NOT A DROP. The debt stays open and tracked exactly as
1826
2424
  // it was; what is withheld is the content-free half. The room has heard
1827
2425
  // 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);
2426
+ // The whole batch is latched, not just the carrier: its siblings are in
2427
+ // the same room and would each re-ask the same refused question.
2428
+ latchBatchSilent(verdict, now, (budget && budget.reason) || "room-budget");
1831
2429
  counters.bump("assurance.interim_suppressed", { reason: (budget && budget.reason) || "room-budget" });
1832
- stats.suppressed++;
2430
+ stats.suppressed += verdict.size;
1833
2431
  continue;
1834
2432
  }
2433
+ // ── SOMETIMES A REACTION IS THE WHOLE REPLY ────────────────────────
2434
+ // The owner's third case, and the narrowest: "sometimes the initial
2435
+ // response can be an emoji response". `firstReplyShape` decides, not a
2436
+ // prompt — the distinction it draws is that an emoji acknowledges an FYI
2437
+ // and never answers a QUESTION, and a rule stated only in a prompt is a
2438
+ // rule nothing checks (the ack prompt banned "on it" from the day it was
2439
+ // written and 3,069 of them shipped anyway).
2440
+ //
2441
+ // Reached only here, inside every gate the interim already passes — time,
2442
+ // tier, once, room budget — so a reaction can never be louder than the
2443
+ // line it replaces. It is cheaper than a line in the one way that matters
2444
+ // to a reader: it adds no row to the channel.
2445
+ const shape = firstReplyShape({
2446
+ tier: rec.tier,
2447
+ action: rec.action,
2448
+ directed: rec.directed !== false,
2449
+ text: item.content || item.subject || "",
2450
+ reactionsAvailable: canReactTo(item),
2451
+ reaction: "seen",
2452
+ });
2453
+ if (shape.shape === "react") {
2454
+ const react = (a.deps && a.deps.reactImpl) || deliverReaction;
2455
+ const rres = await react(item, shape.emoji);
2456
+ if (rres && rres.sent) {
2457
+ budgetOf({ service: rec.service, channel: rec.channel, now, agentRoot: AGENT_REPO_DIR, commit: true });
2458
+ rec.acknowledged = true;
2459
+ rec.ackAt = now;
2460
+ rec.interimSaid = true;
2461
+ rec.interimAt = now;
2462
+ rec.ackShape = "react";
2463
+ writeRecord(rec);
2464
+ counters.bump("assurance.interim_reaction", { emoji: shape.emoji });
2465
+ stats.acked++;
2466
+ continue;
2467
+ }
2468
+ // A reaction that would not go out is not a reason to stay silent —
2469
+ // fall through and write the line instead.
2470
+ counters.bump("assurance.interim_reaction_failed", { reason: (rres && rres.error) || "unknown" });
2471
+ }
2472
+
1835
2473
  const gen = (a.deps && a.deps.generateAckImpl) || generateAck;
1836
- const text = await gen(item, { summary: rec.summary, model: rec.model, priority: rec.priority });
2474
+ // THE BATCH, oldest first, is what the line is composed from. With one
2475
+ // member this is the message itself and the prompt is unchanged.
2476
+ const batchText = batchMessages(mine.records);
2477
+ const text = await gen(
2478
+ item,
2479
+ { summary: rec.summary, model: rec.model, priority: rec.priority },
2480
+ { batch: batchText },
2481
+ );
1837
2482
  if (text == null) {
1838
2483
  rec.ackGenFailures = (rec.ackGenFailures || 0) + 1;
1839
2484
  if (rec.ackGenFailures >= ACK_GEN_MAX_FAILURES) {
@@ -1855,8 +2500,23 @@ async function _sweepObligations(a = {}) {
1855
2500
  rec.ackAt = now;
1856
2501
  rec.interimSaid = true;
1857
2502
  rec.interimAt = now;
2503
+ rec.batchSize = verdict.size;
2504
+ rec.batchReason = verdict.reason;
1858
2505
  writeRecord(rec);
2506
+ // THE SIBLINGS THIS LINE SPOKE FOR. Latched here and not left to the
2507
+ // room budget, for a reason that is not cosmetic: the budget expires
2508
+ // after fifteen minutes, and a sibling still open at minute sixteen
2509
+ // would then post a SECOND first-contact line about a conversation
2510
+ // that was acknowledged a quarter of an hour ago. `interimSaid` never
2511
+ // expires; the budget does.
2512
+ for (const sib of verdict.speaksFor) {
2513
+ sib.interimSaid = true;
2514
+ sib.interimAt = now;
2515
+ sib.spokenForBy = rec.key;
2516
+ writeRecord(sib);
2517
+ }
1859
2518
  stats.acked++;
2519
+ stats.batched += verdict.speaksFor.length;
1860
2520
  } else {
1861
2521
  // A permanent refusal (no transport, policy block) is worth five ticks
1862
2522
  // of nobody's time; count it out at once.
@@ -1932,11 +2592,16 @@ export default {
1932
2592
  generateAck,
1933
2593
  sanitiseAckText,
1934
2594
  composeFailure,
2595
+ generateFailureNotice,
2596
+ noticeGenEnabled,
2597
+ _buildAckUserPromptForTest: buildAckUserPrompt,
1935
2598
  composeSilentSuccess,
1936
2599
  composeInterrupted,
2600
+ looksLikePeerOutcomeNotice,
1937
2601
  classifyFailure,
1938
2602
  openAndAcknowledge,
1939
2603
  notePlan,
2604
+ interimEligible,
1940
2605
  _resetSweepGuard,
1941
2606
  noticeAlreadySaid,
1942
2607
  markNoticeSaid,