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
@@ -0,0 +1,557 @@
1
+ """Detached spawning of managed session hosts.
2
+
3
+ A managed worker's host must be a first-class, init-parented process that
4
+ outlives whatever launched it — and the *launch itself* must be safe to call
5
+ from any thread of a long-running Python orchestrator. ``os.fork()`` in the
6
+ caller's process is neither: a forked child of a threaded process can inherit a
7
+ lock held by another thread and deadlock before ``exec``, and it drags every
8
+ open fd of the caller into a host that lives for hours.
9
+
10
+ So spawning is split in two:
11
+
12
+ * :func:`spawn_host_detached` — the caller side. Runs ``ccx host <short>
13
+ --detach`` as a plain subprocess (fork+exec happens at the C level with no
14
+ Python in between, safe in threaded processes) and parses the launcher's
15
+ one-line JSON verdict. The subprocess is reaped by ``subprocess.run`` — no
16
+ zombies, no stray ``waitpid`` racing the caller's own child management.
17
+ With an explicit caller deadline the caller stops *observing* at it but
18
+ never kills the launcher; a still-running launcher is left to the stdlib's
19
+ own deferred reaping.
20
+ * :func:`run_detached_launcher` — the launcher side, entered by ``cmd_host``
21
+ when ``--detach`` is passed. It runs in a fresh, single-threaded process that
22
+ owns no caller state, so the classic double-fork/setsid daemonization is safe
23
+ *here*. The grandchild redirects stdio to the host log and execs the real
24
+ host (via :func:`_host_exec_argv`, so ``CCX_SPAWN_SCOPE`` systemd scoping
25
+ applies uniformly to fresh spawns and revives); the launcher waits for the
26
+ host socket and reports the verdict.
27
+
28
+ The environment is inherited unchanged from the caller in both hops: Claude
29
+ auth, proxy, and subscription behavior depend on it, so ccx deliberately does
30
+ not curate an env allowlist. fd hygiene comes from ``subprocess``'s default
31
+ ``close_fds=True`` plus the launcher's stdio redirection, not from env
32
+ filtering.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import json
38
+ import os
39
+ import re
40
+ import shutil
41
+ import subprocess
42
+ import sys
43
+ import time
44
+
45
+ from ccx.system import oslayer
46
+ from ccx.constants import (
47
+ HOST_SOCK_WAIT_NEW_S,
48
+ SPAWN_LAUNCHER_GRACE_S,
49
+ )
50
+ from ccx.system.executable import current_executable_path
51
+ from ccx.store.paths import host_log_path, sock_path
52
+
53
+ # Launcher verdict phases (the "phase" field when ok is False):
54
+ # launcher-failed the launcher subprocess could not run / crashed / timed out
55
+ # — no host was handed off; treat as a hard spawn failure.
56
+ # bad-verdict the launcher ran but printed no parseable verdict — assume
57
+ # no host; hard failure.
58
+ # host-exited the host process exited before its socket appeared; the
59
+ # error carries the host log's last line. Hard failure.
60
+ # sock-timeout the host was forked and is still running, but its socket
61
+ # did not appear within the launcher's wait — it may still
62
+ # be booting slowly, so callers may treat this as soft
63
+ # (legacy semantics). Also returned when an explicit caller
64
+ # launch deadline expires while the launcher still runs:
65
+ # the host may not be forked yet, but it may still start.
66
+ PHASE_LAUNCHER_FAILED = "launcher-failed"
67
+ PHASE_BAD_VERDICT = "bad-verdict"
68
+ PHASE_HOST_EXITED = "host-exited"
69
+ PHASE_SOCK_TIMEOUT = "sock-timeout"
70
+
71
+
72
+ def _scope_enabled(value: str | None) -> bool:
73
+ """Whether CCX_SPAWN_SCOPE asks for systemd user-scope isolation.
74
+
75
+ Opt-in only: truthy means the host (and the `claude` child it spawns) should
76
+ run in their own transient systemd user scope so they don't inherit — and
77
+ outlive — the cgroup of whatever service launched ccx (e.g. a dashboard
78
+ systemd unit). Unset/empty/falsey leaves the historic direct-exec path.
79
+ """
80
+ if not value:
81
+ return False
82
+ return value.strip().lower() in ("1", "true", "yes", "on")
83
+
84
+
85
+ def _user_bus_available() -> bool:
86
+ """Whether ``systemd-run --user`` can reach a per-user manager bus."""
87
+ if os.environ.get("DBUS_SESSION_BUS_ADDRESS"):
88
+ return True
89
+ runtime = os.environ.get("XDG_RUNTIME_DIR")
90
+ return bool(runtime) and os.path.exists(os.path.join(runtime, "bus"))
91
+
92
+
93
+ def _scope_unit_name(short: str, instance: str) -> str:
94
+ """A safe, per-host systemd scope name: ``ccx-<short>-<instance>``.
95
+
96
+ A session can intentionally leave a background process alive after its CCX
97
+ host exits. Such a process keeps the transient scope active, so reusing a
98
+ deterministic ``ccx-<short>`` name makes every later revive fail with
99
+ "unit already loaded". Each host lifetime therefore gets a unique suffix;
100
+ the short remains in the prefix for operator visibility.
101
+
102
+ ``systemd-run`` appends ``.scope`` for scope runs, so this is the bare name.
103
+ Sanitize both parts defensively so neither can produce an invalid unit name.
104
+ """
105
+ safe = re.sub(r"[^A-Za-z0-9-]", "-", short)
106
+ safe_instance = re.sub(r"[^A-Za-z0-9-]", "-", instance)
107
+ return f"ccx-{safe}-{safe_instance}"
108
+
109
+
110
+ def _host_exec_argv(short: str, *, executable: str | None = None,
111
+ script: str | None = None, scope_env: str | None = None,
112
+ systemd_run: str | None = None,
113
+ scope_instance: str | None = None) -> tuple[str, list[str]]:
114
+ """Build the (program, argv) used to re-exec ourselves as `ccx host <short>`.
115
+
116
+ The default is a direct ``python <ccx-entrypoint> host <short>`` exec — the
117
+ historic behavior. When ``CCX_SPAWN_SCOPE`` is truthy and ``systemd-run`` is
118
+ available, the exec is wrapped in a transient systemd user scope so the host
119
+ lands in its own ``ccx-<short>-<instance>.scope`` cgroup instead of the
120
+ launcher's. This is purely opt-in: with the env unset or systemd-run absent,
121
+ the wrapping is
122
+ skipped and the direct exec is returned unchanged, so CLI users and
123
+ non-systemd environments see no behavior change.
124
+
125
+ Pure and side-effect free (the params are injectable) so command
126
+ construction can be unit-tested without forking.
127
+ """
128
+ executable = executable or sys.executable
129
+ script = script or current_executable_path()
130
+ direct = [executable, script, "host", short]
131
+
132
+ if scope_env is None:
133
+ scope_env = os.environ.get("CCX_SPAWN_SCOPE")
134
+ if not _scope_enabled(scope_env):
135
+ return executable, direct
136
+
137
+ if systemd_run is None:
138
+ systemd_run = shutil.which("systemd-run")
139
+ if systemd_run and not _user_bus_available():
140
+ # Binary present but no user manager to talk to (container,
141
+ # `su`/cron/SSH without a session): `systemd-run --user` would fail.
142
+ systemd_run = None
143
+ if not systemd_run:
144
+ # Opt-in requested but unavailable: fall back to the direct exec rather
145
+ # than failing the spawn. Keeps non-systemd hosts working.
146
+ return executable, direct
147
+
148
+ # PID alone can eventually be reused while an older scope is still held by
149
+ # a long-lived background child. Pair it with a monotonic nanosecond stamp
150
+ # to keep the unit unique across every revive in this boot.
151
+ instance = scope_instance or f"{os.getpid()}-{time.monotonic_ns()}"
152
+ unit = _scope_unit_name(short, instance)
153
+ wrapped = [
154
+ systemd_run, "--user", "--scope", "--quiet", "--collect",
155
+ f"--unit={unit}", "--",
156
+ *direct,
157
+ ]
158
+ return systemd_run, wrapped
159
+
160
+
161
+ def _verified_full_uuid_host_start(worker_id: str) -> str:
162
+ """Verify first native submission or a confirmed original host revival.
163
+
164
+ First creation needs submitted-unknown; later host lifetimes require the
165
+ same confirmed native session/source, resuming state and native history.
166
+ Mere record existence never authorizes a provider start. Full ID only.
167
+ """
168
+ from ccx import constants
169
+ from ccx.store.root import require_ccx_active, CreationIndeterminate
170
+ from ccx.store.identity import EngineMismatch
171
+ store = require_ccx_active(constants.CCX_ROOT)
172
+ owner = store.resolve(worker_id)
173
+ if owner.engine != "claude":
174
+ raise EngineMismatch("Claude startup requires a worker handle")
175
+ record = store.record(owner.worker)
176
+ native = record.get("native")
177
+ if not isinstance(native, dict) or not isinstance(native.get("sessionId"), str):
178
+ raise CreationIndeterminate("Claude session is missing; inspect worker status")
179
+ act = store.native_activation(owner.worker)
180
+ original_source = f"claude-session:{native['sessionId']}"
181
+ if (act.get("state") == "submitted-unknown"
182
+ and act.get("source") == original_source
183
+ and isinstance(act.get("submissionId"), str)
184
+ and act["submissionId"]):
185
+ pass # unchanged first, fsynced original-native submission
186
+ elif (act.get("state") == "confirmed"
187
+ and act.get("source") == original_source
188
+ and (record.get("lifecycle") or {}).get("state") == "resuming"):
189
+ # This is a *host lifetime* restart, not a second native activation.
190
+ # Only original source-bound nonempty history may enter --resume.
191
+ from pathlib import Path
192
+ import stat
193
+ store.authorize_native_action(owner.worker, "resume")
194
+ transcript = record.get("transcript")
195
+ if not isinstance(transcript, str):
196
+ raise CreationIndeterminate("Claude history is missing; check worker status")
197
+ try:
198
+ st = Path(transcript).stat()
199
+ except OSError as exc:
200
+ raise CreationIndeterminate(
201
+ "Claude history is missing; inspect the worker before resuming"
202
+ ) from exc
203
+ if not stat.S_ISREG(st.st_mode) or st.st_size == 0:
204
+ raise CreationIndeterminate(
205
+ "Claude history is empty; check the source session before resuming"
206
+ )
207
+ else:
208
+ raise CreationIndeterminate(
209
+ "no originally submitted or confirmed/resuming source-verified "
210
+ "Claude host lifetime; never start a replacement session"
211
+ )
212
+ store.revalidate()
213
+ return owner.worker_id
214
+
215
+
216
+ def verified_original_claude_start(store, prepared, *, deadline: float | None = None):
217
+ """Observe one original host/provider SessionStart; never start or retry it.
218
+
219
+ Host startup is positively bound to the immutable full CCX owner and
220
+ separately minted native session, original submitted activation token,
221
+ Linux host/worker PID+start, and the server's exact socket handshake on
222
+ the same kernel-authenticated connection. This is the existing host and
223
+ rpc module, not an alternate provider transport.
224
+ """
225
+ from ccx.store.liveness import owned_pid_alive
226
+ from ccx.rpc.rpc import _rpc
227
+ from ccx.store.store import _digest
228
+ from ccx.store.root import NativeEffectIndeterminate
229
+ def remaining_status_time():
230
+ remaining = (.25 if deadline is None else
231
+ min(.25, deadline - time.monotonic()))
232
+ if remaining <= 0:
233
+ raise NativeEffectIndeterminate(
234
+ "Claude startup timed out; inspect the worker before retrying"
235
+ )
236
+ return remaining
237
+
238
+ wid = prepared.worker_id
239
+ remaining_status_time()
240
+ store.revalidate()
241
+ claim = store.native_activation(prepared.worker)
242
+ if (claim.get("state") not in ("submitted-unknown", "confirmed")
243
+ or claim.get("source") != prepared.native_source
244
+ or not isinstance(claim.get("submissionId"), str)):
245
+ return None
246
+ record = store.record(prepared.worker)
247
+ native = record.get("native") or {}
248
+ lifecycle = record.get("lifecycle") or {}
249
+ proof = lifecycle.get("nativeStartProof")
250
+ if (not isinstance(proof, dict)
251
+ or proof.get("workerId") != wid
252
+ or proof.get("engine") != "claude"
253
+ or proof.get("generation") != store.generation
254
+ or proof.get("nativeSource") != prepared.native_source
255
+ or proof.get("sessionId") != native.get("sessionId")
256
+ or proof.get("submissionId") != claim["submissionId"]
257
+ or proof.get("hostPid") != lifecycle.get("hostPid")
258
+ or proof.get("hostStart") != str(lifecycle.get("hostStart"))
259
+ or proof.get("workerPid") != lifecycle.get("workerPid")
260
+ or proof.get("workerStart") != str(lifecycle.get("workerStart"))
261
+ or not owned_pid_alive(proof.get("hostPid"), proof.get("hostStart"))
262
+ or not owned_pid_alive(proof.get("workerPid"), proof.get("workerStart"))
263
+ or not owned_pid_alive(proof.get("peerPid"), proof.get("peerStart"))
264
+ or not isinstance(proof.get("nativeEvidenceSha256"), str)):
265
+ return None
266
+ digest = proof["nativeEvidenceSha256"]
267
+ if digest != _digest({
268
+ k: value for k, value in proof.items()
269
+ if k != "nativeEvidenceSha256"
270
+ }):
271
+ return None
272
+ # _rpc verifies real SO_PEERCRED against this exact host PID/start,
273
+ # returned root/generation/full-ID and current pinned store *before*
274
+ # transmitting this read-only status request on the same socket.
275
+ observed = _rpc(
276
+ wid, {"op": "startup-status"}, timeout=remaining_status_time()
277
+ )
278
+ remaining_status_time() # A late positive reply is not timely success.
279
+ if (observed.get("ok") is not True
280
+ or observed.get("workerId") != wid
281
+ or observed.get("engine") != "claude"
282
+ or observed.get("generation") != store.generation
283
+ or observed.get("nativeStartProof") != proof):
284
+ return None
285
+ store.revalidate()
286
+ remaining_status_time()
287
+ return {
288
+ "accepted": True,
289
+ "workerId": wid, "engine": "claude",
290
+ "source": prepared.native_source,
291
+ "submissionId": claim["submissionId"],
292
+ "nativeEvidenceSha256": digest,
293
+ "nativeBinding": {"sessionId": native["sessionId"]},
294
+ }
295
+
296
+
297
+ def submit_original_claude_start(store, prepared, budget: float, *,
298
+ deadline: float | None = None):
299
+ """one existing detached host spawn under fsynced native submitted claim.
300
+
301
+ Socket readiness alone is not native success. Only the native child
302
+ SessionStart proof above can turn a claimed worker into sendable state.
303
+ Any lost/delayed native result stays submitted-unknown: the caller later
304
+ reconciles the same original claim, never launches another host.
305
+ """
306
+ from ccx.store.root import NativeEffectIndeterminate
307
+ until = min(time.monotonic() + budget,
308
+ deadline if deadline is not None else float("inf"))
309
+ remaining = until - time.monotonic()
310
+ if remaining <= 0:
311
+ raise NativeEffectIndeterminate(
312
+ "Claude startup timed out before it began; retry with more time"
313
+ )
314
+ verdict = spawn_host_detached(
315
+ prepared.worker_id,
316
+ sock_wait_s=min(.6, remaining),
317
+ launch_timeout_s=min(max(.1, budget - .05), remaining),
318
+ )
319
+ if time.monotonic() >= until:
320
+ raise NativeEffectIndeterminate(
321
+ "Claude startup timed out; inspect the worker before retrying"
322
+ )
323
+ if not verdict.get("ok"):
324
+ raise NativeEffectIndeterminate(
325
+ "Claude startup could not be confirmed; check worker status: "
326
+ + str(verdict.get("phase") or verdict.get("error"))
327
+ )
328
+ while time.monotonic() < until:
329
+ answer = verified_original_claude_start(store, prepared, deadline=until)
330
+ if answer:
331
+ if time.monotonic() >= until:
332
+ raise NativeEffectIndeterminate(
333
+ "Claude startup timed out; check worker status before retrying"
334
+ )
335
+ return answer
336
+ time.sleep(min(.025, max(0.0, until - time.monotonic())))
337
+ raise NativeEffectIndeterminate(
338
+ "original native SessionStart not positively attested within bounded "
339
+ "submission; submitted-unknown, reconcile original later"
340
+ )
341
+
342
+
343
+ def spawn_host_detached(short: str, *,
344
+ sock_wait_s: float = HOST_SOCK_WAIT_NEW_S,
345
+ launch_timeout_s: float | None = None) -> dict:
346
+ """Launch the detached host for `short` and return the launcher's verdict.
347
+
348
+ Runs ``ccx host <short> --detach`` as a normal subprocess: the only fork in
349
+ the caller's process is subprocess's own C-level fork+exec, so this is safe
350
+ from threads and never runs Python in a forked copy of the caller. The
351
+ launcher daemonizes the real host (init-parented, own session — it survives
352
+ the caller exiting), waits up to ``sock_wait_s`` for the host socket, and
353
+ reports one JSON line on stdout.
354
+
355
+ Returns the verdict dict. ``{"ok": True, ...}`` means the host is up
356
+ (socket bound). ``ok: False`` carries a ``phase``:
357
+ :data:`PHASE_SOCK_TIMEOUT` (host or launcher still running, socket late —
358
+ possibly still booting), or :data:`PHASE_LAUNCHER_FAILED` /
359
+ :data:`PHASE_BAD_VERDICT` (hard failure — assume no host). Never raises.
360
+
361
+ An explicit ``launch_timeout_s`` bounds only the caller's observation: the
362
+ one launcher is never killed mid-import, so a fenced submission can still
363
+ produce its host after the caller has returned.
364
+
365
+ ``start_new_session=True`` keeps the short-lived launcher out of the
366
+ caller's process group so an orchestrator Ctrl-C can't kill it mid-detach.
367
+ The caller's environment is inherited unchanged (see module docstring).
368
+ """
369
+ short = _verified_full_uuid_host_start(short)
370
+ argv = [sys.executable, current_executable_path(), "host", short,
371
+ "--detach", "--sock-wait", f"{sock_wait_s:g}"]
372
+ try:
373
+ if launch_timeout_s is None:
374
+ proc = subprocess.run(
375
+ argv,
376
+ stdin=subprocess.DEVNULL,
377
+ stdout=subprocess.PIPE,
378
+ stderr=subprocess.PIPE,
379
+ start_new_session=True,
380
+ timeout=(sock_wait_s + SPAWN_LAUNCHER_GRACE_S
381
+ if launch_timeout_s is None else launch_timeout_s),
382
+ )
383
+ else:
384
+ launcher = subprocess.Popen(
385
+ argv,
386
+ stdin=subprocess.DEVNULL,
387
+ stdout=subprocess.PIPE,
388
+ stderr=subprocess.PIPE,
389
+ start_new_session=True,
390
+ )
391
+ try:
392
+ stdout, stderr = launcher.communicate(timeout=launch_timeout_s)
393
+ except subprocess.TimeoutExpired:
394
+ return {"ok": False, "short": short, "phase": PHASE_SOCK_TIMEOUT,
395
+ "error": f"launcher still running after {launch_timeout_s:g}s; "
396
+ "host may still start (launcher not killed)"}
397
+ proc = subprocess.CompletedProcess(argv, launcher.returncode, stdout, stderr)
398
+ except (OSError, subprocess.SubprocessError) as e:
399
+ return {"ok": False, "short": short, "phase": PHASE_LAUNCHER_FAILED,
400
+ "error": f"{e.__class__.__name__}: {e}"}
401
+
402
+ verdict = _parse_verdict(proc.stdout)
403
+ if verdict is None:
404
+ err = (proc.stderr or b"").decode("utf-8", "replace").strip()
405
+ return {"ok": False, "short": short, "phase": PHASE_BAD_VERDICT,
406
+ "error": err or f"launcher exited rc={proc.returncode} "
407
+ "with no verdict"}
408
+ verdict.setdefault("short", short)
409
+ return verdict
410
+
411
+
412
+ def _parse_verdict(stdout: bytes | None) -> dict | None:
413
+ """Last parseable JSON object line of the launcher's stdout, or None."""
414
+ if not stdout:
415
+ return None
416
+ for line in reversed(stdout.decode("utf-8", "replace").splitlines()):
417
+ line = line.strip()
418
+ if not line:
419
+ continue
420
+ try:
421
+ obj = json.loads(line)
422
+ except ValueError:
423
+ continue
424
+ if isinstance(obj, dict):
425
+ return obj
426
+ return None
427
+
428
+
429
+ def _open_private_host_log(short: str):
430
+ """Open the Claude host's stdio log owner-only despite permissive umask.
431
+
432
+ fchmod also tightens a log an older host created group/world-readable.
433
+ """
434
+ fd = os.open(host_log_path(short), os.O_WRONLY | os.O_APPEND | os.O_CREAT,
435
+ 0o600)
436
+ os.fchmod(fd, 0o600)
437
+ return os.fdopen(fd, "ab", buffering=0)
438
+
439
+
440
+ def run_detached_launcher(short: str, sock_wait_s: float) -> int:
441
+ """Launcher side of a detached spawn (``ccx host <short> --detach``).
442
+
443
+ Runs in a fresh process, so the double-fork is safe: no caller threads, no
444
+ caller locks, no caller fds beyond stdio. Daemonizes the host (setsid +
445
+ second fork → grandchild reparents to init), redirects the grandchild's
446
+ stdio to the host log, and execs the real host via :func:`_host_exec_argv`
447
+ (so CCX_SPAWN_SCOPE scoping applies to spawns and revives alike). The
448
+ surviving launcher then waits up to ``sock_wait_s`` for the host socket and
449
+ prints a one-line JSON verdict for :func:`spawn_host_detached`.
450
+
451
+ Returns the launcher's exit code: 0 when the socket came up, 1 otherwise
452
+ (host exited, or still booting past the wait).
453
+ """
454
+ short = _verified_full_uuid_host_start(short)
455
+ pid_read, pid_write = os.pipe()
456
+ pid = os.fork()
457
+ if pid == 0:
458
+ # First child: detach from the launcher's session, then fork the real
459
+ # host so it reparents to init when we exit.
460
+ os.close(pid_read)
461
+ os.setsid()
462
+ pid2 = os.fork()
463
+ if pid2 > 0:
464
+ # Tell the launcher which process is the host, so it can stop
465
+ # waiting (and say why) if the host dies before binding.
466
+ os.write(pid_write, str(pid2).encode())
467
+ os._exit(0)
468
+ os.close(pid_write)
469
+ # Grandchild: become the host. Point stdio at the host log (this also
470
+ # drops the launcher's stdout/stderr pipes so the caller's read of the
471
+ # verdict is never held open by the long-lived host).
472
+ log = _open_private_host_log(short)
473
+ devnull = os.open(os.devnull, os.O_RDONLY)
474
+ os.dup2(devnull, 0)
475
+ os.close(devnull)
476
+ os.dup2(log.fileno(), 1)
477
+ os.dup2(log.fileno(), 2)
478
+ try:
479
+ log.close()
480
+ except Exception:
481
+ pass
482
+ prog, argv = _host_exec_argv(short)
483
+ try:
484
+ os.execv(prog, argv)
485
+ except BaseException as e:
486
+ # stderr is the host log by now; an exec failure would otherwise
487
+ # leave it empty.
488
+ os.write(2, f"[host {short}] could not exec {prog}: {e}\n".encode())
489
+ finally:
490
+ os._exit(127)
491
+
492
+ # Launcher: reap the intermediate child, then wait for the host socket.
493
+ os.close(pid_write)
494
+ os.waitpid(pid, 0)
495
+ with os.fdopen(pid_read, "rb") as handle:
496
+ raw = handle.read().decode("ascii", "replace").strip()
497
+ host_pid = int(raw) if raw.isdigit() else None
498
+ deadline = time.time() + sock_wait_s
499
+ host_gone = False
500
+ while time.time() < deadline and not sock_path(short).exists():
501
+ if host_pid is not None and not _process_running(host_pid):
502
+ host_gone = not sock_path(short).exists()
503
+ break
504
+ time.sleep(0.05)
505
+ ok = sock_path(short).exists()
506
+ verdict = {"ok": ok, "short": short}
507
+ if not ok and host_gone:
508
+ verdict["phase"] = PHASE_HOST_EXITED
509
+ tail = _log_tail(short)
510
+ verdict["error"] = ("host exited before opening its socket"
511
+ + (f": {tail}" if tail else "")
512
+ + f" (log: {host_log_path(short)})")
513
+ elif not ok:
514
+ verdict["phase"] = PHASE_SOCK_TIMEOUT
515
+ verdict["error"] = (f"host socket did not appear within "
516
+ f"{sock_wait_s:g}s; the host is still running "
517
+ f"(log: {host_log_path(short)})")
518
+ sys.stdout.write(json.dumps(verdict) + "\n")
519
+ sys.stdout.flush()
520
+ return 0 if ok else 1
521
+
522
+
523
+ def _process_running(pid: int) -> bool:
524
+ """Whether ``pid`` exists and is not a zombie awaiting its reaper."""
525
+ probe = oslayer.probe_pid(pid)
526
+ if probe == "missing":
527
+ return False
528
+ if probe == "denied":
529
+ return True
530
+ # Old launcher considers every state but Z running; missing/unreadable
531
+ # stat after a successful kill(0) is also still considered running.
532
+ return oslayer.process_info(pid).state != "Z"
533
+
534
+
535
+ def _log_tail(short: str) -> str:
536
+ """The host log's last non-empty line, bounded, or ''."""
537
+ try:
538
+ with open(host_log_path(short), "rb") as handle:
539
+ handle.seek(0, os.SEEK_END)
540
+ handle.seek(max(0, handle.tell() - 4096))
541
+ lines = handle.read().decode("utf-8", "replace").splitlines()
542
+ except OSError:
543
+ return ""
544
+ for line in reversed(lines):
545
+ if line.strip():
546
+ return line.strip()[:500]
547
+ return ""
548
+
549
+
550
+ __all__ = [
551
+ "PHASE_BAD_VERDICT",
552
+ "PHASE_HOST_EXITED",
553
+ "PHASE_LAUNCHER_FAILED",
554
+ "PHASE_SOCK_TIMEOUT",
555
+ "run_detached_launcher",
556
+ "spawn_host_detached",
557
+ ]
@@ -0,0 +1,72 @@
1
+ """Read-only projection of Claude Code's native structured task list.
2
+
3
+ Claude Code 2.1.260 stores ordinary session tasks below
4
+ ``~/.claude/tasks/<session-id>/`` as one JSON file per task. This module is the
5
+ compatibility boundary for that private upstream layout: callers receive typed
6
+ normalized tasks and never touch Claude's lock/high-watermark files.
7
+
8
+ Reads are deliberately tolerant. Claude may replace files while we inspect the
9
+ directory, future versions may add fields/statuses, and a task directory may be
10
+ created or removed between syscalls. None of those conditions should make a CCX
11
+ worker unreadable.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ from pathlib import Path
18
+
19
+ from ccx.store.paths import task_list_dir_for
20
+ from ccx.types import ClaudeTask
21
+
22
+
23
+ def _sort_key(task: ClaudeTask) -> tuple[int, int | str]:
24
+ try:
25
+ return (0, int(task.id))
26
+ except (TypeError, ValueError):
27
+ return (1, task.id)
28
+
29
+
30
+ def read_task_list(session_id: str) -> list[ClaudeTask]:
31
+ """Return the current native task list for ``session_id``.
32
+
33
+ Missing/unreadable/partially-written task state is an empty or partial list,
34
+ never an exception. Internal Claude tasks (``metadata._internal``) are omitted
35
+ just like Claude Code's own task-list UI. No lock is acquired and no file is
36
+ ever mutated.
37
+ """
38
+ root = task_list_dir_for(session_id)
39
+ if root is None:
40
+ return []
41
+ try:
42
+ entries = list(root.iterdir())
43
+ except OSError:
44
+ return []
45
+
46
+ tasks: list[ClaudeTask] = []
47
+ for path in entries:
48
+ if path.suffix != ".json" or path.name.startswith("."):
49
+ continue
50
+ try:
51
+ # Native task files are ordinary files. Refuse symlinks so a corrupt
52
+ # or adversarial task directory cannot turn this read-only helper into
53
+ # an arbitrary-file reader.
54
+ if path.is_symlink() or not path.is_file():
55
+ continue
56
+ raw = json.loads(path.read_text(encoding="utf-8"))
57
+ except (OSError, ValueError, TypeError):
58
+ continue
59
+ if not isinstance(raw, dict):
60
+ continue
61
+ metadata = raw.get("metadata")
62
+ if isinstance(metadata, dict) and metadata.get("_internal"):
63
+ continue
64
+ task = ClaudeTask.from_dict(raw)
65
+ if not task.id:
66
+ # A nameless task cannot be updated/addressed coherently and is most
67
+ # likely a transient/corrupt file. Ignore it rather than invent an id.
68
+ continue
69
+ tasks.append(task)
70
+
71
+ tasks.sort(key=_sort_key)
72
+ return tasks