@lyupro/codex-bridge 0.6.1 → 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.
@@ -7,13 +7,17 @@
7
7
  * split-guard run and made an honest build appear out of scope. This hook compares the live
8
8
  * run's recorded git state with the repository after every shell tool and directs the
9
9
  * orchestrator to undo any change outside the run's declared scope.
10
+ * The 2026-09-19 run accused itself of editing its own artifacts: the witness must use
11
+ * the verdict's snapshot and environment split, not a second definition of changed work.
10
12
  */
11
13
  import fs from 'node:fs';
12
14
  import path from 'node:path';
13
- import { spawnSync } from 'node:child_process';
14
15
  import { SHELL_TOOLS } from '../lib/hook-definitions.mjs';
15
16
  import { parseJsonText } from '../lib/json-file.mjs';
16
- import { globToRegExp } from '../lib/meta/paths.mjs';
17
+ import { splitRunChanges } from '../lib/meta/environment.mjs';
18
+ import { changedPaths } from '../lib/meta/paths.mjs';
19
+ import { outOfScope } from '../lib/meta/verdict.mjs';
20
+ import { git, worktreeSnapshot } from '../lib/runner/git-state.mjs';
17
21
  import { runsRoot } from '../lib/runner/runs-root.mjs';
18
22
  import { allLiveRuns, normalizePath } from './live-runs.mjs';
19
23
 
@@ -28,19 +32,6 @@ function readRequired(file) {
28
32
  }
29
33
  }
30
34
 
31
- function statusMap(text) {
32
- if (typeof text !== 'string') return null;
33
- const result = new Map();
34
- for (const line of text.split(/\r?\n/).filter(Boolean)) {
35
- if (!/^.. /.test(line)) return null;
36
- const rawPath = line.slice(3);
37
- const file = rawPath.includes(' -> ') ? rawPath.slice(rawPath.lastIndexOf(' -> ') + 4) : rawPath;
38
- if (!file.trim()) return null;
39
- result.set(file, line.slice(0, 2));
40
- }
41
- return result;
42
- }
43
-
44
35
  let input;
45
36
  try {
46
37
  input = parseJsonText('stdin', fs.readFileSync(0, 'utf8'));
@@ -67,37 +58,26 @@ const owner = liveRuns.find(({ status }) => {
67
58
  if (!owner) pass();
68
59
 
69
60
  const { dir, status } = owner;
70
- const beforeText = readRequired(path.join(dir, 'git-before.txt'));
61
+ const beforeText = readRequired(path.join(dir, 'state-before.txt'));
71
62
  const scopeText = readRequired(path.join(dir, 'scope.txt'));
72
63
  if (beforeText === null || scopeText === null) pass();
73
64
 
74
- const before = statusMap(beforeText);
75
- let allowed;
65
+ let outside;
76
66
  try {
67
+ // changedPaths tolerates malformed rows; a hook must instead fail open on a damaged baseline.
68
+ if (beforeText.split(/\r?\n/).filter(Boolean).some((row) =>
69
+ !/^(?:\d+\t\d+|-\t-|U\t\d+)\t\S.*$/.test(row))) pass();
77
70
  const patterns = scopeText.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
78
71
  if (!patterns.length) pass();
79
- allowed = patterns.map(globToRegExp);
72
+ // The shared snapshot returns empty output on git errors; do not call that a restored tree.
73
+ const repository = git(status.repo, ['rev-parse', '--is-inside-work-tree']);
74
+ if (repository.error || repository.status !== 0 || repository.stdout?.trim() !== 'true') pass();
75
+ const current = worktreeSnapshot(status.repo);
76
+ const { work } = splitRunChanges(dir, changedPaths(beforeText, current));
77
+ outside = outOfScope(work, patterns);
80
78
  } catch {
81
79
  pass();
82
80
  }
83
- if (!before) pass();
84
-
85
- let currentResult;
86
- try {
87
- currentResult = spawnSync('git', ['-C', status.repo, 'status', '--porcelain'], {
88
- encoding: 'utf8',
89
- });
90
- } catch {
91
- pass();
92
- }
93
- if (currentResult.error || currentResult.status !== 0 || typeof currentResult.stdout !== 'string') pass();
94
- const current = statusMap(currentResult.stdout);
95
- if (!current) pass();
96
-
97
- const changed = [];
98
- for (const [file, state] of current) if (before.get(file) !== state) changed.push(file);
99
- for (const file of before.keys()) if (!current.has(file)) changed.push(file);
100
- const outside = [...new Set(changed)].filter((file) => !allowed.some((pattern) => pattern.test(file)));
101
81
  if (!outside.length) pass();
102
82
 
103
83
  const folder = path.basename(dir);
@@ -105,9 +85,10 @@ const paths = outside.join(', ');
105
85
  process.stdout.write(JSON.stringify({
106
86
  hookSpecificOutput: {
107
87
  hookEventName: 'PostToolUse',
108
- additionalContext: `WORKTREE WITNESS — act now: the shell changed paths outside the scope of `
109
- + `live run folder ${dir} (agent ${status.agent}, slug ${status.slug}): ${paths}. `
110
- + `Revert those paths now, or run codex-bridge stop ${folder} to take the repository back `
88
+ additionalContext: `WORKTREE WITNESS — the run's own folder, environment paths and gitignored `
89
+ + `files are already excluded. These are the orchestrator's own edits inside the repository `
90
+ + `of live run folder ${dir} (agent ${status.agent}, slug ${status.slug}), outside its scope: ${paths}. `
91
+ + `Act now: revert those paths, or run codex-bridge stop ${folder} to take the repository back `
111
92
  + `before making further changes.`,
112
93
  },
113
94
  }));
@@ -157,6 +157,10 @@ export function readEvents(runDir) {
157
157
  // not an archived run.
158
158
  hasStream: exists,
159
159
  hasEvents: events.length > 0,
160
+ // The 2026-09-16 dead sandbox rejected every command; refused calls never enter the
161
+ // stream. Only completed items prove execution, including commands with nonzero exits.
162
+ commands_executed: events.filter((event) =>
163
+ event.type === 'item.completed' && event.item?.type === 'command_execution').length,
160
164
  tokens: accounting.tokens,
161
165
  usage: accounting.usage,
162
166
  session_id: started ? String(started.thread_id) : null,
@@ -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, readJson, readText } from './paths.mjs';
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
- IDENTITY_ALIVE,
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
- * Is this pid still running for this run? Unknown identity remains live here: treating an
21
- * unverified process as dead would mark a live run abandoned, and readers must fail open.
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
- const isAliveIdentity = (identity) => identity === IDENTITY_ALIVE || identity === IDENTITY_UNVERIFIED;
24
-
25
- const pidAlive = (pid, runDir, status = {}) => {
26
- const identity = processIdentity({ runDir, status: { ...status, pid } });
27
- return isAliveIdentity(identity);
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
- if (pidAlive(status.pid, runDir, status)) continue;
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 = processIdentity({ runDir, status });
163
- if (isAliveIdentity(identity)) return { run: entry.name, identity };
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 = [`FAIL — ${meta.reason}`, ...extraLines, `Run: ${runDir}`].join('\n');
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
- constructor(message) {
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 = 2;
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 { IDENTITY_DEAD, IDENTITY_FOREIGN, processAlive, processIdentity } from '../process-identity.mjs';
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 (!alive(workerPid, runDir, status)) {
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 (alive(entry.status.pid, dir, entry.status)) {
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
- 'read-only',
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
- 'workspace-write',
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
- 'read-only',
150
+ sandboxMode,
143
151
  '-C',
144
152
  opts.repo,
145
153
  '--output-schema',