🐛 fix(kanban): resume orchestrator as a distinct turn on a busy-session wake - #29
Conversation
…on wake An origin-routed kanban-transition wake arrives at the platform adapter as a plain TEXT MessageEvent. When the origin session is mid-turn, handle_message takes the busy branch and either text-debounces the event or newline-merges it into the in-progress/queued turn via merge_pending_message_event(merge_text=True) — so the wake is absorbed silently instead of producing its own identifiable turn (the busy-session swallow). Tag the origin-routed wake on the webhook side (metadata["kanban_transition_wake"] = True), keyed on the emitter-stamped origin_* fields so ordinary webhook routes are unaffected. A pure predicate is_transition_wake_event reads the flag; a wake-precedence branch in merge_pending_message_event keeps the wake in the single pending slot un-merged and un-clobbered (a pending wake is never overwritten by a later non-wake, and an incoming wake replaces a pending non-wake intact — a dropped autonomy wake is worse than a user follow-up the user can resend); and the busy branch queues the wake without debounce/merge so the existing in-band drain cascade runs it as a distinct turn. Cache- and alternation-safe: no system-prompt or message-history mutation, no new drain site, no synthetic mid-loop user message, _active_sessions lifecycle untouched — the wake is delivered as an ordinary next-turn user message via the same cascade every follow-up uses.
cwest
left a comment
There was a problem hiding this comment.
Verified end to end. The busy-branch bypass sits in the right place inside the active-session block: after the command and clarify intercepts and the photo branch, before the text-debounce path, so a wake (a plain text event, no command) reaches it and gets queued un-merged instead of being debounced or newline-merged into an unrelated turn. The single-slot precedence in merge_pending_message_event is complete across all four cases — pending wake plus incoming non-wake drops the non-wake, an incoming wake always takes the slot intact, and two non-wakes still fall through to the existing media/text merge untouched. The webhook tag keys on origin_platform + origin_chat_id, the same discriminator _build_origin_source already uses and a field pair only the transition emitter stamps, so GitHub, monitoring, and cron routes are never tagged.
Proof I ran, not assumed:
- RED->GREEN confirmed: with the busy-branch bypass removed from a throwaway checkout at the head SHA, test_busy_session_queues_wake_un_merged_without_debounce fails with "wake was swallowed (debounced or dropped)"; restored, all 11 tests across the two touched files pass.
- Regression: 203 passed, 0 failed across the pending-merge, busy-session, text-batching, photo-interrupt, drain-race, internal-event, thread-origin, and webhook test modules — every path that would break if the precedence guard or the bypass disturbed ordinary merging. The wider gateway suite's remaining reds are the pre-existing optional-platform env gaps (whatsapp/matrix/wecom) unrelated to this change.
- No new secrets, injection, eval/exec, or unsafe deserialization in the added lines. metadata is a default_factory dict, so the tag assignment is safe.
Invariants hold: the wake rides the same next-turn drain cascade every follow-up uses, so no synthetic mid-loop message, no new drain site, no system-prompt or history mutation, and the _active_sessions lifecycle is untouched. Merge-readiness is green — mergeable, all required checks passing, no unresolved threads, mergeStateStatus CLEAN. Ready to merge.
…on wake (#29) An origin-routed kanban-transition wake arrives at the platform adapter as a plain TEXT MessageEvent. When the origin session is mid-turn, handle_message takes the busy branch and either text-debounces the event or newline-merges it into the in-progress/queued turn via merge_pending_message_event(merge_text=True) — so the wake is absorbed silently instead of producing its own identifiable turn (the busy-session swallow). Tag the origin-routed wake on the webhook side (metadata["kanban_transition_wake"] = True), keyed on the emitter-stamped origin_* fields so ordinary webhook routes are unaffected. A pure predicate is_transition_wake_event reads the flag; a wake-precedence branch in merge_pending_message_event keeps the wake in the single pending slot un-merged and un-clobbered (a pending wake is never overwritten by a later non-wake, and an incoming wake replaces a pending non-wake intact — a dropped autonomy wake is worse than a user follow-up the user can resend); and the busy branch queues the wake without debounce/merge so the existing in-band drain cascade runs it as a distinct turn. Cache- and alternation-safe: no system-prompt or message-history mutation, no new drain site, no synthetic mid-loop user message, _active_sessions lifecycle untouched — the wake is delivered as an ordinary next-turn user message via the same cascade every follow-up uses. (cherry picked from commit c5b1f1f)
Why
An origin-routed kanban-transition wake arrives at the platform adapter as a plain TEXT
MessageEvent. When the origin session is mid-turn,handle_message(gateway/platforms/base.py) takes the busy branch and either text-debounces the event or newline-merges it into the in-progress/queued turn viamerge_pending_message_event(merge_text=True)— so the wake is absorbed silently instead of producing its own identifiable turn (the busy-session swallow). Verified live: a transition emitted + the wake POSTed 202 to the correct origin session, but the woken run did not resume as a distinct actionable turn because the session was busy at emit time.What
Make a kanban-transition wake produce its own distinct turn even when the origin session is busy — never queue-merged into an unrelated in-progress turn.
event.metadata["kanban_transition_wake"] = True, keyed on the emitter-stampedorigin_*fields (_is_transition_wake_payload) so ordinary webhook routes (GitHub PR, monitoring, cron) are never tagged.is_transition_wake_event(event)reads the flag.merge_pending_message_event: the single pending slot holds one event and the wake wins — a pending wake is never overwritten by a later non-wake, and an incoming wake replaces a pending non-wake intact (never appended). A dropped autonomy wake is worse than a user follow-up the user can resend.handle_message: a transition wake is queued un-merged (no debounce, no text-merge) so the existing in-band drain cascade runs it as a distinct turn.Invariants preserved: prompt caching, strict role alternation, byte-stable system prompt. No system-prompt or message-history mutation, no new drain site, no synthetic mid-loop user message,
_active_sessionslifecycle untouched — the wake is delivered as an ordinary next-turn user message via the same cascade every follow-up uses.Scope: this PR is ONLY the distinct-turn delivery. The self-announcing wake banner and latency tightening are separate follow-up work.
Tests
RED→GREEN proven: with the busy-branch bypass removed,
test_busy_session_queues_wake_un_merged_without_debouncefails withwake was swallowed (debounced or dropped); with the fix it passes.tests/gateway/test_transition_wake_busy_session.py(new): predicate, merge-precedence (both directions + regression that ordinary text still merges), busy-branch queues the wake un-merged.tests/gateway/test_webhook_origin_routing.py: addedtest_wake_event_is_tagged_for_busy_bypass.22 tests across the touched files pass via
scripts/run_tests.sh. Fulltests/gateway/suite: the only failures are 6 pre-existing environment/platform artifacts (macOS/tmp→/private/tmppath canonicalization, POSIX subprocess-spawn timing, an async race, a 48-worker FD-ulimitToo many open files, and 3 wecom tests needing an uninstalled optional extra) — proven identical on the cleancwest/integrationbase with these changes stashed, so this change introduces zero regressions.