@codedology/code-forge 0.11.0 → 0.13.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 +80 -0
- package/README.md +30 -2
- package/docs/reference/blocks.json +359 -1
- package/docs/reference/config.md +7 -5
- package/package.json +2 -2
- package/schema/code-forge.schema.json +4 -2
- package/skill/SKILL.md +2 -0
- package/skill/references/code.md +19 -5
- package/skill/references/proof.md +8 -2
- package/skill/references/review.md +16 -7
- package/skill/references/rules-core.md +1 -1
- package/skill/scripts/forge +1 -1
- package/skill/templates/coder-brief.md +15 -3
- package/src/autopilot/actions.mjs +4 -5
- package/src/cli/block.mjs +41 -0
- package/src/cli/context.mjs +3 -2
- package/src/cli/gates.mjs +15 -6
- package/src/cli/review-file.mjs +114 -3
- package/src/cli/review.mjs +2 -2
- package/src/config/validate.mjs +12 -5
- package/src/context/pack.mjs +20 -13
- package/src/doctor/index.mjs +2 -1
- package/src/doctor/local.mjs +19 -0
- package/src/doctor/worker-probe.mjs +15 -2
- package/src/gates/expand.mjs +102 -9
- package/src/gates/run.mjs +6 -4
- package/src/install/wizard/flags.mjs +1 -1
- package/src/ledger/spend.mjs +3 -3
- package/src/proof/lock.mjs +27 -10
- package/src/proof/rule.mjs +9 -0
- package/src/proof/tiers.mjs +2 -14
- package/src/review/fixloop.mjs +11 -6
- package/src/review/gate-check.mjs +34 -9
- package/src/review/lenses/A.md +4 -0
- package/src/review/lenses/B.md +4 -0
- package/src/review/lenses/full.md +4 -0
- package/src/review/lenses/judge.md +4 -0
- package/src/review/lenses/quick.md +4 -0
- package/src/review/lenses/recheck.md +4 -0
- package/src/review/paired.mjs +1 -0
- package/src/review/reopen.mjs +18 -6
- package/src/review/schema-fallback.mjs +3 -3
- package/src/util/exec.mjs +2 -1
- package/src/util/file-locks.mjs +118 -0
- package/src/util/forbidden.mjs +29 -2
- package/src/util/gate-token.mjs +3 -0
- package/src/worker/engine.mjs +2 -2
- package/src/worker/loop.mjs +98 -33
- package/src/worker/queue.mjs +183 -33
- package/src/worker/watch.mjs +115 -0
package/src/cli/review-file.mjs
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* review-file --wait <ticket> [--max <90s>] poll; prints {ticket, status: done, result} or
|
|
6
6
|
* {ticket, status: pending, reason: wait_timeout}
|
|
7
7
|
* when --max elapses (the review is still running)
|
|
8
|
+
* review-file --wait-block <id> --run <id> join owned changed files' current tickets;
|
|
9
|
+
* prints {block, files, done}; never reviews here
|
|
8
10
|
* review-file <answering file> --block <id> --answers <ids> --of <source> [--run <id>]
|
|
9
11
|
* B71: enqueue an answers ticket; prints {ticket,
|
|
10
12
|
* status, kind: answers, file: source, by_file, answers}
|
|
@@ -67,20 +69,22 @@
|
|
|
67
69
|
|
|
68
70
|
import { readFileSync, realpathSync, rmSync } from 'node:fs';
|
|
69
71
|
import path from 'node:path';
|
|
70
|
-
import { answersTicketId, assertAnswers, dropDoneIf, enqueue, enqueueAnswers, isDone, liveWorker, MAX_ANSWERS, queueDir, resolveTicket } from '../worker/queue.mjs';
|
|
72
|
+
import { answersTicketId, assertAnswers, blockTickets, deliverBlockFindings, dropDoneIf, enqueue, enqueueAnswers, isDone, liveWorker, MAX_ANSWERS, queueDir, resolveTicket } from '../worker/queue.mjs';
|
|
71
73
|
import { assertBlockId, assertInsideRoot, assertTicketId, contentHash, mainRootOf, normalizeRequestPath, repoRootOf, WorkerError } from '../worker/ticket.mjs';
|
|
72
|
-
import { namedOnHash } from '../review/gate-check.mjs';
|
|
74
|
+
import { blockFileSet, namedOnHash } from '../review/gate-check.mjs';
|
|
73
75
|
import { STOP_EXITS } from '../review/stop-exits.mjs';
|
|
74
76
|
import { ownsFile } from '../state/registry.mjs';
|
|
75
77
|
import { readAllRows } from '../ledger/write.mjs';
|
|
76
78
|
import { parseFlags } from '../state/cli-args.mjs';
|
|
77
79
|
import { StateError } from '../state/paths.mjs';
|
|
80
|
+
import { assertRunId } from '../state/paths.mjs';
|
|
78
81
|
import { readRun } from '../state/run.mjs';
|
|
79
82
|
import { blockTree } from '../proof/worktree.mjs';
|
|
80
83
|
import { writeSafe } from '../util/redact.mjs';
|
|
81
84
|
|
|
82
85
|
const USAGE =
|
|
83
86
|
'usage: code-forge review-file <path> --block <id> [--run <id>] [--fresh] | review-file --wait <ticket> [--max <seconds>s]\n' +
|
|
87
|
+
' code-forge review-file --wait-block <id> --run <id> [--max <seconds>s]\n' +
|
|
84
88
|
' code-forge review-file <answering file> --block <id> --answers <id,id,…> --of <source> [--run <id>]\n';
|
|
85
89
|
|
|
86
90
|
export const DEFAULT_WAIT_MS = 90_000;
|
|
@@ -355,6 +359,106 @@ export function fixList(result) {
|
|
|
355
359
|
return out;
|
|
356
360
|
}
|
|
357
361
|
|
|
362
|
+
/**
|
|
363
|
+
* B100: read block scope from the workspace mirror, never the private run record. This is a
|
|
364
|
+
* progress summary, not an approval: block close still verifies the authoritative signed rows.
|
|
365
|
+
* @param {string} repoRoot @param {string} cwd @param {string} run @param {string} block
|
|
366
|
+
* @returns {Promise<{entry: Record<string, any>, tree: string}>}
|
|
367
|
+
*/
|
|
368
|
+
async function waitBlockScope(repoRoot, cwd, run, block) {
|
|
369
|
+
const top = await repoRootOf(cwd);
|
|
370
|
+
const candidates = new Set([repoRoot]);
|
|
371
|
+
for (let dir = realpathSync(cwd); ; dir = path.dirname(dir)) {
|
|
372
|
+
candidates.add(dir);
|
|
373
|
+
if (dir === top || dir === path.dirname(dir)) break;
|
|
374
|
+
}
|
|
375
|
+
for (const dir of candidates) {
|
|
376
|
+
let record;
|
|
377
|
+
try {
|
|
378
|
+
record = JSON.parse(readFileSync(path.join(dir, '.code-forge', 'runs', `${run}.json`), 'utf8'));
|
|
379
|
+
} catch (err) {
|
|
380
|
+
if (err.code === 'ENOENT') continue;
|
|
381
|
+
throw new WorkerError('block-scope-unreadable', 'the block scope cannot be read');
|
|
382
|
+
}
|
|
383
|
+
const entry = record?.blocks?.[block];
|
|
384
|
+
if (record?.run_id !== run || !entry || !Array.isArray(entry.owned_files) || entry.owned_files.length === 0) {
|
|
385
|
+
throw new WorkerError('block-scope-unreadable', 'the block scope cannot be read');
|
|
386
|
+
}
|
|
387
|
+
const tree = (await blockTree(record.workspace, entry))?.top ?? repoRoot;
|
|
388
|
+
return { entry, tree };
|
|
389
|
+
}
|
|
390
|
+
throw new WorkerError('block-scope-unreadable', 'the block scope cannot be read');
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** A block wait reads only the marker, never review results or the signing key.
|
|
394
|
+
* @param {string} repoRoot @param {string} ticket @returns {Record<string, any> | null}
|
|
395
|
+
*/
|
|
396
|
+
function blockTicketMarker(repoRoot, ticket) {
|
|
397
|
+
try {
|
|
398
|
+
const marker = JSON.parse(readFileSync(path.join(queueDir(repoRoot), `${ticket}.done`), 'utf8'));
|
|
399
|
+
if (marker?.ticket === ticket && typeof marker.status === 'string' && marker.status.length > 0) return marker;
|
|
400
|
+
} catch (err) {
|
|
401
|
+
if (err.code === 'ENOENT') return null;
|
|
402
|
+
}
|
|
403
|
+
throw new WorkerError('result-unreadable', 'a ticket completion marker cannot be read');
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* A block wait reads only completion/delivery markers, never review results or the signing key.
|
|
408
|
+
* Re-scan current content on every poll. Deliver unseen findings of the newest earlier ticket
|
|
409
|
+
* before rechecking a blind edit; the next join queues the fix. Stopped files stay finished.
|
|
410
|
+
* @param {{repoRoot: string, cwd: string, block: string, run: string, maxMs: number, pollMs: number, say: (obj: Record<string, any>) => unknown}} opts
|
|
411
|
+
*/
|
|
412
|
+
async function waitBlock({ repoRoot, cwd, block, run, maxMs, pollMs, say }) {
|
|
413
|
+
const { entry, tree } = await waitBlockScope(repoRoot, cwd, run, block);
|
|
414
|
+
const where = realpathSync(tree) === realpathSync(repoRoot) ? {} : { tree: realpathSync(tree) };
|
|
415
|
+
const deadline = Date.now() + maxMs;
|
|
416
|
+
let downPolls = 0;
|
|
417
|
+
const deliveredHere = new Set();
|
|
418
|
+
for (;;) {
|
|
419
|
+
const worker = liveWorker(repoRoot);
|
|
420
|
+
const live = worker?.run === run;
|
|
421
|
+
downPolls = live ? 0 : downPolls + 1;
|
|
422
|
+
const tickets = blockTickets({ repoRoot, run, block, ...where });
|
|
423
|
+
const current = await blockFileSet({ repoRoot: tree, base: entry.base_sha, owned: entry.owned_files, ownedKinds: entry.owned_kinds });
|
|
424
|
+
const files = current.map(({ file }) => {
|
|
425
|
+
const opts = { repoRoot, run, block, file, ...where };
|
|
426
|
+
const { ticket } = resolveTicket(opts);
|
|
427
|
+
const previous = tickets.filter((t) => t.file === file);
|
|
428
|
+
let present = previous.find((t) => t.ticket === ticket);
|
|
429
|
+
if (!present && previous.length > 0) {
|
|
430
|
+
const newest = previous[previous.length - 1];
|
|
431
|
+
const marker = blockTicketMarker(repoRoot, newest.ticket);
|
|
432
|
+
if (marker?.status === 'stopped') return { file, ticket: newest.ticket, status: 'done' };
|
|
433
|
+
if (marker?.status === 'reviewed' && marker.open_findings === true && live) {
|
|
434
|
+
if (deliveredHere.has(newest.ticket) || deliverBlockFindings({ repoRoot, block, file, ticket: newest.ticket })) {
|
|
435
|
+
deliveredHere.add(newest.ticket);
|
|
436
|
+
return { file, ticket: newest.ticket, status: 'findings' };
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
if (!present && previous.length > 0 && live) {
|
|
441
|
+
enqueue(opts);
|
|
442
|
+
present = { ...previous[previous.length - 1], ticket };
|
|
443
|
+
}
|
|
444
|
+
return { file, ticket: present ? ticket : null, status: !present ? 'not_queued' : blockTicketMarker(repoRoot, ticket) ? 'done' : 'pending' };
|
|
445
|
+
});
|
|
446
|
+
if (downPolls >= 2) {
|
|
447
|
+
say({ block, files, done: false, status: 'worker_down' });
|
|
448
|
+
return 3;
|
|
449
|
+
}
|
|
450
|
+
if (live && files.every((f) => f.status === 'done' || f.status === 'findings')) {
|
|
451
|
+
say({ block, files, done: true });
|
|
452
|
+
return 0;
|
|
453
|
+
}
|
|
454
|
+
if (live && (files.some((f) => f.status === 'not_queued') || Date.now() >= deadline)) {
|
|
455
|
+
say({ block, files, done: false, reason: files.some((f) => f.status === 'not_queued') ? 'not_queued' : 'wait_timeout' });
|
|
456
|
+
return 0;
|
|
457
|
+
}
|
|
458
|
+
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
358
462
|
/**
|
|
359
463
|
* @param {string[]} args
|
|
360
464
|
* @param {{stdout?: {write: (s: string) => unknown}, stderr?: {write: (s: string) => unknown}, cwd?: string, pollMs?: number, dropDone?: typeof dropDoneIf}} [deps] - `dropDone`: B75's claim, for tests.
|
|
@@ -366,10 +470,17 @@ export async function runReviewFile(args, deps = {}) {
|
|
|
366
470
|
const say = (/** @type {Record<string, any>} */ obj) => writeSafe(stdout, `${JSON.stringify(obj)}\n`);
|
|
367
471
|
const cwd = deps.cwd ?? process.cwd();
|
|
368
472
|
try {
|
|
369
|
-
const { flags, positionals } = parseFlags(args, { values: ['block', 'run', 'wait', 'max', 'answers', 'of'], booleans: ['fresh'] });
|
|
473
|
+
const { flags, positionals } = parseFlags(args, { values: ['block', 'run', 'wait', 'wait-block', 'max', 'answers', 'of'], booleans: ['fresh'] });
|
|
370
474
|
// the queue and the worker: the main tree, also from inside a block's worktree
|
|
371
475
|
const repoRoot = await mainRootOf(cwd);
|
|
372
476
|
|
|
477
|
+
if (flags['wait-block'] !== undefined) {
|
|
478
|
+
if (positionals.length > 0 || flags.block !== undefined || flags.wait !== undefined || flags.answers !== undefined || flags.of !== undefined || flags.fresh === true || typeof flags.run !== 'string') {
|
|
479
|
+
throw new WorkerError('usage', '--wait-block takes a block, --run and --max only');
|
|
480
|
+
}
|
|
481
|
+
return await waitBlock({ repoRoot, cwd, block: assertBlockId(flags['wait-block']), run: assertRunId(flags.run), maxMs: flags.max === undefined ? DEFAULT_WAIT_MS : parseMax(flags.max), pollMs: deps.pollMs ?? POLL_MS, say });
|
|
482
|
+
}
|
|
483
|
+
|
|
373
484
|
if (typeof flags.wait === 'string') {
|
|
374
485
|
if (positionals.length > 0 || flags.block !== undefined || flags.answers !== undefined || flags.of !== undefined || flags.fresh === true) throw new WorkerError('usage', '--wait takes only a ticket and --max');
|
|
375
486
|
const ticket = assertTicketId(flags.wait);
|
package/src/cli/review.mjs
CHANGED
|
@@ -48,7 +48,7 @@ import { semaphore } from '../util/locks.mjs';
|
|
|
48
48
|
import { writeSafe } from '../util/redact.mjs';
|
|
49
49
|
import { isAlive, listEntries, readStartTime, UNKNOWN_START_TIME } from '../util/reaper.mjs';
|
|
50
50
|
import { pidsDir, runRoot, setRunRoot, tmpBase } from '../util/tmp.mjs';
|
|
51
|
-
import {
|
|
51
|
+
import { liveWorker } from '../worker/queue.mjs';
|
|
52
52
|
import { gitChildEnv, normalizeRequestPath, repoRootOf, WorkerError } from '../worker/ticket.mjs';
|
|
53
53
|
|
|
54
54
|
const BIN = fileURLToPath(new URL('../../bin/code-forge.mjs', import.meta.url));
|
|
@@ -288,7 +288,7 @@ async function runWorkers(repoRoot, runId) {
|
|
|
288
288
|
} catch {
|
|
289
289
|
// no record: nothing pinned
|
|
290
290
|
}
|
|
291
|
-
const w =
|
|
291
|
+
const w = liveWorker(repoRoot);
|
|
292
292
|
if (w?.run === runId && !found.some((f) => f.pid === w.pid)) {
|
|
293
293
|
found.push({ pid: w.pid, same: async () => w.start_time === UNKNOWN_START_TIME || readStartTime(w.pid) === w.start_time });
|
|
294
294
|
}
|
package/src/config/validate.mjs
CHANGED
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
import { Ajv2020 } from 'ajv/dist/2020.js';
|
|
67
67
|
import schema from '../../schema/code-forge.schema.json' with { type: 'json' };
|
|
68
68
|
import { redact } from '../util/redact.mjs';
|
|
69
|
-
import { TEST_FILES_TOKEN } from '../util/gate-token.mjs';
|
|
69
|
+
import { TEST_FILES_TOKEN, CHANGED_FILES_TOKEN } from '../util/gate-token.mjs';
|
|
70
70
|
import { checkEffort, displayEffort } from '../engines/efforts.mjs';
|
|
71
71
|
import { CLOSED_BOOK_LEVELS, CODEX_CLOSED_BOOK_REFUSAL, isNoClosedBookProvider, OPEN_BOOK_CODEX_WARNING, openBookCodexAllowed } from './closed-book.mjs';
|
|
72
72
|
import { isKnownId, resolveLevel } from './known-ids.mjs';
|
|
@@ -484,10 +484,10 @@ function gateMessages(key, value) {
|
|
|
484
484
|
// full-suite run would be an empty argv
|
|
485
485
|
/** @param {string[]} argv @returns {string[]} */
|
|
486
486
|
const tokenMessages = (argv) =>
|
|
487
|
-
argv.length === 1 && argv[0]
|
|
488
|
-
? [`gates.${key}: '${
|
|
489
|
-
: argv[0]
|
|
490
|
-
? [`gates.${key}: '${
|
|
487
|
+
argv.length === 1 && [TEST_FILES_TOKEN, CHANGED_FILES_TOKEN].includes(argv[0])
|
|
488
|
+
? [`gates.${key}: '${argv[0]}' cannot be the only item (the full suite would run an empty command)`]
|
|
489
|
+
: [TEST_FILES_TOKEN, CHANGED_FILES_TOKEN].includes(argv[0])
|
|
490
|
+
? [`gates.${key}: '${argv[0]}' cannot be the command (argv[0])`]
|
|
491
491
|
: [];
|
|
492
492
|
if (typeof value !== 'object' || Array.isArray(value)) {
|
|
493
493
|
if (!isArgvArray(value)) return [`gates.${key} must be a non-empty argv array of strings, an {argv, env} object, or null`];
|
|
@@ -1162,3 +1162,10 @@ export function validateConfig(cfg, opts = {}) {
|
|
|
1162
1162
|
}
|
|
1163
1163
|
|
|
1164
1164
|
export { schema as configSchema };
|
|
1165
|
+
|
|
1166
|
+
/** The effective file loop for a validated config, including opt-in commit watch.
|
|
1167
|
+
* @param {Record<string, any>} cfg @returns {string}
|
|
1168
|
+
*/
|
|
1169
|
+
export function reviewFileLoop(cfg) {
|
|
1170
|
+
return cfg?.review?.file_loop ?? 'wait';
|
|
1171
|
+
}
|
package/src/context/pack.mjs
CHANGED
|
@@ -12,11 +12,11 @@
|
|
|
12
12
|
*
|
|
13
13
|
* `assemblePack` turns it into ONE pack for a block, in this order: the header (run, block, level,
|
|
14
14
|
* owned files, test files when the block has them, worktree path and branch when it has one), the
|
|
15
|
-
* commands (never run; each argv shown POSIX shell-quoted, so a pasted line expands nothing),
|
|
16
|
-
* rules file, then the lessons file — every file section headed by its
|
|
17
|
-
* file: the heading alone). Over `max_lines` the lessons are cut first, then the
|
|
15
|
+
* commands (never run; each argv shown POSIX shell-quoted, so a pasted line expands nothing), the
|
|
16
|
+
* built-in proof rule, each rules file, then the lessons file — every file section headed by its
|
|
17
|
+
* source path (an empty file: the heading alone). Over `max_lines` the lessons are cut first, then the
|
|
18
18
|
* rules from the last file backwards, each cut section ending in `[cut: N lines dropped]`; the
|
|
19
|
-
* header and
|
|
19
|
+
* header, commands and proof rule are never cut; all count toward the budget.
|
|
20
20
|
*
|
|
21
21
|
* `reviewDigest` is the ONE builder of the review packets' rules digest (`## project rules`, ≤ 60
|
|
22
22
|
* lines, `packet.mjs`): the rules files' text, then a `### lessons` subheading and the lessons.
|
|
@@ -41,6 +41,7 @@ import path from 'node:path';
|
|
|
41
41
|
import { estimateTokens } from '../review/packet.mjs';
|
|
42
42
|
import { splitLines } from '../review/context.mjs';
|
|
43
43
|
import { redact } from '../util/redact.mjs';
|
|
44
|
+
import { PROOF_RULE } from '../proof/rule.mjs';
|
|
44
45
|
|
|
45
46
|
/** The pack's line cap when `context.max_lines` is not set. */
|
|
46
47
|
export const DEFAULT_MAX_LINES = 300;
|
|
@@ -58,7 +59,7 @@ export const DIGEST_LESSONS_HEADING = '### lessons';
|
|
|
58
59
|
* A refusal with a stable `code`. Thrown here: `bad-context` (a bad `context` value),
|
|
59
60
|
* `control-character` (in a path or a header value), `outside`, `missing` (ENOENT), `unreadable`
|
|
60
61
|
* (any other lstat or read error, with its code), `not-file`. Thrown by `src/cli/context.mjs`:
|
|
61
|
-
* `no-block`, `bad-config`, `no-
|
|
62
|
+
* `no-block`, `bad-config`, `no-run-root`, `write-failed`.
|
|
62
63
|
*/
|
|
63
64
|
export class ContextError extends Error {
|
|
64
65
|
/** @param {string} code @param {string} message */
|
|
@@ -215,11 +216,12 @@ function readLines(repoRoot, rel, key) {
|
|
|
215
216
|
|
|
216
217
|
/**
|
|
217
218
|
* Every rules file and the lessons file, read. A missing or outside file is a refusal.
|
|
218
|
-
* @param {string} repoRoot @param {ContextSettings} settings
|
|
219
|
+
* @param {string} repoRoot @param {ContextSettings | null} settings
|
|
219
220
|
* @returns {{rules: Array<{path: string, lines: string[]}>, lessons: {path: string, lines: string[]} | null}}
|
|
220
221
|
* @throws {ContextError}
|
|
221
222
|
*/
|
|
222
223
|
export function readSources(repoRoot, settings) {
|
|
224
|
+
if (settings === null) return { rules: [], lessons: null };
|
|
223
225
|
const rules = settings.rules.map((rel) => ({ path: rel, lines: readLines(repoRoot, rel, 'context.rules') }));
|
|
224
226
|
const lessons = settings.lessons === null ? null : { path: settings.lessons, lines: readLines(repoRoot, settings.lessons, 'context.lessons') };
|
|
225
227
|
return { rules, lessons };
|
|
@@ -248,7 +250,8 @@ export const argvText = (argv) => argv.map(argvToken).join(' ');
|
|
|
248
250
|
|
|
249
251
|
/**
|
|
250
252
|
* Build the pack text for a block (pure over what it is given).
|
|
251
|
-
*
|
|
253
|
+
* Missing settings give a header and proof rule only, with the default line cap.
|
|
254
|
+
* @param {{settings: ContextSettings | null, sources: ReturnType<typeof readSources>, block: PackBlock}} opts
|
|
252
255
|
* @returns {{text: string, lines: number, cut: number}} `cut`: the lines dropped.
|
|
253
256
|
* @throws {ContextError} `control-character` when a header value holds one.
|
|
254
257
|
*/
|
|
@@ -266,11 +269,15 @@ export function assemblePack({ settings, sources, block }) {
|
|
|
266
269
|
for (const f of block.tests) header.push(`- ${f}`);
|
|
267
270
|
}
|
|
268
271
|
if (block.worktree) header.push(`worktree: ${block.worktree.path}`, `branch: ${block.worktree.branch}`);
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
272
|
+
if (settings !== null) {
|
|
273
|
+
header.push('## commands (never run by code-forge)');
|
|
274
|
+
const keys = COMMAND_KEYS.filter((k) => Object.hasOwn(settings.commands, k));
|
|
275
|
+
if (keys.length === 0) header.push('(none)');
|
|
276
|
+
// a command line can name a secret (a token in an argv): it is redacted as rules and lessons are
|
|
277
|
+
for (const k of keys) header.push(`${k}: ${redact(argvText(settings.commands[k]))}`);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
header.push(...splitLines(PROOF_RULE));
|
|
274
281
|
|
|
275
282
|
/** @type {Array<{heading: string, body: string[], dropped: number}>} */
|
|
276
283
|
const sections = sources.rules.map((r) => ({ heading: `## rules: ${r.path}`, body: [...r.lines], dropped: 0 }));
|
|
@@ -279,7 +286,7 @@ export function assemblePack({ settings, sources, block }) {
|
|
|
279
286
|
const total = () => header.length + sections.reduce((n, s) => n + 1 + s.body.length + (s.dropped > 0 ? 1 : 0), 0);
|
|
280
287
|
// cut order: the lessons first, then the rules from the last file backwards
|
|
281
288
|
for (const s of [...sections].reverse()) {
|
|
282
|
-
let excess = total() - settings
|
|
289
|
+
let excess = total() - (settings?.maxLines ?? DEFAULT_MAX_LINES);
|
|
283
290
|
if (excess <= 0) break;
|
|
284
291
|
// the marker takes one line: a section is only cut when that leaves it shorter
|
|
285
292
|
if (s.body.length < 2) continue;
|
package/src/doctor/index.mjs
CHANGED
|
@@ -13,7 +13,7 @@ import path from 'node:path';
|
|
|
13
13
|
import { cliNameForProvider } from '../engines/provider-cli.mjs';
|
|
14
14
|
import { newRunId } from '../state/paths.mjs';
|
|
15
15
|
import { runRoot } from '../util/tmp.mjs';
|
|
16
|
-
import { checkCommands, checkConfig, checkJev, checkKeys, checkLedger, checkLinks, checkReviewConcurrency, checkReviewState, checkTmp, checkTools } from './local.mjs';
|
|
16
|
+
import { checkCommands, checkConfig, checkJev, checkKeys, checkLedger, checkLinks, checkLocks, checkReviewConcurrency, checkReviewState, checkTmp, checkTools } from './local.mjs';
|
|
17
17
|
import { checkCoderSandbox, configuredProviders, pingRoles, probeCli, probeCodexRules, probeIsolation, probePathDeny } from './probes.mjs';
|
|
18
18
|
import { row } from './rows.mjs';
|
|
19
19
|
import { probeWorkerAndSigner, SIGNER_ROW } from './worker-probe.mjs';
|
|
@@ -67,6 +67,7 @@ export async function runDoctor(opts = {}, deps = {}) {
|
|
|
67
67
|
};
|
|
68
68
|
rows.push(...(await checkTools(toolOpts)));
|
|
69
69
|
rows.push(...(await checkLedger()));
|
|
70
|
+
rows.push(...(await checkLocks()));
|
|
70
71
|
rows.push(...checkTmp(cfg));
|
|
71
72
|
rows.push(...(await checkReviewState(cfg)));
|
|
72
73
|
rows.push(checkReviewConcurrency(cfg));
|
package/src/doctor/local.mjs
CHANGED
|
@@ -25,13 +25,32 @@ import { assertRunId, runsDir } from '../state/paths.mjs';
|
|
|
25
25
|
import { readRun } from '../state/run.mjs';
|
|
26
26
|
import { loadKey } from '../state/signer.mjs';
|
|
27
27
|
import { listEntries } from '../util/reaper.mjs';
|
|
28
|
+
import { listBadLocks } from '../util/file-locks.mjs';
|
|
28
29
|
import { ownerState, runRootPath, tmpBase } from '../util/tmp.mjs';
|
|
29
30
|
import { parallelTickets } from '../worker/loop.mjs';
|
|
31
|
+
import { workerRegistry } from '../worker/queue.mjs';
|
|
32
|
+
import { mainRootOf } from '../worker/ticket.mjs';
|
|
30
33
|
import { missingStateFiles } from '../worker/review-state.mjs';
|
|
31
34
|
import { row } from './rows.mjs';
|
|
32
35
|
|
|
33
36
|
/** @typedef {import('./rows.mjs').Row} Row */
|
|
34
37
|
|
|
38
|
+
/** @param {string} [cwd] @returns {Promise<Row[]>} */
|
|
39
|
+
export async function checkLocks(cwd = process.cwd()) {
|
|
40
|
+
try {
|
|
41
|
+
const paths = listBadLocks();
|
|
42
|
+
const rows = paths.length === 0
|
|
43
|
+
? [row('locks', 'OK', 'locks', 'no unreadable or malformed locks')]
|
|
44
|
+
: paths.map((file) => row('locks', 'WARN', 'locks', file));
|
|
45
|
+
let repoRoot = cwd;
|
|
46
|
+
try { repoRoot = await mainRootOf(cwd); } catch { /* non-repository doctor */ }
|
|
47
|
+
rows.push(...workerRegistry(repoRoot).filter((entry) => entry.worker === null).map((entry) => row('worker-registry', 'WARN', 'worker registry', entry.file)));
|
|
48
|
+
return rows;
|
|
49
|
+
} catch (err) {
|
|
50
|
+
return [row('locks', 'WARN', 'locks', `could not list locks (${err?.code ?? 'error'})`)];
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
35
54
|
/** The ids of the `--quick` rows, in print order. */
|
|
36
55
|
export const QUICK_IDS = Object.freeze(['config', 'links', 'keys']);
|
|
37
56
|
|
|
@@ -11,8 +11,9 @@
|
|
|
11
11
|
* MAC is tamper evidence within ordinary tool use, never a boundary against a same-user process.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import { copyFileSync, mkdirSync, readdirSync, rmSync } from 'node:fs';
|
|
14
|
+
import { copyFileSync, mkdirSync, readdirSync, rmSync, symlinkSync } from 'node:fs';
|
|
15
15
|
import path from 'node:path';
|
|
16
|
+
import { commandOnPath } from '../install/detect.mjs';
|
|
16
17
|
import { runsDir } from '../state/paths.mjs';
|
|
17
18
|
import { startRun } from '../state/run.mjs';
|
|
18
19
|
import { loadKey, signRow, verifyRow } from '../state/signer.mjs';
|
|
@@ -94,7 +95,19 @@ export async function probeWorkerAndSigner(ctx) {
|
|
|
94
95
|
const signerOk = verifyRow(signed, key).ok && verifyRow(flipped, key).reason === 'mismatch';
|
|
95
96
|
rows.push(row('signer-key', signerOk ? 'OK' : 'FAIL', 'signer key', signerOk ? 'generated; a signed row verifies, a changed one is refused' : 'sign/verify round trip failed'));
|
|
96
97
|
|
|
97
|
-
|
|
98
|
+
// The boot lock needs ps even with a restricted provider PATH. Expose just that utility.
|
|
99
|
+
let workerEnv = env;
|
|
100
|
+
if (!await commandOnPath('ps', { pathEnv: env.PATH ?? '' })) {
|
|
101
|
+
const tools = path.join(repo, 'worker-tools');
|
|
102
|
+
for (const systemDir of ['/bin', '/usr/bin']) {
|
|
103
|
+
if (!await commandOnPath('ps', { pathEnv: systemDir })) continue;
|
|
104
|
+
mkdirSync(tools, { mode: 0o700 });
|
|
105
|
+
symlinkSync(path.join(systemDir, 'ps'), path.join(tools, 'ps'));
|
|
106
|
+
workerEnv = { ...env, PATH: [env.PATH, tools].filter(Boolean).join(path.delimiter) };
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const started = await launchWorker({ runId, workspace: repo, env: workerEnv });
|
|
98
111
|
pid = started.pid;
|
|
99
112
|
registerPid(pidsDir(ctx.runRootDir), pid, 'code-forge worker'); // a killed doctor's worker is reaped by the next sweep
|
|
100
113
|
const announced = liveWorker(started.repoRoot)?.pid === pid;
|
package/src/gates/expand.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* B60 (issue #13 item 3): run only the block's related tests, and give a gate its own environment.
|
|
3
|
-
*
|
|
3
|
+
* Scope and argv rules are pure; B99's fileBaseline reads bounded hashes from the workspace.
|
|
4
|
+
* A gate argv item that is EXACTLY {@link TEST_FILES_TOKEN}
|
|
4
5
|
* (one whole item, never a substring, never a shell string) stands for the block's test files:
|
|
5
6
|
*
|
|
6
7
|
* - the block's `test_files` (`block open --tests`), else its owned entries that are concrete
|
|
@@ -20,8 +21,14 @@
|
|
|
20
21
|
* `CI=1` or `MODE=test` would otherwise mask unrelated text (`npm test`).
|
|
21
22
|
*/
|
|
22
23
|
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
24
|
+
import { createHash } from 'node:crypto';
|
|
25
|
+
import { access, lstat, readFile, realpath } from 'node:fs/promises';
|
|
26
|
+
import os from 'node:os';
|
|
27
|
+
import path from 'node:path';
|
|
28
|
+
import { isExactEntry, ownsFile } from '../state/registry.mjs';
|
|
29
|
+
import { pairsFor } from '../review/paired.mjs';
|
|
30
|
+
import { exec } from '../util/exec.mjs';
|
|
31
|
+
import { TEST_FILES_TOKEN, CHANGED_FILES_TOKEN } from '../util/gate-token.mjs';
|
|
25
32
|
|
|
26
33
|
export { TEST_FILES_TOKEN };
|
|
27
34
|
|
|
@@ -67,6 +74,7 @@ const isConcreteFile = (p) => typeof p === 'string' && p.length > 0 && isExactEn
|
|
|
67
74
|
|
|
68
75
|
/**
|
|
69
76
|
* @typedef {{scope: 'related', files: string[]} | {scope: 'full', reason: 'threshold' | 'no_tests'}} TestScope
|
|
77
|
+
* @typedef {{scope: 'changed', files: string[], changedFiles: string[], reason?: 'no_baseline'|'unpaired'|'threshold'|'no_changes'}} ChangedScope
|
|
70
78
|
*/
|
|
71
79
|
|
|
72
80
|
/** A gate run with no block: no test file is known. */
|
|
@@ -108,30 +116,115 @@ export function hasTestFilesItem(gates) {
|
|
|
108
116
|
return Object.values(gates ?? {}).some((command) => argvOf(command)?.includes(TEST_FILES_TOKEN) === true);
|
|
109
117
|
}
|
|
110
118
|
|
|
119
|
+
/** @param {Record<string, unknown> | null | undefined} gates @returns {boolean} */
|
|
120
|
+
export function hasScopeItem(gates) {
|
|
121
|
+
return hasTestFilesItem(gates) || Object.values(gates ?? {}).some((command) => argvOf(command)?.includes(CHANGED_FILES_TOKEN));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** A safe repo-relative path that cannot turn into an argv option. @param {string} file */
|
|
125
|
+
function safeFile(file) {
|
|
126
|
+
return file.length > 0 && !file.startsWith('-') && !path.isAbsolute(file) && !/[\\\x00-\x1f]/.test(file) && !file.split('/').some((s) => s === '' || s === '.' || s === '..');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Bounded content baseline. Missing listed files are omitted from hashes and candidates; an
|
|
131
|
+
* owned entry with no current file has no hash. More than 200 owned files, a symlink, a
|
|
132
|
+
* non-regular owned file, a path outside the root or another read error means no record;
|
|
133
|
+
* callers retain the old scope. Git sees only argv and an isolated git environment.
|
|
134
|
+
* @param {string} cwd @param {string[]} owned @param {Record<string, string>} [kinds]
|
|
135
|
+
* @returns {Promise<{files: Record<string, string>, candidates: string[]} | null>}
|
|
136
|
+
*/
|
|
137
|
+
export async function fileBaseline(cwd, owned, kinds) {
|
|
138
|
+
const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('GIT_')));
|
|
139
|
+
env.GIT_CONFIG_NOSYSTEM = '1';
|
|
140
|
+
env.GIT_CONFIG_GLOBAL = os.devNull;
|
|
141
|
+
const listed = await exec(['git', 'ls-files', '-z', '--cached', '--others', '--exclude-standard'], { cwd, env, timeoutMs: 30000 });
|
|
142
|
+
if (listed.result !== 'ok') return null;
|
|
143
|
+
const listedFiles = [...new Set(listed.stdout.split('\0').filter(Boolean))].sort();
|
|
144
|
+
const candidates = [];
|
|
145
|
+
const files = /** @type {Record<string, string>} */ (Object.create(null));
|
|
146
|
+
try {
|
|
147
|
+
const root = await realpath(cwd);
|
|
148
|
+
for (const file of listedFiles) {
|
|
149
|
+
const isOwned = ownsFile(owned, file, kinds);
|
|
150
|
+
if (isOwned && !safeFile(file)) return null;
|
|
151
|
+
const absolute = path.join(root, file);
|
|
152
|
+
if (!isOwned) {
|
|
153
|
+
const exists = await access(absolute).then(() => true).catch((error) => {
|
|
154
|
+
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return false;
|
|
155
|
+
throw error;
|
|
156
|
+
});
|
|
157
|
+
if (exists) candidates.push(file);
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
const stat = await lstat(absolute).catch((error) => {
|
|
161
|
+
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return null;
|
|
162
|
+
throw error;
|
|
163
|
+
});
|
|
164
|
+
if (stat === null) continue;
|
|
165
|
+
candidates.push(file);
|
|
166
|
+
if (Object.keys(files).length >= 200 || !stat.isFile() || !(await realpath(absolute)).startsWith(root + path.sep)) return null;
|
|
167
|
+
files[file] = createHash('sha256').update(await readFile(absolute)).digest('hex');
|
|
168
|
+
}
|
|
169
|
+
} catch { return null; }
|
|
170
|
+
return { files, candidates };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Compare the last green record. A source without a stem pair cannot narrow the test gate.
|
|
175
|
+
* @param {{baseline: unknown, current: Awaited<ReturnType<typeof fileBaseline>>, fallback: TestScope, threshold?: number, owned?: string[], kinds?: Record<string, string>}} opts
|
|
176
|
+
* @returns {ChangedScope}
|
|
177
|
+
*/
|
|
178
|
+
export function changedScope({ baseline, current, fallback, threshold = DEFAULT_FULL_SUITE_THRESHOLD, owned, kinds }) {
|
|
179
|
+
const old = /** @type {Record<string, string> | null} */ (baseline);
|
|
180
|
+
const candidates = new Set(current?.candidates);
|
|
181
|
+
const fallbackFiles = fallback.scope === 'related' ? fallback.files.filter((file) => !current || candidates.has(file)) : [];
|
|
182
|
+
if (!current || !old || typeof old !== 'object' || Array.isArray(old) || Object.keys(old).length > 200 ||
|
|
183
|
+
Object.entries(old).some(([file, hash]) => !safeFile(file) || (owned && !ownsFile(owned, file, kinds)) || typeof hash !== 'string' || !/^[a-f0-9]{64}$/.test(hash))) {
|
|
184
|
+
return { scope: 'changed', files: fallbackFiles, changedFiles: [], reason: 'no_baseline' };
|
|
185
|
+
}
|
|
186
|
+
const changes = [...new Set([...Object.keys(old), ...Object.keys(current.files)])].filter((file) => old[file] !== current.files[file]).sort();
|
|
187
|
+
if (changes.length > threshold) return { scope: 'changed', files: [], changedFiles: [], reason: 'threshold' };
|
|
188
|
+
const changedFiles = changes.filter((file) => candidates.has(file));
|
|
189
|
+
// B97 also pairs Swift Tests names outside lowercase test directories.
|
|
190
|
+
const isChangedTest = (/** @type {string} */ file) => isTestPath(file) || file.endsWith('Tests.swift');
|
|
191
|
+
const tests = new Set(changedFiles.filter(isChangedTest));
|
|
192
|
+
for (const file of changes.filter((f) => !isChangedTest(f))) {
|
|
193
|
+
const pairs = pairsFor(file, current.candidates).filter(isChangedTest);
|
|
194
|
+
if (pairs.length === 0) return { scope: 'changed', files: fallbackFiles, changedFiles, reason: 'unpaired' };
|
|
195
|
+
for (const paired of pairs) tests.add(paired);
|
|
196
|
+
}
|
|
197
|
+
if (tests.size === 0) return { scope: 'changed', files: fallbackFiles, changedFiles, reason: 'no_changes' };
|
|
198
|
+
return { scope: 'changed', files: [...tests].sort(), changedFiles };
|
|
199
|
+
}
|
|
200
|
+
|
|
111
201
|
/**
|
|
112
202
|
* One gate argv with every item that is exactly {@link TEST_FILES_TOKEN} replaced by the related
|
|
113
203
|
* test files, or dropped for a full run. Always a new array; `scoped` is false (and the items
|
|
114
204
|
* unchanged) when the gate has no such item.
|
|
115
|
-
* @param {ReadonlyArray<string>} argv @param {TestScope} scope
|
|
205
|
+
* @param {ReadonlyArray<string>} argv @param {TestScope | ChangedScope} scope
|
|
116
206
|
* @returns {{argv: string[], scoped: boolean}}
|
|
117
207
|
* @throws {TypeError} a test path that starts with `-` (it would be read as an option; `block
|
|
118
208
|
* open --tests` refuses one too).
|
|
119
209
|
*/
|
|
120
210
|
export function expandArgv(argv, scope) {
|
|
121
|
-
if (!argv.includes(TEST_FILES_TOKEN)) return { argv: [...argv], scoped: false };
|
|
122
|
-
const files = scope.scope === '
|
|
211
|
+
if (!argv.includes(TEST_FILES_TOKEN) && !argv.includes(CHANGED_FILES_TOKEN)) return { argv: [...argv], scoped: false };
|
|
212
|
+
const files = scope.scope === 'full' ? [] : scope.files;
|
|
213
|
+
const changed = scope.scope === 'changed' ? scope.changedFiles : [];
|
|
214
|
+
if (changed.some((file) => !safeFile(file))) throw new TypeError('expandArgv: unsafe changed path');
|
|
123
215
|
const dashed = files.find((f) => f.startsWith('-'));
|
|
124
216
|
if (dashed !== undefined) throw new TypeError(`expandArgv: test path ${JSON.stringify(dashed)} starts with "-"`);
|
|
125
|
-
return { argv: argv.flatMap((item) =>
|
|
217
|
+
return { argv: argv.flatMap((item) => item === TEST_FILES_TOKEN ? files : item === CHANGED_FILES_TOKEN ? changed : [item]), scoped: true };
|
|
126
218
|
}
|
|
127
219
|
|
|
128
220
|
/**
|
|
129
221
|
* The fields a scoped gate's result and ledger row carry: `scope`, plus `test_count` when related
|
|
130
222
|
* or `reason` when full.
|
|
131
|
-
* @param {TestScope} scope
|
|
132
|
-
* @returns {{scope: 'related', test_count: number} | {scope: 'full', reason: 'threshold'
|
|
223
|
+
* @param {TestScope | ChangedScope} scope
|
|
224
|
+
* @returns {{scope: 'changed', test_count: number, reason?: 'no_baseline'|'unpaired'|'threshold'|'no_changes'} | {scope: 'related', test_count: number} | {scope: 'full', reason: 'threshold'|'no_tests'}}
|
|
133
225
|
*/
|
|
134
226
|
export function scopeFields(scope) {
|
|
227
|
+
if (scope.scope === 'changed') return { scope: 'changed', test_count: scope.files.length, ...(scope.reason ? { reason: scope.reason } : {}) };
|
|
135
228
|
return scope.scope === 'related' ? { scope: 'related', test_count: scope.files.length } : { scope: 'full', reason: scope.reason };
|
|
136
229
|
}
|
|
137
230
|
|
package/src/gates/run.mjs
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import { exec } from '../util/exec.mjs';
|
|
10
10
|
import { registerSecret } from '../util/redact.mjs';
|
|
11
|
+
import { CHANGED_FILES_TOKEN } from '../util/gate-token.mjs';
|
|
11
12
|
import { NO_BLOCK_SCOPE, envOf, expandArgv, maskableEnvValues, scopeFields } from './expand.mjs';
|
|
12
13
|
|
|
13
14
|
/** @typedef {import('./detect.mjs').Gate} Gate */
|
|
@@ -25,9 +26,9 @@ import { NO_BLOCK_SCOPE, envOf, expandArgv, maskableEnvValues, scopeFields } fro
|
|
|
25
26
|
* @property {number | null} code
|
|
26
27
|
* @property {string} stdout
|
|
27
28
|
* @property {string} stderr
|
|
28
|
-
* @property {'related'|'full'} [scope] -
|
|
29
|
+
* @property {'related'|'full'|'changed'} [scope] - only on a gate with a scope token.
|
|
29
30
|
* @property {number} [test_count] - B60: with `scope: related`, how many test files it ran.
|
|
30
|
-
* @property {'threshold'|'no_tests'} [reason] -
|
|
31
|
+
* @property {'threshold'|'no_tests'|'no_baseline'|'unpaired'|'no_changes'} [reason] - why narrowing fell back.
|
|
31
32
|
* @property {string[]} [env_keys] - B60: only on a gate given with a non-empty `env`: the variable
|
|
32
33
|
* NAMES, sorted (the values are never in a result).
|
|
33
34
|
*/
|
|
@@ -53,7 +54,7 @@ function normalizeCommand(command) {
|
|
|
53
54
|
* no test file is known (`scope: full, reason: no_tests`). A gate with `env` runs with those
|
|
54
55
|
* variables added to the inherited environment; a gate with neither runs exactly as before B60.
|
|
55
56
|
* @param {{test?: Gate, lint?: Gate, types?: Gate, format?: Gate}} gates
|
|
56
|
-
* @param {{cwd: string, timeoutMs?: number, scope?: TestScope}} opts
|
|
57
|
+
* @param {{cwd: string, timeoutMs?: number, scope?: TestScope | import('./expand.mjs').ChangedScope}} opts
|
|
57
58
|
* @returns {Promise<{results: GateResult[], allOk: boolean}>}
|
|
58
59
|
*/
|
|
59
60
|
export async function runGates(gates, opts) {
|
|
@@ -77,8 +78,9 @@ export async function runGates(gates, opts) {
|
|
|
77
78
|
const command = gates[gate] ?? null;
|
|
78
79
|
if (command === null) return { gate, command: null };
|
|
79
80
|
const normalized = normalizeCommand(command);
|
|
81
|
+
if (normalized.argv[0] === CHANGED_FILES_TOKEN) throw new TypeError(`gates.${gate}: '{changed_files}' cannot be the command (argv[0])`);
|
|
80
82
|
const { argv, scoped } = expandArgv(normalized.argv, scope);
|
|
81
|
-
if (argv.length === 0 && normalized.argv.length > 0) throw new TypeError(`gates.${gate}: the argv is empty once '{
|
|
83
|
+
if (argv.length === 0 && normalized.argv.length > 0) throw new TypeError(`gates.${gate}: the argv is empty once ${normalized.argv.map((item) => `'${item}'`).join(' and ')} is dropped for the full suite — put the test command before it`);
|
|
82
84
|
return { gate, command: { argv, scoped, failOnStdout: normalized.failOnStdout, env: normalized.env } };
|
|
83
85
|
});
|
|
84
86
|
/** @type {GateResult[]} */
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* wizard answer (`ANSWER_FLAGS` maps flag -> answer key); `--no-interaction` is a mode switch and
|
|
4
4
|
* overrides no answer.
|
|
5
5
|
*
|
|
6
|
-
* **Q16 = cut (
|
|
6
|
+
* **Q16 = cut (the owner, 2026-09-25):** step 8 asks no mutation-tool or nightly question, so the
|
|
7
7
|
* v1.2 `--no-nightly` flag is gone and `--proof` takes only the keys the schema still has
|
|
8
8
|
* (`isolation`, `high`, `link_dirs`, `copy_untracked`). With `-g` and `-p` counted as two flags
|
|
9
9
|
* (two tokens that each set the scope answer) there are 21 answer flags: B65 added
|
package/src/ledger/spend.mjs
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { logWarning } from '../util/error-log.mjs';
|
|
15
|
-
import {
|
|
15
|
+
import { withLock } from '../util/file-locks.mjs';
|
|
16
16
|
import { writeSafe } from '../util/redact.mjs';
|
|
17
17
|
import { estimateCostUsd } from './prices.mjs';
|
|
18
18
|
|
|
@@ -199,7 +199,7 @@ export const SPEND_UNREADABLE_MESSAGE = "budget.usd is set but the run's spend c
|
|
|
199
199
|
* Several sessions of one run can start at once (B40 slots, B42 ticket pool). The ledger only
|
|
200
200
|
* learns a session's cost when its row is written, so a plain "spent < budget" check lets every
|
|
201
201
|
* session that starts before the first row lands go through. Instead the check AND a reservation
|
|
202
|
-
* of the session's estimated cost happen under ONE
|
|
202
|
+
* of the session's estimated cost happen under ONE cross-process lock per run (`withBudgetLock`):
|
|
203
203
|
*
|
|
204
204
|
* spent ≥ budget, or spent + reserved + estimate > budget ⇒ refused (the B33 message, unchanged)
|
|
205
205
|
*
|
|
@@ -275,7 +275,7 @@ export function reserveBudget(run, usd) {
|
|
|
275
275
|
*/
|
|
276
276
|
export function withBudgetLock(run, fn) {
|
|
277
277
|
lockCalls += 1;
|
|
278
|
-
return
|
|
278
|
+
return withLock(`budget:${run}`, fn);
|
|
279
279
|
}
|
|
280
280
|
|
|
281
281
|
/** Test spy: how many times {@link withBudgetLock} was entered since the last reset. */
|