@lyupro/codex-bridge 0.5.3 → 0.5.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 +71 -0
- package/README.md +152 -152
- package/bin/codex-bridge.mjs +188 -174
- package/cli/doctor-format.mjs +23 -0
- package/cli/doctor-hooks.mjs +114 -0
- package/cli/doctor-installation.mjs +111 -0
- package/cli/doctor-runs.mjs +55 -0
- package/cli/doctor.mjs +150 -399
- package/cli/host-contract.mjs +135 -0
- package/cli/hosts.mjs +4 -3
- package/cli/install.mjs +31 -8
- package/cli/probe-contract.mjs +174 -0
- package/package.json +49 -49
- package/scripts/run-tests.mjs +4 -1
- package/src/agents/codex-build.md +265 -265
- package/src/agents/codex-review.md +219 -219
- package/src/agents/codex-scout.md +232 -232
- package/src/home/lib/brand-home.mjs +39 -0
- package/src/home/lib/meta/reply.mjs +27 -0
- package/src/home/lib/run-config.mjs +18 -6
- package/src/home/lib/runner/codex-args.mjs +154 -0
- package/src/home/lib/runner/codex-cmd.mjs +268 -398
- package/src/home/lib/runner/launcher.mjs +18 -30
- package/src/home/lib/runner/worker-order.mjs +56 -0
- package/src/home/lib/write-meta.mjs +11 -3
|
@@ -1,398 +1,268 @@
|
|
|
1
|
-
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
import
|
|
14
|
-
import
|
|
15
|
-
import {
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
};
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
let
|
|
155
|
-
let
|
|
156
|
-
let
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
if (
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
if (
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
], true);
|
|
270
|
-
console.log(reply);
|
|
271
|
-
process.exit(1);
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
/**
|
|
276
|
-
* Which model runs a mode, and how deep it reasons.
|
|
277
|
-
*
|
|
278
|
-
* Three sources, in this order: what the dispatcher asked for this one task, the mode's
|
|
279
|
-
* configured profile, and finally a depth to fall back on. The order is the whole point —
|
|
280
|
-
* pinning a model without its depth leaves every run at the fallback, which is how a mode
|
|
281
|
-
* configured for maximum reasoning would quietly keep working at the shallowest setting.
|
|
282
|
-
*/
|
|
283
|
-
const FALLBACK_EFFORT = 'medium';
|
|
284
|
-
|
|
285
|
-
/**
|
|
286
|
-
* A run is one worker, not a team it recruits for itself.
|
|
287
|
-
*
|
|
288
|
-
* Codex can spawn subagents when a prompt asks it to, and their edits land in the same
|
|
289
|
-
* worktree — so they arrive in the snapshot as the run's own work, outside the scope check
|
|
290
|
-
* that grades it, on quota nobody budgeted. The prompts say not to; this is why they cannot.
|
|
291
|
-
*/
|
|
292
|
-
const NO_SUBAGENTS = ['-c', 'agents.enabled=false'];
|
|
293
|
-
|
|
294
|
-
/**
|
|
295
|
-
* Which configured mode an agent is. One table, because the launcher needs the same answer to
|
|
296
|
-
* pick a run's time budget: a second copy of this mapping is a second place to forget a mode.
|
|
297
|
-
*/
|
|
298
|
-
export const runMode = (agent) =>
|
|
299
|
-
({
|
|
300
|
-
'codex-scout': 'scout',
|
|
301
|
-
'codex-build': 'build',
|
|
302
|
-
'codex-review': 'review',
|
|
303
|
-
})[agent];
|
|
304
|
-
|
|
305
|
-
function runProfile(opts) {
|
|
306
|
-
const mode = runMode(opts.agent);
|
|
307
|
-
// opts.models is how tests state a configuration; a real run reads the one loaded for it.
|
|
308
|
-
const configured = (opts.models || RUN_ENV?.models || {})[mode] || {};
|
|
309
|
-
return {
|
|
310
|
-
model: configured.model || '',
|
|
311
|
-
effort: opts.effort || configured.effort || FALLBACK_EFFORT,
|
|
312
|
-
};
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
export function codexArgs(opts, runDir, isGitRepo) {
|
|
316
|
-
const schema = path.join(runDir, 'schema.json');
|
|
317
|
-
const profile = runProfile(opts);
|
|
318
|
-
const effort = `model_reasoning_effort=${profile.effort}`;
|
|
319
|
-
const modelArgs = profile.model ? ['-m', profile.model] : [];
|
|
320
|
-
if (opts.agent === 'codex-scout') {
|
|
321
|
-
return [
|
|
322
|
-
'exec',
|
|
323
|
-
'--json',
|
|
324
|
-
...CLEAN_ENV,
|
|
325
|
-
'--ignore-user-config',
|
|
326
|
-
'-c',
|
|
327
|
-
effort,
|
|
328
|
-
...NO_SUBAGENTS,
|
|
329
|
-
...platformSandboxArgs(),
|
|
330
|
-
...modelArgs,
|
|
331
|
-
'--sandbox',
|
|
332
|
-
'read-only',
|
|
333
|
-
'--skip-git-repo-check',
|
|
334
|
-
'-C',
|
|
335
|
-
opts.repo,
|
|
336
|
-
'--output-schema',
|
|
337
|
-
schema,
|
|
338
|
-
'-o',
|
|
339
|
-
path.join(runDir, 'result.json'),
|
|
340
|
-
'-',
|
|
341
|
-
];
|
|
342
|
-
}
|
|
343
|
-
if (opts.agent === 'codex-build') {
|
|
344
|
-
// --ignore-user-config is deliberately absent: with it Codex forces read-only and
|
|
345
|
-
// every edit is rejected ("writing is blocked by read-only sandbox").
|
|
346
|
-
return [
|
|
347
|
-
'exec',
|
|
348
|
-
'--json',
|
|
349
|
-
...CLEAN_ENV,
|
|
350
|
-
'-c',
|
|
351
|
-
effort,
|
|
352
|
-
...NO_SUBAGENTS,
|
|
353
|
-
...platformSandboxArgs(),
|
|
354
|
-
...modelArgs,
|
|
355
|
-
'--sandbox',
|
|
356
|
-
'workspace-write',
|
|
357
|
-
'-C',
|
|
358
|
-
opts.repo,
|
|
359
|
-
...(isGitRepo ? [] : ['--skip-git-repo-check']),
|
|
360
|
-
'--output-schema',
|
|
361
|
-
schema,
|
|
362
|
-
'-o',
|
|
363
|
-
path.join(runDir, 'result.json'),
|
|
364
|
-
'-',
|
|
365
|
-
];
|
|
366
|
-
}
|
|
367
|
-
// codex-review deliberately does NOT use the `review` subcommand. Two of its traits
|
|
368
|
-
// break the contract this design rests on: it rejects a scope flag together with a
|
|
369
|
-
// prompt ("the argument '--uncommitted' cannot be used with '[PROMPT]'"), so task.md
|
|
370
|
-
// was silently discarded and the review rules never arrived; and it ignores
|
|
371
|
-
// --output-schema, writing prose into -o, so the old JSON parsing of review.json could
|
|
372
|
-
// only ever fail — which is what pushed a dispatcher into reviewing the diff itself.
|
|
373
|
-
//
|
|
374
|
-
// Plain `codex exec` honours the schema (proven by codex-scout), so the review comes
|
|
375
|
-
// back as machine-checkable JSON. Scope lives in the prompt as a file list plus the
|
|
376
|
-
// exact diff command computed by reviewScope(). Read-only through
|
|
377
|
-
// --ignore-user-config, same as scout: review never needs write access.
|
|
378
|
-
return [
|
|
379
|
-
'exec',
|
|
380
|
-
'--json',
|
|
381
|
-
...CLEAN_ENV,
|
|
382
|
-
'--ignore-user-config',
|
|
383
|
-
'-c',
|
|
384
|
-
effort,
|
|
385
|
-
...NO_SUBAGENTS,
|
|
386
|
-
...platformSandboxArgs(),
|
|
387
|
-
...modelArgs,
|
|
388
|
-
'--sandbox',
|
|
389
|
-
'read-only',
|
|
390
|
-
'-C',
|
|
391
|
-
opts.repo,
|
|
392
|
-
'--output-schema',
|
|
393
|
-
schema,
|
|
394
|
-
'-o',
|
|
395
|
-
path.join(runDir, 'review.json'),
|
|
396
|
-
'-',
|
|
397
|
-
];
|
|
398
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Invoking the `codex` CLI: starting it, stopping its whole tree, telling whether it is there at
|
|
3
|
+
* all, and keeping its JSONL stdout in events.jsonl while stderr stays separate.
|
|
4
|
+
*
|
|
5
|
+
* Which flags a run gets is the neighbouring codex-args.mjs: this file starts the process and
|
|
6
|
+
* stops it, and knows nothing about what the arguments mean. The two were one module until it
|
|
7
|
+
* outgrew the size limit, and the seam is the honest one — "how we run Codex" against "what we
|
|
8
|
+
* ask it to run".
|
|
9
|
+
*/
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
13
|
+
import { createHeartbeat, HEARTBEAT_INTERVAL_MS } from '../heartbeat.mjs';
|
|
14
|
+
import { writeFailure } from '../write-meta.mjs';
|
|
15
|
+
import { MAX_LOG } from './git-state.mjs';
|
|
16
|
+
|
|
17
|
+
// Thirty seconds, not a fraction of one: after `exit` the pipe still holds whatever Codex wrote
|
|
18
|
+
// last, and that tail carries `item.completed` — the result the verdict is read from. A quarter
|
|
19
|
+
// of a second is enough to lose it on a loaded Windows host, turning a finished run into "the run
|
|
20
|
+
// left no event". The grace exists to bound a grandchild holding stdio open forever (2026-08-06,
|
|
21
|
+
// run 2026-08-06_204007_build waited 25 minutes), and thirty seconds bounds that just as well.
|
|
22
|
+
const STDIO_DRAIN_GRACE_MS = 30_000;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* An argument cmd.exe cannot be handed safely, or undefined. `%` is fatal because cmd
|
|
26
|
+
* expands variables even inside quotes. The launcher asks first, so the refusal costs
|
|
27
|
+
* nothing; the worker asks again on its own copy, because a process whose stderr goes
|
|
28
|
+
* nowhere must not be the one to discover this.
|
|
29
|
+
*/
|
|
30
|
+
export const unsafeForCmd = (args) =>
|
|
31
|
+
['codex', ...args].find((a) => a.includes('%') || a.includes('"'));
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Stop the process that owns the Codex invocation. On Windows that process is cmd.exe, so
|
|
35
|
+
* child.kill() would reap only the shell and leave Codex spending quota as its grandchild —
|
|
36
|
+
* the exact orphan left by the killed caller on 2026-07-31. taskkill's tree flag is required;
|
|
37
|
+
* POSIX has no shell wrapper here, so killing the child itself is the honest boundary.
|
|
38
|
+
*/
|
|
39
|
+
export function stopCodex(child, onWindows = process.platform === 'win32') {
|
|
40
|
+
if (!onWindows) {
|
|
41
|
+
child.kill('SIGKILL');
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const killed = spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], {
|
|
45
|
+
stdio: 'ignore',
|
|
46
|
+
windowsHide: true,
|
|
47
|
+
});
|
|
48
|
+
if (killed.error || killed.status !== 0) child.kill();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Windows npm ships `codex` as codex.cmd, and Node refuses to spawn .cmd without a
|
|
53
|
+
* shell. One command line through cmd.exe keeps a single code path; every argument is
|
|
54
|
+
* quoted here, and unsafeForCmd() has already refused the ones that cannot be.
|
|
55
|
+
*
|
|
56
|
+
* Stdout is streamed, not collected: spawnSync kept every byte in memory and wrote the file
|
|
57
|
+
* only on return, so a worker killed at minute twelve left events.jsonl missing entirely and
|
|
58
|
+
* the run became unexplainable (2026-07-31, run 114736). Now whatever Codex has already said
|
|
59
|
+
* is on disk, and the task text is pushed into stdin by hand since there is no `input` option
|
|
60
|
+
* outside spawnSync. MAX_LOG stays the ceiling; past it the stream is cut rather than the run
|
|
61
|
+
* being killed — a huge stream is a diagnosis, not a reason to lose the work. stderr has its
|
|
62
|
+
* own bounded file because text outside the JSONL protocol must remain distinguishable from
|
|
63
|
+
* protocol events, and a line cut in half cannot be parsed back safely.
|
|
64
|
+
*/
|
|
65
|
+
export function runCodex(args, taskText, eventsPath, budgetMinutes, graceMs = STDIO_DRAIN_GRACE_MS) {
|
|
66
|
+
const onWindows = process.platform === 'win32';
|
|
67
|
+
const deadlineMs =
|
|
68
|
+
typeof budgetMinutes === 'number' && Number.isFinite(budgetMinutes) && budgetMinutes > 0
|
|
69
|
+
? budgetMinutes * 60 * 1000
|
|
70
|
+
: null;
|
|
71
|
+
let exited = false;
|
|
72
|
+
const heartbeat = createHeartbeat(path.dirname(eventsPath));
|
|
73
|
+
const stampHeartbeat = () => {
|
|
74
|
+
if (!exited) heartbeat.stamp();
|
|
75
|
+
};
|
|
76
|
+
const stderrLog = fs.createWriteStream(path.join(path.dirname(eventsPath), 'stderr.log'), { flags: 'a' });
|
|
77
|
+
const eventsLog = fs.createWriteStream(eventsPath, { flags: 'a' });
|
|
78
|
+
const appendTo = (stream, label) => {
|
|
79
|
+
let written = 0;
|
|
80
|
+
let cut = false;
|
|
81
|
+
return (chunk) => {
|
|
82
|
+
stampHeartbeat();
|
|
83
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), 'utf8');
|
|
84
|
+
if (written >= MAX_LOG) {
|
|
85
|
+
if (!cut) {
|
|
86
|
+
cut = true;
|
|
87
|
+
stream.write(`\nrun-codex: ${label} truncated at ${MAX_LOG} bytes; further output is not saved\n`);
|
|
88
|
+
}
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const room = MAX_LOG - written;
|
|
92
|
+
const slice = buf.length > room ? buf.subarray(0, room) : buf;
|
|
93
|
+
written += slice.length;
|
|
94
|
+
stream.write(slice);
|
|
95
|
+
};
|
|
96
|
+
};
|
|
97
|
+
const appendStderr = appendTo(stderrLog, 'stderr log');
|
|
98
|
+
const appendEvents = (() => {
|
|
99
|
+
let pending = Buffer.alloc(0);
|
|
100
|
+
let written = 0;
|
|
101
|
+
let cut = false;
|
|
102
|
+
const append = (chunk) => {
|
|
103
|
+
stampHeartbeat();
|
|
104
|
+
if (cut) return;
|
|
105
|
+
const incoming = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), 'utf8');
|
|
106
|
+
pending = pending.length ? Buffer.concat([pending, incoming]) : incoming;
|
|
107
|
+
let newline = pending.indexOf(0x0a);
|
|
108
|
+
while (newline !== -1) {
|
|
109
|
+
const line = pending.subarray(0, newline + 1);
|
|
110
|
+
pending = pending.subarray(newline + 1);
|
|
111
|
+
if (written + line.length > MAX_LOG) {
|
|
112
|
+
cut = true;
|
|
113
|
+
pending = Buffer.alloc(0);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
written += line.length;
|
|
117
|
+
eventsLog.write(line);
|
|
118
|
+
newline = pending.indexOf(0x0a);
|
|
119
|
+
}
|
|
120
|
+
if (pending.length > MAX_LOG - written) {
|
|
121
|
+
cut = true;
|
|
122
|
+
pending = Buffer.alloc(0);
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
const finish = () => {
|
|
126
|
+
if (!cut && pending.length && written + pending.length <= MAX_LOG) eventsLog.write(pending);
|
|
127
|
+
pending = Buffer.alloc(0);
|
|
128
|
+
};
|
|
129
|
+
return { append, finish };
|
|
130
|
+
})();
|
|
131
|
+
|
|
132
|
+
let child;
|
|
133
|
+
if (onWindows) {
|
|
134
|
+
// Thrown rather than died: in the worker a bare exit would leave the run open, while a
|
|
135
|
+
// throw goes through the crash handler and closes it with meta.json like any failure.
|
|
136
|
+
const bad = unsafeForCmd(args);
|
|
137
|
+
if (bad) throw new Error(`argument unsafe for cmd.exe (contains % or "): ${bad}`);
|
|
138
|
+
const cmdline = ['codex', ...args].map((a) => (/[\s&|<>^]/.test(a) ? `"${a}"` : a)).join(' ');
|
|
139
|
+
child = spawn(process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', `"${cmdline}"`], {
|
|
140
|
+
windowsVerbatimArguments: true,
|
|
141
|
+
windowsHide: true,
|
|
142
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
143
|
+
});
|
|
144
|
+
} else {
|
|
145
|
+
child = spawn('codex', args, { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return new Promise((resolve) => {
|
|
149
|
+
let failed = false;
|
|
150
|
+
let deadlineTimer;
|
|
151
|
+
let heartbeatTimer;
|
|
152
|
+
let stdioGraceTimer;
|
|
153
|
+
let stoppedOnDeadline = false;
|
|
154
|
+
let exitCode;
|
|
155
|
+
let settled = false;
|
|
156
|
+
let elapsedMs = 0;
|
|
157
|
+
const startedAt = Date.now();
|
|
158
|
+
stampHeartbeat();
|
|
159
|
+
heartbeatTimer = setInterval(stampHeartbeat, HEARTBEAT_INTERVAL_MS);
|
|
160
|
+
child.stdout.on('data', appendEvents.append);
|
|
161
|
+
child.stdout.on('end', appendEvents.finish);
|
|
162
|
+
child.stderr.on('data', (chunk) => {
|
|
163
|
+
appendStderr(chunk);
|
|
164
|
+
});
|
|
165
|
+
// A log file that cannot be opened must not leave Codex spending quota unattended. Without
|
|
166
|
+
// a listener the stream's 'error' event throws inside the worker, and the crash handler
|
|
167
|
+
// closes the run folder while the CLI keeps running — the orphan of 2026-07-31 with an
|
|
168
|
+
// extra step. The run dies, but it dies with its child: killing first, recording second.
|
|
169
|
+
const onStreamError = (which) => (err) => {
|
|
170
|
+
failed = true;
|
|
171
|
+
const note = `\nrun-codex: cannot write ${which}: ${err.message}\n`;
|
|
172
|
+
if (which !== 'stderr.log' && stderrLog.writable) stderrLog.write(note);
|
|
173
|
+
stopCodex(child, onWindows);
|
|
174
|
+
};
|
|
175
|
+
stderrLog.on('error', onStreamError('stderr.log'));
|
|
176
|
+
eventsLog.on('error', onStreamError('events.jsonl'));
|
|
177
|
+
child.on('error', (err) => {
|
|
178
|
+
failed = true;
|
|
179
|
+
appendStderr(`\nrun-codex: ${err.message}\n`);
|
|
180
|
+
});
|
|
181
|
+
// Codex that dies before reading the order leaves an EPIPE here; the exit code and the
|
|
182
|
+
// log already say what happened, and an unhandled stream error would replace that with
|
|
183
|
+
// a crash of the runner.
|
|
184
|
+
child.stdin.on('error', () => {});
|
|
185
|
+
child.stdin.end(Buffer.from(taskText, 'utf8'));
|
|
186
|
+
if (deadlineMs !== null) {
|
|
187
|
+
deadlineTimer = setTimeout(() => {
|
|
188
|
+
const elapsed = Date.now() - startedAt;
|
|
189
|
+
// The tree is stopped either way — a grandchild still holding stdio is exactly what
|
|
190
|
+
// the budget exists to end — but only a process that was still alive was killed by
|
|
191
|
+
// the deadline, and only that run may say so.
|
|
192
|
+
if (!exited) {
|
|
193
|
+
stoppedOnDeadline = true;
|
|
194
|
+
elapsedMs = elapsed;
|
|
195
|
+
stderrLog.write(
|
|
196
|
+
`\nrun-codex: run stopped on its deadline after ${elapsed} ms (budget ${deadlineMs} ms)\n`,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
stopCodex(child, onWindows);
|
|
200
|
+
}, deadlineMs);
|
|
201
|
+
}
|
|
202
|
+
// A stream that already failed never calls back from end(), so waiting on both callbacks in
|
|
203
|
+
// sequence hangs the worker until its own deadline — the run then costs a full budget and
|
|
204
|
+
// answers nothing. Closing is best-effort; the verdict is not.
|
|
205
|
+
const close = (stream) =>
|
|
206
|
+
new Promise((done) => {
|
|
207
|
+
if (stream.destroyed || stream.errored) {
|
|
208
|
+
stream.destroy();
|
|
209
|
+
done();
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
stream.end(done);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// 'exit' fires when Codex is gone; 'close' waits for stdio, which the 2026-08-06 incident
|
|
216
|
+
// showed a grandchild can hold open forever. A bounded drain grace keeps normal logs intact,
|
|
217
|
+
// then closes the run with an explicit marker instead of waiting for an unbounded pipe.
|
|
218
|
+
const finish = async (code, stdioDrained) => {
|
|
219
|
+
if (settled) return;
|
|
220
|
+
settled = true;
|
|
221
|
+
if (deadlineTimer) clearTimeout(deadlineTimer);
|
|
222
|
+
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
223
|
+
if (stdioGraceTimer) clearTimeout(stdioGraceTimer);
|
|
224
|
+
if (!stoppedOnDeadline) elapsedMs = Date.now() - startedAt;
|
|
225
|
+
const exit = failed || code === null ? 1 : code;
|
|
226
|
+
appendEvents.finish();
|
|
227
|
+
if (!stdioDrained) {
|
|
228
|
+
try { child.stdout.destroy(); } catch {}
|
|
229
|
+
try { child.stderr.destroy(); } catch {}
|
|
230
|
+
}
|
|
231
|
+
await close(stderrLog);
|
|
232
|
+
await close(eventsLog);
|
|
233
|
+
resolve({ exit, stoppedOnDeadline, elapsedMs, stdioDrained });
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
child.on('exit', (code) => {
|
|
237
|
+
exited = true;
|
|
238
|
+
exitCode = code;
|
|
239
|
+
if (deadlineTimer) clearTimeout(deadlineTimer);
|
|
240
|
+
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
241
|
+
// The grace is an argument only so tests can prove the fallback without waiting it out;
|
|
242
|
+
// a run never passes one, because a per-run grace is a second place to configure a
|
|
243
|
+
// contract that must be identical for every run.
|
|
244
|
+
stdioGraceTimer = setTimeout(() => finish(exitCode, false), graceMs);
|
|
245
|
+
});
|
|
246
|
+
child.on('close', (code) => finish(code, true));
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Codex missing or not authorised is a deterministic FAIL, never a reason to improvise.
|
|
252
|
+
* Runs after the folder exists so this failure is recorded like any other.
|
|
253
|
+
*/
|
|
254
|
+
export function requireCodex(runDir, agent) {
|
|
255
|
+
const probe = spawnSync(
|
|
256
|
+
process.platform === 'win32' ? process.env.ComSpec || 'cmd.exe' : 'codex',
|
|
257
|
+
process.platform === 'win32' ? ['/d', '/s', '/c', 'codex --version'] : ['--version'],
|
|
258
|
+
{ encoding: 'utf8' },
|
|
259
|
+
);
|
|
260
|
+
if (probe.error || probe.status !== 0) {
|
|
261
|
+
const why = (probe.stderr || probe.error?.message || 'codex --version is not responding').trim();
|
|
262
|
+
const { reply } = writeFailure(runDir, agent, `Codex CLI unavailable: ${why}`, [
|
|
263
|
+
'Operator check: codex --version (and codex login if authorization is rejected)',
|
|
264
|
+
], true);
|
|
265
|
+
console.log(reply);
|
|
266
|
+
process.exit(1);
|
|
267
|
+
}
|
|
268
|
+
}
|