ccx-native 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +241 -4
- package/bin/ccx +55 -2
- 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 +39 -7
- 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
package/ccx/workers.py
ADDED
|
@@ -0,0 +1,2081 @@
|
|
|
1
|
+
"""Engine-neutral client for managed workers.
|
|
2
|
+
|
|
3
|
+
Both engine clients pin the same sealed active generation and resolve full
|
|
4
|
+
CCX worker UUIDs independently of provider-native identifiers."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
import ccx.cli.commands.read as commands_read
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import signal
|
|
12
|
+
import time
|
|
13
|
+
from contextlib import ExitStack, contextmanager
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from functools import wraps
|
|
16
|
+
from typing import Callable, Iterator
|
|
17
|
+
|
|
18
|
+
from ccx.codex import evidence as codex_evidence
|
|
19
|
+
from ccx.codex import records as codex_records
|
|
20
|
+
from ccx.ops import event_feed
|
|
21
|
+
from ccx.ops import turn_reports
|
|
22
|
+
from ccx.ops import terminate
|
|
23
|
+
from ccx.system import oslayer
|
|
24
|
+
from ccx.ops import conversation as _conv
|
|
25
|
+
from .client import CcxClient
|
|
26
|
+
from ccx.codex.client import CodexClient
|
|
27
|
+
from ccx.codex.types import rate_limit_windows, window_label
|
|
28
|
+
from .constants import (
|
|
29
|
+
DEFAULT_IDLE_TIMEOUT_S,
|
|
30
|
+
STEER_OBSERVE_DEFAULT_S,
|
|
31
|
+
S,
|
|
32
|
+
)
|
|
33
|
+
from ccx.codex.read import pending_input as _codex_pending
|
|
34
|
+
from ccx.claude.context import current_context, public_context, read_claude_observation
|
|
35
|
+
from .errors import (
|
|
36
|
+
CapabilityUnsupported,
|
|
37
|
+
CcxError,
|
|
38
|
+
DeliveryRejected,
|
|
39
|
+
InvalidRequest,
|
|
40
|
+
SessionNotFound,
|
|
41
|
+
SessionUnavailable,
|
|
42
|
+
SessionWaiting,
|
|
43
|
+
TurnIncomplete,
|
|
44
|
+
worker_not_found,
|
|
45
|
+
)
|
|
46
|
+
from ccx.store.liveness import pid_alive, recorded_host_loaded
|
|
47
|
+
from ccx.store.paths import sock_path, statusline_json_path
|
|
48
|
+
from ccx.store.records import merge_lifecycle
|
|
49
|
+
from ccx.rpc.rpc import _respawn, _respawn_failure_message, _rpc
|
|
50
|
+
from ccx.ops.semantics import KIND_ELICITATION, pending_input_kind
|
|
51
|
+
from ccx.claude.usage import fetch_usage, usage_summary
|
|
52
|
+
from ccx.store.worker_state import (
|
|
53
|
+
RUNTIME_INCONCLUSIVE,
|
|
54
|
+
normalize_native_state,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
_ENGINES = ("claude", "codex")
|
|
58
|
+
|
|
59
|
+
# Failures a per-engine step may raise that must NOT take down the other engine
|
|
60
|
+
# in a cross-engine list/usage: typed ccx errors plus the ordinary ways a
|
|
61
|
+
# corrupt record or an unexpected provider payload blows up. Deliberately not
|
|
62
|
+
# BaseException — a KeyboardInterrupt is not a provider failure.
|
|
63
|
+
_PROVIDER_FAILURES = (CcxError, OSError, RuntimeError, ValueError, KeyError, TypeError,
|
|
64
|
+
AttributeError)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _engine_error(error: BaseException) -> dict:
|
|
68
|
+
return {"error": type(error).__name__, "message": str(error),
|
|
69
|
+
"exit_code": getattr(error, "exit_code", 1)}
|
|
70
|
+
|
|
71
|
+
_CAP_KEYS = ("5h", "7d", "opus")
|
|
72
|
+
|
|
73
|
+
# States in which a worker is settled enough to stop without forcing.
|
|
74
|
+
_SETTLED = ("idle", "stopped", "failed", "unavailable")
|
|
75
|
+
# WaitResult background-task fields carried through ``wait`` for Claude.
|
|
76
|
+
_BACKGROUND_FACT_FIELDS = (
|
|
77
|
+
"background_tasks", "background_task_count",
|
|
78
|
+
"running_background_task_count", "open_background_task_count",
|
|
79
|
+
"active_background_task_count", "stale_background_task_count",
|
|
80
|
+
"background_tasks_truncated", "background_task_limit",
|
|
81
|
+
"background_task_hint",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# Normalized states in the order a recap summary counts them: lifecycle order,
|
|
85
|
+
# so the one-line header reads the same way every time.
|
|
86
|
+
_RECAP_STATES = ("starting", "idle", "busy", "waiting", "stopped", "failed",
|
|
87
|
+
"unavailable")
|
|
88
|
+
|
|
89
|
+
# Sentinel: "read the state yourself" vs an explicitly supplied one (which may
|
|
90
|
+
# legitimately be None).
|
|
91
|
+
_UNSET = object()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# --- handles, records, budgets ----------------------------------------------
|
|
95
|
+
|
|
96
|
+
def _handle(worker) -> str:
|
|
97
|
+
"""Coerce a handle string or any facade result dict to a handle string."""
|
|
98
|
+
if isinstance(worker, dict):
|
|
99
|
+
worker = worker.get("worker")
|
|
100
|
+
if not isinstance(worker, str):
|
|
101
|
+
raise InvalidRequest(
|
|
102
|
+
"worker must be a handle string like 'claude:966a8a1b' "
|
|
103
|
+
"(or a result dict carrying one)"
|
|
104
|
+
)
|
|
105
|
+
return worker
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _split(worker) -> tuple[str, str, str]:
|
|
109
|
+
"""``"claude:966a8a1b"`` -> ``("claude:966a8a1b", "claude", "966a8a1b")``."""
|
|
110
|
+
handle = _handle(worker)
|
|
111
|
+
engine, sep, short = handle.partition(":")
|
|
112
|
+
if not sep or engine not in _ENGINES or not short:
|
|
113
|
+
raise InvalidRequest(
|
|
114
|
+
f"invalid worker handle {handle!r}; expected "
|
|
115
|
+
"'claude:<short>' or 'codex:<short>'"
|
|
116
|
+
)
|
|
117
|
+
return handle, engine, short
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _state(native) -> str:
|
|
121
|
+
return normalize_native_state(native)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _public_created_result(store, raw: dict, *, fork: bool = False) -> dict:
|
|
125
|
+
"""Main's neutral create/fork shape from the child's latest recorded lifecycle."""
|
|
126
|
+
try:
|
|
127
|
+
record = store.record(raw["worker"])
|
|
128
|
+
except Exception as error:
|
|
129
|
+
# The original native effect may already have landed. Keep its full UUID
|
|
130
|
+
# and cause rather than returning a stale activation snapshot or retrying.
|
|
131
|
+
from .errors import CreatePartialFailure
|
|
132
|
+
raise CreatePartialFailure(
|
|
133
|
+
"worker created but public projection is incomplete; retain worker_id",
|
|
134
|
+
info=raw, phase="inspect", cause=error,
|
|
135
|
+
) from error
|
|
136
|
+
native = (record.get("lifecycle") or {}).get("state")
|
|
137
|
+
report = raw.get("report")
|
|
138
|
+
reply = (report.get("text") if isinstance(report, dict)
|
|
139
|
+
and report.get("status") == "completed"
|
|
140
|
+
and isinstance(report.get("text"), str) else None)
|
|
141
|
+
public = {
|
|
142
|
+
"worker": raw["worker"], "worker_id": raw["worker_id"],
|
|
143
|
+
"engine": raw["engine"], "short": raw["short"],
|
|
144
|
+
"state": _state(native), "native_state": native,
|
|
145
|
+
"report": report, "reply": reply,
|
|
146
|
+
"created": raw["created"], "reconciled": raw["reconciled"],
|
|
147
|
+
"delivery_id": raw.get("delivery_id"),
|
|
148
|
+
"delivery_status": raw.get("delivery_status"),
|
|
149
|
+
"notification": raw.get("notification"),
|
|
150
|
+
}
|
|
151
|
+
if fork:
|
|
152
|
+
public["forked_from"] = raw["forked_from"]
|
|
153
|
+
return public
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _host_from_loaded(loaded: bool | None) -> str:
|
|
157
|
+
if loaded is True:
|
|
158
|
+
return "ok"
|
|
159
|
+
if loaded is False:
|
|
160
|
+
return "unreachable"
|
|
161
|
+
return "timeout"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# Budget arithmetic is shared with ccx.terminate, which owns the confirmed
|
|
165
|
+
# teardown these same budgets are spent on.
|
|
166
|
+
_deadline = terminate.deadline_for
|
|
167
|
+
_left = terminate.left
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _codex_record(short: str) -> dict:
|
|
173
|
+
rec = codex_records.read_record(short)
|
|
174
|
+
if rec is None:
|
|
175
|
+
raise SessionNotFound(f"no such Codex worker: {short}", short=short)
|
|
176
|
+
return rec
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _background_task_block_for_worker(store, owner) -> dict:
|
|
182
|
+
"""Bound the original background-task facts for facade wait and report."""
|
|
183
|
+
from ccx.ops.common import background_task_block, recorded_background_facts
|
|
184
|
+
|
|
185
|
+
rec = store.record(owner.worker) # mandatory owner/record evidence stays strict
|
|
186
|
+
recorded = rec.get("backgroundTaskSummary")
|
|
187
|
+
if isinstance(recorded, dict):
|
|
188
|
+
return background_task_block(recorded_background_facts(recorded), supported=True)
|
|
189
|
+
if owner.engine != "claude":
|
|
190
|
+
return background_task_block(None, supported=False)
|
|
191
|
+
try:
|
|
192
|
+
facts = CcxClient._uuid_background_task_facts_owned(store, owner, rec)
|
|
193
|
+
except (SessionUnavailable, OSError) as exc:
|
|
194
|
+
store.revalidate() # never hide a generation change as missing task facts
|
|
195
|
+
return {"background_tasks_supported": True,
|
|
196
|
+
"background_tasks_available": False,
|
|
197
|
+
"background_task_note": f"background tasks unknown: {exc}",
|
|
198
|
+
"background_task_summary": rec.get("backgroundTaskSummary")}
|
|
199
|
+
if "backgroundTaskSummary" in rec:
|
|
200
|
+
facts["background_task_summary"] = rec["backgroundTaskSummary"]
|
|
201
|
+
return background_task_block(facts, supported=owner.engine == "claude")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _check_caps(caps, *, engine: str | None = None) -> None:
|
|
205
|
+
"""Validate a per-turn quota cap before anything is delivered.
|
|
206
|
+
|
|
207
|
+
``{"5h"|"7d"|"opus": 0-100}``. Booleans are rejected explicitly: ``True``
|
|
208
|
+
is an ``int`` in Python and would silently arm a 1% cap.
|
|
209
|
+
"""
|
|
210
|
+
if not isinstance(caps, dict) or not caps:
|
|
211
|
+
raise InvalidRequest("caps must be a non-empty dict")
|
|
212
|
+
unknown = set(caps) - set(_CAP_KEYS)
|
|
213
|
+
if unknown:
|
|
214
|
+
raise InvalidRequest(
|
|
215
|
+
f"unknown caps keys {sorted(unknown)}; expected {_CAP_KEYS}"
|
|
216
|
+
)
|
|
217
|
+
if engine == "codex" and "opus" in caps:
|
|
218
|
+
raise InvalidRequest("Codex does not support cap-opus (Claude-only)")
|
|
219
|
+
for key, value in caps.items():
|
|
220
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
221
|
+
raise InvalidRequest(
|
|
222
|
+
f"caps[{key!r}] must be a number between 0 and 100, "
|
|
223
|
+
f"got {value!r}"
|
|
224
|
+
)
|
|
225
|
+
if not 0 <= value <= 100:
|
|
226
|
+
raise InvalidRequest(
|
|
227
|
+
f"caps[{key!r}]={value!r} is outside 0..100"
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def evaluate_quota(usage: dict, limits: dict) -> dict:
|
|
232
|
+
"""Evaluate account quota windows against exact normalized window ids.
|
|
233
|
+
|
|
234
|
+
Unknown or malformed requested windows make ``allowed`` false. The
|
|
235
|
+
function is deliberately policy-free: callers may inspect ``unknown`` and
|
|
236
|
+
choose to fail open, report a checkpoint, or retry later themselves.
|
|
237
|
+
"""
|
|
238
|
+
if not isinstance(usage, dict):
|
|
239
|
+
raise InvalidRequest("usage must be a WorkerClient.usage result dict")
|
|
240
|
+
if not isinstance(limits, dict) or not limits:
|
|
241
|
+
raise InvalidRequest("quota limits must be a non-empty dict")
|
|
242
|
+
normalized_limits: dict[str, float] = {}
|
|
243
|
+
for window_id, limit in limits.items():
|
|
244
|
+
if not isinstance(window_id, str) or not window_id:
|
|
245
|
+
raise InvalidRequest("quota limit ids must be non-empty strings")
|
|
246
|
+
if isinstance(limit, bool) or not isinstance(limit, (int, float)):
|
|
247
|
+
raise InvalidRequest(
|
|
248
|
+
f"quota limit {window_id!r} must be a number between 0 and 100"
|
|
249
|
+
)
|
|
250
|
+
if not 0 <= limit <= 100:
|
|
251
|
+
raise InvalidRequest(
|
|
252
|
+
f"quota limit {window_id!r}={limit!r} is outside 0..100"
|
|
253
|
+
)
|
|
254
|
+
normalized_limits[window_id] = float(limit)
|
|
255
|
+
|
|
256
|
+
raw_windows = usage.get("windows")
|
|
257
|
+
if not isinstance(raw_windows, list):
|
|
258
|
+
raw_windows = []
|
|
259
|
+
by_id: dict[str, list[dict]] = {}
|
|
260
|
+
for item in raw_windows:
|
|
261
|
+
if isinstance(item, dict) and isinstance(item.get("id"), str):
|
|
262
|
+
by_id.setdefault(item["id"], []).append(item)
|
|
263
|
+
|
|
264
|
+
reached: list[str] = []
|
|
265
|
+
unknown: list[str] = []
|
|
266
|
+
windows: list[dict] = []
|
|
267
|
+
reached_resets: list[tuple[datetime, str]] = []
|
|
268
|
+
retry_known = True
|
|
269
|
+
for window_id, limit in normalized_limits.items():
|
|
270
|
+
candidates = by_id.get(window_id, [])
|
|
271
|
+
item = candidates[0] if len(candidates) == 1 else None
|
|
272
|
+
used = item.get("used_percent") if item is not None else None
|
|
273
|
+
if (
|
|
274
|
+
item is None
|
|
275
|
+
or isinstance(used, bool)
|
|
276
|
+
or not isinstance(used, (int, float))
|
|
277
|
+
or not 0 <= used <= 100
|
|
278
|
+
):
|
|
279
|
+
unknown.append(window_id)
|
|
280
|
+
windows.append({
|
|
281
|
+
"id": window_id, "limit_percent": limit,
|
|
282
|
+
"used_percent": None, "resets_at": None, "state": "unknown",
|
|
283
|
+
})
|
|
284
|
+
continue
|
|
285
|
+
used = float(used)
|
|
286
|
+
is_reached = used >= limit
|
|
287
|
+
resets_at = item.get("resets_at")
|
|
288
|
+
reset_time = None
|
|
289
|
+
if isinstance(resets_at, str):
|
|
290
|
+
try:
|
|
291
|
+
reset_time = datetime.fromisoformat(resets_at.replace("Z", "+00:00"))
|
|
292
|
+
if reset_time.tzinfo is None:
|
|
293
|
+
reset_time = None
|
|
294
|
+
except ValueError:
|
|
295
|
+
reset_time = None
|
|
296
|
+
else:
|
|
297
|
+
resets_at = None
|
|
298
|
+
windows.append({
|
|
299
|
+
"id": window_id, "limit_percent": limit,
|
|
300
|
+
"used_percent": used, "resets_at": resets_at,
|
|
301
|
+
"state": "reached" if is_reached else "allowed",
|
|
302
|
+
})
|
|
303
|
+
if is_reached:
|
|
304
|
+
reached.append(window_id)
|
|
305
|
+
if reset_time is None:
|
|
306
|
+
retry_known = False
|
|
307
|
+
else:
|
|
308
|
+
reached_resets.append((reset_time, resets_at))
|
|
309
|
+
retry_at = (
|
|
310
|
+
max(reached_resets, key=lambda item: item[0])[1] if reached and retry_known
|
|
311
|
+
and len(reached_resets) == len(reached) else None
|
|
312
|
+
)
|
|
313
|
+
return {
|
|
314
|
+
"allowed": not reached and not unknown,
|
|
315
|
+
"reached": reached,
|
|
316
|
+
"unknown": unknown,
|
|
317
|
+
"retry_at": retry_at,
|
|
318
|
+
"windows": windows,
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _unsupported(engine: str, capability: str, detail: str) -> CapabilityUnsupported:
|
|
323
|
+
return CapabilityUnsupported(
|
|
324
|
+
f"{engine} does not support {detail}", engine=engine, capability=capability
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _claude_observed_model(rec: dict) -> str | None:
|
|
329
|
+
"""The model Claude actually answered with, from the rendered transcript.
|
|
330
|
+
|
|
331
|
+
Never falls back to the requested model, and never accepts ``<synthetic>``
|
|
332
|
+
(the pseudo-model on API-error replies). Reads the already-rendered document
|
|
333
|
+
so this stays cheap enough for ``status``.
|
|
334
|
+
"""
|
|
335
|
+
path = rec.get("transcriptJson")
|
|
336
|
+
if not path:
|
|
337
|
+
return None
|
|
338
|
+
try:
|
|
339
|
+
with open(path, encoding="utf-8") as handle:
|
|
340
|
+
doc = json.load(handle)
|
|
341
|
+
except (OSError, ValueError):
|
|
342
|
+
return None
|
|
343
|
+
for turn in reversed(doc.get("turns") or []):
|
|
344
|
+
model = turn.get("model")
|
|
345
|
+
if isinstance(model, str) and model and model != "<synthetic>":
|
|
346
|
+
return model
|
|
347
|
+
return None
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _claude_statusline_model(short: str) -> str | None:
|
|
351
|
+
"""Last model Claude reported through the compact statusline sidecar.
|
|
352
|
+
|
|
353
|
+
Unlike context freshness, a settled worker's last observed model does not
|
|
354
|
+
expire. This is a read-only fallback for inventory summaries when the
|
|
355
|
+
cleaned transcript cache has not been rendered yet.
|
|
356
|
+
"""
|
|
357
|
+
try:
|
|
358
|
+
doc = json.loads(statusline_json_path(short).read_text())
|
|
359
|
+
except (OSError, ValueError):
|
|
360
|
+
return None
|
|
361
|
+
model = doc.get("model") if isinstance(doc, dict) else None
|
|
362
|
+
value = model.get("id") if isinstance(model, dict) else None
|
|
363
|
+
return value if isinstance(value, str) and value and value != "<synthetic>" else None
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _summary_context(block: dict | None, *, has_turn: bool, codex: bool) -> dict:
|
|
367
|
+
"""Normalize persisted context without rendering either engine transcript."""
|
|
368
|
+
if not has_turn:
|
|
369
|
+
state = "not_started"
|
|
370
|
+
block = None
|
|
371
|
+
elif not isinstance(block, dict):
|
|
372
|
+
state = "unavailable"
|
|
373
|
+
block = None
|
|
374
|
+
else:
|
|
375
|
+
native_state = block.get("state")
|
|
376
|
+
state = native_state if native_state in {
|
|
377
|
+
"known", "unknown", "pending", "unavailable"
|
|
378
|
+
} else ("known" if block.get("tokens_used") is not None else "unavailable")
|
|
379
|
+
block = block or {}
|
|
380
|
+
return {
|
|
381
|
+
"state": state,
|
|
382
|
+
"tokens_used": block.get("tokens_used"),
|
|
383
|
+
"window_size": block.get("window_size") if codex else None,
|
|
384
|
+
"percent_used": block.get("percent_used") if codex else None,
|
|
385
|
+
"as_of_turn_ref": block.get("as_of_turn_ref"),
|
|
386
|
+
"as_of_ms": block.get("as_of_ms"),
|
|
387
|
+
"reason": block.get("reason"),
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _claude_pending(waiting_for) -> dict | None:
|
|
392
|
+
if not isinstance(waiting_for, dict) or not waiting_for:
|
|
393
|
+
return None
|
|
394
|
+
tool = waiting_for.get("tool")
|
|
395
|
+
questions = waiting_for.get("input") or {}
|
|
396
|
+
questions = (questions.get("questions") or []) if isinstance(questions, dict) else []
|
|
397
|
+
out = {
|
|
398
|
+
"kind": pending_input_kind(waiting_for),
|
|
399
|
+
"request_id": waiting_for.get("id"),
|
|
400
|
+
"tool": tool,
|
|
401
|
+
"message": waiting_for.get("message"),
|
|
402
|
+
"questions": list(questions),
|
|
403
|
+
"native": dict(waiting_for),
|
|
404
|
+
}
|
|
405
|
+
if out["kind"] == KIND_ELICITATION:
|
|
406
|
+
# Only the fields a responder needs; `native` keeps the full preview.
|
|
407
|
+
out["elicitation"] = {
|
|
408
|
+
key: waiting_for.get(key) for key in ("server", "mode", "url", "schema")
|
|
409
|
+
}
|
|
410
|
+
return out
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _codex_approval_turn_id(record: dict, request_id) -> str | None:
|
|
414
|
+
"""Return the native turn fenced by one pending approval request."""
|
|
415
|
+
approvals = [value for value in record.get("pendingApprovals") or []
|
|
416
|
+
if isinstance(value, dict)]
|
|
417
|
+
selected = None
|
|
418
|
+
if request_id is None and len(approvals) == 1:
|
|
419
|
+
selected = approvals[0]
|
|
420
|
+
elif request_id is not None:
|
|
421
|
+
selected = next(
|
|
422
|
+
(
|
|
423
|
+
value for value in approvals
|
|
424
|
+
if str(request_id) in {
|
|
425
|
+
str(value.get("requestId")), str(value.get("gateId")),
|
|
426
|
+
}
|
|
427
|
+
),
|
|
428
|
+
None,
|
|
429
|
+
)
|
|
430
|
+
turn_id = selected.get("turnId") if selected else None
|
|
431
|
+
if not isinstance(turn_id, str) or not turn_id:
|
|
432
|
+
turn_id = (record.get("lifecycle") or {}).get("activeTurnId")
|
|
433
|
+
return turn_id if isinstance(turn_id, str) and turn_id else None
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _recap_row(handle: str, engine: str, short: str) -> dict:
|
|
437
|
+
"""A recap row before anything is observed.
|
|
438
|
+
|
|
439
|
+
Every row carries the same keys whatever happened to the worker, so a script
|
|
440
|
+
can index them unconditionally. ``unavailable`` is the honest starting point:
|
|
441
|
+
a row only claims a better state once one has actually been read.
|
|
442
|
+
"""
|
|
443
|
+
return {"worker": handle, "engine": engine, "short": short, "name": None,
|
|
444
|
+
"state": "unavailable", "native_state": None,
|
|
445
|
+
"requested_model": None, "observed_model": None,
|
|
446
|
+
"requested_effort": None, "turns": None,
|
|
447
|
+
"context_tokens": None, "context_as_of_turn_ref": None,
|
|
448
|
+
"context_as_of_ms": None, "error": None}
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _recap_counts(rows: list[dict]) -> str:
|
|
452
|
+
counts: dict[str, int] = {}
|
|
453
|
+
for row in rows:
|
|
454
|
+
counts[row["state"]] = counts.get(row["state"], 0) + 1
|
|
455
|
+
order = [s for s in _RECAP_STATES if s in counts]
|
|
456
|
+
order += sorted(s for s in counts if s not in _RECAP_STATES)
|
|
457
|
+
return ", ".join(f"{state} {counts[state]}" for state in order)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _recap_line(row: dict, width: int) -> str:
|
|
461
|
+
"""One worker as one line. Unobservable fields are omitted, never guessed."""
|
|
462
|
+
fields = [row["worker"].ljust(width)]
|
|
463
|
+
if row["name"]:
|
|
464
|
+
fields.append(str(row["name"]))
|
|
465
|
+
state, native = row["state"], row["native_state"]
|
|
466
|
+
fields.append(f"{state}({native})" if native and native != state else state)
|
|
467
|
+
model, observed = row["requested_model"], row["observed_model"]
|
|
468
|
+
if observed and observed != model:
|
|
469
|
+
# Requested and observed never merge, so a divergence is shown as one.
|
|
470
|
+
model = f"{model or '?'}->{observed}"
|
|
471
|
+
if model:
|
|
472
|
+
fields.append(f"model={model}")
|
|
473
|
+
if row["requested_effort"]:
|
|
474
|
+
fields.append(f"effort={row['requested_effort']}")
|
|
475
|
+
if row["turns"] is not None:
|
|
476
|
+
fields.append(f"turns={row['turns']}")
|
|
477
|
+
if row["context_tokens"] is not None:
|
|
478
|
+
marker = row["context_as_of_turn_ref"]
|
|
479
|
+
fields.append(f"context={row['context_tokens']}"
|
|
480
|
+
+ ("" if marker is None else f"@{marker}"))
|
|
481
|
+
if row["error"]:
|
|
482
|
+
fields.append(f"error={row['error']}")
|
|
483
|
+
return " ".join(fields)
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _recap_text(rows: list[dict]) -> str:
|
|
487
|
+
"""The printable recap: a count header plus one line per worker, in order.
|
|
488
|
+
|
|
489
|
+
Deterministic by construction — no timestamps, no elapsed time, no ordering
|
|
490
|
+
by anything but the caller's input. Task-specific facts (result, phase,
|
|
491
|
+
workspace, report paths, continuation commands) belong to the script, which
|
|
492
|
+
is the only thing that knows them.
|
|
493
|
+
"""
|
|
494
|
+
if not rows:
|
|
495
|
+
return "0 workers"
|
|
496
|
+
width = max(len(row["worker"]) for row in rows)
|
|
497
|
+
header = (f"{len(rows)} worker{'' if len(rows) == 1 else 's'}: "
|
|
498
|
+
f"{_recap_counts(rows)}")
|
|
499
|
+
return "\n".join([header] + [_recap_line(row, width) for row in rows])
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
# Process termination primitives live in ccx.terminate, which both this facade
|
|
503
|
+
# and the two CLIs use for confirmed kills.
|
|
504
|
+
_signal = terminate.signal_pid
|
|
505
|
+
_escalate_at = terminate.escalate_at
|
|
506
|
+
_await_exit = terminate.await_exit
|
|
507
|
+
_reap = terminate.reap
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def _claude_windows(summary: dict) -> list[dict]:
|
|
511
|
+
windows: list[dict] = []
|
|
512
|
+
for key, label in (("five_hour", "5h"), ("seven_day", "7d")):
|
|
513
|
+
block = summary.get(key)
|
|
514
|
+
if isinstance(block, dict):
|
|
515
|
+
used = block.get("utilization")
|
|
516
|
+
windows.append({
|
|
517
|
+
"id": key,
|
|
518
|
+
"label": label,
|
|
519
|
+
"used_percent": float(used) if isinstance(used, (int, float)) else None,
|
|
520
|
+
"resets_at": block.get("resets_at"),
|
|
521
|
+
})
|
|
522
|
+
for quota in summary.get("model_quotas") or []:
|
|
523
|
+
if not isinstance(quota, dict):
|
|
524
|
+
continue
|
|
525
|
+
used = quota.get("utilization")
|
|
526
|
+
windows.append({
|
|
527
|
+
"id": f"model:{quota.get('model_id') or quota.get('model')}",
|
|
528
|
+
"label": str(quota.get("model") or "model"),
|
|
529
|
+
"used_percent": float(used) if isinstance(used, (int, float)) else None,
|
|
530
|
+
"resets_at": quota.get("resets_at"),
|
|
531
|
+
})
|
|
532
|
+
return windows
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _codex_windows(data: dict) -> list[dict]:
|
|
536
|
+
"""Normalize Codex windows with duration-based IDs."""
|
|
537
|
+
windows: list[dict] = []
|
|
538
|
+
for limit_id, window in rate_limit_windows(data):
|
|
539
|
+
used, resets = window.get("usedPercent"), window.get("resetsAt")
|
|
540
|
+
label = window_label(window)
|
|
541
|
+
windows.append({
|
|
542
|
+
"id": f"{limit_id}:{label}",
|
|
543
|
+
"label": label,
|
|
544
|
+
"used_percent": float(used) if type(used) in (int, float) else None,
|
|
545
|
+
"resets_at": (
|
|
546
|
+
datetime.fromtimestamp(resets, timezone.utc).strftime(
|
|
547
|
+
"%Y-%m-%dT%H:%M:%SZ"
|
|
548
|
+
) if type(resets) in (int, float) else None
|
|
549
|
+
),
|
|
550
|
+
})
|
|
551
|
+
return windows
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
class WorkerClient:
|
|
555
|
+
"""One lifecycle for ccx-managed Claude workers and CCX-owned Codex workers.
|
|
556
|
+
|
|
557
|
+
Stateless and cheap to construct; it holds only the two stateless concrete
|
|
558
|
+
clients. There is no "current engine" anywhere, so concurrent calls against
|
|
559
|
+
distinct workers are safe by construction. Never shells out to the ``ccx``
|
|
560
|
+
CLI and never calls ``os.fork()`` in your process.
|
|
561
|
+
"""
|
|
562
|
+
|
|
563
|
+
def _uuid_owner(self, worker):
|
|
564
|
+
from ccx.store.identity import UnknownWorker, IdentityError
|
|
565
|
+
from ccx.store.root import GenerationError
|
|
566
|
+
target=worker.get("worker") if isinstance(worker,dict) else worker
|
|
567
|
+
try:
|
|
568
|
+
return self._uuid_store.resolve(target)
|
|
569
|
+
except UnknownWorker as exc:
|
|
570
|
+
raise worker_not_found(target) from exc
|
|
571
|
+
except IdentityError as exc:
|
|
572
|
+
raise InvalidRequest(f"Invalid worker handle: {exc}") from exc
|
|
573
|
+
except GenerationError as exc:
|
|
574
|
+
raise SessionUnavailable(f"Worker record unavailable; check its status: {exc}") from exc
|
|
575
|
+
|
|
576
|
+
def __init__(self) -> None:
|
|
577
|
+
"""One pinned global generation for both engine views, no ensure_dirs."""
|
|
578
|
+
self._claude = CcxClient()
|
|
579
|
+
self._codex = CodexClient()
|
|
580
|
+
self._uuid_store = self._claude._uuid_store
|
|
581
|
+
if self._codex._uuid_store.pointer != self._uuid_store.pointer:
|
|
582
|
+
raise SessionUnavailable("CCX worker records changed during startup; retry")
|
|
583
|
+
self._uuid_store.revalidate()
|
|
584
|
+
|
|
585
|
+
# -- introspection ------------------------------------------------------
|
|
586
|
+
|
|
587
|
+
def capabilities(self, engine) -> dict:
|
|
588
|
+
"""Truthful implemented UUID capabilities, not old native promises."""
|
|
589
|
+
if isinstance(engine,dict):
|
|
590
|
+
engine=engine.get("worker")
|
|
591
|
+
if engine not in _ENGINES:
|
|
592
|
+
engine=self._uuid_owner(engine).engine
|
|
593
|
+
# The dashboard's busy-turn router uses steer_current_turn.
|
|
594
|
+
# All other published flags below describe actual engine differences.
|
|
595
|
+
return {
|
|
596
|
+
"engine": engine,
|
|
597
|
+
"steer_current_turn": True,
|
|
598
|
+
"auto_delivery": engine == "claude",
|
|
599
|
+
"wait_next_turn": engine == "claude",
|
|
600
|
+
"wait_after_turn": engine == "claude",
|
|
601
|
+
"wait_known_input": engine == "claude",
|
|
602
|
+
"answer_question": engine == "claude",
|
|
603
|
+
"respond_elicitation": engine == "claude",
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
def variables(self, worker, name: str | None = None) -> dict:
|
|
607
|
+
"""Full-UUID durable variable read, no live object resurrection."""
|
|
608
|
+
owner=self._uuid_owner(worker)
|
|
609
|
+
from ccx.store.root import GenerationError
|
|
610
|
+
try:
|
|
611
|
+
return self._uuid_store.variables_info(owner.worker,name=name)
|
|
612
|
+
except GenerationError as exc:
|
|
613
|
+
raise SessionUnavailable(f"full-UUID variables unavailable: {exc}") from exc
|
|
614
|
+
|
|
615
|
+
# -- create -------------------------------------------------------------
|
|
616
|
+
|
|
617
|
+
def create(
|
|
618
|
+
self, *, engine: str, cwd: str, worker_id: str | None = None,
|
|
619
|
+
model: str | None = None, effort: str | None = None,
|
|
620
|
+
permissions: str = "full", name: str | None = None,
|
|
621
|
+
prompt: str | None = None, idle_timeout: int | None = None,
|
|
622
|
+
wait: bool = False, timeout: float | None = None,
|
|
623
|
+
start_timeout: float | None = None,
|
|
624
|
+
caps: dict | None = None, variables: dict | None = None,
|
|
625
|
+
variables_from=None,
|
|
626
|
+
) -> dict:
|
|
627
|
+
"""One engine-neutral original native-ready UUID creation operation."""
|
|
628
|
+
from ccx.ops.creation import create
|
|
629
|
+
if engine not in _ENGINES:
|
|
630
|
+
raise InvalidRequest(f"unknown worker engine: {engine!r}")
|
|
631
|
+
if variables is not None and variables_from is not None:
|
|
632
|
+
raise InvalidRequest("variables and variables_from are mutually exclusive")
|
|
633
|
+
attachment = None
|
|
634
|
+
if variables is not None or variables_from is not None:
|
|
635
|
+
from ccx.controllers import live_vars
|
|
636
|
+
if variables_from is not None:
|
|
637
|
+
source = self._uuid_owner(variables_from)
|
|
638
|
+
attachment = live_vars.prepare_shared_durable_attachment(
|
|
639
|
+
source.engine, source.worker_id,
|
|
640
|
+
)
|
|
641
|
+
else:
|
|
642
|
+
attachment = live_vars.prepare_attachment(variables)
|
|
643
|
+
raw = create(
|
|
644
|
+
engine=engine,cwd=cwd,worker_id=worker_id,prompt=prompt,wait=wait,
|
|
645
|
+
caps=caps,
|
|
646
|
+
name=name,model=model,effort=effort,permissions=permissions,
|
|
647
|
+
idle_timeout=(DEFAULT_IDLE_TIMEOUT_S
|
|
648
|
+
if idle_timeout is None else idle_timeout),
|
|
649
|
+
created_via="python-api",timeout=timeout,
|
|
650
|
+
start_timeout=start_timeout, attachment=attachment,
|
|
651
|
+
expected_generation=self._uuid_store.generation,
|
|
652
|
+
)
|
|
653
|
+
return _public_created_result(self._uuid_store, raw)
|
|
654
|
+
|
|
655
|
+
def fork(
|
|
656
|
+
self, worker, *, worker_id: str | None = None, name: str | None = None,
|
|
657
|
+
model: str | None = None, effort: str | None = None,
|
|
658
|
+
prompt: str | None = None, wait: bool = False,
|
|
659
|
+
timeout: float | None = None, caps: dict | None = None,
|
|
660
|
+
variables: dict | None = None, variables_from=None,
|
|
661
|
+
) -> dict:
|
|
662
|
+
"""Fork the CCX-owned ``worker`` into a new worker with its history.
|
|
663
|
+
|
|
664
|
+
The result has :meth:`create`'s keys plus ``forked_from``. Retrying
|
|
665
|
+
with the same ``worker_id`` reuses the original fork point.
|
|
666
|
+
"""
|
|
667
|
+
from math import isfinite
|
|
668
|
+
from ccx.ops.forking import fork
|
|
669
|
+
from ccx.store.root import GenerationError
|
|
670
|
+
from ccx.store.identity import IdentityError, UnknownWorker
|
|
671
|
+
|
|
672
|
+
if variables is not None and variables_from is not None:
|
|
673
|
+
raise InvalidRequest("variables and variables_from are mutually exclusive")
|
|
674
|
+
attachment = None
|
|
675
|
+
if variables is not None or variables_from is not None:
|
|
676
|
+
from ccx.controllers import live_vars
|
|
677
|
+
if variables_from is not None:
|
|
678
|
+
source = self._uuid_owner(variables_from)
|
|
679
|
+
attachment = live_vars.prepare_shared_durable_attachment(
|
|
680
|
+
source.engine, source.worker_id,
|
|
681
|
+
)
|
|
682
|
+
else:
|
|
683
|
+
attachment = live_vars.prepare_attachment(variables)
|
|
684
|
+
if timeout is not None and (
|
|
685
|
+
type(timeout) not in (int, float) or not isfinite(timeout) or timeout <= 0
|
|
686
|
+
):
|
|
687
|
+
raise InvalidRequest("fork timeout must be positive finite seconds or None")
|
|
688
|
+
deadline = time.monotonic() + timeout if timeout is not None else None
|
|
689
|
+
try:
|
|
690
|
+
raw = fork(
|
|
691
|
+
_handle(worker), worker_id=worker_id, name=name, model=model,
|
|
692
|
+
effort=effort, created_via="python-api",
|
|
693
|
+
expected_generation=self._uuid_store.generation, deadline=deadline,
|
|
694
|
+
prompt=prompt, wait=wait, caps=caps, attachment=attachment,
|
|
695
|
+
)
|
|
696
|
+
except UnknownWorker as exc:
|
|
697
|
+
raise worker_not_found(_handle(worker)) from exc
|
|
698
|
+
except IdentityError as exc:
|
|
699
|
+
raise InvalidRequest(f"fork: invalid full-UUID worker: {exc}") from exc
|
|
700
|
+
except GenerationError as exc:
|
|
701
|
+
raise SessionUnavailable(f"fork: ACTIVE generation unavailable: {exc}") from exc
|
|
702
|
+
return _public_created_result(self._uuid_store, raw, fork=True)
|
|
703
|
+
|
|
704
|
+
# -- state readers ------------------------------------------------------
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def status(self, worker) -> dict:
|
|
709
|
+
"""Recorded-only worker status, with native identity distinct."""
|
|
710
|
+
owner=self._uuid_owner(worker)
|
|
711
|
+
rec=self._uuid_store.record(owner.worker)
|
|
712
|
+
lc=rec.get("lifecycle")
|
|
713
|
+
lc=lc if isinstance(lc,dict) else {}
|
|
714
|
+
native=rec.get("native")
|
|
715
|
+
native=native if isinstance(native,dict) else {}
|
|
716
|
+
stats=rec.get("stats")
|
|
717
|
+
stats=stats if isinstance(stats,dict) else {}
|
|
718
|
+
args=rec.get("createArgs")
|
|
719
|
+
args=args if isinstance(args,dict) else {}
|
|
720
|
+
if owner.engine == "claude":
|
|
721
|
+
observation = read_claude_observation(self._uuid_store, owner, rec)
|
|
722
|
+
observed = observation.get("observedModel") if observation else None
|
|
723
|
+
else:
|
|
724
|
+
observed = rec.get("resolvedModel")
|
|
725
|
+
return {
|
|
726
|
+
"worker": owner.worker,
|
|
727
|
+
"worker_id":owner.worker_id, "engine":owner.engine,
|
|
728
|
+
"short":owner.short, "name":rec.get("name"),
|
|
729
|
+
"cwd":rec.get("cwd"), "state":normalize_native_state(lc.get("state")),
|
|
730
|
+
"native_state": lc.get("state"),
|
|
731
|
+
"native_id": (native.get("sessionId") if owner.engine=="claude"
|
|
732
|
+
else native.get("threadId")),
|
|
733
|
+
"terminal_only":bool(rec.get("terminalOnly")),
|
|
734
|
+
"state_source":"recorded",
|
|
735
|
+
"host":"unobserved",
|
|
736
|
+
"loaded":recorded_host_loaded(owner.engine, lc),
|
|
737
|
+
# Recorded gate previews, like peek: the request_id is what
|
|
738
|
+
# approve/reject/answer require; the host stays the final fence.
|
|
739
|
+
"pending_input": (_claude_pending(rec.get("waitingFor"))
|
|
740
|
+
if owner.engine == "claude"
|
|
741
|
+
else _codex_pending(rec.get("pendingApprovals"))
|
|
742
|
+
if lc.get("state") == "waiting" else None),
|
|
743
|
+
"last_turn_at":rec.get("lastTurnAt"),
|
|
744
|
+
"managed_turns":stats.get("turns"),
|
|
745
|
+
"requested_model":args.get("model"),
|
|
746
|
+
"requested_effort":args.get("effort"),
|
|
747
|
+
"observed_model":observed,
|
|
748
|
+
"record_revision":rec.get("recordRevision"),
|
|
749
|
+
"context": commands_read._status_context_for_record(owner.worker, rec),
|
|
750
|
+
**({"background_task_summary": rec["backgroundTaskSummary"]}
|
|
751
|
+
if "backgroundTaskSummary" in rec else {}),
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
def list(self, *, engine: str | None = None, cwd: str | None = None,
|
|
755
|
+
state: str | None = None, parent: str | None = None,
|
|
756
|
+
limit: int | None = None) -> dict:
|
|
757
|
+
"""Recorded owned workers, optionally exact direct children of a full UUID."""
|
|
758
|
+
if engine is not None and engine not in _ENGINES:
|
|
759
|
+
raise InvalidRequest(f"unknown engine: {engine!r}")
|
|
760
|
+
if cwd is not None and not isinstance(cwd,str):
|
|
761
|
+
raise InvalidRequest("cwd filter must be a path string")
|
|
762
|
+
parent_owner = self._uuid_owner(parent) if parent is not None else None
|
|
763
|
+
from ccx.system.cwd_paths import canonical_cwd
|
|
764
|
+
from ccx.store.root import GenerationError
|
|
765
|
+
try:
|
|
766
|
+
rows=self._uuid_store.list_workers(
|
|
767
|
+
engine=engine, cwd=canonical_cwd(cwd) if cwd else None,
|
|
768
|
+
state=state, parent=parent_owner.worker if parent_owner else None,
|
|
769
|
+
limit=limit,
|
|
770
|
+
)
|
|
771
|
+
except GenerationError as exc:
|
|
772
|
+
raise SessionUnavailable(f"full-UUID list unavailable: {exc}") from exc
|
|
773
|
+
return {"workers": rows, "errors": {}}
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def summaries(self, handles) -> dict:
|
|
777
|
+
"""Bounded recorded-only enrichment of exact owned full-UUID workers.
|
|
778
|
+
|
|
779
|
+
Preserve caller order and isolate only a genuinely absent full ID.
|
|
780
|
+
Neither the display prefix nor a native session/thread id is a target;
|
|
781
|
+
no host, daemon, transcript rendering or inferred liveness is queried.
|
|
782
|
+
Claude's already-rendered observation is a small owned sidecar; this
|
|
783
|
+
never starts a provider, renders a transcript, or guesses liveness.
|
|
784
|
+
"""
|
|
785
|
+
from ccx.store.identity import IdentityError, parse_managed_target
|
|
786
|
+
|
|
787
|
+
if isinstance(handles, (str, bytes, dict)):
|
|
788
|
+
raise InvalidRequest("summaries expects an iterable of worker handles")
|
|
789
|
+
try:
|
|
790
|
+
items = iter(handles)
|
|
791
|
+
except TypeError as exc:
|
|
792
|
+
raise InvalidRequest("summaries expects an iterable of worker handles") from exc
|
|
793
|
+
self._uuid_store.revalidate()
|
|
794
|
+
rows: list[dict] = []
|
|
795
|
+
errors: dict[str, dict] = {}
|
|
796
|
+
seen: set[str] = set()
|
|
797
|
+
for value in items:
|
|
798
|
+
target = value.get("worker") if isinstance(value, dict) else value
|
|
799
|
+
try:
|
|
800
|
+
parsed = parse_managed_target(target)
|
|
801
|
+
except IdentityError as exc:
|
|
802
|
+
raise InvalidRequest(f"Invalid worker handle: {exc}") from exc
|
|
803
|
+
try:
|
|
804
|
+
owner = self._uuid_owner(target)
|
|
805
|
+
except SessionNotFound as exc:
|
|
806
|
+
# Missing is a per-row consumer result; invalid/contradictory
|
|
807
|
+
# authority, stale generations and corrupt records are NOT.
|
|
808
|
+
missing = (f"{parsed.qualified_engine}:{parsed.worker_id}"
|
|
809
|
+
if parsed.qualified_engine else parsed.worker_id)
|
|
810
|
+
if missing in seen:
|
|
811
|
+
continue
|
|
812
|
+
seen.add(missing)
|
|
813
|
+
failure = _engine_error(exc)
|
|
814
|
+
errors[missing] = failure
|
|
815
|
+
rows.append({
|
|
816
|
+
"worker": missing, "worker_id": parsed.worker_id,
|
|
817
|
+
"engine": parsed.qualified_engine, "short": parsed.worker_id[:8],
|
|
818
|
+
"observed_model": None,
|
|
819
|
+
"context": _summary_context(
|
|
820
|
+
None, has_turn=True, codex=parsed.qualified_engine == "codex",
|
|
821
|
+
),
|
|
822
|
+
"error": failure["error"],
|
|
823
|
+
})
|
|
824
|
+
continue
|
|
825
|
+
if owner.worker in seen:
|
|
826
|
+
continue
|
|
827
|
+
seen.add(owner.worker)
|
|
828
|
+
rec = self._uuid_store.record(owner.worker)
|
|
829
|
+
if owner.engine == "claude":
|
|
830
|
+
observation = read_claude_observation(self._uuid_store, owner, rec)
|
|
831
|
+
block = observation.get("context") if observation else None
|
|
832
|
+
observed = observation.get("observedModel") if observation else None
|
|
833
|
+
else:
|
|
834
|
+
block = current_context(rec, engine="codex", live=rec.get("context"))
|
|
835
|
+
observed = rec.get("resolvedModel")
|
|
836
|
+
rows.append({
|
|
837
|
+
"worker": owner.worker, "worker_id": owner.worker_id,
|
|
838
|
+
"engine": owner.engine, "short": owner.short,
|
|
839
|
+
"observed_model": observed,
|
|
840
|
+
"context": _summary_context(
|
|
841
|
+
block, has_turn=rec.get("lastTurnAt") is not None or block is not None,
|
|
842
|
+
codex=owner.engine == "codex",
|
|
843
|
+
),
|
|
844
|
+
"error": None,
|
|
845
|
+
})
|
|
846
|
+
return {"count": len(rows), "workers": rows, "errors": errors}
|
|
847
|
+
|
|
848
|
+
def report(
|
|
849
|
+
self, worker, *, max_chars: int | None = None,
|
|
850
|
+
turn_ref: str | None = None, timeout: float = 30,
|
|
851
|
+
) -> dict:
|
|
852
|
+
"""Read the latest turn's reply, or an explicitly selected completed turn.
|
|
853
|
+
|
|
854
|
+
``has_final_message`` says whether the selected turn produced a final
|
|
855
|
+
assistant message, independent of ``max_chars`` clipping of ``text``.
|
|
856
|
+
``note`` explains when a newer turn is running, an earlier reply was
|
|
857
|
+
selected, native history could not be read, or the worker failed.
|
|
858
|
+
"""
|
|
859
|
+
from ccx.ops.report import report_worker
|
|
860
|
+
owner=self._uuid_owner(worker)
|
|
861
|
+
if timeout<=0:
|
|
862
|
+
raise InvalidRequest("native report timeout must be positive")
|
|
863
|
+
if max_chars is not None and (type(max_chars) is not int or max_chars<0):
|
|
864
|
+
raise InvalidRequest("report max_chars must be nonnegative integer")
|
|
865
|
+
self._uuid_store.revalidate()
|
|
866
|
+
result=report_worker(
|
|
867
|
+
owner.worker,max_chars=max_chars,turn_ref=turn_ref,
|
|
868
|
+
timeout=timeout,
|
|
869
|
+
)
|
|
870
|
+
background = _background_task_block_for_worker(self._uuid_store, owner)
|
|
871
|
+
return {
|
|
872
|
+
"worker":owner.worker,"worker_id":owner.worker_id,
|
|
873
|
+
"engine":owner.engine,"short":owner.short,
|
|
874
|
+
"text":result["text"],"truncated":result["truncated"],
|
|
875
|
+
"has_final_message":result["has_final_message"],
|
|
876
|
+
"turn":None,"turn_id":result.get("turn_id"),
|
|
877
|
+
"turn_ref":result.get("turn_ref"),
|
|
878
|
+
"delivery_id":result.get("delivery_id"),
|
|
879
|
+
"status":result["status"],
|
|
880
|
+
"source":result["source"],
|
|
881
|
+
"native_evidence_sha256":result.get("native_evidence_sha256"),
|
|
882
|
+
"note":result.get("note"),
|
|
883
|
+
**background,
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
def telemetry(self, worker, *, after_cursor: str | None = None,
|
|
887
|
+
limit: int = 200, timeout: float = 120) -> dict:
|
|
888
|
+
"""Read the same source-bound telemetry page as worker.telemetry RPC."""
|
|
889
|
+
from ccx.ops.report import telemetry_worker
|
|
890
|
+
owner = self._uuid_owner(worker)
|
|
891
|
+
return telemetry_worker(
|
|
892
|
+
owner.worker, after_cursor=after_cursor, limit=limit, timeout=timeout,
|
|
893
|
+
)
|
|
894
|
+
|
|
895
|
+
def context(self, worker) -> dict:
|
|
896
|
+
"""Read recorded carried-context evidence, never native liveness."""
|
|
897
|
+
owner = self._uuid_owner(worker)
|
|
898
|
+
rec = self._uuid_store.record(owner.worker)
|
|
899
|
+
lifecycle = rec.get("lifecycle") or {}
|
|
900
|
+
native = rec.get("native") or {}
|
|
901
|
+
if owner.engine == "claude":
|
|
902
|
+
observation = read_claude_observation(self._uuid_store, owner, rec)
|
|
903
|
+
block = observation.get("context") if observation else None
|
|
904
|
+
else:
|
|
905
|
+
block = current_context(rec, engine="codex", live=rec.get("context"))
|
|
906
|
+
return {
|
|
907
|
+
"worker": owner.worker, "worker_id": owner.worker_id,
|
|
908
|
+
"engine": owner.engine, "short": owner.short,
|
|
909
|
+
"state": lifecycle.get("state"),
|
|
910
|
+
"native_state": native.get("nativeState"),
|
|
911
|
+
**public_context(
|
|
912
|
+
block, engine=owner.engine,
|
|
913
|
+
compacting=(owner.engine == "codex"
|
|
914
|
+
and isinstance(rec.get("activeCompact"), dict)),
|
|
915
|
+
),
|
|
916
|
+
**({"note": observation["note"]} if owner.engine == "claude"
|
|
917
|
+
and observation and observation.get("note") else {}),
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
def recap(self, handles) -> dict:
|
|
921
|
+
"""Deterministic recorded-only recap of exact full-UUID workers.
|
|
922
|
+
|
|
923
|
+
Reuse the authoritative summaries, owner records and carried-context
|
|
924
|
+
reader. Only valid absent UUIDs become unavailable rows; bad identity,
|
|
925
|
+
generation and observation integrity fail closed. No native probes,
|
|
926
|
+
transcript rendering or lifecycle changes occur.
|
|
927
|
+
"""
|
|
928
|
+
summary = self.summaries(handles)
|
|
929
|
+
rows: list[dict] = []
|
|
930
|
+
for item in summary["workers"]:
|
|
931
|
+
handle = item["worker"]
|
|
932
|
+
row = _recap_row(handle, item["engine"], item["short"])
|
|
933
|
+
row["worker_id"] = item["worker_id"]
|
|
934
|
+
row["error"] = item["error"]
|
|
935
|
+
if item["error"] is None:
|
|
936
|
+
rec = self._uuid_store.record(handle)
|
|
937
|
+
lifecycle = rec.get("lifecycle") or {}
|
|
938
|
+
stats = rec.get("stats") or {}
|
|
939
|
+
args = rec.get("createArgs")
|
|
940
|
+
args = args if isinstance(args, dict) else {}
|
|
941
|
+
context = self.context(handle)
|
|
942
|
+
row.update(
|
|
943
|
+
name=rec.get("name"),
|
|
944
|
+
state=_state(lifecycle.get("state")),
|
|
945
|
+
native_state=lifecycle.get("state"),
|
|
946
|
+
turns=stats.get("turns"),
|
|
947
|
+
requested_model=args.get("model"),
|
|
948
|
+
observed_model=item["observed_model"],
|
|
949
|
+
requested_effort=args.get("effort"),
|
|
950
|
+
context_tokens=context["tokens_used"],
|
|
951
|
+
context_as_of_turn_ref=context["as_of_turn_ref"],
|
|
952
|
+
context_as_of_ms=context["as_of_ms"],
|
|
953
|
+
)
|
|
954
|
+
rows.append(row)
|
|
955
|
+
return {
|
|
956
|
+
"count": summary["count"], "workers": rows,
|
|
957
|
+
"errors": summary["errors"], "text": _recap_text(rows),
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
# -- delivery -----------------------------------------------------------
|
|
961
|
+
|
|
962
|
+
def send(
|
|
963
|
+
self, worker, text: str, *, wait: bool = True,
|
|
964
|
+
timeout: float | None = None,
|
|
965
|
+
caps: dict | None = None, variables: dict | None = None,
|
|
966
|
+
variables_from=None, delivery_id: str | None = None,
|
|
967
|
+
) -> dict:
|
|
968
|
+
"""Full UUID facade routes to original engine native send, no alias.
|
|
969
|
+
|
|
970
|
+
Native delivery_id is caller-owned and may be retried only for the
|
|
971
|
+
exact same prompt. Indeterminate original effects are returned as
|
|
972
|
+
typed errors, never changed into a fresh token/provider request.
|
|
973
|
+
"""
|
|
974
|
+
owner=self._uuid_owner(worker)
|
|
975
|
+
handle=owner.worker
|
|
976
|
+
if caps is not None:
|
|
977
|
+
_check_caps(caps, engine=owner.engine)
|
|
978
|
+
if variables is not None and variables_from is not None:
|
|
979
|
+
raise InvalidRequest("variables and variables_from are mutually exclusive")
|
|
980
|
+
if variables is not None or variables_from is not None:
|
|
981
|
+
from ccx.controllers import live_vars
|
|
982
|
+
record = self._uuid_store.record(handle)
|
|
983
|
+
live_vars.require_send_variable_permission(owner, record)
|
|
984
|
+
if variables_from is not None:
|
|
985
|
+
source = self._uuid_owner(variables_from)
|
|
986
|
+
attachment = live_vars.prepare_shared_durable_attachment(
|
|
987
|
+
source.engine, source.worker_id,
|
|
988
|
+
)
|
|
989
|
+
else:
|
|
990
|
+
attachment = live_vars.prepare_attachment(variables)
|
|
991
|
+
if attachment is not None and (
|
|
992
|
+
attachment.names or attachment.state_id is not None
|
|
993
|
+
):
|
|
994
|
+
if live_vars.should_publish_send_attachment(
|
|
995
|
+
self._uuid_store, owner, delivery_id, caps=caps,
|
|
996
|
+
):
|
|
997
|
+
live_vars.publish_attachment(
|
|
998
|
+
attachment, owner.engine, owner.worker_id,
|
|
999
|
+
)
|
|
1000
|
+
names = self._uuid_store.variables_info(handle)["names"]
|
|
1001
|
+
text = live_vars.with_variable_hint(text, names)
|
|
1002
|
+
self._uuid_store.revalidate()
|
|
1003
|
+
try:
|
|
1004
|
+
if owner.engine=="claude":
|
|
1005
|
+
native=self._claude.send(
|
|
1006
|
+
handle,text,wait=wait,timeout=timeout,
|
|
1007
|
+
delivery_id=delivery_id,caps=caps,
|
|
1008
|
+
)
|
|
1009
|
+
else:
|
|
1010
|
+
native=self._codex.send(
|
|
1011
|
+
handle,text,wait=wait,timeout=timeout,delivery_id=delivery_id,
|
|
1012
|
+
caps=caps,
|
|
1013
|
+
)
|
|
1014
|
+
except DeliveryRejected as exc:
|
|
1015
|
+
if exc.state=="waiting":
|
|
1016
|
+
# Only a positive native refusal triggers waiting. Recorded
|
|
1017
|
+
# status supplies the pending gate preview, never authority.
|
|
1018
|
+
status=self.status(handle)
|
|
1019
|
+
raise SessionWaiting(
|
|
1020
|
+
f"{owner.engine} worker {owner.short} is waiting on input",
|
|
1021
|
+
short=owner.short,
|
|
1022
|
+
waiting_for=(status.get("pending_input") or {}).get("native"),
|
|
1023
|
+
) from exc
|
|
1024
|
+
if (owner.engine=="codex" and exc.refusal=="worker-state"
|
|
1025
|
+
and exc.state=="busy"):
|
|
1026
|
+
exc.refusal="busy"
|
|
1027
|
+
raise
|
|
1028
|
+
if owner.engine=="claude":
|
|
1029
|
+
token=native.delivery_id
|
|
1030
|
+
return {
|
|
1031
|
+
"worker":handle,"worker_id":owner.worker_id,
|
|
1032
|
+
"engine":"claude","short":owner.short,
|
|
1033
|
+
"delivery_id":token,
|
|
1034
|
+
"delivery_status":native.delivery_status,
|
|
1035
|
+
"waited":bool(wait),"queued":False,
|
|
1036
|
+
"reply":native.reply,"turn_id":None,
|
|
1037
|
+
"turn_ref":native.turn_ref,
|
|
1038
|
+
"client_user_message_id":None,
|
|
1039
|
+
"settled":native.settled,
|
|
1040
|
+
"state":native.state,
|
|
1041
|
+
}
|
|
1042
|
+
return {
|
|
1043
|
+
"worker":handle,"worker_id":owner.worker_id,
|
|
1044
|
+
"engine":"codex","short":owner.short,
|
|
1045
|
+
"delivery_id":native.delivery_id,
|
|
1046
|
+
"delivery_status":("settled" if native.reply is not None
|
|
1047
|
+
else "accepted"),
|
|
1048
|
+
"waited":bool(wait),"queued":False,
|
|
1049
|
+
"reply":native.reply,"turn_id":native.turn_id,
|
|
1050
|
+
"turn_ref":("ccx-turn:codex:v1:"+native.turn_id
|
|
1051
|
+
if native.turn_id else None),
|
|
1052
|
+
"client_user_message_id":native.client_user_message_id,
|
|
1053
|
+
"settled":native.reply is not None,
|
|
1054
|
+
"state":native.state,
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
def steer(self, worker, text: str, *, wait: bool = True,
|
|
1058
|
+
timeout: float | None = STEER_OBSERVE_DEFAULT_S,
|
|
1059
|
+
caps: dict | None = None,
|
|
1060
|
+
delivery_id: str | None = None) -> dict:
|
|
1061
|
+
"""Place one busy-turn instruction; Codex returns on native placement."""
|
|
1062
|
+
owner = self._uuid_owner(worker)
|
|
1063
|
+
if owner.engine == "codex":
|
|
1064
|
+
if caps is not None:
|
|
1065
|
+
_check_caps(caps, engine="codex")
|
|
1066
|
+
try:
|
|
1067
|
+
native = self._codex.steer(
|
|
1068
|
+
owner.worker, text, delivery_id=delivery_id, caps=caps,
|
|
1069
|
+
)
|
|
1070
|
+
except DeliveryRejected as exc:
|
|
1071
|
+
status = self.status(owner.worker)
|
|
1072
|
+
if status["state"] == "waiting":
|
|
1073
|
+
raise SessionWaiting(
|
|
1074
|
+
f"Codex worker {owner.worker} is waiting on input",
|
|
1075
|
+
short=owner.short,
|
|
1076
|
+
waiting_for=(status.get("pending_input") or {}).get("native"),
|
|
1077
|
+
) from exc
|
|
1078
|
+
raise
|
|
1079
|
+
return {
|
|
1080
|
+
"worker": owner.worker, "worker_id": owner.worker_id,
|
|
1081
|
+
"engine": "codex", "short": owner.short,
|
|
1082
|
+
"accepted": True, "delivery_id": native.delivery_id,
|
|
1083
|
+
"status": "accepted-current-turn", "pending": False,
|
|
1084
|
+
"timed_out": False, "turn_id": native.turn_id,
|
|
1085
|
+
"client_user_message_id": native.client_user_message_id,
|
|
1086
|
+
"recommended_action": None, "state": native.state,
|
|
1087
|
+
}
|
|
1088
|
+
if caps is not None:
|
|
1089
|
+
_check_caps(caps, engine="claude")
|
|
1090
|
+
value = self._claude.steer(
|
|
1091
|
+
owner.worker, text, delivery_id=delivery_id,
|
|
1092
|
+
wait=wait, timeout=timeout, caps=caps,
|
|
1093
|
+
).as_dict()
|
|
1094
|
+
self._uuid_store.revalidate()
|
|
1095
|
+
return {**value, "turn_id": None, "client_user_message_id": None}
|
|
1096
|
+
|
|
1097
|
+
def _codex_settle(
|
|
1098
|
+
self, owner, deadline: float | None, operation: str, *,
|
|
1099
|
+
gate_id: str, turn_id: str,
|
|
1100
|
+
) -> str:
|
|
1101
|
+
"""Observe the one approved original Codex turn; never decide again."""
|
|
1102
|
+
turn_ref = f"ccx-turn:codex:v1:{turn_id}"
|
|
1103
|
+
|
|
1104
|
+
def incomplete(
|
|
1105
|
+
reason: str, *, state: str | None = None,
|
|
1106
|
+
native_status: str | None = None, native_error=None,
|
|
1107
|
+
) -> TurnIncomplete:
|
|
1108
|
+
failure = TurnIncomplete(
|
|
1109
|
+
f"{reason}; approval input accepted for gateId={gate_id!r}, "
|
|
1110
|
+
f"turn_ref={turn_ref!r}; never retry the decision",
|
|
1111
|
+
short=owner.short, state=state, turn_id=turn_id,
|
|
1112
|
+
turn_status=native_status, turn_error=native_error,
|
|
1113
|
+
)
|
|
1114
|
+
failure.input_accepted = True
|
|
1115
|
+
failure.request_id = gate_id
|
|
1116
|
+
failure.turn_ref = turn_ref
|
|
1117
|
+
return failure
|
|
1118
|
+
|
|
1119
|
+
from ccx.store.root import GenerationError
|
|
1120
|
+
|
|
1121
|
+
try:
|
|
1122
|
+
observed = self.wait(owner.worker, timeout=_left(deadline))
|
|
1123
|
+
except (CcxError, GenerationError) as exc:
|
|
1124
|
+
raise incomplete("original approved turn observation unavailable") from exc
|
|
1125
|
+
state = observed.get("native_state") or observed.get("state")
|
|
1126
|
+
status = observed.get("status")
|
|
1127
|
+
if status == "waiting" or state == "waiting":
|
|
1128
|
+
gate_error = SessionWaiting(
|
|
1129
|
+
f"Codex worker {owner.short} opened another input gate after "
|
|
1130
|
+
f"{operation}; original {gate_id!r} already accepted, never retry",
|
|
1131
|
+
short=owner.short,
|
|
1132
|
+
waiting_for=(observed.get("pending_input") or {}).get("native"),
|
|
1133
|
+
)
|
|
1134
|
+
gate_error.input_accepted = True
|
|
1135
|
+
gate_error.request_id = gate_id
|
|
1136
|
+
gate_error.turn_ref = turn_ref
|
|
1137
|
+
raise gate_error
|
|
1138
|
+
if observed.get("timed_out") or status == "timeout":
|
|
1139
|
+
raise incomplete("The approved turn did not finish in time; inspect its status", state=state)
|
|
1140
|
+
last = observed.get("last_settled_turn") or {}
|
|
1141
|
+
actual = observed.get("last_turn") or observed.get("lastTurn") or {}
|
|
1142
|
+
if (state != "idle" or status not in ("idle", None)
|
|
1143
|
+
or last.get("turn_ref") != turn_ref
|
|
1144
|
+
or last.get("turn_id") != turn_id
|
|
1145
|
+
or last.get("outcome") != "completed"
|
|
1146
|
+
or actual.get("turnId") != turn_id
|
|
1147
|
+
or actual.get("status") != "completed"):
|
|
1148
|
+
raise incomplete(
|
|
1149
|
+
"Approved turn did not complete as expected; inspect its status",
|
|
1150
|
+
state=state, native_status=actual.get("status"),
|
|
1151
|
+
native_error=actual.get("error"),
|
|
1152
|
+
)
|
|
1153
|
+
remaining = _left(deadline, 30)
|
|
1154
|
+
if remaining is not None and remaining <= 0:
|
|
1155
|
+
raise incomplete("The reply was not available before timeout; inspect the turn",
|
|
1156
|
+
state=state, native_status="completed")
|
|
1157
|
+
try:
|
|
1158
|
+
report = self.report(owner.worker, turn_ref=turn_ref,
|
|
1159
|
+
timeout=remaining if remaining is not None else 30)
|
|
1160
|
+
except (CcxError, GenerationError) as exc:
|
|
1161
|
+
raise incomplete(
|
|
1162
|
+
"original approved turn completed but its exact report is unavailable",
|
|
1163
|
+
state=state, native_status="completed",
|
|
1164
|
+
) from exc
|
|
1165
|
+
if (report.get("turn_ref") != turn_ref
|
|
1166
|
+
or report.get("turn_id") != turn_id
|
|
1167
|
+
or report.get("status") != "completed"
|
|
1168
|
+
or not isinstance(report.get("text"), str)):
|
|
1169
|
+
raise incomplete(
|
|
1170
|
+
"The approved reply could not be verified; inspect the turn",
|
|
1171
|
+
state=state,
|
|
1172
|
+
)
|
|
1173
|
+
self._uuid_store.revalidate()
|
|
1174
|
+
return report["text"]
|
|
1175
|
+
|
|
1176
|
+
def wait(self, worker, *, timeout: float | None = None,
|
|
1177
|
+
next_turn: bool = False, after_turn: str | None = None,
|
|
1178
|
+
known_input: str | None = None) -> dict:
|
|
1179
|
+
"""Read-only full-UUID stability/next-turn verdict; timeout is data.
|
|
1180
|
+
|
|
1181
|
+
Both engines share one native-evidence observer. Claude additionally
|
|
1182
|
+
accepts next-turn/checkpoint/input selectors; Codex rejects them.
|
|
1183
|
+
"""
|
|
1184
|
+
from ccx.ops.wait import wait_worker
|
|
1185
|
+
|
|
1186
|
+
owner = self._uuid_owner(worker)
|
|
1187
|
+
result = wait_worker(
|
|
1188
|
+
owner.worker, timeout=timeout, next_turn=next_turn,
|
|
1189
|
+
after_turn=after_turn, known_input=known_input,
|
|
1190
|
+
expected_generation=self._uuid_store.generation,
|
|
1191
|
+
)
|
|
1192
|
+
background = _background_task_block_for_worker(self._uuid_store, owner)
|
|
1193
|
+
# Keep the engine-neutral snake_case views without changing the
|
|
1194
|
+
# canonical worker/ref or interpreting a terminal as a completed reply.
|
|
1195
|
+
state = result.get("state")
|
|
1196
|
+
if isinstance(state, str):
|
|
1197
|
+
result = {**result, "native_state": result.get("native_state", state),
|
|
1198
|
+
"state": normalize_native_state(state)}
|
|
1199
|
+
public = {key: value for key, value in result.items()
|
|
1200
|
+
if key not in {"apiError", "lastTurn", "pendingApprovals"}}
|
|
1201
|
+
return {**public,
|
|
1202
|
+
"api_error": result.get("apiError"),
|
|
1203
|
+
"last_turn": result.get("lastTurn"),
|
|
1204
|
+
"pending_approvals": result.get("pendingApprovals"), **background}
|
|
1205
|
+
|
|
1206
|
+
def answer(
|
|
1207
|
+
self, worker, text: str | None = None, *,
|
|
1208
|
+
request_id: str | None = None,
|
|
1209
|
+
options: list[tuple[int, str]] | None = None,
|
|
1210
|
+
text_answers: list[tuple[int, str]] | None = None,
|
|
1211
|
+
wait: bool = False, timeout: float | None = None,
|
|
1212
|
+
fallback_to_send: bool = False,
|
|
1213
|
+
response_id: str | None = None,
|
|
1214
|
+
) -> dict:
|
|
1215
|
+
"""Answer a Claude question using its existing exact input fence."""
|
|
1216
|
+
owner = self._uuid_owner(worker)
|
|
1217
|
+
if owner.engine != "claude":
|
|
1218
|
+
raise _unsupported(owner.engine, "answer_question", "questions")
|
|
1219
|
+
result = self._claude.answer(
|
|
1220
|
+
owner.worker, text, request_id=request_id, options=options,
|
|
1221
|
+
text_answers=text_answers, wait=wait, timeout=timeout,
|
|
1222
|
+
fallback_to_send=fallback_to_send, response_id=response_id,
|
|
1223
|
+
)
|
|
1224
|
+
return result.as_dict()
|
|
1225
|
+
|
|
1226
|
+
def respond_elicitation(
|
|
1227
|
+
self, worker, action: str, content: dict | None = None, *,
|
|
1228
|
+
request_id: str | None = None, response_id: str | None = None,
|
|
1229
|
+
wait: bool = False, timeout: float | None = None,
|
|
1230
|
+
) -> dict:
|
|
1231
|
+
"""Resolve a Claude MCP form through its existing native handler."""
|
|
1232
|
+
owner = self._uuid_owner(worker)
|
|
1233
|
+
if owner.engine != "claude":
|
|
1234
|
+
raise _unsupported(owner.engine, "respond_elicitation", "MCP forms")
|
|
1235
|
+
result = self._claude.respond_elicitation(
|
|
1236
|
+
owner.worker, action, content, request_id=request_id,
|
|
1237
|
+
response_id=response_id, wait=wait, timeout=timeout,
|
|
1238
|
+
)
|
|
1239
|
+
return result.as_dict()
|
|
1240
|
+
|
|
1241
|
+
def approve(self, worker, *, request_id: str | None = None,
|
|
1242
|
+
wait: bool = False, timeout: float | None = None) -> dict:
|
|
1243
|
+
"""Allow the original current permission gate, optionally await its turn."""
|
|
1244
|
+
return self._decision(worker, "allow", request_id, None, wait, timeout)
|
|
1245
|
+
|
|
1246
|
+
def reject(self, worker, *, request_id: str | None = None,
|
|
1247
|
+
reason: str | None = None, wait: bool = False,
|
|
1248
|
+
timeout: float | None = None) -> dict:
|
|
1249
|
+
"""Deny the original permission gate without guessing a successor."""
|
|
1250
|
+
return self._decision(worker, "deny", request_id, reason, wait, timeout)
|
|
1251
|
+
|
|
1252
|
+
def _decision(self, worker, decision, request_id, reason, wait, timeout) -> dict:
|
|
1253
|
+
from math import isfinite
|
|
1254
|
+
from ccx.store.root import GenerationError
|
|
1255
|
+
|
|
1256
|
+
if timeout is not None and (
|
|
1257
|
+
type(timeout) not in (int, float) or not isfinite(timeout) or timeout <= 0
|
|
1258
|
+
):
|
|
1259
|
+
raise InvalidRequest("permission timeout must be positive finite or None")
|
|
1260
|
+
if request_id is not None and (
|
|
1261
|
+
not isinstance(request_id, str) or not request_id
|
|
1262
|
+
or len(request_id) > 256 or "\x00" in request_id
|
|
1263
|
+
):
|
|
1264
|
+
raise InvalidRequest("Invalid request ID; use the one shown in ccx peek")
|
|
1265
|
+
deadline = _deadline(timeout)
|
|
1266
|
+
owner = self._uuid_owner(worker)
|
|
1267
|
+
if owner.engine == "codex" and reason is not None:
|
|
1268
|
+
raise _unsupported(
|
|
1269
|
+
"codex", "decision_reason",
|
|
1270
|
+
"Codex native approval does not take a rationale",
|
|
1271
|
+
)
|
|
1272
|
+
|
|
1273
|
+
record = self._uuid_store.record(owner.worker)
|
|
1274
|
+
if owner.engine == "claude":
|
|
1275
|
+
waiting = record.get("waitingFor")
|
|
1276
|
+
if (not isinstance(waiting, dict)
|
|
1277
|
+
or pending_input_kind(waiting) != "permission"):
|
|
1278
|
+
raise SessionUnavailable(
|
|
1279
|
+
"no Claude permission request is pending", short=owner.short,
|
|
1280
|
+
)
|
|
1281
|
+
if not isinstance(waiting.get("id"), str) or not waiting["id"]:
|
|
1282
|
+
# Claude Code's own safety checks prompt only in its terminal.
|
|
1283
|
+
raise SessionUnavailable(
|
|
1284
|
+
"this prompt is a Claude Code safety check that can only be "
|
|
1285
|
+
f"answered in its terminal: ccx attach {owner.worker}",
|
|
1286
|
+
short=owner.short,
|
|
1287
|
+
)
|
|
1288
|
+
actual_id = waiting["id"]
|
|
1289
|
+
if request_id is None:
|
|
1290
|
+
request_id = actual_id
|
|
1291
|
+
if request_id != actual_id:
|
|
1292
|
+
raise DeliveryRejected(
|
|
1293
|
+
"permission request_id differs from the current original gate",
|
|
1294
|
+
short=owner.short, refusal="request-id-mismatch",
|
|
1295
|
+
)
|
|
1296
|
+
verb = self._claude.approve if decision == "allow" else self._claude.reject
|
|
1297
|
+
remaining = _left(deadline)
|
|
1298
|
+
if remaining is not None and remaining <= 0:
|
|
1299
|
+
raise TurnIncomplete("permission budget elapsed before response",
|
|
1300
|
+
short=owner.short)
|
|
1301
|
+
result = verb(
|
|
1302
|
+
owner.worker, request_id=request_id, reason=reason,
|
|
1303
|
+
wait=wait, timeout=remaining,
|
|
1304
|
+
)
|
|
1305
|
+
reply = result.reply
|
|
1306
|
+
else:
|
|
1307
|
+
native = record.get("native") or {}
|
|
1308
|
+
original_thread = native.get("threadId")
|
|
1309
|
+
try:
|
|
1310
|
+
source = self._uuid_store.codex_source(owner.worker)
|
|
1311
|
+
activation = self._uuid_store.native_activation(owner.worker)
|
|
1312
|
+
except GenerationError as exc:
|
|
1313
|
+
raise SessionUnavailable(
|
|
1314
|
+
"original Codex approval source unavailable", short=owner.short,
|
|
1315
|
+
) from exc
|
|
1316
|
+
if (not isinstance(original_thread, str) or not original_thread
|
|
1317
|
+
or native.get("threadSource") != source
|
|
1318
|
+
or activation.get("state") != "confirmed"
|
|
1319
|
+
or activation.get("source") != source
|
|
1320
|
+
or (activation.get("nativeBinding") or {}).get("threadId")
|
|
1321
|
+
!= original_thread):
|
|
1322
|
+
raise SessionUnavailable(
|
|
1323
|
+
"original Codex approval thread/source unconfirmed",
|
|
1324
|
+
short=owner.short,
|
|
1325
|
+
)
|
|
1326
|
+
gates = [
|
|
1327
|
+
gate for gate in record.get("pendingApprovals") or []
|
|
1328
|
+
if isinstance(gate, dict)
|
|
1329
|
+
]
|
|
1330
|
+
if request_id is None:
|
|
1331
|
+
if len(gates) != 1:
|
|
1332
|
+
raise InvalidRequest(
|
|
1333
|
+
"Codex approval requires request_id when zero or multiple "
|
|
1334
|
+
"original gates are pending; inspect pending_input"
|
|
1335
|
+
)
|
|
1336
|
+
request_id = gates[0].get("gateId")
|
|
1337
|
+
matches = [gate for gate in gates if gate.get("gateId") == request_id]
|
|
1338
|
+
if len(matches) != 1:
|
|
1339
|
+
raise DeliveryRejected(
|
|
1340
|
+
"no unique original Codex permission for this CCX gateId; "
|
|
1341
|
+
"inspect pending_input before responding",
|
|
1342
|
+
short=owner.short, refusal="request-id-mismatch",
|
|
1343
|
+
)
|
|
1344
|
+
native_turn_id = matches[0].get("turnId")
|
|
1345
|
+
if (not isinstance(request_id, str) or not request_id
|
|
1346
|
+
or len(request_id) > 128 or "\x00" in request_id
|
|
1347
|
+
or not isinstance(native_turn_id, str) or not native_turn_id):
|
|
1348
|
+
raise SessionUnavailable(
|
|
1349
|
+
"original Codex approval gateId/turnId is untrusted",
|
|
1350
|
+
short=owner.short,
|
|
1351
|
+
)
|
|
1352
|
+
remaining = _left(deadline, 10)
|
|
1353
|
+
if remaining is not None and remaining <= 0:
|
|
1354
|
+
raise TurnIncomplete("approval budget elapsed before native decision",
|
|
1355
|
+
short=owner.short, turn_id=native_turn_id)
|
|
1356
|
+
op = "approve" if decision == "allow" else "reject"
|
|
1357
|
+
if wait:
|
|
1358
|
+
# Reuse the concrete client's original ACK check including the
|
|
1359
|
+
# same native turn; no raw daemon response or second decision.
|
|
1360
|
+
result = self._codex._decision(
|
|
1361
|
+
owner.worker, op, request_id, remaining,
|
|
1362
|
+
expected_turn_id=native_turn_id,
|
|
1363
|
+
)
|
|
1364
|
+
else:
|
|
1365
|
+
verb = self._codex.approve if decision == "allow" else self._codex.reject
|
|
1366
|
+
result = verb(owner.worker, request_id=request_id, timeout=remaining)
|
|
1367
|
+
request_id = result.request_id or request_id
|
|
1368
|
+
reply = (self._codex_settle(
|
|
1369
|
+
owner, deadline, decision, gate_id=request_id,
|
|
1370
|
+
turn_id=native_turn_id,
|
|
1371
|
+
) if wait else None)
|
|
1372
|
+
self._uuid_store.revalidate()
|
|
1373
|
+
return {
|
|
1374
|
+
"worker": owner.worker, "worker_id": owner.worker_id,
|
|
1375
|
+
"short": owner.short, "engine": owner.engine,
|
|
1376
|
+
"decision": decision, "request_id": request_id, "reply": reply,
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
# -- context management -------------------------------------------------
|
|
1380
|
+
|
|
1381
|
+
@staticmethod
|
|
1382
|
+
def _compact_result_row(owner, result, *, wait: bool = True,
|
|
1383
|
+
before_tokens: int | None = None) -> dict:
|
|
1384
|
+
"""Project one original Claude compact result, never an inferred turn."""
|
|
1385
|
+
proven = result.operation_status == "succeeded" and bool(result.compacted)
|
|
1386
|
+
pre = result.pre_tokens if proven else None
|
|
1387
|
+
post = result.post_tokens if proven else None
|
|
1388
|
+
comparable = type(pre) is int and type(post) is int
|
|
1389
|
+
return {
|
|
1390
|
+
"worker": owner.worker, "worker_id": owner.worker_id,
|
|
1391
|
+
"engine": owner.engine, "short": owner.short,
|
|
1392
|
+
"state": normalize_native_state(result.state),
|
|
1393
|
+
"native_state": result.state,
|
|
1394
|
+
"compacted": bool(result.compacted) if wait else False,
|
|
1395
|
+
"changed": (pre != post) if wait and comparable else None,
|
|
1396
|
+
"tokens_before": pre if wait else before_tokens,
|
|
1397
|
+
"tokens_after": post if wait else None,
|
|
1398
|
+
"message": result.message,
|
|
1399
|
+
"operation_id": result.operation_id,
|
|
1400
|
+
"operation_status": result.operation_status,
|
|
1401
|
+
"evidence_id": result.evidence_uuid,
|
|
1402
|
+
"reason": result.reason,
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
def compact(self, worker, *, wait: bool = True,
|
|
1406
|
+
timeout: float | None = None,
|
|
1407
|
+
operation_id: str | None = None) -> dict:
|
|
1408
|
+
"""Run one native compact through the exact-owner engine core.
|
|
1409
|
+
|
|
1410
|
+
The one pre-read is recorded, not a fabricated post-compact measurement.
|
|
1411
|
+
"""
|
|
1412
|
+
owner = self._uuid_owner(worker)
|
|
1413
|
+
if owner.engine == "codex":
|
|
1414
|
+
from uuid import uuid4
|
|
1415
|
+
from ccx.codex.ops import compact_worker
|
|
1416
|
+
from .errors import CompactFailed, CompactIndeterminate
|
|
1417
|
+
from ccx.store.root import GenerationError, NativeEffectIndeterminate
|
|
1418
|
+
|
|
1419
|
+
before = self.context(owner.worker) # One fenced recorded pre-read.
|
|
1420
|
+
token = str(uuid4()) if operation_id is None else operation_id
|
|
1421
|
+
try:
|
|
1422
|
+
result = compact_worker(
|
|
1423
|
+
owner.worker, operation_id=token, wait=wait, timeout=timeout,
|
|
1424
|
+
)
|
|
1425
|
+
except NativeEffectIndeterminate as exc:
|
|
1426
|
+
raise CompactIndeterminate(
|
|
1427
|
+
f"Codex compact effect unknown; reconcile, never resubmit: {exc}",
|
|
1428
|
+
short=owner.short, operation_id=token, operation_status="unknown",
|
|
1429
|
+
) from exc
|
|
1430
|
+
except GenerationError as exc:
|
|
1431
|
+
raise SessionUnavailable(
|
|
1432
|
+
f"full-UUID Codex native compact unavailable: {exc}",
|
|
1433
|
+
short=owner.short,
|
|
1434
|
+
) from exc
|
|
1435
|
+
native_state = (
|
|
1436
|
+
self._uuid_store.record(owner.worker).get("lifecycle") or {}
|
|
1437
|
+
).get("state")
|
|
1438
|
+
status = result["status"]
|
|
1439
|
+
if wait and status == "failed":
|
|
1440
|
+
raise CompactFailed(
|
|
1441
|
+
result.get("reason") or "Codex native compact turn failed",
|
|
1442
|
+
short=owner.short, state=native_state, operation_id=token,
|
|
1443
|
+
)
|
|
1444
|
+
if status == "rejected":
|
|
1445
|
+
raise DeliveryRejected(
|
|
1446
|
+
result.get("reason") or "Codex compact refused before native admission",
|
|
1447
|
+
short=owner.short, state=native_state,
|
|
1448
|
+
refusal="native-rejected", operation_id=token,
|
|
1449
|
+
)
|
|
1450
|
+
proven = status == "succeeded" and result.get("compacted") is True
|
|
1451
|
+
if wait and not proven:
|
|
1452
|
+
# Main contract: a returned wait=True compact is a settled one.
|
|
1453
|
+
raise CompactIndeterminate(
|
|
1454
|
+
f"Codex compact result {status!r} has no proven native completion",
|
|
1455
|
+
short=owner.short, state=native_state,
|
|
1456
|
+
operation_id=token, operation_status=status,
|
|
1457
|
+
)
|
|
1458
|
+
native_before = result.get("contextBefore")
|
|
1459
|
+
native_after = result.get("contextAfter") if proven else None
|
|
1460
|
+
baseline = native_before if isinstance(native_before, dict) else before
|
|
1461
|
+
pre = baseline.get("tokens_used") if isinstance(baseline, dict) else None
|
|
1462
|
+
post = (native_after.get("tokens_used")
|
|
1463
|
+
if isinstance(native_after, dict) else None)
|
|
1464
|
+
comparable = (proven and type(pre) is int and type(post) is int)
|
|
1465
|
+
return {
|
|
1466
|
+
"worker": owner.worker, "worker_id": owner.worker_id,
|
|
1467
|
+
"engine": "codex", "short": owner.short,
|
|
1468
|
+
"state": normalize_native_state(native_state),
|
|
1469
|
+
"native_state": native_state,
|
|
1470
|
+
"operation_id": token, "operation_status": status,
|
|
1471
|
+
"compacted": proven,
|
|
1472
|
+
"tokens_before": pre if type(pre) is int else None,
|
|
1473
|
+
"tokens_after": post if type(post) is int else None,
|
|
1474
|
+
"changed": (pre != post) if comparable else None,
|
|
1475
|
+
"message": result.get("reason") or f"Codex compact {status}",
|
|
1476
|
+
"reason": result.get("reason"),
|
|
1477
|
+
"evidence_id": result.get("contextItemId") if proven else None,
|
|
1478
|
+
"compact_turn_id": result.get("compactTurnId"),
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
before = self.context(owner.worker)
|
|
1482
|
+
result = self._claude.compact(
|
|
1483
|
+
owner.worker, wait=wait, timeout=timeout, operation_id=operation_id,
|
|
1484
|
+
)
|
|
1485
|
+
return self._compact_result_row(
|
|
1486
|
+
owner, result, wait=wait,
|
|
1487
|
+
before_tokens=before.get("tokens_used") if not wait else None,
|
|
1488
|
+
)
|
|
1489
|
+
|
|
1490
|
+
def compact_status(self, worker, operation_id: str) -> dict:
|
|
1491
|
+
"""Read an existing native compaction result without submitting work."""
|
|
1492
|
+
owner = self._uuid_owner(worker)
|
|
1493
|
+
if owner.engine == "claude":
|
|
1494
|
+
result = self._claude.compact_status(owner.worker, operation_id)
|
|
1495
|
+
return self._compact_result_row(owner, result)
|
|
1496
|
+
|
|
1497
|
+
from ccx.codex.ops import compact_observation
|
|
1498
|
+
row = compact_observation(owner.worker, operation_id)
|
|
1499
|
+
self._uuid_store.revalidate()
|
|
1500
|
+
native_state = (self._uuid_store.record(owner.worker).get("lifecycle") or {}).get("state")
|
|
1501
|
+
status = row["status"]
|
|
1502
|
+
proven = status == "succeeded" and row.get("compacted") is True
|
|
1503
|
+
before = row.get("contextBefore")
|
|
1504
|
+
after = row.get("contextAfter") if proven else None
|
|
1505
|
+
pre = before.get("tokens_used") if isinstance(before, dict) else None
|
|
1506
|
+
post = after.get("tokens_used") if isinstance(after, dict) else None
|
|
1507
|
+
comparable = type(pre) is int and type(post) is int
|
|
1508
|
+
return {
|
|
1509
|
+
"worker": owner.worker, "worker_id": owner.worker_id,
|
|
1510
|
+
"engine": owner.engine, "short": owner.short,
|
|
1511
|
+
"state": normalize_native_state(native_state),
|
|
1512
|
+
"native_state": native_state,
|
|
1513
|
+
"operation_id": operation_id, "operation_status": status,
|
|
1514
|
+
"compacted": proven,
|
|
1515
|
+
"tokens_before": pre if type(pre) is int else None,
|
|
1516
|
+
"tokens_after": post if type(post) is int else None,
|
|
1517
|
+
"changed": pre != post if proven and comparable else None,
|
|
1518
|
+
"message": row.get("reason") or f"Codex compact {status}",
|
|
1519
|
+
"reason": row.get("reason"),
|
|
1520
|
+
"evidence_id": row.get("contextItemId") if proven else None,
|
|
1521
|
+
"compact_turn_id": row.get("compactTurnId"),
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
# -- next-turn configuration -------------------------------------------
|
|
1525
|
+
|
|
1526
|
+
def set_model(self, worker, model: str | None, *, timeout: float = 5) -> dict:
|
|
1527
|
+
"""Persist the model for future turns without starting a native turn.
|
|
1528
|
+
|
|
1529
|
+
Claude automatically confirms a settled-idle host stop if warm;
|
|
1530
|
+
Codex updates the next original turn in-place without restarting.
|
|
1531
|
+
"""
|
|
1532
|
+
return self._set_next_turn_config(worker, "model", model, timeout=timeout)
|
|
1533
|
+
|
|
1534
|
+
def set_effort(self, worker, effort: str | None, *, timeout: float = 5) -> dict:
|
|
1535
|
+
"""Persist future-turn effort without changing current model work."""
|
|
1536
|
+
return self._set_next_turn_config(worker, "effort", effort, timeout=timeout)
|
|
1537
|
+
|
|
1538
|
+
def set_name(self, worker, name: str | None) -> dict:
|
|
1539
|
+
"""Rename a full-UUID worker with the same CAS used by the CLI."""
|
|
1540
|
+
owner=self._uuid_owner(worker)
|
|
1541
|
+
try:
|
|
1542
|
+
rec=self._uuid_store.rename_worker(
|
|
1543
|
+
owner.worker,engine=owner.engine,name=name,
|
|
1544
|
+
)
|
|
1545
|
+
except Exception as exc:
|
|
1546
|
+
from ccx.store.root import GenerationError
|
|
1547
|
+
if isinstance(exc, GenerationError):
|
|
1548
|
+
raise SessionUnavailable(f"full-UUID rename unavailable: {exc}") from exc
|
|
1549
|
+
raise
|
|
1550
|
+
return {
|
|
1551
|
+
"worker":owner.worker, "worker_id":owner.worker_id,
|
|
1552
|
+
"engine":owner.engine, "short":owner.short,
|
|
1553
|
+
"name":rec["name"], "action":"rename",
|
|
1554
|
+
"record_revision":rec["recordRevision"],
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
def _set_next_turn_config(self, worker, key: str, value: str | None,
|
|
1558
|
+
*, timeout: float) -> dict:
|
|
1559
|
+
"""Project one verified owner from its engine's existing config core."""
|
|
1560
|
+
owner = self._uuid_owner(worker)
|
|
1561
|
+
if owner.engine == "claude":
|
|
1562
|
+
configured, restarted = self._claude._set_spawn_config(
|
|
1563
|
+
owner.worker, {key: value}, restart=None, timeout=timeout,
|
|
1564
|
+
)
|
|
1565
|
+
else:
|
|
1566
|
+
configured = (
|
|
1567
|
+
self._codex.set_model(owner.worker, value, timeout=timeout)
|
|
1568
|
+
if key == "model" else
|
|
1569
|
+
self._codex.set_effort(owner.worker, value, timeout=timeout)
|
|
1570
|
+
)
|
|
1571
|
+
restarted = False
|
|
1572
|
+
self._uuid_store.revalidate()
|
|
1573
|
+
return {**configured.as_dict(), "restarted": restarted}
|
|
1574
|
+
|
|
1575
|
+
# -- lifecycle ----------------------------------------------------------
|
|
1576
|
+
|
|
1577
|
+
def resume(self, worker, *, timeout: float | None = 90) -> dict:
|
|
1578
|
+
"""Explicit promptless revival of the original engine runtime.
|
|
1579
|
+
|
|
1580
|
+
Claude restarts its proven original session; Codex re-subscribes the
|
|
1581
|
+
original thread. No synthetic model message is created.
|
|
1582
|
+
"""
|
|
1583
|
+
owner = self._uuid_owner(worker)
|
|
1584
|
+
from ccx.ops.send import ensure_ready
|
|
1585
|
+
result = ensure_ready(
|
|
1586
|
+
owner.worker, timeout=timeout,
|
|
1587
|
+
expected_generation=self._uuid_store.generation,
|
|
1588
|
+
)
|
|
1589
|
+
if owner.engine == "claude":
|
|
1590
|
+
return {"worker": owner.worker, "worker_id": owner.worker_id,
|
|
1591
|
+
"engine": "claude", "short": owner.short,
|
|
1592
|
+
"session_id": result["session_id"], "state": result["state"],
|
|
1593
|
+
"resumed": result["resumed"]}
|
|
1594
|
+
return {"worker": owner.worker, "worker_id": owner.worker_id,
|
|
1595
|
+
"engine": "codex", "short": owner.short,
|
|
1596
|
+
"thread_id": result["thread_id"], "state": result["state"],
|
|
1597
|
+
"resumed": result["resumed"]}
|
|
1598
|
+
|
|
1599
|
+
def stop(self, worker) -> dict:
|
|
1600
|
+
"""Request one exact active turn cancellation; keep the runtime alive.
|
|
1601
|
+
|
|
1602
|
+
``accepted`` means the native interrupt was requested, not that the
|
|
1603
|
+
original turn ended. ``confirmed``/``outcome`` are separate proof.
|
|
1604
|
+
"""
|
|
1605
|
+
from ccx.ops.cancel import stop_worker
|
|
1606
|
+
owner = self._uuid_owner(worker)
|
|
1607
|
+
result = stop_worker(
|
|
1608
|
+
owner.worker, expected_generation=self._uuid_store.generation,
|
|
1609
|
+
)
|
|
1610
|
+
return {
|
|
1611
|
+
**result, "worker": owner.worker, "short": owner.worker_id[:8],
|
|
1612
|
+
"worker_id": owner.worker_id, "engine": owner.engine,
|
|
1613
|
+
"requested": True,
|
|
1614
|
+
"cancelled": result["confirmed"] and result["outcome"] == "interrupted",
|
|
1615
|
+
"turn_id": (result["turn_ref"].rsplit(":", 1)[-1]
|
|
1616
|
+
if owner.engine == "codex" else None),
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
def kill(self, worker, *,
|
|
1620
|
+
timeout: float | None = terminate.KILL_TIMEOUT_DEFAULT_S) -> dict:
|
|
1621
|
+
"""Confirm only the exact owned worker runtime is terminated.
|
|
1622
|
+
|
|
1623
|
+
History survives; callable full-UUID resume is not yet integrated.
|
|
1624
|
+
"""
|
|
1625
|
+
from ccx.ops.wait import park_worker
|
|
1626
|
+
owner = self._uuid_owner(worker)
|
|
1627
|
+
result = park_worker(owner.worker, timeout=timeout,
|
|
1628
|
+
expected_generation=self._uuid_store.generation)
|
|
1629
|
+
return {**result, "stopped": True}
|
|
1630
|
+
|
|
1631
|
+
# -- event feed ---------------------------------------------------------
|
|
1632
|
+
|
|
1633
|
+
def events(self, worker, *, after: str | None = None,
|
|
1634
|
+
tail: int | None = None, limit: int = event_feed.LIMIT_DEFAULT,
|
|
1635
|
+
kinds=None, wait: float = 0.0) -> dict:
|
|
1636
|
+
"""One original full-UUID lifecycle page, optionally held for an append."""
|
|
1637
|
+
from ccx.ops.worker_events import EventCursorInvalid, wait_full_uuid_event_page
|
|
1638
|
+
from ccx.store.root import GenerationError
|
|
1639
|
+
from .errors import CursorInvalidated
|
|
1640
|
+
from ccx.store.identity import UnknownWorker
|
|
1641
|
+
|
|
1642
|
+
owner = self._uuid_owner(worker)
|
|
1643
|
+
try:
|
|
1644
|
+
page = wait_full_uuid_event_page(
|
|
1645
|
+
self._uuid_store, owner.worker, after=after, tail=tail,
|
|
1646
|
+
limit=limit, kinds=list(kinds) if kinds is not None else None,
|
|
1647
|
+
wait=wait,
|
|
1648
|
+
)
|
|
1649
|
+
# Internal semantic bookkeeping has no public lifecycle kind.
|
|
1650
|
+
# Retain the native cursor so subsequent pages remain resumable.
|
|
1651
|
+
if owner.engine == "claude":
|
|
1652
|
+
page["events"] = [event for event in page["events"]
|
|
1653
|
+
if event.get("kind") is not None]
|
|
1654
|
+
return page
|
|
1655
|
+
except EventCursorInvalid as exc:
|
|
1656
|
+
raise CursorInvalidated(f"Event history changed; restart reading without --after: {exc}") from exc
|
|
1657
|
+
except UnknownWorker as exc:
|
|
1658
|
+
raise worker_not_found(owner.worker) from exc
|
|
1659
|
+
except GenerationError as exc:
|
|
1660
|
+
raise SessionUnavailable(f"Events are unavailable; check the worker: {exc}") from exc
|
|
1661
|
+
|
|
1662
|
+
def follow(self, worker, *, after: str | None = None,
|
|
1663
|
+
tail: int | None = None, kinds=None,
|
|
1664
|
+
timeout: float | None = None,
|
|
1665
|
+
heartbeat: float | None = event_feed.HEARTBEAT_DEFAULT_S,
|
|
1666
|
+
) -> Iterator[dict]:
|
|
1667
|
+
"""Replay then follow one owned original event source, even while stopped."""
|
|
1668
|
+
from ccx.ops.worker_events import EventCursorInvalid, follow_full_uuid_events
|
|
1669
|
+
from ccx.store.root import GenerationError
|
|
1670
|
+
from .errors import CursorInvalidated
|
|
1671
|
+
from ccx.store.identity import UnknownWorker
|
|
1672
|
+
|
|
1673
|
+
owner = self._uuid_owner(worker)
|
|
1674
|
+
|
|
1675
|
+
def observations() -> Iterator[dict]:
|
|
1676
|
+
try:
|
|
1677
|
+
for event in follow_full_uuid_events(
|
|
1678
|
+
self._uuid_store, owner.worker, after=after, tail=tail,
|
|
1679
|
+
kinds=list(kinds) if kinds is not None else None,
|
|
1680
|
+
timeout=timeout, heartbeat=heartbeat,
|
|
1681
|
+
):
|
|
1682
|
+
if owner.engine != "claude" or event.get("kind") is not None:
|
|
1683
|
+
yield event
|
|
1684
|
+
except EventCursorInvalid as exc:
|
|
1685
|
+
raise CursorInvalidated(f"Event history changed; restart reading without --after: {exc}") from exc
|
|
1686
|
+
except UnknownWorker as exc:
|
|
1687
|
+
raise worker_not_found(owner.worker) from exc
|
|
1688
|
+
except GenerationError as exc:
|
|
1689
|
+
raise SessionUnavailable(f"Cannot follow events; check the worker: {exc}") from exc
|
|
1690
|
+
|
|
1691
|
+
return observations()
|
|
1692
|
+
|
|
1693
|
+
# -- conversation -------------------------------------------------------
|
|
1694
|
+
|
|
1695
|
+
def conversation_page(
|
|
1696
|
+
self,
|
|
1697
|
+
worker,
|
|
1698
|
+
*,
|
|
1699
|
+
cursor: str | None = None,
|
|
1700
|
+
source_record_target: int = 40,
|
|
1701
|
+
max_tool_result_bytes: int | None = 16_384,
|
|
1702
|
+
timeout: float = 30,
|
|
1703
|
+
) -> dict:
|
|
1704
|
+
"""Read one bounded normalized native-source conversation page.
|
|
1705
|
+
|
|
1706
|
+
The first call returns the newest source records; older_cursor pages
|
|
1707
|
+
backward. Page sizing is a target over provider/native conversation
|
|
1708
|
+
records, never over rendered messages or normalized children.
|
|
1709
|
+
"""
|
|
1710
|
+
from ccx.ops import conversation_paging as paging
|
|
1711
|
+
from ccx.claude.transcript import open_original_claude_source
|
|
1712
|
+
from ccx.store.root import GenerationError
|
|
1713
|
+
from ccx.store.identity import UnknownWorker
|
|
1714
|
+
from .errors import CursorInvalidated
|
|
1715
|
+
|
|
1716
|
+
paging._validate_options(source_record_target, max_tool_result_bytes, timeout)
|
|
1717
|
+
owner = self._uuid_owner(worker)
|
|
1718
|
+
if owner.engine == "codex":
|
|
1719
|
+
from ccx.codex import telemetry as codex_telemetry
|
|
1720
|
+
from .errors import OperationTimeout
|
|
1721
|
+
from ccx.store.identity import UnknownWorker
|
|
1722
|
+
|
|
1723
|
+
# The original journal fold and every budget refetch share one
|
|
1724
|
+
# caller deadline. The transport receives only the time remaining.
|
|
1725
|
+
deadline = time.monotonic() + float(timeout)
|
|
1726
|
+
store = self._uuid_store
|
|
1727
|
+
try:
|
|
1728
|
+
rec, thread_id, _journal, foreign, evidence = (
|
|
1729
|
+
codex_telemetry._original_source(store, owner)
|
|
1730
|
+
)
|
|
1731
|
+
remaining = deadline - time.monotonic()
|
|
1732
|
+
if remaining <= 0:
|
|
1733
|
+
raise OperationTimeout(
|
|
1734
|
+
"Codex conversation page deadline expired", short=owner.short,
|
|
1735
|
+
)
|
|
1736
|
+
page = paging.read_codex_page(
|
|
1737
|
+
rec, owner.worker, root=store.root,
|
|
1738
|
+
generation=store.generation, worker_id=owner.worker_id,
|
|
1739
|
+
thread_id=thread_id, expected_source=store.codex_source(owner.worker),
|
|
1740
|
+
evidence_index=evidence,
|
|
1741
|
+
foreign_evidence=bool(foreign), cursor=cursor,
|
|
1742
|
+
source_record_target=source_record_target,
|
|
1743
|
+
max_tool_result_bytes=max_tool_result_bytes,
|
|
1744
|
+
timeout=remaining,
|
|
1745
|
+
)
|
|
1746
|
+
store.revalidate()
|
|
1747
|
+
return page
|
|
1748
|
+
except UnknownWorker as exc:
|
|
1749
|
+
raise worker_not_found(owner.worker) from exc
|
|
1750
|
+
except (GenerationError, OSError) as exc:
|
|
1751
|
+
raise SessionUnavailable(
|
|
1752
|
+
f"original Codex conversation page source unavailable: {exc}",
|
|
1753
|
+
short=owner.short,
|
|
1754
|
+
) from exc
|
|
1755
|
+
store = self._uuid_store
|
|
1756
|
+
try:
|
|
1757
|
+
rec = store.record(owner.worker)
|
|
1758
|
+
with open_original_claude_source(store, owner, rec) as source:
|
|
1759
|
+
if source is None:
|
|
1760
|
+
if cursor is not None:
|
|
1761
|
+
raise CursorInvalidated(
|
|
1762
|
+
"original Claude transcript disappeared; restart paging",
|
|
1763
|
+
short=owner.short,
|
|
1764
|
+
)
|
|
1765
|
+
raise SessionNotFound(
|
|
1766
|
+
f"no original Claude transcript for {owner.worker}",
|
|
1767
|
+
short=owner.short,
|
|
1768
|
+
)
|
|
1769
|
+
pinned, source_stat, end_offset, _original_path, _directory = source
|
|
1770
|
+
page = paging.read_claude_page(
|
|
1771
|
+
pinned, owner.worker, root=store.root,
|
|
1772
|
+
generation=store.generation,
|
|
1773
|
+
session_id=rec["native"]["sessionId"],
|
|
1774
|
+
source_stat=source_stat, end_offset=end_offset,
|
|
1775
|
+
cursor=cursor, source_record_target=source_record_target,
|
|
1776
|
+
max_tool_result_bytes=max_tool_result_bytes, timeout=timeout,
|
|
1777
|
+
)
|
|
1778
|
+
store.revalidate()
|
|
1779
|
+
return page
|
|
1780
|
+
except UnknownWorker as exc:
|
|
1781
|
+
raise worker_not_found(owner.worker) from exc
|
|
1782
|
+
except (GenerationError, OSError) as exc:
|
|
1783
|
+
raise SessionUnavailable(
|
|
1784
|
+
f"original Claude conversation page source unavailable: {exc}",
|
|
1785
|
+
short=owner.short,
|
|
1786
|
+
) from exc
|
|
1787
|
+
|
|
1788
|
+
@contextmanager
|
|
1789
|
+
def _open_full_conversation(self, worker, *, max_tool_result_bytes,
|
|
1790
|
+
timeout, include_native=False,
|
|
1791
|
+
codex_recent_turns: int | None = None,
|
|
1792
|
+
claude_recent_turns: int | None = None):
|
|
1793
|
+
"""Build once from the original owner, retaining its native source until exit."""
|
|
1794
|
+
from ccx.codex import runtime as codex_runtime
|
|
1795
|
+
from ccx.codex import telemetry as codex_telemetry
|
|
1796
|
+
from ccx.claude.transcript import open_original_claude_source, PinnedNativeSource
|
|
1797
|
+
from ccx.store.root import GenerationError
|
|
1798
|
+
from ccx.store.identity import EngineMismatch, UnknownWorker
|
|
1799
|
+
from .errors import OperationTimeout
|
|
1800
|
+
|
|
1801
|
+
_conv._requested_label(max_tool_result_bytes)
|
|
1802
|
+
if (timeout is not None and (isinstance(timeout, bool)
|
|
1803
|
+
or not isinstance(timeout, (int, float))
|
|
1804
|
+
or not 0 < timeout < float("inf"))):
|
|
1805
|
+
raise InvalidRequest("conversation timeout must be positive and finite")
|
|
1806
|
+
deadline = time.monotonic() + timeout if timeout is not None else None
|
|
1807
|
+
|
|
1808
|
+
def read_budget() -> float:
|
|
1809
|
+
remaining = 120 if deadline is None else deadline - time.monotonic()
|
|
1810
|
+
if remaining <= 0:
|
|
1811
|
+
raise OperationTimeout("conversation read deadline expired")
|
|
1812
|
+
return remaining
|
|
1813
|
+
|
|
1814
|
+
if deadline is not None:
|
|
1815
|
+
read_budget()
|
|
1816
|
+
owner = self._uuid_owner(worker)
|
|
1817
|
+
store = self._uuid_store
|
|
1818
|
+
with ExitStack() as held:
|
|
1819
|
+
# Catch only original source/read failures. The yield (and export's
|
|
1820
|
+
# destination writes) must remain outside this exception mapping.
|
|
1821
|
+
try:
|
|
1822
|
+
native_files = ()
|
|
1823
|
+
if owner.engine == "claude":
|
|
1824
|
+
rec = store.record(owner.worker)
|
|
1825
|
+
source = held.enter_context(
|
|
1826
|
+
open_original_claude_source(store, owner, rec)
|
|
1827
|
+
)
|
|
1828
|
+
if source is None:
|
|
1829
|
+
raise SessionNotFound(
|
|
1830
|
+
f"no original Claude transcript available for {owner.worker}",
|
|
1831
|
+
short=owner.short,
|
|
1832
|
+
)
|
|
1833
|
+
pinned, _stat, end_offset, original_path, _directory = source
|
|
1834
|
+
native = rec.get("native") or {}
|
|
1835
|
+
view = {**rec, "short": owner.short,
|
|
1836
|
+
"sessionId": native.get("sessionId"),
|
|
1837
|
+
"transcript": pinned}
|
|
1838
|
+
after_offset = 0
|
|
1839
|
+
if claude_recent_turns:
|
|
1840
|
+
from ccx.ops.conversation_paging import claude_tail_offset
|
|
1841
|
+
after_offset = claude_tail_offset(
|
|
1842
|
+
pinned, before_offset=end_offset,
|
|
1843
|
+
turns=claude_recent_turns)
|
|
1844
|
+
document = _conv.build_claude(
|
|
1845
|
+
view, state=normalize_native_state(
|
|
1846
|
+
(rec.get("lifecycle") or {}).get("state")),
|
|
1847
|
+
worker=owner.worker,
|
|
1848
|
+
max_tool_result_bytes=max_tool_result_bytes,
|
|
1849
|
+
before_offset=end_offset, after_offset=after_offset,
|
|
1850
|
+
)
|
|
1851
|
+
# Public reference names the verified original pathname;
|
|
1852
|
+
# export uses the still-open native descriptor, not that path.
|
|
1853
|
+
document["nativeArtifacts"][0]["path"] = str(original_path)
|
|
1854
|
+
native_source = pinned
|
|
1855
|
+
else:
|
|
1856
|
+
rec, thread_id, journal, foreign, evidence = (
|
|
1857
|
+
codex_telemetry._original_source(store, owner)
|
|
1858
|
+
)
|
|
1859
|
+
if foreign:
|
|
1860
|
+
# Foreign events cannot prove original-thread coverage.
|
|
1861
|
+
evidence = {**evidence, "status": "degraded", "complete": False,
|
|
1862
|
+
"warnings": [*(evidence.get("warnings") or []),
|
|
1863
|
+
"excluded native evidence from a foreign thread"]}
|
|
1864
|
+
known_empty = rec.get("historyKnownEmpty") is True
|
|
1865
|
+
if known_empty:
|
|
1866
|
+
thread = {"id": thread_id, "threadSource": store.codex_source(owner.worker),
|
|
1867
|
+
"turns": []}
|
|
1868
|
+
else:
|
|
1869
|
+
# Only rich Codex peek requests a bounded recent window.
|
|
1870
|
+
# Ordinary full conversation, logs and export retain
|
|
1871
|
+
# their unmodified source-read argument contract.
|
|
1872
|
+
recent = ({"limit": codex_recent_turns, "newest_first": True}
|
|
1873
|
+
if codex_recent_turns is not None else {})
|
|
1874
|
+
thread = codex_runtime.read_thread(
|
|
1875
|
+
thread_id, cwd=rec["cwd"], timeout=read_budget(),
|
|
1876
|
+
worker_id=owner.worker_id,
|
|
1877
|
+
expected_source=store.codex_source(owner.worker),
|
|
1878
|
+
**recent,
|
|
1879
|
+
)
|
|
1880
|
+
document = _conv.build_codex(
|
|
1881
|
+
rec, thread, state=normalize_native_state(
|
|
1882
|
+
(rec.get("lifecycle") or {}).get("state")),
|
|
1883
|
+
worker=owner.worker,
|
|
1884
|
+
max_tool_result_bytes=max_tool_result_bytes, evidence=evidence,
|
|
1885
|
+
)
|
|
1886
|
+
if (codex_recent_turns is not None
|
|
1887
|
+
and len(thread.get("turns") or []) >= codex_recent_turns):
|
|
1888
|
+
# A bounded tail cannot claim global 1-based turn numbers,
|
|
1889
|
+
# nor pretend to describe the complete native history.
|
|
1890
|
+
for turn in document["turns"]:
|
|
1891
|
+
turn["sequence"] = None
|
|
1892
|
+
complete = document["completeness"]
|
|
1893
|
+
complete["sourceLimited"] = True
|
|
1894
|
+
complete["status"] = "source-limited"
|
|
1895
|
+
complete["warnings"] = [
|
|
1896
|
+
*(complete.get("warnings") or []),
|
|
1897
|
+
f"showing only the most recent {codex_recent_turns} native turns",
|
|
1898
|
+
]
|
|
1899
|
+
document["peekHistoryWindow"] = {
|
|
1900
|
+
"kind": "recent", "limit": codex_recent_turns,
|
|
1901
|
+
}
|
|
1902
|
+
# A proven empty history is a synthetic normalization view,
|
|
1903
|
+
# never evidence that a native thread/read actually occurred.
|
|
1904
|
+
native_source = None if known_empty else thread
|
|
1905
|
+
if include_native and journal.exists:
|
|
1906
|
+
try:
|
|
1907
|
+
fd = codex_evidence._owned_evidence_fd(
|
|
1908
|
+
owner.worker, os.O_RDONLY, store=store,
|
|
1909
|
+
)
|
|
1910
|
+
except FileNotFoundError:
|
|
1911
|
+
# Secondary evidence vanished; do not claim bytes.
|
|
1912
|
+
pass
|
|
1913
|
+
else:
|
|
1914
|
+
held.callback(os.close, fd)
|
|
1915
|
+
native_files = ((
|
|
1916
|
+
"codex-evidence-jsonl", "codex-evidence.jsonl",
|
|
1917
|
+
PinnedNativeSource(fd),
|
|
1918
|
+
),)
|
|
1919
|
+
store.revalidate()
|
|
1920
|
+
if deadline is not None:
|
|
1921
|
+
read_budget()
|
|
1922
|
+
except UnknownWorker as exc:
|
|
1923
|
+
raise worker_not_found(owner.worker) from exc
|
|
1924
|
+
except (GenerationError, OSError, EngineMismatch) as exc:
|
|
1925
|
+
raise SessionUnavailable(
|
|
1926
|
+
f"original {owner.engine} conversation source unavailable: {exc}",
|
|
1927
|
+
short=owner.short,
|
|
1928
|
+
) from exc
|
|
1929
|
+
yield owner, document, native_source, native_files
|
|
1930
|
+
|
|
1931
|
+
def conversation(self, worker, *, max_tool_result_bytes: int | None = None,
|
|
1932
|
+
timeout: float | None = None,
|
|
1933
|
+
_recent_codex_turns: int | None = None,
|
|
1934
|
+
_recent_claude_turns: int | None = None) -> dict:
|
|
1935
|
+
"""Read a normalized full history from this original full-UUID owner.
|
|
1936
|
+
|
|
1937
|
+
Claude reads a pinned native JSONL complete-line snapshot; Codex reads
|
|
1938
|
+
its proven native thread without loading a worker or starting a turn.
|
|
1939
|
+
Neither engine uses lifecycle events or a short-keyed transcript cache.
|
|
1940
|
+
"""
|
|
1941
|
+
with self._open_full_conversation(
|
|
1942
|
+
worker, max_tool_result_bytes=max_tool_result_bytes,
|
|
1943
|
+
timeout=timeout, codex_recent_turns=_recent_codex_turns,
|
|
1944
|
+
claude_recent_turns=_recent_claude_turns,
|
|
1945
|
+
) as (_owner, document, _native, _files):
|
|
1946
|
+
return document
|
|
1947
|
+
|
|
1948
|
+
def export_conversation(self, worker, destination, *, include_native: bool = True,
|
|
1949
|
+
max_tool_result_bytes: int | None = None) -> dict:
|
|
1950
|
+
"""Write ``conversation.json`` (+ hashed native evidence) atomically."""
|
|
1951
|
+
from ccx.store.root import GenerationError
|
|
1952
|
+
|
|
1953
|
+
with self._open_full_conversation(
|
|
1954
|
+
worker, max_tool_result_bytes=max_tool_result_bytes,
|
|
1955
|
+
timeout=None, include_native=include_native,
|
|
1956
|
+
) as (owner, document, native, native_files):
|
|
1957
|
+
written = _conv.write_export(
|
|
1958
|
+
document, destination, engine=owner.engine, native_source=native,
|
|
1959
|
+
include_native=include_native, native_files=native_files,
|
|
1960
|
+
)
|
|
1961
|
+
try:
|
|
1962
|
+
self._uuid_store.revalidate()
|
|
1963
|
+
except GenerationError as exc:
|
|
1964
|
+
raise SessionUnavailable(
|
|
1965
|
+
f"original {owner.engine} export generation unavailable: {exc}",
|
|
1966
|
+
short=owner.short,
|
|
1967
|
+
) from exc
|
|
1968
|
+
return {"worker": owner.worker, "engine": owner.engine,
|
|
1969
|
+
"short": owner.short,
|
|
1970
|
+
"destination": written["destination"],
|
|
1971
|
+
"conversation_path": written["conversation_path"],
|
|
1972
|
+
"native_path": written["native_path"],
|
|
1973
|
+
"native_paths": written["native_paths"],
|
|
1974
|
+
"turn_count": len(document["turns"]),
|
|
1975
|
+
"completeness": document["completeness"],
|
|
1976
|
+
"evidence": document["evidence"]}
|
|
1977
|
+
|
|
1978
|
+
# -- account usage ------------------------------------------------------
|
|
1979
|
+
|
|
1980
|
+
def usage(self, engine: str | None = None, *, include_tokens: bool = False,
|
|
1981
|
+
timeout: float = 30) -> dict:
|
|
1982
|
+
"""Account-scoped quota utilization for one or both providers.
|
|
1983
|
+
|
|
1984
|
+
The normalized ``windows`` list is a convenience, not a claim of
|
|
1985
|
+
equivalence: Claude and Codex quota buckets are provider-specific, so
|
|
1986
|
+
``raw`` always carries the untouched provider payload.
|
|
1987
|
+
|
|
1988
|
+
``timeout`` is one end-to-end budget for the whole call, so querying
|
|
1989
|
+
both providers cannot take twice as long as querying one. Neither
|
|
1990
|
+
provider supports unbounded account reads, so it must be a positive
|
|
1991
|
+
number — there is no ``None``.
|
|
1992
|
+
"""
|
|
1993
|
+
if engine is not None and engine not in _ENGINES:
|
|
1994
|
+
raise InvalidRequest(f"unknown engine: {engine!r}")
|
|
1995
|
+
if isinstance(timeout, bool) or not isinstance(timeout, (int, float)):
|
|
1996
|
+
raise InvalidRequest(
|
|
1997
|
+
f"usage timeout must be a positive number of seconds, "
|
|
1998
|
+
f"got {timeout!r}"
|
|
1999
|
+
)
|
|
2000
|
+
if timeout <= 0:
|
|
2001
|
+
raise InvalidRequest("usage timeout must be greater than zero")
|
|
2002
|
+
deadline = _deadline(timeout)
|
|
2003
|
+
if engine is not None:
|
|
2004
|
+
return self._usage_one(engine, include_tokens, deadline)
|
|
2005
|
+
from ccx.system.engine_binary import resolve_engine_binary, account_signed_out
|
|
2006
|
+
from .errors import EngineCliNotFound
|
|
2007
|
+
out: dict[str, dict] = {}
|
|
2008
|
+
errors: dict[str, dict] = {}
|
|
2009
|
+
skipped: dict[str, dict] = {}
|
|
2010
|
+
for eng in _ENGINES:
|
|
2011
|
+
try:
|
|
2012
|
+
binary = resolve_engine_binary(eng)
|
|
2013
|
+
except EngineCliNotFound as error:
|
|
2014
|
+
skipped[eng] = {"reason": "not_installed", "message": str(error)}
|
|
2015
|
+
continue
|
|
2016
|
+
try:
|
|
2017
|
+
out[eng] = self._usage_one(eng, include_tokens and eng == "codex", deadline)
|
|
2018
|
+
except _PROVIDER_FAILURES as error:
|
|
2019
|
+
if account_signed_out(eng, binary):
|
|
2020
|
+
skipped[eng] = {"reason": "signed_out", "message": f"{eng} is not signed in"}
|
|
2021
|
+
else:
|
|
2022
|
+
errors[eng] = _engine_error(error)
|
|
2023
|
+
return {"usage": out, "errors": errors, "skipped": skipped}
|
|
2024
|
+
|
|
2025
|
+
def _usage_one(self, engine: str, include_tokens: bool,
|
|
2026
|
+
deadline: float | None) -> dict:
|
|
2027
|
+
if engine == "claude" and include_tokens:
|
|
2028
|
+
raise _unsupported(engine, "account_token_activity",
|
|
2029
|
+
"account token-activity reporting")
|
|
2030
|
+
left = _left(deadline)
|
|
2031
|
+
if left is not None and left <= 0:
|
|
2032
|
+
# The other provider already spent the budget. Say so as a typed
|
|
2033
|
+
# per-engine failure rather than borrowing more time.
|
|
2034
|
+
raise SessionUnavailable(
|
|
2035
|
+
f"usage budget exhausted before {engine} was queried"
|
|
2036
|
+
)
|
|
2037
|
+
if engine == "claude":
|
|
2038
|
+
from ccx.system.engine_binary import resolve_engine_binary
|
|
2039
|
+
resolve_engine_binary("claude")
|
|
2040
|
+
data = fetch_usage(timeout=left, max_age=0)
|
|
2041
|
+
if not data:
|
|
2042
|
+
raise SessionUnavailable("could not read Claude account usage")
|
|
2043
|
+
return {"engine": "claude", "kind": "rate_limits",
|
|
2044
|
+
"windows": _claude_windows(usage_summary(data)),
|
|
2045
|
+
"raw": data}
|
|
2046
|
+
result = self._codex.usage(tokens=include_tokens, timeout=left)
|
|
2047
|
+
return {"engine": "codex", "kind": result.kind,
|
|
2048
|
+
"windows": [] if include_tokens else _codex_windows(result.raw),
|
|
2049
|
+
"raw": dict(result.raw)}
|
|
2050
|
+
|
|
2051
|
+
|
|
2052
|
+
def _controller_observed(method_name, function):
|
|
2053
|
+
"""Journal original full-UUID mutations when a controller is active."""
|
|
2054
|
+
@wraps(function)
|
|
2055
|
+
def observed(self, *args, **kwargs):
|
|
2056
|
+
from sys import modules
|
|
2057
|
+
controller = modules.get('ccx.controllers.controller')
|
|
2058
|
+
context = getattr(controller, "_active_runtime", None)
|
|
2059
|
+
if context is None or context.get() is None:
|
|
2060
|
+
return function(self, *args, **kwargs)
|
|
2061
|
+
# Static creation refusals remain before any controller ordinal.
|
|
2062
|
+
if method_name == "create":
|
|
2063
|
+
if kwargs.get("engine") not in _ENGINES:
|
|
2064
|
+
raise InvalidRequest(f"unknown worker engine: {kwargs.get('engine')!r}")
|
|
2065
|
+
from ccx.controllers.controller import observe_worker_call
|
|
2066
|
+
return observe_worker_call(self, method_name, function, args, kwargs)
|
|
2067
|
+
return observed
|
|
2068
|
+
|
|
2069
|
+
|
|
2070
|
+
for _controller_method in (
|
|
2071
|
+
"create", "fork", "send", "steer", "resume", "compact", "answer", "respond_elicitation", "approve",
|
|
2072
|
+
"reject", "stop", "kill",
|
|
2073
|
+
):
|
|
2074
|
+
setattr(
|
|
2075
|
+
WorkerClient,
|
|
2076
|
+
_controller_method,
|
|
2077
|
+
_controller_observed(_controller_method, getattr(WorkerClient, _controller_method)),
|
|
2078
|
+
)
|
|
2079
|
+
|
|
2080
|
+
|
|
2081
|
+
__all__ = ["WorkerClient", "evaluate_quota"]
|