diff --git a/packages/engine/src/__tests__/executor-pause.test.ts b/packages/engine/src/__tests__/executor-pause.test.ts index e2acc80b48..e3667b32d9 100644 --- a/packages/engine/src/__tests__/executor-pause.test.ts +++ b/packages/engine/src/__tests__/executor-pause.test.ts @@ -14,6 +14,7 @@ import { generateWorktreeName, slugify } from "../worktree-names.js"; import type { Task, TaskDetail } from "@fusion/core"; import { SessionManager } from "@earendil-works/pi-coding-agent"; import { StepSessionExecutor } from "../step-session-executor.js"; +import { activeSessionRegistry } from "../active-session-registry.js"; import { executorLog } from "../logger.js"; import { withRateLimitRetry } from "../rate-limit-retry.js"; import { runVerificationCommand as mockedRunVerificationCommand } from "../verification-utils.js"; @@ -1041,11 +1042,13 @@ describe("TaskExecutor agent execution flow (FN-978)", () => { steer: vi.fn(), }; - // Simulate an active session - (executor as any).activeSessions.set("FN-001", { + const worktreePath = "/tmp/test/.worktrees/fn-001"; + (executor as any).addActiveWorktree("FN-001", worktreePath); + (executor as any).setActiveSession("FN-001", { session: mockSession, seenSteeringIds: new Set(), - }); + }, worktreePath); + expect(activeSessionRegistry.isPathActive(worktreePath)).toBe(true); const task = { id: "FN-001", @@ -1070,6 +1073,7 @@ describe("TaskExecutor agent execution flow (FN-978)", () => { // Verify session was disposed and removed from map expect(disposeSpy).toHaveBeenCalled(); expect((executor as any).activeSessions.has("FN-001")).toBe(false); + expect(activeSessionRegistry.isPathActive(worktreePath)).toBe(false); // Verify task was added to pausedAborted set expect((executor as any).pausedAborted.has("FN-001")).toBe(true); }); @@ -1085,8 +1089,10 @@ describe("TaskExecutor agent execution flow (FN-978)", () => { cleanup: vi.fn().mockResolvedValue(undefined), }; - // Simulate an active step executor - (executor as any).activeStepExecutors.set("FN-002", mockStepExecutor as any); + const worktreePath = "/tmp/test/.worktrees/fn-002"; + (executor as any).addActiveWorktree("FN-002", worktreePath); + (executor as any).setActiveStepExecutor("FN-002", mockStepExecutor as any, worktreePath); + expect(activeSessionRegistry.isPathActive(worktreePath)).toBe(true); const task = { id: "FN-002", @@ -1112,6 +1118,42 @@ describe("TaskExecutor agent execution flow (FN-978)", () => { expect(mockTerminateAllSessions).toHaveBeenCalled(); // Verify removed from map expect((executor as any).activeStepExecutors.has("FN-002")).toBe(false); + expect(activeSessionRegistry.isPathActive(worktreePath)).toBe(false); + }); + + it("terminates active workflow-step session when task is moved away", async () => { + const store = createMockStore(); + const executor = new TaskExecutor(store, "/tmp/test"); + const worktreePath = "/tmp/test/.worktrees/fn-006"; + const abort = vi.fn().mockResolvedValue(undefined); + const dispose = vi.fn(); + + (executor as any).addActiveWorktree("FN-006", worktreePath); + (executor as any).setActiveWorkflowStepSession("FN-006", { abort, dispose } as any, worktreePath); + expect(activeSessionRegistry.isPathActive(worktreePath)).toBe(true); + + const task = { + id: "FN-006", + title: "Test Task", + description: "Test", + column: "todo" as const, + dependencies: [], + steps: [], + currentStep: 0, + log: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + store._trigger("task:moved", { task, from: "in-progress", to: "triage" }); + + await waitForAsyncExpectation(() => { + expect(abort).toHaveBeenCalled(); + }); + + expect(dispose).toHaveBeenCalled(); + expect((executor as any).activeWorkflowStepSessions.has("FN-006")).toBe(false); + expect(activeSessionRegistry.isPathActive(worktreePath)).toBe(false); }); it("handles graceful no-op when no active session exists", async () => { diff --git a/packages/engine/src/__tests__/executor-test-helpers.ts b/packages/engine/src/__tests__/executor-test-helpers.ts index 641277b269..8a17d1e403 100644 --- a/packages/engine/src/__tests__/executor-test-helpers.ts +++ b/packages/engine/src/__tests__/executor-test-helpers.ts @@ -289,7 +289,7 @@ import { hydrateWorktreeDb } from "../worktree-db-hydrate.js"; import { classifyTaskWorktree, describeRegisteredWorktrees, isUsableTaskWorktree } from "../worktree-pool.js"; import { classifyStaleLock, tryRemoveStaleLock } from "../worktree-stale-lock.js"; import { parseStaleRegistrationPath, recoverStaleRegistration } from "../worktree-stale-registration.js"; -import { executingTaskLock } from "../active-session-registry.js"; +import { activeSessionRegistry, executingTaskLock } from "../active-session-registry.js"; export const mockedCreateFnAgent = vi.mocked(createFnAgent); export const mockedSessionManager = vi.mocked(SessionManager); @@ -438,9 +438,7 @@ export function resetExecutorMocks() { mockTerminateAllSessions.mockResolvedValue(undefined); mockCleanup.mockResolvedValue(undefined); mockSteerActiveSessions.mockResolvedValue(undefined); - // FN-4811 follow-up: the executingTaskLock is process-wide module state, so it must - // be cleared between tests or earlier tests' claims will block later tests' execute() - // calls ("expected at least 2 createFnAgent calls but got 0" / "expected not called - // but called 3 times" symptoms in executor-pause / executor-prompt tests). + // FNXC:ExecutorTests 2026-06-24-21:09: Executor liveness guards are process-wide module state, so test reset must clear both executing locks and active-session registry paths; otherwise earlier tests' claims can block later execute() calls with duplicate-execution or foreign active-session path symptoms. executingTaskLock._clearForTest(); + activeSessionRegistry.clear(); } diff --git a/packages/engine/src/__tests__/executor-worktree.test.ts b/packages/engine/src/__tests__/executor-worktree.test.ts index d521447ac7..c7e24d7a7e 100644 --- a/packages/engine/src/__tests__/executor-worktree.test.ts +++ b/packages/engine/src/__tests__/executor-worktree.test.ts @@ -2174,7 +2174,7 @@ describe("TaskExecutor worktree pool integration", () => { return "" as any; }); mockedExec.mockImplementation(((cmd: any, _opts: any, cb: any) => { - if (String(cmd).includes("git merge-base HEAD origin/main")) { + if (String(cmd).includes("git merge-base HEAD")) { cb(null, "newbase123\n", ""); return {} as any; } diff --git a/packages/engine/src/__tests__/reliability-interactions/owning-node-unavailable-interactions.test.ts b/packages/engine/src/__tests__/reliability-interactions/owning-node-unavailable-interactions.test.ts index da1d315671..19be468dfe 100644 --- a/packages/engine/src/__tests__/reliability-interactions/owning-node-unavailable-interactions.test.ts +++ b/packages/engine/src/__tests__/reliability-interactions/owning-node-unavailable-interactions.test.ts @@ -263,7 +263,7 @@ describe("reliability interactions: owning-node unavailable handoff", () => { await scheduler.schedule(); expect(store.logEntry).toHaveBeenCalledWith(task.id, expect.stringContaining("Owning-node handoff applied")); - expect(store.updateTask).toHaveBeenCalledWith(task.id, expect.objectContaining({ effectiveNodeId: null })); expect(store.moveTask).toHaveBeenCalledWith(task.id, "in-progress", expect.any(Object)); + expect(store.updateTask).not.toHaveBeenCalledWith(task.id, expect.objectContaining({ effectiveNodeId: "node-b" })); }); }); diff --git a/packages/engine/src/__tests__/restart.integration.test.ts b/packages/engine/src/__tests__/restart.integration.test.ts index d366de61fe..60bf4b4444 100644 --- a/packages/engine/src/__tests__/restart.integration.test.ts +++ b/packages/engine/src/__tests__/restart.integration.test.ts @@ -1096,7 +1096,13 @@ describe("Scheduler after restart", () => { expect(store.moveTask).toHaveBeenCalledWith("FN-070", "in-progress", expect.objectContaining({ allocateWorktree: expect.any(Function) })); expect(store.updateTask).toHaveBeenCalledWith("FN-070", expect.objectContaining({ status: null, blockedBy: null })); - expect(onSchedule).toHaveBeenCalledWith(todoTask); + expect(onSchedule).toHaveBeenCalledWith(expect.objectContaining({ + id: todoTask.id, + column: "in-progress", + effectiveNodeSource: "local", + dispatchStormCount: 1, + blockedBy: undefined, + })); }); it("schedule() respects dependency ordering — blocked tasks stay in todo", async () => {