From 1382fb5bd7e6ea261334a5c1ec653e6cdd6b7c47 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Apr 2026 14:30:46 +0100 Subject: [PATCH] fix(agents): fail closed missing requester completion routes --- CHANGELOG.md | 1 + .../subagent-announce.format.e2e.test.ts | 56 +++++++++++++++++++ .../outbound/bound-delivery-router.test.ts | 12 +++- src/infra/outbound/bound-delivery-router.ts | 7 +++ 4 files changed, 75 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 24b6010b87c..771abdb4a8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ Docs: https://docs.openclaw.ai - Channels/message tool: surface Discord, Slack, and Mattermost `user:`/`channel:` target syntax in the shared message target schema and Discord ambiguity errors, so DM sends by numeric id stop burning retries before finding `user:`. Fixes #72401. Thanks @garyd9, @hclsys, and @praveen9354. - Agents/tools: scope tool-loop detection history to the active run when available, so scheduled heartbeat cycles no longer inherit stale repeated-call counts from previous runs. Fixes #40144. Thanks @mattbrown319. - Agents/subagents: preserve requester delivery for completion announces when a child agent is bound to a different channel account while keeping same-channel thread completions routed to the child thread. Thanks @sfuminya. +- Agents/subagents: fail closed instead of selecting a single child thread binding when completion delivery lacks requester conversation signal. Thanks @suyua9. - Control UI: show loading, reload, and retry states when a lazy dashboard panel cannot load after an upgrade, so the Logs tab no longer appears blank on stale browser bundles. Fixes #72450. Thanks @sobergou. - Gateway/plugins: start the Gateway in degraded mode when a single plugin entry has invalid schema config, and let `openclaw doctor --fix` quarantine that plugin config instead of crash-looping every channel. Fixes #62976 and #70371. Thanks @Doraemon-Claw and @pksidekyk. - Agents/plugins: skip malformed plugin tools with missing schema objects and report plugin diagnostics, so one broken tool no longer crashes Anthropic agent runs. Fixes #69423. Thanks @jmnickels. diff --git a/src/agents/subagent-announce.format.e2e.test.ts b/src/agents/subagent-announce.format.e2e.test.ts index 08c4f50b389..f09db36272d 100644 --- a/src/agents/subagent-announce.format.e2e.test.ts +++ b/src/agents/subagent-announce.format.e2e.test.ts @@ -985,6 +985,62 @@ describe("subagent announce formatting", () => { expect(call?.params?.to).toBe("channel:thread-bound-1"); }); + it("does not use a child bound destination when completion requester conversation is missing", async () => { + sessionStore = { + "agent:main:subagent:test": { + sessionId: "child-session-bound-missing-requester", + }, + "agent:main:main": { + sessionId: "requester-session-bound-missing-requester", + }, + }; + chatHistoryMock.mockResolvedValueOnce({ + messages: [{ role: "assistant", content: [{ type: "text", text: "bound answer: 2" }] }], + }); + registerSessionBindingAdapter({ + channel: "discord", + accountId: "acct-1", + listBySession: (targetSessionKey: string) => + targetSessionKey === "agent:main:subagent:test" + ? [ + { + bindingId: "discord:acct-1:thread-bound-1", + targetSessionKey, + targetKind: "subagent", + conversation: { + channel: "discord", + accountId: "acct-1", + conversationId: "thread-bound-1", + parentConversationId: "parent-main", + }, + status: "active", + boundAt: Date.now(), + }, + ] + : [], + resolveByConversation: () => null, + }); + + const didAnnounce = await runSubagentAnnounceFlow({ + childSessionKey: "agent:main:subagent:test", + childRunId: "run-session-bound-missing-requester", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + requesterOrigin: { channel: "discord", accountId: "acct-1" }, + ...defaultOutcomeAnnounce, + expectsCompletionMessage: true, + spawnMode: "session", + }); + + expect(didAnnounce).toBe(true); + expect(sendSpy).not.toHaveBeenCalled(); + expect(agentSpy).toHaveBeenCalledTimes(1); + const call = agentSpy.mock.calls[0]?.[0] as { params?: Record }; + expect(call?.params?.deliver).toBe(false); + expect(call?.params?.to).toBeUndefined(); + expect(call?.params?.threadId).toBeUndefined(); + }); + it("does not duplicate to main channel when two active bound sessions complete from the same requester channel", async () => { sessionStore = { "agent:main:subagent:child-a": { diff --git a/src/infra/outbound/bound-delivery-router.test.ts b/src/infra/outbound/bound-delivery-router.test.ts index bd36180aef0..accd5edd366 100644 --- a/src/infra/outbound/bound-delivery-router.test.ts +++ b/src/infra/outbound/bound-delivery-router.test.ts @@ -105,7 +105,17 @@ describe("bound delivery router", () => { expected: { binding: null, mode: "fallback", - reason: "ambiguous-without-requester", + reason: "missing-requester", + }, + }, + { + name: "fails closed when requester signal is missing even with a single binding", + bindings: [createRuntimeBinding(TARGET_SESSION_KEY, "thread-1", 1)], + failClosed: true, + expected: { + binding: null, + mode: "fallback", + reason: "missing-requester", }, }, { diff --git a/src/infra/outbound/bound-delivery-router.ts b/src/infra/outbound/bound-delivery-router.ts index 08f779de78f..245ad078105 100644 --- a/src/infra/outbound/bound-delivery-router.ts +++ b/src/infra/outbound/bound-delivery-router.ts @@ -79,6 +79,13 @@ export function createBoundDeliveryRouter( } if (!input.requester) { + if (input.failClosed) { + return { + binding: null, + mode: "fallback", + reason: "missing-requester", + }; + } if (activeBindings.length === 1) { return { binding: activeBindings[0] ?? null,