@codedology/code-forge 0.14.1 → 0.15.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 +18 -0
- package/README.md +1 -1
- package/docs/reference/blocks.json +366 -1
- package/docs/reference/config.md +7 -0
- package/package.json +1 -1
- package/schema/code-forge.schema.json +22 -0
- package/scripts/first-use.mjs +11 -5
- package/skill/SKILL.md +2 -0
- package/skill/references/autopilot.md +2 -0
- package/skill/references/code.md +25 -1
- package/skill/references/review.md +10 -2
- package/skill/scripts/forge +1 -1
- package/src/autopilot/scopes.mjs +2 -1
- package/src/cli/block.mjs +235 -44
- package/src/cli/gates.mjs +36 -6
- package/src/cli/ledger.mjs +21 -2
- package/src/cli/report.mjs +14 -0
- package/src/cli/review-file.mjs +56 -16
- package/src/cli/run.mjs +44 -7
- package/src/cli/status.mjs +7 -3
- package/src/config/validate.mjs +13 -1
- package/src/gates/acceptance.mjs +7 -3
- package/src/gates/expand.mjs +25 -2
- package/src/gates/run.mjs +38 -8
- package/src/ledger/report.mjs +29 -7
- package/src/review/accept-hit.mjs +22 -4
- package/src/review/engine.mjs +30 -10
- package/src/review/finding.schema.json +2 -1
- package/src/review/fixloop.mjs +79 -14
- package/src/review/gate-check.mjs +22 -3
- package/src/review/lenses/recheck.md +3 -0
- package/src/review/packet.mjs +26 -0
- package/src/state/block.mjs +162 -20
- package/src/state/run.mjs +35 -8
- package/src/util/forbidden.mjs +14 -0
- package/src/util/reaper.mjs +3 -3
- package/src/worker/engine.mjs +60 -4
- package/src/worker/loop.mjs +19 -10
- package/src/worker/queue.mjs +5 -2
package/src/state/block.mjs
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { createHash, randomUUID } from 'node:crypto';
|
|
15
|
-
import { createReadStream, existsSync, lstatSync, readlinkSync, realpathSync } from 'node:fs';
|
|
15
|
+
import { createReadStream, existsSync, lstatSync, readFileSync, readdirSync, readlinkSync, realpathSync } from 'node:fs';
|
|
16
16
|
import os from 'node:os';
|
|
17
17
|
import path from 'node:path';
|
|
18
18
|
import { BLOCK_KINDS, blockKind, dispatchLevel } from '../decide/escalation.mjs';
|
|
@@ -25,6 +25,8 @@ import { StateError } from './paths.mjs';
|
|
|
25
25
|
import { assertOwned, blocksOverlap, isExactEntry, ownsFile, scopeGate } from './registry.mjs';
|
|
26
26
|
import { checkWorkerPin, readRun, saveRun, withRunLock, writeSigned } from './run.mjs';
|
|
27
27
|
import { verifyRow, loadKey } from './signer.mjs';
|
|
28
|
+
import * as reviewQueue from '../worker/queue.mjs';
|
|
29
|
+
import { repoRootOf } from '../worker/ticket.mjs';
|
|
28
30
|
|
|
29
31
|
/** @typedef {import('./run.mjs').WriteRow} WriteRow */
|
|
30
32
|
|
|
@@ -216,16 +218,18 @@ export function briefPointer(relPath, content) {
|
|
|
216
218
|
|
|
217
219
|
/**
|
|
218
220
|
* @param {unknown} clauses
|
|
219
|
-
* @returns {
|
|
221
|
+
* @returns {import('../gates/acceptance.mjs').AcceptanceClause[]}
|
|
220
222
|
*/
|
|
221
223
|
function assertAcceptance(clauses) {
|
|
222
224
|
const ok =
|
|
223
225
|
Array.isArray(clauses) &&
|
|
224
226
|
clauses.length > 0 &&
|
|
225
227
|
clauses.every(
|
|
226
|
-
(c) => typeof c?.clause === 'string' && c.clause.length > 0 &&
|
|
228
|
+
(c) => typeof c?.clause === 'string' && c.clause.length > 0 && (c.kind === 'human_check'
|
|
229
|
+
? !Object.hasOwn(c, 'tests')
|
|
230
|
+
: Array.isArray(c.tests) && c.tests.length > 0 && c.tests.every((t) => typeof t === 'string' && t.length > 0)),
|
|
227
231
|
);
|
|
228
|
-
if (!ok) throw new StateError('bad-acceptance', 'acceptance: a non-empty list of {clause, tests: [test ids…]} is required');
|
|
232
|
+
if (!ok) throw new StateError('bad-acceptance', 'acceptance: a non-empty list of {clause, tests: [test ids…]} or {clause, kind: human_check} without tests is required');
|
|
229
233
|
return clauses;
|
|
230
234
|
}
|
|
231
235
|
|
|
@@ -315,6 +319,7 @@ function refuseConfigOwner(owned, kinds, guarded) {
|
|
|
315
319
|
* and the dispatch row carry the floor level, and the row adds `requested_level` and
|
|
316
320
|
* `trigger: 'docs_floor'`. B52: the entry also records `tree_at_open` ({@link treeAtOpen}), the
|
|
317
321
|
* tree's changes at open, which `block close` reads to tell the coder's changes from earlier ones.
|
|
322
|
+
* B134: `base_branch` is the workspace's current branch, or null when detached.
|
|
318
323
|
* B58: with `review.external.required` set, an owned entry that owns the project's config is
|
|
319
324
|
* refused (`config-owned`, {@link refuseConfigOwner}).
|
|
320
325
|
* B59: `decision` (the rules lane of a brief-mode block) is written as a signed `decision` row
|
|
@@ -352,12 +357,13 @@ export async function openBlock(opts) {
|
|
|
352
357
|
if (typeof level !== 'string' || !LEVEL.test(level)) throw new StateError('bad-level', 'level must be L0..L3');
|
|
353
358
|
if (!Number.isInteger(attempt) || attempt < 1) throw new StateError('bad-attempt', 'attempt must be an integer >= 1');
|
|
354
359
|
if (lines !== null && !(Number.isInteger(lines) && lines > 0)) throw new StateError('bad-lines', 'lines forecast must be a positive integer');
|
|
355
|
-
if (opts.kind !== undefined && !BLOCK_KINDS.includes(opts.kind)) throw new StateError('bad-kind', `kind must be one of ${BLOCK_KINDS.join(', ')}`);
|
|
360
|
+
if (opts.kind !== undefined && opts.kind !== 'light' && !BLOCK_KINDS.includes(opts.kind)) throw new StateError('bad-kind', `kind must be one of ${[...BLOCK_KINDS, 'light'].join(', ')}`);
|
|
356
361
|
if (opts.decision && opts.decision.answer !== level) throw new StateError('bad-decision', `the decision answer ${opts.decision.answer} is not the requested level ${level}`);
|
|
357
362
|
const owned = assertOwned(opts.owned);
|
|
358
363
|
const acceptance = assertAcceptance(opts.acceptance);
|
|
364
|
+
const humanChecks = acceptance.filter((c) => c.kind === 'human_check').map((c) => c.clause);
|
|
359
365
|
const tests = opts.tests === undefined ? null : [...assertOwned(opts.tests, { label: 'test files', code: 'bad-tests' })];
|
|
360
|
-
const kind = blockKind({ owned, declared: opts.kind });
|
|
366
|
+
const kind = opts.kind === 'light' ? 'light' : blockKind({ owned, declared: opts.kind });
|
|
361
367
|
const dispatched = dispatchLevel({ lane: level, kind, cfg: opts.cfg });
|
|
362
368
|
const pointer = opts.brief ? briefPointer(opts.brief.path, opts.brief.content) : null;
|
|
363
369
|
|
|
@@ -384,6 +390,7 @@ export async function openBlock(opts) {
|
|
|
384
390
|
try {
|
|
385
391
|
const { block, reused } = await mutateRun(runId, null, writeRow, async (record) => {
|
|
386
392
|
if (record.blocks[id]?.status === 'open') throw new StateError('already-open', `block ${id} is already open`);
|
|
393
|
+
if (record.blocks[id]?.status === 'awaiting_human_check') throw new StateError('already-open', `block ${id} is awaiting a human check`);
|
|
387
394
|
// B57: what each exact owned entry is on disk, under the lock (as `block claim`) — a regular
|
|
388
395
|
// file owns only itself, here and in every later check
|
|
389
396
|
const kinds = ownedKinds(record.workspace, owned);
|
|
@@ -391,6 +398,8 @@ export async function openBlock(opts) {
|
|
|
391
398
|
const prior = previousTree(record);
|
|
392
399
|
const baseSha = await resolveCommit(prior ? prior.base_sha : opts.base ?? 'HEAD', record.workspace);
|
|
393
400
|
if (baseSha !== stampedBase || record.workspace !== workspace) throw new StateError('base-moved', `the base moved while block ${id} was being opened; run block open again`);
|
|
401
|
+
const branch = await git(['git', 'symbolic-ref', '--quiet', '--short', 'HEAD'], record.workspace, [0, 1]);
|
|
402
|
+
const baseBranch = branch.code === 0 ? branch.stdout.trim() : null;
|
|
394
403
|
// B62a: the tree is planned (read-only checks) before the overlap check, which needs its path
|
|
395
404
|
const planned = opts.worktree ? await opts.worktree({ baseSha, previous: prior }) : null;
|
|
396
405
|
const self = { owned_files: owned, owned_kinds: kinds, worktree: planned ? { path: planned.path } : null };
|
|
@@ -402,7 +411,8 @@ export async function openBlock(opts) {
|
|
|
402
411
|
// passed; a tree made here is rolled back below if the rows or the save then fail
|
|
403
412
|
made = planned ? await planned.make() : null;
|
|
404
413
|
const tree = made;
|
|
405
|
-
const entry = { block: id, base_sha: baseSha, owned_files: [...owned], owned_kinds: { ...kinds }, level: dispatched.level, kind, attempt, opened_at: now.toISOString(), acceptance, lines_forecast: lines, status: 'open', tree_at_open: treeOpen, ...(tests ? { test_files: tests } : {}), ...(tree ? { worktree: tree.worktree } : {}) };
|
|
414
|
+
const entry = { block: id, base_sha: baseSha, base_branch: baseBranch, owned_files: [...owned], owned_kinds: { ...kinds }, level: dispatched.level, kind, attempt, opened_at: now.toISOString(), acceptance, lines_forecast: lines, status: 'open', tree_at_open: treeOpen, ...(tests ? { test_files: tests } : {}), ...(tree ? { worktree: tree.worktree } : {}) };
|
|
415
|
+
if (humanChecks.length > 0) Object.assign(entry, { human_checks: humanChecks });
|
|
406
416
|
record.blocks[id] = entry;
|
|
407
417
|
const floor = dispatched.trigger === 'docs_floor' ? { requested_level: level, trigger: 'docs_floor' } : {};
|
|
408
418
|
const created = tree?.created ? [{ event: 'worktree.created', block: id, path: tree.worktree.path, branch: tree.worktree.branch, base: tree.worktree.base_sha }] : [];
|
|
@@ -411,7 +421,7 @@ export async function openBlock(opts) {
|
|
|
411
421
|
rows: [
|
|
412
422
|
...(opts.decision ? [{ ...opts.decision, event: 'decision', decision_id: opts.decisionId ?? randomUUID(), block: id }] : []),
|
|
413
423
|
...created,
|
|
414
|
-
{ event: 'dispatch', block: id, level: dispatched.level, kind, ...floor, attempt, base_sha: baseSha, owned_files: entry.owned_files, lines_forecast: lines, ...(tests ? { test_files: tests } : {}) },
|
|
424
|
+
{ event: 'dispatch', block: id, level: dispatched.level, kind, ...floor, attempt, base_sha: baseSha, base_branch: baseBranch, owned_files: entry.owned_files, lines_forecast: lines, ...(tests ? { test_files: tests } : {}) },
|
|
415
425
|
],
|
|
416
426
|
};
|
|
417
427
|
});
|
|
@@ -444,11 +454,14 @@ export async function attemptBlock({ runId, id, writeRow }) {
|
|
|
444
454
|
* in between touched a file this block owns (that is a conflict ⇒ stop). Rename detection is OFF
|
|
445
455
|
* (`--no-renames`): a rename lists both its old and its new path, so moving an owned file away
|
|
446
456
|
* is a conflict too. Never silent. Owned means `ownsFile` (B57: a directory entry owns the files
|
|
447
|
-
* below it). B62a: refused for
|
|
457
|
+
* below it). B62a: refused for an open block with its own worktree (`worktree-rebase`).
|
|
458
|
+
* B134: a closed worktree block instead rebases its branch onto its recorded base branch,
|
|
459
|
+
* retaining every changed owned file's blob; a conflict or changed blob restores the old head.
|
|
448
460
|
* @param {{runId: string, id: string, head?: string, writeRow: WriteRow}} opts
|
|
449
461
|
* @returns {Promise<{old_base: string, new_base: string}>}
|
|
450
462
|
*/
|
|
451
463
|
export async function rebaseBlock({ runId, id, head = 'HEAD', writeRow }) {
|
|
464
|
+
if (['closed', 'awaiting_human_check'].includes((await readRun(runId)).blocks[id]?.status)) return rebaseClosedBlock({ runId, id, writeRow });
|
|
452
465
|
return mutateRun(runId, id, writeRow, async (record, block) => {
|
|
453
466
|
// B62a: a worktree block's base is the commit its tree started from; moving it would leave the
|
|
454
467
|
// tree on its old base, so the rebase is refused
|
|
@@ -466,6 +479,75 @@ export async function rebaseBlock({ runId, id, head = 'HEAD', writeRow }) {
|
|
|
466
479
|
});
|
|
467
480
|
}
|
|
468
481
|
|
|
482
|
+
/**
|
|
483
|
+
* Hash committed paths, with null for a deletion. NUL-delimited tree entries preserve unusual
|
|
484
|
+
* file names; filter exact names because git's path selection may also return descendants.
|
|
485
|
+
* @param {string} cwd @param {string} ref @param {string[]} files
|
|
486
|
+
* @returns {Promise<Map<string, string | null>>}
|
|
487
|
+
*/
|
|
488
|
+
async function committedBlobs(cwd, ref, files) {
|
|
489
|
+
const hashes = new Map(files.map((file) => [file, /** @type {string | null} */ (null)]));
|
|
490
|
+
if (files.length === 0) return hashes;
|
|
491
|
+
const tree = await git(['git', 'ls-tree', '-r', '-z', '--full-tree', ref, '--', ...files], cwd);
|
|
492
|
+
for (const entry of tree.stdout.split('\0').filter(Boolean)) {
|
|
493
|
+
const tab = entry.indexOf('\t');
|
|
494
|
+
const file = entry.slice(tab + 1);
|
|
495
|
+
if (hashes.has(file)) hashes.set(file, entry.slice(0, tab).split(' ')[2]);
|
|
496
|
+
}
|
|
497
|
+
return hashes;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Closed blocks have a separate mutation path: check their status again under the lock,
|
|
502
|
+
* restore the branch on failure (including a failed signed write), and retain closed/awaiting status.
|
|
503
|
+
* @param {{runId: string, id: string, writeRow: WriteRow}} opts
|
|
504
|
+
* @returns {Promise<{old_base: string, new_base: string}>}
|
|
505
|
+
*/
|
|
506
|
+
async function rebaseClosedBlock({ runId, id, writeRow }) {
|
|
507
|
+
return withRunLock(runId, async () => {
|
|
508
|
+
const record = await readRun(runId);
|
|
509
|
+
if (record.status !== 'active') throw new StateError('run-ended', `run ${runId} has ended`);
|
|
510
|
+
const block = record.blocks[id];
|
|
511
|
+
if (!['closed', 'awaiting_human_check'].includes(block?.status) || !block.worktree) throw new StateError('no-block', `block ${id} is not open in run ${runId}`);
|
|
512
|
+
if (!block.base_branch) throw new StateError('rebase-no-base', `block ${id} has no recorded base branch`);
|
|
513
|
+
const tree = block.worktree.path;
|
|
514
|
+
const branch = await git(['git', 'symbolic-ref', '--quiet', 'HEAD'], tree, [0, 1]);
|
|
515
|
+
if (branch.stdout.trim() !== `refs/heads/${block.worktree.branch}`) throw new StateError('worktree-rebase', `block ${id}: its worktree is not on branch ${block.worktree.branch}`);
|
|
516
|
+
// A reset must never discard edits made after close, or abort an earlier rebase.
|
|
517
|
+
const status = await git(['git', 'status', '--porcelain'], tree);
|
|
518
|
+
if (status.stdout.length > 0) throw new StateError('rebase-conflict', 'rebase-conflict');
|
|
519
|
+
const oldHead = await resolveCommit('HEAD', tree);
|
|
520
|
+
const newBase = await resolveCommit(`refs/heads/${block.base_branch}`, record.workspace);
|
|
521
|
+
const top = await repoTop(record.workspace);
|
|
522
|
+
const prefix = path.relative(top, realpathSync(record.workspace)).split(path.sep).join('/');
|
|
523
|
+
const diff = await git(['git', 'diff', '--no-renames', '--name-only', '-z', block.base_sha, oldHead, '--'], tree);
|
|
524
|
+
const files = diff.stdout.split('\0').filter((file) => file.length > 0 && (
|
|
525
|
+
ownsFile(block.owned_files, file, block.owned_kinds) ||
|
|
526
|
+
(prefix !== '' && file.startsWith(`${prefix}/`) && ownsFile(block.owned_files, file.slice(prefix.length + 1), block.owned_kinds))
|
|
527
|
+
)).sort();
|
|
528
|
+
const before = await committedBlobs(tree, oldHead, files);
|
|
529
|
+
const moved = { old_base: block.base_sha, new_base: newBase };
|
|
530
|
+
try {
|
|
531
|
+
const rebased = await exec(['git', '-C', tree, 'rebase', newBase], { cwd: record.workspace, env: gitEnv(), timeoutMs: 30000 });
|
|
532
|
+
if (rebased.result !== 'ok') throw new StateError('rebase-conflict', 'rebase-conflict');
|
|
533
|
+
const after = await committedBlobs(tree, 'HEAD', files);
|
|
534
|
+
const changed = files.filter((file) => before.get(file) !== after.get(file));
|
|
535
|
+
if (changed.length > 0) throw new StateError('rebase-changed-reviewed', `rebase-changed-reviewed ${changed.join(', ')}`);
|
|
536
|
+
block.base_sha = newBase;
|
|
537
|
+
block.worktree.base_sha = newBase;
|
|
538
|
+
await writeSigned(runId, writeRow, { event: 'block.rebase', block: id, ...moved, closed: true });
|
|
539
|
+
await saveRun(record);
|
|
540
|
+
return moved;
|
|
541
|
+
} catch (err) {
|
|
542
|
+
// --abort restores a conflicted rebase; after a completed rebase there is nothing to
|
|
543
|
+
// abort, and reset restores the old branch head. The tree was clean before either.
|
|
544
|
+
await exec(['git', '-C', tree, 'rebase', '--abort'], { cwd: record.workspace, env: gitEnv(), timeoutMs: 30000 });
|
|
545
|
+
await git(['git', 'reset', '--hard', oldHead], tree);
|
|
546
|
+
throw err;
|
|
547
|
+
}
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
|
|
469
551
|
/**
|
|
470
552
|
* `block claim` — assign one EXACT path (an orphan, or any unowned path) to `id`; refused for a
|
|
471
553
|
* glob, and when another open block owns it — or anything it would own: the claimed path becomes an
|
|
@@ -513,13 +595,35 @@ export async function checkScope({ runId, id, files, writeRow }) {
|
|
|
513
595
|
|
|
514
596
|
/** @param {{runId: string, id: string, reason: string, writeRow: WriteRow, event?: string}} opts */
|
|
515
597
|
export async function stopBlock({ runId, id, reason, writeRow, event = 'block.stop' }) {
|
|
516
|
-
await mutateRun(runId,
|
|
598
|
+
await mutateRun(runId, null, writeRow, (record) => {
|
|
599
|
+
const block = record.blocks[id];
|
|
600
|
+
if (!['open', 'awaiting_human_check'].includes(block?.status)) throw new StateError('no-block', `block ${id} is not open in run ${runId}`);
|
|
517
601
|
block.status = 'stopped';
|
|
518
602
|
block.stop_reason = reason;
|
|
519
603
|
return { result: undefined, rows: [{ event, block: id, reason }] };
|
|
520
604
|
});
|
|
521
605
|
}
|
|
522
606
|
|
|
607
|
+
/**
|
|
608
|
+
* Called only after the CLI's terminal confirmation; recheck under the lock before signing.
|
|
609
|
+
* @param {{runId: string, id: string, note: string, proof?: string | null, writeRow: WriteRow, now?: Date}} opts
|
|
610
|
+
*/
|
|
611
|
+
export async function humanPassBlock({ runId, id, note, proof = null, writeRow, now = new Date() }) {
|
|
612
|
+
return mutateRun(runId, null, writeRow, (record) => {
|
|
613
|
+
const block = record.blocks[id];
|
|
614
|
+
if (block?.status !== 'awaiting_human_check') throw new StateError('not-awaiting', `block ${id} is not awaiting a human check`);
|
|
615
|
+
if (typeof note !== 'string' || note.trim().length === 0) throw new StateError('usage', 'block human-pass needs --note <text>');
|
|
616
|
+
if (proof !== null && (typeof proof !== 'string' || !existsSync(proof))) throw new StateError('usage', '--proof must name an existing directory or file');
|
|
617
|
+
const grant = record.autopilot;
|
|
618
|
+
if (grant && !['stopped', 'expired'].includes(grant.status) && !(now.getTime() >= Date.parse(grant.until))) {
|
|
619
|
+
throw new StateError('autopilot-active', `block human-pass is refused while an autopilot grant is active on run ${runId}`);
|
|
620
|
+
}
|
|
621
|
+
const row = { event: 'block.human_pass', block: id, note, proof, checks: [...block.human_checks], by: 'human' };
|
|
622
|
+
block.status = 'closed';
|
|
623
|
+
return { result: row, rows: [row] };
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
|
|
523
627
|
/**
|
|
524
628
|
* B8's rows of the block gate, in order: worker pin ⇒ `worker.replaced`/`worker.down` (block
|
|
525
629
|
* stopped); the block's signed `dispatch` row must exist and verify, and every other
|
|
@@ -533,16 +637,44 @@ export async function stopBlock({ runId, id, reason, writeRow, event = 'block.st
|
|
|
533
637
|
* livePid?: number, probe?: import('./run.mjs').StartTimeProbe,
|
|
534
638
|
* extraChecks?: Array<() => Promise<{ok: boolean, reason?: string}>>,
|
|
535
639
|
* closeRows?: Record<string, any>[],
|
|
640
|
+
* dryRun?: boolean,
|
|
536
641
|
* }} opts
|
|
537
|
-
* @returns {Promise<{ok: boolean, status: 'closed' | 'stopped' | 'open', reason?: string, event?: string}>}
|
|
642
|
+
* @returns {Promise<{ok: boolean, status: 'closed' | 'awaiting_human_check' | 'stopped' | 'open', reason?: string, event?: string}>}
|
|
538
643
|
*/
|
|
539
|
-
export async function closeBlock({ runId, id, rows, writeRow, livePid, probe, extraChecks = [], closeRows = [] }) {
|
|
644
|
+
export async function closeBlock({ runId, id, rows, writeRow, livePid, probe, extraChecks = [], closeRows = [], dryRun = false }) {
|
|
540
645
|
const record = await readRun(runId);
|
|
541
646
|
if (record.blocks[id]?.status !== 'open') throw new StateError('no-block', `block ${id} is not open in run ${runId}`);
|
|
542
647
|
|
|
543
|
-
|
|
648
|
+
let pin = await checkWorkerPin(record, { livePid, probe });
|
|
649
|
+
const pins = record.workers ?? (record.worker ? [record.worker] : []);
|
|
650
|
+
const counted = rows.filter((row) => row.block === id && (row.run === undefined || row.run === runId) && row.event === 'review.result' && !['stale', 'unavailable', 'answers'].includes(row.status) && !(row.status === 'refused' && row.reason === 'other-run'));
|
|
651
|
+
// Ledgers written before B107 omit worker identity and use the run's pin check above.
|
|
652
|
+
if (counted.some((row) => row.worker !== undefined && !pins.some((p) => p.pid === row.worker?.pid && p.started_at === row.worker?.started_at))) {
|
|
653
|
+
pin = { ok: false, event: 'worker.replaced', reason: 'a counted review result does not name a pinned worker' };
|
|
654
|
+
}
|
|
655
|
+
if (pin.ok) {
|
|
656
|
+
const repoRoot = await repoRootOf(record.workspace);
|
|
657
|
+
let names;
|
|
658
|
+
try { names = readdirSync(reviewQueue.queueDir(repoRoot)); }
|
|
659
|
+
catch (err) { if (err.code !== 'ENOENT') throw err; names = []; }
|
|
660
|
+
for (const name of names.filter((n) => /^[0-9a-f]{24}\.json$/.test(n))) {
|
|
661
|
+
const ticket = name.slice(0, 24);
|
|
662
|
+
const queued = reviewQueue.readTicket(repoRoot, ticket);
|
|
663
|
+
if (queued.run !== runId || queued.block !== id) continue;
|
|
664
|
+
const claimKey = `ticket\0${runId}\0${ticket}`;
|
|
665
|
+
const file = path.join(os.homedir(), '.code-forge', 'locks', `${createHash('sha256').update(claimKey).digest('hex')}.lock`);
|
|
666
|
+
let claim;
|
|
667
|
+
try { claim = JSON.parse(readFileSync(file, 'utf8')); }
|
|
668
|
+
catch (err) { if (err.code === 'ENOENT') continue; throw err; }
|
|
669
|
+
const owner = pins.find((p) => p.pid === claim.pid && p.started_at === claim.start);
|
|
670
|
+
if (owner && !(await checkWorkerPin({ worker: owner }, { probe })).ok) {
|
|
671
|
+
pin = { ok: false, event: 'worker.down', reason: `dead pinned worker pid ${owner.pid} holds ticket ${ticket}` };
|
|
672
|
+
break;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
}
|
|
544
676
|
if (!pin.ok) {
|
|
545
|
-
await stopBlock({ runId, id, reason: pin.reason, writeRow, event: pin.event });
|
|
677
|
+
if (!dryRun) await stopBlock({ runId, id, reason: pin.reason, writeRow, event: pin.event });
|
|
546
678
|
return { ok: false, status: 'stopped', event: pin.event, reason: pin.reason };
|
|
547
679
|
}
|
|
548
680
|
|
|
@@ -555,25 +687,35 @@ export async function closeBlock({ runId, id, rows, writeRow, livePid, probe, ex
|
|
|
555
687
|
const reasons = bad.map((x) => `${x.row.event}: ${x.result.reason}`);
|
|
556
688
|
if (!dispatchOk) reasons.push('no verified dispatch row');
|
|
557
689
|
const reason = reasons.join('; ');
|
|
558
|
-
await stopBlock({ runId, id, reason, writeRow, event: 'ledger.tamper' });
|
|
690
|
+
if (!dryRun) await stopBlock({ runId, id, reason, writeRow, event: 'ledger.tamper' });
|
|
559
691
|
return { ok: false, status: 'stopped', event: 'ledger.tamper', reason };
|
|
560
692
|
}
|
|
561
693
|
|
|
694
|
+
const failures = [];
|
|
562
695
|
for (const check of extraChecks) {
|
|
563
696
|
const result = await check();
|
|
564
|
-
if (!result.ok)
|
|
697
|
+
if (!result.ok) {
|
|
698
|
+
if (!dryRun) return { ok: false, status: 'open', reason: result.reason ?? 'check failed' };
|
|
699
|
+
failures.push(result.reason ?? 'check failed');
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
if (dryRun) {
|
|
703
|
+
if (record.orphans.length > 0) failures.push(`orphans: ${record.orphans.join(', ')}`);
|
|
704
|
+
return { ok: failures.length === 0, status: 'open', ...(failures.length ? { reason: failures.join('; ') } : {}) };
|
|
565
705
|
}
|
|
566
706
|
|
|
567
|
-
/** @typedef {{ok: boolean, status: 'closed' | 'stopped' | 'open', reason?: string, event?: string}} CloseResult */
|
|
707
|
+
/** @typedef {{ok: boolean, status: 'closed' | 'awaiting_human_check' | 'stopped' | 'open', reason?: string, event?: string}} CloseResult */
|
|
568
708
|
return mutateRun(runId, id, writeRow, (current, block) => {
|
|
569
709
|
if (current.orphans.length > 0) {
|
|
570
710
|
/** @type {CloseResult} */
|
|
571
711
|
const refused = { ok: false, status: 'open', reason: `orphans: ${current.orphans.join(', ')}` };
|
|
572
712
|
return { result: refused, rows: [] };
|
|
573
713
|
}
|
|
574
|
-
block.
|
|
714
|
+
const checks = block.human_checks ?? [];
|
|
715
|
+
block.status = checks.length > 0 ? 'awaiting_human_check' : 'closed';
|
|
575
716
|
/** @type {CloseResult} */
|
|
576
|
-
const closed = { ok: true, status:
|
|
577
|
-
return { result: closed, rows: [...closeRows, { event: 'block.close', block: id, status: 'complete' }
|
|
717
|
+
const closed = { ok: true, status: block.status };
|
|
718
|
+
return { result: closed, rows: [...closeRows, { event: 'block.close', block: id, status: 'complete' },
|
|
719
|
+
...(checks.length > 0 ? [{ event: 'block.awaiting_human_check', block: id, checks }] : [])] };
|
|
578
720
|
});
|
|
579
721
|
}
|
package/src/state/run.mjs
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* parameter; the CLI verb does the static import) and are SIGNED with the run key first.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
import { existsSync } from 'node:fs';
|
|
15
16
|
import { mkdir, readFile, rename, rmdir, writeFile } from 'node:fs/promises';
|
|
16
17
|
import path from 'node:path';
|
|
17
18
|
import { exec } from '../util/exec.mjs';
|
|
@@ -31,7 +32,7 @@ const LOCK_POLL_MS = 25;
|
|
|
31
32
|
*/
|
|
32
33
|
export async function processStartTime(pid) {
|
|
33
34
|
if (!Number.isInteger(pid) || pid <= 0) return null;
|
|
34
|
-
const res = await exec(['ps', '-o', 'lstart=', '-p', String(pid)], {
|
|
35
|
+
const res = await exec([existsSync('/bin/ps') ? '/bin/ps' : 'ps', '-o', 'lstart=', '-p', String(pid)], {
|
|
35
36
|
env: { ...process.env, LC_ALL: 'C' },
|
|
36
37
|
okExitCodes: [0, 1],
|
|
37
38
|
timeoutMs: 10000,
|
|
@@ -177,6 +178,7 @@ export async function startRun(opts) {
|
|
|
177
178
|
orchestrator_pid: opts.orchestratorPid ?? process.pid,
|
|
178
179
|
solo_pid: opts.soloPid ?? null,
|
|
179
180
|
worker,
|
|
181
|
+
workers: worker ? [worker] : [],
|
|
180
182
|
status: 'active',
|
|
181
183
|
blocks: {},
|
|
182
184
|
orphans: [],
|
|
@@ -250,6 +252,10 @@ export async function reloadRun({ runId, config, readPending = () => [], effecti
|
|
|
250
252
|
if (oldHash === newHash) return { changed: [], oldHash, newHash, pinned: 0, rowError: null };
|
|
251
253
|
const before = state && Object.hasOwn(stored, state.hash) ? stored[state.hash] : undefined;
|
|
252
254
|
if (state && (before === null || typeof before !== 'object')) throw new StateError('config-snapshot', `run ${runId}: the current config snapshot is missing from the run record`);
|
|
255
|
+
// B138: close authority belongs to the run-start snapshot, including legacy runs.
|
|
256
|
+
if (before?.close?.require_human !== config.close?.require_human) {
|
|
257
|
+
throw new StateError('immutable-key', 'close.require_human cannot change mid-run (fixed by the owner before the run starts)');
|
|
258
|
+
}
|
|
253
259
|
/** @type {string[]} */
|
|
254
260
|
let frozen;
|
|
255
261
|
if (state) {
|
|
@@ -309,15 +315,18 @@ export async function reloadRun({ runId, config, readPending = () => [], effecti
|
|
|
309
315
|
|
|
310
316
|
/**
|
|
311
317
|
* `run start --reattach`: re-pin a restarted worker (the orchestrator only — §4.8).
|
|
312
|
-
* @param {{runId: string, workerPid: number, writeRow: WriteRow, probe?: StartTimeProbe}} opts
|
|
318
|
+
* @param {{runId: string, workerPid: number, writeRow: WriteRow, probe?: StartTimeProbe, add?: boolean}} opts
|
|
313
319
|
*/
|
|
314
|
-
export async function reattachWorker({ runId, workerPid, writeRow, probe = processStartTime }) {
|
|
320
|
+
export async function reattachWorker({ runId, workerPid, writeRow, probe = processStartTime, add = false }) {
|
|
315
321
|
if (!isSignalablePid(workerPid)) throw new StateError('bad-worker-pid', 'worker pid must be an integer > 1');
|
|
316
322
|
return withRunLock(runId, async () => {
|
|
317
323
|
const current = await readRun(runId);
|
|
318
324
|
if (current.status !== 'active') throw new StateError('run-ended', `run ${runId} has ended`);
|
|
319
|
-
|
|
320
|
-
|
|
325
|
+
const pin = await pinWorker(workerPid, probe);
|
|
326
|
+
const pins = current.workers ?? (current.worker ? [current.worker] : []);
|
|
327
|
+
current.workers = add ? [...pins, pin] : [pin];
|
|
328
|
+
current.worker = current.workers[0];
|
|
329
|
+
await writeSigned(runId, writeRow, { event: add ? 'worker.add' : 'worker.reattach', pid: workerPid });
|
|
321
330
|
await saveRun(current);
|
|
322
331
|
return current;
|
|
323
332
|
});
|
|
@@ -331,7 +340,18 @@ export async function reattachWorker({ runId, workerPid, writeRow, probe = proce
|
|
|
331
340
|
* @returns {Promise<{ok: boolean, event?: 'worker.replaced' | 'worker.down', reason?: string}>}
|
|
332
341
|
*/
|
|
333
342
|
export async function checkWorkerPin(record, { livePid, probe = processStartTime } = {}) {
|
|
334
|
-
|
|
343
|
+
let pin = record.worker;
|
|
344
|
+
if (record.workers?.length > 1) {
|
|
345
|
+
const pins = record.workers;
|
|
346
|
+
if (pins.some((p) => !isSignalablePid(p.pid)) || (livePid !== undefined && !pins.some((p) => p.pid === livePid))) {
|
|
347
|
+
return { ok: false, event: 'worker.replaced', reason: 'the live worker does not match a pinned worker' };
|
|
348
|
+
}
|
|
349
|
+
if (livePid === undefined) {
|
|
350
|
+
const live = await Promise.all(pins.map(async (p) => (await probe(p.pid)) === p.started_at));
|
|
351
|
+
return live.some(Boolean) ? { ok: true } : { ok: false, event: 'worker.down', reason: 'no pinned worker is running' };
|
|
352
|
+
}
|
|
353
|
+
pin = pins.find((p) => p.pid === livePid);
|
|
354
|
+
}
|
|
335
355
|
if (!pin) return { ok: false, event: 'worker.down', reason: 'no worker is pinned for this run' };
|
|
336
356
|
if (!isSignalablePid(pin.pid)) return { ok: false, event: 'worker.replaced', reason: 'the pinned worker pid is not an integer > 1' };
|
|
337
357
|
if (livePid !== undefined && livePid !== pin.pid) {
|
|
@@ -380,14 +400,21 @@ export async function endRun({ runId, writeRow, stopWorker = stopPinnedWorker, p
|
|
|
380
400
|
return withRunLock(runId, async () => {
|
|
381
401
|
const current = await readRun(runId);
|
|
382
402
|
if (current.status !== 'active') throw new StateError('run-ended', `run ${runId} has already ended`);
|
|
403
|
+
const awaiting = Object.keys(current.blocks).filter((id) => current.blocks[id].status === 'awaiting_human_check');
|
|
404
|
+
if (awaiting.length > 0) throw new StateError('awaiting-human-check', `run ${runId} has blocks awaiting human check: ${awaiting.join(', ')}; human-pass or stop them first`);
|
|
383
405
|
const open = Object.keys(current.blocks).filter((id) => current.blocks[id].status === 'open');
|
|
384
406
|
if (open.length > 0) throw new StateError('open-blocks', `run ${runId} still has open blocks: ${open.join(', ')}`);
|
|
385
407
|
const pin = await checkWorkerPin(current, { probe });
|
|
386
408
|
if (!pin.ok) await writeSigned(runId, writeRow, { event: pin.event, reason: pin.reason });
|
|
387
|
-
|
|
409
|
+
let stopped = false;
|
|
410
|
+
for (const worker of current.workers ?? (current.worker ? [current.worker] : [])) {
|
|
411
|
+
if ((await checkWorkerPin({ worker }, { probe })).ok) {
|
|
412
|
+
stopped = (await stopWorker(worker, { probe })).stopped || stopped;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
388
415
|
current.status = 'ended';
|
|
389
416
|
current.ended_at = new Date().toISOString();
|
|
390
|
-
await writeSigned(runId, writeRow, { event: 'run.end', worker_check: pin.ok ? 'ok' : pin.event, worker_stopped:
|
|
417
|
+
await writeSigned(runId, writeRow, { event: 'run.end', worker_check: pin.ok ? 'ok' : pin.event, worker_stopped: stopped });
|
|
391
418
|
await saveRun(current);
|
|
392
419
|
return current;
|
|
393
420
|
});
|
package/src/util/forbidden.mjs
CHANGED
|
@@ -282,6 +282,7 @@ export const FORBIDDEN = Object.freeze(
|
|
|
282
282
|
* - `--no-require-reviews` — the human's way out of `block close`'s per-file review check;
|
|
283
283
|
* - `--remove-worktree` (B62a) — `block close` removing the block's worktree, the orchestrator's
|
|
284
284
|
* alone. `block close` itself is not on this list, so the flag is listed on its own.
|
|
285
|
+
* - `code-forge block human-pass` (B137) — passing a human device check is human-only.
|
|
285
286
|
* @type {ReadonlyArray<ForbiddenEntry>}
|
|
286
287
|
*/
|
|
287
288
|
export const CODER_ONLY_FORBIDDEN = Object.freeze(
|
|
@@ -346,6 +347,19 @@ export const CODER_ONLY_FORBIDDEN = Object.freeze(
|
|
|
346
347
|
prefixes: [['code-forge', 'block', 'ack-waivers'], ['forge', 'block', 'ack-waivers']],
|
|
347
348
|
description: 'Acknowledge agent waivers — human-only, confirmed in the human terminal (B124b)',
|
|
348
349
|
},
|
|
350
|
+
{
|
|
351
|
+
id: 'code-forge-block-human-pass-from-coder',
|
|
352
|
+
kind: 'anyPrefix',
|
|
353
|
+
prefixes: [['code-forge', 'block', 'human-pass'], ['forge', 'block', 'human-pass']],
|
|
354
|
+
description: 'Pass a human device check — human-only, confirmed in the human terminal (B137)',
|
|
355
|
+
},
|
|
356
|
+
{
|
|
357
|
+
id: 'code-forge-add-worker-from-coder',
|
|
358
|
+
kind: 'commandFlag',
|
|
359
|
+
command: ['code-forge', 'run', 'start'],
|
|
360
|
+
groups: [{ anyOf: ['--add-worker'] }],
|
|
361
|
+
description: 'Add a review worker to a run — the orchestrator only (B107)',
|
|
362
|
+
},
|
|
349
363
|
].map(freezeEntry),
|
|
350
364
|
);
|
|
351
365
|
|
package/src/util/reaper.mjs
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
29
|
import { execFileSync } from 'node:child_process';
|
|
30
|
-
import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
30
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
31
31
|
import path from 'node:path';
|
|
32
32
|
import { setTimeout as sleep } from 'node:timers/promises';
|
|
33
33
|
|
|
@@ -85,7 +85,7 @@ export function isAlive(pid) {
|
|
|
85
85
|
export function readStartTime(pid) {
|
|
86
86
|
if (!isValidPid(pid)) return null;
|
|
87
87
|
try {
|
|
88
|
-
const out = execFileSync('ps', ['-o', 'lstart=', '-p', String(pid)], {
|
|
88
|
+
const out = execFileSync(existsSync('/bin/ps') ? '/bin/ps' : 'ps', ['-o', 'lstart=', '-p', String(pid)], {
|
|
89
89
|
encoding: 'utf8',
|
|
90
90
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
91
91
|
env: { ...process.env, LC_ALL: 'C', LANG: 'C' },
|
|
@@ -165,7 +165,7 @@ export function groupMembers(pgid) {
|
|
|
165
165
|
const members = new Map();
|
|
166
166
|
let out = '';
|
|
167
167
|
try {
|
|
168
|
-
out = execFileSync('ps', ['-A', '-o', 'pid=,pgid=,lstart='], {
|
|
168
|
+
out = execFileSync(existsSync('/bin/ps') ? '/bin/ps' : 'ps', ['-A', '-o', 'pid=,pgid=,lstart='], {
|
|
169
169
|
encoding: 'utf8',
|
|
170
170
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
171
171
|
env: { ...process.env, LC_ALL: 'C', LANG: 'C' },
|
package/src/worker/engine.mjs
CHANGED
|
@@ -95,13 +95,13 @@ import { askJev } from '../decide/jev-client.mjs';
|
|
|
95
95
|
import { planAndRecord, tierOf } from '../review/budget.mjs';
|
|
96
96
|
import { budgetFor } from '../review/packet.mjs';
|
|
97
97
|
import { ContextError, reviewDigest } from '../context/pack.mjs';
|
|
98
|
-
import { reviewFile, rulesRisk } from '../review/engine.mjs';
|
|
98
|
+
import { reviewFile, reviewLightBatch, rulesRisk } from '../review/engine.mjs';
|
|
99
99
|
import { reviewAnswers } from '../review/answers.mjs';
|
|
100
100
|
import { peerDiffReader } from '../review/moved.mjs';
|
|
101
101
|
import { computeFileSet, ownsFile } from '../gates/scope.mjs';
|
|
102
102
|
import { extraRoundsFor, newFileState, reopenForExtraRound, runRound } from '../review/fixloop.mjs';
|
|
103
103
|
import { reopenRowFor } from '../review/reopen.mjs';
|
|
104
|
-
import { readRun } from '../state/run.mjs';
|
|
104
|
+
import { processStartTime, readRun } from '../state/run.mjs';
|
|
105
105
|
import { StateError } from '../state/paths.mjs';
|
|
106
106
|
import { blockTree, worktreeRecord } from '../proof/worktree.mjs';
|
|
107
107
|
import { ledgerSchemaHistory } from '../review/schema-fallback.mjs';
|
|
@@ -109,7 +109,7 @@ import { verifyRow } from '../state/signer.mjs';
|
|
|
109
109
|
import { redact } from '../util/redact.mjs';
|
|
110
110
|
import { withLock } from '../util/file-locks.mjs';
|
|
111
111
|
import { blockAnchors, blockRungUsed, loadState, RESTORED_EVENT, saveState } from './review-state.mjs';
|
|
112
|
-
import { assertAnswers } from './queue.mjs';
|
|
112
|
+
import { assertAnswers, enqueueHash, isDone, writeResult } from './queue.mjs';
|
|
113
113
|
import { assertTicketId, contentHash, readRegularFileNoFollow, WorkerError } from './ticket.mjs';
|
|
114
114
|
|
|
115
115
|
/**
|
|
@@ -127,6 +127,8 @@ import { assertTicketId, contentHash, readRegularFileNoFollow, WorkerError } fro
|
|
|
127
127
|
* the worker's and a per-call `cfg` wins).
|
|
128
128
|
* @property {(row: Record<string, any>) => Promise<unknown>} [writeRow] - a signed ledger row
|
|
129
129
|
* (the worker adds `run`, `block`, `file`, `content_hash`).
|
|
130
|
+
* @property {(row: Record<string, any>) => Promise<unknown>} [writeBlockRow] - light fanout rows;
|
|
131
|
+
* the worker pins run and block, while the batch supplies each file and content hash.
|
|
130
132
|
* @property {Buffer} [key] - the run's HMAC key: signs the fix-loop state. Absent ⇒ engine only.
|
|
131
133
|
* @property {() => Promise<Array<Record<string, any>>>} [readRows] - the run's ledger rows (the
|
|
132
134
|
* `review.state` anchors the state is checked against); required with `key`.
|
|
@@ -197,7 +199,7 @@ export async function blockEntryFor(runId, block) {
|
|
|
197
199
|
throw new Error('worktree-malformed');
|
|
198
200
|
}
|
|
199
201
|
}
|
|
200
|
-
return { base: tree ? tree.base_sha : sha, level: typeof entry.level === 'string' && /^L[0-3]$/.test(entry.level) ? entry.level : 'L2', owned, ownedKinds, testFiles, acceptance: entry.acceptance, kind: blockKind({ owned, declared: entry.kind }), tree, workspace: typeof record.workspace === 'string' ? record.workspace : null };
|
|
202
|
+
return { base: tree ? tree.base_sha : sha, level: typeof entry.level === 'string' && /^L[0-3]$/.test(entry.level) ? entry.level : 'L2', owned, ownedKinds, testFiles, acceptance: entry.acceptance, kind: entry.kind === 'light' ? 'light' : blockKind({ owned, declared: entry.kind }), tree, workspace: typeof record.workspace === 'string' ? record.workspace : null };
|
|
201
203
|
}
|
|
202
204
|
|
|
203
205
|
/** @typedef {{path: string, branch: string, base_sha: string}} WorktreeEntry */
|
|
@@ -411,6 +413,10 @@ export async function reviewTicket(ticket, ctx) {
|
|
|
411
413
|
return { status: 'unavailable', reason: 'context-unreadable', approved: false, engine: 'adaptive', sessions: [] };
|
|
412
414
|
}
|
|
413
415
|
const digest = rulesDigest === '' ? {} : { rulesDigest };
|
|
416
|
+
if (entry.kind === 'light' && ctx.key) {
|
|
417
|
+
const batch = await lightRound(ticket, tctx, { entry, workDir, factsExcerpt, rulesDigest });
|
|
418
|
+
if (batch) return batch;
|
|
419
|
+
}
|
|
414
420
|
if (!ctx.key) {
|
|
415
421
|
return await reviewFile(
|
|
416
422
|
{ repoRoot: contentRoot, file: ticket.file, base: entry.base, cfg: ctx.cfg, kind: entry.kind, workDir, factsExcerpt, ...digest, peerDiffs, ...(await fileContext()) },
|
|
@@ -423,6 +429,56 @@ export async function reviewTicket(ticket, ctx) {
|
|
|
423
429
|
}
|
|
424
430
|
}
|
|
425
431
|
|
|
432
|
+
/** Serialize a light block across tickets/workers; durable signed rows enforce the two-batch cap.
|
|
433
|
+
* A batch completes every file's queue ticket, including files queued after this ticket started.
|
|
434
|
+
* @param {import('./queue.mjs').Ticket} ticket @param {ReviewContext} ctx
|
|
435
|
+
* @param {{entry: Awaited<ReturnType<typeof blockEntryFor>>, workDir: string, factsExcerpt: string, rulesDigest: string}} input
|
|
436
|
+
* @returns {Promise<ReviewOutcome | null>} null enters the ordinary per-file loop.
|
|
437
|
+
*/
|
|
438
|
+
async function lightRound(ticket, ctx, { entry, workDir, factsExcerpt, rulesDigest }) {
|
|
439
|
+
if (!ctx.key || !ctx.readRows || !ctx.writeBlockRow) return { status: 'unavailable', reason: 'no-ledger', approved: false, engine: 'adaptive', sessions: [] };
|
|
440
|
+
const key = ctx.key, readRows = ctx.readRows, writeRow = ctx.writeBlockRow;
|
|
441
|
+
const worker = { pid: process.pid, started_at: await processStartTime(process.pid) };
|
|
442
|
+
return withLock(`light:${ctx.runId}\0${ticket.block}`, async () => {
|
|
443
|
+
const root = ctx.contentRoot ?? ctx.repoRoot;
|
|
444
|
+
const files = (await computeFileSet({ cwd: root, base: entry.base ?? 'HEAD' })).all.filter((file) => ownsFile(entry.owned, file, entry.ownedKinds)).sort();
|
|
445
|
+
const hashes = files.map((file) => ({ file, content_hash: contentHash(root, file) }));
|
|
446
|
+
if (!hashes.some((f) => f.file === ticket.file && f.content_hash === ticket.content_hash)) return { status: 'stale', approved: false, engine: 'adaptive', sessions: [] };
|
|
447
|
+
const signature = JSON.stringify([entry.base, hashes]);
|
|
448
|
+
const rows = (await readRows()).filter((r) => r.run === ctx.runId && r.block === ticket.block && verifyRow(r, key).ok);
|
|
449
|
+
const cached = rows.filter((r) => r.event === 'review.light_result' && r.signature === signature).at(-1)?.outcomes?.[ticket.file];
|
|
450
|
+
if (cached && (cached.status === 'reviewed' || isDone(ctx.repoRoot, ticket.ticket))) return cached;
|
|
451
|
+
const count = rows.filter((r) => r.event === 'review.light_batch').length;
|
|
452
|
+
if (count >= 2) return null;
|
|
453
|
+
const round = count + 1;
|
|
454
|
+
// Reserve before the session: a crash cannot reset the cap on a worker restart.
|
|
455
|
+
await writeRow({ event: 'review.light_batch', round, signature });
|
|
456
|
+
const batch = await reviewLightBatch({ repoRoot: root, files, base: entry.base, cfg: ctx.cfg, workDir, factsExcerpt, rulesDigest }, { spawn: ctx.spawn, writeRow });
|
|
457
|
+
const moved = hashes.some((f) => contentHash(root, f.file) !== f.content_hash);
|
|
458
|
+
const findings = batch.findings ?? [];
|
|
459
|
+
const blocking = findings.some((f) => f.severity === 'critical' || f.severity === 'warning');
|
|
460
|
+
/** @type {Record<string, ReviewOutcome>} */
|
|
461
|
+
const outcomes = {};
|
|
462
|
+
for (const { file, content_hash } of hashes) {
|
|
463
|
+
const own = findings.filter((f) => f.file === file);
|
|
464
|
+
const approved = !moved && batch.status === 'reviewed' && (batch.approved || blocking) && own.every((f) => f.severity === 'nit');
|
|
465
|
+
const outcome = { ...batch, ...(moved ? { status: 'stale' } : {}), approved, findings: own, round, kind: 'full', level: entry.level,
|
|
466
|
+
next: { action: approved ? 'complete' : 'fix', level: entry.level } };
|
|
467
|
+
outcomes[file] = outcome;
|
|
468
|
+
if (batch.status === 'reviewed' && !moved) {
|
|
469
|
+
await writeRow({ event: 'review.round', file, content_hash, round, kind: 'full', level: entry.level, open_after: own.filter((f) => f.severity !== 'nit').length });
|
|
470
|
+
if (approved) await writeRow({ event: 'review.approved', file, content_hash });
|
|
471
|
+
}
|
|
472
|
+
const queued = enqueueHash({ repoRoot: ctx.repoRoot, run: ctx.runId, block: ticket.block, file, content_hash, ...(ticket.tree ? { tree: ticket.tree } : {}) });
|
|
473
|
+
const open = approved ? [] : own;
|
|
474
|
+
await writeRow({ event: 'review.result', ticket: queued.ticket, file, content_hash, status: outcome.status, approved, open, worker });
|
|
475
|
+
writeResult({ repoRoot: ctx.repoRoot, runId: ctx.runId, ticket: queued.ticket, result: { ...outcome, block: ticket.block, file, content_hash, open, worker }, key });
|
|
476
|
+
}
|
|
477
|
+
await writeRow({ event: 'review.light_result', signature, outcomes });
|
|
478
|
+
return outcomes[ticket.file];
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
|
|
426
482
|
/**
|
|
427
483
|
* B56: the block's OTHER changed files — the §4.1 file set since the block base (tracked changes ∪
|
|
428
484
|
* untracked), filtered to the block's owned files (`ownsFile`, as the budget row does), minus the
|