ClickHouse / ClickHouse/nerve

Claude backend silently swallows abnormal turn terminations (max_turns, API errors, aborts)

Open
#292 2 comments 0 reactions 0 assignees View on GitHub
Dominant language
Python
Stars
80
Forks
30
Avg merge
1d 14h
Merged PRs (30d)
19

Description

## Summary

When the Claude backend's agent loop terminates abnormally — `max_turns` exhausted, an API error, an aborted stream — Nerve reports it to the user as a **normal, successful end of turn**. There is no message in the conversation, no error in the UI, and nothing in `nerve.log`. The agent simply stops mid-tool-chain and goes quiet.

The Codex backend does not have this problem, and the engine already has the machinery to surface it. Only the Claude backend's translation layer drops the signal.

## Root cause

`nerve/agent/backends/claude.py`, `ResultMessage` → `TurnCompleted` translation:

```python
elif isinstance(message, ResultMessage):
usage = (...)
out.append(ev.TurnCompleted(
native_session_id=message.session_id,
...
num_turns=getattr(message, "num_turns", None),
status="completed", # <-- hardcoded
))
```

`claude_agent_sdk.types.ResultMessage` carries five fields describing *how* the loop ended, and all five are discarded:

| field | example value on an abnormal stop |
|---|---|
| `subtype` | `"error_max_turns"` |
| `is_error` | `True` |
| `terminal_reason` | `"max_turns"`, `"aborted_streaming"`, `"aborted_tools"` |
| `errors` | `list[str]` |
| `api_error_status` | `429` / `500` / `529` |

## Why this is a silent failure rather than a cosmetic one

The engine already knows how to render a failed turn — `nerve/agent/engine.py`:

```python
if event.status == "failed" and event.error:
# Failed turns still complete: surface the error inline so
# the conversation shows what happened
note = f"⚠️ Turn failed: {event.error}"
...
```

and the Codex backend feeds it correctly (`nerve/agent/backends/codex/backend.py`):

```python
return ev.TurnCompleted(
...
status=status, # type: ignore[arg-type]
error=error,
)
```

So the plumbing is in place end to end; the Claude backend is the only producer that never sets `status`/`error`.

## Reproduction

1. Set a low `agent.max_turns` (e.g. `5`) in the workspace config.
2. Give a Claude-backed session a task that needs more tool calls than that.
3. The session stops mid-task. The UI shows a normal completed turn; `nerve.log` contains no indication that the cap was hit.

Observed on a real session with `agent.max_turns: 50`:

```
sqlite> SELECT created_at, num_turns FROM session_usage WHERE session_id = '';
2026-08-09 16:09:09 | 51
```

`num_turns` sitting at the cap is currently the *only* way to tell this happened, and it requires querying the DB directly. `grep -i max_turns nerve.log` over 150k lines returns nothing.

## Impact

Worst for exactly the sessions that matter most: long autonomous runs (worker mode, cron jobs, review-loop legs). A capped-out turn is indistinguishable from a completed one, so an operator sees "the agent finished" when it actually stopped halfway. Automation downstream of a turn result inherits the same wrong conclusion.

## Suggested fix

Map the SDK's terminal signals onto the existing `TurnStatus` in the Claude translator, mirroring the Codex backend:

- `terminal_reason` in `{"aborted_streaming", "aborted_tools"}` → `status="interrupted"`
- `subtype != "success"` or `is_error` → `status="failed"`, with `error` built from `subtype`/`terminal_reason`/`errors`/`api_error_status` (e.g. `"max turns (50) exhausted"`)
- otherwise → `status="completed"` as today

That alone makes a capped turn render as `⚠️ Turn failed: max turns (50) exhausted` in the conversation, with no engine changes. A log line at WARNING for any non-success terminal state would also help, since today the event leaves no trace at all.

Happy to open a PR for this.

Contributor guide

No contributing guide indexed for this repository

Research direction

Start in nerve/agent/backends/claude.py at the ResultMessage-to-TurnCompleted translation, then compare the status and error handling in nerve/agent/backends/codex/backend.py. Read nerve/agent/engine.py to confirm how failed and interrupted turns are rendered. Done means abnormal Claude terminal signals reach the existing UI and logging paths instead of appearing as successful completion.

Written by the indexing model from the issue text.

Assessment

Tech stack
python
Domain
backend
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
78/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.