feat: support operator-routed external task checkouts (#3398)
## Summary - add an explicit API route that persists one clean external Git checkout for task execution and enforced review - fence execution to the checkout's persisted branch and fail closed when the route becomes invalid - allow completion invariants to validate explicitly routed checkouts outside the project worktree directory ## Test plan - `pnpm --filter @fusion/engine exec vitest run src/__tests__/external-execution-checkout.test.ts src/__tests__/review-checkout.test.ts src/__tests__/engine-no-blocking-shellout.test.ts --silent=passed-only --reporter=dot` - `pnpm --filter @fusion/engine exec vitest run src/__tests__/executor-fast-mode-workflows.test.ts -t 'prepares a persisted external execution checkout' --silent=passed-only --reporter=dot` - `FUSION_DASHBOARD_DEEP=1 pnpm --filter @fusion/dashboard exec vitest run src/__tests__/routes-tasks-near-duplicate.test.ts -t 'PATCH external-checkout' --project dashboard-api --silent=passed-only --reporter=dot` - `pnpm --filter @fusion/engine typecheck` - `pnpm --filter @fusion/dashboard exec tsc --noEmit` - `pnpm verify:fast` - `pnpm test:gate` (605 non-PostgreSQL tests pass; local PostgreSQL suites cannot authenticate because the configured client returns an empty password) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added support for routing task execution and review through operator-selected external Git checkouts. * External checkouts are validated for valid Git repositories, attached branches, clean status, and branch consistency. * Tasks can clear previously configured external checkout routing. * Valid routed checkouts are used directly without creating a separate worktree. * **Bug Fixes** * Invalid, incomplete, dirty, or mismatched checkout configurations now fail early with clear validation errors. * Missing tasks return the appropriate not-found response. <!-- end of auto-generated comment: release notes by coderabbit.ai --> Co-authored-by: gsxdsm <gsxdsm@users.noreply.github.com>
This commit is contained in:
7
.changeset/quiet-external-checkouts.md
Normal file
7
.changeset/quiet-external-checkouts.md
Normal file
@@ -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.
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import * as core from "@fusion/core";
|
import * as core from "@fusion/core";
|
||||||
|
import * as engine from "@fusion/engine";
|
||||||
import type { Column, Task, TaskStore } from "@fusion/core";
|
import type { Column, Task, TaskStore } from "@fusion/core";
|
||||||
import { registerTaskWorkflowRoutes } from "../routes/register-task-workflow-routes.js";
|
import { registerTaskWorkflowRoutes } from "../routes/register-task-workflow-routes.js";
|
||||||
import { request as performRequest } from "../test-request.js";
|
import { request as performRequest } from "../test-request.js";
|
||||||
@@ -62,6 +63,7 @@ function buildApp(seed: Task[]) {
|
|||||||
tasks[index] = next;
|
tasks[index] = next;
|
||||||
return next;
|
return next;
|
||||||
}),
|
}),
|
||||||
|
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||||
recordActivity: vi.fn().mockResolvedValue(undefined),
|
recordActivity: vi.fn().mockResolvedValue(undefined),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -339,4 +341,59 @@ describe("routes /api/tasks near duplicate", () => {
|
|||||||
nearDuplicateDismissed: true,
|
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ import {
|
|||||||
prepareRevertPrBranch,
|
prepareRevertPrBranch,
|
||||||
prepareWorkspaceRevertPrBranches,
|
prepareWorkspaceRevertPrBranches,
|
||||||
isInReviewMissingWorktreeSessionStartFailure,
|
isInReviewMissingWorktreeSessionStartFailure,
|
||||||
|
inspectExternalGitCheckout,
|
||||||
// FN-8004 follow-up: shared with SelfHealingManager.recoverStaleMergingStatus so the manual
|
// 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.
|
// Retry gate and the automatic sweep agree on when a merge-active stamp is orphaned.
|
||||||
isStaleMergeActiveStatus,
|
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
|
// Patch a task's custom field values (U13/KTD-14). Delegates to the single
|
||||||
// store write authority (`updateTaskCustomFields`), which validates the patch
|
// store write authority (`updateTaskCustomFields`), which validates the patch
|
||||||
// against the task's workflow field schema. A typed rejection surfaces as a
|
// against the task's workflow field schema. A typed rejection surfaces as a
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
|||||||
import "./executor-test-helpers.js";
|
import "./executor-test-helpers.js";
|
||||||
import { getBuiltinWorkflow } from "@fusion/core";
|
import { getBuiltinWorkflow } from "@fusion/core";
|
||||||
import { TaskExecutor } from "../executor.js";
|
import { TaskExecutor } from "../executor.js";
|
||||||
|
import { resolveExternalExecutionCheckoutRoute } from "../execution/external-execution-checkout.js";
|
||||||
import { WorkflowGraphTaskRunner } from "../workflows/workflow-graph-task-runner.js";
|
import { WorkflowGraphTaskRunner } from "../workflows/workflow-graph-task-runner.js";
|
||||||
import { FOREACH_ACTIVE_CONTEXT_KEY } from "../workflows/workflow-node-handlers.js";
|
import { FOREACH_ACTIVE_CONTEXT_KEY } from "../workflows/workflow-node-handlers.js";
|
||||||
import {
|
import {
|
||||||
@@ -20,6 +21,12 @@ import {
|
|||||||
resetExecutorMocks,
|
resetExecutorMocks,
|
||||||
} from "./executor-test-helpers.js";
|
} 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";
|
const now = "2026-06-10T00:00:00.000Z";
|
||||||
|
|
||||||
function task(overrides: Record<string, unknown> = {}) {
|
function task(overrides: Record<string, unknown> = {}) {
|
||||||
@@ -70,6 +77,8 @@ function workflowResult() {
|
|||||||
describe("fast mode workflow/runtime invariants", () => {
|
describe("fast mode workflow/runtime invariants", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resetExecutorMocks();
|
resetExecutorMocks();
|
||||||
|
mockedResolveExternalExecutionCheckoutRoute.mockReset();
|
||||||
|
mockedResolveExternalExecutionCheckoutRoute.mockResolvedValue({ configured: false });
|
||||||
mockedExistsSync.mockReturnValue(true);
|
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 () => {
|
it("does not project a fresh graph step or capture its baseline before the executor creates its worktree", async () => {
|
||||||
let liveTask = task({
|
let liveTask = task({
|
||||||
steps: [{ name: "Preflight", status: "pending" }],
|
steps: [{ name: "Preflight", status: "pending" }],
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
118
packages/engine/src/execution/external-execution-checkout.ts
Normal file
118
packages/engine/src/execution/external-execution-checkout.ts
Normal file
@@ -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<string, unknown> | undefined {
|
||||||
|
if (!task || typeof task !== "object") return undefined;
|
||||||
|
const sourceMetadata = (task as Record<string, unknown>).sourceMetadata;
|
||||||
|
return sourceMetadata && typeof sourceMetadata === "object"
|
||||||
|
? sourceMetadata as Record<string, unknown>
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function inspectExternalGitCheckout(
|
||||||
|
candidate: unknown,
|
||||||
|
options: { requireClean?: boolean } = {},
|
||||||
|
): Promise<ExternalGitCheckoutInspection> {
|
||||||
|
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<ExternalExecutionCheckoutResolution> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ import { WorkflowAgentCapacity } from "./agents/workflow-agent-capacity.js";
|
|||||||
import { createExecutorColumnBoundaryHooks } from "./workflow-column-boundary-hooks.js";
|
import { createExecutorColumnBoundaryHooks } from "./workflow-column-boundary-hooks.js";
|
||||||
import { ensureWorkflowCompletionSummary } from "./workflows/workflow-completion-summary.js";
|
import { ensureWorkflowCompletionSummary } from "./workflows/workflow-completion-summary.js";
|
||||||
import { createCodeNodeRunner } from "./execution/code-node-runner.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 { getTaskReviewCheckoutPath, resolveReviewCheckoutCwd } from "./execution/review-checkout.js";
|
||||||
import { getActiveNotificationService } from "./util/notifier.js";
|
import { getActiveNotificationService } from "./util/notifier.js";
|
||||||
import type { ParseStepsHandlerDeps, CodeNodeRunner } from "./workflows/workflow-node-handlers.js";
|
import type { ParseStepsHandlerDeps, CodeNodeRunner } from "./workflows/workflow-node-handlers.js";
|
||||||
@@ -9007,6 +9008,14 @@ export class TaskExecutor {
|
|||||||
prepareWorktree: async (_ctx, task) => {
|
prepareWorktree: async (_ctx, task) => {
|
||||||
const live = await this.store.getTask(task.id).catch(() => null);
|
const live = await this.store.getTask(task.id).catch(() => null);
|
||||||
const liveTask = live?.id === task.id ? live : 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:
|
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.
|
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.
|
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 = {
|
const prepared: PreparedWorktree = {
|
||||||
worktreePath: liveTask?.worktree || task.worktree || "",
|
worktreePath: externalRoute.configured
|
||||||
branchName: liveTask?.branch || task.branch,
|
? externalRoute.checkoutPath ?? ""
|
||||||
|
: liveTask?.worktree || task.worktree || "",
|
||||||
|
branchName: externalRoute.configured
|
||||||
|
? externalRoute.branch
|
||||||
|
: liveTask?.branch || task.branch,
|
||||||
};
|
};
|
||||||
return { outcome: "success", value: "worktree-ready", data: prepared };
|
return { outcome: "success", value: "worktree-ready", data: prepared };
|
||||||
},
|
},
|
||||||
@@ -14092,6 +14105,12 @@ export class TaskExecutor {
|
|||||||
// Behavior-inert when nothing is customized (declaration defaults === legacy
|
// Behavior-inert when nothing is customized (declaration defaults === legacy
|
||||||
// defaults; absent-default lanes never override).
|
// defaults; absent-default lanes never override).
|
||||||
const settings = await mergeEffectiveSettings(this.store, task, await this.store.getSettings());
|
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.
|
// Keep runtime plugin workflow step templates synchronized into TaskStore.
|
||||||
// TaskStore resolves plugin-prefixed workflow IDs from this injected cache
|
// 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(
|
executorLog.error(
|
||||||
`${task.id}: drift detected — task is in-progress with no worktree. ` +
|
`${task.id}: drift detected — task is in-progress with no worktree. ` +
|
||||||
`Recovering by creating a fresh worktree. This usually indicates a partial ` +
|
`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
|
// 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
|
// 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).
|
// 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();
|
const taskCommandAbortController = new AbortController();
|
||||||
this.registerConfiguredCommandController(task.id, taskCommandAbortController);
|
this.registerConfiguredCommandController(task.id, taskCommandAbortController);
|
||||||
/*
|
/*
|
||||||
@@ -14348,6 +14369,14 @@ export class TaskExecutor {
|
|||||||
hydrated: true,
|
hydrated: true,
|
||||||
isResume: Boolean(task.sessionFile),
|
isResume: Boolean(task.sessionFile),
|
||||||
}
|
}
|
||||||
|
: externalExecutionRoute.configured
|
||||||
|
? {
|
||||||
|
worktreePath: externalExecutionRoute.checkoutPath ?? "",
|
||||||
|
branch: externalExecutionRoute.branch ?? "",
|
||||||
|
source: "existing",
|
||||||
|
hydrated: true,
|
||||||
|
isResume: Boolean(task.sessionFile),
|
||||||
|
}
|
||||||
: await (async () => {
|
: await (async () => {
|
||||||
try {
|
try {
|
||||||
return await acquireTaskWorktree({
|
return await acquireTaskWorktree({
|
||||||
@@ -14443,7 +14472,7 @@ export class TaskExecutor {
|
|||||||
FNXC:Workspace 2026-06-21-12:00:
|
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.
|
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
|
// Capture the base commit SHA for diff computation whenever a task
|
||||||
// starts with a newly assigned worktree.
|
// starts with a newly assigned worktree.
|
||||||
if (!acquisition.isResume) {
|
if (!acquisition.isResume) {
|
||||||
@@ -18148,9 +18177,24 @@ export class TaskExecutor {
|
|||||||
}
|
}
|
||||||
return { ok: true };
|
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.
|
// 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) {
|
if (!worktreePath) {
|
||||||
return {
|
return {
|
||||||
@@ -18198,12 +18242,13 @@ export class TaskExecutor {
|
|||||||
if (observedTopLevelRaw) {
|
if (observedTopLevelRaw) {
|
||||||
const observedTopLevel = canonicalizePath(observedTopLevelRaw);
|
const observedTopLevel = canonicalizePath(observedTopLevelRaw);
|
||||||
|
|
||||||
if (
|
const violatesCheckoutBoundary = externalExecutionRoute.configured
|
||||||
observedTopLevel === expectedRoot ||
|
? observedTopLevel !== expectedWorktreeRealpath
|
||||||
!isInsideWorktreesDir(this.rootDir, observedTopLevel, settings) ||
|
: observedTopLevel === expectedRoot
|
||||||
observedTopLevel !== expectedWorktreeRealpath
|
|| !isInsideWorktreesDir(this.rootDir, observedTopLevel, settings)
|
||||||
) {
|
|| observedTopLevel !== expectedWorktreeRealpath;
|
||||||
if (allowReanchor && observedTopLevel !== expectedRoot && isInsideWorktreesDir(this.rootDir, observedTopLevel, settings)) {
|
if (violatesCheckoutBoundary) {
|
||||||
|
if (!externalExecutionRoute.configured && allowReanchor && observedTopLevel !== expectedRoot && isInsideWorktreesDir(this.rootDir, observedTopLevel, settings)) {
|
||||||
const reanchor = await detectNestedWorktreeRoot(this.rootDir, worktreePath, settings);
|
const reanchor = await detectNestedWorktreeRoot(this.rootDir, worktreePath, settings);
|
||||||
if (reanchor.reanchored) {
|
if (reanchor.reanchored) {
|
||||||
await this.store.updateTask(task.id, { worktree: reanchor.root });
|
await this.store.updateTask(task.id, { worktree: reanchor.root });
|
||||||
|
|||||||
@@ -482,6 +482,12 @@ export {
|
|||||||
type SquashAuditRecentMainCommit,
|
type SquashAuditRecentMainCommit,
|
||||||
} from "./merge/merger-squash-audit.js";
|
} from "./merge/merger-squash-audit.js";
|
||||||
export { reviewStep, type ReviewType, type ReviewVerdict, type ReviewResult, type ReviewOptions } from "./execution/reviewer.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 { createFnAgent, promptWithFallback, describeModel, setHostExtensionPaths, getHostExtensionPaths, wrapToolsWithActionGate, type AgentOptions, type AgentResult } from "./pi.js";
|
||||||
export { resolveMcpServersForRuntime, resolveMcpServersForStore, type ResolvedMcpServersForRuntime } from "./mcp/mcp-resolution.js";
|
export { resolveMcpServersForRuntime, resolveMcpServersForStore, type ResolvedMcpServersForRuntime } from "./mcp/mcp-resolution.js";
|
||||||
export { discoverMcpServers, type DiscoverMcpServersOptions, type DiscoverMcpServersResult } from "./mcp/mcp-discovery-service.js";
|
export { discoverMcpServers, type DiscoverMcpServersOptions, type DiscoverMcpServersResult } from "./mcp/mcp-discovery-service.js";
|
||||||
|
|||||||
Reference in New Issue
Block a user