Files
fusion/packages/engine/src/__tests__/executor-soft-delete-abort.test.ts
gsxdsm 7d25d98b2f fix(FN-5256): await disposal across task:moved-away and task:deleted
Close the last fire-and-forget gap from the previous fixes: the task:moved
(away from in-progress) and task:deleted listeners no longer call the
synchronous fire-and-forget `abortInFlightTaskWork`. Instead they track an
awaited disposal promise per task in `pendingTaskDisposals`. The task:moved
(to in-progress) dispatch path awaits any in-flight disposal for the same
task before calling `execute()`, so a fast bounce (in-progress → todo →
in-progress) no longer races the conflict-cleanup path against a still-live
shell.

`awaitAbortInFlightTaskWork` now claims each session surface (activeSessions,
activeStepExecutors, activeWorkflowStepSessions, activeSubagentSessions)
synchronously before awaiting any async abort. This lets concurrent disposal
calls for the same task dedupe naturally — the second call finds the maps
empty and no-ops, preserving the existing single-abort/single-dispose
contract that the soft-delete and user-cancel tests assert.

Adds a regression test in executor-user-cancel covering the re-dispatch
ordering: an immediate task:moved-to-in-progress that follows a still-running
task:moved-away must wait for abort to complete before execute() runs.

The legacy `abortInFlightTaskWork` is removed (no callers).

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

138 lines
4.9 KiB
TypeScript

import "./executor-test-helpers.js";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Task } from "@fusion/core";
import { TaskExecutor } from "../executor.js";
import { executorLog } from "../logger.js";
import { resetExecutorMocks } from "./executor-test-helpers.js";
type Listener = (...args: any[]) => void;
function createEventedStore() {
const listeners = new Map<string, Set<Listener>>();
return {
store: {
on: vi.fn((event: string, listener: Listener) => {
const set = listeners.get(event) ?? new Set<Listener>();
set.add(listener);
listeners.set(event, set);
}),
off: vi.fn((event: string, listener: Listener) => {
listeners.get(event)?.delete(listener);
}),
getSettings: vi.fn().mockResolvedValue({ globalPause: false, enginePaused: false }),
listTasks: vi.fn().mockResolvedValue([]),
} as any,
emit(event: string, ...args: any[]) {
for (const listener of listeners.get(event) ?? []) {
listener(...args);
}
},
};
}
function makeTask(id: string): Task {
return {
id,
title: id,
description: "desc",
status: "open",
column: "in-progress",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
dependencies: [],
comments: [],
steps: [],
currentStep: 0,
log: [],
} as unknown as Task;
}
describe("TaskExecutor soft-delete aborts", () => {
beforeEach(() => {
resetExecutorMocks();
vi.clearAllMocks();
});
it("aborts and disposes an active agent session on task:deleted", async () => {
const { store, emit } = createEventedStore();
const stuckTaskDetector = { untrackTask: vi.fn() };
const executor = new TaskExecutor(store, "/tmp/test", { stuckTaskDetector } as any);
const abort = vi.fn().mockResolvedValue(undefined);
const dispose = vi.fn();
(executor as any).activeSessions.set("FN-TEST-1", {
session: { abort, dispose },
seenSteeringIds: new Set<string>(),
});
emit("task:deleted", makeTask("FN-TEST-1"));
await (executor as any).pendingTaskDisposals.get("FN-TEST-1");
expect(abort).toHaveBeenCalledTimes(1);
expect(dispose).toHaveBeenCalledTimes(1);
expect((executor as any).activeSessions.has("FN-TEST-1")).toBe(false);
expect((executor as any).pausedAborted.has("FN-TEST-1")).toBe(true);
expect((executor as any).userCanceledTaskIds.has("FN-TEST-1")).toBe(true);
expect(stuckTaskDetector.untrackTask).toHaveBeenCalledWith("FN-TEST-1");
});
it("aborts and removes an active step-session executor on task:deleted", async () => {
const { store, emit } = createEventedStore();
const executor = new TaskExecutor(store, "/tmp/test");
const abortAllSessionBash = vi.fn();
const terminateAllSessions = vi.fn().mockResolvedValue(undefined);
(executor as any).activeStepExecutors.set("FN-TEST-2", {
abortAllSessionBash,
terminateAllSessions,
});
emit("task:deleted", makeTask("FN-TEST-2"));
await (executor as any).pendingTaskDisposals.get("FN-TEST-2");
expect(abortAllSessionBash).toHaveBeenCalledTimes(1);
expect(terminateAllSessions).toHaveBeenCalledTimes(1);
expect((executor as any).activeStepExecutors.has("FN-TEST-2")).toBe(false);
});
it("aborts and disposes an active workflow session on task:deleted", async () => {
const { store, emit } = createEventedStore();
const executor = new TaskExecutor(store, "/tmp/test");
const abort = vi.fn().mockResolvedValue(undefined);
const dispose = vi.fn();
(executor as any).activeWorkflowStepSessions.set("FN-TEST-3", { abort, dispose });
emit("task:deleted", makeTask("FN-TEST-3"));
await (executor as any).pendingTaskDisposals.get("FN-TEST-3");
expect(abort).toHaveBeenCalledTimes(1);
expect(dispose).toHaveBeenCalledTimes(1);
expect((executor as any).activeWorkflowStepSessions.has("FN-TEST-3")).toBe(false);
});
it("disposes reviewer subagents on task:deleted", () => {
const { store, emit } = createEventedStore();
const executor = new TaskExecutor(store, "/tmp/test");
const dispose = vi.fn();
(executor as any).registerSubagentSession("FN-TEST-4", { dispose });
emit("task:deleted", makeTask("FN-TEST-4"));
expect(dispose).toHaveBeenCalledTimes(1);
expect((executor as any).activeSubagentSessions.has("FN-TEST-4")).toBe(false);
});
it("is a silent no-op when the deleted task has no active surfaces", () => {
const { store, emit } = createEventedStore();
const executor = new TaskExecutor(store, "/tmp/test");
const errorSpy = vi.spyOn(executorLog, "error");
expect(() => emit("task:deleted", makeTask("FN-TEST-5"))).not.toThrow();
expect(errorSpy).not.toHaveBeenCalled();
expect((executor as any).pausedAborted.has("FN-TEST-5")).toBe(true);
expect((executor as any).userCanceledTaskIds.has("FN-TEST-5")).toBe(true);
});
});