Skip to content

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

Description

@alex-clickhouse

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:

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:

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):

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 = '<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.

Activity

  1. bytehello commented on Sep 7, 2026

    @bytehello
    Contributor

    I confirmed the current Claude translator hardcodes every ResultMessage to completed while the normalized TurnCompleted event already supports failed/interrupted states. I’ll send a focused translator fix covering max-turn/API/error/abort terminal signals with regression tests, without changing the engine.

  2. alex-clickhouse commented on Sep 7, 2026

    @alex-clickhouse
    CollaboratorAuthor

    Keeping this open because #428 fixes the Claude translator, but the outcome still gets lost downstream: workflows infer failure from session status, and cron jobs record success whenever the engine returns normally. A failed turn can therefore still be reported as successful automation. Interrupted results also lack an inline notice, and abnormal terminal results still need logging.

    The remaining work is to preserve a structured turn outcome through engine finalization, use it in workflow/cron completion, and cover those paths with integration tests—while keeping turn failure separate from session health.

  3. alex-clickhouse commented on Oct 1, 2026

    @alex-clickhouse
    CollaboratorAuthor

    @groeneai can you pick it up?

  4. groeneai commented on Oct 1, 2026

    @groeneai
    Contributor

    Acknowledged. I'll take the remaining scope from your comment above (a structured turn outcome kept through engine finalization and used by workflow and cron completion, the interrupted notice, abnormal-result logging, integration tests, turn failure kept separate from session health) and follow up here with a PR.

  5. groeneai commented on Oct 1, 2026

    @groeneai
    Contributor

    @alex-clickhouse one scoping question before I open the PR.

    Settled: every turn end produces a TurnOutcome(status, error). run_cron / run_persistent_cron return it with the text, and cron logs error with the reason when the turn failed or was interrupted. Workflow runs decide from the outcome instead of session status: a failed turn leaves the session active, and a turn that crashes after streaming is put back to active by _finalize_turn's mark_active, so today both end done. Interrupted turns get an inline notice and both kinds log a WARNING. Session status is not touched.

    Open: autonomous (background-continuation) turns, which is where claude-workflow runs and parked isolated cron runs actually finish.

    • A. The workflow watches its own session and takes the outcome of the last turn that ended when the existing quiescence loop settles (the same heuristic it already uses for the result text). A parked isolated cron run is logged from its launching turn, as today.
    • B. The engine also tracks an autonomous turn from init to its end, so a turn that is slow to its first token no longer reads as idle. Every drain exit publishes an outcome, and cron follows a parked isolated run until it settles and re-logs it. This is a noticeably larger engine change.

    Either way I would leave the pre-query-drain desync (an autonomous ResultMessage ending the next receive_response) to a separate change; fixing it would mean reading ResultMessage.origin in the receive loop.

    I lean to A here and B as a follow-up. A or B?

  6. alex-clickhouse commented on Oct 1, 2026

    @alex-clickhouse
    CollaboratorAuthor

    @groeneai let's go with A.

  7. groeneai commented on Oct 1, 2026

    @groeneai
    Contributor

    Thanks, going with A. I'll post the PR link here when it opens.

  8. groeneai commented on Oct 1, 2026

    @groeneai
    Contributor

    The PR is open: #481 (option A).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions