fix(FN-8400): normalize preserved worktree recovery
Relocate idle native checkouts into the configured root across executor and self-healing recovery while preserving live, Worktrunk-managed, and task-pinned paths. Cover the invariant with real Git and focused recovery tests. Fusion-Task-Id: FN-8400
This commit is contained in:
@@ -4,6 +4,7 @@ import { TaskExecutor } from "../executor.js";
|
||||
import { activeSessionRegistry } from "../active-session-registry.js";
|
||||
import { ActiveSessionWorktreeRemovalError } from "../worktree-backend.js";
|
||||
import * as worktreePoolModule from "../worktree-pool.js";
|
||||
import * as branchConflictModule from "../branch-conflicts.js";
|
||||
import { createMockStore, mockedGenerateWorktreeName, resetExecutorMocks } from "./executor-test-helpers.js";
|
||||
|
||||
const CONFLICT_PATH = "/tmp/test/.worktrees/stale-self-owned";
|
||||
@@ -57,6 +58,45 @@ describe("FN-4973: executor worktree conflict cleanup", () => {
|
||||
expect(activeSessionRegistry.lookupByPath(CONFLICT_PATH)?.taskId).toBe("FN-4973");
|
||||
});
|
||||
|
||||
it("defers out-of-root reclaim instead of moving a live same-task checkout", async () => {
|
||||
const store = createMockStore();
|
||||
const executor = new TaskExecutor(store, "/tmp/test");
|
||||
const outsidePath = "/tmp/legacy-worktrees/recover-fn-8400";
|
||||
const targetPath = "/tmp/test/.worktrees/recover-fn-8400";
|
||||
(executor as any).addActiveWorktree("FN-8400", outsidePath);
|
||||
vi.spyOn(branchConflictModule, "inspectBranchConflict").mockResolvedValueOnce({
|
||||
kind: "reclaimable",
|
||||
livePath: outsidePath,
|
||||
tipSha: "abc123",
|
||||
taskAttributedCommitCount: 1,
|
||||
strandedCommits: [{ sha: "abc123", subject: "fix(FN-8400): preserve implementation" }],
|
||||
} as any);
|
||||
const relocate = vi.spyOn(worktreePoolModule, "relocateReclaimableWorktreeIntoRoot");
|
||||
|
||||
const result = await (executor as any).handleWorktreeConflict(
|
||||
outsidePath,
|
||||
"fusion/fn-8400",
|
||||
targetPath,
|
||||
"FN-8400",
|
||||
"main",
|
||||
0,
|
||||
false,
|
||||
{},
|
||||
);
|
||||
|
||||
expect(result).toEqual({ path: outsidePath, branch: "fusion/fn-8400" });
|
||||
expect(relocate).toHaveBeenCalledWith(expect.objectContaining({
|
||||
sourcePath: outsidePath,
|
||||
targetPath,
|
||||
taskId: "FN-8400",
|
||||
}));
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
"FN-8400",
|
||||
expect.stringContaining("deferred relocation of active preserved worktree"),
|
||||
outsidePath,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not reconcile foreign-task registry entries and keeps refusal behavior", async () => {
|
||||
const store = createMockStore();
|
||||
const executor = new TaskExecutor(store, "/tmp/test");
|
||||
|
||||
@@ -949,7 +949,7 @@ describe("TaskExecutor worktree recovery", () => {
|
||||
const conflictPath = "/tmp/legacy-worktrees/recover-fn-8400";
|
||||
const targetPath = "/tmp/test/.worktrees/pearl-otter";
|
||||
vi.spyOn(executor as any, "shouldGenerateNewWorktreeName").mockResolvedValue(false);
|
||||
const relocate = vi.spyOn(executor as any, "relocateReclaimableWorktree").mockResolvedValue(targetPath);
|
||||
const relocate = vi.spyOn(executor as any, "normalizeReclaimableWorktreePath").mockResolvedValue(targetPath);
|
||||
vi.spyOn(branchConflictModule, "inspectBranchConflict").mockResolvedValueOnce({
|
||||
kind,
|
||||
livePath: conflictPath,
|
||||
@@ -981,6 +981,70 @@ describe("TaskExecutor worktree recovery", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("normalizes an out-of-root branch-conflict reclaim before persisting it", async () => {
|
||||
const store = createMockStore();
|
||||
store.getSettings.mockResolvedValue({ worktreesDir: ".worktrees" } as any);
|
||||
const executor = new TaskExecutor(store, "/tmp/test");
|
||||
const conflictPath = "/tmp/legacy-worktrees/recover-fn-8400";
|
||||
const targetPath = "/tmp/test/.worktrees/recover-fn-8400";
|
||||
vi.spyOn(branchConflictModule, "inspectBranchConflict").mockResolvedValueOnce({
|
||||
kind: "reclaimable",
|
||||
livePath: conflictPath,
|
||||
tipSha: "70b47804bc6f27659638e17ac7cf279ed343ff6f",
|
||||
taskAttributedCommitCount: 1,
|
||||
strandedCommits: [{ sha: "70b47804bc6f27659638e17ac7cf279ed343ff6f", subject: "fix(FN-8400): preserve implementation" }],
|
||||
} as any);
|
||||
const normalize = vi.spyOn(executor as any, "normalizeReclaimableWorktreePath").mockResolvedValue(targetPath);
|
||||
|
||||
const result = await (executor as any).handleBranchConflict(
|
||||
{ ...makeTask("FN-8400"), branch: "fusion/fn-8400", worktree: conflictPath },
|
||||
new BranchConflictError({
|
||||
branchName: "fusion/fn-8400",
|
||||
conflictingWorktreePath: conflictPath,
|
||||
existingTipSha: "70b47804bc6f27659638e17ac7cf279ed343ff6f",
|
||||
strandedCommits: [],
|
||||
startPoint: "main",
|
||||
recommendedAction: "reclaim",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).toBe("reclaimed");
|
||||
expect(normalize).toHaveBeenCalledWith(conflictPath, targetPath, "FN-8400", expect.objectContaining({ worktreesDir: ".worktrees" }));
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-8400", expect.objectContaining({ worktree: targetPath }));
|
||||
});
|
||||
|
||||
it("uses the task-pinned target when normalizing a branch-conflict reclaim", async () => {
|
||||
const store = createMockStore();
|
||||
store.getSettings.mockResolvedValue({ worktreesDir: ".worktrees", worktreeNaming: "task-id" } as any);
|
||||
const executor = new TaskExecutor(store, "/tmp/test");
|
||||
const conflictPath = "/tmp/legacy-worktrees/recover-fn-8400";
|
||||
const pinnedPath = "/tmp/test/.worktrees/fn-8400";
|
||||
vi.spyOn(branchConflictModule, "inspectBranchConflict").mockResolvedValueOnce({
|
||||
kind: "reclaimable",
|
||||
livePath: conflictPath,
|
||||
tipSha: "70b47804bc6f27659638e17ac7cf279ed343ff6f",
|
||||
taskAttributedCommitCount: 1,
|
||||
strandedCommits: [{ sha: "70b47804bc6f27659638e17ac7cf279ed343ff6f", subject: "fix(FN-8400): preserve implementation" }],
|
||||
} as any);
|
||||
const normalize = vi.spyOn(executor as any, "normalizeReclaimableWorktreePath").mockResolvedValue(pinnedPath);
|
||||
|
||||
const result = await (executor as any).handleBranchConflict(
|
||||
{ ...makeTask("FN-8400"), branch: "fusion/fn-8400", worktree: conflictPath },
|
||||
new BranchConflictError({
|
||||
branchName: "fusion/fn-8400",
|
||||
conflictingWorktreePath: conflictPath,
|
||||
existingTipSha: "70b47804bc6f27659638e17ac7cf279ed343ff6f",
|
||||
strandedCommits: [],
|
||||
startPoint: "main",
|
||||
recommendedAction: "reclaim",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).toBe("reclaimed");
|
||||
expect(normalize).toHaveBeenCalledWith(conflictPath, pinnedPath, "FN-8400", expect.objectContaining({ worktreeNaming: "task-id" }));
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-8400", expect.objectContaining({ worktree: pinnedPath }));
|
||||
});
|
||||
|
||||
it("records recovery context when handling a branch conflict (FN-4847: now discards + requeues instead of pausing)", async () => {
|
||||
// FN-4847: branch-conflict-unrecoverable previously paused the task with
|
||||
// status=failed + pausedReason="branch-conflict-unrecoverable". The user has
|
||||
|
||||
@@ -91,6 +91,7 @@ vi.mock("../worktree-pool.js", () => ({
|
||||
getRegisteredWorktreePaths: vi.fn().mockResolvedValue(new Set<string>()),
|
||||
getRegisteredWorktreeBranchMap: vi.fn().mockResolvedValue(new Map<string, string>()),
|
||||
removeWorktree: vi.fn().mockResolvedValue(undefined),
|
||||
relocateReclaimableWorktreeIntoRoot: vi.fn(async ({ sourcePath }: { sourcePath: string }) => ({ kind: "ready", path: sourcePath, relocated: false })),
|
||||
resolveWorktreeBackend: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -120,7 +121,7 @@ import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { classifyTaskWorktree, getRegisteredWorktreeBranchMap, getRegisteredWorktreePaths, isUsableTaskWorktree, removeWorktree, resolveWorktreeBackend, scanIdleWorktrees, scanOrphanedBranches } from "../worktree-pool.js";
|
||||
import { classifyTaskWorktree, getRegisteredWorktreeBranchMap, getRegisteredWorktreePaths, isUsableTaskWorktree, relocateReclaimableWorktreeIntoRoot, removeWorktree, resolveWorktreeBackend, scanIdleWorktrees, scanOrphanedBranches } from "../worktree-pool.js";
|
||||
import { activeSessionRegistry, executingTaskLock } from "../active-session-registry.js";
|
||||
import * as branchConflictModule from "../branch-conflicts.js";
|
||||
import { createLogger } from "../logger.js";
|
||||
@@ -10457,6 +10458,36 @@ describe("SelfHealingManager reclaimSelfOwnedBranchConflicts", () => {
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-500", expect.objectContaining({ worktree: "/tmp/fn-500", branch: "fusion/fn-500", status: null, paused: false }));
|
||||
});
|
||||
|
||||
it("normalizes an out-of-root self-healing reclaim before persisting it", async () => {
|
||||
const outsidePath = "/tmp/legacy-worktrees/recover-fn-8400";
|
||||
const targetPath = "/tmp/test-project/.worktrees/recover-fn-8400";
|
||||
(store.listTasks as any)
|
||||
.mockResolvedValueOnce([{ id: "FN-8400", checkedOutBy: null, branch: "fusion/fn-8400", worktree: outsidePath, lineageId: "lin-8400" }])
|
||||
.mockResolvedValueOnce([]);
|
||||
vi.spyOn(branchConflictModule, "inspectBranchConflict").mockResolvedValueOnce({
|
||||
kind: "reclaimable",
|
||||
livePath: outsidePath,
|
||||
tipSha: "abc123def456",
|
||||
taskAttributedCommitCount: 1,
|
||||
strandedCommits: [{ sha: "abc123", subject: "work" }],
|
||||
} as any);
|
||||
vi.mocked(relocateReclaimableWorktreeIntoRoot).mockResolvedValueOnce({ kind: "ready", path: targetPath, relocated: true });
|
||||
|
||||
const recovered = await manager.reclaimSelfOwnedBranchConflicts();
|
||||
|
||||
expect(recovered).toBe(1);
|
||||
expect(relocateReclaimableWorktreeIntoRoot).toHaveBeenCalledWith(expect.objectContaining({
|
||||
rootDir: "/tmp/test-project",
|
||||
sourcePath: outsidePath,
|
||||
targetPath,
|
||||
taskId: "FN-8400",
|
||||
}));
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-8400", expect.objectContaining({ worktree: targetPath }));
|
||||
expect((store as any).recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({
|
||||
metadata: expect.objectContaining({ worktreePath: targetPath }),
|
||||
}));
|
||||
});
|
||||
|
||||
it("skips blocked todo tasks with preserved branches", async () => {
|
||||
(store.listTasks as any)
|
||||
.mockResolvedValueOnce([{ id: "FN-516", column: "todo", blockedBy: "FN-216", checkedOutBy: null, branch: "fusion/fn-516", worktree: "/tmp/fn-516" }])
|
||||
@@ -11189,7 +11220,7 @@ describe("FN-5335 triple-proof no-action unit coverage", () => {
|
||||
});
|
||||
const manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
|
||||
vi.spyOn(manager as any, "evaluateBackwardMoveTripleProof").mockResolvedValue({ ok: false, reason: "test" });
|
||||
vi.spyOn(branchConflictModule, "inspectBranchConflict").mockResolvedValue({ kind: "reclaimable", tipSha: "abc", taskAttributedCommitCount: 1, strandedCommits: [{ sha: "abc", subject: "work" }] } as any);
|
||||
vi.spyOn(branchConflictModule, "inspectBranchConflict").mockResolvedValue({ kind: "reclaimable", livePath: "/tmp/wt-rsbc", tipSha: "abc", taskAttributedCommitCount: 1, strandedCommits: [{ sha: "abc", subject: "work" }] } as any);
|
||||
|
||||
const result = await manager.reclaimSelfOwnedBranchConflicts();
|
||||
expect(result).toBeGreaterThanOrEqual(0);
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
isTaskPinnedWorktreeNaming,
|
||||
pinnedWorktreeSlug,
|
||||
pinnedWorktreePathForTask,
|
||||
preservedWorktreeTargetPathForTask,
|
||||
} from "../worktree-pinning.js";
|
||||
|
||||
describe("worktree-pinning", () => {
|
||||
@@ -50,4 +51,24 @@ describe("worktree-pinning", () => {
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
});
|
||||
|
||||
describe("preservedWorktreeTargetPathForTask", () => {
|
||||
it("uses the task id for task-pinned naming", () => {
|
||||
expect(preservedWorktreeTargetPathForTask(
|
||||
"FN-8400",
|
||||
"/legacy/recover-fn-8400",
|
||||
{ worktreeNaming: "task-id" },
|
||||
"/repo",
|
||||
)).toBe("/repo/.worktrees/fn-8400");
|
||||
});
|
||||
|
||||
it("preserves the legacy basename for non-pinned naming", () => {
|
||||
expect(preservedWorktreeTargetPathForTask(
|
||||
"FN-8400",
|
||||
"/legacy/recover-fn-8400",
|
||||
{ worktreeNaming: "random" },
|
||||
"/repo",
|
||||
)).toBe("/repo/.worktrees/recover-fn-8400");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { relocateReclaimableWorktreeIntoRoot } from "../worktree-pool.js";
|
||||
|
||||
const cleanupPaths: string[] = [];
|
||||
|
||||
function git(cwd: string, args: string[]): string {
|
||||
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
||||
}
|
||||
|
||||
function createRepositoryFixture(): { rootDir: string; sourcePath: string; targetPath: string } {
|
||||
const fixtureRoot = mkdtempSync(join(tmpdir(), "fn-8400-reclaim-placement-"));
|
||||
cleanupPaths.push(fixtureRoot);
|
||||
const rootDir = join(fixtureRoot, "repo");
|
||||
const sourcePath = join(fixtureRoot, "legacy-worktrees", "recover-fn-8400");
|
||||
const targetPath = join(rootDir, ".worktrees", "recover-fn-8400");
|
||||
|
||||
git(fixtureRoot, ["init", "repo"]);
|
||||
git(rootDir, ["config", "user.name", "Fusion Test"]);
|
||||
git(rootDir, ["config", "user.email", "fusion-test@example.com"]);
|
||||
writeFileSync(join(rootDir, "README.md"), "base\n");
|
||||
git(rootDir, ["add", "README.md"]);
|
||||
git(rootDir, ["commit", "-m", "base"]);
|
||||
git(rootDir, ["worktree", "add", "-b", "fusion/fn-8400", sourcePath, "HEAD"]);
|
||||
writeFileSync(join(sourcePath, "preserved.txt"), "uncommitted task work\n");
|
||||
|
||||
return { rootDir, sourcePath, targetPath };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
while (cleanupPaths.length > 0) {
|
||||
rmSync(cleanupPaths.pop()!, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("reclaimable worktree placement", () => {
|
||||
it("moves a registered legacy worktree into the configured root without losing task work", async () => {
|
||||
const { rootDir, sourcePath, targetPath } = createRepositoryFixture();
|
||||
|
||||
const result = await relocateReclaimableWorktreeIntoRoot({
|
||||
rootDir,
|
||||
sourcePath,
|
||||
targetPath,
|
||||
taskId: "FN-8400",
|
||||
settings: {},
|
||||
isPathActive: async () => false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ kind: "ready", path: targetPath, relocated: true });
|
||||
expect(existsSync(sourcePath)).toBe(false);
|
||||
expect(existsSync(targetPath)).toBe(true);
|
||||
expect(readFileSync(join(targetPath, "preserved.txt"), "utf8")).toBe("uncommitted task work\n");
|
||||
expect(git(rootDir, ["worktree", "list", "--porcelain"])).toContain(`worktree ${realpathSync(targetPath)}`);
|
||||
expect(git(targetPath, ["branch", "--show-current"])).toBe("fusion/fn-8400");
|
||||
expect(git(targetPath, ["status", "--porcelain"])).toContain("?? preserved.txt");
|
||||
});
|
||||
|
||||
it("defers without moving when the exact legacy path is still active", async () => {
|
||||
const { rootDir, sourcePath, targetPath } = createRepositoryFixture();
|
||||
|
||||
const result = await relocateReclaimableWorktreeIntoRoot({
|
||||
rootDir,
|
||||
sourcePath,
|
||||
targetPath,
|
||||
taskId: "FN-8400",
|
||||
settings: {},
|
||||
isPathActive: async (path) => path === sourcePath,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ kind: "deferred-live", path: sourcePath });
|
||||
expect(existsSync(sourcePath)).toBe(true);
|
||||
expect(existsSync(targetPath)).toBe(false);
|
||||
expect(git(rootDir, ["worktree", "list", "--porcelain"])).toContain(`worktree ${realpathSync(sourcePath)}`);
|
||||
});
|
||||
|
||||
it("preserves the backend-assigned path when Worktrunk owns the layout", async () => {
|
||||
const { rootDir, sourcePath, targetPath } = createRepositoryFixture();
|
||||
|
||||
const result = await relocateReclaimableWorktreeIntoRoot({
|
||||
rootDir,
|
||||
sourcePath,
|
||||
targetPath,
|
||||
taskId: "FN-8400",
|
||||
settings: { worktrunk: { enabled: true } },
|
||||
isPathActive: async () => false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ kind: "ready", path: sourcePath, relocated: false });
|
||||
expect(existsSync(sourcePath)).toBe(true);
|
||||
expect(existsSync(targetPath)).toBe(false);
|
||||
expect(git(rootDir, ["worktree", "list", "--porcelain"])).toContain(`worktree ${realpathSync(sourcePath)}`);
|
||||
});
|
||||
|
||||
it("chooses a task-scoped target when the legacy basename is occupied", async () => {
|
||||
const { rootDir, sourcePath, targetPath } = createRepositoryFixture();
|
||||
mkdirSync(targetPath, { recursive: true });
|
||||
writeFileSync(join(targetPath, "owner.txt"), "unrelated path\n");
|
||||
const disambiguatedPath = `${targetPath}-fn-8400`;
|
||||
|
||||
const result = await relocateReclaimableWorktreeIntoRoot({
|
||||
rootDir,
|
||||
sourcePath,
|
||||
targetPath,
|
||||
taskId: "FN-8400",
|
||||
settings: { worktreeNaming: "random" },
|
||||
isPathActive: async () => false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ kind: "ready", path: disambiguatedPath, relocated: true });
|
||||
expect(readFileSync(join(targetPath, "owner.txt"), "utf8")).toBe("unrelated path\n");
|
||||
expect(readFileSync(join(disambiguatedPath, "preserved.txt"), "utf8")).toBe("uncommitted task work\n");
|
||||
expect(git(rootDir, ["worktree", "list", "--porcelain"])).toContain(`worktree ${realpathSync(disambiguatedPath)}`);
|
||||
});
|
||||
|
||||
it("rejects a relocation target outside the configured root before touching the source", async () => {
|
||||
const { rootDir, sourcePath } = createRepositoryFixture();
|
||||
const invalidTarget = join(dirname(rootDir), "still-outside", "recover-fn-8400");
|
||||
|
||||
await expect(relocateReclaimableWorktreeIntoRoot({
|
||||
rootDir,
|
||||
sourcePath,
|
||||
targetPath: invalidTarget,
|
||||
taskId: "FN-8400",
|
||||
settings: {},
|
||||
isPathActive: async () => false,
|
||||
})).rejects.toThrow(/outside configured worktrees directory/);
|
||||
|
||||
expect(existsSync(sourcePath)).toBe(true);
|
||||
expect(existsSync(invalidTarget)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -9,9 +9,9 @@ const execFileAsync = promisify(execFile);
|
||||
|
||||
const WORKFLOW_THINKING_LEVEL_SET: ReadonlySet<string> = new Set(THINKING_LEVELS);
|
||||
|
||||
import { delimiter, dirname, isAbsolute, join, relative, resolve as resolvePath } from "node:path";
|
||||
import { delimiter, isAbsolute, join, relative, resolve as resolvePath } from "node:path";
|
||||
import { existsSync, lstatSync, realpathSync } from "node:fs";
|
||||
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { readFile, rm, writeFile } from "node:fs/promises";
|
||||
import type { TaskStore, Task, TaskDetail, TaskTokenUsage, StepStatus, Settings, WorkflowStep, MissionStore, AsyncMissionStore, Slice, AgentState, AgentCapability, RunMutationContext, AgentHeartbeatConfig, Agent, AgentMemoryInclusionMode, ProjectSettings, MergeResult, WorkflowIrNode, WorkflowIrNodeKind, WorkflowStepResult as CoreWorkflowStepResult, ThinkingLevel } from "@fusion/core";
|
||||
import { getUnmetSchedulingDependencies } from "./scheduler.js";
|
||||
import { RetryStormError, serializeRetryStormError, evaluateCompletedPromotionFailureProvenance, evaluateSkipBypassTaint, resolveWorkflowIrForTask, resolveCompleteColumn, resolveMergeOrchestrationColumn, resolveReboundTarget, resolveColumnAgentBinding, resolveEffectiveAgent, instanceNodeId, getWorkflowExtensionRegistry, getBuiltinWorkflow, parseNoOpCompletionMarker, allowsAutoMergeProcessing, resolveEffectiveAutoMerge, isLiveSharedBranchGroupMemberIntegration, resolveMaxAutoMergeRetries, resolveMaxConsecutiveToolFailureRetries, resolveConsecutiveToolFailureRetryBackoffMs, resolveConsecutiveToolFailureThreshold, resolveExecutorEscalationTarget, resolveOptionalStepRevisionBudget, resolveOptionalReviewRevisionBudget, COMPLETION_SUMMARY_NODE_ID, upsertWorkflowStepResult, AWAITING_APPROVAL_PAUSE_REASON, THINKING_LEVELS, AgentStore, resolveExecutorFallbackModel } from "@fusion/core";
|
||||
@@ -125,7 +125,8 @@ import {
|
||||
// filter reuses the SAME always-allowed/scope-match surface as the non-workspace path (F5). One-way
|
||||
// executor→workspace-paths edge (workspace-paths imports nothing).
|
||||
import { deriveRepoScopeSubset, normalizeRepoRelPath } from "./workspace-paths.js";
|
||||
import { RemovalReason, classifyTaskWorktree, describeRegisteredWorktrees, detectGitRepository, detectNestedWorktreeRoot, getRegisteredWorktreePaths, isInsideWorktreesDir, isRegisteredGitWorktree, removeWorktree, type GitRepoDetection, type WorktreePool } from "./worktree-pool.js";
|
||||
import { preservedWorktreeTargetPathForTask } from "./worktree-pinning.js";
|
||||
import { RemovalReason, classifyTaskWorktree, describeRegisteredWorktrees, detectGitRepository, detectNestedWorktreeRoot, getRegisteredWorktreePaths, isInsideWorktreesDir, isRegisteredGitWorktree, relocateReclaimableWorktreeIntoRoot, removeWorktree, type GitRepoDetection, type WorktreePool } from "./worktree-pool.js";
|
||||
import { attemptBranchAutocorrect } from "./branch-autocorrect.js";
|
||||
import { ActiveSessionWorktreeRemovalError } from "./worktree-backend.js";
|
||||
import {canonicalizeWorktreePath, registerArchiveWorkspaceWorktreeDisposer, registerArchiveWorktreeDisposer, registerTaskMoveDisposer} from "@fusion/core";
|
||||
@@ -16851,14 +16852,17 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB
|
||||
branch: string,
|
||||
tipSha: string,
|
||||
count: number,
|
||||
settings: Partial<Settings>,
|
||||
): Promise<void> {
|
||||
await this.store.updateTask(task.id, { worktree: livePath, branch });
|
||||
const targetPath = preservedWorktreeTargetPathForTask(task.id, livePath, settings, this.rootDir);
|
||||
const normalizedPath = await this.normalizeReclaimableWorktreePath(livePath, targetPath, task.id, settings);
|
||||
await this.store.updateTask(task.id, { worktree: normalizedPath, branch });
|
||||
const latestTask = await this.store.getTask(task.id);
|
||||
const baseRef = await this.resolveDiffBaseRef(livePath, latestTask.baseCommitSha);
|
||||
const baseRef = await this.resolveDiffBaseRef(normalizedPath, latestTask.baseCommitSha);
|
||||
if (baseRef) {
|
||||
await assertCleanBranchAtBase(this.rootDir, branch, baseRef, task.id);
|
||||
}
|
||||
const message = `[recovery] reclaimed existing worktree for ${task.id} at ${livePath} (${count} commits preserved, tip ${tipSha.slice(0, 12)})`;
|
||||
const message = `[recovery] reclaimed existing worktree for ${task.id} at ${normalizedPath} (${count} commits preserved, tip ${tipSha.slice(0, 12)})`;
|
||||
await this.store.logEntry(task.id, message, undefined, this.getRunContextFor(task.id));
|
||||
await this.store.appendAgentLog(task.id, "Branch conflict auto-recovery", "status", message, "executor");
|
||||
}
|
||||
@@ -16875,6 +16879,7 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB
|
||||
await this.store.logEntry(task.id, refusalMessage, undefined, this.getRunContextFor(task.id));
|
||||
return "sticky";
|
||||
}
|
||||
const settings = await mergeEffectiveSettings(this.store, task, await this.store.getSettings());
|
||||
|
||||
const integrationRef = task.mergeDetails?.mergeTargetBranch ?? task.baseBranch ?? task.executionStartBranch ?? await resolveIntegrationBranch(this.rootDir, undefined);
|
||||
const inspection = await inspectBranchConflict({
|
||||
@@ -16925,12 +16930,12 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB
|
||||
}
|
||||
|
||||
if (inspection.kind === "reclaimable") {
|
||||
await this.reclaimExistingWorktree(task, inspection.livePath, error.branchName, inspection.tipSha, inspection.taskAttributedCommitCount);
|
||||
await this.reclaimExistingWorktree(task, inspection.livePath, error.branchName, inspection.tipSha, inspection.taskAttributedCommitCount, settings);
|
||||
return "reclaimed";
|
||||
}
|
||||
|
||||
if (inspection.kind === "fully-subsumed") {
|
||||
await this.reclaimExistingWorktree(task, inspection.livePath, error.branchName, inspection.tipSha, 0);
|
||||
await this.reclaimExistingWorktree(task, inspection.livePath, error.branchName, inspection.tipSha, 0, settings);
|
||||
return "reclaimed";
|
||||
}
|
||||
|
||||
@@ -17956,7 +17961,7 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB
|
||||
if (inspection.kind === "reclaimable") {
|
||||
const livePath = isInsideWorktreesDir(this.rootDir, inspection.livePath, settings)
|
||||
? inspection.livePath
|
||||
: await this.relocateReclaimableWorktree(inspection.livePath, path, taskId, settings);
|
||||
: await this.normalizeReclaimableWorktreePath(inspection.livePath, path, taskId, settings);
|
||||
await this.store.logEntry(
|
||||
taskId,
|
||||
`[recovery] reclaimed existing worktree for ${taskId} at ${livePath} (${inspection.taskAttributedCommitCount} commits preserved)`,
|
||||
@@ -17968,7 +17973,7 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB
|
||||
if (inspection.kind === "fully-subsumed") {
|
||||
const livePath = isInsideWorktreesDir(this.rootDir, inspection.livePath, settings)
|
||||
? inspection.livePath
|
||||
: await this.relocateReclaimableWorktree(inspection.livePath, path, taskId, settings);
|
||||
: await this.normalizeReclaimableWorktreePath(inspection.livePath, path, taskId, settings);
|
||||
await this.store.logEntry(
|
||||
taskId,
|
||||
`[recovery] reclaimed existing worktree for ${taskId} at ${livePath} (0 commits preserved)`,
|
||||
@@ -18024,25 +18029,40 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB
|
||||
return null;
|
||||
}
|
||||
|
||||
private async relocateReclaimableWorktree(
|
||||
private async normalizeReclaimableWorktreePath(
|
||||
sourcePath: string,
|
||||
targetPath: string,
|
||||
taskId: string,
|
||||
settings: Partial<Settings>,
|
||||
): Promise<string> {
|
||||
if (!isInsideWorktreesDir(this.rootDir, targetPath, settings)) {
|
||||
throw new NonRetryableWorktreeError(
|
||||
`Refusing to relocate ${taskId} worktree to path outside configured worktrees directory: ${targetPath}`,
|
||||
);
|
||||
}
|
||||
|
||||
await mkdir(dirname(targetPath), { recursive: true });
|
||||
const isRelocationActive = async (path: string) =>
|
||||
this.hasActiveWorktreeBinding(taskId, path)
|
||||
|| await this.isLiveCleanupRefusal(path, taskId);
|
||||
try {
|
||||
await execFileAsync("git", ["worktree", "move", sourcePath, targetPath], {
|
||||
cwd: this.rootDir,
|
||||
timeout: 120_000,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
const placement = await relocateReclaimableWorktreeIntoRoot({
|
||||
rootDir: this.rootDir,
|
||||
sourcePath,
|
||||
targetPath,
|
||||
taskId,
|
||||
settings,
|
||||
isPathActive: isRelocationActive,
|
||||
});
|
||||
if (placement.kind === "deferred-live") {
|
||||
await this.store.logEntry(
|
||||
taskId,
|
||||
`[recovery] deferred relocation of active preserved worktree ${sourcePath}`,
|
||||
sourcePath,
|
||||
);
|
||||
return placement.path;
|
||||
}
|
||||
if (placement.relocated) {
|
||||
await this.store.logEntry(
|
||||
taskId,
|
||||
`[recovery] relocated preserved worktree from ${sourcePath} to ${placement.path}`,
|
||||
placement.path,
|
||||
);
|
||||
}
|
||||
return placement.path;
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
await this.store.logEntry(
|
||||
@@ -18054,13 +18074,6 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB
|
||||
`Could not relocate preserved ${taskId} worktree into the configured worktrees directory: ${detail}`,
|
||||
);
|
||||
}
|
||||
|
||||
await this.store.logEntry(
|
||||
taskId,
|
||||
`[recovery] relocated preserved worktree from ${sourcePath} to ${targetPath}`,
|
||||
targetPath,
|
||||
);
|
||||
return targetPath;
|
||||
}
|
||||
|
||||
private async tryFreshWorktreeAfterLiveConflict(input: {
|
||||
|
||||
@@ -34,7 +34,7 @@ import { IN_REVIEW_STALL_DEADLOCK_LOG_PREFIX, IN_REVIEW_STALL_LOG_PREFIX, IN_REV
|
||||
import type { MeshLeaseManager } from "./mesh-lease-manager.js";
|
||||
import { createLogger, schedulerLog } from "./logger.js";
|
||||
import { mergeEffectiveSettings } from "./effective-settings.js";
|
||||
import { RemovalReason, classifyTaskWorktree, getRegisteredWorktreeBranchMap, getRegisteredWorktreePaths, isUsableTaskWorktree, removeWorktree, resolveWorktreeBackend, scanIdleWorktrees, scanOrphanedBranches } from "./worktree-pool.js";
|
||||
import { RemovalReason, classifyTaskWorktree, getRegisteredWorktreeBranchMap, getRegisteredWorktreePaths, isUsableTaskWorktree, relocateReclaimableWorktreeIntoRoot, removeWorktree, resolveWorktreeBackend, scanIdleWorktrees, scanOrphanedBranches } from "./worktree-pool.js";
|
||||
import {
|
||||
classifyMissingWorktreeSessionStartFailure,
|
||||
extractMissingWorktreePathFromSessionStartFailure,
|
||||
@@ -77,6 +77,7 @@ import { shouldReclaimWedgedMerge } from "./merge-reclaim-policy.js";
|
||||
import { advanceIntegrationBranchRef } from "./merger-ref-update-advance.js";
|
||||
import { isAiMergeContainerDir, resolveAiMergeRootPath, resolveLegacyAiMergeRootPath, resolveWorktreesDir } from "./worktree-paths.js";
|
||||
import { canonicalFusionBranchName, resolveTaskWorkingBranch } from "./worktree-names.js";
|
||||
import { preservedWorktreeTargetPathForTask } from "./worktree-pinning.js";
|
||||
import { resolveIntegrationBranch } from "./integration-branch.js";
|
||||
import { resolveBranchGroupMergeRouting } from "./group-merge-coordinator.js";
|
||||
import type { OwnedLandedClassification } from "./merger.js";
|
||||
@@ -3844,6 +3845,32 @@ export class SelfHealingManager {
|
||||
const preservedCommitCount = inspection.kind === "fully-subsumed"
|
||||
? 0
|
||||
: inspection.taskAttributedCommitCount;
|
||||
const placement = await relocateReclaimableWorktreeIntoRoot({
|
||||
rootDir: this.options.rootDir,
|
||||
sourcePath: inspection.livePath,
|
||||
targetPath: preservedWorktreeTargetPathForTask(task.id, inspection.livePath, settings, this.options.rootDir),
|
||||
taskId: task.id,
|
||||
settings,
|
||||
isPathActive: (path) =>
|
||||
activeSessionRegistry.isPathActive(path)
|
||||
|| executingTaskLock.has(task.id)
|
||||
|| executingIds.has(task.id)
|
||||
|| activeTaskIds.has(task.id.toUpperCase()),
|
||||
});
|
||||
if (placement.kind === "deferred-live") {
|
||||
await this.store.logEntry(
|
||||
task.id,
|
||||
`[recovery] deferred relocation of active preserved worktree ${placement.path}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const reclaimedWorktreePath = placement.path;
|
||||
if (placement.relocated) {
|
||||
await this.store.logEntry(
|
||||
task.id,
|
||||
`[recovery] relocated preserved worktree from ${inspection.livePath} to ${reclaimedWorktreePath}`,
|
||||
);
|
||||
}
|
||||
const stepSignature = buildResumeLimboStepSignature(task);
|
||||
const hasActiveSessionSignal = Boolean(task.checkedOutBy) || activeTaskIds.has(task.id.toUpperCase());
|
||||
const hasPriorSnapshot = typeof task.resumeLimboTipSha === "string" && typeof task.resumeLimboStepSignature === "string";
|
||||
@@ -3868,6 +3895,7 @@ export class SelfHealingManager {
|
||||
preserveResumeState: true,
|
||||
});
|
||||
await this.store.updateTask(task.id, {
|
||||
...(placement.relocated ? { worktree: reclaimedWorktreePath } : {}),
|
||||
resumeLimboCount: 0,
|
||||
resumeLimboTipSha: inspection.tipSha,
|
||||
resumeLimboStepSignature: stepSignature,
|
||||
@@ -3908,7 +3936,7 @@ export class SelfHealingManager {
|
||||
}
|
||||
|
||||
await this.store.updateTask(task.id, {
|
||||
worktree: inspection.livePath,
|
||||
worktree: reclaimedWorktreePath,
|
||||
branch: task.branch,
|
||||
paused: false,
|
||||
pausedReason: undefined,
|
||||
@@ -3920,7 +3948,7 @@ export class SelfHealingManager {
|
||||
});
|
||||
await this.store.logEntry(
|
||||
task.id,
|
||||
`[recovery] ${wasPausedBranchConflict ? "reclaim-paused-review" : "reclaim-self-owned"} ${task.id} at ${inspection.livePath} (${preservedCommitCount} commits preserved, tip ${inspection.tipSha.slice(0, 12)})`,
|
||||
`[recovery] ${wasPausedBranchConflict ? "reclaim-paused-review" : "reclaim-self-owned"} ${task.id} at ${reclaimedWorktreePath} (${preservedCommitCount} commits preserved, tip ${inspection.tipSha.slice(0, 12)})`,
|
||||
);
|
||||
|
||||
if (task.column === "in-review") {
|
||||
@@ -3952,7 +3980,7 @@ export class SelfHealingManager {
|
||||
metadata: {
|
||||
taskId: task.id,
|
||||
branch: task.branch,
|
||||
worktreePath: inspection.livePath,
|
||||
worktreePath: reclaimedWorktreePath,
|
||||
existingTipSha: inspection.tipSha,
|
||||
strandedCommitCount: inspection.kind === "fully-subsumed" ? 0 : inspection.strandedCommits.length,
|
||||
subsumed: inspection.kind === "fully-subsumed",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Settings } from "@fusion/core";
|
||||
import { basename } from "node:path";
|
||||
import { resolveTaskWorktreePath } from "./worktree-paths.js";
|
||||
|
||||
/*
|
||||
@@ -37,3 +38,15 @@ export function pinnedWorktreePathForTask(
|
||||
): string {
|
||||
return resolveTaskWorktreePath(rootDir, settings, pinnedWorktreeSlug(taskId));
|
||||
}
|
||||
|
||||
/** Preserve the task-pinned naming invariant while normalizing legacy paths. */
|
||||
export function preservedWorktreeTargetPathForTask(
|
||||
taskId: string,
|
||||
sourcePath: string,
|
||||
settings: Pick<Settings, "worktreeNaming" | "worktreesDir"> | undefined,
|
||||
rootDir: string,
|
||||
): string {
|
||||
return isTaskPinnedWorktreeNaming(settings)
|
||||
? pinnedWorktreePathForTask(taskId, settings, rootDir)
|
||||
: resolveTaskWorktreePath(rootDir, settings, basename(sourcePath));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { exec } from "node:child_process";
|
||||
import { exec, execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { existsSync, lstatSync, readdirSync, readFileSync, rmSync, realpathSync } from "node:fs";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { basename, dirname, join, relative, resolve, isAbsolute } from "node:path";
|
||||
import type { ColumnId, SecretsStore, Settings, TaskStore, WorktrunkSettings } from "@fusion/core";
|
||||
import { assertCleanBranchAtBase, inspectBranchConflict } from "./branch-conflicts.js";
|
||||
@@ -40,6 +41,7 @@ export {
|
||||
} from "./worktrunk-installer.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
// ── Worktrunk binary lazy resolver ─────────────────────────────────────────────
|
||||
// Memoizes per (homedir, settings.binaryPath) so the resolution+install flow
|
||||
@@ -384,6 +386,68 @@ export function isInsideWorktreesDir(
|
||||
return isInsideConfiguredWorktreesDir(rootDir, settings, worktreePath);
|
||||
}
|
||||
|
||||
export type ReclaimableWorktreePlacement =
|
||||
| { kind: "ready"; path: string; relocated: boolean }
|
||||
| { kind: "deferred-live"; path: string };
|
||||
|
||||
export interface RelocateReclaimableWorktreeInput {
|
||||
rootDir: string;
|
||||
sourcePath: string;
|
||||
targetPath: string;
|
||||
taskId: string;
|
||||
settings?: Pick<Settings, "worktreeNaming" | "worktreesDir" | "worktrunk">;
|
||||
isPathActive: (path: string) => boolean | Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Put a preserved, registered native checkout under the configured worktree
|
||||
* root. Worktrunk-assigned paths remain backend-owned. The exact source path
|
||||
* must be idle before it can move; callers treat a live result as deferred
|
||||
* recovery rather than invalidating a running process cwd.
|
||||
*/
|
||||
export async function relocateReclaimableWorktreeIntoRoot(
|
||||
input: RelocateReclaimableWorktreeInput,
|
||||
): Promise<ReclaimableWorktreePlacement> {
|
||||
const { rootDir, sourcePath, targetPath, taskId, settings, isPathActive } = input;
|
||||
if (settings?.worktrunk?.enabled === true) {
|
||||
return { kind: "ready", path: sourcePath, relocated: false };
|
||||
}
|
||||
if (isInsideWorktreesDir(rootDir, sourcePath, settings)) {
|
||||
return { kind: "ready", path: sourcePath, relocated: false };
|
||||
}
|
||||
if (await isPathActive(sourcePath)) {
|
||||
return { kind: "deferred-live", path: sourcePath };
|
||||
}
|
||||
if (!isInsideWorktreesDir(rootDir, targetPath, settings)) {
|
||||
throw new Error(
|
||||
`Refusing to relocate ${taskId} worktree to path outside configured worktrees directory: ${targetPath}`,
|
||||
);
|
||||
}
|
||||
|
||||
let resolvedTargetPath = targetPath;
|
||||
if (existsSync(resolvedTargetPath) && settings?.worktreeNaming !== "task-id") {
|
||||
const taskSuffix = taskId.toLowerCase();
|
||||
const candidates = [
|
||||
`${targetPath}-${taskSuffix}`,
|
||||
...Array.from({ length: 5 }, (_, index) => `${targetPath}-${taskSuffix}-${index + 2}`),
|
||||
];
|
||||
const available = candidates.find((candidate) => !existsSync(candidate));
|
||||
if (!available) {
|
||||
throw new Error(`No available relocation target for ${taskId} worktree near ${targetPath}`);
|
||||
}
|
||||
resolvedTargetPath = available;
|
||||
}
|
||||
|
||||
await mkdir(dirname(resolvedTargetPath), { recursive: true });
|
||||
await execFileAsync("git", ["worktree", "move", sourcePath, resolvedTargetPath], {
|
||||
cwd: rootDir,
|
||||
timeout: 120_000,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
|
||||
return { kind: "ready", path: resolvedTargetPath, relocated: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* A pool of idle git worktrees that can be recycled across tasks.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user