Filed as a follow-up to #77260 per @martingarramon's suggestion to split the
delivery telemetry bug from the visibleReplies regression. #77260 was closed
as the command-reply scope is fixed on main; this telemetry issue is still live.
Summary
deliverySucceeded flips to true whenever no per-payload onError fires,
including code paths that exit deliverOutboundPayloads without ever invoking
a channel adapter. Result: the gateway reports successful delivery for replies
that were never sent. There is no gateway.log line indicating suppression or
the silent no-op, so installs go dark for days without any signal.
Code references (current main)
src/agents/command/delivery.ts:357 — let deliverySucceeded = false; let deliveryHadError = false;
src/agents/command/delivery.ts:374 — deliveryHadError = true; (only set inside per-payload onError: markDeliveryError)
src/agents/command/delivery.ts:398 — deliverySucceeded = !deliveryHadError;
src/infra/outbound/deliver.ts:1084 — export async function deliverOutboundPayloads(...)
src/infra/outbound/deliver.ts:1140 — if (claimResult.status === "claimed-by-other-owner") { return []; } (early return, no onError invoked)
The !deliveryHadError semantics mean any return path that doesn't iterate
payloads — claim conflict, bindings-empty Discord threads, message_tool
plugin not in plugins.allow, etc. — returns to the caller with
deliveryHadError=false, and line 398 reports success.
Reproduced silent-success paths
- Claim conflict —
claimed-by-other-owner at deliver.ts:1140 returns [] directly. Caller flips deliverySucceeded=true.
- Discord thread bindings empty — when
~/.openclaw/discord/thread-bindings.json is missing or its entry was evicted by a session-store cap fire, resolveBoundThreadBinding(sessionKey) returns empty, the Discord adapter is never invoked, and the call returns without any onError firing. CLI --reply-to probe with --deliver returned deliverySucceeded: true while Discord API confirmed the bot's reply never posted (last 15 messages 100% inbound over 7 days).
message_tool plugin not in plugins.allow — same shape: adapter not invoked, no error, success reported.
In each case the trajectory shows session.started → ... → model.completed → trace.artifacts → session.ended with no delivery event between model.completed and session.ended, the session JSONL records the reply text, and deliverySucceeded returns true.
Why this matters
This is the difference between "agent looks broken for an hour" and "agent looks broken for 7 days." With deliverySucceeded=true lying, every operator-facing health check, log dashboard, and probe says delivery is fine, so nobody investigates. The actual missing message is only discoverable by querying the channel API directly (GET /channels/<id>/messages) and noticing the bot's reply isn't there.
This is structurally distinct from the message_tool_only regression in #77260:
Suggested fix shape
Default deliverySucceeded to false and only flip it true when an adapter explicitly succeeds (positive acknowledgment, not absence of error). Concretely:
- Track
deliveryAdapterSucceeded: boolean set by adapters on confirmed send.
- Replace
deliverySucceeded = !deliveryHadError with deliverySucceeded = deliveryAdapterSucceeded.
- Audit every early return in
deliverOutboundPayloads and downstream paths to confirm none of them reach the success flip without invoking an adapter.
- Add a
gateway.log line at WARN whenever delivery exits without adapter invocation, including the reason (claim-conflict, bindings-empty, tool-unavailable, etc.). Silent no-ops are the diagnostic killer here.
Suggested test additions
src/agents/command/delivery.test.ts — assert deliverySucceeded === false on the claimed-by-other-owner path.
src/infra/outbound/deliver.test.ts — assert deliverySucceeded === false when deliverOutboundPayloads exits without iterating payloads.
- A Discord-thread variant asserting
deliverySucceeded === false when resolveBoundThreadBinding returns empty.
- A
message_tool-not-in-plugins.allow variant asserting the same.
Environment of the original repro (from #77260)
- OpenClaw 2026.5.4 (built from
main, channel=dev)
- macOS 14.x, npm install
- Discord public thread (type=11) under guild text channel parent
messages.groupChat.visibleReplies = "message_tool" (default after doctor --fix)
message_tool plugin NOT in plugins.allow
Trajectory JSONL, session JSONL, and --reply-to probe transcript available
on request for regression-test seed data.
cc @martingarramon @vincentkoc — flagging per Martin's split suggestion in
#77260 (comment)
Filed as a follow-up to #77260 per @martingarramon's suggestion to split the
delivery telemetry bug from the visibleReplies regression. #77260 was closed
as the command-reply scope is fixed on main; this telemetry issue is still live.
Summary
deliverySucceededflips totruewhenever no per-payloadonErrorfires,including code paths that exit
deliverOutboundPayloadswithout ever invokinga channel adapter. Result: the gateway reports successful delivery for replies
that were never sent. There is no
gateway.logline indicating suppression orthe silent no-op, so installs go dark for days without any signal.
Code references (current main)
src/agents/command/delivery.ts:357—let deliverySucceeded = false; let deliveryHadError = false;src/agents/command/delivery.ts:374—deliveryHadError = true;(only set inside per-payloadonError: markDeliveryError)src/agents/command/delivery.ts:398—deliverySucceeded = !deliveryHadError;src/infra/outbound/deliver.ts:1084—export async function deliverOutboundPayloads(...)src/infra/outbound/deliver.ts:1140—if (claimResult.status === "claimed-by-other-owner") { return []; }(early return, no onError invoked)The
!deliveryHadErrorsemantics mean any return path that doesn't iteratepayloads — claim conflict, bindings-empty Discord threads,
message_toolplugin not in
plugins.allow, etc. — returns to the caller withdeliveryHadError=false, and line 398 reports success.Reproduced silent-success paths
claimed-by-other-owneratdeliver.ts:1140returns[]directly. Caller flipsdeliverySucceeded=true.~/.openclaw/discord/thread-bindings.jsonis missing or its entry was evicted by a session-store cap fire,resolveBoundThreadBinding(sessionKey)returns empty, the Discord adapter is never invoked, and the call returns without anyonErrorfiring. CLI--reply-toprobe with--deliverreturneddeliverySucceeded: truewhile Discord API confirmed the bot's reply never posted (last 15 messages 100% inbound over 7 days).message_toolplugin not inplugins.allow— same shape: adapter not invoked, no error, success reported.In each case the trajectory shows
session.started → ... → model.completed → trace.artifacts → session.endedwith no delivery event betweenmodel.completedandsession.ended, the session JSONL records the reply text, anddeliverySucceededreturnstrue.Why this matters
This is the difference between "agent looks broken for an hour" and "agent looks broken for 7 days." With
deliverySucceeded=truelying, every operator-facing health check, log dashboard, and probe says delivery is fine, so nobody investigates. The actual missing message is only discoverable by querying the channel API directly (GET /channels/<id>/messages) and noticing the bot's reply isn't there.This is structurally distinct from the
message_tool_onlyregression in #77260:a2f1d1dfd8ab.Suggested fix shape
Default
deliverySucceededtofalseand only flip ittruewhen an adapter explicitly succeeds (positive acknowledgment, not absence of error). Concretely:deliveryAdapterSucceeded: booleanset by adapters on confirmed send.deliverySucceeded = !deliveryHadErrorwithdeliverySucceeded = deliveryAdapterSucceeded.deliverOutboundPayloadsand downstream paths to confirm none of them reach the success flip without invoking an adapter.gateway.logline atWARNwhenever delivery exits without adapter invocation, including the reason (claim-conflict,bindings-empty,tool-unavailable, etc.). Silent no-ops are the diagnostic killer here.Suggested test additions
src/agents/command/delivery.test.ts— assertdeliverySucceeded === falseon theclaimed-by-other-ownerpath.src/infra/outbound/deliver.test.ts— assertdeliverySucceeded === falsewhendeliverOutboundPayloadsexits without iterating payloads.deliverySucceeded === falsewhenresolveBoundThreadBindingreturns empty.message_tool-not-in-plugins.allowvariant asserting the same.Environment of the original repro (from #77260)
main, channel=dev)messages.groupChat.visibleReplies = "message_tool"(default afterdoctor --fix)message_toolplugin NOT inplugins.allowTrajectory JSONL, session JSONL, and
--reply-toprobe transcript availableon request for regression-test seed data.
cc @martingarramon @vincentkoc — flagging per Martin's split suggestion in
#77260 (comment)