@codedology/code-forge 0.14.0 → 0.15.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 +28 -0
- package/README.md +136 -438
- package/docs/README.md +1 -1
- package/docs/reference/blocks.json +420 -1
- package/docs/reference/config.md +7 -0
- package/package.json +1 -1
- package/schema/code-forge.schema.json +22 -0
- package/scripts/first-use.mjs +11 -5
- package/skill/SKILL.md +2 -0
- package/skill/references/autopilot.md +2 -0
- package/skill/references/code.md +25 -1
- package/skill/references/review.md +10 -2
- package/skill/scripts/forge +1 -1
- package/src/autopilot/scopes.mjs +2 -1
- package/src/cli/block.mjs +235 -44
- package/src/cli/gates.mjs +36 -6
- package/src/cli/ledger.mjs +21 -2
- package/src/cli/report.mjs +14 -0
- package/src/cli/review-file.mjs +56 -16
- package/src/cli/run.mjs +44 -7
- package/src/cli/status.mjs +7 -3
- package/src/cli/upgrade.mjs +95 -15
- package/src/config/validate.mjs +13 -1
- package/src/gates/acceptance.mjs +7 -3
- package/src/gates/expand.mjs +25 -2
- package/src/gates/run.mjs +38 -8
- package/src/install/link.mjs +12 -6
- package/src/ledger/report.mjs +29 -7
- package/src/review/accept-hit.mjs +22 -4
- package/src/review/engine.mjs +30 -10
- package/src/review/finding.schema.json +2 -1
- package/src/review/fixloop.mjs +79 -14
- package/src/review/gate-check.mjs +22 -3
- package/src/review/lenses/recheck.md +3 -0
- package/src/review/packet.mjs +26 -0
- package/src/state/block.mjs +162 -20
- package/src/state/run.mjs +35 -8
- package/src/util/forbidden.mjs +14 -0
- package/src/util/reaper.mjs +3 -3
- package/src/worker/engine.mjs +60 -4
- package/src/worker/loop.mjs +19 -10
- package/src/worker/queue.mjs +5 -2
package/src/state/run.mjs
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* parameter; the CLI verb does the static import) and are SIGNED with the run key first.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
import { existsSync } from 'node:fs';
|
|
15
16
|
import { mkdir, readFile, rename, rmdir, writeFile } from 'node:fs/promises';
|
|
16
17
|
import path from 'node:path';
|
|
17
18
|
import { exec } from '../util/exec.mjs';
|
|
@@ -31,7 +32,7 @@ const LOCK_POLL_MS = 25;
|
|
|
31
32
|
*/
|
|
32
33
|
export async function processStartTime(pid) {
|
|
33
34
|
if (!Number.isInteger(pid) || pid <= 0) return null;
|
|
34
|
-
const res = await exec(['ps', '-o', 'lstart=', '-p', String(pid)], {
|
|
35
|
+
const res = await exec([existsSync('/bin/ps') ? '/bin/ps' : 'ps', '-o', 'lstart=', '-p', String(pid)], {
|
|
35
36
|
env: { ...process.env, LC_ALL: 'C' },
|
|
36
37
|
okExitCodes: [0, 1],
|
|
37
38
|
timeoutMs: 10000,
|
|
@@ -177,6 +178,7 @@ export async function startRun(opts) {
|
|
|
177
178
|
orchestrator_pid: opts.orchestratorPid ?? process.pid,
|
|
178
179
|
solo_pid: opts.soloPid ?? null,
|
|
179
180
|
worker,
|
|
181
|
+
workers: worker ? [worker] : [],
|
|
180
182
|
status: 'active',
|
|
181
183
|
blocks: {},
|
|
182
184
|
orphans: [],
|
|
@@ -250,6 +252,10 @@ export async function reloadRun({ runId, config, readPending = () => [], effecti
|
|
|
250
252
|
if (oldHash === newHash) return { changed: [], oldHash, newHash, pinned: 0, rowError: null };
|
|
251
253
|
const before = state && Object.hasOwn(stored, state.hash) ? stored[state.hash] : undefined;
|
|
252
254
|
if (state && (before === null || typeof before !== 'object')) throw new StateError('config-snapshot', `run ${runId}: the current config snapshot is missing from the run record`);
|
|
255
|
+
// B138: close authority belongs to the run-start snapshot, including legacy runs.
|
|
256
|
+
if (before?.close?.require_human !== config.close?.require_human) {
|
|
257
|
+
throw new StateError('immutable-key', 'close.require_human cannot change mid-run (fixed by the owner before the run starts)');
|
|
258
|
+
}
|
|
253
259
|
/** @type {string[]} */
|
|
254
260
|
let frozen;
|
|
255
261
|
if (state) {
|
|
@@ -309,15 +315,18 @@ export async function reloadRun({ runId, config, readPending = () => [], effecti
|
|
|
309
315
|
|
|
310
316
|
/**
|
|
311
317
|
* `run start --reattach`: re-pin a restarted worker (the orchestrator only — §4.8).
|
|
312
|
-
* @param {{runId: string, workerPid: number, writeRow: WriteRow, probe?: StartTimeProbe}} opts
|
|
318
|
+
* @param {{runId: string, workerPid: number, writeRow: WriteRow, probe?: StartTimeProbe, add?: boolean}} opts
|
|
313
319
|
*/
|
|
314
|
-
export async function reattachWorker({ runId, workerPid, writeRow, probe = processStartTime }) {
|
|
320
|
+
export async function reattachWorker({ runId, workerPid, writeRow, probe = processStartTime, add = false }) {
|
|
315
321
|
if (!isSignalablePid(workerPid)) throw new StateError('bad-worker-pid', 'worker pid must be an integer > 1');
|
|
316
322
|
return withRunLock(runId, async () => {
|
|
317
323
|
const current = await readRun(runId);
|
|
318
324
|
if (current.status !== 'active') throw new StateError('run-ended', `run ${runId} has ended`);
|
|
319
|
-
|
|
320
|
-
|
|
325
|
+
const pin = await pinWorker(workerPid, probe);
|
|
326
|
+
const pins = current.workers ?? (current.worker ? [current.worker] : []);
|
|
327
|
+
current.workers = add ? [...pins, pin] : [pin];
|
|
328
|
+
current.worker = current.workers[0];
|
|
329
|
+
await writeSigned(runId, writeRow, { event: add ? 'worker.add' : 'worker.reattach', pid: workerPid });
|
|
321
330
|
await saveRun(current);
|
|
322
331
|
return current;
|
|
323
332
|
});
|
|
@@ -331,7 +340,18 @@ export async function reattachWorker({ runId, workerPid, writeRow, probe = proce
|
|
|
331
340
|
* @returns {Promise<{ok: boolean, event?: 'worker.replaced' | 'worker.down', reason?: string}>}
|
|
332
341
|
*/
|
|
333
342
|
export async function checkWorkerPin(record, { livePid, probe = processStartTime } = {}) {
|
|
334
|
-
|
|
343
|
+
let pin = record.worker;
|
|
344
|
+
if (record.workers?.length > 1) {
|
|
345
|
+
const pins = record.workers;
|
|
346
|
+
if (pins.some((p) => !isSignalablePid(p.pid)) || (livePid !== undefined && !pins.some((p) => p.pid === livePid))) {
|
|
347
|
+
return { ok: false, event: 'worker.replaced', reason: 'the live worker does not match a pinned worker' };
|
|
348
|
+
}
|
|
349
|
+
if (livePid === undefined) {
|
|
350
|
+
const live = await Promise.all(pins.map(async (p) => (await probe(p.pid)) === p.started_at));
|
|
351
|
+
return live.some(Boolean) ? { ok: true } : { ok: false, event: 'worker.down', reason: 'no pinned worker is running' };
|
|
352
|
+
}
|
|
353
|
+
pin = pins.find((p) => p.pid === livePid);
|
|
354
|
+
}
|
|
335
355
|
if (!pin) return { ok: false, event: 'worker.down', reason: 'no worker is pinned for this run' };
|
|
336
356
|
if (!isSignalablePid(pin.pid)) return { ok: false, event: 'worker.replaced', reason: 'the pinned worker pid is not an integer > 1' };
|
|
337
357
|
if (livePid !== undefined && livePid !== pin.pid) {
|
|
@@ -380,14 +400,21 @@ export async function endRun({ runId, writeRow, stopWorker = stopPinnedWorker, p
|
|
|
380
400
|
return withRunLock(runId, async () => {
|
|
381
401
|
const current = await readRun(runId);
|
|
382
402
|
if (current.status !== 'active') throw new StateError('run-ended', `run ${runId} has already ended`);
|
|
403
|
+
const awaiting = Object.keys(current.blocks).filter((id) => current.blocks[id].status === 'awaiting_human_check');
|
|
404
|
+
if (awaiting.length > 0) throw new StateError('awaiting-human-check', `run ${runId} has blocks awaiting human check: ${awaiting.join(', ')}; human-pass or stop them first`);
|
|
383
405
|
const open = Object.keys(current.blocks).filter((id) => current.blocks[id].status === 'open');
|
|
384
406
|
if (open.length > 0) throw new StateError('open-blocks', `run ${runId} still has open blocks: ${open.join(', ')}`);
|
|
385
407
|
const pin = await checkWorkerPin(current, { probe });
|
|
386
408
|
if (!pin.ok) await writeSigned(runId, writeRow, { event: pin.event, reason: pin.reason });
|
|
387
|
-
|
|
409
|
+
let stopped = false;
|
|
410
|
+
for (const worker of current.workers ?? (current.worker ? [current.worker] : [])) {
|
|
411
|
+
if ((await checkWorkerPin({ worker }, { probe })).ok) {
|
|
412
|
+
stopped = (await stopWorker(worker, { probe })).stopped || stopped;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
388
415
|
current.status = 'ended';
|
|
389
416
|
current.ended_at = new Date().toISOString();
|
|
390
|
-
await writeSigned(runId, writeRow, { event: 'run.end', worker_check: pin.ok ? 'ok' : pin.event, worker_stopped:
|
|
417
|
+
await writeSigned(runId, writeRow, { event: 'run.end', worker_check: pin.ok ? 'ok' : pin.event, worker_stopped: stopped });
|
|
391
418
|
await saveRun(current);
|
|
392
419
|
return current;
|
|
393
420
|
});
|
package/src/util/forbidden.mjs
CHANGED
|
@@ -282,6 +282,7 @@ export const FORBIDDEN = Object.freeze(
|
|
|
282
282
|
* - `--no-require-reviews` — the human's way out of `block close`'s per-file review check;
|
|
283
283
|
* - `--remove-worktree` (B62a) — `block close` removing the block's worktree, the orchestrator's
|
|
284
284
|
* alone. `block close` itself is not on this list, so the flag is listed on its own.
|
|
285
|
+
* - `code-forge block human-pass` (B137) — passing a human device check is human-only.
|
|
285
286
|
* @type {ReadonlyArray<ForbiddenEntry>}
|
|
286
287
|
*/
|
|
287
288
|
export const CODER_ONLY_FORBIDDEN = Object.freeze(
|
|
@@ -346,6 +347,19 @@ export const CODER_ONLY_FORBIDDEN = Object.freeze(
|
|
|
346
347
|
prefixes: [['code-forge', 'block', 'ack-waivers'], ['forge', 'block', 'ack-waivers']],
|
|
347
348
|
description: 'Acknowledge agent waivers — human-only, confirmed in the human terminal (B124b)',
|
|
348
349
|
},
|
|
350
|
+
{
|
|
351
|
+
id: 'code-forge-block-human-pass-from-coder',
|
|
352
|
+
kind: 'anyPrefix',
|
|
353
|
+
prefixes: [['code-forge', 'block', 'human-pass'], ['forge', 'block', 'human-pass']],
|
|
354
|
+
description: 'Pass a human device check — human-only, confirmed in the human terminal (B137)',
|
|
355
|
+
},
|
|
356
|
+
{
|
|
357
|
+
id: 'code-forge-add-worker-from-coder',
|
|
358
|
+
kind: 'commandFlag',
|
|
359
|
+
command: ['code-forge', 'run', 'start'],
|
|
360
|
+
groups: [{ anyOf: ['--add-worker'] }],
|
|
361
|
+
description: 'Add a review worker to a run — the orchestrator only (B107)',
|
|
362
|
+
},
|
|
349
363
|
].map(freezeEntry),
|
|
350
364
|
);
|
|
351
365
|
|
package/src/util/reaper.mjs
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
29
|
import { execFileSync } from 'node:child_process';
|
|
30
|
-
import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
30
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
31
31
|
import path from 'node:path';
|
|
32
32
|
import { setTimeout as sleep } from 'node:timers/promises';
|
|
33
33
|
|
|
@@ -85,7 +85,7 @@ export function isAlive(pid) {
|
|
|
85
85
|
export function readStartTime(pid) {
|
|
86
86
|
if (!isValidPid(pid)) return null;
|
|
87
87
|
try {
|
|
88
|
-
const out = execFileSync('ps', ['-o', 'lstart=', '-p', String(pid)], {
|
|
88
|
+
const out = execFileSync(existsSync('/bin/ps') ? '/bin/ps' : 'ps', ['-o', 'lstart=', '-p', String(pid)], {
|
|
89
89
|
encoding: 'utf8',
|
|
90
90
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
91
91
|
env: { ...process.env, LC_ALL: 'C', LANG: 'C' },
|
|
@@ -165,7 +165,7 @@ export function groupMembers(pgid) {
|
|
|
165
165
|
const members = new Map();
|
|
166
166
|
let out = '';
|
|
167
167
|
try {
|
|
168
|
-
out = execFileSync('ps', ['-A', '-o', 'pid=,pgid=,lstart='], {
|
|
168
|
+
out = execFileSync(existsSync('/bin/ps') ? '/bin/ps' : 'ps', ['-A', '-o', 'pid=,pgid=,lstart='], {
|
|
169
169
|
encoding: 'utf8',
|
|
170
170
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
171
171
|
env: { ...process.env, LC_ALL: 'C', LANG: 'C' },
|
package/src/worker/engine.mjs
CHANGED
|
@@ -95,13 +95,13 @@ import { askJev } from '../decide/jev-client.mjs';
|
|
|
95
95
|
import { planAndRecord, tierOf } from '../review/budget.mjs';
|
|
96
96
|
import { budgetFor } from '../review/packet.mjs';
|
|
97
97
|
import { ContextError, reviewDigest } from '../context/pack.mjs';
|
|
98
|
-
import { reviewFile, rulesRisk } from '../review/engine.mjs';
|
|
98
|
+
import { reviewFile, reviewLightBatch, rulesRisk } from '../review/engine.mjs';
|
|
99
99
|
import { reviewAnswers } from '../review/answers.mjs';
|
|
100
100
|
import { peerDiffReader } from '../review/moved.mjs';
|
|
101
101
|
import { computeFileSet, ownsFile } from '../gates/scope.mjs';
|
|
102
102
|
import { extraRoundsFor, newFileState, reopenForExtraRound, runRound } from '../review/fixloop.mjs';
|
|
103
103
|
import { reopenRowFor } from '../review/reopen.mjs';
|
|
104
|
-
import { readRun } from '../state/run.mjs';
|
|
104
|
+
import { processStartTime, readRun } from '../state/run.mjs';
|
|
105
105
|
import { StateError } from '../state/paths.mjs';
|
|
106
106
|
import { blockTree, worktreeRecord } from '../proof/worktree.mjs';
|
|
107
107
|
import { ledgerSchemaHistory } from '../review/schema-fallback.mjs';
|
|
@@ -109,7 +109,7 @@ import { verifyRow } from '../state/signer.mjs';
|
|
|
109
109
|
import { redact } from '../util/redact.mjs';
|
|
110
110
|
import { withLock } from '../util/file-locks.mjs';
|
|
111
111
|
import { blockAnchors, blockRungUsed, loadState, RESTORED_EVENT, saveState } from './review-state.mjs';
|
|
112
|
-
import { assertAnswers } from './queue.mjs';
|
|
112
|
+
import { assertAnswers, enqueueHash, isDone, writeResult } from './queue.mjs';
|
|
113
113
|
import { assertTicketId, contentHash, readRegularFileNoFollow, WorkerError } from './ticket.mjs';
|
|
114
114
|
|
|
115
115
|
/**
|
|
@@ -127,6 +127,8 @@ import { assertTicketId, contentHash, readRegularFileNoFollow, WorkerError } fro
|
|
|
127
127
|
* the worker's and a per-call `cfg` wins).
|
|
128
128
|
* @property {(row: Record<string, any>) => Promise<unknown>} [writeRow] - a signed ledger row
|
|
129
129
|
* (the worker adds `run`, `block`, `file`, `content_hash`).
|
|
130
|
+
* @property {(row: Record<string, any>) => Promise<unknown>} [writeBlockRow] - light fanout rows;
|
|
131
|
+
* the worker pins run and block, while the batch supplies each file and content hash.
|
|
130
132
|
* @property {Buffer} [key] - the run's HMAC key: signs the fix-loop state. Absent ⇒ engine only.
|
|
131
133
|
* @property {() => Promise<Array<Record<string, any>>>} [readRows] - the run's ledger rows (the
|
|
132
134
|
* `review.state` anchors the state is checked against); required with `key`.
|
|
@@ -197,7 +199,7 @@ export async function blockEntryFor(runId, block) {
|
|
|
197
199
|
throw new Error('worktree-malformed');
|
|
198
200
|
}
|
|
199
201
|
}
|
|
200
|
-
return { base: tree ? tree.base_sha : sha, level: typeof entry.level === 'string' && /^L[0-3]$/.test(entry.level) ? entry.level : 'L2', owned, ownedKinds, testFiles, acceptance: entry.acceptance, kind: blockKind({ owned, declared: entry.kind }), tree, workspace: typeof record.workspace === 'string' ? record.workspace : null };
|
|
202
|
+
return { base: tree ? tree.base_sha : sha, level: typeof entry.level === 'string' && /^L[0-3]$/.test(entry.level) ? entry.level : 'L2', owned, ownedKinds, testFiles, acceptance: entry.acceptance, kind: entry.kind === 'light' ? 'light' : blockKind({ owned, declared: entry.kind }), tree, workspace: typeof record.workspace === 'string' ? record.workspace : null };
|
|
201
203
|
}
|
|
202
204
|
|
|
203
205
|
/** @typedef {{path: string, branch: string, base_sha: string}} WorktreeEntry */
|
|
@@ -411,6 +413,10 @@ export async function reviewTicket(ticket, ctx) {
|
|
|
411
413
|
return { status: 'unavailable', reason: 'context-unreadable', approved: false, engine: 'adaptive', sessions: [] };
|
|
412
414
|
}
|
|
413
415
|
const digest = rulesDigest === '' ? {} : { rulesDigest };
|
|
416
|
+
if (entry.kind === 'light' && ctx.key) {
|
|
417
|
+
const batch = await lightRound(ticket, tctx, { entry, workDir, factsExcerpt, rulesDigest });
|
|
418
|
+
if (batch) return batch;
|
|
419
|
+
}
|
|
414
420
|
if (!ctx.key) {
|
|
415
421
|
return await reviewFile(
|
|
416
422
|
{ repoRoot: contentRoot, file: ticket.file, base: entry.base, cfg: ctx.cfg, kind: entry.kind, workDir, factsExcerpt, ...digest, peerDiffs, ...(await fileContext()) },
|
|
@@ -423,6 +429,56 @@ export async function reviewTicket(ticket, ctx) {
|
|
|
423
429
|
}
|
|
424
430
|
}
|
|
425
431
|
|
|
432
|
+
/** Serialize a light block across tickets/workers; durable signed rows enforce the two-batch cap.
|
|
433
|
+
* A batch completes every file's queue ticket, including files queued after this ticket started.
|
|
434
|
+
* @param {import('./queue.mjs').Ticket} ticket @param {ReviewContext} ctx
|
|
435
|
+
* @param {{entry: Awaited<ReturnType<typeof blockEntryFor>>, workDir: string, factsExcerpt: string, rulesDigest: string}} input
|
|
436
|
+
* @returns {Promise<ReviewOutcome | null>} null enters the ordinary per-file loop.
|
|
437
|
+
*/
|
|
438
|
+
async function lightRound(ticket, ctx, { entry, workDir, factsExcerpt, rulesDigest }) {
|
|
439
|
+
if (!ctx.key || !ctx.readRows || !ctx.writeBlockRow) return { status: 'unavailable', reason: 'no-ledger', approved: false, engine: 'adaptive', sessions: [] };
|
|
440
|
+
const key = ctx.key, readRows = ctx.readRows, writeRow = ctx.writeBlockRow;
|
|
441
|
+
const worker = { pid: process.pid, started_at: await processStartTime(process.pid) };
|
|
442
|
+
return withLock(`light:${ctx.runId}\0${ticket.block}`, async () => {
|
|
443
|
+
const root = ctx.contentRoot ?? ctx.repoRoot;
|
|
444
|
+
const files = (await computeFileSet({ cwd: root, base: entry.base ?? 'HEAD' })).all.filter((file) => ownsFile(entry.owned, file, entry.ownedKinds)).sort();
|
|
445
|
+
const hashes = files.map((file) => ({ file, content_hash: contentHash(root, file) }));
|
|
446
|
+
if (!hashes.some((f) => f.file === ticket.file && f.content_hash === ticket.content_hash)) return { status: 'stale', approved: false, engine: 'adaptive', sessions: [] };
|
|
447
|
+
const signature = JSON.stringify([entry.base, hashes]);
|
|
448
|
+
const rows = (await readRows()).filter((r) => r.run === ctx.runId && r.block === ticket.block && verifyRow(r, key).ok);
|
|
449
|
+
const cached = rows.filter((r) => r.event === 'review.light_result' && r.signature === signature).at(-1)?.outcomes?.[ticket.file];
|
|
450
|
+
if (cached && (cached.status === 'reviewed' || isDone(ctx.repoRoot, ticket.ticket))) return cached;
|
|
451
|
+
const count = rows.filter((r) => r.event === 'review.light_batch').length;
|
|
452
|
+
if (count >= 2) return null;
|
|
453
|
+
const round = count + 1;
|
|
454
|
+
// Reserve before the session: a crash cannot reset the cap on a worker restart.
|
|
455
|
+
await writeRow({ event: 'review.light_batch', round, signature });
|
|
456
|
+
const batch = await reviewLightBatch({ repoRoot: root, files, base: entry.base, cfg: ctx.cfg, workDir, factsExcerpt, rulesDigest }, { spawn: ctx.spawn, writeRow });
|
|
457
|
+
const moved = hashes.some((f) => contentHash(root, f.file) !== f.content_hash);
|
|
458
|
+
const findings = batch.findings ?? [];
|
|
459
|
+
const blocking = findings.some((f) => f.severity === 'critical' || f.severity === 'warning');
|
|
460
|
+
/** @type {Record<string, ReviewOutcome>} */
|
|
461
|
+
const outcomes = {};
|
|
462
|
+
for (const { file, content_hash } of hashes) {
|
|
463
|
+
const own = findings.filter((f) => f.file === file);
|
|
464
|
+
const approved = !moved && batch.status === 'reviewed' && (batch.approved || blocking) && own.every((f) => f.severity === 'nit');
|
|
465
|
+
const outcome = { ...batch, ...(moved ? { status: 'stale' } : {}), approved, findings: own, round, kind: 'full', level: entry.level,
|
|
466
|
+
next: { action: approved ? 'complete' : 'fix', level: entry.level } };
|
|
467
|
+
outcomes[file] = outcome;
|
|
468
|
+
if (batch.status === 'reviewed' && !moved) {
|
|
469
|
+
await writeRow({ event: 'review.round', file, content_hash, round, kind: 'full', level: entry.level, open_after: own.filter((f) => f.severity !== 'nit').length });
|
|
470
|
+
if (approved) await writeRow({ event: 'review.approved', file, content_hash });
|
|
471
|
+
}
|
|
472
|
+
const queued = enqueueHash({ repoRoot: ctx.repoRoot, run: ctx.runId, block: ticket.block, file, content_hash, ...(ticket.tree ? { tree: ticket.tree } : {}) });
|
|
473
|
+
const open = approved ? [] : own;
|
|
474
|
+
await writeRow({ event: 'review.result', ticket: queued.ticket, file, content_hash, status: outcome.status, approved, open, worker });
|
|
475
|
+
writeResult({ repoRoot: ctx.repoRoot, runId: ctx.runId, ticket: queued.ticket, result: { ...outcome, block: ticket.block, file, content_hash, open, worker }, key });
|
|
476
|
+
}
|
|
477
|
+
await writeRow({ event: 'review.light_result', signature, outcomes });
|
|
478
|
+
return outcomes[ticket.file];
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
|
|
426
482
|
/**
|
|
427
483
|
* B56: the block's OTHER changed files — the §4.1 file set since the block base (tracked changes ∪
|
|
428
484
|
* untracked), filtered to the block's owned files (`ownsFile`, as the budget row does), minus the
|
package/src/worker/loop.mjs
CHANGED
|
@@ -42,12 +42,13 @@ import path from 'node:path';
|
|
|
42
42
|
import { fileURLToPath } from 'node:url';
|
|
43
43
|
import { expiryChecks } from '../autopilot/limits.mjs';
|
|
44
44
|
import { loadProjectConfig } from '../config/load.mjs';
|
|
45
|
+
import { reviewWorkers } from '../config/validate.mjs';
|
|
45
46
|
import { cacheHoursFrom, createDefaultKeyStore, resolveKey } from '../keys/store.mjs';
|
|
46
47
|
import { appendRow, readAllRows } from '../ledger/write.mjs';
|
|
47
48
|
import { spawnSession } from '../session/spawn.mjs';
|
|
48
49
|
import { snapshotFor } from '../state/config-snapshot.mjs';
|
|
49
50
|
import { runRecordPath } from '../state/paths.mjs';
|
|
50
|
-
import { readRun, reattachWorker } from '../state/run.mjs';
|
|
51
|
+
import { processStartTime, readRun, reattachWorker } from '../state/run.mjs';
|
|
51
52
|
import { loadKey, signRow } from '../state/signer.mjs';
|
|
52
53
|
import { logWarning } from '../util/error-log.mjs';
|
|
53
54
|
import { withLock, tryLock } from '../util/file-locks.mjs';
|
|
@@ -260,6 +261,7 @@ export function recordedVerdict(result) {
|
|
|
260
261
|
*/
|
|
261
262
|
export async function createWorker(opts, deps = {}) {
|
|
262
263
|
const { runId, repoRoot, cfg, runRootDir, slug, key } = opts;
|
|
264
|
+
const identity = { pid: process.pid, started_at: await processStartTime(process.pid) };
|
|
263
265
|
const env = deps.env ?? process.env;
|
|
264
266
|
const review = deps.review ?? reviewTicket;
|
|
265
267
|
const spawn = deps.spawn ?? spawnSession;
|
|
@@ -382,6 +384,7 @@ export async function createWorker(opts, deps = {}) {
|
|
|
382
384
|
{ env: childEnv, ...(deps.bins ? { bins: deps.bins } : {}), ...(deps.stderr ? { stderr: deps.stderr } : {}) },
|
|
383
385
|
),
|
|
384
386
|
writeRow: (row) => ledger({ ...row, block: ticket.block, file: ticket.file, content_hash: ticket.content_hash }),
|
|
387
|
+
writeBlockRow: (row) => ledger({ ...row, block: ticket.block }),
|
|
385
388
|
};
|
|
386
389
|
try {
|
|
387
390
|
const outcome = await review(ticket, ctx);
|
|
@@ -484,12 +487,14 @@ export async function createWorker(opts, deps = {}) {
|
|
|
484
487
|
* @param {string} id @param {Record<string, any>} outcome - recorded through {@link recordedVerdict}.
|
|
485
488
|
*/
|
|
486
489
|
async function complete(id, outcome) {
|
|
487
|
-
|
|
490
|
+
/** @type {Record<string, any>} */
|
|
491
|
+
const result = { ...recordedVerdict(outcome), worker: identity };
|
|
488
492
|
const signed = writeResult({ repoRoot, runId, ticket: id, result, key });
|
|
489
493
|
try {
|
|
490
494
|
await ledger({
|
|
491
495
|
event: 'review.result',
|
|
492
496
|
ticket: id,
|
|
497
|
+
worker: identity,
|
|
493
498
|
block: result.block ?? null,
|
|
494
499
|
file: result.file ?? null,
|
|
495
500
|
content_hash: result.content_hash ?? null,
|
|
@@ -793,9 +798,6 @@ export async function createWorker(opts, deps = {}) {
|
|
|
793
798
|
return { processTicket, drain, run, once, stop, release, announce, watchTick: tickWatch, jevKeyResolved: jevKey !== null, sessionEnv: childEnv };
|
|
794
799
|
}
|
|
795
800
|
|
|
796
|
-
/** This block permits one registered worker per queue. */
|
|
797
|
-
const WORKER_LIMIT = 1;
|
|
798
|
-
|
|
799
801
|
/**
|
|
800
802
|
* Start-up for the `worker` verb: run record, repo root, config, run temp root (owned by this
|
|
801
803
|
* process), run key; refuses when another live worker already serves this queue.
|
|
@@ -831,7 +833,10 @@ export async function bootWorker(opts, deps = {}) {
|
|
|
831
833
|
const legacy = liveWorker(repoRoot);
|
|
832
834
|
if (legacy && !entries.some((entry) => entry.worker?.pid === legacy.pid && entry.worker.start_time === legacy.start_time)) count++;
|
|
833
835
|
await deps.hooks?.afterCount?.(count);
|
|
834
|
-
|
|
836
|
+
const limit = reviewWorkers(cfg);
|
|
837
|
+
if (entries.some((entry) => entry.live && !entry.worker) || (legacy && legacy.run !== opts.runId) || entries.some((entry) => entry.live && entry.worker?.run !== opts.runId) || count >= limit) {
|
|
838
|
+
throw new WorkerError('worker-running', `review.workers limit ${limit}: a live or unreadable worker entry already serves this queue`);
|
|
839
|
+
}
|
|
835
840
|
worker.announce();
|
|
836
841
|
});
|
|
837
842
|
return { ...worker, repoRoot, runRootDir };
|
|
@@ -844,10 +849,10 @@ export async function bootWorker(opts, deps = {}) {
|
|
|
844
849
|
* `expectPid` (a `run start` that just launched a worker) pins ONLY that pid: an older or stale
|
|
845
850
|
* announcement naming another live pid is refused (`worker_mismatch`) instead of being pinned
|
|
846
851
|
* over the new worker.
|
|
847
|
-
* @param {{runId: string, repoRoot: string, writeRow?: (row: Record<string, any>) => Promise<unknown>, probe?: import('../state/run.mjs').StartTimeProbe, expectPid?: number}} opts
|
|
852
|
+
* @param {{runId: string, repoRoot: string, writeRow?: (row: Record<string, any>) => Promise<unknown>, probe?: import('../state/run.mjs').StartTimeProbe, expectPid?: number, add?: boolean}} opts
|
|
848
853
|
*/
|
|
849
|
-
export async function repinWorker({ runId, repoRoot, writeRow, probe, expectPid }) {
|
|
850
|
-
const live = liveWorker(repoRoot);
|
|
854
|
+
export async function repinWorker({ runId, repoRoot, writeRow, probe, expectPid, add = false }) {
|
|
855
|
+
const live = (expectPid === undefined ? null : liveWorker(repoRoot, { pid: expectPid, registeredOnly: true })) ?? liveWorker(repoRoot);
|
|
851
856
|
if (!live) throw new WorkerError('worker_down', 'no live worker serves this queue');
|
|
852
857
|
if (live.run !== runId) throw new WorkerError('other-run', `the live worker serves run ${live.run}`);
|
|
853
858
|
if (expectPid !== undefined && live.pid !== expectPid) {
|
|
@@ -855,7 +860,7 @@ export async function repinWorker({ runId, repoRoot, writeRow, probe, expectPid
|
|
|
855
860
|
}
|
|
856
861
|
const { project } = await readRun(runId);
|
|
857
862
|
const write = writeRow ?? ((/** @type {Record<string, any>} */ row) => appendRow(row, { slug: project }));
|
|
858
|
-
return reattachWorker({ runId, workerPid: live.pid, writeRow: write, ...(probe ? { probe } : {}) });
|
|
863
|
+
return reattachWorker({ runId, workerPid: live.pid, writeRow: write, add, ...(probe ? { probe } : {}) });
|
|
859
864
|
}
|
|
860
865
|
|
|
861
866
|
/** The CLI entry the worker is launched from. */
|
|
@@ -887,5 +892,9 @@ export async function launchWorker({ runId, workspace, env = process.env, timeou
|
|
|
887
892
|
// already gone
|
|
888
893
|
}
|
|
889
894
|
}
|
|
895
|
+
const other = liveWorker(repoRoot);
|
|
896
|
+
if (other && other.run !== runId) {
|
|
897
|
+
throw new WorkerError('worker_down', `the worker did not start: run ${other.run} still has a live worker (pid ${other.pid}); end it with: code-forge run end --run ${other.run}`);
|
|
898
|
+
}
|
|
890
899
|
throw new WorkerError('worker_down', 'the worker did not start (run `code-forge worker --run <id>` by hand to see why)');
|
|
891
900
|
}
|
package/src/worker/queue.mjs
CHANGED
|
@@ -516,11 +516,14 @@ export function readTicket(repoRoot, id) {
|
|
|
516
516
|
* @returns {Record<string, any>} the signed result
|
|
517
517
|
*/
|
|
518
518
|
export function writeResult({ repoRoot, runId, ticket, result, key }) {
|
|
519
|
-
const
|
|
519
|
+
const split = result.status === 'split_required' || (result.status === 'stopped' && result.stopped === 'split_required');
|
|
520
|
+
// The engine's depth identifies the round-1 packet; rechecks always use the recheck lens.
|
|
521
|
+
const measured = split ? { budget: result.budget ?? null, lens: result.lens ?? (result.status === 'stopped' ? 'recheck' : result.depth === 'quick' ? 'quick' : result.depth === 'dual' ? 'A' : 'full') } : {};
|
|
522
|
+
const signed = signRow({ ...result, ...measured, run: runId, ticket: assertTicketId(ticket) }, key);
|
|
520
523
|
const file = path.join(reviewsDir(repoRoot, runId), `${ticket}.json`);
|
|
521
524
|
writeAtomic(file, `${JSON.stringify(signed)}\n`);
|
|
522
525
|
const openFindings = signed.status === 'reviewed' && signed.approved !== true && Array.isArray(signed.findings) && signed.findings.length > 0;
|
|
523
|
-
writeAtomic(path.join(queueDir(repoRoot), `${ticket}.done`), `${JSON.stringify({ ticket, status: signed.status, ...(openFindings ? { open_findings: true } : {}), result: path.relative(repoRoot, file) })}\n`);
|
|
526
|
+
writeAtomic(path.join(queueDir(repoRoot), `${ticket}.done`), `${JSON.stringify({ ticket, status: signed.status, ...measured, ...(openFindings ? { open_findings: true } : {}), result: path.relative(repoRoot, file) })}\n`);
|
|
524
527
|
return signed;
|
|
525
528
|
}
|
|
526
529
|
|