@lyupro/codex-bridge 0.6.9 → 0.6.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +132 -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/lifecycle-lock-inspect.mjs +3 -2
- package/cli/lifecycle-lock.mjs +19 -132
- package/cli/manifest.mjs +6 -6
- 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 +37 -121
- package/src/agents/codex-build.md +27 -135
- package/src/agents/codex-review.md +19 -111
- package/src/agents/codex-scout.md +21 -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 +80 -47
- package/src/home/hooks/reply-guard.mjs +52 -65
- package/src/home/hooks/reply-identity.mjs +64 -0
- package/src/home/hooks/reply-parser.mjs +1 -1
- 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/command-path.mjs +32 -0
- package/src/home/lib/dispatcher-call.mjs +171 -0
- package/src/home/lib/dispatcher-command.mjs +18 -54
- 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/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/paths.mjs +1 -1
- package/src/home/lib/meta/reply.mjs +39 -4
- package/src/home/lib/meta/run-state.mjs +3 -0
- package/src/home/lib/meta/verdict.mjs +16 -8
- 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/run-codex.mjs +6 -3
- package/src/home/lib/runner/advise-carry.mjs +2 -1
- package/src/home/lib/runner/args.mjs +140 -220
- package/src/home/lib/runner/attach.mjs +24 -17
- 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 +174 -140
- 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 +237 -277
- package/src/home/lib/runner/order-claim.mjs +84 -0
- package/src/home/lib/runner/order-options.mjs +30 -0
- package/src/home/lib/runner/order-owner.mjs +19 -0
- package/src/home/lib/runner/pass-gate.mjs +111 -0
- package/src/home/lib/runner/preflight.mjs +26 -28
- 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 +31 -12
- package/src/home/lib/runner/task-input.mjs +34 -12
- package/src/home/lib/runner/worker-admission.mjs +43 -0
- package/src/home/lib/runner/worker.mjs +19 -18
- package/src/home/lib/subagent-model-env.mjs +44 -0
- package/src/home/lib/task-header.mjs +158 -0
- package/src/home/lib/untyped-stop.mjs +30 -0
- package/src/home/lib/write-meta.mjs +17 -5
- package/src/home/lib/required-inputs.mjs +0 -261
- /package/{cli → src/home/lib}/frontmatter.mjs +0 -0
|
@@ -1,220 +1,140 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Reads the command line of a run and refuses it before anything is spent.
|
|
3
|
-
*
|
|
4
|
-
* Every check here happens before the run folder exists and before a single token of someone
|
|
5
|
-
* else's quota is touched, and every refusal is an exit code rather than a message: a
|
|
6
|
-
* dispatcher branches on it.
|
|
7
|
-
*/
|
|
8
|
-
import
|
|
9
|
-
import
|
|
10
|
-
import { AGENTS } from '../write-meta.mjs';
|
|
11
|
-
import { isAbsoluteTaskFilePath
|
|
12
|
-
import { firstShellUnsafeSequence } from '../shell-unsafe.mjs';
|
|
13
|
-
import { parseTaskDocument } from './task-file.mjs';
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
//
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
if (
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
]
|
|
97
|
-
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
opts[name] =
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
if (
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
`--${name} contains forbidden shell sequence ${JSON.stringify(sequence)}; ` +
|
|
142
|
-
'put free text in the task file and pass only a short command-line value.',
|
|
143
|
-
);
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
if (!opts.agent) die('--agent is required');
|
|
147
|
-
if (!AGENTS[opts.agent]) die(`unknown --agent ${opts.agent}`);
|
|
148
|
-
opts.orderId = String(opts['order-id'] ?? '').trim();
|
|
149
|
-
opts.noWait = Boolean(opts['no-wait']);
|
|
150
|
-
if (opts.noWait && opts.continue) {
|
|
151
|
-
die('--no-wait cannot be combined with --continue: checking an existing run must never authorize a new one.');
|
|
152
|
-
}
|
|
153
|
-
if (!opts.orderId) {
|
|
154
|
-
const orderInput = requiredInput(opts.agent, 'order id');
|
|
155
|
-
die(
|
|
156
|
-
`--order-id is required: ${orderInput.source} supplies the ${orderInput.label}; the runner will not invent one. ` +
|
|
157
|
-
'A repeat of the same order should come back with --continue. If this appeared right after a package update, ' +
|
|
158
|
-
'the installed dispatcher prompts are stale and need npm run dev:install (local dev) or codex-bridge update. ' +
|
|
159
|
-
`Example: --order-id "${orderInput.example}". Action: get the value from ${orderInput.source}, pass it as --order-id, and retry.`,
|
|
160
|
-
);
|
|
161
|
-
}
|
|
162
|
-
if (opts.agent === 'codex-scout') {
|
|
163
|
-
// The requirement itself moved to the launcher, which is the first point that has seen both
|
|
164
|
-
// sources: questions now arrive either as flags or as a section of the task file, and this
|
|
165
|
-
// check runs before either file or stdin has been read. Only the shape of a supplied flag is
|
|
166
|
-
// still judged here.
|
|
167
|
-
if (opts.questions?.some((question) => !String(question).trim())) {
|
|
168
|
-
die('--question must not be empty for codex-scout; no quota was spent');
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
// Left unset when not given, rather than defaulted here: the role's configured profile is
|
|
172
|
-
// what fills the gap, and a default applied this early would always win over it.
|
|
173
|
-
// Plan_56 step 3: support belongs to the live catalogue on write and Codex at run start.
|
|
174
|
-
if (opts.effort !== undefined && (!opts.effort || /\s/.test(opts.effort))) {
|
|
175
|
-
die(`--effort must be a non-empty single word with no whitespace; got ${JSON.stringify(opts.effort)}`);
|
|
176
|
-
}
|
|
177
|
-
opts.repo = path.resolve(opts.repo || process.cwd());
|
|
178
|
-
const slugSource = opts.slug ? '--slug' : '--order-id';
|
|
179
|
-
opts.slug = (opts.slug || opts.orderId).replace(/[^A-Za-z0-9._-]+/g, '-');
|
|
180
|
-
// Plan_29 incident: the generic `build` slug made an honest order inherit a days-old chain;
|
|
181
|
-
// reject a value with no alphanumeric anchor instead of creating an empty or dot-only folder.
|
|
182
|
-
if (!/[A-Za-z0-9]/.test(opts.slug)) {
|
|
183
|
-
die(
|
|
184
|
-
`${slugSource} produces an unusable run folder name after sanitization: ` +
|
|
185
|
-
`${JSON.stringify(opts.slug)} must contain a letter or digit.`,
|
|
186
|
-
);
|
|
187
|
-
}
|
|
188
|
-
opts.changeset = opts.changeset || 'uncommitted';
|
|
189
|
-
// A writing run without a declared scope is how `!Plans/*.md` got edited by a run that was
|
|
190
|
-
// told in prose not to touch them: prose does not bind, a file list does. Required rather
|
|
191
|
-
// than defaulted to "everything", and checked here — before the folder exists and before a
|
|
192
|
-
// single token of someone else's quota is spent.
|
|
193
|
-
const declaredScopePatterns = String(opts.scope || '')
|
|
194
|
-
.split(',')
|
|
195
|
-
.map((p) => p.trim())
|
|
196
|
-
.filter(Boolean);
|
|
197
|
-
opts.scopeNewPatterns = String(opts['scope-new'] || '')
|
|
198
|
-
.split(',')
|
|
199
|
-
.map((p) => p.trim())
|
|
200
|
-
.filter(Boolean);
|
|
201
|
-
// Plan_27 keeps ordinary patterns strict: only paths named explicitly as new may be absent.
|
|
202
|
-
opts.scopePatterns = [...declaredScopePatterns, ...opts.scopeNewPatterns];
|
|
203
|
-
// Only the writing agent creates files, so only its scope may name one that does not exist yet.
|
|
204
|
-
// Accepting the flag elsewhere would hand scout and review a way to waive the check for nothing.
|
|
205
|
-
if (opts.agent !== 'codex-build' && opts.scopeNewPatterns.length) {
|
|
206
|
-
die(
|
|
207
|
-
`--scope-new is only for codex-build: ${opts.agent} does not create files. ` +
|
|
208
|
-
'Action: drop --scope-new and pass every path through --scope.',
|
|
209
|
-
);
|
|
210
|
-
}
|
|
211
|
-
if (opts.agent === 'codex-build' && !declaredScopePatterns.length) {
|
|
212
|
-
const scopeInput = requiredInput(opts.agent, 'scope');
|
|
213
|
-
die(
|
|
214
|
-
`--scope is required for codex-build: ${scopeInput.source} supplies the ${scopeInput.label}. ` +
|
|
215
|
-
`${scopeInput.explanation} Example: --scope "${scopeInput.example}". ` +
|
|
216
|
-
`Action: get the value from ${scopeInput.source}, pass it as --scope, and retry.`,
|
|
217
|
-
);
|
|
218
|
-
}
|
|
219
|
-
return opts;
|
|
220
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Reads the command line of a run and refuses it before anything is spent.
|
|
3
|
+
*
|
|
4
|
+
* Every check here happens before the run folder exists and before a single token of someone
|
|
5
|
+
* else's quota is touched, and every refusal is an exit code rather than a message: a
|
|
6
|
+
* dispatcher branches on it.
|
|
7
|
+
*/
|
|
8
|
+
import { EXIT } from './exit-codes.mjs';
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import { AGENTS } from '../write-meta.mjs';
|
|
11
|
+
import { isAbsoluteTaskFilePath } from '../order-values.mjs';
|
|
12
|
+
import { firstShellUnsafeSequence } from '../shell-unsafe.mjs';
|
|
13
|
+
import { parseTaskDocument } from './task-file.mjs';
|
|
14
|
+
import { parseTaskHeader, taskHeaderRefusal } from '../task-header.mjs';
|
|
15
|
+
import { renderOrderHeaderHelp } from '../order-schema.mjs';
|
|
16
|
+
|
|
17
|
+
export class RunnerUsageError extends Error {
|
|
18
|
+
// 2 says the order itself is wrong and has to be rewritten. A refusal about the state of the
|
|
19
|
+
// host or the worktree passes 1: the order was fine, nothing about it needs changing, and
|
|
20
|
+
// Plan_58 moved two such refusals here from a code path that already answered 1.
|
|
21
|
+
constructor(message, exitCode = EXIT.USAGE) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.exitCode = exitCode;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function die(message, exitCode = EXIT.USAGE) {
|
|
28
|
+
console.error(`run-codex: ${message}`);
|
|
29
|
+
throw new RunnerUsageError(message, exitCode);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function readTaskDocument(opts) {
|
|
33
|
+
const refuse = (message) => die([message, renderOrderHeaderHelp(opts.agent)].filter(Boolean).join('\n'));
|
|
34
|
+
const stdinText = process.stdin.isTTY ? '' : fs.readFileSync(0, 'utf8');
|
|
35
|
+
if (opts['task-file'] !== undefined) {
|
|
36
|
+
if (stdinText.trim()) {
|
|
37
|
+
refuse('task text was supplied through both stdin and --task-file; choose exactly one channel');
|
|
38
|
+
}
|
|
39
|
+
const taskFile = opts['task-file'];
|
|
40
|
+
// The 2026-08-15 incident resolved a relative order against the repository cwd and ran an
|
|
41
|
+
// unrelated task.md. Refuse the supplied value instead of silently selecting another file.
|
|
42
|
+
if (!isAbsoluteTaskFilePath(taskFile)) {
|
|
43
|
+
refuse(`--task-file must be an absolute path; got ${JSON.stringify(taskFile)}`);
|
|
44
|
+
}
|
|
45
|
+
let fileText;
|
|
46
|
+
try {
|
|
47
|
+
fileText = fs.readFileSync(taskFile, 'utf8');
|
|
48
|
+
} catch (err) {
|
|
49
|
+
refuse(`task file from --task-file could not be read: ${err.message}`);
|
|
50
|
+
}
|
|
51
|
+
if (!fileText.trim()) refuse(`task file from --task-file is empty: ${taskFile}`);
|
|
52
|
+
// Plan_75 D5, 2026-10-03 20:42: validate raw metadata before sections can hide a grant.
|
|
53
|
+
const parsed = parseTaskHeader(fileText);
|
|
54
|
+
const refusal = taskHeaderRefusal(parsed);
|
|
55
|
+
if (refusal) refuse(`${taskFile}: ${refusal}`);
|
|
56
|
+
if (!parsed.body.trim()) refuse(`task file from --task-file is empty: ${taskFile}`);
|
|
57
|
+
let document;
|
|
58
|
+
try {
|
|
59
|
+
document = parseTaskDocument(parsed.body);
|
|
60
|
+
} catch (err) {
|
|
61
|
+
refuse(`${taskFile}: ${err.message}`);
|
|
62
|
+
}
|
|
63
|
+
return { ...document, header: parsed };
|
|
64
|
+
}
|
|
65
|
+
if (!stdinText.trim()) refuse('task text on stdin is empty');
|
|
66
|
+
const parsed = parseTaskHeader(stdinText);
|
|
67
|
+
const refusal = taskHeaderRefusal(parsed);
|
|
68
|
+
if (refusal) refuse(refusal);
|
|
69
|
+
if (!parsed.body.trim()) refuse('task text on stdin is empty');
|
|
70
|
+
try {
|
|
71
|
+
return { ...parseTaskDocument(parsed.body), header: parsed };
|
|
72
|
+
} catch (err) {
|
|
73
|
+
refuse(err.message);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Flags that carry no value. A value is accepted only in its explicit yes/no spellings;
|
|
78
|
+
// anything else stops the run. The permissive reading this replaces — "not 0/false/no means
|
|
79
|
+
// yes" — turned the prompt's own placeholder text (`--continue "<only if the orchestrator provided
|
|
80
|
+
// continue>"`) into a silent opt-in, and a real run started on someone else's quota. A flag
|
|
81
|
+
// whose whole point is that a human decided it must never be switched on by a leftover
|
|
82
|
+
// template.
|
|
83
|
+
const BOOLEAN_FLAGS = new Set(['no-wait']);
|
|
84
|
+
// Plan_42 keeps free text in the task file because these command-line values otherwise disable
|
|
85
|
+
// the host's standing permission before the runner can spend quota.
|
|
86
|
+
const SHELL_CHECKED_FLAGS = Object.freeze([
|
|
87
|
+
'agent',
|
|
88
|
+
'task-file',
|
|
89
|
+
]);
|
|
90
|
+
const BOOLEAN_YES = /^(1|true|yes)$/i;
|
|
91
|
+
const BOOLEAN_NO = /^(0|false|no)$/i;
|
|
92
|
+
|
|
93
|
+
export function parseArgs(argv) {
|
|
94
|
+
const opts = {};
|
|
95
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
96
|
+
const key = argv[i];
|
|
97
|
+
if (!key.startsWith('--')) die(`unexpected argument: ${key}`);
|
|
98
|
+
const name = key.slice(2);
|
|
99
|
+
if (!['agent', 'task-file', 'no-wait'].includes(name)) {
|
|
100
|
+
const agent = opts.agent ?? argv[argv.indexOf('--agent') + 1];
|
|
101
|
+
const help = Object.hasOwn(AGENTS, agent) ? renderOrderHeaderHelp(agent) : '';
|
|
102
|
+
die(`unknown flag --${name}: codex-bridge run takes only --agent, --task-file and --no-wait; ` +
|
|
103
|
+
'the order belongs in the task-file header' + (help ? `\n${help}` : ''));
|
|
104
|
+
}
|
|
105
|
+
const value = argv[i + 1];
|
|
106
|
+
if (BOOLEAN_FLAGS.has(name)) {
|
|
107
|
+
if (value !== undefined && !value.startsWith('--')) {
|
|
108
|
+
if (!BOOLEAN_YES.test(value) && !BOOLEAN_NO.test(value)) {
|
|
109
|
+
die(
|
|
110
|
+
`--${name} takes no value, or one of 1/true/yes/0/false/no; got ${JSON.stringify(value)}. ` +
|
|
111
|
+
'A placeholder left in from the prompt template is not consent.',
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
opts[name] = BOOLEAN_YES.test(value);
|
|
115
|
+
i += 1;
|
|
116
|
+
} else {
|
|
117
|
+
opts[name] = true;
|
|
118
|
+
}
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
// A flag name where a value belongs means the value was left out.
|
|
122
|
+
if (value === undefined || value.startsWith('--')) die(`missing value for ${key}`);
|
|
123
|
+
opts[name] = value;
|
|
124
|
+
i += 1;
|
|
125
|
+
}
|
|
126
|
+
for (const name of SHELL_CHECKED_FLAGS) {
|
|
127
|
+
if (opts[name] === undefined) continue;
|
|
128
|
+
const sequence = firstShellUnsafeSequence(opts[name]);
|
|
129
|
+
if (sequence !== null) {
|
|
130
|
+
die(
|
|
131
|
+
`--${name} contains forbidden shell sequence ${JSON.stringify(sequence)}; ` +
|
|
132
|
+
'put free text in the task file and pass only a short command-line value.',
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (!opts.agent) die('--agent is required');
|
|
137
|
+
if (!AGENTS[opts.agent]) die(`unknown --agent ${opts.agent}`);
|
|
138
|
+
opts.noWait = Boolean(opts['no-wait']);
|
|
139
|
+
return opts;
|
|
140
|
+
}
|
|
@@ -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.
|
|
@@ -88,39 +89,39 @@ export async function waitForReply(runDir, workerPid, status) {
|
|
|
88
89
|
* What counts as "not finished yet" is the absence of reply.txt, not the absence of meta.json.
|
|
89
90
|
* The artifact order is meta.json first, reply.txt last, so between the two there is a moment
|
|
90
91
|
* where the verdict exists and the run is not closed — judged by meta.json, a repeat arriving in
|
|
91
|
-
* that window would be refused and sent to the
|
|
92
|
+
* that window would be refused and sent to the continuation gate instead of being answered. The
|
|
92
93
|
* promise being kept here is that repeating the command is always safe, and a promise with a
|
|
93
94
|
* window in it is not one. markAbandoned() closes dead runs with FAIL before the gate, so this
|
|
94
95
|
* path only covers a live run that has not recorded a verdict yet.
|
|
95
96
|
*
|
|
96
97
|
* A repeat that arrives after the verdict is answered from disk rather than refused, for the same
|
|
97
|
-
* reason.
|
|
98
|
+
* reason. A `continue:` grant may attach to a run created from it: 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.
|
|
123
|
-
// Reading it from the end is the whole point:
|
|
124
|
+
// Reading it from the end is the whole point: a continuation adds a second run under the same
|
|
124
125
|
// order, and while it was in flight a repeat used to be answered by the first run's reply.txt —
|
|
125
126
|
// a stale verdict presented as this pass's answer. Found by the Plan_11-2 checklist, 2026-08-04.
|
|
126
127
|
let candidate = null;
|
|
@@ -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
|
-
}
|