diff --git a/backend/apps/agents/core/error_classify.py b/backend/apps/agents/core/error_classify.py index 2f683d21..ba5417fc 100644 --- a/backend/apps/agents/core/error_classify.py +++ b/backend/apps/agents/core/error_classify.py @@ -165,6 +165,22 @@ def is_unknown_model_error(exc: BaseException, extra_text: str = "") -> bool: )) +@typechecked +def is_cli_binary_missing(exc: BaseException, extra_text: str = "") -> bool: + """True when the bundled Claude CLI binary is gone from disk (the SDK's + CLINotFoundError at spawn time). Field data shows this only on Windows, + where antivirus quarantine deletes the unsigned exe out from under an + installed app; restore-from-quarantine or reinstall is the only fix, so + the card must say that instead of dumping the dead path. + """ + if "CLINotFoundError" in type(exc).__name__: + return True + combined = f"{exc!s}\n{extra_text}".strip() + if not combined: + return False + return bool(re.search(r"claude\s+code\s+not\s+found", combined, re.IGNORECASE)) + + def parse_retry_after(exc: BaseException, extra_text: str = "") -> int | None: """Best-effort seconds-until-retry pulled from a throttle error; None if the upstream didn't say. Only used to label the rate-limit pill, so a miss just diff --git a/backend/apps/agents/core/first_real_exception.py b/backend/apps/agents/core/first_real_exception.py new file mode 100644 index 00000000..3e73d0fe --- /dev/null +++ b/backend/apps/agents/core/first_real_exception.py @@ -0,0 +1,15 @@ +"""Unwrap (possibly nested) exception groups to the first plain Exception. +anyio task groups deliver a concurrent CLI crash + cancellation as a +BaseExceptionGroup whose str() names the group, not the cause; classifying the +group instead of the member turns a retryable 429 into a raw error card.""" +from typing import Optional + + +def first_real_exception(exc: BaseException) -> Optional[Exception]: + if isinstance(exc, BaseExceptionGroup): + for p_sub in exc.exceptions: + p_found = first_real_exception(p_sub) + if p_found is not None: + return p_found + return None + return exc if isinstance(exc, Exception) else None diff --git a/backend/tests/test_error_classify.py b/backend/tests/test_error_classify.py index 191edead..ce4886bc 100644 --- a/backend/tests/test_error_classify.py +++ b/backend/tests/test_error_classify.py @@ -7,7 +7,28 @@ The secret-shaped inputs are built by concatenation on purpose: no contiguous key-shaped literal lands in this source file (so it never trips gitleaks or alarms a reader), yet the runtime values are still key-shaped enough to exercise the scrub. None of these are real keys; they unlock nothing.""" -from backend.apps.agents.core.error_classify import redact_for_telemetry +import asyncio + +from backend.apps.agents.core.error_classify import ( + capacity_retry_wait, + is_auth_error, + is_cli_binary_missing, + is_free_trial_exhausted, + is_transient_capacity_error, + is_unknown_model_error, + redact_for_telemetry, +) +from backend.apps.agents.core.first_real_exception import first_real_exception + +# Verbatim field strings from prod analytics (2026-07): the exact shapes users hit. +P_FIELD_POOL_BUSY = ( + "Error code: 429 - {'type': 'error', 'error': {'type': 'free_pool_busy', " + "'message': \"OpenSwarm's free pool is busy right now. Sign in for more, or try again shortly.\"}}" +) +P_FIELD_CLI_MISSING = ( + "Claude Code not found at: C:\\Users\\Rishi\\AppData\\Local\\openswarm\\app-1.5.6\\resources" + "\\python-env\\Lib\\site-packages\\claude_agent_sdk\\_bundled\\claude.exe" +) def test_redacts_provider_key_shapes_keeps_context(): @@ -43,3 +64,44 @@ def test_keeps_tail_and_bounds_length(): def test_empty_is_safe(): assert redact_for_telemetry("") == "" + + +def test_field_pool_busy_is_transient_and_retried(): + e = Exception(P_FIELD_POOL_BUSY) + assert is_transient_capacity_error(e) + assert capacity_retry_wait(e, 0) == 5 + # Must not be claimed by the branches that would surface a card instead of retrying. + assert not is_free_trial_exhausted(e) + assert not is_auth_error(e) + + +def test_cli_missing_matches_field_string_and_nothing_else_claims_it(): + e = Exception(P_FIELD_CLI_MISSING) + assert is_cli_binary_missing(e) + assert not is_transient_capacity_error(e) + assert not is_auth_error(e) + assert not is_unknown_model_error(e) + + +def test_cli_missing_matches_sdk_exception_type(): + class CLINotFoundError(Exception): + pass + assert is_cli_binary_missing(CLINotFoundError("whatever text")) + + +def test_first_real_exception_unwraps_nested_groups(): + boom = ValueError("boom") + group = BaseExceptionGroup( + "outer", [asyncio.CancelledError(), ExceptionGroup("inner", [boom])] + ) + assert first_real_exception(group) is boom + + +def test_first_real_exception_all_cancelled_is_none(): + group = BaseExceptionGroup("outer", [asyncio.CancelledError()]) + assert first_real_exception(group) is None + + +def test_first_real_exception_plain_passthrough(): + boom = RuntimeError("x") + assert first_real_exception(boom) is boom