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,681 @@
|
|
|
1
|
+
"""Durable storage for task-specific Python controller runs.
|
|
2
|
+
|
|
3
|
+
Controller records are deliberately independent from Claude and Codex worker
|
|
4
|
+
records. They retain qualified worker handles, never duplicate or own worker
|
|
5
|
+
lifecycle state.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from contextlib import contextmanager
|
|
11
|
+
import copy
|
|
12
|
+
import fcntl
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
import tempfile
|
|
18
|
+
import time
|
|
19
|
+
import uuid
|
|
20
|
+
from typing import Any, Callable, Iterator
|
|
21
|
+
|
|
22
|
+
from ccx import constants
|
|
23
|
+
from ccx.system import oslayer
|
|
24
|
+
from ccx.errors import ControllerError, ControllerRunNotFound
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
CONTROLLER_SCHEMA_VERSION = 1
|
|
28
|
+
# Operation ordinals changed when WorkerClient.steer/kill became observable.
|
|
29
|
+
# Missing/unknown layout markers MUST NOT silently adopt the new slot order.
|
|
30
|
+
CONTROLLER_OPERATION_JOURNAL_LAYOUT = 3
|
|
31
|
+
TERMINAL_LIFECYCLES = frozenset({"succeeded", "failed", "stopped", "unavailable"})
|
|
32
|
+
|
|
33
|
+
#: Mechanical form each execution attempt began with. These describe only the
|
|
34
|
+
#: CLI shape that started the attempt, never why an orchestrator started it.
|
|
35
|
+
ATTEMPT_START_INITIAL = "initial"
|
|
36
|
+
ATTEMPT_START_RESUME = "resume"
|
|
37
|
+
ATTEMPT_START_RESUME_NOTE = "resume-note"
|
|
38
|
+
ATTEMPT_START_RESUME_WORKER_COMMENT = "resume-worker-comment"
|
|
39
|
+
ATTEMPT_START_FORMS = frozenset({
|
|
40
|
+
ATTEMPT_START_INITIAL,
|
|
41
|
+
ATTEMPT_START_RESUME,
|
|
42
|
+
ATTEMPT_START_RESUME_NOTE,
|
|
43
|
+
ATTEMPT_START_RESUME_WORKER_COMMENT,
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def now_ms() -> int:
|
|
48
|
+
return int(time.time() * 1000)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def controllers_root() -> Path:
|
|
52
|
+
return constants.CCX_ROOT / "controllers"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def bootstrap_root() -> Path:
|
|
56
|
+
return controllers_root() / ".bootstrap"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def launch_root() -> Path:
|
|
60
|
+
return controllers_root() / ".launch"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def stop_marker_path(run_id: str) -> Path:
|
|
64
|
+
return launch_root() / f"{run_id}.stopped"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def clear_stop_marker(run_id: str) -> None:
|
|
68
|
+
try:
|
|
69
|
+
stop_marker_path(run_id).unlink()
|
|
70
|
+
except FileNotFoundError:
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def ensure_controller_dirs() -> None:
|
|
75
|
+
from ccx.store.paths import private_mkdir
|
|
76
|
+
|
|
77
|
+
for path in (controllers_root(), bootstrap_root(), launch_root()):
|
|
78
|
+
private_mkdir(path)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def validate_controller_name(name: str) -> str:
|
|
82
|
+
if not isinstance(name, str) or not name.strip():
|
|
83
|
+
raise ControllerError("controller name must be a non-empty string")
|
|
84
|
+
value = name.strip()
|
|
85
|
+
if len(value) > 80:
|
|
86
|
+
raise ControllerError("controller name must be at most 80 characters")
|
|
87
|
+
if value.startswith(".") or os.sep in value or (os.altsep and os.altsep in value):
|
|
88
|
+
raise ControllerError(
|
|
89
|
+
f"controller name must be a single path segment, got {name!r}"
|
|
90
|
+
)
|
|
91
|
+
if any(ord(char) < 32 or ord(char) == 127 for char in value):
|
|
92
|
+
raise ControllerError("controller name cannot contain control characters")
|
|
93
|
+
return value
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def source_sha256(path: Path) -> str:
|
|
97
|
+
digest = hashlib.sha256()
|
|
98
|
+
with path.open("rb") as handle:
|
|
99
|
+
for block in iter(lambda: handle.read(1024 * 1024), b""):
|
|
100
|
+
digest.update(block)
|
|
101
|
+
return digest.hexdigest()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@contextmanager
|
|
105
|
+
def file_lock(path: Path) -> Iterator[None]:
|
|
106
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
107
|
+
with path.open("a+b") as handle:
|
|
108
|
+
fcntl.flock(handle, fcntl.LOCK_EX)
|
|
109
|
+
try:
|
|
110
|
+
yield
|
|
111
|
+
finally:
|
|
112
|
+
fcntl.flock(handle, fcntl.LOCK_UN)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def read_json(path: Path) -> dict[str, Any]:
|
|
116
|
+
try:
|
|
117
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
118
|
+
except FileNotFoundError as error:
|
|
119
|
+
raise ControllerError(f"controller state not found: {path}") from error
|
|
120
|
+
except (OSError, json.JSONDecodeError) as error:
|
|
121
|
+
raise ControllerError(f"cannot read controller state {path}: {error}") from error
|
|
122
|
+
if not isinstance(value, dict):
|
|
123
|
+
raise ControllerError(f"controller state is not a JSON object: {path}")
|
|
124
|
+
return value
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def write_json_atomic(path: Path, value: dict[str, Any]) -> None:
|
|
128
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
129
|
+
fd, temporary = tempfile.mkstemp(
|
|
130
|
+
dir=path.parent, prefix=f".{path.name}.", suffix=".tmp"
|
|
131
|
+
)
|
|
132
|
+
try:
|
|
133
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
134
|
+
json.dump(value, handle, indent=2, sort_keys=True)
|
|
135
|
+
handle.write("\n")
|
|
136
|
+
handle.flush()
|
|
137
|
+
os.fsync(handle.fileno())
|
|
138
|
+
os.replace(temporary, path)
|
|
139
|
+
except BaseException:
|
|
140
|
+
try:
|
|
141
|
+
os.unlink(temporary)
|
|
142
|
+
except FileNotFoundError:
|
|
143
|
+
pass
|
|
144
|
+
raise
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def append_ndjson(path: Path, value: dict[str, Any]) -> None:
|
|
148
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
149
|
+
line = (json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n").encode()
|
|
150
|
+
with path.open("ab", buffering=0) as handle:
|
|
151
|
+
fcntl.flock(handle, fcntl.LOCK_EX)
|
|
152
|
+
try:
|
|
153
|
+
handle.write(line)
|
|
154
|
+
os.fsync(handle.fileno())
|
|
155
|
+
finally:
|
|
156
|
+
fcntl.flock(handle, fcntl.LOCK_UN)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def run_record_path(run_dir: Path) -> Path:
|
|
160
|
+
return run_dir / "run.json"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def require_operation_journal_layout(
|
|
164
|
+
run_dir: Path, record: dict[str, Any] | None = None,
|
|
165
|
+
) -> None:
|
|
166
|
+
"""Refuse replay across incompatible positional WorkerClient journals.
|
|
167
|
+
|
|
168
|
+
Never repair, infer or auto-upgrade old runs: operations absent from their
|
|
169
|
+
journals (notably steer/kill) may already have mutated a real worker.
|
|
170
|
+
"""
|
|
171
|
+
if record is None:
|
|
172
|
+
record = read_json(run_record_path(run_dir))
|
|
173
|
+
found = record.get("operationJournalLayout")
|
|
174
|
+
if type(found) is int and found == CONTROLLER_OPERATION_JOURNAL_LAYOUT:
|
|
175
|
+
return
|
|
176
|
+
label = "missing" if found is None else repr(found)
|
|
177
|
+
raise ControllerError(
|
|
178
|
+
f"controller run {record.get('runId') or run_dir.name} uses "
|
|
179
|
+
f"unsupported operation journal layout {label} "
|
|
180
|
+
f"(required {CONTROLLER_OPERATION_JOURNAL_LAYOUT}); start a new run."
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def mutate_run(run_dir: Path, update: Callable[[dict[str, Any]], None]) -> dict[str, Any]:
|
|
185
|
+
with file_lock(run_dir / "state.lock"):
|
|
186
|
+
record = read_json(run_record_path(run_dir))
|
|
187
|
+
snapshot_attempt_outcome(record)
|
|
188
|
+
update(record)
|
|
189
|
+
snapshot_attempt_outcome(record)
|
|
190
|
+
record["updatedAt"] = now_ms()
|
|
191
|
+
write_json_atomic(run_record_path(run_dir), record)
|
|
192
|
+
return copy.deepcopy(record)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def snapshot_attempt_outcome(record: dict[str, Any]) -> None:
|
|
196
|
+
"""Keep a settled attempt's report stable across later resumes."""
|
|
197
|
+
attempts = record.get("attempts") or []
|
|
198
|
+
if not attempts:
|
|
199
|
+
return
|
|
200
|
+
attempt = attempts[-1]
|
|
201
|
+
state = attempt.get("lifecycle")
|
|
202
|
+
if state not in TERMINAL_LIFECYCLES or "outcome" in attempt:
|
|
203
|
+
return
|
|
204
|
+
reports = record.get("reports") or []
|
|
205
|
+
reports = reports[int(attempt.get("reportStartIndex", 0)):]
|
|
206
|
+
report = reports[-1] if reports else None
|
|
207
|
+
attempt["outcome"] = copy.deepcopy({
|
|
208
|
+
"status": report.get("status") if report else ("completed" if state == "succeeded" else None),
|
|
209
|
+
"checkpoint": report.get("checkpoint") if report else None,
|
|
210
|
+
"summary": report.get("summary") if report else None,
|
|
211
|
+
"report": report,
|
|
212
|
+
"error": record.get("error"),
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def append_event(run_dir: Path, event_type: str, **data: Any) -> None:
|
|
217
|
+
append_ndjson(
|
|
218
|
+
run_dir / "events.ndjson",
|
|
219
|
+
{
|
|
220
|
+
"schemaVersion": CONTROLLER_SCHEMA_VERSION,
|
|
221
|
+
"type": event_type,
|
|
222
|
+
"ts": now_ms(),
|
|
223
|
+
**data,
|
|
224
|
+
},
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def create_bootstrap(
|
|
229
|
+
script: Path,
|
|
230
|
+
launch_cwd: Path,
|
|
231
|
+
argv: list[str],
|
|
232
|
+
*,
|
|
233
|
+
controller_name: str | None = None,
|
|
234
|
+
) -> tuple[str, Path]:
|
|
235
|
+
requested_name = (
|
|
236
|
+
validate_controller_name(controller_name)
|
|
237
|
+
if controller_name is not None
|
|
238
|
+
else None
|
|
239
|
+
)
|
|
240
|
+
ensure_controller_dirs()
|
|
241
|
+
run_id = str(uuid.uuid4())
|
|
242
|
+
directory = bootstrap_root() / run_id
|
|
243
|
+
directory.mkdir(parents=True)
|
|
244
|
+
for child in ("reports", "tasks", "worktrees", "operations"):
|
|
245
|
+
(directory / child).mkdir()
|
|
246
|
+
created = now_ms()
|
|
247
|
+
record = {
|
|
248
|
+
"schemaVersion": CONTROLLER_SCHEMA_VERSION,
|
|
249
|
+
"operationJournalLayout": CONTROLLER_OPERATION_JOURNAL_LAYOUT,
|
|
250
|
+
"runId": run_id,
|
|
251
|
+
"controller": None,
|
|
252
|
+
"requestedControllerName": requested_name,
|
|
253
|
+
"declaredControllerName": None,
|
|
254
|
+
"scriptPath": str(script),
|
|
255
|
+
"sourceSha256": source_sha256(script),
|
|
256
|
+
"launchCwd": str(launch_cwd),
|
|
257
|
+
"workspace": str(launch_cwd),
|
|
258
|
+
"createdAt": created,
|
|
259
|
+
"updatedAt": created,
|
|
260
|
+
"lifecycle": {
|
|
261
|
+
"state": "starting", "since": created, "pid": None,
|
|
262
|
+
"pidStart": None,
|
|
263
|
+
},
|
|
264
|
+
"status": None,
|
|
265
|
+
"checkpoint": None,
|
|
266
|
+
"summary": None,
|
|
267
|
+
"latestReport": None,
|
|
268
|
+
"reports": [],
|
|
269
|
+
"result": None,
|
|
270
|
+
"error": None,
|
|
271
|
+
"attempts": [{
|
|
272
|
+
"number": 1,
|
|
273
|
+
"attemptToken": str(uuid.uuid4()),
|
|
274
|
+
"reportStartIndex": 0,
|
|
275
|
+
"startedAt": created,
|
|
276
|
+
"argv": list(argv),
|
|
277
|
+
"startForm": ATTEMPT_START_INITIAL,
|
|
278
|
+
"startInputId": None,
|
|
279
|
+
}],
|
|
280
|
+
"tasks": {},
|
|
281
|
+
"workers": [],
|
|
282
|
+
"inputs": 0,
|
|
283
|
+
"worktrees": {},
|
|
284
|
+
}
|
|
285
|
+
write_json_atomic(run_record_path(directory), record)
|
|
286
|
+
append_event(directory, "run-created", runId=run_id, scriptPath=str(script))
|
|
287
|
+
return run_id, directory
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _controller_dirs() -> list[Path]:
|
|
291
|
+
root = controllers_root()
|
|
292
|
+
if not root.is_dir():
|
|
293
|
+
return []
|
|
294
|
+
return sorted(
|
|
295
|
+
(
|
|
296
|
+
path
|
|
297
|
+
for path in root.iterdir()
|
|
298
|
+
if path.is_dir() and not path.name.startswith(".")
|
|
299
|
+
),
|
|
300
|
+
key=lambda path: path.name.casefold(),
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _controller_entry_by_name(name: str) -> tuple[Path, dict[str, Any]] | None:
|
|
305
|
+
folded = name.casefold()
|
|
306
|
+
matches: list[tuple[Path, dict[str, Any]]] = []
|
|
307
|
+
for directory in _controller_dirs():
|
|
308
|
+
try:
|
|
309
|
+
record = read_json(directory / "controller.json")
|
|
310
|
+
except ControllerError:
|
|
311
|
+
continue
|
|
312
|
+
if str(record.get("name", "")).casefold() == folded:
|
|
313
|
+
matches.append((directory, record))
|
|
314
|
+
if len(matches) > 1:
|
|
315
|
+
raise ControllerError(f"ambiguous controller name: {name!r}")
|
|
316
|
+
return matches[0] if matches else None
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def publish_bootstrap(
|
|
320
|
+
bootstrap: Path,
|
|
321
|
+
*,
|
|
322
|
+
name: str,
|
|
323
|
+
declared_name: str | None = None,
|
|
324
|
+
script: Path,
|
|
325
|
+
workspace: Path,
|
|
326
|
+
) -> Path:
|
|
327
|
+
name = validate_controller_name(name)
|
|
328
|
+
if declared_name is not None:
|
|
329
|
+
declared_name = validate_controller_name(declared_name)
|
|
330
|
+
script = script.resolve()
|
|
331
|
+
workspace = workspace.resolve()
|
|
332
|
+
if not workspace.is_dir():
|
|
333
|
+
raise ControllerError(f"controller workspace is not a directory: {workspace}")
|
|
334
|
+
with file_lock(controllers_root() / ".controllers.lock"):
|
|
335
|
+
existing = _controller_entry_by_name(name)
|
|
336
|
+
if existing is not None:
|
|
337
|
+
directory, controller_record = existing
|
|
338
|
+
bound = Path(str(controller_record.get("scriptPath", ""))).resolve()
|
|
339
|
+
if bound != script:
|
|
340
|
+
raise ControllerError(
|
|
341
|
+
f"controller name {name!r} is already bound to {bound}; "
|
|
342
|
+
f"choose a different controller name for {script}"
|
|
343
|
+
)
|
|
344
|
+
name = str(controller_record["name"])
|
|
345
|
+
else:
|
|
346
|
+
directory = controllers_root() / name
|
|
347
|
+
directory.mkdir(parents=True)
|
|
348
|
+
created = now_ms()
|
|
349
|
+
controller_record = {
|
|
350
|
+
"schemaVersion": CONTROLLER_SCHEMA_VERSION,
|
|
351
|
+
"name": name,
|
|
352
|
+
"scriptPath": str(script),
|
|
353
|
+
"createdAt": created,
|
|
354
|
+
"updatedAt": created,
|
|
355
|
+
}
|
|
356
|
+
write_json_atomic(directory / "controller.json", controller_record)
|
|
357
|
+
(directory / "runs").mkdir(exist_ok=True)
|
|
358
|
+
record = read_json(run_record_path(bootstrap))
|
|
359
|
+
run_id = str(record["runId"])
|
|
360
|
+
destination = directory / "runs" / run_id
|
|
361
|
+
if destination.exists():
|
|
362
|
+
raise ControllerError(f"controller run already exists: {run_id}")
|
|
363
|
+
record["controller"] = name
|
|
364
|
+
record["declaredControllerName"] = declared_name
|
|
365
|
+
record["workspace"] = str(workspace)
|
|
366
|
+
write_json_atomic(run_record_path(bootstrap), record)
|
|
367
|
+
os.replace(bootstrap, destination)
|
|
368
|
+
controller_record["updatedAt"] = now_ms()
|
|
369
|
+
controller_record["latestRunId"] = run_id
|
|
370
|
+
write_json_atomic(directory / "controller.json", controller_record)
|
|
371
|
+
append_event(
|
|
372
|
+
destination, "run-published", controller=name,
|
|
373
|
+
declaredControllerName=declared_name, workspace=str(workspace),
|
|
374
|
+
)
|
|
375
|
+
return destination
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def launch_verdict_path(run_id: str) -> Path:
|
|
379
|
+
return launch_root() / f"{run_id}.json"
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def write_launch_verdict(run_id: str, value: dict[str, Any]) -> None:
|
|
383
|
+
ensure_controller_dirs()
|
|
384
|
+
write_json_atomic(launch_verdict_path(run_id), value)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def read_launch_verdict(run_id: str) -> dict[str, Any] | None:
|
|
388
|
+
path = launch_verdict_path(run_id)
|
|
389
|
+
if not path.exists():
|
|
390
|
+
return None
|
|
391
|
+
try:
|
|
392
|
+
return read_json(path)
|
|
393
|
+
except ControllerError:
|
|
394
|
+
return None
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def controller_inventory() -> list[dict[str, Any]]:
|
|
398
|
+
rows: list[dict[str, Any]] = []
|
|
399
|
+
for directory in _controller_dirs():
|
|
400
|
+
try:
|
|
401
|
+
controller_record = read_json(directory / "controller.json")
|
|
402
|
+
except ControllerError:
|
|
403
|
+
continue
|
|
404
|
+
runs = run_inventory(str(controller_record.get("name", directory.name)))
|
|
405
|
+
latest = runs[0] if runs else None
|
|
406
|
+
rows.append({
|
|
407
|
+
"name": controller_record.get("name", directory.name),
|
|
408
|
+
"scriptPath": controller_record.get("scriptPath"),
|
|
409
|
+
"runs": len(runs),
|
|
410
|
+
"latest": latest,
|
|
411
|
+
})
|
|
412
|
+
return sorted(rows, key=lambda row: str(row["name"]).casefold())
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def run_inventory(name: str) -> list[dict[str, Any]]:
|
|
416
|
+
entry = _controller_entry_by_name(name)
|
|
417
|
+
if entry is None:
|
|
418
|
+
raise ControllerError(f"controller not found: {name}")
|
|
419
|
+
directory, _record = entry
|
|
420
|
+
rows: list[dict[str, Any]] = []
|
|
421
|
+
runs = directory / "runs"
|
|
422
|
+
if runs.is_dir():
|
|
423
|
+
for run_dir in runs.iterdir():
|
|
424
|
+
if not run_dir.is_dir():
|
|
425
|
+
continue
|
|
426
|
+
try:
|
|
427
|
+
run = refresh_liveness(run_dir)
|
|
428
|
+
except ControllerError:
|
|
429
|
+
continue
|
|
430
|
+
rows.append(run_summary(run, run_dir))
|
|
431
|
+
return sorted(rows, key=lambda row: int(row.get("createdAt") or 0), reverse=True)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def latest_attempt_start(record: dict[str, Any]) -> dict[str, Any]:
|
|
435
|
+
"""Project the recorded mechanical start form of the newest attempt.
|
|
436
|
+
|
|
437
|
+
Attempts written before this field existed stay valid and are reported as
|
|
438
|
+
unrecorded rather than guessed at or backfilled.
|
|
439
|
+
"""
|
|
440
|
+
attempts = [item for item in (record.get("attempts") or []) if isinstance(item, dict)]
|
|
441
|
+
if not attempts:
|
|
442
|
+
return {"number": None, "form": None, "inputId": None, "recorded": False}
|
|
443
|
+
latest = attempts[-1]
|
|
444
|
+
form = latest.get("startForm")
|
|
445
|
+
if form not in ATTEMPT_START_FORMS:
|
|
446
|
+
form = None
|
|
447
|
+
input_id = latest.get("startInputId")
|
|
448
|
+
return {
|
|
449
|
+
"number": latest.get("number"),
|
|
450
|
+
"form": form,
|
|
451
|
+
"inputId": str(input_id) if isinstance(input_id, str) and input_id else None,
|
|
452
|
+
"recorded": form is not None,
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def run_summary(record: dict[str, Any], run_dir: Path | None = None) -> dict[str, Any]:
|
|
457
|
+
lifecycle = record.get("lifecycle") or {}
|
|
458
|
+
tasks = record.get("tasks") or {}
|
|
459
|
+
complete = sum(
|
|
460
|
+
1 for task_record in tasks.values()
|
|
461
|
+
if isinstance(task_record, dict) and task_record.get("state") == "succeeded"
|
|
462
|
+
)
|
|
463
|
+
summary = {
|
|
464
|
+
"runId": record.get("runId"),
|
|
465
|
+
"controller": record.get("controller"),
|
|
466
|
+
"declaredControllerName": record.get("declaredControllerName"),
|
|
467
|
+
"requestedControllerName": record.get("requestedControllerName"),
|
|
468
|
+
"lifecycle": lifecycle.get("state"),
|
|
469
|
+
"pid": lifecycle.get("pid"),
|
|
470
|
+
"status": record.get("status"),
|
|
471
|
+
"checkpoint": record.get("checkpoint"),
|
|
472
|
+
"summary": record.get("summary"),
|
|
473
|
+
"createdAt": record.get("createdAt"),
|
|
474
|
+
"updatedAt": record.get("updatedAt"),
|
|
475
|
+
"tasks": len(tasks),
|
|
476
|
+
"tasksSucceeded": complete,
|
|
477
|
+
"workers": len(record.get("workers") or []),
|
|
478
|
+
"attempts": len([
|
|
479
|
+
item for item in (record.get("attempts") or []) if isinstance(item, dict)
|
|
480
|
+
]),
|
|
481
|
+
"latestAttemptStart": latest_attempt_start(record),
|
|
482
|
+
}
|
|
483
|
+
if run_dir is not None:
|
|
484
|
+
summary["runDir"] = str(run_dir)
|
|
485
|
+
return summary
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _all_run_dirs() -> Iterator[Path]:
|
|
489
|
+
for controller_dir in _controller_dirs():
|
|
490
|
+
runs = controller_dir / "runs"
|
|
491
|
+
if not runs.is_dir():
|
|
492
|
+
continue
|
|
493
|
+
yield from (path for path in runs.iterdir() if path.is_dir())
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def resolve_run(target: str) -> Path:
|
|
497
|
+
exact_controller = _controller_entry_by_name(target)
|
|
498
|
+
if exact_controller is not None:
|
|
499
|
+
runs = run_inventory(target)
|
|
500
|
+
if not runs:
|
|
501
|
+
raise ControllerRunNotFound(f"controller has no runs: {target}")
|
|
502
|
+
return Path(str(runs[0]["runDir"]))
|
|
503
|
+
|
|
504
|
+
candidates: list[Path] = []
|
|
505
|
+
for run_dir in _all_run_dirs():
|
|
506
|
+
run_id = run_dir.name
|
|
507
|
+
if run_id == target:
|
|
508
|
+
return run_dir
|
|
509
|
+
if run_id.startswith(target):
|
|
510
|
+
candidates.append(run_dir)
|
|
511
|
+
if not candidates:
|
|
512
|
+
raise ControllerRunNotFound(f"controller run not found: {target}")
|
|
513
|
+
if len(candidates) > 1:
|
|
514
|
+
raise ControllerError(f"ambiguous controller run prefix: {target}")
|
|
515
|
+
return candidates[0]
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def append_controller_input(
|
|
519
|
+
run_dir: Path,
|
|
520
|
+
*,
|
|
521
|
+
worker_target: str | None,
|
|
522
|
+
comment: str,
|
|
523
|
+
) -> dict[str, Any]:
|
|
524
|
+
if not isinstance(comment, str) or not comment.strip():
|
|
525
|
+
raise ControllerError("controller input comment must be non-empty")
|
|
526
|
+
record = read_json(run_record_path(run_dir))
|
|
527
|
+
worker = None
|
|
528
|
+
if worker_target is not None:
|
|
529
|
+
from ccx.store.identity import IdentityError, parse_managed_target
|
|
530
|
+
try:
|
|
531
|
+
target = parse_managed_target(worker_target)
|
|
532
|
+
if target.qualified_engine is None:
|
|
533
|
+
raise IdentityError("controller worker input needs a qualified full UUID")
|
|
534
|
+
except IdentityError as error:
|
|
535
|
+
raise ControllerError(f"controller worker must be an exact qualified full UUID: {error}") from error
|
|
536
|
+
matches = [
|
|
537
|
+
item for item in record.get("workers", [])
|
|
538
|
+
if isinstance(item, dict)
|
|
539
|
+
and item.get("worker") == worker_target
|
|
540
|
+
and item.get("worker_id") == target.worker_id
|
|
541
|
+
and item.get("engine") == target.qualified_engine
|
|
542
|
+
]
|
|
543
|
+
if len(matches) != 1:
|
|
544
|
+
raise ControllerError(
|
|
545
|
+
f"worker {worker_target!r} is not uniquely recorded by controller run {record['runId']}"
|
|
546
|
+
)
|
|
547
|
+
worker = matches[0]
|
|
548
|
+
entry = {
|
|
549
|
+
"id": str(uuid.uuid4()),
|
|
550
|
+
"createdAt": now_ms(),
|
|
551
|
+
"worker": worker["worker"] if worker is not None else None,
|
|
552
|
+
"workerName": worker.get("name") if worker is not None else None,
|
|
553
|
+
"comment": comment,
|
|
554
|
+
}
|
|
555
|
+
append_ndjson(run_dir / "inputs.ndjson", entry)
|
|
556
|
+
mutate_run(run_dir, lambda value: value.__setitem__("inputs", int(value.get("inputs") or 0) + 1))
|
|
557
|
+
append_event(run_dir, "input-appended", inputId=entry["id"], worker=entry["worker"])
|
|
558
|
+
return entry
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def read_inputs(run_dir: Path) -> list[dict[str, Any]]:
|
|
562
|
+
path = run_dir / "inputs.ndjson"
|
|
563
|
+
if not path.exists():
|
|
564
|
+
return []
|
|
565
|
+
values: list[dict[str, Any]] = []
|
|
566
|
+
try:
|
|
567
|
+
with path.open("r", encoding="utf-8") as handle:
|
|
568
|
+
fcntl.flock(handle, fcntl.LOCK_SH)
|
|
569
|
+
try:
|
|
570
|
+
lines = handle.read().splitlines()
|
|
571
|
+
finally:
|
|
572
|
+
fcntl.flock(handle, fcntl.LOCK_UN)
|
|
573
|
+
for line in lines:
|
|
574
|
+
if not line.strip():
|
|
575
|
+
continue
|
|
576
|
+
parsed = json.loads(line)
|
|
577
|
+
if isinstance(parsed, dict):
|
|
578
|
+
values.append(parsed)
|
|
579
|
+
except (OSError, json.JSONDecodeError) as error:
|
|
580
|
+
raise ControllerError(f"cannot read controller inputs: {error}") from error
|
|
581
|
+
return values
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def process_presence(pid: Any, start: Any) -> oslayer.Presence:
|
|
585
|
+
"""Controller PID/start identity, never an argv-only signal authority."""
|
|
586
|
+
from ccx.store.liveness import owned_process
|
|
587
|
+
|
|
588
|
+
if pid is None:
|
|
589
|
+
return "dead"
|
|
590
|
+
if type(pid) is not int or pid <= 0 or not isinstance(start, str) or not start:
|
|
591
|
+
return "unprovable"
|
|
592
|
+
return owned_process(pid, start)
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def refresh_liveness(run_dir: Path) -> dict[str, Any]:
|
|
596
|
+
record = read_json(run_record_path(run_dir))
|
|
597
|
+
lifecycle = record.get("lifecycle") or {}
|
|
598
|
+
state = lifecycle.get("state")
|
|
599
|
+
run_id = str(record.get("runId") or "")
|
|
600
|
+
if state in {"starting", "running"} and process_presence(
|
|
601
|
+
lifecycle.get("pid"), lifecycle.get("pidStart")
|
|
602
|
+
) == "dead":
|
|
603
|
+
stopped = stop_marker_path(run_id).exists()
|
|
604
|
+
changed = False
|
|
605
|
+
|
|
606
|
+
def terminal(value: dict[str, Any]) -> None:
|
|
607
|
+
nonlocal changed
|
|
608
|
+
current = value.setdefault("lifecycle", {})
|
|
609
|
+
# A resume or host startup may land while liveness is checked.
|
|
610
|
+
# Never apply a dead-PID observation to that newer lifecycle.
|
|
611
|
+
if (current.get("state") in {"starting", "running"}
|
|
612
|
+
and current.get("pid") == lifecycle.get("pid")
|
|
613
|
+
and current.get("pidStart") == lifecycle.get("pidStart")
|
|
614
|
+
and current.get("since") == lifecycle.get("since")
|
|
615
|
+
and len(value.get("attempts") or []) == len(record.get("attempts") or [])):
|
|
616
|
+
changed = True
|
|
617
|
+
ended_at = now_ms()
|
|
618
|
+
current.update(
|
|
619
|
+
state="stopped" if stopped else "unavailable",
|
|
620
|
+
since=ended_at,
|
|
621
|
+
pid=None,
|
|
622
|
+
pidStart=None,
|
|
623
|
+
)
|
|
624
|
+
attempts = value.setdefault("attempts", [])
|
|
625
|
+
if attempts:
|
|
626
|
+
attempts[-1].update(
|
|
627
|
+
completedAt=ended_at,
|
|
628
|
+
lifecycle="stopped" if stopped else "unavailable",
|
|
629
|
+
)
|
|
630
|
+
if stopped:
|
|
631
|
+
value["error"] = None
|
|
632
|
+
else:
|
|
633
|
+
value["error"] = value.get("error") or {
|
|
634
|
+
"type": "ControllerHostUnavailable",
|
|
635
|
+
"message": "controller host exited without publishing a terminal state",
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
record = mutate_run(run_dir, terminal)
|
|
639
|
+
if changed:
|
|
640
|
+
append_event(run_dir, "run-stopped" if stopped else "host-unavailable")
|
|
641
|
+
return record
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
__all__ = [
|
|
645
|
+
"ATTEMPT_START_FORMS",
|
|
646
|
+
"ATTEMPT_START_INITIAL",
|
|
647
|
+
"ATTEMPT_START_RESUME",
|
|
648
|
+
"ATTEMPT_START_RESUME_NOTE",
|
|
649
|
+
"ATTEMPT_START_RESUME_WORKER_COMMENT",
|
|
650
|
+
"CONTROLLER_SCHEMA_VERSION",
|
|
651
|
+
"TERMINAL_LIFECYCLES",
|
|
652
|
+
"latest_attempt_start",
|
|
653
|
+
"append_controller_input",
|
|
654
|
+
"append_event",
|
|
655
|
+
"append_ndjson",
|
|
656
|
+
"bootstrap_root",
|
|
657
|
+
"clear_stop_marker",
|
|
658
|
+
"controller_inventory",
|
|
659
|
+
"controllers_root",
|
|
660
|
+
"create_bootstrap",
|
|
661
|
+
"ensure_controller_dirs",
|
|
662
|
+
"file_lock",
|
|
663
|
+
"launch_verdict_path",
|
|
664
|
+
"mutate_run",
|
|
665
|
+
"now_ms",
|
|
666
|
+
"process_presence",
|
|
667
|
+
"publish_bootstrap",
|
|
668
|
+
"read_inputs",
|
|
669
|
+
"read_json",
|
|
670
|
+
"read_launch_verdict",
|
|
671
|
+
"refresh_liveness",
|
|
672
|
+
"resolve_run",
|
|
673
|
+
"run_inventory",
|
|
674
|
+
"run_record_path",
|
|
675
|
+
"run_summary",
|
|
676
|
+
"source_sha256",
|
|
677
|
+
"stop_marker_path",
|
|
678
|
+
"validate_controller_name",
|
|
679
|
+
"write_json_atomic",
|
|
680
|
+
"write_launch_verdict",
|
|
681
|
+
]
|