@cohortapp/agent-sdk 2.18.14 → 2.18.15

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.
@@ -19,6 +19,10 @@
19
19
  * - subAgentsRunning over the cap → INFO (more children than expected).
20
20
  * - machine.sessionNote present → WARNING/CRITICAL (frontdoor) — the
21
21
  * seat's front door is not answering.
22
+ * - machine.upgrade.failStreak >= N → WARNING/CRITICAL (rollout) — N
23
+ * consecutive attempts to reach
24
+ * @latest have failed; the seat is
25
+ * stranded on old code.
22
26
  *
23
27
  * Output: a deterministic array `[{ id, severity, kind, detail }, ...]` (the
24
28
  * snapshot's alert shape) sorted critical→warning→info then by id, so identical
@@ -90,6 +94,30 @@ export const DEFAULT_THRESHOLDS = Object.freeze({
90
94
  warnMs: 15 * 60 * 1000,
91
95
  critMs: 2 * 60 * 60 * 1000,
92
96
  }),
97
+ /**
98
+ * How many CONSECUTIVE failed attempts to reach @latest make a seat STUCK.
99
+ *
100
+ * `warnStreak` 3, not 1: one failure is a bad release or a slow mirror and
101
+ * the hourly job is entitled to try again; the failed-target hold already
102
+ * spaces those out. Three separate, completed attempts have failed, which
103
+ * means every fix the seat can apply to itself has been applied three times
104
+ * and the seat is still where it was.
105
+ *
106
+ * `critStreak` 6: past this the seat has been refusing @latest for the best
107
+ * part of a week under the default 24 h hold — which is exactly how long
108
+ * three seats sat on 2.17.0 while every hourly log line was individually
109
+ * correct. A release that cannot reach a colleague is not a machine problem
110
+ * that resolves itself; it waits for a person, like a scope fault.
111
+ */
112
+ upgradeStuck: Object.freeze({
113
+ // THE ONE DEFINITION of the stuck threshold. scripts/fleet/rollout.mjs
114
+ // imports `warnStreak` as its STUCK_STREAK rather than restating it, and
115
+ // the shell copy (autoupdate.sh) is pinned against this value by a
116
+ // source-reading test — because three literals agreeing by comment is the
117
+ // prose-promise-instead-of-a-check pattern this repo bans.
118
+ warnStreak: 3,
119
+ critStreak: 6,
120
+ }),
93
121
  replyDebt: Object.freeze({
94
122
  // ONE withheld reply is already worth saying. This is not a resource gauge
95
123
  // where a low reading is normal noise — it counts people who asked this
@@ -175,6 +203,7 @@ export function deriveAlerts(status, thresholds, now = Date.now()) {
175
203
  push(alerts, frontDoorRule(machine, t.frontDoor, now));
176
204
  push(alerts, scopeFaultRule(machine));
177
205
  push(alerts, repliesWithheldRule(machine, t.replyDebt));
206
+ push(alerts, upgradeStuckRule(machine, t.upgradeStuck, now));
178
207
 
179
208
  alerts.sort((a, b) => {
180
209
  const r = (SEVERITY_RANK[a.severity] ?? 9) - (SEVERITY_RANK[b.severity] ?? 9);
@@ -282,6 +311,71 @@ function repliesWithheldRule(machine, cfg = {}) {
282
311
  };
283
312
  }
284
313
 
314
+ /**
315
+ * THIS SEAT CANNOT GET TO @latest, AND HAS PROVED IT N TIMES.
316
+ *
317
+ * Every other rule in this file reads a gauge. This one reads a COUNT of
318
+ * completed failures, because the failure it names has no gauge: on 2026-09-25
319
+ * three seats were found sitting on 2.17.0 while the fleet ran 2.18.13, and
320
+ * nothing anywhere was in an error state. The launchd job fired hourly, npm
321
+ * answered, the install ran, the health gate did its job, the rollback worked,
322
+ * `last.json` recorded the outcome honestly and the beat carried it. Each hour
323
+ * was a correct, self-contained failure — and the org has no way to see the
324
+ * difference between the first one and the fortieth, which is the only thing
325
+ * that distinguishes "a release is settling" from "a colleague is stranded on
326
+ * code from three weeks ago and will never leave it unaided".
327
+ *
328
+ * WHY THIS RIDES THE EXISTING BEAT AND NOTHING ELSE. The seats this rule is
329
+ * about are, by construction, running the OLDEST code in the fleet — so any
330
+ * new channel it might use is the one channel they do not have. `machine.upgrade`
331
+ * is a field their beat already carries; the count is added to it, and this
332
+ * rule turns it into an alert on the seats new enough to run this file.
333
+ *
334
+ * FOR THE ONES THAT ARE NOT, THIS RULE IS STRUCTURALLY BLIND AND SAYING SO IS
335
+ * PART OF THE DESIGN. A seat too stale to install the SDK runs a collector that
336
+ * never writes `failStreak` and an alerts file that has never heard of this
337
+ * rule — so the alert cannot reach exactly the seats it was written for. The
338
+ * fleet-side derivation covers them from outside: `seatStuck()` in
339
+ * scripts/fleet/rollout.mjs reads the same fact out of a 2.17-era beat, and
340
+ * `propagationOutcome()` there returns reason `"stuck"` so that stage 3 of
341
+ * every publish exits non-zero and names the machine. That path is automatic
342
+ * because publishing is; nobody has to decide to go looking.
343
+ *
344
+ * Both answer (d) the same way: a streak counts ATTEMPTS, and a seat that has
345
+ * not been asked has made none.
346
+ *
347
+ * DISTINCT FROM DRIFT. A `stranded` seat (framework paths pinned in
348
+ * `.maestroignore`, `machine.stranded`) cannot RECEIVE parts of a release it
349
+ * did install; a STUCK seat never installs the release at all. Different
350
+ * cause, different fix, different alert id — they can be true at once.
351
+ */
352
+ function upgradeStuckRule(machine, cfg = {}, now = Date.now()) {
353
+ const u = machine && machine.upgrade;
354
+ if (!u || typeof u !== "object") return null;
355
+ // Strict: the producer (autoupdate.sh via collect.upgradeSummary) writes an
356
+ // integer, and both validate it. A string here means something else wrote
357
+ // this record, and a rule that coerces would be reporting on a shape it does
358
+ // not understand.
359
+ const n = Number.isInteger(u.failStreak) ? u.failStreak : 0;
360
+ if (!(n > 0)) return null;
361
+ const sev = severityFor(n, num(cfg.warnStreak, Infinity), num(cfg.critStreak, Infinity));
362
+ if (!sev) return null;
363
+ const target = typeof u.streakTarget === "string" && u.streakTarget ? u.streakTarget : (typeof u.to === "string" ? u.to : "@latest");
364
+ // A DURATION, not only a tally: four attempts since Monday and four attempts
365
+ // in the last hour are different seats with different urgency, and the count
366
+ // alone cannot tell them apart.
367
+ const sinceMs = typeof u.stuckSince === "string" ? Date.parse(u.stuckSince) : NaN;
368
+ const days = Number.isFinite(sinceMs) ? Math.floor((now - sinceMs) / 86400000) : null;
369
+ const forHow = days === null ? "" : days >= 1 ? ` over ${days} ${days === 1 ? "day" : "days"}` : " today";
370
+ const why = typeof u.reason === "string" && u.reason ? ` Last failure: ${u.reason.split(":")[0]}.` : "";
371
+ return {
372
+ id: "upgrade_stuck",
373
+ severity: sev,
374
+ kind: "rollout",
375
+ detail: `${n} consecutive upgrade attempts to ${target} have failed${forHow} — this seat is running ${typeof u.from === "string" && u.from ? u.from : "older code"} and will not arrive on its own.${why} It needs a person at the machine.`,
376
+ };
377
+ }
378
+
285
379
  function subAgentsRule(status, cfg = {}) {
286
380
  const v = num(status.subAgentsRunning);
287
381
  const cap = num(cfg.infoCap, Infinity);
@@ -84,8 +84,14 @@
84
84
  * // Also on `machine` (open record), so hq's fleet view can show it next
85
85
  * // to sdkVersion without a schema change. `ok` is the attempt's verdict:
86
86
  * // true = healthy on `to`; false = install failed or rolled back to `from`.
87
+ * // `failStreak` is the one field here that describes a PATTERN: N
88
+ * // consecutive failed attempts to reach @latest, since `stuckSince`,
89
+ * // against `streakTarget`. Absent below 1. A seat that has simply not
90
+ * // been asked (nothing newer published, or held after its last failure)
91
+ * // never accrues one — see autoupdate.sh's write_last.
87
92
  * upgrade?: { at: ISO8601, from: string, to: string, ok: boolean,
88
- * reason?: string, healthy?: boolean }, // reason: WHY a failure failed
93
+ * reason?: string, healthy?: boolean, // reason: WHY a failure failed
94
+ * failStreak?: number, stuckSince?: ISO8601, streakTarget?: string },
89
95
  * // THE BEATING DAEMON — which process is emitting this beat, what code
90
96
  * // it is actually running, and the last health-gate verdict on it.
91
97
  * // Also on `machine` (open record). `sdkVersion` ABOVE is what is
@@ -1395,7 +1401,9 @@ export function sessionNote(a = {}) {
1395
1401
  * fields the fleet view reads; anything malformed → null, and the field drops
1396
1402
  * out rather than lying.
1397
1403
  * @param {object|null} last
1398
- * @returns {{at:string, from:string, to:string, ok:boolean}|null}
1404
+ * @returns {{at:string, from:string, to:string, ok:boolean, reason?:string,
1405
+ * healthy?:boolean, failStreak?:number, stuckSince?:string,
1406
+ * streakTarget?:string}|null}
1399
1407
  */
1400
1408
  export function upgradeSummary(last) {
1401
1409
  if (!last || typeof last !== "object") return null;
@@ -1413,6 +1421,19 @@ export function upgradeSummary(last) {
1413
1421
  // Bounded on the way out: a reason is a short token plus at most a trimmed
1414
1422
  // error line, and the beat is not a log shipper.
1415
1423
  const reason = typeof last.reason === "string" ? last.reason.trim().slice(0, 300) : "";
1424
+ // `failStreak` — how many CONSECUTIVE upgrade attempts have failed, with the
1425
+ // instant the run of failures began and the version it is failing against.
1426
+ // Written by autoupdate.sh's write_last; see the long block above it for what
1427
+ // does and does not count as an attempt. This is the ONLY field here that is
1428
+ // about a pattern rather than an event, and it is the reason three seats sat
1429
+ // on 2.17.0 for days without anything saying so: every individual record was
1430
+ // a truthful `ok:false` that told a reader nothing about the fortieth one.
1431
+ //
1432
+ // Absent when zero, so a healthy seat's beat carries nothing extra and a
1433
+ // reader can treat presence as the finding.
1434
+ const failStreak = Number.isInteger(last.failStreak) && last.failStreak > 0 ? last.failStreak : 0;
1435
+ const stuckSince = typeof last.stuckSince === "string" && !Number.isNaN(Date.parse(last.stuckSince)) ? last.stuckSince : "";
1436
+ const streakTarget = typeof last.streakTarget === "string" ? last.streakTarget.trim().slice(0, 40) : "";
1416
1437
  return {
1417
1438
  at,
1418
1439
  from: typeof last.from === "string" ? last.from : "",
@@ -1420,6 +1441,9 @@ export function upgradeSummary(last) {
1420
1441
  ok: last.ok === true,
1421
1442
  ...(reason ? { reason } : {}),
1422
1443
  ...(typeof last.healthy === "boolean" ? { healthy: last.healthy } : {}),
1444
+ ...(failStreak ? { failStreak } : {}),
1445
+ ...(failStreak && stuckSince ? { stuckSince } : {}),
1446
+ ...(failStreak && streakTarget ? { streakTarget } : {}),
1423
1447
  };
1424
1448
  }
1425
1449
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cohortapp/agent-sdk",
3
- "version": "2.18.14",
3
+ "version": "2.18.15",
4
4
  "description": "Cohort Agent SDK — autonomous AI colleague runtime. Deploy senior AI colleagues on dedicated Mac minis, wired to the Cohort operating surface.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -67,6 +67,7 @@ import { classifyItem, isDirectedAtAgent } from "./classifier.mjs";
67
67
  // gets a deterministic classification (no LLM triage), no holding ack, and a
68
68
  // per-seat jitter so thirteen answers do not land in the same second.
69
69
  import { isRollCallItem, rollCallJitterMs } from "../../lib/org/inbound/broadcast.mjs";
70
+ import { stampFirstReplyPlan } from "../../lib/assurance/first-reply.mjs";
70
71
  import { isRoomSurface } from "../../lib/org/inbound/surfaces.mjs";
71
72
  import { isMembershipReason } from "../../lib/org/inbound/directedness.mjs";
72
73
  import { cohortSurfaceOf, cohortSurfaceLabel, cohortSurfaceIsDeclared } from "./deliver.mjs";
@@ -96,7 +97,7 @@ import {
96
97
  isPidAlive,
97
98
  } from "./assurance.mjs";
98
99
  import { recordPoll, recordClassification, recordSession, writeHealthDashboard } from "./health.mjs";
99
- import { acquireLock, releaseLock, updateLock, scanStaleLocks, acquireThreadLock, claimRequest, hasActiveClaim, sweepStaleItemClaims, sanitiseItemId } from "./session-lock.mjs";
100
+ import { acquireLock, releaseLock, updateLock, scanStaleLocks, acquireThreadLock, threadLockKey, claimRequest, hasActiveClaim, sweepStaleItemClaims, sanitiseItemId } from "./session-lock.mjs";
100
101
  import { markDeferred } from "./inbox-deferral.mjs";
101
102
  import { parseQueueItems, rankBacklog, resolveBacklogWeights } from "../../lib/backlog.mjs";
102
103
  import { readLatestGaps } from "../../lib/goals/gaps.mjs";
@@ -322,10 +323,23 @@ async function reviveFrontDoorIfShut(state, deps = {}) {
322
323
  return verdict;
323
324
  }
324
325
 
325
- async function pollService(svc) {
326
+ /**
327
+ * Drain one service's inbox: poll, dedup, stamp the flurry plan, process.
328
+ *
329
+ * @param {{name:string, fn:Function}} svc
330
+ * @param {object} [deps] TEST-ONLY seams; PRODUCTION PASSES NOTHING, so the
331
+ * defaults are the real collaborators and behaviour is byte-identical. The
332
+ * same idiom as `processItem`/`sweepBacklog` above. Exported (and seamed) so
333
+ * the ONE-FIRST-REPLY-PER-FLURRY stamping has a test that fails when the call
334
+ * is removed — see `notice-emission.test.mjs`.
335
+ * @param {object} [deps.frontDoorGate] override the front-door gate
336
+ * @param {Function} [deps.processItem] override processItem
337
+ */
338
+ export async function pollService(svc, deps = {}) {
339
+ const processItemImpl = deps.processItem || processItem;
326
340
  try {
327
341
  // Front door: a live main session owns this service's inbox — leave it.
328
- const fd = _frontDoorGate.check(svc.name);
342
+ const fd = (deps.frontDoorGate || _frontDoorGate).check(svc.name);
329
343
  if (fd.changed) console.log(`[daemon] front door for ${svc.name}: ${fd.dispatch ? "daemon dispatches" : "main session owns intake"} (${fd.reason})`);
330
344
  if (!fd.dispatch) return 0;
331
345
  const result = await svc.fn();
@@ -358,8 +372,33 @@ async function pollService(svc) {
358
372
  return lock.acquired;
359
373
  });
360
374
 
375
+ // ── ONE FIRST REPLY PER FLURRY ───────────────────────────────────────
376
+ // "If there's a flurry of 4 messages in one go by others, instead of having
377
+ // a generic reply per message, it would be responding to the batch of
378
+ // messages." (owner, 2026-09-25.) The batch is computed HERE, over the whole
379
+ // poll, because this is the only place that sees more than one item at a
380
+ // time — `processItem` is called per item and structurally cannot know it is
381
+ // the third of four. Every non-leader carries `covered_by_batch`, which
382
+ // `assurance.shouldAcknowledge` turns into `ack:false` and the daemon makes
383
+ // durable as `interimForbidden`, so no later sweep in any process speaks for
384
+ // it either. The leader is told how many it stands for, so the one line it
385
+ // writes can answer all of them.
386
+ //
387
+ // WORK IS UNAFFECTED. Every item still gets its own `processItem`, its own
388
+ // obligation and its own answer; what is batched is only the FIRST thing
389
+ // said. Silence about timing is not silence about outcome.
390
+ //
391
+ // The stamping itself is `assurance/first-reply.stampFirstReplyPlan`, not
392
+ // four lines here, because four lines here had NO TEST THAT COULD SEE THEM:
393
+ // the only call site was inside this module-internal function, so deleting
394
+ // them left the whole suite green — including a test named "wired, not just
395
+ // available". `first-reply.test.mjs` now exercises the stamp and
396
+ // `notice-emission.test.mjs` drives THIS function through `pollService`'s
397
+ // injected seams, so removing the call is red in both.
398
+ stampFirstReplyPlan(newItems);
399
+
361
400
  for (const item of newItems) {
362
- await processItem(item, svc.name);
401
+ await processItemImpl(item, svc.name);
363
402
  }
364
403
 
365
404
  if (result.errors.length > 0) {
@@ -1107,7 +1146,38 @@ export async function answerItem(item, service, itemId, trace_id, deps = {}, rou
1107
1146
  // This check runs BEFORE quick reply to prevent ALL duplicate responses.
1108
1147
  {
1109
1148
  const channel = item.channel_id || (item.raw_ref ? item.raw_ref.match(/slack:([^:]+):/)?.[1] : null) || item.channel;
1110
- const threadTs = item.thread_id || (isDm ? `dm-channel` : null);
1149
+ // THE UNTREADED CHANNEL POST USED TO TAKE NO LOCK AT ALL.
1150
+ //
1151
+ // `item.thread_id || (isDm ? "dm-channel" : null)` is null for exactly
1152
+ // one shape: a message posted to the main surface of a channel or group,
1153
+ // not in a thread, not a DM. That is the shape the owner screenshotted —
1154
+ // a flurry of messages into a room — and it was the one shape with no
1155
+ // dedup: each message cleared this block, spawned its own session, and
1156
+ // opened its own obligation, so the room got N sessions and N chances at
1157
+ // a holding line for one conversation.
1158
+ //
1159
+ // `channel-main` is the DM lock's counterpart for that surface: one
1160
+ // session at a time on a room's main feed, the rest deferred and
1161
+ // promoted at session close exactly like a DM burst.
1162
+ //
1163
+ // THIS IS ONLY SAFE BECAUSE OF THE BUNDLING FIX BESIDE IT. Deferral ends
1164
+ // in `promoteDeferred`, which collapses a group to its latest member and
1165
+ // marks the rest `.processed-bundled`. On a shared channel the deferred
1166
+ // set is several PEOPLE, not one person's flurry, so an unconditional
1167
+ // collapse here would silently bin a colleague's question — which is why
1168
+ // inbox-deferral.mjs now splits a group by sender unless the winner
1169
+ // actually carries the room's history. Widening this lock without that
1170
+ // change would trade a message storm for lost messages.
1171
+ //
1172
+ // The off-switch is real and deliberate: MAESTRO_CHANNEL_MAIN_LOCK=0
1173
+ // restores the previous behaviour on a seat without a code change, in
1174
+ // the same style as every other threshold in the assurance stack.
1175
+ const threadTs = threadLockKey({
1176
+ channel,
1177
+ threadId: item.thread_id,
1178
+ isDm,
1179
+ channelMainLock: String(process.env.MAESTRO_CHANNEL_MAIN_LOCK ?? "1") !== "0",
1180
+ });
1111
1181
  if (threadTs && channel) {
1112
1182
  const threadCheck = acquireThreadLock(channel, threadTs);
1113
1183
  if (!threadCheck.allowed) {