@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.
- package/CHANGELOG.md +114 -0
- package/README.md +46 -7
- package/docs/reference/blocks.json +618 -1
- package/docs/reference/config.md +36 -0
- package/package.json +1 -1
- package/schema/code-forge.schema.json +89 -0
- package/skill/references/adapters/claude-code.md +1 -1
- package/skill/references/adapters/solo.md +2 -2
- package/skill/references/adapters/subprocess.md +12 -2
- package/skill/references/code.md +21 -7
- package/skill/references/ledger.md +2 -2
- package/skill/references/proof.md +77 -2
- package/skill/references/review.md +29 -4
- package/skill/references/security.md +5 -5
- package/skill/scripts/forge +1 -1
- package/skill/templates/coder-brief.md +4 -2
- package/src/autopilot/binnacle.mjs +9 -3
- package/src/cli/block.mjs +300 -33
- package/src/cli/jev.mjs +22 -3
- package/src/cli/keys.mjs +156 -21
- package/src/cli/proof.mjs +35 -7
- package/src/cli/review-file.mjs +264 -13
- package/src/cli/spawn.mjs +44 -3
- package/src/config/validate.mjs +320 -5
- package/src/doctor/index.mjs +7 -5
- package/src/doctor/local.mjs +116 -8
- package/src/doctor/probes.mjs +38 -0
- package/src/engines/builders/codex.mjs +44 -4
- package/src/engines/sandbox-dirs.mjs +257 -0
- package/src/keys/store.mjs +128 -21
- package/src/ledger/write.mjs +7 -1
- package/src/proof/junit.mjs +530 -0
- package/src/proof/red-green.mjs +57 -9
- package/src/proof/red-parse.mjs +2 -0
- package/src/proof/runners.mjs +472 -0
- package/src/proof/xcresult.mjs +285 -0
- package/src/review/answers.mjs +445 -0
- package/src/review/deletion.mjs +106 -0
- package/src/review/engine.mjs +117 -31
- package/src/review/fixloop.mjs +70 -28
- package/src/review/gate-check.mjs +378 -80
- package/src/review/lenses/A.md +1 -0
- package/src/review/lenses/B.md +1 -0
- package/src/review/lenses/full.md +1 -0
- package/src/review/lenses/quick.md +1 -0
- package/src/review/lenses/recheck.md +1 -0
- package/src/review/reopen.mjs +256 -0
- package/src/review/route.mjs +232 -0
- package/src/review/schema-fallback.mjs +77 -22
- package/src/review/session-retry.mjs +44 -9
- package/src/review/stop-exits.mjs +15 -0
- package/src/review/test-quality.mjs +201 -0
- package/src/review/verb-forms.mjs +39 -0
- package/src/session/spawn.mjs +39 -2
- package/src/state/block.mjs +13 -3
- package/src/state/config-snapshot.mjs +39 -0
- package/src/state/run.mjs +4 -0
- package/src/util/forbidden.mjs +11 -1
- package/src/util/tmp.mjs +16 -4
- package/src/worker/engine.mjs +75 -9
- package/src/worker/loop.mjs +82 -15
- package/src/worker/queue.mjs +265 -10
- package/src/worker/review-state.mjs +346 -51
- package/src/worker/ticket.mjs +9 -1
|
@@ -44,6 +44,11 @@ export const IMMUTABLE_KEYS = Object.freeze({
|
|
|
44
44
|
// B64b: the block-end hook runs a command on the owner's machine at every block end, so only
|
|
45
45
|
// the owner sets it, before the run starts
|
|
46
46
|
'hooks.on_block_end': 'it runs a command on the owner\'s machine at every block end; fixed by the owner before the run starts',
|
|
47
|
+
// B87: the runner argv proves the coder's own work, so only the owner sets it, before the run starts
|
|
48
|
+
'proof.runners': 'the commands that prove a block are fixed by the owner before the run starts',
|
|
49
|
+
// B93: the coder sandbox profile widens what the coder may write, so only the owner sets it,
|
|
50
|
+
// before the run starts (a coder can never own `.code-forge.yml`)
|
|
51
|
+
'coder.sandbox': 'the coder sandbox widens what the coder may write; fixed by the owner before the run starts',
|
|
47
52
|
});
|
|
48
53
|
|
|
49
54
|
/** @param {unknown} v @returns {boolean} what `JSON.stringify` drops from an object. */
|
|
@@ -235,3 +240,37 @@ export function blockEndHook(record) {
|
|
|
235
240
|
if (!Number.isInteger(timeoutS) || timeoutS < HOOK_TIMEOUT_MIN_S || timeoutS > HOOK_TIMEOUT_MAX_S) return null;
|
|
236
241
|
return { argv: [...argv], timeoutS };
|
|
237
242
|
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* B87: `proof.runners` of the run-start config snapshot (never the live config), as a deep copy
|
|
246
|
+
* (entries and argv arrays), or null when the key is absent or the record is from before
|
|
247
|
+
* snapshots. `proof/runners.mjs` checks each entry again before it runs one (fail closed).
|
|
248
|
+
* @param {Record<string, any>} record
|
|
249
|
+
* @returns {unknown[] | null}
|
|
250
|
+
* @throws {StateError} `config-snapshot` when the snapshot in force does not match its hash;
|
|
251
|
+
* `runner-invalid` when the stored value is not a list, or is an empty one.
|
|
252
|
+
*/
|
|
253
|
+
export function proofRunners(record) {
|
|
254
|
+
const runners = configInForce(record)?.proof?.runners;
|
|
255
|
+
if (runners === undefined || runners === null) return null;
|
|
256
|
+
if (!Array.isArray(runners)) throw new StateError('runner-invalid', `run ${record.run_id}: proof.runners in the run snapshot is not a list`);
|
|
257
|
+
// `validate` refuses an empty list (minItems 1); a record that holds one anyway is refused too
|
|
258
|
+
if (runners.length === 0) throw new StateError('runner-invalid', `run ${record.run_id}: proof.runners in the run snapshot is an empty list`);
|
|
259
|
+
// a deep copy: a caller that changes an entry or an argv array never changes the snapshot
|
|
260
|
+
return structuredClone(runners);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* B93: `coder.sandbox` of the run-start config snapshot (never the live config). A record from
|
|
265
|
+
* before snapshots has no run-start value: `{snapshot: false}`. Otherwise `{snapshot: true,
|
|
266
|
+
* sandbox}`, where `sandbox` is a deep copy of the value, or null when the snapshot has no key.
|
|
267
|
+
* @param {Record<string, any>} record
|
|
268
|
+
* @returns {{snapshot: false} | {snapshot: true, sandbox: Record<string, any> | null}}
|
|
269
|
+
* @throws {StateError} `config-snapshot` when the snapshot in force does not match its hash.
|
|
270
|
+
*/
|
|
271
|
+
export function coderSandbox(record) {
|
|
272
|
+
const config = configInForce(record);
|
|
273
|
+
if (config === null) return { snapshot: false };
|
|
274
|
+
const sandbox = config.coder?.sandbox;
|
|
275
|
+
return { snapshot: true, sandbox: sandbox === undefined || sandbox === null ? null : structuredClone(sandbox) };
|
|
276
|
+
}
|
package/src/state/run.mjs
CHANGED
|
@@ -269,6 +269,10 @@ export async function reloadRun({ runId, config, readPending = () => [], effecti
|
|
|
269
269
|
if (config.context?.require_coder_tests === true) frozen.push('context.require_coder_tests');
|
|
270
270
|
// B64b: and never had a block-end hook
|
|
271
271
|
if (config.hooks?.on_block_end !== undefined) frozen.push('hooks.on_block_end');
|
|
272
|
+
// B87: and never had a proof runner
|
|
273
|
+
if (config.proof?.runners !== undefined) frozen.push('proof.runners');
|
|
274
|
+
// B93: and never had a coder sandbox profile
|
|
275
|
+
if (config.coder?.sandbox !== undefined) frozen.push('coder.sandbox');
|
|
272
276
|
}
|
|
273
277
|
if (frozen.length > 0) {
|
|
274
278
|
const why = frozen.map((key) => `${key} cannot change mid-run (${IMMUTABLE_KEYS[/** @type {keyof typeof IMMUTABLE_KEYS} */ (key)]})`);
|
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
|
|
@@ -291,7 +292,16 @@ export const CODER_ONLY_FORBIDDEN = Object.freeze(
|
|
|
291
292
|
['code-forge', 'block', 'waive'],
|
|
292
293
|
['forge', 'block', 'waive'],
|
|
293
294
|
],
|
|
294
|
-
description: 'Waive a review finding (forge block waive) — human-only,
|
|
295
|
+
description: 'Waive a review finding (forge block waive) — human-only, typed by the human in their own terminal (§4.9, B80)',
|
|
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, typed by the human in their own terminal (B72, B80)',
|
|
295
305
|
},
|
|
296
306
|
{
|
|
297
307
|
id: 'code-forge-autopilot-from-coder',
|
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
|
@@ -39,7 +39,7 @@ import path from 'node:path';
|
|
|
39
39
|
import { fileURLToPath } from 'node:url';
|
|
40
40
|
import { expiryChecks } from '../autopilot/limits.mjs';
|
|
41
41
|
import { loadProjectConfig } from '../config/load.mjs';
|
|
42
|
-
import { createDefaultKeyStore, resolveKey } from '../keys/store.mjs';
|
|
42
|
+
import { cacheHoursFrom, createDefaultKeyStore, resolveKey } from '../keys/store.mjs';
|
|
43
43
|
import { appendRow, readAllRows } from '../ledger/write.mjs';
|
|
44
44
|
import { spawnSession } from '../session/spawn.mjs';
|
|
45
45
|
import { snapshotFor } from '../state/config-snapshot.mjs';
|
|
@@ -47,12 +47,13 @@ import { readRun, reattachWorker } from '../state/run.mjs';
|
|
|
47
47
|
import { loadKey, signRow } from '../state/signer.mjs';
|
|
48
48
|
import { logWarning } from '../util/error-log.mjs';
|
|
49
49
|
import { keyedLock } from '../util/locks.mjs';
|
|
50
|
-
import { writeSafe } from '../util/redact.mjs';
|
|
50
|
+
import { redact, 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 { countLines, RouteError, routeTicket } from '../review/route.mjs';
|
|
53
54
|
import { canonicalPath, contentRootFor, reviewTicket } from './engine.mjs';
|
|
54
55
|
import { announceWorker, beatWorker, HEARTBEAT_MS, liveWorker, pendingTickets, readTicket, retractWorker, writeResult } from './queue.mjs';
|
|
55
|
-
import {
|
|
56
|
+
import { DELETED_HASH, hashBytes, readRegularFileNoFollow, repoRootOf, WorkerError } from './ticket.mjs';
|
|
56
57
|
|
|
57
58
|
/** Name of the key the worker resolves for System 1 (`keys.jev` / `system1.key` reference). */
|
|
58
59
|
export const JEV_KEY_NAME = 'jev';
|
|
@@ -136,16 +137,37 @@ export function sessionEnv(env, secrets) {
|
|
|
136
137
|
/**
|
|
137
138
|
* Resolve the Jev key through B2's chain (env → store → 1Password). A missing key is not an error:
|
|
138
139
|
* System 1 then falls back (`system1.fallback`, B3). The value is registered for redaction by B2.
|
|
140
|
+
* A refresh from 1Password is cached for the config's `keys.cache_hours` (B84: the run's snapshot;
|
|
141
|
+
* a snapshot from before the key has none ⇒ 8 h).
|
|
139
142
|
* @param {Record<string, any>} cfg
|
|
140
|
-
* @param {{store: import('../keys/store.mjs').KeyStore, env: NodeJS.ProcessEnv, opRead?: any}} deps
|
|
143
|
+
* @param {{store: import('../keys/store.mjs').KeyStore, env: NodeJS.ProcessEnv, opRead?: any, now?: number}} deps
|
|
141
144
|
* @returns {Promise<string | null>}
|
|
142
145
|
*/
|
|
143
|
-
export async function resolveJevKey(cfg, { store, env, opRead }) {
|
|
146
|
+
export async function resolveJevKey(cfg, { store, env, opRead, now }) {
|
|
144
147
|
const ref = cfg?.system1?.key ?? cfg?.keys?.[JEV_KEY_NAME];
|
|
145
|
-
const res = await resolveKey(JEV_KEY_NAME, {
|
|
148
|
+
const res = await resolveKey(JEV_KEY_NAME, {
|
|
149
|
+
store,
|
|
150
|
+
env,
|
|
151
|
+
ref: typeof ref === 'string' ? ref : undefined,
|
|
152
|
+
cacheHours: cacheHoursFrom(cfg).hours,
|
|
153
|
+
...(opRead ? { opRead } : {}),
|
|
154
|
+
...(now !== undefined ? { now } : {}),
|
|
155
|
+
});
|
|
146
156
|
return res.value;
|
|
147
157
|
}
|
|
148
158
|
|
|
159
|
+
/**
|
|
160
|
+
* B90: an unexpected failure inside a named worker step (today: `routing`). The ticket still ends
|
|
161
|
+
* `worker-error`; its result's `detail` says `<step>: <original error text>` (redacted).
|
|
162
|
+
*/
|
|
163
|
+
class StepError extends Error {
|
|
164
|
+
/** @param {string} step @param {unknown} cause */
|
|
165
|
+
constructor(step, cause) {
|
|
166
|
+
super(`${step}: ${cause instanceof Error ? `${cause.name}: ${cause.message}` : String(cause)}`, { cause });
|
|
167
|
+
this.step = step;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
149
171
|
/** The fix-loop fields of an engine outcome the result carries (B12c, §4.11). */
|
|
150
172
|
const LOOP_FIELDS = Object.freeze(['round', 'kind', 'level', 'next', 'trigger', 'stopped', 'late']);
|
|
151
173
|
|
|
@@ -282,14 +304,51 @@ export async function createWorker(opts, deps = {}) {
|
|
|
282
304
|
}
|
|
283
305
|
|
|
284
306
|
/**
|
|
285
|
-
* One ticket through the engine hook with
|
|
307
|
+
* One ticket through the engine hook with the ticket's config snapshot, routed by size (B90): the unsigned result.
|
|
308
|
+
* @param {string} id - the ticket id (B90: a retried ticket never writes its route rows twice).
|
|
286
309
|
* @param {import('./queue.mjs').Ticket} ticket @param {Record<string, any>} base
|
|
287
|
-
* @param {Record<string, any>}
|
|
310
|
+
* @param {Record<string, any>} snapshotCfg - the ticket's config snapshot, before B90 routing.
|
|
288
311
|
* @param {string} contentRoot - B62b: the tree the content was hashed in, and is reviewed in.
|
|
312
|
+
* @param {{lines: number, deleted: boolean}} size - B90: the line count of the hashed bytes.
|
|
289
313
|
* @returns {Promise<Record<string, any>>}
|
|
290
314
|
*/
|
|
291
|
-
async function reviewWith(ticket, base,
|
|
292
|
-
const
|
|
315
|
+
async function reviewWith(id, ticket, base, snapshotCfg, contentRoot, size) {
|
|
316
|
+
const baseTimeoutS = Number.isInteger(snapshotCfg?.review?.session_timeout_s) && snapshotCfg.review.session_timeout_s > 0 ? snapshotCfg.review.session_timeout_s : DEFAULT_SESSION_TIMEOUT_S;
|
|
317
|
+
// B90: `review.routes` picks the model, second level and timeout of every session of this
|
|
318
|
+
// ticket from its size, once, before the first session. No route ⇒ the snapshot object itself.
|
|
319
|
+
// A route list that cannot be used is `unavailable` — never a review on a model it did not name.
|
|
320
|
+
let routed;
|
|
321
|
+
try {
|
|
322
|
+
routed = routeTicket(snapshotCfg, { lines: size.lines, deleted: size.deleted, timeoutS: baseTimeoutS });
|
|
323
|
+
} catch (err) {
|
|
324
|
+
// only a route list that cannot be used is `route-invalid`; anything else is a real failure
|
|
325
|
+
// and goes to runOne (`worker-error`), named as a routing-step failure with its own text
|
|
326
|
+
if (!(err instanceof RouteError)) throw new StepError('routing', err);
|
|
327
|
+
return { ...base, status: 'unavailable', reason: 'route-invalid', approved: false };
|
|
328
|
+
}
|
|
329
|
+
const tcfg = routed.cfg;
|
|
330
|
+
const timeoutS = routed.timeoutS;
|
|
331
|
+
// the signed `review.route` row comes before the first session; a row that cannot be written
|
|
332
|
+
// is `unavailable` (a routed review the ledger cannot explain never runs). One row per routed
|
|
333
|
+
// level per ticket: a retried ticket (same id) skips the levels whose row the ledger already has.
|
|
334
|
+
// `runId` is the run this ticket belongs to (reviewOne refuses `ticket.run !== runId` as
|
|
335
|
+
// other-run), and a restarted worker serves the same run id (startWorker: `opts.runId`), so the
|
|
336
|
+
// check holds across workers of that run.
|
|
337
|
+
if (routed.rows.length > 0) {
|
|
338
|
+
try {
|
|
339
|
+
const have = new Set(
|
|
340
|
+
(await readRows())
|
|
341
|
+
.filter((r) => r?.event === 'review.route' && r.run === runId && r.ticket === id)
|
|
342
|
+
.map((r) => r.level),
|
|
343
|
+
);
|
|
344
|
+
for (const row of routed.rows) {
|
|
345
|
+
if (have.has(row.level)) continue;
|
|
346
|
+
await ledger({ ...row, ticket: id, block: ticket.block, file: ticket.file, content_hash: ticket.content_hash });
|
|
347
|
+
}
|
|
348
|
+
} catch {
|
|
349
|
+
return { ...base, status: 'unavailable', reason: 'route-row', approved: false };
|
|
350
|
+
}
|
|
351
|
+
}
|
|
293
352
|
/** @type {import('./engine.mjs').ReviewContext} */
|
|
294
353
|
const ctx = {
|
|
295
354
|
repoRoot,
|
|
@@ -318,6 +377,7 @@ export async function createWorker(opts, deps = {}) {
|
|
|
318
377
|
approved: outcome.approved === true,
|
|
319
378
|
engine: outcome.engine,
|
|
320
379
|
...(typeof outcome.reason === 'string' ? { reason: outcome.reason } : {}),
|
|
380
|
+
...(typeof outcome.message === 'string' ? { message: outcome.message } : {}),
|
|
321
381
|
...(Array.isArray(outcome.findings) ? { findings: outcome.findings } : {}),
|
|
322
382
|
...packetSize(outcome),
|
|
323
383
|
...loopResult(outcome),
|
|
@@ -381,22 +441,27 @@ export async function createWorker(opts, deps = {}) {
|
|
|
381
441
|
if (canonicalPath(hashedIn) !== canonicalPath(contentRoot)) return { ...base, status: 'unavailable', reason: 'worktree-mismatch', approved: false };
|
|
382
442
|
/** @type {string} */
|
|
383
443
|
let current;
|
|
444
|
+
/** @type {Buffer | null} */
|
|
445
|
+
let bytes;
|
|
384
446
|
try {
|
|
385
|
-
|
|
447
|
+
// B90: one read — the bytes hashed are the bytes whose lines route the review
|
|
448
|
+
bytes = readRegularFileNoFollow(contentRoot, ticket.file);
|
|
449
|
+
current = hashBytes(bytes);
|
|
386
450
|
} catch (err) {
|
|
387
451
|
// the path no longer passes the on-disk check, or the file cannot be read
|
|
388
452
|
const reason = err instanceof WorkerError ? err.code : 'unreadable';
|
|
389
453
|
return { ...base, status: 'refused', reason, approved: false };
|
|
390
454
|
}
|
|
391
455
|
if (current === DELETED_HASH && ticket.content_hash !== DELETED_HASH) return { ...base, status: 'stale', reason: 'missing-file', approved: false };
|
|
392
|
-
|
|
456
|
+
// B71: an answers ticket names why (its source moved); a review ticket keeps no reason
|
|
457
|
+
if (current !== ticket.content_hash) return { ...base, status: 'stale', ...(ticket.kind === 'answers' ? { reason: 'source-changed' } : {}), approved: false };
|
|
393
458
|
let ticketCfg;
|
|
394
459
|
try {
|
|
395
460
|
ticketCfg = await configFor(id);
|
|
396
461
|
} catch {
|
|
397
462
|
return { ...base, status: 'unavailable', reason: 'config-snapshot', approved: false };
|
|
398
463
|
}
|
|
399
|
-
return reviewWith(ticket, base, ticketCfg, contentRoot);
|
|
464
|
+
return reviewWith(id, ticket, base, ticketCfg, contentRoot, { lines: countLines(bytes), deleted: bytes === null });
|
|
400
465
|
}
|
|
401
466
|
|
|
402
467
|
/**
|
|
@@ -443,9 +508,11 @@ export async function createWorker(opts, deps = {}) {
|
|
|
443
508
|
async function runOne(id) {
|
|
444
509
|
try {
|
|
445
510
|
return (await processTicket(id)) !== null ? 1 : 0;
|
|
446
|
-
} catch {
|
|
511
|
+
} catch (err) {
|
|
512
|
+
// B90: a failure a step names (StepError) keeps its step and original text, redacted
|
|
513
|
+
const detail = err instanceof StepError ? { detail: redact(err.message) } : {};
|
|
447
514
|
try {
|
|
448
|
-
await complete(id, { event: 'review.result', status: 'unavailable', reason: 'worker-error', approved: false });
|
|
515
|
+
await complete(id, { event: 'review.result', status: 'unavailable', reason: 'worker-error', ...detail, approved: false });
|
|
449
516
|
return 1;
|
|
450
517
|
} catch {
|
|
451
518
|
poisoned.add(id);
|