Skip to content

fix(opencode): eliminate EventBus loopback causing duplicate SSE projections - #403

Closed
Million-mo wants to merge 1 commit into
refactor/382-framework-onlyfrom
refactor/391-eventbus-loopback
Closed

fix(opencode): eliminate EventBus loopback causing duplicate SSE projections#403
Million-mo wants to merge 1 commit into
refactor/382-framework-onlyfrom
refactor/391-eventbus-loopback

Conversation

@Million-mo

Copy link
Copy Markdown
Collaborator

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.

…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.
@github-actions

Copy link
Copy Markdown

Review: fix(opencode) — eliminate EventBus loopback (PR #403)

Reviewed against refactor/382-framework-only base with the root AGENTS.md, subsystem AGENTS.md files, and the L1–L4 test pyramid. The core fix is sound and well-executed: removing OpenCodeEventBridge and fanning projections direct-wire from ServerState.broadcast_event() to per-connection SSE queues correctly kills the feedback loop (issue #380), the fanout is atomic (no await in the loop, so the replay/live ordering races we probed for cannot occur), and the new typed _extract_session_id match in global_routes.py is a strict superset of the deleted bridge's extractor (it also fixes the MessageUpdatedEvent mis-session case). No type-safety regressions: zero # type: ignore/cast introduced, and the pre-existing # ty: ignore suppressions at mixins.py:61 / global_routes.py:110 are NOT from this PR.

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 — ServerState._projection_buffers grows unbounded (never cleaned)

state.py:85-87, 409-412 append a deque(maxlen=100) key per session, and nothing ever pops it (grep confirms no cleanup anywhere; delete_session/close_session don't touch it, unlike EventBus._replay_buffers which is popped by close_session/clear_replay_buffer). A long-lived server with session churn leaks ~100 full Pydantic event models per deleted session. Fix: pop the key in delete_session/close_session (or LRU-bound the dict) + add a regression test.

MINOR — Telemetry gap on the new critical delivery path

broadcast_event() (100+ call sites, now the sole OpenCode SSE delivery path) and _event_generator have no @logfire.instrument/span. This is a pre-existing gap (the old event_bus.publish path was also span-free), so not a regression — but since the PR rewrote the delivery path anyway, instrumenting it here would have been cheap and matches the AGENTS.md telemetry rule ("protocol entry points MUST be instrumented").

MINOR — source_hint/exclude_source is inert-by-design API surface

No production caller passes source_hint (grep: only event_bus.py, mixins.py, tests). So _get_subscription_exclude_source(){"opencode_event_bridge"}, the exclude_source plumbing, and the EventEnvelope.source_hint field are dead code — the "belt-and-suspenders" is decorative, since the belt was removed. It is documented as such (mixins.py:109-115), tested, and defensible as defense-in-depth, but it sits uneasily with the project's "no dead code" red line. Consider deferring the API until a real producer exists, or at minimum keep it as-is deliberately.

MINOR — Stale tests now model removed architecture

tests/test_sse_conditional_replay_integration.py and tests/servers/opencode_server/test_duplication_reproduction.py still publish CustomEvent(source="opencode_event_bridge", ...) and describe the EventBus-subscription SSE path this PR eliminated. They still pass (EventBus replay primitives are unchanged) so they must not be deleted, but they mislead — and opencode_event_bridge.py:161 cites test_duplication_reproduction.py as documentation of a race it no longer models. Repurpose (neutral payloads, reframed as EventBus-API contract tests) or update the docstrings.

RISK (low) — replay_projections aborts on QueueFull

state.py:479-487: merged buffers (up to N sessions × 100) can exceed the maxsize=1000 subscriber queue; a reconnecting client then loses the replay suffix with only a warning. Minor for current scale; a follow-up could truncate instead of abort.

RISK (low) — replay=False events-lost window

opencode_event_bridge.py:152-166: the session consumer now subscribes with replay=False, relying on the invariant that no native event is published before the consumer subscribes (safe today — the buffer is empty at subscribe time, and resume uses _resume_contexts, not EventBus replay). This invariant is implicit and untested; a future publish-before-consume path (e.g. steer) would silently drop events. Worth a comment or test pinning it.

Docs findings (see below for full detail)

  • src/wolfharness_server/AGENTS.md:116 — stale "Dedup Mechanism" paragraph (dict[str, set[str]] on SessionController passed to the converter) directly contradicts the paragraph this PR rewrote at src/wolfharness/AGENTS.md:158. The PR updated one copy and left the other — MUST-FIX.
  • src/wolfharness/AGENTS.md:158 — newly rewritten sentence has two slips: _displayed_message_ids is a plain set[str] on the converter (not "keyed by session_id", event_converter.py:269) and is populated by the converter (event_converter.py:607), not by _emit_user_message_chunks(). MUST-FIX within PR scope.
  • docs/ops/opencode-handler.md — active runbook describing a deleted OpenCodeProtocolHandler/handler.py with internals that no longer exist; now doubly stale after this architecture change.
  • docs/records/audit/opencode-client-audit.md — claims "no event IDs / no replay" (now false: global_routes.py:281 yields id:, lines 243-245 replay), references deleted _broadcast_event_impl, and links to the non-existent docs/design/eventbus-replay.md (actual: docs/adr/eventbus-replay.md). Update or archive.
  • docs/explanation/ — no page documents the direct-wire SSE architecture / source_hint provenance mechanism; an explanation page is warranted per docs/meta/documentation-guide.md (would need an mkdocs.yml nav entry).
  • docs/rfcs/draft/RFC-0057 (:127,859,908) and test_duplicate_message_id.py:102 — stale line-number references. LOW (draft/historical).

What checked out clean

  • EventBus source_hint/exclude_source: replay-path filtering, live fanout, _rebind preservation, and _SubscriberEntry consistency all correct.
  • _event_generator: queue lifecycle (append/remove/shutdown atomic on the event loop), replay-before-connected ordering, heartbeat timeout, and first-subscriber trigger all correct; no interleaving/duplicate risk.
  • Removing the C4 CustomEvent skip is safe — no production producer emits a CustomEvent into the EventBus anymore (event_emitter.custom() has zero callers; the AG-UI CustomEvent is a different type), so restoring C3 semantics is consistent.
  • Other ProtocolEventConsumerMixin subclasses (ACP, AG-UI, OpenAI API) inherit the unchanged defaults and are unaffected.
  • Changelog entry, ADR supersede notice, src/wolfharness_server/AGENTS.md:23 pipeline row, and link integrity all verified accurate; all three referenced commit hashes exist.
  • New tests are well-typed and meaningful, including a solid L4a reproducer (test_user_message_no_duplicate_e2e.py) and a QueueFull-abort regression test.

Top actions: (1) add _projection_buffers cleanup + test; (2) fix the two contradicting/stale dedup paragraphs in AGENTS.md; (3) instrument broadcast_event/_event_generator; (4) repurpose or reframe the two stale EventBus-replay tests; (5) update/archive the stale docs/ops/opencode-handler.md and docs/records/audit/opencode-client-audit.md.

@github-actions

Copy link
Copy Markdown

Review posted: #403 (comment)

Summary of the review — the loopback fix itself is sound (atomic direct-wire fanout, no ordering races, strict-superset _extract_session_id, zero type-safety regressions). Findings, verified against source:

Code

  • MAJOR: _projection_buffers in state.py is never cleaned up — per-session deques leak on session churn (unlike EventBus._replay_buffers).
  • MINOR: no logfire instrumentation on the new critical path (broadcast_event / _event_generator) — pre-existing gap, but cheap to fix here.
  • MINOR: source_hint/exclude_source is inert-by-design — no production caller passes source_hint, so the "belt-and-suspenders" is decorative.
  • MINOR: two tests (test_duplication_reproduction.py, test_sse_conditional_replay_integration.py) model the removed EventBus-SSE architecture; still pass, but misleading.
  • LOW RISK: replay_projections aborts replay on QueueFull; replay=False consumer relies on an untested publish-before-subscribe invariant.

Docs

  • MUST-FIX: src/wolfharness_server/AGENTS.md:116 contradicts the paragraph this PR rewrote at src/wolfharness/AGENTS.md:158 (the updated sentence also mis-attributes _displayed_message_ids population).
  • docs/ops/opencode-handler.md describes a deleted class; docs/records/audit/opencode-client-audit.md claims no SSE replay (now false) with broken links; no docs/explanation/ page covers the new architecture.

New%20session%20-%202026-08-28T08%3A37%3A23.524Z
opencode session  |  github run

@Million-mo Million-mo closed this Aug 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant