fix(review): TUI sink must never backpressure the orchestrator · Entire

fix(review): TUI sink must never backpressure the orchestrator

a91d234→main·

payment-alt·1w ago·2 files·+263 added/-28 removed

During a live full-crew dogfood run (2026-07-07, run 6), the parent entire process wedged mid-run: the TUI stopped rendering (elapsed froze at 11m58s, spinner ticks dead), the armed --timeout 20m never surfaced, no judge ran, and the process had to be killed externally 15 minutes later. The mechanism is structural: Program.Send is an unbuffered BLOCKING send, and TUISink called it directly from the orchestrator's serial dispatch goroutine — so any stall in the Bubble Tea Update/render pipeline freezes sink dispatch, the bounded fanIn drain loop, the forwarding goroutines, the stdout parsers, and reviewer-timeout event handling with it. (The stall's own trigger was not reproduced in three targeted shim experiments; this closes the amplification path that turned a display stall into a full orchestrator freeze.)

TUISink now enqueues onto a bounded internal queue drained by a pump goroutine — the only goroutine allowed to block on Send. Display events (AgentEvent) never block: overflow beyond the 4096-message cap is dropped and counted. Rare control messages (run summary, phase transitions, quit) use a bounded wait, and PostRunComplete keeps its Kill fallback, so a wedged TUI degrades to lost frames and a stale footer instead of a hung run. Order is preserved through the single queue for a healthy program.

Tests inject a deterministically wedged teaRunner: AgentEvent must complete 3x the queue cap without blocking, control messages must return within their bounded wait, and a recording runner pins FIFO delivery.

Co-Authored-By: Claude Fable 5 noreply@anthropic.com

Sessions

01KX0HNDFDWYN2F02BKXA5AJ6AView transcript

Changes

2

17 unmodified lines

// teaRunner is the slice of *tea.Program the sink depends on, extracted so  
// tests can substitute a program with a deterministically stalled event loop.  
type teaRunner interface {  
    Run() (tea.Model, error)  
    Send(msg tea.Msg)  
    Kill()  
}

// tuiSinkQueueCap bounds the sink's internal dispatch queue. Program.Send is  
// an unbuffered BLOCKING send: if the Bubble Tea Update/render pipeline ever  
// stalls, a direct Send from the orchestrator's dispatch goroutine parks  
// forever — freezing sink dispatch, the fanIn drain loop, the parsers, and  
// reviewer-timeout handling with it (observed live: the 2026-07-07 run-6  
// wedge, where the TUI froze mid-run and a 20m --timeout never surfaced).  
// The queue absorbs bursts; overflow beyond the cap is dropped and counted —  
// a display that can lag must never backpressure the data plane.  
const tuiSinkQueueCap = 4096

// TUISink is a Sink that renders a Bubble Tea dashboard. The orchestrator  
// calls AgentEvent/RunFinished from a single goroutine (CU4 serial-dispatch  
// contract); the sink translates each event into a tea.Msg and sends it via  
// Program.Send. Bubble Tea's Send is thread-safe, but we never rely on that  
// property — the serial-dispatch promise means Send is only called from the  
// orchestrator's dispatch goroutine.  
// contract); the sink translates each event into a tea.Msg, enqueues it on a  
// bounded internal queue, and a pump goroutine forwards it via Program.Send —  
// so only the pump can ever block on a stalled Bubble Tea loop, never the  
// orchestrator.

// Cancellation: cancel is the same context.CancelFunc that controls the  
// orchestrator's run context. The first KeyCtrlC in the dashboard fires this
// root's context, which cancels the same function — no parallel signal.Notify  
// goroutine is needed here.

// Compile-time interface check.

// wedgedProgram is a teaRunner whose event loop never consumes messages:
// Send blocks until Kill, modeling a Bubble Tea program whose Update/render
// pipeline has stalled (the 2026-07-07 run-6 incident shape). Run blocks
// until Kill so the sink's done channel behaves like a live program's.

// TestTUISink_AgentEventNeverBlocksWhenProgramLoopIsWedged pins the wedge
// hardening: a stalled Bubble Tea loop must never backpressure the
// orchestrator. Before the fix, the first AgentEvent after the stall parked
// forever inside Program.Send, freezing sink dispatch, the fanIn drain loop,
// the parsers, and reviewer-timeout handling with them.
func TestTUISink_AgentEventNeverBlocksWhenProgramLoopIsWedged(t *testing.T) {
t.Parallel()
prog := newWedgedProgram()
sink := newTUISinkWithProgram(prog)
sink.Start()
defer func() {
prog.Kill()
sink.Wait()
}()

finished := make(chan struct{})  
go func() {  
    for range 3 * tuiSinkQueueCap {  
        sink.AgentEvent("agent-a", reviewtypes.AssistantText{Text: "x"})  
    }  
    close(finished)  
}()

select {
case <-finished:
case <-time.After(5 * time.Second):
t.Fatal("AgentEvent blocked on a wedged TUI loop — orchestrator freeze")
}

if got := sink.droppedCount(); got == 0 {
t.Error("expected overflow drops to be counted when the queue jams")
}
}

// TestTUISink_EventsReachProgramInOrder pins that the async pump preserves
// dispatch order for a healthy program. func TestTUISink_EventsReachProgramInOrder(t *testing.T) {
t.Parallel()
prog := newRecordingProgram()
sink := newTUISinkWithProgram(prog)
sink.Start()
defer func() {
prog.Kill()
sink.Wait()
}()

for i := range 50 {
sink.AgentEvent("agent-a", reviewtypes.AssistantText{Text: string(rune('a' + i%26))})
}
sink.RunFinished(reviewtypes.RunSummary{})

deadline := time.After(5 * time.Second)
for {
msgs := prog.recorded()
if len(msgs) >= 51 {
for i := range 50 {
if _, ok := msgs[i].(agentEventMsg); !ok {
t.Fatalf("msgs[%d] = %T, want agentEventMsg", i, msgs[i])
}
}
if _, ok := msgs[50].(runFinishedMsg); !ok {
t.Fatalf("msgs[50] = %T, want runFinishedMsg (order violated)", msgs[50])
}
return
}
select {
case <-deadline:
t.Fatalf("only %d/51 messages reached the program", len(msgs))
case <-time.After(10 * time.Millisecond):
}
}
}

// TestTUISink_RunFinishedBoundedWhenWedged pins that control messages use a
// bounded wait rather than blocking forever when the queue is jammed.
func TestTUISink_RunFinishedBoundedWhenWedged(t *testing.T) {
t.Parallel()
prog := newWedgedProgram()
sink := newTUISinkWithProgram(prog)
sink.Start()
defer func() {
prog.Kill()
sink.Wait()
}()

// Jam the queue.
for range 2 * tuiSinkQueueCap {
sink.AgentEvent("agent-a", reviewtypes.AssistantText{Text: "x"})
}

finished := make(chan struct{})
go func() {
sink.RunFinished(reviewtypes.RunSummary{})
close(finished)
}()
select {
case <-finished:
case <-time.After(tuiPostRunCompleteGrace + 3*time.Second):
t.Fatal("RunFinished blocked past its bounded wait on a wedged TUI")
}
}


Mcmd/entire/cli/review/tui_sink_test.go+168