FN-5772: re-anchor nested task worktree paths
Fix executor worktree invariant handling by re-anchoring nested task worktree paths to the actual git top-level. - add nested worktree root detection that only re-anchors when the top-level is a registered worktree inside the configured worktrees directory - update executor liveness gating and verifyWorktreeInvariants to persist re-anchored task.worktree values and retry checks safely - emit a new run-audit git mutation (worktree:reanchored) and add reliability tests/docs coverage plus a patch changeset Files changed: .changeset/fn-5772-worktree-reanchor.md | 7 +++ docs/architecture.md | 4 +- packages/engine/src/__tests__/reliability-interactions/executor-liveness-gate.test.ts | 33 ++++++++++ packages/engine/src/__tests__/verify-worktree-invariants-missing.test.ts | 62 +++++++++++++++++- packages/engine/src/__tests__/worktree-reanchor-nested-root.test.ts | 73 ++++++++++++++++++++++ packages/engine/src/executor.ts | 54 ++++++++++++++-- packages/engine/src/run-audit.ts | 1 + packages/engine/src/worktree-pool.ts | 63 +++++++++++++++++++ 8 files changed, 290 insertions(+), 7 deletions(-) Fusion-Task-Id: FN-5772 Fusion-Task-Lineage: 475de161-7bc1-4359-bb76-20c4c07196d9
This commit is contained in:
@@ -103,6 +103,39 @@ describe("reliability interactions: FN-4935 executor liveness gate", () => {
|
||||
expect(events.some((event) => (event.type === "worktree:incomplete-detected" || event.mutationType === "worktree:incomplete-detected") && event.metadata?.source === "executor-liveness-gate" && event.metadata?.terminalAction === "requeue-todo")).toBe(true);
|
||||
});
|
||||
|
||||
it("re-anchors nested subdir classification failures instead of requeueing", async () => {
|
||||
vi.spyOn(worktreeAcquisition, "acquireTaskWorktree").mockResolvedValue({
|
||||
worktreePath: "/repo/.worktrees/gentle-flame/packages/core",
|
||||
branch: "fusion/fn-4935-t",
|
||||
source: "existing",
|
||||
hydrated: true,
|
||||
isResume: true,
|
||||
});
|
||||
vi.spyOn(worktreePool, "classifyTaskWorktree").mockResolvedValue({ ok: false, classification: "incomplete", reason: "missing .git metadata" });
|
||||
const reanchorSpy = vi.spyOn(worktreePool, "detectNestedWorktreeRoot").mockResolvedValue({ reanchored: true, root: "/repo/.worktrees/gentle-flame" });
|
||||
mockedExecSync.mockImplementation((cmd: string) => {
|
||||
if (cmd.includes("rev-parse HEAD")) return Buffer.from("abc123\n");
|
||||
if (cmd.includes("rev-parse --show-toplevel")) return Buffer.from("/repo/.worktrees/gentle-flame\n");
|
||||
if (cmd.includes("rev-parse --abbrev-ref HEAD")) return Buffer.from("fusion/fn-4935-t\n");
|
||||
if (cmd.includes("rev-list --count")) return Buffer.from("1\n");
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
const store = createMockStore();
|
||||
const executor = new TaskExecutor(store as any, "/repo");
|
||||
await executor.execute(makeTask());
|
||||
|
||||
expect(reanchorSpy).toHaveBeenCalled();
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-4935-T", expect.objectContaining({ worktree: "/repo/.worktrees/gentle-flame" }));
|
||||
expect(store.logEntry).toHaveBeenCalledWith("FN-4935-T", expect.stringContaining("Re-anchored nested task.worktree"), undefined, expect.anything());
|
||||
expect(
|
||||
store.logEntry.mock.calls.some(
|
||||
(call: unknown[]) => call[0] === "FN-4935-T" && typeof call[1] === "string" && call[1].includes("not_usable_task_worktree"),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(mockedCreateFnAgent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
"missing",
|
||||
"incomplete",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import "./executor-test-helpers.js";
|
||||
import { TaskExecutor } from "../executor.js";
|
||||
import { createMockStore, mockedExistsSync, resetExecutorMocks } from "./executor-test-helpers.js";
|
||||
import { createMockStore, mockedExecSync, mockedExistsSync, resetExecutorMocks } from "./executor-test-helpers.js";
|
||||
|
||||
describe("FN-009: verifyWorktreeInvariants with missing worktree directory", () => {
|
||||
let executor: TaskExecutor;
|
||||
@@ -70,6 +70,66 @@ describe("FN-009: verifyWorktreeInvariants with missing worktree directory", ()
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
it("re-anchors nested task worktree to registered root and passes invariants", async () => {
|
||||
const task = {
|
||||
id: "FN-9004",
|
||||
title: "Test",
|
||||
description: "Test",
|
||||
column: "in-progress",
|
||||
worktree: "/repo/.worktrees/gentle-flame/packages/core",
|
||||
branch: "fusion/fn-9004",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
mockedExistsSync.mockReturnValue(true);
|
||||
mockedExecSync.mockImplementation((cmd: string) => {
|
||||
if (cmd.includes("rev-parse --show-toplevel")) return Buffer.from("/repo/.worktrees/gentle-flame\n");
|
||||
if (cmd.includes("worktree list --porcelain")) {
|
||||
return Buffer.from("worktree /repo\nbranch refs/heads/main\n\nworktree /repo/.worktrees/gentle-flame\nbranch refs/heads/fusion/fn-9004\n");
|
||||
}
|
||||
if (cmd.includes("rev-parse --abbrev-ref HEAD")) return Buffer.from("fusion/fn-9004\n");
|
||||
if (cmd.includes("rev-list --count")) return Buffer.from("1\n");
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
const result = await (executor as any).verifyWorktreeInvariants(task);
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-9004", { worktree: "/repo/.worktrees/gentle-flame" });
|
||||
});
|
||||
|
||||
it("preserves wrong_toplevel for non-reanchorable mismatch", async () => {
|
||||
const task = {
|
||||
id: "FN-9005",
|
||||
title: "Test",
|
||||
description: "Test",
|
||||
column: "in-progress",
|
||||
worktree: "/repo/.worktrees/gentle-flame/packages/core",
|
||||
branch: "fusion/fn-9005",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
mockedExistsSync.mockReturnValue(true);
|
||||
mockedExecSync.mockImplementation((cmd: string) => {
|
||||
if (cmd.includes("rev-parse --show-toplevel")) return Buffer.from("/repo\n");
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
const result = await (executor as any).verifyWorktreeInvariants(task);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.reason).toBe("wrong_toplevel");
|
||||
expect(store.updateTask).not.toHaveBeenCalledWith("FN-9005", expect.objectContaining({ worktree: expect.any(String) }));
|
||||
});
|
||||
|
||||
it("preserves validation failure when worktree path is null", async () => {
|
||||
const task = {
|
||||
id: "FN-9003",
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import "./executor-test-helpers.js";
|
||||
import { detectNestedWorktreeRoot } from "../worktree-pool.js";
|
||||
import { mockedExecSync, mockedExistsSync, resetExecutorMocks } from "./executor-test-helpers.js";
|
||||
describe("detectNestedWorktreeRoot", () => {
|
||||
beforeEach(() => {
|
||||
resetExecutorMocks();
|
||||
mockedExistsSync.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it("re-anchors when task worktree is a nested subdirectory of a registered worktree root", async () => {
|
||||
mockedExecSync.mockImplementation((cmd: string) => {
|
||||
if (cmd.includes("rev-parse --show-toplevel")) return Buffer.from("/repo/.worktrees/gentle-flame\n");
|
||||
if (cmd.includes("worktree list --porcelain")) {
|
||||
return Buffer.from("worktree /repo\nbranch refs/heads/main\n\nworktree /repo/.worktrees/gentle-flame\nbranch refs/heads/fusion/fn-1\n");
|
||||
}
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
const result = await detectNestedWorktreeRoot("/repo", "/repo/.worktrees/gentle-flame/packages/core");
|
||||
expect(result).toEqual({ reanchored: true, root: "/repo/.worktrees/gentle-flame" });
|
||||
});
|
||||
|
||||
it("does not re-anchor when git top-level is repo root", async () => {
|
||||
mockedExecSync.mockImplementation((cmd: string) => {
|
||||
if (cmd.includes("rev-parse --show-toplevel")) return Buffer.from("/repo\n");
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
const result = await detectNestedWorktreeRoot("/repo", "/repo/.worktrees/gentle-flame/packages/core");
|
||||
expect(result).toEqual({ reanchored: false, reason: "toplevel_is_repo_root" });
|
||||
});
|
||||
|
||||
it("does not re-anchor when top-level is outside configured worktrees dir", async () => {
|
||||
mockedExecSync.mockImplementation((cmd: string) => {
|
||||
if (cmd.includes("rev-parse --show-toplevel")) return Buffer.from("/tmp/other\n");
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
const result = await detectNestedWorktreeRoot("/repo", "/repo/.worktrees/gentle-flame/packages/core");
|
||||
expect(result).toEqual({ reanchored: false, reason: "toplevel_outside_configured_dir" });
|
||||
});
|
||||
|
||||
it("does not re-anchor when top-level worktree is not registered", async () => {
|
||||
mockedExecSync.mockImplementation((cmd: string) => {
|
||||
if (cmd.includes("rev-parse --show-toplevel")) return Buffer.from("/repo/.worktrees/gentle-flame\n");
|
||||
if (cmd.includes("worktree list --porcelain")) {
|
||||
return Buffer.from("worktree /repo\nbranch refs/heads/main\n\nworktree /repo/.worktrees/some-other\nbranch refs/heads/fusion/fn-2\n");
|
||||
}
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
const result = await detectNestedWorktreeRoot("/repo", "/repo/.worktrees/gentle-flame/packages/core");
|
||||
expect(result).toEqual({ reanchored: false, reason: "toplevel_not_registered_worktree" });
|
||||
});
|
||||
|
||||
it("does not re-anchor when worktree path is missing", async () => {
|
||||
mockedExistsSync.mockReturnValue(false);
|
||||
|
||||
const result = await detectNestedWorktreeRoot("/repo", "/repo/.worktrees/gentle-flame/packages/core");
|
||||
expect(result).toEqual({ reanchored: false, reason: "worktree_missing" });
|
||||
});
|
||||
|
||||
it("does not re-anchor when worktree path is already at top-level", async () => {
|
||||
mockedExecSync.mockImplementation((cmd: string) => {
|
||||
if (cmd.includes("rev-parse --show-toplevel")) return Buffer.from("/repo/.worktrees/gentle-flame\n");
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
const result = await detectNestedWorktreeRoot("/repo", "/repo/.worktrees/gentle-flame");
|
||||
expect(result).toEqual({ reanchored: false, reason: "already_at_toplevel" });
|
||||
});
|
||||
});
|
||||
@@ -46,7 +46,7 @@ import { resolveSandboxBackend } from "./sandbox/index.js";
|
||||
import type { SandboxBackend } from "./sandbox/types.js";
|
||||
import { ModelRegistry, SessionManager, type ToolDefinition, type AgentSession } from "@earendil-works/pi-coding-agent";
|
||||
import { PRIORITY_EXECUTE, type AgentSemaphore } from "./concurrency.js";
|
||||
import { RemovalReason, classifyTaskWorktree, describeRegisteredWorktrees, getRegisteredWorktreePaths, isGitRepository, isInsideWorktreesDir, isRegisteredGitWorktree, removeWorktree, type WorktreePool } from "./worktree-pool.js";
|
||||
import { RemovalReason, classifyTaskWorktree, describeRegisteredWorktrees, detectNestedWorktreeRoot, getRegisteredWorktreePaths, isGitRepository, isInsideWorktreesDir, isRegisteredGitWorktree, removeWorktree, type WorktreePool } from "./worktree-pool.js";
|
||||
import { attemptBranchAutocorrect } from "./branch-autocorrect.js";
|
||||
import { ActiveSessionWorktreeRemovalError } from "./worktree-backend.js";
|
||||
import {
|
||||
@@ -3380,9 +3380,18 @@ export class TaskExecutor {
|
||||
if (!livenessFailure && shouldGate) {
|
||||
const classification = await classifyTaskWorktree(this.rootDir, worktreePath);
|
||||
if (!classification.ok) {
|
||||
livenessClassification = classification.classification;
|
||||
livenessFailureReason = classification.reason;
|
||||
livenessFailure = `not_usable_task_worktree:${classification.classification}`;
|
||||
const reanchor = await detectNestedWorktreeRoot(this.rootDir, worktreePath, settings);
|
||||
if (reanchor.reanchored) {
|
||||
await this.store.updateTask(task.id, { worktree: reanchor.root });
|
||||
await this.store.logEntry(task.id, `Re-anchored nested task.worktree from ${worktreePath} to ${reanchor.root}`, undefined, this.getRunContextFor(task.id));
|
||||
await this.emitWorktreeReanchoredAudit(task.id, worktreePath, reanchor.root, "executor-liveness-gate");
|
||||
worktreePath = reanchor.root;
|
||||
observedWorktreeRealpath = canonicalizePath(reanchor.root);
|
||||
} else {
|
||||
livenessClassification = classification.classification;
|
||||
livenessFailureReason = classification.reason;
|
||||
livenessFailure = `not_usable_task_worktree:${classification.classification}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5916,6 +5925,7 @@ export class TaskExecutor {
|
||||
private async verifyWorktreeInvariants(
|
||||
task: Task,
|
||||
worktreePathOverride?: string,
|
||||
allowReanchor = true,
|
||||
): Promise<{ ok: true } | { ok: false; reason: "wrong_toplevel" | "wrong_branch" | "no_commits"; observed: string; expected: string }> {
|
||||
const settings = await this.store.getSettings();
|
||||
const branchName = task.branch || canonicalFusionBranchName(task.id);
|
||||
@@ -5972,6 +5982,16 @@ export class TaskExecutor {
|
||||
!isInsideWorktreesDir(this.rootDir, observedTopLevel, settings) ||
|
||||
observedTopLevel !== expectedWorktreeRealpath
|
||||
) {
|
||||
if (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 });
|
||||
executorLog.log(`${task.id}: re-anchored nested task.worktree ${worktreePath} -> ${reanchor.root}`);
|
||||
await this.store.logEntry(task.id, `Re-anchored nested task.worktree from ${worktreePath} to ${reanchor.root}`, undefined, this.getRunContextFor(task.id));
|
||||
await this.emitWorktreeReanchoredAudit(task.id, worktreePath, reanchor.root, "verify-worktree-invariants");
|
||||
return this.verifyWorktreeInvariants(task, reanchor.root, false);
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
reason: "wrong_toplevel",
|
||||
@@ -9167,6 +9187,32 @@ Backward compat fallback: if JSON is unavailable, you may still begin output wit
|
||||
return true;
|
||||
}
|
||||
|
||||
private async emitWorktreeReanchoredAudit(
|
||||
taskId: string,
|
||||
fromPath: string,
|
||||
toPath: string,
|
||||
source: "verify-worktree-invariants" | "executor-liveness-gate",
|
||||
): Promise<void> {
|
||||
const runContext = this.getRunContextFor(taskId);
|
||||
if (!runContext?.runId || !runContext.agentId) return;
|
||||
const auditor = createRunAuditor(this.store, {
|
||||
runId: runContext.runId,
|
||||
agentId: runContext.agentId,
|
||||
taskId,
|
||||
phase: "execute",
|
||||
});
|
||||
await auditor.git({
|
||||
type: "worktree:reanchored",
|
||||
target: toPath,
|
||||
metadata: {
|
||||
taskId,
|
||||
fromPath,
|
||||
toPath,
|
||||
source,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async emitStaleLockAudit(
|
||||
taskId: string,
|
||||
event:
|
||||
|
||||
@@ -93,6 +93,7 @@ export type GitMutationType =
|
||||
| "worktree:remove"
|
||||
| "worktree:reuse"
|
||||
| "worktree:incomplete-detected"
|
||||
| "worktree:reanchored"
|
||||
| "worktree:auto-recovered"
|
||||
/**
|
||||
* worktrunk run-audit metadata shape:
|
||||
|
||||
@@ -197,6 +197,69 @@ export type TaskWorktreeClassificationResult =
|
||||
| { ok: true }
|
||||
| { ok: false; classification: TaskWorktreeClassification; reason: string };
|
||||
|
||||
export type NestedWorktreeRootDetectionResult =
|
||||
| { reanchored: true; root: string }
|
||||
| { reanchored: false; reason: string };
|
||||
|
||||
export async function detectNestedWorktreeRoot(
|
||||
rootDir: string,
|
||||
worktreePath: string,
|
||||
settings?: Pick<Settings, "worktreesDir">,
|
||||
): Promise<NestedWorktreeRootDetectionResult> {
|
||||
if (!existsSync(worktreePath)) {
|
||||
return { reanchored: false, reason: "worktree_missing" };
|
||||
}
|
||||
|
||||
if (!isInsideWorktreesDir(rootDir, worktreePath, settings)) {
|
||||
return { reanchored: false, reason: "worktree_outside_configured_dir" };
|
||||
}
|
||||
|
||||
const canonicalRootDir = canonicalizePath(rootDir);
|
||||
const canonicalWorktreePath = canonicalizePath(worktreePath);
|
||||
|
||||
let topLevelRaw = "";
|
||||
try {
|
||||
const result = await execAsync("git rev-parse --show-toplevel", {
|
||||
cwd: worktreePath,
|
||||
encoding: "utf-8",
|
||||
timeout: 10_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
topLevelRaw = getExecStdout(result).trim();
|
||||
} catch (error) {
|
||||
return { reanchored: false, reason: `top_level_probe_failed:${error instanceof Error ? error.message : String(error)}` };
|
||||
}
|
||||
|
||||
if (!topLevelRaw) {
|
||||
return { reanchored: false, reason: "top_level_empty" };
|
||||
}
|
||||
|
||||
const canonicalTopLevel = canonicalizePath(topLevelRaw);
|
||||
if (canonicalTopLevel === canonicalWorktreePath) {
|
||||
return { reanchored: false, reason: "already_at_toplevel" };
|
||||
}
|
||||
|
||||
if (canonicalTopLevel === canonicalRootDir) {
|
||||
return { reanchored: false, reason: "toplevel_is_repo_root" };
|
||||
}
|
||||
|
||||
if (!isInsideWorktreesDir(rootDir, canonicalTopLevel, settings)) {
|
||||
return { reanchored: false, reason: "toplevel_outside_configured_dir" };
|
||||
}
|
||||
|
||||
const relFromTopLevel = relative(canonicalTopLevel, canonicalWorktreePath);
|
||||
const nestedUnderTopLevel = relFromTopLevel !== "" && !relFromTopLevel.startsWith("..") && !isAbsolute(relFromTopLevel);
|
||||
if (!nestedUnderTopLevel) {
|
||||
return { reanchored: false, reason: "not_nested_under_toplevel" };
|
||||
}
|
||||
|
||||
if (!await isRegisteredGitWorktree(rootDir, canonicalTopLevel)) {
|
||||
return { reanchored: false, reason: "toplevel_not_registered_worktree" };
|
||||
}
|
||||
|
||||
return { reanchored: true, root: canonicalTopLevel };
|
||||
}
|
||||
|
||||
/**
|
||||
* Language-agnostic liveness/classification gate for task worktrees.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user