🚨 Fail a Claude run that never launched instead of reporting it done - #161
Merged
Conversation
Claude Code writes `--agent 'x' not found. Available agents: ...` to stderr and exits within ~300ms when it does not recognise the `--agent` name. The process was gone before `WaitForPaneReady` finished polling, so both terminal backends saw only a dead terminal and reported `session.end` with `status: done`, `stop_reason: end_turn`, and no output — identical to a real turn that said nothing. A typo'd agent name looked like success. `Session.EmitTerminalGone` now classifies the exit before reporting it. It lives in claudecore and is called from each provider's `sessionGone` branch, so the `claude` backend (PTY and tmux) and the tmux-only `claude-channel` backend both inherit it. A refused launch ends `status: failed` with `stop_reason: agent_not_found`, the rejected name, and the roster Claude Code listed; an exit before avenor ever submitted a prompt ends `failed` with `stop_reason: launch_failed` and claude's stderr tail. Both emit `session.end` under `MarkFinished` rather than cancelling the context, which would leave `avenor_status` and `avenor_result` waiting forever. The banner is unreachable from the pane: tmux destroys the session with the process, and `capture-pane` shows a blank grid for that pane's whole life even with `remain-on-exit` holding it open. `LaunchCommand` redirects claude's stderr into a per-session temp file instead, which outlives both terminal kinds and stays empty on a healthy run. The match anchors on the banner's invariant middle, `not found. Available agents:`, because both ends carry per-run values. AI-Generated-By: Claude (Opus 5, centaur) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Owner
Author
|
Addressed 5 comments across 3 files:
Review loop: 1 iteration. No valid ≥80 regression findings in the changed areas. Verification: |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
A typo'd
--agentname reported success. Claude Code writes--agent 'x' not found. Available agents: ...to stderr and exits within ~300ms, so both terminal backends saw only a dead terminal and emittedsession.endwithstatus: done,stop_reason: end_turn, and no output. That is byte-for-byte what a realend_turnthat said nothing looks like, so no caller could tell the two apart.Session.EmitTerminalGonenow classifies the exit before reporting it. It lives inclaudecoreand each provider calls it from its singlesessionGonebranch (claude/provider.go:206,claudechannel/provider.go:317), so theclaudebackend (PTY and tmux) and the tmux-onlyclaude-channelbackend inherit identical classification.--agentrefusedfailed,stop_reason: agent_not_found, rejected name + rosterfailed,stop_reason: launch_failed, stderr taildone/end_turnlaunch_failedis a deliberate extension beyond unrecognised agents.Prompted == falseis a sound test for it becauseBuildArgsnever puts a prompt on the command line, and the channel push only lands after the first terminal-delivered turn.Both failure paths emit
session.endunderMarkFinishedrather than cancelling the context. Cancelling leaves each provider'sctx.Donebranch killing the terminal and emitting nothing, which strandsavenor_statusandavenor_result.Why stderr and not the pane
The banner cannot be read from the pane. tmux destroys the session together with its process, and
capture-panereturns a blank grid for that pane's whole life — confirmed at 25ms sampling and withremain-on-exitholding the dead pane open.pipe-panedoes capture the bytes, so they exist but never enter the grid. An initial pane-polling design passed unit tests and failed end-to-end under tmux for exactly this reason.LaunchCommandredirects claude's stderr into a per-session temp file, which outlives both terminal kinds and stays empty on a healthy run because Claude Code draws its TUI on stdout. Detection reads the file first, then falls back toTerm.Capture, which still helps under PTY because the vt10x screen survives the child.Detection anchors on the banner's invariant middle,
not found. Available agents:. Both ends carry per-run values, andnot foundalone appears in healthy panes as ordinary agent output.Verification
go build ./...,go vet ./internal/runtime/..., andgo test ./...pass.go test -race -count=1passes onclaudecore/...,claude/..., andclaudechannel/....End-to-end against Claude Code v2.1.222 with
--agent general, which is not installed:session.endmain(e79e989)done/end_turn— the bugfailed/agent_not_found+ rosterfailed/agent_not_found+ rosterclaude-channelfailed/agent_not_found+ rosterHealthy-run regression,
--agent mulewith a real prompt: completes unchanged throughsession.enddone/end_turnunder tmux, and under PTY once the unrelated environment bug below is worked around. The stderr log is 0 bytes on both, which is what shows the redirect does not disturb the TUI.Caveats
agent.statusstill reportsphase: donefor a failed launch.cli.statusTrackermaps everysession.endto the terminal phase, whileevents.SessionStatecarriesstop_reasonseparately — soavenor_statusdoes surfaceagent_not_found, but consumers must readstatus/stop_reasonrather thanphase. Changing the phase vocabulary would touchdigest/classify.goandstable/supervisor.go, which both compare against"done", so it is left alone here.TestPTYSessionInterruptThenWaitReapsin the untouchedterminalpackage is flaky: it sleeps 20ms then sends SIGINT to a shell that installs itstrapasynchronously, so losing the race kills the child by signal instead of exit 0. Pre-existing; passed 11 subsequent runs including-race.Adjacent, not addressed here
Two separate silent-hang bugs found while testing this, both present on
mainand neither touched by this diff:TranscriptPathdoes not resolve symlinks, so-dir /tmpreadsprojects/-tmp/while Claude Code writesprojects/-private-tmp/. The scanner finds nothing, never seesend_turn, and the run hangs with no error.PTYLauncher.Startpasses the whole parent environment to the child. A run launched from inside a Claude Code session inheritsCLAUDE_CODE_CHILD_SESSION, Claude Code then disables transcript persistence, and the transcript the PTY backend depends on forend_turnis never written. The turn completes on screen and the run hangs forever.env -u CLAUDE_CODE_CHILD_SESSIONturns a 150s+ hang into a 5s cleanend_turn. This is the configuration the avenor MCP plugin uses, and PTY is the default terminal.Also adjacent and out of scope:
claude --effortwarns on an unrecognised value on stderr and then exits 0, silently falling back to the default. Same family, but not a launch failure, soEmitTerminalGoneis the wrong home — and this PR's needle requires theAvailable agents:tail, so it cannot match that warning. The stderr-log plumbing added here is what a later fix would build on.🤖 Generated with Claude Code