From d306ab67b296edc80d7038ba33483fcb0cdd8231 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 16 Jul 2026 14:01:59 -0700 Subject: [PATCH] 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) --- .changeset/fn-8140-taskstore-boot-timeout.md | 7 ++ .../__tests__/extension-tool-timeout.test.ts | 88 ++++++++++++++++++- packages/cli/src/extension.ts | 48 ++++++++-- 3 files changed, 136 insertions(+), 7 deletions(-) create mode 100644 .changeset/fn-8140-taskstore-boot-timeout.md diff --git a/.changeset/fn-8140-taskstore-boot-timeout.md b/.changeset/fn-8140-taskstore-boot-timeout.md new file mode 100644 index 0000000000..4637f82220 --- /dev/null +++ b/.changeset/fn-8140-taskstore-boot-timeout.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Keep agent reads responsive by reusing the host TaskStore across extension loads. +category: fix +dev: Shares extension store cache state across Pi-loaded module instances to avoid dual backend boots. diff --git a/packages/cli/src/__tests__/extension-tool-timeout.test.ts b/packages/cli/src/__tests__/extension-tool-timeout.test.ts index 63a20a8759..71aad094a0 100644 --- a/packages/cli/src/__tests__/extension-tool-timeout.test.ts +++ b/packages/cli/src/__tests__/extension-tool-timeout.test.ts @@ -1,9 +1,12 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { __clearExtensionStoreBootStateForTesting, + __getStoreForTesting, __peekCachedStoreForTesting, + __setExtensionStoreBootFactoryForTesting, clampImportBrowseLimit, clearHostTaskStores, + closeCachedStores, raceWithTimeoutAndAbort, resolveExtensionToolTimeoutMs, setHostTaskStore, @@ -18,7 +21,8 @@ FNXC:MergeQueue 2026-07-15-11:28: Host extension research tools are off; budgets cover remaining long host tools only. */ -afterEach(() => { +afterEach(async () => { + await closeCachedStores(); __clearExtensionStoreBootStateForTesting(); clearHostTaskStores(); vi.restoreAllMocks(); @@ -65,6 +69,88 @@ describe("setHostTaskStore", () => { }); }); +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>; + + 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(() => {})); + 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>) => void) | undefined; + const factory = vi.fn(() => new Promise>>((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>) => 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>); + + 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(() => {})); + __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; diff --git a/packages/cli/src/extension.ts b/packages/cli/src/extension.ts index f4fc1ae696..553a8a730f 100644 --- a/packages/cli/src/extension.ts +++ b/packages/cli/src/extension.ts @@ -230,19 +230,38 @@ interface CachedStoreEntry { readonly external?: boolean; } +/* +FNXC:ExtensionStoreRegistry 2026-07-16-15:20: +Agent-read tools loaded through Pi's additionalExtensionPaths can be evaluated as a different ESM module instance from the CLI host that called setHostTaskStore. Keep cache, inflight, and cooldown state in one process registry so fn_list_agents and fn_agent_show reuse the host pool instead of opening a second backend that can wedge on schema/pool contention for 30 seconds. +*/ +interface ExtensionStoreState { + readonly cache: Map; + readonly bootInflight: Map>; + readonly bootFailureCooldown: Map; +} + +const extensionStoreStateKey = Symbol.for("@runfusion/fusion/extension-store-state"); +const extensionStoreGlobal = globalThis as typeof globalThis & { [key: symbol]: ExtensionStoreState | undefined }; +const extensionStoreState = extensionStoreGlobal[extensionStoreStateKey] ?? { + cache: new Map(), + bootInflight: new Map>(), + bootFailureCooldown: new Map(), +}; +extensionStoreGlobal[extensionStoreStateKey] = extensionStoreState; + /** Cache stores per project root to avoid re-booting the backend on every tool call. */ -const storeCache = new Map(); +const storeCache = extensionStoreState.cache; /* FNXC:MergeQueue 2026-07-15-11:08: Concurrent first-call fn_* tools must share one boot promise. Without this, two parallel cache misses each call createTaskStoreForBackend and contend on fusion:schema-applier advisory locks / pool setup — the pattern behind wedged fn_task_show during AI merge. */ -const storeBootInflight = new Map>(); +const storeBootInflight = extensionStoreState.bootInflight; /* FNXC:MergeQueue 2026-07-15-11:20: After a hard boot failure, brief cooldown prevents stampede re-boots against a broken backend. Timeout alone does not set cooldown — the orphan inflight may still succeed and populate storeCache. */ -const storeBootFailureCooldown = new Map(); +const storeBootFailureCooldown = extensionStoreState.bootFailureCooldown; const BOOT_FAILURE_COOLDOWN_MS = 5_000; /* FNXC:MergeQueue 2026-07-15-11:08: @@ -441,7 +460,13 @@ export function wrapExtensionToolExecute( FNXC:MergeQueue 2026-07-15-11:40: When dashboard/serve/daemon injects the live engine TaskStore via setHostTaskStore, getStore must never call createTaskStoreForBackend for that project root — dual-boot was the FN-7956 hang class (second pool + schema advisory lock). CLI one-shot sessions without a host store still boot a short-lived cache entry. */ -async function getStore(cwd: string, signal?: AbortSignal): Promise { +let extensionStoreBootFactory: typeof createTaskStoreForBackend = createTaskStoreForBackend; + +async function getStore( + cwd: string, + signal?: AbortSignal, + bootTimeoutMs = EXTENSION_STORE_BOOT_TIMEOUT_MS, +): Promise { const projectRoot = resolveProjectRoot(cwd); const existing = storeCache.get(projectRoot); if (existing) return existing.store; @@ -470,7 +495,7 @@ async function getStore(cwd: string, signal?: AbortSignal): Promise { */ inflight = (async () => { try { - const boot = await createTaskStoreForBackend({ rootDir: projectRoot }); + const boot = await extensionStoreBootFactory({ rootDir: projectRoot }); storeBootFailureCooldown.delete(projectRoot); // Do not overwrite a host-injected external store that landed while we were booting. const raced = storeCache.get(projectRoot); @@ -499,7 +524,7 @@ async function getStore(cwd: string, signal?: AbortSignal): Promise { try { return await raceWithTimeoutAndAbort( inflight, - EXTENSION_STORE_BOOT_TIMEOUT_MS, + bootTimeoutMs, effectiveSignal, "fn extension TaskStore boot", ); @@ -564,6 +589,17 @@ export function __peekCachedStoreForTesting(projectRoot: string): TaskStore | un export function __clearExtensionStoreBootStateForTesting(): void { storeBootInflight.clear(); storeBootFailureCooldown.clear(); + extensionStoreBootFactory = createTaskStoreForBackend; +} + +/** @internal Test-only: control cold-cache boot without starting embedded PostgreSQL. */ +export function __setExtensionStoreBootFactoryForTesting(factory?: typeof createTaskStoreForBackend): void { + extensionStoreBootFactory = factory ?? createTaskStoreForBackend; +} + +/** @internal Test-only: exercise the same cache/inflight resolution seam with a bounded test budget. */ +export function __getStoreForTesting(cwd: string, bootTimeoutMs = EXTENSION_STORE_BOOT_TIMEOUT_MS): Promise { + return getStore(cwd, undefined, bootTimeoutMs); } /**