@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/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 });
|
|
@@ -102,17 +107,56 @@ export function resolveTicket({ repoRoot, run, block, file, tree = null }) {
|
|
|
102
107
|
* @throws {WorkerError} `bad-path` for a relative `tree` or one that cannot be resolved.
|
|
103
108
|
*/
|
|
104
109
|
export function enqueue({ repoRoot, run, block, file, tree = null, now = new Date() }) {
|
|
105
|
-
const {
|
|
110
|
+
const { content_hash } = resolveTicket({ repoRoot, run, block, file, tree });
|
|
111
|
+
return enqueueHash({ repoRoot, run, block, file, tree, content_hash, now });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Queue proven git-object content through the same identity as a manual review.
|
|
115
|
+
* @param {{repoRoot: string, run: string, block: string, file: string, content_hash: string, tree?: string | null, now?: Date}} opts
|
|
116
|
+
* @returns {{ticket: string, status: 'queued' | 'done', file: string, content_hash: string, tree?: string}}
|
|
117
|
+
*/
|
|
118
|
+
export function enqueueHash({ repoRoot, run, block, file, content_hash, tree = null, now = new Date() }) {
|
|
119
|
+
const rel = assertRowPath(file);
|
|
120
|
+
const root = tree === null ? null : realTree(tree);
|
|
121
|
+
const ticket = ticketId({ run, block, file: rel, content_hash, tree: root });
|
|
106
122
|
const target = path.join(queueDir(repoRoot), `${ticket}.json`);
|
|
107
123
|
const named = root === null ? {} : { tree: root };
|
|
108
124
|
if (!existsSync(target)) {
|
|
109
125
|
/** @type {Ticket} */
|
|
110
126
|
const body = { ticket, run, block, file: rel, content_hash, enqueued_at: now.toISOString(), ...named };
|
|
111
|
-
|
|
127
|
+
// Publish a complete immutable ticket without replacing a concurrent coder's ticket.
|
|
128
|
+
mkdirSync(path.dirname(target), { recursive: true });
|
|
129
|
+
const tmp = `${target}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
|
|
130
|
+
try {
|
|
131
|
+
writeFileSync(tmp, `${JSON.stringify(body)}\n`, { mode: 0o644 });
|
|
132
|
+
try { linkSync(tmp, target); } catch (err) { if (err.code !== 'EEXIST') throw err; }
|
|
133
|
+
} finally { rmSync(tmp, { force: true }); }
|
|
112
134
|
}
|
|
135
|
+
// Returning content is current again. Claim only a superseded marker's exact bytes;
|
|
136
|
+
// a real result that replaced it wins the comparison and is restored unchanged.
|
|
137
|
+
let seen;
|
|
138
|
+
let done;
|
|
139
|
+
try {
|
|
140
|
+
seen = readFileSync(path.join(queueDir(repoRoot), `${ticket}.done`));
|
|
141
|
+
done = JSON.parse(seen.toString('utf8'));
|
|
142
|
+
} catch { /* missing or unreadable markers remain untouched */ }
|
|
143
|
+
if (seen && done?.status === 'refused' && done.reason === 'superseded' && !done.result) dropDoneIf(repoRoot, ticket, seen);
|
|
113
144
|
return { ticket, status: isDone(repoRoot, ticket) ? 'done' : 'queued', file: rel, content_hash, ...named };
|
|
114
145
|
}
|
|
115
146
|
|
|
147
|
+
/** Finish only an unstarted, pending review. A superseded marker has no review result.
|
|
148
|
+
* @param {string} repoRoot @param {string} ticket
|
|
149
|
+
* @param {{write?: (file: string, text: string) => void, link?: (from: string, to: string) => void}} [io] - publication seams for tests.
|
|
150
|
+
*/
|
|
151
|
+
export function supersedeTicket(repoRoot, ticket, io = {}) {
|
|
152
|
+
const marker = path.join(queueDir(repoRoot), `${assertTicketId(ticket)}.done`);
|
|
153
|
+
const tmp = `${marker}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
|
|
154
|
+
try {
|
|
155
|
+
(io.write ?? ((file, text) => writeFileSync(file, text, { mode: 0o644 })))(tmp, `${JSON.stringify({ ticket, status: 'refused', reason: 'superseded' })}\n`);
|
|
156
|
+
try { (io.link ?? linkSync)(tmp, marker); } catch (err) { if (err.code !== 'EEXIST') throw err; }
|
|
157
|
+
} finally { rmSync(tmp, { force: true }); }
|
|
158
|
+
}
|
|
159
|
+
|
|
116
160
|
/** B71: the most finding ids one answers ticket may name. */
|
|
117
161
|
export const MAX_ANSWERS = 10;
|
|
118
162
|
|
|
@@ -369,6 +413,55 @@ export function pendingTickets(repoRoot) {
|
|
|
369
413
|
return pending.map((id) => ({ id, at: at(id) })).sort((a, b) => (a.at === b.at ? (a.id < b.id ? -1 : 1) : a.at < b.at ? -1 : 1)).map((e) => e.id);
|
|
370
414
|
}
|
|
371
415
|
|
|
416
|
+
/**
|
|
417
|
+
* Valid review tickets for one block and content tree, oldest first. Answers tickets cannot
|
|
418
|
+
* stand in for a file's own review. Unreadable tickets are refused, never silently omitted.
|
|
419
|
+
* @param {{repoRoot: string, run: string, block: string, tree?: string | null}} opts
|
|
420
|
+
* @returns {Ticket[]}
|
|
421
|
+
*/
|
|
422
|
+
export function blockTickets({ repoRoot, run, block, tree = null }) {
|
|
423
|
+
let names;
|
|
424
|
+
try {
|
|
425
|
+
names = readdirSync(queueDir(repoRoot));
|
|
426
|
+
} catch (err) {
|
|
427
|
+
if (err.code === 'ENOENT') return [];
|
|
428
|
+
throw new WorkerError('queue-unreadable', 'the review queue cannot be read');
|
|
429
|
+
}
|
|
430
|
+
const tickets = names.filter((n) => /^[0-9a-f]{24}\.json$/.test(n)).map((n) => readTicket(repoRoot, n.slice(0, 24)));
|
|
431
|
+
return tickets.filter((t) => !t.kind && t.run === run && t.block === block && (t.tree ?? null) === tree)
|
|
432
|
+
.sort((a, b) => a.enqueued_at.localeCompare(b.enqueued_at) || a.ticket.localeCompare(b.ticket));
|
|
433
|
+
}
|
|
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
|
+
|
|
372
465
|
/**
|
|
373
466
|
* Read and re-validate a ticket (coder-writable): its id must match its content.
|
|
374
467
|
* @param {string} repoRoot @param {string} id
|
|
@@ -410,7 +503,8 @@ export function writeResult({ repoRoot, runId, ticket, result, key }) {
|
|
|
410
503
|
const signed = signRow({ ...result, run: runId, ticket: assertTicketId(ticket) }, key);
|
|
411
504
|
const file = path.join(reviewsDir(repoRoot, runId), `${ticket}.json`);
|
|
412
505
|
writeAtomic(file, `${JSON.stringify(signed)}\n`);
|
|
413
|
-
|
|
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`);
|
|
414
508
|
return signed;
|
|
415
509
|
}
|
|
416
510
|
|
|
@@ -439,20 +533,25 @@ export async function verifyResult(repoRoot, runId, ticket) {
|
|
|
439
533
|
}
|
|
440
534
|
|
|
441
535
|
/**
|
|
442
|
-
* Announce the serving worker in the
|
|
536
|
+
* Announce the serving worker in the private registry and legacy queue file.
|
|
443
537
|
* @param {string} repoRoot @param {{pid: number, run: string}} who
|
|
444
538
|
*/
|
|
445
539
|
export function announceWorker(repoRoot, { pid, run }) {
|
|
446
540
|
const now = new Date().toISOString();
|
|
447
541
|
const body = { pid, start_time: readStartTime(pid) ?? UNKNOWN_START_TIME, run, started_at: now, heartbeat_at: now };
|
|
448
|
-
|
|
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`);
|
|
449
547
|
return body;
|
|
450
548
|
}
|
|
451
549
|
|
|
452
550
|
/**
|
|
453
|
-
* Refresh
|
|
551
|
+
* Refresh both announcements' `heartbeat_at` (B30) while each is still THIS worker's: each file is
|
|
454
552
|
* re-read right before the write and must name the same `pid` AND `start_time` the worker
|
|
455
|
-
* 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.
|
|
456
555
|
* The beat is written to a temp file, the announcement re-read and compared once more, and only
|
|
457
556
|
* then renamed over it. The worker calls this from a timer of its own, so it keeps beating while
|
|
458
557
|
* a long review session is awaited.
|
|
@@ -462,23 +561,23 @@ export function announceWorker(repoRoot, { pid, run }) {
|
|
|
462
561
|
*/
|
|
463
562
|
export function beatWorker(repoRoot, self, now = new Date()) {
|
|
464
563
|
const mine = (/** @type {Record<string, any> | null} */ w) => w?.pid === self.pid && w.start_time === self.start_time;
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
const file
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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 {
|
|
473
577
|
rmSync(tmp, { force: true });
|
|
474
|
-
return false;
|
|
475
578
|
}
|
|
476
|
-
renameSync(tmp, file);
|
|
477
|
-
return true;
|
|
478
|
-
} catch (err) {
|
|
479
|
-
rmSync(tmp, { force: true });
|
|
480
|
-
throw err;
|
|
481
579
|
}
|
|
580
|
+
return written;
|
|
482
581
|
}
|
|
483
582
|
|
|
484
583
|
/** A heartbeat further than this in the future is not fresh (clock skew tolerance). */
|
|
@@ -502,9 +601,59 @@ export function readWorker(repoRoot) {
|
|
|
502
601
|
return w && Number.isInteger(w.pid) && w.pid > 1 && typeof w.run === 'string' ? w : null;
|
|
503
602
|
}
|
|
504
603
|
|
|
505
|
-
/** Remove
|
|
506
|
-
export function retractWorker(repoRoot, pid) {
|
|
507
|
-
|
|
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;
|
|
508
657
|
}
|
|
509
658
|
|
|
510
659
|
/**
|
|
@@ -516,13 +665,14 @@ export function retractWorker(repoRoot, pid) {
|
|
|
516
665
|
* Accepted trade-off: a worker that is SIGKILLed (no retraction) leaves a heartbeat that stays
|
|
517
666
|
* fresh for up to `HEARTBEAT_STALE_MS` (60 s); if the OS reuses its pid inside that window, the
|
|
518
667
|
* queue reads as served until the heartbeat goes stale. A clean stop retracts the file at once.
|
|
519
|
-
*
|
|
668
|
+
* Registry entries take precedence; worker.json supports workers from older versions.
|
|
669
|
+
* @param {string} repoRoot @param {{nowMs?: number, pid?: number, registeredOnly?: boolean}} [opts]
|
|
520
670
|
*/
|
|
521
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;
|
|
522
675
|
const w = readWorker(repoRoot);
|
|
523
|
-
|
|
524
|
-
if (heartbeatFresh(w, opts.nowMs)) return w;
|
|
525
|
-
if (w.start_time !== UNKNOWN_START_TIME && readStartTime(w.pid) !== w.start_time) return null;
|
|
526
|
-
return w;
|
|
676
|
+
return w && (opts.pid === undefined || w.pid === opts.pid) && workerLive(w, opts.nowMs) ? w : null;
|
|
527
677
|
}
|
|
528
678
|
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/** Opt-in commit watch. Read immutable git objects, never the coder's working bytes. */
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { reviewFileLoop } from '../config/validate.mjs';
|
|
6
|
+
import { snapshotFor } from '../state/config-snapshot.mjs';
|
|
7
|
+
import { readRun } from '../state/run.mjs';
|
|
8
|
+
import { ownsAt } from '../review/gate-check.mjs';
|
|
9
|
+
import { exec } from '../util/exec.mjs';
|
|
10
|
+
import { blockTickets, enqueueHash, isDone, queueDir, supersedeTicket } from './queue.mjs';
|
|
11
|
+
import { assertRowPath, DELETED_HASH, gitChildEnv, hashBytes } from './ticket.mjs';
|
|
12
|
+
|
|
13
|
+
const SHA = /^[0-9a-f]{40,64}$/;
|
|
14
|
+
const BLOCK = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* One scheduler tick, also exposed directly for deterministic race tests.
|
|
18
|
+
* The boot config gates ALL watch work; absent/wait/overlap never reads the run or spawns git.
|
|
19
|
+
* @param {{repoRoot: string, runId: string, cfg: Record<string, any>, readRun?: typeof readRun,
|
|
20
|
+
* writeRow: (row: Record<string, any>) => Promise<unknown>, exec?: typeof exec,
|
|
21
|
+
* running?: ReadonlySet<string>, hooks?: {afterHead?: (head: string) => Promise<unknown>, beforeQueue?: () => Promise<unknown>}}} opts
|
|
22
|
+
*/
|
|
23
|
+
export async function watchTick({ repoRoot, runId, cfg, readRun: loadRun = readRun, writeRow, exec: execute = exec, running = new Set(), hooks = {} }) {
|
|
24
|
+
if (reviewFileLoop(cfg) !== 'watch') return;
|
|
25
|
+
const record = await loadRun(runId);
|
|
26
|
+
if (record.status !== 'active' || reviewFileLoop(snapshotFor(record, '')?.config ?? cfg) !== 'watch') return;
|
|
27
|
+
const env = gitChildEnv();
|
|
28
|
+
for (const [block, entry] of Object.entries(record.blocks ?? {})) {
|
|
29
|
+
if (entry.status !== 'open' || !BLOCK.test(block)) continue;
|
|
30
|
+
const stateFile = path.join(queueDir(repoRoot), 'watch', `${block}.json`);
|
|
31
|
+
let tree = null;
|
|
32
|
+
let root = repoRoot;
|
|
33
|
+
let state = {};
|
|
34
|
+
try { state = JSON.parse(readFileSync(stateFile, 'utf8')) ?? {}; } catch { /* restart at base */ }
|
|
35
|
+
const base = entry.base_sha;
|
|
36
|
+
/** @param {Record<string, any>} next */
|
|
37
|
+
const save = (next) => {
|
|
38
|
+
mkdirSync(path.dirname(stateFile), { recursive: true });
|
|
39
|
+
const tmp = `${stateFile}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
|
|
40
|
+
try {
|
|
41
|
+
writeFileSync(tmp, `${JSON.stringify(next)}\n`, { mode: 0o600 });
|
|
42
|
+
renameSync(tmp, stateFile);
|
|
43
|
+
state = next;
|
|
44
|
+
} finally { rmSync(tmp, { force: true }); }
|
|
45
|
+
};
|
|
46
|
+
/** @param {string} reason */
|
|
47
|
+
const skip = async (reason) => {
|
|
48
|
+
const skipped = Array.isArray(state.skipped) ? state.skipped : [];
|
|
49
|
+
if (!skipped.includes(reason)) {
|
|
50
|
+
await writeRow({ event: 'watch.skipped', block, reason });
|
|
51
|
+
save({ ...state, skipped: [...skipped, reason] });
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
try {
|
|
55
|
+
if (entry.worktree) {
|
|
56
|
+
root = realpathSync(entry.worktree.path);
|
|
57
|
+
tree = root;
|
|
58
|
+
}
|
|
59
|
+
if (state.run !== runId || state.base !== base || state.tree !== tree) state = { run: runId, base, tree, skipped: [] };
|
|
60
|
+
/** @param {string[]} argv */
|
|
61
|
+
const git = (argv) => execute(['git', ...argv], { cwd: root, env, timeoutMs: 10000 });
|
|
62
|
+
const headResult = await git(['rev-parse', '--verify', 'HEAD^{commit}']);
|
|
63
|
+
const head = headResult.stdout.trim();
|
|
64
|
+
if (headResult.result !== 'ok' || !SHA.test(head)) { await skip('unreadable_head'); continue; }
|
|
65
|
+
const earlier = blockTickets({ repoRoot, run: runId, block, tree });
|
|
66
|
+
await hooks.afterHead?.(head);
|
|
67
|
+
if (typeof base !== 'string' || !SHA.test(base) || (await git(['merge-base', '--is-ancestor', base, head])).result !== 'ok') {
|
|
68
|
+
await skip('not_descendant'); continue;
|
|
69
|
+
}
|
|
70
|
+
const last = typeof state.sha === 'string' && SHA.test(state.sha) ? state.sha : base;
|
|
71
|
+
if (last !== base && (await git(['merge-base', '--is-ancestor', last, head])).result !== 'ok') {
|
|
72
|
+
await skip('rewound');
|
|
73
|
+
const { sha: _sha, ...restart } = state;
|
|
74
|
+
save(restart);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
// A healthy tick ends the skip streak: no new commit here, or a queued commit below. An
|
|
78
|
+
// unreadable commit keeps the streak, so it writes one watch.skipped row, not one per tick.
|
|
79
|
+
if (last === head) { if (state.skipped?.length) save({ ...state, skipped: [] }); continue; }
|
|
80
|
+
const diff = await git(['diff', '--name-only', '-z', '--no-renames', last, head, '--']);
|
|
81
|
+
if (diff.result !== 'ok') { await skip('unreadable_head'); continue; }
|
|
82
|
+
const owns = ownsAt(tree ? '' : path.relative(repoRoot, record.workspace).split(path.sep).join('/'));
|
|
83
|
+
const files = diff.stdout.split('\0').filter(Boolean);
|
|
84
|
+
const owned = files.filter((file) => owns(entry.owned_files, file, entry.owned_kinds));
|
|
85
|
+
if (owned.length !== files.length) await skip('unowned_path');
|
|
86
|
+
// Stage every hash before publishing anything: an unreadable object queues nothing.
|
|
87
|
+
const hashes = [];
|
|
88
|
+
let unreadable = false;
|
|
89
|
+
for (const raw of owned) {
|
|
90
|
+
const file = assertRowPath(raw);
|
|
91
|
+
const listed = await git(['ls-tree', '-z', head, '--', file]);
|
|
92
|
+
if (listed.result !== 'ok') { unreadable = true; break; }
|
|
93
|
+
if (!listed.stdout) { hashes.push({ file, content_hash: DELETED_HASH }); continue; }
|
|
94
|
+
if (!/^100(?:644|755) blob /.test(listed.stdout)) { unreadable = true; break; }
|
|
95
|
+
const shown = await execute(['git', 'show', `${head}:${file}`], { cwd: root, env, timeoutMs: 10000, stdoutEncoding: 'base64' });
|
|
96
|
+
if (shown.result !== 'ok') { unreadable = true; break; }
|
|
97
|
+
hashes.push({ file, content_hash: hashBytes(Buffer.from(shown.stdout, 'base64')) });
|
|
98
|
+
}
|
|
99
|
+
if (unreadable) { await skip('unreadable_head'); continue; }
|
|
100
|
+
await hooks.beforeQueue?.();
|
|
101
|
+
for (const { file, content_hash } of hashes) {
|
|
102
|
+
const held = earlier.some((old) => old.file === file);
|
|
103
|
+
if (!held) enqueueHash({ repoRoot, run: runId, block, file, content_hash, tree });
|
|
104
|
+
for (const old of earlier) {
|
|
105
|
+
if (old.file === file && old.content_hash !== content_hash && !running.has(old.ticket) && !isDone(repoRoot, old.ticket)) supersedeTicket(repoRoot, old.ticket);
|
|
106
|
+
}
|
|
107
|
+
if (held) await writeRow({ event: 'watch.held', block, file, content_hash });
|
|
108
|
+
}
|
|
109
|
+
save({ ...state, run: runId, base, tree, sha: head, skipped: [] });
|
|
110
|
+
} catch {
|
|
111
|
+
// A bad path, missing tree or failed git read is a skip, never a block failure.
|
|
112
|
+
await skip('unreadable_head');
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|