@lyupro/codex-bridge 0.6.15 → 0.6.16
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 +23 -0
- package/README.md +1 -1
- package/cli/command-registry.mjs +1 -1
- package/cli/guidance-render.mjs +57 -0
- package/cli/guidance-topics.mjs +8 -0
- package/cli/guidance.mjs +9 -10
- package/package.json +2 -2
- package/scripts/home-write-inventory.mjs +2 -0
- package/scripts/run-tests.mjs +44 -0
- package/src/claude/rules/core.md +5 -4
- package/src/home/guidance/order.md +12 -13
- package/src/home/guidance/records.md +3 -1
- package/src/home/hooks/dispatcher-gate.mjs +8 -2
- package/src/home/hooks/order-gate.mjs +16 -15
- package/src/home/lib/dispatcher-call.mjs +27 -24
- package/src/home/lib/dispatcher-command.mjs +12 -6
- package/src/home/lib/dispatcher-gate.mjs +20 -16
- package/src/home/lib/dispatcher-model.mjs +7 -8
- package/src/home/lib/dispatcher-pin.mjs +7 -6
- package/src/home/lib/dispatcher-protocol.mjs +76 -4
- package/src/home/lib/dispatcher-state.mjs +4 -3
- package/src/home/lib/install-owner-roots.mjs +57 -55
- package/src/home/lib/meta/advisor-task.mjs +21 -12
- package/src/home/lib/meta/reply.mjs +1 -10
- package/src/home/lib/meta/unavailable-rows.mjs +11 -0
- package/src/home/lib/order-diagnostics/cli-flags.mjs +56 -0
- package/src/home/lib/order-diagnostics/filesystem.mjs +18 -0
- package/src/home/lib/order-diagnostics/host-state.mjs +263 -0
- package/src/home/lib/order-diagnostics/internal.mjs +82 -0
- package/src/home/lib/order-diagnostics/run-state.mjs +58 -0
- package/src/home/lib/order-diagnostics.mjs +48 -0
- package/src/home/lib/order-rules/call.mjs +130 -0
- package/src/home/lib/order-rules/header-grants.mjs +175 -0
- package/src/home/lib/order-rules/header.mjs +206 -0
- package/src/home/lib/order-rules/task-document.mjs +80 -0
- package/src/home/lib/order-rules.mjs +77 -0
- package/src/home/lib/order-schema.mjs +206 -200
- package/src/home/lib/order-text-record.mjs +104 -0
- package/src/home/lib/order-values.mjs +46 -46
- package/src/home/lib/runner/advise-carry.mjs +9 -7
- package/src/home/lib/runner/args.mjs +23 -26
- package/src/home/lib/runner/codex-availability.mjs +10 -6
- package/src/home/lib/runner/continuation.mjs +158 -174
- package/src/home/lib/runner/launcher.mjs +19 -17
- package/src/home/lib/runner/order-claim.mjs +15 -9
- package/src/home/lib/runner/order-options.mjs +3 -2
- package/src/home/lib/runner/order-owner.mjs +9 -7
- package/src/home/lib/runner/pass-gate.mjs +14 -18
- package/src/home/lib/runner/preflight.mjs +25 -35
- package/src/home/lib/runner/question-kind.mjs +4 -2
- package/src/home/lib/runner/retired-roots.mjs +18 -9
- package/src/home/lib/runner/runs-root.mjs +3 -3
- package/src/home/lib/runner/sandbox-diagnosis.mjs +17 -17
- package/src/home/lib/runner/sandbox-probe.mjs +18 -27
- package/src/home/lib/runner/scope-check.mjs +30 -23
- package/src/home/lib/runner/task-file.mjs +6 -4
- package/src/home/lib/runner/task-input.mjs +12 -16
- package/src/home/lib/runner/worker-admission.mjs +5 -2
- package/src/home/lib/subagent-model-env.mjs +5 -6
- package/src/home/lib/task-header.mjs +159 -158
|
@@ -4,25 +4,26 @@ import path from 'node:path';
|
|
|
4
4
|
import { readJsonFileSync } from '../json-file.mjs';
|
|
5
5
|
import { parseAdvisorTask } from '../meta/advisor-task.mjs';
|
|
6
6
|
import { die } from './args.mjs';
|
|
7
|
+
import { orderDiagnosticMessage } from '../order-diagnostics.mjs';
|
|
7
8
|
|
|
8
9
|
export function advisorTaskArtifact({ taskText, phase, runsRoot, grantRun }) {
|
|
9
10
|
const task = parseAdvisorTask(taskText);
|
|
10
11
|
if (phase !== 'advise') return task;
|
|
11
12
|
if (typeof runsRoot !== 'string' || !runsRoot || typeof grantRun !== 'string' || !grantRun) {
|
|
12
|
-
throw new Error('
|
|
13
|
+
throw new Error(orderDiagnosticMessage('advise-scope-input-required'));
|
|
13
14
|
}
|
|
14
15
|
const scopeFile = path.join(runsRoot, grantRun, 'result.json');
|
|
15
16
|
let result;
|
|
16
17
|
try {
|
|
17
18
|
result = readJsonFileSync(scopeFile);
|
|
18
19
|
} catch (error) {
|
|
19
|
-
throw new Error(
|
|
20
|
+
throw new Error(orderDiagnosticMessage('advise-scope-result-readable', { scopeFile, message: error.message }));
|
|
20
21
|
}
|
|
21
22
|
if (!result || typeof result !== 'object' || Array.isArray(result) ||
|
|
22
23
|
!Array.isArray(result.predicted_risks) || !Array.isArray(result.missing_paths) ||
|
|
23
24
|
result.predicted_risks.some((risk) => !risk || typeof risk.id !== 'string' || typeof risk.risk !== 'string') ||
|
|
24
25
|
result.missing_paths.some((entry) => typeof entry !== 'string')) {
|
|
25
|
-
throw new Error(
|
|
26
|
+
throw new Error(orderDiagnosticMessage('advise-scope-result-shape', { scopeFile }));
|
|
26
27
|
}
|
|
27
28
|
return { ...task, scope: { run: grantRun, predicted_risks: result.predicted_risks, missing_paths: result.missing_paths } };
|
|
28
29
|
}
|
|
@@ -33,13 +34,13 @@ export function advisorTaskOrRefuse(input) {
|
|
|
33
34
|
try {
|
|
34
35
|
return advisorTaskArtifact(input);
|
|
35
36
|
} catch (error) {
|
|
36
|
-
return die(
|
|
37
|
+
return die([error.message, orderDiagnosticMessage('run-not-created')].join(' '), EXIT.FAIL);
|
|
37
38
|
}
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
export function adviseSection(artifact) {
|
|
41
|
-
if (!artifact?.scope) return
|
|
42
|
-
|
|
42
|
+
if (!artifact?.scope) return String();
|
|
43
|
+
const lines = [
|
|
43
44
|
'## Scope phase results',
|
|
44
45
|
'',
|
|
45
46
|
'Predicted risks:',
|
|
@@ -47,5 +48,6 @@ export function adviseSection(artifact) {
|
|
|
47
48
|
'',
|
|
48
49
|
'Paths you may additionally read and cite:',
|
|
49
50
|
...artifact.scope.missing_paths.map((entry) => `- ${entry}`),
|
|
50
|
-
]
|
|
51
|
+
];
|
|
52
|
+
return lines.join('\n');
|
|
51
53
|
}
|
|
@@ -12,7 +12,10 @@ import { isAbsoluteTaskFilePath } from '../order-values.mjs';
|
|
|
12
12
|
import { firstShellUnsafeSequence } from '../shell-unsafe.mjs';
|
|
13
13
|
import { parseTaskDocument } from './task-file.mjs';
|
|
14
14
|
import { parseTaskHeader, taskHeaderRefusal } from '../task-header.mjs';
|
|
15
|
-
import { renderOrderHeaderHelp } from '../order-schema.mjs';
|
|
15
|
+
import { renderOrderHeaderHelp } from '../order-schema.mjs';
|
|
16
|
+
import { orderRuleMessage, orderRefusalAt } from '../order-rules.mjs';
|
|
17
|
+
import { orderDiagnosticMessage } from '../order-diagnostics.mjs';
|
|
18
|
+
import { checkOutgoingRefusal } from '../order-text-record.mjs';
|
|
16
19
|
|
|
17
20
|
export class RunnerUsageError extends Error {
|
|
18
21
|
// 2 says the order itself is wrong and has to be rewritten. A refusal about the state of the
|
|
@@ -24,8 +27,9 @@ export class RunnerUsageError extends Error {
|
|
|
24
27
|
}
|
|
25
28
|
}
|
|
26
29
|
|
|
27
|
-
export function die(message, exitCode = EXIT.USAGE) {
|
|
28
|
-
|
|
30
|
+
export function die(message, exitCode = EXIT.USAGE) {
|
|
31
|
+
checkOutgoingRefusal(message, 'die');
|
|
32
|
+
console.error(`run-codex: ${message}`);
|
|
29
33
|
throw new RunnerUsageError(message, exitCode);
|
|
30
34
|
}
|
|
31
35
|
|
|
@@ -34,39 +38,39 @@ export function readTaskDocument(opts) {
|
|
|
34
38
|
const stdinText = process.stdin.isTTY ? '' : fs.readFileSync(0, 'utf8');
|
|
35
39
|
if (opts['task-file'] !== undefined) {
|
|
36
40
|
if (stdinText.trim()) {
|
|
37
|
-
refuse('task
|
|
41
|
+
refuse(orderDiagnosticMessage('task-channel-conflict'));
|
|
38
42
|
}
|
|
39
43
|
const taskFile = opts['task-file'];
|
|
40
44
|
// The 2026-08-15 incident resolved a relative order against the repository cwd and ran an
|
|
41
45
|
// unrelated task.md. Refuse the supplied value instead of silently selecting another file.
|
|
42
46
|
if (!isAbsoluteTaskFilePath(taskFile)) {
|
|
43
|
-
refuse(
|
|
47
|
+
refuse(orderRuleMessage('task-file-path', { value: taskFile }));
|
|
44
48
|
}
|
|
45
49
|
let fileText;
|
|
46
50
|
try {
|
|
47
51
|
fileText = fs.readFileSync(taskFile, 'utf8');
|
|
48
52
|
} catch (err) {
|
|
49
|
-
refuse(
|
|
53
|
+
refuse(orderDiagnosticMessage('task-file-readable', { message: err.message }));
|
|
50
54
|
}
|
|
51
|
-
if (!fileText.trim()) refuse(
|
|
55
|
+
if (!fileText.trim()) refuse(orderRuleMessage('task-body-required', { channel: 'task-file', taskFile }));
|
|
52
56
|
// Plan_75 D5, 2026-10-03 20:42: validate raw metadata before sections can hide a grant.
|
|
53
57
|
const parsed = parseTaskHeader(fileText);
|
|
54
58
|
const refusal = taskHeaderRefusal(parsed);
|
|
55
|
-
if (refusal) refuse(
|
|
56
|
-
if (!parsed.body.trim()) refuse(
|
|
59
|
+
if (refusal) refuse(orderRefusalAt(taskFile, refusal));
|
|
60
|
+
if (!parsed.body.trim()) refuse(orderRuleMessage('task-body-required', { channel: 'task-file', taskFile }));
|
|
57
61
|
let document;
|
|
58
62
|
try {
|
|
59
63
|
document = parseTaskDocument(parsed.body);
|
|
60
64
|
} catch (err) {
|
|
61
|
-
refuse(
|
|
65
|
+
refuse(orderRefusalAt(taskFile, err.message));
|
|
62
66
|
}
|
|
63
67
|
return { ...document, header: parsed };
|
|
64
68
|
}
|
|
65
|
-
if (!stdinText.trim()) refuse('task
|
|
69
|
+
if (!stdinText.trim()) refuse(orderRuleMessage('task-body-required', { channel: 'stdin' }));
|
|
66
70
|
const parsed = parseTaskHeader(stdinText);
|
|
67
71
|
const refusal = taskHeaderRefusal(parsed);
|
|
68
72
|
if (refusal) refuse(refusal);
|
|
69
|
-
if (!parsed.body.trim()) refuse('task
|
|
73
|
+
if (!parsed.body.trim()) refuse(orderRuleMessage('task-body-required', { channel: 'stdin' }));
|
|
70
74
|
try {
|
|
71
75
|
return { ...parseTaskDocument(parsed.body), header: parsed };
|
|
72
76
|
} catch (err) {
|
|
@@ -94,22 +98,18 @@ export function parseArgs(argv) {
|
|
|
94
98
|
const opts = {};
|
|
95
99
|
for (let i = 0; i < argv.length; i += 1) {
|
|
96
100
|
const key = argv[i];
|
|
97
|
-
if (!key.startsWith('--')) die(
|
|
101
|
+
if (!key.startsWith('--')) die(orderDiagnosticMessage('unexpected-argument', { value: key }));
|
|
98
102
|
const name = key.slice(2);
|
|
99
103
|
if (!['agent', 'task-file', 'no-wait'].includes(name)) {
|
|
100
104
|
const agent = opts.agent ?? argv[argv.indexOf('--agent') + 1];
|
|
101
105
|
const help = Object.hasOwn(AGENTS, agent) ? renderOrderHeaderHelp(agent) : '';
|
|
102
|
-
die(
|
|
103
|
-
'the order belongs in the task-file header' + (help ? `\n${help}` : ''));
|
|
106
|
+
die(orderDiagnosticMessage('unknown-flag', { name, help }));
|
|
104
107
|
}
|
|
105
108
|
const value = argv[i + 1];
|
|
106
109
|
if (BOOLEAN_FLAGS.has(name)) {
|
|
107
110
|
if (value !== undefined && !value.startsWith('--')) {
|
|
108
111
|
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
|
-
);
|
|
112
|
+
die(orderDiagnosticMessage('boolean-flag-value', { name, value }));
|
|
113
113
|
}
|
|
114
114
|
opts[name] = BOOLEAN_YES.test(value);
|
|
115
115
|
i += 1;
|
|
@@ -119,7 +119,7 @@ export function parseArgs(argv) {
|
|
|
119
119
|
continue;
|
|
120
120
|
}
|
|
121
121
|
// A flag name where a value belongs means the value was left out.
|
|
122
|
-
if (value === undefined || value.startsWith('--')) die(
|
|
122
|
+
if (value === undefined || value.startsWith('--')) die(orderDiagnosticMessage('missing-flag-value', { key }));
|
|
123
123
|
opts[name] = value;
|
|
124
124
|
i += 1;
|
|
125
125
|
}
|
|
@@ -127,14 +127,11 @@ export function parseArgs(argv) {
|
|
|
127
127
|
if (opts[name] === undefined) continue;
|
|
128
128
|
const sequence = firstShellUnsafeSequence(opts[name]);
|
|
129
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
|
-
);
|
|
130
|
+
die(orderDiagnosticMessage('flag-shell-safety', { name, sequence }));
|
|
134
131
|
}
|
|
135
132
|
}
|
|
136
|
-
if (!opts.agent) die('
|
|
137
|
-
if (!AGENTS[opts.agent]) die(
|
|
133
|
+
if (!opts.agent) die(orderDiagnosticMessage('agent-required'));
|
|
134
|
+
if (!AGENTS[opts.agent]) die(orderDiagnosticMessage('agent-known', { agent: opts.agent }));
|
|
138
135
|
opts.noWait = Boolean(opts['no-wait']);
|
|
139
136
|
return opts;
|
|
140
137
|
}
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { resolveCommandOnPath } from '../command-path.mjs';
|
|
6
6
|
import { codexSpawnSpec, spawnCaptured } from './codex-cmd.mjs';
|
|
7
|
+
import { orderDiagnosticMessage } from '../order-diagnostics.mjs';
|
|
7
8
|
|
|
8
9
|
// A signed-in answer takes ~200 ms; ten seconds bounds a hung CLI without delaying a healthy start.
|
|
9
10
|
export const PROBE_TIMEOUT_MS = 10_000;
|
|
@@ -13,9 +14,10 @@ function lines(text) {
|
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
function failureDetail(result, command) {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
const message = lines(result.stderr).at(-1) || lines(result.error?.message).at(-1);
|
|
18
|
+
return orderDiagnosticMessage('codex-availability-detail', message
|
|
19
|
+
? { variant: 'message', message }
|
|
20
|
+
: { variant: 'exit-status', command, status: result.status });
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
export async function probeCodexAvailability({
|
|
@@ -31,7 +33,7 @@ export async function probeCodexAvailability({
|
|
|
31
33
|
if (resolve('codex', env) === null) {
|
|
32
34
|
return {
|
|
33
35
|
state: 'inconclusive', pathMiss: true,
|
|
34
|
-
detail: 'codex
|
|
36
|
+
detail: orderDiagnosticMessage('codex-availability-detail', { variant: 'path-miss' }),
|
|
35
37
|
};
|
|
36
38
|
}
|
|
37
39
|
}
|
|
@@ -50,10 +52,12 @@ export async function probeCodexAvailability({
|
|
|
50
52
|
}
|
|
51
53
|
if (login.status === 0) return { state: 'available' };
|
|
52
54
|
if (login.status === 1 && [...lines(login.stderr), ...lines(login.stdout)].includes('Not logged in')) {
|
|
53
|
-
return { state: 'logged-out', detail: 'codex
|
|
55
|
+
return { state: 'logged-out', detail: orderDiagnosticMessage('codex-availability-detail', { variant: 'logged-out' }) };
|
|
54
56
|
}
|
|
55
57
|
return { state: 'inconclusive', detail: failureDetail(login, 'codex login status') };
|
|
56
58
|
} catch (error) {
|
|
57
|
-
|
|
59
|
+
const message = lines(error?.message).at(-1);
|
|
60
|
+
return { state: 'inconclusive', detail: orderDiagnosticMessage('codex-availability-detail', message
|
|
61
|
+
? { variant: 'message', message } : { variant: 'failed' }) };
|
|
58
62
|
}
|
|
59
63
|
}
|
|
@@ -1,174 +1,158 @@
|
|
|
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_ORDER_INPUT, orderInputName } from '../order-schema.mjs';
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
return null;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
}
|
|
72
|
-
return
|
|
73
|
-
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
const last = chain[chain.length - 1];
|
|
104
|
-
// Continuing the last run appends a later run, so the old grant stops matching by itself; this
|
|
105
|
-
// incident needs no counter or new state to make an orchestrator grant single-use.
|
|
106
|
-
if (!last || !sameRun(runsRootPath, continuation.run, last)) {
|
|
107
|
-
return (
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
);
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
return (
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
const
|
|
135
|
-
if (!
|
|
136
|
-
return (
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
if (
|
|
155
|
-
return refuse(
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
const previousOrder = String(status?.order_id ?? '').trim();
|
|
160
|
-
if (!previousOrder || previousOrder !== wanted) {
|
|
161
|
-
return refuse(`Retry is refused: run ${grant.run} order_id ${previousOrder || '(missing)'} differs from order ${wanted}. ` +
|
|
162
|
-
'A retry repeats its own order; a run without order_id predates orders — start a new order.');
|
|
163
|
-
}
|
|
164
|
-
const meta = readJson(path.join(runDir, 'meta.json'));
|
|
165
|
-
// Plan_75 D1: a verdict alone must not let a retry overlap the failed advise's live writer.
|
|
166
|
-
if (!meta?.status || liveness({ runDir, status })) {
|
|
167
|
-
return refuse(`Retry is refused: run ${grant.run} has no finished verdict or its worker may still be alive and writing. ` +
|
|
168
|
-
`Repeat without ${orderInputName('retry')} to attach to it.`);
|
|
169
|
-
}
|
|
170
|
-
if (meta.status === 'OK') {
|
|
171
|
-
return refuse(`Retry is refused: run ${grant.run} ended OK. A retry repeats a failed pass; use continue: for the next pass.`);
|
|
172
|
-
}
|
|
173
|
-
return null;
|
|
174
|
-
}
|
|
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_ORDER_INPUT, orderInputName } from '../order-schema.mjs';
|
|
10
|
+
import { orderRuleMessage } from '../order-rules.mjs';
|
|
11
|
+
import { orderDiagnosticMessage } from '../order-diagnostics.mjs';
|
|
12
|
+
import { readJson } from '../write-meta.mjs';
|
|
13
|
+
import { workerMayBeAlive } from '../meta/run-liveness.mjs';
|
|
14
|
+
|
|
15
|
+
// One comparison of run names for the grant gate and for attach, so a repeat and a grant can never
|
|
16
|
+
// disagree about whether two spellings name the same folder.
|
|
17
|
+
export const sameRun = (runsRootPath, left, right) => {
|
|
18
|
+
const a = path.resolve(runsRootPath, left);
|
|
19
|
+
const b = path.resolve(runsRootPath, right);
|
|
20
|
+
return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function namedRunDirectory(runsRootPath, run) {
|
|
24
|
+
if (!run || run === '.' || run === '..' || path.basename(run) !== run) return null;
|
|
25
|
+
const directory = path.join(runsRootPath, run);
|
|
26
|
+
try {
|
|
27
|
+
return fs.statSync(directory).isDirectory() ? directory : null;
|
|
28
|
+
} catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function normalizeGrant(grant) {
|
|
34
|
+
if (!grant || typeof grant !== 'object') return null;
|
|
35
|
+
const run = String(grant.run ?? '').trim();
|
|
36
|
+
const reason = String(grant.reason ?? '').trim();
|
|
37
|
+
return run && reason ? { run, reason } : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// The 2026-08-10_220535_plan25-2-install-table-two-roots incident showed that a refusal must
|
|
41
|
+
// carry the repair line; otherwise a typo forces an unnecessary directory listing and retry.
|
|
42
|
+
function lastRunOutcome(runsRootPath, chain) {
|
|
43
|
+
const run = chain[chain.length - 1];
|
|
44
|
+
if (!run) return null;
|
|
45
|
+
const status = readJson(path.join(runsRootPath, run, 'status.json')) || {};
|
|
46
|
+
const meta = readJson(path.join(runsRootPath, run, 'meta.json')) || {};
|
|
47
|
+
const outcomeStatus = String(meta.status ?? status.status ?? status.state ?? 'unknown').trim() || 'unknown';
|
|
48
|
+
const outcomeReason = String(meta.reason ?? status.reason ?? 'reason not recorded').trim() || 'reason not recorded';
|
|
49
|
+
return { run, status: outcomeStatus, reason: outcomeReason, verdict: meta.status, orderId: status.order_id };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function readyGrantLines(runsRootPath, chain, grantReason) {
|
|
53
|
+
const last = lastRunOutcome(runsRootPath, chain);
|
|
54
|
+
if (!last) return orderRuleMessage('ready-grant-line', { variant: 'none' });
|
|
55
|
+
const reason = String(grantReason ?? last.reason).trim() || last.reason;
|
|
56
|
+
const continuation = orderRuleMessage('ready-grant-line', { variant: 'continue', run: last.run, reason });
|
|
57
|
+
// Plan_75 D1: the TradeForge failed advise spent its continuation, so show the same-pass repair.
|
|
58
|
+
// A run without order_id cannot be retried (retryRefusal step d), so it gets no retry line.
|
|
59
|
+
const orderId = String(last.orderId ?? '').trim();
|
|
60
|
+
if (!last.verdict || last.verdict === 'OK' || !orderId) return continuation;
|
|
61
|
+
return [continuation, orderRuleMessage('ready-grant-line', {
|
|
62
|
+
variant: 'retry', run: last.run, reason: last.reason, orderId,
|
|
63
|
+
})].join(' ');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function lastRunHints(runsRootPath, chain, grantReason) {
|
|
67
|
+
const last = lastRunOutcome(runsRootPath, chain);
|
|
68
|
+
if (!last) {
|
|
69
|
+
return [orderRuleMessage('grant-last-run-hint', { variant: 'none' }),
|
|
70
|
+
orderRuleMessage('ready-grant-line', { variant: 'none' })].join(' ');
|
|
71
|
+
}
|
|
72
|
+
return [orderRuleMessage('grant-last-run-hint', {
|
|
73
|
+
variant: 'last', run: last.run, status: last.status, reason: last.reason,
|
|
74
|
+
}), readyGrantLines(runsRootPath, chain, grantReason)].join(' ');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Applies the one-continuation limit and all continuation safety gates before a folder is created.
|
|
79
|
+
*
|
|
80
|
+
* The limit is counted over the runs carrying THIS order id, not over the whole chain. The chain
|
|
81
|
+
* also ties runs together by slug and by the fingerprint of the task text, which is what catches a
|
|
82
|
+
* repeat that renamed itself — but counting continuations that way makes the escape hatch
|
|
83
|
+
* unreachable: the operator's rule is "more passes than one need a new order id", and a new order
|
|
84
|
+
* id lands in the same chain through the task hash. A task would then be refused with a grant
|
|
85
|
+
* for having spent its continuation and refused without one for already having runs — permanently
|
|
86
|
+
* unrunnable, which is a worse failure than the retry storm this limit exists to stop.
|
|
87
|
+
*/
|
|
88
|
+
export function continuationRefusal(runsRootPath, chain, isContinue, orderId, grant) {
|
|
89
|
+
const continuation = normalizeGrant(grant);
|
|
90
|
+
// Plan_63 D9: only header grant objects authorize a pass; a string is no grant.
|
|
91
|
+
if (!isContinue || !continuation) return null;
|
|
92
|
+
|
|
93
|
+
const refuse = (violation, values) => [orderRuleMessage('continuation-grant-usable', {
|
|
94
|
+
violation, labelName: orderInputName('continue'), run: continuation.run,
|
|
95
|
+
hints: lastRunHints(runsRootPath, chain, continuation.reason), ...values,
|
|
96
|
+
}), orderDiagnosticMessage('run-not-created')].join(' ');
|
|
97
|
+
|
|
98
|
+
const namedDirectory = namedRunDirectory(runsRootPath, continuation.run);
|
|
99
|
+
if (!namedDirectory) {
|
|
100
|
+
return refuse('missing-folder', { runsRoot: runsRootPath, example: CONTINUATION_ORDER_INPUT.example });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const last = chain[chain.length - 1];
|
|
104
|
+
// Continuing the last run appends a later run, so the old grant stops matching by itself; this
|
|
105
|
+
// incident needs no counter or new state to make an orchestrator grant single-use.
|
|
106
|
+
if (!last || !sameRun(runsRootPath, continuation.run, last)) {
|
|
107
|
+
return refuse('not-last', { lastRun: last || 'none', example: CONTINUATION_ORDER_INPUT.example });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const wanted = String(orderId ?? '').trim();
|
|
111
|
+
// Plan_75 D1, TradeForge capacity incident: a retry spends no new pass, even after an OK verdict.
|
|
112
|
+
const ofThisOrder = chain.filter((run) => {
|
|
113
|
+
const status = readJson(path.join(runsRootPath, run, 'status.json'));
|
|
114
|
+
return String(status?.order_id ?? '').trim() === wanted && !String(status?.retry_of ?? '').trim();
|
|
115
|
+
});
|
|
116
|
+
if (ofThisOrder.length === 0) return null;
|
|
117
|
+
if (ofThisOrder.length > 1) {
|
|
118
|
+
const spent = ofThisOrder.map((run) => path.join(runsRootPath, run)).join(', ');
|
|
119
|
+
return refuse('spent', { orderId: wanted, spent });
|
|
120
|
+
}
|
|
121
|
+
const previous = path.join(runsRootPath, ofThisOrder[0]);
|
|
122
|
+
const status = readJson(path.join(previous, 'status.json'));
|
|
123
|
+
const meta = readJson(path.join(previous, 'meta.json'));
|
|
124
|
+
if (!meta?.status || status?.state === 'running') {
|
|
125
|
+
return refuse('unfinished', { previous });
|
|
126
|
+
}
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function retryRefusal(runsRootPath, chain, isContinue, orderId, grant, liveness = workerMayBeAlive) {
|
|
131
|
+
const refuse = (violation, values) => [orderRuleMessage('retry-grant-usable', {
|
|
132
|
+
violation, run: grant?.run || 'none', ...values,
|
|
133
|
+
}), orderDiagnosticMessage('run-not-created')].join(' ');
|
|
134
|
+
const runDir = namedRunDirectory(runsRootPath, grant?.run);
|
|
135
|
+
if (!runDir) {
|
|
136
|
+
return refuse('missing-folder', { runsRoot: runsRootPath });
|
|
137
|
+
}
|
|
138
|
+
const last = chain[chain.length - 1];
|
|
139
|
+
// Plan_75 D1: appending the TradeForge retry makes this grant single-use without a new counter.
|
|
140
|
+
if (!last || !sameRun(runsRootPath, grant.run, last)) {
|
|
141
|
+
return refuse('not-last', {});
|
|
142
|
+
}
|
|
143
|
+
const status = readJson(path.join(runDir, 'status.json'));
|
|
144
|
+
const wanted = String(orderId ?? '').trim();
|
|
145
|
+
const previousOrder = String(status?.order_id ?? '').trim();
|
|
146
|
+
if (!previousOrder || previousOrder !== wanted) {
|
|
147
|
+
return refuse('order-mismatch', { previousOrder: previousOrder || '(missing)', orderId: wanted });
|
|
148
|
+
}
|
|
149
|
+
const meta = readJson(path.join(runDir, 'meta.json'));
|
|
150
|
+
// Plan_75 D1: a verdict alone must not let a retry overlap the failed advise's live writer.
|
|
151
|
+
if (!meta?.status || liveness({ runDir, status })) {
|
|
152
|
+
return refuse('unfinished', { labelName: orderInputName('retry') });
|
|
153
|
+
}
|
|
154
|
+
if (meta.status === 'OK') {
|
|
155
|
+
return refuse('ended-ok', {});
|
|
156
|
+
}
|
|
157
|
+
return null;
|
|
158
|
+
}
|