fix(opencode): eliminate EventBus loopback causing duplicate SSE projections - #403
fix(opencode): eliminate EventBus loopback causing duplicate SSE projections#403Million-mo wants to merge 1 commit into
Conversation
…ections (#391) * fix(opencode): eliminate EventBus loopback causing duplicate SSE projections The OpenCodeEventBridge republished protocol projections (MessageUpdatedEvent, PartUpdatedEvent, etc.) back into the same EventBus that carries native agent events. This created a feedback loop: native events → event bridge → broadcast → EventBus republish → SSE delivery, causing duplicate renders in attached OpenCode TUI clients (issue #380). Architecture change — direct-wire SSE: - state.broadcast_event() now fans projections directly to per-connection SSE subscriber queues instead of republishing to EventBus - global_routes._event_generator reads from state.event_subscribers queues (no EventBus subscription, no CustomEvent unwrapping) - Reconnect replay via state.replay_projections() using Last-Event-ID - Deleted event_bridge.py (the loopback republisher) EventBus source isolation (defense-in-depth): - EventEnvelope gains source_hint field; publish() accepts source_hint - subscribe() accepts exclude_source param (filters both live fanout and replay) - ProtocolEventConsumerMixin hooks: _get_subscription_replay() and _get_subscription_exclude_source() (defaults: replay=True, exclude=None) - OpenCode overrides: replay=False, exclude_source={"opencode_event_bridge"} Session consumer replay alignment: - OpenCode session-level consumers now use replay=False (matching the global SSE endpoint's first-connect policy), preventing stale events from being redelivered on consumer startup Testing: - 7082 tests passed (full suite), ruff/mypy clean - New e2e test: test_attach_existing_session_first_prompt_renders_once - New unit tests: EventBus source_hint/exclude_source (4 tests) - Rewritten integration tests for direct-wire SSE model Note: A residual TUI-side duplication may still be visible in opencode attach mode due to the TUI's local echo (createUserMessage) not matching the server-generated message ID in the SSE event. This is tracked as an opencode TUI bug (anomalyco/opencode#14372, #24773, #29478) with upstream fix PR #31945 still unmerged. The server-side fix in this commit eliminates the EventBus loopback path; the remaining duplication is purely client-side. * fix(opencode): address review — QueueFull policy, typed session-id extraction, mock alignment Review-driven fixes on PR #391 (direct-wire SSE loopback elimination): BLOCKER: broadcast_event now handles asyncio.QueueFull per-subscriber with the same drop-oldest policy as EventBus._enqueue, so a stalled SSE client can no longer abort fanout to every other subscriber. Adds structured warning logging on overflow plus a debug fanout log (telemetry on the delivery critical path). New regression test: test_broadcast_event_drop_oldest_on_queue_full. MAJOR: ServerState.extract_session_id now delegates to the typed global_routes._extract_session_id (match-based, no getattr) instead of the getattr probe that read info.id for MessageUpdatedEvent — buffering message.updated under per-message keys and leaking memory. Typed variant reads props.info.session_id as documented. Test mocks in test_global_event.py / test_sse_compliance.py aligned with production: deque(maxlen=100) buffers, drop-oldest overflow, typed extractor — the queue-full behavior is no longer suppressed out of the green suite. MINOR: replay_projections replays merged buffers in global event_id order (monotonic SSE ids for reconnecting clients) and stops with a warning on QueueFull instead of silently dropping a suffix. MINOR: _get_subscription_exclude_source documented as currently inert (its only producer was deleted with the loopback bridge; kept as defense-in-depth, exercised by unit tests). Nits: stale event_bridge docstrings/comment/names updated across test_event_pipeline_e2e.py and conftest.py; corrected the pre-existing dedup-set claim in src/wolfharness/AGENTS.md (the set is a private ACPEventConverter field, not on SessionController); changelog trailing newline; ADR eventbus-replay.md annotated as superseded by the direct-wire design (PR #391). Verified: ruff clean, mypy strict clean (686 files), 326 affected tests pass.
Review: fix(opencode) — eliminate EventBus loopback (PR #403)Reviewed against Below are the findings, prioritized. Nothing here is a blocker on the loopback fix itself; the first item is a real leak worth fixing before merge. MAJOR —
|
|
Review posted: #403 (comment) Summary of the review — the loopback fix itself is sound (atomic direct-wire fanout, no ordering races, strict-superset Code
Docs
|

Replacement for #391 (original was merged but got caught in main rollback; #399 auto-merged into wrong base).
Eliminate EventBus loopback causing duplicate SSE projections. Remove
event_bridge.py(replaced by direct event mapping).19 files, +918/-638 — matches original #391 exactly.
Depends on #402 (clean #382). Review #402 first.