@codedology/code-forge 0.9.0 → 0.11.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.
Files changed (62) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/README.md +49 -9
  3. package/docs/reference/blocks.json +587 -1
  4. package/docs/reference/config.md +36 -0
  5. package/package.json +2 -2
  6. package/schema/code-forge.schema.json +89 -0
  7. package/skill/SKILL.md +1 -1
  8. package/skill/references/adapters/claude-code.md +1 -1
  9. package/skill/references/adapters/solo.md +2 -2
  10. package/skill/references/adapters/subprocess.md +12 -2
  11. package/skill/references/code.md +14 -7
  12. package/skill/references/ledger.md +2 -2
  13. package/skill/references/proof.md +77 -2
  14. package/skill/references/review.md +39 -7
  15. package/skill/references/security.md +4 -4
  16. package/skill/scripts/forge +1 -1
  17. package/skill/templates/coder-brief.md +6 -6
  18. package/src/autopilot/binnacle.mjs +9 -3
  19. package/src/cli/block.mjs +262 -31
  20. package/src/cli/jev.mjs +22 -3
  21. package/src/cli/keys.mjs +156 -21
  22. package/src/cli/proof.mjs +35 -7
  23. package/src/cli/review-file.mjs +17 -11
  24. package/src/cli/spawn.mjs +44 -3
  25. package/src/config/validate.mjs +320 -5
  26. package/src/doctor/index.mjs +2 -1
  27. package/src/doctor/local.mjs +27 -6
  28. package/src/doctor/probes.mjs +38 -0
  29. package/src/engines/builders/codex.mjs +44 -4
  30. package/src/engines/sandbox-dirs.mjs +257 -0
  31. package/src/keys/store.mjs +128 -21
  32. package/src/proof/junit.mjs +530 -0
  33. package/src/proof/red-green.mjs +57 -9
  34. package/src/proof/red-parse.mjs +2 -0
  35. package/src/proof/runners.mjs +472 -0
  36. package/src/proof/xcresult.mjs +285 -0
  37. package/src/review/engine.mjs +141 -42
  38. package/src/review/fixloop.mjs +75 -23
  39. package/src/review/gate-check.mjs +3 -2
  40. package/src/review/lenses/A.md +2 -0
  41. package/src/review/lenses/B.md +2 -0
  42. package/src/review/lenses/full.md +2 -0
  43. package/src/review/lenses/quick.md +1 -0
  44. package/src/review/lenses/recheck.md +2 -0
  45. package/src/review/packet.mjs +48 -14
  46. package/src/review/paired.mjs +80 -0
  47. package/src/review/reopen.mjs +4 -6
  48. package/src/review/reviewed-lines.mjs +94 -0
  49. package/src/review/route.mjs +232 -0
  50. package/src/review/schema-fallback.mjs +77 -22
  51. package/src/review/session-retry.mjs +44 -9
  52. package/src/review/test-quality.mjs +201 -0
  53. package/src/review/verb-forms.mjs +39 -0
  54. package/src/session/spawn.mjs +39 -2
  55. package/src/state/block-end.mjs +10 -6
  56. package/src/state/block.mjs +13 -3
  57. package/src/state/config-snapshot.mjs +39 -0
  58. package/src/state/run.mjs +4 -0
  59. package/src/util/forbidden.mjs +2 -2
  60. package/src/worker/engine.mjs +43 -15
  61. package/src/worker/loop.mjs +98 -15
  62. package/src/worker/ticket.mjs +9 -1
@@ -42,7 +42,7 @@
42
42
  * second `schema` miss — this round's first try after the earlier ticket's — is tried once on
43
43
  * `review.second_levels.<level>` when one is configured.
44
44
  * B56: every round gets ONE memoised reader of the block's OTHER changed files' diffs
45
- * (`blockPeerDiffs`: listed and read at most once per ticket) so the review can name code moved
45
+ * (the ticket's `fileContext`: listed once, diffs read lazily at most once per ticket) so the review can name code moved
46
46
  * between them in a `## moved code` packet section (round 1, rechecks and patch check alike).
47
47
  * The review NEVER approves an empty or failed session: that is `unavailable` (§4.2 stub guard).
48
48
  * A throw inside the round is `unavailable` too, with the state saved as `next: retry` (see
@@ -169,14 +169,14 @@ import { assertTicketId, contentHash, readRegularFileNoFollow, WorkerError } fro
169
169
  * `tree` (B62b) is the recorded `worktree` `{path, branch, base_sha}`, null when the block has
170
170
  * none; `base` is then the tree's `base_sha`. A recorded worktree that `worktreeRecord` refuses
171
171
  * (path, branch, hex base_sha) throws `worktree-malformed`.
172
- * @returns {Promise<{base: string | null, level: string, owned: string[], ownedKinds: Record<string, string>, acceptance?: unknown, kind: string | null, tree: WorktreeEntry | null, workspace: string | null}>}
172
+ * @returns {Promise<{base: string | null, level: string, owned: string[], ownedKinds: Record<string, string>, testFiles: string[], acceptance?: unknown, kind: string | null, tree: WorktreeEntry | null, workspace: string | null}>}
173
173
  */
174
174
  export async function blockEntryFor(runId, block) {
175
175
  let record;
176
176
  try {
177
177
  record = await readRun(runId);
178
178
  } catch (err) {
179
- if (/** @type {any} */ (err)?.code === 'no-run') return { base: null, level: 'L2', owned: [], ownedKinds: {}, acceptance: undefined, kind: null, tree: null, workspace: null };
179
+ if (/** @type {any} */ (err)?.code === 'no-run') return { base: null, level: 'L2', owned: [], ownedKinds: {}, testFiles: [], acceptance: undefined, kind: null, tree: null, workspace: null };
180
180
  throw new Error('run-record-unreadable');
181
181
  }
182
182
  if (!record || typeof record !== 'object' || !record.blocks || typeof record.blocks !== 'object') throw new Error('run-record-unreadable');
@@ -187,6 +187,7 @@ export async function blockEntryFor(runId, block) {
187
187
  const owned = Array.isArray(entry.owned_files) ? entry.owned_files.filter((f) => typeof f === 'string') : [];
188
188
  const kinds = entry.owned_kinds;
189
189
  const ownedKinds = kinds && typeof kinds === 'object' && !Array.isArray(kinds) ? kinds : {};
190
+ const testFiles = Array.isArray(entry.test_files) ? entry.test_files.filter((f) => typeof f === 'string' && !/[*?\[\]{}]/.test(f) && ownsFile(owned, f, ownedKinds)) : [];
190
191
  /** @type {WorktreeEntry | null} */
191
192
  let tree = null;
192
193
  if (entry.worktree !== undefined && entry.worktree !== null) {
@@ -196,10 +197,11 @@ export async function blockEntryFor(runId, block) {
196
197
  throw new Error('worktree-malformed');
197
198
  }
198
199
  }
199
- return { base: tree ? tree.base_sha : sha, level: typeof entry.level === 'string' && /^L[0-3]$/.test(entry.level) ? entry.level : 'L2', owned, ownedKinds, acceptance: entry.acceptance, kind: blockKind({ owned, declared: entry.kind }), tree, workspace: typeof record.workspace === 'string' ? record.workspace : null };
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 };
200
201
  }
201
202
 
202
203
  /** @typedef {{path: string, branch: string, base_sha: string}} WorktreeEntry */
204
+ /** @typedef {{peers: string[], changedFiles: string[], ownedFiles: string[], testFiles: string[]}} FileContext */
203
205
 
204
206
  /** The refusals of {@link blockTree} a ticket answers with, as its `reason`. */
205
207
  const TREE_CODES = new Set(['worktree-malformed', 'worktree-missing', 'worktree-not-dir', 'worktree-branch-mismatch', 'worktree-workspace-missing', 'worktree-foreign', 'git-failed']);
@@ -373,7 +375,28 @@ export async function reviewTicket(ticket, ctx) {
373
375
  }
374
376
  }
375
377
  const factsExcerpt = factsFor(entry, ctx.cfg);
376
- const peerDiffs = blockPeerDiffs(contentRoot, { base: entry.base, owned: entry.owned, ownedKinds: entry.ownedKinds, file: ticket.file });
378
+ // Resolve concrete paths in this block's tree; neither globs nor another block's tests
379
+ // authorize an untracked attachment. Read lazily so a stopped ticket needs no file listing.
380
+ /** @type {Promise<FileContext> | undefined} */
381
+ let contextOnce;
382
+ /** @type {() => Promise<import('../review/moved.mjs').PeerDiff[]>} */
383
+ let readDiffs = () => Promise.resolve([]);
384
+ const fileContext = () => contextOnce ??= (async () => {
385
+ try {
386
+ // Read the listing directly: blockPeers' [] fallback would hide a git failure and
387
+ // still authorize explicit tests. Context and peer diffs must fail soft together.
388
+ const peers = entry.base === null || entry.owned.length === 0 ? [] : (await computeFileSet({ cwd: contentRoot, base: entry.base })).all.filter((f) => f !== ticket.file && ownsFile(entry.owned, f, entry.ownedKinds));
389
+ readDiffs = peerDiffReader({ repoRoot: contentRoot, base: entry.base, file: ticket.file, peers });
390
+ const changedFiles = [...peers, ticket.file].filter((f) => ownsFile(entry.owned, f, entry.ownedKinds));
391
+ return { peers, changedFiles, ownedFiles: [...new Set([...changedFiles, ...entry.testFiles])], testFiles: entry.testFiles };
392
+ } catch {
393
+ return { peers: [], changedFiles: [], ownedFiles: [], testFiles: [] };
394
+ }
395
+ })();
396
+ const peerDiffs = async () => {
397
+ await fileContext();
398
+ return readDiffs();
399
+ };
377
400
  try {
378
401
  // B63a: the `context` rules and lessons, read from the MAIN tree (`ctx.repoRoot`, never the
379
402
  // block's worktree) with the config in force for this ticket; '' without a `context` key, so
@@ -390,11 +413,11 @@ export async function reviewTicket(ticket, ctx) {
390
413
  const digest = rulesDigest === '' ? {} : { rulesDigest };
391
414
  if (!ctx.key) {
392
415
  return await reviewFile(
393
- { repoRoot: contentRoot, file: ticket.file, base: entry.base, cfg: ctx.cfg, kind: entry.kind, workDir, factsExcerpt, ...digest, peerDiffs },
416
+ { repoRoot: contentRoot, file: ticket.file, base: entry.base, cfg: ctx.cfg, kind: entry.kind, workDir, factsExcerpt, ...digest, peerDiffs, ...(await fileContext()) },
394
417
  { spawn: ctx.spawn, ...(ctx.writeRow ? { writeRow: ctx.writeRow } : {}) },
395
418
  );
396
419
  }
397
- return await fixLoopRound(ticket, tctx, { base: entry.base, level: entry.level, owned: entry.owned, ownedKinds: entry.ownedKinds, blockKindOf: entry.kind, key: ctx.key, workDir, factsExcerpt, rulesDigest, peerDiffs });
420
+ return await fixLoopRound(ticket, tctx, { base: entry.base, level: entry.level, owned: entry.owned, ownedKinds: entry.ownedKinds, blockKindOf: entry.kind, key: ctx.key, workDir, factsExcerpt, rulesDigest, peerDiffs, fileContext });
398
421
  } finally {
399
422
  rmSync(workDir, { recursive: true, force: true });
400
423
  }
@@ -506,10 +529,10 @@ export function mayTakeRung(state, kind, cfg, rungUsed) {
506
529
  * first full round, or any round once the rung is gone) runs outside the block lock, in parallel
507
530
  * with the block's other files.
508
531
  * @param {import('./queue.mjs').Ticket} ticket @param {ReviewContext} ctx
509
- * @param {{base: string | null, level: string, owned: string[], ownedKinds?: Record<string, string>, blockKindOf: string, key: Buffer, workDir: string, factsExcerpt: string, rulesDigest?: string, peerDiffs?: () => Promise<import('../review/moved.mjs').PeerDiff[]>}} opts
532
+ * @param {{base: string | null, level: string, owned: string[], ownedKinds?: Record<string, string>, blockKindOf: string, key: Buffer, workDir: string, factsExcerpt: string, rulesDigest?: string, peerDiffs?: () => Promise<import('../review/moved.mjs').PeerDiff[]>, fileContext: () => Promise<FileContext>}} opts
510
533
  * @returns {Promise<ReviewOutcome>}
511
534
  */
512
- async function fixLoopRound(ticket, ctx, { base, level, owned, ownedKinds, blockKindOf, key, workDir, factsExcerpt, rulesDigest = '', peerDiffs }) {
535
+ async function fixLoopRound(ticket, ctx, { base, level, owned, ownedKinds, blockKindOf, key, workDir, factsExcerpt, rulesDigest = '', peerDiffs, fileContext }) {
513
536
  if (!ctx.readRows || !ctx.writeRow) return { status: 'unavailable', reason: 'no-ledger', approved: false, engine: 'adaptive', sessions: [] };
514
537
  const readRows = ctx.readRows;
515
538
  /** @type {{outcome: ReviewOutcome} | {round: () => Promise<ReviewOutcome>}} */
@@ -572,7 +595,7 @@ async function fixLoopRound(ticket, ctx, { base, level, owned, ownedKinds, block
572
595
  state.l3_rung_used = true; // the orchestrator ran the block's one L3 patch before this ticket
573
596
  }
574
597
  const fixed = state;
575
- const round = () => playRound(ticket, ctx, { state: fixed, kind, where, seq, base, blockKindOf, workDir, factsExcerpt, rulesDigest, extraRounds, peerDiffs });
598
+ const round = () => playRound(ticket, ctx, { state: fixed, kind, where, seq, base, blockKindOf, workDir, factsExcerpt, rulesDigest, extraRounds, peerDiffs, fileContext });
576
599
  // a round that could take the rung keeps the block lock until its state is saved
577
600
  if (mayTakeRung(fixed, kind, ctx.cfg, rungUsed)) return { outcome: await round() };
578
601
  return { round };
@@ -583,10 +606,10 @@ async function fixLoopRound(ticket, ctx, { base, level, owned, ownedKinds, block
583
606
  /**
584
607
  * Run the prepared round and save the file's state: the ticket's outcome.
585
608
  * @param {import('./queue.mjs').Ticket} ticket @param {ReviewContext} ctx
586
- * @param {{state: import('../review/fixloop.mjs').FileState, kind: 'full' | 'recheck' | 'patch_check', where: import('./review-state.mjs').Where, seq: number, base: string | null, blockKindOf: string, workDir: string, factsExcerpt: string, rulesDigest?: string, extraRounds?: number, peerDiffs?: () => Promise<import('../review/moved.mjs').PeerDiff[]>}} opts
609
+ * @param {{state: import('../review/fixloop.mjs').FileState, kind: 'full' | 'recheck' | 'patch_check', where: import('./review-state.mjs').Where, seq: number, base: string | null, blockKindOf: string, workDir: string, factsExcerpt: string, rulesDigest?: string, extraRounds?: number, peerDiffs?: () => Promise<import('../review/moved.mjs').PeerDiff[]>, fileContext: () => Promise<FileContext>}} opts
587
610
  * @returns {Promise<ReviewOutcome>}
588
611
  */
589
- async function playRound(ticket, ctx, { state, kind, where, seq, base, blockKindOf, workDir, factsExcerpt, rulesDigest = '', extraRounds = 0, peerDiffs }) {
612
+ async function playRound(ticket, ctx, { state, kind, where, seq, base, blockKindOf, workDir, factsExcerpt, rulesDigest = '', extraRounds = 0, peerDiffs, fileContext }) {
590
613
  /** @type {Record<string, any> | null} */
591
614
  let engineOutcome = null;
592
615
  let approvalWritten = false;
@@ -626,6 +649,7 @@ async function playRound(ticket, ctx, { state, kind, where, seq, base, blockKind
626
649
  const schemaHistory = ctx.readRows
627
650
  ? ledgerSchemaHistory({ readRows: ctx.readRows, verify: (row) => verifyRow(row, where.key).ok, runId: ctx.runId, file: ticket.file, contentHash: ticket.content_hash })
628
651
  : undefined;
652
+ const files = await fileContext();
629
653
  await runRound(
630
654
  state,
631
655
  {
@@ -637,16 +661,20 @@ async function playRound(ticket, ctx, { state, kind, where, seq, base, blockKind
637
661
  factsExcerpt,
638
662
  ...(rulesDigest !== '' ? { rulesDigest } : {}),
639
663
  extraRounds,
664
+ ...files,
640
665
  ...(peerDiffs ? { peerDiffs } : {}),
641
666
  ...(jev ? { jev } : {}),
642
667
  ...(schemaHistory ? { schemaHistory } : {}),
643
668
  review: async () => {
644
- engineOutcome = await reviewFile({ repoRoot: ctx.contentRoot ?? ctx.repoRoot, file: ticket.file, base, cfg: ctx.cfg, kind: blockKindOf, workDir, factsExcerpt, ...(rulesDigest !== '' ? { rulesDigest } : {}), ...(peerDiffs ? { peerDiffs } : {}) }, { spawn: ctx.spawn, writeRow, ...(schemaHistory ? { schemaHistory } : {}) });
669
+ engineOutcome = await reviewFile({ repoRoot: ctx.contentRoot ?? ctx.repoRoot, file: ticket.file, base, cfg: ctx.cfg, kind: blockKindOf, workDir, factsExcerpt, ...files, ...(rulesDigest !== '' ? { rulesDigest } : {}), ...(peerDiffs ? { peerDiffs } : {}) }, { spawn: ctx.spawn, writeRow, ...(schemaHistory ? { schemaHistory } : {}) });
645
670
  return engineOutcome;
646
671
  },
647
672
  spawn: async (opts) => {
648
673
  const res = await ctx.spawn(opts);
649
- rechecks.push({ lens: opts?.rowExtra?.lens ?? null, role: opts?.role ?? null, level: opts?.level ?? null, provider: res?.provider ?? null, model: res?.model ?? null });
674
+ rechecks.push({ lens: opts?.rowExtra?.lens ?? null, role: opts?.role ?? null, level: opts?.level ?? null, provider: res?.provider ?? null, model: res?.model ?? null,
675
+ status: res?.status ?? null, tokens_in: res?.usage?.tokens_in ?? res?.row?.tokens_in ?? null, tokens_out: res?.usage?.tokens_out ?? res?.row?.tokens_out ?? null,
676
+ ...(typeof res?.status === 'string' && res.status !== 'ok' ? { reason: res.reason ?? res.status } : {}),
677
+ });
650
678
  return res;
651
679
  },
652
680
  },
@@ -689,7 +717,7 @@ async function playRound(ticket, ctx, { state, kind, where, seq, base, blockKind
689
717
  return { ...head, status: 'reviewed', approved: true, findings: [] };
690
718
  }
691
719
  if (status === 'stopped') return { ...head, status: 'stopped', stopped: next?.reason ?? 'stopped', ...stopSize(state), approved: false, findings: state.open };
692
- return { ...head, status: 'reviewed', approved: false, reason: 'findings-open', findings: state.open };
720
+ return { ...head, status: 'reviewed', approved: false, reason: next?.reason === 'no-fix-hunks' ? 'no-fix-hunks' : 'findings-open', findings: state.open };
693
721
  }
694
722
 
695
723
  /**
@@ -35,24 +35,28 @@
35
35
  */
36
36
 
37
37
  import { spawn as nodeSpawn } from 'node:child_process';
38
+ import { existsSync, statSync } from 'node:fs';
39
+ import os from 'node:os';
38
40
  import path from 'node:path';
39
41
  import { fileURLToPath } from 'node:url';
40
42
  import { expiryChecks } from '../autopilot/limits.mjs';
41
43
  import { loadProjectConfig } from '../config/load.mjs';
42
- import { createDefaultKeyStore, resolveKey } from '../keys/store.mjs';
44
+ import { cacheHoursFrom, createDefaultKeyStore, resolveKey } from '../keys/store.mjs';
43
45
  import { appendRow, readAllRows } from '../ledger/write.mjs';
44
46
  import { spawnSession } from '../session/spawn.mjs';
45
47
  import { snapshotFor } from '../state/config-snapshot.mjs';
48
+ import { runRecordPath } from '../state/paths.mjs';
46
49
  import { readRun, reattachWorker } from '../state/run.mjs';
47
50
  import { loadKey, signRow } from '../state/signer.mjs';
48
51
  import { logWarning } from '../util/error-log.mjs';
49
52
  import { keyedLock } from '../util/locks.mjs';
50
- import { writeSafe } from '../util/redact.mjs';
53
+ import { redact, writeSafe } from '../util/redact.mjs';
51
54
  import { runRoot, setRunRoot } from '../util/tmp.mjs';
52
55
  import { openFindings } from '../review/gate-check.mjs';
56
+ import { countLines, RouteError, routeTicket } from '../review/route.mjs';
53
57
  import { canonicalPath, contentRootFor, reviewTicket } from './engine.mjs';
54
58
  import { announceWorker, beatWorker, HEARTBEAT_MS, liveWorker, pendingTickets, readTicket, retractWorker, writeResult } from './queue.mjs';
55
- import { contentHash, DELETED_HASH, repoRootOf, WorkerError } from './ticket.mjs';
59
+ import { DELETED_HASH, hashBytes, readRegularFileNoFollow, repoRootOf, WorkerError } from './ticket.mjs';
56
60
 
57
61
  /** Name of the key the worker resolves for System 1 (`keys.jev` / `system1.key` reference). */
58
62
  export const JEV_KEY_NAME = 'jev';
@@ -136,16 +140,37 @@ export function sessionEnv(env, secrets) {
136
140
  /**
137
141
  * Resolve the Jev key through B2's chain (env → store → 1Password). A missing key is not an error:
138
142
  * System 1 then falls back (`system1.fallback`, B3). The value is registered for redaction by B2.
143
+ * A refresh from 1Password is cached for the config's `keys.cache_hours` (B84: the run's snapshot;
144
+ * a snapshot from before the key has none ⇒ 8 h).
139
145
  * @param {Record<string, any>} cfg
140
- * @param {{store: import('../keys/store.mjs').KeyStore, env: NodeJS.ProcessEnv, opRead?: any}} deps
146
+ * @param {{store: import('../keys/store.mjs').KeyStore, env: NodeJS.ProcessEnv, opRead?: any, now?: number}} deps
141
147
  * @returns {Promise<string | null>}
142
148
  */
143
- export async function resolveJevKey(cfg, { store, env, opRead }) {
149
+ export async function resolveJevKey(cfg, { store, env, opRead, now }) {
144
150
  const ref = cfg?.system1?.key ?? cfg?.keys?.[JEV_KEY_NAME];
145
- const res = await resolveKey(JEV_KEY_NAME, { store, env, ref: typeof ref === 'string' ? ref : undefined, ...(opRead ? { opRead } : {}) });
151
+ const res = await resolveKey(JEV_KEY_NAME, {
152
+ store,
153
+ env,
154
+ ref: typeof ref === 'string' ? ref : undefined,
155
+ cacheHours: cacheHoursFrom(cfg).hours,
156
+ ...(opRead ? { opRead } : {}),
157
+ ...(now !== undefined ? { now } : {}),
158
+ });
146
159
  return res.value;
147
160
  }
148
161
 
162
+ /**
163
+ * B90: an unexpected failure inside a named worker step (today: `routing`). The ticket still ends
164
+ * `worker-error`; its result's `detail` says `<step>: <original error text>` (redacted).
165
+ */
166
+ class StepError extends Error {
167
+ /** @param {string} step @param {unknown} cause */
168
+ constructor(step, cause) {
169
+ super(`${step}: ${cause instanceof Error ? `${cause.name}: ${cause.message}` : String(cause)}`, { cause });
170
+ this.step = step;
171
+ }
172
+ }
173
+
149
174
  /** The fix-loop fields of an engine outcome the result carries (B12c, §4.11). */
150
175
  const LOOP_FIELDS = Object.freeze(['round', 'kind', 'level', 'next', 'trigger', 'stopped', 'late']);
151
176
 
@@ -198,6 +223,7 @@ export function recordedVerdict(result) {
198
223
  * @property {string} slug - ledger project slug (the run record's `project`).
199
224
  * @property {Buffer} key - the run's HMAC key.
200
225
  * @property {number} [pollMs]
226
+ * @property {string} [guardRunRecordPath] - booted workers stop when this authoritative record disappears.
201
227
  * @property {number} [heartbeatMs] - how often `heartbeat_at` is refreshed: an integer ≥ `MIN_HEARTBEAT_MS`,
202
228
  * else `HEARTBEAT_MS`.
203
229
  */
@@ -282,14 +308,51 @@ export async function createWorker(opts, deps = {}) {
282
308
  }
283
309
 
284
310
  /**
285
- * One ticket through the engine hook with `tcfg` (the ticket's config snapshot): the unsigned result.
311
+ * One ticket through the engine hook with the ticket's config snapshot, routed by size (B90): the unsigned result.
312
+ * @param {string} id - the ticket id (B90: a retried ticket never writes its route rows twice).
286
313
  * @param {import('./queue.mjs').Ticket} ticket @param {Record<string, any>} base
287
- * @param {Record<string, any>} tcfg
314
+ * @param {Record<string, any>} snapshotCfg - the ticket's config snapshot, before B90 routing.
288
315
  * @param {string} contentRoot - B62b: the tree the content was hashed in, and is reviewed in.
316
+ * @param {{lines: number, deleted: boolean}} size - B90: the line count of the hashed bytes.
289
317
  * @returns {Promise<Record<string, any>>}
290
318
  */
291
- async function reviewWith(ticket, base, tcfg, contentRoot) {
292
- const timeoutS = Number.isInteger(tcfg?.review?.session_timeout_s) && tcfg.review.session_timeout_s > 0 ? tcfg.review.session_timeout_s : DEFAULT_SESSION_TIMEOUT_S;
319
+ async function reviewWith(id, ticket, base, snapshotCfg, contentRoot, size) {
320
+ const baseTimeoutS = Number.isInteger(snapshotCfg?.review?.session_timeout_s) && snapshotCfg.review.session_timeout_s > 0 ? snapshotCfg.review.session_timeout_s : DEFAULT_SESSION_TIMEOUT_S;
321
+ // B90: `review.routes` picks the model, second level and timeout of every session of this
322
+ // ticket from its size, once, before the first session. No route ⇒ the snapshot object itself.
323
+ // A route list that cannot be used is `unavailable` — never a review on a model it did not name.
324
+ let routed;
325
+ try {
326
+ routed = routeTicket(snapshotCfg, { lines: size.lines, deleted: size.deleted, timeoutS: baseTimeoutS });
327
+ } catch (err) {
328
+ // only a route list that cannot be used is `route-invalid`; anything else is a real failure
329
+ // and goes to runOne (`worker-error`), named as a routing-step failure with its own text
330
+ if (!(err instanceof RouteError)) throw new StepError('routing', err);
331
+ return { ...base, status: 'unavailable', reason: 'route-invalid', approved: false };
332
+ }
333
+ const tcfg = routed.cfg;
334
+ const timeoutS = routed.timeoutS;
335
+ // the signed `review.route` row comes before the first session; a row that cannot be written
336
+ // is `unavailable` (a routed review the ledger cannot explain never runs). One row per routed
337
+ // level per ticket: a retried ticket (same id) skips the levels whose row the ledger already has.
338
+ // `runId` is the run this ticket belongs to (reviewOne refuses `ticket.run !== runId` as
339
+ // other-run), and a restarted worker serves the same run id (startWorker: `opts.runId`), so the
340
+ // check holds across workers of that run.
341
+ if (routed.rows.length > 0) {
342
+ try {
343
+ const have = new Set(
344
+ (await readRows())
345
+ .filter((r) => r?.event === 'review.route' && r.run === runId && r.ticket === id)
346
+ .map((r) => r.level),
347
+ );
348
+ for (const row of routed.rows) {
349
+ if (have.has(row.level)) continue;
350
+ await ledger({ ...row, ticket: id, block: ticket.block, file: ticket.file, content_hash: ticket.content_hash });
351
+ }
352
+ } catch {
353
+ return { ...base, status: 'unavailable', reason: 'route-row', approved: false };
354
+ }
355
+ }
293
356
  /** @type {import('./engine.mjs').ReviewContext} */
294
357
  const ctx = {
295
358
  repoRoot,
@@ -382,8 +445,12 @@ export async function createWorker(opts, deps = {}) {
382
445
  if (canonicalPath(hashedIn) !== canonicalPath(contentRoot)) return { ...base, status: 'unavailable', reason: 'worktree-mismatch', approved: false };
383
446
  /** @type {string} */
384
447
  let current;
448
+ /** @type {Buffer | null} */
449
+ let bytes;
385
450
  try {
386
- current = contentHash(contentRoot, ticket.file);
451
+ // B90: one read — the bytes hashed are the bytes whose lines route the review
452
+ bytes = readRegularFileNoFollow(contentRoot, ticket.file);
453
+ current = hashBytes(bytes);
387
454
  } catch (err) {
388
455
  // the path no longer passes the on-disk check, or the file cannot be read
389
456
  const reason = err instanceof WorkerError ? err.code : 'unreadable';
@@ -398,7 +465,7 @@ export async function createWorker(opts, deps = {}) {
398
465
  } catch {
399
466
  return { ...base, status: 'unavailable', reason: 'config-snapshot', approved: false };
400
467
  }
401
- return reviewWith(ticket, base, ticketCfg, contentRoot);
468
+ return reviewWith(id, ticket, base, ticketCfg, contentRoot, { lines: countLines(bytes), deleted: bytes === null });
402
469
  }
403
470
 
404
471
  /**
@@ -445,9 +512,11 @@ export async function createWorker(opts, deps = {}) {
445
512
  async function runOne(id) {
446
513
  try {
447
514
  return (await processTicket(id)) !== null ? 1 : 0;
448
- } catch {
515
+ } catch (err) {
516
+ // B90: a failure a step names (StepError) keeps its step and original text, redacted
517
+ const detail = err instanceof StepError ? { detail: redact(err.message) } : {};
449
518
  try {
450
- await complete(id, { event: 'review.result', status: 'unavailable', reason: 'worker-error', approved: false });
519
+ await complete(id, { event: 'review.result', status: 'unavailable', reason: 'worker-error', ...detail, approved: false });
451
520
  return 1;
452
521
  } catch {
453
522
  poisoned.add(id);
@@ -533,6 +602,20 @@ export async function createWorker(opts, deps = {}) {
533
602
  /** @type {Set<string>} */
534
603
  const started = new Set();
535
604
  for (;;) {
605
+ // One cheap stat per scheduler tick, including while tickets are in flight. Removing a
606
+ // test HOME (or the run record) stops new tickets; an open real run remains untouched.
607
+ if (!stopping && opts.guardRunRecordPath) {
608
+ try {
609
+ statSync(opts.guardRunRecordPath);
610
+ } catch (err) {
611
+ if (err.code === 'ENOENT' || err.code === 'ENOTDIR') {
612
+ stop();
613
+ // The error log lives under HOME: never write it when HOME itself is gone, or the
614
+ // log write would create the removed HOME again.
615
+ if (existsSync(os.homedir())) await logWarning({ warning: 'worker_orphan_stop', message: `run record missing: ${opts.guardRunRecordPath}` }).catch(() => null);
616
+ }
617
+ }
618
+ }
536
619
  if (!stopping) {
537
620
  for (const id of pendingTickets(repoRoot)) {
538
621
  if (poisoned.has(id) || started.has(id)) continue;
@@ -685,7 +768,7 @@ export async function bootWorker(opts, deps = {}) {
685
768
  const runRootDir = runRoot(opts.runId, { root: tmpRoot });
686
769
  setRunRoot(runRootDir);
687
770
  const key = await loadKey(opts.runId);
688
- const worker = await createWorker({ runId: opts.runId, repoRoot, cfg, runRootDir, slug: record.project, key, pollMs: opts.pollMs }, deps);
771
+ const worker = await createWorker({ runId: opts.runId, repoRoot, cfg, runRootDir, slug: record.project, key, pollMs: opts.pollMs, guardRunRecordPath: runRecordPath(opts.runId) }, deps);
689
772
  return { ...worker, repoRoot, runRootDir };
690
773
  }
691
774
 
@@ -185,7 +185,15 @@ function realOrSelf(p) {
185
185
  * @returns {string}
186
186
  */
187
187
  export function contentHash(repoRoot, file) {
188
- const bytes = readRegularFileNoFollow(repoRoot, file);
188
+ return hashBytes(readRegularFileNoFollow(repoRoot, file));
189
+ }
190
+
191
+ /**
192
+ * The one content-hash rule (B90: the worker hashes the bytes it also counts lines on): sha256 of
193
+ * the bytes, or `DELETED_HASH` for `null` (no file).
194
+ * @param {Buffer | null} bytes @returns {string}
195
+ */
196
+ export function hashBytes(bytes) {
189
197
  if (bytes === null) return DELETED_HASH;
190
198
  return createHash('sha256').update(bytes).digest('hex');
191
199
  }