@codedology/code-forge 0.11.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +80 -0
- package/README.md +30 -2
- package/docs/reference/blocks.json +359 -1
- package/docs/reference/config.md +7 -5
- package/package.json +2 -2
- package/schema/code-forge.schema.json +4 -2
- package/skill/SKILL.md +2 -0
- package/skill/references/code.md +19 -5
- package/skill/references/proof.md +8 -2
- package/skill/references/review.md +16 -7
- package/skill/references/rules-core.md +1 -1
- package/skill/scripts/forge +1 -1
- package/skill/templates/coder-brief.md +15 -3
- package/src/autopilot/actions.mjs +4 -5
- package/src/cli/block.mjs +41 -0
- package/src/cli/context.mjs +3 -2
- package/src/cli/gates.mjs +15 -6
- package/src/cli/review-file.mjs +114 -3
- package/src/cli/review.mjs +2 -2
- package/src/config/validate.mjs +12 -5
- package/src/context/pack.mjs +20 -13
- package/src/doctor/index.mjs +2 -1
- package/src/doctor/local.mjs +19 -0
- package/src/doctor/worker-probe.mjs +15 -2
- package/src/gates/expand.mjs +102 -9
- package/src/gates/run.mjs +6 -4
- package/src/install/wizard/flags.mjs +1 -1
- package/src/ledger/spend.mjs +3 -3
- package/src/proof/lock.mjs +27 -10
- package/src/proof/rule.mjs +9 -0
- package/src/proof/tiers.mjs +2 -14
- package/src/review/fixloop.mjs +11 -6
- package/src/review/gate-check.mjs +34 -9
- package/src/review/lenses/A.md +4 -0
- package/src/review/lenses/B.md +4 -0
- package/src/review/lenses/full.md +4 -0
- package/src/review/lenses/judge.md +4 -0
- package/src/review/lenses/quick.md +4 -0
- package/src/review/lenses/recheck.md +4 -0
- package/src/review/paired.mjs +1 -0
- package/src/review/reopen.mjs +18 -6
- package/src/review/schema-fallback.mjs +3 -3
- package/src/util/exec.mjs +2 -1
- package/src/util/file-locks.mjs +118 -0
- package/src/util/forbidden.mjs +29 -2
- package/src/util/gate-token.mjs +3 -0
- package/src/worker/engine.mjs +2 -2
- package/src/worker/loop.mjs +98 -33
- package/src/worker/queue.mjs +183 -33
- package/src/worker/watch.mjs +115 -0
package/src/util/forbidden.mjs
CHANGED
|
@@ -790,6 +790,10 @@ export function renderForCodex(list = FORBIDDEN) {
|
|
|
790
790
|
* `$ cat ~/.code-forge/runs/r.key`, `Write(.code-forge/reviews/x.json)`, a stream-json
|
|
791
791
|
* `"name":"Write"` followed by the path — is found. It is a grep: a line that merely MENTIONS a
|
|
792
792
|
* forbidden command is a hit too (fail closed; the block gate reports it as `rule_break`, B5).
|
|
793
|
+
* Codex `exec --json` logs (every non-empty line an object with a string `type`, first event
|
|
794
|
+
* `thread.started`) scan command_execution commands and file_change paths as writes. Other
|
|
795
|
+
* items scan whole, except agent_message and reasoning; a command_execution's aggregated_output
|
|
796
|
+
* is never scanned; non-item events are skipped. Any other log uses the plain-text grep above, unchanged.
|
|
793
797
|
*
|
|
794
798
|
* Linear in the line length: `contains` and `path` entries are evaluated ONCE per line over its
|
|
795
799
|
* tokens (a `write` path entry hits when a write command starts before a token naming the path);
|
|
@@ -801,6 +805,17 @@ export function renderForCodex(list = FORBIDDEN) {
|
|
|
801
805
|
* @returns {{id: string, line: number}[]}
|
|
802
806
|
*/
|
|
803
807
|
export function scanTranscript(text, list = FORBIDDEN) {
|
|
808
|
+
const lines = String(text).split('\n');
|
|
809
|
+
/** @type {any[] | null} */
|
|
810
|
+
let events = null;
|
|
811
|
+
try {
|
|
812
|
+
const parsed = lines.map((line) => line.trim() === '' ? undefined : JSON.parse(line));
|
|
813
|
+
const nonEmpty = parsed.filter((event) => event !== undefined);
|
|
814
|
+
if (nonEmpty[0]?.type === 'thread.started' && nonEmpty.every((event) =>
|
|
815
|
+
event !== null && typeof event === 'object' && !Array.isArray(event) && typeof event.type === 'string')) events = parsed;
|
|
816
|
+
} catch {
|
|
817
|
+
// One bad JSONL line makes the entire transcript a plain-text, fail-closed grep.
|
|
818
|
+
}
|
|
804
819
|
const commandEntries = list.filter((e) => e.kind === 'prefix' || e.kind === 'commandFlag' || e.kind === 'anyPrefix');
|
|
805
820
|
const firstWords = new Set(['npx', 'bunx', 'pnpm', 'yarn', 'node']);
|
|
806
821
|
for (const e of commandEntries) {
|
|
@@ -811,9 +826,21 @@ export function scanTranscript(text, list = FORBIDDEN) {
|
|
|
811
826
|
const writeStarts = new Set([...WRITE_VERBS, ...WRAPPERS, 'sed', 'perl', 'git']);
|
|
812
827
|
/** @type {{id: string, line: number}[]} */
|
|
813
828
|
const hits = [];
|
|
814
|
-
|
|
815
|
-
.split('\n')
|
|
829
|
+
lines
|
|
816
830
|
.forEach((lineText, index) => {
|
|
831
|
+
if (events !== null) {
|
|
832
|
+
const event = events[index];
|
|
833
|
+
if (!event?.type.startsWith('item.') || !event.item) return;
|
|
834
|
+
const item = event.item;
|
|
835
|
+
if (item.type === 'agent_message' || item.type === 'reasoning') return;
|
|
836
|
+
// a command that is not a string is scanned with the rest of its item (fail closed), its output left out
|
|
837
|
+
if (item.type === 'command_execution') lineText = typeof item.command === 'string' ? item.command : JSON.stringify({ ...item, aggregated_output: undefined });
|
|
838
|
+
else if (item.type === 'file_change') {
|
|
839
|
+
lineText = (Array.isArray(item.changes) ? item.changes : [])
|
|
840
|
+
.filter((change) => typeof change?.path === 'string')
|
|
841
|
+
.map((change) => `Write(${JSON.stringify(change.path)})`).join(' ');
|
|
842
|
+
} else lineText = JSON.stringify(item);
|
|
843
|
+
}
|
|
817
844
|
const tokens = lineText.split(/(>>?)|[\s'"`;|&(),{}[\]]+/).filter((t) => typeof t === 'string' && t.length > 0);
|
|
818
845
|
const ids = new Set();
|
|
819
846
|
let firstWrite = -1;
|
package/src/util/gate-token.mjs
CHANGED
package/src/worker/engine.mjs
CHANGED
|
@@ -107,7 +107,7 @@ import { blockTree, worktreeRecord } from '../proof/worktree.mjs';
|
|
|
107
107
|
import { ledgerSchemaHistory } from '../review/schema-fallback.mjs';
|
|
108
108
|
import { verifyRow } from '../state/signer.mjs';
|
|
109
109
|
import { redact } from '../util/redact.mjs';
|
|
110
|
-
import {
|
|
110
|
+
import { withLock } from '../util/file-locks.mjs';
|
|
111
111
|
import { blockAnchors, blockRungUsed, loadState, RESTORED_EVENT, saveState } from './review-state.mjs';
|
|
112
112
|
import { assertAnswers } from './queue.mjs';
|
|
113
113
|
import { assertTicketId, contentHash, readRegularFileNoFollow, WorkerError } from './ticket.mjs';
|
|
@@ -536,7 +536,7 @@ async function fixLoopRound(ticket, ctx, { base, level, owned, ownedKinds, block
|
|
|
536
536
|
if (!ctx.readRows || !ctx.writeRow) return { status: 'unavailable', reason: 'no-ledger', approved: false, engine: 'adaptive', sessions: [] };
|
|
537
537
|
const readRows = ctx.readRows;
|
|
538
538
|
/** @type {{outcome: ReviewOutcome} | {round: () => Promise<ReviewOutcome>}} */
|
|
539
|
-
const prepared = await
|
|
539
|
+
const prepared = await withLock(blockLockKey(ctx.runId, ticket.block), async () => {
|
|
540
540
|
let rows;
|
|
541
541
|
try {
|
|
542
542
|
rows = (await readRows()).filter((r) => r?.run === ctx.runId);
|
package/src/worker/loop.mjs
CHANGED
|
@@ -21,8 +21,9 @@
|
|
|
21
21
|
* snapshots (or none) keeps the config the worker booted with.
|
|
22
22
|
*
|
|
23
23
|
* Ticket pool (B42): the worker runs up to `review.parallel_tickets` (default 3, 1–16) tickets at
|
|
24
|
-
* once, oldest first
|
|
25
|
-
* (block, file) never overlap (a file lock;
|
|
24
|
+
* once, oldest first (or fewest running per block with `review.fair_share`); when one ends the
|
|
25
|
+
* next pending one starts. Two tickets for the same (block, file) never overlap (a file lock;
|
|
26
|
+
* the later one stays pending without taking a slot),
|
|
26
27
|
* and the engine takes the block's budget row and L3 rung under a block lock (`engine.mjs`). A
|
|
27
28
|
* throw in one ticket is that ticket's `unavailable` result and never stops the others. `stop()`
|
|
28
29
|
* starts no new ticket and waits for the running ones. With 1 the worker runs exactly as before.
|
|
@@ -49,14 +50,15 @@ import { runRecordPath } from '../state/paths.mjs';
|
|
|
49
50
|
import { readRun, reattachWorker } from '../state/run.mjs';
|
|
50
51
|
import { loadKey, signRow } from '../state/signer.mjs';
|
|
51
52
|
import { logWarning } from '../util/error-log.mjs';
|
|
52
|
-
import {
|
|
53
|
+
import { withLock, tryLock } from '../util/file-locks.mjs';
|
|
53
54
|
import { redact, writeSafe } from '../util/redact.mjs';
|
|
54
55
|
import { runRoot, setRunRoot } from '../util/tmp.mjs';
|
|
55
56
|
import { openFindings } from '../review/gate-check.mjs';
|
|
56
57
|
import { countLines, RouteError, routeTicket } from '../review/route.mjs';
|
|
57
58
|
import { canonicalPath, contentRootFor, reviewTicket } from './engine.mjs';
|
|
58
|
-
import { announceWorker, beatWorker, HEARTBEAT_MS, liveWorker, pendingTickets, readTicket, retractWorker, writeResult } from './queue.mjs';
|
|
59
|
+
import { announceWorker, beatWorker, HEARTBEAT_MS, isDone, liveWorker, pendingTickets, readTicket, retractWorker, workerRegistry, writeResult } from './queue.mjs';
|
|
59
60
|
import { DELETED_HASH, hashBytes, readRegularFileNoFollow, repoRootOf, WorkerError } from './ticket.mjs';
|
|
61
|
+
import { watchTick } from './watch.mjs';
|
|
60
62
|
|
|
61
63
|
/** Name of the key the worker resolves for System 1 (`keys.jev` / `system1.key` reference). */
|
|
62
64
|
export const JEV_KEY_NAME = 'jev';
|
|
@@ -233,7 +235,10 @@ export function recordedVerdict(result) {
|
|
|
233
235
|
* @property {typeof reviewTicket} [review] - the engine hook (default `./engine.mjs`).
|
|
234
236
|
* @property {typeof spawnSession} [spawn]
|
|
235
237
|
* @property {typeof readRun} [readRun] - the run-record read behind the per-ticket config (a test seam).
|
|
238
|
+
* @property {{beforeLock?: () => void, afterCount?: (count: number) => Promise<unknown>}} [hooks] - deterministic boot races.
|
|
236
239
|
* @property {typeof readTicket} [readTicket] - the ticket read (a test seam).
|
|
240
|
+
* @property {typeof import('../util/exec.mjs').exec} [watchExec] - watch git exec seam.
|
|
241
|
+
* @property {Parameters<typeof watchTick>[0]['hooks']} [watchHooks] - deterministic watch races.
|
|
237
242
|
* @property {import('../keys/store.mjs').KeyStore} [store] - default: B2's production store.
|
|
238
243
|
* @property {any} [opRead]
|
|
239
244
|
* @property {NodeJS.ProcessEnv} [env]
|
|
@@ -274,6 +279,8 @@ export async function createWorker(opts, deps = {}) {
|
|
|
274
279
|
let stopping = false;
|
|
275
280
|
/** @type {ReturnType<typeof setInterval> | null} */
|
|
276
281
|
let heartbeat = null;
|
|
282
|
+
/** @type {ReturnType<typeof announceWorker> | null} */
|
|
283
|
+
let self = null;
|
|
277
284
|
/** @type {(() => void) | null} */
|
|
278
285
|
let wake = null;
|
|
279
286
|
/** @type {(() => void) | null} wakes the pool's wait in `drain` (stop) */
|
|
@@ -281,6 +288,9 @@ export async function createWorker(opts, deps = {}) {
|
|
|
281
288
|
|
|
282
289
|
/** @param {Record<string, any>} row */
|
|
283
290
|
const ledger = (row) => writeRow(signRow({ run: runId, ...row }, key));
|
|
291
|
+
// Shared across direct ticks and the scheduler, so running tickets are never superseded.
|
|
292
|
+
const activeTickets = new Set();
|
|
293
|
+
const tickWatch = () => watchTick({ repoRoot, runId, cfg, readRun: loadRun, writeRow: ledger, running: activeTickets, exec: deps.watchExec, hooks: deps.watchHooks });
|
|
284
294
|
|
|
285
295
|
/**
|
|
286
296
|
* The config ticket `id` is reviewed with (B35): its snapshot from the run record, re-read per
|
|
@@ -410,7 +420,7 @@ export async function createWorker(opts, deps = {}) {
|
|
|
410
420
|
// read, advanced and saved by one ticket at a time; the later one waits here. The key
|
|
411
421
|
// carries the run so two workers in one process (tests) never share a lock.
|
|
412
422
|
const t = ticket;
|
|
413
|
-
result = await
|
|
423
|
+
result = await withLock(`${runId}\0${fileLockKey(t.block, t.file)}`, () => reviewOne(id, t));
|
|
414
424
|
}
|
|
415
425
|
// Stopping: a ticket whose session our own stop killed ends `unavailable` — leave it queued
|
|
416
426
|
// (no done marker) for the next worker instead of recording the kill as a failure. A ticket
|
|
@@ -526,50 +536,50 @@ export async function createWorker(opts, deps = {}) {
|
|
|
526
536
|
}
|
|
527
537
|
|
|
528
538
|
/**
|
|
529
|
-
*
|
|
530
|
-
*
|
|
531
|
-
*
|
|
532
|
-
* @returns {Promise<number>}
|
|
539
|
+
* Pool settings from the snapshot in force, else the boot config. A run reload applies at the
|
|
540
|
+
* next drain; anything but an integer 1–16 uses the default size.
|
|
541
|
+
* @returns {Promise<{limit: number, fairShare: boolean}>}
|
|
533
542
|
*/
|
|
534
|
-
async function
|
|
543
|
+
async function poolSettings() {
|
|
535
544
|
let current = cfg;
|
|
536
545
|
try {
|
|
537
546
|
current = await configFor('');
|
|
538
547
|
} catch {
|
|
539
|
-
// an unreadable record: the boot
|
|
548
|
+
// an unreadable record: the boot settings (each ticket still fails closed on its own)
|
|
540
549
|
}
|
|
541
|
-
return parallelTickets(current);
|
|
550
|
+
return { limit: parallelTickets(current), fairShare: current?.review?.fair_share === true };
|
|
542
551
|
}
|
|
543
552
|
|
|
544
553
|
/**
|
|
545
|
-
* The (block, file) key of each ticket read so far, by id
|
|
546
|
-
* id is its content's hash), so
|
|
554
|
+
* The block and (block, file) key of each ticket read so far, by id. A written ticket never
|
|
555
|
+
* changes (its id is its content's hash), so scheduling reads it once across wake-ups.
|
|
547
556
|
* Dropped once the ticket is done.
|
|
548
|
-
* @type {Map<string, string>}
|
|
557
|
+
* @type {Map<string, {key: string, block: string}>}
|
|
549
558
|
*/
|
|
550
559
|
const fileKeys = new Map();
|
|
551
560
|
|
|
552
561
|
/**
|
|
553
|
-
* The
|
|
562
|
+
* The block and file-lock key a pending ticket names, or null when unreadable (it is started
|
|
554
563
|
* anyway and refused by `processTicket`; a null is not cached). Only a scheduling hint:
|
|
555
564
|
* `processTicket` takes the file lock on the ticket it actually read.
|
|
556
|
-
* @param {string} id @returns {string | null}
|
|
565
|
+
* @param {string} id @returns {{key: string, block: string} | null}
|
|
557
566
|
*/
|
|
558
567
|
function fileKeyOf(id) {
|
|
559
568
|
const known = fileKeys.get(id);
|
|
560
569
|
if (known !== undefined) return known;
|
|
561
570
|
try {
|
|
562
571
|
const t = loadTicket(repoRoot, id);
|
|
563
|
-
const
|
|
564
|
-
fileKeys.set(id,
|
|
565
|
-
return
|
|
572
|
+
const file = { key: fileLockKey(t.block, t.file), block: t.block };
|
|
573
|
+
fileKeys.set(id, file);
|
|
574
|
+
return file;
|
|
566
575
|
} catch {
|
|
567
576
|
return null;
|
|
568
577
|
}
|
|
569
578
|
}
|
|
570
579
|
|
|
571
580
|
/**
|
|
572
|
-
* Serve every pending ticket (B42): up to `review.parallel_tickets` at once, oldest first;
|
|
581
|
+
* Serve every pending ticket (B42): up to `review.parallel_tickets` at once, oldest first; with
|
|
582
|
+
* fair share, each slot goes to the block with fewest running tickets, ties oldest first. When
|
|
573
583
|
* one finishes, the queue is re-read and the next pending ticket starts (no batch barrier; the
|
|
574
584
|
* queue is also re-read every `pollMs` while tickets run, so a new ticket fills a free slot).
|
|
575
585
|
* A ticket whose (block, file) is already running is left pending (it does not take a slot)
|
|
@@ -594,11 +604,14 @@ export async function createWorker(opts, deps = {}) {
|
|
|
594
604
|
}
|
|
595
605
|
/** @type {number | null} */
|
|
596
606
|
let limit = null;
|
|
607
|
+
let fairShare = false;
|
|
597
608
|
let done = 0;
|
|
598
609
|
/** @type {Map<string, Promise<void>>} */
|
|
599
610
|
const running = new Map();
|
|
600
611
|
/** @type {Set<string>} the (block, file) keys of the running tickets */
|
|
601
612
|
const busy = new Set();
|
|
613
|
+
/** @type {Map<string, number>} running tickets per block */
|
|
614
|
+
const runningByBlock = new Map();
|
|
602
615
|
/** @type {Set<string>} */
|
|
603
616
|
const started = new Set();
|
|
604
617
|
for (;;) {
|
|
@@ -617,23 +630,61 @@ export async function createWorker(opts, deps = {}) {
|
|
|
617
630
|
}
|
|
618
631
|
}
|
|
619
632
|
if (!stopping) {
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
633
|
+
try {
|
|
634
|
+
await tickWatch();
|
|
635
|
+
} catch (thrown) {
|
|
636
|
+
const text = `watch tick failed (${errorKind(thrown)})`;
|
|
637
|
+
writeSafe(warnStream, `worker: WARN ${text}\n`);
|
|
638
|
+
try { await logWarning({ warning: 'watch_tick_failed', message: text }); } catch { /* a log failure never stops dispatch */ }
|
|
639
|
+
}
|
|
640
|
+
const pending = pendingTickets(repoRoot).filter((id) => !poisoned.has(id) && !started.has(id));
|
|
641
|
+
if (pending.length > 0 && limit === null) ({ limit, fairShare } = await poolSettings());
|
|
642
|
+
// Cache even unreadable hints for this tick: ranking never reads a ticket twice.
|
|
643
|
+
/** @type {Map<string, ReturnType<typeof fileKeyOf>>} */
|
|
644
|
+
const hints = new Map();
|
|
645
|
+
while (pending.length > 0 && running.size < limit && !stopping) {
|
|
646
|
+
let next = -1;
|
|
647
|
+
let fewest = Infinity;
|
|
648
|
+
for (let i = 0; i < pending.length; i += 1) {
|
|
649
|
+
const id = pending[i];
|
|
650
|
+
if (!hints.has(id)) hints.set(id, fileKeyOf(id));
|
|
651
|
+
const file = hints.get(id);
|
|
652
|
+
if (file && busy.has(file.key)) continue; // waits without a slot
|
|
653
|
+
const count = fairShare && file ? runningByBlock.get(file.block) ?? 0 : 0;
|
|
654
|
+
if (count < fewest) {
|
|
655
|
+
next = i;
|
|
656
|
+
fewest = count;
|
|
657
|
+
}
|
|
658
|
+
if (count === 0) break; // best possible count; ties stay oldest first
|
|
659
|
+
}
|
|
660
|
+
if (next === -1) break;
|
|
661
|
+
const [id] = pending.splice(next, 1);
|
|
662
|
+
const claim = await tryLock(`ticket\0${runId}\0${id}`);
|
|
663
|
+
if (!claim.ok) continue; // another process owns it; no pool slot is taken
|
|
664
|
+
// Acquisition awaited a process probe: another worker may have completed this ticket
|
|
665
|
+
// since the pending snapshot, or stop() may have arrived while we waited.
|
|
666
|
+
if (stopping || isDone(repoRoot, id)) {
|
|
667
|
+
claim.release();
|
|
668
|
+
continue;
|
|
669
|
+
}
|
|
670
|
+
const file = hints.get(id);
|
|
671
|
+
const fileKey = file?.key ?? null;
|
|
626
672
|
started.add(id);
|
|
673
|
+
activeTickets.add(id);
|
|
627
674
|
if (fileKey !== null) busy.add(fileKey);
|
|
675
|
+
if (file) runningByBlock.set(file.block, (runningByBlock.get(file.block) ?? 0) + 1);
|
|
628
676
|
const slot = runOne(id)
|
|
629
677
|
.then((n) => {
|
|
630
678
|
done += n;
|
|
631
679
|
})
|
|
632
680
|
.finally(() => {
|
|
681
|
+
claim.release(); // the done marker is published before the claim is released
|
|
633
682
|
// the slot is freed on every exit path (`runOne` never rejects)
|
|
634
683
|
running.delete(id);
|
|
684
|
+
activeTickets.delete(id);
|
|
635
685
|
fileKeys.delete(id);
|
|
636
686
|
if (fileKey !== null) busy.delete(fileKey);
|
|
687
|
+
if (file) runningByBlock.set(file.block, runningByBlock.get(file.block) - 1);
|
|
637
688
|
});
|
|
638
689
|
running.set(id, slot);
|
|
639
690
|
}
|
|
@@ -669,7 +720,8 @@ export async function createWorker(opts, deps = {}) {
|
|
|
669
720
|
* must still see the worker as live the whole time.
|
|
670
721
|
*/
|
|
671
722
|
function announce() {
|
|
672
|
-
|
|
723
|
+
if (self !== null) return; // boot already announced under the lock
|
|
724
|
+
self = announceWorker(repoRoot, { pid: process.pid, run: runId });
|
|
673
725
|
stopBeating();
|
|
674
726
|
const timer = setInterval(() => {
|
|
675
727
|
try {
|
|
@@ -734,12 +786,16 @@ export async function createWorker(opts, deps = {}) {
|
|
|
734
786
|
/** Withdraw this process's announcement (`worker.json`) if it is still ours. */
|
|
735
787
|
function release() {
|
|
736
788
|
stopBeating();
|
|
737
|
-
retractWorker(repoRoot,
|
|
789
|
+
if (self !== null) retractWorker(repoRoot, self.pid, self.start_time);
|
|
790
|
+
self = null;
|
|
738
791
|
}
|
|
739
792
|
|
|
740
|
-
return { processTicket, drain, run, once, stop, release, jevKeyResolved: jevKey !== null, sessionEnv: childEnv };
|
|
793
|
+
return { processTicket, drain, run, once, stop, release, announce, watchTick: tickWatch, jevKeyResolved: jevKey !== null, sessionEnv: childEnv };
|
|
741
794
|
}
|
|
742
795
|
|
|
796
|
+
/** This block permits one registered worker per queue. */
|
|
797
|
+
const WORKER_LIMIT = 1;
|
|
798
|
+
|
|
743
799
|
/**
|
|
744
800
|
* Start-up for the `worker` verb: run record, repo root, config, run temp root (owned by this
|
|
745
801
|
* process), run key; refuses when another live worker already serves this queue.
|
|
@@ -762,13 +818,22 @@ export async function bootWorker(opts, deps = {}) {
|
|
|
762
818
|
if (!loaded.ok) throw new WorkerError('config', loaded.message ?? 'the project config cannot be loaded');
|
|
763
819
|
cfg = loaded.config;
|
|
764
820
|
}
|
|
765
|
-
const other = liveWorker(repoRoot);
|
|
766
|
-
if (other && other.pid !== process.pid) throw new WorkerError('worker-running', `worker pid ${other.pid} already serves this queue`);
|
|
767
821
|
const tmpRoot = typeof cfg?.tmp?.root === 'string' ? cfg.tmp.root : undefined;
|
|
768
822
|
const runRootDir = runRoot(opts.runId, { root: tmpRoot });
|
|
769
823
|
setRunRoot(runRootDir);
|
|
770
824
|
const key = await loadKey(opts.runId);
|
|
771
825
|
const worker = await createWorker({ runId: opts.runId, repoRoot, cfg, runRootDir, slug: record.project, key, pollMs: opts.pollMs, guardRunRecordPath: runRecordPath(opts.runId) }, deps);
|
|
826
|
+
deps.hooks?.beforeLock?.();
|
|
827
|
+
await withLock(`worker-boot:${repoRoot}`, async () => {
|
|
828
|
+
const entries = workerRegistry(repoRoot, { prune: true });
|
|
829
|
+
let count = entries.filter((entry) => entry.live).length;
|
|
830
|
+
// Older workers have only worker.json; do not boot over a live legacy owner.
|
|
831
|
+
const legacy = liveWorker(repoRoot);
|
|
832
|
+
if (legacy && !entries.some((entry) => entry.worker?.pid === legacy.pid && entry.worker.start_time === legacy.start_time)) count++;
|
|
833
|
+
await deps.hooks?.afterCount?.(count);
|
|
834
|
+
if (count >= WORKER_LIMIT) throw new WorkerError('worker-running', 'a live or unreadable worker entry already serves this queue');
|
|
835
|
+
worker.announce();
|
|
836
|
+
});
|
|
772
837
|
return { ...worker, repoRoot, runRootDir };
|
|
773
838
|
}
|
|
774
839
|
|
|
@@ -811,7 +876,7 @@ export async function launchWorker({ runId, workspace, env = process.env, timeou
|
|
|
811
876
|
child.unref();
|
|
812
877
|
const deadline = Date.now() + timeoutMs;
|
|
813
878
|
while (Date.now() < deadline) {
|
|
814
|
-
if (typeof pid === 'number' && liveWorker(repoRoot
|
|
879
|
+
if (typeof pid === 'number' && liveWorker(repoRoot, { pid, registeredOnly: true })) return { pid, repoRoot };
|
|
815
880
|
if (child.exitCode !== null || child.signalCode !== null) break;
|
|
816
881
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
817
882
|
}
|