diff --git a/.changeset/fn-8144-archive-removes-workspace-worktrees.md b/.changeset/fn-8144-archive-removes-workspace-worktrees.md new file mode 100644 index 0000000000..80b1a607a5 --- /dev/null +++ b/.changeset/fn-8144-archive-removes-workspace-worktrees.md @@ -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. diff --git a/AGENTS.md b/AGENTS.md index a2185c8223..9ea3f4ef5a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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/` 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. diff --git a/docs/task-management.md b/docs/task-management.md index a96dc47b18..55b6fc194c 100644 --- a/docs/task-management.md +++ b/docs/task-management.md @@ -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: diff --git a/packages/core/src/__tests__/archive-removes-workspace-worktrees.test.ts b/packages/core/src/__tests__/archive-removes-workspace-worktrees.test.ts new file mode 100644 index 0000000000..84358bf5e8 --- /dev/null +++ b/packages/core/src/__tests__/archive-removes-workspace-worktrees.test.ts @@ -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); + }); +}); diff --git a/packages/core/src/archive-worktree-disposer.ts b/packages/core/src/archive-worktree-disposer.ts index 15a059f7e6..9241b0a261 100644 --- a/packages/core/src/archive-worktree-disposer.ts +++ b/packages/core/src/archive-worktree-disposer.ts @@ -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, +) => Promise; + +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(); +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); +} diff --git a/packages/core/src/index.gate.ts b/packages/core/src/index.gate.ts index abe33061c9..2c0738027a 100644 --- a/packages/core/src/index.gate.ts +++ b/packages/core/src/index.gate.ts @@ -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, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 39e777f731..3a5d8b9a1b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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, diff --git a/packages/core/src/task-store/archive-lifecycle-2.ts b/packages/core/src/task-store/archive-lifecycle-2.ts index 0ea954b3b3..08b064d927 100644 --- a/packages/core/src/task-store/archive-lifecycle-2.ts +++ b/packages/core/src/task-store/archive-lifecycle-2.ts @@ -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 { 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 }); diff --git a/packages/core/src/task-store/archive-lifecycle.ts b/packages/core/src/task-store/archive-lifecycle.ts index 8307ea22f8..5ee95b1542 100644 --- a/packages/core/src/task-store/archive-lifecycle.ts +++ b/packages/core/src/task-store/archive-lifecycle.ts @@ -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(); + 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; failures: Map} { + const owners = new Set(plan.map((entry) => entry.repoRel)); + const counts = new Map(); + for (const repoRel of result.removed) counts.set(repoRel, (counts.get(repoRel) ?? 0) + 1); + const reportedFailures = new Map(); + for (const failure of result.failed) if (owners.has(failure.repoRel)) reportedFailures.set(failure.repoRel, failure.error); + const removed = new Set(); + const failures = new Map(); + 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>>; + 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 { + 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 { + 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 { 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({ diff --git a/packages/engine/src/archive-worktree-disposer-install.ts b/packages/engine/src/archive-worktree-disposer-install.ts index 33e5801940..fb2a659b51 100644 --- a/packages/engine/src/archive-worktree-disposer-install.ts +++ b/packages/engine/src/archive-worktree-disposer-install.ts @@ -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>}): () => 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(); }; } diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index 4c4c3469c3..36ba2894ea 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -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 = 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>(); 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(); /** 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: {