Skip to content

Commit f0b8ac6

Browse files
committed
channels: tighten persistent state cache behavior
Address draft PR review feedback for the opt-in SDK-backed channel state migration: - backfill Slack and MS Teams in-memory caches after persistent lookup hits so hot inbound paths only pay the SQLite lookup once per key after restart - reset cached persistent store handles from existing cache clear helpers so tests and runtime reset paths reopen stores from the current runtime - use a narrow Matrix plugins config lookup type instead of a double cast - document why Slack persists agentId even though current reads only check participation presence Validation: pnpm test extensions/slack/src/sent-thread-cache.test.ts extensions/discord/src/components.test.ts extensions/msteams/src/sent-message-cache.test.ts extensions/matrix/src/approval-reactions.test.ts; pnpm check:changed.
1 parent 55ecf09 commit f0b8ac6

10 files changed

Lines changed: 83 additions & 36 deletions

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ Docs: https://docs.openclaw.ai
2727
- Providers/NVIDIA: add the NVIDIA provider with API-key onboarding, setup docs, static catalog metadata, and literal model-ref picker support so NVIDIA hosted models can be selected with their provider prefix intact. (#71204) Thanks @eleqtrizit.
2828
- Models: suppress explicitly configured openai-codex/gpt-5.4-mini inline entries so a stale models config written by `openclaw doctor --fix` cannot bypass the manifest capability block and cause repeated assistant-turn failures when the runtime switches to that model on ChatGPT-backed Codex accounts. Conditional suppressions (e.g. qwen Coding Plan endpoint guards) remain bypassable by explicit user configuration. (#74451) Thanks @0xCyda, @hclsys, and @Marvae.
2929
- Added SQLite-backed plugin state store (`api.runtime.state.openKeyedStore`) for restart-safe keyed registries with TTL, eviction, and automatic plugin isolation. Thanks @amknight.
30-
- Channels/plugins: add opt-in SDK-backed persistent state for Slack thread participation, Discord component/modal registries, Microsoft Teams sent-message markers, and Matrix approval reaction targets while keeping process-local caches as the default. Thanks @amknight.
30+
- Channels/plugins: add opt-in SDK-backed persistent state for Slack thread participation, Discord component/modal registries, Microsoft Teams sent-message markers, and Matrix approval reaction targets while keeping process-local caches as the default and warming hot in-memory paths after restart lookups. Thanks @amknight.
3131
- Plugin SDK: mark remaining legacy alias exports and diffs tool/config aliases with deprecation metadata, and add a guard so future legacy alias comments require `@deprecated` tags. Thanks @vincentkoc.
3232
- CLI/QR/dependencies: internalize small terminal progress and QR wrapper helpers while keeping the real QR encoder dependency direct, reducing the default runtime dependency graph without changing QR output behavior. Thanks @vincentkoc.
3333
- Dependencies: refresh workspace runtime, plugin, and tooling packages, including ACP, Pi, AWS SDK, TypeBox, pnpm, oxlint, oxfmt, jsdom, pdfjs, ciao, and tokenjuice, while keeping patched ACP behavior and lint gates current. Thanks @mariozechner.

extensions/discord/src/components-registry.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,4 +332,6 @@ export async function resolveDiscordModalEntryForConfig(params: {
332332
export function clearDiscordComponentEntries(): void {
333333
getComponentEntries().clear();
334334
getModalEntries().clear();
335+
persistentComponentStore = undefined;
336+
persistentModalStore = undefined;
335337
}

extensions/discord/src/components.test.ts

Lines changed: 20 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -153,24 +153,25 @@ describe("discord component registry", () => {
153153
version: 1,
154154
entry: { id: "mdl_persisted", title: "Persisted", fields: [] },
155155
});
156-
const openKeyedStore = vi
157-
.fn()
158-
.mockReturnValueOnce({
159-
register: componentRegister,
160-
lookup: componentLookup,
161-
consume: vi.fn(),
162-
delete: vi.fn(),
163-
entries: vi.fn(),
164-
clear: vi.fn(),
165-
})
166-
.mockReturnValueOnce({
167-
register: modalRegister,
168-
lookup: modalLookup,
169-
consume: vi.fn(),
170-
delete: vi.fn(),
171-
entries: vi.fn(),
172-
clear: vi.fn(),
173-
});
156+
const componentStore = {
157+
register: componentRegister,
158+
lookup: componentLookup,
159+
consume: vi.fn(),
160+
delete: vi.fn(),
161+
entries: vi.fn(),
162+
clear: vi.fn(),
163+
};
164+
const modalStore = {
165+
register: modalRegister,
166+
lookup: modalLookup,
167+
consume: vi.fn(),
168+
delete: vi.fn(),
169+
entries: vi.fn(),
170+
clear: vi.fn(),
171+
};
172+
const openKeyedStore = vi.fn((opts: { namespace: string }) =>
173+
opts.namespace === "discord.components" ? componentStore : modalStore,
174+
);
174175
const { setDiscordRuntime } = await import("./runtime.js");
175176
setDiscordRuntime({
176177
state: { openKeyedStore },
@@ -214,5 +215,6 @@ describe("discord component registry", () => {
214215
).resolves.toMatchObject({ id: "mdl_persisted" });
215216
expect(componentLookup).toHaveBeenCalledWith("btn_persisted");
216217
expect(modalLookup).toHaveBeenCalledWith("mdl_persisted");
218+
expect(openKeyedStore).toHaveBeenCalledTimes(4);
217219
});
218220
});

extensions/matrix/src/approval-reactions.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ describe("matrix approval reactions", () => {
166166
reactionKey: "❌",
167167
}),
168168
).resolves.toEqual({ approvalId: "req-persisted", decision: "deny" });
169+
expect(openKeyedStore).toHaveBeenCalledTimes(2);
169170
expect(lookup).toHaveBeenCalledWith("!ops:example.org:$approval-msg-2");
170171
});
171172
});

extensions/matrix/src/approval-reactions.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ type MatrixApprovalReactionStore = {
6060
delete(key: string): Promise<boolean>;
6161
};
6262

63+
type MatrixPluginConfigLookup = Pick<OpenClawConfig, "plugins">;
64+
6365
const matrixApprovalReactionTargets = new Map<string, MatrixApprovalReactionTarget>();
6466
let persistentStore: MatrixApprovalReactionStore | undefined;
6567

@@ -72,11 +74,10 @@ function buildReactionTargetKey(roomId: string, eventId: string): string | null
7274
return `${normalizedRoomId}:${normalizedEventId}`;
7375
}
7476

75-
function isPersistentApprovalReactionStateEnabled(cfg: CoreConfig | undefined): boolean {
76-
return (
77-
resolvePluginConfigObject(cfg as unknown as OpenClawConfig | undefined, "matrix")
78-
?.experimentalPersistentState === true
79-
);
77+
function isPersistentApprovalReactionStateEnabled(
78+
cfg: MatrixPluginConfigLookup | undefined,
79+
): boolean {
80+
return resolvePluginConfigObject(cfg, "matrix")?.experimentalPersistentState === true;
8081
}
8182

8283
function getPersistentApprovalReactionStore(): MatrixApprovalReactionStore | undefined {
@@ -336,4 +337,5 @@ export async function resolveMatrixApprovalReactionTargetForConfig(params: {
336337

337338
export function clearMatrixApprovalReactionTargetsForTest(): void {
338339
matrixApprovalReactionTargets.clear();
340+
persistentStore = undefined;
339341
}

extensions/matrix/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,5 +237,6 @@ export type CoreConfig = {
237237
ackReactionScope?: "group-mentions" | "group-all" | "direct" | "all" | "none" | "off";
238238
};
239239
secrets?: OpenClawConfig["secrets"];
240+
plugins?: OpenClawConfig["plugins"];
240241
[key: string]: unknown;
241242
};

extensions/msteams/src/sent-message-cache.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,14 @@ describe("msteams sent message cache", () => {
5050
await expect(
5151
wasMSTeamsMessageSentForConfig({ cfg, conversationId: "conv-1", messageId: "msg-2" }),
5252
).resolves.toBe(true);
53+
expect(openKeyedStore).toHaveBeenCalledTimes(2);
5354
expect(lookup).toHaveBeenCalledWith("conv-1:msg-2");
55+
56+
lookup.mockClear();
57+
await expect(
58+
wasMSTeamsMessageSentForConfig({ cfg, conversationId: "conv-1", messageId: "msg-2" }),
59+
).resolves.toBe(true);
60+
expect(wasMSTeamsMessageSent("conv-1", "msg-2")).toBe(true);
61+
expect(lookup).not.toHaveBeenCalled();
5462
});
5563
});

extensions/msteams/src/sent-message-cache.ts

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,23 @@ function cleanupExpired(scopeKey: string, entry: Map<string, number>, now: numbe
7575
}
7676
}
7777

78+
function rememberSentMessageInMemory(
79+
conversationId: string,
80+
messageId: string,
81+
sentAt: number,
82+
): void {
83+
const store = getSentMessageCache();
84+
let entry = store.get(conversationId);
85+
if (!entry) {
86+
entry = new Map<string, number>();
87+
store.set(conversationId, entry);
88+
}
89+
entry.set(messageId, sentAt);
90+
if (entry.size > 200) {
91+
cleanupExpired(conversationId, entry, sentAt);
92+
}
93+
}
94+
7895
function rememberPersistentSentMessage(params: {
7996
cfg?: OpenClawConfig;
8097
conversationId: string;
@@ -134,16 +151,7 @@ export function recordMSTeamsSentMessage(
134151
return;
135152
}
136153
const now = Date.now();
137-
const store = getSentMessageCache();
138-
let entry = store.get(conversationId);
139-
if (!entry) {
140-
entry = new Map<string, number>();
141-
store.set(conversationId, entry);
142-
}
143-
entry.set(messageId, now);
144-
if (entry.size > 200) {
145-
cleanupExpired(conversationId, entry, now);
146-
}
154+
rememberSentMessageInMemory(conversationId, messageId, now);
147155
rememberPersistentSentMessage({ cfg: opts?.cfg, conversationId, messageId, sentAt: now });
148156
}
149157

@@ -167,9 +175,14 @@ export async function wasMSTeamsMessageSentForConfig(params: {
167175
if (wasMSTeamsMessageSent(params.conversationId, params.messageId)) {
168176
return true;
169177
}
170-
return await lookupPersistentSentMessage(params);
178+
const found = await lookupPersistentSentMessage(params);
179+
if (found) {
180+
rememberSentMessageInMemory(params.conversationId, params.messageId, Date.now());
181+
}
182+
return found;
171183
}
172184

173185
export function clearMSTeamsSentMessageCache(): void {
174186
getSentMessageCache().clear();
187+
persistentStore = undefined;
175188
}

extensions/slack/src/sent-thread-cache.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,18 @@ describe("slack sent-thread-cache", () => {
131131
threadTs: "1700000000.000002",
132132
}),
133133
).resolves.toBe(true);
134+
expect(openKeyedStore).toHaveBeenCalledTimes(2);
134135
expect(lookup).toHaveBeenCalledWith("A1:C123:1700000000.000002");
136+
137+
lookup.mockClear();
138+
await expect(
139+
hasSlackThreadParticipationForConfig({
140+
cfg,
141+
accountId: "A1",
142+
channelId: "C123",
143+
threadTs: "1700000000.000002",
144+
}),
145+
).resolves.toBe(true);
146+
expect(lookup).not.toHaveBeenCalled();
135147
});
136148
});

extensions/slack/src/sent-thread-cache.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ function rememberPersistentThreadParticipation(params: {
9494
}
9595
void store
9696
.register(params.key, {
97+
// Stored for future per-agent thread routing; current reads only need presence.
9798
...(params.agentId ? { agentId: params.agentId } : {}),
9899
repliedAt: Date.now(),
99100
})
@@ -163,9 +164,14 @@ export async function hasSlackThreadParticipationForConfig(params: {
163164
if (threadParticipation.peek(key)) {
164165
return true;
165166
}
166-
return await lookupPersistentThreadParticipation({ cfg: params.cfg, key });
167+
const found = await lookupPersistentThreadParticipation({ cfg: params.cfg, key });
168+
if (found) {
169+
threadParticipation.check(key);
170+
}
171+
return found;
167172
}
168173

169174
export function clearSlackThreadParticipationCache(): void {
170175
threadParticipation.clear();
176+
persistentStore = undefined;
171177
}

0 commit comments

Comments
 (0)