@codedology/code-forge 0.3.2 → 0.5.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 (72) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +88 -21
  3. package/bin/code-forge.mjs +6 -1
  4. package/docs/README.md +5 -4
  5. package/docs/reference/blocks.json +236 -1
  6. package/docs/reference/config.md +13 -1
  7. package/package.json +6 -2
  8. package/schema/code-forge.schema.json +26 -4
  9. package/skill/SKILL.md +8 -7
  10. package/skill/references/adapters/claude-code.md +5 -1
  11. package/skill/references/adapters/solo.md +1 -1
  12. package/skill/references/adapters/subprocess.md +1 -1
  13. package/skill/references/code.md +4 -3
  14. package/skill/references/continuity.md +4 -0
  15. package/skill/references/decisions.md +5 -2
  16. package/skill/references/ledger.md +10 -2
  17. package/skill/references/plan.md +12 -3
  18. package/skill/references/review.md +13 -6
  19. package/skill/references/security.md +3 -1
  20. package/skill/scripts/forge +1 -1
  21. package/skill/templates/coder-brief.md +36 -0
  22. package/skill/templates/plan.md +2 -0
  23. package/src/cli/block.mjs +51 -8
  24. package/src/cli/facts.mjs +16 -1
  25. package/src/cli/jev.mjs +114 -17
  26. package/src/cli/ledger.mjs +99 -8
  27. package/src/cli/logs.mjs +193 -58
  28. package/src/cli/plan.mjs +27 -11
  29. package/src/cli/report.mjs +15 -1
  30. package/src/cli/resolve.mjs +16 -1
  31. package/src/cli/review-file.mjs +10 -5
  32. package/src/cli/review.mjs +40 -9
  33. package/src/cli/run.mjs +109 -8
  34. package/src/config/closed-book.mjs +75 -0
  35. package/src/config/validate.mjs +128 -7
  36. package/src/decide/escalation.mjs +112 -11
  37. package/src/decide/questions.mjs +27 -4
  38. package/src/doctor/index.mjs +2 -1
  39. package/src/doctor/local.mjs +15 -0
  40. package/src/doctor/probes.mjs +48 -6
  41. package/src/engines/builders/claude.mjs +4 -1
  42. package/src/engines/builders/codex.mjs +26 -11
  43. package/src/engines/builders/grok.mjs +3 -3
  44. package/src/engines/efforts.mjs +109 -0
  45. package/src/engines/sentinel.mjs +34 -0
  46. package/src/keys/onepassword.mjs +3 -0
  47. package/src/ledger/report.mjs +66 -29
  48. package/src/ledger/spend.mjs +288 -0
  49. package/src/review/engine.mjs +57 -16
  50. package/src/review/fixloop.mjs +29 -6
  51. package/src/review/session-retry.mjs +69 -0
  52. package/src/review/triage.mjs +26 -1
  53. package/src/review/validate-review.mjs +6 -3
  54. package/src/session/author.mjs +6 -2
  55. package/src/session/facts.mjs +218 -53
  56. package/src/session/plan-check.mjs +71 -6
  57. package/src/session/plan-sections.mjs +98 -0
  58. package/src/session/spawn.mjs +373 -20
  59. package/src/state/block.mjs +13 -4
  60. package/src/state/config-snapshot.mjs +150 -0
  61. package/src/state/run.mjs +86 -0
  62. package/src/util/breadcrumbs.mjs +145 -0
  63. package/src/util/error-hint.mjs +3 -2
  64. package/src/util/error-kind.mjs +2 -2
  65. package/src/util/error-log.mjs +109 -18
  66. package/src/util/forbidden.mjs +3 -1
  67. package/src/util/known-fixes.json +1 -0
  68. package/src/util/known-fixes.mjs +153 -0
  69. package/src/util/locks.mjs +104 -0
  70. package/src/worker/engine.mjs +122 -40
  71. package/src/worker/loop.mjs +326 -78
  72. package/src/worker/queue.mjs +68 -7
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Known fixes (B38): a small table shipped in the package, `src/util/known-fixes.json`, that maps
3
+ * an error fingerprint (`util/error-log.mjs` `fingerprint`) to the version that fixed it.
4
+ *
5
+ * [{ "fp": "<12 hex>", "fixed_in": "x.y.z", "summary": "<one plain line>", "issue": <number|null> }]
6
+ *
7
+ * `code-forge logs` marks a known-fixed line `[fixed in x.y.z]`; `logs report` does not file an
8
+ * error that is fixed in a newer version than the one running (unless `--force`) and notes a
9
+ * possible regression when the running version already has the fix. The maintainer adds entries
10
+ * with `npm run known-fix -- add …` (`scripts/known-fix.mjs`, which uses {@link validateKnownFixes});
11
+ * `.github/workflows/error-triage.yml` reads the same file to comment on new error reports.
12
+ *
13
+ * Reading never throws: a missing or unreadable table is empty, and an entry that does not
14
+ * validate is left out (a broken table must never hide a report).
15
+ */
16
+
17
+ import { readFileSync } from 'node:fs';
18
+ import path from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+
21
+ /** The shipped table. */
22
+ export const KNOWN_FIXES_FILE = path.join(path.dirname(fileURLToPath(import.meta.url)), 'known-fixes.json');
23
+
24
+ /** A summary longer than this is refused (it is one plain line). */
25
+ export const SUMMARY_MAX_CHARS = 200;
26
+
27
+ const FP_RE = /^[0-9a-f]{12}$/;
28
+ const VERSION_RE = /^(0|[1-9]\d{0,5})\.(0|[1-9]\d{0,5})\.(0|[1-9]\d{0,5})$/;
29
+ const KEYS = Object.freeze(['fp', 'fixed_in', 'summary', 'issue']);
30
+
31
+ /** @typedef {{fp: string, fixed_in: string, summary: string, issue: number|null}} KnownFix */
32
+
33
+ /**
34
+ * Compare two semver versions: `x.y.z`, then a pre-release is older than its release
35
+ * (`1.2.0-beta.1` < `1.2.0`) and pre-releases compare part by part (numbers as numbers, numbers
36
+ * before words). Build metadata (`+…`) is ignored.
37
+ * @param {string} a @param {string} b
38
+ * @returns {number|null} >0 when a is newer, <0 when older, 0 when equal; null when unreadable.
39
+ */
40
+ export function compareVersions(a, b) {
41
+ const re = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
42
+ const pa = typeof a === 'string' ? re.exec(a.trim()) : null;
43
+ const pb = typeof b === 'string' ? re.exec(b.trim()) : null;
44
+ if (!pa || !pb) return null;
45
+ for (let i = 1; i <= 3; i += 1) {
46
+ const d = Number(pa[i]) - Number(pb[i]);
47
+ if (d !== 0) return d;
48
+ }
49
+ if (pa[4] === undefined || pb[4] === undefined) return pa[4] === pb[4] ? 0 : pa[4] === undefined ? 1 : -1;
50
+ const xa = pa[4].split('.');
51
+ const xb = pb[4].split('.');
52
+ for (let i = 0; i < Math.max(xa.length, xb.length); i += 1) {
53
+ if (xa[i] === undefined) return -1;
54
+ if (xb[i] === undefined) return 1;
55
+ const na = /^\d+$/.test(xa[i]);
56
+ const nb = /^\d+$/.test(xb[i]);
57
+ if (na && nb) {
58
+ const d = Number(xa[i]) - Number(xb[i]);
59
+ if (d !== 0) return d;
60
+ } else if (na !== nb) {
61
+ return na ? -1 : 1;
62
+ } else if (xa[i] !== xb[i]) {
63
+ return xa[i] < xb[i] ? -1 : 1;
64
+ }
65
+ }
66
+ return 0;
67
+ }
68
+
69
+ /**
70
+ * The problems of one entry, each naming the key only (never its value).
71
+ * @param {unknown} e
72
+ * @returns {string[]} empty when the entry is valid.
73
+ */
74
+ export function entryProblems(e) {
75
+ if (e === null || typeof e !== 'object' || Array.isArray(e)) return ['not an object'];
76
+ const o = /** @type {Record<string, unknown>} */ (e);
77
+ const problems = [];
78
+ for (const k of Object.keys(o)) if (!KEYS.includes(k)) problems.push(`unknown key ${JSON.stringify(k)}`);
79
+ if (typeof o.fp !== 'string' || !FP_RE.test(o.fp)) problems.push('fp must be 12 lowercase hex characters');
80
+ if (typeof o.fixed_in !== 'string' || !VERSION_RE.test(o.fixed_in)) problems.push('fixed_in must be a version x.y.z');
81
+ if (typeof o.summary !== 'string' || o.summary.trim().length === 0) problems.push('summary must be a non-empty string');
82
+ else if (o.summary !== o.summary.trim() || /[\u0000-\u001f\u007f-\u009f\u2028\u2029\u202a-\u202e\u2066-\u2069]/.test(o.summary)) problems.push('summary must be one plain line (no line breaks, tabs, control or text-direction characters, or leading/trailing spaces)');
83
+ else if ([...o.summary].length > SUMMARY_MAX_CHARS) problems.push(`summary must be at most ${SUMMARY_MAX_CHARS} characters`);
84
+ if (!Object.hasOwn(o, 'issue') || !(o.issue === null || (Number.isSafeInteger(o.issue) && /** @type {number} */ (o.issue) > 0))) {
85
+ problems.push('issue must be a positive issue number or null');
86
+ }
87
+ return problems;
88
+ }
89
+
90
+ /**
91
+ * Validate a whole table: an array of valid entries, each fingerprint once.
92
+ * @param {unknown} data
93
+ * @returns {string[]} the problems (`entry 2: fp must be …`); empty when the table is valid.
94
+ */
95
+ export function validateKnownFixes(data) {
96
+ if (!Array.isArray(data)) return ['the table must be a JSON array'];
97
+ const problems = [];
98
+ /** @type {Map<string, number>} */
99
+ const seen = new Map();
100
+ data.forEach((e, i) => {
101
+ for (const p of entryProblems(e)) problems.push(`entry ${i + 1}: ${p}`);
102
+ const fp = /** @type {any} */ (e)?.fp;
103
+ if (typeof fp === 'string' && FP_RE.test(fp)) {
104
+ if (seen.has(fp)) problems.push(`entry ${i + 1}: fp is already listed in entry ${seen.get(fp)}`);
105
+ else seen.set(fp, i + 1);
106
+ }
107
+ });
108
+ return problems;
109
+ }
110
+
111
+ /**
112
+ * The valid entries of the table at `file`, by fingerprint (first one wins). Never throws.
113
+ * @param {string} [file]
114
+ * @returns {Map<string, KnownFix>}
115
+ */
116
+ export function loadKnownFixes(file = KNOWN_FIXES_FILE) {
117
+ let data;
118
+ try {
119
+ data = JSON.parse(readFileSync(file, 'utf8'));
120
+ } catch {
121
+ return new Map();
122
+ }
123
+ return knownFixMap(data);
124
+ }
125
+
126
+ /**
127
+ * The valid entries of `data`, by fingerprint (first one wins); anything else is left out.
128
+ * @param {unknown} data
129
+ * @returns {Map<string, KnownFix>}
130
+ */
131
+ export function knownFixMap(data) {
132
+ /** @type {Map<string, KnownFix>} */
133
+ const out = new Map();
134
+ if (!Array.isArray(data)) return out;
135
+ for (const e of data) {
136
+ if (entryProblems(e).length > 0 || out.has(e.fp)) continue;
137
+ out.set(e.fp, { fp: e.fp, fixed_in: e.fixed_in, summary: e.summary, issue: e.issue });
138
+ }
139
+ return out;
140
+ }
141
+
142
+ /**
143
+ * How a known fix relates to the running version: `older` (the fix is in a newer version than the
144
+ * one running), `has-fix` (the running version is the fixed one or newer: a possible regression),
145
+ * or `unknown` (the running version cannot be read).
146
+ * @param {KnownFix} fix @param {string} running
147
+ * @returns {'older'|'has-fix'|'unknown'}
148
+ */
149
+ export function fixStatus(fix, running) {
150
+ const c = compareVersions(running, fix.fixed_in);
151
+ if (c === null) return 'unknown';
152
+ return c < 0 ? 'older' : 'has-fix';
153
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * In-process locks (parallel review, block B40): a keyed mutex and a counting semaphore.
3
+ *
4
+ * Both live in this process only — no files, no cross-process coordination — and use no timers,
5
+ * so a waiting lock never keeps the event loop alive on its own.
6
+ *
7
+ * `keyedLock(key, fn)`: calls for the same key run one at a time, in call order (FIFO); calls for
8
+ * different keys run together. A throw (or rejection) in `fn` releases the key and is passed on.
9
+ *
10
+ * `semaphore(n)`: `acquire()` resolves to a `release` function once fewer than `n` holders are
11
+ * active; waiters are served in order. `release` is idempotent (a second call does nothing).
12
+ * `run(fn)` acquires, runs `fn`, and releases on every exit path.
13
+ */
14
+
15
+ /** @type {Map<unknown, Promise<void>>} the tail of each key's queue. */
16
+ const tails = new Map();
17
+
18
+ /**
19
+ * Run `fn` once every earlier call for `key` has finished.
20
+ * @template T
21
+ * @param {unknown} key
22
+ * @param {() => T | Promise<T>} fn
23
+ * @returns {Promise<T>}
24
+ */
25
+ export function keyedLock(key, fn) {
26
+ const prev = tails.get(key) ?? Promise.resolve();
27
+ /** @type {() => void} */
28
+ let release = () => {};
29
+ const done = new Promise((resolve) => {
30
+ release = () => resolve(undefined);
31
+ });
32
+ const tail = prev.then(() => done);
33
+ tails.set(key, tail);
34
+ return prev.then(async () => {
35
+ try {
36
+ return await fn();
37
+ } finally {
38
+ release();
39
+ if (tails.get(key) === tail) tails.delete(key);
40
+ }
41
+ });
42
+ }
43
+
44
+ /**
45
+ * @typedef {object} Semaphore
46
+ * @property {() => Promise<() => void>} acquire - resolves to an idempotent `release`.
47
+ * @property {<T>(fn: () => T | Promise<T>) => Promise<T>} run - `fn` under one slot, released on every exit path.
48
+ * @property {number} active - holders right now.
49
+ * @property {number} pending - waiters right now.
50
+ */
51
+
52
+ /**
53
+ * A counting semaphore: never more than `n` holders at once.
54
+ * @param {number} n - an integer ≥ 1.
55
+ * @returns {Semaphore}
56
+ */
57
+ export function semaphore(n) {
58
+ if (!Number.isInteger(n) || n < 1) throw new TypeError('semaphore: n must be an integer >= 1');
59
+ let active = 0;
60
+ /** @type {Array<(release: () => void) => void>} */
61
+ const waiters = [];
62
+
63
+ /** @returns {() => void} */
64
+ function grant() {
65
+ active += 1;
66
+ let released = false;
67
+ return () => {
68
+ if (released) return;
69
+ released = true;
70
+ active -= 1;
71
+ const next = waiters.shift();
72
+ if (next) next(grant());
73
+ };
74
+ }
75
+
76
+ /** @returns {Promise<() => void>} */
77
+ function acquire() {
78
+ if (active < n && waiters.length === 0) return Promise.resolve(grant());
79
+ return new Promise((resolve) => {
80
+ waiters.push(resolve);
81
+ });
82
+ }
83
+
84
+ /** @template T @param {() => T | Promise<T>} fn @returns {Promise<T>} */
85
+ async function run(fn) {
86
+ const release = await acquire();
87
+ try {
88
+ return await fn();
89
+ } finally {
90
+ release();
91
+ }
92
+ }
93
+
94
+ return {
95
+ acquire,
96
+ run,
97
+ get active() {
98
+ return active;
99
+ },
100
+ get pending() {
101
+ return waiters.length;
102
+ },
103
+ };
104
+ }
@@ -36,6 +36,7 @@
36
36
 
37
37
  import { rmSync } from 'node:fs';
38
38
  import path from 'node:path';
39
+ import { blockKind } from '../decide/escalation.mjs';
39
40
  import { askJev } from '../decide/jev-client.mjs';
40
41
  import { planAndRecord, tierOf } from '../review/budget.mjs';
41
42
  import { budgetFor } from '../review/packet.mjs';
@@ -44,7 +45,8 @@ import { computeFileSet, ownsFile } from '../gates/scope.mjs';
44
45
  import { newFileState, runRound } from '../review/fixloop.mjs';
45
46
  import { readRun } from '../state/run.mjs';
46
47
  import { redact } from '../util/redact.mjs';
47
- import { blockRungUsed, loadState, saveState } from './review-state.mjs';
48
+ import { keyedLock } from '../util/locks.mjs';
49
+ import { blockAnchors, blockRungUsed, loadState, saveState } from './review-state.mjs';
48
50
  import { assertTicketId } from './ticket.mjs';
49
51
 
50
52
  /**
@@ -89,14 +91,17 @@ import { assertTicketId } from './ticket.mjs';
89
91
  * `block-unknown`; a record that cannot be read or parsed throws `run-record-unreadable`, and a
90
92
  * block entry without a base `block-base-missing` — a guessed base could let a change escape review.
91
93
  * @param {string} runId @param {string} block
92
- * @returns {Promise<{base: string | null, level: string, owned: string[], acceptance?: unknown}>}
94
+ * The block's kind (B34) is the recorded `kind` when `block open` stored one, else `blockKind`
95
+ * of its owned files.
96
+ * With no run record the kind is null: `reviewTicket` takes the ticket file's kind.
97
+ * @returns {Promise<{base: string | null, level: string, owned: string[], acceptance?: unknown, kind: string | null}>}
93
98
  */
94
99
  export async function blockEntryFor(runId, block) {
95
100
  let record;
96
101
  try {
97
102
  record = await readRun(runId);
98
103
  } catch (err) {
99
- if (/** @type {any} */ (err)?.code === 'no-run') return { base: null, level: 'L2', owned: [], acceptance: undefined };
104
+ if (/** @type {any} */ (err)?.code === 'no-run') return { base: null, level: 'L2', owned: [], acceptance: undefined, kind: null };
100
105
  throw new Error('run-record-unreadable');
101
106
  }
102
107
  if (!record || typeof record !== 'object' || !record.blocks || typeof record.blocks !== 'object') throw new Error('run-record-unreadable');
@@ -105,7 +110,7 @@ export async function blockEntryFor(runId, block) {
105
110
  const sha = entry?.base_sha;
106
111
  if (typeof sha !== 'string' || sha.length === 0) throw new Error('block-base-missing');
107
112
  const owned = Array.isArray(entry.owned_files) ? entry.owned_files.filter((f) => typeof f === 'string') : [];
108
- return { base: sha, level: typeof entry.level === 'string' && /^L[0-3]$/.test(entry.level) ? entry.level : 'L2', owned, acceptance: entry.acceptance };
113
+ return { base: sha, level: typeof entry.level === 'string' && /^L[0-3]$/.test(entry.level) ? entry.level : 'L2', owned, acceptance: entry.acceptance, kind: blockKind({ owned, declared: entry.kind }) };
109
114
  }
110
115
 
111
116
  /** The acceptance line written when the block's clauses cannot be read. */
@@ -189,60 +194,137 @@ export async function reviewTicket(ticket, ctx) {
189
194
  } catch (err) {
190
195
  return { status: 'unavailable', reason: err instanceof Error ? err.message : 'run-record-unreadable', approved: false, engine: 'adaptive', sessions: [] };
191
196
  }
197
+ if (entry.kind === null) entry = { ...entry, kind: blockKind({ owned: [ticket.file] }) };
192
198
  try {
193
199
  if (!ctx.key) {
194
200
  return await reviewFile(
195
- { repoRoot: ctx.repoRoot, file: ticket.file, base: entry.base, cfg: ctx.cfg, workDir, factsExcerpt: acceptanceExcerpt(entry.acceptance, ctx.cfg) },
201
+ { repoRoot: ctx.repoRoot, file: ticket.file, base: entry.base, cfg: ctx.cfg, kind: entry.kind, workDir, factsExcerpt: acceptanceExcerpt(entry.acceptance, ctx.cfg) },
196
202
  { spawn: ctx.spawn, ...(ctx.writeRow ? { writeRow: ctx.writeRow } : {}) },
197
203
  );
198
204
  }
199
- return await fixLoopRound(ticket, ctx, { base: entry.base, level: entry.level, owned: entry.owned, key: ctx.key, workDir, factsExcerpt: acceptanceExcerpt(entry.acceptance, ctx.cfg) });
205
+ return await fixLoopRound(ticket, ctx, { base: entry.base, level: entry.level, owned: entry.owned, blockKindOf: entry.kind, key: ctx.key, workDir, factsExcerpt: acceptanceExcerpt(entry.acceptance, ctx.cfg) });
200
206
  } finally {
201
207
  rmSync(workDir, { recursive: true, force: true });
202
208
  }
203
209
  }
204
210
 
211
+ /**
212
+ * The key of a block's lock (B42): the block's budget row and its L3 rung are taken under it.
213
+ * It carries the run, so two runs in one process never share a lock.
214
+ * @param {string} runId @param {string} block @returns {string}
215
+ */
216
+ export const blockLockKey = (runId, block) => `block:${runId}\0${block}`;
217
+
218
+ /**
219
+ * Whether the block's one L3 rung is taken: a verified anchor says so (`blockRungUsed`), OR another
220
+ * file of the block has a saved state whose round decided `next: patch` — that round took the rung,
221
+ * but its anchor records `l3_rung_used` only once the patch_check ticket saves. A sibling state that
222
+ * does not load is counted as taken (fail closed; its own ticket refuses it).
223
+ * @param {import('./review-state.mjs').Where} where
224
+ * @returns {{status: 'ok' | 'tampered', used: boolean}}
225
+ */
226
+ export function rungTaken(where) {
227
+ const rung = blockRungUsed(where);
228
+ if (rung.status === 'tampered' || rung.used) return rung;
229
+ const anchors = blockAnchors(where) ?? [];
230
+ const siblings = new Set(anchors.map((a) => a.file).filter((f) => typeof f === 'string' && f !== where.file));
231
+ for (const file of siblings) {
232
+ let other;
233
+ try {
234
+ other = loadState({ ...where, file });
235
+ } catch {
236
+ return { status: 'ok', used: true };
237
+ }
238
+ if (other.status !== 'ok' || other.state?.next?.action === 'patch') return { status: 'ok', used: true };
239
+ }
240
+ return rung;
241
+ }
242
+
243
+ /**
244
+ * Whether this round could take the block's L3 rung (so it must run under the block lock): never
245
+ * when the rung is taken or the round is the patch_check (the rung is already this file's); never
246
+ * for a file's first full round while the cap and the per-level ladder are both above 1 (round 1
247
+ * cannot stall, reach the cap or exhaust the ladder); else conservatively yes.
248
+ * @param {import('../review/fixloop.mjs').FileState} state @param {'full' | 'recheck' | 'patch_check'} kind
249
+ * @param {Record<string, any>} cfg @param {boolean} rungUsed
250
+ * @returns {boolean}
251
+ */
252
+ export function mayTakeRung(state, kind, cfg, rungUsed) {
253
+ if (rungUsed || kind === 'patch_check') return false;
254
+ const int = (/** @type {unknown} */ v, /** @type {number} */ d) => (Number.isInteger(v) && /** @type {number} */ (v) >= 0 ? /** @type {number} */ (v) : d);
255
+ const maxRounds = int(cfg?.review?.max_rounds_per_file, 4);
256
+ const perLevel = int(cfg?.escalation?.review_rounds_per_level, 2);
257
+ if (kind === 'full' && state.round === 0 && state.rounds_at_level === 0 && maxRounds > 1 && perLevel > 1) return false;
258
+ return true;
259
+ }
260
+
205
261
  /**
206
262
  * One fix-loop round for the ticket (see the module doc).
263
+ *
264
+ * Concurrency (B42): the worker runs several tickets at once but never two for the same
265
+ * (block, file) (its file lock, `loop.mjs`), so this file's state `seq` is read and saved by one
266
+ * ticket at a time. What the files of a BLOCK share is taken under the block lock
267
+ * (`blockLockKey`): the budget row's check-then-write (written once per block) and the L3 rung —
268
+ * the rung read and, for a round that could take the rung (`mayTakeRung`), the whole round and its
269
+ * state save, so two files of one block never both take it. A round that cannot take the rung (a
270
+ * first full round, or any round once the rung is gone) runs outside the block lock, in parallel
271
+ * with the block's other files.
207
272
  * @param {import('./queue.mjs').Ticket} ticket @param {ReviewContext} ctx
208
- * @param {{base: string | null, level: string, owned: string[], key: Buffer, workDir: string, factsExcerpt: string}} opts
273
+ * @param {{base: string | null, level: string, owned: string[], blockKindOf: string, key: Buffer, workDir: string, factsExcerpt: string}} opts
209
274
  * @returns {Promise<ReviewOutcome>}
210
275
  */
211
- async function fixLoopRound(ticket, ctx, { base, level, owned, key, workDir, factsExcerpt }) {
276
+ async function fixLoopRound(ticket, ctx, { base, level, owned, blockKindOf, key, workDir, factsExcerpt }) {
212
277
  if (!ctx.readRows || !ctx.writeRow) return { status: 'unavailable', reason: 'no-ledger', approved: false, engine: 'adaptive', sessions: [] };
213
- let rows;
214
- try {
215
- rows = (await ctx.readRows()).filter((r) => r?.run === ctx.runId);
216
- } catch (err) {
217
- return { status: 'unavailable', reason: failureReason(err), approved: false, engine: 'adaptive', sessions: [] };
218
- }
219
- // Check-then-write is safe here: a run has exactly ONE worker, and it processes its tickets one
220
- // at a time (`loop.mjs` awaits each `processTicket`), so no second ticket of this block can read
221
- // the rows between this check and the budget row's write.
222
- if (!rows.some((r) => r?.event === 'review.budget' && r.block === ticket.block)) {
223
- await recordBlockBudget(ticket, ctx, { base, owned });
224
- }
225
- const where = { runRootDir: ctx.runRootDir, runId: ctx.runId, block: ticket.block, file: ticket.file, key, rows };
226
- const loaded = loadState(where);
227
- const rung = blockRungUsed(where);
228
- if (loaded.status === 'tampered' || rung.status === 'tampered') return { status: 'refused', reason: 'review-state-tampered', approved: false, engine: 'adaptive', sessions: [] };
229
- const rungUsed = rung.used;
230
- let state = loaded.state;
231
- if (state === null || state.status === 'complete') state = newFileState({ file: ticket.file, level, l3RungUsed: rungUsed });
232
- state.l3_rung_used = state.l3_rung_used || rungUsed;
278
+ const readRows = ctx.readRows;
279
+ /** @type {{outcome: ReviewOutcome} | {round: () => Promise<ReviewOutcome>}} */
280
+ const prepared = await keyedLock(blockLockKey(ctx.runId, ticket.block), async () => {
281
+ let rows;
282
+ try {
283
+ rows = (await readRows()).filter((r) => r?.run === ctx.runId);
284
+ } catch (err) {
285
+ return { outcome: /** @type {ReviewOutcome} */ ({ status: 'unavailable', reason: failureReason(err), approved: false, engine: 'adaptive', sessions: [] }) };
286
+ }
287
+ // Check-then-write under the block lock: no other ticket of this block reads the rows between
288
+ // this check and the budget row's write, so the row is written once per block.
289
+ if (!rows.some((r) => r?.event === 'review.budget' && r.block === ticket.block)) {
290
+ await recordBlockBudget(ticket, ctx, { base, owned });
291
+ }
292
+ const where = { runRootDir: ctx.runRootDir, runId: ctx.runId, block: ticket.block, file: ticket.file, key, rows };
293
+ const loaded = loadState(where);
294
+ const rung = rungTaken(where);
295
+ if (loaded.status === 'tampered' || rung.status === 'tampered') return { outcome: /** @type {ReviewOutcome} */ ({ status: 'refused', reason: 'review-state-tampered', approved: false, engine: 'adaptive', sessions: [] }) };
296
+ const rungUsed = rung.used;
297
+ let state = loaded.state;
298
+ if (state === null || state.status === 'complete') state = newFileState({ file: ticket.file, level, l3RungUsed: rungUsed });
299
+ state.l3_rung_used = state.l3_rung_used || rungUsed;
233
300
 
234
- if (state.status === 'stopped') {
235
- return { ...loopFields(state, null), status: 'stopped', stopped: state.next?.reason ?? 'stopped', approved: false, engine: 'adaptive', sessions: [], findings: state.open };
236
- }
301
+ if (state.status === 'stopped') {
302
+ return { outcome: /** @type {ReviewOutcome} */ ({ ...loopFields(state, null), status: 'stopped', stopped: state.next?.reason ?? 'stopped', approved: false, engine: 'adaptive', sessions: [], findings: state.open }) };
303
+ }
237
304
 
238
- /** @type {'full' | 'recheck' | 'patch_check'} */
239
- let kind = state.round === 0 ? 'full' : 'recheck';
240
- if (state.next?.action === 'retry') kind = state.pending_kind ?? kind;
241
- else if (state.next?.action === 'patch') {
242
- kind = 'patch_check';
243
- state.l3_rung_used = true; // the orchestrator ran the block's one L3 patch before this ticket
244
- }
305
+ /** @type {'full' | 'recheck' | 'patch_check'} */
306
+ let kind = state.round === 0 ? 'full' : 'recheck';
307
+ if (state.next?.action === 'retry') kind = state.pending_kind ?? kind;
308
+ else if (state.next?.action === 'patch') {
309
+ kind = 'patch_check';
310
+ state.l3_rung_used = true; // the orchestrator ran the block's one L3 patch before this ticket
311
+ }
312
+ const fixed = state;
313
+ const round = () => playRound(ticket, ctx, { state: fixed, kind, where, seq: loaded.seq, base, blockKindOf, workDir, factsExcerpt });
314
+ // a round that could take the rung keeps the block lock until its state is saved
315
+ if (mayTakeRung(fixed, kind, ctx.cfg, rungUsed)) return { outcome: await round() };
316
+ return { round };
317
+ });
318
+ return 'outcome' in prepared ? prepared.outcome : prepared.round();
319
+ }
245
320
 
321
+ /**
322
+ * Run the prepared round and save the file's state: the ticket's outcome.
323
+ * @param {import('./queue.mjs').Ticket} ticket @param {ReviewContext} ctx
324
+ * @param {{state: import('../review/fixloop.mjs').FileState, kind: 'full' | 'recheck' | 'patch_check', where: import('./review-state.mjs').Where, seq: number, base: string | null, blockKindOf: string, workDir: string, factsExcerpt: string}} opts
325
+ * @returns {Promise<ReviewOutcome>}
326
+ */
327
+ async function playRound(ticket, ctx, { state, kind, where, seq, base, blockKindOf, workDir, factsExcerpt }) {
246
328
  /** @type {Record<string, any> | null} */
247
329
  let engineOutcome = null;
248
330
  let approvalWritten = false;
@@ -276,7 +358,7 @@ async function fixLoopRound(ticket, ctx, { base, level, owned, key, workDir, fac
276
358
  factsExcerpt,
277
359
  ...(jev ? { jev } : {}),
278
360
  review: async () => {
279
- engineOutcome = await reviewFile({ repoRoot: ctx.repoRoot, file: ticket.file, base, cfg: ctx.cfg, workDir, factsExcerpt }, { spawn: ctx.spawn, writeRow });
361
+ engineOutcome = await reviewFile({ repoRoot: ctx.repoRoot, file: ticket.file, base, cfg: ctx.cfg, kind: blockKindOf, workDir, factsExcerpt }, { spawn: ctx.spawn, writeRow });
280
362
  return engineOutcome;
281
363
  },
282
364
  spawn: async (opts) => {
@@ -302,7 +384,7 @@ async function fixLoopRound(ticket, ctx, { base, level, owned, key, workDir, fac
302
384
  state.pending_kind = kind;
303
385
  }
304
386
  }
305
- await saveState({ ...where, state, seq: loaded.seq, writeRow: /** @type {(row: Record<string, any>) => Promise<unknown>} */ (ctx.writeRow) });
387
+ await saveState({ ...where, state, seq, writeRow: /** @type {(row: Record<string, any>) => Promise<unknown>} */ (ctx.writeRow) });
306
388
 
307
389
  const eng = /** @type {Record<string, any> | null} */ (engineOutcome);
308
390
  const head = { ...loopFields(state, kind), engine: eng?.engine ?? 'adaptive', sessions: eng?.sessions ?? rechecks };