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
|
@@ -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
|