feat(FN-5346): add post-completion defensive backstop and shared reconcile

The merge adds a post-completion defensive backstop that probes and removes stale same-task `activeSessionRegistry` entries on `done`/`archived` transitions, completing FN-5346 with a shared reconcile helper, a defensive ownership probe wired into paused cleanup, audit event alignment, and regressio

Fusion-Task-Id: FN-5346
This commit is contained in:
Fusion (runfusion.ai)
2026-05-20 21:52:16 -07:00
committed by gsxdsm
parent e96cb09982
commit f798378693
11 changed files with 413 additions and 43 deletions

View File

@@ -207,6 +207,7 @@ Detailed mechanism logs live in `docs/architecture.md` and `docs/design/`. The c
- **Restart recovery**: `RestartRecoveryCoordinator` classifies interrupted `in-progress` runs. Unusable-worktree session-start failures (`missing`, `incomplete`, `unregistered git worktree`) are recoverable; retries are capped at `MAX_WORKTREE_SESSION_RETRIES=3` before escalating.
- **Executor pre-session liveness gate (FN-4935)**: the gate now skips for fresh acquisitions (`acquisition.source === "fresh"`), emits structured `not_usable_task_worktree:<classification>` diagnostics (including canonicalized registered-path snapshots) and a `worktree:incomplete-detected` audit event with `source: "executor-liveness-gate"`, while preserving the existing `taskDoneRetryCount` / `MAX_TASK_DONE_REQUEUE_RETRIES` requeue contract. FN-4651 `worktreeSessionRetryCount` remains scoped to the in-review/session-start recovery path.
- **Stale self-owned active-session reconcile on conflict cleanup (FN-4973)**: when executor worktree-conflict cleanup finds only a same-task stale `activeSessionRegistry` entry and no live in-memory `activeWorktrees` binding for that task/path, it must unregister the stale entry before `removeWorktree` (plus one-shot backstop reconcile on same-task `ActiveSessionWorktreeRemovalError` races). Foreign-task entries remain protected by FN-4811 and must never be reconciled by the requesting task.
- **Same-task stale removal canonical helper (FN-5346)**: executor same-task cleanup paths now route pre-removal reconciliation through `reconcileSelfOwnedActiveSessionForRemoval` (via executor helper wiring), so stale self-owned `activeSessionRegistry` residues are cleared only when no live in-memory binding exists, while FN-4811 foreign-owner refusals and live-owner protections remain intact.
- **Task title/ID drift (FN-4898)**: active and archived title writes normalize foreign embedded `FN-NNN` tokens via `packages/core/src/task-title-id-drift.ts`. Empty placeholder groups (`()`, `[]`, `{}`) left behind by token stripping are also removed in both `normalizeTitleForTaskId` and `sanitizeTitle` (FN-4978). Lineage is preserved in `sourceParentTaskId` / description markers, not title embeds. FN-5077 extends drift normalization to reject dangling-connector fragments (`"Close as duplicate of"`) so token-stripped residuals never persist as task titles.
- **PR-conflict reclaim wiring (FN-4763)**: GitHub PR refresh now persists normalized `prInfo.mergeable` conflict state and, when conflicting, funnels tasks into self-healings existing reclaim machinery (`reclaimPrConflictForTask` / `reclaim-pr-conflicts` stage) so branch-conflict handling stays centralized with existing `inspectBranchConflict` outcomes and unrecoverable pause semantics. PR refresh also captures `prInfo.conflictDiagnostics` (conflicting files + suggested local recovery commands) for dashboard surfacing.
- **Worktrunk-managed lifecycles**: when `worktrunk.enabled`, self-healing defers prune/idle/worktree-cap sweeps to the worktrunk backend; branch-level stale/ conflict reclaim stays native. Orphan `fusion/*` branches are operator-managed via standard git tooling (no auto-rescue task filing).
@@ -550,6 +551,7 @@ Reliability-layer changes are in scope. Interaction regression backstops live in
- FN-4887 backstop: `packages/engine/src/__tests__/reliability-interactions/foreign-only-contamination-recovery.real-git.test.ts` covers composition between bootstrap-misbinding, contamination dispatcher retry, misbound-in-review ordering, and FN-4811 active-session safeguards.
- FN-5039 backstop: `packages/engine/src/__tests__/reliability-interactions/worktree-contamination-attribution.real-git.test.ts` guards `captureModifiedFiles` trailer attribution filtering and `task:worktree-contamination-detected` audit fan-out across rebase contamination, clean, untrailered, and fallback paths.
- FN-4976 backstop: `packages/engine/src/__tests__/reliability-interactions/stale-self-owned-session-registry.test.ts` guards `cleanupConflictingWorktree` clearing stale same-task `activeSessionRegistry` entries before the FN-4811 foreign-owner check, while preserving refusal behavior for foreign owners and live same-task bindings.
- FN-5346 backstop: `packages/engine/src/__tests__/reliability-interactions/post-completion-stale-self-owned-binding.test.ts` covers post-completion and dep-abort same-task stale-binding cleanup, restart-residue recovery, same-task live-binding refusal, foreign-owner FN-4811 refusal, idempotent repeat sweeps, and FN-4954 lease-map composition.
- FN-4999 backstop: `packages/engine/src/__tests__/reliability-interactions/completion-handoff-limbo.test.ts` covers the `recoverCompletionHandoffLimbo` sweep stage (grace window, active-task skip, merge-blocker guard, capped retries, and audit fan-out).
- FN-5345/FN-5377 backstops: `packages/engine/src/__tests__/reliability-interactions/merge-reuse-task-worktree.test.ts` (`FN-5345: empty-own-diff branch auto-finalizes via early fast-path without acquiring reuse handoff`) covers the early no-op fast-path under drifted worktree mapping; `packages/engine/src/__tests__/real-git/prepare-commit-msg-empty-guard.real-git.test.ts` covers the empty-commit refusal hook (refuses `--allow-empty`, allows amend + real commits, no-op outside fusion worktrees).
- FN-5083 backstop: `packages/engine/src/__tests__/reliability-interactions/in-review-branch-rebind.test.ts` covers in-review branch rebind composition with metadata-cleared state, idempotent re-sweeps, and ambiguous-candidate skip behavior.

View File

@@ -55,6 +55,13 @@ Time-based stuck/stalled/stale surfaces now floor activity timestamps using `set
- Audit event: `task:stuck-no-progress-churn-terminalized` with `{ taskId, ignoredStepUpdateCount, stuckKillStreak, lastReason: "no-progress-churn" }`.
- Outcome: task is marked `status: "failed"`, moved to `in-review`, and not requeued; operators should decompose/rescope the task instead of waiting for more automatic stuck-kill retries.
## Stale self-owned active-session cleanup diagnostics (`[executor]`)
FN-5346 adds a same-task stale-binding reconcile marker before worktree removal:
- `[FN-5346] <taskId>: dropped stale self-owned activeSessionRegistry entry before removeWorktree at <worktreePath>`
- Follow-up task log entry: `Cleared stale self-owned active-session entry before remove`
## Reports health stale-classifier diagnostics (`[reports-health]`)
Direct-report stale decisions in `HeartbeatMonitor.buildReportsHealthSection()` now emit a structured log when an agent is marked `**stale**`.

View File

@@ -1,5 +1,8 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { activeSessionRegistry } from "../active-session-registry.js";
import {
activeSessionRegistry,
reconcileSelfOwnedActiveSessionForRemoval,
} from "../active-session-registry.js";
describe("activeSessionRegistry", () => {
beforeEach(() => {
@@ -62,4 +65,48 @@ describe("activeSessionRegistry", () => {
});
expect(activeSessionRegistry.lookupByPath("/tmp/w1")).toBeNull();
});
it("reconcileSelfOwnedActiveSessionForRemoval returns no-entry when path is unregistered", () => {
expect(
reconcileSelfOwnedActiveSessionForRemoval(activeSessionRegistry, "/tmp/missing", "FN-1", () => false),
).toEqual({ action: "no-entry" });
});
it("reconcileSelfOwnedActiveSessionForRemoval returns foreign-task without clearing", () => {
activeSessionRegistry.registerPath("/tmp/w1", { taskId: "FN-2", kind: "executor", ownerKey: "FN-2" });
expect(
reconcileSelfOwnedActiveSessionForRemoval(activeSessionRegistry, "/tmp/w1", "FN-1", () => false),
).toEqual({ action: "foreign-task", ownerTaskId: "FN-2" });
expect(activeSessionRegistry.lookupByPath("/tmp/w1")?.taskId).toBe("FN-2");
});
it("reconcileSelfOwnedActiveSessionForRemoval returns live-binding-refuses without clearing", () => {
activeSessionRegistry.registerPath("/tmp/w1", { taskId: "FN-1", kind: "executor", ownerKey: "FN-1" });
expect(
reconcileSelfOwnedActiveSessionForRemoval(activeSessionRegistry, "/tmp/w1", "FN-1", () => true),
).toEqual({ action: "live-binding-refuses", ownerTaskId: "FN-1" });
expect(activeSessionRegistry.lookupByPath("/tmp/w1")?.taskId).toBe("FN-1");
});
it("reconcileSelfOwnedActiveSessionForRemoval clears stale same-task entry", () => {
activeSessionRegistry.registerPath("/tmp/w1", { taskId: "FN-1", kind: "executor", ownerKey: "FN-1" });
expect(
reconcileSelfOwnedActiveSessionForRemoval(activeSessionRegistry, "/tmp/w1", "FN-1", () => false),
).toEqual({ action: "reconciled" });
expect(activeSessionRegistry.lookupByPath("/tmp/w1")).toBeNull();
});
it("reconcileSelfOwnedActiveSessionForRemoval is idempotent", () => {
activeSessionRegistry.registerPath("/tmp/w1", { taskId: "FN-1", kind: "executor", ownerKey: "FN-1" });
expect(
reconcileSelfOwnedActiveSessionForRemoval(activeSessionRegistry, "/tmp/w1", "FN-1", () => false),
).toEqual({ action: "reconciled" });
expect(
reconcileSelfOwnedActiveSessionForRemoval(activeSessionRegistry, "/tmp/w1", "FN-1", () => false),
).toEqual({ action: "no-entry" });
});
});

View File

@@ -28,7 +28,7 @@ describe("FN-4973: executor worktree conflict cleanup", () => {
expect(activeSessionRegistry.lookupByPath(CONFLICT_PATH)).toBeNull();
expect(store.logEntry).toHaveBeenCalledWith(
"FN-4973",
"Cleared stale self-owned activeSessionRegistry entry",
"Cleared stale self-owned active-session entry before remove",
CONFLICT_PATH,
);
});

View File

@@ -0,0 +1,128 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import "../executor-test-helpers.js";
import { TaskExecutor } from "../../executor.js";
import { activeSessionRegistry } from "../../active-session-registry.js";
import { ActiveSessionWorktreeRemovalError, RemovalReason } from "../../worktree-backend.js";
import { executorLog } from "../../logger.js";
import { WorktreePool } from "../../worktree-pool.js";
import * as worktreePoolModule from "../../worktree-pool.js";
import { createMockStore, mockedExistsSync, resetExecutorMocks } from "../executor-test-helpers.js";
const ROOT = "/tmp/test";
const PATH = "/tmp/test/.worktrees/fn-5346";
const TASK_ID = "FN-5346";
describe("FN-5346 reliability interactions: post-completion stale self-owned binding", () => {
beforeEach(() => {
resetExecutorMocks();
vi.restoreAllMocks();
activeSessionRegistry.clear();
mockedExistsSync.mockReturnValue(true);
});
it("reconciles stale same-task registry entry during cleanup()", async () => {
const store = createMockStore();
const executor = new TaskExecutor(store, ROOT);
(executor as any).activeWorktrees.set(TASK_ID, PATH);
activeSessionRegistry.registerPath(PATH, { taskId: TASK_ID, kind: "executor", ownerKey: TASK_ID });
const removeSpy = vi.spyOn(worktreePoolModule, "removeWorktree").mockResolvedValue(undefined);
await executor.cleanup(TASK_ID);
expect(removeSpy).toHaveBeenCalledTimes(1);
expect(activeSessionRegistry.lookupByPath(PATH)).toBeNull();
expect(store.logEntry).toHaveBeenCalledWith(
TASK_ID,
"Cleared stale self-owned active-session entry before remove",
PATH,
);
expect((executorLog.warn as any).mock.calls.some((call: unknown[]) => String(call[0]).includes("[FN-5346]"))).toBe(true);
});
it("recovers same stale registry entry on first attempt after restart-style fresh executor", async () => {
const store = createMockStore();
const executor = new TaskExecutor(store, ROOT);
activeSessionRegistry.registerPath(PATH, { taskId: TASK_ID, kind: "executor", ownerKey: TASK_ID });
const removeSpy = vi.spyOn(worktreePoolModule, "removeWorktree").mockResolvedValue(undefined);
await (executor as any).handleDepAbortCleanup(TASK_ID, PATH);
expect(removeSpy).toHaveBeenCalledTimes(1);
expect(activeSessionRegistry.lookupByPath(PATH)).toBeNull();
});
it("preserves refusal for truly-live same-task bindings", async () => {
const store = createMockStore();
const executor = new TaskExecutor(store, ROOT);
(executor as any).activeWorktrees.set(TASK_ID, PATH);
activeSessionRegistry.registerPath(PATH, { taskId: TASK_ID, kind: "executor", ownerKey: TASK_ID });
vi.spyOn(worktreePoolModule, "removeWorktree").mockRejectedValue(
new ActiveSessionWorktreeRemovalError({
worktreePath: PATH,
taskId: TASK_ID,
kind: "executor",
ownerKey: TASK_ID,
reason: RemovalReason.ExecutorDispose,
}),
);
await (executor as any).cleanupConflictingWorktree(PATH, "fusion/fn-5346", TASK_ID);
expect(activeSessionRegistry.lookupByPath(PATH)?.taskId).toBe(TASK_ID);
expect(store.logEntry).not.toHaveBeenCalledWith(
TASK_ID,
"Cleared stale self-owned active-session entry before remove",
PATH,
);
});
it("preserves FN-4811 refusal for foreign active owner", async () => {
const store = createMockStore();
store.listTasks.mockResolvedValue([]);
const executor = new TaskExecutor(store, ROOT);
(executor as any).activeWorktrees.set("FN-FOREIGN", PATH);
activeSessionRegistry.registerPath(PATH, { taskId: "FN-FOREIGN", kind: "executor", ownerKey: "FN-FOREIGN" });
const removeSpy = vi.spyOn(worktreePoolModule, "removeWorktree").mockResolvedValue(undefined);
const cleaned = await (executor as any).cleanupConflictingWorktree(PATH, "fusion/fn-5346", TASK_ID);
expect(cleaned).toBe(false);
expect(removeSpy).not.toHaveBeenCalled();
expect(activeSessionRegistry.lookupByPath(PATH)?.taskId).toBe("FN-FOREIGN");
});
it("is idempotent across repeated cleanup sweeps", async () => {
const store = createMockStore();
const executor = new TaskExecutor(store, ROOT);
(executor as any).activeWorktrees.set(TASK_ID, PATH);
activeSessionRegistry.registerPath(PATH, { taskId: TASK_ID, kind: "executor", ownerKey: TASK_ID });
const removeSpy = vi.spyOn(worktreePoolModule, "removeWorktree").mockResolvedValue(undefined);
await executor.cleanup(TASK_ID);
(executor as any).activeWorktrees.set(TASK_ID, PATH);
await executor.cleanup(TASK_ID);
const clearedCalls = (store.logEntry as any).mock.calls.filter(
(call: unknown[]) => call[1] === "Cleared stale self-owned active-session entry before remove",
);
expect(clearedCalls).toHaveLength(1);
expect(removeSpy).toHaveBeenCalledTimes(2);
});
it("preserves FN-4954 pool lease bookkeeping while reconciling stale registry", async () => {
const pool = new WorktreePool();
pool.rehydrate([PATH]);
expect(pool.acquire(TASK_ID)).toBe(PATH);
const beforeLeased = new Map(pool.getLeasedPaths());
const store = createMockStore();
const executor = new TaskExecutor(store, ROOT);
(executor as any).activeWorktrees.set(TASK_ID, PATH);
activeSessionRegistry.registerPath(PATH, { taskId: TASK_ID, kind: "executor", ownerKey: TASK_ID });
vi.spyOn(worktreePoolModule, "removeWorktree").mockResolvedValue(undefined);
await executor.cleanup(TASK_ID);
expect(new Map(pool.getLeasedPaths())).toEqual(beforeLeased);
});
});

View File

@@ -35,7 +35,7 @@ describe("FN-4976: stale self-owned activeSessionRegistry deadlock backstop", ()
expect(activeSessionRegistry.lookupByPath(PATH)).toBeNull();
expect(store.logEntry).toHaveBeenCalledWith(
TASK_ID,
"Cleared stale self-owned activeSessionRegistry entry",
"Cleared stale self-owned active-session entry before remove",
PATH,
);
});
@@ -53,7 +53,7 @@ describe("FN-4976: stale self-owned activeSessionRegistry deadlock backstop", ()
expect(activeSessionRegistry.lookupByPath(PATH)?.taskId).toBe("FN-OTHER");
const messages = store.logEntry.mock.calls.map((call: any[]) => String(call[1] ?? ""));
expect(messages.some((m: string) => m.includes("Refused to remove conflicting worktree"))).toBe(true);
expect(messages.some((m: string) => m.includes("Cleared stale self-owned activeSessionRegistry entry"))).toBe(false);
expect(messages.some((m: string) => m.includes("Cleared stale self-owned active-session entry before remove"))).toBe(false);
});
it("FN-4976 leaves behavior unchanged when no stale entry exists", async () => {
@@ -70,6 +70,6 @@ describe("FN-4976: stale self-owned activeSessionRegistry deadlock backstop", ()
expect(result).toBe(true);
expect(unregisterSpy).not.toHaveBeenCalledWith(PATH);
const messages = store.logEntry.mock.calls.map((call: any[]) => String(call[1] ?? ""));
expect(messages.some((m: string) => m.includes("Cleared stale self-owned activeSessionRegistry entry"))).toBe(false);
expect(messages.some((m: string) => m.includes("Cleared stale self-owned active-session entry before remove"))).toBe(false);
});
});

View File

@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import {
ActiveSessionWorktreeRemovalError,
NativeWorktreeBackend,
WorktrunkOperationError,
WorktrunkWorktreeBackend,
@@ -7,6 +8,7 @@ import {
resolveWorktreeBackend,
RemovalReason,
} from "../worktree-backend.js";
import { activeSessionRegistry } from "../active-session-registry.js";
const { execMock, accessMock, existsSyncMock, parseIndexLockPathMock, classifyStaleLockMock, tryRemoveStaleLockMock, parseStaleRegistrationPathMock, recoverStaleRegistrationMock, installGuardMock } = vi.hoisted(() => {
const mock = vi.fn();
@@ -73,6 +75,7 @@ beforeEach(() => {
recoverStaleRegistrationMock.mockResolvedValue({ recovered: true, actions: ["prune"] });
classifyStaleLockMock.mockResolvedValue({ kind: "fresh", reason: "fresh" });
tryRemoveStaleLockMock.mockResolvedValue({ removed: true });
activeSessionRegistry.clear();
});
describe("NativeWorktreeBackend", () => {
@@ -852,6 +855,93 @@ describe("removeWorktree", () => {
}),
).rejects.toMatchObject({ code: "worktrunk_binary_missing", operation: "remove" });
});
it("reconciles same-task stale active session when defensive owner probe says not live", async () => {
execMock.mockResolvedValue({ stdout: "", stderr: "" });
const audit = { git: vi.fn().mockResolvedValue(undefined) } as any;
activeSessionRegistry.registerPath("/repo/.worktrees/fn-1", {
taskId: "FN-1",
kind: "executor",
ownerKey: "FN-1/executor",
});
await removeWorktree({
rootDir: "/repo",
worktreePath: "/repo/.worktrees/fn-1",
settings: {},
audit,
reason: RemovalReason.ExecutorDispose,
expectedOwnerTaskId: "FN-1",
liveOwnerProbe: () => false,
});
expect(activeSessionRegistry.lookupByPath("/repo/.worktrees/fn-1")).toBeNull();
expect(audit.git).toHaveBeenCalledWith(
expect.objectContaining({
type: "worktree:active-session-reconciled",
target: "/repo/.worktrees/fn-1",
metadata: { taskId: "FN-1", source: "removeWorktree-defensive" },
}),
);
});
it("preserves refusal when same-task owner is still live", async () => {
activeSessionRegistry.registerPath("/repo/.worktrees/fn-1", {
taskId: "FN-1",
kind: "executor",
ownerKey: "FN-1/executor",
});
await expect(
removeWorktree({
rootDir: "/repo",
worktreePath: "/repo/.worktrees/fn-1",
settings: {},
reason: RemovalReason.ExecutorDispose,
expectedOwnerTaskId: "FN-1",
liveOwnerProbe: () => true,
}),
).rejects.toBeInstanceOf(ActiveSessionWorktreeRemovalError);
});
it("preserves foreign-owner refusal with defensive owner hints", async () => {
activeSessionRegistry.registerPath("/repo/.worktrees/fn-1", {
taskId: "FN-2",
kind: "executor",
ownerKey: "FN-2/executor",
});
await expect(
removeWorktree({
rootDir: "/repo",
worktreePath: "/repo/.worktrees/fn-1",
settings: {},
reason: RemovalReason.ExecutorDispose,
expectedOwnerTaskId: "FN-1",
liveOwnerProbe: () => false,
}),
).rejects.toMatchObject({
name: "ActiveSessionWorktreeRemovalError",
details: expect.objectContaining({ taskId: "FN-2" }),
});
});
it("keeps pre-FN-5346 behavior when defensive owner hints are omitted", async () => {
activeSessionRegistry.registerPath("/repo/.worktrees/fn-1", {
taskId: "FN-1",
kind: "executor",
ownerKey: "FN-1/executor",
});
await expect(
removeWorktree({
rootDir: "/repo",
worktreePath: "/repo/.worktrees/fn-1",
settings: {},
reason: RemovalReason.ExecutorDispose,
}),
).rejects.toBeInstanceOf(ActiveSessionWorktreeRemovalError);
});
});
describe("resolveWorktreeBackend", () => {

View File

@@ -15,7 +15,15 @@ export interface ReconcileStaleSelfOwnedResult {
reason: "no-entry" | "foreign-task" | "reconciled";
}
class ActiveSessionRegistry {
export type LiveBindingProbe = (worktreePath: string, taskId: string) => boolean;
export type SelfOwnedReconcileOutcome =
| { action: "no-entry" }
| { action: "foreign-task"; ownerTaskId: string }
| { action: "live-binding-refuses"; ownerTaskId: string }
| { action: "reconciled" };
export class ActiveSessionRegistry {
private readonly records = new Map<string, ActiveSessionRecord>();
registerPath(worktreePath: string, registration: ActiveSessionRegistration): void {
@@ -68,6 +76,29 @@ class ActiveSessionRegistry {
}
}
export function reconcileSelfOwnedActiveSessionForRemoval(
registry: ActiveSessionRegistry,
worktreePath: string,
requestingTaskId: string,
liveBindingProbe: LiveBindingProbe,
): SelfOwnedReconcileOutcome {
const record = registry.lookupByPath(worktreePath);
if (!record) {
return { action: "no-entry" };
}
if (record.taskId !== requestingTaskId) {
return { action: "foreign-task", ownerTaskId: record.taskId };
}
if (liveBindingProbe(worktreePath, requestingTaskId)) {
return { action: "live-binding-refuses", ownerTaskId: requestingTaskId };
}
registry.unregisterPath(worktreePath);
return { action: "reconciled" };
}
export const activeSessionRegistry = new ActiveSessionRegistry();
/**

View File

@@ -46,7 +46,11 @@ import { PRIORITY_EXECUTE, type AgentSemaphore } from "./concurrency.js";
import { RemovalReason, classifyTaskWorktree, describeRegisteredWorktrees, getRegisteredWorktreePaths, isGitRepository, isInsideWorktreesDir, isRegisteredGitWorktree, removeWorktree, type WorktreePool } from "./worktree-pool.js";
import { attemptBranchAutocorrect } from "./branch-autocorrect.js";
import { ActiveSessionWorktreeRemovalError } from "./worktree-backend.js";
import { activeSessionRegistry, executingTaskLock } from "./active-session-registry.js";
import {
activeSessionRegistry,
executingTaskLock,
reconcileSelfOwnedActiveSessionForRemoval,
} from "./active-session-registry.js";
import {
StaleWorktreeIndexLockError,
classifyStaleLock,
@@ -3478,6 +3482,8 @@ export class TaskExecutor {
taskId: task.id,
audit,
reason: RemovalReason.ExecutorTransientRetry,
expectedOwnerTaskId: task.id,
liveOwnerProbe: (path, ownerTaskId) => this.hasActiveWorktreeBinding(ownerTaskId, path),
});
} catch (wtErr: unknown) {
const msg = wtErr instanceof Error ? wtErr.message : String(wtErr);
@@ -3565,6 +3571,8 @@ export class TaskExecutor {
settings,
taskId: task.id,
reason: RemovalReason.ExecutorStuckKilled,
expectedOwnerTaskId: task.id,
liveOwnerProbe: (path, ownerTaskId) => this.hasActiveWorktreeBinding(ownerTaskId, path),
});
} catch (wtErr: unknown) {
const msg = wtErr instanceof Error ? wtErr.message : String(wtErr);
@@ -4490,6 +4498,8 @@ export class TaskExecutor {
taskId: task.id,
audit,
reason: RemovalReason.ExecutorDispose,
expectedOwnerTaskId: task.id,
liveOwnerProbe: (path, ownerTaskId) => this.hasActiveWorktreeBinding(ownerTaskId, path),
});
executorLog.log(`Removed old worktree for paused task: ${worktreePath}`);
} catch (cleanupErr: unknown) {
@@ -4896,6 +4906,8 @@ export class TaskExecutor {
taskId: task.id,
audit,
reason: RemovalReason.ExecutorTransientRetry,
expectedOwnerTaskId: task.id,
liveOwnerProbe: (path, ownerTaskId) => this.hasActiveWorktreeBinding(ownerTaskId, path),
});
executorLog.log(`Removed old worktree for transient retry: ${worktreePath}`);
} catch (cleanupErr: unknown) {
@@ -5025,6 +5037,8 @@ export class TaskExecutor {
taskId: task.id,
audit,
reason: RemovalReason.ExecutorStuckKilled,
expectedOwnerTaskId: task.id,
liveOwnerProbe: (path, ownerTaskId) => this.hasActiveWorktreeBinding(ownerTaskId, path),
});
executorLog.log(`Removed old worktree for stuck-killed retry: ${worktreePath}`);
} catch (cleanupErr: unknown) {
@@ -6146,9 +6160,8 @@ export class TaskExecutor {
// Remove worktree
try {
const settings = await this.store.getSettings();
await removeWorktree({
await this.removeOwnWorktreeWithReconcile({
worktreePath,
rootDir: this.rootDir,
settings,
taskId,
reason: RemovalReason.ExecutorDispose,
@@ -8507,6 +8520,8 @@ Backward compat fallback: if JSON is unavailable, you may still begin output wit
reason: RemovalReason.PoolPrune,
taskId: task.id,
audit,
expectedOwnerTaskId: task.id,
liveOwnerProbe: (path, ownerTaskId) => this.hasActiveWorktreeBinding(ownerTaskId, path),
});
} catch (removeErr) {
executorLog.warn(`${task.id}: failed to remove unusable session-start worktree ${staleWorktreePath}: ${formatError(removeErr)}`);
@@ -9151,19 +9166,68 @@ Backward compat fallback: if JSON is unavailable, you may still begin output wit
return false;
}
private async reconcileSelfOwnedBeforeRemove(worktreePath: string, taskId: string): Promise<void> {
const outcome = reconcileSelfOwnedActiveSessionForRemoval(
activeSessionRegistry,
worktreePath,
taskId,
(path, ownerTaskId) => this.hasActiveWorktreeBinding(ownerTaskId, path),
);
if (outcome.action === "reconciled") {
executorLog.warn(
`[FN-5346] ${taskId}: dropped stale self-owned activeSessionRegistry entry before removeWorktree at ${worktreePath}`,
);
await this.store.logEntry(taskId, "Cleared stale self-owned active-session entry before remove", worktreePath);
}
}
private async removeOwnWorktreeWithReconcile(input: {
worktreePath: string;
settings: Settings;
taskId: string;
reason: RemovalReason;
audit?: Parameters<typeof removeWorktree>[0]["audit"];
}): Promise<void> {
await this.reconcileSelfOwnedBeforeRemove(input.worktreePath, input.taskId);
const removeArgs = {
worktreePath: input.worktreePath,
rootDir: this.rootDir,
settings: input.settings,
taskId: input.taskId,
reason: input.reason,
audit: input.audit,
expectedOwnerTaskId: input.taskId,
liveOwnerProbe: (path: string, ownerTaskId: string) => this.hasActiveWorktreeBinding(ownerTaskId, path),
} as const;
try {
await removeWorktree(removeArgs);
} catch (error: unknown) {
if (
error instanceof ActiveSessionWorktreeRemovalError
&& error.details.taskId === input.taskId
&& !this.hasActiveWorktreeBinding(input.taskId, input.worktreePath)
) {
const reconcileResult = activeSessionRegistry.reconcileStaleSelfOwned(input.worktreePath, input.taskId);
if (reconcileResult.reconciled) {
await this.store.logEntry(
input.taskId,
"Reconciled stale self-owned active-session registration (post-throw)",
input.worktreePath,
);
}
await removeWorktree(removeArgs);
return;
}
throw error;
}
}
private async cleanupConflictingWorktree(
worktreePath: string,
branch: string,
taskId: string,
): Promise<boolean> {
const activeSessionRecord = activeSessionRegistry.lookupByPath(worktreePath);
if (activeSessionRecord?.taskId === taskId && !this.hasActiveWorktreeBinding(taskId, worktreePath)) {
executorLog.warn(
`[FN-4976] ${taskId}: clearing stale self-owned activeSessionRegistry entry before cleanup for ${worktreePath}`,
);
activeSessionRegistry.unregisterPath(worktreePath);
await this.store.logEntry(taskId, "Cleared stale self-owned activeSessionRegistry entry", worktreePath);
}
await this.reconcileSelfOwnedBeforeRemove(worktreePath, taskId);
// FN-4811: Hard liveness gate — refuse to remove a worktree that is currently bound to
// an active executor/merger session, regardless of git-level conflict classification.
@@ -9192,33 +9256,12 @@ Backward compat fallback: if JSON is unavailable, you may still begin output wit
// Remove the worktree
const settings = await this.store.getSettings();
const removeArgs = {
await this.removeOwnWorktreeWithReconcile({
worktreePath,
rootDir: this.rootDir,
settings,
taskId,
reason: RemovalReason.ExecutorDispose,
} as const;
try {
await removeWorktree(removeArgs);
} catch (error: unknown) {
if (
error instanceof ActiveSessionWorktreeRemovalError
&& error.details.taskId === taskId
&& !this.hasActiveWorktreeBinding(taskId, worktreePath)
) {
const reconcileResult = activeSessionRegistry.reconcileStaleSelfOwned(worktreePath, taskId);
if (reconcileResult.reconciled) {
await this.store.logEntry(taskId, "Reconciled stale self-owned active-session registration", worktreePath);
executorLog.log(
`[executor] reconciled stale self-owned active-session entry taskId=${taskId} worktreePath=${worktreePath} reason=${reconcileResult.reason}`,
);
}
await removeWorktree(removeArgs);
} else {
throw error;
}
}
});
await this.store.logEntry(taskId, `Removed conflicting worktree`, worktreePath);
// Delete the branch if it exists
@@ -9433,9 +9476,8 @@ Backward compat fallback: if JSON is unavailable, you may still begin output wit
try {
const settings = await this.store.getSettings();
await removeWorktree({
await this.removeOwnWorktreeWithReconcile({
worktreePath,
rootDir: this.rootDir,
settings,
taskId,
reason: RemovalReason.ExecutorDispose,

View File

@@ -134,6 +134,7 @@ export type GitMutationType =
| "worktree:admin-entry-pruned"
| "worktree:removal-refused-active-session"
| "worktree:removal-forced-over-active-session"
| "worktree:active-session-reconciled"
| "worktree:stale-lock-detected"
| "worktree:stale-lock-recovered"
| "worktree:stale-lock-recovery-failed"

View File

@@ -4,7 +4,11 @@ import { access } from "node:fs/promises";
import { basename, resolve } from "node:path";
import { promisify } from "node:util";
import type { Settings } from "@fusion/core";
import { activeSessionRegistry } from "./active-session-registry.js";
import {
activeSessionRegistry,
reconcileSelfOwnedActiveSessionForRemoval,
type LiveBindingProbe,
} from "./active-session-registry.js";
import type { RunAuditor } from "./run-audit.js";
import { resolveTaskWorktreePath } from "./worktree-paths.js";
import { inspectBranchConflict } from "./branch-conflicts.js";
@@ -776,6 +780,8 @@ export async function removeWorktree(input: {
audit?: RunAuditor;
force?: boolean;
timeout?: number;
expectedOwnerTaskId?: string;
liveOwnerProbe?: LiveBindingProbe;
}): Promise<void> {
const logger = {
log: (_message: string): void => {},
@@ -786,6 +792,22 @@ export async function removeWorktree(input: {
throw new InvalidForceUsageError(input.reason);
}
if (input.expectedOwnerTaskId && input.liveOwnerProbe) {
const reconciled = reconcileSelfOwnedActiveSessionForRemoval(
activeSessionRegistry,
input.worktreePath,
input.expectedOwnerTaskId,
input.liveOwnerProbe,
);
if (reconciled.action === "reconciled") {
await input.audit?.git({
type: "worktree:active-session-reconciled",
target: input.worktreePath,
metadata: { taskId: input.expectedOwnerTaskId, source: "removeWorktree-defensive" },
});
}
}
const active = activeSessionRegistry.lookupByPath(input.worktreePath);
if (active && input.force !== true) {
await input.audit?.git({