FN-6922: guard worktree acquisition returns from repo root

Prevent task worktree acquisition from handing the project root back to executors.

- Add a repo-root return guard across resume, pool, and fresh acquisition paths.
- Clear invalid repo-root task assignments and create a fresh worktree fallback when safe.
- Share repo-root canonicalization and expand regression coverage for acquisition liveness.
- Document the acquisition guard and add a patch changeset.

Files changed:
 .changeset/fn-6922-repo-root-acquisition-guard.md  |   5 +
 .../repo-root-task-worktree-requeue-loop.md        |   9 +-
 .../__tests__/executor-worktree-liveness.test.ts   |  35 +++
 .../worktree-acquisition-secrets-env.test.ts       |   9 +-
 .../src/__tests__/worktree-acquisition.test.ts     |  91 ++++++-
 packages/engine/src/worktree-acquisition.ts        | 272 +++++++++++++--------
 packages/engine/src/worktree-pool.ts               |   8 +-
 7 files changed, 304 insertions(+), 125 deletions(-)

Fusion-Task-Id: FN-6922

Fusion-Task-Lineage: c16cee1b-de16-4d5f-90e0-132f94fa8377
This commit is contained in:
gsxdsm
2026-06-22 19:11:08 -07:00
parent 0b123b3f4f
commit ec1d29e818
7 changed files with 304 additions and 125 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Prevent task worktree acquisition from returning the project repository root by enforcing a non-root postcondition across resume, pooled, and fresh checkout paths.

View File

@@ -14,7 +14,7 @@ resolution_type: code_fix
severity: high severity: high
related_components: related_components:
- "packages/engine/src/worktree-pool.ts (classifyTaskWorktree)" - "packages/engine/src/worktree-pool.ts (classifyTaskWorktree)"
- "packages/engine/src/worktree-acquisition.ts (resume fallback)" - "packages/engine/src/worktree-acquisition.ts (resume fallback + return guard)"
- "packages/engine/src/executor.ts (pre-session liveness gate)" - "packages/engine/src/executor.ts (pre-session liveness gate)"
tags: tags:
- worktrees - worktrees
@@ -34,7 +34,9 @@ A recovered task can carry `task.worktree` that canonicalizes to the project rep
Make the invariant explicit at the shared classification boundary: the project root is never a usable task worktree. `classifyTaskWorktree` now compares canonicalized paths and returns `classification: "repo-root"` for root-equal paths even when Git reports the path as registered. Make the invariant explicit at the shared classification boundary: the project root is never a usable task worktree. `classifyTaskWorktree` now compares canonicalized paths and returns `classification: "repo-root"` for root-equal paths even when Git reports the path as registered.
Because `acquireTaskWorktree` already treats non-usable resume classifications as self-healable stale metadata, a root-valued `task.worktree` is cleared and replaced with a fresh checkout under the configured worktrees directory. The executor liveness gate remains defense-in-depth and emits structured `worktree:incomplete-detected` evidence if a repo-root path still reaches it. Because `acquireTaskWorktree` already treats non-usable resume classifications as self-healable stale metadata, a root-valued `task.worktree` is cleared and replaced with a fresh checkout under the configured worktrees directory. FN-6922 adds the same invariant as an acquisition return postcondition: every existing, pooled, and fresh-created return path is checked immediately before returning to executor/heartbeat callers. If a return candidate canonicalizes to the project root, acquisition emits `worktree:incomplete-detected` with `source: "acquire-return-guard"`, clears worktree metadata, and attempts one fresh checkout; if the fresh checkout is also root-equal, it throws `RepoRootWorktreeError` instead of returning the root.
The executor liveness gate remains defense-in-depth and emits structured `worktree:incomplete-detected` evidence if a repo-root path still reaches it.
## Verification ## Verification
@@ -42,8 +44,9 @@ Cover the invariant at three seams:
- Classification: real Git repo root registered in `git worktree list` must classify as `repo-root`, including canonical-equal variants such as trailing slashes or symlink-normalized paths. - Classification: real Git repo root registered in `git worktree list` must classify as `repo-root`, including canonical-equal variants such as trailing slashes or symlink-normalized paths.
- Acquisition: resume with `task.worktree === rootDir` must return a fresh `.worktrees/*` (or configured worktrees-dir) checkout and must not return the root. - Acquisition: resume with `task.worktree === rootDir` must return a fresh `.worktrees/*` (or configured worktrees-dir) checkout and must not return the root.
- Acquisition return guard: even if a classifier mock/regression marks a root path usable, or if a custom fresh backend returns the root, `acquireTaskWorktree` must either self-heal to a non-root checkout or throw `RepoRootWorktreeError`.
- Executor diagnostics: if the root reaches the pre-session liveness gate, the audit payload must identify `classification: "repo-root"`, the observed path, the registered snapshot, and that the expected task-worktree pattern excludes the root. - Executor diagnostics: if the root reaches the pre-session liveness gate, the audit payload must identify `classification: "repo-root"`, the observed path, the registered snapshot, and that the expected task-worktree pattern excludes the root.
## Prevention ## Prevention
Registered Git worktree membership is necessary but not sufficient for task execution. Any new worktree-liveness or self-healing path should call the shared classifier and preserve the distinction between the main checkout (`repo-root`) and isolated task checkouts under the configured worktrees directory. Registered Git worktree membership is necessary but not sufficient for task execution. Any new worktree-liveness or self-healing path should call the shared classifier and preserve the distinction between the main checkout (`repo-root`) and isolated task checkouts under the configured worktrees directory. Any new `acquireTaskWorktree` return branch must also flow through the return guard so branch-local checks cannot be the only line of defense.

View File

@@ -57,6 +57,13 @@ describe("FN-4114 worktree liveness assertion", () => {
}); });
it("FN-6861 aborts with structured audit when worktree realpath collides with repo root", async () => { it("FN-6861 aborts with structured audit when worktree realpath collides with repo root", async () => {
vi.spyOn(worktreeAcquisition, "acquireTaskWorktree").mockResolvedValue({
worktreePath: "/repo",
branch: "fusion/fn-4114",
source: "existing",
hydrated: true,
isResume: true,
});
vi.spyOn(worktreePool, "classifyTaskWorktree").mockResolvedValue({ ok: true }); vi.spyOn(worktreePool, "classifyTaskWorktree").mockResolvedValue({ ok: true });
vi.spyOn(worktreePool, "describeRegisteredWorktrees").mockResolvedValue({ vi.spyOn(worktreePool, "describeRegisteredWorktrees").mockResolvedValue({
rawOutput: "worktree /repo\nworktree /repo/.worktrees/swift-falcon\n", rawOutput: "worktree /repo\nworktree /repo/.worktrees/swift-falcon\n",
@@ -93,6 +100,34 @@ describe("FN-4114 worktree liveness assertion", () => {
})); }));
}); });
it("FN-6922 proceeds when acquisition self-heals a repo-root assignment to a fresh worktree", async () => {
vi.spyOn(worktreeAcquisition, "acquireTaskWorktree").mockResolvedValue({
worktreePath: "/repo/.worktrees/fn-6922-fresh",
branch: "fusion/fn-4114",
source: "fresh",
hydrated: true,
isResume: false,
});
vi.spyOn(worktreePool, "classifyTaskWorktree").mockResolvedValue({ ok: false, classification: "repo-root", reason: "would have been root before acquisition guard" });
const store = createMockStore();
store.recordRunAuditEvent = vi.fn().mockResolvedValue(undefined);
store.getTask.mockResolvedValue(task({ worktree: "/repo", sessionFile: null }));
mockedCreateFnAgent.mockImplementation(async () => ({
session: { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn() },
}) as any);
const executor = new TaskExecutor(store as any, "/repo");
await executor.execute(task({ worktree: "/repo", sessionFile: null }) as any);
expect(mockedCreateFnAgent).toHaveBeenCalled();
expect(store.moveTask).not.toHaveBeenCalledWith("FN-4114", "todo", { preserveProgress: true });
expect(store.recordRunAuditEvent).not.toHaveBeenCalledWith(expect.objectContaining({
mutationType: "worktree:incomplete-detected",
metadata: expect.objectContaining({ classification: "repo-root", source: "executor-liveness-gate" }),
}));
});
it.each([ it.each([
{ name: "default worktreesDir", settings: {}, outsidePath: "/repo/not-a-worktree" }, { name: "default worktreesDir", settings: {}, outsidePath: "/repo/not-a-worktree" },
{ name: "absolute worktreesDir", settings: { worktreesDir: "/custom/trees" }, outsidePath: "/repo/not-a-worktree" }, { name: "absolute worktreesDir", settings: { worktreesDir: "/custom/trees" }, outsidePath: "/repo/not-a-worktree" },

View File

@@ -1,3 +1,5 @@
import { dirname } from "node:path";
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
const { writeSecretsEnvFile } = vi.hoisted(() => ({ writeSecretsEnvFile: vi.fn() })); const { writeSecretsEnvFile } = vi.hoisted(() => ({ writeSecretsEnvFile: vi.fn() }));
@@ -60,9 +62,12 @@ describe("worktree-acquisition secrets env hook", () => {
}); });
it("does not call writer for existing resume", async () => { it("does not call writer for existing resume", async () => {
const existingWorktree = process.cwd();
const projectRoot = dirname(existingWorktree);
await acquireTaskWorktree({ await acquireTaskWorktree({
task: { ...task, branch: "fusion/fn-1", worktree: process.cwd() }, task: { ...task, branch: "fusion/fn-1", worktree: existingWorktree },
rootDir: process.cwd(), rootDir: projectRoot,
store, store,
settings: { secretsEnv: { enabled: true } } as any, settings: { secretsEnv: { enabled: true } } as any,
createWorktree: vi.fn(), createWorktree: vi.fn(),

View File

@@ -2,9 +2,9 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { execSync } from "node:child_process"; import { execSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { dirname, join } from "node:path";
import { promisify } from "node:util"; import { promisify } from "node:util";
import { acquireTaskWorktree } from "../worktree-acquisition.js"; import { acquireTaskWorktree, RepoRootWorktreeError } from "../worktree-acquisition.js";
import { classifyTaskWorktree, PoolDoubleLeaseError } from "../worktree-pool.js"; import { classifyTaskWorktree, PoolDoubleLeaseError } from "../worktree-pool.js";
import * as desktopArtifacts from "../worktree-desktop-artifacts.js"; import * as desktopArtifacts from "../worktree-desktop-artifacts.js";
import * as branchConflicts from "../branch-conflicts.js"; import * as branchConflicts from "../branch-conflicts.js";
@@ -87,15 +87,16 @@ describe("acquireTaskWorktree", () => {
}); });
it("reuses existing usable worktree", async () => { it("reuses existing usable worktree", async () => {
const worktreePath = process.cwd();
const result = await acquireTaskWorktree({ const result = await acquireTaskWorktree({
task: { ...task, worktree: process.cwd(), branch: "fusion/fn-1" }, task: { ...task, worktree: worktreePath, branch: "fusion/fn-1" },
rootDir: process.cwd(), rootDir: dirname(worktreePath),
store, store,
settings: {}, settings: {},
createWorktree: vi.fn(), createWorktree: vi.fn(),
}); });
expect(result.source).toBe("existing"); expect(result.source).toBe("existing");
expect(result.worktreePath).toBe(process.cwd()); expect(result.worktreePath).toBe(worktreePath);
}); });
// Regression: FN-5475 — when a resumed worktree's branch was created from // Regression: FN-5475 — when a resumed worktree's branch was created from
@@ -111,9 +112,10 @@ describe("acquireTaskWorktree", () => {
nonAttributedCount: 0, nonAttributedCount: 0,
}); });
const worktreePath = process.cwd();
const result = await acquireTaskWorktree({ const result = await acquireTaskWorktree({
task: { ...task, worktree: process.cwd(), branch: "fusion/fn-1" }, task: { ...task, worktree: worktreePath, branch: "fusion/fn-1" },
rootDir: process.cwd(), rootDir: dirname(worktreePath),
store, store,
settings: {}, settings: {},
audit, audit,
@@ -129,9 +131,10 @@ describe("acquireTaskWorktree", () => {
}); });
it("does not re-anchor a resumed branch when not misbound", async () => { it("does not re-anchor a resumed branch when not misbound", async () => {
const worktreePath = process.cwd();
const result = await acquireTaskWorktree({ const result = await acquireTaskWorktree({
task: { ...task, worktree: process.cwd(), branch: "fusion/fn-1" }, task: { ...task, worktree: worktreePath, branch: "fusion/fn-1" },
rootDir: process.cwd(), rootDir: dirname(worktreePath),
store, store,
settings: {}, settings: {},
createWorktree: vi.fn(), createWorktree: vi.fn(),
@@ -328,6 +331,76 @@ describe("acquireTaskWorktree", () => {
expect(store.updateTask).toHaveBeenCalledWith("FN-1", { worktree: freshPath, branch: "fusion/fn-1" }); expect(store.updateTask).toHaveBeenCalledWith("FN-1", { worktree: freshPath, branch: "fusion/fn-1" });
}); });
it("FN-6922 rejects a canonical-equal resumed repo root before returning", async () => {
const rootDir = makeRepo();
const actualPool = await vi.importActual<typeof import("../worktree-pool.js")>("../worktree-pool.js");
vi.mocked(classifyTaskWorktree).mockImplementationOnce(actualPool.classifyTaskWorktree);
const freshPath = join(rootDir, ".worktrees", "fn-6922-trailing-slash");
const createWorktree = vi.fn().mockResolvedValue({ path: freshPath, branch: "fusion/fn-1" });
const result = await acquireTaskWorktree({
task: { ...task, worktree: `${rootDir}/`, branch: "fusion/fn-1", sessionFile: "/tmp/session.json" },
rootDir,
store,
settings: {} as any,
createWorktree,
});
expect(result.worktreePath).toBe(freshPath);
expect(result.worktreePath).not.toBe(rootDir);
expect(result.isResume).toBe(false);
expect(store.updateTask).toHaveBeenCalledWith("FN-1", { worktree: null, branch: null, sessionFile: null });
expect(store.updateTask).toHaveBeenCalledWith("FN-1", { worktree: freshPath, branch: "fusion/fn-1" });
});
it("FN-6922 self-heals when the return guard catches a mocked repo-root resume", async () => {
const rootDir = makeRepo();
vi.mocked(classifyTaskWorktree).mockResolvedValueOnce({ ok: true });
const freshPath = join(rootDir, ".worktrees", "fn-6922-guard-fresh");
const createWorktree = vi.fn().mockResolvedValue({ path: freshPath, branch: "fusion/fn-1" });
const auditGit = vi.fn().mockResolvedValue(undefined);
const result = await acquireTaskWorktree({
task: { ...task, worktree: rootDir, branch: "fusion/fn-1", sessionFile: "/tmp/session.json" },
rootDir,
store,
settings: {} as any,
createWorktree,
audit: { git: auditGit } as any,
});
expect(result).toMatchObject({ worktreePath: freshPath, source: "fresh", isResume: false });
expect(createWorktree).toHaveBeenCalledWith("fusion/fn-1", expect.stringContaining(`${join(rootDir, ".worktrees")}/`), "FN-1", undefined, false);
expect(auditGit).toHaveBeenCalledWith(expect.objectContaining({
type: "worktree:incomplete-detected",
target: rootDir,
metadata: expect.objectContaining({ classification: "repo-root", source: "acquire-return-guard", returnSource: "existing" }),
}));
expect(store.updateTask).toHaveBeenCalledWith("FN-1", { worktree: null, branch: null, sessionFile: null });
expect(store.updateTask).toHaveBeenCalledWith("FN-1", { worktree: freshPath, branch: "fusion/fn-1" });
});
it("FN-6922 throws a typed error when fresh creation returns the repo root", async () => {
const rootDir = makeRepo();
const auditGit = vi.fn().mockResolvedValue(undefined);
await expect(acquireTaskWorktree({
task: { ...task, worktree: null, branch: null },
rootDir,
store,
settings: {} as any,
createWorktree: vi.fn().mockResolvedValue({ path: rootDir, branch: "fusion/fn-1" }),
audit: { git: auditGit } as any,
})).rejects.toBeInstanceOf(RepoRootWorktreeError);
expect(auditGit).toHaveBeenCalledWith(expect.objectContaining({
type: "worktree:incomplete-detected",
target: rootDir,
metadata: expect.objectContaining({ classification: "repo-root", source: "acquire-return-guard", returnSource: "fresh" }),
}));
expect(store.updateTask).toHaveBeenCalledWith("FN-1", { worktree: null, branch: null, sessionFile: null });
});
it("falls through to fresh creation when pool acquire throws PoolDoubleLeaseError", async () => { it("falls through to fresh creation when pool acquire throws PoolDoubleLeaseError", async () => {
const createWorktree = vi.fn().mockResolvedValue({ path: "/tmp/new", branch: "fusion/fn-1" }); const createWorktree = vi.fn().mockResolvedValue({ path: "/tmp/new", branch: "fusion/fn-1" });
const result = await acquireTaskWorktree({ const result = await acquireTaskWorktree({

View File

@@ -11,6 +11,7 @@ import {
type WorktreePool, type WorktreePool,
classifyTaskWorktree, classifyTaskWorktree,
isInsideWorktreesDir, isInsideWorktreesDir,
isRepoRootPath,
removeWorktree, removeWorktree,
RemovalReason, RemovalReason,
PoolDoubleLeaseError, PoolDoubleLeaseError,
@@ -88,6 +89,13 @@ export interface AcquireTaskWorktreeResult {
type InitCommandResult = Awaited<ReturnType<NonNullable<AcquireTaskWorktreeOptions["runConfiguredCommand"]>>>; type InitCommandResult = Awaited<ReturnType<NonNullable<AcquireTaskWorktreeOptions["runConfiguredCommand"]>>>;
export class RepoRootWorktreeError extends Error {
constructor(public readonly taskId: string, public readonly rootDir: string, public readonly worktreePath: string, public readonly source: string) {
super(`Refusing to return repo root as task worktree for ${taskId}: ${worktreePath} (${source}) canonicalizes to ${rootDir}`);
this.name = "RepoRootWorktreeError";
}
}
const INIT_OUTCOME_MAX_CHARS = 2_000; const INIT_OUTCOME_MAX_CHARS = 2_000;
function configuredCommandErrorMessage(result: { spawnError?: string | Error; timedOut?: boolean; exitCode?: number | null }): string { function configuredCommandErrorMessage(result: { spawnError?: string | Error; timedOut?: boolean; exitCode?: number | null }): string {
@@ -239,6 +247,9 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro
} }
} }
let acquiredFromPool = false;
let branch = branchName;
const hydrate = async (path: string): Promise<boolean> => { const hydrate = async (path: string): Promise<boolean> => {
if (rootDir === path) return false; if (rootDir === path) return false;
try { try {
@@ -255,6 +266,155 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro
} }
}; };
const createWorktreeImpl = createWorktree
? createWorktree
: async (createBranch: string, createPath: string, createTaskId: string, startPoint?: string, allowRename?: boolean) => {
try {
const created = await backend.create({
rootDir,
branch: createBranch,
worktreePath: createPath,
startPoint,
taskId: createTaskId,
allowSiblingBranchRename: allowRename,
});
if (backend.kind === "worktrunk") {
await audit?.git({
type: "worktree:worktrunk-create",
target: created.path,
metadata: { branch: created.branch },
});
}
return created;
} catch (error) {
if (backend.kind === "worktrunk" && error instanceof WorktrunkOperationError) {
const nativeBackend = new NativeWorktreeBackend({ logger: logger ?? undefined });
const fallback = () => nativeBackend.create({
rootDir,
branch: createBranch,
worktreePath: createPath,
startPoint,
taskId: createTaskId,
allowSiblingBranchRename: allowRename,
});
return await handleWorktrunkFailure("create", error, fallback) as { path: string; branch: string };
}
throw error;
}
};
const emitRepoRootReturnGuardAudit = async (guardedPath: string, source: string) => {
await audit?.git({
type: "worktree:incomplete-detected",
target: guardedPath,
metadata: {
classification: "repo-root",
reason: "acquireTaskWorktree return path canonicalizes to the project root",
source: "acquire-return-guard",
returnSource: source,
taskId: task.id,
},
});
};
const finalizeCreatedWorktree = async (
created: { path: string; branch: string },
source: "fresh" | "pool",
logOrigin: "normal" | "return-guard",
): Promise<AcquireTaskWorktreeResult> => {
/*
* FNXC:WorktreeLiveness 2026-06-22-18:30:
* FN-6861 fixed the resume classifier path, but FN-6888 showed the repo root can still reach the executor through another acquisition return branch. FN-6922 makes acquisition itself enforce a return-value invariant: no resume, pool, or fresh branch may return the repo root, so the executor's realpath_matches_repo_root gate remains defense-in-depth instead of a requeue loop source.
*/
if (isRepoRootPath(rootDir, created.path)) {
await emitRepoRootReturnGuardAudit(created.path, source);
await store.updateTask(task.id, { worktree: null, branch: null, sessionFile: null });
throw new RepoRootWorktreeError(task.id, rootDir, created.path, `fresh-create:${logOrigin}`);
}
worktreePath = created.path;
branch = created.branch;
await store.updateTask(task.id, { worktree: created.path, branch: created.branch });
await audit?.git({ type: "worktree:create", target: created.path, metadata: { branch: created.branch, source: logOrigin === "return-guard" ? "acquire-return-guard" : undefined } });
await audit?.git({ type: "branch:create", target: created.branch });
if (created.branch !== branchName) {
logger?.log(`Branch conflict resolved: using ${created.branch} instead of ${branchName}`);
await store.logEntry(task.id, `Worktree created at ${worktreePath} (branch conflict: using ${created.branch})`, undefined, runContext);
} else if (baseBranch) {
await store.logEntry(task.id, `Worktree created at ${worktreePath} (based on ${baseBranch})`, undefined, runContext);
} else {
await store.logEntry(task.id, `Worktree created at ${worktreePath}`, undefined, runContext);
}
const cleanup = await removeDesktopBuildArtifacts(worktreePath, logger);
if (cleanup.removed.length > 0) {
await store.logEntry(task.id, `Removed desktop build artifacts from worktree: ${cleanup.removed.join(", ")}`, undefined, runContext);
}
if (runInitCommand && settings.worktreeInitCommand && runConfiguredCommand) {
const initStartedAt = Date.now();
let initResult: InitCommandResult | undefined;
try {
initResult = await runConfiguredCommand(settings.worktreeInitCommand, worktreePath, 300_000, taskEnv);
if (initResult.spawnError || initResult.timedOut || initResult.exitCode !== 0) {
throw new Error(configuredCommandErrorMessage(initResult));
}
await store.logEntry(task.id, `[timing] Worktree init command completed in ${Date.now() - initStartedAt}ms`, settings.worktreeInitCommand, runContext);
} catch (err) {
if (err instanceof Error && err.name === "AbortError") {
throw err;
}
await store.logEntry(task.id, `[timing] Worktree init command failed after ${Date.now() - initStartedAt}ms`, undefined, runContext);
const message = err instanceof Error ? err.message : String(err);
const outcome = formatInitFailureOutcome(initResult, err);
logger?.error?.(`${task.id}: worktree init command failed — first test run will likely fail: ${message} (stderr captured in task log outcome)`);
await store.logEntry(task.id, `Worktree init command failed (first test run will likely fail): ${message}`, outcome, runContext);
}
}
await maybeWarnForeignTaskStartPoint({
baseBranch,
rootDir,
worktreePath,
taskId: task.id,
logger,
store,
runContext,
});
const hydrated = await hydrate(worktreePath);
try {
await writeSecretsEnvFile({
rootDir,
worktreePath,
taskId: task.id,
settings,
worktreeSource: "fresh",
secretsStore,
audit,
logger,
});
} catch (err) {
logger?.warn?.(`${task.id}: secrets-env write failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
}
return { worktreePath, branch, source, hydrated, isResume: false };
};
const createFreshWorktreeFromReturnGuard = async (guardedPath: string, source: string): Promise<AcquireTaskWorktreeResult> => {
await emitRepoRootReturnGuardAudit(guardedPath, source);
logger?.warn(`${task.id}: acquisition ${source} returned repo root; clearing assignment and creating a fresh worktree`);
await store.logEntry(task.id, "Acquisition attempted to return the project root as a task worktree; creating a fresh worktree instead", guardedPath, runContext);
await store.updateTask(task.id, { worktree: null, branch: null, sessionFile: null });
const fallbackName = generateWorktreeName(rootDir, settings);
const fallbackPath = await resolveTaskWorktreePathForBackend(rootDir, fallbackName, settings, backend, branchName);
const created = await createWorktreeImpl(branchName, fallbackPath, task.id, baseBranch ?? undefined, allowSiblingBranchRename);
return finalizeCreatedWorktree(created, "fresh", "return-guard");
};
const guardAcquisitionReturn = async (result: AcquireTaskWorktreeResult): Promise<AcquireTaskWorktreeResult> => {
if (!isRepoRootPath(rootDir, result.worktreePath)) return result;
return createFreshWorktreeFromReturnGuard(result.worktreePath, result.source);
};
if (task.worktree && isResume) { if (task.worktree && isResume) {
logger?.log(`Reusing existing worktree: ${worktreePath}`); logger?.log(`Reusing existing worktree: ${worktreePath}`);
const cleanup = await removeDesktopBuildArtifacts(worktreePath, logger); const cleanup = await removeDesktopBuildArtifacts(worktreePath, logger);
@@ -274,12 +434,9 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro
runContext, runContext,
}); });
// FN-4912: resume path reuses the prior on-disk .env (and its fingerprint sidecar). Rewrite is owned by the next fresh acquisition. // FN-4912: resume path reuses the prior on-disk .env (and its fingerprint sidecar). Rewrite is owned by the next fresh acquisition.
return { worktreePath, branch: resumedBranch, source: "existing", hydrated, isResume: true }; return guardAcquisitionReturn({ worktreePath, branch: resumedBranch, source: "existing", hydrated, isResume: true });
} }
let acquiredFromPool = false;
let branch = branchName;
if (!isResume && pool && settings.recycleWorktrees) { if (!isResume && pool && settings.recycleWorktrees) {
let pooled: string | null = null; let pooled: string | null = null;
try { try {
@@ -379,7 +536,7 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro
} catch (err) { } catch (err) {
logger?.warn?.(`${task.id}: secrets-env write failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`); logger?.warn?.(`${task.id}: secrets-env write failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
} }
return { return guardAcquisitionReturn({
worktreePath, worktreePath,
branch, branch,
source: "pool", source: "pool",
@@ -391,7 +548,7 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro
strandedCommitCount: prepared.strandedCommitCount, strandedCommitCount: prepared.strandedCommitCount,
} }
: undefined, : undefined,
}; });
} }
} catch (poolErr) { } catch (poolErr) {
pool.release(pooled, task.id); pool.release(pooled, task.id);
@@ -407,112 +564,11 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro
} }
} }
const createWorktreeImpl = createWorktree
? createWorktree
: async (branch: string, path: string, taskId: string, startPoint?: string, allowRename?: boolean) => {
try {
const created = await backend.create({
rootDir,
branch,
worktreePath: path,
startPoint,
taskId,
allowSiblingBranchRename: allowRename,
});
if (backend.kind === "worktrunk") {
await audit?.git({
type: "worktree:worktrunk-create",
target: created.path,
metadata: { branch: created.branch },
});
}
return created;
} catch (error) {
if (backend.kind === "worktrunk" && error instanceof WorktrunkOperationError) {
const nativeBackend = new NativeWorktreeBackend({ logger: logger ?? undefined });
const fallback = () => nativeBackend.create({
rootDir,
branch,
worktreePath: path,
startPoint,
taskId,
allowSiblingBranchRename: allowRename,
});
return await handleWorktrunkFailure("create", error, fallback) as { path: string; branch: string };
}
throw error;
}
};
// Worktree removal in merger.ts, worktree-pool.ts, and self-healing.ts is now // Worktree removal in merger.ts, worktree-pool.ts, and self-healing.ts is now
// backend-mediated via WorktreeBackend.remove(). executor.ts and // backend-mediated via WorktreeBackend.remove(). executor.ts and
// step-session-executor.ts remain native-only paths (tracked separately). // step-session-executor.ts remain native-only paths (tracked separately).
const created = await createWorktreeImpl(branchName, worktreePath, task.id, baseBranch ?? undefined, allowSiblingBranchRename); const created = await createWorktreeImpl(branchName, worktreePath, task.id, baseBranch ?? undefined, allowSiblingBranchRename);
worktreePath = created.path; return finalizeCreatedWorktree(created, acquiredFromPool ? "pool" : "fresh", "normal");
branch = created.branch;
await store.updateTask(task.id, { worktree: created.path, branch: created.branch });
await audit?.git({ type: "worktree:create", target: created.path, metadata: { branch: created.branch } });
await audit?.git({ type: "branch:create", target: created.branch });
if (created.branch !== branchName) {
logger?.log(`Branch conflict resolved: using ${created.branch} instead of ${branchName}`);
await store.logEntry(task.id, `Worktree created at ${worktreePath} (branch conflict: using ${created.branch})`, undefined, runContext);
} else if (baseBranch) {
await store.logEntry(task.id, `Worktree created at ${worktreePath} (based on ${baseBranch})`, undefined, runContext);
} else {
await store.logEntry(task.id, `Worktree created at ${worktreePath}`, undefined, runContext);
}
const cleanup = await removeDesktopBuildArtifacts(worktreePath, logger);
if (cleanup.removed.length > 0) {
await store.logEntry(task.id, `Removed desktop build artifacts from worktree: ${cleanup.removed.join(", ")}`, undefined, runContext);
}
if (runInitCommand && settings.worktreeInitCommand && runConfiguredCommand) {
const initStartedAt = Date.now();
let initResult: InitCommandResult | undefined;
try {
initResult = await runConfiguredCommand(settings.worktreeInitCommand, worktreePath, 300_000, taskEnv);
if (initResult.spawnError || initResult.timedOut || initResult.exitCode !== 0) {
throw new Error(configuredCommandErrorMessage(initResult));
}
await store.logEntry(task.id, `[timing] Worktree init command completed in ${Date.now() - initStartedAt}ms`, settings.worktreeInitCommand, runContext);
} catch (err) {
if (err instanceof Error && err.name === "AbortError") {
throw err;
}
await store.logEntry(task.id, `[timing] Worktree init command failed after ${Date.now() - initStartedAt}ms`, undefined, runContext);
const message = err instanceof Error ? err.message : String(err);
const outcome = formatInitFailureOutcome(initResult, err);
logger?.error?.(`${task.id}: worktree init command failed — first test run will likely fail: ${message} (stderr captured in task log outcome)`);
await store.logEntry(task.id, `Worktree init command failed (first test run will likely fail): ${message}`, outcome, runContext);
}
}
await maybeWarnForeignTaskStartPoint({
baseBranch,
rootDir,
worktreePath,
taskId: task.id,
logger,
store,
runContext,
});
const hydrated = await hydrate(worktreePath);
try {
await writeSecretsEnvFile({
rootDir,
worktreePath,
taskId: task.id,
settings,
worktreeSource: "fresh",
secretsStore,
audit,
logger,
});
} catch (err) {
logger?.warn?.(`${task.id}: secrets-env write failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
}
return { worktreePath, branch, source: acquiredFromPool ? "pool" : "fresh", hydrated, isResume: false };
} }
/** /**

View File

@@ -74,6 +74,10 @@ export function canonicalizePath(path: string): string {
} }
} }
export function isRepoRootPath(rootDir: string, candidate: string): boolean {
return canonicalizePath(rootDir) === canonicalizePath(candidate);
}
function getExecStdout(result: unknown): string { function getExecStdout(result: unknown): string {
if (typeof result === "string") return result; if (typeof result === "string") return result;
if (result && typeof result === "object" && "stdout" in result) { if (result && typeof result === "object" && "stdout" in result) {
@@ -268,13 +272,11 @@ export async function classifyTaskWorktree(rootDir: string, worktreePath: string
return { ok: false, classification: "missing", reason: "worktree directory does not exist" }; return { ok: false, classification: "missing", reason: "worktree directory does not exist" };
} }
const canonicalRootDir = canonicalizePath(rootDir);
const canonicalWorktreePath = canonicalizePath(worktreePath);
/* /*
* FNXC:WorktreeLiveness 2026-06-21-11:10: * FNXC:WorktreeLiveness 2026-06-21-11:10:
* The project root is a legitimately registered git worktree, but it is never a usable task worktree. Tasks must execute inside the configured worktrees directory, so classification rejects root-equal paths here to stop the resume↔executor-gate requeue loop observed in FN-6861/FN-6709. * The project root is a legitimately registered git worktree, but it is never a usable task worktree. Tasks must execute inside the configured worktrees directory, so classification rejects root-equal paths here to stop the resume↔executor-gate requeue loop observed in FN-6861/FN-6709.
*/ */
if (canonicalWorktreePath === canonicalRootDir) { if (isRepoRootPath(rootDir, worktreePath)) {
return { ok: false, classification: "repo-root", reason: "worktree path is the project root, not a task worktree" }; return { ok: false, classification: "repo-root", reason: "worktree path is the project root, not a task worktree" };
} }