From 76ba214b983185db7952d0632c9c9e23c3fd0118 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 4 Aug 2026 16:10:51 +0800 Subject: [PATCH 01/17] fix(llama-cpp): dispose runtime on plugin stop --- CHANGELOG.md | 1 + extensions/llama-cpp/index.test.ts | 25 +++ extensions/llama-cpp/index.ts | 10 +- .../llama-cpp/src/inference-provider.test.ts | 132 ++++++++++++-- .../llama-cpp/src/inference-provider.ts | 171 ++++++++++++------ 5 files changed, 263 insertions(+), 76 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 389f26d66c81..5bd4777fec09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- **llama.cpp runtime cleanup:** dispose in-process GGUF contexts, models, and native runtimes when the provider plugin stops or reloads so replaced registrations do not retain model memory. Fixes #119144. - **Guided onboarding skip-UI routing:** keep `openclaw onboard --skip-ui` and `openclaw setup --skip-ui` on guided onboarding while skipping both browser and terminal handoffs, instead of silently switching to the classic wizard. Thanks @shakkernerd. - **Telegram durable ingress:** preserve pre-identity control-lane ownership during replay and attempt each drain snapshot row only once per pass, preventing targeted commands from spinning the spool and blocking polling shutdown. - **Control UI operator session permissions:** honor Gateway-advertised operator scopes for new-thread creation, thread management, checkpoints, and sharing controls while preserving read-only navigation and legacy Gateway compatibility. Fixes #117786. Thanks @shakkernerd. diff --git a/extensions/llama-cpp/index.test.ts b/extensions/llama-cpp/index.test.ts index 15a1e5c4bd73..4483334ae9cb 100644 --- a/extensions/llama-cpp/index.test.ts +++ b/extensions/llama-cpp/index.test.ts @@ -1,6 +1,7 @@ import os from "node:os"; import path from "node:path"; import { expectDefined } from "@openclaw/normalization-core"; +import type { OpenClawPluginService } from "openclaw/plugin-sdk/plugin-entry"; import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api"; import { createPluginRegistryFixture, @@ -116,6 +117,30 @@ afterEach(() => { }); describe("llama.cpp provider plugin", () => { + it("registers process-owned inference cleanup as a plugin service", async () => { + const services: OpenClawPluginService[] = []; + llamaCppPlugin.register( + createTestPluginApi({ + id: "llama-cpp", + name: "llama.cpp Provider", + source: "test", + config: {}, + pluginConfig: {}, + runtime: {} as never, + registerService: (service) => services.push(service), + }), + ); + + expect(services).toEqual([ + expect.objectContaining({ + id: "llama-cpp-inference-runtime", + start: expect.any(Function), + stop: expect.any(Function), + }), + ]); + await services[0]?.stop?.({} as never); + }); + it("registers the local text-inference provider", () => { expect(registerLlamaCppTextProvider()).toEqual( expect.objectContaining({ diff --git a/extensions/llama-cpp/index.ts b/extensions/llama-cpp/index.ts index ed2a74b32656..dbbd42102e19 100644 --- a/extensions/llama-cpp/index.ts +++ b/extensions/llama-cpp/index.ts @@ -8,7 +8,7 @@ import { resolveLlamaCppSyntheticApiKey, } from "./src/defaults.js"; import { llamaCppEmbeddingProviderAdapter } from "./src/embedding-provider.js"; -import { createLlamaCppStreamFn } from "./src/inference-provider.js"; +import { createLlamaCppInferenceRuntime } from "./src/inference-provider.js"; import { detectLlamaCppSetup, prepareLlamaCppSetup, runLlamaCppSetup } from "./src/setup.js"; export default definePluginEntry({ @@ -16,6 +16,12 @@ export default definePluginEntry({ name: "llama.cpp Provider", description: "Local GGUF text inference and embeddings through node-llama-cpp", register(api: OpenClawPluginApi) { + const inferenceRuntime = createLlamaCppInferenceRuntime(); + api.registerService({ + id: "llama-cpp-inference-runtime", + start: () => undefined, + stop: () => inferenceRuntime.dispose(), + }); api.registerEmbeddingProvider(llamaCppEmbeddingProviderAdapter); api.registerProvider({ id: LLAMA_CPP_PROVIDER_ID, @@ -51,7 +57,7 @@ export default definePluginEntry({ if (model.baseUrl !== LLAMA_CPP_LOCAL_BASE_URL) { return undefined; } - return createLlamaCppStreamFn({ + return inferenceRuntime.createStreamFn({ providerConfig: config?.models?.providers?.[provider], }); }, diff --git a/extensions/llama-cpp/src/inference-provider.test.ts b/extensions/llama-cpp/src/inference-provider.test.ts index e645b1fce79f..214dacb61ede 100644 --- a/extensions/llama-cpp/src/inference-provider.test.ts +++ b/extensions/llama-cpp/src/inference-provider.test.ts @@ -54,19 +54,17 @@ vi.mock("node-llama-cpp", () => ({ }, })); -import { createLlamaCppStreamFn } from "./inference-provider.js"; +import { + createLlamaCppInferenceRuntime, + llamaCppInferenceTestApi, + type LlamaCppInferenceRuntime, +} from "./inference-provider.js"; -const { - clearLlamaCppInferenceCacheForTests, - mapContextToLlamaChatHistory, - mapToolsToLlamaFunctions, -} = (globalThis as Record)[ - Symbol.for("openclaw.llamaCppInferenceTestApi") -] as { - clearLlamaCppInferenceCacheForTests: () => Promise; - mapContextToLlamaChatHistory: (context: Context) => unknown[]; - mapToolsToLlamaFunctions: (context: Context) => Record | undefined; -}; +if (!llamaCppInferenceTestApi) { + throw new Error("expected llama.cpp inference test API"); +} +const { mapContextToLlamaChatHistory, mapToolsToLlamaFunctions } = llamaCppInferenceTestApi; +let inferenceRuntime: LlamaCppInferenceRuntime; const model: Model = { id: "test.gguf", @@ -113,11 +111,11 @@ type TestStreamParams = { selectedModel?: Model; prompt?: string; tools?: Context["tools"]; - options?: Parameters>[2]; + options?: Parameters>[2]; }; async function createTestStream(params: TestStreamParams = {}) { - return await createLlamaCppStreamFn({})( + return await inferenceRuntime.createStreamFn({})( params.selectedModel ?? model, { messages: [{ role: "user", content: params.prompt ?? "Hi", timestamp: 1 }], @@ -131,8 +129,24 @@ async function collectTestEvents(params: TestStreamParams = {}) { return await collectEvents(await createTestStream(params)); } -beforeEach(async () => { - await clearLlamaCppInferenceCacheForTests(); +function deferGeneration() { + let finishGeneration: (() => void) | undefined; + mocks.generateResponse.mockImplementationOnce( + async () => + await new Promise((resolve) => { + finishGeneration = () => + resolve({ + response: "", + functionCalls: undefined, + metadata: { stopReason: "eogToken" }, + }); + }), + ); + return () => finishGeneration?.(); +} + +beforeEach(() => { + inferenceRuntime = createLlamaCppInferenceRuntime(); vi.clearAllMocks(); mocks.generateResponse.mockResolvedValue({ response: "", @@ -142,7 +156,7 @@ beforeEach(async () => { }); afterEach(async () => { - await clearLlamaCppInferenceCacheForTests(); + await inferenceRuntime.dispose(); }); describe("llama.cpp inference provider", () => { @@ -836,7 +850,7 @@ describe("llama.cpp inference provider", () => { }); it("disposes the previous model and context when the model changes", async () => { - const streamFn = createLlamaCppStreamFn({}); + const streamFn = inferenceRuntime.createStreamFn({}); await collectEvents( await streamFn(model, { messages: [{ role: "user", content: "one", timestamp: 1 }] }), ); @@ -853,7 +867,7 @@ describe("llama.cpp inference provider", () => { }); it("reuses one context sequence across serialized requests for the same model", async () => { - const streamFn = createLlamaCppStreamFn({}); + const streamFn = inferenceRuntime.createStreamFn({}); await collectEvents( await streamFn(model, { messages: [{ role: "user", content: "one", timestamp: 1 }] }), ); @@ -865,6 +879,84 @@ describe("llama.cpp inference provider", () => { expect(mocks.llama.loadModel).toHaveBeenCalledTimes(1); }); + it("disposes the context, model, and native runtime in ownership order", async () => { + await collectTestEvents(); + + await inferenceRuntime.dispose(); + + expect(mocks.contextDispose).toHaveBeenCalledOnce(); + expect(mocks.modelDispose).toHaveBeenCalledOnce(); + expect(mocks.llamaDispose).toHaveBeenCalledOnce(); + expect(mocks.contextDispose.mock.invocationCallOrder[0]).toBeLessThan( + mocks.modelDispose.mock.invocationCallOrder[0] ?? 0, + ); + expect(mocks.modelDispose.mock.invocationCallOrder[0]).toBeLessThan( + mocks.llamaDispose.mock.invocationCallOrder[0] ?? 0, + ); + }); + + it("waits for admitted inference before disposing the runtime", async () => { + const finishGeneration = deferGeneration(); + const stream = await createTestStream(); + await vi.waitFor(() => expect(mocks.generateResponse).toHaveBeenCalledOnce()); + + const disposing = inferenceRuntime.dispose(); + await Promise.resolve(); + expect(mocks.contextDispose).not.toHaveBeenCalled(); + + finishGeneration(); + await stream.result(); + await disposing; + + expect(mocks.contextDispose).toHaveBeenCalledOnce(); + expect(mocks.modelDispose).toHaveBeenCalledOnce(); + expect(mocks.llamaDispose).toHaveBeenCalledOnce(); + }); + + it("rejects new inference once runtime disposal begins", async () => { + const finishGeneration = deferGeneration(); + const activeStream = await createTestStream(); + await vi.waitFor(() => expect(mocks.generateResponse).toHaveBeenCalledOnce()); + + const disposing = inferenceRuntime.dispose(); + const rejectedStream = await createTestStream({ prompt: "too late" }); + + await expect(rejectedStream.result()).resolves.toMatchObject({ + stopReason: "error", + errorMessage: "llama.cpp runtime is stopping", + }); + expect(mocks.generateResponse).toHaveBeenCalledOnce(); + + finishGeneration(); + await activeStream.result(); + await disposing; + }); + + it("shares concurrent runtime disposal and performs cleanup once", async () => { + await collectTestEvents(); + + const disposals = [inferenceRuntime.dispose(), inferenceRuntime.dispose()]; + expect(disposals[1]).toBe(disposals[0]); + await Promise.all(disposals); + expect(mocks.contextDispose).toHaveBeenCalledOnce(); + expect(mocks.modelDispose).toHaveBeenCalledOnce(); + expect(mocks.llamaDispose).toHaveBeenCalledOnce(); + }); + + it("retains failed cleanup ownership so disposal can be retried", async () => { + await collectTestEvents(); + mocks.contextDispose.mockRejectedValueOnce(new Error("context cleanup failed")); + + await expect(inferenceRuntime.dispose()).rejects.toThrow("context cleanup failed"); + expect([mocks.modelDispose.mock.calls.length, mocks.llamaDispose.mock.calls.length]).toEqual([ + 0, 0, + ]); + await inferenceRuntime.dispose(); + expect(mocks.contextDispose).toHaveBeenCalledTimes(2); + expect(mocks.modelDispose).toHaveBeenCalledOnce(); + expect(mocks.llamaDispose).toHaveBeenCalledOnce(); + }); + it.each([ { scenario: "a smaller advertised model window", @@ -964,7 +1056,7 @@ describe("llama.cpp inference provider", () => { resolveFirst = resolve; }), ); - const streamFn = createLlamaCppStreamFn({}); + const streamFn = inferenceRuntime.createStreamFn({}); const firstStream = await streamFn(model, { messages: [{ role: "user", content: "first", timestamp: 1 }], }); diff --git a/extensions/llama-cpp/src/inference-provider.ts b/extensions/llama-cpp/src/inference-provider.ts index a6d04d927831..f55dee5bd4d2 100644 --- a/extensions/llama-cpp/src/inference-provider.ts +++ b/extensions/llama-cpp/src/inference-provider.ts @@ -41,11 +41,18 @@ type LoadedModel = { type LlamaJsonSchemaInput = Parameters[0]; -// Process-owned, single-slot cache. A model/context pair lives until another -// model replaces it or the process exits, bounding resident model memory. -let loadedModel: LoadedModel | undefined; -let llamaInstance: Llama | undefined; -let operationQueue: Promise = Promise.resolve(); +type LlamaCppInferenceRuntimeState = { + loadedModel?: LoadedModel; + llamaInstance?: Llama; + operationQueue: Promise; + lifecycle: "open" | "closing" | "closed"; + disposePromise?: Promise; +}; + +export type LlamaCppInferenceRuntime = { + createStreamFn: (params: { providerConfig?: ModelProviderConfig }) => StreamFn; + dispose: () => Promise; +}; function zeroCostUsage(input = 0, output = 0): Usage { return { @@ -235,17 +242,20 @@ function resolveContextSize( return { max: modelCap }; } -async function disposeLoadedModel(): Promise { - if (!loadedModel) { +async function disposeLoadedModel(state: LlamaCppInferenceRuntimeState): Promise { + if (!state.loadedModel) { return; } - const previous = loadedModel; - loadedModel = undefined; + const previous = state.loadedModel; await previous.context.dispose(); await previous.model.dispose(); + if (state.loadedModel === previous) { + state.loadedModel = undefined; + } } async function getLoadedModel(params: { + state: LlamaCppInferenceRuntimeState; runtime: NodeLlamaCppModule; model: Parameters[0]; providerConfig?: ModelProviderConfig; @@ -258,12 +268,12 @@ async function getLoadedModel(params: { }); const contextSize = resolveContextSize(params.model, params.providerConfig); const key = `${modelPath}\0${JSON.stringify(contextSize)}`; - if (loadedModel?.key === key) { - return loadedModel; + if (params.state.loadedModel?.key === key) { + return params.state.loadedModel; } - await disposeLoadedModel(); - const llama = llamaInstance ?? (await params.runtime.getLlama()); - llamaInstance = llama; + await disposeLoadedModel(params.state); + const llama = params.state.llamaInstance ?? (await params.runtime.getLlama()); + params.state.llamaInstance = llama; const fitContextSize = typeof contextSize === "number" ? contextSize : contextSize.max; const model = await llama.loadModel({ modelPath, @@ -276,8 +286,8 @@ async function getLoadedModel(params: { // Serialized requests reuse this one sequence. Disposing/reallocating it per // turn races node-llama-cpp's asynchronous sequence-id reclamation. const sequence = context.getSequence(); - loadedModel = { key, llama, model, context, sequence }; - return loadedModel; + params.state.loadedModel = { key, llama, model, context, sequence }; + return params.state.loadedModel; } catch (error) { await context?.dispose(); await model.dispose(); @@ -285,25 +295,66 @@ async function getLoadedModel(params: { } } -async function serialize(operation: () => Promise): Promise { - const current = operationQueue.then(operation, operation); - operationQueue = current.catch(() => undefined); +async function serialize( + state: LlamaCppInferenceRuntimeState, + operation: () => Promise, +): Promise { + const current = state.operationQueue.then(operation, operation); + state.operationQueue = current.catch(() => undefined); await current; } -async function clearLlamaCppInferenceCacheForTests(): Promise { - await serialize(async () => { - await disposeLoadedModel(); - if (llamaInstance) { - await llamaInstance.dispose(); - llamaInstance = undefined; +function disposeLlamaCppInferenceRuntime(state: LlamaCppInferenceRuntimeState): Promise { + if (state.lifecycle === "closed") { + return Promise.resolve(); + } + if (state.disposePromise) { + return state.disposePromise; + } + state.lifecycle = "closing"; + const attempt = serialize(state, async () => { + await disposeLoadedModel(state); + if (state.llamaInstance) { + const previous = state.llamaInstance; + await previous.dispose(); + if (state.llamaInstance === previous) { + state.llamaInstance = undefined; + } } }); + state.disposePromise = attempt.then( + () => { + state.lifecycle = "closed"; + state.disposePromise = undefined; + }, + (error: unknown) => { + state.disposePromise = undefined; + throw error; + }, + ); + return state.disposePromise; } -export function createLlamaCppStreamFn(params: { providerConfig?: ModelProviderConfig }): StreamFn { +function createLlamaCppStreamFnForRuntime( + state: LlamaCppInferenceRuntimeState, + params: { providerConfig?: ModelProviderConfig }, +): StreamFn { return createPlainTextToolCallCompatWrapper((model, context, options) => { const stream = createAssistantMessageEventStream(); + if (state.lifecycle !== "open") { + stream.push({ + type: "error", + reason: "error", + error: buildMessage({ + model, + content: [], + stopReason: "error", + errorMessage: "llama.cpp runtime is stopping", + }), + }); + stream.end(); + return stream; + } let streamedText = ""; const streamedContent: AssistantMessage["content"] = []; let generationAborted = false; @@ -340,6 +391,7 @@ export function createLlamaCppStreamFn(params: { providerConfig?: ModelProviderC try { const runtime = await importNodeLlamaCpp(); const loaded = await getLoadedModel({ + state, runtime, model, providerConfig: params.providerConfig, @@ -458,10 +510,10 @@ export function createLlamaCppStreamFn(params: { providerConfig?: ModelProviderC const appendFunctionCallParamsChunk = (chunk: LlamaChatResponseFunctionCallParamsChunk) => { closeThinkingBlock(); closeTextBlock(); - let state = streamedToolCalls.get(chunk.callIndex); - if (!state) { + let callState = streamedToolCalls.get(chunk.callIndex); + if (!callState) { ensureStreamStarted(); - state = { + callState = { toolCall: { type: "toolCall", id: `llama_cpp_call_${randomUUID()}`, @@ -471,26 +523,26 @@ export function createLlamaCppStreamFn(params: { providerConfig?: ModelProviderC contentIndex: streamedContent.length, partialArgs: "", }; - streamedToolCalls.set(chunk.callIndex, state); - streamedContent.push(state.toolCall); + streamedToolCalls.set(chunk.callIndex, callState); + streamedContent.push(callState.toolCall); stream.push({ type: "toolcall_start", - contentIndex: state.contentIndex, + contentIndex: callState.contentIndex, partial: partial(), }); } if (chunk.paramsChunk) { - state.partialArgs += chunk.paramsChunk; + callState.partialArgs += chunk.paramsChunk; // Replace the block so already queued partial snapshots retain the // exact argument state they exposed before this streamed delta. - state.toolCall = { - ...state.toolCall, - arguments: parseStreamingJson(state.partialArgs), + callState.toolCall = { + ...callState.toolCall, + arguments: parseStreamingJson(callState.partialArgs), }; - streamedContent[state.contentIndex] = state.toolCall; + streamedContent[callState.contentIndex] = callState.toolCall; stream.push({ type: "toolcall_delta", - contentIndex: state.contentIndex, + contentIndex: callState.contentIndex, delta: chunk.paramsChunk, partial: partial(), }); @@ -542,35 +594,35 @@ export function createLlamaCppStreamFn(params: { providerConfig?: ModelProviderC const confirmedCalls = result.metadata.stopReason === "maxTokens" ? [] : (result.functionCalls ?? []); const toolCalls: ToolCall[] = confirmedCalls.map((call, callIndex) => { - let state = streamedToolCalls.get(callIndex); + let callState = streamedToolCalls.get(callIndex); const argumentsObject = normalizeArguments(call.params); - if (!state) { + if (!callState) { appendFunctionCallParamsChunk({ callIndex, functionName: call.functionName, paramsChunk: JSON.stringify(argumentsObject), done: true, }); - state = streamedToolCalls.get(callIndex); + callState = streamedToolCalls.get(callIndex); } - if (!state) { + if (!callState) { throw new Error("llama.cpp native tool call stream state is missing"); } - state.toolCall = { - ...state.toolCall, + callState.toolCall = { + ...callState.toolCall, name: call.functionName, arguments: argumentsObject, }; - streamedContent[state.contentIndex] = state.toolCall; + streamedContent[callState.contentIndex] = callState.toolCall; // The dependency reports its final argument chunk before checking the // token budget; only this authoritative result can complete a call. stream.push({ type: "toolcall_end", - contentIndex: state.contentIndex, - toolCall: state.toolCall, + contentIndex: callState.contentIndex, + toolCall: callState.toolCall, partial: partial(), }); - return state.toolCall; + return callState.toolCall; }); const confirmedToolCallIds = new Set(toolCalls.map((toolCall) => toolCall.id)); const content = streamedContent.filter( @@ -612,16 +664,27 @@ export function createLlamaCppStreamFn(params: { providerConfig?: ModelProviderC } }; if (!ended) { - queueMicrotask(() => void serialize(run)); + void serialize(state, run); } return stream; }); } -if (process.env.VITEST || process.env.NODE_ENV === "test") { - (globalThis as Record)[Symbol.for("openclaw.llamaCppInferenceTestApi")] = { - mapContextToLlamaChatHistory, - mapToolsToLlamaFunctions, - clearLlamaCppInferenceCacheForTests, +export function createLlamaCppInferenceRuntime(): LlamaCppInferenceRuntime { + const state: LlamaCppInferenceRuntimeState = { + operationQueue: Promise.resolve(), + lifecycle: "open", + }; + return { + createStreamFn: (params) => createLlamaCppStreamFnForRuntime(state, params), + dispose: () => disposeLlamaCppInferenceRuntime(state), }; } + +export const llamaCppInferenceTestApi = + process.env.VITEST || process.env.NODE_ENV === "test" + ? { + mapContextToLlamaChatHistory, + mapToolsToLlamaFunctions, + } + : undefined; From b8eebb07e4ae918337236e1d5c524eea3bb5e9e0 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 4 Aug 2026 21:27:41 +0800 Subject: [PATCH 02/17] test(llama-cpp): keep lifecycle helpers private --- .../llama-cpp/src/inference-provider.test.ts | 17 ++++++++--------- extensions/llama-cpp/src/inference-provider.ts | 15 +++++++-------- 2 files changed, 15 insertions(+), 17 deletions(-) diff --git a/extensions/llama-cpp/src/inference-provider.test.ts b/extensions/llama-cpp/src/inference-provider.test.ts index 214dacb61ede..630e0a416738 100644 --- a/extensions/llama-cpp/src/inference-provider.test.ts +++ b/extensions/llama-cpp/src/inference-provider.test.ts @@ -54,16 +54,15 @@ vi.mock("node-llama-cpp", () => ({ }, })); -import { - createLlamaCppInferenceRuntime, - llamaCppInferenceTestApi, - type LlamaCppInferenceRuntime, -} from "./inference-provider.js"; +import { createLlamaCppInferenceRuntime } from "./inference-provider.js"; -if (!llamaCppInferenceTestApi) { - throw new Error("expected llama.cpp inference test API"); -} -const { mapContextToLlamaChatHistory, mapToolsToLlamaFunctions } = llamaCppInferenceTestApi; +const { mapContextToLlamaChatHistory, mapToolsToLlamaFunctions } = ( + globalThis as Record +)[Symbol.for("openclaw.llamaCppInferenceTestApi")] as { + mapContextToLlamaChatHistory: (context: Context) => unknown[]; + mapToolsToLlamaFunctions: (context: Context) => Record | undefined; +}; +type LlamaCppInferenceRuntime = ReturnType; let inferenceRuntime: LlamaCppInferenceRuntime; const model: Model = { diff --git a/extensions/llama-cpp/src/inference-provider.ts b/extensions/llama-cpp/src/inference-provider.ts index f55dee5bd4d2..80eb7138f3af 100644 --- a/extensions/llama-cpp/src/inference-provider.ts +++ b/extensions/llama-cpp/src/inference-provider.ts @@ -49,7 +49,7 @@ type LlamaCppInferenceRuntimeState = { disposePromise?: Promise; }; -export type LlamaCppInferenceRuntime = { +type LlamaCppInferenceRuntime = { createStreamFn: (params: { providerConfig?: ModelProviderConfig }) => StreamFn; dispose: () => Promise; }; @@ -681,10 +681,9 @@ export function createLlamaCppInferenceRuntime(): LlamaCppInferenceRuntime { }; } -export const llamaCppInferenceTestApi = - process.env.VITEST || process.env.NODE_ENV === "test" - ? { - mapContextToLlamaChatHistory, - mapToolsToLlamaFunctions, - } - : undefined; +if (process.env.VITEST || process.env.NODE_ENV === "test") { + (globalThis as Record)[Symbol.for("openclaw.llamaCppInferenceTestApi")] = { + mapContextToLlamaChatHistory, + mapToolsToLlamaFunctions, + }; +} From b04131670adb4f6d09db1ebb77d08974abd14e61 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 4 Aug 2026 21:51:35 +0800 Subject: [PATCH 03/17] chore(llama-cpp): leave release notes to release flow --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bd4777fec09..389f26d66c81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,7 +64,6 @@ Docs: https://docs.openclaw.ai ### Fixes -- **llama.cpp runtime cleanup:** dispose in-process GGUF contexts, models, and native runtimes when the provider plugin stops or reloads so replaced registrations do not retain model memory. Fixes #119144. - **Guided onboarding skip-UI routing:** keep `openclaw onboard --skip-ui` and `openclaw setup --skip-ui` on guided onboarding while skipping both browser and terminal handoffs, instead of silently switching to the classic wizard. Thanks @shakkernerd. - **Telegram durable ingress:** preserve pre-identity control-lane ownership during replay and attempt each drain snapshot row only once per pass, preventing targeted commands from spinning the spool and blocking polling shutdown. - **Control UI operator session permissions:** honor Gateway-advertised operator scopes for new-thread creation, thread management, checkpoints, and sharing controls while preserving read-only navigation and legacy Gateway compatibility. Fixes #117786. Thanks @shakkernerd. From 48b950d9fc28b466b116da3261b5c6530e675ca8 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 4 Aug 2026 22:15:34 +0800 Subject: [PATCH 04/17] fix(llama-cpp): make runtime cleanup best effort --- .../llama-cpp/src/inference-provider.test.ts | 20 +++++---- .../llama-cpp/src/inference-provider.ts | 41 +++++++++++-------- 2 files changed, 37 insertions(+), 24 deletions(-) diff --git a/extensions/llama-cpp/src/inference-provider.test.ts b/extensions/llama-cpp/src/inference-provider.test.ts index 630e0a416738..35f9e22bb7ca 100644 --- a/extensions/llama-cpp/src/inference-provider.test.ts +++ b/extensions/llama-cpp/src/inference-provider.test.ts @@ -155,7 +155,7 @@ beforeEach(() => { }); afterEach(async () => { - await inferenceRuntime.dispose(); + await inferenceRuntime.dispose().catch(() => undefined); }); describe("llama.cpp inference provider", () => { @@ -942,16 +942,20 @@ describe("llama.cpp inference provider", () => { expect(mocks.llamaDispose).toHaveBeenCalledOnce(); }); - it("retains failed cleanup ownership so disposal can be retried", async () => { + it("keeps a failed best-effort cleanup as one disposal attempt", async () => { await collectTestEvents(); mocks.contextDispose.mockRejectedValueOnce(new Error("context cleanup failed")); - await expect(inferenceRuntime.dispose()).rejects.toThrow("context cleanup failed"); - expect([mocks.modelDispose.mock.calls.length, mocks.llamaDispose.mock.calls.length]).toEqual([ - 0, 0, - ]); - await inferenceRuntime.dispose(); - expect(mocks.contextDispose).toHaveBeenCalledTimes(2); + const firstDisposal = inferenceRuntime.dispose(); + await expect(firstDisposal).rejects.toThrow( + "llama.cpp runtime cleanup failed: Error: context cleanup failed", + ); + expect(mocks.modelDispose).toHaveBeenCalledOnce(); + expect(mocks.llamaDispose).toHaveBeenCalledOnce(); + const repeatedDisposal = inferenceRuntime.dispose(); + expect(repeatedDisposal).toBe(firstDisposal); + await expect(repeatedDisposal).rejects.toThrow("context cleanup failed"); + expect(mocks.contextDispose).toHaveBeenCalledOnce(); expect(mocks.modelDispose).toHaveBeenCalledOnce(); expect(mocks.llamaDispose).toHaveBeenCalledOnce(); }); diff --git a/extensions/llama-cpp/src/inference-provider.ts b/extensions/llama-cpp/src/inference-provider.ts index 80eb7138f3af..01e13899c3b1 100644 --- a/extensions/llama-cpp/src/inference-provider.ts +++ b/extensions/llama-cpp/src/inference-provider.ts @@ -305,33 +305,42 @@ async function serialize( } function disposeLlamaCppInferenceRuntime(state: LlamaCppInferenceRuntimeState): Promise { - if (state.lifecycle === "closed") { - return Promise.resolve(); - } if (state.disposePromise) { return state.disposePromise; } state.lifecycle = "closing"; - const attempt = serialize(state, async () => { - await disposeLoadedModel(state); + // Plugin services stop once, and node-llama-cpp disposers mark themselves + // disposed before awaiting cleanup. The first disposal attempt is authoritative. + state.disposePromise = serialize(state, async () => { + const errors: unknown[] = []; + const attemptDispose = async (dispose: () => Promise) => { + try { + await dispose(); + } catch (error) { + errors.push(error); + } + }; + if (state.loadedModel) { + const previous = state.loadedModel; + await attemptDispose(() => previous.context.dispose()); + await attemptDispose(() => previous.model.dispose()); + if (state.loadedModel === previous) { + state.loadedModel = undefined; + } + } if (state.llamaInstance) { const previous = state.llamaInstance; - await previous.dispose(); + await attemptDispose(() => previous.dispose()); if (state.llamaInstance === previous) { state.llamaInstance = undefined; } } + if (errors.length > 0) { + throw new AggregateError(errors, `llama.cpp runtime cleanup failed: ${String(errors[0])}`); + } + }).finally(() => { + state.lifecycle = "closed"; }); - state.disposePromise = attempt.then( - () => { - state.lifecycle = "closed"; - state.disposePromise = undefined; - }, - (error: unknown) => { - state.disposePromise = undefined; - throw error; - }, - ); return state.disposePromise; } From 889cd316c7a0e45229e69e8b29c750d11d8f6de9 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 4 Aug 2026 22:57:59 +0800 Subject: [PATCH 05/17] fix(llama-cpp): keep failed cleanup terminal --- .../llama-cpp/src/inference-provider.test.ts | 12 +++++----- .../llama-cpp/src/inference-provider.ts | 22 ++----------------- 2 files changed, 7 insertions(+), 27 deletions(-) diff --git a/extensions/llama-cpp/src/inference-provider.test.ts b/extensions/llama-cpp/src/inference-provider.test.ts index 35f9e22bb7ca..d2a948c631e7 100644 --- a/extensions/llama-cpp/src/inference-provider.test.ts +++ b/extensions/llama-cpp/src/inference-provider.test.ts @@ -947,17 +947,15 @@ describe("llama.cpp inference provider", () => { mocks.contextDispose.mockRejectedValueOnce(new Error("context cleanup failed")); const firstDisposal = inferenceRuntime.dispose(); - await expect(firstDisposal).rejects.toThrow( - "llama.cpp runtime cleanup failed: Error: context cleanup failed", - ); - expect(mocks.modelDispose).toHaveBeenCalledOnce(); - expect(mocks.llamaDispose).toHaveBeenCalledOnce(); + await expect(firstDisposal).rejects.toThrow("context cleanup failed"); + expect(mocks.modelDispose).not.toHaveBeenCalled(); + expect(mocks.llamaDispose).not.toHaveBeenCalled(); const repeatedDisposal = inferenceRuntime.dispose(); expect(repeatedDisposal).toBe(firstDisposal); await expect(repeatedDisposal).rejects.toThrow("context cleanup failed"); expect(mocks.contextDispose).toHaveBeenCalledOnce(); - expect(mocks.modelDispose).toHaveBeenCalledOnce(); - expect(mocks.llamaDispose).toHaveBeenCalledOnce(); + expect(mocks.modelDispose).not.toHaveBeenCalled(); + expect(mocks.llamaDispose).not.toHaveBeenCalled(); }); it.each([ diff --git a/extensions/llama-cpp/src/inference-provider.ts b/extensions/llama-cpp/src/inference-provider.ts index 01e13899c3b1..8f1918dd8076 100644 --- a/extensions/llama-cpp/src/inference-provider.ts +++ b/extensions/llama-cpp/src/inference-provider.ts @@ -312,32 +312,14 @@ function disposeLlamaCppInferenceRuntime(state: LlamaCppInferenceRuntimeState): // Plugin services stop once, and node-llama-cpp disposers mark themselves // disposed before awaiting cleanup. The first disposal attempt is authoritative. state.disposePromise = serialize(state, async () => { - const errors: unknown[] = []; - const attemptDispose = async (dispose: () => Promise) => { - try { - await dispose(); - } catch (error) { - errors.push(error); - } - }; - if (state.loadedModel) { - const previous = state.loadedModel; - await attemptDispose(() => previous.context.dispose()); - await attemptDispose(() => previous.model.dispose()); - if (state.loadedModel === previous) { - state.loadedModel = undefined; - } - } + await disposeLoadedModel(state); if (state.llamaInstance) { const previous = state.llamaInstance; - await attemptDispose(() => previous.dispose()); + await previous.dispose(); if (state.llamaInstance === previous) { state.llamaInstance = undefined; } } - if (errors.length > 0) { - throw new AggregateError(errors, `llama.cpp runtime cleanup failed: ${String(errors[0])}`); - } }).finally(() => { state.lifecycle = "closed"; }); From a9d768ced6d3e2fb5201314d6dbf282dd7a46a23 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 4 Aug 2026 23:03:19 +0800 Subject: [PATCH 06/17] fix(llama-cpp): detach retired model state --- .../llama-cpp/src/inference-provider.test.ts | 23 ++++++++----------- .../llama-cpp/src/inference-provider.ts | 4 +--- 2 files changed, 10 insertions(+), 17 deletions(-) diff --git a/extensions/llama-cpp/src/inference-provider.test.ts b/extensions/llama-cpp/src/inference-provider.test.ts index d2a948c631e7..300dad2e1f62 100644 --- a/extensions/llama-cpp/src/inference-provider.test.ts +++ b/extensions/llama-cpp/src/inference-provider.test.ts @@ -848,21 +848,16 @@ describe("llama.cpp inference provider", () => { expect(mocks.generateResponse.mock.calls[0]?.[1]).not.toHaveProperty("grammar"); }); - it("disposes the previous model and context when the model changes", async () => { - const streamFn = inferenceRuntime.createStreamFn({}); - await collectEvents( - await streamFn(model, { messages: [{ role: "user", content: "one", timestamp: 1 }] }), - ); - await collectEvents( - await streamFn( - { ...model, id: "other.gguf", params: { modelPath: "other.gguf" } }, - { messages: [{ role: "user", content: "two", timestamp: 2 }] }, - ), - ); - - expect(mocks.contextDispose).toHaveBeenCalledTimes(1); + it("disposes changed models without reusing retired state", async () => { + const otherModel = { ...model, id: "other.gguf", params: { modelPath: "other.gguf" } }; + await collectTestEvents({ prompt: "one" }); + await collectTestEvents({ selectedModel: otherModel, prompt: "two" }); + mocks.contextDispose.mockRejectedValueOnce(new Error("context cleanup failed")); + await collectTestEvents({ prompt: "three" }); + await collectTestEvents({ selectedModel: otherModel, prompt: "four" }); + expect(mocks.contextDispose).toHaveBeenCalledTimes(2); expect(mocks.modelDispose).toHaveBeenCalledTimes(1); - expect(mocks.llama.loadModel).toHaveBeenCalledTimes(2); + expect(mocks.llama.loadModel).toHaveBeenCalledTimes(3); }); it("reuses one context sequence across serialized requests for the same model", async () => { diff --git a/extensions/llama-cpp/src/inference-provider.ts b/extensions/llama-cpp/src/inference-provider.ts index 8f1918dd8076..d719782ff7f2 100644 --- a/extensions/llama-cpp/src/inference-provider.ts +++ b/extensions/llama-cpp/src/inference-provider.ts @@ -247,11 +247,9 @@ async function disposeLoadedModel(state: LlamaCppInferenceRuntimeState): Promise return; } const previous = state.loadedModel; + state.loadedModel = undefined; await previous.context.dispose(); await previous.model.dispose(); - if (state.loadedModel === previous) { - state.loadedModel = undefined; - } } async function getLoadedModel(params: { From de8afade5726f46dde9d58af01da720b6c4ade16 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 4 Aug 2026 23:12:06 +0800 Subject: [PATCH 07/17] refactor(llama-cpp): clarify terminal cleanup contract --- extensions/llama-cpp/src/inference-provider.test.ts | 2 +- extensions/llama-cpp/src/inference-provider.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/extensions/llama-cpp/src/inference-provider.test.ts b/extensions/llama-cpp/src/inference-provider.test.ts index 300dad2e1f62..fa29691c1e42 100644 --- a/extensions/llama-cpp/src/inference-provider.test.ts +++ b/extensions/llama-cpp/src/inference-provider.test.ts @@ -937,7 +937,7 @@ describe("llama.cpp inference provider", () => { expect(mocks.llamaDispose).toHaveBeenCalledOnce(); }); - it("keeps a failed best-effort cleanup as one disposal attempt", async () => { + it("keeps a failed cleanup terminal", async () => { await collectTestEvents(); mocks.contextDispose.mockRejectedValueOnce(new Error("context cleanup failed")); diff --git a/extensions/llama-cpp/src/inference-provider.ts b/extensions/llama-cpp/src/inference-provider.ts index d719782ff7f2..b8e85ede0688 100644 --- a/extensions/llama-cpp/src/inference-provider.ts +++ b/extensions/llama-cpp/src/inference-provider.ts @@ -307,8 +307,9 @@ function disposeLlamaCppInferenceRuntime(state: LlamaCppInferenceRuntimeState): return state.disposePromise; } state.lifecycle = "closing"; - // Plugin services stop once, and node-llama-cpp disposers mark themselves - // disposed before awaiting cleanup. The first disposal attempt is authoritative. + // node-llama-cpp disposers are one-shot and child cleanup releases the + // parent's disposal guard. Do not force parent cleanup after a child rejects: + // the retained guard can make that parent disposer wait forever. state.disposePromise = serialize(state, async () => { await disposeLoadedModel(state); if (state.llamaInstance) { From 622d8a048eaac2839bf6a49491b7e439391554b3 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 4 Aug 2026 23:37:13 +0800 Subject: [PATCH 08/17] fix(llama-cpp): make cleanup failures terminal --- .../llama-cpp/src/inference-provider.test.ts | 64 +++++++++++-------- .../llama-cpp/src/inference-provider.ts | 35 ++++++++-- 2 files changed, 66 insertions(+), 33 deletions(-) diff --git a/extensions/llama-cpp/src/inference-provider.test.ts b/extensions/llama-cpp/src/inference-provider.test.ts index fa29691c1e42..dabfa648400f 100644 --- a/extensions/llama-cpp/src/inference-provider.test.ts +++ b/extensions/llama-cpp/src/inference-provider.test.ts @@ -144,6 +144,12 @@ function deferGeneration() { return () => finishGeneration?.(); } +function expectDisposeCalls(contextCount: number, modelCount: number, llamaCount: number) { + expect(mocks.contextDispose).toHaveBeenCalledTimes(contextCount); + expect(mocks.modelDispose).toHaveBeenCalledTimes(modelCount); + expect(mocks.llamaDispose).toHaveBeenCalledTimes(llamaCount); +} + beforeEach(() => { inferenceRuntime = createLlamaCppInferenceRuntime(); vi.clearAllMocks(); @@ -848,26 +854,37 @@ describe("llama.cpp inference provider", () => { expect(mocks.generateResponse.mock.calls[0]?.[1]).not.toHaveProperty("grammar"); }); - it("disposes changed models without reusing retired state", async () => { + it("makes failed changed-model cleanup terminal", async () => { const otherModel = { ...model, id: "other.gguf", params: { modelPath: "other.gguf" } }; await collectTestEvents({ prompt: "one" }); await collectTestEvents({ selectedModel: otherModel, prompt: "two" }); - mocks.contextDispose.mockRejectedValueOnce(new Error("context cleanup failed")); - await collectTestEvents({ prompt: "three" }); - await collectTestEvents({ selectedModel: otherModel, prompt: "four" }); - expect(mocks.contextDispose).toHaveBeenCalledTimes(2); - expect(mocks.modelDispose).toHaveBeenCalledTimes(1); - expect(mocks.llama.loadModel).toHaveBeenCalledTimes(3); + const cleanup = Promise.withResolvers(); + mocks.contextDispose.mockImplementationOnce(async () => await cleanup.promise); + const failedSwitch = await createTestStream({ prompt: "three" }); + await vi.waitFor(() => expect(mocks.contextDispose).toHaveBeenCalledTimes(2)); + const disposing = inferenceRuntime.dispose(); + cleanup.reject(new Error("context cleanup failed")); + await failedSwitch.result(); + const unavailable = await createTestStream({ selectedModel: otherModel, prompt: "four" }); + await expect(unavailable.result()).resolves.toMatchObject({ + errorMessage: "llama.cpp runtime stopped after cleanup failed", + }); + await expect(disposing).rejects.toThrow("context cleanup failed"); + expectDisposeCalls(2, 1, 0); + expect(mocks.llama.loadModel).toHaveBeenCalledTimes(2); + }); + + it("records cleanup failure during partial model initialization", async () => { + mocks.model.createContext.mockRejectedValueOnce(new Error("context creation failed")); + mocks.modelDispose.mockRejectedValueOnce(new Error("model cleanup failed")); + await collectTestEvents(); + await expect(inferenceRuntime.dispose()).rejects.toThrow("model cleanup failed"); + expectDisposeCalls(0, 1, 0); }); it("reuses one context sequence across serialized requests for the same model", async () => { - const streamFn = inferenceRuntime.createStreamFn({}); - await collectEvents( - await streamFn(model, { messages: [{ role: "user", content: "one", timestamp: 1 }] }), - ); - await collectEvents( - await streamFn(model, { messages: [{ role: "user", content: "two", timestamp: 2 }] }), - ); + await collectTestEvents({ prompt: "one" }); + await collectTestEvents({ prompt: "two" }); expect(mocks.context.getSequence).toHaveBeenCalledTimes(1); expect(mocks.llama.loadModel).toHaveBeenCalledTimes(1); @@ -878,9 +895,7 @@ describe("llama.cpp inference provider", () => { await inferenceRuntime.dispose(); - expect(mocks.contextDispose).toHaveBeenCalledOnce(); - expect(mocks.modelDispose).toHaveBeenCalledOnce(); - expect(mocks.llamaDispose).toHaveBeenCalledOnce(); + expectDisposeCalls(1, 1, 1); expect(mocks.contextDispose.mock.invocationCallOrder[0]).toBeLessThan( mocks.modelDispose.mock.invocationCallOrder[0] ?? 0, ); @@ -902,9 +917,7 @@ describe("llama.cpp inference provider", () => { await stream.result(); await disposing; - expect(mocks.contextDispose).toHaveBeenCalledOnce(); - expect(mocks.modelDispose).toHaveBeenCalledOnce(); - expect(mocks.llamaDispose).toHaveBeenCalledOnce(); + expectDisposeCalls(1, 1, 1); }); it("rejects new inference once runtime disposal begins", async () => { @@ -932,9 +945,7 @@ describe("llama.cpp inference provider", () => { const disposals = [inferenceRuntime.dispose(), inferenceRuntime.dispose()]; expect(disposals[1]).toBe(disposals[0]); await Promise.all(disposals); - expect(mocks.contextDispose).toHaveBeenCalledOnce(); - expect(mocks.modelDispose).toHaveBeenCalledOnce(); - expect(mocks.llamaDispose).toHaveBeenCalledOnce(); + expectDisposeCalls(1, 1, 1); }); it("keeps a failed cleanup terminal", async () => { @@ -943,14 +954,11 @@ describe("llama.cpp inference provider", () => { const firstDisposal = inferenceRuntime.dispose(); await expect(firstDisposal).rejects.toThrow("context cleanup failed"); - expect(mocks.modelDispose).not.toHaveBeenCalled(); - expect(mocks.llamaDispose).not.toHaveBeenCalled(); + expectDisposeCalls(1, 0, 0); const repeatedDisposal = inferenceRuntime.dispose(); expect(repeatedDisposal).toBe(firstDisposal); await expect(repeatedDisposal).rejects.toThrow("context cleanup failed"); - expect(mocks.contextDispose).toHaveBeenCalledOnce(); - expect(mocks.modelDispose).not.toHaveBeenCalled(); - expect(mocks.llamaDispose).not.toHaveBeenCalled(); + expectDisposeCalls(1, 0, 0); }); it.each([ diff --git a/extensions/llama-cpp/src/inference-provider.ts b/extensions/llama-cpp/src/inference-provider.ts index b8e85ede0688..413d2e24127a 100644 --- a/extensions/llama-cpp/src/inference-provider.ts +++ b/extensions/llama-cpp/src/inference-provider.ts @@ -46,6 +46,7 @@ type LlamaCppInferenceRuntimeState = { llamaInstance?: Llama; operationQueue: Promise; lifecycle: "open" | "closing" | "closed"; + cleanupFailure?: { error: unknown }; disposePromise?: Promise; }; @@ -248,8 +249,18 @@ async function disposeLoadedModel(state: LlamaCppInferenceRuntimeState): Promise } const previous = state.loadedModel; state.loadedModel = undefined; - await previous.context.dispose(); - await previous.model.dispose(); + try { + await previous.context.dispose(); + await previous.model.dispose(); + } catch (error) { + recordCleanupFailure(state, error); + throw error; + } +} + +function recordCleanupFailure(state: LlamaCppInferenceRuntimeState, error: unknown): void { + state.cleanupFailure ??= { error }; + state.lifecycle = "closed"; } async function getLoadedModel(params: { @@ -287,8 +298,13 @@ async function getLoadedModel(params: { params.state.loadedModel = { key, llama, model, context, sequence }; return params.state.loadedModel; } catch (error) { - await context?.dispose(); - await model.dispose(); + try { + await context?.dispose(); + await model.dispose(); + } catch (cleanupError) { + recordCleanupFailure(params.state, cleanupError); + throw cleanupError; + } throw error; } } @@ -306,11 +322,18 @@ function disposeLlamaCppInferenceRuntime(state: LlamaCppInferenceRuntimeState): if (state.disposePromise) { return state.disposePromise; } + if (state.cleanupFailure) { + state.disposePromise = Promise.reject(state.cleanupFailure.error); + return state.disposePromise; + } state.lifecycle = "closing"; // node-llama-cpp disposers are one-shot and child cleanup releases the // parent's disposal guard. Do not force parent cleanup after a child rejects: // the retained guard can make that parent disposer wait forever. state.disposePromise = serialize(state, async () => { + if (state.cleanupFailure) { + throw state.cleanupFailure.error; + } await disposeLoadedModel(state); if (state.llamaInstance) { const previous = state.llamaInstance; @@ -339,7 +362,9 @@ function createLlamaCppStreamFnForRuntime( model, content: [], stopReason: "error", - errorMessage: "llama.cpp runtime is stopping", + errorMessage: state.cleanupFailure + ? "llama.cpp runtime stopped after cleanup failed" + : "llama.cpp runtime is stopping", }), }); stream.end(); From 31d8e046bf0ed215b8a89ba079fa4ab06a3c9729 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 5 Aug 2026 01:40:08 +0800 Subject: [PATCH 09/17] fix(llama-cpp): reject queued work after cleanup failure --- .../llama-cpp/src/inference-provider.test.ts | 14 ++++---- .../llama-cpp/src/inference-provider.ts | 35 +++++++++++++------ 2 files changed, 33 insertions(+), 16 deletions(-) diff --git a/extensions/llama-cpp/src/inference-provider.test.ts b/extensions/llama-cpp/src/inference-provider.test.ts index dabfa648400f..26bc0959e268 100644 --- a/extensions/llama-cpp/src/inference-provider.test.ts +++ b/extensions/llama-cpp/src/inference-provider.test.ts @@ -858,14 +858,16 @@ describe("llama.cpp inference provider", () => { const otherModel = { ...model, id: "other.gguf", params: { modelPath: "other.gguf" } }; await collectTestEvents({ prompt: "one" }); await collectTestEvents({ selectedModel: otherModel, prompt: "two" }); - const cleanup = Promise.withResolvers(); - mocks.contextDispose.mockImplementationOnce(async () => await cleanup.promise); - const failedSwitch = await createTestStream({ prompt: "three" }); + let rejectCleanup!: (error: Error) => void; + const cleanup = new Promise((_resolve, reject) => { + rejectCleanup = reject; + }); + mocks.contextDispose.mockImplementationOnce(async () => await cleanup); + await createTestStream({ prompt: "three" }); await vi.waitFor(() => expect(mocks.contextDispose).toHaveBeenCalledTimes(2)); - const disposing = inferenceRuntime.dispose(); - cleanup.reject(new Error("context cleanup failed")); - await failedSwitch.result(); const unavailable = await createTestStream({ selectedModel: otherModel, prompt: "four" }); + const disposing = inferenceRuntime.dispose(); + rejectCleanup(new Error("context cleanup failed")); await expect(unavailable.result()).resolves.toMatchObject({ errorMessage: "llama.cpp runtime stopped after cleanup failed", }); diff --git a/extensions/llama-cpp/src/inference-provider.ts b/extensions/llama-cpp/src/inference-provider.ts index 413d2e24127a..1481862a8776 100644 --- a/extensions/llama-cpp/src/inference-provider.ts +++ b/extensions/llama-cpp/src/inference-provider.ts @@ -46,7 +46,7 @@ type LlamaCppInferenceRuntimeState = { llamaInstance?: Llama; operationQueue: Promise; lifecycle: "open" | "closing" | "closed"; - cleanupFailure?: { error: unknown }; + cleanupFailure?: { error: Error }; disposePromise?: Promise; }; @@ -86,6 +86,20 @@ function buildMessage(params: { }; } +function runtimeUnavailableMessage( + state: LlamaCppInferenceRuntimeState, + model: Parameters[0], +): AssistantMessage { + return buildMessage({ + model, + content: [], + stopReason: "error", + errorMessage: state.cleanupFailure + ? "llama.cpp runtime stopped after cleanup failed" + : "llama.cpp runtime is stopping", + }); +} + function extractText(content: unknown): string { if (typeof content === "string") { return content; @@ -259,7 +273,7 @@ async function disposeLoadedModel(state: LlamaCppInferenceRuntimeState): Promise } function recordCleanupFailure(state: LlamaCppInferenceRuntimeState, error: unknown): void { - state.cleanupFailure ??= { error }; + state.cleanupFailure ??= { error: error instanceof Error ? error : new Error(String(error)) }; state.lifecycle = "closed"; } @@ -358,14 +372,7 @@ function createLlamaCppStreamFnForRuntime( stream.push({ type: "error", reason: "error", - error: buildMessage({ - model, - content: [], - stopReason: "error", - errorMessage: state.cleanupFailure - ? "llama.cpp runtime stopped after cleanup failed" - : "llama.cpp runtime is stopping", - }), + error: runtimeUnavailableMessage(state, model), }); stream.end(); return stream; @@ -404,6 +411,14 @@ function createLlamaCppStreamFnForRuntime( started = true; signal?.removeEventListener("abort", abortWhileQueued); try { + if (state.lifecycle !== "open") { + stream.push({ + type: "error", + reason: "error", + error: runtimeUnavailableMessage(state, model), + }); + return; + } const runtime = await importNodeLlamaCpp(); const loaded = await getLoadedModel({ state, From 6dc5d003743ab3ce1b1d517abcb0e5f88f89da6f Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 5 Aug 2026 02:10:27 +0800 Subject: [PATCH 10/17] fix(llama-cpp): explain cleanup failure recovery --- extensions/llama-cpp/src/inference-provider.test.ts | 2 +- extensions/llama-cpp/src/inference-provider.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/extensions/llama-cpp/src/inference-provider.test.ts b/extensions/llama-cpp/src/inference-provider.test.ts index 26bc0959e268..c723a29fb55d 100644 --- a/extensions/llama-cpp/src/inference-provider.test.ts +++ b/extensions/llama-cpp/src/inference-provider.test.ts @@ -869,7 +869,7 @@ describe("llama.cpp inference provider", () => { const disposing = inferenceRuntime.dispose(); rejectCleanup(new Error("context cleanup failed")); await expect(unavailable.result()).resolves.toMatchObject({ - errorMessage: "llama.cpp runtime stopped after cleanup failed", + errorMessage: expect.stringContaining("openclaw gateway restart"), }); await expect(disposing).rejects.toThrow("context cleanup failed"); expectDisposeCalls(2, 1, 0); diff --git a/extensions/llama-cpp/src/inference-provider.ts b/extensions/llama-cpp/src/inference-provider.ts index 1481862a8776..82b13e4b59ee 100644 --- a/extensions/llama-cpp/src/inference-provider.ts +++ b/extensions/llama-cpp/src/inference-provider.ts @@ -95,7 +95,7 @@ function runtimeUnavailableMessage( content: [], stopReason: "error", errorMessage: state.cleanupFailure - ? "llama.cpp runtime stopped after cleanup failed" + ? "llama.cpp runtime stopped after cleanup failed. Run `openclaw gateway restart` to recover." : "llama.cpp runtime is stopping", }); } From 865b517235d27d042b982dc2bf6e5eaaa5d8a8ff Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 5 Aug 2026 02:25:52 +0800 Subject: [PATCH 11/17] fix(llama-cpp): record native shutdown failures --- .../llama-cpp/src/inference-provider.test.ts | 18 +++++++++--------- extensions/llama-cpp/src/inference-provider.ts | 11 ++++++++--- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/extensions/llama-cpp/src/inference-provider.test.ts b/extensions/llama-cpp/src/inference-provider.test.ts index c723a29fb55d..e14857caa969 100644 --- a/extensions/llama-cpp/src/inference-provider.test.ts +++ b/extensions/llama-cpp/src/inference-provider.test.ts @@ -950,17 +950,17 @@ describe("llama.cpp inference provider", () => { expectDisposeCalls(1, 1, 1); }); - it("keeps a failed cleanup terminal", async () => { + it("keeps a failed native runtime cleanup terminal", async () => { await collectTestEvents(); - mocks.contextDispose.mockRejectedValueOnce(new Error("context cleanup failed")); - + mocks.llamaDispose.mockRejectedValueOnce(new Error("llama cleanup failed")); const firstDisposal = inferenceRuntime.dispose(); - await expect(firstDisposal).rejects.toThrow("context cleanup failed"); - expectDisposeCalls(1, 0, 0); - const repeatedDisposal = inferenceRuntime.dispose(); - expect(repeatedDisposal).toBe(firstDisposal); - await expect(repeatedDisposal).rejects.toThrow("context cleanup failed"); - expectDisposeCalls(1, 0, 0); + await expect(firstDisposal).rejects.toThrow("llama cleanup failed"); + expectDisposeCalls(1, 1, 1); + const unavailable = await createTestStream({ prompt: "after failed stop" }); + await expect(unavailable.result()).resolves.toMatchObject({ + errorMessage: expect.stringContaining("openclaw gateway restart"), + }); + expect(inferenceRuntime.dispose()).toBe(firstDisposal); }); it.each([ diff --git a/extensions/llama-cpp/src/inference-provider.ts b/extensions/llama-cpp/src/inference-provider.ts index 82b13e4b59ee..d3eb8f5e86ed 100644 --- a/extensions/llama-cpp/src/inference-provider.ts +++ b/extensions/llama-cpp/src/inference-provider.ts @@ -356,9 +356,14 @@ function disposeLlamaCppInferenceRuntime(state: LlamaCppInferenceRuntimeState): state.llamaInstance = undefined; } } - }).finally(() => { - state.lifecycle = "closed"; - }); + }) + .catch((error) => { + recordCleanupFailure(state, error); + throw error; + }) + .finally(() => { + state.lifecycle = "closed"; + }); return state.disposePromise; } From 220445b1b7596b1da48381764fb6185bece63a67 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 5 Aug 2026 02:43:34 +0800 Subject: [PATCH 12/17] fix(llama-cpp): type shutdown cleanup failures Punchcard-Session: frost-brook-timber-mx --- extensions/llama-cpp/src/inference-provider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/llama-cpp/src/inference-provider.ts b/extensions/llama-cpp/src/inference-provider.ts index d3eb8f5e86ed..3e295f6fb577 100644 --- a/extensions/llama-cpp/src/inference-provider.ts +++ b/extensions/llama-cpp/src/inference-provider.ts @@ -357,7 +357,7 @@ function disposeLlamaCppInferenceRuntime(state: LlamaCppInferenceRuntimeState): } } }) - .catch((error) => { + .catch((error: unknown) => { recordCleanupFailure(state, error); throw error; }) From 8b443bb34fcfe713dd8b9b353a2b2ac2213f3eee Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 5 Aug 2026 02:44:19 +0800 Subject: [PATCH 13/17] test(llama-cpp): split inference mapping coverage Punchcard-Session: frost-brook-timber-mx --- .../src/inference-provider.mapping.test.ts | 101 ++++++++++++++++++ .../llama-cpp/src/inference-provider.test.ts | 95 ---------------- 2 files changed, 101 insertions(+), 95 deletions(-) create mode 100644 extensions/llama-cpp/src/inference-provider.mapping.test.ts diff --git a/extensions/llama-cpp/src/inference-provider.mapping.test.ts b/extensions/llama-cpp/src/inference-provider.mapping.test.ts new file mode 100644 index 000000000000..c70dd6b3b401 --- /dev/null +++ b/extensions/llama-cpp/src/inference-provider.mapping.test.ts @@ -0,0 +1,101 @@ +import type { Context } from "openclaw/plugin-sdk/llm"; +import { describe, expect, it } from "vitest"; +import "./inference-provider.js"; + +const { mapContextToLlamaChatHistory, mapToolsToLlamaFunctions } = ( + globalThis as Record +)[Symbol.for("openclaw.llamaCppInferenceTestApi")] as { + mapContextToLlamaChatHistory: (context: Context) => unknown[]; + mapToolsToLlamaFunctions: (context: Context) => Record | undefined; +}; + +describe("llama.cpp inference mappings", () => { + it("maps OpenClaw history and tool results into the model chat template history", () => { + const context = { + systemPrompt: "Be concise.", + messages: [ + { role: "user" as const, content: "weather?", timestamp: 1 }, + { + role: "assistant" as const, + api: "openai-completions", + provider: "test", + model: "test", + stopReason: "toolUse" as const, + usage: { + input: 1, + output: 1, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 2, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + timestamp: 2, + content: [ + { type: "text" as const, text: "Checking." }, + { + type: "toolCall" as const, + id: "call-1", + name: "weather", + arguments: { city: "Berlin" }, + }, + ], + }, + { + role: "toolResult" as const, + toolCallId: "call-1", + toolName: "weather", + content: [{ type: "text" as const, text: "Sunny" }], + isError: false, + timestamp: 3, + }, + { role: "user" as const, content: "thanks", timestamp: 4 }, + ], + }; + + expect(mapContextToLlamaChatHistory(context)).toEqual([ + { type: "system", text: "Be concise." }, + { type: "user", text: "weather?" }, + { + type: "model", + response: [ + "Checking.", + { + type: "functionCall", + name: "weather", + params: { city: "Berlin" }, + result: "Sunny", + }, + ], + }, + { type: "user", text: "thanks" }, + ]); + }); + + it("maps JSON-schema tools to native node-llama-cpp function definitions", () => { + expect( + mapToolsToLlamaFunctions({ + messages: [], + tools: [ + { + name: "weather", + description: "Get weather", + parameters: { + type: "object", + properties: { city: { type: "string" } }, + required: ["city"], + }, + }, + ], + }), + ).toEqual({ + weather: { + description: "Get weather", + params: { + type: "object", + properties: { city: { type: "string" } }, + required: ["city"], + }, + }, + }); + }); +}); diff --git a/extensions/llama-cpp/src/inference-provider.test.ts b/extensions/llama-cpp/src/inference-provider.test.ts index e14857caa969..63bc58a09c21 100644 --- a/extensions/llama-cpp/src/inference-provider.test.ts +++ b/extensions/llama-cpp/src/inference-provider.test.ts @@ -56,12 +56,6 @@ vi.mock("node-llama-cpp", () => ({ import { createLlamaCppInferenceRuntime } from "./inference-provider.js"; -const { mapContextToLlamaChatHistory, mapToolsToLlamaFunctions } = ( - globalThis as Record -)[Symbol.for("openclaw.llamaCppInferenceTestApi")] as { - mapContextToLlamaChatHistory: (context: Context) => unknown[]; - mapToolsToLlamaFunctions: (context: Context) => Record | undefined; -}; type LlamaCppInferenceRuntime = ReturnType; let inferenceRuntime: LlamaCppInferenceRuntime; @@ -165,95 +159,6 @@ afterEach(async () => { }); describe("llama.cpp inference provider", () => { - it("maps OpenClaw history and tool results into the model chat template history", () => { - const context = { - systemPrompt: "Be concise.", - messages: [ - { role: "user" as const, content: "weather?", timestamp: 1 }, - { - role: "assistant" as const, - api: "openai-completions", - provider: "test", - model: "test", - stopReason: "toolUse" as const, - usage: { - input: 1, - output: 1, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 2, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - timestamp: 2, - content: [ - { type: "text" as const, text: "Checking." }, - { - type: "toolCall" as const, - id: "call-1", - name: "weather", - arguments: { city: "Berlin" }, - }, - ], - }, - { - role: "toolResult" as const, - toolCallId: "call-1", - toolName: "weather", - content: [{ type: "text" as const, text: "Sunny" }], - isError: false, - timestamp: 3, - }, - { role: "user" as const, content: "thanks", timestamp: 4 }, - ], - }; - - expect(mapContextToLlamaChatHistory(context)).toEqual([ - { type: "system", text: "Be concise." }, - { type: "user", text: "weather?" }, - { - type: "model", - response: [ - "Checking.", - { - type: "functionCall", - name: "weather", - params: { city: "Berlin" }, - result: "Sunny", - }, - ], - }, - { type: "user", text: "thanks" }, - ]); - }); - - it("maps JSON-schema tools to native node-llama-cpp function definitions", () => { - expect( - mapToolsToLlamaFunctions({ - messages: [], - tools: [ - { - name: "weather", - description: "Get weather", - parameters: { - type: "object", - properties: { city: { type: "string" } }, - required: ["city"], - }, - }, - ], - }), - ).toEqual({ - weather: { - description: "Get weather", - params: { - type: "object", - properties: { city: { type: "string" } }, - required: ["city"], - }, - }, - }); - }); - it("streams text deltas and reports native token-meter usage", async () => { mocks.generateResponse.mockImplementationOnce(async (_history, options) => { options.onTextChunk("Hel"); From 2b0e1e579a374e63bfc21755ea560add8ab2a8a1 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 5 Aug 2026 14:46:39 +0800 Subject: [PATCH 14/17] fix(llama-cpp): report cleanup recovery to initiating request Punchcard-Session: frost-brook-timber-mx --- .../llama-cpp/src/inference-provider.test.ts | 10 ++++++++-- extensions/llama-cpp/src/inference-provider.ts | 16 ++++++++++++---- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/extensions/llama-cpp/src/inference-provider.test.ts b/extensions/llama-cpp/src/inference-provider.test.ts index 63bc58a09c21..e35030c26f6b 100644 --- a/extensions/llama-cpp/src/inference-provider.test.ts +++ b/extensions/llama-cpp/src/inference-provider.test.ts @@ -768,11 +768,14 @@ describe("llama.cpp inference provider", () => { rejectCleanup = reject; }); mocks.contextDispose.mockImplementationOnce(async () => await cleanup); - await createTestStream({ prompt: "three" }); + const failedSwitch = await createTestStream({ prompt: "three" }); await vi.waitFor(() => expect(mocks.contextDispose).toHaveBeenCalledTimes(2)); const unavailable = await createTestStream({ selectedModel: otherModel, prompt: "four" }); const disposing = inferenceRuntime.dispose(); rejectCleanup(new Error("context cleanup failed")); + await expect(failedSwitch.result()).resolves.toMatchObject({ + errorMessage: expect.stringContaining("openclaw gateway restart"), + }); await expect(unavailable.result()).resolves.toMatchObject({ errorMessage: expect.stringContaining("openclaw gateway restart"), }); @@ -784,7 +787,10 @@ describe("llama.cpp inference provider", () => { it("records cleanup failure during partial model initialization", async () => { mocks.model.createContext.mockRejectedValueOnce(new Error("context creation failed")); mocks.modelDispose.mockRejectedValueOnce(new Error("model cleanup failed")); - await collectTestEvents(); + const failedInitialization = await createTestStream(); + await expect(failedInitialization.result()).resolves.toMatchObject({ + errorMessage: expect.stringContaining("openclaw gateway restart"), + }); await expect(inferenceRuntime.dispose()).rejects.toThrow("model cleanup failed"); expectDisposeCalls(0, 1, 0); }); diff --git a/extensions/llama-cpp/src/inference-provider.ts b/extensions/llama-cpp/src/inference-provider.ts index 3e295f6fb577..f077aca88deb 100644 --- a/extensions/llama-cpp/src/inference-provider.ts +++ b/extensions/llama-cpp/src/inference-provider.ts @@ -86,6 +86,12 @@ function buildMessage(params: { }; } +function runtimeUnavailableErrorMessage(state: LlamaCppInferenceRuntimeState): string { + return state.cleanupFailure + ? "llama.cpp runtime stopped after cleanup failed. Run `openclaw gateway restart` to recover." + : "llama.cpp runtime is stopping"; +} + function runtimeUnavailableMessage( state: LlamaCppInferenceRuntimeState, model: Parameters[0], @@ -94,9 +100,7 @@ function runtimeUnavailableMessage( model, content: [], stopReason: "error", - errorMessage: state.cleanupFailure - ? "llama.cpp runtime stopped after cleanup failed. Run `openclaw gateway restart` to recover." - : "llama.cpp runtime is stopping", + errorMessage: runtimeUnavailableErrorMessage(state), }); } @@ -682,7 +686,11 @@ function createLlamaCppStreamFnForRuntime( } catch (error) { const aborted = generationAborted || options?.signal?.aborted === true; const reason = aborted ? "aborted" : "error"; - const errorMessage = aborted ? "Request was aborted" : formatLlamaCppSetupError(error); + const errorMessage = aborted + ? "Request was aborted" + : state.cleanupFailure + ? runtimeUnavailableErrorMessage(state) + : formatLlamaCppSetupError(error); stream.push({ type: "error", reason, From a87ee810da816bf33cbbfd2be4448ea769acea01 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 5 Aug 2026 15:33:49 +0800 Subject: [PATCH 15/17] fix(llama-cpp): fence native runtime handoff Punchcard-Session: frost-brook-timber-mx --- .../llama-cpp/src/inference-messages.ts | 51 +++++ .../llama-cpp/src/inference-provider.test.ts | 86 +++++++++ .../llama-cpp/src/inference-provider.ts | 100 ++++------ .../src/inference-runtime-coordinator.test.ts | 113 ++++++++++++ .../src/inference-runtime-coordinator.ts | 174 ++++++++++++++++++ 5 files changed, 464 insertions(+), 60 deletions(-) create mode 100644 extensions/llama-cpp/src/inference-messages.ts create mode 100644 extensions/llama-cpp/src/inference-runtime-coordinator.test.ts create mode 100644 extensions/llama-cpp/src/inference-runtime-coordinator.ts diff --git a/extensions/llama-cpp/src/inference-messages.ts b/extensions/llama-cpp/src/inference-messages.ts new file mode 100644 index 000000000000..7ab1d76b5a5d --- /dev/null +++ b/extensions/llama-cpp/src/inference-messages.ts @@ -0,0 +1,51 @@ +import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; +import type { AssistantMessage, StopReason, Usage } from "openclaw/plugin-sdk/llm"; + +export function zeroCostUsage(input = 0, output = 0): Usage { + return { + input, + output, + cacheRead: 0, + cacheWrite: 0, + totalTokens: input + output, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }; +} + +export function buildMessage(params: { + model: Parameters[0]; + content: AssistantMessage["content"]; + stopReason: StopReason; + usage?: Usage; + errorMessage?: string; +}): AssistantMessage { + return { + role: "assistant", + content: params.content, + api: params.model.api, + provider: params.model.provider, + model: params.model.id, + stopReason: params.stopReason, + usage: params.usage ?? zeroCostUsage(), + timestamp: Date.now(), + ...(params.errorMessage ? { errorMessage: params.errorMessage } : {}), + }; +} + +export function runtimeUnavailableErrorMessage(restartRequired: boolean): string { + return restartRequired + ? "llama.cpp runtime stopped after cleanup failed. Run `openclaw gateway restart` to recover." + : "llama.cpp runtime is stopping"; +} + +export function runtimeUnavailableMessage( + model: Parameters[0], + restartRequired: boolean, +): AssistantMessage { + return buildMessage({ + model, + content: [], + stopReason: "error", + errorMessage: runtimeUnavailableErrorMessage(restartRequired), + }); +} diff --git a/extensions/llama-cpp/src/inference-provider.test.ts b/extensions/llama-cpp/src/inference-provider.test.ts index e35030c26f6b..73157965b220 100644 --- a/extensions/llama-cpp/src/inference-provider.test.ts +++ b/extensions/llama-cpp/src/inference-provider.test.ts @@ -58,6 +58,11 @@ import { createLlamaCppInferenceRuntime } from "./inference-provider.js"; type LlamaCppInferenceRuntime = ReturnType; let inferenceRuntime: LlamaCppInferenceRuntime; +const testApi = (globalThis as Record)[ + Symbol.for("openclaw.llamaCppInferenceTestApi") +] as { + resetInferenceRuntimeCoordinator: () => void; +}; const model: Model = { id: "test.gguf", @@ -145,6 +150,7 @@ function expectDisposeCalls(contextCount: number, modelCount: number, llamaCount } beforeEach(() => { + testApi.resetInferenceRuntimeCoordinator(); inferenceRuntime = createLlamaCppInferenceRuntime(); vi.clearAllMocks(); mocks.generateResponse.mockResolvedValue({ @@ -817,6 +823,86 @@ describe("llama.cpp inference provider", () => { ); }); + it("blocks a published replacement until predecessor service stop finishes", async () => { + const retiringRuntime = inferenceRuntime; + await collectTestEvents(); + let finishCleanup!: () => void; + mocks.contextDispose.mockImplementationOnce( + async () => + await new Promise((resolve) => { + finishCleanup = resolve; + }), + ); + // Gateway publishes the replacement registry before stopping old services. + const disposing = retiringRuntime.dispose(); + await vi.waitFor(() => expect(mocks.contextDispose).toHaveBeenCalledOnce()); + + const replacementRuntime = createLlamaCppInferenceRuntime(); + const replacementEvents = collectEvents( + await replacementRuntime.createStreamFn({})(model, { + messages: [{ role: "user", content: "after reload", timestamp: 2 }], + }), + ); + await Promise.resolve(); + expect(mocks.llama.loadModel).toHaveBeenCalledOnce(); + + finishCleanup(); + await disposing; + await expect(replacementEvents).resolves.toEqual( + expect.arrayContaining([expect.objectContaining({ type: "done", reason: "stop" })]), + ); + expect(mocks.llama.loadModel).toHaveBeenCalledTimes(2); + inferenceRuntime = replacementRuntime; + }); + + it("keeps a published replacement blocked when predecessor service stop fails", async () => { + const retiringRuntime = inferenceRuntime; + await collectTestEvents(); + mocks.contextDispose.mockRejectedValueOnce(new Error("retiring cleanup failed")); + + const replacementRuntime = createLlamaCppInferenceRuntime(); + // Match reload ordering: replacement is reachable before old service stop settles. + const disposing = retiringRuntime.dispose(); + const replacementEvents = collectEvents( + await replacementRuntime.createStreamFn({})(model, { + messages: [{ role: "user", content: "after failed reload", timestamp: 2 }], + }), + ); + + await expect(disposing).rejects.toThrow("retiring cleanup failed"); + await expect(replacementEvents).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "error", + error: expect.objectContaining({ + errorMessage: expect.stringContaining("openclaw gateway restart"), + }), + }), + ]), + ); + expect(mocks.llama.loadModel).toHaveBeenCalledOnce(); + await expect(replacementRuntime.dispose()).resolves.toBeUndefined(); + inferenceRuntime = replacementRuntime; + }); + + it("lets a superseded waiting replacement stop before its predecessor retires", async () => { + await collectTestEvents(); + const waitingRuntime = createLlamaCppInferenceRuntime(); + const waitingStream = await waitingRuntime.createStreamFn({})(model, { + messages: [{ role: "user", content: "superseded reload", timestamp: 2 }], + }); + await Promise.resolve(); + + const disposingWaitingRuntime = waitingRuntime.dispose(); + + await expect(waitingStream.result()).resolves.toMatchObject({ + stopReason: "error", + errorMessage: "llama.cpp runtime is stopping", + }); + await expect(disposingWaitingRuntime).resolves.toBeUndefined(); + expect(mocks.llama.loadModel).toHaveBeenCalledOnce(); + }); + it("waits for admitted inference before disposing the runtime", async () => { const finishGeneration = deferGeneration(); const stream = await createTestStream(); diff --git a/extensions/llama-cpp/src/inference-provider.ts b/extensions/llama-cpp/src/inference-provider.ts index f077aca88deb..22096b9e5559 100644 --- a/extensions/llama-cpp/src/inference-provider.ts +++ b/extensions/llama-cpp/src/inference-provider.ts @@ -10,13 +10,7 @@ import type { LlamaModel, } from "node-llama-cpp"; import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; -import type { - AssistantMessage, - Context, - StopReason, - ToolCall, - Usage, -} from "openclaw/plugin-sdk/llm"; +import type { AssistantMessage, Context, StopReason, ToolCall } from "openclaw/plugin-sdk/llm"; import { createAssistantMessageEventStream, parseStreamingJson } from "openclaw/plugin-sdk/llm"; import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared"; import { createPlainTextToolCallCompatWrapper } from "openclaw/plugin-sdk/provider-stream-shared"; @@ -25,6 +19,16 @@ import { resolveLlamaCppModelCacheDir, resolveLlamaCppModelSource, } from "./defaults.js"; +import { + buildMessage, + runtimeUnavailableErrorMessage, + runtimeUnavailableMessage, + zeroCostUsage, +} from "./inference-messages.js"; +import { + createLlamaCppInferenceRuntimeToken, + type LlamaCppInferenceRuntimeToken, +} from "./inference-runtime-coordinator.js"; import { formatLlamaCppSetupError, importNodeLlamaCpp, @@ -42,11 +46,13 @@ type LoadedModel = { type LlamaJsonSchemaInput = Parameters[0]; type LlamaCppInferenceRuntimeState = { + admission: LlamaCppInferenceRuntimeToken; loadedModel?: LoadedModel; llamaInstance?: Llama; operationQueue: Promise; lifecycle: "open" | "closing" | "closed"; cleanupFailure?: { error: Error }; + retiringRuntimeFailure?: boolean; disposePromise?: Promise; }; @@ -55,53 +61,8 @@ type LlamaCppInferenceRuntime = { dispose: () => Promise; }; -function zeroCostUsage(input = 0, output = 0): Usage { - return { - input, - output, - cacheRead: 0, - cacheWrite: 0, - totalTokens: input + output, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }; -} - -function buildMessage(params: { - model: Parameters[0]; - content: AssistantMessage["content"]; - stopReason: StopReason; - usage?: Usage; - errorMessage?: string; -}): AssistantMessage { - return { - role: "assistant", - content: params.content, - api: params.model.api, - provider: params.model.provider, - model: params.model.id, - stopReason: params.stopReason, - usage: params.usage ?? zeroCostUsage(), - timestamp: Date.now(), - ...(params.errorMessage ? { errorMessage: params.errorMessage } : {}), - }; -} - -function runtimeUnavailableErrorMessage(state: LlamaCppInferenceRuntimeState): string { - return state.cleanupFailure - ? "llama.cpp runtime stopped after cleanup failed. Run `openclaw gateway restart` to recover." - : "llama.cpp runtime is stopping"; -} - -function runtimeUnavailableMessage( - state: LlamaCppInferenceRuntimeState, - model: Parameters[0], -): AssistantMessage { - return buildMessage({ - model, - content: [], - stopReason: "error", - errorMessage: runtimeUnavailableErrorMessage(state), - }); +function runtimeRequiresRestart(state: LlamaCppInferenceRuntimeState): boolean { + return Boolean(state.cleanupFailure || state.retiringRuntimeFailure); } function extractText(content: unknown): string { @@ -279,6 +240,12 @@ async function disposeLoadedModel(state: LlamaCppInferenceRuntimeState): Promise function recordCleanupFailure(state: LlamaCppInferenceRuntimeState, error: unknown): void { state.cleanupFailure ??= { error: error instanceof Error ? error : new Error(String(error)) }; state.lifecycle = "closed"; + state.admission.fail(state.cleanupFailure.error); +} + +function recordRetiringRuntimeFailure(state: LlamaCppInferenceRuntimeState): void { + state.retiringRuntimeFailure = true; + state.lifecycle = "closed"; } async function getLoadedModel(params: { @@ -345,6 +312,7 @@ function disposeLlamaCppInferenceRuntime(state: LlamaCppInferenceRuntimeState): return state.disposePromise; } state.lifecycle = "closing"; + state.admission.close(); // node-llama-cpp disposers are one-shot and child cleanup releases the // parent's disposal guard. Do not force parent cleanup after a child rejects: // the retained guard can make that parent disposer wait forever. @@ -360,6 +328,7 @@ function disposeLlamaCppInferenceRuntime(state: LlamaCppInferenceRuntimeState): state.llamaInstance = undefined; } } + state.admission.release(); }) .catch((error: unknown) => { recordCleanupFailure(state, error); @@ -381,7 +350,7 @@ function createLlamaCppStreamFnForRuntime( stream.push({ type: "error", reason: "error", - error: runtimeUnavailableMessage(state, model), + error: runtimeUnavailableMessage(model, runtimeRequiresRestart(state)), }); stream.end(); return stream; @@ -424,10 +393,18 @@ function createLlamaCppStreamFnForRuntime( stream.push({ type: "error", reason: "error", - error: runtimeUnavailableMessage(state, model), + error: runtimeUnavailableMessage(model, runtimeRequiresRestart(state)), }); return; } + await state.admission.acquire({ + signal, + isLive: () => state.lifecycle === "open", + onRestartRequired: () => recordRetiringRuntimeFailure(state), + }); + if (state.lifecycle !== "open") { + throw new Error("llama.cpp runtime is stopping"); + } const runtime = await importNodeLlamaCpp(); const loaded = await getLoadedModel({ state, @@ -688,8 +665,8 @@ function createLlamaCppStreamFnForRuntime( const reason = aborted ? "aborted" : "error"; const errorMessage = aborted ? "Request was aborted" - : state.cleanupFailure - ? runtimeUnavailableErrorMessage(state) + : state.lifecycle !== "open" + ? runtimeUnavailableErrorMessage(runtimeRequiresRestart(state)) : formatLlamaCppSetupError(error); stream.push({ type: "error", @@ -715,6 +692,7 @@ function createLlamaCppStreamFnForRuntime( export function createLlamaCppInferenceRuntime(): LlamaCppInferenceRuntime { const state: LlamaCppInferenceRuntimeState = { + admission: createLlamaCppInferenceRuntimeToken(), operationQueue: Promise.resolve(), lifecycle: "open", }; @@ -725,8 +703,10 @@ export function createLlamaCppInferenceRuntime(): LlamaCppInferenceRuntime { } if (process.env.VITEST || process.env.NODE_ENV === "test") { - (globalThis as Record)[Symbol.for("openclaw.llamaCppInferenceTestApi")] = { + const globalStore = globalThis as Record; + const testApiKey = Symbol.for("openclaw.llamaCppInferenceTestApi"); + Object.assign((globalStore[testApiKey] ??= {}), { mapContextToLlamaChatHistory, mapToolsToLlamaFunctions, - }; + }); } diff --git a/extensions/llama-cpp/src/inference-runtime-coordinator.test.ts b/extensions/llama-cpp/src/inference-runtime-coordinator.test.ts new file mode 100644 index 000000000000..d215a4e421f0 --- /dev/null +++ b/extensions/llama-cpp/src/inference-runtime-coordinator.test.ts @@ -0,0 +1,113 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + createLlamaCppInferenceRuntimeToken, + LlamaCppInferenceRestartRequiredError, +} from "./inference-runtime-coordinator.js"; + +const testApi = (globalThis as Record)[ + Symbol.for("openclaw.llamaCppInferenceTestApi") +] as { + resetInferenceRuntimeCoordinator: () => void; +}; + +function createLiveToken() { + let live = true; + const token = createLlamaCppInferenceRuntimeToken(); + return { + acquire: (signal?: AbortSignal) => + token.acquire({ signal, isLive: () => live, onRestartRequired: () => undefined }), + dispose: () => { + live = false; + token.close(); + }, + fail: token.fail, + release: token.release, + }; +} + +beforeEach(() => { + testApi.resetInferenceRuntimeCoordinator(); +}); + +describe("llama.cpp inference runtime coordinator", () => { + it("does not reserve native ownership until a runtime acquires it", async () => { + createLiveToken(); + const replacement = createLiveToken(); + + await expect(replacement.acquire()).resolves.toBeUndefined(); + }); + + it("hands native ownership to rapid contenders one generation at a time", async () => { + const first = createLiveToken(); + const second = createLiveToken(); + const third = createLiveToken(); + await first.acquire(); + let secondAcquired = false; + let thirdAcquired = false; + const secondAcquisition = second.acquire().then(() => { + secondAcquired = true; + }); + const thirdAcquisition = third.acquire().then(() => { + thirdAcquired = true; + }); + + first.release(); + await secondAcquisition; + expect(secondAcquired).toBe(true); + expect(thirdAcquired).toBe(false); + + second.release(); + await thirdAcquisition; + expect(thirdAcquired).toBe(true); + }); + + it("skips a disposed waiter before handing ownership to the next runtime", async () => { + const first = createLiveToken(); + const disposed = createLiveToken(); + const replacement = createLiveToken(); + await first.acquire(); + const disposedAcquisition = disposed.acquire(); + let replacementAcquired = false; + const replacementAcquisition = replacement.acquire().then(() => { + replacementAcquired = true; + }); + + disposed.dispose(); + + await expect(disposedAcquisition).rejects.toThrow("runtime is stopping"); + expect(replacementAcquired).toBe(false); + + first.release(); + await expect(replacementAcquisition).resolves.toBeUndefined(); + }); + + it("removes an aborted waiter without letting it claim ownership", async () => { + const first = createLiveToken(); + const aborted = createLiveToken(); + const replacement = createLiveToken(); + const abortController = new AbortController(); + await first.acquire(); + const abortedAcquisition = aborted.acquire(abortController.signal); + const replacementAcquisition = replacement.acquire(); + + abortController.abort(); + first.release(); + + await expect(abortedAcquisition).rejects.toThrow(); + await expect(replacementAcquisition).resolves.toBeUndefined(); + }); + + it("latches cleanup failure for current and future waiters", async () => { + const first = createLiveToken(); + const waiting = createLiveToken(); + await first.acquire(); + const waitingAcquisition = waiting.acquire(); + + first.fail(new Error("native cleanup failed")); + + await expect(waitingAcquisition).rejects.toBeInstanceOf(LlamaCppInferenceRestartRequiredError); + await expect(createLiveToken().acquire()).rejects.toBeInstanceOf( + LlamaCppInferenceRestartRequiredError, + ); + }); +}); diff --git a/extensions/llama-cpp/src/inference-runtime-coordinator.ts b/extensions/llama-cpp/src/inference-runtime-coordinator.ts new file mode 100644 index 000000000000..288f1c0a106c --- /dev/null +++ b/extensions/llama-cpp/src/inference-runtime-coordinator.ts @@ -0,0 +1,174 @@ +import { resolveGlobalSingleton } from "openclaw/plugin-sdk/global-singleton"; + +const COORDINATOR_KEY = Symbol.for("openclaw.llamaCppInferenceRuntimeCoordinator"); +const TEST_API_KEY = Symbol.for("openclaw.llamaCppInferenceTestApi"); +const RESTART_REQUIRED_CODE = "LLAMA_CPP_INFERENCE_RESTART_REQUIRED"; + +type Completion = { + promise: Promise; + complete: () => void; +}; + +type CoordinatorState = { + owner?: Completion; + restartRequired?: Error; +}; + +type RuntimeToken = { + closing: Completion; + owner?: Completion; + released: boolean; +}; + +export type LlamaCppInferenceRuntimeToken = { + acquire: (params: { + signal?: AbortSignal; + isLive: () => boolean; + onRestartRequired: () => void; + }) => Promise; + close: () => void; + fail: (error: unknown) => void; + release: () => void; +}; + +export class LlamaCppInferenceRestartRequiredError extends Error { + readonly code = RESTART_REQUIRED_CODE; + + constructor(cause: Error) { + super("A previous llama.cpp runtime failed to release native resources", { cause }); + this.name = "LlamaCppInferenceRestartRequiredError"; + } +} + +function getCoordinatorState(): CoordinatorState { + return resolveGlobalSingleton(COORDINATOR_KEY, () => ({})); +} + +function createCompletion(): Completion { + let complete!: () => void; + const promise = new Promise((resolve) => { + complete = resolve; + }); + return { promise, complete }; +} + +function abortedError(signal: AbortSignal): Error { + return signal.reason instanceof Error ? signal.reason : new Error("Request was aborted"); +} + +function waitForTurn(params: { + closing: Promise; + retired: Promise; + signal?: AbortSignal; +}): Promise<"closed" | "retired"> { + const signal = params.signal; + if (signal?.aborted) { + return Promise.reject(abortedError(signal)); + } + return new Promise((resolve, reject) => { + const cleanup = () => signal?.removeEventListener("abort", abort); + const abort = () => { + cleanup(); + reject(signal ? abortedError(signal) : new Error("Request was aborted")); + }; + const finish = (result: "closed" | "retired") => { + cleanup(); + resolve(result); + }; + signal?.addEventListener("abort", abort, { once: true }); + void params.closing.then(() => finish("closed")); + void params.retired.then(() => finish("retired")); + }); +} + +function isRestartRequiredError(error: unknown): error is LlamaCppInferenceRestartRequiredError { + return error instanceof Error && "code" in error && error.code === RESTART_REQUIRED_CODE; +} + +async function acquireRuntime( + token: RuntimeToken, + params: { signal?: AbortSignal; isLive: () => boolean }, +): Promise { + if (token.owner) { + return; + } + while (true) { + if (token.released || !params.isLive()) { + throw new Error("llama.cpp runtime is stopping"); + } + if (params.signal?.aborted) { + throw abortedError(params.signal); + } + const state = getCoordinatorState(); + if (state.restartRequired) { + throw new LlamaCppInferenceRestartRequiredError(state.restartRequired); + } + if (!state.owner) { + const owner = createCompletion(); + state.owner = owner; + token.owner = owner; + return; + } + if ( + (await waitForTurn({ + closing: token.closing.promise, + retired: state.owner.promise, + signal: params.signal, + })) === "closed" + ) { + throw new Error("llama.cpp runtime is stopping"); + } + } +} + +function releaseRuntime(token: RuntimeToken): void { + token.released = true; + if (!token.owner) { + return; + } + const state = getCoordinatorState(); + if (state.owner === token.owner) { + state.owner = undefined; + } + token.owner.complete(); + token.owner = undefined; +} + +function failRuntime(token: RuntimeToken, error: unknown): void { + const state = getCoordinatorState(); + state.restartRequired ??= error instanceof Error ? error : new Error(String(error)); + token.owner?.complete(); +} + +export function createLlamaCppInferenceRuntimeToken(): LlamaCppInferenceRuntimeToken { + // Registration creates only a local token; native ownership remains lazy. + const token: RuntimeToken = { closing: createCompletion(), released: false }; + return { + acquire: async ({ onRestartRequired, ...params }) => { + try { + await acquireRuntime(token, params); + } catch (error) { + // Reloaded plugin chunks have distinct class identities. + if (isRestartRequiredError(error)) { + onRestartRequired(); + } + throw error; + } + }, + close: () => token.closing.complete(), + fail: (error) => failRuntime(token, error), + release: () => releaseRuntime(token), + }; +} + +if (process.env.VITEST || process.env.NODE_ENV === "test") { + const globalStore = globalThis as Record; + const testApi = (globalStore[TEST_API_KEY] ?? {}) as Record; + testApi.resetInferenceRuntimeCoordinator = () => { + const state = getCoordinatorState(); + state.owner?.complete(); + state.owner = undefined; + state.restartRequired = undefined; + }; + globalStore[TEST_API_KEY] = testApi; +} From 51374854f065e1656d99e40ecbfb565482863175 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 5 Aug 2026 15:53:40 +0800 Subject: [PATCH 16/17] fix(llama-cpp): keep restart error internal Punchcard-Session: frost-brook-timber-mx --- .../src/inference-runtime-coordinator.test.ts | 17 +++++++++-------- .../src/inference-runtime-coordinator.ts | 2 +- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/extensions/llama-cpp/src/inference-runtime-coordinator.test.ts b/extensions/llama-cpp/src/inference-runtime-coordinator.test.ts index d215a4e421f0..040e9a98f928 100644 --- a/extensions/llama-cpp/src/inference-runtime-coordinator.test.ts +++ b/extensions/llama-cpp/src/inference-runtime-coordinator.test.ts @@ -1,8 +1,5 @@ import { beforeEach, describe, expect, it } from "vitest"; -import { - createLlamaCppInferenceRuntimeToken, - LlamaCppInferenceRestartRequiredError, -} from "./inference-runtime-coordinator.js"; +import { createLlamaCppInferenceRuntimeToken } from "./inference-runtime-coordinator.js"; const testApi = (globalThis as Record)[ Symbol.for("openclaw.llamaCppInferenceTestApi") @@ -105,9 +102,13 @@ describe("llama.cpp inference runtime coordinator", () => { first.fail(new Error("native cleanup failed")); - await expect(waitingAcquisition).rejects.toBeInstanceOf(LlamaCppInferenceRestartRequiredError); - await expect(createLiveToken().acquire()).rejects.toBeInstanceOf( - LlamaCppInferenceRestartRequiredError, - ); + await expect(waitingAcquisition).rejects.toMatchObject({ + name: "LlamaCppInferenceRestartRequiredError", + code: "LLAMA_CPP_INFERENCE_RESTART_REQUIRED", + }); + await expect(createLiveToken().acquire()).rejects.toMatchObject({ + name: "LlamaCppInferenceRestartRequiredError", + code: "LLAMA_CPP_INFERENCE_RESTART_REQUIRED", + }); }); }); diff --git a/extensions/llama-cpp/src/inference-runtime-coordinator.ts b/extensions/llama-cpp/src/inference-runtime-coordinator.ts index 288f1c0a106c..8d0626cece71 100644 --- a/extensions/llama-cpp/src/inference-runtime-coordinator.ts +++ b/extensions/llama-cpp/src/inference-runtime-coordinator.ts @@ -31,7 +31,7 @@ export type LlamaCppInferenceRuntimeToken = { release: () => void; }; -export class LlamaCppInferenceRestartRequiredError extends Error { +class LlamaCppInferenceRestartRequiredError extends Error { readonly code = RESTART_REQUIRED_CODE; constructor(cause: Error) { From e67d1644035df60f2e1200fa4a98d5d92b8a2126 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 5 Aug 2026 22:26:11 +0800 Subject: [PATCH 17/17] fix(llama-cpp): require full-process cleanup recovery Punchcard-Session: frost-brook-timber-mx --- extensions/llama-cpp/src/inference-messages.ts | 2 +- extensions/llama-cpp/src/inference-provider.test.ts | 12 +++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/extensions/llama-cpp/src/inference-messages.ts b/extensions/llama-cpp/src/inference-messages.ts index 7ab1d76b5a5d..f7116bd34e1d 100644 --- a/extensions/llama-cpp/src/inference-messages.ts +++ b/extensions/llama-cpp/src/inference-messages.ts @@ -34,7 +34,7 @@ export function buildMessage(params: { export function runtimeUnavailableErrorMessage(restartRequired: boolean): string { return restartRequired - ? "llama.cpp runtime stopped after cleanup failed. Run `openclaw gateway restart` to recover." + ? "llama.cpp runtime stopped after native cleanup failed. Fully stop the managed Gateway service or foreground Gateway process, then start it again. An in-process restart cannot recover native resources." : "llama.cpp runtime is stopping"; } diff --git a/extensions/llama-cpp/src/inference-provider.test.ts b/extensions/llama-cpp/src/inference-provider.test.ts index 73157965b220..e395bb8116c0 100644 --- a/extensions/llama-cpp/src/inference-provider.test.ts +++ b/extensions/llama-cpp/src/inference-provider.test.ts @@ -63,6 +63,8 @@ const testApi = (globalThis as Record)[ ] as { resetInferenceRuntimeCoordinator: () => void; }; +const NATIVE_CLEANUP_RECOVERY_MESSAGE = + "llama.cpp runtime stopped after native cleanup failed. Fully stop the managed Gateway service or foreground Gateway process, then start it again. An in-process restart cannot recover native resources."; const model: Model = { id: "test.gguf", @@ -780,10 +782,10 @@ describe("llama.cpp inference provider", () => { const disposing = inferenceRuntime.dispose(); rejectCleanup(new Error("context cleanup failed")); await expect(failedSwitch.result()).resolves.toMatchObject({ - errorMessage: expect.stringContaining("openclaw gateway restart"), + errorMessage: NATIVE_CLEANUP_RECOVERY_MESSAGE, }); await expect(unavailable.result()).resolves.toMatchObject({ - errorMessage: expect.stringContaining("openclaw gateway restart"), + errorMessage: NATIVE_CLEANUP_RECOVERY_MESSAGE, }); await expect(disposing).rejects.toThrow("context cleanup failed"); expectDisposeCalls(2, 1, 0); @@ -795,7 +797,7 @@ describe("llama.cpp inference provider", () => { mocks.modelDispose.mockRejectedValueOnce(new Error("model cleanup failed")); const failedInitialization = await createTestStream(); await expect(failedInitialization.result()).resolves.toMatchObject({ - errorMessage: expect.stringContaining("openclaw gateway restart"), + errorMessage: NATIVE_CLEANUP_RECOVERY_MESSAGE, }); await expect(inferenceRuntime.dispose()).rejects.toThrow("model cleanup failed"); expectDisposeCalls(0, 1, 0); @@ -875,7 +877,7 @@ describe("llama.cpp inference provider", () => { expect.objectContaining({ type: "error", error: expect.objectContaining({ - errorMessage: expect.stringContaining("openclaw gateway restart"), + errorMessage: NATIVE_CLEANUP_RECOVERY_MESSAGE, }), }), ]), @@ -955,7 +957,7 @@ describe("llama.cpp inference provider", () => { expectDisposeCalls(1, 1, 1); const unavailable = await createTestStream({ prompt: "after failed stop" }); await expect(unavailable.result()).resolves.toMatchObject({ - errorMessage: expect.stringContaining("openclaw gateway restart"), + errorMessage: NATIVE_CLEANUP_RECOVERY_MESSAGE, }); expect(inferenceRuntime.dispose()).toBe(firstDisposal); });