@codedology/code-forge 0.3.2 → 0.5.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 +85 -0
- package/README.md +88 -21
- package/bin/code-forge.mjs +6 -1
- package/docs/README.md +5 -4
- package/docs/reference/blocks.json +236 -1
- package/docs/reference/config.md +13 -1
- package/package.json +6 -2
- package/schema/code-forge.schema.json +26 -4
- package/skill/SKILL.md +8 -7
- package/skill/references/adapters/claude-code.md +5 -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 +10 -2
- package/skill/references/plan.md +12 -3
- package/skill/references/review.md +13 -6
- 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/facts.mjs +16 -1
- 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/review.mjs +40 -9
- 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/index.mjs +2 -1
- package/src/doctor/local.mjs +15 -0
- 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 +288 -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/facts.mjs +218 -53
- package/src/session/plan-check.mjs +71 -6
- package/src/session/plan-sections.mjs +98 -0
- package/src/session/spawn.mjs +373 -20
- 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/util/locks.mjs +104 -0
- package/src/worker/engine.mjs +122 -40
- package/src/worker/loop.mjs +326 -78
- package/src/worker/queue.mjs +68 -7
package/src/session/spawn.mjs
CHANGED
|
@@ -16,13 +16,24 @@
|
|
|
16
16
|
* Retry ladder (§5.6, V9): step 0 is the level's own model; step `k + 1` is `fallback[k]`. For a
|
|
17
17
|
* Claude coder, `fallback[0]` (same provider) rides on `--fallback-model` inside step 0 and is not
|
|
18
18
|
* spawned again. A step that reports unavailability (CLI missing, login expired, HTTP 402, or
|
|
19
|
-
* rate-limited
|
|
19
|
+
* rate-limited three times in a row) hands over to the next step; any other failure is final.
|
|
20
|
+
*
|
|
21
|
+
* Provider slots and backoff (B40): every attempt holds one slot of the module-level semaphore of
|
|
22
|
+
* its step's provider (`review.provider_concurrency[provider]`, defaults anthropic 4, openai 2,
|
|
23
|
+
* xai 2) for as long as the attempt runs, and frees it on every exit path. B41: the slot is taken
|
|
24
|
+
* FIRST, then the budget gate (check + reservation under the run's lock) runs; a refused session
|
|
25
|
+
* frees its slot at once, and a session waiting for a slot holds no reservation. A rate-limited attempt frees its slot, waits
|
|
26
|
+
* `RATE_LIMIT_BACKOFF_MS` (2 s, then 6 s, each ±30 % jitter) and tries the same step again; after
|
|
27
|
+
* the second wait a third rate limit moves the ladder to the next step.
|
|
20
28
|
*
|
|
21
29
|
* Every spawn prints `level=<Lx> provider=<p> model=<id> effort=<e> fallback_step=<n>` on stderr,
|
|
22
30
|
* and every attempt writes one ledger row (`event: session`) with `tokens_source` `reported` when
|
|
23
31
|
* the CLI's JSON carried usage, else `estimated` (bytes / 4).
|
|
24
32
|
*
|
|
25
|
-
* A coder argv that `isForbidden` matches is refused before anything spawns (§8.4).
|
|
33
|
+
* A coder argv that `isForbidden` matches is refused before anything spawns (§8.4). A
|
|
34
|
+
* reviewer/judge/S2/author level on openai is refused too (`closed-book`, B32: Codex always has a
|
|
35
|
+
* shell); an openai fallback step of such a session is skipped with one stderr line. The opt-in
|
|
36
|
+
* `review.allow_open_book_codex: true` lifts both and builds Codex's read-only argv.
|
|
26
37
|
*
|
|
27
38
|
* `background: true` (coders, §5.2): the child is started detached with stdout/stderr going to
|
|
28
39
|
* `<run-root>/sessions/<id>/session.log`, its pid written to `<run-root>/sessions/<id>/pid.json`
|
|
@@ -36,6 +47,7 @@ import { createHash, randomBytes } from 'node:crypto';
|
|
|
36
47
|
import { closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
37
48
|
import path from 'node:path';
|
|
38
49
|
import { Ajv2020 } from 'ajv/dist/2020.js';
|
|
50
|
+
import { CODEX_CLOSED_BOOK_REFUSAL, closedBookRefused, isNoToolRole, openBookCodexAllowed } from '../config/closed-book.mjs';
|
|
39
51
|
import { resolveLevel } from '../config/known-ids.mjs';
|
|
40
52
|
import { compileSchema } from '../config/schema-compile.mjs';
|
|
41
53
|
import { buildArgv } from '../engines/builders/index.mjs';
|
|
@@ -43,9 +55,11 @@ import { removeCodexHome } from '../engines/codex-home.mjs';
|
|
|
43
55
|
import { cliNameForProvider } from '../engines/provider-cli.mjs';
|
|
44
56
|
import { parseSentinel } from '../engines/sentinel.mjs';
|
|
45
57
|
import { parseUsage } from '../engines/usage-parse.mjs';
|
|
46
|
-
import {
|
|
58
|
+
import { budgetUsdOf, checkBudget, estimateSessionUsd, priceSession, reservedUsd, reserveBudget, SPEND_UNREADABLE_MESSAGE, withBudgetLock } from '../ledger/spend.mjs';
|
|
59
|
+
import { appendRow, readAllRows } from '../ledger/write.mjs';
|
|
47
60
|
import { exec } from '../util/exec.mjs';
|
|
48
61
|
import { isForbidden, mergeForbidden } from '../util/forbidden.mjs';
|
|
62
|
+
import { semaphore } from '../util/locks.mjs';
|
|
49
63
|
import { readStartTime, registerPid, UNKNOWN_START_TIME } from '../util/reaper.mjs';
|
|
50
64
|
import { redact, writeSafe } from '../util/redact.mjs';
|
|
51
65
|
import { currentRunRoot, pidsDir, runRoot, tmpBase, untrustedReason } from '../util/tmp.mjs';
|
|
@@ -59,7 +73,58 @@ export const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000;
|
|
|
59
73
|
/** Unavailability reasons that move the ladder to the next step (§5.6). */
|
|
60
74
|
export const UNAVAILABLE_REASONS = Object.freeze(['cli-missing', 'login-expired', 'http-402', 'rate-limited']);
|
|
61
75
|
|
|
62
|
-
/**
|
|
76
|
+
/** B40: concurrent sessions per provider when `review.provider_concurrency` does not say. */
|
|
77
|
+
export const DEFAULT_PROVIDER_CONCURRENCY = Object.freeze({ anthropic: 4, openai: 2, xai: 2 });
|
|
78
|
+
|
|
79
|
+
/** B40: the waits before each retry of a rate-limited step (ms, before jitter); its length is the retry count. */
|
|
80
|
+
export const RATE_LIMIT_BACKOFF_MS = Object.freeze([2000, 6000]);
|
|
81
|
+
|
|
82
|
+
/** B40: each backoff wait is its base ±30 %. */
|
|
83
|
+
export const RATE_LIMIT_JITTER = 0.3;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* B40: the session limit of `provider` — `review.provider_concurrency[provider]` when it is an
|
|
87
|
+
* integer ≥ 1, else the default (1 for a provider with no default).
|
|
88
|
+
* @param {Record<string, any>} cfg @param {string} provider @returns {number}
|
|
89
|
+
*/
|
|
90
|
+
export function providerLimit(cfg, provider) {
|
|
91
|
+
const set = /** @type {any} */ (cfg)?.review?.provider_concurrency?.[provider];
|
|
92
|
+
if (Number.isInteger(set) && set >= 1) return set;
|
|
93
|
+
return Object.hasOwn(DEFAULT_PROVIDER_CONCURRENCY, provider) ? DEFAULT_PROVIDER_CONCURRENCY[/** @type {"anthropic"|"openai"|"xai"} */ (provider)] : 1;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** @type {Map<string, import('../util/locks.mjs').Semaphore>} one semaphore per (provider, limit), created on first use. */
|
|
97
|
+
const providerSemaphores = new Map();
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* B40: the in-process semaphore holding `provider`'s session slots at `limit`.
|
|
101
|
+
* @param {string} provider @param {number} limit
|
|
102
|
+
* @returns {import('../util/locks.mjs').Semaphore}
|
|
103
|
+
*/
|
|
104
|
+
export function providerSemaphore(provider, limit) {
|
|
105
|
+
const key = `${provider}:${limit}`;
|
|
106
|
+
let sem = providerSemaphores.get(key);
|
|
107
|
+
if (sem === undefined) {
|
|
108
|
+
sem = semaphore(limit);
|
|
109
|
+
providerSemaphores.set(key, sem);
|
|
110
|
+
}
|
|
111
|
+
return sem;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* B40: the wait before retry number `n` (0-based) of a rate-limited step: the base ±30 %, from
|
|
116
|
+
* `random` in [0, 1) (0.5 gives the base exactly).
|
|
117
|
+
* @param {number} n @param {() => number} random @returns {number}
|
|
118
|
+
*/
|
|
119
|
+
export function backoffMs(n, random) {
|
|
120
|
+
const base = RATE_LIMIT_BACKOFF_MS[n];
|
|
121
|
+
return Math.round(base * (1 + RATE_LIMIT_JITTER * (2 * random() - 1)));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** @param {number} ms @returns {Promise<void>} */
|
|
125
|
+
const realSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
126
|
+
|
|
127
|
+
/** An error the spawner raises before anything runs (`code`: `usage`, `forbidden`, `closed-book`). */
|
|
63
128
|
export class SessionError extends Error {
|
|
64
129
|
/** @param {string} code @param {string} message */
|
|
65
130
|
constructor(code, message) {
|
|
@@ -84,6 +149,125 @@ export function runRootFor(runId, root) {
|
|
|
84
149
|
return runRoot(runId, { root });
|
|
85
150
|
}
|
|
86
151
|
|
|
152
|
+
/** How much of a timed-out session's stderr is kept (B30). */
|
|
153
|
+
export const DIAGNOSTIC_TAIL_BYTES = 4096;
|
|
154
|
+
|
|
155
|
+
/** A timed-out session's stderr over this size keeps no text at all (bounds the redact cost). */
|
|
156
|
+
export const STDERR_MAX_BYTES = 1024 * 1024;
|
|
157
|
+
|
|
158
|
+
/** How many stdout event types a timed-out session keeps. */
|
|
159
|
+
export const STDOUT_EVENTS_MAX = 20;
|
|
160
|
+
|
|
161
|
+
/** The longest event type kept. */
|
|
162
|
+
const EVENT_TYPE_MAX = 40;
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Packet lines shorter than this are too common to tell apart from ordinary output, so they are
|
|
166
|
+
* NOT filtered. Accepted risk (B30): a packet line under 16 characters (a brace, `return x;`) can
|
|
167
|
+
* appear in a stderr tail; such a line carries no meaningful packet content on its own.
|
|
168
|
+
*/
|
|
169
|
+
const PACKET_LINE_MIN = 16;
|
|
170
|
+
|
|
171
|
+
/** The tail note when the packet cannot be read: no stderr text is kept at all (fail closed). */
|
|
172
|
+
export const TAIL_WITHHELD_PACKET = 'withheld: packet unreadable';
|
|
173
|
+
/** The tail note when stderr is over `STDERR_MAX_BYTES`. */
|
|
174
|
+
export const TAIL_WITHHELD_LARGE = 'stderr too large, withheld';
|
|
175
|
+
/** The tail note when the last 4 KB of stderr is one partial line. */
|
|
176
|
+
export const TAIL_WITHHELD_LONG_LINE = 'one long line, withheld';
|
|
177
|
+
|
|
178
|
+
/** @param {Buffer} buf @param {number} at @returns {number} the first character start at or after `at`. */
|
|
179
|
+
function charStart(buf, at) {
|
|
180
|
+
let i = at;
|
|
181
|
+
while (i < buf.length && (buf[i] & 0xc0) === 0x80) i += 1;
|
|
182
|
+
return i;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** @param {unknown} text @returns {string} */
|
|
186
|
+
function asText(text) {
|
|
187
|
+
return Buffer.isBuffer(text) ? text.toString('utf8') : typeof text === 'string' ? text : '';
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const PEM_BEGIN = /-----BEGIN [A-Z0-9 ]*-----/;
|
|
191
|
+
const PEM_END = /-----END [A-Z0-9 ]*-----/;
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* The stderr tail of a timed-out session (B30), never packet content:
|
|
195
|
+
* 1. no packet text (unreadable) ⇒ no text: `{tail: null, note: 'withheld: packet unreadable'}`;
|
|
196
|
+
* stderr over `STDERR_MAX_BYTES` ⇒ `{tail: null, note: 'stderr too large, withheld'}`;
|
|
197
|
+
* 2. on the FULL raw text: every PEM block (`-----BEGIN` … `-----END` lines, an unterminated one
|
|
198
|
+
* to the end) and every line repeating a packet line (≥ 16 chars) is dropped;
|
|
199
|
+
* 3. the FULL remaining text goes through B0 `redact`;
|
|
200
|
+
* 4. over `maxBytes`, the last `maxBytes` bytes are kept, starting on a character, with the first
|
|
201
|
+
* (partial) line dropped; when that piece has no line break the tail is withheld.
|
|
202
|
+
* @param {string | Buffer | null | undefined} text @param {string | null} packetText @param {number} [maxBytes]
|
|
203
|
+
* @returns {{tail: string | null, note?: string}}
|
|
204
|
+
*/
|
|
205
|
+
export function stderrTail(text, packetText, maxBytes = DIAGNOSTIC_TAIL_BYTES) {
|
|
206
|
+
if (typeof packetText !== 'string') return { tail: null, note: TAIL_WITHHELD_PACKET };
|
|
207
|
+
const raw = asText(text);
|
|
208
|
+
if (raw.length === 0) return { tail: '' };
|
|
209
|
+
if (Buffer.byteLength(raw) > STDERR_MAX_BYTES) return { tail: null, note: TAIL_WITHHELD_LARGE };
|
|
210
|
+
const packetLines = [
|
|
211
|
+
...new Set(
|
|
212
|
+
packetText
|
|
213
|
+
.split('\n')
|
|
214
|
+
.map((l) => l.trim())
|
|
215
|
+
.filter((l) => l.length >= PACKET_LINE_MIN),
|
|
216
|
+
),
|
|
217
|
+
];
|
|
218
|
+
let inPem = false;
|
|
219
|
+
/** @type {string[]} */
|
|
220
|
+
const kept = [];
|
|
221
|
+
for (const line of raw.split('\n')) {
|
|
222
|
+
const t = line.trim();
|
|
223
|
+
if (inPem) {
|
|
224
|
+
if (PEM_END.test(t)) inPem = false;
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (PEM_BEGIN.test(t)) {
|
|
228
|
+
inPem = !PEM_END.test(t);
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
if (t.length >= PACKET_LINE_MIN && packetLines.some((p) => t.includes(p))) continue;
|
|
232
|
+
kept.push(line);
|
|
233
|
+
}
|
|
234
|
+
const safe = /** @type {string} */ (redact(kept.join('\n')));
|
|
235
|
+
const buf = Buffer.from(safe, 'utf8');
|
|
236
|
+
if (buf.length <= maxBytes) return { tail: safe };
|
|
237
|
+
const from = charStart(buf, buf.length - maxBytes);
|
|
238
|
+
const piece = buf.subarray(from).toString('utf8');
|
|
239
|
+
if (buf[from - 1] === 0x0a) return { tail: piece }; // the cut fell exactly on a line start
|
|
240
|
+
const nl = piece.indexOf('\n');
|
|
241
|
+
if (nl === -1) return { tail: '', note: TAIL_WITHHELD_LONG_LINE };
|
|
242
|
+
return { tail: piece.slice(nl + 1) };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* What a timed-out session's stdout says without any of its text (B30): the last
|
|
247
|
+
* `STDOUT_EVENTS_MAX` event types (JSON lines with a string `type`, each cut to 40 characters and
|
|
248
|
+
* kept only when it is all `[a-z0-9_.-]`) and the total byte length. Non-JSON stdout gives only
|
|
249
|
+
* `stdout_bytes`. Event payloads, which can echo the packet, are never kept.
|
|
250
|
+
* @param {string | Buffer | null | undefined} text
|
|
251
|
+
* @returns {{stdout_bytes: number, stdout_events?: string[]}}
|
|
252
|
+
*/
|
|
253
|
+
export function stdoutEvents(text) {
|
|
254
|
+
const raw = asText(text);
|
|
255
|
+
const bytes = Buffer.byteLength(raw);
|
|
256
|
+
/** @type {string[]} */
|
|
257
|
+
const types = [];
|
|
258
|
+
const lines = raw.split('\n');
|
|
259
|
+
// scan from the end: stop once enough events are kept, or after a bounded number of lines
|
|
260
|
+
for (let i = lines.length - 1, scanned = 0; i >= 0 && types.length < STDOUT_EVENTS_MAX && scanned < 5000; i -= 1, scanned += 1) {
|
|
261
|
+
const line = lines[i].trim();
|
|
262
|
+
if (!line.startsWith('{')) continue;
|
|
263
|
+
const event = tryJSON(line);
|
|
264
|
+
if (!event || typeof event !== 'object' || typeof event.type !== 'string') continue;
|
|
265
|
+
const type = event.type.slice(0, EVENT_TYPE_MAX);
|
|
266
|
+
if (/^[a-z0-9_.-]+$/.test(type)) types.unshift(type);
|
|
267
|
+
}
|
|
268
|
+
return types.length > 0 ? { stdout_bytes: bytes, stdout_events: types } : { stdout_bytes: bytes };
|
|
269
|
+
}
|
|
270
|
+
|
|
87
271
|
/** @param {number} bytes */
|
|
88
272
|
export const estimateTokens = (bytes) => Math.ceil(bytes / 4);
|
|
89
273
|
|
|
@@ -317,8 +501,12 @@ function childEnv(/** @type {NodeJS.ProcessEnv} */ env) {
|
|
|
317
501
|
* @property {Partial<Record<"claude"|"codex"|"grok", string>>} [bins] - replaces argv[0] (tests: the fakes).
|
|
318
502
|
* @property {typeof exec} [exec]
|
|
319
503
|
* @property {(row: Record<string, any>) => Promise<unknown>} [writeRow]
|
|
504
|
+
* @property {() => Promise<Array<Record<string, any>>>} [readRows] - the ledger rows the
|
|
505
|
+
* `budget.usd` check reads (default: `readAllRows(opts.slug)`).
|
|
320
506
|
* @property {{write: (s: string) => unknown}} [stderr]
|
|
321
507
|
* @property {NodeJS.ProcessEnv} [env]
|
|
508
|
+
* @property {(ms: number) => Promise<unknown>} [sleep] - B40: the rate-limit backoff wait (tests: a recorder).
|
|
509
|
+
* @property {() => number} [random] - B40: the backoff jitter source, in [0, 1) (default `Math.random`).
|
|
322
510
|
*/
|
|
323
511
|
|
|
324
512
|
/**
|
|
@@ -432,18 +620,67 @@ export async function spawnSession(opts, deps = {}) {
|
|
|
432
620
|
const runRootDir = opts.runRoot ?? currentRunRoot();
|
|
433
621
|
if (role === 'facts' && opts.cwd !== undefined) assertFactsCwd(opts.cwd, runRootDir);
|
|
434
622
|
const writeRow = deps.writeRow ?? (opts.slug ? (/** @type {Record<string, any>} */ row) => appendRow(row, { slug: /** @type {string} */ (opts.slug) }) : null);
|
|
435
|
-
const
|
|
623
|
+
const ladder = ladderFor(resolveLevel(cfg, level), role);
|
|
624
|
+
// B32: Codex has no no-tools mode, so it runs no reviewer/judge/S2/author session unless the
|
|
625
|
+
// config opts in (`review.allow_open_book_codex: true`). Without it, step 0 on openai refuses the
|
|
626
|
+
// session and an openai fallback step is dropped from the ladder.
|
|
627
|
+
const openBook = openBookCodexAllowed(cfg); // read ONCE: the refusal and every build use it
|
|
628
|
+
if (closedBookRefused(ladder[0].provider, role, openBook)) throw new SessionError('closed-book', CODEX_CLOSED_BOOK_REFUSAL);
|
|
629
|
+
const steps = ladder.filter((step) => !closedBookRefused(step.provider, role, openBook));
|
|
630
|
+
if (steps.length < ladder.length) writeSafe(stderr, `fallback skipped: ${ladder.length - steps.length} openai step(s) — ${CODEX_CLOSED_BOOK_REFUSAL}\n`);
|
|
436
631
|
if (opts.background) steps.length = 1;
|
|
437
632
|
|
|
438
633
|
const attempts = [];
|
|
439
|
-
|
|
634
|
+
const sleep = deps.sleep ?? realSleep;
|
|
635
|
+
const random = deps.random ?? Math.random;
|
|
636
|
+
let rateLimitWaits = 0; // backoff waits taken on the current step
|
|
440
637
|
for (let i = 0; i < steps.length; i += 1) {
|
|
441
638
|
const step = steps[i];
|
|
639
|
+
// B40/B41: the provider slot first, then the budget gate — a session queued for a slot never
|
|
640
|
+
// holds a reservation; a refusal frees the slot at once (the `finally` below)
|
|
641
|
+
const releaseSlot = await providerSemaphore(step.provider, providerLimit(cfg, step.provider)).acquire();
|
|
642
|
+
/** @type {number | null} */
|
|
643
|
+
let waitMs = null;
|
|
644
|
+
try {
|
|
645
|
+
const gate = await budgetGate(opts, deps, writeRow, stderr, step);
|
|
646
|
+
if (gate.refused) return { ...gate.refused, provider: step.provider, model: step.model, effort: step.effort ?? null, fallback_step: step.fallback_step, attempts };
|
|
647
|
+
// B41: the reservation is released once the row is written (inside runAttempt) and, as a net,
|
|
648
|
+
// in the `finally` below on every other exit path (a throw, a background start)
|
|
649
|
+
const releaseReservation = gate.release;
|
|
650
|
+
try {
|
|
651
|
+
const outcome = await runAttempt(step, i + 1 === steps.length, releaseReservation);
|
|
652
|
+
if (outcome.result) return outcome.result;
|
|
653
|
+
waitMs = outcome.waitMs;
|
|
654
|
+
} finally {
|
|
655
|
+
releaseReservation();
|
|
656
|
+
}
|
|
657
|
+
} finally {
|
|
658
|
+
releaseSlot();
|
|
659
|
+
}
|
|
660
|
+
if (waitMs !== null) {
|
|
661
|
+
// B40: the slot is free while we wait; the same step is tried again (through the budget gate)
|
|
662
|
+
writeSafe(stderr, `rate-limited: provider=${step.provider} model=${step.model} retry in ${(waitMs / 1000).toFixed(1)} s\n`);
|
|
663
|
+
await sleep(waitMs);
|
|
664
|
+
i -= 1;
|
|
665
|
+
continue;
|
|
666
|
+
}
|
|
667
|
+
rateLimitWaits = 0;
|
|
668
|
+
}
|
|
669
|
+
throw new Error('spawnSession: unreachable — the ladder always has a step');
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* One attempt of `step` (the caller holds its provider slot).
|
|
673
|
+
* @param {ReturnType<typeof ladderFor>[number]} step @param {boolean} isLast - no step after this one.
|
|
674
|
+
* @param {() => void} releaseReservation - B41: called as soon as the session row is written.
|
|
675
|
+
* @returns {Promise<{result: SessionResult | null, waitMs: number | null}>} a `result` ends the
|
|
676
|
+
* session; otherwise `waitMs` = back off and retry the step, null = go to the next step.
|
|
677
|
+
*/
|
|
678
|
+
async function runAttempt(step, isLast, releaseReservation) {
|
|
442
679
|
const sessionDir = path.join(runRootDir, 'sessions', `${role}-${Date.now()}-${randomBytes(4).toString('hex')}`);
|
|
443
680
|
mkdirSync(sessionDir, { recursive: true, mode: 0o700 });
|
|
444
681
|
let keep = false;
|
|
445
682
|
try {
|
|
446
|
-
const built = buildStep(opts, step, sessionDir);
|
|
683
|
+
const built = buildStep(opts, step, sessionDir, openBook);
|
|
447
684
|
const cli = built.cli;
|
|
448
685
|
built.argv[0] = deps.bins?.[cli] ?? built.argv[0];
|
|
449
686
|
if (role === 'coder') {
|
|
@@ -458,8 +695,13 @@ export async function spawnSession(opts, deps = {}) {
|
|
|
458
695
|
// B4.1: the builder's env (the Codex coder's CODEX_HOME) wins over the caller's; a
|
|
459
696
|
// background session's home stays until the run root is swept.
|
|
460
697
|
const bg = startBackground(built, sessionDir, runRootDir, { ...childEnv(deps.env ?? process.env), ...(built.env ?? {}) });
|
|
461
|
-
|
|
462
|
-
|
|
698
|
+
// a detached child's tokens are never read back: its price is unknown (use `ledger add coder`)
|
|
699
|
+
try {
|
|
700
|
+
if (writeRow) await writeRow({ event: 'session.background', ...base, pid: bg.pid, status: 'started', usd: null, usd_unknown: true });
|
|
701
|
+
} finally {
|
|
702
|
+
releaseReservation();
|
|
703
|
+
}
|
|
704
|
+
return { waitMs: null, result: { status: 'started', ...bg, provider: step.provider, model: step.model, effort: step.effort ?? null, fallback_step: step.fallback_step, attempts } };
|
|
463
705
|
}
|
|
464
706
|
|
|
465
707
|
const attempt = await runForeground(built, opts, deps);
|
|
@@ -471,24 +713,117 @@ export async function spawnSession(opts, deps = {}) {
|
|
|
471
713
|
tokens_in: attempt.usage.tokens_in,
|
|
472
714
|
tokens_out: attempt.usage.tokens_out,
|
|
473
715
|
tokens_source: attempt.usage.tokens_source,
|
|
716
|
+
...sessionUsd(step.provider, level, attempt.usage),
|
|
474
717
|
duration_ms: attempt.duration_ms,
|
|
475
718
|
...(level === 'L3' && step.fallback_step > 0 ? { l3_fallback: true } : {}),
|
|
476
719
|
};
|
|
477
|
-
|
|
720
|
+
try {
|
|
721
|
+
if (writeRow) await writeRow(row);
|
|
722
|
+
} finally {
|
|
723
|
+
releaseReservation(); // B41: the row now carries the spend (or the write failed)
|
|
724
|
+
}
|
|
478
725
|
attempts.push({ fallback_step: step.fallback_step, provider: step.provider, model: step.model, status: attempt.status, reason: attempt.reason ?? null });
|
|
479
|
-
if (attempt.status === 'unavailable' && attempt.reason === 'rate-limited' &&
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
726
|
+
if (attempt.status === 'unavailable' && attempt.reason === 'rate-limited' && rateLimitWaits < RATE_LIMIT_BACKOFF_MS.length) {
|
|
727
|
+
// B40: back off (2 s, then 6 s, ±30 %) and try the same step again — never at once
|
|
728
|
+
const waitMs = backoffMs(rateLimitWaits, random);
|
|
729
|
+
rateLimitWaits += 1;
|
|
730
|
+
return { waitMs, result: null };
|
|
483
731
|
}
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
return { ...attempt, provider: step.provider, model: step.model, effort: step.effort ?? null, fallback_step: step.fallback_step, row, attempts };
|
|
732
|
+
if (attempt.status === 'unavailable' && !isLast) return { waitMs: null, result: null };
|
|
733
|
+
return { waitMs: null, result: { ...attempt, provider: step.provider, model: step.model, effort: step.effort ?? null, fallback_step: step.fallback_step, row, attempts } };
|
|
487
734
|
} finally {
|
|
488
735
|
if (!keep) rmSync(sessionDir, { recursive: true, force: true });
|
|
489
736
|
}
|
|
490
737
|
}
|
|
491
|
-
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* B33: a session row's dollars from its tokens and the static price table — `{usd, cost_source:
|
|
742
|
+
* 'estimated'}`, or `{usd: null, usd_unknown: true}` when the provider/level has no price.
|
|
743
|
+
* @param {string} provider @param {string} level @param {{tokens_in: number, tokens_out: number}} usage
|
|
744
|
+
*/
|
|
745
|
+
function sessionUsd(provider, level, usage) {
|
|
746
|
+
const priced = priceSession({ provider, level, tokens_in: usage.tokens_in, tokens_out: usage.tokens_out });
|
|
747
|
+
return priced.usd_unknown ? { usd: null, usd_unknown: true } : { usd: priced.usd, cost_source: 'estimated' };
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* B33, R6 — the single budget choke point: before every spawn attempt of every role, a run with
|
|
752
|
+
* `budget.usd` set reads its spend from the ledger (`deps.readRows`, else the `slug` ledger).
|
|
753
|
+
* ≥ 80 % prints and records one warning per run; ≥ 100 % refuses with a plain message and a
|
|
754
|
+
* `budget.refused` row, and nothing is spawned. No `budget.usd` or no `run` ⇒ no check. Fail
|
|
755
|
+
* closed: with a budget and a run but no way to read the spend (no `readRows`, no `slug`, or the
|
|
756
|
+
* read throws) the session is refused too. It sits at the top of the ladder loop, so every attempt
|
|
757
|
+
* — each fallback step, the rate-limit retry of a step, and every `spawnSession` call a caller
|
|
758
|
+
* makes again (B30's timeout retry) — passes it.
|
|
759
|
+
*
|
|
760
|
+
* B41: with a budget, the ledger read, the check and the reservation of this attempt's estimated
|
|
761
|
+
* cost (`estimateSessionUsd`: the packet's bytes / 4 in, a fixed tokens_out per role and level)
|
|
762
|
+
* run under the run's budget lock, so concurrent sessions of one run see each other's holds. No
|
|
763
|
+
* budget (or no run) ⇒ no lock, no reservation.
|
|
764
|
+
* @param {SessionOpts} opts @param {SessionDeps} deps
|
|
765
|
+
* @param {((row: Record<string, any>) => Promise<unknown>) | null} writeRow
|
|
766
|
+
* @param {{write: (s: string) => unknown}} stderr
|
|
767
|
+
* @param {{provider: string}} step - the ladder step about to run (its provider prices the estimate).
|
|
768
|
+
* @returns {Promise<{refused: SessionResult, release?: undefined} | {refused: null, release: () => void}>}
|
|
769
|
+
* the refusal result, or the (idempotent) release of this attempt's reservation.
|
|
770
|
+
*/
|
|
771
|
+
async function budgetGate(opts, deps, writeRow, stderr, step) {
|
|
772
|
+
const budget = budgetUsdOf(opts.cfg);
|
|
773
|
+
if (budget === null || typeof opts.run !== 'string' || opts.run.length === 0) return { refused: null, release: () => {} };
|
|
774
|
+
const run = opts.run;
|
|
775
|
+
const estimate = estimateSessionUsd({ provider: step.provider, level: opts.level, role: opts.role, tokensIn: packetTokens(opts) });
|
|
776
|
+
return withBudgetLock(run, async () => {
|
|
777
|
+
const refused = await checkGate(opts, deps, writeRow, stderr, budget, estimate);
|
|
778
|
+
if (refused) return { refused };
|
|
779
|
+
return { refused: null, release: reserveBudget(run, estimate) };
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* The packet's (or brief's) tokens plus the system prompt's, by the ledger's bytes / 4 rule (an
|
|
785
|
+
* unreadable packet counts 0 bytes; tokens_out is still reserved).
|
|
786
|
+
* @param {SessionOpts} opts @returns {number}
|
|
787
|
+
*/
|
|
788
|
+
function packetTokens(opts) {
|
|
789
|
+
return estimateTokens(fileSize(opts.promptPath) + (typeof opts.systemPromptText === 'string' ? Buffer.byteLength(opts.systemPromptText) : 0));
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* The B33 check proper (called under the budget lock): read the run's spend, fail closed, refuse
|
|
794
|
+
* when spent + reserved + `estimate` passes the budget.
|
|
795
|
+
* @param {SessionOpts} opts - `opts.run` is a non-empty string (checked by `budgetGate`).
|
|
796
|
+
* @param {SessionDeps} deps
|
|
797
|
+
* @param {((row: Record<string, any>) => Promise<unknown>) | null} writeRow
|
|
798
|
+
* @param {{write: (s: string) => unknown}} stderr @param {number} budget @param {number} estimate
|
|
799
|
+
* @returns {Promise<SessionResult | null>} the refusal result, or null to go on.
|
|
800
|
+
*/
|
|
801
|
+
async function checkGate(opts, deps, writeRow, stderr, budget, estimate) {
|
|
802
|
+
/** @type {Array<Record<string, any>> | null} */
|
|
803
|
+
let rows = null;
|
|
804
|
+
try {
|
|
805
|
+
const slug = opts.slug;
|
|
806
|
+
const read = deps.readRows ?? (slug ? () => readAllRows(slug) : null);
|
|
807
|
+
const got = read ? await read() : null;
|
|
808
|
+
rows = Array.isArray(got) ? got : null;
|
|
809
|
+
} catch {
|
|
810
|
+
rows = null;
|
|
811
|
+
}
|
|
812
|
+
if (rows === null) {
|
|
813
|
+
// fail closed: a budget we cannot check is never treated as "not reached"
|
|
814
|
+
writeSafe(stderr, `code-forge: ${SPEND_UNREADABLE_MESSAGE}\n`);
|
|
815
|
+
if (writeRow) {
|
|
816
|
+
try {
|
|
817
|
+
await writeRow({ event: 'budget.refused', run: opts.run, block: opts.block ?? null, role: opts.role, budget_usd: budget, spent_usd: null, reason: 'spend-unreadable' });
|
|
818
|
+
} catch {
|
|
819
|
+
// the refusal stands without its row
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
return { status: 'unavailable', reason: 'budget', message: SPEND_UNREADABLE_MESSAGE, answer: null, spent_usd: null, budget_usd: budget };
|
|
823
|
+
}
|
|
824
|
+
const verdict = await checkBudget({ budget, run: /** @type {string} */ (opts.run), rows, block: opts.block ?? null, role: opts.role, writeRow, stderr, reserved: reservedUsd(/** @type {string} */ (opts.run)), estimate });
|
|
825
|
+
if (!verdict.refuse) return null;
|
|
826
|
+
return { status: 'unavailable', reason: 'budget', message: verdict.message, answer: null, spent_usd: verdict.spent, budget_usd: budget };
|
|
492
827
|
}
|
|
493
828
|
|
|
494
829
|
/**
|
|
@@ -498,8 +833,9 @@ export async function spawnSession(opts, deps = {}) {
|
|
|
498
833
|
* @param {SessionOpts} opts
|
|
499
834
|
* @param {{provider: string, model: string, effort?: string, flagFallback?: {provider: string, model: string}}} step
|
|
500
835
|
* @param {string} sessionDir
|
|
836
|
+
* @param {boolean} openBook - B32: `review.allow_open_book_codex`, read once by `spawnSession`.
|
|
501
837
|
*/
|
|
502
|
-
function buildStep(opts, step, sessionDir) {
|
|
838
|
+
function buildStep(opts, step, sessionDir, openBook) {
|
|
503
839
|
const closedBook = opts.role !== 'coder';
|
|
504
840
|
// B9b.1: the facts delegate may run in the caller's read-only project snapshot; nobody else may
|
|
505
841
|
const snapshot = opts.role === 'facts' && opts.cwd !== undefined ? realpathSync(path.resolve(opts.cwd)) : null; // validated by assertFactsCwd
|
|
@@ -514,6 +850,8 @@ function buildStep(opts, step, sessionDir) {
|
|
|
514
850
|
if (cli === 'claude' && opts.maxBudgetUsd !== undefined) params.maxBudgetUsd = opts.maxBudgetUsd;
|
|
515
851
|
if (closedBook && opts.systemPromptText !== undefined && cli !== 'codex') params.systemPromptText = opts.systemPromptText;
|
|
516
852
|
if (cli === 'codex') params.outPath = path.join(sessionDir, 'out.json');
|
|
853
|
+
// B32 opt-in: only a no-tools role needs it; coder and facts build exactly as without it
|
|
854
|
+
if (cli === 'codex' && openBook && isNoToolRole(opts.role)) params.allowOpenBook = true;
|
|
517
855
|
/** @type {Record<string, any> | null} */
|
|
518
856
|
let compiled = null;
|
|
519
857
|
const strict = variantFor(step.provider) === 'strict';
|
|
@@ -570,7 +908,22 @@ async function runForeground(built, opts, deps) {
|
|
|
570
908
|
|
|
571
909
|
const unavailable = classifyUnavailable(res, read.parsed);
|
|
572
910
|
if (unavailable) return { status: 'unavailable', reason: unavailable, answer: null, ...common };
|
|
573
|
-
if (res.timedOut)
|
|
911
|
+
if (res.timedOut) {
|
|
912
|
+
// B30: what the CLI printed last, so a hang's cause can be seen — stderr redacted, capped and
|
|
913
|
+
// packet-free; stdout only as event types and a byte count, never its text
|
|
914
|
+
/** @type {string | null} */
|
|
915
|
+
let packetText = input !== undefined ? Buffer.from(input).toString('utf8') : null;
|
|
916
|
+
if (packetText === null) {
|
|
917
|
+
try {
|
|
918
|
+
packetText = readFileSync(opts.promptPath, 'utf8');
|
|
919
|
+
} catch {
|
|
920
|
+
packetText = null; // fail closed: no tails without the packet to filter against
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
const err = stderrTail(res.stderr, packetText);
|
|
924
|
+
const tails = { stderr_tail: err.tail, ...(err.note ? { tail_note: err.note } : {}), ...stdoutEvents(res.stdout) };
|
|
925
|
+
return { status: 'timeout', reason: `killed after ${opts.timeoutMs ?? DEFAULT_TIMEOUT_MS} ms`, answer: null, ...common, ...tails };
|
|
926
|
+
}
|
|
574
927
|
if (res.result !== 'ok' || read.parsed?.is_error === true) return { status: 'failed', reason: `exit ${res.code}`, answer: null, ...common };
|
|
575
928
|
if (built.compiled) {
|
|
576
929
|
// 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 };
|