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