@codedology/code-forge 0.15.3 → 0.16.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 +51 -0
- package/README.md +1 -1
- package/docs/reference/blocks.json +9 -1
- package/docs/reference/config.md +11 -1
- package/native/README.md +10 -0
- package/native/bin/darwin-arm64/code-forge-native +0 -0
- package/native/bin/darwin-x64/code-forge-native +0 -0
- package/native/bin/linux-arm64/code-forge-native +0 -0
- package/native/bin/linux-x64/code-forge-native +0 -0
- package/package.json +7 -2
- package/schema/code-forge.schema.json +26 -1
- package/schema/facts.schema.json +1 -1
- package/skill/SKILL.md +1 -1
- package/skill/references/adapters/solo.md +1 -1
- package/skill/references/autopilot.md +6 -1
- package/skill/references/code.md +37 -0
- package/skill/references/decisions.md +1 -1
- package/skill/references/plan.md +18 -5
- package/skill/references/review.md +47 -9
- package/skill/scripts/forge +1 -1
- package/skill/templates/facts-sheet.md +9 -3
- package/src/cli/block.mjs +284 -11
- package/src/cli/doctor.mjs +32 -1
- package/src/cli/help.mjs +1 -0
- package/src/cli/native-status.mjs +13 -0
- package/src/cli/review-file.mjs +126 -14
- package/src/cli/spawn.mjs +26 -22
- package/src/cli/status.mjs +80 -14
- package/src/decide/escalation.mjs +5 -5
- package/src/doctor/index.mjs +11 -0
- package/src/doctor/worker-probe.mjs +1 -1
- package/src/gates/dependents.mjs +200 -0
- package/src/gates/expand.mjs +83 -26
- package/src/gates/run.mjs +76 -14
- package/src/ledger/tail.mjs +89 -0
- package/src/review/agent-waiver.mjs +95 -8
- package/src/review/definitions.mjs +170 -0
- package/src/review/fixloop.mjs +4 -4
- package/src/review/gate-check.mjs +33 -13
- package/src/review/model-stats.mjs +125 -0
- package/src/review/packet.mjs +111 -22
- package/src/review/reopen.mjs +38 -13
- package/src/review/route.mjs +58 -4
- package/src/session/facts.mjs +252 -70
- package/src/session/plan-check.mjs +4 -1
- package/src/session/spawn.mjs +70 -19
- package/src/state/block-end.mjs +3 -1
- package/src/state/block.mjs +56 -21
- package/src/state/run.mjs +12 -5
- package/src/util/native.mjs +133 -0
- package/src/worker/engine.mjs +11 -26
- package/src/worker/loop.mjs +150 -20
- package/src/worker/queue.mjs +3 -2
- package/src/worker/review-state.mjs +15 -3
- package/src/worker/watch.mjs +97 -83
- package/src/worker/worker-warning.mjs +35 -0
package/src/cli/review-file.mjs
CHANGED
|
@@ -67,11 +67,14 @@
|
|
|
67
67
|
* needs its own review.
|
|
68
68
|
*/
|
|
69
69
|
|
|
70
|
-
import { lstatSync, readdirSync, readFileSync, realpathSync, rmSync, statSync } from 'node:fs';
|
|
70
|
+
import { lstatSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, watch } from 'node:fs';
|
|
71
71
|
import path from 'node:path';
|
|
72
|
-
import {
|
|
72
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
73
|
+
import { answersTicketId, assertAnswers, blockTickets, clearDone, deliverBlockFindings, dropDoneIf, enqueue, enqueueAnswers, enqueueHash, isDone, liveWorker, MAX_ANSWERS, queueDir, resolveHashTicket, resolveTicket, reviewExclusionFor } from '../worker/queue.mjs';
|
|
73
74
|
import { assertBlockId, assertInsideRoot, assertTicketId, contentHash, mainRootOf, normalizeRequestPath, repoRootOf, WorkerError } from '../worker/ticket.mjs';
|
|
74
|
-
import { changedFiles, namedOnHash } from '../review/gate-check.mjs';
|
|
75
|
+
import { changedFiles, checkBlockReviews, namedOnHash, ownsAt } from '../review/gate-check.mjs';
|
|
76
|
+
import { AUTO_REQUEUE_REASON, isAutomaticReopen, reopenFile, stopContentHash } from '../review/reopen.mjs';
|
|
77
|
+
import { loadState } from '../worker/review-state.mjs';
|
|
75
78
|
import { STOP_EXITS } from '../review/stop-exits.mjs';
|
|
76
79
|
import { budgetFor } from '../review/packet.mjs';
|
|
77
80
|
import { ownsFile } from '../state/registry.mjs';
|
|
@@ -79,12 +82,15 @@ import { appendRow, readAllRows } from '../ledger/write.mjs';
|
|
|
79
82
|
import { parseFlags } from '../state/cli-args.mjs';
|
|
80
83
|
import { StateError } from '../state/paths.mjs';
|
|
81
84
|
import { assertRunId } from '../state/paths.mjs';
|
|
82
|
-
import { readRun } from '../state/run.mjs';
|
|
85
|
+
import { readRun, writeSigned } from '../state/run.mjs';
|
|
86
|
+
import { loadKey } from '../state/signer.mjs';
|
|
83
87
|
import { rebaseBlock } from '../state/block.mjs';
|
|
84
88
|
import { configInForce } from '../state/config-snapshot.mjs';
|
|
85
89
|
import { reviewExcludeRule } from '../config/validate.mjs';
|
|
86
90
|
import { blockTree } from '../proof/worktree.mjs';
|
|
87
91
|
import { writeSafe } from '../util/redact.mjs';
|
|
92
|
+
import { daemonRequest } from '../util/native.mjs';
|
|
93
|
+
import { runRootPath } from '../util/tmp.mjs';
|
|
88
94
|
|
|
89
95
|
const USAGE =
|
|
90
96
|
'usage: code-forge review-file [--cwd <dir>] <path> --block <id> [--run <id>] [--fresh] | review-file --wait <ticket> [--max <seconds>s]\n' +
|
|
@@ -510,13 +516,64 @@ function ticketTimings(value) {
|
|
|
510
516
|
.filter((key) => value && Object.hasOwn(value, key)).map((key) => [key, value[key]]));
|
|
511
517
|
}
|
|
512
518
|
|
|
519
|
+
/** Restart only verified stopped reviews whose findings were not waived and whose bytes changed.
|
|
520
|
+
* Shared by the join and close; ordinary progress polling needs no signing key.
|
|
521
|
+
* @param {{repoRoot: string, run: string, block: string, tree: string, files: string[], print: (line: string) => unknown}} opts
|
|
522
|
+
* @returns {Promise<Map<string, string>>} restart timestamp by file
|
|
523
|
+
*/
|
|
524
|
+
export async function requeueStoppedFiles({ repoRoot, run, block, tree, files, print }) {
|
|
525
|
+
const restarted = new Map();
|
|
526
|
+
if (files.length === 0) return restarted;
|
|
527
|
+
const record = await readRun(run);
|
|
528
|
+
const entry = record.blocks?.[block];
|
|
529
|
+
if (entry?.status !== 'open') return restarted;
|
|
530
|
+
if (realpathSync(await mainRootOf(record.workspace)) !== realpathSync(repoRoot)) throw new WorkerError('other-workspace', 'the run belongs to another workspace');
|
|
531
|
+
const recordedTree = (await blockTree(record.workspace, entry))?.top ?? repoRoot;
|
|
532
|
+
if (realpathSync(recordedTree) !== realpathSync(tree)) throw new WorkerError('worktree-mismatch', 'the block scope does not match the recorded content tree');
|
|
533
|
+
const owns = ownsAt(path.relative(realpathSync(repoRoot), realpathSync(record.workspace)).split(path.sep).join('/'));
|
|
534
|
+
const key = await loadKey(run);
|
|
535
|
+
const rows = (await readAllRows(record.project)).filter((row) => row.run === run);
|
|
536
|
+
const root = configInForce(record)?.tmp?.root;
|
|
537
|
+
const runRootDir = runRootPath(run, root == null ? {} : { root });
|
|
538
|
+
const where = realpathSync(tree) === realpathSync(repoRoot) ? {} : { tree: realpathSync(tree) };
|
|
539
|
+
const writeRow = (row) => appendRow(row, { slug: record.project });
|
|
540
|
+
for (const file of files) {
|
|
541
|
+
if (!owns(entry.owned_files, file, entry.owned_kinds) || reviewExcludeRule(configInForce(record), file) !== null) continue;
|
|
542
|
+
if (Object.entries(record.blocks).some(([id, other]) => id !== block && other.status === 'open' && owns(other.owned_files, file, other.owned_kinds))) continue;
|
|
543
|
+
const loaded = loadState({ runId: run, block, file, key, rows, runRootDir, contentRoot: tree, migrate: false });
|
|
544
|
+
if (!['ok', 'restored'].includes(loaded.status) || loaded.state?.status !== 'stopped' || !loaded.state.open?.length) continue;
|
|
545
|
+
const fromHash = stopContentHash(loaded.state, rows, { runId: run, block, file, key });
|
|
546
|
+
const currentHash = contentHash(tree, file);
|
|
547
|
+
if (fromHash === null || fromHash === currentHash) continue;
|
|
548
|
+
// A newer accepted review wins over a stopped snapshot. At the stopped hash, use the
|
|
549
|
+
// same waiver rules as the gate, including severity and signature checks.
|
|
550
|
+
const needsReview = (hash) => checkBlockReviews({ runId: run, block, key,
|
|
551
|
+
rows: rows.filter((row) => row.file === undefined || row.file === file),
|
|
552
|
+
files: [{ file, content_hash: hash }], proofFiles: [], highPaths: [],
|
|
553
|
+
}).refusals.some((value) => value.file === file && ['unreviewed', 'unsigned', 'not_approved', 'review_cap', 'autopilot_waiver_invalid'].includes(value.code));
|
|
554
|
+
if (!needsReview(currentHash) || !needsReview(fromHash)) continue;
|
|
555
|
+
const result = await reopenFile({ runId: run, id: block, file, reason: AUTO_REQUEUE_REASON,
|
|
556
|
+
record, rows, key, writeRow, writeSigned, automatic: true,
|
|
557
|
+
io: {
|
|
558
|
+
owns, loadState: () => loaded, contentHash: (f) => contentHash(tree, f),
|
|
559
|
+
clearDone: (f) => clearDone({ repoRoot, run, block, file: f, ...where }).removed,
|
|
560
|
+
enqueue: (f, row) => enqueue({ repoRoot, run, block, file: f, ...where, after: row.ts }).ticket,
|
|
561
|
+
},
|
|
562
|
+
});
|
|
563
|
+
restarted.set(file, result.row.ts);
|
|
564
|
+
if (!result.existing) print(`auto-requeue ${file}: content changed after stopped review (ticket ${result.ticket})\n`);
|
|
565
|
+
}
|
|
566
|
+
return restarted;
|
|
567
|
+
}
|
|
568
|
+
|
|
513
569
|
/**
|
|
514
|
-
*
|
|
570
|
+
* Ordinary block progress uses unsigned results. Restarting changed stopped findings verifies
|
|
571
|
+
* the durable state and writes a signed restart before queuing the new attempt.
|
|
515
572
|
* Re-scan metadata each poll and read content only after its stamp changes. Deliver unseen findings of the newest earlier ticket
|
|
516
|
-
* before rechecking a blind edit; the next join queues the fix.
|
|
517
|
-
* @param {{repoRoot: string, cwd: string, block: string, run: string, maxMs: number, pollMs: number, say: (obj: Record<string, any>) => unknown, dropDone?: typeof dropDoneIf, now?: () => number, sleep?: (ms: number) => Promise<unknown>}} opts
|
|
573
|
+
* before rechecking a blind edit; the next join queues the fix. Changed stopped findings restart.
|
|
574
|
+
* @param {{repoRoot: string, cwd: string, block: string, run: string, maxMs: number, pollMs: number, say: (obj: Record<string, any>) => unknown, print: (line: string) => unknown, dropDone?: typeof dropDoneIf, now?: () => number, sleep?: (ms: number, signal?: AbortSignal) => Promise<unknown>}} opts
|
|
518
575
|
*/
|
|
519
|
-
async function waitBlock({ repoRoot, cwd, block, run, maxMs, pollMs, say, dropDone = dropDoneIf, now = Date.now, sleep = (ms) =>
|
|
576
|
+
async function waitBlock({ repoRoot, cwd, block, run, maxMs, pollMs, say, print, dropDone = dropDoneIf, now = Date.now, sleep = (ms, signal) => delay(ms, undefined, { signal }).catch((err) => { if (err.name !== 'AbortError') throw err; }) }) {
|
|
520
577
|
let scope = await waitBlockScope(repoRoot, cwd, run, block);
|
|
521
578
|
const { tree } = scope;
|
|
522
579
|
const gitPaths = waitGitPaths(tree);
|
|
@@ -531,7 +588,43 @@ async function waitBlock({ repoRoot, cwd, block, run, maxMs, pollMs, say, dropDo
|
|
|
531
588
|
const hashes = new Map();
|
|
532
589
|
let downPolls = 0;
|
|
533
590
|
const deliveredHere = new Set();
|
|
591
|
+
let changed = false;
|
|
592
|
+
/** @type {(() => void) | null} */
|
|
593
|
+
let wake = null;
|
|
594
|
+
/** @type {import('node:fs').FSWatcher | undefined} */
|
|
595
|
+
let watcher;
|
|
596
|
+
const notify = () => { changed = true; wake?.(); };
|
|
597
|
+
// Install before scanning so a completion between the scan and the wait is retained.
|
|
598
|
+
// Queue .done markers are already published after their results, on macOS and Linux.
|
|
599
|
+
try {
|
|
600
|
+
watcher = watch(queueDir(repoRoot), (_event, name) => {
|
|
601
|
+
if (name === null || String(name).endsWith('.done')) notify();
|
|
602
|
+
});
|
|
603
|
+
watcher.on('error', () => { watcher.close(); notify(); });
|
|
604
|
+
} catch { /* unavailable watches retain the existing backoff polling */ }
|
|
605
|
+
try {
|
|
534
606
|
for (;;) {
|
|
607
|
+
changed = false;
|
|
608
|
+
scope = await waitBlockScope(repoRoot, cwd, run, block, scope);
|
|
609
|
+
// Keep one daemon join for the remaining deadline: it retains findings already
|
|
610
|
+
// delivered while other files are pending, just like deliveredHere below.
|
|
611
|
+
const answer = await daemonRequest(run, { method: 'wait-block', block, max_ms: Math.max(0, deadline - now()) }, { config: scope.cfg });
|
|
612
|
+
// A stopped review may need a signed automatic reopen. Keep that mutation
|
|
613
|
+
// on the existing Node path; ordinary pending/completed polls stay read-free.
|
|
614
|
+
if (answer && !answer.files.some((/** @type {Record<string, any>} */ file) => file.outcome === 'stopped')) {
|
|
615
|
+
if (answer.reason === 'wait_timeout' && now() < deadline) continue;
|
|
616
|
+
// Go serializes map keys alphabetically; retain the CLI's byte order.
|
|
617
|
+
const files = answer.files.map((/** @type {Record<string, any>} */ f) => ({
|
|
618
|
+
file: f.file, ticket: f.ticket, status: f.status, outcome: f.outcome,
|
|
619
|
+
...(f.rule !== undefined ? { rule: f.rule } : {}), ...ticketTimings(f),
|
|
620
|
+
}));
|
|
621
|
+
say({ block, files, done: answer.done,
|
|
622
|
+
...(answer.status !== undefined ? { status: answer.status } : {}),
|
|
623
|
+
...(answer.reason !== undefined ? { reason: answer.reason } : {}),
|
|
624
|
+
...(answer.next !== undefined ? { next: answer.next } : {}),
|
|
625
|
+
});
|
|
626
|
+
return answer.status === 'worker_down' ? 3 : 0;
|
|
627
|
+
}
|
|
535
628
|
const worker = liveWorker(repoRoot);
|
|
536
629
|
const live = worker?.run === run;
|
|
537
630
|
downPolls = live ? 0 : downPolls + 1;
|
|
@@ -543,6 +636,12 @@ async function waitBlock({ repoRoot, cwd, block, run, maxMs, pollMs, say, dropDo
|
|
|
543
636
|
current = (await changedFiles({ repoRoot: tree, base: entry.base_sha })).filter((file) => ownsFile(entry.owned_files, file, entry.owned_kinds));
|
|
544
637
|
treeStamp = stamp;
|
|
545
638
|
}
|
|
639
|
+
const stopped = current.filter((file) => {
|
|
640
|
+
const latest = tickets.filter((ticket) => ticket.file === file).at(-1);
|
|
641
|
+
return latest && blockTicketMarker(repoRoot, latest.ticket)?.status === 'stopped'
|
|
642
|
+
&& doneResult(repoRoot, latest.ticket)?.findings?.length > 0;
|
|
643
|
+
});
|
|
644
|
+
const restarted = live ? await requeueStoppedFiles({ repoRoot, run, block, tree, files: stopped, print }) : new Map();
|
|
546
645
|
// Progress only, like answersPrecheck: sandboxed callers may not read the private ledger.
|
|
547
646
|
// Reopens issued here also renew the workspace ticket, which needs no private read to join.
|
|
548
647
|
let rows = [];
|
|
@@ -550,9 +649,10 @@ async function waitBlock({ repoRoot, cwd, block, run, maxMs, pollMs, say, dropDo
|
|
|
550
649
|
const reopens = new Map();
|
|
551
650
|
for (const row of rows) {
|
|
552
651
|
if (row.event !== 'review.reopened' || row.run !== run || row.block !== block || !Number.isFinite(Date.parse(row.ts))) continue;
|
|
553
|
-
if (row.by !== 'human' && !(row.by === 'agent' && row.verdict?.verdict === 'agree')) continue;
|
|
652
|
+
if (row.by !== 'human' && !(row.by === 'agent' && row.verdict?.verdict === 'agree') && !isAutomaticReopen(row)) continue;
|
|
554
653
|
if (!reopens.has(row.file) || row.ts > reopens.get(row.file)) reopens.set(row.file, row.ts);
|
|
555
654
|
}
|
|
655
|
+
for (const [file, ts] of restarted) reopens.set(file, ts);
|
|
556
656
|
const files = current.map((file) => {
|
|
557
657
|
const rule = reviewExcludeRule(cfg, file);
|
|
558
658
|
if (rule !== null) return { file, ticket: null, status: 'excluded', outcome: null, rule };
|
|
@@ -594,6 +694,11 @@ async function waitBlock({ repoRoot, cwd, block, run, maxMs, pollMs, say, dropDo
|
|
|
594
694
|
say({ block, files, done: false, reason: 'split_required', next: `The owner raises review.budgets for ${split.file}, runs run reload, then review-file ${split.file} --fresh.` });
|
|
595
695
|
return 0;
|
|
596
696
|
}
|
|
697
|
+
const unavailable = files.find((f) => f.outcome === 'unavailable');
|
|
698
|
+
if (live && unavailable) {
|
|
699
|
+
say({ block, files, done: false, reason: 'review_unavailable', next: `review-file ${unavailable.file} --fresh re-runs it; if --fresh refuses, block reopen ${block} --run ${run} --file ${unavailable.file} --reason <text>` });
|
|
700
|
+
return 0;
|
|
701
|
+
}
|
|
597
702
|
if (live && files.every((f) => f.status === 'done' || f.status === 'findings' || f.status === 'excluded')) {
|
|
598
703
|
say({ block, files, done: true });
|
|
599
704
|
return 0;
|
|
@@ -604,15 +709,22 @@ async function waitBlock({ repoRoot, cwd, block, run, maxMs, pollMs, say, dropDo
|
|
|
604
709
|
}
|
|
605
710
|
const ticketIds = [...new Set([...tickets.map((t) => t.ticket), ...files.map((f) => f.ticket).filter(Boolean)])].sort();
|
|
606
711
|
const state = JSON.stringify([stamp, live, ticketIds, files]);
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
712
|
+
if (!changed) {
|
|
713
|
+
interval = seen === state ? Math.min(MAX_IDLE_MS, interval * 2) : pollMs;
|
|
714
|
+
seen = state;
|
|
715
|
+
const abort = new AbortController();
|
|
716
|
+
try {
|
|
717
|
+
const event = new Promise((resolve) => { wake = () => resolve(undefined); });
|
|
718
|
+
await Promise.race([event, sleep(Math.min(interval, Math.max(0, deadline - now())), abort.signal)]);
|
|
719
|
+
} finally { wake = null; abort.abort(); }
|
|
720
|
+
}
|
|
610
721
|
}
|
|
722
|
+
} finally { watcher?.close(); }
|
|
611
723
|
}
|
|
612
724
|
|
|
613
725
|
/**
|
|
614
726
|
* @param {string[]} args
|
|
615
|
-
* @param {{stdout?: {write: (s: string) => unknown}, stderr?: {write: (s: string) => unknown}, cwd?: string, pollMs?: number, dropDone?: typeof dropDoneIf, now?: () => number, sleep?: (ms: number) => Promise<unknown>}} [deps] - `dropDone`: B75's claim, for tests.
|
|
727
|
+
* @param {{stdout?: {write: (s: string) => unknown}, stderr?: {write: (s: string) => unknown}, cwd?: string, pollMs?: number, dropDone?: typeof dropDoneIf, now?: () => number, sleep?: (ms: number, signal?: AbortSignal) => Promise<unknown>}} [deps] - `dropDone`: B75's claim, for tests.
|
|
616
728
|
* @returns {Promise<number>}
|
|
617
729
|
*/
|
|
618
730
|
export async function runReviewFile(args, deps = {}) {
|
|
@@ -652,7 +764,7 @@ export async function runReviewFile(args, deps = {}) {
|
|
|
652
764
|
if (positionals.length > 0 || flags.block !== undefined || flags.wait !== undefined || flags.answers !== undefined || flags.of !== undefined || flags.fresh === true || typeof flags.run !== 'string') {
|
|
653
765
|
throw new WorkerError('usage', '--wait-block takes a block, --run and --max only');
|
|
654
766
|
}
|
|
655
|
-
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, dropDone: deps.dropDone, now: deps.now, sleep: deps.sleep });
|
|
767
|
+
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, print: (line) => writeSafe(stderr, line), dropDone: deps.dropDone, now: deps.now, sleep: deps.sleep });
|
|
656
768
|
}
|
|
657
769
|
|
|
658
770
|
if (typeof flags.wait === 'string') {
|
package/src/cli/spawn.mjs
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `code-forge spawn --level L<n> --role <role> --brief <file> [--schema <file>] [--cwd <dir>]
|
|
3
|
-
* [--run <id>] [--block <id>] [--timeout <s>] [--background]` (plan §5.2; block B9a).
|
|
3
|
+
* [--run <id>] [--block <id>] [--timeout <s>] [--background] [--print-argv]` (plan §5.2; block B9a).
|
|
4
4
|
*
|
|
5
5
|
* Runs one isolated session through `src/session/spawn.mjs` and prints its result as one JSON line
|
|
6
6
|
* on stdout; the `level=… provider=… model=… effort=… fallback_step=…` line goes to stderr.
|
|
7
|
+
* With `--print-argv`, prints the first step's argv and metadata instead of launching a session.
|
|
7
8
|
* Exit codes: 0 ok / started, 1 failed (timeout, bad output, refused), 2 usage, 3 unavailable
|
|
8
9
|
* (every step of the ladder reported unavailability).
|
|
9
10
|
*
|
|
@@ -17,7 +18,7 @@
|
|
|
17
18
|
import { readFile } from 'node:fs/promises';
|
|
18
19
|
import path from 'node:path';
|
|
19
20
|
import { loadProjectConfig, projectRootFor, slugFor } from '../config/load.mjs';
|
|
20
|
-
import { ROLES, runRootFor, SessionError, spawnSession } from '../session/spawn.mjs';
|
|
21
|
+
import { previewArgv, ROLES, runRootFor, SessionError, spawnSession } from '../session/spawn.mjs';
|
|
21
22
|
import { intFlag, parseFlags } from '../state/cli-args.mjs';
|
|
22
23
|
import { StateError } from '../state/paths.mjs';
|
|
23
24
|
import { readRun } from '../state/run.mjs';
|
|
@@ -43,7 +44,7 @@ async function readJSONFlag(file, flag) {
|
|
|
43
44
|
}
|
|
44
45
|
}
|
|
45
46
|
|
|
46
|
-
const USAGE = `usage: code-forge spawn --level L0|L1|L2|L3 --role ${ROLES.join('|')} --brief <file> [--schema <file>] [--cwd <dir>] [--run <id>] [--block <id>] [--timeout <seconds>] [--background]\n`;
|
|
47
|
+
const USAGE = `usage: code-forge spawn --level L0|L1|L2|L3 --role ${ROLES.join('|')} --brief <file> [--schema <file>] [--cwd <dir>] [--run <id>] [--block <id>] [--timeout <seconds>] [--background] [--print-argv]\n`;
|
|
47
48
|
|
|
48
49
|
/**
|
|
49
50
|
* @param {{status: string}} result
|
|
@@ -93,8 +94,8 @@ export async function runSpawn(args, deps = {}) {
|
|
|
93
94
|
const stdout = deps.stdout ?? process.stdout;
|
|
94
95
|
const stderr = deps.stderr ?? process.stderr;
|
|
95
96
|
try {
|
|
96
|
-
const { flags, positionals } = parseFlags(args, { values: ['level', 'role', 'brief', 'schema', 'cwd', 'run', 'block', 'timeout'], booleans: ['background'] });
|
|
97
|
-
if (positionals.length > 0 || typeof flags.level !== 'string' || typeof flags.role !== 'string' || typeof flags.brief !== 'string') {
|
|
97
|
+
const { flags, positionals } = parseFlags(args, { values: ['level', 'role', 'brief', 'schema', 'cwd', 'run', 'block', 'timeout'], booleans: ['background', 'print-argv'] });
|
|
98
|
+
if (positionals.length > 0 || typeof flags.level !== 'string' || typeof flags.role !== 'string' || typeof flags.brief !== 'string' || (flags['print-argv'] && flags.background)) {
|
|
98
99
|
writeSafe(stderr, USAGE);
|
|
99
100
|
return 2;
|
|
100
101
|
}
|
|
@@ -111,23 +112,26 @@ export async function runSpawn(args, deps = {}) {
|
|
|
111
112
|
const runRootDir = typeof flags.run === 'string' ? runRootFor(flags.run, loaded.config?.tmp?.root) : undefined;
|
|
112
113
|
// B93: only a coder reads coder.sandbox, so only a coder session is held to the run-start value
|
|
113
114
|
const cfg = typeof flags.run === 'string' && flags.role === 'coder' ? await checkRunSandbox(loaded.config, flags.run) : loaded.config;
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
{ ...deps, stderr }
|
|
130
|
-
|
|
115
|
+
const opts = {
|
|
116
|
+
cfg,
|
|
117
|
+
level: /** @type {any} */ (flags.level),
|
|
118
|
+
role: /** @type {any} */ (flags.role),
|
|
119
|
+
promptPath: path.resolve(flags.brief),
|
|
120
|
+
cwd: typeof flags.cwd === 'string' ? path.resolve(flags.cwd) : undefined,
|
|
121
|
+
schema,
|
|
122
|
+
timeoutMs: timeout === undefined ? undefined : timeout * 1000,
|
|
123
|
+
background: flags.background === true,
|
|
124
|
+
runRoot: runRootDir,
|
|
125
|
+
run: typeof flags.run === 'string' ? flags.run : undefined,
|
|
126
|
+
block: typeof flags.block === 'string' ? flags.block : undefined,
|
|
127
|
+
slug: slugFor(cfg, root),
|
|
128
|
+
};
|
|
129
|
+
if (flags['print-argv']) {
|
|
130
|
+
const preview = await previewArgv(opts, { ...deps, stderr });
|
|
131
|
+
writeSafe(stdout, `${JSON.stringify(preview)}\n`);
|
|
132
|
+
return 0;
|
|
133
|
+
}
|
|
134
|
+
const result = await spawnSession(opts, { ...deps, stderr });
|
|
131
135
|
const { text: _text, ...shown } = /** @type {Record<string, any>} */ (result);
|
|
132
136
|
writeSafe(stdout, `${JSON.stringify(shown)}\n`);
|
|
133
137
|
return exitCodeFor(result);
|
package/src/cli/status.mjs
CHANGED
|
@@ -15,11 +15,12 @@
|
|
|
15
15
|
*
|
|
16
16
|
* Text: at most {@link STATUS_MAX_LINES} lines per run, runs separated by one blank line:
|
|
17
17
|
* run <id> · <status> · <plan|brief> · spent $<x>[ of $<budget>]
|
|
18
|
-
* <id> <state> <level> · <model or -> · gate <pass|fail|none>[ (<gate>)] · findings <n>
|
|
18
|
+
* <id> <state> <level> · <model or -> · gate <pass|fail|timeout|none>[ (<gate>)] · findings <n>
|
|
19
19
|
* … +<k> more blocks (only when the open blocks do not fit)
|
|
20
20
|
* closed <a> · stopped <b> (only when any exist; printed last, but block lines are
|
|
21
21
|
* dropped first to keep it inside the cap)
|
|
22
|
-
* The gate field is `gate pass`, `gate fail (<gate>)`, `gate
|
|
22
|
+
* The gate field is `gate pass`, `gate fail (<gate>)`, `gate timeout (<gate>)`, `gate fail`
|
|
23
|
+
* (no gate named) or `gate none`.
|
|
23
24
|
* `<x>` is `run status`'s spent_usd with 2 decimals, followed by `+?` when some session has no
|
|
24
25
|
* known price (`run status`'s unknown_usd_sessions > 0); the whole amount is `$?` when the ledger
|
|
25
26
|
* cannot be read. A run record with no status shows `-`.
|
|
@@ -54,9 +55,12 @@ import { budgetUsdOf } from '../ledger/spend.mjs';
|
|
|
54
55
|
import { readAllRows } from '../ledger/write.mjs';
|
|
55
56
|
import { findingsKey, openFindingsOf } from '../review/open-findings.mjs';
|
|
56
57
|
import { briefStatus } from '../state/brief-status.mjs';
|
|
58
|
+
import { configInForce } from '../state/config-snapshot.mjs';
|
|
57
59
|
import { parseFlags } from '../state/cli-args.mjs';
|
|
58
60
|
import { runsDir, StateError } from '../state/paths.mjs';
|
|
59
61
|
import { readRun } from '../state/run.mjs';
|
|
62
|
+
import { unpinnedWorkerWarnings } from '../worker/worker-warning.mjs';
|
|
63
|
+
import { daemonRequest } from '../util/native.mjs';
|
|
60
64
|
import { writeSafe } from '../util/redact.mjs';
|
|
61
65
|
|
|
62
66
|
const USAGE = 'usage: code-forge status [--run <id>] [--json]\n';
|
|
@@ -65,7 +69,8 @@ const USAGE = 'usage: code-forge status [--run <id>] [--json]\n';
|
|
|
65
69
|
const STATUS_MAX_LINES = 10;
|
|
66
70
|
|
|
67
71
|
/**
|
|
68
|
-
* @typedef {import('../state/brief-status.mjs').
|
|
72
|
+
* @typedef {Omit<import('../state/brief-status.mjs').BlockStatus, 'gate'> & {gate: {result: 'pass'|'fail'|'timeout'|'none', name: string | null}}} BlockStatus
|
|
73
|
+
* @typedef {Omit<import('../state/brief-status.mjs').RunStatus, 'blocks'> & {blocks: BlockStatus[], awaiting_human_check?: string[]}} RunStatus
|
|
69
74
|
*/
|
|
70
75
|
|
|
71
76
|
/** @param {string} p @returns {Promise<string>} */
|
|
@@ -77,18 +82,46 @@ async function realOr(p) {
|
|
|
77
82
|
}
|
|
78
83
|
}
|
|
79
84
|
|
|
85
|
+
/** Recover timeout reasons from the last gate run, including older rows without timed_out.
|
|
86
|
+
* @param {RunStatus} summary @param {Array<Record<string, any>> | null} rows
|
|
87
|
+
* @returns {RunStatus}
|
|
88
|
+
*/
|
|
89
|
+
function timeoutStatus(summary, rows) {
|
|
90
|
+
const pending = new Set(), timedOut = new Set();
|
|
91
|
+
for (const row of rows ?? []) {
|
|
92
|
+
if (row.run !== summary.run_id || typeof row.block !== 'string') continue;
|
|
93
|
+
if (row.event === 'gate.red') {
|
|
94
|
+
if (row.reason === 'timeout' || row.timed_out === true) {
|
|
95
|
+
pending.add(row.block);
|
|
96
|
+
timedOut.add(row.block);
|
|
97
|
+
} else {
|
|
98
|
+
pending.delete(row.block);
|
|
99
|
+
timedOut.delete(row.block);
|
|
100
|
+
}
|
|
101
|
+
} else if (row.event === 'gate.done') {
|
|
102
|
+
if (row.all_ok === true || !pending.has(row.block)) timedOut.delete(row.block);
|
|
103
|
+
pending.delete(row.block);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return { ...summary, blocks: summary.blocks.map((block) => block.gate.result === 'fail' && timedOut.has(block.id)
|
|
107
|
+
? { ...block, gate: { ...block.gate, result: 'timeout' } } : block) };
|
|
108
|
+
}
|
|
109
|
+
|
|
80
110
|
/**
|
|
81
111
|
* The status data of one run.
|
|
82
112
|
* @param {Record<string, any>} record
|
|
113
|
+
* @param {Array<Record<string, any>> | null | undefined} [cachedRows] - daemon's incrementally read ledger
|
|
83
114
|
* @returns {Promise<RunStatus>}
|
|
84
115
|
*/
|
|
85
|
-
async function statusOf(record) {
|
|
116
|
+
export async function statusOf(record, cachedRows) {
|
|
86
117
|
/** @type {Array<Record<string, any>> | null} null: the ledger cannot be read */
|
|
87
|
-
let rows = null;
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
118
|
+
let rows = cachedRows ?? null;
|
|
119
|
+
if (cachedRows === undefined) {
|
|
120
|
+
try {
|
|
121
|
+
rows = await readAllRows(record.project);
|
|
122
|
+
} catch {
|
|
123
|
+
rows = null;
|
|
124
|
+
}
|
|
92
125
|
}
|
|
93
126
|
let budget = null;
|
|
94
127
|
try {
|
|
@@ -109,8 +142,24 @@ async function statusOf(record) {
|
|
|
109
142
|
else byBlock.set(r.block, [r]);
|
|
110
143
|
}
|
|
111
144
|
const awaiting = Object.keys(record.blocks ?? {}).filter((id) => record.blocks[id].status === 'awaiting_human_check').sort();
|
|
112
|
-
return { ...briefStatus({ record, rows, budget, findingsOf: (block) => openFindingsOf({ block, runId, mine: byBlock.get(block) ?? [], rows: all, key }) }),
|
|
113
|
-
...(awaiting.length > 0 ? { awaiting_human_check: awaiting } : {}) };
|
|
145
|
+
return timeoutStatus({ ...briefStatus({ record, rows, budget, findingsOf: (block) => openFindingsOf({ block, runId, mine: byBlock.get(block) ?? [], rows: all, key }) }),
|
|
146
|
+
...(awaiting.length > 0 ? { awaiting_human_check: awaiting } : {}) }, rows);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Restore the Node renderer's insertion order after Go's sorted JSON map encoding.
|
|
150
|
+
* @param {Record<string, any>} s @returns {RunStatus}
|
|
151
|
+
*/
|
|
152
|
+
function daemonStatus(s) {
|
|
153
|
+
return {
|
|
154
|
+
run_id: s.run_id, status: s.status, mode: s.mode, spent_usd: s.spent_usd,
|
|
155
|
+
unknown_usd_sessions: s.unknown_usd_sessions, budget_usd: s.budget_usd,
|
|
156
|
+
blocks: s.blocks.map((/** @type {Record<string, any>} */ b) => ({
|
|
157
|
+
id: b.id, state: b.state, level: b.level, model: b.model,
|
|
158
|
+
gate: { result: b.gate.result, name: b.gate.name }, findings: b.findings,
|
|
159
|
+
})),
|
|
160
|
+
more: s.more, closed: s.closed, stopped: s.stopped,
|
|
161
|
+
...(s.awaiting_human_check ? { awaiting_human_check: s.awaiting_human_check } : {}),
|
|
162
|
+
};
|
|
114
163
|
}
|
|
115
164
|
|
|
116
165
|
/**
|
|
@@ -155,7 +204,7 @@ export function runLines(s) {
|
|
|
155
204
|
const lines = [`run ${s.run_id} · ${s.status ?? '-'} · ${s.mode} · spent ${spent}${s.budget_usd !== null ? ` of ${usd(s.budget_usd)}` : ''}`];
|
|
156
205
|
const tally = s.closed + s.stopped > 0 ? `closed ${s.closed} · stopped ${s.stopped}` : null;
|
|
157
206
|
const awaiting = s.awaiting_human_check?.length ? `awaiting_human_check: ${JSON.stringify(s.awaiting_human_check)}` : null;
|
|
158
|
-
const room = STATUS_MAX_LINES -
|
|
207
|
+
const room = STATUS_MAX_LINES - lines.length - (tally === null ? 0 : 1) - (awaiting === null ? 0 : 1);
|
|
159
208
|
const shown = s.blocks.length <= room ? s.blocks : s.blocks.slice(0, room - 1);
|
|
160
209
|
for (const b of shown) {
|
|
161
210
|
lines.push(`${b.id} ${b.state} ${b.level ?? '-'} · ${b.model ?? '-'} · gate ${b.gate.result}${b.gate.name ? ` (${b.gate.name})` : ''} · findings ${b.findings}`);
|
|
@@ -198,9 +247,26 @@ export default async function status(args) {
|
|
|
198
247
|
records = await activeRunsFor(projectRootFor(process.cwd()));
|
|
199
248
|
}
|
|
200
249
|
const shown = [];
|
|
201
|
-
for (const record of records)
|
|
250
|
+
for (const record of records) {
|
|
251
|
+
let config;
|
|
252
|
+
try {
|
|
253
|
+
config = configInForce(record);
|
|
254
|
+
if (!config) {
|
|
255
|
+
const loaded = await loadProjectConfig(record.workspace);
|
|
256
|
+
config = loaded.ok ? loaded.config : { native: { daemon: false } };
|
|
257
|
+
}
|
|
258
|
+
} catch { config = { native: { daemon: false } }; }
|
|
259
|
+
const answer = await daemonRequest(String(record.run_id), { method: 'status' }, { config });
|
|
260
|
+
let summary;
|
|
261
|
+
try { summary = answer ? daemonStatus(answer.runs[0]) : null; } catch { summary = null; }
|
|
262
|
+
if (summary?.blocks.some((block) => block.gate.result === 'fail')) {
|
|
263
|
+
summary = timeoutStatus(summary, await readAllRows(record.project).catch(() => null));
|
|
264
|
+
}
|
|
265
|
+
shown.push(summary ?? await statusOf(record));
|
|
266
|
+
for (const warning of await unpinnedWorkerWarnings(record)) err(`${warning}\n`);
|
|
267
|
+
}
|
|
202
268
|
if (json) {
|
|
203
|
-
out(`${JSON.stringify({ runs: shown })}\n`);
|
|
269
|
+
out(`${JSON.stringify({ runs: shown })}\n`);
|
|
204
270
|
return 0;
|
|
205
271
|
}
|
|
206
272
|
if (shown.length === 0) {
|
|
@@ -220,13 +220,13 @@ export function runningCeiling(stopAt = 'L3') {
|
|
|
220
220
|
|
|
221
221
|
/**
|
|
222
222
|
* Rules 1, 2b and 2 share this: below the running ceiling ⇒ +1 level (never to L3); at the
|
|
223
|
-
* running ceiling ⇒ rule 5, the
|
|
223
|
+
* running ceiling ⇒ rule 5, the file's one L3 rung (R1), then `stop`.
|
|
224
224
|
* @param {'retries'|'review_rounds'|'review_stall'|'review_warnings'} trigger
|
|
225
225
|
* @param {string} currentLevel
|
|
226
226
|
* @param {string} stopAt
|
|
227
227
|
* @param {'patch'|'code'} l3Mode
|
|
228
|
-
* @param {boolean} l3RungAlreadyUsed - the L3 rung was already used once on THIS
|
|
229
|
-
* EITHER mode and from either rule 5 or rule 6 (one rung per
|
|
228
|
+
* @param {boolean} l3RungAlreadyUsed - the L3 rung was already used once on THIS file, in
|
|
229
|
+
* EITHER mode and from either rule 5 or rule 6 (one rung per file, not per trigger). A second
|
|
230
230
|
* hit always stops.
|
|
231
231
|
* @returns {EscalationResult}
|
|
232
232
|
*/
|
|
@@ -270,8 +270,8 @@ function finalizeEscalate(trigger, currentLevel, stopAt, l3Mode, l3RungAlreadyUs
|
|
|
270
270
|
* @property {string} [stopAt] - `escalation.stop_at`, default `'L3'`.
|
|
271
271
|
* @property {'patch'|'code'} [l3Mode] - `escalation.l3_mode`, default `'patch'` (R1).
|
|
272
272
|
* @property {boolean} [l3RungAlreadyUsed] - the L3 rung (ceiling, rule 5, OR the file's review
|
|
273
|
-
* cap, rule 6) was already used once before on THIS
|
|
274
|
-
* always `stop`s — the plan's L3 rung is one per
|
|
273
|
+
* cap, rule 6) was already used once before on THIS file, from either source. A second hit
|
|
274
|
+
* always `stop`s — the plan's L3 rung is one per file, not one per trigger.
|
|
275
275
|
* @property {number} [roundsAtFile] - rule 6 (v1.3, §4.11): total review rounds run on this file
|
|
276
276
|
* so far, counted across every level — unlike `reviewRoundsAtLevel`, this is never reset by a
|
|
277
277
|
* level escalation.
|
package/src/doctor/index.mjs
CHANGED
|
@@ -11,6 +11,8 @@ import { mkdirSync, rmSync } from 'node:fs';
|
|
|
11
11
|
import os from 'node:os';
|
|
12
12
|
import path from 'node:path';
|
|
13
13
|
import { cliNameForProvider } from '../engines/provider-cli.mjs';
|
|
14
|
+
import { readAllRows } from '../ledger/write.mjs';
|
|
15
|
+
import { modelStats, reviewerLines } from '../review/model-stats.mjs';
|
|
14
16
|
import { newRunId } from '../state/paths.mjs';
|
|
15
17
|
import { runRoot } from '../util/tmp.mjs';
|
|
16
18
|
import { checkCommands, checkConfig, checkJev, checkKeys, checkLedger, checkLinks, checkLocks, checkReviewConcurrency, checkReviewState, checkTmp, checkTools } from './local.mjs';
|
|
@@ -53,6 +55,15 @@ export async function runDoctor(opts = {}, deps = {}) {
|
|
|
53
55
|
const keys = await checkKeys(cfg, { env, store: deps.store, opRead: deps.opRead });
|
|
54
56
|
/** @type {Row[]} */
|
|
55
57
|
const rows = [...checked.rows, ...links, ...keys.rows];
|
|
58
|
+
if (cfg?.project?.slug) {
|
|
59
|
+
try {
|
|
60
|
+
const lines = reviewerLines(modelStats(await readAllRows(cfg.project.slug), cfg.review?.adaptive));
|
|
61
|
+
rows.push(...lines.map((line, i) => {
|
|
62
|
+
const at = line.indexOf(': ');
|
|
63
|
+
return row(`reviewer.${i}`, 'INFO', line.slice(0, at), line.slice(at + 2));
|
|
64
|
+
}));
|
|
65
|
+
} catch { /* a corrupt ledger is diagnosed by the existing ledger check */ }
|
|
66
|
+
}
|
|
56
67
|
if (opts.quick) return rows;
|
|
57
68
|
|
|
58
69
|
if (cfg) {
|
|
@@ -69,7 +69,7 @@ function removeRun(runId) {
|
|
|
69
69
|
} catch {
|
|
70
70
|
return;
|
|
71
71
|
}
|
|
72
|
-
for (const name of names) if (name === `${runId}.json` || name === `${runId}.key` || name === `${runId}.lock` || name.startsWith(`${runId}.json.`)) rmSync(path.join(runsDir(), name), { recursive: true, force: true });
|
|
72
|
+
for (const name of names) if (name === runId || name === `${runId}.json` || name === `${runId}.key` || name === `${runId}.lock` || name.startsWith(`${runId}.json.`)) rmSync(path.join(runsDir(), name), { recursive: true, force: true });
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
/**
|