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/rpc/rpc.py
ADDED
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import fcntl
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import signal
|
|
7
|
+
import socket
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
from ccx.constants import (
|
|
11
|
+
BUDGET_EXIT,
|
|
12
|
+
HOST_SOCK_WAIT_RESPAWN_S,
|
|
13
|
+
RESPAWN_IDLE_DEADLINE_S,
|
|
14
|
+
S,
|
|
15
|
+
)
|
|
16
|
+
from ccx.store.liveness import lifecycle_host_alive, owned_pid_alive, owned_process
|
|
17
|
+
from ccx.store.paths import respawn_lock_path, sock_path
|
|
18
|
+
from ccx.store.records import merge_lifecycle, read_record
|
|
19
|
+
from ccx.claude.settings import ensure_trusted_paths, write_settings
|
|
20
|
+
from ccx.claude.spawn import PHASE_SOCK_TIMEOUT, spawn_host_detached
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def host_socket_live(short: str, rec: dict) -> bool:
|
|
24
|
+
"""Is the session's managed host actually reachable over its Unix socket?
|
|
25
|
+
|
|
26
|
+
True only when the socket file is present AND, if we recorded a hostPid,
|
|
27
|
+
that process is still alive. A host killed ungracefully (host crash, SIGKILL,
|
|
28
|
+
machine reset) can leave its socket on disk while the lifecycle state is
|
|
29
|
+
still non-terminal — dialing that stale socket would otherwise refuse the
|
|
30
|
+
connection. Detecting the dead hostPid lets `send` revive transparently and
|
|
31
|
+
`answer`/`approve` fail with a clear "host not alive" instead of a confusing
|
|
32
|
+
connection-refused rejection. A None hostPid (host mid-boot, pid not yet
|
|
33
|
+
recorded) is treated as live to avoid racing a legitimate startup."""
|
|
34
|
+
if not sock_path(short).exists():
|
|
35
|
+
return False
|
|
36
|
+
lifecycle = rec.get("lifecycle") or {}
|
|
37
|
+
return not (lifecycle.get("hostPid") and not lifecycle_host_alive(lifecycle))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _rpc(short: str, msg: dict, timeout: float = 5.0) -> dict:
|
|
41
|
+
"""One existing host RPC with same-connection Linux PID/start handshake.
|
|
42
|
+
|
|
43
|
+
First read the SERVER's exact full-root/generation/worker handshake on
|
|
44
|
+
the SAME socket used for the subsequent native request. Verify its
|
|
45
|
+
kernel SO_PEERCRED PID/start against the currently owned worker record
|
|
46
|
+
and revalidate the generation before writing an operation. Never infer
|
|
47
|
+
ownership from an AF_UNIX pathname or same UID alone.
|
|
48
|
+
"""
|
|
49
|
+
from ccx import constants
|
|
50
|
+
from ccx.store.root import require_ccx_active
|
|
51
|
+
from ccx.rpc.transport import (
|
|
52
|
+
plan_transport, handshake_payload, verify_handshake,
|
|
53
|
+
)
|
|
54
|
+
from ccx.store.identity import EngineMismatch
|
|
55
|
+
|
|
56
|
+
store = require_ccx_active(constants.CCX_ROOT)
|
|
57
|
+
owner = store.resolve(short)
|
|
58
|
+
if owner.engine != "claude":
|
|
59
|
+
raise EngineMismatch("Claude host RPC requires full Claude worker UUID")
|
|
60
|
+
plan = plan_transport(store, target=owner.worker)
|
|
61
|
+
if type(timeout) not in (int, float) or timeout <= 0 or timeout > 600:
|
|
62
|
+
return {"ok": False, "error": "invalid bounded RPC timeout"}
|
|
63
|
+
|
|
64
|
+
def one_line(sock: socket.socket) -> dict:
|
|
65
|
+
buf = bytearray()
|
|
66
|
+
while True:
|
|
67
|
+
chunk = sock.recv(4096)
|
|
68
|
+
if not chunk:
|
|
69
|
+
return {"ok": False, "error": "no reply"}
|
|
70
|
+
buf.extend(chunk)
|
|
71
|
+
if len(buf) > 1024 * 1024:
|
|
72
|
+
return {"ok": False, "error": "reply too large"}
|
|
73
|
+
if b"\n" in buf:
|
|
74
|
+
try:
|
|
75
|
+
value = json.loads(bytes(buf).split(b"\n", 1)[0])
|
|
76
|
+
except (UnicodeError, ValueError):
|
|
77
|
+
return {"ok": False, "error": "bad reply"}
|
|
78
|
+
return value if isinstance(value, dict) else {
|
|
79
|
+
"ok": False, "error": "non-object reply"
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
|
|
84
|
+
sock.settimeout(timeout)
|
|
85
|
+
sock.connect(str(plan.final))
|
|
86
|
+
sock.sendall((json.dumps({
|
|
87
|
+
"op": "handshake",
|
|
88
|
+
"identity": handshake_payload(plan),
|
|
89
|
+
}) + "\n").encode())
|
|
90
|
+
hello = one_line(sock)
|
|
91
|
+
if not hello.get("ok"):
|
|
92
|
+
return {"ok": False, "error": "unverified host: " + str(
|
|
93
|
+
hello.get("error", "no handshake reply")
|
|
94
|
+
)}
|
|
95
|
+
verify_handshake(
|
|
96
|
+
store, plan, received=hello.get("identity"),
|
|
97
|
+
connected_socket=sock,
|
|
98
|
+
)
|
|
99
|
+
store.revalidate() # closing gate BEFORE native request
|
|
100
|
+
sock.sendall((json.dumps(msg) + "\n").encode())
|
|
101
|
+
return one_line(sock)
|
|
102
|
+
except socket.timeout:
|
|
103
|
+
return {"ok": False, "error": "timeout"}
|
|
104
|
+
except OSError as exc:
|
|
105
|
+
return {"ok": False, "error": f"unreachable: {exc.__class__.__name__}"}
|
|
106
|
+
|
|
107
|
+
def _drain_old_host(
|
|
108
|
+
short: str,
|
|
109
|
+
rec: dict,
|
|
110
|
+
timeout: float = 8.0,
|
|
111
|
+
*,
|
|
112
|
+
force_on_timeout: bool = True,
|
|
113
|
+
) -> bool:
|
|
114
|
+
"""Block until any prior host for `short` is fully gone before we fork a new
|
|
115
|
+
one. `ccx kill` only *signals* the host; its shutdown (worker SIGTERM + an
|
|
116
|
+
up-to-5s wait, then sock unlink, then a final lifecycle write) can still be in
|
|
117
|
+
flight when a revive `send` arrives right after. Forking into that window
|
|
118
|
+
causes two failures:
|
|
119
|
+
- our sock-wait latches the dying host's stale sock and reads its terminal
|
|
120
|
+
state, so the IDLE-wait bails with a spurious rc=1; and
|
|
121
|
+
- the old host's *post-unlink* final `merge_lifecycle` clobbers the new
|
|
122
|
+
host's `booting`/`hostPid` straight back to the terminal state.
|
|
123
|
+
Both are only safe once the old host's PID is dead (past that last write), so
|
|
124
|
+
we wait on the PID, not just the sock. A host still alive at the normal
|
|
125
|
+
drain timeout is wedged — force-kill it and drop its sock so revive can
|
|
126
|
+
proceed. When an outer operation deadline shortens the wait,
|
|
127
|
+
``force_on_timeout=False`` returns ``False`` instead: a caller's small
|
|
128
|
+
timeout must not make an otherwise healthy shutdown look wedged."""
|
|
129
|
+
sp = sock_path(short)
|
|
130
|
+
lifecycle = rec.get("lifecycle") or {}
|
|
131
|
+
hp = lifecycle.get("hostPid")
|
|
132
|
+
host_start = lifecycle.get("hostStart")
|
|
133
|
+
deadline = time.time() + timeout
|
|
134
|
+
while time.time() < deadline:
|
|
135
|
+
presence = owned_process(hp, host_start)
|
|
136
|
+
if presence == "unprovable":
|
|
137
|
+
return False # cannot remove a potentially live owner's socket
|
|
138
|
+
if presence == "dead":
|
|
139
|
+
# Old host is proved gone (or none was recorded).
|
|
140
|
+
try:
|
|
141
|
+
sp.unlink()
|
|
142
|
+
except (OSError, FileNotFoundError):
|
|
143
|
+
pass
|
|
144
|
+
return True
|
|
145
|
+
time.sleep(min(0.1, max(0.0, deadline - time.time())))
|
|
146
|
+
if not force_on_timeout:
|
|
147
|
+
return False
|
|
148
|
+
# Wedged: the prior host outlived the drain budget. Force it down — but
|
|
149
|
+
# only the recorded host itself, never a process that reused its pid.
|
|
150
|
+
presence = owned_process(hp, host_start)
|
|
151
|
+
if presence == "unprovable":
|
|
152
|
+
return False
|
|
153
|
+
if presence == "alive":
|
|
154
|
+
try:
|
|
155
|
+
os.kill(hp, signal.SIGKILL)
|
|
156
|
+
except OSError:
|
|
157
|
+
pass
|
|
158
|
+
try:
|
|
159
|
+
sp.unlink()
|
|
160
|
+
except (OSError, FileNotFoundError):
|
|
161
|
+
pass
|
|
162
|
+
return True
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _missing_claude_bin() -> str | None:
|
|
166
|
+
"""Why the configured Claude binary cannot run, or None when it can."""
|
|
167
|
+
from ccx.system.engine_binary import resolve_engine_binary
|
|
168
|
+
from ccx.errors import SessionUnavailable
|
|
169
|
+
|
|
170
|
+
try:
|
|
171
|
+
resolve_engine_binary("claude")
|
|
172
|
+
return None
|
|
173
|
+
except SessionUnavailable as error:
|
|
174
|
+
return str(error)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _record_respawn_failure(short: str, detail: str) -> int:
|
|
178
|
+
"""Make a failed revive retryable and diagnostically honest.
|
|
179
|
+
|
|
180
|
+
``RESUMING`` is transitional: it is only valid while a newly launched host
|
|
181
|
+
is expected to take ownership. If no host was handed off, leaving that
|
|
182
|
+
state on disk makes UIs show an endless resume and obscures the launch
|
|
183
|
+
error. ``CRASHED`` is both truthful and already part of the transparent
|
|
184
|
+
revive set, so the next send can retry normally.
|
|
185
|
+
"""
|
|
186
|
+
try:
|
|
187
|
+
merge_lifecycle(
|
|
188
|
+
short,
|
|
189
|
+
state=S.CRASHED,
|
|
190
|
+
hostPid=None,
|
|
191
|
+
hostStart=None,
|
|
192
|
+
workerPid=None,
|
|
193
|
+
workerStart=None,
|
|
194
|
+
lastError=f"respawn: {detail}",
|
|
195
|
+
# Invariant: whoever writes a new lastError also clears apiError.
|
|
196
|
+
# The RESUMING write already cleared it on this path, but keeping
|
|
197
|
+
# the rule local means a future reordering cannot resurrect a stale
|
|
198
|
+
# classification against a fresh, unrelated reason.
|
|
199
|
+
apiError=None,
|
|
200
|
+
)
|
|
201
|
+
except OSError:
|
|
202
|
+
pass
|
|
203
|
+
return 1
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _respawn_failure_message(short: str, rc: int) -> str:
|
|
207
|
+
"""Return the actionable recorded reason for a failed managed respawn.
|
|
208
|
+
|
|
209
|
+
``_respawn`` keeps its historic integer return contract because the CLI and
|
|
210
|
+
Python clients share it. The lifecycle record is the durable diagnostic
|
|
211
|
+
channel, so every caller should surface ``lastError`` instead of collapsing
|
|
212
|
+
all failures to an opaque ``rc=1``.
|
|
213
|
+
"""
|
|
214
|
+
last_error = (
|
|
215
|
+
((read_record(short) or {}).get("lifecycle") or {}).get("lastError")
|
|
216
|
+
)
|
|
217
|
+
if isinstance(last_error, str) and last_error.strip():
|
|
218
|
+
detail = last_error.strip()
|
|
219
|
+
if detail.startswith("respawn:"):
|
|
220
|
+
detail = detail.removeprefix("respawn:").strip()
|
|
221
|
+
return f"respawn failed (rc={rc}): {detail}"
|
|
222
|
+
return f"respawn failed (rc={rc})"
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _acquire_respawn_lock(handle, deadline: float | None) -> bool:
|
|
226
|
+
"""Acquire one session's revive lock, respecting an optional deadline."""
|
|
227
|
+
while True:
|
|
228
|
+
if deadline is not None and time.time() >= deadline:
|
|
229
|
+
return False
|
|
230
|
+
try:
|
|
231
|
+
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
232
|
+
return True
|
|
233
|
+
except BlockingIOError:
|
|
234
|
+
wait_s = 0.05
|
|
235
|
+
if deadline is not None:
|
|
236
|
+
wait_s = min(wait_s, max(0.0, deadline - time.time()))
|
|
237
|
+
time.sleep(wait_s)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _responsive_nonterminal_host(
|
|
241
|
+
short: str, deadline: float | None = None
|
|
242
|
+
) -> bool:
|
|
243
|
+
"""Whether another caller already established a usable managed host."""
|
|
244
|
+
# A terminal record can briefly coexist with the old host's still-responsive
|
|
245
|
+
# socket while `kill` is draining. Never reuse that dying host; the normal
|
|
246
|
+
# locked revive path waits for its PID to exit before launching a successor.
|
|
247
|
+
rec_state = ((read_record(short) or {}).get("lifecycle") or {}).get("state")
|
|
248
|
+
if rec_state in (S.IDLE_KILLED, S.CRASHED, S.DEAD, S.BUDGET_STOPPED):
|
|
249
|
+
return False
|
|
250
|
+
if not sock_path(short).exists():
|
|
251
|
+
return False
|
|
252
|
+
timeout = 1.0
|
|
253
|
+
if deadline is not None:
|
|
254
|
+
remaining = deadline - time.time()
|
|
255
|
+
if remaining <= 0:
|
|
256
|
+
return False
|
|
257
|
+
timeout = min(timeout, remaining)
|
|
258
|
+
status = _rpc(short, {"op": "status"}, timeout=timeout)
|
|
259
|
+
return bool(
|
|
260
|
+
status.get("ok")
|
|
261
|
+
and status.get("state")
|
|
262
|
+
not in (S.IDLE_KILLED, S.CRASHED, S.DEAD, S.BUDGET_STOPPED)
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _finalize_killed_steers(
|
|
267
|
+
store, handle: str, *, child_pid: int | None, child_start: str | None,
|
|
268
|
+
lock_held: bool = False,
|
|
269
|
+
) -> None:
|
|
270
|
+
"""Fence kill cleanup against a newer Claude lifetime on respawn.lock.
|
|
271
|
+
|
|
272
|
+
A concurrent revive can already have launched a new child and accepted its
|
|
273
|
+
own steer. Its current nativeStartProof MUST still match the old dead child
|
|
274
|
+
when the finalizer runs, or nothing in the newer hot journal is touched.
|
|
275
|
+
"""
|
|
276
|
+
def finish() -> None:
|
|
277
|
+
lifecycle = (store.record(handle).get("lifecycle") or {})
|
|
278
|
+
proof = lifecycle.get("nativeStartProof") or {}
|
|
279
|
+
if (type(child_pid) is not int or not child_start
|
|
280
|
+
or proof.get("workerPid") != child_pid
|
|
281
|
+
or str(proof.get("workerStart")) != str(child_start)
|
|
282
|
+
or owned_process(child_pid, child_start) != "dead"):
|
|
283
|
+
return
|
|
284
|
+
from ccx.ops.steer_ops import finalize_dead_claude_steers
|
|
285
|
+
finalize_dead_claude_steers(
|
|
286
|
+
store, handle, child_pid=child_pid, child_start=child_start)
|
|
287
|
+
|
|
288
|
+
if lock_held:
|
|
289
|
+
# _respawn_locked already owns this same flock; reacquiring would
|
|
290
|
+
# deadlock when its park_worker path confirms an old child kill.
|
|
291
|
+
finish()
|
|
292
|
+
return
|
|
293
|
+
short = store.resolve(handle).worker_id
|
|
294
|
+
lock_path = respawn_lock_path(short)
|
|
295
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
296
|
+
with lock_path.open("a") as lock:
|
|
297
|
+
try:
|
|
298
|
+
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
299
|
+
except BlockingIOError:
|
|
300
|
+
return # native kill succeeded; a locked revive repairs old receipts
|
|
301
|
+
finish()
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _respawn(short: str, caps: dict | None = None,
|
|
305
|
+
deadline: float | None = None, *,
|
|
306
|
+
report_launch: bool = False) -> int | tuple[int, bool]:
|
|
307
|
+
'Serialize and respawn a dead host for an existing session.\n\n The per-session file lock spans the full revive-to-IDLE transition. After\n acquiring it, liveness is re-checked: a concurrent ``send`` or ``compact``\n may already have revived the worker while this caller was waiting, in which\n case the existing host is reused instead of launching a duplicate.\n\n Returns 0 on success. With report_launch=True, returns (rc, launched)\n so concurrent revivers cannot both claim to have started a new host.\n\n Usage caps are run-scoped: a respawn always starts uncapped unless `caps` is\n passed (i.e. the reviving `send` explicitly re-specifies a cap). This is what\n lets you "break the cap" — send to a budget-stopped session and keep going.\n\n The host is launched via :func:`ccx.claude.spawn.spawn_host_detached` — never an\n in-process ``os.fork()`` — so revive is safe from threaded library callers\n and the revived host survives the caller exiting. `deadline` (epoch seconds,\n optional) caps the whole revive: the sock wait and the post-spawn IDLE wait\n are clamped to it so a caller\'s send `timeout` truthfully bounds the revive\n too. ``None`` keeps the historic bounds (sock 10s, IDLE\n ``RESPAWN_IDLE_DEADLINE_S``).'
|
|
308
|
+
lp = respawn_lock_path(short)
|
|
309
|
+
try:
|
|
310
|
+
lp.parent.mkdir(parents=True, exist_ok=True)
|
|
311
|
+
with lp.open("a") as lock:
|
|
312
|
+
if not _acquire_respawn_lock(lock, deadline):
|
|
313
|
+
return (1, False) if report_launch else 1
|
|
314
|
+
if _responsive_nonterminal_host(short, deadline):
|
|
315
|
+
return (0, False) if report_launch else 0
|
|
316
|
+
if deadline is not None and time.time() >= deadline:
|
|
317
|
+
return (1, False) if report_launch else 1
|
|
318
|
+
rc = _respawn_locked(short, caps=caps, deadline=deadline)
|
|
319
|
+
return (rc, rc == 0) if report_launch else rc
|
|
320
|
+
except OSError as e:
|
|
321
|
+
try:
|
|
322
|
+
# Clear the old diagnosis with the old reason: this failure is the
|
|
323
|
+
# lock's, and it happens BEFORE the RESUMING write that would
|
|
324
|
+
# otherwise have cleared it.
|
|
325
|
+
merge_lifecycle(short, lastError=f"respawn-lock: {e}", apiError=None)
|
|
326
|
+
except OSError:
|
|
327
|
+
pass
|
|
328
|
+
return (1, False) if report_launch else 1
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _respawn_locked(short: str, caps: dict | None = None,
|
|
332
|
+
deadline: float | None = None) -> int:
|
|
333
|
+
"""Perform a managed-host revive while the caller holds its session lock."""
|
|
334
|
+
rec = read_record(short)
|
|
335
|
+
if rec is None:
|
|
336
|
+
return 2
|
|
337
|
+
dargs = rec.get("createArgs", {})
|
|
338
|
+
cwd = rec["cwd"]
|
|
339
|
+
|
|
340
|
+
# A managed session can outlive a temporary/project directory. Never
|
|
341
|
+
# recreate it implicitly: the caller may need to restore a specific
|
|
342
|
+
# worktree, and silently resuming in an empty directory would give the model
|
|
343
|
+
# a materially different environment. Fail before launching and preserve an
|
|
344
|
+
# actionable reason for CLI, Python, and dashboard callers.
|
|
345
|
+
if not os.path.isdir(cwd):
|
|
346
|
+
return _record_respawn_failure(
|
|
347
|
+
short,
|
|
348
|
+
f"recorded cwd does not exist or is not a directory: {cwd}; "
|
|
349
|
+
"restore that directory before resuming this session",
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
# Reuse the accepted K host+native-child teardown (same exact UUID,
|
|
353
|
+
# PID/start pair and terminal CAS) rather than adding a second orphan
|
|
354
|
+
# sweep. _respawn holds only respawn.lock; park_worker never takes it.
|
|
355
|
+
# A running prior BOOTING/RESUMING host is an outstanding original launch,
|
|
356
|
+
# NOT permission to start a second provider process.
|
|
357
|
+
from ccx import constants
|
|
358
|
+
from ccx.store.root import require_ccx_active
|
|
359
|
+
from ccx.ops.wait import park_worker
|
|
360
|
+
store = require_ccx_active(constants.CCX_ROOT)
|
|
361
|
+
handle = store.resolve(short).worker
|
|
362
|
+
store.authorize_native_action(handle, "resume")
|
|
363
|
+
lifecycle = rec.get("lifecycle") or {}
|
|
364
|
+
if lifecycle.get("state") in (S.BOOTING, S.RESUMING):
|
|
365
|
+
hp, hs = lifecycle.get("hostPid"), lifecycle.get("hostStart")
|
|
366
|
+
if hp and owned_process(hp, hs) != "dead":
|
|
367
|
+
return 1 # observe the original in-flight startup, no new launch
|
|
368
|
+
if lifecycle.get("state") == S.RESUMING:
|
|
369
|
+
return 1 # indeterminate last launcher, no speculative replay
|
|
370
|
+
if lifecycle.get("state") in (S.BUSY, S.WAITING):
|
|
371
|
+
hp, hs = lifecycle.get("hostPid"), lifecycle.get("hostStart")
|
|
372
|
+
if hp and owned_process(hp, hs) != "dead":
|
|
373
|
+
return 1 # live owned turn/gate: a missed status probe is no teardown proof
|
|
374
|
+
recorded_process = (lifecycle.get("hostPid") is not None
|
|
375
|
+
or lifecycle.get("workerPid") is not None)
|
|
376
|
+
if (lifecycle.get("state") not in
|
|
377
|
+
(S.DEAD, S.CRASHED, S.IDLE_KILLED, S.BUDGET_STOPPED) or recorded_process):
|
|
378
|
+
budget = (90.0 if deadline is None else max(0.0, deadline - time.time()))
|
|
379
|
+
if budget <= 0:
|
|
380
|
+
return 1
|
|
381
|
+
park_worker(handle, timeout=budget,
|
|
382
|
+
expected_generation=store.generation,
|
|
383
|
+
_respawn_lock_held=True)
|
|
384
|
+
rec = read_record(short) # only the now-confirmed terminal original
|
|
385
|
+
# Drain of the now-confirmed gone host is purely for its stale socket.
|
|
386
|
+
# Make sure any prior host for this short has fully exited before we spawn —
|
|
387
|
+
# otherwise its stale sock / late lifecycle write races the new host (see
|
|
388
|
+
# _drain_old_host). This is what makes `ccx kill` immediately followed by a
|
|
389
|
+
# revive `ccx send` reliable instead of a ~rc=1 coin-flip.
|
|
390
|
+
drain_timeout = 8.0
|
|
391
|
+
force_on_timeout = True
|
|
392
|
+
if deadline is not None:
|
|
393
|
+
remaining = max(0.0, deadline - time.time())
|
|
394
|
+
if remaining < drain_timeout:
|
|
395
|
+
drain_timeout = remaining
|
|
396
|
+
force_on_timeout = False
|
|
397
|
+
if not _drain_old_host(
|
|
398
|
+
short,
|
|
399
|
+
rec,
|
|
400
|
+
timeout=drain_timeout,
|
|
401
|
+
force_on_timeout=force_on_timeout,
|
|
402
|
+
):
|
|
403
|
+
return 1
|
|
404
|
+
|
|
405
|
+
# Reconcile the same old child's steers only if its PID/start is positively
|
|
406
|
+
# dead. Never infer death from a stale socket or a recorded DEAD string.
|
|
407
|
+
proof = (rec.get("lifecycle") or {}).get("nativeStartProof") or {}
|
|
408
|
+
child_pid, child_start = proof.get("workerPid"), proof.get("workerStart")
|
|
409
|
+
if (isinstance(child_pid, int) and child_start
|
|
410
|
+
and owned_process(child_pid, child_start) == "dead"):
|
|
411
|
+
try:
|
|
412
|
+
from ccx.ops.steer_ops import finalize_dead_claude_steers
|
|
413
|
+
finalize_dead_claude_steers(
|
|
414
|
+
store, handle, child_pid=child_pid, child_start=child_start)
|
|
415
|
+
except Exception:
|
|
416
|
+
pass # keep unresolved receipt; later proven kill/revive can repair
|
|
417
|
+
|
|
418
|
+
# A revived host without a runnable claude could only crash; refuse before
|
|
419
|
+
# trusting folders or rewriting settings for it.
|
|
420
|
+
missing_bin = _missing_claude_bin()
|
|
421
|
+
if missing_bin:
|
|
422
|
+
return _record_respawn_failure(short, missing_bin)
|
|
423
|
+
|
|
424
|
+
# Re-seed trust for cwd AND every --add-dir on revive too: the added dirs are
|
|
425
|
+
# persisted in createArgs, and a missing trust would hang the resumed boot.
|
|
426
|
+
# IO failure = no revivable host; keep the CLI's int contract (rc=1) and
|
|
427
|
+
# record the reason instead of leaking a raw OSError to library callers.
|
|
428
|
+
try:
|
|
429
|
+
ensure_trusted_paths([cwd, *(dargs.get("add_dir") or [])])
|
|
430
|
+
write_settings(short)
|
|
431
|
+
except OSError as e:
|
|
432
|
+
try:
|
|
433
|
+
merge_lifecycle(short, lastError=f"respawn: settings-io: {e}",
|
|
434
|
+
apiError=None)
|
|
435
|
+
except OSError:
|
|
436
|
+
pass
|
|
437
|
+
return 1
|
|
438
|
+
|
|
439
|
+
if deadline is not None and time.time() >= deadline:
|
|
440
|
+
return 1
|
|
441
|
+
|
|
442
|
+
# A fork whose first activation died before its transcript existed is
|
|
443
|
+
# copied here, under the same lock and with the old host drained.
|
|
444
|
+
from ccx.errors import CcxError
|
|
445
|
+
from ccx.ops.forking import claude_fork_pending, ensure_claude_fork_materialized
|
|
446
|
+
if claude_fork_pending(rec):
|
|
447
|
+
try:
|
|
448
|
+
rec = ensure_claude_fork_materialized(short, deadline=deadline)
|
|
449
|
+
except (CcxError, OSError) as e:
|
|
450
|
+
return _record_respawn_failure(short, f"fork copy: {e}")
|
|
451
|
+
if deadline is not None and time.time() >= deadline:
|
|
452
|
+
return _record_respawn_failure(short, "deadline expired after the fork copy")
|
|
453
|
+
|
|
454
|
+
# The revived host detects resume itself: cmd_host passes `--resume <uuid>`
|
|
455
|
+
# to claude whenever the session transcript already has content, so the
|
|
456
|
+
# relaunch needs no resume flag or env hint here.
|
|
457
|
+
#
|
|
458
|
+
# Publish lifecycle/transients in one fresh field CAS. The old
|
|
459
|
+
# snapshot must never erase another writer's unrelated accepted turn.
|
|
460
|
+
patches = {
|
|
461
|
+
"lifecycle.state": S.RESUMING,
|
|
462
|
+
"lifecycle.since": int(time.time() * 1000),
|
|
463
|
+
"lifecycle.lastError": None,
|
|
464
|
+
"lifecycle.apiError": None,
|
|
465
|
+
}
|
|
466
|
+
if caps:
|
|
467
|
+
patches["activeCap"] = caps
|
|
468
|
+
try:
|
|
469
|
+
store.mutate_record(
|
|
470
|
+
handle, patches=patches,
|
|
471
|
+
removals=() if caps else ("activeCap",),
|
|
472
|
+
expect={"lifecycle.state": (rec.get("lifecycle") or {}).get("state")},
|
|
473
|
+
)
|
|
474
|
+
except OSError:
|
|
475
|
+
return 1
|
|
476
|
+
|
|
477
|
+
# Launch the detached host through the subprocess launcher — no in-process
|
|
478
|
+
# fork. The launcher waits for the sock within the caller's remaining
|
|
479
|
+
# budget and the host reparents to init, so it outlives whoever called this.
|
|
480
|
+
sock_wait = HOST_SOCK_WAIT_RESPAWN_S
|
|
481
|
+
if deadline is not None:
|
|
482
|
+
remaining = deadline - time.time()
|
|
483
|
+
if remaining <= 0:
|
|
484
|
+
return _record_respawn_failure(
|
|
485
|
+
short, "deadline expired before host launch"
|
|
486
|
+
)
|
|
487
|
+
sock_wait = min(sock_wait, remaining)
|
|
488
|
+
verdict = spawn_host_detached(short, sock_wait_s=sock_wait)
|
|
489
|
+
if not verdict.get("ok"):
|
|
490
|
+
if verdict.get("phase") != PHASE_SOCK_TIMEOUT:
|
|
491
|
+
# Hard launcher failure: no host was handed off. A transitional
|
|
492
|
+
# RESUMING record must not survive this path.
|
|
493
|
+
return _record_respawn_failure(
|
|
494
|
+
short, verdict.get("error") or "host launcher failed"
|
|
495
|
+
)
|
|
496
|
+
if not sock_path(short).exists():
|
|
497
|
+
# A detached child could still be starting. Preserve RESUMING as
|
|
498
|
+
# unresolved; do not mark dead and relaunch on the next call.
|
|
499
|
+
return 1
|
|
500
|
+
# Wait for state=IDLE. Claude --resume can be slow loading the prior transcript
|
|
501
|
+
# and remounting Ink. Older builds may also run a synthetic auto-continue turn
|
|
502
|
+
# (bounded at AUTO_CONTINUE_MAX_S=25s host-side). On slow disk/system we have
|
|
503
|
+
# observed up to ~150s end-to-end; deadline 180s leaves headroom. A caller
|
|
504
|
+
# `deadline` clamps this so a send timeout truthfully bounds the revive.
|
|
505
|
+
state_deadline = time.time() + RESPAWN_IDLE_DEADLINE_S
|
|
506
|
+
if deadline is not None:
|
|
507
|
+
state_deadline = min(state_deadline, deadline)
|
|
508
|
+
while time.time() < state_deadline:
|
|
509
|
+
cur = (read_record(short) or {}).get("lifecycle", {}).get("state")
|
|
510
|
+
if cur == S.IDLE:
|
|
511
|
+
return 0
|
|
512
|
+
if cur == S.BUDGET_STOPPED:
|
|
513
|
+
# A re-capped resume tripped its cap during the auto-continue turn.
|
|
514
|
+
# Return promptly instead of waiting out the IDLE deadline.
|
|
515
|
+
return BUDGET_EXIT
|
|
516
|
+
if cur in (S.CRASHED, S.DEAD):
|
|
517
|
+
return 1
|
|
518
|
+
sleep_s = 0.25
|
|
519
|
+
if deadline is not None:
|
|
520
|
+
sleep_s = min(sleep_s, max(0.0, state_deadline - time.time()))
|
|
521
|
+
time.sleep(sleep_s)
|
|
522
|
+
# If the host is absent/unresponsive, convert the abandoned transition to a
|
|
523
|
+
# retryable terminal state. If a live host merely exceeded the caller's
|
|
524
|
+
# deadline, preserve its authoritative state and let it finish/watchdog.
|
|
525
|
+
status_timeout = 1.0
|
|
526
|
+
if deadline is not None:
|
|
527
|
+
remaining = deadline - time.time()
|
|
528
|
+
if remaining <= 0:
|
|
529
|
+
rec_now = read_record(short) or {}
|
|
530
|
+
lifecycle_now = rec_now.get("lifecycle") or {}
|
|
531
|
+
if owned_process(lifecycle_now.get("hostPid"),
|
|
532
|
+
lifecycle_now.get("hostStart")) == "dead":
|
|
533
|
+
return _record_respawn_failure(
|
|
534
|
+
short, "host did not reach idle before deadline"
|
|
535
|
+
)
|
|
536
|
+
try:
|
|
537
|
+
merge_lifecycle(
|
|
538
|
+
short,
|
|
539
|
+
lastError="respawn: host did not reach idle before deadline",
|
|
540
|
+
apiError=None,
|
|
541
|
+
)
|
|
542
|
+
except OSError:
|
|
543
|
+
pass
|
|
544
|
+
return 1
|
|
545
|
+
status_timeout = min(status_timeout, remaining)
|
|
546
|
+
status = _rpc(short, {"op": "status"}, timeout=status_timeout)
|
|
547
|
+
if not status.get("ok"):
|
|
548
|
+
reason = status.get("error") or "host unreachable"
|
|
549
|
+
return _record_respawn_failure(
|
|
550
|
+
short, f"did not reach idle before deadline ({reason})"
|
|
551
|
+
)
|
|
552
|
+
try:
|
|
553
|
+
merge_lifecycle(
|
|
554
|
+
short, lastError="respawn: host did not reach idle before deadline",
|
|
555
|
+
apiError=None,
|
|
556
|
+
)
|
|
557
|
+
except OSError:
|
|
558
|
+
pass
|
|
559
|
+
return 1
|