@lyupro/codex-bridge 0.6.8 → 0.6.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +160 -0
- package/README.md +6 -4
- package/bin/codex-bridge.mjs +35 -177
- package/cli/command-help.mjs +49 -0
- package/cli/command-registry.mjs +284 -0
- package/cli/copy.mjs +42 -5
- package/cli/dispatcher-contract-record.mjs +3 -2
- package/cli/doctor-host-side.mjs +110 -0
- package/cli/doctor.mjs +17 -1
- package/cli/home-inspection.mjs +122 -0
- package/cli/home-plan-execute.mjs +140 -0
- package/cli/home-removal-plan.mjs +61 -0
- package/cli/hook-recognizer.mjs +78 -0
- package/cli/host-contract.mjs +11 -5
- package/cli/host-inspection.mjs +156 -0
- package/cli/host-removal.mjs +91 -0
- package/cli/image-evidence.mjs +44 -0
- package/cli/install-owners.mjs +244 -0
- package/cli/install-record.mjs +71 -9
- package/cli/install.mjs +88 -75
- package/cli/inventory-removal.mjs +84 -0
- package/cli/inventory-transition.mjs +66 -0
- package/cli/lifecycle-lock-clear.mjs +104 -0
- package/cli/lifecycle-lock-inspect.mjs +92 -0
- package/cli/lifecycle-lock.mjs +222 -0
- package/cli/lifecycle-transaction.mjs +57 -0
- package/cli/link-segments.mjs +45 -0
- package/cli/model-set.mjs +4 -3
- package/cli/model-speed.mjs +9 -2
- package/cli/model.mjs +5 -3
- package/cli/owner-record.mjs +40 -0
- package/cli/owner-sync-apply.mjs +108 -0
- package/cli/owner-sync.mjs +115 -0
- package/cli/projects.mjs +5 -2
- package/cli/prune-args.mjs +9 -4
- package/cli/prune.mjs +23 -72
- package/cli/purge-live-runs.mjs +98 -0
- package/cli/purge-preflight.mjs +103 -0
- package/cli/read.mjs +22 -1
- package/cli/record-removal.mjs +43 -0
- package/cli/removal-outcomes.mjs +115 -0
- package/cli/removal-plan.mjs +187 -0
- package/cli/remove-layout.mjs +40 -1
- package/cli/rules-owners.mjs +4 -2
- package/cli/settings-merge.mjs +3 -29
- package/cli/terminal-question.mjs +41 -0
- package/cli/uninstall-purge.mjs +139 -0
- package/cli/uninstall-rules.mjs +72 -0
- package/cli/uninstall.mjs +208 -127
- package/cli/unlock-lifecycle.mjs +102 -0
- package/cli/unlock.mjs +7 -4
- package/cli/update.mjs +102 -27
- package/package.json +4 -3
- package/scripts/home-write-audit.mjs +286 -0
- package/scripts/home-write-inventory.mjs +59 -0
- package/scripts/home-write-lexer.mjs +182 -0
- package/scripts/isolated-roots.mjs +6 -1
- package/scripts/open-work/check.mjs +180 -0
- package/scripts/open-work/live-thresholds.mjs +155 -0
- package/scripts/open-work/register.mjs +56 -0
- package/scripts/root-guard/root-entries.mjs +15 -0
- package/scripts/run-tests.mjs +26 -1
- package/src/agents/codex-advisor.md +23 -10
- package/src/agents/codex-build.md +24 -14
- package/src/agents/codex-review.md +17 -10
- package/src/agents/codex-scout.md +22 -12
- package/src/home/conventions.md +9 -0
- package/src/home/hooks/guard-tries.mjs +6 -4
- package/src/home/hooks/order-gate.mjs +27 -19
- package/src/home/hooks/reply-guard.mjs +2 -7
- package/src/home/hooks/reply-parser.mjs +1 -1
- package/src/home/hooks/worktree-witness.mjs +6 -6
- package/src/home/lib/agents.mjs +1 -1
- package/src/home/lib/atomic-json.mjs +24 -6
- package/src/home/lib/command-path.mjs +32 -0
- package/src/home/lib/config-edit.mjs +25 -10
- package/src/home/lib/dispatcher-command.mjs +4 -2
- package/src/home/lib/dispatcher-state.mjs +25 -10
- package/src/home/lib/file-lock.mjs +41 -10
- package/src/home/lib/handback-witness.mjs +7 -4
- package/src/home/lib/home-registry.mjs +211 -0
- package/src/home/lib/home-write.mjs +189 -0
- package/src/home/lib/hook-diagnostics.mjs +20 -0
- package/src/home/lib/host-observations.mjs +7 -4
- package/src/home/lib/kernel-lock.mjs +171 -0
- package/src/home/lib/meta/advice-status.mjs +2 -2
- package/src/home/lib/meta/advice-verdict.mjs +30 -1
- package/src/home/lib/meta/chain.mjs +19 -8
- package/src/home/lib/meta/context-only.mjs +43 -0
- package/src/home/lib/meta/events.mjs +16 -3
- package/src/home/lib/meta/launch-rows.mjs +23 -0
- package/src/home/lib/meta/paths.mjs +24 -24
- package/src/home/lib/meta/reply.mjs +37 -3
- package/src/home/lib/meta/run-snapshots.mjs +29 -0
- package/src/home/lib/meta/run-state.mjs +3 -0
- package/src/home/lib/meta/snapshot-format.mjs +96 -0
- package/src/home/lib/meta/verdict.mjs +30 -24
- package/src/home/lib/required-inputs.mjs +48 -8
- package/src/home/lib/run-codex.mjs +6 -3
- package/src/home/lib/run-config.mjs +10 -1
- package/src/home/lib/runner/advise-carry.mjs +2 -1
- package/src/home/lib/runner/args.mjs +16 -5
- package/src/home/lib/runner/attach.mjs +21 -14
- package/src/home/lib/runner/build-evidence.mjs +29 -0
- package/src/home/lib/runner/codex-availability.mjs +50 -0
- package/src/home/lib/runner/codex-cmd.mjs +2 -19
- package/src/home/lib/runner/continuation.mjs +60 -8
- package/src/home/lib/runner/exit-codes.mjs +13 -0
- package/src/home/lib/runner/flag-baseline.mjs +105 -0
- package/src/home/lib/runner/flag-scan.mjs +165 -0
- package/src/home/lib/runner/git-paths.mjs +74 -0
- package/src/home/lib/runner/git-state.mjs +37 -66
- package/src/home/lib/runner/launcher.mjs +233 -273
- package/src/home/lib/runner/order-claim.mjs +84 -0
- package/src/home/lib/runner/order-owner.mjs +18 -0
- package/src/home/lib/runner/pass-gate.mjs +109 -0
- package/src/home/lib/runner/preflight.mjs +22 -18
- package/src/home/lib/runner/prompts.mjs +42 -4
- package/src/home/lib/runner/question-kind.mjs +41 -0
- package/src/home/lib/runner/run-env.mjs +2 -1
- package/src/home/lib/runner/sandbox-probe.mjs +1 -1
- package/src/home/lib/runner/schemas.mjs +4 -1
- package/src/home/lib/runner/scope-check.mjs +55 -28
- package/src/home/lib/runner/task-input.mjs +11 -2
- package/src/home/lib/runner/worker-admission.mjs +43 -0
- package/src/home/lib/runner/worker.mjs +19 -18
- package/src/home/lib/task-header.mjs +127 -0
- package/src/home/lib/write-meta.mjs +17 -5
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
/** Owns the scope snapshot shared by advise readers because run 2026-09-23_141341_plan59-a1-stdin-hang left them with different inputs. */
|
|
2
|
+
import { EXIT } from './exit-codes.mjs';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import { readJsonFileSync } from '../json-file.mjs';
|
|
4
5
|
import { parseAdvisorTask } from '../meta/advisor-task.mjs';
|
|
@@ -32,7 +33,7 @@ export function advisorTaskOrRefuse(input) {
|
|
|
32
33
|
try {
|
|
33
34
|
return advisorTaskArtifact(input);
|
|
34
35
|
} catch (error) {
|
|
35
|
-
return die(`${error.message} The run folder was not created; quota was not spent.`,
|
|
36
|
+
return die(`${error.message} The run folder was not created; quota was not spent.`, EXIT.FAIL);
|
|
36
37
|
}
|
|
37
38
|
}
|
|
38
39
|
|
|
@@ -5,24 +5,26 @@
|
|
|
5
5
|
* else's quota is touched, and every refusal is an exit code rather than a message: a
|
|
6
6
|
* dispatcher branches on it.
|
|
7
7
|
*/
|
|
8
|
+
import { EXIT } from './exit-codes.mjs';
|
|
8
9
|
import fs from 'node:fs';
|
|
9
10
|
import path from 'node:path';
|
|
10
11
|
import { AGENTS } from '../write-meta.mjs';
|
|
11
12
|
import { isAbsoluteTaskFilePath, requiredInputsFor } from '../required-inputs.mjs';
|
|
12
13
|
import { firstShellUnsafeSequence } from '../shell-unsafe.mjs';
|
|
13
14
|
import { parseTaskDocument } from './task-file.mjs';
|
|
15
|
+
import { parseTaskHeader, taskHeaderRefusal } from '../task-header.mjs';
|
|
14
16
|
|
|
15
17
|
export class RunnerUsageError extends Error {
|
|
16
18
|
// 2 says the order itself is wrong and has to be rewritten. A refusal about the state of the
|
|
17
19
|
// host or the worktree passes 1: the order was fine, nothing about it needs changing, and
|
|
18
20
|
// Plan_58 moved two such refusals here from a code path that already answered 1.
|
|
19
|
-
constructor(message, exitCode =
|
|
21
|
+
constructor(message, exitCode = EXIT.USAGE) {
|
|
20
22
|
super(message);
|
|
21
23
|
this.exitCode = exitCode;
|
|
22
24
|
}
|
|
23
25
|
}
|
|
24
26
|
|
|
25
|
-
export function die(message, exitCode =
|
|
27
|
+
export function die(message, exitCode = EXIT.USAGE) {
|
|
26
28
|
console.error(`run-codex: ${message}`);
|
|
27
29
|
throw new RunnerUsageError(message, exitCode);
|
|
28
30
|
}
|
|
@@ -46,9 +48,14 @@ export function readTaskDocument(opts) {
|
|
|
46
48
|
die(`task file from --task-file could not be read: ${err.message}`);
|
|
47
49
|
}
|
|
48
50
|
if (!fileText.trim()) die(`task file from --task-file is empty: ${taskFile}`);
|
|
51
|
+
// Plan_75 D5, 2026-10-03 20:42: validate raw metadata before sections can hide a grant.
|
|
52
|
+
const parsed = parseTaskHeader(fileText);
|
|
53
|
+
const refusal = taskHeaderRefusal(parsed);
|
|
54
|
+
if (refusal) die(`${taskFile}: ${refusal}`);
|
|
55
|
+
if (!parsed.body.trim()) die(`task file from --task-file is empty: ${taskFile}`);
|
|
49
56
|
let document;
|
|
50
57
|
try {
|
|
51
|
-
document = parseTaskDocument(
|
|
58
|
+
document = parseTaskDocument(parsed.body);
|
|
52
59
|
} catch (err) {
|
|
53
60
|
die(`${taskFile}: ${err.message}`);
|
|
54
61
|
}
|
|
@@ -58,11 +65,15 @@ export function readTaskDocument(opts) {
|
|
|
58
65
|
if (document.verify !== undefined && opts.verify !== undefined) {
|
|
59
66
|
die('verification command was supplied through both --verify and the task file; choose exactly one channel');
|
|
60
67
|
}
|
|
61
|
-
return document;
|
|
68
|
+
return { ...document, header: parsed };
|
|
62
69
|
}
|
|
63
70
|
if (!stdinText.trim()) die('task text on stdin is empty');
|
|
71
|
+
const parsed = parseTaskHeader(stdinText);
|
|
72
|
+
const refusal = taskHeaderRefusal(parsed);
|
|
73
|
+
if (refusal) die(refusal);
|
|
74
|
+
if (!parsed.body.trim()) die('task text on stdin is empty');
|
|
64
75
|
try {
|
|
65
|
-
return parseTaskDocument(
|
|
76
|
+
return { ...parseTaskDocument(parsed.body), header: parsed };
|
|
66
77
|
} catch (err) {
|
|
67
78
|
die(err.message);
|
|
68
79
|
}
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
/** Finds a live run for an order and waits for its verdict when a repeated launcher call attaches. */
|
|
2
|
+
import { EXIT } from './exit-codes.mjs';
|
|
2
3
|
import fs from 'node:fs';
|
|
3
4
|
import path from 'node:path';
|
|
4
5
|
import { readJsonFileSync } from '../json-file.mjs';
|
|
5
6
|
import { workerMayBeAlive } from '../meta/run-liveness.mjs';
|
|
6
7
|
import { processAlive } from '../process-identity.mjs';
|
|
7
8
|
import { exitCodeFor, writeFailure, chainRuns } from '../write-meta.mjs';
|
|
8
|
-
import { sameRun } from './continuation.mjs';
|
|
9
|
-
import { conflictingOrderOwner, runsForOrder } from './order-owner.mjs';
|
|
9
|
+
import { readyGrantLines, sameRun } from './continuation.mjs';
|
|
10
|
+
import { conflictingOrderOwner, orderOwnerConflictText, runsForOrder } from './order-owner.mjs';
|
|
10
11
|
|
|
11
12
|
// The attach call is the only process that waits for a worker it did not spawn. Keeping that
|
|
12
13
|
// wait here leaves the launcher free to return before a caller's time ceiling kills it.
|
|
@@ -97,26 +98,26 @@ export async function waitForReply(runDir, workerPid, status) {
|
|
|
97
98
|
* reason. `--continue` may attach to a run created from its grant: the 2026-09-23 dispatcher
|
|
98
99
|
* incident showed that repeating the identical continuation command must return to that run.
|
|
99
100
|
*/
|
|
100
|
-
export async function attach({
|
|
101
|
+
export async function attach({
|
|
102
|
+
runsRoot, repo, slug, taskHash, orderId, chain, grantRun, retryRun, isContinue, noWait, beforeWait,
|
|
103
|
+
} = {}) {
|
|
101
104
|
const runs = chain || chainRuns(runsRoot, repo, slug, taskHash, orderId);
|
|
102
105
|
const runRecords = runs
|
|
103
106
|
.map((run) => ({ run, status: readJsonFile(path.join(runsRoot, run, 'status.json')) }))
|
|
104
107
|
.filter(({ status }) => status);
|
|
105
108
|
const sameOrder = runsForOrder(runRecords, orderId);
|
|
106
109
|
const attachRuns = isContinue
|
|
107
|
-
? sameOrder.filter(({ status }) =>
|
|
110
|
+
? sameOrder.filter(({ status }) => retryRun
|
|
111
|
+
? status.retry_of && sameRun(runsRoot, status.retry_of, retryRun)
|
|
112
|
+
: grantRun && status.continued_from && sameRun(runsRoot, status.continued_from, grantRun))
|
|
108
113
|
: sameOrder;
|
|
109
114
|
if (isContinue && !attachRuns.length) return null;
|
|
110
115
|
|
|
111
116
|
const owner = isContinue ? null : conflictingOrderOwner(runRecords, orderId, taskHash);
|
|
112
117
|
if (owner) {
|
|
113
118
|
const ownerDir = path.join(runsRoot, owner.run);
|
|
114
|
-
console.log(
|
|
115
|
-
|
|
116
|
-
`(slug ${owner.status.slug}, started_at ${owner.status.started_at}) with a different task. ` +
|
|
117
|
-
'Pass a new --order-id, or pass --continue if this really is another pass of the same order.',
|
|
118
|
-
);
|
|
119
|
-
return 2;
|
|
119
|
+
console.log(orderOwnerConflictText(owner, ownerDir, orderId));
|
|
120
|
+
return EXIT.USAGE;
|
|
120
121
|
}
|
|
121
122
|
|
|
122
123
|
// The run an order is currently about is its newest one, and the chain arrives oldest first.
|
|
@@ -130,7 +131,12 @@ export async function attach({ runsRoot, repo, slug, taskHash, orderId, chain, g
|
|
|
130
131
|
if (fs.existsSync(path.join(dir, 'reply.txt'))) {
|
|
131
132
|
announceSavedReply(dir, orderId, entry.status.started_at);
|
|
132
133
|
console.log(fs.readFileSync(path.join(dir, 'reply.txt'), 'utf8').replace(/\s+$/, ''));
|
|
133
|
-
|
|
134
|
+
const meta = readJsonFile(path.join(dir, 'meta.json'));
|
|
135
|
+
// Plan_75 D1, 2026-10-03 TradeForge: a cached failure must name its current repair.
|
|
136
|
+
if (meta?.status !== 'OK' && entry.run === runs.at(-1)) {
|
|
137
|
+
console.log(readyGrantLines(runsRoot, runs));
|
|
138
|
+
}
|
|
139
|
+
return exitCodeFor(meta?.status);
|
|
134
140
|
}
|
|
135
141
|
if (workerMayBeAlive({ runDir: dir, status: entry.status })) {
|
|
136
142
|
candidate = entry;
|
|
@@ -140,7 +146,7 @@ export async function attach({ runsRoot, repo, slug, taskHash, orderId, chain, g
|
|
|
140
146
|
if (!candidate) {
|
|
141
147
|
if (!noWait) return null;
|
|
142
148
|
console.log(`No run exists for order id ${JSON.stringify(String(orderId ?? ''))}; --no-wait never starts a new run.`);
|
|
143
|
-
return
|
|
149
|
+
return EXIT.PENDING;
|
|
144
150
|
}
|
|
145
151
|
|
|
146
152
|
const runDir = path.join(runsRoot, candidate.run);
|
|
@@ -148,8 +154,9 @@ export async function attach({ runsRoot, repo, slug, taskHash, orderId, chain, g
|
|
|
148
154
|
// A distinct call outcome lets the dispatcher inspect disk without mistaking pending for failure.
|
|
149
155
|
if (noWait) {
|
|
150
156
|
announcePending(runDir, orderId, candidate.status.started_at);
|
|
151
|
-
return
|
|
157
|
+
return EXIT.PENDING;
|
|
152
158
|
}
|
|
159
|
+
await beforeWait?.();
|
|
153
160
|
announceLiveAttach(runDir, orderId, candidate.status.started_at);
|
|
154
161
|
const replyText = await waitForReply(runDir, candidate.status.pid, candidate.status);
|
|
155
162
|
if (replyText !== null) {
|
|
@@ -174,5 +181,5 @@ export async function attach({ runsRoot, repo, slug, taskHash, orderId, chain, g
|
|
|
174
181
|
'Any Codex changes remain in the tree — check them with git status',
|
|
175
182
|
]);
|
|
176
183
|
console.log(reply);
|
|
177
|
-
return
|
|
184
|
+
return EXIT.FAIL;
|
|
178
185
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Writes the evidence a build run leaves of the tree before and after it.
|
|
3
|
+
* The before half was written by the launcher and the after half by the worker, so one artifact pair was
|
|
4
|
+
* named in two files; Plan_60 D3a adds a content baseline and its comparison to both halves.
|
|
5
|
+
*/
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { git, headSha, branchName, worktreeSnapshot } from './git-state.mjs';
|
|
9
|
+
import { captureFlagBaseline } from './flag-baseline.mjs';
|
|
10
|
+
import { scanRunFlags } from './flag-scan.mjs';
|
|
11
|
+
|
|
12
|
+
export function writeBuildBefore({ runDir, repoRoot, isGitRepo }) {
|
|
13
|
+
fs.writeFileSync(path.join(runDir, 'head-before.txt'), `${isGitRepo ? headSha(repoRoot) : ''}\n`);
|
|
14
|
+
fs.writeFileSync(path.join(runDir, 'branch-before.txt'), `${isGitRepo ? branchName(repoRoot) : ''}\n`);
|
|
15
|
+
fs.writeFileSync(path.join(runDir, 'git-before.txt'), git(repoRoot, ['-c', 'core.quotepath=false', 'status', '--porcelain']).stdout || '');
|
|
16
|
+
fs.writeFileSync(path.join(runDir, 'state-before.txt'), worktreeSnapshot(repoRoot));
|
|
17
|
+
captureFlagBaseline({ runDir, repoRoot, isGitRepo });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function writeBuildAfter({ runDir, repoRoot, isGitRepo }) {
|
|
21
|
+
fs.writeFileSync(path.join(runDir, 'head-after.txt'), `${isGitRepo ? headSha(repoRoot) : ''}\n`);
|
|
22
|
+
fs.writeFileSync(path.join(runDir, 'branch-after.txt'), `${isGitRepo ? branchName(repoRoot) : ''}\n`);
|
|
23
|
+
fs.writeFileSync(path.join(runDir, 'git-after.txt'), git(repoRoot, ['-c', 'core.quotepath=false', 'status', '--porcelain']).stdout || '');
|
|
24
|
+
fs.writeFileSync(path.join(runDir, 'state-after.txt'), worktreeSnapshot(repoRoot));
|
|
25
|
+
fs.writeFileSync(path.join(runDir, 'diff.stat'), git(repoRoot, ['-c', 'core.quotepath=false', 'diff', '--stat']).stdout || '');
|
|
26
|
+
const scan = scanRunFlags({ runDir, repoRoot });
|
|
27
|
+
fs.writeFileSync(path.join(runDir, 'flags.txt'), scan.text);
|
|
28
|
+
fs.writeFileSync(path.join(runDir, 'flags-coverage.txt'), scan.coverage.gaps.map((gap) => `${gap}\n`).join(''));
|
|
29
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decides from conclusive evidence only whether Codex is missing, signed out, available, or cannot be judged.
|
|
3
|
+
* Plan_60 D2: an inconclusive probe must never become "do not retry".
|
|
4
|
+
*/
|
|
5
|
+
import { resolveCommandOnPath } from '../command-path.mjs';
|
|
6
|
+
import { codexSpawnSpec, spawnCaptured } from './codex-cmd.mjs';
|
|
7
|
+
|
|
8
|
+
// A signed-in answer takes ~200 ms; ten seconds bounds a hung CLI without delaying a healthy start.
|
|
9
|
+
export const PROBE_TIMEOUT_MS = 10_000;
|
|
10
|
+
|
|
11
|
+
function lines(text) {
|
|
12
|
+
return String(text ?? '').split(/[\r\n]+/).map((line) => line.trim()).filter(Boolean);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function failureDetail(result, command) {
|
|
16
|
+
return lines(result.stderr).at(-1)
|
|
17
|
+
|| lines(result.error?.message).at(-1)
|
|
18
|
+
|| `${command} exited ${result.status}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function probeCodexAvailability({
|
|
22
|
+
env = process.env, platform = process.platform,
|
|
23
|
+
run = spawnCaptured, resolve = resolveCommandOnPath, timeoutMs = PROBE_TIMEOUT_MS,
|
|
24
|
+
} = {}) {
|
|
25
|
+
try {
|
|
26
|
+
if (resolve('codex', env) === null) {
|
|
27
|
+
return { state: 'missing', detail: 'codex is not on PATH' };
|
|
28
|
+
}
|
|
29
|
+
const capture = (args) => {
|
|
30
|
+
const spec = codexSpawnSpec(args, platform);
|
|
31
|
+
return run(spec.command, spec.args, { ...spec.options, env, timeout: timeoutMs, encoding: 'utf8' });
|
|
32
|
+
};
|
|
33
|
+
const version = await capture(['--version']);
|
|
34
|
+
if (version.error || version.status !== 0) {
|
|
35
|
+
return { state: 'inconclusive', detail: failureDetail(version, 'codex --version') };
|
|
36
|
+
}
|
|
37
|
+
const login = await capture(['login', 'status']);
|
|
38
|
+
// Plan_60 D2: capture errors invalidate even a familiar line; only a completed probe proves sign-out.
|
|
39
|
+
if (login.error) {
|
|
40
|
+
return { state: 'inconclusive', detail: failureDetail(login, 'codex login status') };
|
|
41
|
+
}
|
|
42
|
+
if (login.status === 0) return { state: 'available' };
|
|
43
|
+
if (login.status === 1 && [...lines(login.stderr), ...lines(login.stdout)].includes('Not logged in')) {
|
|
44
|
+
return { state: 'logged-out', detail: 'codex login status: Not logged in' };
|
|
45
|
+
}
|
|
46
|
+
return { state: 'inconclusive', detail: failureDetail(login, 'codex login status') };
|
|
47
|
+
} catch (error) {
|
|
48
|
+
return { state: 'inconclusive', detail: lines(error?.message).at(-1) || 'codex availability probe failed' };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Invoking the `codex` CLI: starting it, stopping its whole tree,
|
|
3
|
-
*
|
|
2
|
+
* Invoking the `codex` CLI: starting it, stopping its whole tree, and keeping its JSONL stdout
|
|
3
|
+
* in events.jsonl while stderr stays separate. Availability belongs to codex-availability.mjs.
|
|
4
4
|
*
|
|
5
5
|
* Which flags a run gets is the neighbouring codex-args.mjs: this file starts the process and
|
|
6
6
|
* stops it, and knows nothing about what the arguments mean. The two were one module until it
|
|
@@ -336,20 +336,3 @@ export function runCodex(args, taskText, eventsPath, budgetMinutes, graceMs = ST
|
|
|
336
336
|
child.on('close', (code) => finish(code, true));
|
|
337
337
|
});
|
|
338
338
|
}
|
|
339
|
-
|
|
340
|
-
/**
|
|
341
|
-
* Codex missing or not authorised is a deterministic FAIL, never a reason to improvise.
|
|
342
|
-
* Plan_58, 2026-09-19: return a reason for refusal before registration, like the dead-sandbox
|
|
343
|
-
* probe. One no-quota question must not have two answers about whether it leaves a folder.
|
|
344
|
-
*/
|
|
345
|
-
export function codexUnavailableReason() {
|
|
346
|
-
const probe = spawnSync(
|
|
347
|
-
process.platform === 'win32' ? process.env.ComSpec || 'cmd.exe' : 'codex',
|
|
348
|
-
process.platform === 'win32' ? ['/d', '/s', '/c', 'codex --version'] : ['--version'],
|
|
349
|
-
{ encoding: 'utf8', windowsHide: true },
|
|
350
|
-
);
|
|
351
|
-
if (probe.error || probe.status !== 0) {
|
|
352
|
-
return (probe.stderr || probe.error?.message || 'codex --version is not responding').trim();
|
|
353
|
-
}
|
|
354
|
-
return null;
|
|
355
|
-
}
|
|
@@ -8,6 +8,7 @@ import fs from 'node:fs';
|
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import { CONTINUATION_INPUT, parseContinuationGrant } from '../required-inputs.mjs';
|
|
10
10
|
import { readJson } from '../write-meta.mjs';
|
|
11
|
+
import { workerMayBeAlive } from '../meta/run-liveness.mjs';
|
|
11
12
|
|
|
12
13
|
// One comparison of run names for the grant gate and for attach, so a repeat and a grant can never
|
|
13
14
|
// disagree about whether two spellings name the same folder.
|
|
@@ -48,7 +49,20 @@ function lastRunOutcome(runsRootPath, chain) {
|
|
|
48
49
|
const meta = readJson(path.join(runsRootPath, run, 'meta.json')) || {};
|
|
49
50
|
const outcomeStatus = String(meta.status ?? status.status ?? status.state ?? 'unknown').trim() || 'unknown';
|
|
50
51
|
const outcomeReason = String(meta.reason ?? status.reason ?? 'reason not recorded').trim() || 'reason not recorded';
|
|
51
|
-
return { run, status: outcomeStatus, reason: outcomeReason };
|
|
52
|
+
return { run, status: outcomeStatus, reason: outcomeReason, verdict: meta.status, orderId: status.order_id };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function readyGrantLines(runsRootPath, chain, grantReason) {
|
|
56
|
+
const last = lastRunOutcome(runsRootPath, chain);
|
|
57
|
+
if (!last) return 'Ready grant line: none (there is no run to continue).';
|
|
58
|
+
const reason = String(grantReason ?? last.reason).trim() || last.reason;
|
|
59
|
+
const continuation = `Ready grant line: continue: ${last.run} — ${reason}.`;
|
|
60
|
+
// Plan_75 D1: the TradeForge failed advise spent its continuation, so show the same-pass repair.
|
|
61
|
+
// A run without order_id cannot be retried (retryRefusal step d), so it gets no retry line.
|
|
62
|
+
const orderId = String(last.orderId ?? '').trim();
|
|
63
|
+
if (!last.verdict || last.verdict === 'OK' || !orderId) return continuation;
|
|
64
|
+
return continuation +
|
|
65
|
+
` Ready retry line: retry: ${last.run} — ${last.reason} (repeats that pass under order ${orderId}).`;
|
|
52
66
|
}
|
|
53
67
|
|
|
54
68
|
function lastRunHints(runsRootPath, chain, grantReason) {
|
|
@@ -56,9 +70,8 @@ function lastRunHints(runsRootPath, chain, grantReason) {
|
|
|
56
70
|
if (!last) {
|
|
57
71
|
return 'Last run: none. Outcome: none. Ready grant line: none (there is no run to continue).';
|
|
58
72
|
}
|
|
59
|
-
const reason = String(grantReason ?? last.reason).trim() || last.reason;
|
|
60
73
|
return `Last run: ${last.run}. Outcome: ${last.status} — ${last.reason}. ` +
|
|
61
|
-
|
|
74
|
+
readyGrantLines(runsRootPath, chain, grantReason);
|
|
62
75
|
}
|
|
63
76
|
|
|
64
77
|
/**
|
|
@@ -86,6 +99,7 @@ export function continuationRefusal(runsRootPath, chain, isContinue, orderId, gr
|
|
|
86
99
|
if (!continuation) {
|
|
87
100
|
return (
|
|
88
101
|
'--continue is refused: the orchestrator did not provide a `continue:` grant in the task text. ' +
|
|
102
|
+
`${lastRunHints(runsRootPath, chain)} ` +
|
|
89
103
|
`${grantExample} ${grantAction} The run folder was not created; quota was not spent.`
|
|
90
104
|
);
|
|
91
105
|
}
|
|
@@ -109,19 +123,23 @@ export function continuationRefusal(runsRootPath, chain, isContinue, orderId, gr
|
|
|
109
123
|
`--continue is refused: grant ${continuation.run} is not the LAST run of this task's chain; ` +
|
|
110
124
|
`the current last run is ${last || 'none'}. A continuation is single-use: continuing the ` +
|
|
111
125
|
'last run appends a later run, so the old grant stops matching by itself — no counter or new ' +
|
|
112
|
-
`state is used. ${
|
|
126
|
+
`state is used. ${lastRunHints(runsRootPath, chain, continuation.reason)} ` +
|
|
127
|
+
`${grantExample} ${grantAction} The run folder was not created; quota was not spent.`
|
|
113
128
|
);
|
|
114
129
|
}
|
|
115
130
|
|
|
116
131
|
const wanted = String(orderId ?? '').trim();
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
132
|
+
// Plan_75 D1, TradeForge capacity incident: a retry spends no new pass, even after an OK verdict.
|
|
133
|
+
const ofThisOrder = chain.filter((run) => {
|
|
134
|
+
const status = readJson(path.join(runsRootPath, run, 'status.json'));
|
|
135
|
+
return String(status?.order_id ?? '').trim() === wanted && !String(status?.retry_of ?? '').trim();
|
|
136
|
+
});
|
|
120
137
|
if (ofThisOrder.length === 0) return null;
|
|
121
138
|
if (ofThisOrder.length > 1) {
|
|
122
139
|
const spent = ofThisOrder.map((run) => path.join(runsRootPath, run)).join(', ');
|
|
123
140
|
return (
|
|
124
141
|
`--continue is refused: order “${wanted}” already spent its allowed continuation on ${spent}. ` +
|
|
142
|
+
`${lastRunHints(runsRootPath, chain, continuation.reason)} ` +
|
|
125
143
|
'A further pass needs a new order id from the orchestrator. The run folder was not ' +
|
|
126
144
|
'created; quota was not spent.'
|
|
127
145
|
);
|
|
@@ -132,9 +150,43 @@ export function continuationRefusal(runsRootPath, chain, isContinue, orderId, gr
|
|
|
132
150
|
if (!meta?.status || status?.state === 'running') {
|
|
133
151
|
return (
|
|
134
152
|
`--continue is refused: previous run ${previous} has no finished verdict and may still ` +
|
|
135
|
-
'be editing the worktree. Repeat without --continue to attach to it.
|
|
153
|
+
'be editing the worktree. Repeat without --continue to attach to it. ' +
|
|
154
|
+
`${lastRunHints(runsRootPath, chain, continuation.reason)} The run folder was ` +
|
|
136
155
|
'not created; quota was not spent.'
|
|
137
156
|
);
|
|
138
157
|
}
|
|
139
158
|
return null;
|
|
140
159
|
}
|
|
160
|
+
|
|
161
|
+
export function retryRefusal(runsRootPath, chain, isContinue, orderId, grant, liveness = workerMayBeAlive) {
|
|
162
|
+
const refuse = (message) => `${message} The run folder was not created; quota was not spent.`;
|
|
163
|
+
if (!isContinue) {
|
|
164
|
+
return refuse('--continue is required for a `retry:` grant: the orchestrator deliberately repeats the same pass.');
|
|
165
|
+
}
|
|
166
|
+
const runDir = namedRunDirectory(runsRootPath, grant?.run);
|
|
167
|
+
if (!runDir) {
|
|
168
|
+
return refuse(`Retry is refused: ${grant?.run || 'none'} is not a bare existing run folder in ${runsRootPath}.`);
|
|
169
|
+
}
|
|
170
|
+
const last = chain[chain.length - 1];
|
|
171
|
+
// Plan_75 D1: appending the TradeForge retry makes this grant single-use without a new counter.
|
|
172
|
+
if (!last || !sameRun(runsRootPath, grant.run, last)) {
|
|
173
|
+
return refuse(`Retry is refused: grant ${grant.run} is not the LAST run of this task's chain; a retry is single-use.`);
|
|
174
|
+
}
|
|
175
|
+
const status = readJson(path.join(runDir, 'status.json'));
|
|
176
|
+
const wanted = String(orderId ?? '').trim();
|
|
177
|
+
const previousOrder = String(status?.order_id ?? '').trim();
|
|
178
|
+
if (!previousOrder || previousOrder !== wanted) {
|
|
179
|
+
return refuse(`Retry is refused: run ${grant.run} order_id ${previousOrder || '(missing)'} differs from order ${wanted}. ` +
|
|
180
|
+
'A retry repeats its own order; a run without order_id predates orders — start a new order.');
|
|
181
|
+
}
|
|
182
|
+
const meta = readJson(path.join(runDir, 'meta.json'));
|
|
183
|
+
// Plan_75 D1: a verdict alone must not let a retry overlap the failed advise's live writer.
|
|
184
|
+
if (!meta?.status || liveness({ runDir, status })) {
|
|
185
|
+
return refuse(`Retry is refused: run ${grant.run} has no finished verdict or its worker may still be alive and writing. ` +
|
|
186
|
+
'Repeat without --continue to attach to it.');
|
|
187
|
+
}
|
|
188
|
+
if (meta.status === 'OK') {
|
|
189
|
+
return refuse(`Retry is refused: run ${grant.run} ended OK. A retry repeats a failed pass; use continue: for the next pass.`);
|
|
190
|
+
}
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Owns every exit code the runner returns.
|
|
3
|
+
* Plan_60 D2a: a new status took code 4, already the released --no-wait outcome, because the codes
|
|
4
|
+
* were literals scattered over six modules.
|
|
5
|
+
*/
|
|
6
|
+
export const EXIT = Object.freeze({
|
|
7
|
+
OK: 0, // the run's artifacts confirm the contract
|
|
8
|
+
FAIL: 1, // the run failed, or a pre-flight refusal: the order was right, the host or tree was not
|
|
9
|
+
USAGE: 2, // argument error: the order itself must be rewritten
|
|
10
|
+
LIMIT: 3, // ChatGPT quota exhausted; not a task failure
|
|
11
|
+
PENDING: 4, // --no-wait only: the run is still in progress, or no run exists (a call outcome, not a status)
|
|
12
|
+
UNAVAILABLE: 5, // Codex missing or signed out; hand the task to the next executor
|
|
13
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Records dirty files' start bytes so Plan_60 D3a can distinguish this run's flags from earlier uncommitted work.
|
|
3
|
+
*/
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { headSha, runsPrefixInside } from './git-state.mjs';
|
|
7
|
+
import { diffNames, listUntrackedPaths } from './git-paths.mjs';
|
|
8
|
+
import { readJsonFileSync } from '../json-file.mjs';
|
|
9
|
+
|
|
10
|
+
export const FLAG_BASELINE_VERSION = 1;
|
|
11
|
+
export const FLAG_BASELINE_MANIFEST = 'flags-baseline.json';
|
|
12
|
+
export const FLAG_BASELINE_DIR = 'flags-baseline';
|
|
13
|
+
export const FLAG_BASELINE_LIMITS = Object.freeze({
|
|
14
|
+
perFileBytes: 1024 * 1024,
|
|
15
|
+
perRunBytes: 32 * 1024 * 1024,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export function captureFlagBaseline({ runDir, repoRoot, isGitRepo, limits = FLAG_BASELINE_LIMITS }) {
|
|
19
|
+
const manifest = {
|
|
20
|
+
version: FLAG_BASELINE_VERSION,
|
|
21
|
+
head: '',
|
|
22
|
+
complete: false,
|
|
23
|
+
reason: '',
|
|
24
|
+
limits: { ...limits },
|
|
25
|
+
files: [],
|
|
26
|
+
};
|
|
27
|
+
if (!isGitRepo) {
|
|
28
|
+
manifest.reason = 'not a git repository';
|
|
29
|
+
} else {
|
|
30
|
+
const head = headSha(repoRoot);
|
|
31
|
+
// rev-parse prints the literal HEAD on an unborn branch; it is not a start commit.
|
|
32
|
+
manifest.head = head === 'HEAD' ? '' : head;
|
|
33
|
+
if (!manifest.head) {
|
|
34
|
+
manifest.reason = 'no start commit';
|
|
35
|
+
} else {
|
|
36
|
+
const dirty = diffNames(repoRoot, 'HEAD');
|
|
37
|
+
const untracked = listUntrackedPaths(repoRoot);
|
|
38
|
+
if (dirty === null || untracked === null) {
|
|
39
|
+
manifest.reason = 'git listing failed';
|
|
40
|
+
} else {
|
|
41
|
+
const tracked = new Set(dirty);
|
|
42
|
+
const skip = runsPrefixInside(repoRoot);
|
|
43
|
+
const names = [...new Set([...dirty, ...untracked])]
|
|
44
|
+
.filter((name) => !(skip && `${name}/`.startsWith(skip)))
|
|
45
|
+
.sort();
|
|
46
|
+
let total = 0;
|
|
47
|
+
for (const [index, name] of names.entries()) {
|
|
48
|
+
const entry = { path: name, tracked: tracked.has(name), state: 'unreadable' };
|
|
49
|
+
manifest.files.push(entry);
|
|
50
|
+
const full = path.join(repoRoot, name);
|
|
51
|
+
let content;
|
|
52
|
+
try {
|
|
53
|
+
const stat = fs.lstatSync(full);
|
|
54
|
+
if (!stat.isFile()) continue;
|
|
55
|
+
if (stat.size > limits.perFileBytes) {
|
|
56
|
+
entry.state = 'truncated';
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (total + stat.size > limits.perRunBytes) {
|
|
60
|
+
entry.state = 'over-run-cap';
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
content = fs.readFileSync(full);
|
|
64
|
+
} catch (error) {
|
|
65
|
+
if (error.code === 'ENOENT') entry.state = entry.tracked ? 'deleted' : 'absent';
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
// Recheck actual bytes: the file may have grown since its metadata was read.
|
|
69
|
+
if (content.length > limits.perFileBytes) {
|
|
70
|
+
entry.state = 'truncated';
|
|
71
|
+
} else if (total + content.length > limits.perRunBytes) {
|
|
72
|
+
entry.state = 'over-run-cap';
|
|
73
|
+
} else if (content.subarray(0, 8000).includes(0)) {
|
|
74
|
+
entry.state = 'binary';
|
|
75
|
+
} else {
|
|
76
|
+
const copyDir = path.join(runDir, FLAG_BASELINE_DIR);
|
|
77
|
+
fs.mkdirSync(copyDir, { recursive: true });
|
|
78
|
+
const copy = `${index}.bin`;
|
|
79
|
+
fs.writeFileSync(path.join(copyDir, copy), content);
|
|
80
|
+
Object.assign(entry, { state: 'copied', copy, bytes: content.length });
|
|
81
|
+
total += content.length;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
manifest.complete = manifest.files.every((entry) =>
|
|
85
|
+
['copied', 'deleted', 'absent'].includes(entry.state));
|
|
86
|
+
if (!manifest.complete) manifest.reason = 'some start contents are unknown';
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
fs.writeFileSync(path.join(runDir, FLAG_BASELINE_MANIFEST), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
91
|
+
return manifest;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function readFlagBaseline(runDir) {
|
|
95
|
+
try {
|
|
96
|
+
const manifest = readJsonFileSync(path.join(runDir, FLAG_BASELINE_MANIFEST));
|
|
97
|
+
return manifest?.version === FLAG_BASELINE_VERSION ? manifest : null;
|
|
98
|
+
} catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function removeFlagBaselineCopies(runDir) {
|
|
104
|
+
fs.rmSync(path.join(runDir, FLAG_BASELINE_DIR), { recursive: true, force: true });
|
|
105
|
+
}
|