ccx-native 0.0.1 → 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.
Files changed (150) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +241 -4
  3. package/bin/ccx +55 -2
  4. package/ccx/__init__.py +163 -0
  5. package/ccx/__main__.py +28 -0
  6. package/ccx/claude/__init__.py +0 -0
  7. package/ccx/claude/analytics.py +466 -0
  8. package/ccx/claude/attach.py +762 -0
  9. package/ccx/claude/context.py +465 -0
  10. package/ccx/claude/delivery.py +806 -0
  11. package/ccx/claude/external.py +253 -0
  12. package/ccx/claude/host/__init__.py +0 -0
  13. package/ccx/claude/host/events.py +297 -0
  14. package/ccx/claude/host/hooks.py +438 -0
  15. package/ccx/claude/host/pty.py +250 -0
  16. package/ccx/claude/host/session.py +5184 -0
  17. package/ccx/claude/host/subagents.py +169 -0
  18. package/ccx/claude/host/successor.py +267 -0
  19. package/ccx/claude/managed_turns.py +240 -0
  20. package/ccx/claude/oauth.py +176 -0
  21. package/ccx/claude/settings.py +339 -0
  22. package/ccx/claude/spawn.py +557 -0
  23. package/ccx/claude/tasks.py +72 -0
  24. package/ccx/claude/telemetry.py +282 -0
  25. package/ccx/claude/transcript.py +2281 -0
  26. package/ccx/claude/usage.py +837 -0
  27. package/ccx/claude/usage_control.py +152 -0
  28. package/ccx/cli/__init__.py +0 -0
  29. package/ccx/cli/codex.py +1009 -0
  30. package/ccx/cli/commands/__init__.py +0 -0
  31. package/ccx/cli/commands/_common.py +407 -0
  32. package/ccx/cli/commands/control.py +161 -0
  33. package/ccx/cli/commands/create.py +669 -0
  34. package/ccx/cli/commands/read.py +1020 -0
  35. package/ccx/cli/commands/turns.py +1081 -0
  36. package/ccx/cli/controller.py +757 -0
  37. package/ccx/cli/main.py +301 -0
  38. package/ccx/cli/parser.py +1324 -0
  39. package/ccx/cli/skills.py +215 -0
  40. package/ccx/cli/statusline.py +160 -0
  41. package/ccx/client.py +1900 -0
  42. package/ccx/codex/__init__.py +0 -0
  43. package/ccx/codex/attach_tty.py +267 -0
  44. package/ccx/codex/client.py +732 -0
  45. package/ccx/codex/completion.py +121 -0
  46. package/ccx/codex/control.py +288 -0
  47. package/ccx/codex/daemon/__init__.py +0 -0
  48. package/ccx/codex/daemon/__main__.py +4 -0
  49. package/ccx/codex/daemon/daemon.py +1556 -0
  50. package/ccx/codex/daemon/models.py +140 -0
  51. package/ccx/codex/daemon/native.py +1590 -0
  52. package/ccx/codex/daemon/release.py +367 -0
  53. package/ccx/codex/daemon/worker.py +2396 -0
  54. package/ccx/codex/delivery.py +758 -0
  55. package/ccx/codex/delivery_recovery.py +232 -0
  56. package/ccx/codex/evidence.py +827 -0
  57. package/ccx/codex/history.py +158 -0
  58. package/ccx/codex/notify_history.py +140 -0
  59. package/ccx/codex/ops.py +666 -0
  60. package/ccx/codex/paths.py +70 -0
  61. package/ccx/codex/protocol.py +805 -0
  62. package/ccx/codex/provenance.py +105 -0
  63. package/ccx/codex/read.py +29 -0
  64. package/ccx/codex/records.py +51 -0
  65. package/ccx/codex/reports.py +177 -0
  66. package/ccx/codex/runtime.py +1412 -0
  67. package/ccx/codex/settle.py +363 -0
  68. package/ccx/codex/telemetry.py +792 -0
  69. package/ccx/codex/types.py +659 -0
  70. package/ccx/codex/worker_environment.py +25 -0
  71. package/ccx/constants.py +288 -0
  72. package/ccx/controllers/__init__.py +0 -0
  73. package/ccx/controllers/controller.py +1449 -0
  74. package/ccx/controllers/live_vars.py +1706 -0
  75. package/ccx/controllers/live_vars_runner.py +216 -0
  76. package/ccx/controllers/notify.py +213 -0
  77. package/ccx/controllers/orchestrator.py +193 -0
  78. package/ccx/controllers/policy.py +57 -0
  79. package/ccx/controllers/store.py +681 -0
  80. package/ccx/errors.py +634 -0
  81. package/ccx/notify/__init__.py +0 -0
  82. package/ccx/notify/__main__.py +5 -0
  83. package/ccx/notify/client.py +116 -0
  84. package/ccx/notify/daemon.py +480 -0
  85. package/ccx/notify/deliver.py +304 -0
  86. package/ccx/notify/paths.py +51 -0
  87. package/ccx/notify/registry.py +649 -0
  88. package/ccx/notify/select.py +750 -0
  89. package/ccx/notify/settle.py +684 -0
  90. package/ccx/ops/__init__.py +0 -0
  91. package/ccx/ops/background.py +115 -0
  92. package/ccx/ops/cancel.py +289 -0
  93. package/ccx/ops/codex_import.py +96 -0
  94. package/ccx/ops/common.py +397 -0
  95. package/ccx/ops/compact.py +337 -0
  96. package/ccx/ops/compact_ops.py +550 -0
  97. package/ccx/ops/context_events.py +48 -0
  98. package/ccx/ops/conversation.py +1540 -0
  99. package/ccx/ops/conversation_paging.py +1338 -0
  100. package/ccx/ops/create.py +56 -0
  101. package/ccx/ops/creation.py +952 -0
  102. package/ccx/ops/creation_steps.py +579 -0
  103. package/ccx/ops/event_feed.py +710 -0
  104. package/ccx/ops/forking.py +711 -0
  105. package/ccx/ops/input.py +556 -0
  106. package/ccx/ops/maintenance.py +295 -0
  107. package/ccx/ops/report.py +770 -0
  108. package/ccx/ops/semantics.py +490 -0
  109. package/ccx/ops/send.py +961 -0
  110. package/ccx/ops/steer_ops.py +867 -0
  111. package/ccx/ops/terminate.py +383 -0
  112. package/ccx/ops/turn_reports.py +306 -0
  113. package/ccx/ops/wait.py +762 -0
  114. package/ccx/ops/worker_events.py +636 -0
  115. package/ccx/rpc/__init__.py +0 -0
  116. package/ccx/rpc/local_rpc.py +1602 -0
  117. package/ccx/rpc/rpc.py +559 -0
  118. package/ccx/rpc/serve.py +374 -0
  119. package/ccx/rpc/transport.py +409 -0
  120. package/ccx/rpc/view.py +125 -0
  121. package/ccx/store/__init__.py +0 -0
  122. package/ccx/store/discovery.py +170 -0
  123. package/ccx/store/identity.py +105 -0
  124. package/ccx/store/journal_io.py +79 -0
  125. package/ccx/store/liveness.py +194 -0
  126. package/ccx/store/paths.py +254 -0
  127. package/ccx/store/record_cache.py +40 -0
  128. package/ccx/store/records.py +309 -0
  129. package/ccx/store/root.py +804 -0
  130. package/ccx/store/runtime_presence.py +137 -0
  131. package/ccx/store/store.py +2332 -0
  132. package/ccx/store/worker_state.py +86 -0
  133. package/ccx/system/__init__.py +0 -0
  134. package/ccx/system/bounded.py +296 -0
  135. package/ccx/system/cwd_paths.py +25 -0
  136. package/ccx/system/engine_binary.py +59 -0
  137. package/ccx/system/executable.py +50 -0
  138. package/ccx/system/oslayer.py +667 -0
  139. package/ccx/system/pipes.py +46 -0
  140. package/ccx/system/sanitize.py +43 -0
  141. package/ccx/types.py +705 -0
  142. package/ccx/version.py +29 -0
  143. package/ccx/workers.py +2081 -0
  144. package/ccx.py +9 -0
  145. package/package.json +39 -7
  146. package/pyproject.toml +87 -0
  147. package/skills/ccx/SKILL.md +155 -0
  148. package/skills/ccx/references/controllers.md +244 -0
  149. package/skills/ccx/references/troubleshooting.md +94 -0
  150. package/skills/ccx/references/variables.md +62 -0
@@ -0,0 +1,757 @@
1
+ """CLI presentation and detached process lifecycle for Python controllers."""
2
+
3
+ from __future__ import annotations
4
+ import ccx.notify.daemon as notify_daemon
5
+
6
+ import json
7
+ import os
8
+ from pathlib import Path
9
+ import signal
10
+ import subprocess
11
+ import sys
12
+ import time
13
+ import uuid
14
+ from typing import Any
15
+
16
+ from ccx.controllers import store as store
17
+ from ccx.constants import EXIT_ERROR, EXIT_TURN_INCOMPLETE, EXIT_UNAVAILABLE
18
+ from ccx.controllers.controller import _host_execute
19
+ from ccx.errors import CcxError, ControllerError
20
+ from ccx.system.executable import current_executable_path
21
+ from ccx.controllers.orchestrator import codex_blocking_override_enabled, current_caller, require_notify_parent
22
+ from ccx.notify import registry as parent_notify
23
+ from ccx.workers import WorkerClient
24
+
25
+
26
+ STARTUP_TIMEOUT_S = 30.0
27
+
28
+
29
+ def _prepare_completion(args, *, notify: bool = False) -> dict[str, Any] | None:
30
+ if notify or getattr(args, "notify_parent", False):
31
+ try:
32
+ return notify_daemon.prepare_notify_parent(require_notify_parent())
33
+ except CcxError as error:
34
+ # The shared hint names worker waits; controllers have their own.
35
+ _fail(str(error).replace("use --wait / ccx wait TARGET instead",
36
+ "use ccx controller wait TARGET instead"), error.exit_code)
37
+ if getattr(args, "detach", False):
38
+ return None
39
+ try:
40
+ caller = current_caller()
41
+ except CcxError as error:
42
+ _fail("caller worker identity is not proven in this CCX_ROOT; use the "
43
+ f"worker's original root: {error}", error.exit_code)
44
+ if caller.supports_parent_notify and not codex_blocking_override_enabled():
45
+ _fail(
46
+ "blocking controller waits are disabled for Codex orchestrators; "
47
+ "use --detach for run/resume and inspect with "
48
+ "`ccx controller status TARGET`, `ccx controller report TARGET`, "
49
+ "or `ccx controller logs TARGET` "
50
+ "(without --follow)"
51
+ )
52
+ return None
53
+
54
+
55
+ def _arm_notification(
56
+ parent,
57
+ record,
58
+ *,
59
+ attempt: int,
60
+ source: str,
61
+ attempt_token: str | None = None,
62
+ ):
63
+ if attempt_token is None:
64
+ existing = next(
65
+ (
66
+ item
67
+ for item in (record.get("attempts") or [])
68
+ if isinstance(item, dict) and item.get("number") == attempt
69
+ ),
70
+ None,
71
+ )
72
+ token = existing.get("attemptToken") if isinstance(existing, dict) else None
73
+ attempt_token = token if isinstance(token, str) and token else None
74
+ if not attempt_token:
75
+ raise ControllerError("selected controller attempt lacks its original token")
76
+ operation = {
77
+ "kind": "controller-attempt",
78
+ "attempt": attempt,
79
+ "attemptToken": attempt_token,
80
+ "startupDeadlineMs": store.now_ms() + int((STARTUP_TIMEOUT_S + 15) * 1000),
81
+ }
82
+ return parent_notify.create_registration(
83
+ parent=parent,
84
+ worker_engine="controller",
85
+ worker_id=record["runId"],
86
+ worker_name=record.get("controller") or record.get("requestedControllerName"),
87
+ operation=operation,
88
+ source=source,
89
+ )
90
+
91
+
92
+ def _emit_notification(args, verdict, registration) -> None:
93
+ if verdict.get("controller"):
94
+ registration = parent_notify.mutate_registration(
95
+ registration["eventId"],
96
+ lambda value: value["worker"].update(name=verdict["controller"]),
97
+ ) or registration
98
+ public = parent_notify.public_registration(registration)
99
+ supervisor = ("" if public.get("notifier_available", True)
100
+ else " (supervisor currently unavailable)")
101
+ _emit(
102
+ {**verdict, "notify": public}, as_json=args.json,
103
+ text=(f"{verdict.get('controller') or registration['worker'].get('name')} "
104
+ f"{verdict['runId']}\nnotify: {public['state']} {public['event_id']}"
105
+ f"{supervisor}\n{public['instruction']}"),
106
+ )
107
+
108
+
109
+ def _script_args(values: list[str] | None) -> list[str]:
110
+ result = list(values or [])
111
+ if result[:1] == ["--"]:
112
+ result.pop(0)
113
+ return result
114
+
115
+
116
+ def _emit(value: dict[str, Any], *, as_json: bool, text: str | None = None) -> None:
117
+ if as_json:
118
+ print(json.dumps(value, indent=2, sort_keys=True))
119
+ elif text is not None:
120
+ print(text)
121
+
122
+
123
+ def _fail(message: str, code: int = EXIT_ERROR) -> None:
124
+ sys.stderr.write(f"[ccx controller] {message}\n")
125
+ raise SystemExit(code)
126
+
127
+
128
+ def _parse_verdict(stdout: bytes | None) -> dict[str, Any] | None:
129
+ if not stdout:
130
+ return None
131
+ for raw in reversed(stdout.decode("utf-8", "replace").splitlines()):
132
+ try:
133
+ value = json.loads(raw)
134
+ except json.JSONDecodeError:
135
+ continue
136
+ if isinstance(value, dict):
137
+ return value
138
+ return None
139
+
140
+
141
+ def _spawn_controller(
142
+ *, run_id: str, run_dir: Path, script: Path, resume: bool,
143
+ script_args: list[str], startup_timeout: float = STARTUP_TIMEOUT_S,
144
+ ) -> dict[str, Any]:
145
+ verdict_path = store.launch_verdict_path(run_id)
146
+ store.clear_stop_marker(run_id)
147
+ try:
148
+ verdict_path.unlink()
149
+ except FileNotFoundError:
150
+ pass
151
+ argv = [
152
+ sys.executable, current_executable_path(), "controller", "_launch",
153
+ "--run-id", run_id, "--run-dir", str(run_dir), "--script", str(script),
154
+ "--startup-timeout", f"{startup_timeout:g}",
155
+ "--argv-json", json.dumps(script_args),
156
+ ]
157
+ if resume:
158
+ argv.append("--resume")
159
+ try:
160
+ proc = subprocess.run(
161
+ argv, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
162
+ stderr=subprocess.PIPE, start_new_session=True,
163
+ timeout=startup_timeout + 10,
164
+ )
165
+ except (OSError, subprocess.SubprocessError) as error:
166
+ return {"ok": False, "runId": run_id, "error": f"{type(error).__name__}: {error}"}
167
+ verdict = _parse_verdict(proc.stdout)
168
+ try:
169
+ if verdict is None:
170
+ detail = (proc.stderr or b"").decode("utf-8", "replace").strip()
171
+ return {
172
+ "ok": False, "runId": run_id,
173
+ "error": detail or f"controller launcher exited {proc.returncode} without a verdict",
174
+ }
175
+ return verdict
176
+ finally:
177
+ try:
178
+ verdict_path.unlink()
179
+ except FileNotFoundError:
180
+ pass
181
+
182
+
183
+ def cmd_internal_launch(args) -> None:
184
+ run_dir = Path(args.run_dir)
185
+ try:
186
+ store.require_operation_journal_layout(run_dir)
187
+ except ControllerError as error:
188
+ _fail(str(error), getattr(error, "exit_code", EXIT_ERROR))
189
+ log_path = run_dir / "controller.log"
190
+ pid = os.fork()
191
+ if pid == 0:
192
+ os.setsid()
193
+ child = os.fork()
194
+ if child > 0:
195
+ os._exit(0)
196
+ log_path.parent.mkdir(parents=True, exist_ok=True)
197
+ log = log_path.open("ab", buffering=0)
198
+ devnull = os.open(os.devnull, os.O_RDONLY)
199
+ os.dup2(devnull, 0)
200
+ os.close(devnull)
201
+ os.dup2(log.fileno(), 1)
202
+ os.dup2(log.fileno(), 2)
203
+ try:
204
+ log.close()
205
+ except Exception:
206
+ pass
207
+ argv = [
208
+ sys.executable, current_executable_path(), "controller", "_host",
209
+ "--run-id", args.run_id, "--run-dir", args.run_dir,
210
+ "--script", args.script, "--argv-json", args.argv_json,
211
+ ]
212
+ if args.resume:
213
+ argv.append("--resume")
214
+ try:
215
+ os.execv(sys.executable, argv)
216
+ finally:
217
+ os._exit(127)
218
+
219
+ os.waitpid(pid, 0)
220
+ deadline = time.monotonic() + args.startup_timeout
221
+ verdict = None
222
+ while time.monotonic() < deadline:
223
+ verdict = store.read_launch_verdict(args.run_id)
224
+ if verdict is not None:
225
+ break
226
+ time.sleep(0.05)
227
+ if verdict is None:
228
+ try:
229
+ record = store.read_json(store.run_record_path(run_dir))
230
+ lifecycle = record.get("lifecycle") or {}
231
+ host_pid = lifecycle.get("pid")
232
+ # A PID can exit or be reused between observation and signalling.
233
+ # The single check immediately before SIGTERM proves the recorded
234
+ # original identity; a second adjacent read would not close the
235
+ # unavoidable kernel scheduling window.
236
+ if store.process_presence(host_pid, lifecycle.get("pidStart")) == "alive":
237
+ os.kill(int(host_pid), signal.SIGTERM)
238
+ except (ControllerError, OSError, TypeError, ValueError):
239
+ pass
240
+ verdict = {
241
+ "ok": False, "runId": args.run_id,
242
+ "error": f"controller did not finish startup within {args.startup_timeout:g}s",
243
+ }
244
+ print(json.dumps(verdict, separators=(",", ":")))
245
+ raise SystemExit(0 if verdict.get("ok") else 1)
246
+
247
+
248
+ def cmd_internal_host(args) -> None:
249
+ run_dir = Path(args.run_dir)
250
+ script = Path(args.script).resolve()
251
+ try:
252
+ script_args = json.loads(args.argv_json)
253
+ if not isinstance(script_args, list) or not all(isinstance(item, str) for item in script_args):
254
+ raise ValueError("argv is not a string list")
255
+ except (json.JSONDecodeError, ValueError) as error:
256
+ store.write_launch_verdict(
257
+ args.run_id, {"ok": False, "runId": args.run_id, "error": str(error)}
258
+ )
259
+ raise SystemExit(1)
260
+
261
+ marker_path = os.fspath(store.stop_marker_path(args.run_id))
262
+
263
+ def stopped(_signum, _frame):
264
+ try:
265
+ fd = os.open(marker_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
266
+ try:
267
+ os.write(fd, b"SIGTERM\n")
268
+ finally:
269
+ os.close(fd)
270
+ finally:
271
+ os._exit(143)
272
+
273
+ signal.signal(signal.SIGTERM, stopped)
274
+ raise SystemExit(
275
+ _host_execute(
276
+ run_id=args.run_id, run_dir=run_dir, script=script,
277
+ resume=bool(args.resume), script_args=script_args,
278
+ )
279
+ )
280
+
281
+
282
+ def _identity_line(value: dict[str, Any]) -> str:
283
+ declared = value.get("declaredControllerName")
284
+ requested = value.get("requestedControllerName")
285
+ return (
286
+ f"names: declared={declared or 'unrecorded'} "
287
+ f"requested={requested or '-'} effective={value.get('controller')}"
288
+ )
289
+
290
+
291
+ def _attempt_line(value: dict[str, Any]) -> str:
292
+ attempts = value.get("attempts") or 0
293
+ start = value.get("latestAttemptStart") or {}
294
+ if not start.get("recorded"):
295
+ return f"attempts: {attempts} (latest start: unrecorded)"
296
+ detail = f"latest start: {start['form']}"
297
+ if start.get("inputId"):
298
+ detail += f" input={str(start['inputId'])[:8]}"
299
+ return f"attempts: {attempts} ({detail})"
300
+
301
+
302
+ def _print_summary(record: dict[str, Any], run_dir: Path) -> None:
303
+ value = store.run_summary(record, run_dir)
304
+ print(f"controller: {value.get('controller')}")
305
+ print(_identity_line(value))
306
+ print(f"run: {value.get('runId')}")
307
+ print(f"lifecycle: {value.get('lifecycle')}")
308
+ print(f"script-reported status: {value.get('status') or '-'}")
309
+ print(f"checkpoint: {value.get('checkpoint') or '-'}")
310
+ print(f"tasks: {value.get('tasksSucceeded')}/{value.get('tasks')}")
311
+ print(f"workers: {value.get('workers')}")
312
+ print(_attempt_line(value))
313
+ if value.get("summary"):
314
+ print(f"summary: {value['summary']}")
315
+
316
+
317
+ def _latest_report(record: dict[str, Any]) -> tuple[dict[str, Any] | None, str | None]:
318
+ metadata = record.get("latestReport")
319
+ if not isinstance(metadata, dict):
320
+ return None, None
321
+ path = metadata.get("path")
322
+ if not path:
323
+ return metadata, None
324
+ try:
325
+ return metadata, Path(str(path)).read_text(encoding="utf-8")
326
+ except OSError:
327
+ return metadata, None
328
+
329
+
330
+ def _wait_and_follow(
331
+ run_dir: Path, *, start_offset: int, timeout: float | None,
332
+ as_json: bool, include_final: bool = True,
333
+ ) -> int:
334
+ started = time.monotonic()
335
+ log_path = run_dir / "controller.log"
336
+ offset = start_offset
337
+ while True:
338
+ if not as_json and log_path.exists():
339
+ try:
340
+ with log_path.open("rb") as handle:
341
+ handle.seek(offset)
342
+ data = handle.read()
343
+ offset = handle.tell()
344
+ if data:
345
+ sys.stdout.write(data.decode("utf-8", "replace"))
346
+ sys.stdout.flush()
347
+ except OSError:
348
+ pass
349
+ record = store.refresh_liveness(run_dir)
350
+ state = (record.get("lifecycle") or {}).get("state")
351
+ if state in store.TERMINAL_LIFECYCLES:
352
+ break
353
+ if timeout is not None and time.monotonic() - started >= timeout:
354
+ if as_json:
355
+ print(json.dumps({"timedOut": True, **store.run_summary(record, run_dir)}, indent=2))
356
+ else:
357
+ print(f"[ccx controller] wait timed out; controller remains {state}")
358
+ # Same code as every other timed-out CCX wait; 4 means blocked on input.
359
+ return EXIT_TURN_INCOMPLETE
360
+ time.sleep(0.1)
361
+ if include_final:
362
+ metadata, report_text = _latest_report(record)
363
+ if as_json:
364
+ print(json.dumps({
365
+ **store.run_summary(record, run_dir),
366
+ "report": {**(metadata or {}), "text": report_text} if metadata else None,
367
+ "error": record.get("error"),
368
+ }, indent=2, sort_keys=True))
369
+ else:
370
+ if offset and not str(log_path.read_text(encoding="utf-8", errors="replace") if log_path.exists() else "").endswith("\n"):
371
+ print()
372
+ _print_summary(record, run_dir)
373
+ if report_text is not None:
374
+ print("\nreport:\n")
375
+ print(report_text)
376
+ return 0 if state == "succeeded" else EXIT_ERROR
377
+
378
+
379
+ def cmd_run(args) -> None:
380
+ parent = _prepare_completion(args)
381
+ script = Path(args.script).expanduser().resolve()
382
+ if not script.is_file():
383
+ _fail(f"controller script is not a file: {script}")
384
+ launch_cwd = Path.cwd().resolve()
385
+ script_args = _script_args(args.script_args)
386
+ try:
387
+ run_id, bootstrap = store.create_bootstrap(
388
+ script, launch_cwd, script_args, controller_name=args.name
389
+ )
390
+ registration = _arm_notification(
391
+ parent, store.read_json(store.run_record_path(bootstrap)),
392
+ attempt=1, source="controller.run",
393
+ ) if parent else None
394
+ verdict = _spawn_controller(
395
+ run_id=run_id, run_dir=bootstrap, script=script, resume=False,
396
+ script_args=script_args,
397
+ )
398
+ except CcxError as error:
399
+ _fail(str(error), getattr(error, "exit_code", EXIT_ERROR))
400
+ if not verdict.get("ok"):
401
+ if registration:
402
+ _emit_notification(args, {**verdict, "runId": run_id}, registration)
403
+ _fail(str(verdict.get("error") or "controller startup failed"))
404
+ run_dir = Path(str(verdict["runDir"]))
405
+ if registration:
406
+ _emit_notification(args, verdict, registration)
407
+ return
408
+ if args.detach:
409
+ _emit(verdict, as_json=args.json, text=f"{verdict['controller']} {run_id[:8]}")
410
+ return
411
+ try:
412
+ code = _wait_and_follow(run_dir, start_offset=0, timeout=None, as_json=args.json)
413
+ except KeyboardInterrupt:
414
+ sys.stderr.write("\n[ccx controller] detached; controller is still running\n")
415
+ raise SystemExit(130)
416
+ if code:
417
+ raise SystemExit(code)
418
+
419
+
420
+ def cmd_list(args) -> None:
421
+ rows = store.controller_inventory()
422
+ if args.json:
423
+ _emit({"controllers": rows}, as_json=True)
424
+ return
425
+ if not rows:
426
+ print("0 controller(s)")
427
+ return
428
+ print(f"{'NAME':28} {'RUNS':>4} {'LIFECYCLE':12} {'SCRIPT-REPORTED STATUS':22} LATEST")
429
+ for row in rows:
430
+ latest = row.get("latest") or {}
431
+ print(
432
+ f"{str(row['name'])[:28]:28} {row['runs']:>4} "
433
+ f"{str(latest.get('lifecycle') or '-')[:12]:12} "
434
+ f"{str(latest.get('status') or '-')[:22]:22} "
435
+ f"{str(latest.get('runId') or '-')[:8]}"
436
+ )
437
+
438
+
439
+ def cmd_runs(args) -> None:
440
+ try:
441
+ rows = store.run_inventory(args.name)
442
+ except ControllerError as error:
443
+ _fail(str(error), getattr(error, "exit_code", EXIT_ERROR))
444
+ if args.json:
445
+ _emit({"controller": args.name, "runs": rows}, as_json=True)
446
+ return
447
+ if not rows:
448
+ print(f"0 run(s) for {args.name}")
449
+ return
450
+ print(f"{'RUN':8} {'LIFECYCLE':12} {'SCRIPT-REPORTED STATUS':22} {'TASKS':>7} {'WORKERS':>7} UPDATED")
451
+ for row in rows:
452
+ print(
453
+ f"{str(row['runId'])[:8]:8} {str(row.get('lifecycle') or '-')[:12]:12} "
454
+ f"{str(row.get('status') or '-')[:22]:22} "
455
+ f"{row.get('tasksSucceeded', 0)}/{row.get('tasks', 0):<5} "
456
+ f"{row.get('workers', 0):>7} {row.get('updatedAt')}"
457
+ )
458
+
459
+
460
+ def _status_payload(run_dir: Path) -> dict[str, Any]:
461
+ record = store.refresh_liveness(run_dir)
462
+ client = WorkerClient()
463
+ live_workers: list[dict[str, Any]] = []
464
+ for worker in record.get("workers", []):
465
+ if not isinstance(worker, dict) or not worker.get("worker"):
466
+ continue
467
+ try:
468
+ live = client.status(worker["worker"])
469
+ except Exception as error:
470
+ live = {"worker": worker["worker"], "state": "unavailable", "error": type(error).__name__}
471
+ live_workers.append({**worker, "live": live})
472
+ return {
473
+ **store.run_summary(record, run_dir),
474
+ "scriptPath": record.get("scriptPath"),
475
+ "sourceSha256": record.get("sourceSha256"),
476
+ "attemptsDetail": [
477
+ item for item in (record.get("attempts") or []) if isinstance(item, dict)
478
+ ],
479
+ "workspace": record.get("workspace"),
480
+ "tasksDetail": record.get("tasks") or {},
481
+ "workersDetail": live_workers,
482
+ "worktrees": record.get("worktrees") or {},
483
+ "latestReport": record.get("latestReport"),
484
+ "error": record.get("error"),
485
+ }
486
+
487
+
488
+ def cmd_status(args) -> None:
489
+ try:
490
+ run_dir = store.resolve_run(args.target)
491
+ payload = _status_payload(run_dir)
492
+ except ControllerError as error:
493
+ _fail(str(error), getattr(error, "exit_code", EXIT_ERROR))
494
+ if args.json:
495
+ _emit(payload, as_json=True)
496
+ return
497
+ _print_summary(store.read_json(store.run_record_path(run_dir)), run_dir)
498
+ if payload["workersDetail"]:
499
+ print("\nworkers:")
500
+ for worker in payload["workersDetail"]:
501
+ live = worker["live"]
502
+ print(
503
+ f" {worker.get('name')}: {worker.get('worker')} "
504
+ f"state={live.get('state')} cwd={worker.get('cwd')}"
505
+ )
506
+ if payload["worktrees"]:
507
+ print("\nworktrees:")
508
+ for task_id, worktree in payload["worktrees"].items():
509
+ print(
510
+ f" {task_id}: {worktree.get('path')} "
511
+ f"branch={worktree.get('branch')} commit={worktree.get('commit') or '-'}"
512
+ )
513
+
514
+
515
+ def cmd_report(args) -> None:
516
+ try:
517
+ run_dir = store.resolve_run(args.target)
518
+ record = store.refresh_liveness(run_dir)
519
+ except ControllerError as error:
520
+ _fail(str(error), getattr(error, "exit_code", EXIT_ERROR))
521
+ metadata, text = _latest_report(record)
522
+ if args.json:
523
+ _emit({
524
+ "runId": record.get("runId"), "controller": record.get("controller"),
525
+ "report": {**(metadata or {}), "text": text} if metadata else None,
526
+ }, as_json=True)
527
+ elif metadata is None:
528
+ _fail("controller has not published a report", EXIT_UNAVAILABLE)
529
+ elif text is not None:
530
+ print(text)
531
+ else:
532
+ print(metadata.get("summary") or "controller report has no Markdown body")
533
+
534
+
535
+ def cmd_wait(args) -> None:
536
+ _prepare_completion(args)
537
+ try:
538
+ run_dir = store.resolve_run(args.target)
539
+ log = run_dir / "controller.log"
540
+ offset = log.stat().st_size if log.exists() else 0
541
+ code = _wait_and_follow(
542
+ run_dir, start_offset=offset, timeout=args.timeout, as_json=args.json
543
+ )
544
+ except ControllerError as error:
545
+ _fail(str(error), getattr(error, "exit_code", EXIT_ERROR))
546
+ except KeyboardInterrupt:
547
+ sys.stderr.write("\n[ccx controller] detached; controller is still running\n")
548
+ raise SystemExit(130)
549
+ if code:
550
+ raise SystemExit(code)
551
+
552
+
553
+ def cmd_logs(args) -> None:
554
+ if args.follow:
555
+ _prepare_completion(args)
556
+ try:
557
+ run_dir = store.resolve_run(args.target)
558
+ except ControllerError as error:
559
+ _fail(str(error), getattr(error, "exit_code", EXIT_ERROR))
560
+ log = run_dir / "controller.log"
561
+ text = log.read_text(encoding="utf-8", errors="replace") if log.exists() else ""
562
+ if args.json:
563
+ if args.follow:
564
+ _fail("--json and --follow cannot be combined")
565
+ _emit({"runDir": str(run_dir), "text": text}, as_json=True)
566
+ return
567
+ sys.stdout.write(text)
568
+ sys.stdout.flush()
569
+ if args.follow:
570
+ code = _wait_and_follow(
571
+ run_dir, start_offset=log.stat().st_size if log.exists() else 0, timeout=None,
572
+ as_json=False, include_final=False,
573
+ )
574
+ if code:
575
+ raise SystemExit(code)
576
+
577
+
578
+ def cmd_input(args) -> None:
579
+ try:
580
+ run_dir = store.resolve_run(args.target)
581
+ if args.note is not None:
582
+ if args.worker is not None or args.comment is not None:
583
+ raise ControllerError("--note cannot be combined with --worker or --comment")
584
+ worker_target = None
585
+ comment = args.note
586
+ else:
587
+ if not args.worker or args.comment is None:
588
+ raise ControllerError("supply --note or both --worker and --comment")
589
+ worker_target = args.worker
590
+ comment = args.comment
591
+ entry = store.append_controller_input(
592
+ run_dir, worker_target=worker_target, comment=comment
593
+ )
594
+ except ControllerError as error:
595
+ _fail(str(error), getattr(error, "exit_code", EXIT_ERROR))
596
+ _emit(entry, as_json=args.json, text=entry["id"])
597
+
598
+
599
+ def cmd_notify(args) -> None:
600
+ parent = _prepare_completion(args, notify=True)
601
+ try:
602
+ run_dir = store.resolve_run(args.target)
603
+ # The resume lock prevents adopting a partially prepared attempt.
604
+ with store.file_lock(run_dir / "resume.lock"):
605
+ record = store.read_json(store.run_record_path(run_dir))
606
+ registration = _arm_notification(
607
+ parent, record, attempt=record["attempts"][-1]["number"],
608
+ source="controller.notify",
609
+ )
610
+ except CcxError as error:
611
+ _fail(str(error), getattr(error, "exit_code", EXIT_ERROR))
612
+ _emit_notification(args, {"runId": record["runId"], "controller": record["controller"]}, registration)
613
+
614
+
615
+ def cmd_resume(args) -> None:
616
+ parent = _prepare_completion(args)
617
+ registration = None
618
+ dispatch_started = False
619
+ try:
620
+ run_dir = store.resolve_run(args.target)
621
+ if args.note is not None and (args.worker is not None or args.comment is not None):
622
+ raise ControllerError("--note cannot be combined with --worker or --comment")
623
+ if bool(args.worker) != bool(args.comment):
624
+ raise ControllerError("--worker and --comment must be supplied together")
625
+ with store.file_lock(run_dir / "resume.lock"):
626
+ # Refuse old/missing/unknown positional journals before recording
627
+ # inputs, attempts, notifications, or starting any controller code.
628
+ store.require_operation_journal_layout(run_dir)
629
+ record = store.refresh_liveness(run_dir)
630
+ state = (record.get("lifecycle") or {}).get("state")
631
+ if state in {"starting", "running"}:
632
+ raise ControllerError(f"controller run is already {state}")
633
+ script = Path(str(record["scriptPath"]))
634
+ if not script.is_file():
635
+ raise ControllerError(f"controller script no longer exists: {script}")
636
+ actual_hash = store.source_sha256(script)
637
+ if actual_hash != record.get("sourceSha256"):
638
+ raise ControllerError(
639
+ "controller source changed since this run; start a new run instead of replaying"
640
+ )
641
+ script_args = _script_args(args.script_args)
642
+ attempt = len(record.get("attempts") or []) + 1
643
+ attempt_token = str(uuid.uuid4())
644
+ if parent:
645
+ registration = _arm_notification(
646
+ parent,
647
+ record,
648
+ attempt=attempt,
649
+ source="controller.resume",
650
+ attempt_token=attempt_token,
651
+ )
652
+ start_input_id: str | None = None
653
+ if args.worker:
654
+ start_form = store.ATTEMPT_START_RESUME_WORKER_COMMENT
655
+ start_input_id = store.append_controller_input(
656
+ run_dir, worker_target=args.worker, comment=args.comment
657
+ )["id"]
658
+ elif args.note is not None:
659
+ start_form = store.ATTEMPT_START_RESUME_NOTE
660
+ start_input_id = store.append_controller_input(
661
+ run_dir, worker_target=None, comment=args.note
662
+ )["id"]
663
+ else:
664
+ start_form = store.ATTEMPT_START_RESUME
665
+ started = store.now_ms()
666
+ store.clear_stop_marker(str(record["runId"]))
667
+
668
+ def update(value: dict[str, Any]) -> None:
669
+ value.setdefault("attempts", []).append({
670
+ "number": attempt,
671
+ "attemptToken": attempt_token,
672
+ "startedAt": started,
673
+ "argv": script_args,
674
+ "reportStartIndex": len(value.get("reports") or []),
675
+ "startForm": start_form, "startInputId": start_input_id,
676
+ })
677
+ value.setdefault("lifecycle", {}).update(state="starting", since=started, pid=None)
678
+ value["error"] = None
679
+
680
+ store.mutate_run(run_dir, update)
681
+ store.append_event(
682
+ run_dir, "run-resuming", attempt=attempt,
683
+ startForm=start_form, startInputId=start_input_id,
684
+ )
685
+ log = run_dir / "controller.log"
686
+ attach_offset = log.stat().st_size if log.exists() else 0
687
+ dispatch_started = True
688
+ verdict = _spawn_controller(
689
+ run_id=str(record["runId"]), run_dir=run_dir, script=script,
690
+ resume=True, script_args=script_args,
691
+ )
692
+ except (CcxError, OSError) as error:
693
+ if registration and not dispatch_started:
694
+ parent_notify.cancel_registration(registration["eventId"], str(error))
695
+ _fail(f"{error}; notification {registration['eventId']} cancelled before dispatch")
696
+ _fail(str(error), getattr(error, "exit_code", EXIT_ERROR))
697
+ if not verdict.get("ok"):
698
+ if registration:
699
+ _emit_notification(args, {**verdict, "runId": record["runId"]}, registration)
700
+ _fail(str(verdict.get("error") or "controller resume failed"))
701
+ if registration:
702
+ _emit_notification(args, verdict, registration)
703
+ return
704
+ if args.detach:
705
+ _emit(verdict, as_json=args.json, text=f"{record['controller']} {str(record['runId'])[:8]}")
706
+ return
707
+ code = _wait_and_follow(
708
+ run_dir, start_offset=attach_offset, timeout=None, as_json=args.json
709
+ )
710
+ if code:
711
+ raise SystemExit(code)
712
+
713
+
714
+ def cmd_stop(args) -> None:
715
+ try:
716
+ run_dir = store.resolve_run(args.target)
717
+ record = store.refresh_liveness(run_dir)
718
+ lifecycle = record.get("lifecycle") or {}
719
+ pid, start = lifecycle.get("pid"), lifecycle.get("pidStart")
720
+ if lifecycle.get("state") not in {"starting", "running"}:
721
+ raise ControllerError(f"controller is not running (state={lifecycle.get('state')})")
722
+ if store.process_presence(pid, start) != "alive":
723
+ raise ControllerError("controller original PID/start not proven alive; no signal")
724
+ os.kill(int(pid), signal.SIGTERM)
725
+ deadline = time.monotonic() + 5
726
+ while time.monotonic() < deadline and store.process_presence(pid, start) == "alive":
727
+ time.sleep(0.05)
728
+ presence = store.process_presence(pid, start)
729
+ if presence == "unprovable":
730
+ raise ControllerError("controller exit not proven after SIGTERM")
731
+ if presence == "alive":
732
+ if not args.force:
733
+ raise ControllerError("controller did not stop within 5s; retry with --force")
734
+ if store.process_presence(pid, start) != "alive":
735
+ raise ControllerError("controller original PID/start changed before SIGKILL")
736
+ os.kill(int(pid), signal.SIGKILL)
737
+ deadline = time.monotonic() + 2
738
+ while time.monotonic() < deadline and store.process_presence(pid, start) == "alive":
739
+ time.sleep(0.05)
740
+ if store.process_presence(pid, start) != "dead":
741
+ raise ControllerError("controller host remained alive after SIGKILL")
742
+ def stopped(value: dict[str, Any]) -> None:
743
+ stopped_at = store.now_ms()
744
+ value.setdefault("lifecycle", {}).update(
745
+ state="stopped", since=stopped_at, pid=None, pidStart=None,
746
+ )
747
+ attempts = value.setdefault("attempts", [])
748
+ if attempts:
749
+ attempts[-1].update(completedAt=stopped_at, lifecycle="stopped")
750
+ record = store.mutate_run(run_dir, stopped)
751
+ store.append_event(run_dir, "run-stopped", forced=bool(args.force))
752
+ except (ControllerError, OSError) as error:
753
+ _fail(str(error), getattr(error, "exit_code", EXIT_ERROR))
754
+ _emit(store.run_summary(record, run_dir), as_json=args.json, text=str(record["runId"])[:8])
755
+
756
+
757
+ __all__ = [name for name in globals() if name.startswith("cmd_")]