@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.
- package/CHANGELOG.md +51 -0
- package/README.md +1 -1
- package/cli/lifecycle-lock-inspect.mjs +3 -2
- package/cli/lifecycle-lock.mjs +19 -132
- package/cli/read.mjs +20 -0
- package/cli/settings-merge.mjs +3 -29
- package/package.json +2 -2
- package/scripts/home-write-inventory.mjs +12 -2
- package/src/agents/codex-advisor.md +22 -12
- package/src/agents/codex-build.md +24 -14
- package/src/agents/codex-review.md +17 -10
- package/src/agents/codex-scout.md +22 -12
- package/src/home/hooks/order-gate.mjs +25 -11
- package/src/home/hooks/reply-parser.mjs +1 -1
- package/src/home/lib/command-path.mjs +32 -0
- package/src/home/lib/dispatcher-command.mjs +4 -2
- package/src/home/lib/kernel-lock.mjs +171 -0
- package/src/home/lib/meta/chain.mjs +19 -8
- package/src/home/lib/meta/context-only.mjs +43 -0
- package/src/home/lib/meta/events.mjs +14 -2
- package/src/home/lib/meta/launch-rows.mjs +23 -0
- package/src/home/lib/meta/reply.mjs +37 -3
- package/src/home/lib/meta/run-state.mjs +3 -0
- package/src/home/lib/meta/verdict.mjs +16 -8
- package/src/home/lib/required-inputs.mjs +48 -8
- package/src/home/lib/run-codex.mjs +6 -3
- package/src/home/lib/runner/advise-carry.mjs +2 -1
- package/src/home/lib/runner/args.mjs +16 -5
- package/src/home/lib/runner/attach.mjs +21 -14
- package/src/home/lib/runner/build-evidence.mjs +29 -0
- package/src/home/lib/runner/codex-availability.mjs +50 -0
- package/src/home/lib/runner/codex-cmd.mjs +2 -19
- package/src/home/lib/runner/continuation.mjs +60 -8
- package/src/home/lib/runner/exit-codes.mjs +13 -0
- package/src/home/lib/runner/flag-baseline.mjs +105 -0
- package/src/home/lib/runner/flag-scan.mjs +165 -0
- package/src/home/lib/runner/git-state.mjs +0 -33
- package/src/home/lib/runner/launcher.mjs +233 -273
- package/src/home/lib/runner/order-claim.mjs +84 -0
- package/src/home/lib/runner/order-owner.mjs +18 -0
- package/src/home/lib/runner/pass-gate.mjs +109 -0
- package/src/home/lib/runner/preflight.mjs +22 -25
- package/src/home/lib/runner/prompts.mjs +7 -2
- package/src/home/lib/runner/question-kind.mjs +41 -0
- package/src/home/lib/runner/run-env.mjs +2 -1
- package/src/home/lib/runner/sandbox-probe.mjs +1 -1
- package/src/home/lib/runner/scope-check.mjs +29 -11
- package/src/home/lib/runner/task-input.mjs +11 -2
- package/src/home/lib/runner/worker-admission.mjs +43 -0
- package/src/home/lib/runner/worker.mjs +19 -18
- package/src/home/lib/task-header.mjs +127 -0
- package/src/home/lib/write-meta.mjs +17 -5
|
@@ -28,6 +28,7 @@ import { startupGap } from './startup.mjs';
|
|
|
28
28
|
import { transportGap } from './transport.mjs';
|
|
29
29
|
import { reasonFrom } from './reason.mjs';
|
|
30
30
|
import { adviceGap } from './advice-status.mjs';
|
|
31
|
+
import { allStartupContext, contextOnlyGap } from './context-only.mjs';
|
|
31
32
|
import { compareSnapshots } from './snapshot-format.mjs';
|
|
32
33
|
import { readSnapshot, runSnapshotChanges, snapshotRefusal } from './run-snapshots.mjs';
|
|
33
34
|
|
|
@@ -156,7 +157,7 @@ function substanceLength(text) {
|
|
|
156
157
|
|
|
157
158
|
const questionsOf = (runDir) => {
|
|
158
159
|
const parsed = readJson(path.join(runDir, 'questions.json'));
|
|
159
|
-
return Array.isArray(parsed) ? parsed
|
|
160
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
160
161
|
};
|
|
161
162
|
|
|
162
163
|
/** Answers indexed by question id, first one wins; ids come back in whatever case Codex used. */
|
|
@@ -175,8 +176,8 @@ const answersById = (result) => {
|
|
|
175
176
|
* single-answer check. Every branch here exists because a schema alone accepted the run that
|
|
176
177
|
* answered in coordinates.
|
|
177
178
|
*/
|
|
178
|
-
function scoutCoverageGap(
|
|
179
|
-
const questions =
|
|
179
|
+
function scoutCoverageGap(orderedQuestions, result) {
|
|
180
|
+
const questions = orderedQuestions.filter((q) => q && q.id);
|
|
180
181
|
const show = (list) => list.slice(0, 8).join(', ');
|
|
181
182
|
|
|
182
183
|
if (!questions.length) {
|
|
@@ -211,7 +212,7 @@ function scoutCoverageGap(runDir, result) {
|
|
|
211
212
|
|
|
212
213
|
/** `1/1 sub-questions`, or null for legacy runs with no questions.json artifact. */
|
|
213
214
|
export function scoutCoverage(runDir, result) {
|
|
214
|
-
const questions = questionsOf(runDir);
|
|
215
|
+
const questions = questionsOf(runDir).filter((q) => q && q.id);
|
|
215
216
|
if (!questions.length) return null;
|
|
216
217
|
const byId = answersById(result);
|
|
217
218
|
const answered = questions.filter((q) => String(byId.get(q.id)?.answer || '').trim()).length;
|
|
@@ -288,9 +289,10 @@ export function resolveStatus({ resultOk, exit, agent, result, runDir, events })
|
|
|
288
289
|
// This branch already knows why the run died: the CLI said so itself, with a status. Letting
|
|
289
290
|
// the ordered reason speak here would answer a transport question with the model's older
|
|
290
291
|
// complaint about the task — a LIMIT that never mentions quota is the same "verdict says the
|
|
291
|
-
// wrong thing" defect this module exists to remove.
|
|
292
|
+
// wrong thing" defect this module exists to remove. Plan_60 D2: UNAVAILABLE only on a measured
|
|
293
|
+
// signed-out sample (events.mjs); an unfamiliar failure stays FAIL with its raw CLI text.
|
|
292
294
|
return {
|
|
293
|
-
status: transportError.quota ? 'LIMIT' : 'FAIL',
|
|
295
|
+
status: transportError.quota ? 'LIMIT' : transportError.unavailable ? 'UNAVAILABLE' : 'FAIL',
|
|
294
296
|
reason: transportError.reason,
|
|
295
297
|
};
|
|
296
298
|
}
|
|
@@ -318,19 +320,25 @@ export function resolveStatus({ resultOk, exit, agent, result, runDir, events })
|
|
|
318
320
|
// Answers that never arrived, or arrived as coordinates. Scout-only, and per-question
|
|
319
321
|
// only when questions.json is absent — see scoutCoverageGap().
|
|
320
322
|
if (agent === 'codex-scout') {
|
|
323
|
+
const questions = questionsOf(runDir);
|
|
321
324
|
// The 2026-09-16 dead sandbox produced nonempty evidence without reading code. Require an
|
|
322
325
|
// execution fact first: checking whether evidence paths existed falsely rejected 64/151 runs.
|
|
323
326
|
// Only a run that left a stream can prove it: an archived run from before `--json` has none and
|
|
324
327
|
// is judged by the contract of its day; a `--json` run missing its stream already failed above.
|
|
325
|
-
|
|
328
|
+
// Plan_60 D1 exempts only orders whose every question asks about handed startup context.
|
|
329
|
+
if (eventData.hasStream && eventData.commands_executed === 0 && !allStartupContext(questions)) {
|
|
326
330
|
return {
|
|
327
331
|
status: 'FAIL',
|
|
328
332
|
reason: 'scout executed no command, so no answer rests on reading the code; '
|
|
329
333
|
+ 'check stderr.log for sandbox refusals before changing the order',
|
|
330
334
|
};
|
|
331
335
|
}
|
|
332
|
-
const gap = scoutCoverageGap(
|
|
336
|
+
const gap = scoutCoverageGap(questions, result);
|
|
333
337
|
if (gap) return { status: 'FAIL', reason: gap };
|
|
338
|
+
const contextGap = contextOnlyGap({
|
|
339
|
+
questions, result, commandsExecuted: eventData.hasStream ? eventData.commands_executed : null,
|
|
340
|
+
});
|
|
341
|
+
if (contextGap) return { status: 'FAIL', reason: contextGap };
|
|
334
342
|
}
|
|
335
343
|
// Plan_59 D3/D4/D5/D10: a schema-valid agreement still needs evidence and decision checks.
|
|
336
344
|
// Place the advisor judge before the build-only scope check, alongside the other agent contracts.
|
|
@@ -23,6 +23,15 @@ export const CONTINUATION_INPUT = Object.freeze({
|
|
|
23
23
|
conditional: 'when --continue is passed',
|
|
24
24
|
});
|
|
25
25
|
|
|
26
|
+
export const RETRY_INPUT = Object.freeze({
|
|
27
|
+
label: 'retry',
|
|
28
|
+
source: 'the orchestrator',
|
|
29
|
+
explanation:
|
|
30
|
+
'The failed run this pass repeats, followed by why the orchestrator pays for the same pass again. A retry repeats that failed pass under its own order; it does not authorize the next pass.',
|
|
31
|
+
example: '2026-10-03_172017_cc-d66-advisor — model at capacity, same pass again',
|
|
32
|
+
conditional: 'when --continue is passed',
|
|
33
|
+
});
|
|
34
|
+
|
|
26
35
|
/**
|
|
27
36
|
* The task statement reaches the runner as a file because every other channel puts the invocation
|
|
28
37
|
* back into the multi-line form that no permission rule can cover — a heredoc on stdin, or a
|
|
@@ -53,6 +62,7 @@ export const REQUIRED_INPUTS = Object.freeze({
|
|
|
53
62
|
},
|
|
54
63
|
TASK_FILE_INPUT,
|
|
55
64
|
CONTINUATION_INPUT,
|
|
65
|
+
RETRY_INPUT,
|
|
56
66
|
]),
|
|
57
67
|
'codex-build': freezeEntries([
|
|
58
68
|
{
|
|
@@ -69,6 +79,7 @@ export const REQUIRED_INPUTS = Object.freeze({
|
|
|
69
79
|
},
|
|
70
80
|
TASK_FILE_INPUT,
|
|
71
81
|
CONTINUATION_INPUT,
|
|
82
|
+
RETRY_INPUT,
|
|
72
83
|
]),
|
|
73
84
|
'codex-review': freezeEntries([
|
|
74
85
|
{
|
|
@@ -79,6 +90,7 @@ export const REQUIRED_INPUTS = Object.freeze({
|
|
|
79
90
|
},
|
|
80
91
|
TASK_FILE_INPUT,
|
|
81
92
|
CONTINUATION_INPUT,
|
|
93
|
+
RETRY_INPUT,
|
|
82
94
|
]),
|
|
83
95
|
// Plan_59 D7: the caller must name the phase and authorize the continued decision pass.
|
|
84
96
|
'codex-advisor': freezeEntries([
|
|
@@ -90,6 +102,7 @@ export const REQUIRED_INPUTS = Object.freeze({
|
|
|
90
102
|
},
|
|
91
103
|
TASK_FILE_INPUT,
|
|
92
104
|
CONTINUATION_INPUT,
|
|
105
|
+
RETRY_INPUT,
|
|
93
106
|
{
|
|
94
107
|
label: 'phase',
|
|
95
108
|
source: 'the orchestrator',
|
|
@@ -122,13 +135,15 @@ export function isPlaceholder(value) {
|
|
|
122
135
|
export const isInputPlaceholder = (value, label) =>
|
|
123
136
|
!(label === 'phase' && cleanValue(value) === 'scope') && isPlaceholder(value);
|
|
124
137
|
|
|
125
|
-
|
|
138
|
+
// Plan_75 D5: dispatcher prompts still need one spelling of a labelled line for reading values.
|
|
139
|
+
const labelledLine = (label) => {
|
|
126
140
|
const labelPattern = escapeRegExp(label).replaceAll('\\ ', '\\s+');
|
|
141
|
+
return `(?:[-*]\\s*)?(?:[*_` + '`' + `]?${labelPattern}[*_` + '`' + `]?)\\s*(?::|=|—|-)`;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
export function extractValue(promptText, label) {
|
|
127
145
|
const flag = `--${label.replaceAll(/\s+/g, '-')}`;
|
|
128
|
-
const direct = new RegExp(
|
|
129
|
-
`(?:^|\\r?\\n)\\s*(?:[-*]\\s*)?(?:[*_` + '`' + `]?${labelPattern}[*_` + '`' + `]?)\\s*(?::|=|—|-)\\s*([^\\r\\n]*)`,
|
|
130
|
-
'im',
|
|
131
|
-
);
|
|
146
|
+
const direct = new RegExp(`(?:^|\\r?\\n)\\s*${labelledLine(label)}\\s*([^\\r\\n]*)`, 'im');
|
|
132
147
|
const directMatch = promptText.match(direct);
|
|
133
148
|
if (directMatch) return cleanValue(directMatch[1]);
|
|
134
149
|
|
|
@@ -188,10 +203,15 @@ export function diagnoseInput(promptText, label) {
|
|
|
188
203
|
return { line, reason: `expected \`${label}: value\` with a separator immediately after the label` };
|
|
189
204
|
}
|
|
190
205
|
|
|
191
|
-
|
|
192
|
-
|
|
206
|
+
// Plan_75 D1, TradeForge capacity incident (2026-10-03): both grants need the same run/reason grammar.
|
|
207
|
+
function parseLabelledGrant(promptText, label) {
|
|
193
208
|
const prompt = typeof promptText === 'string' ? promptText : '';
|
|
194
|
-
const value = extractValue(prompt,
|
|
209
|
+
const value = extractValue(prompt, label);
|
|
210
|
+
return splitGrantValue(value);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Plan_75 D5, 2026-10-03 20:42: header parsing must reuse the existing grant value grammar.
|
|
214
|
+
export function splitGrantValue(value) {
|
|
195
215
|
if (isPlaceholder(value)) return null;
|
|
196
216
|
|
|
197
217
|
for (const separator of [/\s+—\s+/, /\s+-\s+/, /\s*:\s*/]) {
|
|
@@ -205,6 +225,26 @@ export function parseContinuationGrant(promptText) {
|
|
|
205
225
|
return null;
|
|
206
226
|
}
|
|
207
227
|
|
|
228
|
+
/** Keeps the 2026-08-05 continuation incident's run and reason in the shared input parser. */
|
|
229
|
+
export function parseContinuationGrant(promptText) {
|
|
230
|
+
return parseLabelledGrant(promptText, CONTINUATION_INPUT.label);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function parseRetryGrant(promptText) {
|
|
234
|
+
return parseLabelledGrant(promptText, RETRY_INPUT.label);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function parseGrant(promptText) {
|
|
238
|
+
const continuation = parseContinuationGrant(promptText);
|
|
239
|
+
const retry = parseRetryGrant(promptText);
|
|
240
|
+
if (continuation && retry) {
|
|
241
|
+
return { error: 'exactly one grant per task: remove either the continue: or the retry: line' };
|
|
242
|
+
}
|
|
243
|
+
if (continuation) return { kind: 'continue', ...continuation };
|
|
244
|
+
if (retry) return { kind: 'retry', ...retry };
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
|
|
208
248
|
/** Returns the immutable input entries for one dispatcher type. */
|
|
209
249
|
export function requiredInputsFor(agentType) {
|
|
210
250
|
return REQUIRED_INPUTS[agentType] || [];
|
|
@@ -54,6 +54,7 @@ import { setWorkerDir, getRun, emitReply } from './runner/run-context.mjs';
|
|
|
54
54
|
import { launcher } from './runner/launcher.mjs';
|
|
55
55
|
import { worker } from './runner/worker.mjs';
|
|
56
56
|
import { RunnerUsageError } from './runner/args.mjs';
|
|
57
|
+
import { EXIT } from './runner/exit-codes.mjs';
|
|
57
58
|
|
|
58
59
|
export { parseArgs } from './runner/args.mjs';
|
|
59
60
|
export { runsPrefixInside, worktreeSnapshot } from './runner/git-state.mjs';
|
|
@@ -88,18 +89,20 @@ export function installRunnerCrashBoundary() {
|
|
|
88
89
|
`Log: codex-bridge read ${currentRun}`,
|
|
89
90
|
]);
|
|
90
91
|
emitReply(reply);
|
|
91
|
-
process.exit(
|
|
92
|
+
process.exit(EXIT.FAIL);
|
|
92
93
|
} catch {
|
|
93
94
|
// Falls through to the bare reply below: the disk itself is not cooperating.
|
|
94
95
|
}
|
|
95
96
|
}
|
|
96
97
|
emitReply(
|
|
97
98
|
[
|
|
98
|
-
`FAIL — Codex runner crashed before creating the run folder: ${
|
|
99
|
+
`FAIL — Codex runner crashed before creating the run folder: ${
|
|
100
|
+
String(err.message).replace(/\s+/g, ' ').slice(0, 150)
|
|
101
|
+
}`,
|
|
99
102
|
'No artifacts; nothing to inspect',
|
|
100
103
|
].join('\n'),
|
|
101
104
|
);
|
|
102
|
-
process.exit(
|
|
105
|
+
process.exit(EXIT.FAIL);
|
|
103
106
|
});
|
|
104
107
|
}
|
|
105
108
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
/** Owns the scope snapshot shared by advise readers because run 2026-09-23_141341_plan59-a1-stdin-hang left them with different inputs. */
|
|
2
|
+
import { EXIT } from './exit-codes.mjs';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import { readJsonFileSync } from '../json-file.mjs';
|
|
4
5
|
import { parseAdvisorTask } from '../meta/advisor-task.mjs';
|
|
@@ -32,7 +33,7 @@ export function advisorTaskOrRefuse(input) {
|
|
|
32
33
|
try {
|
|
33
34
|
return advisorTaskArtifact(input);
|
|
34
35
|
} catch (error) {
|
|
35
|
-
return die(`${error.message} The run folder was not created; quota was not spent.`,
|
|
36
|
+
return die(`${error.message} The run folder was not created; quota was not spent.`, EXIT.FAIL);
|
|
36
37
|
}
|
|
37
38
|
}
|
|
38
39
|
|
|
@@ -5,24 +5,26 @@
|
|
|
5
5
|
* else's quota is touched, and every refusal is an exit code rather than a message: a
|
|
6
6
|
* dispatcher branches on it.
|
|
7
7
|
*/
|
|
8
|
+
import { EXIT } from './exit-codes.mjs';
|
|
8
9
|
import fs from 'node:fs';
|
|
9
10
|
import path from 'node:path';
|
|
10
11
|
import { AGENTS } from '../write-meta.mjs';
|
|
11
12
|
import { isAbsoluteTaskFilePath, requiredInputsFor } from '../required-inputs.mjs';
|
|
12
13
|
import { firstShellUnsafeSequence } from '../shell-unsafe.mjs';
|
|
13
14
|
import { parseTaskDocument } from './task-file.mjs';
|
|
15
|
+
import { parseTaskHeader, taskHeaderRefusal } from '../task-header.mjs';
|
|
14
16
|
|
|
15
17
|
export class RunnerUsageError extends Error {
|
|
16
18
|
// 2 says the order itself is wrong and has to be rewritten. A refusal about the state of the
|
|
17
19
|
// host or the worktree passes 1: the order was fine, nothing about it needs changing, and
|
|
18
20
|
// Plan_58 moved two such refusals here from a code path that already answered 1.
|
|
19
|
-
constructor(message, exitCode =
|
|
21
|
+
constructor(message, exitCode = EXIT.USAGE) {
|
|
20
22
|
super(message);
|
|
21
23
|
this.exitCode = exitCode;
|
|
22
24
|
}
|
|
23
25
|
}
|
|
24
26
|
|
|
25
|
-
export function die(message, exitCode =
|
|
27
|
+
export function die(message, exitCode = EXIT.USAGE) {
|
|
26
28
|
console.error(`run-codex: ${message}`);
|
|
27
29
|
throw new RunnerUsageError(message, exitCode);
|
|
28
30
|
}
|
|
@@ -46,9 +48,14 @@ export function readTaskDocument(opts) {
|
|
|
46
48
|
die(`task file from --task-file could not be read: ${err.message}`);
|
|
47
49
|
}
|
|
48
50
|
if (!fileText.trim()) die(`task file from --task-file is empty: ${taskFile}`);
|
|
51
|
+
// Plan_75 D5, 2026-10-03 20:42: validate raw metadata before sections can hide a grant.
|
|
52
|
+
const parsed = parseTaskHeader(fileText);
|
|
53
|
+
const refusal = taskHeaderRefusal(parsed);
|
|
54
|
+
if (refusal) die(`${taskFile}: ${refusal}`);
|
|
55
|
+
if (!parsed.body.trim()) die(`task file from --task-file is empty: ${taskFile}`);
|
|
49
56
|
let document;
|
|
50
57
|
try {
|
|
51
|
-
document = parseTaskDocument(
|
|
58
|
+
document = parseTaskDocument(parsed.body);
|
|
52
59
|
} catch (err) {
|
|
53
60
|
die(`${taskFile}: ${err.message}`);
|
|
54
61
|
}
|
|
@@ -58,11 +65,15 @@ export function readTaskDocument(opts) {
|
|
|
58
65
|
if (document.verify !== undefined && opts.verify !== undefined) {
|
|
59
66
|
die('verification command was supplied through both --verify and the task file; choose exactly one channel');
|
|
60
67
|
}
|
|
61
|
-
return document;
|
|
68
|
+
return { ...document, header: parsed };
|
|
62
69
|
}
|
|
63
70
|
if (!stdinText.trim()) die('task text on stdin is empty');
|
|
71
|
+
const parsed = parseTaskHeader(stdinText);
|
|
72
|
+
const refusal = taskHeaderRefusal(parsed);
|
|
73
|
+
if (refusal) die(refusal);
|
|
74
|
+
if (!parsed.body.trim()) die('task text on stdin is empty');
|
|
64
75
|
try {
|
|
65
|
-
return parseTaskDocument(
|
|
76
|
+
return { ...parseTaskDocument(parsed.body), header: parsed };
|
|
66
77
|
} catch (err) {
|
|
67
78
|
die(err.message);
|
|
68
79
|
}
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
/** Finds a live run for an order and waits for its verdict when a repeated launcher call attaches. */
|
|
2
|
+
import { EXIT } from './exit-codes.mjs';
|
|
2
3
|
import fs from 'node:fs';
|
|
3
4
|
import path from 'node:path';
|
|
4
5
|
import { readJsonFileSync } from '../json-file.mjs';
|
|
5
6
|
import { workerMayBeAlive } from '../meta/run-liveness.mjs';
|
|
6
7
|
import { processAlive } from '../process-identity.mjs';
|
|
7
8
|
import { exitCodeFor, writeFailure, chainRuns } from '../write-meta.mjs';
|
|
8
|
-
import { sameRun } from './continuation.mjs';
|
|
9
|
-
import { conflictingOrderOwner, runsForOrder } from './order-owner.mjs';
|
|
9
|
+
import { readyGrantLines, sameRun } from './continuation.mjs';
|
|
10
|
+
import { conflictingOrderOwner, orderOwnerConflictText, runsForOrder } from './order-owner.mjs';
|
|
10
11
|
|
|
11
12
|
// The attach call is the only process that waits for a worker it did not spawn. Keeping that
|
|
12
13
|
// wait here leaves the launcher free to return before a caller's time ceiling kills it.
|
|
@@ -97,26 +98,26 @@ export async function waitForReply(runDir, workerPid, status) {
|
|
|
97
98
|
* reason. `--continue` may attach to a run created from its grant: the 2026-09-23 dispatcher
|
|
98
99
|
* incident showed that repeating the identical continuation command must return to that run.
|
|
99
100
|
*/
|
|
100
|
-
export async function attach({
|
|
101
|
+
export async function attach({
|
|
102
|
+
runsRoot, repo, slug, taskHash, orderId, chain, grantRun, retryRun, isContinue, noWait, beforeWait,
|
|
103
|
+
} = {}) {
|
|
101
104
|
const runs = chain || chainRuns(runsRoot, repo, slug, taskHash, orderId);
|
|
102
105
|
const runRecords = runs
|
|
103
106
|
.map((run) => ({ run, status: readJsonFile(path.join(runsRoot, run, 'status.json')) }))
|
|
104
107
|
.filter(({ status }) => status);
|
|
105
108
|
const sameOrder = runsForOrder(runRecords, orderId);
|
|
106
109
|
const attachRuns = isContinue
|
|
107
|
-
? sameOrder.filter(({ status }) =>
|
|
110
|
+
? sameOrder.filter(({ status }) => retryRun
|
|
111
|
+
? status.retry_of && sameRun(runsRoot, status.retry_of, retryRun)
|
|
112
|
+
: grantRun && status.continued_from && sameRun(runsRoot, status.continued_from, grantRun))
|
|
108
113
|
: sameOrder;
|
|
109
114
|
if (isContinue && !attachRuns.length) return null;
|
|
110
115
|
|
|
111
116
|
const owner = isContinue ? null : conflictingOrderOwner(runRecords, orderId, taskHash);
|
|
112
117
|
if (owner) {
|
|
113
118
|
const ownerDir = path.join(runsRoot, owner.run);
|
|
114
|
-
console.log(
|
|
115
|
-
|
|
116
|
-
`(slug ${owner.status.slug}, started_at ${owner.status.started_at}) with a different task. ` +
|
|
117
|
-
'Pass a new --order-id, or pass --continue if this really is another pass of the same order.',
|
|
118
|
-
);
|
|
119
|
-
return 2;
|
|
119
|
+
console.log(orderOwnerConflictText(owner, ownerDir, orderId));
|
|
120
|
+
return EXIT.USAGE;
|
|
120
121
|
}
|
|
121
122
|
|
|
122
123
|
// The run an order is currently about is its newest one, and the chain arrives oldest first.
|
|
@@ -130,7 +131,12 @@ export async function attach({ runsRoot, repo, slug, taskHash, orderId, chain, g
|
|
|
130
131
|
if (fs.existsSync(path.join(dir, 'reply.txt'))) {
|
|
131
132
|
announceSavedReply(dir, orderId, entry.status.started_at);
|
|
132
133
|
console.log(fs.readFileSync(path.join(dir, 'reply.txt'), 'utf8').replace(/\s+$/, ''));
|
|
133
|
-
|
|
134
|
+
const meta = readJsonFile(path.join(dir, 'meta.json'));
|
|
135
|
+
// Plan_75 D1, 2026-10-03 TradeForge: a cached failure must name its current repair.
|
|
136
|
+
if (meta?.status !== 'OK' && entry.run === runs.at(-1)) {
|
|
137
|
+
console.log(readyGrantLines(runsRoot, runs));
|
|
138
|
+
}
|
|
139
|
+
return exitCodeFor(meta?.status);
|
|
134
140
|
}
|
|
135
141
|
if (workerMayBeAlive({ runDir: dir, status: entry.status })) {
|
|
136
142
|
candidate = entry;
|
|
@@ -140,7 +146,7 @@ export async function attach({ runsRoot, repo, slug, taskHash, orderId, chain, g
|
|
|
140
146
|
if (!candidate) {
|
|
141
147
|
if (!noWait) return null;
|
|
142
148
|
console.log(`No run exists for order id ${JSON.stringify(String(orderId ?? ''))}; --no-wait never starts a new run.`);
|
|
143
|
-
return
|
|
149
|
+
return EXIT.PENDING;
|
|
144
150
|
}
|
|
145
151
|
|
|
146
152
|
const runDir = path.join(runsRoot, candidate.run);
|
|
@@ -148,8 +154,9 @@ export async function attach({ runsRoot, repo, slug, taskHash, orderId, chain, g
|
|
|
148
154
|
// A distinct call outcome lets the dispatcher inspect disk without mistaking pending for failure.
|
|
149
155
|
if (noWait) {
|
|
150
156
|
announcePending(runDir, orderId, candidate.status.started_at);
|
|
151
|
-
return
|
|
157
|
+
return EXIT.PENDING;
|
|
152
158
|
}
|
|
159
|
+
await beforeWait?.();
|
|
153
160
|
announceLiveAttach(runDir, orderId, candidate.status.started_at);
|
|
154
161
|
const replyText = await waitForReply(runDir, candidate.status.pid, candidate.status);
|
|
155
162
|
if (replyText !== null) {
|
|
@@ -174,5 +181,5 @@ export async function attach({ runsRoot, repo, slug, taskHash, orderId, chain, g
|
|
|
174
181
|
'Any Codex changes remain in the tree — check them with git status',
|
|
175
182
|
]);
|
|
176
183
|
console.log(reply);
|
|
177
|
-
return
|
|
184
|
+
return EXIT.FAIL;
|
|
178
185
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Writes the evidence a build run leaves of the tree before and after it.
|
|
3
|
+
* The before half was written by the launcher and the after half by the worker, so one artifact pair was
|
|
4
|
+
* named in two files; Plan_60 D3a adds a content baseline and its comparison to both halves.
|
|
5
|
+
*/
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { git, headSha, branchName, worktreeSnapshot } from './git-state.mjs';
|
|
9
|
+
import { captureFlagBaseline } from './flag-baseline.mjs';
|
|
10
|
+
import { scanRunFlags } from './flag-scan.mjs';
|
|
11
|
+
|
|
12
|
+
export function writeBuildBefore({ runDir, repoRoot, isGitRepo }) {
|
|
13
|
+
fs.writeFileSync(path.join(runDir, 'head-before.txt'), `${isGitRepo ? headSha(repoRoot) : ''}\n`);
|
|
14
|
+
fs.writeFileSync(path.join(runDir, 'branch-before.txt'), `${isGitRepo ? branchName(repoRoot) : ''}\n`);
|
|
15
|
+
fs.writeFileSync(path.join(runDir, 'git-before.txt'), git(repoRoot, ['-c', 'core.quotepath=false', 'status', '--porcelain']).stdout || '');
|
|
16
|
+
fs.writeFileSync(path.join(runDir, 'state-before.txt'), worktreeSnapshot(repoRoot));
|
|
17
|
+
captureFlagBaseline({ runDir, repoRoot, isGitRepo });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function writeBuildAfter({ runDir, repoRoot, isGitRepo }) {
|
|
21
|
+
fs.writeFileSync(path.join(runDir, 'head-after.txt'), `${isGitRepo ? headSha(repoRoot) : ''}\n`);
|
|
22
|
+
fs.writeFileSync(path.join(runDir, 'branch-after.txt'), `${isGitRepo ? branchName(repoRoot) : ''}\n`);
|
|
23
|
+
fs.writeFileSync(path.join(runDir, 'git-after.txt'), git(repoRoot, ['-c', 'core.quotepath=false', 'status', '--porcelain']).stdout || '');
|
|
24
|
+
fs.writeFileSync(path.join(runDir, 'state-after.txt'), worktreeSnapshot(repoRoot));
|
|
25
|
+
fs.writeFileSync(path.join(runDir, 'diff.stat'), git(repoRoot, ['-c', 'core.quotepath=false', 'diff', '--stat']).stdout || '');
|
|
26
|
+
const scan = scanRunFlags({ runDir, repoRoot });
|
|
27
|
+
fs.writeFileSync(path.join(runDir, 'flags.txt'), scan.text);
|
|
28
|
+
fs.writeFileSync(path.join(runDir, 'flags-coverage.txt'), scan.coverage.gaps.map((gap) => `${gap}\n`).join(''));
|
|
29
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decides from conclusive evidence only whether Codex is missing, signed out, available, or cannot be judged.
|
|
3
|
+
* Plan_60 D2: an inconclusive probe must never become "do not retry".
|
|
4
|
+
*/
|
|
5
|
+
import { resolveCommandOnPath } from '../command-path.mjs';
|
|
6
|
+
import { codexSpawnSpec, spawnCaptured } from './codex-cmd.mjs';
|
|
7
|
+
|
|
8
|
+
// A signed-in answer takes ~200 ms; ten seconds bounds a hung CLI without delaying a healthy start.
|
|
9
|
+
export const PROBE_TIMEOUT_MS = 10_000;
|
|
10
|
+
|
|
11
|
+
function lines(text) {
|
|
12
|
+
return String(text ?? '').split(/[\r\n]+/).map((line) => line.trim()).filter(Boolean);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function failureDetail(result, command) {
|
|
16
|
+
return lines(result.stderr).at(-1)
|
|
17
|
+
|| lines(result.error?.message).at(-1)
|
|
18
|
+
|| `${command} exited ${result.status}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function probeCodexAvailability({
|
|
22
|
+
env = process.env, platform = process.platform,
|
|
23
|
+
run = spawnCaptured, resolve = resolveCommandOnPath, timeoutMs = PROBE_TIMEOUT_MS,
|
|
24
|
+
} = {}) {
|
|
25
|
+
try {
|
|
26
|
+
if (resolve('codex', env) === null) {
|
|
27
|
+
return { state: 'missing', detail: 'codex is not on PATH' };
|
|
28
|
+
}
|
|
29
|
+
const capture = (args) => {
|
|
30
|
+
const spec = codexSpawnSpec(args, platform);
|
|
31
|
+
return run(spec.command, spec.args, { ...spec.options, env, timeout: timeoutMs, encoding: 'utf8' });
|
|
32
|
+
};
|
|
33
|
+
const version = await capture(['--version']);
|
|
34
|
+
if (version.error || version.status !== 0) {
|
|
35
|
+
return { state: 'inconclusive', detail: failureDetail(version, 'codex --version') };
|
|
36
|
+
}
|
|
37
|
+
const login = await capture(['login', 'status']);
|
|
38
|
+
// Plan_60 D2: capture errors invalidate even a familiar line; only a completed probe proves sign-out.
|
|
39
|
+
if (login.error) {
|
|
40
|
+
return { state: 'inconclusive', detail: failureDetail(login, 'codex login status') };
|
|
41
|
+
}
|
|
42
|
+
if (login.status === 0) return { state: 'available' };
|
|
43
|
+
if (login.status === 1 && [...lines(login.stderr), ...lines(login.stdout)].includes('Not logged in')) {
|
|
44
|
+
return { state: 'logged-out', detail: 'codex login status: Not logged in' };
|
|
45
|
+
}
|
|
46
|
+
return { state: 'inconclusive', detail: failureDetail(login, 'codex login status') };
|
|
47
|
+
} catch (error) {
|
|
48
|
+
return { state: 'inconclusive', detail: lines(error?.message).at(-1) || 'codex availability probe failed' };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Invoking the `codex` CLI: starting it, stopping its whole tree,
|
|
3
|
-
*
|
|
2
|
+
* Invoking the `codex` CLI: starting it, stopping its whole tree, and keeping its JSONL stdout
|
|
3
|
+
* in events.jsonl while stderr stays separate. Availability belongs to codex-availability.mjs.
|
|
4
4
|
*
|
|
5
5
|
* Which flags a run gets is the neighbouring codex-args.mjs: this file starts the process and
|
|
6
6
|
* stops it, and knows nothing about what the arguments mean. The two were one module until it
|
|
@@ -336,20 +336,3 @@ export function runCodex(args, taskText, eventsPath, budgetMinutes, graceMs = ST
|
|
|
336
336
|
child.on('close', (code) => finish(code, true));
|
|
337
337
|
});
|
|
338
338
|
}
|
|
339
|
-
|
|
340
|
-
/**
|
|
341
|
-
* Codex missing or not authorised is a deterministic FAIL, never a reason to improvise.
|
|
342
|
-
* Plan_58, 2026-09-19: return a reason for refusal before registration, like the dead-sandbox
|
|
343
|
-
* probe. One no-quota question must not have two answers about whether it leaves a folder.
|
|
344
|
-
*/
|
|
345
|
-
export function codexUnavailableReason() {
|
|
346
|
-
const probe = spawnSync(
|
|
347
|
-
process.platform === 'win32' ? process.env.ComSpec || 'cmd.exe' : 'codex',
|
|
348
|
-
process.platform === 'win32' ? ['/d', '/s', '/c', 'codex --version'] : ['--version'],
|
|
349
|
-
{ encoding: 'utf8', windowsHide: true },
|
|
350
|
-
);
|
|
351
|
-
if (probe.error || probe.status !== 0) {
|
|
352
|
-
return (probe.stderr || probe.error?.message || 'codex --version is not responding').trim();
|
|
353
|
-
}
|
|
354
|
-
return null;
|
|
355
|
-
}
|
|
@@ -8,6 +8,7 @@ import fs from 'node:fs';
|
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import { CONTINUATION_INPUT, parseContinuationGrant } from '../required-inputs.mjs';
|
|
10
10
|
import { readJson } from '../write-meta.mjs';
|
|
11
|
+
import { workerMayBeAlive } from '../meta/run-liveness.mjs';
|
|
11
12
|
|
|
12
13
|
// One comparison of run names for the grant gate and for attach, so a repeat and a grant can never
|
|
13
14
|
// disagree about whether two spellings name the same folder.
|
|
@@ -48,7 +49,20 @@ function lastRunOutcome(runsRootPath, chain) {
|
|
|
48
49
|
const meta = readJson(path.join(runsRootPath, run, 'meta.json')) || {};
|
|
49
50
|
const outcomeStatus = String(meta.status ?? status.status ?? status.state ?? 'unknown').trim() || 'unknown';
|
|
50
51
|
const outcomeReason = String(meta.reason ?? status.reason ?? 'reason not recorded').trim() || 'reason not recorded';
|
|
51
|
-
return { run, status: outcomeStatus, reason: outcomeReason };
|
|
52
|
+
return { run, status: outcomeStatus, reason: outcomeReason, verdict: meta.status, orderId: status.order_id };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function readyGrantLines(runsRootPath, chain, grantReason) {
|
|
56
|
+
const last = lastRunOutcome(runsRootPath, chain);
|
|
57
|
+
if (!last) return 'Ready grant line: none (there is no run to continue).';
|
|
58
|
+
const reason = String(grantReason ?? last.reason).trim() || last.reason;
|
|
59
|
+
const continuation = `Ready grant line: continue: ${last.run} — ${reason}.`;
|
|
60
|
+
// Plan_75 D1: the TradeForge failed advise spent its continuation, so show the same-pass repair.
|
|
61
|
+
// A run without order_id cannot be retried (retryRefusal step d), so it gets no retry line.
|
|
62
|
+
const orderId = String(last.orderId ?? '').trim();
|
|
63
|
+
if (!last.verdict || last.verdict === 'OK' || !orderId) return continuation;
|
|
64
|
+
return continuation +
|
|
65
|
+
` Ready retry line: retry: ${last.run} — ${last.reason} (repeats that pass under order ${orderId}).`;
|
|
52
66
|
}
|
|
53
67
|
|
|
54
68
|
function lastRunHints(runsRootPath, chain, grantReason) {
|
|
@@ -56,9 +70,8 @@ function lastRunHints(runsRootPath, chain, grantReason) {
|
|
|
56
70
|
if (!last) {
|
|
57
71
|
return 'Last run: none. Outcome: none. Ready grant line: none (there is no run to continue).';
|
|
58
72
|
}
|
|
59
|
-
const reason = String(grantReason ?? last.reason).trim() || last.reason;
|
|
60
73
|
return `Last run: ${last.run}. Outcome: ${last.status} — ${last.reason}. ` +
|
|
61
|
-
|
|
74
|
+
readyGrantLines(runsRootPath, chain, grantReason);
|
|
62
75
|
}
|
|
63
76
|
|
|
64
77
|
/**
|
|
@@ -86,6 +99,7 @@ export function continuationRefusal(runsRootPath, chain, isContinue, orderId, gr
|
|
|
86
99
|
if (!continuation) {
|
|
87
100
|
return (
|
|
88
101
|
'--continue is refused: the orchestrator did not provide a `continue:` grant in the task text. ' +
|
|
102
|
+
`${lastRunHints(runsRootPath, chain)} ` +
|
|
89
103
|
`${grantExample} ${grantAction} The run folder was not created; quota was not spent.`
|
|
90
104
|
);
|
|
91
105
|
}
|
|
@@ -109,19 +123,23 @@ export function continuationRefusal(runsRootPath, chain, isContinue, orderId, gr
|
|
|
109
123
|
`--continue is refused: grant ${continuation.run} is not the LAST run of this task's chain; ` +
|
|
110
124
|
`the current last run is ${last || 'none'}. A continuation is single-use: continuing the ` +
|
|
111
125
|
'last run appends a later run, so the old grant stops matching by itself — no counter or new ' +
|
|
112
|
-
`state is used. ${
|
|
126
|
+
`state is used. ${lastRunHints(runsRootPath, chain, continuation.reason)} ` +
|
|
127
|
+
`${grantExample} ${grantAction} The run folder was not created; quota was not spent.`
|
|
113
128
|
);
|
|
114
129
|
}
|
|
115
130
|
|
|
116
131
|
const wanted = String(orderId ?? '').trim();
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
132
|
+
// Plan_75 D1, TradeForge capacity incident: a retry spends no new pass, even after an OK verdict.
|
|
133
|
+
const ofThisOrder = chain.filter((run) => {
|
|
134
|
+
const status = readJson(path.join(runsRootPath, run, 'status.json'));
|
|
135
|
+
return String(status?.order_id ?? '').trim() === wanted && !String(status?.retry_of ?? '').trim();
|
|
136
|
+
});
|
|
120
137
|
if (ofThisOrder.length === 0) return null;
|
|
121
138
|
if (ofThisOrder.length > 1) {
|
|
122
139
|
const spent = ofThisOrder.map((run) => path.join(runsRootPath, run)).join(', ');
|
|
123
140
|
return (
|
|
124
141
|
`--continue is refused: order “${wanted}” already spent its allowed continuation on ${spent}. ` +
|
|
142
|
+
`${lastRunHints(runsRootPath, chain, continuation.reason)} ` +
|
|
125
143
|
'A further pass needs a new order id from the orchestrator. The run folder was not ' +
|
|
126
144
|
'created; quota was not spent.'
|
|
127
145
|
);
|
|
@@ -132,9 +150,43 @@ export function continuationRefusal(runsRootPath, chain, isContinue, orderId, gr
|
|
|
132
150
|
if (!meta?.status || status?.state === 'running') {
|
|
133
151
|
return (
|
|
134
152
|
`--continue is refused: previous run ${previous} has no finished verdict and may still ` +
|
|
135
|
-
'be editing the worktree. Repeat without --continue to attach to it.
|
|
153
|
+
'be editing the worktree. Repeat without --continue to attach to it. ' +
|
|
154
|
+
`${lastRunHints(runsRootPath, chain, continuation.reason)} The run folder was ` +
|
|
136
155
|
'not created; quota was not spent.'
|
|
137
156
|
);
|
|
138
157
|
}
|
|
139
158
|
return null;
|
|
140
159
|
}
|
|
160
|
+
|
|
161
|
+
export function retryRefusal(runsRootPath, chain, isContinue, orderId, grant, liveness = workerMayBeAlive) {
|
|
162
|
+
const refuse = (message) => `${message} The run folder was not created; quota was not spent.`;
|
|
163
|
+
if (!isContinue) {
|
|
164
|
+
return refuse('--continue is required for a `retry:` grant: the orchestrator deliberately repeats the same pass.');
|
|
165
|
+
}
|
|
166
|
+
const runDir = namedRunDirectory(runsRootPath, grant?.run);
|
|
167
|
+
if (!runDir) {
|
|
168
|
+
return refuse(`Retry is refused: ${grant?.run || 'none'} is not a bare existing run folder in ${runsRootPath}.`);
|
|
169
|
+
}
|
|
170
|
+
const last = chain[chain.length - 1];
|
|
171
|
+
// Plan_75 D1: appending the TradeForge retry makes this grant single-use without a new counter.
|
|
172
|
+
if (!last || !sameRun(runsRootPath, grant.run, last)) {
|
|
173
|
+
return refuse(`Retry is refused: grant ${grant.run} is not the LAST run of this task's chain; a retry is single-use.`);
|
|
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
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Owns every exit code the runner returns.
|
|
3
|
+
* Plan_60 D2a: a new status took code 4, already the released --no-wait outcome, because the codes
|
|
4
|
+
* were literals scattered over six modules.
|
|
5
|
+
*/
|
|
6
|
+
export const EXIT = Object.freeze({
|
|
7
|
+
OK: 0, // the run's artifacts confirm the contract
|
|
8
|
+
FAIL: 1, // the run failed, or a pre-flight refusal: the order was right, the host or tree was not
|
|
9
|
+
USAGE: 2, // argument error: the order itself must be rewritten
|
|
10
|
+
LIMIT: 3, // ChatGPT quota exhausted; not a task failure
|
|
11
|
+
PENDING: 4, // --no-wait only: the run is still in progress, or no run exists (a call outcome, not a status)
|
|
12
|
+
UNAVAILABLE: 5, // Codex missing or signed out; hand the task to the next executor
|
|
13
|
+
});
|