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>
This commit is contained in:
gsxdsm
2026-07-16 14:01:59 -07:00
parent f111a40c72
commit d306ab67b2
3 changed files with 136 additions and 7 deletions

View File

@@ -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.

View File

@@ -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<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;

View File

@@ -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<string, CachedStoreEntry>;
readonly bootInflight: Map<string, Promise<TaskStore>>;
readonly bootFailureCooldown: Map<string, { untilMs: number; error: string }>;
}
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<string, CachedStoreEntry>(),
bootInflight: new Map<string, Promise<TaskStore>>(),
bootFailureCooldown: new Map<string, { untilMs: number; error: string }>(),
};
extensionStoreGlobal[extensionStoreStateKey] = extensionStoreState;
/** Cache stores per project root to avoid re-booting the backend on every tool call. */
const storeCache = new Map<string, CachedStoreEntry>();
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<string, Promise<TaskStore>>();
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<string, { untilMs: number; error: string }>();
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<TArgs extends unknown[], TResult>(
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<TaskStore> {
let extensionStoreBootFactory: typeof createTaskStoreForBackend = createTaskStoreForBackend;
async function getStore(
cwd: string,
signal?: AbortSignal,
bootTimeoutMs = EXTENSION_STORE_BOOT_TIMEOUT_MS,
): Promise<TaskStore> {
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<TaskStore> {
*/
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<TaskStore> {
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<TaskStore> {
return getStore(cwd, undefined, bootTimeoutMs);
}
/**