@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.
- package/CHANGELOG.md +35 -0
- package/README.md +9 -0
- package/docs/reference/blocks.json +84 -1
- package/docs/reference/config.md +5 -0
- package/package.json +4 -2
- package/schema/code-forge.schema.json +12 -1
- package/skill/SKILL.md +1 -0
- package/skill/references/adapters/claude-code.md +4 -0
- package/skill/references/ledger.md +3 -1
- package/skill/references/plan.md +9 -0
- package/skill/references/review.md +5 -1
- package/skill/scripts/forge +1 -1
- package/src/cli/facts.mjs +16 -1
- package/src/cli/review.mjs +40 -9
- package/src/doctor/index.mjs +2 -1
- package/src/doctor/local.mjs +15 -0
- package/src/ledger/spend.mjs +123 -6
- package/src/session/facts.mjs +218 -53
- package/src/session/spawn.mjs +164 -20
- package/src/util/locks.mjs +104 -0
- package/src/worker/engine.mjs +109 -32
- package/src/worker/loop.mjs +197 -45
package/src/worker/loop.mjs
CHANGED
|
@@ -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
|
|
25
|
-
*
|
|
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
|
|
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 =
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
307
|
-
*
|
|
308
|
-
*
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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. */
|