Files
fusion/packages/cli/src/__tests__/extension-tool-timeout.test.ts
gsxdsm d306ab67b2 FN-8140: reuse host TaskStore across extension loads
Reuse process-wide extension store state to prevent duplicate backend boots from blocking agent reads.

- Share TaskStore cache, boot-inflight, and failure cooldown state across ESM module instances.
- Preserve host-injected stores when cold boots race and add bounded boot-resolution coverage.
- Add a patch changeset for responsive agent reads.

Files changed:
 .changeset/fn-8140-taskstore-boot-timeout.md       |  7 ++
 .../src/__tests__/extension-tool-timeout.test.ts   | 88 +++++++++++++++++++++-
 packages/cli/src/extension.ts                      | 48 ++++++++++--
 3 files changed, 136 insertions(+), 7 deletions(-)

Fusion-Task-Id: FN-8140

Fusion-Task-Lineage: de32f4e9-5870-4d8f-8454-2fe342a575a8

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-16 14:01:59 -07:00

260 lines
10 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "vitest";
import {
__clearExtensionStoreBootStateForTesting,
__getStoreForTesting,
__peekCachedStoreForTesting,
__setExtensionStoreBootFactoryForTesting,
clampImportBrowseLimit,
clearHostTaskStores,
closeCachedStores,
raceWithTimeoutAndAbort,
resolveExtensionToolTimeoutMs,
setHostTaskStore,
wrapExtensionToolExecute,
} from "../extension.js";
/*
FNXC:MergeQueue 2026-07-15-11:15:
FN-7956 hung AI merge review on unbounded extension fn_task_show. These unit tests lock the fail-closed timeout/abort budgets that unblock agent turns when store work wedges.
FNXC:MergeQueue 2026-07-15-11:28:
Host extension research tools are off; budgets cover remaining long host tools only.
*/
afterEach(async () => {
await closeCachedStores();
__clearExtensionStoreBootStateForTesting();
clearHostTaskStores();
vi.restoreAllMocks();
});
describe("resolveExtensionToolTimeoutMs", () => {
it("keeps the default 60s budget for ordinary store tools", () => {
expect(resolveExtensionToolTimeoutMs("fn_task_show")).toBe(60_000);
expect(resolveExtensionToolTimeoutMs("fn_task_list")).toBe(60_000);
});
it("gives multi-minute budgets to long host tools", () => {
expect(resolveExtensionToolTimeoutMs("fn_skills_install")).toBe(300_000);
expect(resolveExtensionToolTimeoutMs("fn_task_plan")).toBe(300_000);
expect(resolveExtensionToolTimeoutMs("fn_experiment_finalize")).toBe(180_000);
expect(resolveExtensionToolTimeoutMs("fn_mission_backfill_assertions")).toBe(180_000);
expect(resolveExtensionToolTimeoutMs("fn_task_import_github")).toBe(180_000);
expect(resolveExtensionToolTimeoutMs("fn_task_browse_github_issues")).toBe(180_000);
expect(resolveExtensionToolTimeoutMs("fn_web_fetch")).toBe(90_000);
});
});
describe("clampImportBrowseLimit", () => {
it("defaults to 30 and hard-caps at 50", () => {
expect(clampImportBrowseLimit(undefined)).toBe(30);
expect(clampImportBrowseLimit(100)).toBe(50);
expect(clampImportBrowseLimit(0)).toBe(1);
expect(clampImportBrowseLimit(12)).toBe(12);
});
});
describe("setHostTaskStore", () => {
it("caches the host store so getStore reuses it without dual-boot", () => {
/*
FNXC:MergeQueue 2026-07-15-11:50:
Host injection must place the store under resolveProjectRoot so tool getStore hits the external entry.
*/
const fakeStore = { id: "host-store" } as unknown as import("@fusion/core").TaskStore;
const root = "/tmp/fusion-host-store-test";
setHostTaskStore(root, fakeStore);
expect(__peekCachedStoreForTesting(root)).toBe(fakeStore);
clearHostTaskStores(root);
expect(__peekCachedStoreForTesting(root)).toBeUndefined();
});
});
describe("extension TaskStore resolution", () => {
const root = "/tmp/fusion-extension-store-resolution";
const bootResult = (store: import("@fusion/core").TaskStore) => ({
taskStore: store,
shutdown: vi.fn(async () => {}),
}) as Awaited<ReturnType<typeof import("@fusion/core").createTaskStoreForBackend>>;
it("shares the host-injected store with a separately evaluated host extension", async () => {
/*
FNXC:ExtensionStoreRegistry 2026-07-16-15:20:
Pi can evaluate the extension separately from the daemon's CLI import. This reproduces the FN-8140 no-host-cache symptom without starting embedded PostgreSQL: a separate copy must see the host store and never invoke its controllable wedged cold boot.
*/
const hostStore = { id: "host-store" } as unknown as import("@fusion/core").TaskStore;
setHostTaskStore(root, hostStore);
vi.resetModules();
const isolated = await import("../extension.js");
const neverSettles = vi.fn(() => new Promise<never>(() => {}));
isolated.__setExtensionStoreBootFactoryForTesting(neverSettles as typeof import("@fusion/core").createTaskStoreForBackend);
await expect(isolated.__getStoreForTesting(root, 25)).resolves.toBe(hostStore);
expect(neverSettles).not.toHaveBeenCalled();
});
it("boots a healthy cold cache once and coalesces concurrent callers", async () => {
const store = { id: "cold-store" } as unknown as import("@fusion/core").TaskStore;
let resolveBoot: ((value: Awaited<ReturnType<typeof import("@fusion/core").createTaskStoreForBackend>>) => void) | undefined;
const factory = vi.fn(() => new Promise<Awaited<ReturnType<typeof import("@fusion/core").createTaskStoreForBackend>>>((resolve) => {
resolveBoot = resolve;
}));
__setExtensionStoreBootFactoryForTesting(factory as typeof import("@fusion/core").createTaskStoreForBackend);
const first = __getStoreForTesting(`${root}-cold`, 100);
const second = __getStoreForTesting(`${root}-cold`, 100);
expect(factory).toHaveBeenCalledOnce();
resolveBoot!(bootResult(store));
await expect(Promise.all([first, second])).resolves.toEqual([store, store]);
});
it("does not overwrite a host store injected while a cold boot is inflight", async () => {
const coldStore = { id: "cold-store" } as unknown as import("@fusion/core").TaskStore;
const hostStore = { id: "late-host-store" } as unknown as import("@fusion/core").TaskStore;
let resolveBoot: ((value: Awaited<ReturnType<typeof import("@fusion/core").createTaskStoreForBackend>>) => void) | undefined;
const shutdown = vi.fn(async () => {});
__setExtensionStoreBootFactoryForTesting((() => new Promise((resolve) => {
resolveBoot = resolve;
})) as typeof import("@fusion/core").createTaskStoreForBackend);
const inflightRoot = `${root}-late-host`;
const waiting = __getStoreForTesting(inflightRoot, 100);
setHostTaskStore(inflightRoot, hostStore);
resolveBoot!({ taskStore: coldStore, shutdown } as Awaited<ReturnType<typeof import("@fusion/core").createTaskStoreForBackend>>);
await expect(waiting).resolves.toBe(hostStore);
expect(__peekCachedStoreForTesting(inflightRoot)).toBe(hostStore);
expect(shutdown).toHaveBeenCalledOnce();
});
it("fails a controllable wedged cold boot at the bounded caller budget", async () => {
const factory = vi.fn(() => new Promise<never>(() => {}));
__setExtensionStoreBootFactoryForTesting(factory as typeof import("@fusion/core").createTaskStoreForBackend);
const wedgedRoot = `${root}-wedged`;
await expect(__getStoreForTesting(wedgedRoot, 20)).rejects.toThrow(/timed out after 20ms/);
expect(factory).toHaveBeenCalledOnce();
});
it("applies cooldown after a hard cold-boot failure", async () => {
const factory = vi.fn(async () => {
throw new Error("backend unavailable");
});
__setExtensionStoreBootFactoryForTesting(factory as typeof import("@fusion/core").createTaskStoreForBackend);
const failedRoot = `${root}-failed`;
await expect(__getStoreForTesting(failedRoot, 100)).rejects.toThrow("backend unavailable");
await expect(__getStoreForTesting(failedRoot, 100)).rejects.toThrow(/recently failed/);
expect(factory).toHaveBeenCalledOnce();
});
});
describe("wrapExtensionToolExecute timeout abort", () => {
it("aborts the tool signal when the outer budget expires so nested work can stop", async () => {
let seenSignal: AbortSignal | undefined;
const execute = vi.fn((_id: string, _params: unknown, signal?: AbortSignal) => {
seenSignal = signal;
return new Promise(() => {
/* never settles */
});
});
const wrapped = wrapExtensionToolExecute("fn_budget", execute, 30);
const result = await wrapped("id", {}, undefined);
expect(result).toMatchObject({ isError: true });
expect(seenSignal?.aborted).toBe(true);
});
});
describe("raceWithTimeoutAndAbort", () => {
it("resolves when the promise wins", async () => {
await expect(
raceWithTimeoutAndAbort(Promise.resolve("ok"), 1_000, undefined, "t"),
).resolves.toBe("ok");
});
it("rejects on timeout", async () => {
await expect(
raceWithTimeoutAndAbort(
new Promise(() => {
/* never settles */
}),
20,
undefined,
"slow-tool",
),
).rejects.toThrow(/slow-tool timed out after 20ms/);
});
it("rejects when the signal aborts", async () => {
const controller = new AbortController();
const pending = raceWithTimeoutAndAbort(
new Promise(() => {
/* never settles */
}),
5_000,
controller.signal,
"aborted-tool",
);
controller.abort();
await expect(pending).rejects.toMatchObject({ name: "AbortError" });
});
it("rejects immediately when signal is already aborted", async () => {
const controller = new AbortController();
controller.abort();
await expect(
raceWithTimeoutAndAbort(Promise.resolve("late"), 1_000, controller.signal, "pre-aborted"),
).rejects.toMatchObject({ name: "AbortError" });
});
});
describe("wrapExtensionToolExecute", () => {
it("returns the tool result on success", async () => {
const execute = vi.fn(async () => ({ content: [{ type: "text" as const, text: "hi" }] }));
const wrapped = wrapExtensionToolExecute("fn_demo", execute, 1_000);
await expect(wrapped("id", {}, undefined)).resolves.toEqual({
content: [{ type: "text", text: "hi" }],
});
expect(execute).toHaveBeenCalledOnce();
});
it("converts timeouts into isError tool results instead of hanging", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const execute = vi.fn(
() =>
new Promise(() => {
/* never settles */
}),
);
const wrapped = wrapExtensionToolExecute("fn_hang", execute, 25);
const result = await wrapped("id", {}, undefined);
expect(result).toMatchObject({
isError: true,
details: { error: expect.stringMatching(/timed out after 25ms/) },
});
expect((result as { content: Array<{ text: string }> }).content[0].text).toContain("fn_hang failed");
expect(warn).toHaveBeenCalledWith(expect.stringContaining("fn_hang"));
});
it("converts abort into isError tool results", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const controller = new AbortController();
const execute = vi.fn(
() =>
new Promise(() => {
/* never settles */
}),
);
const wrapped = wrapExtensionToolExecute("fn_abort", execute, 5_000);
const pending = wrapped("id", {}, controller.signal);
controller.abort();
await expect(pending).resolves.toMatchObject({
isError: true,
details: { error: "aborted" },
});
expect(warn).toHaveBeenCalledWith(expect.stringContaining("fn_abort aborted"));
});
});