fix(app): synthesize StreamStopped when the runtime stream closes without one - #4154
Conversation
…hout one The runtime documents the events channel close (not receipt of StreamStoppedEvent) as the terminal signal, and drops the event under back-pressure at teardown (LocalRuntime.finalizeEventChannel's non-blocking emit). A fast local model streaming faster than the synchronous per-delta SQLite writes reliably hits this. The chat page, supervisor, and leantui only clear their busy state on receipt of the event, so the TUI is left showing "Working…" forever until Esc. forwardRunStreamEvents and RunSkillFork now track whether a StreamStoppedEvent for the relevant session was actually forwarded during the drain loop, and synthesize one when the channel closes without it. Reason is canceled/error/normal depending on ctx state and whether a root ErrorEvent was seen. Also exports the turnEndReason* constants (TurnEndReasonNormal/Error/ Canceled) so app and the TUI stop duplicating those string literals. Fixes #4136. Root cause and fix plan validated in a companion investigation; the runtime-side drop (LocalRuntime.finalizeEventChannel) is fixed separately, tracing back to the best-effort emit introduced by #3074/#3275.
prove it |
|
🤖 Automated implementer agent — this comment was posted by the implementer bot from Docker Agentic Platform, not by a human developer Three pieces of evidence: 1. The bug report's own logs (#4136 itself). The reporter's debug log shows the sequence: 2. The code's own documented contract,
The actual emit: 3. A deterministic repro I ran locally against this exact code path (not committed — a throwaway ch := make(chan Event, 2) // small buffer to force overflow
sink := nonBlocking(NewChannelSink(ch))
sink.Emit(StreamStarted("s", "a"))
sink.Emit(StreamStarted("s", "a")) // buffer now full
sink.Emit(StreamStopped("s", "a", "normal")) // this call: proof time
close(ch)
// draining ch: 2 StreamStartedEvents, zero StreamStoppedEventOutput: A fast local model (vLLM, no network round-trip) streaming deltas faster than the consumer drains them (each delta triggers a synchronous SQLite write per the reporter's log timestamps) is exactly the burst that fills the 128-slot buffer and drops the |
|
There is no way to make it not be best effort? |
|
🤖 Automated implementer agent — this comment was posted by the implementer bot from Docker Agentic Platform, not by a human developer Yes — that's exactly what #4153 (companion PR, same effort) does: it changes This PR is deliberately independent of that fix, as defense-in-depth: even with #4153's bounded send, an emit can still legitimately be dropped (consumer genuinely gone, deadline exceeded) or a future regression could reintroduce a non-blocking path — this PR makes the app layer honor the runtime's own documented "channel close is the guaranteed terminal signal" contract directly, so the TUI/supervisor can't get stuck regardless of whether the runtime's emit succeeds. The two together: #4153 makes delivery much more reliable, this PR makes correctness not depend on delivery at all. |
🤖 Automated implementer agent — this comment was posted by the implementer bot from Docker Agentic Platform, not by a human developer
Fixes #4136 (TUI stuck on "Working…" after the stream ends).
Root cause (App-layer half)
The runtime documents the events channel close, not receipt of
StreamStoppedEvent, as the terminal signal — the event is emitted non-blockingly at teardown (LocalRuntime.finalizeEventChannel) and is dropped under back-pressure (e.g. a fast local model streaming faster than the synchronous per-delta SQLite writes can keep up). The chat page, supervisor, and leantui only clear their "Working…" state on receipt of that event, so the TUI is stuck until Esc.This traces back to the best-effort emit introduced by #3074/#3275. A separate, independent PR (Task A) fixes the runtime-side delivery guarantee in
pkg/runtime; this PR is the App-layer half — honoring the runtime's own documented "channel close is terminal" contract.Change
forwardRunStreamEvents(used byRun/Retry/RunWithMessage) andRunSkillFork's drain loop now track whether aStreamStoppedEventfor the relevant session was actually observed during the drain, and synthesize+forward one when the channel closes without it.canceledifctx.Err() != nil, elseerrorif a rootErrorEventwas seen, elsenormal; agent name falls back to the last observed root-session agent name, then toRuntime.CurrentAgentName.runtime.TurnEndReasonNormal/Error/Canceled(aliased internally) so app/TUI stop duplicating those string literals.Testing
TestForwardRunStreamEvents_SynthesizesRootStreamStopped: 5 scenarios (no stop, real stop, sub-session-only stop, ctx cancelled, root error) × all 3 entry points (Run/Retry/RunWithMessage).RunSkillForktests (synthesize-when-missing, no-duplicate-when-present).go build ./...,go test ./...,go test -race ./pkg/app/...,golangci-lint runall pass.