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:
@@ -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();
|
||||
|
||||
|
||||
@@ -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)!;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user