@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
@@ -14,9 +14,23 @@
14
14
  * Then it polls the queue, hands each ticket to the engine hook (`./engine.mjs`, B12a), signs the
15
15
  * outcome with the run key and writes it with its done marker and a signed ledger row.
16
16
  *
17
+ * Config (B35): the worker reviews each ticket with the run's config snapshot, read from the run
18
+ * record per ticket — the ticket's pin when `run reload` pinned it (it was queued before the
19
+ * reload), else the snapshot in force. So `run reload` reaches the worker without a restart, and a
20
+ * restarted worker boots on the snapshot, not on whatever the file says now. A record from before
21
+ * snapshots (or none) keeps the config the worker booted with.
22
+ *
23
+ * Ticket pool (B42): the worker runs up to `review.parallel_tickets` (default 3, 1–16) tickets at
24
+ * once, oldest first; when one ends the next pending one starts. Two tickets for the same
25
+ * (block, file) never overlap (a file lock; the later one stays pending without taking a slot),
26
+ * and the engine takes the block's budget row and L3 rung under a block lock (`engine.mjs`). A
27
+ * throw in one ticket is that ticket's `unavailable` result and never stops the others. `stop()`
28
+ * starts no new ticket and waits for the running ones. With 1 the worker runs exactly as before.
29
+ *
17
30
  * Restart: tickets live on disk and are idempotent by content hash, so a new worker simply re-reads
18
- * every ticket without a done marker. A worker that is stopping abandons its in-flight ticket (no
19
- * done marker) instead of recording the killed session as a failure. The new worker's pid is
31
+ * every ticket without a done marker. A worker that is stopping waits for its in-flight tickets: one
32
+ * whose session the stop killed (an `unavailable` outcome) is abandoned (no done marker) instead of
33
+ * recorded as a failure; one that reached a real outcome is recorded. The new worker's pid is
20
34
  * re-pinned in the run record by the orchestrator (`repinWorker`, = `run start --reattach`).
21
35
  */
22
36
 
@@ -27,18 +41,46 @@ import { loadProjectConfig } from '../config/load.mjs';
27
41
  import { createDefaultKeyStore, resolveKey } from '../keys/store.mjs';
28
42
  import { appendRow, readAllRows } from '../ledger/write.mjs';
29
43
  import { spawnSession } from '../session/spawn.mjs';
44
+ import { snapshotFor } from '../state/config-snapshot.mjs';
30
45
  import { readRun, reattachWorker } from '../state/run.mjs';
31
46
  import { loadKey, signRow } from '../state/signer.mjs';
47
+ import { keyedLock } from '../util/locks.mjs';
32
48
  import { runRoot, setRunRoot } from '../util/tmp.mjs';
33
49
  import { reviewTicket } from './engine.mjs';
34
- import { announceWorker, liveWorker, pendingTickets, readTicket, retractWorker, writeResult } from './queue.mjs';
50
+ import { announceWorker, beatWorker, HEARTBEAT_MS, liveWorker, pendingTickets, readTicket, retractWorker, writeResult } from './queue.mjs';
35
51
  import { contentHash, DELETED_HASH, repoRootOf, WorkerError } from './ticket.mjs';
36
52
 
37
53
  /** Name of the key the worker resolves for System 1 (`keys.jev` / `system1.key` reference). */
38
54
  export const JEV_KEY_NAME = 'jev';
39
55
 
40
56
  export const DEFAULT_POLL_MS = 250;
41
- export const DEFAULT_SESSION_TIMEOUT_S = 600;
57
+ export const DEFAULT_SESSION_TIMEOUT_S = 300;
58
+
59
+ /** B42: `review.parallel_tickets` when the config does not set a valid one. */
60
+ export const DEFAULT_PARALLEL_TICKETS = 3;
61
+ export const MAX_PARALLEL_TICKETS = 16;
62
+
63
+ /**
64
+ * How many tickets the worker runs at once: `review.parallel_tickets` when it is an integer
65
+ * 1–16, else `DEFAULT_PARALLEL_TICKETS`.
66
+ * @param {Record<string, any> | null | undefined} cfg @returns {number}
67
+ */
68
+ export function parallelTickets(cfg) {
69
+ const n = cfg?.review?.parallel_tickets;
70
+ return Number.isInteger(n) && n >= 1 && n <= MAX_PARALLEL_TICKETS ? n : DEFAULT_PARALLEL_TICKETS;
71
+ }
72
+
73
+ /**
74
+ * The key of the file lock a ticket takes (B42): one ticket per (block, file) at a time.
75
+ * @param {string} block @param {string} file @returns {string}
76
+ */
77
+ export const fileLockKey = (block, file) => `${block}\0${file}`;
78
+
79
+ /** B35: how long `configFor` waits before its one retry of a failed run-record read. */
80
+ export const CONFIG_RETRY_MS = 200;
81
+
82
+ /** The shortest heartbeat interval a worker accepts; anything else falls back to `HEARTBEAT_MS`. */
83
+ export const MIN_HEARTBEAT_MS = 100;
42
84
 
43
85
  /**
44
86
  * Provider-CLI auth variables a reviewer CLI may legitimately need (the reviewer runs on the
@@ -104,12 +146,16 @@ export function loopResult(outcome) {
104
146
  * @property {string} slug - ledger project slug (the run record's `project`).
105
147
  * @property {Buffer} key - the run's HMAC key.
106
148
  * @property {number} [pollMs]
149
+ * @property {number} [heartbeatMs] - how often `heartbeat_at` is refreshed: an integer ≥ `MIN_HEARTBEAT_MS`,
150
+ * else `HEARTBEAT_MS`.
107
151
  */
108
152
 
109
153
  /**
110
154
  * @typedef {object} WorkerDeps
111
155
  * @property {typeof reviewTicket} [review] - the engine hook (default `./engine.mjs`).
112
156
  * @property {typeof spawnSession} [spawn]
157
+ * @property {typeof readRun} [readRun] - the run-record read behind the per-ticket config (a test seam).
158
+ * @property {typeof readTicket} [readTicket] - the ticket read (a test seam).
113
159
  * @property {import('../keys/store.mjs').KeyStore} [store] - default: B2's production store.
114
160
  * @property {any} [opRead]
115
161
  * @property {NodeJS.ProcessEnv} [env]
@@ -130,20 +176,95 @@ export async function createWorker(opts, deps = {}) {
130
176
  const env = deps.env ?? process.env;
131
177
  const review = deps.review ?? reviewTicket;
132
178
  const spawn = deps.spawn ?? spawnSession;
179
+ const loadRun = deps.readRun ?? readRun;
180
+ const loadTicket = deps.readTicket ?? readTicket;
133
181
  const writeRow = deps.writeRow ?? ((/** @type {Record<string, any>} */ row) => appendRow(row, { slug }));
134
182
  const readRows = deps.readRows ?? (() => readAllRows(slug));
135
183
  const store = deps.store ?? (await createDefaultKeyStore(env));
136
184
  const jevKey = await resolveJevKey(cfg, { store, env, opRead: deps.opRead });
137
185
  const childEnv = sessionEnv(env, [jevKey, key.toString('hex')]);
138
- const timeoutS = Number.isInteger(cfg?.review?.session_timeout_s) && cfg.review.session_timeout_s > 0 ? cfg.review.session_timeout_s : DEFAULT_SESSION_TIMEOUT_S;
139
186
  const pollMs = opts.pollMs ?? DEFAULT_POLL_MS;
187
+ const heartbeatMs = Number.isInteger(opts.heartbeatMs) && /** @type {number} */ (opts.heartbeatMs) >= MIN_HEARTBEAT_MS ? /** @type {number} */ (opts.heartbeatMs) : HEARTBEAT_MS;
140
188
  let stopping = false;
189
+ /** @type {ReturnType<typeof setInterval> | null} */
190
+ let heartbeat = null;
141
191
  /** @type {(() => void) | null} */
142
192
  let wake = null;
193
+ /** @type {(() => void) | null} wakes the pool's wait in `drain` (stop) */
194
+ let poolWake = null;
143
195
 
144
196
  /** @param {Record<string, any>} row */
145
197
  const ledger = (row) => writeRow(signRow({ run: runId, ...row }, key));
146
198
 
199
+ /**
200
+ * The config ticket `id` is reviewed with (B35): its snapshot from the run record, re-read per
201
+ * ticket; the boot `cfg` when the run has no record or the record has no snapshot.
202
+ * @param {string} id
203
+ * @returns {Promise<Record<string, any>>}
204
+ * @throws {Error} `config-snapshot` — a record that cannot be read, or a missing/altered snapshot.
205
+ */
206
+ async function configFor(id) {
207
+ // The Jev key and the run root were settled at boot from `cfg`; a reload can never change
208
+ // them, because `keys`, `system1.key` and `tmp.root` are fixed keys `run reload` refuses.
209
+ let record;
210
+ for (let attempt = 1; ; attempt += 1) {
211
+ try {
212
+ record = await loadRun(runId);
213
+ break;
214
+ } catch (err) {
215
+ if (/** @type {any} */ (err)?.code === 'no-run') return cfg;
216
+ // a transient read error (e.g. the record caught mid-replace) gets ONE retry
217
+ if (attempt >= 2) throw new Error('config-snapshot');
218
+ await new Promise((resolve) => setTimeout(resolve, CONFIG_RETRY_MS));
219
+ }
220
+ }
221
+ return snapshotFor(record, id)?.config ?? cfg;
222
+ }
223
+
224
+ /**
225
+ * One ticket through the engine hook with `tcfg` (the ticket's config snapshot): the unsigned result.
226
+ * @param {import('./queue.mjs').Ticket} ticket @param {Record<string, any>} base
227
+ * @param {Record<string, any>} tcfg
228
+ * @returns {Promise<Record<string, any>>}
229
+ */
230
+ async function reviewWith(ticket, base, tcfg) {
231
+ const timeoutS = Number.isInteger(tcfg?.review?.session_timeout_s) && tcfg.review.session_timeout_s > 0 ? tcfg.review.session_timeout_s : DEFAULT_SESSION_TIMEOUT_S;
232
+ /** @type {import('./engine.mjs').ReviewContext} */
233
+ const ctx = {
234
+ repoRoot,
235
+ runId,
236
+ runRootDir,
237
+ cfg: tcfg,
238
+ jevKey,
239
+ key, // signs the fix-loop state (B12c); the sessions never see it (`childEnv`)
240
+ readRows,
241
+ ...(deps.jev ? { jev: deps.jev } : {}),
242
+ // `sessionOpts.cfg` (a per-call config, e.g. the consensus second reviewer's level) wins
243
+ // over the ticket's; everything else the worker pins.
244
+ spawn: (sessionOpts) =>
245
+ spawn(
246
+ /** @type {any} */ ({ cfg: tcfg, ...sessionOpts, runRoot: runRootDir, run: runId, block: ticket.block, slug, timeoutMs: timeoutS * 1000 }),
247
+ { env: childEnv, ...(deps.bins ? { bins: deps.bins } : {}), ...(deps.stderr ? { stderr: deps.stderr } : {}) },
248
+ ),
249
+ writeRow: (row) => ledger({ ...row, block: ticket.block, file: ticket.file, content_hash: ticket.content_hash }),
250
+ };
251
+ try {
252
+ const outcome = await review(ticket, ctx);
253
+ return {
254
+ ...base,
255
+ status: outcome.status,
256
+ approved: outcome.approved === true,
257
+ engine: outcome.engine,
258
+ ...(typeof outcome.reason === 'string' ? { reason: outcome.reason } : {}),
259
+ ...(Array.isArray(outcome.findings) ? { findings: outcome.findings } : {}),
260
+ ...loopResult(outcome),
261
+ sessions: outcome.sessions,
262
+ };
263
+ } catch {
264
+ return { ...base, status: 'unavailable', reason: 'engine-error', approved: false };
265
+ }
266
+ }
267
+
147
268
  /**
148
269
  * @param {string} id
149
270
  * @returns {Promise<Record<string, any> | null>} the signed result, or null when abandoned.
@@ -153,68 +274,51 @@ export async function createWorker(opts, deps = {}) {
153
274
  let result;
154
275
  let ticket = null;
155
276
  try {
156
- ticket = readTicket(repoRoot, id);
277
+ ticket = loadTicket(repoRoot, id);
157
278
  } catch (err) {
158
279
  result = { event: 'review.result', status: 'refused', reason: err instanceof WorkerError ? err.code : 'bad-ticket', approved: false };
159
280
  }
160
281
  if (ticket) {
161
- const base = { event: 'review.result', block: ticket.block, file: ticket.file, content_hash: ticket.content_hash };
162
- /** @type {string | null} */
163
- let current = null;
164
- try {
165
- current = ticket.run === runId ? contentHash(repoRoot, ticket.file) : null;
166
- } catch (err) {
167
- result = { ...base, status: 'refused', reason: err instanceof WorkerError ? err.code : 'unreadable', approved: false };
168
- }
169
- if (result) {
170
- // refused above: the path no longer passes the on-disk check, or the file cannot be read
171
- } else if (ticket.run !== runId) {
172
- result = { ...base, status: 'refused', reason: 'other-run', approved: false };
173
- } else if (current === DELETED_HASH && ticket.content_hash !== DELETED_HASH) {
174
- result = { ...base, status: 'stale', reason: 'missing-file', approved: false };
175
- } else if (current !== ticket.content_hash) {
176
- result = { ...base, status: 'stale', approved: false };
177
- } else {
178
- /** @type {import('./engine.mjs').ReviewContext} */
179
- const ctx = {
180
- repoRoot,
181
- runId,
182
- runRootDir,
183
- cfg,
184
- jevKey,
185
- key, // signs the fix-loop state (B12c); the sessions never see it (`childEnv`)
186
- readRows,
187
- ...(deps.jev ? { jev: deps.jev } : {}),
188
- // `sessionOpts.cfg` (a per-call config, e.g. the consensus second reviewer's level) wins
189
- // over the worker's; everything else the worker pins.
190
- spawn: (sessionOpts) =>
191
- spawn(
192
- /** @type {any} */ ({ cfg, ...sessionOpts, runRoot: runRootDir, run: runId, block: ticket.block, slug, timeoutMs: timeoutS * 1000 }),
193
- { env: childEnv, ...(deps.bins ? { bins: deps.bins } : {}), ...(deps.stderr ? { stderr: deps.stderr } : {}) },
194
- ),
195
- writeRow: (row) => ledger({ ...row, block: ticket.block, file: ticket.file, content_hash: ticket.content_hash }),
196
- };
197
- try {
198
- const outcome = await review(ticket, ctx);
199
- result = {
200
- ...base,
201
- status: outcome.status,
202
- approved: outcome.approved === true,
203
- engine: outcome.engine,
204
- ...(typeof outcome.reason === 'string' ? { reason: outcome.reason } : {}),
205
- ...(Array.isArray(outcome.findings) ? { findings: outcome.findings } : {}),
206
- ...loopResult(outcome),
207
- sessions: outcome.sessions,
208
- };
209
- } catch {
210
- result = { ...base, status: 'unavailable', reason: 'engine-error', approved: false };
211
- }
212
- }
282
+ // B42: never two tickets for the same (block, file) at once — its fix-loop state (`seq`) is
283
+ // read, advanced and saved by one ticket at a time; the later one waits here. The key
284
+ // carries the run so two workers in one process (tests) never share a lock.
285
+ const t = ticket;
286
+ result = await keyedLock(`${runId}\0${fileLockKey(t.block, t.file)}`, () => reviewOne(id, t));
213
287
  }
214
- if (stopping) return null; // the session was killed by our own stop: leave the ticket queued
288
+ // Stopping: a ticket whose session our own stop killed ends `unavailable` — leave it queued
289
+ // (no done marker) for the next worker instead of recording the kill as a failure. A ticket
290
+ // that reached a real outcome while the pool drained is recorded as usual.
291
+ if (stopping && result.status === 'unavailable') return null;
215
292
  return complete(id, result);
216
293
  }
217
294
 
295
+ /**
296
+ * The unsigned result for a ticket that was read: refused / stale checks, then the review.
297
+ * @param {string} id @param {import('./queue.mjs').Ticket} ticket
298
+ * @returns {Promise<Record<string, any>>}
299
+ */
300
+ async function reviewOne(id, ticket) {
301
+ const base = { event: 'review.result', block: ticket.block, file: ticket.file, content_hash: ticket.content_hash };
302
+ /** @type {string | null} */
303
+ let current;
304
+ try {
305
+ current = ticket.run === runId ? contentHash(repoRoot, ticket.file) : null;
306
+ } catch (err) {
307
+ // the path no longer passes the on-disk check, or the file cannot be read
308
+ return { ...base, status: 'refused', reason: err instanceof WorkerError ? err.code : 'unreadable', approved: false };
309
+ }
310
+ if (ticket.run !== runId) return { ...base, status: 'refused', reason: 'other-run', approved: false };
311
+ if (current === DELETED_HASH && ticket.content_hash !== DELETED_HASH) return { ...base, status: 'stale', reason: 'missing-file', approved: false };
312
+ if (current !== ticket.content_hash) return { ...base, status: 'stale', approved: false };
313
+ let ticketCfg;
314
+ try {
315
+ ticketCfg = await configFor(id);
316
+ } catch {
317
+ return { ...base, status: 'unavailable', reason: 'config-snapshot', approved: false };
318
+ }
319
+ return reviewWith(ticket, base, ticketCfg);
320
+ }
321
+
218
322
  /**
219
323
  * Sign and write the result + done marker, then the signed ledger row. A ledger failure does not
220
324
  * undo the result: the done marker is what the queue reads.
@@ -243,33 +347,164 @@ export async function createWorker(opts, deps = {}) {
243
347
  const poisoned = new Set();
244
348
 
245
349
  /**
246
- * Process every pending ticket once. An unexpected throw on one ticket becomes a signed
247
- * `unavailable` result (reason `worker-error`) when that can still be written, and never stops
248
- * the loop; a ticket whose failure cannot be written either is skipped for this worker's life.
350
+ * One ticket in a pool slot: never rejects. An unexpected throw becomes a signed `unavailable`
351
+ * result (reason `worker-error`) when that can still be written; a ticket whose failure cannot
352
+ * be written either is poisoned (skipped for this worker's life).
353
+ * @param {string} id @returns {Promise<number>} 1 when completed, else 0.
354
+ */
355
+ async function runOne(id) {
356
+ try {
357
+ return (await processTicket(id)) !== null ? 1 : 0;
358
+ } catch {
359
+ try {
360
+ await complete(id, { event: 'review.result', status: 'unavailable', reason: 'worker-error', approved: false });
361
+ return 1;
362
+ } catch {
363
+ poisoned.add(id);
364
+ return 0;
365
+ }
366
+ }
367
+ }
368
+
369
+ /**
370
+ * B42: how many tickets run at once — `review.parallel_tickets` of the snapshot in force (so a
371
+ * `run reload` resizes the pool at the next drain), else of the boot config; anything but an
372
+ * integer 1–16 is the default.
373
+ * @returns {Promise<number>}
374
+ */
375
+ async function poolSize() {
376
+ let current = cfg;
377
+ try {
378
+ current = await configFor('');
379
+ } catch {
380
+ // an unreadable record: the boot config's size (each ticket still fails closed on its own)
381
+ }
382
+ return parallelTickets(current);
383
+ }
384
+
385
+ /**
386
+ * The (block, file) key of each ticket read so far, by id: a written ticket never changes (its
387
+ * id is its content's hash), so it is read once for scheduling, however many wake-ups it waits.
388
+ * Dropped once the ticket is done.
389
+ * @type {Map<string, string>}
390
+ */
391
+ const fileKeys = new Map();
392
+
393
+ /**
394
+ * The (block, file) a pending ticket names, or null when it cannot be read (it is started
395
+ * anyway and refused by `processTicket`; a null is not cached). Only a scheduling hint:
396
+ * `processTicket` takes the file lock on the ticket it actually read.
397
+ * @param {string} id @returns {string | null}
398
+ */
399
+ function fileKeyOf(id) {
400
+ const known = fileKeys.get(id);
401
+ if (known !== undefined) return known;
402
+ try {
403
+ const t = loadTicket(repoRoot, id);
404
+ const key = fileLockKey(t.block, t.file);
405
+ fileKeys.set(id, key);
406
+ return key;
407
+ } catch {
408
+ return null;
409
+ }
410
+ }
411
+
412
+ /**
413
+ * Serve every pending ticket (B42): up to `review.parallel_tickets` at once, oldest first; when
414
+ * one finishes, the queue is re-read and the next pending ticket starts (no batch barrier; the
415
+ * queue is also re-read every `pollMs` while tickets run, so a new ticket fills a free slot).
416
+ * A ticket whose (block, file) is already running is left pending (it does not take a slot)
417
+ * and starts once that one ends. Each ticket starts at most once per drain. On `stop()` no new
418
+ * ticket starts; the running ones are awaited before `drain` returns. The pool size (a run-record
419
+ * read) is read only when there is a pending ticket: an idle drain reads nothing but the queue.
249
420
  * @returns {Promise<number>} how many were completed.
250
421
  */
251
422
  async function drain() {
423
+ /** @type {number | null} */
424
+ let limit = null;
252
425
  let done = 0;
253
- for (const id of pendingTickets(repoRoot)) {
254
- if (stopping) break;
255
- if (poisoned.has(id)) continue;
256
- try {
257
- if ((await processTicket(id)) !== null) done += 1;
258
- } catch {
259
- try {
260
- await complete(id, { event: 'review.result', status: 'unavailable', reason: 'worker-error', approved: false });
261
- done += 1;
262
- } catch {
263
- poisoned.add(id);
426
+ /** @type {Map<string, Promise<void>>} */
427
+ const running = new Map();
428
+ /** @type {Set<string>} the (block, file) keys of the running tickets */
429
+ const busy = new Set();
430
+ /** @type {Set<string>} */
431
+ const started = new Set();
432
+ for (;;) {
433
+ if (!stopping) {
434
+ for (const id of pendingTickets(repoRoot)) {
435
+ if (poisoned.has(id) || started.has(id)) continue;
436
+ if (limit === null) limit = await poolSize();
437
+ if (running.size >= limit || stopping) break;
438
+ const fileKey = fileKeyOf(id);
439
+ if (fileKey !== null && busy.has(fileKey)) continue; // waits without a slot
440
+ started.add(id);
441
+ if (fileKey !== null) busy.add(fileKey);
442
+ const slot = runOne(id)
443
+ .then((n) => {
444
+ done += n;
445
+ })
446
+ .finally(() => {
447
+ // the slot is freed on every exit path (`runOne` never rejects)
448
+ running.delete(id);
449
+ fileKeys.delete(id);
450
+ if (fileKey !== null) busy.delete(fileKey);
451
+ });
452
+ running.set(id, slot);
264
453
  }
265
454
  }
455
+ if (running.size === 0) break;
456
+ await nextEvent([...running.values()]);
266
457
  }
267
458
  return done;
268
459
  }
269
460
 
461
+ /**
462
+ * Resolve when one of `slots` settles, `pollMs` passes, or `stop()` is called.
463
+ * @param {Array<Promise<void>>} slots
464
+ */
465
+ async function nextEvent(slots) {
466
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
467
+ let timer;
468
+ const tick = new Promise((resolve) => {
469
+ timer = setTimeout(resolve, pollMs);
470
+ poolWake = () => resolve(undefined);
471
+ });
472
+ try {
473
+ await Promise.race([...slots, tick]);
474
+ } finally {
475
+ clearTimeout(timer);
476
+ poolWake = null;
477
+ }
478
+ }
479
+
480
+ /**
481
+ * Announce this worker and keep its heartbeat beating on a timer of its own (B30), independent
482
+ * of the ticket being processed: a review session can run for minutes, and `review-file --wait`
483
+ * must still see the worker as live the whole time.
484
+ */
485
+ function announce() {
486
+ const self = announceWorker(repoRoot, { pid: process.pid, run: runId });
487
+ stopBeating();
488
+ const timer = setInterval(() => {
489
+ try {
490
+ // another worker announced itself over us: stop beating, never overwrite its file
491
+ if (!beatWorker(repoRoot, self)) stopBeating();
492
+ } catch {
493
+ // a missed beat (an fs error) is retried on the next tick; the poll loop goes on
494
+ }
495
+ }, heartbeatMs);
496
+ timer.unref();
497
+ heartbeat = timer;
498
+ }
499
+
500
+ function stopBeating() {
501
+ if (heartbeat !== null) clearInterval(heartbeat);
502
+ heartbeat = null;
503
+ }
504
+
270
505
  /** Serve the queue until `stop()`. */
271
506
  async function run() {
272
- announceWorker(repoRoot, { pid: process.pid, run: runId });
507
+ announce();
273
508
  try {
274
509
  while (!stopping) {
275
510
  try {
@@ -294,7 +529,7 @@ export async function createWorker(opts, deps = {}) {
294
529
 
295
530
  /** Drain the queue once while announced, then release (`worker --once`). */
296
531
  async function once() {
297
- announceWorker(repoRoot, { pid: process.pid, run: runId });
532
+ announce();
298
533
  try {
299
534
  return await drain();
300
535
  } finally {
@@ -303,13 +538,16 @@ export async function createWorker(opts, deps = {}) {
303
538
  }
304
539
  }
305
540
 
541
+ /** Stop taking tickets; the running ones are awaited (a killed one is abandoned, see `processTicket`). */
306
542
  function stop() {
307
543
  stopping = true;
308
544
  wake?.();
545
+ poolWake?.();
309
546
  }
310
547
 
311
548
  /** Withdraw this process's announcement (`worker.json`) if it is still ours. */
312
549
  function release() {
550
+ stopBeating();
313
551
  retractWorker(repoRoot, process.pid);
314
552
  }
315
553
 
@@ -325,11 +563,21 @@ export async function bootWorker(opts, deps = {}) {
325
563
  const record = await readRun(opts.runId);
326
564
  if (record.status !== 'active') throw new WorkerError('run-ended', `run ${opts.runId} has ended`);
327
565
  const repoRoot = await repoRootOf(path.resolve(opts.cwd ?? record.workspace));
328
- const loaded = await loadProjectConfig(repoRoot);
329
- if (!loaded.ok) throw new WorkerError('config', loaded.message ?? 'the project config cannot be loaded');
566
+ // B35: a run with a config snapshot boots on it (the snapshot in force, not on whatever the file
567
+ // says now); a run from before snapshots boots on the file, as before.
568
+ let cfg;
569
+ try {
570
+ cfg = snapshotFor(record, '')?.config;
571
+ } catch {
572
+ throw new WorkerError('config', `run ${opts.runId}: the config snapshot in the run record is missing or altered`);
573
+ }
574
+ if (!cfg) {
575
+ const loaded = await loadProjectConfig(repoRoot);
576
+ if (!loaded.ok) throw new WorkerError('config', loaded.message ?? 'the project config cannot be loaded');
577
+ cfg = loaded.config;
578
+ }
330
579
  const other = liveWorker(repoRoot);
331
580
  if (other && other.pid !== process.pid) throw new WorkerError('worker-running', `worker pid ${other.pid} already serves this queue`);
332
- const cfg = loaded.config;
333
581
  const tmpRoot = typeof cfg?.tmp?.root === 'string' ? cfg.tmp.root : undefined;
334
582
  const runRootDir = runRoot(opts.runId, { root: tmpRoot });
335
583
  setRunRoot(runRootDir);
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * <repo>/.code-forge/queue/<ticket>.json the ticket (written by `review-file`, idempotent)
6
6
  * <repo>/.code-forge/queue/<ticket>.done the worker's done marker `{ticket, status, result}`
7
- * <repo>/.code-forge/queue/worker.json the worker's announcement `{pid, start_time, run, started_at}`
7
+ * <repo>/.code-forge/queue/worker.json the worker's announcement `{pid, start_time, run, started_at, heartbeat_at}`
8
8
  * <repo>/.code-forge/reviews/<run>/<ticket>.json the SIGNED result (run key HMAC, §8.6)
9
9
  *
10
10
  * The forbidden list (§8.4 ★) denies a coder any write under `reviews/` and to `queue/*.done`;
@@ -20,6 +20,12 @@ import { assertRowPath, assertTicketId, contentHash, ticketId, WorkerError } fro
20
20
 
21
21
  export const WORKER_FILE = 'worker.json';
22
22
 
23
+ /** How often a serving worker refreshes `heartbeat_at` (B30). */
24
+ export const HEARTBEAT_MS = 5000;
25
+
26
+ /** A heartbeat older than this is not fresh (twelve missed beats). */
27
+ export const HEARTBEAT_STALE_MS = 60_000;
28
+
23
29
  /** @param {string} repoRoot */
24
30
  export const queueDir = (repoRoot) => path.join(repoRoot, '.code-forge', 'queue');
25
31
  /** @param {string} repoRoot @param {string} runId */
@@ -156,12 +162,60 @@ export async function verifyResult(repoRoot, runId, ticket) {
156
162
  * @param {string} repoRoot @param {{pid: number, run: string}} who
157
163
  */
158
164
  export function announceWorker(repoRoot, { pid, run }) {
159
- const body = { pid, start_time: readStartTime(pid) ?? UNKNOWN_START_TIME, run, started_at: new Date().toISOString() };
165
+ const now = new Date().toISOString();
166
+ const body = { pid, start_time: readStartTime(pid) ?? UNKNOWN_START_TIME, run, started_at: now, heartbeat_at: now };
160
167
  writeAtomic(path.join(queueDir(repoRoot), WORKER_FILE), `${JSON.stringify(body)}\n`);
161
168
  return body;
162
169
  }
163
170
 
164
- /** @param {string} repoRoot @returns {{pid: number, start_time: string, run: string} | null} */
171
+ /**
172
+ * Refresh the announcement's `heartbeat_at` (B30) while it is still THIS worker's: the file is
173
+ * re-read right before the write and must name the same `pid` AND `start_time` the worker
174
+ * announced. Another worker's announcement is never overwritten (the caller stops beating).
175
+ * The beat is written to a temp file, the announcement re-read and compared once more, and only
176
+ * then renamed over it. The worker calls this from a timer of its own, so it keeps beating while
177
+ * a long review session is awaited.
178
+ * @param {string} repoRoot @param {{pid: number, start_time: string}} self - what `announceWorker` returned.
179
+ * @param {Date} [now]
180
+ * @returns {boolean} whether a beat was written.
181
+ */
182
+ export function beatWorker(repoRoot, self, now = new Date()) {
183
+ const mine = (/** @type {Record<string, any> | null} */ w) => w?.pid === self.pid && w.start_time === self.start_time;
184
+ const w = readWorker(repoRoot);
185
+ if (!mine(w)) return false;
186
+ const file = path.join(queueDir(repoRoot), WORKER_FILE);
187
+ const tmp = `${file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
188
+ try {
189
+ writeFileSync(tmp, `${JSON.stringify({ ...w, heartbeat_at: now.toISOString() })}\n`, { mode: 0o644 });
190
+ // re-read right before the rename: an announcement that changed meanwhile is never replaced
191
+ if (!mine(readWorker(repoRoot))) {
192
+ rmSync(tmp, { force: true });
193
+ return false;
194
+ }
195
+ renameSync(tmp, file);
196
+ return true;
197
+ } catch (err) {
198
+ rmSync(tmp, { force: true });
199
+ throw err;
200
+ }
201
+ }
202
+
203
+ /** A heartbeat further than this in the future is not fresh (clock skew tolerance). */
204
+ export const HEARTBEAT_FUTURE_MS = 5000;
205
+
206
+ /**
207
+ * Whether the announcement's heartbeat is fresh: at most `HEARTBEAT_STALE_MS` old and at most
208
+ * `HEARTBEAT_FUTURE_MS` ahead of our clock (a heartbeat far in the future is forged or skewed).
209
+ * @param {{heartbeat_at?: unknown}} w @param {number} [nowMs]
210
+ */
211
+ export function heartbeatFresh(w, nowMs = Date.now()) {
212
+ const at = typeof w?.heartbeat_at === 'string' ? Date.parse(w.heartbeat_at) : NaN;
213
+ if (!Number.isFinite(at)) return false;
214
+ const age = nowMs - at;
215
+ return age <= HEARTBEAT_STALE_MS && age >= -HEARTBEAT_FUTURE_MS;
216
+ }
217
+
218
+ /** @param {string} repoRoot @returns {{pid: number, start_time: string, run: string, heartbeat_at?: string} | null} */
165
219
  export function readWorker(repoRoot) {
166
220
  const w = readJSON(path.join(queueDir(repoRoot), WORKER_FILE));
167
221
  return w && Number.isInteger(w.pid) && w.pid > 1 && typeof w.run === 'string' ? w : null;
@@ -173,13 +227,20 @@ export function retractWorker(repoRoot, pid) {
173
227
  }
174
228
 
175
229
  /**
176
- * The live worker serving this queue, or null (`worker_down`): the announced pid must be alive
177
- * AND still have the announced start time (a recycled pid is not our worker).
178
- * @param {string} repoRoot
230
+ * The live worker serving this queue, or null (`worker_down`): the announced pid must be alive,
231
+ * AND either its heartbeat is fresh (B30: the worker itself wrote it within `HEARTBEAT_STALE_MS`)
232
+ * or it still has the announced start time (a recycled pid is not our worker). A fresh heartbeat
233
+ * wins over the start-time check: `ps` can fail or print another time zone's `lstart` inside a
234
+ * coder's sandbox, which made a live worker look down mid-review (issue #2).
235
+ * Accepted trade-off: a worker that is SIGKILLed (no retraction) leaves a heartbeat that stays
236
+ * fresh for up to `HEARTBEAT_STALE_MS` (60 s); if the OS reuses its pid inside that window, the
237
+ * queue reads as served until the heartbeat goes stale. A clean stop retracts the file at once.
238
+ * @param {string} repoRoot @param {{nowMs?: number}} [opts]
179
239
  */
180
- export function liveWorker(repoRoot) {
240
+ export function liveWorker(repoRoot, opts = {}) {
181
241
  const w = readWorker(repoRoot);
182
242
  if (!w || !isAlive(w.pid)) return null;
243
+ if (heartbeatFresh(w, opts.nowMs)) return w;
183
244
  if (w.start_time !== UNKNOWN_START_TIME && readStartTime(w.pid) !== w.start_time) return null;
184
245
  return w;
185
246
  }