@lyupro/codex-bridge 0.1.4 → 0.3.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 +48 -0
- package/README.md +25 -4
- package/bin/codex-bridge.mjs +37 -0
- package/cli/doctor.mjs +20 -2
- package/cli/install.mjs +18 -4
- package/cli/manifest.mjs +3 -1
- 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/sweep.mjs +144 -0
- package/cli/table.mjs +110 -0
- package/package.json +1 -1
- package/src/agents/codex-build.md +7 -6
- package/src/agents/codex-review.md +8 -8
- package/src/agents/codex-scout.md +7 -6
- package/src/commands/usage.md +4 -3
- package/src/heartbeat.mjs +80 -0
- package/src/hook-definitions.mjs +11 -0
- package/src/hooks/live-runs.mjs +13 -6
- package/src/hooks/prune-guard.mjs +62 -0
- package/src/hooks/reply-guard.mjs +2 -2
- package/src/hooks/worktree-lock.mjs +8 -1
- 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 +25 -2
- package/src/meta/startup.mjs +21 -0
- package/src/meta/transport.mjs +35 -0
- package/src/meta/verdict.mjs +40 -42
- package/src/no-self-execution.mjs +18 -0
- 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 +158 -31
- package/src/runner/launcher.mjs +11 -0
- package/src/runner/worker.mjs +14 -3
- package/src/write-meta.mjs +31 -22
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one definition of run progress: a heartbeat file in the run folder, its age, and the
|
|
3
|
+
* stale threshold. On 2026-08-06 run 2026-08-06_204007_build left a live worker waiting on
|
|
4
|
+
* Codex's grandchild; a worker timer alone would have kept that dead run looking alive, so
|
|
5
|
+
* the timer stops at exit.
|
|
6
|
+
*/
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
|
|
10
|
+
export const HEARTBEAT_FILE = 'heartbeat';
|
|
11
|
+
// Ten seconds bounds metadata writes when a 300KB stream arrives as many chunks; output still
|
|
12
|
+
// proves movement immediately in memory, while the periodic worker stamp keeps silence fresh.
|
|
13
|
+
export const HEARTBEAT_WRITE_INTERVAL_MS = 10_000;
|
|
14
|
+
export const HEARTBEAT_INTERVAL_MS = 30_000;
|
|
15
|
+
// Five minutes leaves a wide margin over the longest model-thinking silence seen in healthy
|
|
16
|
+
// runs. The active-child timer refreshes below it; the margin is safer than unlocking a tree
|
|
17
|
+
// while a working build is silent, which is the failure this heartbeat exists to prevent.
|
|
18
|
+
export const HEARTBEAT_STALE_MS = 5 * 60 * 1000;
|
|
19
|
+
|
|
20
|
+
export function heartbeatPath(runDir) {
|
|
21
|
+
return path.join(runDir, HEARTBEAT_FILE);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A failed stamp is deliberately swallowed: the verdict must not depend on this hint file. */
|
|
25
|
+
export function createHeartbeat(runDir) {
|
|
26
|
+
let lastStamp = Number.NEGATIVE_INFINITY;
|
|
27
|
+
return {
|
|
28
|
+
stamp(now = Date.now()) {
|
|
29
|
+
if (!Number.isFinite(now)) return;
|
|
30
|
+
if (now >= lastStamp && now - lastStamp < HEARTBEAT_WRITE_INTERVAL_MS) return;
|
|
31
|
+
lastStamp = now;
|
|
32
|
+
try {
|
|
33
|
+
fs.writeFileSync(heartbeatPath(runDir), `${now}\n`);
|
|
34
|
+
} catch {
|
|
35
|
+
// A read-only or full run folder must still produce its normal verdict and artifacts.
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Missing is an explicit legacy state: old live records keep their lock until their pid dies. */
|
|
42
|
+
export function heartbeatAge(runDir, now = Date.now()) {
|
|
43
|
+
try {
|
|
44
|
+
const stat = fs.statSync(heartbeatPath(runDir));
|
|
45
|
+
if (!stat.isFile() || !Number.isFinite(now)) return Number.POSITIVE_INFINITY;
|
|
46
|
+
return Math.max(0, now - stat.mtimeMs);
|
|
47
|
+
} catch (err) {
|
|
48
|
+
return err.code === 'ENOENT' ? null : Number.POSITIVE_INFINITY;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* How long a run has been silent, in words. It lives beside the age it formats because both the
|
|
54
|
+
* worktree lock's refusal and `sweep`'s report have to name the same silence to the same operator;
|
|
55
|
+
* two copies of this drifted apart the moment they were written.
|
|
56
|
+
*/
|
|
57
|
+
export function formatSilence(age) {
|
|
58
|
+
if (age === null || !Number.isFinite(age)) return 'an unknown duration';
|
|
59
|
+
const seconds = Math.floor(Math.max(0, age) / 1000);
|
|
60
|
+
if (seconds < 60) return `${seconds} second${seconds === 1 ? '' : 's'}`;
|
|
61
|
+
const minutes = Math.floor(seconds / 60);
|
|
62
|
+
const remainingSeconds = seconds % 60;
|
|
63
|
+
if (minutes < 60) {
|
|
64
|
+
return `${minutes} minute${minutes === 1 ? '' : 's'}`
|
|
65
|
+
+ (remainingSeconds ? ` ${remainingSeconds} second${remainingSeconds === 1 ? '' : 's'}` : '');
|
|
66
|
+
}
|
|
67
|
+
const hours = Math.floor(minutes / 60);
|
|
68
|
+
const remainingMinutes = minutes % 60;
|
|
69
|
+
return `${hours} hour${hours === 1 ? '' : 's'}`
|
|
70
|
+
+ (remainingMinutes ? ` ${remainingMinutes} minute${remainingMinutes === 1 ? '' : 's'}` : '');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* A running record is live when its heartbeat is fresh. Missing heartbeats remain live only for
|
|
75
|
+
* pre-Plan_20 records, an explicit fail-closed compatibility choice that preserves their lock.
|
|
76
|
+
*/
|
|
77
|
+
export function isHeartbeatFresh(runDir, now = Date.now()) {
|
|
78
|
+
const age = heartbeatAge(runDir, now);
|
|
79
|
+
return age === null || age <= HEARTBEAT_STALE_MS;
|
|
80
|
+
}
|
package/src/hook-definitions.mjs
CHANGED
|
@@ -29,8 +29,19 @@ export const WRITE_TOOLS = Object.freeze(['Write', 'Edit', 'MultiEdit', 'Noteboo
|
|
|
29
29
|
/** A PreToolUse matcher is a regular expression over tool names, so alternation covers all four. */
|
|
30
30
|
export const WRITE_TOOL_MATCHER = WRITE_TOOLS.join('|');
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Shell tools, listed here for the same reason as the two lists above: the prune guard must see the
|
|
34
|
+
* command whichever name a host gives its shell, and a name known only to the hook would leave that
|
|
35
|
+
* host's agents able to delete run artifacts.
|
|
36
|
+
*/
|
|
37
|
+
export const SHELL_TOOLS = Object.freeze(['Bash', 'PowerShell']);
|
|
38
|
+
|
|
39
|
+
/** A PreToolUse matcher is a regular expression over tool names, so alternation covers both. */
|
|
40
|
+
export const SHELL_TOOL_MATCHER = SHELL_TOOLS.join('|');
|
|
41
|
+
|
|
32
42
|
export const HOOK_DEFINITIONS = Object.freeze([
|
|
33
43
|
Object.freeze({ event: 'SubagentStop', matcher: '*', file: 'reply-guard.mjs' }),
|
|
34
44
|
Object.freeze({ event: 'PreToolUse', matcher: SUBAGENT_TOOL_MATCHER, file: 'order-gate.mjs' }),
|
|
35
45
|
Object.freeze({ event: 'PreToolUse', matcher: WRITE_TOOL_MATCHER, file: 'worktree-lock.mjs' }),
|
|
46
|
+
Object.freeze({ event: 'PreToolUse', matcher: SHELL_TOOL_MATCHER, file: 'prune-guard.mjs' }),
|
|
36
47
|
]);
|
package/src/hooks/live-runs.mjs
CHANGED
|
@@ -2,12 +2,18 @@
|
|
|
2
2
|
* Shared, fail-open knowledge of live Codex runs.
|
|
3
3
|
*
|
|
4
4
|
* The reply guard and the worktree lock must answer the same question: a status.json is live
|
|
5
|
-
* only while it says `running
|
|
6
|
-
* directory scan here prevents the two protections from
|
|
7
|
-
*
|
|
5
|
+
* only while it says `running`, its runner pid accepts signal 0, and its progress heartbeat is
|
|
6
|
+
* fresh. Keeping those probes and the directory scan here prevents the two protections from
|
|
7
|
+
* disagreeing about a run, which would recreate the 2026-08-06 incident where a dead Codex
|
|
8
|
+
* child left its worker and lock looking live through a grandchild-held stdio pipe.
|
|
9
|
+
*
|
|
10
|
+
* The heartbeat is required here and deliberately NOT in meta/run-state.mjs: releasing a lock
|
|
11
|
+
* early costs an operator one knowing edit, while closing a record early makes a second writer
|
|
12
|
+
* of its meta.json. See the comment above pidAlive() there.
|
|
8
13
|
*/
|
|
9
14
|
import fs from 'node:fs';
|
|
10
15
|
import path from 'node:path';
|
|
16
|
+
import { isHeartbeatFresh } from '../heartbeat.mjs';
|
|
11
17
|
|
|
12
18
|
/** Signal 0 tests existence without stopping the process; EPERM still means that it exists. */
|
|
13
19
|
export const isPidAlive = (pid) => {
|
|
@@ -41,7 +47,7 @@ function isNonEmptyString(value) {
|
|
|
41
47
|
return typeof value === 'string' && Boolean(value.trim());
|
|
42
48
|
}
|
|
43
49
|
|
|
44
|
-
function recognizedStatus(status) {
|
|
50
|
+
function recognizedStatus(runDir, status) {
|
|
45
51
|
return Boolean(status)
|
|
46
52
|
&& typeof status === 'object'
|
|
47
53
|
&& !Array.isArray(status)
|
|
@@ -50,7 +56,8 @@ function recognizedStatus(status) {
|
|
|
50
56
|
&& isNonEmptyString(status.agent)
|
|
51
57
|
&& isNonEmptyString(status.slug)
|
|
52
58
|
&& isNonEmptyString(status.repo)
|
|
53
|
-
&& isPidAlive(status.pid)
|
|
59
|
+
&& isPidAlive(status.pid)
|
|
60
|
+
&& isHeartbeatFresh(runDir);
|
|
54
61
|
}
|
|
55
62
|
|
|
56
63
|
function readStatus(runDir) {
|
|
@@ -75,7 +82,7 @@ export function liveRuns(runsDir) {
|
|
|
75
82
|
if (!entry.isDirectory()) continue;
|
|
76
83
|
const dir = path.join(runsDir, entry.name);
|
|
77
84
|
const status = readStatus(dir);
|
|
78
|
-
if (recognizedStatus(status)) result.push({ dir, status });
|
|
85
|
+
if (recognizedStatus(dir, status)) result.push({ dir, status });
|
|
79
86
|
}
|
|
80
87
|
return result;
|
|
81
88
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* PreToolUse guard that keeps `codex-bridge prune` out of agent-issued shell commands.
|
|
4
|
+
*
|
|
5
|
+
* Deletion is an operator action. The command already refuses without a terminal, but a guard in
|
|
6
|
+
* front of it is the second rubezh Plan_17 asks for: the TTY check lives inside the very process an
|
|
7
|
+
* agent is asking to start, and a package that trusts one check has trusted prose twice before
|
|
8
|
+
* (Plan_13, Plan_14) and been wrong both times. Denying the call means no deleting process is
|
|
9
|
+
* created at all.
|
|
10
|
+
*
|
|
11
|
+
* Anything unrecognised passes. A guard that misreads its input must not break unrelated host work.
|
|
12
|
+
*/
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import { SHELL_TOOLS } from '../hook-definitions.mjs';
|
|
15
|
+
|
|
16
|
+
const SHELL_TOOL_NAMES = new Set(SHELL_TOOLS);
|
|
17
|
+
const pass = () => process.exit(0);
|
|
18
|
+
|
|
19
|
+
// The binary, then the verb, with any path in front of it: an agent reaches the CLI as
|
|
20
|
+
// `codex-bridge`, as `node bin/codex-bridge.mjs`, or through an absolute path, and a guard that
|
|
21
|
+
// only knew the bare name would be bypassed by the spelling everyone uses inside a clone. Matching
|
|
22
|
+
// a lone `prune` is not an option either — `git prune` and `npm prune` are not this package's
|
|
23
|
+
// business, and a guard that breaks unrelated work is a guard that gets uninstalled.
|
|
24
|
+
const PRUNE_CALL = /(^|[\s;&|(`])(\S*[\\/])?codex-bridge(\.mjs)?\s+prune(\s|$)/;
|
|
25
|
+
|
|
26
|
+
let input;
|
|
27
|
+
try {
|
|
28
|
+
input = JSON.parse(fs.readFileSync(0, 'utf8'));
|
|
29
|
+
} catch {
|
|
30
|
+
pass();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) pass();
|
|
34
|
+
if (!SHELL_TOOL_NAMES.has(input.tool_name)) pass();
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Strips the parts of a command line that are data rather than instructions: heredoc bodies and
|
|
38
|
+
* quoted strings. Found the moment this guard existed — it denied the commit that introduced it,
|
|
39
|
+
* because the message text quoted the command name. Nothing was going to be deleted; the words
|
|
40
|
+
* were cargo. A guard that cannot tell an invocation from a mention refuses honest work, and a
|
|
41
|
+
* guard that refuses honest work is removed, after which it guards nothing.
|
|
42
|
+
*/
|
|
43
|
+
function withoutQuotedText(command) {
|
|
44
|
+
return command
|
|
45
|
+
.replace(/<<-?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1[\s\S]*?^\s*\2\s*$/gm, ' ')
|
|
46
|
+
.replace(/'[^']*'/g, ' ')
|
|
47
|
+
.replace(/"[^"]*"/g, ' ');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const command = input.tool_input?.command;
|
|
51
|
+
if (typeof command !== 'string' || !command.trim()) pass();
|
|
52
|
+
if (!PRUNE_CALL.test(withoutQuotedText(command))) pass();
|
|
53
|
+
|
|
54
|
+
process.stdout.write(JSON.stringify({
|
|
55
|
+
hookSpecificOutput: {
|
|
56
|
+
hookEventName: 'PreToolUse',
|
|
57
|
+
permissionDecision: 'deny',
|
|
58
|
+
permissionDecisionReason: 'codex-bridge prune deletes run artifacts and is an operator action, '
|
|
59
|
+
+ 'not an agent one. There is deliberately no bypass flag. Ask the operator to run it in '
|
|
60
|
+
+ 'their own terminal; `codex-bridge projects` shows the same store read-only.',
|
|
61
|
+
},
|
|
62
|
+
}));
|
|
@@ -284,7 +284,7 @@ if (fs.existsSync(statusPath)) {
|
|
|
284
284
|
`status.json currently says state=running, but process pid ${fact(runStatus.pid)} is ` +
|
|
285
285
|
'dead and meta.json is missing: an interrupted Bash call killed the runner. Codex ' +
|
|
286
286
|
'survives the runner and keeps editing the tree — in run 2026-07-31_114736, changes ' +
|
|
287
|
-
'in 11+ files survived while
|
|
287
|
+
'in 11+ files survived while no run artifact and no meta.json were ever recorded.',
|
|
288
288
|
runStatus,
|
|
289
289
|
),
|
|
290
290
|
);
|
|
@@ -300,7 +300,7 @@ if (fs.existsSync(statusPath)) {
|
|
|
300
300
|
`status.json currently says state=abandoned (${fact(runStatus.abandoned_reason)}, ` +
|
|
301
301
|
`${fact(runStatus.abandoned_at)}): the runner died without recording a verdict. Codex ` +
|
|
302
302
|
'survives the runner and keeps editing the tree — in run 2026-07-31_114736, changes in ' +
|
|
303
|
-
'11+ files survived while
|
|
303
|
+
'11+ files survived while no run artifact and no meta.json were ever recorded.',
|
|
304
304
|
runStatus,
|
|
305
305
|
),
|
|
306
306
|
);
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import fs from 'node:fs';
|
|
12
12
|
import path from 'node:path';
|
|
13
|
+
import { formatSilence, heartbeatAge } from '../heartbeat.mjs';
|
|
13
14
|
import { WRITE_TOOLS } from '../hook-definitions.mjs';
|
|
14
15
|
import { runsRoot } from '../runner/runs-root.mjs';
|
|
15
16
|
import { allLiveRuns, normalizePath } from './live-runs.mjs';
|
|
@@ -65,6 +66,12 @@ process.stdout.write(JSON.stringify({
|
|
|
65
66
|
permissionDecision: 'deny',
|
|
66
67
|
permissionDecisionReason: `Worktree lock denied ${input.tool_name}: ${targetPath} is inside repository `
|
|
67
68
|
+ `${status.repo}, held by live run folder ${dir} (agent ${status.agent}, slug ${status.slug}). `
|
|
68
|
-
|
|
69
|
+
// "Working, last progress X ago" rather than "silent for X": this hook only ever denies a
|
|
70
|
+
// run whose heartbeat is still fresh — a stale one is not live here and the edit passes. The
|
|
71
|
+
// first live probe printed "silent for 10 seconds" next to a stop command on a perfectly
|
|
72
|
+
// healthy run, which reads as "it hung, kill it" and would cost the operator 20 minutes of
|
|
73
|
+
// work they meant to keep.
|
|
74
|
+
+ `It is working; last progress ${formatSilence(heartbeatAge(dir))} ago. `
|
|
75
|
+
+ `To take the repository back before it finishes: codex-bridge stop ${path.basename(dir)}.`,
|
|
69
76
|
},
|
|
70
77
|
}));
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads the runner-owned deadline fact from status.json.
|
|
3
|
+
*
|
|
4
|
+
* stderr.log is diagnostic text and can contain a quoted copy of the runner's own message,
|
|
5
|
+
* so it cannot prove that the runner actually stopped the process (Plan_15).
|
|
6
|
+
*/
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { readJson } from './paths.mjs';
|
|
9
|
+
|
|
10
|
+
export function deadlineReason(runDir) {
|
|
11
|
+
const status = readJson(path.join(runDir, 'status.json'));
|
|
12
|
+
if (status?.stopped_on_deadline !== true) return null;
|
|
13
|
+
// How long the run lived, not the budget it was given: a field named for the budget and
|
|
14
|
+
// holding the elapsed time is read wrong by whoever opens the folder months later.
|
|
15
|
+
const elapsed = status.elapsed_ms;
|
|
16
|
+
return Number.isFinite(elapsed)
|
|
17
|
+
? `run stopped on its deadline after ${elapsed} ms`
|
|
18
|
+
: 'run stopped on its deadline';
|
|
19
|
+
}
|
|
@@ -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
|
@@ -25,10 +25,31 @@ export const pidAlive = (pid) => {
|
|
|
25
25
|
}
|
|
26
26
|
};
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* This module deliberately judges by the pid alone, while hooks/live-runs.mjs also requires a
|
|
30
|
+
* fresh progress heartbeat. The two answer different questions, and merging them broke both:
|
|
31
|
+
*
|
|
32
|
+
* - a hook asks "may the operator edit this tree", and a stalled run must stop holding that
|
|
33
|
+
* lock — the operator is editing knowingly, and a stale run should not cost them 25 minutes;
|
|
34
|
+
* - this module asks "may a second paid run write into this tree" and "is this folder finished".
|
|
35
|
+
* Closing a run whose pid still lives makes markAbandoned the second writer of its meta.json:
|
|
36
|
+
* the live worker reaches collect() afterwards and overwrites the verdict, so the documented
|
|
37
|
+
* artifact order stops being a contract. Letting activeRun() past a live pid starts a second
|
|
38
|
+
* writing run in one worktree, which is the 2026-08-05 incident this whole lock exists for.
|
|
39
|
+
*
|
|
40
|
+
* A stalled-but-live run is closed by `stop`, which kills the process first and only then
|
|
41
|
+
* records the verdict — one writer, in the right order.
|
|
42
|
+
*/
|
|
43
|
+
|
|
28
44
|
/**
|
|
29
45
|
* Run state on disk, merged over whatever is already there. A killed runner leaves no
|
|
30
46
|
* report and no meta.json, and four abandoned folders from one order were indistinguishable
|
|
31
47
|
* from four runs still working — status.json is what makes that difference visible.
|
|
48
|
+
*
|
|
49
|
+
* It writes status.json and nothing else. Housekeeping facts such as `retention` live here and
|
|
50
|
+
* are read from here; an earlier attempt also appended them to meta.json, which would have made
|
|
51
|
+
* two functions write the same artifact and put the documented artifact order (meta.json before
|
|
52
|
+
* status.json is closed) at the mercy of whoever ran last.
|
|
32
53
|
*/
|
|
33
54
|
export function writeStatus(runDir, patch) {
|
|
34
55
|
const current = readJson(path.join(runDir, 'status.json')) || {};
|
|
@@ -126,7 +147,8 @@ export function activeRun(runsRoot, repo, agent = 'codex-build') {
|
|
|
126
147
|
const wanted = normalizePath(repo);
|
|
127
148
|
for (const entry of entries) {
|
|
128
149
|
if (!entry.isDirectory()) continue;
|
|
129
|
-
const
|
|
150
|
+
const runDir = path.join(runsRoot, entry.name);
|
|
151
|
+
const status = readJson(path.join(runDir, 'status.json'));
|
|
130
152
|
if (!status || status.state !== 'running' || status.agent !== agent) continue;
|
|
131
153
|
if (normalizePath(status.repo) !== wanted) continue;
|
|
132
154
|
if (pidAlive(status.pid)) return entry.name;
|
|
@@ -182,7 +204,8 @@ export function writeFailure(runDir, agent, reason, extraLines = []) {
|
|
|
182
204
|
status: 'FAIL',
|
|
183
205
|
reason: line(reason, 300),
|
|
184
206
|
result_ok: false,
|
|
185
|
-
|
|
207
|
+
events_bytes: size(path.join(runDir, 'events.jsonl')),
|
|
208
|
+
stderr_bytes: size(path.join(runDir, 'stderr.log')),
|
|
186
209
|
tokens: null,
|
|
187
210
|
tokens_reported: false,
|
|
188
211
|
model: null,
|