fix(gateway): preserve restart runtime generations

This commit is contained in:
Peter Steinberger
2026-08-20 05:30:22 -07:00
parent 53684904ad
commit 5c019d309a
6 changed files with 123 additions and 5 deletions
@@ -348,7 +348,9 @@ export function isHeartbeatPrompt(text: string) {
if (!trimmed || /remember this fact/i.test(trimmed)) {
return false;
}
return /(?:^|\n)Read HEARTBEAT\.md if it exists\b/i.test(trimmed);
return /(?:^|\n)(?:Read HEARTBEAT\.md if it exists|Follow the heartbeat monitor scratch context when provided)\b/i.test(
trimmed,
);
}
export function readFirstMediaPath(value: unknown): string {
@@ -4883,6 +4883,33 @@ Update and merge these partial structured summaries.`,
expect(outputText(await response.json())).toBe("HEARTBEAT_OK");
});
it("answers current heartbeat prompts without replaying earlier image requests", async () => {
const server = await startMockServer();
const imageRequest =
"Capability flip image check: generate a QA lighthouse image in this turn right now.";
const heartbeatRequest = [
"OpenClaw assembled context for this turn:",
"<conversation_context>",
"[user]",
imageRequest,
"</conversation_context>",
"",
"Current user request:",
"System: Gateway restart required (config.patch)",
"",
"Follow the heartbeat monitor scratch context when provided. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.",
].join("\n");
const response = await expectNonStreamingResponses(server, {
tools: [IMAGE_GENERATE_TOOL],
input: [makeUserInput(imageRequest), makeUserInput(heartbeatRequest)],
});
const payload = await response.json();
expect(outputItem(payload)).toMatchObject({ type: "message" });
expect(outputText(payload)).toBe("HEARTBEAT_OK");
});
it("returns exact markers for visible and hot-installed skills", async () => {
const server = await startMockServer();
+59
View File
@@ -494,6 +494,65 @@ describe("config.patch hash-free ui.prefs LWW", () => {
});
describe("config.patch ID-keyed arrays", () => {
it.each([false, true])(
"keeps catalog-only model defaults out of authored patches (authored compat: %s)",
async (authoredCompat) => {
const authoredTextModel = {
id: "gpt-5.5",
...(authoredCompat ? { compat: { supportsStore: false } } : {}),
};
const sourceConfig = {
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
models: [authoredTextModel, { id: "gpt-image-1" }],
},
},
},
} as unknown as OpenClawConfig;
const runtimeConfig = structuredClone(sourceConfig);
const runtimeModel = runtimeConfig.models?.providers?.openai?.models[0];
expect(runtimeModel).toBeDefined();
Object.assign(runtimeModel!, {
compat: authoredCompat ? { supportsStore: false } : { codeMode: "preferred" },
contextTokens: 272_000,
});
storedConfig = sourceConfig;
configWriteMocks.readConfigFileSnapshotForWrite.mockImplementationOnce(async () => {
const snapshot = createConfigWriteSnapshot(runtimeConfig);
snapshot.snapshot.sourceConfig = sourceConfig;
snapshot.snapshot.resolved = sourceConfig;
snapshot.snapshot.parsed = sourceConfig;
snapshot.snapshot.raw = JSON.stringify(sourceConfig);
return snapshot;
});
const { respond } = await invokeConfigPatch({
raw: {
models: {
providers: {
openai: {
models: [{ id: "gpt-image-1", baseUrl: "http://127.0.0.1:44080/v1" }],
},
},
},
},
baseHash: "base-hash",
});
expect(respond).toHaveBeenCalledWith(
true,
expect.objectContaining({ ok: true, hash: "next-hash-1" }),
undefined,
);
expect(storedConfig.models?.providers?.openai?.models).toEqual([
authoredTextModel,
{ id: "gpt-image-1", baseUrl: "http://127.0.0.1:44080/v1" },
]);
},
);
it("rejects duplicate IDs before applying an ID-merged array patch", async () => {
storedConfig = {
models: {
+6 -1
View File
@@ -1050,11 +1050,16 @@ export const configHandlers: GatewayRequestHandlers = {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, formatErrorMessage(error)));
return;
}
const merged = applyMergePatch(snapshot.config, normalizedPatch, {
// Source-owned array rows must not persist runtime-only catalog defaults from siblings.
const mergedSource = applyMergePatch(snapshot.resolved, normalizedPatch, {
// Arrays with stable ids behave like maps for partial control-plane edits.
mergeObjectArraysById: true,
replaceArrayPaths: replacePaths,
});
const merged = applyMergePatch(
snapshot.config,
createMergePatch(snapshot.resolved, mergedSource),
);
const schemaPatch = loadSchemaWithPlugins();
const restoredMerge = restoreRedactedValues(merged, snapshot.config, schemaPatch.uiHints);
if (!restoredMerge.ok) {
+6 -2
View File
@@ -1,3 +1,4 @@
import { resolveGlobalSingleton } from "../shared/global-singleton.js";
import { PluginLoaderCacheState } from "./loader-cache-state.js";
import { resolvePluginLoadCacheContext } from "./loader-load-context.js";
import type { PluginLoadOptions } from "./loader-types.js";
@@ -6,8 +7,11 @@ import type { PluginRegistry } from "./registry-types.js";
const MAX_PLUGIN_REGISTRY_CACHE_ENTRIES = 128;
export const pluginLoaderCacheState = new PluginLoaderCacheState<PluginRegistry>(
MAX_PLUGIN_REGISTRY_CACHE_ENTRIES,
export const pluginLoaderCacheState = resolveGlobalSingleton(
Symbol.for("openclaw.plugins.loader-cache-state"),
() => new PluginLoaderCacheState<PluginRegistry>(MAX_PLUGIN_REGISTRY_CACHE_ENTRIES),
(cache) => cache.clearCachedRegistries(),
"plugin-registry",
);
export function setCachedPluginRegistry(cacheKey: string, registry: PluginRegistry): void {
+22 -1
View File
@@ -12,6 +12,7 @@ import {
loadInstalledPluginIndexInstallRecordsSync,
writePersistedInstalledPluginIndexInstallRecordsSync,
} from "./installed-plugin-index-records.js";
import { getReusableCachedPluginRegistry, setCachedPluginRegistry } from "./loader-cache.js";
import { resolvePluginLoadCacheContext } from "./loader-load-context.js";
import { createLazyPluginRuntime } from "./loader-module-runtime.js";
import {
@@ -30,7 +31,11 @@ import {
import { buildMemoryPromptSection, registerMemoryCapability } from "./memory-state.js";
import { clearPluginMetadataLifecycleCaches } from "./plugin-metadata-lifecycle.js";
import { createEmptyPluginRegistry } from "./registry.js";
import { getActivePluginRegistry, setActivePluginRegistry } from "./runtime.js";
import {
clearActivePluginRegistry,
getActivePluginRegistry,
setActivePluginRegistry,
} from "./runtime.js";
import type { PluginRuntime } from "./runtime/types.js";
afterEach(() => {
@@ -119,6 +124,22 @@ describe("cached plugin load failures", () => {
});
});
it("invalidates cached registries when the active plugin registry lifecycle closes", async () => {
const rootCacheKey = "restart-root";
const snapshotCacheKey = "restart-gateway-bound-snapshot";
const root = createEmptyPluginRegistry();
const snapshot = createEmptyPluginRegistry();
setCachedPluginRegistry(rootCacheKey, root);
setCachedPluginRegistry(snapshotCacheKey, snapshot);
setActivePluginRegistry(root, rootCacheKey, "gateway-bindable");
await clearActivePluginRegistry();
expect(getReusableCachedPluginRegistry(rootCacheKey)).toBeUndefined();
expect(getReusableCachedPluginRegistry(snapshotCacheKey)).toBeUndefined();
expect(getActivePluginRegistry()).toBeNull();
});
function requireMemoryEmbeddingProvider(providerId: string) {
const provider = getRegisteredEmbeddingProvider(providerId)?.adapter;
if (!provider) {