ccx-native 0.0.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +240 -4
- package/bin/ccx +55 -2
- package/ccx/__init__.py +167 -0
- package/ccx/__main__.py +28 -0
- package/ccx/claude/__init__.py +0 -0
- package/ccx/claude/analytics.py +466 -0
- package/ccx/claude/attach.py +807 -0
- package/ccx/claude/context.py +465 -0
- package/ccx/claude/delivery.py +806 -0
- package/ccx/claude/external.py +253 -0
- package/ccx/claude/host/__init__.py +0 -0
- package/ccx/claude/host/events.py +297 -0
- package/ccx/claude/host/hooks.py +438 -0
- package/ccx/claude/host/pty.py +269 -0
- package/ccx/claude/host/session.py +5244 -0
- package/ccx/claude/host/subagents.py +169 -0
- package/ccx/claude/host/successor.py +267 -0
- package/ccx/claude/managed_turns.py +240 -0
- package/ccx/claude/oauth.py +176 -0
- package/ccx/claude/settings.py +339 -0
- package/ccx/claude/spawn.py +557 -0
- package/ccx/claude/tasks.py +72 -0
- package/ccx/claude/telemetry.py +282 -0
- package/ccx/claude/transcript.py +2535 -0
- package/ccx/claude/usage.py +796 -0
- package/ccx/claude/usage_control.py +204 -0
- package/ccx/cli/__init__.py +0 -0
- package/ccx/cli/codex.py +1009 -0
- package/ccx/cli/commands/__init__.py +0 -0
- package/ccx/cli/commands/_common.py +407 -0
- package/ccx/cli/commands/control.py +161 -0
- package/ccx/cli/commands/create.py +669 -0
- package/ccx/cli/commands/models.py +53 -0
- package/ccx/cli/commands/read.py +1104 -0
- package/ccx/cli/commands/turns.py +1081 -0
- package/ccx/cli/controller.py +757 -0
- package/ccx/cli/main.py +302 -0
- package/ccx/cli/parser.py +1439 -0
- package/ccx/cli/skills.py +215 -0
- package/ccx/cli/statusline.py +160 -0
- package/ccx/client.py +1904 -0
- package/ccx/codex/__init__.py +0 -0
- package/ccx/codex/attach_tty.py +267 -0
- package/ccx/codex/client.py +738 -0
- package/ccx/codex/completion.py +121 -0
- package/ccx/codex/control.py +288 -0
- package/ccx/codex/daemon/__init__.py +0 -0
- package/ccx/codex/daemon/__main__.py +4 -0
- package/ccx/codex/daemon/daemon.py +1558 -0
- package/ccx/codex/daemon/models.py +140 -0
- package/ccx/codex/daemon/native.py +1590 -0
- package/ccx/codex/daemon/release.py +367 -0
- package/ccx/codex/daemon/worker.py +2424 -0
- package/ccx/codex/delivery.py +758 -0
- package/ccx/codex/delivery_recovery.py +232 -0
- package/ccx/codex/evidence.py +827 -0
- package/ccx/codex/history.py +158 -0
- package/ccx/codex/history_store.py +131 -0
- package/ccx/codex/notify_history.py +140 -0
- package/ccx/codex/ops.py +666 -0
- package/ccx/codex/paths.py +70 -0
- package/ccx/codex/protocol.py +805 -0
- package/ccx/codex/provenance.py +105 -0
- package/ccx/codex/read.py +29 -0
- package/ccx/codex/records.py +51 -0
- package/ccx/codex/reports.py +177 -0
- package/ccx/codex/runtime.py +1454 -0
- package/ccx/codex/settle.py +363 -0
- package/ccx/codex/telemetry.py +792 -0
- package/ccx/codex/types.py +659 -0
- package/ccx/codex/worker_environment.py +25 -0
- package/ccx/constants.py +288 -0
- package/ccx/controllers/__init__.py +0 -0
- package/ccx/controllers/controller.py +1449 -0
- package/ccx/controllers/live_vars.py +1706 -0
- package/ccx/controllers/live_vars_runner.py +216 -0
- package/ccx/controllers/notify.py +213 -0
- package/ccx/controllers/orchestrator.py +193 -0
- package/ccx/controllers/policy.py +57 -0
- package/ccx/controllers/store.py +681 -0
- package/ccx/errors.py +634 -0
- package/ccx/models.py +225 -0
- package/ccx/notify/__init__.py +0 -0
- package/ccx/notify/__main__.py +5 -0
- package/ccx/notify/client.py +116 -0
- package/ccx/notify/daemon.py +480 -0
- package/ccx/notify/deliver.py +304 -0
- package/ccx/notify/paths.py +51 -0
- package/ccx/notify/registry.py +649 -0
- package/ccx/notify/select.py +750 -0
- package/ccx/notify/settle.py +684 -0
- package/ccx/ops/__init__.py +0 -0
- package/ccx/ops/background.py +115 -0
- package/ccx/ops/cancel.py +296 -0
- package/ccx/ops/codex_import.py +96 -0
- package/ccx/ops/common.py +397 -0
- package/ccx/ops/compact.py +337 -0
- package/ccx/ops/compact_ops.py +550 -0
- package/ccx/ops/context_events.py +48 -0
- package/ccx/ops/conversation.py +1593 -0
- package/ccx/ops/conversation_paging.py +1339 -0
- package/ccx/ops/create.py +56 -0
- package/ccx/ops/creation.py +952 -0
- package/ccx/ops/creation_steps.py +575 -0
- package/ccx/ops/event_feed.py +710 -0
- package/ccx/ops/forking.py +711 -0
- package/ccx/ops/history.py +1041 -0
- package/ccx/ops/input.py +556 -0
- package/ccx/ops/maintenance.py +295 -0
- package/ccx/ops/report.py +773 -0
- package/ccx/ops/semantics.py +490 -0
- package/ccx/ops/send.py +961 -0
- package/ccx/ops/steer_ops.py +867 -0
- package/ccx/ops/terminate.py +383 -0
- package/ccx/ops/turn_reports.py +348 -0
- package/ccx/ops/wait.py +762 -0
- package/ccx/ops/worker_events.py +640 -0
- package/ccx/rpc/__init__.py +0 -0
- package/ccx/rpc/local_rpc.py +1602 -0
- package/ccx/rpc/rpc.py +559 -0
- package/ccx/rpc/serve.py +374 -0
- package/ccx/rpc/transport.py +409 -0
- package/ccx/rpc/view.py +125 -0
- package/ccx/store/__init__.py +0 -0
- package/ccx/store/discovery.py +170 -0
- package/ccx/store/identity.py +105 -0
- package/ccx/store/journal_io.py +79 -0
- package/ccx/store/liveness.py +194 -0
- package/ccx/store/paths.py +250 -0
- package/ccx/store/record_cache.py +40 -0
- package/ccx/store/records.py +309 -0
- package/ccx/store/root.py +789 -0
- package/ccx/store/runtime_presence.py +137 -0
- package/ccx/store/store.py +2332 -0
- package/ccx/store/worker_state.py +86 -0
- package/ccx/system/__init__.py +0 -0
- package/ccx/system/bounded.py +296 -0
- package/ccx/system/cwd_paths.py +25 -0
- package/ccx/system/engine_binary.py +59 -0
- package/ccx/system/executable.py +50 -0
- package/ccx/system/oslayer.py +667 -0
- package/ccx/system/pipes.py +46 -0
- package/ccx/system/private_json.py +54 -0
- package/ccx/system/sanitize.py +43 -0
- package/ccx/types.py +809 -0
- package/ccx/version.py +29 -0
- package/ccx/workers.py +2093 -0
- package/ccx.py +9 -0
- package/package.json +39 -7
- package/pyproject.toml +87 -0
- package/skills/ccx/SKILL.md +166 -0
- package/skills/ccx/references/controllers.md +244 -0
- package/skills/ccx/references/history.md +50 -0
- package/skills/ccx/references/troubleshooting.md +94 -0
- package/skills/ccx/references/variables.md +62 -0
package/ccx/errors.py
ADDED
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
"""Typed exceptions for the public CCX Python clients.
|
|
2
|
+
|
|
3
|
+
The CLI signals failure by writing to stderr and calling ``sys.exit`` with one
|
|
4
|
+
of the integer codes named in :mod:`ccx.constants`. A library cannot do that —
|
|
5
|
+
exiting the host process from inside a function call is hostile to anything that
|
|
6
|
+
imports it. So every public client method that its CLI would have exited from
|
|
7
|
+
raises one of the exceptions below instead.
|
|
8
|
+
|
|
9
|
+
Each exception carries the CLI ``exit_code`` it corresponds to, so an
|
|
10
|
+
orchestrator that wants the old numeric semantics (or a thin CLI adapter) can
|
|
11
|
+
recover them without re-deriving the mapping:
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
client.send(short, "go")
|
|
15
|
+
except CcxError as e:
|
|
16
|
+
sys.exit(e.exit_code)
|
|
17
|
+
|
|
18
|
+
The mapping mirrors the exit-code table in ``ccx/constants.py`` exactly; see
|
|
19
|
+
that file for what each code means.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from .constants import (
|
|
25
|
+
BUDGET_EXIT,
|
|
26
|
+
EXIT_ERROR,
|
|
27
|
+
EXIT_NOT_FOUND,
|
|
28
|
+
EXIT_API_TRANSIENT,
|
|
29
|
+
EXIT_RATE_LIMITED,
|
|
30
|
+
EXIT_REJECTED,
|
|
31
|
+
EXIT_TURN_INCOMPLETE,
|
|
32
|
+
EXIT_UNAVAILABLE,
|
|
33
|
+
EXIT_WAITING,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class CcxError(Exception):
|
|
38
|
+
"""Base class for every error the public client raises.
|
|
39
|
+
|
|
40
|
+
``exit_code`` is the CLI exit code this condition maps to (see
|
|
41
|
+
``ccx/constants.py``). ``short`` is the session id involved, when known.
|
|
42
|
+
Subclasses may attach extra structured context (e.g. ``waiting_for`` on
|
|
43
|
+
:class:`SessionWaiting`) so callers never have to parse the message string.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
exit_code: int = EXIT_ERROR
|
|
47
|
+
|
|
48
|
+
def __init__(self, message: str = "", *, short: str | None = None):
|
|
49
|
+
super().__init__(message)
|
|
50
|
+
self.short = short
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class InvalidRequest(CcxError):
|
|
54
|
+
"""A call's arguments were invalid or unusable (bad prompt source, a usage
|
|
55
|
+
cap with no prompt to attach to, an unreadable cwd, …). Maps to the CLI's
|
|
56
|
+
general-failure code."""
|
|
57
|
+
|
|
58
|
+
exit_code = EXIT_ERROR
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class AmbiguousWorker(InvalidRequest):
|
|
62
|
+
"""Discovery matched multiple exact managed owners; no target was chosen."""
|
|
63
|
+
|
|
64
|
+
def __init__(self, query: str, candidates: tuple[str, ...] | list[str]):
|
|
65
|
+
self.candidates = tuple(sorted(candidates))
|
|
66
|
+
assert len(self.candidates) >= 2
|
|
67
|
+
super().__init__(
|
|
68
|
+
f"ambiguous managed worker prefix {query!r}; candidates: "
|
|
69
|
+
+ ", ".join(self.candidates)
|
|
70
|
+
+ "; use a longer canonical prefix or full UUID"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class OperationConflict(InvalidRequest):
|
|
75
|
+
"""An immutable caller-owned identity conflicts with durable ownership."""
|
|
76
|
+
|
|
77
|
+
exit_code = EXIT_REJECTED
|
|
78
|
+
|
|
79
|
+
def __init__(self, message="", *, short=None, reason=None, worker_id=None):
|
|
80
|
+
super().__init__(message, short=short)
|
|
81
|
+
self.reason = reason
|
|
82
|
+
self.worker_id = worker_id
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class CreatePartialFailure(CcxError):
|
|
86
|
+
"""A durable worker identity is claimed, but creation is incomplete.
|
|
87
|
+
|
|
88
|
+
In phase="record", the claim may exist without a usable worker record
|
|
89
|
+
or native activation. Later phases may follow a live native worker. In
|
|
90
|
+
either case the caller must retain and reconcile the full worker_id.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
exit_code = EXIT_UNAVAILABLE
|
|
94
|
+
|
|
95
|
+
def __init__(self, message="", *, info, phase, delivery_id=None,
|
|
96
|
+
delivery_status=None, cause=None, notification_intent=False):
|
|
97
|
+
super().__init__(message, short=info["short"])
|
|
98
|
+
self.worker_id = info["worker_id"]
|
|
99
|
+
self.worker = info["worker"]
|
|
100
|
+
self.engine = info["engine"]
|
|
101
|
+
self.phase = phase
|
|
102
|
+
self.delivery_id = delivery_id
|
|
103
|
+
self.delivery_status = delivery_status
|
|
104
|
+
self.cause = cause
|
|
105
|
+
self.notification_intent = notification_intent
|
|
106
|
+
self.info = info
|
|
107
|
+
if isinstance(cause, CcxError):
|
|
108
|
+
self.exit_code = cause.exit_code
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class CursorInvalidated(InvalidRequest):
|
|
112
|
+
"""An opaque paging cursor no longer belongs to the current native source."""
|
|
113
|
+
|
|
114
|
+
exit_code = EXIT_REJECTED
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class SessionNotFound(CcxError):
|
|
118
|
+
"""No session (managed or discoverable external) matches the identifier, or
|
|
119
|
+
a requested transcript/reply does not exist yet."""
|
|
120
|
+
|
|
121
|
+
exit_code = EXIT_NOT_FOUND
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def worker_not_found(target: str) -> SessionNotFound:
|
|
125
|
+
"""One public error for an absent owned worker or missing CLI prefix."""
|
|
126
|
+
return SessionNotFound(f"no such worker: {target}")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class PayloadTooLarge(InvalidRequest):
|
|
130
|
+
"""A send()'s prompt text exceeds the paste-channel payload bound.
|
|
131
|
+
|
|
132
|
+
Managed delivery drains the complete bracketed paste through a bounded PTY
|
|
133
|
+
write path. Operators may still configure ``CCX_SEND_MAX_BYTES`` (unlimited
|
|
134
|
+
by default) to reject payloads above their own resource policy.
|
|
135
|
+
|
|
136
|
+
``size`` is the offending UTF-8 byte length; ``limit`` the enforced bound.
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
def __init__(self, message: str = "", *, short: str | None = None,
|
|
140
|
+
size: int | None = None, limit: int | None = None):
|
|
141
|
+
super().__init__(message, short=short)
|
|
142
|
+
self.size = size
|
|
143
|
+
self.limit = limit
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class CapabilityUnsupported(InvalidRequest):
|
|
147
|
+
"""An engine cannot honor a requested option, and the request is refused
|
|
148
|
+
rather than silently downgraded.
|
|
149
|
+
|
|
150
|
+
Raised only by :class:`ccx.WorkerClient`, whose contract is that an
|
|
151
|
+
unsupported delivery mode, quota budget, or usage projection is an error —
|
|
152
|
+
never a quiet no-op that runs with different semantics than asked for.
|
|
153
|
+
``engine`` and ``capability`` name the offending pair so a caller can branch
|
|
154
|
+
on them without parsing the message. See ``WorkerClient.capabilities()``.
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
def __init__(self, message: str = "", *, short: str | None = None,
|
|
158
|
+
engine: str | None = None, capability: str | None = None):
|
|
159
|
+
super().__init__(message, short=short)
|
|
160
|
+
self.engine = engine
|
|
161
|
+
self.capability = capability
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class ProviderProtocolError(CcxError):
|
|
165
|
+
"""A provider/runtime response violated a contract CCX needs for correctness.
|
|
166
|
+
|
|
167
|
+
Retrying the same request/cursor is not expected to repair the response.
|
|
168
|
+
Protocol V1 maps this to ``provider_protocol_error`` rather than presenting
|
|
169
|
+
the condition as caller input or transient runtime availability.
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
exit_code = EXIT_ERROR
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class OperationTimeout(CcxError):
|
|
176
|
+
"""A bounded operation exhausted its caller-owned deadline.
|
|
177
|
+
|
|
178
|
+
Unlike :class:`TurnIncomplete`, this does not imply that a model turn was
|
|
179
|
+
running or failed to settle; it is for read/control operations whose own
|
|
180
|
+
end-to-end deadline elapsed. V1 maps it to the stable ``timeout`` code.
|
|
181
|
+
"""
|
|
182
|
+
|
|
183
|
+
exit_code = EXIT_TURN_INCOMPLETE
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class SessionUnavailable(CcxError):
|
|
187
|
+
"""The session exists but cannot service this operation in its current state
|
|
188
|
+
— wrong lifecycle state, host not alive, an external session that is
|
|
189
|
+
read-only for this verb, or a failed respawn. The most overloaded code."""
|
|
190
|
+
|
|
191
|
+
exit_code = EXIT_UNAVAILABLE
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class EngineCliNotFound(SessionUnavailable):
|
|
195
|
+
"""An engine's configured native executable cannot run; exit 3."""
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class SpawnFailed(SessionUnavailable):
|
|
199
|
+
"The detached host launcher failed hard — it never handed off to a host\n process (could not run, crashed, or produced no verdict). Distinct from a\n slow boot: a host whose socket is merely late does NOT raise this. ``phase``\n is the launcher verdict phase (see :mod:`ccx.claude.spawn`) and ``detail`` carries\n the launcher's error text when present."
|
|
200
|
+
|
|
201
|
+
def __init__(self, message: str = "", *, short: str | None = None,
|
|
202
|
+
phase: str | None = None, detail: str | None = None):
|
|
203
|
+
super().__init__(message, short=short)
|
|
204
|
+
self.phase = phase
|
|
205
|
+
self.detail = detail
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class SessionWaiting(CcxError):
|
|
209
|
+
"""A turn ended (or a wait resolved) with the session blocked on input — an
|
|
210
|
+
AskUserQuestion dialog or a permission prompt. ``waiting_for`` carries the
|
|
211
|
+
live pending-input preview (the raw host dict) when one is available;
|
|
212
|
+
``pending_input`` is the same thing as a typed
|
|
213
|
+
:class:`~ccx.types.PendingInput`."""
|
|
214
|
+
|
|
215
|
+
exit_code = EXIT_WAITING
|
|
216
|
+
|
|
217
|
+
def __init__(self, message: str = "", *, short: str | None = None,
|
|
218
|
+
waiting_for: dict | None = None):
|
|
219
|
+
super().__init__(message, short=short)
|
|
220
|
+
self.waiting_for = waiting_for
|
|
221
|
+
|
|
222
|
+
@property
|
|
223
|
+
def pending_input(self):
|
|
224
|
+
"""Typed view of ``waiting_for`` (:class:`~ccx.types.PendingInput`),
|
|
225
|
+
or None when no preview was available."""
|
|
226
|
+
from .types import PendingInput # lazy: keep errors dependency-free
|
|
227
|
+
return PendingInput.from_waiting_for(self.waiting_for)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class DeliveryRejected(CcxError):
|
|
231
|
+
"""The host/daemon refused to accept the message or decision (queue full,
|
|
232
|
+
wrong state, unreachable socket). ``reason`` and ``state`` echo the host's
|
|
233
|
+
rejection when present.
|
|
234
|
+
|
|
235
|
+
``refusal`` is the host's STRUCTURED refusal code when it sent one
|
|
236
|
+
(``busy``, ``waiting``, ``input-surface``, ``steer-pending``). Branch on it
|
|
237
|
+
rather than on the prose in ``reason``: only the code distinguishes a
|
|
238
|
+
worker blocked on a question (answer/approve it) from one holding an
|
|
239
|
+
unresolved durable steer (reconcile it, never resend).
|
|
240
|
+
|
|
241
|
+
``native_refusal`` is the engine's own code. It equals ``refusal`` except
|
|
242
|
+
where :class:`~ccx.workers.WorkerClient` normalized a busy refusal (Codex
|
|
243
|
+
``worker-state``) to ``busy``."""
|
|
244
|
+
|
|
245
|
+
exit_code = EXIT_REJECTED
|
|
246
|
+
|
|
247
|
+
def __init__(self, message: str = "", *, short: str | None = None,
|
|
248
|
+
reason: str | None = None, state: str | None = None,
|
|
249
|
+
refusal: str | None = None, delivery_id: str | None = None,
|
|
250
|
+
delivery_status: str | None = None, operation_id: str | None = None,
|
|
251
|
+
native_refusal: str | None = None):
|
|
252
|
+
super().__init__(message, short=short)
|
|
253
|
+
self.reason = reason
|
|
254
|
+
self.state = state
|
|
255
|
+
self.refusal = refusal
|
|
256
|
+
self.native_refusal = native_refusal if native_refusal is not None else refusal
|
|
257
|
+
self.delivery_id = delivery_id
|
|
258
|
+
self.delivery_status = delivery_status
|
|
259
|
+
self.operation_id = operation_id
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
class TurnIncomplete(CcxError):
|
|
263
|
+
"""A blocking call's turn did not finish cleanly — it never reached IDLE
|
|
264
|
+
before the timeout, or the session moved to a crashed/dead/idle-killed
|
|
265
|
+
state mid-turn. ``state`` is the final observed state. Codex turn failures
|
|
266
|
+
also carry the native turn identity, status, and structured error when
|
|
267
|
+
those facts are available."""
|
|
268
|
+
|
|
269
|
+
exit_code = EXIT_TURN_INCOMPLETE
|
|
270
|
+
|
|
271
|
+
def __init__(self, message: str = "", *, short: str | None = None,
|
|
272
|
+
state: str | None = None, turn_id: str | None = None,
|
|
273
|
+
turn_status: str | None = None, turn_error=None):
|
|
274
|
+
super().__init__(message, short=short)
|
|
275
|
+
self.state = state
|
|
276
|
+
self.turn_id = turn_id
|
|
277
|
+
self.turn_status = turn_status
|
|
278
|
+
self.turn_error = turn_error
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
class SendIndeterminate(TurnIncomplete):
|
|
282
|
+
"""A Codex turn may have been accepted and must be reconciled, never resent.
|
|
283
|
+
|
|
284
|
+
The caller-known delivery_id remains the durable key. delivery_status is
|
|
285
|
+
the last durable CCX receipt state, normally prepared or submitted.
|
|
286
|
+
"""
|
|
287
|
+
|
|
288
|
+
def __init__(
|
|
289
|
+
self,
|
|
290
|
+
message: str = "",
|
|
291
|
+
*,
|
|
292
|
+
short: str | None = None,
|
|
293
|
+
state: str | None = None,
|
|
294
|
+
delivery_id: str | None = None,
|
|
295
|
+
delivery_status: str | None = None,
|
|
296
|
+
):
|
|
297
|
+
super().__init__(message, short=short, state=state)
|
|
298
|
+
self.delivery_id = delivery_id
|
|
299
|
+
self.delivery_status = delivery_status
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
class CompactFailed(TurnIncomplete):
|
|
303
|
+
"""Claude reported that a submitted compaction did not happen.
|
|
304
|
+
|
|
305
|
+
A *proven* terminal failure, and the deliberate opposite of
|
|
306
|
+
:class:`CompactIndeterminate`: there the evidence was missing, here Claude
|
|
307
|
+
wrote the verdict itself. The session is unharmed — compaction owns no turn
|
|
308
|
+
of its own, so the worker stays idle and healthy and the conversation is
|
|
309
|
+
simply un-compacted. Claude Code does not retry compaction, so re-issuing
|
|
310
|
+
``compact()`` is the caller's move.
|
|
311
|
+
|
|
312
|
+
``api_error`` carries the :class:`WorkerApiError` when the cause was an API
|
|
313
|
+
failure, and is ``None`` for failures owned by the conversation itself (it
|
|
314
|
+
could not be reduced below the context limit, unstrippable media).
|
|
315
|
+
"""
|
|
316
|
+
|
|
317
|
+
def __init__(self, message: str = "", *, short: str | None = None,
|
|
318
|
+
state: str | None = None, operation_id: str | None = None,
|
|
319
|
+
api_error: "WorkerApiError | None" = None):
|
|
320
|
+
super().__init__(message, short=short, state=state)
|
|
321
|
+
self.operation_id = operation_id
|
|
322
|
+
self.api_error = api_error
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
class CompactIndeterminate(TurnIncomplete):
|
|
326
|
+
"""A native compact was accepted but CCX cannot prove its terminal result.
|
|
327
|
+
|
|
328
|
+
This is deliberately distinct from a confirmed compact failure. The
|
|
329
|
+
operation may still reconcile to success from durable transcript evidence;
|
|
330
|
+
``operation_id`` lets an orchestrator retain and diagnose that identity
|
|
331
|
+
without parsing text.
|
|
332
|
+
"""
|
|
333
|
+
|
|
334
|
+
def __init__(self, message: str = "", *, short: str | None = None,
|
|
335
|
+
state: str | None = None, operation_id: str | None = None,
|
|
336
|
+
operation_status: str | None = None):
|
|
337
|
+
super().__init__(message, short=short, state=state)
|
|
338
|
+
self.operation_id = operation_id
|
|
339
|
+
self.operation_status = operation_status
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
class SteerIndeterminate(TurnIncomplete):
|
|
343
|
+
"""A steer's delivery cannot be established or proven.
|
|
344
|
+
|
|
345
|
+
Raised when durable acceptance itself could not be confirmed (so the caller
|
|
346
|
+
must reconcile with the SAME ``delivery_id`` rather than retry with a fresh
|
|
347
|
+
one), or when an accepted delivery resolved to durable ``unknown``. It is
|
|
348
|
+
never raised merely because an observation window elapsed: a still-pending
|
|
349
|
+
delivery is a successful, honest outcome.
|
|
350
|
+
"""
|
|
351
|
+
|
|
352
|
+
def __init__(self, message: str = "", *, short: str | None = None,
|
|
353
|
+
state: str | None = None, delivery_id: str | None = None,
|
|
354
|
+
delivery_status: str | None = None):
|
|
355
|
+
super().__init__(message, short=short, state=state)
|
|
356
|
+
self.delivery_id = delivery_id
|
|
357
|
+
self.delivery_status = delivery_status
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
class BudgetStopped(CcxError):
|
|
361
|
+
"""A usage cap hard-stopped the run. ``reason`` is the recorded stop reason.
|
|
362
|
+
The session is still inspectable and a fresh (re-capped or uncapped) send
|
|
363
|
+
will revive it."""
|
|
364
|
+
|
|
365
|
+
exit_code = BUDGET_EXIT
|
|
366
|
+
|
|
367
|
+
def __init__(self, message: str = "", *, short: str | None = None,
|
|
368
|
+
reason: str | None = None, phase: str | None = None):
|
|
369
|
+
super().__init__(message, short=short)
|
|
370
|
+
self.reason = reason
|
|
371
|
+
self.phase = phase
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
class WorkerApiError(SessionUnavailable):
|
|
375
|
+
"""A turn ended because the Claude API returned a terminal error.
|
|
376
|
+
|
|
377
|
+
**The catch-all parent, and never raised on its own.** Every failure is one
|
|
378
|
+
of the three concrete subclasses below, each with the exit code its remedy
|
|
379
|
+
deserves. Catch this class when you only need "the API killed this"; catch a
|
|
380
|
+
subclass when you will act differently. If you ever *do* see this exact type,
|
|
381
|
+
it came from hand-construction, not from CCX.
|
|
382
|
+
|
|
383
|
+
CCX does **not** retry on your behalf, and neither should you immediately:
|
|
384
|
+
Claude Code has already spent its own retry budget — roughly ten attempts
|
|
385
|
+
with exponential backoff, honoring ``retry-after`` — before the error is
|
|
386
|
+
ever written to the transcript. An instant retry re-runs a call that just
|
|
387
|
+
failed ten times.
|
|
388
|
+
|
|
389
|
+
The fields are Claude's own classification, so a caller never has to match
|
|
390
|
+
on prose:
|
|
391
|
+
|
|
392
|
+
``kind``
|
|
393
|
+
Claude's error class: ``server_error``, ``authentication_failed``,
|
|
394
|
+
``invalid_request``, ``rate_limit``, ...
|
|
395
|
+
``status``
|
|
396
|
+
HTTP status, or ``None`` for failures that have none — notably a stream
|
|
397
|
+
that dies after a successful 200.
|
|
398
|
+
``transient``
|
|
399
|
+
The failure was a property of the server or connection at that instant
|
|
400
|
+
rather than of the request, so the identical call could succeed later.
|
|
401
|
+
Informational: it says whether coming back later is meaningful at all,
|
|
402
|
+
not that CCX will do it for you.
|
|
403
|
+
``request_id``
|
|
404
|
+
Anthropic's request id, for support tickets.
|
|
405
|
+
``message``
|
|
406
|
+
Claude's own message, verbatim. ``str(exc)`` is the composed CCX line
|
|
407
|
+
(``Claude API <label> (<status>): <that message>``); this attribute is
|
|
408
|
+
the upstream half of it on its own.
|
|
409
|
+
"""
|
|
410
|
+
|
|
411
|
+
def __init__(self, message: str = "", *, short: str | None = None,
|
|
412
|
+
kind: str | None = None, status: int | None = None,
|
|
413
|
+
transient: bool = False, request_id: str | None = None,
|
|
414
|
+
detail: str | None = None):
|
|
415
|
+
super().__init__(message, short=short)
|
|
416
|
+
self.kind = kind
|
|
417
|
+
self.status = status
|
|
418
|
+
self.transient = transient
|
|
419
|
+
self.request_id = request_id
|
|
420
|
+
self.message = detail
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
class WorkerRateLimited(WorkerApiError):
|
|
424
|
+
"""Anthropic rate-limited the account (HTTP 429).
|
|
425
|
+
|
|
426
|
+
Distinct from :class:`BudgetStopped`, which is CCX's *own* usage cap: this
|
|
427
|
+
limit is imposed upstream and clears on a reset the caller does not control.
|
|
428
|
+
Conflating them would hide the one condition no orchestrator should miss.
|
|
429
|
+
|
|
430
|
+
It is also deliberately not folded into ``transient``. Both flags can be
|
|
431
|
+
set at once — Claude Code marks a 429 that is a capacity throttle as
|
|
432
|
+
transient, while a quota wall is not — so ``transient and rate_limited``
|
|
433
|
+
means "throttled, worth retrying soon" and a bare rate limit means "wait
|
|
434
|
+
for the reset".
|
|
435
|
+
|
|
436
|
+
``reset_text`` is the reset wording lifted verbatim from Claude's message
|
|
437
|
+
(e.g. ``"resets 8:40pm (Europe/Madrid)"``), or ``None`` when it carried
|
|
438
|
+
none. It is deliberately NOT parsed into a timestamp: the format is human
|
|
439
|
+
prose, not a contract, and a parser would fail silently when it drifts. Use
|
|
440
|
+
``usage()`` when you need structured quota data.
|
|
441
|
+
"""
|
|
442
|
+
|
|
443
|
+
exit_code = EXIT_RATE_LIMITED
|
|
444
|
+
|
|
445
|
+
def __init__(self, message: str = "", *, short: str | None = None,
|
|
446
|
+
kind: str | None = None, status: int | None = None,
|
|
447
|
+
transient: bool = False, request_id: str | None = None,
|
|
448
|
+
detail: str | None = None, reset_text: str | None = None):
|
|
449
|
+
super().__init__(message, short=short, kind=kind, status=status,
|
|
450
|
+
transient=transient, request_id=request_id,
|
|
451
|
+
detail=detail)
|
|
452
|
+
self.reset_text = reset_text
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
class WorkerApiPermanent(WorkerApiError):
|
|
456
|
+
"""The request itself, the account, or the configuration is wrong.
|
|
457
|
+
|
|
458
|
+
Expired auth, a billing problem, a model you cannot reach, a malformed
|
|
459
|
+
request: the identical call will fail identically, so the remedy is to fix
|
|
460
|
+
something or stop — never to retry. Exit 3, the session-unavailable code,
|
|
461
|
+
because that is exactly the situation.
|
|
462
|
+
|
|
463
|
+
Spelled out as its own class rather than left as "the bare parent" so that
|
|
464
|
+
``type(e) is WorkerApiError`` never has to silently mean "permanent".
|
|
465
|
+
"""
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
class WorkerApiTransient(WorkerApiError):
|
|
469
|
+
"""Anthropic could not serve the request right now, but could later.
|
|
470
|
+
|
|
471
|
+
Covers everything CCX classifies as transient: a 529 (the canonical case —
|
|
472
|
+
Anthropic at capacity), a 5xx, or a stream that died mid-response. They
|
|
473
|
+
share the only property that matters to a caller — the identical call may
|
|
474
|
+
succeed later, unchanged.
|
|
475
|
+
|
|
476
|
+
A 529 is capacity, **not** your quota, and costs nothing against it. That is
|
|
477
|
+
what separates this from :class:`WorkerRateLimited`: here you wait minutes
|
|
478
|
+
and retry, there you wait for a reset you do not control.
|
|
479
|
+
|
|
480
|
+
CCX still does not retry for you. Claude Code has already spent its own
|
|
481
|
+
retry budget before this reaches you, so back off meaningfully rather than
|
|
482
|
+
looping immediately.
|
|
483
|
+
"""
|
|
484
|
+
|
|
485
|
+
exit_code = EXIT_API_TRANSIENT
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def build_worker_api_error(
|
|
489
|
+
message: str, *, short: str | None = None, kind: str | None = None,
|
|
490
|
+
status: int | None = None, transient: bool = False,
|
|
491
|
+
rate_limited: bool = False, request_id: str | None = None,
|
|
492
|
+
detail: str | None = None, reset_text: str | None = None,
|
|
493
|
+
) -> WorkerApiError:
|
|
494
|
+
"""Construct the most specific API-error class the classification supports.
|
|
495
|
+
|
|
496
|
+
Single source of the class-selection rule. Every surface that rebuilds one
|
|
497
|
+
of these — lifecycle records, compact journals, controller replay — goes
|
|
498
|
+
through here, so they cannot drift into disagreeing about which condition
|
|
499
|
+
maps to which class and exit code.
|
|
500
|
+
|
|
501
|
+
Rate limiting wins over transience when both are set: Claude Code marks a
|
|
502
|
+
capacity-throttle 429 as transient, but it is still a rate limit, and that
|
|
503
|
+
is the one an orchestrator must not miss.
|
|
504
|
+
|
|
505
|
+
Always returns a concrete subclass; :class:`WorkerApiError` itself is only
|
|
506
|
+
ever the catch-all, never a verdict.
|
|
507
|
+
"""
|
|
508
|
+
common = {
|
|
509
|
+
"short": short, "kind": kind, "status": status,
|
|
510
|
+
"transient": transient, "request_id": request_id, "detail": detail,
|
|
511
|
+
}
|
|
512
|
+
if rate_limited:
|
|
513
|
+
return WorkerRateLimited(message, reset_text=reset_text, **common)
|
|
514
|
+
if transient:
|
|
515
|
+
return WorkerApiTransient(message, **common)
|
|
516
|
+
return WorkerApiPermanent(message, **common)
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def api_error_from_lifecycle(
|
|
520
|
+
lifecycle: dict | None, *, short: str | None = None,
|
|
521
|
+
) -> WorkerApiError | None:
|
|
522
|
+
"""Rebuild the typed error from a record's ``lifecycle.apiError`` block.
|
|
523
|
+
|
|
524
|
+
Returns ``None`` when the session did not stop on an API error, so callers
|
|
525
|
+
can fall through to their existing generic handling.
|
|
526
|
+
"""
|
|
527
|
+
payload = (lifecycle or {}).get("apiError")
|
|
528
|
+
if not isinstance(payload, dict):
|
|
529
|
+
return None
|
|
530
|
+
reason = (lifecycle or {}).get("lastError") or payload.get("message") or ""
|
|
531
|
+
return build_worker_api_error(
|
|
532
|
+
reason,
|
|
533
|
+
short=short,
|
|
534
|
+
kind=payload.get("kind"),
|
|
535
|
+
status=payload.get("status"),
|
|
536
|
+
transient=bool(payload.get("transient")),
|
|
537
|
+
rate_limited=bool(payload.get("rate_limited")),
|
|
538
|
+
request_id=payload.get("request_id"),
|
|
539
|
+
detail=payload.get("message"),
|
|
540
|
+
reset_text=payload.get("reset_text"),
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
class ControllerError(CcxError):
|
|
545
|
+
"""Base error for the durable Python controller runtime."""
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
class ControllerRunNotFound(ControllerError):
|
|
549
|
+
"""No controller run matches the name or run-id prefix."""
|
|
550
|
+
|
|
551
|
+
exit_code = EXIT_NOT_FOUND
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
class ControllerLaunchRequired(ControllerError):
|
|
555
|
+
"""A decorated controller was invoked outside ``ccx controller run``."""
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
class ControllerReplayMismatch(ControllerError):
|
|
559
|
+
"""Persisted replay identity no longer matches the controller invocation."""
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
class ControllerLiveStateLost(ControllerError):
|
|
563
|
+
"""An accepted worker operation mutated live Python state from a prior attempt.
|
|
564
|
+
|
|
565
|
+
CCX will not redeliver the already-accepted worker turn, and arbitrary live
|
|
566
|
+
object identity is intentionally not reconstructed across controller process
|
|
567
|
+
death. The controller must complete any branch that consumes live variables
|
|
568
|
+
in the same successful task/attempt and return a durable JSON-compatible
|
|
569
|
+
result for later replay.
|
|
570
|
+
"""
|
|
571
|
+
|
|
572
|
+
def __init__(self, message: str = "", *, operation_id: str | None = None):
|
|
573
|
+
super().__init__(message)
|
|
574
|
+
self.operation_id = operation_id
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
class ControllerOperationIndeterminate(ControllerError):
|
|
578
|
+
"""A worker mutation may have landed but cannot be safely replayed."""
|
|
579
|
+
|
|
580
|
+
def __init__(self, message: str = "", *, operation_id: str | None = None):
|
|
581
|
+
super().__init__(message)
|
|
582
|
+
self.operation_id = operation_id
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
# Exit code → exception class, for a thin CLI adapter or tests. Only the codes a
|
|
588
|
+
# library call can raise are listed; EXIT_ERROR is the CcxError default.
|
|
589
|
+
EXIT_CODE_TO_ERROR: dict[int, type[CcxError]] = {
|
|
590
|
+
EXIT_ERROR: InvalidRequest,
|
|
591
|
+
EXIT_NOT_FOUND: SessionNotFound,
|
|
592
|
+
EXIT_UNAVAILABLE: SessionUnavailable,
|
|
593
|
+
EXIT_WAITING: SessionWaiting,
|
|
594
|
+
EXIT_REJECTED: DeliveryRejected,
|
|
595
|
+
EXIT_TURN_INCOMPLETE: TurnIncomplete,
|
|
596
|
+
BUDGET_EXIT: BudgetStopped,
|
|
597
|
+
EXIT_RATE_LIMITED: WorkerRateLimited,
|
|
598
|
+
EXIT_API_TRANSIENT: WorkerApiTransient,
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
__all__ = [
|
|
603
|
+
"CcxError",
|
|
604
|
+
"InvalidRequest",
|
|
605
|
+
"OperationConflict",
|
|
606
|
+
"CursorInvalidated",
|
|
607
|
+
"ProviderProtocolError",
|
|
608
|
+
"OperationTimeout",
|
|
609
|
+
"SessionNotFound",
|
|
610
|
+
"PayloadTooLarge",
|
|
611
|
+
"CapabilityUnsupported",
|
|
612
|
+
"SessionUnavailable",
|
|
613
|
+
"SpawnFailed",
|
|
614
|
+
"SessionWaiting",
|
|
615
|
+
"DeliveryRejected",
|
|
616
|
+
"TurnIncomplete",
|
|
617
|
+
"CompactFailed",
|
|
618
|
+
"CompactIndeterminate",
|
|
619
|
+
"SteerIndeterminate",
|
|
620
|
+
"SendIndeterminate",
|
|
621
|
+
"BudgetStopped",
|
|
622
|
+
"WorkerApiError",
|
|
623
|
+
"WorkerApiPermanent",
|
|
624
|
+
"WorkerApiTransient",
|
|
625
|
+
"WorkerRateLimited",
|
|
626
|
+
"api_error_from_lifecycle",
|
|
627
|
+
"build_worker_api_error",
|
|
628
|
+
"ControllerError",
|
|
629
|
+
"ControllerLaunchRequired",
|
|
630
|
+
"ControllerReplayMismatch",
|
|
631
|
+
"ControllerLiveStateLost",
|
|
632
|
+
"ControllerOperationIndeterminate",
|
|
633
|
+
"EXIT_CODE_TO_ERROR",
|
|
634
|
+
]
|