@cohortapp/agent-sdk 2.11.4 → 2.11.6

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.
@@ -663,7 +663,21 @@ export function dailyAllowance(deps) {
663
663
  * @param {object} [deps] { now?, ledgerDir?, agentRoot?, capUSD?, iterationCap?,
664
664
  * seatEnvelope?, mandateCachePath?, log? }
665
665
  */
666
+ /**
667
+ * Is this seat authenticated by a flat-fee Max SUBSCRIPTION (not the metered
668
+ * API)? A subscription seat has no dollar meter: the CLI's `total_cost_usd` is
669
+ * API-EQUIVALENT, not spend, and every session reports "unmeasured" by design —
670
+ * that is normal, not a telemetry outage. Banding such a seat on dollars
671
+ * suspends a seat that costs a flat fee, stops its autonomous work, and floods
672
+ * the log every tick. `deps.subscriptionAuth` overrides for tests.
673
+ */
674
+ export function isSubscriptionAuth(deps) {
675
+ if (deps && typeof deps.subscriptionAuth === "boolean") return deps.subscriptionAuth;
676
+ return process.env.MAESTRO_PREFER_SUBSCRIPTION_AUTH === "1" && !process.env.ANTHROPIC_API_KEY;
677
+ }
678
+
666
679
  export function dailyStatus(deps) {
680
+ const subscriptionAuth = isSubscriptionAuth(deps);
667
681
  const iterationCap = cfgIterationCap(deps);
668
682
  const today = sumToday(deps);
669
683
  const { spentUSD, sessions } = today;
@@ -708,15 +722,23 @@ export function dailyStatus(deps) {
708
722
  // The binding constraint is whichever is closest to (or furthest past) the cap.
709
723
  // A null day-reading contributes nothing; the exhausted-envelope case is
710
724
  // already ≥100 % on the month, so nothing is lost by skipping it.
711
- const pct = Math.max(spendPct ?? 0, monthPct, iterPct);
725
+ // A subscription-auth seat has no dollar meter (see isSubscriptionAuth): its
726
+ // dollar "spend" is API-equivalent fiction, so banding on it wrongly suspends a
727
+ // flat-fee seat and halts its autonomous work. Band it on SESSION VOLUME only
728
+ // (a real limit); concurrency (resource-governor) + the 429 rate-breaker are
729
+ // its other guards.
730
+ const pct = subscriptionAuth
731
+ ? iterPct
732
+ : Math.max(spendPct ?? 0, monthPct, iterPct);
712
733
  let band = bandForPct(pct);
713
734
 
714
735
  // ── BLIND ⇒ DEGRADE ────────────────────────────────────────────────────────
715
736
  // `blind` (sessions ran, none measured) was computed and reported and enforced
716
737
  // by nothing. It is now load-bearing: we do not invent a price, we lower the
717
738
  // rung. Only when the day is TRULY unpriceable — if a month mean was available
718
- // the spend was imputed from it and the ordinary band applies.
719
- if (today.blind && today.unpriceable && band < BLIND_MIN_BAND) {
739
+ // the spend was imputed from it and the ordinary band applies. Does NOT apply
740
+ // to subscription auth, where "unmeasured" is the normal, expected state.
741
+ if (!subscriptionAuth && today.blind && today.unpriceable && band < BLIND_MIN_BAND) {
720
742
  extraDegradations.push(
721
743
  `band raised ${band}% → ${BLIND_MIN_BAND}%: ${today.unmeasuredSessions} session(s) ran today and NONE could be ` +
722
744
  "measured or priced. An unmeasurable seat is degraded, not trusted."
@@ -725,7 +747,16 @@ export function dailyStatus(deps) {
725
747
  }
726
748
  const posture = postureForBand(band);
727
749
 
728
- const degradations = [...today.degradations, ...allowance.degradations, ...extraDegradations];
750
+ let degradations = [...today.degradations, ...allowance.degradations, ...extraDegradations];
751
+ // On a subscription seat the dollar-meter degradations (imputed / unmeasured /
752
+ // unfunded-envelope / telemetry) are irrelevant — there is no dollar meter —
753
+ // and were logged EVERY tick, burying real signal (a DM not answered) under a
754
+ // flood. Drop them; keep any non-dollar note (e.g. a malformed ledger line).
755
+ if (subscriptionAuth) {
756
+ degradations = degradations.filter(
757
+ (d) => !/imputed|unmeasured|unpriceable|UNFUNDED SEAT|envelope|per-session|telemetry|blind/i.test(String(d)),
758
+ );
759
+ }
729
760
  // Degradations ride on the returned status AND go to the log: the daemon is
730
761
  // usually the only thing awake when telemetry breaks, and a governor that
731
762
  // quietly under-counts is the failure mode this module now exists to prevent.
@@ -338,3 +338,28 @@ test("a ledger of only zero-token rows sums to $0 (proves the bug is the input,
338
338
  assert.equal(s.sessions, 2);
339
339
  } finally { await rm(dir); }
340
340
  });
341
+
342
+ test("dailyStatus: a subscription-auth seat is banded on session VOLUME, not dollars", async () => {
343
+ const dir = await makeLedgerDir();
344
+ try {
345
+ seedLedger(dir, [{ usd: 40 }]); // $40 of $10 = 400%
346
+ // A metered (API) seat: dollars bind → band 150 (refused).
347
+ assert.equal(dailyStatus({ ledgerDir: dir, now: clk, capUSD: 10, iterationCap: 100 }).band, 150);
348
+ // A flat-fee subscription seat has no dollar meter — the same API-equivalent
349
+ // "spend" is fiction. It lands at band 0 / normal so autonomous work keeps
350
+ // running; only session VOLUME can band it.
351
+ const sub = dailyStatus({ ledgerDir: dir, now: clk, capUSD: 10, iterationCap: 100, subscriptionAuth: true });
352
+ assert.equal(sub.band, 0);
353
+ assert.equal(sub.mode, "normal");
354
+ } finally { await rm(dir); }
355
+ });
356
+
357
+ test("dailyStatus: a subscription-auth seat STILL bands on session volume (a real limit)", async () => {
358
+ const dir = await makeLedgerDir();
359
+ try {
360
+ seedLedger(dir, Array.from({ length: 12 }, () => ({ usd: 0.01 }))); // 12 sessions
361
+ const sub = dailyStatus({ ledgerDir: dir, now: clk, capUSD: 1000, iterationCap: 10, subscriptionAuth: true });
362
+ assert.equal(sub.band, 100, "12 sessions / cap 10 = 120% → degrade even on subscription");
363
+ assert.equal(sub.mode, "degraded");
364
+ } finally { await rm(dir); }
365
+ });
@@ -79,19 +79,21 @@ export const FREEMEM_FLOOR_FRACTION = frac(process.env.GOV_FREEMEM_FLOOR, 0.15);
79
79
  /** Load-average multiplier over cpu count that triggers a DEFER. */
80
80
  export const LOAD_FACTOR = num(process.env.GOV_LOAD_FACTOR, 1.5);
81
81
  /**
82
- * Hard ceiling on dynamicMax regardless of RAM. The binding constraint on a
83
- * subscription-auth seat is NOT host RAM — it is the ONE Max subscription, which
84
- * cannot serve many parallel `claude --print` calls without throttling. Ten
85
- * concurrent backlog sessions starved the reactive quick-reply's claude call
86
- * into a 60s timeout (observed on A016/isla). Default is subscription-safe;
87
- * API/multi-key deployments raise it with GOV_HARD_MAX.
82
+ * Hard ceiling on dynamicMax regardless of RAM. Generous by design: the runtime
83
+ * guards below (free-memory floor, load, projected RSS) throttle DOWN under real
84
+ * pressure, and stale sessions no longer count (see liveClaudeStats + STALE_*),
85
+ * so the ceiling can use available capacity and back off when it isn't there.
86
+ * The earlier 60s quick-reply timeout "at 10 sessions" was really TWO permanent
87
+ * zombie `claude --resume` procs (counted forever) + a log flood, not 8 healthy
88
+ * sessions. With those fixed, more real lanes are safe. GOV_HARD_MAX overrides.
88
89
  */
89
- export const HARD_MAX = num(process.env.GOV_HARD_MAX, 4);
90
+ export const HARD_MAX = num(process.env.GOV_HARD_MAX, 7);
90
91
  /**
91
92
  * Slots the governor holds back from self-directed (backlog/cadence) work so a
92
93
  * human/agent reply always has subscription + host headroom to GENERATE, not
93
94
  * merely a dispatch slot. The dispatcher's RESERVED_INBOX_SLOTS reserves a
94
95
  * dispatch slot; this reserves the scarce resource that actually timed out.
96
+ * With HARD_MAX 7 this leaves ~5 backlog lanes + 2 reactive.
95
97
  */
96
98
  export const REACTIVE_RESERVE = num(process.env.GOV_REACTIVE_RESERVE, 2);
97
99
  /**
@@ -101,9 +103,36 @@ export const REACTIVE_RESERVE = num(process.env.GOV_REACTIVE_RESERVE, 2);
101
103
  * gets near-all capacity.
102
104
  */
103
105
  export const DEGRADED_BACKLOG_MAX = num(process.env.GOV_DEGRADED_BACKLOG_MAX, 1);
106
+ /**
107
+ * A `claude` session older than this is treated as STALE and does NOT count
108
+ * toward the concurrency ceiling — an idle/wedged lane must not hold a slot
109
+ * forever. No healthy cadence/reply session runs anywhere near this long. Two
110
+ * 8-day-old `--resume` zombies once pinned the whole box (owner: "idle lanes
111
+ * shouldn't count"). GOV_STALE_SESSION_MS overrides.
112
+ */
113
+ export const STALE_SESSION_MS = num(process.env.GOV_STALE_SESSION_MS, 30 * 60_000);
114
+ /**
115
+ * A `claude` session older than this is REAPED (killed) by reapStaleClaude —
116
+ * the self-cleaning half. Higher than STALE so a merely-slow session is only
117
+ * discounted, not killed, until it is unambiguously wedged. GOV_REAP_SESSION_MS
118
+ * overrides; set to 0 to disable reaping (discount-only).
119
+ */
120
+ export const REAP_SESSION_MS = num(process.env.GOV_REAP_SESSION_MS, 45 * 60_000);
104
121
  /** Minimum dynamicMax (always allow at least one session). */
105
122
  const MIN_MAX = 1;
106
123
 
124
+ /**
125
+ * Parse a `ps` etime string ("[[DD-]HH:]MM:SS") to milliseconds. Returns 0 on
126
+ * anything unparseable (fail-safe: an unparseable age is treated as young, so we
127
+ * never discount/kill a session we cannot age).
128
+ */
129
+ export function etimeToMs(etime) {
130
+ const m = String(etime || "").trim().match(/^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/);
131
+ if (!m) return 0;
132
+ const [, d, h, mm, ss] = m;
133
+ return ((((Number(d || 0) * 24) + Number(h || 0)) * 60 + Number(mm || 0)) * 60 + Number(ss || 0)) * 1000;
134
+ }
135
+
107
136
  function num(v, dflt) {
108
137
  const n = Number(v);
109
138
  return Number.isFinite(n) && n > 0 ? n : dflt;
@@ -176,20 +205,26 @@ export function liveClaudeStats(opts = {}) {
176
205
  try {
177
206
  // pid, rss(KB), comm — mirror the watchdog's filter set so the governor's
178
207
  // view of "live claude" matches what the watchdog would act on.
179
- const out = execFileSync("/bin/ps", ["-eo", "pid,rss,comm"], { encoding: "utf-8", timeout: 4000 });
208
+ const out = execFileSync("/bin/ps", ["-eo", "pid,etime,rss,comm"], { encoding: "utf-8", timeout: 4000 });
180
209
  let count = 0;
181
210
  let rssKb = 0;
211
+ let stale = 0;
182
212
  for (const line of out.split("\n")) {
183
213
  const l = line.trim();
184
214
  if (!l) continue;
185
215
  if (!/claude/i.test(l)) continue;
186
216
  if (/Claude\.app|Claude Helper|watchdog|grep|ps -eo/.test(l)) continue;
187
217
  const parts = l.split(/\s+/);
188
- const rss = Number(parts[1]);
218
+ // Columns: pid etime rss comm... A STALE session (idle/wedged, older than
219
+ // STALE_SESSION_MS) does NOT count toward the ceiling and its RSS is
220
+ // excluded — an abandoned lane must not hold a slot or RAM headroom forever.
221
+ const ageMs = etimeToMs(parts[1]);
222
+ if (STALE_SESSION_MS > 0 && ageMs >= STALE_SESSION_MS) { stale += 1; continue; }
223
+ const rss = Number(parts[2]);
189
224
  if (Number.isFinite(rss)) rssKb += rss;
190
225
  count += 1;
191
226
  }
192
- value = { count, rssMB: Math.round(rssKb / 1024) };
227
+ value = { count, rssMB: Math.round(rssKb / 1024), stale };
193
228
  } catch {
194
229
  value = { count: 0, rssMB: 0 };
195
230
  }
@@ -200,6 +235,43 @@ export function liveClaudeStats(opts = {}) {
200
235
  /** Reset the live-stats cache (tests). */
201
236
  export function _resetLiveCache() { _liveCache = { at: 0, value: null }; }
202
237
 
238
+ /**
239
+ * Self-cleaning: KILL `claude` sessions older than REAP_SESSION_MS. A session
240
+ * that has run this long is unambiguously wedged — no healthy cadence or reply
241
+ * session runs anywhere near this long — and a wedged session holds a dispatch
242
+ * slot, RAM, and (worst) a share of the single Max subscription. Two 8-day-old
243
+ * `--resume` zombies once deferred every cadence on a seat, silencing its inbox.
244
+ *
245
+ * Side-effecting BY DESIGN, so it is NOT called from the pure admit() path — the
246
+ * daemon calls it on a timer. Best-effort: a ps/kill failure never throws.
247
+ * `deps` ({ psImpl, killImpl, reapMs }) is injected by tests.
248
+ *
249
+ * @returns {{ reaped:number[], errors:number }}
250
+ */
251
+ export function reapStaleClaude(deps = {}) {
252
+ const reapMs = Number.isFinite(deps.reapMs) ? deps.reapMs : REAP_SESSION_MS;
253
+ if (!(reapMs > 0)) return { reaped: [], errors: 0 };
254
+ const reaped = [];
255
+ let errors = 0;
256
+ try {
257
+ const out = typeof deps.psImpl === "function"
258
+ ? deps.psImpl()
259
+ : execFileSync("/bin/ps", ["-eo", "pid,etime,comm"], { encoding: "utf-8", timeout: 4000 });
260
+ const kill = typeof deps.killImpl === "function" ? deps.killImpl : (pid) => process.kill(pid, "SIGKILL");
261
+ for (const line of String(out).split("\n")) {
262
+ const l = line.trim();
263
+ if (!l || !/claude/i.test(l)) continue;
264
+ if (/Claude\.app|Claude Helper|watchdog|grep|ps -eo/.test(l)) continue;
265
+ const parts = l.split(/\s+/);
266
+ const pid = Number(parts[0]);
267
+ if (!Number.isInteger(pid) || pid <= 1) continue; // never signal pid 0/1
268
+ if (etimeToMs(parts[1]) < reapMs) continue;
269
+ try { kill(pid); reaped.push(pid); } catch { errors += 1; }
270
+ }
271
+ } catch { /* best-effort — a ps failure must never crash the daemon */ }
272
+ return { reaped, errors };
273
+ }
274
+
203
275
  // ---------------------------------------------------------------------------
204
276
  // Default deps snapshot
205
277
  // ---------------------------------------------------------------------------
@@ -23,6 +23,8 @@ import {
23
23
  DECISIONS,
24
24
  availableMemoryBytes,
25
25
  FREEMEM_FLOOR_FRACTION,
26
+ etimeToMs,
27
+ reapStaleClaude,
26
28
  } from "./resource-governor.mjs";
27
29
 
28
30
  test("availableMemoryBytes counts reclaimable pages, not just free ones", () => {
@@ -68,15 +70,14 @@ function deps(over = {}) {
68
70
  // ---------------------------------------------------------------------------
69
71
 
70
72
  test("dynamicMax sizes by RAM, clamped to min(HARD_MAX, cpus)", () => {
71
- // HARD_MAX is 4 (subscription-safe): the single Max subscription, not host RAM,
72
- // is the binding constraint, so anything with a few GB clamps to 4.
73
+ // HARD_MAX is 7 (generous; runtime guards + stale-reaping keep it safe).
73
74
  // 4GB: byRam=floor(4*0.6/0.75)=3 → RAM-bound, below HARD_MAX → 3.
74
75
  assert.equal(dynamicMax({ totalmem: 4 * GB, cpus: 8 }), 3);
75
- // 8GB: byRam=floor(8*0.6/0.75)=6 → clamp to min(4,8)=4 → HARD_MAX-bound.
76
- assert.equal(dynamicMax({ totalmem: 8 * GB, cpus: 8 }), 4);
77
- // 16GB with 16 cpus: byRam=12 → clamp to min(4,16)=4.
78
- assert.equal(dynamicMax({ totalmem: 16 * GB, cpus: 16 }), 4);
79
- // cpu-bound below HARD_MAX: 16GB with 2 cpus → min(4,2)=2.
76
+ // 8GB: byRam=floor(8*0.6/0.75)=6 → RAM-bound, below HARD_MAX → 6.
77
+ assert.equal(dynamicMax({ totalmem: 8 * GB, cpus: 8 }), 6);
78
+ // 16GB with 16 cpus: byRam=12 → clamp to min(7,16)=7 (HARD_MAX-bound).
79
+ assert.equal(dynamicMax({ totalmem: 16 * GB, cpus: 16 }), 7);
80
+ // cpu-bound below HARD_MAX: 16GB with 2 cpus → min(7,2)=2.
80
81
  assert.equal(dynamicMax({ totalmem: 16 * GB, cpus: 2 }), 2);
81
82
  // Tiny box always yields at least 1.
82
83
  assert.equal(dynamicMax({ totalmem: 1 * GB, cpus: 1 }), 1);
@@ -93,8 +94,8 @@ test("dynamicMax never returns 0 even on a starved box", () => {
93
94
  test("admit: healthy box with spare capacity → ADMIT", () => {
94
95
  const r = admit({ source: "backlog" }, deps());
95
96
  assert.equal(r.decision, DECISIONS.ADMIT);
96
- assert.equal(r.snapshot.dynamicMax, 4);
97
- assert.equal(r.snapshot.effectiveMax, 4);
97
+ assert.equal(r.snapshot.dynamicMax, 7);
98
+ assert.equal(r.snapshot.effectiveMax, 7);
98
99
  });
99
100
 
100
101
  test("admit: inbox on a healthy box → ADMIT", () => {
@@ -107,22 +108,22 @@ test("admit: inbox on a healthy box → ADMIT", () => {
107
108
  // ---------------------------------------------------------------------------
108
109
 
109
110
  test("admit: backlog stops REACTIVE_RESERVE slots short of the ceiling → QUEUE", () => {
110
- // effectiveMax = 4; self-directed backlog may use effectiveMax - REACTIVE_RESERVE(2)
111
- // = 2. With 2 already live, backlog QUEUEs — holding 2 slots for the inbox so a
111
+ // effectiveMax = 7; self-directed backlog may use effectiveMax - REACTIVE_RESERVE(2)
112
+ // = 5. With 5 already live, backlog QUEUEs — holding 2 slots for the inbox so a
112
113
  // reply can always be GENERATED (subscription + host headroom, not just a slot).
113
- const r = admit({ source: "backlog" }, deps({ liveClaude: { count: 2, rssMB: 500 } }));
114
+ const r = admit({ source: "backlog" }, deps({ liveClaude: { count: 5, rssMB: 1200 } }));
114
115
  assert.equal(r.decision, DECISIONS.QUEUE);
115
116
  assert.match(r.reason, /yields 2 slots to the inbox/);
116
117
  });
117
118
 
118
119
  test("admit: inbox may use the whole envelope; the reactive reserve is for it", () => {
119
- // 2 live (backlog's reserved cap) but INBOX still ADMITs — the reserve is its.
120
+ // 5 live (backlog's reserved cap) but INBOX still ADMITs — the reserve is its.
120
121
  assert.equal(
121
- admit({ source: "inbox" }, deps({ liveClaude: { count: 2, rssMB: 500 } })).decision,
122
+ admit({ source: "inbox" }, deps({ liveClaude: { count: 5, rssMB: 1200 } })).decision,
122
123
  DECISIONS.ADMIT,
123
124
  );
124
- // Only at the full envelope (effectiveMax=4 live) does even the inbox QUEUE.
125
- const r = admit({ source: "inbox" }, deps({ liveClaude: { count: 4, rssMB: 1000 } }));
125
+ // Only at the full envelope (effectiveMax=7 live) does even the inbox QUEUE.
126
+ const r = admit({ source: "inbox" }, deps({ liveClaude: { count: 7, rssMB: 1800 } }));
126
127
  assert.equal(r.decision, DECISIONS.QUEUE);
127
128
  assert.match(r.reason, /concurrency ceiling/);
128
129
  });
@@ -347,3 +348,46 @@ test("an unrecognised budget mode is treated as normal, never as something stric
347
348
  assert.equal(admit({ source: "backlog", mode: "catastrophic" }, deps()).decision, "ADMIT");
348
349
  assert.equal(admit({ source: "backlog", mode: "normal" }, deps()).decision, "ADMIT");
349
350
  });
351
+
352
+ // ---------------------------------------------------------------------------
353
+ // Stale-session accounting + reaping (idle lanes must not count / self-clean)
354
+ // ---------------------------------------------------------------------------
355
+
356
+ test("etimeToMs parses ps elapsed-time in all three shapes", () => {
357
+ assert.equal(etimeToMs("05:20"), (5 * 60 + 20) * 1000); // MM:SS
358
+ assert.equal(etimeToMs("07:05:20"), (7 * 3600 + 5 * 60 + 20) * 1000); // HH:MM:SS
359
+ assert.equal(etimeToMs("08-07:05:20"), (8 * 86400 + 7 * 3600 + 5 * 60 + 20) * 1000); // DD-HH:MM:SS
360
+ assert.equal(etimeToMs("garbage"), 0); // fail-safe: unparseable → young
361
+ assert.equal(etimeToMs(""), 0);
362
+ });
363
+
364
+ test("reapStaleClaude kills only claude sessions older than the reap threshold", () => {
365
+ const ps = [
366
+ " 100 08-07:05:20 claude --dangerously-skip-permissions --resume old", // 8 days → reap
367
+ " 200 00:30 claude --print young", // 30s → keep
368
+ " 300 50:00 claude --resume wedged", // 50 min → reap
369
+ " 400 90:00 node scripts/daemon/maestro-daemon.mjs", // not claude → skip
370
+ " 500 46:00 Claude Helper (Renderer)", // helper → skip
371
+ " 1 99-00:00:00 launchd", // pid 1 / not claude → skip
372
+ ].join("\n");
373
+ const killed = [];
374
+ const r = reapStaleClaude({ psImpl: () => ps, killImpl: (p) => killed.push(p), reapMs: 45 * 60_000 });
375
+ assert.deepEqual(r.reaped.sort((a, b) => a - b), [100, 300]);
376
+ assert.deepEqual(killed.sort((a, b) => a - b), [100, 300]);
377
+ assert.equal(r.errors, 0);
378
+ });
379
+
380
+ test("reapStaleClaude is a no-op when reaping is disabled (reapMs=0)", () => {
381
+ const ps = " 100 08-07:05:20 claude --resume old\n";
382
+ const killed = [];
383
+ const r = reapStaleClaude({ psImpl: () => ps, killImpl: (p) => killed.push(p), reapMs: 0 });
384
+ assert.deepEqual(r.reaped, []);
385
+ assert.deepEqual(killed, []);
386
+ });
387
+
388
+ test("reapStaleClaude counts kill failures without throwing", () => {
389
+ const ps = " 100 08-07:05:20 claude --resume old\n";
390
+ const r = reapStaleClaude({ psImpl: () => ps, killImpl: () => { throw new Error("ESRCH"); }, reapMs: 60_000 });
391
+ assert.deepEqual(r.reaped, []);
392
+ assert.equal(r.errors, 1);
393
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cohortapp/agent-sdk",
3
- "version": "2.11.4",
3
+ "version": "2.11.6",
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": {
@@ -113,6 +113,31 @@ try {
113
113
  // Reactive daemon continues. Doctor / healthcheck will surface this.
114
114
  }
115
115
 
116
+ // ---------------------------------------------------------------------------
117
+ // 3a. Stale-session reaper (self-cleaning)
118
+ // ---------------------------------------------------------------------------
119
+ // A wedged `claude` session (crash mid-stream, an orphaned `--resume`) otherwise
120
+ // lingers for DAYS. The governor counts it as "live" via ps, so it holds a
121
+ // dispatch slot, RAM, and — worst — a share of the single Max subscription, until
122
+ // the seat defers every cadence and its inbox goes silent (observed on A016: two
123
+ // 8-day-old zombies deferred 6704 ticks). Reap anything older than
124
+ // REAP_SESSION_MS: once at boot (any old session is a leftover) and on a timer so
125
+ // idle lanes self-clean. Best-effort — never takes the daemon down.
126
+ try {
127
+ const { reapStaleClaude } = await import("../../lib/resource-governor.mjs");
128
+ const sweep = () => {
129
+ try {
130
+ const r = reapStaleClaude();
131
+ if (r.reaped.length) console.log(`[DAEMON] reaped ${r.reaped.length} stale claude session(s): ${r.reaped.join(", ")}`);
132
+ } catch { /* best-effort */ }
133
+ };
134
+ sweep();
135
+ const reapTimer = setInterval(sweep, 5 * 60_000);
136
+ if (reapTimer && typeof reapTimer.unref === "function") reapTimer.unref();
137
+ } catch (err) {
138
+ console.error(`[DAEMON] stale-session reaper failed to start: ${err && err.message}`);
139
+ }
140
+
116
141
  // ---------------------------------------------------------------------------
117
142
  // 3b. Channel bus (1.12.0+; generic platform loop in 1.15.0/WS2)
118
143
  // ---------------------------------------------------------------------------