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.
Files changed (156) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +240 -4
  3. package/bin/ccx +55 -2
  4. package/ccx/__init__.py +167 -0
  5. package/ccx/__main__.py +28 -0
  6. package/ccx/claude/__init__.py +0 -0
  7. package/ccx/claude/analytics.py +466 -0
  8. package/ccx/claude/attach.py +807 -0
  9. package/ccx/claude/context.py +465 -0
  10. package/ccx/claude/delivery.py +806 -0
  11. package/ccx/claude/external.py +253 -0
  12. package/ccx/claude/host/__init__.py +0 -0
  13. package/ccx/claude/host/events.py +297 -0
  14. package/ccx/claude/host/hooks.py +438 -0
  15. package/ccx/claude/host/pty.py +269 -0
  16. package/ccx/claude/host/session.py +5244 -0
  17. package/ccx/claude/host/subagents.py +169 -0
  18. package/ccx/claude/host/successor.py +267 -0
  19. package/ccx/claude/managed_turns.py +240 -0
  20. package/ccx/claude/oauth.py +176 -0
  21. package/ccx/claude/settings.py +339 -0
  22. package/ccx/claude/spawn.py +557 -0
  23. package/ccx/claude/tasks.py +72 -0
  24. package/ccx/claude/telemetry.py +282 -0
  25. package/ccx/claude/transcript.py +2535 -0
  26. package/ccx/claude/usage.py +796 -0
  27. package/ccx/claude/usage_control.py +204 -0
  28. package/ccx/cli/__init__.py +0 -0
  29. package/ccx/cli/codex.py +1009 -0
  30. package/ccx/cli/commands/__init__.py +0 -0
  31. package/ccx/cli/commands/_common.py +407 -0
  32. package/ccx/cli/commands/control.py +161 -0
  33. package/ccx/cli/commands/create.py +669 -0
  34. package/ccx/cli/commands/models.py +53 -0
  35. package/ccx/cli/commands/read.py +1104 -0
  36. package/ccx/cli/commands/turns.py +1081 -0
  37. package/ccx/cli/controller.py +757 -0
  38. package/ccx/cli/main.py +302 -0
  39. package/ccx/cli/parser.py +1439 -0
  40. package/ccx/cli/skills.py +215 -0
  41. package/ccx/cli/statusline.py +160 -0
  42. package/ccx/client.py +1904 -0
  43. package/ccx/codex/__init__.py +0 -0
  44. package/ccx/codex/attach_tty.py +267 -0
  45. package/ccx/codex/client.py +738 -0
  46. package/ccx/codex/completion.py +121 -0
  47. package/ccx/codex/control.py +288 -0
  48. package/ccx/codex/daemon/__init__.py +0 -0
  49. package/ccx/codex/daemon/__main__.py +4 -0
  50. package/ccx/codex/daemon/daemon.py +1558 -0
  51. package/ccx/codex/daemon/models.py +140 -0
  52. package/ccx/codex/daemon/native.py +1590 -0
  53. package/ccx/codex/daemon/release.py +367 -0
  54. package/ccx/codex/daemon/worker.py +2424 -0
  55. package/ccx/codex/delivery.py +758 -0
  56. package/ccx/codex/delivery_recovery.py +232 -0
  57. package/ccx/codex/evidence.py +827 -0
  58. package/ccx/codex/history.py +158 -0
  59. package/ccx/codex/history_store.py +131 -0
  60. package/ccx/codex/notify_history.py +140 -0
  61. package/ccx/codex/ops.py +666 -0
  62. package/ccx/codex/paths.py +70 -0
  63. package/ccx/codex/protocol.py +805 -0
  64. package/ccx/codex/provenance.py +105 -0
  65. package/ccx/codex/read.py +29 -0
  66. package/ccx/codex/records.py +51 -0
  67. package/ccx/codex/reports.py +177 -0
  68. package/ccx/codex/runtime.py +1454 -0
  69. package/ccx/codex/settle.py +363 -0
  70. package/ccx/codex/telemetry.py +792 -0
  71. package/ccx/codex/types.py +659 -0
  72. package/ccx/codex/worker_environment.py +25 -0
  73. package/ccx/constants.py +288 -0
  74. package/ccx/controllers/__init__.py +0 -0
  75. package/ccx/controllers/controller.py +1449 -0
  76. package/ccx/controllers/live_vars.py +1706 -0
  77. package/ccx/controllers/live_vars_runner.py +216 -0
  78. package/ccx/controllers/notify.py +213 -0
  79. package/ccx/controllers/orchestrator.py +193 -0
  80. package/ccx/controllers/policy.py +57 -0
  81. package/ccx/controllers/store.py +681 -0
  82. package/ccx/errors.py +634 -0
  83. package/ccx/models.py +225 -0
  84. package/ccx/notify/__init__.py +0 -0
  85. package/ccx/notify/__main__.py +5 -0
  86. package/ccx/notify/client.py +116 -0
  87. package/ccx/notify/daemon.py +480 -0
  88. package/ccx/notify/deliver.py +304 -0
  89. package/ccx/notify/paths.py +51 -0
  90. package/ccx/notify/registry.py +649 -0
  91. package/ccx/notify/select.py +750 -0
  92. package/ccx/notify/settle.py +684 -0
  93. package/ccx/ops/__init__.py +0 -0
  94. package/ccx/ops/background.py +115 -0
  95. package/ccx/ops/cancel.py +296 -0
  96. package/ccx/ops/codex_import.py +96 -0
  97. package/ccx/ops/common.py +397 -0
  98. package/ccx/ops/compact.py +337 -0
  99. package/ccx/ops/compact_ops.py +550 -0
  100. package/ccx/ops/context_events.py +48 -0
  101. package/ccx/ops/conversation.py +1593 -0
  102. package/ccx/ops/conversation_paging.py +1339 -0
  103. package/ccx/ops/create.py +56 -0
  104. package/ccx/ops/creation.py +952 -0
  105. package/ccx/ops/creation_steps.py +575 -0
  106. package/ccx/ops/event_feed.py +710 -0
  107. package/ccx/ops/forking.py +711 -0
  108. package/ccx/ops/history.py +1041 -0
  109. package/ccx/ops/input.py +556 -0
  110. package/ccx/ops/maintenance.py +295 -0
  111. package/ccx/ops/report.py +773 -0
  112. package/ccx/ops/semantics.py +490 -0
  113. package/ccx/ops/send.py +961 -0
  114. package/ccx/ops/steer_ops.py +867 -0
  115. package/ccx/ops/terminate.py +383 -0
  116. package/ccx/ops/turn_reports.py +348 -0
  117. package/ccx/ops/wait.py +762 -0
  118. package/ccx/ops/worker_events.py +640 -0
  119. package/ccx/rpc/__init__.py +0 -0
  120. package/ccx/rpc/local_rpc.py +1602 -0
  121. package/ccx/rpc/rpc.py +559 -0
  122. package/ccx/rpc/serve.py +374 -0
  123. package/ccx/rpc/transport.py +409 -0
  124. package/ccx/rpc/view.py +125 -0
  125. package/ccx/store/__init__.py +0 -0
  126. package/ccx/store/discovery.py +170 -0
  127. package/ccx/store/identity.py +105 -0
  128. package/ccx/store/journal_io.py +79 -0
  129. package/ccx/store/liveness.py +194 -0
  130. package/ccx/store/paths.py +250 -0
  131. package/ccx/store/record_cache.py +40 -0
  132. package/ccx/store/records.py +309 -0
  133. package/ccx/store/root.py +789 -0
  134. package/ccx/store/runtime_presence.py +137 -0
  135. package/ccx/store/store.py +2332 -0
  136. package/ccx/store/worker_state.py +86 -0
  137. package/ccx/system/__init__.py +0 -0
  138. package/ccx/system/bounded.py +296 -0
  139. package/ccx/system/cwd_paths.py +25 -0
  140. package/ccx/system/engine_binary.py +59 -0
  141. package/ccx/system/executable.py +50 -0
  142. package/ccx/system/oslayer.py +667 -0
  143. package/ccx/system/pipes.py +46 -0
  144. package/ccx/system/private_json.py +54 -0
  145. package/ccx/system/sanitize.py +43 -0
  146. package/ccx/types.py +809 -0
  147. package/ccx/version.py +29 -0
  148. package/ccx/workers.py +2093 -0
  149. package/ccx.py +9 -0
  150. package/package.json +39 -7
  151. package/pyproject.toml +87 -0
  152. package/skills/ccx/SKILL.md +166 -0
  153. package/skills/ccx/references/controllers.md +244 -0
  154. package/skills/ccx/references/history.md +50 -0
  155. package/skills/ccx/references/troubleshooting.md +94 -0
  156. 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