@codedology/code-forge 0.7.7 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +41 -0
- package/README.md +50 -2
- package/docs/README.md +2 -1
- package/docs/reference/blocks.json +157 -1
- package/docs/reference/config.md +26 -8
- package/package.json +1 -1
- package/schema/code-forge.schema.json +81 -4
- package/skill/SKILL.md +6 -6
- package/skill/references/code.md +33 -4
- package/skill/references/ledger.md +8 -4
- package/skill/references/proof.md +44 -3
- package/skill/references/review.md +4 -2
- package/skill/references/security.md +1 -1
- package/skill/scripts/forge +1 -1
- package/skill/templates/coder-brief.md +18 -1
- package/src/autopilot/binnacle.mjs +21 -3
- package/src/cli/block.mjs +292 -33
- package/src/cli/context.mjs +175 -0
- package/src/cli/gates.mjs +125 -15
- package/src/cli/ledger.mjs +46 -2
- package/src/cli/proof.mjs +62 -16
- package/src/cli/report.mjs +7 -4
- package/src/cli/review-file.mjs +61 -6
- package/src/cli/status.mjs +212 -0
- package/src/config/validate.mjs +45 -11
- package/src/context/pack.mjs +319 -0
- package/src/doctor/local.mjs +2 -1
- package/src/engines/sentinel.mjs +27 -6
- package/src/gates/detect.mjs +21 -8
- package/src/gates/expand.mjs +202 -0
- package/src/gates/owned-count.mjs +66 -0
- package/src/gates/run.mjs +49 -12
- package/src/install/wizard/answers.mjs +34 -5
- package/src/install/wizard/flags.mjs +8 -1
- package/src/install/wizard/project-file.mjs +2 -0
- package/src/install/wizard/run.mjs +6 -0
- package/src/install/wizard/steps.mjs +24 -4
- package/src/install/wizard/summary.mjs +13 -5
- package/src/ledger/export.mjs +1 -1
- package/src/ledger/outside-review.mjs +164 -0
- package/src/ledger/report.mjs +17 -5
- package/src/proof/export.mjs +93 -10
- package/src/proof/lock.mjs +28 -5
- package/src/proof/worktree.mjs +482 -0
- package/src/review/gate-check.mjs +9 -5
- package/src/review/open-findings.mjs +59 -0
- package/src/state/block-end.mjs +290 -0
- package/src/state/block.mjs +103 -39
- package/src/state/brief-status.mjs +131 -0
- package/src/state/config-snapshot.mjs +51 -0
- package/src/state/registry.mjs +35 -4
- package/src/state/run.mjs +10 -1
- package/src/util/breadcrumbs.mjs +1 -0
- package/src/util/forbidden.mjs +36 -1
- package/src/util/gate-token.mjs +8 -0
- package/src/worker/engine.mjs +157 -17
- package/src/worker/loop.mjs +26 -7
- package/src/worker/queue.mjs +36 -10
- package/src/worker/ticket.mjs +46 -6
|
@@ -34,6 +34,16 @@ export const IMMUTABLE_KEYS = Object.freeze({
|
|
|
34
34
|
// B58: the owner's switch past the local per-file review is set before the run starts or not at
|
|
35
35
|
// all, so nobody turns it on (or off) mid-run
|
|
36
36
|
'review.external': 'the outside reviewer is fixed by the owner before the run starts',
|
|
37
|
+
// B62a: every block of the run is isolated the same way — only two blocks that each have their
|
|
38
|
+
// own worktree may share owned paths — so neither key changes mid-run
|
|
39
|
+
'proof.isolation': 'every block of the run is isolated the same way',
|
|
40
|
+
'proof.worktree': 'the worktrees and branches of the open blocks were made with it',
|
|
41
|
+
// B63b: it tightens `block close` (the coder's green test run), so it is authority like
|
|
42
|
+
// `review.external`; the rest of `context` stays live
|
|
43
|
+
'context.require_coder_tests': 'whether close needs the coder\'s green test run is fixed by the owner before the run starts',
|
|
44
|
+
// B64b: the block-end hook runs a command on the owner's machine at every block end, so only
|
|
45
|
+
// the owner sets it, before the run starts
|
|
46
|
+
'hooks.on_block_end': 'it runs a command on the owner\'s machine at every block end; fixed by the owner before the run starts',
|
|
37
47
|
});
|
|
38
48
|
|
|
39
49
|
/** @param {unknown} v @returns {boolean} what `JSON.stringify` drops from an object. */
|
|
@@ -184,3 +194,44 @@ export function externalReviewer(record) {
|
|
|
184
194
|
const name = typeof external.name === 'string' ? external.name.trim() : '';
|
|
185
195
|
return name === '' ? null : name; // trimmed: no row ever carries padding
|
|
186
196
|
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* B63b: whether `block close` needs the coder's `TESTS <command> exit=0` line — true only when the
|
|
200
|
+
* snapshot in force sets `context.require_coder_tests: true` (false for the key absent or false,
|
|
201
|
+
* or a record from before snapshots). The key is immutable ({@link IMMUTABLE_KEYS}), so this is
|
|
202
|
+
* the value `run start` stored; the live config is never read.
|
|
203
|
+
* @param {Record<string, any>} record
|
|
204
|
+
* @returns {boolean}
|
|
205
|
+
* @throws {StateError} `config-snapshot` when the snapshot in force does not match its hash.
|
|
206
|
+
*/
|
|
207
|
+
export function requireCoderTests(record) {
|
|
208
|
+
const context = configInForce(record)?.context;
|
|
209
|
+
return isMapping(context) && context.require_coder_tests === true;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** The default `hooks.on_block_end.timeout_s`. */
|
|
213
|
+
export const HOOK_TIMEOUT_DEFAULT_S = 60;
|
|
214
|
+
/** The schema's bounds of `hooks.on_block_end.timeout_s`. */
|
|
215
|
+
export const HOOK_TIMEOUT_MIN_S = 1;
|
|
216
|
+
export const HOOK_TIMEOUT_MAX_S = 600;
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* `hooks.on_block_end` of the run-start config snapshot (never the live config), or null when it
|
|
220
|
+
* is absent or malformed (the schema refuses a malformed one at `run start`).
|
|
221
|
+
* @param {Record<string, any>} record
|
|
222
|
+
* @returns {{argv: string[], timeoutS: number} | null}
|
|
223
|
+
*/
|
|
224
|
+
export function blockEndHook(record) {
|
|
225
|
+
let hook;
|
|
226
|
+
try {
|
|
227
|
+
hook = configInForce(record)?.hooks?.on_block_end;
|
|
228
|
+
} catch {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
if (hook === null || typeof hook !== 'object') return null;
|
|
232
|
+
const { argv, timeout_s: timeout } = hook;
|
|
233
|
+
if (!Array.isArray(argv) || argv.length === 0 || !argv.every((a) => typeof a === 'string' && a.length > 0)) return null;
|
|
234
|
+
const timeoutS = timeout === undefined ? HOOK_TIMEOUT_DEFAULT_S : timeout;
|
|
235
|
+
if (!Number.isInteger(timeoutS) || timeoutS < HOOK_TIMEOUT_MIN_S || timeoutS > HOOK_TIMEOUT_MAX_S) return null;
|
|
236
|
+
return { argv: [...argv], timeoutS };
|
|
237
|
+
}
|
package/src/state/registry.mjs
CHANGED
|
@@ -34,6 +34,10 @@
|
|
|
34
34
|
* vs `src/**` and vs `src/feature/*.swift` overlap, vs `src/*.mjs` and `src/featureX/*` do not);
|
|
35
35
|
* a `file` entry against a glob overlaps only when the glob matches it (`src/README` vs
|
|
36
36
|
* `src/**\/*.mjs` does not).
|
|
37
|
+
*
|
|
38
|
+
* B62a — between two OPEN BLOCKS the rule is `blocksOverlap`: two blocks that each have their own
|
|
39
|
+
* worktree (`proof.isolation: worktree`) may own the same path; any pair with a shared-tree block
|
|
40
|
+
* in it is checked with `findOverlap` exactly as above.
|
|
37
41
|
*/
|
|
38
42
|
|
|
39
43
|
import { StateError } from './paths.mjs';
|
|
@@ -99,21 +103,24 @@ export function expandBraces(pattern) {
|
|
|
99
103
|
|
|
100
104
|
/**
|
|
101
105
|
* @param {unknown} owned
|
|
106
|
+
* @param {{label?: string, code?: string}} [as] - B60: `block open --tests` checks its test files by
|
|
107
|
+
* the same rules, named `test files` with the code `bad-tests`.
|
|
102
108
|
* @returns {string[]}
|
|
103
109
|
* An EXACT entry (no `*`, `?` or `{`) may contain `[ ] ( ) ! + @ }` — real routes such as
|
|
104
110
|
* `pages/[id].vue`, `app/(group)/page.tsx`, `@types/x.d.ts` — and is matched literally by
|
|
105
111
|
* string equality. A GLOB entry may not: there those characters would be character-class or
|
|
106
112
|
* extglob syntax this matcher does not implement, so it is refused with that reason rather than
|
|
107
113
|
* silently read one way by us and another way by git or a shell.
|
|
108
|
-
* @throws {StateError} `bad-owned`
|
|
114
|
+
* @throws {StateError} with code `as.code` (default `bad-owned`) and a message that starts with
|
|
115
|
+
* `as.label` (default `owned files`) — empty, absolute, `.`/`..`/empty segments (checked on every
|
|
109
116
|
* brace alternative), backslashes, unbalanced braces, or unsupported glob syntax in a glob.
|
|
110
117
|
*/
|
|
111
|
-
export function assertOwned(owned) {
|
|
112
|
-
if (!Array.isArray(owned) || owned.length === 0) throw new StateError(
|
|
118
|
+
export function assertOwned(owned, { label = 'owned files', code = 'bad-owned' } = {}) {
|
|
119
|
+
if (!Array.isArray(owned) || owned.length === 0) throw new StateError(code, `${label}: at least one path is required`);
|
|
113
120
|
for (const entry of owned) {
|
|
114
121
|
/** @param {string} why */
|
|
115
122
|
const refuse = (why) => {
|
|
116
|
-
throw new StateError(
|
|
123
|
+
throw new StateError(code, `${label}: invalid path ${JSON.stringify(entry)}: ${why}`);
|
|
117
124
|
};
|
|
118
125
|
if (typeof entry !== 'string' || entry.length === 0) refuse('must be a non-empty string');
|
|
119
126
|
if (entry.includes('\\')) refuse('backslashes are not allowed (POSIX paths only)');
|
|
@@ -240,6 +247,30 @@ export function findOverlap(ownedA, ownedB, { kindsA, kindsB } = {}) {
|
|
|
240
247
|
return null;
|
|
241
248
|
}
|
|
242
249
|
|
|
250
|
+
/**
|
|
251
|
+
* One open block as the overlap rule sees it: its owned entries, their kinds (B57) and its own
|
|
252
|
+
* worktree, if any (B62a). `worktree` is the run record's `{path, branch, base_sha}` (or, for a
|
|
253
|
+
* block being opened, `{path}` of the tree it is about to get); `null` or absent means it works in
|
|
254
|
+
* the shared tree (export or lock isolation). Every caller passes this one shape.
|
|
255
|
+
* @typedef {{owned_files: ReadonlyArray<string>, owned_kinds?: OwnedKinds, worktree?: {path: string} | null}} OverlapBlock
|
|
256
|
+
*/
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* B62a: the owned-files overlap rule between two open blocks. Two blocks that EACH have their own
|
|
260
|
+
* worktree, at different paths, may own the same path — each edits its own copy on its own branch,
|
|
261
|
+
* and nothing is merged by code-forge — so the pair never overlaps. Two blocks on the SAME tree
|
|
262
|
+
* path edit one tree, and a block without a worktree (export or lock isolation) edits the shared
|
|
263
|
+
* tree: both are checked exactly as before B62a ({@link findOverlap}, B57's kinds included).
|
|
264
|
+
* @param {OverlapBlock} a @param {OverlapBlock} b
|
|
265
|
+
* @returns {[string, string] | null} the first overlapping pair (a's entry, b's entry), or null.
|
|
266
|
+
*/
|
|
267
|
+
export function blocksOverlap(a, b) {
|
|
268
|
+
const treeA = a.worktree ?? null;
|
|
269
|
+
const treeB = b.worktree ?? null;
|
|
270
|
+
if (treeA !== null && treeB !== null && treeA.path !== treeB.path) return null;
|
|
271
|
+
return findOverlap(a.owned_files, b.owned_files, { kindsA: a.owned_kinds, kindsB: b.owned_kinds });
|
|
272
|
+
}
|
|
273
|
+
|
|
243
274
|
/**
|
|
244
275
|
* Does `owned` own repo-relative `file`? A directory-like exact entry owns its path and every path
|
|
245
276
|
* below it, a `file` one (per `kinds`) only itself (B57); a glob entry owns what it matches. A
|
package/src/state/run.mjs
CHANGED
|
@@ -217,7 +217,9 @@ export async function startRun(opts) {
|
|
|
217
217
|
* Fixed keys ({@link IMMUTABLE_KEYS}): every one that changed is refused at once
|
|
218
218
|
* (`immutable-key`, nothing written). A record from before snapshots (no `config`) is checked
|
|
219
219
|
* against what it does store: `engine` and the ledger slug (`effectiveSlug`, computed by the
|
|
220
|
-
* caller the same way `run start` did),
|
|
220
|
+
* caller the same way `run start` did), B58's `review.external.required` must not turn true,
|
|
221
|
+
* B62a's `proof.isolation` must not turn to `worktree`, and B63b's `context.require_coder_tests`
|
|
222
|
+
* must not turn true;
|
|
221
223
|
* it then gets its first snapshot, `changed_keys: null`, `old_hash: null`.
|
|
222
224
|
*
|
|
223
225
|
* No change (same hash) ⇒ nothing written, no row. Otherwise the record is saved FIRST (it is the
|
|
@@ -260,6 +262,13 @@ export async function reloadRun({ runId, config, readPending = () => [], effecti
|
|
|
260
262
|
// B58: a run started without the key has none to keep — turning it on now is a change; an
|
|
261
263
|
// entry that leaves it off (`required` false or absent, as the schema default) is not
|
|
262
264
|
if (config.review?.external?.required === true) frozen.push('review.external');
|
|
265
|
+
// B62a: a run started without a snapshot was isolated by export (or lock); turning worktree
|
|
266
|
+
// isolation on now is a change
|
|
267
|
+
if (config.proof?.isolation === 'worktree') frozen.push('proof.isolation');
|
|
268
|
+
// B63b: likewise, a run started without a snapshot never required the coder's tests
|
|
269
|
+
if (config.context?.require_coder_tests === true) frozen.push('context.require_coder_tests');
|
|
270
|
+
// B64b: and never had a block-end hook
|
|
271
|
+
if (config.hooks?.on_block_end !== undefined) frozen.push('hooks.on_block_end');
|
|
263
272
|
}
|
|
264
273
|
if (frozen.length > 0) {
|
|
265
274
|
const why = frozen.map((key) => `${key} cannot change mid-run (${IMMUTABLE_KEYS[/** @type {keyof typeof IMMUTABLE_KEYS} */ (key)]})`);
|
package/src/util/breadcrumbs.mjs
CHANGED
|
@@ -27,6 +27,7 @@ import path from 'node:path';
|
|
|
27
27
|
export const SUBCOMMANDS = Object.freeze({
|
|
28
28
|
autopilot: ['start', 'status', 'stop'],
|
|
29
29
|
block: ['open', 'attempt', 'rebase', 'claim', 'close', 'stop', 'waive'],
|
|
30
|
+
context: ['pack'],
|
|
30
31
|
gates: ['detect', 'run', 'secret-scan', 'safe-edit', 'scope', 'acceptance', 'transcript-grep'],
|
|
31
32
|
jev: ['ask'],
|
|
32
33
|
keys: ['list', 'set', 'test', 'remove'],
|
package/src/util/forbidden.mjs
CHANGED
|
@@ -277,7 +277,9 @@ export const FORBIDDEN = Object.freeze(
|
|
|
277
277
|
* always appends them, so every list built for a coder carries them.
|
|
278
278
|
* - `code-forge block waive` — waiving a review finding is human-only;
|
|
279
279
|
* - `code-forge autopilot` (any subcommand, B45) — the owner's grant to a delegate;
|
|
280
|
-
* - `--no-require-reviews` — the human's way out of `block close`'s per-file review check
|
|
280
|
+
* - `--no-require-reviews` — the human's way out of `block close`'s per-file review check;
|
|
281
|
+
* - `--remove-worktree` (B62a) — `block close` removing the block's worktree, the orchestrator's
|
|
282
|
+
* alone. `block close` itself is not on this list, so the flag is listed on its own.
|
|
281
283
|
* @type {ReadonlyArray<ForbiddenEntry>}
|
|
282
284
|
*/
|
|
283
285
|
export const CODER_ONLY_FORBIDDEN = Object.freeze(
|
|
@@ -306,6 +308,12 @@ export const CODER_ONLY_FORBIDDEN = Object.freeze(
|
|
|
306
308
|
tokens: ['--no-require-reviews'],
|
|
307
309
|
description: 'Close a block without the per-file review check (--no-require-reviews) — human-only',
|
|
308
310
|
},
|
|
311
|
+
{
|
|
312
|
+
id: 'code-forge-remove-worktree',
|
|
313
|
+
kind: 'contains',
|
|
314
|
+
tokens: ['--remove-worktree'],
|
|
315
|
+
description: "Remove a block's worktree at close (block close --remove-worktree, B62a) — the orchestrator only",
|
|
316
|
+
},
|
|
309
317
|
].map(freezeEntry),
|
|
310
318
|
);
|
|
311
319
|
|
|
@@ -342,6 +350,33 @@ export function mergeForbidden(extraTokens = []) {
|
|
|
342
350
|
return [...FORBIDDEN, ...CODER_ONLY_FORBIDDEN, ...extras];
|
|
343
351
|
}
|
|
344
352
|
|
|
353
|
+
/** B64b: the id of the entry {@link hookForbidden} builds. */
|
|
354
|
+
export const HOOK_FORBIDDEN_ID = 'code-forge-on-block-end-hook';
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* B64b: the owner's `hooks.on_block_end` command as a coder-forbidden `prefix` entry — the hook
|
|
358
|
+
* runs only from the orchestrator's block-end verbs, never for a coder. The tokens are TRIMMED:
|
|
359
|
+
* the command name of `argv[0]` (its basename, as every reading normalizes it), plus `argv[1]`
|
|
360
|
+
* when there is one that is not blank (clause 3, "if any"). `argv[1]` stays ONE token even with
|
|
361
|
+
* inner whitespace — a literal match, as ruled. No entry (`[]`, so a list built with it is
|
|
362
|
+
* unchanged) when there is no hook (`argv` absent, empty, or not all non-empty strings), or when
|
|
363
|
+
* `argv[0]` is blank, names a directory (`/`, `dir/`), or is `.` or `..`. Appended by the caller
|
|
364
|
+
* after the static entries; it never replaces or weakens one.
|
|
365
|
+
* @param {unknown} argv
|
|
366
|
+
* @returns {ForbiddenEntry[]}
|
|
367
|
+
*/
|
|
368
|
+
export function hookForbidden(argv) {
|
|
369
|
+
if (!Array.isArray(argv) || argv.length === 0 || !argv.every((a) => typeof a === 'string' && a.length > 0)) return [];
|
|
370
|
+
const first = argv[0].trim();
|
|
371
|
+
// a command name that is blank, a directory (`/`, `dir/`), `.` or `..` matches nothing a coder runs
|
|
372
|
+
if (first.length === 0 || /[/\\]$/.test(first)) return [];
|
|
373
|
+
const name = commandName(first).trim();
|
|
374
|
+
if (name.length === 0 || name === '.' || name === '..') return [];
|
|
375
|
+
const arg = argv.length > 1 ? argv[1].trim() : '';
|
|
376
|
+
const tokens = arg.length > 0 ? [name, arg] : [name];
|
|
377
|
+
return [freezeEntry({ id: HOOK_FORBIDDEN_ID, kind: 'prefix', tokens, description: 'Run the configured on_block_end hook (hooks.on_block_end) — it runs only at a block end, from the orchestrator\'s verbs' })];
|
|
378
|
+
}
|
|
379
|
+
|
|
345
380
|
// ── Normalization ────────────────────────────────────────────────────────────
|
|
346
381
|
|
|
347
382
|
/** git global options that take the NEXT argv token as their value (when not written `--opt=value`). */
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one argv item a gate uses for the block's related tests. It lives in `util` because two
|
|
3
|
+
* layers need the same value: `gates/expand.mjs` expands it, and `config/validate.mjs` refuses it as
|
|
4
|
+
* a gate's command (`config` may not import `gates`).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** A gate argv item that is EXACTLY this string expands to the block's test files. */
|
|
8
|
+
export const TEST_FILES_TOKEN = '{test_files}';
|
package/src/worker/engine.mjs
CHANGED
|
@@ -40,29 +40,59 @@
|
|
|
40
40
|
* The review NEVER approves an empty or failed session: that is `unavailable` (§4.2 stub guard).
|
|
41
41
|
* A throw inside the round is `unavailable` too, with the state saved as `next: retry` (see
|
|
42
42
|
* `fixLoopRound`): the next ticket re-runs that round, never a fresh round 1.
|
|
43
|
+
* B62b: for a block the run record gives a worktree (`proof.isolation: worktree`) every content
|
|
44
|
+
* read — the file, its diff against the block base, the peers' diffs, the fix hunks and the
|
|
45
|
+
* budget's file set — happens in that tree, against the tree's `base_sha` (`contentRootFor`; the
|
|
46
|
+
* tree comes from the run record, never from the coder-writable ticket), and the packet's facts
|
|
47
|
+
* slot names the tree and its branch. A file changed only in the main tree is never seen. The
|
|
48
|
+
* base, the tree and the facts come from ONE read of the run record. A ticket is `unavailable`
|
|
49
|
+
* with one of these reasons:
|
|
50
|
+
* - `worktree-missing`: the recorded tree is gone;
|
|
51
|
+
* - `worktree-not-dir`: its path is not a directory;
|
|
52
|
+
* - `worktree-malformed`: no absolute path, a branch that is empty or has control characters, or
|
|
53
|
+
* a base_sha that is not a full hex id;
|
|
54
|
+
* - `worktree-branch-mismatch`: the tree is not on its recorded branch;
|
|
55
|
+
* - `worktree-foreign`: the tree belongs to another repository;
|
|
56
|
+
* - `worktree-workspace-missing`: the workspace's subdirectory is not in the tree;
|
|
57
|
+
* - `worktree-mismatch`: the tree the worker hashed the ticket in (`ctx.contentRoot`, from the
|
|
58
|
+
* ticket's `tree` or its absence) is not the record's tree;
|
|
59
|
+
* - `git-failed`: git could not be run on the tree;
|
|
60
|
+
* - `run-record-unreadable`: the record cannot be parsed, or names no workspace.
|
|
61
|
+
* A block without a worktree, or one the run record does not know, reads the main tree as before.
|
|
62
|
+
* B63a: a ticket is also `unavailable` with `context-unreadable` when the config's `context` key
|
|
63
|
+
* is bad or one of its rules or lessons files is missing, outside the repository or unreadable at
|
|
64
|
+
* review time; no session runs and the packet dir is removed. The worker does not retry it (the
|
|
65
|
+
* ticket is done); `review-file` on the same content is a fresh attempt every time (`retry: true`).
|
|
66
|
+
* The tree is passed as `contentRoot`: `repoRoot` stays the main tree, and the fix-loop state
|
|
67
|
+
* stays under the run root.
|
|
43
68
|
*/
|
|
44
69
|
|
|
45
|
-
import { rmSync } from 'node:fs';
|
|
70
|
+
import { realpathSync, rmSync } from 'node:fs';
|
|
46
71
|
import path from 'node:path';
|
|
47
72
|
import { blockKind } from '../decide/escalation.mjs';
|
|
48
73
|
import { askJev } from '../decide/jev-client.mjs';
|
|
49
74
|
import { planAndRecord, tierOf } from '../review/budget.mjs';
|
|
50
75
|
import { budgetFor } from '../review/packet.mjs';
|
|
76
|
+
import { ContextError, reviewDigest } from '../context/pack.mjs';
|
|
51
77
|
import { reviewFile, rulesRisk } from '../review/engine.mjs';
|
|
52
78
|
import { peerDiffReader } from '../review/moved.mjs';
|
|
53
79
|
import { computeFileSet, ownsFile } from '../gates/scope.mjs';
|
|
54
80
|
import { extraRoundsFor, newFileState, reopenForExtraRound, runRound } from '../review/fixloop.mjs';
|
|
55
81
|
import { readRun } from '../state/run.mjs';
|
|
82
|
+
import { StateError } from '../state/paths.mjs';
|
|
83
|
+
import { blockTree, worktreeRecord } from '../proof/worktree.mjs';
|
|
56
84
|
import { ledgerSchemaHistory } from '../review/schema-fallback.mjs';
|
|
57
85
|
import { verifyRow } from '../state/signer.mjs';
|
|
58
86
|
import { redact } from '../util/redact.mjs';
|
|
59
87
|
import { keyedLock } from '../util/locks.mjs';
|
|
60
88
|
import { blockAnchors, blockRungUsed, loadState, saveState } from './review-state.mjs';
|
|
61
|
-
import { assertTicketId } from './ticket.mjs';
|
|
89
|
+
import { assertTicketId, WorkerError } from './ticket.mjs';
|
|
62
90
|
|
|
63
91
|
/**
|
|
64
92
|
* @typedef {object} ReviewContext
|
|
65
|
-
* @property {string} repoRoot
|
|
93
|
+
* @property {string} repoRoot - the main tree (the queue, the worker).
|
|
94
|
+
* @property {string} [contentRoot] - B62b: the tree the ticket's content is read in (the block's
|
|
95
|
+
* worktree); `repoRoot` when absent.
|
|
66
96
|
* @property {string} runId
|
|
67
97
|
* @property {string} runRootDir - the run's temp root (packets and session dirs live under it).
|
|
68
98
|
* @property {Record<string, any>} cfg - the project config.
|
|
@@ -110,14 +140,17 @@ import { assertTicketId } from './ticket.mjs';
|
|
|
110
140
|
* of its owned files. `ownedKinds` is the recorded `owned_kinds` (B57: which exact entries were
|
|
111
141
|
* regular files at `block open`/`claim`), `{}` when there is none (every exact entry directory-like).
|
|
112
142
|
* With no run record the kind is null: `reviewTicket` takes the ticket file's kind.
|
|
113
|
-
*
|
|
143
|
+
* `tree` (B62b) is the recorded `worktree` `{path, branch, base_sha}`, null when the block has
|
|
144
|
+
* none; `base` is then the tree's `base_sha`. A recorded worktree that `worktreeRecord` refuses
|
|
145
|
+
* (path, branch, hex base_sha) throws `worktree-malformed`.
|
|
146
|
+
* @returns {Promise<{base: string | null, level: string, owned: string[], ownedKinds: Record<string, string>, acceptance?: unknown, kind: string | null, tree: WorktreeEntry | null, workspace: string | null}>}
|
|
114
147
|
*/
|
|
115
148
|
export async function blockEntryFor(runId, block) {
|
|
116
149
|
let record;
|
|
117
150
|
try {
|
|
118
151
|
record = await readRun(runId);
|
|
119
152
|
} catch (err) {
|
|
120
|
-
if (/** @type {any} */ (err)?.code === 'no-run') return { base: null, level: 'L2', owned: [], ownedKinds: {}, acceptance: undefined, kind: null };
|
|
153
|
+
if (/** @type {any} */ (err)?.code === 'no-run') return { base: null, level: 'L2', owned: [], ownedKinds: {}, acceptance: undefined, kind: null, tree: null, workspace: null };
|
|
121
154
|
throw new Error('run-record-unreadable');
|
|
122
155
|
}
|
|
123
156
|
if (!record || typeof record !== 'object' || !record.blocks || typeof record.blocks !== 'object') throw new Error('run-record-unreadable');
|
|
@@ -128,7 +161,85 @@ export async function blockEntryFor(runId, block) {
|
|
|
128
161
|
const owned = Array.isArray(entry.owned_files) ? entry.owned_files.filter((f) => typeof f === 'string') : [];
|
|
129
162
|
const kinds = entry.owned_kinds;
|
|
130
163
|
const ownedKinds = kinds && typeof kinds === 'object' && !Array.isArray(kinds) ? kinds : {};
|
|
131
|
-
|
|
164
|
+
/** @type {WorktreeEntry | null} */
|
|
165
|
+
let tree = null;
|
|
166
|
+
if (entry.worktree !== undefined && entry.worktree !== null) {
|
|
167
|
+
try {
|
|
168
|
+
tree = worktreeRecord(entry.worktree);
|
|
169
|
+
} catch {
|
|
170
|
+
throw new Error('worktree-malformed');
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
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 };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** @typedef {{path: string, branch: string, base_sha: string}} WorktreeEntry */
|
|
177
|
+
|
|
178
|
+
/** The refusals of {@link blockTree} a ticket answers with, as its `reason`. */
|
|
179
|
+
const TREE_CODES = new Set(['worktree-malformed', 'worktree-missing', 'worktree-not-dir', 'worktree-branch-mismatch', 'worktree-workspace-missing', 'worktree-foreign', 'git-failed']);
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* B62b: where the content of a ticket of `block` is read — the top of the block's worktree from
|
|
183
|
+
* the run record (`blockTree`: present, a directory, on its recorded branch, of this repository),
|
|
184
|
+
* else `repoRoot`. `repoRoot` exactly as before B62b when there is no run record, the block is not
|
|
185
|
+
* in it, or it has no `worktree`: only a block WITH a worktree record can be refused here.
|
|
186
|
+
* @param {string} runId @param {string} block @param {string} repoRoot
|
|
187
|
+
* @returns {Promise<string>}
|
|
188
|
+
* @throws {WorkerError} a code of {@link TREE_CODES}, or `run-record-unreadable` for a record that
|
|
189
|
+
* cannot be parsed or is not a record; any other failure (an I/O error) is rethrown.
|
|
190
|
+
*/
|
|
191
|
+
export async function contentRootFor(runId, block, repoRoot) {
|
|
192
|
+
let record;
|
|
193
|
+
try {
|
|
194
|
+
record = await readRun(runId);
|
|
195
|
+
} catch (err) {
|
|
196
|
+
if (/** @type {any} */ (err)?.code === 'no-run') return repoRoot;
|
|
197
|
+
if (err instanceof SyntaxError) throw new WorkerError('run-record-unreadable', `block ${block}: the run record cannot be read`);
|
|
198
|
+
throw err;
|
|
199
|
+
}
|
|
200
|
+
if (record === null || typeof record !== 'object') throw new WorkerError('run-record-unreadable', `block ${block}: the run record cannot be read`);
|
|
201
|
+
const blocks = record.blocks;
|
|
202
|
+
const entry = blocks && typeof blocks === 'object' && typeof block === 'string' && Object.hasOwn(blocks, block) ? blocks[block] : null;
|
|
203
|
+
if (entry?.worktree === undefined || entry.worktree === null) return repoRoot;
|
|
204
|
+
return treeTopOf(record.workspace, block, entry.worktree);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The realpath'd top of the recorded worktree `worktree` of `block` (`blockTree`).
|
|
209
|
+
* @param {string} workspace @param {string} block @param {unknown} worktree
|
|
210
|
+
* @returns {Promise<string>}
|
|
211
|
+
* @throws {WorkerError} a code of {@link TREE_CODES}; any other failure is rethrown.
|
|
212
|
+
*/
|
|
213
|
+
async function treeTopOf(workspace, block, worktree) {
|
|
214
|
+
if (typeof workspace !== 'string' || !path.isAbsolute(workspace)) throw new WorkerError('run-record-unreadable', `block ${block}: the run record names no workspace`);
|
|
215
|
+
try {
|
|
216
|
+
return /** @type {import('../proof/worktree.mjs').BlockTree} */ (await blockTree(workspace, { worktree })).top;
|
|
217
|
+
} catch (err) {
|
|
218
|
+
const code = /** @type {any} */ (err)?.code;
|
|
219
|
+
if (err instanceof StateError && TREE_CODES.has(code)) throw new WorkerError(code, `block ${block}: ${err.message}`);
|
|
220
|
+
throw err;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** @param {string} p @returns {string} `p` realpath'd, or `p` itself when it cannot be resolved. */
|
|
225
|
+
export function canonicalPath(p) {
|
|
226
|
+
try {
|
|
227
|
+
return realpathSync(p);
|
|
228
|
+
} catch {
|
|
229
|
+
return p;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* The packet's facts slot: the acceptance clauses, and for a block with a worktree (B62b) a line
|
|
235
|
+
* naming the tree and its branch the file is reviewed in.
|
|
236
|
+
* @param {{acceptance?: unknown, tree: WorktreeEntry | null}} entry @param {Record<string, any> | undefined} cfg
|
|
237
|
+
* @returns {string}
|
|
238
|
+
*/
|
|
239
|
+
function factsFor(entry, cfg) {
|
|
240
|
+
const excerpt = acceptanceExcerpt(entry.acceptance, cfg);
|
|
241
|
+
if (!entry.tree) return excerpt;
|
|
242
|
+
return `${excerpt}\nWorktree: the file is reviewed as it is in the block's worktree ${redact(entry.tree.path)} (branch ${redact(entry.tree.branch)}), against the block base.`;
|
|
132
243
|
}
|
|
133
244
|
|
|
134
245
|
/** The acceptance line written when the block's clauses cannot be read. */
|
|
@@ -213,15 +324,43 @@ export async function reviewTicket(ticket, ctx) {
|
|
|
213
324
|
return { status: 'unavailable', reason: err instanceof Error ? err.message : 'run-record-unreadable', approved: false, engine: 'adaptive', sessions: [] };
|
|
214
325
|
}
|
|
215
326
|
if (entry.kind === null) entry = { ...entry, kind: blockKind({ owned: [ticket.file] }) };
|
|
216
|
-
|
|
327
|
+
// B62b: every content read of this ticket happens in the block's tree (the main tree without
|
|
328
|
+
// one), taken from the SAME entry as the base and the facts. The worker passes the tree it
|
|
329
|
+
// hashed the ticket in as `ctx.contentRoot`; a record that changed since names another tree.
|
|
330
|
+
let contentRoot;
|
|
217
331
|
try {
|
|
332
|
+
contentRoot = entry.tree ? await treeTopOf(entry.workspace, ticket.block, entry.tree) : ctx.repoRoot;
|
|
333
|
+
} catch (err) {
|
|
334
|
+
return { status: 'unavailable', reason: err instanceof WorkerError ? err.code : 'run-record-unreadable', approved: false, engine: 'adaptive', sessions: [] };
|
|
335
|
+
}
|
|
336
|
+
if (ctx.contentRoot !== undefined && canonicalPath(ctx.contentRoot) !== canonicalPath(contentRoot)) {
|
|
337
|
+
return { status: 'unavailable', reason: 'worktree-mismatch', approved: false, engine: 'adaptive', sessions: [] };
|
|
338
|
+
}
|
|
339
|
+
/** @type {ReviewContext} */
|
|
340
|
+
const tctx = { ...ctx, contentRoot };
|
|
341
|
+
const factsExcerpt = factsFor(entry, ctx.cfg);
|
|
342
|
+
const peerDiffs = blockPeerDiffs(contentRoot, { base: entry.base, owned: entry.owned, ownedKinds: entry.ownedKinds, file: ticket.file });
|
|
343
|
+
try {
|
|
344
|
+
// B63a: the `context` rules and lessons, read from the MAIN tree (`ctx.repoRoot`, never the
|
|
345
|
+
// block's worktree) with the config in force for this ticket; '' without a `context` key, so
|
|
346
|
+
// the packet stays as it was. A bad `context` value or a rules/lessons file that is missing,
|
|
347
|
+
// outside the repository or unreadable makes the ticket `unavailable` (`context-unreadable`,
|
|
348
|
+
// re-queued by `review-file` on the same content, as often as asked): no review runs without the rules it was promised.
|
|
349
|
+
let rulesDigest;
|
|
350
|
+
try {
|
|
351
|
+
rulesDigest = reviewDigest({ repoRoot: ctx.repoRoot, cfg: ctx.cfg });
|
|
352
|
+
} catch (err) {
|
|
353
|
+
if (!(err instanceof ContextError)) throw err;
|
|
354
|
+
return { status: 'unavailable', reason: 'context-unreadable', approved: false, engine: 'adaptive', sessions: [] };
|
|
355
|
+
}
|
|
356
|
+
const digest = rulesDigest === '' ? {} : { rulesDigest };
|
|
218
357
|
if (!ctx.key) {
|
|
219
358
|
return await reviewFile(
|
|
220
|
-
{ repoRoot:
|
|
359
|
+
{ repoRoot: contentRoot, file: ticket.file, base: entry.base, cfg: ctx.cfg, kind: entry.kind, workDir, factsExcerpt, ...digest, peerDiffs },
|
|
221
360
|
{ spawn: ctx.spawn, ...(ctx.writeRow ? { writeRow: ctx.writeRow } : {}) },
|
|
222
361
|
);
|
|
223
362
|
}
|
|
224
|
-
return await fixLoopRound(ticket,
|
|
363
|
+
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 });
|
|
225
364
|
} finally {
|
|
226
365
|
rmSync(workDir, { recursive: true, force: true });
|
|
227
366
|
}
|
|
@@ -329,10 +468,10 @@ export function mayTakeRung(state, kind, cfg, rungUsed) {
|
|
|
329
468
|
* first full round, or any round once the rung is gone) runs outside the block lock, in parallel
|
|
330
469
|
* with the block's other files.
|
|
331
470
|
* @param {import('./queue.mjs').Ticket} ticket @param {ReviewContext} ctx
|
|
332
|
-
* @param {{base: string | null, level: string, owned: string[], ownedKinds?: Record<string, string>, blockKindOf: string, key: Buffer, workDir: string, factsExcerpt: string, peerDiffs?: () => Promise<import('../review/moved.mjs').PeerDiff[]>}} opts
|
|
471
|
+
* @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
|
|
333
472
|
* @returns {Promise<ReviewOutcome>}
|
|
334
473
|
*/
|
|
335
|
-
async function fixLoopRound(ticket, ctx, { base, level, owned, ownedKinds, blockKindOf, key, workDir, factsExcerpt, peerDiffs }) {
|
|
474
|
+
async function fixLoopRound(ticket, ctx, { base, level, owned, ownedKinds, blockKindOf, key, workDir, factsExcerpt, rulesDigest = '', peerDiffs }) {
|
|
336
475
|
if (!ctx.readRows || !ctx.writeRow) return { status: 'unavailable', reason: 'no-ledger', approved: false, engine: 'adaptive', sessions: [] };
|
|
337
476
|
const readRows = ctx.readRows;
|
|
338
477
|
/** @type {{outcome: ReviewOutcome} | {round: () => Promise<ReviewOutcome>}} */
|
|
@@ -372,7 +511,7 @@ async function fixLoopRound(ticket, ctx, { base, level, owned, ownedKinds, block
|
|
|
372
511
|
state.l3_rung_used = true; // the orchestrator ran the block's one L3 patch before this ticket
|
|
373
512
|
}
|
|
374
513
|
const fixed = state;
|
|
375
|
-
const round = () => playRound(ticket, ctx, { state: fixed, kind, where, seq: loaded.seq, base, blockKindOf, workDir, factsExcerpt, extraRounds, peerDiffs });
|
|
514
|
+
const round = () => playRound(ticket, ctx, { state: fixed, kind, where, seq: loaded.seq, base, blockKindOf, workDir, factsExcerpt, rulesDigest, extraRounds, peerDiffs });
|
|
376
515
|
// a round that could take the rung keeps the block lock until its state is saved
|
|
377
516
|
if (mayTakeRung(fixed, kind, ctx.cfg, rungUsed)) return { outcome: await round() };
|
|
378
517
|
return { round };
|
|
@@ -383,10 +522,10 @@ async function fixLoopRound(ticket, ctx, { base, level, owned, ownedKinds, block
|
|
|
383
522
|
/**
|
|
384
523
|
* Run the prepared round and save the file's state: the ticket's outcome.
|
|
385
524
|
* @param {import('./queue.mjs').Ticket} ticket @param {ReviewContext} ctx
|
|
386
|
-
* @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, extraRounds?: number, peerDiffs?: () => Promise<import('../review/moved.mjs').PeerDiff[]>}} opts
|
|
525
|
+
* @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
|
|
387
526
|
* @returns {Promise<ReviewOutcome>}
|
|
388
527
|
*/
|
|
389
|
-
async function playRound(ticket, ctx, { state, kind, where, seq, base, blockKindOf, workDir, factsExcerpt, extraRounds = 0, peerDiffs }) {
|
|
528
|
+
async function playRound(ticket, ctx, { state, kind, where, seq, base, blockKindOf, workDir, factsExcerpt, rulesDigest = '', extraRounds = 0, peerDiffs }) {
|
|
390
529
|
/** @type {Record<string, any> | null} */
|
|
391
530
|
let engineOutcome = null;
|
|
392
531
|
let approvalWritten = false;
|
|
@@ -429,18 +568,19 @@ async function playRound(ticket, ctx, { state, kind, where, seq, base, blockKind
|
|
|
429
568
|
await runRound(
|
|
430
569
|
state,
|
|
431
570
|
{
|
|
432
|
-
repoRoot: ctx.repoRoot,
|
|
571
|
+
repoRoot: ctx.contentRoot ?? ctx.repoRoot,
|
|
433
572
|
cfg: ctx.cfg,
|
|
434
573
|
workDir,
|
|
435
574
|
base,
|
|
436
575
|
writeRow,
|
|
437
576
|
factsExcerpt,
|
|
577
|
+
...(rulesDigest !== '' ? { rulesDigest } : {}),
|
|
438
578
|
extraRounds,
|
|
439
579
|
...(peerDiffs ? { peerDiffs } : {}),
|
|
440
580
|
...(jev ? { jev } : {}),
|
|
441
581
|
...(schemaHistory ? { schemaHistory } : {}),
|
|
442
582
|
review: async () => {
|
|
443
|
-
engineOutcome = await reviewFile({ repoRoot: ctx.repoRoot, file: ticket.file, base, cfg: ctx.cfg, kind: blockKindOf, workDir, factsExcerpt, ...(peerDiffs ? { peerDiffs } : {}) }, { spawn: ctx.spawn, writeRow, ...(schemaHistory ? { schemaHistory } : {}) });
|
|
583
|
+
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 } : {}) });
|
|
444
584
|
return engineOutcome;
|
|
445
585
|
},
|
|
446
586
|
spawn: async (opts) => {
|
|
@@ -500,7 +640,7 @@ async function recordBlockBudget(ticket, ctx, { base, owned, ownedKinds }) {
|
|
|
500
640
|
let changed = [];
|
|
501
641
|
if (base !== null && owned.length > 0) {
|
|
502
642
|
try {
|
|
503
|
-
changed = (await computeFileSet({ cwd: ctx.repoRoot, base })).all.filter((f) => ownsFile(owned, f, ownedKinds));
|
|
643
|
+
changed = (await computeFileSet({ cwd: ctx.contentRoot ?? ctx.repoRoot, base })).all.filter((f) => ownsFile(owned, f, ownedKinds));
|
|
504
644
|
} catch {
|
|
505
645
|
// no row on a git failure: a ticket-only forecast would be permanent (the row is written
|
|
506
646
|
// once per block); the block's next keyed ticket retries
|
package/src/worker/loop.mjs
CHANGED
|
@@ -50,7 +50,7 @@ import { keyedLock } from '../util/locks.mjs';
|
|
|
50
50
|
import { writeSafe } from '../util/redact.mjs';
|
|
51
51
|
import { runRoot, setRunRoot } from '../util/tmp.mjs';
|
|
52
52
|
import { openFindings } from '../review/gate-check.mjs';
|
|
53
|
-
import { reviewTicket } from './engine.mjs';
|
|
53
|
+
import { canonicalPath, contentRootFor, reviewTicket } from './engine.mjs';
|
|
54
54
|
import { announceWorker, beatWorker, HEARTBEAT_MS, liveWorker, pendingTickets, readTicket, retractWorker, writeResult } from './queue.mjs';
|
|
55
55
|
import { contentHash, DELETED_HASH, repoRootOf, WorkerError } from './ticket.mjs';
|
|
56
56
|
|
|
@@ -285,13 +285,15 @@ export async function createWorker(opts, deps = {}) {
|
|
|
285
285
|
* One ticket through the engine hook with `tcfg` (the ticket's config snapshot): the unsigned result.
|
|
286
286
|
* @param {import('./queue.mjs').Ticket} ticket @param {Record<string, any>} base
|
|
287
287
|
* @param {Record<string, any>} tcfg
|
|
288
|
+
* @param {string} contentRoot - B62b: the tree the content was hashed in, and is reviewed in.
|
|
288
289
|
* @returns {Promise<Record<string, any>>}
|
|
289
290
|
*/
|
|
290
|
-
async function reviewWith(ticket, base, tcfg) {
|
|
291
|
+
async function reviewWith(ticket, base, tcfg, contentRoot) {
|
|
291
292
|
const timeoutS = Number.isInteger(tcfg?.review?.session_timeout_s) && tcfg.review.session_timeout_s > 0 ? tcfg.review.session_timeout_s : DEFAULT_SESSION_TIMEOUT_S;
|
|
292
293
|
/** @type {import('./engine.mjs').ReviewContext} */
|
|
293
294
|
const ctx = {
|
|
294
295
|
repoRoot,
|
|
296
|
+
contentRoot,
|
|
295
297
|
runId,
|
|
296
298
|
runRootDir,
|
|
297
299
|
cfg: tcfg,
|
|
@@ -360,15 +362,32 @@ export async function createWorker(opts, deps = {}) {
|
|
|
360
362
|
*/
|
|
361
363
|
async function reviewOne(id, ticket) {
|
|
362
364
|
const base = { event: 'review.result', block: ticket.block, file: ticket.file, content_hash: ticket.content_hash };
|
|
363
|
-
|
|
365
|
+
if (ticket.run !== runId) return { ...base, status: 'refused', reason: 'other-run', approved: false };
|
|
366
|
+
// B62b: the tree the content is hashed AND reviewed in — a block's worktree (from the run
|
|
367
|
+
// record, as review-file did), else the main tree. A tree that cannot be used (gone, malformed)
|
|
368
|
+
// or a block entry that cannot be read is `unavailable` with its code: never approval, and a
|
|
369
|
+
// later ticket for the same content runs again once it is fixed.
|
|
370
|
+
let contentRoot;
|
|
371
|
+
try {
|
|
372
|
+
contentRoot = await contentRootFor(runId, ticket.block, repoRoot);
|
|
373
|
+
} catch (err) {
|
|
374
|
+
// only the codes contentRootFor answers with; an I/O failure goes to runOne (`worker-error`)
|
|
375
|
+
if (!(err instanceof WorkerError)) throw err;
|
|
376
|
+
return { ...base, status: 'unavailable', reason: err.code, approved: false };
|
|
377
|
+
}
|
|
378
|
+
// a ticket was hashed in the tree it names (the main tree when it names none): never review it
|
|
379
|
+
// in another one — also a ticket with no tree for a block the record gives a worktree
|
|
380
|
+
const hashedIn = typeof ticket.tree === 'string' ? ticket.tree : repoRoot;
|
|
381
|
+
if (canonicalPath(hashedIn) !== canonicalPath(contentRoot)) return { ...base, status: 'unavailable', reason: 'worktree-mismatch', approved: false };
|
|
382
|
+
/** @type {string} */
|
|
364
383
|
let current;
|
|
365
384
|
try {
|
|
366
|
-
current =
|
|
385
|
+
current = contentHash(contentRoot, ticket.file);
|
|
367
386
|
} catch (err) {
|
|
368
387
|
// the path no longer passes the on-disk check, or the file cannot be read
|
|
369
|
-
|
|
388
|
+
const reason = err instanceof WorkerError ? err.code : 'unreadable';
|
|
389
|
+
return { ...base, status: 'refused', reason, approved: false };
|
|
370
390
|
}
|
|
371
|
-
if (ticket.run !== runId) return { ...base, status: 'refused', reason: 'other-run', approved: false };
|
|
372
391
|
if (current === DELETED_HASH && ticket.content_hash !== DELETED_HASH) return { ...base, status: 'stale', reason: 'missing-file', approved: false };
|
|
373
392
|
if (current !== ticket.content_hash) return { ...base, status: 'stale', approved: false };
|
|
374
393
|
let ticketCfg;
|
|
@@ -377,7 +396,7 @@ export async function createWorker(opts, deps = {}) {
|
|
|
377
396
|
} catch {
|
|
378
397
|
return { ...base, status: 'unavailable', reason: 'config-snapshot', approved: false };
|
|
379
398
|
}
|
|
380
|
-
return reviewWith(ticket, base, ticketCfg);
|
|
399
|
+
return reviewWith(ticket, base, ticketCfg, contentRoot);
|
|
381
400
|
}
|
|
382
401
|
|
|
383
402
|
/**
|