@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.
- package/docs/runbooks/fleet-rollout.md +45 -1
- package/lib/assurance/batch.mjs +353 -0
- package/lib/assurance/first-reply.mjs +423 -0
- package/lib/assurance/notice-voice.mjs +357 -0
- package/lib/assurance/plan-note.mjs +43 -0
- package/lib/assurance/room-budget.mjs +55 -6
- package/lib/comms/send-gate.mjs +59 -0
- package/lib/identity/persona.mjs +31 -2
- package/lib/telemetry/alerts.mjs +94 -0
- package/lib/telemetry/collect.mjs +26 -2
- package/package.json +1 -1
- package/scripts/daemon/agent-daemon.mjs +75 -5
- package/scripts/daemon/assurance.mjs +709 -44
- package/scripts/daemon/deliver.mjs +109 -0
- package/scripts/daemon/dispatcher.mjs +21 -3
- package/scripts/daemon/inbox-deferral.mjs +102 -9
- package/scripts/daemon/session-lock.mjs +41 -1
- package/scripts/fleet/rollout.mjs +246 -7
- package/scripts/local-triggers/autoupdate.sh +144 -11
|
@@ -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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
794
|
-
|
|
795
|
-
|
|
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
|
-
|
|
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
|
|
891
|
-
*
|
|
892
|
-
*
|
|
893
|
-
*
|
|
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
|
-
|
|
899
|
-
|
|
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
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
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
|
-
|
|
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 = {
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
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
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
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
|
-
|
|
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,
|