@lyupro/codex-bridge 0.3.1 → 0.5.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 +92 -0
- package/README.md +88 -84
- package/bin/codex-bridge.mjs +38 -11
- package/cli/copy.mjs +7 -6
- package/cli/doctor.mjs +73 -12
- package/cli/hook-targets.mjs +39 -0
- package/cli/hook.mjs +34 -0
- package/cli/hosts.mjs +28 -4
- package/cli/install-record.mjs +287 -0
- package/cli/install.mjs +73 -39
- package/cli/invoked-directly.mjs +37 -0
- package/cli/manifest.mjs +90 -164
- package/cli/permissions.mjs +179 -0
- package/cli/projects.mjs +20 -5
- package/cli/prune-args.mjs +1 -1
- package/cli/remove-layout.mjs +59 -0
- package/cli/rules-owners.mjs +6 -8
- package/cli/runs-inventory.mjs +2 -1
- package/cli/settings-merge.mjs +182 -50
- package/cli/stop.mjs +33 -20
- package/cli/uninstall.mjs +64 -36
- package/cli/unlock.mjs +239 -0
- package/cli/update.mjs +109 -48
- package/package.json +2 -2
- package/scripts/check-file-size.mjs +8 -10
- package/scripts/isolated-roots.mjs +13 -0
- package/scripts/run-tests.mjs +14 -4
- package/src/agents/codex-build.md +17 -13
- package/src/agents/codex-review.md +15 -9
- package/src/agents/codex-scout.md +16 -11
- package/src/commands/env.md +4 -4
- package/src/commands/usage.md +8 -6
- package/src/heartbeat.mjs +1 -1
- package/src/hook-definitions.mjs +17 -4
- package/src/hooks/guard-tries.mjs +59 -0
- package/src/hooks/live-runs.mjs +31 -20
- package/src/hooks/order-gate.mjs +7 -2
- package/src/hooks/reply-guard.mjs +4 -60
- package/src/hooks/stop-guard.mjs +71 -0
- 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 +26 -3
- package/src/runner/attach.mjs +32 -19
- package/src/runner/codex-cmd.mjs +1 -1
- package/src/runner/continuation.mjs +34 -3
- package/src/runner/launcher.mjs +54 -28
- 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/stop-contract.mjs +15 -0
- package/src/write-meta.mjs +12 -1
- package/cli/sweep.mjs +0 -144
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { STOP_TOOLS } from '../hook-definitions.mjs';
|
|
4
|
+
import { runsRoot } from '../runner/runs-root.mjs';
|
|
5
|
+
import { renderStopCommand, STOP_REASON } from '../stop-contract.mjs';
|
|
6
|
+
import { allLiveRuns, normalizePath } from './live-runs.mjs';
|
|
7
|
+
import { takeTry } from './guard-tries.mjs';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Plan_31 catches TaskStop killing the dispatcher wrapper while the runner keeps writing. It
|
|
11
|
+
* must stay silent on malformed input or uncertain liveness, and the second denial budget keeps
|
|
12
|
+
* an unrelated host task from becoming impossible to stop.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const pass = () => process.exit(0);
|
|
16
|
+
const isObject = (value) => value && typeof value === 'object' && !Array.isArray(value);
|
|
17
|
+
|
|
18
|
+
function readInput() {
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(fs.readFileSync(0, 'utf8'));
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function runForRepository(cwd) {
|
|
27
|
+
const repository = normalizePath(cwd);
|
|
28
|
+
if (!repository) return null;
|
|
29
|
+
|
|
30
|
+
let live;
|
|
31
|
+
try {
|
|
32
|
+
live = allLiveRuns(runsRoot(), { requireConfirmedIdentity: true });
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return live
|
|
38
|
+
.filter(({ status }) => {
|
|
39
|
+
const runRepository = normalizePath(status.repo);
|
|
40
|
+
return runRepository === repository || repository.startsWith(`${runRepository}/`);
|
|
41
|
+
})
|
|
42
|
+
.sort(({ status: left }, { status: right }) => right.repo.length - left.repo.length)[0] ?? null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function deny(run) {
|
|
46
|
+
const runName = path.basename(run.dir);
|
|
47
|
+
// Key by run folder: the host task_id can belong to an unrelated background build, so it cannot
|
|
48
|
+
// safely identify the dispatcher this guard just warned about.
|
|
49
|
+
if (takeTry(normalizePath(run.dir), 'stop', 1) !== 'granted') pass();
|
|
50
|
+
|
|
51
|
+
process.stdout.write(JSON.stringify({
|
|
52
|
+
hookSpecificOutput: {
|
|
53
|
+
hookEventName: 'PreToolUse',
|
|
54
|
+
permissionDecision: 'deny',
|
|
55
|
+
permissionDecisionReason:
|
|
56
|
+
`TaskStop denied: live run ${runName} is working. `
|
|
57
|
+
+ `Use ${renderStopCommand(runName)}. ${STOP_REASON}`,
|
|
58
|
+
},
|
|
59
|
+
}));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const input = readInput();
|
|
63
|
+
if (!isObject(input) || !STOP_TOOLS.includes(input.tool_name)) pass();
|
|
64
|
+
if (!isObject(input.tool_input) || typeof input.tool_input.task_id !== 'string' || !input.tool_input.task_id) {
|
|
65
|
+
pass();
|
|
66
|
+
}
|
|
67
|
+
if (typeof input.cwd !== 'string' || !input.cwd) pass();
|
|
68
|
+
|
|
69
|
+
const run = runForRepository(input.cwd);
|
|
70
|
+
if (!run) pass();
|
|
71
|
+
deny(run);
|
|
@@ -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;
|
package/src/required-inputs.mjs
CHANGED
|
@@ -89,6 +89,48 @@ export function extractValue(promptText, label) {
|
|
|
89
89
|
return cleanValue(flagMatch[1] ?? flagMatch[2] ?? flagMatch[3]);
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
const MAX_DIAGNOSIS_LINE_LENGTH = 160;
|
|
93
|
+
|
|
94
|
+
function readableDiagnosisLine(line) {
|
|
95
|
+
const trimmed = line.trim();
|
|
96
|
+
if (trimmed.length <= MAX_DIAGNOSIS_LINE_LENGTH) return trimmed;
|
|
97
|
+
return `${trimmed.slice(0, MAX_DIAGNOSIS_LINE_LENGTH - 1)}…`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Explains the Plan_28 incident without widening the strict input parser's accepted spellings. */
|
|
101
|
+
export function diagnoseInput(promptText, label) {
|
|
102
|
+
if (typeof label !== 'string' || !label.trim()) return null;
|
|
103
|
+
const prompt = typeof promptText === 'string' ? promptText : '';
|
|
104
|
+
const labelPattern = escapeRegExp(label).replaceAll('\\ ', '\\s+');
|
|
105
|
+
const candidatePattern = new RegExp(
|
|
106
|
+
`^[ \\t]*(?:[-*][ \\t]*)?(?:[*_` + '`' + `]?${labelPattern}[*_` + '`' + `]?)(?=$|[^A-Za-z0-9_])[^\\r\\n]*`,
|
|
107
|
+
'i',
|
|
108
|
+
);
|
|
109
|
+
const candidateLine = prompt.split(/\r?\n/).find((line) => candidatePattern.test(line));
|
|
110
|
+
if (candidateLine === undefined) return null;
|
|
111
|
+
|
|
112
|
+
const value = extractValue(candidateLine, label);
|
|
113
|
+
if (value !== null && !isPlaceholder(value)) return null;
|
|
114
|
+
|
|
115
|
+
const line = readableDiagnosisLine(candidateLine);
|
|
116
|
+
if (value !== null && isPlaceholder(value)) {
|
|
117
|
+
const displayedValue = value.trim();
|
|
118
|
+
if (!displayedValue) return { line, reason: 'value is empty; replace it with a concrete value' };
|
|
119
|
+
return {
|
|
120
|
+
line,
|
|
121
|
+
reason: `value \`${displayedValue}\` is a placeholder; replace it with a concrete value`,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const labelTail = candidateLine.match(
|
|
126
|
+
new RegExp(`^[ \\t]*(?:[-*][ \\t]*)?(?:[*_` + '`' + `]?${labelPattern}[*_` + '`' + `]?)([^\\r\\n]*)$`, 'i'),
|
|
127
|
+
)?.[1] ?? '';
|
|
128
|
+
if (labelTail.includes(':')) {
|
|
129
|
+
return { line, reason: `expected \`${label}:\` with nothing between the label and the colon` };
|
|
130
|
+
}
|
|
131
|
+
return { line, reason: `expected \`${label}: value\` with a separator immediately after the label` };
|
|
132
|
+
}
|
|
133
|
+
|
|
92
134
|
/** Keeps the 2026-08-05 continuation incident's run and reason in the shared input parser. */
|
|
93
135
|
export function parseContinuationGrant(promptText) {
|
|
94
136
|
const prompt = typeof promptText === 'string' ? promptText : '';
|
package/src/run-config.mjs
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
import fs from 'node:fs';
|
|
20
20
|
import path from 'node:path';
|
|
21
21
|
import { fileURLToPath } from 'node:url';
|
|
22
|
+
import { readJsonFileSync } from './json-file.mjs';
|
|
22
23
|
|
|
23
24
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
24
25
|
export const CONFIG_PATH = path.join(HERE, 'run-config.json');
|
|
@@ -163,18 +164,13 @@ function readBudgets(file, value) {
|
|
|
163
164
|
}
|
|
164
165
|
|
|
165
166
|
export function readRunConfig(file = CONFIG_PATH) {
|
|
166
|
-
let raw;
|
|
167
|
-
try {
|
|
168
|
-
raw = fs.readFileSync(file, 'utf8');
|
|
169
|
-
} catch {
|
|
170
|
-
return { ...DEFAULTS };
|
|
171
|
-
}
|
|
172
167
|
let parsed;
|
|
173
168
|
try {
|
|
174
|
-
parsed =
|
|
169
|
+
parsed = readJsonFileSync(file);
|
|
175
170
|
} catch (err) {
|
|
171
|
+
if (err.code) return { ...DEFAULTS };
|
|
176
172
|
throw new Error(
|
|
177
|
-
`${file} cannot be parsed as JSON (${err.message}). Fix or delete the file — without it, ` +
|
|
173
|
+
`${file} cannot be parsed as JSON (${err.cause?.message || err.message}). Fix or delete the file — without it, ` +
|
|
178
174
|
'the default mode applies: hooks and plugins are disabled.',
|
|
179
175
|
);
|
|
180
176
|
}
|
package/src/runner/args.mjs
CHANGED
|
@@ -97,17 +97,40 @@ export function parseArgs(argv) {
|
|
|
97
97
|
die(`--effort must be one of: ${allowedEfforts}; got ${JSON.stringify(opts.effort)}`);
|
|
98
98
|
}
|
|
99
99
|
opts.repo = path.resolve(opts.repo || process.cwd());
|
|
100
|
-
|
|
100
|
+
const slugSource = opts.slug ? '--slug' : '--order-id';
|
|
101
|
+
opts.slug = (opts.slug || opts.orderId).replace(/[^A-Za-z0-9._-]+/g, '-');
|
|
102
|
+
// Plan_29 incident: the generic `build` slug made an honest order inherit a days-old chain;
|
|
103
|
+
// reject a value with no alphanumeric anchor instead of creating an empty or dot-only folder.
|
|
104
|
+
if (!/[A-Za-z0-9]/.test(opts.slug)) {
|
|
105
|
+
die(
|
|
106
|
+
`${slugSource} produces an unusable run folder name after sanitization: ` +
|
|
107
|
+
`${JSON.stringify(opts.slug)} must contain a letter or digit.`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
101
110
|
opts.mode = opts.mode || 'uncommitted';
|
|
102
111
|
// A writing run without a declared scope is how `!Plans/*.md` got edited by a run that was
|
|
103
112
|
// told in prose not to touch them: prose does not bind, a file list does. Required rather
|
|
104
113
|
// than defaulted to "everything", and checked here — before the folder exists and before a
|
|
105
114
|
// single token of someone else's quota is spent.
|
|
106
|
-
|
|
115
|
+
const declaredScopePatterns = String(opts.scope || '')
|
|
107
116
|
.split(',')
|
|
108
117
|
.map((p) => p.trim())
|
|
109
118
|
.filter(Boolean);
|
|
110
|
-
|
|
119
|
+
opts.scopeNewPatterns = String(opts['scope-new'] || '')
|
|
120
|
+
.split(',')
|
|
121
|
+
.map((p) => p.trim())
|
|
122
|
+
.filter(Boolean);
|
|
123
|
+
// Plan_27 keeps ordinary patterns strict: only paths named explicitly as new may be absent.
|
|
124
|
+
opts.scopePatterns = [...declaredScopePatterns, ...opts.scopeNewPatterns];
|
|
125
|
+
// Only the writing agent creates files, so only its scope may name one that does not exist yet.
|
|
126
|
+
// Accepting the flag elsewhere would hand scout and review a way to waive the check for nothing.
|
|
127
|
+
if (opts.agent !== 'codex-build' && opts.scopeNewPatterns.length) {
|
|
128
|
+
die(
|
|
129
|
+
`--scope-new is only for codex-build: ${opts.agent} does not create files. ` +
|
|
130
|
+
'Action: drop --scope-new and pass every path through --scope.',
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
if (opts.agent === 'codex-build' && !declaredScopePatterns.length) {
|
|
111
134
|
const scopeInput = requiredInput(opts.agent, 'scope');
|
|
112
135
|
die(
|
|
113
136
|
`--scope is required for codex-build: ${scopeInput.source} supplies the ${scopeInput.label}. ` +
|
package/src/runner/attach.mjs
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/** Finds a live run for an order and waits for its verdict when a repeated launcher call attaches. */
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { readJsonFileSync } from '../json-file.mjs';
|
|
5
|
+
import { IDENTITY_DEAD, IDENTITY_FOREIGN, processAlive, processIdentity } from '../process-identity.mjs';
|
|
4
6
|
import { exitCodeFor, writeFailure, chainRuns } from '../write-meta.mjs';
|
|
5
7
|
|
|
6
8
|
// The attach call is the only process that waits for a worker it did not spawn. Keeping that
|
|
@@ -8,38 +10,49 @@ import { exitCodeFor, writeFailure, chainRuns } from '../write-meta.mjs';
|
|
|
8
10
|
const POLL_MS = 500;
|
|
9
11
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
10
12
|
|
|
13
|
+
// The 2026-08-10_220535_plan25-2-install-table-two-roots incident showed that ATTACH alone
|
|
14
|
+
// can be mistaken for this pass; state the previous run and the fact that no work was started.
|
|
15
|
+
function announceAttach(runDir, startedAt) {
|
|
16
|
+
console.log(`ATTACH=${runDir} started=${startedAt}`);
|
|
17
|
+
console.log(`This is the answer of the previous run started at ${startedAt}; no new work was started.`);
|
|
18
|
+
}
|
|
19
|
+
|
|
11
20
|
export const readJsonFile = (file) => {
|
|
12
21
|
try {
|
|
13
|
-
return
|
|
22
|
+
return readJsonFileSync(file);
|
|
14
23
|
} catch {
|
|
15
24
|
return null;
|
|
16
25
|
}
|
|
17
26
|
};
|
|
18
27
|
|
|
19
28
|
/**
|
|
20
|
-
* Is this pid still running?
|
|
21
|
-
*
|
|
22
|
-
* this run still going" would be one answer too many.
|
|
29
|
+
* Is this pid still running for this run? Unknown identity remains live so an attach does not
|
|
30
|
+
* declare a worker dead merely because the identity probe was unavailable.
|
|
23
31
|
*/
|
|
24
|
-
export const alive = (pid) => {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
process.kill(pid, 0);
|
|
28
|
-
return true;
|
|
29
|
-
} catch (err) {
|
|
30
|
-
return err.code === 'EPERM';
|
|
31
|
-
}
|
|
32
|
+
export const alive = (pid, runDir, status = {}) => {
|
|
33
|
+
const identity = processIdentity({ runDir, status: { ...status, pid } });
|
|
34
|
+
return identity !== IDENTITY_DEAD && identity !== IDENTITY_FOREIGN;
|
|
32
35
|
};
|
|
33
36
|
|
|
34
|
-
/**
|
|
35
|
-
|
|
37
|
+
/**
|
|
38
|
+
* Blocks until the worker's reply exists, or until the worker is provably gone without one.
|
|
39
|
+
*
|
|
40
|
+
* Identity is settled once, before the loop: a recycled pid would otherwise keep this call waiting
|
|
41
|
+
* for a reply nobody is going to write, and asking again every 500 ms would spawn the start-time
|
|
42
|
+
* probe for the whole length of a 20-minute run. Inside the loop the question is only whether the
|
|
43
|
+
* number is still busy, which signal 0 answers for free.
|
|
44
|
+
*/
|
|
45
|
+
export async function waitForReply(runDir, workerPid, status = {}) {
|
|
36
46
|
const replyPath = path.join(runDir, 'reply.txt');
|
|
47
|
+
if (!alive(workerPid, runDir, status)) {
|
|
48
|
+
return fs.existsSync(replyPath) ? fs.readFileSync(replyPath, 'utf8').replace(/\s+$/, '') : null;
|
|
49
|
+
}
|
|
37
50
|
let pollsSinceDeath = 0;
|
|
38
51
|
for (;;) {
|
|
39
52
|
if (fs.existsSync(replyPath)) return fs.readFileSync(replyPath, 'utf8').replace(/\s+$/, '');
|
|
40
53
|
// A dead worker will never write it. Two extra polls before believing that: the pid is
|
|
41
54
|
// released around the same moment as the last write, and the order is not guaranteed.
|
|
42
|
-
if (
|
|
55
|
+
if (processAlive(workerPid)) {
|
|
43
56
|
pollsSinceDeath = 0;
|
|
44
57
|
} else {
|
|
45
58
|
pollsSinceDeath += 1;
|
|
@@ -80,11 +93,11 @@ export async function attach({ runsRoot, repo, slug, taskHash, orderId, chain, i
|
|
|
80
93
|
const entry = sameOrder[i];
|
|
81
94
|
const dir = path.join(runsRoot, entry.run);
|
|
82
95
|
if (fs.existsSync(path.join(dir, 'reply.txt'))) {
|
|
83
|
-
|
|
96
|
+
announceAttach(dir, entry.status.started_at);
|
|
84
97
|
console.log(fs.readFileSync(path.join(dir, 'reply.txt'), 'utf8').replace(/\s+$/, ''));
|
|
85
98
|
return exitCodeFor(readJsonFile(path.join(dir, 'meta.json'))?.status);
|
|
86
99
|
}
|
|
87
|
-
if (alive(entry.status.pid)) {
|
|
100
|
+
if (alive(entry.status.pid, dir, entry.status)) {
|
|
88
101
|
candidate = entry;
|
|
89
102
|
break;
|
|
90
103
|
}
|
|
@@ -92,8 +105,8 @@ export async function attach({ runsRoot, repo, slug, taskHash, orderId, chain, i
|
|
|
92
105
|
if (!candidate) return null;
|
|
93
106
|
|
|
94
107
|
const runDir = path.join(runsRoot, candidate.run);
|
|
95
|
-
|
|
96
|
-
const replyText = await waitForReply(runDir, candidate.status.pid);
|
|
108
|
+
announceAttach(runDir, candidate.status.started_at);
|
|
109
|
+
const replyText = await waitForReply(runDir, candidate.status.pid, candidate.status);
|
|
97
110
|
if (replyText !== null) {
|
|
98
111
|
console.log(replyText);
|
|
99
112
|
return exitCodeFor(readJsonFile(path.join(runDir, 'meta.json'))?.status);
|