@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
|
@@ -38,13 +38,17 @@ files, do not run grep, do not retell the report, and do not reason about the ta
|
|
|
38
38
|
hides. If the orchestrator did not give a required input, do not guess — start the runner without
|
|
39
39
|
its flag and return the runner's refusal verbatim.
|
|
40
40
|
- The sub-questions are NOT yours to pass. They live in the task file the orchestrator wrote, under
|
|
41
|
-
a `Questions` heading, one Markdown list item each.
|
|
42
|
-
|
|
41
|
+
a `Questions` heading, one Markdown list item each. A question may carry the orderer's
|
|
42
|
+
`[context-only]` marker; the dispatcher never adds, removes or repairs it. You never read that
|
|
43
|
+
file, never edit it and never put a question on the command line: a question is free prose, and
|
|
44
|
+
free prose in an argument
|
|
43
45
|
makes the host stop applying the operator's permission rule, which is how a delegation dies on a
|
|
44
46
|
refusal. If the file holds no question, start the runner anyway and return its refusal verbatim.
|
|
45
|
-
- `continue` — when the
|
|
46
|
-
bare `--continue` flag even when its run name or reason looks malformed.
|
|
47
|
-
|
|
47
|
+
- `continue` — when the orchestrator's call carries a line beginning with `continue:` or `retry:`,
|
|
48
|
+
pass the bare `--continue` flag even when its run name or reason looks malformed. A `retry:`
|
|
49
|
+
grant lets the orchestrator repeat a failed pass (FAIL, LIMIT, UNAVAILABLE) of the same order
|
|
50
|
+
once; it is not the next pass. Do not inspect, repair, or swallow this grant; pass it through
|
|
51
|
+
and let the runner issue the refusal. A continuation is
|
|
48
52
|
assigned by the orchestrator, never chosen by you. After the verdict, return the exact attaching
|
|
49
53
|
output and stop; do not issue or invent another continuation. If no such grant line is present,
|
|
50
54
|
the flag must not be present.
|
|
@@ -85,8 +89,9 @@ Add `--effort "<value>"` only when the orchestrator named a depth, and only with
|
|
|
85
89
|
decides, which is the intended default — a placeholder copied from this template is refused before
|
|
86
90
|
Codex starts.
|
|
87
91
|
|
|
88
|
-
If the
|
|
89
|
-
flag even when its run name or reason looks malformed. Do not inspect, repair, or
|
|
92
|
+
If the orchestrator's call carries a line beginning with `continue:` or `retry:`, add the bare
|
|
93
|
+
`--continue` flag even when its run name or reason looks malformed. Do not inspect, repair, or
|
|
94
|
+
swallow the line;
|
|
90
95
|
the runner parses it and issues the refusal. If no such grant line is present, the flag must not be
|
|
91
96
|
present in the command.
|
|
92
97
|
No value
|
|
@@ -170,6 +175,7 @@ confidence), `unknowns[]`, and `report_markdown`. An uncovered subquestion or an
|
|
|
170
175
|
evidence is FAIL;
|
|
171
176
|
the runner prints the line `Coverage: N/M subquestions`. The runner expands the last field into
|
|
172
177
|
`report.md`.
|
|
178
|
+
A `[context-only]` subquestion is answered with `startup:<source>` evidence.
|
|
173
179
|
An empty `answer` is FAIL with a path to the log, not a reason to choose different flags.
|
|
174
180
|
|
|
175
181
|
## The script determines status, not you
|
|
@@ -180,9 +186,13 @@ An empty `answer` is FAIL with a path to the log, not a reason to choose differe
|
|
|
180
186
|
- `LIMIT` — the result is empty and the log signals a limit. The ChatGPT quota is exhausted, and the
|
|
181
187
|
task was not
|
|
182
188
|
completed; this is not a task failure and not a reason to restart.
|
|
189
|
+
- `UNAVAILABLE` — Codex is missing or signed out (checked before start, or recognised from the CLI's own
|
|
190
|
+
sign-in refusal after start). The task was not attempted or could not run; this is not a task failure and
|
|
191
|
+
not a reason to restart — return the block as is, the orchestrator hands the task to the next executor.
|
|
183
192
|
|
|
184
|
-
The script return code mirrors the status: `0` / `1` / `3`. A nonzero code is not a reason to retry,
|
|
193
|
+
The script return code mirrors the status: `0` / `1` / `3` / `5`. A nonzero code is not a reason to retry,
|
|
185
194
|
not a reason to change the command, and not a reason to investigate on your own.
|
|
195
|
+
Code `5` means `UNAVAILABLE`.
|
|
186
196
|
|
|
187
197
|
The run folder contains `status.json` (`running` / `finished` / `failed` / `abandoned`) and the
|
|
188
198
|
runner pid.
|
|
@@ -194,10 +204,10 @@ which is the runner telling you the answer is on disk already.
|
|
|
194
204
|
|
|
195
205
|
## Codex is unavailable
|
|
196
206
|
|
|
197
|
-
The runner
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
output.
|
|
207
|
+
The runner looks `codex` up on PATH and asks `codex login status` before starting. A missing binary
|
|
208
|
+
or the measured "Not logged in" answer prints a ready-made `UNAVAILABLE` block (first line
|
|
209
|
+
`UNAVAILABLE — `, exit 5, no run folder). An unclear probe prints an ordinary refusal starting
|
|
210
|
+
`Codex CLI unavailable:` (exit 1). Return that output verbatim.
|
|
201
211
|
Performing the task manually instead of Codex is prohibited for any reason it fails.
|
|
202
212
|
|
|
203
213
|
## What a violation looks like
|
|
@@ -20,15 +20,16 @@ import {
|
|
|
20
20
|
diagnoseInput,
|
|
21
21
|
extractValue,
|
|
22
22
|
missingInputs,
|
|
23
|
-
|
|
23
|
+
parseGrant,
|
|
24
24
|
shellUnsafeInputs,
|
|
25
25
|
} from '../lib/required-inputs.mjs';
|
|
26
26
|
import { SUBAGENT_TOOLS } from '../lib/hook-definitions.mjs';
|
|
27
27
|
import { taskFingerprint } from '../lib/meta/chain.mjs';
|
|
28
|
-
import { conflictingOrderOwner } from '../lib/runner/order-owner.mjs';
|
|
28
|
+
import { conflictingOrderOwner, orderOwnerConflictText } from '../lib/runner/order-owner.mjs';
|
|
29
29
|
import { resolveProjectRunsDir } from '../lib/runner/project-dir.mjs';
|
|
30
30
|
import { runsRoot } from '../lib/runner/runs-root.mjs';
|
|
31
31
|
import { parseTaskDocument } from '../lib/runner/task-file.mjs';
|
|
32
|
+
import { parseTaskHeader, taskHeaderRefusal } from '../lib/task-header.mjs';
|
|
32
33
|
|
|
33
34
|
const GUARDED = new Set(Object.keys(AGENTS));
|
|
34
35
|
/**
|
|
@@ -115,11 +116,29 @@ const readStatus = (file) => {
|
|
|
115
116
|
|
|
116
117
|
const orderId = extractValue(toolInput.prompt, 'order id');
|
|
117
118
|
const taskFile = extractValue(toolInput.prompt, 'task file');
|
|
118
|
-
|
|
119
|
+
// Plan_75 D1, TradeForge capacity incident: retries keep their order; the runner owns conflicting-grant refusal.
|
|
120
|
+
const grant = parseGrant(toolInput.prompt);
|
|
121
|
+
if (!orderId || !taskFile || grant?.kind || grant?.error) pass();
|
|
122
|
+
|
|
123
|
+
let rawTask;
|
|
124
|
+
try {
|
|
125
|
+
rawTask = fs.readFileSync(taskFile, 'utf8');
|
|
126
|
+
} catch {
|
|
127
|
+
pass();
|
|
128
|
+
}
|
|
129
|
+
// Plan_75 D5, 2026-10-03 20:42: malformed metadata must not disappear into the disk-state guard.
|
|
130
|
+
const parsed = parseTaskHeader(rawTask);
|
|
131
|
+
const headerRefusal = taskHeaderRefusal(parsed);
|
|
132
|
+
if (headerRefusal) deny(headerRefusal);
|
|
133
|
+
let taskHash;
|
|
134
|
+
try {
|
|
135
|
+
taskHash = taskFingerprint(parseTaskDocument(parsed.body).task);
|
|
136
|
+
} catch {
|
|
137
|
+
// A malformed Questions or Verify section is the runner's refusal to issue, with its own text.
|
|
138
|
+
pass();
|
|
139
|
+
}
|
|
119
140
|
|
|
120
141
|
try {
|
|
121
|
-
const taskDocument = parseTaskDocument(fs.readFileSync(taskFile, 'utf8'));
|
|
122
|
-
const taskHash = taskFingerprint(taskDocument.task);
|
|
123
142
|
const projectRunsDir = resolveProjectRunsDir(runsRoot(), input.cwd, { create: false }).dir;
|
|
124
143
|
// Per folder, not per directory: a run folder created a moment before its status.json, or any
|
|
125
144
|
// leftover beside the runs, would otherwise throw out of the whole check and silently disarm the
|
|
@@ -131,12 +150,7 @@ try {
|
|
|
131
150
|
const owner = conflictingOrderOwner(runs, orderId, taskHash);
|
|
132
151
|
if (owner) {
|
|
133
152
|
const ownerDir = path.join(projectRunsDir, owner.run);
|
|
134
|
-
deny(
|
|
135
|
-
`Order gate denied the Agent call because order id ${JSON.stringify(orderId)} already belongs ` +
|
|
136
|
-
`to run folder ${ownerDir} (slug ${owner.status.slug}, started_at ${owner.status.started_at}) ` +
|
|
137
|
-
'with a different task. Use a new order id, or provide an explicit continuation grant if ' +
|
|
138
|
-
'this is another pass of that order.',
|
|
139
|
-
);
|
|
153
|
+
deny(orderOwnerConflictText(owner, ownerDir, orderId));
|
|
140
154
|
}
|
|
141
155
|
} catch {
|
|
142
156
|
// The 2026-08-15 collision guard is diagnostic: uncertain disk state must not block real work.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finds a command on PATH the way the host shell would, without starting it.
|
|
3
|
+
* The runner and CLI share this home module because the home never imports cli/ (Plan_60 D2).
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync, statSync } from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
8
|
+
export function envValue(env, name) {
|
|
9
|
+
const key = Object.keys(env).find((entry) => entry.toLowerCase() === name);
|
|
10
|
+
return key ? env[key] : '';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function pathValue(env) {
|
|
14
|
+
return envValue(env, 'path');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function resolveCommandOnPath(name, env = process.env) {
|
|
18
|
+
const extensions = process.platform === 'win32'
|
|
19
|
+
? (env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';')
|
|
20
|
+
: [''];
|
|
21
|
+
for (const directory of pathValue(env).split(path.delimiter).filter(Boolean)) {
|
|
22
|
+
for (const extension of extensions) {
|
|
23
|
+
const candidate = path.join(path.resolve(directory), `${name}${extension}`);
|
|
24
|
+
try {
|
|
25
|
+
if (existsSync(candidate) && statSync(candidate).isFile()) return candidate;
|
|
26
|
+
} catch {
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
extractValue,
|
|
5
5
|
isAbsoluteTaskFilePath,
|
|
6
6
|
isInputPlaceholder,
|
|
7
|
-
|
|
7
|
+
parseGrant,
|
|
8
8
|
REQUIRED_INPUTS,
|
|
9
9
|
} from './required-inputs.mjs';
|
|
10
10
|
import { firstShellUnsafeSequence } from './shell-unsafe.mjs';
|
|
@@ -69,7 +69,9 @@ export function canonicalRunCommand(agentType, promptText) {
|
|
|
69
69
|
tokens.push('--order-id', quote(inputs.get('order id')));
|
|
70
70
|
tokens.push('--task-file', quote(inputs.get('task file')));
|
|
71
71
|
if (inputs.get('effort')) tokens.push('--effort', quote(inputs.get('effort')));
|
|
72
|
-
|
|
72
|
+
// Plan_75 D1, TradeForge capacity incident: forward either grant; the runner owns conflicting-grant refusal.
|
|
73
|
+
const grant = parseGrant(promptText);
|
|
74
|
+
if (grant?.kind || grant?.error) tokens.push('--continue');
|
|
73
75
|
return { command: tokens.join(' ') };
|
|
74
76
|
}
|
|
75
77
|
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locks the kernel releases on its holder's death, shared by the lifecycle lock and the order claim.
|
|
3
|
+
* Plan_60 D4: on 2026-09-24 two launchers with one order id started 2.8 seconds apart and both ran
|
|
4
|
+
* and billed; the runner needs the lifecycle lock's primitive and may not import `cli/`.
|
|
5
|
+
* Where the kernel can release a lock on the holder's death, the lock is a kernel object: a named
|
|
6
|
+
* pipe on Windows, an abstract socket on Linux — both reproduced on real machines (hard kill, name
|
|
7
|
+
* free at once, nothing on disk), which matters on a machine that lost power six times in two weeks.
|
|
8
|
+
* The name comes from the directory's dev+ino, not its path: aliases must reach the same claim.
|
|
9
|
+
* A second listen inside the holder must fail, or the platform did not give exclusivity and the
|
|
10
|
+
* call refuses rather than switch protocols (Plan_65 D5/D8). What the holder answers a waiter is
|
|
11
|
+
* diagnosis only — it never grants anything.
|
|
12
|
+
*/
|
|
13
|
+
import { createHash } from 'node:crypto';
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import net from 'node:net';
|
|
16
|
+
|
|
17
|
+
const wait = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
18
|
+
|
|
19
|
+
export function kernelLockStrategy(platform = process.platform) {
|
|
20
|
+
if (platform === 'win32') return 'named-pipe';
|
|
21
|
+
return platform === 'linux' ? 'abstract-socket' : null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function kernelLockAddress(purpose, digest, platform = process.platform) {
|
|
25
|
+
if (typeof purpose !== 'string' || !/^[a-z][a-z-]*$/.test(purpose)) {
|
|
26
|
+
throw new TypeError('purpose must match ^[a-z][a-z-]*$');
|
|
27
|
+
}
|
|
28
|
+
if (platform === 'win32') return `\\\\.\\pipe\\codex-bridge-${purpose}-${digest}`;
|
|
29
|
+
return platform === 'linux' ? `\0codex-bridge-${purpose}-${digest}` : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function directoryDigest(directory, { suffix } = {}) {
|
|
33
|
+
try {
|
|
34
|
+
const stat = fs.statSync(directory, { bigint: true });
|
|
35
|
+
if (!stat.isDirectory()) throw new Error(`${directory} is not a directory`);
|
|
36
|
+
const identity = `${stat.dev}:${stat.ino}`;
|
|
37
|
+
const extra = typeof suffix === 'string' && suffix.length > 0 ? `:${suffix}` : '';
|
|
38
|
+
return createHash('sha256').update(identity + extra).digest('hex');
|
|
39
|
+
} catch (error) {
|
|
40
|
+
if (error?.code === 'ENOENT') return null;
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function listen(server, address) {
|
|
46
|
+
return new Promise((resolve, reject) => {
|
|
47
|
+
const listening = () => { server.removeListener('error', failed); resolve(); };
|
|
48
|
+
const failed = (error) => { server.removeListener('listening', listening); reject(error); };
|
|
49
|
+
server.once('listening', listening);
|
|
50
|
+
server.once('error', failed);
|
|
51
|
+
try {
|
|
52
|
+
server.listen(address);
|
|
53
|
+
} catch (error) {
|
|
54
|
+
server.removeListener('listening', listening);
|
|
55
|
+
server.removeListener('error', failed);
|
|
56
|
+
reject(error);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function closeServer(server) {
|
|
62
|
+
if (!server) return Promise.resolve();
|
|
63
|
+
return new Promise((resolve, reject) => {
|
|
64
|
+
try {
|
|
65
|
+
server.close((error) => {
|
|
66
|
+
if (error && error.code !== 'ERR_SERVER_NOT_RUNNING') reject(error);
|
|
67
|
+
else resolve();
|
|
68
|
+
});
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if (error.code === 'ERR_SERVER_NOT_RUNNING') resolve();
|
|
71
|
+
else reject(error);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function closeQuietly(server) {
|
|
77
|
+
try { await closeServer(server); } catch { /* Preserve the original failure. */ }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function selfCheck(server, address, createServer, label) {
|
|
81
|
+
let probe;
|
|
82
|
+
let detail;
|
|
83
|
+
try {
|
|
84
|
+
probe = createServer();
|
|
85
|
+
await listen(probe, address);
|
|
86
|
+
detail = 'a second listener succeeded';
|
|
87
|
+
} catch (error) {
|
|
88
|
+
if (!error || error.code !== 'EADDRINUSE') detail = (error && (error.code || error.message)) || String(error);
|
|
89
|
+
}
|
|
90
|
+
if (detail === undefined) {
|
|
91
|
+
await closeQuietly(probe);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
await Promise.all([closeQuietly(probe), closeQuietly(server)]);
|
|
95
|
+
throw new Error(`${label} self-check failed: ${detail}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function tryHoldSocket(address, createServer = net.createServer, label = 'kernel lock') {
|
|
99
|
+
const server = createServer((socket) => socket.destroy());
|
|
100
|
+
try {
|
|
101
|
+
await listen(server, address);
|
|
102
|
+
await selfCheck(server, address, createServer, label);
|
|
103
|
+
let releasePromise;
|
|
104
|
+
return () => (releasePromise ??= closeServer(server));
|
|
105
|
+
} catch (error) {
|
|
106
|
+
await closeQuietly(server);
|
|
107
|
+
if (error?.code === 'EADDRINUSE') return 'in-use';
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function readSocketHolder(address, timeoutMs, parseAnswer) {
|
|
113
|
+
return new Promise((resolve) => {
|
|
114
|
+
let data = '';
|
|
115
|
+
let settled = false;
|
|
116
|
+
const socket = net.createConnection(address);
|
|
117
|
+
const timer = setTimeout(() => finish(null), timeoutMs);
|
|
118
|
+
const finish = (holder) => {
|
|
119
|
+
if (settled) return;
|
|
120
|
+
settled = true;
|
|
121
|
+
clearTimeout(timer);
|
|
122
|
+
socket.destroy();
|
|
123
|
+
resolve(holder);
|
|
124
|
+
};
|
|
125
|
+
socket.on('data', (chunk) => {
|
|
126
|
+
data += chunk.toString('utf8');
|
|
127
|
+
const newline = data.indexOf('\n');
|
|
128
|
+
if (newline !== -1) finish(parseAnswer(data.slice(0, newline)));
|
|
129
|
+
});
|
|
130
|
+
socket.once('end', () => finish(parseAnswer(data)));
|
|
131
|
+
socket.once('error', () => finish(null));
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function acquireKernelLock({
|
|
136
|
+
address, holder, waitMs, retryMs, answerTimeoutMs, parseAnswer, label,
|
|
137
|
+
createServer = net.createServer,
|
|
138
|
+
}) {
|
|
139
|
+
const accepted = new Set();
|
|
140
|
+
const onConnection = (socket) => {
|
|
141
|
+
accepted.add(socket);
|
|
142
|
+
socket.once('close', () => accepted.delete(socket));
|
|
143
|
+
socket.end(`${JSON.stringify(holder)}\n`);
|
|
144
|
+
};
|
|
145
|
+
const deadline = Date.now() + waitMs;
|
|
146
|
+
|
|
147
|
+
for (;;) {
|
|
148
|
+
const server = createServer(onConnection);
|
|
149
|
+
try {
|
|
150
|
+
await listen(server, address);
|
|
151
|
+
holder.acquiredAt = new Date().toISOString();
|
|
152
|
+
await selfCheck(server, address, createServer, label);
|
|
153
|
+
server.unref();
|
|
154
|
+
let releasePromise;
|
|
155
|
+
const release = async () => {
|
|
156
|
+
const closing = closeServer(server);
|
|
157
|
+
for (const socket of accepted) socket.destroy();
|
|
158
|
+
await closing;
|
|
159
|
+
};
|
|
160
|
+
return { held: true, release: () => (releasePromise ??= release()) };
|
|
161
|
+
} catch (error) {
|
|
162
|
+
await closeQuietly(server);
|
|
163
|
+
if (!error || error.code !== 'EADDRINUSE') throw error;
|
|
164
|
+
const remaining = deadline - Date.now();
|
|
165
|
+
if (remaining <= 0) break;
|
|
166
|
+
await wait(Math.min(retryMs, remaining));
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return { held: false, holder: await readSocketHolder(address, answerTimeoutMs, parseAnswer) };
|
|
171
|
+
}
|
|
@@ -12,6 +12,10 @@ import { createHash } from 'node:crypto';
|
|
|
12
12
|
import { startedRuns } from './pre-start.mjs';
|
|
13
13
|
import { normalizePath, readJson } from './paths.mjs';
|
|
14
14
|
|
|
15
|
+
// Plan_75 D5: 2 hashes the header-free body. Runs without this field used earlier schemes:
|
|
16
|
+
// the whole task text, then the task without grant lines (Plan_75 P1).
|
|
17
|
+
export const TASK_HASH_SCHEME = 2;
|
|
18
|
+
|
|
15
19
|
/**
|
|
16
20
|
* What makes two runs the same task when their names disagree.
|
|
17
21
|
*
|
|
@@ -21,6 +25,8 @@ import { normalizePath, readJson } from './paths.mjs';
|
|
|
21
25
|
* built to refuse. Whitespace and case are normalized away because the same order re-sent
|
|
22
26
|
* through a shell is rewrapped, not rewritten; anything more forgiving would tie together
|
|
23
27
|
* tasks that merely look alike.
|
|
28
|
+
* Plan_75 D5: the runner supplies the header-free body; every body line counts, including
|
|
29
|
+
* prose beginning with a grant label. Stripping it again would silently change identity.
|
|
24
30
|
*/
|
|
25
31
|
export const taskFingerprint = (taskText) => {
|
|
26
32
|
const normalized = String(taskText ?? '').replace(/\s+/g, ' ').trim().toLowerCase();
|
|
@@ -35,14 +41,19 @@ export const taskFingerprint = (taskText) => {
|
|
|
35
41
|
* honestly, changed nothing and was told it had done no work. Folder names rather than
|
|
36
42
|
* full paths; the current run joins the list as soon as its status.json exists.
|
|
37
43
|
*/
|
|
38
|
-
export function chainRuns(runsRoot, repo, slug, taskHash = '', orderId = '') {
|
|
44
|
+
export function chainRuns(runsRoot, repo, slug, taskHash = '', orderId = '', grantRun = '') {
|
|
39
45
|
const wantedRepo = normalizePath(repo);
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
|
|
46
|
+
if (!wantedRepo) return [];
|
|
47
|
+
const namedStatus = typeof grantRun === 'string' && grantRun &&
|
|
48
|
+
!/[\\/]/.test(grantRun) && grantRun !== '.' && grantRun !== '..'
|
|
49
|
+
? readJson(path.join(runsRoot, grantRun, 'status.json')) : null;
|
|
50
|
+
const namedRun = namedStatus && normalizePath(namedStatus.repo) === wantedRepo ? namedStatus : null;
|
|
51
|
+
const wantedSlugs = new Set([slug, namedRun?.slug].map((value) => String(value ?? '').trim().toLowerCase()).filter(Boolean));
|
|
52
|
+
const wantedHashes = new Set([taskHash, namedRun?.task_hash].map((value) => String(value ?? '').trim().toLowerCase()).filter(Boolean));
|
|
53
|
+
const wantedOrderIds = new Set([orderId, namedRun?.order_id].map((value) => String(value ?? '').trim()).filter(Boolean));
|
|
43
54
|
// No handle on the task, or no repo, means nothing to chain — an empty context must find
|
|
44
55
|
// nothing rather than fall back to "every run in this folder".
|
|
45
|
-
if (!
|
|
56
|
+
if (!wantedSlugs.size && !wantedHashes.size && !wantedOrderIds.size) return [];
|
|
46
57
|
let entries;
|
|
47
58
|
try {
|
|
48
59
|
entries = fs.readdirSync(runsRoot, { withFileTypes: true });
|
|
@@ -56,9 +67,9 @@ export function chainRuns(runsRoot, repo, slug, taskHash = '', orderId = '') {
|
|
|
56
67
|
if (!status) continue;
|
|
57
68
|
// Either handle identifies the task. The hash catches a repeat that renamed itself; the
|
|
58
69
|
// slug still catches the follow-up whose wording the orchestrator edited on purpose.
|
|
59
|
-
const sameSlug =
|
|
60
|
-
const sameTask =
|
|
61
|
-
const sameOrder =
|
|
70
|
+
const sameSlug = wantedSlugs.has(String(status.slug ?? '').trim().toLowerCase());
|
|
71
|
+
const sameTask = wantedHashes.has(String(status.task_hash ?? '').trim().toLowerCase());
|
|
72
|
+
const sameOrder = wantedOrderIds.has(String(status.order_id ?? '').trim());
|
|
62
73
|
if (!sameSlug && !sameTask && !sameOrder) continue;
|
|
63
74
|
if (normalizePath(status.repo) !== wantedRepo) continue;
|
|
64
75
|
runs.push({ name: entry.name, at: String(status.started_at || '') });
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Judges startup-context references and unread repository coordinates in scout responses.
|
|
3
|
+
* Plan_60 D1: honest answers about handed context need no command, unlike the 2026-09-16
|
|
4
|
+
* dead sandbox; a marker must never excuse invented addresses or an unsourced answer.
|
|
5
|
+
*/
|
|
6
|
+
import { QUESTION_KIND } from '../runner/question-kind.mjs';
|
|
7
|
+
|
|
8
|
+
export function allStartupContext(questions) {
|
|
9
|
+
return Array.isArray(questions) && questions.length > 0
|
|
10
|
+
&& questions.every((question) => question?.kind === QUESTION_KIND.STARTUP_CONTEXT);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function contextOnlyGap({ questions, result, commandsExecuted }) {
|
|
14
|
+
const answers = Array.isArray(result?.answers) ? result.answers : [];
|
|
15
|
+
const byId = new Map();
|
|
16
|
+
for (const answer of answers) {
|
|
17
|
+
const id = String(answer?.question_id || '').trim().toUpperCase();
|
|
18
|
+
if (id && !byId.has(id)) byId.set(id, answer);
|
|
19
|
+
}
|
|
20
|
+
const unsourced = (Array.isArray(questions) ? questions : [])
|
|
21
|
+
.filter((question) => question?.kind === QUESTION_KIND.STARTUP_CONTEXT)
|
|
22
|
+
.filter((question) => {
|
|
23
|
+
const answer = byId.get(String(question.id || '').trim().toUpperCase());
|
|
24
|
+
return !Array.isArray(answer?.evidence)
|
|
25
|
+
|| !answer.evidence.some((item) => /^startup:\s*\S/.test(String(item ?? '')));
|
|
26
|
+
})
|
|
27
|
+
.map((question) => question.id);
|
|
28
|
+
if (unsourced.length) {
|
|
29
|
+
return `responses to startup-context questions without a startup:<source> reference: ${unsourced.join(', ')}`;
|
|
30
|
+
}
|
|
31
|
+
if (commandsExecuted !== 0) return null;
|
|
32
|
+
const references = answers.flatMap((answer) => Array.isArray(answer?.evidence) ? answer.evidence : []);
|
|
33
|
+
references.push(...(Array.isArray(result?.findings) ? result.findings : []).map((finding) => finding?.where));
|
|
34
|
+
for (const reference of references) {
|
|
35
|
+
const text = String(reference ?? '');
|
|
36
|
+
if (text.startsWith('startup:')) continue;
|
|
37
|
+
const coordinate = text.match(/\S*\.[A-Za-z0-9]+:\d+/)?.[0];
|
|
38
|
+
if (coordinate) {
|
|
39
|
+
return `scout cited ${coordinate.slice(0, 60)} without executing a command: an address nobody read`;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
@@ -14,6 +14,15 @@ import { safeSlice } from './paths.mjs';
|
|
|
14
14
|
*/
|
|
15
15
|
const LIMIT_RE = /rate[\s_-]*limit|usage[\s_-]*limit|quota|too many requests|\b429\b/i;
|
|
16
16
|
|
|
17
|
+
// Plan_60 D2: add a new measured sample only together with its verbatim fixture.
|
|
18
|
+
const UNAVAILABLE_SAMPLES = [
|
|
19
|
+
{
|
|
20
|
+
cli: 'codex-cli 0.159.0',
|
|
21
|
+
measured: '2026-10-03',
|
|
22
|
+
pattern: /^unexpected status 401 Unauthorized: Missing bearer or basic authentication in header\b/,
|
|
23
|
+
},
|
|
24
|
+
];
|
|
25
|
+
|
|
17
26
|
const record = (value) => value && typeof value === 'object' && !Array.isArray(value);
|
|
18
27
|
|
|
19
28
|
const compact = (value, max = 300) =>
|
|
@@ -129,6 +138,8 @@ function transportErrorOf(event) {
|
|
|
129
138
|
const status = payload.status ?? nested.status ?? null;
|
|
130
139
|
const marker = [errorType, message].join(' ');
|
|
131
140
|
const quota = Number(status) === 429 || LIMIT_RE.test(marker);
|
|
141
|
+
const unavailable = !quota && event.type === 'turn.failed' &&
|
|
142
|
+
UNAVAILABLE_SAMPLES.some((sample) => sample.pattern.test(message));
|
|
132
143
|
const reason = compact(
|
|
133
144
|
[errorType, message, status === null || status === undefined ? '' : `status ${status}`]
|
|
134
145
|
.filter(Boolean)
|
|
@@ -137,7 +148,7 @@ function transportErrorOf(event) {
|
|
|
137
148
|
(typeof event.error?.message === 'string' ? event.error.message : '') ||
|
|
138
149
|
event.type,
|
|
139
150
|
);
|
|
140
|
-
return { event, payload, status, error_type: errorType, message, quota, reason };
|
|
151
|
+
return { event, payload, status, error_type: errorType, message, quota, unavailable, reason };
|
|
141
152
|
}
|
|
142
153
|
|
|
143
154
|
/**
|
|
@@ -150,7 +161,8 @@ export function readEvents(runDir) {
|
|
|
150
161
|
const accounting = usageOf(events);
|
|
151
162
|
const started = events.find((event) => event.type === 'thread.started' && event.thread_id);
|
|
152
163
|
const errors = events.map(transportErrorOf).filter(Boolean);
|
|
153
|
-
const transport_error = errors.find((error) => error.quota) ||
|
|
164
|
+
const transport_error = errors.find((error) => error.quota) ||
|
|
165
|
+
errors.find((error) => error.unavailable) || errors[0] || null;
|
|
154
166
|
return {
|
|
155
167
|
events,
|
|
156
168
|
// The file was written, whatever it holds. Pass 2 judges a run by this: a stream the
|
|
@@ -23,6 +23,27 @@ function retentionReply(runDir) {
|
|
|
23
23
|
return `Retention: freed ${formatBytes(bytes)} from ${runs} runs older than ${days} days`;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
function attemptReply(runDir) {
|
|
27
|
+
const retryOf = readJson(path.join(runDir, 'status.json'))?.retry_of;
|
|
28
|
+
if (typeof retryOf !== 'string' || !retryOf.trim()) return null;
|
|
29
|
+
const runsRoot = path.dirname(runDir);
|
|
30
|
+
const key = (name) => process.platform === 'win32' ? name.toLowerCase() : name;
|
|
31
|
+
const seen = new Set([key(path.basename(runDir))]);
|
|
32
|
+
let previous = retryOf;
|
|
33
|
+
let attempt = 1;
|
|
34
|
+
// Plan_75 D1, 2026-10-03 TradeForge: show retries without trusting broken provenance links.
|
|
35
|
+
for (let hops = 0; hops < 50; hops += 1) {
|
|
36
|
+
if (typeof previous !== 'string' || !previous || /[/\\]/.test(previous)
|
|
37
|
+
|| previous === '.' || previous === '..' || seen.has(key(previous))) break;
|
|
38
|
+
const status = readJson(path.join(runsRoot, previous, 'status.json'));
|
|
39
|
+
if (!status) break;
|
|
40
|
+
seen.add(key(previous));
|
|
41
|
+
attempt += 1;
|
|
42
|
+
previous = status.retry_of;
|
|
43
|
+
}
|
|
44
|
+
return `Attempt: ${attempt} of this pass — retry of ${retryOf}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
26
47
|
// Five copies of Retention missed writeFailure(), so both reply assemblers share this point.
|
|
27
48
|
// The 2026-09-16 dead Codex sandbox incident is why the launcher probes the sandbox at all.
|
|
28
49
|
export function launchRows(runDir) {
|
|
@@ -33,6 +54,8 @@ export function launchRows(runDir) {
|
|
|
33
54
|
const reason = line(probe.reason, 160) || 'no reason recorded';
|
|
34
55
|
rows.push(`Sandbox probe: inconclusive — ${reason} The run started without a sandbox check.`);
|
|
35
56
|
}
|
|
57
|
+
const attempt = attemptReply(runDir);
|
|
58
|
+
if (attempt) rows.push(attempt);
|
|
36
59
|
return rows;
|
|
37
60
|
}
|
|
38
61
|
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* Renders the reply lines a dispatcher is allowed to return, one format per agent.
|
|
3
3
|
*
|
|
4
4
|
* AGENTS adds reply strategies to the shared agent registry: for each one it describes how
|
|
5
|
-
* to tell the result file is filled in and which reply strategy below renders it.
|
|
5
|
+
* to tell the result file is filled in and which reply strategy below renders it.
|
|
6
|
+
* FAIL, LIMIT and UNAVAILABLE bypass the per-agent strategy — a run that
|
|
6
7
|
* produced nothing has nothing agent-specific left to say.
|
|
7
8
|
*
|
|
8
9
|
* The lines built here ARE the reply. Agents forward this text verbatim instead of
|
|
@@ -24,6 +25,11 @@ const runChanges = (runDir) =>
|
|
|
24
25
|
|
|
25
26
|
const readCommand = (runDir) => `codex-bridge read ${runDir}`;
|
|
26
27
|
|
|
28
|
+
function stripVerdict(value) {
|
|
29
|
+
// Run 2026-10-01_114244_plan72-r3q-live-probe-20261001 repeated OK because the model prefixed its summary.
|
|
30
|
+
return String(value ?? '').replace(/^(?:OK|FAIL|LIMIT|UNAVAILABLE)(?: — | - |: )/, '');
|
|
31
|
+
}
|
|
32
|
+
|
|
27
33
|
export const AGENTS = {
|
|
28
34
|
'codex-scout': {
|
|
29
35
|
...AGENT_DEFINITIONS['codex-scout'],
|
|
@@ -54,7 +60,7 @@ function scoutReply(ctx) {
|
|
|
54
60
|
const unknowns = (r.unknowns || []).filter(Boolean);
|
|
55
61
|
const coverage = scoutCoverage(ctx.runDir, r);
|
|
56
62
|
return [
|
|
57
|
-
`OK — ${line(r.answer, 160)}`,
|
|
63
|
+
`OK — ${line(stripVerdict(r.answer), 160)}`,
|
|
58
64
|
// Any explicit question gets a coverage line, including a valid one-question order.
|
|
59
65
|
...(coverage ? [`Coverage: ${coverage}`] : []),
|
|
60
66
|
`Key finding: ${top ? `${line(top.fact, 130)} (${line(top.where, 60)})` : 'no findings listed'}`,
|
|
@@ -68,6 +74,7 @@ function buildReply(ctx) {
|
|
|
68
74
|
const { work: touchedPaths, environment } = runChanges(ctx.runDir);
|
|
69
75
|
const paths = touchedPaths.slice(0, 3).join(', ');
|
|
70
76
|
const flags = readText(path.join(ctx.runDir, 'flags.txt')).split(/\r?\n/).filter(Boolean);
|
|
77
|
+
const gaps = readText(path.join(ctx.runDir, 'flags-coverage.txt')).split(/\r?\n/).filter((gap) => gap.trim());
|
|
71
78
|
// A multi-line verification is several commands, and collapsing them into one 60-character
|
|
72
79
|
// line cut the last one mid-word: the reply named a command nobody could run.
|
|
73
80
|
const commands = String(r.verify_command ?? '')
|
|
@@ -90,7 +97,7 @@ function buildReply(ctx) {
|
|
|
90
97
|
? `${paths ? `${paths} · ` : ''}changes were made by an earlier run of this task`
|
|
91
98
|
: paths || 'worktree untouched';
|
|
92
99
|
return [
|
|
93
|
-
`OK — ${line(r.summary, 300)}`,
|
|
100
|
+
`OK — ${line(stripVerdict(r.summary), 300)}`,
|
|
94
101
|
`Files: ${touchedPaths.length} changed · ${files}`,
|
|
95
102
|
// Only when something outside the run wrote to the tree. Subtracting those paths from the
|
|
96
103
|
// verdict without naming them would hide a real edit behind a pattern.
|
|
@@ -99,6 +106,9 @@ function buildReply(ctx) {
|
|
|
99
106
|
: []),
|
|
100
107
|
`Verification: ${verify} — ${verdict}`,
|
|
101
108
|
`Flags: ${flags.length ? `${flags.length} TODO/skip — ${line(flags.slice(0, 3).join(' | '), 140)}` : 'none'}`,
|
|
109
|
+
...(gaps.length
|
|
110
|
+
? [`Flags coverage: incomplete — ${line(gaps[0], 120)}${gaps.length > 1 ? ` (+${gaps.length - 1} more)` : ''}`]
|
|
111
|
+
: []),
|
|
102
112
|
`Report: ${ctx.file('report.md')} · Log: ${readCommand(ctx.runDir)}`,
|
|
103
113
|
];
|
|
104
114
|
}
|
|
@@ -223,3 +233,27 @@ export function limitReply(ctx, meta) {
|
|
|
223
233
|
rows.push(`Log: ${readCommand(ctx.runDir)}`);
|
|
224
234
|
return rows;
|
|
225
235
|
}
|
|
236
|
+
|
|
237
|
+
/** Plan_60 D2: pre-start and post-start refusals share the same handoff contract. */
|
|
238
|
+
export function unavailableRows(signal) {
|
|
239
|
+
return [
|
|
240
|
+
'UNAVAILABLE — Codex is not available (missing or signed out); ' +
|
|
241
|
+
'hand the task to the next executor, do not retry',
|
|
242
|
+
`Signal: ${line(signal, 170)}`,
|
|
243
|
+
'Operator check: codex --version; codex login status (sign in with codex login)',
|
|
244
|
+
];
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function unavailableReply(ctx, meta) {
|
|
248
|
+
const rows = unavailableRows(meta.reason);
|
|
249
|
+
if (ctx.agent === 'codex-build') {
|
|
250
|
+
const { work: touched } = runChanges(ctx.runDir);
|
|
251
|
+
rows.push(
|
|
252
|
+
`Worktree: ${
|
|
253
|
+
touched.length ? `has unfinished changes (${touched.length}), see git-after.txt` : 'no new changes'
|
|
254
|
+
}`,
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
rows.push(`Log: ${readCommand(ctx.runDir)}`);
|
|
258
|
+
return rows;
|
|
259
|
+
}
|
|
@@ -12,6 +12,7 @@ import path from 'node:path';
|
|
|
12
12
|
import { changedPaths, line, normalizePath, readJson, readText, size } from './paths.mjs';
|
|
13
13
|
import { withLaunchRows } from './launch-rows.mjs';
|
|
14
14
|
import { runLiveness } from './run-liveness.mjs';
|
|
15
|
+
import { removeFlagBaselineCopies } from '../runner/flag-baseline.mjs';
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* Run state on disk, merged over whatever is already there. A killed runner leaves no
|
|
@@ -115,6 +116,8 @@ export function markAbandoned(runsRoot, currentTree) {
|
|
|
115
116
|
}
|
|
116
117
|
try {
|
|
117
118
|
writeStatus(runDir, patch);
|
|
119
|
+
// Plan_60 D3a: a dead worker cannot remove its start copies; keep the manifest for inspection.
|
|
120
|
+
if (patch.state === 'abandoned') removeFlagBaselineCopies(runDir);
|
|
118
121
|
changed.push({ run: entry.name, state: patch.state });
|
|
119
122
|
} catch {
|
|
120
123
|
// A folder that cannot be written to is not worth failing a fresh run over.
|