Files
fusion/packages/engine/src/stuck-task-detector.test.ts
gsxdsm 5647e500cf feat(KB-332): rename task prefix from KB to FN and branches from kb/ to fusion/
- Change default task prefix from KB to FN across all packages

- Rename branch naming pattern from kb/{id} to fusion/{id}

- Update all test assertions and fixtures to use new prefixes

- Add changeset for the breaking change

- Resolve merge conflicts in TaskCard.test.tsx touch gesture tests
2026-03-31 19:29:47 -07:00

347 lines
9.8 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { StuckTaskDetector } from "./stuck-task-detector.js";
import type { TaskStore } from "@fusion/core";
// Mock store factory
function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
return {
getSettings: vi.fn().mockResolvedValue({}),
logEntry: vi.fn().mockResolvedValue(undefined),
updateTask: vi.fn().mockResolvedValue(undefined),
moveTask: vi.fn().mockResolvedValue(undefined),
...overrides,
} as unknown as TaskStore;
}
// Mock disposable session
function createMockSession(): { dispose: ReturnType<typeof vi.fn> } {
return {
dispose: vi.fn(),
};
}
describe("StuckTaskDetector", () => {
let store: TaskStore;
let detector: StuckTaskDetector;
beforeEach(() => {
store = createMockStore();
detector = new StuckTaskDetector(store);
});
afterEach(() => {
detector.stop();
});
describe("constructor", () => {
it("initializes with default options", () => {
expect(detector).toBeDefined();
expect(detector.trackedCount).toBe(0);
});
it("accepts custom poll interval", () => {
const customDetector = new StuckTaskDetector(store, { pollIntervalMs: 5000 });
expect(customDetector).toBeDefined();
});
it("accepts onStuck callback", () => {
const onStuck = vi.fn();
const customDetector = new StuckTaskDetector(store, { onStuck });
expect(customDetector).toBeDefined();
});
});
describe("start/stop", () => {
it("starts and stops the polling loop", () => {
detector.start();
detector.stop();
// Should not throw
});
it("is safe to stop when not started", () => {
detector.stop();
// Should not throw
});
it("is safe to start multiple times", () => {
detector.start();
detector.start(); // Second call should no-op
detector.stop();
});
});
describe("trackTask", () => {
it("adds task to tracking", () => {
const session = createMockSession();
detector.trackTask("FN-001", session);
expect(detector.trackedCount).toBe(1);
});
it("sets initial activity timestamp", () => {
const session = createMockSession();
const before = Date.now();
detector.trackTask("FN-001", session);
const after = Date.now();
const lastActivity = detector.getLastActivity("FN-001");
expect(lastActivity).toBeDefined();
expect(lastActivity).toBeGreaterThanOrEqual(before);
expect(lastActivity).toBeLessThanOrEqual(after);
});
it("can track multiple tasks", () => {
detector.trackTask("FN-001", createMockSession());
detector.trackTask("FN-002", createMockSession());
expect(detector.trackedCount).toBe(2);
});
});
describe("untrackTask", () => {
it("removes task from tracking", () => {
detector.trackTask("FN-001", createMockSession());
expect(detector.trackedCount).toBe(1);
detector.untrackTask("FN-001");
expect(detector.trackedCount).toBe(0);
});
it("is safe to untrack untracked task", () => {
detector.untrackTask("FN-001");
expect(detector.trackedCount).toBe(0);
});
});
describe("recordActivity", () => {
it("updates last activity timestamp", () => {
const session = createMockSession();
vi.useFakeTimers({ shouldAdvanceTime: true });
detector.trackTask("FN-001", session);
const initialActivity = detector.getLastActivity("FN-001")!;
// Advance time
vi.advanceTimersByTime(10);
detector.recordActivity("FN-001");
const newActivity = detector.getLastActivity("FN-001")!;
expect(newActivity).toBeGreaterThanOrEqual(initialActivity);
vi.useRealTimers();
});
it("does nothing for untracked task", () => {
// Should not throw
detector.recordActivity("FN-001");
});
});
describe("isStuck", () => {
it("returns false when no timeout exceeded", () => {
const session = createMockSession();
detector.trackTask("FN-001", session);
expect(detector.isStuck("FN-001", 60000)).toBe(false);
});
it("returns true when timeout exceeded", () => {
const session = createMockSession();
vi.useFakeTimers({ shouldAdvanceTime: true });
detector.trackTask("FN-001", session);
vi.advanceTimersByTime(61000); // Advance 61 seconds
expect(detector.isStuck("FN-001", 60000)).toBe(true);
vi.useRealTimers();
});
it("returns false for untracked task", () => {
expect(detector.isStuck("FN-001", 60000)).toBe(false);
});
});
describe("killAndRetry", () => {
it("disposes the session", async () => {
const session = createMockSession();
detector.trackTask("FN-001", session);
vi.useFakeTimers({ shouldAdvanceTime: true });
vi.advanceTimersByTime(61000);
await detector.killAndRetry("FN-001", 60000);
expect(session.dispose).toHaveBeenCalled();
vi.useRealTimers();
});
it("removes task from tracking", async () => {
const session = createMockSession();
detector.trackTask("FN-001", session);
expect(detector.trackedCount).toBe(1);
vi.useFakeTimers({ shouldAdvanceTime: true });
vi.advanceTimersByTime(61000);
await detector.killAndRetry("FN-001", 60000);
expect(detector.trackedCount).toBe(0);
vi.useRealTimers();
});
it("logs to task log", async () => {
const session = createMockSession();
detector.trackTask("FN-001", session);
vi.useFakeTimers({ shouldAdvanceTime: true });
vi.advanceTimersByTime(61000);
await detector.killAndRetry("FN-001", 60000);
expect(store.logEntry).toHaveBeenCalledWith(
"FN-001",
expect.stringContaining("Task terminated due to stuck agent session")
);
vi.useRealTimers();
});
it("updates task status and moves to todo", async () => {
const session = createMockSession();
detector.trackTask("FN-001", session);
vi.useFakeTimers({ shouldAdvanceTime: true });
vi.advanceTimersByTime(61000);
await detector.killAndRetry("FN-001", 60000);
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: "stuck-killed" });
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo");
vi.useRealTimers();
});
it("calls onStuck callback", async () => {
const onStuck = vi.fn();
const customDetector = new StuckTaskDetector(store, { onStuck });
const session = createMockSession();
customDetector.trackTask("FN-001", session);
vi.useFakeTimers({ shouldAdvanceTime: true });
vi.advanceTimersByTime(61000);
await customDetector.killAndRetry("FN-001", 60000);
expect(onStuck).toHaveBeenCalledWith("FN-001");
vi.useRealTimers();
});
it("does nothing for untracked task", async () => {
await detector.killAndRetry("FN-001", 60000);
// Should not throw
expect(store.moveTask).not.toHaveBeenCalled();
});
});
describe("checkNow", () => {
it("checks stuck tasks immediately", async () => {
store = createMockStore({
getSettings: vi.fn().mockResolvedValue({ taskStuckTimeoutMs: 60000 }),
});
const customDetector = new StuckTaskDetector(store);
const session = createMockSession();
customDetector.trackTask("FN-001", session);
vi.useFakeTimers({ shouldAdvanceTime: true });
vi.advanceTimersByTime(61000);
await customDetector.checkNow();
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo");
vi.useRealTimers();
});
});
describe("checkStuckTasks (via polling)", () => {
it("does nothing when no tasks tracked", async () => {
store = createMockStore({
getSettings: vi.fn().mockResolvedValue({ taskStuckTimeoutMs: 60000 }),
});
const customDetector = new StuckTaskDetector(store);
// Start and let it poll
customDetector.start();
vi.useFakeTimers({ shouldAdvanceTime: true });
vi.advanceTimersByTime(35000); // Default poll interval is 30s
expect(store.moveTask).not.toHaveBeenCalled();
customDetector.stop();
vi.useRealTimers();
});
it("does nothing when timeout is disabled", async () => {
store = createMockStore({
getSettings: vi.fn().mockResolvedValue({ taskStuckTimeoutMs: undefined }),
});
const customDetector = new StuckTaskDetector(store);
const session = createMockSession();
customDetector.trackTask("FN-001", session);
vi.useFakeTimers({ shouldAdvanceTime: true });
vi.advanceTimersByTime(61000);
await customDetector.checkNow();
expect(store.moveTask).not.toHaveBeenCalled();
vi.useRealTimers();
});
it("does nothing when timeout is zero or negative", async () => {
store = createMockStore({
getSettings: vi.fn().mockResolvedValue({ taskStuckTimeoutMs: 0 }),
});
const customDetector = new StuckTaskDetector(store);
const session = createMockSession();
customDetector.trackTask("FN-001", session);
vi.useFakeTimers({ shouldAdvanceTime: true });
vi.advanceTimersByTime(61000);
await customDetector.checkNow();
expect(store.moveTask).not.toHaveBeenCalled();
vi.useRealTimers();
});
it("skips check when settings cannot be read", async () => {
store = createMockStore({
getSettings: vi.fn().mockRejectedValue(new Error("Settings error")),
});
const customDetector = new StuckTaskDetector(store);
const session = createMockSession();
customDetector.trackTask("FN-001", session);
vi.useFakeTimers({ shouldAdvanceTime: true });
vi.advanceTimersByTime(61000);
await customDetector.checkNow();
// Should not throw, just skip
expect(store.moveTask).not.toHaveBeenCalled();
vi.useRealTimers();
});
});
});