@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.
- 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/lib/telemetry/collect.mjs +21 -1
- package/lib/telemetry/collect.test.mjs +54 -0
- 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
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/assurance/room-budget.mjs — one interim per room per window, per seat.
|
|
3
|
+
*
|
|
4
|
+
* THE DEFECT THIS IS PART OF THE ANSWER TO
|
|
5
|
+
*
|
|
6
|
+
* The interim budget used to be per OBLIGATION and never per ROOM. Each inbound
|
|
7
|
+
* item opened its own debt with its own ack → progress → failure lifecycle, so
|
|
8
|
+
* N concurrent obligations in one channel emitted up to N acks, 2N progress
|
|
9
|
+
* pings and N failure notices — and nothing anywhere counted messages per room.
|
|
10
|
+
* Measured in `capital-formation` over 21 days: 9,187 messages from 8 agents,
|
|
11
|
+
* ~45 % exact duplicates, ~38 msg/h, and two independent timers narrating the
|
|
12
|
+
* same overlapping work at the SAME timestamp ("Still on this — 5 minutes in"
|
|
13
|
+
* beside "Still going — 15 minutes in"). Every individual decision was correct.
|
|
14
|
+
* The room was the thing nobody was counting.
|
|
15
|
+
*
|
|
16
|
+
* THE RULE, AND ITS HONEST SCOPE
|
|
17
|
+
*
|
|
18
|
+
* At most ONE interim per (service, channel) per window — default 15 minutes,
|
|
19
|
+
* `ASSURANCE_ROOM_INTERIM_WINDOW_MS` to change it — regardless of how many
|
|
20
|
+
* obligations are open in that room, **for this agent seat**.
|
|
21
|
+
*
|
|
22
|
+
* THAT LAST CLAUSE IS LOAD-BEARING AND WAS ONCE MISSING FROM THIS HEADER.
|
|
23
|
+
* The ledger lives under `AGENT_DIR`, which is one agent's repo on one Mac
|
|
24
|
+
* mini. There is no shared store between seats — the fleet is N machines with
|
|
25
|
+
* no common filesystem — so the guarantee this module can actually make is
|
|
26
|
+
* per (seat, service, channel), not per (service, channel).
|
|
27
|
+
*
|
|
28
|
+
* The arithmetic that follows from that, stated rather than glossed, because a
|
|
29
|
+
* reader who believes "one room ⇒ one message" will mis-size the next change:
|
|
30
|
+
* the measured room had 8 seats in it, so this gate's ceiling there is
|
|
31
|
+
* 8 × (60/15) = 32 interims/hour. The measured content-free rate in that room
|
|
32
|
+
* was ~38 msg/h × 37.8 % ≈ 14/h. 32 > 14, so THIS GATE ALONE WOULD NOT HAVE
|
|
33
|
+
* BEEN THE BINDING CONSTRAINT on the measured data. What removed the measured
|
|
34
|
+
* traffic is the whole stack, and mostly the other three gates:
|
|
35
|
+
*
|
|
36
|
+
* - the deleted progress branch — 961 messages → 0, unconditionally;
|
|
37
|
+
* - `sanitiseAckText`'s generic-opener
|
|
38
|
+
* rejection ⇒ null ⇒ send nothing — blocks the measured opening-ack
|
|
39
|
+
* population outright;
|
|
40
|
+
* - nothing at all before ACK_AFTER_MS
|
|
41
|
+
* plus the per-obligation `interimSaid`
|
|
42
|
+
* latch, inherited across retries — one interim per ask, ever.
|
|
43
|
+
*
|
|
44
|
+
* This gate is the BACKSTOP that bounds what survives all of those: it is what
|
|
45
|
+
* keeps a single seat holding twenty concurrent asks in one channel from
|
|
46
|
+
* emitting twenty bespoke, individually-defensible holding lines. Sized
|
|
47
|
+
* correctly, described correctly, and not credited with the whole fix.
|
|
48
|
+
*
|
|
49
|
+
* WHAT THE BUDGET GOVERNS, AND WHAT IT DOES NOT
|
|
50
|
+
*
|
|
51
|
+
* governed — interims: the holding acknowledgement and the plan note. These
|
|
52
|
+
* are courtesies. A room that has had one does not need another.
|
|
53
|
+
* ungoverned — outcome messages: failure, stale, interrupted, and the
|
|
54
|
+
* silent-success recovery. These carry information a human
|
|
55
|
+
* cannot get any other way, and rate-limiting one re-creates the
|
|
56
|
+
* original bug (a person who is never told their work died).
|
|
57
|
+
*
|
|
58
|
+
* They are not ungoverned ENTIRELY, though: see
|
|
59
|
+
* `claimRoomNotice` below, which suppresses a BYTE-IDENTICAL
|
|
60
|
+
* repeat of one into the same room. That is a different
|
|
61
|
+
* question from "has this room had enough" — a sentence
|
|
62
|
+
* indistinguishable from one already on screen cannot tell a
|
|
63
|
+
* reader which ask it is about, so the second copy carries no
|
|
64
|
+
* information at all, only volume.
|
|
65
|
+
*
|
|
66
|
+
* SHAPE. The decisions are pure and take the ledger as an argument; the durable
|
|
67
|
+
* edges (`claimRoomInterim`, `claimRoomNotice`) are the only functions that
|
|
68
|
+
* touch disk, and their clock and filesystem are both injected. The default
|
|
69
|
+
* window is resolved by `roomInterimWindowMs(env)` — a function, called at the
|
|
70
|
+
* EDGE, not a constant captured at module load: a constant folded at import
|
|
71
|
+
* time means an operator who exports the env var after the first import gets
|
|
72
|
+
* the stale default with no error, and means a pure module read the
|
|
73
|
+
* environment on the way past.
|
|
74
|
+
*
|
|
75
|
+
* FAIL-OPEN DIRECTION, chosen deliberately. An unreadable or unwritable ledger
|
|
76
|
+
* ALLOWS the message and reports `degraded: true`. A JSON file that will not
|
|
77
|
+
* parse must never be the reason a waiting human hears nothing; the cost of
|
|
78
|
+
* that direction is bounded by every other gate still standing, and `degraded`
|
|
79
|
+
* makes the condition countable instead of silent.
|
|
80
|
+
*
|
|
81
|
+
* @module lib/assurance/room-budget
|
|
82
|
+
*/
|
|
83
|
+
|
|
84
|
+
"use strict";
|
|
85
|
+
|
|
86
|
+
import { readFileSync } from "node:fs";
|
|
87
|
+
import { join } from "node:path";
|
|
88
|
+
import { writeJsonAtomic } from "../fs-atomic.mjs";
|
|
89
|
+
|
|
90
|
+
const num = (v, d) => { const n = parseInt(v, 10); return Number.isFinite(n) ? n : d; };
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* How long one interim buys a room's silence, absent an override.
|
|
94
|
+
*
|
|
95
|
+
* Fifteen minutes is chosen against the measured session distribution (p50
|
|
96
|
+
* 14.7 min): a room hears at most one holding line per typical unit of work,
|
|
97
|
+
* which is the point at which an interim is still a courtesy rather than a nag.
|
|
98
|
+
*
|
|
99
|
+
* A plain constant, with no environment in it, so the pure functions below can
|
|
100
|
+
* fall back to it without reading anything.
|
|
101
|
+
*/
|
|
102
|
+
export const DEFAULT_ROOM_INTERIM_WINDOW_MS = 15 * 60_000;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* How long one outcome notice suppresses a byte-identical copy of itself in the
|
|
106
|
+
* same room. Same default, separately named and separately overridable: the two
|
|
107
|
+
* are different judgements and collapsing them would mean tuning one retunes
|
|
108
|
+
* the other by accident.
|
|
109
|
+
*/
|
|
110
|
+
export const DEFAULT_ROOM_NOTICE_WINDOW_MS = 15 * 60_000;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The configured interim window. A FUNCTION, read at the edge — see the header.
|
|
114
|
+
* @param {object} [env]
|
|
115
|
+
* @returns {number}
|
|
116
|
+
*/
|
|
117
|
+
export function roomInterimWindowMs(env = process.env) {
|
|
118
|
+
return num(env && env.ASSURANCE_ROOM_INTERIM_WINDOW_MS, DEFAULT_ROOM_INTERIM_WINDOW_MS);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The configured identical-notice window. Same reasoning as above.
|
|
123
|
+
* @param {object} [env]
|
|
124
|
+
* @returns {number}
|
|
125
|
+
*/
|
|
126
|
+
export function roomNoticeWindowMs(env = process.env) {
|
|
127
|
+
return num(env && env.ASSURANCE_ROOM_NOTICE_WINDOW_MS, DEFAULT_ROOM_NOTICE_WINDOW_MS);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Entries older than this are landfill: they can no longer refuse anything. */
|
|
131
|
+
const RETAIN_WINDOWS = 2;
|
|
132
|
+
|
|
133
|
+
/** The shape on disk. `v` is there so a future migration is not a guess. */
|
|
134
|
+
export function _emptyLedger() {
|
|
135
|
+
return { v: 1, rooms: {}, notices: {} };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The room's identity: the service and the channel, normalised.
|
|
140
|
+
*
|
|
141
|
+
* Deliberately NOT the thread, the sender or the obligation. A thread-scoped
|
|
142
|
+
* budget would have permitted exactly the flood that was measured — eight
|
|
143
|
+
* agents, dozens of threads, one very loud channel.
|
|
144
|
+
*
|
|
145
|
+
* @param {object} [o] {service, channel}
|
|
146
|
+
* @returns {string}
|
|
147
|
+
*/
|
|
148
|
+
export function roomKey(o) {
|
|
149
|
+
const a = o && typeof o === "object" ? o : {};
|
|
150
|
+
const norm = (v) => String(v == null ? "" : v).trim().toLowerCase() || "unknown";
|
|
151
|
+
return `${norm(a.service)}|${norm(a.channel)}`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* A stable, dependency-free digest of a message's text.
|
|
156
|
+
*
|
|
157
|
+
* FNV-1a over the trimmed, whitespace-collapsed string. It only ever has to
|
|
158
|
+
* answer "is this the same sentence again", so a 32-bit non-cryptographic hash
|
|
159
|
+
* is the right size; a collision costs one suppressed notice, against a
|
|
160
|
+
* baseline of N identical ones.
|
|
161
|
+
*
|
|
162
|
+
* @param {unknown} text
|
|
163
|
+
* @returns {string}
|
|
164
|
+
*/
|
|
165
|
+
export function textDigest(text) {
|
|
166
|
+
const s = String(text == null ? "" : text).trim().replace(/\s+/g, " ");
|
|
167
|
+
let h = 0x811c9dc5;
|
|
168
|
+
for (let i = 0; i < s.length; i++) {
|
|
169
|
+
h ^= s.charCodeAt(i);
|
|
170
|
+
h = Math.imul(h, 0x01000193) >>> 0;
|
|
171
|
+
}
|
|
172
|
+
return `${h.toString(36)}.${s.length}`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The identical-notice key: the room, the notice id, and the sentence itself. */
|
|
176
|
+
export function noticeKey(o) {
|
|
177
|
+
const a = o && typeof o === "object" ? o : {};
|
|
178
|
+
const notice = String(a.notice == null ? "" : a.notice).trim().toLowerCase() || "notice";
|
|
179
|
+
return `${roomKey(a)}|${notice}|${textDigest(a.text)}`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** One of the ledger's maps, or an empty one — never a throw, never a null deref. */
|
|
183
|
+
function mapOf(ledger, field) {
|
|
184
|
+
if (!ledger || typeof ledger !== "object") return {};
|
|
185
|
+
const r = ledger[field];
|
|
186
|
+
return r && typeof r === "object" && !Array.isArray(r) ? r : {};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const roomsOf = (ledger) => mapOf(ledger, "rooms");
|
|
190
|
+
const noticesOf = (ledger) => mapOf(ledger, "notices");
|
|
191
|
+
|
|
192
|
+
/** A stamp we are willing to reason from, or null. */
|
|
193
|
+
function stampAt(map, key) {
|
|
194
|
+
const v = map[key];
|
|
195
|
+
return typeof v === "number" && Number.isFinite(v) ? v : null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* PURE. Is `now` still inside the window opened at `lastAt`?
|
|
200
|
+
*
|
|
201
|
+
* `now - lastAt` and not `Math.abs(...)`: a clock that has stepped BACKWARDS
|
|
202
|
+
* (NTP correction, a restored snapshot) yields a negative elapsed, which is
|
|
203
|
+
* less than any window and therefore reads as "inside it". A free message on
|
|
204
|
+
* every backwards step is how a budget becomes decorative.
|
|
205
|
+
*/
|
|
206
|
+
function insideWindow(now, lastAt, windowMs) {
|
|
207
|
+
return !Number.isFinite(now) || now - lastAt < windowMs;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* PURE. May this room hear an interim right now?
|
|
212
|
+
*
|
|
213
|
+
* @param {object} o
|
|
214
|
+
* @param {object} o.ledger the ledger as read (any shape; garbage fails open)
|
|
215
|
+
* @param {string} o.service
|
|
216
|
+
* @param {string} o.channel
|
|
217
|
+
* @param {number} o.now
|
|
218
|
+
* @param {number} [o.windowMs] defaults to DEFAULT_ROOM_INTERIM_WINDOW_MS —
|
|
219
|
+
* the constant, never the environment; the edge resolves the configured
|
|
220
|
+
* value and passes it in.
|
|
221
|
+
* @returns {{allowed:boolean, reason:string, room:string, lastAt:number|null}}
|
|
222
|
+
*/
|
|
223
|
+
export function interimAllowed(o = {}) {
|
|
224
|
+
const room = roomKey(o);
|
|
225
|
+
const windowMs = Number.isFinite(o.windowMs) ? o.windowMs : DEFAULT_ROOM_INTERIM_WINDOW_MS;
|
|
226
|
+
const lastAt = stampAt(roomsOf(o.ledger), room);
|
|
227
|
+
if (lastAt == null) return { allowed: true, reason: "room-budget-free", room, lastAt: null };
|
|
228
|
+
if (insideWindow(o.now, lastAt, windowMs)) {
|
|
229
|
+
return { allowed: false, reason: "room-budget-spent", room, lastAt };
|
|
230
|
+
}
|
|
231
|
+
return { allowed: true, reason: "room-budget-window-elapsed", room, lastAt };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* PURE. Has this EXACT sentence already been said in this room, recently?
|
|
236
|
+
*
|
|
237
|
+
* @param {object} o {ledger, service, channel, notice, text, now, windowMs}
|
|
238
|
+
* @returns {{allowed:boolean, reason:string, room:string, key:string, lastAt:number|null}}
|
|
239
|
+
*/
|
|
240
|
+
export function noticeAllowed(o = {}) {
|
|
241
|
+
const room = roomKey(o);
|
|
242
|
+
const key = noticeKey(o);
|
|
243
|
+
const windowMs = Number.isFinite(o.windowMs) ? o.windowMs : DEFAULT_ROOM_NOTICE_WINDOW_MS;
|
|
244
|
+
const lastAt = stampAt(noticesOf(o.ledger), key);
|
|
245
|
+
if (lastAt == null) return { allowed: true, reason: "room-notice-new", room, key, lastAt: null };
|
|
246
|
+
if (insideWindow(o.now, lastAt, windowMs)) {
|
|
247
|
+
return { allowed: false, reason: "room-notice-duplicate", room, key, lastAt };
|
|
248
|
+
}
|
|
249
|
+
return { allowed: true, reason: "room-notice-window-elapsed", room, key, lastAt };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* PURE. The ledger that results from this room having spoken at `now`.
|
|
254
|
+
* Returns a NEW object; the input is never mutated.
|
|
255
|
+
*/
|
|
256
|
+
export function recordInterim(o = {}) {
|
|
257
|
+
const rooms = { ...roomsOf(o.ledger), [roomKey(o)]: Number.isFinite(o.now) ? o.now : 0 };
|
|
258
|
+
return pruneLedger({
|
|
259
|
+
ledger: { v: 1, rooms, notices: { ...noticesOf(o.ledger) } },
|
|
260
|
+
now: o.now,
|
|
261
|
+
windowMs: o.windowMs,
|
|
262
|
+
noticeWindowMs: o.noticeWindowMs,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* PURE. The ledger that results from this sentence having been said at `now`.
|
|
268
|
+
* Returns a NEW object; the input is never mutated.
|
|
269
|
+
*/
|
|
270
|
+
export function recordNotice(o = {}) {
|
|
271
|
+
const notices = { ...noticesOf(o.ledger), [noticeKey(o)]: Number.isFinite(o.now) ? o.now : 0 };
|
|
272
|
+
return pruneLedger({
|
|
273
|
+
ledger: { v: 1, rooms: { ...roomsOf(o.ledger) }, notices },
|
|
274
|
+
now: o.now,
|
|
275
|
+
windowMs: o.windowMs,
|
|
276
|
+
noticeWindowMs: o.noticeWindowMs,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* PURE. Drop entries that can no longer refuse anything.
|
|
282
|
+
*
|
|
283
|
+
* Two windows of slack rather than one, so a boundary case is never pruned into
|
|
284
|
+
* a free message.
|
|
285
|
+
*/
|
|
286
|
+
export function pruneLedger(o = {}) {
|
|
287
|
+
const windowMs = Number.isFinite(o.windowMs) ? o.windowMs : DEFAULT_ROOM_INTERIM_WINDOW_MS;
|
|
288
|
+
const noticeWindowMs = Number.isFinite(o.noticeWindowMs) ? o.noticeWindowMs : DEFAULT_ROOM_NOTICE_WINDOW_MS;
|
|
289
|
+
const now = Number.isFinite(o.now) ? o.now : null;
|
|
290
|
+
const rooms = roomsOf(o.ledger);
|
|
291
|
+
const notices = noticesOf(o.ledger);
|
|
292
|
+
if (now == null) return { v: 1, rooms: { ...rooms }, notices: { ...notices } };
|
|
293
|
+
const sift = (map, win) => {
|
|
294
|
+
const keep = {};
|
|
295
|
+
for (const k of Object.keys(map)) {
|
|
296
|
+
const at = stampAt(map, k);
|
|
297
|
+
if (at == null) continue; // a corrupt entry refuses nothing; drop it
|
|
298
|
+
if (now - at < win * RETAIN_WINDOWS) keep[k] = at;
|
|
299
|
+
}
|
|
300
|
+
return keep;
|
|
301
|
+
};
|
|
302
|
+
return { v: 1, rooms: sift(rooms, windowMs), notices: sift(notices, noticeWindowMs) };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Where the durable ledger lives — beside the obligations it governs, at the
|
|
307
|
+
* path the design names.
|
|
308
|
+
*
|
|
309
|
+
* It is NOT an obligation record, and the ledger's readers must not treat it as
|
|
310
|
+
* one: `assurance.listObligations` skips this basename by name. A JSON file in
|
|
311
|
+
* a directory whose reader parses every `*.json` as a debt was, before that
|
|
312
|
+
* guard, a phantom record with no `key` and no `state` that `pruneObligations`
|
|
313
|
+
* tried to unlink once a minute.
|
|
314
|
+
*/
|
|
315
|
+
export function ledgerPath(agentRoot) {
|
|
316
|
+
return join(String(agentRoot || "."), "state", "obligations", "room-budget.json");
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** The basename the obligation reader must skip. Exported so it is one string. */
|
|
320
|
+
export const LEDGER_BASENAME = "room-budget.json";
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Read the durable ledger. Absent or corrupt reads as empty, flagged.
|
|
324
|
+
* @returns {{v:number, rooms:object, notices:object, degraded?:boolean}}
|
|
325
|
+
*/
|
|
326
|
+
export function readLedger(o = {}) {
|
|
327
|
+
const impl = o.deps && o.deps.readImpl;
|
|
328
|
+
if (typeof impl === "function") {
|
|
329
|
+
try {
|
|
330
|
+
const led = impl(ledgerPath(o.agentRoot));
|
|
331
|
+
return led && typeof led === "object" ? led : _emptyLedger();
|
|
332
|
+
} catch { return { ..._emptyLedger(), degraded: true }; } // an injected reader that throws is still "nothing known"
|
|
333
|
+
}
|
|
334
|
+
let raw;
|
|
335
|
+
try { raw = readFileSync(ledgerPath(o.agentRoot), "utf-8"); }
|
|
336
|
+
catch { return _emptyLedger(); } // absent is the normal first-run case, not a fault
|
|
337
|
+
try {
|
|
338
|
+
const led = JSON.parse(raw);
|
|
339
|
+
return led && typeof led === "object" ? led : { ..._emptyLedger(), degraded: true };
|
|
340
|
+
} catch {
|
|
341
|
+
// A torn or hand-edited file. Fail OPEN and SAY SO — see the module header.
|
|
342
|
+
return { ..._emptyLedger(), degraded: true };
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Persist, reporting a write failure rather than swallowing it. */
|
|
347
|
+
function persist(o, next, room) {
|
|
348
|
+
const write = (o.deps && o.deps.writeImpl) || writeJsonAtomic;
|
|
349
|
+
try { write(ledgerPath(o.agentRoot), next); return true; }
|
|
350
|
+
catch (err) {
|
|
351
|
+
// The message still goes out — see the fail-open note in the header — but a
|
|
352
|
+
// spend that did not persist is a budget that is not being kept, so it is
|
|
353
|
+
// reported rather than swallowed.
|
|
354
|
+
console.warn(`[room-budget] could not persist spend for ${room}: ${err.message}`);
|
|
355
|
+
return false;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* THE DURABLE EDGE for interims. Decide, and if the answer is yes, spend.
|
|
361
|
+
*
|
|
362
|
+
* Read-decide-write is not atomic. Two callers can therefore both claim one
|
|
363
|
+
* window, and there are two distinct ways for that to happen:
|
|
364
|
+
*
|
|
365
|
+
* ACROSS PROCESSES — two daemons sharing one AGENT_DIR, which is what an
|
|
366
|
+
* operator has while debugging beside the launchd job. Same exposure the
|
|
367
|
+
* obligation ledger already carries; the sweep's `foreignOwnerAlive` guard
|
|
368
|
+
* covers it from the other side.
|
|
369
|
+
*
|
|
370
|
+
* WITHIN ONE PROCESS — two overlapping sweeps on the 60-second health timer.
|
|
371
|
+
* This one is likelier, because branch (e) peeks with `commit:false`, then
|
|
372
|
+
* awaits a model call, and only commits after a successful send: a daemon
|
|
373
|
+
* holding enough eligible obligations spends more than a tick in generation
|
|
374
|
+
* alone. It is closed at the other end — `sweepObligations` refuses to run
|
|
375
|
+
* re-entrantly — rather than with a lock here, because a lock in this
|
|
376
|
+
* function would serialise every caller in the fleet to fix one caller's
|
|
377
|
+
* overlap, and a stuck lock file would gag a room permanently.
|
|
378
|
+
*
|
|
379
|
+
* Either way the failure mode is one extra message against a baseline of up to
|
|
380
|
+
* N per room.
|
|
381
|
+
*
|
|
382
|
+
* @param {object} o
|
|
383
|
+
* @param {string} o.service
|
|
384
|
+
* @param {string} o.channel
|
|
385
|
+
* @param {number} o.now INJECTED clock; omitting it is a caller bug
|
|
386
|
+
* @param {string} o.agentRoot
|
|
387
|
+
* @param {number} [o.windowMs] defaults to the CONFIGURED window
|
|
388
|
+
* @param {boolean} [o.commit=true] false asks the question WITHOUT spending the
|
|
389
|
+
* budget. The caller needs this because the message it wants to send may
|
|
390
|
+
* not exist yet: the interim's text comes from a model call that can
|
|
391
|
+
* fail, and a budget spent on a message that was never composed gags the
|
|
392
|
+
* room for fifteen minutes in exchange for nothing.
|
|
393
|
+
* @param {object} [o.deps] {readImpl, writeImpl}
|
|
394
|
+
* @returns {{allowed:boolean, reason:string, room:string, lastAt:number|null, degraded?:boolean, committed?:boolean}}
|
|
395
|
+
*/
|
|
396
|
+
export function claimRoomInterim(o = {}) {
|
|
397
|
+
const room = roomKey(o);
|
|
398
|
+
if (!Number.isFinite(o.now)) {
|
|
399
|
+
// No clock, no decision. Refusing is right here and only here: the caller
|
|
400
|
+
// has a defect, and a defective caller must not be handed a budget it
|
|
401
|
+
// cannot account for.
|
|
402
|
+
return { allowed: false, reason: "no-clock", room, lastAt: null };
|
|
403
|
+
}
|
|
404
|
+
const windowMs = Number.isFinite(o.windowMs) ? o.windowMs : roomInterimWindowMs();
|
|
405
|
+
const ledger = readLedger(o);
|
|
406
|
+
const degraded = ledger.degraded === true;
|
|
407
|
+
const verdict = interimAllowed({ ledger, service: o.service, channel: o.channel, now: o.now, windowMs });
|
|
408
|
+
if (!verdict.allowed) return degraded ? { ...verdict, degraded } : verdict;
|
|
409
|
+
if (o.commit === false) return degraded ? { ...verdict, degraded, committed: false } : { ...verdict, committed: false };
|
|
410
|
+
|
|
411
|
+
const next = recordInterim({ ledger, service: o.service, channel: o.channel, now: o.now, windowMs });
|
|
412
|
+
const ok = persist(o, next, room);
|
|
413
|
+
if (!ok) return { ...verdict, degraded: true, committed: true };
|
|
414
|
+
return degraded ? { ...verdict, degraded, committed: true } : { ...verdict, committed: true };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* THE DURABLE EDGE for outcome notices: may this EXACT sentence be said here
|
|
419
|
+
* again?
|
|
420
|
+
*
|
|
421
|
+
* WHY THIS EXISTS, given that outcome notices are deliberately ungoverned by
|
|
422
|
+
* the interim budget. The per-obligation dedupe (`assurance.noticeAlreadySaid`)
|
|
423
|
+
* is keyed on the obligation RECORD, so it suppresses the repeat that R4
|
|
424
|
+
* produced — notice, retry, a fresh record under the same key, notice again.
|
|
425
|
+
* It cannot suppress SIBLINGS: twenty separate asks in one room whose sessions
|
|
426
|
+
* all die the same way are twenty separate records, and each composes the same
|
|
427
|
+
* fixed sentence, because `composeFailure`'s stale text has no per-ask content
|
|
428
|
+
* in it at all. Measured shape, and the one the live sample showed: the same
|
|
429
|
+
* failure sentence three times in one minute.
|
|
430
|
+
*
|
|
431
|
+
* The test applied here is therefore NOT "has this room had enough". It is
|
|
432
|
+
* "would this add anything": a sentence byte-identical to one already on screen
|
|
433
|
+
* cannot tell the reader which ask it is about, so the second copy is volume
|
|
434
|
+
* without information. A notice that differs — a different cause, a different
|
|
435
|
+
* excerpt, "retrying" versus "I have stopped" — is NOT suppressed, at any
|
|
436
|
+
* volume, because it carries a fact the reader does not have.
|
|
437
|
+
*
|
|
438
|
+
* The escalation record is written per obligation regardless, so nothing is
|
|
439
|
+
* lost operationally: an operator still sees every dead ask in needs-attention.
|
|
440
|
+
*
|
|
441
|
+
* @param {object} o
|
|
442
|
+
* @param {string} o.service
|
|
443
|
+
* @param {string} o.channel
|
|
444
|
+
* @param {string} o.notice the notice id (see assurance.NOTICE)
|
|
445
|
+
* @param {string} o.text the exact composed sentence
|
|
446
|
+
* @param {number} o.now INJECTED clock
|
|
447
|
+
* @param {string} o.agentRoot
|
|
448
|
+
* @param {number} [o.windowMs]
|
|
449
|
+
* @param {boolean} [o.commit=true]
|
|
450
|
+
* @param {object} [o.deps] {readImpl, writeImpl}
|
|
451
|
+
* @returns {{allowed:boolean, reason:string, room:string, key:string, lastAt:number|null, degraded?:boolean, committed?:boolean}}
|
|
452
|
+
*/
|
|
453
|
+
export function claimRoomNotice(o = {}) {
|
|
454
|
+
const room = roomKey(o);
|
|
455
|
+
const key = noticeKey(o);
|
|
456
|
+
if (!Number.isFinite(o.now)) {
|
|
457
|
+
// FAIL OPEN, and the OPPOSITE way to claimRoomInterim. A missing clock must
|
|
458
|
+
// not be able to swallow "your work died": the interim is a courtesy that
|
|
459
|
+
// is safe to drop, and this is not.
|
|
460
|
+
return { allowed: true, reason: "no-clock-fail-open", room, key, lastAt: null, degraded: true };
|
|
461
|
+
}
|
|
462
|
+
const windowMs = Number.isFinite(o.windowMs) ? o.windowMs : roomNoticeWindowMs();
|
|
463
|
+
const ledger = readLedger(o);
|
|
464
|
+
const degraded = ledger.degraded === true;
|
|
465
|
+
const verdict = noticeAllowed({
|
|
466
|
+
ledger, service: o.service, channel: o.channel, notice: o.notice, text: o.text, now: o.now, windowMs,
|
|
467
|
+
});
|
|
468
|
+
if (!verdict.allowed) return degraded ? { ...verdict, degraded } : verdict;
|
|
469
|
+
if (o.commit === false) return degraded ? { ...verdict, degraded, committed: false } : { ...verdict, committed: false };
|
|
470
|
+
|
|
471
|
+
const next = recordNotice({
|
|
472
|
+
ledger, service: o.service, channel: o.channel, notice: o.notice, text: o.text, now: o.now, noticeWindowMs: windowMs,
|
|
473
|
+
});
|
|
474
|
+
const ok = persist(o, next, room);
|
|
475
|
+
if (!ok) return { ...verdict, degraded: true, committed: true };
|
|
476
|
+
return degraded ? { ...verdict, degraded, committed: true } : { ...verdict, committed: true };
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
export default {
|
|
480
|
+
DEFAULT_ROOM_INTERIM_WINDOW_MS,
|
|
481
|
+
DEFAULT_ROOM_NOTICE_WINDOW_MS,
|
|
482
|
+
roomInterimWindowMs,
|
|
483
|
+
roomNoticeWindowMs,
|
|
484
|
+
roomKey,
|
|
485
|
+
noticeKey,
|
|
486
|
+
textDigest,
|
|
487
|
+
interimAllowed,
|
|
488
|
+
noticeAllowed,
|
|
489
|
+
recordInterim,
|
|
490
|
+
recordNotice,
|
|
491
|
+
pruneLedger,
|
|
492
|
+
ledgerPath,
|
|
493
|
+
LEDGER_BASENAME,
|
|
494
|
+
readLedger,
|
|
495
|
+
claimRoomInterim,
|
|
496
|
+
claimRoomNotice,
|
|
497
|
+
};
|