@cohortapp/agent-sdk 2.14.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.
@@ -0,0 +1,486 @@
1
+ /**
2
+ * room-budget.test.mjs — one room, one interim, whatever else is going on.
3
+ *
4
+ * The production failure this pins: the interim budget was per OBLIGATION and
5
+ * never per ROOM. Eight agents with N concurrent obligations each in
6
+ * `capital-formation` emitted up to N acks + 2N progress pings + N failure
7
+ * notices into one channel — 9,187 messages in 21 days, ~45 % of them exact
8
+ * duplicates, two independent timers narrating the same overlapping work at the
9
+ * same timestamp. Every one of those decisions was individually correct.
10
+ *
11
+ * Run: node --test lib/assurance/room-budget.test.mjs
12
+ */
13
+
14
+ import { test, describe, beforeEach } from "node:test";
15
+ import assert from "node:assert/strict";
16
+ import { mkdtempSync, rmSync, readFileSync, mkdirSync, writeFileSync } from "node:fs";
17
+ import { tmpdir } from "node:os";
18
+ import { join } from "node:path";
19
+
20
+ import {
21
+ DEFAULT_ROOM_INTERIM_WINDOW_MS,
22
+ DEFAULT_ROOM_NOTICE_WINDOW_MS,
23
+ roomInterimWindowMs,
24
+ roomNoticeWindowMs,
25
+ roomKey,
26
+ noticeKey,
27
+ textDigest,
28
+ interimAllowed,
29
+ noticeAllowed,
30
+ recordInterim,
31
+ recordNotice,
32
+ pruneLedger,
33
+ claimRoomInterim,
34
+ claimRoomNotice,
35
+ ledgerPath,
36
+ LEDGER_BASENAME,
37
+ readLedger,
38
+ _emptyLedger,
39
+ } from "./room-budget.mjs";
40
+
41
+ const ROOM_INTERIM_WINDOW_MS = DEFAULT_ROOM_INTERIM_WINDOW_MS;
42
+
43
+ const T0 = 1_757_000_000_000; // a fixed instant; nothing here reads a clock
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // The pure half
47
+ // ---------------------------------------------------------------------------
48
+
49
+ describe("roomKey — a room is (service, channel) and nothing else", () => {
50
+ test("service and channel together name the room", () => {
51
+ assert.equal(roomKey({ service: "cohort", channel: "C-CAPFORM" }), "cohort|c-capform");
52
+ });
53
+
54
+ test("case and surrounding space never make two rooms out of one", () => {
55
+ assert.equal(roomKey({ service: "Cohort", channel: " C-CapForm " }), roomKey({ service: "cohort", channel: "c-capform" }));
56
+ });
57
+
58
+ test("a missing channel is still a room, not a crash", () => {
59
+ assert.equal(typeof roomKey({ service: "cohort", channel: null }), "string");
60
+ assert.equal(typeof roomKey({}), "string");
61
+ assert.equal(typeof roomKey(), "string");
62
+ });
63
+
64
+ test("two different channels on one service are two rooms", () => {
65
+ assert.notEqual(roomKey({ service: "cohort", channel: "A" }), roomKey({ service: "cohort", channel: "B" }));
66
+ });
67
+
68
+ test("the same channel name on two services is two rooms", () => {
69
+ assert.notEqual(roomKey({ service: "cohort", channel: "general" }), roomKey({ service: "slack", channel: "general" }));
70
+ });
71
+ });
72
+
73
+ describe("interimAllowed — the pure decision", () => {
74
+ test("an empty ledger allows the first interim", () => {
75
+ const v = interimAllowed({ ledger: _emptyLedger(), service: "cohort", channel: "C1", now: T0 });
76
+ assert.equal(v.allowed, true);
77
+ assert.equal(v.reason, "room-budget-free");
78
+ });
79
+
80
+ test("a second interim inside the window is refused, and says which room and when", () => {
81
+ const led = recordInterim({ ledger: _emptyLedger(), service: "cohort", channel: "C1", now: T0 });
82
+ const v = interimAllowed({ ledger: led, service: "cohort", channel: "C1", now: T0 + 60_000 });
83
+ assert.equal(v.allowed, false);
84
+ assert.equal(v.reason, "room-budget-spent");
85
+ assert.equal(v.lastAt, T0);
86
+ assert.equal(v.room, roomKey({ service: "cohort", channel: "C1" }));
87
+ });
88
+
89
+ test("the window reopens exactly at the boundary, not a millisecond early", () => {
90
+ const led = recordInterim({ ledger: _emptyLedger(), service: "cohort", channel: "C1", now: T0 });
91
+ assert.equal(interimAllowed({ ledger: led, service: "cohort", channel: "C1", now: T0 + ROOM_INTERIM_WINDOW_MS - 1 }).allowed, false);
92
+ assert.equal(interimAllowed({ ledger: led, service: "cohort", channel: "C1", now: T0 + ROOM_INTERIM_WINDOW_MS }).allowed, true);
93
+ });
94
+
95
+ test("a spent room never silences a DIFFERENT room", () => {
96
+ const led = recordInterim({ ledger: _emptyLedger(), service: "cohort", channel: "C1", now: T0 });
97
+ assert.equal(interimAllowed({ ledger: led, service: "cohort", channel: "C2", now: T0 + 1 }).allowed, true);
98
+ assert.equal(interimAllowed({ ledger: led, service: "slack", channel: "C1", now: T0 + 1 }).allowed, true);
99
+ });
100
+
101
+ test("a clock that goes backwards does not hand out a free interim", () => {
102
+ // NTP steps, a restored snapshot, a test harness. `now` before the recorded
103
+ // stamp must read as "inside the window", never as "the window elapsed".
104
+ const led = recordInterim({ ledger: _emptyLedger(), service: "cohort", channel: "C1", now: T0 });
105
+ assert.equal(interimAllowed({ ledger: led, service: "cohort", channel: "C1", now: T0 - 60 * 60_000 }).allowed, false);
106
+ });
107
+
108
+ test("a corrupt or absent entry reads as 'never spoken here', never as a permanent gag", () => {
109
+ for (const bad of [{ rooms: null }, { rooms: { "cohort|c1": "yesterday" } }, {}, null, undefined, "nonsense"]) {
110
+ assert.equal(interimAllowed({ ledger: bad, service: "cohort", channel: "C1", now: T0 }).allowed, true, `${JSON.stringify(bad)} must fail open`);
111
+ }
112
+ });
113
+
114
+ test("the decision is pure: it never mutates the ledger it is given", () => {
115
+ const led = recordInterim({ ledger: _emptyLedger(), service: "cohort", channel: "C1", now: T0 });
116
+ const before = JSON.stringify(led);
117
+ interimAllowed({ ledger: led, service: "cohort", channel: "C1", now: T0 + 1 });
118
+ assert.equal(JSON.stringify(led), before);
119
+ });
120
+
121
+ test("the window is env-tunable and defaults to fifteen minutes", () => {
122
+ assert.equal(DEFAULT_ROOM_INTERIM_WINDOW_MS, 15 * 60_000);
123
+ const led = recordInterim({ ledger: _emptyLedger(), service: "cohort", channel: "C1", now: T0 });
124
+ assert.equal(interimAllowed({ ledger: led, service: "cohort", channel: "C1", now: T0 + 2000, windowMs: 1000 }).allowed, true);
125
+ });
126
+ });
127
+
128
+ describe("recordInterim / pruneLedger", () => {
129
+ test("recording returns a NEW ledger and leaves the old one untouched", () => {
130
+ const led = _emptyLedger();
131
+ const next = recordInterim({ ledger: led, service: "cohort", channel: "C1", now: T0 });
132
+ assert.notEqual(led, next);
133
+ assert.deepEqual(led.rooms, {});
134
+ assert.equal(next.rooms[roomKey({ service: "cohort", channel: "C1" })], T0);
135
+ });
136
+
137
+ test("the ledger does not grow without bound — entries older than two windows are dropped", () => {
138
+ // The store is a durable file on a machine that runs for months. An entry
139
+ // that can never refuse anything again is landfill.
140
+ let led = _emptyLedger();
141
+ for (let i = 0; i < 50; i++) led = recordInterim({ ledger: led, service: "cohort", channel: `OLD-${i}`, now: T0 });
142
+ led = recordInterim({ ledger: led, service: "cohort", channel: "FRESH", now: T0 + 3 * ROOM_INTERIM_WINDOW_MS });
143
+ const pruned = pruneLedger({ ledger: led, now: T0 + 3 * ROOM_INTERIM_WINDOW_MS });
144
+ assert.equal(Object.keys(pruned.rooms).length, 1);
145
+ assert.ok(pruned.rooms[roomKey({ service: "cohort", channel: "FRESH" })]);
146
+ });
147
+
148
+ test("pruning never drops an entry that can still refuse an interim", () => {
149
+ const led = recordInterim({ ledger: _emptyLedger(), service: "cohort", channel: "C1", now: T0 });
150
+ const pruned = pruneLedger({ ledger: led, now: T0 + ROOM_INTERIM_WINDOW_MS });
151
+ assert.equal(interimAllowed({ ledger: pruned, service: "cohort", channel: "C1", now: T0 + ROOM_INTERIM_WINDOW_MS - 1 }).allowed, false);
152
+ });
153
+ });
154
+
155
+ // ---------------------------------------------------------------------------
156
+ // The durable edge
157
+ // ---------------------------------------------------------------------------
158
+
159
+ describe("claimRoomInterim — the durable edge, with an injected clock and fs", () => {
160
+ let ROOT;
161
+ beforeEach(() => {
162
+ if (ROOT) { try { rmSync(ROOT, { recursive: true, force: true }); } catch { /* */ } }
163
+ ROOT = mkdtempSync(join(tmpdir(), "room-budget-"));
164
+ });
165
+
166
+ test("THE FLOOD FIX: N concurrent obligations in one room yield exactly ONE interim", () => {
167
+ // 8 agents' worth of concurrent asks, arriving together, as measured.
168
+ const said = [];
169
+ for (let i = 0; i < 40; i++) {
170
+ const v = claimRoomInterim({ service: "cohort", channel: "capital-formation", now: T0 + i * 1000, agentRoot: ROOT });
171
+ if (v.allowed) said.push(i);
172
+ }
173
+ assert.deepEqual(said, [0], `expected one interim across 40 obligations, got ${said.length}`);
174
+ });
175
+
176
+ test("…and the next window allows exactly one more, not a backlog burst", () => {
177
+ for (let i = 0; i < 40; i++) claimRoomInterim({ service: "cohort", channel: "capital-formation", now: T0 + i * 1000, agentRoot: ROOT });
178
+ let allowed = 0;
179
+ for (let i = 0; i < 40; i++) {
180
+ if (claimRoomInterim({ service: "cohort", channel: "capital-formation", now: T0 + ROOM_INTERIM_WINDOW_MS + i * 1000, agentRoot: ROOT }).allowed) allowed++;
181
+ }
182
+ assert.equal(allowed, 1);
183
+ });
184
+
185
+ test("a refused claim writes nothing and does not move the window", () => {
186
+ claimRoomInterim({ service: "cohort", channel: "C1", now: T0, agentRoot: ROOT });
187
+ claimRoomInterim({ service: "cohort", channel: "C1", now: T0 + 10 * 60_000, agentRoot: ROOT });
188
+ // If the refused claim had stamped the ledger, the window would run from
189
+ // T0+10min and the room would stay gagged past T0+15min.
190
+ assert.equal(claimRoomInterim({ service: "cohort", channel: "C1", now: T0 + ROOM_INTERIM_WINDOW_MS, agentRoot: ROOT }).allowed, true);
191
+ });
192
+
193
+ test("busy rooms never gag quiet ones", () => {
194
+ for (let i = 0; i < 20; i++) claimRoomInterim({ service: "cohort", channel: "busy", now: T0 + i, agentRoot: ROOT });
195
+ assert.equal(claimRoomInterim({ service: "cohort", channel: "quiet", now: T0 + 100, agentRoot: ROOT }).allowed, true);
196
+ });
197
+
198
+ test("the store is durable — a fresh process sees the spend", () => {
199
+ claimRoomInterim({ service: "cohort", channel: "C1", now: T0, agentRoot: ROOT });
200
+ const onDisk = JSON.parse(readFileSync(ledgerPath(ROOT), "utf-8"));
201
+ assert.equal(onDisk.rooms[roomKey({ service: "cohort", channel: "C1" })], T0);
202
+ assert.equal(readLedger({ agentRoot: ROOT }).rooms[roomKey({ service: "cohort", channel: "C1" })], T0);
203
+ });
204
+
205
+ test("it lives beside the obligation ledger it governs", () => {
206
+ assert.equal(ledgerPath(ROOT), join(ROOT, "state", "obligations", "room-budget.json"));
207
+ });
208
+
209
+ test("an unreadable store fails OPEN — a broken file may not silence a human", () => {
210
+ mkdirSync(join(ROOT, "state", "obligations"), { recursive: true });
211
+ writeFileSync(ledgerPath(ROOT), "{ this is not json");
212
+ const v = claimRoomInterim({ service: "cohort", channel: "C1", now: T0, agentRoot: ROOT });
213
+ assert.equal(v.allowed, true);
214
+ assert.equal(v.degraded, true, "…but it SAYS it is degraded, so the condition is measurable");
215
+ });
216
+
217
+ test("an unwritable store still lets the message out, and says so", () => {
218
+ const v = claimRoomInterim({
219
+ service: "cohort", channel: "C1", now: T0, agentRoot: ROOT,
220
+ deps: { writeImpl: () => { throw new Error("EROFS"); } },
221
+ });
222
+ assert.equal(v.allowed, true);
223
+ assert.equal(v.degraded, true);
224
+ });
225
+
226
+ test("the clock is injected, never read from the wall", () => {
227
+ // Passing `now` is the contract; omitting it is a caller bug, not a licence
228
+ // to read Date.now() from inside a decision module.
229
+ const v = claimRoomInterim({ service: "cohort", channel: "C1", agentRoot: ROOT });
230
+ assert.equal(v.allowed, false);
231
+ assert.equal(v.reason, "no-clock");
232
+ });
233
+
234
+ test("fs is injectable end to end — no real filesystem needed", () => {
235
+ let stored = null;
236
+ const deps = {
237
+ readImpl: () => stored,
238
+ writeImpl: (_p, obj) => { stored = obj; },
239
+ };
240
+ assert.equal(claimRoomInterim({ service: "cohort", channel: "C1", now: T0, agentRoot: ROOT, deps }).allowed, true);
241
+ assert.equal(claimRoomInterim({ service: "cohort", channel: "C1", now: T0 + 1, agentRoot: ROOT, deps }).allowed, false);
242
+ assert.equal(stored.rooms[roomKey({ service: "cohort", channel: "C1" })], T0);
243
+ });
244
+ });
245
+
246
+ describe("claimRoomInterim commit:false — ask without spending", () => {
247
+ let ROOT;
248
+ beforeEach(() => {
249
+ if (ROOT) { try { rmSync(ROOT, { recursive: true, force: true }); } catch { /* */ } }
250
+ ROOT = mkdtempSync(join(tmpdir(), "room-budget-peek-"));
251
+ });
252
+
253
+ test("a peek does not spend the budget", () => {
254
+ // The interim's text comes from a model call that can fail. Spending the
255
+ // room's fifteen minutes BEFORE the message exists gags the room in
256
+ // exchange for nothing — so the caller asks first, composes, then claims.
257
+ const peek = claimRoomInterim({ service: "cohort", channel: "C1", now: T0, agentRoot: ROOT, commit: false });
258
+ assert.equal(peek.allowed, true);
259
+ assert.equal(peek.committed, false);
260
+ assert.equal(claimRoomInterim({ service: "cohort", channel: "C1", now: T0 + 1, agentRoot: ROOT }).allowed, true, "the peek left the budget unspent");
261
+ });
262
+
263
+ test("a peek still reports a spent room", () => {
264
+ claimRoomInterim({ service: "cohort", channel: "C1", now: T0, agentRoot: ROOT });
265
+ const peek = claimRoomInterim({ service: "cohort", channel: "C1", now: T0 + 1000, agentRoot: ROOT, commit: false });
266
+ assert.equal(peek.allowed, false);
267
+ assert.equal(peek.reason, "room-budget-spent");
268
+ });
269
+
270
+ test("a committing claim says so", () => {
271
+ assert.equal(claimRoomInterim({ service: "cohort", channel: "C1", now: T0, agentRoot: ROOT }).committed, true);
272
+ });
273
+ });
274
+
275
+ // ---------------------------------------------------------------------------
276
+ // The HONEST SCOPE of the guarantee
277
+ // ---------------------------------------------------------------------------
278
+
279
+ describe("the budget is per (SEAT, service, channel) — not per (service, channel)", () => {
280
+ let A, B;
281
+ beforeEach(() => {
282
+ A = mkdtempSync(join(tmpdir(), "room-seat-a-"));
283
+ B = mkdtempSync(join(tmpdir(), "room-seat-b-"));
284
+ });
285
+
286
+ test("two seats in ONE room each get their own interim, because they share no store", () => {
287
+ // This is the arithmetic the module header now states rather than glosses.
288
+ // `agentRoot` is AGENT_DIR — one agent's repo on one Mac mini. There is no
289
+ // shared filesystem between seats, so the guarantee this module can make is
290
+ // per seat. The measured room had EIGHT seats in it: the ceiling this gate
291
+ // alone imposes there is 8 x (60/15) = 32 interims an hour, against a
292
+ // measured content-free rate of ~14/h. It is the backstop, not the thing
293
+ // that removed the measured flood.
294
+ //
295
+ // Pinned as a TEST rather than left in prose because the comment that said
296
+ // otherwise survived a review: an operator who reads "one room, one message"
297
+ // will size the next change wrong.
298
+ assert.equal(claimRoomInterim({ service: "cohort", channel: "capital-formation", now: T0, agentRoot: A }).allowed, true);
299
+ assert.equal(claimRoomInterim({ service: "cohort", channel: "capital-formation", now: T0 + 1, agentRoot: B }).allowed, true,
300
+ "a second SEAT is not gagged by the first — there is no store between them");
301
+ // …and within either seat the guarantee still holds absolutely.
302
+ assert.equal(claimRoomInterim({ service: "cohort", channel: "capital-formation", now: T0 + 2, agentRoot: A }).allowed, false);
303
+ assert.equal(claimRoomInterim({ service: "cohort", channel: "capital-formation", now: T0 + 3, agentRoot: B }).allowed, false);
304
+ });
305
+
306
+ test("the eight-seat ceiling is exactly eight per window, and no more", () => {
307
+ const seats = [];
308
+ for (let i = 0; i < 8; i++) seats.push(mkdtempSync(join(tmpdir(), `room-seat-${i}-`)));
309
+ let spoke = 0;
310
+ // Each seat tries ten times inside one window; each may speak once.
311
+ for (const seat of seats) {
312
+ for (let i = 0; i < 10; i++) {
313
+ if (claimRoomInterim({ service: "cohort", channel: "capital-formation", now: T0 + i, agentRoot: seat }).allowed) spoke++;
314
+ }
315
+ }
316
+ assert.equal(spoke, 8, "eight seats, eight interims per window — the honest ceiling");
317
+ for (const seat of seats) rmSync(seat, { recursive: true, force: true });
318
+ });
319
+ });
320
+
321
+ describe("the window default is resolved at the EDGE, never captured at import", () => {
322
+ // The gate report for this package claimed the pure modules read no
323
+ // clock/env/fs. A `const X = process.env.Y` at module scope falsified that
324
+ // and, more concretely, meant an operator who exported the variable after the
325
+ // first import silently got the stale default.
326
+ test("roomInterimWindowMs reads the env it is given, every time it is called", () => {
327
+ assert.equal(roomInterimWindowMs({}), DEFAULT_ROOM_INTERIM_WINDOW_MS);
328
+ assert.equal(roomInterimWindowMs({ ASSURANCE_ROOM_INTERIM_WINDOW_MS: "60000" }), 60_000);
329
+ assert.equal(roomNoticeWindowMs({ ASSURANCE_ROOM_NOTICE_WINDOW_MS: "1000" }), 1000);
330
+ // Garbage falls back rather than producing NaN, which would compare false
331
+ // against every elapsed time and make the budget decorative.
332
+ assert.equal(roomInterimWindowMs({ ASSURANCE_ROOM_INTERIM_WINDOW_MS: "soon" }), DEFAULT_ROOM_INTERIM_WINDOW_MS);
333
+ assert.equal(roomNoticeWindowMs(undefined) > 0, true);
334
+ });
335
+
336
+ test("an env set AFTER this module was first imported still takes effect", () => {
337
+ const ROOT = mkdtempSync(join(tmpdir(), "room-env-"));
338
+ const prev = process.env.ASSURANCE_ROOM_INTERIM_WINDOW_MS;
339
+ try {
340
+ process.env.ASSURANCE_ROOM_INTERIM_WINDOW_MS = "1000";
341
+ assert.equal(claimRoomInterim({ service: "cohort", channel: "C1", now: T0, agentRoot: ROOT }).allowed, true);
342
+ assert.equal(claimRoomInterim({ service: "cohort", channel: "C1", now: T0 + 999, agentRoot: ROOT }).allowed, false);
343
+ assert.equal(claimRoomInterim({ service: "cohort", channel: "C1", now: T0 + 1000, agentRoot: ROOT }).allowed, true,
344
+ "the one-second window was honoured, so the value was not folded at import time");
345
+ } finally {
346
+ if (prev === undefined) delete process.env.ASSURANCE_ROOM_INTERIM_WINDOW_MS;
347
+ else process.env.ASSURANCE_ROOM_INTERIM_WINDOW_MS = prev;
348
+ rmSync(ROOT, { recursive: true, force: true });
349
+ }
350
+ });
351
+
352
+ test("the pure decision never reads the environment — it defaults to the CONSTANT", () => {
353
+ const prev = process.env.ASSURANCE_ROOM_INTERIM_WINDOW_MS;
354
+ try {
355
+ process.env.ASSURANCE_ROOM_INTERIM_WINDOW_MS = "1000";
356
+ const led = recordInterim({ ledger: _emptyLedger(), service: "cohort", channel: "C1", now: T0 });
357
+ assert.equal(
358
+ interimAllowed({ ledger: led, service: "cohort", channel: "C1", now: T0 + 2000 }).allowed,
359
+ false,
360
+ "the pure function used the 15-minute constant, not the environment",
361
+ );
362
+ } finally {
363
+ if (prev === undefined) delete process.env.ASSURANCE_ROOM_INTERIM_WINDOW_MS;
364
+ else process.env.ASSURANCE_ROOM_INTERIM_WINDOW_MS = prev;
365
+ }
366
+ });
367
+ });
368
+
369
+ // ---------------------------------------------------------------------------
370
+ // Identical outcome notices
371
+ // ---------------------------------------------------------------------------
372
+
373
+ describe("textDigest / noticeKey", () => {
374
+ test("the same sentence digests the same however it is spaced", () => {
375
+ assert.equal(textDigest("Couldn't finish this — it timed out."), textDigest(" Couldn't finish this — it timed out. "));
376
+ });
377
+
378
+ test("a different sentence digests differently", () => {
379
+ assert.notEqual(textDigest("Hit a problem — it timed out. Retrying now."), textDigest("Couldn't finish this — it timed out."));
380
+ });
381
+
382
+ test("the key separates room, notice id and sentence", () => {
383
+ const base = { service: "cohort", channel: "C1", notice: "failure:final", text: "same" };
384
+ assert.notEqual(noticeKey(base), noticeKey({ ...base, channel: "C2" }));
385
+ assert.notEqual(noticeKey(base), noticeKey({ ...base, notice: "failure:retrying" }));
386
+ assert.notEqual(noticeKey(base), noticeKey({ ...base, text: "different" }));
387
+ assert.equal(noticeKey(base), noticeKey({ ...base }));
388
+ });
389
+ });
390
+
391
+ describe("claimRoomNotice — a byte-identical outcome notice is not said twice in one room", () => {
392
+ let ROOT;
393
+ const STALE = "Couldn't finish this — the work never came back with a result and has now outrun its time limit. I've stopped retrying and flagged it so it isn't lost.";
394
+ beforeEach(() => { ROOT = mkdtempSync(join(tmpdir(), "room-notice-")); });
395
+
396
+ test("THE SIBLING CASE: twenty debts, one sentence, one message", () => {
397
+ // The per-obligation notice ledger cannot reach this: twenty separate asks
398
+ // are twenty separate records, and the stale sentence carries nothing about
399
+ // which ask it is, so the reader gets the identical line twenty times and
400
+ // cannot tell any of them apart.
401
+ let said = 0;
402
+ for (let i = 0; i < 20; i++) {
403
+ if (claimRoomNotice({ service: "cohort", channel: "C-CAPFORM", notice: "failure:final", text: STALE, now: T0 + i * 1000, agentRoot: ROOT }).allowed) said++;
404
+ }
405
+ assert.equal(said, 1, `twenty identical failure sentences must reach one room once — got ${said}`);
406
+ });
407
+
408
+ test("a DIFFERENT sentence is never suppressed, at any volume", () => {
409
+ // This is the safety argument. Suppressing information a reader does not
410
+ // have re-creates the original bug: a person never told their work died.
411
+ let said = 0;
412
+ for (let i = 0; i < 20; i++) {
413
+ const text = `Couldn't finish this — cause number ${i}. I've stopped retrying.`;
414
+ if (claimRoomNotice({ service: "cohort", channel: "C-CAPFORM", notice: "failure:final", text, now: T0 + i * 1000, agentRoot: ROOT }).allowed) said++;
415
+ }
416
+ assert.equal(said, 20, "every distinct fact is still said");
417
+ });
418
+
419
+ test("'a retry is running' and 'I have stopped' stay separately sayable", () => {
420
+ const retrying = "Hit a problem — it timed out. Retrying now; if it fails again I'll come straight back.";
421
+ assert.equal(claimRoomNotice({ service: "cohort", channel: "C1", notice: "failure:retrying", text: retrying, now: T0, agentRoot: ROOT }).allowed, true);
422
+ assert.equal(claimRoomNotice({ service: "cohort", channel: "C1", notice: "failure:final", text: STALE, now: T0 + 1, agentRoot: ROOT }).allowed, true);
423
+ });
424
+
425
+ test("a different ROOM is never gagged by a busy one", () => {
426
+ claimRoomNotice({ service: "cohort", channel: "busy", notice: "failure:final", text: STALE, now: T0, agentRoot: ROOT });
427
+ assert.equal(claimRoomNotice({ service: "cohort", channel: "quiet", notice: "failure:final", text: STALE, now: T0 + 1, agentRoot: ROOT }).allowed, true);
428
+ });
429
+
430
+ test("the window reopens, because the same fact a day later is news again", () => {
431
+ claimRoomNotice({ service: "cohort", channel: "C1", notice: "failure:final", text: STALE, now: T0, agentRoot: ROOT });
432
+ assert.equal(claimRoomNotice({ service: "cohort", channel: "C1", notice: "failure:final", text: STALE, now: T0 + DEFAULT_ROOM_NOTICE_WINDOW_MS - 1, agentRoot: ROOT }).allowed, false);
433
+ assert.equal(claimRoomNotice({ service: "cohort", channel: "C1", notice: "failure:final", text: STALE, now: T0 + DEFAULT_ROOM_NOTICE_WINDOW_MS, agentRoot: ROOT }).allowed, true);
434
+ });
435
+
436
+ test("commit:false asks without spending", () => {
437
+ const peek = claimRoomNotice({ service: "cohort", channel: "C1", notice: "failure:final", text: STALE, now: T0, agentRoot: ROOT, commit: false });
438
+ assert.equal(peek.allowed, true);
439
+ assert.equal(peek.committed, false);
440
+ assert.equal(claimRoomNotice({ service: "cohort", channel: "C1", notice: "failure:final", text: STALE, now: T0 + 1, agentRoot: ROOT }).allowed, true,
441
+ "the peek left it unspent — a send that never happened must not silence the next one");
442
+ });
443
+
444
+ test("FAIL-OPEN, and the OPPOSITE way to the interim: no clock ⇒ say it", () => {
445
+ // An interim is a courtesy and is safe to drop. "Your work died" is not.
446
+ const v = claimRoomNotice({ service: "cohort", channel: "C1", notice: "failure:final", text: STALE, agentRoot: ROOT });
447
+ assert.equal(v.allowed, true);
448
+ assert.equal(v.degraded, true);
449
+ });
450
+
451
+ test("an unreadable ledger lets the notice through, flagged", () => {
452
+ mkdirSync(join(ROOT, "state", "obligations"), { recursive: true });
453
+ writeFileSync(ledgerPath(ROOT), "{ not json");
454
+ const v = claimRoomNotice({ service: "cohort", channel: "C1", notice: "failure:final", text: STALE, now: T0, agentRoot: ROOT });
455
+ assert.equal(v.allowed, true);
456
+ assert.equal(v.degraded, true);
457
+ });
458
+
459
+ test("interims and notices share one file without evicting each other", () => {
460
+ claimRoomInterim({ service: "cohort", channel: "C1", now: T0, agentRoot: ROOT });
461
+ claimRoomNotice({ service: "cohort", channel: "C1", notice: "failure:final", text: STALE, now: T0 + 1, agentRoot: ROOT });
462
+ const led = JSON.parse(readFileSync(ledgerPath(ROOT), "utf-8"));
463
+ assert.equal(Object.keys(led.rooms).length, 1, "the interim spend survived the notice write");
464
+ assert.equal(Object.keys(led.notices).length, 1);
465
+ assert.equal(claimRoomInterim({ service: "cohort", channel: "C1", now: T0 + 2, agentRoot: ROOT }).allowed, false);
466
+ assert.equal(claimRoomNotice({ service: "cohort", channel: "C1", notice: "failure:final", text: STALE, now: T0 + 3, agentRoot: ROOT }).allowed, false);
467
+ });
468
+
469
+ test("the notice map is pruned too, so the file cannot grow without bound", () => {
470
+ let led = _emptyLedger();
471
+ for (let i = 0; i < 50; i++) {
472
+ led = recordNotice({ ledger: led, service: "cohort", channel: "C1", notice: "failure:final", text: `old ${i}`, now: T0 });
473
+ }
474
+ const pruned = pruneLedger({ ledger: led, now: T0 + 3 * DEFAULT_ROOM_NOTICE_WINDOW_MS });
475
+ assert.equal(Object.keys(pruned.notices).length, 0);
476
+ });
477
+ });
478
+
479
+ describe("the ledger basename is exported, because its reader has to skip it", () => {
480
+ test("LEDGER_BASENAME is the file ledgerPath produces", () => {
481
+ // `assurance.listObligations` parses every *.json in this directory as a
482
+ // debt record. It skips this one BY NAME, so the name must be one string.
483
+ assert.equal(ledgerPath("/tmp/x").endsWith(`/${LEDGER_BASENAME}`), true);
484
+ assert.equal(LEDGER_BASENAME, "room-budget.json");
485
+ });
486
+ });
@@ -0,0 +1,166 @@
1
+ /**
2
+ * lib/assurance/tier.mjs — how loud may this ask make the agent?
3
+ *
4
+ * THE MEASUREMENT THIS EXISTS TO ANSWER
5
+ *
6
+ * Production, org_default_adaptic, the fourteen days to 2026-09-12: 10,667
7
+ * agent messages, of which 3,069 (28.8 %) were opening acknowledgements and 961
8
+ * (9.0 %) were clock-derived progress nags — 37.8 % content-free. 272 of those
9
+ * acknowledgements were never followed by a substantive reply within the hour.
10
+ * Agent-to-human volume went from 2:1 to 158:1 in a fortnight, and 9,187 of
11
+ * ~11,400 messages in 21 days landed in ONE channel from eight agents.
12
+ *
13
+ * The cause was not a bad message. It was the absence of a decision: every
14
+ * inbound ask that spawned a session earned an interim, so the interim rate was
15
+ * the session rate. This module is that missing decision.
16
+ *
17
+ * THE RULE
18
+ *
19
+ * Silence is the default. An interim message is never a reflex; it is either
20
+ * absent, or it is a plan.
21
+ *
22
+ * answer — the reply arrives in this turn. Nothing is said. The reply IS the
23
+ * acknowledgement (typing indicator aside).
24
+ * work — a session is spawning against a single objective. Nothing is said
25
+ * for ACK_AFTER_MS; after that, at most ONE line, and only if the
26
+ * room's own budget allows it.
27
+ * plan — the work is big enough that a human genuinely needs to know the
28
+ * shape of it. ONE message, and it is the plan: what will be done,
29
+ * in what order, and what comes back.
30
+ *
31
+ * PURE. Everything arrives on the argument; nothing is read from disk, the
32
+ * clock, the environment or the network. That is what lets a routing decision
33
+ * be replayed offline from a journal row, and what lets the whole matrix below
34
+ * be pinned by a test rather than sampled by a daemon.
35
+ *
36
+ * WHAT "rung" MEANS HERE
37
+ *
38
+ * The execution rung from `lib/execution/route.mjs` — 0 function call, 1 skill,
39
+ * 2 plugin, 3 session, 4 workflow, 5 sub-agent team. It is an INPUT, not a
40
+ * guess: an unrouted item (rung null, which is every inbound item until WP-2
41
+ * wires effort routing) is treated as unknown, and unknown buys nothing. It
42
+ * does not qualify for the answer tier's silence and it does not qualify for
43
+ * the plan tier's licence to speak. `lib/backlog` learned the same lesson from
44
+ * the other side — "absence must not become rung 0 — that is the cheapest
45
+ * rung".
46
+ *
47
+ * FAIL-OPEN DIRECTION. Garbage in gives `work` out: the tier that can still
48
+ * speak once, never the tier that cannot speak at all. A broken classifier must
49
+ * not silently mute an agent — that is the failure the obligation ledger was
50
+ * built to end, and it outranks tidiness.
51
+ *
52
+ * @module lib/assurance/tier
53
+ */
54
+
55
+ "use strict";
56
+
57
+ /** The only three answers this module can give. */
58
+ export const TIERS = Object.freeze(["answer", "work", "plan"]);
59
+
60
+ /** Highest rung that can still be answered inside the turn. */
61
+ export const ANSWER_MAX_RUNG = 1;
62
+
63
+ /** Lowest rung whose shape a human deserves to be told before it runs. */
64
+ export const PLAN_MIN_RUNG = 3;
65
+
66
+ /** Classifier actions whose high-priority form is worth a plan. */
67
+ export const PLAN_ACTIONS = Object.freeze(["research", "draft"]);
68
+
69
+ /** …and the priorities at which they are. */
70
+ export const PLAN_PRIORITIES = Object.freeze(["critical", "high"]);
71
+
72
+ /** Rungs, as `lib/execution/route.RUNGS` numbers them. */
73
+ const MIN_RUNG = 0;
74
+ const MAX_RUNG = 5;
75
+
76
+ /**
77
+ * A rung we are willing to reason from, or null.
78
+ *
79
+ * Only an in-range integer counts. A string "3", a NaN, a 6 — each is a caller
80
+ * bug or a stale field, and reading any of them as a rung would let a typo
81
+ * decide whether a human is spoken to.
82
+ *
83
+ * @param {unknown} rung
84
+ * @returns {number|null}
85
+ */
86
+ function routedRung(rung) {
87
+ if (typeof rung !== "number") return null;
88
+ if (!Number.isInteger(rung)) return null;
89
+ if (rung < MIN_RUNG || rung > MAX_RUNG) return null;
90
+ return rung;
91
+ }
92
+
93
+ /**
94
+ * Decide the reply tier for one inbound ask.
95
+ *
96
+ * The order of the rules is the policy, so it is stated rather than implied:
97
+ *
98
+ * 1. The caller asserts no session is spawning ⇒ `answer`. This is a fact about control flow,
99
+ * not a classifier opinion, and it is the same fact `shouldAcknowledge`
100
+ * has always turned on: if the answer arrives in this turn, an interim in
101
+ * front of it is pure noise.
102
+ * 2. The classifier says answerable AND the routed rung is 0 or 1 AND the
103
+ * caller has NOT asserted that a session is spawning ⇒ `answer`. All
104
+ * three halves are required, and the third was once missing — see
105
+ * THE FALL-THROUGH HAZARD below.
106
+ * 3. A routed rung of 3+ ⇒ `plan`. A workflow or a sub-agent team is not a
107
+ * reply, it is a project; a human who is about to wait on one is owed its
108
+ * shape.
109
+ * 4. research/draft at critical or high ⇒ `plan`, even unrouted. These are
110
+ * the asks where the first thing a human wants is the approach, not the
111
+ * output.
112
+ * 5. Otherwise ⇒ `work`.
113
+ *
114
+ * @param {object} [o]
115
+ * @param {boolean} [o.answerable] classifier verdict: answerable in one reply
116
+ * @param {string} [o.action] classifier action (respond|draft|research|queue|archive|ignore)
117
+ * @param {string} [o.priority] classifier priority (critical|high|normal|ignore)
118
+ * @param {number|null} [o.rung] routed execution rung, or null when unrouted
119
+ * @param {boolean} [o.willSpawnSession] is a session being dispatched for this item.
120
+ * `false` asserts it is not, and buys total silence (rule 1). `true`
121
+ * asserts one is, and DISQUALIFIES the answer tier (rule 2) however
122
+ * answerable the classifier thought the item was. Absent means unknown,
123
+ * which buys nothing either way.
124
+ * @returns {"answer"|"work"|"plan"}
125
+ */
126
+ export function replyTier(o) {
127
+ const a = o && typeof o === "object" ? o : {};
128
+
129
+ // (1) Control flow, not classification. `=== false` and not merely falsy:
130
+ // "no session is spawning" is a fact the caller must ASSERT, because it buys
131
+ // total silence. An absent or malformed flag is unknown, and unknown must not
132
+ // mute an agent — it falls through to `work`, which speaks once at most.
133
+ if (a.willSpawnSession === false) return "answer";
134
+
135
+ const rung = routedRung(a.rung);
136
+
137
+ // (2) Answerable AND cheap AND not, in fact, spawning a session.
138
+ //
139
+ // THE FALL-THROUGH HAZARD. `agent-daemon.mjs` tries a quick reply first; if
140
+ // that reply fails transiently or is blocked by validation it falls THROUGH
141
+ // to a full session dispatch and then asks this module for a tier with
142
+ // `willSpawnSession: true` while `classResult.answerable` is still true. The
143
+ // premise of the answer tier — "the reply arrives in this turn" — is exactly
144
+ // what the fall-through has already falsified. Without the third clause, the
145
+ // moment WP-2 routes `rung` (it is null today, so this is latent rather than
146
+ // live) that item would land in `answer`, `shouldAcknowledge` would return
147
+ // ack:false, the sweep would read the durable tier as never-speak, and a
148
+ // 15-45 minute session would run with the human hearing NOTHING at all.
149
+ //
150
+ // `!== true` and not `=== false`: the two ways the caller can leave this
151
+ // unknown (absent, malformed) are handled by rule (5), which speaks once.
152
+ // Only an ASSERTED spawn disqualifies the answer tier here — which is the
153
+ // mirror of rule (1), where only an ASSERTED non-spawn qualifies for it.
154
+ if (a.willSpawnSession !== true && a.answerable === true && rung != null && rung <= ANSWER_MAX_RUNG) return "answer";
155
+
156
+ // (3) Routed to machinery that is a project, not a reply.
157
+ if (rung != null && rung >= PLAN_MIN_RUNG) return "plan";
158
+
159
+ // (4) The two actions whose high-priority form a human wants the shape of.
160
+ if (PLAN_ACTIONS.includes(a.action) && PLAN_PRIORITIES.includes(a.priority)) return "plan";
161
+
162
+ // (5) The common case, and the one the flood was made of.
163
+ return "work";
164
+ }
165
+
166
+ export default { replyTier, TIERS, ANSWER_MAX_RUNG, PLAN_MIN_RUNG, PLAN_ACTIONS, PLAN_PRIORITIES };