-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy path_exit_classification.py
More file actions
141 lines (117 loc) · 5.73 KB
/
Copy path_exit_classification.py
File metadata and controls
141 lines (117 loc) · 5.73 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
"""Infrastructure exit classification for headless sessions."""
from __future__ import annotations
from typing import TYPE_CHECKING
import regex as re
from autoskillit.core import CODEX_CONTEXT_EXHAUSTION_MARKER, InfraExitCategory, get_logger
if TYPE_CHECKING:
from autoskillit.core import BackendCapabilities, SubprocessResult
from autoskillit.execution.session._session_model import ClaudeSessionResult
logger = get_logger(__name__)
_API_ERROR_PATTERNS: tuple[re.Pattern[str], ...] = (
re.compile(r"overloaded", re.IGNORECASE),
re.compile(r"\b529\b"),
re.compile(r"\b503\b"),
re.compile(r"ECONNRESET", re.IGNORECASE),
re.compile(r"ECONNREFUSED", re.IGNORECASE),
re.compile(r"socket hang up", re.IGNORECASE),
re.compile(r"network error", re.IGNORECASE),
re.compile(r"connection reset", re.IGNORECASE),
re.compile(r"socket connection was closed", re.IGNORECASE), # Bun HTTP client
re.compile(r"rate[\s_\-]limited", re.IGNORECASE),
)
_CODEX_API_ERROR_PATTERNS: tuple[re.Pattern[str], ...] = (
re.compile(r"rate_limit_exceeded", re.IGNORECASE),
re.compile(r"\bserver_error\b", re.IGNORECASE),
re.compile(r"insufficient_quota", re.IGNORECASE),
re.compile(r"model_not_found", re.IGNORECASE),
)
_KNOWN_API_ERROR_PATTERNS: tuple[re.Pattern[str], ...] = (
_API_ERROR_PATTERNS + _CODEX_API_ERROR_PATTERNS
)
# Additive subset of _KNOWN_API_ERROR_PATTERNS covering transient rate-limit signals.
# Must NOT remove these patterns from _API_ERROR_PATTERNS or _CODEX_API_ERROR_PATTERNS —
# _session_model._has_api_error() imports _KNOWN_API_ERROR_PATTERNS and relies on the
# full union. This tuple exists only for the RATE_LIMITED classification branch.
_RATE_LIMIT_PATTERNS: tuple[re.Pattern[str], ...] = (
re.compile(r"rate[\s_\-]limited", re.IGNORECASE),
re.compile(r"rate_limit_exceeded", re.IGNORECASE),
)
_CODEX_CONTEXT_EXHAUSTION_PATTERN: re.Pattern[str] = re.compile(
CODEX_CONTEXT_EXHAUSTION_MARKER, re.IGNORECASE
)
def _all_text_sources(
session: ClaudeSessionResult,
result: SubprocessResult,
) -> list[str]:
"""Collect all searchable text from a session and subprocess result."""
sources: list[str] = list(session.assistant_messages)
if session.errors:
sources.extend(session.errors)
if session.result:
sources.append(session.result)
if result.stderr:
sources.append(result.stderr)
return sources
def has_rate_limit_signal(
session: ClaudeSessionResult,
result: SubprocessResult,
) -> bool:
"""Check whether a session exhibits rate-limit signals (HTTP 429 or text patterns)."""
if session.api_error_status == 429:
return True
return any(
p.search(msg) for p in _RATE_LIMIT_PATTERNS for msg in _all_text_sources(session, result)
)
_SHELL_SIGNAL_MAX = 192 # 128 + SIGRTMAX (64 on Linux)
def is_signal_death_code(returncode: int) -> bool:
"""Return True if returncode indicates death by signal.
Covers both Python convention (negative: -(signal_number)) and shell
convention (positive: 128 + signal_number, range 129–192).
"""
return returncode < 0 or (128 < returncode <= _SHELL_SIGNAL_MAX)
def classify_infra_exit(
session: ClaudeSessionResult,
result: SubprocessResult,
*,
capabilities: BackendCapabilities | None = None,
) -> InfraExitCategory:
"""Classify why a headless session exited at the infrastructure level.
Priority order: context exhaustion > rate limit > API error > process kill > completed.
Context exhaustion takes precedence because it is more specific. Rate limit
(HTTP 429) is checked before other API errors so transient rate limits are
distinguished from structural failures and routed to on_rate_limit instead of
on_context_limit.
When ``capabilities`` is provided, context-exhaustion detection is gated on
``supports_context_exhaustion_detection``; backends that do not support
context-exhaustion telemetry (e.g., Claude's local session JSONL marker is
already authoritative) skip those checks. ``capabilities=None`` is permissive
and preserves the prior behavior for callers that have not yet threaded a
capability object through.
"""
if capabilities is None or capabilities.supports_context_exhaustion_detection:
if session._is_context_exhausted():
return InfraExitCategory.CONTEXT_EXHAUSTED
# Codex turn.failed arrives on stdout (NDJSON); this stderr branch is non-Codex fallback.
if _CODEX_CONTEXT_EXHAUSTION_PATTERN.search(result.stderr):
return InfraExitCategory.CONTEXT_EXHAUSTED
# Rate limit detection — must precede all API_ERROR checks (including
# api_retry_exhausted) so 429s are classified as RATE_LIMITED even
# when retries are exhausted.
if session.api_error_status == 429:
return InfraExitCategory.RATE_LIMITED
if any(
p.search(msg) for p in _RATE_LIMIT_PATTERNS for msg in _all_text_sources(session, result)
):
return InfraExitCategory.RATE_LIMITED
if session._has_api_error() or any(p.search(result.stderr) for p in _KNOWN_API_ERROR_PATTERNS):
return InfraExitCategory.API_ERROR
# Separate guard: _has_api_error() scans message text for known patterns, but
# api_retry_last_error can be "unknown" or another value not in _KNOWN_API_ERROR_PATTERNS.
# In that case _has_api_error() returns False while api_retry_exhausted is still True.
if session.api_retry_exhausted:
return InfraExitCategory.API_ERROR
if session.api_error_status is not None and session.api_error_status >= 400:
return InfraExitCategory.API_ERROR
if result.returncode is not None and is_signal_death_code(result.returncode):
return InfraExitCategory.PROCESS_KILLED
return InfraExitCategory.COMPLETED