ccx-native 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +241 -2
- package/bin/ccx +56 -0
- package/ccx/__init__.py +163 -0
- package/ccx/__main__.py +28 -0
- package/ccx/claude/__init__.py +0 -0
- package/ccx/claude/analytics.py +466 -0
- package/ccx/claude/attach.py +762 -0
- package/ccx/claude/context.py +465 -0
- package/ccx/claude/delivery.py +806 -0
- package/ccx/claude/external.py +253 -0
- package/ccx/claude/host/__init__.py +0 -0
- package/ccx/claude/host/events.py +297 -0
- package/ccx/claude/host/hooks.py +438 -0
- package/ccx/claude/host/pty.py +250 -0
- package/ccx/claude/host/session.py +5184 -0
- package/ccx/claude/host/subagents.py +169 -0
- package/ccx/claude/host/successor.py +267 -0
- package/ccx/claude/managed_turns.py +240 -0
- package/ccx/claude/oauth.py +176 -0
- package/ccx/claude/settings.py +339 -0
- package/ccx/claude/spawn.py +557 -0
- package/ccx/claude/tasks.py +72 -0
- package/ccx/claude/telemetry.py +282 -0
- package/ccx/claude/transcript.py +2281 -0
- package/ccx/claude/usage.py +837 -0
- package/ccx/claude/usage_control.py +152 -0
- package/ccx/cli/__init__.py +0 -0
- package/ccx/cli/codex.py +1009 -0
- package/ccx/cli/commands/__init__.py +0 -0
- package/ccx/cli/commands/_common.py +407 -0
- package/ccx/cli/commands/control.py +161 -0
- package/ccx/cli/commands/create.py +669 -0
- package/ccx/cli/commands/read.py +1020 -0
- package/ccx/cli/commands/turns.py +1081 -0
- package/ccx/cli/controller.py +757 -0
- package/ccx/cli/main.py +301 -0
- package/ccx/cli/parser.py +1324 -0
- package/ccx/cli/skills.py +215 -0
- package/ccx/cli/statusline.py +160 -0
- package/ccx/client.py +1900 -0
- package/ccx/codex/__init__.py +0 -0
- package/ccx/codex/attach_tty.py +267 -0
- package/ccx/codex/client.py +732 -0
- package/ccx/codex/completion.py +121 -0
- package/ccx/codex/control.py +288 -0
- package/ccx/codex/daemon/__init__.py +0 -0
- package/ccx/codex/daemon/__main__.py +4 -0
- package/ccx/codex/daemon/daemon.py +1556 -0
- package/ccx/codex/daemon/models.py +140 -0
- package/ccx/codex/daemon/native.py +1590 -0
- package/ccx/codex/daemon/release.py +367 -0
- package/ccx/codex/daemon/worker.py +2396 -0
- package/ccx/codex/delivery.py +758 -0
- package/ccx/codex/delivery_recovery.py +232 -0
- package/ccx/codex/evidence.py +827 -0
- package/ccx/codex/history.py +158 -0
- package/ccx/codex/notify_history.py +140 -0
- package/ccx/codex/ops.py +666 -0
- package/ccx/codex/paths.py +70 -0
- package/ccx/codex/protocol.py +805 -0
- package/ccx/codex/provenance.py +105 -0
- package/ccx/codex/read.py +29 -0
- package/ccx/codex/records.py +51 -0
- package/ccx/codex/reports.py +177 -0
- package/ccx/codex/runtime.py +1412 -0
- package/ccx/codex/settle.py +363 -0
- package/ccx/codex/telemetry.py +792 -0
- package/ccx/codex/types.py +659 -0
- package/ccx/codex/worker_environment.py +25 -0
- package/ccx/constants.py +288 -0
- package/ccx/controllers/__init__.py +0 -0
- package/ccx/controllers/controller.py +1449 -0
- package/ccx/controllers/live_vars.py +1706 -0
- package/ccx/controllers/live_vars_runner.py +216 -0
- package/ccx/controllers/notify.py +213 -0
- package/ccx/controllers/orchestrator.py +193 -0
- package/ccx/controllers/policy.py +57 -0
- package/ccx/controllers/store.py +681 -0
- package/ccx/errors.py +634 -0
- package/ccx/notify/__init__.py +0 -0
- package/ccx/notify/__main__.py +5 -0
- package/ccx/notify/client.py +116 -0
- package/ccx/notify/daemon.py +480 -0
- package/ccx/notify/deliver.py +304 -0
- package/ccx/notify/paths.py +51 -0
- package/ccx/notify/registry.py +649 -0
- package/ccx/notify/select.py +750 -0
- package/ccx/notify/settle.py +684 -0
- package/ccx/ops/__init__.py +0 -0
- package/ccx/ops/background.py +115 -0
- package/ccx/ops/cancel.py +289 -0
- package/ccx/ops/codex_import.py +96 -0
- package/ccx/ops/common.py +397 -0
- package/ccx/ops/compact.py +337 -0
- package/ccx/ops/compact_ops.py +550 -0
- package/ccx/ops/context_events.py +48 -0
- package/ccx/ops/conversation.py +1540 -0
- package/ccx/ops/conversation_paging.py +1338 -0
- package/ccx/ops/create.py +56 -0
- package/ccx/ops/creation.py +952 -0
- package/ccx/ops/creation_steps.py +579 -0
- package/ccx/ops/event_feed.py +710 -0
- package/ccx/ops/forking.py +711 -0
- package/ccx/ops/input.py +556 -0
- package/ccx/ops/maintenance.py +295 -0
- package/ccx/ops/report.py +770 -0
- package/ccx/ops/semantics.py +490 -0
- package/ccx/ops/send.py +961 -0
- package/ccx/ops/steer_ops.py +867 -0
- package/ccx/ops/terminate.py +383 -0
- package/ccx/ops/turn_reports.py +306 -0
- package/ccx/ops/wait.py +762 -0
- package/ccx/ops/worker_events.py +636 -0
- package/ccx/rpc/__init__.py +0 -0
- package/ccx/rpc/local_rpc.py +1602 -0
- package/ccx/rpc/rpc.py +559 -0
- package/ccx/rpc/serve.py +374 -0
- package/ccx/rpc/transport.py +409 -0
- package/ccx/rpc/view.py +125 -0
- package/ccx/store/__init__.py +0 -0
- package/ccx/store/discovery.py +170 -0
- package/ccx/store/identity.py +105 -0
- package/ccx/store/journal_io.py +79 -0
- package/ccx/store/liveness.py +194 -0
- package/ccx/store/paths.py +254 -0
- package/ccx/store/record_cache.py +40 -0
- package/ccx/store/records.py +309 -0
- package/ccx/store/root.py +804 -0
- package/ccx/store/runtime_presence.py +137 -0
- package/ccx/store/store.py +2332 -0
- package/ccx/store/worker_state.py +86 -0
- package/ccx/system/__init__.py +0 -0
- package/ccx/system/bounded.py +296 -0
- package/ccx/system/cwd_paths.py +25 -0
- package/ccx/system/engine_binary.py +59 -0
- package/ccx/system/executable.py +50 -0
- package/ccx/system/oslayer.py +667 -0
- package/ccx/system/pipes.py +46 -0
- package/ccx/system/sanitize.py +43 -0
- package/ccx/types.py +705 -0
- package/ccx/version.py +29 -0
- package/ccx/workers.py +2081 -0
- package/ccx.py +9 -0
- package/package.json +40 -4
- package/pyproject.toml +87 -0
- package/skills/ccx/SKILL.md +155 -0
- package/skills/ccx/references/controllers.md +244 -0
- package/skills/ccx/references/troubleshooting.md +94 -0
- package/skills/ccx/references/variables.md +62 -0
|
@@ -0,0 +1,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_")]
|