@codedology/code-forge 0.10.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 +35 -0
- package/README.md +21 -2
- package/docs/reference/blocks.json +203 -1
- package/package.json +2 -2
- package/skill/SKILL.md +1 -1
- package/skill/references/code.md +6 -4
- package/skill/references/review.md +28 -3
- package/skill/scripts/forge +1 -1
- package/skill/templates/coder-brief.md +4 -6
- package/src/cli/block.mjs +98 -7
- package/src/cli/review-file.mjs +17 -11
- package/src/review/engine.mjs +59 -12
- package/src/review/fixloop.mjs +30 -10
- package/src/review/lenses/A.md +1 -0
- package/src/review/lenses/B.md +1 -0
- package/src/review/lenses/full.md +1 -0
- package/src/review/lenses/recheck.md +1 -0
- package/src/review/packet.mjs +48 -14
- package/src/review/paired.mjs +80 -0
- package/src/review/reviewed-lines.mjs +94 -0
- package/src/state/block-end.mjs +10 -6
- package/src/worker/engine.mjs +43 -15
- package/src/worker/loop.mjs +19 -1
package/src/review/packet.mjs
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* ## project rules the rules digest (≤ 60 lines)
|
|
11
11
|
* ## facts the facts-sheet excerpt
|
|
12
12
|
* ## context the current file (whole, or hunk windows — `context.mjs`)
|
|
13
|
+
* ## paired optional paired-file names and summary (B97), dropped first over budget
|
|
13
14
|
* ## moved code only when code moved to/from another file of the block (`moved.mjs`, B56)
|
|
14
15
|
* ## diff `file:`, the `hunks:` list the reviewer must echo, then the diff
|
|
15
16
|
*
|
|
@@ -20,7 +21,7 @@
|
|
|
20
21
|
* Budget (§4.3): `review.budgets.full_in` (`quick_in` for the quick lens), in tokens estimated as
|
|
21
22
|
* bytes / 4. A diff that alone exceeds it ⇒ `split_required` (a Markdown file is then reviewed
|
|
22
23
|
* section by section — `sections.mjs`, B54). Otherwise over budget ⇒ the context shrinks to
|
|
23
|
-
* ± `min_context_lines` (`minimal`)
|
|
24
|
+
* ± `min_context_lines` (`minimal`) after dropping paired, then the digest is trimmed from the end; the diff is
|
|
24
25
|
* never cut. The `## moved code` section (B56) is part of that measure: it is counted in the diff-only
|
|
25
26
|
* size (`diffOnlyTokens`, `split_required`) and never trimmed.
|
|
26
27
|
*
|
|
@@ -190,11 +191,11 @@ export async function readFileDiff({ repoRoot, file, base }) {
|
|
|
190
191
|
* Assemble a reviewer packet (pure).
|
|
191
192
|
* @param {{
|
|
192
193
|
* diff: FileDiff, lens: string, rulesDigest?: string, factsExcerpt?: string,
|
|
193
|
-
* cfg?: Record<string, any>, contextMode?: 'auto' | 'recheck', budget?: number, moved?: string,
|
|
194
|
+
* cfg?: Record<string, any>, contextMode?: 'auto' | 'recheck', budget?: number, moved?: string, paired?: string,
|
|
194
195
|
* }} opts - `moved`: the file's `## moved code` section (`moved.mjs`), '' for none.
|
|
195
196
|
* @returns {Packet | {status: 'split_required', tokensIn: number, budget: number}}
|
|
196
197
|
*/
|
|
197
|
-
export function assemblePacket({ diff, lens, rulesDigest = '', factsExcerpt = '', cfg, contextMode = 'auto', budget, moved = '' }) {
|
|
198
|
+
export function assemblePacket({ diff, lens, rulesDigest = '', factsExcerpt = '', cfg, contextMode = 'auto', budget, moved = '', paired = '' }) {
|
|
198
199
|
const lensText = loadLens(lens);
|
|
199
200
|
const limit = budget ?? budgetFor(cfg, lens === 'quick' ? 'quick_in' : 'full_in');
|
|
200
201
|
const hunkHeaders = diff.hunks.map((h) => h.header);
|
|
@@ -204,7 +205,12 @@ export function assemblePacket({ diff, lens, rulesDigest = '', factsExcerpt = ''
|
|
|
204
205
|
|
|
205
206
|
let digest = splitLines(rulesDigest).slice(0, DIGEST_MAX_LINES);
|
|
206
207
|
let ctx = buildContext({ file: diff.file, content: diff.content, hunks: diff.hunks, cfg, mode: contextMode });
|
|
207
|
-
let
|
|
208
|
+
let pairedSection = boundedPaired(paired, budgetFor(cfg, 'full_in'));
|
|
209
|
+
let text = render(lensText, digest, factsExcerpt, ctx.text, diffSection, moved, pairedSection);
|
|
210
|
+
if (estimateTokens(text) > limit && pairedSection.length > 0) {
|
|
211
|
+
pairedSection = '';
|
|
212
|
+
text = render(lensText, digest, factsExcerpt, ctx.text, diffSection, moved);
|
|
213
|
+
}
|
|
208
214
|
if (estimateTokens(text) > limit) {
|
|
209
215
|
ctx = buildContext({ file: diff.file, content: diff.content, hunks: diff.hunks, cfg, mode: 'minimal' });
|
|
210
216
|
text = render(lensText, digest, factsExcerpt, ctx.text, diffSection, moved);
|
|
@@ -217,6 +223,22 @@ export function assemblePacket({ diff, lens, rulesDigest = '', factsExcerpt = ''
|
|
|
217
223
|
return { status: 'ok', text, hunkHeaders, contextMode: ctx.mode, contextLines: ctx.lineCount, digestLines: digest.length, tokensIn, budget: limit, overBudget: tokensIn > limit };
|
|
218
224
|
}
|
|
219
225
|
|
|
226
|
+
/** Cap the entire section at full_in / 16 tokens, without cutting a UTF-8 character.
|
|
227
|
+
* @param {string} paired @param {number} fullIn @returns {string}
|
|
228
|
+
*/
|
|
229
|
+
function boundedPaired(paired, fullIn) {
|
|
230
|
+
if (paired.trim().length === 0) return '';
|
|
231
|
+
const heading = '## paired\n';
|
|
232
|
+
let left = Math.floor(fullIn / 16) * 4 - Buffer.byteLength(heading) - 1;
|
|
233
|
+
let body = '';
|
|
234
|
+
for (const char of paired) {
|
|
235
|
+
left -= Buffer.byteLength(char);
|
|
236
|
+
if (left < 0) break;
|
|
237
|
+
body += char;
|
|
238
|
+
}
|
|
239
|
+
return body.length > 0 ? heading + body.trimEnd() : '';
|
|
240
|
+
}
|
|
241
|
+
|
|
220
242
|
/**
|
|
221
243
|
* The tokens of the diff-only packet (lens + moved code + diff, no digest, facts or context): what
|
|
222
244
|
* `assemblePacket` holds against the budget before it says `split_required`. Pass the SAME `moved`
|
|
@@ -244,8 +266,9 @@ function diffSectionOf(diff) {
|
|
|
244
266
|
/**
|
|
245
267
|
* @param {string} lensText @param {string[]} digest @param {string} facts @param {string} context
|
|
246
268
|
* @param {string} diffSection @param {string} [moved] - the `## moved code` section (heading included), '' for none.
|
|
269
|
+
* @param {string} [paired] - the bounded `## paired` section (heading included).
|
|
247
270
|
*/
|
|
248
|
-
function render(lensText, digest, facts, context, diffSection, moved = '') {
|
|
271
|
+
function render(lensText, digest, facts, context, diffSection, moved = '', paired = '') {
|
|
249
272
|
return [
|
|
250
273
|
'# code-forge review packet',
|
|
251
274
|
'## lens',
|
|
@@ -256,6 +279,7 @@ function render(lensText, digest, facts, context, diffSection, moved = '') {
|
|
|
256
279
|
facts.trim().length > 0 ? facts.trimEnd() : '(none)',
|
|
257
280
|
'## context',
|
|
258
281
|
context.length > 0 ? context : '(none)',
|
|
282
|
+
...(paired.length > 0 ? [paired] : []),
|
|
259
283
|
...(moved.length > 0 ? [moved] : []),
|
|
260
284
|
'## diff',
|
|
261
285
|
diffSection,
|
|
@@ -319,9 +343,10 @@ function readRepoText(repoRoot, rel) {
|
|
|
319
343
|
|
|
320
344
|
/**
|
|
321
345
|
* Why `rel` may not be attached, or null.
|
|
322
|
-
* @param {string} repoRoot @param {string} rel @
|
|
346
|
+
* @param {string} repoRoot @param {string} rel @param {ReadonlyArray<string>} ownedFiles
|
|
347
|
+
* @param {ReadonlyArray<string>} changedFiles @returns {Promise<string | null>}
|
|
323
348
|
*/
|
|
324
|
-
async function attachRefusal(repoRoot, rel) {
|
|
349
|
+
async function attachRefusal(repoRoot, rel, ownedFiles, changedFiles) {
|
|
325
350
|
if (isSecretLike(rel)) return 'refused: secret-like path';
|
|
326
351
|
let st;
|
|
327
352
|
try {
|
|
@@ -330,20 +355,29 @@ async function attachRefusal(repoRoot, rel) {
|
|
|
330
355
|
return 'missing';
|
|
331
356
|
}
|
|
332
357
|
if (!st.isFile()) return 'not a file';
|
|
333
|
-
const res = await exec(['git', 'ls-files', '-z', '--
|
|
334
|
-
if (res.result !== 'ok'
|
|
358
|
+
const res = await exec(['git', 'ls-files', '-z', '--', rel], { cwd: repoRoot, env: reviewGitEnv(), timeoutMs: GIT_TIMEOUT_MS });
|
|
359
|
+
if (res.result !== 'ok') return 'refused: git failed';
|
|
360
|
+
if (res.stdout === `${rel}\0`) return null;
|
|
361
|
+
if (res.stdout.length > 0) return 'refused: git failed';
|
|
362
|
+
if (!ownedFiles.includes(rel)) return 'refused: untracked, not owned by this block';
|
|
363
|
+
if (!changedFiles.includes(rel)) return 'refused: untracked, not changed in this block';
|
|
364
|
+
const ignored = await exec(['git', 'check-ignore', '-q', '--', rel], { cwd: repoRoot, env: plainGitEnv(), timeoutMs: GIT_TIMEOUT_MS, okExitCodes: [0, 1] });
|
|
365
|
+
if (ignored.result !== 'ok' || (ignored.code !== 0 && ignored.code !== 1)) return 'refused: git failed';
|
|
366
|
+
if (ignored.code === 0) return 'refused: gitignored';
|
|
335
367
|
return null;
|
|
336
368
|
}
|
|
337
369
|
|
|
338
370
|
/**
|
|
339
371
|
* The `needs_file` follow-up (§4.2): the requested files, read-only, appended AFTER the packet
|
|
340
|
-
* (the stable prefix stays stable).
|
|
341
|
-
*
|
|
372
|
+
* (the stable prefix stays stable). Regular tracked files, or untracked files present in BOTH
|
|
373
|
+
* concrete path lists of this block, are attached. Ignored untracked files never reach a provider,
|
|
374
|
+
* and secret-like paths are refused even
|
|
342
375
|
* when tracked; every refusal is named in the section. The attachment is trimmed to `budgetTokens`.
|
|
343
|
-
* @param {{repoRoot: string, paths: ReadonlyArray<unknown>, budgetTokens: number}} opts
|
|
376
|
+
* @param {{repoRoot: string, paths: ReadonlyArray<unknown>, budgetTokens: number, ownedFiles?: ReadonlyArray<string>, changedFiles?: ReadonlyArray<string>}} opts
|
|
377
|
+
* `ownedFiles` and `changedFiles`: resolved paths from the SAME block in this repoRoot; no globs.
|
|
344
378
|
* @returns {Promise<string>} the section to append.
|
|
345
379
|
*/
|
|
346
|
-
export async function attachFiles({ repoRoot, paths, budgetTokens }) {
|
|
380
|
+
export async function attachFiles({ repoRoot, paths, budgetTokens, ownedFiles = [], changedFiles = [] }) {
|
|
347
381
|
const out = ['## attached files (read-only, requested in needs_file)'];
|
|
348
382
|
let left = budgetTokens * 4;
|
|
349
383
|
for (const raw of paths.slice(0, 10)) {
|
|
@@ -356,7 +390,7 @@ export async function attachFiles({ repoRoot, paths, budgetTokens }) {
|
|
|
356
390
|
out.push(notFile ? `### ${String(raw)} (not a file)` : '### (refused: not a repo-relative path)');
|
|
357
391
|
continue;
|
|
358
392
|
}
|
|
359
|
-
const refusal = await attachRefusal(repoRoot, rel);
|
|
393
|
+
const refusal = await attachRefusal(repoRoot, rel, ownedFiles, changedFiles);
|
|
360
394
|
if (refusal) {
|
|
361
395
|
out.push(`### ${rel} (${refusal})`);
|
|
362
396
|
continue;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/** Stem pairs and bounded names for closed-book review packets (B97). No file reads. */
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { findSecret } from '../util/gh.mjs';
|
|
4
|
+
import { redact } from '../util/redact.mjs';
|
|
5
|
+
|
|
6
|
+
/** @param {string} file */
|
|
7
|
+
function identity(file) {
|
|
8
|
+
if (typeof file !== 'string' || file.startsWith('/') || /[\\\x00-\x1f]/.test(file) || file.split('/').some((s) => s === '.' || s === '..' || s === '')) return null;
|
|
9
|
+
const ext = path.posix.extname(file);
|
|
10
|
+
let stem = path.posix.basename(file, ext);
|
|
11
|
+
let test = /(^|\/)(?:test|tests)\//.test(file);
|
|
12
|
+
if (ext === '.mjs' || ext === '.ts') {
|
|
13
|
+
if (/\.(test|spec)$/.test(stem)) { test = true; stem = stem.replace(/\.(test|spec)$/, ''); }
|
|
14
|
+
} else if (ext === '.php') {
|
|
15
|
+
if (stem.endsWith('Test')) { test = true; stem = stem.slice(0, -4); }
|
|
16
|
+
} else if (ext === '.go') {
|
|
17
|
+
if (stem.endsWith('_test')) { test = true; stem = stem.slice(0, -5); }
|
|
18
|
+
} else if (ext === '.py') {
|
|
19
|
+
if (stem.startsWith('test_')) { test = true; stem = stem.slice(5); }
|
|
20
|
+
} else if (ext === '.swift') {
|
|
21
|
+
if (stem.endsWith('Tests')) { test = true; stem = stem.slice(0, -5); }
|
|
22
|
+
} else return null;
|
|
23
|
+
return { ext, stem, test };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** @param {string} file @param {ReadonlyArray<string>} candidates @returns {string[]} */
|
|
27
|
+
export function pairsFor(file, candidates) {
|
|
28
|
+
const source = identity(file);
|
|
29
|
+
if (!source) return [];
|
|
30
|
+
return [...new Set(candidates)].filter((candidate) => {
|
|
31
|
+
const other = identity(candidate);
|
|
32
|
+
return candidate !== file && other?.ext === source.ext && other.stem === source.stem && other.test !== source.test;
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A best-effort list, not a parser or proof of coverage. Names only, never function bodies.
|
|
38
|
+
* Redact before truncating so a token crossing the 120-character boundary cannot leak.
|
|
39
|
+
* @param {string} text @param {string} file @returns {string[]}
|
|
40
|
+
*/
|
|
41
|
+
export function summarize(text, file) {
|
|
42
|
+
const id = identity(file);
|
|
43
|
+
if (!id) return [];
|
|
44
|
+
/** @type {Set<string>} */
|
|
45
|
+
const names = new Set();
|
|
46
|
+
for (const line of text.split(/\r?\n/)) {
|
|
47
|
+
/** @type {RegExpMatchArray | null} */
|
|
48
|
+
let match = null;
|
|
49
|
+
if (id.test) {
|
|
50
|
+
if (id.ext === '.mjs' || id.ext === '.ts' || id.ext === '.php') {
|
|
51
|
+
match = line.match(/^\s*(?:await\s+)?(?:test|it)(?:\.(?:only|skip|todo))?\(\s*(['"`])([^\r\n]+?)\1/);
|
|
52
|
+
if (match) { names.add(match[2]); match = null; }
|
|
53
|
+
else if (id.ext === '.php') match = line.match(/^\s*(?:(?:public|protected)\s+)?function\s+(test\w+)\s*\(/);
|
|
54
|
+
else continue;
|
|
55
|
+
} else if (id.ext === '.go') match = line.match(/^\s*func\s+(Test\w+)\s*\(/);
|
|
56
|
+
else if (id.ext === '.py') match = line.match(/^\s*(?:async\s+)?def\s+(test_\w+)\s*\(/);
|
|
57
|
+
else if (id.ext === '.swift') match = line.match(/^\s*func\s+(test\w+)\s*\(/);
|
|
58
|
+
} else {
|
|
59
|
+
if (id.ext === '.mjs' || id.ext === '.ts') {
|
|
60
|
+
match = line.match(/^\s*export\s+(?:default\s+)?(?:async\s+)?(?:function\*?|class|const|let|var|interface|type|enum)\s+(\w+)/);
|
|
61
|
+
const list = line.match(/^\s*export\s*\{([^}]+)\}/);
|
|
62
|
+
if (list) {
|
|
63
|
+
for (const item of list[1].split(',')) {
|
|
64
|
+
const symbol = item.trim().match(/^(?:type\s+)?(\w+)(?:\s+as\s+(\w+))?$/);
|
|
65
|
+
if (symbol) names.add(symbol[2] ?? symbol[1]);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
} else if (id.ext === '.php') match = line.match(/^\s*(?:(?:public|protected|private|static|final|abstract)\s+)*(?:function|class|interface|trait)\s+(\w+)/);
|
|
69
|
+
else if (id.ext === '.go') match = line.match(/^\s*(?:func\s+(?:\([^)]*\)\s+)?|(?:type|var|const)\s+)([A-Z]\w*)/);
|
|
70
|
+
else if (id.ext === '.py') match = line.match(/^\s*(?:async\s+)?(?:def|class)\s+([a-zA-Z]\w*)/);
|
|
71
|
+
else if (id.ext === '.swift') match = line.match(/^\s*(?:public|open)\s+(?:final\s+)?(?:func|class|struct|enum|protocol|let|var)\s+(\w+)/);
|
|
72
|
+
}
|
|
73
|
+
if (match) names.add(match[1]);
|
|
74
|
+
if (names.size >= 40) break;
|
|
75
|
+
}
|
|
76
|
+
return [...names].slice(0, 40).map((name) => {
|
|
77
|
+
const safe = redact(name);
|
|
78
|
+
return Array.from(findSecret(safe) ? '[REDACTED]' : safe).slice(0, 120).join('');
|
|
79
|
+
});
|
|
80
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* B96 (E1): the close report is built by the tool. After the review gate passed (`reviewGateCheck`
|
|
3
|
+
* over `checkBlockReviews`), every file the gate checked is accepted at its current hash, either
|
|
4
|
+
* by a signed `review.approved` row or — the gate's `waivedAll` list (B70/B71) — because every
|
|
5
|
+
* finding its review left open was waived or answered. This module turns that verdict into one
|
|
6
|
+
* line per file: `{file, ticket, via}`, `via` one of `approved`, `waiver`, `answered`, and
|
|
7
|
+
* `ticket` the queue's ticket for that file and hash (the caller passes `ticketOf`, the queue's
|
|
8
|
+
* own rule: `src/review` never imports the worker). It never re-decides the acceptance: the gate
|
|
9
|
+
* is the only judge, and a file the gate did not accept is never passed here.
|
|
10
|
+
*
|
|
11
|
+
* It also reads the coder's hand lines (`reviewed <path> ticket <id>`), to name a hand line that
|
|
12
|
+
* names another ticket (D1: a warning, the tool lines are the record).
|
|
13
|
+
*
|
|
14
|
+
* Its warnings (`onNoTicket`, `onBadTicket`) are callbacks: the close holds what they report and
|
|
15
|
+
* prints it only when the block closes (src/cli/block.mjs).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** @typedef {'approved' | 'waiver' | 'answered'} Via */
|
|
19
|
+
/** @typedef {{file: string, ticket: string, via: Via}} ReviewedLine */
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The `via` of a file the gate accepted, read from the gate's own record (`WaivedAll`, built by
|
|
23
|
+
* `waivedAllOf` in gate-check.mjs): no record at the same hash ⇒ `approved` (a signed approval);
|
|
24
|
+
* a record whose `by` is null ⇒ `answered` (the gate sets `by` to null exactly when every open
|
|
25
|
+
* finding was answered); any other `by` (`human` or `autopilot`) ⇒ `waiver` — also for a file
|
|
26
|
+
* with some findings answered and at least one waived (the waiver is what a human or a grant
|
|
27
|
+
* decided, so the line names it).
|
|
28
|
+
* @param {import('./gate-check.mjs').WaivedAll | undefined} record @returns {Via}
|
|
29
|
+
*/
|
|
30
|
+
export function viaOf(record) {
|
|
31
|
+
if (record === undefined) return 'approved';
|
|
32
|
+
return record.by === null ? 'answered' : 'waiver';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A ticket id as the queue gives it: 24 lowercase hex characters (`assertTicketId`). */
|
|
36
|
+
const TICKET_ID = /^[0-9a-f]{24}$/;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The tool's reviewed lines, sorted by file. Pure but for `onNoTicket` and `onBadTicket`.
|
|
40
|
+
* @param {{
|
|
41
|
+
* files: ReadonlyArray<{file: string, content_hash: string}>,
|
|
42
|
+
* waivedAll: ReadonlyArray<import('./gate-check.mjs').WaivedAll>,
|
|
43
|
+
* ticketOf: (file: {file: string, content_hash: string}) => unknown,
|
|
44
|
+
* onNoTicket?: (file: string) => void,
|
|
45
|
+
* onBadTicket?: (file: string) => void,
|
|
46
|
+
* }} opts `files`: the files the review gate checked and ACCEPTED (it passed); `waivedAll`: the
|
|
47
|
+
* gate's `reviewed_by_waiver` files ({@link viaOf}); every other file passed through a signed
|
|
48
|
+
* approval. `ticketOf`: the queue's ticket for that file at that hash, or null (or undefined)
|
|
49
|
+
* when it cannot be given — then the file has no line (fail closed) and `onNoTicket(file)` is
|
|
50
|
+
* called once. Any other value that is not 24 lowercase hex characters (a string of another
|
|
51
|
+
* shape, a number…) is a bad ticket: the file has no line either and `onBadTicket(file)` is
|
|
52
|
+
* called once instead.
|
|
53
|
+
* @returns {ReviewedLine[]}
|
|
54
|
+
*/
|
|
55
|
+
export function reviewedLines({ files, waivedAll, ticketOf, onNoTicket = () => {}, onBadTicket = () => {} }) {
|
|
56
|
+
/** @type {ReviewedLine[]} */
|
|
57
|
+
const lines = [];
|
|
58
|
+
for (const f of files) {
|
|
59
|
+
const via = viaOf(waivedAll.find((w) => w.file === f.file && w.content_hash === f.content_hash));
|
|
60
|
+
const ticket = ticketOf(f);
|
|
61
|
+
if (ticket === null || ticket === undefined) onNoTicket(f.file);
|
|
62
|
+
else if (typeof ticket === 'string' && TICKET_ID.test(ticket)) lines.push({ file: f.file, ticket, via });
|
|
63
|
+
else onBadTicket(f.file);
|
|
64
|
+
}
|
|
65
|
+
return lines.sort((x, y) => (x.file < y.file ? -1 : x.file > y.file ? 1 : 0));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A hand line: exactly `reviewed <path> ticket <id>` once trimmed, with backticks dropped and an
|
|
70
|
+
* optional leading list marker (`- `, `* ` or a number and a dot, `1. `). The id is 24 hex
|
|
71
|
+
* characters of either case: a ticket id is lowercase, so an id in capitals is read here and then
|
|
72
|
+
* never equals the tool's ticket ({@link reportMismatches} compares exactly).
|
|
73
|
+
*/
|
|
74
|
+
const HAND_LINE = /^(?:(?:[-*]|\d{1,9}\.)\s+)?reviewed\s+(\S+)\s+ticket\s+([0-9a-fA-F]{24})$/;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* D1: the files whose hand line in `report` names a ticket id other than the tool's (compared
|
|
78
|
+
* exactly, so an id in capitals is another ticket). Only a `reviewed <path> ticket <id>` line
|
|
79
|
+
* counts ({@link HAND_LINE}); a hash of another length, or a line of any other shape that names
|
|
80
|
+
* the file, never does.
|
|
81
|
+
* @param {string} report @param {ReadonlyArray<ReviewedLine>} lines
|
|
82
|
+
* @returns {string[]} the files with a mismatched hand line, in the order of `lines`.
|
|
83
|
+
*/
|
|
84
|
+
export function reportMismatches(report, lines) {
|
|
85
|
+
/** @type {Set<string>} */
|
|
86
|
+
const other = new Set();
|
|
87
|
+
for (const raw of report.split('\n')) {
|
|
88
|
+
const m = HAND_LINE.exec(raw.replaceAll('`', '').trim());
|
|
89
|
+
if (m === null) continue;
|
|
90
|
+
const line = lines.find((l) => l.file === m[1]);
|
|
91
|
+
if (line !== undefined && line.ticket !== m[2]) other.add(line.file);
|
|
92
|
+
}
|
|
93
|
+
return lines.filter((l) => other.has(l.file)).map((l) => l.file);
|
|
94
|
+
}
|
package/src/state/block-end.mjs
CHANGED
|
@@ -54,11 +54,13 @@ function safeId(id) {
|
|
|
54
54
|
* @param {{
|
|
55
55
|
* block: string, runId: string, status: 'closed' | 'stopped', event: string, reason?: string | null,
|
|
56
56
|
* commit: string | null, spend: {usd: number, unknown: number} | null, findings: number | null, at: Date,
|
|
57
|
-
* }
|
|
57
|
+
* reviewed?: ReadonlyArray<{file: string, ticket: string, via: string}>,
|
|
58
|
+
* }} facts `reviewed` (B96): the close's reviewed lines, sorted by file; the key is added last, and only when given.
|
|
58
59
|
* @returns {{block: string, run_id: string, status: string, event: string, reason: string | null, commit: string | null,
|
|
59
|
-
* spent_usd: number | null, unknown_usd_sessions: number | null, findings: number | null, at: string
|
|
60
|
+
* spent_usd: number | null, unknown_usd_sessions: number | null, findings: number | null, at: string,
|
|
61
|
+
* reviewed?: Array<{file: string, ticket: string, via: string}>}}
|
|
60
62
|
*/
|
|
61
|
-
export function doneFileContent({ block, runId, status, event, reason = null, commit, spend, findings, at }) {
|
|
63
|
+
export function doneFileContent({ block, runId, status, event, reason = null, commit, spend, findings, at, reviewed }) {
|
|
62
64
|
return {
|
|
63
65
|
block,
|
|
64
66
|
run_id: runId,
|
|
@@ -71,6 +73,7 @@ export function doneFileContent({ block, runId, status, event, reason = null, co
|
|
|
71
73
|
unknown_usd_sessions: spend && Number.isInteger(spend.unknown) ? spend.unknown : null,
|
|
72
74
|
findings: Number.isInteger(findings) ? findings : null,
|
|
73
75
|
at: at.toISOString(),
|
|
76
|
+
...(Array.isArray(reviewed) ? { reviewed: reviewed.map((r) => ({ file: r.file, ticket: r.ticket, via: r.via })).sort((x, y) => (x.file < y.file ? -1 : x.file > y.file ? 1 : 0)) } : {}),
|
|
74
77
|
};
|
|
75
78
|
}
|
|
76
79
|
|
|
@@ -97,12 +100,13 @@ async function headCommit(cwd) {
|
|
|
97
100
|
* @param {{
|
|
98
101
|
* record: Record<string, any>, id: string, status: 'closed' | 'stopped', event: string, reason?: string | null,
|
|
99
102
|
* openFindings: (rows: Record<string, any>[]) => Promise<number>, err: (s: string) => unknown, now?: Date,
|
|
103
|
+
* reviewed?: ReadonlyArray<{file: string, ticket: string, via: string}>,
|
|
100
104
|
* }} opts - `record` is the run record read before the end (its `workspace` and the block's
|
|
101
105
|
* `worktree` do not change at an end); `openFindings` counts the block's open findings exactly
|
|
102
|
-
* as `code-forge status` does.
|
|
106
|
+
* as `code-forge status` does. `reviewed` (B96): the close's reviewed lines, kept as the last key.
|
|
103
107
|
* @returns {Promise<string | null>} the done file's absolute path, or null when it was not written.
|
|
104
108
|
*/
|
|
105
|
-
export async function writeDoneFile({ record, id, status, event, reason = null, openFindings, err, now = new Date() }) {
|
|
109
|
+
export async function writeDoneFile({ record, id, status, event, reason = null, openFindings, err, now = new Date(), reviewed }) {
|
|
106
110
|
const runId = String(record?.run_id);
|
|
107
111
|
try {
|
|
108
112
|
const file = doneFilePath(runId, id);
|
|
@@ -127,7 +131,7 @@ export async function writeDoneFile({ record, id, status, event, reason = null,
|
|
|
127
131
|
}
|
|
128
132
|
const tree = record.blocks?.[id]?.worktree?.path;
|
|
129
133
|
const commit = await headCommit(typeof tree === 'string' && tree.length > 0 ? tree : record.workspace);
|
|
130
|
-
const content = doneFileContent({ block: id, runId, status, event, reason, commit, spend, findings, at: now });
|
|
134
|
+
const content = doneFileContent({ block: id, runId, status, event, reason, commit, spend, findings, at: now, ...(reviewed === undefined ? {} : { reviewed }) });
|
|
131
135
|
const dir = path.dirname(file);
|
|
132
136
|
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
133
137
|
const tmp = path.join(dir, `.${path.basename(file)}.${process.pid}.${Date.now()}.tmp`);
|
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';
|
|
@@ -220,6 +223,7 @@ export function recordedVerdict(result) {
|
|
|
220
223
|
* @property {string} slug - ledger project slug (the run record's `project`).
|
|
221
224
|
* @property {Buffer} key - the run's HMAC key.
|
|
222
225
|
* @property {number} [pollMs]
|
|
226
|
+
* @property {string} [guardRunRecordPath] - booted workers stop when this authoritative record disappears.
|
|
223
227
|
* @property {number} [heartbeatMs] - how often `heartbeat_at` is refreshed: an integer ≥ `MIN_HEARTBEAT_MS`,
|
|
224
228
|
* else `HEARTBEAT_MS`.
|
|
225
229
|
*/
|
|
@@ -598,6 +602,20 @@ export async function createWorker(opts, deps = {}) {
|
|
|
598
602
|
/** @type {Set<string>} */
|
|
599
603
|
const started = new Set();
|
|
600
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
|
+
}
|
|
601
619
|
if (!stopping) {
|
|
602
620
|
for (const id of pendingTickets(repoRoot)) {
|
|
603
621
|
if (poisoned.has(id) || started.has(id)) continue;
|
|
@@ -750,7 +768,7 @@ export async function bootWorker(opts, deps = {}) {
|
|
|
750
768
|
const runRootDir = runRoot(opts.runId, { root: tmpRoot });
|
|
751
769
|
setRunRoot(runRootDir);
|
|
752
770
|
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);
|
|
771
|
+
const worker = await createWorker({ runId: opts.runId, repoRoot, cfg, runRootDir, slug: record.project, key, pollMs: opts.pollMs, guardRunRecordPath: runRecordPath(opts.runId) }, deps);
|
|
754
772
|
return { ...worker, repoRoot, runRootDir };
|
|
755
773
|
}
|
|
756
774
|
|