fix(plugin-state): preserve fresh evicted entries

This commit is contained in:
Vincent Koc
2026-05-04 01:25:12 -07:00
parent fcb396bf65
commit 43bdb886e9
3 changed files with 40 additions and 0 deletions

View File

@@ -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.

View File

@@ -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;
});

View File

@@ -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<number>("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<number>("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([