@cohortapp/agent-sdk 2.11.5 → 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.
|
@@ -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.
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
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,
|
|
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
|
-
|
|
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
|
|
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 →
|
|
76
|
-
assert.equal(dynamicMax({ totalmem: 8 * GB, cpus: 8 }),
|
|
77
|
-
// 16GB with 16 cpus: byRam=12 → clamp to min(
|
|
78
|
-
assert.equal(dynamicMax({ totalmem: 16 * GB, cpus: 16 }),
|
|
79
|
-
// cpu-bound below HARD_MAX: 16GB with 2 cpus → min(
|
|
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,
|
|
97
|
-
assert.equal(r.snapshot.effectiveMax,
|
|
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 =
|
|
111
|
-
// =
|
|
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:
|
|
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
|
-
//
|
|
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:
|
|
122
|
+
admit({ source: "inbox" }, deps({ liveClaude: { count: 5, rssMB: 1200 } })).decision,
|
|
122
123
|
DECISIONS.ADMIT,
|
|
123
124
|
);
|
|
124
|
-
// Only at the full envelope (effectiveMax=
|
|
125
|
-
const r = admit({ source: "inbox" }, deps({ liveClaude: { count:
|
|
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.
|
|
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
|
// ---------------------------------------------------------------------------
|