@codedology/code-forge 0.9.0 → 0.11.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 +93 -0
- package/README.md +49 -9
- package/docs/reference/blocks.json +587 -1
- package/docs/reference/config.md +36 -0
- package/package.json +2 -2
- package/schema/code-forge.schema.json +89 -0
- package/skill/SKILL.md +1 -1
- package/skill/references/adapters/claude-code.md +1 -1
- package/skill/references/adapters/solo.md +2 -2
- package/skill/references/adapters/subprocess.md +12 -2
- package/skill/references/code.md +14 -7
- package/skill/references/ledger.md +2 -2
- package/skill/references/proof.md +77 -2
- package/skill/references/review.md +39 -7
- package/skill/references/security.md +4 -4
- package/skill/scripts/forge +1 -1
- package/skill/templates/coder-brief.md +6 -6
- package/src/autopilot/binnacle.mjs +9 -3
- package/src/cli/block.mjs +262 -31
- package/src/cli/jev.mjs +22 -3
- package/src/cli/keys.mjs +156 -21
- package/src/cli/proof.mjs +35 -7
- package/src/cli/review-file.mjs +17 -11
- package/src/cli/spawn.mjs +44 -3
- package/src/config/validate.mjs +320 -5
- package/src/doctor/index.mjs +2 -1
- package/src/doctor/local.mjs +27 -6
- package/src/doctor/probes.mjs +38 -0
- package/src/engines/builders/codex.mjs +44 -4
- package/src/engines/sandbox-dirs.mjs +257 -0
- package/src/keys/store.mjs +128 -21
- package/src/proof/junit.mjs +530 -0
- package/src/proof/red-green.mjs +57 -9
- package/src/proof/red-parse.mjs +2 -0
- package/src/proof/runners.mjs +472 -0
- package/src/proof/xcresult.mjs +285 -0
- package/src/review/engine.mjs +141 -42
- package/src/review/fixloop.mjs +75 -23
- package/src/review/gate-check.mjs +3 -2
- package/src/review/lenses/A.md +2 -0
- package/src/review/lenses/B.md +2 -0
- package/src/review/lenses/full.md +2 -0
- package/src/review/lenses/quick.md +1 -0
- package/src/review/lenses/recheck.md +2 -0
- package/src/review/packet.mjs +48 -14
- package/src/review/paired.mjs +80 -0
- package/src/review/reopen.mjs +4 -6
- package/src/review/reviewed-lines.mjs +94 -0
- package/src/review/route.mjs +232 -0
- package/src/review/schema-fallback.mjs +77 -22
- package/src/review/session-retry.mjs +44 -9
- package/src/review/test-quality.mjs +201 -0
- package/src/review/verb-forms.mjs +39 -0
- package/src/session/spawn.mjs +39 -2
- package/src/state/block-end.mjs +10 -6
- package/src/state/block.mjs +13 -3
- package/src/state/config-snapshot.mjs +39 -0
- package/src/state/run.mjs +4 -0
- package/src/util/forbidden.mjs +2 -2
- package/src/worker/engine.mjs +43 -15
- package/src/worker/loop.mjs +98 -15
- package/src/worker/ticket.mjs +9 -1
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,24 +35,28 @@
|
|
|
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';
|
|
41
43
|
import { loadProjectConfig } from '../config/load.mjs';
|
|
42
|
-
import { createDefaultKeyStore, resolveKey } from '../keys/store.mjs';
|
|
44
|
+
import { cacheHoursFrom, createDefaultKeyStore, resolveKey } from '../keys/store.mjs';
|
|
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';
|
|
49
52
|
import { keyedLock } from '../util/locks.mjs';
|
|
50
|
-
import { writeSafe } from '../util/redact.mjs';
|
|
53
|
+
import { redact, writeSafe } from '../util/redact.mjs';
|
|
51
54
|
import { runRoot, setRunRoot } from '../util/tmp.mjs';
|
|
52
55
|
import { openFindings } from '../review/gate-check.mjs';
|
|
56
|
+
import { countLines, RouteError, routeTicket } from '../review/route.mjs';
|
|
53
57
|
import { canonicalPath, contentRootFor, reviewTicket } from './engine.mjs';
|
|
54
58
|
import { announceWorker, beatWorker, HEARTBEAT_MS, liveWorker, pendingTickets, readTicket, retractWorker, writeResult } from './queue.mjs';
|
|
55
|
-
import {
|
|
59
|
+
import { DELETED_HASH, hashBytes, readRegularFileNoFollow, repoRootOf, WorkerError } from './ticket.mjs';
|
|
56
60
|
|
|
57
61
|
/** Name of the key the worker resolves for System 1 (`keys.jev` / `system1.key` reference). */
|
|
58
62
|
export const JEV_KEY_NAME = 'jev';
|
|
@@ -136,16 +140,37 @@ export function sessionEnv(env, secrets) {
|
|
|
136
140
|
/**
|
|
137
141
|
* Resolve the Jev key through B2's chain (env → store → 1Password). A missing key is not an error:
|
|
138
142
|
* System 1 then falls back (`system1.fallback`, B3). The value is registered for redaction by B2.
|
|
143
|
+
* A refresh from 1Password is cached for the config's `keys.cache_hours` (B84: the run's snapshot;
|
|
144
|
+
* a snapshot from before the key has none ⇒ 8 h).
|
|
139
145
|
* @param {Record<string, any>} cfg
|
|
140
|
-
* @param {{store: import('../keys/store.mjs').KeyStore, env: NodeJS.ProcessEnv, opRead?: any}} deps
|
|
146
|
+
* @param {{store: import('../keys/store.mjs').KeyStore, env: NodeJS.ProcessEnv, opRead?: any, now?: number}} deps
|
|
141
147
|
* @returns {Promise<string | null>}
|
|
142
148
|
*/
|
|
143
|
-
export async function resolveJevKey(cfg, { store, env, opRead }) {
|
|
149
|
+
export async function resolveJevKey(cfg, { store, env, opRead, now }) {
|
|
144
150
|
const ref = cfg?.system1?.key ?? cfg?.keys?.[JEV_KEY_NAME];
|
|
145
|
-
const res = await resolveKey(JEV_KEY_NAME, {
|
|
151
|
+
const res = await resolveKey(JEV_KEY_NAME, {
|
|
152
|
+
store,
|
|
153
|
+
env,
|
|
154
|
+
ref: typeof ref === 'string' ? ref : undefined,
|
|
155
|
+
cacheHours: cacheHoursFrom(cfg).hours,
|
|
156
|
+
...(opRead ? { opRead } : {}),
|
|
157
|
+
...(now !== undefined ? { now } : {}),
|
|
158
|
+
});
|
|
146
159
|
return res.value;
|
|
147
160
|
}
|
|
148
161
|
|
|
162
|
+
/**
|
|
163
|
+
* B90: an unexpected failure inside a named worker step (today: `routing`). The ticket still ends
|
|
164
|
+
* `worker-error`; its result's `detail` says `<step>: <original error text>` (redacted).
|
|
165
|
+
*/
|
|
166
|
+
class StepError extends Error {
|
|
167
|
+
/** @param {string} step @param {unknown} cause */
|
|
168
|
+
constructor(step, cause) {
|
|
169
|
+
super(`${step}: ${cause instanceof Error ? `${cause.name}: ${cause.message}` : String(cause)}`, { cause });
|
|
170
|
+
this.step = step;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
149
174
|
/** The fix-loop fields of an engine outcome the result carries (B12c, §4.11). */
|
|
150
175
|
const LOOP_FIELDS = Object.freeze(['round', 'kind', 'level', 'next', 'trigger', 'stopped', 'late']);
|
|
151
176
|
|
|
@@ -198,6 +223,7 @@ export function recordedVerdict(result) {
|
|
|
198
223
|
* @property {string} slug - ledger project slug (the run record's `project`).
|
|
199
224
|
* @property {Buffer} key - the run's HMAC key.
|
|
200
225
|
* @property {number} [pollMs]
|
|
226
|
+
* @property {string} [guardRunRecordPath] - booted workers stop when this authoritative record disappears.
|
|
201
227
|
* @property {number} [heartbeatMs] - how often `heartbeat_at` is refreshed: an integer ≥ `MIN_HEARTBEAT_MS`,
|
|
202
228
|
* else `HEARTBEAT_MS`.
|
|
203
229
|
*/
|
|
@@ -282,14 +308,51 @@ export async function createWorker(opts, deps = {}) {
|
|
|
282
308
|
}
|
|
283
309
|
|
|
284
310
|
/**
|
|
285
|
-
* One ticket through the engine hook with
|
|
311
|
+
* One ticket through the engine hook with the ticket's config snapshot, routed by size (B90): the unsigned result.
|
|
312
|
+
* @param {string} id - the ticket id (B90: a retried ticket never writes its route rows twice).
|
|
286
313
|
* @param {import('./queue.mjs').Ticket} ticket @param {Record<string, any>} base
|
|
287
|
-
* @param {Record<string, any>}
|
|
314
|
+
* @param {Record<string, any>} snapshotCfg - the ticket's config snapshot, before B90 routing.
|
|
288
315
|
* @param {string} contentRoot - B62b: the tree the content was hashed in, and is reviewed in.
|
|
316
|
+
* @param {{lines: number, deleted: boolean}} size - B90: the line count of the hashed bytes.
|
|
289
317
|
* @returns {Promise<Record<string, any>>}
|
|
290
318
|
*/
|
|
291
|
-
async function reviewWith(ticket, base,
|
|
292
|
-
const
|
|
319
|
+
async function reviewWith(id, ticket, base, snapshotCfg, contentRoot, size) {
|
|
320
|
+
const baseTimeoutS = Number.isInteger(snapshotCfg?.review?.session_timeout_s) && snapshotCfg.review.session_timeout_s > 0 ? snapshotCfg.review.session_timeout_s : DEFAULT_SESSION_TIMEOUT_S;
|
|
321
|
+
// B90: `review.routes` picks the model, second level and timeout of every session of this
|
|
322
|
+
// ticket from its size, once, before the first session. No route ⇒ the snapshot object itself.
|
|
323
|
+
// A route list that cannot be used is `unavailable` — never a review on a model it did not name.
|
|
324
|
+
let routed;
|
|
325
|
+
try {
|
|
326
|
+
routed = routeTicket(snapshotCfg, { lines: size.lines, deleted: size.deleted, timeoutS: baseTimeoutS });
|
|
327
|
+
} catch (err) {
|
|
328
|
+
// only a route list that cannot be used is `route-invalid`; anything else is a real failure
|
|
329
|
+
// and goes to runOne (`worker-error`), named as a routing-step failure with its own text
|
|
330
|
+
if (!(err instanceof RouteError)) throw new StepError('routing', err);
|
|
331
|
+
return { ...base, status: 'unavailable', reason: 'route-invalid', approved: false };
|
|
332
|
+
}
|
|
333
|
+
const tcfg = routed.cfg;
|
|
334
|
+
const timeoutS = routed.timeoutS;
|
|
335
|
+
// the signed `review.route` row comes before the first session; a row that cannot be written
|
|
336
|
+
// is `unavailable` (a routed review the ledger cannot explain never runs). One row per routed
|
|
337
|
+
// level per ticket: a retried ticket (same id) skips the levels whose row the ledger already has.
|
|
338
|
+
// `runId` is the run this ticket belongs to (reviewOne refuses `ticket.run !== runId` as
|
|
339
|
+
// other-run), and a restarted worker serves the same run id (startWorker: `opts.runId`), so the
|
|
340
|
+
// check holds across workers of that run.
|
|
341
|
+
if (routed.rows.length > 0) {
|
|
342
|
+
try {
|
|
343
|
+
const have = new Set(
|
|
344
|
+
(await readRows())
|
|
345
|
+
.filter((r) => r?.event === 'review.route' && r.run === runId && r.ticket === id)
|
|
346
|
+
.map((r) => r.level),
|
|
347
|
+
);
|
|
348
|
+
for (const row of routed.rows) {
|
|
349
|
+
if (have.has(row.level)) continue;
|
|
350
|
+
await ledger({ ...row, ticket: id, block: ticket.block, file: ticket.file, content_hash: ticket.content_hash });
|
|
351
|
+
}
|
|
352
|
+
} catch {
|
|
353
|
+
return { ...base, status: 'unavailable', reason: 'route-row', approved: false };
|
|
354
|
+
}
|
|
355
|
+
}
|
|
293
356
|
/** @type {import('./engine.mjs').ReviewContext} */
|
|
294
357
|
const ctx = {
|
|
295
358
|
repoRoot,
|
|
@@ -382,8 +445,12 @@ export async function createWorker(opts, deps = {}) {
|
|
|
382
445
|
if (canonicalPath(hashedIn) !== canonicalPath(contentRoot)) return { ...base, status: 'unavailable', reason: 'worktree-mismatch', approved: false };
|
|
383
446
|
/** @type {string} */
|
|
384
447
|
let current;
|
|
448
|
+
/** @type {Buffer | null} */
|
|
449
|
+
let bytes;
|
|
385
450
|
try {
|
|
386
|
-
|
|
451
|
+
// B90: one read — the bytes hashed are the bytes whose lines route the review
|
|
452
|
+
bytes = readRegularFileNoFollow(contentRoot, ticket.file);
|
|
453
|
+
current = hashBytes(bytes);
|
|
387
454
|
} catch (err) {
|
|
388
455
|
// the path no longer passes the on-disk check, or the file cannot be read
|
|
389
456
|
const reason = err instanceof WorkerError ? err.code : 'unreadable';
|
|
@@ -398,7 +465,7 @@ export async function createWorker(opts, deps = {}) {
|
|
|
398
465
|
} catch {
|
|
399
466
|
return { ...base, status: 'unavailable', reason: 'config-snapshot', approved: false };
|
|
400
467
|
}
|
|
401
|
-
return reviewWith(ticket, base, ticketCfg, contentRoot);
|
|
468
|
+
return reviewWith(id, ticket, base, ticketCfg, contentRoot, { lines: countLines(bytes), deleted: bytes === null });
|
|
402
469
|
}
|
|
403
470
|
|
|
404
471
|
/**
|
|
@@ -445,9 +512,11 @@ export async function createWorker(opts, deps = {}) {
|
|
|
445
512
|
async function runOne(id) {
|
|
446
513
|
try {
|
|
447
514
|
return (await processTicket(id)) !== null ? 1 : 0;
|
|
448
|
-
} catch {
|
|
515
|
+
} catch (err) {
|
|
516
|
+
// B90: a failure a step names (StepError) keeps its step and original text, redacted
|
|
517
|
+
const detail = err instanceof StepError ? { detail: redact(err.message) } : {};
|
|
449
518
|
try {
|
|
450
|
-
await complete(id, { event: 'review.result', status: 'unavailable', reason: 'worker-error', approved: false });
|
|
519
|
+
await complete(id, { event: 'review.result', status: 'unavailable', reason: 'worker-error', ...detail, approved: false });
|
|
451
520
|
return 1;
|
|
452
521
|
} catch {
|
|
453
522
|
poisoned.add(id);
|
|
@@ -533,6 +602,20 @@ export async function createWorker(opts, deps = {}) {
|
|
|
533
602
|
/** @type {Set<string>} */
|
|
534
603
|
const started = new Set();
|
|
535
604
|
for (;;) {
|
|
605
|
+
// One cheap stat per scheduler tick, including while tickets are in flight. Removing a
|
|
606
|
+
// test HOME (or the run record) stops new tickets; an open real run remains untouched.
|
|
607
|
+
if (!stopping && opts.guardRunRecordPath) {
|
|
608
|
+
try {
|
|
609
|
+
statSync(opts.guardRunRecordPath);
|
|
610
|
+
} catch (err) {
|
|
611
|
+
if (err.code === 'ENOENT' || err.code === 'ENOTDIR') {
|
|
612
|
+
stop();
|
|
613
|
+
// The error log lives under HOME: never write it when HOME itself is gone, or the
|
|
614
|
+
// log write would create the removed HOME again.
|
|
615
|
+
if (existsSync(os.homedir())) await logWarning({ warning: 'worker_orphan_stop', message: `run record missing: ${opts.guardRunRecordPath}` }).catch(() => null);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
536
619
|
if (!stopping) {
|
|
537
620
|
for (const id of pendingTickets(repoRoot)) {
|
|
538
621
|
if (poisoned.has(id) || started.has(id)) continue;
|
|
@@ -685,7 +768,7 @@ export async function bootWorker(opts, deps = {}) {
|
|
|
685
768
|
const runRootDir = runRoot(opts.runId, { root: tmpRoot });
|
|
686
769
|
setRunRoot(runRootDir);
|
|
687
770
|
const key = await loadKey(opts.runId);
|
|
688
|
-
const worker = await createWorker({ runId: opts.runId, repoRoot, cfg, runRootDir, slug: record.project, key, pollMs: opts.pollMs }, deps);
|
|
771
|
+
const worker = await createWorker({ runId: opts.runId, repoRoot, cfg, runRootDir, slug: record.project, key, pollMs: opts.pollMs, guardRunRecordPath: runRecordPath(opts.runId) }, deps);
|
|
689
772
|
return { ...worker, repoRoot, runRootDir };
|
|
690
773
|
}
|
|
691
774
|
|
package/src/worker/ticket.mjs
CHANGED
|
@@ -185,7 +185,15 @@ function realOrSelf(p) {
|
|
|
185
185
|
* @returns {string}
|
|
186
186
|
*/
|
|
187
187
|
export function contentHash(repoRoot, file) {
|
|
188
|
-
|
|
188
|
+
return hashBytes(readRegularFileNoFollow(repoRoot, file));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The one content-hash rule (B90: the worker hashes the bytes it also counts lines on): sha256 of
|
|
193
|
+
* the bytes, or `DELETED_HASH` for `null` (no file).
|
|
194
|
+
* @param {Buffer | null} bytes @returns {string}
|
|
195
|
+
*/
|
|
196
|
+
export function hashBytes(bytes) {
|
|
189
197
|
if (bytes === null) return DELETED_HASH;
|
|
190
198
|
return createHash('sha256').update(bytes).digest('hex');
|
|
191
199
|
}
|