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:
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(); };
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user