@lyupro/codex-bridge 0.6.9 → 0.6.11
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 +132 -0
- package/README.md +15 -4
- package/cli/dispatcher-model-check.mjs +59 -0
- package/cli/doctor-host-side.mjs +2 -1
- package/cli/doctor-installation.mjs +111 -111
- package/cli/doctor.mjs +13 -5
- package/cli/handback-witness-check.mjs +24 -18
- package/cli/hosts.mjs +2 -10
- package/cli/install-owners.mjs +1 -4
- package/cli/install-record.mjs +2 -1
- package/cli/inventory-transition.mjs +1 -1
- package/cli/lifecycle-lock-inspect.mjs +3 -2
- package/cli/lifecycle-lock.mjs +19 -132
- package/cli/manifest.mjs +6 -6
- package/cli/read.mjs +20 -0
- package/cli/settings-merge.mjs +3 -29
- package/package.json +2 -2
- package/scripts/home-write-inventory.mjs +12 -2
- package/src/agents/codex-advisor.md +37 -121
- package/src/agents/codex-build.md +27 -135
- package/src/agents/codex-review.md +19 -111
- package/src/agents/codex-scout.md +21 -130
- package/src/home/hooks/dispatcher-gate.mjs +14 -9
- package/src/home/hooks/live-runs.mjs +0 -41
- package/src/home/hooks/order-gate.mjs +80 -47
- package/src/home/hooks/reply-guard.mjs +52 -65
- package/src/home/hooks/reply-identity.mjs +64 -0
- package/src/home/hooks/reply-parser.mjs +1 -1
- package/src/home/hooks/reply-verdicts.mjs +31 -27
- package/src/home/hooks/reply-witness.mjs +48 -0
- package/src/home/lib/claude-layout.mjs +14 -0
- package/src/home/lib/command-path.mjs +32 -0
- package/src/home/lib/dispatcher-call.mjs +171 -0
- package/src/home/lib/dispatcher-command.mjs +18 -54
- package/src/home/lib/dispatcher-gate.mjs +4 -0
- package/src/home/lib/dispatcher-model.mjs +112 -0
- package/src/home/lib/dispatcher-order.mjs +21 -22
- package/src/home/lib/dispatcher-pin.mjs +60 -0
- package/src/home/lib/dispatcher-protocol.mjs +91 -0
- package/src/home/lib/dispatcher-stop.mjs +11 -2
- package/src/home/lib/handback-witness.mjs +137 -48
- package/src/home/lib/home-registry.mjs +7 -0
- package/src/home/lib/install-owner-roots.mjs +53 -0
- package/src/home/lib/kernel-lock.mjs +171 -0
- package/src/home/lib/meta/chain.mjs +19 -8
- package/src/home/lib/meta/context-only.mjs +43 -0
- package/src/home/lib/meta/events.mjs +14 -2
- package/src/home/lib/meta/launch-rows.mjs +23 -0
- package/src/home/lib/meta/paths.mjs +1 -1
- package/src/home/lib/meta/reply.mjs +39 -4
- package/src/home/lib/meta/run-state.mjs +3 -0
- package/src/home/lib/meta/verdict.mjs +16 -8
- package/src/home/lib/model-family.mjs +38 -0
- package/src/home/lib/observation-ledger.mjs +145 -0
- package/src/home/lib/order-schema.mjs +204 -0
- package/src/home/lib/order-values.mjs +46 -0
- package/src/home/lib/run-codex.mjs +6 -3
- package/src/home/lib/runner/advise-carry.mjs +2 -1
- package/src/home/lib/runner/args.mjs +140 -220
- package/src/home/lib/runner/attach.mjs +24 -17
- package/src/home/lib/runner/build-evidence.mjs +29 -0
- package/src/home/lib/runner/codex-availability.mjs +50 -0
- package/src/home/lib/runner/codex-cmd.mjs +2 -19
- package/src/home/lib/runner/continuation.mjs +174 -140
- package/src/home/lib/runner/exit-codes.mjs +13 -0
- package/src/home/lib/runner/flag-baseline.mjs +105 -0
- package/src/home/lib/runner/flag-scan.mjs +165 -0
- package/src/home/lib/runner/git-state.mjs +0 -33
- package/src/home/lib/runner/launcher.mjs +237 -277
- package/src/home/lib/runner/order-claim.mjs +84 -0
- package/src/home/lib/runner/order-options.mjs +30 -0
- package/src/home/lib/runner/order-owner.mjs +19 -0
- package/src/home/lib/runner/pass-gate.mjs +111 -0
- package/src/home/lib/runner/preflight.mjs +26 -28
- package/src/home/lib/runner/prompts.mjs +7 -2
- package/src/home/lib/runner/question-kind.mjs +41 -0
- package/src/home/lib/runner/run-env.mjs +2 -1
- package/src/home/lib/runner/sandbox-probe.mjs +1 -1
- package/src/home/lib/runner/scope-check.mjs +31 -12
- package/src/home/lib/runner/task-input.mjs +34 -12
- package/src/home/lib/runner/worker-admission.mjs +43 -0
- package/src/home/lib/runner/worker.mjs +19 -18
- package/src/home/lib/subagent-model-env.mjs +44 -0
- package/src/home/lib/task-header.mjs +158 -0
- package/src/home/lib/untyped-stop.mjs +30 -0
- package/src/home/lib/write-meta.mjs +17 -5
- package/src/home/lib/required-inputs.mjs +0 -261
- /package/{cli → src/home/lib}/frontmatter.mjs +0 -0
|
@@ -2,9 +2,8 @@
|
|
|
2
2
|
* The half the caller can kill: every preparation and every refusal that costs no quota,
|
|
3
3
|
* then the spawn of the worker and the immediate return; a repeated call waits for the reply.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* worker-order.mjs, which writes it.
|
|
5
|
+
* worker.json in the run folder is the only connection between the two halves after the split.
|
|
6
|
+
* worker-order.mjs writes it and owns its shape and the reason for every field.
|
|
8
7
|
*/
|
|
9
8
|
import fs from 'node:fs';
|
|
10
9
|
import path from 'node:path';
|
|
@@ -15,22 +14,21 @@ import {
|
|
|
15
14
|
writeStatus,
|
|
16
15
|
markAbandoned,
|
|
17
16
|
abandonedBranchDrift,
|
|
18
|
-
chainRuns,
|
|
19
|
-
startedRuns,
|
|
20
|
-
taskFingerprint,
|
|
21
|
-
readJson,
|
|
22
17
|
} from '../write-meta.mjs';
|
|
23
|
-
import {
|
|
18
|
+
import { TASK_HASH_SCHEME } from '../meta/chain.mjs';
|
|
19
|
+
import { orderInputName } from '../order-schema.mjs';
|
|
20
|
+
import { EXIT } from './exit-codes.mjs';
|
|
21
|
+
import { passGate } from './pass-gate.mjs';
|
|
24
22
|
import { setRun } from './run-context.mjs';
|
|
25
23
|
import { loadRunEnv, RUN_ENV } from './run-env.mjs';
|
|
26
24
|
import { parseArgs, die } from './args.mjs';
|
|
27
25
|
import { settleTaskInput } from './task-input.mjs';
|
|
28
|
-
import {
|
|
29
|
-
import {
|
|
30
|
-
import { advisorTaskOrRefuse, adviseSection } from './advise-carry.mjs';
|
|
26
|
+
import { questionsFromTexts, QUESTION_KIND } from './question-kind.mjs';
|
|
27
|
+
import { adviseSection } from './advise-carry.mjs';
|
|
31
28
|
import { schemaFor } from './schemas.mjs';
|
|
32
29
|
import { INSTRUCTIONS } from './prompts.mjs';
|
|
33
|
-
import { git,
|
|
30
|
+
import { git, branchName, worktreeSnapshot, reviewScope } from './git-state.mjs';
|
|
31
|
+
import { writeBuildBefore } from './build-evidence.mjs';
|
|
34
32
|
import { agentRole } from '../agents.mjs';
|
|
35
33
|
import { codexArgs, runProfile } from './codex-args.mjs';
|
|
36
34
|
import { writeWorkerOrder } from './worker-order.mjs';
|
|
@@ -41,15 +39,16 @@ import { cleanupRetention } from '../retention.mjs';
|
|
|
41
39
|
import { renderConventions } from './conventions.mjs';
|
|
42
40
|
import { validateScope } from './scope-check.mjs';
|
|
43
41
|
import { probeSandbox, sandboxRefusal } from './sandbox-probe.mjs';
|
|
44
|
-
import {
|
|
42
|
+
import { acquireOrderClaim, orderClaimBusyText, unclaimedRaceRefusal } from './order-claim.mjs';
|
|
43
|
+
import {
|
|
44
|
+
codexAvailabilityRefusal, preflightRefusal, resolveRunPhase, taskPreflight,
|
|
45
|
+
} from './preflight.mjs';
|
|
45
46
|
|
|
46
47
|
/**
|
|
47
48
|
* The worker is this same program re-invoked as `--worker <runDir>`, so the path spawned
|
|
48
49
|
* below is the CLI entry one level up — not this module, which has no command line of its own.
|
|
49
50
|
*/
|
|
50
51
|
const RUNNER_ENTRY = fileURLToPath(new URL('../run-codex.mjs', import.meta.url));
|
|
51
|
-
export const questionsFromFlags = (questionTexts = []) =>
|
|
52
|
-
questionTexts.map((text, i) => ({ id: `Q${i + 1}`, text }));
|
|
53
52
|
|
|
54
53
|
const stamp = () => {
|
|
55
54
|
const d = new Date();
|
|
@@ -87,308 +86,269 @@ export const runDirPath = (root, slug, runStamp = stamp()) => {
|
|
|
87
86
|
|
|
88
87
|
/**
|
|
89
88
|
* Everything a run needs before a single token of someone else's quota is spent, and every
|
|
90
|
-
* refusal that costs nothing: bad arguments, an open chain without
|
|
89
|
+
* refusal that costs nothing: bad arguments, an open chain without a `continue:` grant, a busy
|
|
91
90
|
* worktree, a dead Codex sandbox, a missing Codex CLI. All in the process the caller is free
|
|
92
91
|
* to kill — so a killed caller can only ever interrupt a run that was already paid for.
|
|
93
92
|
*/
|
|
94
93
|
export async function launcher(argv = process.argv.slice(2)) {
|
|
95
94
|
loadRunEnv();
|
|
96
95
|
const opts = parseArgs(argv);
|
|
96
|
+
const { task: taskText, header } = settleTaskInput(opts);
|
|
97
97
|
opts.phase = resolveRunPhase(opts, RUN_ENV.budgets);
|
|
98
|
-
const taskText = settleTaskInput(opts);
|
|
99
98
|
// Plan_59 D5/D6: blind choices and design authority must be checked before the paid probe.
|
|
100
|
-
const taskGate = taskPreflight({ agent: opts.agent, taskText });
|
|
101
|
-
if (taskGate.refusal) die(taskGate.refusal,
|
|
99
|
+
const taskGate = taskPreflight({ agent: opts.agent, taskText, header });
|
|
100
|
+
if (taskGate.refusal) die(taskGate.refusal, EXIT.FAIL);
|
|
102
101
|
const topLevel = git(opts.repo, ['rev-parse', '--show-toplevel']);
|
|
103
102
|
const isGitRepo = topLevel.status === 0;
|
|
104
103
|
const repoRoot = isGitRepo ? topLevel.stdout.trim() : opts.repo;
|
|
105
104
|
// Plan_27 moved impossible scope failures ahead of the run directory: an absolute pattern had
|
|
106
105
|
// already cost 18 minutes before the verdict could prove it matched nothing.
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
// carries --scope-new; for the other two the list is empty and every pattern must match.
|
|
106
|
+
// Plan_63 D6: only codex-build's header accepts `scope:` and `scope new:`; for the other agents both
|
|
107
|
+
// lists are empty and the check passes. Only paths named in `scope new:` may be absent.
|
|
110
108
|
const scopeRefusal = validateScope(repoRoot, opts.scopePatterns, opts.scopeNewPatterns);
|
|
111
109
|
if (scopeRefusal) {
|
|
112
110
|
die(
|
|
113
|
-
|
|
111
|
+
`${orderInputName(scopeRefusal.label)} pattern ${JSON.stringify(scopeRefusal.pattern)} refused: ` +
|
|
112
|
+
`${scopeRefusal.reason}. ` +
|
|
114
113
|
`Action: ${scopeRefusal.action}. The run folder was not created; quota was not spent.`,
|
|
115
114
|
);
|
|
116
115
|
}
|
|
117
116
|
const projectRunsRoot = resolveProjectRunsDir(runsRoot(), repoRoot).dir;
|
|
117
|
+
// Plan_60 D4c, 2026-09-24: two launchers of one order both billed. The claim comes before markAbandoned, or a
|
|
118
|
+
// contender could close a run whose worker is admitting itself and start a second paid one.
|
|
119
|
+
const claim = await acquireOrderClaim({ projectRunsRoot, orderId: opts.orderId, role: 'launcher' });
|
|
120
|
+
if (claim.busy) die(orderClaimBusyText(opts.orderId, claim.holder), EXIT.FAIL);
|
|
121
|
+
try {
|
|
122
|
+
// Folders left behind by a runner that was killed mid-run get an explicit state before
|
|
123
|
+
// anything else happens. One order produced four of them, and without this pass an
|
|
124
|
+
// abandoned folder is indistinguishable from a run still working: neither has meta.json.
|
|
125
|
+
// The snapshot is passed in because meta/ makes no git calls of its own: it is the only way an
|
|
126
|
+
// abandoned run can be closed with the files it left behind rather than with a bare label.
|
|
127
|
+
markAbandoned(projectRunsRoot, isGitRepo ? worktreeSnapshot(repoRoot) : undefined);
|
|
118
128
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
const drift = abandonedBranchDrift(projectRunsRoot, repoRoot, branchName(repoRoot));
|
|
128
|
-
if (drift) {
|
|
129
|
-
die(
|
|
130
|
-
`repository is detached after abandoned run ${drift.run}, which recorded branch ${drift.branch}. ` +
|
|
131
|
-
`Return with: git checkout ${drift.branch}. No run folder was created and no quota was spent.`,
|
|
132
|
-
);
|
|
129
|
+
if (isGitRepo) {
|
|
130
|
+
const drift = abandonedBranchDrift(projectRunsRoot, repoRoot, branchName(repoRoot));
|
|
131
|
+
if (drift) {
|
|
132
|
+
die(
|
|
133
|
+
`repository is detached after abandoned run ${drift.run}, which recorded branch ${drift.branch}. ` +
|
|
134
|
+
`Return with: git checkout ${drift.branch}. No run folder was created and no quota was spent.`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
133
137
|
}
|
|
134
|
-
}
|
|
135
138
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
//
|
|
141
|
-
// The task is identified by its text as well as by its slug, because the slug is chosen by
|
|
142
|
-
// whoever repeats the run: on 2026-08-02 a dispatcher whose launcher was killed restarted
|
|
143
|
-
// the identical order as `<slug>-v2` and spent 46k on it. Refused before the folder exists,
|
|
144
|
-
// like --scope.
|
|
145
|
-
const taskHash = taskFingerprint(taskText);
|
|
146
|
-
const chain = chainRuns(projectRunsRoot, repoRoot, opts.slug, taskHash, opts.orderId);
|
|
147
|
-
const startedChain = startedRuns(projectRunsRoot, chain);
|
|
148
|
-
const continuationGrant = parseContinuationGrant(taskText);
|
|
149
|
-
const attachExistingRun = () => attach({
|
|
150
|
-
runsRoot: projectRunsRoot, repo: repoRoot, slug: opts.slug, taskHash, orderId: opts.orderId, chain,
|
|
151
|
-
grantRun: continuationGrant?.run, isContinue: opts.continue, noWait: opts.noWait,
|
|
152
|
-
});
|
|
153
|
-
let attachedExitCode = opts.continue ? await attachExistingRun() : null;
|
|
154
|
-
if (attachedExitCode !== null) return attachedExitCode;
|
|
139
|
+
// The pass gate lives in pass-gate.mjs, Plan_75 P0.
|
|
140
|
+
const gate = await passGate({ opts, taskText, header, projectRunsRoot, repoRoot, beforeWait: claim.release });
|
|
141
|
+
if ('exitCode' in gate) return gate.exitCode;
|
|
142
|
+
const { taskHash, chain, startedChain, continuationGrant, advisorTask, retryOf } = gate;
|
|
155
143
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
opts.continue,
|
|
165
|
-
opts.orderId,
|
|
166
|
-
continuationGrant,
|
|
167
|
-
);
|
|
168
|
-
if (continuationError) die(continuationError);
|
|
169
|
-
// Plan_59 D14: the grant is valid by now; an advise pass may continue only an OK scope run.
|
|
170
|
-
const scopeRun = continuationGrant &&
|
|
171
|
-
scopeRunRefusal({ agent: opts.agent, phase: opts.phase, runsRoot: projectRunsRoot, grantRun: continuationGrant.run });
|
|
172
|
-
if (scopeRun) die(scopeRun, 1);
|
|
173
|
-
// Plan_59 D22: the advise snapshot is read before the folder exists, so a broken scope result refuses for free.
|
|
174
|
-
const advisorTask = opts.agent === 'codex-advisor' ? advisorTaskOrRefuse({ taskText, phase: opts.phase, runsRoot: projectRunsRoot, grantRun: continuationGrant?.run }) : null;
|
|
144
|
+
// Plan_60 D2: a missing or signed-out Codex answers UNAVAILABLE (exit 5) before the paid sandbox probe.
|
|
145
|
+
const availability = await codexAvailabilityRefusal();
|
|
146
|
+
if (availability?.unavailable) { process.stdout.write(`${availability.text}\n`); return EXIT.UNAVAILABLE; }
|
|
147
|
+
if (availability) die(availability.text, EXIT.FAIL);
|
|
148
|
+
// Review 2026-09-17: probe before the busy check so it sees writers that registered while the slow
|
|
149
|
+
// probe waited; the dead refusal stays before retention to preserve old run artifacts.
|
|
150
|
+
const sandboxProbe = await probeSandbox({ agent: opts.agent, repo: repoRoot });
|
|
151
|
+
if (sandboxProbe.outcome === 'dead') die(sandboxRefusal(sandboxProbe));
|
|
175
152
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
153
|
+
// Everything that can refuse without touching the tree is asked here, after the probe so the
|
|
154
|
+
// busy check sees writers that registered while it waited, and before makeRunDir below: on
|
|
155
|
+
// 2026-09-19 a busy refusal left its folder inside ~/.claude and the live run's witness spent
|
|
156
|
+
// every tool call demanding the orchestrator revert a directory the tool itself had created.
|
|
157
|
+
// Exit 1, not the usage code 2: the order was correct, the host or the tree was not.
|
|
158
|
+
const preflightError = preflightRefusal({ agent: opts.agent, projectRunsRoot, repoRoot });
|
|
159
|
+
if (preflightError) die(preflightError, EXIT.FAIL);
|
|
181
160
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
161
|
+
let retention = null;
|
|
162
|
+
try {
|
|
163
|
+
retention = cleanupRetention(projectRunsRoot, RUN_ENV?.retention);
|
|
164
|
+
} catch {
|
|
165
|
+
// Plan_17 step 4 makes retention advisory housekeeping: one broken filesystem call must never
|
|
166
|
+
// block a new run.
|
|
167
|
+
retention = null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (claim.unsupported) {
|
|
171
|
+
const text = unclaimedRaceRefusal({
|
|
172
|
+
projectRunsRoot, repoRoot, slug: opts.slug, taskHash, orderId: opts.orderId,
|
|
173
|
+
grantRun: header.grant?.run, chainBefore: chain,
|
|
174
|
+
});
|
|
175
|
+
if (text) die(text, EXIT.FAIL);
|
|
176
|
+
}
|
|
177
|
+
const runDir = makeRunDir(runDirPath(projectRunsRoot, opts.slug));
|
|
178
|
+
setRun(runDir, opts.agent);
|
|
198
179
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
180
|
+
// Written before the worker can start. From here on a killed runner leaves a folder that
|
|
181
|
+
// says what it was and whose pid to check, instead of a folder that says nothing — the run
|
|
182
|
+
// itself takes 20-25 minutes, far longer than the caller's default timeout, so being killed
|
|
183
|
+
// mid-run is the normal way for this to end, not the exotic one.
|
|
184
|
+
//
|
|
185
|
+
// `pid` is the launcher's only until the worker exists, and the worker's from then on:
|
|
186
|
+
// activeRun(), markAbandoned() and the reply guard all read `pid` as "the process whose
|
|
187
|
+
// death means this run is abandoned", and after the spawn that process is the worker.
|
|
188
|
+
writeStatus(runDir, {
|
|
189
|
+
state: 'running',
|
|
190
|
+
pid: process.pid,
|
|
191
|
+
launcher_pid: process.pid,
|
|
192
|
+
process_started_at: performance.timeOrigin,
|
|
193
|
+
agent: opts.agent,
|
|
194
|
+
slug: opts.slug,
|
|
195
|
+
order_id: opts.orderId,
|
|
196
|
+
phase: opts.phase,
|
|
197
|
+
...(taskGate.advice === undefined ? {} : { advice: taskGate.advice }),
|
|
198
|
+
// Fingerprint of the order, so a later run of the same task finds this one whatever it
|
|
199
|
+
// calls itself. Written here, before Codex starts, like everything the chain reads.
|
|
200
|
+
task_hash: taskHash,
|
|
201
|
+
task_hash_scheme: TASK_HASH_SCHEME,
|
|
202
|
+
repo: repoRoot,
|
|
203
|
+
started_at: new Date().toISOString(),
|
|
204
|
+
// Which run of this task started the chain — the base every later pass is measured
|
|
205
|
+
// against. Absent means this is the first pass.
|
|
206
|
+
...(startedChain.length ? { continues: startedChain[0] } : {}),
|
|
207
|
+
// `continued_from` is the exact run the orchestrator named; `continues` above remains the chain base.
|
|
208
|
+
...(continuationGrant ? { continued_from: continuationGrant.run } : {}),
|
|
209
|
+
// Plan_75: the repeated failed run; pass accounting skips runs carrying it.
|
|
210
|
+
...(retryOf ? { retry_of: retryOf } : {}),
|
|
211
|
+
...(retention ? { retention } : {}),
|
|
212
|
+
sandbox_probe: sandboxProbe,
|
|
213
|
+
});
|
|
204
214
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
// every tool call demanding the orchestrator revert a directory the tool itself had created.
|
|
209
|
-
// Exit 1, not the usage code 2: the order was correct, the host or the tree was not.
|
|
210
|
-
const preflightError = preflightRefusal({ agent: opts.agent, projectRunsRoot, repoRoot });
|
|
211
|
-
if (preflightError) die(preflightError, 1);
|
|
215
|
+
// Printed before anything can go wrong: even a dispatcher that dies mid-run leaves the
|
|
216
|
+
// orchestrator with a folder to look into.
|
|
217
|
+
console.log(`RUN=${runDir} order-id=${opts.orderId}`);
|
|
212
218
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
}
|
|
219
|
+
const scope = opts.agent === 'codex-review' ? reviewScope(repoRoot, opts.changeset) : null;
|
|
220
|
+
if (scope) {
|
|
221
|
+
fs.writeFileSync(
|
|
222
|
+
path.join(runDir, 'scope.txt'),
|
|
223
|
+
`${scope.label}\n${scope.diffCommand}\n${scope.files.join('\n')}\n`,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
221
226
|
|
|
222
|
-
|
|
223
|
-
|
|
227
|
+
// The sub-questions this run will be graded against come only from the task document's
|
|
228
|
+
// `Questions` section. They are written before the task is assembled so the prompt and verdict
|
|
229
|
+
// read the same ordered list, including a valid one-question order.
|
|
230
|
+
const questions = opts.agent === 'codex-scout' ? questionsFromTexts(opts.questions) : [];
|
|
231
|
+
if (opts.agent === 'codex-scout') {
|
|
232
|
+
fs.writeFileSync(path.join(runDir, 'questions.json'), `${JSON.stringify(questions, null, 2)}\n`);
|
|
233
|
+
}
|
|
234
|
+
if (advisorTask) {
|
|
235
|
+
fs.writeFileSync(path.join(runDir, 'advisor-task.json'), `${JSON.stringify(advisorTask, null, 2)}\n`);
|
|
236
|
+
}
|
|
224
237
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
// mid-run is the normal way for this to end, not the exotic one.
|
|
229
|
-
//
|
|
230
|
-
// `pid` is the launcher's only until the worker exists, and the worker's from then on:
|
|
231
|
-
// activeRun(), markAbandoned() and the reply guard all read `pid` as "the process whose
|
|
232
|
-
// death means this run is abandoned", and after the spawn that process is the worker.
|
|
233
|
-
writeStatus(runDir, {
|
|
234
|
-
state: 'running',
|
|
235
|
-
pid: process.pid,
|
|
236
|
-
launcher_pid: process.pid,
|
|
237
|
-
process_started_at: performance.timeOrigin,
|
|
238
|
-
agent: opts.agent,
|
|
239
|
-
slug: opts.slug,
|
|
240
|
-
order_id: opts.orderId,
|
|
241
|
-
phase: opts.phase,
|
|
242
|
-
...(taskGate.advice === undefined ? {} : { advice: taskGate.advice }),
|
|
243
|
-
// Fingerprint of the order, so a later run of the same task finds this one whatever it
|
|
244
|
-
// calls itself. Written here, before Codex starts, like everything the chain reads.
|
|
245
|
-
task_hash: taskHash,
|
|
246
|
-
repo: repoRoot,
|
|
247
|
-
started_at: new Date().toISOString(),
|
|
248
|
-
// Which run of this task started the chain — the base every later pass is measured
|
|
249
|
-
// against. Absent means this is the first pass.
|
|
250
|
-
...(startedChain.length ? { continues: startedChain[0] } : {}),
|
|
251
|
-
// `continued_from` is the exact run the orchestrator named; `continues` above remains the chain base.
|
|
252
|
-
...(continuationGrant ? { continued_from: continuationGrant.run } : {}),
|
|
253
|
-
...(retention ? { retention } : {}),
|
|
254
|
-
sandbox_probe: sandboxProbe,
|
|
255
|
-
});
|
|
238
|
+
if (opts.agent === 'codex-build') {
|
|
239
|
+
fs.writeFileSync(path.join(runDir, 'scope.txt'), `${opts.scopePatterns.join('\n')}\n`);
|
|
240
|
+
}
|
|
256
241
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
242
|
+
// Which environment this run actually got. Without it a replay months later cannot tell
|
|
243
|
+
// whether the operator's hooks were in play, and that is the first question a run that
|
|
244
|
+
// wandered off task raises.
|
|
245
|
+
fs.writeFileSync(path.join(runDir, 'env.json'), `${JSON.stringify(RUN_ENV, null, 2)}\n`);
|
|
260
246
|
|
|
261
|
-
|
|
262
|
-
|
|
247
|
+
// The extra sections carry the two things prose could not enforce: what has to be answered,
|
|
248
|
+
// and what may be edited. Both also go to disk as questions.json / scope.txt, so the verdict
|
|
249
|
+
// is computed from the same list Codex was handed, not from a second reading of the wording.
|
|
250
|
+
const sections = [`## Operator task (verbatim)\n\n${taskText}`];
|
|
251
|
+
const scopeResults = adviseSection(advisorTask);
|
|
252
|
+
if (scopeResults) sections.push(scopeResults);
|
|
253
|
+
if (questions.length) {
|
|
254
|
+
sections.push(
|
|
255
|
+
[
|
|
256
|
+
'## Sub-questions, each requires a separate response',
|
|
257
|
+
'',
|
|
258
|
+
questions.map((q) =>
|
|
259
|
+
`${q.id}${q.kind === QUESTION_KIND.STARTUP_CONTEXT ? ' [context-only]' : ''}: ${q.text}`).join('\n'),
|
|
260
|
+
'',
|
|
261
|
+
'A missed sub-question fails the run; a response containing only coordinates counts as missed.',
|
|
262
|
+
].join('\n'),
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
if (opts.agent === 'codex-build') {
|
|
266
|
+
sections.push(
|
|
267
|
+
[
|
|
268
|
+
'## Scope (hard boundary)',
|
|
269
|
+
'',
|
|
270
|
+
'Only these may be changed:',
|
|
271
|
+
opts.scopePatterns.map((p) => `- ${p}`).join('\n'),
|
|
272
|
+
'',
|
|
273
|
+
'Do not touch any file outside this list — even if it blocks the work or looks broken.',
|
|
274
|
+
'Put the obstacle in leftovers instead of changing the file. The touched worktree is',
|
|
275
|
+
'checked against this list after the run.',
|
|
276
|
+
].join('\n'),
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
const conventions = renderConventions(repoRoot);
|
|
280
|
+
if (conventions) sections.push(conventions);
|
|
281
|
+
sections.push(`## Instructions for Codex\n\n${INSTRUCTIONS[opts.agent](opts, scope, questions)}`);
|
|
282
|
+
fs.writeFileSync(path.join(runDir, 'task.md'), `${sections.join('\n\n')}\n`);
|
|
263
283
|
fs.writeFileSync(
|
|
264
|
-
path.join(runDir, '
|
|
265
|
-
`${
|
|
284
|
+
path.join(runDir, 'schema.json'),
|
|
285
|
+
`${JSON.stringify(schemaFor(opts.agent, opts.phase), null, 2)}\n`,
|
|
266
286
|
);
|
|
267
|
-
}
|
|
268
287
|
|
|
269
|
-
|
|
270
|
-
// repeatable flags. They are written before the task is assembled so the prompt and verdict
|
|
271
|
-
// read the same ordered list, including a valid one-question order.
|
|
272
|
-
const questions = opts.agent === 'codex-scout' ? questionsFromFlags(opts.questions) : [];
|
|
273
|
-
if (opts.agent === 'codex-scout') {
|
|
274
|
-
fs.writeFileSync(path.join(runDir, 'questions.json'), `${JSON.stringify(questions, null, 2)}\n`);
|
|
275
|
-
}
|
|
276
|
-
if (advisorTask) fs.writeFileSync(path.join(runDir, 'advisor-task.json'), `${JSON.stringify(advisorTask, null, 2)}\n`);
|
|
288
|
+
if (opts.agent === 'codex-build') writeBuildBefore({ runDir, repoRoot, isGitRepo });
|
|
277
289
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
290
|
+
// The worker's entire order, on disk. Not passed as arguments: the launcher may be gone
|
|
291
|
+
// when the worker needs to know what it is doing, and a folder that explains itself is
|
|
292
|
+
// also the only way to read a run back months later.
|
|
293
|
+
const codexArgv = codexArgs({ ...opts, repo: repoRoot }, runDir, isGitRepo);
|
|
294
|
+
const unsafe = process.platform === 'win32' ? unsafeForCmd(codexArgv) : undefined;
|
|
295
|
+
if (unsafe) {
|
|
296
|
+
const { reply } = writeFailure(
|
|
297
|
+
runDir,
|
|
298
|
+
opts.agent,
|
|
299
|
+
`argument cannot be passed through cmd.exe (contains % or "): ${unsafe}`,
|
|
300
|
+
['Codex was not started; quota was not spent'],
|
|
301
|
+
true,
|
|
302
|
+
);
|
|
303
|
+
console.log(reply);
|
|
304
|
+
return EXIT.FAIL;
|
|
305
|
+
}
|
|
306
|
+
writeWorkerOrder(runDir, {
|
|
307
|
+
agent: opts.agent,
|
|
308
|
+
slug: opts.slug,
|
|
309
|
+
orderId: opts.orderId,
|
|
310
|
+
repo: repoRoot,
|
|
311
|
+
isGitRepo,
|
|
312
|
+
launcherPid: process.pid,
|
|
313
|
+
phase: opts.phase,
|
|
314
|
+
budgetMinutes: RUN_ENV.budgets[agentRole(opts.agent)][opts.phase],
|
|
315
|
+
scopeNew: opts.scopeNewPatterns,
|
|
316
|
+
profile: runProfile({ ...opts, repo: repoRoot }),
|
|
317
|
+
args: codexArgv,
|
|
318
|
+
});
|
|
281
319
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
320
|
+
// detached + unref + no stdio: the worker leaves the caller's process group, so a Ctrl+C
|
|
321
|
+
// or a timeout kill aimed at the dispatcher's shell does not reach it, and it holds no
|
|
322
|
+
// pipe that could fill up and stall once nobody is reading.
|
|
323
|
+
const worker = spawn(process.execPath, [RUNNER_ENTRY, '--worker', runDir], {
|
|
324
|
+
detached: true,
|
|
325
|
+
stdio: 'ignore',
|
|
326
|
+
windowsHide: true,
|
|
327
|
+
cwd: repoRoot,
|
|
328
|
+
});
|
|
329
|
+
const started = await new Promise((resolve) => {
|
|
330
|
+
worker.once('spawn', () => resolve(true));
|
|
331
|
+
worker.once('error', (err) => {
|
|
332
|
+
const { reply } = writeFailure(runDir, opts.agent, `run worker process failed to start: ${err.message}`, [
|
|
333
|
+
'Codex was not started; quota was not spent',
|
|
334
|
+
], true);
|
|
335
|
+
console.log(reply);
|
|
336
|
+
resolve(false);
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
if (!started) return EXIT.FAIL;
|
|
340
|
+
worker.unref();
|
|
341
|
+
writeStatus(runDir, { pid: worker.pid, runner_pid: worker.pid, process_started_at: null });
|
|
286
342
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
// is computed from the same list Codex was handed, not from a second reading of the wording.
|
|
290
|
-
const sections = [`## Operator task (verbatim)\n\n${taskText}`];
|
|
291
|
-
const scopeResults = adviseSection(advisorTask);
|
|
292
|
-
if (scopeResults) sections.push(scopeResults);
|
|
293
|
-
if (questions.length) {
|
|
294
|
-
sections.push(
|
|
295
|
-
[
|
|
296
|
-
'## Sub-questions, each requires a separate response',
|
|
297
|
-
'',
|
|
298
|
-
questions.map((q) => `${q.id}: ${q.text}`).join('\n'),
|
|
299
|
-
'',
|
|
300
|
-
'A missed sub-question fails the run; a response containing only coordinates counts as missed.',
|
|
301
|
-
].join('\n'),
|
|
343
|
+
console.log(
|
|
344
|
+
`STARTED agent=${opts.agent} slug=${opts.slug} order-id=${opts.orderId} worker-pid=${worker.pid}`,
|
|
302
345
|
);
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
sections.push(
|
|
306
|
-
[
|
|
307
|
-
'## Scope (hard boundary)',
|
|
308
|
-
'',
|
|
309
|
-
'Only these may be changed:',
|
|
310
|
-
opts.scopePatterns.map((p) => `- ${p}`).join('\n'),
|
|
311
|
-
'',
|
|
312
|
-
'Do not touch any file outside this list — even if it blocks the work or looks broken.',
|
|
313
|
-
'Put the obstacle in leftovers instead of changing the file. The touched worktree is',
|
|
314
|
-
'checked against this list after the run.',
|
|
315
|
-
].join('\n'),
|
|
316
|
-
);
|
|
317
|
-
}
|
|
318
|
-
const conventions = renderConventions(repoRoot);
|
|
319
|
-
if (conventions) sections.push(conventions);
|
|
320
|
-
sections.push(`## Instructions for Codex\n\n${INSTRUCTIONS[opts.agent](opts, scope, questions)}`);
|
|
321
|
-
fs.writeFileSync(path.join(runDir, 'task.md'), `${sections.join('\n\n')}\n`);
|
|
322
|
-
fs.writeFileSync(
|
|
323
|
-
path.join(runDir, 'schema.json'),
|
|
324
|
-
`${JSON.stringify(schemaFor(opts.agent, opts.phase), null, 2)}\n`,
|
|
325
|
-
);
|
|
326
|
-
|
|
327
|
-
if (opts.agent === 'codex-build') {
|
|
328
|
-
fs.writeFileSync(path.join(runDir, 'head-before.txt'), `${isGitRepo ? headSha(repoRoot) : ''}\n`);
|
|
329
|
-
fs.writeFileSync(path.join(runDir, 'branch-before.txt'), `${isGitRepo ? branchName(repoRoot) : ''}\n`);
|
|
330
|
-
fs.writeFileSync(path.join(runDir, 'git-before.txt'), git(repoRoot, ['-c', 'core.quotepath=false', 'status', '--porcelain']).stdout || '');
|
|
331
|
-
fs.writeFileSync(path.join(runDir, 'state-before.txt'), worktreeSnapshot(repoRoot));
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
// The worker's entire order, on disk. Not passed as arguments: the launcher may be gone
|
|
335
|
-
// when the worker needs to know what it is doing, and a folder that explains itself is
|
|
336
|
-
// also the only way to read a run back months later.
|
|
337
|
-
const codexArgv = codexArgs({ ...opts, repo: repoRoot }, runDir, isGitRepo);
|
|
338
|
-
const unsafe = process.platform === 'win32' ? unsafeForCmd(codexArgv) : undefined;
|
|
339
|
-
if (unsafe) {
|
|
340
|
-
const { reply } = writeFailure(
|
|
341
|
-
runDir,
|
|
342
|
-
opts.agent,
|
|
343
|
-
`argument cannot be passed through cmd.exe (contains % or "): ${unsafe}`,
|
|
344
|
-
['Codex was not started; quota was not spent'],
|
|
345
|
-
true,
|
|
346
|
+
console.log(
|
|
347
|
+
`To get the verdict, repeat the identical command with the same ${orderInputName('order id')}; it will attach to this run and will not start a second run.`,
|
|
346
348
|
);
|
|
347
|
-
|
|
348
|
-
|
|
349
|
+
return EXIT.OK;
|
|
350
|
+
} finally {
|
|
351
|
+
// Plan_60 D4c: release on refusals too; a registered worker must be able to claim admission.
|
|
352
|
+
await claim.release();
|
|
349
353
|
}
|
|
350
|
-
writeWorkerOrder(runDir, {
|
|
351
|
-
agent: opts.agent,
|
|
352
|
-
slug: opts.slug,
|
|
353
|
-
orderId: opts.orderId,
|
|
354
|
-
repo: repoRoot,
|
|
355
|
-
isGitRepo,
|
|
356
|
-
launcherPid: process.pid,
|
|
357
|
-
phase: opts.phase,
|
|
358
|
-
budgetMinutes: RUN_ENV.budgets[agentRole(opts.agent)][opts.phase],
|
|
359
|
-
scopeNew: opts.scopeNewPatterns,
|
|
360
|
-
profile: runProfile({ ...opts, repo: repoRoot }),
|
|
361
|
-
args: codexArgv,
|
|
362
|
-
});
|
|
363
|
-
|
|
364
|
-
// detached + unref + no stdio: the worker leaves the caller's process group, so a Ctrl+C
|
|
365
|
-
// or a timeout kill aimed at the dispatcher's shell does not reach it, and it holds no
|
|
366
|
-
// pipe that could fill up and stall once nobody is reading.
|
|
367
|
-
const worker = spawn(process.execPath, [RUNNER_ENTRY, '--worker', runDir], {
|
|
368
|
-
detached: true,
|
|
369
|
-
stdio: 'ignore',
|
|
370
|
-
windowsHide: true,
|
|
371
|
-
cwd: repoRoot,
|
|
372
|
-
});
|
|
373
|
-
const started = await new Promise((resolve) => {
|
|
374
|
-
worker.once('spawn', () => resolve(true));
|
|
375
|
-
worker.once('error', (err) => {
|
|
376
|
-
const { reply } = writeFailure(runDir, opts.agent, `run worker process failed to start: ${err.message}`, [
|
|
377
|
-
'Codex was not started; quota was not spent',
|
|
378
|
-
], true);
|
|
379
|
-
console.log(reply);
|
|
380
|
-
resolve(false);
|
|
381
|
-
});
|
|
382
|
-
});
|
|
383
|
-
if (!started) return 1;
|
|
384
|
-
worker.unref();
|
|
385
|
-
writeStatus(runDir, { pid: worker.pid, runner_pid: worker.pid, process_started_at: null });
|
|
386
|
-
|
|
387
|
-
console.log(
|
|
388
|
-
`STARTED agent=${opts.agent} slug=${opts.slug} order-id=${opts.orderId} worker-pid=${worker.pid}`,
|
|
389
|
-
);
|
|
390
|
-
console.log(
|
|
391
|
-
'To get the verdict, repeat the identical command with the same --order-id; it will attach to this run and will not start a second run.',
|
|
392
|
-
);
|
|
393
|
-
return 0;
|
|
394
354
|
}
|