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:
committed by
gsxdsm
parent
e96cb09982
commit
f798378693
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user