@codedology/code-forge 0.15.3 → 0.16.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 +51 -0
- package/README.md +1 -1
- package/docs/reference/blocks.json +9 -1
- package/docs/reference/config.md +11 -1
- package/native/README.md +10 -0
- package/native/bin/darwin-arm64/code-forge-native +0 -0
- package/native/bin/darwin-x64/code-forge-native +0 -0
- package/native/bin/linux-arm64/code-forge-native +0 -0
- package/native/bin/linux-x64/code-forge-native +0 -0
- package/package.json +7 -2
- package/schema/code-forge.schema.json +26 -1
- package/schema/facts.schema.json +1 -1
- package/skill/SKILL.md +1 -1
- package/skill/references/adapters/solo.md +1 -1
- package/skill/references/autopilot.md +6 -1
- package/skill/references/code.md +37 -0
- package/skill/references/decisions.md +1 -1
- package/skill/references/plan.md +18 -5
- package/skill/references/review.md +47 -9
- package/skill/scripts/forge +1 -1
- package/skill/templates/facts-sheet.md +9 -3
- package/src/cli/block.mjs +284 -11
- package/src/cli/doctor.mjs +32 -1
- package/src/cli/help.mjs +1 -0
- package/src/cli/native-status.mjs +13 -0
- package/src/cli/review-file.mjs +126 -14
- package/src/cli/spawn.mjs +26 -22
- package/src/cli/status.mjs +80 -14
- package/src/decide/escalation.mjs +5 -5
- package/src/doctor/index.mjs +11 -0
- package/src/doctor/worker-probe.mjs +1 -1
- package/src/gates/dependents.mjs +200 -0
- package/src/gates/expand.mjs +83 -26
- package/src/gates/run.mjs +76 -14
- package/src/ledger/tail.mjs +89 -0
- package/src/review/agent-waiver.mjs +95 -8
- package/src/review/definitions.mjs +170 -0
- package/src/review/fixloop.mjs +4 -4
- package/src/review/gate-check.mjs +33 -13
- package/src/review/model-stats.mjs +125 -0
- package/src/review/packet.mjs +111 -22
- package/src/review/reopen.mjs +38 -13
- package/src/review/route.mjs +58 -4
- package/src/session/facts.mjs +252 -70
- package/src/session/plan-check.mjs +4 -1
- package/src/session/spawn.mjs +70 -19
- package/src/state/block-end.mjs +3 -1
- package/src/state/block.mjs +56 -21
- package/src/state/run.mjs +12 -5
- package/src/util/native.mjs +133 -0
- package/src/worker/engine.mjs +11 -26
- package/src/worker/loop.mjs +150 -20
- package/src/worker/queue.mjs +3 -2
- package/src/worker/review-state.mjs +15 -3
- package/src/worker/watch.mjs +97 -83
- package/src/worker/worker-warning.mjs +35 -0
package/src/worker/loop.mjs
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
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
|
|
23
|
+
* Ticket pool (B42): the worker runs up to `review.parallel_tickets` (default 6, 1–16) tickets at
|
|
24
24
|
* once, oldest first (or fewest running per block with `review.fair_share`); when one ends the
|
|
25
25
|
* next pending one starts. Two tickets for the same (block, file) never overlap (a file lock;
|
|
26
26
|
* the later one stays pending without taking a slot),
|
|
@@ -44,14 +44,16 @@ import { expiryChecks } from '../autopilot/limits.mjs';
|
|
|
44
44
|
import { loadProjectConfig } from '../config/load.mjs';
|
|
45
45
|
import { reviewWorkers } from '../config/validate.mjs';
|
|
46
46
|
import { cacheHoursFrom, createDefaultKeyStore, resolveKey } from '../keys/store.mjs';
|
|
47
|
-
import {
|
|
48
|
-
import {
|
|
47
|
+
import { createTailReader } from '../ledger/tail.mjs';
|
|
48
|
+
import { appendRow } from '../ledger/write.mjs';
|
|
49
|
+
import { providerLimit, spawnSession } from '../session/spawn.mjs';
|
|
49
50
|
import { snapshotFor } from '../state/config-snapshot.mjs';
|
|
50
51
|
import { runRecordPath } from '../state/paths.mjs';
|
|
51
52
|
import { processStartTime, readRun, reattachWorker } from '../state/run.mjs';
|
|
52
53
|
import { loadKey, signRow } from '../state/signer.mjs';
|
|
53
54
|
import { logWarning } from '../util/error-log.mjs';
|
|
54
55
|
import { withLock, tryLock } from '../util/file-locks.mjs';
|
|
56
|
+
import { startNativeDaemon } from '../util/native.mjs';
|
|
55
57
|
import { redact, writeSafe } from '../util/redact.mjs';
|
|
56
58
|
import { sweepProcesses } from '../util/reaper.mjs';
|
|
57
59
|
import { runRoot, setRunRoot } from '../util/tmp.mjs';
|
|
@@ -69,7 +71,8 @@ export const DEFAULT_POLL_MS = 250;
|
|
|
69
71
|
export const DEFAULT_SESSION_TIMEOUT_S = 300;
|
|
70
72
|
|
|
71
73
|
/** B42: `review.parallel_tickets` when the config does not set a valid one. */
|
|
72
|
-
export const DEFAULT_PARALLEL_TICKETS =
|
|
74
|
+
export const DEFAULT_PARALLEL_TICKETS = 6;
|
|
75
|
+
export const DEFAULT_PROVIDER_PARALLEL = 4;
|
|
73
76
|
|
|
74
77
|
/** B48: the fixed text of a failed autopilot expiry check in a drain (stderr and the warning log). */
|
|
75
78
|
export const EXPIRY_CHECK_FAILED = 'the autopilot expiry check failed in a worker drain; it runs again at the next drain';
|
|
@@ -241,6 +244,7 @@ export function recordedVerdict(result) {
|
|
|
241
244
|
* @property {{beforeLock?: () => void, afterCount?: (count: number) => Promise<unknown>}} [hooks] - deterministic boot races.
|
|
242
245
|
* @property {typeof readTicket} [readTicket] - the ticket read (a test seam).
|
|
243
246
|
* @property {typeof import('../util/exec.mjs').exec} [watchExec] - watch git exec seam.
|
|
247
|
+
* @property {() => number} [watchNow] - watch debounce clock (default: Date.now).
|
|
244
248
|
* @property {Parameters<typeof watchTick>[0]['hooks']} [watchHooks] - deterministic watch races.
|
|
245
249
|
* @property {import('../keys/store.mjs').KeyStore} [store] - default: B2's production store.
|
|
246
250
|
* @property {any} [opRead]
|
|
@@ -248,7 +252,7 @@ export function recordedVerdict(result) {
|
|
|
248
252
|
* @property {Partial<Record<'claude'|'codex'|'grok', string>>} [bins]
|
|
249
253
|
* @property {(row: Record<string, any>) => Promise<unknown>} [writeRow] - default: B6 `appendRow`.
|
|
250
254
|
* @property {() => Promise<Array<Record<string, any>>>} [readRows] - the ledger rows the fix-loop
|
|
251
|
-
* state is anchored in; default:
|
|
255
|
+
* state is anchored in; default: one project tail reader for the worker.
|
|
252
256
|
* @property {{write: (s: string) => unknown}} [stderr]
|
|
253
257
|
* @property {import('../review/triage.mjs').JevAsk} [jev] - S1 for triage and the recheck (tests
|
|
254
258
|
* inject a mock); default: `askJev` with the resolved Jev key, none without one.
|
|
@@ -270,7 +274,7 @@ export async function createWorker(opts, deps = {}) {
|
|
|
270
274
|
const loadRun = deps.readRun ?? readRun;
|
|
271
275
|
const loadTicket = deps.readTicket ?? readTicket;
|
|
272
276
|
const writeRow = deps.writeRow ?? ((/** @type {Record<string, any>} */ row) => appendRow(row, { slug }));
|
|
273
|
-
const readRows = deps.readRows ?? (
|
|
277
|
+
const readRows = deps.readRows ?? createTailReader(slug).read;
|
|
274
278
|
// B48: the run's own writer (unsigned base; the checks sign their rows with the run key)
|
|
275
279
|
const expiryCheck = deps.expiryCheck ?? (() => expiryChecks(runId, new Date(), { writeRow }));
|
|
276
280
|
const warnStream = deps.stderr ?? process.stderr;
|
|
@@ -281,6 +285,8 @@ export async function createWorker(opts, deps = {}) {
|
|
|
281
285
|
const pollMs = opts.pollMs ?? DEFAULT_POLL_MS;
|
|
282
286
|
const heartbeatMs = Number.isInteger(opts.heartbeatMs) && /** @type {number} */ (opts.heartbeatMs) >= MIN_HEARTBEAT_MS ? /** @type {number} */ (opts.heartbeatMs) : HEARTBEAT_MS;
|
|
283
287
|
let stopping = false;
|
|
288
|
+
let stopDaemon = async () => {};
|
|
289
|
+
let daemonStarted = false;
|
|
284
290
|
/** @type {ReturnType<typeof setInterval> | null} */
|
|
285
291
|
let heartbeat = null;
|
|
286
292
|
/** @type {ReturnType<typeof announceWorker> | null} */
|
|
@@ -291,11 +297,68 @@ export async function createWorker(opts, deps = {}) {
|
|
|
291
297
|
let poolWake = null;
|
|
292
298
|
let activity = 0;
|
|
293
299
|
|
|
300
|
+
// Sessions waiting for a provider retain their ticket's pool slot. Resolve the actual provider
|
|
301
|
+
// at spawn time, after routing and second-level overrides; admission hints never bypass caps.
|
|
302
|
+
let ticketLimit = parallelTickets(cfg);
|
|
303
|
+
let providerParallel = DEFAULT_PROVIDER_PARALLEL;
|
|
304
|
+
/** @type {Map<string, {active: number, pending: number}>} */
|
|
305
|
+
const ticketSessions = new Map();
|
|
306
|
+
/** @type {Map<string, number>} */
|
|
307
|
+
const providerActive = new Map();
|
|
308
|
+
/** @type {Array<{id: string, provider: string, start: () => void}>} */
|
|
309
|
+
const sessionQueue = [];
|
|
310
|
+
|
|
311
|
+
function dispatchSessions() {
|
|
312
|
+
for (let i = 0; i < sessionQueue.length;) {
|
|
313
|
+
const next = sessionQueue[i];
|
|
314
|
+
const state = ticketSessions.get(next.id);
|
|
315
|
+
const liveTickets = [...ticketSessions.values()].filter((s) => s.active > 0).length;
|
|
316
|
+
if ((providerActive.get(next.provider) ?? 0) >= providerParallel || (state.active === 0 && liveTickets >= ticketLimit)) {
|
|
317
|
+
i += 1;
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
sessionQueue.splice(i, 1);
|
|
321
|
+
state.pending -= 1;
|
|
322
|
+
state.active += 1;
|
|
323
|
+
providerActive.set(next.provider, (providerActive.get(next.provider) ?? 0) + 1);
|
|
324
|
+
next.start();
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** @param {string} id @param {Parameters<typeof spawnSession>[0]} opts @returns {ReturnType<typeof spawnSession>} */
|
|
329
|
+
function spawnPooled(id, opts) {
|
|
330
|
+
// Match the spawner's per-level override and top-level fallback; validation stays there.
|
|
331
|
+
const provider = opts.cfg?.levels?.[opts.level]?.provider ?? opts.cfg?.provider ?? 'unresolved';
|
|
332
|
+
const state = ticketSessions.get(id) ?? { active: 0, pending: 0 };
|
|
333
|
+
ticketSessions.set(id, state);
|
|
334
|
+
state.pending += 1;
|
|
335
|
+
// The spawner's existing per-attempt semaphores also cover fallback providers. Retain
|
|
336
|
+
// stricter provider_concurrency settings while applying this cap to fallback attempts.
|
|
337
|
+
const providerConcurrency = Object.fromEntries(['anthropic', 'openai', 'xai'].map((p) => [p, Math.min(providerParallel, providerLimit(opts.cfg, p))]));
|
|
338
|
+
const needsCap = Object.entries(providerConcurrency).some(([p, limit]) => limit < providerLimit(opts.cfg, p));
|
|
339
|
+
const sessionCfg = needsCap ? { ...opts.cfg, review: { ...opts.cfg.review, provider_concurrency: providerConcurrency } } : opts.cfg;
|
|
340
|
+
return new Promise((resolve, reject) => {
|
|
341
|
+
sessionQueue.push({ id, provider, start: () => {
|
|
342
|
+
Promise.resolve().then(() => spawn({ ...opts, cfg: sessionCfg },
|
|
343
|
+
{ env: childEnv, ...(deps.bins ? { bins: deps.bins } : {}), ...(deps.stderr ? { stderr: deps.stderr } : {}) },
|
|
344
|
+
)).then(resolve, reject).finally(() => {
|
|
345
|
+
state.active -= 1;
|
|
346
|
+
providerActive.set(provider, providerActive.get(provider) - 1);
|
|
347
|
+
if (state.active === 0 && state.pending === 0) ticketSessions.delete(id);
|
|
348
|
+
dispatchSessions();
|
|
349
|
+
poolWake?.();
|
|
350
|
+
});
|
|
351
|
+
} });
|
|
352
|
+
dispatchSessions();
|
|
353
|
+
poolWake?.();
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
294
357
|
/** @param {Record<string, any>} row */
|
|
295
358
|
const ledger = (row) => writeRow(signRow({ run: runId, ...row }, key));
|
|
296
359
|
// Shared across direct ticks and the scheduler, so running tickets are never superseded.
|
|
297
360
|
const activeTickets = new Set();
|
|
298
|
-
const tickWatch = () => watchTick({ repoRoot, runId, cfg, readRun: loadRun, writeRow: ledger, running: activeTickets, exec: deps.watchExec, hooks: deps.watchHooks });
|
|
361
|
+
const tickWatch = () => watchTick({ repoRoot, runId, cfg, readRun: loadRun, writeRow: ledger, running: activeTickets, exec: deps.watchExec, hooks: deps.watchHooks, now: deps.watchNow });
|
|
299
362
|
|
|
300
363
|
/**
|
|
301
364
|
* The config ticket `id` is reviewed with (B35): its snapshot from the run record, re-read per
|
|
@@ -305,6 +368,11 @@ export async function createWorker(opts, deps = {}) {
|
|
|
305
368
|
* @throws {Error} `config-snapshot` — a record that cannot be read, or a missing/altered snapshot.
|
|
306
369
|
*/
|
|
307
370
|
async function configFor(id) {
|
|
371
|
+
return snapshotFor(await configRecord(), id)?.config ?? cfg;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Read once for pool admission hints; ticket processing still re-reads its own snapshot. */
|
|
375
|
+
async function configRecord() {
|
|
308
376
|
// The Jev key and the run root were settled at boot from `cfg`; a reload can never change
|
|
309
377
|
// them, because `keys`, `system1.key` and `tmp.root` are fixed keys `run reload` refuses.
|
|
310
378
|
let record;
|
|
@@ -313,13 +381,13 @@ export async function createWorker(opts, deps = {}) {
|
|
|
313
381
|
record = await loadRun(runId);
|
|
314
382
|
break;
|
|
315
383
|
} catch (err) {
|
|
316
|
-
if (/** @type {any} */ (err)?.code === 'no-run') return
|
|
384
|
+
if (/** @type {any} */ (err)?.code === 'no-run') return null;
|
|
317
385
|
// a transient read error (e.g. the record caught mid-replace) gets ONE retry
|
|
318
386
|
if (attempt >= 2) throw new Error('config-snapshot');
|
|
319
387
|
await new Promise((resolve) => setTimeout(resolve, CONFIG_RETRY_MS));
|
|
320
388
|
}
|
|
321
389
|
}
|
|
322
|
-
return
|
|
390
|
+
return record;
|
|
323
391
|
}
|
|
324
392
|
|
|
325
393
|
/**
|
|
@@ -338,7 +406,9 @@ export async function createWorker(opts, deps = {}) {
|
|
|
338
406
|
// A route list that cannot be used is `unavailable` — never a review on a model it did not name.
|
|
339
407
|
let routed;
|
|
340
408
|
try {
|
|
341
|
-
routed = routeTicket(snapshotCfg, { lines: size.lines, deleted: size.deleted, timeoutS: baseTimeoutS }
|
|
409
|
+
routed = routeTicket(snapshotCfg, { lines: size.lines, deleted: size.deleted, timeoutS: baseTimeoutS },
|
|
410
|
+
snapshotCfg.review?.adaptive?.enabled === false || !snapshotCfg.levels?.L2?.model
|
|
411
|
+
? undefined : { rows: await readRows(), ticketId: id, runId });
|
|
342
412
|
} catch (err) {
|
|
343
413
|
// only a route list that cannot be used is `route-invalid`; anything else is a real failure
|
|
344
414
|
// and goes to runOne (`worker-error`), named as a routing-step failure with its own text
|
|
@@ -382,9 +452,8 @@ export async function createWorker(opts, deps = {}) {
|
|
|
382
452
|
// `sessionOpts.cfg` (a per-call config, e.g. the consensus second reviewer's level) wins
|
|
383
453
|
// over the ticket's; everything else the worker pins.
|
|
384
454
|
spawn: (sessionOpts) =>
|
|
385
|
-
|
|
455
|
+
spawnPooled(id,
|
|
386
456
|
/** @type {any} */ ({ cfg: tcfg, ...sessionOpts, runRoot: runRootDir, run: runId, block: ticket.block, slug, timeoutMs: timeoutS * 1000 }),
|
|
387
|
-
{ env: childEnv, ...(deps.bins ? { bins: deps.bins } : {}), ...(deps.stderr ? { stderr: deps.stderr } : {}) },
|
|
388
457
|
),
|
|
389
458
|
writeRow: (row) => ledger({ ...row, block: ticket.block, file: ticket.file, content_hash: ticket.content_hash }),
|
|
390
459
|
writeBlockRow: (row) => ledger({ ...row, block: ticket.block }),
|
|
@@ -559,16 +628,21 @@ export async function createWorker(opts, deps = {}) {
|
|
|
559
628
|
/**
|
|
560
629
|
* Pool settings from the snapshot in force, else the boot config. A run reload applies at the
|
|
561
630
|
* next drain; anything but an integer 1–16 uses the default size.
|
|
562
|
-
* @returns {Promise<{limit: number, fairShare: boolean}>}
|
|
631
|
+
* @returns {Promise<{limit: number, fairShare: boolean, record: Record<string, any> | null}>}
|
|
563
632
|
*/
|
|
564
633
|
async function poolSettings() {
|
|
565
634
|
let current = cfg;
|
|
635
|
+
let record = null;
|
|
566
636
|
try {
|
|
567
|
-
|
|
637
|
+
record = await configRecord();
|
|
638
|
+
current = snapshotFor(record, '')?.config ?? cfg;
|
|
568
639
|
} catch {
|
|
569
640
|
// an unreadable record: the boot settings (each ticket still fails closed on its own)
|
|
570
641
|
}
|
|
571
|
-
|
|
642
|
+
ticketLimit = parallelTickets(current);
|
|
643
|
+
const cap = current?.review?.provider_parallel;
|
|
644
|
+
providerParallel = Number.isInteger(cap) && cap >= 1 && cap <= MAX_PARALLEL_TICKETS ? cap : DEFAULT_PROVIDER_PARALLEL;
|
|
645
|
+
return { limit: parallelTickets(current), fairShare: current?.review?.fair_share === true, record };
|
|
572
646
|
}
|
|
573
647
|
|
|
574
648
|
/**
|
|
@@ -598,6 +672,27 @@ export async function createWorker(opts, deps = {}) {
|
|
|
598
672
|
}
|
|
599
673
|
}
|
|
600
674
|
|
|
675
|
+
/**
|
|
676
|
+
* The initial reviewer's provider is an admission hint, including snapshot and size routing.
|
|
677
|
+
* Unknown or invalid hints still enter the bounded pool so the normal checks can refuse them.
|
|
678
|
+
* Later sessions may use another provider; `spawnPooled` enforces their actual session caps.
|
|
679
|
+
* @param {string} id @param {Record<string, any> | null} record @returns {Promise<string | null>}
|
|
680
|
+
*/
|
|
681
|
+
async function providerHint(id, record) {
|
|
682
|
+
try {
|
|
683
|
+
let current = snapshotFor(record, id)?.config ?? cfg;
|
|
684
|
+
if (current?.review?.routes?.length) {
|
|
685
|
+
const ticket = loadTicket(repoRoot, id);
|
|
686
|
+
const root = await contentRootFor(runId, ticket.block, repoRoot);
|
|
687
|
+
const bytes = readRegularFileNoFollow(root, ticket.file);
|
|
688
|
+
current = routeTicket(current, { lines: countLines(bytes), deleted: bytes === null, timeoutS: DEFAULT_SESSION_TIMEOUT_S }).cfg;
|
|
689
|
+
}
|
|
690
|
+
return current?.levels?.L2?.provider ?? current?.provider ?? null;
|
|
691
|
+
} catch {
|
|
692
|
+
return null;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
601
696
|
/**
|
|
602
697
|
* Serve every pending ticket (B42): up to `review.parallel_tickets` at once, oldest first; with
|
|
603
698
|
* fair share, each slot goes to the block with fewest running tickets, ties oldest first. When
|
|
@@ -625,6 +720,8 @@ export async function createWorker(opts, deps = {}) {
|
|
|
625
720
|
}
|
|
626
721
|
/** @type {number | null} */
|
|
627
722
|
let limit = null;
|
|
723
|
+
/** @type {Record<string, any> | null} */
|
|
724
|
+
let record = null;
|
|
628
725
|
let fairShare = false;
|
|
629
726
|
let done = 0;
|
|
630
727
|
/** @type {Map<string, Promise<void>>} */
|
|
@@ -633,6 +730,8 @@ export async function createWorker(opts, deps = {}) {
|
|
|
633
730
|
const busy = new Set();
|
|
634
731
|
/** @type {Map<string, number>} running tickets per block */
|
|
635
732
|
const runningByBlock = new Map();
|
|
733
|
+
/** @type {Map<string, number>} reserve provider capacity while admitted tickets prepare */
|
|
734
|
+
const admittedByProvider = new Map();
|
|
636
735
|
/** @type {Set<string>} */
|
|
637
736
|
const started = new Set();
|
|
638
737
|
for (;;) {
|
|
@@ -660,10 +759,12 @@ export async function createWorker(opts, deps = {}) {
|
|
|
660
759
|
}
|
|
661
760
|
const pending = pendingTickets(repoRoot).filter((id) => !poisoned.has(id) && !started.has(id));
|
|
662
761
|
if (pending.length > 0 || running.size > 0) activity += 1;
|
|
663
|
-
if (pending.length > 0 && limit === null) ({ limit, fairShare } = await poolSettings());
|
|
762
|
+
if (pending.length > 0 && limit === null) ({ limit, fairShare, record } = await poolSettings());
|
|
664
763
|
// Cache even unreadable hints for this tick: ranking never reads a ticket twice.
|
|
665
764
|
/** @type {Map<string, ReturnType<typeof fileKeyOf>>} */
|
|
666
765
|
const hints = new Map();
|
|
766
|
+
/** @type {Map<string, string | null>} */
|
|
767
|
+
const providers = new Map();
|
|
667
768
|
while (pending.length > 0 && running.size < limit && !stopping) {
|
|
668
769
|
let next = -1;
|
|
669
770
|
let fewest = Infinity;
|
|
@@ -672,6 +773,9 @@ export async function createWorker(opts, deps = {}) {
|
|
|
672
773
|
if (!hints.has(id)) hints.set(id, fileKeyOf(id));
|
|
673
774
|
const file = hints.get(id);
|
|
674
775
|
if (file && busy.has(file.key)) continue; // waits without a slot
|
|
776
|
+
if (!providers.has(id)) providers.set(id, await providerHint(id, record));
|
|
777
|
+
const provider = providers.get(id);
|
|
778
|
+
if (provider && Math.max(admittedByProvider.get(provider) ?? 0, providerActive.get(provider) ?? 0) >= providerParallel) continue;
|
|
675
779
|
const count = fairShare && file ? runningByBlock.get(file.block) ?? 0 : 0;
|
|
676
780
|
if (count < fewest) {
|
|
677
781
|
next = i;
|
|
@@ -691,10 +795,12 @@ export async function createWorker(opts, deps = {}) {
|
|
|
691
795
|
}
|
|
692
796
|
const file = hints.get(id);
|
|
693
797
|
const fileKey = file?.key ?? null;
|
|
798
|
+
const provider = providers.get(id);
|
|
694
799
|
started.add(id);
|
|
695
800
|
activeTickets.add(id);
|
|
696
801
|
if (fileKey !== null) busy.add(fileKey);
|
|
697
802
|
if (file) runningByBlock.set(file.block, (runningByBlock.get(file.block) ?? 0) + 1);
|
|
803
|
+
if (provider) admittedByProvider.set(provider, (admittedByProvider.get(provider) ?? 0) + 1);
|
|
698
804
|
const slot = runOne(id)
|
|
699
805
|
.then((n) => {
|
|
700
806
|
done += n;
|
|
@@ -707,6 +813,7 @@ export async function createWorker(opts, deps = {}) {
|
|
|
707
813
|
fileKeys.delete(id);
|
|
708
814
|
if (fileKey !== null) busy.delete(fileKey);
|
|
709
815
|
if (file) runningByBlock.set(file.block, runningByBlock.get(file.block) - 1);
|
|
816
|
+
if (provider) admittedByProvider.set(provider, admittedByProvider.get(provider) - 1);
|
|
710
817
|
});
|
|
711
818
|
running.set(id, slot);
|
|
712
819
|
}
|
|
@@ -769,14 +876,22 @@ export async function createWorker(opts, deps = {}) {
|
|
|
769
876
|
heartbeat = null;
|
|
770
877
|
}
|
|
771
878
|
|
|
879
|
+
async function startDaemon() {
|
|
880
|
+
if (daemonStarted) return;
|
|
881
|
+
daemonStarted = true;
|
|
882
|
+
stopDaemon = await startNativeDaemon(runId, cfg, env);
|
|
883
|
+
if (stopping) await stopDaemon();
|
|
884
|
+
}
|
|
885
|
+
|
|
772
886
|
/** Serve the queue until `stop()`. */
|
|
773
887
|
async function run() {
|
|
774
|
-
announce();
|
|
775
888
|
let interval = pollMs;
|
|
776
889
|
let polled = false;
|
|
777
890
|
/** @type {import('node:fs').FSWatcher | null} */
|
|
778
891
|
let queueWatch = null;
|
|
779
892
|
try {
|
|
893
|
+
await startDaemon();
|
|
894
|
+
announce();
|
|
780
895
|
// Enqueue publishes a ticket by rename. Wake even during the longest idle back-off;
|
|
781
896
|
// heartbeat and temporary files do not wake an idle worker.
|
|
782
897
|
try {
|
|
@@ -818,37 +933,42 @@ export async function createWorker(opts, deps = {}) {
|
|
|
818
933
|
} finally {
|
|
819
934
|
queueWatch?.close();
|
|
820
935
|
release();
|
|
936
|
+
await stopDaemon();
|
|
821
937
|
}
|
|
822
938
|
}
|
|
823
939
|
|
|
824
940
|
/** Drain the queue once while announced, then release (`worker --once`). */
|
|
825
941
|
async function once() {
|
|
826
|
-
announce();
|
|
827
942
|
try {
|
|
943
|
+
await startDaemon();
|
|
944
|
+
announce();
|
|
828
945
|
const done = await drain();
|
|
829
946
|
if (done > 0) await cleanOrphans();
|
|
830
947
|
return done;
|
|
831
948
|
} finally {
|
|
832
949
|
stop();
|
|
833
950
|
release();
|
|
951
|
+
await stopDaemon();
|
|
834
952
|
}
|
|
835
953
|
}
|
|
836
954
|
|
|
837
955
|
/** Stop taking tickets; the running ones are awaited (a killed one is abandoned, see `processTicket`). */
|
|
838
956
|
function stop() {
|
|
839
957
|
stopping = true;
|
|
958
|
+
void stopDaemon();
|
|
840
959
|
wake?.();
|
|
841
960
|
poolWake?.();
|
|
842
961
|
}
|
|
843
962
|
|
|
844
963
|
/** Withdraw this process's announcement (`worker.json`) if it is still ours. */
|
|
845
964
|
function release() {
|
|
965
|
+
void stopDaemon();
|
|
846
966
|
stopBeating();
|
|
847
967
|
if (self !== null) retractWorker(repoRoot, self.pid, self.start_time);
|
|
848
968
|
self = null;
|
|
849
969
|
}
|
|
850
970
|
|
|
851
|
-
return { processTicket, drain, run, once, stop, release, announce, watchTick: tickWatch, jevKeyResolved: jevKey !== null, sessionEnv: childEnv };
|
|
971
|
+
return { processTicket, drain, run, once, stop, release, announce, startDaemon, stopDaemon: () => stopDaemon(), watchTick: tickWatch, jevKeyResolved: jevKey !== null, sessionEnv: childEnv };
|
|
852
972
|
}
|
|
853
973
|
|
|
854
974
|
/**
|
|
@@ -890,7 +1010,17 @@ export async function bootWorker(opts, deps = {}) {
|
|
|
890
1010
|
if (entries.some((entry) => entry.live && !entry.worker) || (legacy && legacy.run !== opts.runId) || entries.some((entry) => entry.live && entry.worker?.run !== opts.runId) || count >= limit) {
|
|
891
1011
|
throw new WorkerError('worker-running', `review.workers limit ${limit}: a live or unreadable worker entry already serves this queue`);
|
|
892
1012
|
}
|
|
893
|
-
worker.
|
|
1013
|
+
// Startup must finish before worker.json signals readiness: an owner may
|
|
1014
|
+
// remove HOME immediately after that announcement to stop an orphan worker.
|
|
1015
|
+
try {
|
|
1016
|
+
await worker.startDaemon();
|
|
1017
|
+
worker.announce();
|
|
1018
|
+
} catch (error) {
|
|
1019
|
+
worker.stop();
|
|
1020
|
+
worker.release();
|
|
1021
|
+
await worker.stopDaemon();
|
|
1022
|
+
throw error;
|
|
1023
|
+
}
|
|
894
1024
|
});
|
|
895
1025
|
return { ...worker, repoRoot, runRootDir };
|
|
896
1026
|
}
|
package/src/worker/queue.mjs
CHANGED
|
@@ -520,8 +520,9 @@ export function pendingTickets(repoRoot) {
|
|
|
520
520
|
return [];
|
|
521
521
|
}
|
|
522
522
|
const claimed = new Set(names.map((n) => CLAIM.exec(n)?.[1]));
|
|
523
|
+
const present = new Set(names);
|
|
523
524
|
const ids = names.filter((n) => /^[0-9a-f]{24}\.json$/.test(n)).map((n) => n.slice(0, 24));
|
|
524
|
-
const pending = ids.filter((id) => !
|
|
525
|
+
const pending = ids.filter((id) => !present.has(`${id}.done`) && !claimed.has(id));
|
|
525
526
|
const at = (/** @type {string} */ id) => {
|
|
526
527
|
const t = readJSON(path.join(queueDir(repoRoot), `${id}.json`));
|
|
527
528
|
return typeof t?.enqueued_at === 'string' ? t.enqueued_at : '';
|
|
@@ -589,7 +590,7 @@ export function readTicket(repoRoot, id) {
|
|
|
589
590
|
const t = readJSON(path.join(queueDir(repoRoot), `${assertTicketId(id)}.json`));
|
|
590
591
|
if (!t || typeof t !== 'object') throw new WorkerError('bad-ticket', `ticket ${id} is not readable JSON`);
|
|
591
592
|
assertRowPath(t.file);
|
|
592
|
-
if (t.tree
|
|
593
|
+
if (t.tree != null && (typeof t.tree !== 'string' || !path.isAbsolute(t.tree))) throw new WorkerError('bad-ticket', `ticket ${id} names a tree that is not an absolute path`);
|
|
593
594
|
if (typeof t.run !== 'string' || typeof t.block !== 'string' || typeof t.content_hash !== 'string') {
|
|
594
595
|
throw new WorkerError('bad-ticket', `ticket ${id} does not match its id`);
|
|
595
596
|
}
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
* (the engine saves it as a new seq and writes `review.state_restored`); an anchor with no body
|
|
26
26
|
* makes a missing file `lost` (refused, never a round 1). A
|
|
27
27
|
* `review.state` row of the run whose MAC does not verify is `tampered` for every block and file
|
|
28
|
-
* of it (`blockAnchors`). Whether
|
|
28
|
+
* of it (`blockAnchors`). Whether a file's one L3 rung was used comes from the anchors, never
|
|
29
29
|
* from the state files.
|
|
30
30
|
*
|
|
31
31
|
* B78: the reviewed text itself is kept as a git blob in the content root's object store
|
|
@@ -427,8 +427,8 @@ export async function saveState({ runId, block, file, key, state, seq, writeRow,
|
|
|
427
427
|
}
|
|
428
428
|
|
|
429
429
|
/**
|
|
430
|
-
* Whether the block
|
|
431
|
-
*
|
|
430
|
+
* Whether any file of the block has used its L3 rung (§3.6 rules 5/6).
|
|
431
|
+
* Derived from the ledger anchors only; fails closed: a block anchor that
|
|
432
432
|
* does not verify is `tampered`.
|
|
433
433
|
* @param {{runId: string, block: string, key: Buffer, rows: Array<Record<string, any>>}} opts
|
|
434
434
|
* @returns {{status: 'ok' | 'tampered', used: boolean}}
|
|
@@ -438,3 +438,15 @@ export function blockRungUsed(opts) {
|
|
|
438
438
|
if (anchors === null) return { status: 'tampered', used: true };
|
|
439
439
|
return { status: 'ok', used: anchors.some((a) => a.l3_rung_used === true) };
|
|
440
440
|
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Whether this file has used its L3 rung, once per file across all its anchors.
|
|
444
|
+
* Verify the run's anchors before filtering by file, preserving fail-closed tamper detection.
|
|
445
|
+
* @param {{runId: string, block: string, file: string, key: Buffer, rows: Array<Record<string, any>>}} opts
|
|
446
|
+
* @returns {{status: 'ok' | 'tampered', used: boolean}}
|
|
447
|
+
*/
|
|
448
|
+
export function fileRungUsed(opts) {
|
|
449
|
+
const anchors = blockAnchors(opts);
|
|
450
|
+
if (anchors === null) return { status: 'tampered', used: true };
|
|
451
|
+
return { status: 'ok', used: anchors.some((a) => a.file === opts.file && a.l3_rung_used === true) };
|
|
452
|
+
}
|