Files
fusion/packages/engine/src/executor/cleanup-task-worktree.ts
gsxdsm db8e715452 FN-9056: reclaim safe terminal workspace worktrees
Reclaim safely abandoned workspace worktrees and canonical task branches without disrupting live or recoverable tasks.

- Tear down eligible complete, idle failed, and soft-deleted per-repository worktrees with bounded retries.
- Veto live, paused, scheduled-recovery, ambiguous, and unsafe-path cleanup candidates.
- Verify failed-task landing evidence against each repository integration branch before deleting canonical branches.
- Cover liveness and stale landed-SHA safety regressions.

Files changed:
 .changeset/fn-9056-workspace-terminal-teardown.md  |   7 +
 AGENTS.md                                          |   2 +-
 docs/architecture.md                               |   1 +
 .../src/__tests__/self-healing-workspace.test.ts   | 319 ++++++++++++++++++++-
 .../engine/src/executor/cleanup-task-worktree.ts   |   8 +-
 packages/engine/src/self-healing.ts                | 268 ++++++++++++-----
 packages/engine/src/util/run-audit.ts              |   7 +-
 7 files changed, 525 insertions(+), 87 deletions(-)

Fusion-Task-Id: FN-9056

Fusion-Task-Lineage: 4353f7d9-063b-442b-88d3-6f3da1c9aae8

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-08-14 23:14:19 -07:00

71 lines
2.6 KiB
TypeScript

/**
* FNXC:CodeOrganization 2026-08-03-15:40:
* TaskExecutor.cleanup peeled from TaskExecutor (U4).
*
* Drops in-memory active-worktree tracking and removes the single-repo worktree
* when no other task still needs it. Workspace roots are tracking-only (never removed).
*/
import type { TaskStore, WorkspaceConfig } from "@fusion/core";
import { findWorktreeUser } from "../merger.js";
import { RemovalReason } from "../worktree/worktree-pool.js";
import { executorLog } from "../logger.js";
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mirror TaskExecutor method surface
type AnyFn = (...args: any[]) => any;
export type CleanupTaskWorktreeDeps = {
store: TaskStore;
workspaceConfig: WorkspaceConfig | null | undefined;
ensureWorkspaceConfig?: () => Promise<WorkspaceConfig | null>;
activeWorktrees: Map<string, Set<string>>;
getActiveWorktreePaths: (taskId: string) => string[];
removeOwnWorktreeWithReconcile: AnyFn;
};
export async function cleanupTaskWorktree(
deps: CleanupTaskWorktreeDeps,
taskId: string,
): Promise<void> {
const workspaceConfig = deps.ensureWorkspaceConfig
? await deps.ensureWorkspaceConfig()
: deps.workspaceConfig;
const worktreePaths = deps.getActiveWorktreePaths(taskId);
if (worktreePaths.length === 0) return;
deps.activeWorktrees.delete(taskId);
/*
FNXC:Workspace 2026-08-15-05:13:
In workspace mode the tracked path is the browse-only non-git root, never a removable worktree.
Per-repo teardown belongs to SelfHealingManager.reconcileOrphanedWorkspaceWorktrees for complete
and terminal lanes, while archive-lifecycle owns archived rows. A failed task may be retried, so
executor cleanup only drops in-memory tracking and must not discard sub-repo work at this boundary.
*/
if (workspaceConfig) {
return;
}
// Non-workspace tasks hold a one-element set — preserve the original single-path removal semantics.
const worktreePath = worktreePaths[0];
// Check if another task still needs this worktree
const otherUser = await findWorktreeUser(deps.store, worktreePath, taskId);
if (otherUser) {
executorLog.log(`Worktree retained for ${taskId} — still needed by ${otherUser}`);
return;
}
try {
const settings = await deps.store.getSettings();
await deps.removeOwnWorktreeWithReconcile({
worktreePath,
settings,
taskId,
reason: RemovalReason.ExecutorDispose,
});
executorLog.log(`Cleaned up worktree for ${taskId}`);
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : String(err);
executorLog.error(`Failed to clean up worktree for ${taskId}:`, errorMessage);
}
}