@lyupro/codex-bridge 0.6.2 → 0.6.5
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 +146 -0
- package/README.md +3 -3
- 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/agents/codex-advisor.md +187 -0
- package/src/agents/codex-build.md +4 -0
- package/src/commands/env.md +5 -4
- 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/agents.mjs +3 -0
- package/src/home/lib/budgets.mjs +64 -0
- package/src/home/lib/config-validate.mjs +3 -39
- package/src/home/lib/meta/advice-status.mjs +61 -0
- package/src/home/lib/meta/advice-verdict.mjs +226 -0
- package/src/home/lib/meta/advisor-task.mjs +69 -0
- 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 +42 -34
- package/src/home/lib/meta/run-liveness.mjs +64 -0
- package/src/home/lib/meta/run-state.mjs +28 -34
- package/src/home/lib/meta/verdict.mjs +18 -0
- package/src/home/lib/required-inputs.mjs +25 -4
- package/src/home/lib/run-codex.mjs +1 -0
- package/src/home/lib/run-config.mjs +5 -1
- package/src/home/lib/runner/args.mjs +8 -4
- package/src/home/lib/runner/attach.mjs +5 -13
- package/src/home/lib/runner/codex-args.mjs +14 -5
- package/src/home/lib/runner/codex-cmd.mjs +105 -19
- package/src/home/lib/runner/git-state.mjs +6 -1
- package/src/home/lib/runner/launcher.mjs +36 -30
- package/src/home/lib/runner/preflight.mjs +109 -0
- package/src/home/lib/runner/prompts.mjs +39 -0
- package/src/home/lib/runner/sandbox-probe.mjs +109 -0
- package/src/home/lib/runner/schemas.mjs +137 -1
- package/src/home/lib/runner/scope-check.mjs +31 -3
- package/src/home/lib/runner/worker-order.mjs +4 -2
- package/src/home/lib/runner/worker.mjs +2 -2
- package/src/home/lib/write-meta.mjs +18 -1
|
@@ -2,8 +2,7 @@
|
|
|
2
2
|
* Renders the reply lines a dispatcher is allowed to return, one format per agent.
|
|
3
3
|
*
|
|
4
4
|
* AGENTS adds reply strategies to the shared agent registry: for each one it describes how
|
|
5
|
-
* to tell the result file is filled in and which
|
|
6
|
-
* renders it. FAIL and LIMIT bypass the per-agent strategy — a run that
|
|
5
|
+
* to tell the result file is filled in and which reply strategy below renders it. FAIL and LIMIT bypass the per-agent strategy — a run that
|
|
7
6
|
* produced nothing has nothing agent-specific left to say.
|
|
8
7
|
*
|
|
9
8
|
* The lines built here ARE the reply. Agents forward this text verbatim instead of
|
|
@@ -12,7 +11,7 @@
|
|
|
12
11
|
import fs from 'node:fs';
|
|
13
12
|
import path from 'node:path';
|
|
14
13
|
import { AGENTS as AGENT_DEFINITIONS } from '../agents.mjs';
|
|
15
|
-
import { changedPaths, line,
|
|
14
|
+
import { changedPaths, line, readText } from './paths.mjs';
|
|
16
15
|
import { splitRunChanges } from './environment.mjs';
|
|
17
16
|
import { scoutCoverage } from './verdict.mjs';
|
|
18
17
|
|
|
@@ -25,27 +24,6 @@ const runChanges = (runDir) =>
|
|
|
25
24
|
|
|
26
25
|
const readCommand = (runDir) => `codex-bridge read ${runDir}`;
|
|
27
26
|
|
|
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
27
|
export const AGENTS = {
|
|
50
28
|
'codex-scout': {
|
|
51
29
|
...AGENT_DEFINITIONS['codex-scout'],
|
|
@@ -62,6 +40,12 @@ export const AGENTS = {
|
|
|
62
40
|
filled: (r) => Boolean(String(r?.verdict || '').trim()),
|
|
63
41
|
reply: reviewReply,
|
|
64
42
|
},
|
|
43
|
+
// Plan_59 D4: one result file, two contracts. Either phase's decisive field proves it is filled.
|
|
44
|
+
'codex-advisor': {
|
|
45
|
+
...AGENT_DEFINITIONS['codex-advisor'],
|
|
46
|
+
filled: (r) => typeof r?.sufficient === 'boolean' || Boolean(String(r?.recommendation?.option_id || '').trim()),
|
|
47
|
+
reply: advisorReply,
|
|
48
|
+
},
|
|
65
49
|
};
|
|
66
50
|
|
|
67
51
|
function scoutReply(ctx) {
|
|
@@ -69,14 +53,12 @@ function scoutReply(ctx) {
|
|
|
69
53
|
const top = (r.findings || [])[0];
|
|
70
54
|
const unknowns = (r.unknowns || []).filter(Boolean);
|
|
71
55
|
const coverage = scoutCoverage(ctx.runDir, r);
|
|
72
|
-
const retention = retentionReply(ctx.runDir);
|
|
73
56
|
return [
|
|
74
57
|
`OK — ${line(r.answer, 160)}`,
|
|
75
58
|
// Any explicit question gets a coverage line, including a valid one-question order.
|
|
76
59
|
...(coverage ? [`Coverage: ${coverage}`] : []),
|
|
77
60
|
`Key finding: ${top ? `${line(top.fact, 130)} (${line(top.where, 60)})` : 'no findings listed'}`,
|
|
78
61
|
`Unresolved: ${unknowns.length ? line(unknowns.join('; '), 160) : 'none'}`,
|
|
79
|
-
...(retention ? [retention] : []),
|
|
80
62
|
`Report: ${ctx.file('report.md')} · Log: ${readCommand(ctx.runDir)}`,
|
|
81
63
|
];
|
|
82
64
|
}
|
|
@@ -107,7 +89,6 @@ function buildReply(ctx) {
|
|
|
107
89
|
const files = ctx.carried
|
|
108
90
|
? `${paths ? `${paths} · ` : ''}changes were made by an earlier run of this task`
|
|
109
91
|
: paths || 'worktree untouched';
|
|
110
|
-
const retention = retentionReply(ctx.runDir);
|
|
111
92
|
return [
|
|
112
93
|
`OK — ${line(r.summary, 300)}`,
|
|
113
94
|
`Files: ${touchedPaths.length} changed · ${files}`,
|
|
@@ -118,7 +99,6 @@ function buildReply(ctx) {
|
|
|
118
99
|
: []),
|
|
119
100
|
`Verification: ${verify} — ${verdict}`,
|
|
120
101
|
`Flags: ${flags.length ? `${flags.length} TODO/skip — ${line(flags.slice(0, 3).join(' | '), 140)}` : 'none'}`,
|
|
121
|
-
...(retention ? [retention] : []),
|
|
122
102
|
`Report: ${ctx.file('report.md')} · Log: ${readCommand(ctx.runDir)}`,
|
|
123
103
|
];
|
|
124
104
|
}
|
|
@@ -130,16 +110,48 @@ function reviewReply(ctx) {
|
|
|
130
110
|
if (counts[f.severity] !== undefined) counts[f.severity] += 1;
|
|
131
111
|
});
|
|
132
112
|
const top = (r.findings || []).find((f) => f.severity === 'critical' || f.severity === 'high');
|
|
133
|
-
const retention = retentionReply(ctx.runDir);
|
|
134
113
|
return [
|
|
135
114
|
`OK — verdict ${line(r.verdict, 40)}`,
|
|
136
115
|
`Findings: critical ${counts.critical} · high ${counts.high} · medium ${counts.medium} · low ${counts.low}`,
|
|
137
116
|
`Top: ${top ? `${top.severity} ${line(top.file, 80)}:${top.line_start} — ${line(top.title, 90)}` : 'no critical or high findings'}`,
|
|
138
|
-
...(retention ? [retention] : []),
|
|
139
117
|
`Report: ${ctx.file(path.basename(ctx.resultPath))} · Log: ${readCommand(ctx.runDir)}`,
|
|
140
118
|
];
|
|
141
119
|
}
|
|
142
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Plan_59 D4: an insufficient scope is an OK run with a named outcome, not a LIMIT — the first
|
|
123
|
+
* word stays the status, and the outcome that decides the orchestrator's next step is the first
|
|
124
|
+
* thing after it, derived from the same fields meta.json records.
|
|
125
|
+
*/
|
|
126
|
+
function advisorReply(ctx) {
|
|
127
|
+
const r = ctx.result;
|
|
128
|
+
const log = `Report: ${ctx.file(path.basename(ctx.resultPath))} · Log: ${readCommand(ctx.runDir)}`;
|
|
129
|
+
// The result's own shape picks the lines, as it does in `filled`: a run folder missing its phase
|
|
130
|
+
// record must not send a scope answer into the advise branch and crash the verdict.
|
|
131
|
+
if (typeof r.sufficient === 'boolean') {
|
|
132
|
+
const missing = r.missing_paths || [];
|
|
133
|
+
const risks = r.predicted_risks || [];
|
|
134
|
+
return [
|
|
135
|
+
r.sufficient
|
|
136
|
+
? 'OK — scope: sufficient; continue this run with --phase advise'
|
|
137
|
+
: `OK — scope: insufficient; ${missing.length} paths named`,
|
|
138
|
+
...(missing.length ? [`Missing: ${line(missing.join(', '), 160)}`] : []),
|
|
139
|
+
`Predicted risks: ${risks.length}${risks[0] ? ` — ${risks[0].id} ${line(risks[0].risk, 120)}` : ''}`,
|
|
140
|
+
log,
|
|
141
|
+
];
|
|
142
|
+
}
|
|
143
|
+
const outcomes = r.risk_outcomes || [];
|
|
144
|
+
const confirmed = outcomes.filter((o) => o.outcome === 'confirmed').length;
|
|
145
|
+
const rejected = (r.rejected || []).map((o) => `${o.option_id} (${line(o.cost, 60)})`);
|
|
146
|
+
return [
|
|
147
|
+
`OK — recommend ${r.recommendation.option_id}: ${line(r.recommendation.text, 200)}`,
|
|
148
|
+
`Rejected: ${rejected.length ? line(rejected.join('; '), 180) : 'none'}`,
|
|
149
|
+
`Counter: ${line(r.strongest_counterargument, 180)}`,
|
|
150
|
+
`Risks: ${confirmed} confirmed · ${outcomes.length - confirmed} refuted · open questions ${(r.open_questions || []).length} · confidence ${r.confidence}`,
|
|
151
|
+
log,
|
|
152
|
+
];
|
|
153
|
+
}
|
|
154
|
+
|
|
143
155
|
/**
|
|
144
156
|
* Which worker actually ran, printed next to the log link.
|
|
145
157
|
*
|
|
@@ -168,7 +180,6 @@ export function withProfileRow(rows, meta) {
|
|
|
168
180
|
}
|
|
169
181
|
|
|
170
182
|
export function failReply(ctx, meta) {
|
|
171
|
-
const retention = retentionReply(ctx.runDir);
|
|
172
183
|
return [
|
|
173
184
|
`FAIL — ${line(meta.reason, 170)}`,
|
|
174
185
|
`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 +188,6 @@ export function failReply(ctx, meta) {
|
|
|
177
188
|
// and the orchestrator has to know whether there is something to revert before it decides
|
|
178
189
|
// anything else.
|
|
179
190
|
...(ctx.agent === 'codex-build' ? [`Worktree: ${worktreeState(ctx.runDir)}`] : []),
|
|
180
|
-
...(retention ? [retention] : []),
|
|
181
191
|
`Log: ${readCommand(ctx.runDir)}`,
|
|
182
192
|
];
|
|
183
193
|
}
|
|
@@ -200,7 +210,6 @@ function worktreeState(runDir) {
|
|
|
200
210
|
}
|
|
201
211
|
|
|
202
212
|
export function limitReply(ctx, meta) {
|
|
203
|
-
const retention = retentionReply(ctx.runDir);
|
|
204
213
|
const rows = [
|
|
205
214
|
'LIMIT — ChatGPT quota exhausted, work not completed',
|
|
206
215
|
`Signal: ${line(meta.reason, 170)}`,
|
|
@@ -211,7 +220,6 @@ export function limitReply(ctx, meta) {
|
|
|
211
220
|
`Worktree: ${touched.length ? `has unfinished changes (${touched.length}), see git-after.txt` : 'no new changes'}`,
|
|
212
221
|
);
|
|
213
222
|
}
|
|
214
|
-
if (retention) rows.push(retention);
|
|
215
223
|
rows.push(`Log: ${readCommand(ctx.runDir)}`);
|
|
216
224
|
return rows;
|
|
217
225
|
}
|
|
@@ -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
|
}
|
|
@@ -208,6 +197,8 @@ export function abandonedBranchDrift(runsRoot, repo, currentBranch) {
|
|
|
208
197
|
export function writeFailure(runDir, agent, reason, extraLines = [], preStart = false) {
|
|
209
198
|
const meta = {
|
|
210
199
|
agent,
|
|
200
|
+
phase: readJson(path.join(runDir, 'worker.json'))?.phase
|
|
201
|
+
?? readJson(path.join(runDir, 'status.json'))?.phase ?? null,
|
|
211
202
|
project: path.basename(path.dirname(runDir)),
|
|
212
203
|
run: path.basename(runDir),
|
|
213
204
|
finished_at: new Date().toISOString(),
|
|
@@ -230,6 +221,9 @@ export function writeFailure(runDir, agent, reason, extraLines = [], preStart =
|
|
|
230
221
|
status: 'FAIL',
|
|
231
222
|
finished_at: meta.finished_at,
|
|
232
223
|
});
|
|
233
|
-
const reply =
|
|
224
|
+
const reply = withLaunchRows(
|
|
225
|
+
[`FAIL — ${meta.reason}`, ...extraLines, `Run: ${runDir}`],
|
|
226
|
+
runDir,
|
|
227
|
+
).join('\n');
|
|
234
228
|
return { meta, reply };
|
|
235
229
|
}
|
|
@@ -28,6 +28,7 @@ import { deadlineReason } from './deadline.mjs';
|
|
|
28
28
|
import { startupGap } from './startup.mjs';
|
|
29
29
|
import { transportGap } from './transport.mjs';
|
|
30
30
|
import { reasonFrom } from './reason.mjs';
|
|
31
|
+
import { adviceGap } from './advice-status.mjs';
|
|
31
32
|
|
|
32
33
|
// How much prose an answer must carry once coordinates and paths are subtracted. Measured
|
|
33
34
|
// on the scout run of 2026-07-30 that replied with a table of `file.ts:60-79` rows: every
|
|
@@ -315,9 +316,26 @@ export function resolveStatus({ resultOk, exit, agent, result, runDir, events })
|
|
|
315
316
|
// Answers that never arrived, or arrived as coordinates. Scout-only, and per-question
|
|
316
317
|
// only when questions.json is absent — see scoutCoverageGap().
|
|
317
318
|
if (agent === 'codex-scout') {
|
|
319
|
+
// The 2026-09-16 dead sandbox produced nonempty evidence without reading code. Require an
|
|
320
|
+
// execution fact first: checking whether evidence paths existed falsely rejected 64/151 runs.
|
|
321
|
+
// Only a run that left a stream can prove it: an archived run from before `--json` has none and
|
|
322
|
+
// is judged by the contract of its day; a `--json` run missing its stream already failed above.
|
|
323
|
+
if (eventData.hasStream && eventData.commands_executed === 0) {
|
|
324
|
+
return {
|
|
325
|
+
status: 'FAIL',
|
|
326
|
+
reason: 'scout executed no command, so no answer rests on reading the code; '
|
|
327
|
+
+ 'check stderr.log for sandbox refusals before changing the order',
|
|
328
|
+
};
|
|
329
|
+
}
|
|
318
330
|
const gap = scoutCoverageGap(runDir, result);
|
|
319
331
|
if (gap) return { status: 'FAIL', reason: gap };
|
|
320
332
|
}
|
|
333
|
+
// Plan_59 D3/D4/D5/D10: a schema-valid agreement still needs evidence and decision checks.
|
|
334
|
+
// Place the advisor judge before the build-only scope check, alongside the other agent contracts.
|
|
335
|
+
if (agent === 'codex-advisor') {
|
|
336
|
+
const gap = adviceGap(runDir, result, eventData);
|
|
337
|
+
if (gap) return { status: 'FAIL', reason: gap };
|
|
338
|
+
}
|
|
321
339
|
// Files the task never authorised. Checked before the mismatch below, because a run that
|
|
322
340
|
// did the job AND edited three unrelated files passes reportVersusWork() — one matching
|
|
323
341
|
// path is all it needs — and the unrelated files would go unmentioned. No automatic
|
|
@@ -80,6 +80,23 @@ export const REQUIRED_INPUTS = Object.freeze({
|
|
|
80
80
|
TASK_FILE_INPUT,
|
|
81
81
|
CONTINUATION_INPUT,
|
|
82
82
|
]),
|
|
83
|
+
// Plan_59 D7: the caller must name the phase and authorize the continued decision pass.
|
|
84
|
+
'codex-advisor': freezeEntries([
|
|
85
|
+
{
|
|
86
|
+
label: 'order id',
|
|
87
|
+
source: 'the orchestrator',
|
|
88
|
+
explanation: 'The label this design order is known by. Keep the same order id for scope and advise so phase 2 can settle the risks predicted in phase 1.',
|
|
89
|
+
example: 'plan-59-advisor-20260922',
|
|
90
|
+
},
|
|
91
|
+
TASK_FILE_INPUT,
|
|
92
|
+
CONTINUATION_INPUT,
|
|
93
|
+
{
|
|
94
|
+
label: 'phase',
|
|
95
|
+
source: 'the orchestrator',
|
|
96
|
+
explanation: 'Pass scope first to predict risks and check the reading boundary, then advise with --continue and a continuation grant naming the scope run of the same order.',
|
|
97
|
+
example: 'scope',
|
|
98
|
+
},
|
|
99
|
+
]),
|
|
83
100
|
});
|
|
84
101
|
|
|
85
102
|
const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
@@ -101,6 +118,10 @@ export function isPlaceholder(value) {
|
|
|
101
118
|
return PLACEHOLDER_VALUES.has(cleaned.replace(/[.,;:]+$/, '').trim().toLowerCase());
|
|
102
119
|
}
|
|
103
120
|
|
|
121
|
+
// Plan_59: scope is a real phase value, while it remains a placeholder for existing inputs.
|
|
122
|
+
const isInputPlaceholder = (value, label) =>
|
|
123
|
+
!(label === 'phase' && cleanValue(value) === 'scope') && isPlaceholder(value);
|
|
124
|
+
|
|
104
125
|
export function extractValue(promptText, label) {
|
|
105
126
|
const labelPattern = escapeRegExp(label).replaceAll('\\ ', '\\s+');
|
|
106
127
|
const flag = `--${label.replaceAll(/\s+/g, '-')}`;
|
|
@@ -141,7 +162,7 @@ export function diagnoseInput(promptText, label) {
|
|
|
141
162
|
if (candidateLine === undefined) return null;
|
|
142
163
|
|
|
143
164
|
const value = extractValue(candidateLine, label);
|
|
144
|
-
if (value !== null && !
|
|
165
|
+
if (value !== null && !isInputPlaceholder(value, label)) {
|
|
145
166
|
if (label === TASK_FILE_INPUT.label && !isAbsoluteTaskFilePath(value)) {
|
|
146
167
|
return { line: readableDiagnosisLine(candidateLine), reason: `value \`${value}\` is not an absolute path` };
|
|
147
168
|
}
|
|
@@ -149,7 +170,7 @@ export function diagnoseInput(promptText, label) {
|
|
|
149
170
|
}
|
|
150
171
|
|
|
151
172
|
const line = readableDiagnosisLine(candidateLine);
|
|
152
|
-
if (value !== null &&
|
|
173
|
+
if (value !== null && isInputPlaceholder(value, label)) {
|
|
153
174
|
const displayedValue = value.trim();
|
|
154
175
|
if (!displayedValue) return { line, reason: 'value is empty; replace it with a concrete value' };
|
|
155
176
|
return {
|
|
@@ -195,7 +216,7 @@ export function missingInputs(agentType, promptText) {
|
|
|
195
216
|
return requiredInputsFor(agentType).filter((entry) => {
|
|
196
217
|
if (entry.conditional) return false;
|
|
197
218
|
const value = extractValue(prompt, entry.label);
|
|
198
|
-
if (
|
|
219
|
+
if (isInputPlaceholder(value, entry.label)) return true;
|
|
199
220
|
return entry === TASK_FILE_INPUT && !isAbsoluteTaskFilePath(value);
|
|
200
221
|
});
|
|
201
222
|
}
|
|
@@ -206,7 +227,7 @@ export function shellUnsafeInputs(agentType, promptText) {
|
|
|
206
227
|
return requiredInputsFor(agentType).flatMap((entry) => {
|
|
207
228
|
if (entry.conditional) return [];
|
|
208
229
|
const value = extractValue(prompt, entry.label);
|
|
209
|
-
if (value === null ||
|
|
230
|
+
if (value === null || isInputPlaceholder(value, entry.label)) return [];
|
|
210
231
|
const sequence = firstShellUnsafeSequence(value);
|
|
211
232
|
return sequence === null ? [] : [{ entry, sequence }];
|
|
212
233
|
});
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*
|
|
7
7
|
* Options:
|
|
8
8
|
* --agent codex-scout | codex-build | codex-review (required)
|
|
9
|
+
* --phase declared phase name (optional only for a role with just the default phase)
|
|
9
10
|
* --repo repository path (default: current directory)
|
|
10
11
|
* --slug short run name used in the run folder (default: agent suffix)
|
|
11
12
|
* --effort reasoning effort, passed to Codex as given (default: medium)
|
|
@@ -79,7 +79,11 @@ const state = (config) => [
|
|
|
79
79
|
return `${key}: ${model}${profile.effort ? ` at ${profile.effort} effort` : ''}`
|
|
80
80
|
+ (profile.speed ? ` on ${profile.speed} tier` : '');
|
|
81
81
|
}).join('; ')}`,
|
|
82
|
-
`budgets: ${ROLES.map((role) =>
|
|
82
|
+
`budgets: ${ROLES.map((role) => {
|
|
83
|
+
const phases = Object.entries(config.budgets?.[role] ?? DEFAULTS.budgets[role]);
|
|
84
|
+
return `${role}: ` + (phases.length === 1 ? `${phases[0][1]} minutes`
|
|
85
|
+
: phases.map(([phase, minutes]) => `${phase}: ${minutes} minutes`).join(', '));
|
|
86
|
+
}).join('; ')}`,
|
|
83
87
|
`retention: ${config.retention?.enabled ? `on — transport older than ${config.retention.days} days` : 'off — automatic cleanup disabled'}`,
|
|
84
88
|
];
|
|
85
89
|
|
|
@@ -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) {
|
|
@@ -86,6 +89,7 @@ const SHELL_CHECKED_FLAGS = Object.freeze([
|
|
|
86
89
|
'scope-new',
|
|
87
90
|
'repo',
|
|
88
91
|
'agent',
|
|
92
|
+
'phase',
|
|
89
93
|
'effort',
|
|
90
94
|
'changeset',
|
|
91
95
|
'task-file',
|
|
@@ -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,14 +60,23 @@ 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' || agent === 'codex-advisor') 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}`;
|
|
67
75
|
const modelArgs = profile.model ? ['-m', profile.model] : [];
|
|
68
76
|
// Plan_56 D35: there is no identifier for ordinary speed; an unpinned tier adds no override.
|
|
69
77
|
const speedArgs = profile.speed ? ['-c', `service_tier=${profile.speed}`] : [];
|
|
70
|
-
|
|
78
|
+
// Plan_59: advisor uses schema-backed plain exec with the same read-only isolation as scout.
|
|
79
|
+
if (opts.agent === 'codex-scout' || opts.agent === 'codex-advisor') {
|
|
71
80
|
return [
|
|
72
81
|
'exec',
|
|
73
82
|
'--json',
|
|
@@ -80,8 +89,8 @@ export function codexArgs(opts, runDir, isGitRepo) {
|
|
|
80
89
|
...platformSandboxArgs(),
|
|
81
90
|
...modelArgs,
|
|
82
91
|
'--sandbox',
|
|
83
|
-
|
|
84
|
-
'--skip-git-repo-check',
|
|
92
|
+
sandboxMode,
|
|
93
|
+
...(opts.agent === 'codex-scout' || !isGitRepo ? ['--skip-git-repo-check'] : []),
|
|
85
94
|
'-C',
|
|
86
95
|
opts.repo,
|
|
87
96
|
'--output-schema',
|
|
@@ -105,7 +114,7 @@ export function codexArgs(opts, runDir, isGitRepo) {
|
|
|
105
114
|
...platformSandboxArgs(),
|
|
106
115
|
...modelArgs,
|
|
107
116
|
'--sandbox',
|
|
108
|
-
|
|
117
|
+
sandboxMode,
|
|
109
118
|
'-C',
|
|
110
119
|
opts.repo,
|
|
111
120
|
...(isGitRepo ? [] : ['--skip-git-repo-check']),
|
|
@@ -139,7 +148,7 @@ export function codexArgs(opts, runDir, isGitRepo) {
|
|
|
139
148
|
...platformSandboxArgs(),
|
|
140
149
|
...modelArgs,
|
|
141
150
|
'--sandbox',
|
|
142
|
-
|
|
151
|
+
sandboxMode,
|
|
143
152
|
'-C',
|
|
144
153
|
opts.repo,
|
|
145
154
|
'--output-schema',
|