@codedology/code-forge 0.3.2 → 0.4.0
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 +50 -0
- package/README.md +79 -21
- package/bin/code-forge.mjs +6 -1
- package/docs/README.md +5 -4
- package/docs/reference/blocks.json +153 -1
- package/docs/reference/config.md +8 -1
- package/package.json +4 -2
- package/schema/code-forge.schema.json +15 -4
- package/skill/SKILL.md +7 -7
- package/skill/references/adapters/claude-code.md +1 -1
- package/skill/references/adapters/solo.md +1 -1
- package/skill/references/adapters/subprocess.md +1 -1
- package/skill/references/code.md +4 -3
- package/skill/references/continuity.md +4 -0
- package/skill/references/decisions.md +5 -2
- package/skill/references/ledger.md +8 -2
- package/skill/references/plan.md +3 -3
- package/skill/references/review.md +8 -5
- package/skill/references/security.md +3 -1
- package/skill/scripts/forge +1 -1
- package/skill/templates/coder-brief.md +36 -0
- package/skill/templates/plan.md +2 -0
- package/src/cli/block.mjs +51 -8
- package/src/cli/jev.mjs +114 -17
- package/src/cli/ledger.mjs +99 -8
- package/src/cli/logs.mjs +193 -58
- package/src/cli/plan.mjs +27 -11
- package/src/cli/report.mjs +15 -1
- package/src/cli/resolve.mjs +16 -1
- package/src/cli/review-file.mjs +10 -5
- package/src/cli/run.mjs +109 -8
- package/src/config/closed-book.mjs +75 -0
- package/src/config/validate.mjs +128 -7
- package/src/decide/escalation.mjs +112 -11
- package/src/decide/questions.mjs +27 -4
- package/src/doctor/probes.mjs +48 -6
- package/src/engines/builders/claude.mjs +4 -1
- package/src/engines/builders/codex.mjs +26 -11
- package/src/engines/builders/grok.mjs +3 -3
- package/src/engines/efforts.mjs +109 -0
- package/src/engines/sentinel.mjs +34 -0
- package/src/keys/onepassword.mjs +3 -0
- package/src/ledger/report.mjs +66 -29
- package/src/ledger/spend.mjs +171 -0
- package/src/review/engine.mjs +57 -16
- package/src/review/fixloop.mjs +29 -6
- package/src/review/session-retry.mjs +69 -0
- package/src/review/triage.mjs +26 -1
- package/src/review/validate-review.mjs +6 -3
- package/src/session/author.mjs +6 -2
- package/src/session/plan-check.mjs +71 -6
- package/src/session/plan-sections.mjs +98 -0
- package/src/session/spawn.mjs +217 -8
- package/src/state/block.mjs +13 -4
- package/src/state/config-snapshot.mjs +150 -0
- package/src/state/run.mjs +86 -0
- package/src/util/breadcrumbs.mjs +145 -0
- package/src/util/error-hint.mjs +3 -2
- package/src/util/error-kind.mjs +2 -2
- package/src/util/error-log.mjs +109 -18
- package/src/util/forbidden.mjs +3 -1
- package/src/util/known-fixes.json +1 -0
- package/src/util/known-fixes.mjs +153 -0
- package/src/worker/engine.mjs +13 -8
- package/src/worker/loop.mjs +135 -39
- package/src/worker/queue.mjs +68 -7
package/src/session/spawn.mjs
CHANGED
|
@@ -22,7 +22,10 @@
|
|
|
22
22
|
* and every attempt writes one ledger row (`event: session`) with `tokens_source` `reported` when
|
|
23
23
|
* the CLI's JSON carried usage, else `estimated` (bytes / 4).
|
|
24
24
|
*
|
|
25
|
-
* A coder argv that `isForbidden` matches is refused before anything spawns (§8.4).
|
|
25
|
+
* A coder argv that `isForbidden` matches is refused before anything spawns (§8.4). A
|
|
26
|
+
* reviewer/judge/S2/author level on openai is refused too (`closed-book`, B32: Codex always has a
|
|
27
|
+
* shell); an openai fallback step of such a session is skipped with one stderr line. The opt-in
|
|
28
|
+
* `review.allow_open_book_codex: true` lifts both and builds Codex's read-only argv.
|
|
26
29
|
*
|
|
27
30
|
* `background: true` (coders, §5.2): the child is started detached with stdout/stderr going to
|
|
28
31
|
* `<run-root>/sessions/<id>/session.log`, its pid written to `<run-root>/sessions/<id>/pid.json`
|
|
@@ -36,6 +39,7 @@ import { createHash, randomBytes } from 'node:crypto';
|
|
|
36
39
|
import { closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
37
40
|
import path from 'node:path';
|
|
38
41
|
import { Ajv2020 } from 'ajv/dist/2020.js';
|
|
42
|
+
import { CODEX_CLOSED_BOOK_REFUSAL, closedBookRefused, isNoToolRole, openBookCodexAllowed } from '../config/closed-book.mjs';
|
|
39
43
|
import { resolveLevel } from '../config/known-ids.mjs';
|
|
40
44
|
import { compileSchema } from '../config/schema-compile.mjs';
|
|
41
45
|
import { buildArgv } from '../engines/builders/index.mjs';
|
|
@@ -43,7 +47,8 @@ import { removeCodexHome } from '../engines/codex-home.mjs';
|
|
|
43
47
|
import { cliNameForProvider } from '../engines/provider-cli.mjs';
|
|
44
48
|
import { parseSentinel } from '../engines/sentinel.mjs';
|
|
45
49
|
import { parseUsage } from '../engines/usage-parse.mjs';
|
|
46
|
-
import {
|
|
50
|
+
import { budgetUsdOf, checkBudget, priceSession, SPEND_UNREADABLE_MESSAGE } from '../ledger/spend.mjs';
|
|
51
|
+
import { appendRow, readAllRows } from '../ledger/write.mjs';
|
|
47
52
|
import { exec } from '../util/exec.mjs';
|
|
48
53
|
import { isForbidden, mergeForbidden } from '../util/forbidden.mjs';
|
|
49
54
|
import { readStartTime, registerPid, UNKNOWN_START_TIME } from '../util/reaper.mjs';
|
|
@@ -59,7 +64,7 @@ export const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000;
|
|
|
59
64
|
/** Unavailability reasons that move the ladder to the next step (§5.6). */
|
|
60
65
|
export const UNAVAILABLE_REASONS = Object.freeze(['cli-missing', 'login-expired', 'http-402', 'rate-limited']);
|
|
61
66
|
|
|
62
|
-
/** An error the spawner raises before anything runs (`code`: `usage`, `forbidden`). */
|
|
67
|
+
/** An error the spawner raises before anything runs (`code`: `usage`, `forbidden`, `closed-book`). */
|
|
63
68
|
export class SessionError extends Error {
|
|
64
69
|
/** @param {string} code @param {string} message */
|
|
65
70
|
constructor(code, message) {
|
|
@@ -84,6 +89,125 @@ export function runRootFor(runId, root) {
|
|
|
84
89
|
return runRoot(runId, { root });
|
|
85
90
|
}
|
|
86
91
|
|
|
92
|
+
/** How much of a timed-out session's stderr is kept (B30). */
|
|
93
|
+
export const DIAGNOSTIC_TAIL_BYTES = 4096;
|
|
94
|
+
|
|
95
|
+
/** A timed-out session's stderr over this size keeps no text at all (bounds the redact cost). */
|
|
96
|
+
export const STDERR_MAX_BYTES = 1024 * 1024;
|
|
97
|
+
|
|
98
|
+
/** How many stdout event types a timed-out session keeps. */
|
|
99
|
+
export const STDOUT_EVENTS_MAX = 20;
|
|
100
|
+
|
|
101
|
+
/** The longest event type kept. */
|
|
102
|
+
const EVENT_TYPE_MAX = 40;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Packet lines shorter than this are too common to tell apart from ordinary output, so they are
|
|
106
|
+
* NOT filtered. Accepted risk (B30): a packet line under 16 characters (a brace, `return x;`) can
|
|
107
|
+
* appear in a stderr tail; such a line carries no meaningful packet content on its own.
|
|
108
|
+
*/
|
|
109
|
+
const PACKET_LINE_MIN = 16;
|
|
110
|
+
|
|
111
|
+
/** The tail note when the packet cannot be read: no stderr text is kept at all (fail closed). */
|
|
112
|
+
export const TAIL_WITHHELD_PACKET = 'withheld: packet unreadable';
|
|
113
|
+
/** The tail note when stderr is over `STDERR_MAX_BYTES`. */
|
|
114
|
+
export const TAIL_WITHHELD_LARGE = 'stderr too large, withheld';
|
|
115
|
+
/** The tail note when the last 4 KB of stderr is one partial line. */
|
|
116
|
+
export const TAIL_WITHHELD_LONG_LINE = 'one long line, withheld';
|
|
117
|
+
|
|
118
|
+
/** @param {Buffer} buf @param {number} at @returns {number} the first character start at or after `at`. */
|
|
119
|
+
function charStart(buf, at) {
|
|
120
|
+
let i = at;
|
|
121
|
+
while (i < buf.length && (buf[i] & 0xc0) === 0x80) i += 1;
|
|
122
|
+
return i;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** @param {unknown} text @returns {string} */
|
|
126
|
+
function asText(text) {
|
|
127
|
+
return Buffer.isBuffer(text) ? text.toString('utf8') : typeof text === 'string' ? text : '';
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const PEM_BEGIN = /-----BEGIN [A-Z0-9 ]*-----/;
|
|
131
|
+
const PEM_END = /-----END [A-Z0-9 ]*-----/;
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The stderr tail of a timed-out session (B30), never packet content:
|
|
135
|
+
* 1. no packet text (unreadable) ⇒ no text: `{tail: null, note: 'withheld: packet unreadable'}`;
|
|
136
|
+
* stderr over `STDERR_MAX_BYTES` ⇒ `{tail: null, note: 'stderr too large, withheld'}`;
|
|
137
|
+
* 2. on the FULL raw text: every PEM block (`-----BEGIN` … `-----END` lines, an unterminated one
|
|
138
|
+
* to the end) and every line repeating a packet line (≥ 16 chars) is dropped;
|
|
139
|
+
* 3. the FULL remaining text goes through B0 `redact`;
|
|
140
|
+
* 4. over `maxBytes`, the last `maxBytes` bytes are kept, starting on a character, with the first
|
|
141
|
+
* (partial) line dropped; when that piece has no line break the tail is withheld.
|
|
142
|
+
* @param {string | Buffer | null | undefined} text @param {string | null} packetText @param {number} [maxBytes]
|
|
143
|
+
* @returns {{tail: string | null, note?: string}}
|
|
144
|
+
*/
|
|
145
|
+
export function stderrTail(text, packetText, maxBytes = DIAGNOSTIC_TAIL_BYTES) {
|
|
146
|
+
if (typeof packetText !== 'string') return { tail: null, note: TAIL_WITHHELD_PACKET };
|
|
147
|
+
const raw = asText(text);
|
|
148
|
+
if (raw.length === 0) return { tail: '' };
|
|
149
|
+
if (Buffer.byteLength(raw) > STDERR_MAX_BYTES) return { tail: null, note: TAIL_WITHHELD_LARGE };
|
|
150
|
+
const packetLines = [
|
|
151
|
+
...new Set(
|
|
152
|
+
packetText
|
|
153
|
+
.split('\n')
|
|
154
|
+
.map((l) => l.trim())
|
|
155
|
+
.filter((l) => l.length >= PACKET_LINE_MIN),
|
|
156
|
+
),
|
|
157
|
+
];
|
|
158
|
+
let inPem = false;
|
|
159
|
+
/** @type {string[]} */
|
|
160
|
+
const kept = [];
|
|
161
|
+
for (const line of raw.split('\n')) {
|
|
162
|
+
const t = line.trim();
|
|
163
|
+
if (inPem) {
|
|
164
|
+
if (PEM_END.test(t)) inPem = false;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (PEM_BEGIN.test(t)) {
|
|
168
|
+
inPem = !PEM_END.test(t);
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (t.length >= PACKET_LINE_MIN && packetLines.some((p) => t.includes(p))) continue;
|
|
172
|
+
kept.push(line);
|
|
173
|
+
}
|
|
174
|
+
const safe = /** @type {string} */ (redact(kept.join('\n')));
|
|
175
|
+
const buf = Buffer.from(safe, 'utf8');
|
|
176
|
+
if (buf.length <= maxBytes) return { tail: safe };
|
|
177
|
+
const from = charStart(buf, buf.length - maxBytes);
|
|
178
|
+
const piece = buf.subarray(from).toString('utf8');
|
|
179
|
+
if (buf[from - 1] === 0x0a) return { tail: piece }; // the cut fell exactly on a line start
|
|
180
|
+
const nl = piece.indexOf('\n');
|
|
181
|
+
if (nl === -1) return { tail: '', note: TAIL_WITHHELD_LONG_LINE };
|
|
182
|
+
return { tail: piece.slice(nl + 1) };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* What a timed-out session's stdout says without any of its text (B30): the last
|
|
187
|
+
* `STDOUT_EVENTS_MAX` event types (JSON lines with a string `type`, each cut to 40 characters and
|
|
188
|
+
* kept only when it is all `[a-z0-9_.-]`) and the total byte length. Non-JSON stdout gives only
|
|
189
|
+
* `stdout_bytes`. Event payloads, which can echo the packet, are never kept.
|
|
190
|
+
* @param {string | Buffer | null | undefined} text
|
|
191
|
+
* @returns {{stdout_bytes: number, stdout_events?: string[]}}
|
|
192
|
+
*/
|
|
193
|
+
export function stdoutEvents(text) {
|
|
194
|
+
const raw = asText(text);
|
|
195
|
+
const bytes = Buffer.byteLength(raw);
|
|
196
|
+
/** @type {string[]} */
|
|
197
|
+
const types = [];
|
|
198
|
+
const lines = raw.split('\n');
|
|
199
|
+
// scan from the end: stop once enough events are kept, or after a bounded number of lines
|
|
200
|
+
for (let i = lines.length - 1, scanned = 0; i >= 0 && types.length < STDOUT_EVENTS_MAX && scanned < 5000; i -= 1, scanned += 1) {
|
|
201
|
+
const line = lines[i].trim();
|
|
202
|
+
if (!line.startsWith('{')) continue;
|
|
203
|
+
const event = tryJSON(line);
|
|
204
|
+
if (!event || typeof event !== 'object' || typeof event.type !== 'string') continue;
|
|
205
|
+
const type = event.type.slice(0, EVENT_TYPE_MAX);
|
|
206
|
+
if (/^[a-z0-9_.-]+$/.test(type)) types.unshift(type);
|
|
207
|
+
}
|
|
208
|
+
return types.length > 0 ? { stdout_bytes: bytes, stdout_events: types } : { stdout_bytes: bytes };
|
|
209
|
+
}
|
|
210
|
+
|
|
87
211
|
/** @param {number} bytes */
|
|
88
212
|
export const estimateTokens = (bytes) => Math.ceil(bytes / 4);
|
|
89
213
|
|
|
@@ -317,6 +441,8 @@ function childEnv(/** @type {NodeJS.ProcessEnv} */ env) {
|
|
|
317
441
|
* @property {Partial<Record<"claude"|"codex"|"grok", string>>} [bins] - replaces argv[0] (tests: the fakes).
|
|
318
442
|
* @property {typeof exec} [exec]
|
|
319
443
|
* @property {(row: Record<string, any>) => Promise<unknown>} [writeRow]
|
|
444
|
+
* @property {() => Promise<Array<Record<string, any>>>} [readRows] - the ledger rows the
|
|
445
|
+
* `budget.usd` check reads (default: `readAllRows(opts.slug)`).
|
|
320
446
|
* @property {{write: (s: string) => unknown}} [stderr]
|
|
321
447
|
* @property {NodeJS.ProcessEnv} [env]
|
|
322
448
|
*/
|
|
@@ -432,18 +558,27 @@ export async function spawnSession(opts, deps = {}) {
|
|
|
432
558
|
const runRootDir = opts.runRoot ?? currentRunRoot();
|
|
433
559
|
if (role === 'facts' && opts.cwd !== undefined) assertFactsCwd(opts.cwd, runRootDir);
|
|
434
560
|
const writeRow = deps.writeRow ?? (opts.slug ? (/** @type {Record<string, any>} */ row) => appendRow(row, { slug: /** @type {string} */ (opts.slug) }) : null);
|
|
435
|
-
const
|
|
561
|
+
const ladder = ladderFor(resolveLevel(cfg, level), role);
|
|
562
|
+
// B32: Codex has no no-tools mode, so it runs no reviewer/judge/S2/author session unless the
|
|
563
|
+
// config opts in (`review.allow_open_book_codex: true`). Without it, step 0 on openai refuses the
|
|
564
|
+
// session and an openai fallback step is dropped from the ladder.
|
|
565
|
+
const openBook = openBookCodexAllowed(cfg); // read ONCE: the refusal and every build use it
|
|
566
|
+
if (closedBookRefused(ladder[0].provider, role, openBook)) throw new SessionError('closed-book', CODEX_CLOSED_BOOK_REFUSAL);
|
|
567
|
+
const steps = ladder.filter((step) => !closedBookRefused(step.provider, role, openBook));
|
|
568
|
+
if (steps.length < ladder.length) writeSafe(stderr, `fallback skipped: ${ladder.length - steps.length} openai step(s) — ${CODEX_CLOSED_BOOK_REFUSAL}\n`);
|
|
436
569
|
if (opts.background) steps.length = 1;
|
|
437
570
|
|
|
438
571
|
const attempts = [];
|
|
439
572
|
let rateLimitedOnce = false;
|
|
440
573
|
for (let i = 0; i < steps.length; i += 1) {
|
|
441
574
|
const step = steps[i];
|
|
575
|
+
const refused = await budgetGate(opts, deps, writeRow, stderr);
|
|
576
|
+
if (refused) return { ...refused, provider: step.provider, model: step.model, effort: step.effort ?? null, fallback_step: step.fallback_step, attempts };
|
|
442
577
|
const sessionDir = path.join(runRootDir, 'sessions', `${role}-${Date.now()}-${randomBytes(4).toString('hex')}`);
|
|
443
578
|
mkdirSync(sessionDir, { recursive: true, mode: 0o700 });
|
|
444
579
|
let keep = false;
|
|
445
580
|
try {
|
|
446
|
-
const built = buildStep(opts, step, sessionDir);
|
|
581
|
+
const built = buildStep(opts, step, sessionDir, openBook);
|
|
447
582
|
const cli = built.cli;
|
|
448
583
|
built.argv[0] = deps.bins?.[cli] ?? built.argv[0];
|
|
449
584
|
if (role === 'coder') {
|
|
@@ -458,7 +593,8 @@ export async function spawnSession(opts, deps = {}) {
|
|
|
458
593
|
// B4.1: the builder's env (the Codex coder's CODEX_HOME) wins over the caller's; a
|
|
459
594
|
// background session's home stays until the run root is swept.
|
|
460
595
|
const bg = startBackground(built, sessionDir, runRootDir, { ...childEnv(deps.env ?? process.env), ...(built.env ?? {}) });
|
|
461
|
-
|
|
596
|
+
// a detached child's tokens are never read back: its price is unknown (use `ledger add coder`)
|
|
597
|
+
if (writeRow) await writeRow({ event: 'session.background', ...base, pid: bg.pid, status: 'started', usd: null, usd_unknown: true });
|
|
462
598
|
return { status: 'started', ...bg, provider: step.provider, model: step.model, effort: step.effort ?? null, fallback_step: step.fallback_step, attempts };
|
|
463
599
|
}
|
|
464
600
|
|
|
@@ -471,6 +607,7 @@ export async function spawnSession(opts, deps = {}) {
|
|
|
471
607
|
tokens_in: attempt.usage.tokens_in,
|
|
472
608
|
tokens_out: attempt.usage.tokens_out,
|
|
473
609
|
tokens_source: attempt.usage.tokens_source,
|
|
610
|
+
...sessionUsd(step.provider, level, attempt.usage),
|
|
474
611
|
duration_ms: attempt.duration_ms,
|
|
475
612
|
...(level === 'L3' && step.fallback_step > 0 ? { l3_fallback: true } : {}),
|
|
476
613
|
};
|
|
@@ -491,6 +628,60 @@ export async function spawnSession(opts, deps = {}) {
|
|
|
491
628
|
throw new Error('spawnSession: unreachable — the ladder always has a step');
|
|
492
629
|
}
|
|
493
630
|
|
|
631
|
+
/**
|
|
632
|
+
* B33: a session row's dollars from its tokens and the static price table — `{usd, cost_source:
|
|
633
|
+
* 'estimated'}`, or `{usd: null, usd_unknown: true}` when the provider/level has no price.
|
|
634
|
+
* @param {string} provider @param {string} level @param {{tokens_in: number, tokens_out: number}} usage
|
|
635
|
+
*/
|
|
636
|
+
function sessionUsd(provider, level, usage) {
|
|
637
|
+
const priced = priceSession({ provider, level, tokens_in: usage.tokens_in, tokens_out: usage.tokens_out });
|
|
638
|
+
return priced.usd_unknown ? { usd: null, usd_unknown: true } : { usd: priced.usd, cost_source: 'estimated' };
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* B33, R6 — the single budget choke point: before every spawn attempt of every role, a run with
|
|
643
|
+
* `budget.usd` set reads its spend from the ledger (`deps.readRows`, else the `slug` ledger).
|
|
644
|
+
* ≥ 80 % prints and records one warning per run; ≥ 100 % refuses with a plain message and a
|
|
645
|
+
* `budget.refused` row, and nothing is spawned. No `budget.usd` or no `run` ⇒ no check. Fail
|
|
646
|
+
* closed: with a budget and a run but no way to read the spend (no `readRows`, no `slug`, or the
|
|
647
|
+
* read throws) the session is refused too. It sits at the top of the ladder loop, so every attempt
|
|
648
|
+
* — each fallback step, the rate-limit retry of a step, and every `spawnSession` call a caller
|
|
649
|
+
* makes again (B30's timeout retry) — passes it.
|
|
650
|
+
* @param {SessionOpts} opts @param {SessionDeps} deps
|
|
651
|
+
* @param {((row: Record<string, any>) => Promise<unknown>) | null} writeRow
|
|
652
|
+
* @param {{write: (s: string) => unknown}} stderr
|
|
653
|
+
* @returns {Promise<SessionResult | null>} the refusal result, or null to go on.
|
|
654
|
+
*/
|
|
655
|
+
async function budgetGate(opts, deps, writeRow, stderr) {
|
|
656
|
+
const budget = budgetUsdOf(opts.cfg);
|
|
657
|
+
if (budget === null || typeof opts.run !== 'string' || opts.run.length === 0) return null;
|
|
658
|
+
/** @type {Array<Record<string, any>> | null} */
|
|
659
|
+
let rows = null;
|
|
660
|
+
try {
|
|
661
|
+
const slug = opts.slug;
|
|
662
|
+
const read = deps.readRows ?? (slug ? () => readAllRows(slug) : null);
|
|
663
|
+
const got = read ? await read() : null;
|
|
664
|
+
rows = Array.isArray(got) ? got : null;
|
|
665
|
+
} catch {
|
|
666
|
+
rows = null;
|
|
667
|
+
}
|
|
668
|
+
if (rows === null) {
|
|
669
|
+
// fail closed: a budget we cannot check is never treated as "not reached"
|
|
670
|
+
writeSafe(stderr, `code-forge: ${SPEND_UNREADABLE_MESSAGE}\n`);
|
|
671
|
+
if (writeRow) {
|
|
672
|
+
try {
|
|
673
|
+
await writeRow({ event: 'budget.refused', run: opts.run, block: opts.block ?? null, role: opts.role, budget_usd: budget, spent_usd: null, reason: 'spend-unreadable' });
|
|
674
|
+
} catch {
|
|
675
|
+
// the refusal stands without its row
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
return { status: 'unavailable', reason: 'budget', message: SPEND_UNREADABLE_MESSAGE, answer: null, spent_usd: null, budget_usd: budget };
|
|
679
|
+
}
|
|
680
|
+
const verdict = await checkBudget({ budget, run: opts.run, rows, block: opts.block ?? null, role: opts.role, writeRow, stderr });
|
|
681
|
+
if (!verdict.refuse) return null;
|
|
682
|
+
return { status: 'unavailable', reason: 'budget', message: verdict.message, answer: null, spent_usd: verdict.spent, budget_usd: budget };
|
|
683
|
+
}
|
|
684
|
+
|
|
494
685
|
/**
|
|
495
686
|
* Build one step's argv; closed-book roles get a fresh empty cwd inside the session dir (the facts
|
|
496
687
|
* role: the caller's snapshot when `opts.cwd` names one), and Codex
|
|
@@ -498,8 +689,9 @@ export async function spawnSession(opts, deps = {}) {
|
|
|
498
689
|
* @param {SessionOpts} opts
|
|
499
690
|
* @param {{provider: string, model: string, effort?: string, flagFallback?: {provider: string, model: string}}} step
|
|
500
691
|
* @param {string} sessionDir
|
|
692
|
+
* @param {boolean} openBook - B32: `review.allow_open_book_codex`, read once by `spawnSession`.
|
|
501
693
|
*/
|
|
502
|
-
function buildStep(opts, step, sessionDir) {
|
|
694
|
+
function buildStep(opts, step, sessionDir, openBook) {
|
|
503
695
|
const closedBook = opts.role !== 'coder';
|
|
504
696
|
// B9b.1: the facts delegate may run in the caller's read-only project snapshot; nobody else may
|
|
505
697
|
const snapshot = opts.role === 'facts' && opts.cwd !== undefined ? realpathSync(path.resolve(opts.cwd)) : null; // validated by assertFactsCwd
|
|
@@ -514,6 +706,8 @@ function buildStep(opts, step, sessionDir) {
|
|
|
514
706
|
if (cli === 'claude' && opts.maxBudgetUsd !== undefined) params.maxBudgetUsd = opts.maxBudgetUsd;
|
|
515
707
|
if (closedBook && opts.systemPromptText !== undefined && cli !== 'codex') params.systemPromptText = opts.systemPromptText;
|
|
516
708
|
if (cli === 'codex') params.outPath = path.join(sessionDir, 'out.json');
|
|
709
|
+
// B32 opt-in: only a no-tools role needs it; coder and facts build exactly as without it
|
|
710
|
+
if (cli === 'codex' && openBook && isNoToolRole(opts.role)) params.allowOpenBook = true;
|
|
517
711
|
/** @type {Record<string, any> | null} */
|
|
518
712
|
let compiled = null;
|
|
519
713
|
const strict = variantFor(step.provider) === 'strict';
|
|
@@ -570,7 +764,22 @@ async function runForeground(built, opts, deps) {
|
|
|
570
764
|
|
|
571
765
|
const unavailable = classifyUnavailable(res, read.parsed);
|
|
572
766
|
if (unavailable) return { status: 'unavailable', reason: unavailable, answer: null, ...common };
|
|
573
|
-
if (res.timedOut)
|
|
767
|
+
if (res.timedOut) {
|
|
768
|
+
// B30: what the CLI printed last, so a hang's cause can be seen — stderr redacted, capped and
|
|
769
|
+
// packet-free; stdout only as event types and a byte count, never its text
|
|
770
|
+
/** @type {string | null} */
|
|
771
|
+
let packetText = input !== undefined ? Buffer.from(input).toString('utf8') : null;
|
|
772
|
+
if (packetText === null) {
|
|
773
|
+
try {
|
|
774
|
+
packetText = readFileSync(opts.promptPath, 'utf8');
|
|
775
|
+
} catch {
|
|
776
|
+
packetText = null; // fail closed: no tails without the packet to filter against
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
const err = stderrTail(res.stderr, packetText);
|
|
780
|
+
const tails = { stderr_tail: err.tail, ...(err.note ? { tail_note: err.note } : {}), ...stdoutEvents(res.stdout) };
|
|
781
|
+
return { status: 'timeout', reason: `killed after ${opts.timeoutMs ?? DEFAULT_TIMEOUT_MS} ms`, answer: null, ...common, ...tails };
|
|
782
|
+
}
|
|
574
783
|
if (res.result !== 'ok' || read.parsed?.is_error === true) return { status: 'failed', reason: `exit ${res.code}`, answer: null, ...common };
|
|
575
784
|
if (built.compiled) {
|
|
576
785
|
// validate against the schema the provider was actually given (the cached variant, stable across attempts)
|
package/src/state/block.mjs
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import { createHash } from 'node:crypto';
|
|
15
15
|
import os from 'node:os';
|
|
16
|
+
import { BLOCK_KINDS, blockKind, dispatchLevel } from '../decide/escalation.mjs';
|
|
16
17
|
import { exec } from '../util/exec.mjs';
|
|
17
18
|
import { isAncestorArgv, revParseArgv } from '../util/git.mjs';
|
|
18
19
|
import { StateError } from './paths.mjs';
|
|
@@ -125,11 +126,15 @@ function activeBlocks(record) {
|
|
|
125
126
|
|
|
126
127
|
/**
|
|
127
128
|
* `block open` — refuses a second open of the same id and any owned set that overlaps another
|
|
128
|
-
* ACTIVE block's; records base, owned files, level, attempt, acceptance, line forecast
|
|
129
|
+
* ACTIVE block's; records base, owned files, level, attempt, acceptance, line forecast and the
|
|
130
|
+
* block kind (B34: the declared `kind`, else `blockKind` of the owned files). A docs/contract
|
|
131
|
+
* block asked for below `levels.coder_floor_docs` (default L1) is opened AT the floor: the entry
|
|
132
|
+
* and the dispatch row carry the floor level, and the row adds `requested_level` and
|
|
133
|
+
* `trigger: 'docs_floor'`.
|
|
129
134
|
* @param {{
|
|
130
135
|
* runId: string, id: string, level: string, owned: string[], acceptance: unknown,
|
|
131
136
|
* attempt?: number, base?: string, lines?: number, brief?: {path: string, content: Buffer | string},
|
|
132
|
-
* writeRow: WriteRow, now?: Date,
|
|
137
|
+
* kind?: string, cfg?: Record<string, any>, writeRow: WriteRow, now?: Date,
|
|
133
138
|
* }} opts
|
|
134
139
|
* @returns {Promise<{block: Record<string, any>, pointer: string | null}>}
|
|
135
140
|
*/
|
|
@@ -139,8 +144,11 @@ export async function openBlock(opts) {
|
|
|
139
144
|
if (typeof level !== 'string' || !LEVEL.test(level)) throw new StateError('bad-level', 'level must be L0..L3');
|
|
140
145
|
if (!Number.isInteger(attempt) || attempt < 1) throw new StateError('bad-attempt', 'attempt must be an integer >= 1');
|
|
141
146
|
if (lines !== null && !(Number.isInteger(lines) && lines > 0)) throw new StateError('bad-lines', 'lines forecast must be a positive integer');
|
|
147
|
+
if (opts.kind !== undefined && !BLOCK_KINDS.includes(opts.kind)) throw new StateError('bad-kind', `kind must be one of ${BLOCK_KINDS.join(', ')}`);
|
|
142
148
|
const owned = assertOwned(opts.owned);
|
|
143
149
|
const acceptance = assertAcceptance(opts.acceptance);
|
|
150
|
+
const kind = blockKind({ owned, declared: opts.kind });
|
|
151
|
+
const dispatched = dispatchLevel({ lane: level, kind, cfg: opts.cfg });
|
|
144
152
|
const pointer = opts.brief ? briefPointer(opts.brief.path, opts.brief.content) : null;
|
|
145
153
|
|
|
146
154
|
const block = await mutateRun(runId, null, writeRow, async (record) => {
|
|
@@ -150,11 +158,12 @@ export async function openBlock(opts) {
|
|
|
150
158
|
if (pair) throw new StateError('overlap', `block ${id} owns ${pair[0]}, which overlaps ${pair[1]} owned by open block ${otherId}`);
|
|
151
159
|
}
|
|
152
160
|
const baseSha = await resolveCommit(opts.base ?? 'HEAD', record.workspace);
|
|
153
|
-
const entry = { block: id, base_sha: baseSha, owned_files: [...owned], level, attempt, opened_at: now.toISOString(), acceptance, lines_forecast: lines, status: 'open' };
|
|
161
|
+
const entry = { block: id, base_sha: baseSha, owned_files: [...owned], level: dispatched.level, kind, attempt, opened_at: now.toISOString(), acceptance, lines_forecast: lines, status: 'open' };
|
|
154
162
|
record.blocks[id] = entry;
|
|
163
|
+
const floor = dispatched.trigger === 'docs_floor' ? { requested_level: level, trigger: 'docs_floor' } : {};
|
|
155
164
|
return {
|
|
156
165
|
result: entry,
|
|
157
|
-
rows: [{ event: 'dispatch', block: id, level, attempt, base_sha: baseSha, owned_files: entry.owned_files, lines_forecast: lines }],
|
|
166
|
+
rows: [{ event: 'dispatch', block: id, level: dispatched.level, kind, ...floor, attempt, base_sha: baseSha, owned_files: entry.owned_files, lines_forecast: lines }],
|
|
158
167
|
};
|
|
159
168
|
});
|
|
160
169
|
return { block, pointer };
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The run's config snapshot (issue #2, block B35): `run start` stores the loaded
|
|
3
|
+
* `.code-forge.yml` in the run record, `run reload` replaces it mid-run, and the worker reviews
|
|
4
|
+
* every ticket with the snapshot it was enqueued under.
|
|
5
|
+
*
|
|
6
|
+
* Shape, in the authoritative run record (`~/.code-forge/runs/<run>.json`):
|
|
7
|
+
*
|
|
8
|
+
* config: {
|
|
9
|
+
* hash: sha256 of the canonical JSON of the snapshot in force,
|
|
10
|
+
* snapshots: {<hash>: <config>} every snapshot a queued ticket may still need,
|
|
11
|
+
* pins: {<ticket id>: <hash>} tickets that were pending at a reload keep the old snapshot,
|
|
12
|
+
* reloaded_at?: ISO time of the last reload,
|
|
13
|
+
* }
|
|
14
|
+
*
|
|
15
|
+
* A ticket is pinned by `run reload` itself (the orchestrator, under the run lock), never by the
|
|
16
|
+
* coder-writable ticket file, so a coder cannot pick an older (weaker) config for a new ticket.
|
|
17
|
+
* Messages here name key PATHS only, never values.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { createHash } from 'node:crypto';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Keys that cannot change mid-run, each with why. A change to one is refused by `run reload`
|
|
24
|
+
* (end the run and start a new one).
|
|
25
|
+
*/
|
|
26
|
+
export const IMMUTABLE_KEYS = Object.freeze({
|
|
27
|
+
version: 'the config format is fixed for the run',
|
|
28
|
+
'project.slug': 'the ledger file of the run is chosen at run start',
|
|
29
|
+
engine: 'the run engine is fixed at run start',
|
|
30
|
+
'tmp.root': "the worker's run temp root is created at run start",
|
|
31
|
+
keys: 'the worker resolves keys once, at start',
|
|
32
|
+
'system1.key': 'the worker resolves the Jev key once, at start',
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
/** @param {unknown} v @returns {boolean} what `JSON.stringify` drops from an object. */
|
|
36
|
+
const dropped = (v) => v === undefined || typeof v === 'function' || typeof v === 'symbol';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* JSON with object keys sorted at every level (arrays keep their order), so the hash does not
|
|
40
|
+
* depend on key order in the YAML. JSON semantics otherwise: a value with `toJSON` (a `Date` from
|
|
41
|
+
* a YAML timestamp ⇒ its ISO string) is converted first, and what JSON drops is dropped (`null` in
|
|
42
|
+
* a list) — so the hash of a config equals the hash of the same config after the run record's
|
|
43
|
+
* JSON save/read round trip.
|
|
44
|
+
* @param {unknown} value
|
|
45
|
+
* @returns {string}
|
|
46
|
+
*/
|
|
47
|
+
export function canonicalJSON(value) {
|
|
48
|
+
const v = value !== null && typeof value === 'object' && typeof (/** @type {any} */ (value).toJSON) === 'function' ? /** @type {any} */ (value).toJSON() : value;
|
|
49
|
+
if (Array.isArray(v)) return `[${v.map((item) => canonicalJSON(dropped(item) ? null : item)).join(',')}]`;
|
|
50
|
+
if (v !== null && typeof v === 'object') {
|
|
51
|
+
const obj = /** @type {Record<string, unknown>} */ (v);
|
|
52
|
+
const keys = Object.keys(obj)
|
|
53
|
+
.filter((k) => !dropped(obj[k]))
|
|
54
|
+
.sort();
|
|
55
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJSON(obj[k])}`).join(',')}}`;
|
|
56
|
+
}
|
|
57
|
+
return dropped(v) ? 'null' : JSON.stringify(v);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** @param {unknown} cfg @returns {string} sha256 hex of the canonical JSON. */
|
|
61
|
+
export function configHash(cfg) {
|
|
62
|
+
return createHash('sha256').update(canonicalJSON(cfg)).digest('hex');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** @param {unknown} v @returns {v is Record<string, unknown>} */
|
|
66
|
+
const isMapping = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The dotted key paths whose value differs between `before` and `after`, sorted. Mappings are
|
|
70
|
+
* walked key by key; a key present on one side only, a list, or a scalar is one path. A path
|
|
71
|
+
* segment that is not a plain identifier is written in brackets (`keys["a.b"]`).
|
|
72
|
+
* @param {unknown} before @param {unknown} after
|
|
73
|
+
* @returns {string[]}
|
|
74
|
+
*/
|
|
75
|
+
export function changedKeyPaths(before, after) {
|
|
76
|
+
/** @type {string[]} */
|
|
77
|
+
const out = [];
|
|
78
|
+
/** @param {unknown} a @param {unknown} b @param {string} at */
|
|
79
|
+
const walk = (a, b, at) => {
|
|
80
|
+
if (isMapping(a) && isMapping(b)) {
|
|
81
|
+
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
|
|
82
|
+
for (const k of keys) walk(own(a, k), own(b, k), join(at, k));
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (canonicalJSON(a ?? null) !== canonicalJSON(b ?? null) || (a === undefined) !== (b === undefined)) out.push(at);
|
|
86
|
+
};
|
|
87
|
+
walk(before, after, '');
|
|
88
|
+
return out.sort();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** @param {Record<string, unknown>} obj @param {string} key @returns {unknown} an OWN property only. */
|
|
92
|
+
const own = (obj, key) => (Object.hasOwn(obj, key) ? obj[key] : undefined);
|
|
93
|
+
|
|
94
|
+
/** @param {string} at @param {string} key */
|
|
95
|
+
function join(at, key) {
|
|
96
|
+
const seg = /^[A-Za-z_][A-Za-z0-9_-]*$/.test(key) ? key : `[${JSON.stringify(key)}]`;
|
|
97
|
+
if (at === '') return seg;
|
|
98
|
+
return seg.startsWith('[') ? `${at}${seg}` : `${at}.${seg}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* @param {unknown} cfg @param {string} dotted
|
|
103
|
+
* @returns {unknown} the value at `dotted`, or undefined.
|
|
104
|
+
*/
|
|
105
|
+
function valueAt(cfg, dotted) {
|
|
106
|
+
/** @type {unknown} */
|
|
107
|
+
let cur = cfg;
|
|
108
|
+
for (const seg of dotted.split('.')) {
|
|
109
|
+
if (!isMapping(cur)) return undefined;
|
|
110
|
+
cur = own(cur, seg);
|
|
111
|
+
}
|
|
112
|
+
return cur;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The immutable keys whose value differs between the two configs, in {@link IMMUTABLE_KEYS} order.
|
|
117
|
+
* @param {unknown} before @param {unknown} after
|
|
118
|
+
* @returns {string[]}
|
|
119
|
+
*/
|
|
120
|
+
export function immutableChanges(before, after) {
|
|
121
|
+
return Object.keys(IMMUTABLE_KEYS).filter((k) => canonicalJSON(valueAt(before, k) ?? null) !== canonicalJSON(valueAt(after, k) ?? null));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The `config` block for a new run record.
|
|
126
|
+
* @param {Record<string, any>} cfg
|
|
127
|
+
*/
|
|
128
|
+
export function initialConfigState(cfg) {
|
|
129
|
+
const hash = configHash(cfg);
|
|
130
|
+
return { hash, snapshots: { [hash]: cfg }, pins: {} };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The snapshot a ticket is reviewed with: its pin when `run reload` pinned it, else the current
|
|
135
|
+
* one. Null when the record predates snapshots (the caller keeps the config it booted with).
|
|
136
|
+
* A snapshot that is missing or does not match its hash throws `config-snapshot`.
|
|
137
|
+
* @param {Record<string, any>} record @param {string} ticket
|
|
138
|
+
* @returns {{hash: string, config: Record<string, any>} | null}
|
|
139
|
+
*/
|
|
140
|
+
export function snapshotFor(record, ticket) {
|
|
141
|
+
const state = record?.config;
|
|
142
|
+
if (!isMapping(state) || typeof state.hash !== 'string') return null;
|
|
143
|
+
const pins = isMapping(state.pins) ? state.pins : {};
|
|
144
|
+
const pin = own(pins, ticket);
|
|
145
|
+
const hash = typeof pin === 'string' ? pin : state.hash;
|
|
146
|
+
const snaps = isMapping(state.snapshots) ? state.snapshots : {};
|
|
147
|
+
const config = own(snaps, hash);
|
|
148
|
+
if (!isMapping(config) || configHash(config) !== hash) throw new Error('config-snapshot');
|
|
149
|
+
return { hash, config };
|
|
150
|
+
}
|
package/src/state/run.mjs
CHANGED
|
@@ -16,6 +16,7 @@ import { mkdir, readFile, rename, rmdir, writeFile } from 'node:fs/promises';
|
|
|
16
16
|
import path from 'node:path';
|
|
17
17
|
import { exec } from '../util/exec.mjs';
|
|
18
18
|
import { StateError, assertRunId, assertWorkspaceOutsideRuns, mirrorPath, newRunId, runRecordPath, runsDir } from './paths.mjs';
|
|
19
|
+
import { changedKeyPaths, configHash, IMMUTABLE_KEYS, immutableChanges, initialConfigState } from './config-snapshot.mjs';
|
|
19
20
|
import { generateKey, loadKey, signRow } from './signer.mjs';
|
|
20
21
|
|
|
21
22
|
/** @typedef {(row: Record<string, any>) => Promise<unknown>} WriteRow */
|
|
@@ -165,6 +166,9 @@ export async function startRun(opts) {
|
|
|
165
166
|
status: 'active',
|
|
166
167
|
blocks: {},
|
|
167
168
|
orphans: [],
|
|
169
|
+
// B35: the config snapshot the worker reviews with (`run reload` replaces it). Only when the
|
|
170
|
+
// caller passed the loaded config: a record without it keeps the worker on its boot config.
|
|
171
|
+
...(opts.config !== undefined ? { config: initialConfigState(config) } : {}),
|
|
168
172
|
};
|
|
169
173
|
return withRunLock(runId, async () => {
|
|
170
174
|
const exists = await readRun(runId).then(
|
|
@@ -179,6 +183,88 @@ export async function startRun(opts) {
|
|
|
179
183
|
});
|
|
180
184
|
}
|
|
181
185
|
|
|
186
|
+
/**
|
|
187
|
+
* `run reload` (B35): replace the run's config snapshot with `config` (already loaded and
|
|
188
|
+
* validated by the caller), entirely under the run lock. Open blocks, their attempts and the
|
|
189
|
+
* worker pin are untouched.
|
|
190
|
+
*
|
|
191
|
+
* `readPending()` (the queue's tickets without a done marker) is called UNDER the lock, right
|
|
192
|
+
* before the record is saved, so the window in which a ticket could be enqueued unpinned is as
|
|
193
|
+
* short as it can be (enqueueing itself does not take the run lock). Each pending ticket keeps a
|
|
194
|
+
* pin whose snapshot is still stored; any other pending ticket — new, or pinned to a snapshot that
|
|
195
|
+
* has gone missing (never left dangling) — is pinned to the snapshot in force until now. Pins of
|
|
196
|
+
* tickets no longer pending are dropped, and so are snapshots no pin or the current hash names.
|
|
197
|
+
*
|
|
198
|
+
* Fixed keys ({@link IMMUTABLE_KEYS}): every one that changed is refused at once
|
|
199
|
+
* (`immutable-key`, nothing written). A record from before snapshots (no `config`) is checked
|
|
200
|
+
* against what it does store: `engine` and the ledger slug (`effectiveSlug`, computed by the
|
|
201
|
+
* caller the same way `run start` did); it then gets its first snapshot, `changed_keys: null`,
|
|
202
|
+
* `old_hash: null`.
|
|
203
|
+
*
|
|
204
|
+
* No change (same hash) ⇒ nothing written, no row. Otherwise the record is saved FIRST (it is the
|
|
205
|
+
* source of truth), then one signed `run.reload` row: the changed key PATHS (never values) and
|
|
206
|
+
* the old/new snapshot hashes. A row that cannot be written leaves the reload in place and is
|
|
207
|
+
* reported as `rowError`.
|
|
208
|
+
* @param {{
|
|
209
|
+
* runId: string, config: Record<string, any>, readPending?: () => string[], effectiveSlug?: string,
|
|
210
|
+
* writeRow: WriteRow, now?: Date,
|
|
211
|
+
* }} opts
|
|
212
|
+
* @returns {Promise<{changed: string[] | null, oldHash: string | null, newHash: string, pinned: number, rowError: string | null}>}
|
|
213
|
+
* `changed` is `[]` when nothing changed.
|
|
214
|
+
*/
|
|
215
|
+
export async function reloadRun({ runId, config, readPending = () => [], effectiveSlug, writeRow, now = new Date() }) {
|
|
216
|
+
return withRunLock(runId, async () => {
|
|
217
|
+
const current = await readRun(runId);
|
|
218
|
+
if (current.status !== 'active') throw new StateError('run-ended', `run ${runId} has ended`);
|
|
219
|
+
const state = current.config && typeof current.config === 'object' && typeof current.config.hash === 'string' ? current.config : null;
|
|
220
|
+
const stored = state && state.snapshots && typeof state.snapshots === 'object' ? state.snapshots : {};
|
|
221
|
+
const newHash = configHash(config);
|
|
222
|
+
const oldHash = state ? state.hash : null;
|
|
223
|
+
if (oldHash === newHash) return { changed: [], oldHash, newHash, pinned: 0, rowError: null };
|
|
224
|
+
const before = state && Object.hasOwn(stored, state.hash) ? stored[state.hash] : undefined;
|
|
225
|
+
if (state && (before === null || typeof before !== 'object')) throw new StateError('config-snapshot', `run ${runId}: the current config snapshot is missing from the run record`);
|
|
226
|
+
/** @type {string[]} */
|
|
227
|
+
let frozen;
|
|
228
|
+
if (state) {
|
|
229
|
+
frozen = immutableChanges(before, config);
|
|
230
|
+
} else {
|
|
231
|
+
// no snapshot: compare what the record stores (`run start` wrote engine and the slug)
|
|
232
|
+
frozen = [];
|
|
233
|
+
if (effectiveSlug !== undefined && effectiveSlug !== current.project) frozen.push('project.slug');
|
|
234
|
+
if (typeof current.engine === 'string' && (config.engine ?? 'auto') !== current.engine) frozen.push('engine');
|
|
235
|
+
}
|
|
236
|
+
if (frozen.length > 0) {
|
|
237
|
+
const why = frozen.map((key) => `${key} cannot change mid-run (${IMMUTABLE_KEYS[/** @type {keyof typeof IMMUTABLE_KEYS} */ (key)]})`);
|
|
238
|
+
throw new StateError('immutable-key', `${why.join('; ')} — put ${frozen.length === 1 ? 'it' : 'them'} back, or end the run and start a new one`);
|
|
239
|
+
}
|
|
240
|
+
const changed = state ? changedKeyPaths(before, config) : null;
|
|
241
|
+
/** @type {Record<string, string>} */
|
|
242
|
+
const pins = {};
|
|
243
|
+
const oldPins = state?.pins && typeof state.pins === 'object' ? state.pins : {};
|
|
244
|
+
for (const ticket of new Set(readPending())) {
|
|
245
|
+
const hash = Object.hasOwn(oldPins, ticket) ? oldPins[ticket] : undefined;
|
|
246
|
+
if (typeof hash === 'string' && Object.hasOwn(stored, hash)) pins[ticket] = hash;
|
|
247
|
+
else if (oldHash !== null) pins[ticket] = oldHash;
|
|
248
|
+
}
|
|
249
|
+
/** @type {Record<string, any>} */
|
|
250
|
+
const snapshots = { [newHash]: config };
|
|
251
|
+
for (const hash of new Set(Object.values(pins))) snapshots[hash] = stored[hash];
|
|
252
|
+
current.config = { hash: newHash, snapshots, pins, reloaded_at: now.toISOString() };
|
|
253
|
+
current.provider = config.provider ?? null;
|
|
254
|
+
current.levels = config.levels ?? null;
|
|
255
|
+
current.fallback_mode = config.system1?.fallback ?? 'rules';
|
|
256
|
+
await saveRun(current);
|
|
257
|
+
const pinned = Object.keys(pins).length;
|
|
258
|
+
let rowError = null;
|
|
259
|
+
try {
|
|
260
|
+
await writeSigned(runId, writeRow, { event: 'run.reload', changed_keys: changed, old_hash: oldHash, new_hash: newHash, pinned_tickets: pinned });
|
|
261
|
+
} catch (err) {
|
|
262
|
+
rowError = err?.code ?? err?.name ?? 'error';
|
|
263
|
+
}
|
|
264
|
+
return { changed, oldHash, newHash, pinned, rowError };
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
182
268
|
/**
|
|
183
269
|
* `run start --reattach`: re-pin a restarted worker (the orchestrator only — §4.8).
|
|
184
270
|
* @param {{runId: string, workerPid: number, writeRow: WriteRow, probe?: StartTimeProbe}} opts
|