Skip to content

Commit 842d514

Browse files
authored
fix: share teamwire event channel across concurrent triple-shots (#668)
When starting a second triple-shot, the old event channel was closed and replaced. The old listener's TeamwireChannelClosedMsg then nil'd out m.teamwireEventCh, causing subsequent re-subscriptions to block forever on a nil channel read. This left the second group visible but empty — its instances appeared as top-level items. Fix: reuse the existing event channel (all messages already carry a GroupID for demux), only start a new listener when there isn't one, and keep listening after completion when other coordinators are active. Also adds listenTeamwireCmd() nil-guard helper used by all teamwire handlers, preventing the nil-channel block-forever issue defensively.
1 parent 42bd330 commit 842d514

File tree

3 files changed

+384
-32
lines changed

3 files changed

+384
-32
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1919

2020
### Fixed
2121

22+
- **Multiple Triple-Shot Event Channel** - Fixed second triple-shot instances appearing outside their group. The teamwire event channel was replaced (old closed, new created) on each new triple-shot, causing the old listener's `TeamwireChannelClosedMsg` to nil out the new channel. Subsequent events blocked forever on a nil channel read. Now all coordinators share a single event channel (demuxed by GroupID), and `handleTeamwireCompleted` re-subscribes when the channel is still active.
23+
2224
- **TripleShot Implementer Auto-Collapse and Judge Nesting** - Fixed two TUI visual regressions introduced when Orchestration 2.0 became the default tripleshot execution path: (1) implementer instances were not auto-collapsed when the judge started because `ImplementersGroupID` was never set, and (2) the judge instance was not nested within the tripleshot group. Ported the legacy coordinator's group restructuring logic to `teamwire.TeamCoordinator.reorganizeGroupForJudge()` and added judge-to-group registration in the TUI handler.
2325

2426
- **TripleShot Combine Evaluation Parse Failure** - `FlexibleStringSlice` now handles LLM judge output that writes an array of objects (e.g., `[{"description":"...","source":"attempt_1"}]`) where flat strings were expected; also improved the judge prompt to show a populated `suggested_changes` example and explicitly require plain strings

internal/tui/tripleshot.go

Lines changed: 38 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -351,11 +351,17 @@ func (m Model) initiateTeamwireTripleShot(
351351
// Persist tripleshot session for potential restore
352352
m.session.TripleShots = append(m.session.TripleShots, coordinator.Session())
353353

354-
// Create buffered channel for callback → Bubble Tea bridge
355-
eventCh := make(chan tea.Msg, 16)
354+
// Reuse the existing event channel if one is already active.
355+
// Multiple triple-shot coordinators share a single channel, with each
356+
// message carrying a GroupID for demultiplexing.
357+
eventCh := m.teamwireEventCh
358+
needsNewListener := eventCh == nil
359+
if eventCh == nil {
360+
eventCh = make(chan tea.Msg, 16)
361+
}
356362
groupID := group.ID
357363

358-
// Register callbacks that write to the event channel
364+
// Register callbacks that write to the shared event channel
359365
coordinator.SetCallbacks(&tripleshot.CoordinatorCallbacks{
360366
OnPhaseChange: func(phase tripleshot.Phase) {
361367
eventCh <- tuimsg.TeamwirePhaseChangedMsg{GroupID: groupID, Phase: phase}
@@ -381,19 +387,6 @@ func (m Model) initiateTeamwireTripleShot(
381387
},
382388
})
383389

384-
// Stop existing runners before closing their event channel. Their
385-
// callbacks hold a reference to the old channel and would panic with
386-
// "send on closed channel" if they fire after close. Stop() cancels
387-
// contexts and waits for goroutines, ensuring no more callback writes.
388-
if m.tripleShot != nil {
389-
for id, runner := range m.tripleShot.Runners {
390-
if runner != nil {
391-
runner.Stop()
392-
}
393-
delete(m.tripleShot.Runners, id)
394-
}
395-
}
396-
397390
// Store coordinator in runners map BEFORE starting so event handlers
398391
// can find it when callbacks fire during Start().
399392
if m.tripleShot == nil {
@@ -407,11 +400,6 @@ func (m Model) initiateTeamwireTripleShot(
407400
m.tripleShot.Runners[groupID] = coordinator
408401
m.tripleShot.UseTeamwire = true
409402

410-
// Close the previous event channel to unblock the old ListenTeamwireEvents
411-
// reader. Safe because we stopped all old runners above (no more writes).
412-
if ch := m.teamwireEventCh; ch != nil {
413-
close(ch)
414-
}
415403
m.teamwireEventCh = eventCh
416404

417405
numActive := len(m.tripleShot.Runners)
@@ -435,8 +423,14 @@ func (m Model) initiateTeamwireTripleShot(
435423
return tuimsg.TeamwireStartResultMsg{GroupID: groupID}
436424
}
437425

438-
// Listen for callback events and start the coordinator concurrently.
439-
return m, tea.Batch(startCmd, tuimsg.ListenTeamwireEvents(eventCh))
426+
// Start the coordinator. Only start a new event listener if one isn't
427+
// already running — existing listeners re-subscribe after each message
428+
// and share the channel with all coordinators.
429+
cmds := []tea.Cmd{startCmd}
430+
if needsNewListener {
431+
cmds = append(cmds, tuimsg.ListenTeamwireEvents(eventCh))
432+
}
433+
return m, tea.Batch(cmds...)
440434
}
441435

442436
// handleTeamwirePhaseChanged handles a phase change from the teamwire coordinator.
@@ -464,7 +458,7 @@ func (m *Model) handleTeamwirePhaseChanged(msg tuimsg.TeamwirePhaseChangedMsg) (
464458
// OnComplete callback handles this
465459
}
466460

467-
return m, tuimsg.ListenTeamwireEvents(m.teamwireEventCh)
461+
return m, m.listenTeamwireCmd()
468462
}
469463

470464
// handleTeamwireAttemptStarted handles an attempt start from the teamwire coordinator.
@@ -486,7 +480,7 @@ func (m *Model) handleTeamwireAttemptStarted(msg tuimsg.TeamwireAttemptStartedMs
486480
}
487481

488482
m.infoMessage = fmt.Sprintf("Attempt %d started", msg.AttemptIndex+1)
489-
return m, tuimsg.ListenTeamwireEvents(m.teamwireEventCh)
483+
return m, m.listenTeamwireCmd()
490484
}
491485

492486
// handleTeamwireAttemptCompleted handles an attempt completion from the teamwire coordinator.
@@ -499,7 +493,7 @@ func (m *Model) handleTeamwireAttemptCompleted(msg tuimsg.TeamwireAttemptComplet
499493
}
500494

501495
m.infoMessage = fmt.Sprintf("Attempt %d completed", msg.AttemptIndex+1)
502-
return m, tuimsg.ListenTeamwireEvents(m.teamwireEventCh)
496+
return m, m.listenTeamwireCmd()
503497
}
504498

505499
// handleTeamwireAttemptFailed handles an attempt failure from the teamwire coordinator.
@@ -513,7 +507,7 @@ func (m *Model) handleTeamwireAttemptFailed(msg tuimsg.TeamwireAttemptFailedMsg)
513507
}
514508

515509
m.errorMessage = fmt.Sprintf("Attempt %d failed: %s", msg.AttemptIndex+1, msg.Reason)
516-
return m, tuimsg.ListenTeamwireEvents(m.teamwireEventCh)
510+
return m, m.listenTeamwireCmd()
517511
}
518512

519513
// handleTeamwireJudgeStarted handles judge start from the teamwire coordinator.
@@ -555,7 +549,7 @@ func (m *Model) handleTeamwireJudgeStarted(msg tuimsg.TeamwireJudgeStartedMsg) (
555549
}
556550
}
557551

558-
return m, tuimsg.ListenTeamwireEvents(m.teamwireEventCh)
552+
return m, m.listenTeamwireCmd()
559553
}
560554

561555
// handleTeamwireCompleted handles completion of the teamwire triple-shot.
@@ -582,10 +576,22 @@ func (m *Model) handleTeamwireCompleted(msg tuimsg.TeamwireCompletedMsg) (tea.Mo
582576
m.tripleShot.NeedsNotification = true
583577
}
584578

585-
// Don't re-subscribe — the session is done. The channel will be closed
586-
// during cleanup. Returning nil avoids a goroutine leak from a reader
587-
// permanently blocked on a channel that will never receive another message.
588-
return m, nil
579+
// Keep listening if the shared channel is still active — other
580+
// triple-shot coordinators may still be running and sending events.
581+
// When no runners remain, cleanup will close the channel and the
582+
// listener will receive TeamwireChannelClosedMsg.
583+
return m, m.listenTeamwireCmd()
584+
}
585+
586+
// listenTeamwireCmd returns a tea.Cmd that re-subscribes to the shared
587+
// teamwire event channel. Returns nil if the channel has been closed
588+
// (set to nil by handleTeamwireChannelClosed or cleanupTripleShot),
589+
// preventing a goroutine from blocking forever on a nil channel read.
590+
func (m *Model) listenTeamwireCmd() tea.Cmd {
591+
if m.teamwireEventCh == nil {
592+
return nil
593+
}
594+
return tuimsg.ListenTeamwireEvents(m.teamwireEventCh)
589595
}
590596

591597
// handleTeamwireChannelClosed handles the teamwire event channel being closed.

0 commit comments

Comments
 (0)