ccx-native 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +241 -2
- package/bin/ccx +56 -0
- package/ccx/__init__.py +163 -0
- package/ccx/__main__.py +28 -0
- package/ccx/claude/__init__.py +0 -0
- package/ccx/claude/analytics.py +466 -0
- package/ccx/claude/attach.py +762 -0
- package/ccx/claude/context.py +465 -0
- package/ccx/claude/delivery.py +806 -0
- package/ccx/claude/external.py +253 -0
- package/ccx/claude/host/__init__.py +0 -0
- package/ccx/claude/host/events.py +297 -0
- package/ccx/claude/host/hooks.py +438 -0
- package/ccx/claude/host/pty.py +250 -0
- package/ccx/claude/host/session.py +5184 -0
- package/ccx/claude/host/subagents.py +169 -0
- package/ccx/claude/host/successor.py +267 -0
- package/ccx/claude/managed_turns.py +240 -0
- package/ccx/claude/oauth.py +176 -0
- package/ccx/claude/settings.py +339 -0
- package/ccx/claude/spawn.py +557 -0
- package/ccx/claude/tasks.py +72 -0
- package/ccx/claude/telemetry.py +282 -0
- package/ccx/claude/transcript.py +2281 -0
- package/ccx/claude/usage.py +837 -0
- package/ccx/claude/usage_control.py +152 -0
- package/ccx/cli/__init__.py +0 -0
- package/ccx/cli/codex.py +1009 -0
- package/ccx/cli/commands/__init__.py +0 -0
- package/ccx/cli/commands/_common.py +407 -0
- package/ccx/cli/commands/control.py +161 -0
- package/ccx/cli/commands/create.py +669 -0
- package/ccx/cli/commands/read.py +1020 -0
- package/ccx/cli/commands/turns.py +1081 -0
- package/ccx/cli/controller.py +757 -0
- package/ccx/cli/main.py +301 -0
- package/ccx/cli/parser.py +1324 -0
- package/ccx/cli/skills.py +215 -0
- package/ccx/cli/statusline.py +160 -0
- package/ccx/client.py +1900 -0
- package/ccx/codex/__init__.py +0 -0
- package/ccx/codex/attach_tty.py +267 -0
- package/ccx/codex/client.py +732 -0
- package/ccx/codex/completion.py +121 -0
- package/ccx/codex/control.py +288 -0
- package/ccx/codex/daemon/__init__.py +0 -0
- package/ccx/codex/daemon/__main__.py +4 -0
- package/ccx/codex/daemon/daemon.py +1556 -0
- package/ccx/codex/daemon/models.py +140 -0
- package/ccx/codex/daemon/native.py +1590 -0
- package/ccx/codex/daemon/release.py +367 -0
- package/ccx/codex/daemon/worker.py +2396 -0
- package/ccx/codex/delivery.py +758 -0
- package/ccx/codex/delivery_recovery.py +232 -0
- package/ccx/codex/evidence.py +827 -0
- package/ccx/codex/history.py +158 -0
- package/ccx/codex/notify_history.py +140 -0
- package/ccx/codex/ops.py +666 -0
- package/ccx/codex/paths.py +70 -0
- package/ccx/codex/protocol.py +805 -0
- package/ccx/codex/provenance.py +105 -0
- package/ccx/codex/read.py +29 -0
- package/ccx/codex/records.py +51 -0
- package/ccx/codex/reports.py +177 -0
- package/ccx/codex/runtime.py +1412 -0
- package/ccx/codex/settle.py +363 -0
- package/ccx/codex/telemetry.py +792 -0
- package/ccx/codex/types.py +659 -0
- package/ccx/codex/worker_environment.py +25 -0
- package/ccx/constants.py +288 -0
- package/ccx/controllers/__init__.py +0 -0
- package/ccx/controllers/controller.py +1449 -0
- package/ccx/controllers/live_vars.py +1706 -0
- package/ccx/controllers/live_vars_runner.py +216 -0
- package/ccx/controllers/notify.py +213 -0
- package/ccx/controllers/orchestrator.py +193 -0
- package/ccx/controllers/policy.py +57 -0
- package/ccx/controllers/store.py +681 -0
- package/ccx/errors.py +634 -0
- package/ccx/notify/__init__.py +0 -0
- package/ccx/notify/__main__.py +5 -0
- package/ccx/notify/client.py +116 -0
- package/ccx/notify/daemon.py +480 -0
- package/ccx/notify/deliver.py +304 -0
- package/ccx/notify/paths.py +51 -0
- package/ccx/notify/registry.py +649 -0
- package/ccx/notify/select.py +750 -0
- package/ccx/notify/settle.py +684 -0
- package/ccx/ops/__init__.py +0 -0
- package/ccx/ops/background.py +115 -0
- package/ccx/ops/cancel.py +289 -0
- package/ccx/ops/codex_import.py +96 -0
- package/ccx/ops/common.py +397 -0
- package/ccx/ops/compact.py +337 -0
- package/ccx/ops/compact_ops.py +550 -0
- package/ccx/ops/context_events.py +48 -0
- package/ccx/ops/conversation.py +1540 -0
- package/ccx/ops/conversation_paging.py +1338 -0
- package/ccx/ops/create.py +56 -0
- package/ccx/ops/creation.py +952 -0
- package/ccx/ops/creation_steps.py +579 -0
- package/ccx/ops/event_feed.py +710 -0
- package/ccx/ops/forking.py +711 -0
- package/ccx/ops/input.py +556 -0
- package/ccx/ops/maintenance.py +295 -0
- package/ccx/ops/report.py +770 -0
- package/ccx/ops/semantics.py +490 -0
- package/ccx/ops/send.py +961 -0
- package/ccx/ops/steer_ops.py +867 -0
- package/ccx/ops/terminate.py +383 -0
- package/ccx/ops/turn_reports.py +306 -0
- package/ccx/ops/wait.py +762 -0
- package/ccx/ops/worker_events.py +636 -0
- package/ccx/rpc/__init__.py +0 -0
- package/ccx/rpc/local_rpc.py +1602 -0
- package/ccx/rpc/rpc.py +559 -0
- package/ccx/rpc/serve.py +374 -0
- package/ccx/rpc/transport.py +409 -0
- package/ccx/rpc/view.py +125 -0
- package/ccx/store/__init__.py +0 -0
- package/ccx/store/discovery.py +170 -0
- package/ccx/store/identity.py +105 -0
- package/ccx/store/journal_io.py +79 -0
- package/ccx/store/liveness.py +194 -0
- package/ccx/store/paths.py +254 -0
- package/ccx/store/record_cache.py +40 -0
- package/ccx/store/records.py +309 -0
- package/ccx/store/root.py +804 -0
- package/ccx/store/runtime_presence.py +137 -0
- package/ccx/store/store.py +2332 -0
- package/ccx/store/worker_state.py +86 -0
- package/ccx/system/__init__.py +0 -0
- package/ccx/system/bounded.py +296 -0
- package/ccx/system/cwd_paths.py +25 -0
- package/ccx/system/engine_binary.py +59 -0
- package/ccx/system/executable.py +50 -0
- package/ccx/system/oslayer.py +667 -0
- package/ccx/system/pipes.py +46 -0
- package/ccx/system/sanitize.py +43 -0
- package/ccx/types.py +705 -0
- package/ccx/version.py +29 -0
- package/ccx/workers.py +2081 -0
- package/ccx.py +9 -0
- package/package.json +40 -4
- package/pyproject.toml +87 -0
- package/skills/ccx/SKILL.md +155 -0
- package/skills/ccx/references/controllers.md +244 -0
- package/skills/ccx/references/troubleshooting.md +94 -0
- package/skills/ccx/references/variables.md +62 -0
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import errno
|
|
4
|
+
import os
|
|
5
|
+
import pathlib
|
|
6
|
+
import re
|
|
7
|
+
import socket
|
|
8
|
+
import tempfile
|
|
9
|
+
import unicodedata
|
|
10
|
+
|
|
11
|
+
from ccx.system import oslayer
|
|
12
|
+
from ccx import constants
|
|
13
|
+
from ccx.constants import CLAUDE_HOME
|
|
14
|
+
|
|
15
|
+
def private_mkdir(path: pathlib.Path | str) -> pathlib.Path:
|
|
16
|
+
"""``mkdir -p`` where every directory it creates, and the leaf, is 0700.
|
|
17
|
+
|
|
18
|
+
``Path.mkdir(parents=True, mode=...)`` applies the mode to the leaf only;
|
|
19
|
+
the parents it creates get the umask default, which would leave e.g.
|
|
20
|
+
``operations/`` listable by other users.
|
|
21
|
+
"""
|
|
22
|
+
path = pathlib.Path(path)
|
|
23
|
+
missing = []
|
|
24
|
+
probe = path
|
|
25
|
+
while not probe.exists() and probe != probe.parent:
|
|
26
|
+
missing.append(probe)
|
|
27
|
+
probe = probe.parent
|
|
28
|
+
for directory in reversed(missing):
|
|
29
|
+
try:
|
|
30
|
+
directory.mkdir(mode=0o700)
|
|
31
|
+
except FileExistsError:
|
|
32
|
+
continue
|
|
33
|
+
directory.chmod(0o700)
|
|
34
|
+
path.chmod(0o700)
|
|
35
|
+
return path
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def ensure_dirs():
|
|
39
|
+
"""New runtime bootstrap is observational; never mkdir/chmod old short tree."""
|
|
40
|
+
from ccx import constants
|
|
41
|
+
from ccx.store.root import require_ccx_active
|
|
42
|
+
return require_ccx_active(constants.CCX_ROOT)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def peer_is_same_user(conn: socket.socket) -> bool:
|
|
47
|
+
"""Whether an accepted AF_UNIX peer runs as this user (Linux SO_PEERCRED).
|
|
48
|
+
|
|
49
|
+
The 0700 directory and 0600 socket are the primary guard; this is the
|
|
50
|
+
second one, so a socket reachable by mistake still only answers its
|
|
51
|
+
owner. Missing or unreadable kernel credentials always fail closed;
|
|
52
|
+
file permissions alone cannot authenticate a connected peer.
|
|
53
|
+
"""
|
|
54
|
+
try:
|
|
55
|
+
credentials = oslayer.peer_credentials(conn)
|
|
56
|
+
except oslayer.PeerIdentityUnavailable:
|
|
57
|
+
return False
|
|
58
|
+
return credentials.uid == os.getuid()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def accept_same_user(server: socket.socket) -> socket.socket | None:
|
|
62
|
+
"""``server.accept()`` that closes and drops a peer of another user.
|
|
63
|
+
|
|
64
|
+
Returns None for a dropped peer; accept errors propagate as before.
|
|
65
|
+
"""
|
|
66
|
+
conn, _ = server.accept()
|
|
67
|
+
if peer_is_same_user(conn):
|
|
68
|
+
return conn
|
|
69
|
+
try:
|
|
70
|
+
conn.close()
|
|
71
|
+
except OSError:
|
|
72
|
+
pass
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _require_unix_socket_budget(path: pathlib.Path) -> None:
|
|
77
|
+
"""Check the final and fixed-width staged bind paths without writing."""
|
|
78
|
+
# mkdtemp(prefix=".") generates exactly eight characters after the dot.
|
|
79
|
+
for candidate, kind in (
|
|
80
|
+
(path, "final"),
|
|
81
|
+
(path.parent / ".XXXXXXXX" / "s", "staged"),
|
|
82
|
+
):
|
|
83
|
+
if not oslayer.fits_unix_path(candidate):
|
|
84
|
+
raise OSError(errno.ENAMETOOLONG,
|
|
85
|
+
f"{kind} Unix socket path exceeds AF_UNIX pathname budget",
|
|
86
|
+
os.fspath(candidate))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def bind_private_unix_socket(server: socket.socket, path: pathlib.Path | str) -> None:
|
|
90
|
+
"""Bind ``server`` at ``path`` with mode 0600 and no permissive window.
|
|
91
|
+
|
|
92
|
+
Binding a socket creates the inode with umask permissions, so chmod alone
|
|
93
|
+
leaves an exposure window. Instead, bind inside a fresh private 0700
|
|
94
|
+
staging directory, chmod there, and atomically rename the 0600 socket.
|
|
95
|
+
This avoids changing the process-wide umask, which could race threads.
|
|
96
|
+
"""
|
|
97
|
+
path = pathlib.Path(path)
|
|
98
|
+
_require_unix_socket_budget(path) # before mkdtemp creates any directory
|
|
99
|
+
staging = pathlib.Path(tempfile.mkdtemp(prefix=".", dir=path.parent))
|
|
100
|
+
try:
|
|
101
|
+
tmp = staging / "s"
|
|
102
|
+
server.bind(str(tmp))
|
|
103
|
+
os.chmod(tmp, 0o600)
|
|
104
|
+
os.replace(tmp, path)
|
|
105
|
+
finally:
|
|
106
|
+
try:
|
|
107
|
+
(staging / "s").unlink()
|
|
108
|
+
except FileNotFoundError:
|
|
109
|
+
pass
|
|
110
|
+
try:
|
|
111
|
+
os.rmdir(staging)
|
|
112
|
+
except OSError:
|
|
113
|
+
pass
|
|
114
|
+
|
|
115
|
+
def trust_ledger_path() -> pathlib.Path:
|
|
116
|
+
"""Which ``.claude.json`` trust entries ccx itself wrote (see settings)."""
|
|
117
|
+
return constants.CCX_ROOT / "trust-ledger.json"
|
|
118
|
+
|
|
119
|
+
def _owner_path(target: str, filename: str) -> pathlib.Path:
|
|
120
|
+
"""Inert per-owned-worker pathname; actual writers must use pinned fds."""
|
|
121
|
+
from ccx import constants
|
|
122
|
+
from ccx.store.root import require_ccx_active
|
|
123
|
+
from ccx.store.identity import EngineMismatch
|
|
124
|
+
store = require_ccx_active(constants.CCX_ROOT)
|
|
125
|
+
owner = store.resolve(target)
|
|
126
|
+
if owner.engine != "claude":
|
|
127
|
+
raise EngineMismatch("Claude CCX sidecar requires Claude full worker owner")
|
|
128
|
+
# The path is NOT a second authority: the authoritative owner is checked
|
|
129
|
+
# on each call, and native internal sidecar I/O must use the pinned fd.
|
|
130
|
+
return (store.pointer.generation_root / "workers" /
|
|
131
|
+
owner.engine / owner.worker_id / filename)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def sessions_dir() -> pathlib.Path:
|
|
135
|
+
from ccx import constants
|
|
136
|
+
from ccx.store.root import require_ccx_active
|
|
137
|
+
store = require_ccx_active(constants.CCX_ROOT)
|
|
138
|
+
return store.pointer.generation_root / "workers" / "claude"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def record_path(short: str) -> pathlib.Path:
|
|
142
|
+
return _owner_path(short, "record.json")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def transcript_json_path(short: str) -> pathlib.Path:
|
|
146
|
+
return _owner_path(short, "transcript.json")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def statusline_json_path(short: str) -> pathlib.Path:
|
|
150
|
+
return _owner_path(short, "statusline.json")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def settings_path(short: str) -> pathlib.Path:
|
|
154
|
+
return _owner_path(short, "settings.json")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def events_ndjson_path(short: str) -> pathlib.Path:
|
|
158
|
+
return _owner_path(short, "events.ndjson")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def conversation_page_index_path(short: str) -> pathlib.Path:
|
|
162
|
+
return _owner_path(short, "conversation-page-index.json")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def compact_operations_path(short: str) -> pathlib.Path:
|
|
166
|
+
return _owner_path(short, "compact.json")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def steer_deliveries_path(short: str) -> pathlib.Path:
|
|
170
|
+
return _owner_path(short, "deliveries.json")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def steer_deliveries_lock_path(short: str) -> pathlib.Path:
|
|
174
|
+
return _owner_path(short, "deliveries.lock")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def sock_path(short: str) -> pathlib.Path:
|
|
178
|
+
from ccx import constants
|
|
179
|
+
from ccx.store.root import require_ccx_active
|
|
180
|
+
from ccx.rpc.transport import plan_transport
|
|
181
|
+
store = require_ccx_active(constants.CCX_ROOT)
|
|
182
|
+
owner = store.resolve(short)
|
|
183
|
+
if owner.engine != "claude":
|
|
184
|
+
from ccx.store.identity import EngineMismatch
|
|
185
|
+
raise EngineMismatch("Claude socket cannot use Codex worker")
|
|
186
|
+
return plan_transport(store, target=owner.worker).final
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def respawn_lock_path(short: str) -> pathlib.Path:
|
|
190
|
+
return _owner_path(short, "respawn.lock")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def host_pid_path(short: str) -> pathlib.Path:
|
|
194
|
+
return _owner_path(short, "host.pid")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def host_log_path(short: str) -> pathlib.Path:
|
|
198
|
+
return _owner_path(short, "host.log")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def claude_project_slug(cwd: str) -> str:
|
|
202
|
+
"""Mirror Claude 2.1.311's ``hL`` key (UTF-16 units / 200-unit cap)."""
|
|
203
|
+
# Claude's macOS cwd is NFC before hL; on Linux it retains on-disk NFD.
|
|
204
|
+
if oslayer.claude_project_cwd_is_nfc():
|
|
205
|
+
cwd = unicodedata.normalize("NFC", cwd)
|
|
206
|
+
encoded = cwd.encode("utf-16-le", "surrogatepass")
|
|
207
|
+
units = [encoded[i] | encoded[i + 1] << 8 for i in range(0, len(encoded), 2)]
|
|
208
|
+
# Claude's JS /[^a-zA-Z0-9]/g replaces each astral surrogate separately.
|
|
209
|
+
slug = "".join(
|
|
210
|
+
chr(unit) if (48 <= unit <= 57 or 65 <= unit <= 90 or 97 <= unit <= 122)
|
|
211
|
+
else "-" for unit in units
|
|
212
|
+
)
|
|
213
|
+
if len(slug) <= 200:
|
|
214
|
+
return slug
|
|
215
|
+
h = 0
|
|
216
|
+
for unit in units:
|
|
217
|
+
h = (31 * h + unit) & 0xffffffff
|
|
218
|
+
value = abs(h if h < 0x80000000 else h - 0x100000000)
|
|
219
|
+
digits = "0123456789abcdefghijklmnopqrstuvwxyz"
|
|
220
|
+
suffix = ""
|
|
221
|
+
while value:
|
|
222
|
+
value, remainder = divmod(value, 36)
|
|
223
|
+
suffix = digits[remainder] + suffix
|
|
224
|
+
return f"{slug[:200]}-{suffix or '0'}"
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def transcript_path_for(cwd: str, session_id: str) -> pathlib.Path:
|
|
228
|
+
"""Mirror of Claude Code's path derivation for ~/.claude/projects/<slug>/<uuid>.jsonl."""
|
|
229
|
+
slug = claude_project_slug(cwd)
|
|
230
|
+
return CLAUDE_HOME / "projects" / slug / f"{session_id}.jsonl"
|
|
231
|
+
|
|
232
|
+
def task_list_dir_for(session_id: str) -> pathlib.Path | None:
|
|
233
|
+
"""Claude Code's structured task-list directory for one native session.
|
|
234
|
+
|
|
235
|
+
Claude currently keys ordinary task lists by the native session UUID under
|
|
236
|
+
``~/.claude/tasks/<session-id>``. Keep that private upstream layout behind
|
|
237
|
+
CCX so consumers never need to derive it themselves. A malformed record id
|
|
238
|
+
degrades to ``None`` instead of letting a path separator escape the task root.
|
|
239
|
+
"""
|
|
240
|
+
if not session_id or pathlib.PurePath(session_id).name != session_id:
|
|
241
|
+
return None
|
|
242
|
+
if session_id in {".", ".."}:
|
|
243
|
+
return None
|
|
244
|
+
return CLAUDE_HOME / "tasks" / session_id
|
|
245
|
+
|
|
246
|
+
def subagent_dir_for(cwd: str, session_id: str) -> pathlib.Path:
|
|
247
|
+
slug = claude_project_slug(cwd)
|
|
248
|
+
return CLAUDE_HOME / "projects" / slug / session_id / "subagents"
|
|
249
|
+
|
|
250
|
+
def iter_subagent_transcripts(cwd: str, session_id: str):
|
|
251
|
+
root = subagent_dir_for(cwd, session_id)
|
|
252
|
+
if not root.exists():
|
|
253
|
+
return
|
|
254
|
+
yield from sorted(root.rglob("*.jsonl"))
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""File-stamped caching for expensive derived reads."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
# Avoid caching writes within coarse filesystem timestamp granularity.
|
|
9
|
+
_MIN_AGE_NS = 50_000_000
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class StampedCache:
|
|
13
|
+
"""Cache by mtime, size and inode; missing files delegate to the reader."""
|
|
14
|
+
|
|
15
|
+
def __init__(self) -> None:
|
|
16
|
+
self._entries: dict[str, tuple[tuple[int, int, int], object]] = {}
|
|
17
|
+
self._lock = threading.Lock()
|
|
18
|
+
|
|
19
|
+
def get(self, path, compute):
|
|
20
|
+
key = str(path)
|
|
21
|
+
try:
|
|
22
|
+
st = Path(path).stat()
|
|
23
|
+
except OSError:
|
|
24
|
+
with self._lock:
|
|
25
|
+
self._entries.pop(key, None)
|
|
26
|
+
return compute(path)
|
|
27
|
+
stamp = (st.st_mtime_ns, st.st_size, st.st_ino)
|
|
28
|
+
with self._lock:
|
|
29
|
+
hit = self._entries.get(key)
|
|
30
|
+
if hit is not None and hit[0] == stamp:
|
|
31
|
+
return hit[1]
|
|
32
|
+
value = compute(path)
|
|
33
|
+
if time.time_ns() - st.st_mtime_ns >= _MIN_AGE_NS:
|
|
34
|
+
with self._lock:
|
|
35
|
+
self._entries[key] = (stamp, value)
|
|
36
|
+
return value
|
|
37
|
+
|
|
38
|
+
def clear(self) -> None:
|
|
39
|
+
with self._lock:
|
|
40
|
+
self._entries.clear()
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import tempfile
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
from ccx.constants import SELF_SHORT_ENV, S
|
|
10
|
+
from ccx.store.paths import record_path, sessions_dir
|
|
11
|
+
|
|
12
|
+
NAME_MAX_LEN = 80
|
|
13
|
+
|
|
14
|
+
_CONTROL_RE = re.compile(r"[\x00-\x1f\x7f]+")
|
|
15
|
+
_WS_RE = re.compile(r"\s+")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def sanitize_name(name: str | None) -> str:
|
|
19
|
+
"""Normalize an operator-set display name to a safe single line.
|
|
20
|
+
|
|
21
|
+
Control characters and newlines collapse to a single space, surrounding
|
|
22
|
+
whitespace is stripped, and the result is capped at :data:`NAME_MAX_LEN`
|
|
23
|
+
characters. Returns ``""`` for ``None`` / empty / whitespace-only input — the
|
|
24
|
+
caller decides what default to substitute (the auto-name ``ccx-<short>``)."""
|
|
25
|
+
if not name:
|
|
26
|
+
return ""
|
|
27
|
+
collapsed = _WS_RE.sub(" ", _CONTROL_RE.sub(" ", name)).strip()
|
|
28
|
+
return collapsed[:NAME_MAX_LEN].strip()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def iter_session_records():
|
|
32
|
+
"""Read only global full-ID managed Claude owners for observational search."""
|
|
33
|
+
store = _uuid_claude_store()
|
|
34
|
+
for row in store.list_workers(engine="claude"):
|
|
35
|
+
yield store.record(row["worker"])
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _uuid_claude_store():
|
|
39
|
+
"""One operational ACTIVE-generation gate; never initialize a legacy root."""
|
|
40
|
+
from ccx.constants import CCX_ROOT
|
|
41
|
+
from ccx.store.root import require_ccx_active
|
|
42
|
+
return require_ccx_active(CCX_ROOT)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def read_record(target: str) -> dict:
|
|
46
|
+
"""Exact CCX-owned Claude full UUID only, no short/name/path fallback."""
|
|
47
|
+
from ccx.store.identity import EngineMismatch
|
|
48
|
+
store = _uuid_claude_store()
|
|
49
|
+
owner = store.resolve(target)
|
|
50
|
+
if owner.engine != "claude":
|
|
51
|
+
raise EngineMismatch("Claude record lookup requires Claude full worker UUID")
|
|
52
|
+
return store.record(owner.worker)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def write_record(rec: dict) -> None:
|
|
56
|
+
"""Explicit version-CAS only for existing CCX-owned Claude worker.
|
|
57
|
+
|
|
58
|
+
Historical host code that passes a stale whole-record snapshot will
|
|
59
|
+
fail RecordConflict. A missing revision, native session change, original
|
|
60
|
+
fork/owner change or short-keyed record fails closed before publication.
|
|
61
|
+
Prefer merge_lifecycle / GenerationStore.mutate_record for new writers.
|
|
62
|
+
"""
|
|
63
|
+
from ccx.store.root import RecordConflict, StorageIntegrityError
|
|
64
|
+
if not isinstance(rec, dict) or rec.get("engine") != "claude":
|
|
65
|
+
raise StorageIntegrityError("Claude full-UUID record needs exact engine")
|
|
66
|
+
worker_id = rec.get("workerId")
|
|
67
|
+
revision = rec.get("recordRevision")
|
|
68
|
+
if type(revision) is not int or revision < 0:
|
|
69
|
+
raise RecordConflict(
|
|
70
|
+
"Claude whole-record update requires fresh original recordRevision"
|
|
71
|
+
)
|
|
72
|
+
store = _uuid_claude_store()
|
|
73
|
+
updated = store.replace_record_if_revision(
|
|
74
|
+
f"claude:{worker_id}", rec, expected_revision=revision,
|
|
75
|
+
)
|
|
76
|
+
rec.clear()
|
|
77
|
+
rec.update(updated)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def merge_lifecycle(target: str, **patch) -> dict:
|
|
81
|
+
"""One locked fresh-disk semantic patch; no stale whole-record write."""
|
|
82
|
+
store = _uuid_claude_store()
|
|
83
|
+
owner = store.resolve(target)
|
|
84
|
+
if owner.engine != "claude":
|
|
85
|
+
from ccx.store.identity import EngineMismatch
|
|
86
|
+
raise EngineMismatch("Claude lifecycle update requires Claude full worker UUID")
|
|
87
|
+
return store.patch_lifecycle(owner.worker, changes=patch)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def default_claude_name(worker_id: str) -> str:
|
|
91
|
+
"""Original v2 fallback display from a validated full UUID's short field."""
|
|
92
|
+
from ccx.store.identity import WorkerOwner
|
|
93
|
+
return "ccx-" + WorkerOwner(worker_id, "claude").short
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def take_host_boot_fields(target: str) -> tuple[dict | None, dict]:
|
|
97
|
+
"""Atomically consume only host-owned run cap and stale waiting preview."""
|
|
98
|
+
from ccx.store.root import RecordConflict
|
|
99
|
+
store = _uuid_claude_store()
|
|
100
|
+
for _ in range(6):
|
|
101
|
+
current = store.record(target)
|
|
102
|
+
cap = current.get("activeCap")
|
|
103
|
+
if "activeCap" not in current and "waitingFor" not in current:
|
|
104
|
+
return None, current
|
|
105
|
+
try:
|
|
106
|
+
committed = store.mutate_record(
|
|
107
|
+
target, removals=("activeCap", "waitingFor"),
|
|
108
|
+
expect={"activeCap": cap, "waitingFor": current.get("waitingFor")},
|
|
109
|
+
)
|
|
110
|
+
return cap, committed
|
|
111
|
+
except RecordConflict:
|
|
112
|
+
continue # changed host transient: re-read its actual value
|
|
113
|
+
raise RecordConflict("host boot transients changed during bounded consumption")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def restore_host_turn_count(target: str, minimum: int) -> dict:
|
|
117
|
+
"""Repair a sidecar-authoritative settled turn count, never decrease it."""
|
|
118
|
+
from ccx.store.root import RecordConflict, StorageIntegrityError
|
|
119
|
+
if type(minimum) is not int or minimum < 0:
|
|
120
|
+
raise StorageIntegrityError("invalid host settled turn floor")
|
|
121
|
+
store = _uuid_claude_store()
|
|
122
|
+
for _ in range(6):
|
|
123
|
+
current = store.record(target)
|
|
124
|
+
value = (current.get("stats") or {}).get("turns")
|
|
125
|
+
if type(value) is not int or value < 0:
|
|
126
|
+
raise StorageIntegrityError("host turn counter malformed")
|
|
127
|
+
if value >= minimum:
|
|
128
|
+
return current
|
|
129
|
+
try:
|
|
130
|
+
return store.mutate_record(
|
|
131
|
+
target, patches={"stats.turns": minimum},
|
|
132
|
+
expect={"stats.turns": value},
|
|
133
|
+
)
|
|
134
|
+
except RecordConflict:
|
|
135
|
+
continue
|
|
136
|
+
raise RecordConflict("host turn count changed during bounded recovery")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def commit_host_turn_settlement(
|
|
140
|
+
target: str, *, turn_ref: str, prior_turns: int, outcome: str,
|
|
141
|
+
settled_at_ms: int, proposed_title: str | None = None,
|
|
142
|
+
context: dict | None = None, background_task_summary: dict | None = None,
|
|
143
|
+
) -> dict:
|
|
144
|
+
"""Atomic/idempotent settlement, rebased only over fields this host owns.
|
|
145
|
+
|
|
146
|
+
No lifecycle state is written here: an external terminal kill owns it.
|
|
147
|
+
Exact turn ref and counter/time fields are one CAS, including after a lost
|
|
148
|
+
acknowledgement. An independent CLI name/config patch is never lost.
|
|
149
|
+
"""
|
|
150
|
+
from ccx.store.root import RecordConflict, StorageIntegrityError
|
|
151
|
+
if (not isinstance(turn_ref, str) or not turn_ref
|
|
152
|
+
or type(prior_turns) is not int or prior_turns < 0
|
|
153
|
+
or outcome not in ("completed", "interrupted", "unknown")
|
|
154
|
+
or type(settled_at_ms) is not int or settled_at_ms <= 0):
|
|
155
|
+
raise StorageIntegrityError("invalid exact host settlement")
|
|
156
|
+
store = _uuid_claude_store()
|
|
157
|
+
for _ in range(8):
|
|
158
|
+
current = store.record(target)
|
|
159
|
+
turns = (current.get("stats") or {}).get("turns")
|
|
160
|
+
prior = current.get("lastSettledTurn")
|
|
161
|
+
if (type(turns) is not int or turns < 0
|
|
162
|
+
or prior is not None and not isinstance(prior, dict)):
|
|
163
|
+
raise StorageIntegrityError("host settlement state malformed")
|
|
164
|
+
if prior and prior.get("turnRef") == turn_ref:
|
|
165
|
+
if (prior.get("outcome") != outcome
|
|
166
|
+
or type(prior.get("turn")) is not int
|
|
167
|
+
or turns < prior["turn"]):
|
|
168
|
+
raise RecordConflict("replayed host settlement contradicts original")
|
|
169
|
+
return current
|
|
170
|
+
if prior and type(prior.get("turn")) is int and prior["turn"] > prior_turns:
|
|
171
|
+
raise RecordConflict("a newer distinct host turn is already settled")
|
|
172
|
+
ordinal = max(turns, prior_turns) + 1
|
|
173
|
+
settled_time = max(settled_at_ms, int(current.get("lastSettledAt") or 0))
|
|
174
|
+
patches = {
|
|
175
|
+
"stats.turns": ordinal,
|
|
176
|
+
"lastSettledAt": settled_time,
|
|
177
|
+
"lastSettledTurn": {
|
|
178
|
+
"turn": ordinal, "turnRef": turn_ref, "outcome": outcome,
|
|
179
|
+
"settledAtMs": settled_time, "context": context,
|
|
180
|
+
},
|
|
181
|
+
}
|
|
182
|
+
if background_task_summary is not None:
|
|
183
|
+
patches["backgroundTaskSummary"] = background_task_summary
|
|
184
|
+
if outcome in ("completed", "interrupted"):
|
|
185
|
+
patches["lastTurnAt"] = max(settled_time, int(current.get("lastTurnAt") or 0))
|
|
186
|
+
expected = {"stats.turns": turns, "lastSettledTurn": prior}
|
|
187
|
+
fallback = default_claude_name(target)
|
|
188
|
+
if proposed_title and current.get("name") == fallback:
|
|
189
|
+
adopted = sanitize_name(proposed_title)
|
|
190
|
+
if adopted and adopted != fallback:
|
|
191
|
+
patches["name"] = adopted
|
|
192
|
+
expected["name"] = fallback
|
|
193
|
+
try:
|
|
194
|
+
return store.mutate_record(target, patches=patches, expect=expected)
|
|
195
|
+
except RecordConflict:
|
|
196
|
+
continue
|
|
197
|
+
raise RecordConflict("host settlement changed during bounded rebase")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
_HOST_TERMINAL_STATES = frozenset({S.DEAD, S.CRASHED, S.IDLE_KILLED, S.BUDGET_STOPPED})
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def host_live_transition(target: str, *, new_state: str, host_pid: int) -> dict | None:
|
|
204
|
+
"""Only this host PID may publish a nonterminal state, never over a kill."""
|
|
205
|
+
from ccx.store.root import RecordConflict
|
|
206
|
+
if new_state in _HOST_TERMINAL_STATES:
|
|
207
|
+
raise ValueError("terminal host state must be published after socket teardown")
|
|
208
|
+
store = _uuid_claude_store()
|
|
209
|
+
for _ in range(6):
|
|
210
|
+
current = store.record(target)
|
|
211
|
+
lifecycle = current.get("lifecycle") or {}
|
|
212
|
+
if (lifecycle.get("state") in _HOST_TERMINAL_STATES
|
|
213
|
+
or lifecycle.get("hostPid") != host_pid):
|
|
214
|
+
return None
|
|
215
|
+
try:
|
|
216
|
+
return store.mutate_record(
|
|
217
|
+
target, patches={
|
|
218
|
+
"lifecycle.state": new_state,
|
|
219
|
+
"lifecycle.since": int(time.time() * 1000),
|
|
220
|
+
"lifecycle.lastError": None,
|
|
221
|
+
"lifecycle.apiError": None,
|
|
222
|
+
}, expect={"lifecycle.state": lifecycle.get("state"),
|
|
223
|
+
"lifecycle.hostPid": host_pid},
|
|
224
|
+
)
|
|
225
|
+
except RecordConflict:
|
|
226
|
+
continue
|
|
227
|
+
raise RecordConflict("host live state changed during bounded transition")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def host_terminal_transition(
|
|
231
|
+
target: str, *, state: str, host_pid: int, last_error: str | None,
|
|
232
|
+
clear_child: bool,
|
|
233
|
+
) -> dict:
|
|
234
|
+
"""Publish shutdown for this PID only; preserve another terminal verdict."""
|
|
235
|
+
from ccx.store.root import RecordConflict
|
|
236
|
+
if state not in _HOST_TERMINAL_STATES:
|
|
237
|
+
raise ValueError("host final state must be terminal")
|
|
238
|
+
store = _uuid_claude_store()
|
|
239
|
+
for _ in range(6):
|
|
240
|
+
current = store.record(target)
|
|
241
|
+
lifecycle = current.get("lifecycle") or {}
|
|
242
|
+
current_state = lifecycle.get("state")
|
|
243
|
+
if lifecycle.get("hostPid") != host_pid:
|
|
244
|
+
return current # other/cleared host owner always wins
|
|
245
|
+
patches = {"lifecycle.hostPid": None, "lifecycle.hostStart": None}
|
|
246
|
+
if clear_child:
|
|
247
|
+
patches.update({"lifecycle.workerPid": None, "lifecycle.workerStart": None})
|
|
248
|
+
if current_state not in _HOST_TERMINAL_STATES:
|
|
249
|
+
patches.update({
|
|
250
|
+
"lifecycle.state": state,
|
|
251
|
+
"lifecycle.since": int(time.time() * 1000),
|
|
252
|
+
"lifecycle.lastError": last_error,
|
|
253
|
+
})
|
|
254
|
+
try:
|
|
255
|
+
return store.mutate_record(
|
|
256
|
+
target, patches=patches,
|
|
257
|
+
expect={"lifecycle.state": current_state,
|
|
258
|
+
"lifecycle.hostPid": host_pid},
|
|
259
|
+
)
|
|
260
|
+
except RecordConflict:
|
|
261
|
+
continue
|
|
262
|
+
raise RecordConflict("terminal host state changed during bounded shutdown")
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def spawned_by_from_env() -> dict | None:
|
|
266
|
+
"""Return attested full-UUID worker origin, or untrusted native metadata."""
|
|
267
|
+
from ccx.constants import SELF_ENGINE_ENV, SELF_WORKER_ID_ENV
|
|
268
|
+
from ccx.errors import SessionUnavailable
|
|
269
|
+
from ccx.store.identity import InvalidTarget, canonical_worker_id
|
|
270
|
+
old = os.environ.get(SELF_SHORT_ENV, "").strip()
|
|
271
|
+
if old:
|
|
272
|
+
raise SessionUnavailable(
|
|
273
|
+
"stale CCX_SELF_SHORT cannot identify a full-UUID parent"
|
|
274
|
+
)
|
|
275
|
+
full = os.environ.get(SELF_WORKER_ID_ENV, "").strip()
|
|
276
|
+
engine = os.environ.get(SELF_ENGINE_ENV, "").strip()
|
|
277
|
+
if full or engine:
|
|
278
|
+
if engine not in ("claude", "codex"):
|
|
279
|
+
raise SessionUnavailable("CCX_SELF_ENGINE must be explicit")
|
|
280
|
+
try:
|
|
281
|
+
wid = canonical_worker_id(full)
|
|
282
|
+
except InvalidTarget as exc:
|
|
283
|
+
raise SessionUnavailable("CCX_SELF_WORKER_ID is not full UUIDv4") from exc
|
|
284
|
+
try:
|
|
285
|
+
store = _uuid_claude_store()
|
|
286
|
+
owner = store.resolve(f"{engine}:{wid}")
|
|
287
|
+
except Exception as exc:
|
|
288
|
+
from ccx.store.root import GenerationError
|
|
289
|
+
from ccx.store.identity import IdentityError
|
|
290
|
+
if isinstance(exc, (GenerationError, IdentityError, OSError)):
|
|
291
|
+
raise SessionUnavailable(
|
|
292
|
+
f"unproven full UUID caller identity/generation: {exc}"
|
|
293
|
+
) from exc
|
|
294
|
+
raise
|
|
295
|
+
return {"kind": "ccx-worker", "workerId": owner.worker_id,
|
|
296
|
+
"engine": owner.engine}
|
|
297
|
+
# Native provider IDs are not managed CCX worker routing authority.
|
|
298
|
+
claude = os.environ.get("CLAUDE_CODE_SESSION_ID", "").strip()
|
|
299
|
+
codex = os.environ.get("CODEX_THREAD_ID", "").strip()
|
|
300
|
+
if claude and codex:
|
|
301
|
+
return {"kind": "provider-native-unknown",
|
|
302
|
+
"facts": {"claudeSessionId": claude, "codexThreadId": codex}}
|
|
303
|
+
if claude:
|
|
304
|
+
return {"kind": "provider-native-unknown", "engine": "claude",
|
|
305
|
+
"nativeId": claude}
|
|
306
|
+
if codex:
|
|
307
|
+
return {"kind": "provider-native-unknown", "engine": "codex",
|
|
308
|
+
"nativeId": codex}
|
|
309
|
+
return None
|