@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
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
|
@@ -103,11 +103,25 @@ export function parseArgs(argv) {
|
|
|
103
103
|
// told in prose not to touch them: prose does not bind, a file list does. Required rather
|
|
104
104
|
// than defaulted to "everything", and checked here — before the folder exists and before a
|
|
105
105
|
// single token of someone else's quota is spent.
|
|
106
|
-
|
|
106
|
+
const declaredScopePatterns = String(opts.scope || '')
|
|
107
107
|
.split(',')
|
|
108
108
|
.map((p) => p.trim())
|
|
109
109
|
.filter(Boolean);
|
|
110
|
-
|
|
110
|
+
opts.scopeNewPatterns = String(opts['scope-new'] || '')
|
|
111
|
+
.split(',')
|
|
112
|
+
.map((p) => p.trim())
|
|
113
|
+
.filter(Boolean);
|
|
114
|
+
// Plan_27 keeps ordinary patterns strict: only paths named explicitly as new may be absent.
|
|
115
|
+
opts.scopePatterns = [...declaredScopePatterns, ...opts.scopeNewPatterns];
|
|
116
|
+
// Only the writing agent creates files, so only its scope may name one that does not exist yet.
|
|
117
|
+
// Accepting the flag elsewhere would hand scout and review a way to waive the check for nothing.
|
|
118
|
+
if (opts.agent !== 'codex-build' && opts.scopeNewPatterns.length) {
|
|
119
|
+
die(
|
|
120
|
+
`--scope-new is only for codex-build: ${opts.agent} does not create files. ` +
|
|
121
|
+
'Action: drop --scope-new and pass every path through --scope.',
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
if (opts.agent === 'codex-build' && !declaredScopePatterns.length) {
|
|
111
125
|
const scopeInput = requiredInput(opts.agent, 'scope');
|
|
112
126
|
die(
|
|
113
127
|
`--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
|
|
@@ -10,36 +12,40 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
10
12
|
|
|
11
13
|
export const readJsonFile = (file) => {
|
|
12
14
|
try {
|
|
13
|
-
return
|
|
15
|
+
return readJsonFileSync(file);
|
|
14
16
|
} catch {
|
|
15
17
|
return null;
|
|
16
18
|
}
|
|
17
19
|
};
|
|
18
20
|
|
|
19
21
|
/**
|
|
20
|
-
* Is this pid still running?
|
|
21
|
-
*
|
|
22
|
-
* this run still going" would be one answer too many.
|
|
22
|
+
* Is this pid still running for this run? Unknown identity remains live so an attach does not
|
|
23
|
+
* declare a worker dead merely because the identity probe was unavailable.
|
|
23
24
|
*/
|
|
24
|
-
export const alive = (pid) => {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
process.kill(pid, 0);
|
|
28
|
-
return true;
|
|
29
|
-
} catch (err) {
|
|
30
|
-
return err.code === 'EPERM';
|
|
31
|
-
}
|
|
25
|
+
export const alive = (pid, runDir, status = {}) => {
|
|
26
|
+
const identity = processIdentity({ runDir, status: { ...status, pid } });
|
|
27
|
+
return identity !== IDENTITY_DEAD && identity !== IDENTITY_FOREIGN;
|
|
32
28
|
};
|
|
33
29
|
|
|
34
|
-
/**
|
|
35
|
-
|
|
30
|
+
/**
|
|
31
|
+
* Blocks until the worker's reply exists, or until the worker is provably gone without one.
|
|
32
|
+
*
|
|
33
|
+
* Identity is settled once, before the loop: a recycled pid would otherwise keep this call waiting
|
|
34
|
+
* for a reply nobody is going to write, and asking again every 500 ms would spawn the start-time
|
|
35
|
+
* probe for the whole length of a 20-minute run. Inside the loop the question is only whether the
|
|
36
|
+
* number is still busy, which signal 0 answers for free.
|
|
37
|
+
*/
|
|
38
|
+
export async function waitForReply(runDir, workerPid, status = {}) {
|
|
36
39
|
const replyPath = path.join(runDir, 'reply.txt');
|
|
40
|
+
if (!alive(workerPid, runDir, status)) {
|
|
41
|
+
return fs.existsSync(replyPath) ? fs.readFileSync(replyPath, 'utf8').replace(/\s+$/, '') : null;
|
|
42
|
+
}
|
|
37
43
|
let pollsSinceDeath = 0;
|
|
38
44
|
for (;;) {
|
|
39
45
|
if (fs.existsSync(replyPath)) return fs.readFileSync(replyPath, 'utf8').replace(/\s+$/, '');
|
|
40
46
|
// A dead worker will never write it. Two extra polls before believing that: the pid is
|
|
41
47
|
// released around the same moment as the last write, and the order is not guaranteed.
|
|
42
|
-
if (
|
|
48
|
+
if (processAlive(workerPid)) {
|
|
43
49
|
pollsSinceDeath = 0;
|
|
44
50
|
} else {
|
|
45
51
|
pollsSinceDeath += 1;
|
|
@@ -84,7 +90,7 @@ export async function attach({ runsRoot, repo, slug, taskHash, orderId, chain, i
|
|
|
84
90
|
console.log(fs.readFileSync(path.join(dir, 'reply.txt'), 'utf8').replace(/\s+$/, ''));
|
|
85
91
|
return exitCodeFor(readJsonFile(path.join(dir, 'meta.json'))?.status);
|
|
86
92
|
}
|
|
87
|
-
if (alive(entry.status.pid)) {
|
|
93
|
+
if (alive(entry.status.pid, dir, entry.status)) {
|
|
88
94
|
candidate = entry;
|
|
89
95
|
break;
|
|
90
96
|
}
|
|
@@ -93,7 +99,7 @@ export async function attach({ runsRoot, repo, slug, taskHash, orderId, chain, i
|
|
|
93
99
|
|
|
94
100
|
const runDir = path.join(runsRoot, candidate.run);
|
|
95
101
|
console.log(`ATTACH=${runDir} started=${candidate.status.started_at}`);
|
|
96
|
-
const replyText = await waitForReply(runDir, candidate.status.pid);
|
|
102
|
+
const replyText = await waitForReply(runDir, candidate.status.pid, candidate.status);
|
|
97
103
|
if (replyText !== null) {
|
|
98
104
|
console.log(replyText);
|
|
99
105
|
return exitCodeFor(readJsonFile(path.join(runDir, 'meta.json'))?.status);
|
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
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** Renders the host and repository conventions that a run must carry in its task artifact. */
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { CONFIG_PATH } from '../run-config.mjs';
|
|
5
|
+
|
|
6
|
+
const HOST_FILE = path.join(path.dirname(CONFIG_PATH), 'conventions.md');
|
|
7
|
+
const REPOSITORY_FILE = '.codex-conventions.md';
|
|
8
|
+
|
|
9
|
+
function readOptional(file) {
|
|
10
|
+
try {
|
|
11
|
+
return fs.readFileSync(file, 'utf8');
|
|
12
|
+
} catch (err) {
|
|
13
|
+
if (err.code === 'ENOENT') return null;
|
|
14
|
+
throw err;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Reads both convention layers at the point of use. Plan_13 showed that a reference to a required
|
|
20
|
+
* file is not enforcement: the task must contain the bytes the run was given.
|
|
21
|
+
*
|
|
22
|
+
* A layer that is present but blank is dropped rather than rendered as an empty heading, for the
|
|
23
|
+
* same reason doctor warns about it — an empty rules file looks like a working mechanism and is
|
|
24
|
+
* not one, and in the artifact it would look like rules that were given and ignored.
|
|
25
|
+
*
|
|
26
|
+
* Duplication is impossible by construction and deliberately not guarded against: `sections`
|
|
27
|
+
* starts empty on every launch and task.md is written into a folder that is never reused, so
|
|
28
|
+
* there is nothing to append to. A guard reading the operator's task text for this heading would
|
|
29
|
+
* only find the word in a task that talks ABOUT conventions, and silently drop the section from
|
|
30
|
+
* the one run that most obviously needed it.
|
|
31
|
+
*/
|
|
32
|
+
export function renderConventions(repoRoot, hostFile = HOST_FILE) {
|
|
33
|
+
const layers = [];
|
|
34
|
+
const host = readOptional(hostFile);
|
|
35
|
+
if (host?.trim()) layers.push(`### Host conventions\n\n${host}`);
|
|
36
|
+
const repository = readOptional(path.join(repoRoot, REPOSITORY_FILE));
|
|
37
|
+
if (repository?.trim()) layers.push(`### Repository conventions\n\n${repository}`);
|
|
38
|
+
return layers.length ? `## Conventions\n\n${layers.join('\n\n')}` : '';
|
|
39
|
+
}
|
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';
|
|
@@ -37,6 +40,8 @@ import { codexArgs, requireCodex, runMode, unsafeForCmd } from './codex-cmd.mjs'
|
|
|
37
40
|
import { runsRoot } from './runs-root.mjs';
|
|
38
41
|
import { resolveProjectRunsDir } from './project-dir.mjs';
|
|
39
42
|
import { cleanupRetention } from '../retention.mjs';
|
|
43
|
+
import { renderConventions } from './conventions.mjs';
|
|
44
|
+
import { validateScope } from './scope-check.mjs';
|
|
40
45
|
|
|
41
46
|
/**
|
|
42
47
|
* The worker is this same program re-invoked as `--worker <runDir>`, so the path spawned
|
|
@@ -87,6 +92,18 @@ export async function launcher() {
|
|
|
87
92
|
const topLevel = git(opts.repo, ['rev-parse', '--show-toplevel']);
|
|
88
93
|
const isGitRepo = topLevel.status === 0;
|
|
89
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
|
+
}
|
|
90
107
|
const projectRunsRoot = resolveProjectRunsDir(runsRoot(), repoRoot).dir;
|
|
91
108
|
|
|
92
109
|
// Folders left behind by a runner that was killed mid-run get an explicit state before
|
|
@@ -117,6 +134,7 @@ export async function launcher() {
|
|
|
117
134
|
// like --scope.
|
|
118
135
|
const taskHash = taskFingerprint(taskText);
|
|
119
136
|
const chain = chainRuns(projectRunsRoot, repoRoot, opts.slug, taskHash, opts.orderId);
|
|
137
|
+
const startedChain = startedRuns(projectRunsRoot, chain);
|
|
120
138
|
const continuationGrant = opts.continue ? parseContinuationGrant(taskText) : null;
|
|
121
139
|
|
|
122
140
|
// One order produced six Codex runs on 2026-08-03 because the caller's time ceiling made it
|
|
@@ -138,15 +156,15 @@ export async function launcher() {
|
|
|
138
156
|
// attach(), while --continue must refuse to overlap that live work.
|
|
139
157
|
const continuationError = continuationRefusal(
|
|
140
158
|
projectRunsRoot,
|
|
141
|
-
|
|
159
|
+
startedChain,
|
|
142
160
|
opts.continue,
|
|
143
161
|
opts.orderId,
|
|
144
162
|
continuationGrant,
|
|
145
163
|
);
|
|
146
164
|
if (continuationError) die(continuationError);
|
|
147
165
|
|
|
148
|
-
if (
|
|
149
|
-
const last =
|
|
166
|
+
if (startedChain.length && !opts.continue) {
|
|
167
|
+
const last = startedChain[startedChain.length - 1];
|
|
150
168
|
const lastSlug = String(readJson(path.join(projectRunsRoot, last, 'status.json'))?.slug || '');
|
|
151
169
|
const renamed = lastSlug && lastSlug.toLowerCase() !== String(opts.slug).toLowerCase();
|
|
152
170
|
die(
|
|
@@ -154,7 +172,7 @@ export async function launcher() {
|
|
|
154
172
|
renamed
|
|
155
173
|
? `this task already ran in this repository under the name “${lastSlug}”`
|
|
156
174
|
: `runs for task “${opts.slug}” already exist in this repository`
|
|
157
|
-
} (${
|
|
175
|
+
} (${startedChain.length}), latest: ${path.join(projectRunsRoot, last)}. ` +
|
|
158
176
|
'A repeat run is allowed, but the orchestrator decides, not the runner: it read the ' +
|
|
159
177
|
'previous response and knows whether work remains. Add --continue if you are finishing ' +
|
|
160
178
|
'the same task; changing --slug with the same task text does not stop it being a repeat. ' +
|
|
@@ -165,7 +183,7 @@ export async function launcher() {
|
|
|
165
183
|
// Asked before this run registers itself, so it cannot find itself. Two writing runs share
|
|
166
184
|
// one worktree with no isolation: the second one's before/after snapshot picks up the
|
|
167
185
|
// first one's edits, and an honest run gets failed for work it never did.
|
|
168
|
-
const busy = opts.agent === 'codex-build' ?
|
|
186
|
+
const busy = opts.agent === 'codex-build' ? activeRunDetails(projectRunsRoot, repoRoot) : null;
|
|
169
187
|
|
|
170
188
|
let retention = null;
|
|
171
189
|
try {
|
|
@@ -191,6 +209,7 @@ export async function launcher() {
|
|
|
191
209
|
state: 'running',
|
|
192
210
|
pid: process.pid,
|
|
193
211
|
launcher_pid: process.pid,
|
|
212
|
+
process_started_at: performance.timeOrigin,
|
|
194
213
|
agent: opts.agent,
|
|
195
214
|
slug: opts.slug,
|
|
196
215
|
order_id: opts.orderId,
|
|
@@ -201,7 +220,7 @@ export async function launcher() {
|
|
|
201
220
|
started_at: new Date().toISOString(),
|
|
202
221
|
// Which run of this task started the chain — the base every later pass is measured
|
|
203
222
|
// against. Absent means this is the first pass.
|
|
204
|
-
...(
|
|
223
|
+
...(startedChain.length ? { continues: startedChain[0] } : {}),
|
|
205
224
|
// `continued_from` is the exact run the orchestrator named; `continues` above remains the chain base.
|
|
206
225
|
...(continuationGrant ? { continued_from: continuationGrant.run } : {}),
|
|
207
226
|
...(retention ? { retention } : {}),
|
|
@@ -213,11 +232,15 @@ export async function launcher() {
|
|
|
213
232
|
|
|
214
233
|
// Before requireCodex, so a blocked run costs nothing at all.
|
|
215
234
|
if (busy) {
|
|
235
|
+
const identityNote = busy.identity === 'unverified'
|
|
236
|
+
? '; process identity could not be confirmed'
|
|
237
|
+
: '';
|
|
216
238
|
const { reply } = writeFailure(
|
|
217
239
|
runDir,
|
|
218
240
|
opts.agent,
|
|
219
|
-
`run ${busy} is already active for this repository; two writing runs in one tree are prohibited`,
|
|
220
|
-
[`Active run: ${path.join(projectRunsRoot, busy)}`, 'Codex was not started; quota was not spent'],
|
|
241
|
+
`run ${busy.run} is already active for this repository; two writing runs in one tree are prohibited${identityNote}`,
|
|
242
|
+
[`Active run: ${path.join(projectRunsRoot, busy.run)}`, 'Codex was not started; quota was not spent'],
|
|
243
|
+
true,
|
|
221
244
|
);
|
|
222
245
|
console.log(reply);
|
|
223
246
|
process.exit(1);
|
|
@@ -279,6 +302,8 @@ export async function launcher() {
|
|
|
279
302
|
].join('\n'),
|
|
280
303
|
);
|
|
281
304
|
}
|
|
305
|
+
const conventions = renderConventions(repoRoot);
|
|
306
|
+
if (conventions) sections.push(conventions);
|
|
282
307
|
sections.push(`## Instructions for Codex\n\n${INSTRUCTIONS[opts.agent](opts, scope, questions)}`);
|
|
283
308
|
fs.writeFileSync(path.join(runDir, 'task.md'), `${sections.join('\n\n')}\n`);
|
|
284
309
|
fs.writeFileSync(
|
|
@@ -304,6 +329,7 @@ export async function launcher() {
|
|
|
304
329
|
opts.agent,
|
|
305
330
|
`argument cannot be passed through cmd.exe (contains % or "): ${unsafe}`,
|
|
306
331
|
['Codex was not started; quota was not spent'],
|
|
332
|
+
true,
|
|
307
333
|
);
|
|
308
334
|
console.log(reply);
|
|
309
335
|
process.exit(1);
|
|
@@ -321,6 +347,7 @@ export async function launcher() {
|
|
|
321
347
|
// The mode's wall-clock budget, resolved here and never re-read by the worker: a run
|
|
322
348
|
// that consulted run-config.json twice could end up honouring two different limits.
|
|
323
349
|
budget_minutes: RUN_ENV?.budgets?.[runMode(opts.agent)],
|
|
350
|
+
scope_new: opts.scopeNewPatterns,
|
|
324
351
|
args: codexArgv,
|
|
325
352
|
},
|
|
326
353
|
null,
|
|
@@ -340,12 +367,12 @@ export async function launcher() {
|
|
|
340
367
|
worker.on('error', (err) => {
|
|
341
368
|
const { reply } = writeFailure(runDir, opts.agent, `run worker process failed to start: ${err.message}`, [
|
|
342
369
|
'Codex was not started; quota was not spent',
|
|
343
|
-
]);
|
|
370
|
+
], true);
|
|
344
371
|
console.log(reply);
|
|
345
372
|
process.exit(1);
|
|
346
373
|
});
|
|
347
374
|
worker.unref();
|
|
348
|
-
writeStatus(runDir, { pid: worker.pid, runner_pid: worker.pid });
|
|
375
|
+
writeStatus(runDir, { pid: worker.pid, runner_pid: worker.pid, process_started_at: null });
|
|
349
376
|
|
|
350
377
|
console.log(
|
|
351
378
|
`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.
|
package/src/write-meta.mjs
CHANGED
|
@@ -31,7 +31,14 @@ import { AGENTS, failReply, limitReply } from './meta/reply.mjs';
|
|
|
31
31
|
|
|
32
32
|
export { expandDeclared, globToRegExp, readJson } from './meta/paths.mjs';
|
|
33
33
|
export { chainBaseline, chainRuns, taskFingerprint } from './meta/chain.mjs';
|
|
34
|
-
export {
|
|
34
|
+
export { abortedPreStart, startedRuns } from './meta/pre-start.mjs';
|
|
35
|
+
export {
|
|
36
|
+
abandonedBranchDrift,
|
|
37
|
+
activeRun,
|
|
38
|
+
activeRunDetails,
|
|
39
|
+
markAbandoned,
|
|
40
|
+
writeFailure,
|
|
41
|
+
} from './meta/run-state.mjs';
|
|
35
42
|
export { outOfScope, reportVersusWork } from './meta/verdict.mjs';
|
|
36
43
|
export { AGENTS, writeStatus };
|
|
37
44
|
|