@codedology/code-forge 0.15.1 → 0.15.3

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.
Files changed (63) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +5 -1
  3. package/docs/README.md +2 -0
  4. package/docs/reference/blocks.json +154 -1
  5. package/docs/reference/config.md +27 -14
  6. package/package.json +1 -1
  7. package/schema/code-forge.schema.json +57 -2
  8. package/schema/outcome.schema.json +45 -0
  9. package/schema/pipeline.schema.json +30 -0
  10. package/scripts/first-use.mjs +8 -0
  11. package/scripts/gen-config-doc.mjs +1 -1
  12. package/skill/references/code.md +15 -0
  13. package/skill/references/ledger.md +13 -1
  14. package/skill/scripts/forge +1 -1
  15. package/src/cli/block.mjs +146 -30
  16. package/src/cli/context.mjs +17 -7
  17. package/src/cli/gates.mjs +76 -10
  18. package/src/cli/help.mjs +4 -0
  19. package/src/cli/outcome.mjs +87 -0
  20. package/src/cli/pipeline.mjs +61 -0
  21. package/src/cli/proof.mjs +10 -7
  22. package/src/cli/report.mjs +53 -6
  23. package/src/cli/review-file.mjs +168 -38
  24. package/src/cli/run.mjs +3 -2
  25. package/src/cli/sweep.mjs +28 -0
  26. package/src/cli/validate.mjs +6 -0
  27. package/src/config/schema-compile.mjs +6 -4
  28. package/src/config/validate.mjs +83 -1
  29. package/src/context/pack.mjs +53 -3
  30. package/src/doctor/local.mjs +22 -4
  31. package/src/engines/builders/codex.mjs +27 -7
  32. package/src/engines/builders/grok.mjs +11 -5
  33. package/src/engines/codex-home.mjs +18 -15
  34. package/src/engines/sentinel.mjs +11 -0
  35. package/src/gates/detect.mjs +6 -3
  36. package/src/gates/run.mjs +295 -27
  37. package/src/ledger/export.mjs +1 -1
  38. package/src/ledger/report.mjs +143 -5
  39. package/src/outcome/outcome.mjs +108 -0
  40. package/src/pipeline/pipeline.mjs +112 -0
  41. package/src/proof/red-green.mjs +41 -13
  42. package/src/proof/runners.mjs +28 -4
  43. package/src/proof/worktree.mjs +25 -15
  44. package/src/review/agent-waiver.mjs +18 -4
  45. package/src/review/engine.mjs +25 -5
  46. package/src/review/gate-check.mjs +39 -1
  47. package/src/review/lenses/full.md +1 -0
  48. package/src/review/lenses/quick.md +1 -0
  49. package/src/review/packet.mjs +34 -6
  50. package/src/review/related.mjs +112 -0
  51. package/src/review/reopen.mjs +4 -3
  52. package/src/review/session-retry.mjs +59 -0
  53. package/src/session/facts.mjs +18 -4
  54. package/src/session/spawn.mjs +54 -3
  55. package/src/state/block.mjs +101 -34
  56. package/src/state/config-snapshot.mjs +13 -3
  57. package/src/state/run.mjs +3 -2
  58. package/src/util/exec.mjs +48 -31
  59. package/src/util/reaper.mjs +238 -8
  60. package/src/worker/engine.mjs +7 -3
  61. package/src/worker/loop.mjs +67 -14
  62. package/src/worker/queue.mjs +106 -23
  63. package/src/worker/watch.mjs +2 -2
@@ -24,9 +24,10 @@ import { isAncestorArgv, revParseArgv } from '../util/git.mjs';
24
24
  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
- import { canonicalJSON, verifyRow, loadKey } from './signer.mjs';
27
+ import { canonicalJSON, verifyRow, loadKey, signRow } from './signer.mjs';
28
+ import { readAllRows } from '../ledger/write.mjs';
28
29
  import * as reviewQueue from '../worker/queue.mjs';
29
- import { repoRootOf } from '../worker/ticket.mjs';
30
+ import { contentHash, repoRootOf } from '../worker/ticket.mjs';
30
31
 
31
32
  /** @typedef {import('./run.mjs').WriteRow} WriteRow */
32
33
 
@@ -362,6 +363,11 @@ function refuseConfigOwner(owned, kinds, guarded) {
362
363
  * B60: `tests` (`block open --tests <paths…>`, the same path rules as owned entries, else
363
364
  * `bad-tests`) is recorded as the entry's `test_files` and on the dispatch row; a gate argv item
364
365
  * `{test_files}` expands to them (`gates/expand.mjs`). Left out, neither carries the key.
366
+ * B148: `after` lists dependencies in this run. Under the same run lock, every dependency must
367
+ * be closed (`after-not-done`); self-dependency is `bad-after`. Unique ids are recorded on the
368
+ * entry and dispatch only when supplied, and a later attempt checks only its own supplied list.
369
+ * B153: `after_outcome` carries the specialist step ids and outcome byte hashes accepted by
370
+ * the CLI against the run's project root; it is recorded on the entry and dispatch when supplied.
365
371
  * B62a: `worktree` (given by `block open` under `proof.isolation: worktree`, `proof/worktree.mjs`;
366
372
  * this module never imports it) plans the block's own worktree, or the reuse of the one an earlier
367
373
  * entry of the same id recorded (`previous`); its `make()` makes or reuses it. Both are called
@@ -376,9 +382,10 @@ function refuseConfigOwner(owned, kinds, guarded) {
376
382
  * @param {{
377
383
  * runId: string, id: string, level: string, owned: string[], acceptance: unknown,
378
384
  * attempt?: number, base?: string, lines?: number, brief?: {path: string, content: Buffer | string},
379
- * kind?: string, cfg?: Record<string, any>, writeRow: WriteRow, now?: Date, tests?: string[],
385
+ * kind?: string, cfg?: Record<string, any>, writeRow: WriteRow, now?: Date, tests?: string[], after?: string[],
386
+ * after_outcome?: {step: string, sha256: string}[],
380
387
  * decision?: {question: 'lane', answer: string, source: 'rules', facts: Record<string, unknown>}, decisionId?: string,
381
- * worktree?: (ctx: {baseSha: string, previous: TreeRecord | null}) => Promise<{path: string, make: () => Promise<OpenTree>}>,
388
+ * worktree?: (ctx: {baseSha: string, baseExplicit: boolean, previous: TreeRecord | null}) => Promise<{path: string, make: () => Promise<OpenTree>}>,
382
389
  * }} opts
383
390
  * @returns {Promise<{block: Record<string, any>, pointer: string | null, worktreeReused: boolean}>} `worktreeReused`:
384
391
  * the block took an earlier attempt's worktree as it was.
@@ -395,6 +402,7 @@ export async function openBlock(opts) {
395
402
  const acceptance = assertAcceptance(opts.acceptance);
396
403
  const humanChecks = acceptance.filter((c) => c.kind === 'human_check').map((c) => c.clause);
397
404
  const tests = opts.tests === undefined ? null : [...assertOwned(opts.tests, { label: 'test files', code: 'bad-tests' })];
405
+ const after = opts.after === undefined ? null : [...new Set(opts.after)];
398
406
  const kind = opts.kind === 'light' ? 'light' : blockKind({ owned, declared: opts.kind });
399
407
  const dispatched = dispatchLevel({ lane: level, kind, cfg: opts.cfg });
400
408
  const pointer = opts.brief ? briefPointer(opts.brief.path, opts.brief.content) : null;
@@ -423,6 +431,14 @@ export async function openBlock(opts) {
423
431
  const { block, reused } = await mutateRun(runId, null, writeRow, async (record) => {
424
432
  if (record.blocks[id]?.status === 'open') throw new StateError('already-open', `block ${id} is already open`);
425
433
  if (record.blocks[id]?.status === 'awaiting_human_check') throw new StateError('already-open', `block ${id} is awaiting a human check`);
434
+ if (after !== null) {
435
+ if (after.includes(id)) throw new StateError('bad-after', `block ${id} cannot name itself in --after`);
436
+ const blocking = after.filter((dependency) => record.blocks[dependency]?.status !== 'closed');
437
+ if (blocking.length > 0) {
438
+ const statuses = blocking.map((dependency) => `${dependency} (${record.blocks[dependency]?.status ?? 'not in run'})`);
439
+ throw new StateError('after-not-done', `block ${id}: --after dependencies not closed: ${statuses.join(', ')}`);
440
+ }
441
+ }
426
442
  // B57: what each exact owned entry is on disk, under the lock (as `block claim`) — a regular
427
443
  // file owns only itself, here and in every later check
428
444
  const kinds = ownedKinds(record.workspace, owned);
@@ -433,7 +449,7 @@ export async function openBlock(opts) {
433
449
  const branch = await git(['git', 'symbolic-ref', '--quiet', '--short', 'HEAD'], record.workspace, [0, 1]);
434
450
  const baseBranch = branch.code === 0 ? branch.stdout.trim() : null;
435
451
  // B62a: the tree is planned (read-only checks) before the overlap check, which needs its path
436
- const planned = opts.worktree ? await opts.worktree({ baseSha, previous: prior }) : null;
452
+ const planned = opts.worktree ? await opts.worktree({ baseSha, baseExplicit: opts.base !== undefined, previous: prior }) : null;
437
453
  const self = { owned_files: owned, owned_kinds: kinds, worktree: planned ? { path: planned.path } : null };
438
454
  for (const [otherId, other] of Object.entries(activeBlocks(record))) {
439
455
  const pair = blocksOverlap(self, other);
@@ -443,8 +459,10 @@ export async function openBlock(opts) {
443
459
  // passed; a tree made here is rolled back below if the rows or the save then fail
444
460
  made = planned ? await planned.make() : null;
445
461
  const tree = made;
446
- 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 } : {}) };
462
+ const outcomeDependencies = opts.after_outcome === undefined ? {} : { after_outcome: opts.after_outcome };
463
+ 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 } : {}), ...(after !== null ? { after } : {}), ...outcomeDependencies, ...(tree ? { worktree: tree.worktree } : {}) };
447
464
  if (humanChecks.length > 0) Object.assign(entry, { human_checks: humanChecks });
465
+ if (opts.brief) Object.assign(entry, { brief: { path: opts.brief.path } });
448
466
  record.blocks[id] = entry;
449
467
  const floor = dispatched.trigger === 'docs_floor' ? { requested_level: level, trigger: 'docs_floor' } : {};
450
468
  const created = tree?.created ? [{ event: 'worktree.created', block: id, path: tree.worktree.path, branch: tree.worktree.branch, base: tree.worktree.base_sha }] : [];
@@ -453,7 +471,7 @@ export async function openBlock(opts) {
453
471
  rows: [
454
472
  ...(opts.decision ? [{ ...opts.decision, event: 'decision', decision_id: opts.decisionId ?? randomUUID(), block: id }] : []),
455
473
  ...created,
456
- { 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 } : {}) },
474
+ { 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 } : {}), ...(after !== null ? { after } : {}), ...outcomeDependencies },
457
475
  ],
458
476
  };
459
477
  });
@@ -486,18 +504,19 @@ export async function attemptBlock({ runId, id, writeRow }) {
486
504
  * in between touched a file this block owns (that is a conflict ⇒ stop). Rename detection is OFF
487
505
  * (`--no-renames`): a rename lists both its old and its new path, so moving an owned file away
488
506
  * is a conflict too. Never silent. Owned means `ownsFile` (B57: a directory entry owns the files
489
- * below it). B62a: refused for an open block with its own worktree (`worktree-rebase`).
490
- * B134: a closed worktree block instead rebases its branch onto its recorded base branch,
491
- * retaining every changed owned file's blob; a conflict or changed blob restores the old head.
492
- * @param {{runId: string, id: string, head?: string, writeRow: WriteRow}} opts
493
- * @returns {Promise<{old_base: string, new_base: string}>}
507
+ * below it). Worktree blocks rebase their branch onto the recorded base branch. Open blocks
508
+ * refuse upstream commits touching owned files; closed blocks retain every changed owned blob.
509
+ * A refusal restores the head at entry. A rebase check keeps the candidate content available
510
+ * to the worker, with a signed checkpoint; the recorded base moves only after clearance.
511
+ * @param {{runId: string, id: string, head?: string, writeRow: WriteRow, acceptChanged?: string[], reason?: string, rebaseCheck?: boolean}} opts
512
+ * @returns {Promise<{old_base: string, new_base: string, files?: ReturnType<typeof reviewQueue.enqueue>[], pending?: boolean}>}
494
513
  */
495
- export async function rebaseBlock({ runId, id, head = 'HEAD', writeRow }) {
496
- if (['closed', 'awaiting_human_check'].includes((await readRun(runId)).blocks[id]?.status)) return rebaseClosedBlock({ runId, id, writeRow });
514
+ export async function rebaseBlock({ runId, id, head = 'HEAD', writeRow, acceptChanged = [], reason, rebaseCheck = false }) {
515
+ if (acceptChanged.length && (typeof reason !== 'string' || !reason.trim())) throw new StateError('usage', '--accept-changed needs a nonblank --reason');
516
+ const block = (await readRun(runId)).blocks[id];
517
+ if (block?.worktree || ['closed', 'awaiting_human_check'].includes(block?.status)) return rebaseWorktreeBlock({ runId, id, writeRow, acceptChanged, reason, rebaseCheck });
518
+ if (rebaseCheck || acceptChanged.length) throw new StateError('usage', 'rebase clearance requires a worktree block');
497
519
  return mutateRun(runId, id, writeRow, async (record, block) => {
498
- // B62a: a worktree block's base is the commit its tree started from; moving it would leave the
499
- // tree on its old base, so the rebase is refused
500
- if (block.worktree) throw new StateError('worktree-rebase', `block ${id} has its own worktree ${block.worktree.path} on branch ${block.worktree.branch}; its base is the base of that tree, so block rebase does not apply — work on the branch in the tree instead`);
501
520
  const cwd = record.workspace;
502
521
  const newBase = await resolveCommit(head, cwd);
503
522
  const ancestor = await git(isAncestorArgv(block.base_sha, newBase), cwd, [0, 1]);
@@ -507,7 +526,7 @@ export async function rebaseBlock({ runId, id, head = 'HEAD', writeRow }) {
507
526
  if (conflicts.length > 0) throw new StateError('owned-conflict', `block ${id}: commits since its base changed owned files: ${conflicts.join(', ')}`);
508
527
  const moved = { old_base: block.base_sha, new_base: newBase };
509
528
  block.base_sha = newBase;
510
- return { result: moved, rows: [{ event: 'block.rebase', block: id, ...moved }] };
529
+ return { result: moved, rows: [{ event: 'block.rebase', block: id, ...moved, changed_reviewed: [], cleared: [] }] };
511
530
  });
512
531
  }
513
532
 
@@ -530,44 +549,91 @@ async function committedBlobs(cwd, ref, files) {
530
549
  }
531
550
 
532
551
  /**
533
- * Closed blocks have a separate mutation path: check their status again under the lock,
534
- * restore the branch on failure (including a failed signed write), and retain closed/awaiting status.
535
- * @param {{runId: string, id: string, writeRow: WriteRow}} opts
536
- * @returns {Promise<{old_base: string, new_base: string}>}
552
+ * Recheck worktree block status under the lock, restore the branch on failure (including a
553
+ * failed signed write), and retain its open/closed/awaiting status.
554
+ * @param {{runId: string, id: string, writeRow: WriteRow, acceptChanged: string[], reason?: string, rebaseCheck: boolean}} opts
555
+ * @returns {Promise<{old_base: string, new_base: string, files?: ReturnType<typeof reviewQueue.enqueue>[], pending?: boolean}>}
537
556
  */
538
- async function rebaseClosedBlock({ runId, id, writeRow }) {
557
+ async function rebaseWorktreeBlock({ runId, id, writeRow, acceptChanged, reason, rebaseCheck }) {
539
558
  return withRunLock(runId, async () => {
540
559
  const record = await readRun(runId);
541
560
  if (record.status !== 'active') throw new StateError('run-ended', `run ${runId} has ended`);
542
561
  const block = record.blocks[id];
543
- if (!['closed', 'awaiting_human_check'].includes(block?.status) || !block.worktree) throw new StateError('no-block', `block ${id} is not open in run ${runId}`);
562
+ if (!['open', 'closed', 'awaiting_human_check'].includes(block?.status) || !block.worktree) throw new StateError('no-block', `block ${id} is not open in run ${runId}`);
563
+ const closed = block.status !== 'open';
544
564
  if (!block.base_branch) throw new StateError('rebase-no-base', `block ${id} has no recorded base branch`);
545
565
  const tree = block.worktree.path;
546
566
  const branch = await git(['git', 'symbolic-ref', '--quiet', 'HEAD'], tree, [0, 1]);
547
567
  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}`);
548
568
  // A reset must never discard edits made after close, or abort an earlier rebase.
549
569
  const status = await git(['git', 'status', '--porcelain'], tree);
550
- if (status.stdout.length > 0) throw new StateError('rebase-conflict', 'rebase-conflict');
570
+ if (status.stdout.length > 0) throw new StateError(closed ? 'rebase-conflict' : 'rebase-dirty', closed ? 'rebase-conflict' : `rebase-dirty: block ${id}: commit or clean first`);
551
571
  const oldHead = await resolveCommit('HEAD', tree);
572
+ const key = await loadKey(runId);
573
+ const pending = block.rebase_check;
574
+ if (pending && (!verifyRow(pending, key).ok || pending.run !== runId || pending.block !== id || pending.old_base !== block.base_sha)) {
575
+ throw new StateError('rebase-check-invalid', 'the pending rebase checkpoint is invalid');
576
+ }
577
+ if (pending && (await git(isAncestorArgv(pending.head, oldHead), tree, [0, 1])).code !== 0) {
578
+ throw new StateError('rebase-check-invalid', 'the worktree no longer contains the pending rebase');
579
+ }
580
+ const reviewedHead = pending?.old_head ?? oldHead;
552
581
  const newBase = await resolveCommit(`refs/heads/${block.base_branch}`, record.workspace);
553
582
  const top = await repoTop(record.workspace);
554
583
  const prefix = path.relative(top, realpathSync(record.workspace)).split(path.sep).join('/');
555
- const diff = await git(['git', 'diff', '--no-renames', '--name-only', '-z', block.base_sha, oldHead, '--'], tree);
556
- const files = diff.stdout.split('\0').filter((file) => file.length > 0 && (
584
+ const owns = (/** @type {string} */ file) => (
557
585
  ownsFile(block.owned_files, file, block.owned_kinds) ||
558
586
  (prefix !== '' && file.startsWith(`${prefix}/`) && ownsFile(block.owned_files, file.slice(prefix.length + 1), block.owned_kinds))
559
- )).sort();
560
- const before = await committedBlobs(tree, oldHead, files);
587
+ );
588
+ let files = [];
589
+ if (closed) {
590
+ const diff = await git(['git', 'diff', '--no-renames', '--name-only', '-z', block.base_sha, reviewedHead, '--'], tree);
591
+ files = diff.stdout.split('\0').filter((file) => file.length > 0 && owns(file)).sort();
592
+ } else {
593
+ // Inspect every upstream commit, including changes later reverted and both rename paths.
594
+ const upstream = await git(['git', 'log', '--format=', '--no-renames', '--name-only', '-z', '-m', `${block.base_sha}..${newBase}`, '--'], tree);
595
+ const conflicts = [...new Set(upstream.stdout.split('\0').filter((file) => file.length > 0 && owns(file)))].sort();
596
+ if (conflicts.length > 0) throw new StateError('owned-conflict', `block ${id}: commits since its base changed owned files: ${conflicts.join(', ')}`);
597
+ }
598
+ const before = closed ? await committedBlobs(tree, reviewedHead, files) : null;
561
599
  const moved = { old_base: block.base_sha, new_base: newBase };
562
600
  try {
563
601
  const rebased = await exec(['git', '-C', tree, 'rebase', newBase], { cwd: record.workspace, env: gitEnv(), timeoutMs: 30000 });
564
602
  if (rebased.result !== 'ok') throw new StateError('rebase-conflict', 'rebase-conflict');
565
- const after = await committedBlobs(tree, 'HEAD', files);
566
- const changed = files.filter((file) => before.get(file) !== after.get(file));
567
- if (changed.length > 0) throw new StateError('rebase-changed-reviewed', `rebase-changed-reviewed ${changed.join(', ')}`);
603
+ const after = before === null ? null : await committedBlobs(tree, 'HEAD', files);
604
+ const changed = after === null ? [] : files.filter((file) => before.get(file) !== after.get(file));
605
+ const repoRoot = await repoRootOf(record.workspace);
606
+ if (rebaseCheck) {
607
+ // The worker reviews the authoritative worktree, so retain this candidate while leaving
608
+ // both bases untouched. Sign the original head so a later retry cannot forget changes.
609
+ block.rebase_check = signRow({ event: 'block.rebase_check', run: runId, block: id, ...moved, old_head: reviewedHead, head: await resolveCommit('HEAD', tree), started_at: pending?.started_at ?? new Date().toISOString() }, key);
610
+ const queued = changed.map((file) => reviewQueue.enqueue({ repoRoot, run: runId, block: id, file, tree, after: block.rebase_check.started_at }));
611
+ await saveRun(record);
612
+ return { ...moved, files: queued, pending: true };
613
+ }
614
+ const invalidAccepted = acceptChanged.filter((file) => !changed.includes(file));
615
+ if (invalidAccepted.length) throw new StateError('usage', `--accept-changed names files not changed by this rebase: ${invalidAccepted.join(', ')}`);
616
+ const rows = pending ? (await readAllRows(record.project)).filter((row) => row.run === runId && row.block === id && verifyRow(row, key).ok && Date.parse(row.ts) >= Date.parse(pending.started_at)) : [];
617
+ /** @type {Array<{file: string, via: string, ticket?: string, reason?: string}>} */
618
+ const cleared = [];
619
+ for (const file of changed) {
620
+ if (acceptChanged.includes(file)) {
621
+ cleared.push({ file, via: 'human', reason });
622
+ continue;
623
+ }
624
+ const hash = contentHash(tree, file);
625
+ const ticket = reviewQueue.resolveTicket({ repoRoot, run: runId, block: id, file, tree, after: pending?.started_at }).ticket;
626
+ const relevant = rows.filter((row) => row.file === file && row.content_hash === hash);
627
+ const approval = relevant.reduce((last, row, index) => row.event === 'review.approved' && (row.ticket === undefined || row.ticket === ticket) ? index : last, -1);
628
+ const denied = relevant.slice(approval + 1).some((row) => row.event === 'review.cap' || (row.event === 'review.result' && row.approved === false) || (row.event === 'review.round' && row.open_after > 0));
629
+ if (approval >= 0 && !denied) cleared.push({ file, via: 'review', ticket });
630
+ }
631
+ const uncleared = changed.filter((file) => !cleared.some((entry) => entry.file === file));
632
+ if (uncleared.length) throw new StateError('rebase-changed-reviewed', `rebase-changed-reviewed ${uncleared.join(', ')}\nRe-review: code-forge review-file --rebase-check --block ${id} --run ${runId}\nHuman accept (repeat --accept-changed for each file): code-forge block rebase ${id} --run ${runId} --accept-changed <file> --reason <text>`);
568
633
  block.base_sha = newBase;
569
634
  block.worktree.base_sha = newBase;
570
- await writeSigned(runId, writeRow, { event: 'block.rebase', block: id, ...moved, closed: true });
635
+ delete block.rebase_check;
636
+ await writeSigned(runId, writeRow, { event: 'block.rebase', block: id, ...moved, changed_reviewed: changed, cleared, ...(closed ? { closed: true } : { worktree: true }) });
571
637
  await saveRun(record);
572
638
  return moved;
573
639
  } catch (err) {
@@ -679,9 +745,10 @@ export async function closeBlock({ runId, id, rows, writeRow, livePid, probe, ex
679
745
 
680
746
  let pin = await checkWorkerPin(record, { livePid, probe });
681
747
  const pins = record.workers ?? (record.worker ? [record.worker] : []);
748
+ const reviewPins = [...pins, ...(record.worker_history ?? [])];
682
749
  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'));
683
750
  // Ledgers written before B107 omit worker identity and use the run's pin check above.
684
- if (counted.some((row) => row.worker !== undefined && !pins.some((p) => p.pid === row.worker?.pid && p.started_at === row.worker?.started_at))) {
751
+ if (counted.some((row) => row.worker !== undefined && !reviewPins.some((p) => p.pid === row.worker?.pid && p.started_at === row.worker?.started_at))) {
685
752
  pin = { ok: false, event: 'worker.replaced', reason: 'a counted review result does not name a pinned worker' };
686
753
  }
687
754
  if (pin.ok) {
@@ -45,7 +45,7 @@ export const IMMUTABLE_KEYS = Object.freeze({
45
45
  // B64b: the block-end hook runs a command on the owner's machine at every block end, so only
46
46
  // the owner sets it, before the run starts
47
47
  'hooks.on_block_end': 'it runs a command on the owner\'s machine at every block end; fixed by the owner before the run starts',
48
- // B87: the runner argv proves the coder's own work, so only the owner sets it, before the run starts
48
+ // B171: existing runner entries stay fixed; immutableChanges permits append-only additions.
49
49
  'proof.runners': 'the commands that prove a block are fixed by the owner before the run starts',
50
50
  // B93: the coder sandbox profile widens what the coder may write, so only the owner sets it,
51
51
  // before the run starts (a coder can never own `.code-forge.yml`)
@@ -138,7 +138,17 @@ function valueAt(cfg, dotted) {
138
138
  * @returns {string[]}
139
139
  */
140
140
  export function immutableChanges(before, after) {
141
- return Object.keys(IMMUTABLE_KEYS).filter((k) => canonicalJSON(valueAt(before, k) ?? null) !== canonicalJSON(valueAt(after, k) ?? null));
141
+ return Object.keys(IMMUTABLE_KEYS).filter((k) => {
142
+ const oldValue = valueAt(before, k);
143
+ const newValue = valueAt(after, k);
144
+ // Additions go at the end: prepending could replace the first match of an existing runner.
145
+ if (k === 'proof.runners' && Array.isArray(newValue)) {
146
+ const oldRunners = oldValue ?? [];
147
+ if (Array.isArray(oldRunners) && newValue.length >= oldRunners.length
148
+ && oldRunners.every((entry, i) => canonicalJSON(entry) === canonicalJSON(newValue[i]))) return false;
149
+ }
150
+ return canonicalJSON(oldValue ?? null) !== canonicalJSON(newValue ?? null);
151
+ });
142
152
  }
143
153
 
144
154
  /**
@@ -243,7 +253,7 @@ export function blockEndHook(record) {
243
253
  }
244
254
 
245
255
  /**
246
- * B87: `proof.runners` of the run-start config snapshot (never the live config), as a deep copy
256
+ * B87: `proof.runners` of the snapshot in force (never the live config), as a deep copy
247
257
  * (entries and argv arrays), or null when the key is absent or the record is from before
248
258
  * snapshots. `proof/runners.mjs` checks each entry again before it runs one (fail closed).
249
259
  * @param {Record<string, any>} record
package/src/state/run.mjs CHANGED
@@ -275,8 +275,7 @@ export async function reloadRun({ runId, config, readPending = () => [], effecti
275
275
  if (config.context?.require_coder_tests === true) frozen.push('context.require_coder_tests');
276
276
  // B64b: and never had a block-end hook
277
277
  if (config.hooks?.on_block_end !== undefined) frozen.push('hooks.on_block_end');
278
- // B87: and never had a proof runner
279
- if (config.proof?.runners !== undefined) frozen.push('proof.runners');
278
+ // B171: a legacy run has no existing runners; adding its first list is safe.
280
279
  // B93: and never had a coder sandbox profile
281
280
  if (config.coder?.sandbox !== undefined) frozen.push('coder.sandbox');
282
281
  }
@@ -324,6 +323,8 @@ export async function reattachWorker({ runId, workerPid, writeRow, probe = proce
324
323
  if (current.status !== 'active') throw new StateError('run-ended', `run ${runId} has ended`);
325
324
  const pin = await pinWorker(workerPid, probe);
326
325
  const pins = current.workers ?? (current.worker ? [current.worker] : []);
326
+ // Retired pins still authorize completed reviews, but never live-worker checks or signals.
327
+ if (!add) current.worker_history = [...(current.worker_history ?? []), ...pins];
327
328
  current.workers = add ? [...pins, pin] : [pin];
328
329
  current.worker = current.workers[0];
329
330
  await writeSigned(runId, writeRow, { event: add ? 'worker.add' : 'worker.reattach', pid: workerPid });
package/src/util/exec.mjs CHANGED
@@ -8,9 +8,9 @@
8
8
  * indefinitely (callers that spawn a reviewer/coder CLI are expected to always pass one —
9
9
  * this module does not impose a default, since a default here would silently apply to every
10
10
  * caller, including ones with a legitimately long-running command).
11
- * - On timeout the whole process GROUP is sent SIGTERM, then SIGKILL — the SIGKILL goes to the
12
- * group as soon as the direct child exits (or after a grace period), so a grandchild that
13
- * traps SIGTERM cannot outlive the call. The result is `failed`, never a partial success.
11
+ * - On timeout the process group receives SIGTERM, then SIGKILL after a grace period while
12
+ * the leader lives. On every leader exit, remaining members are captured and stopped
13
+ * individually after checking their start times. Timeout always resolves `failed`.
14
14
  * - stdin is `ignore`d by default, so a child that reads stdin when it isn't a TTY (`claude -p`,
15
15
  * for one) sees an immediate EOF instead of blocking forever. With `input` (a string or bytes),
16
16
  * stdin is a pipe: the content is written verbatim and then the pipe is closed, so the child
@@ -50,7 +50,8 @@
50
50
  */
51
51
 
52
52
  import { spawn } from 'node:child_process';
53
- import { registerPid, unregisterPid } from './reaper.mjs';
53
+ import path from 'node:path';
54
+ import { groupMembers, readStartTime, registerPid, stopMembers, unregisterPid } from './reaper.mjs';
54
55
  import { pidsDir } from './tmp.mjs';
55
56
 
56
57
  /** Grace period after SIGTERM before escalating to SIGKILL, in milliseconds. */
@@ -170,16 +171,32 @@ export function exec(argv, opts = {}) {
170
171
  const { cwd, env, timeoutMs, input, okExitCodes = [0], maxBufferBytes = DEFAULT_MAX_BUFFER_BYTES } = opts;
171
172
  const [command, ...args] = argv;
172
173
 
174
+ /** @type {NodeJS.ProcessEnv} */
175
+ const childEnv = { ...(env ?? process.env), CODE_FORGE_OWNER_PID: String(process.pid) };
176
+ const ownerStart = readStartTime(process.pid);
177
+ if (ownerStart) childEnv.CODE_FORGE_OWNER_START_TIME = ownerStart;
178
+ else delete childEnv.CODE_FORGE_OWNER_START_TIME;
179
+ let spawnRegistry = null;
180
+ try {
181
+ spawnRegistry = pidsDir();
182
+ const rootId = path.basename(path.dirname(spawnRegistry));
183
+ const runAt = process.argv.indexOf('--run');
184
+ const inlineRun = process.argv.find((arg) => arg.startsWith('--run='))?.slice('--run='.length);
185
+ const runId = childEnv.CODE_FORGE_RUN_ID ?? process.env.CODE_FORGE_RUN_ID
186
+ ?? (runAt >= 0 ? process.argv[runAt + 1] : inlineRun) ?? (/^r-/.test(rootId) ? rootId : undefined);
187
+ if (runId) childEnv.CODE_FORGE_RUN_ID = runId;
188
+ } catch { /* registry hygiene must never prevent a spawn */ }
189
+
173
190
  return new Promise((resolve) => {
174
191
  const stdinMode = input === undefined ? 'ignore' : 'pipe';
175
- const child = spawn(command, args, { cwd, env, shell: false, detached: true, stdio: [stdinMode, 'pipe', 'pipe'] });
192
+ const child = spawn(command, args, { cwd, env: childEnv, shell: false, detached: true, stdio: [stdinMode, 'pipe', 'pipe'] });
176
193
  const pid = child.pid;
177
194
  /** @type {string | null} */
178
195
  let registryDir = null;
179
196
  if (typeof pid === 'number') {
180
197
  trackChild(pid);
181
198
  try {
182
- registryDir = pidsDir();
199
+ registryDir = spawnRegistry ?? pidsDir();
183
200
  registerPid(registryDir, pid, command);
184
201
  } catch {
185
202
  registryDir = null;
@@ -200,6 +217,7 @@ export function exec(argv, opts = {}) {
200
217
  let timedOut = false;
201
218
  let overBuffer = false;
202
219
  let settled = false;
220
+ let remaining = Promise.resolve();
203
221
  /** @type {NodeJS.Timeout | undefined} */
204
222
  let termTimer;
205
223
  /** @type {NodeJS.Timeout | undefined} */
@@ -216,12 +234,24 @@ export function exec(argv, opts = {}) {
216
234
  const stdoutText = () => Buffer.concat(stdoutChunks).toString(opts.stdoutEncoding ?? 'utf8');
217
235
  const stderrText = () => Buffer.concat(stderrChunks).toString('utf8');
218
236
 
219
- /** @param {ExecResult} result */
220
- const settle = (result) => {
237
+ /** @param {Pick<ExecResult, 'code'|'signal'|'error'>} outcome */
238
+ const settle = async (outcome) => {
221
239
  if (settled) return;
222
240
  settled = true;
223
241
  if (termTimer) clearTimeout(termTimer);
224
242
  if (killTimer) clearTimeout(killTimer);
243
+ await remaining;
244
+ // Pipe data can arrive while the remaining group members are being stopped.
245
+ // Snapshot the output and buffer-limit state only once that wait is over.
246
+ /** @type {ExecResult} */
247
+ const result = {
248
+ ...outcome,
249
+ result: !outcome.error && !timedOut && !overBuffer && outcome.code !== null && okExitCodes.includes(outcome.code) ? 'ok' : 'failed',
250
+ stdout: stdoutText(),
251
+ stderr: stderrText(),
252
+ timedOut,
253
+ ...(overBuffer ? { error: 'exec: maxBufferBytes exceeded' } : {}),
254
+ };
225
255
  if (typeof pid === 'number') {
226
256
  untrackChild(pid);
227
257
  if (registryDir !== null) {
@@ -277,40 +307,27 @@ export function exec(argv, opts = {}) {
277
307
 
278
308
  child.on('error', (err) => {
279
309
  settle({
280
- result: 'failed',
281
310
  code: null,
282
311
  signal: null,
283
- stdout: stdoutText(),
284
- stderr: stderrText(),
285
- timedOut,
286
312
  error: err.message,
287
313
  });
288
314
  });
289
315
 
290
- // Once we've decided to kill (timeout or over-buffer), don't wait for 'close' — a
291
- // grandchild that inherited the pipes can keep them open indefinitely, and 'close' would
292
- // then never fire even though the child we care about is dead. 'exit' fires as soon as the
293
- // child process itself has terminated, which is what "killed after timeout_ms" means. Before
294
- // settling (which cancels the grace timer), SIGKILL the group: a grandchild that trapped the
295
- // SIGTERM must not survive just because its parent died first.
316
+ // Capture the group before unregistering on EVERY leader exit. Never signal the dead
317
+ // leader/group again: each remaining member has its own synchronous identity check.
296
318
  child.on('exit', (code, signal) => {
297
- if (timedOut || overBuffer) {
298
- killGroup('SIGKILL');
299
- settle({
300
- result: 'failed',
301
- code,
302
- signal,
303
- stdout: stdoutText(),
304
- stderr: stderrText(),
305
- timedOut,
306
- ...(overBuffer ? { error: 'exec: maxBufferBytes exceeded' } : {}),
307
- });
319
+ if (termTimer) clearTimeout(termTimer);
320
+ if (killTimer) clearTimeout(killTimer);
321
+ const members = typeof pid === 'number' ? groupMembers(pid) : new Map();
322
+ if (typeof pid === 'number') untrackChild(pid);
323
+ remaining = stopMembers(members, KILL_GRACE_MS).then(() => {});
324
+ if (timedOut || overBuffer || members.size > 0) {
325
+ settle({ code, signal });
308
326
  }
309
327
  });
310
328
 
311
329
  child.on('close', (code, signal) => {
312
- const result = !timedOut && !overBuffer && code !== null && okExitCodes.includes(code) ? 'ok' : 'failed';
313
- settle({ result, code, signal, stdout: stdoutText(), stderr: stderrText(), timedOut });
330
+ settle({ code, signal });
314
331
  });
315
332
  });
316
333
  }