@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.
Files changed (88) hide show
  1. package/CHANGELOG.md +132 -0
  2. package/README.md +15 -4
  3. package/cli/dispatcher-model-check.mjs +59 -0
  4. package/cli/doctor-host-side.mjs +2 -1
  5. package/cli/doctor-installation.mjs +111 -111
  6. package/cli/doctor.mjs +13 -5
  7. package/cli/handback-witness-check.mjs +24 -18
  8. package/cli/hosts.mjs +2 -10
  9. package/cli/install-owners.mjs +1 -4
  10. package/cli/install-record.mjs +2 -1
  11. package/cli/inventory-transition.mjs +1 -1
  12. package/cli/lifecycle-lock-inspect.mjs +3 -2
  13. package/cli/lifecycle-lock.mjs +19 -132
  14. package/cli/manifest.mjs +6 -6
  15. package/cli/read.mjs +20 -0
  16. package/cli/settings-merge.mjs +3 -29
  17. package/package.json +2 -2
  18. package/scripts/home-write-inventory.mjs +12 -2
  19. package/src/agents/codex-advisor.md +37 -121
  20. package/src/agents/codex-build.md +27 -135
  21. package/src/agents/codex-review.md +19 -111
  22. package/src/agents/codex-scout.md +21 -130
  23. package/src/home/hooks/dispatcher-gate.mjs +14 -9
  24. package/src/home/hooks/live-runs.mjs +0 -41
  25. package/src/home/hooks/order-gate.mjs +80 -47
  26. package/src/home/hooks/reply-guard.mjs +52 -65
  27. package/src/home/hooks/reply-identity.mjs +64 -0
  28. package/src/home/hooks/reply-parser.mjs +1 -1
  29. package/src/home/hooks/reply-verdicts.mjs +31 -27
  30. package/src/home/hooks/reply-witness.mjs +48 -0
  31. package/src/home/lib/claude-layout.mjs +14 -0
  32. package/src/home/lib/command-path.mjs +32 -0
  33. package/src/home/lib/dispatcher-call.mjs +171 -0
  34. package/src/home/lib/dispatcher-command.mjs +18 -54
  35. package/src/home/lib/dispatcher-gate.mjs +4 -0
  36. package/src/home/lib/dispatcher-model.mjs +112 -0
  37. package/src/home/lib/dispatcher-order.mjs +21 -22
  38. package/src/home/lib/dispatcher-pin.mjs +60 -0
  39. package/src/home/lib/dispatcher-protocol.mjs +91 -0
  40. package/src/home/lib/dispatcher-stop.mjs +11 -2
  41. package/src/home/lib/handback-witness.mjs +137 -48
  42. package/src/home/lib/home-registry.mjs +7 -0
  43. package/src/home/lib/install-owner-roots.mjs +53 -0
  44. package/src/home/lib/kernel-lock.mjs +171 -0
  45. package/src/home/lib/meta/chain.mjs +19 -8
  46. package/src/home/lib/meta/context-only.mjs +43 -0
  47. package/src/home/lib/meta/events.mjs +14 -2
  48. package/src/home/lib/meta/launch-rows.mjs +23 -0
  49. package/src/home/lib/meta/paths.mjs +1 -1
  50. package/src/home/lib/meta/reply.mjs +39 -4
  51. package/src/home/lib/meta/run-state.mjs +3 -0
  52. package/src/home/lib/meta/verdict.mjs +16 -8
  53. package/src/home/lib/model-family.mjs +38 -0
  54. package/src/home/lib/observation-ledger.mjs +145 -0
  55. package/src/home/lib/order-schema.mjs +204 -0
  56. package/src/home/lib/order-values.mjs +46 -0
  57. package/src/home/lib/run-codex.mjs +6 -3
  58. package/src/home/lib/runner/advise-carry.mjs +2 -1
  59. package/src/home/lib/runner/args.mjs +140 -220
  60. package/src/home/lib/runner/attach.mjs +24 -17
  61. package/src/home/lib/runner/build-evidence.mjs +29 -0
  62. package/src/home/lib/runner/codex-availability.mjs +50 -0
  63. package/src/home/lib/runner/codex-cmd.mjs +2 -19
  64. package/src/home/lib/runner/continuation.mjs +174 -140
  65. package/src/home/lib/runner/exit-codes.mjs +13 -0
  66. package/src/home/lib/runner/flag-baseline.mjs +105 -0
  67. package/src/home/lib/runner/flag-scan.mjs +165 -0
  68. package/src/home/lib/runner/git-state.mjs +0 -33
  69. package/src/home/lib/runner/launcher.mjs +237 -277
  70. package/src/home/lib/runner/order-claim.mjs +84 -0
  71. package/src/home/lib/runner/order-options.mjs +30 -0
  72. package/src/home/lib/runner/order-owner.mjs +19 -0
  73. package/src/home/lib/runner/pass-gate.mjs +111 -0
  74. package/src/home/lib/runner/preflight.mjs +26 -28
  75. package/src/home/lib/runner/prompts.mjs +7 -2
  76. package/src/home/lib/runner/question-kind.mjs +41 -0
  77. package/src/home/lib/runner/run-env.mjs +2 -1
  78. package/src/home/lib/runner/sandbox-probe.mjs +1 -1
  79. package/src/home/lib/runner/scope-check.mjs +31 -12
  80. package/src/home/lib/runner/task-input.mjs +34 -12
  81. package/src/home/lib/runner/worker-admission.mjs +43 -0
  82. package/src/home/lib/runner/worker.mjs +19 -18
  83. package/src/home/lib/subagent-model-env.mjs +44 -0
  84. package/src/home/lib/task-header.mjs +158 -0
  85. package/src/home/lib/untyped-stop.mjs +30 -0
  86. package/src/home/lib/write-meta.mjs +17 -5
  87. package/src/home/lib/required-inputs.mjs +0 -261
  88. /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
- * The order it leaves for the worker is worker.json in the run folder — after the split that file
6
- * is the only connection between the two halves. Its shape and the reason for every field live in
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 { attach } from './attach.mjs';
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 { parseContinuationGrant } from '../required-inputs.mjs';
29
- import { continuationRefusal } from './continuation.mjs';
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, headSha, branchName, worktreeSnapshot, reviewScope } from './git-state.mjs';
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 { preflightRefusal, resolveRunPhase, scopeRunRefusal, taskPreflight } from './preflight.mjs';
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 --continue, a busy
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, 1);
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
- // Every agent, not only the writing one: a pattern that cannot match gives a scout empty coverage
108
- // instead of an answer. Only codex-build may declare a not-yet-existing path, so only its scope
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
- `--scope pattern ${JSON.stringify(scopeRefusal.pattern)} refused: ${scopeRefusal.reason}. ` +
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
- // Folders left behind by a runner that was killed mid-run get an explicit state before
120
- // anything else happens. One order produced four of them, and without this pass an
121
- // abandoned folder is indistinguishable from a run still working: neither has meta.json.
122
- // The snapshot is passed in because meta/ makes no git calls of its own: it is the only way an
123
- // abandoned run can be closed with the files it left behind rather than with a bare label.
124
- markAbandoned(projectRunsRoot, isGitRepo ? worktreeSnapshot(repoRoot) : undefined);
125
-
126
- if (isGitRepo) {
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
- // A second pass at the same task is a real need — after a timeout or a LIMIT the work has
137
- // to be finished — so it is allowed and made visible rather than forbidden. What is
138
- // forbidden is repeating by accident: the orchestrator saw the previous reply, the runner
139
- // did not, so only the orchestrator can decide there is something left to finish.
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
- // The 2026-08-10_220535_plan25-2-install-table-two-roots incident exposed this ordering:
157
- // a grant without its flag must refuse before attach can print an older run's verdict.
158
- // markAbandoned() ran before this gate, so a dead runner already has meta.json with FAIL.
159
- // A run without a verdict can therefore only still be in flight; ordinary repeats go through
160
- // attach() after this gate. Repeated --continue calls attach first because their grant is spent.
161
- const continuationError = continuationRefusal(
162
- projectRunsRoot,
163
- startedChain,
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
- // One order produced six Codex runs on 2026-08-03 because the caller's time ceiling made it
177
- // restart the synchronous launcher. A live same-order run is now the repeat target: attach
178
- // before creating a folder, probing Codex or spending another token.
179
- if (!opts.continue) attachedExitCode = await attachExistingRun();
180
- if (attachedExitCode !== null) return attachedExitCode;
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
- if (startedChain.length && !opts.continue) {
183
- const last = startedChain[startedChain.length - 1];
184
- const lastSlug = String(readJson(path.join(projectRunsRoot, last, 'status.json'))?.slug || '');
185
- const renamed = lastSlug && lastSlug.toLowerCase() !== String(opts.slug).toLowerCase();
186
- die(
187
- `--continue is required: ${
188
- renamed
189
- ? `this task already ran in this repository under the name “${lastSlug}”`
190
- : `runs for task “${opts.slug}” already exist in this repository`
191
- } (${startedChain.length}), latest: ${path.join(projectRunsRoot, last)}. ` +
192
- 'A repeat run is allowed, but the orchestrator decides, not the runner: it read the ' +
193
- 'previous response and knows whether work remains. Add --continue if you are finishing ' +
194
- 'the same task; changing --slug with the same task text does not stop it being a repeat. ' +
195
- 'The run folder was not created; quota was not spent.',
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
- // Review 2026-09-17: the slow probe widened the window between the busy check and registration.
200
- // Probe first so the busy check sees writers that registered while it waited.
201
- // Keep dead refusal before retention to preserve old run artifacts.
202
- const sandboxProbe = await probeSandbox({ agent: opts.agent, repo: repoRoot });
203
- if (sandboxProbe.outcome === 'dead') die(sandboxRefusal(sandboxProbe));
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
- // Everything that can refuse without touching the tree is asked here, after the probe so the
206
- // busy check sees writers that registered while it waited, and before makeRunDir below: on
207
- // 2026-09-19 a busy refusal left its folder inside ~/.claude and the live run's witness spent
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
- let retention = null;
214
- try {
215
- retention = cleanupRetention(projectRunsRoot, RUN_ENV?.retention);
216
- } catch {
217
- // Plan_17 step 4 makes retention advisory housekeeping: one broken filesystem call must never
218
- // block a new run.
219
- retention = null;
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
- const runDir = makeRunDir(runDirPath(projectRunsRoot, opts.slug));
223
- setRun(runDir, opts.agent);
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
- // Written before the worker can start. From here on a killed runner leaves a folder that
226
- // says what it was and whose pid to check, instead of a folder that says nothing — the run
227
- // itself takes 20-25 minutes, far longer than the caller's default timeout, so being killed
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
- // Printed before anything can go wrong: even a dispatcher that dies mid-run leaves the
258
- // orchestrator with a folder to look into.
259
- console.log(`RUN=${runDir} order-id=${opts.orderId}`);
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
- const scope = opts.agent === 'codex-review' ? reviewScope(repoRoot, opts.changeset) : null;
262
- if (scope) {
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, 'scope.txt'),
265
- `${scope.label}\n${scope.diffCommand}\n${scope.files.join('\n')}\n`,
284
+ path.join(runDir, 'schema.json'),
285
+ `${JSON.stringify(schemaFor(opts.agent, opts.phase), null, 2)}\n`,
266
286
  );
267
- }
268
287
 
269
- // The sub-questions this run will be graded against come only from the orchestrator's
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
- if (opts.agent === 'codex-build') {
279
- fs.writeFileSync(path.join(runDir, 'scope.txt'), `${opts.scopePatterns.join('\n')}\n`);
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
- // Which environment this run actually got. Without it a replay months later cannot tell
283
- // whether the operator's hooks were in play, and that is the first question a run that
284
- // wandered off task raises.
285
- fs.writeFileSync(path.join(runDir, 'env.json'), `${JSON.stringify(RUN_ENV, null, 2)}\n`);
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
- // The extra sections carry the two things prose could not enforce: what has to be answered,
288
- // and what may be edited. Both also go to disk as questions.json / scope.txt, so the verdict
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
- if (opts.agent === 'codex-build') {
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
- console.log(reply);
348
- return 1;
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
  }