ccx-native 0.0.1 → 0.1.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 +241 -4
- package/bin/ccx +55 -2
- package/ccx/__init__.py +163 -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 +762 -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 +250 -0
- package/ccx/claude/host/session.py +5184 -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 +2281 -0
- package/ccx/claude/usage.py +837 -0
- package/ccx/claude/usage_control.py +152 -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/read.py +1020 -0
- package/ccx/cli/commands/turns.py +1081 -0
- package/ccx/cli/controller.py +757 -0
- package/ccx/cli/main.py +301 -0
- package/ccx/cli/parser.py +1324 -0
- package/ccx/cli/skills.py +215 -0
- package/ccx/cli/statusline.py +160 -0
- package/ccx/client.py +1900 -0
- package/ccx/codex/__init__.py +0 -0
- package/ccx/codex/attach_tty.py +267 -0
- package/ccx/codex/client.py +732 -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 +1556 -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 +2396 -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/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 +1412 -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/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 +289 -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 +1540 -0
- package/ccx/ops/conversation_paging.py +1338 -0
- package/ccx/ops/create.py +56 -0
- package/ccx/ops/creation.py +952 -0
- package/ccx/ops/creation_steps.py +579 -0
- package/ccx/ops/event_feed.py +710 -0
- package/ccx/ops/forking.py +711 -0
- package/ccx/ops/input.py +556 -0
- package/ccx/ops/maintenance.py +295 -0
- package/ccx/ops/report.py +770 -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 +306 -0
- package/ccx/ops/wait.py +762 -0
- package/ccx/ops/worker_events.py +636 -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 +254 -0
- package/ccx/store/record_cache.py +40 -0
- package/ccx/store/records.py +309 -0
- package/ccx/store/root.py +804 -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/sanitize.py +43 -0
- package/ccx/types.py +705 -0
- package/ccx/version.py +29 -0
- package/ccx/workers.py +2081 -0
- package/ccx.py +9 -0
- package/package.json +39 -7
- package/pyproject.toml +87 -0
- package/skills/ccx/SKILL.md +155 -0
- package/skills/ccx/references/controllers.md +244 -0
- package/skills/ccx/references/troubleshooting.md +94 -0
- package/skills/ccx/references/variables.md +62 -0
package/ccx/ops/wait.py
ADDED
|
@@ -0,0 +1,762 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import time
|
|
3
|
+
from typing import Any
|
|
4
|
+
from ccx.ops import turn_reports
|
|
5
|
+
from ccx.notify.registry import public_registration as public_parent_notification, read_registration as read_parent_notification
|
|
6
|
+
from ccx.errors import (
|
|
7
|
+
CapabilityUnsupported,
|
|
8
|
+
InvalidRequest,
|
|
9
|
+
SessionNotFound,
|
|
10
|
+
SessionUnavailable,
|
|
11
|
+
)
|
|
12
|
+
from ccx.rpc.rpc import (
|
|
13
|
+
_rpc as claude_host_rpc,
|
|
14
|
+
)
|
|
15
|
+
from ccx.ops.common import split_target
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def park_worker(
|
|
19
|
+
target: str, *, timeout: float | None = 90,
|
|
20
|
+
expected_generation: str | None = None,
|
|
21
|
+
_respawn_lock_held: bool = False,
|
|
22
|
+
) -> dict[str, Any]:
|
|
23
|
+
"""Confirmed teardown of one exact full-UUID runtime, native history kept."""
|
|
24
|
+
from ccx import constants
|
|
25
|
+
from ccx.store.root import require_ccx_active
|
|
26
|
+
from ccx.ops.terminate import confirmed_kill_uuid
|
|
27
|
+
handle, _engine, _worker_id = split_target(target)
|
|
28
|
+
store = require_ccx_active(constants.CCX_ROOT,
|
|
29
|
+
expected_generation=expected_generation)
|
|
30
|
+
return confirmed_kill_uuid(
|
|
31
|
+
store, handle, timeout=timeout,
|
|
32
|
+
_respawn_lock_held=_respawn_lock_held,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def inspect_notification(event_id: str) -> dict[str, Any]:
|
|
37
|
+
"""Read the original notification's rich protocol projection, without delivery."""
|
|
38
|
+
from ccx.notify.client import _event_id
|
|
39
|
+
from ccx.notify.paths import registration_path
|
|
40
|
+
|
|
41
|
+
event_id = _event_id(event_id)
|
|
42
|
+
value = read_parent_notification(event_id)
|
|
43
|
+
if value is None:
|
|
44
|
+
# The existing reader returns None for both missing and damaged JSON.
|
|
45
|
+
# One path check distinguishes the two without another parse/registry.
|
|
46
|
+
if registration_path(event_id).exists():
|
|
47
|
+
raise SessionUnavailable(f"notification registration is unreadable or corrupt: {event_id}")
|
|
48
|
+
raise SessionNotFound(f"no such notification registration: {event_id}")
|
|
49
|
+
if value.get("eventId") != event_id:
|
|
50
|
+
raise SessionUnavailable(f"notification registration event ID differs from requested ID: {event_id}")
|
|
51
|
+
public = public_parent_notification(value)
|
|
52
|
+
operation = (
|
|
53
|
+
value.get("operation")
|
|
54
|
+
if isinstance(value.get("operation"), dict)
|
|
55
|
+
else {}
|
|
56
|
+
)
|
|
57
|
+
return {
|
|
58
|
+
**public,
|
|
59
|
+
"source": value.get("source"),
|
|
60
|
+
"attempts": int(value.get("attempts") or 0),
|
|
61
|
+
"last_attempt_at_ms": value.get("lastAttemptAtMs"),
|
|
62
|
+
"last_error": value.get("lastError"),
|
|
63
|
+
"delivered_at_ms": value.get("deliveredAtMs"),
|
|
64
|
+
"cancel_reason": value.get("cancelReason"),
|
|
65
|
+
"caller_metadata": operation.get("callerMetadata"),
|
|
66
|
+
"settlement": (
|
|
67
|
+
value.get("settlement")
|
|
68
|
+
if isinstance(value.get("settlement"), dict)
|
|
69
|
+
else None
|
|
70
|
+
),
|
|
71
|
+
"payload": (
|
|
72
|
+
value.get("payload")
|
|
73
|
+
if isinstance(value.get("payload"), dict)
|
|
74
|
+
else None
|
|
75
|
+
),
|
|
76
|
+
"delivery": (
|
|
77
|
+
value.get("delivery")
|
|
78
|
+
if isinstance(value.get("delivery"), dict)
|
|
79
|
+
else None
|
|
80
|
+
),
|
|
81
|
+
"operation": {
|
|
82
|
+
"kind": operation.get("kind"),
|
|
83
|
+
"delivery_id": operation.get("deliveryId"),
|
|
84
|
+
"turn_id": operation.get("turnId"),
|
|
85
|
+
"resume_waiting_request_id": operation.get(
|
|
86
|
+
"resumeWaitingRequestId"
|
|
87
|
+
),
|
|
88
|
+
"caller_metadata": operation.get("callerMetadata"),
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def wait_worker(
|
|
94
|
+
target: str, *, timeout: float | None = None,
|
|
95
|
+
next_turn: bool = False, after_turn: str | None = None,
|
|
96
|
+
known_input: str | None = None, expected_generation: str | None = None,
|
|
97
|
+
) -> dict[str, Any]:
|
|
98
|
+
"""Observe an exact full-UUID worker, never starting or replaying a turn.
|
|
99
|
+
|
|
100
|
+
A stable-state or new-turn wait returns verdict data; the separate
|
|
101
|
+
wait_delivery observes one original semantic receipt and raises on timeout.
|
|
102
|
+
"""
|
|
103
|
+
from math import isfinite
|
|
104
|
+
from ccx.codex import runtime as codex_runtime
|
|
105
|
+
from ccx import constants
|
|
106
|
+
from ccx.codex.read import pending_input as codex_pending
|
|
107
|
+
from ccx.constants import CODEXD_RPC_GENERATION
|
|
108
|
+
from ccx.store.liveness import owned_process
|
|
109
|
+
from ccx.store.root import GenerationError, require_ccx_active
|
|
110
|
+
from ccx.workers import _claude_pending
|
|
111
|
+
|
|
112
|
+
if (timeout is not None and (
|
|
113
|
+
type(timeout) not in (int, float) or not isfinite(timeout) or timeout < 0
|
|
114
|
+
)):
|
|
115
|
+
raise InvalidRequest("wait timeout must be finite and nonnegative")
|
|
116
|
+
if after_turn is not None and not next_turn:
|
|
117
|
+
raise InvalidRequest("after_turn requires next_turn=true")
|
|
118
|
+
if known_input is not None:
|
|
119
|
+
if not next_turn or not isinstance(known_input, str) or not known_input:
|
|
120
|
+
raise InvalidRequest("known_input requires next_turn and a nonempty request id")
|
|
121
|
+
handle, engine, worker_id = split_target(target)
|
|
122
|
+
if engine == "codex" and next_turn:
|
|
123
|
+
selector = ("wait_known_input" if known_input is not None else
|
|
124
|
+
"wait_after_turn" if after_turn is not None else "wait_next_turn")
|
|
125
|
+
raise CapabilityUnsupported("Codex next-turn wait is not supported",
|
|
126
|
+
engine=engine, capability=selector)
|
|
127
|
+
if after_turn is not None:
|
|
128
|
+
turn_reports._ref_id(after_turn, engine="claude")
|
|
129
|
+
try:
|
|
130
|
+
store = require_ccx_active(constants.CCX_ROOT, expected_generation=expected_generation)
|
|
131
|
+
store.resolve(handle)
|
|
132
|
+
except GenerationError as exc:
|
|
133
|
+
raise SessionUnavailable(f"Cannot access worker records to wait; check CCX_ROOT: {exc}") from exc
|
|
134
|
+
|
|
135
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
136
|
+
terminal = {"dead", "crashed", "idle-killed", "budget-stopped"}
|
|
137
|
+
host_missing_since = None
|
|
138
|
+
|
|
139
|
+
def remaining() -> float:
|
|
140
|
+
return float("inf") if deadline is None else max(0.0, deadline - time.monotonic())
|
|
141
|
+
|
|
142
|
+
def claude_record_settlement(record: dict) -> dict | None:
|
|
143
|
+
"""One validated source-owned Claude checkpoint, or genuinely unknown."""
|
|
144
|
+
last = record.get("lastSettledTurn")
|
|
145
|
+
if not isinstance(last, dict):
|
|
146
|
+
return None
|
|
147
|
+
ref, stamped = last.get("turnRef"), last.get("settledAtMs")
|
|
148
|
+
floor = record.get("lastSettledAt")
|
|
149
|
+
if (isinstance(ref, str) and ref.startswith("ccx-turn:claude:v1:")
|
|
150
|
+
and len(ref) > len("ccx-turn:claude:v1:")
|
|
151
|
+
and type(stamped) is int
|
|
152
|
+
and (type(floor) is not int or stamped >= floor)):
|
|
153
|
+
return {
|
|
154
|
+
"turn_ref": ref, "turn_id": None,
|
|
155
|
+
"outcome": last.get("outcome") or "unknown",
|
|
156
|
+
"completed_at": stamped,
|
|
157
|
+
"context": ({**last["context"], "as_of_turn_ref": ref}
|
|
158
|
+
if isinstance(last.get("context"), dict) else None),
|
|
159
|
+
}
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
def codex_record_settlement(record: dict) -> dict | None:
|
|
163
|
+
"""Only a terminal native Codex turn is an exact checkpoint."""
|
|
164
|
+
last = record.get("lastTurn")
|
|
165
|
+
if (isinstance(last, dict)
|
|
166
|
+
and isinstance(last.get("turnId"), str) and last.get("turnId")
|
|
167
|
+
and last.get("status") in {"completed", "failed", "interrupted"}):
|
|
168
|
+
return turn_reports.codex_settled_turn(
|
|
169
|
+
last, fallback_completed_at=record.get("lastTurnAt"))
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
def snapshot() -> dict[str, Any]:
|
|
173
|
+
store.revalidate()
|
|
174
|
+
rec = store.record(handle)
|
|
175
|
+
lc = rec.get("lifecycle") or {}
|
|
176
|
+
state = lc.get("state")
|
|
177
|
+
waiting = None
|
|
178
|
+
input_block = None
|
|
179
|
+
active = None
|
|
180
|
+
served = False
|
|
181
|
+
unavailable = False
|
|
182
|
+
pending = False
|
|
183
|
+
last_turn = None
|
|
184
|
+
last_settled = None
|
|
185
|
+
|
|
186
|
+
# Each probe fits the transport limit; the overall deadline is unchanged.
|
|
187
|
+
budget = 5.0 if deadline is None else min(600.0, max(0.01, remaining()))
|
|
188
|
+
if engine == "claude":
|
|
189
|
+
last_settled = claude_record_settlement(rec)
|
|
190
|
+
if state not in terminal:
|
|
191
|
+
try:
|
|
192
|
+
reply = claude_host_rpc(handle, {"op": "status"}, timeout=budget)
|
|
193
|
+
except (OSError, ValueError, TimeoutError):
|
|
194
|
+
reply = {}
|
|
195
|
+
if reply.get("ok") is True:
|
|
196
|
+
served = True
|
|
197
|
+
state = reply.get("state") or state
|
|
198
|
+
active = reply.get("activeTurnRef")
|
|
199
|
+
waiting = reply.get("waitingFor") if state == "waiting" else None
|
|
200
|
+
pending = bool(reply.get("pendingDeliveries"))
|
|
201
|
+
# Status may cross the original turn's durable CAS. A host
|
|
202
|
+
# that now certifies stable IDLE must not pair that new
|
|
203
|
+
# state with a stale pre-RPC null settlement snapshot.
|
|
204
|
+
if (state == "idle" and active is None and not pending
|
|
205
|
+
and last_settled is None):
|
|
206
|
+
store.revalidate()
|
|
207
|
+
last_settled = claude_record_settlement(store.record(handle))
|
|
208
|
+
if state == "waiting" and served:
|
|
209
|
+
waiting = waiting or rec.get("waitingFor")
|
|
210
|
+
input_block = _claude_pending(waiting)
|
|
211
|
+
if not served and state not in terminal:
|
|
212
|
+
unavailable = owned_process(
|
|
213
|
+
lc.get("hostPid"), lc.get("hostStart")) == "dead"
|
|
214
|
+
else:
|
|
215
|
+
last_turn = rec.get("lastTurn")
|
|
216
|
+
if state not in terminal:
|
|
217
|
+
try:
|
|
218
|
+
reply = codex_runtime.daemon_rpc({
|
|
219
|
+
"op": "status", "short": worker_id,
|
|
220
|
+
"rpcGeneration": CODEXD_RPC_GENERATION,
|
|
221
|
+
}, timeout=budget)
|
|
222
|
+
except (OSError, ValueError, TimeoutError):
|
|
223
|
+
reply = {}
|
|
224
|
+
served = (reply.get("ok") is True and reply.get("loaded") is True
|
|
225
|
+
and reply.get("transport") == "served"
|
|
226
|
+
and codex_runtime.daemon_generation(reply) == CODEXD_RPC_GENERATION)
|
|
227
|
+
if served:
|
|
228
|
+
state = reply.get("state") or state
|
|
229
|
+
active_id = reply.get("activeTurnId")
|
|
230
|
+
if isinstance(active_id, str) and active_id:
|
|
231
|
+
active = "ccx-turn:codex:v1:" + active_id
|
|
232
|
+
last_turn = reply.get("lastTurn") or last_turn
|
|
233
|
+
waiting = reply.get("pendingApprovals") if state == "waiting" else None
|
|
234
|
+
else:
|
|
235
|
+
unavailable = (reply.get("transport") == "absent"
|
|
236
|
+
or (reply.get("transport") == "served"
|
|
237
|
+
and reply.get("loaded") is False))
|
|
238
|
+
if state == "waiting" and served:
|
|
239
|
+
input_block = codex_pending(waiting or rec.get("pendingApprovals"))
|
|
240
|
+
last_settled = codex_record_settlement({
|
|
241
|
+
"lastTurn": last_turn, "lastTurnAt": rec.get("lastTurnAt")})
|
|
242
|
+
if (served and state == "idle" and active is None
|
|
243
|
+
and not reply.get("pendingApprovals")
|
|
244
|
+
and last_settled is None):
|
|
245
|
+
# The original codexd status normally includes lastTurn, but
|
|
246
|
+
# it can be a pre-commit snapshot while a separate record CAS
|
|
247
|
+
# completes. Refresh the same generation once, never infer a
|
|
248
|
+
# turn from an idle state or make another daemon RPC.
|
|
249
|
+
store.revalidate()
|
|
250
|
+
fresh = store.record(handle)
|
|
251
|
+
last_settled = codex_record_settlement(fresh)
|
|
252
|
+
if last_settled is not None:
|
|
253
|
+
last_turn = fresh.get("lastTurn")
|
|
254
|
+
|
|
255
|
+
# The recorded turn error is useful even when its host is gone.
|
|
256
|
+
# Prefer the exact terminal turn to a stale previous API error.
|
|
257
|
+
sources = (
|
|
258
|
+
(last_turn or {}).get("error") if engine == "codex" else None,
|
|
259
|
+
rec.get("activeTurnError") if engine == "codex" else None,
|
|
260
|
+
(lc.get("apiError") or {}).get("message") if engine == "claude" else None,
|
|
261
|
+
)
|
|
262
|
+
def error_text(value):
|
|
263
|
+
if isinstance(value, str):
|
|
264
|
+
return value or None
|
|
265
|
+
if isinstance(value, dict):
|
|
266
|
+
return value.get("message") or value.get("text") or value.get("error")
|
|
267
|
+
return None
|
|
268
|
+
failure = next((text for source in sources
|
|
269
|
+
if (text := error_text(source))), None)
|
|
270
|
+
result = {
|
|
271
|
+
"worker": handle, "worker_id": worker_id, "engine": engine,
|
|
272
|
+
"short": worker_id[:8], "mode": "managed", "state": state,
|
|
273
|
+
"native_state": state, "status": state,
|
|
274
|
+
"reason": None, "waiting_for": waiting if state == "waiting" and served else None,
|
|
275
|
+
"pending_input": input_block,
|
|
276
|
+
"wait_kind": (input_block or {}).get("kind"),
|
|
277
|
+
"last_settled_turn": last_settled, "active_turn_ref": active,
|
|
278
|
+
"apiError": lc.get("apiError") if engine == "claude" else None,
|
|
279
|
+
"error": failure if state in terminal else None,
|
|
280
|
+
"lastTurn": last_turn if engine == "codex" else None,
|
|
281
|
+
"pendingApprovals": waiting if engine == "codex" and served else None,
|
|
282
|
+
"timed_out": False,
|
|
283
|
+
"_served": served, "_unavailable": unavailable, "_pending": pending,
|
|
284
|
+
}
|
|
285
|
+
if engine == "codex" and state == "idle" and active is None:
|
|
286
|
+
stop = rec.get("quotaStop") or {}
|
|
287
|
+
if (isinstance(last_turn, dict)
|
|
288
|
+
and last_turn.get("turnId") == stop.get("turnId")
|
|
289
|
+
and last_turn.get("status") in {"interrupted", "cancelled", "failed"}
|
|
290
|
+
and stop.get("turnId")):
|
|
291
|
+
result["status"] = "budget-stopped"
|
|
292
|
+
result["reason"] = stop.get("reason") or "usage cap reached"
|
|
293
|
+
store.revalidate()
|
|
294
|
+
return result
|
|
295
|
+
|
|
296
|
+
def finish(value: dict[str, Any], status: str, *, observed: bool = False,
|
|
297
|
+
reason: str | None = None) -> dict[str, Any]:
|
|
298
|
+
out = {key: item for key, item in value.items() if not key.startswith("_")}
|
|
299
|
+
out.update(status=status, timed_out=status == "timeout")
|
|
300
|
+
if reason is not None:
|
|
301
|
+
out["reason"] = reason
|
|
302
|
+
if next_turn:
|
|
303
|
+
out.update(wait_mode="next-turn", next_turn_observed=observed,
|
|
304
|
+
next_turn_event=None)
|
|
305
|
+
store.revalidate()
|
|
306
|
+
return out
|
|
307
|
+
|
|
308
|
+
entry = snapshot()
|
|
309
|
+
entry_ref = (entry.get("last_settled_turn") or {}).get("turn_ref")
|
|
310
|
+
entry_gate = ((entry.get("waiting_for") or {}).get("id")
|
|
311
|
+
if entry["_served"] and engine == "claude" else None)
|
|
312
|
+
entry_waiting = entry["_served"] and entry["state"] == "waiting"
|
|
313
|
+
value = entry
|
|
314
|
+
while True:
|
|
315
|
+
current = value["status"]
|
|
316
|
+
is_terminal = current in terminal
|
|
317
|
+
latest = (value.get("last_settled_turn") or {}).get("turn_ref")
|
|
318
|
+
changed = (latest is not None and
|
|
319
|
+
latest != (after_turn if after_turn is not None else entry_ref))
|
|
320
|
+
gate = value.get("waiting_for") if value["_served"] else None
|
|
321
|
+
gate_id = gate.get("id") if isinstance(gate, dict) else None
|
|
322
|
+
waiting_now = value["_served"] and current == "waiting"
|
|
323
|
+
|
|
324
|
+
# A durable boundary precedes a later crash, without certifying that
|
|
325
|
+
# the model produced a completed assistant message.
|
|
326
|
+
if next_turn and changed:
|
|
327
|
+
return finish(value, current, observed=True)
|
|
328
|
+
if next_turn and known_input is not None:
|
|
329
|
+
if value["_served"] and (not waiting_now or gate_id != known_input):
|
|
330
|
+
return finish(value, current, observed=True)
|
|
331
|
+
if after_turn is not None and latest is None and not is_terminal:
|
|
332
|
+
raise SessionUnavailable(
|
|
333
|
+
"latest exact settlement is unknown; cannot compare after_turn")
|
|
334
|
+
elif next_turn and after_turn is not None:
|
|
335
|
+
if waiting_now:
|
|
336
|
+
return finish(value, "waiting", observed=True)
|
|
337
|
+
if latest is None and not is_terminal:
|
|
338
|
+
raise SessionUnavailable("latest exact settlement is unknown; cannot compare after_turn")
|
|
339
|
+
elif next_turn and waiting_now:
|
|
340
|
+
if not entry_waiting or (gate_id is not None and gate_id != entry_gate):
|
|
341
|
+
return finish(value, "waiting", observed=True)
|
|
342
|
+
|
|
343
|
+
if is_terminal:
|
|
344
|
+
return finish(value, current)
|
|
345
|
+
if value["_unavailable"]:
|
|
346
|
+
if engine == "codex":
|
|
347
|
+
activation = store.native_activation(handle)
|
|
348
|
+
unloaded = (current in {"prepared", "idle", "idle-killed"}
|
|
349
|
+
and activation.get("state") == "confirmed")
|
|
350
|
+
return finish(value, "stopped" if unloaded else "unavailable",
|
|
351
|
+
reason="Codex runtime is absent; no worker was resumed")
|
|
352
|
+
if host_missing_since is None:
|
|
353
|
+
host_missing_since = time.monotonic()
|
|
354
|
+
grace = 5.0 if current in {"booting", "resuming"} else 0.35
|
|
355
|
+
if time.monotonic() - host_missing_since >= grace:
|
|
356
|
+
return finish(value, "host-dead",
|
|
357
|
+
reason="owned Claude host is no longer alive")
|
|
358
|
+
else:
|
|
359
|
+
host_missing_since = None
|
|
360
|
+
if not next_turn and value["_served"]:
|
|
361
|
+
if waiting_now:
|
|
362
|
+
return finish(value, "waiting")
|
|
363
|
+
if (current == "idle" and not value["_pending"]
|
|
364
|
+
and value["active_turn_ref"] is None):
|
|
365
|
+
return finish(value, "idle", reason="worker is idle")
|
|
366
|
+
|
|
367
|
+
# The first observation wins when already stable, even at timeout=0.
|
|
368
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
369
|
+
return finish(value, "timeout", reason="wait deadline elapsed")
|
|
370
|
+
delay = min(0.2, remaining())
|
|
371
|
+
if delay > 0:
|
|
372
|
+
time.sleep(delay)
|
|
373
|
+
value = snapshot()
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def reconcile_turn(
|
|
377
|
+
target: str, *, delivery_id: str | None = None,
|
|
378
|
+
turn_id: str | None = None, timeout: float | None = None,
|
|
379
|
+
include_worker: bool = True,
|
|
380
|
+
expected_generation: str | None = None,
|
|
381
|
+
) -> dict[str, Any]:
|
|
382
|
+
"""Original full-ID delivery receipt reconciliation.
|
|
383
|
+
|
|
384
|
+
Never infer acceptance from a native transcript prefix or retry unknown
|
|
385
|
+
effects; the existing semantic receipt and pinned generation are the
|
|
386
|
+
only authority until a real host receipt adapter is connected. Its one
|
|
387
|
+
write: a Codex receipt left submitted by a lost acknowledgment is accepted
|
|
388
|
+
from the exact journaled native user message carrying its own client id
|
|
389
|
+
(``codex_delivery_recovery``); anything less leaves it unchanged.
|
|
390
|
+
"""
|
|
391
|
+
from ccx.claude import delivery as claude_delivery
|
|
392
|
+
from ccx import constants
|
|
393
|
+
from ccx.store.root import require_ccx_active
|
|
394
|
+
handle, engine, worker_id = split_target(target)
|
|
395
|
+
deadline = time.monotonic() + (timeout if timeout is not None else 30.0)
|
|
396
|
+
if expected_generation is not None:
|
|
397
|
+
require_ccx_active(constants.CCX_ROOT, expected_generation=expected_generation)
|
|
398
|
+
if engine == "codex":
|
|
399
|
+
from ccx.codex import delivery as codex_delivery
|
|
400
|
+
if (delivery_id is None) == (turn_id is None):
|
|
401
|
+
raise InvalidRequest("Codex reconcile needs exactly delivery_id or turn_id")
|
|
402
|
+
from ccx.codex.delivery_recovery import recover, recover_for_turn
|
|
403
|
+
from ccx.store.root import GenerationBusy
|
|
404
|
+
recovery_store = require_ccx_active(constants.CCX_ROOT)
|
|
405
|
+
if delivery_id is not None:
|
|
406
|
+
value = codex_delivery.read_receipt(handle, delivery_id)
|
|
407
|
+
if isinstance(value, dict) and value.get("state") in ("submitted", "accepted"):
|
|
408
|
+
# A lost acknowledgment is recovered only from the exact
|
|
409
|
+
# journaled native user message; otherwise unchanged.
|
|
410
|
+
try:
|
|
411
|
+
value = recover(recovery_store, handle, delivery_id) or value
|
|
412
|
+
except GenerationBusy:
|
|
413
|
+
pass
|
|
414
|
+
else:
|
|
415
|
+
value = codex_delivery.receipt_for_turn(handle, turn_id)
|
|
416
|
+
if value is None:
|
|
417
|
+
# Never call a turn foreign while a submitted delivery of ours
|
|
418
|
+
# may have opened it: recover from exact evidence first.
|
|
419
|
+
try:
|
|
420
|
+
recover_for_turn(recovery_store, handle, turn_id)
|
|
421
|
+
except GenerationBusy:
|
|
422
|
+
pass
|
|
423
|
+
value = codex_delivery.receipt_for_turn(handle, turn_id)
|
|
424
|
+
info = {"worker": handle, "worker_id": worker_id, "engine": "codex"}
|
|
425
|
+
if value is None:
|
|
426
|
+
return {
|
|
427
|
+
"worker": info if include_worker else None,
|
|
428
|
+
"status": "not_found", "pending": False, "owned": False,
|
|
429
|
+
"recommended_action": "none", "delivery": None,
|
|
430
|
+
}
|
|
431
|
+
native_observation = None
|
|
432
|
+
if (value.get("state") == "accepted"
|
|
433
|
+
and value.get("settlement") is None
|
|
434
|
+
and isinstance(value.get("turnId"), str)
|
|
435
|
+
and codex_delivery.route(value) != "steer"):
|
|
436
|
+
# Original turn/completed events survived in the sparse evidence
|
|
437
|
+
# journal, even when the native rollout is too large to hydrate.
|
|
438
|
+
# This also repairs completions that preceded acceptance or a
|
|
439
|
+
# daemon restart, without requiring the old codexd to run new code.
|
|
440
|
+
from ccx.codex.completion import settle_from_journal
|
|
441
|
+
store = require_ccx_active(constants.CCX_ROOT)
|
|
442
|
+
completed = settle_from_journal(store, handle, value["turnId"])
|
|
443
|
+
if completed is not None:
|
|
444
|
+
value = completed
|
|
445
|
+
else:
|
|
446
|
+
# A concurrent native completion may have settled this same
|
|
447
|
+
# accepted receipt while the journal was being observed.
|
|
448
|
+
value = codex_delivery.read_receipt(handle, value["deliveryId"])
|
|
449
|
+
if (value.get("state") == "accepted"
|
|
450
|
+
and value.get("settlement") is None
|
|
451
|
+
and isinstance(value.get("turnId"), str)
|
|
452
|
+
and codex_delivery.route(value) != "steer"):
|
|
453
|
+
# Read native provider-owned thread/read through the actual
|
|
454
|
+
# authenticated singleton codexd. A missing daemon, pending
|
|
455
|
+
# turn or lost observation never causes a second turn/start.
|
|
456
|
+
from ccx.codex.runtime import daemon_rpc
|
|
457
|
+
from ccx.constants import CODEXD_RPC_GENERATION
|
|
458
|
+
from ccx.store.store import _digest
|
|
459
|
+
budget = max(0.001, deadline - time.monotonic())
|
|
460
|
+
observed = daemon_rpc({
|
|
461
|
+
"op": "native-observe",
|
|
462
|
+
"workerId": worker_id,
|
|
463
|
+
"deliveryId": value["deliveryId"],
|
|
464
|
+
"turnId": value["turnId"],
|
|
465
|
+
"observeSeconds": budget,
|
|
466
|
+
"rpcGeneration": CODEXD_RPC_GENERATION,
|
|
467
|
+
}, timeout=budget)
|
|
468
|
+
if (observed.get("ok") is True
|
|
469
|
+
and observed.get("status") in ("failed", "interrupted", "cancelled")
|
|
470
|
+
and observed.get("workerId") == worker_id
|
|
471
|
+
and observed.get("turnId") == value["turnId"]
|
|
472
|
+
and observed.get("deliveryId") == value["deliveryId"]):
|
|
473
|
+
native_observation = observed
|
|
474
|
+
if (observed.get("ok") is True
|
|
475
|
+
and observed.get("status") == "completed"):
|
|
476
|
+
native = store.record(handle)["native"]
|
|
477
|
+
source = store.codex_source(handle)
|
|
478
|
+
if (observed.get("workerId") != worker_id
|
|
479
|
+
or observed.get("engine") != "codex"
|
|
480
|
+
or observed.get("nativeSource") != source
|
|
481
|
+
or observed.get("threadId") != native.get("threadId")
|
|
482
|
+
or native.get("threadSource") != source
|
|
483
|
+
or observed.get("turnId") != value["turnId"]
|
|
484
|
+
or observed.get("deliveryId") != value["deliveryId"]
|
|
485
|
+
or observed.get("nativeEvidenceSha256") != _digest({
|
|
486
|
+
"source": source, "threadId": native["threadId"],
|
|
487
|
+
"turnId": value["turnId"], "status": "completed",
|
|
488
|
+
"text": observed.get("text"),
|
|
489
|
+
})):
|
|
490
|
+
raise SessionUnavailable(
|
|
491
|
+
"Codex completion belongs to another turn; inspect the delivery"
|
|
492
|
+
)
|
|
493
|
+
value = codex_delivery.mark_settled(
|
|
494
|
+
handle, value["deliveryId"],
|
|
495
|
+
turn_id=value["turnId"],
|
|
496
|
+
original_positive_completion=observed,
|
|
497
|
+
)
|
|
498
|
+
result = codex_delivery.public_receipt(value)
|
|
499
|
+
status = result["status"]
|
|
500
|
+
if value.get("state") == "prepared":
|
|
501
|
+
# Auto/steer stay prepared until codexd routes them, but their
|
|
502
|
+
# global intent is fsynced first: once it advanced, the request
|
|
503
|
+
# may still land, so this is submission uncertainty, never retry.
|
|
504
|
+
effect = require_ccx_active(constants.CCX_ROOT).read_receipt(
|
|
505
|
+
handle, "codex-send", value["deliveryId"],
|
|
506
|
+
)
|
|
507
|
+
if isinstance(effect, dict) and effect.get("state") != "prepared":
|
|
508
|
+
status = "submitted"
|
|
509
|
+
result = {**result, "pending": True, "recommended_action": "wait"}
|
|
510
|
+
steered = codex_delivery.route(value) == "steer"
|
|
511
|
+
complete = isinstance(value.get("settlement"), dict) and not steered
|
|
512
|
+
return {
|
|
513
|
+
"worker": info if include_worker else None,
|
|
514
|
+
"status": "settled" if complete else status,
|
|
515
|
+
"pending": result["pending"] or (
|
|
516
|
+
value["state"] == "accepted" and not complete and not steered),
|
|
517
|
+
"owned": result["owned"],
|
|
518
|
+
"recommended_action": result["recommended_action"],
|
|
519
|
+
"route": result["route"], "placement": result["placement"],
|
|
520
|
+
"turn_ref": result["turn_ref"],
|
|
521
|
+
"caller_metadata": result["caller_metadata"],
|
|
522
|
+
"delivery": result,
|
|
523
|
+
**({"native_status": native_observation["status"],
|
|
524
|
+
"native_error": native_observation.get("error")}
|
|
525
|
+
if native_observation else {}),
|
|
526
|
+
}
|
|
527
|
+
if turn_id is not None:
|
|
528
|
+
raise CapabilityUnsupported(
|
|
529
|
+
"Claude reconcile does not support turn_id",
|
|
530
|
+
engine="claude", capability="reconcile_turn_id",
|
|
531
|
+
)
|
|
532
|
+
if delivery_id is None or not claude_delivery.valid_delivery_id(delivery_id):
|
|
533
|
+
raise InvalidRequest("Claude reconcile requires one valid delivery_id")
|
|
534
|
+
store = require_ccx_active(constants.CCX_ROOT,
|
|
535
|
+
expected_generation=expected_generation)
|
|
536
|
+
value = claude_delivery.read_receipt(handle, delivery_id)
|
|
537
|
+
recorded = {"worker": handle, "worker_id": worker_id, "engine": "claude"}
|
|
538
|
+
if value is None:
|
|
539
|
+
return {
|
|
540
|
+
"worker": recorded if include_worker else None,
|
|
541
|
+
"status": "not_found", "pending": False, "owned": False,
|
|
542
|
+
"recommended_action": "none", "delivery": None,
|
|
543
|
+
}
|
|
544
|
+
if (value.get("placement") == "current_turn"
|
|
545
|
+
and isinstance(value.get("settlement"), dict)):
|
|
546
|
+
from ccx.store.root import NativeEffectIndeterminate
|
|
547
|
+
raise NativeEffectIndeterminate(
|
|
548
|
+
"a steer joined the target turn; it cannot borrow its completion"
|
|
549
|
+
)
|
|
550
|
+
# Crash-window repair: the real host can fsync its original accepted
|
|
551
|
+
# completion before it fsyncs the companion native-effect terminal.
|
|
552
|
+
# Reconcile using the same full owner, immutable original prompt spec
|
|
553
|
+
# and native source evidence; never dispatch or invent a new turn.
|
|
554
|
+
if value.get("state") == "accepted" and isinstance(value.get("settlement"), dict):
|
|
555
|
+
from ccx.store.store import OperationIntent, _digest
|
|
556
|
+
from ccx.store.root import NativeEffectIndeterminate, _HASH
|
|
557
|
+
native = store.record(handle).get("native") or {}
|
|
558
|
+
proof = value["settlement"]
|
|
559
|
+
effect = store.read_receipt(handle, "claude-delivery", delivery_id)
|
|
560
|
+
original_spec = claude_delivery.spec_digest(value)
|
|
561
|
+
if (not isinstance(effect, dict)
|
|
562
|
+
or effect.get("intentSha256") != original_spec
|
|
563
|
+
or effect.get("action") != "send"
|
|
564
|
+
or effect.get("workerId") != worker_id
|
|
565
|
+
or effect.get("engine") != "claude"
|
|
566
|
+
or effect.get("generation") != store.generation
|
|
567
|
+
or proof.get("workerId") != worker_id
|
|
568
|
+
or proof.get("deliveryId") != delivery_id
|
|
569
|
+
or proof.get("nativeSource")
|
|
570
|
+
!= f"claude-session:{native.get('sessionId')}"
|
|
571
|
+
# Recovery from an UNKNOWN native effect requires the
|
|
572
|
+
# original provider sessionId explicitly in Stop evidence.
|
|
573
|
+
# Historical/direct positive callbacks already settled the
|
|
574
|
+
# native effect before their independent completion callback;
|
|
575
|
+
# they may contain only the exact nativeSource, not a separate
|
|
576
|
+
# sessionId field. Never weaken submitted-unknown recovery.
|
|
577
|
+
or (effect.get("state") == "submitted-unknown"
|
|
578
|
+
and proof.get("sessionId") != native.get("sessionId"))
|
|
579
|
+
or proof.get("turnRef") != value["turnRef"]
|
|
580
|
+
or proof.get("status") != "completed"
|
|
581
|
+
or not isinstance(proof.get("nativeEvidenceSha256"), str)
|
|
582
|
+
or not _HASH.fullmatch(proof["nativeEvidenceSha256"])):
|
|
583
|
+
raise NativeEffectIndeterminate(
|
|
584
|
+
"Completed turn has no matching delivery record; reconcile it"
|
|
585
|
+
"native source/effect proof; no false settlement"
|
|
586
|
+
)
|
|
587
|
+
if effect.get("state") == "submitted-unknown":
|
|
588
|
+
store.settle_native_intent(
|
|
589
|
+
OperationIntent(
|
|
590
|
+
worker_id, "claude", "claude-delivery", delivery_id,
|
|
591
|
+
original_spec, "submitted-unknown", store.generation,
|
|
592
|
+
),
|
|
593
|
+
state="accepted",
|
|
594
|
+
native_evidence_sha256=proof["nativeEvidenceSha256"],
|
|
595
|
+
)
|
|
596
|
+
elif effect.get("state") != "accepted":
|
|
597
|
+
raise NativeEffectIndeterminate(
|
|
598
|
+
"native-effect state contradicts positively settled original turn"
|
|
599
|
+
)
|
|
600
|
+
projected = claude_delivery.public_receipt(value)
|
|
601
|
+
complete = isinstance(value.get("settlement"), dict)
|
|
602
|
+
status = "settled" if complete else projected["status"]
|
|
603
|
+
return {
|
|
604
|
+
"worker": recorded if include_worker else None,
|
|
605
|
+
"status": status,
|
|
606
|
+
"pending": value["state"] in ("prepared", "owned")
|
|
607
|
+
or value["state"] == "accepted" and not complete,
|
|
608
|
+
"owned": projected["owned"],
|
|
609
|
+
"recommended_action": projected["recommended_action"],
|
|
610
|
+
"route": projected["route"],
|
|
611
|
+
"placement": projected["placement"],
|
|
612
|
+
"turn_ref": projected["turn_ref"],
|
|
613
|
+
"caller_metadata": projected["caller_metadata"],
|
|
614
|
+
"delivery": projected,
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def wait_delivery(
|
|
619
|
+
target: str, *, delivery_id: str, timeout: float | None = None,
|
|
620
|
+
expected_generation: str | None = None,
|
|
621
|
+
) -> dict[str, Any]:
|
|
622
|
+
"""Wait for one delivery, raising on failure or required input."""
|
|
623
|
+
from ccx.ops.report import report_worker
|
|
624
|
+
from ccx import constants
|
|
625
|
+
from ccx.errors import (DeliveryRejected, SessionWaiting, TurnIncomplete,
|
|
626
|
+
SessionUnavailable, BudgetStopped)
|
|
627
|
+
from ccx.store.liveness import owned_process
|
|
628
|
+
from ccx.store.root import require_ccx_active
|
|
629
|
+
|
|
630
|
+
handle, engine, worker_id = split_target(target)
|
|
631
|
+
store = require_ccx_active(constants.CCX_ROOT,
|
|
632
|
+
expected_generation=expected_generation)
|
|
633
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
634
|
+
last_status, last_ref = None, None
|
|
635
|
+
|
|
636
|
+
def fail(exc):
|
|
637
|
+
# The high-level creator attaches the original full UUID; retain the
|
|
638
|
+
# latest *observed* semantic identity on its typed cause as well.
|
|
639
|
+
exc.delivery_id = delivery_id
|
|
640
|
+
exc.delivery_status = last_status
|
|
641
|
+
exc.turn_ref = last_ref
|
|
642
|
+
raise exc
|
|
643
|
+
|
|
644
|
+
while True:
|
|
645
|
+
store.revalidate()
|
|
646
|
+
observation = None
|
|
647
|
+
try:
|
|
648
|
+
observation = reconcile_turn(
|
|
649
|
+
handle, delivery_id=delivery_id,
|
|
650
|
+
expected_generation=store.generation,
|
|
651
|
+
timeout=(None if deadline is None else
|
|
652
|
+
max(0.001, deadline - time.monotonic())),
|
|
653
|
+
)
|
|
654
|
+
except (SessionUnavailable, OSError):
|
|
655
|
+
# A transiently unreachable native daemon cannot prove a new
|
|
656
|
+
# effect or a terminal outcome. Keep the original receipt pending.
|
|
657
|
+
pass
|
|
658
|
+
store.revalidate()
|
|
659
|
+
if observation is not None:
|
|
660
|
+
last_status = observation.get("status")
|
|
661
|
+
receipt = observation.get("delivery") or {}
|
|
662
|
+
last_ref = receipt.get("turn_ref") or last_ref
|
|
663
|
+
if last_status == "settled":
|
|
664
|
+
if (receipt.get("delivery_id") != delivery_id
|
|
665
|
+
or not isinstance(last_ref, str) or not last_ref
|
|
666
|
+
or not isinstance(receipt.get("settlement"), dict)):
|
|
667
|
+
fail(TurnIncomplete("turn has no confirmed completed reply",
|
|
668
|
+
short=worker_id[:8]))
|
|
669
|
+
report = report_worker(handle, turn_ref=last_ref, max_chars=None)
|
|
670
|
+
store.revalidate()
|
|
671
|
+
if (report.get("worker") != handle
|
|
672
|
+
or report.get("delivery_id") != delivery_id
|
|
673
|
+
or report.get("turn_ref") != last_ref
|
|
674
|
+
or report.get("status") != "completed"):
|
|
675
|
+
fail(TurnIncomplete("turn reply does not match its delivery receipt",
|
|
676
|
+
short=worker_id[:8]))
|
|
677
|
+
return {"delivery": receipt, "delivery_status": "settled",
|
|
678
|
+
"turn_ref": last_ref, "report": report}
|
|
679
|
+
if last_status == "rejected":
|
|
680
|
+
fail(DeliveryRejected(
|
|
681
|
+
receipt.get("rejection_message") or "turn's prompt was rejected",
|
|
682
|
+
short=worker_id[:8], refusal=receipt.get("rejection_code"),
|
|
683
|
+
delivery_id=delivery_id, delivery_status="rejected",
|
|
684
|
+
))
|
|
685
|
+
if (engine == "codex" and observation.get("native_status")
|
|
686
|
+
in ("failed", "interrupted", "cancelled")):
|
|
687
|
+
marker = store.record(handle).get("quotaStop") or {}
|
|
688
|
+
if marker.get("turnId") == receipt.get("turn_id"):
|
|
689
|
+
reason = marker.get("reason") or "usage cap reached"
|
|
690
|
+
fail(BudgetStopped(f"budget stop: {reason}",
|
|
691
|
+
short=worker_id[:8], reason=reason, phase="turn"))
|
|
692
|
+
fail(TurnIncomplete(
|
|
693
|
+
"Codex turn ended " + observation["native_status"],
|
|
694
|
+
short=worker_id[:8], turn_id=receipt.get("turn_id"),
|
|
695
|
+
turn_status=observation["native_status"],
|
|
696
|
+
turn_error=observation.get("native_error"),
|
|
697
|
+
))
|
|
698
|
+
if last_status in ("not_found", "prepared"):
|
|
699
|
+
fail(TurnIncomplete(
|
|
700
|
+
"Message submission is not confirmed; "
|
|
701
|
+
f"reconcile delivery_id={delivery_id!r}, or retry with that "
|
|
702
|
+
"same delivery ID and message",
|
|
703
|
+
short=worker_id[:8],
|
|
704
|
+
))
|
|
705
|
+
# A missing Stop can leave a Claude owned receipt forever. The
|
|
706
|
+
# original worker's own gate/death is conclusive only while this first
|
|
707
|
+
# delivery remains unsettled (the exact receipt check runs first).
|
|
708
|
+
record = store.record(handle)
|
|
709
|
+
lifecycle = record.get("lifecycle") or {}
|
|
710
|
+
state = lifecycle.get("state")
|
|
711
|
+
if engine == "claude":
|
|
712
|
+
settled = record.get("lastSettledTurn") or {}
|
|
713
|
+
outcome = settled.get("outcome")
|
|
714
|
+
if last_ref and settled.get("turnRef") == last_ref and outcome in {
|
|
715
|
+
"interrupted", "failed", "cancelled"}:
|
|
716
|
+
fail(TurnIncomplete("Claude turn ended " + outcome,
|
|
717
|
+
short=worker_id[:8], turn_status=outcome))
|
|
718
|
+
pending = record.get("waitingFor")
|
|
719
|
+
if pending or state == "waiting":
|
|
720
|
+
fail(SessionWaiting(
|
|
721
|
+
"Claude turn needs input; use ccx answer or approve", short=worker_id[:8],
|
|
722
|
+
waiting_for=pending,
|
|
723
|
+
))
|
|
724
|
+
if state == "budget-stopped":
|
|
725
|
+
reason = lifecycle.get("lastError") or "usage cap reached"
|
|
726
|
+
fail(BudgetStopped(
|
|
727
|
+
f"budget stop: {reason}", short=worker_id[:8],
|
|
728
|
+
reason=reason, phase="turn",
|
|
729
|
+
))
|
|
730
|
+
if state in {"dead", "crashed", "idle-killed", "killed", "stopped", "budget-stopped"}:
|
|
731
|
+
fail(TurnIncomplete("Claude turn cannot complete: " + state,
|
|
732
|
+
short=worker_id[:8], state=state))
|
|
733
|
+
if (lifecycle.get("hostPid") and owned_process(
|
|
734
|
+
lifecycle.get("hostPid"), lifecycle.get("hostStart")) == "dead"):
|
|
735
|
+
fail(TurnIncomplete("Claude stopped before completing the turn",
|
|
736
|
+
short=worker_id[:8], state=state))
|
|
737
|
+
elif engine == "codex":
|
|
738
|
+
# This stored original native turn is another source of positive
|
|
739
|
+
# terminal evidence when codexd becomes temporarily unavailable.
|
|
740
|
+
receipt_id = (observation or {}).get("delivery") or {}
|
|
741
|
+
if not receipt_id:
|
|
742
|
+
from ccx.codex.delivery import public_receipt, read_receipt
|
|
743
|
+
receipt_id = public_receipt(read_receipt(handle, delivery_id)) or {}
|
|
744
|
+
native_turn = receipt_id.get("turn_id")
|
|
745
|
+
last = record.get("lastTurn") or {}
|
|
746
|
+
if (native_turn and last.get("turnId") == native_turn
|
|
747
|
+
and last.get("status") in {"failed", "interrupted", "cancelled"}):
|
|
748
|
+
marker = record.get("quotaStop") or {}
|
|
749
|
+
if marker.get("turnId") == native_turn:
|
|
750
|
+
reason = marker.get("reason") or "usage cap reached"
|
|
751
|
+
fail(BudgetStopped(f"budget stop: {reason}",
|
|
752
|
+
short=worker_id[:8], reason=reason, phase="turn"))
|
|
753
|
+
fail(TurnIncomplete(
|
|
754
|
+
"Codex turn ended " + last["status"],
|
|
755
|
+
short=worker_id[:8], turn_id=native_turn,
|
|
756
|
+
turn_status=last["status"], turn_error=last.get("error"),
|
|
757
|
+
))
|
|
758
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
759
|
+
fail(TurnIncomplete("turn did not finish before timeout",
|
|
760
|
+
short=worker_id[:8], state=state))
|
|
761
|
+
time.sleep(.2 if deadline is None else
|
|
762
|
+
min(.2, max(0., deadline - time.monotonic())))
|