@cohortapp/agent-sdk 2.18.14 → 2.18.16

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.
@@ -79,12 +79,56 @@ summary}` — whose `reason` separates the cases the exit code merges:
79
79
 
80
80
  | `reason` | meaning |
81
81
  | --- | --- |
82
- | `verified` | every seat reports the target version (`code` 0) |
82
+ | `verified` | every seat reports the target version **and** no seat is stuck (`code` 0) |
83
+ | `stuck` | at least one seat has attempted `@latest` repeatedly and failed — waiting will not fix it |
83
84
  | `behind` | at least one seat is **provably** on an older version |
84
85
  | `unverifiable` | no seat is known to be behind; nothing available can prove any seat is current |
85
86
  | `empty-fleet` | no beating seats were found — never treated as done |
86
87
  | `no-fleet-read` | the org read never succeeded; says nothing about the fleet |
87
88
 
89
+ ### `behind` is waited out; `stuck` is visited
90
+
91
+ Every seat is `behind` for twenty minutes after a publish. Three seats were
92
+ behind for three weeks (2.17.0 while the fleet ran 2.18.13) and **nothing was in
93
+ an error state**: the hourly job fired, npm answered, the install ran, the health
94
+ gate failed honestly, the rollback worked, `last.json` recorded it and the beat
95
+ carried it. Every one of those records was correct. Only the REPETITION says the
96
+ seat will never arrive unaided, and no single record can hold it — so the org
97
+ learned it the way it always had, from a person noticing an old number in a
98
+ table.
99
+
100
+ `stuck` is that repetition, made sayable. A seat earns it two ways:
101
+
102
+ - **streak basis** — the seat counted its own consecutive failures
103
+ (`machine.upgrade.failStreak`, SDK 2.18.15+) and reached the threshold.
104
+ - **attempt basis** — an older beat carries one outcome and no count. A failed
105
+ attempt with the seat *still reporting the version it failed from* proves the
106
+ hop did not take; older than two failed-target holds (48 h) it also proves the
107
+ retry it was owed came and went. Below that it reads `failing`, not `stuck`:
108
+ a seat that has not been ASKED again is not a stuck seat.
109
+
110
+ Two things the table will not tell you, so they are said here:
111
+
112
+ - **`stuck` is not a subset of `behind`.** A seat's verdict is about *this run's*
113
+ target; its streak is about whatever `@latest` was when it last tried. A seat
114
+ can be current on the version you are verifying and unable to move off it.
115
+ - **The seat-side alert cannot cover the seats this is for.** `upgrade_stuck`
116
+ (`lib/telemetry/alerts.mjs`) is derived *on* the seat, so a seat too stale to
117
+ install the SDK never emits it — by construction the alert is blind to exactly
118
+ the machines it was written for. This tool is their cover, and it is automatic
119
+ because publishing is: stage 3 runs on every publish, and a stuck seat makes
120
+ the release command exit 3 with `reason: "stuck"` and the machine's name.
121
+ **Between publishes nothing watches them** — if a release is weeks away, run
122
+ `--verify-only` deliberately.
123
+
124
+ **What to do with a stuck seat:** it needs a person at the machine. `maestro
125
+ doctor` there, read `state/autoupdate/last.json` for the `reason`
126
+ (`install-failed` is a network problem; `unhealthy-rolled-back` means the gate
127
+ rejected the new version and the rollback worked; `rollback-unhealthy` means the
128
+ gate rejects the OLD version too, so the seat is sick independently of the
129
+ upgrade). Deleting `state/autoupdate/last.json` clears the 24 h hold and the
130
+ streak, and the next hourly run retries immediately.
131
+
88
132
  `unverifiable` becomes `verified` the moment the one-line hq change below lands,
89
133
  with no change to this script. `propagationOutcome()` is pure and exported, so
90
134
  the mapping is pinned by test rather than by this table.
@@ -0,0 +1,353 @@
1
+ /**
2
+ * lib/assurance/batch.mjs — the unit of acknowledgement is the CONVERSATION,
3
+ * not the message.
4
+ *
5
+ * THE COMPLAINT THIS EXISTS TO FIX
6
+ *
7
+ * "If there's a flurry of 4 messages in one go by others, instead of having a
8
+ * generic reply per message, it would be responding to the batch of messages."
9
+ *
10
+ * The acknowledgement path already counts per ROOM (`room-budget.mjs`: one
11
+ * interim per (seat, service, channel) per fifteen minutes) and per DEBT
12
+ * (`interimSaid`, latched and inherited across retries). Neither of those is
13
+ * the thing being asked for here, and it is worth being exact about why,
14
+ * because "the room budget already caps it at one" is the wrong answer:
15
+ *
16
+ * The room budget caps HOW MANY. It says nothing about WHICH, or WHEN.
17
+ *
18
+ * Under the budget alone, four messages arriving together open four debts; the
19
+ * sweep reaches whichever one `readdir` yielded first, composes a holding line
20
+ * from THAT ONE MESSAGE, sends it, and spends the room's fifteen minutes. The
21
+ * other three are latched silent. So the room does get exactly one line — and
22
+ * that line answers a quarter of what was said, chosen arbitrarily, while the
23
+ * sender watches three of their four messages go unmentioned. One arbitrary
24
+ * reply is not the same thing as one reply to the batch.
25
+ *
26
+ * Worse, the budget cannot wait. The sweep fires on the first debt to cross
27
+ * ACK_AFTER_MS, which is the FIRST message of the flurry. A person mid-flurry
28
+ * gets acknowledged for message one and then sends messages two, three and
29
+ * four into a room that has already spent its budget.
30
+ *
31
+ * THE MODEL
32
+ *
33
+ * A conversation is (service, channel) — deliberately the same key as
34
+ * `roomKey`, so this gate and the budget can never disagree about what a room
35
+ * is. Within a conversation, the open debts that may still speak form a BATCH.
36
+ * A batch has three times on it:
37
+ *
38
+ * firstAt the earliest arrival. The human has been waiting since here, so
39
+ * ACK_AFTER_MS is measured from it — unchanged from today.
40
+ * lastAt the latest arrival. The batch is not finished until it has been
41
+ * QUIET for `quietMs` after this, which is the whole point: an ack
42
+ * that fires mid-flurry cannot possibly address the flurry.
43
+ * span lastAt - firstAt, capped by `maxSpanMs` so a slow-drip
44
+ * conversation can never defer its acknowledgement forever.
45
+ *
46
+ * READY = aged past ACK_AFTER_MS, and (quiet, or spanned out, or urgent).
47
+ *
48
+ * WHY TWENTY SECONDS OF QUIET
49
+ *
50
+ * The quiet window is not a display-grouping heuristic (chat clients group at
51
+ * 30-60 s for layout; that is a rendering question, not an obligation one). It
52
+ * is the answer to "how long after someone's last message may I still expect
53
+ * another in the same breath". Twenty seconds is chosen because it is BELOW
54
+ * the floor at which a human expects any response at all — nothing is said
55
+ * before ACK_AFTER_MS (90 s) regardless, so in the common case where a flurry
56
+ * lands inside five seconds the quiet window costs exactly nothing and the ack
57
+ * still fires at 90 s — and ABOVE the inter-message gap of a real flurry,
58
+ * where a follow-up thought arrives while the previous message is still on
59
+ * screen. Raising it buys later acks for no additional coverage; lowering it
60
+ * starts splitting one person's two-part thought into two batches.
61
+ *
62
+ * WHAT HAPPENS TO A GENUINELY URGENT MESSAGE MID-WINDOW — the question this
63
+ * design has to answer honestly, because a quiet window is a delay and a delay
64
+ * is exactly the wrong thing to add to an emergency. Three answers, in order:
65
+ *
66
+ * 1. It was never delayed by this module in the first place. The quiet
67
+ * window only ever moves an acknowledgement — a courtesy — and only
68
+ * within the span between 90 s and `maxSpanMs`. The ANSWER is dispatched
69
+ * by the daemon the moment the item is classified; nothing here touches
70
+ * the session, the work, or the reply.
71
+ * 2. `isUrgent` (critical priority, or the classifier's urgent flags) takes
72
+ * the record OUT of the batch into a singleton of its own, which waives
73
+ * the quiet wait entirely: it acks at ACK_AFTER_MS on its own terms and
74
+ * does not wait behind its noisier neighbours. It still passes the room
75
+ * budget, so it cannot double up with a batch ack.
76
+ * 3. `maxSpanMs` (10 min) is the backstop for the case urgency flags miss: a
77
+ * conversation that never falls quiet is acknowledged anyway at ten
78
+ * minutes, because at that point the flurry IS the conversation and
79
+ * waiting for it to end is waiting forever.
80
+ *
81
+ * PURE. Everything arrives on the argument — the records, the clock, the
82
+ * windows. Nothing is read from disk, the environment or the network, so the
83
+ * whole matrix below is pinned by a test rather than sampled from a daemon.
84
+ * The environment is resolved by FUNCTIONS called at the edge, never folded
85
+ * into a constant at import time (the lesson `room-budget.mjs` records: a
86
+ * constant captured at module load means an operator who exports the variable
87
+ * afterwards gets the stale default with no error).
88
+ *
89
+ * @module lib/assurance/batch
90
+ */
91
+
92
+ "use strict";
93
+
94
+ const num = (v, d) => { const n = parseInt(v, 10); return Number.isFinite(n) ? n : d; };
95
+
96
+ /**
97
+ * How long a conversation must be QUIET before its batch may be acknowledged.
98
+ * See the header for why twenty seconds and not five or sixty.
99
+ */
100
+ export const DEFAULT_BATCH_QUIET_MS = 20_000;
101
+
102
+ /**
103
+ * The hard cap on how long a batch may stay open waiting for quiet. Past this,
104
+ * the batch is acknowledged whether or not the flurry has stopped — the
105
+ * starvation guard, and the honest answer to a conversation that never stops.
106
+ *
107
+ * Ten minutes is sized against the measured session distribution (p50 14.7
108
+ * min): an acknowledgement that arrives later than this is competing with the
109
+ * answer, at which point silence is better.
110
+ */
111
+ export const DEFAULT_BATCH_MAX_SPAN_MS = 10 * 60_000;
112
+
113
+ /** The configured quiet window. A FUNCTION, read at the edge — see the header. */
114
+ export function batchQuietMs(env = process.env) {
115
+ return num(env && env.ASSURANCE_BATCH_QUIET_MS, DEFAULT_BATCH_QUIET_MS);
116
+ }
117
+
118
+ /** The configured maximum batch span. Same reasoning. */
119
+ export function batchMaxSpanMs(env = process.env) {
120
+ return num(env && env.ASSURANCE_BATCH_MAX_SPAN_MS, DEFAULT_BATCH_MAX_SPAN_MS);
121
+ }
122
+
123
+ /**
124
+ * PURE. The conversation's identity.
125
+ *
126
+ * (service, channel), normalised — BYTE-FOR-BYTE the same construction as
127
+ * `room-budget.roomKey`, and that is a requirement rather than a coincidence.
128
+ * If this module batched by thread while the budget counted by channel, a
129
+ * four-thread room would assemble four batches and the budget would silently
130
+ * refuse three of them: three batches latched silent having said nothing,
131
+ * which is the failure mode this whole file exists to end. One definition of a
132
+ * room, or the two gates disagree.
133
+ *
134
+ * @param {object} [o] {service, channel}
135
+ * @returns {string}
136
+ */
137
+ export function conversationKey(o) {
138
+ const a = o && typeof o === "object" ? o : {};
139
+ const norm = (v) => String(v == null ? "" : v).trim().toLowerCase() || "unknown";
140
+ return `${norm(a.service)}|${norm(a.channel)}`;
141
+ }
142
+
143
+ /**
144
+ * PURE. Does this debt refuse to wait for its neighbours?
145
+ *
146
+ * Deliberately narrow. "Urgent" here does not mean "important" — it means "a
147
+ * twenty-second wait for the room to fall quiet is itself a defect". Only two
148
+ * signals qualify: the classifier's own `critical` priority, and an explicit
149
+ * urgent flag from the poller's priority signals. Anything looser (a keyword
150
+ * scan over the body, a CEO sender) would take most of a busy room out of
151
+ * batching and hand the flood back.
152
+ *
153
+ * @param {object} rec an obligation record
154
+ * @returns {boolean}
155
+ */
156
+ export function isUrgent(rec) {
157
+ if (!rec || typeof rec !== "object") return false;
158
+ const p = String(rec.priority == null ? "" : rec.priority).trim().toLowerCase();
159
+ if (p === "critical") return true;
160
+ const sig = (rec.item && rec.item.priority_signals) || rec.priority_signals;
161
+ return !!(sig && typeof sig === "object" && sig.tagged_urgent === true);
162
+ }
163
+
164
+ /**
165
+ * PURE. When did this debt arrive?
166
+ *
167
+ * `openedAt`, and NOT `lastAttemptAt`: a retry restarts the work but it does
168
+ * not restart the conversation, and measuring a batch from a retry clock would
169
+ * let a single retrying debt drag its whole room's batch forward repeatedly.
170
+ * A record with no usable clock is treated as arriving `now`, which makes it
171
+ * the batch's newest member and therefore delays the ack rather than firing
172
+ * one early — the safe direction, because the cost is a later courtesy and the
173
+ * alternative cost is acknowledging a flurry that is still arriving.
174
+ */
175
+ export function arrivalAt(rec, now) {
176
+ const t = rec && rec.openedAt;
177
+ return Number.isFinite(t) ? t : (Number.isFinite(now) ? now : 0);
178
+ }
179
+
180
+ /**
181
+ * PURE. Split interim-eligible debts into the batches that will each earn at
182
+ * most ONE first response.
183
+ *
184
+ * Urgent records are extracted into singleton batches BEFORE grouping, so an
185
+ * urgent ask is never held behind a chatty neighbour and never drags a quiet
186
+ * conversation's batch forward.
187
+ *
188
+ * Returned batches carry their conversation key and their members sorted
189
+ * OLDEST FIRST, because that is the order the acknowledgement has to read them
190
+ * in and the order the carrier selection assumes.
191
+ *
192
+ * @param {object} o
193
+ * @param {object[]} o.records interim-eligible obligation records
194
+ * @param {number} o.now
195
+ * @returns {{conversation:string, urgent:boolean, records:object[]}[]}
196
+ */
197
+ export function groupIntoBatches(o = {}) {
198
+ const records = Array.isArray(o.records) ? o.records.filter((r) => r && typeof r === "object") : [];
199
+ const now = Number.isFinite(o.now) ? o.now : 0;
200
+ const batches = [];
201
+ const grouped = new Map();
202
+
203
+ for (const rec of records) {
204
+ const conv = conversationKey(rec);
205
+ if (isUrgent(rec)) {
206
+ batches.push({ conversation: conv, urgent: true, records: [rec] });
207
+ continue;
208
+ }
209
+ if (!grouped.has(conv)) grouped.set(conv, []);
210
+ grouped.get(conv).push(rec);
211
+ }
212
+
213
+ for (const [conversation, group] of grouped) {
214
+ // Oldest first. Ties broken on `key` so two records stamped in the same
215
+ // millisecond order identically in every process — a sweep that picked a
216
+ // different carrier per tick would be a different message each time.
217
+ group.sort((a, b) => {
218
+ const d = arrivalAt(a, now) - arrivalAt(b, now);
219
+ return d !== 0 ? d : String(a.key || "").localeCompare(String(b.key || ""));
220
+ });
221
+ batches.push({ conversation, urgent: false, records: group });
222
+ }
223
+
224
+ return batches;
225
+ }
226
+
227
+ /**
228
+ * PURE. Is this batch ready to be acknowledged, and if so, by whom?
229
+ *
230
+ * THE CARRIER IS THE NEWEST RECORD, not the oldest. The acknowledgement is
231
+ * about to be read directly underneath the most recent thing the person said,
232
+ * and a line that answers the oldest message of four reads as an agent that
233
+ * stopped listening three messages ago. The older members are `speaksFor`:
234
+ * they are latched silent by the caller and their content is handed to the
235
+ * composer, so the one line that does go out was written with all four
236
+ * messages in front of it.
237
+ *
238
+ * `ready:false` is NOT a refusal and the caller must not latch anything on it.
239
+ * It means "not yet" — the batch is still assembling, and the next sweep tick
240
+ * will ask again. That distinction is the first of the two boundary pins:
241
+ * a flurry's LAST message must be able to join the batch it belongs to, which
242
+ * it can only do if an unready batch leaves every member untouched.
243
+ *
244
+ * @param {object} o
245
+ * @param {{records:object[], urgent?:boolean}} o.batch
246
+ * @param {number} o.now
247
+ * @param {number} o.ackAfterMs nothing is said before this, measured from firstAt
248
+ * @param {number} [o.quietMs]
249
+ * @param {number} [o.maxSpanMs]
250
+ * @returns {{ready:boolean, reason:string, carrier:object|null, speaksFor:object[],
251
+ * firstAt:number, lastAt:number, size:number, waitMs:number}}
252
+ */
253
+ export function batchVerdict(o = {}) {
254
+ const batch = o.batch && typeof o.batch === "object" ? o.batch : {};
255
+ const records = Array.isArray(batch.records) ? batch.records.filter(Boolean) : [];
256
+ const now = Number.isFinite(o.now) ? o.now : 0;
257
+ const ackAfterMs = Number.isFinite(o.ackAfterMs) ? o.ackAfterMs : 0;
258
+ const quietMs = Number.isFinite(o.quietMs) ? o.quietMs : DEFAULT_BATCH_QUIET_MS;
259
+ const maxSpanMs = Number.isFinite(o.maxSpanMs) ? o.maxSpanMs : DEFAULT_BATCH_MAX_SPAN_MS;
260
+
261
+ const empty = { ready: false, reason: "empty-batch", carrier: null, speaksFor: [], firstAt: 0, lastAt: 0, size: 0, waitMs: 0 };
262
+ if (!records.length) return empty;
263
+
264
+ const stamped = records.map((r) => ({ rec: r, at: arrivalAt(r, now) }));
265
+ const firstAt = Math.min(...stamped.map((s) => s.at));
266
+ const lastAt = Math.max(...stamped.map((s) => s.at));
267
+ const size = records.length;
268
+
269
+ // Newest wins the carrier; `key` breaks a same-millisecond tie the same way
270
+ // in every process.
271
+ let carrierEntry = stamped[0];
272
+ for (const s of stamped) {
273
+ if (s.at > carrierEntry.at) { carrierEntry = s; continue; }
274
+ if (s.at === carrierEntry.at
275
+ && String(s.rec.key || "").localeCompare(String(carrierEntry.rec.key || "")) > 0) carrierEntry = s;
276
+ }
277
+ const carrier = carrierEntry.rec;
278
+ const speaksFor = records.filter((r) => r !== carrier);
279
+
280
+ const age = now - firstAt;
281
+ const quiet = now - lastAt;
282
+ const span = lastAt - firstAt;
283
+
284
+ // TIME first, and measured from firstAt, exactly as branch (e) does today.
285
+ if (age < ackAfterMs) {
286
+ return { ready: false, reason: "not-yet-aged", carrier, speaksFor, firstAt, lastAt, size, waitMs: ackAfterMs - age };
287
+ }
288
+
289
+ // URGENT waives the quiet wait — see the header's answer 2.
290
+ if (batch.urgent === true) {
291
+ return { ready: true, reason: "urgent", carrier, speaksFor, firstAt, lastAt, size, waitMs: 0 };
292
+ }
293
+
294
+ // SPANNED OUT — the starvation guard. Checked BEFORE quiet, because a
295
+ // conversation that has run past the cap is ready whether or not the current
296
+ // instant happens to be quiet, and ordering it the other way would make the
297
+ // reported reason depend on the arrival jitter rather than the rule.
298
+ if (span >= maxSpanMs) {
299
+ return { ready: true, reason: "max-span", carrier, speaksFor, firstAt, lastAt, size, waitMs: 0 };
300
+ }
301
+
302
+ // QUIET — the flurry has stopped.
303
+ if (quiet < quietMs) {
304
+ return { ready: false, reason: "flurry-open", carrier, speaksFor, firstAt, lastAt, size, waitMs: quietMs - quiet };
305
+ }
306
+
307
+ return { ready: true, reason: size > 1 ? "batch-settled" : "settled", carrier, speaksFor, firstAt, lastAt, size, waitMs: 0 };
308
+ }
309
+
310
+ /**
311
+ * PURE. Render a batch's messages as the context the acknowledgement is
312
+ * composed from — OLDEST FIRST, so the line reads as a reply to a
313
+ * conversation rather than to a list.
314
+ *
315
+ * WHAT THIS DELIBERATELY DOES NOT DO: it does not count. "I have four
316
+ * messages" is a report about the inbox, not a reply to a person, and it is
317
+ * the exact register the owner rejected. The composer is handed the messages
318
+ * themselves and nothing else; how the line reads is
319
+ * `assurance.buildAckUserPrompt`'s to decide.
320
+ *
321
+ * @param {object[]} records
322
+ * @param {object} [o] {perMessageChars, maxMessages}
323
+ * @returns {{sender:string|null, text:string}[]}
324
+ */
325
+ export function batchMessages(records, o = {}) {
326
+ const perMessageChars = Number.isFinite(o.perMessageChars) ? o.perMessageChars : 400;
327
+ // Six is the most a short acknowledgement can meaningfully address; past
328
+ // that the oldest are already stale and the line would be a summary.
329
+ const maxMessages = Number.isFinite(o.maxMessages) ? o.maxMessages : 6;
330
+ const list = Array.isArray(records) ? records.filter(Boolean) : [];
331
+ const tail = list.slice(-maxMessages);
332
+ const out = [];
333
+ for (const rec of tail) {
334
+ const item = (rec && rec.item) || {};
335
+ const text = String(item.content || item.subject || rec.summary || "").trim();
336
+ if (!text) continue;
337
+ out.push({ sender: item.sender || rec.sender || null, text: text.slice(0, perMessageChars) });
338
+ }
339
+ return out;
340
+ }
341
+
342
+ export default {
343
+ DEFAULT_BATCH_QUIET_MS,
344
+ DEFAULT_BATCH_MAX_SPAN_MS,
345
+ batchQuietMs,
346
+ batchMaxSpanMs,
347
+ conversationKey,
348
+ isUrgent,
349
+ arrivalAt,
350
+ groupIntoBatches,
351
+ batchVerdict,
352
+ batchMessages,
353
+ };