@lyupro/codex-bridge 0.1.4 → 0.2.0
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 +31 -0
- package/README.md +18 -4
- package/bin/codex-bridge.mjs +29 -0
- package/cli/doctor.mjs +20 -2
- package/cli/install.mjs +18 -4
- package/cli/projects.mjs +92 -0
- package/cli/prune-args.mjs +133 -0
- package/cli/prune-plan.mjs +205 -0
- package/cli/prune.mjs +278 -0
- package/cli/read.mjs +103 -0
- package/cli/run-lookup.mjs +59 -0
- package/cli/runs-inventory.mjs +173 -0
- package/cli/stop.mjs +4 -37
- package/cli/table.mjs +110 -0
- package/package.json +1 -1
- package/src/agents/codex-build.md +3 -3
- package/src/agents/codex-review.md +4 -5
- package/src/agents/codex-scout.md +3 -4
- package/src/commands/usage.md +4 -3
- package/src/hook-definitions.mjs +11 -0
- package/src/hooks/prune-guard.mjs +62 -0
- package/src/hooks/reply-guard.mjs +2 -2
- package/src/meta/deadline.mjs +19 -0
- package/src/meta/events.mjs +166 -0
- package/src/meta/reason.mjs +29 -0
- package/src/meta/reply.mjs +40 -7
- package/src/meta/run-state.mjs +7 -1
- package/src/meta/startup.mjs +21 -0
- package/src/meta/transport.mjs +35 -0
- package/src/meta/verdict.mjs +40 -42
- package/src/retention.mjs +120 -0
- package/src/run-codex.mjs +3 -3
- package/src/run-config.json +5 -1
- package/src/run-config.mjs +54 -1
- package/src/runner/attach.mjs +1 -1
- package/src/runner/codex-cmd.mjs +119 -31
- package/src/runner/launcher.mjs +11 -0
- package/src/runner/worker.mjs +11 -3
- package/src/write-meta.mjs +31 -22
package/src/run-config.mjs
CHANGED
|
@@ -26,6 +26,9 @@ export const CONFIG_PATH = path.join(HERE, 'run-config.json');
|
|
|
26
26
|
const BUDGET_KEY = 'budgets';
|
|
27
27
|
const BUDGET_MODES = ['scout', 'build', 'review'];
|
|
28
28
|
const DEFAULT_BUDGETS = { scout: 15, build: 25, review: 20 };
|
|
29
|
+
const RETENTION_KEY = 'retention';
|
|
30
|
+
const RETENTION_FIELDS = ['enabled', 'days'];
|
|
31
|
+
const DEFAULT_RETENTION = { enabled: true, days: 30 };
|
|
29
32
|
|
|
30
33
|
/**
|
|
31
34
|
* Files the surrounding tooling writes on its own, in any repository a run touches.
|
|
@@ -52,6 +55,7 @@ export const DEFAULTS = {
|
|
|
52
55
|
plugins: false,
|
|
53
56
|
models: {},
|
|
54
57
|
budgets: DEFAULT_BUDGETS,
|
|
58
|
+
retention: DEFAULT_RETENTION,
|
|
55
59
|
environmentPaths: DEFAULT_ENVIRONMENT_PATHS,
|
|
56
60
|
answerLanguage: 'English',
|
|
57
61
|
};
|
|
@@ -75,7 +79,51 @@ export const ALLOWED_EFFORTS = Object.freeze(['none', 'minimal', 'low', 'medium'
|
|
|
75
79
|
* two languages. English is the default because the package is read by strangers.
|
|
76
80
|
*/
|
|
77
81
|
const STRING_KEYS = ['answerLanguage'];
|
|
78
|
-
const KEYS = [...SWITCH_KEYS, ...LIST_KEYS, ...OBJECT_KEYS, ...STRING_KEYS, BUDGET_KEY];
|
|
82
|
+
const KEYS = [...SWITCH_KEYS, ...LIST_KEYS, ...OBJECT_KEYS, ...STRING_KEYS, BUDGET_KEY, RETENTION_KEY];
|
|
83
|
+
|
|
84
|
+
function readRetention(file, value) {
|
|
85
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
`${file}: key “${RETENTION_KEY}” must be an object with enabled and days, not ${JSON.stringify(value)}`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
for (const key of Object.keys(value)) {
|
|
91
|
+
if (!RETENTION_FIELDS.includes(key)) {
|
|
92
|
+
throw new Error(
|
|
93
|
+
`${file}: key “${RETENTION_KEY}” has unknown field “${key}”. ` +
|
|
94
|
+
`Only ${RETENTION_FIELDS.join(', ')} are allowed`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (typeof value.enabled !== 'boolean') {
|
|
99
|
+
throw new Error(
|
|
100
|
+
`${file}: key “${RETENTION_KEY}.enabled” must be true or false, not ${JSON.stringify(value.enabled)}`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
// Plan_17 step 4 does not read days when disabled, so stale values cannot revive deletion after
|
|
104
|
+
// the step 3 incident where a copied default silently turned cleanup back on.
|
|
105
|
+
if (!value.enabled) return { enabled: false };
|
|
106
|
+
if (typeof value.days !== 'number' || !Number.isFinite(value.days) || value.days <= 0) {
|
|
107
|
+
throw new Error(
|
|
108
|
+
`${file}: key “${RETENTION_KEY}.days” must be a positive number of days, not ${JSON.stringify(value.days)}`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
return { enabled: true, days: value.days };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function retentionNotice(config) {
|
|
115
|
+
if (config?.retention?.enabled) {
|
|
116
|
+
const days = config.retention.days;
|
|
117
|
+
return {
|
|
118
|
+
enabled: true,
|
|
119
|
+
text: `Automatic cleanup is ON — run transport older than ${days} days is removed to reclaim disk space. Accounting and reports are never touched. Change or disable: retention in run-config.json.`,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
return {
|
|
123
|
+
enabled: false,
|
|
124
|
+
text: 'Automatic cleanup is OFF — run transport is retained until manually pruned.',
|
|
125
|
+
};
|
|
126
|
+
}
|
|
79
127
|
|
|
80
128
|
/**
|
|
81
129
|
* A run gets a hard wall-clock budget, because the caller's timeout is not a run contract:
|
|
@@ -152,6 +200,10 @@ export function readRunConfig(file = CONFIG_PATH) {
|
|
|
152
200
|
config[key] = readBudgets(file, value);
|
|
153
201
|
continue;
|
|
154
202
|
}
|
|
203
|
+
if (key === RETENTION_KEY) {
|
|
204
|
+
config[key] = readRetention(file, value);
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
155
207
|
if (LIST_KEYS.includes(key)) {
|
|
156
208
|
if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) {
|
|
157
209
|
throw new Error(
|
|
@@ -252,6 +304,7 @@ const state = (config) => [
|
|
|
252
304
|
return `${key}: ${model}${profile.effort ? ` at ${profile.effort} effort` : ''}`;
|
|
253
305
|
}).join('; ')}`,
|
|
254
306
|
`budgets: ${BUDGET_MODES.map((mode) => `${mode}: ${config.budgets?.[mode] ?? DEFAULT_BUDGETS[mode]} minutes`).join('; ')}`,
|
|
307
|
+
`retention: ${config.retention?.enabled ? `on — transport older than ${config.retention.days} days` : 'off — automatic cleanup disabled'}`,
|
|
255
308
|
];
|
|
256
309
|
|
|
257
310
|
function main(argv) {
|
package/src/runner/attach.mjs
CHANGED
|
@@ -112,7 +112,7 @@ export async function attach({ runsRoot, repo, slug, taskHash, orderId, chain, i
|
|
|
112
112
|
return exitCodeFor(meta.status);
|
|
113
113
|
}
|
|
114
114
|
const { reply } = writeFailure(runDir, candidate.status.agent, 'run worker process died without recording a verdict', [
|
|
115
|
-
`Log: ${
|
|
115
|
+
`Log: codex-bridge read ${runDir}`,
|
|
116
116
|
'Any Codex changes remain in the tree — check them with git status',
|
|
117
117
|
]);
|
|
118
118
|
console.log(reply);
|
package/src/runner/codex-cmd.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Everything about invoking the `codex` CLI: which flags a run gets, whether the CLI is
|
|
3
|
-
* there at all, and how its
|
|
3
|
+
* there at all, and how its JSONL stdout reaches events.jsonl while stderr stays separate.
|
|
4
4
|
*
|
|
5
5
|
* The flag sets below are the ones the three agent files carried before, verbatim.
|
|
6
6
|
* Sandboxes especially must not drift: codex-scout is read-only through
|
|
@@ -47,36 +47,74 @@ export function stopCodex(child, onWindows = process.platform === 'win32') {
|
|
|
47
47
|
* shell. One command line through cmd.exe keeps a single code path; every argument is
|
|
48
48
|
* quoted here, and unsafeForCmd() has already refused the ones that cannot be.
|
|
49
49
|
*
|
|
50
|
-
*
|
|
51
|
-
* only on return, so a worker killed at minute twelve left
|
|
52
|
-
* run became unexplainable (2026-07-31, run 114736). Now whatever Codex has already said
|
|
53
|
-
* on disk, and the task text is pushed into stdin by hand since there is no `input` option
|
|
54
|
-
* outside spawnSync. MAX_LOG stays the ceiling; past it the
|
|
55
|
-
*
|
|
50
|
+
* Stdout is streamed, not collected: spawnSync kept every byte in memory and wrote the file
|
|
51
|
+
* only on return, so a worker killed at minute twelve left events.jsonl missing entirely and
|
|
52
|
+
* the run became unexplainable (2026-07-31, run 114736). Now whatever Codex has already said
|
|
53
|
+
* is on disk, and the task text is pushed into stdin by hand since there is no `input` option
|
|
54
|
+
* outside spawnSync. MAX_LOG stays the ceiling; past it the stream is cut rather than the run
|
|
55
|
+
* being killed — a huge stream is a diagnosis, not a reason to lose the work. stderr has its
|
|
56
|
+
* own bounded file because text outside the JSONL protocol must remain distinguishable from
|
|
57
|
+
* protocol events, and a line cut in half cannot be parsed back safely.
|
|
56
58
|
*/
|
|
57
|
-
export function runCodex(args, taskText,
|
|
59
|
+
export function runCodex(args, taskText, eventsPath, budgetMinutes) {
|
|
58
60
|
const onWindows = process.platform === 'win32';
|
|
59
61
|
const deadlineMs =
|
|
60
62
|
typeof budgetMinutes === 'number' && Number.isFinite(budgetMinutes) && budgetMinutes > 0
|
|
61
63
|
? budgetMinutes * 60 * 1000
|
|
62
64
|
: null;
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
65
|
+
const stderrLog = fs.createWriteStream(path.join(path.dirname(eventsPath), 'stderr.log'), { flags: 'a' });
|
|
66
|
+
const eventsLog = fs.createWriteStream(eventsPath, { flags: 'a' });
|
|
67
|
+
const appendTo = (stream, label) => {
|
|
68
|
+
let written = 0;
|
|
69
|
+
let cut = false;
|
|
70
|
+
return (chunk) => {
|
|
71
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), 'utf8');
|
|
72
|
+
if (written >= MAX_LOG) {
|
|
73
|
+
if (!cut) {
|
|
74
|
+
cut = true;
|
|
75
|
+
stream.write(`\nrun-codex: ${label} truncated at ${MAX_LOG} bytes; further output is not saved\n`);
|
|
76
|
+
}
|
|
77
|
+
return;
|
|
72
78
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
log.write(slice);
|
|
79
|
+
const room = MAX_LOG - written;
|
|
80
|
+
const slice = buf.length > room ? buf.subarray(0, room) : buf;
|
|
81
|
+
written += slice.length;
|
|
82
|
+
stream.write(slice);
|
|
83
|
+
};
|
|
79
84
|
};
|
|
85
|
+
const appendStderr = appendTo(stderrLog, 'stderr log');
|
|
86
|
+
const appendEvents = (() => {
|
|
87
|
+
let pending = Buffer.alloc(0);
|
|
88
|
+
let written = 0;
|
|
89
|
+
let cut = false;
|
|
90
|
+
const append = (chunk) => {
|
|
91
|
+
if (cut) return;
|
|
92
|
+
const incoming = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), 'utf8');
|
|
93
|
+
pending = pending.length ? Buffer.concat([pending, incoming]) : incoming;
|
|
94
|
+
let newline = pending.indexOf(0x0a);
|
|
95
|
+
while (newline !== -1) {
|
|
96
|
+
const line = pending.subarray(0, newline + 1);
|
|
97
|
+
pending = pending.subarray(newline + 1);
|
|
98
|
+
if (written + line.length > MAX_LOG) {
|
|
99
|
+
cut = true;
|
|
100
|
+
pending = Buffer.alloc(0);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
written += line.length;
|
|
104
|
+
eventsLog.write(line);
|
|
105
|
+
newline = pending.indexOf(0x0a);
|
|
106
|
+
}
|
|
107
|
+
if (pending.length > MAX_LOG - written) {
|
|
108
|
+
cut = true;
|
|
109
|
+
pending = Buffer.alloc(0);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
const finish = () => {
|
|
113
|
+
if (!cut && pending.length && written + pending.length <= MAX_LOG) eventsLog.write(pending);
|
|
114
|
+
pending = Buffer.alloc(0);
|
|
115
|
+
};
|
|
116
|
+
return { append, finish };
|
|
117
|
+
})();
|
|
80
118
|
|
|
81
119
|
let child;
|
|
82
120
|
if (onWindows) {
|
|
@@ -97,12 +135,36 @@ export function runCodex(args, taskText, logPath, budgetMinutes) {
|
|
|
97
135
|
return new Promise((resolve) => {
|
|
98
136
|
let failed = false;
|
|
99
137
|
let deadlineTimer;
|
|
138
|
+
let stoppedOnDeadline = false;
|
|
139
|
+
let exited = false;
|
|
140
|
+
let elapsedMs = 0;
|
|
100
141
|
const startedAt = Date.now();
|
|
101
|
-
child.stdout.on('data', append);
|
|
102
|
-
child.
|
|
142
|
+
child.stdout.on('data', appendEvents.append);
|
|
143
|
+
child.stdout.on('end', appendEvents.finish);
|
|
144
|
+
child.stderr.on('data', (chunk) => {
|
|
145
|
+
appendStderr(chunk);
|
|
146
|
+
});
|
|
147
|
+
// A log file that cannot be opened must not leave Codex spending quota unattended. Without
|
|
148
|
+
// a listener the stream's 'error' event throws inside the worker, and the crash handler
|
|
149
|
+
// closes the run folder while the CLI keeps running — the orphan of 2026-07-31 with an
|
|
150
|
+
// extra step. The run dies, but it dies with its child: killing first, recording second.
|
|
151
|
+
const onStreamError = (which) => (err) => {
|
|
152
|
+
failed = true;
|
|
153
|
+
const note = `\nrun-codex: cannot write ${which}: ${err.message}\n`;
|
|
154
|
+
if (which !== 'stderr.log' && stderrLog.writable) stderrLog.write(note);
|
|
155
|
+
stopCodex(child, onWindows);
|
|
156
|
+
};
|
|
157
|
+
stderrLog.on('error', onStreamError('stderr.log'));
|
|
158
|
+
eventsLog.on('error', onStreamError('events.jsonl'));
|
|
103
159
|
child.on('error', (err) => {
|
|
104
160
|
failed = true;
|
|
105
|
-
|
|
161
|
+
appendStderr(`\nrun-codex: ${err.message}\n`);
|
|
162
|
+
});
|
|
163
|
+
// 'exit' fires when the process is gone; 'close' waits for stdio, which a grandchild can
|
|
164
|
+
// hold open past the deadline. Judging by 'close' would let the timer brand a run that
|
|
165
|
+
// finished honestly at minute 24 as one the runner killed.
|
|
166
|
+
child.on('exit', () => {
|
|
167
|
+
exited = true;
|
|
106
168
|
});
|
|
107
169
|
// Codex that dies before reading the order leaves an EPIPE here; the exit code and the
|
|
108
170
|
// log already say what happened, and an unhandled stream error would replace that with
|
|
@@ -112,15 +174,38 @@ export function runCodex(args, taskText, logPath, budgetMinutes) {
|
|
|
112
174
|
if (deadlineMs !== null) {
|
|
113
175
|
deadlineTimer = setTimeout(() => {
|
|
114
176
|
const elapsed = Date.now() - startedAt;
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
177
|
+
// The tree is stopped either way — a grandchild still holding stdio is exactly what
|
|
178
|
+
// the budget exists to end — but only a process that was still alive was killed by
|
|
179
|
+
// the deadline, and only that run may say so.
|
|
180
|
+
if (!exited) {
|
|
181
|
+
stoppedOnDeadline = true;
|
|
182
|
+
elapsedMs = elapsed;
|
|
183
|
+
stderrLog.write(
|
|
184
|
+
`\nrun-codex: run stopped on its deadline after ${elapsed} ms (budget ${deadlineMs} ms)\n`,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
118
187
|
stopCodex(child, onWindows);
|
|
119
188
|
}, deadlineMs);
|
|
120
189
|
}
|
|
121
|
-
|
|
190
|
+
// A stream that already failed never calls back from end(), so waiting on both callbacks in
|
|
191
|
+
// sequence hangs the worker until its own deadline — the run then costs a full budget and
|
|
192
|
+
// answers nothing. Closing is best-effort; the verdict is not.
|
|
193
|
+
const close = (stream) =>
|
|
194
|
+
new Promise((done) => {
|
|
195
|
+
if (stream.destroyed || stream.errored) {
|
|
196
|
+
stream.destroy();
|
|
197
|
+
done();
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
stream.end(done);
|
|
201
|
+
});
|
|
202
|
+
child.on('close', async (code) => {
|
|
122
203
|
if (deadlineTimer) clearTimeout(deadlineTimer);
|
|
123
|
-
|
|
204
|
+
if (!stoppedOnDeadline) elapsedMs = Date.now() - startedAt;
|
|
205
|
+
const exit = failed || code === null ? 1 : code;
|
|
206
|
+
await close(stderrLog);
|
|
207
|
+
await close(eventsLog);
|
|
208
|
+
resolve({ exit, stoppedOnDeadline, elapsedMs });
|
|
124
209
|
});
|
|
125
210
|
});
|
|
126
211
|
}
|
|
@@ -193,6 +278,7 @@ export function codexArgs(opts, runDir, isGitRepo) {
|
|
|
193
278
|
if (opts.agent === 'codex-scout') {
|
|
194
279
|
return [
|
|
195
280
|
'exec',
|
|
281
|
+
'--json',
|
|
196
282
|
...CLEAN_ENV,
|
|
197
283
|
'--ignore-user-config',
|
|
198
284
|
'-c',
|
|
@@ -216,6 +302,7 @@ export function codexArgs(opts, runDir, isGitRepo) {
|
|
|
216
302
|
// every edit is rejected ("writing is blocked by read-only sandbox").
|
|
217
303
|
return [
|
|
218
304
|
'exec',
|
|
305
|
+
'--json',
|
|
219
306
|
...CLEAN_ENV,
|
|
220
307
|
'-c',
|
|
221
308
|
effort,
|
|
@@ -246,6 +333,7 @@ export function codexArgs(opts, runDir, isGitRepo) {
|
|
|
246
333
|
// --ignore-user-config, same as scout: review never needs write access.
|
|
247
334
|
return [
|
|
248
335
|
'exec',
|
|
336
|
+
'--json',
|
|
249
337
|
...CLEAN_ENV,
|
|
250
338
|
'--ignore-user-config',
|
|
251
339
|
'-c',
|
package/src/runner/launcher.mjs
CHANGED
|
@@ -36,6 +36,7 @@ import { git, headSha, branchName, worktreeSnapshot, reviewScope } from './git-s
|
|
|
36
36
|
import { codexArgs, requireCodex, runMode, unsafeForCmd } from './codex-cmd.mjs';
|
|
37
37
|
import { runsRoot } from './runs-root.mjs';
|
|
38
38
|
import { resolveProjectRunsDir } from './project-dir.mjs';
|
|
39
|
+
import { cleanupRetention } from '../retention.mjs';
|
|
39
40
|
|
|
40
41
|
/**
|
|
41
42
|
* The worker is this same program re-invoked as `--worker <runDir>`, so the path spawned
|
|
@@ -166,6 +167,15 @@ export async function launcher() {
|
|
|
166
167
|
// first one's edits, and an honest run gets failed for work it never did.
|
|
167
168
|
const busy = opts.agent === 'codex-build' ? activeRun(projectRunsRoot, repoRoot) : null;
|
|
168
169
|
|
|
170
|
+
let retention = null;
|
|
171
|
+
try {
|
|
172
|
+
retention = cleanupRetention(projectRunsRoot, RUN_ENV?.retention);
|
|
173
|
+
} catch {
|
|
174
|
+
// Plan_17 step 4 makes retention advisory housekeeping: one broken filesystem call must never
|
|
175
|
+
// block a new run.
|
|
176
|
+
retention = null;
|
|
177
|
+
}
|
|
178
|
+
|
|
169
179
|
const runDir = makeRunDir(path.join(projectRunsRoot, `${stamp()}_${opts.slug}`));
|
|
170
180
|
setRun(runDir, opts.agent);
|
|
171
181
|
|
|
@@ -194,6 +204,7 @@ export async function launcher() {
|
|
|
194
204
|
...(chain.length ? { continues: chain[0] } : {}),
|
|
195
205
|
// `continued_from` is the exact run the orchestrator named; `continues` above remains the chain base.
|
|
196
206
|
...(continuationGrant ? { continued_from: continuationGrant.run } : {}),
|
|
207
|
+
...(retention ? { retention } : {}),
|
|
197
208
|
});
|
|
198
209
|
|
|
199
210
|
// Printed before anything can go wrong: even a dispatcher that dies mid-run leaves the
|
package/src/runner/worker.mjs
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import fs from 'node:fs';
|
|
14
14
|
import path from 'node:path';
|
|
15
15
|
import { collect, exitCodeFor, AGENTS } from '../write-meta.mjs';
|
|
16
|
+
import { writeStatus } from '../meta/run-state.mjs';
|
|
16
17
|
import { setRun, emitReply } from './run-context.mjs';
|
|
17
18
|
import { git, headSha, branchName, worktreeSnapshot, findFakeDone } from './git-state.mjs';
|
|
18
19
|
import { runCodex } from './codex-cmd.mjs';
|
|
@@ -26,12 +27,19 @@ export async function worker(runDir) {
|
|
|
26
27
|
const repoRoot = cfg.repo;
|
|
27
28
|
setRun(runDir, cfg.agent);
|
|
28
29
|
|
|
29
|
-
const
|
|
30
|
+
const run = await runCodex(
|
|
30
31
|
cfg.args,
|
|
31
32
|
fs.readFileSync(path.join(runDir, 'task.md'), 'utf8'),
|
|
32
|
-
path.join(runDir, '
|
|
33
|
+
path.join(runDir, 'events.jsonl'),
|
|
33
34
|
cfg.budget_minutes,
|
|
34
35
|
);
|
|
36
|
+
// This runner-owned fact cannot be forged through events.jsonl, which Codex can write.
|
|
37
|
+
// Written even when no deadline fired: `false` is the marker that a runner was watching,
|
|
38
|
+
// and an archived run with no field at all must stay distinguishable from a run that lived.
|
|
39
|
+
writeStatus(runDir, {
|
|
40
|
+
stopped_on_deadline: run.stoppedOnDeadline,
|
|
41
|
+
elapsed_ms: run.elapsedMs,
|
|
42
|
+
});
|
|
35
43
|
|
|
36
44
|
if (cfg.agent === 'codex-build') {
|
|
37
45
|
fs.writeFileSync(path.join(runDir, 'head-after.txt'), `${cfg.is_git_repo ? headSha(repoRoot) : ''}\n`);
|
|
@@ -56,7 +64,7 @@ export async function worker(runDir) {
|
|
|
56
64
|
|
|
57
65
|
// collect() writes meta.json and closes status.json; reply.txt comes last, so the launcher
|
|
58
66
|
// seeing a reply is proof the verdict behind it is already on disk.
|
|
59
|
-
const { meta, reply } = collect(runDir, cfg.agent, exit);
|
|
67
|
+
const { meta, reply } = collect(runDir, cfg.agent, run.exit);
|
|
60
68
|
emitReply(reply);
|
|
61
69
|
process.exit(exitCodeFor(meta.status));
|
|
62
70
|
}
|
package/src/write-meta.mjs
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
* Turns one Codex run directory into accounting plus a verdict:
|
|
4
4
|
* node write-meta.mjs <runDir> <agent> <exitCode>
|
|
5
5
|
*
|
|
6
|
-
* This is the only entry point that reads a run's artifacts. It writes meta.json —
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* This is the only entry point that reads a run's artifacts. It writes meta.json — accounting
|
|
7
|
+
* for the two transport files — and derives the run status from what is actually on disk:
|
|
8
|
+
* exit code, structured events, result file, git state.
|
|
9
9
|
* A dispatcher agent therefore cannot report an outcome its own run does not support.
|
|
10
10
|
*
|
|
11
11
|
* The reply lines printed here ARE the reply. Agents forward this stdout verbatim
|
|
@@ -24,6 +24,7 @@ import path from 'node:path';
|
|
|
24
24
|
import { fileURLToPath } from 'node:url';
|
|
25
25
|
import { changedPaths, readJson, readText, size } from './meta/paths.mjs';
|
|
26
26
|
import { splitRunChanges } from './meta/environment.mjs';
|
|
27
|
+
import { readEvents } from './meta/events.mjs';
|
|
27
28
|
import { writeStatus } from './meta/run-state.mjs';
|
|
28
29
|
import { resolveStatus } from './meta/verdict.mjs';
|
|
29
30
|
import { AGENTS, failReply, limitReply } from './meta/reply.mjs';
|
|
@@ -49,24 +50,30 @@ export function collect(runDir, agent, exitCode) {
|
|
|
49
50
|
const cfg = AGENTS[agent];
|
|
50
51
|
if (!cfg) throw new Error(`unknown agent: ${agent} (expected one of ${Object.keys(AGENTS).join(', ')})`);
|
|
51
52
|
|
|
52
|
-
const
|
|
53
|
+
const eventsPath = path.join(runDir, 'events.jsonl');
|
|
54
|
+
const stderrPath = path.join(runDir, 'stderr.log');
|
|
53
55
|
const resultPath = path.join(runDir, cfg.result);
|
|
54
|
-
const
|
|
55
|
-
const
|
|
56
|
+
const eventsBytes = size(eventsPath);
|
|
57
|
+
const stderrBytes = size(stderrPath);
|
|
56
58
|
const result = readJson(resultPath);
|
|
57
59
|
const resultOk = Boolean(result) && cfg.filled(result);
|
|
58
60
|
const exit = exitCode === undefined || exitCode === null ? null : Number(exitCode);
|
|
61
|
+
const events = readEvents(runDir);
|
|
62
|
+
const worker = readJson(path.join(runDir, 'worker.json'));
|
|
59
63
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
64
|
+
const args = Array.isArray(worker?.args) ? worker.args : [];
|
|
65
|
+
const argValue = (flag) => {
|
|
66
|
+
const index = args.indexOf(flag);
|
|
67
|
+
return index >= 0 && typeof args[index + 1] === 'string' ? args[index + 1] : null;
|
|
68
|
+
};
|
|
69
|
+
const { status, reason, carried } = resolveStatus({
|
|
70
|
+
resultOk,
|
|
71
|
+
exit,
|
|
72
|
+
agent,
|
|
73
|
+
result,
|
|
74
|
+
runDir,
|
|
75
|
+
events,
|
|
76
|
+
});
|
|
70
77
|
|
|
71
78
|
const meta = {
|
|
72
79
|
agent,
|
|
@@ -88,14 +95,16 @@ export function collect(runDir, agent, exitCode) {
|
|
|
88
95
|
changedPaths(readText(path.join(runDir, 'state-before.txt')), readText(path.join(runDir, 'state-after.txt'))),
|
|
89
96
|
).environment,
|
|
90
97
|
result_ok: resultOk,
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
98
|
+
events_bytes: eventsBytes,
|
|
99
|
+
stderr_bytes: stderrBytes,
|
|
100
|
+
tokens: events.tokens,
|
|
101
|
+
tokens_reported: events.tokens !== null,
|
|
102
|
+
usage: events.usage,
|
|
103
|
+
model: argValue('-m'),
|
|
104
|
+
sandbox: argValue('--sandbox'),
|
|
96
105
|
// Null for runs made before env.json existed: absence is not the same as "both off".
|
|
97
106
|
env: readJson(path.join(runDir, 'env.json')),
|
|
98
|
-
session_id:
|
|
107
|
+
session_id: events.session_id,
|
|
99
108
|
};
|
|
100
109
|
|
|
101
110
|
fs.writeFileSync(path.join(runDir, 'meta.json'), `${JSON.stringify(meta, null, 2)}\n`);
|