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