pi-workflow-engine 0.12.0 → 0.13.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/.pi/extensions/pi-workflow-engine/src/advisory-challenge.ts +75 -0
- package/.pi/extensions/pi-workflow-engine/src/advisory-evidence.ts +90 -0
- package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +13 -1
- package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +16 -2
- package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +12 -6
- package/.pi/extensions/pi-workflow-engine/src/journal.ts +1 -0
- package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +53 -3
- package/.pi/extensions/pi-workflow-engine/src/review/code-review-orchestration.ts +0 -25
- package/.pi/extensions/pi-workflow-engine/src/review/patch-validation.ts +120 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-fix-workflow.ts +43 -7
- package/.pi/extensions/pi-workflow-engine/src/review/review-format.ts +2 -1
- package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +1 -1
- package/.pi/extensions/pi-workflow-engine/src/types.ts +3 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +5 -2
- package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +131 -178
- package/.pi/extensions/pi-workflow-engine/src/worktree.ts +7 -0
- package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +55 -128
- package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +45 -139
- package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +25 -67
- package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +25 -67
- package/USAGE.md +59 -0
- package/package.json +1 -1
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { Type, type Static } from "typebox";
|
|
2
|
+
import { collectAdvisoryStage, type AdvisoryStageCoverage } from "./advisory-evidence.ts";
|
|
3
|
+
import { DEFAULT_ADVISORY_TOOLS, DEFAULT_ADVISORY_TOOL_HINTS, type AdvisoryVerified } from "./workflow-advisory-utils.ts";
|
|
4
|
+
import type { WorkflowApi } from "./types.ts";
|
|
5
|
+
|
|
6
|
+
const ChallengeSchema = Type.Object({
|
|
7
|
+
outcome: Type.Union([Type.Literal("counterexample"), Type.Literal("alternative-explanation"), Type.Literal("supports-original"), Type.Literal("no-counterexample")]),
|
|
8
|
+
evidence: Type.Array(Type.String()),
|
|
9
|
+
experiment: Type.String({ description: "Smallest distinguishing experiment, with observed result if actually run." }),
|
|
10
|
+
});
|
|
11
|
+
const AdjudicationSchema = Type.Object({
|
|
12
|
+
outcome: Type.Union([Type.Literal("upheld"), Type.Literal("refuted"), Type.Literal("unresolved")]),
|
|
13
|
+
evidence: Type.Array(Type.String()),
|
|
14
|
+
reason: Type.String(),
|
|
15
|
+
});
|
|
16
|
+
export type ChallengeRecord =
|
|
17
|
+
| { status: "complete"; challenge: Static<typeof ChallengeSchema>; adjudication: Static<typeof AdjudicationSchema> }
|
|
18
|
+
| { status: "failed"; challenge?: Static<typeof ChallengeSchema> };
|
|
19
|
+
export interface AdvisoryChallengeOptions {
|
|
20
|
+
maxChallenges: number;
|
|
21
|
+
shouldChallenge?: (finding: AdvisoryVerified) => boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Opt in with --challenge or --challenge=N (hard bound of 10). */
|
|
25
|
+
export function parseChallengeArgs(args: string): { args: string; options: AdvisoryChallengeOptions } {
|
|
26
|
+
let maxChallenges = 0;
|
|
27
|
+
const remaining = args.replace(/(?:^|\s)--challenge(?:=([^\s]*))?(?=\s|$)/g, (_match, limit: string | undefined) => {
|
|
28
|
+
if (limit !== undefined && !/^\d+$/.test(limit)) {
|
|
29
|
+
throw new Error(`Invalid --challenge value "${limit}". Use --challenge or --challenge=N with a non-negative integer; zero disables challenges.`);
|
|
30
|
+
}
|
|
31
|
+
maxChallenges = Math.min(10, limit === undefined ? 3 : Number(limit));
|
|
32
|
+
return " ";
|
|
33
|
+
});
|
|
34
|
+
return { args: remaining.trim(), options: { maxChallenges } };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function needsChallenge(finding: AdvisoryVerified): boolean {
|
|
38
|
+
return finding.verdict === "PLAUSIBLE" || finding.verdict === "NOT_SUBSTANTIATED" || finding.evidence.length === 0 ||
|
|
39
|
+
/\b(security|concurren\w*|race|persist\w*|cancel\w*|retr(?:y|ies)|data loss|corrupt\w*|high impact)\b/i.test(`${finding.category} ${finding.summary} ${finding.impact}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A bounded recipe using existing agent/parallel calls, not a new runtime primitive. */
|
|
43
|
+
export async function challengeFindings<T extends AdvisoryVerified>(
|
|
44
|
+
api: Pick<WorkflowApi, "agent" | "parallel">,
|
|
45
|
+
findings: T[],
|
|
46
|
+
context: string,
|
|
47
|
+
options: AdvisoryChallengeOptions,
|
|
48
|
+
coverage: AdvisoryStageCoverage[],
|
|
49
|
+
): Promise<T[]> {
|
|
50
|
+
const limit = Number.isFinite(options.maxChallenges) ? Math.max(0, Math.min(10, Math.trunc(options.maxChallenges))) : 0;
|
|
51
|
+
if (limit === 0) return findings;
|
|
52
|
+
const selected = findings.filter((finding) => finding.verdict !== "REFUTED" && (options.shouldChallenge ?? needsChallenge)(finding)).slice(0, limit);
|
|
53
|
+
const replacements = new Map<string, T>();
|
|
54
|
+
await collectAdvisoryStage(api, "Challenge", selected.map((finding) => ({ id: finding.candidateId, run: async () => {
|
|
55
|
+
// Preserve the candidate as unresolved if either independent stage fails.
|
|
56
|
+
replacements.set(finding.candidateId, { ...finding, verdict: "NOT_SUBSTANTIATED", challenge: { status: "failed" } });
|
|
57
|
+
const challenge = await api.agent(
|
|
58
|
+
`Assume this finding is a false positive. Try to DISPROVE it. Find the strongest concrete counterexample or alternative root cause. Inspect callers, invariants, tests and control flow. For a repair, seek an input, race or error path that still fails. State the smallest experiment distinguishing explanations. Do not edit files or claim tests you did not run. No counterexample found is not proof.\n\nExact review context:\n${context}\n\nCandidate and verifier evidence:\n${JSON.stringify(finding)}`,
|
|
59
|
+
{ label: `challenge:${finding.candidateId}`, phase: "Challenge", profile: "medium", tools: DEFAULT_ADVISORY_TOOLS, toolHints: DEFAULT_ADVISORY_TOOL_HINTS, schema: ChallengeSchema },
|
|
60
|
+
);
|
|
61
|
+
replacements.set(finding.candidateId, { ...finding, verdict: "NOT_SUBSTANTIATED", challenge: { status: "failed", challenge } });
|
|
62
|
+
const adjudication = await api.agent(
|
|
63
|
+
`Adjudicate the original finding, independent verifier evidence and falsification attempt below. Preserve unresolved conflict; do not force consensus. A missing counterexample alone cannot upgrade a plausible claim. Cite concrete evidence and observed test results; never invent experiments.\nContext:\n${context}\nOriginal and verifier:\n${JSON.stringify(finding)}\nChallenger:\n${JSON.stringify(challenge)}`,
|
|
64
|
+
{ label: `adjudicate:${finding.candidateId}`, phase: "Challenge", profile: "medium", tools: [], schema: AdjudicationSchema },
|
|
65
|
+
);
|
|
66
|
+
replacements.set(finding.candidateId, {
|
|
67
|
+
...finding,
|
|
68
|
+
verdict: adjudication.outcome === "refuted" ? "REFUTED" : adjudication.outcome === "unresolved" ? "NOT_SUBSTANTIATED" : finding.verdict,
|
|
69
|
+
evidence: [...finding.evidence, ...challenge.evidence, ...(challenge.experiment ? [challenge.experiment] : []), ...adjudication.evidence, adjudication.reason],
|
|
70
|
+
challenge: { status: "complete", challenge, adjudication },
|
|
71
|
+
});
|
|
72
|
+
return finding.candidateId;
|
|
73
|
+
} })), coverage);
|
|
74
|
+
return findings.map((finding) => replacements.get(finding.candidateId) ?? finding);
|
|
75
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { Type, type Static } from "typebox";
|
|
3
|
+
import { AdvisorySeveritySchema, type IdentifiedAdvisoryCandidate, type AdvisoryCandidate, type AdvisoryReport } from "./advisory-schema.ts";
|
|
4
|
+
import type { WorkflowApi } from "./types.ts";
|
|
5
|
+
|
|
6
|
+
export interface AdvisoryStageCoverage {
|
|
7
|
+
stage: string;
|
|
8
|
+
expected: number;
|
|
9
|
+
completed: number;
|
|
10
|
+
failed: number;
|
|
11
|
+
failures: { branch: string; reason: string; candidate?: AdvisoryCandidate }[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Settled results are required here: a missing branch is not a negative finding. */
|
|
15
|
+
export async function collectAdvisoryStage<T>(
|
|
16
|
+
api: Pick<WorkflowApi, "parallel">,
|
|
17
|
+
stage: string,
|
|
18
|
+
branches: { id: string; candidate?: AdvisoryCandidate; run(): Promise<T> }[],
|
|
19
|
+
coverage: AdvisoryStageCoverage[],
|
|
20
|
+
): Promise<T[]> {
|
|
21
|
+
const results = await api.parallel(branches.map((branch) => branch.run), { settled: true });
|
|
22
|
+
const failures = results.flatMap((result, index) => result.ok ? [] : [{ branch: branches[index]!.id, reason: result.error.message, ...(branches[index]!.candidate ? { candidate: branches[index]!.candidate } : {}) }]);
|
|
23
|
+
coverage.push({ stage, expected: branches.length, completed: results.length - failures.length, failed: failures.length, failures });
|
|
24
|
+
return results.flatMap((result) => result.ok ? [result.value] : []);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function identifyCandidates(candidates: readonly AdvisoryCandidate[], lens: string): IdentifiedAdvisoryCandidate[] {
|
|
28
|
+
return candidates.map((candidate, index) => {
|
|
29
|
+
const candidateId = createHash("sha256").update(JSON.stringify([lens, index, candidate])).digest("hex").slice(0, 20);
|
|
30
|
+
// Identities are assigned by the workflow, never accepted from a finder.
|
|
31
|
+
return { ...candidate, candidateId, sourceCandidateIds: [candidateId] };
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function normalizedSummary(summary: string): string {
|
|
36
|
+
return summary.toLowerCase().replace(/\s+/g, " ").trim().replace(/[.!?]+$/, "");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function candidateDedupKey(candidate: AdvisoryCandidate): string {
|
|
40
|
+
const location = candidate.locations[0];
|
|
41
|
+
return JSON.stringify([candidate.category, location?.file.replace(/^\.\//, "").replace(/^[ab]\//, ""),
|
|
42
|
+
location?.symbol ?? location?.line ?? null, normalizedSummary(candidate.summary)]);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Collapse only equivalent claims at the same anchor; keep every discovery ID and location. */
|
|
46
|
+
export function dedupeCandidates<T extends IdentifiedAdvisoryCandidate>(candidates: readonly T[]): T[] {
|
|
47
|
+
const seen = new Map<string, T>();
|
|
48
|
+
for (const candidate of candidates) {
|
|
49
|
+
const key = candidateDedupKey(candidate);
|
|
50
|
+
const previous = seen.get(key);
|
|
51
|
+
if (!previous) seen.set(key, { ...candidate });
|
|
52
|
+
else {
|
|
53
|
+
previous.sourceCandidateIds = [...new Set([...previous.sourceCandidateIds, ...candidate.sourceCandidateIds])];
|
|
54
|
+
previous.locations = uniqueLocations([...previous.locations, ...candidate.locations]);
|
|
55
|
+
previous.impact = [...new Set([previous.impact, candidate.impact])].join("\n");
|
|
56
|
+
previous.discoveryEvidence = [...new Set([...(previous.discoveryEvidence ?? []), ...(candidate.discoveryEvidence ?? [])])];
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return [...seen.values()];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function uniqueLocations(locations: AdvisoryCandidate["locations"]): AdvisoryCandidate["locations"] {
|
|
63
|
+
return [...new Map(locations.map((location) => [JSON.stringify(location), location])).values()];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Synthesis selects evidence records. It has no fields with which to replace their evidence.
|
|
67
|
+
export const AdvisorySynthesisSchema = Type.Object({
|
|
68
|
+
summary: Type.String(),
|
|
69
|
+
findings: Type.Array(Type.Object({
|
|
70
|
+
sourceCandidateIds: Type.Array(Type.String(), { minItems: 1 }),
|
|
71
|
+
severity: AdvisorySeveritySchema,
|
|
72
|
+
recommendation: Type.String(),
|
|
73
|
+
})),
|
|
74
|
+
nextSteps: Type.Array(Type.String()),
|
|
75
|
+
});
|
|
76
|
+
export type AdvisorySynthesis = Static<typeof AdvisorySynthesisSchema>;
|
|
77
|
+
export const SYNTHESIS_ID_INSTRUCTIONS = "Select or merge findings only by sourceCandidateIds shown below. Return severity and an advisory recommendation per selection. Never select an ID absent from these verified records. ";
|
|
78
|
+
|
|
79
|
+
export function withAdvisoryCoverage<T extends AdvisoryReport>(report: T, coverage: AdvisoryStageCoverage[]) {
|
|
80
|
+
const incomplete = coverage.some((stage) => stage.failed > 0);
|
|
81
|
+
const gaps = coverage.flatMap((stage) => stage.failures.map((failure) => `${stage.stage}/${failure.branch}: ${failure.reason}`));
|
|
82
|
+
return {
|
|
83
|
+
...report,
|
|
84
|
+
status: incomplete ? "incomplete" as const : "complete" as const,
|
|
85
|
+
summary: incomplete ? `Incomplete review: ${gaps.length} branch(es) failed. ${report.findings.length} finding(s) available; no clean conclusion is possible.` : report.summary,
|
|
86
|
+
nextSteps: incomplete ? ["Inspect failed branches and rerun missing work.", ...(report.findings.length > 0 ? report.nextSteps : [])] : report.nextSteps,
|
|
87
|
+
coverage,
|
|
88
|
+
gaps,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
@@ -9,7 +9,7 @@ export const AdvisoryConfidenceSchema = Type.Union([Type.Literal("low"), Type.Li
|
|
|
9
9
|
description: "Confidence that the finding is actionable.",
|
|
10
10
|
});
|
|
11
11
|
|
|
12
|
-
export const AdvisoryVerifierVerdictSchema = Type.Union([Type.Literal("CONFIRMED"), Type.Literal("PLAUSIBLE"), Type.Literal("REFUTED")], {
|
|
12
|
+
export const AdvisoryVerifierVerdictSchema = Type.Union([Type.Literal("CONFIRMED"), Type.Literal("PLAUSIBLE"), Type.Literal("REFUTED"), Type.Literal("NOT_SUBSTANTIATED")], {
|
|
13
13
|
description: "Verifier judgment for a candidate finding.",
|
|
14
14
|
});
|
|
15
15
|
|
|
@@ -20,6 +20,8 @@ export const AdvisoryLocationSchema = Type.Object({
|
|
|
20
20
|
});
|
|
21
21
|
|
|
22
22
|
export const AdvisoryCandidateSchema = Type.Object({
|
|
23
|
+
discoveryEvidence: Type.Optional(Type.Array(Type.String())),
|
|
24
|
+
reviewAnchor: Type.Optional(AdvisoryLocationSchema),
|
|
23
25
|
summary: Type.String({ description: "One-line candidate finding or hypothesis." }),
|
|
24
26
|
category: Type.String({ description: "Workflow-specific category such as bug, duplication, root-cause, or io." }),
|
|
25
27
|
locations: Type.Array(AdvisoryLocationSchema, { description: "Relevant code or configuration locations." }),
|
|
@@ -38,6 +40,9 @@ export const AdvisoryVerdictSchema = Type.Object({
|
|
|
38
40
|
});
|
|
39
41
|
|
|
40
42
|
export const AdvisoryFindingSchema = Type.Object({
|
|
43
|
+
verdict: Type.Optional(AdvisoryVerifierVerdictSchema),
|
|
44
|
+
sourceCandidateIds: Type.Optional(Type.Array(Type.String())),
|
|
45
|
+
reviewAnchor: Type.Optional(AdvisoryLocationSchema),
|
|
41
46
|
summary: Type.String({ description: "One-line final finding." }),
|
|
42
47
|
category: Type.String({ description: "Workflow-specific category for grouping and display." }),
|
|
43
48
|
severity: AdvisorySeveritySchema,
|
|
@@ -49,6 +54,12 @@ export const AdvisoryFindingSchema = Type.Object({
|
|
|
49
54
|
});
|
|
50
55
|
|
|
51
56
|
export const AdvisoryReportSchema = Type.Object({
|
|
57
|
+
status: Type.Optional(Type.Union([Type.Literal("complete"), Type.Literal("incomplete")])),
|
|
58
|
+
gaps: Type.Optional(Type.Array(Type.String())),
|
|
59
|
+
coverage: Type.Optional(Type.Array(Type.Object({
|
|
60
|
+
stage: Type.String(), expected: Type.Number(), completed: Type.Number(), failed: Type.Number(),
|
|
61
|
+
failures: Type.Array(Type.Object({ branch: Type.String(), reason: Type.String(), candidate: Type.Optional(AdvisoryCandidateSchema) })),
|
|
62
|
+
}))),
|
|
52
63
|
summary: Type.String({ description: "Short overall advisory summary." }),
|
|
53
64
|
findings: Type.Array(AdvisoryFindingSchema, { description: "Verified and ranked advisory findings." }),
|
|
54
65
|
nextSteps: Type.Array(Type.String({ description: "Concrete follow-up commands, inspections, or decisions." })),
|
|
@@ -61,6 +72,7 @@ export const AdvisoryReportWithStatsSchema = Type.Object({
|
|
|
61
72
|
|
|
62
73
|
export type AdvisoryLocation = Static<typeof AdvisoryLocationSchema>;
|
|
63
74
|
export type AdvisoryCandidate = Static<typeof AdvisoryCandidateSchema>;
|
|
75
|
+
export type IdentifiedAdvisoryCandidate = AdvisoryCandidate & { candidateId: string; sourceCandidateIds: string[] };
|
|
64
76
|
export type AdvisoryVerdict = Static<typeof AdvisoryVerdictSchema>;
|
|
65
77
|
export type AdvisoryFinding = Static<typeof AdvisoryFindingSchema>;
|
|
66
78
|
export type AdvisoryReport = Static<typeof AdvisoryReportSchema>;
|
|
@@ -21,7 +21,7 @@ export type AgentWorkspace = SharedAgentWorkspace | IsolatedAgentWorkspace;
|
|
|
21
21
|
|
|
22
22
|
export interface AgentWorkspaceContext {
|
|
23
23
|
readonly cwd: string;
|
|
24
|
-
readonly worktrees: Pick<WorktreeRegistry, "probe" | "add" | "capturePatch" | "remove">;
|
|
24
|
+
readonly worktrees: Pick<WorktreeRegistry, "probe" | "add" | "capturePatch" | "applyPatch" | "remove">;
|
|
25
25
|
readonly signal: AbortSignal | undefined;
|
|
26
26
|
readonly progress: Pick<AgentProgress, "log">;
|
|
27
27
|
}
|
|
@@ -31,6 +31,7 @@ export async function createAgentWorkspace(
|
|
|
31
31
|
opts: AgentExecutionOptions,
|
|
32
32
|
label: string,
|
|
33
33
|
): Promise<AgentWorkspace> {
|
|
34
|
+
if (opts.candidatePatch && opts.isolation !== "worktree") throw new Error("Candidate evaluation requires worktree isolation");
|
|
34
35
|
if (opts.isolation !== "worktree") {
|
|
35
36
|
return {
|
|
36
37
|
kind: "shared",
|
|
@@ -53,6 +54,19 @@ export async function createAgentWorkspace(
|
|
|
53
54
|
const added = await rc.worktrees.add(rc.signal, opts.worktreeBaseline);
|
|
54
55
|
if ("error" in added) throw new Error(`Failed to create isolated worktree: ${added.error}`);
|
|
55
56
|
const worktreePath = added.path;
|
|
57
|
+
if (opts.candidatePatch) {
|
|
58
|
+
try {
|
|
59
|
+
if (opts.candidatePatch.baselineOid !== added.baselineOid) throw new Error("Candidate baseline differs from evaluator baseline");
|
|
60
|
+
if (opts.candidatePatch.patch.trim()) {
|
|
61
|
+
const applied = await rc.worktrees.applyPatch(worktreePath, opts.candidatePatch.patch, rc.signal);
|
|
62
|
+
if (!applied.ok) throw new Error(`Candidate patch could not be applied: ${applied.error ?? applied.stderr}`);
|
|
63
|
+
}
|
|
64
|
+
} catch (error) {
|
|
65
|
+
const removed = await rc.worktrees.remove(worktreePath);
|
|
66
|
+
if (!removed.ok) rc.progress.log(`${label}: evaluator setup cleanup failed: ${removed.error ?? removed.stderr}`);
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
56
70
|
rc.progress.log(`${label}: using isolated worktree ${worktreePath}`);
|
|
57
71
|
|
|
58
72
|
return {
|
|
@@ -62,7 +76,7 @@ export async function createAgentWorkspace(
|
|
|
62
76
|
async wrapResult(result) {
|
|
63
77
|
const patch = await rc.worktrees.capturePatch(worktreePath, added.baselineOid, rc.signal);
|
|
64
78
|
if ("error" in patch) throw new Error(`Failed to capture isolated worktree patch: ${patch.error}`);
|
|
65
|
-
return { result, patch: patch.patch, changed: patch.changed };
|
|
79
|
+
return { result, patch: patch.patch, changed: patch.changed, baselineOid: added.baselineOid };
|
|
66
80
|
},
|
|
67
81
|
async dispose() {
|
|
68
82
|
const removed = await rc.worktrees.remove(worktreePath);
|
|
@@ -14,22 +14,28 @@ export class Semaphore {
|
|
|
14
14
|
private active = 0;
|
|
15
15
|
private readonly waiters: Array<() => void> = [];
|
|
16
16
|
|
|
17
|
-
constructor(private readonly max: number) {
|
|
17
|
+
constructor(private readonly max: number) {
|
|
18
|
+
if (!Number.isInteger(max) || max < 1) throw new RangeError("Semaphore capacity must be a positive integer");
|
|
19
|
+
}
|
|
18
20
|
|
|
19
21
|
async run<T>(fn: () => Promise<T>, options: { onQueueWaitMs?: (durationMs: number) => void; signal?: AbortSignal } = {}): Promise<T> {
|
|
20
22
|
throwIfAborted(options.signal);
|
|
21
23
|
const queuedAt = performance.now();
|
|
22
24
|
if (this.active >= this.max) {
|
|
23
25
|
await this.waitForSlot(options.signal);
|
|
26
|
+
} else {
|
|
27
|
+
this.active++;
|
|
24
28
|
}
|
|
25
|
-
options.onQueueWaitMs?.(performance.now() - queuedAt);
|
|
26
|
-
throwIfAborted(options.signal);
|
|
27
|
-
this.active++;
|
|
28
29
|
try {
|
|
30
|
+
options.onQueueWaitMs?.(performance.now() - queuedAt);
|
|
31
|
+
throwIfAborted(options.signal);
|
|
29
32
|
return await fn();
|
|
30
33
|
} finally {
|
|
31
|
-
this
|
|
32
|
-
|
|
34
|
+
// Transfer ownership directly. A selected waiter owns this reservation
|
|
35
|
+
// even before its continuation runs, including if it is then cancelled.
|
|
36
|
+
const next = this.waiters.shift();
|
|
37
|
+
if (next) next();
|
|
38
|
+
else this.active--;
|
|
33
39
|
}
|
|
34
40
|
}
|
|
35
41
|
|
|
@@ -115,6 +115,7 @@ function captureAgentCallHash(
|
|
|
115
115
|
skills: opts.skills,
|
|
116
116
|
schema: opts.schema,
|
|
117
117
|
isolation: opts.isolation,
|
|
118
|
+
candidatePatch: opts.candidatePatch ? { baselineOid: opts.candidatePatch.baselineOid, hash: createHash("sha256").update(opts.candidatePatch.patch).digest("hex") } : undefined,
|
|
118
119
|
worktreeBaseline: worktreeBaseline
|
|
119
120
|
? {
|
|
120
121
|
ref: worktreeBaseline.ref,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { Stats } from "node:fs";
|
|
2
|
+
import { lstat, stat } from "node:fs/promises";
|
|
2
3
|
import { dirname, join, resolve } from "node:path";
|
|
3
4
|
import { throwIfAborted } from "./cancellation.ts";
|
|
4
5
|
import { isMissingPathError } from "./filesystem-error.ts";
|
|
@@ -575,6 +576,54 @@ async function runGit(cwd: string, args: readonly string[], signal: AbortSignal
|
|
|
575
576
|
});
|
|
576
577
|
}
|
|
577
578
|
|
|
579
|
+
/**
|
|
580
|
+
* Decide whether a `.git` entry found during the ancestor walk marks a repository
|
|
581
|
+
* that git may have failed to open, or a stray entry that git's own discovery
|
|
582
|
+
* also skips.
|
|
583
|
+
*
|
|
584
|
+
* The rule mirrors git's discovery loop so that "absent" means git itself would
|
|
585
|
+
* have walked past every `.git` entry and its "not a repository" answer is
|
|
586
|
+
* authoritative:
|
|
587
|
+
*
|
|
588
|
+
* - A regular file is a gitfile. Git halts on any regular `.git` file, even when
|
|
589
|
+
* its content is malformed or its `gitdir:` target is missing, so the entry is
|
|
590
|
+
* a marker regardless of content.
|
|
591
|
+
* - A directory is a repository only when `HEAD`, `objects/`, and `refs/` all
|
|
592
|
+
* exist. Git skips a directory that lacks any of them (an empty `.git` is the
|
|
593
|
+
* common case), so such a directory is not a marker.
|
|
594
|
+
* - A symbolic link is resolved first, as git uses `stat`. A dangling link is
|
|
595
|
+
* skipped by git and is therefore not a marker.
|
|
596
|
+
* - Any other entry type (socket, FIFO, device) is skipped by git.
|
|
597
|
+
*
|
|
598
|
+
* A missing path (`ENOENT`) is a negative answer, not a failure. Any other
|
|
599
|
+
* filesystem error propagates so the caller reports the probe as "unknown".
|
|
600
|
+
*/
|
|
601
|
+
async function isRepositoryMarker(entryPath: string, entry: Stats): Promise<boolean> {
|
|
602
|
+
let target = entry;
|
|
603
|
+
if (entry.isSymbolicLink()) {
|
|
604
|
+
const resolved = await statIfPresent(entryPath);
|
|
605
|
+
if (!resolved) return false;
|
|
606
|
+
target = resolved;
|
|
607
|
+
}
|
|
608
|
+
if (target.isFile()) return true;
|
|
609
|
+
if (!target.isDirectory()) return false;
|
|
610
|
+
for (const name of GIT_DIRECTORY_REQUIRED_ENTRIES) {
|
|
611
|
+
if (!(await statIfPresent(join(entryPath, name)))) return false;
|
|
612
|
+
}
|
|
613
|
+
return true;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
const GIT_DIRECTORY_REQUIRED_ENTRIES = ["HEAD", "objects", "refs"] as const;
|
|
617
|
+
|
|
618
|
+
async function statIfPresent(path: string): Promise<Stats | undefined> {
|
|
619
|
+
try {
|
|
620
|
+
return await stat(path);
|
|
621
|
+
} catch (error) {
|
|
622
|
+
if (isMissingPathError(error)) return undefined;
|
|
623
|
+
throw error;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
578
627
|
type GitControlPathResult =
|
|
579
628
|
| { readonly kind: "present" | "absent" }
|
|
580
629
|
| { readonly kind: "unknown"; readonly reason: string };
|
|
@@ -583,9 +632,10 @@ async function findGitControlPath(cwd: string, signal: AbortSignal | undefined):
|
|
|
583
632
|
let current = resolve(cwd);
|
|
584
633
|
while (true) {
|
|
585
634
|
throwIfAborted(signal);
|
|
635
|
+
const entryPath = join(current, ".git");
|
|
586
636
|
try {
|
|
587
|
-
await lstat(
|
|
588
|
-
return { kind: "present" };
|
|
637
|
+
const entry = await lstat(entryPath);
|
|
638
|
+
if (await isRepositoryMarker(entryPath, entry)) return { kind: "present" };
|
|
589
639
|
} catch (error) {
|
|
590
640
|
if (!isMissingPathError(error)) {
|
|
591
641
|
return { kind: "unknown", reason: `git control-path probe failed: ${unknownErrorMessage(error)}` };
|
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
import type { AdvisoryCandidate } from "../advisory-schema.ts";
|
|
2
|
-
import { normalizePath, primaryLocation } from "../workflow-advisory-utils.ts";
|
|
3
|
-
|
|
4
1
|
const DIFF_EMBED_CAP = 60_000;
|
|
5
2
|
|
|
6
3
|
export function buildCodeReviewScopeBlock(input: {
|
|
@@ -25,25 +22,3 @@ export function buildCodeReviewScopeBlock(input: {
|
|
|
25
22
|
(input.target ? `\n## User instructions (verbatim)\n${input.target}\n` : "")
|
|
26
23
|
);
|
|
27
24
|
}
|
|
28
|
-
|
|
29
|
-
export function dedupeCodeReviewCandidates<Context>(
|
|
30
|
-
groups: readonly { readonly angle: Context; readonly candidates: readonly AdvisoryCandidate[] }[],
|
|
31
|
-
): Array<{ readonly angle: Context; readonly candidate: AdvisoryCandidate }> {
|
|
32
|
-
const seen = new Set<string>();
|
|
33
|
-
return groups.flatMap(({ angle, candidates }) =>
|
|
34
|
-
candidates
|
|
35
|
-
.filter((candidate) => {
|
|
36
|
-
const key = codeReviewDedupKey(candidate);
|
|
37
|
-
if (seen.has(key)) return false;
|
|
38
|
-
seen.add(key);
|
|
39
|
-
return true;
|
|
40
|
-
})
|
|
41
|
-
.map((candidate) => ({ angle, candidate })),
|
|
42
|
-
);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function codeReviewDedupKey(candidate: AdvisoryCandidate): string {
|
|
46
|
-
const location = primaryLocation(candidate);
|
|
47
|
-
const lineKey = location.line != null ? Math.round(location.line / 5) * 5 : candidate.summary.slice(0, 40).toLowerCase();
|
|
48
|
-
return `${normalizePath(location.file)}:${lineKey}`;
|
|
49
|
-
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { Type, type Static } from "typebox";
|
|
3
|
+
import { throwIfAborted } from "../cancellation.ts";
|
|
4
|
+
import { runBoundedProcess, type BoundedProcessResult } from "../process-runner.ts";
|
|
5
|
+
import { WorktreeRegistry, type WorktreeBaseline } from "../worktree.ts";
|
|
6
|
+
import { unknownErrorMessage } from "../unknown-error.ts";
|
|
7
|
+
import { fingerprintReviewWorktreeBaseline } from "./review-snapshot.ts";
|
|
8
|
+
|
|
9
|
+
export const PatchEvaluationSchema = Type.Object({
|
|
10
|
+
outcome: Type.Union([Type.Literal("accepted"), Type.Literal("rejected"), Type.Literal("blocked")]),
|
|
11
|
+
reason: Type.String(),
|
|
12
|
+
checks: Type.Array(Type.Object({
|
|
13
|
+
file: Type.String({ description: "Executable for a focused validation command, not a shell command string." }),
|
|
14
|
+
args: Type.Array(Type.String()),
|
|
15
|
+
required: Type.Boolean(),
|
|
16
|
+
regression: Type.Optional(Type.Object({
|
|
17
|
+
baselinePatch: Type.String({ description: "Test-only patch to apply to the original baseline to reproduce the bug; exclude the repair." }),
|
|
18
|
+
expectedFailure: Type.String({ description: "Specific failure text proving the intended defect, not a missing dependency or setup error." }),
|
|
19
|
+
})),
|
|
20
|
+
}), { maxItems: 6 }),
|
|
21
|
+
});
|
|
22
|
+
export type PatchEvaluation = Static<typeof PatchEvaluationSchema>;
|
|
23
|
+
export type PatchCandidateStatus = "verified" | "rejected" | "blocked" | "no-patch";
|
|
24
|
+
export interface PatchValidation {
|
|
25
|
+
status: PatchCandidateStatus;
|
|
26
|
+
baselineFingerprint: string;
|
|
27
|
+
baselineOid?: string;
|
|
28
|
+
patchHash: string;
|
|
29
|
+
checks: { file: string; args: string[]; required: boolean; stage: "candidate" | "baseline"; result: BoundedProcessResult }[];
|
|
30
|
+
evaluation?: PatchEvaluation;
|
|
31
|
+
reason: string;
|
|
32
|
+
cleanupError?: string;
|
|
33
|
+
}
|
|
34
|
+
export function initialPatchValidation(baseline: WorktreeBaseline, baselineOid: string | undefined, patch: string): PatchValidation {
|
|
35
|
+
return { status: patch.trim() ? "blocked" : "no-patch", baselineFingerprint: fingerprintReviewWorktreeBaseline(baseline), ...(baselineOid ? { baselineOid } : {}),
|
|
36
|
+
patchHash: createHash("sha256").update(patch).digest("hex"), checks: [], reason: patch.trim() ? "Independent evaluation has not completed." : "The implementer produced no patch; this does not establish that no change is needed." };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface PatchValidationOptions {
|
|
40
|
+
cwd: string; baseline: WorktreeBaseline; expectedFingerprint: string; baselineOid: string; patch: string;
|
|
41
|
+
evaluation: PatchEvaluation; signal?: AbortSignal;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Reconstruct the exact reviewed baseline and retain evidence even if cleanup fails. */
|
|
45
|
+
export async function validateCandidatePatch(options: PatchValidationOptions): Promise<PatchValidation> {
|
|
46
|
+
const validation = initialPatchValidation(options.baseline, options.baselineOid, options.patch);
|
|
47
|
+
validation.evaluation = options.evaluation;
|
|
48
|
+
if (validation.baselineFingerprint !== options.expectedFingerprint) return { ...validation, status: "rejected", reason: "Stale reviewed baseline identity." };
|
|
49
|
+
if (!options.patch.trim()) return validation;
|
|
50
|
+
const worktrees = new WorktreeRegistry(options.cwd);
|
|
51
|
+
let result = validation;
|
|
52
|
+
try {
|
|
53
|
+
result = await evaluateCandidateInWorktrees(worktrees, options, validation);
|
|
54
|
+
} catch (error) {
|
|
55
|
+
throwIfAborted(options.signal);
|
|
56
|
+
result = { ...validation, status: "blocked", reason: unknownErrorMessage(error) };
|
|
57
|
+
} finally {
|
|
58
|
+
try {
|
|
59
|
+
await worktrees.removeAll();
|
|
60
|
+
} catch (error) {
|
|
61
|
+
const cleanupError = unknownErrorMessage(error);
|
|
62
|
+
result = {
|
|
63
|
+
...result,
|
|
64
|
+
status: result.status === "verified" ? "blocked" : result.status,
|
|
65
|
+
cleanupError,
|
|
66
|
+
reason: `${result.reason}\nCleanup failed: ${cleanupError}`,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
throwIfAborted(options.signal);
|
|
70
|
+
}
|
|
71
|
+
return result;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function evaluateCandidateInWorktrees(
|
|
75
|
+
worktrees: WorktreeRegistry, options: PatchValidationOptions, validation: PatchValidation,
|
|
76
|
+
): Promise<PatchValidation> {
|
|
77
|
+
const candidate = await worktrees.add(options.signal, options.baseline);
|
|
78
|
+
if ("error" in candidate) return { ...validation, reason: candidate.error };
|
|
79
|
+
if (candidate.baselineOid !== options.baselineOid) return { ...validation, status: "rejected", reason: "Candidate was produced from a different baseline." };
|
|
80
|
+
const applied = await worktrees.applyPatch(candidate.path, options.patch, options.signal);
|
|
81
|
+
if (!applied.ok) return { ...validation, status: "rejected", reason: applied.error ?? applied.stderr };
|
|
82
|
+
const rejected = options.evaluation.outcome === "rejected" ? [options.evaluation.reason] : [];
|
|
83
|
+
const blocked = options.evaluation.outcome === "blocked" ? [options.evaluation.reason] : [];
|
|
84
|
+
if (!options.evaluation.checks.some((check) => check.required)) blocked.push("No required behavior check was selected.");
|
|
85
|
+
for (const check of options.evaluation.checks) {
|
|
86
|
+
const result = await runCheck(check, candidate.path, options.signal);
|
|
87
|
+
validation.checks.push({ ...check, stage: "candidate", result });
|
|
88
|
+
throwIfAborted(options.signal);
|
|
89
|
+
if (!result.ok) {
|
|
90
|
+
if (result.failure.kind === "exit") rejected.push(`${check.file}: ${result.failure.message}`);
|
|
91
|
+
else if (check.required) blocked.push(`${check.file}: ${result.failure.message}`);
|
|
92
|
+
}
|
|
93
|
+
if (check.regression) {
|
|
94
|
+
const original = await worktrees.add(options.signal, options.baseline);
|
|
95
|
+
if ("error" in original) { blocked.push(`Baseline worktree setup failed: ${original.error}`); continue; }
|
|
96
|
+
const testApplied = await worktrees.applyPatch(original.path, check.regression.baselinePatch, options.signal);
|
|
97
|
+
if (!testApplied.ok) { blocked.push(`Baseline regression setup failed: ${testApplied.error ?? testApplied.stderr}`); continue; }
|
|
98
|
+
const baselineResult = await runCheck(check, original.path, options.signal);
|
|
99
|
+
validation.checks.push({ ...check, stage: "baseline", result: baselineResult });
|
|
100
|
+
throwIfAborted(options.signal);
|
|
101
|
+
if (baselineResult.ok || baselineResult.failure.kind !== "exit" || !check.regression.expectedFailure.trim() ||
|
|
102
|
+
!(baselineResult.stdout + baselineResult.stderr).includes(check.regression.expectedFailure)) {
|
|
103
|
+
blocked.push(`${check.file}: baseline did not reproduce the expected failure: ${check.regression.expectedFailure}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// Tests must not silently replace the candidate under evaluation.
|
|
108
|
+
const after = await worktrees.capturePatch(candidate.path, candidate.baselineOid, options.signal);
|
|
109
|
+
if ("error" in after) blocked.push(`Candidate capture failed: ${after.error}`);
|
|
110
|
+
else if (after.patch !== options.patch) blocked.push("Validation checks changed the candidate patch.");
|
|
111
|
+
if (rejected.length > 0) return { ...validation, status: "rejected", reason: [...rejected, ...blocked].join("\n") };
|
|
112
|
+
if (blocked.length > 0) return { ...validation, status: "blocked", reason: blocked.join("\n") };
|
|
113
|
+
return { ...validation, status: "verified", reason: "Independent evaluation and all required checks passed." };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function runCheck(check: PatchEvaluation["checks"][number], cwd: string, signal?: AbortSignal): Promise<BoundedProcessResult> {
|
|
117
|
+
return runBoundedProcess({ file: check.file, args: check.args, cwd, signal, timeoutMs: 120_000, maxBufferBytes: 1 << 20,
|
|
118
|
+
abortError: "Validation aborted", timeoutError: "Validation timed out", maxBufferError: "Validation output exceeded its limit",
|
|
119
|
+
exitError: (stderr, code) => stderr.trim() || `Validation exited with ${code}` });
|
|
120
|
+
}
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import { initialPatchValidation, PatchEvaluationSchema, validateCandidatePatch, type PatchValidation } from "./patch-validation.ts";
|
|
2
|
+
import { isFatalWorkflowError } from "../cancellation.ts";
|
|
3
|
+
import { unknownErrorMessage } from "../unknown-error.ts";
|
|
1
4
|
import type { ParallelSettledError } from "../concurrency.ts";
|
|
2
|
-
import type { LoadedWorkflow, WorkflowApi, WorkflowModule } from "../types.ts";
|
|
5
|
+
import type { IsolatedAgentResult, LoadedWorkflow, WorkflowApi, WorkflowModule } from "../types.ts";
|
|
3
6
|
import type { WorktreeBaseline } from "../worktree.ts";
|
|
4
7
|
import { loadWorkflow } from "../workflow-module.ts";
|
|
5
8
|
import { serializeReviewIssue, type ReviewIssue } from "./review-issues.ts";
|
|
@@ -14,6 +17,7 @@ export interface ReviewFixPreview {
|
|
|
14
17
|
readonly result: string;
|
|
15
18
|
readonly patch: string;
|
|
16
19
|
readonly changed: boolean;
|
|
20
|
+
readonly validation: PatchValidation;
|
|
17
21
|
}
|
|
18
22
|
|
|
19
23
|
export interface ReviewFixFailure {
|
|
@@ -28,7 +32,7 @@ export interface ReviewFixWorkflowResult {
|
|
|
28
32
|
readonly fixes: readonly ReviewFixOutcome[];
|
|
29
33
|
}
|
|
30
34
|
|
|
31
|
-
export type ReviewFixWorkflowApi = Pick<WorkflowApi, "agent" | "parallel" | "phase">;
|
|
35
|
+
export type ReviewFixWorkflowApi = Pick<WorkflowApi, "agent" | "parallel" | "phase" | "cwd" | "signal">;
|
|
32
36
|
|
|
33
37
|
/** Build an ephemeral workflow that generates one isolated patch preview per finding. */
|
|
34
38
|
export function createReviewFixWorkflow(
|
|
@@ -40,9 +44,9 @@ export function createReviewFixWorkflow(
|
|
|
40
44
|
meta: {
|
|
41
45
|
name: "code-review-fix-previews",
|
|
42
46
|
description: "Generate isolated patch previews for selected code-review findings.",
|
|
43
|
-
phases: [{ title: REVIEW_FIX_PHASE }],
|
|
47
|
+
phases: [{ title: REVIEW_FIX_PHASE }, { title: "Validate patch previews" }],
|
|
44
48
|
},
|
|
45
|
-
default: (api) => runReviewFixWorkflow(api, issues, context),
|
|
49
|
+
default: (api) => runReviewFixWorkflow(api, issues, context, baseline),
|
|
46
50
|
};
|
|
47
51
|
return loadWorkflow(
|
|
48
52
|
module,
|
|
@@ -76,6 +80,7 @@ export async function runReviewFixWorkflow(
|
|
|
76
80
|
api: ReviewFixWorkflowApi,
|
|
77
81
|
issues: readonly ReviewIssue[],
|
|
78
82
|
context: ReviewContext | undefined,
|
|
83
|
+
baseline: WorktreeBaseline,
|
|
79
84
|
): Promise<ReviewFixWorkflowResult> {
|
|
80
85
|
api.phase(REVIEW_FIX_PHASE);
|
|
81
86
|
const settled = await api.parallel(
|
|
@@ -84,16 +89,18 @@ export async function runReviewFixWorkflow(
|
|
|
84
89
|
isolation: "worktree",
|
|
85
90
|
label: `fix:${issue.id}`,
|
|
86
91
|
phase: REVIEW_FIX_PHASE,
|
|
87
|
-
|
|
92
|
+
profile: "medium",
|
|
88
93
|
cacheKey: `review-fix:${issue.id}`,
|
|
89
94
|
tools: [...REVIEW_FIX_TOOLS],
|
|
90
95
|
toolHints: ["search"],
|
|
91
96
|
});
|
|
97
|
+
const validation = await evaluateReviewFix(api, { issue, context, baseline, isolated });
|
|
92
98
|
return {
|
|
93
99
|
findingId: issue.id,
|
|
94
100
|
result: isolated.result,
|
|
95
101
|
patch: isolated.patch,
|
|
96
102
|
changed: isolated.changed,
|
|
103
|
+
validation,
|
|
97
104
|
};
|
|
98
105
|
}),
|
|
99
106
|
{ settled: true },
|
|
@@ -103,11 +110,11 @@ export async function runReviewFixWorkflow(
|
|
|
103
110
|
entry.ok ? entry.value : { findingId: issues[index]!.id, error: entry.error },
|
|
104
111
|
);
|
|
105
112
|
const successful = fixes.filter(isReviewFixPreview);
|
|
106
|
-
const
|
|
113
|
+
const count = (status: PatchValidation["status"]) => successful.filter((fix) => fix.validation.status === status).length;
|
|
107
114
|
const failed = fixes.length - successful.length;
|
|
108
115
|
|
|
109
116
|
return {
|
|
110
|
-
summary:
|
|
117
|
+
summary: `${count("verified")} verified candidate(s); ${count("rejected")} rejected; ${count("blocked")} blocked; ${count("no-patch")} no-patch; ${failed} attempt(s) failed.`,
|
|
111
118
|
fixes,
|
|
112
119
|
};
|
|
113
120
|
}
|
|
@@ -115,3 +122,32 @@ export async function runReviewFixWorkflow(
|
|
|
115
122
|
function isReviewFixPreview(outcome: ReviewFixOutcome): outcome is ReviewFixPreview {
|
|
116
123
|
return "patch" in outcome;
|
|
117
124
|
}
|
|
125
|
+
|
|
126
|
+
async function evaluateReviewFix(api: ReviewFixWorkflowApi, input: {
|
|
127
|
+
issue: ReviewIssue; context: ReviewContext | undefined; baseline: WorktreeBaseline; isolated: IsolatedAgentResult<string>;
|
|
128
|
+
}): Promise<PatchValidation> {
|
|
129
|
+
const { issue, context, baseline, isolated } = input;
|
|
130
|
+
const validation = initialPatchValidation(baseline, isolated.baselineOid, isolated.patch);
|
|
131
|
+
if (!isolated.patch.trim()) return validation;
|
|
132
|
+
if (!isolated.baselineOid) return { ...validation, reason: "Candidate has no recorded baseline identity." };
|
|
133
|
+
if (!context?.snapshot) return { ...validation, reason: "Reviewed snapshot identity is unavailable." };
|
|
134
|
+
if (validation.baselineFingerprint !== context.snapshot.baselineFingerprint) {
|
|
135
|
+
return { ...validation, status: "rejected", reason: "Stale reviewed baseline identity." };
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
const evaluated = await api.agent(
|
|
139
|
+
`Independently evaluate a candidate repair. Your fresh worktree contains the exact reviewed baseline plus the captured patch. The implementer's report is not validation evidence. Inspect the finding, callers and tests. Reject incorrect repairs; return blocked if required validation is unavailable. Select at most six focused deterministic checks with executable and argument arrays. Require at least one meaningful behavior check. If a regression test is applicable, supply a test-only baselinePatch and specific expectedFailure so the engine can prove it fails before the repair and passes after. Do not edit, install dependencies, commit or change branches. The engine will execute checks independently.\nFinding: ${JSON.stringify(serializeReviewIssue(issue))}\nBaseline: ${isolated.baselineOid}\nPatch SHA-256: ${validation.patchHash}\nPatch:\n${isolated.patch}`,
|
|
140
|
+
{ isolation: "worktree", candidatePatch: { baselineOid: isolated.baselineOid, patch: isolated.patch },
|
|
141
|
+
label: `evaluate:${issue.id}`, phase: "Validate patch previews", profile: "medium", resume: "off",
|
|
142
|
+
tools: ["read", "bash", "grep", "find", "ls"], toolHints: ["search"], schema: PatchEvaluationSchema },
|
|
143
|
+
);
|
|
144
|
+
if (evaluated.baselineOid !== isolated.baselineOid || evaluated.patch !== isolated.patch) {
|
|
145
|
+
return { ...validation, status: "rejected", reason: "Evaluator changed the candidate or used a different baseline.", evaluation: evaluated.result };
|
|
146
|
+
}
|
|
147
|
+
return await validateCandidatePatch({ cwd: api.cwd, baseline, expectedFingerprint: context.snapshot.baselineFingerprint,
|
|
148
|
+
baselineOid: isolated.baselineOid, patch: isolated.patch, evaluation: evaluated.result, signal: api.signal });
|
|
149
|
+
} catch (error) {
|
|
150
|
+
if (isFatalWorkflowError(error, api.signal)) throw error;
|
|
151
|
+
return { ...validation, status: "blocked", reason: unknownErrorMessage(error) };
|
|
152
|
+
}
|
|
153
|
+
}
|