Skip to content

🚨 Fail a Claude run that never launched instead of reporting it done - #161

Merged
sdougbrown merged 2 commits into
mainfrom
fix/agent-not-found-launch-failure
Aug 6, 2026
Merged

🚨 Fail a Claude run that never launched instead of reporting it done#161
sdougbrown merged 2 commits into
mainfrom
fix/agent-not-found-launch-failure

Conversation

@sdougbrown

@sdougbrown sdougbrown commented Aug 6, 2026

Copy link
Copy Markdown
Owner

What

A typo'd --agent name 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 emitted session.end with status: done, stop_reason: end_turn, and no output. That is byte-for-byte what a real end_turn that said nothing looks like, so no caller could tell the two apart.

Session.EmitTerminalGone now classifies the exit before reporting it. It lives in claudecore and each provider calls it from its single sessionGone branch (claude/provider.go:206, claudechannel/provider.go:317), so the claude backend (PTY and tmux) and the tmux-only claude-channel backend inherit identical classification.

Exit Result
--agent refused failed, stop_reason: agent_not_found, rejected name + roster
Exited before a prompt was submitted failed, stop_reason: launch_failed, stderr tail
Anything else unchanged done / end_turn

launch_failed is a deliberate extension beyond unrecognised agents. Prompted == false is a sound test for it because BuildArgs never puts a prompt on the command line, and the channel push only lands after the first terminal-delivered turn.

Both failure paths emit session.end under MarkFinished rather than cancelling the context. Cancelling leaves each provider's ctx.Done branch killing the terminal and emitting nothing, which strands avenor_status and avenor_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-pane returns a blank grid for that pane's whole life — confirmed at 25ms sampling and with remain-on-exit holding the dead pane open. pipe-pane does 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.

LaunchCommand redirects 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 to Term.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, and not found alone appears in healthy panes as ordinary agent output.

Verification

go build ./..., go vet ./internal/runtime/..., and go test ./... pass. go test -race -count=1 passes on claudecore/..., claude/..., and claudechannel/....

End-to-end against Claude Code v2.1.222 with --agent general, which is not installed:

Binary Terminal Wall Exit session.end
main (e79e989) PTY 0.84s 0 done / end_turn — the bug
this branch PTY 1.36s 1 failed / agent_not_found + roster
this branch tmux 0.57s 1 failed / agent_not_found + roster
this branch claude-channel 0.63s 1 failed / agent_not_found + roster

Healthy-run regression, --agent mule with a real prompt: completes unchanged through session.end done / end_turn under 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.status still reports phase: done for a failed launch. cli.statusTracker maps every session.end to the terminal phase, while events.SessionState carries stop_reason separately — so avenor_status does surface agent_not_found, but consumers must read status/stop_reason rather than phase. Changing the phase vocabulary would touch digest/classify.go and stable/supervisor.go, which both compare against "done", so it is left alone here.

TestPTYSessionInterruptThenWaitReaps in the untouched terminal package is flaky: it sleeps 20ms then sends SIGINT to a shell that installs its trap asynchronously, 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 main and neither touched by this diff:

  1. TranscriptPath does not resolve symlinks, so -dir /tmp reads projects/-tmp/ while Claude Code writes projects/-private-tmp/. The scanner finds nothing, never sees end_turn, and the run hangs with no error.
  2. PTYLauncher.Start passes the whole parent environment to the child. A run launched from inside a Claude Code session inherits CLAUDE_CODE_CHILD_SESSION, Claude Code then disables transcript persistence, and the transcript the PTY backend depends on for end_turn is never written. The turn completes on screen and the run hangs forever. env -u CLAUDE_CODE_CHILD_SESSION turns a 150s+ hang into a 5s clean end_turn. This is the configuration the avenor MCP plugin uses, and PTY is the default terminal.

Also adjacent and out of scope: claude --effort warns on an unrecognised value on stderr and then exits 0, silently falling back to the default. Same family, but not a launch failure, so EmitTerminalGone is the wrong home — and this PR's needle requires the Available 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

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>

@umpire-bot umpire-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR is marked... FOUL BALL. 👉

Comment thread internal/runtime/claude/provider_test.go Outdated
Comment thread internal/runtime/claudechannel/provider_test.go Outdated
Comment thread internal/runtime/claude/provider_test.go
Comment thread internal/runtime/claudechannel/provider_test.go
Comment thread internal/runtime/claudecore/launch.go
@sdougbrown

Copy link
Copy Markdown
Owner Author

👾 AI Agent

Addressed 5 comments across 3 files:

  • Fixed (5): strengthened both provider session-gone tests to assert agent.launch_failed precedes session.end and that the end reason is launch_failed; added direct UTF-8 truncation and stderr-log error/empty-path coverage (e578820).
  • Accepted as is (0).
  • Dismissed (0).

Review loop: 1 iteration. No valid ≥80 regression findings in the changed areas.

Verification: go test ./..., go vet ./internal/runtime/..., and go build ./....

@sdougbrown
sdougbrown merged commit e122850 into main Aug 6, 2026
@sdougbrown
sdougbrown deleted the fix/agent-not-found-launch-failure branch August 6, 2026 22:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant