@cohortapp/agent-sdk 2.15.0 → 2.17.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/.env.example +5 -2
- package/docs/guides/front-door-session.md +16 -5
- package/docs/guides/poller-daemon-setup.md +53 -2
- 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/lib/context/budget.mjs +327 -0
- package/lib/context/budget.test.mjs +252 -0
- package/lib/context/history-scope.mjs +138 -0
- package/lib/context/history-scope.test.mjs +79 -0
- package/lib/model-router/economics.mjs +9 -0
- package/lib/model-router/resolve.mjs +6 -0
- package/lib/org/inbound/facts.mjs +4 -2
- package/lib/org/inbound/hydrate.mjs +555 -51
- package/lib/org/inbound/hydrate.test.mjs +456 -1
- package/package.json +3 -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/context-compiler.mjs +52 -21
- package/scripts/daemon/context-compiler.test.mjs +106 -0
- package/scripts/daemon/deliver.mjs +7 -4
- package/scripts/daemon/dispatcher-session-continuity.test.mjs +365 -0
- package/scripts/daemon/dispatcher.mjs +210 -9
- package/scripts/daemon/lib/session-router.mjs +310 -42
- package/scripts/daemon/lib/session-router.test.mjs +260 -1
- package/scripts/daemon/prompt-builder.mjs +160 -16
- package/scripts/daemon/prompt-builder.test.mjs +287 -7
- package/scripts/daemon/responder-history.test.mjs +37 -1
- package/scripts/daemon/responder.mjs +79 -72
|
@@ -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 };
|