@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
package/src/runner/codex-cmd.mjs
CHANGED
|
@@ -263,7 +263,7 @@ export function requireCodex(runDir, agent) {
|
|
|
263
263
|
const why = (probe.stderr || probe.error?.message || 'codex --version is not responding').trim();
|
|
264
264
|
const { reply } = writeFailure(runDir, agent, `Codex CLI unavailable: ${why}`, [
|
|
265
265
|
'Operator check: codex --version (and codex login if authorization is rejected)',
|
|
266
|
-
]);
|
|
266
|
+
], true);
|
|
267
267
|
console.log(reply);
|
|
268
268
|
process.exit(1);
|
|
269
269
|
}
|
|
@@ -37,6 +37,28 @@ const grantExample = `Example: \`continue: ${CONTINUATION_INPUT.example}\`.`;
|
|
|
37
37
|
const grantAction =
|
|
38
38
|
'Action: ask the orchestrator for an explicit grant naming the run and reason; do not invent or reuse a continuation.';
|
|
39
39
|
|
|
40
|
+
// The 2026-08-10_220535_plan25-2-install-table-two-roots incident showed that a refusal must
|
|
41
|
+
// carry the repair line; otherwise a typo forces an unnecessary directory listing and retry.
|
|
42
|
+
function lastRunOutcome(runsRootPath, chain) {
|
|
43
|
+
const run = chain[chain.length - 1];
|
|
44
|
+
if (!run) return null;
|
|
45
|
+
const status = readJson(path.join(runsRootPath, run, 'status.json')) || {};
|
|
46
|
+
const meta = readJson(path.join(runsRootPath, run, 'meta.json')) || {};
|
|
47
|
+
const outcomeStatus = String(meta.status ?? status.status ?? status.state ?? 'unknown').trim() || 'unknown';
|
|
48
|
+
const outcomeReason = String(meta.reason ?? status.reason ?? 'reason not recorded').trim() || 'reason not recorded';
|
|
49
|
+
return { run, status: outcomeStatus, reason: outcomeReason };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function lastRunHints(runsRootPath, chain, grantReason) {
|
|
53
|
+
const last = lastRunOutcome(runsRootPath, chain);
|
|
54
|
+
if (!last) {
|
|
55
|
+
return 'Last run: none. Outcome: none. Ready grant line: none (there is no run to continue).';
|
|
56
|
+
}
|
|
57
|
+
const reason = String(grantReason ?? last.reason).trim() || last.reason;
|
|
58
|
+
return `Last run: ${last.run}. Outcome: ${last.status} — ${last.reason}. ` +
|
|
59
|
+
`Ready grant line: continue: ${last.run} — ${reason}.`;
|
|
60
|
+
}
|
|
61
|
+
|
|
40
62
|
/**
|
|
41
63
|
* Applies the one-continuation limit and all continuation safety gates before a folder is created.
|
|
42
64
|
*
|
|
@@ -49,9 +71,16 @@ const grantAction =
|
|
|
49
71
|
* unrunnable, which is a worse failure than the retry storm this limit exists to stop.
|
|
50
72
|
*/
|
|
51
73
|
export function continuationRefusal(runsRootPath, chain, isContinue, orderId, grant) {
|
|
52
|
-
if (!isContinue) return null;
|
|
53
|
-
|
|
54
74
|
const continuation = normalizeGrant(grant);
|
|
75
|
+
if (!isContinue) {
|
|
76
|
+
if (!continuation) return null;
|
|
77
|
+
return (
|
|
78
|
+
'--continue is required when the task text contains a `continue:` grant. ' +
|
|
79
|
+
`${lastRunHints(runsRootPath, chain, continuation.reason)} ` +
|
|
80
|
+
'The submitted grant was not rewritten. The run folder was not created; quota was not spent.'
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
55
84
|
if (!continuation) {
|
|
56
85
|
return (
|
|
57
86
|
'--continue is refused: the orchestrator did not provide a `continue:` grant in the task text. ' +
|
|
@@ -64,7 +93,9 @@ export function continuationRefusal(runsRootPath, chain, isContinue, orderId, gr
|
|
|
64
93
|
return (
|
|
65
94
|
`--continue is refused: the orchestrator named run “${continuation.run}”, but it does not ` +
|
|
66
95
|
`exist as a run folder in this project's runs directory ${runsRootPath}. ` +
|
|
67
|
-
`${
|
|
96
|
+
`${lastRunHints(runsRootPath, chain, continuation.reason)} ` +
|
|
97
|
+
`${grantExample} ${grantAction} The submitted grant was not rewritten. ` +
|
|
98
|
+
'The run folder was not created; quota was not spent.'
|
|
68
99
|
);
|
|
69
100
|
}
|
|
70
101
|
|
package/src/runner/launcher.mjs
CHANGED
|
@@ -4,11 +4,13 @@
|
|
|
4
4
|
*
|
|
5
5
|
* The order it leaves for the worker is worker.json in the run folder — after the split
|
|
6
6
|
* that file is the only connection between the two halves. It is written here with
|
|
7
|
-
* `agent`, `slug`, `order_id`, `repo`, `is_git_repo`, `launcher_pid`, `budget_minutes
|
|
8
|
-
*
|
|
9
|
-
* `
|
|
10
|
-
*
|
|
11
|
-
*
|
|
7
|
+
* `agent`, `slug`, `order_id`, `repo`, `is_git_repo`, `launcher_pid`, `budget_minutes`, `scope_new`
|
|
8
|
+
* and `args`;
|
|
9
|
+
* worker.mjs reads `repo`, `agent`, `args`, `is_git_repo` and `budget_minutes`. The extra fields
|
|
10
|
+
* are deliberate: `slug`, `order_id`, `launcher_pid` and `scope_new` are what a run folder read
|
|
11
|
+
* back months later needs in order to explain itself — which order it belonged to and which new
|
|
12
|
+
* paths it declared included. Nothing may be dropped from this shape without changing worker.mjs
|
|
13
|
+
* and saying so out loud.
|
|
12
14
|
*/
|
|
13
15
|
import fs from 'node:fs';
|
|
14
16
|
import path from 'node:path';
|
|
@@ -19,8 +21,9 @@ import {
|
|
|
19
21
|
writeStatus,
|
|
20
22
|
markAbandoned,
|
|
21
23
|
abandonedBranchDrift,
|
|
22
|
-
|
|
24
|
+
activeRunDetails,
|
|
23
25
|
chainRuns,
|
|
26
|
+
startedRuns,
|
|
24
27
|
taskFingerprint,
|
|
25
28
|
readJson,
|
|
26
29
|
} from '../write-meta.mjs';
|
|
@@ -38,6 +41,7 @@ import { runsRoot } from './runs-root.mjs';
|
|
|
38
41
|
import { resolveProjectRunsDir } from './project-dir.mjs';
|
|
39
42
|
import { cleanupRetention } from '../retention.mjs';
|
|
40
43
|
import { renderConventions } from './conventions.mjs';
|
|
44
|
+
import { validateScope } from './scope-check.mjs';
|
|
41
45
|
|
|
42
46
|
/**
|
|
43
47
|
* The worker is this same program re-invoked as `--worker <runDir>`, so the path spawned
|
|
@@ -88,6 +92,18 @@ export async function launcher() {
|
|
|
88
92
|
const topLevel = git(opts.repo, ['rev-parse', '--show-toplevel']);
|
|
89
93
|
const isGitRepo = topLevel.status === 0;
|
|
90
94
|
const repoRoot = isGitRepo ? topLevel.stdout.trim() : opts.repo;
|
|
95
|
+
// Plan_27 moved impossible scope failures ahead of the run directory: an absolute pattern had
|
|
96
|
+
// already cost 18 minutes before the verdict could prove it matched nothing.
|
|
97
|
+
// Every agent, not only the writing one: a pattern that cannot match gives a scout empty coverage
|
|
98
|
+
// instead of an answer. Only codex-build may declare a not-yet-existing path, so only its scope
|
|
99
|
+
// carries --scope-new; for the other two the list is empty and every pattern must match.
|
|
100
|
+
const scopeRefusal = validateScope(repoRoot, opts.scopePatterns, opts.scopeNewPatterns);
|
|
101
|
+
if (scopeRefusal) {
|
|
102
|
+
die(
|
|
103
|
+
`--scope pattern ${JSON.stringify(scopeRefusal.pattern)} refused: ${scopeRefusal.reason}. ` +
|
|
104
|
+
`Action: ${scopeRefusal.action}. The run folder was not created; quota was not spent.`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
91
107
|
const projectRunsRoot = resolveProjectRunsDir(runsRoot(), repoRoot).dir;
|
|
92
108
|
|
|
93
109
|
// Folders left behind by a runner that was killed mid-run get an explicit state before
|
|
@@ -118,7 +134,22 @@ export async function launcher() {
|
|
|
118
134
|
// like --scope.
|
|
119
135
|
const taskHash = taskFingerprint(taskText);
|
|
120
136
|
const chain = chainRuns(projectRunsRoot, repoRoot, opts.slug, taskHash, opts.orderId);
|
|
121
|
-
const
|
|
137
|
+
const startedChain = startedRuns(projectRunsRoot, chain);
|
|
138
|
+
const continuationGrant = parseContinuationGrant(taskText);
|
|
139
|
+
|
|
140
|
+
// The 2026-08-10_220535_plan25-2-install-table-two-roots incident exposed this ordering:
|
|
141
|
+
// a grant without its flag must refuse before attach can print an older run's verdict.
|
|
142
|
+
// markAbandoned() ran before this gate, so a dead runner already has meta.json with FAIL.
|
|
143
|
+
// A run without a verdict can therefore only still be in flight; ordinary repeats go through
|
|
144
|
+
// attach(), while --continue must refuse to overlap that live work.
|
|
145
|
+
const continuationError = continuationRefusal(
|
|
146
|
+
projectRunsRoot,
|
|
147
|
+
startedChain,
|
|
148
|
+
opts.continue,
|
|
149
|
+
opts.orderId,
|
|
150
|
+
continuationGrant,
|
|
151
|
+
);
|
|
152
|
+
if (continuationError) die(continuationError);
|
|
122
153
|
|
|
123
154
|
// One order produced six Codex runs on 2026-08-03 because the caller's time ceiling made it
|
|
124
155
|
// restart the synchronous launcher. A live same-order run is now the repeat target: attach
|
|
@@ -134,20 +165,8 @@ export async function launcher() {
|
|
|
134
165
|
});
|
|
135
166
|
if (attachedExitCode !== null) process.exit(attachedExitCode);
|
|
136
167
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
// attach(), while --continue must refuse to overlap that live work.
|
|
140
|
-
const continuationError = continuationRefusal(
|
|
141
|
-
projectRunsRoot,
|
|
142
|
-
chain,
|
|
143
|
-
opts.continue,
|
|
144
|
-
opts.orderId,
|
|
145
|
-
continuationGrant,
|
|
146
|
-
);
|
|
147
|
-
if (continuationError) die(continuationError);
|
|
148
|
-
|
|
149
|
-
if (chain.length && !opts.continue) {
|
|
150
|
-
const last = chain[chain.length - 1];
|
|
168
|
+
if (startedChain.length && !opts.continue) {
|
|
169
|
+
const last = startedChain[startedChain.length - 1];
|
|
151
170
|
const lastSlug = String(readJson(path.join(projectRunsRoot, last, 'status.json'))?.slug || '');
|
|
152
171
|
const renamed = lastSlug && lastSlug.toLowerCase() !== String(opts.slug).toLowerCase();
|
|
153
172
|
die(
|
|
@@ -155,7 +174,7 @@ export async function launcher() {
|
|
|
155
174
|
renamed
|
|
156
175
|
? `this task already ran in this repository under the name “${lastSlug}”`
|
|
157
176
|
: `runs for task “${opts.slug}” already exist in this repository`
|
|
158
|
-
} (${
|
|
177
|
+
} (${startedChain.length}), latest: ${path.join(projectRunsRoot, last)}. ` +
|
|
159
178
|
'A repeat run is allowed, but the orchestrator decides, not the runner: it read the ' +
|
|
160
179
|
'previous response and knows whether work remains. Add --continue if you are finishing ' +
|
|
161
180
|
'the same task; changing --slug with the same task text does not stop it being a repeat. ' +
|
|
@@ -166,7 +185,7 @@ export async function launcher() {
|
|
|
166
185
|
// Asked before this run registers itself, so it cannot find itself. Two writing runs share
|
|
167
186
|
// one worktree with no isolation: the second one's before/after snapshot picks up the
|
|
168
187
|
// first one's edits, and an honest run gets failed for work it never did.
|
|
169
|
-
const busy = opts.agent === 'codex-build' ?
|
|
188
|
+
const busy = opts.agent === 'codex-build' ? activeRunDetails(projectRunsRoot, repoRoot) : null;
|
|
170
189
|
|
|
171
190
|
let retention = null;
|
|
172
191
|
try {
|
|
@@ -192,6 +211,7 @@ export async function launcher() {
|
|
|
192
211
|
state: 'running',
|
|
193
212
|
pid: process.pid,
|
|
194
213
|
launcher_pid: process.pid,
|
|
214
|
+
process_started_at: performance.timeOrigin,
|
|
195
215
|
agent: opts.agent,
|
|
196
216
|
slug: opts.slug,
|
|
197
217
|
order_id: opts.orderId,
|
|
@@ -202,7 +222,7 @@ export async function launcher() {
|
|
|
202
222
|
started_at: new Date().toISOString(),
|
|
203
223
|
// Which run of this task started the chain — the base every later pass is measured
|
|
204
224
|
// against. Absent means this is the first pass.
|
|
205
|
-
...(
|
|
225
|
+
...(startedChain.length ? { continues: startedChain[0] } : {}),
|
|
206
226
|
// `continued_from` is the exact run the orchestrator named; `continues` above remains the chain base.
|
|
207
227
|
...(continuationGrant ? { continued_from: continuationGrant.run } : {}),
|
|
208
228
|
...(retention ? { retention } : {}),
|
|
@@ -214,11 +234,15 @@ export async function launcher() {
|
|
|
214
234
|
|
|
215
235
|
// Before requireCodex, so a blocked run costs nothing at all.
|
|
216
236
|
if (busy) {
|
|
237
|
+
const identityNote = busy.identity === 'unverified'
|
|
238
|
+
? '; process identity could not be confirmed'
|
|
239
|
+
: '';
|
|
217
240
|
const { reply } = writeFailure(
|
|
218
241
|
runDir,
|
|
219
242
|
opts.agent,
|
|
220
|
-
`run ${busy} is already active for this repository; two writing runs in one tree are prohibited`,
|
|
221
|
-
[`Active run: ${path.join(projectRunsRoot, busy)}`, 'Codex was not started; quota was not spent'],
|
|
243
|
+
`run ${busy.run} is already active for this repository; two writing runs in one tree are prohibited${identityNote}`,
|
|
244
|
+
[`Active run: ${path.join(projectRunsRoot, busy.run)}`, 'Codex was not started; quota was not spent'],
|
|
245
|
+
true,
|
|
222
246
|
);
|
|
223
247
|
console.log(reply);
|
|
224
248
|
process.exit(1);
|
|
@@ -307,6 +331,7 @@ export async function launcher() {
|
|
|
307
331
|
opts.agent,
|
|
308
332
|
`argument cannot be passed through cmd.exe (contains % or "): ${unsafe}`,
|
|
309
333
|
['Codex was not started; quota was not spent'],
|
|
334
|
+
true,
|
|
310
335
|
);
|
|
311
336
|
console.log(reply);
|
|
312
337
|
process.exit(1);
|
|
@@ -324,6 +349,7 @@ export async function launcher() {
|
|
|
324
349
|
// The mode's wall-clock budget, resolved here and never re-read by the worker: a run
|
|
325
350
|
// that consulted run-config.json twice could end up honouring two different limits.
|
|
326
351
|
budget_minutes: RUN_ENV?.budgets?.[runMode(opts.agent)],
|
|
352
|
+
scope_new: opts.scopeNewPatterns,
|
|
327
353
|
args: codexArgv,
|
|
328
354
|
},
|
|
329
355
|
null,
|
|
@@ -343,12 +369,12 @@ export async function launcher() {
|
|
|
343
369
|
worker.on('error', (err) => {
|
|
344
370
|
const { reply } = writeFailure(runDir, opts.agent, `run worker process failed to start: ${err.message}`, [
|
|
345
371
|
'Codex was not started; quota was not spent',
|
|
346
|
-
]);
|
|
372
|
+
], true);
|
|
347
373
|
console.log(reply);
|
|
348
374
|
process.exit(1);
|
|
349
375
|
});
|
|
350
376
|
worker.unref();
|
|
351
|
-
writeStatus(runDir, { pid: worker.pid, runner_pid: worker.pid });
|
|
377
|
+
writeStatus(runDir, { pid: worker.pid, runner_pid: worker.pid, process_started_at: null });
|
|
352
378
|
|
|
353
379
|
console.log(
|
|
354
380
|
`STARTED agent=${opts.agent} slug=${opts.slug} order-id=${opts.orderId} worker-pid=${worker.pid}`,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/** Assigns a stable runs directory to one repository. */
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { readJsonFileSync } from '../json-file.mjs';
|
|
4
5
|
import { projectFolder } from '../write-meta.mjs';
|
|
5
6
|
|
|
6
7
|
export const PROJECT_MARKER = '.project.json';
|
|
@@ -18,9 +19,9 @@ function readMarker(dir) {
|
|
|
18
19
|
if (!fs.existsSync(markerPath)) return null;
|
|
19
20
|
let marker;
|
|
20
21
|
try {
|
|
21
|
-
marker =
|
|
22
|
+
marker = readJsonFileSync(markerPath);
|
|
22
23
|
} catch (err) {
|
|
23
|
-
throw new Error(`Cannot read project marker ${markerPath}: ${err.message}`);
|
|
24
|
+
throw new Error(`Cannot read project marker ${markerPath}: ${err.cause?.message || err.message}`);
|
|
24
25
|
}
|
|
25
26
|
if (!marker || typeof marker.repo !== 'string' || !marker.repo) {
|
|
26
27
|
throw new Error(`Project marker ${markerPath} must contain a non-empty repo path`);
|
|
@@ -65,7 +66,7 @@ function legacyOwner(dir) {
|
|
|
65
66
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
66
67
|
const statuses = entries.map((entry) => {
|
|
67
68
|
try {
|
|
68
|
-
return
|
|
69
|
+
return readJsonFileSync(path.join(dir, entry.name, 'status.json'));
|
|
69
70
|
} catch {
|
|
70
71
|
return null;
|
|
71
72
|
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Answers whether each scope pattern can match an existing repository file.
|
|
3
|
+
*
|
|
4
|
+
* Plan_27 moved impossible scope failures before the run folder: an absolute pattern had already
|
|
5
|
+
* spent 18 minutes before the verdict proved that it matched nothing.
|
|
6
|
+
*/
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { spawnSync } from 'node:child_process';
|
|
10
|
+
import { globToRegExp, normalizePath } from '../meta/paths.mjs';
|
|
11
|
+
|
|
12
|
+
// Directories a self-walk must never descend into. Only reached when the repository has no git:
|
|
13
|
+
// with git present the file list comes from the repository itself, which already knows what is
|
|
14
|
+
// ignored. The first Plan_27 pass walked everything, `.git` and `node_modules` included — invisible
|
|
15
|
+
// here, where the package has no dependencies, and tens of thousands of files in a working monorepo.
|
|
16
|
+
const SKIP_DIRECTORIES = new Set(['.git', 'node_modules']);
|
|
17
|
+
|
|
18
|
+
function absolutePattern(pattern) {
|
|
19
|
+
return (
|
|
20
|
+
/^[A-Za-z]:/.test(pattern) ||
|
|
21
|
+
pattern.startsWith('/') ||
|
|
22
|
+
pattern.startsWith('\\\\?\\') ||
|
|
23
|
+
pattern.startsWith('\\\\')
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function structuralRefusal(pattern) {
|
|
28
|
+
if (absolutePattern(pattern)) {
|
|
29
|
+
return {
|
|
30
|
+
reason: 'is an absolute or drive-qualified path',
|
|
31
|
+
action: 'use a path relative to the repository root with forward slashes',
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
if (pattern.includes('\\')) {
|
|
35
|
+
return {
|
|
36
|
+
reason: 'uses backslash separators',
|
|
37
|
+
action: 'replace backslashes with forward slashes',
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
if (pattern.split('/').some((segment) => segment === '..')) {
|
|
41
|
+
return {
|
|
42
|
+
reason: 'contains a parent-directory (..) segment',
|
|
43
|
+
action: 'remove the .. segment and keep the path relative to the repository root',
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function noMatchRefusal(pattern) {
|
|
50
|
+
return {
|
|
51
|
+
pattern,
|
|
52
|
+
reason: 'does not match any existing path in the repository',
|
|
53
|
+
action: 'correct the pattern or declare an intentionally new path with --scope-new',
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function repositoryPath(repoRoot, absolutePath) {
|
|
58
|
+
const relative = path.relative(repoRoot, absolutePath).split(path.sep).join('/');
|
|
59
|
+
return normalizePath(relative);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The repository's own answer to "which files are there": tracked plus new-but-uncommitted, minus
|
|
64
|
+
* everything ignored. It is the same boundary the run is judged by afterwards, it costs one process
|
|
65
|
+
* instead of a full walk, and it cannot wander into `.git` or a dependency tree.
|
|
66
|
+
*/
|
|
67
|
+
function gitPaths(repoRoot) {
|
|
68
|
+
const result = spawnSync(
|
|
69
|
+
'git',
|
|
70
|
+
['-C', repoRoot, 'ls-files', '--cached', '--others', '--exclude-standard'],
|
|
71
|
+
{ encoding: 'utf8', windowsHide: true, maxBuffer: 64 * 1024 * 1024 },
|
|
72
|
+
);
|
|
73
|
+
if (result.error || result.status !== 0) return null;
|
|
74
|
+
return result.stdout.split('\n').map((line) => line.trim()).filter(Boolean);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Walks a directory that has no git, skipping dependency trees and anything unreadable. */
|
|
78
|
+
function walkPaths(repoRoot) {
|
|
79
|
+
const pending = [repoRoot];
|
|
80
|
+
const found = [];
|
|
81
|
+
while (pending.length) {
|
|
82
|
+
const current = pending.pop();
|
|
83
|
+
let entries;
|
|
84
|
+
try {
|
|
85
|
+
entries = fs.readdirSync(current, { withFileTypes: true });
|
|
86
|
+
} catch {
|
|
87
|
+
// An unreadable subdirectory is not a reason to refuse a run: the pattern may well match
|
|
88
|
+
// somewhere else, and a permission error here would fail work that has nothing to do with it.
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
for (const entry of entries) {
|
|
92
|
+
const absolute = path.join(current, entry.name);
|
|
93
|
+
if (entry.isDirectory() && !entry.isSymbolicLink()) {
|
|
94
|
+
if (!SKIP_DIRECTORIES.has(entry.name)) pending.push(absolute);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (!entry.isFile() && !entry.isSymbolicLink()) continue;
|
|
98
|
+
found.push(repositoryPath(repoRoot, absolute));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return found;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function matchingPaths(repoRoot, matchers) {
|
|
105
|
+
const candidates = gitPaths(repoRoot)?.map((relative) => normalizePath(relative))
|
|
106
|
+
?? walkPaths(repoRoot);
|
|
107
|
+
const matched = new Set();
|
|
108
|
+
for (const candidate of candidates) {
|
|
109
|
+
for (const matcher of matchers) {
|
|
110
|
+
if (!matched.has(matcher.pattern) && matcher.regexp.test(candidate)) {
|
|
111
|
+
matched.add(matcher.pattern);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (matched.size === matchers.length) break;
|
|
115
|
+
}
|
|
116
|
+
return matched;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function patternList(value, label) {
|
|
120
|
+
if (!Array.isArray(value)) throw new TypeError(`${label} must be an array`);
|
|
121
|
+
return value.map((pattern) => String(pattern ?? '').trim());
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Returns the first scope refusal, or null when every ordinary pattern can match a repository
|
|
126
|
+
* path. New-file declarations are still structurally checked; only their existence check is
|
|
127
|
+
* exempted.
|
|
128
|
+
*/
|
|
129
|
+
export function validateScope(repoRoot, patterns, scopeNewPatterns = []) {
|
|
130
|
+
if (typeof repoRoot !== 'string' || !repoRoot) throw new TypeError('repoRoot must be a non-empty string');
|
|
131
|
+
const declared = patternList(patterns, 'patterns');
|
|
132
|
+
const newPaths = patternList(scopeNewPatterns, 'scopeNewPatterns');
|
|
133
|
+
const allPatterns = [...declared, ...newPaths];
|
|
134
|
+
|
|
135
|
+
for (const pattern of allPatterns) {
|
|
136
|
+
const refusal = structuralRefusal(pattern);
|
|
137
|
+
if (refusal) return { pattern, ...refusal };
|
|
138
|
+
if (!pattern) return noMatchRefusal(pattern);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const newPathKeys = new Set(newPaths.map((pattern) => normalizePath(pattern)));
|
|
142
|
+
const required = allPatterns.filter((pattern) => !newPathKeys.has(normalizePath(pattern)));
|
|
143
|
+
if (!required.length) return null;
|
|
144
|
+
|
|
145
|
+
let matchers;
|
|
146
|
+
try {
|
|
147
|
+
matchers = required.map((pattern) => ({ pattern, regexp: globToRegExp(pattern) }));
|
|
148
|
+
} catch (error) {
|
|
149
|
+
return {
|
|
150
|
+
pattern: required[0].pattern,
|
|
151
|
+
reason: `could not parse the pattern: ${error.message}`,
|
|
152
|
+
action: 'correct the pattern and retry',
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const matched = matchingPaths(repoRoot, matchers);
|
|
157
|
+
const missing = matchers.find((matcher) => !matched.has(matcher.pattern));
|
|
158
|
+
return missing ? noMatchRefusal(missing.pattern) : null;
|
|
159
|
+
}
|
package/src/runner/worker.mjs
CHANGED
|
@@ -4,14 +4,15 @@
|
|
|
4
4
|
* It takes its whole order from worker.json, written by launcher.mjs into the run folder —
|
|
5
5
|
* after the split that file is the only connection between the two halves. Read here:
|
|
6
6
|
* `repo`, `agent`, `args`, `is_git_repo`, `budget_minutes`. Written there: those five plus
|
|
7
|
-
* `slug` and `
|
|
8
|
-
* does. The launcher must write the selected mode's budget into worker.json before
|
|
9
|
-
* this half, so the worker never re-reads run-config.json and one run cannot acquire two
|
|
7
|
+
* `slug`, `launcher_pid` and `scope_new`, which nothing here needs but a run folder read back
|
|
8
|
+
* months later does. The launcher must write the selected mode's budget into worker.json before
|
|
9
|
+
* spawning this half, so the worker never re-reads run-config.json and one run cannot acquire two
|
|
10
10
|
* configurations.
|
|
11
11
|
* Neither side may change the shape alone.
|
|
12
12
|
*/
|
|
13
13
|
import fs from 'node:fs';
|
|
14
14
|
import path from 'node:path';
|
|
15
|
+
import { readJsonFileSync } from '../json-file.mjs';
|
|
15
16
|
import { collect, exitCodeFor, AGENTS } from '../write-meta.mjs';
|
|
16
17
|
import { writeStatus } from '../meta/run-state.mjs';
|
|
17
18
|
import { setRun, emitReply } from './run-context.mjs';
|
|
@@ -23,9 +24,16 @@ import { runCodex } from './codex-cmd.mjs';
|
|
|
23
24
|
* may depend on anyone still being on the other end of a pipe.
|
|
24
25
|
*/
|
|
25
26
|
export async function worker(runDir) {
|
|
26
|
-
const cfg =
|
|
27
|
+
const cfg = readJsonFileSync(path.join(runDir, 'worker.json'));
|
|
27
28
|
const repoRoot = cfg.repo;
|
|
28
29
|
setRun(runDir, cfg.agent);
|
|
30
|
+
// The launcher cannot know this detached process's clock origin; takeover must record the
|
|
31
|
+
// worker's own identity before it starts producing the run's artifacts.
|
|
32
|
+
writeStatus(runDir, {
|
|
33
|
+
pid: process.pid,
|
|
34
|
+
runner_pid: process.pid,
|
|
35
|
+
process_started_at: performance.timeOrigin,
|
|
36
|
+
});
|
|
29
37
|
|
|
30
38
|
const run = await runCodex(
|
|
31
39
|
cfg.args,
|
|
@@ -58,7 +66,7 @@ export async function worker(runDir) {
|
|
|
58
66
|
const resultPath = path.join(runDir, AGENTS[cfg.agent].result);
|
|
59
67
|
if (cfg.agent !== 'codex-review') {
|
|
60
68
|
try {
|
|
61
|
-
const result =
|
|
69
|
+
const result = readJsonFileSync(resultPath);
|
|
62
70
|
if (result.report_markdown) fs.writeFileSync(path.join(runDir, 'report.md'), `${result.report_markdown}\n`);
|
|
63
71
|
} catch {
|
|
64
72
|
// No parseable result — write-meta.mjs turns that into FAIL with the log reason.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export const STOP_COMMAND = 'codex-bridge stop';
|
|
2
|
+
export const STOP_COMMAND_TEMPLATE = `${STOP_COMMAND} <run>`;
|
|
3
|
+
export const STOP_REASON =
|
|
4
|
+
'TaskStop removes the wrapper while the run keeps writing to the worktree.';
|
|
5
|
+
|
|
6
|
+
export function renderStopCommand(run) {
|
|
7
|
+
if (typeof run !== 'string' || !run.trim()) {
|
|
8
|
+
throw new TypeError('A run folder name is required to render the stop command.');
|
|
9
|
+
}
|
|
10
|
+
return `${STOP_COMMAND} ${run.trim()}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function renderStopSummary() {
|
|
14
|
+
return `Stop live runs with \`${STOP_COMMAND_TEMPLATE}\` before \`TaskStop\`: ${STOP_REASON}`;
|
|
15
|
+
}
|
package/src/write-meta.mjs
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
import fs from 'node:fs';
|
|
23
23
|
import path from 'node:path';
|
|
24
24
|
import { fileURLToPath } from 'node:url';
|
|
25
|
+
import { readJsonFileSync } from './json-file.mjs';
|
|
25
26
|
import { changedPaths, readJson, readText, size } from './meta/paths.mjs';
|
|
26
27
|
import { splitRunChanges } from './meta/environment.mjs';
|
|
27
28
|
import { readEvents } from './meta/events.mjs';
|
|
@@ -29,9 +30,18 @@ import { writeStatus } from './meta/run-state.mjs';
|
|
|
29
30
|
import { resolveStatus } from './meta/verdict.mjs';
|
|
30
31
|
import { AGENTS, failReply, limitReply } from './meta/reply.mjs';
|
|
31
32
|
|
|
33
|
+
const RUNNER_VERSION = readJsonFileSync(new URL('../package.json', import.meta.url)).version;
|
|
34
|
+
|
|
32
35
|
export { expandDeclared, globToRegExp, readJson } from './meta/paths.mjs';
|
|
33
36
|
export { chainBaseline, chainRuns, taskFingerprint } from './meta/chain.mjs';
|
|
34
|
-
export {
|
|
37
|
+
export { abortedPreStart, startedRuns } from './meta/pre-start.mjs';
|
|
38
|
+
export {
|
|
39
|
+
abandonedBranchDrift,
|
|
40
|
+
activeRun,
|
|
41
|
+
activeRunDetails,
|
|
42
|
+
markAbandoned,
|
|
43
|
+
writeFailure,
|
|
44
|
+
} from './meta/run-state.mjs';
|
|
35
45
|
export { outOfScope, reportVersusWork } from './meta/verdict.mjs';
|
|
36
46
|
export { AGENTS, writeStatus };
|
|
37
47
|
|
|
@@ -77,6 +87,7 @@ export function collect(runDir, agent, exitCode) {
|
|
|
77
87
|
|
|
78
88
|
const meta = {
|
|
79
89
|
agent,
|
|
90
|
+
runner_version: RUNNER_VERSION,
|
|
80
91
|
project: path.basename(path.dirname(runDir)),
|
|
81
92
|
run: path.basename(runDir),
|
|
82
93
|
finished_at: new Date().toISOString(),
|