@codedology/code-forge 0.12.0 → 0.14.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 +53 -0
- package/README.md +28 -2
- package/docs/reference/blocks.json +354 -1
- package/docs/reference/config.md +4 -1
- package/package.json +1 -1
- package/schema/code-forge.schema.json +10 -1
- package/skill/references/adapters/claude-code.md +1 -1
- package/skill/references/adapters/solo.md +1 -1
- package/skill/references/code.md +15 -8
- package/skill/references/ledger.md +4 -2
- package/skill/references/proof.md +1 -1
- package/skill/references/review.md +21 -7
- package/skill/references/security.md +5 -3
- package/skill/scripts/forge +1 -1
- package/src/autopilot/actions.mjs +4 -5
- package/src/cli/block.mjs +130 -31
- package/src/cli/context.mjs +3 -2
- package/src/cli/report.mjs +2 -2
- package/src/cli/review-file.mjs +67 -42
- package/src/cli/review.mjs +2 -2
- package/src/context/pack.mjs +13 -9
- package/src/doctor/index.mjs +2 -1
- package/src/doctor/local.mjs +19 -0
- package/src/doctor/worker-probe.mjs +15 -2
- package/src/ledger/export.mjs +1 -1
- package/src/ledger/report.mjs +9 -3
- package/src/ledger/spend.mjs +3 -3
- package/src/proof/lock.mjs +5 -1
- package/src/review/accept-hit.mjs +78 -0
- package/src/review/agent-waiver.mjs +201 -0
- package/src/review/gate-check.mjs +62 -28
- package/src/review/reopen.mjs +35 -14
- package/src/review/schema-fallback.mjs +3 -3
- package/src/state/config-snapshot.mjs +1 -0
- package/src/util/file-locks.mjs +118 -0
- package/src/util/forbidden.mjs +51 -2
- package/src/worker/engine.mjs +2 -2
- package/src/worker/loop.mjs +83 -33
- package/src/worker/queue.mjs +141 -33
- package/src/worker/watch.mjs +4 -2
package/src/cli/block.mjs
CHANGED
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
* block claim <id> <path> --run <r>
|
|
11
11
|
* block close <id> --run <r> [--worker-pid <pid>] [--remove-worktree]
|
|
12
12
|
* block stop <id> --run <r> --reason <text>
|
|
13
|
-
* block waive <id> <finding-id> --run <r> --file <path> --reason <text> (
|
|
14
|
-
* block reopen <id> --run <r> --file <path> --reason <text> (
|
|
13
|
+
* block waive <id> <finding-id> --run <r> --file <path> --reason <text> [--yes] (orchestrator --yes: evidence and a fresh judge)
|
|
14
|
+
* block reopen <id> --run <r> --file <path> --reason <text> [--yes] (B124c: evidence and a fresh judge for the agent path)
|
|
15
15
|
*
|
|
16
16
|
* B64a `--json` (every subcommand): on success stdout is exactly ONE JSON line instead of the
|
|
17
17
|
* text — `{"block", "event", …}` with the facts the text prints (open: level, asked, source,
|
|
@@ -172,18 +172,17 @@
|
|
|
172
172
|
* <from>[ <stop reason>]; by: human)`; `--json`: `{"block", "event": "reopened", "file", "from",
|
|
173
173
|
* "stop_reason", "at_seq", "from_hash", "to_hash", "ticket_cleared", "by"}`.
|
|
174
174
|
*
|
|
175
|
-
* B80
|
|
175
|
+
* B80: without `--yes`, `block waive` and `block reopen` need the human at a terminal, through the same
|
|
176
176
|
* confirm as `--no-require-reviews` (`confirmAtTerminal`). Right after the usage checks and the run
|
|
177
177
|
* record read — before the autopilot expiry checks or anything else is written — stdin or stdout
|
|
178
|
-
* not a TTY prints `
|
|
179
|
-
* (there is no --yes)` (exit 2). At a terminal, what the verb would refuse is refused before the
|
|
178
|
+
* not a TTY prints `humanVerbNoTTY` (exit 2). At a terminal, what the verb would refuse is refused before the
|
|
180
179
|
* question: a `--file` or finding id with a control character or of the wrong form (`usage`, exit
|
|
181
180
|
* 2, `checkFileForm` / `checkFindingId` — the verbs' own checks), an unknown block, for reopen a
|
|
182
181
|
* block that is not open or a file it does not own (`reopen-not-owned`), for waive a file the block
|
|
183
182
|
* does not own (`waive-not-owned`) or a finding id no review row of the block names for the file
|
|
184
183
|
* (`waive-unknown-finding`; `proof` is exempt) — exit 1. A "no" or a cancel prints `block <id>
|
|
185
184
|
* <verb>: cancelled — nothing was recorded` (exit 1); a "yes" runs the verb exactly as before (the
|
|
186
|
-
* same signed row).
|
|
185
|
+
* same signed row). Only `block waive` accepts `--yes` (B124a, evidence and a fresh L3 judge). An active autopilot grant
|
|
187
186
|
* changes nothing here — autopilot waives through its own path (`autopilot waive`, `by:
|
|
188
187
|
* autopilot`). The prompt checks only that stdin and stdout are terminals; a pseudo-terminal
|
|
189
188
|
* passes it, so it does not prove a human. The coder deny-list entries stay.
|
|
@@ -193,6 +192,7 @@
|
|
|
193
192
|
* through `closeBlock`'s `extraChecks`.
|
|
194
193
|
*/
|
|
195
194
|
|
|
195
|
+
import { statSync } from 'node:fs';
|
|
196
196
|
import { readFile, realpath } from 'node:fs/promises';
|
|
197
197
|
import path from 'node:path';
|
|
198
198
|
import { parse as parseYAML } from 'yaml';
|
|
@@ -201,12 +201,14 @@ import { fallbackLane } from '../decide/fallback-rules.mjs';
|
|
|
201
201
|
import { laneFacts } from '../decide/lane-facts.mjs';
|
|
202
202
|
import { appendRow, readAllRows } from '../ledger/write.mjs';
|
|
203
203
|
import { attemptBlock, claimPath, closeBlock, openBlock, rebaseBlock, repoTop, stopBlock } from '../state/block.mjs';
|
|
204
|
+
import { lastGate } from '../state/brief-status.mjs';
|
|
204
205
|
import { intFlag, parseFlags } from '../state/cli-args.mjs';
|
|
205
206
|
import { configInForce, externalReviewer, requireCoderTests } from '../state/config-snapshot.mjs';
|
|
206
207
|
import { StateError } from '../state/paths.mjs';
|
|
207
208
|
import { readRun, writeSigned } from '../state/run.mjs';
|
|
208
209
|
import { loadKey, verifyRow } from '../state/signer.mjs';
|
|
209
210
|
import { PROOF_FINDING, blockFileSet, changedFiles, openFindings, ownsAt, resultFilesFor, reviewedFiles, reviewGateCheck, unownedChanges, waiveFinding } from '../review/gate-check.mjs';
|
|
211
|
+
import { agentWaivers, agentWaiversAcked, prepareAckWaivers, prepareUnwaive, waiveAsAgent } from '../review/agent-waiver.mjs';
|
|
210
212
|
import { checkExpiry } from '../autopilot/grant.mjs';
|
|
211
213
|
import { redact, writeSafe } from '../util/redact.mjs';
|
|
212
214
|
import { blockTree, prepareWorktree, removeWorktree, worktreeSettings } from '../proof/worktree.mjs';
|
|
@@ -214,10 +216,10 @@ import { DEFAULT_CONFIG_FILENAME, loadProjectConfig } from '../config/load.mjs';
|
|
|
214
216
|
import { validateConfig } from '../config/validate.mjs';
|
|
215
217
|
import { migrateConfig } from '../config/migrate.mjs';
|
|
216
218
|
import { exec } from '../util/exec.mjs';
|
|
217
|
-
import { contentHash, gitChildEnv } from '../worker/ticket.mjs';
|
|
219
|
+
import { contentHash, gitChildEnv, readRegularFileNoFollow } from '../worker/ticket.mjs';
|
|
218
220
|
import { clearDone, resolveTicket } from '../worker/queue.mjs';
|
|
219
221
|
import { loadState } from '../worker/review-state.mjs';
|
|
220
|
-
import { openBlockEntry, reopenFile } from '../review/reopen.mjs';
|
|
222
|
+
import { agentReopens, openBlockEntry, reopenFile } from '../review/reopen.mjs';
|
|
221
223
|
import { checkFileForm, checkFindingId } from '../review/verb-forms.mjs';
|
|
222
224
|
import { runRootPath } from '../util/tmp.mjs';
|
|
223
225
|
import { missingReviewTickets, parseSentinel } from '../engines/sentinel.mjs';
|
|
@@ -225,15 +227,20 @@ import { reportMismatches, reviewedLines } from '../review/reviewed-lines.mjs';
|
|
|
225
227
|
import { blockEndHook, runBlockEndHook, writeDoneFile } from '../state/block-end.mjs';
|
|
226
228
|
import { blockOpenFindings } from '../review/open-findings.mjs';
|
|
227
229
|
import { hookForbidden, scanTranscript } from '../util/forbidden.mjs';
|
|
230
|
+
import { prepareAcceptHit, writeAcceptedHit } from '../review/accept-hit.mjs';
|
|
228
231
|
|
|
229
232
|
/**
|
|
230
233
|
* B72: `block reopen` — the file's content is read where the block works (its worktree, else the
|
|
231
234
|
* main tree), its state is read (never moved) from the code-forge home or the run's old temp root,
|
|
232
235
|
* the checks, the done marker's removal and then the signed row are `reopenFile`'s.
|
|
233
|
-
* @param {{runId: string, id: string, file: string, reason: string, record: Record<string, any>, writeRow: (row: Record<string, any>) => Promise<unknown
|
|
236
|
+
* @param {{runId: string, id: string, file: string, reason: string, record: Record<string, any>, writeRow: (row: Record<string, any>) => Promise<unknown>, agent?: boolean, judge?: import('../review/agent-waiver.mjs').WaiverJudge}} opts
|
|
234
237
|
* @returns {Promise<{row: Record<string, any>, cleared: boolean}>}
|
|
235
238
|
*/
|
|
236
|
-
async function reopenBlockFile({ runId, id, file, reason, record, writeRow }) {
|
|
239
|
+
async function reopenBlockFile({ runId, id, file, reason, record, writeRow, agent = false, judge }) {
|
|
240
|
+
if (agent) {
|
|
241
|
+
if (!reason.trim()) throw new StateError('usage', 'block reopen needs --reason');
|
|
242
|
+
checkFileForm(file, { normalized: true });
|
|
243
|
+
}
|
|
237
244
|
// the stable block codes come first, before any tree, config, key or ledger read
|
|
238
245
|
const entry = openBlockEntry(record, runId, id);
|
|
239
246
|
const real = async (/** @type {string} */ p) => realpath(p).catch(() => path.resolve(p));
|
|
@@ -269,10 +276,12 @@ async function reopenBlockFile({ runId, id, file, reason, record, writeRow }) {
|
|
|
269
276
|
key,
|
|
270
277
|
writeRow,
|
|
271
278
|
writeSigned,
|
|
279
|
+
...(agent ? { agent: { root: contentRoot, judge } } : {}),
|
|
272
280
|
io: {
|
|
273
281
|
owns: ownsAt(wsPrefix),
|
|
274
282
|
loadState: (where) => loadState({ .../** @type {any} */ (where), runRootDir, contentRoot, migrate: false }),
|
|
275
283
|
contentHash: (f) => contentHash(contentRoot, f),
|
|
284
|
+
readFile: (f) => readRegularFileNoFollow(contentRoot, f),
|
|
276
285
|
// C11 (synchronous): the cached result of the current content's ticket would repeat the stop
|
|
277
286
|
clearDone: (f) => clearDone({ repoRoot: mainTop, run: runId, block: id, file: f, ...tree }).removed,
|
|
278
287
|
},
|
|
@@ -283,12 +292,33 @@ async function reopenBlockFile({ runId, id, file, reason, record, writeRow }) {
|
|
|
283
292
|
const USAGE =
|
|
284
293
|
'usage: code-forge block open <id> --run <r> [--level L<n>] --owned <paths…> --acceptance <file> [--brief <file>] [--attempt <n>] [--base <sha>] [--lines <n>] [--kind code|docs|contract] [--tests <paths…>]\n' +
|
|
285
294
|
' code-forge block attempt|rebase <id> --run <r> · block close <id> --run <r> [--transcript <file>] [--report <file>] [--no-require-reviews] [--remove-worktree] · block claim <id> <path> --run <r> · block stop <id> --run <r> --reason <text>\n' +
|
|
286
|
-
' code-forge block waive <id> <finding-id> --run <r> --file <path> --reason <text
|
|
287
|
-
' code-forge block reopen <id> --run <r> --file <path> --reason <text
|
|
295
|
+
' code-forge block waive <id> <finding-id> --run <r> --file <path> --reason <text> [--yes]\n' +
|
|
296
|
+
' code-forge block reopen <id> --run <r> --file <path> --reason <text> [--yes]\n' +
|
|
297
|
+
' code-forge block accept-hit <id> --run <r> --rule <rule-id> --line <n> --transcript <path> --reason <text>\n' +
|
|
298
|
+
' code-forge block unwaive <id> <finding-id> --run <r> --file <path> --reason <text>\n' +
|
|
299
|
+
' code-forge block ack-waivers <id> --run <r>\n' +
|
|
288
300
|
' every block verb takes --json: one JSON line on stdout ({"block","event",…}) instead of the text\n';
|
|
289
301
|
|
|
290
|
-
const SUBCOMMANDS = ['open', 'attempt', 'rebase', 'claim', 'close', 'stop', 'waive', 'reopen'];
|
|
291
|
-
|
|
302
|
+
const SUBCOMMANDS = ['open', 'attempt', 'rebase', 'claim', 'close', 'stop', 'waive', 'reopen', 'accept-hit', 'unwaive', 'ack-waivers'];
|
|
303
|
+
|
|
304
|
+
/** Refuse likely shell quoting slips before any row is written.
|
|
305
|
+
* @param {string} workspace @param {string[]} entries @param {string} label
|
|
306
|
+
*/
|
|
307
|
+
function checkWhitespacePaths(workspace, entries, label) {
|
|
308
|
+
for (const entry of entries) {
|
|
309
|
+
if (!/\s/u.test(entry)) continue;
|
|
310
|
+
let stat;
|
|
311
|
+
try {
|
|
312
|
+
stat = statSync(path.join(workspace, entry));
|
|
313
|
+
} catch {
|
|
314
|
+
// A missing or unreadable path cannot establish a literal whitespace-containing name.
|
|
315
|
+
}
|
|
316
|
+
if (!stat?.isFile() && !stat?.isDirectory()) {
|
|
317
|
+
throw new StateError(label === 'owned files' ? 'bad-owned' : 'bad-tests', `${label}: invalid path ${JSON.stringify(entry)}: contains whitespace but names no existing file or directory`);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
const FLAG_VALUES = ['run', 'level', 'acceptance', 'brief', 'attempt', 'base', 'lines', 'head', 'worker-pid', 'reason', 'file', 'transcript', 'kind', 'report', 'rule', 'line'];
|
|
292
322
|
|
|
293
323
|
/**
|
|
294
324
|
* @param {string[]} args
|
|
@@ -298,6 +328,8 @@ const FLAG_VALUES = ['run', 'level', 'acceptance', 'brief', 'attempt', 'base', '
|
|
|
298
328
|
* ui?: {confirm: (o: {message: string, initialValue?: boolean}) => Promise<unknown>, isCancel: (v: unknown) => boolean},
|
|
299
329
|
* removeBlockTree?: typeof removeBlockTree,
|
|
300
330
|
* resolveTicket?: typeof resolveTicket, reviewedLines?: typeof reviewedLines,
|
|
331
|
+
* waiverJudge?: import('../review/agent-waiver.mjs').WaiverJudge,
|
|
332
|
+
* extraTokens?: string[],
|
|
301
333
|
* }} [deps] - `now` (B48): the clock of the autopilot expiry checks. `stdinIsTTY` / `stdoutIsTTY`
|
|
302
334
|
* (default `process.stdin.isTTY` / `process.stdout.isTTY`; `isTTY` sets both) and `ui` (default
|
|
303
335
|
* `@clack/prompts`): the human's confirmation (B52 `--no-require-reviews`, B80 `block waive` and
|
|
@@ -308,7 +340,7 @@ const FLAG_VALUES = ['run', 'level', 'acceptance', 'brief', 'attempt', 'base', '
|
|
|
308
340
|
* @returns {Promise<number>}
|
|
309
341
|
*/
|
|
310
342
|
export async function runBlock(args, deps = {}) {
|
|
311
|
-
const { stdout = process.stdout, stderr = process.stderr, probe } = deps;
|
|
343
|
+
const { stdout = process.stdout, stderr = process.stderr, probe, extraTokens = [] } = deps;
|
|
312
344
|
const out = (/** @type {string} */ s) => writeSafe(stdout, s);
|
|
313
345
|
const err = (/** @type {string} */ s) => writeSafe(stderr, s);
|
|
314
346
|
const [sub, ...rest] = args;
|
|
@@ -317,7 +349,7 @@ export async function runBlock(args, deps = {}) {
|
|
|
317
349
|
return 2;
|
|
318
350
|
}
|
|
319
351
|
try {
|
|
320
|
-
const { flags, positionals } = parseFlags(rest, { values: FLAG_VALUES, multi: ['owned', 'tests'], booleans: ['no-require-reviews', 'remove-worktree', 'json'] });
|
|
352
|
+
const { flags, positionals } = parseFlags(rest, { values: FLAG_VALUES, multi: ['owned', 'tests'], booleans: ['no-require-reviews', 'remove-worktree', 'json', ...(['waive', 'reopen'].includes(sub) ? ['yes'] : [])] });
|
|
321
353
|
// B64a: --json prints ONE JSON line on success instead of the text lines (`emit`, one object
|
|
322
354
|
// per verb, built where the text is); without it every output is exactly as before (`say` is `out`)
|
|
323
355
|
const json = flags.json === true;
|
|
@@ -344,10 +376,39 @@ export async function runBlock(args, deps = {}) {
|
|
|
344
376
|
if (sub === 'reopen' && positionals.length !== 1) throw new StateError('usage', 'block reopen takes one block id (the file goes in --file)');
|
|
345
377
|
if (sub === 'reopen' && typeof flags.file !== 'string') throw new StateError('usage', 'block reopen needs --file <path>');
|
|
346
378
|
if (sub === 'waive' && typeof flags.file !== 'string') throw new StateError('usage', 'block waive needs --file <path> (a finding id is scoped to one file)');
|
|
379
|
+
if (sub === 'accept-hit') {
|
|
380
|
+
const { record, row } = await prepareAcceptHit({ runId, id, flags, positionals, extraTokens });
|
|
381
|
+
const asked = await confirmHumanVerb(sub, runId, id, { rule: row.rule, line: row.line, file: /** @type {string} */ (flags.transcript) }, record, deps);
|
|
382
|
+
if (asked === 'no-tty') { err(`${humanVerbNoTTY(sub)}\n`); return 2; }
|
|
383
|
+
if (asked === 'cancelled') { err(`block ${id} ${sub}: cancelled — nothing was recorded\n`); return 1; }
|
|
384
|
+
await writeAcceptedHit({ runId, row, writeRow: (signed) => appendRow(signed, { slug: record.project }) });
|
|
385
|
+
say(`block ${id} accepted ${row.rule} transcript line ${row.line} (by: human)\n`);
|
|
386
|
+
if (json) emit({ block: id, event: 'rule_break.accepted', rule: row.rule, line: row.line, reason: row.reason, by: 'human' });
|
|
387
|
+
return 0;
|
|
388
|
+
}
|
|
347
389
|
const record = await readRun(runId);
|
|
390
|
+
if (sub === 'unwaive' || sub === 'ack-waivers') {
|
|
391
|
+
if (positionals.length !== (sub === 'unwaive' ? 2 : 1)) throw new StateError('usage', `block ${sub} takes ${sub === 'unwaive' ? 'one block id and one finding id' : 'one block id'}`);
|
|
392
|
+
const prepared = sub === 'unwaive'
|
|
393
|
+
? { counted: [], row: await prepareUnwaive({ runId, id, finding: extra, file: /** @type {string} */ (flags.file), reason: /** @type {string} */ (flags.reason), record }) }
|
|
394
|
+
: await prepareAckWaivers(record, runId, id);
|
|
395
|
+
const asked = await confirmHumanVerb(sub, runId, id, { finding: extra, file: /** @type {string} */ (flags.file) }, record, deps, () => {
|
|
396
|
+
for (const w of prepared.counted) (json ? err : say)(`block ${id}: agent waiver ${w.file} ${w.finding} reason: ${w.reason}; verdict: ${w.verdict}\n`);
|
|
397
|
+
});
|
|
398
|
+
if (asked === 'no-tty') { err(`${humanVerbNoTTY(sub)}\n`); return 2; }
|
|
399
|
+
if (asked === 'cancelled') { err(`block ${id} ${sub}: cancelled — nothing was recorded\n`); return 1; }
|
|
400
|
+
await writeSigned(runId, (row) => appendRow(row, { slug: record.project }), prepared.row);
|
|
401
|
+
say(sub === 'unwaive' ? `block ${id} unwaived ${extra} in ${flags.file} (by: human)\n` : `block ${id} acknowledged ${prepared.counted.length} agent waiver(s) (by: human)\n`);
|
|
402
|
+
if (json) emit(prepared.row);
|
|
403
|
+
return 0;
|
|
404
|
+
}
|
|
405
|
+
if (sub === 'open') {
|
|
406
|
+
checkWhitespacePaths(record.workspace, /** @type {string[]} */ (flags.owned), 'owned files');
|
|
407
|
+
if (Array.isArray(flags.tests)) checkWhitespacePaths(record.workspace, flags.tests, 'test files');
|
|
408
|
+
}
|
|
348
409
|
// B80: waive and reopen are the human's — asked at a terminal before anything is written (not
|
|
349
410
|
// even the autopilot expiry rows below); no terminal is exit 2, a "no" is exit 1
|
|
350
|
-
if (sub === 'waive' || sub === 'reopen') {
|
|
411
|
+
if ((sub === 'waive' || sub === 'reopen') && flags.yes !== true) {
|
|
351
412
|
const asked = await confirmHumanVerb(sub, runId, id, { finding: extra, file: /** @type {string} */ (flags.file) }, record, deps);
|
|
352
413
|
if (asked === 'no-tty') {
|
|
353
414
|
err(`${humanVerbNoTTY(sub)}\n`);
|
|
@@ -359,6 +420,22 @@ export async function runBlock(args, deps = {}) {
|
|
|
359
420
|
}
|
|
360
421
|
}
|
|
361
422
|
const writeRow =(/** @type {Record<string, any>} */ row) => appendRow(row, { slug: record.project });
|
|
423
|
+
const reopened = async (/** @type {boolean} */ agent) => {
|
|
424
|
+
const file = /** @type {string} */ (flags.file);
|
|
425
|
+
const { row, cleared } = await reopenBlockFile({ runId, id, file, reason: /** @type {string} */ (flags.reason), record, writeRow, agent, judge: deps.waiverJudge });
|
|
426
|
+
const fromText = row.stop_reason == null ? row.from : `${row.from} ${row.stop_reason}`;
|
|
427
|
+
say(`block ${id} reopened ${file} (from ${fromText}; by: ${row.by})\n`);
|
|
428
|
+
if (json) emit({ block: id, event: 'reopened', file, from: row.from, stop_reason: row.stop_reason, at_seq: row.at_seq, from_hash: row.from_hash, to_hash: row.to_hash, ticket_cleared: cleared, by: row.by });
|
|
429
|
+
return 0;
|
|
430
|
+
};
|
|
431
|
+
// Agent refusals precede even the lazy expiry checks: no rows on refusal.
|
|
432
|
+
if (sub === 'reopen' && flags.yes === true) return await reopened(true);
|
|
433
|
+
if (sub === 'waive' && flags.yes === true) {
|
|
434
|
+
await waiveAsAgent({ runId, id, finding: extra, file: /** @type {string} */ (flags.file), reason: /** @type {string} */ (flags.reason), record, writeRow, judge: deps.waiverJudge });
|
|
435
|
+
say(`block ${id} waived ${extra} in ${flags.file} (by: agent)\n`);
|
|
436
|
+
if (json) emit({ block: id, event: 'waived', finding: extra, file: flags.file, by: 'agent' });
|
|
437
|
+
return 0;
|
|
438
|
+
}
|
|
362
439
|
// B64b: the block-end signal — after an end's rows (and outside the run lock) the done file,
|
|
363
440
|
// then the owner's `hooks.on_block_end` from the run-start snapshot. Without the hook key the
|
|
364
441
|
// done file is the only addition: no row, and every output stays exactly as before.
|
|
@@ -510,12 +587,7 @@ export async function runBlock(args, deps = {}) {
|
|
|
510
587
|
return 0;
|
|
511
588
|
}
|
|
512
589
|
if (sub === 'reopen') {
|
|
513
|
-
|
|
514
|
-
const { row, cleared } = await reopenBlockFile({ runId, id, file, reason: /** @type {string} */ (flags.reason), record, writeRow });
|
|
515
|
-
const fromText = row.stop_reason == null ? row.from : `${row.from} ${row.stop_reason}`;
|
|
516
|
-
say(`block ${id} reopened ${file} (from ${fromText}; by: human)\n`);
|
|
517
|
-
if (json) emit({ block: id, event: 'reopened', file, from: row.from, stop_reason: row.stop_reason, at_seq: row.at_seq, from_hash: row.from_hash, to_hash: row.to_hash, ticket_cleared: cleared, by: 'human' });
|
|
518
|
-
return 0;
|
|
590
|
+
return await reopened(false);
|
|
519
591
|
}
|
|
520
592
|
if (sub === 'close') {
|
|
521
593
|
const entry = record.blocks?.[id];
|
|
@@ -586,6 +658,8 @@ export async function runBlock(args, deps = {}) {
|
|
|
586
658
|
const closeRows = external === null ? [] : [{ event: 'gate.reviews_waived', block: id, by: 'config', reviewer: external }];
|
|
587
659
|
/** @type {import('../review/gate-check.mjs').WaivedAll[]} */
|
|
588
660
|
let waivedAll = [];
|
|
661
|
+
/** @type {import('../review/accept-hit.mjs').AcceptedHit[]} */
|
|
662
|
+
let acceptedHits = [];
|
|
589
663
|
// B70: a file whose review left only waived findings passes as `reviewed_by_waiver`; its signed
|
|
590
664
|
// `gate.review_waived_all` row joins the close rows (written just before `block.close`, only
|
|
591
665
|
// when the block closes — a refused close writes none)
|
|
@@ -636,6 +710,7 @@ export async function runBlock(args, deps = {}) {
|
|
|
636
710
|
rows,
|
|
637
711
|
key,
|
|
638
712
|
transcript,
|
|
713
|
+
extraTokens,
|
|
639
714
|
// B52: a change no block of the run owns is refused — `--no-require-reviews` does not lift it
|
|
640
715
|
unowned: scope?.unowned ?? [],
|
|
641
716
|
// B52: an old ledger's review.result rows are judged by their signed result files
|
|
@@ -648,7 +723,7 @@ export async function runBlock(args, deps = {}) {
|
|
|
648
723
|
proofFiles: fileSet,
|
|
649
724
|
highPaths,
|
|
650
725
|
};
|
|
651
|
-
}, { onWaivedAll });
|
|
726
|
+
}, { onWaivedAll, onAcceptedHits: (hits) => { acceptedHits = hits; } });
|
|
652
727
|
// B96: `reviewsPassed` comes from the gate's own result, not from the onWaivedAll call. Each
|
|
653
728
|
// extra check runs at most once per close (`closeBlock`'s one loop, src/state/block.mjs:562,
|
|
654
729
|
// and `closeBlock` is called once below), so the gate's one pass is its final one.
|
|
@@ -724,6 +799,8 @@ export async function runBlock(args, deps = {}) {
|
|
|
724
799
|
};
|
|
725
800
|
/** @type {Array<() => Promise<{ok: boolean, reason?: string}>>} */
|
|
726
801
|
const extraChecks = [reviews, report];
|
|
802
|
+
extraChecks.unshift(async () => agentWaiversAcked(record, rows, id, await loadKey(runId))
|
|
803
|
+
? { ok: true } : { ok: false, reason: 'agent-waivers-not-acked' });
|
|
727
804
|
// B63b: `context.require_coder_tests` in the run's config snapshot (never the live config)
|
|
728
805
|
// needs the coder's green test run — waived reviews do not lift it. It runs FIRST: it only
|
|
729
806
|
// reads the report, so a refusal here leaves no row (not even the human waiver row the review
|
|
@@ -748,6 +825,8 @@ export async function runBlock(args, deps = {}) {
|
|
|
748
825
|
});
|
|
749
826
|
}
|
|
750
827
|
const result = await closeBlock({ runId, id, rows, writeRow, livePid: intFlag(flags['worker-pid'], 'worker-pid'), probe, extraChecks, closeRows });
|
|
828
|
+
const agentRows = agentWaivers(rows, id, await loadKey(runId));
|
|
829
|
+
const reopenedRows = agentReopens(rows.filter((r) => r.run === runId), id, await loadKey(runId));
|
|
751
830
|
// B64b: the block ended (closed, or stopped by a worker pin event or ledger.tamper) — its rows
|
|
752
831
|
// are written and the run lock released; the done file is written before the tree is removed
|
|
753
832
|
// B96: the tool's reviewed lines of a block that closed (also kept in its done file)
|
|
@@ -761,6 +840,9 @@ export async function runBlock(args, deps = {}) {
|
|
|
761
840
|
// B70: each file that passed as reviewed_by_waiver is named before the closed line
|
|
762
841
|
// B71: an answered finding is named as answered; a file with only answered findings has no `by`
|
|
763
842
|
if (result.ok) {
|
|
843
|
+
if (configInForce(record)?.gates?.test != null && lastGate(rows.filter((row) => row.block === id)).result !== 'pass') {
|
|
844
|
+
err(`WARN block ${id}: no green gates run for this block; run gates run --run ${runId} --block ${id} before close\n`);
|
|
845
|
+
}
|
|
764
846
|
for (const w of lineWarnings) err(w);
|
|
765
847
|
for (const l of reviewedJson) say(`reviewed ${l.file} ticket ${l.ticket}\n`);
|
|
766
848
|
for (const f of waivedAll) {
|
|
@@ -768,6 +850,9 @@ export async function runBlock(args, deps = {}) {
|
|
|
768
850
|
if (f.answered?.length) parts.push(`answered ${f.answered.join(', ')}`);
|
|
769
851
|
say(`block ${id}: ${f.file} reviewed_by_waiver (${parts.join('; ')})\n`);
|
|
770
852
|
}
|
|
853
|
+
for (const w of agentRows) say(`block ${id}: agent waiver ${w.file} ${w.finding} reason: ${w.reason}; verdict: ${w.verdict}\n`);
|
|
854
|
+
for (const r of reopenedRows) say(`agent reopen ${r.file}: ${r.reason} (verdict: ${r.verdict?.verdict})\n`);
|
|
855
|
+
for (const hit of acceptedHits) say(`accepted rule_break ${hit.rule} transcript line ${hit.line}: ${hit.reason} (by: human)\n`);
|
|
771
856
|
}
|
|
772
857
|
(result.ok ? say : err)(`block ${id} ${result.status}${result.event ? ` (${result.event})` : ''}${result.reason ? `: ${result.reason}` : ''}\n`);
|
|
773
858
|
// B64a: a refused close also names itself on stdout under --json (its stderr is unchanged)
|
|
@@ -783,6 +868,9 @@ export async function runBlock(args, deps = {}) {
|
|
|
783
868
|
...(result.ok && waivedAll.length > 0 ? { reviewed_by_waiver: waivedAll.map((f) => ({ file: f.file, content_hash: f.content_hash, status: f.status, findings: f.findings, by: f.by, ...(f.answered?.length ? { answered: f.answered } : {}) })) } : {}),
|
|
784
869
|
// B96: only when the tool gave reviewed lines (a waived or external close is unchanged)
|
|
785
870
|
...(reviewedJson.length > 0 ? { reviewed: reviewedJson } : {}),
|
|
871
|
+
...(agentRows.length > 0 ? { agent_waivers: agentRows } : {}),
|
|
872
|
+
...(reopenedRows.length > 0 ? { agent_reopens: reopenedRows } : {}),
|
|
873
|
+
...(acceptedHits.length > 0 ? { accepted_hits: acceptedHits } : {}),
|
|
786
874
|
};
|
|
787
875
|
if (!result.ok || !removeTree) {
|
|
788
876
|
if (json && result.ok) emit(closedJson);
|
|
@@ -871,9 +959,12 @@ export const NO_REVIEWS_NO_TTY = '--no-require-reviews needs the human at a term
|
|
|
871
959
|
|
|
872
960
|
/**
|
|
873
961
|
* B80: the refusal when `block waive` or `block reopen` has no terminal to ask the human at.
|
|
874
|
-
* @param {'waive' | 'reopen'} verb @returns {string}
|
|
962
|
+
* @param {'waive' | 'reopen' | 'accept-hit' | 'unwaive' | 'ack-waivers'} verb @returns {string}
|
|
875
963
|
*/
|
|
876
|
-
export const humanVerbNoTTY = (verb) =>
|
|
964
|
+
export const humanVerbNoTTY = (verb) => verb === 'waive'
|
|
965
|
+
? 'block waive needs the human at a terminal to confirm; nothing was recorded (--yes works only for a recorded warning or nit finding, with evidence)'
|
|
966
|
+
: verb === 'reopen' ? 'block reopen needs the human at a terminal to confirm; nothing was recorded (--yes works only with evidence and agreement from a fresh judge)'
|
|
967
|
+
: `block ${verb} needs the human at a terminal to confirm; nothing was recorded (there is no --yes)`;
|
|
877
968
|
|
|
878
969
|
/**
|
|
879
970
|
* @typedef {{isTTY?: boolean, stdinIsTTY?: boolean, stdoutIsTTY?: boolean, ui?: {confirm: (o: {message: string, initialValue?: boolean}) => Promise<unknown>, isCancel: (v: unknown) => boolean}, now?: () => Date}} ConfirmDeps
|
|
@@ -928,18 +1019,26 @@ async function confirmNoReviews(runId, id, deps) {
|
|
|
928
1019
|
* autopilot grant changes nothing (autopilot waives through its own path, `by: autopilot`). The
|
|
929
1020
|
* pre-checks (`reopenPrecheck`, `waivePrecheck`) run first, at a terminal, so the human is never
|
|
930
1021
|
* asked about a waive or reopen the verb would refuse that way.
|
|
931
|
-
* @param {'waive' | 'reopen'} verb @param {string} runId @param {string} id
|
|
932
|
-
* @param {{finding?: string, file: string}} what @param {Record<string, any>} record @param {ConfirmDeps} deps
|
|
1022
|
+
* @param {'waive' | 'reopen' | 'accept-hit' | 'unwaive' | 'ack-waivers'} verb @param {string} runId @param {string} id
|
|
1023
|
+
* @param {{finding?: string, file: string, rule?: string, line?: number}} what @param {Record<string, any>} record @param {ConfirmDeps} deps
|
|
1024
|
+
* @param {() => void} [before]
|
|
933
1025
|
* @returns {Promise<'no-tty' | 'confirmed' | 'cancelled'>}
|
|
934
1026
|
*/
|
|
935
|
-
function confirmHumanVerb(verb, runId, id, what, record, deps) {
|
|
1027
|
+
function confirmHumanVerb(verb, runId, id, what, record, deps, before) {
|
|
936
1028
|
const message =
|
|
937
1029
|
verb === 'waive'
|
|
938
1030
|
? `Waive finding ${what.finding} in ${what.file} of block ${id} (run ${runId})? Only the owner may say yes.`
|
|
939
|
-
:
|
|
1031
|
+
: verb === 'unwaive'
|
|
1032
|
+
? `Undo agent waivers for finding ${what.finding} in ${what.file} of block ${id} (run ${runId})? Only the owner may say yes.`
|
|
1033
|
+
: verb === 'ack-waivers'
|
|
1034
|
+
? `Acknowledge the listed agent waivers of block ${id} (run ${runId})? Only the owner may say yes.`
|
|
1035
|
+
: verb === 'accept-hit'
|
|
1036
|
+
? `Accept false transcript hit ${what.rule} at line ${what.line} in ${what.file} of block ${id} (run ${runId})? Only the owner may say yes.`
|
|
1037
|
+
: `Reopen the review of ${what.file} in block ${id} (run ${runId})? Only the owner may say yes.`;
|
|
940
1038
|
return confirmAtTerminal(deps, message, async () => {
|
|
941
1039
|
if (verb === 'reopen') await reopenPrecheck(record, runId, id, what.file);
|
|
942
|
-
else await waivePrecheck(record, runId, id, /** @type {string} */ (what.finding), what.file);
|
|
1040
|
+
else if (verb === 'waive') await waivePrecheck(record, runId, id, /** @type {string} */ (what.finding), what.file);
|
|
1041
|
+
before?.();
|
|
943
1042
|
});
|
|
944
1043
|
}
|
|
945
1044
|
|
package/src/cli/context.mjs
CHANGED
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
* `<block>.md`. A row that cannot be appended removes only the temp file, so an earlier pack stays
|
|
17
17
|
* as it was and nothing was written. A rename that fails after the row was appended says so.
|
|
18
18
|
*
|
|
19
|
-
*
|
|
19
|
+
* With no `context` key, the pack holds only the block header and built-in proof rule (300 lines).
|
|
20
|
+
* Exit codes: 0 written, 1 refused (a rules or lessons file missing or outside
|
|
20
21
|
* the repository, a bad `context` value, a control character, an unknown run or block, a file or
|
|
21
22
|
* ledger write that fails), 2 usage. Never runs any command it lists.
|
|
22
23
|
*/
|
|
@@ -94,7 +95,7 @@ export async function runContext(args, deps = {}) {
|
|
|
94
95
|
if (!loaded.ok) throw new ContextError('bad-config', loaded.message);
|
|
95
96
|
const live = /** @type {Record<string, any>} */ (loaded.config);
|
|
96
97
|
const settings = contextSettings(live);
|
|
97
|
-
if (settings === null) throw new ContextError('
|
|
98
|
+
if (settings === null && Object.hasOwn(live, 'context')) throw new ContextError('bad-context', 'context must be a mapping');
|
|
98
99
|
const sources = readSources(repoRoot, settings);
|
|
99
100
|
const { text, lines, cut } = assemblePack({
|
|
100
101
|
settings,
|
package/src/cli/report.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `code-forge report [--slug <slug>] [--json] [--export <dir>]` (plan §6.2, R4). Terminal output
|
|
3
|
-
* only, plus an optional `--export <dir>` that writes the same
|
|
3
|
+
* only, plus an optional `--export <dir>` that writes the same 15 sections as JSON files. The
|
|
4
4
|
* terminal output ends with the spend per run (B33) and the mode of each run (B59: brief or plan);
|
|
5
5
|
* both are text only, never in `--json` or `--export`. The 14th section, `outside_review` (B66), is
|
|
6
6
|
* printed as text lines under its header (`outside review: none`, or one line per PR).
|
|
@@ -75,7 +75,7 @@ export async function runReport(args, deps = {}) {
|
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
/**
|
|
78
|
-
* B33: the running spend per run (open blocks included), after the
|
|
78
|
+
* B33: the running spend per run (open blocks included), after the 15 sections. Unknown-price
|
|
79
79
|
* sessions are named, never priced.
|
|
80
80
|
* @param {ReturnType<typeof buildSpend>} spend @returns {string}
|
|
81
81
|
*/
|
package/src/cli/review-file.mjs
CHANGED
|
@@ -69,7 +69,7 @@
|
|
|
69
69
|
|
|
70
70
|
import { readFileSync, realpathSync, rmSync } from 'node:fs';
|
|
71
71
|
import path from 'node:path';
|
|
72
|
-
import { answersTicketId, assertAnswers, blockTickets, dropDoneIf, enqueue, enqueueAnswers, isDone, liveWorker, MAX_ANSWERS, queueDir, resolveTicket } from '../worker/queue.mjs';
|
|
72
|
+
import { answersTicketId, assertAnswers, blockTickets, deliverBlockFindings, dropDoneIf, enqueue, enqueueAnswers, isDone, liveWorker, MAX_ANSWERS, queueDir, resolveTicket } from '../worker/queue.mjs';
|
|
73
73
|
import { assertBlockId, assertInsideRoot, assertTicketId, contentHash, mainRootOf, normalizeRequestPath, repoRootOf, WorkerError } from '../worker/ticket.mjs';
|
|
74
74
|
import { blockFileSet, namedOnHash } from '../review/gate-check.mjs';
|
|
75
75
|
import { STOP_EXITS } from '../review/stop-exits.mjs';
|
|
@@ -163,21 +163,15 @@ const FRESH_ATTEMPTS = 3;
|
|
|
163
163
|
/** B75: the ways out a counted result names (human-only; a coder stops and reports). */
|
|
164
164
|
export const FRESH_EXITS = Object.freeze(['block reopen', 'block waive']);
|
|
165
165
|
|
|
166
|
+
/** The automatic retry allow-list excludes refused results, even those --fresh can rerun. */
|
|
167
|
+
const RETRY_STATUSES = new Set(['stale', 'unavailable', 'split_required']);
|
|
168
|
+
|
|
166
169
|
/**
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
* A marker that changed under the claim is restored (link only) and classified again, at most
|
|
171
|
-
* {@link FRESH_ATTEMPTS} times. The queue has no lock: the rename claim is the exclusion, and a
|
|
172
|
-
* 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:
|
|
173
|
-
* `fresh-needs-reopen` (names `block reopen` and `block waive`) or `fresh-tampered` (names only
|
|
174
|
-
* the owner's `block close --no-require-reviews`; reopen refuses a tampered state).
|
|
175
|
-
* B110: a plain cached round-1 `split_required` uses the same claim, with `retry: true`.
|
|
176
|
-
* @param {{repoRoot: string, run: string, block: string, file: string, where: {tree?: string}, say: (obj: Record<string, any>) => unknown, dropDone?: typeof dropDoneIf, retry?: boolean}} opts
|
|
177
|
-
* @returns {number} the exit code.
|
|
170
|
+
* Classify in place and use the same byte-checked claim for fresh and automatic retries.
|
|
171
|
+
* @param {{repoRoot: string, ticket: string, file: string, dropDone?: typeof dropDoneIf, once?: boolean, statuses?: Set<string>}} opts
|
|
172
|
+
* @returns {{verdict: 'rerun' | 'tampered' | 'counted', outcome: 'dropped' | 'gone' | 'kept'}}
|
|
178
173
|
*/
|
|
179
|
-
function
|
|
180
|
-
const { ticket } = resolveTicket({ repoRoot, run, block, file, tree: where.tree ?? null });
|
|
174
|
+
function dropFreshDone({ repoRoot, ticket, file, dropDone = dropDoneIf, once = false, statuses }) {
|
|
181
175
|
const markerPath = path.join(queueDir(repoRoot), `${ticket}.done`);
|
|
182
176
|
/** @type {'rerun' | 'tampered' | 'counted'} */
|
|
183
177
|
let verdict = 'counted';
|
|
@@ -191,18 +185,17 @@ function freshTicket({ repoRoot, run, block, file, where, say, dropDone = dropDo
|
|
|
191
185
|
// no marker: a normal enqueue; a marker that exists but cannot be read is never dropped
|
|
192
186
|
bytes = /** @type {{code?: unknown}} */ (err)?.code === 'ENOENT' ? null : Buffer.alloc(0);
|
|
193
187
|
}
|
|
194
|
-
if (bytes === null) {
|
|
195
|
-
say(enqueue({ repoRoot, run, block, file, ...where }));
|
|
196
|
-
return 0;
|
|
197
|
-
}
|
|
188
|
+
if (bytes === null) return { verdict: 'counted', outcome: 'gone' };
|
|
198
189
|
// classified IN PLACE: a refusal never moves, renames or touches the marker
|
|
199
190
|
const text = bytes.toString('utf8');
|
|
200
|
-
|
|
191
|
+
const result = resultOfMarker(repoRoot, text);
|
|
192
|
+
verdict = freshVerdict(text, result);
|
|
193
|
+
if (statuses && !statuses.has(result?.status)) verdict = 'counted';
|
|
201
194
|
if (verdict !== 'rerun') break;
|
|
202
195
|
/** @type {unknown} */
|
|
203
196
|
let outcome;
|
|
204
197
|
try {
|
|
205
|
-
outcome = dropDone(repoRoot, ticket, bytes);
|
|
198
|
+
outcome = dropDone(repoRoot, ticket, bytes, once ? { once: true } : {});
|
|
206
199
|
} catch (err) {
|
|
207
200
|
// a named refusal passes through; any other throw is named here, never echoed
|
|
208
201
|
if (err instanceof WorkerError) throw err;
|
|
@@ -212,10 +205,31 @@ function freshTicket({ repoRoot, run, block, file, where, say, dropDone = dropDo
|
|
|
212
205
|
verdict = 'counted';
|
|
213
206
|
continue;
|
|
214
207
|
}
|
|
208
|
+
if (outcome === 'kept') break;
|
|
215
209
|
// an allow-list of outcomes: anything else is refused and nothing is queued
|
|
216
210
|
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`);
|
|
217
|
-
|
|
218
|
-
|
|
211
|
+
return { verdict, outcome };
|
|
212
|
+
}
|
|
213
|
+
return { verdict, outcome: 'kept' };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* B75: `review-file <path> --block <id> --fresh`. No done marker: a normal enqueue. The marker is
|
|
218
|
+
* read and classified IN PLACE; only a cached result that never counted is then claimed by
|
|
219
|
+
* `dropDoneIf` (rename, then a byte compare with what was classified) and the ticket queued again.
|
|
220
|
+
* A marker that changed under the claim is restored (link only) and classified again, at most
|
|
221
|
+
* {@link FRESH_ATTEMPTS} times. The queue has no lock: the rename claim is the exclusion, and a
|
|
222
|
+
* 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:
|
|
223
|
+
* `fresh-needs-reopen` (names `block reopen` and `block waive`) or `fresh-tampered` (names only
|
|
224
|
+
* the owner's `block close --no-require-reviews`; reopen refuses a tampered state).
|
|
225
|
+
* B110: a plain cached round-1 `split_required` uses the same claim, with `retry: true`.
|
|
226
|
+
* @param {{repoRoot: string, run: string, block: string, file: string, where: {tree?: string}, say: (obj: Record<string, any>) => unknown, dropDone?: typeof dropDoneIf, retry?: boolean}} opts
|
|
227
|
+
* @returns {number} the exit code.
|
|
228
|
+
*/
|
|
229
|
+
function freshTicket({ repoRoot, run, block, file, where, say, dropDone = dropDoneIf, retry = false }) {
|
|
230
|
+
const { ticket } = resolveTicket({ repoRoot, run, block, file, tree: where.tree ?? null });
|
|
231
|
+
const { verdict, outcome } = dropFreshDone({ repoRoot, ticket, file, dropDone });
|
|
232
|
+
if (outcome === 'dropped' || outcome === 'gone') {
|
|
219
233
|
const queued = enqueue({ repoRoot, run, block, file, ...where });
|
|
220
234
|
say(outcome === 'dropped' ? { ...queued, ...(retry ? { retry: true } : { fresh: true }) } : queued);
|
|
221
235
|
return 0;
|
|
@@ -390,29 +404,31 @@ async function waitBlockScope(repoRoot, cwd, run, block) {
|
|
|
390
404
|
throw new WorkerError('block-scope-unreadable', 'the block scope cannot be read');
|
|
391
405
|
}
|
|
392
406
|
|
|
393
|
-
/**
|
|
394
|
-
* @param {string} repoRoot @param {string} ticket @returns {
|
|
407
|
+
/** Read a completion marker without the signing key.
|
|
408
|
+
* @param {string} repoRoot @param {string} ticket @returns {Record<string, any> | null}
|
|
395
409
|
*/
|
|
396
|
-
function
|
|
410
|
+
function blockTicketMarker(repoRoot, ticket) {
|
|
397
411
|
try {
|
|
398
412
|
const marker = JSON.parse(readFileSync(path.join(queueDir(repoRoot), `${ticket}.done`), 'utf8'));
|
|
399
|
-
if (marker?.ticket === ticket && typeof marker.status === 'string' && marker.status.length > 0) return
|
|
413
|
+
if (marker?.ticket === ticket && typeof marker.status === 'string' && marker.status.length > 0) return marker;
|
|
400
414
|
} catch (err) {
|
|
401
|
-
if (err.code === 'ENOENT') return
|
|
415
|
+
if (err.code === 'ENOENT') return null;
|
|
402
416
|
}
|
|
403
417
|
throw new WorkerError('result-unreadable', 'a ticket completion marker cannot be read');
|
|
404
418
|
}
|
|
405
419
|
|
|
406
420
|
/**
|
|
407
|
-
*
|
|
408
|
-
*
|
|
409
|
-
*
|
|
421
|
+
* A block wait uses unsigned results only to classify retries; it never reads the signing key.
|
|
422
|
+
* Re-scan current content on every poll. Deliver unseen findings of the newest earlier ticket
|
|
423
|
+
* before rechecking a blind edit; the next join queues the fix. Stopped files stay finished.
|
|
424
|
+
* @param {{repoRoot: string, cwd: string, block: string, run: string, maxMs: number, pollMs: number, say: (obj: Record<string, any>) => unknown, dropDone?: typeof dropDoneIf}} opts
|
|
410
425
|
*/
|
|
411
|
-
async function waitBlock({ repoRoot, cwd, block, run, maxMs, pollMs, say }) {
|
|
426
|
+
async function waitBlock({ repoRoot, cwd, block, run, maxMs, pollMs, say, dropDone = dropDoneIf }) {
|
|
412
427
|
const { entry, tree } = await waitBlockScope(repoRoot, cwd, run, block);
|
|
413
428
|
const where = realpathSync(tree) === realpathSync(repoRoot) ? {} : { tree: realpathSync(tree) };
|
|
414
429
|
const deadline = Date.now() + maxMs;
|
|
415
430
|
let downPolls = 0;
|
|
431
|
+
const deliveredHere = new Set();
|
|
416
432
|
for (;;) {
|
|
417
433
|
const worker = liveWorker(repoRoot);
|
|
418
434
|
const live = worker?.run === run;
|
|
@@ -424,17 +440,33 @@ async function waitBlock({ repoRoot, cwd, block, run, maxMs, pollMs, say }) {
|
|
|
424
440
|
const { ticket } = resolveTicket(opts);
|
|
425
441
|
const previous = tickets.filter((t) => t.file === file);
|
|
426
442
|
let present = previous.find((t) => t.ticket === ticket);
|
|
443
|
+
if (!present && previous.length > 0) {
|
|
444
|
+
const newest = previous[previous.length - 1];
|
|
445
|
+
const marker = blockTicketMarker(repoRoot, newest.ticket);
|
|
446
|
+
if (marker?.status === 'stopped') return { file, ticket: newest.ticket, status: 'done' };
|
|
447
|
+
if (marker?.status === 'reviewed' && marker.open_findings === true && live) {
|
|
448
|
+
if (deliveredHere.has(newest.ticket) || deliverBlockFindings({ repoRoot, block, file, ticket: newest.ticket })) {
|
|
449
|
+
deliveredHere.add(newest.ticket);
|
|
450
|
+
return { file, ticket: newest.ticket, status: 'findings' };
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
427
454
|
if (!present && previous.length > 0 && live) {
|
|
428
455
|
enqueue(opts);
|
|
429
456
|
present = { ...previous[previous.length - 1], ticket };
|
|
430
457
|
}
|
|
431
|
-
|
|
458
|
+
let marker = present ? blockTicketMarker(repoRoot, ticket) : null;
|
|
459
|
+
if (live && marker && RETRY_STATUSES.has(marker.status)) {
|
|
460
|
+
dropFreshDone({ repoRoot, ticket, file, dropDone, once: true, statuses: RETRY_STATUSES });
|
|
461
|
+
marker = blockTicketMarker(repoRoot, ticket);
|
|
462
|
+
}
|
|
463
|
+
return { file, ticket: present ? ticket : null, status: !present ? 'not_queued' : marker ? 'done' : 'pending' };
|
|
432
464
|
});
|
|
433
465
|
if (downPolls >= 2) {
|
|
434
466
|
say({ block, files, done: false, status: 'worker_down' });
|
|
435
467
|
return 3;
|
|
436
468
|
}
|
|
437
|
-
if (live && files.every((f) => f.status === 'done')) {
|
|
469
|
+
if (live && files.every((f) => f.status === 'done' || f.status === 'findings')) {
|
|
438
470
|
say({ block, files, done: true });
|
|
439
471
|
return 0;
|
|
440
472
|
}
|
|
@@ -465,7 +497,7 @@ export async function runReviewFile(args, deps = {}) {
|
|
|
465
497
|
if (positionals.length > 0 || flags.block !== undefined || flags.wait !== undefined || flags.answers !== undefined || flags.of !== undefined || flags.fresh === true || typeof flags.run !== 'string') {
|
|
466
498
|
throw new WorkerError('usage', '--wait-block takes a block, --run and --max only');
|
|
467
499
|
}
|
|
468
|
-
return await waitBlock({ repoRoot, cwd, block: assertBlockId(flags['wait-block']), run: assertRunId(flags.run), maxMs: flags.max === undefined ? DEFAULT_WAIT_MS : parseMax(flags.max), pollMs: deps.pollMs ?? POLL_MS, say });
|
|
500
|
+
return await waitBlock({ repoRoot, cwd, block: assertBlockId(flags['wait-block']), run: assertRunId(flags.run), maxMs: flags.max === undefined ? DEFAULT_WAIT_MS : parseMax(flags.max), pollMs: deps.pollMs ?? POLL_MS, say, dropDone: deps.dropDone });
|
|
469
501
|
}
|
|
470
502
|
|
|
471
503
|
if (typeof flags.wait === 'string') {
|
|
@@ -511,16 +543,9 @@ export async function runReviewFile(args, deps = {}) {
|
|
|
511
543
|
const where = realpathSync(tree) === realpathSync(repoRoot) ? {} : { tree };
|
|
512
544
|
if (flags.fresh === true) return freshTicket({ repoRoot, run: worker.run, block, file, where, say, ...(deps.dropDone ? { dropDone: deps.dropDone } : {}) });
|
|
513
545
|
const queued = enqueue({ repoRoot, run: worker.run, block, file, ...where });
|
|
514
|
-
if (queued.status === 'done' && doneResult(repoRoot, queued.ticket)?.status
|
|
546
|
+
if (queued.status === 'done' && ['unavailable', 'split_required'].includes(doneResult(repoRoot, queued.ticket)?.status)) {
|
|
515
547
|
return freshTicket({ repoRoot, run: worker.run, block, file, where, say, retry: true, ...(deps.dropDone ? { dropDone: deps.dropDone } : {}) });
|
|
516
548
|
}
|
|
517
|
-
if (queued.status === 'done' && doneResult(repoRoot, queued.ticket)?.status === 'unavailable') {
|
|
518
|
-
// `unavailable` is never approval AND never final: drop the done marker so the worker runs
|
|
519
|
-
// the ticket again (the fix loop re-runs its pending round); the signed result is replaced.
|
|
520
|
-
rmSync(path.join(queueDir(repoRoot), `${queued.ticket}.done`), { force: true });
|
|
521
|
-
say({ ...enqueue({ repoRoot, run: worker.run, block, file, ...where }), retry: true });
|
|
522
|
-
return 0;
|
|
523
|
-
}
|
|
524
549
|
say(queued);
|
|
525
550
|
return 0;
|
|
526
551
|
} catch (err) {
|
package/src/cli/review.mjs
CHANGED
|
@@ -48,7 +48,7 @@ import { semaphore } from '../util/locks.mjs';
|
|
|
48
48
|
import { writeSafe } from '../util/redact.mjs';
|
|
49
49
|
import { isAlive, listEntries, readStartTime, UNKNOWN_START_TIME } from '../util/reaper.mjs';
|
|
50
50
|
import { pidsDir, runRoot, setRunRoot, tmpBase } from '../util/tmp.mjs';
|
|
51
|
-
import {
|
|
51
|
+
import { liveWorker } from '../worker/queue.mjs';
|
|
52
52
|
import { gitChildEnv, normalizeRequestPath, repoRootOf, WorkerError } from '../worker/ticket.mjs';
|
|
53
53
|
|
|
54
54
|
const BIN = fileURLToPath(new URL('../../bin/code-forge.mjs', import.meta.url));
|
|
@@ -288,7 +288,7 @@ async function runWorkers(repoRoot, runId) {
|
|
|
288
288
|
} catch {
|
|
289
289
|
// no record: nothing pinned
|
|
290
290
|
}
|
|
291
|
-
const w =
|
|
291
|
+
const w = liveWorker(repoRoot);
|
|
292
292
|
if (w?.run === runId && !found.some((f) => f.pid === w.pid)) {
|
|
293
293
|
found.push({ pid: w.pid, same: async () => w.start_time === UNKNOWN_START_TIME || readStartTime(w.pid) === w.start_time });
|
|
294
294
|
}
|