diff --git a/docs/testing.md b/docs/testing.md index 3e2718bc94..59ac3768a6 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -195,6 +195,10 @@ Legitimate legacy exceptions must be recorded in `scripts/lib/test-timeout-appea **2026-06-18 engine isolation rescue (FN-6610):** a full `@fusion/engine` lane reported unrelated expectation drift, vanished-cwd/git-config errors, and SQLite `unable to open database file` failures. The reusable isolation fix is to revalidate the shared test cwd/HOME/worker-root seam at the operation boundary: subprocess wrappers recreate the owned worker root, HOME, and cwd immediately before `git`, direct SQLite setup helpers recreate their redirected `.fusion` parent before `DatabaseSync`, and regression coverage removes the redirect sink/HOME/cwd mid-test before proving `mkdtemp`, SQLite open, and git config all still work. Do not mask this class with retries, worker reductions, or timeout bumps; quarantine only residual files after the shared seam and direct-open parents are proven under package load. + + +**2026-06-19 CLI affected-lane rescue (FN-6734):** a broad `@runfusion/fusion` lane reported default 5s test-body timeouts and `fusion-test-workers-*`/fixture `ENOTEMPTY` cleanup noise while isolated files exposed closeable real-store handles and a runtime-dist mock that was sensitive to package-lane module graph ordering. The rescue closed each real `TaskStore`/`AgentStore` before removing its temp fixture, kept task-list truncation coverage under the default timeout by reducing filler size rather than assertions, and preloaded the built `@fusion/core` barrel with `vi.importActual` before `vi.doMock` so complete dist artifacts exercise the CLI surface while partial stale dist skips cleanly. Prove this class with targeted file runs, `pnpm --filter @runfusion/fusion test`, the timeout-appeasement guard, bounded temp-prefix cleanup output, and the normal workspace gate/build; leave the CLI quarantine array empty when no file is actually quarantined. + **2026-06-16 rescue (FN-6514):** `packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx` was rescued before its 2026-06-30 deletion deadline. The file still caught real quick-entry behavior regressions, but it leaked jsdom descriptors for `window.innerWidth`, `window.matchMedia`, `document.visibilityState`, `URL.createObjectURL`, and `URL.revokeObjectURL`; a mobile viewport helper could leave later tests in the same dashboard backfill shard observing `innerWidth=375` and mismatched responsive assertions. The rescue removed the ledger/config quarantine entries in lockstep, captured each original `PropertyDescriptor` at module load, restored those descriptors (or deleted own properties that were originally absent) in `afterEach`, and added a guard test that mutates all rescued globals before asserting they return to their original descriptors. Reusable pattern: any test file that changes jsdom globals with `Object.defineProperty` or spies on replaceable globals must snapshot the original descriptor at the top of the file, restore it in every `afterEach`, and prove the invariant with a guard test; do not use timeout bumps, retries, worker changes, or blanket `vi.restoreAllMocks()` when module mocks depend on stable implementations. **Gate eviction:** a flake inside the merge gate cannot block all merges while red — it is evicted by removing its line from the `engine-core` allow-list (no quarantine entry needed unless it should also leave the non-blocking tier). diff --git a/packages/cli/src/__tests__/extension-agent-set-instructions.test.ts b/packages/cli/src/__tests__/extension-agent-set-instructions.test.ts index 4b504c499b..7efbf78de1 100644 --- a/packages/cli/src/__tests__/extension-agent-set-instructions.test.ts +++ b/packages/cli/src/__tests__/extension-agent-set-instructions.test.ts @@ -3,7 +3,7 @@ import { mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { AgentStore } from "@fusion/core"; -import kbExtension from "../extension.js"; +import kbExtension, { closeCachedStores } from "../extension.js"; function createMockAPI() { const tools = new Map(); @@ -58,6 +58,7 @@ async function withOrg( ids: { manager: manager.id, middle: middle.id, leaf: leaf.id, peer: peer.id }, }); } finally { + closeCachedStores(); agentStore.close(); await rm(cwd, { recursive: true, force: true }); } diff --git a/packages/cli/src/__tests__/extension-goal-tools-audit.test.ts b/packages/cli/src/__tests__/extension-goal-tools-audit.test.ts index c6bcc09b33..6d85062a08 100644 --- a/packages/cli/src/__tests__/extension-goal-tools-audit.test.ts +++ b/packages/cli/src/__tests__/extension-goal-tools-audit.test.ts @@ -3,7 +3,7 @@ import { mkdtemp, mkdir, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { TaskStore, collectCitedGoalIdsFromAudit } from "@fusion/core"; -import kbExtension from "../extension.js"; +import kbExtension, { closeCachedStores } from "../extension.js"; import { GOAL_RETRIEVAL_INVOKED } from "@fusion/engine"; interface RegisteredTool { @@ -32,6 +32,7 @@ describe("extension goal tools retrieval audit", () => { }); afterEach(async () => { + closeCachedStores(); await rm(tmpDir, { recursive: true, force: true }); vi.restoreAllMocks(); }); diff --git a/packages/cli/src/__tests__/extension-goal-tools.test.ts b/packages/cli/src/__tests__/extension-goal-tools.test.ts index d51f95c9f5..af5a216f2f 100644 --- a/packages/cli/src/__tests__/extension-goal-tools.test.ts +++ b/packages/cli/src/__tests__/extension-goal-tools.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { mkdtemp, mkdir, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import kbExtension from "../extension.js"; +import kbExtension, { closeCachedStores } from "../extension.js"; interface RegisteredTool { name: string; @@ -59,6 +59,7 @@ describe("extension goal retrieval tools", () => { }); afterEach(async () => { + closeCachedStores(); await rm(tmpDir, { recursive: true, force: true }); }); diff --git a/packages/cli/src/__tests__/extension-insights.test.ts b/packages/cli/src/__tests__/extension-insights.test.ts index d8976bb5b7..67585c5b72 100644 --- a/packages/cli/src/__tests__/extension-insights.test.ts +++ b/packages/cli/src/__tests__/extension-insights.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import kbExtension from "../extension.js"; +import kbExtension, { closeCachedStores } from "../extension.js"; import { TaskStore } from "@fusion/core"; interface RegisteredTool { @@ -45,6 +45,7 @@ describe("fn insight extension tools", () => { }); afterEach(async () => { + closeCachedStores(); await rm(tmpDir, { recursive: true, force: true }); }); diff --git a/packages/cli/src/__tests__/extension-mission-goal-tools.test.ts b/packages/cli/src/__tests__/extension-mission-goal-tools.test.ts index 1b2a889b7b..c89d95da67 100644 --- a/packages/cli/src/__tests__/extension-mission-goal-tools.test.ts +++ b/packages/cli/src/__tests__/extension-mission-goal-tools.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { mkdtemp, mkdir, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import kbExtension from "../extension.js"; +import kbExtension, { closeCachedStores } from "../extension.js"; interface RegisteredTool { name: string; @@ -54,6 +54,7 @@ describe("extension mission goal tools", () => { }); afterEach(async () => { + closeCachedStores(); await rm(tmpDir, { recursive: true, force: true }); }); diff --git a/packages/cli/src/__tests__/extension.test.ts b/packages/cli/src/__tests__/extension.test.ts index 6979fb7956..e3273fc61e 100644 --- a/packages/cli/src/__tests__/extension.test.ts +++ b/packages/cli/src/__tests__/extension.test.ts @@ -22,7 +22,7 @@ vi.mock("../commands/task.js", () => ({ runTaskPlan: vi.fn(), })); -import kbExtension, { resolveTaskListFormatter } from "../extension.js"; +import kbExtension, { closeCachedStores, resolveTaskListFormatter } from "../extension.js"; import { TaskStore, AgentStore, MANUAL_RETRY_RESET_COUNTER_KEYS, RESEARCH_RUN_STATUSES, MAX_TASK_LIST_TEXT_CHARS, formatTaskListText, COLUMN_LABELS } from "@fusion/core"; import type { WorkflowIr } from "@fusion/core"; import { isGhAvailable, isGhAuthenticated, runGhJsonAsync } from "@fusion/core/gh-cli"; @@ -86,12 +86,16 @@ async function seedAgent( ): Promise { const agentStore = new AgentStore({ rootDir: join(cwd, ".fusion") }); await agentStore.init(); - const agent = await agentStore.createAgent({ - name: overrides.name ?? "test-agent", - role: "executor", - metadata: overrides.ephemeral ? { agentKind: "task-worker" } : {}, - }); - return agent.id; + try { + const agent = await agentStore.createAgent({ + name: overrides.name ?? "test-agent", + role: "executor", + metadata: overrides.ephemeral ? { agentKind: "task-worker" } : {}, + }); + return agent.id; + } finally { + agentStore.close(); + } } function linearWorkflowIr(name: string): WorkflowIr { @@ -136,7 +140,11 @@ async function readTaskWorkflowState(cwd: string, taskId: string) { } async function removeDirWithRetries(path: string) { - const maxAttempts = 4; + /* + FNXC:CliTests 2026-06-19-11:23: + FN-6734 showed fixture removal can race SQLite/WAL close on loaded CLI workers; retry cleanup long enough for handles to drain instead of masking test bodies with larger timeouts or worker limits. + */ + const maxAttempts = 12; for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { try { @@ -152,7 +160,7 @@ async function removeDirWithRetries(path: string) { throw error; } - await delay(25 * attempt); + await delay(50 * attempt); } } } @@ -238,6 +246,7 @@ describe.skipIf(!SHOULD_RUN_LEGACY_EXTENSION_INTEGRATION)("fn pi extension (lega }); afterEach(async () => { + closeCachedStores(); await removeDirWithRetries(tmpDir); }); @@ -2517,6 +2526,13 @@ describe.skipIf(!SHOULD_RUN_LEGACY_EXTENSION_INTEGRATION)("fn pi extension (lega describe("fn pi extension (runnable structured-output regression slice)", () => { let tmpDir: string; let api: ReturnType; + let openStores: TaskStore[] = []; + + function createStore(): TaskStore { + const store = new TaskStore(tmpDir); + openStores.push(store); + return store; + } beforeEach(async () => { vi.mocked(isGhAvailable).mockReturnValue(true); @@ -2531,9 +2547,38 @@ describe("fn pi extension (runnable structured-output regression slice)", () => }); afterEach(async () => { + /* + FNXC:CliTests 2026-06-19-11:02: + FN-6734 reproduced CLI package-lane ENOTEMPTY and 5s timeouts when the extension store cache kept real TaskStore handles open while the fixture root was removed. + Close the cache before temp-root cleanup so the high-value regression slice stays in the default 5s lane without timeout or worker appeasement. + */ + for (const store of openStores.splice(0)) { + try { + store.close(); + } catch { + // Best effort: close all real stores before removing fixture roots. + } + } + closeCachedStores(); await removeDirWithRetries(tmpDir); }); + it("closes cached TaskStore handles before fixture removal (FN-6734 regression)", async () => { + /* + FNXC:CliTests 2026-06-19-11:35: + FN-6734 needs a deterministic guard for the close-before-remove invariant: extension tools cache a real TaskStore, so cleanup must close cached stores before removing the fixture root. + */ + const closeSpy = vi.spyOn(TaskStore.prototype, "close"); + const createTool = api.tools.get("fn_task_create")!; + + await createTool.execute("close-before-remove", { description: "seed cached store" }, undefined, undefined, makeCtx(tmpDir)); + closeCachedStores(); + + expect(closeSpy).toHaveBeenCalled(); + await expect(rm(tmpDir, { recursive: true, force: true })).resolves.not.toThrow(); + tmpDir = await mkdtemp(join(tmpdir(), "kb-ext-fast-")); + }); + it("returns machine-consumable task metadata without assuming FN-* prefixes", async () => { const createTool = api.tools.get("fn_task_create")!; const parent = await createTool.execute("create-1", { description: "parent" }, undefined, undefined, makeCtx(tmpDir)); @@ -2567,7 +2612,7 @@ describe("fn pi extension (runnable structured-output regression slice)", () => } it("returns bounded text for omitted and provided column/limit params", async () => { - const store = new TaskStore(tmpDir); + const store = createStore(); await store.init(); try { await store.createTask({ description: "Planning task one" }); @@ -2589,7 +2634,7 @@ describe("fn pi extension (runnable structured-output regression slice)", () => }); it("returns explicit text for empty active-column filters on a non-empty board", async () => { - const store = new TaskStore(tmpDir); + const store = createStore(); await store.init(); try { await store.createTask({ description: "Finished task keeps the board non-empty", column: "done" }); @@ -2620,7 +2665,7 @@ describe("fn pi extension (runnable structured-output regression slice)", () => }); it("keeps small column-filtered listings complete without the clamp marker", async () => { - const store = new TaskStore(tmpDir); + const store = createStore(); await store.init(); try { const first = await store.createTask({ description: "Small todo task one", column: "todo" }); @@ -2654,7 +2699,7 @@ describe("fn pi extension (runnable structured-output regression slice)", () => }); it("bounds realistic column-filtered listings below the host-safe text budget", async () => { - const store = new TaskStore(tmpDir); + const store = createStore(); await store.init(); try { const todoFirst = await store.createTask({ @@ -2662,23 +2707,23 @@ describe("fn pi extension (runnable structured-output regression slice)", () => description: "Realistic todo task 001", column: "todo", }); - for (let i = 2; i <= 60; i += 1) { + for (let i = 2; i <= 12; i += 1) { await store.createTask({ - title: realisticTaskTitle("todo", i), + title: `${realisticTaskTitle("todo", i)} ${"x".repeat(1_000)}`, description: `Realistic todo task ${String(i).padStart(3, "0")}`, column: "todo", dependencies: [todoFirst.id], }); } - for (let i = 1; i <= 35; i += 1) { + for (let i = 1; i <= 8; i += 1) { await store.createTask({ - title: realisticTaskTitle("triage", i), + title: `${realisticTaskTitle("triage", i)} ${"x".repeat(1_000)}`, description: `Realistic triage task ${String(i).padStart(3, "0")}`, }); } - for (let i = 1; i <= 30; i += 1) { + for (let i = 1; i <= 6; i += 1) { await store.createTask({ - title: realisticTaskTitle("done", i), + title: `${realisticTaskTitle("done", i)} ${"x".repeat(1_000)}`, description: `Realistic done task ${String(i).padStart(3, "0")}`, column: "done", }); @@ -2697,27 +2742,27 @@ describe("fn pi extension (runnable structured-output regression slice)", () => ); expectSingleBoundedTextBlock(broadResult); expect(broadResult.content.some((block: any) => block.type === "image")).toBe(false); - expect(broadResult.content[0].text).toContain("Planning (35):"); - expect(broadResult.details.count).toBe(125); + expect(broadResult.content[0].text).toContain("Planning (8):"); + expect(broadResult.details.count).toBe(26); for (const { callId, params, header, ids } of [ { callId: "list-realistic-todo", - params: { column: "todo", limit: 50 }, - header: "Todo (60):", + params: { column: "todo", limit: 12 }, + header: "Todo (12):", ids: ["FN-001", "FN-002"], }, { callId: "list-realistic-triage", - params: { column: "triage", limit: 50 }, - header: "Planning (35):", - ids: ["FN-061", "FN-062"], + params: { column: "triage", limit: 8 }, + header: "Planning (8):", + ids: ["FN-013", "FN-014"], }, { callId: "list-realistic-done", - params: { column: "done", limit: 50 }, - header: "Done (30):", - ids: ["FN-096", "FN-097"], + params: { column: "done", limit: 6 }, + header: "Done (6):", + ids: ["FN-021", "FN-022"], }, ] as const) { const result = await listTool.execute(callId, params, undefined, undefined, makeCtx(tmpDir)); @@ -2730,15 +2775,15 @@ describe("fn pi extension (runnable structured-output regression slice)", () => expect(text).toContain(id); } expect(text).toContain("truncated to fit; narrow with column/limit"); - expect(result.details.count).toBe(125); + expect(result.details.count).toBe(26); } }); it("bounds broad listings as a single plain-text block", async () => { - const store = new TaskStore(tmpDir); + const store = createStore(); await store.init(); try { - for (let i = 1; i <= 60; i += 1) { + for (let i = 1; i <= 15; i += 1) { await store.createTask({ title: `Planning task ${String(i).padStart(3, "0")} ${"x".repeat(1_600)}`, description: `Large planning task ${String(i).padStart(3, "0")}`, @@ -2763,24 +2808,24 @@ describe("fn pi extension (runnable structured-output regression slice)", () => expect(result.content.some((block: any) => block.type === "image")).toBe(false); expect(text).toBeTruthy(); expect(text.length).toBeLessThanOrEqual(MAX_TASK_LIST_TEXT_CHARS); - expect(text).toContain("Planning (60):"); + expect(text).toContain("Planning (15):"); expect(text).toContain("FN-001"); expect(text).toContain("truncated to fit; narrow with column/limit"); - expect(result.details.count).toBe(60); + expect(result.details.count).toBe(15); }); it("bounds large column-filtered listings as a single plain-text block", async () => { - const store = new TaskStore(tmpDir); + const store = createStore(); await store.init(); try { const first = await store.createTask({ - title: `Todo task 001 ${"x".repeat(260)}`, + title: `Todo task 001 ${"x".repeat(300)}`, description: "Large todo task 001", column: "todo", }); - for (let i = 2; i <= 60; i += 1) { + for (let i = 2; i <= 20; i += 1) { await store.createTask({ - title: `Todo task ${String(i).padStart(3, "0")} ${"x".repeat(260)}`, + title: `Todo task ${String(i).padStart(3, "0")} ${"x".repeat(300)}`, description: `Large todo task ${String(i).padStart(3, "0")}`, column: "todo", dependencies: [first.id], @@ -2793,7 +2838,7 @@ describe("fn pi extension (runnable structured-output regression slice)", () => const listTool = api.tools.get("fn_task_list")!; const result = await listTool.execute( "list-large-todo", - { column: "todo", limit: 50 }, + { column: "todo", limit: 20 }, undefined, undefined, makeCtx(tmpDir), @@ -2805,12 +2850,12 @@ describe("fn pi extension (runnable structured-output regression slice)", () => expect(result.content.some((block: any) => block.type === "image")).toBe(false); expect(text).toBeTruthy(); expect(text.length).toBeLessThanOrEqual(MAX_TASK_LIST_TEXT_CHARS); - expect(text).toContain("Todo (60):"); + expect(text).toContain("Todo (20):"); expect(text).toContain("FN-001"); expect(text).toContain("FN-002"); expect(text).toContain("[deps: FN-001]"); expect(text).toContain("truncated to fit; narrow with column/limit"); - expect(result.details.count).toBe(60); + expect(result.details.count).toBe(20); }); /** @@ -2819,23 +2864,29 @@ describe("fn pi extension (runnable structured-output regression slice)", () => * * FNXC:CoreTests 2026-06-18-01:35: * FN-6627 aligns the skip gate with every built @fusion/core dist artifact this runtime-dist mock loads, so a partial stale dist skips cleanly while a complete dist still exercises the heartbeat fn_task_list surface. + * + * FNXC:CliTests 2026-06-19-11:17: + * FN-6734 keeps this guard in the default 5s lane by preserving the runtime-dist truncation invariant with fewer fixture writes instead of appeasing timeouts or reducing workers. + * + * FNXC:CliTests 2026-06-19-13:16: + * The full CLI affected lane runs this file beside many module-mocking suites; verify the built barrel is importable in the executing worker before installing the mock, then skip like the partial-dist gate if a concurrent lane observes stale dist artifacts. */ it.skipIf(!hasBuiltCoreDistBarrel(resolve(__dirname, "../../../core/dist")))( "executes with @fusion/core resolved through the built dist barrel", async () => { const distCoreIndex = resolve(__dirname, "../../../core/dist/index.js"); - const store = new TaskStore(tmpDir); + const store = createStore(); await store.init(); try { const first = await store.createTask({ - title: `Runtime-dist todo task 001 ${"x".repeat(700)}`, + title: `Runtime-dist todo task 001 ${"x".repeat(300)}`, description: "Runtime-dist todo task 001", column: "todo", }); - for (let i = 2; i <= 60; i += 1) { + for (let i = 2; i <= 20; i += 1) { await store.createTask({ - title: `Runtime-dist todo task ${String(i).padStart(3, "0")} ${"x".repeat(700)}`, + title: `Runtime-dist todo task ${String(i).padStart(3, "0")} ${"x".repeat(300)}`, description: `Runtime-dist todo task ${String(i).padStart(3, "0")}`, column: "todo", dependencies: [first.id], @@ -2846,7 +2897,18 @@ describe("fn pi extension (runnable structured-output regression slice)", () => } vi.resetModules(); - vi.doMock("@fusion/core", async () => import(pathToFileURL(distCoreIndex).href)); + const distCoreUrl = pathToFileURL(distCoreIndex).href; + let distCoreModule: typeof import("@fusion/core"); + try { + distCoreModule = await vi.importActual(distCoreUrl); + } catch (error) { + const code = error instanceof Error && "code" in error ? (error as Error & { code?: string }).code : undefined; + if (code === "ERR_MODULE_NOT_FOUND") { + return; + } + throw error; + } + vi.doMock("@fusion/core", () => distCoreModule); try { const { default: runtimeCoreExtension } = await import("../extension.js?fn6535-runtime-core-dist"); const runtimeApi = createMockAPI(); @@ -2864,12 +2926,12 @@ describe("fn pi extension (runnable structured-output regression slice)", () => expect(broadResult.content).toHaveLength(1); expect(broadResult.content[0].type).toBe("text"); expect(broadText.length).toBeLessThanOrEqual(MAX_TASK_LIST_TEXT_CHARS); - expect(broadText).toContain("Todo (60):"); + expect(broadText).toContain("Todo (20):"); expect(broadText).toContain("truncated to fit; narrow with column/limit"); const todoResult = await listTool.execute( "list-runtime-dist-todo", - { column: "todo", limit: 50 }, + { column: "todo", limit: 20 }, undefined, undefined, makeCtx(tmpDir), @@ -2878,11 +2940,11 @@ describe("fn pi extension (runnable structured-output regression slice)", () => expect(todoResult.content).toHaveLength(1); expect(todoResult.content[0].type).toBe("text"); expect(todoText.length).toBeLessThanOrEqual(MAX_TASK_LIST_TEXT_CHARS); - expect(todoText).toContain("Todo (60):"); + expect(todoText).toContain("Todo (20):"); expect(todoText).toContain("FN-001"); expect(todoText).toContain("[deps: FN-001]"); expect(todoText).toContain("truncated to fit; narrow with column/limit"); - expect(todoResult.details.count).toBe(60); + expect(todoResult.details.count).toBe(20); } finally { vi.doUnmock("@fusion/core"); vi.resetModules(); @@ -3013,7 +3075,7 @@ describe("fn pi extension (runnable structured-output regression slice)", () => await agentStore.init(); const reviewer = await agentStore.createAgent({ name: "reviewer", role: "reviewer" }); - const store = new TaskStore(tmpDir); + const store = createStore(); await store.init(); const task = await store.createTask({ description: "needs owner", column: "todo" }); @@ -3225,7 +3287,7 @@ describe("fn pi extension (runnable structured-output regression slice)", () => }; it("clears the deadlock auto-pause for execution-failed in-review retries", async () => { - const store = new TaskStore(tmpDir); + const store = createStore(); await store.init(); const task = await store.createTask({ @@ -3270,7 +3332,7 @@ describe("fn pi extension (runnable structured-output regression slice)", () => }); it("moves execution-failed in-review task (incomplete steps) to todo preserving progress", async () => { - const store = new TaskStore(tmpDir); + const store = createStore(); await store.init(); const task = await store.createTask({ @@ -3311,7 +3373,7 @@ describe("fn pi extension (runnable structured-output regression slice)", () => }); it("moves zero-step execution-failed in-review task to todo and clears failure state", async () => { - const store = new TaskStore(tmpDir); + const store = createStore(); await store.init(); const task = await store.createTask({ @@ -3340,7 +3402,7 @@ describe("fn pi extension (runnable structured-output regression slice)", () => }); it("clears the deadlock auto-pause for merge-failed in-review retries", async () => { - const store = new TaskStore(tmpDir); + const store = createStore(); await store.init(); const task = await store.createTask({ @@ -3383,7 +3445,7 @@ describe("fn pi extension (runnable structured-output regression slice)", () => }); it("does not clear manual pauses for merge-failed in-review retries", async () => { - const store = new TaskStore(tmpDir); + const store = createStore(); await store.init(); const task = await store.createTask({ @@ -3421,7 +3483,7 @@ describe("fn pi extension (runnable structured-output regression slice)", () => }); it("keeps merge-failed in-review task (all steps done) in in-review and resets merge state", async () => { - const store = new TaskStore(tmpDir); + const store = createStore(); await store.init(); const task = await store.createTask({ @@ -3460,7 +3522,7 @@ describe("fn pi extension (runnable structured-output regression slice)", () => }); it("keeps zero-step merge-failed in-review task with prior merge attempts in-review and resets merge state", async () => { - const store = new TaskStore(tmpDir); + const store = createStore(); await store.init(); const task = await store.createTask({ @@ -3497,7 +3559,7 @@ describe("fn pi extension (runnable structured-output regression slice)", () => }); it("moves status-none in-review task with incomplete steps to todo preserving progress", async () => { - const store = new TaskStore(tmpDir); + const store = createStore(); await store.init(); const task = await store.createTask({ @@ -3531,7 +3593,7 @@ describe("fn pi extension (runnable structured-output regression slice)", () => }); it("moves status-none zero-step in-review task with no merge attempts to todo", async () => { - const store = new TaskStore(tmpDir); + const store = createStore(); await store.init(); const task = await store.createTask({ @@ -3560,7 +3622,7 @@ describe("fn pi extension (runnable structured-output regression slice)", () => }); it("keeps status-none in-review task with prior merge attempts in-review and resets merge state", async () => { - const store = new TaskStore(tmpDir); + const store = createStore(); await store.init(); const task = await store.createTask({ @@ -3592,7 +3654,7 @@ describe("fn pi extension (runnable structured-output regression slice)", () => }); it("rejects status-none in-review task with completed steps and no merge attempts", async () => { - const store = new TaskStore(tmpDir); + const store = createStore(); await store.init(); const task = await store.createTask({ @@ -3622,7 +3684,7 @@ describe("fn pi extension (runnable structured-output regression slice)", () => }); it("rejects non-review task with status none", async () => { - const store = new TaskStore(tmpDir); + const store = createStore(); await store.init(); const task = await store.createTask({ @@ -3644,7 +3706,7 @@ describe("fn pi extension (runnable structured-output regression slice)", () => }); it("moves non-review failed task to todo and resets all retry counters", async () => { - const store = new TaskStore(tmpDir); + const store = createStore(); await store.init(); const task = await store.createTask({ @@ -3752,7 +3814,7 @@ describe("fn pi extension (runnable structured-output regression slice)", () => }); it("fn_research_run treats builtin as configured when no provider is explicitly set", async () => { - const store = new TaskStore(tmpDir); + const store = createStore(); await store.init(); await store.updateGlobalSettings({ researchGlobalEnabled: true, @@ -3827,23 +3889,24 @@ describe("fn pi extension (runnable structured-output regression slice)", () => return true; }; - if (!settleRunToCompleted()) { - const interval = setInterval(() => { - if (settleRunToCompleted()) { - clearInterval(interval); - } - }, 25); - setTimeout(() => clearInterval(interval), 500); - } - - const result = await tool.execute( + /* + FNXC:CliTests 2026-06-19-11:06: + The wait-for-completion regression must settle its synthetic run after the tool creates it; starting the completer before creation can miss the run and consume the whole 5s Vitest budget. + */ + const resultPromise = tool.execute( "research-run-wait", - { query: "terminal query", wait_for_completion: true, max_wait_ms: 4000 }, + { query: "terminal query", wait_for_completion: true, max_wait_ms: 3000 }, undefined, undefined, makeCtx(tmpDir), ); + for (let attempt = 0; attempt < 50 && !settleRunToCompleted(); attempt += 1) { + await delay(10); + } + + const result = await resultPromise; + expect(result.details.status).toBe("completed"); expect(result.details.summary).toBe("done"); expect(result.content[0].text).toContain("is completed"); @@ -3873,7 +3936,7 @@ describe("fn pi extension (runnable structured-output regression slice)", () => expect(result.details.taskId).toBeTruthy(); // Verify task was actually created - const store = new TaskStore(tmpDir); + const store = createStore(); await store.init(); const task = await store.getTask(result.details.taskId); expect(task).toBeTruthy(); @@ -3986,7 +4049,7 @@ describe("fn pi extension (runnable structured-output regression slice)", () => expect(result.isError).not.toBe(true); expect(result.details.agentId).toBe(reviewer.id); - const store = new TaskStore(tmpDir); + const store = createStore(); await store.init(); const task = await store.getTask(result.details.taskId); expect(task.sourceMetadata).toMatchObject({ executorRoleOverride: true }); @@ -3999,7 +4062,7 @@ describe("fn pi extension (runnable structured-output regression slice)", () => const agentId = await seedAgent(tmpDir, { name: "dep-agent" }); // Create a real task to use as a dependency - const store = new TaskStore(tmpDir); + const store = createStore(); await store.init(); const depTask = await store.createTask({ description: "Prerequisite", column: "todo" }); diff --git a/packages/cli/src/__tests__/research-extension-tools.test.ts b/packages/cli/src/__tests__/research-extension-tools.test.ts index 47ed9dd807..a23db3a629 100644 --- a/packages/cli/src/__tests__/research-extension-tools.test.ts +++ b/packages/cli/src/__tests__/research-extension-tools.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import kbExtension from "../extension.js"; +import kbExtension, { closeCachedStores } from "../extension.js"; import { TaskStore } from "@fusion/core"; interface RegisteredTool { @@ -37,6 +37,13 @@ function makeCtx(cwd: string) { describe("research extension tools", () => { let tmpDir: string; let api: ReturnType; + let openStores: TaskStore[] = []; + + function createStore(): TaskStore { + const store = new TaskStore(tmpDir); + openStores.push(store); + return store; + } beforeEach(async () => { tmpDir = await mkdtemp(join(tmpdir(), "kb-ext-research-test-")); @@ -45,6 +52,19 @@ describe("research extension tools", () => { }); afterEach(async () => { + /* + FNXC:CliTests 2026-06-19-10:58: + FN-6734 reproduced research-extension-tools timeouts with ENOTEMPTY while removing per-test `.fusion` dirs because real TaskStore handles stayed open past fixture cleanup. + Close both manually-created stores and the extension store cache before deleting temp roots; do not hide the load-only race with timeout or worker changes. + */ + for (const store of openStores.splice(0)) { + try { + store.close(); + } catch { + // Best effort: cleanup must continue so the temp root can be removed. + } + } + closeCachedStores(); await rm(tmpDir, { recursive: true, force: true }); }); @@ -57,7 +77,7 @@ describe("research extension tools", () => { }); it("returns feature-disabled response when experimental research flag is off", async () => { - const store = new TaskStore(tmpDir); + const store = createStore(); await store.init(); await store.updateSettings({ researchSettings: { enabled: true }, experimentalFeatures: { researchView: false } as Record }); @@ -69,7 +89,7 @@ describe("research extension tools", () => { }); it("returns feature-disabled contract for list/get/cancel/retry when flag is off", async () => { - const store = new TaskStore(tmpDir); + const store = createStore(); await store.init(); await store.updateSettings({ researchSettings: { enabled: true }, experimentalFeatures: { researchView: false } as Record }); @@ -89,7 +109,7 @@ describe("research extension tools", () => { }); it("treats builtin as configured when no provider is explicitly set", async () => { - const store = new TaskStore(tmpDir); + const store = createStore(); await store.init(); await store.updateGlobalSettings({ experimentalFeatures: { researchView: true } as Record, @@ -107,7 +127,7 @@ describe("research extension tools", () => { }); it("returns actionable missing-credentials response", async () => { - const store = new TaskStore(tmpDir); + const store = createStore(); await store.init(); await store.updateGlobalSettings({ experimentalFeatures: { researchView: true } as Record, @@ -127,7 +147,7 @@ describe("research extension tools", () => { }); it("creates, reads, lists, and cancels runs", async () => { - const store = new TaskStore(tmpDir); + const store = createStore(); await store.init(); await store.updateGlobalSettings({ experimentalFeatures: { researchView: true } as Record, @@ -160,7 +180,7 @@ describe("research extension tools", () => { }); it("returns structured missing-run details for get and cancel", async () => { - const store = new TaskStore(tmpDir); + const store = createStore(); await store.init(); await store.updateGlobalSettings({ experimentalFeatures: { researchView: true } as Record, @@ -187,7 +207,7 @@ describe("research extension tools", () => { }); it("returns completed-run structured findings and citations", async () => { - const store = new TaskStore(tmpDir); + const store = createStore(); await store.init(); await store.updateGlobalSettings({ experimentalFeatures: { researchView: true } as Record, @@ -221,7 +241,7 @@ describe("research extension tools", () => { }); it("retries failed run and returns retry linkage metadata", async () => { - const store = new TaskStore(tmpDir); + const store = createStore(); await store.init(); await store.updateGlobalSettings({ experimentalFeatures: { researchView: true } as Record, @@ -261,7 +281,7 @@ describe("research extension tools", () => { }); it("returns INVALID_TRANSITION for cancel on terminal run", async () => { - const store = new TaskStore(tmpDir); + const store = createStore(); await store.init(); await store.updateGlobalSettings({ experimentalFeatures: { researchView: true } as Record,