@codedology/code-forge 0.12.0 → 0.13.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.
@@ -790,6 +790,10 @@ export function renderForCodex(list = FORBIDDEN) {
790
790
  * `$ cat ~/.code-forge/runs/r.key`, `Write(.code-forge/reviews/x.json)`, a stream-json
791
791
  * `"name":"Write"` followed by the path — is found. It is a grep: a line that merely MENTIONS a
792
792
  * forbidden command is a hit too (fail closed; the block gate reports it as `rule_break`, B5).
793
+ * Codex `exec --json` logs (every non-empty line an object with a string `type`, first event
794
+ * `thread.started`) scan command_execution commands and file_change paths as writes. Other
795
+ * items scan whole, except agent_message and reasoning; a command_execution's aggregated_output
796
+ * is never scanned; non-item events are skipped. Any other log uses the plain-text grep above, unchanged.
793
797
  *
794
798
  * Linear in the line length: `contains` and `path` entries are evaluated ONCE per line over its
795
799
  * tokens (a `write` path entry hits when a write command starts before a token naming the path);
@@ -801,6 +805,17 @@ export function renderForCodex(list = FORBIDDEN) {
801
805
  * @returns {{id: string, line: number}[]}
802
806
  */
803
807
  export function scanTranscript(text, list = FORBIDDEN) {
808
+ const lines = String(text).split('\n');
809
+ /** @type {any[] | null} */
810
+ let events = null;
811
+ try {
812
+ const parsed = lines.map((line) => line.trim() === '' ? undefined : JSON.parse(line));
813
+ const nonEmpty = parsed.filter((event) => event !== undefined);
814
+ if (nonEmpty[0]?.type === 'thread.started' && nonEmpty.every((event) =>
815
+ event !== null && typeof event === 'object' && !Array.isArray(event) && typeof event.type === 'string')) events = parsed;
816
+ } catch {
817
+ // One bad JSONL line makes the entire transcript a plain-text, fail-closed grep.
818
+ }
804
819
  const commandEntries = list.filter((e) => e.kind === 'prefix' || e.kind === 'commandFlag' || e.kind === 'anyPrefix');
805
820
  const firstWords = new Set(['npx', 'bunx', 'pnpm', 'yarn', 'node']);
806
821
  for (const e of commandEntries) {
@@ -811,9 +826,21 @@ export function scanTranscript(text, list = FORBIDDEN) {
811
826
  const writeStarts = new Set([...WRITE_VERBS, ...WRAPPERS, 'sed', 'perl', 'git']);
812
827
  /** @type {{id: string, line: number}[]} */
813
828
  const hits = [];
814
- String(text)
815
- .split('\n')
829
+ lines
816
830
  .forEach((lineText, index) => {
831
+ if (events !== null) {
832
+ const event = events[index];
833
+ if (!event?.type.startsWith('item.') || !event.item) return;
834
+ const item = event.item;
835
+ if (item.type === 'agent_message' || item.type === 'reasoning') return;
836
+ // a command that is not a string is scanned with the rest of its item (fail closed), its output left out
837
+ if (item.type === 'command_execution') lineText = typeof item.command === 'string' ? item.command : JSON.stringify({ ...item, aggregated_output: undefined });
838
+ else if (item.type === 'file_change') {
839
+ lineText = (Array.isArray(item.changes) ? item.changes : [])
840
+ .filter((change) => typeof change?.path === 'string')
841
+ .map((change) => `Write(${JSON.stringify(change.path)})`).join(' ');
842
+ } else lineText = JSON.stringify(item);
843
+ }
817
844
  const tokens = lineText.split(/(>>?)|[\s'"`;|&(),{}[\]]+/).filter((t) => typeof t === 'string' && t.length > 0);
818
845
  const ids = new Set();
819
846
  let firstWrite = -1;
@@ -107,7 +107,7 @@ import { blockTree, worktreeRecord } from '../proof/worktree.mjs';
107
107
  import { ledgerSchemaHistory } from '../review/schema-fallback.mjs';
108
108
  import { verifyRow } from '../state/signer.mjs';
109
109
  import { redact } from '../util/redact.mjs';
110
- import { keyedLock } from '../util/locks.mjs';
110
+ import { withLock } from '../util/file-locks.mjs';
111
111
  import { blockAnchors, blockRungUsed, loadState, RESTORED_EVENT, saveState } from './review-state.mjs';
112
112
  import { assertAnswers } from './queue.mjs';
113
113
  import { assertTicketId, contentHash, readRegularFileNoFollow, WorkerError } from './ticket.mjs';
@@ -536,7 +536,7 @@ async function fixLoopRound(ticket, ctx, { base, level, owned, ownedKinds, block
536
536
  if (!ctx.readRows || !ctx.writeRow) return { status: 'unavailable', reason: 'no-ledger', approved: false, engine: 'adaptive', sessions: [] };
537
537
  const readRows = ctx.readRows;
538
538
  /** @type {{outcome: ReviewOutcome} | {round: () => Promise<ReviewOutcome>}} */
539
- const prepared = await keyedLock(blockLockKey(ctx.runId, ticket.block), async () => {
539
+ const prepared = await withLock(blockLockKey(ctx.runId, ticket.block), async () => {
540
540
  let rows;
541
541
  try {
542
542
  rows = (await readRows()).filter((r) => r?.run === ctx.runId);
@@ -21,8 +21,9 @@
21
21
  * snapshots (or none) keeps the config the worker booted with.
22
22
  *
23
23
  * Ticket pool (B42): the worker runs up to `review.parallel_tickets` (default 3, 1–16) tickets at
24
- * once, oldest first; when one ends the next pending one starts. Two tickets for the same
25
- * (block, file) never overlap (a file lock; the later one stays pending without taking a slot),
24
+ * once, oldest first (or fewest running per block with `review.fair_share`); when one ends the
25
+ * next pending one starts. Two tickets for the same (block, file) never overlap (a file lock;
26
+ * the later one stays pending without taking a slot),
26
27
  * and the engine takes the block's budget row and L3 rung under a block lock (`engine.mjs`). A
27
28
  * throw in one ticket is that ticket's `unavailable` result and never stops the others. `stop()`
28
29
  * starts no new ticket and waits for the running ones. With 1 the worker runs exactly as before.
@@ -49,13 +50,13 @@ import { runRecordPath } from '../state/paths.mjs';
49
50
  import { readRun, reattachWorker } from '../state/run.mjs';
50
51
  import { loadKey, signRow } from '../state/signer.mjs';
51
52
  import { logWarning } from '../util/error-log.mjs';
52
- import { keyedLock } from '../util/locks.mjs';
53
+ import { withLock, tryLock } from '../util/file-locks.mjs';
53
54
  import { redact, writeSafe } from '../util/redact.mjs';
54
55
  import { runRoot, setRunRoot } from '../util/tmp.mjs';
55
56
  import { openFindings } from '../review/gate-check.mjs';
56
57
  import { countLines, RouteError, routeTicket } from '../review/route.mjs';
57
58
  import { canonicalPath, contentRootFor, reviewTicket } from './engine.mjs';
58
- import { announceWorker, beatWorker, HEARTBEAT_MS, liveWorker, pendingTickets, readTicket, retractWorker, writeResult } from './queue.mjs';
59
+ import { announceWorker, beatWorker, HEARTBEAT_MS, isDone, liveWorker, pendingTickets, readTicket, retractWorker, workerRegistry, writeResult } from './queue.mjs';
59
60
  import { DELETED_HASH, hashBytes, readRegularFileNoFollow, repoRootOf, WorkerError } from './ticket.mjs';
60
61
  import { watchTick } from './watch.mjs';
61
62
 
@@ -234,6 +235,7 @@ export function recordedVerdict(result) {
234
235
  * @property {typeof reviewTicket} [review] - the engine hook (default `./engine.mjs`).
235
236
  * @property {typeof spawnSession} [spawn]
236
237
  * @property {typeof readRun} [readRun] - the run-record read behind the per-ticket config (a test seam).
238
+ * @property {{beforeLock?: () => void, afterCount?: (count: number) => Promise<unknown>}} [hooks] - deterministic boot races.
237
239
  * @property {typeof readTicket} [readTicket] - the ticket read (a test seam).
238
240
  * @property {typeof import('../util/exec.mjs').exec} [watchExec] - watch git exec seam.
239
241
  * @property {Parameters<typeof watchTick>[0]['hooks']} [watchHooks] - deterministic watch races.
@@ -277,6 +279,8 @@ export async function createWorker(opts, deps = {}) {
277
279
  let stopping = false;
278
280
  /** @type {ReturnType<typeof setInterval> | null} */
279
281
  let heartbeat = null;
282
+ /** @type {ReturnType<typeof announceWorker> | null} */
283
+ let self = null;
280
284
  /** @type {(() => void) | null} */
281
285
  let wake = null;
282
286
  /** @type {(() => void) | null} wakes the pool's wait in `drain` (stop) */
@@ -416,7 +420,7 @@ export async function createWorker(opts, deps = {}) {
416
420
  // read, advanced and saved by one ticket at a time; the later one waits here. The key
417
421
  // carries the run so two workers in one process (tests) never share a lock.
418
422
  const t = ticket;
419
- result = await keyedLock(`${runId}\0${fileLockKey(t.block, t.file)}`, () => reviewOne(id, t));
423
+ result = await withLock(`${runId}\0${fileLockKey(t.block, t.file)}`, () => reviewOne(id, t));
420
424
  }
421
425
  // Stopping: a ticket whose session our own stop killed ends `unavailable` — leave it queued
422
426
  // (no done marker) for the next worker instead of recording the kill as a failure. A ticket
@@ -532,50 +536,50 @@ export async function createWorker(opts, deps = {}) {
532
536
  }
533
537
 
534
538
  /**
535
- * B42: how many tickets run at once — `review.parallel_tickets` of the snapshot in force (so a
536
- * `run reload` resizes the pool at the next drain), else of the boot config; anything but an
537
- * integer 1–16 is the default.
538
- * @returns {Promise<number>}
539
+ * Pool settings from the snapshot in force, else the boot config. A run reload applies at the
540
+ * next drain; anything but an integer 1–16 uses the default size.
541
+ * @returns {Promise<{limit: number, fairShare: boolean}>}
539
542
  */
540
- async function poolSize() {
543
+ async function poolSettings() {
541
544
  let current = cfg;
542
545
  try {
543
546
  current = await configFor('');
544
547
  } catch {
545
- // an unreadable record: the boot config's size (each ticket still fails closed on its own)
548
+ // an unreadable record: the boot settings (each ticket still fails closed on its own)
546
549
  }
547
- return parallelTickets(current);
550
+ return { limit: parallelTickets(current), fairShare: current?.review?.fair_share === true };
548
551
  }
549
552
 
550
553
  /**
551
- * The (block, file) key of each ticket read so far, by id: a written ticket never changes (its
552
- * id is its content's hash), so it is read once for scheduling, however many wake-ups it waits.
554
+ * The block and (block, file) key of each ticket read so far, by id. A written ticket never
555
+ * changes (its id is its content's hash), so scheduling reads it once across wake-ups.
553
556
  * Dropped once the ticket is done.
554
- * @type {Map<string, string>}
557
+ * @type {Map<string, {key: string, block: string}>}
555
558
  */
556
559
  const fileKeys = new Map();
557
560
 
558
561
  /**
559
- * The (block, file) a pending ticket names, or null when it cannot be read (it is started
562
+ * The block and file-lock key a pending ticket names, or null when unreadable (it is started
560
563
  * anyway and refused by `processTicket`; a null is not cached). Only a scheduling hint:
561
564
  * `processTicket` takes the file lock on the ticket it actually read.
562
- * @param {string} id @returns {string | null}
565
+ * @param {string} id @returns {{key: string, block: string} | null}
563
566
  */
564
567
  function fileKeyOf(id) {
565
568
  const known = fileKeys.get(id);
566
569
  if (known !== undefined) return known;
567
570
  try {
568
571
  const t = loadTicket(repoRoot, id);
569
- const key = fileLockKey(t.block, t.file);
570
- fileKeys.set(id, key);
571
- return key;
572
+ const file = { key: fileLockKey(t.block, t.file), block: t.block };
573
+ fileKeys.set(id, file);
574
+ return file;
572
575
  } catch {
573
576
  return null;
574
577
  }
575
578
  }
576
579
 
577
580
  /**
578
- * Serve every pending ticket (B42): up to `review.parallel_tickets` at once, oldest first; when
581
+ * Serve every pending ticket (B42): up to `review.parallel_tickets` at once, oldest first; with
582
+ * fair share, each slot goes to the block with fewest running tickets, ties oldest first. When
579
583
  * one finishes, the queue is re-read and the next pending ticket starts (no batch barrier; the
580
584
  * queue is also re-read every `pollMs` while tickets run, so a new ticket fills a free slot).
581
585
  * A ticket whose (block, file) is already running is left pending (it does not take a slot)
@@ -600,11 +604,14 @@ export async function createWorker(opts, deps = {}) {
600
604
  }
601
605
  /** @type {number | null} */
602
606
  let limit = null;
607
+ let fairShare = false;
603
608
  let done = 0;
604
609
  /** @type {Map<string, Promise<void>>} */
605
610
  const running = new Map();
606
611
  /** @type {Set<string>} the (block, file) keys of the running tickets */
607
612
  const busy = new Set();
613
+ /** @type {Map<string, number>} running tickets per block */
614
+ const runningByBlock = new Map();
608
615
  /** @type {Set<string>} */
609
616
  const started = new Set();
610
617
  for (;;) {
@@ -630,25 +637,54 @@ export async function createWorker(opts, deps = {}) {
630
637
  writeSafe(warnStream, `worker: WARN ${text}\n`);
631
638
  try { await logWarning({ warning: 'watch_tick_failed', message: text }); } catch { /* a log failure never stops dispatch */ }
632
639
  }
633
- for (const id of pendingTickets(repoRoot)) {
634
- if (poisoned.has(id) || started.has(id)) continue;
635
- if (limit === null) limit = await poolSize();
636
- if (running.size >= limit || stopping) break;
637
- const fileKey = fileKeyOf(id);
638
- if (fileKey !== null && busy.has(fileKey)) continue; // waits without a slot
640
+ const pending = pendingTickets(repoRoot).filter((id) => !poisoned.has(id) && !started.has(id));
641
+ if (pending.length > 0 && limit === null) ({ limit, fairShare } = await poolSettings());
642
+ // Cache even unreadable hints for this tick: ranking never reads a ticket twice.
643
+ /** @type {Map<string, ReturnType<typeof fileKeyOf>>} */
644
+ const hints = new Map();
645
+ while (pending.length > 0 && running.size < limit && !stopping) {
646
+ let next = -1;
647
+ let fewest = Infinity;
648
+ for (let i = 0; i < pending.length; i += 1) {
649
+ const id = pending[i];
650
+ if (!hints.has(id)) hints.set(id, fileKeyOf(id));
651
+ const file = hints.get(id);
652
+ if (file && busy.has(file.key)) continue; // waits without a slot
653
+ const count = fairShare && file ? runningByBlock.get(file.block) ?? 0 : 0;
654
+ if (count < fewest) {
655
+ next = i;
656
+ fewest = count;
657
+ }
658
+ if (count === 0) break; // best possible count; ties stay oldest first
659
+ }
660
+ if (next === -1) break;
661
+ const [id] = pending.splice(next, 1);
662
+ const claim = await tryLock(`ticket\0${runId}\0${id}`);
663
+ if (!claim.ok) continue; // another process owns it; no pool slot is taken
664
+ // Acquisition awaited a process probe: another worker may have completed this ticket
665
+ // since the pending snapshot, or stop() may have arrived while we waited.
666
+ if (stopping || isDone(repoRoot, id)) {
667
+ claim.release();
668
+ continue;
669
+ }
670
+ const file = hints.get(id);
671
+ const fileKey = file?.key ?? null;
639
672
  started.add(id);
640
673
  activeTickets.add(id);
641
674
  if (fileKey !== null) busy.add(fileKey);
675
+ if (file) runningByBlock.set(file.block, (runningByBlock.get(file.block) ?? 0) + 1);
642
676
  const slot = runOne(id)
643
677
  .then((n) => {
644
678
  done += n;
645
679
  })
646
680
  .finally(() => {
681
+ claim.release(); // the done marker is published before the claim is released
647
682
  // the slot is freed on every exit path (`runOne` never rejects)
648
683
  running.delete(id);
649
684
  activeTickets.delete(id);
650
685
  fileKeys.delete(id);
651
686
  if (fileKey !== null) busy.delete(fileKey);
687
+ if (file) runningByBlock.set(file.block, runningByBlock.get(file.block) - 1);
652
688
  });
653
689
  running.set(id, slot);
654
690
  }
@@ -684,7 +720,8 @@ export async function createWorker(opts, deps = {}) {
684
720
  * must still see the worker as live the whole time.
685
721
  */
686
722
  function announce() {
687
- const self = announceWorker(repoRoot, { pid: process.pid, run: runId });
723
+ if (self !== null) return; // boot already announced under the lock
724
+ self = announceWorker(repoRoot, { pid: process.pid, run: runId });
688
725
  stopBeating();
689
726
  const timer = setInterval(() => {
690
727
  try {
@@ -749,12 +786,16 @@ export async function createWorker(opts, deps = {}) {
749
786
  /** Withdraw this process's announcement (`worker.json`) if it is still ours. */
750
787
  function release() {
751
788
  stopBeating();
752
- retractWorker(repoRoot, process.pid);
789
+ if (self !== null) retractWorker(repoRoot, self.pid, self.start_time);
790
+ self = null;
753
791
  }
754
792
 
755
- return { processTicket, drain, run, once, stop, release, watchTick: tickWatch, jevKeyResolved: jevKey !== null, sessionEnv: childEnv };
793
+ return { processTicket, drain, run, once, stop, release, announce, watchTick: tickWatch, jevKeyResolved: jevKey !== null, sessionEnv: childEnv };
756
794
  }
757
795
 
796
+ /** This block permits one registered worker per queue. */
797
+ const WORKER_LIMIT = 1;
798
+
758
799
  /**
759
800
  * Start-up for the `worker` verb: run record, repo root, config, run temp root (owned by this
760
801
  * process), run key; refuses when another live worker already serves this queue.
@@ -777,13 +818,22 @@ export async function bootWorker(opts, deps = {}) {
777
818
  if (!loaded.ok) throw new WorkerError('config', loaded.message ?? 'the project config cannot be loaded');
778
819
  cfg = loaded.config;
779
820
  }
780
- const other = liveWorker(repoRoot);
781
- if (other && other.pid !== process.pid) throw new WorkerError('worker-running', `worker pid ${other.pid} already serves this queue`);
782
821
  const tmpRoot = typeof cfg?.tmp?.root === 'string' ? cfg.tmp.root : undefined;
783
822
  const runRootDir = runRoot(opts.runId, { root: tmpRoot });
784
823
  setRunRoot(runRootDir);
785
824
  const key = await loadKey(opts.runId);
786
825
  const worker = await createWorker({ runId: opts.runId, repoRoot, cfg, runRootDir, slug: record.project, key, pollMs: opts.pollMs, guardRunRecordPath: runRecordPath(opts.runId) }, deps);
826
+ deps.hooks?.beforeLock?.();
827
+ await withLock(`worker-boot:${repoRoot}`, async () => {
828
+ const entries = workerRegistry(repoRoot, { prune: true });
829
+ let count = entries.filter((entry) => entry.live).length;
830
+ // Older workers have only worker.json; do not boot over a live legacy owner.
831
+ const legacy = liveWorker(repoRoot);
832
+ if (legacy && !entries.some((entry) => entry.worker?.pid === legacy.pid && entry.worker.start_time === legacy.start_time)) count++;
833
+ await deps.hooks?.afterCount?.(count);
834
+ if (count >= WORKER_LIMIT) throw new WorkerError('worker-running', 'a live or unreadable worker entry already serves this queue');
835
+ worker.announce();
836
+ });
787
837
  return { ...worker, repoRoot, runRootDir };
788
838
  }
789
839
 
@@ -826,7 +876,7 @@ export async function launchWorker({ runId, workspace, env = process.env, timeou
826
876
  child.unref();
827
877
  const deadline = Date.now() + timeoutMs;
828
878
  while (Date.now() < deadline) {
829
- if (typeof pid === 'number' && liveWorker(repoRoot)?.pid === pid) return { pid, repoRoot };
879
+ if (typeof pid === 'number' && liveWorker(repoRoot, { pid, registeredOnly: true })) return { pid, repoRoot };
830
880
  if (child.exitCode !== null || child.signalCode !== null) break;
831
881
  await new Promise((resolve) => setTimeout(resolve, 50));
832
882
  }
@@ -5,6 +5,7 @@
5
5
  * <repo>/.code-forge/queue/<ticket>.json the ticket (written by `review-file`, idempotent)
6
6
  * <repo>/.code-forge/queue/<ticket>.done the worker's done marker `{ticket, status, result}`
7
7
  * <repo>/.code-forge/queue/worker.json the worker's announcement `{pid, start_time, run, started_at, heartbeat_at}`
8
+ * <repo>/.code-forge/workers/<pid>-<start_time>.json each worker's private registry entry
8
9
  * <repo>/.code-forge/reviews/<run>/<ticket>.json the SIGNED result (run key HMAC, §8.6)
9
10
  *
10
11
  * The forbidden list (§8.4 ★) denies a coder any write under `reviews/` and to `queue/*.done`;
@@ -28,7 +29,7 @@
28
29
  */
29
30
 
30
31
  import { createHash, randomBytes } from 'node:crypto';
31
- import { existsSync, linkSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
32
+ import { chmodSync, existsSync, linkSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
32
33
  import path from 'node:path';
33
34
  import { loadKey, signRow, verifyRow } from '../state/signer.mjs';
34
35
  import { isAlive, readStartTime, UNKNOWN_START_TIME } from '../util/reaper.mjs';
@@ -44,15 +45,19 @@ export const HEARTBEAT_STALE_MS = 60_000;
44
45
 
45
46
  /** @param {string} repoRoot */
46
47
  export const queueDir = (repoRoot) => path.join(repoRoot, '.code-forge', 'queue');
48
+ /** @param {string} repoRoot */
49
+ const workersDir = (repoRoot) => path.join(repoRoot, '.code-forge', 'workers');
50
+ /** @param {string} repoRoot @param {{pid: number, start_time: string}} self */
51
+ const workerPath = (repoRoot, self) => path.join(workersDir(repoRoot), `${self.pid}-${self.start_time}.json`);
47
52
  /** @param {string} repoRoot @param {string} runId */
48
53
  export const reviewsDir = (repoRoot, runId) => path.join(repoRoot, '.code-forge', 'reviews', runId);
49
54
 
50
- /** @param {string} file @param {string} text */
51
- function writeAtomic(file, text) {
55
+ /** @param {string} file @param {string} text @param {number} [mode] */
56
+ function writeAtomic(file, text, mode = 0o644) {
52
57
  mkdirSync(path.dirname(file), { recursive: true });
53
58
  const tmp = `${file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
54
59
  try {
55
- writeFileSync(tmp, text, { mode: 0o644 });
60
+ writeFileSync(tmp, text, { mode });
56
61
  renameSync(tmp, file);
57
62
  } catch (err) {
58
63
  rmSync(tmp, { force: true });
@@ -427,6 +432,36 @@ export function blockTickets({ repoRoot, run, block, tree = null }) {
427
432
  .sort((a, b) => a.enqueued_at.localeCompare(b.enqueued_at) || a.ticket.localeCompare(b.ticket));
428
433
  }
429
434
 
435
+ /**
436
+ * Atomically record the join's findings handoff once. Read only this marker, never a result.
437
+ * @param {{repoRoot: string, block: string, file: string, ticket: string}} opts
438
+ * @returns {boolean} true only for the join that first delivered the findings.
439
+ */
440
+ export function deliverBlockFindings({ repoRoot, block, file, ticket }) {
441
+ const marker = path.join(queueDir(repoRoot), `${assertTicketId(ticket)}.delivered`);
442
+ const expected = { block, file, ticket };
443
+ const delivered = () => {
444
+ try {
445
+ const seen = JSON.parse(readFileSync(marker, 'utf8'));
446
+ if (seen?.block === block && seen.file === file && seen.ticket === ticket) return true;
447
+ } catch (err) {
448
+ if (err.code === 'ENOENT') return false;
449
+ }
450
+ throw new WorkerError('result-unreadable', 'a findings delivery marker cannot be read');
451
+ };
452
+ if (delivered()) return false;
453
+ const tmp = `${marker}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
454
+ try {
455
+ writeFileSync(tmp, `${JSON.stringify(expected)}\n`, { flag: 'wx' });
456
+ try { linkSync(tmp, marker); } catch (err) {
457
+ if (err.code !== 'EEXIST') throw err;
458
+ if (delivered()) return false;
459
+ throw new WorkerError('result-unreadable', 'a findings delivery marker disappeared');
460
+ }
461
+ return true;
462
+ } finally { rmSync(tmp, { force: true }); }
463
+ }
464
+
430
465
  /**
431
466
  * Read and re-validate a ticket (coder-writable): its id must match its content.
432
467
  * @param {string} repoRoot @param {string} id
@@ -468,7 +503,8 @@ export function writeResult({ repoRoot, runId, ticket, result, key }) {
468
503
  const signed = signRow({ ...result, run: runId, ticket: assertTicketId(ticket) }, key);
469
504
  const file = path.join(reviewsDir(repoRoot, runId), `${ticket}.json`);
470
505
  writeAtomic(file, `${JSON.stringify(signed)}\n`);
471
- writeAtomic(path.join(queueDir(repoRoot), `${ticket}.done`), `${JSON.stringify({ ticket, status: signed.status, result: path.relative(repoRoot, file) })}\n`);
506
+ const openFindings = signed.status === 'reviewed' && signed.approved !== true && Array.isArray(signed.findings) && signed.findings.length > 0;
507
+ writeAtomic(path.join(queueDir(repoRoot), `${ticket}.done`), `${JSON.stringify({ ticket, status: signed.status, ...(openFindings ? { open_findings: true } : {}), result: path.relative(repoRoot, file) })}\n`);
472
508
  return signed;
473
509
  }
474
510
 
@@ -497,20 +533,25 @@ export async function verifyResult(repoRoot, runId, ticket) {
497
533
  }
498
534
 
499
535
  /**
500
- * Announce the serving worker in the queue directory (what `review-file` checks for liveness).
536
+ * Announce the serving worker in the private registry and legacy queue file.
501
537
  * @param {string} repoRoot @param {{pid: number, run: string}} who
502
538
  */
503
539
  export function announceWorker(repoRoot, { pid, run }) {
504
540
  const now = new Date().toISOString();
505
541
  const body = { pid, start_time: readStartTime(pid) ?? UNKNOWN_START_TIME, run, started_at: now, heartbeat_at: now };
506
- writeAtomic(path.join(queueDir(repoRoot), WORKER_FILE), `${JSON.stringify(body)}\n`);
542
+ mkdirSync(workersDir(repoRoot), { recursive: true, mode: 0o700 });
543
+ chmodSync(workersDir(repoRoot), 0o700);
544
+ writeAtomic(workerPath(repoRoot, body), `${JSON.stringify(body)}\n`, 0o600);
545
+ const legacy = readWorker(repoRoot);
546
+ if (!legacy || !workerLive(legacy)) writeAtomic(path.join(queueDir(repoRoot), WORKER_FILE), `${JSON.stringify(body)}\n`);
507
547
  return body;
508
548
  }
509
549
 
510
550
  /**
511
- * Refresh the announcement's `heartbeat_at` (B30) while it is still THIS worker's: the file is
551
+ * Refresh both announcements' `heartbeat_at` (B30) while each is still THIS worker's: each file is
512
552
  * re-read right before the write and must name the same `pid` AND `start_time` the worker
513
- * announced. Another worker's announcement is never overwritten (the caller stops beating).
553
+ * announced. Another worker's announcement is never overwritten. A worker keeps beating its
554
+ * registry entry even when another worker owns worker.json.
514
555
  * The beat is written to a temp file, the announcement re-read and compared once more, and only
515
556
  * then renamed over it. The worker calls this from a timer of its own, so it keeps beating while
516
557
  * a long review session is awaited.
@@ -520,23 +561,23 @@ export function announceWorker(repoRoot, { pid, run }) {
520
561
  */
521
562
  export function beatWorker(repoRoot, self, now = new Date()) {
522
563
  const mine = (/** @type {Record<string, any> | null} */ w) => w?.pid === self.pid && w.start_time === self.start_time;
523
- const w = readWorker(repoRoot);
524
- if (!mine(w)) return false;
525
- const file = path.join(queueDir(repoRoot), WORKER_FILE);
526
- const tmp = `${file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
527
- try {
528
- writeFileSync(tmp, `${JSON.stringify({ ...w, heartbeat_at: now.toISOString() })}\n`, { mode: 0o644 });
529
- // re-read right before the rename: an announcement that changed meanwhile is never replaced
530
- if (!mine(readWorker(repoRoot))) {
564
+ let written = false;
565
+ const registryFile = workerPath(repoRoot, self);
566
+ for (const file of [registryFile, path.join(queueDir(repoRoot), WORKER_FILE)]) {
567
+ const w = readJSON(file);
568
+ if (!mine(w)) continue;
569
+ const tmp = `${file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
570
+ try {
571
+ writeFileSync(tmp, `${JSON.stringify({ ...w, heartbeat_at: now.toISOString() })}\n`, { mode: file === registryFile ? 0o600 : 0o644 });
572
+ // Re-read before rename; another owner's announcement is never replaced.
573
+ if (!mine(readJSON(file))) continue;
574
+ renameSync(tmp, file);
575
+ written = true;
576
+ } finally {
531
577
  rmSync(tmp, { force: true });
532
- return false;
533
578
  }
534
- renameSync(tmp, file);
535
- return true;
536
- } catch (err) {
537
- rmSync(tmp, { force: true });
538
- throw err;
539
579
  }
580
+ return written;
540
581
  }
541
582
 
542
583
  /** A heartbeat further than this in the future is not fresh (clock skew tolerance). */
@@ -560,9 +601,59 @@ export function readWorker(repoRoot) {
560
601
  return w && Number.isInteger(w.pid) && w.pid > 1 && typeof w.run === 'string' ? w : null;
561
602
  }
562
603
 
563
- /** Remove the announcement if it is still `pid`'s. @param {string} repoRoot @param {number} pid */
564
- export function retractWorker(repoRoot, pid) {
565
- if (readWorker(repoRoot)?.pid === pid) rmSync(path.join(queueDir(repoRoot), WORKER_FILE), { force: true });
604
+ /** Remove only this owner's announcements. @param {string} repoRoot @param {number} pid @param {string} [startTime] */
605
+ export function retractWorker(repoRoot, pid, startTime) {
606
+ const mine = (w) => w?.pid === pid && (startTime === undefined || w.start_time === startTime);
607
+ for (const entry of workerRegistry(repoRoot)) {
608
+ if (mine(entry.worker)) rmSync(entry.file, { force: true });
609
+ }
610
+ if (mine(readWorker(repoRoot))) rmSync(path.join(queueDir(repoRoot), WORKER_FILE), { force: true });
611
+ }
612
+
613
+ /** @typedef {{pid: number, start_time: string, run: string, started_at: string, heartbeat_at: string}} WorkerAnnouncement */
614
+
615
+ /** Bad entries remain occupied until a human fixes them. @param {any} w @returns {w is WorkerAnnouncement} */
616
+ function workerShape(w) {
617
+ return w && Number.isInteger(w.pid) && w.pid > 1 && typeof w.start_time === 'string' && w.start_time.length > 0
618
+ && !/[\\/]/.test(w.start_time) && typeof w.run === 'string' && w.run.length > 0
619
+ && typeof w.started_at === 'string' && Number.isFinite(Date.parse(w.started_at))
620
+ && typeof w.heartbeat_at === 'string' && Number.isFinite(Date.parse(w.heartbeat_at));
621
+ }
622
+
623
+ /** @param {{pid: number, start_time: string, heartbeat_at?: string}} w @param {number} [nowMs] */
624
+ function workerLive(w, nowMs) {
625
+ if (!isAlive(w.pid)) return false;
626
+ if (heartbeatFresh(w, nowMs)) return true;
627
+ return w.start_time === UNKNOWN_START_TIME || readStartTime(w.pid) === w.start_time;
628
+ }
629
+
630
+ /** @param {{pid: number, start_time: string, heartbeat_at?: string}} w @param {number} [nowMs] */
631
+ function workerLiveAtBoot(w, nowMs) {
632
+ if (!isAlive(w.pid)) return false;
633
+ const startTime = readStartTime(w.pid);
634
+ // Boot trusts orchestrator ps; sandbox readers let fresh heartbeats mask unreliable ps.
635
+ if (startTime !== null && w.start_time !== UNKNOWN_START_TIME) return startTime === w.start_time;
636
+ return heartbeatFresh(w, nowMs) || w.start_time === UNKNOWN_START_TIME;
637
+ }
638
+
639
+ /** Read all entries, keeping unreadable/bad entries occupied. Only proven stale owners may be pruned.
640
+ * @param {string} repoRoot @param {{prune?: boolean, nowMs?: number}} [opts]
641
+ * @returns {Array<{file: string, worker: WorkerAnnouncement | null, live: boolean}>}
642
+ */
643
+ export function workerRegistry(repoRoot, opts = {}) {
644
+ let names;
645
+ try { names = readdirSync(workersDir(repoRoot)); }
646
+ catch (err) { if (err.code === 'ENOENT') return []; throw err; }
647
+ const entries = [];
648
+ for (const name of names.sort().filter((n) => n.endsWith('.json'))) {
649
+ const file = path.join(workersDir(repoRoot), name);
650
+ const w = readJSON(file);
651
+ const worker = workerShape(w) && name === `${w.pid}-${w.start_time}.json` ? w : null;
652
+ const live = worker === null || (opts.prune ? workerLiveAtBoot(worker, opts.nowMs) : workerLive(worker, opts.nowMs));
653
+ if (!live && opts.prune) rmSync(file, { force: true });
654
+ else entries.push({ file, worker, live });
655
+ }
656
+ return entries;
566
657
  }
567
658
 
568
659
  /**
@@ -574,13 +665,14 @@ export function retractWorker(repoRoot, pid) {
574
665
  * Accepted trade-off: a worker that is SIGKILLed (no retraction) leaves a heartbeat that stays
575
666
  * fresh for up to `HEARTBEAT_STALE_MS` (60 s); if the OS reuses its pid inside that window, the
576
667
  * queue reads as served until the heartbeat goes stale. A clean stop retracts the file at once.
577
- * @param {string} repoRoot @param {{nowMs?: number}} [opts]
668
+ * Registry entries take precedence; worker.json supports workers from older versions.
669
+ * @param {string} repoRoot @param {{nowMs?: number, pid?: number, registeredOnly?: boolean}} [opts]
578
670
  */
579
671
  export function liveWorker(repoRoot, opts = {}) {
672
+ const entry = workerRegistry(repoRoot, opts).find((e) => e.live && e.worker && (opts.pid === undefined || e.worker.pid === opts.pid));
673
+ if (entry) return entry.worker;
674
+ if (opts.registeredOnly) return null;
580
675
  const w = readWorker(repoRoot);
581
- if (!w || !isAlive(w.pid)) return null;
582
- if (heartbeatFresh(w, opts.nowMs)) return w;
583
- if (w.start_time !== UNKNOWN_START_TIME && readStartTime(w.pid) !== w.start_time) return null;
584
- return w;
676
+ return w && (opts.pid === undefined || w.pid === opts.pid) && workerLive(w, opts.nowMs) ? w : null;
585
677
  }
586
678
 
@@ -99,10 +99,12 @@ export async function watchTick({ repoRoot, runId, cfg, readRun: loadRun = readR
99
99
  if (unreadable) { await skip('unreadable_head'); continue; }
100
100
  await hooks.beforeQueue?.();
101
101
  for (const { file, content_hash } of hashes) {
102
- const current = enqueueHash({ repoRoot, run: runId, block, file, content_hash, tree });
102
+ const held = earlier.some((old) => old.file === file);
103
+ if (!held) enqueueHash({ repoRoot, run: runId, block, file, content_hash, tree });
103
104
  for (const old of earlier) {
104
- if (old.file === file && old.ticket !== current.ticket && !running.has(old.ticket) && !isDone(repoRoot, old.ticket)) supersedeTicket(repoRoot, old.ticket);
105
+ if (old.file === file && old.content_hash !== content_hash && !running.has(old.ticket) && !isDone(repoRoot, old.ticket)) supersedeTicket(repoRoot, old.ticket);
105
106
  }
107
+ if (held) await writeRow({ event: 'watch.held', block, file, content_hash });
106
108
  }
107
109
  save({ ...state, run: runId, base, tree, sha: head, skipped: [] });
108
110
  } catch {