@codedology/code-forge 0.15.3 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +1 -1
  3. package/docs/reference/blocks.json +9 -1
  4. package/docs/reference/config.md +11 -1
  5. package/native/README.md +10 -0
  6. package/native/bin/darwin-arm64/code-forge-native +0 -0
  7. package/native/bin/darwin-x64/code-forge-native +0 -0
  8. package/native/bin/linux-arm64/code-forge-native +0 -0
  9. package/native/bin/linux-x64/code-forge-native +0 -0
  10. package/package.json +7 -2
  11. package/schema/code-forge.schema.json +26 -1
  12. package/schema/facts.schema.json +1 -1
  13. package/skill/SKILL.md +1 -1
  14. package/skill/references/adapters/solo.md +1 -1
  15. package/skill/references/autopilot.md +6 -1
  16. package/skill/references/code.md +37 -0
  17. package/skill/references/decisions.md +1 -1
  18. package/skill/references/plan.md +18 -5
  19. package/skill/references/review.md +47 -9
  20. package/skill/scripts/forge +1 -1
  21. package/skill/templates/facts-sheet.md +9 -3
  22. package/src/cli/block.mjs +284 -11
  23. package/src/cli/doctor.mjs +32 -1
  24. package/src/cli/help.mjs +1 -0
  25. package/src/cli/native-status.mjs +13 -0
  26. package/src/cli/review-file.mjs +126 -14
  27. package/src/cli/spawn.mjs +26 -22
  28. package/src/cli/status.mjs +80 -14
  29. package/src/decide/escalation.mjs +5 -5
  30. package/src/doctor/index.mjs +11 -0
  31. package/src/doctor/worker-probe.mjs +1 -1
  32. package/src/gates/dependents.mjs +200 -0
  33. package/src/gates/expand.mjs +83 -26
  34. package/src/gates/run.mjs +76 -14
  35. package/src/ledger/tail.mjs +89 -0
  36. package/src/review/agent-waiver.mjs +95 -8
  37. package/src/review/definitions.mjs +170 -0
  38. package/src/review/fixloop.mjs +4 -4
  39. package/src/review/gate-check.mjs +33 -13
  40. package/src/review/model-stats.mjs +125 -0
  41. package/src/review/packet.mjs +111 -22
  42. package/src/review/reopen.mjs +38 -13
  43. package/src/review/route.mjs +58 -4
  44. package/src/session/facts.mjs +252 -70
  45. package/src/session/plan-check.mjs +4 -1
  46. package/src/session/spawn.mjs +70 -19
  47. package/src/state/block-end.mjs +3 -1
  48. package/src/state/block.mjs +56 -21
  49. package/src/state/run.mjs +12 -5
  50. package/src/util/native.mjs +133 -0
  51. package/src/worker/engine.mjs +11 -26
  52. package/src/worker/loop.mjs +150 -20
  53. package/src/worker/queue.mjs +3 -2
  54. package/src/worker/review-state.mjs +15 -3
  55. package/src/worker/watch.mjs +97 -83
  56. package/src/worker/worker-warning.mjs +35 -0
@@ -45,7 +45,8 @@
45
45
 
46
46
  import { spawn } from 'node:child_process';
47
47
  import { createHash, randomBytes } from 'node:crypto';
48
- import { closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
48
+ import { closeSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
49
+ import os from 'node:os';
49
50
  import path from 'node:path';
50
51
  import { Ajv2020 } from 'ajv/dist/2020.js';
51
52
  import { CODEX_CLOSED_BOOK_REFUSAL, closedBookRefused, isNoToolRole, openBookCodexAllowed } from '../config/closed-book.mjs';
@@ -531,22 +532,17 @@ function assertFactsCwd(cwd, runRootDir) {
531
532
  if (workTree !== null) throw refuse(`inside a git work tree (${workTree})`);
532
533
  }
533
534
 
534
- /**
535
- * @param {SessionOpts} opts
536
- * @param {SessionDeps} [deps]
537
- * @returns {Promise<SessionResult>}
538
- */
539
- export async function spawnSession(opts, deps = {}) {
540
- const { cfg, level, role, promptPath } = opts;
535
+ /** @param {SessionOpts} opts */
536
+ function validateSessionOpts(opts) {
537
+ const { level, role, promptPath } = opts;
541
538
  if (!LEVELS.includes(level)) throw new SessionError('usage', `level must be one of ${LEVELS.join(', ')}`);
542
539
  if (!ROLES.includes(role)) throw new SessionError('usage', `role must be one of ${ROLES.join(', ')}`);
543
540
  if (typeof promptPath !== 'string' || !path.isAbsolute(promptPath)) throw new SessionError('usage', 'promptPath must be an absolute path');
544
- /** @type {string | null} */
545
- let coderPrompt = null;
546
- const stderr = deps.stderr ?? process.stderr;
547
- const runRootDir = opts.runRoot ?? currentRunRoot();
548
- if (role === 'facts' && opts.cwd !== undefined) assertFactsCwd(opts.cwd, runRootDir);
549
- const writeRow = deps.writeRow ?? (opts.slug ? (/** @type {Record<string, any>} */ row) => appendRow(row, { slug: /** @type {string} */ (opts.slug) }) : null);
541
+ }
542
+
543
+ /** @param {SessionOpts} opts @param {{write: (s: string) => unknown}} stderr */
544
+ function sessionSteps(opts, stderr) {
545
+ const { cfg, level, role } = opts;
550
546
  const ladder = ladderFor(resolveLevel(cfg, level), role);
551
547
  // B32: Codex has no no-tools mode, so it runs no reviewer/judge/S2/author session unless the
552
548
  // config opts in (`review.allow_open_book_codex: true`). Without it, step 0 on openai refuses the
@@ -556,6 +552,53 @@ export async function spawnSession(opts, deps = {}) {
556
552
  const steps = ladder.filter((step) => !closedBookRefused(step.provider, role, openBook));
557
553
  if (steps.length < ladder.length) writeSafe(stderr, `fallback skipped: ${ladder.length - steps.length} openai step(s) — ${CODEX_CLOSED_BOOK_REFUSAL}\n`);
558
554
  if (opts.background) steps.length = 1;
555
+ return { steps, openBook };
556
+ }
557
+
558
+ /**
559
+ * Build the first runnable step without entering the budget gate or provider semaphore.
560
+ * All session artifacts are temporary; argv paths use the stable <session> placeholder.
561
+ * @param {SessionOpts} opts @param {SessionDeps} [deps]
562
+ * @returns {Promise<{argv: string[], cwd: string, provider: string, model: string, effort: string | null, fallback_step: number}>}
563
+ */
564
+ export async function previewArgv(opts, deps = {}) {
565
+ validateSessionOpts(opts);
566
+ if (opts.role === 'facts' && opts.cwd !== undefined) assertFactsCwd(opts.cwd, opts.runRoot ?? currentRunRoot());
567
+ const { steps, openBook } = sessionSteps(opts, deps.stderr ?? process.stderr);
568
+ const step = steps[0];
569
+ const sessionDir = mkdtempSync(path.join(os.tmpdir(), 'code-forge-preview-'));
570
+ try {
571
+ const coderPrompt = opts.role === 'coder' && opts.block ? await coderPromptFor(opts) : null;
572
+ const built = buildStep({ ...opts, runRoot: sessionDir }, step, sessionDir, openBook, coderPrompt);
573
+ checkBuiltArgv(built, opts, deps);
574
+ return {
575
+ argv: built.argv.map((arg) => arg.replaceAll(sessionDir, '<session>')),
576
+ cwd: built.cwd.replaceAll(sessionDir, '<session>'),
577
+ provider: step.provider,
578
+ model: step.model,
579
+ effort: step.effort ?? null,
580
+ fallback_step: step.fallback_step,
581
+ };
582
+ } finally {
583
+ rmSync(sessionDir, { recursive: true, force: true });
584
+ }
585
+ }
586
+
587
+ /**
588
+ * @param {SessionOpts} opts
589
+ * @param {SessionDeps} [deps]
590
+ * @returns {Promise<SessionResult>}
591
+ */
592
+ export async function spawnSession(opts, deps = {}) {
593
+ const { cfg, level, role } = opts;
594
+ validateSessionOpts(opts);
595
+ /** @type {string | null} */
596
+ let coderPrompt = null;
597
+ const stderr = deps.stderr ?? process.stderr;
598
+ const runRootDir = opts.runRoot ?? currentRunRoot();
599
+ if (role === 'facts' && opts.cwd !== undefined) assertFactsCwd(opts.cwd, runRootDir);
600
+ const writeRow = deps.writeRow ?? (opts.slug ? (/** @type {Record<string, any>} */ row) => appendRow(row, { slug: /** @type {string} */ (opts.slug) }) : null);
601
+ const { steps, openBook } = sessionSteps(opts, stderr);
559
602
 
560
603
  const attempts = [];
561
604
  const sleep = deps.sleep ?? realSleep;
@@ -612,11 +655,7 @@ export async function spawnSession(opts, deps = {}) {
612
655
  try {
613
656
  const built = buildStep(opts, step, sessionDir, openBook, coderPrompt);
614
657
  const cli = built.cli;
615
- built.argv[0] = deps.bins?.[cli] ?? built.argv[0];
616
- if (role === 'coder') {
617
- const hit = isForbidden(built.argv, opts.forbidden ?? mergeForbidden());
618
- if (hit) throw new SessionError('forbidden', `coder argv refused before spawn: forbidden entry ${hit.id}`);
619
- }
658
+ checkBuiltArgv(built, opts, deps);
620
659
  writeSafe(stderr, `level=${level} provider=${step.provider} model=${step.model} effort=${step.effort ?? 'none'} fallback_step=${step.fallback_step}\n`);
621
660
  const base = { role, level, provider: step.provider, model: step.model, effort: step.effort ?? null, fallback_step: step.fallback_step, run: opts.run ?? null, block: opts.block ?? null, ...(role === 's2' ? { source: step.fallback_step > 0 ? 's2-fallback' : 's2' } : {}), ...sandboxRowField(opts, cli), ...(opts.rowExtra ?? {}) };
622
661
 
@@ -910,6 +949,18 @@ function buildStep(opts, step, sessionDir, openBook, coderPrompt = null) {
910
949
  return { ...built, argv: [...built.argv], compiled, strict };
911
950
  }
912
951
 
952
+ /**
953
+ * Apply the binary override before the coder's forbidden-argv check, in both launch modes.
954
+ * @param {{cli: string, argv: string[]}} built @param {SessionOpts} opts @param {SessionDeps} deps
955
+ */
956
+ function checkBuiltArgv(built, opts, deps) {
957
+ built.argv[0] = deps.bins?.[built.cli] ?? built.argv[0];
958
+ if (opts.role === 'coder') {
959
+ const hit = isForbidden(built.argv, opts.forbidden ?? mergeForbidden());
960
+ if (hit) throw new SessionError('forbidden', `coder argv refused before spawn: forbidden entry ${hit.id}`);
961
+ }
962
+ }
963
+
913
964
  /**
914
965
  * B93: `{sandbox: <profile>}` for a Codex coder session row when `coder.sandbox` is set; `{}`
915
966
  * otherwise, so a row without the key is byte-equal to the row before B93.
@@ -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
  }
@@ -27,7 +27,7 @@ import { checkWorkerPin, readRun, saveRun, withRunLock, writeSigned } from './ru
27
27
  import { canonicalJSON, verifyRow, loadKey, signRow } from './signer.mjs';
28
28
  import { readAllRows } from '../ledger/write.mjs';
29
29
  import * as reviewQueue from '../worker/queue.mjs';
30
- import { contentHash, repoRootOf } from '../worker/ticket.mjs';
30
+ import { assertInsideRoot, contentHash, repoRootOf } from '../worker/ticket.mjs';
31
31
 
32
32
  /** @typedef {import('./run.mjs').WriteRow} WriteRow */
33
33
 
@@ -293,6 +293,16 @@ function activeBlocks(record) {
293
293
  return Object.fromEntries(Object.entries(record.blocks).filter(([, b]) => b.status === 'open'));
294
294
  }
295
295
 
296
+ /** Earlier claims are authoritative only when their run, block and MAC match.
297
+ * @param {Record<string, any>} record @param {string} id @returns {Promise<string[]>}
298
+ */
299
+ async function signedClaims(record, id) {
300
+ const key = await loadKey(record.run_id);
301
+ const rows = await readAllRows(record.project);
302
+ return [...new Set(rows.filter((row) => row.event === 'block.claim' && row.run === record.run_id && row.block === id && verifyRow(row, key).ok)
303
+ .map((row) => assertOwned([row.path])[0]))];
304
+ }
305
+
296
306
  /**
297
307
  * B58: with `review.external.required` set in the run's snapshot, the project's `.code-forge.yml`
298
308
  * is the owner's switch past the local per-file review, so no block may own it. These are the
@@ -387,7 +397,7 @@ function refuseConfigOwner(owned, kinds, guarded) {
387
397
  * decision?: {question: 'lane', answer: string, source: 'rules', facts: Record<string, unknown>}, decisionId?: string,
388
398
  * worktree?: (ctx: {baseSha: string, baseExplicit: boolean, previous: TreeRecord | null}) => Promise<{path: string, make: () => Promise<OpenTree>}>,
389
399
  * }} opts
390
- * @returns {Promise<{block: Record<string, any>, pointer: string | null, worktreeReused: boolean}>} `worktreeReused`:
400
+ * @returns {Promise<{block: Record<string, any>, pointer: string | null, worktreeReused: boolean, keptClaims: number}>} `worktreeReused`:
391
401
  * the block took an earlier attempt's worktree as it was.
392
402
  */
393
403
  export async function openBlock(opts) {
@@ -398,12 +408,12 @@ export async function openBlock(opts) {
398
408
  if (lines !== null && !(Number.isInteger(lines) && lines > 0)) throw new StateError('bad-lines', 'lines forecast must be a positive integer');
399
409
  if (opts.kind !== undefined && opts.kind !== 'light' && !BLOCK_KINDS.includes(opts.kind)) throw new StateError('bad-kind', `kind must be one of ${[...BLOCK_KINDS, 'light'].join(', ')}`);
400
410
  if (opts.decision && opts.decision.answer !== level) throw new StateError('bad-decision', `the decision answer ${opts.decision.answer} is not the requested level ${level}`);
401
- const owned = assertOwned(opts.owned);
411
+ const declaredOwned = assertOwned(opts.owned);
402
412
  const acceptance = assertAcceptance(opts.acceptance);
403
413
  const humanChecks = acceptance.filter((c) => c.kind === 'human_check').map((c) => c.clause);
404
414
  const tests = opts.tests === undefined ? null : [...assertOwned(opts.tests, { label: 'test files', code: 'bad-tests' })];
405
415
  const after = opts.after === undefined ? null : [...new Set(opts.after)];
406
- const kind = opts.kind === 'light' ? 'light' : blockKind({ owned, declared: opts.kind });
416
+ const kind = opts.kind === 'light' ? 'light' : blockKind({ owned: declaredOwned, declared: opts.kind });
407
417
  const dispatched = dispatchLevel({ lane: level, kind, cfg: opts.cfg });
408
418
  const pointer = opts.brief ? briefPointer(opts.brief.path, opts.brief.content) : null;
409
419
 
@@ -428,7 +438,7 @@ export async function openBlock(opts) {
428
438
  /** @type {OpenTree | null} */
429
439
  let made = null;
430
440
  try {
431
- const { block, reused } = await mutateRun(runId, null, writeRow, async (record) => {
441
+ const { block, reused, keptClaims } = await mutateRun(runId, null, writeRow, async (record) => {
432
442
  if (record.blocks[id]?.status === 'open') throw new StateError('already-open', `block ${id} is already open`);
433
443
  if (record.blocks[id]?.status === 'awaiting_human_check') throw new StateError('already-open', `block ${id} is awaiting a human check`);
434
444
  if (after !== null) {
@@ -441,6 +451,8 @@ export async function openBlock(opts) {
441
451
  }
442
452
  // B57: what each exact owned entry is on disk, under the lock (as `block claim`) — a regular
443
453
  // file owns only itself, here and in every later check
454
+ const claims = attempt > 1 ? await signedClaims(record, id) : [];
455
+ const owned = [...new Set([...declaredOwned, ...claims])];
444
456
  const kinds = ownedKinds(record.workspace, owned);
445
457
  refuseConfigOwner(owned, kinds, guarded);
446
458
  const prior = previousTree(record);
@@ -467,7 +479,7 @@ export async function openBlock(opts) {
467
479
  const floor = dispatched.trigger === 'docs_floor' ? { requested_level: level, trigger: 'docs_floor' } : {};
468
480
  const created = tree?.created ? [{ event: 'worktree.created', block: id, path: tree.worktree.path, branch: tree.worktree.branch, base: tree.worktree.base_sha }] : [];
469
481
  return {
470
- result: { block: entry, reused: tree !== null && !tree.created },
482
+ result: { block: entry, reused: tree !== null && !tree.created, keptClaims: claims.length },
471
483
  rows: [
472
484
  ...(opts.decision ? [{ ...opts.decision, event: 'decision', decision_id: opts.decisionId ?? randomUUID(), block: id }] : []),
473
485
  ...created,
@@ -475,7 +487,7 @@ export async function openBlock(opts) {
475
487
  ],
476
488
  };
477
489
  });
478
- return { block, pointer, worktreeReused: reused };
490
+ return { block, pointer, worktreeReused: reused, keptClaims };
479
491
  } catch (err) {
480
492
  // B62a: nothing was recorded, so a tree this open made must not outlive it
481
493
  if (made?.created && made.rollback) {
@@ -493,9 +505,12 @@ export async function openBlock(opts) {
493
505
 
494
506
  /** @param {{runId: string, id: string, writeRow: WriteRow}} opts */
495
507
  export async function attemptBlock({ runId, id, writeRow }) {
496
- return mutateRun(runId, id, writeRow, (_record, block) => {
508
+ return mutateRun(runId, id, writeRow, async (record, block) => {
509
+ const claims = await signedClaims(record, id);
510
+ block.owned_files = [...new Set([...block.owned_files, ...claims])];
511
+ block.owned_kinds = { ...ownedKinds(record.workspace, claims), ...block.owned_kinds };
497
512
  block.attempt += 1;
498
- return { result: block.attempt, rows: [{ event: 'block.attempt', block: id, attempt: block.attempt }] };
513
+ return { result: { attempt: block.attempt, keptClaims: claims.length }, rows: [{ event: 'block.attempt', block: id, attempt: block.attempt }] };
499
514
  });
500
515
  }
501
516
 
@@ -598,8 +613,20 @@ async function rebaseWorktreeBlock({ runId, id, writeRow, acceptChanged, reason,
598
613
  const before = closed ? await committedBlobs(tree, reviewedHead, files) : null;
599
614
  const moved = { old_base: block.base_sha, new_base: newBase };
600
615
  try {
601
- const rebased = await exec(['git', '-C', tree, 'rebase', newBase], { cwd: record.workspace, env: gitEnv(), timeoutMs: 30000 });
602
- 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
+ }
603
630
  const after = before === null ? null : await committedBlobs(tree, 'HEAD', files);
604
631
  const changed = after === null ? [] : files.filter((file) => before.get(file) !== after.get(file));
605
632
  const repoRoot = await repoRootOf(record.workspace);
@@ -637,9 +664,9 @@ async function rebaseWorktreeBlock({ runId, id, writeRow, acceptChanged, reason,
637
664
  await saveRun(record);
638
665
  return moved;
639
666
  } catch (err) {
640
- // --abort restores a conflicted rebase; after a completed rebase there is nothing to
641
- // abort, and reset restores the old branch head. The tree was clean before either.
642
- 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 });
643
670
  await git(['git', 'reset', '--hard', oldHead], tree);
644
671
  throw err;
645
672
  }
@@ -654,25 +681,33 @@ async function rebaseWorktreeBlock({ runId, id, writeRow, acceptChanged, reason,
654
681
  * B58: with `review.external.required` set, a path that owns the project's config is refused
655
682
  * (`config-owned`), as at `block open`. B62a: two blocks that each have their own worktree may
656
683
  * own the same path (`blocksOverlap`).
657
- * @param {{runId: string, id: string, file: string, writeRow: WriteRow}} opts
684
+ * @param {{runId: string, id: string, file: string, writeRow: WriteRow, auto?: boolean}} opts
658
685
  */
659
- export async function claimPath({ runId, id, file, writeRow }) {
686
+ export async function claimPath({ runId, id, file, writeRow, auto = false }) {
660
687
  assertOwned([file]);
661
688
  if (!isExactEntry(file)) throw new StateError('bad-owned', `block claim takes one exact path, not a glob (${JSON.stringify(file)})`);
662
689
  const guarded = await guardedConfigPaths(await readRun(runId));
663
- await mutateRun(runId, id, writeRow, (record, block) => {
664
- const kinds = { [file]: pathKind(record.workspace, file) };
690
+ await mutateRun(runId, id, writeRow, async (record, block) => {
691
+ const root = auto ? await repoTop(block.worktree?.path ?? record.workspace) : record.workspace;
692
+ if (auto) {
693
+ if (file.split('/').some((part) => ['.git', '.code-forge'].includes(part.toLowerCase()))) throw new StateError('unowned_change', `unowned_change ${file}: cannot auto-claim repository state`);
694
+ try { assertInsideRoot(root, file); }
695
+ catch { throw new StateError('unowned_change', `unowned_change ${file}: cannot auto-claim a path outside the repository or a symlink`); }
696
+ }
697
+ const kinds = { [file]: pathKind(root, file) };
665
698
  refuseConfigOwner([file], kinds, guarded);
699
+ const prefix = auto ? path.relative(await repoTop(record.workspace), realpathSync(record.workspace)).split(path.sep).join('/') : '';
666
700
  for (const [otherId, other] of Object.entries(activeBlocks(record))) {
667
- const pair = otherId === id ? null : blocksOverlap({ owned_files: [file], owned_kinds: kinds, worktree: block.worktree ?? null }, other);
668
- if (pair) throw new StateError('overlap', `${file} overlaps ${pair[1]} owned by open block ${otherId}`);
701
+ const pair = otherId === id ? null : blocksOverlap({ owned_files: [file], owned_kinds: kinds, worktree: auto ? null : block.worktree ?? null }, other);
702
+ const relativeOwner = auto && otherId !== id && prefix !== '' && file.startsWith(`${prefix}/`) && ownsFile(other.owned_files, file.slice(prefix.length + 1), other.owned_kinds);
703
+ if (pair || relativeOwner) throw new StateError(auto ? 'unowned_change' : 'overlap', `${auto ? 'unowned_change ' : ''}${file} overlaps ${pair?.[1] ?? file} owned by open block ${otherId}`);
669
704
  }
670
705
  if (!block.owned_files.includes(file)) {
671
706
  block.owned_files.push(file);
672
707
  block.owned_kinds = { ...block.owned_kinds, ...kinds };
673
708
  }
674
709
  record.orphans = record.orphans.filter((/** @type {string} */ o) => !ownsFile([file], o, kinds));
675
- return { result: undefined, rows: [{ event: 'block.claim', block: id, path: file }] };
710
+ return { result: undefined, rows: [{ event: 'block.claim', block: id, path: file, ...(auto ? { auto: true, reason: 'auto-claim: changed outside owned files' } : {}) }] };
676
711
  });
677
712
  }
678
713
 
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) {
@@ -0,0 +1,133 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { accessSync, constants, statSync } from 'node:fs';
3
+ import net from 'node:net';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import pkg from '../../package.json' with { type: 'json' };
8
+ import { exec } from './exec.mjs';
9
+
10
+ /** @returns {string | null} the optional executable shipped for this host. */
11
+ export function nativePath() {
12
+ if (process.env.CODE_FORGE_NATIVE === '0') return null;
13
+ const binary = fileURLToPath(new URL(`../../native/bin/${process.platform}-${process.arch}/code-forge-native`, import.meta.url));
14
+ try {
15
+ if (!statSync(binary).isFile()) return null;
16
+ accessSync(binary, constants.X_OK);
17
+ return binary;
18
+ } catch { return null; }
19
+ }
20
+
21
+ /**
22
+ * @param {string[]} args
23
+ * @returns {Promise<import('./exec.mjs').ExecResult | null>} null requests the Node fallback.
24
+ */
25
+ export async function runNative(args) {
26
+ const binary = nativePath();
27
+ return binary ? exec([binary, ...args], { timeoutMs: 30_000 }) : null;
28
+ }
29
+
30
+ /** @param {Record<string, any> | null} [config] @param {NodeJS.ProcessEnv} [env] */
31
+ export function daemonEnabled(config, env = process.env) {
32
+ return env.CODE_FORGE_NATIVE !== '0' && config?.native?.daemon !== false;
33
+ }
34
+
35
+ /**
36
+ * One connection per call. Ping negotiates the package version before sending the method;
37
+ * no socket is opened when disabled. Protocol and transport failures request Node fallback.
38
+ * @param {string} run
39
+ * @param {{method: string, block?: string, max_ms?: number}} request
40
+ * @param {{config?: Record<string, any> | null, timeoutMs?: number}} [opts]
41
+ * @returns {Promise<Record<string, any> | null>}
42
+ */
43
+ export async function daemonRequest(run, request, { config, timeoutMs = request.method === 'wait-block' ? (request.max_ms ?? 0) + 2500 : 6500 } = {}) {
44
+ if (!daemonEnabled(config) || !/^[a-z0-9][a-z0-9-]{0,63}$/.test(run)) return null;
45
+ return new Promise((resolve) => {
46
+ let settled = false;
47
+ let negotiated = false;
48
+ let buffer = '';
49
+ const socket = net.createConnection(path.join(os.homedir(), '.code-forge', 'runs', run, 'native.sock'));
50
+ socket.setEncoding('utf8');
51
+ const finish = (/** @type {Record<string, any> | null} */ value) => {
52
+ if (settled) return;
53
+ settled = true;
54
+ clearTimeout(timer);
55
+ socket.destroy();
56
+ resolve(value);
57
+ };
58
+ let timer = setTimeout(() => finish(null), Math.min(2000, timeoutMs));
59
+ const deadline = (/** @type {number} */ ms) => {
60
+ clearTimeout(timer);
61
+ timer = setTimeout(() => finish(null), ms);
62
+ };
63
+ socket.on('error', () => finish(null));
64
+ socket.on('close', () => finish(null));
65
+ socket.on('connect', () => {
66
+ deadline(Math.min(2000, timeoutMs));
67
+ socket.write('{"method":"ping"}\n');
68
+ });
69
+ socket.on('data', (chunk) => {
70
+ buffer += chunk;
71
+ if (buffer.length > 8 * 1024 * 1024) return finish(null);
72
+ const end = buffer.indexOf('\n');
73
+ if (end < 0) return;
74
+ let answer;
75
+ try { answer = JSON.parse(buffer.slice(0, end)); } catch { return finish(null); }
76
+ buffer = buffer.slice(end + 1);
77
+ if (!answer || typeof answer !== 'object' || Array.isArray(answer) || answer.ok === false) return finish(null);
78
+ if (!negotiated) {
79
+ if (answer.ok !== true || answer.version !== pkg.version || answer.run !== run) return finish(null);
80
+ if (request.method === 'ping') return finish(answer);
81
+ negotiated = true;
82
+ deadline(timeoutMs);
83
+ socket.write(`${JSON.stringify(request)}\n`);
84
+ } else {
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);
88
+ if (request.method === 'wait-block' && (result.block !== request.block || !Array.isArray(result.files) || typeof result.done !== 'boolean'
89
+ || !result.files.every((/** @type {any} */ file) => file && typeof file.file === 'string' && typeof file.status === 'string'))) return finish(null);
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);
91
+ finish(result);
92
+ }
93
+ });
94
+ }).catch(() => null);
95
+ }
96
+
97
+ /**
98
+ * The daemon serializes startup with its own OS lock. A loser exits harmlessly;
99
+ * only this child is stopped, never a daemon another worker already owns.
100
+ * @param {string} run @param {Record<string, any> | null} config
101
+ * @param {NodeJS.ProcessEnv} [env]
102
+ * @returns {Promise<() => Promise<void>>}
103
+ */
104
+ export async function startNativeDaemon(run, config, env = process.env) {
105
+ const noop = async () => {};
106
+ if (!daemonEnabled(config, env)) return noop;
107
+ const binary = nativePath();
108
+ if (!binary || await daemonRequest(run, { method: 'ping' }, { config })) return noop;
109
+ let child;
110
+ try { child = spawn(binary, ['serve', '--home', os.homedir(), '--run', run], { stdio: 'ignore', env }); }
111
+ catch { return noop; }
112
+ let ended = false;
113
+ const exited = new Promise((resolve) => {
114
+ child.once('error', () => { ended = true; resolve(undefined); });
115
+ child.once('exit', () => { ended = true; resolve(undefined); });
116
+ });
117
+ for (let i = 0; i < 100 && !ended; i++) {
118
+ if (await daemonRequest(run, { method: 'ping' }, { config, timeoutMs: 100 })) break;
119
+ await new Promise((resolve) => setTimeout(resolve, 20));
120
+ }
121
+ /** @type {Promise<void> | undefined} */
122
+ let stopping;
123
+ return () => {
124
+ stopping ??= (async () => {
125
+ if (ended) return;
126
+ child.kill('SIGTERM');
127
+ const timer = setTimeout(() => child.kill('SIGKILL'), 2000);
128
+ timer.unref();
129
+ try { await exited; } finally { clearTimeout(timer); }
130
+ })();
131
+ return stopping;
132
+ };
133
+ }
@@ -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 });