@codedology/code-forge 0.15.4 → 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 (39) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +1 -1
  3. package/native/bin/darwin-arm64/code-forge-native +0 -0
  4. package/native/bin/darwin-x64/code-forge-native +0 -0
  5. package/native/bin/linux-arm64/code-forge-native +0 -0
  6. package/native/bin/linux-x64/code-forge-native +0 -0
  7. package/package.json +4 -2
  8. package/schema/facts.schema.json +1 -1
  9. package/skill/SKILL.md +1 -1
  10. package/skill/references/adapters/solo.md +1 -1
  11. package/skill/references/decisions.md +1 -1
  12. package/skill/references/plan.md +14 -5
  13. package/skill/references/review.md +1 -1
  14. package/skill/scripts/forge +1 -1
  15. package/skill/templates/facts-sheet.md +9 -3
  16. package/src/cli/review-file.mjs +5 -0
  17. package/src/cli/spawn.mjs +26 -22
  18. package/src/cli/status.mjs +37 -5
  19. package/src/decide/escalation.mjs +5 -5
  20. package/src/gates/dependents.mjs +200 -0
  21. package/src/gates/expand.mjs +83 -26
  22. package/src/gates/run.mjs +76 -14
  23. package/src/ledger/tail.mjs +89 -0
  24. package/src/review/definitions.mjs +170 -0
  25. package/src/review/fixloop.mjs +4 -4
  26. package/src/review/gate-check.mjs +20 -4
  27. package/src/review/packet.mjs +58 -20
  28. package/src/session/facts.mjs +234 -64
  29. package/src/session/spawn.mjs +70 -19
  30. package/src/state/block-end.mjs +3 -1
  31. package/src/state/block.mjs +17 -5
  32. package/src/state/run.mjs +12 -5
  33. package/src/util/native.mjs +2 -0
  34. package/src/worker/engine.mjs +11 -26
  35. package/src/worker/loop.mjs +6 -4
  36. package/src/worker/queue.mjs +2 -1
  37. package/src/worker/review-state.mjs +15 -3
  38. package/src/worker/watch.mjs +92 -89
  39. package/src/worker/worker-warning.mjs +35 -0
@@ -25,6 +25,7 @@ import path from 'node:path';
25
25
  import { runSpend } from '../ledger/spend.mjs';
26
26
  import { readAllRows } from '../ledger/write.mjs';
27
27
  import { exec } from '../util/exec.mjs';
28
+ import { daemonRequest } from '../util/native.mjs';
28
29
  import { assertRunId, runsDir } from './paths.mjs';
29
30
  import { withRunLock, writeSigned } from './run.mjs';
30
31
 
@@ -113,7 +114,8 @@ export async function writeDoneFile({ record, id, status, event, reason = null,
113
114
  /** @type {Record<string, any>[] | null} */
114
115
  let rows = null;
115
116
  try {
116
- rows = await readAllRows(record.project);
117
+ const reply = await daemonRequest(runId, { method: 'rows', block: id }, { config: record.config?.snapshots?.[record.config?.hash] });
118
+ rows = reply?.rows ?? await readAllRows(record.project);
117
119
  } catch {
118
120
  rows = null;
119
121
  }
@@ -613,8 +613,20 @@ async function rebaseWorktreeBlock({ runId, id, writeRow, acceptChanged, reason,
613
613
  const before = closed ? await committedBlobs(tree, reviewedHead, files) : null;
614
614
  const moved = { old_base: block.base_sha, new_base: newBase };
615
615
  try {
616
- const rebased = await exec(['git', '-C', tree, 'rebase', newBase], { cwd: record.workspace, env: gitEnv(), timeoutMs: 30000 });
617
- if (rebased.result !== 'ok') throw new StateError('rebase-conflict', 'rebase-conflict');
616
+ try {
617
+ // Read every original identity once. Exclude commits already in the new base, including
618
+ // upstream commits retained by a pending rebase check. Global config and GIT_* stay scrubbed.
619
+ const commits = await git(['git', 'log', '--format=%H%x00%cn%x00%ce', '--reverse', `${block.base_sha}..${oldHead}`, '--not', newBase], tree);
620
+ await git(['git', 'reset', '--hard', newBase], tree);
621
+ for (const commit of commits.stdout.trimEnd().split('\n').filter(Boolean)) {
622
+ const [sha, committerName, committerEmail] = commit.split('\0');
623
+ // No -m: cherry-pick refuses merge commits. An original empty commit is still a commit.
624
+ const replayed = await exec(['git', '-C', tree, 'cherry-pick', '--allow-empty', sha], { cwd: record.workspace, env: { ...gitEnv(), GIT_COMMITTER_NAME: committerName, GIT_COMMITTER_EMAIL: committerEmail }, timeoutMs: 30000 });
625
+ if (replayed.result !== 'ok') throw new StateError('rebase-conflict', 'rebase-conflict');
626
+ }
627
+ } catch {
628
+ throw new StateError('rebase-conflict', 'rebase-conflict');
629
+ }
618
630
  const after = before === null ? null : await committedBlobs(tree, 'HEAD', files);
619
631
  const changed = after === null ? [] : files.filter((file) => before.get(file) !== after.get(file));
620
632
  const repoRoot = await repoRootOf(record.workspace);
@@ -652,9 +664,9 @@ async function rebaseWorktreeBlock({ runId, id, writeRow, acceptChanged, reason,
652
664
  await saveRun(record);
653
665
  return moved;
654
666
  } catch (err) {
655
- // --abort restores a conflicted rebase; after a completed rebase there is nothing to
656
- // abort, and reset restores the old branch head. The tree was clean before either.
657
- await exec(['git', '-C', tree, 'rebase', '--abort'], { cwd: record.workspace, env: gitEnv(), timeoutMs: 30000 });
667
+ // Abort a conflicted replay; after a completed replay there is nothing to abort.
668
+ // Reset restores the exact old branch head. The tree was clean before either.
669
+ await exec(['git', '-C', tree, 'cherry-pick', '--abort'], { cwd: record.workspace, env: gitEnv(), timeoutMs: 30000 });
658
670
  await git(['git', 'reset', '--hard', oldHead], tree);
659
671
  throw err;
660
672
  }
package/src/state/run.mjs CHANGED
@@ -26,6 +26,11 @@ import { generateKey, loadKey, signRow } from './signer.mjs';
26
26
  const LOCK_TIMEOUT_MS = 5000;
27
27
  const LOCK_POLL_MS = 25;
28
28
 
29
+ /** @param {string} text @returns {string | null} */
30
+ export function normalizeStartTime(text) {
31
+ return text.trim().replace(/\s+/g, ' ') || null;
32
+ }
33
+
29
34
  /**
30
35
  * The process start time of `pid` (`ps -o lstart=`), or null when no such process exists.
31
36
  * @type {StartTimeProbe}
@@ -37,8 +42,7 @@ export async function processStartTime(pid) {
37
42
  okExitCodes: [0, 1],
38
43
  timeoutMs: 10000,
39
44
  });
40
- const text = res.stdout.trim();
41
- return res.result === 'ok' && res.code === 0 && text.length > 0 ? text : null;
45
+ return res.result === 'ok' && res.code === 0 ? normalizeStartTime(res.stdout) : null;
42
46
  }
43
47
 
44
48
  /** @param {unknown} value @returns {string} */
@@ -348,7 +352,10 @@ export async function checkWorkerPin(record, { livePid, probe = processStartTime
348
352
  return { ok: false, event: 'worker.replaced', reason: 'the live worker does not match a pinned worker' };
349
353
  }
350
354
  if (livePid === undefined) {
351
- const live = await Promise.all(pins.map(async (p) => (await probe(p.pid)) === p.started_at));
355
+ const live = await Promise.all(pins.map(async (p) => {
356
+ const startedAt = await probe(p.pid);
357
+ return startedAt !== null && startedAt === normalizeStartTime(p.started_at);
358
+ }));
352
359
  return live.some(Boolean) ? { ok: true } : { ok: false, event: 'worker.down', reason: 'no pinned worker is running' };
353
360
  }
354
361
  pin = pins.find((p) => p.pid === livePid);
@@ -360,7 +367,7 @@ export async function checkWorkerPin(record, { livePid, probe = processStartTime
360
367
  }
361
368
  const startedAt = await probe(pin.pid);
362
369
  if (startedAt === null) return { ok: false, event: 'worker.down', reason: `pinned worker pid ${pin.pid} is not running` };
363
- if (startedAt !== pin.started_at) {
370
+ if (startedAt !== normalizeStartTime(pin.started_at)) {
364
371
  return { ok: false, event: 'worker.replaced', reason: `pid ${pin.pid} was restarted (start time differs from the pin)` };
365
372
  }
366
373
  return { ok: true };
@@ -378,7 +385,7 @@ export async function stopPinnedWorker(pin, { probe = processStartTime, kill = (
378
385
  if (!pin || !isSignalablePid(pin.pid)) return { stopped: false, reason: 'invalid pid' };
379
386
  const startedAt = await probe(pin.pid);
380
387
  if (startedAt === null) return { stopped: false, reason: 'not running' };
381
- if (startedAt !== pin.started_at) return { stopped: false, reason: 'pid reused' };
388
+ if (startedAt !== normalizeStartTime(pin.started_at)) return { stopped: false, reason: 'pid reused' };
382
389
  try {
383
390
  kill(pin.pid, 'SIGTERM');
384
391
  } catch (err) {
@@ -83,6 +83,8 @@ export async function daemonRequest(run, request, { config, timeoutMs = request.
83
83
  socket.write(`${JSON.stringify(request)}\n`);
84
84
  } else {
85
85
  const { stats, ...result } = answer;
86
+ if (request.method === 'rows' && (!Array.isArray(result.rows)
87
+ || !result.rows.every((row) => row !== null && typeof row === 'object' && !Array.isArray(row)))) return finish(null);
86
88
  if (request.method === 'wait-block' && (result.block !== request.block || !Array.isArray(result.files) || typeof result.done !== 'boolean'
87
89
  || !result.files.every((/** @type {any} */ file) => file && typeof file.file === 'string' && typeof file.status === 'string'))) return finish(null);
88
90
  if (request.method === 'status' && (!Array.isArray(result.runs) || result.runs.length !== 1 || result.runs[0]?.run_id !== run || !Array.isArray(result.runs[0]?.blocks))) return finish(null);
@@ -108,7 +108,7 @@ import { ledgerSchemaHistory } from '../review/schema-fallback.mjs';
108
108
  import { verifyRow } from '../state/signer.mjs';
109
109
  import { redact } from '../util/redact.mjs';
110
110
  import { withLock } from '../util/file-locks.mjs';
111
- import { blockAnchors, blockRungUsed, loadState, RESTORED_EVENT, saveState } from './review-state.mjs';
111
+ import { fileRungUsed, loadState, RESTORED_EVENT, saveState } from './review-state.mjs';
112
112
  import { assertAnswers, enqueueHash, isDone, reviewExclusionFor, writeResult } from './queue.mjs';
113
113
  import { assertTicketId, contentHash, readRegularFileNoFollow, WorkerError } from './ticket.mjs';
114
114
 
@@ -527,7 +527,7 @@ export function blockPeerDiffs(repoRoot, { base, owned, ownedKinds, file }) {
527
527
  }
528
528
 
529
529
  /**
530
- * The key of a block's lock (B42): the block's budget row and its L3 rung are taken under it.
530
+ * The key of a block's lock (B42): the budget row and each file's L3 rung are taken under it.
531
531
  * It carries the run, so two runs in one process never share a lock.
532
532
  * @param {string} runId @param {string} block @returns {string}
533
533
  */
@@ -537,33 +537,18 @@ export const blockLockKey = (runId, block) => `block:${runId}\0${block}`;
537
537
  export const LOST_MESSAGE = 'the review state file is gone and the ledger cannot rebuild it (its anchor carries no state_body); the file is not reviewed again from round 1 — the owner can run `code-forge block reopen`';
538
538
 
539
539
  /**
540
- * Whether the block's one L3 rung is taken: a verified anchor says so (`blockRungUsed`), OR another
541
- * file of the block has a saved state whose round decided `next: patch` — that round took the rung,
542
- * but its anchor records `l3_rung_used` only once the patch_check ticket saves. A sibling state that
543
- * does not load is counted as taken (fail closed; its own ticket refuses it).
540
+ * Whether this file's L3 rung is taken, derived from its verified anchors (`fileRungUsed`).
541
+ * A pending `next: patch` on this file is handled by its patch_check ticket; sibling files
542
+ * have their own rungs.
544
543
  * @param {import('./review-state.mjs').Where} where
545
544
  * @returns {{status: 'ok' | 'tampered', used: boolean}}
546
545
  */
547
546
  export function rungTaken(where) {
548
- const rung = blockRungUsed(where);
549
- if (rung.status === 'tampered' || rung.used) return rung;
550
- const anchors = blockAnchors(where) ?? [];
551
- const siblings = new Set(anchors.map((a) => a.file).filter((f) => typeof f === 'string' && f !== where.file));
552
- for (const file of siblings) {
553
- let other;
554
- try {
555
- other = loadState({ ...where, file, migrate: false }); // B69: a sibling's old file is read, never moved here
556
- } catch {
557
- return { status: 'ok', used: true };
558
- }
559
- // a sibling that would be restored (B68) counts by its rebuilt state; lost or tampered fails closed
560
- if ((other.status !== 'ok' && other.status !== 'restored') || other.state?.next?.action === 'patch') return { status: 'ok', used: true };
561
- }
562
- return rung;
547
+ return fileRungUsed(where);
563
548
  }
564
549
 
565
550
  /**
566
- * Whether this round could take the block's L3 rung (so it must run under the block lock): never
551
+ * Whether this round could take the file's L3 rung (so it must run under the block lock): never
567
552
  * when the rung is taken or the round is the patch_check (the rung is already this file's); never
568
553
  * for a file's first full round while the cap and the per-level ladder are both above 1 (round 1
569
554
  * cannot stall, reach the cap or exhaust the ladder); else conservatively yes.
@@ -586,9 +571,9 @@ export function mayTakeRung(state, kind, cfg, rungUsed) {
586
571
  * Concurrency (B42): the worker runs several tickets at once but never two for the same
587
572
  * (block, file) (its file lock, `loop.mjs`), so this file's state `seq` is read and saved by one
588
573
  * ticket at a time. What the files of a BLOCK share is taken under the block lock
589
- * (`blockLockKey`): the budget row's check-then-write (written once per block) and the L3 rung —
574
+ * (`blockLockKey`): the budget row's check-then-write (written once per block) and each file's L3 rung —
590
575
  * the rung read and, for a round that could take the rung (`mayTakeRung`), the whole round and its
591
- * state save, so two files of one block never both take it. A round that cannot take the rung (a
576
+ * state save, so workers cannot take the same file's rung twice. A round that cannot take the rung (a
592
577
  * first full round, or any round once the rung is gone) runs outside the block lock, in parallel
593
578
  * with the block's other files.
594
579
  * @param {import('./queue.mjs').Ticket} ticket @param {ReviewContext} ctx
@@ -636,7 +621,7 @@ async function fixLoopRound(ticket, ctx, { base, level, owned, ownedKinds, block
636
621
  if (row.event === 'review.state' && row.file === ticket.file) anchoredAt = i;
637
622
  });
638
623
  // A changed review input starts a full round, including previously stopped files. Keep
639
- // the anchor sequence and the block's spent L3 rung; tampered state still refuses above.
624
+ // the anchor sequence and the file's spent L3 rung; tampered state still refuses above.
640
625
  const reopened = amendedAt > anchoredAt || reopenRowFor(rows, reopenAt) !== null;
641
626
  if (loaded.status === 'lost' && !reopened) return { outcome: /** @type {ReviewOutcome} */ ({ status: 'refused', reason: 'review-state-lost', message: LOST_MESSAGE, approved: false, engine: 'adaptive', sessions: [] }) };
642
627
  let seq = loaded.seq;
@@ -664,7 +649,7 @@ async function fixLoopRound(ticket, ctx, { base, level, owned, ownedKinds, block
664
649
  if (state.next?.action === 'retry') kind = state.pending_kind ?? kind;
665
650
  else if (state.next?.action === 'patch') {
666
651
  kind = 'patch_check';
667
- state.l3_rung_used = true; // the orchestrator ran the block's one L3 patch before this ticket
652
+ state.l3_rung_used = true; // the orchestrator ran this file's one L3 patch before this ticket
668
653
  }
669
654
  const fixed = state;
670
655
  const round = () => playRound(ticket, ctx, { state: fixed, kind, where, seq, base, blockKindOf, workDir, factsExcerpt, rulesDigest, extraRounds, peerDiffs, fileContext });
@@ -44,7 +44,8 @@ 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';
47
+ import { createTailReader } from '../ledger/tail.mjs';
48
+ import { appendRow } from '../ledger/write.mjs';
48
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';
@@ -243,6 +244,7 @@ export function recordedVerdict(result) {
243
244
  * @property {{beforeLock?: () => void, afterCount?: (count: number) => Promise<unknown>}} [hooks] - deterministic boot races.
244
245
  * @property {typeof readTicket} [readTicket] - the ticket read (a test seam).
245
246
  * @property {typeof import('../util/exec.mjs').exec} [watchExec] - watch git exec seam.
247
+ * @property {() => number} [watchNow] - watch debounce clock (default: Date.now).
246
248
  * @property {Parameters<typeof watchTick>[0]['hooks']} [watchHooks] - deterministic watch races.
247
249
  * @property {import('../keys/store.mjs').KeyStore} [store] - default: B2's production store.
248
250
  * @property {any} [opRead]
@@ -250,7 +252,7 @@ export function recordedVerdict(result) {
250
252
  * @property {Partial<Record<'claude'|'codex'|'grok', string>>} [bins]
251
253
  * @property {(row: Record<string, any>) => Promise<unknown>} [writeRow] - default: B6 `appendRow`.
252
254
  * @property {() => Promise<Array<Record<string, any>>>} [readRows] - the ledger rows the fix-loop
253
- * state is anchored in; default: B6 `readAllRows(slug)`.
255
+ * state is anchored in; default: one project tail reader for the worker.
254
256
  * @property {{write: (s: string) => unknown}} [stderr]
255
257
  * @property {import('../review/triage.mjs').JevAsk} [jev] - S1 for triage and the recheck (tests
256
258
  * inject a mock); default: `askJev` with the resolved Jev key, none without one.
@@ -272,7 +274,7 @@ export async function createWorker(opts, deps = {}) {
272
274
  const loadRun = deps.readRun ?? readRun;
273
275
  const loadTicket = deps.readTicket ?? readTicket;
274
276
  const writeRow = deps.writeRow ?? ((/** @type {Record<string, any>} */ row) => appendRow(row, { slug }));
275
- const readRows = deps.readRows ?? (() => readAllRows(slug));
277
+ const readRows = deps.readRows ?? createTailReader(slug).read;
276
278
  // B48: the run's own writer (unsigned base; the checks sign their rows with the run key)
277
279
  const expiryCheck = deps.expiryCheck ?? (() => expiryChecks(runId, new Date(), { writeRow }));
278
280
  const warnStream = deps.stderr ?? process.stderr;
@@ -356,7 +358,7 @@ export async function createWorker(opts, deps = {}) {
356
358
  const ledger = (row) => writeRow(signRow({ run: runId, ...row }, key));
357
359
  // Shared across direct ticks and the scheduler, so running tickets are never superseded.
358
360
  const activeTickets = new Set();
359
- 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 });
360
362
 
361
363
  /**
362
364
  * The config ticket `id` is reviewed with (B35): its snapshot from the run record, re-read per
@@ -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 : '';
@@ -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
+ }
@@ -7,6 +7,7 @@ import { snapshotFor } from '../state/config-snapshot.mjs';
7
7
  import { readRun } from '../state/run.mjs';
8
8
  import { ownsAt } from '../review/gate-check.mjs';
9
9
  import { exec } from '../util/exec.mjs';
10
+ import { withLock } from '../util/file-locks.mjs';
10
11
  import { blockTickets, enqueueHash, isDone, queueDir, supersedeTicket } from './queue.mjs';
11
12
  import { assertRowPath, DELETED_HASH, gitChildEnv, hashBytes } from './ticket.mjs';
12
13
 
@@ -30,97 +31,99 @@ export async function watchTick({ repoRoot, runId, cfg, readRun: loadRun = readR
30
31
  const env = gitChildEnv();
31
32
  for (const [block, entry] of Object.entries(record.blocks ?? {})) {
32
33
  if (entry.status !== 'open' || !BLOCK.test(block)) continue;
33
- const stateFile = path.join(queueDir(repoRoot), 'watch', `${block}.json`);
34
- let tree = null;
35
- let root = repoRoot;
36
- let state = {};
37
- try { state = JSON.parse(readFileSync(stateFile, 'utf8')) ?? {}; } catch { /* restart at base */ }
38
- const base = entry.base_sha;
39
- /** @param {Record<string, any>} next */
40
- const save = (next) => {
41
- mkdirSync(path.dirname(stateFile), { recursive: true });
42
- const tmp = `${stateFile}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
34
+ await withLock(`watch:${realpathSync(repoRoot)}\0${block}`, async () => {
35
+ const stateFile = path.join(queueDir(repoRoot), 'watch', `${block}.json`);
36
+ let tree = null;
37
+ let root = repoRoot;
38
+ let state = {};
39
+ try { state = JSON.parse(readFileSync(stateFile, 'utf8')) ?? {}; } catch { /* restart at base */ }
40
+ const base = entry.base_sha;
41
+ /** @param {Record<string, any>} next */
42
+ const save = (next) => {
43
+ mkdirSync(path.dirname(stateFile), { recursive: true });
44
+ const tmp = `${stateFile}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
45
+ try {
46
+ writeFileSync(tmp, `${JSON.stringify(next)}\n`, { mode: 0o600 });
47
+ renameSync(tmp, stateFile);
48
+ state = next;
49
+ } finally { rmSync(tmp, { force: true }); }
50
+ };
51
+ /** @param {string} reason */
52
+ const skip = async (reason) => {
53
+ const skipped = Array.isArray(state.skipped) ? state.skipped : [];
54
+ if (!skipped.includes(reason)) {
55
+ await writeRow({ event: 'watch.skipped', block, reason });
56
+ save({ ...state, skipped: [...skipped, reason] });
57
+ }
58
+ };
43
59
  try {
44
- writeFileSync(tmp, `${JSON.stringify(next)}\n`, { mode: 0o600 });
45
- renameSync(tmp, stateFile);
46
- state = next;
47
- } finally { rmSync(tmp, { force: true }); }
48
- };
49
- /** @param {string} reason */
50
- const skip = async (reason) => {
51
- const skipped = Array.isArray(state.skipped) ? state.skipped : [];
52
- if (!skipped.includes(reason)) {
53
- await writeRow({ event: 'watch.skipped', block, reason });
54
- save({ ...state, skipped: [...skipped, reason] });
55
- }
56
- };
57
- try {
58
- if (entry.worktree) {
59
- root = realpathSync(entry.worktree.path);
60
- tree = root;
61
- }
62
- if (state.run !== runId || state.base !== base || state.tree !== tree) state = { run: runId, base, tree, skipped: [] };
63
- /** @param {string[]} argv */
64
- const git = (argv) => execute(['git', ...argv], { cwd: root, env, timeoutMs: 10000 });
65
- const headResult = await git(['rev-parse', '--verify', 'HEAD^{commit}']);
66
- const head = headResult.stdout.trim();
67
- if (headResult.result !== 'ok' || !SHA.test(head)) { await skip('unreadable_head'); continue; }
68
- const earlier = blockTickets({ repoRoot, run: runId, block, tree });
69
- await hooks.afterHead?.(head);
70
- if (typeof base !== 'string' || !SHA.test(base) || (await git(['merge-base', '--is-ancestor', base, head])).result !== 'ok') {
71
- await skip('not_descendant'); continue;
72
- }
73
- const last = typeof state.sha === 'string' && SHA.test(state.sha) ? state.sha : base;
74
- const previous = typeof state.pending?.sha === 'string' && SHA.test(state.pending.sha) ? state.pending.sha : last;
75
- if (previous !== base && (await git(['merge-base', '--is-ancestor', previous, head])).result !== 'ok') {
76
- await skip('rewound');
77
- const { sha: _sha, pending: _pending, burst_started_at: _burst, ...restart } = state;
78
- save(restart);
79
- continue;
80
- }
81
- // A healthy tick ends the skip streak: no new commit here, or a queued commit below. An
82
- // unreadable commit keeps the streak, so it writes one watch.skipped row, not one per tick.
83
- if (last === head) { if (state.skipped?.length) save({ ...state, skipped: [] }); continue; }
84
- const time = now();
85
- if (state.pending?.sha !== head) {
86
- // A new HEAD restarts the quiet wait, but keeps the burst's original deadline.
87
- save({ ...state, pending: { sha: head, seen_at: time }, burst_started_at: state.burst_started_at ?? time });
88
- }
89
- if (time - state.pending.seen_at < QUIET_MS && time - state.burst_started_at < MAX_BURST_MS) continue;
90
- const diff = await git(['diff', '--name-only', '-z', '--no-renames', last, head, '--']);
91
- if (diff.result !== 'ok') { await skip('unreadable_head'); continue; }
92
- const owns = ownsAt(tree ? '' : path.relative(repoRoot, record.workspace).split(path.sep).join('/'));
93
- const files = diff.stdout.split('\0').filter(Boolean);
94
- const owned = files.filter((file) => owns(entry.owned_files, file, entry.owned_kinds));
95
- if (owned.length !== files.length) await skip('unowned_path');
96
- // Stage every hash before publishing anything: an unreadable object queues nothing.
97
- const hashes = [];
98
- let unreadable = false;
99
- for (const raw of owned.filter((file) => reviewExcludeRule(snapshotFor(record, '')?.config ?? cfg, file) === null)) {
100
- const file = assertRowPath(raw);
101
- const listed = await git(['ls-tree', '-z', head, '--', file]);
102
- if (listed.result !== 'ok') { unreadable = true; break; }
103
- if (!listed.stdout) { hashes.push({ file, content_hash: DELETED_HASH }); continue; }
104
- if (!/^100(?:644|755) blob /.test(listed.stdout)) { unreadable = true; break; }
105
- const shown = await execute(['git', 'show', `${head}:${file}`], { cwd: root, env, timeoutMs: 10000, stdoutEncoding: 'base64' });
106
- if (shown.result !== 'ok') { unreadable = true; break; }
107
- hashes.push({ file, content_hash: hashBytes(Buffer.from(shown.stdout, 'base64')) });
108
- }
109
- if (unreadable) { await skip('unreadable_head'); continue; }
110
- await hooks.beforeQueue?.();
111
- for (const { file, content_hash } of hashes) {
112
- const held = earlier.some((old) => old.file === file);
113
- if (!held) enqueueHash({ repoRoot, run: runId, block, file, content_hash, tree });
114
- for (const old of earlier) {
115
- if (old.file === file && old.content_hash !== content_hash && !running.has(old.ticket) && !isDone(repoRoot, old.ticket)) supersedeTicket(repoRoot, old.ticket);
60
+ if (entry.worktree) {
61
+ root = realpathSync(entry.worktree.path);
62
+ tree = root;
63
+ }
64
+ if (state.run !== runId || state.base !== base || state.tree !== tree) state = { run: runId, base, tree, skipped: [] };
65
+ /** @param {string[]} argv */
66
+ const git = (argv) => execute(['git', ...argv], { cwd: root, env, timeoutMs: 10000 });
67
+ const headResult = await git(['rev-parse', '--verify', 'HEAD^{commit}']);
68
+ const head = headResult.stdout.trim();
69
+ if (headResult.result !== 'ok' || !SHA.test(head)) { await skip('unreadable_head'); return; }
70
+ const earlier = blockTickets({ repoRoot, run: runId, block, tree });
71
+ await hooks.afterHead?.(head);
72
+ if (typeof base !== 'string' || !SHA.test(base) || (await git(['merge-base', '--is-ancestor', base, head])).result !== 'ok') {
73
+ await skip('not_descendant'); return;
74
+ }
75
+ const last = typeof state.sha === 'string' && SHA.test(state.sha) ? state.sha : base;
76
+ const previous = typeof state.pending?.sha === 'string' && SHA.test(state.pending.sha) ? state.pending.sha : last;
77
+ if (previous !== base && (await git(['merge-base', '--is-ancestor', previous, head])).result !== 'ok') {
78
+ await skip('rewound');
79
+ const { sha: _sha, pending: _pending, burst_started_at: _burst, ...restart } = state;
80
+ save(restart);
81
+ return;
82
+ }
83
+ // A healthy tick ends the skip streak: no new commit here, or a queued commit below. An
84
+ // unreadable commit keeps the streak, so it writes one watch.skipped row, not one per tick.
85
+ if (last === head) { if (state.skipped?.length) save({ ...state, skipped: [] }); return; }
86
+ const time = now();
87
+ if (state.pending?.sha !== head) {
88
+ // A new HEAD restarts the quiet wait, but keeps the burst's original deadline.
89
+ save({ ...state, pending: { sha: head, seen_at: time }, burst_started_at: state.burst_started_at ?? time });
90
+ }
91
+ if (time - state.pending.seen_at < QUIET_MS && time - state.burst_started_at < MAX_BURST_MS) return;
92
+ const diff = await git(['diff', '--name-only', '-z', '--no-renames', last, head, '--']);
93
+ if (diff.result !== 'ok') { await skip('unreadable_head'); return; }
94
+ const owns = ownsAt(tree ? '' : path.relative(repoRoot, record.workspace).split(path.sep).join('/'));
95
+ const files = diff.stdout.split('\0').filter(Boolean);
96
+ const owned = files.filter((file) => owns(entry.owned_files, file, entry.owned_kinds));
97
+ if (owned.length !== files.length) await skip('unowned_path');
98
+ // Stage every hash before publishing anything: an unreadable object queues nothing.
99
+ const hashes = [];
100
+ let unreadable = false;
101
+ for (const raw of owned.filter((file) => reviewExcludeRule(snapshotFor(record, '')?.config ?? cfg, file) === null)) {
102
+ const file = assertRowPath(raw);
103
+ const listed = await git(['ls-tree', '-z', head, '--', file]);
104
+ if (listed.result !== 'ok') { unreadable = true; break; }
105
+ if (!listed.stdout) { hashes.push({ file, content_hash: DELETED_HASH }); continue; }
106
+ if (!/^100(?:644|755) blob /.test(listed.stdout)) { unreadable = true; break; }
107
+ const shown = await execute(['git', 'show', `${head}:${file}`], { cwd: root, env, timeoutMs: 10000, stdoutEncoding: 'base64' });
108
+ if (shown.result !== 'ok') { unreadable = true; break; }
109
+ hashes.push({ file, content_hash: hashBytes(Buffer.from(shown.stdout, 'base64')) });
110
+ }
111
+ if (unreadable) { await skip('unreadable_head'); return; }
112
+ await hooks.beforeQueue?.();
113
+ for (const { file, content_hash } of hashes) {
114
+ const held = earlier.some((old) => old.file === file);
115
+ if (!held) enqueueHash({ repoRoot, run: runId, block, file, content_hash, tree });
116
+ for (const old of earlier) {
117
+ if (old.file === file && old.content_hash !== content_hash && !running.has(old.ticket) && !isDone(repoRoot, old.ticket)) supersedeTicket(repoRoot, old.ticket);
118
+ }
119
+ if (held) await writeRow({ event: 'watch.held', block, file, content_hash });
116
120
  }
117
- if (held) await writeRow({ event: 'watch.held', block, file, content_hash });
121
+ const { pending: _pending, burst_started_at: _burst, ...queued } = state;
122
+ save({ ...queued, run: runId, base, tree, sha: head, skipped: [] });
123
+ } catch {
124
+ // A bad path, missing tree or failed git read is a skip, never a block failure.
125
+ await skip('unreadable_head');
118
126
  }
119
- const { pending: _pending, burst_started_at: _burst, ...queued } = state;
120
- save({ ...queued, run: runId, base, tree, sha: head, skipped: [] });
121
- } catch {
122
- // A bad path, missing tree or failed git read is a skip, never a block failure.
123
- await skip('unreadable_head');
124
- }
127
+ });
125
128
  }
126
129
  }
@@ -0,0 +1,35 @@
1
+ import { workerRegistry } from './queue.mjs';
2
+ import { repoRootOf } from './ticket.mjs';
3
+ import { checkWorkerPin, normalizeStartTime, processStartTime } from '../state/run.mjs';
4
+
5
+ /** @typedef {{pid: number, start_time: string, run: string}} WarningWorker */
6
+ /** @typedef {{live: boolean, worker: WarningWorker | null}} RegistryEntry */
7
+
8
+ /**
9
+ * Read-only advice about live workers serving a run without a matching process identity pin.
10
+ * Failure to discover the repository, read its registry or probe a pin never breaks status.
11
+ * @param {Record<string, any>} record
12
+ * @param {{repoRoot?: string, registry?: (root: string) => RegistryEntry[] | Promise<RegistryEntry[]>, probe?: import('../state/run.mjs').StartTimeProbe}} [opts]
13
+ * @returns {Promise<string[]>}
14
+ */
15
+ export async function unpinnedWorkerWarnings(record, { repoRoot, registry = workerRegistry, probe = processStartTime } = {}) {
16
+ try {
17
+ if (record.status === 'ended') return [];
18
+ const root = repoRoot ?? await repoRootOf(record.workspace);
19
+ const pins = record.workers ?? (record.worker ? [record.worker] : []);
20
+ const unpinned = (await registry(root)).filter(({ live, worker }) => live && worker?.run === record.run_id
21
+ && !pins.some((pin) => pin.pid === worker.pid
22
+ && normalizeStartTime(pin.started_at) === normalizeStartTime(worker.start_time)));
23
+ if (unpinned.length === 0) return [];
24
+
25
+ let suffix = '';
26
+ if (unpinned.length === 1 && pins.length > 0) {
27
+ const check = await checkWorkerPin(record, { probe });
28
+ if (!check.ok) suffix = ` (pinned pid ${pins[0].pid} is ${check.event === 'worker.replaced' ? 'replaced' : 'down'})`;
29
+ }
30
+ return unpinned.map(({ worker }) => `warning: live worker ${worker.pid} is not pinned: run start --reattach --run ${record.run_id}`
31
+ + (unpinned.length > 1 ? ` --worker-pid ${worker.pid}` : suffix));
32
+ } catch {
33
+ return [];
34
+ }
35
+ }