fix(opencode): eliminate EventBus loopback causing duplicate SSE projections - #399
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.
The except Exception block logged but swallowed startup errors (e.g. OSError: Address already in use), causing silent exit 0. Add raise after log.exception to propagate to the CLI. Fixes CI failure in test_start_async_propagates_serve_oserror.
Review: fix(opencode) — eliminate EventBus loopback (PR #399)Reviewed both commits ( Overall: the #391 loopback elimination itself is well-engineered — direct-wire SSE projection with BLOCKER1. PR includes the business-domain files its own description says were excluded. Both the PR body ("without wiki/business code") and the #382 commit message ("Business-domain wiki capability code… excluded — belongs in downstream project, not framework core") state these were removed, but this branch adds:
These have no place in the framework core and directly contradict the stated scope. They should be dropped from this branch (and, given their sensitivity, it's worth confirming they haven't been published elsewhere). The commit message is misleading — "68 files excluded" is not true for this PR. 2. serve-acp startup-failure fix isn't a fix — the changelog and regression test describe behavior the code doesn't implement. MAJOR (code)3. 4. Viking write-restriction regression. 5. MCP 6. ACP archive error net is incomplete; archive can break the protocol or drop data. 7. 8. Team-mode concurrency TOCTOU. 9. Sanitizer 10. Type-safety red lines violated in new code. New 11. DCP 12. Missing-SDK viking behavior silently loops. MINOR / NIT (docs)
Positive
RecommendationFix the two blockers (drop the business files; implement or correct the serve-acp propagation), then address #3–#12 before merge. The core #391 loopback fix is sound and can land once the scope/cleanup items are handled. |
|
Review posted: #399 (comment) Reviewed via three parallel subagents (two code reviews split by commit, one docs review), then verified the top claims directly against the source. Key findings in the comment:
Core #391 loopback fix itself was assessed as sound. |

Supersedes #391 (reverted during main rollback in PR #397 review).
Original PR #391 was merged but got caught in the force-push rollback that removed PR #382's business-domain wiki code from main. This PR re-submits only the framework fix.
Changes
event_bridge.py(replaced by direct event mapping)Note
This branch also includes the cleaned #382 framework commit (without wiki/ business code) as a dependency, since #391 was originally built on top of #382.