fix(gateway): dispatch origin-routed wake on the owning platform adapter - #34
Conversation
An origin-routed kanban-transition wake carries a SessionSource whose platform is the ORIGIN platform (e.g. discord) and targets a session owned by that platform's adapter. The webhook receiver was processing the wake with self.handle_message(event) — running it on the WEBHOOK adapter, not the origin adapter. Each adapter keeps its own _active_sessions / _pending_messages. Processed on the webhook adapter, the wake never sees the origin session's busy state: it takes the cold path, spawns a turn via the shared runner, collides with the already-running origin turn at the runner's _running_agents guard, and is silently dropped — no turn_context line, banner never surfaces. This is why the adapter-level wake fixes never took effect: they ran on the wrong adapter instance. Dispatch an origin wake through gateway_runner.adapters[origin_platform] (the reference already wired for cross-platform delivery) so busy-detection, the wake-precedence pending slot, and the post-turn drain all operate on the same session state as the running origin turn. Fall back to the webhook adapter when the owning adapter can't be resolved (backward-compatible; ordinary non-origin webhook events are unaffected). Adds regression tests that drive the real handle_message on two independent adapter instances: proving the wake reaches the owning adapter's pending slot while the origin session is busy, and demonstrating the pre-fix bug where the wrong adapter leaves the owner's pending slot empty.
cwest
left a comment
There was a problem hiding this comment.
The root cause here is right and the fix reads correctly. source is origin_source on the wake path, so keying the adapter lookup on source.platform resolves the origin platform's adapter, the owner is not self guard covers the webhook-is-origin edge, and the fallback to self keeps ordinary webhook events on their current path. I traced all of that against the file at head and ran the new suite locally: 5 passed.
One gap before this is done, plus a readiness note.
The routing decision that actually ships lives in _handle_webhook, but the three routing tests exercise a hand-copied _select_dispatch_adapter rather than that code. If the real block drifts (say source.platform becomes origin_source.platform, or the self-guard changes), those tests stay green while production breaks. The end-to-end pair (tests 4 and 5) drive the real handle_message, which is good, but nothing drives the real selection path in _handle_webhook. test_webhook_integration.py's cross-platform test already has the harness for this: a TestClient POST through the live route with a mocked gateway_runner.adapters and handle_message stubbed to capture which adapter it lands on. Anchoring one test to the real _handle_webhook and asserting the origin wake reaches the owning adapter's handle_message would close it and let the copy go.
Readiness: the PR is still a draft and the CI test slices are in progress (mergeStateStatus is UNSTABLE), so it isn't ready to land yet independent of the above.
Add two integration tests that POST an origin-wake payload through the live aiohttp route (WebhookAdapter._handle_webhook) with a mocked gateway_runner.adapters registry, and assert the SHIPPED dispatch-adapter selection routes to the owning platform adapter (not the webhook adapter), plus the fallback-to-webhook path when no owner is registered. Closes the coverage gap where the routing unit tests asserted against a hand-copied selection helper that could drift from production without failing. Verified the new integration test fails against the pre-fix dispatch (owner never called) and passes with it.
cwest
left a comment
There was a problem hiding this comment.
The gap from the last round is closed. The two new integration tests POST an origin-wake payload through the live aiohttp route and drive the shipped selection block in _handle_webhook directly, with a mocked adapters registry — so the routing decision is verified against production code, not a hand-copied re-implementation that could drift. I confirmed they bite: neutralizing the fix (forcing dispatch back onto self) turns test_real_handle_webhook_routes_origin_wake_to_owning_adapter red, and the fallback test stays green as it should. Full file at head passes, 7 tests, and CI is green.
One thing left before this is ready, and it is bookkeeping rather than code: the two review threads from the last round are still open. Please reply on each and resolve them so the conversation state matches the work. Once those are resolved this is done from my side.
cwest
left a comment
There was a problem hiding this comment.
This is ready. The routing fix is correct: on the wake path the source matches the origin, so the owning platform's adapter is resolved and the wake is dispatched there instead of on the webhook adapter that took the cold path before. The owner-is-self and owner-missing edges both fall back cleanly to the existing behavior.
What sealed it this round is the coverage. There's now a test that drives the real _handle_webhook selection block through the live route, not a hand-copied stand-in, so the production path is exercised and drift will surface. Neutralizing the fix turns that test red while the fallback test stays green, which is the property you want from it.
Checks are all green, the branch merges cleanly, and the two earlier threads are resolved. No changes needed.
…ter (#34) * fix(gateway): dispatch origin-routed wake on the owning platform adapter An origin-routed kanban-transition wake carries a SessionSource whose platform is the ORIGIN platform (e.g. discord) and targets a session owned by that platform's adapter. The webhook receiver was processing the wake with self.handle_message(event) — running it on the WEBHOOK adapter, not the origin adapter. Each adapter keeps its own _active_sessions / _pending_messages. Processed on the webhook adapter, the wake never sees the origin session's busy state: it takes the cold path, spawns a turn via the shared runner, collides with the already-running origin turn at the runner's _running_agents guard, and is silently dropped — no turn_context line, banner never surfaces. This is why the adapter-level wake fixes never took effect: they ran on the wrong adapter instance. Dispatch an origin wake through gateway_runner.adapters[origin_platform] (the reference already wired for cross-platform delivery) so busy-detection, the wake-precedence pending slot, and the post-turn drain all operate on the same session state as the running origin turn. Fall back to the webhook adapter when the owning adapter can't be resolved (backward-compatible; ordinary non-origin webhook events are unaffected). Adds regression tests that drive the real handle_message on two independent adapter instances: proving the wake reaches the owning adapter's pending slot while the origin session is busy, and demonstrating the pre-fix bug where the wrong adapter leaves the owner's pending slot empty. * test(gateway): drive the real _handle_webhook origin-wake selection path Add two integration tests that POST an origin-wake payload through the live aiohttp route (WebhookAdapter._handle_webhook) with a mocked gateway_runner.adapters registry, and assert the SHIPPED dispatch-adapter selection routes to the owning platform adapter (not the webhook adapter), plus the fallback-to-webhook path when no owner is registered. Closes the coverage gap where the routing unit tests asserted against a hand-copied selection helper that could drift from production without failing. Verified the new integration test fails against the pre-fix dispatch (owner never called) and passes with it. (cherry picked from commit 51ab0ef)
The bug (the real root cause)
The three prior wake fixes (#29 distinct-turn, #32 busy-handler exemption, #33
stale-suppression exemption) were all correct — and all ran on the wrong
adapter instance, so none of them ever fired for a live wake.
An origin-routed kanban-transition wake carries a
SessionSourcewhoseplatformis the origin platform (discord), targeting a session owned by thediscord adapter. But the webhook receiver dispatched it with
self.handle_message(event)— running it on the webhook adapter.Each adapter keeps its own
_active_sessions/_pending_messages. On thewebhook adapter the origin session looks idle, so the wake takes the cold path,
spawns a turn via the shared runner, collides with the already-running origin
turn at the runner's
_running_agentsguard, and is silently dropped — noturn_context, banner never surfaces. Exactly the observed symptom.The fix
Dispatch an origin wake through
gateway_runner.adapters[origin_platform](thegateway reference already wired onto the webhook adapter for cross-platform
delivery), so busy-detection, the wake-precedence pending slot, and the post-turn
drain all operate on the same session state as the running origin turn. Falls
back to the webhook adapter when the owner can't be resolved (backward-compatible;
ordinary non-origin webhook events are unaffected).
How it was root-caused
Driving the real
BasePlatformAdapter.handle_messagein a standalone harnessproved the base-adapter logic was correct end-to-end (busy → queue → drain runs
the wake as a distinct turn). That eliminated base.py and pointed at the
adapter-instance split as the only remaining explanation, confirmed by reading
the webhook dispatch site.
Verification
tests/gateway/test_326ce4d1_dispatch.py(5 tests):handle_message: with the origin session busy onthe discord adapter, the wake is queued as a distinct pending turn there;
owner's pending slot empty.
cross-test-contamination flakes (telegram-markdown / shutdown-forensics /
memory-timer / busy-session-ack), all green in isolation and independent of
this change.
After merge
Requires a gateway restart (
sys.modules-cachedwebhook.py) before theend-to-end autonomous-wake path goes live.