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,170 @@
|
|
|
1
|
+
"""Explicit full-UUID worker inventory discovery; never runtime addressing.
|
|
2
|
+
|
|
3
|
+
The CLI resolves only selected user worker-reference fields before dispatch.
|
|
4
|
+
Ordinary client, V1, native owner and store APIs remain exact-ID-only.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from ccx.errors import AmbiguousWorker, InvalidRequest, SessionUnavailable, worker_not_found
|
|
12
|
+
from ccx.store.identity import (
|
|
13
|
+
ENGINES, EngineMismatch, IdentityError, UnknownWorker, canonical_worker_id,
|
|
14
|
+
)
|
|
15
|
+
from ccx.store.root import GenerationError, require_ccx_active
|
|
16
|
+
|
|
17
|
+
_PREFIX = re.compile(r"[0-9a-f]{8}[0-9a-f-]{0,27}\Z", re.ASCII)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _query(value: str) -> tuple[str | None, str, bool]:
|
|
21
|
+
"""(explicit engine, textual UUID query, is_incomplete_prefix)."""
|
|
22
|
+
if not isinstance(value, str) or not value:
|
|
23
|
+
raise InvalidRequest("worker query must be a nonempty canonical UUID or prefix")
|
|
24
|
+
if ":" in value:
|
|
25
|
+
engine, sep, uuid_text = value.partition(":")
|
|
26
|
+
if sep != ":" or engine not in ENGINES or ":" in uuid_text:
|
|
27
|
+
raise InvalidRequest("worker query must be bare or claude:/codex: qualified")
|
|
28
|
+
else:
|
|
29
|
+
engine, uuid_text = None, value
|
|
30
|
+
if len(uuid_text) == 36:
|
|
31
|
+
try:
|
|
32
|
+
canonical_worker_id(uuid_text)
|
|
33
|
+
except IdentityError as exc:
|
|
34
|
+
raise InvalidRequest(
|
|
35
|
+
"complete worker UUID must be canonical lowercase UUIDv4"
|
|
36
|
+
) from exc
|
|
37
|
+
return engine, uuid_text, False
|
|
38
|
+
if not _PREFIX.fullmatch(uuid_text):
|
|
39
|
+
raise InvalidRequest(
|
|
40
|
+
"worker prefix must start with 8 lowercase hex characters, "
|
|
41
|
+
"continue with lowercase hex/hyphens, and be at most 35 characters; "
|
|
42
|
+
"no names, casefolding or compact-UUID aliases"
|
|
43
|
+
)
|
|
44
|
+
return engine, uuid_text, True
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _candidates(store, engine: str | None, prefix: str) -> list[str]:
|
|
48
|
+
"""Validated active workers whose full UUID starts with ``prefix``.
|
|
49
|
+
|
|
50
|
+
Only owner files whose NAME matches are read and validated, so a lookup
|
|
51
|
+
costs O(matches), not a validation of the whole fleet.
|
|
52
|
+
"""
|
|
53
|
+
matches = []
|
|
54
|
+
try:
|
|
55
|
+
for worker_id in store.owner_ids(prefix=prefix):
|
|
56
|
+
try:
|
|
57
|
+
owner = store.resolve(f"{engine}:{worker_id}" if engine else worker_id)
|
|
58
|
+
except (UnknownWorker, EngineMismatch):
|
|
59
|
+
continue # reserved/quarantined, or the other engine
|
|
60
|
+
matches.append(owner.worker)
|
|
61
|
+
except (GenerationError, IdentityError, OSError) as exc:
|
|
62
|
+
raise SessionUnavailable(f"managed worker inventory unavailable: {exc}") from exc
|
|
63
|
+
return sorted(matches)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _revalidate(store) -> None:
|
|
67
|
+
try:
|
|
68
|
+
store.revalidate()
|
|
69
|
+
except (GenerationError, OSError) as exc:
|
|
70
|
+
raise SessionUnavailable(f"managed worker generation changed: {exc}") from exc
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _match(query: str, engine: str | None, prefix: str, store) -> str:
|
|
74
|
+
matches = _candidates(store, engine, prefix)
|
|
75
|
+
if not matches and engine is not None:
|
|
76
|
+
other = _candidates(store, None, prefix)
|
|
77
|
+
if len(other) == 1:
|
|
78
|
+
found, _, worker_id = other[0].partition(":")
|
|
79
|
+
raise InvalidRequest(f"{query} is a {found.capitalize()} worker; use "
|
|
80
|
+
f"{found}:{worker_id[:8]} or the bare prefix")
|
|
81
|
+
if not matches:
|
|
82
|
+
raise worker_not_found(query)
|
|
83
|
+
if len(matches) > 1:
|
|
84
|
+
raise AmbiguousWorker(query, matches)
|
|
85
|
+
return matches[0]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def resolve_worker(query: str) -> str:
|
|
89
|
+
"""Explicitly resolve a full UUID or unique textual prefix to engine:UUID.
|
|
90
|
+
|
|
91
|
+
This is a current read-only inventory query, NOT permission to pass the
|
|
92
|
+
prefix to ordinary Python methods, V1, host/daemon or persisted references.
|
|
93
|
+
"""
|
|
94
|
+
from ccx import constants
|
|
95
|
+
|
|
96
|
+
engine, body, incomplete = _query(query)
|
|
97
|
+
try:
|
|
98
|
+
store = require_ccx_active(constants.CCX_ROOT)
|
|
99
|
+
except (GenerationError, OSError) as exc:
|
|
100
|
+
raise SessionUnavailable(f"ACTIVE managed worker inventory unavailable: {exc}") from exc
|
|
101
|
+
if not incomplete:
|
|
102
|
+
try:
|
|
103
|
+
owner = store.resolve(query)
|
|
104
|
+
except UnknownWorker as exc:
|
|
105
|
+
raise worker_not_found(query) from exc
|
|
106
|
+
except EngineMismatch as exc:
|
|
107
|
+
raise InvalidRequest(str(exc)) from exc
|
|
108
|
+
except (GenerationError, OSError) as exc:
|
|
109
|
+
raise SessionUnavailable(f"managed worker lookup unavailable: {exc}") from exc
|
|
110
|
+
resolved = owner.worker
|
|
111
|
+
else:
|
|
112
|
+
resolved = _match(query, engine, body, store)
|
|
113
|
+
_revalidate(store)
|
|
114
|
+
return resolved
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def resolve_cli_fields(namespace: Any, store, fields: tuple[str, ...], *,
|
|
118
|
+
check_exact: bool = False) -> None:
|
|
119
|
+
"""All-or-nothing namespace preflight; prefixes read only matching owners.
|
|
120
|
+
|
|
121
|
+
Complete exact UUIDs stay on the handler's fast path unless ``check_exact``
|
|
122
|
+
requests a missing-owner preflight. Multi-target report batches preserve
|
|
123
|
+
their existing per-row error behavior.
|
|
124
|
+
"""
|
|
125
|
+
prepared: dict[str, list[tuple[str, str | None, str, bool]]] = {}
|
|
126
|
+
any_prefix = False
|
|
127
|
+
for field in fields:
|
|
128
|
+
value = getattr(namespace, field, None)
|
|
129
|
+
if value is None:
|
|
130
|
+
continue
|
|
131
|
+
values = value if isinstance(value, list) else [value]
|
|
132
|
+
parsed = []
|
|
133
|
+
for item in values:
|
|
134
|
+
engine, uuid_text, incomplete = _query(item)
|
|
135
|
+
parsed.append((item, engine, uuid_text, incomplete))
|
|
136
|
+
any_prefix = any_prefix or incomplete
|
|
137
|
+
prepared[field] = parsed
|
|
138
|
+
if check_exact:
|
|
139
|
+
for specs in prepared.values():
|
|
140
|
+
for original, _engine, _uuid_text, incomplete in specs:
|
|
141
|
+
if incomplete:
|
|
142
|
+
continue
|
|
143
|
+
try:
|
|
144
|
+
store.resolve(original)
|
|
145
|
+
except UnknownWorker as exc:
|
|
146
|
+
if str(exc) == "full UUID is not present in global owner index":
|
|
147
|
+
raise worker_not_found(original) from exc
|
|
148
|
+
# Reserved/quarantined identities retain the verb's own
|
|
149
|
+
# refusal; they are not missing global owner entries.
|
|
150
|
+
except EngineMismatch as exc:
|
|
151
|
+
raise InvalidRequest(str(exc)) from exc
|
|
152
|
+
except (GenerationError, OSError):
|
|
153
|
+
# Other refusal reasons retain the handler's existing type,
|
|
154
|
+
# message and exit code rather than being normalized here.
|
|
155
|
+
pass
|
|
156
|
+
if not any_prefix:
|
|
157
|
+
return
|
|
158
|
+
|
|
159
|
+
rewritten: dict[str, str | list[str]] = {}
|
|
160
|
+
for field, specs in prepared.items():
|
|
161
|
+
resolved = [
|
|
162
|
+
_match(original, engine, uuid_text, store) if incomplete else original
|
|
163
|
+
for original, engine, uuid_text, incomplete in specs
|
|
164
|
+
]
|
|
165
|
+
rewritten[field] = (resolved if isinstance(getattr(namespace, field), list)
|
|
166
|
+
else resolved[0])
|
|
167
|
+
_revalidate(store)
|
|
168
|
+
# Nothing from any field was applied until every prefix succeeded.
|
|
169
|
+
for field, resolved in rewritten.items():
|
|
170
|
+
setattr(namespace, field, resolved)
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Exact CCX worker identity and engine-qualified target validation.
|
|
2
|
+
|
|
3
|
+
Pure canonical UUID and owner contracts; no file I/O, provider discovery,
|
|
4
|
+
environment lookup, or runtime compatibility aliases.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from uuid import UUID, uuid4
|
|
10
|
+
|
|
11
|
+
IDENTITY_SCHEMA_VERSION = 2
|
|
12
|
+
MANIFEST_SCHEMA_VERSION = 1
|
|
13
|
+
ENGINES = frozenset({"claude", "codex"})
|
|
14
|
+
OWNER_STATES = frozenset({"worker", "indeterminate-reserved", "quarantined"})
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class IdentityError(ValueError):
|
|
18
|
+
"""Unusable or contradictory CCX-managed identity."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class InvalidTarget(IdentityError):
|
|
22
|
+
"""Not a complete canonical managed UUID or qualified UUID."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class UnknownWorker(IdentityError):
|
|
26
|
+
"""Canonical UUID has no CCX-owned record or is reserved."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class EngineMismatch(IdentityError):
|
|
30
|
+
"""An explicit engine disagrees with the immutable CCX owner."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class OwnerConflict(IdentityError):
|
|
34
|
+
"""One full UUID has two inconsistent owners/reservations."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def canonical_worker_id(value: str) -> str:
|
|
38
|
+
"""Require lowercase, hyphenated RFC 4122 UUIDv4 (including variant)."""
|
|
39
|
+
if not isinstance(value, str):
|
|
40
|
+
raise InvalidTarget("worker id must be a canonical lowercase UUIDv4")
|
|
41
|
+
try:
|
|
42
|
+
value_uuid = UUID(value)
|
|
43
|
+
except (ValueError, TypeError, AttributeError) as exc:
|
|
44
|
+
raise InvalidTarget("worker id must be a canonical lowercase UUIDv4") from exc
|
|
45
|
+
if value_uuid.version != 4 or value_uuid.variant != "specified in RFC 4122" or str(value_uuid) != value:
|
|
46
|
+
raise InvalidTarget("worker id must be a canonical lowercase UUIDv4")
|
|
47
|
+
return value
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def mint_worker_id() -> str:
|
|
51
|
+
"""Independently random UUIDv4; NEVER preserve or derive an old short."""
|
|
52
|
+
return str(uuid4())
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class ManagedTarget:
|
|
57
|
+
worker_id: str
|
|
58
|
+
qualified_engine: str | None = None
|
|
59
|
+
|
|
60
|
+
def __post_init__(self) -> None:
|
|
61
|
+
canonical_worker_id(self.worker_id)
|
|
62
|
+
if self.qualified_engine is not None and self.qualified_engine not in ENGINES:
|
|
63
|
+
raise InvalidTarget("unsupported managed engine")
|
|
64
|
+
|
|
65
|
+
def qualified(self, owner_engine: str) -> str:
|
|
66
|
+
if owner_engine not in ENGINES:
|
|
67
|
+
raise IdentityError("invalid CCX engine")
|
|
68
|
+
if self.qualified_engine is not None and self.qualified_engine != owner_engine:
|
|
69
|
+
raise EngineMismatch("managed target engine does not match CCX ownership")
|
|
70
|
+
return f"{owner_engine}:{self.worker_id}"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def parse_managed_target(target: str) -> ManagedTarget:
|
|
74
|
+
"""Only a canonical full UUID or engine:full UUID; never a display alias."""
|
|
75
|
+
if not isinstance(target, str):
|
|
76
|
+
raise InvalidTarget("managed target must be a canonical full worker UUID")
|
|
77
|
+
if ":" not in target:
|
|
78
|
+
return ManagedTarget(canonical_worker_id(target))
|
|
79
|
+
engine, separator, worker_id = target.partition(":")
|
|
80
|
+
if separator != ":" or engine not in ENGINES:
|
|
81
|
+
raise InvalidTarget("managed target must be a bare or engine-qualified full UUID")
|
|
82
|
+
return ManagedTarget(canonical_worker_id(worker_id), engine)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True)
|
|
86
|
+
class WorkerOwner:
|
|
87
|
+
worker_id: str
|
|
88
|
+
engine: str
|
|
89
|
+
state: str = "worker"
|
|
90
|
+
|
|
91
|
+
def __post_init__(self) -> None:
|
|
92
|
+
canonical_worker_id(self.worker_id)
|
|
93
|
+
if self.engine not in ENGINES:
|
|
94
|
+
raise IdentityError("unsupported CCX worker engine")
|
|
95
|
+
if self.state not in OWNER_STATES:
|
|
96
|
+
raise IdentityError("invalid CCX owner reservation state")
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def worker(self) -> str:
|
|
100
|
+
return f"{self.engine}:{self.worker_id}"
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def short(self) -> str:
|
|
104
|
+
"""Nonunique display field, never a key for routing."""
|
|
105
|
+
return self.worker_id[:8]
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Bounded reentrant locks and durable publication for Claude journals."""
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import fcntl
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import tempfile
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
|
|
12
|
+
_LOCAL = threading.local()
|
|
13
|
+
_THREAD_LOCKS: dict[Path, threading.RLock] = {}
|
|
14
|
+
_THREAD_LOCKS_GUARD = threading.Lock()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@contextlib.contextmanager
|
|
18
|
+
def journal_lock(path: Path, *, timeout: float, busy_error: type[Exception], label: str):
|
|
19
|
+
# Key by lock path so different journals and roots cannot share ownership.
|
|
20
|
+
path = path.resolve()
|
|
21
|
+
held = getattr(_LOCAL, "held", None)
|
|
22
|
+
if held is None:
|
|
23
|
+
held = _LOCAL.held = set()
|
|
24
|
+
if path in held:
|
|
25
|
+
yield
|
|
26
|
+
return
|
|
27
|
+
budget = max(0.0, float(timeout))
|
|
28
|
+
deadline = time.monotonic() + budget
|
|
29
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
with _THREAD_LOCKS_GUARD:
|
|
31
|
+
thread_lock = _THREAD_LOCKS.setdefault(path, threading.RLock())
|
|
32
|
+
acquired = (thread_lock.acquire(timeout=budget) if budget > 0
|
|
33
|
+
else thread_lock.acquire(blocking=False))
|
|
34
|
+
if not acquired:
|
|
35
|
+
raise busy_error(f"{label} is locked by this process (waited {budget:g}s)")
|
|
36
|
+
try:
|
|
37
|
+
fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o600)
|
|
38
|
+
try:
|
|
39
|
+
while True:
|
|
40
|
+
try:
|
|
41
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
42
|
+
break
|
|
43
|
+
except OSError:
|
|
44
|
+
if time.monotonic() >= deadline:
|
|
45
|
+
raise busy_error(
|
|
46
|
+
f"{label} is held by another process (waited {budget:g}s)"
|
|
47
|
+
) from None
|
|
48
|
+
time.sleep(min(0.02, max(0.0, deadline - time.monotonic())))
|
|
49
|
+
held.add(path)
|
|
50
|
+
try:
|
|
51
|
+
yield
|
|
52
|
+
finally:
|
|
53
|
+
held.discard(path)
|
|
54
|
+
finally:
|
|
55
|
+
os.close(fd)
|
|
56
|
+
finally:
|
|
57
|
+
thread_lock.release()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def write_json(path: Path, value: dict) -> None:
|
|
61
|
+
fd, tmp_name = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp")
|
|
62
|
+
try:
|
|
63
|
+
with os.fdopen(fd, "w") as handle:
|
|
64
|
+
json.dump(value, handle, indent=2)
|
|
65
|
+
handle.flush()
|
|
66
|
+
os.fsync(handle.fileno())
|
|
67
|
+
os.replace(tmp_name, path)
|
|
68
|
+
# Persist the rename as well as the contents before acknowledging it.
|
|
69
|
+
dir_fd = os.open(path.parent, os.O_RDONLY)
|
|
70
|
+
try:
|
|
71
|
+
os.fsync(dir_fd)
|
|
72
|
+
finally:
|
|
73
|
+
os.close(dir_fd)
|
|
74
|
+
except BaseException:
|
|
75
|
+
try:
|
|
76
|
+
os.unlink(tmp_name)
|
|
77
|
+
except FileNotFoundError:
|
|
78
|
+
pass
|
|
79
|
+
raise
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import pathlib
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from ccx.system import oslayer
|
|
8
|
+
from ccx.constants import CLAUDE_HOME
|
|
9
|
+
|
|
10
|
+
def pid_alive(pid: int | None) -> bool:
|
|
11
|
+
if not pid:
|
|
12
|
+
return False
|
|
13
|
+
# Keep the historical EPERM-is-present projection, unlike controller runs.
|
|
14
|
+
return oslayer.probe_pid(pid) in ("exists", "denied")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def owned_process(pid: int | None, start_token: str | int | None) -> oslayer.Presence:
|
|
18
|
+
"""Compare an original PID/start identity with the current OS observation.
|
|
19
|
+
|
|
20
|
+
A PID without a start token cannot prove ownership; kernel-confirmed
|
|
21
|
+
absence still proves the original process is gone.
|
|
22
|
+
"""
|
|
23
|
+
if not pid:
|
|
24
|
+
return "dead"
|
|
25
|
+
if not start_token:
|
|
26
|
+
return "dead" if oslayer.probe_pid(pid) == "missing" else "unprovable"
|
|
27
|
+
info = oslayer.process_info(pid)
|
|
28
|
+
if info.presence == "dead":
|
|
29
|
+
return "dead"
|
|
30
|
+
if info.presence != "alive" or info.start_token is None:
|
|
31
|
+
return "unprovable"
|
|
32
|
+
return "alive" if str(info.start_token) == str(start_token) else "dead"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def owned_pid_alive(pid: int | None, start_token: str | int | None = None) -> bool:
|
|
36
|
+
"""Is ``pid`` alive and still the process CCX recorded?
|
|
37
|
+
|
|
38
|
+
A PID can be reused after the original process exits, crashes or after a
|
|
39
|
+
reboot. A bare ``kill(pid, 0)`` can therefore report a stranger's process,
|
|
40
|
+
including another user's process (EPERM still means that PID exists).
|
|
41
|
+
Treating that as ownership could block a safe revive or signal a stranger.
|
|
42
|
+
|
|
43
|
+
Only the original PID plus its kernel start token proves a living owner.
|
|
44
|
+
Without the token, absence can be proven, but ownership cannot.
|
|
45
|
+
"""
|
|
46
|
+
return owned_process(pid, start_token) == "alive"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def lifecycle_host_alive(lifecycle: dict | None) -> bool:
|
|
50
|
+
"""Whether a record's recorded managed host process is still that host."""
|
|
51
|
+
lifecycle = lifecycle or {}
|
|
52
|
+
return owned_pid_alive(lifecycle.get("hostPid"), lifecycle.get("hostStart"))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def recorded_host_loaded(engine: str, lifecycle: dict | None) -> bool | None:
|
|
56
|
+
"""RPC-free host presence, returning True/False only with identity proof.
|
|
57
|
+
|
|
58
|
+
Claude's recorded host PID/start identifies the original host as alive or
|
|
59
|
+
gone. Codex is served by the shared codexd: the daemon being alive says
|
|
60
|
+
nothing about whether this particular thread is loaded, so Codex returns
|
|
61
|
+
None (unobserved), not a fabricated True/False.
|
|
62
|
+
"""
|
|
63
|
+
lifecycle = lifecycle if isinstance(lifecycle, dict) else {}
|
|
64
|
+
if engine != "claude" or not lifecycle.get("hostPid"):
|
|
65
|
+
return None
|
|
66
|
+
observed = owned_process(lifecycle.get("hostPid"), lifecycle.get("hostStart"))
|
|
67
|
+
return None if observed == "unprovable" else observed == "alive"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def proc_argv(pid: int | None) -> list[str] | None:
|
|
71
|
+
"""Full argv of a live pid from /proc/<pid>/cmdline, or None if unreadable.
|
|
72
|
+
|
|
73
|
+
Reads the whole cmdline — a Claude session-id passed via `--session-id`/
|
|
74
|
+
`--resume` sits well past the first argv tokens, so a truncated read would
|
|
75
|
+
miss it."""
|
|
76
|
+
raw = oslayer.read_argv(pid)
|
|
77
|
+
if raw is None:
|
|
78
|
+
return None
|
|
79
|
+
parts = [p.decode("utf-8", "replace") for p in raw.split(b"\x00") if p]
|
|
80
|
+
return parts or None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def session_pid_alive(pid: int | None, session_id: str | None = None) -> bool:
|
|
84
|
+
"""Is `pid` alive and actually the Claude session it claims to be?
|
|
85
|
+
|
|
86
|
+
`~/.claude/sessions/<pid>.json` is pid-keyed and accumulates stale files
|
|
87
|
+
(pids recycle). A bare `pid_alive` check could accept an unrelated process.
|
|
88
|
+
Accept either of these identity checks:
|
|
89
|
+
|
|
90
|
+
1. the live process's argv contains the 8-char session short — definitive,
|
|
91
|
+
and present whenever the session was launched with `--session-id` or
|
|
92
|
+
`--resume <id>` (bg agents, ccx-managed, resumed sessions); or
|
|
93
|
+
2. argv0 is the `claude` binary (plain `claude` interactive sessions don't
|
|
94
|
+
carry the id in argv). Claude rewrites its own pid-keyed session-meta on
|
|
95
|
+
startup, so a live claude at this pid means the meta is its own.
|
|
96
|
+
|
|
97
|
+
A recycled non-claude pid fails both gates, so its stale meta is rejected."""
|
|
98
|
+
if not pid_alive(pid):
|
|
99
|
+
return False
|
|
100
|
+
argv = proc_argv(pid)
|
|
101
|
+
if not argv:
|
|
102
|
+
return False
|
|
103
|
+
if session_id and session_id[:8] in " ".join(argv):
|
|
104
|
+
return True
|
|
105
|
+
argv0 = argv[0]
|
|
106
|
+
base = argv0.rsplit("/", 1)[-1].lower()
|
|
107
|
+
# `claude`, `~/.local/bin/claude`, or the SEA `.../claude/versions/<v>`.
|
|
108
|
+
return base == "claude" or "/claude/versions/" in argv0 or "/claude/launcher" in argv0
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass(frozen=True)
|
|
112
|
+
class NativeClaudeState:
|
|
113
|
+
"""One validated snapshot from Claude's pid-keyed interactive registry."""
|
|
114
|
+
|
|
115
|
+
pid: int
|
|
116
|
+
session_id: str
|
|
117
|
+
kind: str
|
|
118
|
+
status: str | None
|
|
119
|
+
status_updated_at: int | None
|
|
120
|
+
waiting_for: object | None
|
|
121
|
+
proc_start: str | int | None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def proc_start_token(pid: int | None) -> str | None:
|
|
125
|
+
"""Linux /proc start-time token for one live pid, or ``None``.
|
|
126
|
+
|
|
127
|
+
Field 22 is process start time in clock ticks since boot. It is stable for a
|
|
128
|
+
process lifetime and rejects a stale Claude pid-file after PID reuse.
|
|
129
|
+
"""
|
|
130
|
+
return oslayer.process_info(pid).start_token
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def read_managed_claude_native_state(
|
|
134
|
+
pid: int | None,
|
|
135
|
+
session_id: str | None,
|
|
136
|
+
*,
|
|
137
|
+
claude_home: pathlib.Path | None = None,
|
|
138
|
+
) -> NativeClaudeState | None:
|
|
139
|
+
"""Read an exact managed Claude process's native session snapshot.
|
|
140
|
+
|
|
141
|
+
The registry is an upstream-internal compatibility surface, so this helper is
|
|
142
|
+
deliberately strict: any identity mismatch, unsupported kind, dead process,
|
|
143
|
+
stale process-start token, or unreadable JSON is simply ``None`` (no native
|
|
144
|
+
evidence). The caller must fall back to ordinary CCX semantic evidence.
|
|
145
|
+
"""
|
|
146
|
+
if not pid or not session_id:
|
|
147
|
+
return None
|
|
148
|
+
root = claude_home or CLAUDE_HOME
|
|
149
|
+
try:
|
|
150
|
+
value = json.loads((root / "sessions" / f"{int(pid)}.json").read_text())
|
|
151
|
+
except (OSError, ValueError, TypeError):
|
|
152
|
+
return None
|
|
153
|
+
if not isinstance(value, dict):
|
|
154
|
+
return None
|
|
155
|
+
meta_pid = value.get("pid")
|
|
156
|
+
if meta_pid is not None:
|
|
157
|
+
try:
|
|
158
|
+
if int(meta_pid) != int(pid):
|
|
159
|
+
return None
|
|
160
|
+
except (TypeError, ValueError, OverflowError):
|
|
161
|
+
return None
|
|
162
|
+
if value.get("sessionId") != session_id:
|
|
163
|
+
return None
|
|
164
|
+
# Managed PTY workers are ordinary interactive Claude sessions. Future or
|
|
165
|
+
# missing kinds are not silently treated as equivalent.
|
|
166
|
+
kind = value.get("kind")
|
|
167
|
+
if kind != "interactive":
|
|
168
|
+
return None
|
|
169
|
+
meta_start = value.get("procStart")
|
|
170
|
+
if meta_start is None:
|
|
171
|
+
meta_start = value.get("procStartFt")
|
|
172
|
+
if meta_start is not None:
|
|
173
|
+
live_start = proc_start_token(pid)
|
|
174
|
+
if not oslayer.provider_start_matches(
|
|
175
|
+
meta_start, live_start, value.get("pidDomain")):
|
|
176
|
+
return None
|
|
177
|
+
elif not session_pid_alive(pid, session_id):
|
|
178
|
+
# Legacy local metadata may lack the provider token. It is not a
|
|
179
|
+
# Darwin authorization; only Linux's historical argv corroboration
|
|
180
|
+
# can supply the optional diagnostic snapshot.
|
|
181
|
+
return None
|
|
182
|
+
status = value.get("status")
|
|
183
|
+
status_updated_at = value.get("statusUpdatedAt")
|
|
184
|
+
if not isinstance(status_updated_at, int) or isinstance(status_updated_at, bool):
|
|
185
|
+
status_updated_at = None
|
|
186
|
+
return NativeClaudeState(
|
|
187
|
+
pid=int(pid),
|
|
188
|
+
session_id=session_id,
|
|
189
|
+
kind=kind,
|
|
190
|
+
status=status if isinstance(status, str) else None,
|
|
191
|
+
status_updated_at=status_updated_at,
|
|
192
|
+
waiting_for=value.get("waitingFor"),
|
|
193
|
+
proc_start=meta_start,
|
|
194
|
+
)
|