diff --git a/src/context-engine.ts b/src/context-engine.ts index aff58ed2..983927e3 100644 --- a/src/context-engine.ts +++ b/src/context-engine.ts @@ -2410,6 +2410,7 @@ export function buildContextEngineFactory( if (beforeTurnQueryHint) { try { const beforeTurnTimeout = cfg.beforeTurnTimeoutMs ?? 5000; + let timeoutHandle: ReturnType | undefined; const btResult = await Promise.race([ client.beforeTurnKernel({ sessionId, @@ -2420,10 +2421,14 @@ export function buildContextEngineFactory( cursor: undefined, isHeartbeat: false, } as unknown as Parameters[0]), - new Promise((_, reject) => - setTimeout(() => reject(new Error(`BeforeTurnKernel timed out after ${beforeTurnTimeout}ms`)), beforeTurnTimeout) - ), - ]); + new Promise((_, reject) => { + timeoutHandle = setTimeout(() => reject(new Error(`BeforeTurnKernel timed out after ${beforeTurnTimeout}ms`)), beforeTurnTimeout); + }), + ]).finally(() => { + if (timeoutHandle) { + clearTimeout(timeoutHandle); + } + }); const maxMemories = cfg.beforeTurnMaxMemories ?? 5; const clamped = btResult.predictions && btResult.predictions.length > maxMemories ? selectTopByRelevance(btResult.predictions, strippedPrompt, maxMemories) diff --git a/test/integration/markdown-ingest.test.ts b/test/integration/markdown-ingest.test.ts index 3962c314..893d1cc6 100644 --- a/test/integration/markdown-ingest.test.ts +++ b/test/integration/markdown-ingest.test.ts @@ -6,12 +6,6 @@ import path from "node:path"; import { createMarkdownIngestionHandle, type FsDirentLike } from "../../src/markdown-ingest.js"; -type FsDirentLike = { - name: string; - isDirectory(): boolean; - isFile(): boolean; -}; - class FakeRpcClient { calls: Array<{ method: string; params: unknown }> = []; documents = new Map }>(); diff --git a/test/unit/context-engine.test.ts b/test/unit/context-engine.test.ts index 926c0b2a..537b0dff 100644 --- a/test/unit/context-engine.test.ts +++ b/test/unit/context-engine.test.ts @@ -266,6 +266,58 @@ function makeMessage(role: string, content: string, id?: string) { return { role, content, ...(id ? { id } : {}) }; } +test("context engine clears BeforeTurnKernel timeout after successful retrieval", async () => { + class BeforeTurnClient extends FakeClient { + async beforeTurnKernel(params: Record) { + this.calls.push({ method: "beforeTurnKernel", params }); + return { predictions: [] }; + } + } + + const originalSetTimeout = globalThis.setTimeout; + const originalClearTimeout = globalThis.clearTimeout; + const scheduled = new Set>(); + const cleared = new Set>(); + + globalThis.setTimeout = ((...args: Parameters) => { + const handle = Reflect.apply(originalSetTimeout, globalThis, args) as ReturnType; + scheduled.add(handle); + return handle; + }) as typeof setTimeout; + globalThis.clearTimeout = ((handle?: Parameters[0]) => { + if (handle) { + cleared.add(handle as ReturnType); + } + return Reflect.apply(originalClearTimeout, globalThis, [handle]); + }) as typeof clearTimeout; + + try { + const client = new BeforeTurnClient(); + const engine = buildContextEngineFactory(fakeRuntime(client), { + userId: "fixed-user", + beforeTurnTimeoutMs: 60_000, + }); + + await engine.assemble({ + sessionId: "s1-before-turn-clears-timeout", + sessionKey: "sk1", + messages: [makeMessage("user", "what do you remember?")], + prompt: "what do you remember?", + tokenBudget: 4000, + }); + + assert.equal(client.calls.filter((call) => call.method === "beforeTurnKernel").length, 1); + assert.equal(scheduled.size, 1); + assert.deepEqual(cleared, scheduled, "successful before-turn retrieval should clear its timeout"); + } finally { + for (const handle of scheduled) { + originalClearTimeout(handle); + } + globalThis.setTimeout = originalSetTimeout; + globalThis.clearTimeout = originalClearTimeout; + } +}); + function openClawMetadataEnvelope(userText: string): string { return [ "Conversation info (untrusted metadata):",