@codedology/code-forge 0.7.7 → 0.9.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 +97 -0
- package/README.md +68 -2
- package/docs/README.md +2 -1
- package/docs/reference/blocks.json +390 -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 +45 -7
- package/skill/references/ledger.md +8 -4
- package/skill/references/proof.md +44 -3
- package/skill/references/review.md +25 -5
- package/skill/references/security.md +1 -1
- package/skill/scripts/forge +1 -1
- package/skill/templates/coder-brief.md +19 -2
- package/src/autopilot/binnacle.mjs +21 -3
- package/src/cli/block.mjs +427 -41
- 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 +324 -18
- package/src/cli/status.mjs +212 -0
- package/src/config/validate.mjs +45 -11
- package/src/context/pack.mjs +319 -0
- package/src/doctor/index.mjs +5 -4
- package/src/doctor/local.mjs +91 -3
- 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/ledger/write.mjs +7 -1
- package/src/proof/export.mjs +93 -10
- package/src/proof/lock.mjs +28 -5
- package/src/proof/worktree.mjs +482 -0
- package/src/review/answers.mjs +445 -0
- package/src/review/deletion.mjs +106 -0
- package/src/review/engine.mjs +36 -2
- package/src/review/fixloop.mjs +25 -15
- package/src/review/gate-check.mjs +384 -83
- package/src/review/open-findings.mjs +59 -0
- package/src/review/reopen.mjs +258 -0
- package/src/review/stop-exits.mjs +15 -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 +46 -1
- package/src/util/gate-token.mjs +8 -0
- package/src/util/tmp.mjs +16 -4
- package/src/worker/engine.mjs +230 -24
- package/src/worker/loop.mjs +29 -8
- package/src/worker/queue.mjs +296 -15
- package/src/worker/review-state.mjs +346 -51
- package/src/worker/ticket.mjs +46 -6
package/src/util/tmp.mjs
CHANGED
|
@@ -130,18 +130,30 @@ export function tmpBase(root) {
|
|
|
130
130
|
}
|
|
131
131
|
|
|
132
132
|
/**
|
|
133
|
-
*
|
|
134
|
-
*
|
|
133
|
+
* The run root's path `<base>/<runId>`, without creating anything (B69: doctor looks for old
|
|
134
|
+
* review-state files there). The same run id check as {@link runRoot}.
|
|
135
135
|
* @param {string} runId
|
|
136
136
|
* @param {{root?: string}} [opts]
|
|
137
137
|
* @returns {string}
|
|
138
|
+
* @throws {TypeError} a bad run id, or a `root` that is not absolute.
|
|
138
139
|
*/
|
|
139
|
-
export function
|
|
140
|
+
export function runRootPath(runId, opts = {}) {
|
|
140
141
|
if (typeof runId !== 'string' || !RUN_ID.test(runId)) {
|
|
141
142
|
throw new TypeError('tmp: runId must be one path segment of letters, digits, ".", "_" or "-"');
|
|
142
143
|
}
|
|
144
|
+
return path.join(tmpBase(opts.root), runId);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Create (if needed) the run root `<base>/<runId>/`, record this process as its owner, and
|
|
149
|
+
* return its real absolute path.
|
|
150
|
+
* @param {string} runId
|
|
151
|
+
* @param {{root?: string}} [opts]
|
|
152
|
+
* @returns {string}
|
|
153
|
+
*/
|
|
154
|
+
export function runRoot(runId, opts = {}) {
|
|
155
|
+
const dir = runRootPath(runId, opts);
|
|
143
156
|
const base = tmpBase(opts.root);
|
|
144
|
-
const dir = path.join(base, runId);
|
|
145
157
|
// A parallel sweep that claimed a dead root of this name renames it away (see the claim rule)
|
|
146
158
|
// between our mkdir and our write: the directory is then "missing" or the write fails with
|
|
147
159
|
// ENOENT, and the retry creates a fresh directory in its place. A tombstone itself can never be
|
package/src/worker/engine.mjs
CHANGED
|
@@ -17,6 +17,13 @@
|
|
|
17
17
|
* - `stopped` (`review_cap`, `l3_patch_exhausted`, `split_required`) ⇒ no session: the result
|
|
18
18
|
* repeats the stop — only the human moves on (§4.11 rule 5). A `split_required` result (round 1
|
|
19
19
|
* or a recheck stop) carries `tokens_in` and `budget` (B54).
|
|
20
|
+
* A state that does not load is refused with no session: `review-state-tampered` (a wrong file or
|
|
21
|
+
* anchor) or `review-state-lost` (the file is gone and its anchor has no `state_body`). A file
|
|
22
|
+
* that is gone but whose anchor carries the signed body is `restored` (B68): saved as a new seq,
|
|
23
|
+
* one signed `review.state_restored {block, file, seq, from_seq}` row, then the round runs as if
|
|
24
|
+
* it had loaded. B69: the state file lives under the code-forge home (`runs/<run>/review-state/`),
|
|
25
|
+
* not the temp root; a valid file still at the old temp path is moved there on its first load
|
|
26
|
+
* (status `ok`, no restored row).
|
|
20
27
|
* The round's outcome: `approved: true` ONLY when the loop is `complete` AND its signed
|
|
21
28
|
* `review.approved` row (keyed `run, block, file, content_hash` = the ticket's hash) was written;
|
|
22
29
|
* else `findings` = the open fix list, `next` = what the orchestrator does (`fix` at `level`,
|
|
@@ -40,31 +47,79 @@
|
|
|
40
47
|
* The review NEVER approves an empty or failed session: that is `unavailable` (§4.2 stub guard).
|
|
41
48
|
* A throw inside the round is `unavailable` too, with the state saved as `next: retry` (see
|
|
42
49
|
* `fixLoopRound`): the next ticket re-runs that round, never a fresh round 1.
|
|
50
|
+
* B62b: for a block the run record gives a worktree (`proof.isolation: worktree`) every content
|
|
51
|
+
* read — the file, its diff against the block base, the peers' diffs, the fix hunks and the
|
|
52
|
+
* budget's file set — happens in that tree, against the tree's `base_sha` (`contentRootFor`; the
|
|
53
|
+
* tree comes from the run record, never from the coder-writable ticket), and the packet's facts
|
|
54
|
+
* slot names the tree and its branch. A file changed only in the main tree is never seen. The
|
|
55
|
+
* base, the tree and the facts come from ONE read of the run record. A ticket is `unavailable`
|
|
56
|
+
* with one of these reasons:
|
|
57
|
+
* - `worktree-missing`: the recorded tree is gone;
|
|
58
|
+
* - `worktree-not-dir`: its path is not a directory;
|
|
59
|
+
* - `worktree-malformed`: no absolute path, a branch that is empty or has control characters, or
|
|
60
|
+
* a base_sha that is not a full hex id;
|
|
61
|
+
* - `worktree-branch-mismatch`: the tree is not on its recorded branch;
|
|
62
|
+
* - `worktree-foreign`: the tree belongs to another repository;
|
|
63
|
+
* - `worktree-workspace-missing`: the workspace's subdirectory is not in the tree;
|
|
64
|
+
* - `worktree-mismatch`: the tree the worker hashed the ticket in (`ctx.contentRoot`, from the
|
|
65
|
+
* ticket's `tree` or its absence) is not the record's tree;
|
|
66
|
+
* - `git-failed`: git could not be run on the tree;
|
|
67
|
+
* - `run-record-unreadable`: the record cannot be parsed, or names no workspace.
|
|
68
|
+
* A block without a worktree, or one the run record does not know, reads the main tree as before.
|
|
69
|
+
* B63a: a ticket is also `unavailable` with `context-unreadable` when the config's `context` key
|
|
70
|
+
* is bad or one of its rules or lessons files is missing, outside the repository or unreadable at
|
|
71
|
+
* review time; no session runs and the packet dir is removed. The worker does not retry it (the
|
|
72
|
+
* ticket is done); `review-file` on the same content is a fresh attempt every time (`retry: true`).
|
|
73
|
+
* The tree is passed as `contentRoot`: `repoRoot` stays the main tree, and the fix-loop state
|
|
74
|
+
* stays under the run root.
|
|
75
|
+
* B72: a verified `review.reopened {by: human}` row of this run for the file whose `at_seq` is the
|
|
76
|
+
* file's latest anchor seq (`reopenRowFor`, `review/reopen.mjs`) restarts a `stopped` file whose
|
|
77
|
+
* content changed since the stop, or a `lost` one: a new state (round 1, a FULL review with no
|
|
78
|
+
* `reviewed_content`, the level from the run record), saved at `at_seq + 1`, so the row works
|
|
79
|
+
* once; no `review.state_restored` row then. The block's L3 rung stays spent (it is read from the
|
|
80
|
+
* anchors). A row that does not verify, of another run, not `by: human`, at another seq, or a
|
|
81
|
+
* stopped file on the stop's own content changes nothing: the stop (or the lost refusal) repeats.
|
|
82
|
+
* Every stop records the ticket's content hash as the state's `stop_content_hash` (signed with
|
|
83
|
+
* the state), the content `block reopen` and this check compare against.
|
|
84
|
+
* B71: a ticket with `kind: 'answers'` (`review-file --answers`) runs no fix-loop round: after the
|
|
85
|
+
* same run-record, tree and mismatch checks it goes to `reviewAnswers` (`review/answers.mjs`) —
|
|
86
|
+
* one fresh L2 session that judges whether the answering file resolves the named open findings of
|
|
87
|
+
* the source, with signed `review.answered` rows for the resolved ones; the source's fix-loop
|
|
88
|
+
* state is neither read for a round nor saved.
|
|
43
89
|
*/
|
|
44
90
|
|
|
45
|
-
import { rmSync } from 'node:fs';
|
|
91
|
+
import { realpathSync, rmSync } from 'node:fs';
|
|
46
92
|
import path from 'node:path';
|
|
47
93
|
import { blockKind } from '../decide/escalation.mjs';
|
|
48
94
|
import { askJev } from '../decide/jev-client.mjs';
|
|
49
95
|
import { planAndRecord, tierOf } from '../review/budget.mjs';
|
|
50
96
|
import { budgetFor } from '../review/packet.mjs';
|
|
97
|
+
import { ContextError, reviewDigest } from '../context/pack.mjs';
|
|
51
98
|
import { reviewFile, rulesRisk } from '../review/engine.mjs';
|
|
99
|
+
import { reviewAnswers } from '../review/answers.mjs';
|
|
52
100
|
import { peerDiffReader } from '../review/moved.mjs';
|
|
53
101
|
import { computeFileSet, ownsFile } from '../gates/scope.mjs';
|
|
54
102
|
import { extraRoundsFor, newFileState, reopenForExtraRound, runRound } from '../review/fixloop.mjs';
|
|
103
|
+
import { reopenRowFor } from '../review/reopen.mjs';
|
|
55
104
|
import { readRun } from '../state/run.mjs';
|
|
105
|
+
import { StateError } from '../state/paths.mjs';
|
|
106
|
+
import { blockTree, worktreeRecord } from '../proof/worktree.mjs';
|
|
56
107
|
import { ledgerSchemaHistory } from '../review/schema-fallback.mjs';
|
|
57
108
|
import { verifyRow } from '../state/signer.mjs';
|
|
58
109
|
import { redact } from '../util/redact.mjs';
|
|
59
110
|
import { keyedLock } from '../util/locks.mjs';
|
|
60
|
-
import { blockAnchors, blockRungUsed, loadState, saveState } from './review-state.mjs';
|
|
61
|
-
import {
|
|
111
|
+
import { blockAnchors, blockRungUsed, loadState, RESTORED_EVENT, saveState } from './review-state.mjs';
|
|
112
|
+
import { assertAnswers } from './queue.mjs';
|
|
113
|
+
import { assertTicketId, contentHash, readRegularFileNoFollow, WorkerError } from './ticket.mjs';
|
|
62
114
|
|
|
63
115
|
/**
|
|
64
116
|
* @typedef {object} ReviewContext
|
|
65
|
-
* @property {string} repoRoot
|
|
117
|
+
* @property {string} repoRoot - the main tree (the queue, the worker).
|
|
118
|
+
* @property {string} [contentRoot] - B62b: the tree the ticket's content is read in (the block's
|
|
119
|
+
* worktree); `repoRoot` when absent.
|
|
66
120
|
* @property {string} runId
|
|
67
|
-
* @property {string} runRootDir - the run's temp root (packets and session dirs live under it
|
|
121
|
+
* @property {string} runRootDir - the run's temp root (packets and session dirs live under it; the
|
|
122
|
+
* review state no longer does — B69 — it is only read there once, to move an old file).
|
|
68
123
|
* @property {Record<string, any>} cfg - the project config.
|
|
69
124
|
* @property {string | null} jevKey - resolved once by the worker through B2's chain; never print.
|
|
70
125
|
* @property {(opts: Record<string, any>) => Promise<Record<string, any>>} spawn - one isolated
|
|
@@ -86,6 +141,7 @@ import { assertTicketId } from './ticket.mjs';
|
|
|
86
141
|
* @property {string} engine - `adaptive` or `consensus`.
|
|
87
142
|
* @property {Array<Record<string, any>>} sessions - one summary per session run.
|
|
88
143
|
* @property {string} [reason] - why it is not `reviewed` (stub-guard reason, `needs_file-refused`, …).
|
|
144
|
+
* @property {string} [message] - a refusal's human-readable explanation (`review-state-lost`).
|
|
89
145
|
* @property {Array<Record<string, any>>} [findings] - the open fix list (`reviewed` / `stopped`).
|
|
90
146
|
* @property {number} [round] - the fix-loop round this ticket ran (patch_check rounds not counted).
|
|
91
147
|
* @property {string} [kind] - `full`, `recheck` or `patch_check`.
|
|
@@ -110,14 +166,17 @@ import { assertTicketId } from './ticket.mjs';
|
|
|
110
166
|
* of its owned files. `ownedKinds` is the recorded `owned_kinds` (B57: which exact entries were
|
|
111
167
|
* regular files at `block open`/`claim`), `{}` when there is none (every exact entry directory-like).
|
|
112
168
|
* With no run record the kind is null: `reviewTicket` takes the ticket file's kind.
|
|
113
|
-
*
|
|
169
|
+
* `tree` (B62b) is the recorded `worktree` `{path, branch, base_sha}`, null when the block has
|
|
170
|
+
* none; `base` is then the tree's `base_sha`. A recorded worktree that `worktreeRecord` refuses
|
|
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}>}
|
|
114
173
|
*/
|
|
115
174
|
export async function blockEntryFor(runId, block) {
|
|
116
175
|
let record;
|
|
117
176
|
try {
|
|
118
177
|
record = await readRun(runId);
|
|
119
178
|
} catch (err) {
|
|
120
|
-
if (/** @type {any} */ (err)?.code === 'no-run') return { base: null, level: 'L2', owned: [], ownedKinds: {}, acceptance: undefined, kind: null };
|
|
179
|
+
if (/** @type {any} */ (err)?.code === 'no-run') return { base: null, level: 'L2', owned: [], ownedKinds: {}, acceptance: undefined, kind: null, tree: null, workspace: null };
|
|
121
180
|
throw new Error('run-record-unreadable');
|
|
122
181
|
}
|
|
123
182
|
if (!record || typeof record !== 'object' || !record.blocks || typeof record.blocks !== 'object') throw new Error('run-record-unreadable');
|
|
@@ -128,7 +187,85 @@ export async function blockEntryFor(runId, block) {
|
|
|
128
187
|
const owned = Array.isArray(entry.owned_files) ? entry.owned_files.filter((f) => typeof f === 'string') : [];
|
|
129
188
|
const kinds = entry.owned_kinds;
|
|
130
189
|
const ownedKinds = kinds && typeof kinds === 'object' && !Array.isArray(kinds) ? kinds : {};
|
|
131
|
-
|
|
190
|
+
/** @type {WorktreeEntry | null} */
|
|
191
|
+
let tree = null;
|
|
192
|
+
if (entry.worktree !== undefined && entry.worktree !== null) {
|
|
193
|
+
try {
|
|
194
|
+
tree = worktreeRecord(entry.worktree);
|
|
195
|
+
} catch {
|
|
196
|
+
throw new Error('worktree-malformed');
|
|
197
|
+
}
|
|
198
|
+
}
|
|
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
|
+
}
|
|
201
|
+
|
|
202
|
+
/** @typedef {{path: string, branch: string, base_sha: string}} WorktreeEntry */
|
|
203
|
+
|
|
204
|
+
/** The refusals of {@link blockTree} a ticket answers with, as its `reason`. */
|
|
205
|
+
const TREE_CODES = new Set(['worktree-malformed', 'worktree-missing', 'worktree-not-dir', 'worktree-branch-mismatch', 'worktree-workspace-missing', 'worktree-foreign', 'git-failed']);
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* B62b: where the content of a ticket of `block` is read — the top of the block's worktree from
|
|
209
|
+
* the run record (`blockTree`: present, a directory, on its recorded branch, of this repository),
|
|
210
|
+
* else `repoRoot`. `repoRoot` exactly as before B62b when there is no run record, the block is not
|
|
211
|
+
* in it, or it has no `worktree`: only a block WITH a worktree record can be refused here.
|
|
212
|
+
* @param {string} runId @param {string} block @param {string} repoRoot
|
|
213
|
+
* @returns {Promise<string>}
|
|
214
|
+
* @throws {WorkerError} a code of {@link TREE_CODES}, or `run-record-unreadable` for a record that
|
|
215
|
+
* cannot be parsed or is not a record; any other failure (an I/O error) is rethrown.
|
|
216
|
+
*/
|
|
217
|
+
export async function contentRootFor(runId, block, repoRoot) {
|
|
218
|
+
let record;
|
|
219
|
+
try {
|
|
220
|
+
record = await readRun(runId);
|
|
221
|
+
} catch (err) {
|
|
222
|
+
if (/** @type {any} */ (err)?.code === 'no-run') return repoRoot;
|
|
223
|
+
if (err instanceof SyntaxError) throw new WorkerError('run-record-unreadable', `block ${block}: the run record cannot be read`);
|
|
224
|
+
throw err;
|
|
225
|
+
}
|
|
226
|
+
if (record === null || typeof record !== 'object') throw new WorkerError('run-record-unreadable', `block ${block}: the run record cannot be read`);
|
|
227
|
+
const blocks = record.blocks;
|
|
228
|
+
const entry = blocks && typeof blocks === 'object' && typeof block === 'string' && Object.hasOwn(blocks, block) ? blocks[block] : null;
|
|
229
|
+
if (entry?.worktree === undefined || entry.worktree === null) return repoRoot;
|
|
230
|
+
return treeTopOf(record.workspace, block, entry.worktree);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* The realpath'd top of the recorded worktree `worktree` of `block` (`blockTree`).
|
|
235
|
+
* @param {string} workspace @param {string} block @param {unknown} worktree
|
|
236
|
+
* @returns {Promise<string>}
|
|
237
|
+
* @throws {WorkerError} a code of {@link TREE_CODES}; any other failure is rethrown.
|
|
238
|
+
*/
|
|
239
|
+
async function treeTopOf(workspace, block, worktree) {
|
|
240
|
+
if (typeof workspace !== 'string' || !path.isAbsolute(workspace)) throw new WorkerError('run-record-unreadable', `block ${block}: the run record names no workspace`);
|
|
241
|
+
try {
|
|
242
|
+
return /** @type {import('../proof/worktree.mjs').BlockTree} */ (await blockTree(workspace, { worktree })).top;
|
|
243
|
+
} catch (err) {
|
|
244
|
+
const code = /** @type {any} */ (err)?.code;
|
|
245
|
+
if (err instanceof StateError && TREE_CODES.has(code)) throw new WorkerError(code, `block ${block}: ${err.message}`);
|
|
246
|
+
throw err;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** @param {string} p @returns {string} `p` realpath'd, or `p` itself when it cannot be resolved. */
|
|
251
|
+
export function canonicalPath(p) {
|
|
252
|
+
try {
|
|
253
|
+
return realpathSync(p);
|
|
254
|
+
} catch {
|
|
255
|
+
return p;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The packet's facts slot: the acceptance clauses, and for a block with a worktree (B62b) a line
|
|
261
|
+
* naming the tree and its branch the file is reviewed in.
|
|
262
|
+
* @param {{acceptance?: unknown, tree: WorktreeEntry | null}} entry @param {Record<string, any> | undefined} cfg
|
|
263
|
+
* @returns {string}
|
|
264
|
+
*/
|
|
265
|
+
function factsFor(entry, cfg) {
|
|
266
|
+
const excerpt = acceptanceExcerpt(entry.acceptance, cfg);
|
|
267
|
+
if (!entry.tree) return excerpt;
|
|
268
|
+
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
269
|
}
|
|
133
270
|
|
|
134
271
|
/** The acceptance line written when the block's clauses cannot be read. */
|
|
@@ -213,15 +350,51 @@ export async function reviewTicket(ticket, ctx) {
|
|
|
213
350
|
return { status: 'unavailable', reason: err instanceof Error ? err.message : 'run-record-unreadable', approved: false, engine: 'adaptive', sessions: [] };
|
|
214
351
|
}
|
|
215
352
|
if (entry.kind === null) entry = { ...entry, kind: blockKind({ owned: [ticket.file] }) };
|
|
216
|
-
|
|
353
|
+
// B62b: every content read of this ticket happens in the block's tree (the main tree without
|
|
354
|
+
// one), taken from the SAME entry as the base and the facts. The worker passes the tree it
|
|
355
|
+
// hashed the ticket in as `ctx.contentRoot`; a record that changed since names another tree.
|
|
356
|
+
let contentRoot;
|
|
357
|
+
try {
|
|
358
|
+
contentRoot = entry.tree ? await treeTopOf(entry.workspace, ticket.block, entry.tree) : ctx.repoRoot;
|
|
359
|
+
} catch (err) {
|
|
360
|
+
return { status: 'unavailable', reason: err instanceof WorkerError ? err.code : 'run-record-unreadable', approved: false, engine: 'adaptive', sessions: [] };
|
|
361
|
+
}
|
|
362
|
+
if (ctx.contentRoot !== undefined && canonicalPath(ctx.contentRoot) !== canonicalPath(contentRoot)) {
|
|
363
|
+
return { status: 'unavailable', reason: 'worktree-mismatch', approved: false, engine: 'adaptive', sessions: [] };
|
|
364
|
+
}
|
|
365
|
+
/** @type {ReviewContext} */
|
|
366
|
+
const tctx = { ...ctx, contentRoot };
|
|
367
|
+
if (ticket.kind === 'answers') {
|
|
368
|
+
// B71: an answers ticket is no review round: one answers session, the fix-loop state untouched
|
|
369
|
+
try {
|
|
370
|
+
return await reviewAnswers(ticket, tctx, { entry, workDir, io: { readFile: readRegularFileNoFollow, loadState, assertAnswers } });
|
|
371
|
+
} finally {
|
|
372
|
+
rmSync(workDir, { recursive: true, force: true });
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
const factsExcerpt = factsFor(entry, ctx.cfg);
|
|
376
|
+
const peerDiffs = blockPeerDiffs(contentRoot, { base: entry.base, owned: entry.owned, ownedKinds: entry.ownedKinds, file: ticket.file });
|
|
217
377
|
try {
|
|
378
|
+
// B63a: the `context` rules and lessons, read from the MAIN tree (`ctx.repoRoot`, never the
|
|
379
|
+
// block's worktree) with the config in force for this ticket; '' without a `context` key, so
|
|
380
|
+
// the packet stays as it was. A bad `context` value or a rules/lessons file that is missing,
|
|
381
|
+
// outside the repository or unreadable makes the ticket `unavailable` (`context-unreadable`,
|
|
382
|
+
// re-queued by `review-file` on the same content, as often as asked): no review runs without the rules it was promised.
|
|
383
|
+
let rulesDigest;
|
|
384
|
+
try {
|
|
385
|
+
rulesDigest = reviewDigest({ repoRoot: ctx.repoRoot, cfg: ctx.cfg });
|
|
386
|
+
} catch (err) {
|
|
387
|
+
if (!(err instanceof ContextError)) throw err;
|
|
388
|
+
return { status: 'unavailable', reason: 'context-unreadable', approved: false, engine: 'adaptive', sessions: [] };
|
|
389
|
+
}
|
|
390
|
+
const digest = rulesDigest === '' ? {} : { rulesDigest };
|
|
218
391
|
if (!ctx.key) {
|
|
219
392
|
return await reviewFile(
|
|
220
|
-
{ repoRoot:
|
|
393
|
+
{ repoRoot: contentRoot, file: ticket.file, base: entry.base, cfg: ctx.cfg, kind: entry.kind, workDir, factsExcerpt, ...digest, peerDiffs },
|
|
221
394
|
{ spawn: ctx.spawn, ...(ctx.writeRow ? { writeRow: ctx.writeRow } : {}) },
|
|
222
395
|
);
|
|
223
396
|
}
|
|
224
|
-
return await fixLoopRound(ticket,
|
|
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 });
|
|
225
398
|
} finally {
|
|
226
399
|
rmSync(workDir, { recursive: true, force: true });
|
|
227
400
|
}
|
|
@@ -274,6 +447,9 @@ export function blockPeerDiffs(repoRoot, { base, owned, ownedKinds, file }) {
|
|
|
274
447
|
*/
|
|
275
448
|
export const blockLockKey = (runId, block) => `block:${runId}\0${block}`;
|
|
276
449
|
|
|
450
|
+
/** B68: the `review-state-lost` refusal's message (the file is gone and the ledger has no body). */
|
|
451
|
+
export const LOST_MESSAGE = 'the review state file is gone and the ledger cannot rebuild it (its anchor carries no state_body); the file is not reviewed again from round 1 — the owner can run `code-forge block reopen`';
|
|
452
|
+
|
|
277
453
|
/**
|
|
278
454
|
* Whether the block's one L3 rung is taken: a verified anchor says so (`blockRungUsed`), OR another
|
|
279
455
|
* file of the block has a saved state whose round decided `next: patch` — that round took the rung,
|
|
@@ -290,11 +466,12 @@ export function rungTaken(where) {
|
|
|
290
466
|
for (const file of siblings) {
|
|
291
467
|
let other;
|
|
292
468
|
try {
|
|
293
|
-
other = loadState({ ...where, file });
|
|
469
|
+
other = loadState({ ...where, file, migrate: false }); // B69: a sibling's old file is read, never moved here
|
|
294
470
|
} catch {
|
|
295
471
|
return { status: 'ok', used: true };
|
|
296
472
|
}
|
|
297
|
-
|
|
473
|
+
// a sibling that would be restored (B68) counts by its rebuilt state; lost or tampered fails closed
|
|
474
|
+
if ((other.status !== 'ok' && other.status !== 'restored') || other.state?.next?.action === 'patch') return { status: 'ok', used: true };
|
|
298
475
|
}
|
|
299
476
|
return rung;
|
|
300
477
|
}
|
|
@@ -329,10 +506,10 @@ export function mayTakeRung(state, kind, cfg, rungUsed) {
|
|
|
329
506
|
* first full round, or any round once the rung is gone) runs outside the block lock, in parallel
|
|
330
507
|
* with the block's other files.
|
|
331
508
|
* @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
|
|
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
|
|
333
510
|
* @returns {Promise<ReviewOutcome>}
|
|
334
511
|
*/
|
|
335
|
-
async function fixLoopRound(ticket, ctx, { base, level, owned, ownedKinds, blockKindOf, key, workDir, factsExcerpt, peerDiffs }) {
|
|
512
|
+
async function fixLoopRound(ticket, ctx, { base, level, owned, ownedKinds, blockKindOf, key, workDir, factsExcerpt, rulesDigest = '', peerDiffs }) {
|
|
336
513
|
if (!ctx.readRows || !ctx.writeRow) return { status: 'unavailable', reason: 'no-ledger', approved: false, engine: 'adaptive', sessions: [] };
|
|
337
514
|
const readRows = ctx.readRows;
|
|
338
515
|
/** @type {{outcome: ReviewOutcome} | {round: () => Promise<ReviewOutcome>}} */
|
|
@@ -349,11 +526,34 @@ async function fixLoopRound(ticket, ctx, { base, level, owned, ownedKinds, block
|
|
|
349
526
|
await recordBlockBudget(ticket, ctx, { base, owned, ownedKinds });
|
|
350
527
|
}
|
|
351
528
|
const where = { runRootDir: ctx.runRootDir, runId: ctx.runId, block: ticket.block, file: ticket.file, key, rows };
|
|
352
|
-
const loaded = loadState(where);
|
|
529
|
+
const loaded = loadState({ ...where, contentRoot: ctx.contentRoot ?? ctx.repoRoot });
|
|
353
530
|
const rung = rungTaken(where);
|
|
354
531
|
if (loaded.status === 'tampered' || rung.status === 'tampered') return { outcome: /** @type {ReviewOutcome} */ ({ status: 'refused', reason: 'review-state-tampered', approved: false, engine: 'adaptive', sessions: [] }) };
|
|
532
|
+
// B72: the human's `block reopen` row for this file's latest anchor seq restarts a stopped
|
|
533
|
+
// (on changed content) or lost file: a new state, saved at that seq + 1 — the row works once
|
|
534
|
+
// (`rows`: this run's rows read above under the block lock; `key`: the run key from opts)
|
|
535
|
+
const reopenAt = {
|
|
536
|
+
runId: ctx.runId,
|
|
537
|
+
block: ticket.block,
|
|
538
|
+
file: ticket.file,
|
|
539
|
+
key,
|
|
540
|
+
status: loaded.status,
|
|
541
|
+
state: loaded.state,
|
|
542
|
+
seq: loaded.seq,
|
|
543
|
+
contentHash: ticket.content_hash,
|
|
544
|
+
};
|
|
545
|
+
const reopened = reopenRowFor(rows, reopenAt) !== null;
|
|
546
|
+
if (loaded.status === 'lost' && !reopened) return { outcome: /** @type {ReviewOutcome} */ ({ status: 'refused', reason: 'review-state-lost', message: LOST_MESSAGE, approved: false, engine: 'adaptive', sessions: [] }) };
|
|
547
|
+
let seq = loaded.seq;
|
|
548
|
+
if (loaded.status === 'restored' && loaded.state !== null && !reopened) {
|
|
549
|
+
// B68: the lost file is rebuilt from the anchor's signed body; saved as a new seq, then recorded
|
|
550
|
+
const writeRow = /** @type {(row: Record<string, any>) => Promise<unknown>} */ (ctx.writeRow);
|
|
551
|
+
await saveState({ ...where, state: loaded.state, seq, writeRow, contentRoot: ctx.contentRoot ?? ctx.repoRoot });
|
|
552
|
+
seq += 1;
|
|
553
|
+
await writeRow({ event: RESTORED_EVENT, block: ticket.block, file: ticket.file, seq, from_seq: loaded.seq });
|
|
554
|
+
}
|
|
355
555
|
const rungUsed = rung.used;
|
|
356
|
-
let state = loaded.state;
|
|
556
|
+
let state = reopened ? null : loaded.state;
|
|
357
557
|
if (state === null || state.status === 'complete') state = newFileState({ file: ticket.file, level, l3RungUsed: rungUsed });
|
|
358
558
|
state.l3_rung_used = state.l3_rung_used || rungUsed;
|
|
359
559
|
// B47: an autopilot extra round raises the file's cap; a file stopped at the old cap reopens
|
|
@@ -372,7 +572,7 @@ async function fixLoopRound(ticket, ctx, { base, level, owned, ownedKinds, block
|
|
|
372
572
|
state.l3_rung_used = true; // the orchestrator ran the block's one L3 patch before this ticket
|
|
373
573
|
}
|
|
374
574
|
const fixed = state;
|
|
375
|
-
const round = () => playRound(ticket, ctx, { state: fixed, kind, where, seq
|
|
575
|
+
const round = () => playRound(ticket, ctx, { state: fixed, kind, where, seq, base, blockKindOf, workDir, factsExcerpt, rulesDigest, extraRounds, peerDiffs });
|
|
376
576
|
// a round that could take the rung keeps the block lock until its state is saved
|
|
377
577
|
if (mayTakeRung(fixed, kind, ctx.cfg, rungUsed)) return { outcome: await round() };
|
|
378
578
|
return { round };
|
|
@@ -383,10 +583,10 @@ async function fixLoopRound(ticket, ctx, { base, level, owned, ownedKinds, block
|
|
|
383
583
|
/**
|
|
384
584
|
* Run the prepared round and save the file's state: the ticket's outcome.
|
|
385
585
|
* @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
|
|
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
|
|
387
587
|
* @returns {Promise<ReviewOutcome>}
|
|
388
588
|
*/
|
|
389
|
-
async function playRound(ticket, ctx, { state, kind, where, seq, base, blockKindOf, workDir, factsExcerpt, extraRounds = 0, peerDiffs }) {
|
|
589
|
+
async function playRound(ticket, ctx, { state, kind, where, seq, base, blockKindOf, workDir, factsExcerpt, rulesDigest = '', extraRounds = 0, peerDiffs }) {
|
|
390
590
|
/** @type {Record<string, any> | null} */
|
|
391
591
|
let engineOutcome = null;
|
|
392
592
|
let approvalWritten = false;
|
|
@@ -429,18 +629,19 @@ async function playRound(ticket, ctx, { state, kind, where, seq, base, blockKind
|
|
|
429
629
|
await runRound(
|
|
430
630
|
state,
|
|
431
631
|
{
|
|
432
|
-
repoRoot: ctx.repoRoot,
|
|
632
|
+
repoRoot: ctx.contentRoot ?? ctx.repoRoot,
|
|
433
633
|
cfg: ctx.cfg,
|
|
434
634
|
workDir,
|
|
435
635
|
base,
|
|
436
636
|
writeRow,
|
|
437
637
|
factsExcerpt,
|
|
638
|
+
...(rulesDigest !== '' ? { rulesDigest } : {}),
|
|
438
639
|
extraRounds,
|
|
439
640
|
...(peerDiffs ? { peerDiffs } : {}),
|
|
440
641
|
...(jev ? { jev } : {}),
|
|
441
642
|
...(schemaHistory ? { schemaHistory } : {}),
|
|
442
643
|
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 } : {}) });
|
|
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 } : {}) });
|
|
444
645
|
return engineOutcome;
|
|
445
646
|
},
|
|
446
647
|
spawn: async (opts) => {
|
|
@@ -466,7 +667,12 @@ async function playRound(ticket, ctx, { state, kind, where, seq, base, blockKind
|
|
|
466
667
|
state.pending_kind = kind;
|
|
467
668
|
}
|
|
468
669
|
}
|
|
469
|
-
|
|
670
|
+
// B72: a stop (any reason) records the content it reviewed — the ticket's — in the signed state,
|
|
671
|
+
// so `block reopen` and the worker know which content this stop saw (`stopContentHash`)
|
|
672
|
+
const stamped = /** @type {Record<string, any>} */ (state);
|
|
673
|
+
if (state.status === 'stopped') stamped.stop_content_hash = ticket.content_hash;
|
|
674
|
+
else delete stamped.stop_content_hash;
|
|
675
|
+
await saveState({ ...where, state, seq, writeRow: /** @type {(row: Record<string, any>) => Promise<unknown>} */ (ctx.writeRow), contentRoot: ctx.contentRoot ?? ctx.repoRoot });
|
|
470
676
|
|
|
471
677
|
const eng = /** @type {Record<string, any> | null} */ (engineOutcome);
|
|
472
678
|
const head = { ...loopFields(state, kind), engine: eng?.engine ?? 'adaptive', sessions: eng?.sessions ?? rechecks };
|
|
@@ -500,7 +706,7 @@ async function recordBlockBudget(ticket, ctx, { base, owned, ownedKinds }) {
|
|
|
500
706
|
let changed = [];
|
|
501
707
|
if (base !== null && owned.length > 0) {
|
|
502
708
|
try {
|
|
503
|
-
changed = (await computeFileSet({ cwd: ctx.repoRoot, base })).all.filter((f) => ownsFile(owned, f, ownedKinds));
|
|
709
|
+
changed = (await computeFileSet({ cwd: ctx.contentRoot ?? ctx.repoRoot, base })).all.filter((f) => ownsFile(owned, f, ownedKinds));
|
|
504
710
|
} catch {
|
|
505
711
|
// no row on a git failure: a ticket-only forecast would be permanent (the row is written
|
|
506
712
|
// 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,
|
|
@@ -316,6 +318,7 @@ export async function createWorker(opts, deps = {}) {
|
|
|
316
318
|
approved: outcome.approved === true,
|
|
317
319
|
engine: outcome.engine,
|
|
318
320
|
...(typeof outcome.reason === 'string' ? { reason: outcome.reason } : {}),
|
|
321
|
+
...(typeof outcome.message === 'string' ? { message: outcome.message } : {}),
|
|
319
322
|
...(Array.isArray(outcome.findings) ? { findings: outcome.findings } : {}),
|
|
320
323
|
...packetSize(outcome),
|
|
321
324
|
...loopResult(outcome),
|
|
@@ -360,24 +363,42 @@ export async function createWorker(opts, deps = {}) {
|
|
|
360
363
|
*/
|
|
361
364
|
async function reviewOne(id, ticket) {
|
|
362
365
|
const base = { event: 'review.result', block: ticket.block, file: ticket.file, content_hash: ticket.content_hash };
|
|
363
|
-
|
|
366
|
+
if (ticket.run !== runId) return { ...base, status: 'refused', reason: 'other-run', approved: false };
|
|
367
|
+
// B62b: the tree the content is hashed AND reviewed in — a block's worktree (from the run
|
|
368
|
+
// record, as review-file did), else the main tree. A tree that cannot be used (gone, malformed)
|
|
369
|
+
// or a block entry that cannot be read is `unavailable` with its code: never approval, and a
|
|
370
|
+
// later ticket for the same content runs again once it is fixed.
|
|
371
|
+
let contentRoot;
|
|
372
|
+
try {
|
|
373
|
+
contentRoot = await contentRootFor(runId, ticket.block, repoRoot);
|
|
374
|
+
} catch (err) {
|
|
375
|
+
// only the codes contentRootFor answers with; an I/O failure goes to runOne (`worker-error`)
|
|
376
|
+
if (!(err instanceof WorkerError)) throw err;
|
|
377
|
+
return { ...base, status: 'unavailable', reason: err.code, approved: false };
|
|
378
|
+
}
|
|
379
|
+
// a ticket was hashed in the tree it names (the main tree when it names none): never review it
|
|
380
|
+
// in another one — also a ticket with no tree for a block the record gives a worktree
|
|
381
|
+
const hashedIn = typeof ticket.tree === 'string' ? ticket.tree : repoRoot;
|
|
382
|
+
if (canonicalPath(hashedIn) !== canonicalPath(contentRoot)) return { ...base, status: 'unavailable', reason: 'worktree-mismatch', approved: false };
|
|
383
|
+
/** @type {string} */
|
|
364
384
|
let current;
|
|
365
385
|
try {
|
|
366
|
-
current =
|
|
386
|
+
current = contentHash(contentRoot, ticket.file);
|
|
367
387
|
} catch (err) {
|
|
368
388
|
// the path no longer passes the on-disk check, or the file cannot be read
|
|
369
|
-
|
|
389
|
+
const reason = err instanceof WorkerError ? err.code : 'unreadable';
|
|
390
|
+
return { ...base, status: 'refused', reason, approved: false };
|
|
370
391
|
}
|
|
371
|
-
if (ticket.run !== runId) return { ...base, status: 'refused', reason: 'other-run', approved: false };
|
|
372
392
|
if (current === DELETED_HASH && ticket.content_hash !== DELETED_HASH) return { ...base, status: 'stale', reason: 'missing-file', approved: false };
|
|
373
|
-
|
|
393
|
+
// B71: an answers ticket names why (its source moved); a review ticket keeps no reason
|
|
394
|
+
if (current !== ticket.content_hash) return { ...base, status: 'stale', ...(ticket.kind === 'answers' ? { reason: 'source-changed' } : {}), approved: false };
|
|
374
395
|
let ticketCfg;
|
|
375
396
|
try {
|
|
376
397
|
ticketCfg = await configFor(id);
|
|
377
398
|
} catch {
|
|
378
399
|
return { ...base, status: 'unavailable', reason: 'config-snapshot', approved: false };
|
|
379
400
|
}
|
|
380
|
-
return reviewWith(ticket, base, ticketCfg);
|
|
401
|
+
return reviewWith(ticket, base, ticketCfg, contentRoot);
|
|
381
402
|
}
|
|
382
403
|
|
|
383
404
|
/**
|