@commonlyai/cli 0.1.222 → 0.1.224
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/node_modules/@commonlyai/seats/src/process-group.js +9 -1
- package/node_modules/@commonlyai/seats/src/turn-processes.js +199 -0
- package/package.json +1 -1
- package/src/commands/agent.js +2 -0
- package/src/lib/adapters/claude.js +24 -7
- package/src/lib/adapters/codex.js +14 -4
- package/src/lib/first-output-watchdog.js +11 -0
- package/src/lib/memory-bridge.js +9 -0
|
@@ -82,7 +82,9 @@ export const leadsOwnGroup = (child) => !!child
|
|
|
82
82
|
|
|
83
83
|
// ── The process table ──────────────────────────────────────────────────────
|
|
84
84
|
|
|
85
|
-
|
|
85
|
+
// lstart is printed in local time with no offset, which repeats an hour when
|
|
86
|
+
// daylight saving ends. In UTC it never repeats; `startedMs` reads it as UTC.
|
|
87
|
+
const PS_ENV = { ...process.env, LC_ALL: 'C', TZ: 'UTC' };
|
|
86
88
|
|
|
87
89
|
const parseTable = (text) => {
|
|
88
90
|
const rows = [];
|
|
@@ -100,6 +102,9 @@ const ps = (args) => {
|
|
|
100
102
|
} catch { return null; }
|
|
101
103
|
};
|
|
102
104
|
|
|
105
|
+
/** A row's `started` as epoch ms. */
|
|
106
|
+
export const startedMs = (started) => Date.parse(`${started} GMT`);
|
|
107
|
+
|
|
103
108
|
/** Every process: { pid, ppid, started }. null when ps is unavailable. */
|
|
104
109
|
export const readProcessTable = () => ps(['-A', '-o', 'pid=,ppid=,lstart=']);
|
|
105
110
|
|
|
@@ -107,6 +112,9 @@ export const readProcessTable = () => ps(['-A', '-o', 'pid=,ppid=,lstart=']);
|
|
|
107
112
|
export const readProcess = (pid) => (ps(['-o', 'pid=,ppid=,lstart=', '-p', String(pid)]) || [])
|
|
108
113
|
.find((row) => row.pid === pid) || null;
|
|
109
114
|
|
|
115
|
+
/** Several processes as they are now, in one ps call; a gone pid has no row. null when ps is unavailable. */
|
|
116
|
+
export const readProcesses = (pids) => (pids.length ? ps(['-o', 'pid=,ppid=,lstart=', '-p', pids.join(',')]) : []);
|
|
117
|
+
|
|
110
118
|
/** The descendants of `root` in `table`, deepest first. The root is not included. */
|
|
111
119
|
export const descendantsOf = (table, root) => {
|
|
112
120
|
const byParent = new Map();
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Processes a turn leaves running.
|
|
3
|
+
*
|
|
4
|
+
* A turn can start work that outlives it. A dev server backgrounded from a
|
|
5
|
+
* shell tool (`npm exec vite &`) is orphaned the moment that shell exits and
|
|
6
|
+
* is reparented to launchd, where no group kill or descendant walk from the
|
|
7
|
+
* turn's child can reach it. Six such trees, one to four days old, took the
|
|
8
|
+
* fleet host's swap to 12 GB (SRE, 2026-11-01).
|
|
9
|
+
*
|
|
10
|
+
* So a turn's child is started with a fresh marker in its environment, which
|
|
11
|
+
* every process it starts inherits however it detaches. When the child has
|
|
12
|
+
* closed, the processes still carrying the marker are stopped: SIGTERM, then
|
|
13
|
+
* SIGKILL after a grace for any still alive. That includes a lasting server
|
|
14
|
+
* the turn started on first use, such as tmux, and a seat it restarted. A
|
|
15
|
+
* process started with COMMONLY_KEEP_PROCESS=1 is left running, and so is
|
|
16
|
+
* anything it starts, even one that clears the setting; the turn's child itself
|
|
17
|
+
* never inherits it. COMMONLY_TURN_REAP=0 turns the whole check off.
|
|
18
|
+
*
|
|
19
|
+
* Only processes started since the turn began are read, and each one's
|
|
20
|
+
* environment is only compared against the marker and the keep setting. Each
|
|
21
|
+
* pid is re-read just before it is signalled, and is signalled only while its
|
|
22
|
+
* start time is still the one recorded, so a reused pid is left alone.
|
|
23
|
+
*
|
|
24
|
+
* What it does not catch: on macOS `ps -E` cannot read the environment of an
|
|
25
|
+
* Apple platform binary (sh, bash, sleep), so one of those is stopped only as
|
|
26
|
+
* a descendant of a marked process. A process that clears its environment
|
|
27
|
+
* loses the marker. Both are left running. The same unreadable environment
|
|
28
|
+
* hides a keep setting: a kept Apple binary is not seen as kept, so a process
|
|
29
|
+
* it starts is kept only if that process keeps the setting itself.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { randomUUID } from 'node:crypto';
|
|
33
|
+
import { readFileSync } from 'node:fs';
|
|
34
|
+
import { spawnSync } from 'node:child_process';
|
|
35
|
+
import {
|
|
36
|
+
PROCESS_GROUPS_SUPPORTED, readProcesses, readProcessTable, startedMs,
|
|
37
|
+
} from './process-group.js';
|
|
38
|
+
|
|
39
|
+
export const TURN_MARK_ENV = 'COMMONLY_TURN_ID';
|
|
40
|
+
export const KEEP_PROCESS_ENV = 'COMMONLY_KEEP_PROCESS';
|
|
41
|
+
export const TURN_REAP_ENV = 'COMMONLY_TURN_REAP';
|
|
42
|
+
export const TURN_REAP_GRACE_MS = 5_000;
|
|
43
|
+
// How far before the turn a process's recorded start may read and still be
|
|
44
|
+
// looked at. lstart has one-second resolution, and on Linux ps derives it from
|
|
45
|
+
// the boot time, which drifts from the wall clock by a second or more. The
|
|
46
|
+
// window only bounds what is read; the marker decides.
|
|
47
|
+
export const TURN_START_SLACK_MS = 60_000;
|
|
48
|
+
|
|
49
|
+
export const turnReapEnabled = (env = process.env) => PROCESS_GROUPS_SUPPORTED && env?.[TURN_REAP_ENV] !== '0';
|
|
50
|
+
|
|
51
|
+
const PS_ENV = { ...process.env, LC_ALL: 'C' };
|
|
52
|
+
|
|
53
|
+
// pid -> the set of tokens its environment reads as. A pid whose environment
|
|
54
|
+
// could not be read has no entry, or a set without NAME=value pairs.
|
|
55
|
+
const readEnvironmentsDarwin = (pids) => {
|
|
56
|
+
const out = new Map();
|
|
57
|
+
const res = spawnSync('ps', ['-E', '-ww', '-o', 'pid=,command=', '-p', pids.join(',')], {
|
|
58
|
+
encoding: 'utf8', env: PS_ENV, timeout: 5000, maxBuffer: 64 * 1024 * 1024,
|
|
59
|
+
});
|
|
60
|
+
if (res.error) return out;
|
|
61
|
+
for (const line of String(res.stdout || '').split('\n')) {
|
|
62
|
+
const match = /^\s*(\d+)\s+(.*)$/.exec(line);
|
|
63
|
+
if (match) out.set(Number(match[1]), new Set(match[2].split(/\s+/)));
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const readEnvironmentsLinux = (pids) => {
|
|
69
|
+
const out = new Map();
|
|
70
|
+
for (const pid of pids) {
|
|
71
|
+
try {
|
|
72
|
+
out.set(pid, new Set(readFileSync(`/proc/${pid}/environ`, 'utf8').split('\0')));
|
|
73
|
+
} catch { /* gone, or not readable */ }
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export const readEnvironments = (pids) => {
|
|
79
|
+
if (!pids.length || !PROCESS_GROUPS_SUPPORTED) return new Map();
|
|
80
|
+
return process.platform === 'linux' ? readEnvironmentsLinux(pids) : readEnvironmentsDarwin(pids);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const selfAndAncestors = (table, pid) => {
|
|
84
|
+
const byPid = new Map(table.map((row) => [row.pid, row]));
|
|
85
|
+
const out = new Set([pid]);
|
|
86
|
+
for (let row = byPid.get(pid); row && row.ppid > 0 && !out.has(row.ppid); row = byPid.get(row.ppid)) {
|
|
87
|
+
out.add(row.ppid);
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The processes still carrying `mark`, as { pid, ppid, started }: started at
|
|
94
|
+
* or after `since` (ms), neither this process nor an ancestor of it, and
|
|
95
|
+
* neither kept nor under a kept one. Each one brings its descendants, except a
|
|
96
|
+
* kept one and everything
|
|
97
|
+
* under it, whose environments may be unreadable. The start-time window only
|
|
98
|
+
* limits what is read; the marker is what decides. `io` is a test seam
|
|
99
|
+
* ({ table, envs, self }).
|
|
100
|
+
*/
|
|
101
|
+
export const findTurnProcesses = (mark, since, io = {}) => {
|
|
102
|
+
const { table = readProcessTable, envs = readEnvironments, self = process.pid } = io;
|
|
103
|
+
if (!mark) return [];
|
|
104
|
+
const rows = table();
|
|
105
|
+
if (!rows) return [];
|
|
106
|
+
const skip = selfAndAncestors(rows, self);
|
|
107
|
+
const floor = Number(since) - TURN_START_SLACK_MS;
|
|
108
|
+
const recent = rows.filter((row) => !skip.has(row.pid) && startedMs(row.started) >= floor);
|
|
109
|
+
const environments = envs(recent.map((row) => row.pid));
|
|
110
|
+
const marked = `${TURN_MARK_ENV}=${mark}`;
|
|
111
|
+
const keep = `${KEEP_PROCESS_ENV}=1`;
|
|
112
|
+
const kept = (pid) => environments.get(pid)?.has(keep) === true;
|
|
113
|
+
const byPid = new Map(rows.map((row) => [row.pid, row]));
|
|
114
|
+
// A kept process keeps what it starts even when that drops the setting.
|
|
115
|
+
const underKept = (row) => {
|
|
116
|
+
for (let up = byPid.get(row.ppid), seen = new Set(); up && !seen.has(up.pid); up = byPid.get(up.ppid)) {
|
|
117
|
+
if (kept(up.pid)) return true;
|
|
118
|
+
seen.add(up.pid);
|
|
119
|
+
}
|
|
120
|
+
return false;
|
|
121
|
+
};
|
|
122
|
+
const found = new Map();
|
|
123
|
+
for (const row of recent) {
|
|
124
|
+
if (environments.get(row.pid)?.has(marked) && !kept(row.pid) && !underKept(row)) found.set(row.pid, row);
|
|
125
|
+
}
|
|
126
|
+
const byParent = new Map();
|
|
127
|
+
for (const row of rows) {
|
|
128
|
+
if (row.pid === row.ppid) continue;
|
|
129
|
+
if (!byParent.has(row.ppid)) byParent.set(row.ppid, []);
|
|
130
|
+
byParent.get(row.ppid).push(row);
|
|
131
|
+
}
|
|
132
|
+
for (const stack = [...found.keys()]; stack.length;) {
|
|
133
|
+
for (const child of byParent.get(stack.pop()) || []) {
|
|
134
|
+
if (found.has(child.pid) || skip.has(child.pid) || kept(child.pid)) continue;
|
|
135
|
+
found.set(child.pid, child);
|
|
136
|
+
stack.push(child.pid);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return [...found.values()];
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Stop what `findTurnProcesses` returns. SIGTERM is sent before this returns;
|
|
144
|
+
* `done` settles after `graceMs`, once anything still alive has had SIGKILL.
|
|
145
|
+
* A pid is signalled only while its start time is the one recorded, read for
|
|
146
|
+
* all of them in one ps call per signal. The grace timer holds the process
|
|
147
|
+
* open until SIGKILL has gone out, so a seat stopped mid-turn still escalates.
|
|
148
|
+
* `io` adds `read` (pids -> rows) to the find seam.
|
|
149
|
+
*/
|
|
150
|
+
export const reapTurnProcesses = (mark, since, { graceMs = TURN_REAP_GRACE_MS, io = {} } = {}) => {
|
|
151
|
+
const read = io.read || readProcesses;
|
|
152
|
+
const signal = (rows, name) => {
|
|
153
|
+
const now = new Map((read(rows.map((row) => row.pid)) || []).map((row) => [row.pid, row]));
|
|
154
|
+
return rows.filter((row) => {
|
|
155
|
+
if (now.get(row.pid)?.started !== row.started) return false;
|
|
156
|
+
try { process.kill(row.pid, name); return true; } catch { return false; }
|
|
157
|
+
});
|
|
158
|
+
};
|
|
159
|
+
const termed = signal(findTurnProcesses(mark, since, io), 'SIGTERM');
|
|
160
|
+
const done = termed.length === 0
|
|
161
|
+
? Promise.resolve({ killed: [] })
|
|
162
|
+
: new Promise((resolve) => {
|
|
163
|
+
setTimeout(() => resolve({ killed: signal(termed, 'SIGKILL').map((row) => row.pid) }), graceMs);
|
|
164
|
+
});
|
|
165
|
+
return { termed: termed.map((row) => row.pid), done };
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* One turn's marker. `env` is the child's environment with the marker added;
|
|
170
|
+
* `reap(child)` runs once the child has closed, and returns what
|
|
171
|
+
* `reapTurnProcesses` does, or null when the check is off, the child never had
|
|
172
|
+
* a pid, or the turn was already reaped.
|
|
173
|
+
*/
|
|
174
|
+
export const markTurn = (env, { enabled = turnReapEnabled(), now = Date.now, graceMs } = {}) => {
|
|
175
|
+
if (!enabled) return { env, mark: null, reap: () => null };
|
|
176
|
+
const mark = randomUUID();
|
|
177
|
+
const since = now();
|
|
178
|
+
// A keep setting the seat itself was started with would keep every turn.
|
|
179
|
+
const childEnv = { ...env, [TURN_MARK_ENV]: mark };
|
|
180
|
+
delete childEnv[KEEP_PROCESS_ENV];
|
|
181
|
+
let reaped = false;
|
|
182
|
+
return {
|
|
183
|
+
env: childEnv,
|
|
184
|
+
mark,
|
|
185
|
+
reap: (child, { io } = {}) => {
|
|
186
|
+
if (reaped || !Number.isInteger(child?.pid) || child.pid <= 0) return null;
|
|
187
|
+
reaped = true;
|
|
188
|
+
try { return reapTurnProcesses(mark, since, { graceMs, io }); } catch { return null; }
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
/** The log line for a reap that stopped something, or null. */
|
|
194
|
+
export const describeTurnReap = (result) => {
|
|
195
|
+
if (!result?.termed?.length) return null;
|
|
196
|
+
const n = result.termed.length;
|
|
197
|
+
return `stopped ${n} process${n === 1 ? '' : 'es'} this turn left running (pid ${result.termed.join(', ')}); `
|
|
198
|
+
+ `start one with ${KEEP_PROCESS_ENV}=1 to keep it across turns`;
|
|
199
|
+
};
|
package/package.json
CHANGED
package/src/commands/agent.js
CHANGED
|
@@ -96,6 +96,7 @@ import { createSeatStop } from '../lib/seat-stop.js';
|
|
|
96
96
|
import { detectSeatbelt } from '../lib/sandbox/seatbelt.js';
|
|
97
97
|
import { describeUpstreamRefusal } from '../lib/upstream-refusal.js';
|
|
98
98
|
import { createTurnRunner, supervisedRunPolicy } from '../lib/supervised-turn.js';
|
|
99
|
+
import { describeFirstOutputWatch } from '../lib/first-output-watchdog.js';
|
|
99
100
|
import {
|
|
100
101
|
DEFAULT_HOOK_TIMEOUT_MS,
|
|
101
102
|
clampHookTimeoutMs,
|
|
@@ -1586,6 +1587,7 @@ export const performRun = ({
|
|
|
1586
1587
|
seat: agentName, turnTimeoutMs: effectiveTurnTimeoutMs, supervise: supervising, log,
|
|
1587
1588
|
});
|
|
1588
1589
|
if (supervising) log(turnRunner.describe(adapter?.name));
|
|
1590
|
+
if (adapter?.name === 'claude') log(describeFirstOutputWatch());
|
|
1589
1591
|
const runtimeReporter = createRuntimeReporter({
|
|
1590
1592
|
client: runtimeReportClient || { post: async () => {} },
|
|
1591
1593
|
harness: adapter.name, detect: () => adapter.detect(),
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
import {
|
|
7
7
|
descendantsOf, killProcessTree, readProcessTable, spawnInOwnGroup, wantsOwnProcessGroup,
|
|
8
8
|
} from '@commonlyai/seats/process-group';
|
|
9
|
+
import { describeTurnReap, markTurn, turnReapEnabled } from '@commonlyai/seats/turn-processes';
|
|
9
10
|
/**
|
|
10
11
|
* claude adapter — wraps the local `claude` CLI as a Commonly agent.
|
|
11
12
|
*
|
|
@@ -92,7 +93,7 @@ import { deliverSeatCredential, withholdRuntimeCredential } from '../mcp-credent
|
|
|
92
93
|
import {
|
|
93
94
|
prepareMcpSpawn, PUBLIC_MCP_UNAVAILABLE_CODE, publicMcpUnavailableError, selectedMcpVersionDir,
|
|
94
95
|
} from '../mcp-home.js';
|
|
95
|
-
import { buildMemoryPreamble, ROW_SLICING_CUE } from '../memory-bridge.js';
|
|
96
|
+
import { BACKGROUND_PROCESS_CUE, buildMemoryPreamble, ROW_SLICING_CUE } from '../memory-bridge.js';
|
|
96
97
|
import { claudeConfigDirFor, claudeContextTokens, claudeTranscriptPath } from '../session-recycle.js';
|
|
97
98
|
import { skillsAsText } from '../skill-prompt.js';
|
|
98
99
|
import { adapterFailure, spawnCredentials } from '../upstream-refusal.js';
|
|
@@ -115,13 +116,14 @@ const buildPrompt = buildMemoryPreamble;
|
|
|
115
116
|
|
|
116
117
|
// The working habits a private claude seat is taught on a fresh session
|
|
117
118
|
// (TASK-545). Parallel reads live here because only this CLI has a subagent
|
|
118
|
-
// tool; codex is passed row slicing
|
|
119
|
-
// argv allows Read and the MCP under dontAsk, so it
|
|
120
|
-
//
|
|
119
|
+
// tool; codex is passed row slicing and the background-process cue. A public
|
|
120
|
+
// seat is passed none: its argv allows Read and the MCP under dontAsk, so it
|
|
121
|
+
// has no subagent to spawn or shell to background from, and it answers
|
|
122
|
+
// strangers rather than working board rows.
|
|
121
123
|
const PARALLEL_READS_CUE = 'Parallel reads: when a row needs several files or sources read, '
|
|
122
124
|
+ 'use the Agent tool. Spawn read-only subagents in parallel, brief each one with the files '
|
|
123
125
|
+ 'to read and the question to answer, and keep only their conclusions.';
|
|
124
|
-
const WORK_CUES = [PARALLEL_READS_CUE, ROW_SLICING_CUE];
|
|
126
|
+
const WORK_CUES = [PARALLEL_READS_CUE, ROW_SLICING_CUE, BACKGROUND_PROCESS_CUE];
|
|
125
127
|
const RUNTIME_CREDENTIAL_PLACEHOLDER = '${COMMONLY_AGENT_TOKEN}';
|
|
126
128
|
|
|
127
129
|
const QUOTE_SHELL_ARGUMENT = (value) => `'${String(value).replaceAll("'", "'\\''")}'`;
|
|
@@ -635,11 +637,14 @@ export const claudeFailureTail = (stdout) => {
|
|
|
635
637
|
const runClaude = ({
|
|
636
638
|
cmd, args, cwd, env, timeoutMs, credentials = [], spawnImpl = childSpawn, onSpawn, controls = {}, ownGroup = false,
|
|
637
639
|
transcriptPath = null, firstOutputMs = firstOutputWindowMs(), lockProbe, stallKillGraceMs = STALL_KILL_GRACE_MS,
|
|
638
|
-
processTable = readProcessTable, survivorIo, modelStream = true,
|
|
640
|
+
processTable = readProcessTable, survivorIo, modelStream = true, turnReap = turnReapEnabled(),
|
|
639
641
|
}) => new Promise((resolve, reject) => {
|
|
642
|
+
// Every process the turn starts inherits this marker however it detaches,
|
|
643
|
+
// so what it leaves running is stopped once the turn has closed.
|
|
644
|
+
const turn = markTurn(env ?? process.env, { enabled: turnReap });
|
|
640
645
|
const proc = spawnInOwnGroup(spawnImpl, cmd, args, {
|
|
641
646
|
cwd,
|
|
642
|
-
env,
|
|
647
|
+
env: turn.env,
|
|
643
648
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
644
649
|
}, { ownGroup });
|
|
645
650
|
observeSpawn(proc, onSpawn, controls);
|
|
@@ -676,6 +681,13 @@ const runClaude = ({
|
|
|
676
681
|
const table = Number.isInteger(proc.pid) ? processTable() : null;
|
|
677
682
|
if (table) stallSnapshot = descendantsOf(table, proc.pid);
|
|
678
683
|
killProcessTree(proc, 'SIGTERM', treeIo(() => table));
|
|
684
|
+
// A stopped process holds a SIGTERM it handles (claude does) until it is
|
|
685
|
+
// continued, so a turn frozen with SIGSTOP sat out the whole grace below:
|
|
686
|
+
// 191 s on a 180 s window (rivet, 2026-11-01). SIGCONT lets it act on the
|
|
687
|
+
// SIGTERM now. A process that is not stopped is unaffected unless it
|
|
688
|
+
// handles SIGCONT (a job-control shell does); the SIGKILL below still
|
|
689
|
+
// lands at the grace either way.
|
|
690
|
+
killProcessTree(proc, 'SIGCONT', treeIo(() => table));
|
|
679
691
|
// A process parked in a system call may not act on SIGTERM. SIGKILL the
|
|
680
692
|
// child, its group when it leads one, and its descendants.
|
|
681
693
|
stallKill = setTimeout(() => {
|
|
@@ -704,6 +716,9 @@ const runClaude = ({
|
|
|
704
716
|
clearTimeout(timer);
|
|
705
717
|
clearTimeout(stallKill);
|
|
706
718
|
watchdog.stop();
|
|
719
|
+
const reaped = describeTurnReap(turn.reap(proc));
|
|
720
|
+
// eslint-disable-next-line no-console
|
|
721
|
+
if (reaped) console.warn(`[claude] ${reaped}`);
|
|
707
722
|
if (stalled) {
|
|
708
723
|
sweepStallSurvivors();
|
|
709
724
|
return reject(stalled);
|
|
@@ -1411,6 +1426,7 @@ export default {
|
|
|
1411
1426
|
stallKillGraceMs: ctx._stallKillGraceMs,
|
|
1412
1427
|
processTable: ctx._processTable,
|
|
1413
1428
|
survivorIo: ctx._survivorIo,
|
|
1429
|
+
turnReap: ctx._turnReap,
|
|
1414
1430
|
});
|
|
1415
1431
|
// TASK-440: the context this turn left, read from the transcript the
|
|
1416
1432
|
// child wrote under its own env (an isolated HOME for public seats).
|
|
@@ -1479,6 +1495,7 @@ export default {
|
|
|
1479
1495
|
stallKillGraceMs: ctx._stallKillGraceMs,
|
|
1480
1496
|
processTable: ctx._processTable,
|
|
1481
1497
|
survivorIo: ctx._survivorIo,
|
|
1498
|
+
turnReap: ctx._turnReap,
|
|
1482
1499
|
});
|
|
1483
1500
|
const contextTokens = claudeContextTokens({
|
|
1484
1501
|
cwd: physicalPath(ctx.cwd), sessionId: freshId,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { codexRuntimeInventory } from '../native-runtime-inventory.js';
|
|
2
2
|
import { observeSpawn, controlsFromArguments } from '../runtime-observation.js';
|
|
3
3
|
import { spawnInOwnGroup, wantsOwnProcessGroup } from '@commonlyai/seats/process-group';
|
|
4
|
+
import { describeTurnReap, markTurn, turnReapEnabled } from '@commonlyai/seats/turn-processes';
|
|
4
5
|
/**
|
|
5
6
|
* codex adapter — wraps the local `codex` CLI as a Commonly agent.
|
|
6
7
|
*
|
|
@@ -73,7 +74,7 @@ import { CREDENTIAL_KEY, writeCredentialFile } from '../credential-file.js';
|
|
|
73
74
|
import { supportedGitHooks } from '../commit-attribution.js';
|
|
74
75
|
import { deliverSeatCredential, withholdRuntimeCredential } from '../mcp-credential-delivery.js';
|
|
75
76
|
import { prepareMcpSpawn, publicMcpUnavailableError, selectedMcpVersionDir } from '../mcp-home.js';
|
|
76
|
-
import { buildMemoryPreamble, ROW_SLICING_CUE } from '../memory-bridge.js';
|
|
77
|
+
import { BACKGROUND_PROCESS_CUE, buildMemoryPreamble, ROW_SLICING_CUE } from '../memory-bridge.js';
|
|
77
78
|
import { resolveSkillSource, skillsAsText } from '../skill-prompt.js';
|
|
78
79
|
import { isLegacySandboxTrust, normalizeSandboxTrust, refuseWorkspaceTrust } from '../environment.js';
|
|
79
80
|
import { adapterFailure, spawnCredentials } from '../upstream-refusal.js';
|
|
@@ -731,14 +732,19 @@ const CODEX_PROVIDER_KEY_ENVS = [
|
|
|
731
732
|
];
|
|
732
733
|
|
|
733
734
|
// `ownGroup`: see runClaude in claude.js.
|
|
734
|
-
const runCodex = ({
|
|
735
|
+
const runCodex = ({
|
|
736
|
+
args, cwd, env, timeoutMs, credentials = [], spawnImpl = childSpawn, onSpawn, controls = {}, ownGroup = false,
|
|
737
|
+
turnReap = turnReapEnabled(),
|
|
738
|
+
}) => new Promise((resolve, reject) => { // stdio: ['ignore', 'pipe', 'pipe'] — without this, child_process.spawn
|
|
735
739
|
// defaults stdin to a fresh pipe. Codex 0.125.0's `exec` then blocks on
|
|
736
740
|
// `Reading additional input from stdin...` because it sees an open pipe
|
|
737
741
|
// and waits for input that never arrives. Interactive runs are fine because
|
|
738
742
|
// codex detects a TTY and uses the argv prompt directly. Setting stdin to
|
|
739
743
|
// `'ignore'` gives codex /dev/null → immediate EOF → it falls back to the
|
|
740
744
|
// argv prompt as intended. Surfaced live during ADR-005 Phase 2 smoke.
|
|
741
|
-
|
|
745
|
+
// What the turn leaves running is stopped once it has closed (see claude.js).
|
|
746
|
+
const turn = markTurn(env ?? process.env, { enabled: turnReap });
|
|
747
|
+
const proc = spawnInOwnGroup(spawnImpl, 'codex', args, { cwd, env: turn.env, stdio: ['ignore', 'pipe', 'pipe'] }, { ownGroup });
|
|
742
748
|
observeSpawn(proc, onSpawn, controls);
|
|
743
749
|
let stderr = '';
|
|
744
750
|
let timedOut = false;
|
|
@@ -757,6 +763,9 @@ const runCodex = ({ args, cwd, env, timeoutMs, credentials = [], spawnImpl = chi
|
|
|
757
763
|
});
|
|
758
764
|
proc.on('close', (code) => {
|
|
759
765
|
clearTimeout(timer);
|
|
766
|
+
const reaped = describeTurnReap(turn.reap(proc));
|
|
767
|
+
// eslint-disable-next-line no-console
|
|
768
|
+
if (reaped) console.warn(`[codex] ${reaped}`);
|
|
760
769
|
if (timedOut) return reject(new Error(`codex timed out after ${timeoutMs}ms`));
|
|
761
770
|
if (events.turnFailedMessage) {
|
|
762
771
|
// Surface the model-side failure message verbatim — the run loop posts
|
|
@@ -823,7 +832,7 @@ export default {
|
|
|
823
832
|
// answers strangers rather than working board rows, so it is taught neither.
|
|
824
833
|
const fullPrompt = buildPrompt(prompt, ctx.memoryLongTerm, {
|
|
825
834
|
freshSession: !ctx.sessionId,
|
|
826
|
-
workCues: sandbox?.trust === 'public' ? [] : [ROW_SLICING_CUE],
|
|
835
|
+
workCues: sandbox?.trust === 'public' ? [] : [ROW_SLICING_CUE, BACKGROUND_PROCESS_CUE],
|
|
827
836
|
});
|
|
828
837
|
|
|
829
838
|
// Per-spawn temp dir for --output-last-message. Cleaned up in `finally`
|
|
@@ -917,6 +926,7 @@ export default {
|
|
|
917
926
|
credentials: spawnCredentials(ctx, CODEX_PROVIDER_KEY_ENVS),
|
|
918
927
|
spawnImpl: ctx._spawnImpl, onSpawn: ctx.onSpawn, controls: controlsFromArguments(args.slice(args.indexOf('exec') + (args[args.indexOf('exec') + 1] === 'resume' ? 3 : 1), -1), { modelFlag: '--model', valueFlags: ['-c', '-o', '--permission-profile'], codex: true }), // test seam only — do not use in production
|
|
919
928
|
ownGroup: wantsOwnProcessGroup(ctx),
|
|
929
|
+
turnReap: ctx._turnReap,
|
|
920
930
|
});
|
|
921
931
|
|
|
922
932
|
let text = '';
|
|
@@ -77,6 +77,17 @@ export const physicalPath = (dir) => {
|
|
|
77
77
|
// How long a stalled spawn gets to exit after SIGTERM before SIGKILL.
|
|
78
78
|
export const STALL_KILL_GRACE_MS = 10_000;
|
|
79
79
|
|
|
80
|
+
// The boot line for a claude seat. This watchdog is the stall guard a claude
|
|
81
|
+
// turn runs: the seats supervisor's stall window is off for claude
|
|
82
|
+
// (STALL_DEFAULTS_MS in supervised-turn.js), and its package default of 120 s
|
|
83
|
+
// was once read as this one's (the G6 walk, 2026-11-01).
|
|
84
|
+
export const describeFirstOutputWatch = (env = process.env) => {
|
|
85
|
+
const windowMs = firstOutputWindowMs(env);
|
|
86
|
+
if (!windowMs) return 'first-output watchdog OFF for claude (COMMONLY_FIRST_OUTPUT_WINDOW_MS=0)';
|
|
87
|
+
return `first-output watchdog ON for claude: a turn with no model output in ${windowMs}ms is killed `
|
|
88
|
+
+ `(SIGTERM, then SIGKILL ${STALL_KILL_GRACE_MS}ms later if it is still alive)`;
|
|
89
|
+
};
|
|
90
|
+
|
|
80
91
|
/**
|
|
81
92
|
* SIGKILL each snapshotted descendant of a stalled spawn that is still alive
|
|
82
93
|
* once the spawn itself has exited. Its parent may have exited, so it may now
|
package/src/lib/memory-bridge.js
CHANGED
|
@@ -20,6 +20,8 @@
|
|
|
20
20
|
* them from the client is wasted bytes and the kernel discards them.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
+
import { KEEP_PROCESS_ENV } from '@commonlyai/seats/turn-processes';
|
|
24
|
+
|
|
23
25
|
export const SOURCE_RUNTIME = 'local-cli';
|
|
24
26
|
|
|
25
27
|
/**
|
|
@@ -34,6 +36,13 @@ export const ROW_SLICING_CUE = 'Slicing: when a row is too large for one turn, s
|
|
|
34
36
|
+ "to the row's taskId) with one assignee, a written file or area boundary, and its own "
|
|
35
37
|
+ 'acceptance line.';
|
|
36
38
|
|
|
39
|
+
// The turn's leftovers are stopped when it ends (@commonlyai/seats/turn-processes),
|
|
40
|
+
// so a seat that means to reuse a server has to say so when it starts it.
|
|
41
|
+
export const BACKGROUND_PROCESS_CUE = 'Background processes: anything you start in the background is '
|
|
42
|
+
+ 'stopped when this turn ends, including a server that starts on first use, such as tmux, and an agent you '
|
|
43
|
+
+ 'restart with nohup. To keep one for a later turn, such as a dev server you will reuse, start it '
|
|
44
|
+
+ `with ${KEEP_PROCESS_ENV}=1 set, and stop it yourself when you are done with it.`;
|
|
45
|
+
|
|
37
46
|
/**
|
|
38
47
|
* The turn preamble, shared by every adapter so the two cannot drift.
|
|
39
48
|
*
|