diff --git a/backend/apps/agents/manager/run/handle_run_error.py b/backend/apps/agents/manager/run/handle_run_error.py index 9ab9f5b8..97fad2d0 100644 --- a/backend/apps/agents/manager/run/handle_run_error.py +++ b/backend/apps/agents/manager/run/handle_run_error.py @@ -304,6 +304,14 @@ async def handle_run_error(e: Exception, session: AgentSession, session_id: str, else: # Track unclassified agent failures too so we stop flying blind on them. p_report_model_error("unclassified", session_id, session, turn, e, p_stderr_tail) + # Every classified branch above is an EXTERNAL fact (auth, credits, capacity, certs) that a + # respawn cannot fix. Landing here instead means the failure may live in this session's own + # CLI state, and that state is replayed verbatim on every retry, which is how one chat bricks + # forever on "hit a snag" while its siblings are fine (ENG-258). Arm the proven fresh-session + # rebuild so the next ordinary send drops the resume transcript and respawns the client; the + # user's own retry becomes the cure instead of needing a manual branch. + session.needs_fresh_session = True + logger.info(f"Agent {session_id}: unclassified failure, next turn rebuilds on a fresh CLI session") # The SDK's ProcessError masks the cause behind "Check stderr output for details"; append the scrubbed stderr tail so the card (and its analytics copy) names what actually broke instead of shipping a dead end. p_card_text = f"Error: {str(e)}" p_cause = redact_for_telemetry(p_stderr_tail, limit=400).strip() diff --git a/backend/tests/test_handle_run_error.py b/backend/tests/test_handle_run_error.py index 6022876f..1e2bd695 100644 --- a/backend/tests/test_handle_run_error.py +++ b/backend/tests/test_handle_run_error.py @@ -83,3 +83,46 @@ def test_informative_error_does_not_get_stderr_appended(monkeypatch): session, _ = p_drive_error(monkeypatch, exc, stderr=["irrelevant tail"]) card = [m for m in session.messages if m.role == "system"][-1].content assert "Runtime log tail" not in card + + +# ENG-258: one session bricking on "hit a snag" forever while its siblings run fine. Every classified +# branch is an external fact a respawn can't fix; the unclassified bucket is the one that can be this +# session's own poisoned CLI state, replayed identically on every retry. Assert BOTH directions, or +# "it arms" would pass just as well on a version that arms unconditionally. + +def test_unclassified_failure_arms_a_fresh_session_so_the_next_send_self_heals(monkeypatch): + session, _ = p_drive_error( + monkeypatch, Exception("API Error: 400 invalid_request_error: messages.3: unexpected block") + ) + assert session.needs_fresh_session is True + + +def test_a_dead_resume_transcript_does_not_stay_sticky(monkeypatch): + # The real shape of a session bricked by its own CLI state: the resume id no longer resolves, so + # every retry replays the same doomed resume until something drops it. + session, _ = p_drive_error( + monkeypatch, Exception("No conversation found with session ID: 9f3c1a2b-dead-4f00-bbbb-000000000000") + ) + assert session.needs_fresh_session is True + + +def test_out_of_credits_does_not_respawn_the_cli(monkeypatch): + session, _ = p_drive_error( + monkeypatch, Exception("Your credit balance is too low to run this request") + ) + assert session.needs_fresh_session is False + + +def test_auth_failure_does_not_respawn_the_cli(monkeypatch): + session, _ = p_drive_error(monkeypatch, Exception("401 invalid authentication credentials")) + assert session.needs_fresh_session is False + + +def test_rate_limit_does_not_respawn_the_cli(monkeypatch): + session, _ = p_drive_error(monkeypatch, Exception("429 rate_limit_error: overloaded")) + assert session.needs_fresh_session is False + + +def test_missing_cli_binary_does_not_respawn_the_cli(monkeypatch): + session, _ = p_drive_error(monkeypatch, Exception(P_FIELD_CLI_MISSING)) + assert session.needs_fresh_session is False