@lyupro/codex-bridge 0.6.2 → 0.6.4
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/CHANGELOG.md +101 -0
- package/README.md +1 -1
- package/cli/runs-inventory.mjs +5 -3
- package/cli/stop.mjs +2 -2
- package/cli/unlock.mjs +2 -2
- package/package.json +2 -2
- package/src/home/hooks/live-runs.mjs +19 -21
- package/src/home/hooks/reply-guard.mjs +5 -2
- package/src/home/hooks/shell-write-intent.mjs +320 -154
- package/src/home/hooks/worktree-lock.mjs +9 -2
- package/src/home/hooks/worktree-witness.mjs +21 -40
- package/src/home/lib/meta/events.mjs +4 -0
- package/src/home/lib/meta/launch-rows.mjs +45 -0
- package/src/home/lib/meta/reply.mjs +1 -32
- package/src/home/lib/meta/run-liveness.mjs +64 -0
- package/src/home/lib/meta/run-state.mjs +26 -34
- package/src/home/lib/meta/verdict.mjs +11 -0
- package/src/home/lib/runner/args.mjs +7 -4
- package/src/home/lib/runner/attach.mjs +5 -13
- package/src/home/lib/runner/codex-args.mjs +11 -3
- package/src/home/lib/runner/codex-cmd.mjs +104 -18
- package/src/home/lib/runner/git-state.mjs +6 -1
- package/src/home/lib/runner/launcher.mjs +20 -27
- package/src/home/lib/runner/preflight.mjs +35 -0
- package/src/home/lib/runner/sandbox-probe.mjs +109 -0
- package/src/home/lib/runner/scope-check.mjs +22 -2
- package/src/home/lib/write-meta.mjs +5 -1
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** Reply rows about facts the launcher recorded in status.json before the run began. */
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { line, readJson } from './paths.mjs';
|
|
4
|
+
|
|
5
|
+
function formatBytes(bytes) {
|
|
6
|
+
const units = ['B', 'KB', 'MB', 'GB'];
|
|
7
|
+
let value = bytes;
|
|
8
|
+
let index = 0;
|
|
9
|
+
while (value >= 1024 && index < units.length - 1) {
|
|
10
|
+
value /= 1024;
|
|
11
|
+
index += 1;
|
|
12
|
+
}
|
|
13
|
+
return `${value.toFixed(1)} ${units[index]}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function retentionReply(runDir) {
|
|
17
|
+
const retention = readJson(path.join(runDir, 'status.json'))?.retention;
|
|
18
|
+
const bytes = Number(retention?.bytes_freed);
|
|
19
|
+
const runs = Number(retention?.runs);
|
|
20
|
+
const days = Number(retention?.days);
|
|
21
|
+
if (!Number.isFinite(bytes) || bytes <= 0 || !Number.isFinite(runs) || runs <= 0
|
|
22
|
+
|| !Number.isFinite(days) || days <= 0) return null;
|
|
23
|
+
return `Retention: freed ${formatBytes(bytes)} from ${runs} runs older than ${days} days`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Five copies of Retention missed writeFailure(), so both reply assemblers share this point.
|
|
27
|
+
// The 2026-09-16 dead Codex sandbox incident is why the launcher probes the sandbox at all.
|
|
28
|
+
export function launchRows(runDir) {
|
|
29
|
+
const retention = retentionReply(runDir);
|
|
30
|
+
const rows = retention ? [retention] : [];
|
|
31
|
+
const probe = readJson(path.join(runDir, 'status.json'))?.sandbox_probe;
|
|
32
|
+
if (probe?.outcome === 'inconclusive') {
|
|
33
|
+
const reason = line(probe.reason, 160) || 'no reason recorded';
|
|
34
|
+
rows.push(`Sandbox probe: inconclusive — ${reason} The run started without a sandbox check.`);
|
|
35
|
+
}
|
|
36
|
+
return rows;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function withLaunchRows(rows, runDir) {
|
|
40
|
+
const launch = launchRows(runDir);
|
|
41
|
+
if (!launch.length) return rows;
|
|
42
|
+
let at = rows.findIndex((row) => row.includes('Log: '));
|
|
43
|
+
if (at === -1) at = rows.findIndex((row) => row.startsWith('Run: '));
|
|
44
|
+
return at === -1 ? [...rows, ...launch] : [...rows.slice(0, at), ...launch, ...rows.slice(at)];
|
|
45
|
+
}
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import fs from 'node:fs';
|
|
13
13
|
import path from 'node:path';
|
|
14
14
|
import { AGENTS as AGENT_DEFINITIONS } from '../agents.mjs';
|
|
15
|
-
import { changedPaths, line,
|
|
15
|
+
import { changedPaths, line, readText } from './paths.mjs';
|
|
16
16
|
import { splitRunChanges } from './environment.mjs';
|
|
17
17
|
import { scoutCoverage } from './verdict.mjs';
|
|
18
18
|
|
|
@@ -25,27 +25,6 @@ const runChanges = (runDir) =>
|
|
|
25
25
|
|
|
26
26
|
const readCommand = (runDir) => `codex-bridge read ${runDir}`;
|
|
27
27
|
|
|
28
|
-
function formatBytes(bytes) {
|
|
29
|
-
const units = ['B', 'KB', 'MB', 'GB'];
|
|
30
|
-
let value = bytes;
|
|
31
|
-
let index = 0;
|
|
32
|
-
while (value >= 1024 && index < units.length - 1) {
|
|
33
|
-
value /= 1024;
|
|
34
|
-
index += 1;
|
|
35
|
-
}
|
|
36
|
-
return `${value.toFixed(1)} ${units[index]}`;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function retentionReply(runDir) {
|
|
40
|
-
const retention = readJson(path.join(runDir, 'status.json'))?.retention;
|
|
41
|
-
const bytes = Number(retention?.bytes_freed);
|
|
42
|
-
const runs = Number(retention?.runs);
|
|
43
|
-
const days = Number(retention?.days);
|
|
44
|
-
if (!Number.isFinite(bytes) || bytes <= 0 || !Number.isFinite(runs) || runs <= 0
|
|
45
|
-
|| !Number.isFinite(days) || days <= 0) return null;
|
|
46
|
-
return `Retention: freed ${formatBytes(bytes)} from ${runs} runs older than ${days} days`;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
28
|
export const AGENTS = {
|
|
50
29
|
'codex-scout': {
|
|
51
30
|
...AGENT_DEFINITIONS['codex-scout'],
|
|
@@ -69,14 +48,12 @@ function scoutReply(ctx) {
|
|
|
69
48
|
const top = (r.findings || [])[0];
|
|
70
49
|
const unknowns = (r.unknowns || []).filter(Boolean);
|
|
71
50
|
const coverage = scoutCoverage(ctx.runDir, r);
|
|
72
|
-
const retention = retentionReply(ctx.runDir);
|
|
73
51
|
return [
|
|
74
52
|
`OK — ${line(r.answer, 160)}`,
|
|
75
53
|
// Any explicit question gets a coverage line, including a valid one-question order.
|
|
76
54
|
...(coverage ? [`Coverage: ${coverage}`] : []),
|
|
77
55
|
`Key finding: ${top ? `${line(top.fact, 130)} (${line(top.where, 60)})` : 'no findings listed'}`,
|
|
78
56
|
`Unresolved: ${unknowns.length ? line(unknowns.join('; '), 160) : 'none'}`,
|
|
79
|
-
...(retention ? [retention] : []),
|
|
80
57
|
`Report: ${ctx.file('report.md')} · Log: ${readCommand(ctx.runDir)}`,
|
|
81
58
|
];
|
|
82
59
|
}
|
|
@@ -107,7 +84,6 @@ function buildReply(ctx) {
|
|
|
107
84
|
const files = ctx.carried
|
|
108
85
|
? `${paths ? `${paths} · ` : ''}changes were made by an earlier run of this task`
|
|
109
86
|
: paths || 'worktree untouched';
|
|
110
|
-
const retention = retentionReply(ctx.runDir);
|
|
111
87
|
return [
|
|
112
88
|
`OK — ${line(r.summary, 300)}`,
|
|
113
89
|
`Files: ${touchedPaths.length} changed · ${files}`,
|
|
@@ -118,7 +94,6 @@ function buildReply(ctx) {
|
|
|
118
94
|
: []),
|
|
119
95
|
`Verification: ${verify} — ${verdict}`,
|
|
120
96
|
`Flags: ${flags.length ? `${flags.length} TODO/skip — ${line(flags.slice(0, 3).join(' | '), 140)}` : 'none'}`,
|
|
121
|
-
...(retention ? [retention] : []),
|
|
122
97
|
`Report: ${ctx.file('report.md')} · Log: ${readCommand(ctx.runDir)}`,
|
|
123
98
|
];
|
|
124
99
|
}
|
|
@@ -130,12 +105,10 @@ function reviewReply(ctx) {
|
|
|
130
105
|
if (counts[f.severity] !== undefined) counts[f.severity] += 1;
|
|
131
106
|
});
|
|
132
107
|
const top = (r.findings || []).find((f) => f.severity === 'critical' || f.severity === 'high');
|
|
133
|
-
const retention = retentionReply(ctx.runDir);
|
|
134
108
|
return [
|
|
135
109
|
`OK — verdict ${line(r.verdict, 40)}`,
|
|
136
110
|
`Findings: critical ${counts.critical} · high ${counts.high} · medium ${counts.medium} · low ${counts.low}`,
|
|
137
111
|
`Top: ${top ? `${top.severity} ${line(top.file, 80)}:${top.line_start} — ${line(top.title, 90)}` : 'no critical or high findings'}`,
|
|
138
|
-
...(retention ? [retention] : []),
|
|
139
112
|
`Report: ${ctx.file(path.basename(ctx.resultPath))} · Log: ${readCommand(ctx.runDir)}`,
|
|
140
113
|
];
|
|
141
114
|
}
|
|
@@ -168,7 +141,6 @@ export function withProfileRow(rows, meta) {
|
|
|
168
141
|
}
|
|
169
142
|
|
|
170
143
|
export function failReply(ctx, meta) {
|
|
171
|
-
const retention = retentionReply(ctx.runDir);
|
|
172
144
|
return [
|
|
173
145
|
`FAIL — ${line(meta.reason, 170)}`,
|
|
174
146
|
`Artifacts: events.jsonl ${meta.events_bytes} B · stderr.log ${meta.stderr_bytes} B · ${path.basename(ctx.resultPath)} ${meta.result_ok ? 'filled' : 'empty or missing'} · exit=${meta.exit}`,
|
|
@@ -177,7 +149,6 @@ export function failReply(ctx, meta) {
|
|
|
177
149
|
// and the orchestrator has to know whether there is something to revert before it decides
|
|
178
150
|
// anything else.
|
|
179
151
|
...(ctx.agent === 'codex-build' ? [`Worktree: ${worktreeState(ctx.runDir)}`] : []),
|
|
180
|
-
...(retention ? [retention] : []),
|
|
181
152
|
`Log: ${readCommand(ctx.runDir)}`,
|
|
182
153
|
];
|
|
183
154
|
}
|
|
@@ -200,7 +171,6 @@ function worktreeState(runDir) {
|
|
|
200
171
|
}
|
|
201
172
|
|
|
202
173
|
export function limitReply(ctx, meta) {
|
|
203
|
-
const retention = retentionReply(ctx.runDir);
|
|
204
174
|
const rows = [
|
|
205
175
|
'LIMIT — ChatGPT quota exhausted, work not completed',
|
|
206
176
|
`Signal: ${line(meta.reason, 170)}`,
|
|
@@ -211,7 +181,6 @@ export function limitReply(ctx, meta) {
|
|
|
211
181
|
`Worktree: ${touched.length ? `has unfinished changes (${touched.length}), see git-after.txt` : 'no new changes'}`,
|
|
212
182
|
);
|
|
213
183
|
}
|
|
214
|
-
if (retention) rows.push(retention);
|
|
215
184
|
rows.push(`Log: ${readCommand(ctx.runDir)}`);
|
|
216
185
|
return rows;
|
|
217
186
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Judges whether a recorded run is still live and what state its artifacts support.
|
|
3
|
+
*
|
|
4
|
+
* Plan_57 D27 centralizes this rule after reply-guard's bare-pid check mistook a reused
|
|
5
|
+
* Windows pid for a live run, while the project list printed `running` for a dead run.
|
|
6
|
+
* Requiring the run folder keeps identity and artifact evidence in every judgment.
|
|
7
|
+
*/
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { heartbeatAge } from '../heartbeat.mjs';
|
|
11
|
+
import {
|
|
12
|
+
IDENTITY_ALIVE,
|
|
13
|
+
IDENTITY_DEAD,
|
|
14
|
+
IDENTITY_FOREIGN,
|
|
15
|
+
IDENTITY_UNVERIFIED,
|
|
16
|
+
processIdentity,
|
|
17
|
+
} from '../process-identity.mjs';
|
|
18
|
+
import { readJson } from './paths.mjs';
|
|
19
|
+
|
|
20
|
+
// Fail open: treating an unverified process as dead could close a live run and make
|
|
21
|
+
// markAbandoned a second writer of its meta.json, or let another paid run write into
|
|
22
|
+
// the same tree (the 2026-08-05 incident). Only a known-dead writer permits either.
|
|
23
|
+
function identityMayBeAlive(identity) {
|
|
24
|
+
return identity === IDENTITY_ALIVE || identity === IDENTITY_UNVERIFIED;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function runLiveness({ runDir, status, ...identityOptions }) {
|
|
28
|
+
if (typeof runDir !== 'string' || !runDir.trim()) {
|
|
29
|
+
throw new TypeError('runLiveness requires a non-empty runDir string');
|
|
30
|
+
}
|
|
31
|
+
if (status === undefined) status = readJson(path.join(runDir, 'status.json'));
|
|
32
|
+
const recordedState = status && typeof status === 'object' && typeof status.state === 'string'
|
|
33
|
+
? status.state
|
|
34
|
+
: null;
|
|
35
|
+
const heartbeatAgeMs = heartbeatAge(runDir, identityOptions.now);
|
|
36
|
+
if (recordedState !== 'running') {
|
|
37
|
+
return { recordedState, identity: null, processMayBeAlive: null, heartbeatAgeMs, state: recordedState };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const identity = processIdentity({ runDir, status, ...identityOptions });
|
|
41
|
+
const processMayBeAlive = identityMayBeAlive(identity);
|
|
42
|
+
let state = identity === IDENTITY_ALIVE ? 'running' : 'unverified';
|
|
43
|
+
if (identity === IDENTITY_DEAD || identity === IDENTITY_FOREIGN) {
|
|
44
|
+
const metaPath = path.join(runDir, 'meta.json');
|
|
45
|
+
const meta = readJson(metaPath);
|
|
46
|
+
state = meta ? 'finished' : fs.existsSync(metaPath) ? 'unverified' : 'abandoned';
|
|
47
|
+
}
|
|
48
|
+
return { recordedState, identity, processMayBeAlive, heartbeatAgeMs, state };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* D28: a worker can still be writing reply.txt after status.json records finished.
|
|
53
|
+
* This is for waiting on that worker during the closing window, not deciding the
|
|
54
|
+
* record's state; runLiveness preserves already-closed records without probing.
|
|
55
|
+
* @returns {boolean} Whether the recorded worker may still be alive, regardless of status.state.
|
|
56
|
+
*/
|
|
57
|
+
export function workerMayBeAlive({ runDir, status, ...identityOptions }) {
|
|
58
|
+
if (typeof runDir !== 'string' || !runDir.trim()) {
|
|
59
|
+
throw new TypeError('workerMayBeAlive requires a non-empty runDir string');
|
|
60
|
+
}
|
|
61
|
+
if (status === undefined) status = readJson(path.join(runDir, 'status.json'));
|
|
62
|
+
if (!status || typeof status !== 'object') return false;
|
|
63
|
+
return identityMayBeAlive(processIdentity({ runDir, status, ...identityOptions }));
|
|
64
|
+
}
|
|
@@ -10,22 +10,25 @@
|
|
|
10
10
|
import fs from 'node:fs';
|
|
11
11
|
import path from 'node:path';
|
|
12
12
|
import { changedPaths, line, normalizePath, readJson, readText, size } from './paths.mjs';
|
|
13
|
-
import {
|
|
14
|
-
|
|
15
|
-
IDENTITY_UNVERIFIED,
|
|
16
|
-
processIdentity,
|
|
17
|
-
} from '../process-identity.mjs';
|
|
13
|
+
import { withLaunchRows } from './launch-rows.mjs';
|
|
14
|
+
import { runLiveness } from './run-liveness.mjs';
|
|
18
15
|
|
|
19
16
|
/**
|
|
20
|
-
*
|
|
21
|
-
*
|
|
17
|
+
* Run state on disk, merged over whatever is already there. A killed runner leaves no
|
|
18
|
+
* report and no meta.json, and four abandoned folders from one order were indistinguishable
|
|
19
|
+
* from four runs still working — status.json is what makes that difference visible.
|
|
20
|
+
*
|
|
21
|
+
* It writes status.json and nothing else. Housekeeping facts such as `retention` live here and
|
|
22
|
+
* are read from here; an earlier attempt also appended them to meta.json, which would have made
|
|
23
|
+
* two functions write the same artifact and put the documented artifact order (meta.json before
|
|
24
|
+
* status.json is closed) at the mercy of whoever ran last.
|
|
22
25
|
*/
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
return
|
|
28
|
-
}
|
|
26
|
+
export function writeStatus(runDir, patch) {
|
|
27
|
+
const current = readJson(path.join(runDir, 'status.json')) || {};
|
|
28
|
+
const next = { ...current, ...patch };
|
|
29
|
+
fs.writeFileSync(path.join(runDir, 'status.json'), `${JSON.stringify(next, null, 2)}\n`);
|
|
30
|
+
return next;
|
|
31
|
+
}
|
|
29
32
|
|
|
30
33
|
/**
|
|
31
34
|
* This module deliberately judges by the run's pid identity alone, while hooks/live-runs.mjs also
|
|
@@ -43,23 +46,6 @@ const pidAlive = (pid, runDir, status = {}) => {
|
|
|
43
46
|
* records the verdict — one writer, in the right order.
|
|
44
47
|
*/
|
|
45
48
|
|
|
46
|
-
/**
|
|
47
|
-
* Run state on disk, merged over whatever is already there. A killed runner leaves no
|
|
48
|
-
* report and no meta.json, and four abandoned folders from one order were indistinguishable
|
|
49
|
-
* from four runs still working — status.json is what makes that difference visible.
|
|
50
|
-
*
|
|
51
|
-
* It writes status.json and nothing else. Housekeeping facts such as `retention` live here and
|
|
52
|
-
* are read from here; an earlier attempt also appended them to meta.json, which would have made
|
|
53
|
-
* two functions write the same artifact and put the documented artifact order (meta.json before
|
|
54
|
-
* status.json is closed) at the mercy of whoever ran last.
|
|
55
|
-
*/
|
|
56
|
-
export function writeStatus(runDir, patch) {
|
|
57
|
-
const current = readJson(path.join(runDir, 'status.json')) || {};
|
|
58
|
-
const next = { ...current, ...patch };
|
|
59
|
-
fs.writeFileSync(path.join(runDir, 'status.json'), `${JSON.stringify(next, null, 2)}\n`);
|
|
60
|
-
return next;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
49
|
/**
|
|
64
50
|
* Closes out runs whose runner died without writing a verdict. Called at the start of every
|
|
65
51
|
* run, so a folder left by a killed dispatcher gets an explicit state instead of reading as
|
|
@@ -83,9 +69,12 @@ export function markAbandoned(runsRoot, currentTree) {
|
|
|
83
69
|
const runDir = path.join(runsRoot, entry.name);
|
|
84
70
|
const status = readJson(path.join(runDir, 'status.json'));
|
|
85
71
|
if (!status || status.state !== 'running') continue;
|
|
86
|
-
|
|
72
|
+
const liveness = runLiveness({ runDir, status });
|
|
73
|
+
if (liveness.state !== 'abandoned' && liveness.state !== 'finished') continue;
|
|
87
74
|
const metaPath = path.join(runDir, 'meta.json');
|
|
88
75
|
const meta = readJson(metaPath);
|
|
76
|
+
// The judge read meta.json already; this is a second read, and a failure here must still never
|
|
77
|
+
// turn an existing verdict into an abandoned FAIL (review 2026-09-17, Plan_57 D28).
|
|
89
78
|
if (!meta && fs.existsSync(metaPath)) continue;
|
|
90
79
|
let patch = meta
|
|
91
80
|
? { state: 'finished', status: meta.status, finished_at: meta.finished_at }
|
|
@@ -159,8 +148,8 @@ export function activeRunDetails(runsRoot, repo, agent = 'codex-build') {
|
|
|
159
148
|
const status = readJson(path.join(runDir, 'status.json'));
|
|
160
149
|
if (!status || status.state !== 'running' || status.agent !== agent) continue;
|
|
161
150
|
if (normalizePath(status.repo) !== wanted) continue;
|
|
162
|
-
const identity =
|
|
163
|
-
if (
|
|
151
|
+
const { identity, processMayBeAlive } = runLiveness({ runDir, status });
|
|
152
|
+
if (processMayBeAlive) return { run: entry.name, identity };
|
|
164
153
|
}
|
|
165
154
|
return null;
|
|
166
155
|
}
|
|
@@ -230,6 +219,9 @@ export function writeFailure(runDir, agent, reason, extraLines = [], preStart =
|
|
|
230
219
|
status: 'FAIL',
|
|
231
220
|
finished_at: meta.finished_at,
|
|
232
221
|
});
|
|
233
|
-
const reply =
|
|
222
|
+
const reply = withLaunchRows(
|
|
223
|
+
[`FAIL — ${meta.reason}`, ...extraLines, `Run: ${runDir}`],
|
|
224
|
+
runDir,
|
|
225
|
+
).join('\n');
|
|
234
226
|
return { meta, reply };
|
|
235
227
|
}
|
|
@@ -315,6 +315,17 @@ export function resolveStatus({ resultOk, exit, agent, result, runDir, events })
|
|
|
315
315
|
// Answers that never arrived, or arrived as coordinates. Scout-only, and per-question
|
|
316
316
|
// only when questions.json is absent — see scoutCoverageGap().
|
|
317
317
|
if (agent === 'codex-scout') {
|
|
318
|
+
// The 2026-09-16 dead sandbox produced nonempty evidence without reading code. Require an
|
|
319
|
+
// execution fact first: checking whether evidence paths existed falsely rejected 64/151 runs.
|
|
320
|
+
// Only a run that left a stream can prove it: an archived run from before `--json` has none and
|
|
321
|
+
// is judged by the contract of its day; a `--json` run missing its stream already failed above.
|
|
322
|
+
if (eventData.hasStream && eventData.commands_executed === 0) {
|
|
323
|
+
return {
|
|
324
|
+
status: 'FAIL',
|
|
325
|
+
reason: 'scout executed no command, so no answer rests on reading the code; '
|
|
326
|
+
+ 'check stderr.log for sandbox refusals before changing the order',
|
|
327
|
+
};
|
|
328
|
+
}
|
|
318
329
|
const gap = scoutCoverageGap(runDir, result);
|
|
319
330
|
if (gap) return { status: 'FAIL', reason: gap };
|
|
320
331
|
}
|
|
@@ -13,15 +13,18 @@ import { firstShellUnsafeSequence } from '../shell-unsafe.mjs';
|
|
|
13
13
|
import { parseTaskDocument } from './task-file.mjs';
|
|
14
14
|
|
|
15
15
|
export class RunnerUsageError extends Error {
|
|
16
|
-
|
|
16
|
+
// 2 says the order itself is wrong and has to be rewritten. A refusal about the state of the
|
|
17
|
+
// host or the worktree passes 1: the order was fine, nothing about it needs changing, and
|
|
18
|
+
// Plan_58 moved two such refusals here from a code path that already answered 1.
|
|
19
|
+
constructor(message, exitCode = 2) {
|
|
17
20
|
super(message);
|
|
18
|
-
this.exitCode =
|
|
21
|
+
this.exitCode = exitCode;
|
|
19
22
|
}
|
|
20
23
|
}
|
|
21
24
|
|
|
22
|
-
export function die(message) {
|
|
25
|
+
export function die(message, exitCode = 2) {
|
|
23
26
|
console.error(`run-codex: ${message}`);
|
|
24
|
-
throw new RunnerUsageError(message);
|
|
27
|
+
throw new RunnerUsageError(message, exitCode);
|
|
25
28
|
}
|
|
26
29
|
|
|
27
30
|
export function readTaskDocument(opts) {
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { readJsonFileSync } from '../json-file.mjs';
|
|
5
|
-
import {
|
|
5
|
+
import { workerMayBeAlive } from '../meta/run-liveness.mjs';
|
|
6
|
+
import { processAlive } from '../process-identity.mjs';
|
|
6
7
|
import { exitCodeFor, writeFailure, chainRuns } from '../write-meta.mjs';
|
|
7
8
|
import { conflictingOrderOwner, runsForOrder } from './order-owner.mjs';
|
|
8
9
|
|
|
@@ -52,15 +53,6 @@ export const readJsonFile = (file) => {
|
|
|
52
53
|
}
|
|
53
54
|
};
|
|
54
55
|
|
|
55
|
-
/**
|
|
56
|
-
* Is this pid still running for this run? Unknown identity remains live so an attach does not
|
|
57
|
-
* declare a worker dead merely because the identity probe was unavailable.
|
|
58
|
-
*/
|
|
59
|
-
export const alive = (pid, runDir, status = {}) => {
|
|
60
|
-
const identity = processIdentity({ runDir, status: { ...status, pid } });
|
|
61
|
-
return identity !== IDENTITY_DEAD && identity !== IDENTITY_FOREIGN;
|
|
62
|
-
};
|
|
63
|
-
|
|
64
56
|
/**
|
|
65
57
|
* Blocks until the worker's reply exists, or until the worker is provably gone without one.
|
|
66
58
|
*
|
|
@@ -69,9 +61,9 @@ export const alive = (pid, runDir, status = {}) => {
|
|
|
69
61
|
* probe for the whole length of a 20-minute run. Inside the loop the question is only whether the
|
|
70
62
|
* number is still busy, which signal 0 answers for free.
|
|
71
63
|
*/
|
|
72
|
-
export async function waitForReply(runDir, workerPid, status
|
|
64
|
+
export async function waitForReply(runDir, workerPid, status) {
|
|
73
65
|
const replyPath = path.join(runDir, 'reply.txt');
|
|
74
|
-
if (!
|
|
66
|
+
if (!workerMayBeAlive({ runDir, status })) {
|
|
75
67
|
return fs.existsSync(replyPath) ? fs.readFileSync(replyPath, 'utf8').replace(/\s+$/, '') : null;
|
|
76
68
|
}
|
|
77
69
|
let pollsSinceDeath = 0;
|
|
@@ -136,7 +128,7 @@ export async function attach({ runsRoot, repo, slug, taskHash, orderId, chain, i
|
|
|
136
128
|
console.log(fs.readFileSync(path.join(dir, 'reply.txt'), 'utf8').replace(/\s+$/, ''));
|
|
137
129
|
return exitCodeFor(readJsonFile(path.join(dir, 'meta.json'))?.status);
|
|
138
130
|
}
|
|
139
|
-
if (
|
|
131
|
+
if (workerMayBeAlive({ runDir: dir, status: entry.status })) {
|
|
140
132
|
candidate = entry;
|
|
141
133
|
break;
|
|
142
134
|
}
|
|
@@ -60,7 +60,15 @@ export function runProfile(opts) {
|
|
|
60
60
|
};
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
// Plan_57: the preflight must probe the same permissions that the role will request.
|
|
64
|
+
export function sandboxModeFor(agent) {
|
|
65
|
+
if (agent === 'codex-build') return 'workspace-write';
|
|
66
|
+
if (agent === 'codex-scout' || agent === 'codex-review') return 'read-only';
|
|
67
|
+
throw new Error(`unknown agent for sandbox mode: ${agent}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
63
70
|
export function codexArgs(opts, runDir, isGitRepo) {
|
|
71
|
+
const sandboxMode = sandboxModeFor(opts.agent);
|
|
64
72
|
const schema = path.join(runDir, 'schema.json');
|
|
65
73
|
const profile = runProfile(opts);
|
|
66
74
|
const effort = `model_reasoning_effort=${profile.effort}`;
|
|
@@ -80,7 +88,7 @@ export function codexArgs(opts, runDir, isGitRepo) {
|
|
|
80
88
|
...platformSandboxArgs(),
|
|
81
89
|
...modelArgs,
|
|
82
90
|
'--sandbox',
|
|
83
|
-
|
|
91
|
+
sandboxMode,
|
|
84
92
|
'--skip-git-repo-check',
|
|
85
93
|
'-C',
|
|
86
94
|
opts.repo,
|
|
@@ -105,7 +113,7 @@ export function codexArgs(opts, runDir, isGitRepo) {
|
|
|
105
113
|
...platformSandboxArgs(),
|
|
106
114
|
...modelArgs,
|
|
107
115
|
'--sandbox',
|
|
108
|
-
|
|
116
|
+
sandboxMode,
|
|
109
117
|
'-C',
|
|
110
118
|
opts.repo,
|
|
111
119
|
...(isGitRepo ? [] : ['--skip-git-repo-check']),
|
|
@@ -139,7 +147,7 @@ export function codexArgs(opts, runDir, isGitRepo) {
|
|
|
139
147
|
...platformSandboxArgs(),
|
|
140
148
|
...modelArgs,
|
|
141
149
|
'--sandbox',
|
|
142
|
-
|
|
150
|
+
sandboxMode,
|
|
143
151
|
'-C',
|
|
144
152
|
opts.repo,
|
|
145
153
|
'--output-schema',
|
|
@@ -11,7 +11,6 @@ import fs from 'node:fs';
|
|
|
11
11
|
import path from 'node:path';
|
|
12
12
|
import { spawn, spawnSync } from 'node:child_process';
|
|
13
13
|
import { createHeartbeat, HEARTBEAT_INTERVAL_MS } from '../heartbeat.mjs';
|
|
14
|
-
import { writeFailure } from '../write-meta.mjs';
|
|
15
14
|
import { MAX_LOG } from './git-state.mjs';
|
|
16
15
|
|
|
17
16
|
// Thirty seconds, not a fraction of one: after `exit` the pipe still holds whatever Codex wrote
|
|
@@ -20,6 +19,8 @@ import { MAX_LOG } from './git-state.mjs';
|
|
|
20
19
|
// left no event". The grace exists to bound a grandchild holding stdio open forever (2026-08-06,
|
|
21
20
|
// run 2026-08-06_204007_build waited 25 minutes), and thirty seconds bounds that just as well.
|
|
22
21
|
const STDIO_DRAIN_GRACE_MS = 30_000;
|
|
22
|
+
// Plan_57 D28: a stalled taskkill must not block probe or paid-run deadline settlement forever.
|
|
23
|
+
const TASKKILL_TIMEOUT_MS = 10_000;
|
|
23
24
|
|
|
24
25
|
/**
|
|
25
26
|
* An argument cmd.exe cannot be handed safely, or undefined. `%` is fatal because cmd
|
|
@@ -30,6 +31,17 @@ const STDIO_DRAIN_GRACE_MS = 30_000;
|
|
|
30
31
|
export const unsafeForCmd = (args) =>
|
|
31
32
|
['codex', ...args].find((a) => a.includes('%') || a.includes('"'));
|
|
32
33
|
|
|
34
|
+
// Plan_57: preflight and paid runs must use the same Windows quoting and npm shim path.
|
|
35
|
+
export function codexSpawnSpec(args, platform = process.platform) {
|
|
36
|
+
if (platform !== 'win32') return { command: 'codex', args, options: {} };
|
|
37
|
+
const cmdline = ['codex', ...args].map((a) => (/[\s&|<>^]/.test(a) ? `"${a}"` : a)).join(' ');
|
|
38
|
+
return {
|
|
39
|
+
command: process.env.ComSpec || 'cmd.exe',
|
|
40
|
+
args: ['/d', '/s', '/c', `"${cmdline}"`],
|
|
41
|
+
options: { windowsVerbatimArguments: true, windowsHide: true },
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
33
45
|
/**
|
|
34
46
|
* Stop the process that owns the Codex invocation. On Windows that process is cmd.exe, so
|
|
35
47
|
* child.kill() would reap only the shell and leave Codex spending quota as its grandchild —
|
|
@@ -44,10 +56,91 @@ export function stopCodex(child, onWindows = process.platform === 'win32') {
|
|
|
44
56
|
const killed = spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], {
|
|
45
57
|
stdio: 'ignore',
|
|
46
58
|
windowsHide: true,
|
|
59
|
+
timeout: TASKKILL_TIMEOUT_MS,
|
|
47
60
|
});
|
|
48
61
|
if (killed.error || killed.status !== 0) child.kill();
|
|
49
62
|
}
|
|
50
63
|
|
|
64
|
+
// Plan_57 D28: keep spawnSync's 1 MiB default (stdout head, stderr tail); overflow is inconclusive, never dead.
|
|
65
|
+
const CAPTURE_LIMIT_BYTES = 1024 * 1024;
|
|
66
|
+
const CAPTURE_CLOSE_GRACE_MS = 2_000;
|
|
67
|
+
const CAPTURE_STOP_GRACE_MS = 5_000;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Plan_57 D24: async capture lets the deadline stop the whole Windows process tree,
|
|
71
|
+
* preventing the 2026-07-31 orphan that spawnSync's shell-only timeout leaves behind.
|
|
72
|
+
* Bounded pipe draining also prevents the inherited-stdio hang of 2026-08-06.
|
|
73
|
+
*/
|
|
74
|
+
export function spawnCaptured(command, args, options) {
|
|
75
|
+
const { timeout, encoding, shell, ...rest } = options ?? {};
|
|
76
|
+
if (typeof timeout !== 'number' || !Number.isFinite(timeout) || timeout <= 0) {
|
|
77
|
+
throw new TypeError('spawnCaptured requires a finite timeout greater than zero');
|
|
78
|
+
}
|
|
79
|
+
return new Promise((resolve) => {
|
|
80
|
+
const child = spawn(command, args, { ...rest, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
81
|
+
let stdout = Buffer.alloc(0);
|
|
82
|
+
let stderr = Buffer.alloc(0);
|
|
83
|
+
let captureError = null;
|
|
84
|
+
let settled = false;
|
|
85
|
+
let stopped = false;
|
|
86
|
+
let deadlineTimer;
|
|
87
|
+
let closeTimer;
|
|
88
|
+
let stopTimer;
|
|
89
|
+
const finish = (status, signal, error, destroyStreams = false) => {
|
|
90
|
+
if (settled) return;
|
|
91
|
+
settled = true;
|
|
92
|
+
clearTimeout(deadlineTimer);
|
|
93
|
+
clearTimeout(closeTimer);
|
|
94
|
+
clearTimeout(stopTimer);
|
|
95
|
+
if (destroyStreams) {
|
|
96
|
+
child.stdout.destroy();
|
|
97
|
+
child.stderr.destroy();
|
|
98
|
+
child.unref();
|
|
99
|
+
}
|
|
100
|
+
resolve({ status, signal, error, stdout: stdout.toString('utf8'), stderr: stderr.toString('utf8') });
|
|
101
|
+
};
|
|
102
|
+
const stop = () => {
|
|
103
|
+
if (stopped) return;
|
|
104
|
+
stopped = true;
|
|
105
|
+
stopCodex(child);
|
|
106
|
+
};
|
|
107
|
+
const onPipeError = (error) => {
|
|
108
|
+
if (settled) return;
|
|
109
|
+
stop();
|
|
110
|
+
finish(null, null, error, true);
|
|
111
|
+
};
|
|
112
|
+
child.stdout.on('error', onPipeError);
|
|
113
|
+
child.stderr.on('error', onPipeError);
|
|
114
|
+
child.stdout.on('data', (chunk) => {
|
|
115
|
+
const incoming = Buffer.from(chunk);
|
|
116
|
+
const room = CAPTURE_LIMIT_BYTES - stdout.length;
|
|
117
|
+
if (incoming.length > room && !captureError) {
|
|
118
|
+
captureError = Object.assign(new Error(`stdout exceeded ${CAPTURE_LIMIT_BYTES} bytes`), { code: 'ENOBUFS' });
|
|
119
|
+
}
|
|
120
|
+
if (room > 0) stdout = Buffer.concat([stdout, incoming.subarray(0, room)]);
|
|
121
|
+
});
|
|
122
|
+
child.stderr.on('data', (chunk) => {
|
|
123
|
+
stderr = Buffer.concat([stderr, Buffer.from(chunk)]);
|
|
124
|
+
if (stderr.length > CAPTURE_LIMIT_BYTES) stderr = stderr.subarray(-CAPTURE_LIMIT_BYTES);
|
|
125
|
+
});
|
|
126
|
+
child.on('error', (error) => finish(null, null, error, true));
|
|
127
|
+
child.on('close', (code, signal) => finish(code, signal, captureError));
|
|
128
|
+
child.on('exit', (code, signal) => {
|
|
129
|
+
if (settled) return;
|
|
130
|
+
clearTimeout(deadlineTimer);
|
|
131
|
+
clearTimeout(stopTimer);
|
|
132
|
+
closeTimer = setTimeout(() => finish(code, signal, captureError, true), CAPTURE_CLOSE_GRACE_MS);
|
|
133
|
+
});
|
|
134
|
+
deadlineTimer = setTimeout(() => {
|
|
135
|
+
captureError = Object.assign(new Error(`timed out after ${timeout} ms`), { code: 'ETIMEDOUT' });
|
|
136
|
+
stop();
|
|
137
|
+
if (!settled) {
|
|
138
|
+
stopTimer = setTimeout(() => finish(null, null, captureError, true), CAPTURE_STOP_GRACE_MS);
|
|
139
|
+
}
|
|
140
|
+
}, timeout);
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
51
144
|
/**
|
|
52
145
|
* Windows npm ships `codex` as codex.cmd, and Node refuses to spawn .cmd without a
|
|
53
146
|
* shell. One command line through cmd.exe keeps a single code path; every argument is
|
|
@@ -129,21 +222,17 @@ export function runCodex(args, taskText, eventsPath, budgetMinutes, graceMs = ST
|
|
|
129
222
|
return { append, finish };
|
|
130
223
|
})();
|
|
131
224
|
|
|
132
|
-
let child;
|
|
133
225
|
if (onWindows) {
|
|
134
226
|
// Thrown rather than died: in the worker a bare exit would leave the run open, while a
|
|
135
227
|
// throw goes through the crash handler and closes it with meta.json like any failure.
|
|
136
228
|
const bad = unsafeForCmd(args);
|
|
137
229
|
if (bad) throw new Error(`argument unsafe for cmd.exe (contains % or "): ${bad}`);
|
|
138
|
-
const cmdline = ['codex', ...args].map((a) => (/[\s&|<>^]/.test(a) ? `"${a}"` : a)).join(' ');
|
|
139
|
-
child = spawn(process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', `"${cmdline}"`], {
|
|
140
|
-
windowsVerbatimArguments: true,
|
|
141
|
-
windowsHide: true,
|
|
142
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
143
|
-
});
|
|
144
|
-
} else {
|
|
145
|
-
child = spawn('codex', args, { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
146
230
|
}
|
|
231
|
+
const spec = codexSpawnSpec(args);
|
|
232
|
+
const child = spawn(spec.command, spec.args, {
|
|
233
|
+
...spec.options,
|
|
234
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
235
|
+
});
|
|
147
236
|
|
|
148
237
|
return new Promise((resolve) => {
|
|
149
238
|
let failed = false;
|
|
@@ -249,20 +338,17 @@ export function runCodex(args, taskText, eventsPath, budgetMinutes, graceMs = ST
|
|
|
249
338
|
|
|
250
339
|
/**
|
|
251
340
|
* Codex missing or not authorised is a deterministic FAIL, never a reason to improvise.
|
|
252
|
-
*
|
|
341
|
+
* Plan_58, 2026-09-19: return a reason for refusal before registration, like the dead-sandbox
|
|
342
|
+
* probe. One no-quota question must not have two answers about whether it leaves a folder.
|
|
253
343
|
*/
|
|
254
|
-
export function
|
|
344
|
+
export function codexUnavailableReason() {
|
|
255
345
|
const probe = spawnSync(
|
|
256
346
|
process.platform === 'win32' ? process.env.ComSpec || 'cmd.exe' : 'codex',
|
|
257
347
|
process.platform === 'win32' ? ['/d', '/s', '/c', 'codex --version'] : ['--version'],
|
|
258
348
|
{ encoding: 'utf8' },
|
|
259
349
|
);
|
|
260
350
|
if (probe.error || probe.status !== 0) {
|
|
261
|
-
|
|
262
|
-
const { reply } = writeFailure(runDir, agent, `Codex CLI unavailable: ${why}`, [
|
|
263
|
-
'Operator check: codex --version (and codex login if authorization is rejected)',
|
|
264
|
-
], true);
|
|
265
|
-
console.log(reply);
|
|
266
|
-
process.exit(1);
|
|
351
|
+
return (probe.stderr || probe.error?.message || 'codex --version is not responding').trim();
|
|
267
352
|
}
|
|
353
|
+
return null;
|
|
268
354
|
}
|
|
@@ -40,7 +40,12 @@ export function worktreeSnapshot(repo) {
|
|
|
40
40
|
const skip = runsPrefixInside(repo);
|
|
41
41
|
// The path is the third tab-separated field in both git outputs used here.
|
|
42
42
|
const mine = (line) => Boolean(skip) && (line.split('\t')[2] || '').startsWith(skip);
|
|
43
|
-
|
|
43
|
+
// `--no-renames` because every reader of this snapshot compares paths against a scope: with
|
|
44
|
+
// rename detection on, numstat prints one row spelled `old => new` (or `dir/{a => b}/file`),
|
|
45
|
+
// which is not a path and matches no pattern — an in-scope rename would be judged a stray, and
|
|
46
|
+
// the witness named that token at the orchestrator on 2026-09-20. Without it a rename is a
|
|
47
|
+
// deletion plus an addition: two rows, both real paths, both judged on their own merits.
|
|
48
|
+
const tracked = (git(repo, ['diff', 'HEAD', '--numstat', '--no-renames']).stdout || '')
|
|
44
49
|
.split(/\r?\n/)
|
|
45
50
|
.filter((line) => line.trim() && !mine(line))
|
|
46
51
|
.join('\n')
|