@lyupro/codex-bridge 0.3.0 → 0.4.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 +60 -0
- package/README.md +27 -7
- package/bin/codex-bridge.mjs +28 -4
- package/cli/doctor.mjs +53 -3
- package/cli/install.mjs +8 -2
- package/cli/manifest.mjs +24 -12
- package/cli/permissions.mjs +179 -0
- package/cli/projects.mjs +17 -4
- package/cli/prune-args.mjs +1 -1
- package/cli/rules-owners.mjs +6 -8
- package/cli/runs-inventory.mjs +2 -1
- package/cli/settings-merge.mjs +93 -45
- package/cli/stop.mjs +33 -20
- package/cli/uninstall.mjs +23 -10
- package/cli/unlock.mjs +239 -0
- package/package.json +3 -2
- package/scripts/check-file-size.mjs +8 -10
- package/src/agents/codex-build.md +2 -0
- package/src/agents/codex-review.md +2 -0
- package/src/agents/codex-scout.md +2 -0
- package/src/cli-names.mjs +6 -0
- package/src/conventions.md +44 -0
- package/src/heartbeat.mjs +1 -1
- package/src/hooks/live-runs.mjs +15 -14
- package/src/hooks/order-gate.mjs +7 -2
- package/src/hooks/prune-guard.mjs +12 -4
- package/src/hooks/reply-guard.mjs +4 -3
- package/src/json-file.mjs +30 -0
- package/src/meta/chain.mjs +8 -6
- package/src/meta/paths.mjs +5 -9
- package/src/meta/pre-start.mjs +30 -0
- package/src/meta/run-state.mjs +31 -16
- package/src/process-identity.mjs +140 -0
- package/src/required-inputs.mjs +42 -0
- package/src/run-config.mjs +4 -8
- package/src/runner/args.mjs +16 -2
- package/src/runner/attach.mjs +23 -17
- package/src/runner/codex-cmd.mjs +1 -1
- package/src/runner/conventions.mjs +39 -0
- package/src/runner/launcher.mjs +43 -16
- package/src/runner/project-dir.mjs +4 -3
- package/src/runner/scope-check.mjs +159 -0
- package/src/runner/worker.mjs +13 -5
- package/src/write-meta.mjs +8 -1
- package/cli/sweep.mjs +0 -144
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* error mode, 2 internal error (config missing/malformed, or an exclude entry has no
|
|
14
14
|
* matching exclusionRationale).
|
|
15
15
|
*
|
|
16
|
-
* Every exclude entry is classified as either a "pattern" (contains a `*`,
|
|
16
|
+
* Every exclude entry is classified as either a "pattern" (contains a `*`, covers a
|
|
17
17
|
* whole category — e.g. `**\/node_modules/**`) or a "specific path" (no `*`, names one
|
|
18
18
|
* file). Specific paths must carry an exclusionRationale entry; patterns are exempt. This
|
|
19
19
|
* is the structural guard against silently hiding a file in exclude without saying why.
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
import fs from 'node:fs/promises';
|
|
22
22
|
import path from 'node:path';
|
|
23
23
|
import { fileURLToPath } from 'node:url';
|
|
24
|
+
import { readJsonFile } from '../src/json-file.mjs';
|
|
24
25
|
|
|
25
26
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
26
27
|
export const REPO_ROOT = path.resolve(HERE, '..');
|
|
@@ -31,17 +32,14 @@ const VALID_MODES = ['error', 'warning'];
|
|
|
31
32
|
|
|
32
33
|
/** Reads and structurally validates .file-size-limit.json. Throws on anything wrong. */
|
|
33
34
|
export async function loadConfig(configPath = CONFIG_PATH) {
|
|
34
|
-
let raw;
|
|
35
|
-
try {
|
|
36
|
-
raw = await fs.readFile(configPath, 'utf8');
|
|
37
|
-
} catch (err) {
|
|
38
|
-
throw new Error(`cannot read ${configPath}: ${err.message}`);
|
|
39
|
-
}
|
|
40
35
|
let parsed;
|
|
41
36
|
try {
|
|
42
|
-
parsed =
|
|
37
|
+
parsed = await readJsonFile(configPath);
|
|
43
38
|
} catch (err) {
|
|
44
|
-
|
|
39
|
+
if (err.cause) {
|
|
40
|
+
throw new Error(`${configPath} is not valid JSON: ${err.cause.message}`, { cause: err.cause });
|
|
41
|
+
}
|
|
42
|
+
throw new Error(`cannot read ${configPath}: ${err.message}`);
|
|
45
43
|
}
|
|
46
44
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
47
45
|
throw new Error(`${configPath} must contain a JSON object`);
|
|
@@ -69,7 +67,7 @@ export async function loadConfig(configPath = CONFIG_PATH) {
|
|
|
69
67
|
return parsed;
|
|
70
68
|
}
|
|
71
69
|
|
|
72
|
-
/** A pattern (contains `*`)
|
|
70
|
+
/** A pattern (contains `*`) covers a category; a specific path (no `*`) names one file. */
|
|
73
71
|
export function isSpecificExclude(entry) {
|
|
74
72
|
return !entry.includes('*');
|
|
75
73
|
}
|
|
@@ -28,6 +28,8 @@ job is to report the run status honestly, including failure.
|
|
|
28
28
|
|
|
29
29
|
- The task statement: what to change and the completion criteria.
|
|
30
30
|
- The path to the repository. If none is given, use the current working directory.
|
|
31
|
+
- Scope patterns are globs relative to the repository root. A pattern that matches nothing there is
|
|
32
|
+
refused before the run starts; a file this task is meant to create is declared with `--scope-new`.
|
|
31
33
|
- Every input listed under **Required dispatcher inputs** above, passed on exactly as given:
|
|
32
34
|
`order id` as `--order-id`, `scope` as `--scope`. Never invent a value, never edit one, never
|
|
33
35
|
reuse an order id from another order — the runner chains runs by that label, and a made-up label
|
|
@@ -27,6 +27,8 @@ opinion, not a verdict.
|
|
|
27
27
|
- branch against base — `--mode base:<branch>`;
|
|
28
28
|
- specific commit — `--mode commit:<sha>`.
|
|
29
29
|
- The path to the repository. If none is given, use the current working directory.
|
|
30
|
+
- Scope patterns are globs relative to the repository root. A pattern that matches nothing there is
|
|
31
|
+
refused before the run starts.
|
|
30
32
|
- Every input listed under **Required dispatcher inputs** above, passed on exactly as given:
|
|
31
33
|
`order id` as `--order-id`. Never invent a value, never edit one, never reuse an order id from
|
|
32
34
|
another order — the runner chains runs by that label, and a made-up label is how a repeat run
|
|
@@ -23,6 +23,8 @@ files, do not run grep, do not retell the report, and do not reason about the ta
|
|
|
23
23
|
|
|
24
24
|
- The task statement (what to find out / what to diagnose / what to review).
|
|
25
25
|
- The path to the repository. If none is given, work in the current working directory.
|
|
26
|
+
- Scope patterns are globs relative to the repository root. A pattern that matches nothing there is
|
|
27
|
+
refused before the run starts.
|
|
26
28
|
- Optional: `effort: <none|minimal|low|medium|high|xhigh|max>` — Codex reasoning depth.
|
|
27
29
|
- Optional: `slug: <short-name>` for the run folder.
|
|
28
30
|
- Every input listed under **Required dispatcher inputs** above, passed on exactly as given:
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Names this CLI answers to. */
|
|
2
|
+
|
|
3
|
+
// Plan_17 §5 found installer and test hook lists drifting; keep executable spellings here so a
|
|
4
|
+
// new alias cannot silently bypass the prune guard. Under src/ because the installed guard reads
|
|
5
|
+
// it too, and cli/ is not copied into the host.
|
|
6
|
+
export const CLI_NAMES = Object.freeze(['codex-bridge', 'codexb']);
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Delegated-run conventions
|
|
2
|
+
|
|
3
|
+
These are the host conventions for every Codex run. Each block records a real acceptance
|
|
4
|
+
violation caught in this repository and turns it into a guard for the next run.
|
|
5
|
+
|
|
6
|
+
## Task boundaries - Plan_16 acceptance incident
|
|
7
|
+
|
|
8
|
+
- Do not refactor code the task did not ask to change.
|
|
9
|
+
- Do not delete code on your own initiative.
|
|
10
|
+
- Do not change behavior branches whose behavior the task does not mention, even inside an
|
|
11
|
+
allowed file.
|
|
12
|
+
|
|
13
|
+
The 2026-08-06 Plan_16 acceptance finding applied a new reason order to a transport branch that
|
|
14
|
+
already named its reason. Preserve existing branches unless the task explicitly changes them.
|
|
15
|
+
|
|
16
|
+
## Contracts without silent defaults - Plan_16 reasonFrom incident
|
|
17
|
+
|
|
18
|
+
- Require arguments that the contract needs instead of supplying a default.
|
|
19
|
+
- Fail loud when a required value is absent; do not silently fall back.
|
|
20
|
+
- Keep volatile identifiers such as model IDs and API versions in configuration, not runtime code.
|
|
21
|
+
|
|
22
|
+
The 2026-08-06 `reasonFrom(runDir, eventData = {})` finding replaced a required contract with a
|
|
23
|
+
silent default. Missing inputs must remain visible at the boundary that can explain them.
|
|
24
|
+
|
|
25
|
+
## Structure and size - 2026-08-06 projects reload incident
|
|
26
|
+
|
|
27
|
+
- Keep every source file at or below 400 lines.
|
|
28
|
+
- Give each module one responsibility.
|
|
29
|
+
- Do not create `utils.mjs` dumping grounds.
|
|
30
|
+
- Never split coherent pieces solely to reach the line limit.
|
|
31
|
+
|
|
32
|
+
The 2026-08-06 `projects` reload finding exposed pseudo-duplicate exports and just-in-case code.
|
|
33
|
+
Keep related code together, but remove a real responsibility or create a focused module when a
|
|
34
|
+
boundary is needed.
|
|
35
|
+
|
|
36
|
+
## Tests and comments - 2026-08-05 step 2b incident
|
|
37
|
+
|
|
38
|
+
- Add a guard test for every invariant accepted by the task.
|
|
39
|
+
- Splitting a file must delete no test case.
|
|
40
|
+
- Comments explain WHY and name the incident or decision that makes the rule necessary.
|
|
41
|
+
|
|
42
|
+
The 2026-08-05 step 2b acceptance found the abandoned-run check below the commit check. Test the
|
|
43
|
+
ordering invariant itself, and leave the incident in the comment so a later cleanup does not
|
|
44
|
+
silently undo it.
|
package/src/heartbeat.mjs
CHANGED
|
@@ -51,7 +51,7 @@ export function heartbeatAge(runDir, now = Date.now()) {
|
|
|
51
51
|
|
|
52
52
|
/**
|
|
53
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 `
|
|
54
|
+
* worktree lock's refusal and `unlock`'s report have to name the same silence to the same operator;
|
|
55
55
|
* two copies of this drifted apart the moment they were written.
|
|
56
56
|
*/
|
|
57
57
|
export function formatSilence(age) {
|
package/src/hooks/live-runs.mjs
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
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`, its runner pid
|
|
6
|
-
* fresh. Keeping those probes and the directory scan here prevents the two protections from
|
|
5
|
+
* only while it says `running`, its runner pid is judged alive for this run, and its progress
|
|
6
|
+
* heartbeat is fresh. Keeping those probes and the directory scan here prevents the two protections from
|
|
7
7
|
* disagreeing about a run, which would recreate the 2026-08-06 incident where a dead Codex
|
|
8
8
|
* child left its worker and lock looking live through a grandchild-held stdio pipe.
|
|
9
9
|
*
|
|
@@ -14,16 +14,17 @@
|
|
|
14
14
|
import fs from 'node:fs';
|
|
15
15
|
import path from 'node:path';
|
|
16
16
|
import { isHeartbeatFresh } from '../heartbeat.mjs';
|
|
17
|
+
import { readJsonFileSync } from '../json-file.mjs';
|
|
18
|
+
import { IDENTITY_DEAD, IDENTITY_FOREIGN, processIdentity } from '../process-identity.mjs';
|
|
17
19
|
|
|
18
|
-
/**
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
}
|
|
20
|
+
/**
|
|
21
|
+
* The hook requires pid judgment plus a fresh heartbeat. Identity uncertainty stays live here
|
|
22
|
+
* (fail-open), while the identity module deliberately treats a missing heartbeat as unverified;
|
|
23
|
+
* isHeartbeatFresh() keeps its pre-Plan_20 missing-file compatibility for this separate question.
|
|
24
|
+
*/
|
|
25
|
+
export const isPidAlive = (pid, runDir, status = {}) => {
|
|
26
|
+
const identity = processIdentity({ runDir, status: { ...status, pid } });
|
|
27
|
+
return identity !== IDENTITY_DEAD && identity !== IDENTITY_FOREIGN;
|
|
27
28
|
};
|
|
28
29
|
|
|
29
30
|
/**
|
|
@@ -56,13 +57,13 @@ function recognizedStatus(runDir, status) {
|
|
|
56
57
|
&& isNonEmptyString(status.agent)
|
|
57
58
|
&& isNonEmptyString(status.slug)
|
|
58
59
|
&& isNonEmptyString(status.repo)
|
|
59
|
-
&&
|
|
60
|
-
&&
|
|
60
|
+
&& isHeartbeatFresh(runDir)
|
|
61
|
+
&& isPidAlive(status.pid, runDir, status);
|
|
61
62
|
}
|
|
62
63
|
|
|
63
64
|
function readStatus(runDir) {
|
|
64
65
|
try {
|
|
65
|
-
return
|
|
66
|
+
return readJsonFileSync(path.join(runDir, 'status.json'));
|
|
66
67
|
} catch {
|
|
67
68
|
return null;
|
|
68
69
|
}
|
package/src/hooks/order-gate.mjs
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
import fs from 'node:fs';
|
|
15
15
|
import os from 'node:os';
|
|
16
16
|
import path from 'node:path';
|
|
17
|
-
import { missingInputs } from '../required-inputs.mjs';
|
|
17
|
+
import { diagnoseInput, missingInputs } from '../required-inputs.mjs';
|
|
18
18
|
import { SUBAGENT_TOOLS } from '../hook-definitions.mjs';
|
|
19
19
|
|
|
20
20
|
const HOME = os.homedir();
|
|
@@ -56,7 +56,12 @@ if (!missing.length) pass();
|
|
|
56
56
|
const reason = [
|
|
57
57
|
'Order gate denied the Agent call because required dispatcher input(s) are missing or still placeholders.',
|
|
58
58
|
'Write each value in tool_input.prompt using `label: value` before launching the dispatcher:',
|
|
59
|
-
...missing.
|
|
59
|
+
...missing.flatMap((entry) => {
|
|
60
|
+
const lines = [`- ${entry.label}: ${entry.explanation} Example: \`${entry.example}\`.`];
|
|
61
|
+
const diagnosis = diagnoseInput(toolInput.prompt, entry.label);
|
|
62
|
+
if (diagnosis) lines.push(` found \`${diagnosis.line}\`, ${diagnosis.reason}`);
|
|
63
|
+
return lines;
|
|
64
|
+
}),
|
|
60
65
|
].join('\n');
|
|
61
66
|
|
|
62
67
|
process.stdout.write(JSON.stringify({
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* Anything unrecognised passes. A guard that misreads its input must not break unrelated host work.
|
|
12
12
|
*/
|
|
13
13
|
import fs from 'node:fs';
|
|
14
|
+
import { CLI_NAMES } from '../cli-names.mjs';
|
|
14
15
|
import { SHELL_TOOLS } from '../hook-definitions.mjs';
|
|
15
16
|
|
|
16
17
|
const SHELL_TOOL_NAMES = new Set(SHELL_TOOLS);
|
|
@@ -18,10 +19,17 @@ const pass = () => process.exit(0);
|
|
|
18
19
|
|
|
19
20
|
// The binary, then the verb, with any path in front of it: an agent reaches the CLI as
|
|
20
21
|
// `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.
|
|
22
|
-
// a
|
|
23
|
-
//
|
|
24
|
-
|
|
22
|
+
// only knew the bare name would be bypassed by the spelling everyone uses inside a clone. Plan_19
|
|
23
|
+
// adds a second executable spelling, so build this matcher from the shared list instead of
|
|
24
|
+
// repeating names here. Matching a lone `prune` is not an option either — `git prune` and
|
|
25
|
+
// `npm prune` are not this package's business, and a guard that breaks unrelated work is a guard
|
|
26
|
+
// that gets uninstalled.
|
|
27
|
+
const escapeRegex = (name) => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
28
|
+
const CLI_NAME_PATTERN = CLI_NAMES.map(escapeRegex).join('|');
|
|
29
|
+
const PRUNE_CALL = new RegExp(
|
|
30
|
+
'(^|[\\s;&|(`])(\\S*[\\\\/])?(?:' + CLI_NAME_PATTERN
|
|
31
|
+
+ ')(\\.mjs)?\\s+prune(\\s|$)',
|
|
32
|
+
);
|
|
25
33
|
|
|
26
34
|
let input;
|
|
27
35
|
try {
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
import fs from 'node:fs';
|
|
30
30
|
import os from 'node:os';
|
|
31
31
|
import path from 'node:path';
|
|
32
|
+
import { readJsonFileSync } from '../json-file.mjs';
|
|
32
33
|
import { isPidAlive, liveRuns, normalizePath } from './live-runs.mjs';
|
|
33
34
|
|
|
34
35
|
const HOME = os.homedir();
|
|
@@ -120,7 +121,7 @@ const takeTry = (agentId, kind) => {
|
|
|
120
121
|
if (!agentId) return 'untracked';
|
|
121
122
|
let seen;
|
|
122
123
|
try {
|
|
123
|
-
seen =
|
|
124
|
+
seen = readJsonFileSync(BLOCKED_FILE);
|
|
124
125
|
} catch {
|
|
125
126
|
seen = {};
|
|
126
127
|
}
|
|
@@ -253,7 +254,7 @@ const statusPath = path.join(runDir, 'status.json');
|
|
|
253
254
|
if (fs.existsSync(statusPath)) {
|
|
254
255
|
let runStatus = null;
|
|
255
256
|
try {
|
|
256
|
-
runStatus =
|
|
257
|
+
runStatus = readJsonFileSync(statusPath);
|
|
257
258
|
} catch {
|
|
258
259
|
runStatus = null;
|
|
259
260
|
}
|
|
@@ -317,7 +318,7 @@ if (!fs.existsSync(metaPath)) {
|
|
|
317
318
|
|
|
318
319
|
let meta;
|
|
319
320
|
try {
|
|
320
|
-
meta =
|
|
321
|
+
meta = readJsonFileSync(metaPath);
|
|
321
322
|
} catch {
|
|
322
323
|
pass();
|
|
323
324
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads JSON written by another program and keeps its file boundary in one place.
|
|
3
|
+
*
|
|
4
|
+
* Plan_24 records the live Windows incident: PowerShell Out-File -Encoding utf8 wrote a BOM
|
|
5
|
+
* that Claude Code accepted but JSON.parse rejected. Readers must strip that BOM consistently.
|
|
6
|
+
*/
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import fsp from 'node:fs/promises';
|
|
9
|
+
|
|
10
|
+
function parseJson(file, raw) {
|
|
11
|
+
const text = raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw;
|
|
12
|
+
try {
|
|
13
|
+
return JSON.parse(text);
|
|
14
|
+
} catch (error) {
|
|
15
|
+
throw new Error(`cannot parse ${file}: ${error.message}`, { cause: error });
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function readJsonFile(file) {
|
|
20
|
+
return parseJson(file, await fsp.readFile(file, 'utf8'));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function readJsonFileWithRaw(file) {
|
|
24
|
+
const raw = await fsp.readFile(file, 'utf8');
|
|
25
|
+
return { raw, value: parseJson(file, raw) };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function readJsonFileSync(file) {
|
|
29
|
+
return parseJson(file, fs.readFileSync(file, 'utf8'));
|
|
30
|
+
}
|
package/src/meta/chain.mjs
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import fs from 'node:fs';
|
|
10
10
|
import path from 'node:path';
|
|
11
11
|
import { createHash } from 'node:crypto';
|
|
12
|
+
import { startedRuns } from './pre-start.mjs';
|
|
12
13
|
import { normalizePath, readJson } from './paths.mjs';
|
|
13
14
|
|
|
14
15
|
/**
|
|
@@ -69,15 +70,16 @@ export function chainRuns(runsRoot, repo, slug, taskHash = '', orderId = '') {
|
|
|
69
70
|
}
|
|
70
71
|
|
|
71
72
|
/**
|
|
72
|
-
* The tree as it stood before the task began: state-before.txt of the FIRST run of the
|
|
73
|
-
* chain.
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
* null when there is no
|
|
73
|
+
* The tree as it stood before the task began: state-before.txt of the FIRST STARTED run of the
|
|
74
|
+
* chain. A pre-start folder has no snapshot, so treating it as first made the next pass lose
|
|
75
|
+
* its baseline in the Plan_23 incident; after that, the first started run is still preferred
|
|
76
|
+
* over a later one because a middle run can end without an "after" snapshot at all.
|
|
77
|
+
* null when there is no started run or that first run never got a snapshot; an existing but
|
|
77
78
|
* empty snapshot is a clean tree, which is data, not absence.
|
|
78
79
|
*/
|
|
79
80
|
export function chainBaseline(runsRoot, repo, slug, taskHash = '', orderId = '') {
|
|
80
|
-
const
|
|
81
|
+
const chain = chainRuns(runsRoot, repo, slug, taskHash, orderId);
|
|
82
|
+
const [first] = startedRuns(runsRoot, chain);
|
|
81
83
|
if (!first) return null;
|
|
82
84
|
try {
|
|
83
85
|
return fs.readFileSync(path.join(runsRoot, first, 'state-before.txt'), 'utf8');
|
package/src/meta/paths.mjs
CHANGED
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
* Two sides spell the same file differently — git prints repo-relative paths with forward
|
|
5
5
|
* slashes, Codex writes whatever it likes — so nothing above this module compares a path
|
|
6
6
|
* before it has been through here. This is also the only module that touches the
|
|
7
|
-
* filesystem for reading: everything else receives text, JSON or a byte count from it.
|
|
7
|
+
* filesystem for reading artifacts: everything else receives text, JSON or a byte count from it.
|
|
8
8
|
*/
|
|
9
9
|
import fs from 'node:fs';
|
|
10
|
+
import { readJsonFileSync } from '../json-file.mjs';
|
|
10
11
|
|
|
11
12
|
/** One line, no newlines, bounded length — a five-line reply must stay five lines. */
|
|
12
13
|
export const line = (value, max = 200) => String(value ?? '').replace(/\s+/g, ' ').trim().slice(0, max);
|
|
@@ -20,17 +21,12 @@ export const readText = (file) => {
|
|
|
20
21
|
};
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
|
-
* A run's JSON artifact, or null when there is none to read. The
|
|
24
|
-
*
|
|
25
|
-
* PowerShell's `Set-Content -Encoding utf8` would then read as "no such run" — silently
|
|
26
|
-
* excusing that run from every check that looks it up.
|
|
24
|
+
* A run's JSON artifact, or null when there is none to read. The shared reader strips the
|
|
25
|
+
* byte-order mark that PowerShell's `Out-File -Encoding utf8` added in the Plan_24 incident.
|
|
27
26
|
*/
|
|
28
27
|
export const readJson = (file) => {
|
|
29
28
|
try {
|
|
30
|
-
|
|
31
|
-
// Compared by code point rather than matched by a literal: a byte-order mark in the
|
|
32
|
-
// source of this file would be invisible to the next reader.
|
|
33
|
-
return JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text);
|
|
29
|
+
return readJsonFileSync(file);
|
|
34
30
|
} catch {
|
|
35
31
|
return null;
|
|
36
32
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Identifies runs that never started Codex and removes them from paid-run views.
|
|
3
|
+
*
|
|
4
|
+
* The explicit state is the durable contract for new guard refusals; the second branch keeps
|
|
5
|
+
* folders written before this fix from poisoning the next launch. Do not infer this from
|
|
6
|
+
* events.jsonl: retention.mjs deletes that transport file from old paid runs, while
|
|
7
|
+
* meta.events_bytes is recorded at verdict time and survives cleanup. The Plan_23 incident
|
|
8
|
+
* showed why confusing those cases would remove repeat protection from a run that spent quota.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
|
|
13
|
+
import { readJson } from './paths.mjs';
|
|
14
|
+
|
|
15
|
+
export function abortedPreStart(runDir) {
|
|
16
|
+
const status = readJson(path.join(runDir, 'status.json'));
|
|
17
|
+
if (status?.state === 'aborted_pre_start') return true;
|
|
18
|
+
if (status?.state !== 'failed') return false;
|
|
19
|
+
|
|
20
|
+
const meta = readJson(path.join(runDir, 'meta.json'));
|
|
21
|
+
return meta?.exit === null
|
|
22
|
+
&& meta?.session_id == null
|
|
23
|
+
&& meta?.events_bytes === 0
|
|
24
|
+
&& meta?.stderr_bytes === 0
|
|
25
|
+
&& meta?.tokens_reported !== true;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function startedRuns(runsRoot, runs) {
|
|
29
|
+
return runs.filter((run) => !abortedPreStart(path.join(runsRoot, run)));
|
|
30
|
+
}
|
package/src/meta/run-state.mjs
CHANGED
|
@@ -10,24 +10,26 @@
|
|
|
10
10
|
import fs from 'node:fs';
|
|
11
11
|
import path from 'node:path';
|
|
12
12
|
import { changedPaths, line, normalizePath, readJson, readText, size } from './paths.mjs';
|
|
13
|
+
import {
|
|
14
|
+
IDENTITY_ALIVE,
|
|
15
|
+
IDENTITY_UNVERIFIED,
|
|
16
|
+
processIdentity,
|
|
17
|
+
} from '../process-identity.mjs';
|
|
13
18
|
|
|
14
19
|
/**
|
|
15
|
-
* Is this pid still running
|
|
16
|
-
*
|
|
20
|
+
* Is this pid still running for this run? Unknown identity remains live here: treating an
|
|
21
|
+
* unverified process as dead would mark a live run abandoned, and readers must fail open.
|
|
17
22
|
*/
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
} catch (err) {
|
|
24
|
-
return err.code === 'EPERM';
|
|
25
|
-
}
|
|
23
|
+
const isAliveIdentity = (identity) => identity === IDENTITY_ALIVE || identity === IDENTITY_UNVERIFIED;
|
|
24
|
+
|
|
25
|
+
const pidAlive = (pid, runDir, status = {}) => {
|
|
26
|
+
const identity = processIdentity({ runDir, status: { ...status, pid } });
|
|
27
|
+
return isAliveIdentity(identity);
|
|
26
28
|
};
|
|
27
29
|
|
|
28
30
|
/**
|
|
29
|
-
* This module deliberately judges by the pid alone, while hooks/live-runs.mjs also
|
|
30
|
-
* fresh progress heartbeat. The two answer different questions, and merging them broke both:
|
|
31
|
+
* This module deliberately judges by the run's pid identity alone, while hooks/live-runs.mjs also
|
|
32
|
+
* requires a fresh progress heartbeat. The two answer different questions, and merging them broke both:
|
|
31
33
|
*
|
|
32
34
|
* - a hook asks "may the operator edit this tree", and a stalled run must stop holding that
|
|
33
35
|
* lock — the operator is editing knowingly, and a stale run should not cost them 25 minutes;
|
|
@@ -80,7 +82,8 @@ export function markAbandoned(runsRoot, currentTree) {
|
|
|
80
82
|
if (!entry.isDirectory()) continue;
|
|
81
83
|
const runDir = path.join(runsRoot, entry.name);
|
|
82
84
|
const status = readJson(path.join(runDir, 'status.json'));
|
|
83
|
-
if (!status || status.state !== 'running'
|
|
85
|
+
if (!status || status.state !== 'running') continue;
|
|
86
|
+
if (pidAlive(status.pid, runDir, status)) continue;
|
|
84
87
|
const metaPath = path.join(runDir, 'meta.json');
|
|
85
88
|
const meta = readJson(metaPath);
|
|
86
89
|
if (!meta && fs.existsSync(metaPath)) continue;
|
|
@@ -138,6 +141,11 @@ export function markAbandoned(runsRoot, currentTree) {
|
|
|
138
141
|
* are not looked at — running scouting alongside a build is the normal way to work.
|
|
139
142
|
*/
|
|
140
143
|
export function activeRun(runsRoot, repo, agent = 'codex-build') {
|
|
144
|
+
return activeRunDetails(runsRoot, repo, agent)?.run || null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Finds the same active run while retaining identity uncertainty for a refusal message. */
|
|
148
|
+
export function activeRunDetails(runsRoot, repo, agent = 'codex-build') {
|
|
141
149
|
let entries;
|
|
142
150
|
try {
|
|
143
151
|
entries = fs.readdirSync(runsRoot, { withFileTypes: true });
|
|
@@ -151,7 +159,8 @@ export function activeRun(runsRoot, repo, agent = 'codex-build') {
|
|
|
151
159
|
const status = readJson(path.join(runDir, 'status.json'));
|
|
152
160
|
if (!status || status.state !== 'running' || status.agent !== agent) continue;
|
|
153
161
|
if (normalizePath(status.repo) !== wanted) continue;
|
|
154
|
-
|
|
162
|
+
const identity = processIdentity({ runDir, status });
|
|
163
|
+
if (isAliveIdentity(identity)) return { run: entry.name, identity };
|
|
155
164
|
}
|
|
156
165
|
return null;
|
|
157
166
|
}
|
|
@@ -194,7 +203,9 @@ export function abandonedBranchDrift(runsRoot, repo, currentBranch) {
|
|
|
194
203
|
* the runner itself. It still goes through meta.json, so there is no second path by
|
|
195
204
|
* which a reply could exist without an artifact backing it.
|
|
196
205
|
*/
|
|
197
|
-
|
|
206
|
+
// The marker is reserved for refusals before Codex starts; Plan_23 showed that calling those
|
|
207
|
+
// ordinary failures makes a no-quota folder consume the same continuation slot as paid work.
|
|
208
|
+
export function writeFailure(runDir, agent, reason, extraLines = [], preStart = false) {
|
|
198
209
|
const meta = {
|
|
199
210
|
agent,
|
|
200
211
|
project: path.basename(path.dirname(runDir)),
|
|
@@ -214,7 +225,11 @@ export function writeFailure(runDir, agent, reason, extraLines = []) {
|
|
|
214
225
|
env: readJson(path.join(runDir, 'env.json')),
|
|
215
226
|
};
|
|
216
227
|
fs.writeFileSync(path.join(runDir, 'meta.json'), `${JSON.stringify(meta, null, 2)}\n`);
|
|
217
|
-
writeStatus(runDir, {
|
|
228
|
+
writeStatus(runDir, {
|
|
229
|
+
state: preStart ? 'aborted_pre_start' : 'failed',
|
|
230
|
+
status: 'FAIL',
|
|
231
|
+
finished_at: meta.finished_at,
|
|
232
|
+
});
|
|
218
233
|
const reply = [`FAIL — ${meta.reason}`, ...extraLines, `Run: ${runDir}`].join('\n');
|
|
219
234
|
return { meta, reply };
|
|
220
235
|
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Answers whether a recorded pid belongs to the process that started this run.
|
|
3
|
+
*
|
|
4
|
+
* Signal 0 is the cheap usual probe. A permission error means only that a process exists, not
|
|
5
|
+
* that it is this run's process, so a stale or missing heartbeat falls through to the OS start
|
|
6
|
+
* time probe. `foreign` is a known-dead diagnostic for callers that need to explain pid reuse;
|
|
7
|
+
* readers treat it like `dead`, while `unverified` remains fail-open.
|
|
8
|
+
*/
|
|
9
|
+
import { spawnSync } from 'node:child_process';
|
|
10
|
+
import { heartbeatAge, HEARTBEAT_STALE_MS } from './heartbeat.mjs';
|
|
11
|
+
|
|
12
|
+
export const IDENTITY_ALIVE = 'alive';
|
|
13
|
+
export const IDENTITY_DEAD = 'dead';
|
|
14
|
+
export const IDENTITY_FOREIGN = 'foreign';
|
|
15
|
+
export const IDENTITY_UNVERIFIED = 'unverified';
|
|
16
|
+
export const PROCESS_START_TOLERANCE_MS = 1_000;
|
|
17
|
+
export const LEGACY_START_GRACE_MS = 30_000;
|
|
18
|
+
|
|
19
|
+
const signalZero = (pid) => process.kill(pid, 0);
|
|
20
|
+
|
|
21
|
+
function defaultCommandRunner(command, args) {
|
|
22
|
+
return spawnSync(command, args, { encoding: 'utf8', windowsHide: true });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function timestamp(value) {
|
|
26
|
+
if (value instanceof Date) return value.getTime();
|
|
27
|
+
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
|
|
28
|
+
if (typeof value !== 'string' || !value.trim()) return null;
|
|
29
|
+
const parsed = Date.parse(value);
|
|
30
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function commandStartTime(pid, commandRunner = defaultCommandRunner) {
|
|
34
|
+
try {
|
|
35
|
+
const command = process.platform === 'win32' ? 'powershell.exe' : 'ps';
|
|
36
|
+
const args = process.platform === 'win32'
|
|
37
|
+
? [
|
|
38
|
+
'-NoProfile',
|
|
39
|
+
'-NonInteractive',
|
|
40
|
+
'-Command',
|
|
41
|
+
// Get-Process answers only for processes of the same user, and the pid this exists to
|
|
42
|
+
// recognise was taken by a SYSTEM service — so CIM has to answer after it. Get-CimInstance
|
|
43
|
+
// returns CreationDate as a DateTime already; the DMTF converter belongs to the older
|
|
44
|
+
// Get-WmiObject and throws "dmtfDate out of range" on this value, which cost this probe
|
|
45
|
+
// its whole purpose once. No $ErrorActionPreference = 'Stop' either: one failing source
|
|
46
|
+
// must fall through to the next rather than end the script.
|
|
47
|
+
[
|
|
48
|
+
`$process = Get-Process -Id ${pid} -ErrorAction SilentlyContinue`,
|
|
49
|
+
"if ($process) { try { $start = $process.StartTime; if ($start) { $start.ToUniversalTime().ToString('o'); exit 0 } } catch {} }",
|
|
50
|
+
`$cim = Get-CimInstance Win32_Process -Filter \"ProcessId=${pid}\" -ErrorAction SilentlyContinue`,
|
|
51
|
+
"if ($cim -and $cim.CreationDate) { $cim.CreationDate.ToUniversalTime().ToString('o') }",
|
|
52
|
+
].join('; '),
|
|
53
|
+
]
|
|
54
|
+
: ['-o', 'lstart=', '-p', String(pid)];
|
|
55
|
+
const result = commandRunner(command, args);
|
|
56
|
+
if (!result || result.error || result.status !== 0) return null;
|
|
57
|
+
const output = String(result.stdout ?? '').trim();
|
|
58
|
+
return timestamp(output);
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function heartbeatIsFreshForIdentity(runDir, now) {
|
|
65
|
+
if (typeof runDir !== 'string' || !runDir.trim()) return false;
|
|
66
|
+
const age = heartbeatAge(runDir, now);
|
|
67
|
+
// isHeartbeatFresh() deliberately treats a missing file as fresh for pre-Plan_20 records.
|
|
68
|
+
// Identity is stricter: a missing heartbeat proves nothing about THIS process, so it must probe.
|
|
69
|
+
return age !== null && age <= HEARTBEAT_STALE_MS;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function optionsFor(input) {
|
|
73
|
+
return input && typeof input === 'object' ? input : {};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function processAlive(pid, kill = signalZero) {
|
|
77
|
+
if (!Number.isInteger(pid) || pid <= 0 || pid > 0x7fff_ffff) return false;
|
|
78
|
+
try {
|
|
79
|
+
kill(pid, 0);
|
|
80
|
+
return true;
|
|
81
|
+
} catch (error) {
|
|
82
|
+
return error?.code === 'EACCES' || error?.code === 'EPERM';
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Return alive/dead/unverified, with foreign as the explicit known-not-this-run case.
|
|
88
|
+
*
|
|
89
|
+
* The probe and signal-0 runner are injectable so tests can exercise every branch without
|
|
90
|
+
* touching a real process. `commandRunner` receives the same command/arguments pair as spawnSync.
|
|
91
|
+
*/
|
|
92
|
+
export function processIdentity(input = {}) {
|
|
93
|
+
const options = optionsFor(input);
|
|
94
|
+
const status = options.status && typeof options.status === 'object' ? options.status : options;
|
|
95
|
+
const pid = options.pid ?? status.pid;
|
|
96
|
+
if (!Number.isInteger(pid) || pid <= 0 || pid > 0x7fff_ffff) return IDENTITY_DEAD;
|
|
97
|
+
|
|
98
|
+
const kill = options.kill || options.signalZero || signalZero;
|
|
99
|
+
try {
|
|
100
|
+
kill(pid, 0);
|
|
101
|
+
} catch (error) {
|
|
102
|
+
if (error?.code === 'ESRCH') return IDENTITY_DEAD;
|
|
103
|
+
if (error?.code === 'EINVAL') return IDENTITY_DEAD;
|
|
104
|
+
if (error?.code !== 'EACCES' && error?.code !== 'EPERM') return IDENTITY_UNVERIFIED;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const now = options.now ?? Date.now();
|
|
108
|
+
if (!options.ignoreHeartbeat && heartbeatIsFreshForIdentity(options.runDir, now)) {
|
|
109
|
+
return IDENTITY_ALIVE;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const probe = options.probe || options.processStartProbe
|
|
113
|
+
|| ((value) => commandStartTime(value, options.commandRunner));
|
|
114
|
+
let processStartedAt;
|
|
115
|
+
try {
|
|
116
|
+
processStartedAt = timestamp(probe(pid, options));
|
|
117
|
+
} catch {
|
|
118
|
+
processStartedAt = null;
|
|
119
|
+
}
|
|
120
|
+
if (processStartedAt === null) return IDENTITY_UNVERIFIED;
|
|
121
|
+
|
|
122
|
+
const hasRecordedStart = Object.prototype.hasOwnProperty.call(status, 'process_started_at')
|
|
123
|
+
&& status.process_started_at !== null
|
|
124
|
+
&& status.process_started_at !== '';
|
|
125
|
+
if (hasRecordedStart) {
|
|
126
|
+
const recordedStart = timestamp(status.process_started_at);
|
|
127
|
+
if (recordedStart === null) return IDENTITY_UNVERIFIED;
|
|
128
|
+
return Math.abs(processStartedAt - recordedStart) <= PROCESS_START_TOLERANCE_MS
|
|
129
|
+
? IDENTITY_ALIVE
|
|
130
|
+
: IDENTITY_FOREIGN;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const startedAt = timestamp(status.started_at);
|
|
134
|
+
if (startedAt === null) return IDENTITY_UNVERIFIED;
|
|
135
|
+
return processStartedAt > startedAt + LEGACY_START_GRACE_MS
|
|
136
|
+
? IDENTITY_FOREIGN
|
|
137
|
+
: IDENTITY_ALIVE;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export const probeProcessStart = commandStartTime;
|