Files
fusion/packages/engine/src/__tests__/executor-worktree-conflict.test.ts
gsxdsm cf0101be7c fix(FN-5256): keep live task worktrees through pause/resume races
Three independent reliability fixes that all surfaced as the same bug:
live tasks losing their worktrees mid-execution and emitting
`wrong_toplevel` errors.

Fix A — executor stale-self-owned classifier:
`reconcileSelfOwnedActiveSessionForRemoval` now takes a process-active
probe (`executingTaskLock.has`) and a minimum-idle window (default 5s)
in addition to the existing in-memory `activeWorktrees` binding probe.
Recently-registered or still-running entries are refused with
`process-active-refuses` / `too-recent-refuses`, with audit-grade
log lines. Both the pre-remove path
(`reconcileSelfOwnedBeforeRemove`), the post-throw retry in
`removeOwnWorktreeWithReconcile`, and the defensive reconcile in
`removeWorktree` route through the same hardened gates.

Fix B — pause synchronously reaps the agent session:
New `awaitAbortInFlightTaskWork` mirrors the existing fire-and-forget
abort but awaits each `session.abort()` /
`stepExecutor.terminateAllSessions()` /
`workflowSession.abort()`. `parkTaskAfterWorkflowStepPause` calls it
before `moveTask("todo")`, and the `task:updated` user-pause handler
routes through it, so a fast re-dispatch can no longer race a still-
live shell.

Fix C — self-healing realpath + active-task skip:
`reconcileTaskWorktreeMetadata` now realpath-normalizes both sides of
the registry comparison (handling macOS `/private/var/...`) and
refuses to clear `worktree`/`branch` on in-progress or in-review
tasks. The skip emits a new
`task:auto-recover-worktree-metadata-skipped-active` audit event;
executor-level recovery paths remain in charge of active tasks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 09:06:23 -07:00

109 lines
4.7 KiB
TypeScript

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 } from "../worktree-backend.js";
import * as worktreePoolModule from "../worktree-pool.js";
import { createMockStore, resetExecutorMocks } from "./executor-test-helpers.js";
const CONFLICT_PATH = "/tmp/test/.worktrees/stale-self-owned";
describe("FN-4973: executor worktree conflict cleanup", () => {
beforeEach(() => {
resetExecutorMocks();
activeSessionRegistry.clear();
});
it("clears stale self-owned registry entry before removal", async () => {
const store = createMockStore();
const executor = new TaskExecutor(store, "/tmp/test");
store.listTasks.mockResolvedValue([]);
activeSessionRegistry.registerPath(CONFLICT_PATH, { taskId: "FN-4973", kind: "executor", ownerKey: "FN-4973" });
// FN-5256: backdate so the new min-idle window doesn't refuse the reconcile.
(activeSessionRegistry.lookupByPath(CONFLICT_PATH) as any).registeredAt = 0;
const removeSpy = vi.spyOn(worktreePoolModule, "removeWorktree").mockResolvedValue(undefined);
const result = await (executor as any).cleanupConflictingWorktree(CONFLICT_PATH, "fusion/fn-4973", "FN-4973");
expect(result).toBe(true);
expect(removeSpy).toHaveBeenCalled();
expect(activeSessionRegistry.lookupByPath(CONFLICT_PATH)).toBeNull();
expect(store.logEntry).toHaveBeenCalledWith(
"FN-4973",
"Cleared stale self-owned active-session entry before remove",
CONFLICT_PATH,
);
});
it("does not reconcile when same-task in-memory binding is live and refuses removal", async () => {
const store = createMockStore();
const executor = new TaskExecutor(store, "/tmp/test");
store.listTasks.mockResolvedValue([]);
(executor as any).activeWorktrees.set("FN-4973", CONFLICT_PATH);
activeSessionRegistry.registerPath(CONFLICT_PATH, { taskId: "FN-4973", kind: "executor", ownerKey: "FN-4973" });
vi.spyOn(worktreePoolModule, "removeWorktree").mockRejectedValue(
new ActiveSessionWorktreeRemovalError({
worktreePath: CONFLICT_PATH,
taskId: "FN-4973",
kind: "executor",
ownerKey: "FN-4973",
reason: worktreePoolModule.RemovalReason.ExecutorDispose,
}),
);
const result = await (executor as any).cleanupConflictingWorktree(CONFLICT_PATH, "fusion/fn-4973", "FN-4973");
expect(result).toBe(false);
expect(activeSessionRegistry.lookupByPath(CONFLICT_PATH)?.taskId).toBe("FN-4973");
});
it("does not reconcile foreign-task registry entries and keeps refusal behavior", async () => {
const store = createMockStore();
const executor = new TaskExecutor(store, "/tmp/test");
store.listTasks.mockResolvedValue([]);
activeSessionRegistry.registerPath(CONFLICT_PATH, { taskId: "FN-OTHER", kind: "executor", ownerKey: "FN-OTHER" });
vi.spyOn(worktreePoolModule, "removeWorktree").mockRejectedValue(
new ActiveSessionWorktreeRemovalError({
worktreePath: CONFLICT_PATH,
taskId: "FN-OTHER",
kind: "executor",
ownerKey: "FN-OTHER",
reason: worktreePoolModule.RemovalReason.ExecutorDispose,
}),
);
const result = await (executor as any).cleanupConflictingWorktree(CONFLICT_PATH, "fusion/fn-4973", "FN-4973");
expect(result).toBe(false);
expect(activeSessionRegistry.lookupByPath(CONFLICT_PATH)?.taskId).toBe("FN-OTHER");
});
it("reconciles once on race-window ActiveSessionWorktreeRemovalError then retries removal", async () => {
const store = createMockStore();
const executor = new TaskExecutor(store, "/tmp/test");
store.listTasks.mockResolvedValue([]);
const removeSpy = vi.spyOn(worktreePoolModule, "removeWorktree");
removeSpy
.mockImplementationOnce(async () => {
activeSessionRegistry.registerPath(CONFLICT_PATH, { taskId: "FN-4973", kind: "executor", ownerKey: "FN-4973" });
// FN-5256: backdate so the post-throw reconcile is not refused by min-idle.
(activeSessionRegistry.lookupByPath(CONFLICT_PATH) as any).registeredAt = 0;
throw new ActiveSessionWorktreeRemovalError({
worktreePath: CONFLICT_PATH,
taskId: "FN-4973",
kind: "executor",
ownerKey: "FN-4973",
reason: worktreePoolModule.RemovalReason.ExecutorDispose,
});
})
.mockResolvedValueOnce(undefined);
const result = await (executor as any).cleanupConflictingWorktree(CONFLICT_PATH, "fusion/fn-4973", "FN-4973");
expect(result).toBe(true);
expect(removeSpy).toHaveBeenCalledTimes(2);
expect(activeSessionRegistry.lookupByPath(CONFLICT_PATH)).toBeNull();
});
});