@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.
@@ -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
@@ -166,6 +172,7 @@ import { snapshot as countersSnapshot } from "../diagnostics/counters.mjs";
166
172
  import { replyDebtFromCounters } from "../daemon/reply-debt.mjs";
167
173
  import { summarisePinnedDrift, countPins } from "../upgrade/pinned-drift.mjs";
168
174
  import { IGNORED_DRIFT_REL } from "../upgrade/ignored-drift.mjs";
175
+ import { listHandoffs, DEFAULT_HANDOFF_DEADLINE_MS } from "../session/handoffs.mjs";
169
176
 
170
177
  /** Default temperature probe ceiling — used only as a guard in alerts; here we just report. */
171
178
  const VALID_STATES = new Set(["active", "idle", "busy", "error", "offline"]);
@@ -1129,6 +1136,44 @@ async function collectIdentity(o, osImpl) {
1129
1136
  /** A main-session heartbeat older than this is not live. */
1130
1137
  export const SESSION_STALE_MS = 90_000;
1131
1138
 
1139
+ /**
1140
+ * A FAILED REVIVE, written by the on-host actors (the daemon's front-door
1141
+ * revive and `autoupdate.sh#revive_daemon`) when a kickstart returned but no
1142
+ * live pid could be confirmed — a failure to escalate, which has to reach the
1143
+ * fleet, not just the seat's log. Relative to the agent root; fresh window
1144
+ * bounds how long it rides the beat after the last failed attempt.
1145
+ */
1146
+ export const REVIVE_NOTE_REL = "state/telemetry/revive-note.json";
1147
+ export const REVIVE_NOTE_FRESH_MS = 90 * 60 * 1000;
1148
+
1149
+ /**
1150
+ * Pure: a revive-note that NAMES budget exhaustion, or null.
1151
+ *
1152
+ * A separate legibility defect from a failed revive: when the revive ladder
1153
+ * spends its whole restart budget and STOPS (`shouldReviveFrontDoor` →
1154
+ * `budget-spent`), nothing is written — the seat goes quiet and a quiet system
1155
+ * looks healthy. A ladder that gave up is a failure that does not name itself.
1156
+ * This turns that silence into a beat note carried through the SAME channel as
1157
+ * `machine.reviveNote`, so the beat says "stopped because budget spent" rather
1158
+ * than nothing. Only the budget-spent verdict qualifies — any other verdict
1159
+ * (still confirming, backing off, answering) is not the ladder giving up.
1160
+ *
1161
+ * @param {{verdict?:string, target?:string, attempts?:number, at?:string}} a
1162
+ * @returns {{reason:string, target?:string, detail:string, at:string}|null}
1163
+ */
1164
+ export function budgetSpentNote(a = {}) {
1165
+ const x = a && typeof a === "object" ? a : {};
1166
+ if (x.verdict !== "budget-spent") return null;
1167
+ const attempts = Number.isFinite(x.attempts) ? x.attempts : 0;
1168
+ const at = typeof x.at === "string" && x.at ? x.at : new Date().toISOString();
1169
+ return {
1170
+ reason: "revive-budget-spent",
1171
+ ...(typeof x.target === "string" && x.target ? { target: x.target } : {}),
1172
+ detail: `revive budget spent after ${attempts} attempts`,
1173
+ at,
1174
+ };
1175
+ }
1176
+
1132
1177
  /**
1133
1178
  * Pure: derive `{frontDoor, sessionLive}` from the heartbeat the main session's
1134
1179
  * feed writes every 15 s (`state/session/heartbeat.json` `{pid, ppid, sessionId,
@@ -1389,13 +1434,145 @@ export function sessionNote(a = {}) {
1389
1434
  };
1390
1435
  }
1391
1436
 
1437
+ /**
1438
+ * Front-door-routed inbox lanes. `sweep`, `held` and `deferred` are the
1439
+ * consumer's OWN maintenance lanes — items it parked on purpose, not items
1440
+ * waiting on a person — and a `cohort-held-*` id is the sibling hold-queue
1441
+ * shape (see meeting-capture's `kind==HOLD`). Neither is evidence the front
1442
+ * door is failing to read; counting them would wedge a seat that is doing
1443
+ * exactly what it was told to do.
1444
+ */
1445
+ const WEDGE_EXCLUDED_LANES = new Set(["sweep", "held", "deferred"]);
1446
+ const WEDGE_HELD_ID_RE = /^cohort-held-/;
1447
+
1448
+ /** An inbox item this file's `sessionWedge` treats as front-door-routed. */
1449
+ function isFrontDoorInboxItem(item) {
1450
+ if (!item || typeof item !== "object") return false;
1451
+ const id = typeof item.id === "string" ? item.id : "";
1452
+ if (WEDGE_HELD_ID_RE.test(id)) return false;
1453
+ const lane = typeof item.lane === "string" ? item.lane : "";
1454
+ if (WEDGE_EXCLUDED_LANES.has(lane)) return false;
1455
+ return true;
1456
+ }
1457
+
1458
+ /**
1459
+ * Pure: the wedge verdict — "beating but not reading". A seat can have a
1460
+ * perfectly live heartbeat while the process behind it has stopped actually
1461
+ * answering inbound (wedged on a dialog with no watchdog for THIS failure
1462
+ * mode, a deadlocked event loop, anything short of the process dying). Two
1463
+ * independent signals, checked in order, because they catch different
1464
+ * failures and the first is the harder evidence:
1465
+ *
1466
+ * 1. PRIMARY — an un-acked handoff (`lib/session/handoffs.mjs`) past its
1467
+ * deadline. The consumer handed a cadence tick to the front door and
1468
+ * nobody acked it in time; this is true regardless of which front door is
1469
+ * nominally live, because a handoff is only ever written TO whichever
1470
+ * door claimed the tick. Deadline math mirrors `expireHandoffs` exactly
1471
+ * (`deadlineAt`, else `enqueuedAt + deadlineMs`) so the two never
1472
+ * disagree about which handoff is overdue.
1473
+ * 2. SECONDARY — inbox-unconsumed. DEFERRED and HELD OFF in this build
1474
+ * (`SECONDARY_WEDGE_ENABLED === false`), because it cannot yet fire
1475
+ * correctly end-to-end (Hannah, PR #70 review #2). Three things must land
1476
+ * together first, NONE of them in this PR:
1477
+ * (i) a consume-stamp on EVERY front-door path — today only
1478
+ * `ackHandoff` stamps `lastConsumedAt`, so it advances only on
1479
+ * handoff acks (~every 30 min), never on the inbound
1480
+ * claim/reply/done path in session-runtime;
1481
+ * (ii) a real front-door inbox reader feeding `frontDoorInbox` into
1482
+ * `collectStatus` — nothing feeds it, so `inbox` is always `[]`;
1483
+ * (iii) lane/held keying matched to the real item shape — on this fleet
1484
+ * inbox items carry `channel`/`kind`/`claimed_by`, not `lane`, and
1485
+ * no id begins `cohort-held-`, so `isFrontDoorInboxItem`'s
1486
+ * exclusion is keyed on fields the items do not carry.
1487
+ * Shipped live at the 90s bound it would read most seats wedged, and
1488
+ * `rollout`'s `done` (which requires zero wedged) would never clear. The
1489
+ * branch, `isFrontDoorInboxItem` and `WEDGE_EXCLUDED_LANES` are kept
1490
+ * intact behind the flag so the follow-up wires them rather than rebuilds
1491
+ * them; the PRIMARY handoff-overdue tell is the real signal and is fully
1492
+ * live.
1493
+ *
1494
+ * Defensive by construction: every read is behind a type check, nothing here
1495
+ * touches a clock or the filesystem, and no input shape can make it throw.
1496
+ *
1497
+ * @param {object} [a]
1498
+ * @param {object[]} [a.handoffs] `listHandoffs()` records (or equivalent)
1499
+ * @param {object[]} [a.inbox] raw inbox items, each `{id?, lane?}`
1500
+ * @param {number|null} [a.lastConsumedAt] epoch ms, or null/undefined = never
1501
+ * @param {"session"|"daemon"} [a.frontDoor]
1502
+ * @param {boolean} [a.sessionLive]
1503
+ * @param {number} [a.now] epoch ms
1504
+ * @param {number} [a.deadlineMs] handoff deadline (default {@link DEFAULT_HANDOFF_DEADLINE_MS})
1505
+ * @param {number} [a.staleMs] inbox staleness bound (default {@link SESSION_STALE_MS})
1506
+ * @returns {{wedged:false}|{wedged:true, reason:"handoff-overdue"|"inbox-unconsumed", since?:string}}
1507
+ */
1508
+ // SECONDARY (inbox-unconsumed) is deferred — see the header above. A named
1509
+ // const, not a deleted branch: the follow-up flips this once the consume-stamp,
1510
+ // the frontDoorInbox feed and the real lane/held keying are all wired.
1511
+ export const SECONDARY_WEDGE_ENABLED = false;
1512
+
1513
+ export function sessionWedge(a = {}) {
1514
+ const o = a && typeof a === "object" ? a : {};
1515
+ const now = Number(o.now);
1516
+ if (!Number.isFinite(now)) return { wedged: false };
1517
+ const deadlineMs = Number.isFinite(o.deadlineMs) && o.deadlineMs > 0 ? o.deadlineMs : DEFAULT_HANDOFF_DEADLINE_MS;
1518
+ const staleMs = Number.isFinite(o.staleMs) ? o.staleMs : SESSION_STALE_MS;
1519
+
1520
+ // 1. PRIMARY — mirrors expireHandoffs' deadline math exactly.
1521
+ const handoffs = Array.isArray(o.handoffs) ? o.handoffs : [];
1522
+ for (const rec of handoffs) {
1523
+ if (!rec || typeof rec !== "object" || rec.status !== "open") continue;
1524
+ let deadline = Date.parse(rec.deadlineAt || "");
1525
+ if (!Number.isFinite(deadline)) {
1526
+ const enq = Date.parse(rec.enqueuedAt || "");
1527
+ deadline = Number.isFinite(enq) ? enq + deadlineMs : now; // undated → already due
1528
+ }
1529
+ if (now >= deadline) {
1530
+ return { wedged: true, reason: "handoff-overdue", since: new Date(deadline).toISOString() };
1531
+ }
1532
+ }
1533
+
1534
+ // 2. SECONDARY — DEFERRED (see header): held off until the consume-stamp,
1535
+ // the frontDoorInbox feed and the real lane/held keying are wired end-to-end.
1536
+ // Kept behind the flag so it is not shipped reading seats wedged spuriously.
1537
+ if (SECONDARY_WEDGE_ENABLED && o.frontDoor === "session" && o.sessionLive === true) {
1538
+ const inbox = Array.isArray(o.inbox) ? o.inbox : [];
1539
+ const routed = inbox.filter(isFrontDoorInboxItem);
1540
+ if (routed.length > 0) {
1541
+ const lastConsumedAt = Number.isFinite(o.lastConsumedAt) ? o.lastConsumedAt : null;
1542
+ const staleConsume = lastConsumedAt === null || now - lastConsumedAt > staleMs;
1543
+ if (staleConsume) {
1544
+ const since = lastConsumedAt !== null ? lastConsumedAt : oldestInboxAt(routed);
1545
+ return {
1546
+ wedged: true,
1547
+ reason: "inbox-unconsumed",
1548
+ ...(Number.isFinite(since) ? { since: new Date(since).toISOString() } : {}),
1549
+ };
1550
+ }
1551
+ }
1552
+ }
1553
+
1554
+ return { wedged: false };
1555
+ }
1556
+
1557
+ /** Oldest `enqueuedAt`/`ts` among inbox items, epoch ms, else NaN. */
1558
+ function oldestInboxAt(items) {
1559
+ let oldest = NaN;
1560
+ for (const it of items) {
1561
+ const ms = Date.parse((it && (it.enqueuedAt || it.ts)) || "");
1562
+ if (Number.isFinite(ms) && (Number.isNaN(oldest) || ms < oldest)) oldest = ms;
1563
+ }
1564
+ return oldest;
1565
+ }
1566
+
1392
1567
  /**
1393
1568
  * Pure: the beat's `machine.upgrade` from state/autoupdate/last.json
1394
1569
  * (`{from,to,at,ok,healthy,reason}` as autoupdate.sh writes it). Only the four
1395
1570
  * fields the fleet view reads; anything malformed → null, and the field drops
1396
1571
  * out rather than lying.
1397
1572
  * @param {object|null} last
1398
- * @returns {{at:string, from:string, to:string, ok:boolean}|null}
1573
+ * @returns {{at:string, from:string, to:string, ok:boolean, reason?:string,
1574
+ * healthy?:boolean, failStreak?:number, stuckSince?:string,
1575
+ * streakTarget?:string}|null}
1399
1576
  */
1400
1577
  export function upgradeSummary(last) {
1401
1578
  if (!last || typeof last !== "object") return null;
@@ -1413,6 +1590,19 @@ export function upgradeSummary(last) {
1413
1590
  // Bounded on the way out: a reason is a short token plus at most a trimmed
1414
1591
  // error line, and the beat is not a log shipper.
1415
1592
  const reason = typeof last.reason === "string" ? last.reason.trim().slice(0, 300) : "";
1593
+ // `failStreak` — how many CONSECUTIVE upgrade attempts have failed, with the
1594
+ // instant the run of failures began and the version it is failing against.
1595
+ // Written by autoupdate.sh's write_last; see the long block above it for what
1596
+ // does and does not count as an attempt. This is the ONLY field here that is
1597
+ // about a pattern rather than an event, and it is the reason three seats sat
1598
+ // on 2.17.0 for days without anything saying so: every individual record was
1599
+ // a truthful `ok:false` that told a reader nothing about the fortieth one.
1600
+ //
1601
+ // Absent when zero, so a healthy seat's beat carries nothing extra and a
1602
+ // reader can treat presence as the finding.
1603
+ const failStreak = Number.isInteger(last.failStreak) && last.failStreak > 0 ? last.failStreak : 0;
1604
+ const stuckSince = typeof last.stuckSince === "string" && !Number.isNaN(Date.parse(last.stuckSince)) ? last.stuckSince : "";
1605
+ const streakTarget = typeof last.streakTarget === "string" ? last.streakTarget.trim().slice(0, 40) : "";
1416
1606
  return {
1417
1607
  at,
1418
1608
  from: typeof last.from === "string" ? last.from : "",
@@ -1420,6 +1610,9 @@ export function upgradeSummary(last) {
1420
1610
  ok: last.ok === true,
1421
1611
  ...(reason ? { reason } : {}),
1422
1612
  ...(typeof last.healthy === "boolean" ? { healthy: last.healthy } : {}),
1613
+ ...(failStreak ? { failStreak } : {}),
1614
+ ...(failStreak && stuckSince ? { stuckSince } : {}),
1615
+ ...(failStreak && streakTarget ? { streakTarget } : {}),
1423
1616
  };
1424
1617
  }
1425
1618
 
@@ -2045,6 +2238,60 @@ export async function collectStatus(o = {}) {
2045
2238
  } catch { /* no sessionNote — the beat still carries frontDoor/sessionLive */ }
2046
2239
  }
2047
2240
 
2241
+ // 4b-iii. BEATING BUT NOT READING (DARK-SEAT) — `machine.wedge`, present
2242
+ // ONLY when wedged, exactly like `sessionNote` above. The decision is the
2243
+ // pure `sessionWedge`; this is the reads that feed it — the open handoff
2244
+ // ledger — and it is fail-open in the same way every other probe here is: a
2245
+ // throw anywhere (a corrupt handoff file) drops the field, never the beat.
2246
+ // `opt.handoffs` overrides disk, mirroring `opt.attention` above, so a
2247
+ // caller that already has the ledger in memory (the daemon, a test) is never
2248
+ // made to round-trip it through the filesystem.
2249
+ //
2250
+ // The `frontDoorInbox`/`lastConsumedAt` reads that fed the SECONDARY
2251
+ // (inbox-unconsumed) signal are gone from here: that signal is DEFERRED
2252
+ // (`SECONDARY_WEDGE_ENABLED === false` in sessionWedge), so feeding it would
2253
+ // be inert work. They return when the follow-up wires the secondary.
2254
+ try {
2255
+ const handoffs = opt.handoffs !== undefined ? opt.handoffs : listHandoffs(opt.agentRoot);
2256
+ const wedge = sessionWedge({
2257
+ handoffs,
2258
+ frontDoor: machine.frontDoor,
2259
+ sessionLive: machine.sessionLive,
2260
+ now: nowMs,
2261
+ deadlineMs: opt.handoffDeadlineMs,
2262
+ });
2263
+ if (wedge && wedge.wedged) machine.wedge = { reason: wedge.reason, ...(wedge.since ? { since: wedge.since } : {}) };
2264
+ } catch { /* no wedge field — the beat still carries frontDoor/sessionLive/sessionNote */ }
2265
+
2266
+ // 4b-iv. A FAILED REVIVE — `machine.reviveNote`, present only when a revive
2267
+ // kickstarted a job and then could NOT confirm a live pid (Jacob's
2268
+ // drain-then-restart-daemon.sh exited 0 with a dead daemon). The two on-host
2269
+ // actors — the daemon's front-door revive (agent-daemon.mjs) and the hourly
2270
+ // daemon backstop (autoupdate.sh#revive_daemon) — write it to
2271
+ // state/telemetry/revive-note.json; a no-pid-after-kickstart is a FAILURE TO
2272
+ // ESCALATE and has to reach the fleet, not just this seat's log — the seat
2273
+ // needs a person. Surfaced only while fresh (REVIVE_NOTE_FRESH_MS) so a
2274
+ // recovered seat ages out; the writers refresh it every attempt and clear it
2275
+ // on a confirmed revive. `machine` is an OPEN record (like wedge/upgrade), so
2276
+ // this lands without an hq schema change. Fail-open. `opt.reviveNote`
2277
+ // overrides disk for tests.
2278
+ try {
2279
+ const rn = opt.reviveNote !== undefined
2280
+ ? opt.reviveNote
2281
+ : (opt.agentRoot ? safeReadJson(join(resolve(opt.agentRoot), REVIVE_NOTE_REL)) : null);
2282
+ if (rn && typeof rn === "object" && typeof rn.reason === "string") {
2283
+ const at = Date.parse(rn.at || "");
2284
+ const freshMs = Number.isFinite(opt.reviveNoteFreshMs) ? opt.reviveNoteFreshMs : REVIVE_NOTE_FRESH_MS;
2285
+ if (Number.isFinite(at) && nowMs - at <= freshMs) {
2286
+ machine.reviveNote = {
2287
+ reason: rn.reason,
2288
+ at: new Date(at).toISOString(),
2289
+ ...(typeof rn.target === "string" && rn.target ? { target: rn.target } : {}),
2290
+ };
2291
+ }
2292
+ }
2293
+ } catch { /* no reviveNote — the beat still carries everything else */ }
2294
+
2048
2295
  // 4c. last upgrade outcome (WP-M6) — `machine.upgrade`, absent until the
2049
2296
  // first autoupdate attempt; a corrupt file drops the field, never the beat.
2050
2297
  try {
@@ -2145,6 +2392,7 @@ export const _internals = {
2145
2392
  readSdkVersion,
2146
2393
  sessionLiveness,
2147
2394
  sessionNote,
2395
+ sessionWedge,
2148
2396
  sanitizeNoteDetail,
2149
2397
  sessionJobLabel,
2150
2398
  sessionJobInstalled,
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.16",
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": {