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>
138 lines
4.9 KiB
TypeScript
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);
|
|
});
|
|
});
|