fix(agents): fail closed missing requester completion routes

This commit is contained in:
Peter Steinberger
2026-04-27 14:30:46 +01:00
parent 6956e8406d
commit 1382fb5bd7
4 changed files with 75 additions and 1 deletions

View File

@@ -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:<id>`. 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.

View File

@@ -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<string, unknown> };
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": {

View File

@@ -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",
},
},
{

View File

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