Skip to content

Commit 9c44f10

Browse files
authored
fix: preserve canonical restart sentinel routes (#64391)
Merged via squash. Prepared head SHA: 0183c17 Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Reviewed-by: @gumadeiras
1 parent dffad08 commit 9c44f10

5 files changed

Lines changed: 180 additions & 24 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ Docs: https://docs.openclaw.ai
117117
- Matrix/ACP thread bindings: preserve canonical room casing and parent conversation routing during ACP session spawn so mixed-case room ids bind correctly from top-level rooms and existing Matrix threads. (#64343) Thanks @gumadeiras.
118118
- Agents/subagents: deduplicate delivered completion announces so retry or re-entry cleanup does not inject duplicate internal-context completion turns into the parent session. (#61525) Thanks @100yenadmin.
119119
- Agents/exec: keep sandboxed `tools.exec.host=auto` sessions from honoring per-call `host=node` or `host=gateway` overrides while a sandbox runtime is active, and stop advertising node routing in that state so exec stays on the sandbox host. (#63880)
120+
- Gateway/restart sentinel: route restart notices only from stored canonical delivery metadata and skip outbound guessing from lossy session keys, avoiding misdelivery on case-sensitive channels like Matrix. (#64391) Thanks @gumadeiras.
120121

121122
## 2026.4.9
122123

src/config/sessions/delivery-info.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,4 +161,58 @@ describe("extractDeliveryInfo", () => {
161161
threadId: undefined,
162162
});
163163
});
164+
165+
it("derives delivery info from stored last route metadata when deliveryContext is missing", () => {
166+
const sessionKey = "agent:main:matrix:channel:!lowercased:example.org";
167+
storeState.store[sessionKey] = {
168+
sessionId: "session-1",
169+
updatedAt: Date.now(),
170+
origin: {
171+
provider: "matrix",
172+
},
173+
lastChannel: "matrix",
174+
lastTo: "room:!MixedCase:example.org",
175+
};
176+
177+
const result = extractDeliveryInfo(sessionKey);
178+
179+
expect(result).toEqual({
180+
deliveryContext: {
181+
channel: "matrix",
182+
to: "room:!MixedCase:example.org",
183+
accountId: undefined,
184+
},
185+
threadId: undefined,
186+
});
187+
});
188+
189+
it("falls back to the base session when a thread entry only has partial route metadata", () => {
190+
const baseKey = "agent:main:matrix:channel:!MixedCase:example.org";
191+
const threadKey = `${baseKey}:thread:$thread-event`;
192+
storeState.store[threadKey] = {
193+
sessionId: "thread-session",
194+
updatedAt: Date.now(),
195+
origin: {
196+
provider: "matrix",
197+
threadId: "$thread-event",
198+
},
199+
};
200+
storeState.store[baseKey] = {
201+
sessionId: "base-session",
202+
updatedAt: Date.now(),
203+
lastChannel: "matrix",
204+
lastTo: "room:!MixedCase:example.org",
205+
};
206+
207+
const result = extractDeliveryInfo(threadKey);
208+
209+
expect(result).toEqual({
210+
deliveryContext: {
211+
channel: "matrix",
212+
to: "room:!MixedCase:example.org",
213+
accountId: undefined,
214+
},
215+
threadId: "$thread-event",
216+
});
217+
});
164218
});

src/config/sessions/delivery-info.ts

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { deliveryContextFromSession } from "../../utils/delivery-context.js";
12
import { loadConfig } from "../io.js";
23
import { resolveStorePath } from "./paths.js";
34
import { loadSessionStore } from "./store.js";
@@ -10,6 +11,17 @@ export function extractDeliveryInfo(sessionKey: string | undefined): {
1011
| undefined;
1112
threadId: string | undefined;
1213
} {
14+
const hasRoutableDeliveryContext = (context?: {
15+
channel?: string;
16+
to?: string;
17+
accountId?: string;
18+
threadId?: string | number;
19+
}): context is {
20+
channel: string;
21+
to: string;
22+
accountId?: string;
23+
threadId?: string | number;
24+
} => Boolean(context?.channel && context?.to);
1325
const { baseSessionKey, threadId } = parseSessionThreadInfo(sessionKey);
1426
if (!sessionKey || !baseSessionKey) {
1527
return { deliveryContext: undefined, threadId };
@@ -23,17 +35,20 @@ export function extractDeliveryInfo(sessionKey: string | undefined): {
2335
const storePath = resolveStorePath(cfg.session?.store);
2436
const store = loadSessionStore(storePath);
2537
let entry = store[sessionKey];
26-
if (!entry?.deliveryContext && baseSessionKey !== sessionKey) {
38+
let storedDeliveryContext = deliveryContextFromSession(entry);
39+
if (!hasRoutableDeliveryContext(storedDeliveryContext) && baseSessionKey !== sessionKey) {
2740
entry = store[baseSessionKey];
41+
storedDeliveryContext = deliveryContextFromSession(entry);
2842
}
29-
if (entry?.deliveryContext) {
30-
const resolvedThreadId =
31-
entry.deliveryContext.threadId ?? entry.lastThreadId ?? entry.origin?.threadId;
43+
if (hasRoutableDeliveryContext(storedDeliveryContext)) {
3244
deliveryContext = {
33-
channel: entry.deliveryContext.channel,
34-
to: entry.deliveryContext.to,
35-
accountId: entry.deliveryContext.accountId,
36-
threadId: resolvedThreadId != null ? String(resolvedThreadId) : undefined,
45+
channel: storedDeliveryContext.channel,
46+
to: storedDeliveryContext.to,
47+
accountId: storedDeliveryContext.accountId,
48+
threadId:
49+
storedDeliveryContext.threadId != null
50+
? String(storedDeliveryContext.threadId)
51+
: undefined,
3752
};
3853
}
3954
} catch {

src/gateway/server-restart-sentinel.test.ts

Lines changed: 89 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,18 @@ const mocks = vi.hoisted(() => ({
1616
formatRestartSentinelMessage: vi.fn(() => "restart message"),
1717
summarizeRestartSentinel: vi.fn(() => "restart summary"),
1818
resolveMainSessionKeyFromConfig: vi.fn(() => "agent:main:main"),
19-
parseSessionThreadInfo: vi.fn(() => ({ baseSessionKey: null, threadId: undefined })),
19+
parseSessionThreadInfo: vi.fn(
20+
(): { baseSessionKey: string | null | undefined; threadId: string | undefined } => ({
21+
baseSessionKey: null,
22+
threadId: undefined,
23+
}),
24+
),
2025
loadSessionEntry: vi.fn(() => ({ cfg: {}, entry: {} })),
21-
resolveAnnounceTargetFromKey: vi.fn(() => null),
22-
deliveryContextFromSession: vi.fn(() => undefined),
26+
deliveryContextFromSession: vi.fn(
27+
():
28+
| { channel?: string; to?: string; accountId?: string; threadId?: string | number }
29+
| undefined => undefined,
30+
),
2331
mergeDeliveryContext: vi.fn((a?: Record<string, unknown>, b?: Record<string, unknown>) => ({
2432
...b,
2533
...a,
@@ -50,18 +58,14 @@ vi.mock("../config/sessions.js", () => ({
5058
resolveMainSessionKeyFromConfig: mocks.resolveMainSessionKeyFromConfig,
5159
}));
5260

53-
vi.mock("../config/sessions/delivery-info.js", () => ({
61+
vi.mock("../config/sessions/thread-info.js", () => ({
5462
parseSessionThreadInfo: mocks.parseSessionThreadInfo,
5563
}));
5664

5765
vi.mock("./session-utils.js", () => ({
5866
loadSessionEntry: mocks.loadSessionEntry,
5967
}));
6068

61-
vi.mock("../agents/tools/sessions-send-helpers.js", () => ({
62-
resolveAnnounceTargetFromKey: mocks.resolveAnnounceTargetFromKey,
63-
}));
64-
6569
vi.mock("../utils/delivery-context.js", () => ({
6670
deliveryContextFromSession: mocks.deliveryContextFromSession,
6771
mergeDeliveryContext: mocks.mergeDeliveryContext,
@@ -126,6 +130,14 @@ describe("scheduleRestartSentinelWake", () => {
126130
},
127131
},
128132
});
133+
mocks.parseSessionThreadInfo.mockReset();
134+
mocks.parseSessionThreadInfo.mockReturnValue({ baseSessionKey: null, threadId: undefined });
135+
mocks.loadSessionEntry.mockReset();
136+
mocks.loadSessionEntry.mockReturnValue({ cfg: {}, entry: {} });
137+
mocks.deliveryContextFromSession.mockReset();
138+
mocks.deliveryContextFromSession.mockReturnValue(undefined);
139+
mocks.resolveOutboundTarget.mockReset();
140+
mocks.resolveOutboundTarget.mockReturnValue({ ok: true as const, to: "+15550002" });
129141
mocks.deliverOutboundPayloads.mockReset();
130142
mocks.deliverOutboundPayloads.mockResolvedValue([{ channel: "whatsapp", messageId: "msg-1" }]);
131143
mocks.enqueueDelivery.mockReset();
@@ -278,4 +290,73 @@ describe("scheduleRestartSentinelWake", () => {
278290
expect(mocks.requestHeartbeatNow).not.toHaveBeenCalled();
279291
expect(mocks.deliverOutboundPayloads).not.toHaveBeenCalled();
280292
});
293+
294+
it("skips outbound restart notice when no canonical delivery context survives restart", async () => {
295+
mocks.consumeRestartSentinel.mockResolvedValue({
296+
payload: {
297+
sessionKey: "agent:main:matrix:channel:!lowercased:example.org",
298+
},
299+
} as Awaited<ReturnType<typeof mocks.consumeRestartSentinel>>);
300+
mocks.parseSessionThreadInfo.mockReturnValue({
301+
baseSessionKey: "agent:main:matrix:channel:!lowercased:example.org",
302+
threadId: undefined,
303+
});
304+
mocks.deliveryContextFromSession.mockReturnValue(undefined);
305+
306+
await scheduleRestartSentinelWake({ deps: {} as never });
307+
308+
expect(mocks.enqueueSystemEvent).toHaveBeenCalledWith(
309+
"restart message",
310+
expect.objectContaining({
311+
sessionKey: "agent:main:matrix:channel:!lowercased:example.org",
312+
}),
313+
);
314+
expect(mocks.deliverOutboundPayloads).not.toHaveBeenCalled();
315+
expect(mocks.enqueueDelivery).not.toHaveBeenCalled();
316+
expect(mocks.resolveOutboundTarget).not.toHaveBeenCalled();
317+
});
318+
319+
it("falls back to the base session when the thread entry only has partial route metadata", async () => {
320+
mocks.consumeRestartSentinel.mockResolvedValue({
321+
payload: {
322+
sessionKey: "agent:main:matrix:channel:!lowercased:example.org:thread:$thread-event",
323+
},
324+
} as Awaited<ReturnType<typeof mocks.consumeRestartSentinel>>);
325+
mocks.parseSessionThreadInfo.mockReturnValue({
326+
baseSessionKey: "agent:main:matrix:channel:!lowercased:example.org",
327+
threadId: "$thread-event",
328+
});
329+
mocks.loadSessionEntry
330+
.mockReturnValueOnce({
331+
cfg: {},
332+
entry: { origin: { provider: "matrix", threadId: "$thread-event" } },
333+
})
334+
.mockReturnValueOnce({
335+
cfg: {},
336+
entry: { lastChannel: "matrix", lastTo: "room:!MixedCase:example.org" },
337+
});
338+
mocks.deliveryContextFromSession
339+
.mockReturnValueOnce({ channel: "matrix", threadId: "$thread-event" })
340+
.mockReturnValueOnce({ channel: "matrix", to: "room:!MixedCase:example.org" });
341+
mocks.resolveOutboundTarget.mockReturnValue({
342+
ok: true as const,
343+
to: "room:!MixedCase:example.org",
344+
});
345+
346+
await scheduleRestartSentinelWake({ deps: {} as never });
347+
348+
expect(mocks.resolveOutboundTarget).toHaveBeenCalledWith(
349+
expect.objectContaining({
350+
channel: "matrix",
351+
to: "room:!MixedCase:example.org",
352+
}),
353+
);
354+
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
355+
expect.objectContaining({
356+
channel: "matrix",
357+
to: "room:!MixedCase:example.org",
358+
threadId: "$thread-event",
359+
}),
360+
);
361+
});
281362
});

src/gateway/server-restart-sentinel.ts

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { resolveAnnounceTargetFromKey } from "../agents/tools/sessions-send-helpers.js";
21
import { getChannelPlugin, normalizeChannelId } from "../channels/plugins/index.js";
32
import type { CliDeps } from "../cli/deps.js";
43
import { resolveMainSessionKeyFromConfig } from "../config/sessions.js";
@@ -23,6 +22,13 @@ const log = createSubsystemLogger("gateway/restart-sentinel");
2322
const OUTBOUND_RETRY_DELAY_MS = 750;
2423
const OUTBOUND_MAX_ATTEMPTS = 2;
2524

25+
function hasRoutableDeliveryContext(context?: {
26+
channel?: string;
27+
to?: string;
28+
}): context is { channel: string; to: string } {
29+
return Boolean(context?.channel && context?.to);
30+
}
31+
2632
function enqueueRestartSentinelWake(
2733
message: string,
2834
sessionKey: string,
@@ -144,21 +150,21 @@ export async function scheduleRestartSentinelWake(params: { deps: CliDeps }) {
144150
const { baseSessionKey, threadId: sessionThreadId } = parseSessionThreadInfo(sessionKey);
145151

146152
const { cfg, entry } = loadSessionEntry(sessionKey);
147-
const parsedTarget = resolveAnnounceTargetFromKey(baseSessionKey ?? sessionKey);
148153

149154
// Prefer delivery context from sentinel (captured at restart) over session store
150155
// Handles race condition where store wasn't flushed before restart
151156
const sentinelContext = payload.deliveryContext;
152157
let sessionDeliveryContext = deliveryContextFromSession(entry);
153-
if (!sessionDeliveryContext && baseSessionKey && baseSessionKey !== sessionKey) {
158+
if (
159+
!hasRoutableDeliveryContext(sessionDeliveryContext) &&
160+
baseSessionKey &&
161+
baseSessionKey !== sessionKey
162+
) {
154163
const { entry: baseEntry } = loadSessionEntry(baseSessionKey);
155164
sessionDeliveryContext = deliveryContextFromSession(baseEntry);
156165
}
157166

158-
const origin = mergeDeliveryContext(
159-
sentinelContext,
160-
mergeDeliveryContext(sessionDeliveryContext, parsedTarget ?? undefined),
161-
);
167+
const origin = mergeDeliveryContext(sentinelContext, sessionDeliveryContext);
162168

163169
const channelRaw = origin?.channel;
164170
const channel = channelRaw ? normalizeChannelId(channelRaw) : null;
@@ -180,7 +186,6 @@ export async function scheduleRestartSentinelWake(params: { deps: CliDeps }) {
180186

181187
const threadId =
182188
payload.threadId ??
183-
parsedTarget?.threadId ?? // From resolveAnnounceTargetFromKey (extracts :topic:N)
184189
sessionThreadId ??
185190
(origin?.threadId != null ? String(origin.threadId) : undefined);
186191

0 commit comments

Comments
 (0)