@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/review/fixloop.mjs
CHANGED
|
@@ -91,7 +91,7 @@ import { verifyRow } from '../state/signer.mjs';
|
|
|
91
91
|
import { decisionProblem, grantCoverProblem } from './gate-check.mjs';
|
|
92
92
|
import { contentHash, gitChildEnv } from '../worker/ticket.mjs';
|
|
93
93
|
import { parseDiff } from './context.mjs';
|
|
94
|
-
import { assemblePacket, assertPacketPath, diffOnlyTokens, readFileDiff } from './packet.mjs';
|
|
94
|
+
import { assemblePacket, assertPacketPath, budgetFor, diffOnlyTokens, readFileDiff } from './packet.mjs';
|
|
95
95
|
import { movedText, movesFor, peerDiffReader } from './moved.mjs';
|
|
96
96
|
import { pairedContext } from './engine.mjs';
|
|
97
97
|
import { isMarkdownFile, packSections, sectionFindings, sectionGroups, sectionOf } from './sections.mjs';
|
|
@@ -101,6 +101,7 @@ import { spawnWithTimeoutRetry } from './session-retry.mjs';
|
|
|
101
101
|
import { afterSchemaMiss, logSecondLevelMiss, packetHash } from './schema-fallback.mjs';
|
|
102
102
|
|
|
103
103
|
/** @typedef {import('./triage.mjs').Finding} Finding */
|
|
104
|
+
/** @typedef {Finding & {repeated?: boolean}} LoopFinding */
|
|
104
105
|
/** @typedef {import('./context.mjs').Hunk} Hunk */
|
|
105
106
|
|
|
106
107
|
/**
|
|
@@ -121,12 +122,15 @@ import { afterSchemaMiss, logSecondLevelMiss, packetHash } from './schema-fallba
|
|
|
121
122
|
* @property {number} round - rounds counted so far (patch_check rounds excluded).
|
|
122
123
|
* @property {number} rounds_at_level
|
|
123
124
|
* @property {number} [warn_rounds_at_level] - heavy rounds at the current level (rule 2c, B34); absent in a state saved before B34 ⇒ 0.
|
|
124
|
-
* @property {
|
|
125
|
+
* @property {LoopFinding[]} open - the open `fix_now` set.
|
|
125
126
|
* @property {Finding[]} late
|
|
127
|
+
* @property {string[]} [finding_history] - claim/evidence keys from earlier rounds of this file.
|
|
128
|
+
* @property {string[]} [finding_ids] - ids seen in earlier rounds, including closed findings.
|
|
126
129
|
* @property {string | null} reviewed_content - the content the last counted round reviewed.
|
|
127
130
|
* @property {boolean} l3_rung_used - the block's L3 rung (rule 5 or 6) was used — once per block.
|
|
128
131
|
* @property {'open' | 'complete' | 'stopped'} status
|
|
129
132
|
* @property {string} [stop_content_hash] - bytes reviewed by a stop (absent in legacy states).
|
|
133
|
+
* @property {string} [stop_lens] - the lens whose packet stopped before a round.
|
|
130
134
|
* @property {NextStep | null} next
|
|
131
135
|
* @property {{context_mode: string, context_lines: number} | null} [last_packet] - the last recheck round's context: one packet's, or over a Markdown recheck's section packets the summed lines and the one mode they share, else `mixed` (B54).
|
|
132
136
|
* @property {'full' | 'recheck' | 'patch_check' | null} [pending_kind] - the round to re-run after a `retry`.
|
|
@@ -262,11 +266,24 @@ export function extraRoundsFor(rows, { runId, block, file, key }) {
|
|
|
262
266
|
/**
|
|
263
267
|
* B47: reopen a file stopped at `review_cap` when its raised cap leaves a round: the state goes
|
|
264
268
|
* back to `open` with `next: fix` at its level (`trigger: autopilot_extra_round`); the next round
|
|
265
|
-
* is a recheck.
|
|
269
|
+
* is a recheck. B132 also resumes a pre-round split stop when its packet budget changed,
|
|
270
|
+
* preserving the counted rounds and findings. Any other state is left as it is.
|
|
266
271
|
* @param {FileState} state @param {number} extraRounds @param {Record<string, any> | undefined} cfg
|
|
267
272
|
* @returns {boolean} whether the state was reopened.
|
|
268
273
|
*/
|
|
269
274
|
export function reopenForExtraRound(state, extraRounds, cfg) {
|
|
275
|
+
// Only a recheck resumes as a fix; full and patch_check stops keep their round kind.
|
|
276
|
+
// Its packet is measured against full_in, so other lens budgets cannot reopen it.
|
|
277
|
+
if (state.status === 'stopped' && state.next?.reason === 'split_required' && state.stop_lens === 'recheck' &&
|
|
278
|
+
typeof state.next.budget === 'number' && Number.isFinite(state.next.budget) && state.next.budget > 0 &&
|
|
279
|
+
state.next.budget !== budgetFor(cfg, 'full_in')) {
|
|
280
|
+
state.status = 'open';
|
|
281
|
+
state.next = { action: 'fix', level: state.level, trigger: null };
|
|
282
|
+
state.pending_kind = null;
|
|
283
|
+
delete state.stop_content_hash;
|
|
284
|
+
delete state.stop_lens;
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
270
287
|
if (state.status !== 'stopped' || state.next?.reason !== 'review_cap') return false;
|
|
271
288
|
if (state.round >= roundCap(cfg, extraRounds)) return false;
|
|
272
289
|
state.status = 'open';
|
|
@@ -349,8 +366,18 @@ const OPEN_FINDINGS_HEAD = '## open findings\n';
|
|
|
349
366
|
/** @param {Finding} f @returns {string} the finding's line in a recheck packet's open-findings list. */
|
|
350
367
|
export const openFindingLine = (f) => `- ${f.id} (${f.severity}, lines ${f.line_start}-${f.line_end}): ${f.claim ?? ''}`;
|
|
351
368
|
|
|
369
|
+
/** @param {Finding} f @returns {string} compare BOTH fields, ignoring only surrounding whitespace. */
|
|
370
|
+
const findingKey = (f) => JSON.stringify([(f.claim ?? '').trim(), (f.evidence ?? '').trim()]);
|
|
371
|
+
|
|
372
|
+
/** @param {string} text @returns {string} */
|
|
373
|
+
const normaliseWhitespace = (text) => text.trim().replace(/\s+/g, ' ');
|
|
374
|
+
|
|
352
375
|
/** @param {ReadonlyArray<Finding>} open @returns {string} the open-findings list a recheck packet ends with. */
|
|
353
|
-
const openFindingsText = (open) =>
|
|
376
|
+
const openFindingsText = (open) => {
|
|
377
|
+
// Keep the final id/claim list stable for section readers; evidence is keyed by id just before it.
|
|
378
|
+
const evidence = open.length > 0 ? `## finding evidence\n${open.map((f) => `${f.id}: ${(f.evidence ?? '').trim().replace(/\s+/g, ' ').slice(0, 300)}`).join('\n')}\n` : '';
|
|
379
|
+
return `${evidence}${OPEN_FINDINGS_HEAD}${open.length > 0 ? open.map(openFindingLine).join('\n') : '(none)'}\n`;
|
|
380
|
+
};
|
|
354
381
|
|
|
355
382
|
/**
|
|
356
383
|
* The size a recheck packet is held to (B54): its diff-only `recheck` packet (the measure behind
|
|
@@ -631,10 +658,11 @@ async function stopBeforeRound(state, deps, kind, file, contentHash, packet) {
|
|
|
631
658
|
typeof packet.tokensIn === 'number' && typeof packet.budget === 'number'
|
|
632
659
|
? { tokens_in: packet.tokensIn, budget: packet.budget, ...(typeof packet.section === 'string' ? { section: packet.section } : {}) }
|
|
633
660
|
: {};
|
|
634
|
-
const row = { event: 'review.cap', file, round: state.round, reason, open: state.open.map((f) => f.id), ...size };
|
|
661
|
+
const row = { event: 'review.cap', file, round: state.round, reason, open: state.open.map((f) => f.id), ...size, lens: kind };
|
|
635
662
|
if (!(await mustNote(deps, row))) return retry(state, kind, 'ledger_write_failed');
|
|
636
663
|
state.status = 'stopped';
|
|
637
664
|
state.stop_content_hash = contentHash;
|
|
665
|
+
state.stop_lens = kind;
|
|
638
666
|
state.next = { action: 'stop', reason, ...size };
|
|
639
667
|
state.pending_kind = null;
|
|
640
668
|
return state;
|
|
@@ -661,6 +689,14 @@ export async function runRound(state, deps, opts = {}) {
|
|
|
661
689
|
let newInHunks = 0;
|
|
662
690
|
/** @type {Array<Record<string, any>>} */
|
|
663
691
|
const lateRows = [];
|
|
692
|
+
/** @type {Array<Record<string, any>>} */
|
|
693
|
+
const staleRows = [];
|
|
694
|
+
const history = new Set([...(state.finding_history ?? []), ...state.open.map(findingKey), ...state.late.map(findingKey)]);
|
|
695
|
+
const knownIds = new Set([...(state.finding_ids ?? []), ...state.open.map((f) => f.id), ...state.late.map((f) => f.id)]);
|
|
696
|
+
const roundFindings = new Set();
|
|
697
|
+
const roundIds = new Set();
|
|
698
|
+
/** @type {Set<string>} */
|
|
699
|
+
const repeated = new Set();
|
|
664
700
|
/** @type {number | null} */
|
|
665
701
|
let tokensIn = null;
|
|
666
702
|
/** @type {number | null} */
|
|
@@ -671,6 +707,10 @@ export async function runRound(state, deps, opts = {}) {
|
|
|
671
707
|
if (kind === 'full') {
|
|
672
708
|
const result = await deps.review();
|
|
673
709
|
if (result?.status !== 'reviewed') return retry(state, kind, result?.reason ?? result?.status ?? 'unavailable');
|
|
710
|
+
for (const finding of result.findings ?? []) {
|
|
711
|
+
roundFindings.add(findingKey(finding));
|
|
712
|
+
roundIds.add(finding.id);
|
|
713
|
+
}
|
|
674
714
|
const triaged = await triageFindings({ file: current.rel, findings: result.findings ?? [], fromJudge: cameFromJudge(result), cfg: deps.cfg, jev: deps.jev, rule: deps.rule, writeRow: deps.writeRow });
|
|
675
715
|
draft.open = triaged.fix_now;
|
|
676
716
|
newInHunks = triaged.fix_now.length;
|
|
@@ -727,6 +767,9 @@ export async function runRound(state, deps, opts = {}) {
|
|
|
727
767
|
}
|
|
728
768
|
/** @type {Set<Finding>} the pending findings a session that LISTED them resolved */
|
|
729
769
|
const resolved = new Set();
|
|
770
|
+
/** @type {Set<Finding>} */
|
|
771
|
+
const stale = new Set();
|
|
772
|
+
const currentText = normaliseWhitespace(current.content);
|
|
730
773
|
/** @type {Finding[]} */
|
|
731
774
|
const inside = [];
|
|
732
775
|
/** @type {Finding[]} */
|
|
@@ -742,11 +785,30 @@ export async function runRound(state, deps, opts = {}) {
|
|
|
742
785
|
return retry(state, kind, session.verdict.reason ?? 'unavailable');
|
|
743
786
|
}
|
|
744
787
|
const review = /** @type {Record<string, any>} */ (session.verdict.review);
|
|
745
|
-
|
|
746
|
-
//
|
|
747
|
-
for (const f of part.open)
|
|
788
|
+
// Only this packet's findings can close here. Legacy answers without a quote stay
|
|
789
|
+
// open; a nonempty quote that no longer exists in the file proves the answer stale.
|
|
790
|
+
for (const f of part.open) {
|
|
791
|
+
const answer = (review.resolved ?? []).find((/** @type {any} */ r) => r.id === f.id);
|
|
792
|
+
if (answer?.resolved === true) {
|
|
793
|
+
resolved.add(f);
|
|
794
|
+
continue;
|
|
795
|
+
}
|
|
796
|
+
const quote = normaliseWhitespace(answer?.current_lines ?? '');
|
|
797
|
+
const reason = quote.length > 0 && !currentText.includes(quote) ? 'current_lines_not_found' : null;
|
|
798
|
+
if (reason) {
|
|
799
|
+
stale.add(f);
|
|
800
|
+
staleRows.push({ event: 'review.stale_finding', file: current.rel, round: state.round + 1, finding: f.id, reason });
|
|
801
|
+
}
|
|
802
|
+
}
|
|
748
803
|
const found = /** @type {Finding[]} */ (review.findings ?? []);
|
|
749
804
|
for (const f of part.index === null ? found : sectionFindings(found, part.index)) {
|
|
805
|
+
const key = findingKey(f);
|
|
806
|
+
roundFindings.add(key);
|
|
807
|
+
roundIds.add(f.id);
|
|
808
|
+
if (kind === 'recheck' && !knownIds.has(f.id) && history.has(key)) {
|
|
809
|
+
Object.assign(f, { repeated: true });
|
|
810
|
+
repeated.add(f.id);
|
|
811
|
+
}
|
|
750
812
|
if (insideHunks(f, diff.hunks)) {
|
|
751
813
|
inside.push(f);
|
|
752
814
|
continue;
|
|
@@ -756,8 +818,7 @@ export async function runRound(state, deps, opts = {}) {
|
|
|
756
818
|
if (s.late === 'block' && f.severity !== 'nit') blocking.push(f);
|
|
757
819
|
}
|
|
758
820
|
}
|
|
759
|
-
|
|
760
|
-
const stillOpen = pending.filter((f) => !resolved.has(f));
|
|
821
|
+
const stillOpen = pending.filter((f) => !resolved.has(f) && !stale.has(f));
|
|
761
822
|
closed += pending.length - stillOpen.length;
|
|
762
823
|
const triaged = await triageFindings({ file: current.rel, findings: inside, fromJudge: false, diffText: diff.diffText, cfg: deps.cfg, jev: deps.jev, rule: deps.rule, writeRow: deps.writeRow });
|
|
763
824
|
newInHunks = triaged.fix_now.length;
|
|
@@ -767,11 +828,12 @@ export async function runRound(state, deps, opts = {}) {
|
|
|
767
828
|
// A patch_check with no fix hunks closed nothing: B73 still stops it.
|
|
768
829
|
}
|
|
769
830
|
|
|
831
|
+
const unweighted = kind !== 'full' && (staleRows.length > 0 || repeated.size > 0 || draft.open.some((f) => f.repeated)) && draft.open.every((f) => f.repeated);
|
|
770
832
|
if (kind !== 'patch_check') {
|
|
771
833
|
draft.round += 1;
|
|
772
|
-
draft.rounds_at_level += 1;
|
|
834
|
+
if (!unweighted) draft.rounds_at_level += 1;
|
|
773
835
|
// THIS round's open set, after triage: only the warnings still open count
|
|
774
|
-
if (heavyRound(draft.open, s.warnThreshold)) draft.warn_rounds_at_level = (draft.warn_rounds_at_level ?? 0) + 1;
|
|
836
|
+
if (!unweighted && heavyRound(draft.open, s.warnThreshold)) draft.warn_rounds_at_level = (draft.warn_rounds_at_level ?? 0) + 1;
|
|
775
837
|
} else {
|
|
776
838
|
// the block continues at L2 after the rung with a fresh round counter (§3.6) — also here, not
|
|
777
839
|
// only in `converge`, because the worker runs the patch_check from its own ticket
|
|
@@ -779,6 +841,8 @@ export async function runRound(state, deps, opts = {}) {
|
|
|
779
841
|
draft.warn_rounds_at_level = 0;
|
|
780
842
|
}
|
|
781
843
|
draft.reviewed_content = current.content;
|
|
844
|
+
if (history.size > 0 || roundFindings.size > 0) draft.finding_history = [...new Set([...history, ...roundFindings])];
|
|
845
|
+
if (knownIds.size > 0 || roundIds.size > 0) draft.finding_ids = [...new Set([...knownIds, ...roundIds])];
|
|
782
846
|
draft.last_packet = packetInfo;
|
|
783
847
|
const roundRow = {
|
|
784
848
|
event: 'review.round',
|
|
@@ -794,6 +858,7 @@ export async function runRound(state, deps, opts = {}) {
|
|
|
794
858
|
tokens_in: tokensIn,
|
|
795
859
|
tokens_out: tokensOut,
|
|
796
860
|
...(packetInfo ? { context_mode: packetInfo.context_mode } : {}),
|
|
861
|
+
...(repeated.size > 0 ? { repeated: [...repeated] } : {}),
|
|
797
862
|
};
|
|
798
863
|
|
|
799
864
|
if (draft.open.length === 0) {
|
|
@@ -805,13 +870,13 @@ export async function runRound(state, deps, opts = {}) {
|
|
|
805
870
|
// closed one is fixed at L2 (B73). Closed = an id open before the round, not open after it.
|
|
806
871
|
const after = new Set(draft.open.map((f) => f.id));
|
|
807
872
|
const closedIds = new Set(state.open.map((f) => f.id).filter((id) => !after.has(id))).size;
|
|
808
|
-
draft.next = decideNext(draft, openBefore, s, kind, closedIds);
|
|
873
|
+
draft.next = unweighted && kind !== 'patch_check' ? { action: 'fix', level: draft.level, trigger: null } : decideNext(draft, openBefore, s, kind, closedIds);
|
|
809
874
|
if (draft.next.action === 'stop') {
|
|
810
875
|
draft.status = 'stopped';
|
|
811
876
|
draft.stop_content_hash = current.hash;
|
|
812
877
|
}
|
|
813
878
|
}
|
|
814
|
-
const rows = [...lateRows, roundRow];
|
|
879
|
+
const rows = [...staleRows, ...lateRows, roundRow];
|
|
815
880
|
if (draft.status === 'stopped') rows.push({ event: 'review.cap', file: current.rel, round: draft.round, reason: draft.next.reason, open: draft.open.map((f) => f.id) });
|
|
816
881
|
for (const row of rows) {
|
|
817
882
|
if (!(await mustNote(deps, row))) return retry(state, kind, 'ledger_write_failed');
|
|
@@ -237,6 +237,24 @@ export function checkBlockReviews({ block, runId, files, rows, key, transcript =
|
|
|
237
237
|
return { ok: refusals.length === 0, refusals, waivedAll: refusals.length === 0 ? waivedAll : [], ...(acceptedHits.length > 0 ? { acceptedHits } : {}) };
|
|
238
238
|
}
|
|
239
239
|
|
|
240
|
+
/** B138: delegation never turns a waived or answered file into an approval.
|
|
241
|
+
* @param {Parameters<typeof checkBlockReviews>[0]} opts
|
|
242
|
+
* @returns {string | null}
|
|
243
|
+
*/
|
|
244
|
+
export function routineCloseProblem(opts) {
|
|
245
|
+
const mine = opts.rows.filter((r) => r.block === opts.block && r.run === opts.runId);
|
|
246
|
+
if (mine.some((r) => typeof r.event === 'string' && r.event.includes('waiv'))) return 'waiver rows for block';
|
|
247
|
+
if (mine.some((r) => r.event === 'rule_break' || r.event === 'rule_break.accepted')) return 'rule_break or accepted hit for block';
|
|
248
|
+
const hits = acceptedTranscriptHits({ ...opts, transcript: opts.transcript ?? null, rows: [] }).remaining;
|
|
249
|
+
if (hits.length > 0) return `rule_break transcript line ${hits[0].line}: ${hits[0].id}`;
|
|
250
|
+
if (!mine.some((r) => r.event === 'gate.done' && r.all_ok === true && verified(r, opts.key))) return 'no signed green gate row';
|
|
251
|
+
const result = checkBlockReviews(opts);
|
|
252
|
+
if (!result.ok) return result.refusals.map((r) => `${r.code}${r.file ? ` ${r.file}` : ''}`).join('; ');
|
|
253
|
+
if (result.waivedAll.length > 0) return 'reviewed_by_waiver or answered-only file';
|
|
254
|
+
if (opts.files.length === 0) return 'no approved changed files';
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
|
|
240
258
|
/** B71: the signed row the worker writes for a finding a reviewer judged resolved by another owned file. */
|
|
241
259
|
export const ANSWERED_EVENT = 'review.answered';
|
|
242
260
|
|
|
@@ -639,15 +657,16 @@ export function openFindings(findings) {
|
|
|
639
657
|
* B52: the signed result files {@link denialOf} needs — one per `review.result` row of the block
|
|
640
658
|
* that has no `approved` field (an old ledger) and is a verdict — keyed by ticket. A file that is
|
|
641
659
|
* missing, unparsable, names another run or ticket, or fails its MAC is null.
|
|
642
|
-
* @param {{repoRoot: string, runId: string, block: string, rows: Array<Record<string, any
|
|
660
|
+
* @param {{repoRoot: string, runId: string, block: string, rows: Array<Record<string, any>>, includeCurrent?: boolean}} opts
|
|
643
661
|
* `repoRoot`: the repository root the worker writes `.code-forge/reviews/` under.
|
|
662
|
+
* `includeCurrent`: B138 decision lists also need claims from modern signed results.
|
|
644
663
|
* @returns {Promise<Record<string, ResultFile>>}
|
|
645
664
|
*/
|
|
646
|
-
export async function resultFilesFor({ repoRoot, runId, block, rows }) {
|
|
665
|
+
export async function resultFilesFor({ repoRoot, runId, block, rows, includeCurrent = false }) {
|
|
647
666
|
/** @type {Record<string, ResultFile>} */
|
|
648
667
|
const out = {};
|
|
649
668
|
for (const r of rows) {
|
|
650
|
-
if (r?.event !== 'review.result' || r.block !== block || Object.hasOwn(r, 'approved') || neverReviewed(r) || typeof r.ticket !== 'string' || Object.hasOwn(out, r.ticket)) continue;
|
|
669
|
+
if (r?.event !== 'review.result' || r.block !== block || (!includeCurrent && Object.hasOwn(r, 'approved')) || neverReviewed(r) || typeof r.ticket !== 'string' || Object.hasOwn(out, r.ticket)) continue;
|
|
651
670
|
try {
|
|
652
671
|
const checked = await verifyResult(repoRoot, runId, r.ticket);
|
|
653
672
|
out[r.ticket] = checked.ok && checked.result ? { status: checked.result.status, approved: checked.result.approved, findings: checked.result.findings } : null;
|
|
@@ -10,6 +10,9 @@ that changed since that round, each with a window of the current file around it.
|
|
|
10
10
|
- For every open finding, add one entry to `resolved`: `{id, resolved: true|false, why}`
|
|
11
11
|
(`why` ≤ 200 chars). `resolved: true` only when the shown lines fix the problem without
|
|
12
12
|
introducing a new one.
|
|
13
|
+
- For `resolved: false`, you MUST add `current_lines` (≤ 300 chars): quote the exact current
|
|
14
|
+
line(s) from the packet that still show the problem, without line-number prefixes. Read the
|
|
15
|
+
changed lines; the old finding's claim and evidence alone cannot keep it open.
|
|
13
16
|
- A new problem goes in `findings` ONLY when its `line_start..line_end` lies inside a shown fix
|
|
14
17
|
hunk. Anything outside the hunks is not yours to report in this round.
|
|
15
18
|
- `passed` is true only when every open finding is resolved and you raised no new `critical`
|
package/src/review/packet.mjs
CHANGED
|
@@ -223,6 +223,32 @@ export function assemblePacket({ diff, lens, rulesDigest = '', factsExcerpt = ''
|
|
|
223
223
|
return { status: 'ok', text, hunkHeaders, contextMode: ctx.mode, contextLines: ctx.lineCount, digestLines: digest.length, tokensIn, budget: limit, overBudget: tokensIn > limit };
|
|
224
224
|
}
|
|
225
225
|
|
|
226
|
+
/** One full-budget packet for a light block; every diff and path heading is retained.
|
|
227
|
+
* @param {{diffs: FileDiff[], cfg?: Record<string, any>, rulesDigest?: string, factsExcerpt?: string}} opts
|
|
228
|
+
*/
|
|
229
|
+
export function assembleLightPacket({ diffs, cfg, rulesDigest = '', factsExcerpt = '' }) {
|
|
230
|
+
const lens = `${loadLens('full')}\nReview ALL files below in one round. Findings must name their exact file path. Echo every listed hunk in file order, including duplicates.`;
|
|
231
|
+
const diffText = diffs.map((diff) => `### ${diff.file}\n${diffSectionOf(diff)}`).join('\n\n');
|
|
232
|
+
const budget = budgetFor(cfg, 'full_in');
|
|
233
|
+
let digest = splitLines(rulesDigest).slice(0, DIGEST_MAX_LINES);
|
|
234
|
+
const context = (mode) => diffs.map((diff) => `### ${diff.file}\n${buildContext({ file: diff.file, content: diff.content, hunks: diff.hunks, cfg, mode }).text}`).join('\n\n');
|
|
235
|
+
let contextMode = 'auto';
|
|
236
|
+
let ctx = context(contextMode);
|
|
237
|
+
let text = render(lens, digest, factsExcerpt, ctx, diffText);
|
|
238
|
+
if (estimateTokens(text) > budget) {
|
|
239
|
+
contextMode = 'minimal';
|
|
240
|
+
ctx = context(contextMode);
|
|
241
|
+
text = render(lens, digest, factsExcerpt, ctx, diffText);
|
|
242
|
+
}
|
|
243
|
+
while (estimateTokens(text) > budget && digest.length > 0) {
|
|
244
|
+
digest = digest.slice(0, -1);
|
|
245
|
+
text = render(lens, digest, factsExcerpt, ctx, diffText);
|
|
246
|
+
}
|
|
247
|
+
const tokensIn = estimateTokens(text);
|
|
248
|
+
return { status: tokensIn > budget ? 'split_required' : 'ok', text, tokensIn, budget,
|
|
249
|
+
contextMode, hunkHeaders: diffs.flatMap((diff) => diff.hunks.map((h) => h.header)) };
|
|
250
|
+
}
|
|
251
|
+
|
|
226
252
|
/** Cap the entire section at full_in / 16 tokens, without cutting a UTF-8 character.
|
|
227
253
|
* @param {string} paired @param {number} fullIn @returns {string}
|
|
228
254
|
*/
|
package/src/state/block.mjs
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { createHash, randomUUID } from 'node:crypto';
|
|
15
|
-
import { createReadStream, existsSync, lstatSync, readlinkSync, realpathSync } from 'node:fs';
|
|
15
|
+
import { createReadStream, existsSync, lstatSync, readFileSync, readdirSync, readlinkSync, realpathSync } from 'node:fs';
|
|
16
16
|
import os from 'node:os';
|
|
17
17
|
import path from 'node:path';
|
|
18
18
|
import { BLOCK_KINDS, blockKind, dispatchLevel } from '../decide/escalation.mjs';
|
|
@@ -25,6 +25,8 @@ import { StateError } from './paths.mjs';
|
|
|
25
25
|
import { assertOwned, blocksOverlap, isExactEntry, ownsFile, scopeGate } from './registry.mjs';
|
|
26
26
|
import { checkWorkerPin, readRun, saveRun, withRunLock, writeSigned } from './run.mjs';
|
|
27
27
|
import { verifyRow, loadKey } from './signer.mjs';
|
|
28
|
+
import * as reviewQueue from '../worker/queue.mjs';
|
|
29
|
+
import { repoRootOf } from '../worker/ticket.mjs';
|
|
28
30
|
|
|
29
31
|
/** @typedef {import('./run.mjs').WriteRow} WriteRow */
|
|
30
32
|
|
|
@@ -216,16 +218,18 @@ export function briefPointer(relPath, content) {
|
|
|
216
218
|
|
|
217
219
|
/**
|
|
218
220
|
* @param {unknown} clauses
|
|
219
|
-
* @returns {
|
|
221
|
+
* @returns {import('../gates/acceptance.mjs').AcceptanceClause[]}
|
|
220
222
|
*/
|
|
221
223
|
function assertAcceptance(clauses) {
|
|
222
224
|
const ok =
|
|
223
225
|
Array.isArray(clauses) &&
|
|
224
226
|
clauses.length > 0 &&
|
|
225
227
|
clauses.every(
|
|
226
|
-
(c) => typeof c?.clause === 'string' && c.clause.length > 0 &&
|
|
228
|
+
(c) => typeof c?.clause === 'string' && c.clause.length > 0 && (c.kind === 'human_check'
|
|
229
|
+
? !Object.hasOwn(c, 'tests')
|
|
230
|
+
: Array.isArray(c.tests) && c.tests.length > 0 && c.tests.every((t) => typeof t === 'string' && t.length > 0)),
|
|
227
231
|
);
|
|
228
|
-
if (!ok) throw new StateError('bad-acceptance', 'acceptance: a non-empty list of {clause, tests: [test ids…]} is required');
|
|
232
|
+
if (!ok) throw new StateError('bad-acceptance', 'acceptance: a non-empty list of {clause, tests: [test ids…]} or {clause, kind: human_check} without tests is required');
|
|
229
233
|
return clauses;
|
|
230
234
|
}
|
|
231
235
|
|
|
@@ -315,6 +319,7 @@ function refuseConfigOwner(owned, kinds, guarded) {
|
|
|
315
319
|
* and the dispatch row carry the floor level, and the row adds `requested_level` and
|
|
316
320
|
* `trigger: 'docs_floor'`. B52: the entry also records `tree_at_open` ({@link treeAtOpen}), the
|
|
317
321
|
* tree's changes at open, which `block close` reads to tell the coder's changes from earlier ones.
|
|
322
|
+
* B134: `base_branch` is the workspace's current branch, or null when detached.
|
|
318
323
|
* B58: with `review.external.required` set, an owned entry that owns the project's config is
|
|
319
324
|
* refused (`config-owned`, {@link refuseConfigOwner}).
|
|
320
325
|
* B59: `decision` (the rules lane of a brief-mode block) is written as a signed `decision` row
|
|
@@ -352,12 +357,13 @@ export async function openBlock(opts) {
|
|
|
352
357
|
if (typeof level !== 'string' || !LEVEL.test(level)) throw new StateError('bad-level', 'level must be L0..L3');
|
|
353
358
|
if (!Number.isInteger(attempt) || attempt < 1) throw new StateError('bad-attempt', 'attempt must be an integer >= 1');
|
|
354
359
|
if (lines !== null && !(Number.isInteger(lines) && lines > 0)) throw new StateError('bad-lines', 'lines forecast must be a positive integer');
|
|
355
|
-
if (opts.kind !== undefined && !BLOCK_KINDS.includes(opts.kind)) throw new StateError('bad-kind', `kind must be one of ${BLOCK_KINDS.join(', ')}`);
|
|
360
|
+
if (opts.kind !== undefined && opts.kind !== 'light' && !BLOCK_KINDS.includes(opts.kind)) throw new StateError('bad-kind', `kind must be one of ${[...BLOCK_KINDS, 'light'].join(', ')}`);
|
|
356
361
|
if (opts.decision && opts.decision.answer !== level) throw new StateError('bad-decision', `the decision answer ${opts.decision.answer} is not the requested level ${level}`);
|
|
357
362
|
const owned = assertOwned(opts.owned);
|
|
358
363
|
const acceptance = assertAcceptance(opts.acceptance);
|
|
364
|
+
const humanChecks = acceptance.filter((c) => c.kind === 'human_check').map((c) => c.clause);
|
|
359
365
|
const tests = opts.tests === undefined ? null : [...assertOwned(opts.tests, { label: 'test files', code: 'bad-tests' })];
|
|
360
|
-
const kind = blockKind({ owned, declared: opts.kind });
|
|
366
|
+
const kind = opts.kind === 'light' ? 'light' : blockKind({ owned, declared: opts.kind });
|
|
361
367
|
const dispatched = dispatchLevel({ lane: level, kind, cfg: opts.cfg });
|
|
362
368
|
const pointer = opts.brief ? briefPointer(opts.brief.path, opts.brief.content) : null;
|
|
363
369
|
|
|
@@ -384,6 +390,7 @@ export async function openBlock(opts) {
|
|
|
384
390
|
try {
|
|
385
391
|
const { block, reused } = await mutateRun(runId, null, writeRow, async (record) => {
|
|
386
392
|
if (record.blocks[id]?.status === 'open') throw new StateError('already-open', `block ${id} is already open`);
|
|
393
|
+
if (record.blocks[id]?.status === 'awaiting_human_check') throw new StateError('already-open', `block ${id} is awaiting a human check`);
|
|
387
394
|
// B57: what each exact owned entry is on disk, under the lock (as `block claim`) — a regular
|
|
388
395
|
// file owns only itself, here and in every later check
|
|
389
396
|
const kinds = ownedKinds(record.workspace, owned);
|
|
@@ -391,6 +398,8 @@ export async function openBlock(opts) {
|
|
|
391
398
|
const prior = previousTree(record);
|
|
392
399
|
const baseSha = await resolveCommit(prior ? prior.base_sha : opts.base ?? 'HEAD', record.workspace);
|
|
393
400
|
if (baseSha !== stampedBase || record.workspace !== workspace) throw new StateError('base-moved', `the base moved while block ${id} was being opened; run block open again`);
|
|
401
|
+
const branch = await git(['git', 'symbolic-ref', '--quiet', '--short', 'HEAD'], record.workspace, [0, 1]);
|
|
402
|
+
const baseBranch = branch.code === 0 ? branch.stdout.trim() : null;
|
|
394
403
|
// B62a: the tree is planned (read-only checks) before the overlap check, which needs its path
|
|
395
404
|
const planned = opts.worktree ? await opts.worktree({ baseSha, previous: prior }) : null;
|
|
396
405
|
const self = { owned_files: owned, owned_kinds: kinds, worktree: planned ? { path: planned.path } : null };
|
|
@@ -402,7 +411,8 @@ export async function openBlock(opts) {
|
|
|
402
411
|
// passed; a tree made here is rolled back below if the rows or the save then fail
|
|
403
412
|
made = planned ? await planned.make() : null;
|
|
404
413
|
const tree = made;
|
|
405
|
-
const entry = { block: id, base_sha: baseSha, owned_files: [...owned], owned_kinds: { ...kinds }, level: dispatched.level, kind, attempt, opened_at: now.toISOString(), acceptance, lines_forecast: lines, status: 'open', tree_at_open: treeOpen, ...(tests ? { test_files: tests } : {}), ...(tree ? { worktree: tree.worktree } : {}) };
|
|
414
|
+
const entry = { block: id, base_sha: baseSha, base_branch: baseBranch, owned_files: [...owned], owned_kinds: { ...kinds }, level: dispatched.level, kind, attempt, opened_at: now.toISOString(), acceptance, lines_forecast: lines, status: 'open', tree_at_open: treeOpen, ...(tests ? { test_files: tests } : {}), ...(tree ? { worktree: tree.worktree } : {}) };
|
|
415
|
+
if (humanChecks.length > 0) Object.assign(entry, { human_checks: humanChecks });
|
|
406
416
|
record.blocks[id] = entry;
|
|
407
417
|
const floor = dispatched.trigger === 'docs_floor' ? { requested_level: level, trigger: 'docs_floor' } : {};
|
|
408
418
|
const created = tree?.created ? [{ event: 'worktree.created', block: id, path: tree.worktree.path, branch: tree.worktree.branch, base: tree.worktree.base_sha }] : [];
|
|
@@ -411,7 +421,7 @@ export async function openBlock(opts) {
|
|
|
411
421
|
rows: [
|
|
412
422
|
...(opts.decision ? [{ ...opts.decision, event: 'decision', decision_id: opts.decisionId ?? randomUUID(), block: id }] : []),
|
|
413
423
|
...created,
|
|
414
|
-
{ event: 'dispatch', block: id, level: dispatched.level, kind, ...floor, attempt, base_sha: baseSha, owned_files: entry.owned_files, lines_forecast: lines, ...(tests ? { test_files: tests } : {}) },
|
|
424
|
+
{ event: 'dispatch', block: id, level: dispatched.level, kind, ...floor, attempt, base_sha: baseSha, base_branch: baseBranch, owned_files: entry.owned_files, lines_forecast: lines, ...(tests ? { test_files: tests } : {}) },
|
|
415
425
|
],
|
|
416
426
|
};
|
|
417
427
|
});
|
|
@@ -444,11 +454,14 @@ export async function attemptBlock({ runId, id, writeRow }) {
|
|
|
444
454
|
* in between touched a file this block owns (that is a conflict ⇒ stop). Rename detection is OFF
|
|
445
455
|
* (`--no-renames`): a rename lists both its old and its new path, so moving an owned file away
|
|
446
456
|
* is a conflict too. Never silent. Owned means `ownsFile` (B57: a directory entry owns the files
|
|
447
|
-
* below it). B62a: refused for
|
|
457
|
+
* below it). B62a: refused for an open block with its own worktree (`worktree-rebase`).
|
|
458
|
+
* B134: a closed worktree block instead rebases its branch onto its recorded base branch,
|
|
459
|
+
* retaining every changed owned file's blob; a conflict or changed blob restores the old head.
|
|
448
460
|
* @param {{runId: string, id: string, head?: string, writeRow: WriteRow}} opts
|
|
449
461
|
* @returns {Promise<{old_base: string, new_base: string}>}
|
|
450
462
|
*/
|
|
451
463
|
export async function rebaseBlock({ runId, id, head = 'HEAD', writeRow }) {
|
|
464
|
+
if (['closed', 'awaiting_human_check'].includes((await readRun(runId)).blocks[id]?.status)) return rebaseClosedBlock({ runId, id, writeRow });
|
|
452
465
|
return mutateRun(runId, id, writeRow, async (record, block) => {
|
|
453
466
|
// B62a: a worktree block's base is the commit its tree started from; moving it would leave the
|
|
454
467
|
// tree on its old base, so the rebase is refused
|
|
@@ -466,6 +479,75 @@ export async function rebaseBlock({ runId, id, head = 'HEAD', writeRow }) {
|
|
|
466
479
|
});
|
|
467
480
|
}
|
|
468
481
|
|
|
482
|
+
/**
|
|
483
|
+
* Hash committed paths, with null for a deletion. NUL-delimited tree entries preserve unusual
|
|
484
|
+
* file names; filter exact names because git's path selection may also return descendants.
|
|
485
|
+
* @param {string} cwd @param {string} ref @param {string[]} files
|
|
486
|
+
* @returns {Promise<Map<string, string | null>>}
|
|
487
|
+
*/
|
|
488
|
+
async function committedBlobs(cwd, ref, files) {
|
|
489
|
+
const hashes = new Map(files.map((file) => [file, /** @type {string | null} */ (null)]));
|
|
490
|
+
if (files.length === 0) return hashes;
|
|
491
|
+
const tree = await git(['git', 'ls-tree', '-r', '-z', '--full-tree', ref, '--', ...files], cwd);
|
|
492
|
+
for (const entry of tree.stdout.split('\0').filter(Boolean)) {
|
|
493
|
+
const tab = entry.indexOf('\t');
|
|
494
|
+
const file = entry.slice(tab + 1);
|
|
495
|
+
if (hashes.has(file)) hashes.set(file, entry.slice(0, tab).split(' ')[2]);
|
|
496
|
+
}
|
|
497
|
+
return hashes;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Closed blocks have a separate mutation path: check their status again under the lock,
|
|
502
|
+
* restore the branch on failure (including a failed signed write), and retain closed/awaiting status.
|
|
503
|
+
* @param {{runId: string, id: string, writeRow: WriteRow}} opts
|
|
504
|
+
* @returns {Promise<{old_base: string, new_base: string}>}
|
|
505
|
+
*/
|
|
506
|
+
async function rebaseClosedBlock({ runId, id, writeRow }) {
|
|
507
|
+
return withRunLock(runId, async () => {
|
|
508
|
+
const record = await readRun(runId);
|
|
509
|
+
if (record.status !== 'active') throw new StateError('run-ended', `run ${runId} has ended`);
|
|
510
|
+
const block = record.blocks[id];
|
|
511
|
+
if (!['closed', 'awaiting_human_check'].includes(block?.status) || !block.worktree) throw new StateError('no-block', `block ${id} is not open in run ${runId}`);
|
|
512
|
+
if (!block.base_branch) throw new StateError('rebase-no-base', `block ${id} has no recorded base branch`);
|
|
513
|
+
const tree = block.worktree.path;
|
|
514
|
+
const branch = await git(['git', 'symbolic-ref', '--quiet', 'HEAD'], tree, [0, 1]);
|
|
515
|
+
if (branch.stdout.trim() !== `refs/heads/${block.worktree.branch}`) throw new StateError('worktree-rebase', `block ${id}: its worktree is not on branch ${block.worktree.branch}`);
|
|
516
|
+
// A reset must never discard edits made after close, or abort an earlier rebase.
|
|
517
|
+
const status = await git(['git', 'status', '--porcelain'], tree);
|
|
518
|
+
if (status.stdout.length > 0) throw new StateError('rebase-conflict', 'rebase-conflict');
|
|
519
|
+
const oldHead = await resolveCommit('HEAD', tree);
|
|
520
|
+
const newBase = await resolveCommit(`refs/heads/${block.base_branch}`, record.workspace);
|
|
521
|
+
const top = await repoTop(record.workspace);
|
|
522
|
+
const prefix = path.relative(top, realpathSync(record.workspace)).split(path.sep).join('/');
|
|
523
|
+
const diff = await git(['git', 'diff', '--no-renames', '--name-only', '-z', block.base_sha, oldHead, '--'], tree);
|
|
524
|
+
const files = diff.stdout.split('\0').filter((file) => file.length > 0 && (
|
|
525
|
+
ownsFile(block.owned_files, file, block.owned_kinds) ||
|
|
526
|
+
(prefix !== '' && file.startsWith(`${prefix}/`) && ownsFile(block.owned_files, file.slice(prefix.length + 1), block.owned_kinds))
|
|
527
|
+
)).sort();
|
|
528
|
+
const before = await committedBlobs(tree, oldHead, files);
|
|
529
|
+
const moved = { old_base: block.base_sha, new_base: newBase };
|
|
530
|
+
try {
|
|
531
|
+
const rebased = await exec(['git', '-C', tree, 'rebase', newBase], { cwd: record.workspace, env: gitEnv(), timeoutMs: 30000 });
|
|
532
|
+
if (rebased.result !== 'ok') throw new StateError('rebase-conflict', 'rebase-conflict');
|
|
533
|
+
const after = await committedBlobs(tree, 'HEAD', files);
|
|
534
|
+
const changed = files.filter((file) => before.get(file) !== after.get(file));
|
|
535
|
+
if (changed.length > 0) throw new StateError('rebase-changed-reviewed', `rebase-changed-reviewed ${changed.join(', ')}`);
|
|
536
|
+
block.base_sha = newBase;
|
|
537
|
+
block.worktree.base_sha = newBase;
|
|
538
|
+
await writeSigned(runId, writeRow, { event: 'block.rebase', block: id, ...moved, closed: true });
|
|
539
|
+
await saveRun(record);
|
|
540
|
+
return moved;
|
|
541
|
+
} catch (err) {
|
|
542
|
+
// --abort restores a conflicted rebase; after a completed rebase there is nothing to
|
|
543
|
+
// abort, and reset restores the old branch head. The tree was clean before either.
|
|
544
|
+
await exec(['git', '-C', tree, 'rebase', '--abort'], { cwd: record.workspace, env: gitEnv(), timeoutMs: 30000 });
|
|
545
|
+
await git(['git', 'reset', '--hard', oldHead], tree);
|
|
546
|
+
throw err;
|
|
547
|
+
}
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
|
|
469
551
|
/**
|
|
470
552
|
* `block claim` — assign one EXACT path (an orphan, or any unowned path) to `id`; refused for a
|
|
471
553
|
* glob, and when another open block owns it — or anything it would own: the claimed path becomes an
|
|
@@ -513,13 +595,35 @@ export async function checkScope({ runId, id, files, writeRow }) {
|
|
|
513
595
|
|
|
514
596
|
/** @param {{runId: string, id: string, reason: string, writeRow: WriteRow, event?: string}} opts */
|
|
515
597
|
export async function stopBlock({ runId, id, reason, writeRow, event = 'block.stop' }) {
|
|
516
|
-
await mutateRun(runId,
|
|
598
|
+
await mutateRun(runId, null, writeRow, (record) => {
|
|
599
|
+
const block = record.blocks[id];
|
|
600
|
+
if (!['open', 'awaiting_human_check'].includes(block?.status)) throw new StateError('no-block', `block ${id} is not open in run ${runId}`);
|
|
517
601
|
block.status = 'stopped';
|
|
518
602
|
block.stop_reason = reason;
|
|
519
603
|
return { result: undefined, rows: [{ event, block: id, reason }] };
|
|
520
604
|
});
|
|
521
605
|
}
|
|
522
606
|
|
|
607
|
+
/**
|
|
608
|
+
* Called only after the CLI's terminal confirmation; recheck under the lock before signing.
|
|
609
|
+
* @param {{runId: string, id: string, note: string, proof?: string | null, writeRow: WriteRow, now?: Date}} opts
|
|
610
|
+
*/
|
|
611
|
+
export async function humanPassBlock({ runId, id, note, proof = null, writeRow, now = new Date() }) {
|
|
612
|
+
return mutateRun(runId, null, writeRow, (record) => {
|
|
613
|
+
const block = record.blocks[id];
|
|
614
|
+
if (block?.status !== 'awaiting_human_check') throw new StateError('not-awaiting', `block ${id} is not awaiting a human check`);
|
|
615
|
+
if (typeof note !== 'string' || note.trim().length === 0) throw new StateError('usage', 'block human-pass needs --note <text>');
|
|
616
|
+
if (proof !== null && (typeof proof !== 'string' || !existsSync(proof))) throw new StateError('usage', '--proof must name an existing directory or file');
|
|
617
|
+
const grant = record.autopilot;
|
|
618
|
+
if (grant && !['stopped', 'expired'].includes(grant.status) && !(now.getTime() >= Date.parse(grant.until))) {
|
|
619
|
+
throw new StateError('autopilot-active', `block human-pass is refused while an autopilot grant is active on run ${runId}`);
|
|
620
|
+
}
|
|
621
|
+
const row = { event: 'block.human_pass', block: id, note, proof, checks: [...block.human_checks], by: 'human' };
|
|
622
|
+
block.status = 'closed';
|
|
623
|
+
return { result: row, rows: [row] };
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
|
|
523
627
|
/**
|
|
524
628
|
* B8's rows of the block gate, in order: worker pin ⇒ `worker.replaced`/`worker.down` (block
|
|
525
629
|
* stopped); the block's signed `dispatch` row must exist and verify, and every other
|
|
@@ -533,16 +637,44 @@ export async function stopBlock({ runId, id, reason, writeRow, event = 'block.st
|
|
|
533
637
|
* livePid?: number, probe?: import('./run.mjs').StartTimeProbe,
|
|
534
638
|
* extraChecks?: Array<() => Promise<{ok: boolean, reason?: string}>>,
|
|
535
639
|
* closeRows?: Record<string, any>[],
|
|
640
|
+
* dryRun?: boolean,
|
|
536
641
|
* }} opts
|
|
537
|
-
* @returns {Promise<{ok: boolean, status: 'closed' | 'stopped' | 'open', reason?: string, event?: string}>}
|
|
642
|
+
* @returns {Promise<{ok: boolean, status: 'closed' | 'awaiting_human_check' | 'stopped' | 'open', reason?: string, event?: string}>}
|
|
538
643
|
*/
|
|
539
|
-
export async function closeBlock({ runId, id, rows, writeRow, livePid, probe, extraChecks = [], closeRows = [] }) {
|
|
644
|
+
export async function closeBlock({ runId, id, rows, writeRow, livePid, probe, extraChecks = [], closeRows = [], dryRun = false }) {
|
|
540
645
|
const record = await readRun(runId);
|
|
541
646
|
if (record.blocks[id]?.status !== 'open') throw new StateError('no-block', `block ${id} is not open in run ${runId}`);
|
|
542
647
|
|
|
543
|
-
|
|
648
|
+
let pin = await checkWorkerPin(record, { livePid, probe });
|
|
649
|
+
const pins = record.workers ?? (record.worker ? [record.worker] : []);
|
|
650
|
+
const counted = rows.filter((row) => row.block === id && (row.run === undefined || row.run === runId) && row.event === 'review.result' && !['stale', 'unavailable', 'answers'].includes(row.status) && !(row.status === 'refused' && row.reason === 'other-run'));
|
|
651
|
+
// Ledgers written before B107 omit worker identity and use the run's pin check above.
|
|
652
|
+
if (counted.some((row) => row.worker !== undefined && !pins.some((p) => p.pid === row.worker?.pid && p.started_at === row.worker?.started_at))) {
|
|
653
|
+
pin = { ok: false, event: 'worker.replaced', reason: 'a counted review result does not name a pinned worker' };
|
|
654
|
+
}
|
|
655
|
+
if (pin.ok) {
|
|
656
|
+
const repoRoot = await repoRootOf(record.workspace);
|
|
657
|
+
let names;
|
|
658
|
+
try { names = readdirSync(reviewQueue.queueDir(repoRoot)); }
|
|
659
|
+
catch (err) { if (err.code !== 'ENOENT') throw err; names = []; }
|
|
660
|
+
for (const name of names.filter((n) => /^[0-9a-f]{24}\.json$/.test(n))) {
|
|
661
|
+
const ticket = name.slice(0, 24);
|
|
662
|
+
const queued = reviewQueue.readTicket(repoRoot, ticket);
|
|
663
|
+
if (queued.run !== runId || queued.block !== id) continue;
|
|
664
|
+
const claimKey = `ticket\0${runId}\0${ticket}`;
|
|
665
|
+
const file = path.join(os.homedir(), '.code-forge', 'locks', `${createHash('sha256').update(claimKey).digest('hex')}.lock`);
|
|
666
|
+
let claim;
|
|
667
|
+
try { claim = JSON.parse(readFileSync(file, 'utf8')); }
|
|
668
|
+
catch (err) { if (err.code === 'ENOENT') continue; throw err; }
|
|
669
|
+
const owner = pins.find((p) => p.pid === claim.pid && p.started_at === claim.start);
|
|
670
|
+
if (owner && !(await checkWorkerPin({ worker: owner }, { probe })).ok) {
|
|
671
|
+
pin = { ok: false, event: 'worker.down', reason: `dead pinned worker pid ${owner.pid} holds ticket ${ticket}` };
|
|
672
|
+
break;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
}
|
|
544
676
|
if (!pin.ok) {
|
|
545
|
-
await stopBlock({ runId, id, reason: pin.reason, writeRow, event: pin.event });
|
|
677
|
+
if (!dryRun) await stopBlock({ runId, id, reason: pin.reason, writeRow, event: pin.event });
|
|
546
678
|
return { ok: false, status: 'stopped', event: pin.event, reason: pin.reason };
|
|
547
679
|
}
|
|
548
680
|
|
|
@@ -555,25 +687,35 @@ export async function closeBlock({ runId, id, rows, writeRow, livePid, probe, ex
|
|
|
555
687
|
const reasons = bad.map((x) => `${x.row.event}: ${x.result.reason}`);
|
|
556
688
|
if (!dispatchOk) reasons.push('no verified dispatch row');
|
|
557
689
|
const reason = reasons.join('; ');
|
|
558
|
-
await stopBlock({ runId, id, reason, writeRow, event: 'ledger.tamper' });
|
|
690
|
+
if (!dryRun) await stopBlock({ runId, id, reason, writeRow, event: 'ledger.tamper' });
|
|
559
691
|
return { ok: false, status: 'stopped', event: 'ledger.tamper', reason };
|
|
560
692
|
}
|
|
561
693
|
|
|
694
|
+
const failures = [];
|
|
562
695
|
for (const check of extraChecks) {
|
|
563
696
|
const result = await check();
|
|
564
|
-
if (!result.ok)
|
|
697
|
+
if (!result.ok) {
|
|
698
|
+
if (!dryRun) return { ok: false, status: 'open', reason: result.reason ?? 'check failed' };
|
|
699
|
+
failures.push(result.reason ?? 'check failed');
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
if (dryRun) {
|
|
703
|
+
if (record.orphans.length > 0) failures.push(`orphans: ${record.orphans.join(', ')}`);
|
|
704
|
+
return { ok: failures.length === 0, status: 'open', ...(failures.length ? { reason: failures.join('; ') } : {}) };
|
|
565
705
|
}
|
|
566
706
|
|
|
567
|
-
/** @typedef {{ok: boolean, status: 'closed' | 'stopped' | 'open', reason?: string, event?: string}} CloseResult */
|
|
707
|
+
/** @typedef {{ok: boolean, status: 'closed' | 'awaiting_human_check' | 'stopped' | 'open', reason?: string, event?: string}} CloseResult */
|
|
568
708
|
return mutateRun(runId, id, writeRow, (current, block) => {
|
|
569
709
|
if (current.orphans.length > 0) {
|
|
570
710
|
/** @type {CloseResult} */
|
|
571
711
|
const refused = { ok: false, status: 'open', reason: `orphans: ${current.orphans.join(', ')}` };
|
|
572
712
|
return { result: refused, rows: [] };
|
|
573
713
|
}
|
|
574
|
-
block.
|
|
714
|
+
const checks = block.human_checks ?? [];
|
|
715
|
+
block.status = checks.length > 0 ? 'awaiting_human_check' : 'closed';
|
|
575
716
|
/** @type {CloseResult} */
|
|
576
|
-
const closed = { ok: true, status:
|
|
577
|
-
return { result: closed, rows: [...closeRows, { event: 'block.close', block: id, status: 'complete' }
|
|
717
|
+
const closed = { ok: true, status: block.status };
|
|
718
|
+
return { result: closed, rows: [...closeRows, { event: 'block.close', block: id, status: 'complete' },
|
|
719
|
+
...(checks.length > 0 ? [{ event: 'block.awaiting_human_check', block: id, checks }] : [])] };
|
|
578
720
|
});
|
|
579
721
|
}
|