@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
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads the runner's structured Codex transport and turns it into facts for metadata and
|
|
3
|
+
* verdicts. This is the only module that knows the file is JSONL: a model can quote a limit in
|
|
4
|
+
* its own item text, but only these runner-written error events are allowed to stop a retry.
|
|
5
|
+
*/
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Quota exhaustion, and nothing else that merely says "limit". A bare `limit` also appears in
|
|
11
|
+
* "context length limit" — an error about the content of the order, which the orchestrator is
|
|
12
|
+
* supposed to fix and re-send. Calling that LIMIT would forbid the one retry that would work.
|
|
13
|
+
*/
|
|
14
|
+
const LIMIT_RE = /rate[\s_-]*limit|usage[\s_-]*limit|quota|too many requests|\b429\b/i;
|
|
15
|
+
|
|
16
|
+
const record = (value) => value && typeof value === 'object' && !Array.isArray(value);
|
|
17
|
+
|
|
18
|
+
const compact = (value, max = 300) =>
|
|
19
|
+
String(value ?? '').replace(/\s+/g, ' ').trim().slice(0, max);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A broken final line is expected when the runner is stopped while Codex is writing.
|
|
23
|
+
*
|
|
24
|
+
* Whether the file was there at all is reported separately from what it held: a run killed
|
|
25
|
+
* before the CLI said one word leaves an empty stream, and telling the operator that such a
|
|
26
|
+
* run "predates the change" sends them looking for the wrong thing entirely.
|
|
27
|
+
*/
|
|
28
|
+
function readLines(runDir) {
|
|
29
|
+
if (!runDir) return { exists: false, events: [] };
|
|
30
|
+
let text;
|
|
31
|
+
try {
|
|
32
|
+
text = fs.readFileSync(path.join(runDir, 'events.jsonl'), 'utf8');
|
|
33
|
+
} catch {
|
|
34
|
+
return { exists: false, events: [] };
|
|
35
|
+
}
|
|
36
|
+
const events = [];
|
|
37
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
38
|
+
const source = raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw;
|
|
39
|
+
if (!source.trim()) continue;
|
|
40
|
+
try {
|
|
41
|
+
const event = JSON.parse(source);
|
|
42
|
+
if (record(event)) events.push(event);
|
|
43
|
+
} catch {
|
|
44
|
+
// A line cut short by MAX_LOG or a killed process is not a transport verdict.
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return { exists: true, events };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function usageOf(events) {
|
|
51
|
+
const completed = events.filter((event) => event.type === 'turn.completed' && record(event.usage));
|
|
52
|
+
if (!completed.length) return { tokens: null, usage: null };
|
|
53
|
+
|
|
54
|
+
const usage = {};
|
|
55
|
+
let tokens = 0;
|
|
56
|
+
for (const event of completed) {
|
|
57
|
+
for (const [key, value] of Object.entries(event.usage)) {
|
|
58
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
59
|
+
usage[key] = (usage[key] || 0) + value;
|
|
60
|
+
} else if (!(key in usage)) {
|
|
61
|
+
usage[key] = value;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (typeof event.usage.input_tokens === 'number' && Number.isFinite(event.usage.input_tokens)) {
|
|
65
|
+
tokens += event.usage.input_tokens;
|
|
66
|
+
}
|
|
67
|
+
if (typeof event.usage.output_tokens === 'number' && Number.isFinite(event.usage.output_tokens)) {
|
|
68
|
+
tokens += event.usage.output_tokens;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return { tokens, usage };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Finds the last content error emitted by the model.
|
|
76
|
+
*
|
|
77
|
+
* The 2026-08-05 live probe put policy rejections in stderr on a healthy run, so the
|
|
78
|
+
* model's own complaint must stay separate from that noisy diagnostic stream.
|
|
79
|
+
*
|
|
80
|
+
* Only what follows the first `turn.started` is the model's. codex-cli 0.146.0 announces its
|
|
81
|
+
* own configuration warnings through the same `item.completed` error shape before the turn
|
|
82
|
+
* begins: on 2026-08-06 every run of this host carried "`[features].codex_hooks` is
|
|
83
|
+
* deprecated" as its first item, and a failed run would have named that as the reason it
|
|
84
|
+
* failed. A complaint about the order cannot predate the model's first thought — so a run
|
|
85
|
+
* that never opened a turn has no content error at all, and falls back to the transport
|
|
86
|
+
* error or stderr the way it did before this stream existed.
|
|
87
|
+
*/
|
|
88
|
+
export function contentErrorOf(events) {
|
|
89
|
+
const turn = events.findIndex((event) => event?.type === 'turn.started');
|
|
90
|
+
if (turn < 0) return null;
|
|
91
|
+
for (let index = events.length - 1; index > turn; index -= 1) {
|
|
92
|
+
const event = events[index];
|
|
93
|
+
if (event?.type !== 'item.completed' || !record(event.item) || event.item.type !== 'error') continue;
|
|
94
|
+
const value = typeof event.item.message === 'string' ? event.item.message : event.item.text;
|
|
95
|
+
return typeof value === 'string' ? compact(value) : '';
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function payloadOf(event) {
|
|
101
|
+
const source =
|
|
102
|
+
event.type === 'error'
|
|
103
|
+
? event.message ?? event.error
|
|
104
|
+
: event.error?.message ?? event.error ?? event.message;
|
|
105
|
+
if (typeof source === 'string') {
|
|
106
|
+
try {
|
|
107
|
+
const parsed = JSON.parse(source);
|
|
108
|
+
return record(parsed) ? parsed : { message: source };
|
|
109
|
+
} catch {
|
|
110
|
+
return { message: source };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return record(source) ? source : {};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function transportErrorOf(event) {
|
|
117
|
+
if (event.type !== 'error' && event.type !== 'turn.failed') return null;
|
|
118
|
+
const payload = payloadOf(event);
|
|
119
|
+
const nested = record(payload.error) ? payload.error : {};
|
|
120
|
+
const errorType =
|
|
121
|
+
(typeof nested.type === 'string' && nested.type) ||
|
|
122
|
+
(typeof payload.type === 'string' && payload.type !== 'error' && payload.type) ||
|
|
123
|
+
null;
|
|
124
|
+
const message =
|
|
125
|
+
(typeof nested.message === 'string' && nested.message) ||
|
|
126
|
+
(typeof payload.message === 'string' && payload.message) ||
|
|
127
|
+
null;
|
|
128
|
+
const status = payload.status ?? nested.status ?? null;
|
|
129
|
+
const marker = [errorType, message].join(' ');
|
|
130
|
+
const quota = Number(status) === 429 || LIMIT_RE.test(marker);
|
|
131
|
+
const reason = compact(
|
|
132
|
+
[errorType, message, status === null || status === undefined ? '' : `status ${status}`]
|
|
133
|
+
.filter(Boolean)
|
|
134
|
+
.join(': ') ||
|
|
135
|
+
(typeof event.message === 'string' ? event.message : '') ||
|
|
136
|
+
(typeof event.error?.message === 'string' ? event.error.message : '') ||
|
|
137
|
+
event.type,
|
|
138
|
+
);
|
|
139
|
+
return { event, payload, status, error_type: errorType, message, quota, reason };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Reads all usable events and derives accounting without letting unreadable JSONL poison a run.
|
|
144
|
+
* Numeric usage fields are summed across turns; with one turn this preserves the CLI object
|
|
145
|
+
* and its fields exactly, while tokens intentionally count input plus output only.
|
|
146
|
+
*/
|
|
147
|
+
export function readEvents(runDir) {
|
|
148
|
+
const { exists, events } = readLines(runDir);
|
|
149
|
+
const accounting = usageOf(events);
|
|
150
|
+
const started = events.find((event) => event.type === 'thread.started' && event.thread_id);
|
|
151
|
+
const errors = events.map(transportErrorOf).filter(Boolean);
|
|
152
|
+
const transport_error = errors.find((error) => error.quota) || errors[0] || null;
|
|
153
|
+
return {
|
|
154
|
+
events,
|
|
155
|
+
// The file was written, whatever it holds. Pass 2 judges a run by this: a stream the
|
|
156
|
+
// runner promised in its own arguments and did not leave behind is damaged evidence,
|
|
157
|
+
// not an archived run.
|
|
158
|
+
hasStream: exists,
|
|
159
|
+
hasEvents: events.length > 0,
|
|
160
|
+
tokens: accounting.tokens,
|
|
161
|
+
usage: accounting.usage,
|
|
162
|
+
session_id: started ? String(started.thread_id) : null,
|
|
163
|
+
content_error: contentErrorOf(events),
|
|
164
|
+
transport_error,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolves the ordered reason for a failed run.
|
|
3
|
+
*
|
|
4
|
+
* The 2026-08-05 live probe showed that stderr can contain policy noise during a healthy run,
|
|
5
|
+
* so diagnostic text is the final fallback rather than the primary verdict evidence.
|
|
6
|
+
*/
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { contentErrorOf } from './events.mjs';
|
|
9
|
+
import { line, readText } from './paths.mjs';
|
|
10
|
+
|
|
11
|
+
/** Keeps the archived stderr heuristic intact for runs without a useful event message. */
|
|
12
|
+
const stderrReason = (runDir) => {
|
|
13
|
+
const stderr = readText(path.join(runDir, 'stderr.log'));
|
|
14
|
+
const messages = [...stderr.matchAll(/"message"\s*:\s*"((?:[^"\\]|\\.)*)"/g)];
|
|
15
|
+
if (messages.length) return line(messages[messages.length - 1][1].replace(/\\"/g, '"'));
|
|
16
|
+
const lines = stderr.split(/\r?\n/).map((value) => value.trim()).filter(Boolean);
|
|
17
|
+
const errorLine = [...lines].reverse().find((value) => /error|failed|denied|refused/i.test(value));
|
|
18
|
+
return line(errorLine || lines[lines.length - 1] || '');
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// eventData is required, not defaulted: a caller that forgets it would silently fall back to
|
|
22
|
+
// stderr — the exact behaviour this module was written to replace, and a default would hide it.
|
|
23
|
+
export function reasonFrom(runDir, eventData) {
|
|
24
|
+
const content = line(eventData.content_error ?? contentErrorOf(eventData.events || []));
|
|
25
|
+
if (content) return content;
|
|
26
|
+
const transport = line(eventData.transport_error?.reason || eventData.transport_error?.message);
|
|
27
|
+
if (transport) return transport;
|
|
28
|
+
return stderrReason(runDir);
|
|
29
|
+
}
|
package/src/meta/reply.mjs
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import fs from 'node:fs';
|
|
13
13
|
import path from 'node:path';
|
|
14
|
-
import { changedPaths, line, readText } from './paths.mjs';
|
|
14
|
+
import { changedPaths, line, readJson, readText } from './paths.mjs';
|
|
15
15
|
import { splitRunChanges } from './environment.mjs';
|
|
16
16
|
import { scoutCoverage } from './verdict.mjs';
|
|
17
17
|
|
|
@@ -22,6 +22,29 @@ const runChanges = (runDir) =>
|
|
|
22
22
|
changedPaths(readText(path.join(runDir, 'state-before.txt')), readText(path.join(runDir, 'state-after.txt'))),
|
|
23
23
|
);
|
|
24
24
|
|
|
25
|
+
const readCommand = (runDir) => `codex-bridge read ${runDir}`;
|
|
26
|
+
|
|
27
|
+
function formatBytes(bytes) {
|
|
28
|
+
const units = ['B', 'KB', 'MB', 'GB'];
|
|
29
|
+
let value = bytes;
|
|
30
|
+
let index = 0;
|
|
31
|
+
while (value >= 1024 && index < units.length - 1) {
|
|
32
|
+
value /= 1024;
|
|
33
|
+
index += 1;
|
|
34
|
+
}
|
|
35
|
+
return `${value.toFixed(1)} ${units[index]}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function retentionReply(runDir) {
|
|
39
|
+
const retention = readJson(path.join(runDir, 'status.json'))?.retention;
|
|
40
|
+
const bytes = Number(retention?.bytes_freed);
|
|
41
|
+
const runs = Number(retention?.runs);
|
|
42
|
+
const days = Number(retention?.days);
|
|
43
|
+
if (!Number.isFinite(bytes) || bytes <= 0 || !Number.isFinite(runs) || runs <= 0
|
|
44
|
+
|| !Number.isFinite(days) || days <= 0) return null;
|
|
45
|
+
return `Retention: freed ${formatBytes(bytes)} from ${runs} runs older than ${days} days`;
|
|
46
|
+
}
|
|
47
|
+
|
|
25
48
|
export const AGENTS = {
|
|
26
49
|
'codex-scout': {
|
|
27
50
|
result: 'result.json',
|
|
@@ -45,13 +68,15 @@ function scoutReply(ctx) {
|
|
|
45
68
|
const top = (r.findings || [])[0];
|
|
46
69
|
const unknowns = (r.unknowns || []).filter(Boolean);
|
|
47
70
|
const coverage = scoutCoverage(ctx.runDir, r);
|
|
71
|
+
const retention = retentionReply(ctx.runDir);
|
|
48
72
|
return [
|
|
49
73
|
`OK — ${line(r.answer, 160)}`,
|
|
50
74
|
// Any explicit question gets a coverage line, including a valid one-question order.
|
|
51
75
|
...(coverage ? [`Coverage: ${coverage}`] : []),
|
|
52
76
|
`Key finding: ${top ? `${line(top.fact, 130)} (${line(top.where, 60)})` : 'no findings listed'}`,
|
|
53
77
|
`Unresolved: ${unknowns.length ? line(unknowns.join('; '), 160) : 'none'}`,
|
|
54
|
-
|
|
78
|
+
...(retention ? [retention] : []),
|
|
79
|
+
`Report: ${ctx.file('report.md')} · Log: ${readCommand(ctx.runDir)}`,
|
|
55
80
|
];
|
|
56
81
|
}
|
|
57
82
|
|
|
@@ -81,6 +106,7 @@ function buildReply(ctx) {
|
|
|
81
106
|
const files = ctx.carried
|
|
82
107
|
? `${paths ? `${paths} · ` : ''}changes were made by an earlier run of this task`
|
|
83
108
|
: paths || 'worktree untouched';
|
|
109
|
+
const retention = retentionReply(ctx.runDir);
|
|
84
110
|
return [
|
|
85
111
|
`OK — ${line(r.summary, 160)}`,
|
|
86
112
|
`Files: ${touchedPaths.length} changed · ${files}`,
|
|
@@ -91,7 +117,8 @@ function buildReply(ctx) {
|
|
|
91
117
|
: []),
|
|
92
118
|
`Verification: ${verify} — ${verdict}`,
|
|
93
119
|
`Flags: ${flags.length ? `${flags.length} TODO/skip — ${line(flags.slice(0, 3).join(' | '), 140)}` : 'none'}`,
|
|
94
|
-
|
|
120
|
+
...(retention ? [retention] : []),
|
|
121
|
+
`Report: ${ctx.file('report.md')} · Log: ${readCommand(ctx.runDir)}`,
|
|
95
122
|
];
|
|
96
123
|
}
|
|
97
124
|
|
|
@@ -102,24 +129,28 @@ function reviewReply(ctx) {
|
|
|
102
129
|
if (counts[f.severity] !== undefined) counts[f.severity] += 1;
|
|
103
130
|
});
|
|
104
131
|
const top = (r.findings || []).find((f) => f.severity === 'critical' || f.severity === 'high');
|
|
132
|
+
const retention = retentionReply(ctx.runDir);
|
|
105
133
|
return [
|
|
106
134
|
`OK — verdict ${line(r.verdict, 40)}`,
|
|
107
135
|
`Findings: critical ${counts.critical} · high ${counts.high} · medium ${counts.medium} · low ${counts.low}`,
|
|
108
136
|
`Top: ${top ? `${top.severity} ${line(top.file, 80)}:${top.line_start} — ${line(top.title, 90)}` : 'no critical or high findings'}`,
|
|
109
|
-
|
|
137
|
+
...(retention ? [retention] : []),
|
|
138
|
+
`Report: ${ctx.file(path.basename(ctx.resultPath))} · Log: ${readCommand(ctx.runDir)}`,
|
|
110
139
|
];
|
|
111
140
|
}
|
|
112
141
|
|
|
113
142
|
export function failReply(ctx, meta) {
|
|
143
|
+
const retention = retentionReply(ctx.runDir);
|
|
114
144
|
return [
|
|
115
145
|
`FAIL — ${line(meta.reason, 170)}`,
|
|
116
|
-
`Artifacts:
|
|
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}`,
|
|
117
147
|
// A failed build says what it left behind, exactly as a LIMIT does. "The work was not
|
|
118
148
|
// done" is not "the tree is clean": a run can write half a change and then declare fail,
|
|
119
149
|
// and the orchestrator has to know whether there is something to revert before it decides
|
|
120
150
|
// anything else.
|
|
121
151
|
...(ctx.agent === 'codex-build' ? [`Worktree: ${worktreeState(ctx.runDir)}`] : []),
|
|
122
|
-
|
|
152
|
+
...(retention ? [retention] : []),
|
|
153
|
+
`Log: ${readCommand(ctx.runDir)}`,
|
|
123
154
|
];
|
|
124
155
|
}
|
|
125
156
|
|
|
@@ -141,6 +172,7 @@ function worktreeState(runDir) {
|
|
|
141
172
|
}
|
|
142
173
|
|
|
143
174
|
export function limitReply(ctx, meta) {
|
|
175
|
+
const retention = retentionReply(ctx.runDir);
|
|
144
176
|
const rows = [
|
|
145
177
|
'LIMIT — ChatGPT quota exhausted, work not completed',
|
|
146
178
|
`Signal: ${line(meta.reason, 170)}`,
|
|
@@ -151,6 +183,7 @@ export function limitReply(ctx, meta) {
|
|
|
151
183
|
`Worktree: ${touched.length ? `has unfinished changes (${touched.length}), see git-after.txt` : 'no new changes'}`,
|
|
152
184
|
);
|
|
153
185
|
}
|
|
154
|
-
rows.push(
|
|
186
|
+
if (retention) rows.push(retention);
|
|
187
|
+
rows.push(`Log: ${readCommand(ctx.runDir)}`);
|
|
155
188
|
return rows;
|
|
156
189
|
}
|
package/src/meta/run-state.mjs
CHANGED
|
@@ -29,6 +29,11 @@ export const pidAlive = (pid) => {
|
|
|
29
29
|
* Run state on disk, merged over whatever is already there. A killed runner leaves no
|
|
30
30
|
* report and no meta.json, and four abandoned folders from one order were indistinguishable
|
|
31
31
|
* from four runs still working — status.json is what makes that difference visible.
|
|
32
|
+
*
|
|
33
|
+
* It writes status.json and nothing else. Housekeeping facts such as `retention` live here and
|
|
34
|
+
* are read from here; an earlier attempt also appended them to meta.json, which would have made
|
|
35
|
+
* two functions write the same artifact and put the documented artifact order (meta.json before
|
|
36
|
+
* status.json is closed) at the mercy of whoever ran last.
|
|
32
37
|
*/
|
|
33
38
|
export function writeStatus(runDir, patch) {
|
|
34
39
|
const current = readJson(path.join(runDir, 'status.json')) || {};
|
|
@@ -182,7 +187,8 @@ export function writeFailure(runDir, agent, reason, extraLines = []) {
|
|
|
182
187
|
status: 'FAIL',
|
|
183
188
|
reason: line(reason, 300),
|
|
184
189
|
result_ok: false,
|
|
185
|
-
|
|
190
|
+
events_bytes: size(path.join(runDir, 'events.jsonl')),
|
|
191
|
+
stderr_bytes: size(path.join(runDir, 'stderr.log')),
|
|
186
192
|
tokens: null,
|
|
187
193
|
tokens_reported: false,
|
|
188
194
|
model: null,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Explains a current run that never reached either of its two output streams.
|
|
3
|
+
*
|
|
4
|
+
* The existing events file is enough to distinguish this shape: an archived folder has no
|
|
5
|
+
* events stream at all, while a folder with an empty stream and silent stderr proves the CLI
|
|
6
|
+
* never started.
|
|
7
|
+
*/
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { readText } from './paths.mjs';
|
|
11
|
+
|
|
12
|
+
export function startupGap(runDir, events) {
|
|
13
|
+
if (!events?.hasStream || events.hasEvents) return null;
|
|
14
|
+
// Existence before emptiness: a stderr.log that is not there is a missing witness, not a
|
|
15
|
+
// silent one, and the two mean opposite things. Reading an absent file as "" would let a run
|
|
16
|
+
// whose evidence was deleted after the fact pass for a run that never started — the damaged
|
|
17
|
+
// -artifacts check below this one exists precisely to name that case instead.
|
|
18
|
+
const stderr = path.join(runDir, 'stderr.log');
|
|
19
|
+
if (!fs.existsSync(stderr) || readText(stderr)) return null;
|
|
20
|
+
return 'run abandoned at startup: events.jsonl has no events and stderr.log is empty, Codex did not run';
|
|
21
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a run's transport errors live, and whether that evidence can be trusted.
|
|
3
|
+
*
|
|
4
|
+
* Quota exhaustion is the one signal that tells the orchestrator not to retry, so it is read
|
|
5
|
+
* only from structured events the CLI itself writes. Text outside that protocol is diagnostic
|
|
6
|
+
* evidence, not a verdict.
|
|
7
|
+
*/
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { readJson } from './paths.mjs';
|
|
11
|
+
import { readEvents } from './events.mjs';
|
|
12
|
+
|
|
13
|
+
const stderrPath = (runDir) => path.join(runDir, 'stderr.log');
|
|
14
|
+
const hasJsonContract = (runDir) => {
|
|
15
|
+
const args = readJson(path.join(runDir, 'worker.json'))?.args;
|
|
16
|
+
return Array.isArray(args) && args.includes('--json');
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Artifacts that contradict each other about which contract the run was written under.
|
|
21
|
+
*
|
|
22
|
+
* A current run whose worker reached the end of runCodex() has an events.jsonl file. One whose
|
|
23
|
+
* own args promise `--json` but lacks that file lost evidence after the fact; calling it an
|
|
24
|
+
* archived run would make a quota refusal indistinguishable from a quoted one. The deadline
|
|
25
|
+
* marker remains a second consistency check because the same worker also creates stderr.log.
|
|
26
|
+
*/
|
|
27
|
+
export function transportGap(runDir, events = readEvents(runDir)) {
|
|
28
|
+
if (hasJsonContract(runDir) && !events.hasStream) {
|
|
29
|
+
return 'artifacts disagree: worker.json requests --json, but events.jsonl is missing; the evidence was damaged, so a quota refusal cannot be told apart from a quoted one';
|
|
30
|
+
}
|
|
31
|
+
const status = readJson(path.join(runDir, 'status.json'));
|
|
32
|
+
if (!status || !('stopped_on_deadline' in status)) return null;
|
|
33
|
+
if (fs.existsSync(stderrPath(runDir))) return null;
|
|
34
|
+
return 'artifacts disagree: this run recorded a deadline watch but has no stderr.log, so a quota signal cannot be told apart from a quoted one';
|
|
35
|
+
}
|
package/src/meta/verdict.mjs
CHANGED
|
@@ -22,17 +22,12 @@ import {
|
|
|
22
22
|
} from './paths.mjs';
|
|
23
23
|
import { chainBaseline } from './chain.mjs';
|
|
24
24
|
import { splitRunChanges } from './environment.mjs';
|
|
25
|
+
import { readEvents } from './events.mjs';
|
|
25
26
|
import { outcomeGap } from './outcome.mjs';
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
* hit the quota, and reporting LIMIT there tells the orchestrator not to retry when it
|
|
31
|
-
* should. "try again later" is deliberately absent — it is a transient-failure phrase,
|
|
32
|
-
* not a quota one.
|
|
33
|
-
*/
|
|
34
|
-
const LIMIT_RE = /rate.?limit|usage limit|usage_limit|quota exceeded|quota exhausted|too many requests|\b429\b/i;
|
|
35
|
-
const ERROR_RE = /\bERROR\b|error[:=]|stream error|"status"\s*:\s*429|rejected|refused|failed/i;
|
|
27
|
+
import { deadlineReason } from './deadline.mjs';
|
|
28
|
+
import { startupGap } from './startup.mjs';
|
|
29
|
+
import { transportGap } from './transport.mjs';
|
|
30
|
+
import { reasonFrom } from './reason.mjs';
|
|
36
31
|
|
|
37
32
|
// How much prose an answer must carry once coordinates and paths are subtracted. Measured
|
|
38
33
|
// on the scout run of 2026-07-30 that replied with a table of `file.ts:60-79` rows: every
|
|
@@ -42,24 +37,6 @@ const ERROR_RE = /\bERROR\b|error[:=]|stream error|"status"\s*:\s*429|rejected|r
|
|
|
42
37
|
const MIN_SUBSTANCE_CHARS = 80;
|
|
43
38
|
const MIN_SINGLE_SUBSTANCE_CHARS = 200;
|
|
44
39
|
|
|
45
|
-
/**
|
|
46
|
-
* The honest reason for a failure. Codex prints API errors as pretty-printed JSON, so
|
|
47
|
-
* the literally-last line is often just `}` — useless in a one-line reply. Prefer the
|
|
48
|
-
* error message field, then any error line, then the last line that carries text.
|
|
49
|
-
*/
|
|
50
|
-
const reasonFrom = (log) => {
|
|
51
|
-
const messages = [...log.matchAll(/"message"\s*:\s*"((?:[^"\\]|\\.)*)"/g)];
|
|
52
|
-
if (messages.length) return line(messages[messages.length - 1][1].replace(/\\"/g, '"'));
|
|
53
|
-
const lines = log.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
54
|
-
const errorLine = [...lines].reverse().find((l) => /error|failed|denied|refused/i.test(l));
|
|
55
|
-
return line(errorLine || lines[lines.length - 1] || '');
|
|
56
|
-
};
|
|
57
|
-
|
|
58
|
-
const limitSignal = (log) => {
|
|
59
|
-
const hit = log.split(/\r?\n/).find((l) => LIMIT_RE.test(l) && ERROR_RE.test(l));
|
|
60
|
-
return hit ? line(hit) : null;
|
|
61
|
-
};
|
|
62
|
-
|
|
63
40
|
/**
|
|
64
41
|
* Which touched paths the task never authorised. The scope exists because prose does not
|
|
65
42
|
* bind: one build run was told in words to keep away from `!Plans/*.md` and edited them
|
|
@@ -271,17 +248,18 @@ function branchDuringRun(runDir) {
|
|
|
271
248
|
}
|
|
272
249
|
|
|
273
250
|
/**
|
|
274
|
-
* Status comes from artifacts, never from intent.
|
|
275
|
-
*
|
|
276
|
-
*
|
|
251
|
+
* Status comes from artifacts, never from intent. A current run with an events stream but no
|
|
252
|
+
* events and silent stderr is abandoned at startup; an empty result with no structured
|
|
253
|
+
* transport error is FAIL, because diagnostic text cannot turn a quoted limit into LIMIT.
|
|
277
254
|
*/
|
|
278
|
-
export function resolveStatus({
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
255
|
+
export function resolveStatus({ resultOk, exit, agent, result, runDir, events }) {
|
|
256
|
+
const eventData = events ?? readEvents(runDir);
|
|
257
|
+
// First, as the empty raw.log check was before it: a run that never started cannot be graded
|
|
258
|
+
// for what happened in its window. Below this line sit accusations about the run's conduct —
|
|
259
|
+
// a commit, a moved branch — and HEAD moving while Codex was absent is somebody else's doing.
|
|
260
|
+
// Blaming a run that did not exist is the same class of lie every other check here prevents.
|
|
261
|
+
const startup = startupGap(runDir, eventData);
|
|
262
|
+
if (startup) return { status: 'FAIL', reason: startup };
|
|
285
263
|
// Ahead of everything else, including LIMIT: a commit under an explicit ban is a broken
|
|
286
264
|
// contract, not a grade for the work. Whatever else the run produced, the orchestrator has
|
|
287
265
|
// to learn first that history moved under it.
|
|
@@ -291,13 +269,33 @@ export function resolveStatus({ log, logBytes, resultOk, exit, agent, result, ru
|
|
|
291
269
|
const branched = branchDuringRun(runDir);
|
|
292
270
|
if (branched) return { status: 'FAIL', reason: branched };
|
|
293
271
|
}
|
|
272
|
+
const deadline = deadlineReason(runDir);
|
|
273
|
+
if (deadline) return { status: 'FAIL', reason: deadline };
|
|
274
|
+
// Damaged transport evidence is not archived transport evidence. Checked before LIMIT, since
|
|
275
|
+
// the fallback it guards is what LIMIT would otherwise use.
|
|
276
|
+
const transport = transportGap(runDir, eventData);
|
|
277
|
+
if (transport) return { status: 'FAIL', reason: transport };
|
|
278
|
+
// Only a run with nothing to show is judged by its transport errors. The CLI prints an error
|
|
279
|
+
// event for a dropped stream and then retries it, so a run that recovered, filled its result
|
|
280
|
+
// and exited zero did the job — turning that survived error into a verdict would fail honest
|
|
281
|
+
// work, the mirror image of the false LIMIT this whole module exists to remove.
|
|
282
|
+
const transportError =
|
|
283
|
+
eventData.hasEvents && (!resultOk || exit !== 0) ? eventData.transport_error : null;
|
|
284
|
+
if (transportError) {
|
|
285
|
+
// This branch already knows why the run died: the CLI said so itself, with a status. Letting
|
|
286
|
+
// the ordered reason speak here would answer a transport question with the model's older
|
|
287
|
+
// complaint about the task — a LIMIT that never mentions quota is the same "verdict says the
|
|
288
|
+
// wrong thing" defect this module exists to remove.
|
|
289
|
+
return {
|
|
290
|
+
status: transportError.quota ? 'LIMIT' : 'FAIL',
|
|
291
|
+
reason: transportError.reason,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
294
|
if (!resultOk) {
|
|
295
|
-
|
|
296
|
-
if (limit) return { status: 'LIMIT', reason: limit };
|
|
297
|
-
return { status: 'FAIL', reason: reasonFrom(log) || `result is empty, exit=${exit}` };
|
|
295
|
+
return { status: 'FAIL', reason: reasonFrom(runDir, eventData) || `result is empty, exit=${exit}` };
|
|
298
296
|
}
|
|
299
297
|
if (exit !== 0) {
|
|
300
|
-
return { status: 'FAIL', reason: `result exists, but exit=${exit}: ${reasonFrom(
|
|
298
|
+
return { status: 'FAIL', reason: `result exists, but exit=${exit}: ${reasonFrom(runDir, eventData)}` };
|
|
301
299
|
}
|
|
302
300
|
// A red verification is a failed job, not an OK with a footnote. The orchestrator
|
|
303
301
|
// reads the first word; burying "fail" in line three is how a broken build passes.
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/** Owns the transport list and age decision shared by automatic and manual cleanup. */
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { liveRuns, normalizePath } from './hooks/live-runs.mjs';
|
|
5
|
+
|
|
6
|
+
// Plan_17 step 3 found one order's copied list and age default diverging on the same day. Keeping
|
|
7
|
+
// the transport boundary here prevents automatic cleanup from eating accounting files.
|
|
8
|
+
export const TRANSPORT_FILES = Object.freeze(['events.jsonl', 'stderr.log', 'raw.log']);
|
|
9
|
+
|
|
10
|
+
function ageDate(name) {
|
|
11
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})_(\d{2})(\d{2})(\d{2})(?:_|$)/.exec(name);
|
|
12
|
+
if (!match) return null;
|
|
13
|
+
const [, year, month, day, hour, minute, second] = match;
|
|
14
|
+
const timestamp = Date.UTC(
|
|
15
|
+
Number(year), Number(month) - 1, Number(day), Number(hour), Number(minute), Number(second),
|
|
16
|
+
);
|
|
17
|
+
const date = new Date(timestamp);
|
|
18
|
+
if (date.getUTCFullYear() !== Number(year)
|
|
19
|
+
|| date.getUTCMonth() !== Number(month) - 1
|
|
20
|
+
|| date.getUTCDate() !== Number(day)
|
|
21
|
+
|| date.getUTCHours() !== Number(hour)
|
|
22
|
+
|| date.getUTCMinutes() !== Number(minute)
|
|
23
|
+
|| date.getUTCSeconds() !== Number(second)) return null;
|
|
24
|
+
return timestamp;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function nowTimestamp(value) {
|
|
28
|
+
if (value instanceof Date) return value.getTime();
|
|
29
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
|
30
|
+
const parsed = Date.parse(value ?? '');
|
|
31
|
+
return Number.isNaN(parsed) ? Date.now() : parsed;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function ageCutoff(olderThan, now) {
|
|
35
|
+
if (!olderThan) return null;
|
|
36
|
+
if (olderThan.kind === 'duration') {
|
|
37
|
+
const unit = olderThan.unit === 'h' ? 60 * 60 * 1000 : 24 * 60 * 60 * 1000;
|
|
38
|
+
return now - olderThan.amount * unit;
|
|
39
|
+
}
|
|
40
|
+
return Date.parse(`${olderThan.date}T00:00:00.000Z`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Returns whether a run is older than a parsed threshold, using only its folder name. */
|
|
44
|
+
export function runIsOlderThan(runName, olderThan, now = Date.now()) {
|
|
45
|
+
const timestamp = ageDate(runName);
|
|
46
|
+
if (timestamp === null || !olderThan) return false;
|
|
47
|
+
return timestamp < ageCutoff(olderThan, nowTimestamp(now));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Removes only regular transport files from old, non-live runs.
|
|
52
|
+
*
|
|
53
|
+
* Plan_17 step 4 makes cleanup deliberately best effort: a full disk must not stop a new run, and
|
|
54
|
+
* deleting one named file cannot justify recursive removal of a run folder or accounting artifacts.
|
|
55
|
+
*/
|
|
56
|
+
export function cleanupRetention(runsDir, config, options = {}) {
|
|
57
|
+
// Do not inspect days when disabled. A stale or malformed day value must not re-enable cleanup.
|
|
58
|
+
if (!config?.enabled) return null;
|
|
59
|
+
const days = config.days;
|
|
60
|
+
if (typeof days !== 'number' || !Number.isFinite(days) || days <= 0) return null;
|
|
61
|
+
|
|
62
|
+
const filesystem = options.fs || fs;
|
|
63
|
+
const now = options.now ?? Date.now();
|
|
64
|
+
let entries;
|
|
65
|
+
try {
|
|
66
|
+
entries = filesystem.readdirSync(runsDir, { withFileTypes: true });
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let live = new Set();
|
|
72
|
+
try {
|
|
73
|
+
// Normalized like every other path comparison in the package: a case difference between the
|
|
74
|
+
// scan and the probe would make a live run look like a stale one, and this deletes files.
|
|
75
|
+
live = new Set(liveRuns(runsDir).map(({ dir }) => normalizePath(dir)));
|
|
76
|
+
} catch {
|
|
77
|
+
// The launcher wraps the whole pass too; when the shared probe cannot answer, retain every
|
|
78
|
+
// run rather than repeating the abandoned-run incident with a live folder as a target.
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const removedRuns = new Set();
|
|
83
|
+
let bytesFreed = 0;
|
|
84
|
+
for (const entry of entries) {
|
|
85
|
+
let isDirectory;
|
|
86
|
+
try {
|
|
87
|
+
isDirectory = entry.isDirectory();
|
|
88
|
+
} catch {
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (!isDirectory) continue;
|
|
92
|
+
|
|
93
|
+
const runDir = path.join(runsDir, entry.name);
|
|
94
|
+
if (live.has(normalizePath(runDir)) || !runIsOlderThan(entry.name, {
|
|
95
|
+
kind: 'duration',
|
|
96
|
+
amount: days,
|
|
97
|
+
unit: 'd',
|
|
98
|
+
}, now)) continue;
|
|
99
|
+
|
|
100
|
+
for (const filename of TRANSPORT_FILES) {
|
|
101
|
+
const target = path.join(runDir, filename);
|
|
102
|
+
let stat;
|
|
103
|
+
try {
|
|
104
|
+
stat = filesystem.lstatSync(target);
|
|
105
|
+
} catch {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (!stat.isFile()) continue;
|
|
109
|
+
try {
|
|
110
|
+
filesystem.unlinkSync(target);
|
|
111
|
+
} catch {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
removedRuns.add(entry.name);
|
|
115
|
+
if (Number.isFinite(stat.size) && stat.size > 0) bytesFreed += stat.size;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return removedRuns.size ? { bytes_freed: bytesFreed, runs: removedRuns.size, days } : null;
|
|
120
|
+
}
|
package/src/run-codex.mjs
CHANGED
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
* tree and writes the verdict.
|
|
30
30
|
*
|
|
31
31
|
* The split exists because the caller dies long before a 20-25 minute run does. Run
|
|
32
|
-
* `2026-07-31_114736_*` has no
|
|
32
|
+
* `2026-07-31_114736_*` has no events.jsonl at all while 11+ files were written into the tree: the
|
|
33
33
|
* dispatcher's Bash call hit its two-minute timeout, node was killed, spawnSync's buffered
|
|
34
34
|
* output died with it — and Codex kept going, unwatched and unrecorded. Killing that orphan
|
|
35
35
|
* is not the fix either: its edits were the work, and later runs finished on top of them. So
|
|
@@ -84,9 +84,9 @@ if (invokedDirectly) {
|
|
|
84
84
|
const { dir: currentRun, agent: currentAgent } = getRun();
|
|
85
85
|
if (currentRun) {
|
|
86
86
|
try {
|
|
87
|
-
fs.appendFileSync(path.join(currentRun, '
|
|
87
|
+
fs.appendFileSync(path.join(currentRun, 'stderr.log'), `\nrun-codex crash: ${err.stack}\n`);
|
|
88
88
|
const { reply } = writeFailure(currentRun, currentAgent, `Codex runner crashed: ${err.message}`, [
|
|
89
|
-
`Log: ${
|
|
89
|
+
`Log: codex-bridge read ${currentRun}`,
|
|
90
90
|
]);
|
|
91
91
|
emitReply(reply);
|
|
92
92
|
process.exit(1);
|