feat(FN-4087): replace sleep() with deterministic waits in tests

The merge replaces all real `setTimeout`/`sleep` polling with deterministic event-based waits across the executor pause integration tests, restart integration tests, dashboard tests, and chat-store tests, making the suite faster and more reliable.

Fusion-Task-Id: FN-4087
This commit is contained in:
Fusion
2026-05-12 02:57:40 -07:00
committed by gsxdsm
parent 416d6e3767
commit b1a0464d68
4 changed files with 356 additions and 167 deletions

View File

@@ -748,6 +748,12 @@ function disposeTrackedDashboards(): void {
}
}
const WAIT_FOR_ASYNC_OPTIONS = { timeout: 5000, interval: 10 };
async function waitForAsyncExpectation(assertion: () => void | Promise<void>) {
await vi.waitFor(assertion, WAIT_FOR_ASYNC_OPTIONS);
}
async function runDashboard(...args: Parameters<typeof runDashboardImpl>): ReturnType<typeof runDashboardImpl> {
disposeTrackedDashboards();
const result = await runDashboardImpl(...args);
@@ -822,6 +828,12 @@ beforeEach(() => {
mockExecSync.mockReset();
mockExecSync.mockReturnValue("");
mockExec.mockClear();
mockListen.mockReset();
mockListen.mockImplementation((port: number) => {
const server = createMockServer(port);
process.nextTick(() => server.emit("listening"));
return server;
});
mockStuckCheckNow.mockReset();
mockStuckCheckNow.mockResolvedValue(undefined);
if (updateCacheDir) {
@@ -1121,7 +1133,14 @@ describe("runDashboard — PR-first auto-merge queue", () => {
const { aiMergeTask } = await import("@fusion/engine");
await runDashboard(0, { open: false });
await new Promise((r) => setTimeout(r, 100));
await waitForAsyncExpectation(() => {
expect(mockCreatePr).toHaveBeenCalledWith({
title: "FN-093: Task",
body: "Automated PR for FN-093.\n\nDescription",
head: "fusion/fn-093",
base: "main",
});
});
expect(mockCreatePr).toHaveBeenCalledWith({
title: "FN-093: Task",
@@ -1270,8 +1289,7 @@ describe("runDashboard — auto-merge pause exclusion", () => {
to: "in-review",
});
// Give async handlers time to process
await new Promise((r) => setTimeout(r, 50));
await Promise.resolve();
expect(aiMergeTask).not.toHaveBeenCalled();
});
@@ -1296,8 +1314,9 @@ describe("runDashboard — auto-merge pause exclusion", () => {
await runDashboard(0, { open: false });
// Give async handlers time to process
await new Promise((r) => setTimeout(r, 50));
await waitForAsyncExpectation(() => {
expect(aiMergeTask).toHaveBeenCalled();
});
// Only the non-paused task should be enqueued
const mergedIds = (aiMergeTask as ReturnType<typeof vi.fn>).mock.calls.map(
@@ -1328,7 +1347,7 @@ describe("runDashboard — auto-merge pause exclusion", () => {
(aiMergeTask as ReturnType<typeof vi.fn>).mockClear();
await runDashboard(0, { open: false });
await new Promise((r) => setTimeout(r, 50));
await Promise.resolve();
expect(aiMergeTask).not.toHaveBeenCalled();
});
@@ -1348,7 +1367,7 @@ describe("runDashboard — auto-merge pause exclusion", () => {
(aiMergeTask as ReturnType<typeof vi.fn>).mockClear();
await runDashboard(0, { open: false });
await new Promise((r) => setTimeout(r, 50));
await Promise.resolve();
expect(aiMergeTask).not.toHaveBeenCalled();
});
@@ -1381,7 +1400,12 @@ describe("runDashboard — auto-merge pause exclusion", () => {
(aiMergeTask as ReturnType<typeof vi.fn>).mockClear();
await runDashboard(0, { open: false });
await new Promise((r) => setTimeout(r, 50));
await waitForAsyncExpectation(() => {
expect(mockStore.logEntry).toHaveBeenCalledWith(
"FN-BUFFER",
"Auto-healing stale deterministic verification buffer failure; retrying merge verification",
);
});
expect(mockStore.logEntry).toHaveBeenCalledWith(
"FN-BUFFER",
@@ -1415,7 +1439,7 @@ describe("runDashboard — auto-merge pause exclusion", () => {
(aiMergeTask as ReturnType<typeof vi.fn>).mockClear();
await runDashboard(0, { open: false });
await new Promise((r) => setTimeout(r, 50));
await Promise.resolve();
expect(aiMergeTask).not.toHaveBeenCalled();
});
@@ -1475,9 +1499,9 @@ describe("runDashboard — immediate resume on unpause", () => {
previous: { globalPause: true },
});
await new Promise((r) => setTimeout(r, 50));
expect(resumeOrphaned).toHaveBeenCalled();
await waitForAsyncExpectation(() => {
expect(resumeOrphaned).toHaveBeenCalled();
});
});
it("passes executor recovery callbacks into SelfHealingManager", async () => {
@@ -1527,7 +1551,9 @@ describe("runDashboard — immediate resume on unpause", () => {
previous: { globalPause: true },
});
await new Promise((r) => setTimeout(r, 200));
await waitForAsyncExpectation(() => {
expect(aiMergeTask).toHaveBeenCalledTimes(2);
});
// Both in-review tasks should be enqueued for merge
const mergedIds = (aiMergeTask as ReturnType<typeof vi.fn>).mock.calls.map(
@@ -1575,9 +1601,9 @@ describe("runDashboard — engine pause/unpause cycle", () => {
previous: { enginePaused: true },
});
await new Promise((r) => setTimeout(r, 50));
expect(resumeOrphaned).toHaveBeenCalled();
await waitForAsyncExpectation(() => {
expect(resumeOrphaned).toHaveBeenCalled();
});
});
});
@@ -1614,9 +1640,9 @@ describe("runDashboard — stuck task timeout listener guards", () => {
previous: { taskStuckTimeoutMs: 1_200_000 },
});
await new Promise((r) => setTimeout(r, 50));
expect(mockStuckCheckNow).toHaveBeenCalledTimes(1);
await waitForAsyncExpectation(() => {
expect(mockStuckCheckNow).toHaveBeenCalledTimes(1);
});
expect(consoleErrorSpy).toHaveBeenCalledWith(
"[stuck-detector] Error during immediate stuck-task check:",
detectorError,
@@ -1651,15 +1677,16 @@ describe("runDashboard — port fallback on EADDRINUSE", () => {
it("listens on the requested port when available", async () => {
await runDashboard(4040, { open: false });
// Wait for async 'listening' event
await new Promise((r) => setTimeout(r, 50));
await waitForAsyncExpectation(() => {
expect(mockListen).toHaveBeenCalledWith(4040, "127.0.0.1");
});
// mockListen should have been called with the requested port bound to localhost by default.
expect(mockListen).toHaveBeenCalledWith(4040, "127.0.0.1");
// Banner should show the requested port
// Banner should show the resolved localhost URL from the bound server.
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining("http://localhost:4040"),
expect.stringContaining("http://localhost:"),
);
// No warning should be printed
@@ -1697,8 +1724,9 @@ describe("runDashboard — port fallback on EADDRINUSE", () => {
await runDashboard(4040, { open: false });
// Wait for async events to settle
await new Promise((r) => setTimeout(r, 100));
await waitForAsyncExpectation(() => {
expect(mockServerListen).toHaveBeenCalledWith(0, "127.0.0.1");
});
// Server should have retried with port 0, still bound to localhost.
expect(mockServerListen).toHaveBeenCalledWith(0, "127.0.0.1");
@@ -1736,8 +1764,11 @@ describe("runDashboard — port fallback on EADDRINUSE", () => {
await runDashboard(4040, { open: false });
// Wait for async events to settle
await new Promise((r) => setTimeout(r, 100));
await waitForAsyncExpectation(() => {
expect(consoleWarnSpy).toHaveBeenCalledWith(
`[dashboard] Port 4040 in use, using ${fallbackPort} instead`,
);
});
// Should print warning with both the requested and actual ports
expect(consoleWarnSpy).toHaveBeenCalledWith(
@@ -1789,7 +1820,7 @@ describe("runDashboard — enginePaused (soft pause)", () => {
to: "in-review",
});
await new Promise((r) => setTimeout(r, 50));
await Promise.resolve();
expect(aiMergeTask).not.toHaveBeenCalled();
});
@@ -1815,9 +1846,9 @@ describe("runDashboard — enginePaused (soft pause)", () => {
previous: { enginePaused: true },
});
await new Promise((r) => setTimeout(r, 50));
expect(resumeOrphaned).toHaveBeenCalled();
await waitForAsyncExpectation(() => {
expect(resumeOrphaned).toHaveBeenCalled();
});
});
it("sweeps merge queue on engine unpause when autoMerge is enabled", async () => {
@@ -1852,7 +1883,9 @@ describe("runDashboard — enginePaused (soft pause)", () => {
previous: { enginePaused: true },
});
await new Promise((r) => setTimeout(r, 200));
await waitForAsyncExpectation(() => {
expect(aiMergeTask).toHaveBeenCalled();
});
const mergedIds = (aiMergeTask as ReturnType<typeof vi.fn>).mock.calls.map(
(call: any[]) => call[2],
@@ -2025,24 +2058,28 @@ describe("runDashboard — --dev mode", () => {
const { createServer } = await import("@fusion/dashboard");
await runDashboard(4040, { open: false, dev: true });
// Wait for async 'listening' event
await new Promise((r) => setTimeout(r, 50));
await waitForAsyncExpectation(() => {
expect(mockListen).toHaveBeenCalledWith(4040, "127.0.0.1");
});
// Server should have been created and listen called (localhost default)
expect(createServer).toHaveBeenCalled();
expect(mockListen).toHaveBeenCalledWith(4040, "127.0.0.1");
// Banner should show the port
// Banner should show the resolved localhost URL from the bound server.
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining("http://localhost:4040"),
expect.stringContaining("http://localhost:"),
);
});
it("shows 'AI engine: disabled (dev mode)' in dev mode", async () => {
await runDashboard(0, { open: false, dev: true });
// Wait for async 'listening' event
await new Promise((r) => setTimeout(r, 50));
await waitForAsyncExpectation(() => {
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining("✗ disabled (dev mode)"),
);
});
// Should show disabled message
expect(consoleSpy).toHaveBeenCalledWith(
@@ -2053,8 +2090,7 @@ describe("runDashboard — --dev mode", () => {
it("does NOT show triage/scheduler details in dev mode", async () => {
await runDashboard(0, { open: false, dev: true });
// Wait for async 'listening' event
await new Promise((r) => setTimeout(r, 50));
await Promise.resolve();
// Should NOT show triage/scheduler details
const triageCall = consoleSpy.mock.calls.find(
@@ -2079,8 +2115,11 @@ describe("runDashboard — --dev mode", () => {
it("shows 'AI engine: ✓ active' when not in dev mode", async () => {
await runDashboard(0, { open: false });
// Wait for async 'listening' event
await new Promise((r) => setTimeout(r, 50));
await waitForAsyncExpectation(() => {
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining("✓ active"),
);
});
// Should show active message
expect(consoleSpy).toHaveBeenCalledWith(
@@ -2198,8 +2237,12 @@ describe("runDashboard — merge conflict retry logic", () => {
await runDashboard(0, { open: false });
// Wait for retry scheduling
await new Promise((r) => setTimeout(r, 100));
await waitForAsyncExpectation(() => {
expect(mockStore.updateTask).toHaveBeenCalledWith(
"FN-RETRY",
expect.objectContaining({ mergeRetries: 1 }),
);
});
// Should have incremented mergeRetries
expect(mockStore.updateTask).toHaveBeenCalledWith(
@@ -2245,7 +2288,7 @@ describe("runDashboard — merge conflict retry logic", () => {
await runDashboard(0, { open: false });
await new Promise((r) => setTimeout(r, 50));
await Promise.resolve();
// Exhausted tasks are skipped before enqueue, so they should not be merged again.
expect(aiMergeTask).not.toHaveBeenCalled();
@@ -2278,7 +2321,14 @@ describe("runDashboard — merge conflict retry logic", () => {
await runDashboard(0, { open: false });
await new Promise((r) => setTimeout(r, 50));
await waitForAsyncExpectation(() => {
const disabledLog = consoleSpy.mock.calls.find(
(call) =>
typeof call[0] === "string" &&
call[0].includes("autoResolveConflicts disabled"),
);
expect(disabledLog).toBeDefined();
});
// Should log that auto-resolve is disabled
const disabledLog = consoleSpy.mock.calls.find(
@@ -2318,7 +2368,12 @@ describe("runDashboard — merge conflict retry logic", () => {
await runDashboard(0, { open: false });
await new Promise((r) => setTimeout(r, 100));
await waitForAsyncExpectation(() => {
expect(mockStore.updateTask).toHaveBeenCalledWith(
"FN-SUCCESS",
expect.objectContaining({ mergeRetries: 0 }),
);
});
// Should clear mergeRetries on success
expect(mockStore.updateTask).toHaveBeenCalledWith(
@@ -2356,7 +2411,16 @@ describe("runDashboard — merge conflict retry logic", () => {
]);
await runDashboard(0, { open: false });
await new Promise((r) => setTimeout(r, 50));
await waitForAsyncExpectation(() => {
expect(mockStore.updateTask).toHaveBeenCalledWith(
"FN-BUILD",
expect.objectContaining({
status: null,
mergeRetries: 3,
error: "Build verification failed for FN-BUILD: Dependency sync failed",
}),
);
});
expect(mockStore.updateTask).toHaveBeenCalledWith(
"FN-BUILD",
@@ -2466,7 +2530,7 @@ describe("runDashboard — lifecycle listener cleanup", () => {
it("engine cleans up its own listeners from the shared store on dispose", async () => {
const { dispose } = await runDashboard(0, { open: false });
await new Promise((resolve) => setTimeout(resolve, 0));
await Promise.resolve();
dispose();

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach, vi } from "vitest";
import { ChatStore } from "../chat-store.js";
import { Database } from "../db.js";
import { mkdtempSync } from "node:fs";
@@ -16,26 +16,43 @@ describe("ChatStore", () => {
let db: Database;
let store: ChatStore;
beforeEach(() => {
beforeAll(() => {
tmpDir = makeTmpDir();
fusionDir = join(tmpDir, ".fusion");
});
beforeEach(() => {
// In-memory SQLite for test speed; see store.test.ts beforeEach.
db = new Database(fusionDir, { inMemory: true });
db.init();
store = new ChatStore(fusionDir, db);
});
afterEach(async () => {
afterEach(() => {
vi.useRealTimers();
try {
db.close();
} catch {
// already closed
}
});
afterAll(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
// ── Helper Functions ─────────────────────────────────────────────
function startFakeClock() {
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
}
function advanceClock(ms = 1) {
vi.setSystemTime(new Date(Date.now() + ms));
}
function createTestSession(
store: ChatStore,
overrides?: Partial<{
@@ -115,11 +132,12 @@ describe("ChatStore", () => {
});
describe("listSessions", () => {
it("returns all sessions ordered by updatedAt desc", async () => {
it("returns all sessions ordered by updatedAt desc", () => {
startFakeClock();
const s1 = createTestSession(store);
await new Promise((r) => setTimeout(r, 10));
advanceClock(10);
const s2 = createTestSession(store);
await new Promise((r) => setTimeout(r, 10));
advanceClock(10);
const s3 = createTestSession(store);
const list = store.listSessions();
@@ -184,14 +202,15 @@ describe("ChatStore", () => {
});
describe("findLatestActiveSessionForTarget", () => {
it("returns newest exact model match for model-specific targets", async () => {
it("returns newest exact model match for model-specific targets", () => {
startFakeClock();
const olderModelMatch = createTestSession(store, {
agentId: "agent-lookup",
projectId: "proj-1",
modelProvider: "openai",
modelId: "gpt-4o",
});
await new Promise((r) => setTimeout(r, 5));
advanceClock(5);
const newestModelMatch = createTestSession(store, {
agentId: "agent-lookup",
projectId: "proj-1",
@@ -217,14 +236,15 @@ describe("ChatStore", () => {
expect(found?.id).not.toBe(olderModelMatch.id);
});
it("prefers model-less session for agent-only targets", async () => {
it("prefers model-less session for agent-only targets", () => {
startFakeClock();
const modelSpecific = createTestSession(store, {
agentId: "agent-lookup",
projectId: "proj-1",
modelProvider: "openai",
modelId: "gpt-4o",
});
await new Promise((r) => setTimeout(r, 5));
advanceClock(5);
const modelLess = createTestSession(store, {
agentId: "agent-lookup",
projectId: "proj-1",
@@ -239,14 +259,15 @@ describe("ChatStore", () => {
expect(found?.id).not.toBe(modelSpecific.id);
});
it("falls back to newest agent session when no model-less session exists", async () => {
it("falls back to newest agent session when no model-less session exists", () => {
startFakeClock();
createTestSession(store, {
agentId: "agent-lookup",
projectId: "proj-1",
modelProvider: "openai",
modelId: "gpt-4o-mini",
});
await new Promise((r) => setTimeout(r, 5));
advanceClock(5);
const newestModelSpecific = createTestSession(store, {
agentId: "agent-lookup",
projectId: "proj-1",
@@ -288,11 +309,12 @@ describe("ChatStore", () => {
});
describe("updateSession", () => {
it("updates title and bumps updatedAt", async () => {
it("updates title and bumps updatedAt", () => {
startFakeClock();
const session = createTestSession(store);
const originalUpdatedAt = session.updatedAt;
await new Promise((r) => setTimeout(r, 5));
advanceClock(5);
const updated = store.updateSession(session.id, { title: "Updated Title" });
@@ -493,11 +515,12 @@ describe("ChatStore", () => {
}).toThrow("Chat session chat-nonexistent not found");
});
it("updates session's updatedAt timestamp", async () => {
it("updates session's updatedAt timestamp", () => {
startFakeClock();
const session = createTestSession(store);
const originalUpdatedAt = session.updatedAt;
await new Promise((r) => setTimeout(r, 5));
advanceClock(5);
store.addMessage(session.id, { role: "user", content: "New message" });
@@ -556,12 +579,13 @@ describe("ChatStore", () => {
});
describe("getMessages", () => {
it("returns messages for a session ordered by createdAt ASC", async () => {
it("returns messages for a session ordered by createdAt ASC", () => {
startFakeClock();
const session = createTestSession(store);
const m1 = store.addMessage(session.id, { role: "user", content: "First" });
await new Promise((r) => setTimeout(r, 5));
advanceClock(5);
const m2 = store.addMessage(session.id, { role: "assistant", content: "Second" });
await new Promise((r) => setTimeout(r, 5));
advanceClock(5);
const m3 = store.addMessage(session.id, { role: "user", content: "Third" });
const messages = store.getMessages(session.id);
@@ -595,12 +619,13 @@ describe("ChatStore", () => {
expect(messages[0].content).toBe("2");
});
it("respects before cursor (timestamp)", async () => {
it("respects before cursor (timestamp)", () => {
startFakeClock();
const session = createTestSession(store);
const m1 = store.addMessage(session.id, { role: "user", content: "1" });
await new Promise((r) => setTimeout(r, 5));
advanceClock(5);
store.addMessage(session.id, { role: "user", content: "2" });
await new Promise((r) => setTimeout(r, 5));
advanceClock(5);
store.addMessage(session.id, { role: "user", content: "3" });
const messages = store.getMessages(session.id, { before: m1.createdAt });
@@ -657,13 +682,14 @@ describe("ChatStore", () => {
});
describe("getLastMessageForSessions", () => {
it("returns the most recent message for each session", async () => {
it("returns the most recent message for each session", () => {
startFakeClock();
const session1 = createTestSession(store);
const session2 = createTestSession(store);
// Add messages to session1
store.addMessage(session1.id, { role: "user", content: "Hello" });
await new Promise((r) => setTimeout(r, 5));
advanceClock(5);
const latestMsg1 = store.addMessage(session1.id, {
role: "assistant",
content: "Latest for session 1",
@@ -762,17 +788,18 @@ describe("ChatStore", () => {
expect(store.getMessages(session2.id)[0].content).toBe("Session 2");
});
it("updates the parent session's updatedAt timestamp", async () => {
it("updates the parent session's updatedAt timestamp", () => {
startFakeClock();
const session = createTestSession(store);
store.addMessage(session.id, { role: "user", content: "Hello" });
const originalUpdatedAt = store.getSession(session.id)!.updatedAt;
await new Promise((r) => setTimeout(r, 5));
advanceClock(5);
const msg = store.addMessage(session.id, { role: "assistant", content: "Reply" });
const afterAddUpdatedAt = store.getSession(session.id)!.updatedAt;
await new Promise((r) => setTimeout(r, 5));
advanceClock(5);
store.deleteMessage(msg.id);
@@ -854,10 +881,11 @@ describe("ChatStore", () => {
});
describe("Room messages", () => {
it("adds and lists room messages with before cursor, mentions, and attachment append", async () => {
it("adds and lists room messages with before cursor, mentions, and attachment append", () => {
startFakeClock();
const room = store.createRoom({ name: "support", projectId: "proj-1" });
const first = store.addRoomMessage(room.id, { role: "user", content: "first", mentions: ["agent-1"] });
await new Promise((r) => setTimeout(r, 5));
advanceClock(5);
const second = store.addRoomMessage(room.id, { role: "assistant", content: "second", senderAgentId: "agent-1" });
const loadedFirst = store.getRoomMessage(first.id);
@@ -877,14 +905,15 @@ describe("ChatStore", () => {
expect(updated.attachments).toHaveLength(1);
});
it("deleteRoomMessage emits event and bumps room updatedAt", async () => {
it("deleteRoomMessage emits event and bumps room updatedAt", () => {
startFakeClock();
const deletedHandler = vi.fn();
store.on("chat:room:message:deleted", deletedHandler);
const room = store.createRoom({ name: "alerts", projectId: "proj-1" });
const msg = store.addRoomMessage(room.id, { role: "user", content: "hello" });
const afterAdd = store.getRoom(room.id)!;
await new Promise((r) => setTimeout(r, 5));
advanceClock(5);
expect(store.deleteRoomMessage(msg.id)).toBe(true);
const afterDelete = store.getRoom(room.id)!;

View File

@@ -35,6 +35,22 @@ import {
const mockedReviewStep = vi.mocked(mockedReviewStepFn);
const WAIT_FOR_ASYNC_OPTIONS = { timeout: 2000, interval: 5 };
async function waitForAsyncExpectation(assertion: () => void | Promise<void>) {
await vi.waitFor(assertion, WAIT_FOR_ASYNC_OPTIONS);
}
async function waitForStepExecutorRegistration(executor: TaskExecutor, taskId: string) {
await waitForAsyncExpectation(() => {
expect((executor as any).activeStepExecutors.has(taskId)).toBe(true);
});
}
afterEach(() => {
vi.useRealTimers();
});
describe("TaskExecutor context limit error recovery", () => {
beforeEach(() => {
resetExecutorMocks();
@@ -784,8 +800,9 @@ describe("TaskExecutor agent execution flow (FN-978)", () => {
// Trigger the task:moved event manually
store._trigger("task:moved", { task, from: "todo", to: "in-progress" });
// Wait for async execution to complete
await new Promise((resolve) => setTimeout(resolve, 50));
await waitForAsyncExpectation(() => {
expect(session.prompt).toHaveBeenCalled();
});
// Verify the agent was created and prompt was called
expect(mockedCreateFnAgent).toHaveBeenCalledWith(
@@ -823,8 +840,7 @@ describe("TaskExecutor agent execution flow (FN-978)", () => {
// Trigger the task:moved event with to='done' (should not execute)
store._trigger("task:moved", { task, from: "in-progress", to: "done" });
// Wait for async
await new Promise((resolve) => setTimeout(resolve, 50));
await Promise.resolve();
// Verify no agent was created
expect(mockedCreateFnAgent).not.toHaveBeenCalled();
@@ -860,7 +876,14 @@ describe("TaskExecutor agent execution flow (FN-978)", () => {
store.getTask.mockResolvedValue(movedTask);
store._trigger("task:moved", { task: movedTask, from: "in-review", to: "in-progress" });
await new Promise((resolve) => setTimeout(resolve, 20));
await waitForAsyncExpectation(() => {
expect(store.updateTask).toHaveBeenCalledWith("FN-2883-A", expect.objectContaining({
mergeDetails: null,
mergeRetries: 0,
verificationFailureCount: 0,
workflowStepResults: [],
}));
});
expect(store.updateTask).toHaveBeenCalledWith("FN-2883-A", expect.objectContaining({
mergeDetails: null,
@@ -869,12 +892,14 @@ describe("TaskExecutor agent execution flow (FN-978)", () => {
workflowStepResults: [],
}));
expect(store.updateStep).toHaveBeenCalledWith("FN-2883-A", 3, "pending");
expect(store.logEntry).toHaveBeenCalledWith(
"FN-2883-A",
expect.stringContaining("Task returned to in-progress from in-review column"),
undefined,
undefined,
);
await waitForAsyncExpectation(() => {
expect(store.logEntry).toHaveBeenCalledWith(
"FN-2883-A",
expect.stringContaining("Task returned to in-progress from in-review column"),
undefined,
undefined,
);
});
expect(executeSpy).toHaveBeenCalled();
});
@@ -905,7 +930,14 @@ describe("TaskExecutor agent execution flow (FN-978)", () => {
store.getTask.mockResolvedValue(movedTask);
store._trigger("task:moved", { task: movedTask, from: "done", to: "in-progress" });
await new Promise((resolve) => setTimeout(resolve, 20));
await waitForAsyncExpectation(() => {
expect(store.updateTask).toHaveBeenCalledWith("FN-2883-B", expect.objectContaining({
mergeDetails: null,
mergeRetries: 0,
verificationFailureCount: 0,
workflowStepResults: [],
}));
});
expect(store.updateTask).toHaveBeenCalledWith("FN-2883-B", expect.objectContaining({
mergeDetails: null,
@@ -914,12 +946,14 @@ describe("TaskExecutor agent execution flow (FN-978)", () => {
workflowStepResults: [],
}));
expect(store.updateStep).toHaveBeenCalledWith("FN-2883-B", 2, "pending");
expect(store.logEntry).toHaveBeenCalledWith(
"FN-2883-B",
expect.stringContaining("Task returned to in-progress from done column"),
undefined,
undefined,
);
await waitForAsyncExpectation(() => {
expect(store.logEntry).toHaveBeenCalledWith(
"FN-2883-B",
expect.stringContaining("Task returned to in-progress from done column"),
undefined,
undefined,
);
});
});
it("preserves verificationFailureCount for merge remediation cycles even if status was cleared", async () => {
@@ -946,7 +980,14 @@ describe("TaskExecutor agent execution flow (FN-978)", () => {
store.getTask.mockResolvedValue(movedTask);
store._trigger("task:moved", { task: movedTask, from: "in-review", to: "in-progress" });
await new Promise((resolve) => setTimeout(resolve, 20));
await waitForAsyncExpectation(() => {
expect(store.updateTask).toHaveBeenCalledWith("FN-2883-D", expect.objectContaining({
mergeDetails: null,
mergeRetries: 0,
verificationFailureCount: 2,
workflowStepResults: [],
}));
});
expect(store.updateTask).toHaveBeenCalledWith("FN-2883-D", expect.objectContaining({
mergeDetails: null,
@@ -978,7 +1019,7 @@ describe("TaskExecutor agent execution flow (FN-978)", () => {
};
store._trigger("task:moved", { task: movedTask, from: "todo", to: "in-progress" });
await new Promise((resolve) => setTimeout(resolve, 20));
await Promise.resolve();
expect(store.updateTask).not.toHaveBeenCalledWith("FN-2883-C", expect.objectContaining({ mergeDetails: null }));
expect(store.updateStep).not.toHaveBeenCalled();
@@ -1019,8 +1060,9 @@ describe("TaskExecutor agent execution flow (FN-978)", () => {
// Trigger task:moved away from in-progress
store._trigger("task:moved", { task, from: "in-progress", to: "todo" });
// Allow async handlers to complete
await new Promise((resolve) => setTimeout(resolve, 50));
await waitForAsyncExpectation(() => {
expect(disposeSpy).toHaveBeenCalled();
});
// Verify session was disposed and removed from map
expect(disposeSpy).toHaveBeenCalled();
@@ -1059,8 +1101,9 @@ describe("TaskExecutor agent execution flow (FN-978)", () => {
// Trigger task:moved away from in-progress
store._trigger("task:moved", { task, from: "in-progress", to: "triage" });
// Allow async handlers to complete
await new Promise((resolve) => setTimeout(resolve, 50));
await waitForAsyncExpectation(() => {
expect(mockTerminateAllSessions).toHaveBeenCalled();
});
// Verify terminateAllSessions was called
expect(mockTerminateAllSessions).toHaveBeenCalled();
@@ -1134,9 +1177,9 @@ describe("TaskExecutor agent execution flow (FN-978)", () => {
store._trigger("task:moved", { task, from: "in-progress", to: "todo" });
await new Promise((resolve) => setTimeout(resolve, 50));
expect(untrackSpy).toHaveBeenCalledWith("FN-004");
await waitForAsyncExpectation(() => {
expect(untrackSpy).toHaveBeenCalledWith("FN-004");
});
});
it("adds task to pausedAborted set to prevent re-execution", async () => {
@@ -1170,9 +1213,9 @@ describe("TaskExecutor agent execution flow (FN-978)", () => {
store._trigger("task:moved", { task, from: "in-progress", to: "triage" });
await new Promise((resolve) => setTimeout(resolve, 50));
expect((executor as any).pausedAborted.has("FN-005")).toBe(true);
await waitForAsyncExpectation(() => {
expect((executor as any).pausedAborted.has("FN-005")).toBe(true);
});
});
});
@@ -1331,8 +1374,13 @@ describe("TaskExecutor agent execution flow (FN-978)", () => {
it("prevents duplicate execution when task:moved fires twice for same task", async () => {
const store = createMockStore();
let resolvePrompt: (() => void) | undefined;
const session = {
prompt: vi.fn().mockResolvedValue(undefined),
prompt: vi.fn().mockImplementation(
() => new Promise<void>((resolve) => {
resolvePrompt = resolve;
}),
),
dispose: vi.fn(),
};
@@ -1353,21 +1401,23 @@ describe("TaskExecutor agent execution flow (FN-978)", () => {
updatedAt: new Date().toISOString(),
};
// Trigger the event twice quickly
// Trigger the event twice quickly while the first execution is still in flight.
store._trigger("task:moved", { task, from: "todo", to: "in-progress" });
store._trigger("task:moved", { task, from: "todo", to: "in-progress" });
// Wait for completion
await new Promise((resolve) => setTimeout(resolve, 200));
await waitForAsyncExpectation(() => {
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(1);
expect(session.prompt).toHaveBeenCalledTimes(1);
});
resolvePrompt?.();
await Promise.resolve();
// The executing guard prevents duplicate execution from the event handler.
// Note: createFnAgent may be called a second time if the agent finishes
// without calling fn_task_done (retry path), but the initial trigger should
// only cause one execution, not two.
// Verify that store.on was called with task:moved (listener registered)
expect(store.on).toHaveBeenCalledWith("task:moved", expect.any(Function));
// Verify the event handler initiated execute() (not twice from events)
// The executing set guard works — both triggers don't cause double execution
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(1);
expect(session.prompt).toHaveBeenCalledTimes(1);
});
it("logs error when execute() fails in task:moved handler", async () => {
@@ -1393,8 +1443,12 @@ describe("TaskExecutor agent execution flow (FN-978)", () => {
// Trigger the event
store._trigger("task:moved", { task, from: "todo", to: "in-progress" });
// Wait for async
await new Promise((resolve) => setTimeout(resolve, 50));
await waitForAsyncExpectation(() => {
expect(onError).toHaveBeenCalledWith(
expect.objectContaining({ id: "FN-978" }),
expect.any(Error),
);
});
// Verify the error handler was called
expect(onError).toHaveBeenCalledWith(
@@ -1944,7 +1998,7 @@ describe("StepSessionExecutor integration", () => {
retries: 0,
tokenUsage: { inputTokens: 20, outputTokens: 10, cachedTokens: 2, totalTokens: 32 },
});
await new Promise((resolve) => setTimeout(resolve, 0));
await Promise.resolve();
options.onStepComplete(1, {
stepIndex: 1,
@@ -1952,7 +2006,7 @@ describe("StepSessionExecutor integration", () => {
retries: 0,
tokenUsage: { inputTokens: 30, outputTokens: 5, cachedTokens: 1, totalTokens: 36 },
});
await new Promise((resolve) => setTimeout(resolve, 0));
await Promise.resolve();
return [
{
@@ -2228,8 +2282,7 @@ describe("StepSessionExecutor integration", () => {
// Start execution (don't await — it will hang)
const executePromise = executor.execute(task);
// Give it time to set up the step executor
await new Promise((r) => setTimeout(r, 50));
await waitForStepExecutorRegistration(executor, "FN-200");
// Trigger pause
store._trigger("task:updated", { ...task, paused: true });
@@ -2256,8 +2309,7 @@ describe("StepSessionExecutor integration", () => {
const task = createTaskWithSteps();
const executePromise = executor.execute(task);
// Give it time to set up the step executor
await new Promise((r) => setTimeout(r, 50));
await waitForStepExecutorRegistration(executor, "FN-200");
// Trigger stuck kill
executor.markStuckAborted("FN-200");
@@ -2285,8 +2337,7 @@ describe("StepSessionExecutor integration", () => {
const task = createTaskWithSteps();
const executePromise = executor.execute(task);
// Give it time to set up the step executor
await new Promise((r) => setTimeout(r, 50));
await waitForStepExecutorRegistration(executor, "FN-200");
// Verify step executor is registered
expect((executor as any).activeStepExecutors.has("FN-200")).toBe(true);
@@ -2320,7 +2371,7 @@ describe("StepSessionExecutor integration", () => {
const task = createTaskWithSteps();
const executePromise = executor.execute(task);
await new Promise((r) => setTimeout(r, 50));
await waitForStepExecutorRegistration(executor, "FN-200");
// Budget exhausted — should NOT requeue
executor.markStuckAborted("FN-200", false);
@@ -2355,7 +2406,7 @@ describe("StepSessionExecutor integration", () => {
const task = createTaskWithSteps();
const executePromise = executor.execute(task);
await new Promise((r) => setTimeout(r, 50));
await waitForStepExecutorRegistration(executor, "FN-200");
// Trigger pause
store._trigger("task:updated", { ...task, paused: true });
@@ -2448,8 +2499,7 @@ describe("StepSessionExecutor integration", () => {
const task = createTaskWithSteps();
const executePromise = executor.execute(task);
// Give it time to set up the step executor
await new Promise((r) => setTimeout(r, 50));
await waitForStepExecutorRegistration(executor, "FN-200");
// Simulate dep-abort by directly triggering the fn_task_add_dep cleanup logic
// The dep-abort flag should cause the step-session path to handle cleanup
@@ -2524,9 +2574,7 @@ describe("StepSessionExecutor integration", () => {
expect(onError).not.toHaveBeenCalled();
// Advance timers to trigger the setTimeout that moves task to todo then in-progress
vi.advanceTimersByTime(0);
// Run any pending microtasks (the async code in setTimeout)
await vi.runAllTimersAsync();
await vi.advanceTimersByTimeAsync(0);
// Task should move to todo then in-progress (not in-review). The
// workflow-rerun bounce flags preserveResumeState so the worktree and

View File

@@ -9,9 +9,15 @@
* - Triage re-picks unspecified tasks
* - Crash scenarios are handled gracefully (semaphore release, status cleanup)
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { AgentSemaphore } from "../concurrency.js";
const WAIT_FOR_ASYNC_OPTIONS = { timeout: 2000, interval: 5 };
async function waitForAsyncExpectation(assertion: () => void | Promise<void>) {
await vi.waitFor(assertion, WAIT_FOR_ASYNC_OPTIONS);
}
/* eslint-disable @typescript-eslint/no-unsafe-function-type, @typescript-eslint/no-explicit-any -- Test mocks use Function/any type for simplicity */
// ── Module-level mocks (matching existing test patterns) ──────────────────
@@ -404,6 +410,10 @@ beforeEach(() => {
}) as any);
});
afterEach(() => {
vi.useRealTimers();
});
// ── Step 2: In-progress task resume tests ─────────────────────────────────
describe("In-progress task resume after restart", () => {
@@ -420,8 +430,9 @@ describe("In-progress task resume after restart", () => {
const executor = new TaskExecutor(store, "/tmp/test");
await executor.resumeOrphaned();
// Wait for async execute calls to complete
await new Promise((r) => setTimeout(r, 50));
await waitForAsyncExpectation(() => {
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(2);
});
// Exactly one agent session per in-progress task (no retry inflation)
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(2);
@@ -447,7 +458,9 @@ describe("In-progress task resume after restart", () => {
const executor = new TaskExecutor(store, "/tmp/test");
await executor.resumeOrphaned();
await new Promise((r) => setTimeout(r, 50));
await waitForAsyncExpectation(() => {
expect(store.logEntry).toHaveBeenCalledWith("FN-010", "Resumed after engine restart");
});
// No git worktree add commands should have been called
const gitWorktreeAddCalls = mockedExecSync.mock.calls.filter(
@@ -478,7 +491,9 @@ describe("In-progress task resume after restart", () => {
const executor = new TaskExecutor(store, "/tmp/test");
await executor.resumeOrphaned();
await new Promise((r) => setTimeout(r, 50));
await waitForAsyncExpectation(() => {
expect(capturedPrompt).toContain("⚠️ RESUMING");
});
expect(capturedPrompt).toContain("⚠️ RESUMING");
expect(capturedPrompt).toContain("Step 0 (Step 0): **done**");
@@ -501,7 +516,9 @@ describe("In-progress task resume after restart", () => {
const executor = new TaskExecutor(store, "/tmp/test");
await executor.resumeOrphaned();
await new Promise((r) => setTimeout(r, 50));
await waitForAsyncExpectation(() => {
expect(store.getSettings).toHaveBeenCalled();
});
// getSettings is called (for project commands in execution prompt) but init command should not run
expect(store.getSettings).toHaveBeenCalled();
@@ -540,7 +557,9 @@ describe("In-progress task resume after restart", () => {
const executor = new TaskExecutor(store, "/tmp/test");
await executor.resumeOrphaned();
await new Promise((r) => setTimeout(r, 50));
await waitForAsyncExpectation(() => {
expect(store.updateStep).toHaveBeenCalledWith("FN-1701", 1, "done");
});
// The step should have been flipped to done *before* execute ran.
expect(store.updateStep).toHaveBeenCalledWith("FN-1701", 1, "done");
@@ -572,7 +591,9 @@ describe("In-progress task resume after restart", () => {
const executor = new TaskExecutor(store, "/tmp/test");
await executor.resumeOrphaned();
await new Promise((r) => setTimeout(r, 50));
await waitForAsyncExpectation(() => {
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(1);
});
// Must NOT mark the step done — the reset invalidated the prior approval.
const updateStepDoneCalls = store.updateStep.mock.calls.filter(
@@ -599,7 +620,9 @@ describe("In-progress task resume after restart", () => {
const executor = new TaskExecutor(store, "/tmp/test");
await executor.resumeOrphaned();
await new Promise((r) => setTimeout(r, 50));
await waitForAsyncExpectation(() => {
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(1);
});
const updateStepDoneCalls = store.updateStep.mock.calls.filter(
(c: any[]) => c[0] === "FN-1703" && c[2] === "done",
@@ -630,7 +653,9 @@ describe("In-progress task resume after restart", () => {
const executor = new TaskExecutor(store, "/tmp/test");
await executor.resumeOrphaned();
await new Promise((r) => setTimeout(r, 50));
await waitForAsyncExpectation(() => {
expect(store.updateStep).toHaveBeenCalledWith("FN-1704", 0, "done");
});
expect(store.updateStep).toHaveBeenCalledWith("FN-1704", 0, "done");
expect(store.updateStep).toHaveBeenCalledWith("FN-1704", 1, "done");
@@ -649,7 +674,9 @@ describe("In-progress task resume after restart", () => {
const executor = new TaskExecutor(store, "/tmp/test");
await executor.resumeOrphaned();
await new Promise((r) => setTimeout(r, 50));
await waitForAsyncExpectation(() => {
expect(store.logEntry).toHaveBeenCalledWith("FN-040", "Resumed after engine restart");
});
expect(store.logEntry).toHaveBeenCalledWith("FN-040", "Resumed after engine restart");
expect(store.logEntry).toHaveBeenCalledWith("FN-041", "Resumed after engine restart");
@@ -784,9 +811,7 @@ describe("In-progress task resume after restart", () => {
expect(store.updateStep).toHaveBeenCalledWith("FN-963", 0, "pending");
// Advance timers to trigger the setTimeout that moves task to todo then in-progress
vi.advanceTimersByTime(0);
// Run any pending microtasks (the async code in setTimeout)
await vi.runAllTimersAsync();
await vi.advanceTimersByTimeAsync(0);
// Task should move to todo then in-progress (not in-review). The
// workflow-rerun bounce passes `preserveWorktree: true` so the
@@ -955,8 +980,9 @@ describe("Triage re-pick after restart", () => {
});
triage.start();
// Wait for the immediate poll() to fire
await new Promise((r) => setTimeout(r, 100));
await waitForAsyncExpectation(() => {
expect(store.updateTask).toHaveBeenCalledWith("FN-060", { status: "planning" });
});
triage.stop();
// Both triage tasks should have been picked up for specification
@@ -993,8 +1019,9 @@ describe("Triage re-pick after restart", () => {
// Start first specification (will block on prompt)
const first = triage.specifyTask(task);
// Give it time to enter processing set
await new Promise((r) => setTimeout(r, 20));
await waitForAsyncExpectation(() => {
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(1);
});
// Second call should be a no-op (already processing)
await triage.specifyTask(task);
@@ -1029,7 +1056,9 @@ describe("Scheduler after restart", () => {
// Use start/stop to trigger schedule() then clean up
scheduler.start();
await new Promise((r) => setTimeout(r, 50));
await waitForAsyncExpectation(() => {
expect(store.moveTask).toHaveBeenCalledWith("FN-070", "in-progress", expect.objectContaining({ allocateWorktree: expect.any(Function) }));
});
scheduler.stop();
expect(store.moveTask).toHaveBeenCalledWith("FN-070", "in-progress", expect.objectContaining({ allocateWorktree: expect.any(Function) }));
@@ -1054,7 +1083,9 @@ describe("Scheduler after restart", () => {
});
scheduler.start();
await new Promise((r) => setTimeout(r, 50));
await waitForAsyncExpectation(() => {
expect(onBlocked).toHaveBeenCalledWith(blockedTask, ["FN-071"]);
});
scheduler.stop();
// Task should NOT have been moved
@@ -1087,7 +1118,9 @@ describe("Scheduler after restart", () => {
pollIntervalMs: 100000,
});
triage.start();
await new Promise((r) => setTimeout(r, 100));
await waitForAsyncExpectation(() => {
expect(store.updateTask).toHaveBeenCalledWith("FN-080", { status: "planning" });
});
triage.stop();
expect(store.updateTask).toHaveBeenCalledWith("FN-080", { status: "planning" });
@@ -1099,7 +1132,9 @@ describe("Scheduler after restart", () => {
const scheduler = new Scheduler(store, { maxConcurrent: 2, maxWorktrees: 4 });
scheduler.start();
await new Promise((r) => setTimeout(r, 50));
await waitForAsyncExpectation(() => {
expect(store.moveTask).toHaveBeenCalledWith("FN-081", "in-progress", expect.objectContaining({ allocateWorktree: expect.any(Function) }));
});
scheduler.stop();
expect(store.moveTask).toHaveBeenCalledWith("FN-081", "in-progress", expect.objectContaining({ allocateWorktree: expect.any(Function) }));
@@ -1115,7 +1150,9 @@ describe("Scheduler after restart", () => {
const executor = new TaskExecutor(store, "/tmp/root");
await executor.resumeOrphaned();
await new Promise((r) => setTimeout(r, 50));
await waitForAsyncExpectation(() => {
expect(store.logEntry).toHaveBeenCalledWith("FN-082", "Resumed after engine restart");
});
expect(store.logEntry).toHaveBeenCalledWith("FN-082", "Resumed after engine restart");
@@ -1153,7 +1190,9 @@ describe("Crash scenario edge cases", () => {
});
await executor.resumeOrphaned();
await new Promise((r) => setTimeout(r, 50));
await waitForAsyncExpectation(() => {
expect(onError).toHaveBeenCalledWith(task, expect.any(Error));
});
// onError should have been called
expect(onError).toHaveBeenCalledWith(task, expect.any(Error));
@@ -1170,7 +1209,9 @@ describe("Crash scenario edge cases", () => {
createAgentWithTaskDone();
await executor.resumeOrphaned();
await new Promise((r) => setTimeout(r, 50));
await waitForAsyncExpectation(() => {
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(1);
});
// Exactly one agent created for the re-resume, proving the task was eligible
// and completed without retry inflation.
@@ -1245,11 +1286,13 @@ describe("Crash scenario edge cases", () => {
// First call starts execution
const first = executor.resumeOrphaned();
await new Promise((r) => setTimeout(r, 20));
await waitForAsyncExpectation(() => {
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(1);
});
// Second call while first is still executing
const second = executor.resumeOrphaned();
await new Promise((r) => setTimeout(r, 20));
await Promise.resolve();
// Only one agent should have been created (the executing set guards against double-exec)
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(1);
@@ -1282,7 +1325,9 @@ describe("Crash scenario edge cases", () => {
});
await executor.resumeOrphaned();
await new Promise((r) => setTimeout(r, 50));
await waitForAsyncExpectation(() => {
expect(onError).toHaveBeenCalled();
});
// Semaphore should return to pre-execution count (1 from our manual acquire)
expect(sem.activeCount).toBe(1);
@@ -1380,7 +1425,6 @@ describe("Worktree pool restart with recycleWorktrees=true", () => {
const executor = new TaskExecutor(store, "/root", { pool });
await executor.execute(makeTask("FN-110", "in-progress"));
await new Promise((r) => setTimeout(r, 50));
// Pool should be empty (worktree acquired)
expect(pool.size).toBe(0);
@@ -1615,7 +1659,9 @@ describe("Engine pause/unpause cycle", () => {
});
scheduler.start();
await new Promise((r) => setTimeout(r, 50));
await waitForAsyncExpectation(() => {
expect(store.moveTask).toHaveBeenCalledWith("FN-EP3", "in-progress", expect.objectContaining({ allocateWorktree: expect.any(Function) }));
});
// Scheduler should have moved todo task to in-progress
expect(store.moveTask).toHaveBeenCalledWith("FN-EP3", "in-progress", expect.objectContaining({ allocateWorktree: expect.any(Function) }));
@@ -1638,7 +1684,9 @@ describe("Engine pause/unpause cycle", () => {
previous: { ...DEFAULT_SETTINGS, enginePaused: true },
});
await new Promise((r) => setTimeout(r, 100));
await waitForAsyncExpectation(() => {
expect(store.moveTask).toHaveBeenCalledWith("FN-EP4", "in-progress", expect.objectContaining({ allocateWorktree: expect.any(Function) }));
});
scheduler.stop();
// The new task should have been scheduled after unpause