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 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, unknown> = {}) {
|
||||
@@ -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" }],
|
||||
|
||||
@@ -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 { 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 });
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user