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:
7
.changeset/fn-8140-taskstore-boot-timeout.md
Normal file
7
.changeset/fn-8140-taskstore-boot-timeout.md
Normal 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.
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user