@codedology/code-forge 0.8.0 → 0.10.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.
Files changed (64) hide show
  1. package/CHANGELOG.md +114 -0
  2. package/README.md +46 -7
  3. package/docs/reference/blocks.json +618 -1
  4. package/docs/reference/config.md +36 -0
  5. package/package.json +1 -1
  6. package/schema/code-forge.schema.json +89 -0
  7. package/skill/references/adapters/claude-code.md +1 -1
  8. package/skill/references/adapters/solo.md +2 -2
  9. package/skill/references/adapters/subprocess.md +12 -2
  10. package/skill/references/code.md +21 -7
  11. package/skill/references/ledger.md +2 -2
  12. package/skill/references/proof.md +77 -2
  13. package/skill/references/review.md +29 -4
  14. package/skill/references/security.md +5 -5
  15. package/skill/scripts/forge +1 -1
  16. package/skill/templates/coder-brief.md +4 -2
  17. package/src/autopilot/binnacle.mjs +9 -3
  18. package/src/cli/block.mjs +300 -33
  19. package/src/cli/jev.mjs +22 -3
  20. package/src/cli/keys.mjs +156 -21
  21. package/src/cli/proof.mjs +35 -7
  22. package/src/cli/review-file.mjs +264 -13
  23. package/src/cli/spawn.mjs +44 -3
  24. package/src/config/validate.mjs +320 -5
  25. package/src/doctor/index.mjs +7 -5
  26. package/src/doctor/local.mjs +116 -8
  27. package/src/doctor/probes.mjs +38 -0
  28. package/src/engines/builders/codex.mjs +44 -4
  29. package/src/engines/sandbox-dirs.mjs +257 -0
  30. package/src/keys/store.mjs +128 -21
  31. package/src/ledger/write.mjs +7 -1
  32. package/src/proof/junit.mjs +530 -0
  33. package/src/proof/red-green.mjs +57 -9
  34. package/src/proof/red-parse.mjs +2 -0
  35. package/src/proof/runners.mjs +472 -0
  36. package/src/proof/xcresult.mjs +285 -0
  37. package/src/review/answers.mjs +445 -0
  38. package/src/review/deletion.mjs +106 -0
  39. package/src/review/engine.mjs +117 -31
  40. package/src/review/fixloop.mjs +70 -28
  41. package/src/review/gate-check.mjs +378 -80
  42. package/src/review/lenses/A.md +1 -0
  43. package/src/review/lenses/B.md +1 -0
  44. package/src/review/lenses/full.md +1 -0
  45. package/src/review/lenses/quick.md +1 -0
  46. package/src/review/lenses/recheck.md +1 -0
  47. package/src/review/reopen.mjs +256 -0
  48. package/src/review/route.mjs +232 -0
  49. package/src/review/schema-fallback.mjs +77 -22
  50. package/src/review/session-retry.mjs +44 -9
  51. package/src/review/stop-exits.mjs +15 -0
  52. package/src/review/test-quality.mjs +201 -0
  53. package/src/review/verb-forms.mjs +39 -0
  54. package/src/session/spawn.mjs +39 -2
  55. package/src/state/block.mjs +13 -3
  56. package/src/state/config-snapshot.mjs +39 -0
  57. package/src/state/run.mjs +4 -0
  58. package/src/util/forbidden.mjs +11 -1
  59. package/src/util/tmp.mjs +16 -4
  60. package/src/worker/engine.mjs +75 -9
  61. package/src/worker/loop.mjs +82 -15
  62. package/src/worker/queue.mjs +265 -10
  63. package/src/worker/review-state.mjs +346 -51
  64. package/src/worker/ticket.mjs +9 -1
@@ -5,12 +5,17 @@
5
5
  * review-file --wait <ticket> [--max <90s>] poll; prints {ticket, status: done, result} or
6
6
  * {ticket, status: pending, reason: wait_timeout}
7
7
  * when --max elapses (the review is still running)
8
+ * review-file <answering file> --block <id> --answers <ids> --of <source> [--run <id>]
9
+ * B71: enqueue an answers ticket; prints {ticket,
10
+ * status, kind: answers, file: source, by_file, answers}
11
+ * review-file <path> --block <id> --fresh B75: re-run a cached result that never counted
8
12
  *
9
13
  * The same content enqueued again after an `unavailable` result is a fresh attempt (`retry: true`),
10
14
  * not the old result.
11
15
  * A done result that is not approved and carries open findings adds `fix_list` (what the coder
12
16
  * fixes before re-running `review-file <path>`, which is then a round-2 recheck of the fix hunks,
13
- * §4.11); a result the loop stopped adds `stop` with the reason (`review_cap`, …) — the coder
17
+ * §4.11); a result the loop stopped adds `stop` with the reason (`review_cap`, …) and (B73)
18
+ * `exits: ["block reopen", "block waive", "review-file --answers", "re-decompose"]` — the coder
14
19
  * stops and reports; only the orchestrator and the human act on it. A `split_required` result
15
20
  * (the packet is over `review.budgets.full_in`) adds `stop: split_required` with `tokens_in` and
16
21
  * `budget` (always; null when unknown) and a Markdown `section` — never for another result (B54); a Markdown file gets there only when one of its sections alone is too large.
@@ -22,6 +27,15 @@
22
27
  * `===BLOCK <id> FAILED: worker down===`. While waiting, `worker_down` needs the worker to look
23
28
  * down on two polls in a row, and a worker whose pid is alive with a fresh heartbeat is never down
24
29
  * (`liveWorker`, B30). Output is one JSON line on stdout.
30
+ * B75: `--fresh` drops the cached done marker of the file's current content and queues the ticket
31
+ * again ONLY when that result never counted as a review: `stale`, `unavailable`, or `refused` with
32
+ * `other-run`, `context-unreadable` or a `worktree-*` reason (an allow-list, fail closed). Any
33
+ * other cached result — a round ran (findings, approved, stopped), `review-state-lost`, an answers
34
+ * result, an unknown status or reason, an unreadable marker — is refused `fresh-needs-reopen`
35
+ * (exit 1, `exits: [block reopen, block waive]`, the marker kept byte-for-byte); a
36
+ * `review-state-tampered` one is refused `fresh-tampered` (names only `block close
37
+ * --no-require-reviews`). No cached result: a normal enqueue. A coder may run it; it never
38
+ * re-rolls a counted round and writes no ledger row.
25
39
  * Exit codes: 0 queued/done/pending, 1 bad-path or another refusal, 2 usage, 3 worker_down.
26
40
  *
27
41
  * B62b: the queue and the worker are always the MAIN tree's (`mainRootOf`), also when the coder
@@ -35,19 +49,38 @@
35
49
  * from, which is the block's tree when the coder works there. A record naming a worktree that is
36
50
  * gone or malformed is refused (exit 1, `reason: worktree-missing|worktree-malformed|…`), and
37
51
  * nothing is queued.
52
+ *
53
+ * B71: `--answers <id,id,…> --of <source>` (1 to 10 finding ids, comma-separated) asks the worker
54
+ * whether the change in the positional file (the answering file) resolves those open findings of
55
+ * `<source>`. It is refused before anything is queued — exit 2 `usage` for a missing `--of`, no
56
+ * ids, more than 10 ids or a malformed id; exit 1 `answers-same-file` when both name one file,
57
+ * `answers-not-owned` when the run record says the block does not own both, `answers-not-open`
58
+ * when an id is not open on the source at its current hash (not named by the latest refusal or
59
+ * cap row there, or already waived or answered; these two pre-checks read what this process can read, unverified, and are skipped when the run
60
+ * record cannot be opened here). The worker checks all of it again with the signed rows; that check
61
+ * is the one that counts. Its done result has `status: answers` and the coder-facing `answers:
62
+ * [{id, verdict: resolved|not_resolved, reason}]` instead of a fix list; a resolved id is a signed
63
+ * `review.answered` row the close counts until either file changes. The answering file still
64
+ * needs its own review.
38
65
  */
39
66
 
40
67
  import { readFileSync, realpathSync, rmSync } from 'node:fs';
41
68
  import path from 'node:path';
42
- import { enqueue, isDone, liveWorker, queueDir } from '../worker/queue.mjs';
43
- import { assertBlockId, assertInsideRoot, assertTicketId, mainRootOf, normalizeRequestPath, repoRootOf, WorkerError } from '../worker/ticket.mjs';
69
+ import { answersTicketId, assertAnswers, dropDoneIf, enqueue, enqueueAnswers, isDone, liveWorker, MAX_ANSWERS, queueDir, resolveTicket } from '../worker/queue.mjs';
70
+ import { assertBlockId, assertInsideRoot, assertTicketId, contentHash, mainRootOf, normalizeRequestPath, repoRootOf, WorkerError } from '../worker/ticket.mjs';
71
+ import { namedOnHash } from '../review/gate-check.mjs';
72
+ import { STOP_EXITS } from '../review/stop-exits.mjs';
73
+ import { ownsFile } from '../state/registry.mjs';
74
+ import { readAllRows } from '../ledger/write.mjs';
44
75
  import { parseFlags } from '../state/cli-args.mjs';
45
76
  import { StateError } from '../state/paths.mjs';
46
77
  import { readRun } from '../state/run.mjs';
47
78
  import { blockTree } from '../proof/worktree.mjs';
48
79
  import { writeSafe } from '../util/redact.mjs';
49
80
 
50
- const USAGE = 'usage: code-forge review-file <path> --block <id> [--run <id>] | review-file --wait <ticket> [--max <seconds>s]\n';
81
+ const USAGE =
82
+ 'usage: code-forge review-file <path> --block <id> [--run <id>] [--fresh] | review-file --wait <ticket> [--max <seconds>s]\n' +
83
+ ' code-forge review-file <answering file> --block <id> --answers <id,id,…> --of <source> [--run <id>]\n';
51
84
 
52
85
  export const DEFAULT_WAIT_MS = 90_000;
53
86
  const POLL_MS = 200;
@@ -69,15 +102,132 @@ export function parseMax(raw) {
69
102
  */
70
103
  function doneResult(repoRoot, ticket) {
71
104
  try {
72
- const marker = JSON.parse(readFileSync(path.join(queueDir(repoRoot), `${ticket}.done`), 'utf8'));
73
- const rel = typeof marker.result === 'string' ? marker.result : '';
105
+ return resultOfMarker(repoRoot, readFileSync(path.join(queueDir(repoRoot), `${ticket}.done`), 'utf8'));
106
+ } catch {
107
+ return null;
108
+ }
109
+ }
110
+
111
+ /**
112
+ * @param {string} repoRoot @param {string} text - a done marker's text
113
+ * @returns {Record<string, any> | null} the worker's result it names (unverified), or null.
114
+ */
115
+ function resultOfMarker(repoRoot, text) {
116
+ try {
117
+ const marker = JSON.parse(text);
118
+ const rel = typeof marker?.result === 'string' ? marker.result : '';
74
119
  if (!rel.startsWith(`.code-forge${path.sep}reviews${path.sep}`) || rel.split(path.sep).includes('..')) return null;
75
- return JSON.parse(readFileSync(path.join(repoRoot, rel), 'utf8'));
120
+ const result = JSON.parse(readFileSync(path.join(repoRoot, rel), 'utf8'));
121
+ return result !== null && typeof result === 'object' && !Array.isArray(result) ? result : null;
76
122
  } catch {
77
123
  return null;
78
124
  }
79
125
  }
80
126
 
127
+ /** B75: the refused reasons that are not a verdict on the content (`--fresh` re-runs them). */
128
+ const FRESH_REFUSED = new Set(['other-run', 'context-unreadable']);
129
+
130
+ /**
131
+ * B75: may `--fresh` drop this cached result? An allow-list, fail closed: `rerun` only for a
132
+ * result that never counted as a review — `stale`, `unavailable`, or `refused` with a reason that
133
+ * is not a verdict (`other-run`, `context-unreadable`, `worktree-*`) — whose marker names the same
134
+ * status. `tampered` for `refused review-state-tampered`; `counted` for everything else (a round
135
+ * ran, `review-state-lost`, an answers result, an unknown status or reason, an unreadable marker or
136
+ * result).
137
+ * @param {string} text - the done marker's text @param {Record<string, any> | null} result
138
+ * @returns {'rerun' | 'tampered' | 'counted'}
139
+ */
140
+ export function freshVerdict(text, result) {
141
+ let marker;
142
+ try {
143
+ marker = JSON.parse(text);
144
+ } catch {
145
+ return 'counted';
146
+ }
147
+ if (result === null || marker === null || typeof marker !== 'object' || marker.status !== result.status) return 'counted';
148
+ if (result.status === 'stale' || result.status === 'unavailable') return 'rerun';
149
+ if (result.status !== 'refused' || typeof result.reason !== 'string') return 'counted';
150
+ if (result.reason === 'review-state-tampered') return 'tampered';
151
+ return FRESH_REFUSED.has(result.reason) || result.reason.startsWith('worktree-') ? 'rerun' : 'counted';
152
+ }
153
+
154
+ /** B75: how many times --fresh classifies a marker that changed under its claim. */
155
+ const FRESH_ATTEMPTS = 3;
156
+
157
+ /** B75: the ways out a counted result names (human-only; a coder stops and reports). */
158
+ export const FRESH_EXITS = Object.freeze(['block reopen', 'block waive']);
159
+
160
+ /**
161
+ * B75: `review-file <path> --block <id> --fresh`. No done marker: a normal enqueue. The marker is
162
+ * read and classified IN PLACE; only a cached result that never counted is then claimed by
163
+ * `dropDoneIf` (rename, then a byte compare with what was classified) and the ticket queued again.
164
+ * A marker that changed under the claim is restored (link only) and classified again, at most
165
+ * {@link FRESH_ATTEMPTS} times. The queue has no lock: the rename claim is the exclusion, and a
166
+ * crash between the drop and the enqueue leaves only a re-runnable pending ticket. Anything else is refused and the marker kept byte-for-byte:
167
+ * `fresh-needs-reopen` (names `block reopen` and `block waive`) or `fresh-tampered` (names only
168
+ * the owner's `block close --no-require-reviews`; reopen refuses a tampered state).
169
+ * @param {{repoRoot: string, run: string, block: string, file: string, where: {tree?: string}, say: (obj: Record<string, any>) => unknown, dropDone?: typeof dropDoneIf}} opts
170
+ * @returns {number} the exit code.
171
+ */
172
+ function freshTicket({ repoRoot, run, block, file, where, say, dropDone = dropDoneIf }) {
173
+ const { ticket } = resolveTicket({ repoRoot, run, block, file, tree: where.tree ?? null });
174
+ const markerPath = path.join(queueDir(repoRoot), `${ticket}.done`);
175
+ /** @type {'rerun' | 'tampered' | 'counted'} */
176
+ let verdict = 'counted';
177
+ // a marker that changed under the claim is classified again; bounded, then refused (fail closed)
178
+ for (let attempt = 0; attempt < FRESH_ATTEMPTS; attempt += 1) {
179
+ /** @type {Buffer | null} */
180
+ let bytes;
181
+ try {
182
+ bytes = readFileSync(markerPath);
183
+ } catch (err) {
184
+ // no marker: a normal enqueue; a marker that exists but cannot be read is never dropped
185
+ bytes = /** @type {{code?: unknown}} */ (err)?.code === 'ENOENT' ? null : Buffer.alloc(0);
186
+ }
187
+ if (bytes === null) {
188
+ say(enqueue({ repoRoot, run, block, file, ...where }));
189
+ return 0;
190
+ }
191
+ // classified IN PLACE: a refusal never moves, renames or touches the marker
192
+ const text = bytes.toString('utf8');
193
+ verdict = freshVerdict(text, resultOfMarker(repoRoot, text));
194
+ if (verdict !== 'rerun') break;
195
+ /** @type {unknown} */
196
+ let outcome;
197
+ try {
198
+ outcome = dropDone(repoRoot, ticket, bytes);
199
+ } catch (err) {
200
+ // a named refusal passes through; any other throw is named here, never echoed
201
+ if (err instanceof WorkerError) throw err;
202
+ throw new WorkerError('fresh-claim-failed', `the claim of the cached result of ${file} failed; nothing was queued`);
203
+ }
204
+ if (outcome === 'changed') {
205
+ verdict = 'counted';
206
+ continue;
207
+ }
208
+ // an allow-list of outcomes: anything else is refused and nothing is queued
209
+ if (outcome !== 'dropped' && outcome !== 'gone') throw new WorkerError('fresh-claim-unknown', `the claim of the cached result of ${file} gave an unknown outcome; nothing was queued`);
210
+ // dropped: queue it again now (a crash right here leaves a pending ticket with no marker, which
211
+ // the next review-file queues anyway); gone: another caller took the marker, a normal enqueue
212
+ const queued = enqueue({ repoRoot, run, block, file, ...where });
213
+ say(outcome === 'dropped' ? { ...queued, fresh: true } : queued);
214
+ return 0;
215
+ }
216
+ if (verdict === 'tampered') {
217
+ say({ status: 'refused', reason: 'fresh-tampered', ticket, file, exits: ['block close --no-require-reviews'], message: `fresh-tampered: the cached result of ${file} is review-state-tampered (a security event, not a stop); only the owner's block close --no-require-reviews gets past it; the cached result was kept` });
218
+ return 1;
219
+ }
220
+ say({
221
+ status: 'refused',
222
+ reason: 'fresh-needs-reopen',
223
+ ticket,
224
+ file,
225
+ exits: [...FRESH_EXITS],
226
+ message: `fresh-needs-reopen: the cached result of ${file} counts as a review (or cannot be read, or kept changing under the claim); --fresh re-runs only stale, unavailable or refused other-run/worktree-*/context-unreadable results; the human's ways out are block reopen and block waive; the cached result was kept`,
227
+ });
228
+ return 1;
229
+ }
230
+
81
231
  /**
82
232
  * B62b: where the block `block` of run `runId` keeps its content, from the run record (read from
83
233
  * the user's state dir, `~/.code-forge/runs`, whatever the current directory): `{top}` with the
@@ -112,7 +262,63 @@ async function blockTreeOf(runId, block) {
112
262
  }
113
263
 
114
264
  /**
115
- * The coder-facing summary of a done result: the fix list and the stop reason, when any.
265
+ * B71: the coder-side pre-checks of an answers ticket (fast refusal only): with a run record this
266
+ * process can open, the block must own both files and every id must be open on the source's
267
+ * current hash — named by the latest refusal or cap row there, not waived or answered yet — read by
268
+ * the close's rules over unverified rows ({@link namedOnHash}). A record or ledger that
269
+ * cannot be read here skips the check it needs: the worker re-checks everything with signed rows.
270
+ * The rows of the ticket this request gets do not close an id (the retry of an attempt whose
271
+ * ledger write failed part way). `treeName`: the ticket's tree (null in the main tree).
272
+ * @param {{runId: string, block: string, file: string, byFile: string, tree: string, answers: string[], treeName: string | null}} opts
273
+ * @throws {WorkerError} `answers-not-owned`, `answers-not-open`
274
+ */
275
+ async function answersPrecheck({ runId, block, file, byFile, tree, answers, treeName }) {
276
+ let record;
277
+ try {
278
+ record = await readRun(runId);
279
+ } catch {
280
+ return;
281
+ }
282
+ const entry = record?.blocks?.[block];
283
+ if (!entry || typeof entry !== 'object') return;
284
+ const owned = Array.isArray(entry.owned_files) ? entry.owned_files : [];
285
+ if (!ownsFile(owned, file, entry.owned_kinds) || !ownsFile(owned, byFile, entry.owned_kinds)) {
286
+ throw new WorkerError('answers-not-owned', `block ${block} does not own both ${byFile} and ${file}; nothing was queued`);
287
+ }
288
+ let rows;
289
+ try {
290
+ rows = await readAllRows(record.project);
291
+ } catch {
292
+ return;
293
+ }
294
+ const hash = contentHash(tree, file);
295
+ const current = new Map([
296
+ [file, hash],
297
+ [byFile, contentHash(tree, byFile)],
298
+ ]);
299
+ const by_hash = /** @type {string} */ (current.get(byFile));
300
+ const ticket = answersTicketId({ run: runId, block, file, content_hash: hash, by_file: byFile, by_hash, answers, tree: treeName });
301
+ const named = namedOnHash({ block, runId, rows, file, hash, current, ticket });
302
+ const missing = answers.filter((id) => !named.has(id));
303
+ if (missing.length > 0) throw new WorkerError('answers-not-open', `${missing.join(', ')} is not an open finding of ${file} at its current content; nothing was queued`);
304
+ }
305
+
306
+ /**
307
+ * B71: the `--answers` list: comma-separated finding ids, 1 to {@link MAX_ANSWERS}.
308
+ * @param {unknown} raw @returns {string[]}
309
+ * @throws {WorkerError} `usage`
310
+ */
311
+ function answersOf(raw) {
312
+ if (typeof raw !== 'string') throw new WorkerError('usage', '--answers needs finding ids');
313
+ const ids = raw.split(',');
314
+ if (ids.length > MAX_ANSWERS) throw new WorkerError('usage', `--answers takes at most ${MAX_ANSWERS} finding ids`);
315
+ if (new Set(ids).size !== ids.length) throw new WorkerError('usage', '--answers names a finding id twice');
316
+ return assertAnswers(ids);
317
+ }
318
+
319
+ /**
320
+ * The coder-facing summary of a done result: the fix list and the stop reason, when any. B71: an
321
+ * answers result gives `answers: [{id, verdict, reason}]` instead.
116
322
  * @param {Record<string, any> | null} result
117
323
  * @returns {Record<string, any>}
118
324
  */
@@ -120,6 +326,10 @@ export function fixList(result) {
120
326
  if (!result || result.approved === true) return {};
121
327
  /** @type {Record<string, any>} */
122
328
  const out = {};
329
+ if (result.kind === 'answers' || result.status === 'answers') {
330
+ if (Array.isArray(result.findings)) out.answers = result.findings.map((/** @type {Record<string, any>} */ f) => ({ id: f?.id ?? null, verdict: f?.verdict ?? 'not_resolved', reason: f?.reason ?? '' }));
331
+ return out;
332
+ }
123
333
  if (Array.isArray(result.findings) && result.findings.length > 0) {
124
334
  out.fix_list = result.findings.map((/** @type {Record<string, any>} */ f) => ({
125
335
  id: f?.id ?? null,
@@ -129,8 +339,11 @@ export function fixList(result) {
129
339
  fix: f?.fix ?? '',
130
340
  }));
131
341
  }
132
- if (typeof result.stopped === 'string') out.stop = result.stopped;
133
- else if (result.status === 'split_required') out.stop = 'split_required';
342
+ if (typeof result.stopped === 'string') {
343
+ out.stop = result.stopped;
344
+ // B73: a stopped file names its ways out (never a refused or tampered state: no `stopped`)
345
+ out.exits = [...STOP_EXITS];
346
+ } else if (result.status === 'split_required') out.stop = 'split_required';
134
347
  // B54: a split_required stop (only) says how large the packet was, the budget, and the section
135
348
  if (out.stop === 'split_required') {
136
349
  const fin = (/** @type {unknown} */ v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
@@ -141,7 +354,7 @@ export function fixList(result) {
141
354
 
142
355
  /**
143
356
  * @param {string[]} args
144
- * @param {{stdout?: {write: (s: string) => unknown}, stderr?: {write: (s: string) => unknown}, cwd?: string, pollMs?: number}} [deps]
357
+ * @param {{stdout?: {write: (s: string) => unknown}, stderr?: {write: (s: string) => unknown}, cwd?: string, pollMs?: number, dropDone?: typeof dropDoneIf}} [deps] - `dropDone`: B75's claim, for tests.
145
358
  * @returns {Promise<number>}
146
359
  */
147
360
  export async function runReviewFile(args, deps = {}) {
@@ -150,12 +363,12 @@ export async function runReviewFile(args, deps = {}) {
150
363
  const say = (/** @type {Record<string, any>} */ obj) => writeSafe(stdout, `${JSON.stringify(obj)}\n`);
151
364
  const cwd = deps.cwd ?? process.cwd();
152
365
  try {
153
- const { flags, positionals } = parseFlags(args, { values: ['block', 'run', 'wait', 'max'] });
366
+ const { flags, positionals } = parseFlags(args, { values: ['block', 'run', 'wait', 'max', 'answers', 'of'], booleans: ['fresh'] });
154
367
  // the queue and the worker: the main tree, also from inside a block's worktree
155
368
  const repoRoot = await mainRootOf(cwd);
156
369
 
157
370
  if (typeof flags.wait === 'string') {
158
- if (positionals.length > 0 || flags.block !== undefined) throw new WorkerError('usage', '--wait takes only a ticket and --max');
371
+ if (positionals.length > 0 || flags.block !== undefined || flags.answers !== undefined || flags.of !== undefined || flags.fresh === true) throw new WorkerError('usage', '--wait takes only a ticket and --max');
159
372
  const ticket = assertTicketId(flags.wait);
160
373
  const maxMs = flags.max === undefined ? DEFAULT_WAIT_MS : parseMax(flags.max);
161
374
  const deadline = Date.now() + maxMs;
@@ -181,6 +394,8 @@ export async function runReviewFile(args, deps = {}) {
181
394
 
182
395
  if (positionals.length !== 1 || typeof flags.block !== 'string' || flags.max !== undefined) throw new WorkerError('usage', 'give one path and --block');
183
396
  const block = assertBlockId(flags.block);
397
+ if (flags.fresh === true && (flags.answers !== undefined || flags.of !== undefined)) throw new WorkerError('usage', '--fresh takes no --answers or --of');
398
+ if (flags.answers !== undefined || flags.of !== undefined) return await enqueueAnswersTicket({ flags, answering: positionals[0], block, cwd, repoRoot, say });
184
399
  const file = normalizeRequestPath(positionals[0], cwd, await repoRootOf(cwd));
185
400
  const worker = liveWorker(repoRoot);
186
401
  if (!worker || (typeof flags.run === 'string' && flags.run !== worker.run)) {
@@ -193,6 +408,7 @@ export async function runReviewFile(args, deps = {}) {
193
408
  const tree = recorded === null ? await repoRootOf(cwd) : (recorded.top ?? repoRoot);
194
409
  assertInsideRoot(tree, file); // the path, re-checked in the tree its content is read from
195
410
  const where = realpathSync(tree) === realpathSync(repoRoot) ? {} : { tree };
411
+ if (flags.fresh === true) return freshTicket({ repoRoot, run: worker.run, block, file, where, say, ...(deps.dropDone ? { dropDone: deps.dropDone } : {}) });
196
412
  const queued = enqueue({ repoRoot, run: worker.run, block, file, ...where });
197
413
  if (queued.status === 'done' && doneResult(repoRoot, queued.ticket)?.status === 'unavailable') {
198
414
  // `unavailable` is never approval AND never final: drop the done marker so the worker runs
@@ -214,6 +430,41 @@ export async function runReviewFile(args, deps = {}) {
214
430
  }
215
431
  }
216
432
 
433
+ /**
434
+ * B71: `review-file <answering file> --block <id> --answers <ids> --of <source>`.
435
+ * @param {{flags: Record<string, any>, answering: string, block: string, cwd: string, repoRoot: string, say: (obj: Record<string, any>) => unknown}} opts
436
+ * @returns {Promise<number>}
437
+ * @throws {WorkerError}
438
+ */
439
+ async function enqueueAnswersTicket({ flags, answering, block, cwd, repoRoot, say }) {
440
+ if (typeof flags.of !== 'string' || flags.of.length === 0) throw new WorkerError('usage', '--answers needs --of <source>');
441
+ const answers = answersOf(flags.answers);
442
+ const top = await repoRootOf(cwd);
443
+ const byFile = normalizeRequestPath(answering, cwd, top);
444
+ const file = normalizeRequestPath(flags.of, cwd, top);
445
+ if (byFile === file) throw new WorkerError('answers-same-file', `${file} cannot answer its own findings: give the file that resolves them`);
446
+ const worker = liveWorker(repoRoot);
447
+ if (!worker || (typeof flags.run === 'string' && flags.run !== worker.run)) {
448
+ say({ status: 'worker_down', file });
449
+ return 3;
450
+ }
451
+ const recorded = await blockTreeOf(worker.run, block);
452
+ const tree = recorded === null ? top : (recorded.top ?? repoRoot);
453
+ assertInsideRoot(tree, file);
454
+ assertInsideRoot(tree, byFile);
455
+ const where = realpathSync(tree) === realpathSync(repoRoot) ? {} : { tree };
456
+ await answersPrecheck({ runId: worker.run, block, file, byFile, tree, answers, treeName: where.tree === undefined ? null : realpathSync(tree) });
457
+ const opts = { repoRoot, run: worker.run, block, file, byFile, answers, ...where };
458
+ const queued = enqueueAnswers(opts);
459
+ if (queued.status === 'done' && doneResult(repoRoot, queued.ticket)?.status === 'unavailable') {
460
+ rmSync(path.join(queueDir(repoRoot), `${queued.ticket}.done`), { force: true });
461
+ say({ ...enqueueAnswers(opts), kind: 'answers', retry: true });
462
+ return 0;
463
+ }
464
+ say({ ...queued, kind: 'answers' });
465
+ return 0;
466
+ }
467
+
217
468
  /** @param {string[]} args @returns {Promise<number>} */
218
469
  export default async function run(args) {
219
470
  return runReviewFile(args);
package/src/cli/spawn.mjs CHANGED
@@ -6,6 +6,12 @@
6
6
  * on stdout; the `level=… provider=… model=… effort=… fallback_step=…` line goes to stderr.
7
7
  * Exit codes: 0 ok / started, 1 failed (timeout, bad output, refused), 2 usage, 3 unavailable
8
8
  * (every step of the ladder reported unavailability).
9
+ *
10
+ * B93: with `--role coder --run <id>` of a recorded run, `coder.sandbox` in the live config must
11
+ * equal the run-start config snapshot's: a value that differs refuses the session
12
+ * (`sandbox-changed`, exit 1) before anything is spawned. Other roles never read the key and are
13
+ * not checked. A record from before snapshots, a run id with no record, or no `--run` uses the
14
+ * live config. A record that cannot be read is `run-unreadable` (exit 2).
9
15
  */
10
16
 
11
17
  import { readFile } from 'node:fs/promises';
@@ -14,6 +20,8 @@ import { loadProjectConfig, projectRootFor, slugFor } from '../config/load.mjs';
14
20
  import { ROLES, runRootFor, SessionError, spawnSession } from '../session/spawn.mjs';
15
21
  import { intFlag, parseFlags } from '../state/cli-args.mjs';
16
22
  import { StateError } from '../state/paths.mjs';
23
+ import { readRun } from '../state/run.mjs';
24
+ import { canonicalJSON, coderSandbox } from '../state/config-snapshot.mjs';
17
25
  import { writeSafe } from '../util/redact.mjs';
18
26
 
19
27
  /**
@@ -46,6 +54,36 @@ export function exitCodeFor(result) {
46
54
  return result.status === 'unavailable' ? 3 : 1;
47
55
  }
48
56
 
57
+ /** The SessionError codes that are a refusal (exit 1), not a usage error (exit 2). */
58
+ const REFUSED_CODES = new Set(['forbidden', 'sandbox-changed', 'sandbox-dir-refused']);
59
+
60
+ /**
61
+ * B93: for `--role coder --run <id>`, the live config, after checking that its `coder.sandbox`
62
+ * equals the run-start snapshot's (the key is fixed for the run). The value is the same, so the
63
+ * live config is what the session builds from. A run id with no record, or a record from before
64
+ * snapshots (no run-start value), uses the live config unchecked.
65
+ * @param {Record<string, any>} live @param {string} runId
66
+ * @returns {Promise<Record<string, any>>}
67
+ * @throws {SessionError} `sandbox-changed` when the live value differs from the snapshot's;
68
+ * `run-unreadable` when the run record exists but cannot be read, parsed or understood.
69
+ * @throws {StateError} `config-snapshot` when the snapshot in force does not match its hash.
70
+ */
71
+ async function checkRunSandbox(live, runId) {
72
+ let pinned;
73
+ try {
74
+ pinned = coderSandbox(await readRun(runId));
75
+ } catch (err) {
76
+ if (err instanceof StateError && err.code === 'no-run') return live; // no recorded run: the live config
77
+ if (err instanceof StateError) throw err; // e.g. config-snapshot: named, exit 2
78
+ throw new SessionError('run-unreadable', `run-unreadable: the record of run ${runId} cannot be read, so coder.sandbox cannot be checked against the run start`);
79
+ }
80
+ if (!pinned.snapshot) return live; // a run from before snapshots: no run-start value to hold to
81
+ if (canonicalJSON(pinned.sandbox ?? null) !== canonicalJSON(live?.coder?.sandbox ?? null)) {
82
+ throw new SessionError('sandbox-changed', `sandbox-changed: coder.sandbox in the live config differs from the run ${runId} snapshot — put it back, or end the run and start a new one`);
83
+ }
84
+ return live;
85
+ }
86
+
49
87
  /**
50
88
  * @param {string[]} args
51
89
  * @param {import('../session/spawn.mjs').SessionDeps & {stdout?: {write: (s: string) => unknown}}} [deps]
@@ -67,9 +105,12 @@ export async function runSpawn(args, deps = {}) {
67
105
  writeSafe(stderr, `spawn: ${loaded.message}\n`);
68
106
  return 2;
69
107
  }
70
- const cfg = loaded.config;
71
108
  const timeout = intFlag(flags.timeout, 'timeout');
72
109
  const schema = typeof flags.schema === 'string' ? await readJSONFlag(flags.schema, 'schema') : undefined;
110
+ // the run id is checked (and its temp root made) before the run record is read
111
+ const runRootDir = typeof flags.run === 'string' ? runRootFor(flags.run, loaded.config?.tmp?.root) : undefined;
112
+ // B93: only a coder reads coder.sandbox, so only a coder session is held to the run-start value
113
+ const cfg = typeof flags.run === 'string' && flags.role === 'coder' ? await checkRunSandbox(loaded.config, flags.run) : loaded.config;
73
114
  const result = await spawnSession(
74
115
  {
75
116
  cfg,
@@ -80,7 +121,7 @@ export async function runSpawn(args, deps = {}) {
80
121
  schema,
81
122
  timeoutMs: timeout === undefined ? undefined : timeout * 1000,
82
123
  background: flags.background === true,
83
- runRoot: typeof flags.run === 'string' ? runRootFor(flags.run, cfg?.tmp?.root) : undefined,
124
+ runRoot: runRootDir,
84
125
  run: typeof flags.run === 'string' ? flags.run : undefined,
85
126
  block: typeof flags.block === 'string' ? flags.block : undefined,
86
127
  slug: slugFor(cfg, root),
@@ -93,7 +134,7 @@ export async function runSpawn(args, deps = {}) {
93
134
  } catch (err) {
94
135
  if (err instanceof StateError || err instanceof SessionError) {
95
136
  writeSafe(stderr, `spawn: ${/** @type {Error} */ (err).message}\n`);
96
- return err instanceof SessionError && err.code === 'forbidden' ? 1 : 2;
137
+ return err instanceof SessionError && REFUSED_CODES.has(err.code) ? 1 : 2;
97
138
  }
98
139
  throw err;
99
140
  }