fix(engine): review explicit external checkouts

This commit is contained in:
Phil Larson
2026-06-29 13:29:49 -07:00
committed by gsxdsm
parent d7f26bb6cf
commit b829821dd9
4 changed files with 104 additions and 4 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Allow review steps to target a validated external checkout.
category: fix
dev: Resolves explicit review checkout metadata before spawning read-only step reviewers.

View File

@@ -14,6 +14,10 @@ the cwd of each call. Coverage:
*/ */
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { EventEmitter } from "node:events"; import { EventEmitter } from "node:events";
import { execFileSync } from "node:child_process";
import { mkdtempSync, realpathSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { ReviewResult } from "../reviewer.js"; import type { ReviewResult } from "../reviewer.js";
// Narrow AI seam: only reviewStep (the agent boundary) is mocked. Everything else is the real executor. // Narrow AI seam: only reviewStep (the agent boundary) is mocked. Everything else is the real executor.
@@ -32,6 +36,14 @@ const mockedReviewStep = vi.mocked(mockedReviewStepFn);
const ROOT = "/tmp/ws-root"; // NON-git workspace root — must never be a review cwd in workspace mode. const ROOT = "/tmp/ws-root"; // NON-git workspace root — must never be a review cwd in workspace mode.
const WT_A = "/tmp/ws-root/repo-a/.worktrees/fn-1"; const WT_A = "/tmp/ws-root/repo-a/.worktrees/fn-1";
const WT_B = "/tmp/ws-root/repo-b/.worktrees/fn-1"; const WT_B = "/tmp/ws-root/repo-b/.worktrees/fn-1";
const cleanupDirs: string[] = [];
function makeGitCheckout(): string {
const dir = mkdtempSync(join(tmpdir(), "fusion-review-checkout-"));
cleanupDirs.push(dir);
execFileSync("git", ["init", "-b", "main"], { cwd: dir, stdio: "ignore" });
return dir;
}
function makeStore(task: Task): TaskStore & EventEmitter { function makeStore(task: Task): TaskStore & EventEmitter {
const emitter = new EventEmitter(); const emitter = new EventEmitter();
@@ -93,6 +105,10 @@ beforeEach(() => {
}); });
afterEach(() => { afterEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
while (cleanupDirs.length > 0) {
const dir = cleanupDirs.pop();
if (dir) rmSync(dir, { recursive: true, force: true });
}
}); });
describe("U2 KTD3 — reviewWorkspacePerRepo conjunction + tagging (the shared loop both call sites use)", () => { describe("U2 KTD3 — reviewWorkspacePerRepo conjunction + tagging (the shared loop both call sites use)", () => {
@@ -212,6 +228,27 @@ describe("U2 KTD3 — in-session fn_review_step (createReviewStepTool) loops per
await tool.execute("call-1", { step: 1, type: "code", step_name: "Step 1", baseline: "base" }); await tool.execute("call-1", { step: 1, type: "code", step_name: "Step 1", baseline: "base" });
expect(seen).toEqual([WT_A]); expect(seen).toEqual([WT_A]);
}); });
it("explicit external review checkout overrides the Atlas task worktree for fn_review_step", async () => {
const externalCheckout = makeGitCheckout();
const expectedCheckout = realpathSync(externalCheckout);
const task = makeTask({ customFields: { reviewCheckoutPath: externalCheckout } } as any);
const store = makeStore(task);
const executor = new TaskExecutor(store, ROOT);
const seen = scriptReviewByCwd({ [externalCheckout]: { verdict: "APPROVE", review: "external ok", summary: "external" } });
const tool = (executor as any).createReviewStepTool(
task.id,
WT_A,
"PROMPT",
new Map(),
{ current: null },
new Map(),
task,
undefined,
);
await tool.execute("call-1", { step: 1, type: "code", step_name: "Step 1", baseline: "base" });
expect(seen).toEqual([expectedCheckout]);
});
}); });
describe("U2 KTD3 — step-inversion review seam (executor.ts:5668) loops per sub-repo", () => { describe("U2 KTD3 — step-inversion review seam (executor.ts:5668) loops per sub-repo", () => {
@@ -244,4 +281,17 @@ describe("U2 KTD3 — step-inversion review seam (executor.ts:5668) loops per su
await seams.stepReview!(task as any, context, { type: "code", advisory: true } as any); await seams.stepReview!(task as any, context, { type: "code", advisory: true } as any);
expect(seen).toEqual([WT_A]); expect(seen).toEqual([WT_A]);
}); });
it("explicit external review checkout overrides the active graph worktree", async () => {
const externalCheckout = makeGitCheckout();
const expectedCheckout = realpathSync(externalCheckout);
const task = makeTask({ worktree: WT_A, customFields: { reviewCheckoutPath: externalCheckout } } as any);
const store = makeStore(task);
const executor = new TaskExecutor(store, ROOT);
const seen = scriptReviewByCwd({ [externalCheckout]: { verdict: "APPROVE", review: "external", summary: "external" } });
const seams = executor.createAuthoritativeWorkflowSeams({ autoMerge: false } as any);
const context = { [FOREACH_ACTIVE_CONTEXT_KEY]: { stepIndex: 1, worktreePath: WT_A, baselineSha: "base" } } as any;
await seams.stepReview!(task as any, context, { type: "code", advisory: true } as any);
expect(seen).toEqual([expectedCheckout]);
});
}); });

View File

@@ -24,6 +24,7 @@ import {
import { WorkflowGraphTaskRunner, type WorkflowGraphTaskRunResult } from "./workflow-graph-task-runner.js"; import { WorkflowGraphTaskRunner, type WorkflowGraphTaskRunResult } from "./workflow-graph-task-runner.js";
import { ensureWorkflowCompletionSummary } from "./workflow-completion-summary.js"; import { ensureWorkflowCompletionSummary } from "./workflow-completion-summary.js";
import { createCodeNodeRunner } from "./code-node-runner.js"; import { createCodeNodeRunner } from "./code-node-runner.js";
import { resolveReviewCheckoutCwd } from "./review-checkout.js";
import { getActiveNotificationService } from "./notifier.js"; import { getActiveNotificationService } from "./notifier.js";
import type { ParseStepsHandlerDeps, CodeNodeRunner } from "./workflow-node-handlers.js"; import type { ParseStepsHandlerDeps, CodeNodeRunner } from "./workflow-node-handlers.js";
import type { WorkflowBranchPersistence, WorkflowBranchRunState } from "./workflow-graph-branches.js"; import type { WorkflowBranchPersistence, WorkflowBranchRunState } from "./workflow-graph-branches.js";
@@ -6135,6 +6136,7 @@ export class TaskExecutor {
const detail = await this.store.getTask(seamTask.id); const detail = await this.store.getTask(seamTask.id);
// Worktree isolation (KTD-11): review the instance's OWN worktree when set. // Worktree isolation (KTD-11): review the instance's OWN worktree when set.
const worktreePath = active.worktreePath || detail.worktree || this.rootDir; const worktreePath = active.worktreePath || detail.worktree || this.rootDir;
const reviewCwd = resolveReviewCheckoutCwd(detail, worktreePath);
const stepName = detail.steps[stepIndex]?.name ?? `Step ${stepIndex}`; const stepName = detail.steps[stepIndex]?.name ?? `Step ${stepIndex}`;
const promptContent = detail.prompt ?? ""; const promptContent = detail.prompt ?? "";
// Merge per-task effective workflow settings (U3, KTD-3) so the validator // Merge per-task effective workflow settings (U3, KTD-3) so the validator
@@ -6190,9 +6192,9 @@ export class TaskExecutor {
return sem ? sem.runNested(invoke) : invoke(); return sem ? sem.runNested(invoke) : invoke();
}; };
const invokeReviewer = () => const invokeReviewer = () =>
this.workspaceConfig this.workspaceConfig && reviewCwd === worktreePath
? this.reviewWorkspacePerRepo(detail, (cwd) => runForCwd(cwd)) ? this.reviewWorkspacePerRepo(detail, (cwd) => runForCwd(cwd))
: runForCwd(worktreePath); : runForCwd(reviewCwd);
let review: { verdict: ReviewVerdict; review: string; summary: string }; let review: { verdict: ReviewVerdict; review: string; summary: string };
try { try {
@@ -12634,6 +12636,7 @@ export class TaskExecutor {
const latestDetailForReview = await store.getTask(taskId); const latestDetailForReview = await store.getTask(taskId);
const userComments = selectUserCommentsForAgentContext(latestDetailForReview); const userComments = selectUserCommentsForAgentContext(latestDetailForReview);
const settings = await mergeEffectiveSettings(store, latestDetailForReview, await store.getSettings()); const settings = await mergeEffectiveSettings(store, latestDetailForReview, await store.getSettings());
const reviewCwd = resolveReviewCheckoutCwd(latestDetailForReview, worktreePath);
// Run the reviewer via semaphore.runNested so its slot accounting // Run the reviewer via semaphore.runNested so its slot accounting
// is honest: activeCount transiently bumps to reflect the second // is honest: activeCount transiently bumps to reflect the second
// agent session, but the reviewer doesn't enter the wait queue // agent session, but the reviewer doesn't enter the wait queue
@@ -12693,9 +12696,9 @@ export class TaskExecutor {
const invoke = () => invokeReviewerForCwd(cwd); const invoke = () => invokeReviewerForCwd(cwd);
return sem ? sem.runNested(invoke) : invoke(); return sem ? sem.runNested(invoke) : invoke();
}; };
const result = this.workspaceConfig const result = this.workspaceConfig && reviewCwd === worktreePath
? await this.reviewWorkspacePerRepo(currentTask, (cwd) => runForCwd(cwd)) ? await this.reviewWorkspacePerRepo(currentTask, (cwd) => runForCwd(cwd))
: await runForCwd(worktreePath); : await runForCwd(reviewCwd);
await store.logEntry( await store.logEntry(
taskId, taskId,

View File

@@ -0,0 +1,40 @@
import { execFileSync } from "node:child_process";
import { existsSync, lstatSync, realpathSync } from "node:fs";
import { isAbsolute } from "node:path";
function readMetadataPath(value: unknown): string | undefined {
if (!value || typeof value !== "object") return undefined;
const record = value as Record<string, unknown>;
const direct = record.reviewCheckoutPath ?? record.externalReviewCheckoutPath;
if (typeof direct === "string" && direct.trim()) return direct.trim();
const nested = record.reviewCheckout;
if (nested && typeof nested === "object") {
const path = (nested as Record<string, unknown>).path;
if (typeof path === "string" && path.trim()) return path.trim();
}
return undefined;
}
export function getTaskReviewCheckoutPath(task: unknown): string | undefined {
if (!task || typeof task !== "object") return undefined;
const record = task as Record<string, unknown>;
return readMetadataPath(record.customFields) ?? readMetadataPath(record.branchContext) ?? readMetadataPath(record);
}
export function resolveReviewCheckoutCwd(task: unknown, fallbackCwd: string): string {
const candidate = getTaskReviewCheckoutPath(task);
if (!candidate || !isAbsolute(candidate)) return fallbackCwd;
try {
if (!existsSync(candidate) || !lstatSync(candidate).isDirectory()) return fallbackCwd;
const realCandidate = realpathSync(candidate);
const topLevel = execFileSync("git", ["rev-parse", "--show-toplevel"], {
cwd: realCandidate,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
if (!topLevel) return fallbackCwd;
return realpathSync(topLevel);
} catch {
return fallbackCwd;
}
}