@cohortapp/agent-sdk 2.18.13 → 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.
Files changed (46) hide show
  1. package/bin/maestro.mjs +38 -1
  2. package/docs/runbooks/fleet-rollout.md +58 -7
  3. package/docs/runbooks/recovery-and-failover.md +18 -0
  4. package/lib/assurance/batch.mjs +353 -0
  5. package/lib/assurance/first-reply.mjs +423 -0
  6. package/lib/assurance/notice-voice.mjs +357 -0
  7. package/lib/assurance/plan-note.mjs +43 -0
  8. package/lib/assurance/room-budget.mjs +55 -6
  9. package/lib/cadence-failure-class.mjs +245 -0
  10. package/lib/claude-bin.mjs +26 -7
  11. package/lib/cli/doctor-checks.mjs +149 -1
  12. package/lib/comms/send-gate.mjs +59 -0
  13. package/lib/diagnostics/alerts.mjs +33 -0
  14. package/lib/engine/agents/usage.mjs +45 -0
  15. package/lib/engine/budget.mjs +293 -29
  16. package/lib/engine/cli.mjs +54 -5
  17. package/lib/engine/loop.mjs +30 -0
  18. package/lib/engine/output/json.mjs +26 -0
  19. package/lib/engine/wire/errors.mjs +179 -0
  20. package/lib/engine/wire/search.mjs +44 -8
  21. package/lib/identity/persona.mjs +31 -2
  22. package/lib/org/quota.mjs +27 -0
  23. package/lib/session/config.mjs +4 -0
  24. package/lib/session/identity.mjs +71 -7
  25. package/lib/session/launch-failure.mjs +251 -0
  26. package/lib/session/resume-target.mjs +86 -0
  27. package/lib/telemetry/alerts.mjs +94 -0
  28. package/lib/telemetry/collect.mjs +155 -2
  29. package/lib/upgrade/pinned-drift.mjs +467 -0
  30. package/package.json +1 -1
  31. package/scaffold/config/alerts.yaml +7 -0
  32. package/scripts/ci/check-cadence-prompts-exist.mjs +96 -0
  33. package/scripts/ci/check.mjs +3 -0
  34. package/scripts/daemon/agent-daemon.mjs +75 -5
  35. package/scripts/daemon/assurance.mjs +709 -44
  36. package/scripts/daemon/cadence-consumer.mjs +281 -34
  37. package/scripts/daemon/deliver.mjs +109 -0
  38. package/scripts/daemon/dispatcher.mjs +21 -3
  39. package/scripts/daemon/inbox-deferral.mjs +102 -9
  40. package/scripts/daemon/session-lock.mjs +41 -1
  41. package/scripts/emergency-stop.sh +114 -13
  42. package/scripts/fleet/rollout.mjs +256 -10
  43. package/scripts/healthcheck.sh +131 -33
  44. package/scripts/local-triggers/autoupdate.sh +144 -11
  45. package/scripts/resume-operations.sh +101 -6
  46. package/scripts/session/supervisor.mjs +198 -5
@@ -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
@@ -99,6 +105,28 @@
99
105
  * daemon?: { pid?: number, bootAt?: ISO8601, uptimeS?: number,
100
106
  * sdkVersion?: string, dashboardAt?: ISO8601,
101
107
  * healthy?: boolean, healthReason?: string },
108
+ * // WHICH UPSTREAM FIXES CANNOT REACH THIS SEAT. A `.maestroignore`
109
+ * // pin on a FRAMEWORK path is a fork, and upstream moves under it:
110
+ * // five of sixteen seats were degraded by exactly this on 2026-09-25,
111
+ * // two of them crash-looping at import time on a pinned deliver.mjs.
112
+ * // `stranded` counts framework pins where upstream holds lines this
113
+ * // seat refuses (`onlyUpstream > 0` in .maestro/ignored-drift.json) —
114
+ * // that, and not "a file differs", is the property that means no
115
+ * // release can ever reach this colleague. ABSENT ENTIRELY on a seat
116
+ * // that pins nothing; `{unknown:true}` when the seat has pins and
117
+ * // could not read its own report, which must never read as clean.
118
+ * // INVENTORY: carries the `at`/`sdkVersion` of the upgrade that took
119
+ * // it, not the snapshot's.
120
+ * // `patterns` counts lines in `.maestroignore`; `matched` counts the
121
+ * // entries the drift report covers. Two numbers, two names, on both
122
+ * // branches — one glob can match forty files, or none.
123
+ * pinnedDrift?: { patterns: number|null, matched: number, framework: number,
124
+ * seatOwned: number, drifting: number, stranded: number,
125
+ * strandedCode: number, localOnly: number,
126
+ * at: ISO8601|null, sdkVersion: string|null,
127
+ * worst: [{ path: string, behind: number, code: boolean }] }
128
+ * | { unknown: true, patterns: number|null, matched: null,
129
+ * reason: string },
102
130
  * },
103
131
  * claudeAuth: "ok"|"relogin_required"|"unknown",
104
132
  * alerts: [{ id, severity, kind, detail }],
@@ -142,6 +170,8 @@ import { liveClaudeStats } from "../resource-governor.mjs";
142
170
  import { agentFirstName } from "../session/identity.mjs";
143
171
  import { snapshot as countersSnapshot } from "../diagnostics/counters.mjs";
144
172
  import { replyDebtFromCounters } from "../daemon/reply-debt.mjs";
173
+ import { summarisePinnedDrift, countPins } from "../upgrade/pinned-drift.mjs";
174
+ import { IGNORED_DRIFT_REL } from "../upgrade/ignored-drift.mjs";
145
175
 
146
176
  /** Default temperature probe ceiling — used only as a guard in alerts; here we just report. */
147
177
  const VALID_STATES = new Set(["active", "idle", "busy", "error", "offline"]);
@@ -1229,6 +1259,13 @@ const ATTENTION_WHY = {
1229
1259
  "no-heartbeat": "up, never beaten",
1230
1260
  "stale-heartbeat": "no beat since this launch",
1231
1261
  "beat-stopped": "beat, then stopped",
1262
+ // The session is not wedged — it never STARTS. Written by the supervisor
1263
+ // once N consecutive launches have failed identically
1264
+ // (scripts/session/supervisor.mjs, lib/session/launch-failure.mjs). It is a
1265
+ // different fault from the three above, with a different fix, and it is the
1266
+ // one that looked like health for days on a seat relaunching a dead resume
1267
+ // id every ten minutes.
1268
+ "launch-failing": "cannot launch",
1232
1269
  };
1233
1270
 
1234
1271
  /**
@@ -1364,7 +1401,9 @@ export function sessionNote(a = {}) {
1364
1401
  * fields the fleet view reads; anything malformed → null, and the field drops
1365
1402
  * out rather than lying.
1366
1403
  * @param {object|null} last
1367
- * @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}
1368
1407
  */
1369
1408
  export function upgradeSummary(last) {
1370
1409
  if (!last || typeof last !== "object") return null;
@@ -1382,6 +1421,19 @@ export function upgradeSummary(last) {
1382
1421
  // Bounded on the way out: a reason is a short token plus at most a trimmed
1383
1422
  // error line, and the beat is not a log shipper.
1384
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) : "";
1385
1437
  return {
1386
1438
  at,
1387
1439
  from: typeof last.from === "string" ? last.from : "",
@@ -1389,6 +1441,9 @@ export function upgradeSummary(last) {
1389
1441
  ok: last.ok === true,
1390
1442
  ...(reason ? { reason } : {}),
1391
1443
  ...(typeof last.healthy === "boolean" ? { healthy: last.healthy } : {}),
1444
+ ...(failStreak ? { failStreak } : {}),
1445
+ ...(failStreak && stuckSince ? { stuckSince } : {}),
1446
+ ...(failStreak && streakTarget ? { streakTarget } : {}),
1392
1447
  };
1393
1448
  }
1394
1449
 
@@ -1509,6 +1564,86 @@ export function daemonSummary(dash, last) {
1509
1564
  return Object.keys(out).length > 0 ? out : null;
1510
1565
  }
1511
1566
 
1567
+ // ---------------------------------------------------------------------------
1568
+ // Pinned framework files — WHICH UPSTREAM FIXES CANNOT REACH THIS SEAT
1569
+ // (`machine.pinnedDrift`)
1570
+ // ---------------------------------------------------------------------------
1571
+
1572
+ /**
1573
+ * Pure: the beat's `machine.pinnedDrift`.
1574
+ *
1575
+ * WHY THIS FIELD EXISTS. A `.maestroignore` entry on a framework path is a
1576
+ * fork, and upstream keeps moving under it. MEASURED 2026-09-25, repairing
1577
+ * five of sixteen seats by hand: two were crash-looping at import time on a
1578
+ * pinned `lib/org/inbound/deliver.mjs`; two more pinned
1579
+ * `scripts/daemon/agent-daemon.mjs` and therefore could not receive the
1580
+ * front-door revive fix (2.18.11/12) OR the persona fix — one of them went on
1581
+ * emitting an AI self-introduction for days after it was fixed upstream,
1582
+ * because the fix could not physically arrive.
1583
+ *
1584
+ * Every one of those seats already knew. `maestro upgrade` has written the
1585
+ * per-file answer to `.maestro/ignored-drift.json` for weeks and NOTHING read
1586
+ * it — no warning, no beat field, no alert. This is the half that makes the
1587
+ * knowledge leave the machine it is about.
1588
+ *
1589
+ * THE PROPERTY WORTH CARRYING is not "a file differs". It is "upstream holds
1590
+ * lines this pin refuses", which is `onlyUpstream > 0` on a framework path —
1591
+ * see `lib/upgrade/pinned-drift.mjs` for why (on this seat the same day, two
1592
+ * of seven drifting pins refused nothing at all, which is what a pin is FOR).
1593
+ *
1594
+ * SMALL AND BOUNDED, like every other field on `machine`: a handful of counts
1595
+ * and at most three paths, each capped (the size floor is pinned by
1596
+ * `pinned-drift-beat.test.mjs`). It rides `machine` for the reason
1597
+ * `frontDoor` and `daemon` do — hq validates `machine` as an OPEN record
1598
+ * (`presence/beat.ts#statusSchema`) while `session` is `.strict()`, so a new
1599
+ * seat-level fact lands without a lock-step hq deploy.
1600
+ *
1601
+ * THREE ANSWERS, AND THE THIRD IS THE POINT:
1602
+ * · a seat that pins nothing returns `null` — the caller drops the key, and
1603
+ * hq grows no field and no alert for a seat with nothing wrong;
1604
+ * · a seat that pins something and cannot read its own report returns
1605
+ * `{unknown:true}` — never a clean bill. "Could not tell" and "nothing to
1606
+ * tell" are opposite instructions, and collapsing them is the exact
1607
+ * failure this whole field exists to end.
1608
+ *
1609
+ * INVENTORY, not a momentary reading: the report is written by the last
1610
+ * upgrade and stays true until the next one, so it carries its own `at` and
1611
+ * the `sdkVersion` it was taken against rather than borrowing the snapshot's.
1612
+ *
1613
+ * @param {object|null} report parsed `.maestro/ignored-drift.json`, or null
1614
+ * @param {number|null} pinCount pattern lines in `.maestroignore`, or null
1615
+ * when even that could not be read
1616
+ */
1617
+ export function pinnedDriftSummary(report, pinCount) {
1618
+ return summarisePinnedDrift(report, { pinCount });
1619
+ }
1620
+
1621
+ /** `.maestro/ignored-drift.json`, or null — fail-open. */
1622
+ function readIgnoredDriftReport(agentRoot) {
1623
+ if (!agentRoot) return null;
1624
+ return safeReadJson(join(resolve(agentRoot), IGNORED_DRIFT_REL));
1625
+ }
1626
+
1627
+ /**
1628
+ * How many real patterns `.maestroignore` holds — `0` when the file is absent
1629
+ * (a seat with no pins), `null` when it exists and could not be read.
1630
+ *
1631
+ * The distinction is load-bearing: `0` is what lets the field be dropped
1632
+ * entirely, and `null` is what forces `unknown` instead of a clean answer.
1633
+ * A missing file is a definite zero; an unreadable one is not.
1634
+ */
1635
+ function readPinCount(agentRoot) {
1636
+ if (!agentRoot) return null;
1637
+ const file = join(resolve(agentRoot), ".maestroignore");
1638
+ let text;
1639
+ try {
1640
+ text = readFileSync(file, "utf8");
1641
+ } catch (e) {
1642
+ return e && e.code === "ENOENT" ? 0 : null;
1643
+ }
1644
+ return countPins(text);
1645
+ }
1646
+
1512
1647
  /** state/autoupdate/last.json, or null. */
1513
1648
  function readAutoupdateLast(agentRoot) {
1514
1649
  if (!agentRoot) return null;
@@ -1974,6 +2109,21 @@ export async function collectStatus(o = {}) {
1974
2109
  if (d) machine.daemon = d;
1975
2110
  } catch { /* no daemon field */ }
1976
2111
 
2112
+ // 4e. WHICH UPSTREAM FIXES CANNOT REACH THIS SEAT — `machine.pinnedDrift`,
2113
+ // absent on a seat that pins nothing. See `pinnedDriftSummary` for the five
2114
+ // seats this was measured on. Fail-open like every probe here: a throw drops
2115
+ // the field, never the beat — but note that a *readable* seat with pins and
2116
+ // an unreadable report deliberately lands `{unknown:true}` rather than
2117
+ // nothing, because "I could not tell" must never render as "clean".
2118
+ try {
2119
+ const pinReport = opt.ignoredDrift !== undefined
2120
+ ? opt.ignoredDrift
2121
+ : readIgnoredDriftReport(opt.agentRoot);
2122
+ const pins = opt.pinCount !== undefined ? opt.pinCount : readPinCount(opt.agentRoot);
2123
+ const pd = pinnedDriftSummary(pinReport, pins);
2124
+ if (pd) machine.pinnedDrift = pd;
2125
+ } catch { /* no pinnedDrift field */ }
2126
+
1977
2127
  const status = {
1978
2128
  state,
1979
2129
  activity: act.activity || "idle",
@@ -2026,6 +2176,9 @@ export const _internals = {
2026
2176
  upgradeSummary,
2027
2177
  parseDaemonHealthYaml,
2028
2178
  daemonSummary,
2179
+ pinnedDriftSummary,
2180
+ readIgnoredDriftReport,
2181
+ readPinCount,
2029
2182
  detectClaudeAuth,
2030
2183
  resetAuthProbeCache,
2031
2184
  scanForRelogin,