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,1449 @@
|
|
|
1
|
+
"""Durable, task-specific Python controllers over real CCX workers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from concurrent.futures import Future, ThreadPoolExecutor
|
|
7
|
+
from contextvars import ContextVar, copy_context
|
|
8
|
+
from dataclasses import asdict, dataclass, is_dataclass
|
|
9
|
+
from functools import wraps
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
import re
|
|
15
|
+
import runpy
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
import threading
|
|
19
|
+
import traceback
|
|
20
|
+
from typing import Any, Callable, Generic, Literal, ParamSpec, TypeVar, overload
|
|
21
|
+
import uuid
|
|
22
|
+
|
|
23
|
+
from ccx.controllers import store as store
|
|
24
|
+
from ccx.errors import (
|
|
25
|
+
CapabilityUnsupported,
|
|
26
|
+
BudgetStopped,
|
|
27
|
+
CcxError,
|
|
28
|
+
CreatePartialFailure,
|
|
29
|
+
CompactFailed,
|
|
30
|
+
ControllerError,
|
|
31
|
+
ControllerLaunchRequired,
|
|
32
|
+
ControllerOperationIndeterminate,
|
|
33
|
+
ControllerReplayMismatch,
|
|
34
|
+
ControllerLiveStateLost,
|
|
35
|
+
DeliveryRejected,
|
|
36
|
+
InvalidRequest,
|
|
37
|
+
SessionNotFound,
|
|
38
|
+
WorkerApiError,
|
|
39
|
+
WorkerRateLimited,
|
|
40
|
+
build_worker_api_error,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
P = ParamSpec("P")
|
|
45
|
+
R = TypeVar("R")
|
|
46
|
+
|
|
47
|
+
_MUTATING_WORKER_METHODS = frozenset({
|
|
48
|
+
"create", "fork", "send", "steer", "resume", "compact", "answer",
|
|
49
|
+
"respond_elicitation", "approve", "reject", "stop", "kill",
|
|
50
|
+
})
|
|
51
|
+
# Worker-creating calls: the journal owns their UUID across lost returns.
|
|
52
|
+
_CREATING_WORKER_METHODS = frozenset({"create", "fork"})
|
|
53
|
+
_SAFE_REJECTIONS = (
|
|
54
|
+
InvalidRequest,
|
|
55
|
+
CapabilityUnsupported,
|
|
56
|
+
DeliveryRejected,
|
|
57
|
+
SessionNotFound,
|
|
58
|
+
)
|
|
59
|
+
_SLUG_RE = re.compile(r"[^A-Za-z0-9._-]+")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class TaskOutput:
|
|
64
|
+
value: Any = None
|
|
65
|
+
report: str | None = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class ControllerInput:
|
|
70
|
+
id: str
|
|
71
|
+
created_at: int
|
|
72
|
+
worker: str | None
|
|
73
|
+
worker_name: str | None
|
|
74
|
+
comment: str
|
|
75
|
+
|
|
76
|
+
@classmethod
|
|
77
|
+
def from_record(cls, value: dict[str, Any]) -> "ControllerInput":
|
|
78
|
+
return cls(
|
|
79
|
+
id=str(value["id"]),
|
|
80
|
+
created_at=int(value["createdAt"]),
|
|
81
|
+
worker=str(value["worker"]) if value.get("worker") is not None else None,
|
|
82
|
+
worker_name=value.get("workerName"),
|
|
83
|
+
comment=str(value["comment"]),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def task_output(*, value: Any = None, report: str | None = None) -> TaskOutput:
|
|
88
|
+
if report is not None and not isinstance(report, str):
|
|
89
|
+
raise InvalidRequest("task_output report must be a string or None")
|
|
90
|
+
_canonical_json(value, "task output value")
|
|
91
|
+
return TaskOutput(value=value, report=report)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _canonical_json(value: Any, label: str) -> str:
|
|
95
|
+
try:
|
|
96
|
+
return json.dumps(
|
|
97
|
+
value, sort_keys=True, separators=(",", ":"), ensure_ascii=False,
|
|
98
|
+
allow_nan=False,
|
|
99
|
+
)
|
|
100
|
+
except (TypeError, ValueError) as error:
|
|
101
|
+
raise InvalidRequest(f"{label} must be JSON-compatible: {error}") from error
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _fingerprint(value: Any, label: str) -> str:
|
|
105
|
+
return hashlib.sha256(_canonical_json(value, label).encode("utf-8")).hexdigest()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _slug(value: str, fallback: str = "item") -> str:
|
|
109
|
+
rendered = _SLUG_RE.sub("-", value.strip()).strip("-._")
|
|
110
|
+
return (rendered or fallback)[:60]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _write_text_atomic(path: Path, text: str) -> None:
|
|
114
|
+
temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
|
|
115
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
116
|
+
try:
|
|
117
|
+
temporary.write_text(text, encoding="utf-8")
|
|
118
|
+
os.replace(temporary, path)
|
|
119
|
+
finally:
|
|
120
|
+
try:
|
|
121
|
+
temporary.unlink()
|
|
122
|
+
except FileNotFoundError:
|
|
123
|
+
pass
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass
|
|
127
|
+
class _LaunchContext:
|
|
128
|
+
run_id: str
|
|
129
|
+
run_dir: Path
|
|
130
|
+
script: Path
|
|
131
|
+
resume: bool
|
|
132
|
+
name_override: str | None = None
|
|
133
|
+
registered: bool = False
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass
|
|
137
|
+
class _TaskContext:
|
|
138
|
+
task_id: str
|
|
139
|
+
label: str
|
|
140
|
+
workspace: Path
|
|
141
|
+
operation_count: int = 0
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
_launch_context: ContextVar[_LaunchContext | None] = ContextVar(
|
|
145
|
+
"ccx_controller_launch", default=None
|
|
146
|
+
)
|
|
147
|
+
_active_runtime: ContextVar["_ControllerRuntime | None"] = ContextVar(
|
|
148
|
+
"ccx_controller_runtime", default=None
|
|
149
|
+
)
|
|
150
|
+
_active_task: ContextVar[_TaskContext | None] = ContextVar(
|
|
151
|
+
"ccx_controller_task", default=None
|
|
152
|
+
)
|
|
153
|
+
_reserved_task_invocation: ContextVar[tuple[object, int] | None] = ContextVar(
|
|
154
|
+
"ccx_controller_reserved_task_invocation", default=None
|
|
155
|
+
)
|
|
156
|
+
_suspend_worker_hook: ContextVar[bool] = ContextVar(
|
|
157
|
+
"ccx_controller_suspend_worker_hook", default=False
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class _ControllerRuntime:
|
|
162
|
+
def __init__(self, run_dir: Path):
|
|
163
|
+
self.run_dir = run_dir
|
|
164
|
+
record = store.read_json(store.run_record_path(run_dir))
|
|
165
|
+
store.require_operation_journal_layout(run_dir, record)
|
|
166
|
+
self.run_id = str(record["runId"])
|
|
167
|
+
self.name = str(record["controller"])
|
|
168
|
+
self.base_workspace = Path(str(record["workspace"]))
|
|
169
|
+
self._lock = threading.RLock()
|
|
170
|
+
self._task_counts: dict[str, int] = {}
|
|
171
|
+
self._controller_operation_count = 0
|
|
172
|
+
self._executor = ThreadPoolExecutor(thread_name_prefix=f"ccx-{self.run_id[:8]}")
|
|
173
|
+
self._futures: list[Future[Any]] = []
|
|
174
|
+
self._observed_futures: set[Future[Any]] = set()
|
|
175
|
+
self._live_vars_owner = None
|
|
176
|
+
|
|
177
|
+
def live_vars_owner(self):
|
|
178
|
+
with self._lock:
|
|
179
|
+
if self._live_vars_owner is None:
|
|
180
|
+
from ccx.controllers.live_vars import LiveOwner
|
|
181
|
+
self._live_vars_owner = LiveOwner()
|
|
182
|
+
return self._live_vars_owner
|
|
183
|
+
|
|
184
|
+
def _close_live_vars(self) -> None:
|
|
185
|
+
with self._lock:
|
|
186
|
+
owner = self._live_vars_owner
|
|
187
|
+
self._live_vars_owner = None
|
|
188
|
+
if owner is not None:
|
|
189
|
+
owner.close()
|
|
190
|
+
|
|
191
|
+
def next_task_invocation(self, key: str) -> int:
|
|
192
|
+
with self._lock:
|
|
193
|
+
value = self._task_counts.get(key, 0) + 1
|
|
194
|
+
self._task_counts[key] = value
|
|
195
|
+
return value
|
|
196
|
+
|
|
197
|
+
def submit(self, call: Callable[[], R]) -> Future[R]:
|
|
198
|
+
context = copy_context()
|
|
199
|
+
future = self._executor.submit(context.run, call)
|
|
200
|
+
with self._lock:
|
|
201
|
+
self._futures.append(future)
|
|
202
|
+
return future
|
|
203
|
+
|
|
204
|
+
def observe_future(self, future: Future[Any]) -> None:
|
|
205
|
+
with self._lock:
|
|
206
|
+
self._observed_futures.add(future)
|
|
207
|
+
|
|
208
|
+
def finish_futures(self) -> None:
|
|
209
|
+
checked = 0
|
|
210
|
+
first_error: BaseException | None = None
|
|
211
|
+
while True:
|
|
212
|
+
with self._lock:
|
|
213
|
+
futures = self._futures[checked:]
|
|
214
|
+
if not futures:
|
|
215
|
+
break
|
|
216
|
+
checked += len(futures)
|
|
217
|
+
for future in futures:
|
|
218
|
+
try:
|
|
219
|
+
future.result()
|
|
220
|
+
except BaseException as error:
|
|
221
|
+
with self._lock:
|
|
222
|
+
observed = future in self._observed_futures
|
|
223
|
+
if not observed:
|
|
224
|
+
first_error = first_error or error
|
|
225
|
+
self._executor.shutdown(wait=True)
|
|
226
|
+
self._close_live_vars()
|
|
227
|
+
if first_error is not None:
|
|
228
|
+
raise first_error
|
|
229
|
+
|
|
230
|
+
def shutdown_after_error(self) -> None:
|
|
231
|
+
with self._lock:
|
|
232
|
+
futures = list(self._futures)
|
|
233
|
+
for future in futures:
|
|
234
|
+
future.cancel()
|
|
235
|
+
self._executor.shutdown(wait=True, cancel_futures=True)
|
|
236
|
+
self._close_live_vars()
|
|
237
|
+
|
|
238
|
+
def next_operation(self, task_context: _TaskContext | None) -> tuple[str, int, Path]:
|
|
239
|
+
with self._lock:
|
|
240
|
+
if task_context is None:
|
|
241
|
+
self._controller_operation_count += 1
|
|
242
|
+
number = self._controller_operation_count
|
|
243
|
+
scope = "controller"
|
|
244
|
+
directory = self.run_dir / "operations"
|
|
245
|
+
else:
|
|
246
|
+
task_context.operation_count += 1
|
|
247
|
+
number = task_context.operation_count
|
|
248
|
+
scope = task_context.task_id
|
|
249
|
+
directory = self.run_dir / "tasks" / task_context.task_id / "operations"
|
|
250
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
251
|
+
return scope, number, directory
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
class _Current:
|
|
255
|
+
def _runtime(self) -> _ControllerRuntime:
|
|
256
|
+
runtime = _active_runtime.get()
|
|
257
|
+
if runtime is None:
|
|
258
|
+
raise ControllerLaunchRequired("current is only available inside a running CCX controller")
|
|
259
|
+
return runtime
|
|
260
|
+
|
|
261
|
+
@property
|
|
262
|
+
def run_id(self) -> str:
|
|
263
|
+
return self._runtime().run_id
|
|
264
|
+
|
|
265
|
+
@property
|
|
266
|
+
def controller_name(self) -> str:
|
|
267
|
+
return self._runtime().name
|
|
268
|
+
|
|
269
|
+
@property
|
|
270
|
+
def run_dir(self) -> Path:
|
|
271
|
+
return self._runtime().run_dir
|
|
272
|
+
|
|
273
|
+
@property
|
|
274
|
+
def workspace(self) -> Path:
|
|
275
|
+
task_context = _active_task.get()
|
|
276
|
+
return task_context.workspace if task_context is not None else self._runtime().base_workspace
|
|
277
|
+
|
|
278
|
+
@property
|
|
279
|
+
def workers(self) -> tuple[dict[str, Any], ...]:
|
|
280
|
+
record = store.read_json(store.run_record_path(self._runtime().run_dir))
|
|
281
|
+
return tuple(dict(item) for item in record.get("workers", []) if isinstance(item, dict))
|
|
282
|
+
|
|
283
|
+
@property
|
|
284
|
+
def inputs(self) -> tuple[ControllerInput, ...]:
|
|
285
|
+
return tuple(
|
|
286
|
+
ControllerInput.from_record(value)
|
|
287
|
+
for value in store.read_inputs(self._runtime().run_dir)
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
@property
|
|
291
|
+
def latest_input(self) -> ControllerInput | None:
|
|
292
|
+
values = self.inputs
|
|
293
|
+
return values[-1] if values else None
|
|
294
|
+
|
|
295
|
+
def report(
|
|
296
|
+
self,
|
|
297
|
+
*,
|
|
298
|
+
status: str,
|
|
299
|
+
checkpoint: str | None = None,
|
|
300
|
+
summary: str | None = None,
|
|
301
|
+
report: str | None = None,
|
|
302
|
+
data: dict[str, Any] | None = None,
|
|
303
|
+
) -> dict[str, Any]:
|
|
304
|
+
runtime = self._runtime()
|
|
305
|
+
if not isinstance(status, str) or not status.strip():
|
|
306
|
+
raise InvalidRequest("controller report status must be non-empty")
|
|
307
|
+
if data is not None:
|
|
308
|
+
_canonical_json(data, "controller report data")
|
|
309
|
+
report_path: Path | None = None
|
|
310
|
+
with runtime._lock:
|
|
311
|
+
if report is not None:
|
|
312
|
+
if not isinstance(report, str):
|
|
313
|
+
raise InvalidRequest("controller report text must be a string or None")
|
|
314
|
+
record = store.read_json(store.run_record_path(runtime.run_dir))
|
|
315
|
+
sequence = len(record.get("reports") or []) + 1
|
|
316
|
+
report_path = runtime.run_dir / "reports" / f"controller-{sequence:04d}.md"
|
|
317
|
+
_write_text_atomic(report_path, report)
|
|
318
|
+
report_record = {
|
|
319
|
+
"createdAt": store.now_ms(),
|
|
320
|
+
"status": status.strip(),
|
|
321
|
+
"checkpoint": checkpoint,
|
|
322
|
+
"summary": summary,
|
|
323
|
+
"path": str(report_path) if report_path is not None else None,
|
|
324
|
+
"data": data,
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
def update(value: dict[str, Any]) -> None:
|
|
328
|
+
value["status"] = report_record["status"]
|
|
329
|
+
value["checkpoint"] = checkpoint
|
|
330
|
+
value["summary"] = summary
|
|
331
|
+
value["latestReport"] = report_record
|
|
332
|
+
value.setdefault("reports", []).append(report_record)
|
|
333
|
+
|
|
334
|
+
store.mutate_run(runtime.run_dir, update)
|
|
335
|
+
store.append_event(
|
|
336
|
+
runtime.run_dir, "controller-report", status=status.strip(),
|
|
337
|
+
checkpoint=checkpoint, reportPath=report_record["path"],
|
|
338
|
+
)
|
|
339
|
+
return dict(report_record)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
current = _Current()
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _update_task_projection(runtime: _ControllerRuntime, task_record: dict[str, Any]) -> None:
|
|
346
|
+
def update(value: dict[str, Any]) -> None:
|
|
347
|
+
value.setdefault("tasks", {})[str(task_record["id"])] = {
|
|
348
|
+
key: task_record.get(key)
|
|
349
|
+
for key in (
|
|
350
|
+
"id", "name", "invocation", "state", "workspaceMode", "workspace",
|
|
351
|
+
"startedAt", "completedAt", "attempts", "reportPath", "git", "error",
|
|
352
|
+
)
|
|
353
|
+
}
|
|
354
|
+
store.mutate_run(runtime.run_dir, update)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _git(workspace: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
|
|
358
|
+
proc = subprocess.run(
|
|
359
|
+
["git", "-C", str(workspace), *args],
|
|
360
|
+
text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
361
|
+
)
|
|
362
|
+
if check and proc.returncode != 0:
|
|
363
|
+
detail = (proc.stderr or proc.stdout).strip()
|
|
364
|
+
raise ControllerError(f"git {' '.join(args)} failed in {workspace}: {detail}")
|
|
365
|
+
return proc
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _prepare_worktree(
|
|
369
|
+
runtime: _ControllerRuntime, task_id: str, label: str,
|
|
370
|
+
) -> tuple[Path, dict[str, Any]]:
|
|
371
|
+
persisted = (
|
|
372
|
+
store.read_json(store.run_record_path(runtime.run_dir)).get("worktrees") or {}
|
|
373
|
+
).get(task_id)
|
|
374
|
+
if isinstance(persisted, dict):
|
|
375
|
+
repository = Path(str(persisted["repository"])).resolve()
|
|
376
|
+
base = str(persisted["base"])
|
|
377
|
+
workspace = Path(str(persisted["path"])).resolve()
|
|
378
|
+
branch = str(persisted["branch"])
|
|
379
|
+
else:
|
|
380
|
+
repo_text = _git(runtime.base_workspace, "rev-parse", "--show-toplevel").stdout.strip()
|
|
381
|
+
repository = Path(repo_text).resolve()
|
|
382
|
+
base = _git(repository, "rev-parse", "HEAD").stdout.strip()
|
|
383
|
+
workspace = runtime.run_dir / "worktrees" / task_id
|
|
384
|
+
branch = f"ccx/{_slug(runtime.name)}/{runtime.run_id[:8]}/{task_id}"
|
|
385
|
+
with store.file_lock(runtime.run_dir / "git.lock"):
|
|
386
|
+
if workspace.exists():
|
|
387
|
+
_git(workspace, "rev-parse", "--is-inside-work-tree")
|
|
388
|
+
else:
|
|
389
|
+
branch_exists = _git(repository, "show-ref", "--verify", "--quiet", f"refs/heads/{branch}", check=False).returncode == 0
|
|
390
|
+
command = ["worktree", "add"]
|
|
391
|
+
if not branch_exists:
|
|
392
|
+
command += ["-b", branch]
|
|
393
|
+
command += [str(workspace), branch if branch_exists else base]
|
|
394
|
+
_git(repository, *command)
|
|
395
|
+
metadata = {
|
|
396
|
+
"repository": str(repository), "path": str(workspace),
|
|
397
|
+
"branch": branch, "base": base, "head": _git(workspace, "rev-parse", "HEAD").stdout.strip(),
|
|
398
|
+
"status": _git(workspace, "status", "--porcelain=v1").stdout,
|
|
399
|
+
"diffStat": _git(workspace, "diff", "--stat", "HEAD").stdout,
|
|
400
|
+
"commit": None,
|
|
401
|
+
}
|
|
402
|
+
store.mutate_run(
|
|
403
|
+
runtime.run_dir,
|
|
404
|
+
lambda value: value.setdefault("worktrees", {}).__setitem__(task_id, metadata),
|
|
405
|
+
)
|
|
406
|
+
return workspace, metadata
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _finish_git(
|
|
410
|
+
runtime: _ControllerRuntime,
|
|
411
|
+
task_id: str,
|
|
412
|
+
label: str,
|
|
413
|
+
workspace: Path,
|
|
414
|
+
metadata: dict[str, Any],
|
|
415
|
+
commit: bool | str,
|
|
416
|
+
) -> dict[str, Any]:
|
|
417
|
+
dirty = _git(workspace, "status", "--porcelain=v1").stdout
|
|
418
|
+
metadata = dict(metadata)
|
|
419
|
+
metadata["status"] = dirty
|
|
420
|
+
metadata["diffStat"] = _git(workspace, "diff", "--stat", "HEAD").stdout
|
|
421
|
+
if commit and dirty:
|
|
422
|
+
_git(workspace, "add", "-A")
|
|
423
|
+
message = commit if isinstance(commit, str) else f"ccx({runtime.name}): {label}"
|
|
424
|
+
_git(workspace, "commit", "-m", message)
|
|
425
|
+
metadata["commit"] = _git(workspace, "rev-parse", "HEAD").stdout.strip()
|
|
426
|
+
metadata["status"] = _git(workspace, "status", "--porcelain=v1").stdout
|
|
427
|
+
metadata["diffStat"] = _git(workspace, "show", "--stat", "--oneline", "HEAD").stdout
|
|
428
|
+
metadata["head"] = _git(workspace, "rev-parse", "HEAD").stdout.strip()
|
|
429
|
+
store.mutate_run(
|
|
430
|
+
runtime.run_dir,
|
|
431
|
+
lambda value: value.setdefault("worktrees", {}).__setitem__(task_id, metadata),
|
|
432
|
+
)
|
|
433
|
+
return metadata
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
class TaskFuture(Generic[R]):
|
|
437
|
+
def __init__(self, future: Future[R], runtime: _ControllerRuntime):
|
|
438
|
+
self._future = future
|
|
439
|
+
self._runtime = runtime
|
|
440
|
+
|
|
441
|
+
def result(self, timeout: float | None = None) -> R:
|
|
442
|
+
try:
|
|
443
|
+
return self._future.result(timeout=timeout)
|
|
444
|
+
finally:
|
|
445
|
+
if self._future.done():
|
|
446
|
+
self._runtime.observe_future(self._future)
|
|
447
|
+
|
|
448
|
+
def done(self) -> bool:
|
|
449
|
+
return self._future.done()
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
class Task(Generic[P, R]):
|
|
453
|
+
def __init__(
|
|
454
|
+
self,
|
|
455
|
+
function: Callable[P, R],
|
|
456
|
+
*,
|
|
457
|
+
name: str | None = None,
|
|
458
|
+
workspace: Literal["shared", "worktree"] = "shared",
|
|
459
|
+
commit: bool | str = False,
|
|
460
|
+
):
|
|
461
|
+
if workspace not in {"shared", "worktree"}:
|
|
462
|
+
raise InvalidRequest("task workspace must be 'shared' or 'worktree'")
|
|
463
|
+
if commit and workspace != "worktree":
|
|
464
|
+
raise InvalidRequest("task commits require workspace='worktree'")
|
|
465
|
+
self.function = function
|
|
466
|
+
self.name = name or function.__name__
|
|
467
|
+
self.workspace_mode = workspace
|
|
468
|
+
self.commit = commit
|
|
469
|
+
wraps(function)(self)
|
|
470
|
+
|
|
471
|
+
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R:
|
|
472
|
+
runtime = _active_runtime.get()
|
|
473
|
+
if runtime is None:
|
|
474
|
+
raise ControllerLaunchRequired("tasks can only run inside a CCX controller")
|
|
475
|
+
key = _slug(self.name)
|
|
476
|
+
reserved = _reserved_task_invocation.get()
|
|
477
|
+
invocation = (
|
|
478
|
+
reserved[1] if reserved is not None and reserved[0] is self
|
|
479
|
+
else runtime.next_task_invocation(key)
|
|
480
|
+
)
|
|
481
|
+
task_id = f"{_slug(self.name)}--{invocation:04d}"
|
|
482
|
+
input_value = {"args": args, "kwargs": kwargs}
|
|
483
|
+
input_fingerprint = _fingerprint(input_value, f"arguments for task {self.name}")
|
|
484
|
+
task_dir = runtime.run_dir / "tasks" / task_id
|
|
485
|
+
task_dir.mkdir(parents=True, exist_ok=True)
|
|
486
|
+
task_path = task_dir / "task.json"
|
|
487
|
+
with store.file_lock(task_dir / "task.lock"):
|
|
488
|
+
if task_path.exists():
|
|
489
|
+
record = store.read_json(task_path)
|
|
490
|
+
if record.get("function") != self.function.__qualname__:
|
|
491
|
+
raise ControllerReplayMismatch(
|
|
492
|
+
f"task {task_id} resolved to {self.function.__qualname__!r}, but the "
|
|
493
|
+
f"stored invocation belongs to {record.get('function')!r}; controller "
|
|
494
|
+
"resumes must make the same task calls in the same order"
|
|
495
|
+
)
|
|
496
|
+
if record.get("inputSha256") != input_fingerprint:
|
|
497
|
+
raise ControllerReplayMismatch(
|
|
498
|
+
f"task {task_id} input changed while replaying controller {runtime.run_id}; "
|
|
499
|
+
"controllers resume from the top and must make the same task calls with "
|
|
500
|
+
"the same arguments in the same order"
|
|
501
|
+
)
|
|
502
|
+
if record.get("state") == "succeeded":
|
|
503
|
+
if record.get("outputKind") == "task-output":
|
|
504
|
+
report_path = record.get("reportPath")
|
|
505
|
+
report = Path(report_path).read_text(encoding="utf-8") if report_path else None
|
|
506
|
+
return TaskOutput(value=record.get("value"), report=report) # type: ignore[return-value]
|
|
507
|
+
return record.get("value") # type: ignore[return-value]
|
|
508
|
+
attempts = int(record.get("attempts") or 0) + 1
|
|
509
|
+
else:
|
|
510
|
+
attempts = 1
|
|
511
|
+
record = {
|
|
512
|
+
"schemaVersion": 1, "id": task_id, "name": self.name,
|
|
513
|
+
"function": self.function.__qualname__, "invocation": invocation,
|
|
514
|
+
"inputSha256": input_fingerprint, "workspaceMode": self.workspace_mode,
|
|
515
|
+
"workspace": None, "state": "pending", "attempts": 0,
|
|
516
|
+
"createdAt": store.now_ms(), "startedAt": None, "completedAt": None,
|
|
517
|
+
"outputKind": None, "value": None, "reportPath": None,
|
|
518
|
+
"git": None, "error": None,
|
|
519
|
+
}
|
|
520
|
+
record.update(state="running", attempts=attempts, startedAt=store.now_ms(), error=None)
|
|
521
|
+
store.write_json_atomic(task_path, record)
|
|
522
|
+
_update_task_projection(runtime, record)
|
|
523
|
+
store.append_event(runtime.run_dir, "task-started", taskId=task_id, attempt=attempts)
|
|
524
|
+
|
|
525
|
+
git_metadata: dict[str, Any] | None = None
|
|
526
|
+
workspace = runtime.base_workspace
|
|
527
|
+
try:
|
|
528
|
+
if self.workspace_mode == "worktree":
|
|
529
|
+
workspace, git_metadata = _prepare_worktree(runtime, task_id, self.name)
|
|
530
|
+
record["workspace"] = str(workspace)
|
|
531
|
+
task_context = _TaskContext(task_id=task_id, label=self.name, workspace=workspace)
|
|
532
|
+
token = _active_task.set(task_context)
|
|
533
|
+
try:
|
|
534
|
+
result = self.function(*args, **kwargs)
|
|
535
|
+
finally:
|
|
536
|
+
_active_task.reset(token)
|
|
537
|
+
if isinstance(result, TaskOutput):
|
|
538
|
+
value = result.value
|
|
539
|
+
report_text = result.report
|
|
540
|
+
output_kind = "task-output"
|
|
541
|
+
else:
|
|
542
|
+
value = result
|
|
543
|
+
report_text = None
|
|
544
|
+
output_kind = "value"
|
|
545
|
+
_canonical_json(value, f"return value for task {self.name}")
|
|
546
|
+
report_path: Path | None = None
|
|
547
|
+
if report_text is not None:
|
|
548
|
+
report_path = task_dir / "report.md"
|
|
549
|
+
_write_text_atomic(report_path, report_text)
|
|
550
|
+
if git_metadata is not None:
|
|
551
|
+
git_metadata = _finish_git(
|
|
552
|
+
runtime, task_id, self.name, workspace, git_metadata, self.commit
|
|
553
|
+
)
|
|
554
|
+
record.update(
|
|
555
|
+
state="succeeded", completedAt=store.now_ms(), outputKind=output_kind,
|
|
556
|
+
value=value, reportPath=str(report_path) if report_path else None,
|
|
557
|
+
git=git_metadata, error=None,
|
|
558
|
+
)
|
|
559
|
+
store.write_json_atomic(task_path, record)
|
|
560
|
+
_update_task_projection(runtime, record)
|
|
561
|
+
store.append_event(runtime.run_dir, "task-succeeded", taskId=task_id)
|
|
562
|
+
return result
|
|
563
|
+
except BaseException as error:
|
|
564
|
+
if git_metadata is not None and workspace.exists():
|
|
565
|
+
try:
|
|
566
|
+
git_metadata = _finish_git(
|
|
567
|
+
runtime, task_id, self.name, workspace, git_metadata, False
|
|
568
|
+
)
|
|
569
|
+
except Exception:
|
|
570
|
+
pass
|
|
571
|
+
record.update(
|
|
572
|
+
state="failed", completedAt=store.now_ms(), git=git_metadata,
|
|
573
|
+
error={"type": type(error).__name__, "message": str(error)},
|
|
574
|
+
)
|
|
575
|
+
store.write_json_atomic(task_path, record)
|
|
576
|
+
_update_task_projection(runtime, record)
|
|
577
|
+
store.append_event(
|
|
578
|
+
runtime.run_dir, "task-failed", taskId=task_id,
|
|
579
|
+
errorType=type(error).__name__, message=str(error),
|
|
580
|
+
)
|
|
581
|
+
raise
|
|
582
|
+
|
|
583
|
+
def submit(self, *args: P.args, **kwargs: P.kwargs) -> TaskFuture[R]:
|
|
584
|
+
runtime = _active_runtime.get()
|
|
585
|
+
if runtime is None:
|
|
586
|
+
raise ControllerLaunchRequired("tasks can only run inside a CCX controller")
|
|
587
|
+
invocation = runtime.next_task_invocation(_slug(self.name))
|
|
588
|
+
def invoke():
|
|
589
|
+
token = _reserved_task_invocation.set((self, invocation))
|
|
590
|
+
try:
|
|
591
|
+
return self(*args, **kwargs)
|
|
592
|
+
finally:
|
|
593
|
+
_reserved_task_invocation.reset(token)
|
|
594
|
+
return TaskFuture(runtime.submit(invoke), runtime)
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
@overload
|
|
598
|
+
def task(function: Callable[P, R]) -> Task[P, R]: ...
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
@overload
|
|
602
|
+
def task(
|
|
603
|
+
function: None = None, *, name: str | None = None,
|
|
604
|
+
workspace: Literal["shared", "worktree"] = "shared", commit: bool | str = False,
|
|
605
|
+
) -> Callable[[Callable[P, R]], Task[P, R]]: ...
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def task(function=None, *, name=None, workspace="shared", commit=False):
|
|
609
|
+
def decorate(value):
|
|
610
|
+
return Task(value, name=name, workspace=workspace, commit=commit)
|
|
611
|
+
return decorate(function) if function is not None else decorate
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _original_controller_start() -> str:
|
|
615
|
+
from ccx.store.liveness import proc_start_token
|
|
616
|
+
start = proc_start_token(os.getpid())
|
|
617
|
+
if start is None:
|
|
618
|
+
raise ControllerError("controller cannot prove its original PID/start")
|
|
619
|
+
return start
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def _mark_lifecycle(run_dir: Path, state: str, *, error: BaseException | None = None) -> None:
|
|
623
|
+
start = _original_controller_start() if state == "running" else None
|
|
624
|
+
|
|
625
|
+
def update(value: dict[str, Any]) -> None:
|
|
626
|
+
value.setdefault("lifecycle", {}).update(
|
|
627
|
+
state=state, since=store.now_ms(), pid=os.getpid() if state == "running" else None,
|
|
628
|
+
pidStart=start,
|
|
629
|
+
)
|
|
630
|
+
attempts = value.setdefault("attempts", [])
|
|
631
|
+
if attempts:
|
|
632
|
+
attempts[-1]["pid"] = os.getpid()
|
|
633
|
+
if start is not None:
|
|
634
|
+
attempts[-1]["pidStart"] = start
|
|
635
|
+
if state != "running":
|
|
636
|
+
attempts[-1]["completedAt"] = store.now_ms()
|
|
637
|
+
attempts[-1]["lifecycle"] = state
|
|
638
|
+
if error is not None:
|
|
639
|
+
value["error"] = {
|
|
640
|
+
"type": type(error).__name__, "message": str(error),
|
|
641
|
+
"traceback": "".join(traceback.format_exception(error)),
|
|
642
|
+
}
|
|
643
|
+
elif state == "succeeded":
|
|
644
|
+
value["error"] = None
|
|
645
|
+
if value.get("status") is None:
|
|
646
|
+
value["status"] = "completed"
|
|
647
|
+
store.mutate_run(run_dir, update)
|
|
648
|
+
store.append_event(run_dir, f"run-{state}", pid=os.getpid())
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def _register_controller(
|
|
652
|
+
launch: _LaunchContext,
|
|
653
|
+
*,
|
|
654
|
+
name: str | None,
|
|
655
|
+
workspace: str | os.PathLike[str] | None,
|
|
656
|
+
) -> _ControllerRuntime:
|
|
657
|
+
if launch.registered:
|
|
658
|
+
raise ControllerError("a controller script may invoke only one @controller entrypoint")
|
|
659
|
+
# Fence direct entry invocation before publishing a bootstrap.
|
|
660
|
+
store.require_operation_journal_layout(launch.run_dir)
|
|
661
|
+
decorated_name = store.validate_controller_name(name or launch.script.stem)
|
|
662
|
+
effective_name = store.validate_controller_name(
|
|
663
|
+
launch.name_override or decorated_name
|
|
664
|
+
)
|
|
665
|
+
if launch.resume:
|
|
666
|
+
record = store.read_json(store.run_record_path(launch.run_dir))
|
|
667
|
+
recorded_declared = record.get("declaredControllerName")
|
|
668
|
+
if (
|
|
669
|
+
not isinstance(recorded_declared, str)
|
|
670
|
+
or recorded_declared.casefold() != decorated_name.casefold()
|
|
671
|
+
):
|
|
672
|
+
raise ControllerReplayMismatch(
|
|
673
|
+
f"declared controller name changed from {recorded_declared!r} "
|
|
674
|
+
f"to {decorated_name!r}"
|
|
675
|
+
)
|
|
676
|
+
effective_workspace = (
|
|
677
|
+
Path(workspace).expanduser().resolve()
|
|
678
|
+
if workspace is not None
|
|
679
|
+
else Path(str(record.get("workspace"))).resolve()
|
|
680
|
+
)
|
|
681
|
+
if str(record.get("controller", "")).casefold() != effective_name.casefold():
|
|
682
|
+
raise ControllerReplayMismatch(
|
|
683
|
+
f"effective controller name changed from {record.get('controller')!r} "
|
|
684
|
+
f"to {effective_name!r}"
|
|
685
|
+
)
|
|
686
|
+
if Path(str(record.get("workspace"))).resolve() != effective_workspace:
|
|
687
|
+
raise ControllerReplayMismatch("decorated controller workspace changed during resume")
|
|
688
|
+
else:
|
|
689
|
+
effective_workspace = (
|
|
690
|
+
Path(workspace).expanduser().resolve()
|
|
691
|
+
if workspace is not None else Path.cwd().resolve()
|
|
692
|
+
)
|
|
693
|
+
launch.run_dir = store.publish_bootstrap(
|
|
694
|
+
launch.run_dir, name=effective_name, script=launch.script,
|
|
695
|
+
declared_name=decorated_name, workspace=effective_workspace,
|
|
696
|
+
)
|
|
697
|
+
launch.registered = True
|
|
698
|
+
runtime = _ControllerRuntime(launch.run_dir)
|
|
699
|
+
_mark_lifecycle(launch.run_dir, "running")
|
|
700
|
+
store.write_launch_verdict(
|
|
701
|
+
launch.run_id,
|
|
702
|
+
{"ok": True, "runId": launch.run_id, "controller": runtime.name,
|
|
703
|
+
"runDir": str(launch.run_dir), "pid": os.getpid()},
|
|
704
|
+
)
|
|
705
|
+
return runtime
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
@overload
|
|
709
|
+
def controller(function: Callable[P, R]) -> Callable[P, R]: ...
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
@overload
|
|
713
|
+
def controller(
|
|
714
|
+
function: None = None, *, name: str | None = None,
|
|
715
|
+
workspace: str | os.PathLike[str] | None = None,
|
|
716
|
+
) -> Callable[[Callable[P, R]], Callable[P, R]]: ...
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def controller(function=None, *, name=None, workspace=None):
|
|
720
|
+
def decorate(value):
|
|
721
|
+
@wraps(value)
|
|
722
|
+
def wrapped(*args, **kwargs):
|
|
723
|
+
launch = _launch_context.get()
|
|
724
|
+
if launch is None:
|
|
725
|
+
raise ControllerLaunchRequired(
|
|
726
|
+
f"{launch_command_for_current_script()} must launch decorated controllers; "
|
|
727
|
+
"direct Python execution is intentionally refused"
|
|
728
|
+
)
|
|
729
|
+
runtime = _register_controller(launch, name=name, workspace=workspace)
|
|
730
|
+
token = _active_runtime.set(runtime)
|
|
731
|
+
try:
|
|
732
|
+
result = value(*args, **kwargs)
|
|
733
|
+
runtime.finish_futures()
|
|
734
|
+
if isinstance(result, TaskOutput):
|
|
735
|
+
_canonical_json(result.value, "controller return value")
|
|
736
|
+
if result.report is not None:
|
|
737
|
+
current.report(status="completed", report=result.report)
|
|
738
|
+
persisted_result = result.value
|
|
739
|
+
else:
|
|
740
|
+
_canonical_json(result, "controller return value")
|
|
741
|
+
persisted_result = result
|
|
742
|
+
store.mutate_run(
|
|
743
|
+
runtime.run_dir,
|
|
744
|
+
lambda record: record.__setitem__("result", persisted_result),
|
|
745
|
+
)
|
|
746
|
+
_mark_lifecycle(runtime.run_dir, "succeeded")
|
|
747
|
+
return result
|
|
748
|
+
except BaseException as error:
|
|
749
|
+
try:
|
|
750
|
+
runtime.shutdown_after_error()
|
|
751
|
+
except BaseException:
|
|
752
|
+
pass
|
|
753
|
+
try:
|
|
754
|
+
existing = store.read_json(store.run_record_path(runtime.run_dir))
|
|
755
|
+
if (existing.get("lifecycle") or {}).get("state") != "stopped":
|
|
756
|
+
_mark_lifecycle(runtime.run_dir, "failed", error=error)
|
|
757
|
+
except Exception:
|
|
758
|
+
pass
|
|
759
|
+
raise
|
|
760
|
+
finally:
|
|
761
|
+
_active_runtime.reset(token)
|
|
762
|
+
return wrapped
|
|
763
|
+
return decorate(function) if function is not None else decorate
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def launch_command_for_current_script() -> str:
|
|
767
|
+
script = Path(sys.argv[0]).resolve() if sys.argv else Path("controller.py")
|
|
768
|
+
return f"ccx controller run {script}"
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def _host_execute(
|
|
772
|
+
*, run_id: str, run_dir: Path, script: Path, resume: bool, script_args: list[str],
|
|
773
|
+
) -> int:
|
|
774
|
+
from ccx.controllers.live_vars import sanitize_inherited_shell_bridge_env
|
|
775
|
+
sanitize_inherited_shell_bridge_env()
|
|
776
|
+
|
|
777
|
+
launch = _LaunchContext(
|
|
778
|
+
run_id=run_id,
|
|
779
|
+
run_dir=run_dir,
|
|
780
|
+
script=script,
|
|
781
|
+
resume=resume,
|
|
782
|
+
)
|
|
783
|
+
launch_token = _launch_context.set(launch)
|
|
784
|
+
old_argv = sys.argv[:]
|
|
785
|
+
try:
|
|
786
|
+
record = store.read_json(store.run_record_path(run_dir))
|
|
787
|
+
# This precedes lifecycle writes and, crucially, runpy.run_path: top-level
|
|
788
|
+
# controller source can perform arbitrary side effects before @controller.
|
|
789
|
+
try:
|
|
790
|
+
store.require_operation_journal_layout(run_dir, record)
|
|
791
|
+
except ControllerError as error:
|
|
792
|
+
# Direct _host entry is independently fenced. A layout refusal is
|
|
793
|
+
# not an execution failure: do not overwrite the legacy lifecycle,
|
|
794
|
+
# error, attempt, launch verdict or operation journals.
|
|
795
|
+
sys.stderr.write(f"[ccx controller] {error}\n")
|
|
796
|
+
return 1
|
|
797
|
+
requested_name = record.get("requestedControllerName")
|
|
798
|
+
launch.name_override = (
|
|
799
|
+
str(requested_name) if requested_name is not None else None
|
|
800
|
+
)
|
|
801
|
+
start = _original_controller_start()
|
|
802
|
+
store.mutate_run(
|
|
803
|
+
run_dir,
|
|
804
|
+
lambda value: value.setdefault("lifecycle", {}).update(
|
|
805
|
+
state="starting", since=store.now_ms(), pid=os.getpid(),
|
|
806
|
+
pidStart=start,
|
|
807
|
+
),
|
|
808
|
+
)
|
|
809
|
+
sys.argv = [str(script), *script_args]
|
|
810
|
+
runpy.run_path(str(script), run_name="__main__")
|
|
811
|
+
if not launch.registered:
|
|
812
|
+
raise ControllerError(
|
|
813
|
+
"controller script did not invoke an @controller-decorated entrypoint"
|
|
814
|
+
)
|
|
815
|
+
return 0
|
|
816
|
+
except SystemExit as error:
|
|
817
|
+
code = error.code if isinstance(error.code, int) else 1
|
|
818
|
+
if not launch.registered:
|
|
819
|
+
try:
|
|
820
|
+
_mark_lifecycle(run_dir, "failed", error=error)
|
|
821
|
+
except Exception:
|
|
822
|
+
pass
|
|
823
|
+
store.write_launch_verdict(
|
|
824
|
+
run_id, {"ok": False, "runId": run_id, "error": f"script exited with {code} before controller startup"},
|
|
825
|
+
)
|
|
826
|
+
return code
|
|
827
|
+
except BaseException as error:
|
|
828
|
+
if launch.registered:
|
|
829
|
+
try:
|
|
830
|
+
_mark_lifecycle(launch.run_dir, "failed", error=error)
|
|
831
|
+
except Exception:
|
|
832
|
+
pass
|
|
833
|
+
else:
|
|
834
|
+
try:
|
|
835
|
+
_mark_lifecycle(run_dir, "failed", error=error)
|
|
836
|
+
except Exception:
|
|
837
|
+
pass
|
|
838
|
+
store.write_launch_verdict(
|
|
839
|
+
run_id,
|
|
840
|
+
{"ok": False, "runId": run_id, "error": f"{type(error).__name__}: {error}"},
|
|
841
|
+
)
|
|
842
|
+
traceback.print_exc()
|
|
843
|
+
return 1
|
|
844
|
+
finally:
|
|
845
|
+
sys.argv = old_argv
|
|
846
|
+
_launch_context.reset(launch_token)
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def _operation_metadata(method: str, args: tuple[Any, ...], kwargs: dict[str, Any]) -> dict[str, Any]:
|
|
850
|
+
metadata: dict[str, Any] = {"method": method}
|
|
851
|
+
if method == "create":
|
|
852
|
+
metadata.update(
|
|
853
|
+
engine=kwargs.get("engine"), name=kwargs.get("name"), cwd=kwargs.get("cwd"),
|
|
854
|
+
workerId=kwargs.get("worker_id"),
|
|
855
|
+
requestedEngine=kwargs.get("engine"), requestedModel=kwargs.get("model"),
|
|
856
|
+
requestedEffort=kwargs.get("effort"),
|
|
857
|
+
promptBytes=len(str(kwargs.get("prompt") or "").encode("utf-8")),
|
|
858
|
+
)
|
|
859
|
+
elif method == "fork":
|
|
860
|
+
source = args[0] if args else kwargs.get("worker")
|
|
861
|
+
metadata.update(
|
|
862
|
+
source=_worker_handle(source), name=kwargs.get("name"),
|
|
863
|
+
workerId=kwargs.get("worker_id"),
|
|
864
|
+
requestedModel=kwargs.get("model"), requestedEffort=kwargs.get("effort"),
|
|
865
|
+
promptBytes=len(str(kwargs.get("prompt") or "").encode("utf-8")),
|
|
866
|
+
)
|
|
867
|
+
elif method == "send":
|
|
868
|
+
worker = args[0] if args else kwargs.get("worker")
|
|
869
|
+
text = args[1] if len(args) > 1 else kwargs.get("text")
|
|
870
|
+
metadata.update(worker=_worker_handle(worker), deliveryId=kwargs.get("delivery_id"),
|
|
871
|
+
textBytes=len(str(text or "").encode("utf-8")))
|
|
872
|
+
else:
|
|
873
|
+
worker = args[0] if args else kwargs.get("worker")
|
|
874
|
+
if worker is not None:
|
|
875
|
+
metadata["worker"] = _worker_handle(worker)
|
|
876
|
+
if method == "steer":
|
|
877
|
+
metadata["deliveryId"] = kwargs.get("delivery_id")
|
|
878
|
+
if method == "compact":
|
|
879
|
+
metadata["operationId"] = kwargs.get("operation_id")
|
|
880
|
+
variables = kwargs.get("variables")
|
|
881
|
+
if isinstance(variables, Mapping):
|
|
882
|
+
metadata["variableMode"] = "live"
|
|
883
|
+
metadata["liveVariables"] = sorted(str(name) for name in variables)
|
|
884
|
+
variable_source = kwargs.get("variables_from")
|
|
885
|
+
if variable_source is not None:
|
|
886
|
+
metadata["variableMode"] = "durable-shared"
|
|
887
|
+
metadata["variableSource"] = _worker_handle(variable_source)
|
|
888
|
+
return metadata
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
def _stable_call_value(value: Any) -> Any:
|
|
892
|
+
if value is None or isinstance(value, (bool, int, float, str)):
|
|
893
|
+
return value
|
|
894
|
+
if isinstance(value, Path):
|
|
895
|
+
return {"path": str(value)}
|
|
896
|
+
if isinstance(value, (list, tuple)):
|
|
897
|
+
return [_stable_call_value(item) for item in value]
|
|
898
|
+
if isinstance(value, dict):
|
|
899
|
+
return {str(key): _stable_call_value(item) for key, item in sorted(value.items(), key=lambda item: str(item[0]))}
|
|
900
|
+
if is_dataclass(value):
|
|
901
|
+
return _stable_call_value(asdict(value))
|
|
902
|
+
return {"type": f"{type(value).__module__}.{type(value).__qualname__}", "repr": repr(value)}
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
def _worker_handle(value: Any) -> str | None:
|
|
906
|
+
if isinstance(value, str):
|
|
907
|
+
return value
|
|
908
|
+
if isinstance(value, dict) and isinstance(value.get("worker"), str):
|
|
909
|
+
return value["worker"]
|
|
910
|
+
return None
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
def _verified_created_owner(client: Any, result: Any, kwargs: dict[str, Any],
|
|
914
|
+
operation_id: str, *, method: str = "create"):
|
|
915
|
+
"""Require the journaled full UUID and its ACTIVE original owner."""
|
|
916
|
+
worker_id = kwargs.get("worker_id")
|
|
917
|
+
engine = kwargs.get("engine")
|
|
918
|
+
if method == "fork":
|
|
919
|
+
try:
|
|
920
|
+
from ccx.store.identity import canonical_worker_id
|
|
921
|
+
canonical_worker_id(worker_id)
|
|
922
|
+
client._uuid_store.revalidate()
|
|
923
|
+
owner = client._uuid_store.resolve(worker_id)
|
|
924
|
+
engine = owner.engine
|
|
925
|
+
except Exception as error:
|
|
926
|
+
raise ControllerOperationIndeterminate(
|
|
927
|
+
f"fork child has no confirmed original ACTIVE owner: {operation_id}",
|
|
928
|
+
operation_id=operation_id,
|
|
929
|
+
) from error
|
|
930
|
+
handle = f"{engine}:{worker_id}"
|
|
931
|
+
if (not isinstance(result, dict) or result.get("worker") != handle
|
|
932
|
+
or result.get("worker_id") != worker_id
|
|
933
|
+
or result.get("engine") != engine):
|
|
934
|
+
raise ControllerOperationIndeterminate(
|
|
935
|
+
f"create result disagrees with its original full-UUID intent: {operation_id}",
|
|
936
|
+
operation_id=operation_id,
|
|
937
|
+
)
|
|
938
|
+
if method != "fork":
|
|
939
|
+
try:
|
|
940
|
+
from ccx.store.identity import canonical_worker_id
|
|
941
|
+
canonical_worker_id(worker_id)
|
|
942
|
+
client._uuid_store.revalidate()
|
|
943
|
+
owner = client._uuid_store.resolve(handle)
|
|
944
|
+
except Exception as error:
|
|
945
|
+
raise ControllerOperationIndeterminate(
|
|
946
|
+
f"created worker has no confirmed original ACTIVE owner: {operation_id}",
|
|
947
|
+
operation_id=operation_id,
|
|
948
|
+
) from error
|
|
949
|
+
if owner.worker != handle or owner.worker_id != worker_id or owner.engine != engine:
|
|
950
|
+
raise ControllerOperationIndeterminate(
|
|
951
|
+
f"created owner differs from committed journal identity: {operation_id}",
|
|
952
|
+
operation_id=operation_id,
|
|
953
|
+
)
|
|
954
|
+
return owner
|
|
955
|
+
|
|
956
|
+
|
|
957
|
+
def _registered_cwd(client: Any, handle: str, cwd: Any) -> str | None:
|
|
958
|
+
"""Use the exact created owner's record, never the old short-keyed store."""
|
|
959
|
+
if cwd:
|
|
960
|
+
return str(Path(str(cwd)).resolve())
|
|
961
|
+
record = client._uuid_store.record(handle)
|
|
962
|
+
return record.get("cwd")
|
|
963
|
+
|
|
964
|
+
|
|
965
|
+
def _register_worker(
|
|
966
|
+
runtime: _ControllerRuntime,
|
|
967
|
+
client: Any,
|
|
968
|
+
owner: Any,
|
|
969
|
+
result: dict[str, Any],
|
|
970
|
+
kwargs: dict[str, Any],
|
|
971
|
+
task_context: _TaskContext | None,
|
|
972
|
+
operation_id: str,
|
|
973
|
+
) -> None:
|
|
974
|
+
handle = owner.worker
|
|
975
|
+
name = kwargs.get("name")
|
|
976
|
+
worker_record = {
|
|
977
|
+
"worker": handle,
|
|
978
|
+
"engine": owner.engine,
|
|
979
|
+
"short": owner.short,
|
|
980
|
+
"worker_id": owner.worker_id,
|
|
981
|
+
"name": name,
|
|
982
|
+
"cwd": _registered_cwd(client, handle, kwargs.get("cwd") or result.get("cwd")),
|
|
983
|
+
"taskId": task_context.task_id if task_context is not None else None,
|
|
984
|
+
"createdAt": store.now_ms(),
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
def update(value: dict[str, Any]) -> None:
|
|
988
|
+
workers = value.setdefault("workers", [])
|
|
989
|
+
if not any(item.get("worker") == handle for item in workers):
|
|
990
|
+
workers.append(worker_record)
|
|
991
|
+
store.mutate_run(runtime.run_dir, update)
|
|
992
|
+
store.append_event(runtime.run_dir, "worker-recorded", worker=handle, name=name)
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
def _budget_stopped_from_record(record: dict[str, Any]) -> BudgetStopped:
|
|
996
|
+
error = record.get("error") or {}
|
|
997
|
+
return BudgetStopped(
|
|
998
|
+
str(error.get("message") or "budget stopped"),
|
|
999
|
+
short=error.get("short"), reason=error.get("reason"),
|
|
1000
|
+
phase=error.get("phase"),
|
|
1001
|
+
)
|
|
1002
|
+
|
|
1003
|
+
|
|
1004
|
+
# Journal `error.type` values that mean "a WorkerApiError of some subclass".
|
|
1005
|
+
# Listed by name because a journal written by an older ccx must still replay;
|
|
1006
|
+
# the exact subclass is then rebuilt from the stored flags, not from the name.
|
|
1007
|
+
_API_ERROR_TYPES = frozenset({
|
|
1008
|
+
"WorkerApiError", "WorkerRateLimited", "WorkerApiTransient",
|
|
1009
|
+
"WorkerApiPermanent", "WorkerOverloaded",
|
|
1010
|
+
})
|
|
1011
|
+
|
|
1012
|
+
|
|
1013
|
+
def _api_error_fields(error: WorkerApiError | None) -> dict[str, Any] | None:
|
|
1014
|
+
"""Flatten a typed API error for the journal, or ``None`` when absent."""
|
|
1015
|
+
if error is None:
|
|
1016
|
+
return None
|
|
1017
|
+
return {
|
|
1018
|
+
"type": type(error).__name__, "message": str(error),
|
|
1019
|
+
"short": error.short, "kind": error.kind, "status": error.status,
|
|
1020
|
+
"transient": error.transient,
|
|
1021
|
+
"rateLimited": isinstance(error, WorkerRateLimited),
|
|
1022
|
+
"requestId": error.request_id, "detail": error.message,
|
|
1023
|
+
**({"resetText": error.reset_text}
|
|
1024
|
+
if isinstance(error, WorkerRateLimited) else {}),
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
|
|
1028
|
+
def _api_error_from_fields(fields: Any) -> WorkerApiError | None:
|
|
1029
|
+
"""Rebuild the exception from its durable classification flags."""
|
|
1030
|
+
if not isinstance(fields, dict):
|
|
1031
|
+
return None
|
|
1032
|
+
rate_limited = bool(fields.get("rateLimited"))
|
|
1033
|
+
return build_worker_api_error(
|
|
1034
|
+
str(fields.get("message") or "Claude API error"),
|
|
1035
|
+
short=fields.get("short"), kind=fields.get("kind"),
|
|
1036
|
+
status=fields.get("status"),
|
|
1037
|
+
transient=bool(fields.get("transient")),
|
|
1038
|
+
rate_limited=rate_limited,
|
|
1039
|
+
request_id=fields.get("requestId"), detail=fields.get("detail"),
|
|
1040
|
+
reset_text=fields.get("resetText"),
|
|
1041
|
+
)
|
|
1042
|
+
|
|
1043
|
+
|
|
1044
|
+
def _stopped_error_from_record(record: dict[str, Any]) -> CcxError:
|
|
1045
|
+
"""Rebuild the error a `stopped` operation died on, for replay.
|
|
1046
|
+
|
|
1047
|
+
Resume must re-raise the same condition it stopped on, typed as it was
|
|
1048
|
+
originally: a controller that branches on ``WorkerRateLimited`` has to see
|
|
1049
|
+
it again on the second run, not a flattened stand-in.
|
|
1050
|
+
"""
|
|
1051
|
+
error = record.get("error") or {}
|
|
1052
|
+
kind = error.get("type")
|
|
1053
|
+
message = str(error.get("message") or "Claude API error")
|
|
1054
|
+
if kind == "CompactFailed":
|
|
1055
|
+
return CompactFailed(
|
|
1056
|
+
message, short=error.get("short"), state=error.get("state"),
|
|
1057
|
+
operation_id=error.get("operationId"),
|
|
1058
|
+
api_error=_api_error_from_fields(error.get("apiError")),
|
|
1059
|
+
)
|
|
1060
|
+
if kind == "CreatePartialFailure":
|
|
1061
|
+
result = record.get("result") or {}
|
|
1062
|
+
cause = error.get("causeType")
|
|
1063
|
+
typed_cause = (
|
|
1064
|
+
_budget_stopped_from_record(record) if cause == "BudgetStopped" else
|
|
1065
|
+
_api_error_from_fields(error.get("apiError"))
|
|
1066
|
+
if cause in _API_ERROR_TYPES else None
|
|
1067
|
+
)
|
|
1068
|
+
return CreatePartialFailure(
|
|
1069
|
+
message, info={
|
|
1070
|
+
"short": result.get("short") or error.get("short"),
|
|
1071
|
+
"worker_id": result.get("worker_id") or error.get("workerId"),
|
|
1072
|
+
"worker": result.get("worker") or error.get("worker"),
|
|
1073
|
+
"engine": result.get("engine") or error.get("engine") or "claude",
|
|
1074
|
+
},
|
|
1075
|
+
phase=error.get("phase") or "initial-delivery",
|
|
1076
|
+
delivery_id=error.get("deliveryId"),
|
|
1077
|
+
delivery_status=error.get("deliveryStatus"),
|
|
1078
|
+
notification_intent=bool(error.get("notificationIntent")),
|
|
1079
|
+
cause=typed_cause,
|
|
1080
|
+
)
|
|
1081
|
+
if kind not in _API_ERROR_TYPES:
|
|
1082
|
+
return _budget_stopped_from_record(record)
|
|
1083
|
+
return _api_error_from_fields(error) or _budget_stopped_from_record(record)
|
|
1084
|
+
|
|
1085
|
+
|
|
1086
|
+
def _stopped_create_result(client: Any, error: CcxError,
|
|
1087
|
+
kwargs: dict[str, Any], operation_id: str, *,
|
|
1088
|
+
method: str = "create") -> dict[str, Any] | None:
|
|
1089
|
+
"""Record only a positively ACTIVE original worker, never a short alias.
|
|
1090
|
+
|
|
1091
|
+
A partial create may preserve an unconfirmed claimed UUID. Its typed error
|
|
1092
|
+
is durable, but that claim is NOT evidence of native activation or success.
|
|
1093
|
+
"""
|
|
1094
|
+
if not isinstance(error, CreatePartialFailure):
|
|
1095
|
+
return None
|
|
1096
|
+
if (error.info.get("activation_state") != "confirmed"
|
|
1097
|
+
or error.phase not in {"initial-delivery", "wait-report", "notification-registration"}):
|
|
1098
|
+
return None
|
|
1099
|
+
result = {"worker": error.worker, "worker_id": error.worker_id,
|
|
1100
|
+
"engine": error.engine, "short": error.worker_id[:8],
|
|
1101
|
+
"cwd": error.info.get("cwd"), "report": None}
|
|
1102
|
+
owner = _verified_created_owner(client, result, kwargs, operation_id, method=method)
|
|
1103
|
+
record = client._uuid_store.record(owner.worker)
|
|
1104
|
+
lifecycle = record.get("lifecycle") or {}
|
|
1105
|
+
result["state"] = lifecycle.get("state")
|
|
1106
|
+
result["native_state"] = (record.get("native") or {}).get("nativeState")
|
|
1107
|
+
return result
|
|
1108
|
+
|
|
1109
|
+
|
|
1110
|
+
def _worker_call_fingerprint_value(
|
|
1111
|
+
args: tuple[Any, ...], kwargs: dict[str, Any],
|
|
1112
|
+
) -> dict[str, Any]:
|
|
1113
|
+
stable_kwargs = dict(kwargs)
|
|
1114
|
+
variables = stable_kwargs.get("variables")
|
|
1115
|
+
if isinstance(variables, Mapping):
|
|
1116
|
+
stable_kwargs["variables"] = {
|
|
1117
|
+
"mode": "live",
|
|
1118
|
+
"names": sorted(str(name) for name in variables),
|
|
1119
|
+
}
|
|
1120
|
+
variable_source = stable_kwargs.get("variables_from")
|
|
1121
|
+
if variable_source is not None:
|
|
1122
|
+
stable_kwargs["variables_from"] = _worker_handle(variable_source)
|
|
1123
|
+
return {
|
|
1124
|
+
"args": _stable_call_value(args),
|
|
1125
|
+
"kwargs": _stable_call_value(stable_kwargs),
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
|
|
1129
|
+
def _raise_live_state_lost(record: dict[str, Any], operation_id: str) -> None:
|
|
1130
|
+
metadata = record.get("metadata") or {}
|
|
1131
|
+
names = metadata.get("liveVariables")
|
|
1132
|
+
if not isinstance(names, list) or not names:
|
|
1133
|
+
return
|
|
1134
|
+
rendered = ", ".join(str(name) for name in names)
|
|
1135
|
+
raise ControllerLiveStateLost(
|
|
1136
|
+
"worker operation bound live Python variables "
|
|
1137
|
+
f"({rendered}) in a prior controller attempt; CCX will not redeliver "
|
|
1138
|
+
"the worker turn or reconstruct that object graph. Complete the branch "
|
|
1139
|
+
"that consumes live state inside the same successful task/attempt and "
|
|
1140
|
+
"return the JSON-compatible result needed for replay.",
|
|
1141
|
+
operation_id=operation_id,
|
|
1142
|
+
)
|
|
1143
|
+
|
|
1144
|
+
|
|
1145
|
+
def _first_live_binding_committed(client: Any, record: dict, worker_id: str) -> bool:
|
|
1146
|
+
names = (record.get("metadata") or {}).get("liveVariables")
|
|
1147
|
+
if not isinstance(names, list) or not names:
|
|
1148
|
+
return False
|
|
1149
|
+
from ccx.store.identity import UnknownWorker
|
|
1150
|
+
from ccx.store.root import _read_json
|
|
1151
|
+
ctx = client._uuid_store
|
|
1152
|
+
try:
|
|
1153
|
+
owner = ctx.resolve(worker_id)
|
|
1154
|
+
except UnknownWorker:
|
|
1155
|
+
return False
|
|
1156
|
+
try:
|
|
1157
|
+
with ctx.lease():
|
|
1158
|
+
with ctx._gen("variables", "bindings", owner.engine) as directory:
|
|
1159
|
+
prior = _read_json(directory, owner.worker_id + ".json")
|
|
1160
|
+
except FileNotFoundError:
|
|
1161
|
+
return False
|
|
1162
|
+
return (isinstance(prior, dict) and prior.get("schemaVersion") == 1
|
|
1163
|
+
and prior.get("names") == sorted(names))
|
|
1164
|
+
|
|
1165
|
+
|
|
1166
|
+
def observe_worker_call(
|
|
1167
|
+
client: Any,
|
|
1168
|
+
method: str,
|
|
1169
|
+
function: Callable[..., Any],
|
|
1170
|
+
args: tuple[Any, ...],
|
|
1171
|
+
kwargs: dict[str, Any],
|
|
1172
|
+
) -> Any:
|
|
1173
|
+
runtime = _active_runtime.get()
|
|
1174
|
+
if runtime is None or _suspend_worker_hook.get():
|
|
1175
|
+
return function(client, *args, **kwargs)
|
|
1176
|
+
if method not in _MUTATING_WORKER_METHODS:
|
|
1177
|
+
return function(client, *args, **kwargs)
|
|
1178
|
+
kwargs = dict(kwargs)
|
|
1179
|
+
task_context = _active_task.get()
|
|
1180
|
+
scope, number, directory = runtime.next_operation(task_context)
|
|
1181
|
+
operation_id = f"{scope}:{number:04d}:{method}"
|
|
1182
|
+
path = directory / f"{number:04d}-{method}.json"
|
|
1183
|
+
existing_record = store.read_json(path) if path.exists() else None
|
|
1184
|
+
# A positional journal, not a display name, owns the UUID. Use it on
|
|
1185
|
+
# every lost-return retry even when the caller did not specify an ID.
|
|
1186
|
+
supplied_create_id = kwargs.get("worker_id") if method in _CREATING_WORKER_METHODS else None
|
|
1187
|
+
committed_create_id = (
|
|
1188
|
+
(existing_record.get("metadata") or {}).get("workerId")
|
|
1189
|
+
if method in _CREATING_WORKER_METHODS and existing_record is not None else None
|
|
1190
|
+
)
|
|
1191
|
+
if method not in _CREATING_WORKER_METHODS:
|
|
1192
|
+
# Reserve the ordinal before resolving a changing worker target: a
|
|
1193
|
+
# caught lookup refusal must not displace a later accepted operation.
|
|
1194
|
+
worker = args[0] if args else kwargs.get("worker")
|
|
1195
|
+
owner = client._uuid_owner(worker)
|
|
1196
|
+
if args:
|
|
1197
|
+
args = (owner.worker, *args[1:])
|
|
1198
|
+
else:
|
|
1199
|
+
kwargs["worker"] = owner.worker
|
|
1200
|
+
if existing_record is not None and (
|
|
1201
|
+
(existing_record.get("metadata") or {}).get("worker") != owner.worker
|
|
1202
|
+
):
|
|
1203
|
+
raise ControllerOperationIndeterminate(
|
|
1204
|
+
f"{method} intent has no proven original worker: {operation_id}",
|
|
1205
|
+
operation_id=operation_id,
|
|
1206
|
+
)
|
|
1207
|
+
if method in {"send", "steer"}:
|
|
1208
|
+
caps = kwargs.get("caps")
|
|
1209
|
+
if caps is not None:
|
|
1210
|
+
from ccx.workers import _check_caps
|
|
1211
|
+
_check_caps(caps, engine=owner.engine)
|
|
1212
|
+
supplied_delivery_id = kwargs.get("delivery_id")
|
|
1213
|
+
stored_delivery_id = ((existing_record.get("metadata") or {}).get("deliveryId")
|
|
1214
|
+
if existing_record is not None else None)
|
|
1215
|
+
if stored_delivery_id is not None and supplied_delivery_id is not None and supplied_delivery_id != stored_delivery_id:
|
|
1216
|
+
raise ControllerReplayMismatch(f"original {method} delivery token changed during replay: {operation_id}")
|
|
1217
|
+
kwargs["delivery_id"] = stored_delivery_id or supplied_delivery_id or str(uuid.uuid4())
|
|
1218
|
+
from ccx.claude import delivery as claude_delivery
|
|
1219
|
+
from ccx.codex import delivery as codex_delivery
|
|
1220
|
+
validator = claude_delivery.valid_delivery_id if owner.engine == "claude" else codex_delivery.valid_delivery_id
|
|
1221
|
+
if not validator(kwargs["delivery_id"]):
|
|
1222
|
+
raise InvalidRequest(f"controller {method} delivery_id must be a bounded native token")
|
|
1223
|
+
if existing_record is not None and stored_delivery_id != kwargs["delivery_id"]:
|
|
1224
|
+
raise ControllerOperationIndeterminate(
|
|
1225
|
+
f"{method} intent has no proven original delivery token: {operation_id}",
|
|
1226
|
+
operation_id=operation_id,
|
|
1227
|
+
)
|
|
1228
|
+
if method == "compact" and owner.engine == "claude" and not kwargs.get("operation_id"):
|
|
1229
|
+
persisted_operation_id = (
|
|
1230
|
+
(existing_record.get("metadata") or {}).get("operationId")
|
|
1231
|
+
if existing_record is not None else None
|
|
1232
|
+
)
|
|
1233
|
+
kwargs["operation_id"] = persisted_operation_id or str(uuid.uuid4())
|
|
1234
|
+
|
|
1235
|
+
def require_original_result(value: Any) -> None:
|
|
1236
|
+
# The native methods own receipt proof; the controller must not
|
|
1237
|
+
# publish/replay a result for a different worker or operation token.
|
|
1238
|
+
if (not isinstance(value, dict) or value.get("worker") != owner.worker
|
|
1239
|
+
or value.get("worker_id", owner.worker_id) != owner.worker_id
|
|
1240
|
+
or value.get("engine", owner.engine) != owner.engine
|
|
1241
|
+
or (method in {"send", "steer"}
|
|
1242
|
+
and value.get("delivery_id", kwargs["delivery_id"]) != kwargs["delivery_id"])
|
|
1243
|
+
or (method == "compact" and owner.engine == "claude"
|
|
1244
|
+
and value.get("operation_id") != kwargs["operation_id"])):
|
|
1245
|
+
raise ControllerOperationIndeterminate(
|
|
1246
|
+
f"{method} result disagrees with its original worker/token: {operation_id}",
|
|
1247
|
+
operation_id=operation_id,
|
|
1248
|
+
)
|
|
1249
|
+
call_fingerprint = _fingerprint(
|
|
1250
|
+
_worker_call_fingerprint_value(args, kwargs),
|
|
1251
|
+
f"WorkerClient.{method} call",
|
|
1252
|
+
)
|
|
1253
|
+
if method in _CREATING_WORKER_METHODS:
|
|
1254
|
+
from ccx.ops.creation import new_worker_id
|
|
1255
|
+
kwargs["worker_id"] = committed_create_id or supplied_create_id or new_worker_id()
|
|
1256
|
+
if existing_record is not None:
|
|
1257
|
+
record = existing_record
|
|
1258
|
+
if record.get("callSha256") != call_fingerprint:
|
|
1259
|
+
raise ControllerReplayMismatch(f"worker operation changed during replay: {operation_id}")
|
|
1260
|
+
state = record.get("state")
|
|
1261
|
+
if (state in {"intent", "indeterminate", "rejected"}
|
|
1262
|
+
and method in _CREATING_WORKER_METHODS
|
|
1263
|
+
and committed_create_id
|
|
1264
|
+
and _first_live_binding_committed(client, record, committed_create_id)):
|
|
1265
|
+
_raise_live_state_lost(record, operation_id)
|
|
1266
|
+
if state == "accepted":
|
|
1267
|
+
_raise_live_state_lost(record, operation_id)
|
|
1268
|
+
result = record.get("result")
|
|
1269
|
+
if method in _CREATING_WORKER_METHODS:
|
|
1270
|
+
owner = _verified_created_owner(
|
|
1271
|
+
client, result, kwargs, operation_id, method=method,
|
|
1272
|
+
)
|
|
1273
|
+
_register_worker(runtime, client, owner, result, kwargs, task_context, operation_id)
|
|
1274
|
+
else:
|
|
1275
|
+
require_original_result(result)
|
|
1276
|
+
return result
|
|
1277
|
+
if state == "stopped":
|
|
1278
|
+
_raise_live_state_lost(record, operation_id)
|
|
1279
|
+
result = record.get("result")
|
|
1280
|
+
if method in _CREATING_WORKER_METHODS and isinstance(result, dict):
|
|
1281
|
+
owner = _verified_created_owner(
|
|
1282
|
+
client, result, kwargs, operation_id, method=method,
|
|
1283
|
+
)
|
|
1284
|
+
_register_worker(runtime, client, owner, result, kwargs, task_context, operation_id)
|
|
1285
|
+
raise _stopped_error_from_record(record)
|
|
1286
|
+
if state == "rejected":
|
|
1287
|
+
pass
|
|
1288
|
+
elif state not in {"intent", "indeterminate"}:
|
|
1289
|
+
raise ControllerOperationIndeterminate(
|
|
1290
|
+
f"worker operation has unknown journal state {state!r}: {operation_id}",
|
|
1291
|
+
operation_id=operation_id,
|
|
1292
|
+
)
|
|
1293
|
+
elif method in _CREATING_WORKER_METHODS:
|
|
1294
|
+
if not committed_create_id:
|
|
1295
|
+
raise ControllerOperationIndeterminate(
|
|
1296
|
+
f"old worker create intent has no replayable UUID: {operation_id}",
|
|
1297
|
+
operation_id=operation_id,
|
|
1298
|
+
)
|
|
1299
|
+
# Fall through: same worker UUID and create:initial receipt reconcile
|
|
1300
|
+
# both activation and first delivery without matching any names.
|
|
1301
|
+
if state in {"intent", "indeterminate"} and method == "compact" and kwargs.get("operation_id"):
|
|
1302
|
+
token = _suspend_worker_hook.set(True)
|
|
1303
|
+
try:
|
|
1304
|
+
status = client.compact_status(
|
|
1305
|
+
args[0] if args else kwargs.get("worker"), kwargs["operation_id"]
|
|
1306
|
+
)
|
|
1307
|
+
finally:
|
|
1308
|
+
_suspend_worker_hook.reset(token)
|
|
1309
|
+
if status.get("operation_status") == "succeeded":
|
|
1310
|
+
require_original_result(status)
|
|
1311
|
+
record.update(state="accepted", acceptedAt=store.now_ms(), result=status, reconciled=True)
|
|
1312
|
+
store.write_json_atomic(path, record)
|
|
1313
|
+
return status
|
|
1314
|
+
if state != "rejected" and method not in _CREATING_WORKER_METHODS:
|
|
1315
|
+
raise ControllerOperationIndeterminate(
|
|
1316
|
+
f"worker operation may have landed and will not be retried: {operation_id}",
|
|
1317
|
+
operation_id=operation_id,
|
|
1318
|
+
)
|
|
1319
|
+
|
|
1320
|
+
record = {
|
|
1321
|
+
"schemaVersion": 1, "id": operation_id, "method": method,
|
|
1322
|
+
"state": "intent", "createdAt": store.now_ms(),
|
|
1323
|
+
"callSha256": call_fingerprint, "metadata": _operation_metadata(method, args, kwargs),
|
|
1324
|
+
"result": None, "error": None,
|
|
1325
|
+
}
|
|
1326
|
+
if existing_record is None or existing_record.get("state") == "rejected":
|
|
1327
|
+
# A safe rejection permits another dispatch, not reuse of its durable
|
|
1328
|
+
# rejection. Fence the retry before it can land and lose its reply.
|
|
1329
|
+
if existing_record is not None:
|
|
1330
|
+
record["createdAt"] = existing_record["createdAt"]
|
|
1331
|
+
store.write_json_atomic(path, record)
|
|
1332
|
+
store.append_event(runtime.run_dir, "worker-operation-intent", operationId=operation_id, method=method)
|
|
1333
|
+
else:
|
|
1334
|
+
record = existing_record
|
|
1335
|
+
try:
|
|
1336
|
+
# One public mutation owns one replay slot. Nested calls should not
|
|
1337
|
+
# create another slot when restoring an accepted outer result.
|
|
1338
|
+
token = _suspend_worker_hook.set(True)
|
|
1339
|
+
try:
|
|
1340
|
+
result = function(client, *args, **kwargs)
|
|
1341
|
+
finally:
|
|
1342
|
+
_suspend_worker_hook.reset(token)
|
|
1343
|
+
# Never persist accepted before proving its original journaled worker.
|
|
1344
|
+
if method in _CREATING_WORKER_METHODS:
|
|
1345
|
+
created_owner = _verified_created_owner(
|
|
1346
|
+
client, result, kwargs, operation_id, method=method,
|
|
1347
|
+
)
|
|
1348
|
+
else:
|
|
1349
|
+
require_original_result(result)
|
|
1350
|
+
except BaseException as error:
|
|
1351
|
+
budget_stopped = isinstance(error, BudgetStopped)
|
|
1352
|
+
partial = isinstance(error, CreatePartialFailure)
|
|
1353
|
+
partial_cause = error.cause if partial else None
|
|
1354
|
+
# An API error journals like a budget stop rather than as a generic
|
|
1355
|
+
# failure. Generic failures become `indeterminate`, which on resume
|
|
1356
|
+
# raises ControllerOperationIndeterminate and loses the real reason —
|
|
1357
|
+
# exactly the wrong answer for a run halted by capacity or a rate limit.
|
|
1358
|
+
# `stopped` keeps the typed error and re-raises it on resume instead.
|
|
1359
|
+
#
|
|
1360
|
+
# Replay stays off the table on purpose: a turn killed by an API error
|
|
1361
|
+
# had already been delivered, so re-running the call would double-send
|
|
1362
|
+
# the prompt. A controller that wants to wait out a rate limit catches
|
|
1363
|
+
# the exception INSIDE its task, where it knows that is safe.
|
|
1364
|
+
stopped = budget_stopped or partial or isinstance(
|
|
1365
|
+
error, (WorkerApiError, CompactFailed),
|
|
1366
|
+
)
|
|
1367
|
+
# The worker may exist even though the call raised — recover it for an
|
|
1368
|
+
# API error exactly as for a budget stop, or the run leaks a live
|
|
1369
|
+
# worker while freeing its name.
|
|
1370
|
+
expected_engine = kwargs.get("engine") if method == "create" else (
|
|
1371
|
+
error.engine if partial else None
|
|
1372
|
+
)
|
|
1373
|
+
if partial and (error.worker_id != kwargs.get("worker_id")
|
|
1374
|
+
or error.engine != expected_engine
|
|
1375
|
+
or error.worker != f"{expected_engine}:{kwargs.get('worker_id')}"):
|
|
1376
|
+
record.update(state="indeterminate", completedAt=store.now_ms(),
|
|
1377
|
+
error={"type": type(error).__name__, "message": str(error),
|
|
1378
|
+
"workerId": error.worker_id, "worker": error.worker})
|
|
1379
|
+
store.write_json_atomic(path, record)
|
|
1380
|
+
raise ControllerOperationIndeterminate(
|
|
1381
|
+
f"partial create returned foreign original worker identity: {operation_id}",
|
|
1382
|
+
operation_id=operation_id,
|
|
1383
|
+
) from error
|
|
1384
|
+
stopped_result = (
|
|
1385
|
+
_stopped_create_result(client, error, kwargs, operation_id, method=method)
|
|
1386
|
+
if stopped and method in _CREATING_WORKER_METHODS else None
|
|
1387
|
+
)
|
|
1388
|
+
record.update(
|
|
1389
|
+
state=("stopped" if stopped else
|
|
1390
|
+
"rejected" if isinstance(error, _SAFE_REJECTIONS) else "indeterminate"),
|
|
1391
|
+
completedAt=store.now_ms(),
|
|
1392
|
+
error={
|
|
1393
|
+
"type": type(error).__name__, "message": str(error),
|
|
1394
|
+
**({
|
|
1395
|
+
"short": error.short, "reason": error.reason,
|
|
1396
|
+
"phase": error.phase,
|
|
1397
|
+
} if budget_stopped else {}),
|
|
1398
|
+
**(_api_error_fields(error)
|
|
1399
|
+
if isinstance(error, WorkerApiError) else {}),
|
|
1400
|
+
**({
|
|
1401
|
+
"short": error.short, "state": error.state,
|
|
1402
|
+
"operationId": error.operation_id,
|
|
1403
|
+
"apiError": _api_error_fields(error.api_error),
|
|
1404
|
+
} if isinstance(error, CompactFailed) else {}),
|
|
1405
|
+
**({
|
|
1406
|
+
"workerId": error.worker_id, "worker": error.worker,
|
|
1407
|
+
"engine": error.engine, "phase": error.phase,
|
|
1408
|
+
"deliveryId": error.delivery_id,
|
|
1409
|
+
"deliveryStatus": error.delivery_status,
|
|
1410
|
+
"notificationIntent": error.notification_intent,
|
|
1411
|
+
"causeType": type(partial_cause).__name__ if partial_cause else None,
|
|
1412
|
+
"apiError": _api_error_fields(partial_cause)
|
|
1413
|
+
if isinstance(partial_cause, WorkerApiError) else None,
|
|
1414
|
+
"reason": partial_cause.reason if isinstance(partial_cause, BudgetStopped) else None,
|
|
1415
|
+
"short": error.short,
|
|
1416
|
+
} if partial else {}),
|
|
1417
|
+
},
|
|
1418
|
+
result=stopped_result,
|
|
1419
|
+
)
|
|
1420
|
+
store.write_json_atomic(path, record)
|
|
1421
|
+
if method in _CREATING_WORKER_METHODS and stopped and isinstance(stopped_result, dict):
|
|
1422
|
+
stopped_owner = _verified_created_owner(
|
|
1423
|
+
client, stopped_result, kwargs, operation_id, method=method,
|
|
1424
|
+
)
|
|
1425
|
+
_register_worker(runtime, client, stopped_owner, stopped_result, kwargs, task_context, operation_id)
|
|
1426
|
+
store.append_event(
|
|
1427
|
+
runtime.run_dir, "worker-operation-error", operationId=operation_id,
|
|
1428
|
+
state=record["state"], errorType=type(error).__name__,
|
|
1429
|
+
)
|
|
1430
|
+
raise
|
|
1431
|
+
record.update(state="accepted", acceptedAt=store.now_ms(), result=result)
|
|
1432
|
+
store.write_json_atomic(path, record)
|
|
1433
|
+
if method in _CREATING_WORKER_METHODS:
|
|
1434
|
+
_register_worker(runtime, client, created_owner, result, kwargs, task_context, operation_id)
|
|
1435
|
+
store.append_event(runtime.run_dir, "worker-operation-accepted", operationId=operation_id, method=method)
|
|
1436
|
+
return result
|
|
1437
|
+
|
|
1438
|
+
|
|
1439
|
+
__all__ = [
|
|
1440
|
+
"ControllerInput",
|
|
1441
|
+
"Task",
|
|
1442
|
+
"TaskFuture",
|
|
1443
|
+
"TaskOutput",
|
|
1444
|
+
"controller",
|
|
1445
|
+
"current",
|
|
1446
|
+
"observe_worker_call",
|
|
1447
|
+
"task",
|
|
1448
|
+
"task_output",
|
|
1449
|
+
]
|