@commonlyai/cli 0.1.198 → 0.1.200
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.
- package/package.json +1 -1
- package/src/lib/adapters/claude.js +76 -5
- package/src/lib/first-output-watchdog.js +141 -0
- package/src/lib/spawn-retry.js +20 -0
package/package.json
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { claudeRuntimeInventory } from '../native-runtime-inventory.js';
|
|
2
2
|
import { observeSpawn, controlsFromArguments } from '../runtime-observation.js';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
firstOutputWindowMs, killStallSurvivors, physicalPath, spawnStalledError, STALL_KILL_GRACE_MS, watchFirstOutput,
|
|
5
|
+
} from '../first-output-watchdog.js';
|
|
6
|
+
import {
|
|
7
|
+
descendantsOf, killProcessTree, readProcessTable, spawnInOwnGroup, wantsOwnProcessGroup,
|
|
8
|
+
} from '@commonlyai/seats/process-group';
|
|
4
9
|
/**
|
|
5
10
|
* claude adapter — wraps the local `claude` CLI as a Commonly agent.
|
|
6
11
|
*
|
|
@@ -88,7 +93,7 @@ import {
|
|
|
88
93
|
prepareMcpSpawn, PUBLIC_MCP_UNAVAILABLE_CODE, publicMcpUnavailableError, selectedMcpVersionDir,
|
|
89
94
|
} from '../mcp-home.js';
|
|
90
95
|
import { buildMemoryPreamble, ROW_SLICING_CUE } from '../memory-bridge.js';
|
|
91
|
-
import { claudeConfigDirFor, claudeContextTokens } from '../session-recycle.js';
|
|
96
|
+
import { claudeConfigDirFor, claudeContextTokens, claudeTranscriptPath } from '../session-recycle.js';
|
|
92
97
|
import { skillsAsText } from '../skill-prompt.js';
|
|
93
98
|
import { adapterFailure, spawnCredentials } from '../upstream-refusal.js';
|
|
94
99
|
|
|
@@ -484,7 +489,11 @@ const publicSkillPromptArgs = async (skillSources) => {
|
|
|
484
489
|
|
|
485
490
|
// `ownGroup` is set only under the run supervisor, which kills the child's
|
|
486
491
|
// whole group; unsupervised, the spawn options are exactly what they were.
|
|
487
|
-
const runClaude = ({
|
|
492
|
+
const runClaude = ({
|
|
493
|
+
cmd, args, cwd, env, timeoutMs, credentials = [], spawnImpl = childSpawn, onSpawn, controls = {}, ownGroup = false,
|
|
494
|
+
transcriptPath = null, firstOutputMs = firstOutputWindowMs(), lockProbe, stallKillGraceMs = STALL_KILL_GRACE_MS,
|
|
495
|
+
processTable = readProcessTable, survivorIo,
|
|
496
|
+
}) => new Promise((resolve, reject) => {
|
|
488
497
|
const proc = spawnInOwnGroup(spawnImpl, cmd, args, {
|
|
489
498
|
cwd,
|
|
490
499
|
env,
|
|
@@ -499,15 +508,62 @@ const runClaude = ({ cmd, args, cwd, env, timeoutMs, credentials = [], spawnImpl
|
|
|
499
508
|
timedOut = true;
|
|
500
509
|
proc.kill('SIGTERM');
|
|
501
510
|
}, timeoutMs);
|
|
511
|
+
// A turn that never starts is killed long before the run timeout.
|
|
512
|
+
let stalled = null;
|
|
513
|
+
let stallKill = null;
|
|
514
|
+
let stallSnapshot = [];
|
|
515
|
+
// SIGKILL snapshotted descendants that are still alive. Safe to repeat. It
|
|
516
|
+
// runs when the spawn exits, at the grace deadline and on close: a survivor
|
|
517
|
+
// still holding the spawn's stdout or stderr keeps close from ever firing,
|
|
518
|
+
// and once the spawn is reaped killProcessTree can no longer reach it.
|
|
519
|
+
const sweepStallSurvivors = () => {
|
|
520
|
+
if (stalled) killStallSurvivors(stallSnapshot, survivorIo);
|
|
521
|
+
};
|
|
522
|
+
const treeIo = (table) => ({ table, ...(survivorIo?.read ? { read: survivorIo.read } : {}) });
|
|
523
|
+
const watchdog = watchFirstOutput({
|
|
524
|
+
proc,
|
|
525
|
+
windowMs: firstOutputMs,
|
|
526
|
+
transcriptPath,
|
|
527
|
+
lockProbe,
|
|
528
|
+
onStall: (reason) => {
|
|
529
|
+
stalled = spawnStalledError({ label: 'claude', windowMs: firstOutputMs, reason });
|
|
530
|
+
// Snapshot the descendants first: once the spawn exits they are
|
|
531
|
+
// reparented and can no longer be found from it.
|
|
532
|
+
const table = Number.isInteger(proc.pid) ? processTable() : null;
|
|
533
|
+
if (table) stallSnapshot = descendantsOf(table, proc.pid);
|
|
534
|
+
killProcessTree(proc, 'SIGTERM', treeIo(() => table));
|
|
535
|
+
// A process parked in a system call may not act on SIGTERM. SIGKILL the
|
|
536
|
+
// child, its group when it leads one, and its descendants.
|
|
537
|
+
stallKill = setTimeout(() => {
|
|
538
|
+
killProcessTree(proc, 'SIGKILL', treeIo(processTable));
|
|
539
|
+
sweepStallSurvivors();
|
|
540
|
+
// A pipe holder the snapshot missed (ps failed, or it was reparented
|
|
541
|
+
// before the stall) would still keep close from firing. Dropping our
|
|
542
|
+
// ends of the pipes bounds every stalled turn at window plus grace.
|
|
543
|
+
proc.stdout?.destroy?.();
|
|
544
|
+
proc.stderr?.destroy?.();
|
|
545
|
+
}, stallKillGraceMs);
|
|
546
|
+
stallKill.unref?.();
|
|
547
|
+
},
|
|
548
|
+
});
|
|
549
|
+
proc.on('exit', sweepStallSurvivors);
|
|
502
550
|
|
|
503
551
|
proc.stdout?.on('data', (chunk) => { stdout += chunk.toString(); });
|
|
504
552
|
proc.stderr?.on('data', (chunk) => { stderr += chunk.toString(); });
|
|
505
553
|
proc.on('error', (err) => {
|
|
506
554
|
clearTimeout(timer);
|
|
555
|
+
clearTimeout(stallKill);
|
|
556
|
+
watchdog.stop();
|
|
507
557
|
reject(err);
|
|
508
558
|
});
|
|
509
559
|
proc.on('close', (code) => {
|
|
510
560
|
clearTimeout(timer);
|
|
561
|
+
clearTimeout(stallKill);
|
|
562
|
+
watchdog.stop();
|
|
563
|
+
if (stalled) {
|
|
564
|
+
sweepStallSurvivors();
|
|
565
|
+
return reject(stalled);
|
|
566
|
+
}
|
|
511
567
|
if (timedOut) return reject(new Error(`claude timed out after ${timeoutMs}ms`));
|
|
512
568
|
if (code !== 0) {
|
|
513
569
|
// Report stdout too, not just stderr. In `-p` mode claude writes terminal
|
|
@@ -1200,11 +1256,18 @@ export default {
|
|
|
1200
1256
|
credentials: spawnCredentials(spawnCtx),
|
|
1201
1257
|
spawnImpl: ctx._spawnImpl, onSpawn: ctx.onSpawn, controls: controlsFromArguments(modelArgs, { modelFlag: '--model', effortFlag: '--effort' }), // test seam only — do not use in production
|
|
1202
1258
|
ownGroup: wantsOwnProcessGroup(ctx),
|
|
1259
|
+
transcriptPath: claudeTranscriptPath({
|
|
1260
|
+
cwd: physicalPath(ctx.cwd), sessionId, configDir: claudeConfigDirFor({ ...env, HOME: childHome || env?.HOME }),
|
|
1261
|
+
}),
|
|
1262
|
+
lockProbe: ctx._lockProbe, // test seams only
|
|
1263
|
+
stallKillGraceMs: ctx._stallKillGraceMs,
|
|
1264
|
+
processTable: ctx._processTable,
|
|
1265
|
+
survivorIo: ctx._survivorIo,
|
|
1203
1266
|
});
|
|
1204
1267
|
// TASK-440: the context this turn left, read from the transcript the
|
|
1205
1268
|
// child wrote under its own env (an isolated HOME for public seats).
|
|
1206
1269
|
const contextTokens = claudeContextTokens({
|
|
1207
|
-
cwd: ctx.cwd, sessionId, configDir: claudeConfigDirFor({ ...env, HOME: childHome || env?.HOME }),
|
|
1270
|
+
cwd: physicalPath(ctx.cwd), sessionId, configDir: claudeConfigDirFor({ ...env, HOME: childHome || env?.HOME }),
|
|
1208
1271
|
});
|
|
1209
1272
|
return { text: stdout.trim(), newSessionId: sessionId, contextTokens };
|
|
1210
1273
|
} catch (err) {
|
|
@@ -1256,9 +1319,17 @@ export default {
|
|
|
1256
1319
|
credentials: spawnCredentials(retryCtx),
|
|
1257
1320
|
spawnImpl: ctx._spawnImpl, onSpawn: ctx.onSpawn, controls: controlsFromArguments(modelArgs, { modelFlag: '--model', effortFlag: '--effort' }),
|
|
1258
1321
|
ownGroup: wantsOwnProcessGroup(ctx),
|
|
1322
|
+
transcriptPath: claudeTranscriptPath({
|
|
1323
|
+
cwd: physicalPath(ctx.cwd), sessionId: freshId,
|
|
1324
|
+
configDir: claudeConfigDirFor({ ...retry.env, HOME: retry.childHome || retry.env?.HOME }),
|
|
1325
|
+
}),
|
|
1326
|
+
lockProbe: ctx._lockProbe,
|
|
1327
|
+
stallKillGraceMs: ctx._stallKillGraceMs,
|
|
1328
|
+
processTable: ctx._processTable,
|
|
1329
|
+
survivorIo: ctx._survivorIo,
|
|
1259
1330
|
});
|
|
1260
1331
|
const contextTokens = claudeContextTokens({
|
|
1261
|
-
cwd: ctx.cwd, sessionId: freshId,
|
|
1332
|
+
cwd: physicalPath(ctx.cwd), sessionId: freshId,
|
|
1262
1333
|
configDir: claudeConfigDirFor({ ...retry.env, HOME: retry.childHome || retry.env?.HOME }),
|
|
1263
1334
|
});
|
|
1264
1335
|
return { text: stdout.trim(), newSessionId: freshId, contextTokens };
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* First-output watchdog for a seat's model CLI spawn.
|
|
3
|
+
*
|
|
4
|
+
* On 2026-10-30 every launchd seat's `claude -p` turn parked while the Mac's
|
|
5
|
+
* console was locked: no transcript row, no stdout, no stderr, for the full
|
|
6
|
+
* 45-minute run timeout. Each one held its delivery the whole time, the retry
|
|
7
|
+
* respawned it into the same hang, and the log said only "claude timed out
|
|
8
|
+
* after 2700000ms", which names nothing.
|
|
9
|
+
*
|
|
10
|
+
* The signal is not stdout. A seat runs `claude -p --output-format text`,
|
|
11
|
+
* which writes stdout once, at the end, so a healthy 20-minute turn has zero
|
|
12
|
+
* stdout bytes for 20 minutes. What a healthy turn does write is its session
|
|
13
|
+
* transcript: measured on the dart seat, the first row landed 0.7 s after the
|
|
14
|
+
* spawn, and the hung spawns that night wrote no row at all. So a spawn counts
|
|
15
|
+
* as started when it writes any stdout or stderr byte OR its transcript grows.
|
|
16
|
+
* A spawn that does neither inside the window is killed with a reason. When
|
|
17
|
+
* the transcript is missing altogether, the path may simply be wrong, so the
|
|
18
|
+
* spawn is killed only if the console is also locked.
|
|
19
|
+
*/
|
|
20
|
+
import { realpathSync, statSync } from 'fs';
|
|
21
|
+
import { readProcess } from '@commonlyai/seats/process-group';
|
|
22
|
+
|
|
23
|
+
export const DEFAULT_FIRST_OUTPUT_WINDOW_MS = 180_000;
|
|
24
|
+
|
|
25
|
+
// 0 turns the watchdog off; anything unparseable keeps the default.
|
|
26
|
+
export const firstOutputWindowMs = (env = process.env) => {
|
|
27
|
+
const raw = env?.COMMONLY_FIRST_OUTPUT_WINDOW_MS;
|
|
28
|
+
if (raw === undefined || raw === '') return DEFAULT_FIRST_OUTPUT_WINDOW_MS;
|
|
29
|
+
const parsed = Number(raw);
|
|
30
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_FIRST_OUTPUT_WINDOW_MS;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const SPAWN_STALLED_CODE = 'spawn_stalled';
|
|
34
|
+
|
|
35
|
+
// The message carries the class word spawn-retry.js matches, so the stall gets
|
|
36
|
+
// its own backoff rather than RUNTIME's 5-second retry.
|
|
37
|
+
export const spawnStalledError = ({ label, windowMs, reason }) => {
|
|
38
|
+
const error = new Error(
|
|
39
|
+
`${label} produced no output in ${Math.round(windowMs / 1000)}s (${reason}); killed before the run timeout`,
|
|
40
|
+
);
|
|
41
|
+
error.code = SPAWN_STALLED_CODE;
|
|
42
|
+
error.stallReason = reason;
|
|
43
|
+
return error;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// `ioreg -n Root -d1` prints `"IOConsoleLocked" = Yes` while the screen is
|
|
47
|
+
// locked. True, false, or null when it cannot tell (not darwin, ioreg failed).
|
|
48
|
+
// child_process is loaded on demand: suites that mock it with only `spawn`
|
|
49
|
+
// would otherwise fail to import every adapter that uses this module.
|
|
50
|
+
export const consoleLocked = async ({ platform = process.platform, execFileImpl } = {}) => {
|
|
51
|
+
if (platform !== 'darwin') return null;
|
|
52
|
+
const run = execFileImpl || (await import('child_process')).execFile;
|
|
53
|
+
if (typeof run !== 'function') return null;
|
|
54
|
+
return new Promise((resolve) => {
|
|
55
|
+
run('/usr/sbin/ioreg', ['-n', 'Root', '-d1'], { timeout: 5000 }, (error, stdout) => {
|
|
56
|
+
if (error) return resolve(null);
|
|
57
|
+
const match = String(stdout).match(/"IOConsoleLocked"\s*=\s*(Yes|No)/);
|
|
58
|
+
return resolve(match ? match[1] === 'Yes' : null);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// Claude names its project folder after process.cwd(), which on macOS is the
|
|
64
|
+
// physical path (/tmp is /private/tmp), so the transcript is looked for there.
|
|
65
|
+
export const physicalPath = (dir) => {
|
|
66
|
+
if (!dir) return dir;
|
|
67
|
+
try {
|
|
68
|
+
return realpathSync(dir);
|
|
69
|
+
} catch {
|
|
70
|
+
return dir;
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// How long a stalled spawn gets to exit after SIGTERM before SIGKILL.
|
|
75
|
+
export const STALL_KILL_GRACE_MS = 10_000;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* SIGKILL each snapshotted descendant of a stalled spawn that is still alive
|
|
79
|
+
* once the spawn itself has exited. Its parent may have exited, so it may now
|
|
80
|
+
* belong to launchd: only the pid and start time are matched, which still
|
|
81
|
+
* rules out a reused pid. Returns the pids signalled.
|
|
82
|
+
*/
|
|
83
|
+
export const killStallSurvivors = (snapshot, { read = readProcess, kill = process.kill.bind(process) } = {}) => {
|
|
84
|
+
const killed = [];
|
|
85
|
+
for (const row of snapshot || []) {
|
|
86
|
+
const now = read(row.pid);
|
|
87
|
+
if (!now || now.started !== row.started) continue;
|
|
88
|
+
try { kill(row.pid, 'SIGKILL'); killed.push(row.pid); } catch { /* gone */ }
|
|
89
|
+
}
|
|
90
|
+
return killed;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const sizeOf = (file) => {
|
|
94
|
+
if (!file) return -1;
|
|
95
|
+
try {
|
|
96
|
+
return statSync(file).size;
|
|
97
|
+
} catch {
|
|
98
|
+
return -1;
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Watch `proc` for its first sign of life. Returns `{ stop }`; call it when the
|
|
104
|
+
* process closes. `onStall(reason)` fires at most once, after the caller should
|
|
105
|
+
* kill the process.
|
|
106
|
+
*/
|
|
107
|
+
export const watchFirstOutput = ({
|
|
108
|
+
proc,
|
|
109
|
+
windowMs,
|
|
110
|
+
transcriptPath = null,
|
|
111
|
+
onStall,
|
|
112
|
+
lockProbe = consoleLocked,
|
|
113
|
+
setTimeoutImpl = setTimeout,
|
|
114
|
+
clearTimeoutImpl = clearTimeout,
|
|
115
|
+
}) => {
|
|
116
|
+
if (!windowMs) return { stop: () => {} };
|
|
117
|
+
let started = false;
|
|
118
|
+
let stopped = false;
|
|
119
|
+
const markStarted = () => { started = true; };
|
|
120
|
+
proc.stdout?.on('data', markStarted);
|
|
121
|
+
proc.stderr?.on('data', markStarted);
|
|
122
|
+
// A missing file counts as empty, so creating an empty one is not growth.
|
|
123
|
+
const baseline = Math.max(0, sizeOf(transcriptPath));
|
|
124
|
+
const timer = setTimeoutImpl(async () => {
|
|
125
|
+
const size = sizeOf(transcriptPath);
|
|
126
|
+
if (started || size > baseline) return;
|
|
127
|
+
const locked = await lockProbe().catch(() => null);
|
|
128
|
+
if (stopped) return;
|
|
129
|
+
// No transcript where one was expected may only mean the path is wrong, so
|
|
130
|
+
// that alone never kills a turn: it takes a locked console as well.
|
|
131
|
+
if (size < 0 && locked !== true) return;
|
|
132
|
+
onStall(locked ? 'console locked' : 'no transcript, stdout or stderr');
|
|
133
|
+
}, windowMs);
|
|
134
|
+
timer?.unref?.();
|
|
135
|
+
return {
|
|
136
|
+
stop: () => {
|
|
137
|
+
stopped = true;
|
|
138
|
+
clearTimeoutImpl(timer);
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
};
|
package/src/lib/spawn-retry.js
CHANGED
|
@@ -16,8 +16,16 @@ export const SPAWN_FAILURE_CLASS = Object.freeze({
|
|
|
16
16
|
RATE_LIMIT: 'rate_limit',
|
|
17
17
|
CONFIGURATION: 'configuration',
|
|
18
18
|
RUNTIME: 'runtime',
|
|
19
|
+
// The spawn never started: no transcript row, stdout or stderr inside the
|
|
20
|
+
// first-output window (lib/first-output-watchdog.js). A host condition such as
|
|
21
|
+
// a locked console, so the circuit opens at once and the probe waits.
|
|
22
|
+
STALLED: 'stalled',
|
|
19
23
|
});
|
|
20
24
|
|
|
25
|
+
// A stalled probe costs one first-output window, so the wait starts short and
|
|
26
|
+
// doubles to the usual ceiling: 2, 4, 8, then 15 minutes.
|
|
27
|
+
export const SPAWN_STALL_RETRY_BASE_MS = 2 * 60 * 1000;
|
|
28
|
+
|
|
21
29
|
export const SPAWN_CIRCUIT_THRESHOLD = 3;
|
|
22
30
|
// Base ceiling before the stable per-agent 0–20% anti-herd offset.
|
|
23
31
|
export const SPAWN_RETRY_MAX_MS = 15 * 60 * 1000;
|
|
@@ -116,6 +124,7 @@ export const classifySpawnFailure = (error) => {
|
|
|
116
124
|
// First: the refusal text carries none of the words below today, but a
|
|
117
125
|
// refusal must never be read as a transient condition and retried.
|
|
118
126
|
if (CONTENT_REFUSED_RE.test(text)) return SPAWN_FAILURE_CLASS.CONTENT_REFUSED;
|
|
127
|
+
if (error?.code === 'spawn_stalled') return SPAWN_FAILURE_CLASS.STALLED;
|
|
119
128
|
// Provider APIs commonly report an exhausted account quota as HTTP 429.
|
|
120
129
|
// Prefer the more specific body/message over the generic status code so a
|
|
121
130
|
// hard quota failure gets the long cooldown rather than a one-minute probe.
|
|
@@ -194,6 +203,17 @@ export const spawnRetryPolicy = ({
|
|
|
194
203
|
};
|
|
195
204
|
}
|
|
196
205
|
|
|
206
|
+
if (failureClass === SPAWN_FAILURE_CLASS.STALLED) {
|
|
207
|
+
return {
|
|
208
|
+
failureClass,
|
|
209
|
+
circuitOpen: true,
|
|
210
|
+
delayMs: applyJitter(
|
|
211
|
+
Math.min(SPAWN_RETRY_MAX_MS, SPAWN_STALL_RETRY_BASE_MS * (2 ** (failureCount - 1))),
|
|
212
|
+
jitterRatio,
|
|
213
|
+
),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
197
217
|
if (failureClass === SPAWN_FAILURE_CLASS.RATE_LIMIT) {
|
|
198
218
|
return {
|
|
199
219
|
failureClass,
|