mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 20:35:39 -06:00
fix(gateway): preserve restart runtime generations
This commit is contained in:
@@ -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();
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user