@codedology/code-forge 0.15.3 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +1 -1
  3. package/docs/reference/blocks.json +9 -1
  4. package/docs/reference/config.md +11 -1
  5. package/native/README.md +10 -0
  6. package/native/bin/darwin-arm64/code-forge-native +0 -0
  7. package/native/bin/darwin-x64/code-forge-native +0 -0
  8. package/native/bin/linux-arm64/code-forge-native +0 -0
  9. package/native/bin/linux-x64/code-forge-native +0 -0
  10. package/package.json +7 -2
  11. package/schema/code-forge.schema.json +26 -1
  12. package/schema/facts.schema.json +1 -1
  13. package/skill/SKILL.md +1 -1
  14. package/skill/references/adapters/solo.md +1 -1
  15. package/skill/references/autopilot.md +6 -1
  16. package/skill/references/code.md +37 -0
  17. package/skill/references/decisions.md +1 -1
  18. package/skill/references/plan.md +18 -5
  19. package/skill/references/review.md +47 -9
  20. package/skill/scripts/forge +1 -1
  21. package/skill/templates/facts-sheet.md +9 -3
  22. package/src/cli/block.mjs +284 -11
  23. package/src/cli/doctor.mjs +32 -1
  24. package/src/cli/help.mjs +1 -0
  25. package/src/cli/native-status.mjs +13 -0
  26. package/src/cli/review-file.mjs +126 -14
  27. package/src/cli/spawn.mjs +26 -22
  28. package/src/cli/status.mjs +80 -14
  29. package/src/decide/escalation.mjs +5 -5
  30. package/src/doctor/index.mjs +11 -0
  31. package/src/doctor/worker-probe.mjs +1 -1
  32. package/src/gates/dependents.mjs +200 -0
  33. package/src/gates/expand.mjs +83 -26
  34. package/src/gates/run.mjs +76 -14
  35. package/src/ledger/tail.mjs +89 -0
  36. package/src/review/agent-waiver.mjs +95 -8
  37. package/src/review/definitions.mjs +170 -0
  38. package/src/review/fixloop.mjs +4 -4
  39. package/src/review/gate-check.mjs +33 -13
  40. package/src/review/model-stats.mjs +125 -0
  41. package/src/review/packet.mjs +111 -22
  42. package/src/review/reopen.mjs +38 -13
  43. package/src/review/route.mjs +58 -4
  44. package/src/session/facts.mjs +252 -70
  45. package/src/session/plan-check.mjs +4 -1
  46. package/src/session/spawn.mjs +70 -19
  47. package/src/state/block-end.mjs +3 -1
  48. package/src/state/block.mjs +56 -21
  49. package/src/state/run.mjs +12 -5
  50. package/src/util/native.mjs +133 -0
  51. package/src/worker/engine.mjs +11 -26
  52. package/src/worker/loop.mjs +150 -20
  53. package/src/worker/queue.mjs +3 -2
  54. package/src/worker/review-state.mjs +15 -3
  55. package/src/worker/watch.mjs +97 -83
  56. package/src/worker/worker-warning.mjs +35 -0
@@ -20,7 +20,7 @@
20
20
  * restarted worker boots on the snapshot, not on whatever the file says now. A record from before
21
21
  * snapshots (or none) keeps the config the worker booted with.
22
22
  *
23
- * Ticket pool (B42): the worker runs up to `review.parallel_tickets` (default 3, 1–16) tickets at
23
+ * Ticket pool (B42): the worker runs up to `review.parallel_tickets` (default 6, 1–16) tickets at
24
24
  * once, oldest first (or fewest running per block with `review.fair_share`); when one ends the
25
25
  * next pending one starts. Two tickets for the same (block, file) never overlap (a file lock;
26
26
  * the later one stays pending without taking a slot),
@@ -44,14 +44,16 @@ import { expiryChecks } from '../autopilot/limits.mjs';
44
44
  import { loadProjectConfig } from '../config/load.mjs';
45
45
  import { reviewWorkers } from '../config/validate.mjs';
46
46
  import { cacheHoursFrom, createDefaultKeyStore, resolveKey } from '../keys/store.mjs';
47
- import { appendRow, readAllRows } from '../ledger/write.mjs';
48
- import { spawnSession } from '../session/spawn.mjs';
47
+ import { createTailReader } from '../ledger/tail.mjs';
48
+ import { appendRow } from '../ledger/write.mjs';
49
+ import { providerLimit, spawnSession } from '../session/spawn.mjs';
49
50
  import { snapshotFor } from '../state/config-snapshot.mjs';
50
51
  import { runRecordPath } from '../state/paths.mjs';
51
52
  import { processStartTime, readRun, reattachWorker } from '../state/run.mjs';
52
53
  import { loadKey, signRow } from '../state/signer.mjs';
53
54
  import { logWarning } from '../util/error-log.mjs';
54
55
  import { withLock, tryLock } from '../util/file-locks.mjs';
56
+ import { startNativeDaemon } from '../util/native.mjs';
55
57
  import { redact, writeSafe } from '../util/redact.mjs';
56
58
  import { sweepProcesses } from '../util/reaper.mjs';
57
59
  import { runRoot, setRunRoot } from '../util/tmp.mjs';
@@ -69,7 +71,8 @@ export const DEFAULT_POLL_MS = 250;
69
71
  export const DEFAULT_SESSION_TIMEOUT_S = 300;
70
72
 
71
73
  /** B42: `review.parallel_tickets` when the config does not set a valid one. */
72
- export const DEFAULT_PARALLEL_TICKETS = 3;
74
+ export const DEFAULT_PARALLEL_TICKETS = 6;
75
+ export const DEFAULT_PROVIDER_PARALLEL = 4;
73
76
 
74
77
  /** B48: the fixed text of a failed autopilot expiry check in a drain (stderr and the warning log). */
75
78
  export const EXPIRY_CHECK_FAILED = 'the autopilot expiry check failed in a worker drain; it runs again at the next drain';
@@ -241,6 +244,7 @@ export function recordedVerdict(result) {
241
244
  * @property {{beforeLock?: () => void, afterCount?: (count: number) => Promise<unknown>}} [hooks] - deterministic boot races.
242
245
  * @property {typeof readTicket} [readTicket] - the ticket read (a test seam).
243
246
  * @property {typeof import('../util/exec.mjs').exec} [watchExec] - watch git exec seam.
247
+ * @property {() => number} [watchNow] - watch debounce clock (default: Date.now).
244
248
  * @property {Parameters<typeof watchTick>[0]['hooks']} [watchHooks] - deterministic watch races.
245
249
  * @property {import('../keys/store.mjs').KeyStore} [store] - default: B2's production store.
246
250
  * @property {any} [opRead]
@@ -248,7 +252,7 @@ export function recordedVerdict(result) {
248
252
  * @property {Partial<Record<'claude'|'codex'|'grok', string>>} [bins]
249
253
  * @property {(row: Record<string, any>) => Promise<unknown>} [writeRow] - default: B6 `appendRow`.
250
254
  * @property {() => Promise<Array<Record<string, any>>>} [readRows] - the ledger rows the fix-loop
251
- * state is anchored in; default: B6 `readAllRows(slug)`.
255
+ * state is anchored in; default: one project tail reader for the worker.
252
256
  * @property {{write: (s: string) => unknown}} [stderr]
253
257
  * @property {import('../review/triage.mjs').JevAsk} [jev] - S1 for triage and the recheck (tests
254
258
  * inject a mock); default: `askJev` with the resolved Jev key, none without one.
@@ -270,7 +274,7 @@ export async function createWorker(opts, deps = {}) {
270
274
  const loadRun = deps.readRun ?? readRun;
271
275
  const loadTicket = deps.readTicket ?? readTicket;
272
276
  const writeRow = deps.writeRow ?? ((/** @type {Record<string, any>} */ row) => appendRow(row, { slug }));
273
- const readRows = deps.readRows ?? (() => readAllRows(slug));
277
+ const readRows = deps.readRows ?? createTailReader(slug).read;
274
278
  // B48: the run's own writer (unsigned base; the checks sign their rows with the run key)
275
279
  const expiryCheck = deps.expiryCheck ?? (() => expiryChecks(runId, new Date(), { writeRow }));
276
280
  const warnStream = deps.stderr ?? process.stderr;
@@ -281,6 +285,8 @@ export async function createWorker(opts, deps = {}) {
281
285
  const pollMs = opts.pollMs ?? DEFAULT_POLL_MS;
282
286
  const heartbeatMs = Number.isInteger(opts.heartbeatMs) && /** @type {number} */ (opts.heartbeatMs) >= MIN_HEARTBEAT_MS ? /** @type {number} */ (opts.heartbeatMs) : HEARTBEAT_MS;
283
287
  let stopping = false;
288
+ let stopDaemon = async () => {};
289
+ let daemonStarted = false;
284
290
  /** @type {ReturnType<typeof setInterval> | null} */
285
291
  let heartbeat = null;
286
292
  /** @type {ReturnType<typeof announceWorker> | null} */
@@ -291,11 +297,68 @@ export async function createWorker(opts, deps = {}) {
291
297
  let poolWake = null;
292
298
  let activity = 0;
293
299
 
300
+ // Sessions waiting for a provider retain their ticket's pool slot. Resolve the actual provider
301
+ // at spawn time, after routing and second-level overrides; admission hints never bypass caps.
302
+ let ticketLimit = parallelTickets(cfg);
303
+ let providerParallel = DEFAULT_PROVIDER_PARALLEL;
304
+ /** @type {Map<string, {active: number, pending: number}>} */
305
+ const ticketSessions = new Map();
306
+ /** @type {Map<string, number>} */
307
+ const providerActive = new Map();
308
+ /** @type {Array<{id: string, provider: string, start: () => void}>} */
309
+ const sessionQueue = [];
310
+
311
+ function dispatchSessions() {
312
+ for (let i = 0; i < sessionQueue.length;) {
313
+ const next = sessionQueue[i];
314
+ const state = ticketSessions.get(next.id);
315
+ const liveTickets = [...ticketSessions.values()].filter((s) => s.active > 0).length;
316
+ if ((providerActive.get(next.provider) ?? 0) >= providerParallel || (state.active === 0 && liveTickets >= ticketLimit)) {
317
+ i += 1;
318
+ continue;
319
+ }
320
+ sessionQueue.splice(i, 1);
321
+ state.pending -= 1;
322
+ state.active += 1;
323
+ providerActive.set(next.provider, (providerActive.get(next.provider) ?? 0) + 1);
324
+ next.start();
325
+ }
326
+ }
327
+
328
+ /** @param {string} id @param {Parameters<typeof spawnSession>[0]} opts @returns {ReturnType<typeof spawnSession>} */
329
+ function spawnPooled(id, opts) {
330
+ // Match the spawner's per-level override and top-level fallback; validation stays there.
331
+ const provider = opts.cfg?.levels?.[opts.level]?.provider ?? opts.cfg?.provider ?? 'unresolved';
332
+ const state = ticketSessions.get(id) ?? { active: 0, pending: 0 };
333
+ ticketSessions.set(id, state);
334
+ state.pending += 1;
335
+ // The spawner's existing per-attempt semaphores also cover fallback providers. Retain
336
+ // stricter provider_concurrency settings while applying this cap to fallback attempts.
337
+ const providerConcurrency = Object.fromEntries(['anthropic', 'openai', 'xai'].map((p) => [p, Math.min(providerParallel, providerLimit(opts.cfg, p))]));
338
+ const needsCap = Object.entries(providerConcurrency).some(([p, limit]) => limit < providerLimit(opts.cfg, p));
339
+ const sessionCfg = needsCap ? { ...opts.cfg, review: { ...opts.cfg.review, provider_concurrency: providerConcurrency } } : opts.cfg;
340
+ return new Promise((resolve, reject) => {
341
+ sessionQueue.push({ id, provider, start: () => {
342
+ Promise.resolve().then(() => spawn({ ...opts, cfg: sessionCfg },
343
+ { env: childEnv, ...(deps.bins ? { bins: deps.bins } : {}), ...(deps.stderr ? { stderr: deps.stderr } : {}) },
344
+ )).then(resolve, reject).finally(() => {
345
+ state.active -= 1;
346
+ providerActive.set(provider, providerActive.get(provider) - 1);
347
+ if (state.active === 0 && state.pending === 0) ticketSessions.delete(id);
348
+ dispatchSessions();
349
+ poolWake?.();
350
+ });
351
+ } });
352
+ dispatchSessions();
353
+ poolWake?.();
354
+ });
355
+ }
356
+
294
357
  /** @param {Record<string, any>} row */
295
358
  const ledger = (row) => writeRow(signRow({ run: runId, ...row }, key));
296
359
  // Shared across direct ticks and the scheduler, so running tickets are never superseded.
297
360
  const activeTickets = new Set();
298
- const tickWatch = () => watchTick({ repoRoot, runId, cfg, readRun: loadRun, writeRow: ledger, running: activeTickets, exec: deps.watchExec, hooks: deps.watchHooks });
361
+ const tickWatch = () => watchTick({ repoRoot, runId, cfg, readRun: loadRun, writeRow: ledger, running: activeTickets, exec: deps.watchExec, hooks: deps.watchHooks, now: deps.watchNow });
299
362
 
300
363
  /**
301
364
  * The config ticket `id` is reviewed with (B35): its snapshot from the run record, re-read per
@@ -305,6 +368,11 @@ export async function createWorker(opts, deps = {}) {
305
368
  * @throws {Error} `config-snapshot` — a record that cannot be read, or a missing/altered snapshot.
306
369
  */
307
370
  async function configFor(id) {
371
+ return snapshotFor(await configRecord(), id)?.config ?? cfg;
372
+ }
373
+
374
+ /** Read once for pool admission hints; ticket processing still re-reads its own snapshot. */
375
+ async function configRecord() {
308
376
  // The Jev key and the run root were settled at boot from `cfg`; a reload can never change
309
377
  // them, because `keys`, `system1.key` and `tmp.root` are fixed keys `run reload` refuses.
310
378
  let record;
@@ -313,13 +381,13 @@ export async function createWorker(opts, deps = {}) {
313
381
  record = await loadRun(runId);
314
382
  break;
315
383
  } catch (err) {
316
- if (/** @type {any} */ (err)?.code === 'no-run') return cfg;
384
+ if (/** @type {any} */ (err)?.code === 'no-run') return null;
317
385
  // a transient read error (e.g. the record caught mid-replace) gets ONE retry
318
386
  if (attempt >= 2) throw new Error('config-snapshot');
319
387
  await new Promise((resolve) => setTimeout(resolve, CONFIG_RETRY_MS));
320
388
  }
321
389
  }
322
- return snapshotFor(record, id)?.config ?? cfg;
390
+ return record;
323
391
  }
324
392
 
325
393
  /**
@@ -338,7 +406,9 @@ export async function createWorker(opts, deps = {}) {
338
406
  // A route list that cannot be used is `unavailable` — never a review on a model it did not name.
339
407
  let routed;
340
408
  try {
341
- routed = routeTicket(snapshotCfg, { lines: size.lines, deleted: size.deleted, timeoutS: baseTimeoutS });
409
+ routed = routeTicket(snapshotCfg, { lines: size.lines, deleted: size.deleted, timeoutS: baseTimeoutS },
410
+ snapshotCfg.review?.adaptive?.enabled === false || !snapshotCfg.levels?.L2?.model
411
+ ? undefined : { rows: await readRows(), ticketId: id, runId });
342
412
  } catch (err) {
343
413
  // only a route list that cannot be used is `route-invalid`; anything else is a real failure
344
414
  // and goes to runOne (`worker-error`), named as a routing-step failure with its own text
@@ -382,9 +452,8 @@ export async function createWorker(opts, deps = {}) {
382
452
  // `sessionOpts.cfg` (a per-call config, e.g. the consensus second reviewer's level) wins
383
453
  // over the ticket's; everything else the worker pins.
384
454
  spawn: (sessionOpts) =>
385
- spawn(
455
+ spawnPooled(id,
386
456
  /** @type {any} */ ({ cfg: tcfg, ...sessionOpts, runRoot: runRootDir, run: runId, block: ticket.block, slug, timeoutMs: timeoutS * 1000 }),
387
- { env: childEnv, ...(deps.bins ? { bins: deps.bins } : {}), ...(deps.stderr ? { stderr: deps.stderr } : {}) },
388
457
  ),
389
458
  writeRow: (row) => ledger({ ...row, block: ticket.block, file: ticket.file, content_hash: ticket.content_hash }),
390
459
  writeBlockRow: (row) => ledger({ ...row, block: ticket.block }),
@@ -559,16 +628,21 @@ export async function createWorker(opts, deps = {}) {
559
628
  /**
560
629
  * Pool settings from the snapshot in force, else the boot config. A run reload applies at the
561
630
  * next drain; anything but an integer 1–16 uses the default size.
562
- * @returns {Promise<{limit: number, fairShare: boolean}>}
631
+ * @returns {Promise<{limit: number, fairShare: boolean, record: Record<string, any> | null}>}
563
632
  */
564
633
  async function poolSettings() {
565
634
  let current = cfg;
635
+ let record = null;
566
636
  try {
567
- current = await configFor('');
637
+ record = await configRecord();
638
+ current = snapshotFor(record, '')?.config ?? cfg;
568
639
  } catch {
569
640
  // an unreadable record: the boot settings (each ticket still fails closed on its own)
570
641
  }
571
- return { limit: parallelTickets(current), fairShare: current?.review?.fair_share === true };
642
+ ticketLimit = parallelTickets(current);
643
+ const cap = current?.review?.provider_parallel;
644
+ providerParallel = Number.isInteger(cap) && cap >= 1 && cap <= MAX_PARALLEL_TICKETS ? cap : DEFAULT_PROVIDER_PARALLEL;
645
+ return { limit: parallelTickets(current), fairShare: current?.review?.fair_share === true, record };
572
646
  }
573
647
 
574
648
  /**
@@ -598,6 +672,27 @@ export async function createWorker(opts, deps = {}) {
598
672
  }
599
673
  }
600
674
 
675
+ /**
676
+ * The initial reviewer's provider is an admission hint, including snapshot and size routing.
677
+ * Unknown or invalid hints still enter the bounded pool so the normal checks can refuse them.
678
+ * Later sessions may use another provider; `spawnPooled` enforces their actual session caps.
679
+ * @param {string} id @param {Record<string, any> | null} record @returns {Promise<string | null>}
680
+ */
681
+ async function providerHint(id, record) {
682
+ try {
683
+ let current = snapshotFor(record, id)?.config ?? cfg;
684
+ if (current?.review?.routes?.length) {
685
+ const ticket = loadTicket(repoRoot, id);
686
+ const root = await contentRootFor(runId, ticket.block, repoRoot);
687
+ const bytes = readRegularFileNoFollow(root, ticket.file);
688
+ current = routeTicket(current, { lines: countLines(bytes), deleted: bytes === null, timeoutS: DEFAULT_SESSION_TIMEOUT_S }).cfg;
689
+ }
690
+ return current?.levels?.L2?.provider ?? current?.provider ?? null;
691
+ } catch {
692
+ return null;
693
+ }
694
+ }
695
+
601
696
  /**
602
697
  * Serve every pending ticket (B42): up to `review.parallel_tickets` at once, oldest first; with
603
698
  * fair share, each slot goes to the block with fewest running tickets, ties oldest first. When
@@ -625,6 +720,8 @@ export async function createWorker(opts, deps = {}) {
625
720
  }
626
721
  /** @type {number | null} */
627
722
  let limit = null;
723
+ /** @type {Record<string, any> | null} */
724
+ let record = null;
628
725
  let fairShare = false;
629
726
  let done = 0;
630
727
  /** @type {Map<string, Promise<void>>} */
@@ -633,6 +730,8 @@ export async function createWorker(opts, deps = {}) {
633
730
  const busy = new Set();
634
731
  /** @type {Map<string, number>} running tickets per block */
635
732
  const runningByBlock = new Map();
733
+ /** @type {Map<string, number>} reserve provider capacity while admitted tickets prepare */
734
+ const admittedByProvider = new Map();
636
735
  /** @type {Set<string>} */
637
736
  const started = new Set();
638
737
  for (;;) {
@@ -660,10 +759,12 @@ export async function createWorker(opts, deps = {}) {
660
759
  }
661
760
  const pending = pendingTickets(repoRoot).filter((id) => !poisoned.has(id) && !started.has(id));
662
761
  if (pending.length > 0 || running.size > 0) activity += 1;
663
- if (pending.length > 0 && limit === null) ({ limit, fairShare } = await poolSettings());
762
+ if (pending.length > 0 && limit === null) ({ limit, fairShare, record } = await poolSettings());
664
763
  // Cache even unreadable hints for this tick: ranking never reads a ticket twice.
665
764
  /** @type {Map<string, ReturnType<typeof fileKeyOf>>} */
666
765
  const hints = new Map();
766
+ /** @type {Map<string, string | null>} */
767
+ const providers = new Map();
667
768
  while (pending.length > 0 && running.size < limit && !stopping) {
668
769
  let next = -1;
669
770
  let fewest = Infinity;
@@ -672,6 +773,9 @@ export async function createWorker(opts, deps = {}) {
672
773
  if (!hints.has(id)) hints.set(id, fileKeyOf(id));
673
774
  const file = hints.get(id);
674
775
  if (file && busy.has(file.key)) continue; // waits without a slot
776
+ if (!providers.has(id)) providers.set(id, await providerHint(id, record));
777
+ const provider = providers.get(id);
778
+ if (provider && Math.max(admittedByProvider.get(provider) ?? 0, providerActive.get(provider) ?? 0) >= providerParallel) continue;
675
779
  const count = fairShare && file ? runningByBlock.get(file.block) ?? 0 : 0;
676
780
  if (count < fewest) {
677
781
  next = i;
@@ -691,10 +795,12 @@ export async function createWorker(opts, deps = {}) {
691
795
  }
692
796
  const file = hints.get(id);
693
797
  const fileKey = file?.key ?? null;
798
+ const provider = providers.get(id);
694
799
  started.add(id);
695
800
  activeTickets.add(id);
696
801
  if (fileKey !== null) busy.add(fileKey);
697
802
  if (file) runningByBlock.set(file.block, (runningByBlock.get(file.block) ?? 0) + 1);
803
+ if (provider) admittedByProvider.set(provider, (admittedByProvider.get(provider) ?? 0) + 1);
698
804
  const slot = runOne(id)
699
805
  .then((n) => {
700
806
  done += n;
@@ -707,6 +813,7 @@ export async function createWorker(opts, deps = {}) {
707
813
  fileKeys.delete(id);
708
814
  if (fileKey !== null) busy.delete(fileKey);
709
815
  if (file) runningByBlock.set(file.block, runningByBlock.get(file.block) - 1);
816
+ if (provider) admittedByProvider.set(provider, admittedByProvider.get(provider) - 1);
710
817
  });
711
818
  running.set(id, slot);
712
819
  }
@@ -769,14 +876,22 @@ export async function createWorker(opts, deps = {}) {
769
876
  heartbeat = null;
770
877
  }
771
878
 
879
+ async function startDaemon() {
880
+ if (daemonStarted) return;
881
+ daemonStarted = true;
882
+ stopDaemon = await startNativeDaemon(runId, cfg, env);
883
+ if (stopping) await stopDaemon();
884
+ }
885
+
772
886
  /** Serve the queue until `stop()`. */
773
887
  async function run() {
774
- announce();
775
888
  let interval = pollMs;
776
889
  let polled = false;
777
890
  /** @type {import('node:fs').FSWatcher | null} */
778
891
  let queueWatch = null;
779
892
  try {
893
+ await startDaemon();
894
+ announce();
780
895
  // Enqueue publishes a ticket by rename. Wake even during the longest idle back-off;
781
896
  // heartbeat and temporary files do not wake an idle worker.
782
897
  try {
@@ -818,37 +933,42 @@ export async function createWorker(opts, deps = {}) {
818
933
  } finally {
819
934
  queueWatch?.close();
820
935
  release();
936
+ await stopDaemon();
821
937
  }
822
938
  }
823
939
 
824
940
  /** Drain the queue once while announced, then release (`worker --once`). */
825
941
  async function once() {
826
- announce();
827
942
  try {
943
+ await startDaemon();
944
+ announce();
828
945
  const done = await drain();
829
946
  if (done > 0) await cleanOrphans();
830
947
  return done;
831
948
  } finally {
832
949
  stop();
833
950
  release();
951
+ await stopDaemon();
834
952
  }
835
953
  }
836
954
 
837
955
  /** Stop taking tickets; the running ones are awaited (a killed one is abandoned, see `processTicket`). */
838
956
  function stop() {
839
957
  stopping = true;
958
+ void stopDaemon();
840
959
  wake?.();
841
960
  poolWake?.();
842
961
  }
843
962
 
844
963
  /** Withdraw this process's announcement (`worker.json`) if it is still ours. */
845
964
  function release() {
965
+ void stopDaemon();
846
966
  stopBeating();
847
967
  if (self !== null) retractWorker(repoRoot, self.pid, self.start_time);
848
968
  self = null;
849
969
  }
850
970
 
851
- return { processTicket, drain, run, once, stop, release, announce, watchTick: tickWatch, jevKeyResolved: jevKey !== null, sessionEnv: childEnv };
971
+ return { processTicket, drain, run, once, stop, release, announce, startDaemon, stopDaemon: () => stopDaemon(), watchTick: tickWatch, jevKeyResolved: jevKey !== null, sessionEnv: childEnv };
852
972
  }
853
973
 
854
974
  /**
@@ -890,7 +1010,17 @@ export async function bootWorker(opts, deps = {}) {
890
1010
  if (entries.some((entry) => entry.live && !entry.worker) || (legacy && legacy.run !== opts.runId) || entries.some((entry) => entry.live && entry.worker?.run !== opts.runId) || count >= limit) {
891
1011
  throw new WorkerError('worker-running', `review.workers limit ${limit}: a live or unreadable worker entry already serves this queue`);
892
1012
  }
893
- worker.announce();
1013
+ // Startup must finish before worker.json signals readiness: an owner may
1014
+ // remove HOME immediately after that announcement to stop an orphan worker.
1015
+ try {
1016
+ await worker.startDaemon();
1017
+ worker.announce();
1018
+ } catch (error) {
1019
+ worker.stop();
1020
+ worker.release();
1021
+ await worker.stopDaemon();
1022
+ throw error;
1023
+ }
894
1024
  });
895
1025
  return { ...worker, repoRoot, runRootDir };
896
1026
  }
@@ -520,8 +520,9 @@ export function pendingTickets(repoRoot) {
520
520
  return [];
521
521
  }
522
522
  const claimed = new Set(names.map((n) => CLAIM.exec(n)?.[1]));
523
+ const present = new Set(names);
523
524
  const ids = names.filter((n) => /^[0-9a-f]{24}\.json$/.test(n)).map((n) => n.slice(0, 24));
524
- const pending = ids.filter((id) => !names.includes(`${id}.done`) && !claimed.has(id));
525
+ const pending = ids.filter((id) => !present.has(`${id}.done`) && !claimed.has(id));
525
526
  const at = (/** @type {string} */ id) => {
526
527
  const t = readJSON(path.join(queueDir(repoRoot), `${id}.json`));
527
528
  return typeof t?.enqueued_at === 'string' ? t.enqueued_at : '';
@@ -589,7 +590,7 @@ export function readTicket(repoRoot, id) {
589
590
  const t = readJSON(path.join(queueDir(repoRoot), `${assertTicketId(id)}.json`));
590
591
  if (!t || typeof t !== 'object') throw new WorkerError('bad-ticket', `ticket ${id} is not readable JSON`);
591
592
  assertRowPath(t.file);
592
- if (t.tree !== undefined && (typeof t.tree !== 'string' || !path.isAbsolute(t.tree))) throw new WorkerError('bad-ticket', `ticket ${id} names a tree that is not an absolute path`);
593
+ if (t.tree != null && (typeof t.tree !== 'string' || !path.isAbsolute(t.tree))) throw new WorkerError('bad-ticket', `ticket ${id} names a tree that is not an absolute path`);
593
594
  if (typeof t.run !== 'string' || typeof t.block !== 'string' || typeof t.content_hash !== 'string') {
594
595
  throw new WorkerError('bad-ticket', `ticket ${id} does not match its id`);
595
596
  }
@@ -25,7 +25,7 @@
25
25
  * (the engine saves it as a new seq and writes `review.state_restored`); an anchor with no body
26
26
  * makes a missing file `lost` (refused, never a round 1). A
27
27
  * `review.state` row of the run whose MAC does not verify is `tampered` for every block and file
28
- * of it (`blockAnchors`). Whether the block's one L3 rung was used comes from the anchors, never
28
+ * of it (`blockAnchors`). Whether a file's one L3 rung was used comes from the anchors, never
29
29
  * from the state files.
30
30
  *
31
31
  * B78: the reviewed text itself is kept as a git blob in the content root's object store
@@ -427,8 +427,8 @@ export async function saveState({ runId, block, file, key, state, seq, writeRow,
427
427
  }
428
428
 
429
429
  /**
430
- * Whether the block's one L3 rung (§3.6 rules 5/6) was already used on ANY of its files — the
431
- * rung is once per block. Derived from the ledger anchors only; fails closed: a block anchor that
430
+ * Whether any file of the block has used its L3 rung (§3.6 rules 5/6).
431
+ * Derived from the ledger anchors only; fails closed: a block anchor that
432
432
  * does not verify is `tampered`.
433
433
  * @param {{runId: string, block: string, key: Buffer, rows: Array<Record<string, any>>}} opts
434
434
  * @returns {{status: 'ok' | 'tampered', used: boolean}}
@@ -438,3 +438,15 @@ export function blockRungUsed(opts) {
438
438
  if (anchors === null) return { status: 'tampered', used: true };
439
439
  return { status: 'ok', used: anchors.some((a) => a.l3_rung_used === true) };
440
440
  }
441
+
442
+ /**
443
+ * Whether this file has used its L3 rung, once per file across all its anchors.
444
+ * Verify the run's anchors before filtering by file, preserving fail-closed tamper detection.
445
+ * @param {{runId: string, block: string, file: string, key: Buffer, rows: Array<Record<string, any>>}} opts
446
+ * @returns {{status: 'ok' | 'tampered', used: boolean}}
447
+ */
448
+ export function fileRungUsed(opts) {
449
+ const anchors = blockAnchors(opts);
450
+ if (anchors === null) return { status: 'tampered', used: true };
451
+ return { status: 'ok', used: anchors.some((a) => a.file === opts.file && a.l3_rung_used === true) };
452
+ }