@codedology/code-forge 0.4.0 → 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.
@@ -20,9 +20,17 @@
20
20
  * restarted worker boots on the snapshot, not on whatever the file says now. A record from before
21
21
  * snapshots (or none) keeps the config the worker booted with.
22
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
+ *
23
30
  * Restart: tickets live on disk and are idempotent by content hash, so a new worker simply re-reads
24
- * every ticket without a done marker. A worker that is stopping abandons its in-flight ticket (no
25
- * 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
26
34
  * re-pinned in the run record by the orchestrator (`repinWorker`, = `run start --reattach`).
27
35
  */
28
36
 
@@ -36,6 +44,7 @@ import { spawnSession } from '../session/spawn.mjs';
36
44
  import { snapshotFor } from '../state/config-snapshot.mjs';
37
45
  import { readRun, reattachWorker } from '../state/run.mjs';
38
46
  import { loadKey, signRow } from '../state/signer.mjs';
47
+ import { keyedLock } from '../util/locks.mjs';
39
48
  import { runRoot, setRunRoot } from '../util/tmp.mjs';
40
49
  import { reviewTicket } from './engine.mjs';
41
50
  import { announceWorker, beatWorker, HEARTBEAT_MS, liveWorker, pendingTickets, readTicket, retractWorker, writeResult } from './queue.mjs';
@@ -47,6 +56,26 @@ export const JEV_KEY_NAME = 'jev';
47
56
  export const DEFAULT_POLL_MS = 250;
48
57
  export const DEFAULT_SESSION_TIMEOUT_S = 300;
49
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
+
50
79
  /** B35: how long `configFor` waits before its one retry of a failed run-record read. */
51
80
  export const CONFIG_RETRY_MS = 200;
52
81
 
@@ -125,6 +154,8 @@ export function loopResult(outcome) {
125
154
  * @typedef {object} WorkerDeps
126
155
  * @property {typeof reviewTicket} [review] - the engine hook (default `./engine.mjs`).
127
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).
128
159
  * @property {import('../keys/store.mjs').KeyStore} [store] - default: B2's production store.
129
160
  * @property {any} [opRead]
130
161
  * @property {NodeJS.ProcessEnv} [env]
@@ -145,6 +176,8 @@ export async function createWorker(opts, deps = {}) {
145
176
  const env = deps.env ?? process.env;
146
177
  const review = deps.review ?? reviewTicket;
147
178
  const spawn = deps.spawn ?? spawnSession;
179
+ const loadRun = deps.readRun ?? readRun;
180
+ const loadTicket = deps.readTicket ?? readTicket;
148
181
  const writeRow = deps.writeRow ?? ((/** @type {Record<string, any>} */ row) => appendRow(row, { slug }));
149
182
  const readRows = deps.readRows ?? (() => readAllRows(slug));
150
183
  const store = deps.store ?? (await createDefaultKeyStore(env));
@@ -157,6 +190,8 @@ export async function createWorker(opts, deps = {}) {
157
190
  let heartbeat = null;
158
191
  /** @type {(() => void) | null} */
159
192
  let wake = null;
193
+ /** @type {(() => void) | null} wakes the pool's wait in `drain` (stop) */
194
+ let poolWake = null;
160
195
 
161
196
  /** @param {Record<string, any>} row */
162
197
  const ledger = (row) => writeRow(signRow({ run: runId, ...row }, key));
@@ -174,7 +209,7 @@ export async function createWorker(opts, deps = {}) {
174
209
  let record;
175
210
  for (let attempt = 1; ; attempt += 1) {
176
211
  try {
177
- record = await readRun(runId);
212
+ record = await loadRun(runId);
178
213
  break;
179
214
  } catch (err) {
180
215
  if (/** @type {any} */ (err)?.code === 'no-run') return cfg;
@@ -239,42 +274,51 @@ export async function createWorker(opts, deps = {}) {
239
274
  let result;
240
275
  let ticket = null;
241
276
  try {
242
- ticket = readTicket(repoRoot, id);
277
+ ticket = loadTicket(repoRoot, id);
243
278
  } catch (err) {
244
279
  result = { event: 'review.result', status: 'refused', reason: err instanceof WorkerError ? err.code : 'bad-ticket', approved: false };
245
280
  }
246
281
  if (ticket) {
247
- const base = { event: 'review.result', block: ticket.block, file: ticket.file, content_hash: ticket.content_hash };
248
- /** @type {string | null} */
249
- let current = null;
250
- try {
251
- current = ticket.run === runId ? contentHash(repoRoot, ticket.file) : null;
252
- } catch (err) {
253
- result = { ...base, status: 'refused', reason: err instanceof WorkerError ? err.code : 'unreadable', approved: false };
254
- }
255
- if (result) {
256
- // refused above: the path no longer passes the on-disk check, or the file cannot be read
257
- } else if (ticket.run !== runId) {
258
- result = { ...base, status: 'refused', reason: 'other-run', approved: false };
259
- } else if (current === DELETED_HASH && ticket.content_hash !== DELETED_HASH) {
260
- result = { ...base, status: 'stale', reason: 'missing-file', approved: false };
261
- } else if (current !== ticket.content_hash) {
262
- result = { ...base, status: 'stale', approved: false };
263
- } else {
264
- /** @type {Record<string, any> | null} */
265
- let ticketCfg = null;
266
- try {
267
- ticketCfg = await configFor(id);
268
- } catch {
269
- result = { ...base, status: 'unavailable', reason: 'config-snapshot', approved: false };
270
- }
271
- if (ticketCfg) result = await reviewWith(ticket, base, ticketCfg);
272
- }
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));
273
287
  }
274
- 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;
275
292
  return complete(id, result);
276
293
  }
277
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
+
278
322
  /**
279
323
  * Sign and write the result + done marker, then the signed ledger row. A ledger failure does not
280
324
  * undo the result: the done marker is what the queue reads.
@@ -303,30 +347,136 @@ export async function createWorker(opts, deps = {}) {
303
347
  const poisoned = new Set();
304
348
 
305
349
  /**
306
- * Process every pending ticket once. An unexpected throw on one ticket becomes a signed
307
- * `unavailable` result (reason `worker-error`) when that can still be written, and never stops
308
- * 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.
309
420
  * @returns {Promise<number>} how many were completed.
310
421
  */
311
422
  async function drain() {
423
+ /** @type {number | null} */
424
+ let limit = null;
312
425
  let done = 0;
313
- for (const id of pendingTickets(repoRoot)) {
314
- if (stopping) break;
315
- if (poisoned.has(id)) continue;
316
- try {
317
- if ((await processTicket(id)) !== null) done += 1;
318
- } catch {
319
- try {
320
- await complete(id, { event: 'review.result', status: 'unavailable', reason: 'worker-error', approved: false });
321
- done += 1;
322
- } catch {
323
- 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);
324
453
  }
325
454
  }
455
+ if (running.size === 0) break;
456
+ await nextEvent([...running.values()]);
326
457
  }
327
458
  return done;
328
459
  }
329
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
+
330
480
  /**
331
481
  * Announce this worker and keep its heartbeat beating on a timer of its own (B30), independent
332
482
  * of the ticket being processed: a review session can run for minutes, and `review-file --wait`
@@ -388,9 +538,11 @@ export async function createWorker(opts, deps = {}) {
388
538
  }
389
539
  }
390
540
 
541
+ /** Stop taking tickets; the running ones are awaited (a killed one is abandoned, see `processTicket`). */
391
542
  function stop() {
392
543
  stopping = true;
393
544
  wake?.();
545
+ poolWake?.();
394
546
  }
395
547
 
396
548
  /** Withdraw this process's announcement (`worker.json`) if it is still ours. */