Skip to content

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

Merged
Million-mo merged 2 commits into
refactor/382-framework-onlyfrom
refactor/391-eventbus-loopback
Aug 28, 2026
Merged

fix(opencode): eliminate EventBus loopback causing duplicate SSE projections#399
Million-mo merged 2 commits into
refactor/382-framework-onlyfrom
refactor/391-eventbus-loopback

Conversation

@Million-mo

Copy link
Copy Markdown
Collaborator

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

  • Eliminate EventBus loopback causing duplicate SSE projections
  • Remove 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.

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

Copy link
Copy Markdown

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

Reviewed both commits (3f1fcdf #382 framework subset + 2a5535d #391 loopback fix) against origin/main. I verified the diff, cross-checked subagent findings against the code, and statically traced the critical paths (no venv/uv in the sandbox, so tests weren't executed — the failing-test claim below is from direct code tracing).

Overall: the #391 loopback elimination itself is well-engineered — direct-wire SSE projection with ServerState.broadcast_event(), per-connection queues, Last-Event-ID replay via replay_projections(), and source_hint/exclude_source EventBus isolation. The blocker issues are scope integrity and one fix that isn't actually a fix.


BLOCKER

1. 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:

  • WolfHarness情况说明_一页纸.md, WolfHarness项目情况说明与价值评估报告.md — confidential leadership reports to 三一集团/SANY leadership about an internal GitHub-upload incident ("正在配合公司流程处理(转私有/删除)")
  • assets/logo/c036e3d9-…png (1 MB) and assets/logo/dsh-TUI logo设计.png (2.5 MB)

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. changelog/unreleased/2026-08-27-serve-acp-silent-startup-failure.md claims "Startup errors now propagate to the CLI, which prints the error to stderr and exits non-zero." But ACPServer._start_async still swallows everything: src/wolfharness_server/acp_server/server.py:346-348 has except Exception: self.log.exception("ACP server error") with no re-raise, and serve_acp.py was not touched. The new tests/servers/acp_server/test_acp_server_error_propagation.py wraps _start_async() in pytest.raises(OSError) — since OSError is caught by that bare except Exception, the test will fail in CI with "DID NOT RAISE". Either re-raise after logging (or move the archive close() into a finally with re-raise) or correct the changelog/test.


MAJOR (code)

3. ServerState._projection_buffers is never pruned — server-lifetime memory leak. src/wolfharness_server/opencode_server/state.py:85-88,405-412 grows one deque(maxlen=100) of full OpenCode Event objects per session_id, and nothing ever pops it (delete_session at session_routes.py prunes the EventBus replay buffer but not this one). On a long-lived serve-opencode this grows unbounded (sessions × 100 event payloads). Prune on session delete/close.

4. Viking write-restriction regression. src/wolfharness/capabilities/viking/tools.py switches every viking_* write tool from _check_uri_allowed to _check_write_uri_allowed, and the new gate (viking/__init__.py:534-557) returns None when write_allowed_uri_prefixes is empty — the default. Existing configs that set allowed_uri_prefixes to scope writes silently lose all write restrictions with no warning or migration. Fall back to allowed_uri_prefixes when the new field is unset.

5. MCP tool_prefix dedup only applies on the provider path. src/wolfharness/mcp_server/manager.py:419-425 allocates non-colliding prefixes (kb_2…) for pool providers, but the get_capabilities() path (manager.py:799-812) uses the raw server.tool_prefix for PrefixedToolset/allowed_tools, so session-scoped/skill servers bypass dedup and can produce model-visible collisions. Centralize prefix allocation.

6. ACP archive error net is incomplete; archive can break the protocol or drop data. src/wolfharness_server/acp_server/viking_archive.py:18 _ERROR_TYPES omits AttributeError/KeyError, and _resolve_user_from_health (:175-185) calls the private SDK method client._request — a rename raises AttributeError out of flush_session's except, killing the restore path in a background task. Also handler.py:946-960 awaits record_update before session_update, so an archive escape skips the ACP notification. Add AttributeError, KeyError (or except Exception + exc_info=True) and move the send before the archive.

7. run.py event-handler dispatch swallows without logging and misses OSError. src/wolfharness/orchestrator/run.py:544-545,586-592 uses contextlib.suppress(ValueError, TypeError, RuntimeError, KeyError, AttributeError) — a bug in the handler vanishes with zero diagnostics, while OSError (disk-full in the per-session file handler this supports) is not in the tuple and would fail the whole turn. The sibling _deliver_to_event_handler (session_controller_runs.py:208-210) does it right (except Exception + logger.debug(exc_info=True)); mirror it.

8. Team-mode concurrency TOCTOU. src/wolfharness/capabilities/team_comm_capability.py:1618-1626 checks "one in_progress task per member" read-then-write outside the per-task lock; team_add_member (:2874-2898) leaks the task/session if register_member raises after binding. Move the active-task check into the locked update, wrap bind+register in compensating cleanup.

9. Sanitizer {} fallback can wipe legitimate non-JSON string args. src/wolfharness/orchestrator/event_mapper.py:613-635 now replaces any str that fails json.loads with "{}" — for tools whose arg schema is plain text this destroys real args (and TruncateToolCallInputs mid-escape truncation at compaction.py:390-417 then becomes silent data loss). Gate the fallback on args that look JSON-shaped ({/[).

10. Type-safety red lines violated in new code. New getattr(event, "type", "unknown") probes at state.py:438,446 (all Event members declare typed type: fields); duck-typed from_config(config: Any) at viking_archive.py:57-78 bypasses the new typed ACPVikingArchiveConfig; getattr/hasattr in handler.py:60,108-114, team_comm_capability.py:151-166, viking/tools.py:39, ingest.py. These are exactly what AGENTS.md forbids.

11. DCP _compact_on_critical guards the wrong exception. dcp/capability.py:899-900 catches AssertionError but ctx.deps.native_agent raises AttributeError when deps is None, breaking before_model_request with auto_compact_on_critical on.

12. Missing-SDK viking behavior silently loops. viking/__init__.py:120-125 masks a missing openviking-sdk, then _retry_failed_ingest_batches (:2162-2176) re-appends the same failed batch forever. Log a one-time error when a feature requires the SDK.


MINOR / NIT (docs)

  • src/wolfharness_server/AGENTS.md:116 still describes the old dedup design ("dict[str, set[str]] on SessionController… passed as displayed_message_ids") — the PR rewrote the same paragraph in src/wolfharness/AGENTS.md:158 and the code uses a per-converter _displayed_message_ids: set[str] (event_converter.py:269). The two subsystem docs now contradict each other and the code.
  • docs/ops/opencode-handler.md documents the removed architecture — references handler._event_bus_subscriptions, session_pool.event_bus.unsubscribe, and an EventBus-centric SSE flow; the exact runbook the loopback PR should have updated. Rewrite for the direct-wire model or archive it.
  • No docs/explanation/ page for the SSE delivery model (broadcast_event/replay_projections/source_hint). Covered by ADR + changelog, but session-orchestration.md is the natural home.
  • WOLFHARNESS_LOG_DIR (new in serve_opencode.py:57-61) is undocumented in docs/reference/cli/serve-opencode.md.
  • changelog/unreleased/README.md lists only 1 of 26 entries; 2026-08-19-atomic-team-member-task-binding.md is the only file deviating from the # Title format (uses YAML frontmatter).
  • docs/rfcs/draft/RFC-0055-dynamic-team-mode.md:992 references InitialMemberTask without defining its shape.
  • Dead config: ruff.toml:156 per-file-ignore for non-existent viking/ticket.py; pyproject.toml:448-449 mypy overrides for xeno_adp_* with zero imports in src/ — leftover from the excluded business code.
  • .opencode/opencode.json is malformed JSON ({"$schema": "…",} trailing comma, no trailing newline).
  • Dead test mocks (_MockEventBus/_MockSessionPool/_MockPool) in test_sse_compliance.py:77-136 and test_global_event.py:200-258; functional EventBus mock in opencode_server/conftest.py:69-90 silently drops source_hint/exclude_source.
  • # ty: ignore[unresolved-attribute] typo at global_routes.py:110; duplicate image_output is None assertion in test_model_capabilities.py; broadcast_event is async with no awaits; _projection_counter resets on restart (Last-Event-ID from a previous process).
  • Pre-existing (not PR-introduced, but worth fixing opportunistically): root AGENTS.md Context Loading table points to nonexistent docs/explanation/team-mode.md and docs/explanation/extending-wolfharness.md (real file: extending-agentpool.md); docs/records/audit/opencode-client-audit.md has three broken links to the moved ADR.

Positive

  • Loopback elimination is architecturally clean: the EventBus once again carries only native events, and replay_projections restores monotonic Last-Event-ID replay on the direct-wire path. The typed _extract_session_id match replaces the leaky info.id probe (real bug fix).
  • Conservative mixin defaults (replay=True, exclude=None) keep the blast radius limited to OpenCode.
  • Genuine behavioral tests: QueueFull drop-oldest, typed extractor, replay ordering, exclude_source live+replay, the e2e attach-reproducer, team lease/task-binding, MCP prefix dedup, truncation→{} recovery.
  • Changelog, ADR annotation, and src/wolfharness/AGENTS.md accurately document the design reversal and the residual TUI-side duplication.
  • Good structured telemetry on the delivery path (overflow warning + fanout debug log) without per-event span overhead.

Recommendation

Fix 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.

@github-actions

Copy link
Copy Markdown

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:

  • BLOCKER 1 — scope contradiction: the PR body and commit message claim business/wiki code was excluded, but the branch adds confidential SANY leadership reports (WolfHarness项目情况说明与价值评估报告.md, _一页纸.md) and two multi-MB logo PNGs.
  • BLOCKER 2 — serve-acp fix isn't real: the changelog and new test_acp_server_error_propagation.py assert startup errors propagate, but _start_async still swallows except Exception (server.py:346); the test will fail CI with "DID NOT RAISE".
  • MAJOR (code): unbounded _projection_buffers leak, Viking write-restriction regression, MCP prefix dedup gap, ACP archive error-net gap, run.py event-handler suppression, team-mode TOCTOU, sanitizer {} wipe, type-safety red-line violations, DCP wrong exception guard.
  • MINOR (docs): contradictory dedup descriptions across the two subsystem AGENTS.md files, stale docs/ops/opencode-handler.md, undocumented WOLFHARNESS_LOG_DIR, dead ruff.toml/pyproject.toml config, malformed .opencode/opencode.json.

Core #391 loopback fix itself was assessed as sound.

New%20session%20-%202026-08-28T08%3A02%3A22.920Z
opencode session  |  github run

@Million-mo
Million-mo changed the base branch from main to refactor/382-framework-only August 28, 2026 08:24
@Million-mo
Million-mo merged commit 793cdc8 into refactor/382-framework-only Aug 28, 2026
12 checks passed
@Million-mo
Million-mo deleted the refactor/391-eventbus-loopback branch August 28, 2026 08:31
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