@codedology/code-forge 0.12.0 → 0.14.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 +53 -0
- package/README.md +28 -2
- package/docs/reference/blocks.json +354 -1
- package/docs/reference/config.md +4 -1
- package/package.json +1 -1
- package/schema/code-forge.schema.json +10 -1
- package/skill/references/adapters/claude-code.md +1 -1
- package/skill/references/adapters/solo.md +1 -1
- package/skill/references/code.md +15 -8
- package/skill/references/ledger.md +4 -2
- package/skill/references/proof.md +1 -1
- package/skill/references/review.md +21 -7
- package/skill/references/security.md +5 -3
- package/skill/scripts/forge +1 -1
- package/src/autopilot/actions.mjs +4 -5
- package/src/cli/block.mjs +130 -31
- package/src/cli/context.mjs +3 -2
- package/src/cli/report.mjs +2 -2
- package/src/cli/review-file.mjs +67 -42
- package/src/cli/review.mjs +2 -2
- package/src/context/pack.mjs +13 -9
- package/src/doctor/index.mjs +2 -1
- package/src/doctor/local.mjs +19 -0
- package/src/doctor/worker-probe.mjs +15 -2
- package/src/ledger/export.mjs +1 -1
- package/src/ledger/report.mjs +9 -3
- package/src/ledger/spend.mjs +3 -3
- package/src/proof/lock.mjs +5 -1
- package/src/review/accept-hit.mjs +78 -0
- package/src/review/agent-waiver.mjs +201 -0
- package/src/review/gate-check.mjs +62 -28
- package/src/review/reopen.mjs +35 -14
- package/src/review/schema-fallback.mjs +3 -3
- package/src/state/config-snapshot.mjs +1 -0
- package/src/util/file-locks.mjs +118 -0
- package/src/util/forbidden.mjs +51 -2
- package/src/worker/engine.mjs +2 -2
- package/src/worker/loop.mjs +83 -33
- package/src/worker/queue.mjs +141 -33
- package/src/worker/watch.mjs +4 -2
package/src/util/forbidden.mjs
CHANGED
|
@@ -275,6 +275,7 @@ export const FORBIDDEN = Object.freeze(
|
|
|
275
275
|
* Coder-only entries (B12b, plan §4.9): verbs only a human (through the orchestrator) may run.
|
|
276
276
|
* They are NOT part of `FORBIDDEN` (whose rendered count other blocks pin); `mergeForbidden()`
|
|
277
277
|
* always appends them, so every list built for a coder carries them.
|
|
278
|
+
* - `code-forge block accept-hit` (B127) — accepting a false transcript hit is human-only;
|
|
278
279
|
* - `code-forge block waive` — waiving a review finding is human-only;
|
|
279
280
|
* - `code-forge block reopen` (B72) — restarting a stopped or lost file's review is human-only;
|
|
280
281
|
* - `code-forge autopilot` (any subcommand, B45) — the owner's grant to a delegate;
|
|
@@ -324,6 +325,27 @@ export const CODER_ONLY_FORBIDDEN = Object.freeze(
|
|
|
324
325
|
tokens: ['--remove-worktree'],
|
|
325
326
|
description: "Remove a block's worktree at close (block close --remove-worktree, B62a) — the orchestrator only",
|
|
326
327
|
},
|
|
328
|
+
{
|
|
329
|
+
id: 'code-forge-block-accept-hit-from-coder',
|
|
330
|
+
kind: 'anyPrefix',
|
|
331
|
+
prefixes: [
|
|
332
|
+
['code-forge', 'block', 'accept-hit'],
|
|
333
|
+
['forge', 'block', 'accept-hit'],
|
|
334
|
+
],
|
|
335
|
+
description: 'Accept a false transcript hit — human-only, confirmed in the human terminal (B127)',
|
|
336
|
+
},
|
|
337
|
+
{
|
|
338
|
+
id: 'code-forge-block-unwaive-from-coder',
|
|
339
|
+
kind: 'anyPrefix',
|
|
340
|
+
prefixes: [['code-forge', 'block', 'unwaive'], ['forge', 'block', 'unwaive']],
|
|
341
|
+
description: 'Undo agent waivers — human-only, confirmed in the human terminal (B124b)',
|
|
342
|
+
},
|
|
343
|
+
{
|
|
344
|
+
id: 'code-forge-block-ack-waivers-from-coder',
|
|
345
|
+
kind: 'anyPrefix',
|
|
346
|
+
prefixes: [['code-forge', 'block', 'ack-waivers'], ['forge', 'block', 'ack-waivers']],
|
|
347
|
+
description: 'Acknowledge agent waivers — human-only, confirmed in the human terminal (B124b)',
|
|
348
|
+
},
|
|
327
349
|
].map(freezeEntry),
|
|
328
350
|
);
|
|
329
351
|
|
|
@@ -790,6 +812,10 @@ export function renderForCodex(list = FORBIDDEN) {
|
|
|
790
812
|
* `$ cat ~/.code-forge/runs/r.key`, `Write(.code-forge/reviews/x.json)`, a stream-json
|
|
791
813
|
* `"name":"Write"` followed by the path — is found. It is a grep: a line that merely MENTIONS a
|
|
792
814
|
* forbidden command is a hit too (fail closed; the block gate reports it as `rule_break`, B5).
|
|
815
|
+
* Codex `exec --json` logs (every non-empty line an object with a string `type`, first event
|
|
816
|
+
* `thread.started`) scan command_execution commands and file_change paths as writes. Other
|
|
817
|
+
* items scan whole, except agent_message and reasoning; a command_execution's aggregated_output
|
|
818
|
+
* is never scanned; non-item events are skipped. Any other log uses the plain-text grep above, unchanged.
|
|
793
819
|
*
|
|
794
820
|
* Linear in the line length: `contains` and `path` entries are evaluated ONCE per line over its
|
|
795
821
|
* tokens (a `write` path entry hits when a write command starts before a token naming the path);
|
|
@@ -801,6 +827,17 @@ export function renderForCodex(list = FORBIDDEN) {
|
|
|
801
827
|
* @returns {{id: string, line: number}[]}
|
|
802
828
|
*/
|
|
803
829
|
export function scanTranscript(text, list = FORBIDDEN) {
|
|
830
|
+
const lines = String(text).split('\n');
|
|
831
|
+
/** @type {any[] | null} */
|
|
832
|
+
let events = null;
|
|
833
|
+
try {
|
|
834
|
+
const parsed = lines.map((line) => line.trim() === '' ? undefined : JSON.parse(line));
|
|
835
|
+
const nonEmpty = parsed.filter((event) => event !== undefined);
|
|
836
|
+
if (nonEmpty[0]?.type === 'thread.started' && nonEmpty.every((event) =>
|
|
837
|
+
event !== null && typeof event === 'object' && !Array.isArray(event) && typeof event.type === 'string')) events = parsed;
|
|
838
|
+
} catch {
|
|
839
|
+
// One bad JSONL line makes the entire transcript a plain-text, fail-closed grep.
|
|
840
|
+
}
|
|
804
841
|
const commandEntries = list.filter((e) => e.kind === 'prefix' || e.kind === 'commandFlag' || e.kind === 'anyPrefix');
|
|
805
842
|
const firstWords = new Set(['npx', 'bunx', 'pnpm', 'yarn', 'node']);
|
|
806
843
|
for (const e of commandEntries) {
|
|
@@ -811,9 +848,21 @@ export function scanTranscript(text, list = FORBIDDEN) {
|
|
|
811
848
|
const writeStarts = new Set([...WRITE_VERBS, ...WRAPPERS, 'sed', 'perl', 'git']);
|
|
812
849
|
/** @type {{id: string, line: number}[]} */
|
|
813
850
|
const hits = [];
|
|
814
|
-
|
|
815
|
-
.split('\n')
|
|
851
|
+
lines
|
|
816
852
|
.forEach((lineText, index) => {
|
|
853
|
+
if (events !== null) {
|
|
854
|
+
const event = events[index];
|
|
855
|
+
if (!event?.type.startsWith('item.') || !event.item) return;
|
|
856
|
+
const item = event.item;
|
|
857
|
+
if (item.type === 'agent_message' || item.type === 'reasoning') return;
|
|
858
|
+
// a command that is not a string is scanned with the rest of its item (fail closed), its output left out
|
|
859
|
+
if (item.type === 'command_execution') lineText = typeof item.command === 'string' ? item.command : JSON.stringify({ ...item, aggregated_output: undefined });
|
|
860
|
+
else if (item.type === 'file_change') {
|
|
861
|
+
lineText = (Array.isArray(item.changes) ? item.changes : [])
|
|
862
|
+
.filter((change) => typeof change?.path === 'string')
|
|
863
|
+
.map((change) => `Write(${JSON.stringify(change.path)})`).join(' ');
|
|
864
|
+
} else lineText = JSON.stringify(item);
|
|
865
|
+
}
|
|
817
866
|
const tokens = lineText.split(/(>>?)|[\s'"`;|&(),{}[\]]+/).filter((t) => typeof t === 'string' && t.length > 0);
|
|
818
867
|
const ids = new Set();
|
|
819
868
|
let firstWrite = -1;
|
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,13 +50,13 @@ 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';
|
|
60
61
|
import { watchTick } from './watch.mjs';
|
|
61
62
|
|
|
@@ -234,6 +235,7 @@ export function recordedVerdict(result) {
|
|
|
234
235
|
* @property {typeof reviewTicket} [review] - the engine hook (default `./engine.mjs`).
|
|
235
236
|
* @property {typeof spawnSession} [spawn]
|
|
236
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.
|
|
237
239
|
* @property {typeof readTicket} [readTicket] - the ticket read (a test seam).
|
|
238
240
|
* @property {typeof import('../util/exec.mjs').exec} [watchExec] - watch git exec seam.
|
|
239
241
|
* @property {Parameters<typeof watchTick>[0]['hooks']} [watchHooks] - deterministic watch races.
|
|
@@ -277,6 +279,8 @@ export async function createWorker(opts, deps = {}) {
|
|
|
277
279
|
let stopping = false;
|
|
278
280
|
/** @type {ReturnType<typeof setInterval> | null} */
|
|
279
281
|
let heartbeat = null;
|
|
282
|
+
/** @type {ReturnType<typeof announceWorker> | null} */
|
|
283
|
+
let self = null;
|
|
280
284
|
/** @type {(() => void) | null} */
|
|
281
285
|
let wake = null;
|
|
282
286
|
/** @type {(() => void) | null} wakes the pool's wait in `drain` (stop) */
|
|
@@ -416,7 +420,7 @@ export async function createWorker(opts, deps = {}) {
|
|
|
416
420
|
// read, advanced and saved by one ticket at a time; the later one waits here. The key
|
|
417
421
|
// carries the run so two workers in one process (tests) never share a lock.
|
|
418
422
|
const t = ticket;
|
|
419
|
-
result = await
|
|
423
|
+
result = await withLock(`${runId}\0${fileLockKey(t.block, t.file)}`, () => reviewOne(id, t));
|
|
420
424
|
}
|
|
421
425
|
// Stopping: a ticket whose session our own stop killed ends `unavailable` — leave it queued
|
|
422
426
|
// (no done marker) for the next worker instead of recording the kill as a failure. A ticket
|
|
@@ -532,50 +536,50 @@ export async function createWorker(opts, deps = {}) {
|
|
|
532
536
|
}
|
|
533
537
|
|
|
534
538
|
/**
|
|
535
|
-
*
|
|
536
|
-
*
|
|
537
|
-
*
|
|
538
|
-
* @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}>}
|
|
539
542
|
*/
|
|
540
|
-
async function
|
|
543
|
+
async function poolSettings() {
|
|
541
544
|
let current = cfg;
|
|
542
545
|
try {
|
|
543
546
|
current = await configFor('');
|
|
544
547
|
} catch {
|
|
545
|
-
// an unreadable record: the boot
|
|
548
|
+
// an unreadable record: the boot settings (each ticket still fails closed on its own)
|
|
546
549
|
}
|
|
547
|
-
return parallelTickets(current);
|
|
550
|
+
return { limit: parallelTickets(current), fairShare: current?.review?.fair_share === true };
|
|
548
551
|
}
|
|
549
552
|
|
|
550
553
|
/**
|
|
551
|
-
* The (block, file) key of each ticket read so far, by id
|
|
552
|
-
* 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.
|
|
553
556
|
* Dropped once the ticket is done.
|
|
554
|
-
* @type {Map<string, string>}
|
|
557
|
+
* @type {Map<string, {key: string, block: string}>}
|
|
555
558
|
*/
|
|
556
559
|
const fileKeys = new Map();
|
|
557
560
|
|
|
558
561
|
/**
|
|
559
|
-
* The
|
|
562
|
+
* The block and file-lock key a pending ticket names, or null when unreadable (it is started
|
|
560
563
|
* anyway and refused by `processTicket`; a null is not cached). Only a scheduling hint:
|
|
561
564
|
* `processTicket` takes the file lock on the ticket it actually read.
|
|
562
|
-
* @param {string} id @returns {string | null}
|
|
565
|
+
* @param {string} id @returns {{key: string, block: string} | null}
|
|
563
566
|
*/
|
|
564
567
|
function fileKeyOf(id) {
|
|
565
568
|
const known = fileKeys.get(id);
|
|
566
569
|
if (known !== undefined) return known;
|
|
567
570
|
try {
|
|
568
571
|
const t = loadTicket(repoRoot, id);
|
|
569
|
-
const
|
|
570
|
-
fileKeys.set(id,
|
|
571
|
-
return
|
|
572
|
+
const file = { key: fileLockKey(t.block, t.file), block: t.block };
|
|
573
|
+
fileKeys.set(id, file);
|
|
574
|
+
return file;
|
|
572
575
|
} catch {
|
|
573
576
|
return null;
|
|
574
577
|
}
|
|
575
578
|
}
|
|
576
579
|
|
|
577
580
|
/**
|
|
578
|
-
* 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
|
|
579
583
|
* one finishes, the queue is re-read and the next pending ticket starts (no batch barrier; the
|
|
580
584
|
* queue is also re-read every `pollMs` while tickets run, so a new ticket fills a free slot).
|
|
581
585
|
* A ticket whose (block, file) is already running is left pending (it does not take a slot)
|
|
@@ -600,11 +604,14 @@ export async function createWorker(opts, deps = {}) {
|
|
|
600
604
|
}
|
|
601
605
|
/** @type {number | null} */
|
|
602
606
|
let limit = null;
|
|
607
|
+
let fairShare = false;
|
|
603
608
|
let done = 0;
|
|
604
609
|
/** @type {Map<string, Promise<void>>} */
|
|
605
610
|
const running = new Map();
|
|
606
611
|
/** @type {Set<string>} the (block, file) keys of the running tickets */
|
|
607
612
|
const busy = new Set();
|
|
613
|
+
/** @type {Map<string, number>} running tickets per block */
|
|
614
|
+
const runningByBlock = new Map();
|
|
608
615
|
/** @type {Set<string>} */
|
|
609
616
|
const started = new Set();
|
|
610
617
|
for (;;) {
|
|
@@ -630,25 +637,54 @@ export async function createWorker(opts, deps = {}) {
|
|
|
630
637
|
writeSafe(warnStream, `worker: WARN ${text}\n`);
|
|
631
638
|
try { await logWarning({ warning: 'watch_tick_failed', message: text }); } catch { /* a log failure never stops dispatch */ }
|
|
632
639
|
}
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
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;
|
|
639
672
|
started.add(id);
|
|
640
673
|
activeTickets.add(id);
|
|
641
674
|
if (fileKey !== null) busy.add(fileKey);
|
|
675
|
+
if (file) runningByBlock.set(file.block, (runningByBlock.get(file.block) ?? 0) + 1);
|
|
642
676
|
const slot = runOne(id)
|
|
643
677
|
.then((n) => {
|
|
644
678
|
done += n;
|
|
645
679
|
})
|
|
646
680
|
.finally(() => {
|
|
681
|
+
claim.release(); // the done marker is published before the claim is released
|
|
647
682
|
// the slot is freed on every exit path (`runOne` never rejects)
|
|
648
683
|
running.delete(id);
|
|
649
684
|
activeTickets.delete(id);
|
|
650
685
|
fileKeys.delete(id);
|
|
651
686
|
if (fileKey !== null) busy.delete(fileKey);
|
|
687
|
+
if (file) runningByBlock.set(file.block, runningByBlock.get(file.block) - 1);
|
|
652
688
|
});
|
|
653
689
|
running.set(id, slot);
|
|
654
690
|
}
|
|
@@ -684,7 +720,8 @@ export async function createWorker(opts, deps = {}) {
|
|
|
684
720
|
* must still see the worker as live the whole time.
|
|
685
721
|
*/
|
|
686
722
|
function announce() {
|
|
687
|
-
|
|
723
|
+
if (self !== null) return; // boot already announced under the lock
|
|
724
|
+
self = announceWorker(repoRoot, { pid: process.pid, run: runId });
|
|
688
725
|
stopBeating();
|
|
689
726
|
const timer = setInterval(() => {
|
|
690
727
|
try {
|
|
@@ -749,12 +786,16 @@ export async function createWorker(opts, deps = {}) {
|
|
|
749
786
|
/** Withdraw this process's announcement (`worker.json`) if it is still ours. */
|
|
750
787
|
function release() {
|
|
751
788
|
stopBeating();
|
|
752
|
-
retractWorker(repoRoot,
|
|
789
|
+
if (self !== null) retractWorker(repoRoot, self.pid, self.start_time);
|
|
790
|
+
self = null;
|
|
753
791
|
}
|
|
754
792
|
|
|
755
|
-
return { processTicket, drain, run, once, stop, release, watchTick: tickWatch, jevKeyResolved: jevKey !== null, sessionEnv: childEnv };
|
|
793
|
+
return { processTicket, drain, run, once, stop, release, announce, watchTick: tickWatch, jevKeyResolved: jevKey !== null, sessionEnv: childEnv };
|
|
756
794
|
}
|
|
757
795
|
|
|
796
|
+
/** This block permits one registered worker per queue. */
|
|
797
|
+
const WORKER_LIMIT = 1;
|
|
798
|
+
|
|
758
799
|
/**
|
|
759
800
|
* Start-up for the `worker` verb: run record, repo root, config, run temp root (owned by this
|
|
760
801
|
* process), run key; refuses when another live worker already serves this queue.
|
|
@@ -777,13 +818,22 @@ export async function bootWorker(opts, deps = {}) {
|
|
|
777
818
|
if (!loaded.ok) throw new WorkerError('config', loaded.message ?? 'the project config cannot be loaded');
|
|
778
819
|
cfg = loaded.config;
|
|
779
820
|
}
|
|
780
|
-
const other = liveWorker(repoRoot);
|
|
781
|
-
if (other && other.pid !== process.pid) throw new WorkerError('worker-running', `worker pid ${other.pid} already serves this queue`);
|
|
782
821
|
const tmpRoot = typeof cfg?.tmp?.root === 'string' ? cfg.tmp.root : undefined;
|
|
783
822
|
const runRootDir = runRoot(opts.runId, { root: tmpRoot });
|
|
784
823
|
setRunRoot(runRootDir);
|
|
785
824
|
const key = await loadKey(opts.runId);
|
|
786
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
|
+
});
|
|
787
837
|
return { ...worker, repoRoot, runRootDir };
|
|
788
838
|
}
|
|
789
839
|
|
|
@@ -826,7 +876,7 @@ export async function launchWorker({ runId, workspace, env = process.env, timeou
|
|
|
826
876
|
child.unref();
|
|
827
877
|
const deadline = Date.now() + timeoutMs;
|
|
828
878
|
while (Date.now() < deadline) {
|
|
829
|
-
if (typeof pid === 'number' && liveWorker(repoRoot
|
|
879
|
+
if (typeof pid === 'number' && liveWorker(repoRoot, { pid, registeredOnly: true })) return { pid, repoRoot };
|
|
830
880
|
if (child.exitCode !== null || child.signalCode !== null) break;
|
|
831
881
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
832
882
|
}
|