@lyupro/codex-bridge 0.1.2 → 0.1.4
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 +26 -0
- package/README.md +2 -1
- package/cli/doctor.mjs +37 -35
- package/cli/install.mjs +50 -16
- package/cli/manifest.mjs +49 -13
- package/cli/settings-merge.mjs +58 -22
- package/cli/uninstall.mjs +25 -6
- package/cli/update.mjs +33 -7
- package/package.json +1 -1
- package/src/agents/codex-build.md +19 -17
- package/src/agents/codex-review.md +17 -5
- package/src/agents/codex-scout.md +18 -12
- package/src/hook-definitions.mjs +36 -0
- package/src/hooks/live-runs.mjs +98 -0
- package/src/hooks/order-gate.mjs +68 -0
- package/src/hooks/reply-guard.mjs +43 -20
- package/src/hooks/worktree-lock.mjs +70 -0
- package/src/meta/outcome.mjs +65 -0
- package/src/meta/reply.mjs +23 -0
- package/src/meta/verdict.mjs +8 -0
- package/src/required-inputs.mjs +147 -0
- package/src/runner/args.mjs +13 -5
- package/src/runner/continuation.mjs +107 -0
- package/src/runner/launcher.mjs +12 -40
- package/src/runner/prompts.mjs +9 -0
- package/src/runner/schemas.mjs +23 -1
package/src/meta/reply.mjs
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* The lines built here ARE the reply. Agents forward this text verbatim instead of
|
|
10
10
|
* composing prose, which is what keeps a delegated run at five lines.
|
|
11
11
|
*/
|
|
12
|
+
import fs from 'node:fs';
|
|
12
13
|
import path from 'node:path';
|
|
13
14
|
import { changedPaths, line, readText } from './paths.mjs';
|
|
14
15
|
import { splitRunChanges } from './environment.mjs';
|
|
@@ -113,10 +114,32 @@ export function failReply(ctx, meta) {
|
|
|
113
114
|
return [
|
|
114
115
|
`FAIL — ${line(meta.reason, 170)}`,
|
|
115
116
|
`Artifacts: raw.log ${meta.log_bytes} B · ${path.basename(ctx.resultPath)} ${meta.result_ok ? 'filled' : 'empty or missing'} · exit=${meta.exit}`,
|
|
117
|
+
// A failed build says what it left behind, exactly as a LIMIT does. "The work was not
|
|
118
|
+
// done" is not "the tree is clean": a run can write half a change and then declare fail,
|
|
119
|
+
// and the orchestrator has to know whether there is something to revert before it decides
|
|
120
|
+
// anything else.
|
|
121
|
+
...(ctx.agent === 'codex-build' ? [`Worktree: ${worktreeState(ctx.runDir)}`] : []),
|
|
116
122
|
`Log: ${ctx.file('raw.log')}`,
|
|
117
123
|
];
|
|
118
124
|
}
|
|
119
125
|
|
|
126
|
+
/**
|
|
127
|
+
* What the run left in the tree, or an admission that nobody knows. Both snapshots are
|
|
128
|
+
* required: a run killed before it wrote state-after.txt has no delta to compute, and
|
|
129
|
+
* printing "no new changes" there would be a claim made out of missing data — the same
|
|
130
|
+
* mistake status.json's `tree_after: false` exists to prevent.
|
|
131
|
+
*/
|
|
132
|
+
function worktreeState(runDir) {
|
|
133
|
+
const hasSnapshots = ['state-before.txt', 'state-after.txt'].every((f) =>
|
|
134
|
+
fs.existsSync(path.join(runDir, f)),
|
|
135
|
+
);
|
|
136
|
+
if (!hasSnapshots) return 'unknown — the run left no worktree snapshot, check git status';
|
|
137
|
+
const { work } = runChanges(runDir);
|
|
138
|
+
return work.length
|
|
139
|
+
? `has unfinished changes (${work.length}): ${line(work.slice(0, 3).join(', '), 120)}`
|
|
140
|
+
: 'no new changes';
|
|
141
|
+
}
|
|
142
|
+
|
|
120
143
|
export function limitReply(ctx, meta) {
|
|
121
144
|
const rows = [
|
|
122
145
|
'LIMIT — ChatGPT quota exhausted, work not completed',
|
package/src/meta/verdict.mjs
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
} from './paths.mjs';
|
|
23
23
|
import { chainBaseline } from './chain.mjs';
|
|
24
24
|
import { splitRunChanges } from './environment.mjs';
|
|
25
|
+
import { outcomeGap } from './outcome.mjs';
|
|
25
26
|
|
|
26
27
|
/**
|
|
27
28
|
* Quota exhaustion is a transport error, not a word. Both markers must sit on the same
|
|
@@ -306,6 +307,13 @@ export function resolveStatus({ log, logBytes, resultOk, exit, agent, result, ru
|
|
|
306
307
|
reason: `verification “${line(result.verify_command || 'no command', 60)}” failed; changes remain in the tree`,
|
|
307
308
|
};
|
|
308
309
|
}
|
|
310
|
+
// What the executor says about its own work, after verification and ahead of scope: work
|
|
311
|
+
// that was never done matters more than where it was not done. Only runs whose schema.json
|
|
312
|
+
// demanded the field are judged by it — see outcome.mjs.
|
|
313
|
+
if (agent === 'codex-build') {
|
|
314
|
+
const declared = outcomeGap(runDir, result);
|
|
315
|
+
if (declared) return { status: 'FAIL', reason: declared };
|
|
316
|
+
}
|
|
309
317
|
// Answers that never arrived, or arrived as coordinates. Scout-only, and per-question
|
|
310
318
|
// only when questions.json is absent — see scoutCoverageGap().
|
|
311
319
|
if (agent === 'codex-scout') {
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Defines the inputs the orchestrator must give each Codex dispatcher.
|
|
3
|
+
*
|
|
4
|
+
* This contract belongs beside the gate because the dispatcher prompt was the only place that
|
|
5
|
+
* named order id and scope, so the caller launched codex-build without either value and the
|
|
6
|
+
* runner refused the call before Codex could do any work. Pass 2 can render the same entries
|
|
7
|
+
* into agent instructions without creating a second list that can drift.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const PLACEHOLDER_VALUES = new Set(['todo', 'tbd', 'label', 'none', 'order id', 'scope', 'xxx']);
|
|
11
|
+
|
|
12
|
+
const freezeEntries = (entries) => Object.freeze(entries.map((entry) => Object.freeze(entry)));
|
|
13
|
+
|
|
14
|
+
export const CONTINUATION_INPUT = Object.freeze({
|
|
15
|
+
label: 'continue',
|
|
16
|
+
source: 'the orchestrator',
|
|
17
|
+
explanation:
|
|
18
|
+
'The run folder this pass is ordered to continue, followed by why the orchestrator is spending another pass. A continuation is assigned by the orchestrator; after a verdict, the dispatcher returns it and stops.',
|
|
19
|
+
example: '2026-08-05_092913_plan14-build — LIMIT at step 3, tests unwritten',
|
|
20
|
+
conditional: 'when --continue is passed',
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export const REQUIRED_INPUTS = Object.freeze({
|
|
24
|
+
'codex-scout': freezeEntries([
|
|
25
|
+
{
|
|
26
|
+
label: 'order id',
|
|
27
|
+
source: 'the orchestrator',
|
|
28
|
+
explanation: 'The label this order is known by. Repeating a call with the same label joins the run already in flight and costs no quota; a different piece of work needs a new label, never a reused one.',
|
|
29
|
+
example: 'plan-13-scout-20260804',
|
|
30
|
+
},
|
|
31
|
+
CONTINUATION_INPUT,
|
|
32
|
+
]),
|
|
33
|
+
'codex-build': freezeEntries([
|
|
34
|
+
{
|
|
35
|
+
label: 'order id',
|
|
36
|
+
source: 'the orchestrator',
|
|
37
|
+
explanation: 'The label this order is known by. Repeating a call with the same label joins the run already in flight and costs no quota; a different piece of work needs a new label, never a reused one.',
|
|
38
|
+
example: 'plan-13-build-20260804',
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
label: 'scope',
|
|
42
|
+
source: 'the orchestrator',
|
|
43
|
+
explanation: 'Comma-separated globs relative to the repository root, listing every file the run may touch — including each caller of what changes, not only the file being edited. Anything outside the list fails the run.',
|
|
44
|
+
example: 'src/runner/**,tests/runner/**',
|
|
45
|
+
},
|
|
46
|
+
CONTINUATION_INPUT,
|
|
47
|
+
]),
|
|
48
|
+
'codex-review': freezeEntries([
|
|
49
|
+
{
|
|
50
|
+
label: 'order id',
|
|
51
|
+
source: 'the orchestrator',
|
|
52
|
+
explanation: 'The label this order is known by. Repeating a call with the same label joins the run already in flight and costs no quota; a different piece of work needs a new label, never a reused one.',
|
|
53
|
+
example: 'plan-13-review-20260804',
|
|
54
|
+
},
|
|
55
|
+
CONTINUATION_INPUT,
|
|
56
|
+
]),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
60
|
+
|
|
61
|
+
function cleanValue(value) {
|
|
62
|
+
if (typeof value !== 'string') return '';
|
|
63
|
+
return value.trim().replace(/^([`'\"])(.*)\1$/, '$2').trim();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function isPlaceholder(value) {
|
|
67
|
+
const cleaned = cleanValue(value);
|
|
68
|
+
if (!cleaned) return true;
|
|
69
|
+
if (cleaned.startsWith('<') && cleaned.endsWith('>')) return true;
|
|
70
|
+
return PLACEHOLDER_VALUES.has(cleaned.replace(/[.,;:]+$/, '').trim().toLowerCase());
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function extractValue(promptText, label) {
|
|
74
|
+
const labelPattern = escapeRegExp(label).replaceAll('\\ ', '\\s+');
|
|
75
|
+
const flag = `--${label.replaceAll(/\s+/g, '-')}`;
|
|
76
|
+
const direct = new RegExp(
|
|
77
|
+
`(?:^|\\r?\\n)\\s*(?:[-*]\\s*)?(?:[*_` + '`' + `]?${labelPattern}[*_` + '`' + `]?)\\s*(?::|=|—|-)\\s*([^\\r\\n]*)`,
|
|
78
|
+
'im',
|
|
79
|
+
);
|
|
80
|
+
const directMatch = promptText.match(direct);
|
|
81
|
+
if (directMatch) return cleanValue(directMatch[1]);
|
|
82
|
+
|
|
83
|
+
const flagPattern = new RegExp(
|
|
84
|
+
`${escapeRegExp(flag)}(?:=|\\s+)(?:"([^"]*)"|'([^']*)'|([^\\s\\r\\n]+))`,
|
|
85
|
+
'i',
|
|
86
|
+
);
|
|
87
|
+
const flagMatch = promptText.match(flagPattern);
|
|
88
|
+
if (!flagMatch) return null;
|
|
89
|
+
return cleanValue(flagMatch[1] ?? flagMatch[2] ?? flagMatch[3]);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Keeps the 2026-08-05 continuation incident's run and reason in the shared input parser. */
|
|
93
|
+
export function parseContinuationGrant(promptText) {
|
|
94
|
+
const prompt = typeof promptText === 'string' ? promptText : '';
|
|
95
|
+
const value = extractValue(prompt, CONTINUATION_INPUT.label);
|
|
96
|
+
if (isPlaceholder(value)) return null;
|
|
97
|
+
|
|
98
|
+
for (const separator of [/\s+—\s+/, /\s+-\s+/, /\s*:\s*/]) {
|
|
99
|
+
const match = value.match(new RegExp(`^(.+?)${separator.source}(.+)$`));
|
|
100
|
+
if (!match) continue;
|
|
101
|
+
const run = cleanValue(match[1]);
|
|
102
|
+
const reason = cleanValue(match[2]);
|
|
103
|
+
if (isPlaceholder(run) || isPlaceholder(reason)) return null;
|
|
104
|
+
return { run, reason };
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Returns the immutable input entries for one dispatcher type. */
|
|
110
|
+
export function requiredInputsFor(agentType) {
|
|
111
|
+
return REQUIRED_INPUTS[agentType] || [];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Returns required entries whose value is absent or is still an obvious template placeholder. */
|
|
115
|
+
export function missingInputs(agentType, promptText) {
|
|
116
|
+
const prompt = typeof promptText === 'string' ? promptText : '';
|
|
117
|
+
return requiredInputsFor(agentType).filter(
|
|
118
|
+
(entry) => !entry.conditional && isPlaceholder(extractValue(prompt, entry.label)),
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Renders the compact contract the orchestrator sees while choosing a dispatcher. */
|
|
123
|
+
export function renderRequiredInputSummary(agentType) {
|
|
124
|
+
const entries = requiredInputsFor(agentType);
|
|
125
|
+
if (!entries.length) return '';
|
|
126
|
+
const labels = entries.map((entry) => {
|
|
127
|
+
const label = `\`${entry.label}\``;
|
|
128
|
+
return entry.conditional ? `${label} (${entry.conditional})` : label;
|
|
129
|
+
});
|
|
130
|
+
// Commas until the last pair: the list grew to three entries when the continuation grant
|
|
131
|
+
// joined it, and "a and b and c" reads as a stutter in the description the orchestrator
|
|
132
|
+
// sees while choosing a dispatcher.
|
|
133
|
+
const rendered = labels.length > 1
|
|
134
|
+
? `${labels.slice(0, -1).join(', ')} and ${labels[labels.length - 1]}`
|
|
135
|
+
: labels[0];
|
|
136
|
+
return `Requires ${entries[0].source}-provided ${rendered}.`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Renders the same contract for pass 2 agent instructions. */
|
|
140
|
+
export function renderRequiredInputs(agentType) {
|
|
141
|
+
return requiredInputsFor(agentType)
|
|
142
|
+
.map((entry) => {
|
|
143
|
+
const condition = entry.conditional ? ` Condition: ${entry.conditional}.` : '';
|
|
144
|
+
return `- ${entry.label}: ${entry.explanation} Example: \`${entry.example}\`.${condition}`;
|
|
145
|
+
})
|
|
146
|
+
.join('\n');
|
|
147
|
+
}
|
package/src/runner/args.mjs
CHANGED
|
@@ -8,12 +8,17 @@
|
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import { AGENTS } from '../write-meta.mjs';
|
|
10
10
|
import { ALLOWED_EFFORTS } from '../run-config.mjs';
|
|
11
|
+
import { requiredInputsFor } from '../required-inputs.mjs';
|
|
11
12
|
|
|
12
13
|
export function die(message) {
|
|
13
14
|
console.error(`run-codex: ${message}`);
|
|
14
15
|
process.exit(2);
|
|
15
16
|
}
|
|
16
17
|
|
|
18
|
+
function requiredInput(agentType, label) {
|
|
19
|
+
return requiredInputsFor(agentType).find((entry) => entry.label === label);
|
|
20
|
+
}
|
|
21
|
+
|
|
17
22
|
// Flags that carry no value. A value is accepted only in its explicit yes/no spellings;
|
|
18
23
|
// anything else stops the run. The permissive reading this replaces — "not 0/false/no means
|
|
19
24
|
// yes" — turned the prompt's own placeholder text (`--continue "<only if the orchestrator provided
|
|
@@ -63,10 +68,12 @@ export function parseArgs(argv) {
|
|
|
63
68
|
if (!AGENTS[opts.agent]) die(`unknown --agent ${opts.agent}`);
|
|
64
69
|
opts.orderId = String(opts['order-id'] ?? '').trim();
|
|
65
70
|
if (!opts.orderId) {
|
|
71
|
+
const orderInput = requiredInput(opts.agent, 'order id');
|
|
66
72
|
die(
|
|
67
|
-
|
|
73
|
+
`--order-id is required: ${orderInput.source} supplies the ${orderInput.label}; the runner will not invent one. ` +
|
|
68
74
|
'A repeat of the same order should come back with --continue. If this appeared right after a package update, ' +
|
|
69
|
-
'the installed dispatcher prompts are stale and need npm run dev:install (local dev) or codex-bridge update.'
|
|
75
|
+
'the installed dispatcher prompts are stale and need npm run dev:install (local dev) or codex-bridge update. ' +
|
|
76
|
+
`Example: --order-id "${orderInput.example}". Action: get the value from ${orderInput.source}, pass it as --order-id, and retry.`,
|
|
70
77
|
);
|
|
71
78
|
}
|
|
72
79
|
if (opts.agent === 'codex-scout') {
|
|
@@ -101,10 +108,11 @@ export function parseArgs(argv) {
|
|
|
101
108
|
.map((p) => p.trim())
|
|
102
109
|
.filter(Boolean);
|
|
103
110
|
if (opts.agent === 'codex-build' && !opts.scopePatterns.length) {
|
|
111
|
+
const scopeInput = requiredInput(opts.agent, 'scope');
|
|
104
112
|
die(
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
113
|
+
`--scope is required for codex-build: ${scopeInput.source} supplies the ${scopeInput.label}. ` +
|
|
114
|
+
`${scopeInput.explanation} Example: --scope "${scopeInput.example}". ` +
|
|
115
|
+
`Action: get the value from ${scopeInput.source}, pass it as --scope, and retry.`,
|
|
108
116
|
);
|
|
109
117
|
}
|
|
110
118
|
return opts;
|
|
@@ -0,0 +1,107 @@
|
|
|
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_INPUT, parseContinuationGrant } from '../required-inputs.mjs';
|
|
10
|
+
import { readJson } from '../write-meta.mjs';
|
|
11
|
+
|
|
12
|
+
const sameRun = (runsRootPath, left, right) => {
|
|
13
|
+
const a = path.resolve(runsRootPath, left);
|
|
14
|
+
const b = path.resolve(runsRootPath, right);
|
|
15
|
+
return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function namedRunDirectory(runsRootPath, run) {
|
|
19
|
+
if (!run || run === '.' || run === '..' || path.basename(run) !== run) return null;
|
|
20
|
+
const directory = path.join(runsRootPath, run);
|
|
21
|
+
try {
|
|
22
|
+
return fs.statSync(directory).isDirectory() ? directory : null;
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function normalizeGrant(grant) {
|
|
29
|
+
if (typeof grant === 'string') return parseContinuationGrant(grant);
|
|
30
|
+
if (!grant || typeof grant !== 'object') return null;
|
|
31
|
+
const run = String(grant.run ?? '').trim();
|
|
32
|
+
const reason = String(grant.reason ?? '').trim();
|
|
33
|
+
return run && reason ? { run, reason } : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const grantExample = `Example: \`continue: ${CONTINUATION_INPUT.example}\`.`;
|
|
37
|
+
const grantAction =
|
|
38
|
+
'Action: ask the orchestrator for an explicit grant naming the run and reason; do not invent or reuse a continuation.';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Applies the one-continuation limit and all continuation safety gates before a folder is created.
|
|
42
|
+
*
|
|
43
|
+
* The limit is counted over the runs carrying THIS order id, not over the whole chain. The chain
|
|
44
|
+
* also ties runs together by slug and by the fingerprint of the task text, which is what catches a
|
|
45
|
+
* repeat that renamed itself — but counting continuations that way makes the escape hatch
|
|
46
|
+
* unreachable: the operator's rule is "more passes than one need a new order id", and a new order
|
|
47
|
+
* id lands in the same chain through the task hash. A task would then be refused with --continue
|
|
48
|
+
* for having spent its continuation and refused without it for already having runs — permanently
|
|
49
|
+
* unrunnable, which is a worse failure than the retry storm this limit exists to stop.
|
|
50
|
+
*/
|
|
51
|
+
export function continuationRefusal(runsRootPath, chain, isContinue, orderId, grant) {
|
|
52
|
+
if (!isContinue) return null;
|
|
53
|
+
|
|
54
|
+
const continuation = normalizeGrant(grant);
|
|
55
|
+
if (!continuation) {
|
|
56
|
+
return (
|
|
57
|
+
'--continue is refused: the orchestrator did not provide a `continue:` grant in the task text. ' +
|
|
58
|
+
`${grantExample} ${grantAction} The run folder was not created; quota was not spent.`
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const namedDirectory = namedRunDirectory(runsRootPath, continuation.run);
|
|
63
|
+
if (!namedDirectory) {
|
|
64
|
+
return (
|
|
65
|
+
`--continue is refused: the orchestrator named run “${continuation.run}”, but it does not ` +
|
|
66
|
+
`exist as a run folder in this project's runs directory ${runsRootPath}. ` +
|
|
67
|
+
`${grantExample} ${grantAction} The run folder was not created; quota was not spent.`
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const last = chain[chain.length - 1];
|
|
72
|
+
// Continuing the last run appends a later run, so the old grant stops matching by itself; this
|
|
73
|
+
// incident needs no counter or new state to make an orchestrator grant single-use.
|
|
74
|
+
if (!last || !sameRun(runsRootPath, continuation.run, last)) {
|
|
75
|
+
return (
|
|
76
|
+
`--continue is refused: grant ${continuation.run} is not the LAST run of this task's chain; ` +
|
|
77
|
+
`the current last run is ${last || 'none'}. A continuation is single-use: continuing the ` +
|
|
78
|
+
'last run appends a later run, so the old grant stops matching by itself — no counter or new ' +
|
|
79
|
+
`state is used. ${grantExample} ${grantAction} The run folder was not created; quota was not spent.`
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const wanted = String(orderId ?? '').trim();
|
|
84
|
+
const ofThisOrder = chain.filter(
|
|
85
|
+
(run) => String(readJson(path.join(runsRootPath, run, 'status.json'))?.order_id ?? '').trim() === wanted,
|
|
86
|
+
);
|
|
87
|
+
if (ofThisOrder.length === 0) return null;
|
|
88
|
+
if (ofThisOrder.length > 1) {
|
|
89
|
+
const spent = ofThisOrder.map((run) => path.join(runsRootPath, run)).join(', ');
|
|
90
|
+
return (
|
|
91
|
+
`--continue is refused: order “${wanted}” already spent its allowed continuation on ${spent}. ` +
|
|
92
|
+
'A further pass needs a new order id from the orchestrator. The run folder was not ' +
|
|
93
|
+
'created; quota was not spent.'
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
const previous = path.join(runsRootPath, ofThisOrder[0]);
|
|
97
|
+
const status = readJson(path.join(previous, 'status.json'));
|
|
98
|
+
const meta = readJson(path.join(previous, 'meta.json'));
|
|
99
|
+
if (!meta?.status || status?.state === 'running') {
|
|
100
|
+
return (
|
|
101
|
+
`--continue is refused: previous run ${previous} has no finished verdict and may still ` +
|
|
102
|
+
'be editing the worktree. Repeat without --continue to attach to it. The run folder was ' +
|
|
103
|
+
'not created; quota was not spent.'
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
package/src/runner/launcher.mjs
CHANGED
|
@@ -28,6 +28,8 @@ import { attach } from './attach.mjs';
|
|
|
28
28
|
import { setRun } from './run-context.mjs';
|
|
29
29
|
import { loadRunEnv, RUN_ENV } from './run-env.mjs';
|
|
30
30
|
import { parseArgs, die } from './args.mjs';
|
|
31
|
+
import { parseContinuationGrant } from '../required-inputs.mjs';
|
|
32
|
+
import { continuationRefusal } from './continuation.mjs';
|
|
31
33
|
import { SCHEMAS } from './schemas.mjs';
|
|
32
34
|
import { INSTRUCTIONS } from './prompts.mjs';
|
|
33
35
|
import { git, headSha, branchName, worktreeSnapshot, reviewScope } from './git-state.mjs';
|
|
@@ -114,6 +116,7 @@ export async function launcher() {
|
|
|
114
116
|
// like --scope.
|
|
115
117
|
const taskHash = taskFingerprint(taskText);
|
|
116
118
|
const chain = chainRuns(projectRunsRoot, repoRoot, opts.slug, taskHash, opts.orderId);
|
|
119
|
+
const continuationGrant = opts.continue ? parseContinuationGrant(taskText) : null;
|
|
117
120
|
|
|
118
121
|
// One order produced six Codex runs on 2026-08-03 because the caller's time ceiling made it
|
|
119
122
|
// restart the synchronous launcher. A live same-order run is now the repeat target: attach
|
|
@@ -132,7 +135,13 @@ export async function launcher() {
|
|
|
132
135
|
// markAbandoned() ran before this gate, so a dead runner already has meta.json with FAIL.
|
|
133
136
|
// A run without a verdict can therefore only still be in flight; ordinary repeats go through
|
|
134
137
|
// attach(), while --continue must refuse to overlap that live work.
|
|
135
|
-
const continuationError = continuationRefusal(
|
|
138
|
+
const continuationError = continuationRefusal(
|
|
139
|
+
projectRunsRoot,
|
|
140
|
+
chain,
|
|
141
|
+
opts.continue,
|
|
142
|
+
opts.orderId,
|
|
143
|
+
continuationGrant,
|
|
144
|
+
);
|
|
136
145
|
if (continuationError) die(continuationError);
|
|
137
146
|
|
|
138
147
|
if (chain.length && !opts.continue) {
|
|
@@ -183,6 +192,8 @@ export async function launcher() {
|
|
|
183
192
|
// Which run of this task started the chain — the base every later pass is measured
|
|
184
193
|
// against. Absent means this is the first pass.
|
|
185
194
|
...(chain.length ? { continues: chain[0] } : {}),
|
|
195
|
+
// `continued_from` is the exact run the orchestrator named; `continues` above remains the chain base.
|
|
196
|
+
...(continuationGrant ? { continued_from: continuationGrant.run } : {}),
|
|
186
197
|
});
|
|
187
198
|
|
|
188
199
|
// Printed before anything can go wrong: even a dispatcher that dies mid-run leaves the
|
|
@@ -333,42 +344,3 @@ export async function launcher() {
|
|
|
333
344
|
);
|
|
334
345
|
process.exit(0);
|
|
335
346
|
}
|
|
336
|
-
|
|
337
|
-
/**
|
|
338
|
-
* Applies the one-continuation limit and the in-flight safety gate before a folder is created.
|
|
339
|
-
*
|
|
340
|
-
* The limit is counted over the runs carrying THIS order id, not over the whole chain. The chain
|
|
341
|
-
* also ties runs together by slug and by the fingerprint of the task text, which is what catches a
|
|
342
|
-
* repeat that renamed itself — but counting continuations that way makes the escape hatch
|
|
343
|
-
* unreachable: the operator's rule is "more passes than one need a new order id", and a new order
|
|
344
|
-
* id lands in the same chain through the task hash. A task would then be refused with --continue
|
|
345
|
-
* for having spent its continuation and refused without it for already having runs — permanently
|
|
346
|
-
* unrunnable, which is a worse failure than the retry storm this limit exists to stop.
|
|
347
|
-
*/
|
|
348
|
-
export function continuationRefusal(runsRootPath, chain, isContinue, orderId) {
|
|
349
|
-
if (!isContinue || chain.length === 0) return null;
|
|
350
|
-
const wanted = String(orderId ?? '').trim();
|
|
351
|
-
const ofThisOrder = chain.filter(
|
|
352
|
-
(run) => String(readJson(path.join(runsRootPath, run, 'status.json'))?.order_id ?? '').trim() === wanted,
|
|
353
|
-
);
|
|
354
|
-
if (ofThisOrder.length === 0) return null;
|
|
355
|
-
if (ofThisOrder.length > 1) {
|
|
356
|
-
const spent = ofThisOrder.map((run) => path.join(runsRootPath, run)).join(', ');
|
|
357
|
-
return (
|
|
358
|
-
`--continue is refused: order “${wanted}” already spent its allowed continuation on ${spent}. ` +
|
|
359
|
-
'A further pass needs a new order id from the orchestrator. The run folder was not ' +
|
|
360
|
-
'created; quota was not spent.'
|
|
361
|
-
);
|
|
362
|
-
}
|
|
363
|
-
const previous = path.join(runsRootPath, ofThisOrder[0]);
|
|
364
|
-
const status = readJson(path.join(previous, 'status.json'));
|
|
365
|
-
const meta = readJson(path.join(previous, 'meta.json'));
|
|
366
|
-
if (!meta?.status || status?.state === 'running') {
|
|
367
|
-
return (
|
|
368
|
-
`--continue is refused: previous run ${previous} has no finished verdict and may still ` +
|
|
369
|
-
'be editing the worktree. Repeat without --continue to attach to it. The run folder was ' +
|
|
370
|
-
'not created; quota was not spent.'
|
|
371
|
-
);
|
|
372
|
-
}
|
|
373
|
-
return null;
|
|
374
|
-
}
|
package/src/runner/prompts.mjs
CHANGED
|
@@ -52,6 +52,15 @@ Working rules:
|
|
|
52
52
|
cannot be done, do not pretend it is done; list it in leftovers.
|
|
53
53
|
- After making changes, run verification: ${opts.verify || 'determine the command from the project'}.
|
|
54
54
|
Put the command actually run in verify_command and whether it passed in verify_passed.
|
|
55
|
+
- Declare the outcome in outcome: "done" if the work the task asked for was carried out,
|
|
56
|
+
"fail" if it was not — for any reason at all, including an impossible order, a file that
|
|
57
|
+
does not exist, a scope that forbids the only fix, or running out of time. "done" with an
|
|
58
|
+
empty changes[] is correct only when the task asked you to check something and nothing
|
|
59
|
+
needed changing; if you could not do what was asked, "fail" is the answer even when the
|
|
60
|
+
worktree is clean and verification is green. This is not a self-grade: work that was done
|
|
61
|
+
and merely leaves follow-ups is "done" with the follow-ups in leftovers, and partial work
|
|
62
|
+
that satisfies the definition of done is "done" too. On "fail" the first sentence of summary
|
|
63
|
+
states the reason — it is what the orchestrator is shown.
|
|
55
64
|
- summary and changes describe only changes in this repository for this task. Do not include
|
|
56
65
|
anything unrelated in them.
|
|
57
66
|
- Each change is a separate changes[] entry, exactly one file per entry. The file field contains
|
package/src/runner/schemas.mjs
CHANGED
|
@@ -51,8 +51,30 @@ const BUILD_SCHEMA = {
|
|
|
51
51
|
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
|
52
52
|
type: 'object',
|
|
53
53
|
additionalProperties: false,
|
|
54
|
-
required: [
|
|
54
|
+
required: [
|
|
55
|
+
'outcome',
|
|
56
|
+
'summary',
|
|
57
|
+
'changes',
|
|
58
|
+
'verify_command',
|
|
59
|
+
'verify_passed',
|
|
60
|
+
'leftovers',
|
|
61
|
+
'report_markdown',
|
|
62
|
+
],
|
|
55
63
|
properties: {
|
|
64
|
+
// The outcome is declared, never inferred. On 2026-08-04 a build asked to fix a function
|
|
65
|
+
// in a module that does not exist answered `OK — no code change was made`: every artifact
|
|
66
|
+
// was well-formed, the tree was legitimately clean, and the runner has no way to know
|
|
67
|
+
// whether the order required an edit. A string rather than a boolean because the set of
|
|
68
|
+
// outcomes will grow (`blocked` is a candidate) and an enum extends without breaking the
|
|
69
|
+
// contract. Presence of this field in the run's own schema.json is also the marker that
|
|
70
|
+
// says the run was contracted to declare an outcome — see meta/outcome.mjs.
|
|
71
|
+
outcome: {
|
|
72
|
+
type: 'string',
|
|
73
|
+
enum: ['done', 'fail'],
|
|
74
|
+
description:
|
|
75
|
+
'done — the work the task asked for was carried out. fail — it was not, for any ' +
|
|
76
|
+
'reason at all (impossible order, missing file, blocked by scope, out of time).',
|
|
77
|
+
},
|
|
56
78
|
summary: { type: 'string', minLength: 1 },
|
|
57
79
|
changes: {
|
|
58
80
|
type: 'array',
|