@codedology/code-forge 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli/block.mjs CHANGED
@@ -193,6 +193,7 @@
193
193
  * through `closeBlock`'s `extraChecks`.
194
194
  */
195
195
 
196
+ import { statSync } from 'node:fs';
196
197
  import { readFile, realpath } from 'node:fs/promises';
197
198
  import path from 'node:path';
198
199
  import { parse as parseYAML } from 'yaml';
@@ -201,6 +202,7 @@ import { fallbackLane } from '../decide/fallback-rules.mjs';
201
202
  import { laneFacts } from '../decide/lane-facts.mjs';
202
203
  import { appendRow, readAllRows } from '../ledger/write.mjs';
203
204
  import { attemptBlock, claimPath, closeBlock, openBlock, rebaseBlock, repoTop, stopBlock } from '../state/block.mjs';
205
+ import { lastGate } from '../state/brief-status.mjs';
204
206
  import { intFlag, parseFlags } from '../state/cli-args.mjs';
205
207
  import { configInForce, externalReviewer, requireCoderTests } from '../state/config-snapshot.mjs';
206
208
  import { StateError } from '../state/paths.mjs';
@@ -288,6 +290,24 @@ const USAGE =
288
290
  ' every block verb takes --json: one JSON line on stdout ({"block","event",…}) instead of the text\n';
289
291
 
290
292
  const SUBCOMMANDS = ['open', 'attempt', 'rebase', 'claim', 'close', 'stop', 'waive', 'reopen'];
293
+
294
+ /** Refuse likely shell quoting slips before any row is written.
295
+ * @param {string} workspace @param {string[]} entries @param {string} label
296
+ */
297
+ function checkWhitespacePaths(workspace, entries, label) {
298
+ for (const entry of entries) {
299
+ if (!/\s/u.test(entry)) continue;
300
+ let stat;
301
+ try {
302
+ stat = statSync(path.join(workspace, entry));
303
+ } catch {
304
+ // A missing or unreadable path cannot establish a literal whitespace-containing name.
305
+ }
306
+ if (!stat?.isFile() && !stat?.isDirectory()) {
307
+ throw new StateError(label === 'owned files' ? 'bad-owned' : 'bad-tests', `${label}: invalid path ${JSON.stringify(entry)}: contains whitespace but names no existing file or directory`);
308
+ }
309
+ }
310
+ }
291
311
  const FLAG_VALUES = ['run', 'level', 'acceptance', 'brief', 'attempt', 'base', 'lines', 'head', 'worker-pid', 'reason', 'file', 'transcript', 'kind', 'report'];
292
312
 
293
313
  /**
@@ -345,6 +365,10 @@ export async function runBlock(args, deps = {}) {
345
365
  if (sub === 'reopen' && typeof flags.file !== 'string') throw new StateError('usage', 'block reopen needs --file <path>');
346
366
  if (sub === 'waive' && typeof flags.file !== 'string') throw new StateError('usage', 'block waive needs --file <path> (a finding id is scoped to one file)');
347
367
  const record = await readRun(runId);
368
+ if (sub === 'open') {
369
+ checkWhitespacePaths(record.workspace, /** @type {string[]} */ (flags.owned), 'owned files');
370
+ if (Array.isArray(flags.tests)) checkWhitespacePaths(record.workspace, flags.tests, 'test files');
371
+ }
348
372
  // B80: waive and reopen are the human's — asked at a terminal before anything is written (not
349
373
  // even the autopilot expiry rows below); no terminal is exit 2, a "no" is exit 1
350
374
  if (sub === 'waive' || sub === 'reopen') {
@@ -761,6 +785,9 @@ export async function runBlock(args, deps = {}) {
761
785
  // B70: each file that passed as reviewed_by_waiver is named before the closed line
762
786
  // B71: an answered finding is named as answered; a file with only answered findings has no `by`
763
787
  if (result.ok) {
788
+ if (configInForce(record)?.gates?.test != null && lastGate(rows.filter((row) => row.block === id)).result !== 'pass') {
789
+ err(`WARN block ${id}: no green gates run for this block; run gates run --run ${runId} --block ${id} before close\n`);
790
+ }
764
791
  for (const w of lineWarnings) err(w);
765
792
  for (const l of reviewedJson) say(`reviewed ${l.file} ticket ${l.ticket}\n`);
766
793
  for (const f of waivedAll) {
@@ -16,7 +16,8 @@
16
16
  * `<block>.md`. A row that cannot be appended removes only the temp file, so an earlier pack stays
17
17
  * as it was and nothing was written. A rename that fails after the row was appended says so.
18
18
  *
19
- * Exit codes: 0 written, 1 refused (no `context` key, a rules or lessons file missing or outside
19
+ * With no `context` key, the pack holds only the block header and built-in proof rule (300 lines).
20
+ * Exit codes: 0 written, 1 refused (a rules or lessons file missing or outside
20
21
  * the repository, a bad `context` value, a control character, an unknown run or block, a file or
21
22
  * ledger write that fails), 2 usage. Never runs any command it lists.
22
23
  */
@@ -94,7 +95,7 @@ export async function runContext(args, deps = {}) {
94
95
  if (!loaded.ok) throw new ContextError('bad-config', loaded.message);
95
96
  const live = /** @type {Record<string, any>} */ (loaded.config);
96
97
  const settings = contextSettings(live);
97
- if (settings === null) throw new ContextError('no-context', 'the config has no context key: add context (rules, lessons, commands, max_lines) to .code-forge.yml');
98
+ if (settings === null && Object.hasOwn(live, 'context')) throw new ContextError('bad-context', 'context must be a mapping');
98
99
  const sources = readSources(repoRoot, settings);
99
100
  const { text, lines, cut } = assemblePack({
100
101
  settings,
@@ -69,7 +69,7 @@
69
69
 
70
70
  import { readFileSync, realpathSync, rmSync } from 'node:fs';
71
71
  import path from 'node:path';
72
- import { answersTicketId, assertAnswers, blockTickets, dropDoneIf, enqueue, enqueueAnswers, isDone, liveWorker, MAX_ANSWERS, queueDir, resolveTicket } from '../worker/queue.mjs';
72
+ import { answersTicketId, assertAnswers, blockTickets, deliverBlockFindings, dropDoneIf, enqueue, enqueueAnswers, isDone, liveWorker, MAX_ANSWERS, queueDir, resolveTicket } from '../worker/queue.mjs';
73
73
  import { assertBlockId, assertInsideRoot, assertTicketId, contentHash, mainRootOf, normalizeRequestPath, repoRootOf, WorkerError } from '../worker/ticket.mjs';
74
74
  import { blockFileSet, namedOnHash } from '../review/gate-check.mjs';
75
75
  import { STOP_EXITS } from '../review/stop-exits.mjs';
@@ -391,21 +391,22 @@ async function waitBlockScope(repoRoot, cwd, run, block) {
391
391
  }
392
392
 
393
393
  /** A block wait reads only the marker, never review results or the signing key.
394
- * @param {string} repoRoot @param {string} ticket @returns {boolean}
394
+ * @param {string} repoRoot @param {string} ticket @returns {Record<string, any> | null}
395
395
  */
396
- function blockTicketDone(repoRoot, ticket) {
396
+ function blockTicketMarker(repoRoot, ticket) {
397
397
  try {
398
398
  const marker = JSON.parse(readFileSync(path.join(queueDir(repoRoot), `${ticket}.done`), 'utf8'));
399
- if (marker?.ticket === ticket && typeof marker.status === 'string' && marker.status.length > 0) return true;
399
+ if (marker?.ticket === ticket && typeof marker.status === 'string' && marker.status.length > 0) return marker;
400
400
  } catch (err) {
401
- if (err.code === 'ENOENT') return false;
401
+ if (err.code === 'ENOENT') return null;
402
402
  }
403
403
  throw new WorkerError('result-unreadable', 'a ticket completion marker cannot be read');
404
404
  }
405
405
 
406
406
  /**
407
- * Join current-content tickets only. Re-scan on every poll, including before returning done,
408
- * so a file edited while waiting gets a new ticket. The worker alone finishes old tickets.
407
+ * A block wait reads only completion/delivery markers, never review results or the signing key.
408
+ * Re-scan current content on every poll. Deliver unseen findings of the newest earlier ticket
409
+ * before rechecking a blind edit; the next join queues the fix. Stopped files stay finished.
409
410
  * @param {{repoRoot: string, cwd: string, block: string, run: string, maxMs: number, pollMs: number, say: (obj: Record<string, any>) => unknown}} opts
410
411
  */
411
412
  async function waitBlock({ repoRoot, cwd, block, run, maxMs, pollMs, say }) {
@@ -413,6 +414,7 @@ async function waitBlock({ repoRoot, cwd, block, run, maxMs, pollMs, say }) {
413
414
  const where = realpathSync(tree) === realpathSync(repoRoot) ? {} : { tree: realpathSync(tree) };
414
415
  const deadline = Date.now() + maxMs;
415
416
  let downPolls = 0;
417
+ const deliveredHere = new Set();
416
418
  for (;;) {
417
419
  const worker = liveWorker(repoRoot);
418
420
  const live = worker?.run === run;
@@ -424,17 +426,28 @@ async function waitBlock({ repoRoot, cwd, block, run, maxMs, pollMs, say }) {
424
426
  const { ticket } = resolveTicket(opts);
425
427
  const previous = tickets.filter((t) => t.file === file);
426
428
  let present = previous.find((t) => t.ticket === ticket);
429
+ if (!present && previous.length > 0) {
430
+ const newest = previous[previous.length - 1];
431
+ const marker = blockTicketMarker(repoRoot, newest.ticket);
432
+ if (marker?.status === 'stopped') return { file, ticket: newest.ticket, status: 'done' };
433
+ if (marker?.status === 'reviewed' && marker.open_findings === true && live) {
434
+ if (deliveredHere.has(newest.ticket) || deliverBlockFindings({ repoRoot, block, file, ticket: newest.ticket })) {
435
+ deliveredHere.add(newest.ticket);
436
+ return { file, ticket: newest.ticket, status: 'findings' };
437
+ }
438
+ }
439
+ }
427
440
  if (!present && previous.length > 0 && live) {
428
441
  enqueue(opts);
429
442
  present = { ...previous[previous.length - 1], ticket };
430
443
  }
431
- return { file, ticket: present ? ticket : null, status: !present ? 'not_queued' : blockTicketDone(repoRoot, ticket) ? 'done' : 'pending' };
444
+ return { file, ticket: present ? ticket : null, status: !present ? 'not_queued' : blockTicketMarker(repoRoot, ticket) ? 'done' : 'pending' };
432
445
  });
433
446
  if (downPolls >= 2) {
434
447
  say({ block, files, done: false, status: 'worker_down' });
435
448
  return 3;
436
449
  }
437
- if (live && files.every((f) => f.status === 'done')) {
450
+ if (live && files.every((f) => f.status === 'done' || f.status === 'findings')) {
438
451
  say({ block, files, done: true });
439
452
  return 0;
440
453
  }
@@ -48,7 +48,7 @@ import { semaphore } from '../util/locks.mjs';
48
48
  import { writeSafe } from '../util/redact.mjs';
49
49
  import { isAlive, listEntries, readStartTime, UNKNOWN_START_TIME } from '../util/reaper.mjs';
50
50
  import { pidsDir, runRoot, setRunRoot, tmpBase } from '../util/tmp.mjs';
51
- import { readWorker } from '../worker/queue.mjs';
51
+ import { liveWorker } from '../worker/queue.mjs';
52
52
  import { gitChildEnv, normalizeRequestPath, repoRootOf, WorkerError } from '../worker/ticket.mjs';
53
53
 
54
54
  const BIN = fileURLToPath(new URL('../../bin/code-forge.mjs', import.meta.url));
@@ -288,7 +288,7 @@ async function runWorkers(repoRoot, runId) {
288
288
  } catch {
289
289
  // no record: nothing pinned
290
290
  }
291
- const w = readWorker(repoRoot);
291
+ const w = liveWorker(repoRoot);
292
292
  if (w?.run === runId && !found.some((f) => f.pid === w.pid)) {
293
293
  found.push({ pid: w.pid, same: async () => w.start_time === UNKNOWN_START_TIME || readStartTime(w.pid) === w.start_time });
294
294
  }
@@ -59,7 +59,7 @@ export const DIGEST_LESSONS_HEADING = '### lessons';
59
59
  * A refusal with a stable `code`. Thrown here: `bad-context` (a bad `context` value),
60
60
  * `control-character` (in a path or a header value), `outside`, `missing` (ENOENT), `unreadable`
61
61
  * (any other lstat or read error, with its code), `not-file`. Thrown by `src/cli/context.mjs`:
62
- * `no-block`, `bad-config`, `no-context`, `no-run-root`, `write-failed`.
62
+ * `no-block`, `bad-config`, `no-run-root`, `write-failed`.
63
63
  */
64
64
  export class ContextError extends Error {
65
65
  /** @param {string} code @param {string} message */
@@ -216,11 +216,12 @@ function readLines(repoRoot, rel, key) {
216
216
 
217
217
  /**
218
218
  * Every rules file and the lessons file, read. A missing or outside file is a refusal.
219
- * @param {string} repoRoot @param {ContextSettings} settings
219
+ * @param {string} repoRoot @param {ContextSettings | null} settings
220
220
  * @returns {{rules: Array<{path: string, lines: string[]}>, lessons: {path: string, lines: string[]} | null}}
221
221
  * @throws {ContextError}
222
222
  */
223
223
  export function readSources(repoRoot, settings) {
224
+ if (settings === null) return { rules: [], lessons: null };
224
225
  const rules = settings.rules.map((rel) => ({ path: rel, lines: readLines(repoRoot, rel, 'context.rules') }));
225
226
  const lessons = settings.lessons === null ? null : { path: settings.lessons, lines: readLines(repoRoot, settings.lessons, 'context.lessons') };
226
227
  return { rules, lessons };
@@ -249,7 +250,8 @@ export const argvText = (argv) => argv.map(argvToken).join(' ');
249
250
 
250
251
  /**
251
252
  * Build the pack text for a block (pure over what it is given).
252
- * @param {{settings: ContextSettings, sources: ReturnType<typeof readSources>, block: PackBlock}} opts
253
+ * Missing settings give a header and proof rule only, with the default line cap.
254
+ * @param {{settings: ContextSettings | null, sources: ReturnType<typeof readSources>, block: PackBlock}} opts
253
255
  * @returns {{text: string, lines: number, cut: number}} `cut`: the lines dropped.
254
256
  * @throws {ContextError} `control-character` when a header value holds one.
255
257
  */
@@ -267,11 +269,13 @@ export function assemblePack({ settings, sources, block }) {
267
269
  for (const f of block.tests) header.push(`- ${f}`);
268
270
  }
269
271
  if (block.worktree) header.push(`worktree: ${block.worktree.path}`, `branch: ${block.worktree.branch}`);
270
- header.push('## commands (never run by code-forge)');
271
- const keys = COMMAND_KEYS.filter((k) => Object.hasOwn(settings.commands, k));
272
- if (keys.length === 0) header.push('(none)');
273
- // a command line can name a secret (a token in an argv): it is redacted as rules and lessons are
274
- for (const k of keys) header.push(`${k}: ${redact(argvText(settings.commands[k]))}`);
272
+ if (settings !== null) {
273
+ header.push('## commands (never run by code-forge)');
274
+ const keys = COMMAND_KEYS.filter((k) => Object.hasOwn(settings.commands, k));
275
+ if (keys.length === 0) header.push('(none)');
276
+ // a command line can name a secret (a token in an argv): it is redacted as rules and lessons are
277
+ for (const k of keys) header.push(`${k}: ${redact(argvText(settings.commands[k]))}`);
278
+ }
275
279
 
276
280
  header.push(...splitLines(PROOF_RULE));
277
281
 
@@ -282,7 +286,7 @@ export function assemblePack({ settings, sources, block }) {
282
286
  const total = () => header.length + sections.reduce((n, s) => n + 1 + s.body.length + (s.dropped > 0 ? 1 : 0), 0);
283
287
  // cut order: the lessons first, then the rules from the last file backwards
284
288
  for (const s of [...sections].reverse()) {
285
- let excess = total() - settings.maxLines;
289
+ let excess = total() - (settings?.maxLines ?? DEFAULT_MAX_LINES);
286
290
  if (excess <= 0) break;
287
291
  // the marker takes one line: a section is only cut when that leaves it shorter
288
292
  if (s.body.length < 2) continue;
@@ -13,7 +13,7 @@ import path from 'node:path';
13
13
  import { cliNameForProvider } from '../engines/provider-cli.mjs';
14
14
  import { newRunId } from '../state/paths.mjs';
15
15
  import { runRoot } from '../util/tmp.mjs';
16
- import { checkCommands, checkConfig, checkJev, checkKeys, checkLedger, checkLinks, checkReviewConcurrency, checkReviewState, checkTmp, checkTools } from './local.mjs';
16
+ import { checkCommands, checkConfig, checkJev, checkKeys, checkLedger, checkLinks, checkLocks, checkReviewConcurrency, checkReviewState, checkTmp, checkTools } from './local.mjs';
17
17
  import { checkCoderSandbox, configuredProviders, pingRoles, probeCli, probeCodexRules, probeIsolation, probePathDeny } from './probes.mjs';
18
18
  import { row } from './rows.mjs';
19
19
  import { probeWorkerAndSigner, SIGNER_ROW } from './worker-probe.mjs';
@@ -67,6 +67,7 @@ export async function runDoctor(opts = {}, deps = {}) {
67
67
  };
68
68
  rows.push(...(await checkTools(toolOpts)));
69
69
  rows.push(...(await checkLedger()));
70
+ rows.push(...(await checkLocks()));
70
71
  rows.push(...checkTmp(cfg));
71
72
  rows.push(...(await checkReviewState(cfg)));
72
73
  rows.push(checkReviewConcurrency(cfg));
@@ -25,13 +25,32 @@ import { assertRunId, runsDir } from '../state/paths.mjs';
25
25
  import { readRun } from '../state/run.mjs';
26
26
  import { loadKey } from '../state/signer.mjs';
27
27
  import { listEntries } from '../util/reaper.mjs';
28
+ import { listBadLocks } from '../util/file-locks.mjs';
28
29
  import { ownerState, runRootPath, tmpBase } from '../util/tmp.mjs';
29
30
  import { parallelTickets } from '../worker/loop.mjs';
31
+ import { workerRegistry } from '../worker/queue.mjs';
32
+ import { mainRootOf } from '../worker/ticket.mjs';
30
33
  import { missingStateFiles } from '../worker/review-state.mjs';
31
34
  import { row } from './rows.mjs';
32
35
 
33
36
  /** @typedef {import('./rows.mjs').Row} Row */
34
37
 
38
+ /** @param {string} [cwd] @returns {Promise<Row[]>} */
39
+ export async function checkLocks(cwd = process.cwd()) {
40
+ try {
41
+ const paths = listBadLocks();
42
+ const rows = paths.length === 0
43
+ ? [row('locks', 'OK', 'locks', 'no unreadable or malformed locks')]
44
+ : paths.map((file) => row('locks', 'WARN', 'locks', file));
45
+ let repoRoot = cwd;
46
+ try { repoRoot = await mainRootOf(cwd); } catch { /* non-repository doctor */ }
47
+ rows.push(...workerRegistry(repoRoot).filter((entry) => entry.worker === null).map((entry) => row('worker-registry', 'WARN', 'worker registry', entry.file)));
48
+ return rows;
49
+ } catch (err) {
50
+ return [row('locks', 'WARN', 'locks', `could not list locks (${err?.code ?? 'error'})`)];
51
+ }
52
+ }
53
+
35
54
  /** The ids of the `--quick` rows, in print order. */
36
55
  export const QUICK_IDS = Object.freeze(['config', 'links', 'keys']);
37
56
 
@@ -11,8 +11,9 @@
11
11
  * MAC is tamper evidence within ordinary tool use, never a boundary against a same-user process.
12
12
  */
13
13
 
14
- import { copyFileSync, mkdirSync, readdirSync, rmSync } from 'node:fs';
14
+ import { copyFileSync, mkdirSync, readdirSync, rmSync, symlinkSync } from 'node:fs';
15
15
  import path from 'node:path';
16
+ import { commandOnPath } from '../install/detect.mjs';
16
17
  import { runsDir } from '../state/paths.mjs';
17
18
  import { startRun } from '../state/run.mjs';
18
19
  import { loadKey, signRow, verifyRow } from '../state/signer.mjs';
@@ -94,7 +95,19 @@ export async function probeWorkerAndSigner(ctx) {
94
95
  const signerOk = verifyRow(signed, key).ok && verifyRow(flipped, key).reason === 'mismatch';
95
96
  rows.push(row('signer-key', signerOk ? 'OK' : 'FAIL', 'signer key', signerOk ? 'generated; a signed row verifies, a changed one is refused' : 'sign/verify round trip failed'));
96
97
 
97
- const started = await launchWorker({ runId, workspace: repo, env });
98
+ // The boot lock needs ps even with a restricted provider PATH. Expose just that utility.
99
+ let workerEnv = env;
100
+ if (!await commandOnPath('ps', { pathEnv: env.PATH ?? '' })) {
101
+ const tools = path.join(repo, 'worker-tools');
102
+ for (const systemDir of ['/bin', '/usr/bin']) {
103
+ if (!await commandOnPath('ps', { pathEnv: systemDir })) continue;
104
+ mkdirSync(tools, { mode: 0o700 });
105
+ symlinkSync(path.join(systemDir, 'ps'), path.join(tools, 'ps'));
106
+ workerEnv = { ...env, PATH: [env.PATH, tools].filter(Boolean).join(path.delimiter) };
107
+ break;
108
+ }
109
+ }
110
+ const started = await launchWorker({ runId, workspace: repo, env: workerEnv });
98
111
  pid = started.pid;
99
112
  registerPid(pidsDir(ctx.runRootDir), pid, 'code-forge worker'); // a killed doctor's worker is reaped by the next sweep
100
113
  const announced = liveWorker(started.repoRoot)?.pid === pid;
@@ -12,7 +12,7 @@
12
12
  */
13
13
 
14
14
  import { logWarning } from '../util/error-log.mjs';
15
- import { keyedLock } from '../util/locks.mjs';
15
+ import { withLock } from '../util/file-locks.mjs';
16
16
  import { writeSafe } from '../util/redact.mjs';
17
17
  import { estimateCostUsd } from './prices.mjs';
18
18
 
@@ -199,7 +199,7 @@ export const SPEND_UNREADABLE_MESSAGE = "budget.usd is set but the run's spend c
199
199
  * Several sessions of one run can start at once (B40 slots, B42 ticket pool). The ledger only
200
200
  * learns a session's cost when its row is written, so a plain "spent < budget" check lets every
201
201
  * session that starts before the first row lands go through. Instead the check AND a reservation
202
- * of the session's estimated cost happen under ONE in-process lock per run (`withBudgetLock`):
202
+ * of the session's estimated cost happen under ONE cross-process lock per run (`withBudgetLock`):
203
203
  *
204
204
  * spent ≥ budget, or spent + reserved + estimate > budget ⇒ refused (the B33 message, unchanged)
205
205
  *
@@ -275,7 +275,7 @@ export function reserveBudget(run, usd) {
275
275
  */
276
276
  export function withBudgetLock(run, fn) {
277
277
  lockCalls += 1;
278
- return keyedLock(`budget:${run}`, fn);
278
+ return withLock(`budget:${run}`, fn);
279
279
  }
280
280
 
281
281
  /** Test spy: how many times {@link withBudgetLock} was entered since the last reset. */
@@ -150,7 +150,11 @@ export async function runGatesGuarded({ runId, blockId, gates, cwd, timeoutMs, t
150
150
  blockId,
151
151
  fn: async () => {
152
152
  // Plain gates without --changed need no scope or baseline.
153
- if (!hasScopeItem(gates) && !changed) return runGates(gates, { cwd, ...(timeoutMs ? { timeoutMs } : {}) });
153
+ if (!hasScopeItem(gates) && !changed) {
154
+ const record = await readRun(runId);
155
+ const ran = await runGates(gates, { cwd, ...(timeoutMs ? { timeoutMs } : {}) });
156
+ return { ...ran, ledger_slug: slug ?? record.project };
157
+ }
154
158
  // the slot checked the block is open; it may still be gone by the time it is read again
155
159
  const record = await readRun(runId);
156
160
  const block = record.blocks?.[blockId];
@@ -16,7 +16,7 @@
16
16
  * session really reviewed these bytes; a repeated stop or an `unavailable` result never counts),
17
17
  * the latest refusal row for the hash is signed, carries `content_hash` (a hash-less one does
18
18
  * not qualify, though it still refuses) and names at least one finding, and every finding it
19
- * names and every open id of the file's `review.cap` rows is cleared by a waiver the gate
19
+ * names and every open id of the file's unsuperseded `review.cap` rows is cleared by a waiver the gate
20
20
  * accepts (the same rule as below: a critical only by `by: human`). Such a file is never
21
21
  * approved; the close writes one signed `gate.review_waived_all` row for it ({@link reviewGateCheck});
22
22
  * - `unsigned`: the file's approvals for that hash exist but none carries a valid MAC;
@@ -43,7 +43,8 @@
43
43
  * - `review_cap`: a file stopped at `review_cap` has an open finding with no signed
44
44
  * `review.waived {by: human}` row — unless a signed approval of the file's current hash comes
45
45
  * AFTER the cap row (ledger order) and no later refusal row made it stale (the human fixed it
46
- * by hand and it was reviewed); a finding already refused as `not_approved` for that file is
46
+ * by hand and it was reviewed), or a verified human reopen after the cap is followed by a
47
+ * signed review round of the current bytes; a finding already refused as `not_approved` for that file is
47
48
  * not repeated. B73: this detail, and an `unreviewed` detail for a file with a cap row, ends
48
49
  * with the stop's ways out (`exits: block reopen, block waive, review-file --answers,
49
50
  * re-decompose`, `stop-exits.mjs`);
@@ -137,7 +138,7 @@ export function checkBlockReviews({ block, runId, files, rows, key, transcript =
137
138
  /** @type {Map<string, string>} */
138
139
  const current = new Map();
139
140
  for (const f of [...proofFiles, ...files]) current.set(f.file, f.content_hash);
140
- const { mine, latestOf, signedOf, matches, waiverOf, waiverFor, waivedAllOf, recordedRefusalSeverity } = reviewRules({ block, runId, rows, key, resultFiles, current });
141
+ const { mine, latestOf, signedOf, matches, waiverOf, waiverFor, waivedAllOf, recordedRefusalSeverity, supersededCap } = reviewRules({ block, runId, rows, key, resultFiles, current });
141
142
  /** @type {Refusal[]} */
142
143
  const refusals = [];
143
144
  const approvedFor = (/** @type {string} */ file, /** @type {string} */ hash) => mine.filter((r) => r.event === 'review.approved' && r.file === file && r.content_hash === hash);
@@ -216,6 +217,7 @@ export function checkBlockReviews({ block, runId, files, rows, key, transcript =
216
217
  }
217
218
 
218
219
  for (const cap of mine.filter((r) => r.event === 'review.cap')) {
220
+ if (supersededCap(cap)) continue;
219
221
  const hash = hashOf.get(cap.file);
220
222
  // honoured unless a signed approval of the current content comes AFTER the cap and is not stale
221
223
  if (hash !== undefined) {
@@ -260,6 +262,16 @@ function reviewRules({ block, runId, rows, key, resultFiles = {}, current, verif
260
262
  // namedOnHash}), which has no key and can only refuse; it then reads no autopilot waiver
261
263
  const ok = verify ?? ((/** @type {Record<string, any>} */ r) => verified(r, key));
262
264
  const mine = rows.filter((r) => r?.block === block && (runId === undefined || r.run === undefined || r.run === runId));
265
+ /** A human reopen followed by a real review of the current bytes clears older cap ids.
266
+ * @param {Record<string, any>} cap @returns {boolean}
267
+ */
268
+ const supersededCap = (cap) => {
269
+ const hash = current.get(cap.file);
270
+ if (hash === undefined) return false;
271
+ const at = mine.indexOf(cap);
272
+ return mine.some((r, i) => i > at && r.event === 'review.reopened' && r.run === runId && r.file === cap.file && r.by === 'human' && ok(r)
273
+ && mine.some((round, j) => j > i && round.event === 'review.round' && round.run === runId && round.file === cap.file && round.content_hash === hash && ok(round)));
274
+ };
263
275
  /**
264
276
  * B52: the index of the last signed approval and the latest refusal AFTER it for (file, hash);
265
277
  * `denial` is null when that approval stands (or there is none — `unreviewed` / `unsigned` say
@@ -380,7 +392,7 @@ function reviewRules({ block, runId, rows, key, resultFiles = {}, current, verif
380
392
  };
381
393
 
382
394
  /**
383
- * B79: the ids every signed `review.cap` row for (file, hash) names. A cap row records no
395
+ * B79: the ids every unsuperseded signed `review.cap` row for (file, hash) names. A cap row records no
384
396
  * severity, so the rules read each of these ids at {@link recordedRefusalSeverity} — also when
385
397
  * that cap row is the latest refusal (its own null never stands for them).
386
398
  * @param {string} file @param {string} hash @returns {Set<string>}
@@ -390,6 +402,7 @@ function reviewRules({ block, runId, rows, key, resultFiles = {}, current, verif
390
402
  const ids = new Set();
391
403
  for (const cap of mine) {
392
404
  if (cap.event !== 'review.cap' || cap.file !== file || cap.content_hash !== hash || !Array.isArray(cap.open) || !ok(cap)) continue;
405
+ if (supersededCap(cap)) continue;
393
406
  for (const id of cap.open) if (typeof id === 'string' && id.length > 0) ids.add(id);
394
407
  }
395
408
  return ids;
@@ -400,7 +413,7 @@ function reviewRules({ block, runId, rows, key, resultFiles = {}, current, verif
400
413
  * or null when no such review qualifies. All of: a signed `review.round` row for (file, hash) — a
401
414
  * session reviewed these bytes; the latest refusal row for (file, hash) is signed, carries
402
415
  * `content_hash: hash` (a hash-less one never qualifies) and names at least one finding. The ids
403
- * are every finding it names and every open id of the file's `review.cap` rows. Severities: an id
416
+ * are every finding it names and every open id of the file's unsuperseded `review.cap` rows. Severities: an id
404
417
  * a signed `review.cap` row at (file, hash) names ({@link capIdsAt}) — also when that cap row is
405
418
  * the latest refusal — and any other open cap id use {@link recordedRefusalSeverity}, whose
406
419
  * `unknown` (no signed refusal at `hash` records one) is the human's alone and never answered.
@@ -417,6 +430,7 @@ function reviewRules({ block, runId, rows, key, resultFiles = {}, current, verif
417
430
  const named = new Map(latest.open.map((f) => [f.id, f.severity]));
418
431
  for (const id of capIdsAt(file, hash)) named.set(id, recordedRefusalSeverity(file, hash, id));
419
432
  for (const cap of mine.filter((r) => r.event === 'review.cap' && r.file === file)) {
433
+ if (supersededCap(cap)) continue;
420
434
  if (!Array.isArray(cap.open) || !cap.open.every((/** @type {unknown} */ id) => typeof id === 'string' && id.length > 0)) return null;
421
435
  for (const id of cap.open) if (!named.has(id)) named.set(id, recordedRefusalSeverity(file, hash, id));
422
436
  }
@@ -477,7 +491,7 @@ function reviewRules({ block, runId, rows, key, resultFiles = {}, current, verif
477
491
  return new Map([...named].filter(([id, severity]) => id !== PROOF_FINDING && answerableSeverity(severity) && waiverOf(file, id, severity, ticket).by === null));
478
492
  };
479
493
 
480
- return { mine, latestOf, signedOf, matches, waiverOf, waiverFor, waivedAllOf, openAt, answerRows, recordedRefusalSeverity };
494
+ return { mine, latestOf, signedOf, matches, waiverOf, waiverFor, waivedAllOf, openAt, answerRows, recordedRefusalSeverity, supersededCap };
481
495
  }
482
496
 
483
497
  /**
@@ -1045,7 +1059,18 @@ export function reviewGateCheck(load, { onWaivedAll } = {}) {
1045
1059
  onWaivedAll?.(result.waivedAll);
1046
1060
  return { ok: true };
1047
1061
  }
1048
- return { ok: false, reason: result.refusals.map((r) => `${r.code}${r.file ? ` ${r.file}` : ''}${r.finding ? ` ${r.finding}` : ''}`).join('; ') };
1062
+ const reasons = [];
1063
+ const transcriptIds = new Set();
1064
+ for (const r of result.refusals) {
1065
+ const hit = r.code === 'rule_break' && /^transcript line \d+: (.+)$/.exec(r.detail);
1066
+ if (hit) {
1067
+ if (transcriptIds.has(hit[1])) continue;
1068
+ transcriptIds.add(hit[1]);
1069
+ if (transcriptIds.size <= 5) reasons.push(`rule_break ${r.detail}`);
1070
+ } else reasons.push(`${r.code}${r.file ? ` ${r.file}` : ''}${r.finding ? ` ${r.finding}` : ''}`);
1071
+ }
1072
+ if (transcriptIds.size > 5) reasons.push(`+${transcriptIds.size - 5} more rule_break`);
1073
+ return { ok: false, reason: reasons.join('; ') };
1049
1074
  };
1050
1075
  }
1051
1076
 
@@ -59,7 +59,7 @@
59
59
  * Concurrency: the worker never runs two tickets for one (block, file) at once (its file lock,
60
60
  * `worker/loop.mjs`), and round 1, its retry, every recheck and the patch_check of a file are all
61
61
  * tickets for that file, so one packet's history is read and written by one ticket at a time. The
62
- * read-history → write-mark section here also runs under an in-process lock per (packet hash,
62
+ * read-history → write-mark section here also runs under a cross-process lock per (packet hash,
63
63
  * level, role), so even the same file in two blocks cannot take a packet's one try twice.
64
64
  *
65
65
  * No ledger writer (`writeRow` absent) ⇒ nothing is logged and nothing is tried. The helpers never
@@ -71,7 +71,7 @@ import { createHash } from 'node:crypto';
71
71
  import { closedBookRefused, openBookCodexAllowed } from '../config/closed-book.mjs';
72
72
  import { resolveLevel } from '../config/known-ids.mjs';
73
73
  import { logWarning } from '../util/error-log.mjs';
74
- import { keyedLock } from '../util/locks.mjs';
74
+ import { withLock } from '../util/file-locks.mjs';
75
75
  import { schemaErrorsOf } from './validate-review.mjs';
76
76
 
77
77
  /** Broken results (B91: schema misses and timeouts) of the level's own ladder on one packet before the second level is tried. */
@@ -291,7 +291,7 @@ export async function afterSchemaMiss({ res, session, packetText, cfg, allowed,
291
291
  /** @type {{cfg: Record<string, any>, to: {level: string, provider: string, model: string}} | null} */
292
292
  let go = null;
293
293
  try {
294
- go = await keyedLock(`schema-fallback\0${hash}\0${session.level}\0${session.role}`, async () => {
294
+ go = await withLock(`schema-fallback\0${hash}\0${session.level}\0${session.role}`, async () => {
295
295
  // the history is read BEFORE a schema miss is logged: failures = signed earlier misses + this
296
296
  // one. A timeout's note is already in it (B91): it is the miss in hand, never added twice.
297
297
  let seen = null;
@@ -0,0 +1,118 @@
1
+ /** Cross-process locks, with FIFO serialization inside this process. */
2
+ import { createHash } from 'node:crypto';
3
+ import { closeSync, mkdirSync, openSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { processStartTime } from '../state/run.mjs';
7
+ import { keyedLock } from './locks.mjs';
8
+
9
+ /** @typedef {{probe?: typeof processStartTime, alive?: (pid: number) => boolean, afterRead?: () => Promise<unknown>, pollMs?: number}} LockOpts */
10
+ /** @typedef {{ok: true, release: () => void} | {ok: false, result: 'held'}} LockResult */
11
+ /** @type {{ok: false, result: 'held'}} */
12
+ const HELD = { ok: false, result: 'held' };
13
+ const locksDir = () => path.join(os.homedir(), '.code-forge', 'locks');
14
+ /** @param {string} key */
15
+ const lockPath = (key) => path.join(locksDir(), `${createHash('sha256').update(key).digest('hex')}.lock`);
16
+ /** @type {Promise<string | null> | undefined} */
17
+ let selfStart;
18
+
19
+ /** @param {number} pid */
20
+ function alive(pid) {
21
+ try { process.kill(pid, 0); return true; }
22
+ catch (err) { return err.code !== 'ESRCH'; }
23
+ }
24
+
25
+ /** @param {Buffer} bytes @returns {{key: string, pid: number, start: string} | null} */
26
+ function ownerOf(bytes) {
27
+ try {
28
+ const owner = JSON.parse(bytes.toString('utf8'));
29
+ return owner && typeof owner.key === 'string' && Number.isInteger(owner.pid) && owner.pid > 1 && typeof owner.start === 'string' ? owner : null;
30
+ } catch { return null; }
31
+ }
32
+
33
+ /** Exclusive creation; release synchronously removes only this owner's bytes.
34
+ * @param {string} file @param {Buffer} bytes @returns {LockResult}
35
+ */
36
+ function create(file, bytes) {
37
+ let fd;
38
+ try { fd = openSync(file, 'wx', 0o600); }
39
+ catch (err) { if (err.code === 'EEXIST') return HELD; throw err; }
40
+ try { writeFileSync(fd, bytes); }
41
+ finally { closeSync(fd); }
42
+ let released = false;
43
+ return { ok: true, release() {
44
+ if (released) return;
45
+ released = true;
46
+ try { if (readFileSync(file).equals(bytes)) unlinkSync(file); }
47
+ catch { /* missing, unreadable or replaced: leave it alone */ }
48
+ } };
49
+ }
50
+
51
+ /** Take a key or fail closed. A proven dead owner permits one guarded break attempt.
52
+ * @param {string} key @param {LockOpts} [opts] @returns {Promise<LockResult>}
53
+ */
54
+ export async function tryLock(key, opts = {}) {
55
+ const probe = opts.probe ?? processStartTime;
56
+ const start = await (selfStart ??= processStartTime(process.pid));
57
+ if (typeof start !== 'string') throw new Error('cannot determine lock owner process start time');
58
+ const file = lockPath(key);
59
+ const bytes = Buffer.from(JSON.stringify({ key, pid: process.pid, start }));
60
+ mkdirSync(locksDir(), { recursive: true, mode: 0o700 });
61
+ const initial = create(file, bytes);
62
+ if (initial.ok) return initial;
63
+ let seen;
64
+ try { seen = readFileSync(file); }
65
+ catch { return HELD; }
66
+ await opts.afterRead?.();
67
+ const owner = ownerOf(seen);
68
+ if (owner === null || owner.key !== key) return HELD;
69
+ let dead = !(opts.alive ?? alive)(owner.pid);
70
+ if (!dead) {
71
+ const actual = await probe(owner.pid);
72
+ dead = actual !== null && actual !== owner.start;
73
+ }
74
+ if (!dead) return HELD;
75
+
76
+ // Only unchanged dead-owner bytes may be removed under the per-key break mutex.
77
+ const mutex = `${file}.break`;
78
+ let guard;
79
+ try { guard = openSync(mutex, 'wx', 0o600); }
80
+ catch { return HELD; }
81
+ try {
82
+ writeFileSync(guard, bytes);
83
+ try { if (!readFileSync(file).equals(seen)) return HELD; }
84
+ catch { return HELD; }
85
+ unlinkSync(file);
86
+ return create(file, bytes);
87
+ } finally {
88
+ closeSync(guard);
89
+ unlinkSync(mutex);
90
+ }
91
+ }
92
+
93
+ /** @template T @param {string} key @param {() => T | Promise<T>} fn @param {LockOpts} [opts] @returns {Promise<T>} */
94
+ export function withLock(key, fn, opts = {}) {
95
+ return keyedLock(key, async () => {
96
+ let lock = await tryLock(key, opts);
97
+ while (!lock.ok) {
98
+ await new Promise((resolve) => setTimeout(resolve, opts.pollMs ?? 25));
99
+ lock = await tryLock(key, opts);
100
+ }
101
+ try { return await fn(); }
102
+ finally { lock.release(); }
103
+ });
104
+ }
105
+
106
+ /** @returns {string[]} paths of unreadable/malformed locks or dead-owner break mutexes. */
107
+ export function listBadLocks() {
108
+ let names;
109
+ try { names = readdirSync(locksDir()); }
110
+ catch (err) { if (err.code === 'ENOENT') return []; throw err; }
111
+ return names.filter((name) => name.endsWith('.lock') || name.endsWith('.lock.break')).sort().map((name) => path.join(locksDir(), name)).filter((file) => {
112
+ try {
113
+ const owner = ownerOf(readFileSync(file));
114
+ return owner === null || (file.endsWith('.lock.break') && !alive(owner.pid));
115
+ }
116
+ catch { return true; }
117
+ });
118
+ }