@lyupro/codex-bridge 0.6.9 → 0.6.10

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 (52) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +1 -1
  3. package/cli/lifecycle-lock-inspect.mjs +3 -2
  4. package/cli/lifecycle-lock.mjs +19 -132
  5. package/cli/read.mjs +20 -0
  6. package/cli/settings-merge.mjs +3 -29
  7. package/package.json +2 -2
  8. package/scripts/home-write-inventory.mjs +12 -2
  9. package/src/agents/codex-advisor.md +22 -12
  10. package/src/agents/codex-build.md +24 -14
  11. package/src/agents/codex-review.md +17 -10
  12. package/src/agents/codex-scout.md +22 -12
  13. package/src/home/hooks/order-gate.mjs +25 -11
  14. package/src/home/hooks/reply-parser.mjs +1 -1
  15. package/src/home/lib/command-path.mjs +32 -0
  16. package/src/home/lib/dispatcher-command.mjs +4 -2
  17. package/src/home/lib/kernel-lock.mjs +171 -0
  18. package/src/home/lib/meta/chain.mjs +19 -8
  19. package/src/home/lib/meta/context-only.mjs +43 -0
  20. package/src/home/lib/meta/events.mjs +14 -2
  21. package/src/home/lib/meta/launch-rows.mjs +23 -0
  22. package/src/home/lib/meta/reply.mjs +37 -3
  23. package/src/home/lib/meta/run-state.mjs +3 -0
  24. package/src/home/lib/meta/verdict.mjs +16 -8
  25. package/src/home/lib/required-inputs.mjs +48 -8
  26. package/src/home/lib/run-codex.mjs +6 -3
  27. package/src/home/lib/runner/advise-carry.mjs +2 -1
  28. package/src/home/lib/runner/args.mjs +16 -5
  29. package/src/home/lib/runner/attach.mjs +21 -14
  30. package/src/home/lib/runner/build-evidence.mjs +29 -0
  31. package/src/home/lib/runner/codex-availability.mjs +50 -0
  32. package/src/home/lib/runner/codex-cmd.mjs +2 -19
  33. package/src/home/lib/runner/continuation.mjs +60 -8
  34. package/src/home/lib/runner/exit-codes.mjs +13 -0
  35. package/src/home/lib/runner/flag-baseline.mjs +105 -0
  36. package/src/home/lib/runner/flag-scan.mjs +165 -0
  37. package/src/home/lib/runner/git-state.mjs +0 -33
  38. package/src/home/lib/runner/launcher.mjs +233 -273
  39. package/src/home/lib/runner/order-claim.mjs +84 -0
  40. package/src/home/lib/runner/order-owner.mjs +18 -0
  41. package/src/home/lib/runner/pass-gate.mjs +109 -0
  42. package/src/home/lib/runner/preflight.mjs +22 -25
  43. package/src/home/lib/runner/prompts.mjs +7 -2
  44. package/src/home/lib/runner/question-kind.mjs +41 -0
  45. package/src/home/lib/runner/run-env.mjs +2 -1
  46. package/src/home/lib/runner/sandbox-probe.mjs +1 -1
  47. package/src/home/lib/runner/scope-check.mjs +29 -11
  48. package/src/home/lib/runner/task-input.mjs +11 -2
  49. package/src/home/lib/runner/worker-admission.mjs +43 -0
  50. package/src/home/lib/runner/worker.mjs +19 -18
  51. package/src/home/lib/task-header.mjs +127 -0
  52. package/src/home/lib/write-meta.mjs +17 -5
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Holds one kernel claim per project run store and exact order id during preparation.
3
+ * Plan_60 D4/D4c: on 2026-09-24 two launchers started 2.8 seconds apart with one order id
4
+ * and both ran and billed; a kernel-held claim queues preparation and dies with its holder.
5
+ */
6
+ import { randomUUID } from 'node:crypto';
7
+ import path from 'node:path';
8
+ import {
9
+ acquireKernelLock, directoryDigest, kernelLockAddress, kernelLockStrategy,
10
+ } from '../kernel-lock.mjs';
11
+ import { parseJsonText } from '../json-file.mjs';
12
+ import { chainRuns, readJson } from '../write-meta.mjs';
13
+
14
+ // Plan_60 D4/D4c: preparation takes seconds; expiry must refuse rather than permit double billing.
15
+ export const ORDER_CLAIM_TIMING = Object.freeze({ waitMs: 60_000, retryMs: 100, answerTimeoutMs: 1_000 });
16
+
17
+ export function orderClaimAddress(projectRunsRoot, orderId, platform = process.platform) {
18
+ if (typeof orderId !== 'string' || orderId.length === 0) {
19
+ throw new TypeError('orderId must be a non-empty string');
20
+ }
21
+ // Plan_60 A4 r5: owner matching is case-sensitive; retries must retain the exact order id.
22
+ const digest = directoryDigest(projectRunsRoot, { suffix: orderId });
23
+ if (digest === null) throw new Error(`project runs folder does not exist: ${projectRunsRoot}`);
24
+ if (kernelLockStrategy(platform) === null) return null;
25
+ return kernelLockAddress('order', digest, platform);
26
+ }
27
+
28
+ export async function acquireOrderClaim({
29
+ projectRunsRoot, orderId, role, platform = process.platform,
30
+ timing = ORDER_CLAIM_TIMING, createServer,
31
+ }) {
32
+ if (role !== 'launcher' && role !== 'worker') {
33
+ throw new TypeError('role must be launcher or worker');
34
+ }
35
+ const address = orderClaimAddress(projectRunsRoot, orderId, platform);
36
+ if (address === null) return { claimed: false, unsupported: true, release: async () => {} };
37
+ const holder = { pid: process.pid, role, orderId, token: randomUUID() };
38
+ const result = await acquireKernelLock({
39
+ address, holder, ...timing, parseAnswer: parseClaimHolder, label: 'order claim', createServer,
40
+ });
41
+ if (result.held) return { claimed: true, release: result.release };
42
+ return { claimed: false, busy: true, holder: result.holder };
43
+ }
44
+
45
+ export function parseClaimHolder(line) {
46
+ try {
47
+ const holder = parseJsonText('<order claim holder>', line);
48
+ if (holder === null || typeof holder !== 'object') return null;
49
+ if (!Number.isInteger(holder.pid) || holder.pid <= 0) return null;
50
+ if (holder.role !== 'launcher' && holder.role !== 'worker') return null;
51
+ for (const key of ['orderId', 'token', 'acquiredAt']) {
52
+ if (typeof holder[key] !== 'string' || holder[key].length === 0) return null;
53
+ }
54
+ return holder;
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+
60
+ // Plan_60 D4/D4c: a holder answer diagnoses contention but cannot authorize anything.
61
+ export function orderClaimHolderText(holder) {
62
+ return holder === null
63
+ ? 'a process that did not answer (unverified)'
64
+ : `${holder.role} pid ${holder.pid} since ${holder.acquiredAt}`;
65
+ }
66
+
67
+ export function orderClaimBusyText(orderId, holder) {
68
+ return `order id "${orderId}" is being launched by ${orderClaimHolderText(holder)}; repeat the same command later — `
69
+ + 'it attaches to that run instead of starting another. The run folder was not created; quota was not spent.';
70
+ }
71
+
72
+ /**
73
+ * Plan_60 D4, 2026-09-24: macOS has no kernel claim, so re-read just before registration.
74
+ * This shrinks the double-billing window to milliseconds and never waits on another launch.
75
+ */
76
+ export function unclaimedRaceRefusal({ projectRunsRoot, repoRoot, slug, taskHash, orderId, grantRun, chainBefore }) {
77
+ const chain = chainRuns(projectRunsRoot, repoRoot, slug, taskHash, orderId, grantRun);
78
+ const before = new Set(chainBefore);
79
+ const appeared = chain.find((run) => !before.has(run)
80
+ && readJson(path.join(projectRunsRoot, run, 'status.json'))?.order_id === orderId);
81
+ if (!appeared) return null;
82
+ return `another launch of order id "${orderId}" registered ${appeared} while this one was preparing; `
83
+ + 'repeat the same command — it attaches to that run. The run folder was not created; quota was not spent.';
84
+ }
@@ -1,4 +1,5 @@
1
1
  /** Keeps the runner and producer hook on one definition of which task owns an order id. */
2
+ import { TASK_HASH_SCHEME } from '../meta/chain.mjs';
2
3
 
3
4
  const byStartThenName = (left, right) => {
4
5
  const leftAt = String(left.status.started_at || '');
@@ -26,3 +27,20 @@ export function conflictingOrderOwner(runs, orderId, taskHash) {
26
27
  const incomingHash = String(taskHash ?? '').trim().toLowerCase();
27
28
  return ownerHash && incomingHash && ownerHash !== incomingHash ? owner : null;
28
29
  }
30
+
31
+ export function ownerPredatesHashScheme(owner) {
32
+ return owner.status.task_hash_scheme !== TASK_HASH_SCHEME;
33
+ }
34
+
35
+ export function orderOwnerConflictText(owner, ownerDir, orderId) {
36
+ const identity = `Order id collision: ${JSON.stringify(String(orderId ?? ''))} already belongs to run folder ${ownerDir} ` +
37
+ `(slug ${owner.status.slug}, started_at ${owner.status.started_at})`;
38
+ // Plan_75 D5, 2026-10-03: an old hash cannot prove that a repeated order is a different task.
39
+ if (ownerPredatesHashScheme(owner)) {
40
+ return `${identity}, recorded before the task header (hash scheme ${TASK_HASH_SCHEME}), so its task cannot be compared. ` +
41
+ `Its answer is already on disk in that folder (codex-bridge read "${ownerDir}"). ` +
42
+ 'Another pass of that order needs a continue:/retry: header line; a new task needs a new order id.';
43
+ }
44
+ return `${identity} with a different task. Use a new order id (--order-id), or a continue:/retry: header line ` +
45
+ 'if this is another pass of that order.';
46
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Decides, before anything is registered, whether a call attaches to an existing run of its order,
3
+ * is a granted continuation, or is a refused repeat.
4
+ *
5
+ * Moved out of launcher.mjs unchanged (Plan_75 P0): the launcher sat at the 400-line gate, and the
6
+ * 2026-10-03 TradeForge advise that died on model capacity showed this gate needs a retry path.
7
+ * It is handed no run directory, so like preflight.mjs it cannot leave a folder behind a refusal.
8
+ */
9
+ import path from 'node:path';
10
+ import { chainRuns, startedRuns, taskFingerprint, readJson } from '../write-meta.mjs';
11
+ import { die } from './args.mjs';
12
+ import { EXIT } from './exit-codes.mjs';
13
+ import { attach } from './attach.mjs';
14
+ import { continuationRefusal, retryRefusal, readyGrantLines } from './continuation.mjs';
15
+ import { advisorTaskOrRefuse } from './advise-carry.mjs';
16
+ import { scopeRunRefusal } from './preflight.mjs';
17
+
18
+ export async function passGate({ opts, taskText, header, projectRunsRoot, repoRoot, beforeWait }) {
19
+ // A second pass at the same task is a real need — after a timeout or a LIMIT the work has
20
+ // to be finished — so it is allowed and made visible rather than forbidden. What is
21
+ // forbidden is repeating by accident: the orchestrator saw the previous reply, the runner
22
+ // did not, so only the orchestrator can decide there is something left to finish.
23
+ //
24
+ // The task is identified by its text as well as by its slug, because the slug is chosen by
25
+ // whoever repeats the run: on 2026-08-02 a dispatcher whose launcher was killed restarted
26
+ // the identical order as `<slug>-v2` and spent 46k on it. Refused before the folder exists,
27
+ // like --scope.
28
+ const taskHash = taskFingerprint(taskText);
29
+ const grant = header.grant;
30
+ // OW-040, 2026-09-30: only a retry repeats failed scope without spending advise's continuation.
31
+ if (opts.agent === 'codex-advisor' && opts.phase === 'scope' && opts.continue && grant?.kind !== 'retry') {
32
+ die('codex-advisor --phase scope refuses --continue: a scope pass is never continued, and continuing one ' +
33
+ "spends the order's single continuation that its advise phase needs. Action: repeat the scope under a new order id " +
34
+ "without --continue and without a continue: grant, then run advise as that order's continuation; a " +
35
+ 'scope run that failed is repeated with a `retry:` grant instead. The run folder was not created; quota was not spent.');
36
+ }
37
+ let continuationGrant = grant?.kind === 'continue' ? grant : null;
38
+ const retryOf = grant?.kind === 'retry' ? grant.run : null;
39
+ const chain = chainRuns(projectRunsRoot, repoRoot, opts.slug, taskHash, opts.orderId, grant?.run);
40
+ const startedChain = startedRuns(projectRunsRoot, chain);
41
+ const attachExistingRun = () => attach({
42
+ runsRoot: projectRunsRoot, repo: repoRoot, slug: opts.slug, taskHash, orderId: opts.orderId, chain,
43
+ grantRun: continuationGrant?.run, isContinue: opts.continue, noWait: opts.noWait,
44
+ retryRun: retryOf ?? undefined, beforeWait,
45
+ });
46
+ let attachedExitCode = opts.continue ? await attachExistingRun() : null;
47
+ if (attachedExitCode !== null) return { exitCode: attachedExitCode };
48
+
49
+ // The 2026-08-10_220535_plan25-2-install-table-two-roots incident exposed this ordering:
50
+ // a grant without its flag must refuse before attach can print an older run's verdict.
51
+ // markAbandoned() ran before this gate, so a dead runner already has meta.json with FAIL.
52
+ // A run without a verdict can therefore only still be in flight; ordinary repeats go through
53
+ // attach() after this gate. Repeated --continue calls attach first because their grant is spent.
54
+ if (grant?.kind === 'retry') {
55
+ const retryError = retryRefusal(projectRunsRoot, startedChain, opts.continue, opts.orderId, grant);
56
+ if (retryError) die(retryError);
57
+ const namedStatus = readJson(path.join(projectRunsRoot, grant.run, 'status.json'));
58
+ // A run written before phases existed has none; for every agent but the advisor that is 'default'.
59
+ const phaseOf = (value) => String(value ?? '').trim() || 'default';
60
+ if (namedStatus.agent !== opts.agent || phaseOf(namedStatus.phase) !== phaseOf(opts.phase)) {
61
+ die(`a retry repeats the named run's own agent and phase: ${namedStatus.agent}/${namedStatus.phase}. ` +
62
+ 'The run folder was not created; quota was not spent.');
63
+ }
64
+ // Plan_75 D4, TradeForge capacity incident: retry advise against its original successful scope.
65
+ continuationGrant = String(namedStatus.continued_from ?? '').trim()
66
+ ? { run: namedStatus.continued_from, reason: grant.reason } : null;
67
+ } else {
68
+ const continuationError = continuationRefusal(
69
+ projectRunsRoot,
70
+ startedChain,
71
+ opts.continue,
72
+ opts.orderId,
73
+ continuationGrant,
74
+ );
75
+ if (continuationError) die(continuationError);
76
+ }
77
+ // Plan_59 D14: the grant is valid by now; an advise pass may continue only an OK scope run.
78
+ const scopeRun = continuationGrant &&
79
+ scopeRunRefusal({ agent: opts.agent, phase: opts.phase, runsRoot: projectRunsRoot, grantRun: continuationGrant.run });
80
+ if (scopeRun) die(scopeRun, EXIT.FAIL);
81
+ // Plan_59 D22: the advise snapshot is read before the folder exists, so a broken scope result refuses for free.
82
+ const advisorTask = opts.agent === 'codex-advisor' ? advisorTaskOrRefuse({ taskText, phase: opts.phase, runsRoot: projectRunsRoot, grantRun: continuationGrant?.run }) : null;
83
+
84
+ // One order produced six Codex runs on 2026-08-03 because the caller's time ceiling made it
85
+ // restart the synchronous launcher. A live same-order run is now the repeat target: attach
86
+ // before creating a folder, probing Codex or spending another token.
87
+ if (!opts.continue) attachedExitCode = await attachExistingRun();
88
+ if (attachedExitCode !== null) return { exitCode: attachedExitCode };
89
+
90
+ if (startedChain.length && !opts.continue) {
91
+ const last = startedChain[startedChain.length - 1];
92
+ const lastSlug = String(readJson(path.join(projectRunsRoot, last, 'status.json'))?.slug || '');
93
+ const renamed = lastSlug && lastSlug.toLowerCase() !== String(opts.slug).toLowerCase();
94
+ die(
95
+ `--continue is required: ${
96
+ renamed
97
+ ? `this task already ran in this repository under the name “${lastSlug}”`
98
+ : `runs for task “${opts.slug}” already exist in this repository`
99
+ } (${startedChain.length}), latest: ${path.join(projectRunsRoot, last)}. ` +
100
+ 'A repeat run is allowed, but the orchestrator decides, not the runner: it read the ' +
101
+ 'previous response and knows whether work remains. Add --continue if you are finishing ' +
102
+ 'the same task; changing --slug with the same task text does not stop it being a repeat. ' +
103
+ `${readyGrantLines(projectRunsRoot, startedChain)} ` +
104
+ 'The run folder was not created; quota was not spent.',
105
+ );
106
+ }
107
+
108
+ return { taskHash, chain, startedChain, continuationGrant, advisorTask, retryOf };
109
+ }
@@ -6,13 +6,29 @@
6
6
  import path from 'node:path';
7
7
  import fs from 'node:fs';
8
8
  import { advisorTaskRefusal } from '../meta/advisor-task.mjs';
9
+ import { unavailableRows } from '../meta/reply.mjs';
9
10
  import { activeRunDetails, readJson } from '../write-meta.mjs';
10
- import { codexUnavailableReason } from './codex-cmd.mjs';
11
+ import { probeCodexAvailability } from './codex-availability.mjs';
11
12
  import { agentRole } from '../agents.mjs';
12
13
  import { die } from './args.mjs';
13
14
 
15
+ /** Plan_60 D2: only conclusive evidence says UNAVAILABLE; an unclear probe stays an ordinary refusal. */
16
+ export async function codexAvailabilityRefusal({ probe = probeCodexAvailability } = {}) {
17
+ const result = await probe();
18
+ if (result.state === 'available') return null;
19
+ const unavailable = result.state === 'missing' || result.state === 'logged-out';
20
+ const rows = unavailable ? unavailableRows(result.detail) : [
21
+ `Codex CLI unavailable: ${result.detail}`,
22
+ 'Operator check: codex --version (and codex login if authorization is rejected)',
23
+ ];
24
+ return {
25
+ unavailable,
26
+ text: [...rows, 'The run folder was not created; quota was not spent.'].join('\n'),
27
+ };
28
+ }
29
+
14
30
  /** Plan_59 D5/D6: settle design authority before any probe can spend quota. */
15
- export function taskPreflight({ agent, taskText }) {
31
+ export function taskPreflight({ agent, taskText, header }) {
16
32
  const freeRefusal = (reason) => ({
17
33
  refusal: `${reason}\nThe run folder was not created; quota was not spent.`,
18
34
  });
@@ -21,13 +37,8 @@ export function taskPreflight({ agent, taskText }) {
21
37
  return reason ? freeRefusal(reason) : { refusal: null };
22
38
  }
23
39
  if (agent !== 'codex-build') return { refusal: null };
24
- const lines = taskText.split(/\r\n|\n|\r/)
25
- .flatMap((line) => {
26
- const match = line.match(/^\s*(?:-\s+)?advice\s*:\s*(.*?)\s*$/i);
27
- return match ? [match[1]] : [];
28
- });
29
- const advice = lines[0];
30
- if (lines.length === 1) {
40
+ const advice = header.advice;
41
+ if (advice !== null) {
31
42
  if (['mechanical', 'revert', 'docs-only', 'test-only'].includes(advice)) {
32
43
  return { refusal: null, advice };
33
44
  }
@@ -44,7 +55,8 @@ export function taskPreflight({ agent, taskText }) {
44
55
  }
45
56
  }
46
57
  }
47
- return freeRefusal('codex-build requires exactly one advice: line: mechanical | revert | docs-only | test-only ' +
58
+ return freeRefusal('codex-build requires the task file to start with the header line `advice: <value>`, where the ' +
59
+ 'value is mechanical | revert | docs-only | test-only ' +
48
60
  'or an absolute path to an existing advisor run directory whose meta.json says agent codex-advisor, ' +
49
61
  'phase advise, status OK. ' +
50
62
  'An order that invents a construction needs a second opinion first; only work with no design choice may skip it.');
@@ -70,13 +82,6 @@ export function scopeRunRefusal({ agent, phase, runsRoot, grantRun }) {
70
82
 
71
83
  /** D2: phase mistakes must refuse before even the sandbox probe can spend quota. */
72
84
  export function resolveRunPhase({ agent, phase, continue: isContinue }, budgets) {
73
- // OW-040, 2026-09-30: continuing a failed scope spent the order's only continuation and blocked advise.
74
- if (agent === 'codex-advisor' && phase === 'scope' && isContinue) {
75
- die('codex-advisor --phase scope refuses --continue: a scope pass is never continued, and continuing one ' +
76
- "spends the order's single continuation that its advise phase needs. Action: repeat the scope under a new order id " +
77
- "without --continue and without a continue: grant, then run advise as that order's continuation. The run folder " +
78
- 'was not created; quota was not spent.');
79
- }
80
85
  // Plan_59 D7: phase 2 must retain the scope run's predictions before quota is spent.
81
86
  if (agent === 'codex-advisor' && phase === 'advise' && !isContinue) {
82
87
  die('codex-advisor --phase advise requires --continue: phase 2 continues the scope run of the same order ' +
@@ -107,13 +112,5 @@ export function preflightRefusal({ agent, projectRunsRoot, repoRoot }) {
107
112
  ].join('\n');
108
113
  }
109
114
 
110
- const why = codexUnavailableReason();
111
- if (why !== null) {
112
- return [
113
- `Codex CLI unavailable: ${why}`,
114
- 'Operator check: codex --version (and codex login if authorization is rejected)',
115
- 'The run folder was not created; quota was not spent.',
116
- ].join('\n');
117
- }
118
115
  return null;
119
116
  }
@@ -10,6 +10,7 @@
10
10
  import { DEFAULTS } from '../run-config.mjs';
11
11
  import { RUN_ENV } from './run-env.mjs';
12
12
  import { advisorSchema, schemaFor } from './schemas.mjs';
13
+ import { QUESTION_KIND } from './question-kind.mjs';
13
14
 
14
15
  function boundedArrayRules(schema, path = '') {
15
16
  if (schema.type === 'object') {
@@ -93,8 +94,12 @@ ${
93
94
  : `- Put one object in answers with question_id "Q1": analysis in answer, references in evidence.
94
95
  - The top-level answer is the same response summarized in 2-4 sentences.`
95
96
  }
96
- - Base the response on what you read; support every fact with a \`path:line\` reference in where.
97
- - If facts are missing, list them in unknowns; do not make them up.
97
+ - Base the response on what you read; support every fact about the code with a \`path:line\` reference in where.
98
+ ${questions.some((question) => question?.kind === QUESTION_KIND.STARTUP_CONTEXT)
99
+ ? `- A sub-question marked [context-only] asks about what this run was handed at startup
100
+ (task, instructions, schema, environment), not about the repository. Answer it from that context,
101
+ cite evidence as \`startup:<source>\`, and never cite a repository \`path:line\` you did not read
102
+ with a command — that fails the run.\n` : ''}- If facts are missing, list them in unknowns; do not make them up.
98
103
  - report_markdown is a full markdown report with sections: “Response” (2-4 sentences),
99
104
  “How it works” (required: prose analysis ${questions.length ? 'for each sub-question' : 'of the mechanism'},
100
105
  grounded in the code), “Findings” (fact / location / confidence table), “Missing information”.`,
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Classifies orderer-marked scout questions and refuses malformed markers before quota is spent.
3
+ * Plan_60 D1: only the orderer knows whether a question concerns handed startup context or code;
4
+ * the scout cannot choose its own kind, and startup context currently fails without a command.
5
+ */
6
+ export const CONTEXT_ONLY_MARKER = '[context-only]';
7
+ export const QUESTION_KIND = Object.freeze({
8
+ STARTUP_CONTEXT: 'startup-context',
9
+ CODE_REQUIRED: 'code-required',
10
+ });
11
+
12
+ export function questionKindRefusal(texts = []) {
13
+ for (const [index, text] of texts.entries()) {
14
+ const tokens = text.matchAll(/\[\s*context[\s_-]*only\s*\]/gi);
15
+ for (const token of tokens) {
16
+ if (token.index !== 0 || token[0] !== CONTEXT_ONLY_MARKER) {
17
+ return `Q${index + 1} has an invalid context-only marker in ${JSON.stringify(text)}`;
18
+ }
19
+ }
20
+ if (text.startsWith(CONTEXT_ONLY_MARKER)) {
21
+ const question = text.slice(CONTEXT_ONLY_MARKER.length);
22
+ if (!/^\s/.test(question) || !question.trim()) {
23
+ return `Q${index + 1} has an invalid context-only marker in ${JSON.stringify(text)}`;
24
+ }
25
+ }
26
+ }
27
+ return null;
28
+ }
29
+
30
+ export function questionsFromTexts(texts = []) {
31
+ const refusal = questionKindRefusal(texts);
32
+ if (refusal) throw new Error(refusal);
33
+ return texts.map((text, index) => {
34
+ const marked = text.startsWith(CONTEXT_ONLY_MARKER);
35
+ return {
36
+ id: `Q${index + 1}`,
37
+ text: marked ? text.slice(CONTEXT_ONLY_MARKER.length).replace(/^\s+/, '') : text,
38
+ kind: marked ? QUESTION_KIND.STARTUP_CONTEXT : QUESTION_KIND.CODE_REQUIRED,
39
+ };
40
+ });
41
+ }
@@ -13,6 +13,7 @@
13
13
  * falling back — a typo must not decide what environment a run gets.
14
14
  */
15
15
  import { readRunConfig, disableFlags } from '../run-config.mjs';
16
+ import { EXIT } from './exit-codes.mjs';
16
17
 
17
18
  /**
18
19
  * The switches as read (they go to env.json) and the flags they turn into (they go to
@@ -33,6 +34,6 @@ export function loadRunEnv() {
33
34
  CLEAN_ENV = disableFlags(RUN_ENV);
34
35
  } catch (err) {
35
36
  console.log(`FAIL — ${err.message}\nRun was not started; quota was not spent`);
36
- process.exit(1);
37
+ process.exit(EXIT.FAIL);
37
38
  }
38
39
  }
@@ -85,7 +85,7 @@ export async function probeSandbox({
85
85
 
86
86
  const version = await attempt('version', ['--version']);
87
87
  if (version.error || version.signal || version.status !== 0) {
88
- // codexUnavailableReason() owns the refusal for an unavailable CLI, in the same pre-flight pass.
88
+ // codexAvailabilityRefusal() owns the refusal for an unavailable CLI, earlier in the same pre-flight pass.
89
89
  return finish('inconclusive', 'Codex CLI is unavailable.');
90
90
  }
91
91
  return finish('dead', 'The Codex sandbox on this host cannot start a process.');
@@ -81,7 +81,18 @@ function structuralRefusal(repoRoot, pattern) {
81
81
  return null;
82
82
  }
83
83
 
84
- function noMatchRefusal(pattern) {
84
+ function noMatchRefusal(pattern, repoRoot, gitListed) {
85
+ // OW-042: order plan72-r3-b1-live-thresholds-20261001 ran from a folder outside git, the walk missed a tracked
86
+ // file, and the refusal claimed the file did not exist. A walked folder is not the repository git would list.
87
+ if (gitListed === false) {
88
+ return {
89
+ pattern,
90
+ reason: `does not match any existing path under ${repoRoot}; ` +
91
+ 'git could not list that folder as a repository, so it was walked instead',
92
+ action: 'start the run from the repository root (cd into it, or pass --repo <repository root>); ' +
93
+ 'if this folder is the intended one, correct the pattern',
94
+ };
95
+ }
85
96
  return {
86
97
  pattern,
87
98
  reason: 'does not match any existing path in the repository',
@@ -127,7 +138,8 @@ function walkPaths(repoRoot) {
127
138
  * process instead of a walk and cannot wander into `.git` or a dependency tree.
128
139
  */
129
140
  function matchingPaths(repoRoot, matchers) {
130
- const candidates = listRepositoryPaths(repoRoot) ?? walkPaths(repoRoot);
141
+ const listed = listRepositoryPaths(repoRoot);
142
+ const candidates = listed ?? walkPaths(repoRoot);
131
143
  const matched = new Set();
132
144
  for (const candidate of candidates) {
133
145
  for (const matcher of matchers) {
@@ -137,7 +149,7 @@ function matchingPaths(repoRoot, matchers) {
137
149
  }
138
150
  if (matched.size === matchers.length) break;
139
151
  }
140
- return matched;
152
+ return { matched, gitListed: listed !== null };
141
153
  }
142
154
 
143
155
  function patternList(value, label) {
@@ -158,14 +170,18 @@ export function validateScope(repoRoot, patterns, scopeNewPatterns = []) {
158
170
  const declared = patternList(patterns, 'patterns');
159
171
  const newPaths = patternList(scopeNewPatterns, 'scopeNewPatterns');
160
172
  const allPatterns = [...declared, ...newPaths];
173
+ const newPathKeys = new Set(newPaths.map((pattern) => normalizePath(pattern)));
161
174
 
175
+ // The flag is decided by --scope-new membership, not list position: args.mjs merges every --scope-new
176
+ // pattern into the declared list, so position named --scope for a directory given only to --scope-new
177
+ // (OW-042 B1, caught on the live runner 2026-10-04 after the unit test passed separate lists).
162
178
  for (const pattern of allPatterns) {
179
+ const flag = newPathKeys.has(normalizePath(pattern)) ? '--scope-new' : '--scope';
163
180
  const refusal = structuralRefusal(repoRoot, pattern);
164
- if (refusal) return { pattern, ...refusal };
165
- if (!pattern) return noMatchRefusal(pattern);
181
+ if (refusal) return { pattern, flag, ...refusal };
182
+ if (!pattern) return { ...noMatchRefusal(pattern), flag };
166
183
  }
167
184
 
168
- const newPathKeys = new Set(newPaths.map((pattern) => normalizePath(pattern)));
169
185
  const required = allPatterns.filter((pattern) => !newPathKeys.has(normalizePath(pattern)));
170
186
  if (!required.length) return null;
171
187
 
@@ -174,23 +190,25 @@ export function validateScope(repoRoot, patterns, scopeNewPatterns = []) {
174
190
  matchers = required.map((pattern) => ({ pattern, regexp: globToRegExp(pattern) }));
175
191
  } catch (error) {
176
192
  return {
177
- pattern: required[0].pattern,
193
+ pattern: required[0],
194
+ flag: '--scope',
178
195
  reason: `could not parse the pattern: ${error.message}`,
179
196
  action: 'correct the pattern and retry',
180
197
  };
181
198
  }
182
199
 
183
- let matched;
200
+ let matches;
184
201
  try {
185
- matched = matchingPaths(repoRoot, matchers);
202
+ matches = matchingPaths(repoRoot, matchers);
186
203
  } catch (error) {
187
204
  if (error.code !== 'ERR_GIT_PATH_NOT_UTF8') throw error;
188
205
  return {
189
206
  pattern: required[0],
207
+ flag: '--scope',
190
208
  reason: error.message,
191
209
  action: 'rename the non-UTF-8 file using a UTF-8 file name and retry',
192
210
  };
193
211
  }
194
- const missing = matchers.find((matcher) => !matched.has(matcher.pattern));
195
- return missing ? noMatchRefusal(missing.pattern) : null;
212
+ const missing = matchers.find((matcher) => !matches.matched.has(matcher.pattern));
213
+ return missing ? { ...noMatchRefusal(missing.pattern, repoRoot, matches.gitListed), flag: '--scope' } : null;
196
214
  }
@@ -8,9 +8,10 @@
8
8
  * questions in the file got refused for not passing --question.
9
9
  */
10
10
  import { die, readTaskDocument } from './args.mjs';
11
+ import { questionKindRefusal } from './question-kind.mjs';
11
12
 
12
13
  export function settleTaskInput(opts) {
13
- const { task, questions: fileQuestions, verify: fileVerify } = readTaskDocument(opts);
14
+ const { task, header, questions: fileQuestions, verify: fileVerify } = readTaskDocument(opts);
14
15
  opts.questions ??= fileQuestions.length ? fileQuestions : undefined;
15
16
  opts.verify ??= fileVerify;
16
17
  // Still before the run folder exists and before a token of someone else's quota is touched,
@@ -22,5 +23,13 @@ export function settleTaskInput(opts) {
22
23
  'The runner will not infer questions from the task text; no quota was spent.',
23
24
  );
24
25
  }
25
- return task;
26
+ if (opts.agent === 'codex-scout') {
27
+ const refusal = questionKindRefusal(opts.questions);
28
+ if (refusal) {
29
+ die(
30
+ `${refusal}; the marker is exactly [context-only] followed by a space and the question; no quota was spent.`,
31
+ );
32
+ }
33
+ }
34
+ return { task, header };
26
35
  }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Admits a worker and records its identity under its order claim before any paid work starts.
3
+ * Plan_60 D4/D4c, A4 r4: a launcher killed after spawn frees the claim before recording the
4
+ * detached worker, so a second launcher could abandon its run and start another paid run.
5
+ */
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { readJsonFileSync } from '../json-file.mjs';
9
+ import { writeStatus } from '../meta/run-state.mjs';
10
+ import { acquireOrderClaim } from './order-claim.mjs';
11
+
12
+ export async function admitWorker({ runDir, orderId, timing, platform, createServer }) {
13
+ const claim = await acquireOrderClaim({
14
+ projectRunsRoot: path.dirname(runDir),
15
+ orderId,
16
+ role: 'worker',
17
+ ...(timing !== undefined ? { timing } : {}),
18
+ ...(platform !== undefined ? { platform } : {}),
19
+ ...(createServer !== undefined ? { createServer } : {}),
20
+ });
21
+ try {
22
+ if (claim.busy) return { admitted: false, reason: 'claim-timeout', holder: claim.holder };
23
+ let status = null;
24
+ try {
25
+ status = readJsonFileSync(path.join(runDir, 'status.json'));
26
+ } catch {
27
+ // Plan_60 A4 r4: an unreadable run cannot authorize paid work after a launcher dies.
28
+ }
29
+ if (status?.state !== 'running' || fs.existsSync(path.join(runDir, 'meta.json'))) {
30
+ return { admitted: false, reason: 'closed', state: status?.state ?? null };
31
+ }
32
+ // The launcher cannot know this detached process's clock origin; takeover must record the
33
+ // worker's own identity before it starts producing the run's artifacts.
34
+ writeStatus(runDir, {
35
+ pid: process.pid,
36
+ runner_pid: process.pid,
37
+ process_started_at: performance.timeOrigin,
38
+ });
39
+ return { admitted: true };
40
+ } finally {
41
+ await claim.release?.();
42
+ }
43
+ }
@@ -3,7 +3,7 @@
3
3
  *
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
- * `repo`, `agent`, `args`, `is_git_repo`, `budget_minutes`. Written there: those five plus
6
+ * `repo`, `agent`, `args`, `is_git_repo`, `budget_minutes`, `order_id`. Written there: those six plus
7
7
  * `phase`, `slug`, `launcher_pid` and `scope_new`, which nothing here needs but a run folder read back
8
8
  * months later does. The launcher must write the selected phase's budget into worker.json before
9
9
  * spawning this half, so the worker never re-reads config.json and one run cannot acquire two
@@ -13,11 +13,14 @@
13
13
  import fs from 'node:fs';
14
14
  import path from 'node:path';
15
15
  import { readJsonFileSync } from '../json-file.mjs';
16
- import { collect, exitCodeFor, AGENTS } from '../write-meta.mjs';
16
+ import { collect, exitCodeFor, AGENTS, writeFailure } from '../write-meta.mjs';
17
17
  import { writeStatus } from '../meta/run-state.mjs';
18
18
  import { setRun, emitReply } from './run-context.mjs';
19
- import { git, headSha, branchName, worktreeSnapshot, findFakeDone } from './git-state.mjs';
19
+ import { writeBuildAfter } from './build-evidence.mjs';
20
20
  import { runCodex } from './codex-cmd.mjs';
21
+ import { admitWorker } from './worker-admission.mjs';
22
+ import { orderClaimHolderText } from './order-claim.mjs';
23
+ import { EXIT } from './exit-codes.mjs';
21
24
 
22
25
  /**
23
26
  * Runs the order in runDir and answers into reply.txt, never to a console — nothing here
@@ -27,13 +30,18 @@ export async function worker(runDir) {
27
30
  const cfg = readJsonFileSync(path.join(runDir, 'worker.json'));
28
31
  const repoRoot = cfg.repo;
29
32
  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
- });
33
+ const admission = await admitWorker({ runDir, orderId: cfg.order_id });
34
+ if (!admission.admitted) {
35
+ // Plan_60 A4 r4: a replacement launcher may have closed this run while the worker was starting.
36
+ if (admission.reason === 'claim-timeout') {
37
+ const { reply } = writeFailure(
38
+ runDir, cfg.agent, 'run worker was not admitted: the order claim stayed busy',
39
+ [`Claim held by ${orderClaimHolderText(admission.holder)}`, 'Codex was not started; quota was not spent'],
40
+ );
41
+ emitReply(reply);
42
+ }
43
+ process.exit(EXIT.FAIL);
44
+ }
37
45
 
38
46
  const run = await runCodex(
39
47
  cfg.args,
@@ -52,14 +60,7 @@ export async function worker(runDir) {
52
60
  stdio_drained: run.stdioDrained,
53
61
  });
54
62
 
55
- if (cfg.agent === 'codex-build') {
56
- fs.writeFileSync(path.join(runDir, 'head-after.txt'), `${cfg.is_git_repo ? headSha(repoRoot) : ''}\n`);
57
- fs.writeFileSync(path.join(runDir, 'branch-after.txt'), `${cfg.is_git_repo ? branchName(repoRoot) : ''}\n`);
58
- fs.writeFileSync(path.join(runDir, 'git-after.txt'), git(repoRoot, ['-c', 'core.quotepath=false', 'status', '--porcelain']).stdout || '');
59
- fs.writeFileSync(path.join(runDir, 'state-after.txt'), worktreeSnapshot(repoRoot));
60
- fs.writeFileSync(path.join(runDir, 'diff.stat'), git(repoRoot, ['-c', 'core.quotepath=false', 'diff', '--stat']).stdout || '');
61
- fs.writeFileSync(path.join(runDir, 'flags.txt'), findFakeDone(repoRoot));
62
- }
63
+ if (cfg.agent === 'codex-build') writeBuildAfter({ runDir, repoRoot, isGitRepo: cfg.is_git_repo });
63
64
 
64
65
  // Human-readable report is carried inside the schema: a read-only scout cannot write
65
66
  // files itself, so the runner unpacks it.