diff --git a/CHANGELOG.md b/CHANGELOG.md index fe843cb3db6..da9d9b704cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Plugins/runtime state: keep the key being registered when namespace eviction runs in the same millisecond as existing entries, so `register` and `registerIfAbsent` do not report success while evicting their own fresh value. Thanks @vincentkoc. - Control UI/Talk: make failed Talk startup errors dismissable and clear the stale Talk error state when dismissed, so missing realtime voice provider configuration does not leave a permanent chat banner. Fixes #77071. Thanks @ijoshdavis. - Control UI/Talk: stop and clear failed realtime Talk sessions when dismissing runtime error banners, so the next Talk click starts a fresh session instead of only stopping the stale one. Thanks @vincentkoc. - Control UI/Talk: retry from a failed realtime Talk session on the next Talk click instead of requiring a separate stale-session stop click first. Thanks @vincentkoc. diff --git a/src/plugin-state/plugin-state-store.sqlite.ts b/src/plugin-state/plugin-state-store.sqlite.ts index 38125dceb7e..d3e97c7c19b 100644 --- a/src/plugin-state/plugin-state-store.sqlite.ts +++ b/src/plugin-state/plugin-state-store.sqlite.ts @@ -259,6 +259,7 @@ function createStatements(db: DatabaseSync): PluginStateStatements { FROM plugin_state_entries WHERE plugin_id = ? AND namespace = ? + AND entry_key <> ? AND (expires_at IS NULL OR expires_at > ?) ORDER BY created_at ASC, entry_key ASC LIMIT ? @@ -387,6 +388,7 @@ function enforcePostRegisterLimits(params: { namespace: string; maxEntries: number; now: number; + protectedKey: string; }): void { const namespaceCount = countRow( params.store.statements.countLiveNamespace.get( @@ -399,6 +401,7 @@ function enforcePostRegisterLimits(params: { params.store.statements.deleteOldestNamespace.run( params.pluginId, params.namespace, + params.protectedKey, params.now, namespaceCount - params.maxEntries, ); @@ -446,6 +449,7 @@ export function pluginStateRegister(params: { namespace: params.namespace, maxEntries: params.maxEntries, now, + protectedKey: params.key, }); }); } catch (error) { @@ -488,6 +492,7 @@ export function pluginStateRegisterIfAbsent(params: { namespace: params.namespace, maxEntries: params.maxEntries, now, + protectedKey: params.key, }); return true; }); diff --git a/src/plugin-state/plugin-state-store.test.ts b/src/plugin-state/plugin-state-store.test.ts index 94521be7af1..e2f08e85d71 100644 --- a/src/plugin-state/plugin-state-store.test.ts +++ b/src/plugin-state/plugin-state-store.test.ts @@ -264,6 +264,40 @@ describe("plugin state keyed store", () => { }); }); + it("keeps the just-registered key when namespace eviction timestamps tie", async () => { + await withOpenClawTestState({ label: "plugin-state-eviction-tie-register" }, async () => { + vi.useFakeTimers(); + vi.setSystemTime(1000); + const store = createPluginStateKeyedStore("discord", { + namespace: "evict-tie-register", + maxEntries: 1, + }); + + await store.register("z", 1); + await store.register("a", 2); + + await expect(store.entries()).resolves.toMatchObject([{ key: "a", value: 2 }]); + await expect(store.lookup("z")).resolves.toBeUndefined(); + }); + }); + + it("keeps a same-millisecond registerIfAbsent claim during namespace eviction", async () => { + await withOpenClawTestState({ label: "plugin-state-eviction-tie-claim" }, async () => { + vi.useFakeTimers(); + vi.setSystemTime(1000); + const store = createPluginStateKeyedStore("discord", { + namespace: "evict-tie-claim", + maxEntries: 1, + }); + + await expect(store.registerIfAbsent("z", 1)).resolves.toBe(true); + await expect(store.registerIfAbsent("a", 2)).resolves.toBe(true); + + await expect(store.entries()).resolves.toMatchObject([{ key: "a", value: 2 }]); + await expect(store.lookup("z")).resolves.toBeUndefined(); + }); + }); + it("rejects when the per-plugin live row ceiling would be exceeded without evicting siblings", async () => { await withOpenClawTestState({ label: "plugin-state-plugin-limit" }, async () => { seedPluginStateEntriesForTests([