diff --git a/.changeset/quiet-external-checkouts.md b/.changeset/quiet-external-checkouts.md new file mode 100644 index 0000000000..b3449ff74c --- /dev/null +++ b/.changeset/quiet-external-checkouts.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Allow operators to route task execution and review through one validated external Git checkout. +category: feature +dev: PATCH /api/tasks/:id/external-checkout persists a clean checkout path and branch fence in task source metadata. diff --git a/packages/dashboard/src/__tests__/routes-tasks-near-duplicate.test.ts b/packages/dashboard/src/__tests__/routes-tasks-near-duplicate.test.ts index 5f6679cec6..c32a08e719 100644 --- a/packages/dashboard/src/__tests__/routes-tasks-near-duplicate.test.ts +++ b/packages/dashboard/src/__tests__/routes-tasks-near-duplicate.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import express from "express"; import * as core from "@fusion/core"; +import * as engine from "@fusion/engine"; import type { Column, Task, TaskStore } from "@fusion/core"; import { registerTaskWorkflowRoutes } from "../routes/register-task-workflow-routes.js"; import { request as performRequest } from "../test-request.js"; @@ -62,6 +63,7 @@ function buildApp(seed: Task[]) { tasks[index] = next; return next; }), + logEntry: vi.fn().mockResolvedValue(undefined), recordActivity: vi.fn().mockResolvedValue(undefined), }; @@ -339,4 +341,59 @@ describe("routes /api/tasks near duplicate", () => { nearDuplicateDismissed: true, }); }); + + it("PATCH external-checkout persists one clean Git checkout for execution and review", async () => { + const inspection = vi.spyOn(engine, "inspectExternalGitCheckout").mockResolvedValue({ + valid: true, + checkoutPath: "/tmp/external-runtime", + branch: "local/runtime-fixes", + }); + const seeded = mkTask({ + id: "FN-6097", + title: "External runtime task", + description: "Implement in a supported external checkout", + column: "todo", + }); + const { app, tasks } = buildApp([seeded]); + + const res = await performRequest( + app, + "PATCH", + "/api/tasks/FN-6097/external-checkout", + JSON.stringify({ checkoutPath: "/tmp/external-runtime" }), + { "content-type": "application/json" }, + ); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect((res.body as Task).sourceMetadata).toMatchObject({ + externalExecutionCheckout: "/tmp/external-runtime", + externalExecutionBranch: "local/runtime-fixes", + externalReviewCheckout: "/tmp/external-runtime", + }); + expect(tasks[0]?.sourceMetadata).toMatchObject((res.body as Task).sourceMetadata ?? {}); + + inspection.mockResolvedValueOnce({ valid: false, reason: "checkoutPath must be clean before routing" }); + const dirty = await performRequest( + app, + "PATCH", + "/api/tasks/FN-6097/external-checkout", + JSON.stringify({ checkoutPath: "/tmp/external-runtime" }), + { "content-type": "application/json" }, + ); + expect(dirty.status).toBe(400); + + const cleared = await performRequest( + app, + "PATCH", + "/api/tasks/FN-6097/external-checkout", + JSON.stringify({ checkoutPath: null }), + { "content-type": "application/json" }, + ); + expect(cleared.status).toBe(200); + expect((cleared.body as Task).sourceMetadata).toMatchObject({ + externalExecutionCheckout: null, + externalExecutionBranch: null, + externalReviewCheckout: null, + }); + }); }); diff --git a/packages/dashboard/src/routes/register-task-workflow-routes.ts b/packages/dashboard/src/routes/register-task-workflow-routes.ts index 8e84f9e7fd..c51635655b 100644 --- a/packages/dashboard/src/routes/register-task-workflow-routes.ts +++ b/packages/dashboard/src/routes/register-task-workflow-routes.ts @@ -102,6 +102,7 @@ import { prepareRevertPrBranch, prepareWorkspaceRevertPrBranches, isInReviewMissingWorktreeSessionStartFailure, + inspectExternalGitCheckout, // FN-8004 follow-up: shared with SelfHealingManager.recoverStaleMergingStatus so the manual // Retry gate and the automatic sweep agree on when a merge-active stamp is orphaned. isStaleMergeActiveStatus, @@ -6411,6 +6412,54 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork } }); + // Persist one operator-validated external checkout for both implementation + // and enforced review. Keep filesystem routing out of the user-defined + // workflow custom-field schema. + router.patch("/tasks/:id/external-checkout", async (req, res) => { + try { + const { store: scopedStore } = await getProjectContext(req); + const checkoutPath = (req.body as { checkoutPath?: unknown } | undefined)?.checkoutPath; + await scopedStore.getTask(req.params.id); + + if (checkoutPath === null) { + const task = await scopedStore.updateTask(req.params.id, { + sourceMetadataPatch: { + externalExecutionCheckout: null, + externalExecutionBranch: null, + externalReviewCheckout: null, + }, + }); + await scopedStore.logEntry(req.params.id, "External execution/review checkout routing cleared by operator"); + res.json(task); + return; + } + + const inspection = await inspectExternalGitCheckout(checkoutPath, { requireClean: true }); + if (!inspection.valid || !inspection.checkoutPath || !inspection.branch) { + throw badRequest(`Invalid external checkout: ${inspection.reason ?? "unknown error"}`); + } + + const task = await scopedStore.updateTask(req.params.id, { + sourceMetadataPatch: { + externalExecutionCheckout: inspection.checkoutPath, + externalExecutionBranch: inspection.branch, + externalReviewCheckout: inspection.checkoutPath, + }, + }); + await scopedStore.logEntry( + req.params.id, + `External execution/review checkout routed to ${inspection.checkoutPath} (${inspection.branch}) by operator`, + ); + res.json(task); + } catch (err: unknown) { + if (err instanceof ApiError) throw err; + if (isTaskLookupMiss(err) || (err instanceof Error ? err.message : String(err)).includes("not found")) { + throw notFound(err instanceof Error ? err.message : String(err)); + } + rethrowAsApiError(err); + } + }); + // Patch a task's custom field values (U13/KTD-14). Delegates to the single // store write authority (`updateTaskCustomFields`), which validates the patch // against the task's workflow field schema. A typed rejection surfaces as a diff --git a/packages/engine/src/__tests__/executor-fast-mode-workflows.test.ts b/packages/engine/src/__tests__/executor-fast-mode-workflows.test.ts index fdb9a4429d..e57efbabe3 100644 --- a/packages/engine/src/__tests__/executor-fast-mode-workflows.test.ts +++ b/packages/engine/src/__tests__/executor-fast-mode-workflows.test.ts @@ -9,6 +9,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import "./executor-test-helpers.js"; import { getBuiltinWorkflow } from "@fusion/core"; import { TaskExecutor } from "../executor.js"; +import { resolveExternalExecutionCheckoutRoute } from "../execution/external-execution-checkout.js"; import { WorkflowGraphTaskRunner } from "../workflows/workflow-graph-task-runner.js"; import { FOREACH_ACTIVE_CONTEXT_KEY } from "../workflows/workflow-node-handlers.js"; import { @@ -20,6 +21,12 @@ import { resetExecutorMocks, } from "./executor-test-helpers.js"; +vi.mock("../execution/external-execution-checkout.js", () => ({ + resolveExternalExecutionCheckoutRoute: vi.fn(async () => ({ configured: false })), +})); + +const mockedResolveExternalExecutionCheckoutRoute = vi.mocked(resolveExternalExecutionCheckoutRoute); + const now = "2026-06-10T00:00:00.000Z"; function task(overrides: Record = {}) { @@ -70,6 +77,8 @@ function workflowResult() { describe("fast mode workflow/runtime invariants", () => { beforeEach(() => { resetExecutorMocks(); + mockedResolveExternalExecutionCheckoutRoute.mockReset(); + mockedResolveExternalExecutionCheckoutRoute.mockResolvedValue({ configured: false }); mockedExistsSync.mockReturnValue(true); }); @@ -248,6 +257,43 @@ describe("fast mode workflow/runtime invariants", () => { }); }); + it("prepares a persisted external execution checkout instead of the project task worktree", async () => { + const routedTask = task({ + id: "FN-6097", + worktree: "/tmp/project-task-worktree", + branch: "fusion/fn-6097", + sourceMetadata: { + externalExecutionCheckout: "/tmp/external-runtime", + externalExecutionBranch: "local/runtime-fixes", + externalReviewCheckout: "/tmp/external-runtime", + }, + }); + const store = createMockStore(); + store.getTask.mockResolvedValue(routedTask); + mockedResolveExternalExecutionCheckoutRoute.mockResolvedValueOnce({ + configured: true, + valid: true, + checkoutPath: "/tmp/external-runtime", + branch: "local/runtime-fixes", + }); + const executor = new TaskExecutor(store, "/tmp/project-root"); + + const result = await (executor as any) + .createAuthoritativeWorkflowPrimitives({ experimentalFeatures: { workflowGraphExecutor: true } }) + .prepareWorktree( + { run: { taskId: "FN-6097" }, node: { node: { id: "execute" }, context: {} } }, + routedTask, + ); + + expect(result).toMatchObject({ + outcome: "success", + data: { + worktreePath: "/tmp/external-runtime", + branchName: "local/runtime-fixes", + }, + }); + }); + it("does not project a fresh graph step or capture its baseline before the executor creates its worktree", async () => { let liveTask = task({ steps: [{ name: "Preflight", status: "pending" }], diff --git a/packages/engine/src/__tests__/external-execution-checkout.test.ts b/packages/engine/src/__tests__/external-execution-checkout.test.ts new file mode 100644 index 0000000000..93c67d4290 --- /dev/null +++ b/packages/engine/src/__tests__/external-execution-checkout.test.ts @@ -0,0 +1,96 @@ +/* + * Persisted external checkout routing is an explicit operator contract. Execution + * must use the same validated checkout as review, and stale path/branch metadata + * must fail closed instead of silently falling back to the project task worktree. + */ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { inspectExternalGitCheckout, resolveExternalExecutionCheckoutRoute } from "../execution/external-execution-checkout.js"; +import { resolveReviewCheckoutCwd } from "../execution/review-checkout.js"; + +function makeGitCheckout(branch = "local/runtime-fixes"): string { + const dir = mkdtempSync(join(tmpdir(), "external-execution-checkout-")); + execFileSync("git", ["init", "-b", branch], { cwd: dir, stdio: "ignore" }); + execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: dir }); + execFileSync("git", ["config", "user.name", "Fusion Test"], { cwd: dir }); + execFileSync("git", ["commit", "--allow-empty", "-m", "initial"], { cwd: dir, stdio: "ignore" }); + return dir; +} + +let checkout: string; + +beforeAll(() => { + checkout = makeGitCheckout(); +}); + +afterAll(() => { + rmSync(checkout, { recursive: true, force: true }); +}); + +describe("resolveExternalExecutionCheckoutRoute", () => { + it("reports an absent route without widening to other task fields", async () => { + await expect(resolveExternalExecutionCheckoutRoute({ + worktree: "/tmp/task-worktree", + customFields: { executionCheckoutPath: "/tmp/untrusted" }, + })).resolves.toEqual({ configured: false }); + await expect(resolveExternalExecutionCheckoutRoute({ + sourceMetadata: { + externalExecutionCheckout: null, + externalExecutionBranch: null, + }, + })).resolves.toEqual({ configured: false }); + }); + + it("resolves a persisted path and branch and matches explicit review routing", async () => { + const realCheckout = realpathSync(checkout); + const task = { + sourceMetadata: { + externalExecutionCheckout: checkout, + externalExecutionBranch: "local/runtime-fixes", + externalReviewCheckout: checkout, + }, + }; + + await expect(resolveExternalExecutionCheckoutRoute(task)).resolves.toEqual({ + configured: true, + valid: true, + checkoutPath: realCheckout, + branch: "local/runtime-fixes", + }); + expect(resolveReviewCheckoutCwd(task, "/tmp/task-worktree")).toBe(realCheckout); + }); + + it("fails closed when the persisted branch does not match the checkout", async () => { + await expect(resolveExternalExecutionCheckoutRoute({ + sourceMetadata: { + externalExecutionCheckout: checkout, + externalExecutionBranch: "stale-branch", + }, + })).resolves.toMatchObject({ + configured: true, + valid: false, + reason: expect.stringContaining("branch mismatch"), + }); + }); + + it("fails closed when a path is persisted without a branch fence", async () => { + await expect(resolveExternalExecutionCheckoutRoute({ + sourceMetadata: { externalExecutionCheckout: checkout }, + })).resolves.toMatchObject({ + configured: true, + valid: false, + reason: expect.stringContaining("externalExecutionBranch"), + }); + }); + + it("requires a clean checkout when an operator first persists the route", async () => { + writeFileSync(join(checkout, "dirty.txt"), "dirty"); + await expect(inspectExternalGitCheckout(checkout, { requireClean: true })).resolves.toMatchObject({ + valid: false, + reason: expect.stringContaining("must be clean"), + }); + }); +}); diff --git a/packages/engine/src/execution/external-execution-checkout.ts b/packages/engine/src/execution/external-execution-checkout.ts new file mode 100644 index 0000000000..fdb58dff39 --- /dev/null +++ b/packages/engine/src/execution/external-execution-checkout.ts @@ -0,0 +1,118 @@ +import { execFile } from "node:child_process"; +import { existsSync, realpathSync, statSync } from "node:fs"; +import { isAbsolute } from "node:path"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +export interface ExternalGitCheckoutInspection { + valid: boolean; + checkoutPath?: string; + branch?: string; + reason?: string; +} + +export type ExternalExecutionCheckoutResolution = + | { configured: false } + | ({ configured: true } & ExternalGitCheckoutInspection); + +function readSourceMetadata(task: unknown): Record | undefined { + if (!task || typeof task !== "object") return undefined; + const sourceMetadata = (task as Record).sourceMetadata; + return sourceMetadata && typeof sourceMetadata === "object" + ? sourceMetadata as Record + : undefined; +} + +export async function inspectExternalGitCheckout( + candidate: unknown, + options: { requireClean?: boolean } = {}, +): Promise { + if (typeof candidate !== "string" || candidate.trim().length === 0) { + return { valid: false, reason: "checkoutPath must be a non-empty string" }; + } + const checkoutPath = candidate.trim(); + if (!isAbsolute(checkoutPath)) { + return { valid: false, reason: "checkoutPath must be absolute" }; + } + + try { + if (!existsSync(checkoutPath) || !statSync(checkoutPath).isDirectory()) { + return { valid: false, reason: `checkoutPath is not a directory: ${checkoutPath}` }; + } + const canonicalCheckout = realpathSync(checkoutPath); + const { stdout: topLevelOutput } = await execFileAsync("git", ["rev-parse", "--show-toplevel"], { + cwd: canonicalCheckout, + encoding: "utf-8", + timeout: 10_000, + }); + const topLevel = topLevelOutput.trim(); + const canonicalTopLevel = realpathSync(topLevel); + if (canonicalTopLevel !== canonicalCheckout) { + return { + valid: false, + reason: `checkoutPath must be the Git top-level (observed ${canonicalTopLevel})`, + }; + } + const { stdout: branchOutput } = await execFileAsync("git", ["symbolic-ref", "--quiet", "--short", "HEAD"], { + cwd: canonicalCheckout, + encoding: "utf-8", + timeout: 10_000, + }); + const branch = branchOutput.trim(); + if (!branch) { + return { valid: false, reason: "checkoutPath must have a checked-out branch" }; + } + if (options.requireClean) { + const { stdout: statusOutput } = await execFileAsync("git", ["status", "--porcelain=v1"], { + cwd: canonicalCheckout, + encoding: "utf-8", + timeout: 10_000, + }); + const status = statusOutput.trim(); + if (status.length > 0) { + return { valid: false, reason: "checkoutPath must be clean before routing" }; + } + } + return { valid: true, checkoutPath: canonicalCheckout, branch }; + } catch (error) { + return { + valid: false, + reason: error instanceof Error ? error.message : String(error), + }; + } +} + +export async function resolveExternalExecutionCheckoutRoute(task: unknown): Promise { + const sourceMetadata = readSourceMetadata(task); + if (!sourceMetadata || !Object.prototype.hasOwnProperty.call(sourceMetadata, "externalExecutionCheckout")) { + return { configured: false }; + } + if (sourceMetadata.externalExecutionCheckout == null) { + return { configured: false }; + } + + const inspection = await inspectExternalGitCheckout(sourceMetadata.externalExecutionCheckout); + if (!inspection.valid) { + return { configured: true, ...inspection }; + } + + const expectedBranch = sourceMetadata.externalExecutionBranch; + if (typeof expectedBranch !== "string" || expectedBranch.trim().length === 0) { + return { + configured: true, + valid: false, + reason: "sourceMetadata.externalExecutionBranch must be persisted with the checkout route", + }; + } + const normalizedExpectedBranch = expectedBranch.trim(); + if (inspection.branch !== normalizedExpectedBranch) { + return { + configured: true, + valid: false, + reason: `external execution checkout branch mismatch: observed ${inspection.branch}, expected ${normalizedExpectedBranch}`, + }; + } + + return { configured: true, ...inspection }; +} diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index 873c80b445..10876aaf3e 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -36,6 +36,7 @@ import { WorkflowAgentCapacity } from "./agents/workflow-agent-capacity.js"; import { createExecutorColumnBoundaryHooks } from "./workflow-column-boundary-hooks.js"; import { ensureWorkflowCompletionSummary } from "./workflows/workflow-completion-summary.js"; import { createCodeNodeRunner } from "./execution/code-node-runner.js"; +import { resolveExternalExecutionCheckoutRoute } from "./execution/external-execution-checkout.js"; import { getTaskReviewCheckoutPath, resolveReviewCheckoutCwd } from "./execution/review-checkout.js"; import { getActiveNotificationService } from "./util/notifier.js"; import type { ParseStepsHandlerDeps, CodeNodeRunner } from "./workflows/workflow-node-handlers.js"; @@ -9007,6 +9008,14 @@ export class TaskExecutor { prepareWorktree: async (_ctx, task) => { const live = await this.store.getTask(task.id).catch(() => null); const liveTask = live?.id === task.id ? live : null; + const routedTask = liveTask ?? task; + const externalRoute = await resolveExternalExecutionCheckoutRoute(routedTask); + if (externalRoute.configured && !externalRoute.valid) { + return { + outcome: "failure", + value: `external-execution-checkout-invalid: ${externalRoute.reason ?? "unknown error"}`, + }; + } /* FNXC:WorkflowExecution 2026-06-23-11:49: The workflow execute node must not perform a second worktree acquisition ahead of the authoritative executor. Passing the repo root as a prepared worktree makes the inner execute() reject a valid fresh-worktree task as repo-root reuse; pass only an existing task worktree and let execute() acquire when none exists. @@ -9015,8 +9024,12 @@ export class TaskExecutor { Upgrade safety requires the graph primitive to tolerate older or minimal stores that return null or a mismatched row during startup/cutover. Only trust the live row when it is for the requested task; otherwise fall back to the runner snapshot. */ const prepared: PreparedWorktree = { - worktreePath: liveTask?.worktree || task.worktree || "", - branchName: liveTask?.branch || task.branch, + worktreePath: externalRoute.configured + ? externalRoute.checkoutPath ?? "" + : liveTask?.worktree || task.worktree || "", + branchName: externalRoute.configured + ? externalRoute.branch + : liveTask?.branch || task.branch, }; return { outcome: "success", value: "worktree-ready", data: prepared }; }, @@ -14092,6 +14105,12 @@ export class TaskExecutor { // Behavior-inert when nothing is customized (declaration defaults === legacy // defaults; absent-default lanes never override). const settings = await mergeEffectiveSettings(this.store, task, await this.store.getSettings()); + const externalExecutionRoute = await resolveExternalExecutionCheckoutRoute(task); + if (externalExecutionRoute.configured && !externalExecutionRoute.valid) { + const message = `Persisted external execution checkout is invalid: ${externalExecutionRoute.reason ?? "unknown error"}`; + await this.store.logEntry(task.id, message, undefined, this.getRunContextFor(task.id)); + throw new Error(message); + } // Keep runtime plugin workflow step templates synchronized into TaskStore. // TaskStore resolves plugin-prefixed workflow IDs from this injected cache @@ -14228,7 +14247,7 @@ export class TaskExecutor { ); } - if (task.column === preflightWipLane && !task.worktree) { + if (task.column === preflightWipLane && !task.worktree && !externalExecutionRoute.configured) { executorLog.error( `${task.id}: drift detected — task is in-progress with no worktree. ` + `Recovering by creating a fresh worktree. This usually indicates a partial ` + @@ -14243,7 +14262,9 @@ export class TaskExecutor { } // Hoist worktreePath so it's accessible in the catch block for dep-abort cleanup - let worktreePath = task.worktree ?? ""; + let worktreePath = externalExecutionRoute.configured + ? externalExecutionRoute.checkoutPath ?? "" + : task.worktree ?? ""; // Set by stuck-abort handlers; the actual moveTask("todo") is deferred to // the finally block so this.executing is cleared first (prevents re-dispatch race). @@ -14333,7 +14354,7 @@ export class TaskExecutor { } } - const hadAssignedWorktree = Boolean(task.worktree); + const hadAssignedWorktree = Boolean(task.worktree) || externalExecutionRoute.configured; const taskCommandAbortController = new AbortController(); this.registerConfiguredCommandController(task.id, taskCommandAbortController); /* @@ -14348,6 +14369,14 @@ export class TaskExecutor { hydrated: true, isResume: Boolean(task.sessionFile), } + : externalExecutionRoute.configured + ? { + worktreePath: externalExecutionRoute.checkoutPath ?? "", + branch: externalExecutionRoute.branch ?? "", + source: "existing", + hydrated: true, + isResume: Boolean(task.sessionFile), + } : await (async () => { try { return await acquireTaskWorktree({ @@ -14443,7 +14472,7 @@ export class TaskExecutor { FNXC:Workspace 2026-06-21-12:00: KTD1 — every preflight below (base-commit capture, contamination check, worktree-liveness gate) runs git against `worktreePath`, which equals the non-git workspace root in workspace mode. They would all fail. Gate the whole block off in workspace mode; the per-repo equivalents return in Phase B (master U3) against each acquired sub-repo worktree. The non-workspace branch is unchanged. */ - if (!this.workspaceConfig) { + if (!this.workspaceConfig && !externalExecutionRoute.configured) { // Capture the base commit SHA for diff computation whenever a task // starts with a newly assigned worktree. if (!acquisition.isResume) { @@ -18148,9 +18177,24 @@ export class TaskExecutor { } return { ok: true }; } - const branchName = resolveTaskWorkingBranch(task); + const externalExecutionRoute = await resolveExternalExecutionCheckoutRoute(task); + if (externalExecutionRoute.configured && !externalExecutionRoute.valid) { + return { + ok: false, + reason: "wrong_toplevel", + observed: externalExecutionRoute.reason ?? "invalid persisted external execution checkout", + expected: "valid persisted external execution checkout", + }; + } + const branchName = externalExecutionRoute.configured + ? externalExecutionRoute.branch ?? "" + : resolveTaskWorkingBranch(task); // Non-workspace tasks hold a one-element set; fall back to its sole member to preserve the original singular resolution. - const worktreePath = worktreePathOverride ?? task.worktree ?? this.getActiveWorktreePaths(task.id)[0] ?? null; + const worktreePath = worktreePathOverride + ?? (externalExecutionRoute.configured ? externalExecutionRoute.checkoutPath : undefined) + ?? task.worktree + ?? this.getActiveWorktreePaths(task.id)[0] + ?? null; if (!worktreePath) { return { @@ -18198,12 +18242,13 @@ export class TaskExecutor { if (observedTopLevelRaw) { const observedTopLevel = canonicalizePath(observedTopLevelRaw); - if ( - observedTopLevel === expectedRoot || - !isInsideWorktreesDir(this.rootDir, observedTopLevel, settings) || - observedTopLevel !== expectedWorktreeRealpath - ) { - if (allowReanchor && observedTopLevel !== expectedRoot && isInsideWorktreesDir(this.rootDir, observedTopLevel, settings)) { + const violatesCheckoutBoundary = externalExecutionRoute.configured + ? observedTopLevel !== expectedWorktreeRealpath + : observedTopLevel === expectedRoot + || !isInsideWorktreesDir(this.rootDir, observedTopLevel, settings) + || observedTopLevel !== expectedWorktreeRealpath; + if (violatesCheckoutBoundary) { + if (!externalExecutionRoute.configured && allowReanchor && observedTopLevel !== expectedRoot && isInsideWorktreesDir(this.rootDir, observedTopLevel, settings)) { const reanchor = await detectNestedWorktreeRoot(this.rootDir, worktreePath, settings); if (reanchor.reanchored) { await this.store.updateTask(task.id, { worktree: reanchor.root }); diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index e50b4fbd10..95db16ca2e 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -482,6 +482,12 @@ export { type SquashAuditRecentMainCommit, } from "./merge/merger-squash-audit.js"; export { reviewStep, type ReviewType, type ReviewVerdict, type ReviewResult, type ReviewOptions } from "./execution/reviewer.js"; +export { + inspectExternalGitCheckout, + resolveExternalExecutionCheckoutRoute, + type ExternalExecutionCheckoutResolution, + type ExternalGitCheckoutInspection, +} from "./execution/external-execution-checkout.js"; export { createFnAgent, promptWithFallback, describeModel, setHostExtensionPaths, getHostExtensionPaths, wrapToolsWithActionGate, type AgentOptions, type AgentResult } from "./pi.js"; export { resolveMcpServersForRuntime, resolveMcpServersForStore, type ResolvedMcpServersForRuntime } from "./mcp/mcp-resolution.js"; export { discoverMcpServers, type DiscoverMcpServersOptions, type DiscoverMcpServersResult } from "./mcp/mcp-discovery-service.js";