* fix(lifecycle): drain StreamManager goroutines in tests
Goroutines spawned by `agentctl.Client.StreamUpdates` /
`StreamWorkspace` and by `lifecycle.StreamManager.connectWorkspaceStream`
could outlive the tests that created them on slow CI runners, causing
intermittent `goleak.VerifyTestMain` failures in
`internal/agent/runtime/lifecycle` even though every individual test
passed. The leak required the test to race: `client.Close()` could
return while a workspace dial was still in flight, leaving the
just-spawned WS read/write loops with nobody to drain them, and
`StreamManager.Wait()` only fired the drain when the external `stopCh`
had been closed first.
Make the close paths absolute drain barriers:
- `Client.Close()` now tracks every stream goroutine it spawns
(`streamWG` + a per-stream `WorkspaceStream` reference) and blocks
until all of them have exited. A `closed` flag flipped under the
client mutex makes subsequent `StreamUpdates` / `StreamWorkspace`
calls reject the dial, so a Close that races a dial in flight cannot
leave behind a stranded WS connection. `manager_interaction.go`
switches the restart path to per-stream Close helpers so it can keep
reusing the client after teardown.
- `StreamManager.Wait()` closes a new internal `waitCh` that the retry
backoff (`sleepOrStop`) and the connected `<-ws.Done() / <-stop>`
select also observe. This makes `Wait` an absolute drain barrier
regardless of whether the caller ever closes the external `stopCh`.
`streamContext` carries both stops through `stopChannelContext` so
in-flight WebSocket dials cancel on either signal.
Local stress (`make test-lifecycle-goleak LIFECYCLE_GOLEAK_COUNT=20`)
passes 20/20 with `-race` after the fix. The new
`TestClientClose_DrainsStreamGoroutines` covers the drain barrier and
the post-Close rejection path. Added a `test-lifecycle-goleak`
Makefile target as the repro hook for the CI-only flake.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(lifecycle): track mergeStops goroutine and guard workspace stream races
Address PR review feedback:
- stopChannelContext now registers the mergeStops goroutine on an optional
*sync.WaitGroup so sm.wg.Wait stays a true drain barrier even for the
connectUpdatesStream path (outer goroutine returns immediately after dial).
- StreamWorkspace re-checks workspaceStreamConn after dial to seal the
concurrent-callers race that could orphan a conn.
- readWorkspaceStream's defer now guards the workspaceStreamConn reset by
identity so an old read goroutine cannot zero out a newer conn pointer.
- Clarify comments on streamWG, Client.Close, and the mock.connected wait
in the close-barrier test.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(lifecycle): drop agent-stream drain barrier to unblock workflow cascade
The previous Close drain barrier for the agent (updates) stream blocked
the on_turn_complete cascade flow. completeAndStopSession calls Close
synchronously on the current session's client, and Close was waiting on
streamWG (which tracked the read goroutine). The wait stalled the
cascade long enough that Step2's auto-started session never got its
profile name into the tab — surfacing as a flaky "Profile B tab not
visible" failure in workflow-agent-switch E2E shard 7.
Drain barrier is still in place for the workspace stream (where the
original goleak flake lived). Agent stream is closed but not awaited:
the cascade legitimately stops + restarts the agent stream on the same
client, and the workspace stream's existing barrier is sufficient for
the leak coverage.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Kandev Agent <agent@kandev.dev>