@codedology/code-forge 0.8.0 → 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 +56 -0
- package/README.md +18 -0
- package/docs/reference/blocks.json +234 -1
- package/package.json +1 -1
- package/skill/references/code.md +15 -6
- package/skill/references/ledger.md +1 -1
- package/skill/references/review.md +21 -3
- package/skill/references/security.md +1 -1
- package/skill/scripts/forge +1 -1
- package/skill/templates/coder-brief.md +2 -2
- package/src/cli/block.mjs +137 -10
- package/src/cli/review-file.mjs +264 -13
- package/src/doctor/index.mjs +5 -4
- package/src/doctor/local.mjs +89 -2
- package/src/ledger/write.mjs +7 -1
- 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 +375 -78
- package/src/review/reopen.mjs +258 -0
- package/src/review/stop-exits.mjs +15 -0
- package/src/util/forbidden.mjs +10 -0
- package/src/util/tmp.mjs +16 -4
- package/src/worker/engine.mjs +75 -9
- package/src/worker/loop.mjs +3 -1
- package/src/worker/queue.mjs +265 -10
- package/src/worker/review-state.mjs +346 -51
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* B72 (issues #12, #15): `block reopen <id> --file <path> --run <r> --reason <text>` — the human
|
|
3
|
+
* restarts the review of a file the fix loop STOPPED (any reason: `review_cap`,
|
|
4
|
+
* `l3_patch_exhausted`, `split_required`, …) or whose state is LOST (B68). Only a stopped file
|
|
5
|
+
* needs content that differs from what its stop reviewed; a lost file restarts on its current
|
|
6
|
+
* content.
|
|
7
|
+
*
|
|
8
|
+
* Human-only like `block waive`: the verb is on `CODER_ONLY_FORBIDDEN` (`src/util/forbidden.mjs`)
|
|
9
|
+
* and its row is signed with the run key, `by: human` (`block waive` has no other in-verb guard,
|
|
10
|
+
* so neither has this). The verb (`reopenFile`) refuses, writing no row, with a stable code:
|
|
11
|
+
* - `usage` (exit 2) — no `--file`, no `--reason`, or a path that is not a normalised
|
|
12
|
+
* repo-root-relative path;
|
|
13
|
+
* - `unknown_block` — the run does not know the block (the code `block waive` uses);
|
|
14
|
+
* - `reopen-block-not-open` — the block is not open (stopped or closed);
|
|
15
|
+
* - `reopen-not-owned` — the block does not own the file;
|
|
16
|
+
* - `reopen-tampered` — the file's state does not verify (an integrity failure stays a security
|
|
17
|
+
* event; the owner's exit is `block close --no-require-reviews`);
|
|
18
|
+
* - `reopen-not-stopped` — no state, or a state that is open or complete;
|
|
19
|
+
* - `reopen-same-content` — a stopped file whose current content is the content its stop
|
|
20
|
+
* reviewed (no free re-roll: the human waives the findings instead), or a stop whose content
|
|
21
|
+
* is unknown;
|
|
22
|
+
* - `reopen-already-recorded` — a verified human row already names the file's latest anchor seq
|
|
23
|
+
* for other content (run `review-file`, or the content changed since that reopen);
|
|
24
|
+
* - `reopen-write-failed` — the signed row was not written.
|
|
25
|
+
* Accepted, it first drops the done marker of the current content's ticket (C11, `io.clearDone`),
|
|
26
|
+
* then writes ONE signed `review.reopened {block, file, from: stopped|lost, stop_reason, at_seq,
|
|
27
|
+
* from_hash, to_hash, reason, by: human}` row (`at_seq` = the file's latest anchor seq). There is
|
|
28
|
+
* at most one verified human row per `at_seq`: a retry on the same content finds it and writes
|
|
29
|
+
* none.
|
|
30
|
+
*
|
|
31
|
+
* The content a stop reviewed (`stopContentHash`) is the stopped state's `stop_content_hash`,
|
|
32
|
+
* which the worker sets on every stop (`src/worker/engine.mjs`) and which is signed through the
|
|
33
|
+
* anchor. A state from before B72 has none: then the latest verified `review.cap` row of THIS
|
|
34
|
+
* stop counts — written after the file's latest verified `review.reopened` row, with the state's
|
|
35
|
+
* round and stop reason — else the content is unknown and the reopen is refused.
|
|
36
|
+
*
|
|
37
|
+
* The worker (`fixLoopRound`) asks `reopenRowFor`: a verified human row of this run for the block
|
|
38
|
+
* and file whose `at_seq` is the file's latest anchor seq starts a new state (round 1, a FULL
|
|
39
|
+
* review, `reviewed_content` null, the level from the run record, `l3_rung_used` kept). The new
|
|
40
|
+
* state saves at `at_seq + 1`, so a row works once. For `stopped` the content must still differ
|
|
41
|
+
* from the stop's content; else the stop repeats.
|
|
42
|
+
*
|
|
43
|
+
* This file imports nothing from `src/worker/`: the fix-loop state is read through `io`.
|
|
44
|
+
*/
|
|
45
|
+
import path from 'node:path';
|
|
46
|
+
import { StateError } from '../state/paths.mjs';
|
|
47
|
+
import { verifyRow } from '../state/signer.mjs';
|
|
48
|
+
|
|
49
|
+
export const REOPENED_EVENT = 'review.reopened';
|
|
50
|
+
|
|
51
|
+
/** A code a refusal may name: an errno (`EACCES`), a StateError code (`ledger-locked`), a name. */
|
|
52
|
+
const CAUSE_CODE = /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* B79: the code a failed write names — the error's `code`, else its `name`, when it has the form
|
|
56
|
+
* {@link CAUSE_CODE}, else `unknown` (a thrown `undefined` or `null` too). Called only for a
|
|
57
|
+
* write that threw; one that wrote nothing is named `no-write` by its caller. Never the message:
|
|
58
|
+
* it may hold a path or a value.
|
|
59
|
+
* @param {unknown} err @returns {string}
|
|
60
|
+
*/
|
|
61
|
+
function causeCode(err) {
|
|
62
|
+
if (err === null || err === undefined) return 'unknown';
|
|
63
|
+
const e = /** @type {{code?: unknown, name?: unknown}} */ (err);
|
|
64
|
+
if (typeof e.code === 'string' && CAUSE_CODE.test(e.code)) return e.code;
|
|
65
|
+
if (typeof e.name === 'string' && CAUSE_CODE.test(e.name)) return e.name;
|
|
66
|
+
return 'unknown';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Whether a row's MAC verifies with the run key (a throw is a no).
|
|
71
|
+
* @param {Record<string, any>} row @param {Buffer} key
|
|
72
|
+
*/
|
|
73
|
+
function verified(row, key) {
|
|
74
|
+
try {
|
|
75
|
+
return verifyRow(row, key).ok === true;
|
|
76
|
+
} catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The content hash the stop of a stopped state reviewed, or null when it is unknown: the state's
|
|
83
|
+
* `stop_content_hash`; for a state from before B72, the latest verified `review.cap` row of this
|
|
84
|
+
* run for the block and file that belongs to this stop (after the file's latest verified
|
|
85
|
+
* `review.reopened` row, with the state's round and stop reason).
|
|
86
|
+
* @param {Record<string, any> | null} state
|
|
87
|
+
* @param {ReadonlyArray<Record<string, any>>} rows
|
|
88
|
+
* @param {{runId: string, block: string, file: string, key: Buffer}} at
|
|
89
|
+
* @returns {string | null}
|
|
90
|
+
*/
|
|
91
|
+
export function stopContentHash(state, rows, { runId, block, file, key }) {
|
|
92
|
+
if (state?.status !== 'stopped') return null;
|
|
93
|
+
if (typeof state.stop_content_hash === 'string' && state.stop_content_hash.length > 0) return state.stop_content_hash;
|
|
94
|
+
const mine = (/** @type {Record<string, any>} */ r) => r?.run === runId && r.block === block && r.file === file && verified(r, key);
|
|
95
|
+
for (let i = rows.length - 1; i >= 0; i -= 1) {
|
|
96
|
+
const r = rows[i];
|
|
97
|
+
if (r?.event === REOPENED_EVENT && mine(r)) return null;
|
|
98
|
+
if (r?.event !== 'review.cap' || !mine(r)) continue;
|
|
99
|
+
if (typeof r.content_hash !== 'string' || r.round !== state.round || r.reason !== state.next?.reason) return null;
|
|
100
|
+
return r.content_hash;
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The block checks every reopen runs first, before any config, key or ledger read: the run must
|
|
107
|
+
* know the block (`unknown_block`, the code `block waive` uses) and the block must be open.
|
|
108
|
+
* @param {Record<string, any>} record @param {string} runId @param {string} id
|
|
109
|
+
* @returns {Record<string, any>} the block's entry.
|
|
110
|
+
* @throws {StateError} `unknown_block` or `reopen-block-not-open`.
|
|
111
|
+
*/
|
|
112
|
+
export function openBlockEntry(record, runId, id) {
|
|
113
|
+
const entry = record.blocks?.[id];
|
|
114
|
+
if (!entry) throw new StateError('unknown_block', `block ${id} is not in run ${runId}`);
|
|
115
|
+
if (entry.status !== 'open') throw new StateError('reopen-block-not-open', `reopen-block-not-open: block ${id} is not open in run ${runId}; nothing was recorded`);
|
|
116
|
+
return entry;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The latest verified human `review.reopened` row of this run for the block and file whose
|
|
121
|
+
* `at_seq` is `seq`, or null.
|
|
122
|
+
* @param {ReadonlyArray<Record<string, any>>} rows
|
|
123
|
+
* @param {{runId: string, block: string, file: string, key: Buffer, seq: number}} at
|
|
124
|
+
* @returns {Record<string, any> | null}
|
|
125
|
+
*/
|
|
126
|
+
function latestRowAt(rows, { runId, block, file, key, seq }) {
|
|
127
|
+
for (let i = rows.length - 1; i >= 0; i -= 1) {
|
|
128
|
+
const r = rows[i];
|
|
129
|
+
if (r?.event !== REOPENED_EVENT || r.run !== runId || r.block !== block || r.file !== file) continue;
|
|
130
|
+
if (r.by !== 'human' || r.at_seq !== seq || !verified(r, key)) continue;
|
|
131
|
+
return r;
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Worker side: the `review.reopened` row that restarts this file now, or null. Only a row whose MAC
|
|
138
|
+
* verifies with the run key, of this run, block and file, `by: human`, and whose `at_seq` is the
|
|
139
|
+
* file's latest anchor seq counts (the latest such row). A `lost` state needs `from: lost`; a
|
|
140
|
+
* stopped state needs `from: stopped` AND current content that is neither the row's `from_hash`
|
|
141
|
+
* nor the content the stop reviewed (`stopContentHash`; unknown ⇒ no restart).
|
|
142
|
+
* @param {ReadonlyArray<Record<string, any>>} rows
|
|
143
|
+
* @param {{runId: string, block: string, file: string, key: Buffer, status: string, state: Record<string, any> | null, seq: number, contentHash: string}} at
|
|
144
|
+
* @returns {Record<string, any> | null}
|
|
145
|
+
*/
|
|
146
|
+
export function reopenRowFor(rows, { runId, block, file, key, status, state, seq, contentHash }) {
|
|
147
|
+
const row = latestRowAt(rows, { runId, block, file, key, seq });
|
|
148
|
+
if (row === null) return null;
|
|
149
|
+
if (status === 'lost') return row.from === 'lost' ? row : null;
|
|
150
|
+
if ((status === 'ok' || status === 'restored') && state?.status === 'stopped' && row.from === 'stopped') {
|
|
151
|
+
const stopHash = stopContentHash(state, rows, { runId, block, file, key });
|
|
152
|
+
if (stopHash === null || contentHash === stopHash || contentHash === row.from_hash) return null;
|
|
153
|
+
return row;
|
|
154
|
+
}
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The verb (`block reopen`): check, drop the done marker (`io.clearDone`), then write the signed
|
|
160
|
+
* row — or return the verified human row an earlier try wrote for the same `at_seq` and `to_hash`
|
|
161
|
+
* (at most one row per `at_seq`: one for other content is refused `reopen-already-recorded`).
|
|
162
|
+
* @param {{
|
|
163
|
+
* runId: string, id: string, file: unknown, reason: unknown,
|
|
164
|
+
* record: Record<string, any>, rows: ReadonlyArray<Record<string, any>>, key: Buffer,
|
|
165
|
+
* writeRow: (row: Record<string, any>) => Promise<unknown>,
|
|
166
|
+
* writeSigned: (runId: string, writeRow: (row: Record<string, any>) => Promise<unknown>, row: Record<string, any>) => Promise<unknown>,
|
|
167
|
+
* io: {
|
|
168
|
+
* owns: (owned: ReadonlyArray<string>, file: string, kinds?: Readonly<Record<string, string>>) => boolean,
|
|
169
|
+
* loadState: (where: Record<string, any>) => {status: string, state: Record<string, any> | null, seq: number},
|
|
170
|
+
* contentHash: (file: string) => string,
|
|
171
|
+
* clearDone: (file: string) => boolean,
|
|
172
|
+
* },
|
|
173
|
+
* }} opts
|
|
174
|
+
* @returns {Promise<{row: Record<string, any>, cleared: boolean, existing: boolean}>}
|
|
175
|
+
* @throws {StateError} `usage` or a refusal code (see the module doc).
|
|
176
|
+
*/
|
|
177
|
+
export async function reopenFile({ runId, id, file, reason, record, rows, key, writeRow, writeSigned, io }) {
|
|
178
|
+
if (typeof file !== 'string' || file.length === 0) throw new StateError('usage', 'block reopen needs --file <path>');
|
|
179
|
+
if (typeof reason !== 'string' || reason.trim().length === 0) throw new StateError('usage', 'block reopen needs --reason');
|
|
180
|
+
if (path.isAbsolute(file) || file.split('/').includes('..') || file.startsWith('./') || path.posix.normalize(file) !== file) {
|
|
181
|
+
throw new StateError('usage', '--file must be repo-root-relative');
|
|
182
|
+
}
|
|
183
|
+
const entry = openBlockEntry(record, runId, id);
|
|
184
|
+
const owned = Array.isArray(entry.owned_files) ? entry.owned_files : [];
|
|
185
|
+
if (!io.owns(owned, file, entry.owned_kinds)) throw new StateError('reopen-not-owned', `reopen-not-owned: block ${id} does not own ${file}; nothing was recorded`);
|
|
186
|
+
|
|
187
|
+
const runRows = rows.filter((r) => r?.run === runId);
|
|
188
|
+
const loaded = io.loadState({ runId, block: id, file, key, rows: runRows });
|
|
189
|
+
if (loaded.status === 'tampered') {
|
|
190
|
+
throw new StateError('reopen-tampered', `reopen-tampered: the review state of ${file} does not verify (a security event, not a stop); the owner's way out is block close --no-require-reviews; nothing was recorded`);
|
|
191
|
+
}
|
|
192
|
+
const current = io.contentHash(file);
|
|
193
|
+
/** @type {'stopped' | 'lost'} */
|
|
194
|
+
let from;
|
|
195
|
+
/** @type {string | null} */
|
|
196
|
+
let stopReason = null;
|
|
197
|
+
/** @type {string | null} */
|
|
198
|
+
let fromHash = null;
|
|
199
|
+
if (loaded.status === 'lost') {
|
|
200
|
+
from = 'lost';
|
|
201
|
+
} else if ((loaded.status === 'ok' || loaded.status === 'restored') && loaded.state?.status === 'stopped') {
|
|
202
|
+
from = 'stopped';
|
|
203
|
+
stopReason = typeof loaded.state.next?.reason === 'string' ? loaded.state.next.reason : 'stopped';
|
|
204
|
+
fromHash = stopContentHash(loaded.state, runRows, { runId, block: id, file, key });
|
|
205
|
+
if (fromHash === null) {
|
|
206
|
+
throw new StateError('reopen-same-content', `reopen-same-content: the content the stop of ${file} reviewed is unknown, so a change cannot be shown; waive its findings instead (block waive); nothing was recorded`);
|
|
207
|
+
}
|
|
208
|
+
if (fromHash === current) {
|
|
209
|
+
throw new StateError('reopen-same-content', `reopen-same-content: ${file} still has the content its stop (${stopReason}) reviewed; change the file first, or waive its findings (block waive); nothing was recorded`);
|
|
210
|
+
}
|
|
211
|
+
} else {
|
|
212
|
+
const what = loaded.status === 'none' || loaded.state === null ? 'has no review state' : `is ${loaded.state.status}`;
|
|
213
|
+
throw new StateError('reopen-not-stopped', `reopen-not-stopped: ${file} ${what}; only a stopped or lost file is reopened; nothing was recorded`);
|
|
214
|
+
}
|
|
215
|
+
if (!Number.isInteger(loaded.seq) || loaded.seq < 0) {
|
|
216
|
+
throw new StateError('reopen-tampered', `reopen-tampered: the review state of ${file} has no valid anchor seq; nothing was recorded`);
|
|
217
|
+
}
|
|
218
|
+
// one verified human row per at_seq: the same content reuses it, other content is refused
|
|
219
|
+
const earlier = latestRowAt(runRows, { runId, block: id, file, key, seq: loaded.seq });
|
|
220
|
+
if (earlier !== null && earlier.to_hash !== current) {
|
|
221
|
+
throw new StateError('reopen-already-recorded', `reopen-already-recorded: ${file} was already reopened at state seq ${loaded.seq} for other content; run review-file, or the content changed since the reopen; nothing was recorded`);
|
|
222
|
+
}
|
|
223
|
+
// C11: the cached result of the current content's ticket would repeat the stop — dropped FIRST,
|
|
224
|
+
// so a failed row write leaves at worst a re-run that returns the same stop, never a row with a
|
|
225
|
+
// stale marker
|
|
226
|
+
const cleared = io.clearDone(file);
|
|
227
|
+
if (earlier !== null) return { row: earlier, cleared, existing: true };
|
|
228
|
+
const row = { event: REOPENED_EVENT, block: id, file, from, stop_reason: stopReason, at_seq: loaded.seq, from_hash: fromHash, to_hash: current, reason: reason.trim(), by: 'human' };
|
|
229
|
+
/** @type {Record<string, any> | null} */
|
|
230
|
+
let written = null;
|
|
231
|
+
let signedOut = null;
|
|
232
|
+
/** @type {unknown} */
|
|
233
|
+
let failure = null;
|
|
234
|
+
// a thrown `undefined` (or null) is still a throw: `threw`, not the value, says the write threw
|
|
235
|
+
let threw = false;
|
|
236
|
+
try {
|
|
237
|
+
signedOut = await writeSigned(runId, async (signed) => {
|
|
238
|
+
const result = await writeRow(signed);
|
|
239
|
+
// the ledger's appendRow resolves with the row it wrote; nothing (or false) is no write
|
|
240
|
+
if (result === null || result === undefined || result === false) return result;
|
|
241
|
+
written = signed;
|
|
242
|
+
return result;
|
|
243
|
+
}, row);
|
|
244
|
+
} catch (err) {
|
|
245
|
+
written = null;
|
|
246
|
+
failure = err;
|
|
247
|
+
threw = true;
|
|
248
|
+
}
|
|
249
|
+
if (written === null || signedOut === null || signedOut === undefined || signedOut === false) {
|
|
250
|
+
// B79: the cause is kept (`cause`) and named by its code only — never a path, key or config value
|
|
251
|
+
const refused = new StateError('reopen-write-failed', `reopen-write-failed: the signed review.reopened row for ${file} was not written (${threw ? causeCode(failure) : 'no-write'}); run block reopen again`);
|
|
252
|
+
// StateError takes (code, message) only (src/state/paths.mjs is not this block's): `cause` is
|
|
253
|
+
// set here, non-enumerable like a native one, so a serializer never emits the cause's path
|
|
254
|
+
if (threw) Object.defineProperty(refused, 'cause', { value: failure, enumerable: false, configurable: true, writable: true });
|
|
255
|
+
throw refused;
|
|
256
|
+
}
|
|
257
|
+
return { row: written, cleared, existing: false };
|
|
258
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* B73 (issue #12): the ways out of a file the fix loop stopped (`review_cap`,
|
|
3
|
+
* `l3_patch_exhausted`, `split_required`), in this order — `block reopen` after a fix,
|
|
4
|
+
* `block waive` for each open finding, `review-file --answers` for a fix made in another owned
|
|
5
|
+
* file, or re-decompose the block. `review-file`'s `stopped` answer carries them as `exits`; the
|
|
6
|
+
* gate appends {@link stopExitsLine} to its `review_cap` and `unreviewed` detail for a stopped
|
|
7
|
+
* file. A `tampered` review state is not a stop (it is refused; the owner's way out is
|
|
8
|
+
* `block close --no-require-reviews`) and never gets them.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** @type {ReadonlyArray<string>} */
|
|
12
|
+
export const STOP_EXITS = Object.freeze(['block reopen', 'block waive', 'review-file --answers', 're-decompose']);
|
|
13
|
+
|
|
14
|
+
/** The one short line the gate appends to a stopped file's detail. */
|
|
15
|
+
export const stopExitsLine = `exits: ${STOP_EXITS.join(', ')}`;
|
package/src/util/forbidden.mjs
CHANGED
|
@@ -276,6 +276,7 @@ export const FORBIDDEN = Object.freeze(
|
|
|
276
276
|
* They are NOT part of `FORBIDDEN` (whose rendered count other blocks pin); `mergeForbidden()`
|
|
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
|
+
* - `code-forge block reopen` (B72) — restarting a stopped or lost file's review is human-only;
|
|
279
280
|
* - `code-forge autopilot` (any subcommand, B45) — the owner's grant to a delegate;
|
|
280
281
|
* - `--no-require-reviews` — the human's way out of `block close`'s per-file review check;
|
|
281
282
|
* - `--remove-worktree` (B62a) — `block close` removing the block's worktree, the orchestrator's
|
|
@@ -293,6 +294,15 @@ export const CODER_ONLY_FORBIDDEN = Object.freeze(
|
|
|
293
294
|
],
|
|
294
295
|
description: 'Waive a review finding (forge block waive) — human-only, run by the orchestrator after the human said so (§4.9)',
|
|
295
296
|
},
|
|
297
|
+
{
|
|
298
|
+
id: 'code-forge-block-reopen-from-coder',
|
|
299
|
+
kind: 'anyPrefix',
|
|
300
|
+
prefixes: [
|
|
301
|
+
['code-forge', 'block', 'reopen'],
|
|
302
|
+
['forge', 'block', 'reopen'],
|
|
303
|
+
],
|
|
304
|
+
description: 'Reopen the review of a stopped or lost file (forge block reopen) — human-only, run by the orchestrator after the human said so (B72)',
|
|
305
|
+
},
|
|
296
306
|
{
|
|
297
307
|
id: 'code-forge-autopilot-from-coder',
|
|
298
308
|
kind: 'anyPrefix',
|
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`,
|
|
@@ -65,6 +72,20 @@
|
|
|
65
72
|
* ticket is done); `review-file` on the same content is a fresh attempt every time (`retry: true`).
|
|
66
73
|
* The tree is passed as `contentRoot`: `repoRoot` stays the main tree, and the fix-loop state
|
|
67
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.
|
|
68
89
|
*/
|
|
69
90
|
|
|
70
91
|
import { realpathSync, rmSync } from 'node:fs';
|
|
@@ -75,9 +96,11 @@ import { planAndRecord, tierOf } from '../review/budget.mjs';
|
|
|
75
96
|
import { budgetFor } from '../review/packet.mjs';
|
|
76
97
|
import { ContextError, reviewDigest } from '../context/pack.mjs';
|
|
77
98
|
import { reviewFile, rulesRisk } from '../review/engine.mjs';
|
|
99
|
+
import { reviewAnswers } from '../review/answers.mjs';
|
|
78
100
|
import { peerDiffReader } from '../review/moved.mjs';
|
|
79
101
|
import { computeFileSet, ownsFile } from '../gates/scope.mjs';
|
|
80
102
|
import { extraRoundsFor, newFileState, reopenForExtraRound, runRound } from '../review/fixloop.mjs';
|
|
103
|
+
import { reopenRowFor } from '../review/reopen.mjs';
|
|
81
104
|
import { readRun } from '../state/run.mjs';
|
|
82
105
|
import { StateError } from '../state/paths.mjs';
|
|
83
106
|
import { blockTree, worktreeRecord } from '../proof/worktree.mjs';
|
|
@@ -85,8 +108,9 @@ import { ledgerSchemaHistory } from '../review/schema-fallback.mjs';
|
|
|
85
108
|
import { verifyRow } from '../state/signer.mjs';
|
|
86
109
|
import { redact } from '../util/redact.mjs';
|
|
87
110
|
import { keyedLock } from '../util/locks.mjs';
|
|
88
|
-
import { blockAnchors, blockRungUsed, loadState, saveState } from './review-state.mjs';
|
|
89
|
-
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';
|
|
90
114
|
|
|
91
115
|
/**
|
|
92
116
|
* @typedef {object} ReviewContext
|
|
@@ -94,7 +118,8 @@ import { assertTicketId, WorkerError } from './ticket.mjs';
|
|
|
94
118
|
* @property {string} [contentRoot] - B62b: the tree the ticket's content is read in (the block's
|
|
95
119
|
* worktree); `repoRoot` when absent.
|
|
96
120
|
* @property {string} runId
|
|
97
|
-
* @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).
|
|
98
123
|
* @property {Record<string, any>} cfg - the project config.
|
|
99
124
|
* @property {string | null} jevKey - resolved once by the worker through B2's chain; never print.
|
|
100
125
|
* @property {(opts: Record<string, any>) => Promise<Record<string, any>>} spawn - one isolated
|
|
@@ -116,6 +141,7 @@ import { assertTicketId, WorkerError } from './ticket.mjs';
|
|
|
116
141
|
* @property {string} engine - `adaptive` or `consensus`.
|
|
117
142
|
* @property {Array<Record<string, any>>} sessions - one summary per session run.
|
|
118
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`).
|
|
119
145
|
* @property {Array<Record<string, any>>} [findings] - the open fix list (`reviewed` / `stopped`).
|
|
120
146
|
* @property {number} [round] - the fix-loop round this ticket ran (patch_check rounds not counted).
|
|
121
147
|
* @property {string} [kind] - `full`, `recheck` or `patch_check`.
|
|
@@ -338,6 +364,14 @@ export async function reviewTicket(ticket, ctx) {
|
|
|
338
364
|
}
|
|
339
365
|
/** @type {ReviewContext} */
|
|
340
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
|
+
}
|
|
341
375
|
const factsExcerpt = factsFor(entry, ctx.cfg);
|
|
342
376
|
const peerDiffs = blockPeerDiffs(contentRoot, { base: entry.base, owned: entry.owned, ownedKinds: entry.ownedKinds, file: ticket.file });
|
|
343
377
|
try {
|
|
@@ -413,6 +447,9 @@ export function blockPeerDiffs(repoRoot, { base, owned, ownedKinds, file }) {
|
|
|
413
447
|
*/
|
|
414
448
|
export const blockLockKey = (runId, block) => `block:${runId}\0${block}`;
|
|
415
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
|
+
|
|
416
453
|
/**
|
|
417
454
|
* Whether the block's one L3 rung is taken: a verified anchor says so (`blockRungUsed`), OR another
|
|
418
455
|
* file of the block has a saved state whose round decided `next: patch` — that round took the rung,
|
|
@@ -429,11 +466,12 @@ export function rungTaken(where) {
|
|
|
429
466
|
for (const file of siblings) {
|
|
430
467
|
let other;
|
|
431
468
|
try {
|
|
432
|
-
other = loadState({ ...where, file });
|
|
469
|
+
other = loadState({ ...where, file, migrate: false }); // B69: a sibling's old file is read, never moved here
|
|
433
470
|
} catch {
|
|
434
471
|
return { status: 'ok', used: true };
|
|
435
472
|
}
|
|
436
|
-
|
|
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 };
|
|
437
475
|
}
|
|
438
476
|
return rung;
|
|
439
477
|
}
|
|
@@ -488,11 +526,34 @@ async function fixLoopRound(ticket, ctx, { base, level, owned, ownedKinds, block
|
|
|
488
526
|
await recordBlockBudget(ticket, ctx, { base, owned, ownedKinds });
|
|
489
527
|
}
|
|
490
528
|
const where = { runRootDir: ctx.runRootDir, runId: ctx.runId, block: ticket.block, file: ticket.file, key, rows };
|
|
491
|
-
const loaded = loadState(where);
|
|
529
|
+
const loaded = loadState({ ...where, contentRoot: ctx.contentRoot ?? ctx.repoRoot });
|
|
492
530
|
const rung = rungTaken(where);
|
|
493
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
|
+
}
|
|
494
555
|
const rungUsed = rung.used;
|
|
495
|
-
let state = loaded.state;
|
|
556
|
+
let state = reopened ? null : loaded.state;
|
|
496
557
|
if (state === null || state.status === 'complete') state = newFileState({ file: ticket.file, level, l3RungUsed: rungUsed });
|
|
497
558
|
state.l3_rung_used = state.l3_rung_used || rungUsed;
|
|
498
559
|
// B47: an autopilot extra round raises the file's cap; a file stopped at the old cap reopens
|
|
@@ -511,7 +572,7 @@ async function fixLoopRound(ticket, ctx, { base, level, owned, ownedKinds, block
|
|
|
511
572
|
state.l3_rung_used = true; // the orchestrator ran the block's one L3 patch before this ticket
|
|
512
573
|
}
|
|
513
574
|
const fixed = state;
|
|
514
|
-
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 });
|
|
515
576
|
// a round that could take the rung keeps the block lock until its state is saved
|
|
516
577
|
if (mayTakeRung(fixed, kind, ctx.cfg, rungUsed)) return { outcome: await round() };
|
|
517
578
|
return { round };
|
|
@@ -606,7 +667,12 @@ async function playRound(ticket, ctx, { state, kind, where, seq, base, blockKind
|
|
|
606
667
|
state.pending_kind = kind;
|
|
607
668
|
}
|
|
608
669
|
}
|
|
609
|
-
|
|
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 });
|
|
610
676
|
|
|
611
677
|
const eng = /** @type {Record<string, any> | null} */ (engineOutcome);
|
|
612
678
|
const head = { ...loopFields(state, kind), engine: eng?.engine ?? 'adaptive', sessions: eng?.sessions ?? rechecks };
|
package/src/worker/loop.mjs
CHANGED
|
@@ -318,6 +318,7 @@ export async function createWorker(opts, deps = {}) {
|
|
|
318
318
|
approved: outcome.approved === true,
|
|
319
319
|
engine: outcome.engine,
|
|
320
320
|
...(typeof outcome.reason === 'string' ? { reason: outcome.reason } : {}),
|
|
321
|
+
...(typeof outcome.message === 'string' ? { message: outcome.message } : {}),
|
|
321
322
|
...(Array.isArray(outcome.findings) ? { findings: outcome.findings } : {}),
|
|
322
323
|
...packetSize(outcome),
|
|
323
324
|
...loopResult(outcome),
|
|
@@ -389,7 +390,8 @@ export async function createWorker(opts, deps = {}) {
|
|
|
389
390
|
return { ...base, status: 'refused', reason, approved: false };
|
|
390
391
|
}
|
|
391
392
|
if (current === DELETED_HASH && ticket.content_hash !== DELETED_HASH) return { ...base, status: 'stale', reason: 'missing-file', approved: false };
|
|
392
|
-
|
|
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 };
|
|
393
395
|
let ticketCfg;
|
|
394
396
|
try {
|
|
395
397
|
ticketCfg = await configFor(id);
|