FN-8144: remove workspace worktrees on archive

Archive workspace task worktrees synchronously and safely across archive entry points.

- Add store-scoped workspace disposal planning, reservations, and quarantine handling.
- Install baseline and executor disposers that remove per-repository worktrees and branches without shell interpolation.
- Cover disposal-plan deduplication and document the archive cleanup behavior.

Files changed:
 .../fn-8144-archive-removes-workspace-worktrees.md |   7 ++
 AGENTS.md                                          |   1 +
 docs/task-management.md                            |   4 +
 .../archive-removes-workspace-worktrees.test.ts    |  59 +++++++++++
 packages/core/src/archive-worktree-disposer.ts     |  52 ++++++++++
 packages/core/src/index.gate.ts                    |   8 ++
 packages/core/src/index.ts                         |   8 ++
 .../core/src/task-store/archive-lifecycle-2.ts     |  29 ++++--
 packages/core/src/task-store/archive-lifecycle.ts  | 114 ++++++++++++++++++++-
 .../src/archive-worktree-disposer-install.ts       |  27 ++++-
 packages/engine/src/executor.ts                    |  25 ++++-
 11 files changed, 319 insertions(+), 15 deletions(-)

Fusion-Task-Id: FN-8144

Fusion-Task-Lineage: 1c4b65f3-a1d2-4a5c-a4b6-c263f9e6f61d

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-16 17:05:29 -07:00
parent fd036665ae
commit ca7a5a7106
11 changed files with 319 additions and 15 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Archiving a workspace task now removes its per-sub-repo worktrees.
category: fix
dev: Workspace archive disposal is store-scoped, awaits backend removal under canonical per-repository reservations, and quarantines paths whose removal is not explicitly reported successful.

View File

@@ -273,6 +273,7 @@ Scoped exception (FN-5819): shared-branch-group members (`branchContext.assignme
- Workspace (Phase D U1): self-healing emits `task:reconcile-workspace-partial-land` when it re-enqueues a partial/zero-landed workspace task's per-repo land (or parks it `failed` when a sub-repo's `fusion/<id>` branch is gone with no `landedSha`), and `task:reconcile-workspace-partial-land-no-action` when `autoMerge:false`, user-pause, or a live sub-repo worktree (workspace-aware liveness) blocks that backward move.
- Workspace (Phase D U1): self-healing emits `task:reclaim-phantom-workspace-land-lease` when it clears a leaked `workspace-repo-land` lease whose owning task is terminal/dead and older than the FN-6736 staleness floor (a live merging owner is left untouched).
- Workspace (Phase D U1): self-healing emits `task:reconcile-orphaned-workspace-worktree` when it removes a done/dead workspace task's recorded per-repo worktree from its stored `worktreePath` (guarded by `isPathActive`; no temp-root walk).
- FN-8144: archive emits `archive-workspace-worktree-disposer-missing` when a workspace archive has no store-scoped backend disposer; per-repository archive removal is awaited under canonical-path reservations, with failed paths quarantined for successor reconciliation.
- FN-7514: the planner overseer's per-task oversight loop (`PlannerRecoveryController.tick`) emits `overseer:oversight-withheld-human-control` when the pure `evaluateOverseerHumanControl` guard withholds ALL oversight action (no steering, retry, targeted-fix, or pending confirmation) for a task that is user-paused (`task.userPaused===true`, or `task.paused===true` with no `pausedReason`) or ineligible for auto-merge processing per `allowsAutoMergeProcessing` (`autoMerge:false`/PR-based human-review terminal contract). The guard runs BEFORE FN-7513's confirmation classification, so a withheld task never records a pending confirmation. Metadata: `{ taskId, reason: "user-paused" | "auto-merge-off-human-review", stage, oversightLevel }`; deduped per (taskId, withheld reason) so it is not re-emitted every poll while the reason is unchanged.
- FN-7720: `TaskStore.bypassFailedPreMergeReviewStep` emits `task:bypass-review` when a privileged operator bypasses the latest failed pre-merge review step of an `in-review` task; metadata includes `workflowStepId`, `workflowStepName`, `bypassedFromStatus`, `bypassedFromVerdict`, and the mandatory `reason`. The bypass rewrites the step's `status` to `"skipped"` with `bypassedBy`/`bypassedAt`/`bypassReason`/`bypassedFromStatus` fields; it never fabricates a reviewer `verdict` and clears only the failed-pre-merge-step `getTaskMergeBlocker` reason. Reachable via `fn_task_bypass_review` (CLI/pi-extension operator tool surface only — not executor/reviewer/triage) and `POST /tasks/:id/bypass-review`.
- FN-7996: executor emits `task:execution-tool-failure-retry` for a claimed same-model consecutive-tool-failure retry and `task:execution-tool-failure-retry-exhausted` when the matching run budget is spent. Metadata is ids/counts/outcomes-only; the exhausted event is emitted once through a project-scoped compare-and-set while terminal parking remains idempotent.

View File

@@ -106,6 +106,10 @@ Dashboard surfaces this as a yellow Duplicate chip plus modal actions only while
This layer complements, rather than replaces, FN-4829 similarity detection, FN-4918 deterministic deduplication, and FN-4892 same-agent intake heuristics.
### Workspace worktree cleanup on archive
Archiving a workspace (multi-repository) task now synchronously removes every recorded per-sub-repository worktree, including archives initiated by `fn_task_archive` and CLI paths that do not construct an executor. Each path is protected by a per-repository cross-process reservation until backend removal and branch cleanup finish. If one removal fails, its reservation is quarantined and the next acquisition reconciles that orphan; successful sibling repositories are still released. `archiveTask(..., { cleanup: false })` intentionally retains worktrees, and the self-healing workspace sweep remains an idempotent backstop.
#### Explicit duplicate-marker guard (FN-5220)
Fusion also recognizes the canonical one-line redirect marker:

View File

@@ -0,0 +1,59 @@
import {describe, expect, it} from "vitest";
import {join} from "node:path";
import {
ArchiveWorkspaceDisposalError,
ArchiveWorkspaceDisposalIncompleteError,
ArchiveWorkspaceWorktreeDisposerMissingError,
getArchiveWorkspaceWorktreeDisposer,
registerArchiveWorkspaceWorktreeDisposer,
type TaskStore,
} from "../index.js";
import {buildWorkspaceDisposalPlan} from "../task-store/archive-lifecycle.js";
describe("workspace archive worktree disposer seam", () => {
it("is store scoped and identity-guarded during executor replacement", async () => {
const storeA = {} as TaskStore;
const storeB = {} as TaskStore;
const baseline = async () => ({removed: [], failed: []});
const executor = async () => ({removed: [], failed: []});
const removeBaseline = registerArchiveWorkspaceWorktreeDisposer(storeA, baseline);
registerArchiveWorkspaceWorktreeDisposer(storeB, executor);
const removeExecutor = registerArchiveWorkspaceWorktreeDisposer(storeA, executor);
removeBaseline();
expect(getArchiveWorkspaceWorktreeDisposer(storeA)).toBe(executor);
expect(getArchiveWorkspaceWorktreeDisposer(storeB)).toBe(executor);
removeExecutor();
expect(getArchiveWorkspaceWorktreeDisposer(storeA)).toBeUndefined();
expect(getArchiveWorkspaceWorktreeDisposer(storeB)).toBe(executor);
});
it("retains typed outcome identity for incomplete and missing removal handling", () => {
expect(new ArchiveWorkspaceDisposalError("partial", ["repo-a"], [{repoRel: "repo-b", error: new Error("failed")}]).removed).toEqual(["repo-a"]);
expect(new ArchiveWorkspaceDisposalIncompleteError("repo-c").message).toContain("repo-c");
expect(new ArchiveWorkspaceWorktreeDisposerMissingError("repo-d").message).toContain("repo-d");
});
it("builds one deterministic plan entry for aliases and a colliding singular path", async () => {
const rootDir = "/workspace";
const shared = join(rootDir, ".worktrees", "shared");
const task = {
worktree: shared,
workspaceWorktrees: {
"repo-b": {worktreePath: shared, branch: "fusion/b"},
"repo-a": {worktreePath: shared, branch: "fusion/a"},
},
} as never;
const {plan, singularDeduplicated} = await buildWorkspaceDisposalPlan({rootDir} as TaskStore, task);
expect(plan).toEqual([{
repoRel: "repo-a",
worktreePath: shared,
branch: "fusion/a",
repoRootDir: join(rootDir, "repo-a"),
aliasRepoRels: ["repo-b", "__singular_worktree__"],
}]);
expect(singularDeduplicated).toBe(true);
});
});

View File

@@ -19,3 +19,55 @@ export function registerArchiveWorktreeDisposer(store: TaskStore, disposer: Arch
export function getArchiveWorktreeDisposer(store: TaskStore): ArchiveWorktreeDisposer | undefined {
return disposers.get(store);
}
/** A canonical-path-deduplicated workspace disposal unit owned by `repoRel`. */
export type WorkspaceDisposalPlanEntry = {
repoRel: string;
worktreePath: string;
branch: string;
repoRootDir: string;
aliasRepoRels: string[];
};
export type ArchiveWorkspaceDisposalResult = {
removed: string[];
failed: {repoRel: string; error: unknown}[];
};
export type ArchiveWorkspaceWorktreeDisposer = (
task: Task,
plan: WorkspaceDisposalPlanEntry[],
reservations: Record<string, WorktreePathReservation>,
) => Promise<ArchiveWorkspaceDisposalResult>;
export class ArchiveWorkspaceDisposalError extends Error {
constructor(message: string, readonly removed: string[], readonly failed: {repoRel: string; error: unknown}[]) {
super(message);
this.name = "ArchiveWorkspaceDisposalError";
}
}
export class ArchiveWorkspaceDisposalIncompleteError extends Error {
constructor(repoRel: string) {
super(`Workspace archive disposer did not report one unambiguous successful removal for ${repoRel}`);
this.name = "ArchiveWorkspaceDisposalIncompleteError";
}
}
export class ArchiveWorkspaceWorktreeDisposerMissingError extends Error {
constructor(repoRel: string) {
super(`No archive workspace worktree disposer is registered for ${repoRel}`);
this.name = "ArchiveWorkspaceWorktreeDisposerMissingError";
}
}
/*
FNXC:WorkflowLifecycle 2026-07-16-14:00:
Workspace archives have one destructive operation per sub-repository. Keep this
DI seam store-scoped so executor-less archive surfaces use the baseline backend
remover while an executor can replace only its own store with session-aware work.
*/
const workspaceDisposers = new WeakMap<TaskStore, ArchiveWorkspaceWorktreeDisposer>();
export function registerArchiveWorkspaceWorktreeDisposer(store: TaskStore, disposer: ArchiveWorkspaceWorktreeDisposer): () => void {
workspaceDisposers.set(store, disposer);
return () => { if (workspaceDisposers.get(store) === disposer) workspaceDisposers.delete(store); };
}
export function getArchiveWorkspaceWorktreeDisposer(store: TaskStore): ArchiveWorkspaceWorktreeDisposer | undefined {
return workspaceDisposers.get(store);
}

View File

@@ -531,7 +531,15 @@ export {
export {
registerArchiveWorktreeDisposer,
getArchiveWorktreeDisposer,
registerArchiveWorkspaceWorktreeDisposer,
getArchiveWorkspaceWorktreeDisposer,
ArchiveWorkspaceDisposalError,
ArchiveWorkspaceDisposalIncompleteError,
ArchiveWorkspaceWorktreeDisposerMissingError,
type ArchiveWorktreeDisposer,
type ArchiveWorkspaceWorktreeDisposer,
type WorkspaceDisposalPlanEntry,
type ArchiveWorkspaceDisposalResult,
} from "./archive-worktree-disposer.js";
export {
acquireWorktreePathReservation,

View File

@@ -546,7 +546,15 @@ export {
export {
registerArchiveWorktreeDisposer,
getArchiveWorktreeDisposer,
registerArchiveWorkspaceWorktreeDisposer,
getArchiveWorkspaceWorktreeDisposer,
ArchiveWorkspaceDisposalError,
ArchiveWorkspaceDisposalIncompleteError,
ArchiveWorkspaceWorktreeDisposerMissingError,
type ArchiveWorktreeDisposer,
type ArchiveWorkspaceWorktreeDisposer,
type WorkspaceDisposalPlanEntry,
type ArchiveWorkspaceDisposalResult,
} from "./archive-worktree-disposer.js";
export {
acquireWorktreePathReservation,

View File

@@ -22,7 +22,7 @@ import {softDeleteTaskRowInTransaction, readTaskRow as readTaskRowAsync} from ".
import {findLiveLineageChildren as findLiveLineageChildrenAsync, projectPartition, removeLineageReferences} from "../task-store/async-lifecycle.js";
import {archiveParentTaskWithLineageGate, findArchivedTaskEntry, deleteArchivedTaskEntry, restoreTaskFromArchive} from "../task-store/async-archive-lineage.js";
import {getArchivedRowCount, listArchivedTaskEntriesPage} from "../async-archive-db.js";
import {disposeArchivedWorktree} from "./archive-lifecycle.js";
import {disposeArchivedWorkspaceWorktrees, disposeArchivedWorktree, prepareArchivedWorkspaceWorktrees, releasePreparedWorkspaceArchiveDisposal} from "./archive-lifecycle.js";
export async function taskToArchiveEntryImpl(store: TaskStore, task: Task, archivedAt: string): Promise<ArchivedTaskEntry> {
const settings = await store.getSettingsFast();
@@ -178,13 +178,27 @@ export async function archiveTaskBackendImpl(store: TaskStore, id: string, optio
// Build the archive entry for cold storage.
const entry = await store.taskToArchiveEntry(task, archivedAt);
// Lineage gate + archive in one transaction.
const result = await archiveParentTaskWithLineageGate(layer, id, entry, {
removeLineageReferences: removeLineageRefs,
now: archivedAt,
});
/*
FNXC:WorkflowLifecycle 2026-07-16-15:30:
Backend archive persists cold storage before its cleanup phase. Hold the
per-repository reservations across that transaction so another process sees
the path as unavailable until the awaited workspace disposer has removed it.
*/
const preparedWorkspace = cleanup ? await prepareArchivedWorkspaceWorktrees(store, task) : undefined;
let result;
try {
// Lineage gate + archive in one transaction.
result = await archiveParentTaskWithLineageGate(layer, id, entry, {
removeLineageReferences: removeLineageRefs,
now: archivedAt,
});
} catch (error) {
if (preparedWorkspace) await releasePreparedWorkspaceArchiveDisposal(preparedWorkspace);
throw error;
}
if (!result.archived) {
if (preparedWorkspace) await releasePreparedWorkspaceArchiveDisposal(preparedWorkspace);
throw new TaskHasLineageChildrenError(id, result.liveChildIds);
}
@@ -197,7 +211,8 @@ export async function archiveTaskBackendImpl(store: TaskStore, id: string, optio
A rejected archive leaves its live task and pinned worktree untouched;
successful archives still await disposal before publishing the move event.
*/
await disposeArchivedWorktree(store, task);
const workspace = await disposeArchivedWorkspaceWorktrees(store, task, preparedWorkspace);
if (!workspace.singularDeduplicated) await disposeArchivedWorktree(store, task);
await store.cleanupBranchForTask(task);
const { rm } = await import("node:fs/promises");
await rm(dir, { recursive: true, force: true });

View File

@@ -9,12 +9,12 @@
import {TaskStore, storeLog} from "../store.js";
import {MissionStore} from "../mission-store.js";
import {TaskHasDependentsError, TaskHasLineageChildrenError, TaskSelfDeleteError} from "./errors.js";
import type {Task, Column, GithubIssueAction} from "../types.js";
import {isWorkspaceTask, type Task, type Column, type GithubIssueAction} from "../types.js";
import "../builtin-traits.js";
import {__setTaskActivityLogLimitsForTesting} from "../task-store/comments.js";
import {toJson} from "../db-helpers.js";
import {getErrorMessage} from "../error-message.js";
import {getArchiveWorktreeDisposer} from "../archive-worktree-disposer.js";
import {ArchiveWorkspaceDisposalError, ArchiveWorkspaceDisposalIncompleteError, ArchiveWorkspaceWorktreeDisposerMissingError, getArchiveWorkspaceWorktreeDisposer, getArchiveWorktreeDisposer, type ArchiveWorkspaceDisposalResult, type WorkspaceDisposalPlanEntry} from "../archive-worktree-disposer.js";
import {acquireWorktreePathReservation, canonicalizeWorktreePath} from "../worktree-path-reservation.js";
import {basename, join, resolve} from "node:path";
import {homedir} from "node:os";
@@ -24,6 +24,113 @@ function resolveArchiveWorktreesDir(store: TaskStore, configured?: string): stri
return value ? resolve(store.rootDir, value) : join(store.rootDir, ".worktrees");
}
export async function buildWorkspaceDisposalPlan(store: TaskStore, task: Task): Promise<{plan: WorkspaceDisposalPlanEntry[]; singularDeduplicated: boolean}> {
const entries = Object.entries(task.workspaceWorktrees ?? {}).sort(([a], [b]) => a.localeCompare(b));
const byCanonical = new Map<string, WorkspaceDisposalPlanEntry>();
for (const [repoRel, entry] of entries) {
const canonical = await canonicalizeWorktreePath(entry.worktreePath);
const repoRootDir = join(store.rootDir, repoRel);
const existing = byCanonical.get(canonical);
if (existing) existing.aliasRepoRels.push(repoRel);
else byCanonical.set(canonical, {repoRel, worktreePath: entry.worktreePath, branch: entry.branch, repoRootDir, aliasRepoRels: []});
}
let singularDeduplicated = false;
if (task.worktree) {
const canonical = await canonicalizeWorktreePath(task.worktree);
const existing = byCanonical.get(canonical);
if (existing) { existing.aliasRepoRels.push("__singular_worktree__"); singularDeduplicated = true; }
}
return {plan: [...byCanonical.values()], singularDeduplicated};
}
function normalizeWorkspaceDisposalResult(plan: WorkspaceDisposalPlanEntry[], result: ArchiveWorkspaceDisposalResult): {removed: Set<string>; failures: Map<string, unknown>} {
const owners = new Set(plan.map((entry) => entry.repoRel));
const counts = new Map<string, number>();
for (const repoRel of result.removed) counts.set(repoRel, (counts.get(repoRel) ?? 0) + 1);
const reportedFailures = new Map<string, unknown>();
for (const failure of result.failed) if (owners.has(failure.repoRel)) reportedFailures.set(failure.repoRel, failure.error);
const removed = new Set<string>();
const failures = new Map<string, unknown>();
for (const repoRel of owners) {
if (counts.get(repoRel) === 1 && !reportedFailures.has(repoRel)) removed.add(repoRel);
else failures.set(repoRel, reportedFailures.get(repoRel) ?? new ArchiveWorkspaceDisposalIncompleteError(repoRel));
}
return {removed, failures};
}
/*
FNXC:WorkflowLifecycle 2026-07-16-14:00:
FN-8105 reserved only the singular path. Workspace tasks retain one worktree per
sub-repo, so archive holds a canonical per-repo reservation through an awaited,
store-scoped disposal and quarantines every path not explicitly reported removed.
*/
export type PreparedWorkspaceArchiveDisposal = {
plan: WorkspaceDisposalPlanEntry[];
reservations: Record<string, Awaited<ReturnType<typeof acquireWorktreePathReservation>>>;
singularDeduplicated: boolean;
};
/**
* FNXC:WorkflowLifecycle 2026-07-16-15:30:
* The PostgreSQL archive commits its cold-storage row before filesystem cleanup.
* Acquire every workspace reservation before that mutation, then carry the held
* handles into disposal so a separate process cannot recreate a deterministic
* sub-repository worktree in the commit-to-removal window.
*/
export async function prepareArchivedWorkspaceWorktrees(store: TaskStore, task: Task): Promise<PreparedWorkspaceArchiveDisposal> {
if (!isWorkspaceTask(task)) return {plan: [], reservations: {}, singularDeduplicated: false};
const {plan, singularDeduplicated} = await buildWorkspaceDisposalPlan(store, task);
const reservations: PreparedWorkspaceArchiveDisposal["reservations"] = {};
if (plan.length === 0) return {plan, reservations, singularDeduplicated};
try {
const settings = await store.getSettings();
for (const entry of plan) {
const canonical = await canonicalizeWorktreePath(entry.worktreePath);
reservations[entry.repoRel] = await acquireWorktreePathReservation({
canonicalPath: canonical,
rootDir: entry.repoRootDir,
worktreesDir: resolveArchiveWorktreesDir({rootDir: entry.repoRootDir} as TaskStore, settings.worktreesDir),
});
}
return {plan, reservations, singularDeduplicated};
} catch (error) {
await releasePreparedWorkspaceArchiveDisposal({plan, reservations, singularDeduplicated});
throw error;
}
}
export async function releasePreparedWorkspaceArchiveDisposal(prepared: PreparedWorkspaceArchiveDisposal): Promise<void> {
for (const reservation of Object.values(prepared.reservations)) {
if (reservation.state === "held") await reservation.release();
}
}
export async function disposeArchivedWorkspaceWorktrees(store: TaskStore, task: Task, prepared = undefined as PreparedWorkspaceArchiveDisposal | undefined): Promise<{singularDeduplicated: boolean}> {
const disposal = prepared ?? await prepareArchivedWorkspaceWorktrees(store, task);
const {plan, reservations, singularDeduplicated} = disposal;
if (plan.length === 0) return {singularDeduplicated};
try {
const disposer = getArchiveWorkspaceWorktreeDisposer(store);
let result: ArchiveWorkspaceDisposalResult;
if (!disposer) {
storeLog.warn("archive-workspace-worktree-disposer-missing", {taskId: task.id, repos: plan.map((entry) => entry.repoRel)});
result = {removed: [], failed: plan.map((entry) => ({repoRel: entry.repoRel, error: new ArchiveWorkspaceWorktreeDisposerMissingError(entry.repoRel)}))};
} else {
try { result = await disposer(task, plan, reservations); }
catch (error) {
result = error instanceof ArchiveWorkspaceDisposalError
? {removed: error.removed, failed: error.failed}
: {removed: [], failed: plan.map((entry) => ({repoRel: entry.repoRel, error}))};
}
}
const normalized = normalizeWorkspaceDisposalResult(plan, result);
for (const [repoRel, error] of normalized.failures) await reservations[repoRel].quarantine(getErrorMessage(error));
} finally {
await releasePreparedWorkspaceArchiveDisposal(disposal);
}
return {singularDeduplicated};
}
export async function disposeArchivedWorktree(store: TaskStore, task: Task): Promise<void> {
if (!task.worktree) return;
const settings = await store.getSettings();
@@ -284,7 +391,8 @@ export async function archiveTaskImpl(store: TaskStore, id: string, optionsOrCle
until the awaited engine disposer finishes. The disposer is store-scoped
so executor-less fn/CLI archives cannot silently leak a worktree.
*/
await disposeArchivedWorktree(store, task);
const workspace = await disposeArchivedWorkspaceWorktrees(store, task);
if (!workspace.singularDeduplicated) await disposeArchivedWorktree(store, task);
const cleanedBranches = await store.cleanupBranchForTask(task);
if (cleanedBranches.length > 0) {
task.log.push({

View File

@@ -1,6 +1,10 @@
import {canonicalizeWorktreePath, getArchiveWorktreeDisposer, registerArchiveWorktreeDisposer, type Settings, type TaskStore} from "@fusion/core";
import {execFile} from "node:child_process";
import {promisify} from "node:util";
import {canonicalizeWorktreePath, getArchiveWorkspaceWorktreeDisposer, getArchiveWorktreeDisposer, registerArchiveWorkspaceWorktreeDisposer, registerArchiveWorktreeDisposer, type Settings, type TaskStore} from "@fusion/core";
import {removeWorktree, RemovalReason} from "./worktree-backend.js";
const execFileAsync = promisify(execFile);
/**
* FNXC:WorkflowLifecycle 2026-07-16-10:00:
* CLI/fn archive paths can own a store without constructing an executor. This
@@ -8,11 +12,28 @@ import {removeWorktree, RemovalReason} from "./worktree-backend.js";
* replace it with its session-aware disposer for the same store.
*/
export function installBaselineArchiveWorktreeDisposer(store: TaskStore, input: {rootDir: string; getSettings: () => Promise<Partial<Settings>>}): () => void {
if (getArchiveWorktreeDisposer(store)) return () => {};
return registerArchiveWorktreeDisposer(store, async (task) => {
const unregisterSingle = getArchiveWorktreeDisposer(store) ? () => {} : registerArchiveWorktreeDisposer(store, async (task) => {
if (!task.worktree) return;
if (await canonicalizeWorktreePath(task.worktree) === await canonicalizeWorktreePath(input.rootDir)) return;
await removeWorktree({worktreePath: task.worktree, rootDir: input.rootDir, settings: await input.getSettings(), taskId: task.id, reason: RemovalReason.ExecutorDispose, force: true});
task.worktree = undefined;
});
const unregisterWorkspace = getArchiveWorkspaceWorktreeDisposer(store) ? () => {} : registerArchiveWorkspaceWorktreeDisposer(store, async (task, plan) => {
const removed: string[] = [];
const failed: {repoRel: string; error: unknown}[] = [];
for (const entry of plan) {
try {
if (await canonicalizeWorktreePath(entry.worktreePath) === await canonicalizeWorktreePath(entry.repoRootDir)) throw new Error("Refusing to remove workspace repository root");
await removeWorktree({worktreePath: entry.worktreePath, rootDir: entry.repoRootDir, settings: await input.getSettings(), taskId: task.id, reason: RemovalReason.ExecutorDispose, force: true});
/* FNXC:WorkflowLifecycle 2026-07-16-16:00: Archive metadata can contain valid Git refs with shell metacharacters. Pass the ref as an argv value so cleanup never evaluates it as shell code. */
await execFileAsync("git", ["branch", "-D", entry.branch], {cwd: entry.repoRootDir, timeout: 120_000, maxBuffer: 10 * 1024 * 1024});
if (task.workspaceWorktrees) for (const repoRel of [entry.repoRel, ...entry.aliasRepoRels]) delete task.workspaceWorktrees[repoRel];
removed.push(entry.repoRel);
} catch (error) {
failed.push({repoRel: entry.repoRel, error});
}
}
return {removed, failed};
});
return () => { unregisterWorkspace(); unregisterSingle(); };
}

View File

@@ -1,10 +1,11 @@
// port-4040-allowlist: this file embeds the "never kill port 4040" rule in the executor prompt.
import { exec, execSync } from "node:child_process";
import { exec, execFile, execSync } from "node:child_process";
import { promisify } from "node:util";
import { setImmediate as setImmediateCb } from "node:timers";
// Internal git plumbing intentionally bypasses sandbox backends.
const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
const WORKFLOW_THINKING_LEVEL_SET: ReadonlySet<string> = new Set(THINKING_LEVELS);
@@ -135,7 +136,7 @@ import { deriveRepoScopeSubset, normalizeRepoRelPath } from "./workspace-paths.j
import { RemovalReason, classifyTaskWorktree, describeRegisteredWorktrees, detectGitRepository, detectNestedWorktreeRoot, getRegisteredWorktreePaths, isInsideWorktreesDir, isRegisteredGitWorktree, removeWorktree, type GitRepoDetection, type WorktreePool } from "./worktree-pool.js";
import { attemptBranchAutocorrect } from "./branch-autocorrect.js";
import { ActiveSessionWorktreeRemovalError } from "./worktree-backend.js";
import {canonicalizeWorktreePath, registerArchiveWorktreeDisposer} from "@fusion/core";
import {canonicalizeWorktreePath, registerArchiveWorkspaceWorktreeDisposer, registerArchiveWorktreeDisposer} from "@fusion/core";
import {
activeSessionRegistry,
executingTaskLock,
@@ -1712,6 +1713,7 @@ export class TaskExecutor {
* session being fully reaped before creating/acquiring a new worktree. */
private pendingTaskDisposals = new Map<string, Promise<void>>();
private unregisterArchiveWorktreeDisposer: (() => void) | undefined;
private unregisterArchiveWorkspaceWorktreeDisposer: (() => void) | undefined;
/** Active agent sessions per task, used to terminate on pause and inject steering. */
private activeSessions = new Map<string, ActiveExecutorSessionState>();
/** Active step-session executors per task (mutually exclusive with activeSessions). */
@@ -2966,6 +2968,23 @@ export class TaskExecutor {
await this.removeOwnWorktreeWithReconcile({worktreePath: task.worktree, settings: await store.getSettings(), taskId: task.id, reason: RemovalReason.ExecutorDispose});
task.worktree = undefined;
});
this.unregisterArchiveWorkspaceWorktreeDisposer = registerArchiveWorkspaceWorktreeDisposer(store, async (task, plan) => {
const removed: string[] = [];
const failed: {repoRel: string; error: unknown}[] = [];
await this.awaitAbortInFlightTaskWork(task.id, "workspace task archived");
for (const entry of plan) {
try {
if (await canonicalizeWorktreePath(entry.worktreePath) === await canonicalizeWorktreePath(entry.repoRootDir)) throw new Error("Refusing to remove workspace repository root");
activeSessionRegistry.unregisterPath(entry.worktreePath);
await removeWorktree({worktreePath: entry.worktreePath, rootDir: entry.repoRootDir, settings: await store.getSettings(), taskId: task.id, reason: RemovalReason.ExecutorDispose, force: true});
/* FNXC:WorkflowLifecycle 2026-07-16-16:00: Archive metadata can contain valid Git refs with shell metacharacters. Pass the ref as an argv value so cleanup never evaluates it as shell code. */
await execFileAsync("git", ["branch", "-D", entry.branch], {cwd: entry.repoRootDir, timeout: 120_000, maxBuffer: 10 * 1024 * 1024});
if (task.workspaceWorktrees) for (const repoRel of [entry.repoRel, ...entry.aliasRepoRels]) delete task.workspaceWorktrees[repoRel];
removed.push(entry.repoRel);
} catch (error) { failed.push({repoRel: entry.repoRel, error}); }
}
return {removed, failed};
});
store.on("task:moved", ({ task, from, to, source }) => {
executorLog.log(`[event:task:moved] ${task.id}: ${from} → ${to}`);
@@ -18123,6 +18142,8 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB
disposeArchiveWorktreeDisposer(): void {
this.unregisterArchiveWorktreeDisposer?.();
this.unregisterArchiveWorktreeDisposer = undefined;
this.unregisterArchiveWorkspaceWorktreeDisposer?.();
this.unregisterArchiveWorkspaceWorktreeDisposer = undefined;
}
private async removeOwnWorktreeWithReconcile(input: {