@lyupro/codex-bridge 0.1.3 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/cli/doctor.mjs +6 -1
- package/cli/manifest.mjs +6 -4
- package/cli/settings-merge.mjs +18 -4
- package/package.json +1 -1
- package/src/agents/codex-build.md +9 -7
- package/src/agents/codex-review.md +7 -0
- package/src/agents/codex-scout.md +8 -6
- package/src/hook-definitions.mjs +11 -0
- package/src/hooks/live-runs.mjs +98 -0
- package/src/hooks/reply-guard.mjs +43 -20
- package/src/hooks/worktree-lock.mjs +70 -0
- package/src/required-inputs.mjs +49 -6
- package/src/runner/continuation.mjs +107 -0
- package/src/runner/launcher.mjs +12 -40
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,19 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [0.1.4] - 2026-08-05
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- A continuation has to prove the orchestrator ordered it. `--continue` is refused unless the task text carries a `continue:` grant naming the run being continued and why, and the refusal costs nothing: no run folder is created and Codex is never started. The grant is single-use by construction rather than by a counter — continuing the last run of a chain appends a later one, so the same line stops matching by itself. Until this release a dispatcher could decide on a second pass alone: one did, spent 75 691 tokens of the Codex subscription and created a module the order never asked for.
|
|
12
|
+
- A reply that stays silent about a live `codex-build` run of the same project is blocked. The guard reads the project's runs folder from disk instead of trusting the paths the reply chose to mention, so a second run can no longer be left working in the background while the verdict of the first is handed back. Runs of `codex-scout` and `codex-review` are not counted: they hold a read-only sandbox, and blocking a reply over one would spend the guard's budget on a run that touches nothing.
|
|
13
|
+
- A `PreToolUse` lock refuses file edits inside a repository held by a live `codex-build` run. Knowing that a run is live is not the same as being unable to disturb it — edits made during that window land in the runner's before/after snapshot and fail an honest run for work it never did. An edit made through `Bash` still walks past the lock; parsing a command line is not something a guard can do reliably.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- `doctor` names the file each hook was registered for. With two hooks on one event it matched the installation record by event alone and reported the worktree lock's matcher as pointing at `order-gate.mjs` — the one line where an operator can check what is installed, showing the wrong file.
|
|
18
|
+
- Hook presence is decided by this package's own command rather than by the matcher it currently sits under. The matcher is generated from the list of host tool names and grows whenever a spelling is added, so a host installed under the previous matcher would collect a duplicate hook on update and keep a hook pointing at a deleted file after uninstall.
|
|
19
|
+
|
|
7
20
|
## [0.1.3] - 2026-08-05
|
|
8
21
|
|
|
9
22
|
### Added
|
package/cli/doctor.mjs
CHANGED
|
@@ -73,7 +73,12 @@ async function hookChecks(host, record) {
|
|
|
73
73
|
return Promise.all(HOOK_DEFINITIONS.map(async (definition) => {
|
|
74
74
|
const key = `hook:${definition.event}`;
|
|
75
75
|
if (!record) return check(key, 'warn', 'cannot check before installation');
|
|
76
|
-
|
|
76
|
+
// The file, not just the event: PreToolUse carries two hooks of this package, and matching
|
|
77
|
+
// by event alone reported the worktree lock's matcher as pointing at order-gate.mjs. The
|
|
78
|
+
// hook line is the only place an operator can see WHICH file a matcher was registered for,
|
|
79
|
+
// so a wrong name here is worse than no line at all.
|
|
80
|
+
const recorded = record.hooks.find((hook) => hook.event === definition.event
|
|
81
|
+
&& path.basename(hook.path) === definition.file);
|
|
77
82
|
if (!recorded) {
|
|
78
83
|
return check(key, 'warn', `${definition.event} hook is not present in the installation record`);
|
|
79
84
|
}
|
package/cli/manifest.mjs
CHANGED
|
@@ -164,15 +164,17 @@ export function validateInstallRecord(record) {
|
|
|
164
164
|
if (!hook || typeof hook !== 'object' || Array.isArray(hook)) {
|
|
165
165
|
throw new Error('installation record hook must be an object');
|
|
166
166
|
}
|
|
167
|
-
const
|
|
168
|
-
if (!
|
|
167
|
+
const definitions = HOOK_DEFINITIONS.filter((entry) => entry.event === hook.event);
|
|
168
|
+
if (!definitions.length || typeof hook.path !== 'string' || !hook.path) {
|
|
169
169
|
throw new Error('installation record hook must identify a supported event and its path');
|
|
170
170
|
}
|
|
171
171
|
if (path.isAbsolute(hook.path) || hook.path.split(/[\\/]/).includes('..')) {
|
|
172
172
|
throw new Error('installation record hook path must stay relative to the host root');
|
|
173
173
|
}
|
|
174
|
-
|
|
175
|
-
|
|
174
|
+
const definition = definitions.find((entry) => path.basename(hook.path) === entry.file);
|
|
175
|
+
if (!definition || !record.files.includes(hook.path)) {
|
|
176
|
+
const files = definitions.map((entry) => entry.file).join(' or ');
|
|
177
|
+
throw new Error(`installation record hook path must name the installed ${files}`);
|
|
176
178
|
}
|
|
177
179
|
}
|
|
178
180
|
return record;
|
package/cli/settings-merge.mjs
CHANGED
|
@@ -57,13 +57,24 @@ function groupHooks(group) {
|
|
|
57
57
|
return Array.isArray(group?.hooks) ? group.hooks : [];
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
const hasOwnCommand = (group, command) =>
|
|
61
|
+
groupHooks(group).some((hook) => hook?.type === 'command' && hook.command === command);
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Presence is decided by the command, not by the matcher it currently sits under.
|
|
65
|
+
*
|
|
66
|
+
* The matcher is generated from the tool list in hook-definitions.mjs, so it changes whenever a
|
|
67
|
+
* host spelling is added — `Agent` became `Agent|Task` in one release, and the write-tool matcher
|
|
68
|
+
* will grow the same way. A host installed under the old matcher would then be invisible to the
|
|
69
|
+
* new lookup: update would register a duplicate and uninstall would leave a hook pointing at a
|
|
70
|
+
* deleted file. The command is this package's own absolute path, so it identifies our entry
|
|
71
|
+
* wherever the group ended up.
|
|
72
|
+
*/
|
|
60
73
|
export async function inspectHook(settingsPath, specOrPath) {
|
|
61
74
|
const state = await readSettings(settingsPath);
|
|
62
75
|
const spec = normalizeSpec(specOrPath);
|
|
63
76
|
const present = groups(state.settings, spec.event)
|
|
64
|
-
.
|
|
65
|
-
.some((group) => groupHooks(group)
|
|
66
|
-
.some((hook) => hook?.type === 'command' && hook.command === spec.command));
|
|
77
|
+
.some((group) => hasOwnCommand(group, spec.command));
|
|
67
78
|
return { ...state, ...spec, present };
|
|
68
79
|
}
|
|
69
80
|
|
|
@@ -110,9 +121,12 @@ export async function removeHook(settingsPath, specOrPath, { createdGroup = fals
|
|
|
110
121
|
if (!state.present) return { changed: false };
|
|
111
122
|
const settings = structuredClone(state.settings);
|
|
112
123
|
const eventGroups = groups(settings, spec.event);
|
|
124
|
+
// Removal follows the same rule as the lookup above: our command, whatever matcher it is
|
|
125
|
+
// filed under. A foreign hook is left alone because its command is not ours, not because it
|
|
126
|
+
// sits in another group.
|
|
113
127
|
for (let index = eventGroups.length - 1; index >= 0; index -= 1) {
|
|
114
128
|
const group = eventGroups[index];
|
|
115
|
-
if (group
|
|
129
|
+
if (!hasOwnCommand(group, spec.command)) continue;
|
|
116
130
|
if (!Array.isArray(group?.hooks)) continue;
|
|
117
131
|
group.hooks = group.hooks.filter((hook) =>
|
|
118
132
|
!(hook?.type === 'command' && hook.command === spec.command));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lyupro/codex-bridge",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Delegate implementation, reconnaissance and code review from Claude Code to the Codex CLI: dispatcher agents, a runner that survives a dropped call, and a verdict that checks the report against the worktree.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude-code",
|
|
@@ -32,12 +32,13 @@ job is to report the run status honestly, including failure.
|
|
|
32
32
|
reuse an order id from another order — the runner chains runs by that label, and a made-up label
|
|
33
33
|
is how a repeat run hides. If the orchestrator did not give a required input, do not guess: start
|
|
34
34
|
the runner without its flag and return the runner's refusal exactly.
|
|
35
|
-
- `continue` — pass
|
|
36
|
-
add or guess it yourself.
|
|
37
|
-
|
|
38
|
-
`--
|
|
39
|
-
|
|
40
|
-
|
|
35
|
+
- `continue` — pass the `--continue` flag only when the orchestrator gave the matching `continue:`
|
|
36
|
+
grant explicitly; do not add or guess it yourself. A continuation is assigned by the
|
|
37
|
+
orchestrator, never chosen by you. After the verdict, return the exact attaching output and stop;
|
|
38
|
+
do not issue or invent another continuation. The contract is the same kind as `--scope`: if this
|
|
39
|
+
task (the same `slug` and the same repository) already had a run, the runner will refuse to start
|
|
40
|
+
without `--continue` — before starting Codex, so no quota is spent. If the orchestrator did not
|
|
41
|
+
grant a repeat, return the runner's refusal exactly.
|
|
41
42
|
- Optional: `effort: <none|minimal|low|medium|high|xhigh|max>`, `slug:`, `verify: <verification
|
|
42
43
|
command>` (for example `npm test`, `tsc --noEmit`).
|
|
43
44
|
|
|
@@ -57,7 +58,8 @@ Add `--effort "<value>"` only when the orchestrator named a depth, and only with
|
|
|
57
58
|
decides, which is the intended default — a placeholder copied from this template is refused before
|
|
58
59
|
Codex starts.
|
|
59
60
|
|
|
60
|
-
If the orchestrator gave `continue
|
|
61
|
+
If the orchestrator gave the matching `continue:` grant, add the bare `--continue` flag to the
|
|
62
|
+
command, with no value.
|
|
61
63
|
No value
|
|
62
64
|
is passed to it: the runner accepts only `1/true/yes/0/false/no`, while a placeholder string left in
|
|
63
65
|
the
|
|
@@ -31,6 +31,10 @@ opinion, not a verdict.
|
|
|
31
31
|
another order — the runner chains runs by that label, and a made-up label is how a repeat run
|
|
32
32
|
hides. If the orchestrator did not give a required input, do not guess — start the runner without
|
|
33
33
|
its flag and return the runner's refusal verbatim.
|
|
34
|
+
- `continue` is conditional — pass the `--continue` flag only when the orchestrator gave the
|
|
35
|
+
matching `continue:` grant explicitly; do not add or guess it yourself. A continuation is
|
|
36
|
+
assigned by the orchestrator, never chosen by you. After the verdict, return the exact attaching
|
|
37
|
+
output and stop; do not issue or invent another continuation.
|
|
34
38
|
- Optional: review focus as text ("look for races and error handling"), `slug:`, `effort:
|
|
35
39
|
<none|minimal|low|medium|high|xhigh|max>`.
|
|
36
40
|
|
|
@@ -54,6 +58,9 @@ line. To get the verdict, run the **identical command a second time** — same `
|
|
|
54
58
|
`--slug`, same flags. That second call does not start a second run and costs no quota: it attaches
|
|
55
59
|
to the run already in flight, prints `ATTACH=<path>`, blocks until the verdict exists and prints it.
|
|
56
60
|
|
|
61
|
+
If the orchestrator gave the matching `continue:` grant, add the bare `--continue` flag to the
|
|
62
|
+
command, with no value. If it was not given, the flag must not be present in the command at all.
|
|
63
|
+
|
|
57
64
|
Background execution (`run_in_background`, `&`, `nohup`) is prohibited. Interruption is no longer a
|
|
58
65
|
problem worth handling: if the attaching call is killed by a time ceiling, repeat it — every repeat
|
|
59
66
|
attaches to the same run. A real run takes 20-25 minutes, which is normal, not a hang. Give the
|
|
@@ -32,11 +32,12 @@ files, do not run grep, do not retell the report, and do not reason about the ta
|
|
|
32
32
|
pass one `--question "<text>"` flag exactly as given. Never invent, merge, reword, or drop a
|
|
33
33
|
sub-question. If the orchestrator did not give one, do not guess — start the runner without
|
|
34
34
|
the flag and return its refusal verbatim.
|
|
35
|
-
- `continue` — pass
|
|
36
|
-
add or guess it yourself.
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
35
|
+
- `continue` — pass the `--continue` flag only when the orchestrator gave the matching `continue:`
|
|
36
|
+
grant explicitly; do not add or guess it yourself. A continuation is assigned by the
|
|
37
|
+
orchestrator, never chosen by you. After the verdict, return the exact attaching output and stop;
|
|
38
|
+
do not issue or invent another continuation. The contract is strict, like `--scope` in
|
|
39
|
+
codex-build: if this task (the same `slug` and the same repository) already had a run, the runner
|
|
40
|
+
will refuse to start without `--continue` — before starting Codex, so no quota is spent.
|
|
40
41
|
|
|
41
42
|
## The only thing you do
|
|
42
43
|
|
|
@@ -57,7 +58,8 @@ Add `--effort "<value>"` only when the orchestrator named a depth, and only with
|
|
|
57
58
|
decides, which is the intended default — a placeholder copied from this template is refused before
|
|
58
59
|
Codex starts.
|
|
59
60
|
|
|
60
|
-
If the orchestrator gave `continue
|
|
61
|
+
If the orchestrator gave the matching `continue:` grant, add the bare `--continue` flag to the
|
|
62
|
+
command, with no value.
|
|
61
63
|
No value
|
|
62
64
|
is passed to it: the runner accepts only `1/true/yes/0/false/no`, while a placeholder string left in
|
|
63
65
|
the
|
package/src/hook-definitions.mjs
CHANGED
|
@@ -19,7 +19,18 @@ export const SUBAGENT_TOOLS = Object.freeze(['Agent', 'Task']);
|
|
|
19
19
|
/** A PreToolUse matcher is a regular expression over tool names, so alternation covers both. */
|
|
20
20
|
export const SUBAGENT_TOOL_MATCHER = SUBAGENT_TOOLS.join('|');
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* File-writing tools differ across Claude Code hosts just like subagent tools do. Register every
|
|
24
|
+
* host spelling here so the worktree lock receives the edit before it can enter a live runner's
|
|
25
|
+
* before/after snapshot; a list kept only in the hook would leave one host silently unlocked.
|
|
26
|
+
*/
|
|
27
|
+
export const WRITE_TOOLS = Object.freeze(['Write', 'Edit', 'MultiEdit', 'NotebookEdit']);
|
|
28
|
+
|
|
29
|
+
/** A PreToolUse matcher is a regular expression over tool names, so alternation covers all four. */
|
|
30
|
+
export const WRITE_TOOL_MATCHER = WRITE_TOOLS.join('|');
|
|
31
|
+
|
|
22
32
|
export const HOOK_DEFINITIONS = Object.freeze([
|
|
23
33
|
Object.freeze({ event: 'SubagentStop', matcher: '*', file: 'reply-guard.mjs' }),
|
|
24
34
|
Object.freeze({ event: 'PreToolUse', matcher: SUBAGENT_TOOL_MATCHER, file: 'order-gate.mjs' }),
|
|
35
|
+
Object.freeze({ event: 'PreToolUse', matcher: WRITE_TOOL_MATCHER, file: 'worktree-lock.mjs' }),
|
|
25
36
|
]);
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared, fail-open knowledge of live Codex runs.
|
|
3
|
+
*
|
|
4
|
+
* The reply guard and the worktree lock must answer the same question: a status.json is live
|
|
5
|
+
* only while it says `running` and its runner pid accepts signal 0. Keeping that probe and the
|
|
6
|
+
* directory scan here prevents the two protections from disagreeing about a run, which would
|
|
7
|
+
* recreate the incident where the dispatcher reported while another run still edited the tree.
|
|
8
|
+
*/
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
|
|
12
|
+
/** Signal 0 tests existence without stopping the process; EPERM still means that it exists. */
|
|
13
|
+
export const isPidAlive = (pid) => {
|
|
14
|
+
if (typeof pid !== 'number') return false;
|
|
15
|
+
try {
|
|
16
|
+
process.kill(pid, 0);
|
|
17
|
+
return true;
|
|
18
|
+
} catch (err) {
|
|
19
|
+
return err.code === 'EPERM';
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Match the package's repository comparison without resolving symlinks: Windows realpath adds
|
|
25
|
+
* `\\?\\` and UNC spellings, turning equal paths into unequal ones. A null result is uncertainty,
|
|
26
|
+
* and callers must pass rather than deny on it.
|
|
27
|
+
*/
|
|
28
|
+
export function normalizePath(value) {
|
|
29
|
+
if (typeof value !== 'string' || !value.trim()) return null;
|
|
30
|
+
try {
|
|
31
|
+
let normalized = path.resolve(value).replaceAll('\\', '/').replace(/\/+$/, '');
|
|
32
|
+
if (!normalized) normalized = '/';
|
|
33
|
+
if (process.platform === 'win32') normalized = normalized.toLowerCase();
|
|
34
|
+
return normalized;
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isNonEmptyString(value) {
|
|
41
|
+
return typeof value === 'string' && Boolean(value.trim());
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function recognizedStatus(status) {
|
|
45
|
+
return Boolean(status)
|
|
46
|
+
&& typeof status === 'object'
|
|
47
|
+
&& !Array.isArray(status)
|
|
48
|
+
&& status.state === 'running'
|
|
49
|
+
&& typeof status.pid === 'number'
|
|
50
|
+
&& isNonEmptyString(status.agent)
|
|
51
|
+
&& isNonEmptyString(status.slug)
|
|
52
|
+
&& isNonEmptyString(status.repo)
|
|
53
|
+
&& isPidAlive(status.pid);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function readStatus(runDir) {
|
|
57
|
+
try {
|
|
58
|
+
return JSON.parse(fs.readFileSync(path.join(runDir, 'status.json'), 'utf8'));
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Scan one project's direct run folders; unreadable or unfamiliar entries are ignored. */
|
|
65
|
+
export function liveRuns(runsDir) {
|
|
66
|
+
if (typeof runsDir !== 'string' || !runsDir.trim()) return [];
|
|
67
|
+
let entries;
|
|
68
|
+
try {
|
|
69
|
+
entries = fs.readdirSync(runsDir, { withFileTypes: true });
|
|
70
|
+
} catch {
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
const result = [];
|
|
74
|
+
for (const entry of entries) {
|
|
75
|
+
if (!entry.isDirectory()) continue;
|
|
76
|
+
const dir = path.join(runsDir, entry.name);
|
|
77
|
+
const status = readStatus(dir);
|
|
78
|
+
if (recognizedStatus(status)) result.push({ dir, status });
|
|
79
|
+
}
|
|
80
|
+
return result;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Scan every project under the configured runs root for worktree ownership. */
|
|
84
|
+
export function allLiveRuns(runsRoot) {
|
|
85
|
+
if (typeof runsRoot !== 'string' || !runsRoot.trim()) return [];
|
|
86
|
+
let projects;
|
|
87
|
+
try {
|
|
88
|
+
projects = fs.readdirSync(runsRoot, { withFileTypes: true });
|
|
89
|
+
} catch {
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
const result = [];
|
|
93
|
+
for (const project of projects) {
|
|
94
|
+
if (!project.isDirectory()) continue;
|
|
95
|
+
result.push(...liveRuns(path.join(runsRoot, project.name)));
|
|
96
|
+
}
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
import fs from 'node:fs';
|
|
30
30
|
import os from 'node:os';
|
|
31
31
|
import path from 'node:path';
|
|
32
|
+
import { isPidAlive, liveRuns, normalizePath } from './live-runs.mjs';
|
|
32
33
|
|
|
33
34
|
const HOME = os.homedir();
|
|
34
35
|
const LOG_DIR = path.join(HOME, '.claude', 'logs');
|
|
@@ -168,8 +169,9 @@ const blockState = (reason, stopReason) => {
|
|
|
168
169
|
* verdict is the stdout of the attaching call. Matching only `RUN=` would block every honest
|
|
169
170
|
* answer given under the new contract as "did not delegate at all".
|
|
170
171
|
*/
|
|
171
|
-
const
|
|
172
|
-
const
|
|
172
|
+
const cleanRunDir = (value) => value.trim().replace(/\s+started=\S*$/, '').replace(/[`"'*]+$/g, '');
|
|
173
|
+
const runDirs = [...reply.matchAll(/(?:RUN|ATTACH)=(.+?)(?:\r?\n|$)/g)].map((match) => cleanRunDir(match[1]));
|
|
174
|
+
const runDir = runDirs[0] || null;
|
|
173
175
|
|
|
174
176
|
if (!runDir || !fs.existsSync(runDir)) {
|
|
175
177
|
blockForm(
|
|
@@ -180,22 +182,6 @@ if (!runDir || !fs.existsSync(runDir)) {
|
|
|
180
182
|
);
|
|
181
183
|
}
|
|
182
184
|
|
|
183
|
-
/**
|
|
184
|
-
* Best-effort liveness probe: signal 0 sends nothing, it only tests that the pid exists.
|
|
185
|
-
* EPERM means the process is there and owned by someone else — alive for our purposes, and
|
|
186
|
-
* the same reading write-meta.mjs uses. Two answers to "is this run still going" would be
|
|
187
|
-
* one answer too many.
|
|
188
|
-
*/
|
|
189
|
-
const isPidAlive = (pid) => {
|
|
190
|
-
if (typeof pid !== 'number') return false;
|
|
191
|
-
try {
|
|
192
|
-
process.kill(pid, 0);
|
|
193
|
-
return true;
|
|
194
|
-
} catch (err) {
|
|
195
|
-
return err.code === 'EPERM';
|
|
196
|
-
}
|
|
197
|
-
};
|
|
198
|
-
|
|
199
185
|
/**
|
|
200
186
|
* The text the operator reads when the state budget is gone. Everything in it is quoted
|
|
201
187
|
* from status.json — the operator is being told to stop, and a stop justified by guesswork
|
|
@@ -203,12 +189,12 @@ const isPidAlive = (pid) => {
|
|
|
203
189
|
*/
|
|
204
190
|
const fact = (value) => (value === undefined || value === null || value === '' ? 'not recorded' : String(value));
|
|
205
191
|
|
|
206
|
-
const stopText = (headline, observed, runStatus) =>
|
|
192
|
+
const stopText = (headline, observed, runStatus, folder = runDir) =>
|
|
207
193
|
[
|
|
208
194
|
headline,
|
|
209
195
|
`Run: slug ${fact(runStatus?.slug)}, agent ${fact(runStatus?.agent)}, repository ` +
|
|
210
196
|
`${fact(runStatus?.repo)}, started ${fact(runStatus?.started_at)}.`,
|
|
211
|
-
`Run folder: ${
|
|
197
|
+
`Run folder: ${folder}. Read state from ${path.join(folder, 'status.json')}; the verdict ` +
|
|
212
198
|
'will appear in the adjacent meta.json.',
|
|
213
199
|
observed,
|
|
214
200
|
`Worktree ${fact(runStatus?.repo)} is busy with this run: do not build, test, or commit until ` +
|
|
@@ -220,6 +206,43 @@ const stopText = (headline, observed, runStatus) =>
|
|
|
220
206
|
'command with the same --order-id attaches to this run rather than starting another.',
|
|
221
207
|
].join(' ');
|
|
222
208
|
|
|
209
|
+
/**
|
|
210
|
+
* The dispatcher must name every WRITING run the project has live, not just the one it chose to
|
|
211
|
+
* quote. The sibling scan is disk-only, so silence cannot hide a second runner; a scan failure
|
|
212
|
+
* remains fail-open because an uncertain diagnostic must never deny unrelated work.
|
|
213
|
+
*
|
|
214
|
+
* Only codex-build counts. Scout and review run in a read-only sandbox and leave the worktree
|
|
215
|
+
* alone, and launching them alongside other work is deliberate practice here — blocking a reply
|
|
216
|
+
* over a live reader would spend the state budget, and possibly the session, on a run that
|
|
217
|
+
* threatens nothing. What the 2026-08-05 incident cost was a hidden WRITER: the orchestrator
|
|
218
|
+
* edited files that landed in its before/after snapshot.
|
|
219
|
+
*/
|
|
220
|
+
const namedRunDirs = new Set(runDirs.map((dir) => normalizePath(dir)).filter(Boolean));
|
|
221
|
+
let silentLiveSibling = null;
|
|
222
|
+
try {
|
|
223
|
+
silentLiveSibling = liveRuns(path.dirname(runDir)).find((run) => {
|
|
224
|
+
if (run.status.agent !== 'codex-build') return false;
|
|
225
|
+
const normalized = normalizePath(run.dir);
|
|
226
|
+
return normalized && !namedRunDirs.has(normalized);
|
|
227
|
+
});
|
|
228
|
+
} catch {
|
|
229
|
+
silentLiveSibling = null;
|
|
230
|
+
}
|
|
231
|
+
if (silentLiveSibling) {
|
|
232
|
+
const { dir, status } = silentLiveSibling;
|
|
233
|
+
blockState(
|
|
234
|
+
`Contract violated: the response names ${runDir} but omits live run folder ${dir}; report every `
|
|
235
|
+
+ `live run with its agent ${status.agent}, slug ${status.slug}, and repository ${status.repo}.`,
|
|
236
|
+
stopText(
|
|
237
|
+
`The reply guard stopped the session: the dispatcher responded ${MAX_STATE_BLOCKS} times `
|
|
238
|
+
+ 'while a live sibling Codex run was omitted from its reply.',
|
|
239
|
+
`status.json in ${dir} says state=running; process pid ${fact(status.pid)} is alive.`,
|
|
240
|
+
status,
|
|
241
|
+
dir,
|
|
242
|
+
),
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
223
246
|
/**
|
|
224
247
|
* status.json is written by run-codex.mjs before meta.json exists, so it catches the
|
|
225
248
|
* dispatcher that reports before its own run is done — meta.json alone cannot, because a
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* PreToolUse lock for file-writing tools in the Claude Code host.
|
|
4
|
+
*
|
|
5
|
+
* A live codex-build runner snapshots its repository before and after editing. Letting the
|
|
6
|
+
* orchestrator write during that window folds unrelated work into the runner's result, exactly
|
|
7
|
+
* as the 2026-08-05 incident did. This hook denies only a recognised file path inside a live
|
|
8
|
+
* build run's repository; every malformed or unreadable input passes so diagnostics never break
|
|
9
|
+
* unrelated host work.
|
|
10
|
+
*/
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { WRITE_TOOLS } from '../hook-definitions.mjs';
|
|
14
|
+
import { runsRoot } from '../runner/runs-root.mjs';
|
|
15
|
+
import { allLiveRuns, normalizePath } from './live-runs.mjs';
|
|
16
|
+
|
|
17
|
+
const WRITE_TOOL_NAMES = new Set(WRITE_TOOLS);
|
|
18
|
+
const pass = () => process.exit(0);
|
|
19
|
+
|
|
20
|
+
let input;
|
|
21
|
+
try {
|
|
22
|
+
input = JSON.parse(fs.readFileSync(0, 'utf8'));
|
|
23
|
+
} catch {
|
|
24
|
+
pass();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) pass();
|
|
28
|
+
if (!WRITE_TOOL_NAMES.has(input.tool_name)) pass();
|
|
29
|
+
|
|
30
|
+
const toolInput = input.tool_input;
|
|
31
|
+
if (!toolInput || typeof toolInput !== 'object' || Array.isArray(toolInput)) pass();
|
|
32
|
+
const pathField = input.tool_name === 'NotebookEdit' ? 'notebook_path' : 'file_path';
|
|
33
|
+
const rawPath = toolInput[pathField];
|
|
34
|
+
if (typeof rawPath !== 'string' || !rawPath.trim()) pass();
|
|
35
|
+
|
|
36
|
+
let targetPath;
|
|
37
|
+
try {
|
|
38
|
+
if (path.isAbsolute(rawPath)) targetPath = rawPath;
|
|
39
|
+
else if (typeof input.cwd === 'string' && input.cwd.trim()) targetPath = path.resolve(input.cwd, rawPath);
|
|
40
|
+
else pass();
|
|
41
|
+
} catch {
|
|
42
|
+
pass();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const target = normalizePath(targetPath);
|
|
46
|
+
if (!target) pass();
|
|
47
|
+
|
|
48
|
+
let liveRuns;
|
|
49
|
+
try {
|
|
50
|
+
liveRuns = allLiveRuns(runsRoot());
|
|
51
|
+
} catch {
|
|
52
|
+
pass();
|
|
53
|
+
}
|
|
54
|
+
const owner = liveRuns.find(({ status }) => {
|
|
55
|
+
if (status.agent !== 'codex-build') return false;
|
|
56
|
+
const repository = normalizePath(status.repo);
|
|
57
|
+
return Boolean(repository) && (target === repository || target.startsWith(`${repository}/`));
|
|
58
|
+
});
|
|
59
|
+
if (!owner) pass();
|
|
60
|
+
|
|
61
|
+
const { status, dir } = owner;
|
|
62
|
+
process.stdout.write(JSON.stringify({
|
|
63
|
+
hookSpecificOutput: {
|
|
64
|
+
hookEventName: 'PreToolUse',
|
|
65
|
+
permissionDecision: 'deny',
|
|
66
|
+
permissionDecisionReason: `Worktree lock denied ${input.tool_name}: ${targetPath} is inside repository `
|
|
67
|
+
+ `${status.repo}, held by live run folder ${dir} (agent ${status.agent}, slug ${status.slug}). `
|
|
68
|
+
+ 'Wait for status.json to change state before editing this repository.',
|
|
69
|
+
},
|
|
70
|
+
}));
|
package/src/required-inputs.mjs
CHANGED
|
@@ -11,6 +11,15 @@ const PLACEHOLDER_VALUES = new Set(['todo', 'tbd', 'label', 'none', 'order id',
|
|
|
11
11
|
|
|
12
12
|
const freezeEntries = (entries) => Object.freeze(entries.map((entry) => Object.freeze(entry)));
|
|
13
13
|
|
|
14
|
+
export const CONTINUATION_INPUT = Object.freeze({
|
|
15
|
+
label: 'continue',
|
|
16
|
+
source: 'the orchestrator',
|
|
17
|
+
explanation:
|
|
18
|
+
'The run folder this pass is ordered to continue, followed by why the orchestrator is spending another pass. A continuation is assigned by the orchestrator; after a verdict, the dispatcher returns it and stops.',
|
|
19
|
+
example: '2026-08-05_092913_plan14-build — LIMIT at step 3, tests unwritten',
|
|
20
|
+
conditional: 'when --continue is passed',
|
|
21
|
+
});
|
|
22
|
+
|
|
14
23
|
export const REQUIRED_INPUTS = Object.freeze({
|
|
15
24
|
'codex-scout': freezeEntries([
|
|
16
25
|
{
|
|
@@ -19,6 +28,7 @@ export const REQUIRED_INPUTS = Object.freeze({
|
|
|
19
28
|
explanation: 'The label this order is known by. Repeating a call with the same label joins the run already in flight and costs no quota; a different piece of work needs a new label, never a reused one.',
|
|
20
29
|
example: 'plan-13-scout-20260804',
|
|
21
30
|
},
|
|
31
|
+
CONTINUATION_INPUT,
|
|
22
32
|
]),
|
|
23
33
|
'codex-build': freezeEntries([
|
|
24
34
|
{
|
|
@@ -33,6 +43,7 @@ export const REQUIRED_INPUTS = Object.freeze({
|
|
|
33
43
|
explanation: 'Comma-separated globs relative to the repository root, listing every file the run may touch — including each caller of what changes, not only the file being edited. Anything outside the list fails the run.',
|
|
34
44
|
example: 'src/runner/**,tests/runner/**',
|
|
35
45
|
},
|
|
46
|
+
CONTINUATION_INPUT,
|
|
36
47
|
]),
|
|
37
48
|
'codex-review': freezeEntries([
|
|
38
49
|
{
|
|
@@ -41,6 +52,7 @@ export const REQUIRED_INPUTS = Object.freeze({
|
|
|
41
52
|
explanation: 'The label this order is known by. Repeating a call with the same label joins the run already in flight and costs no quota; a different piece of work needs a new label, never a reused one.',
|
|
42
53
|
example: 'plan-13-review-20260804',
|
|
43
54
|
},
|
|
55
|
+
CONTINUATION_INPUT,
|
|
44
56
|
]),
|
|
45
57
|
});
|
|
46
58
|
|
|
@@ -51,14 +63,14 @@ function cleanValue(value) {
|
|
|
51
63
|
return value.trim().replace(/^([`'\"])(.*)\1$/, '$2').trim();
|
|
52
64
|
}
|
|
53
65
|
|
|
54
|
-
function isPlaceholder(value) {
|
|
66
|
+
export function isPlaceholder(value) {
|
|
55
67
|
const cleaned = cleanValue(value);
|
|
56
68
|
if (!cleaned) return true;
|
|
57
69
|
if (cleaned.startsWith('<') && cleaned.endsWith('>')) return true;
|
|
58
70
|
return PLACEHOLDER_VALUES.has(cleaned.replace(/[.,;:]+$/, '').trim().toLowerCase());
|
|
59
71
|
}
|
|
60
72
|
|
|
61
|
-
function extractValue(promptText, label) {
|
|
73
|
+
export function extractValue(promptText, label) {
|
|
62
74
|
const labelPattern = escapeRegExp(label).replaceAll('\\ ', '\\s+');
|
|
63
75
|
const flag = `--${label.replaceAll(/\s+/g, '-')}`;
|
|
64
76
|
const direct = new RegExp(
|
|
@@ -77,6 +89,23 @@ function extractValue(promptText, label) {
|
|
|
77
89
|
return cleanValue(flagMatch[1] ?? flagMatch[2] ?? flagMatch[3]);
|
|
78
90
|
}
|
|
79
91
|
|
|
92
|
+
/** Keeps the 2026-08-05 continuation incident's run and reason in the shared input parser. */
|
|
93
|
+
export function parseContinuationGrant(promptText) {
|
|
94
|
+
const prompt = typeof promptText === 'string' ? promptText : '';
|
|
95
|
+
const value = extractValue(prompt, CONTINUATION_INPUT.label);
|
|
96
|
+
if (isPlaceholder(value)) return null;
|
|
97
|
+
|
|
98
|
+
for (const separator of [/\s+—\s+/, /\s+-\s+/, /\s*:\s*/]) {
|
|
99
|
+
const match = value.match(new RegExp(`^(.+?)${separator.source}(.+)$`));
|
|
100
|
+
if (!match) continue;
|
|
101
|
+
const run = cleanValue(match[1]);
|
|
102
|
+
const reason = cleanValue(match[2]);
|
|
103
|
+
if (isPlaceholder(run) || isPlaceholder(reason)) return null;
|
|
104
|
+
return { run, reason };
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
80
109
|
/** Returns the immutable input entries for one dispatcher type. */
|
|
81
110
|
export function requiredInputsFor(agentType) {
|
|
82
111
|
return REQUIRED_INPUTS[agentType] || [];
|
|
@@ -85,20 +114,34 @@ export function requiredInputsFor(agentType) {
|
|
|
85
114
|
/** Returns required entries whose value is absent or is still an obvious template placeholder. */
|
|
86
115
|
export function missingInputs(agentType, promptText) {
|
|
87
116
|
const prompt = typeof promptText === 'string' ? promptText : '';
|
|
88
|
-
return requiredInputsFor(agentType).filter(
|
|
117
|
+
return requiredInputsFor(agentType).filter(
|
|
118
|
+
(entry) => !entry.conditional && isPlaceholder(extractValue(prompt, entry.label)),
|
|
119
|
+
);
|
|
89
120
|
}
|
|
90
121
|
|
|
91
122
|
/** Renders the compact contract the orchestrator sees while choosing a dispatcher. */
|
|
92
123
|
export function renderRequiredInputSummary(agentType) {
|
|
93
124
|
const entries = requiredInputsFor(agentType);
|
|
94
125
|
if (!entries.length) return '';
|
|
95
|
-
const labels = entries.map((entry) =>
|
|
96
|
-
|
|
126
|
+
const labels = entries.map((entry) => {
|
|
127
|
+
const label = `\`${entry.label}\``;
|
|
128
|
+
return entry.conditional ? `${label} (${entry.conditional})` : label;
|
|
129
|
+
});
|
|
130
|
+
// Commas until the last pair: the list grew to three entries when the continuation grant
|
|
131
|
+
// joined it, and "a and b and c" reads as a stutter in the description the orchestrator
|
|
132
|
+
// sees while choosing a dispatcher.
|
|
133
|
+
const rendered = labels.length > 1
|
|
134
|
+
? `${labels.slice(0, -1).join(', ')} and ${labels[labels.length - 1]}`
|
|
135
|
+
: labels[0];
|
|
136
|
+
return `Requires ${entries[0].source}-provided ${rendered}.`;
|
|
97
137
|
}
|
|
98
138
|
|
|
99
139
|
/** Renders the same contract for pass 2 agent instructions. */
|
|
100
140
|
export function renderRequiredInputs(agentType) {
|
|
101
141
|
return requiredInputsFor(agentType)
|
|
102
|
-
.map((entry) =>
|
|
142
|
+
.map((entry) => {
|
|
143
|
+
const condition = entry.conditional ? ` Condition: ${entry.conditional}.` : '';
|
|
144
|
+
return `- ${entry.label}: ${entry.explanation} Example: \`${entry.example}\`.${condition}`;
|
|
145
|
+
})
|
|
103
146
|
.join('\n');
|
|
104
147
|
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Owns the zero-quota decision about whether a requested continuation was ordered.
|
|
3
|
+
*
|
|
4
|
+
* The 2026-08-05 incident showed why the runner must not infer a second pass from a verdict:
|
|
5
|
+
* only the orchestrator read that verdict and can name the run and reason it is willing to fund.
|
|
6
|
+
*/
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { CONTINUATION_INPUT, parseContinuationGrant } from '../required-inputs.mjs';
|
|
10
|
+
import { readJson } from '../write-meta.mjs';
|
|
11
|
+
|
|
12
|
+
const sameRun = (runsRootPath, left, right) => {
|
|
13
|
+
const a = path.resolve(runsRootPath, left);
|
|
14
|
+
const b = path.resolve(runsRootPath, right);
|
|
15
|
+
return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function namedRunDirectory(runsRootPath, run) {
|
|
19
|
+
if (!run || run === '.' || run === '..' || path.basename(run) !== run) return null;
|
|
20
|
+
const directory = path.join(runsRootPath, run);
|
|
21
|
+
try {
|
|
22
|
+
return fs.statSync(directory).isDirectory() ? directory : null;
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function normalizeGrant(grant) {
|
|
29
|
+
if (typeof grant === 'string') return parseContinuationGrant(grant);
|
|
30
|
+
if (!grant || typeof grant !== 'object') return null;
|
|
31
|
+
const run = String(grant.run ?? '').trim();
|
|
32
|
+
const reason = String(grant.reason ?? '').trim();
|
|
33
|
+
return run && reason ? { run, reason } : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const grantExample = `Example: \`continue: ${CONTINUATION_INPUT.example}\`.`;
|
|
37
|
+
const grantAction =
|
|
38
|
+
'Action: ask the orchestrator for an explicit grant naming the run and reason; do not invent or reuse a continuation.';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Applies the one-continuation limit and all continuation safety gates before a folder is created.
|
|
42
|
+
*
|
|
43
|
+
* The limit is counted over the runs carrying THIS order id, not over the whole chain. The chain
|
|
44
|
+
* also ties runs together by slug and by the fingerprint of the task text, which is what catches a
|
|
45
|
+
* repeat that renamed itself — but counting continuations that way makes the escape hatch
|
|
46
|
+
* unreachable: the operator's rule is "more passes than one need a new order id", and a new order
|
|
47
|
+
* id lands in the same chain through the task hash. A task would then be refused with --continue
|
|
48
|
+
* for having spent its continuation and refused without it for already having runs — permanently
|
|
49
|
+
* unrunnable, which is a worse failure than the retry storm this limit exists to stop.
|
|
50
|
+
*/
|
|
51
|
+
export function continuationRefusal(runsRootPath, chain, isContinue, orderId, grant) {
|
|
52
|
+
if (!isContinue) return null;
|
|
53
|
+
|
|
54
|
+
const continuation = normalizeGrant(grant);
|
|
55
|
+
if (!continuation) {
|
|
56
|
+
return (
|
|
57
|
+
'--continue is refused: the orchestrator did not provide a `continue:` grant in the task text. ' +
|
|
58
|
+
`${grantExample} ${grantAction} The run folder was not created; quota was not spent.`
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const namedDirectory = namedRunDirectory(runsRootPath, continuation.run);
|
|
63
|
+
if (!namedDirectory) {
|
|
64
|
+
return (
|
|
65
|
+
`--continue is refused: the orchestrator named run “${continuation.run}”, but it does not ` +
|
|
66
|
+
`exist as a run folder in this project's runs directory ${runsRootPath}. ` +
|
|
67
|
+
`${grantExample} ${grantAction} The run folder was not created; quota was not spent.`
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const last = chain[chain.length - 1];
|
|
72
|
+
// Continuing the last run appends a later run, so the old grant stops matching by itself; this
|
|
73
|
+
// incident needs no counter or new state to make an orchestrator grant single-use.
|
|
74
|
+
if (!last || !sameRun(runsRootPath, continuation.run, last)) {
|
|
75
|
+
return (
|
|
76
|
+
`--continue is refused: grant ${continuation.run} is not the LAST run of this task's chain; ` +
|
|
77
|
+
`the current last run is ${last || 'none'}. A continuation is single-use: continuing the ` +
|
|
78
|
+
'last run appends a later run, so the old grant stops matching by itself — no counter or new ' +
|
|
79
|
+
`state is used. ${grantExample} ${grantAction} The run folder was not created; quota was not spent.`
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const wanted = String(orderId ?? '').trim();
|
|
84
|
+
const ofThisOrder = chain.filter(
|
|
85
|
+
(run) => String(readJson(path.join(runsRootPath, run, 'status.json'))?.order_id ?? '').trim() === wanted,
|
|
86
|
+
);
|
|
87
|
+
if (ofThisOrder.length === 0) return null;
|
|
88
|
+
if (ofThisOrder.length > 1) {
|
|
89
|
+
const spent = ofThisOrder.map((run) => path.join(runsRootPath, run)).join(', ');
|
|
90
|
+
return (
|
|
91
|
+
`--continue is refused: order “${wanted}” already spent its allowed continuation on ${spent}. ` +
|
|
92
|
+
'A further pass needs a new order id from the orchestrator. The run folder was not ' +
|
|
93
|
+
'created; quota was not spent.'
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
const previous = path.join(runsRootPath, ofThisOrder[0]);
|
|
97
|
+
const status = readJson(path.join(previous, 'status.json'));
|
|
98
|
+
const meta = readJson(path.join(previous, 'meta.json'));
|
|
99
|
+
if (!meta?.status || status?.state === 'running') {
|
|
100
|
+
return (
|
|
101
|
+
`--continue is refused: previous run ${previous} has no finished verdict and may still ` +
|
|
102
|
+
'be editing the worktree. Repeat without --continue to attach to it. The run folder was ' +
|
|
103
|
+
'not created; quota was not spent.'
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
package/src/runner/launcher.mjs
CHANGED
|
@@ -28,6 +28,8 @@ import { attach } from './attach.mjs';
|
|
|
28
28
|
import { setRun } from './run-context.mjs';
|
|
29
29
|
import { loadRunEnv, RUN_ENV } from './run-env.mjs';
|
|
30
30
|
import { parseArgs, die } from './args.mjs';
|
|
31
|
+
import { parseContinuationGrant } from '../required-inputs.mjs';
|
|
32
|
+
import { continuationRefusal } from './continuation.mjs';
|
|
31
33
|
import { SCHEMAS } from './schemas.mjs';
|
|
32
34
|
import { INSTRUCTIONS } from './prompts.mjs';
|
|
33
35
|
import { git, headSha, branchName, worktreeSnapshot, reviewScope } from './git-state.mjs';
|
|
@@ -114,6 +116,7 @@ export async function launcher() {
|
|
|
114
116
|
// like --scope.
|
|
115
117
|
const taskHash = taskFingerprint(taskText);
|
|
116
118
|
const chain = chainRuns(projectRunsRoot, repoRoot, opts.slug, taskHash, opts.orderId);
|
|
119
|
+
const continuationGrant = opts.continue ? parseContinuationGrant(taskText) : null;
|
|
117
120
|
|
|
118
121
|
// One order produced six Codex runs on 2026-08-03 because the caller's time ceiling made it
|
|
119
122
|
// restart the synchronous launcher. A live same-order run is now the repeat target: attach
|
|
@@ -132,7 +135,13 @@ export async function launcher() {
|
|
|
132
135
|
// markAbandoned() ran before this gate, so a dead runner already has meta.json with FAIL.
|
|
133
136
|
// A run without a verdict can therefore only still be in flight; ordinary repeats go through
|
|
134
137
|
// attach(), while --continue must refuse to overlap that live work.
|
|
135
|
-
const continuationError = continuationRefusal(
|
|
138
|
+
const continuationError = continuationRefusal(
|
|
139
|
+
projectRunsRoot,
|
|
140
|
+
chain,
|
|
141
|
+
opts.continue,
|
|
142
|
+
opts.orderId,
|
|
143
|
+
continuationGrant,
|
|
144
|
+
);
|
|
136
145
|
if (continuationError) die(continuationError);
|
|
137
146
|
|
|
138
147
|
if (chain.length && !opts.continue) {
|
|
@@ -183,6 +192,8 @@ export async function launcher() {
|
|
|
183
192
|
// Which run of this task started the chain — the base every later pass is measured
|
|
184
193
|
// against. Absent means this is the first pass.
|
|
185
194
|
...(chain.length ? { continues: chain[0] } : {}),
|
|
195
|
+
// `continued_from` is the exact run the orchestrator named; `continues` above remains the chain base.
|
|
196
|
+
...(continuationGrant ? { continued_from: continuationGrant.run } : {}),
|
|
186
197
|
});
|
|
187
198
|
|
|
188
199
|
// Printed before anything can go wrong: even a dispatcher that dies mid-run leaves the
|
|
@@ -333,42 +344,3 @@ export async function launcher() {
|
|
|
333
344
|
);
|
|
334
345
|
process.exit(0);
|
|
335
346
|
}
|
|
336
|
-
|
|
337
|
-
/**
|
|
338
|
-
* Applies the one-continuation limit and the in-flight safety gate before a folder is created.
|
|
339
|
-
*
|
|
340
|
-
* The limit is counted over the runs carrying THIS order id, not over the whole chain. The chain
|
|
341
|
-
* also ties runs together by slug and by the fingerprint of the task text, which is what catches a
|
|
342
|
-
* repeat that renamed itself — but counting continuations that way makes the escape hatch
|
|
343
|
-
* unreachable: the operator's rule is "more passes than one need a new order id", and a new order
|
|
344
|
-
* id lands in the same chain through the task hash. A task would then be refused with --continue
|
|
345
|
-
* for having spent its continuation and refused without it for already having runs — permanently
|
|
346
|
-
* unrunnable, which is a worse failure than the retry storm this limit exists to stop.
|
|
347
|
-
*/
|
|
348
|
-
export function continuationRefusal(runsRootPath, chain, isContinue, orderId) {
|
|
349
|
-
if (!isContinue || chain.length === 0) return null;
|
|
350
|
-
const wanted = String(orderId ?? '').trim();
|
|
351
|
-
const ofThisOrder = chain.filter(
|
|
352
|
-
(run) => String(readJson(path.join(runsRootPath, run, 'status.json'))?.order_id ?? '').trim() === wanted,
|
|
353
|
-
);
|
|
354
|
-
if (ofThisOrder.length === 0) return null;
|
|
355
|
-
if (ofThisOrder.length > 1) {
|
|
356
|
-
const spent = ofThisOrder.map((run) => path.join(runsRootPath, run)).join(', ');
|
|
357
|
-
return (
|
|
358
|
-
`--continue is refused: order “${wanted}” already spent its allowed continuation on ${spent}. ` +
|
|
359
|
-
'A further pass needs a new order id from the orchestrator. The run folder was not ' +
|
|
360
|
-
'created; quota was not spent.'
|
|
361
|
-
);
|
|
362
|
-
}
|
|
363
|
-
const previous = path.join(runsRootPath, ofThisOrder[0]);
|
|
364
|
-
const status = readJson(path.join(previous, 'status.json'));
|
|
365
|
-
const meta = readJson(path.join(previous, 'meta.json'));
|
|
366
|
-
if (!meta?.status || status?.state === 'running') {
|
|
367
|
-
return (
|
|
368
|
-
`--continue is refused: previous run ${previous} has no finished verdict and may still ` +
|
|
369
|
-
'be editing the worktree. Repeat without --continue to attach to it. The run folder was ' +
|
|
370
|
-
'not created; quota was not spent.'
|
|
371
|
-
);
|
|
372
|
-
}
|
|
373
|
-
return null;
|
|
374
|
-
}
|