@codedology/code-forge 0.14.0 → 0.15.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 (42) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +136 -438
  3. package/docs/README.md +1 -1
  4. package/docs/reference/blocks.json +420 -1
  5. package/docs/reference/config.md +7 -0
  6. package/package.json +1 -1
  7. package/schema/code-forge.schema.json +22 -0
  8. package/scripts/first-use.mjs +11 -5
  9. package/skill/SKILL.md +2 -0
  10. package/skill/references/autopilot.md +2 -0
  11. package/skill/references/code.md +25 -1
  12. package/skill/references/review.md +10 -2
  13. package/skill/scripts/forge +1 -1
  14. package/src/autopilot/scopes.mjs +2 -1
  15. package/src/cli/block.mjs +235 -44
  16. package/src/cli/gates.mjs +36 -6
  17. package/src/cli/ledger.mjs +21 -2
  18. package/src/cli/report.mjs +14 -0
  19. package/src/cli/review-file.mjs +56 -16
  20. package/src/cli/run.mjs +44 -7
  21. package/src/cli/status.mjs +7 -3
  22. package/src/cli/upgrade.mjs +95 -15
  23. package/src/config/validate.mjs +13 -1
  24. package/src/gates/acceptance.mjs +7 -3
  25. package/src/gates/expand.mjs +25 -2
  26. package/src/gates/run.mjs +38 -8
  27. package/src/install/link.mjs +12 -6
  28. package/src/ledger/report.mjs +29 -7
  29. package/src/review/accept-hit.mjs +22 -4
  30. package/src/review/engine.mjs +30 -10
  31. package/src/review/finding.schema.json +2 -1
  32. package/src/review/fixloop.mjs +79 -14
  33. package/src/review/gate-check.mjs +22 -3
  34. package/src/review/lenses/recheck.md +3 -0
  35. package/src/review/packet.mjs +26 -0
  36. package/src/state/block.mjs +162 -20
  37. package/src/state/run.mjs +35 -8
  38. package/src/util/forbidden.mjs +14 -0
  39. package/src/util/reaper.mjs +3 -3
  40. package/src/worker/engine.mjs +60 -4
  41. package/src/worker/loop.mjs +19 -10
  42. package/src/worker/queue.mjs +5 -2
package/src/state/run.mjs CHANGED
@@ -12,6 +12,7 @@
12
12
  * parameter; the CLI verb does the static import) and are SIGNED with the run key first.
13
13
  */
14
14
 
15
+ import { existsSync } from 'node:fs';
15
16
  import { mkdir, readFile, rename, rmdir, writeFile } from 'node:fs/promises';
16
17
  import path from 'node:path';
17
18
  import { exec } from '../util/exec.mjs';
@@ -31,7 +32,7 @@ const LOCK_POLL_MS = 25;
31
32
  */
32
33
  export async function processStartTime(pid) {
33
34
  if (!Number.isInteger(pid) || pid <= 0) return null;
34
- const res = await exec(['ps', '-o', 'lstart=', '-p', String(pid)], {
35
+ const res = await exec([existsSync('/bin/ps') ? '/bin/ps' : 'ps', '-o', 'lstart=', '-p', String(pid)], {
35
36
  env: { ...process.env, LC_ALL: 'C' },
36
37
  okExitCodes: [0, 1],
37
38
  timeoutMs: 10000,
@@ -177,6 +178,7 @@ export async function startRun(opts) {
177
178
  orchestrator_pid: opts.orchestratorPid ?? process.pid,
178
179
  solo_pid: opts.soloPid ?? null,
179
180
  worker,
181
+ workers: worker ? [worker] : [],
180
182
  status: 'active',
181
183
  blocks: {},
182
184
  orphans: [],
@@ -250,6 +252,10 @@ export async function reloadRun({ runId, config, readPending = () => [], effecti
250
252
  if (oldHash === newHash) return { changed: [], oldHash, newHash, pinned: 0, rowError: null };
251
253
  const before = state && Object.hasOwn(stored, state.hash) ? stored[state.hash] : undefined;
252
254
  if (state && (before === null || typeof before !== 'object')) throw new StateError('config-snapshot', `run ${runId}: the current config snapshot is missing from the run record`);
255
+ // B138: close authority belongs to the run-start snapshot, including legacy runs.
256
+ if (before?.close?.require_human !== config.close?.require_human) {
257
+ throw new StateError('immutable-key', 'close.require_human cannot change mid-run (fixed by the owner before the run starts)');
258
+ }
253
259
  /** @type {string[]} */
254
260
  let frozen;
255
261
  if (state) {
@@ -309,15 +315,18 @@ export async function reloadRun({ runId, config, readPending = () => [], effecti
309
315
 
310
316
  /**
311
317
  * `run start --reattach`: re-pin a restarted worker (the orchestrator only — §4.8).
312
- * @param {{runId: string, workerPid: number, writeRow: WriteRow, probe?: StartTimeProbe}} opts
318
+ * @param {{runId: string, workerPid: number, writeRow: WriteRow, probe?: StartTimeProbe, add?: boolean}} opts
313
319
  */
314
- export async function reattachWorker({ runId, workerPid, writeRow, probe = processStartTime }) {
320
+ export async function reattachWorker({ runId, workerPid, writeRow, probe = processStartTime, add = false }) {
315
321
  if (!isSignalablePid(workerPid)) throw new StateError('bad-worker-pid', 'worker pid must be an integer > 1');
316
322
  return withRunLock(runId, async () => {
317
323
  const current = await readRun(runId);
318
324
  if (current.status !== 'active') throw new StateError('run-ended', `run ${runId} has ended`);
319
- current.worker = await pinWorker(workerPid, probe);
320
- await writeSigned(runId, writeRow, { event: 'worker.reattach', pid: workerPid });
325
+ const pin = await pinWorker(workerPid, probe);
326
+ const pins = current.workers ?? (current.worker ? [current.worker] : []);
327
+ current.workers = add ? [...pins, pin] : [pin];
328
+ current.worker = current.workers[0];
329
+ await writeSigned(runId, writeRow, { event: add ? 'worker.add' : 'worker.reattach', pid: workerPid });
321
330
  await saveRun(current);
322
331
  return current;
323
332
  });
@@ -331,7 +340,18 @@ export async function reattachWorker({ runId, workerPid, writeRow, probe = proce
331
340
  * @returns {Promise<{ok: boolean, event?: 'worker.replaced' | 'worker.down', reason?: string}>}
332
341
  */
333
342
  export async function checkWorkerPin(record, { livePid, probe = processStartTime } = {}) {
334
- const pin = record.worker;
343
+ let pin = record.worker;
344
+ if (record.workers?.length > 1) {
345
+ const pins = record.workers;
346
+ if (pins.some((p) => !isSignalablePid(p.pid)) || (livePid !== undefined && !pins.some((p) => p.pid === livePid))) {
347
+ return { ok: false, event: 'worker.replaced', reason: 'the live worker does not match a pinned worker' };
348
+ }
349
+ if (livePid === undefined) {
350
+ const live = await Promise.all(pins.map(async (p) => (await probe(p.pid)) === p.started_at));
351
+ return live.some(Boolean) ? { ok: true } : { ok: false, event: 'worker.down', reason: 'no pinned worker is running' };
352
+ }
353
+ pin = pins.find((p) => p.pid === livePid);
354
+ }
335
355
  if (!pin) return { ok: false, event: 'worker.down', reason: 'no worker is pinned for this run' };
336
356
  if (!isSignalablePid(pin.pid)) return { ok: false, event: 'worker.replaced', reason: 'the pinned worker pid is not an integer > 1' };
337
357
  if (livePid !== undefined && livePid !== pin.pid) {
@@ -380,14 +400,21 @@ export async function endRun({ runId, writeRow, stopWorker = stopPinnedWorker, p
380
400
  return withRunLock(runId, async () => {
381
401
  const current = await readRun(runId);
382
402
  if (current.status !== 'active') throw new StateError('run-ended', `run ${runId} has already ended`);
403
+ const awaiting = Object.keys(current.blocks).filter((id) => current.blocks[id].status === 'awaiting_human_check');
404
+ if (awaiting.length > 0) throw new StateError('awaiting-human-check', `run ${runId} has blocks awaiting human check: ${awaiting.join(', ')}; human-pass or stop them first`);
383
405
  const open = Object.keys(current.blocks).filter((id) => current.blocks[id].status === 'open');
384
406
  if (open.length > 0) throw new StateError('open-blocks', `run ${runId} still has open blocks: ${open.join(', ')}`);
385
407
  const pin = await checkWorkerPin(current, { probe });
386
408
  if (!pin.ok) await writeSigned(runId, writeRow, { event: pin.event, reason: pin.reason });
387
- const stop = pin.ok ? await stopWorker(current.worker, { probe }) : { stopped: false };
409
+ let stopped = false;
410
+ for (const worker of current.workers ?? (current.worker ? [current.worker] : [])) {
411
+ if ((await checkWorkerPin({ worker }, { probe })).ok) {
412
+ stopped = (await stopWorker(worker, { probe })).stopped || stopped;
413
+ }
414
+ }
388
415
  current.status = 'ended';
389
416
  current.ended_at = new Date().toISOString();
390
- await writeSigned(runId, writeRow, { event: 'run.end', worker_check: pin.ok ? 'ok' : pin.event, worker_stopped: stop.stopped });
417
+ await writeSigned(runId, writeRow, { event: 'run.end', worker_check: pin.ok ? 'ok' : pin.event, worker_stopped: stopped });
391
418
  await saveRun(current);
392
419
  return current;
393
420
  });
@@ -282,6 +282,7 @@ export const FORBIDDEN = Object.freeze(
282
282
  * - `--no-require-reviews` — the human's way out of `block close`'s per-file review check;
283
283
  * - `--remove-worktree` (B62a) — `block close` removing the block's worktree, the orchestrator's
284
284
  * alone. `block close` itself is not on this list, so the flag is listed on its own.
285
+ * - `code-forge block human-pass` (B137) — passing a human device check is human-only.
285
286
  * @type {ReadonlyArray<ForbiddenEntry>}
286
287
  */
287
288
  export const CODER_ONLY_FORBIDDEN = Object.freeze(
@@ -346,6 +347,19 @@ export const CODER_ONLY_FORBIDDEN = Object.freeze(
346
347
  prefixes: [['code-forge', 'block', 'ack-waivers'], ['forge', 'block', 'ack-waivers']],
347
348
  description: 'Acknowledge agent waivers — human-only, confirmed in the human terminal (B124b)',
348
349
  },
350
+ {
351
+ id: 'code-forge-block-human-pass-from-coder',
352
+ kind: 'anyPrefix',
353
+ prefixes: [['code-forge', 'block', 'human-pass'], ['forge', 'block', 'human-pass']],
354
+ description: 'Pass a human device check — human-only, confirmed in the human terminal (B137)',
355
+ },
356
+ {
357
+ id: 'code-forge-add-worker-from-coder',
358
+ kind: 'commandFlag',
359
+ command: ['code-forge', 'run', 'start'],
360
+ groups: [{ anyOf: ['--add-worker'] }],
361
+ description: 'Add a review worker to a run — the orchestrator only (B107)',
362
+ },
349
363
  ].map(freezeEntry),
350
364
  );
351
365
 
@@ -27,7 +27,7 @@
27
27
  */
28
28
 
29
29
  import { execFileSync } from 'node:child_process';
30
- import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
30
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
31
31
  import path from 'node:path';
32
32
  import { setTimeout as sleep } from 'node:timers/promises';
33
33
 
@@ -85,7 +85,7 @@ export function isAlive(pid) {
85
85
  export function readStartTime(pid) {
86
86
  if (!isValidPid(pid)) return null;
87
87
  try {
88
- const out = execFileSync('ps', ['-o', 'lstart=', '-p', String(pid)], {
88
+ const out = execFileSync(existsSync('/bin/ps') ? '/bin/ps' : 'ps', ['-o', 'lstart=', '-p', String(pid)], {
89
89
  encoding: 'utf8',
90
90
  stdio: ['ignore', 'pipe', 'ignore'],
91
91
  env: { ...process.env, LC_ALL: 'C', LANG: 'C' },
@@ -165,7 +165,7 @@ export function groupMembers(pgid) {
165
165
  const members = new Map();
166
166
  let out = '';
167
167
  try {
168
- out = execFileSync('ps', ['-A', '-o', 'pid=,pgid=,lstart='], {
168
+ out = execFileSync(existsSync('/bin/ps') ? '/bin/ps' : 'ps', ['-A', '-o', 'pid=,pgid=,lstart='], {
169
169
  encoding: 'utf8',
170
170
  stdio: ['ignore', 'pipe', 'ignore'],
171
171
  env: { ...process.env, LC_ALL: 'C', LANG: 'C' },
@@ -95,13 +95,13 @@ import { askJev } from '../decide/jev-client.mjs';
95
95
  import { planAndRecord, tierOf } from '../review/budget.mjs';
96
96
  import { budgetFor } from '../review/packet.mjs';
97
97
  import { ContextError, reviewDigest } from '../context/pack.mjs';
98
- import { reviewFile, rulesRisk } from '../review/engine.mjs';
98
+ import { reviewFile, reviewLightBatch, rulesRisk } from '../review/engine.mjs';
99
99
  import { reviewAnswers } from '../review/answers.mjs';
100
100
  import { peerDiffReader } from '../review/moved.mjs';
101
101
  import { computeFileSet, ownsFile } from '../gates/scope.mjs';
102
102
  import { extraRoundsFor, newFileState, reopenForExtraRound, runRound } from '../review/fixloop.mjs';
103
103
  import { reopenRowFor } from '../review/reopen.mjs';
104
- import { readRun } from '../state/run.mjs';
104
+ import { processStartTime, readRun } from '../state/run.mjs';
105
105
  import { StateError } from '../state/paths.mjs';
106
106
  import { blockTree, worktreeRecord } from '../proof/worktree.mjs';
107
107
  import { ledgerSchemaHistory } from '../review/schema-fallback.mjs';
@@ -109,7 +109,7 @@ import { verifyRow } from '../state/signer.mjs';
109
109
  import { redact } from '../util/redact.mjs';
110
110
  import { withLock } from '../util/file-locks.mjs';
111
111
  import { blockAnchors, blockRungUsed, loadState, RESTORED_EVENT, saveState } from './review-state.mjs';
112
- import { assertAnswers } from './queue.mjs';
112
+ import { assertAnswers, enqueueHash, isDone, writeResult } from './queue.mjs';
113
113
  import { assertTicketId, contentHash, readRegularFileNoFollow, WorkerError } from './ticket.mjs';
114
114
 
115
115
  /**
@@ -127,6 +127,8 @@ import { assertTicketId, contentHash, readRegularFileNoFollow, WorkerError } fro
127
127
  * the worker's and a per-call `cfg` wins).
128
128
  * @property {(row: Record<string, any>) => Promise<unknown>} [writeRow] - a signed ledger row
129
129
  * (the worker adds `run`, `block`, `file`, `content_hash`).
130
+ * @property {(row: Record<string, any>) => Promise<unknown>} [writeBlockRow] - light fanout rows;
131
+ * the worker pins run and block, while the batch supplies each file and content hash.
130
132
  * @property {Buffer} [key] - the run's HMAC key: signs the fix-loop state. Absent ⇒ engine only.
131
133
  * @property {() => Promise<Array<Record<string, any>>>} [readRows] - the run's ledger rows (the
132
134
  * `review.state` anchors the state is checked against); required with `key`.
@@ -197,7 +199,7 @@ export async function blockEntryFor(runId, block) {
197
199
  throw new Error('worktree-malformed');
198
200
  }
199
201
  }
200
- return { base: tree ? tree.base_sha : sha, level: typeof entry.level === 'string' && /^L[0-3]$/.test(entry.level) ? entry.level : 'L2', owned, ownedKinds, testFiles, acceptance: entry.acceptance, kind: blockKind({ owned, declared: entry.kind }), tree, workspace: typeof record.workspace === 'string' ? record.workspace : null };
202
+ return { base: tree ? tree.base_sha : sha, level: typeof entry.level === 'string' && /^L[0-3]$/.test(entry.level) ? entry.level : 'L2', owned, ownedKinds, testFiles, acceptance: entry.acceptance, kind: entry.kind === 'light' ? 'light' : blockKind({ owned, declared: entry.kind }), tree, workspace: typeof record.workspace === 'string' ? record.workspace : null };
201
203
  }
202
204
 
203
205
  /** @typedef {{path: string, branch: string, base_sha: string}} WorktreeEntry */
@@ -411,6 +413,10 @@ export async function reviewTicket(ticket, ctx) {
411
413
  return { status: 'unavailable', reason: 'context-unreadable', approved: false, engine: 'adaptive', sessions: [] };
412
414
  }
413
415
  const digest = rulesDigest === '' ? {} : { rulesDigest };
416
+ if (entry.kind === 'light' && ctx.key) {
417
+ const batch = await lightRound(ticket, tctx, { entry, workDir, factsExcerpt, rulesDigest });
418
+ if (batch) return batch;
419
+ }
414
420
  if (!ctx.key) {
415
421
  return await reviewFile(
416
422
  { repoRoot: contentRoot, file: ticket.file, base: entry.base, cfg: ctx.cfg, kind: entry.kind, workDir, factsExcerpt, ...digest, peerDiffs, ...(await fileContext()) },
@@ -423,6 +429,56 @@ export async function reviewTicket(ticket, ctx) {
423
429
  }
424
430
  }
425
431
 
432
+ /** Serialize a light block across tickets/workers; durable signed rows enforce the two-batch cap.
433
+ * A batch completes every file's queue ticket, including files queued after this ticket started.
434
+ * @param {import('./queue.mjs').Ticket} ticket @param {ReviewContext} ctx
435
+ * @param {{entry: Awaited<ReturnType<typeof blockEntryFor>>, workDir: string, factsExcerpt: string, rulesDigest: string}} input
436
+ * @returns {Promise<ReviewOutcome | null>} null enters the ordinary per-file loop.
437
+ */
438
+ async function lightRound(ticket, ctx, { entry, workDir, factsExcerpt, rulesDigest }) {
439
+ if (!ctx.key || !ctx.readRows || !ctx.writeBlockRow) return { status: 'unavailable', reason: 'no-ledger', approved: false, engine: 'adaptive', sessions: [] };
440
+ const key = ctx.key, readRows = ctx.readRows, writeRow = ctx.writeBlockRow;
441
+ const worker = { pid: process.pid, started_at: await processStartTime(process.pid) };
442
+ return withLock(`light:${ctx.runId}\0${ticket.block}`, async () => {
443
+ const root = ctx.contentRoot ?? ctx.repoRoot;
444
+ const files = (await computeFileSet({ cwd: root, base: entry.base ?? 'HEAD' })).all.filter((file) => ownsFile(entry.owned, file, entry.ownedKinds)).sort();
445
+ const hashes = files.map((file) => ({ file, content_hash: contentHash(root, file) }));
446
+ if (!hashes.some((f) => f.file === ticket.file && f.content_hash === ticket.content_hash)) return { status: 'stale', approved: false, engine: 'adaptive', sessions: [] };
447
+ const signature = JSON.stringify([entry.base, hashes]);
448
+ const rows = (await readRows()).filter((r) => r.run === ctx.runId && r.block === ticket.block && verifyRow(r, key).ok);
449
+ const cached = rows.filter((r) => r.event === 'review.light_result' && r.signature === signature).at(-1)?.outcomes?.[ticket.file];
450
+ if (cached && (cached.status === 'reviewed' || isDone(ctx.repoRoot, ticket.ticket))) return cached;
451
+ const count = rows.filter((r) => r.event === 'review.light_batch').length;
452
+ if (count >= 2) return null;
453
+ const round = count + 1;
454
+ // Reserve before the session: a crash cannot reset the cap on a worker restart.
455
+ await writeRow({ event: 'review.light_batch', round, signature });
456
+ const batch = await reviewLightBatch({ repoRoot: root, files, base: entry.base, cfg: ctx.cfg, workDir, factsExcerpt, rulesDigest }, { spawn: ctx.spawn, writeRow });
457
+ const moved = hashes.some((f) => contentHash(root, f.file) !== f.content_hash);
458
+ const findings = batch.findings ?? [];
459
+ const blocking = findings.some((f) => f.severity === 'critical' || f.severity === 'warning');
460
+ /** @type {Record<string, ReviewOutcome>} */
461
+ const outcomes = {};
462
+ for (const { file, content_hash } of hashes) {
463
+ const own = findings.filter((f) => f.file === file);
464
+ const approved = !moved && batch.status === 'reviewed' && (batch.approved || blocking) && own.every((f) => f.severity === 'nit');
465
+ const outcome = { ...batch, ...(moved ? { status: 'stale' } : {}), approved, findings: own, round, kind: 'full', level: entry.level,
466
+ next: { action: approved ? 'complete' : 'fix', level: entry.level } };
467
+ outcomes[file] = outcome;
468
+ if (batch.status === 'reviewed' && !moved) {
469
+ await writeRow({ event: 'review.round', file, content_hash, round, kind: 'full', level: entry.level, open_after: own.filter((f) => f.severity !== 'nit').length });
470
+ if (approved) await writeRow({ event: 'review.approved', file, content_hash });
471
+ }
472
+ const queued = enqueueHash({ repoRoot: ctx.repoRoot, run: ctx.runId, block: ticket.block, file, content_hash, ...(ticket.tree ? { tree: ticket.tree } : {}) });
473
+ const open = approved ? [] : own;
474
+ await writeRow({ event: 'review.result', ticket: queued.ticket, file, content_hash, status: outcome.status, approved, open, worker });
475
+ writeResult({ repoRoot: ctx.repoRoot, runId: ctx.runId, ticket: queued.ticket, result: { ...outcome, block: ticket.block, file, content_hash, open, worker }, key });
476
+ }
477
+ await writeRow({ event: 'review.light_result', signature, outcomes });
478
+ return outcomes[ticket.file];
479
+ });
480
+ }
481
+
426
482
  /**
427
483
  * B56: the block's OTHER changed files — the §4.1 file set since the block base (tracked changes ∪
428
484
  * untracked), filtered to the block's owned files (`ownsFile`, as the budget row does), minus the
@@ -42,12 +42,13 @@ import path from 'node:path';
42
42
  import { fileURLToPath } from 'node:url';
43
43
  import { expiryChecks } from '../autopilot/limits.mjs';
44
44
  import { loadProjectConfig } from '../config/load.mjs';
45
+ import { reviewWorkers } from '../config/validate.mjs';
45
46
  import { cacheHoursFrom, createDefaultKeyStore, resolveKey } from '../keys/store.mjs';
46
47
  import { appendRow, readAllRows } from '../ledger/write.mjs';
47
48
  import { spawnSession } from '../session/spawn.mjs';
48
49
  import { snapshotFor } from '../state/config-snapshot.mjs';
49
50
  import { runRecordPath } from '../state/paths.mjs';
50
- import { readRun, reattachWorker } from '../state/run.mjs';
51
+ import { processStartTime, readRun, reattachWorker } from '../state/run.mjs';
51
52
  import { loadKey, signRow } from '../state/signer.mjs';
52
53
  import { logWarning } from '../util/error-log.mjs';
53
54
  import { withLock, tryLock } from '../util/file-locks.mjs';
@@ -260,6 +261,7 @@ export function recordedVerdict(result) {
260
261
  */
261
262
  export async function createWorker(opts, deps = {}) {
262
263
  const { runId, repoRoot, cfg, runRootDir, slug, key } = opts;
264
+ const identity = { pid: process.pid, started_at: await processStartTime(process.pid) };
263
265
  const env = deps.env ?? process.env;
264
266
  const review = deps.review ?? reviewTicket;
265
267
  const spawn = deps.spawn ?? spawnSession;
@@ -382,6 +384,7 @@ export async function createWorker(opts, deps = {}) {
382
384
  { env: childEnv, ...(deps.bins ? { bins: deps.bins } : {}), ...(deps.stderr ? { stderr: deps.stderr } : {}) },
383
385
  ),
384
386
  writeRow: (row) => ledger({ ...row, block: ticket.block, file: ticket.file, content_hash: ticket.content_hash }),
387
+ writeBlockRow: (row) => ledger({ ...row, block: ticket.block }),
385
388
  };
386
389
  try {
387
390
  const outcome = await review(ticket, ctx);
@@ -484,12 +487,14 @@ export async function createWorker(opts, deps = {}) {
484
487
  * @param {string} id @param {Record<string, any>} outcome - recorded through {@link recordedVerdict}.
485
488
  */
486
489
  async function complete(id, outcome) {
487
- const result = recordedVerdict(outcome);
490
+ /** @type {Record<string, any>} */
491
+ const result = { ...recordedVerdict(outcome), worker: identity };
488
492
  const signed = writeResult({ repoRoot, runId, ticket: id, result, key });
489
493
  try {
490
494
  await ledger({
491
495
  event: 'review.result',
492
496
  ticket: id,
497
+ worker: identity,
493
498
  block: result.block ?? null,
494
499
  file: result.file ?? null,
495
500
  content_hash: result.content_hash ?? null,
@@ -793,9 +798,6 @@ export async function createWorker(opts, deps = {}) {
793
798
  return { processTicket, drain, run, once, stop, release, announce, watchTick: tickWatch, jevKeyResolved: jevKey !== null, sessionEnv: childEnv };
794
799
  }
795
800
 
796
- /** This block permits one registered worker per queue. */
797
- const WORKER_LIMIT = 1;
798
-
799
801
  /**
800
802
  * Start-up for the `worker` verb: run record, repo root, config, run temp root (owned by this
801
803
  * process), run key; refuses when another live worker already serves this queue.
@@ -831,7 +833,10 @@ export async function bootWorker(opts, deps = {}) {
831
833
  const legacy = liveWorker(repoRoot);
832
834
  if (legacy && !entries.some((entry) => entry.worker?.pid === legacy.pid && entry.worker.start_time === legacy.start_time)) count++;
833
835
  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');
836
+ const limit = reviewWorkers(cfg);
837
+ 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) {
838
+ throw new WorkerError('worker-running', `review.workers limit ${limit}: a live or unreadable worker entry already serves this queue`);
839
+ }
835
840
  worker.announce();
836
841
  });
837
842
  return { ...worker, repoRoot, runRootDir };
@@ -844,10 +849,10 @@ export async function bootWorker(opts, deps = {}) {
844
849
  * `expectPid` (a `run start` that just launched a worker) pins ONLY that pid: an older or stale
845
850
  * announcement naming another live pid is refused (`worker_mismatch`) instead of being pinned
846
851
  * over the new worker.
847
- * @param {{runId: string, repoRoot: string, writeRow?: (row: Record<string, any>) => Promise<unknown>, probe?: import('../state/run.mjs').StartTimeProbe, expectPid?: number}} opts
852
+ * @param {{runId: string, repoRoot: string, writeRow?: (row: Record<string, any>) => Promise<unknown>, probe?: import('../state/run.mjs').StartTimeProbe, expectPid?: number, add?: boolean}} opts
848
853
  */
849
- export async function repinWorker({ runId, repoRoot, writeRow, probe, expectPid }) {
850
- const live = liveWorker(repoRoot);
854
+ export async function repinWorker({ runId, repoRoot, writeRow, probe, expectPid, add = false }) {
855
+ const live = (expectPid === undefined ? null : liveWorker(repoRoot, { pid: expectPid, registeredOnly: true })) ?? liveWorker(repoRoot);
851
856
  if (!live) throw new WorkerError('worker_down', 'no live worker serves this queue');
852
857
  if (live.run !== runId) throw new WorkerError('other-run', `the live worker serves run ${live.run}`);
853
858
  if (expectPid !== undefined && live.pid !== expectPid) {
@@ -855,7 +860,7 @@ export async function repinWorker({ runId, repoRoot, writeRow, probe, expectPid
855
860
  }
856
861
  const { project } = await readRun(runId);
857
862
  const write = writeRow ?? ((/** @type {Record<string, any>} */ row) => appendRow(row, { slug: project }));
858
- return reattachWorker({ runId, workerPid: live.pid, writeRow: write, ...(probe ? { probe } : {}) });
863
+ return reattachWorker({ runId, workerPid: live.pid, writeRow: write, add, ...(probe ? { probe } : {}) });
859
864
  }
860
865
 
861
866
  /** The CLI entry the worker is launched from. */
@@ -887,5 +892,9 @@ export async function launchWorker({ runId, workspace, env = process.env, timeou
887
892
  // already gone
888
893
  }
889
894
  }
895
+ const other = liveWorker(repoRoot);
896
+ if (other && other.run !== runId) {
897
+ throw new WorkerError('worker_down', `the worker did not start: run ${other.run} still has a live worker (pid ${other.pid}); end it with: code-forge run end --run ${other.run}`);
898
+ }
890
899
  throw new WorkerError('worker_down', 'the worker did not start (run `code-forge worker --run <id>` by hand to see why)');
891
900
  }
@@ -516,11 +516,14 @@ export function readTicket(repoRoot, id) {
516
516
  * @returns {Record<string, any>} the signed result
517
517
  */
518
518
  export function writeResult({ repoRoot, runId, ticket, result, key }) {
519
- const signed = signRow({ ...result, run: runId, ticket: assertTicketId(ticket) }, key);
519
+ const split = result.status === 'split_required' || (result.status === 'stopped' && result.stopped === 'split_required');
520
+ // The engine's depth identifies the round-1 packet; rechecks always use the recheck lens.
521
+ const measured = split ? { budget: result.budget ?? null, lens: result.lens ?? (result.status === 'stopped' ? 'recheck' : result.depth === 'quick' ? 'quick' : result.depth === 'dual' ? 'A' : 'full') } : {};
522
+ const signed = signRow({ ...result, ...measured, run: runId, ticket: assertTicketId(ticket) }, key);
520
523
  const file = path.join(reviewsDir(repoRoot, runId), `${ticket}.json`);
521
524
  writeAtomic(file, `${JSON.stringify(signed)}\n`);
522
525
  const openFindings = signed.status === 'reviewed' && signed.approved !== true && Array.isArray(signed.findings) && signed.findings.length > 0;
523
- writeAtomic(path.join(queueDir(repoRoot), `${ticket}.done`), `${JSON.stringify({ ticket, status: signed.status, ...(openFindings ? { open_findings: true } : {}), result: path.relative(repoRoot, file) })}\n`);
526
+ writeAtomic(path.join(queueDir(repoRoot), `${ticket}.done`), `${JSON.stringify({ ticket, status: signed.status, ...measured, ...(openFindings ? { open_findings: true } : {}), result: path.relative(repoRoot, file) })}\n`);
524
527
  return signed;
525
528
  }
526
529