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>
109 lines
4.7 KiB
TypeScript
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();
|
|
});
|
|
});
|