@codedology/code-forge 0.10.0 → 0.12.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 +74 -0
- package/README.md +34 -3
- package/docs/reference/blocks.json +360 -1
- package/docs/reference/config.md +6 -5
- package/package.json +2 -2
- package/schema/code-forge.schema.json +3 -2
- package/skill/SKILL.md +3 -1
- package/skill/references/code.md +17 -8
- package/skill/references/proof.md +8 -2
- package/skill/references/review.md +32 -5
- package/skill/references/rules-core.md +1 -1
- package/skill/scripts/forge +1 -1
- package/skill/templates/coder-brief.md +17 -7
- package/src/cli/block.mjs +112 -7
- package/src/cli/gates.mjs +15 -6
- package/src/cli/review-file.mjs +118 -14
- package/src/config/validate.mjs +12 -5
- package/src/context/pack.mjs +7 -4
- package/src/gates/expand.mjs +102 -9
- package/src/gates/run.mjs +6 -4
- package/src/install/wizard/flags.mjs +1 -1
- package/src/proof/lock.mjs +23 -10
- package/src/proof/rule.mjs +9 -0
- package/src/proof/tiers.mjs +2 -14
- package/src/review/engine.mjs +59 -12
- package/src/review/fixloop.mjs +41 -16
- package/src/review/gate-check.mjs +2 -2
- package/src/review/lenses/A.md +5 -0
- package/src/review/lenses/B.md +5 -0
- package/src/review/lenses/full.md +5 -0
- package/src/review/lenses/judge.md +4 -0
- package/src/review/lenses/quick.md +4 -0
- package/src/review/lenses/recheck.md +5 -0
- package/src/review/packet.mjs +48 -14
- package/src/review/paired.mjs +81 -0
- package/src/review/reopen.mjs +18 -6
- package/src/review/reviewed-lines.mjs +94 -0
- package/src/state/block-end.mjs +10 -6
- package/src/util/exec.mjs +2 -1
- package/src/util/gate-token.mjs +3 -0
- package/src/worker/engine.mjs +43 -15
- package/src/worker/loop.mjs +35 -2
- package/src/worker/queue.mjs +60 -2
- package/src/worker/watch.mjs +113 -0
package/src/worker/engine.mjs
CHANGED
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
* second `schema` miss — this round's first try after the earlier ticket's — is tried once on
|
|
43
43
|
* `review.second_levels.<level>` when one is configured.
|
|
44
44
|
* B56: every round gets ONE memoised reader of the block's OTHER changed files' diffs
|
|
45
|
-
* (`
|
|
45
|
+
* (the ticket's `fileContext`: listed once, diffs read lazily at most once per ticket) so the review can name code moved
|
|
46
46
|
* between them in a `## moved code` packet section (round 1, rechecks and patch check alike).
|
|
47
47
|
* The review NEVER approves an empty or failed session: that is `unavailable` (§4.2 stub guard).
|
|
48
48
|
* A throw inside the round is `unavailable` too, with the state saved as `next: retry` (see
|
|
@@ -169,14 +169,14 @@ import { assertTicketId, contentHash, readRegularFileNoFollow, WorkerError } fro
|
|
|
169
169
|
* `tree` (B62b) is the recorded `worktree` `{path, branch, base_sha}`, null when the block has
|
|
170
170
|
* none; `base` is then the tree's `base_sha`. A recorded worktree that `worktreeRecord` refuses
|
|
171
171
|
* (path, branch, hex base_sha) throws `worktree-malformed`.
|
|
172
|
-
* @returns {Promise<{base: string | null, level: string, owned: string[], ownedKinds: Record<string, string>, acceptance?: unknown, kind: string | null, tree: WorktreeEntry | null, workspace: string | null}>}
|
|
172
|
+
* @returns {Promise<{base: string | null, level: string, owned: string[], ownedKinds: Record<string, string>, testFiles: string[], acceptance?: unknown, kind: string | null, tree: WorktreeEntry | null, workspace: string | null}>}
|
|
173
173
|
*/
|
|
174
174
|
export async function blockEntryFor(runId, block) {
|
|
175
175
|
let record;
|
|
176
176
|
try {
|
|
177
177
|
record = await readRun(runId);
|
|
178
178
|
} catch (err) {
|
|
179
|
-
if (/** @type {any} */ (err)?.code === 'no-run') return { base: null, level: 'L2', owned: [], ownedKinds: {}, acceptance: undefined, kind: null, tree: null, workspace: null };
|
|
179
|
+
if (/** @type {any} */ (err)?.code === 'no-run') return { base: null, level: 'L2', owned: [], ownedKinds: {}, testFiles: [], acceptance: undefined, kind: null, tree: null, workspace: null };
|
|
180
180
|
throw new Error('run-record-unreadable');
|
|
181
181
|
}
|
|
182
182
|
if (!record || typeof record !== 'object' || !record.blocks || typeof record.blocks !== 'object') throw new Error('run-record-unreadable');
|
|
@@ -187,6 +187,7 @@ export async function blockEntryFor(runId, block) {
|
|
|
187
187
|
const owned = Array.isArray(entry.owned_files) ? entry.owned_files.filter((f) => typeof f === 'string') : [];
|
|
188
188
|
const kinds = entry.owned_kinds;
|
|
189
189
|
const ownedKinds = kinds && typeof kinds === 'object' && !Array.isArray(kinds) ? kinds : {};
|
|
190
|
+
const testFiles = Array.isArray(entry.test_files) ? entry.test_files.filter((f) => typeof f === 'string' && !/[*?\[\]{}]/.test(f) && ownsFile(owned, f, ownedKinds)) : [];
|
|
190
191
|
/** @type {WorktreeEntry | null} */
|
|
191
192
|
let tree = null;
|
|
192
193
|
if (entry.worktree !== undefined && entry.worktree !== null) {
|
|
@@ -196,10 +197,11 @@ export async function blockEntryFor(runId, block) {
|
|
|
196
197
|
throw new Error('worktree-malformed');
|
|
197
198
|
}
|
|
198
199
|
}
|
|
199
|
-
return { base: tree ? tree.base_sha : sha, level: typeof entry.level === 'string' && /^L[0-3]$/.test(entry.level) ? entry.level : 'L2', owned, ownedKinds, acceptance: entry.acceptance, kind: blockKind({ owned, declared: entry.kind }), tree, workspace: typeof record.workspace === 'string' ? record.workspace : null };
|
|
200
|
+
return { base: tree ? tree.base_sha : sha, level: typeof entry.level === 'string' && /^L[0-3]$/.test(entry.level) ? entry.level : 'L2', owned, ownedKinds, testFiles, acceptance: entry.acceptance, kind: blockKind({ owned, declared: entry.kind }), tree, workspace: typeof record.workspace === 'string' ? record.workspace : null };
|
|
200
201
|
}
|
|
201
202
|
|
|
202
203
|
/** @typedef {{path: string, branch: string, base_sha: string}} WorktreeEntry */
|
|
204
|
+
/** @typedef {{peers: string[], changedFiles: string[], ownedFiles: string[], testFiles: string[]}} FileContext */
|
|
203
205
|
|
|
204
206
|
/** The refusals of {@link blockTree} a ticket answers with, as its `reason`. */
|
|
205
207
|
const TREE_CODES = new Set(['worktree-malformed', 'worktree-missing', 'worktree-not-dir', 'worktree-branch-mismatch', 'worktree-workspace-missing', 'worktree-foreign', 'git-failed']);
|
|
@@ -373,7 +375,28 @@ export async function reviewTicket(ticket, ctx) {
|
|
|
373
375
|
}
|
|
374
376
|
}
|
|
375
377
|
const factsExcerpt = factsFor(entry, ctx.cfg);
|
|
376
|
-
|
|
378
|
+
// Resolve concrete paths in this block's tree; neither globs nor another block's tests
|
|
379
|
+
// authorize an untracked attachment. Read lazily so a stopped ticket needs no file listing.
|
|
380
|
+
/** @type {Promise<FileContext> | undefined} */
|
|
381
|
+
let contextOnce;
|
|
382
|
+
/** @type {() => Promise<import('../review/moved.mjs').PeerDiff[]>} */
|
|
383
|
+
let readDiffs = () => Promise.resolve([]);
|
|
384
|
+
const fileContext = () => contextOnce ??= (async () => {
|
|
385
|
+
try {
|
|
386
|
+
// Read the listing directly: blockPeers' [] fallback would hide a git failure and
|
|
387
|
+
// still authorize explicit tests. Context and peer diffs must fail soft together.
|
|
388
|
+
const peers = entry.base === null || entry.owned.length === 0 ? [] : (await computeFileSet({ cwd: contentRoot, base: entry.base })).all.filter((f) => f !== ticket.file && ownsFile(entry.owned, f, entry.ownedKinds));
|
|
389
|
+
readDiffs = peerDiffReader({ repoRoot: contentRoot, base: entry.base, file: ticket.file, peers });
|
|
390
|
+
const changedFiles = [...peers, ticket.file].filter((f) => ownsFile(entry.owned, f, entry.ownedKinds));
|
|
391
|
+
return { peers, changedFiles, ownedFiles: [...new Set([...changedFiles, ...entry.testFiles])], testFiles: entry.testFiles };
|
|
392
|
+
} catch {
|
|
393
|
+
return { peers: [], changedFiles: [], ownedFiles: [], testFiles: [] };
|
|
394
|
+
}
|
|
395
|
+
})();
|
|
396
|
+
const peerDiffs = async () => {
|
|
397
|
+
await fileContext();
|
|
398
|
+
return readDiffs();
|
|
399
|
+
};
|
|
377
400
|
try {
|
|
378
401
|
// B63a: the `context` rules and lessons, read from the MAIN tree (`ctx.repoRoot`, never the
|
|
379
402
|
// block's worktree) with the config in force for this ticket; '' without a `context` key, so
|
|
@@ -390,11 +413,11 @@ export async function reviewTicket(ticket, ctx) {
|
|
|
390
413
|
const digest = rulesDigest === '' ? {} : { rulesDigest };
|
|
391
414
|
if (!ctx.key) {
|
|
392
415
|
return await reviewFile(
|
|
393
|
-
{ repoRoot: contentRoot, file: ticket.file, base: entry.base, cfg: ctx.cfg, kind: entry.kind, workDir, factsExcerpt, ...digest, peerDiffs },
|
|
416
|
+
{ repoRoot: contentRoot, file: ticket.file, base: entry.base, cfg: ctx.cfg, kind: entry.kind, workDir, factsExcerpt, ...digest, peerDiffs, ...(await fileContext()) },
|
|
394
417
|
{ spawn: ctx.spawn, ...(ctx.writeRow ? { writeRow: ctx.writeRow } : {}) },
|
|
395
418
|
);
|
|
396
419
|
}
|
|
397
|
-
return await fixLoopRound(ticket, tctx, { base: entry.base, level: entry.level, owned: entry.owned, ownedKinds: entry.ownedKinds, blockKindOf: entry.kind, key: ctx.key, workDir, factsExcerpt, rulesDigest, peerDiffs });
|
|
420
|
+
return await fixLoopRound(ticket, tctx, { base: entry.base, level: entry.level, owned: entry.owned, ownedKinds: entry.ownedKinds, blockKindOf: entry.kind, key: ctx.key, workDir, factsExcerpt, rulesDigest, peerDiffs, fileContext });
|
|
398
421
|
} finally {
|
|
399
422
|
rmSync(workDir, { recursive: true, force: true });
|
|
400
423
|
}
|
|
@@ -506,10 +529,10 @@ export function mayTakeRung(state, kind, cfg, rungUsed) {
|
|
|
506
529
|
* first full round, or any round once the rung is gone) runs outside the block lock, in parallel
|
|
507
530
|
* with the block's other files.
|
|
508
531
|
* @param {import('./queue.mjs').Ticket} ticket @param {ReviewContext} ctx
|
|
509
|
-
* @param {{base: string | null, level: string, owned: string[], ownedKinds?: Record<string, string>, blockKindOf: string, key: Buffer, workDir: string, factsExcerpt: string, rulesDigest?: string, peerDiffs?: () => Promise<import('../review/moved.mjs').PeerDiff[]>}} opts
|
|
532
|
+
* @param {{base: string | null, level: string, owned: string[], ownedKinds?: Record<string, string>, blockKindOf: string, key: Buffer, workDir: string, factsExcerpt: string, rulesDigest?: string, peerDiffs?: () => Promise<import('../review/moved.mjs').PeerDiff[]>, fileContext: () => Promise<FileContext>}} opts
|
|
510
533
|
* @returns {Promise<ReviewOutcome>}
|
|
511
534
|
*/
|
|
512
|
-
async function fixLoopRound(ticket, ctx, { base, level, owned, ownedKinds, blockKindOf, key, workDir, factsExcerpt, rulesDigest = '', peerDiffs }) {
|
|
535
|
+
async function fixLoopRound(ticket, ctx, { base, level, owned, ownedKinds, blockKindOf, key, workDir, factsExcerpt, rulesDigest = '', peerDiffs, fileContext }) {
|
|
513
536
|
if (!ctx.readRows || !ctx.writeRow) return { status: 'unavailable', reason: 'no-ledger', approved: false, engine: 'adaptive', sessions: [] };
|
|
514
537
|
const readRows = ctx.readRows;
|
|
515
538
|
/** @type {{outcome: ReviewOutcome} | {round: () => Promise<ReviewOutcome>}} */
|
|
@@ -572,7 +595,7 @@ async function fixLoopRound(ticket, ctx, { base, level, owned, ownedKinds, block
|
|
|
572
595
|
state.l3_rung_used = true; // the orchestrator ran the block's one L3 patch before this ticket
|
|
573
596
|
}
|
|
574
597
|
const fixed = state;
|
|
575
|
-
const round = () => playRound(ticket, ctx, { state: fixed, kind, where, seq, base, blockKindOf, workDir, factsExcerpt, rulesDigest, extraRounds, peerDiffs });
|
|
598
|
+
const round = () => playRound(ticket, ctx, { state: fixed, kind, where, seq, base, blockKindOf, workDir, factsExcerpt, rulesDigest, extraRounds, peerDiffs, fileContext });
|
|
576
599
|
// a round that could take the rung keeps the block lock until its state is saved
|
|
577
600
|
if (mayTakeRung(fixed, kind, ctx.cfg, rungUsed)) return { outcome: await round() };
|
|
578
601
|
return { round };
|
|
@@ -583,10 +606,10 @@ async function fixLoopRound(ticket, ctx, { base, level, owned, ownedKinds, block
|
|
|
583
606
|
/**
|
|
584
607
|
* Run the prepared round and save the file's state: the ticket's outcome.
|
|
585
608
|
* @param {import('./queue.mjs').Ticket} ticket @param {ReviewContext} ctx
|
|
586
|
-
* @param {{state: import('../review/fixloop.mjs').FileState, kind: 'full' | 'recheck' | 'patch_check', where: import('./review-state.mjs').Where, seq: number, base: string | null, blockKindOf: string, workDir: string, factsExcerpt: string, rulesDigest?: string, extraRounds?: number, peerDiffs?: () => Promise<import('../review/moved.mjs').PeerDiff[]>}} opts
|
|
609
|
+
* @param {{state: import('../review/fixloop.mjs').FileState, kind: 'full' | 'recheck' | 'patch_check', where: import('./review-state.mjs').Where, seq: number, base: string | null, blockKindOf: string, workDir: string, factsExcerpt: string, rulesDigest?: string, extraRounds?: number, peerDiffs?: () => Promise<import('../review/moved.mjs').PeerDiff[]>, fileContext: () => Promise<FileContext>}} opts
|
|
587
610
|
* @returns {Promise<ReviewOutcome>}
|
|
588
611
|
*/
|
|
589
|
-
async function playRound(ticket, ctx, { state, kind, where, seq, base, blockKindOf, workDir, factsExcerpt, rulesDigest = '', extraRounds = 0, peerDiffs }) {
|
|
612
|
+
async function playRound(ticket, ctx, { state, kind, where, seq, base, blockKindOf, workDir, factsExcerpt, rulesDigest = '', extraRounds = 0, peerDiffs, fileContext }) {
|
|
590
613
|
/** @type {Record<string, any> | null} */
|
|
591
614
|
let engineOutcome = null;
|
|
592
615
|
let approvalWritten = false;
|
|
@@ -626,6 +649,7 @@ async function playRound(ticket, ctx, { state, kind, where, seq, base, blockKind
|
|
|
626
649
|
const schemaHistory = ctx.readRows
|
|
627
650
|
? ledgerSchemaHistory({ readRows: ctx.readRows, verify: (row) => verifyRow(row, where.key).ok, runId: ctx.runId, file: ticket.file, contentHash: ticket.content_hash })
|
|
628
651
|
: undefined;
|
|
652
|
+
const files = await fileContext();
|
|
629
653
|
await runRound(
|
|
630
654
|
state,
|
|
631
655
|
{
|
|
@@ -637,16 +661,20 @@ async function playRound(ticket, ctx, { state, kind, where, seq, base, blockKind
|
|
|
637
661
|
factsExcerpt,
|
|
638
662
|
...(rulesDigest !== '' ? { rulesDigest } : {}),
|
|
639
663
|
extraRounds,
|
|
664
|
+
...files,
|
|
640
665
|
...(peerDiffs ? { peerDiffs } : {}),
|
|
641
666
|
...(jev ? { jev } : {}),
|
|
642
667
|
...(schemaHistory ? { schemaHistory } : {}),
|
|
643
668
|
review: async () => {
|
|
644
|
-
engineOutcome = await reviewFile({ repoRoot: ctx.contentRoot ?? ctx.repoRoot, file: ticket.file, base, cfg: ctx.cfg, kind: blockKindOf, workDir, factsExcerpt, ...(rulesDigest !== '' ? { rulesDigest } : {}), ...(peerDiffs ? { peerDiffs } : {}) }, { spawn: ctx.spawn, writeRow, ...(schemaHistory ? { schemaHistory } : {}) });
|
|
669
|
+
engineOutcome = await reviewFile({ repoRoot: ctx.contentRoot ?? ctx.repoRoot, file: ticket.file, base, cfg: ctx.cfg, kind: blockKindOf, workDir, factsExcerpt, ...files, ...(rulesDigest !== '' ? { rulesDigest } : {}), ...(peerDiffs ? { peerDiffs } : {}) }, { spawn: ctx.spawn, writeRow, ...(schemaHistory ? { schemaHistory } : {}) });
|
|
645
670
|
return engineOutcome;
|
|
646
671
|
},
|
|
647
672
|
spawn: async (opts) => {
|
|
648
673
|
const res = await ctx.spawn(opts);
|
|
649
|
-
rechecks.push({ lens: opts?.rowExtra?.lens ?? null, role: opts?.role ?? null, level: opts?.level ?? null, provider: res?.provider ?? null, model: res?.model ?? null
|
|
674
|
+
rechecks.push({ lens: opts?.rowExtra?.lens ?? null, role: opts?.role ?? null, level: opts?.level ?? null, provider: res?.provider ?? null, model: res?.model ?? null,
|
|
675
|
+
status: res?.status ?? null, tokens_in: res?.usage?.tokens_in ?? res?.row?.tokens_in ?? null, tokens_out: res?.usage?.tokens_out ?? res?.row?.tokens_out ?? null,
|
|
676
|
+
...(typeof res?.status === 'string' && res.status !== 'ok' ? { reason: res.reason ?? res.status } : {}),
|
|
677
|
+
});
|
|
650
678
|
return res;
|
|
651
679
|
},
|
|
652
680
|
},
|
|
@@ -689,7 +717,7 @@ async function playRound(ticket, ctx, { state, kind, where, seq, base, blockKind
|
|
|
689
717
|
return { ...head, status: 'reviewed', approved: true, findings: [] };
|
|
690
718
|
}
|
|
691
719
|
if (status === 'stopped') return { ...head, status: 'stopped', stopped: next?.reason ?? 'stopped', ...stopSize(state), approved: false, findings: state.open };
|
|
692
|
-
return { ...head, status: 'reviewed', approved: false, reason: 'findings-open', findings: state.open };
|
|
720
|
+
return { ...head, status: 'reviewed', approved: false, reason: next?.reason === 'no-fix-hunks' ? 'no-fix-hunks' : 'findings-open', findings: state.open };
|
|
693
721
|
}
|
|
694
722
|
|
|
695
723
|
/**
|
package/src/worker/loop.mjs
CHANGED
|
@@ -35,6 +35,8 @@
|
|
|
35
35
|
*/
|
|
36
36
|
|
|
37
37
|
import { spawn as nodeSpawn } from 'node:child_process';
|
|
38
|
+
import { existsSync, statSync } from 'node:fs';
|
|
39
|
+
import os from 'node:os';
|
|
38
40
|
import path from 'node:path';
|
|
39
41
|
import { fileURLToPath } from 'node:url';
|
|
40
42
|
import { expiryChecks } from '../autopilot/limits.mjs';
|
|
@@ -43,6 +45,7 @@ import { cacheHoursFrom, createDefaultKeyStore, resolveKey } from '../keys/store
|
|
|
43
45
|
import { appendRow, readAllRows } from '../ledger/write.mjs';
|
|
44
46
|
import { spawnSession } from '../session/spawn.mjs';
|
|
45
47
|
import { snapshotFor } from '../state/config-snapshot.mjs';
|
|
48
|
+
import { runRecordPath } from '../state/paths.mjs';
|
|
46
49
|
import { readRun, reattachWorker } from '../state/run.mjs';
|
|
47
50
|
import { loadKey, signRow } from '../state/signer.mjs';
|
|
48
51
|
import { logWarning } from '../util/error-log.mjs';
|
|
@@ -54,6 +57,7 @@ import { countLines, RouteError, routeTicket } from '../review/route.mjs';
|
|
|
54
57
|
import { canonicalPath, contentRootFor, reviewTicket } from './engine.mjs';
|
|
55
58
|
import { announceWorker, beatWorker, HEARTBEAT_MS, liveWorker, pendingTickets, readTicket, retractWorker, writeResult } from './queue.mjs';
|
|
56
59
|
import { DELETED_HASH, hashBytes, readRegularFileNoFollow, repoRootOf, WorkerError } from './ticket.mjs';
|
|
60
|
+
import { watchTick } from './watch.mjs';
|
|
57
61
|
|
|
58
62
|
/** Name of the key the worker resolves for System 1 (`keys.jev` / `system1.key` reference). */
|
|
59
63
|
export const JEV_KEY_NAME = 'jev';
|
|
@@ -220,6 +224,7 @@ export function recordedVerdict(result) {
|
|
|
220
224
|
* @property {string} slug - ledger project slug (the run record's `project`).
|
|
221
225
|
* @property {Buffer} key - the run's HMAC key.
|
|
222
226
|
* @property {number} [pollMs]
|
|
227
|
+
* @property {string} [guardRunRecordPath] - booted workers stop when this authoritative record disappears.
|
|
223
228
|
* @property {number} [heartbeatMs] - how often `heartbeat_at` is refreshed: an integer ≥ `MIN_HEARTBEAT_MS`,
|
|
224
229
|
* else `HEARTBEAT_MS`.
|
|
225
230
|
*/
|
|
@@ -230,6 +235,8 @@ export function recordedVerdict(result) {
|
|
|
230
235
|
* @property {typeof spawnSession} [spawn]
|
|
231
236
|
* @property {typeof readRun} [readRun] - the run-record read behind the per-ticket config (a test seam).
|
|
232
237
|
* @property {typeof readTicket} [readTicket] - the ticket read (a test seam).
|
|
238
|
+
* @property {typeof import('../util/exec.mjs').exec} [watchExec] - watch git exec seam.
|
|
239
|
+
* @property {Parameters<typeof watchTick>[0]['hooks']} [watchHooks] - deterministic watch races.
|
|
233
240
|
* @property {import('../keys/store.mjs').KeyStore} [store] - default: B2's production store.
|
|
234
241
|
* @property {any} [opRead]
|
|
235
242
|
* @property {NodeJS.ProcessEnv} [env]
|
|
@@ -277,6 +284,9 @@ export async function createWorker(opts, deps = {}) {
|
|
|
277
284
|
|
|
278
285
|
/** @param {Record<string, any>} row */
|
|
279
286
|
const ledger = (row) => writeRow(signRow({ run: runId, ...row }, key));
|
|
287
|
+
// Shared across direct ticks and the scheduler, so running tickets are never superseded.
|
|
288
|
+
const activeTickets = new Set();
|
|
289
|
+
const tickWatch = () => watchTick({ repoRoot, runId, cfg, readRun: loadRun, writeRow: ledger, running: activeTickets, exec: deps.watchExec, hooks: deps.watchHooks });
|
|
280
290
|
|
|
281
291
|
/**
|
|
282
292
|
* The config ticket `id` is reviewed with (B35): its snapshot from the run record, re-read per
|
|
@@ -598,7 +608,28 @@ export async function createWorker(opts, deps = {}) {
|
|
|
598
608
|
/** @type {Set<string>} */
|
|
599
609
|
const started = new Set();
|
|
600
610
|
for (;;) {
|
|
611
|
+
// One cheap stat per scheduler tick, including while tickets are in flight. Removing a
|
|
612
|
+
// test HOME (or the run record) stops new tickets; an open real run remains untouched.
|
|
613
|
+
if (!stopping && opts.guardRunRecordPath) {
|
|
614
|
+
try {
|
|
615
|
+
statSync(opts.guardRunRecordPath);
|
|
616
|
+
} catch (err) {
|
|
617
|
+
if (err.code === 'ENOENT' || err.code === 'ENOTDIR') {
|
|
618
|
+
stop();
|
|
619
|
+
// The error log lives under HOME: never write it when HOME itself is gone, or the
|
|
620
|
+
// log write would create the removed HOME again.
|
|
621
|
+
if (existsSync(os.homedir())) await logWarning({ warning: 'worker_orphan_stop', message: `run record missing: ${opts.guardRunRecordPath}` }).catch(() => null);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
601
625
|
if (!stopping) {
|
|
626
|
+
try {
|
|
627
|
+
await tickWatch();
|
|
628
|
+
} catch (thrown) {
|
|
629
|
+
const text = `watch tick failed (${errorKind(thrown)})`;
|
|
630
|
+
writeSafe(warnStream, `worker: WARN ${text}\n`);
|
|
631
|
+
try { await logWarning({ warning: 'watch_tick_failed', message: text }); } catch { /* a log failure never stops dispatch */ }
|
|
632
|
+
}
|
|
602
633
|
for (const id of pendingTickets(repoRoot)) {
|
|
603
634
|
if (poisoned.has(id) || started.has(id)) continue;
|
|
604
635
|
if (limit === null) limit = await poolSize();
|
|
@@ -606,6 +637,7 @@ export async function createWorker(opts, deps = {}) {
|
|
|
606
637
|
const fileKey = fileKeyOf(id);
|
|
607
638
|
if (fileKey !== null && busy.has(fileKey)) continue; // waits without a slot
|
|
608
639
|
started.add(id);
|
|
640
|
+
activeTickets.add(id);
|
|
609
641
|
if (fileKey !== null) busy.add(fileKey);
|
|
610
642
|
const slot = runOne(id)
|
|
611
643
|
.then((n) => {
|
|
@@ -614,6 +646,7 @@ export async function createWorker(opts, deps = {}) {
|
|
|
614
646
|
.finally(() => {
|
|
615
647
|
// the slot is freed on every exit path (`runOne` never rejects)
|
|
616
648
|
running.delete(id);
|
|
649
|
+
activeTickets.delete(id);
|
|
617
650
|
fileKeys.delete(id);
|
|
618
651
|
if (fileKey !== null) busy.delete(fileKey);
|
|
619
652
|
});
|
|
@@ -719,7 +752,7 @@ export async function createWorker(opts, deps = {}) {
|
|
|
719
752
|
retractWorker(repoRoot, process.pid);
|
|
720
753
|
}
|
|
721
754
|
|
|
722
|
-
return { processTicket, drain, run, once, stop, release, jevKeyResolved: jevKey !== null, sessionEnv: childEnv };
|
|
755
|
+
return { processTicket, drain, run, once, stop, release, watchTick: tickWatch, jevKeyResolved: jevKey !== null, sessionEnv: childEnv };
|
|
723
756
|
}
|
|
724
757
|
|
|
725
758
|
/**
|
|
@@ -750,7 +783,7 @@ export async function bootWorker(opts, deps = {}) {
|
|
|
750
783
|
const runRootDir = runRoot(opts.runId, { root: tmpRoot });
|
|
751
784
|
setRunRoot(runRootDir);
|
|
752
785
|
const key = await loadKey(opts.runId);
|
|
753
|
-
const worker = await createWorker({ runId: opts.runId, repoRoot, cfg, runRootDir, slug: record.project, key, pollMs: opts.pollMs }, deps);
|
|
786
|
+
const worker = await createWorker({ runId: opts.runId, repoRoot, cfg, runRootDir, slug: record.project, key, pollMs: opts.pollMs, guardRunRecordPath: runRecordPath(opts.runId) }, deps);
|
|
754
787
|
return { ...worker, repoRoot, runRootDir };
|
|
755
788
|
}
|
|
756
789
|
|
package/src/worker/queue.mjs
CHANGED
|
@@ -102,17 +102,56 @@ export function resolveTicket({ repoRoot, run, block, file, tree = null }) {
|
|
|
102
102
|
* @throws {WorkerError} `bad-path` for a relative `tree` or one that cannot be resolved.
|
|
103
103
|
*/
|
|
104
104
|
export function enqueue({ repoRoot, run, block, file, tree = null, now = new Date() }) {
|
|
105
|
-
const {
|
|
105
|
+
const { content_hash } = resolveTicket({ repoRoot, run, block, file, tree });
|
|
106
|
+
return enqueueHash({ repoRoot, run, block, file, tree, content_hash, now });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Queue proven git-object content through the same identity as a manual review.
|
|
110
|
+
* @param {{repoRoot: string, run: string, block: string, file: string, content_hash: string, tree?: string | null, now?: Date}} opts
|
|
111
|
+
* @returns {{ticket: string, status: 'queued' | 'done', file: string, content_hash: string, tree?: string}}
|
|
112
|
+
*/
|
|
113
|
+
export function enqueueHash({ repoRoot, run, block, file, content_hash, tree = null, now = new Date() }) {
|
|
114
|
+
const rel = assertRowPath(file);
|
|
115
|
+
const root = tree === null ? null : realTree(tree);
|
|
116
|
+
const ticket = ticketId({ run, block, file: rel, content_hash, tree: root });
|
|
106
117
|
const target = path.join(queueDir(repoRoot), `${ticket}.json`);
|
|
107
118
|
const named = root === null ? {} : { tree: root };
|
|
108
119
|
if (!existsSync(target)) {
|
|
109
120
|
/** @type {Ticket} */
|
|
110
121
|
const body = { ticket, run, block, file: rel, content_hash, enqueued_at: now.toISOString(), ...named };
|
|
111
|
-
|
|
122
|
+
// Publish a complete immutable ticket without replacing a concurrent coder's ticket.
|
|
123
|
+
mkdirSync(path.dirname(target), { recursive: true });
|
|
124
|
+
const tmp = `${target}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
|
|
125
|
+
try {
|
|
126
|
+
writeFileSync(tmp, `${JSON.stringify(body)}\n`, { mode: 0o644 });
|
|
127
|
+
try { linkSync(tmp, target); } catch (err) { if (err.code !== 'EEXIST') throw err; }
|
|
128
|
+
} finally { rmSync(tmp, { force: true }); }
|
|
112
129
|
}
|
|
130
|
+
// Returning content is current again. Claim only a superseded marker's exact bytes;
|
|
131
|
+
// a real result that replaced it wins the comparison and is restored unchanged.
|
|
132
|
+
let seen;
|
|
133
|
+
let done;
|
|
134
|
+
try {
|
|
135
|
+
seen = readFileSync(path.join(queueDir(repoRoot), `${ticket}.done`));
|
|
136
|
+
done = JSON.parse(seen.toString('utf8'));
|
|
137
|
+
} catch { /* missing or unreadable markers remain untouched */ }
|
|
138
|
+
if (seen && done?.status === 'refused' && done.reason === 'superseded' && !done.result) dropDoneIf(repoRoot, ticket, seen);
|
|
113
139
|
return { ticket, status: isDone(repoRoot, ticket) ? 'done' : 'queued', file: rel, content_hash, ...named };
|
|
114
140
|
}
|
|
115
141
|
|
|
142
|
+
/** Finish only an unstarted, pending review. A superseded marker has no review result.
|
|
143
|
+
* @param {string} repoRoot @param {string} ticket
|
|
144
|
+
* @param {{write?: (file: string, text: string) => void, link?: (from: string, to: string) => void}} [io] - publication seams for tests.
|
|
145
|
+
*/
|
|
146
|
+
export function supersedeTicket(repoRoot, ticket, io = {}) {
|
|
147
|
+
const marker = path.join(queueDir(repoRoot), `${assertTicketId(ticket)}.done`);
|
|
148
|
+
const tmp = `${marker}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
|
|
149
|
+
try {
|
|
150
|
+
(io.write ?? ((file, text) => writeFileSync(file, text, { mode: 0o644 })))(tmp, `${JSON.stringify({ ticket, status: 'refused', reason: 'superseded' })}\n`);
|
|
151
|
+
try { (io.link ?? linkSync)(tmp, marker); } catch (err) { if (err.code !== 'EEXIST') throw err; }
|
|
152
|
+
} finally { rmSync(tmp, { force: true }); }
|
|
153
|
+
}
|
|
154
|
+
|
|
116
155
|
/** B71: the most finding ids one answers ticket may name. */
|
|
117
156
|
export const MAX_ANSWERS = 10;
|
|
118
157
|
|
|
@@ -369,6 +408,25 @@ export function pendingTickets(repoRoot) {
|
|
|
369
408
|
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
409
|
}
|
|
371
410
|
|
|
411
|
+
/**
|
|
412
|
+
* Valid review tickets for one block and content tree, oldest first. Answers tickets cannot
|
|
413
|
+
* stand in for a file's own review. Unreadable tickets are refused, never silently omitted.
|
|
414
|
+
* @param {{repoRoot: string, run: string, block: string, tree?: string | null}} opts
|
|
415
|
+
* @returns {Ticket[]}
|
|
416
|
+
*/
|
|
417
|
+
export function blockTickets({ repoRoot, run, block, tree = null }) {
|
|
418
|
+
let names;
|
|
419
|
+
try {
|
|
420
|
+
names = readdirSync(queueDir(repoRoot));
|
|
421
|
+
} catch (err) {
|
|
422
|
+
if (err.code === 'ENOENT') return [];
|
|
423
|
+
throw new WorkerError('queue-unreadable', 'the review queue cannot be read');
|
|
424
|
+
}
|
|
425
|
+
const tickets = names.filter((n) => /^[0-9a-f]{24}\.json$/.test(n)).map((n) => readTicket(repoRoot, n.slice(0, 24)));
|
|
426
|
+
return tickets.filter((t) => !t.kind && t.run === run && t.block === block && (t.tree ?? null) === tree)
|
|
427
|
+
.sort((a, b) => a.enqueued_at.localeCompare(b.enqueued_at) || a.ticket.localeCompare(b.ticket));
|
|
428
|
+
}
|
|
429
|
+
|
|
372
430
|
/**
|
|
373
431
|
* Read and re-validate a ticket (coder-writable): its id must match its content.
|
|
374
432
|
* @param {string} repoRoot @param {string} id
|
|
@@ -0,0 +1,113 @@
|
|
|
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 current = enqueueHash({ repoRoot, run: runId, block, file, content_hash, tree });
|
|
103
|
+
for (const old of earlier) {
|
|
104
|
+
if (old.file === file && old.ticket !== current.ticket && !running.has(old.ticket) && !isDone(repoRoot, old.ticket)) supersedeTicket(repoRoot, old.ticket);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
save({ ...state, run: runId, base, tree, sha: head, skipped: [] });
|
|
108
|
+
} catch {
|
|
109
|
+
// A bad path, missing tree or failed git read is a skip, never a block failure.
|
|
110
|
+
await skip('unreadable_head');
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|