@lyupro/codex-bridge 0.6.10 → 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 +81 -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/manifest.mjs +6 -6
- package/package.json +2 -2
- package/src/agents/codex-advisor.md +25 -119
- package/src/agents/codex-build.md +15 -133
- package/src/agents/codex-review.md +10 -109
- package/src/agents/codex-scout.md +11 -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 +59 -40
- package/src/home/hooks/reply-guard.mjs +52 -65
- package/src/home/hooks/reply-identity.mjs +64 -0
- 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/dispatcher-call.mjs +171 -0
- package/src/home/lib/dispatcher-command.mjs +18 -56
- 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/meta/paths.mjs +1 -1
- package/src/home/lib/meta/reply.mjs +2 -1
- 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/runner/args.mjs +140 -231
- package/src/home/lib/runner/attach.mjs +3 -3
- package/src/home/lib/runner/continuation.mjs +174 -192
- package/src/home/lib/runner/launcher.mjs +9 -9
- package/src/home/lib/runner/order-options.mjs +30 -0
- package/src/home/lib/runner/order-owner.mjs +2 -1
- package/src/home/lib/runner/pass-gate.mjs +11 -9
- package/src/home/lib/runner/preflight.mjs +4 -3
- package/src/home/lib/runner/scope-check.mjs +11 -10
- package/src/home/lib/runner/task-input.mjs +23 -10
- package/src/home/lib/subagent-model-env.mjs +44 -0
- package/src/home/lib/task-header.mjs +158 -127
- package/src/home/lib/untyped-stop.mjs +30 -0
- package/src/home/lib/required-inputs.mjs +0 -301
- /package/{cli → src/home/lib}/frontmatter.mjs +0 -0
|
@@ -1,192 +1,174 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Owns the zero-quota decision about whether a requested continuation was ordered.
|
|
3
|
-
*
|
|
4
|
-
* The 2026-08-05 incident showed why the runner must not infer a second pass from a verdict:
|
|
5
|
-
* only the orchestrator read that verdict and can name the run and reason it is willing to fund.
|
|
6
|
-
*/
|
|
7
|
-
import fs from 'node:fs';
|
|
8
|
-
import path from 'node:path';
|
|
9
|
-
import {
|
|
10
|
-
import { readJson } from '../write-meta.mjs';
|
|
11
|
-
import { workerMayBeAlive } from '../meta/run-liveness.mjs';
|
|
12
|
-
|
|
13
|
-
// One comparison of run names for the grant gate and for attach, so a repeat and a grant can never
|
|
14
|
-
// disagree about whether two spellings name the same folder.
|
|
15
|
-
export const sameRun = (runsRootPath, left, right) => {
|
|
16
|
-
const a = path.resolve(runsRootPath, left);
|
|
17
|
-
const b = path.resolve(runsRootPath, right);
|
|
18
|
-
return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
function namedRunDirectory(runsRootPath, run) {
|
|
22
|
-
if (!run || run === '.' || run === '..' || path.basename(run) !== run) return null;
|
|
23
|
-
const directory = path.join(runsRootPath, run);
|
|
24
|
-
try {
|
|
25
|
-
return fs.statSync(directory).isDirectory() ? directory : null;
|
|
26
|
-
} catch {
|
|
27
|
-
return null;
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function normalizeGrant(grant) {
|
|
32
|
-
if (typeof grant
|
|
33
|
-
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
//
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const
|
|
49
|
-
const
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
//
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
if (!isContinue)
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
'
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
`
|
|
112
|
-
`${
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
`${
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
const
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
const status = readJson(path.join(runDir, 'status.json'));
|
|
176
|
-
const wanted = String(orderId ?? '').trim();
|
|
177
|
-
const previousOrder = String(status?.order_id ?? '').trim();
|
|
178
|
-
if (!previousOrder || previousOrder !== wanted) {
|
|
179
|
-
return refuse(`Retry is refused: run ${grant.run} order_id ${previousOrder || '(missing)'} differs from order ${wanted}. ` +
|
|
180
|
-
'A retry repeats its own order; a run without order_id predates orders — start a new order.');
|
|
181
|
-
}
|
|
182
|
-
const meta = readJson(path.join(runDir, 'meta.json'));
|
|
183
|
-
// Plan_75 D1: a verdict alone must not let a retry overlap the failed advise's live writer.
|
|
184
|
-
if (!meta?.status || liveness({ runDir, status })) {
|
|
185
|
-
return refuse(`Retry is refused: run ${grant.run} has no finished verdict or its worker may still be alive and writing. ` +
|
|
186
|
-
'Repeat without --continue to attach to it.');
|
|
187
|
-
}
|
|
188
|
-
if (meta.status === 'OK') {
|
|
189
|
-
return refuse(`Retry is refused: run ${grant.run} ended OK. A retry repeats a failed pass; use continue: for the next pass.`);
|
|
190
|
-
}
|
|
191
|
-
return null;
|
|
192
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Owns the zero-quota decision about whether a requested continuation was ordered.
|
|
3
|
+
*
|
|
4
|
+
* The 2026-08-05 incident showed why the runner must not infer a second pass from a verdict:
|
|
5
|
+
* only the orchestrator read that verdict and can name the run and reason it is willing to fund.
|
|
6
|
+
*/
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { CONTINUATION_ORDER_INPUT, orderInputName } from '../order-schema.mjs';
|
|
10
|
+
import { readJson } from '../write-meta.mjs';
|
|
11
|
+
import { workerMayBeAlive } from '../meta/run-liveness.mjs';
|
|
12
|
+
|
|
13
|
+
// One comparison of run names for the grant gate and for attach, so a repeat and a grant can never
|
|
14
|
+
// disagree about whether two spellings name the same folder.
|
|
15
|
+
export const sameRun = (runsRootPath, left, right) => {
|
|
16
|
+
const a = path.resolve(runsRootPath, left);
|
|
17
|
+
const b = path.resolve(runsRootPath, right);
|
|
18
|
+
return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function namedRunDirectory(runsRootPath, run) {
|
|
22
|
+
if (!run || run === '.' || run === '..' || path.basename(run) !== run) return null;
|
|
23
|
+
const directory = path.join(runsRootPath, run);
|
|
24
|
+
try {
|
|
25
|
+
return fs.statSync(directory).isDirectory() ? directory : null;
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function normalizeGrant(grant) {
|
|
32
|
+
if (!grant || typeof grant !== 'object') return null;
|
|
33
|
+
const run = String(grant.run ?? '').trim();
|
|
34
|
+
const reason = String(grant.reason ?? '').trim();
|
|
35
|
+
return run && reason ? { run, reason } : null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const grantExample = `Example: \`continue: ${CONTINUATION_ORDER_INPUT.example}\`.`;
|
|
39
|
+
const grantAction =
|
|
40
|
+
'Action: ask the orchestrator for an explicit grant naming the run and reason; do not invent or reuse a continuation.';
|
|
41
|
+
|
|
42
|
+
// The 2026-08-10_220535_plan25-2-install-table-two-roots incident showed that a refusal must
|
|
43
|
+
// carry the repair line; otherwise a typo forces an unnecessary directory listing and retry.
|
|
44
|
+
function lastRunOutcome(runsRootPath, chain) {
|
|
45
|
+
const run = chain[chain.length - 1];
|
|
46
|
+
if (!run) return null;
|
|
47
|
+
const status = readJson(path.join(runsRootPath, run, 'status.json')) || {};
|
|
48
|
+
const meta = readJson(path.join(runsRootPath, run, 'meta.json')) || {};
|
|
49
|
+
const outcomeStatus = String(meta.status ?? status.status ?? status.state ?? 'unknown').trim() || 'unknown';
|
|
50
|
+
const outcomeReason = String(meta.reason ?? status.reason ?? 'reason not recorded').trim() || 'reason not recorded';
|
|
51
|
+
return { run, status: outcomeStatus, reason: outcomeReason, verdict: meta.status, orderId: status.order_id };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function readyGrantLines(runsRootPath, chain, grantReason) {
|
|
55
|
+
const last = lastRunOutcome(runsRootPath, chain);
|
|
56
|
+
if (!last) return 'Ready grant line: none (there is no run to continue).';
|
|
57
|
+
const reason = String(grantReason ?? last.reason).trim() || last.reason;
|
|
58
|
+
const continuation = `Ready grant line: continue: ${last.run} — ${reason}.`;
|
|
59
|
+
// Plan_75 D1: the TradeForge failed advise spent its continuation, so show the same-pass repair.
|
|
60
|
+
// A run without order_id cannot be retried (retryRefusal step d), so it gets no retry line.
|
|
61
|
+
const orderId = String(last.orderId ?? '').trim();
|
|
62
|
+
if (!last.verdict || last.verdict === 'OK' || !orderId) return continuation;
|
|
63
|
+
return continuation +
|
|
64
|
+
` Ready retry line: retry: ${last.run} — ${last.reason} (repeats that pass under order ${orderId}).`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function lastRunHints(runsRootPath, chain, grantReason) {
|
|
68
|
+
const last = lastRunOutcome(runsRootPath, chain);
|
|
69
|
+
if (!last) {
|
|
70
|
+
return 'Last run: none. Outcome: none. Ready grant line: none (there is no run to continue).';
|
|
71
|
+
}
|
|
72
|
+
return `Last run: ${last.run}. Outcome: ${last.status} — ${last.reason}. ` +
|
|
73
|
+
readyGrantLines(runsRootPath, chain, grantReason);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Applies the one-continuation limit and all continuation safety gates before a folder is created.
|
|
78
|
+
*
|
|
79
|
+
* The limit is counted over the runs carrying THIS order id, not over the whole chain. The chain
|
|
80
|
+
* also ties runs together by slug and by the fingerprint of the task text, which is what catches a
|
|
81
|
+
* repeat that renamed itself — but counting continuations that way makes the escape hatch
|
|
82
|
+
* unreachable: the operator's rule is "more passes than one need a new order id", and a new order
|
|
83
|
+
* id lands in the same chain through the task hash. A task would then be refused with a grant
|
|
84
|
+
* for having spent its continuation and refused without one for already having runs — permanently
|
|
85
|
+
* unrunnable, which is a worse failure than the retry storm this limit exists to stop.
|
|
86
|
+
*/
|
|
87
|
+
export function continuationRefusal(runsRootPath, chain, isContinue, orderId, grant) {
|
|
88
|
+
const continuation = normalizeGrant(grant);
|
|
89
|
+
// Plan_63 D9: only header grant objects authorize a pass; a string is no grant.
|
|
90
|
+
if (!isContinue || !continuation) return null;
|
|
91
|
+
|
|
92
|
+
const namedDirectory = namedRunDirectory(runsRootPath, continuation.run);
|
|
93
|
+
if (!namedDirectory) {
|
|
94
|
+
return (
|
|
95
|
+
`${orderInputName('continue')} is refused: the orchestrator named run “${continuation.run}”, but it does not ` +
|
|
96
|
+
`exist as a run folder in this project's runs directory ${runsRootPath}. ` +
|
|
97
|
+
`${lastRunHints(runsRootPath, chain, continuation.reason)} ` +
|
|
98
|
+
`${grantExample} ${grantAction} The submitted grant was not rewritten. ` +
|
|
99
|
+
'The run folder was not created; quota was not spent.'
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const last = chain[chain.length - 1];
|
|
104
|
+
// Continuing the last run appends a later run, so the old grant stops matching by itself; this
|
|
105
|
+
// incident needs no counter or new state to make an orchestrator grant single-use.
|
|
106
|
+
if (!last || !sameRun(runsRootPath, continuation.run, last)) {
|
|
107
|
+
return (
|
|
108
|
+
`${orderInputName('continue')} is refused: grant ${continuation.run} is not the LAST run of this task's chain; ` +
|
|
109
|
+
`the current last run is ${last || 'none'}. A continuation is single-use: continuing the ` +
|
|
110
|
+
'last run appends a later run, so the old grant stops matching by itself — no counter or new ' +
|
|
111
|
+
`state is used. ${lastRunHints(runsRootPath, chain, continuation.reason)} ` +
|
|
112
|
+
`${grantExample} ${grantAction} The run folder was not created; quota was not spent.`
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const wanted = String(orderId ?? '').trim();
|
|
117
|
+
// Plan_75 D1, TradeForge capacity incident: a retry spends no new pass, even after an OK verdict.
|
|
118
|
+
const ofThisOrder = chain.filter((run) => {
|
|
119
|
+
const status = readJson(path.join(runsRootPath, run, 'status.json'));
|
|
120
|
+
return String(status?.order_id ?? '').trim() === wanted && !String(status?.retry_of ?? '').trim();
|
|
121
|
+
});
|
|
122
|
+
if (ofThisOrder.length === 0) return null;
|
|
123
|
+
if (ofThisOrder.length > 1) {
|
|
124
|
+
const spent = ofThisOrder.map((run) => path.join(runsRootPath, run)).join(', ');
|
|
125
|
+
return (
|
|
126
|
+
`${orderInputName('continue')} is refused: order “${wanted}” already spent its allowed continuation on ${spent}. ` +
|
|
127
|
+
`${lastRunHints(runsRootPath, chain, continuation.reason)} ` +
|
|
128
|
+
'A further pass needs a new order id from the orchestrator. The run folder was not ' +
|
|
129
|
+
'created; quota was not spent.'
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
const previous = path.join(runsRootPath, ofThisOrder[0]);
|
|
133
|
+
const status = readJson(path.join(previous, 'status.json'));
|
|
134
|
+
const meta = readJson(path.join(previous, 'meta.json'));
|
|
135
|
+
if (!meta?.status || status?.state === 'running') {
|
|
136
|
+
return (
|
|
137
|
+
`${orderInputName('continue')} is refused: previous run ${previous} has no finished verdict and may still ` +
|
|
138
|
+
`be editing the worktree. Repeat without ${orderInputName('continue')} to attach to it. ` +
|
|
139
|
+
`${lastRunHints(runsRootPath, chain, continuation.reason)} The run folder was ` +
|
|
140
|
+
'not created; quota was not spent.'
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function retryRefusal(runsRootPath, chain, isContinue, orderId, grant, liveness = workerMayBeAlive) {
|
|
147
|
+
const refuse = (message) => `${message} The run folder was not created; quota was not spent.`;
|
|
148
|
+
const runDir = namedRunDirectory(runsRootPath, grant?.run);
|
|
149
|
+
if (!runDir) {
|
|
150
|
+
return refuse(`Retry is refused: ${grant?.run || 'none'} is not a bare existing run folder in ${runsRootPath}.`);
|
|
151
|
+
}
|
|
152
|
+
const last = chain[chain.length - 1];
|
|
153
|
+
// Plan_75 D1: appending the TradeForge retry makes this grant single-use without a new counter.
|
|
154
|
+
if (!last || !sameRun(runsRootPath, grant.run, last)) {
|
|
155
|
+
return refuse(`Retry is refused: grant ${grant.run} is not the LAST run of this task's chain; a retry is single-use.`);
|
|
156
|
+
}
|
|
157
|
+
const status = readJson(path.join(runDir, 'status.json'));
|
|
158
|
+
const wanted = String(orderId ?? '').trim();
|
|
159
|
+
const previousOrder = String(status?.order_id ?? '').trim();
|
|
160
|
+
if (!previousOrder || previousOrder !== wanted) {
|
|
161
|
+
return refuse(`Retry is refused: run ${grant.run} order_id ${previousOrder || '(missing)'} differs from order ${wanted}. ` +
|
|
162
|
+
'A retry repeats its own order; a run without order_id predates orders — start a new order.');
|
|
163
|
+
}
|
|
164
|
+
const meta = readJson(path.join(runDir, 'meta.json'));
|
|
165
|
+
// Plan_75 D1: a verdict alone must not let a retry overlap the failed advise's live writer.
|
|
166
|
+
if (!meta?.status || liveness({ runDir, status })) {
|
|
167
|
+
return refuse(`Retry is refused: run ${grant.run} has no finished verdict or its worker may still be alive and writing. ` +
|
|
168
|
+
`Repeat without ${orderInputName('retry')} to attach to it.`);
|
|
169
|
+
}
|
|
170
|
+
if (meta.status === 'OK') {
|
|
171
|
+
return refuse(`Retry is refused: run ${grant.run} ended OK. A retry repeats a failed pass; use continue: for the next pass.`);
|
|
172
|
+
}
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
abandonedBranchDrift,
|
|
17
17
|
} from '../write-meta.mjs';
|
|
18
18
|
import { TASK_HASH_SCHEME } from '../meta/chain.mjs';
|
|
19
|
+
import { orderInputName } from '../order-schema.mjs';
|
|
19
20
|
import { EXIT } from './exit-codes.mjs';
|
|
20
21
|
import { passGate } from './pass-gate.mjs';
|
|
21
22
|
import { setRun } from './run-context.mjs';
|
|
@@ -85,15 +86,15 @@ export const runDirPath = (root, slug, runStamp = stamp()) => {
|
|
|
85
86
|
|
|
86
87
|
/**
|
|
87
88
|
* Everything a run needs before a single token of someone else's quota is spent, and every
|
|
88
|
-
* 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
|
|
89
90
|
* worktree, a dead Codex sandbox, a missing Codex CLI. All in the process the caller is free
|
|
90
91
|
* to kill — so a killed caller can only ever interrupt a run that was already paid for.
|
|
91
92
|
*/
|
|
92
93
|
export async function launcher(argv = process.argv.slice(2)) {
|
|
93
94
|
loadRunEnv();
|
|
94
95
|
const opts = parseArgs(argv);
|
|
95
|
-
opts.phase = resolveRunPhase(opts, RUN_ENV.budgets);
|
|
96
96
|
const { task: taskText, header } = settleTaskInput(opts);
|
|
97
|
+
opts.phase = resolveRunPhase(opts, RUN_ENV.budgets);
|
|
97
98
|
// Plan_59 D5/D6: blind choices and design authority must be checked before the paid probe.
|
|
98
99
|
const taskGate = taskPreflight({ agent: opts.agent, taskText, header });
|
|
99
100
|
if (taskGate.refusal) die(taskGate.refusal, EXIT.FAIL);
|
|
@@ -102,13 +103,12 @@ export async function launcher(argv = process.argv.slice(2)) {
|
|
|
102
103
|
const repoRoot = isGitRepo ? topLevel.stdout.trim() : opts.repo;
|
|
103
104
|
// Plan_27 moved impossible scope failures ahead of the run directory: an absolute pattern had
|
|
104
105
|
// already cost 18 minutes before the verdict could prove it matched nothing.
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
// 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.
|
|
108
108
|
const scopeRefusal = validateScope(repoRoot, opts.scopePatterns, opts.scopeNewPatterns);
|
|
109
109
|
if (scopeRefusal) {
|
|
110
110
|
die(
|
|
111
|
-
`${scopeRefusal.
|
|
111
|
+
`${orderInputName(scopeRefusal.label)} pattern ${JSON.stringify(scopeRefusal.pattern)} refused: ` +
|
|
112
112
|
`${scopeRefusal.reason}. ` +
|
|
113
113
|
`Action: ${scopeRefusal.action}. The run folder was not created; quota was not spent.`,
|
|
114
114
|
);
|
|
@@ -224,8 +224,8 @@ export async function launcher(argv = process.argv.slice(2)) {
|
|
|
224
224
|
);
|
|
225
225
|
}
|
|
226
226
|
|
|
227
|
-
// The sub-questions this run will be graded against come only from the
|
|
228
|
-
//
|
|
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
229
|
// read the same ordered list, including a valid one-question order.
|
|
230
230
|
const questions = opts.agent === 'codex-scout' ? questionsFromTexts(opts.questions) : [];
|
|
231
231
|
if (opts.agent === 'codex-scout') {
|
|
@@ -344,7 +344,7 @@ export async function launcher(argv = process.argv.slice(2)) {
|
|
|
344
344
|
`STARTED agent=${opts.agent} slug=${opts.slug} order-id=${opts.orderId} worker-pid=${worker.pid}`,
|
|
345
345
|
);
|
|
346
346
|
console.log(
|
|
347
|
-
|
|
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.`,
|
|
348
348
|
);
|
|
349
349
|
return EXIT.OK;
|
|
350
350
|
} finally {
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Normalises one order's values into runner options, whatever channel carried them (Plan_63 D5). */
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
export function orderOptions(agentType, order, { cwd = process.cwd() } = {}) {
|
|
5
|
+
const options = { orderId: (order.get('order id') ?? '').trim() };
|
|
6
|
+
const problems = [];
|
|
7
|
+
// Left unset when absent: the role's configured profile fills the gap, and a default here would always win.
|
|
8
|
+
// Plan_56 step 3 leaves effort support to the live catalogue and Codex at run start.
|
|
9
|
+
if (order.has('effort')) {
|
|
10
|
+
options.effort = order.get('effort');
|
|
11
|
+
if (!options.effort || /\s/.test(options.effort)) {
|
|
12
|
+
problems.push({ label: 'effort',
|
|
13
|
+
reason: `must be a non-empty single word with no whitespace; got ${JSON.stringify(options.effort)}` });
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
options.repo = path.resolve(order.get('repository') || cwd);
|
|
17
|
+
options.slug = (order.get('slug') || options.orderId).replace(/[^A-Za-z0-9._-]+/g, '-');
|
|
18
|
+
// Plan_29 incident: a generic slug let an honest order inherit a days-old chain.
|
|
19
|
+
if (!/[A-Za-z0-9]/.test(options.slug)) {
|
|
20
|
+
problems.push({ label: order.has('slug') ? 'slug' : 'order id',
|
|
21
|
+
reason: `produces an unusable run folder name after sanitization: ${JSON.stringify(options.slug)} must contain a letter or digit.` });
|
|
22
|
+
}
|
|
23
|
+
options.changeset = order.get('changeset') || 'uncommitted';
|
|
24
|
+
const declaredScopePatterns = (order.get('scope') || '').split(',').map((p) => p.trim()).filter(Boolean);
|
|
25
|
+
options.scopeNewPatterns = (order.get('scope new') || '').split(',').map((p) => p.trim()).filter(Boolean);
|
|
26
|
+
// Plan_27 keeps ordinary patterns strict: only paths named explicitly as new may be absent.
|
|
27
|
+
options.scopePatterns = [...declaredScopePatterns, ...options.scopeNewPatterns];
|
|
28
|
+
if (order.has('phase')) options.phase = order.get('phase');
|
|
29
|
+
return { options, problems };
|
|
30
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** Keeps the runner and producer hook on one definition of which task owns an order id. */
|
|
2
2
|
import { TASK_HASH_SCHEME } from '../meta/chain.mjs';
|
|
3
|
+
import { orderInputName } from '../order-schema.mjs';
|
|
3
4
|
|
|
4
5
|
const byStartThenName = (left, right) => {
|
|
5
6
|
const leftAt = String(left.status.started_at || '');
|
|
@@ -41,6 +42,6 @@ export function orderOwnerConflictText(owner, ownerDir, orderId) {
|
|
|
41
42
|
`Its answer is already on disk in that folder (codex-bridge read "${ownerDir}"). ` +
|
|
42
43
|
'Another pass of that order needs a continue:/retry: header line; a new task needs a new order id.';
|
|
43
44
|
}
|
|
44
|
-
return `${identity} with a different task. Use a new order id (
|
|
45
|
+
return `${identity} with a different task. Use a new order id (${orderInputName('order id')}), or a continue:/retry: header line ` +
|
|
45
46
|
'if this is another pass of that order.';
|
|
46
47
|
}
|
|
@@ -14,6 +14,7 @@ import { attach } from './attach.mjs';
|
|
|
14
14
|
import { continuationRefusal, retryRefusal, readyGrantLines } from './continuation.mjs';
|
|
15
15
|
import { advisorTaskOrRefuse } from './advise-carry.mjs';
|
|
16
16
|
import { scopeRunRefusal } from './preflight.mjs';
|
|
17
|
+
import { orderInputName } from '../order-schema.mjs';
|
|
17
18
|
|
|
18
19
|
export async function passGate({ opts, taskText, header, projectRunsRoot, repoRoot, beforeWait }) {
|
|
19
20
|
// A second pass at the same task is a real need — after a timeout or a LIMIT the work has
|
|
@@ -24,14 +25,14 @@ export async function passGate({ opts, taskText, header, projectRunsRoot, repoRo
|
|
|
24
25
|
// The task is identified by its text as well as by its slug, because the slug is chosen by
|
|
25
26
|
// whoever repeats the run: on 2026-08-02 a dispatcher whose launcher was killed restarted
|
|
26
27
|
// the identical order as `<slug>-v2` and spent 46k on it. Refused before the folder exists,
|
|
27
|
-
// like
|
|
28
|
+
// like an impossible scope.
|
|
28
29
|
const taskHash = taskFingerprint(taskText);
|
|
29
30
|
const grant = header.grant;
|
|
30
31
|
// OW-040, 2026-09-30: only a retry repeats failed scope without spending advise's continuation.
|
|
31
32
|
if (opts.agent === 'codex-advisor' && opts.phase === 'scope' && opts.continue && grant?.kind !== 'retry') {
|
|
32
|
-
die(
|
|
33
|
+
die(`codex-advisor ${orderInputName('phase')} scope refuses ${orderInputName('continue')}: a scope pass is never continued, and continuing one ` +
|
|
33
34
|
"spends the order's single continuation that its advise phase needs. Action: repeat the scope under a new order id " +
|
|
34
|
-
|
|
35
|
+
`without ${orderInputName('continue')} and without a continue: grant, then run advise as that order's continuation; a ` +
|
|
35
36
|
'scope run that failed is repeated with a `retry:` grant instead. The run folder was not created; quota was not spent.');
|
|
36
37
|
}
|
|
37
38
|
let continuationGrant = grant?.kind === 'continue' ? grant : null;
|
|
@@ -46,11 +47,12 @@ export async function passGate({ opts, taskText, header, projectRunsRoot, repoRo
|
|
|
46
47
|
let attachedExitCode = opts.continue ? await attachExistingRun() : null;
|
|
47
48
|
if (attachedExitCode !== null) return { exitCode: attachedExitCode };
|
|
48
49
|
|
|
49
|
-
// The 2026-08-10_220535_plan25-2-install-table-two-roots incident exposed this ordering:
|
|
50
|
-
//
|
|
50
|
+
// The 2026-08-10_220535_plan25-2-install-table-two-roots incident exposed this ordering: an
|
|
51
|
+
// invalid grant must refuse before attach can print an older run's verdict (Plan_63 D8: the
|
|
52
|
+
// grant alone means continuation, so "grant without flag" no longer exists).
|
|
51
53
|
// markAbandoned() ran before this gate, so a dead runner already has meta.json with FAIL.
|
|
52
54
|
// A run without a verdict can therefore only still be in flight; ordinary repeats go through
|
|
53
|
-
// attach() after this gate. Repeated
|
|
55
|
+
// attach() after this gate. Repeated continuation calls attach first because their grant is spent.
|
|
54
56
|
if (grant?.kind === 'retry') {
|
|
55
57
|
const retryError = retryRefusal(projectRunsRoot, startedChain, opts.continue, opts.orderId, grant);
|
|
56
58
|
if (retryError) die(retryError);
|
|
@@ -92,14 +94,14 @@ export async function passGate({ opts, taskText, header, projectRunsRoot, repoRo
|
|
|
92
94
|
const lastSlug = String(readJson(path.join(projectRunsRoot, last, 'status.json'))?.slug || '');
|
|
93
95
|
const renamed = lastSlug && lastSlug.toLowerCase() !== String(opts.slug).toLowerCase();
|
|
94
96
|
die(
|
|
95
|
-
|
|
97
|
+
`${orderInputName('continue')} is required: ${
|
|
96
98
|
renamed
|
|
97
99
|
? `this task already ran in this repository under the name “${lastSlug}”`
|
|
98
100
|
: `runs for task “${opts.slug}” already exist in this repository`
|
|
99
101
|
} (${startedChain.length}), latest: ${path.join(projectRunsRoot, last)}. ` +
|
|
100
102
|
'A repeat run is allowed, but the orchestrator decides, not the runner: it read the ' +
|
|
101
|
-
|
|
102
|
-
|
|
103
|
+
`previous response and knows whether work remains. Add ${orderInputName('continue')} if you are finishing ` +
|
|
104
|
+
`the same task; changing ${orderInputName('slug')} with the same task text does not stop it being a repeat. ` +
|
|
103
105
|
`${readyGrantLines(projectRunsRoot, startedChain)} ` +
|
|
104
106
|
'The run folder was not created; quota was not spent.',
|
|
105
107
|
);
|
|
@@ -11,6 +11,7 @@ import { activeRunDetails, readJson } from '../write-meta.mjs';
|
|
|
11
11
|
import { probeCodexAvailability } from './codex-availability.mjs';
|
|
12
12
|
import { agentRole } from '../agents.mjs';
|
|
13
13
|
import { die } from './args.mjs';
|
|
14
|
+
import { orderInputName } from '../order-schema.mjs';
|
|
14
15
|
|
|
15
16
|
/** Plan_60 D2: only conclusive evidence says UNAVAILABLE; an unclear probe stays an ordinary refusal. */
|
|
16
17
|
export async function codexAvailabilityRefusal({ probe = probeCodexAvailability } = {}) {
|
|
@@ -84,15 +85,15 @@ export function scopeRunRefusal({ agent, phase, runsRoot, grantRun }) {
|
|
|
84
85
|
export function resolveRunPhase({ agent, phase, continue: isContinue }, budgets) {
|
|
85
86
|
// Plan_59 D7: phase 2 must retain the scope run's predictions before quota is spent.
|
|
86
87
|
if (agent === 'codex-advisor' && phase === 'advise' && !isContinue) {
|
|
87
|
-
die(
|
|
88
|
+
die(`codex-advisor ${orderInputName('phase')} advise requires a ${orderInputName('continue')} grant naming the scope run: phase 2 continues the scope run of the same order ` +
|
|
88
89
|
'so it can settle the risks phase 1 predicted. The run folder was not created; quota was not spent.');
|
|
89
90
|
}
|
|
90
91
|
const phases = budgets[agentRole(agent)];
|
|
91
92
|
const names = Object.keys(phases);
|
|
92
93
|
if (phase === undefined && names.length === 1 && names[0] === 'default') return 'default';
|
|
93
94
|
if (phase !== undefined && Object.hasOwn(phases, phase)) return phase;
|
|
94
|
-
die(`${phase === undefined ? '
|
|
95
|
-
`for ${agent}. Action: pass
|
|
95
|
+
die(`${phase === undefined ? `${orderInputName('phase')} is required` : `undeclared ${orderInputName('phase')} ${JSON.stringify(phase)}`} ` +
|
|
96
|
+
`for ${agent}. Action: pass ${orderInputName('phase')} <name>; allowed phases: ${names.join(', ')}. ` +
|
|
96
97
|
'The run folder was not created; quota was not spent.');
|
|
97
98
|
}
|
|
98
99
|
|