Skip to content

fix(orchestrator): eliminate duplicate events in session_pool.run_stream - #338

Open
Leoyzen wants to merge 12 commits into
mainfrom
fix/run-stream-dedup-events
Open

fix(orchestrator): eliminate duplicate events in session_pool.run_stream#338
Leoyzen wants to merge 12 commits into
mainfrom
fix/run-stream-dedup-events

Conversation

@Leoyzen

@Leoyzen Leoyzen commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Problem

The opencode client showed duplicated model output (every text token appearing twice), visible immediately after a slash/skill command (e.g. /lodestone). Access-log evidence showed the same SSE delta delivered twice per event, and two concurrent assistant messages with identical content.

Root Cause

SessionPool._run_stream_run_turn() (in src/agentpool/orchestrator/session_pool_runs.py) drained events from two sources simultaneously:

  1. run_handle.start() directly — which already publishes every event to the EventBus (ProtocolChannel.publishevent_bus.publish inside RunHandle._execute_turn)
  2. A direct EventBus subscription (bus_queue)

Each streamed event was therefore yielded twice to the consumer. The slash-command executor is the only production path consuming session_pool.run_stream(), which is why normal prompts (which go through _consume_run → EventBus-only) never showed the bug.

The double-path was introduced in a56b10419 (session-debt-cleanup #171) as a patch with the intent of capturing tool-published mid-turn events (SpawnSessionStart), but it incorrectly assumed start() does not publish to the same EventBus — a wrong-premise patch on top of the single-publish architecture.

Fix

Consume events from the EventBus subscription only — a single delivery path:

  • Drive start() as a background side-effect, discarding its yields — exactly like SessionController._consume_run (async for _ in gen: pass) and the ACP path.
  • Yield from bus_queue until StreamCompleteEvent / RunErrorEvent.
  • finally: cancel the driver task, gen.aclose(), unsubscribe, clear current_run_id, pop the run handle.

This unifies run_stream with the _consume_run / ACP architecture: one source of truth (EventBus), each event delivered exactly once, ordering preserved, tool-published events still delivered.

Verification

  • New regression test tests/team_mode/test_session_pool_run_stream_no_dup.py (@pytest.mark.integration): drives a real AgentPool + FunctionModel streaming known chunks through session_pool.run_stream() and asserts the concatenated text matches the source exactly once. Failed on the old code (each chunk doubled), passes with the fix.
  • tests/team_mode/ suite: 318 passed.
  • ruff check / ruff format clean; mypy clean on the changed file.

Leoyzen added 11 commits July 31, 2026 23:48
SessionPool._run_stream_run_turn() previously drained events from BOTH
run_handle.start() (which publishes every event to the EventBus via
ProtocolChannel.publish) AND a direct EventBus subscription, delivering
each streamed event twice to the consumer. This surfaced in the opencode
client as duplicated model output after slash/skill commands, which are
the only production path consuming session_pool.run_stream().

Fix: drive start() as a background side-effect (discarding yields,
exactly like SessionController._consume_run) and consume events from the
EventBus subscription only — a single delivery path, matching the
_consume_run/ACP architecture. Tool-published mid-turn events
(SpawnSessionStart) arrive on the same EventBus path, preserving order.

Adds an integration regression test asserting concatenated text deltas
through session_pool.run_stream() match the model output exactly once.
Previous approach used a background asyncio task to drive start() while
consuming from the EventBus subscription. This caused two regressions:

1. test_cross_provider_session_lifecycle (scope=descendants): the
   background task's CancelledError from our own cancel surfaced as a
   spurious run error.
2. test_subsequent_run_after_break: anyio cancel scopes cannot be exited
   from a different task, so cancelling the background driver deadlocked
   during GeneratorExit cleanup (180s hang).

Fix: drive start() in the CURRENT task (like the original dual-drain),
but yield events ONLY from the EventBus subscription (bus_queue), never
from gen directly. start() publishes every event to the EventBus before
yielding it (ProtocolChannel.publish runs before  in
_execute_turn), so each event is already in bus_queue when we drain it
with get_nowait(). This gives a single delivery path with no background
task, no cross-task cancel scope issues, and exactly-once events.
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