@codedology/code-forge 0.12.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 +41 -0
- package/README.md +17 -1
- package/docs/reference/blocks.json +202 -1
- package/docs/reference/config.md +2 -1
- package/package.json +1 -1
- package/schema/code-forge.schema.json +2 -1
- package/skill/references/code.md +12 -5
- package/skill/references/review.md +12 -5
- package/skill/scripts/forge +1 -1
- package/src/autopilot/actions.mjs +4 -5
- package/src/cli/block.mjs +27 -0
- package/src/cli/context.mjs +3 -2
- package/src/cli/review-file.mjs +22 -9
- 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/spend.mjs +3 -3
- package/src/proof/lock.mjs +5 -1
- package/src/review/gate-check.mjs +32 -7
- package/src/review/schema-fallback.mjs +3 -3
- package/src/util/file-locks.mjs +118 -0
- package/src/util/forbidden.mjs +29 -2
- package/src/worker/engine.mjs +2 -2
- package/src/worker/loop.mjs +83 -33
- package/src/worker/queue.mjs +123 -31
- package/src/worker/watch.mjs +4 -2
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/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
|
}
|
package/src/worker/queue.mjs
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* <repo>/.code-forge/queue/<ticket>.json the ticket (written by `review-file`, idempotent)
|
|
6
6
|
* <repo>/.code-forge/queue/<ticket>.done the worker's done marker `{ticket, status, result}`
|
|
7
7
|
* <repo>/.code-forge/queue/worker.json the worker's announcement `{pid, start_time, run, started_at, heartbeat_at}`
|
|
8
|
+
* <repo>/.code-forge/workers/<pid>-<start_time>.json each worker's private registry entry
|
|
8
9
|
* <repo>/.code-forge/reviews/<run>/<ticket>.json the SIGNED result (run key HMAC, §8.6)
|
|
9
10
|
*
|
|
10
11
|
* The forbidden list (§8.4 ★) denies a coder any write under `reviews/` and to `queue/*.done`;
|
|
@@ -28,7 +29,7 @@
|
|
|
28
29
|
*/
|
|
29
30
|
|
|
30
31
|
import { createHash, randomBytes } from 'node:crypto';
|
|
31
|
-
import { existsSync, linkSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
32
|
+
import { chmodSync, existsSync, linkSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
32
33
|
import path from 'node:path';
|
|
33
34
|
import { loadKey, signRow, verifyRow } from '../state/signer.mjs';
|
|
34
35
|
import { isAlive, readStartTime, UNKNOWN_START_TIME } from '../util/reaper.mjs';
|
|
@@ -44,15 +45,19 @@ export const HEARTBEAT_STALE_MS = 60_000;
|
|
|
44
45
|
|
|
45
46
|
/** @param {string} repoRoot */
|
|
46
47
|
export const queueDir = (repoRoot) => path.join(repoRoot, '.code-forge', 'queue');
|
|
48
|
+
/** @param {string} repoRoot */
|
|
49
|
+
const workersDir = (repoRoot) => path.join(repoRoot, '.code-forge', 'workers');
|
|
50
|
+
/** @param {string} repoRoot @param {{pid: number, start_time: string}} self */
|
|
51
|
+
const workerPath = (repoRoot, self) => path.join(workersDir(repoRoot), `${self.pid}-${self.start_time}.json`);
|
|
47
52
|
/** @param {string} repoRoot @param {string} runId */
|
|
48
53
|
export const reviewsDir = (repoRoot, runId) => path.join(repoRoot, '.code-forge', 'reviews', runId);
|
|
49
54
|
|
|
50
|
-
/** @param {string} file @param {string} text */
|
|
51
|
-
function writeAtomic(file, text) {
|
|
55
|
+
/** @param {string} file @param {string} text @param {number} [mode] */
|
|
56
|
+
function writeAtomic(file, text, mode = 0o644) {
|
|
52
57
|
mkdirSync(path.dirname(file), { recursive: true });
|
|
53
58
|
const tmp = `${file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
|
|
54
59
|
try {
|
|
55
|
-
writeFileSync(tmp, text, { mode
|
|
60
|
+
writeFileSync(tmp, text, { mode });
|
|
56
61
|
renameSync(tmp, file);
|
|
57
62
|
} catch (err) {
|
|
58
63
|
rmSync(tmp, { force: true });
|
|
@@ -427,6 +432,36 @@ export function blockTickets({ repoRoot, run, block, tree = null }) {
|
|
|
427
432
|
.sort((a, b) => a.enqueued_at.localeCompare(b.enqueued_at) || a.ticket.localeCompare(b.ticket));
|
|
428
433
|
}
|
|
429
434
|
|
|
435
|
+
/**
|
|
436
|
+
* Atomically record the join's findings handoff once. Read only this marker, never a result.
|
|
437
|
+
* @param {{repoRoot: string, block: string, file: string, ticket: string}} opts
|
|
438
|
+
* @returns {boolean} true only for the join that first delivered the findings.
|
|
439
|
+
*/
|
|
440
|
+
export function deliverBlockFindings({ repoRoot, block, file, ticket }) {
|
|
441
|
+
const marker = path.join(queueDir(repoRoot), `${assertTicketId(ticket)}.delivered`);
|
|
442
|
+
const expected = { block, file, ticket };
|
|
443
|
+
const delivered = () => {
|
|
444
|
+
try {
|
|
445
|
+
const seen = JSON.parse(readFileSync(marker, 'utf8'));
|
|
446
|
+
if (seen?.block === block && seen.file === file && seen.ticket === ticket) return true;
|
|
447
|
+
} catch (err) {
|
|
448
|
+
if (err.code === 'ENOENT') return false;
|
|
449
|
+
}
|
|
450
|
+
throw new WorkerError('result-unreadable', 'a findings delivery marker cannot be read');
|
|
451
|
+
};
|
|
452
|
+
if (delivered()) return false;
|
|
453
|
+
const tmp = `${marker}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
|
|
454
|
+
try {
|
|
455
|
+
writeFileSync(tmp, `${JSON.stringify(expected)}\n`, { flag: 'wx' });
|
|
456
|
+
try { linkSync(tmp, marker); } catch (err) {
|
|
457
|
+
if (err.code !== 'EEXIST') throw err;
|
|
458
|
+
if (delivered()) return false;
|
|
459
|
+
throw new WorkerError('result-unreadable', 'a findings delivery marker disappeared');
|
|
460
|
+
}
|
|
461
|
+
return true;
|
|
462
|
+
} finally { rmSync(tmp, { force: true }); }
|
|
463
|
+
}
|
|
464
|
+
|
|
430
465
|
/**
|
|
431
466
|
* Read and re-validate a ticket (coder-writable): its id must match its content.
|
|
432
467
|
* @param {string} repoRoot @param {string} id
|
|
@@ -468,7 +503,8 @@ export function writeResult({ repoRoot, runId, ticket, result, key }) {
|
|
|
468
503
|
const signed = signRow({ ...result, run: runId, ticket: assertTicketId(ticket) }, key);
|
|
469
504
|
const file = path.join(reviewsDir(repoRoot, runId), `${ticket}.json`);
|
|
470
505
|
writeAtomic(file, `${JSON.stringify(signed)}\n`);
|
|
471
|
-
|
|
506
|
+
const openFindings = signed.status === 'reviewed' && signed.approved !== true && Array.isArray(signed.findings) && signed.findings.length > 0;
|
|
507
|
+
writeAtomic(path.join(queueDir(repoRoot), `${ticket}.done`), `${JSON.stringify({ ticket, status: signed.status, ...(openFindings ? { open_findings: true } : {}), result: path.relative(repoRoot, file) })}\n`);
|
|
472
508
|
return signed;
|
|
473
509
|
}
|
|
474
510
|
|
|
@@ -497,20 +533,25 @@ export async function verifyResult(repoRoot, runId, ticket) {
|
|
|
497
533
|
}
|
|
498
534
|
|
|
499
535
|
/**
|
|
500
|
-
* Announce the serving worker in the
|
|
536
|
+
* Announce the serving worker in the private registry and legacy queue file.
|
|
501
537
|
* @param {string} repoRoot @param {{pid: number, run: string}} who
|
|
502
538
|
*/
|
|
503
539
|
export function announceWorker(repoRoot, { pid, run }) {
|
|
504
540
|
const now = new Date().toISOString();
|
|
505
541
|
const body = { pid, start_time: readStartTime(pid) ?? UNKNOWN_START_TIME, run, started_at: now, heartbeat_at: now };
|
|
506
|
-
|
|
542
|
+
mkdirSync(workersDir(repoRoot), { recursive: true, mode: 0o700 });
|
|
543
|
+
chmodSync(workersDir(repoRoot), 0o700);
|
|
544
|
+
writeAtomic(workerPath(repoRoot, body), `${JSON.stringify(body)}\n`, 0o600);
|
|
545
|
+
const legacy = readWorker(repoRoot);
|
|
546
|
+
if (!legacy || !workerLive(legacy)) writeAtomic(path.join(queueDir(repoRoot), WORKER_FILE), `${JSON.stringify(body)}\n`);
|
|
507
547
|
return body;
|
|
508
548
|
}
|
|
509
549
|
|
|
510
550
|
/**
|
|
511
|
-
* Refresh
|
|
551
|
+
* Refresh both announcements' `heartbeat_at` (B30) while each is still THIS worker's: each file is
|
|
512
552
|
* re-read right before the write and must name the same `pid` AND `start_time` the worker
|
|
513
|
-
* announced. Another worker's announcement is never overwritten
|
|
553
|
+
* announced. Another worker's announcement is never overwritten. A worker keeps beating its
|
|
554
|
+
* registry entry even when another worker owns worker.json.
|
|
514
555
|
* The beat is written to a temp file, the announcement re-read and compared once more, and only
|
|
515
556
|
* then renamed over it. The worker calls this from a timer of its own, so it keeps beating while
|
|
516
557
|
* a long review session is awaited.
|
|
@@ -520,23 +561,23 @@ export function announceWorker(repoRoot, { pid, run }) {
|
|
|
520
561
|
*/
|
|
521
562
|
export function beatWorker(repoRoot, self, now = new Date()) {
|
|
522
563
|
const mine = (/** @type {Record<string, any> | null} */ w) => w?.pid === self.pid && w.start_time === self.start_time;
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
const file
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
564
|
+
let written = false;
|
|
565
|
+
const registryFile = workerPath(repoRoot, self);
|
|
566
|
+
for (const file of [registryFile, path.join(queueDir(repoRoot), WORKER_FILE)]) {
|
|
567
|
+
const w = readJSON(file);
|
|
568
|
+
if (!mine(w)) continue;
|
|
569
|
+
const tmp = `${file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
|
|
570
|
+
try {
|
|
571
|
+
writeFileSync(tmp, `${JSON.stringify({ ...w, heartbeat_at: now.toISOString() })}\n`, { mode: file === registryFile ? 0o600 : 0o644 });
|
|
572
|
+
// Re-read before rename; another owner's announcement is never replaced.
|
|
573
|
+
if (!mine(readJSON(file))) continue;
|
|
574
|
+
renameSync(tmp, file);
|
|
575
|
+
written = true;
|
|
576
|
+
} finally {
|
|
531
577
|
rmSync(tmp, { force: true });
|
|
532
|
-
return false;
|
|
533
578
|
}
|
|
534
|
-
renameSync(tmp, file);
|
|
535
|
-
return true;
|
|
536
|
-
} catch (err) {
|
|
537
|
-
rmSync(tmp, { force: true });
|
|
538
|
-
throw err;
|
|
539
579
|
}
|
|
580
|
+
return written;
|
|
540
581
|
}
|
|
541
582
|
|
|
542
583
|
/** A heartbeat further than this in the future is not fresh (clock skew tolerance). */
|
|
@@ -560,9 +601,59 @@ export function readWorker(repoRoot) {
|
|
|
560
601
|
return w && Number.isInteger(w.pid) && w.pid > 1 && typeof w.run === 'string' ? w : null;
|
|
561
602
|
}
|
|
562
603
|
|
|
563
|
-
/** Remove
|
|
564
|
-
export function retractWorker(repoRoot, pid) {
|
|
565
|
-
|
|
604
|
+
/** Remove only this owner's announcements. @param {string} repoRoot @param {number} pid @param {string} [startTime] */
|
|
605
|
+
export function retractWorker(repoRoot, pid, startTime) {
|
|
606
|
+
const mine = (w) => w?.pid === pid && (startTime === undefined || w.start_time === startTime);
|
|
607
|
+
for (const entry of workerRegistry(repoRoot)) {
|
|
608
|
+
if (mine(entry.worker)) rmSync(entry.file, { force: true });
|
|
609
|
+
}
|
|
610
|
+
if (mine(readWorker(repoRoot))) rmSync(path.join(queueDir(repoRoot), WORKER_FILE), { force: true });
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/** @typedef {{pid: number, start_time: string, run: string, started_at: string, heartbeat_at: string}} WorkerAnnouncement */
|
|
614
|
+
|
|
615
|
+
/** Bad entries remain occupied until a human fixes them. @param {any} w @returns {w is WorkerAnnouncement} */
|
|
616
|
+
function workerShape(w) {
|
|
617
|
+
return w && Number.isInteger(w.pid) && w.pid > 1 && typeof w.start_time === 'string' && w.start_time.length > 0
|
|
618
|
+
&& !/[\\/]/.test(w.start_time) && typeof w.run === 'string' && w.run.length > 0
|
|
619
|
+
&& typeof w.started_at === 'string' && Number.isFinite(Date.parse(w.started_at))
|
|
620
|
+
&& typeof w.heartbeat_at === 'string' && Number.isFinite(Date.parse(w.heartbeat_at));
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/** @param {{pid: number, start_time: string, heartbeat_at?: string}} w @param {number} [nowMs] */
|
|
624
|
+
function workerLive(w, nowMs) {
|
|
625
|
+
if (!isAlive(w.pid)) return false;
|
|
626
|
+
if (heartbeatFresh(w, nowMs)) return true;
|
|
627
|
+
return w.start_time === UNKNOWN_START_TIME || readStartTime(w.pid) === w.start_time;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/** @param {{pid: number, start_time: string, heartbeat_at?: string}} w @param {number} [nowMs] */
|
|
631
|
+
function workerLiveAtBoot(w, nowMs) {
|
|
632
|
+
if (!isAlive(w.pid)) return false;
|
|
633
|
+
const startTime = readStartTime(w.pid);
|
|
634
|
+
// Boot trusts orchestrator ps; sandbox readers let fresh heartbeats mask unreliable ps.
|
|
635
|
+
if (startTime !== null && w.start_time !== UNKNOWN_START_TIME) return startTime === w.start_time;
|
|
636
|
+
return heartbeatFresh(w, nowMs) || w.start_time === UNKNOWN_START_TIME;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/** Read all entries, keeping unreadable/bad entries occupied. Only proven stale owners may be pruned.
|
|
640
|
+
* @param {string} repoRoot @param {{prune?: boolean, nowMs?: number}} [opts]
|
|
641
|
+
* @returns {Array<{file: string, worker: WorkerAnnouncement | null, live: boolean}>}
|
|
642
|
+
*/
|
|
643
|
+
export function workerRegistry(repoRoot, opts = {}) {
|
|
644
|
+
let names;
|
|
645
|
+
try { names = readdirSync(workersDir(repoRoot)); }
|
|
646
|
+
catch (err) { if (err.code === 'ENOENT') return []; throw err; }
|
|
647
|
+
const entries = [];
|
|
648
|
+
for (const name of names.sort().filter((n) => n.endsWith('.json'))) {
|
|
649
|
+
const file = path.join(workersDir(repoRoot), name);
|
|
650
|
+
const w = readJSON(file);
|
|
651
|
+
const worker = workerShape(w) && name === `${w.pid}-${w.start_time}.json` ? w : null;
|
|
652
|
+
const live = worker === null || (opts.prune ? workerLiveAtBoot(worker, opts.nowMs) : workerLive(worker, opts.nowMs));
|
|
653
|
+
if (!live && opts.prune) rmSync(file, { force: true });
|
|
654
|
+
else entries.push({ file, worker, live });
|
|
655
|
+
}
|
|
656
|
+
return entries;
|
|
566
657
|
}
|
|
567
658
|
|
|
568
659
|
/**
|
|
@@ -574,13 +665,14 @@ export function retractWorker(repoRoot, pid) {
|
|
|
574
665
|
* Accepted trade-off: a worker that is SIGKILLed (no retraction) leaves a heartbeat that stays
|
|
575
666
|
* fresh for up to `HEARTBEAT_STALE_MS` (60 s); if the OS reuses its pid inside that window, the
|
|
576
667
|
* queue reads as served until the heartbeat goes stale. A clean stop retracts the file at once.
|
|
577
|
-
*
|
|
668
|
+
* Registry entries take precedence; worker.json supports workers from older versions.
|
|
669
|
+
* @param {string} repoRoot @param {{nowMs?: number, pid?: number, registeredOnly?: boolean}} [opts]
|
|
578
670
|
*/
|
|
579
671
|
export function liveWorker(repoRoot, opts = {}) {
|
|
672
|
+
const entry = workerRegistry(repoRoot, opts).find((e) => e.live && e.worker && (opts.pid === undefined || e.worker.pid === opts.pid));
|
|
673
|
+
if (entry) return entry.worker;
|
|
674
|
+
if (opts.registeredOnly) return null;
|
|
580
675
|
const w = readWorker(repoRoot);
|
|
581
|
-
|
|
582
|
-
if (heartbeatFresh(w, opts.nowMs)) return w;
|
|
583
|
-
if (w.start_time !== UNKNOWN_START_TIME && readStartTime(w.pid) !== w.start_time) return null;
|
|
584
|
-
return w;
|
|
676
|
+
return w && (opts.pid === undefined || w.pid === opts.pid) && workerLive(w, opts.nowMs) ? w : null;
|
|
585
677
|
}
|
|
586
678
|
|
package/src/worker/watch.mjs
CHANGED
|
@@ -99,10 +99,12 @@ export async function watchTick({ repoRoot, runId, cfg, readRun: loadRun = readR
|
|
|
99
99
|
if (unreadable) { await skip('unreadable_head'); continue; }
|
|
100
100
|
await hooks.beforeQueue?.();
|
|
101
101
|
for (const { file, content_hash } of hashes) {
|
|
102
|
-
const
|
|
102
|
+
const held = earlier.some((old) => old.file === file);
|
|
103
|
+
if (!held) enqueueHash({ repoRoot, run: runId, block, file, content_hash, tree });
|
|
103
104
|
for (const old of earlier) {
|
|
104
|
-
if (old.file === file && old.
|
|
105
|
+
if (old.file === file && old.content_hash !== content_hash && !running.has(old.ticket) && !isDone(repoRoot, old.ticket)) supersedeTicket(repoRoot, old.ticket);
|
|
105
106
|
}
|
|
107
|
+
if (held) await writeRow({ event: 'watch.held', block, file, content_hash });
|
|
106
108
|
}
|
|
107
109
|
save({ ...state, run: runId, base, tree, sha: head, skipped: [] });
|
|
108
110
|
} catch {
|