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

@@ -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({