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
|
@@ -0,0 +1,1556 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from ccx.version import ccx_version
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import queue
|
|
9
|
+
import signal
|
|
10
|
+
import socket
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
import uuid
|
|
16
|
+
from copy import deepcopy
|
|
17
|
+
from typing import Any
|
|
18
|
+
from ccx.codex.evidence import DELIVERY_CHANGED_METHOD, INPUT_RESOLVED_METHOD, LIFECYCLE_METHOD, EvidenceJournal, journal_path
|
|
19
|
+
from ccx.codex.paths import codexd_sock_path
|
|
20
|
+
from ccx.codex.protocol import AppServerClient, ProtocolError
|
|
21
|
+
from ccx.codex.records import (
|
|
22
|
+
iter_records,
|
|
23
|
+
now_ms,
|
|
24
|
+
read_record,
|
|
25
|
+
)
|
|
26
|
+
from ccx.constants import CODEXD_RECOVERY_OPS, CODEXD_RPC_GENERATION
|
|
27
|
+
from ccx.store.paths import accept_same_user, bind_private_unix_socket
|
|
28
|
+
from ccx.codex.daemon.models import ReleasePending, _LAUNCH_SETTLE_CAP_S, _ThreadRelease, _bounded, _native_history_budget, _protocol_failure, _retire_runtime_markers
|
|
29
|
+
from ccx.codex.daemon.worker import Worker
|
|
30
|
+
import ccx.codex.daemon.native as daemon_native
|
|
31
|
+
import ccx.codex.daemon.release as daemon_release
|
|
32
|
+
import ccx.codex.daemon.models as daemon_models
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
STABLE_STATES = {"idle", "waiting", "idle-killed", "crashed", "dead"}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
LIVE_STATES = {"booting", "resuming", "idle", "busy", "waiting"}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
RATE_LIMIT_CACHE_TTL_S = 30.0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
CAP_ADMISSION_READ_TIMEOUT_S = 1.0 # Preserve the caller's short native-send RPC budget.
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
DAEMON_DIAGNOSTIC_NOTIFICATIONS = {
|
|
48
|
+
"deprecationNotice",
|
|
49
|
+
"warning",
|
|
50
|
+
"guardianWarning",
|
|
51
|
+
"configWarning",
|
|
52
|
+
"model/rerouted",
|
|
53
|
+
"model/verification",
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _merge_non_null(old: Any, incoming: Any) -> Any:
|
|
58
|
+
"""Merge one sparse native update without letting null clear known data."""
|
|
59
|
+
|
|
60
|
+
if incoming is None:
|
|
61
|
+
return deepcopy(old)
|
|
62
|
+
if isinstance(old, dict) and isinstance(incoming, dict):
|
|
63
|
+
merged = deepcopy(old)
|
|
64
|
+
for key, value in incoming.items():
|
|
65
|
+
if value is None:
|
|
66
|
+
continue
|
|
67
|
+
if isinstance(value, dict) and isinstance(merged.get(key), dict):
|
|
68
|
+
merged[key] = _merge_non_null(merged[key], value)
|
|
69
|
+
else:
|
|
70
|
+
merged[key] = deepcopy(value)
|
|
71
|
+
return merged
|
|
72
|
+
return deepcopy(incoming)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _rate_limit_reset_passed(response: Any) -> bool:
|
|
76
|
+
"""Return whether a known primary or secondary reset boundary is past."""
|
|
77
|
+
|
|
78
|
+
rate_limits = response.get("rateLimits") if isinstance(response, dict) else None
|
|
79
|
+
if not isinstance(rate_limits, dict):
|
|
80
|
+
return False
|
|
81
|
+
now = time.time()
|
|
82
|
+
for name in ("primary", "secondary"):
|
|
83
|
+
window = rate_limits.get(name)
|
|
84
|
+
if not isinstance(window, dict):
|
|
85
|
+
continue
|
|
86
|
+
resets_at = window.get("resetsAt")
|
|
87
|
+
if (
|
|
88
|
+
isinstance(resets_at, (int, float))
|
|
89
|
+
and not isinstance(resets_at, bool)
|
|
90
|
+
and resets_at <= now
|
|
91
|
+
):
|
|
92
|
+
return True
|
|
93
|
+
return False
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _probe_codex_version(codex_bin: str, *, timeout: float = 5) -> str | None:
|
|
97
|
+
"""Return the raw Codex version when the optional probe succeeds."""
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
completed = subprocess.run(
|
|
101
|
+
[codex_bin, "--version"],
|
|
102
|
+
capture_output=True,
|
|
103
|
+
text=True,
|
|
104
|
+
check=False,
|
|
105
|
+
timeout=timeout,
|
|
106
|
+
)
|
|
107
|
+
except (OSError, subprocess.SubprocessError):
|
|
108
|
+
return None
|
|
109
|
+
if completed.returncode != 0:
|
|
110
|
+
return None
|
|
111
|
+
output = completed.stdout.strip()
|
|
112
|
+
return output or None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _thread_id(params: dict[str, Any]) -> str | None:
|
|
116
|
+
"""Extract the routing thread id from any stable-API server message."""
|
|
117
|
+
value = params.get("threadId")
|
|
118
|
+
if isinstance(value, str) and value:
|
|
119
|
+
return value
|
|
120
|
+
thread = params.get("thread")
|
|
121
|
+
if isinstance(thread, dict) and isinstance(thread.get("id"), str):
|
|
122
|
+
return thread["id"]
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _journal_orphan_repair(
|
|
127
|
+
record: dict[str, Any],
|
|
128
|
+
previous: str | None,
|
|
129
|
+
codex_version: str | None,
|
|
130
|
+
) -> None:
|
|
131
|
+
"""Journal the transition an orphan repair wrote, as a loaded worker would.
|
|
132
|
+
|
|
133
|
+
The dead daemon never journaled it, so without this the event feed would
|
|
134
|
+
keep showing the worker in its last live state. The lines are the ones
|
|
135
|
+
``Worker._journal_lifecycle_locked`` writes: one ``input/resolved`` per
|
|
136
|
+
approval the repair dropped, then one ``lifecycle`` line. A worker with no
|
|
137
|
+
journal yet gets none, as it would not have been loaded with one.
|
|
138
|
+
"""
|
|
139
|
+
short = record["workerId"]
|
|
140
|
+
if not journal_path(short).exists():
|
|
141
|
+
return
|
|
142
|
+
lifecycle = record.get("lifecycle") or {}
|
|
143
|
+
thread_id = (record.get("native") or {}).get("threadId")
|
|
144
|
+
journal = EvidenceJournal(
|
|
145
|
+
short, codex_version=codex_version, health=record.get("evidence"),
|
|
146
|
+
)
|
|
147
|
+
try:
|
|
148
|
+
for approval in record.get("pendingApprovals") or []:
|
|
149
|
+
if isinstance(approval, dict):
|
|
150
|
+
journal.record(INPUT_RESOLVED_METHOD, {
|
|
151
|
+
"requestId": approval.get("requestId"),
|
|
152
|
+
"gateId": approval.get("gateId"),
|
|
153
|
+
"kind": approval.get("kind"),
|
|
154
|
+
"decision": None,
|
|
155
|
+
}, thread_id=thread_id)
|
|
156
|
+
journal.record(LIFECYCLE_METHOD, {
|
|
157
|
+
"from": previous,
|
|
158
|
+
"to": lifecycle.get("state"),
|
|
159
|
+
"reason": lifecycle.get("lastError"),
|
|
160
|
+
}, thread_id=thread_id)
|
|
161
|
+
finally:
|
|
162
|
+
journal.close()
|
|
163
|
+
record["evidence"] = journal.state()
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class CodexDaemon:
|
|
167
|
+
def __init__(self):
|
|
168
|
+
self.lock = threading.RLock()
|
|
169
|
+
# Durable create markers use this nonce to distinguish "the native
|
|
170
|
+
# request may still complete later" from "the owning app-server is gone".
|
|
171
|
+
self.instance_id = uuid.uuid4().hex
|
|
172
|
+
# Per-daemon bounded one-shot pending/positive Codex creation proofs.
|
|
173
|
+
# Lock only this map briefly; never hold it while acquiring the store
|
|
174
|
+
# generation lease/effect lock or waiting for app-server responses.
|
|
175
|
+
self._native_start_hold_lock = threading.Lock()
|
|
176
|
+
self._native_start_held: dict[tuple[str, str, str], dict[str, Any]] = {}
|
|
177
|
+
# Unsafe original evidence is diagnostic only: it cannot be repaired,
|
|
178
|
+
# but must not permanently consume capacity reserved for recoverable
|
|
179
|
+
# responses. Old diagnostics roll off at the bounded cap; never evict a
|
|
180
|
+
# safe unsaved positive reply or an unanswered original native start.
|
|
181
|
+
self._native_start_unsafe: dict[tuple[str, str, str], str] = {}
|
|
182
|
+
self._native_start_unsafe_total = 0
|
|
183
|
+
self.workers: dict[str, Worker] = {}
|
|
184
|
+
self.by_thread: dict[str, str] = {}
|
|
185
|
+
self.client: AppServerClient | None = None
|
|
186
|
+
self.app_server_path = None
|
|
187
|
+
self.app_server_inode = None
|
|
188
|
+
self.codex_version: str | None = None
|
|
189
|
+
self.diagnostics: list[dict[str, Any]] = []
|
|
190
|
+
self._seen_diagnostics: set[tuple[str, str, str | None]] = set()
|
|
191
|
+
self.ready = threading.Event()
|
|
192
|
+
self.stop = threading.Event()
|
|
193
|
+
# Set under `self.lock` in the same critical section that decides to
|
|
194
|
+
# stop, and never cleared. From then on no worker is registered, so a
|
|
195
|
+
# load cannot start on a runtime about to close; callers see
|
|
196
|
+
# `stopping` and wait for the next daemon instead.
|
|
197
|
+
self.stopping = False
|
|
198
|
+
self.started_at = time.monotonic()
|
|
199
|
+
self.empty_since: float | None = time.monotonic()
|
|
200
|
+
# Native thread id -> unsubscribes Codex has not answered yet.
|
|
201
|
+
self.releases: dict[str, list[_ThreadRelease]] = {}
|
|
202
|
+
self.lifecycle_locks: dict[str, threading.Lock] = {}
|
|
203
|
+
self.server: socket.socket | None = None
|
|
204
|
+
self.accepter: threading.Thread | None = None
|
|
205
|
+
self.rate_limits_cache: dict[str, Any] | None = None
|
|
206
|
+
self.rate_limits_read_at: float | None = None
|
|
207
|
+
# Lost-acknowledgment recovery from journaled user messages. Its
|
|
208
|
+
# receipt and native-intent locks can be held by a dispatching caller
|
|
209
|
+
# waiting on this app-server, so it never runs on the reader thread.
|
|
210
|
+
self._recovery_queue: "queue.Queue[tuple[Worker, str]]" = queue.Queue()
|
|
211
|
+
self._recovery_thread: threading.Thread | None = None
|
|
212
|
+
self._recovery_done: set[tuple[str, str]] = set()
|
|
213
|
+
|
|
214
|
+
def queue_delivery_recovery(self, worker: "Worker", client_id: str) -> None:
|
|
215
|
+
with self.lock:
|
|
216
|
+
if self._recovery_thread is None:
|
|
217
|
+
self._recovery_thread = threading.Thread(
|
|
218
|
+
target=self._delivery_recovery_loop, daemon=True,
|
|
219
|
+
name="ccx-codex-delivery-recovery",
|
|
220
|
+
)
|
|
221
|
+
self._recovery_thread.start()
|
|
222
|
+
self._recovery_queue.put((worker, client_id))
|
|
223
|
+
|
|
224
|
+
def _delivery_recovery_loop(self) -> None:
|
|
225
|
+
while not self.stop.is_set():
|
|
226
|
+
try:
|
|
227
|
+
worker, client_id = self._recovery_queue.get(timeout=.5)
|
|
228
|
+
except queue.Empty:
|
|
229
|
+
continue
|
|
230
|
+
try:
|
|
231
|
+
self._recover_delivery(worker, client_id)
|
|
232
|
+
except Exception as error: # noqa: BLE001 - one delivery, never the loop
|
|
233
|
+
print("ccx codexd: delivery recovery deferred: "
|
|
234
|
+
+ type(error).__name__ + ": " + str(error)[:200],
|
|
235
|
+
file=sys.stderr, flush=True)
|
|
236
|
+
|
|
237
|
+
def _recover_delivery(self, worker: "Worker", client_id: str) -> None:
|
|
238
|
+
"""Recover one journaled user message's delivery, then wake readers.
|
|
239
|
+
|
|
240
|
+
Retries only while a lock is held (a dispatching caller owns the
|
|
241
|
+
native intent for at most its bounded dispatch), never on missing or
|
|
242
|
+
ambiguous evidence. The wake line is written strictly after the
|
|
243
|
+
receipt is accepted, so a reader that sees it reconciles a final
|
|
244
|
+
answer; a reader that read earlier sees this later line.
|
|
245
|
+
"""
|
|
246
|
+
from ccx.codex.delivery_recovery import delivery_for_client, recover
|
|
247
|
+
from ccx.store.root import GenerationBusy
|
|
248
|
+
key = (worker.worker_id, client_id)
|
|
249
|
+
if key in self._recovery_done:
|
|
250
|
+
return
|
|
251
|
+
handle = f"codex:{worker.worker_id}"
|
|
252
|
+
receipt = None
|
|
253
|
+
for _attempt in range(40):
|
|
254
|
+
store = self._uuid_store
|
|
255
|
+
try:
|
|
256
|
+
store.revalidate()
|
|
257
|
+
delivery = delivery_for_client(handle, client_id)
|
|
258
|
+
if delivery is None:
|
|
259
|
+
return
|
|
260
|
+
receipt = recover(store, handle, delivery)
|
|
261
|
+
effect = store.read_receipt(handle, "codex-send", delivery)
|
|
262
|
+
except GenerationBusy:
|
|
263
|
+
receipt = None
|
|
264
|
+
else:
|
|
265
|
+
if not isinstance(receipt, dict) or receipt.get("state") != "accepted":
|
|
266
|
+
return # Unknown stays unknown; a later item may retry.
|
|
267
|
+
if not (isinstance(effect, dict)
|
|
268
|
+
and effect.get("state") == "submitted-unknown"):
|
|
269
|
+
break
|
|
270
|
+
if self.stop.wait(.25):
|
|
271
|
+
return
|
|
272
|
+
if not isinstance(receipt, dict) or receipt.get("state") != "accepted":
|
|
273
|
+
return
|
|
274
|
+
self._recovery_done.add(key)
|
|
275
|
+
with worker.lock:
|
|
276
|
+
if worker.teardown_claimed or worker.evidence.closed:
|
|
277
|
+
return
|
|
278
|
+
worker.evidence.record(DELIVERY_CHANGED_METHOD, {
|
|
279
|
+
"deliveryId": receipt.get("deliveryId"),
|
|
280
|
+
"state": receipt.get("state"),
|
|
281
|
+
"route": receipt.get("route"),
|
|
282
|
+
"placement": receipt.get("placement"),
|
|
283
|
+
# Deliberately no top-level turnId: feed lines never claim
|
|
284
|
+
# native turn evidence (build_index and settlement skip them).
|
|
285
|
+
"turnRef": receipt.get("turnRef"),
|
|
286
|
+
}, thread_id=(worker.record.get("native") or {}).get("threadId"))
|
|
287
|
+
|
|
288
|
+
def _record_diagnostic(
|
|
289
|
+
self, method: str, params: dict[str, Any]
|
|
290
|
+
) -> None:
|
|
291
|
+
"""Keep a bounded daemon-global diagnostic and log its useful text."""
|
|
292
|
+
summary = params.get("summary") or params.get("message")
|
|
293
|
+
if not isinstance(summary, str) or not summary:
|
|
294
|
+
if method == "model/rerouted":
|
|
295
|
+
summary = (
|
|
296
|
+
f"model rerouted from {params.get('fromModel')!s} "
|
|
297
|
+
f"to {params.get('toModel')!s}"
|
|
298
|
+
)
|
|
299
|
+
elif method == "model/verification":
|
|
300
|
+
summary = "Codex model verification update"
|
|
301
|
+
else:
|
|
302
|
+
summary = f"Codex {method} notification"
|
|
303
|
+
details = params.get("details")
|
|
304
|
+
if not isinstance(details, str) or not details:
|
|
305
|
+
details = None
|
|
306
|
+
entry = {
|
|
307
|
+
"method": method,
|
|
308
|
+
"summary": _bounded(summary),
|
|
309
|
+
"details": _bounded(details),
|
|
310
|
+
"codexVersion": self.codex_version,
|
|
311
|
+
"observedAtMs": now_ms(),
|
|
312
|
+
}
|
|
313
|
+
with self.lock:
|
|
314
|
+
key = (method, entry["summary"], entry["details"])
|
|
315
|
+
if key in self._seen_diagnostics:
|
|
316
|
+
return
|
|
317
|
+
self._seen_diagnostics.add(key)
|
|
318
|
+
self.diagnostics.append(entry)
|
|
319
|
+
del self.diagnostics[:-20]
|
|
320
|
+
log = f"ccx codexd: {method}: {entry['summary']}"
|
|
321
|
+
if entry["details"]:
|
|
322
|
+
log += f" — {entry['details']}"
|
|
323
|
+
print(log, file=sys.stderr, flush=True)
|
|
324
|
+
|
|
325
|
+
def bind_thread(self, thread_id: str, short: str) -> None:
|
|
326
|
+
with self.lock:
|
|
327
|
+
self.by_thread[thread_id] = short
|
|
328
|
+
|
|
329
|
+
def claim_thread(self, thread_id: str, short: str) -> None:
|
|
330
|
+
"""Bind a thread a load is about to resume, or refuse it.
|
|
331
|
+
|
|
332
|
+
The subscription belongs to the shared connection, so one native
|
|
333
|
+
thread has at most one live owner. The fence check and the binding are
|
|
334
|
+
one step under `self.lock`: a teardown that fences the thread later
|
|
335
|
+
can only be the owner's own, and an unanswered release from an earlier
|
|
336
|
+
owner refuses the claim instead of racing it.
|
|
337
|
+
"""
|
|
338
|
+
with self.lock:
|
|
339
|
+
if self.releases.get(thread_id):
|
|
340
|
+
raise ReleasePending(
|
|
341
|
+
f"Codex has not yet confirmed releasing thread "
|
|
342
|
+
f"{thread_id} from an earlier unload; retry shortly"
|
|
343
|
+
)
|
|
344
|
+
owner = self.by_thread.get(thread_id)
|
|
345
|
+
if owner is not None and owner != short:
|
|
346
|
+
raise ProtocolError(
|
|
347
|
+
f"native Codex thread {thread_id} is already loaded by "
|
|
348
|
+
f"worker {owner}"
|
|
349
|
+
)
|
|
350
|
+
self.by_thread[thread_id] = short
|
|
351
|
+
|
|
352
|
+
def unbind_thread(self, thread_id: str) -> None:
|
|
353
|
+
with self.lock:
|
|
354
|
+
self.by_thread.pop(thread_id, None)
|
|
355
|
+
|
|
356
|
+
def _drop_thread_bindings_locked(self, short: str) -> None:
|
|
357
|
+
"""Remove every thread binding owned by one worker. Caller holds the lock."""
|
|
358
|
+
for thread_id, owner in list(self.by_thread.items()):
|
|
359
|
+
if owner == short:
|
|
360
|
+
del self.by_thread[thread_id]
|
|
361
|
+
|
|
362
|
+
def _lifecycle_lock(self, short: str) -> threading.Lock:
|
|
363
|
+
with self.lock:
|
|
364
|
+
lock = self.lifecycle_locks.get(short)
|
|
365
|
+
if lock is None:
|
|
366
|
+
lock = threading.Lock()
|
|
367
|
+
self.lifecycle_locks[short] = lock
|
|
368
|
+
return lock
|
|
369
|
+
|
|
370
|
+
def _idle_kill(self, worker: Worker) -> None:
|
|
371
|
+
# Unload only. The native thread stays unarchived so Codex clients and
|
|
372
|
+
# native ops such as thread/fork can still use it.
|
|
373
|
+
self._unload(worker, terminal_state="idle-killed")
|
|
374
|
+
|
|
375
|
+
def _worker_for(self, params: dict[str, Any]) -> Worker | None:
|
|
376
|
+
thread_id = _thread_id(params)
|
|
377
|
+
if not thread_id:
|
|
378
|
+
return None
|
|
379
|
+
with self.lock:
|
|
380
|
+
short = self.by_thread.get(thread_id)
|
|
381
|
+
return self.workers.get(short) if short else None
|
|
382
|
+
|
|
383
|
+
def _cached_rate_limits(self) -> dict[str, Any] | None:
|
|
384
|
+
with self.lock:
|
|
385
|
+
if self.rate_limits_cache is None or self.rate_limits_read_at is None:
|
|
386
|
+
return None
|
|
387
|
+
if time.monotonic() - self.rate_limits_read_at >= RATE_LIMIT_CACHE_TTL_S:
|
|
388
|
+
return None
|
|
389
|
+
if _rate_limit_reset_passed(self.rate_limits_cache):
|
|
390
|
+
return None
|
|
391
|
+
return deepcopy(self.rate_limits_cache)
|
|
392
|
+
|
|
393
|
+
def _cap_data_from_cache(self) -> dict | None:
|
|
394
|
+
"""Prefer the push-merged general bucket over an older named snapshot."""
|
|
395
|
+
cached = self._cached_rate_limits()
|
|
396
|
+
if (isinstance(cached, dict)
|
|
397
|
+
and isinstance(cached.get("rateLimits"), dict)
|
|
398
|
+
and cached["rateLimits"].get("limitId") in (None, "codex")):
|
|
399
|
+
return {"rateLimits": cached["rateLimits"]}
|
|
400
|
+
return cached
|
|
401
|
+
|
|
402
|
+
def _cap_at_admission(self, caps: dict | None):
|
|
403
|
+
"""Preflight known quota; one short native read on a cold cache.
|
|
404
|
+
|
|
405
|
+
Called only by the native-send request handler, not the app-server
|
|
406
|
+
notification reader. An unavailable quota signal must never block a turn.
|
|
407
|
+
"""
|
|
408
|
+
from ccx.codex.types import codex_cap_tripped
|
|
409
|
+
data = self._cap_data_from_cache()
|
|
410
|
+
if data is None and caps and self.client is not None:
|
|
411
|
+
try:
|
|
412
|
+
self._read_rate_limits(timeout=CAP_ADMISSION_READ_TIMEOUT_S)
|
|
413
|
+
data = self._cap_data_from_cache()
|
|
414
|
+
except Exception:
|
|
415
|
+
# Account quota observations are advisory; fail open.
|
|
416
|
+
pass
|
|
417
|
+
return codex_cap_tripped(data, caps)
|
|
418
|
+
|
|
419
|
+
def _merge_rate_limits_update(self, params: dict[str, Any]) -> None:
|
|
420
|
+
incoming = params.get("rateLimits")
|
|
421
|
+
if not isinstance(incoming, dict) or incoming.get("limitId") not in (None, "codex"):
|
|
422
|
+
return
|
|
423
|
+
with self.lock:
|
|
424
|
+
if self.rate_limits_cache is None:
|
|
425
|
+
self.rate_limits_cache = {"rateLimits": deepcopy(incoming)}
|
|
426
|
+
self.rate_limits_read_at = time.monotonic()
|
|
427
|
+
return
|
|
428
|
+
self.rate_limits_cache["rateLimits"] = _merge_non_null(
|
|
429
|
+
self.rate_limits_cache.get("rateLimits"), incoming
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
def _read_rate_limits(self, *, timeout: float = 30.0) -> Any:
|
|
433
|
+
cached = self._cached_rate_limits()
|
|
434
|
+
if cached is not None:
|
|
435
|
+
return cached
|
|
436
|
+
result = self.client.request("account/rateLimits/read", timeout=timeout)
|
|
437
|
+
if isinstance(result, dict):
|
|
438
|
+
with self.lock:
|
|
439
|
+
self.rate_limits_cache = deepcopy(result)
|
|
440
|
+
self.rate_limits_read_at = time.monotonic()
|
|
441
|
+
return deepcopy(result)
|
|
442
|
+
return result
|
|
443
|
+
|
|
444
|
+
@staticmethod
|
|
445
|
+
def _persistence_failed(worker: Worker, method: str, error: BaseException) -> None:
|
|
446
|
+
"""Degrade one worker, never mistake a store fault for wire failure."""
|
|
447
|
+
worker.persistence_error = {
|
|
448
|
+
"method": method,
|
|
449
|
+
"error": str(error),
|
|
450
|
+
"kind": type(error).__name__,
|
|
451
|
+
"observedAtMs": now_ms(),
|
|
452
|
+
}
|
|
453
|
+
print(
|
|
454
|
+
f"ccx codexd: could not persist worker {worker.short} after "
|
|
455
|
+
f"{method}: {error}",
|
|
456
|
+
file=sys.stderr, flush=True,
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
def _contain_store_failure(
|
|
460
|
+
self, worker: Worker, method: str, error: BaseException
|
|
461
|
+
) -> None:
|
|
462
|
+
from ccx.store.root import StaleGeneration, GenerationUnavailable
|
|
463
|
+
self._persistence_failed(worker, method, error)
|
|
464
|
+
if isinstance(error, (StaleGeneration, GenerationUnavailable)):
|
|
465
|
+
# Not a per-worker rebase: the pinned generation is no longer
|
|
466
|
+
# usable. Close global admission rather than trying a new root.
|
|
467
|
+
with self.lock:
|
|
468
|
+
self.stopping = True
|
|
469
|
+
self.stop.set()
|
|
470
|
+
|
|
471
|
+
def _on_notification(self, method: str, params: dict[str, Any]) -> None:
|
|
472
|
+
# Global diagnostics have no reliable worker ownership (notably
|
|
473
|
+
# deprecationNotice), so capture them before attempting thread routing.
|
|
474
|
+
if method in DAEMON_DIAGNOSTIC_NOTIFICATIONS:
|
|
475
|
+
self._record_diagnostic(method, params)
|
|
476
|
+
return
|
|
477
|
+
if method == "account/rateLimits/updated":
|
|
478
|
+
self._merge_rate_limits_update(params)
|
|
479
|
+
merged = self._cap_data_from_cache()
|
|
480
|
+
with self.lock:
|
|
481
|
+
workers = list(self.workers.values())
|
|
482
|
+
for active in workers:
|
|
483
|
+
active.check_active_caps(params)
|
|
484
|
+
if merged is not None:
|
|
485
|
+
active.check_active_caps(merged)
|
|
486
|
+
return
|
|
487
|
+
worker = self._worker_for(params)
|
|
488
|
+
if worker is not None:
|
|
489
|
+
from ccx.store.root import RecordConflict, GenerationBusy, StaleGeneration, GenerationUnavailable, StorageIntegrityError
|
|
490
|
+
try:
|
|
491
|
+
# A native notification is not allowed to write even its
|
|
492
|
+
# journal after a generation flip.
|
|
493
|
+
self._uuid_store.revalidate()
|
|
494
|
+
worker.on_notification(method, params)
|
|
495
|
+
except (OSError, RecordConflict, GenerationBusy, StaleGeneration,
|
|
496
|
+
GenerationUnavailable, StorageIntegrityError) as error:
|
|
497
|
+
self._contain_store_failure(worker, method, error)
|
|
498
|
+
# Unknown and intentionally unused notifications are additive data.
|
|
499
|
+
# They do not belong in worker records and are intentionally a no-op.
|
|
500
|
+
|
|
501
|
+
def _on_server_request(
|
|
502
|
+
self, request_id: Any, method: str, params: dict[str, Any]
|
|
503
|
+
) -> None:
|
|
504
|
+
worker = self._worker_for(params)
|
|
505
|
+
if worker is not None:
|
|
506
|
+
from ccx.store.root import RecordConflict, GenerationBusy, StaleGeneration, GenerationUnavailable, StorageIntegrityError
|
|
507
|
+
try:
|
|
508
|
+
self._uuid_store.revalidate()
|
|
509
|
+
worker.on_server_request(request_id, method, params)
|
|
510
|
+
except (OSError, RecordConflict, GenerationBusy, StaleGeneration,
|
|
511
|
+
GenerationUnavailable, StorageIntegrityError) as error:
|
|
512
|
+
# A failed pending-approval CAS never becomes an in-memory
|
|
513
|
+
# authorization. The native request is explicitly rejected;
|
|
514
|
+
# silence would leave a hanging approval or a false gate.
|
|
515
|
+
with worker.lock:
|
|
516
|
+
worker.record = deepcopy(worker._disk_baseline)
|
|
517
|
+
self._contain_store_failure(worker, method, error)
|
|
518
|
+
if self.client is not None:
|
|
519
|
+
self.client.reject_request(
|
|
520
|
+
request_id, "CCX could not durably admit this approval"
|
|
521
|
+
)
|
|
522
|
+
elif self.client is not None:
|
|
523
|
+
self.client.reject_request(
|
|
524
|
+
request_id, f"CCX has no handler for app-server request {method}"
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
def _start_client(self) -> None:
|
|
528
|
+
from ccx.system.engine_binary import resolve_engine_binary
|
|
529
|
+
codex_bin = resolve_engine_binary("codex")
|
|
530
|
+
# Version is useful factual metadata, but it is not a production
|
|
531
|
+
# permission check. In particular, an unfamiliar or failing probe
|
|
532
|
+
# must not prevent a working app-server from being attempted.
|
|
533
|
+
self.codex_version = _probe_codex_version(codex_bin)
|
|
534
|
+
import stat
|
|
535
|
+
app_path = codexd_sock_path().with_name("app.sock")
|
|
536
|
+
# The existing codexd transport plan supplies the short private
|
|
537
|
+
# directory (including Darwin's 104-byte Unix socket limit).
|
|
538
|
+
try:
|
|
539
|
+
prior = os.lstat(app_path)
|
|
540
|
+
except FileNotFoundError:
|
|
541
|
+
prior = None
|
|
542
|
+
if prior is not None:
|
|
543
|
+
if (prior.st_uid != os.getuid()
|
|
544
|
+
or not (stat.S_ISSOCK(prior.st_mode)
|
|
545
|
+
or stat.S_ISLNK(prior.st_mode))):
|
|
546
|
+
raise RuntimeError("untrusted old Codex app-server socket")
|
|
547
|
+
# A macOS Codex listener is a symlink; unlink the pathname
|
|
548
|
+
# itself, never its target, under the existing singleton lease.
|
|
549
|
+
os.unlink(app_path)
|
|
550
|
+
self.app_server_path = app_path
|
|
551
|
+
self.client = AppServerClient(
|
|
552
|
+
[codex_bin, "app-server", "--listen", f"unix://{app_path}"],
|
|
553
|
+
socket_path=str(app_path),
|
|
554
|
+
cwd=str(app_path.parent),
|
|
555
|
+
on_notification=self._on_notification,
|
|
556
|
+
on_server_request=self._on_server_request,
|
|
557
|
+
stderr=None,
|
|
558
|
+
)
|
|
559
|
+
bound = os.lstat(app_path)
|
|
560
|
+
if (bound.st_uid != os.getuid()
|
|
561
|
+
or not (stat.S_ISSOCK(bound.st_mode) or stat.S_ISLNK(bound.st_mode))):
|
|
562
|
+
raise RuntimeError("untrusted Codex app-server endpoint")
|
|
563
|
+
# Codex 0.165 on macOS exposes a symlink to its actual socket. Its
|
|
564
|
+
# pathname is in our existing private 0700 runtime directory; do not
|
|
565
|
+
# follow that link to mutate the native target.
|
|
566
|
+
if stat.S_ISSOCK(bound.st_mode):
|
|
567
|
+
os.chmod(app_path, 0o600)
|
|
568
|
+
self.app_server_inode = (bound.st_dev, bound.st_ino)
|
|
569
|
+
result = self.client.request("initialize", {
|
|
570
|
+
"clientInfo": {
|
|
571
|
+
"name": "ccx",
|
|
572
|
+
"title": "CCX shared Codex daemon",
|
|
573
|
+
"version": ccx_version(),
|
|
574
|
+
},
|
|
575
|
+
"capabilities": {"experimentalApi": False},
|
|
576
|
+
})
|
|
577
|
+
if result.get("userAgent") is None:
|
|
578
|
+
# The field is not semantically required, but an empty result usually
|
|
579
|
+
# means a mismatched/non-Codex endpoint.
|
|
580
|
+
result.setdefault("userAgent", "codex")
|
|
581
|
+
self.client.notify("initialized")
|
|
582
|
+
|
|
583
|
+
def _reconcile_orphan_records(self) -> None:
|
|
584
|
+
"""Repair durable nonterminal states left by a previous daemon lifetime.
|
|
585
|
+
|
|
586
|
+
The singleton lease proves no other codexd serves this root, and the
|
|
587
|
+
old app-server died with its stdio pipe, so a recorded active turn
|
|
588
|
+
cannot still be running: end it as a crash, the way a graceful stop
|
|
589
|
+
ends an active worker.
|
|
590
|
+
"""
|
|
591
|
+
for candidate in iter_records():
|
|
592
|
+
short = candidate.get("workerId")
|
|
593
|
+
if not isinstance(short, str) or not short:
|
|
594
|
+
continue
|
|
595
|
+
try:
|
|
596
|
+
self._reconcile_orphan_record(short)
|
|
597
|
+
except Exception as error:
|
|
598
|
+
# One unreadable record must not keep codexd from starting.
|
|
599
|
+
print(f"ccx codexd: orphan repair skipped {short}: {error}",
|
|
600
|
+
file=sys.stderr, flush=True)
|
|
601
|
+
|
|
602
|
+
def _reconcile_orphan_record(self, short: str) -> None:
|
|
603
|
+
diagnostic = "codexd exited without unloading this worker"
|
|
604
|
+
with self._lifecycle_lock(short):
|
|
605
|
+
record = read_record(short)
|
|
606
|
+
if record is None:
|
|
607
|
+
return
|
|
608
|
+
before = deepcopy(record)
|
|
609
|
+
lifecycle = record.setdefault("lifecycle", {})
|
|
610
|
+
state = lifecycle.get("state")
|
|
611
|
+
# Whatever the state (a graceful stop may already have written
|
|
612
|
+
# a terminal one), the old app-server can no longer settle a
|
|
613
|
+
# compact or an ambiguous turn/start.
|
|
614
|
+
markers = _retire_runtime_markers(
|
|
615
|
+
record, "codexd exited before the compaction was observed")
|
|
616
|
+
# An idle record needs nothing: an unloaded idle worker resumes.
|
|
617
|
+
if state in {"busy", "waiting", "resuming"}:
|
|
618
|
+
lifecycle["state"] = "crashed"
|
|
619
|
+
lifecycle["lastError"] = diagnostic
|
|
620
|
+
elif not markers:
|
|
621
|
+
return
|
|
622
|
+
if lifecycle["state"] != state:
|
|
623
|
+
lifecycle["since"] = now_ms()
|
|
624
|
+
lifecycle["activeTurnId"] = None
|
|
625
|
+
record["activeTurnError"] = None
|
|
626
|
+
_journal_orphan_repair(
|
|
627
|
+
record, state, getattr(self, "codex_version", None))
|
|
628
|
+
record["pendingApprovals"] = []
|
|
629
|
+
# Field CAS, as a loaded Worker saves: the expected state fences
|
|
630
|
+
# any writer that moved the record since it was read.
|
|
631
|
+
patches = {
|
|
632
|
+
f"lifecycle.{key}": value for key, value in lifecycle.items()
|
|
633
|
+
if (before.get("lifecycle") or {}).get(key) != value
|
|
634
|
+
}
|
|
635
|
+
patches.update({
|
|
636
|
+
key: value for key, value in record.items()
|
|
637
|
+
if key != "lifecycle" and before.get(key) != value
|
|
638
|
+
})
|
|
639
|
+
self._uuid_store.mutate_record(
|
|
640
|
+
f"codex:{short}", patches=patches,
|
|
641
|
+
expect={"lifecycle.state": state},
|
|
642
|
+
)
|
|
643
|
+
|
|
644
|
+
def set_config(self, target: str, message: dict[str, Any]) -> dict[str, Any]:
|
|
645
|
+
"""Durable full owner config patch, no stale loaded/unloaded snapshot."""
|
|
646
|
+
from ccx.store.identity import EngineMismatch
|
|
647
|
+
from ccx.store.root import GenerationError
|
|
648
|
+
try:
|
|
649
|
+
ctx=self._uuid_store
|
|
650
|
+
owner=ctx.resolve(target)
|
|
651
|
+
if owner.engine!="codex":
|
|
652
|
+
raise EngineMismatch("Codex config requires full Codex UUID")
|
|
653
|
+
changes=message.get("updates")
|
|
654
|
+
if not isinstance(changes,dict) or not changes:
|
|
655
|
+
return {"ok":False,"error":"set-config requires updates"}
|
|
656
|
+
invalid=set(changes)-{"model","effort"}
|
|
657
|
+
if invalid:
|
|
658
|
+
return {"ok":False,"error":
|
|
659
|
+
f"unsupported config fields: {sorted(invalid)}"}
|
|
660
|
+
patches={}
|
|
661
|
+
for field,value in changes.items():
|
|
662
|
+
if value is not None and (not isinstance(value,str)
|
|
663
|
+
or not value.strip()):
|
|
664
|
+
return {"ok":False,"error":
|
|
665
|
+
f"{field} must be nonempty text or null"}
|
|
666
|
+
patches[f"createArgs.{field}"]=(
|
|
667
|
+
value.strip() if isinstance(value,str) else None
|
|
668
|
+
)
|
|
669
|
+
with self._lifecycle_lock(owner.worker_id):
|
|
670
|
+
with self.lock:
|
|
671
|
+
loaded=self.workers.get(owner.worker_id)
|
|
672
|
+
if loaded is not None:
|
|
673
|
+
# Native turn/start owns this same worker.lock when it
|
|
674
|
+
# publishes busy/launch. Hold it through fresh CAS and
|
|
675
|
+
# mirror; never overwrite an unsaved in-memory turn.
|
|
676
|
+
with loaded.lock:
|
|
677
|
+
live_state=(loaded.record.get("lifecycle") or {}).get("state")
|
|
678
|
+
if (live_state!="idle" or not loaded.loaded
|
|
679
|
+
or loaded.launch is not None
|
|
680
|
+
or loaded.killed.is_set()
|
|
681
|
+
or loaded.kill_intent.is_set()):
|
|
682
|
+
return {"ok":False, "error":
|
|
683
|
+
f"next-turn configuration requires an idle or unloaded worker; "
|
|
684
|
+
f"worker is {live_state}", "state":live_state}
|
|
685
|
+
fresh=ctx.mutate_record(owner.worker,patches=patches)
|
|
686
|
+
loaded.record=deepcopy(fresh)
|
|
687
|
+
loaded._disk_baseline=deepcopy(fresh)
|
|
688
|
+
else:
|
|
689
|
+
# Daemon owns config even for unloaded workers. Reject
|
|
690
|
+
# unresolved native states, never change a busy record
|
|
691
|
+
# simply because the Worker object is momentarily absent.
|
|
692
|
+
current=ctx.record(owner.worker)
|
|
693
|
+
state=(current.get("lifecycle") or {}).get("state")
|
|
694
|
+
if state in ("busy", "waiting", "booting", "resuming"):
|
|
695
|
+
return {"ok":False,"error":
|
|
696
|
+
f"next-turn configuration requires an idle or unloaded worker; "
|
|
697
|
+
f"worker is {state}", "state":state}
|
|
698
|
+
fresh=ctx.mutate_record(owner.worker,patches=patches)
|
|
699
|
+
args=fresh.get("createArgs") or {}
|
|
700
|
+
return {"ok":True,
|
|
701
|
+
"state":(fresh.get("lifecycle") or {}).get("state"),
|
|
702
|
+
"model":args.get("model"),"effort":args.get("effort")}
|
|
703
|
+
except (GenerationError,ValueError) as error:
|
|
704
|
+
return {"ok":False,"error":str(error)}
|
|
705
|
+
|
|
706
|
+
def set_name(self, target: str, message: dict[str, Any]) -> dict[str, Any]:
|
|
707
|
+
"""One canonical full-owner rename, independent lifecycle CAS."""
|
|
708
|
+
from ccx.store.root import GenerationError
|
|
709
|
+
from ccx.store.identity import EngineMismatch
|
|
710
|
+
name=message.get("name")
|
|
711
|
+
if not isinstance(name,str) or not name.strip():
|
|
712
|
+
return {"ok":False,"error":"set-name requires nonempty name"}
|
|
713
|
+
try:
|
|
714
|
+
ctx=self._uuid_store
|
|
715
|
+
owner=ctx.resolve(target)
|
|
716
|
+
if owner.engine!="codex":
|
|
717
|
+
raise EngineMismatch("Codex rename requires full Codex UUID")
|
|
718
|
+
with self._lifecycle_lock(owner.worker_id):
|
|
719
|
+
updated=ctx.rename_worker(owner.worker,engine="codex",name=name)
|
|
720
|
+
with self.lock:
|
|
721
|
+
loaded=self.workers.get(owner.worker_id)
|
|
722
|
+
if loaded is not None:
|
|
723
|
+
with loaded.lock:
|
|
724
|
+
loaded.record=deepcopy(updated)
|
|
725
|
+
loaded._disk_baseline=deepcopy(updated)
|
|
726
|
+
return {"ok":True,"loaded":loaded is not None,
|
|
727
|
+
"name":updated["name"]}
|
|
728
|
+
except (GenerationError,ValueError) as error:
|
|
729
|
+
return {"ok":False,"error":str(error)}
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
def runtime_failure(self) -> str | None:
|
|
748
|
+
"""Why the shared Codex runtime is unusable, if it is.
|
|
749
|
+
|
|
750
|
+
The app-server subprocess outliving its protocol channel is the
|
|
751
|
+
dangerous case: the reader latches a transport failure and stops
|
|
752
|
+
consuming notifications, so every loaded worker silently stops being
|
|
753
|
+
updated while ``process.poll()`` still reports a healthy child. Both
|
|
754
|
+
conditions are fatal to the shared runtime and must reach the
|
|
755
|
+
supervisor.
|
|
756
|
+
"""
|
|
757
|
+
client = self.client
|
|
758
|
+
if client is None:
|
|
759
|
+
return None
|
|
760
|
+
if client.process.poll() is not None:
|
|
761
|
+
return "Codex app-server exited"
|
|
762
|
+
failure = client.failure
|
|
763
|
+
return str(failure) if failure is not None else None
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
def handle(self, message: dict[str, Any]) -> dict[str, Any]:
|
|
780
|
+
op = message.get("op")
|
|
781
|
+
if op == "daemon-status":
|
|
782
|
+
with self.lock:
|
|
783
|
+
workers = {
|
|
784
|
+
short: worker.state()
|
|
785
|
+
for short, worker in self.workers.items()
|
|
786
|
+
}
|
|
787
|
+
empty_for = (
|
|
788
|
+
time.monotonic() - self.empty_since
|
|
789
|
+
if self.empty_since is not None
|
|
790
|
+
else None
|
|
791
|
+
)
|
|
792
|
+
return {
|
|
793
|
+
"ok": True,
|
|
794
|
+
"state": (
|
|
795
|
+
"stopping" if self.stopping
|
|
796
|
+
else "ready" if self.ready.is_set() else "booting"
|
|
797
|
+
),
|
|
798
|
+
"runtimeId": self.instance_id,
|
|
799
|
+
"pid": os.getpid(),
|
|
800
|
+
"appServerPid": (
|
|
801
|
+
self.client.process.pid if self.client else None
|
|
802
|
+
),
|
|
803
|
+
"codexVersion": self.codex_version,
|
|
804
|
+
"diagnostics": list(self.diagnostics),
|
|
805
|
+
"workers": workers,
|
|
806
|
+
"lingerSeconds": daemon_models.linger_seconds(),
|
|
807
|
+
"emptyForSeconds": empty_for,
|
|
808
|
+
"nativeStartEvidence": self._native_start_counts(),
|
|
809
|
+
}
|
|
810
|
+
if op == "daemon-stop":
|
|
811
|
+
with self.lock:
|
|
812
|
+
states = {
|
|
813
|
+
short: worker.state()
|
|
814
|
+
for short, worker in self.workers.items()
|
|
815
|
+
}
|
|
816
|
+
active = {
|
|
817
|
+
short: state for short, state in states.items()
|
|
818
|
+
if state in {"booting", "resuming", "busy", "waiting"}
|
|
819
|
+
}
|
|
820
|
+
# Admission reserves its slot under this same lock, so a new
|
|
821
|
+
# original start is either counted here or sees `stopping`.
|
|
822
|
+
# Unsafe-only diagnostics are unrecoverable and do not pin.
|
|
823
|
+
evidence = self._native_start_counts()
|
|
824
|
+
native_unsettled = evidence["pending"] + evidence["held"]
|
|
825
|
+
if (active or native_unsettled) and not message.get("force"):
|
|
826
|
+
reasons = []
|
|
827
|
+
if active:
|
|
828
|
+
reasons.append("workers are still active: " + ", ".join(
|
|
829
|
+
f"{short} ({state})"
|
|
830
|
+
for short, state in sorted(active.items())
|
|
831
|
+
))
|
|
832
|
+
if native_unsettled:
|
|
833
|
+
reasons.append(
|
|
834
|
+
"original Codex native starts are unsettled "
|
|
835
|
+
f"({evidence['pending']} pending, {evidence['held']} "
|
|
836
|
+
"held unsaved); stopping would lose their only "
|
|
837
|
+
"recovery evidence"
|
|
838
|
+
)
|
|
839
|
+
return {
|
|
840
|
+
"ok": False,
|
|
841
|
+
"error": "; ".join(reasons),
|
|
842
|
+
"workers": states,
|
|
843
|
+
"nativeStartEvidence": evidence,
|
|
844
|
+
}
|
|
845
|
+
# One decision with the activity check: a load can no longer
|
|
846
|
+
# register behind it.
|
|
847
|
+
self.stopping = True
|
|
848
|
+
self.stop.set()
|
|
849
|
+
return {"ok": True, "stopped": True, "workers": states,
|
|
850
|
+
"nativeStartEvidence": evidence}
|
|
851
|
+
if op == "import-source-point":
|
|
852
|
+
# Read-only unmanaged native source: no CCX worker/source binding.
|
|
853
|
+
# Never start/resume/alter the user's original thread.
|
|
854
|
+
from ccx.codex.history import read_fork_source_point
|
|
855
|
+
try:
|
|
856
|
+
if not self.ready.is_set() or self.client is None:
|
|
857
|
+
raise RuntimeError("Codex app-server not ready")
|
|
858
|
+
source = read_fork_source_point(
|
|
859
|
+
self.client.request, message.get("threadId"),
|
|
860
|
+
deadline=time.monotonic() + _native_history_budget(message),
|
|
861
|
+
)
|
|
862
|
+
self._uuid_store.revalidate()
|
|
863
|
+
return {"ok": True, **source}
|
|
864
|
+
except (ValueError, RuntimeError, ProtocolError, TimeoutError, OSError) as error:
|
|
865
|
+
return {"ok": False, "error": str(error)}
|
|
866
|
+
if op == "thread-history":
|
|
867
|
+
from ccx.codex.history import read_original_thread
|
|
868
|
+
from ccx.store.root import GenerationError
|
|
869
|
+
from ccx.store.identity import IdentityError
|
|
870
|
+
try:
|
|
871
|
+
owner, tid, source = daemon_native._original_history_binding(self, message)
|
|
872
|
+
if not self.ready.is_set() or self.client is None:
|
|
873
|
+
raise RuntimeError("Codex app-server not ready")
|
|
874
|
+
deadline = time.monotonic() + _native_history_budget(message)
|
|
875
|
+
thread = read_original_thread(
|
|
876
|
+
self.client.request, tid, deadline=deadline,
|
|
877
|
+
exact_turn_id=message.get("turnId"), expected_source=source,
|
|
878
|
+
limit=message.get("limit"), newest_first=message.get("newestFirst") is True,
|
|
879
|
+
)
|
|
880
|
+
if thread.get("threadSource") != source:
|
|
881
|
+
raise ValueError("native source mismatch, no provider thread data")
|
|
882
|
+
self._uuid_store.revalidate()
|
|
883
|
+
return {"ok": True, "workerId": owner.worker_id,
|
|
884
|
+
"threadId": tid, "thread": thread}
|
|
885
|
+
except (GenerationError, IdentityError, ValueError, RuntimeError,
|
|
886
|
+
ProtocolError, TimeoutError) as error:
|
|
887
|
+
return {"ok": False, "error": str(error)}
|
|
888
|
+
if op == "thread-read":
|
|
889
|
+
# Shared immutable original-owner proof; thread/read additionally
|
|
890
|
+
# checks the returned native thread object before exposing turns.
|
|
891
|
+
from ccx.store.root import GenerationError
|
|
892
|
+
from ccx.store.identity import IdentityError
|
|
893
|
+
try:
|
|
894
|
+
owner, tid, source = daemon_native._original_history_binding(self, message)
|
|
895
|
+
if not self.ready.is_set() or self.client is None:
|
|
896
|
+
raise RuntimeError("Codex app-server not ready")
|
|
897
|
+
deadline = time.monotonic() + _native_history_budget(message)
|
|
898
|
+
reply = self.client.request("thread/read", {
|
|
899
|
+
"threadId": tid,
|
|
900
|
+
"includeTurns": False,
|
|
901
|
+
}, timeout=max(0.001, deadline - time.monotonic()))
|
|
902
|
+
thread = reply.get("thread") if isinstance(reply, dict) else None
|
|
903
|
+
if (not isinstance(thread, dict)
|
|
904
|
+
or thread.get("id") != tid
|
|
905
|
+
or thread.get("threadSource") != source):
|
|
906
|
+
raise ValueError("native source mismatch, no provider thread data")
|
|
907
|
+
# Older editable-install clients still use thread-read and
|
|
908
|
+
# default includeTurns to true. Reconstruct those turns from
|
|
909
|
+
# pages without issuing deprecated full-history reads.
|
|
910
|
+
if message.get("includeTurns", True):
|
|
911
|
+
from ccx.codex.history import fill_thread
|
|
912
|
+
thread = fill_thread(thread, self.client.request, deadline=deadline)
|
|
913
|
+
self._uuid_store.revalidate()
|
|
914
|
+
return {"ok": True, "workerId": owner.worker_id,
|
|
915
|
+
"threadId": tid, "thread": thread}
|
|
916
|
+
except (GenerationError, IdentityError, ValueError,
|
|
917
|
+
RuntimeError, ProtocolError, TimeoutError) as error:
|
|
918
|
+
return {"ok": False, "error": str(error)}
|
|
919
|
+
if op in {"native-activate", "native-send", "native-observe",
|
|
920
|
+
"native-activation-status"}:
|
|
921
|
+
try:
|
|
922
|
+
if op == "native-activation-status":
|
|
923
|
+
return self._observe_original_codex_thread(message)
|
|
924
|
+
return self._original_full_uuid_native(message)
|
|
925
|
+
except Exception as error:
|
|
926
|
+
return {"ok": False, "error": str(error)}
|
|
927
|
+
if op == "thread-list":
|
|
928
|
+
# A provider-native thread catalogue is never a CCX ownership index.
|
|
929
|
+
return {"ok": False, "error":
|
|
930
|
+
"unscoped native Codex thread discovery forbidden; "
|
|
931
|
+
"use original full UUID workerId"}
|
|
932
|
+
if op in {"thread-items-list", "thread-turns-list"}:
|
|
933
|
+
from ccx.store.root import GenerationError
|
|
934
|
+
from ccx.store.identity import IdentityError, UnknownWorker
|
|
935
|
+
try:
|
|
936
|
+
params = message.get("params")
|
|
937
|
+
if not isinstance(params, dict):
|
|
938
|
+
raise ValueError("thread-items-list requires native item params")
|
|
939
|
+
turns = op == "thread-turns-list"
|
|
940
|
+
allowed = ({"threadId", "limit", "sortDirection", "cursor", "itemsView"}
|
|
941
|
+
if turns else {"threadId", "limit", "sortDirection", "cursor", "turnId"})
|
|
942
|
+
if (set(params) - allowed
|
|
943
|
+
or type(params.get("limit")) is not int
|
|
944
|
+
or not 1 <= params["limit"] <= 100
|
|
945
|
+
or (not isinstance(params.get("sortDirection"), str)
|
|
946
|
+
or params["sortDirection"] not in {"asc", "desc"})
|
|
947
|
+
or ("cursor" in params and
|
|
948
|
+
(not isinstance(params["cursor"], str) or not params["cursor"]))
|
|
949
|
+
or (turns and (not isinstance(params.get("itemsView"), str)
|
|
950
|
+
or params["itemsView"] not in {"notLoaded", "summary", "full"}))
|
|
951
|
+
or (not turns and "turnId" in params and
|
|
952
|
+
(not isinstance(params["turnId"], str) or not params["turnId"]))):
|
|
953
|
+
raise ValueError("invalid bounded Codex native item parameters")
|
|
954
|
+
owner, tid, source = daemon_native._original_history_binding(self, {
|
|
955
|
+
"workerId": message.get("workerId"),
|
|
956
|
+
"threadId": params.get("threadId"),
|
|
957
|
+
})
|
|
958
|
+
if not self.ready.is_set() or self.client is None:
|
|
959
|
+
raise RuntimeError("Codex app-server not ready")
|
|
960
|
+
page = self.client.request(
|
|
961
|
+
"thread/turns/list" if turns else "thread/items/list",
|
|
962
|
+
params, timeout=2,
|
|
963
|
+
)
|
|
964
|
+
if (not isinstance(page, dict)
|
|
965
|
+
or not isinstance(page.get("data"), list)
|
|
966
|
+
or (page.get("nextCursor") is not None
|
|
967
|
+
and not isinstance(page.get("nextCursor"), str))):
|
|
968
|
+
raise ValueError("Codex native item page malformed")
|
|
969
|
+
self._uuid_store.revalidate()
|
|
970
|
+
return {"ok": True, "workerId": owner.worker_id,
|
|
971
|
+
"threadId": tid, "threadSource": source, "data": page}
|
|
972
|
+
except UnknownWorker as error:
|
|
973
|
+
return {"ok": False, "reason": "worker-not-found", "error": str(error)}
|
|
974
|
+
except ProtocolError as error:
|
|
975
|
+
return _protocol_failure(error)
|
|
976
|
+
except (GenerationError, IdentityError, ValueError, RuntimeError) as error:
|
|
977
|
+
return {"ok": False, "error": str(error)}
|
|
978
|
+
if op == "account-read":
|
|
979
|
+
if not self.ready.is_set() or self.client is None:
|
|
980
|
+
return {"ok": False, "error": "codexd is booting"}
|
|
981
|
+
try:
|
|
982
|
+
if message.get("tokens"):
|
|
983
|
+
result = self.client.request("account/usage/read", timeout=30)
|
|
984
|
+
else:
|
|
985
|
+
result = self._read_rate_limits()
|
|
986
|
+
return {"ok": True, "data": result}
|
|
987
|
+
except ProtocolError as error:
|
|
988
|
+
return _protocol_failure(error)
|
|
989
|
+
short = message.get("short")
|
|
990
|
+
from ccx.store.identity import IdentityError
|
|
991
|
+
try:
|
|
992
|
+
owner = self._uuid_store.resolve(short)
|
|
993
|
+
if owner.engine != "codex":
|
|
994
|
+
raise ValueError("Codex daemon operation needs Codex full UUID")
|
|
995
|
+
short = owner.worker_id
|
|
996
|
+
except (IdentityError, ValueError, TypeError) as error:
|
|
997
|
+
return {"ok":False,"error":
|
|
998
|
+
f"globally owned full-UUID Codex worker required: {error}"}
|
|
999
|
+
if op == "load":
|
|
1000
|
+
return self._load_original_native_worker(short, message.get("timeout"))
|
|
1001
|
+
if op == "set-config":
|
|
1002
|
+
return self.set_config(short, message)
|
|
1003
|
+
if op == "set-name":
|
|
1004
|
+
return self.set_name(short, message)
|
|
1005
|
+
with self.lock:
|
|
1006
|
+
worker = self.workers.get(short)
|
|
1007
|
+
if worker is None and op == "kill":
|
|
1008
|
+
# Promptless original native creation has a live thread but no
|
|
1009
|
+
# daemon Worker until its first semantic send. Read-only adoption
|
|
1010
|
+
# proves exact native threadSource; no start or resume.
|
|
1011
|
+
try:
|
|
1012
|
+
worker = self._adopt_original_native_worker(f"codex:{short}")
|
|
1013
|
+
except Exception as error:
|
|
1014
|
+
return {"ok": False, "error": f"original native kill adoption unconfirmed: {error}"}
|
|
1015
|
+
if op == "background-refresh":
|
|
1016
|
+
if worker is None:
|
|
1017
|
+
return self._not_loaded(short)
|
|
1018
|
+
return {"ok": True, "changed": worker.refresh_parent_notifications()}
|
|
1019
|
+
if worker is None:
|
|
1020
|
+
if op in {"parent-notify", "machine-wake"}:
|
|
1021
|
+
return {"ok": False, "definitive": True,
|
|
1022
|
+
"error": "original parent is unloaded; notification did not reach native"}
|
|
1023
|
+
return self._not_loaded(short)
|
|
1024
|
+
if op == "parent-notify":
|
|
1025
|
+
worker.refresh_parent_notifications()
|
|
1026
|
+
from ccx.notify.registry import read_registration
|
|
1027
|
+
from ccx.notify.select import _original_notification_registration
|
|
1028
|
+
from ccx.notify.deliver import payload_for_registration
|
|
1029
|
+
event = message.get("eventId")
|
|
1030
|
+
delivery = message.get("deliveryId")
|
|
1031
|
+
payload = message.get("payload")
|
|
1032
|
+
if not isinstance(event, str) or not event:
|
|
1033
|
+
return {"ok": False, "error": "original parent notification needs event ID"}
|
|
1034
|
+
reg = read_registration(event)
|
|
1035
|
+
if not isinstance(reg, dict):
|
|
1036
|
+
return {"ok": False, "error": "original notification registration missing"}
|
|
1037
|
+
try:
|
|
1038
|
+
store, parent, _child = _original_notification_registration(reg)
|
|
1039
|
+
store.revalidate()
|
|
1040
|
+
final = (reg.get("state") == "ready" and payload == reg.get("payload")
|
|
1041
|
+
and isinstance(reg.get("settlement"), dict)
|
|
1042
|
+
and payload == payload_for_registration(reg, reg["settlement"])
|
|
1043
|
+
and delivery == f"{event}:settled"
|
|
1044
|
+
and isinstance(payload, dict) and payload.get("parent") == parent)
|
|
1045
|
+
if (parent != f"codex:{short}" or not final
|
|
1046
|
+
or not isinstance(payload, dict)
|
|
1047
|
+
or payload.get("eventId") != event
|
|
1048
|
+
or payload.get("deliveryId") != delivery):
|
|
1049
|
+
raise ValueError("original notification parent/event/payload mismatch")
|
|
1050
|
+
with worker.lock:
|
|
1051
|
+
if (worker.killed.is_set() or worker.kill_intent.is_set()
|
|
1052
|
+
or not worker.loaded
|
|
1053
|
+
or worker.record.get("lifecycle", {}).get("state")
|
|
1054
|
+
not in ("idle", "busy", "waiting")
|
|
1055
|
+
or worker._original_notification_thread()
|
|
1056
|
+
!= reg["parent"]["threadId"]):
|
|
1057
|
+
raise ValueError("original parent not live, subscribed or source verified")
|
|
1058
|
+
except Exception as error:
|
|
1059
|
+
return {"ok": False, "definitive": True,
|
|
1060
|
+
"error": f"original notification admission refused: {error}"}
|
|
1061
|
+
if op == "machine-wake":
|
|
1062
|
+
payload = message.get("payload")
|
|
1063
|
+
event, delivery = message.get("eventId"), message.get("deliveryId")
|
|
1064
|
+
namespace, name = message.get("namespace"), message.get("name")
|
|
1065
|
+
if (not isinstance(event, str) or not event or len(event) > 256
|
|
1066
|
+
or not isinstance(delivery, str) or not delivery or len(delivery) > 512
|
|
1067
|
+
or not isinstance(payload, dict)
|
|
1068
|
+
or payload.get("eventId") != event
|
|
1069
|
+
or payload.get("deliveryId") != delivery
|
|
1070
|
+
or not isinstance(namespace, str) or not 0 < len(namespace) <= 128
|
|
1071
|
+
or namespace == "ccx"
|
|
1072
|
+
or not isinstance(name, str) or not 0 < len(name) <= 128
|
|
1073
|
+
or type(message.get("reconcile")) is not bool):
|
|
1074
|
+
return {"ok": False, "definitive": True,
|
|
1075
|
+
"error": "invalid original machine wake identity/output"}
|
|
1076
|
+
try:
|
|
1077
|
+
with worker.lock:
|
|
1078
|
+
native = worker.record.get("native") or {}
|
|
1079
|
+
if (worker.killed.is_set() or worker.kill_intent.is_set()
|
|
1080
|
+
or not worker.loaded
|
|
1081
|
+
or worker.record.get("lifecycle", {}).get("state")
|
|
1082
|
+
not in ("idle", "busy", "waiting")
|
|
1083
|
+
or message.get("threadId") != worker._original_notification_thread()
|
|
1084
|
+
or native.get("threadId") != message["threadId"]):
|
|
1085
|
+
raise ValueError("original machine wake not subscribed/owned/active")
|
|
1086
|
+
except Exception as error:
|
|
1087
|
+
return {"ok": False, "definitive": True,
|
|
1088
|
+
"error": f"original machine wake admission refused: {error}"}
|
|
1089
|
+
if op == "status":
|
|
1090
|
+
return {"ok": True, "loaded": True, **worker.public_status()}
|
|
1091
|
+
if op == "compact-observe":
|
|
1092
|
+
# No load, settlement, native request or operation execution lock.
|
|
1093
|
+
with worker.lock:
|
|
1094
|
+
active = worker.record.get("activeCompact")
|
|
1095
|
+
last = worker.record.get("lastCompact") or {}
|
|
1096
|
+
owner = "held" if active else (
|
|
1097
|
+
"unknown" if (last.get("status") == "unknown"
|
|
1098
|
+
and not last.get("nativeTurnSettled"))
|
|
1099
|
+
or worker.record["lifecycle"]["state"] not in {"idle", "busy", "waiting"}
|
|
1100
|
+
else "released"
|
|
1101
|
+
)
|
|
1102
|
+
return json.loads(json.dumps({
|
|
1103
|
+
"ok": True, "state": worker.record["lifecycle"]["state"],
|
|
1104
|
+
"activeCompact": active, "lastCompact": last,
|
|
1105
|
+
"input_surface": {"compaction_ownership": owner,
|
|
1106
|
+
"operation_id": (active or {}).get("operationId"),
|
|
1107
|
+
"runtime_id": self.instance_id},
|
|
1108
|
+
}))
|
|
1109
|
+
# Native control/parent receipt families below still lack the
|
|
1110
|
+
# generation-scoped prepared/submitted native-effect fence. They
|
|
1111
|
+
# must not descend into the old short-keyed handler just because
|
|
1112
|
+
# this original worker is now correctly indexed. `interrupt` is fenced
|
|
1113
|
+
# by the caller's GenerationStore cancel intent and expectedTurnId.
|
|
1114
|
+
if op not in ("status", "interrupt", "approve", "reject", "kill",
|
|
1115
|
+
"parent-notify", "machine-wake", "compact"):
|
|
1116
|
+
return {"ok":False,"error":
|
|
1117
|
+
f"{op}: original full-UUID Codex effect receipt unavailable; "
|
|
1118
|
+
"no native call performed"}
|
|
1119
|
+
if op == "kill":
|
|
1120
|
+
return self._kill_worker(worker, message.get("observeSeconds"))
|
|
1121
|
+
history = None
|
|
1122
|
+
if (
|
|
1123
|
+
op in {"parent-notify", "machine-wake"}
|
|
1124
|
+
and message.get("reconcile", True)
|
|
1125
|
+
and isinstance(message.get("payload"), dict)
|
|
1126
|
+
and isinstance(message.get("deliveryId") or message.get("eventId"), str)
|
|
1127
|
+
and not (worker.kill_intent.is_set() or worker.killed.is_set())
|
|
1128
|
+
):
|
|
1129
|
+
# The read-only history walk can page a long parent thread; it runs
|
|
1130
|
+
# before the operation lock so the parent's own sends and kills do
|
|
1131
|
+
# not queue behind it.
|
|
1132
|
+
try:
|
|
1133
|
+
history = (worker.find_parent_notification(
|
|
1134
|
+
message.get("deliveryId") or message["eventId"],
|
|
1135
|
+
message["payload"],
|
|
1136
|
+
name=(message["name"] if op == "machine-wake" else "worker_notification"),
|
|
1137
|
+
),)
|
|
1138
|
+
except ProtocolError as error:
|
|
1139
|
+
return _protocol_failure(error)
|
|
1140
|
+
except RuntimeError as error:
|
|
1141
|
+
if op != "machine-wake":
|
|
1142
|
+
raise
|
|
1143
|
+
return {"ok": False, "pending": True, "error": str(error)}
|
|
1144
|
+
for _ in range(3):
|
|
1145
|
+
if worker.kill_intent.is_set() or worker.killed.is_set():
|
|
1146
|
+
# Refused without waiting for `operation_lock`: a kill may be
|
|
1147
|
+
# sitting on that lock waiting for an earlier operation to
|
|
1148
|
+
# settle, and queueing new work behind it is exactly what a
|
|
1149
|
+
# kill intent must stop.
|
|
1150
|
+
return {"ok": False, "definitive": True,
|
|
1151
|
+
"error": f"worker {short} is being killed", "state": worker.state()}
|
|
1152
|
+
with worker.operation_lock:
|
|
1153
|
+
if self._still_registered(worker):
|
|
1154
|
+
return worker.handle_operation(message, history=history)
|
|
1155
|
+
# The object was unloaded (idle-kill, kill, crash) while this
|
|
1156
|
+
# request waited for its lock. Acting on it would write a stale
|
|
1157
|
+
# record and drive a thread nothing listens to; follow the worker
|
|
1158
|
+
# to the object that is registered now, if any.
|
|
1159
|
+
with self.lock:
|
|
1160
|
+
current = self.workers.get(short)
|
|
1161
|
+
if current is None or current is worker:
|
|
1162
|
+
break
|
|
1163
|
+
worker = current
|
|
1164
|
+
return self._not_loaded(short)
|
|
1165
|
+
|
|
1166
|
+
def _not_loaded(self, short: str) -> dict[str, Any]:
|
|
1167
|
+
return {
|
|
1168
|
+
"ok": False,
|
|
1169
|
+
"loaded": False,
|
|
1170
|
+
"runtime_id": self.instance_id,
|
|
1171
|
+
"error": f"worker {short} is not loaded",
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
def _still_registered(self, worker: "Worker") -> bool:
|
|
1175
|
+
"""Is this object still the daemon's live worker for its short?"""
|
|
1176
|
+
with self.lock:
|
|
1177
|
+
return (
|
|
1178
|
+
self.workers.get(worker.short) is worker
|
|
1179
|
+
and not worker.teardown_claimed
|
|
1180
|
+
)
|
|
1181
|
+
|
|
1182
|
+
def _kill_worker(self, worker: "Worker",
|
|
1183
|
+
observe_seconds: Any = None) -> dict[str, Any]:
|
|
1184
|
+
"""Kill one worker without either blocking indefinitely or racing.
|
|
1185
|
+
|
|
1186
|
+
`kill` must be serialized against an in-flight steer/approve — bypassing
|
|
1187
|
+
`operation_lock` would let it interrupt a turn while another thread is
|
|
1188
|
+
mid-`turn/steer`, and the two would write the same record from opposite
|
|
1189
|
+
directions. But taking that lock unconditionally is how a kill silently
|
|
1190
|
+
outlives its confirmation budget.
|
|
1191
|
+
|
|
1192
|
+
So the intent is published first (new operations are refused
|
|
1193
|
+
immediately, above), and the lock is then awaited only inside the
|
|
1194
|
+
caller's own budget. If the earlier operation cannot settle in time, the
|
|
1195
|
+
kill is reported explicitly unresolved and the worker is deliberately
|
|
1196
|
+
left registered and nonterminal — `confirmed_kill` reconciles that into
|
|
1197
|
+
a raise rather than a false success, and a retry after the operation
|
|
1198
|
+
settles completes normally. The budget is never extended: the wait for
|
|
1199
|
+
the lock and `kill`'s own launch-settle window come out of the same
|
|
1200
|
+
allowance.
|
|
1201
|
+
"""
|
|
1202
|
+
budget = (_LAUNCH_SETTLE_CAP_S if observe_seconds is None
|
|
1203
|
+
else max(0.0, min(float(observe_seconds),
|
|
1204
|
+
_LAUNCH_SETTLE_CAP_S)))
|
|
1205
|
+
worker.kill_intent.set()
|
|
1206
|
+
started = time.monotonic()
|
|
1207
|
+
if budget > 0:
|
|
1208
|
+
acquired = worker.operation_lock.acquire(timeout=budget)
|
|
1209
|
+
else:
|
|
1210
|
+
acquired = worker.operation_lock.acquire(blocking=False)
|
|
1211
|
+
if not acquired:
|
|
1212
|
+
# unresolved, not killed: another operation still owns this worker,
|
|
1213
|
+
# so nothing has been interrupted or terminalized. Saying "killed"
|
|
1214
|
+
# here would be the lie this whole path exists to avoid.
|
|
1215
|
+
#
|
|
1216
|
+
# Nor was anything accepted. No kill ran, and nobody is guaranteed
|
|
1217
|
+
# to retry, so leaving the intent set would strand a perfectly live
|
|
1218
|
+
# worker refusing every operation forever. Withdraw it — unless a
|
|
1219
|
+
# real kill (`killed`) is already in progress, whose sticky marker
|
|
1220
|
+
# is the one that must survive.
|
|
1221
|
+
if not worker.killed.is_set():
|
|
1222
|
+
worker.kill_intent.clear()
|
|
1223
|
+
return {
|
|
1224
|
+
"ok": False,
|
|
1225
|
+
"pending": True,
|
|
1226
|
+
"error": (f"worker {worker.short} has an operation in flight "
|
|
1227
|
+
f"that did not settle within {budget:g}s; the kill "
|
|
1228
|
+
f"was NOT completed and is not confirmed — retry "
|
|
1229
|
+
f"kill once that operation settles"),
|
|
1230
|
+
"state": worker.state(),
|
|
1231
|
+
}
|
|
1232
|
+
remaining = max(0.0, budget - (time.monotonic() - started))
|
|
1233
|
+
if self._still_registered(worker):
|
|
1234
|
+
try:
|
|
1235
|
+
return worker.kill(remaining)
|
|
1236
|
+
finally:
|
|
1237
|
+
worker.operation_lock.release()
|
|
1238
|
+
worker.operation_lock.release()
|
|
1239
|
+
# The operation we waited behind unloaded this object. Killing it now
|
|
1240
|
+
# would confirm a kill against whatever has since been loaded under the
|
|
1241
|
+
# same short and write `dead` over its record; kill that one instead.
|
|
1242
|
+
with self.lock:
|
|
1243
|
+
current = self.workers.get(worker.short)
|
|
1244
|
+
if current is not None and current is not worker:
|
|
1245
|
+
return self._kill_worker(current, remaining)
|
|
1246
|
+
return self._not_loaded(worker.short)
|
|
1247
|
+
|
|
1248
|
+
def dispatch(self, message: dict[str, Any]) -> dict[str, Any]:
|
|
1249
|
+
"""Fence one request on the client's declared RPC generation.
|
|
1250
|
+
|
|
1251
|
+
Recovery and control operations answer any client: they are how an
|
|
1252
|
+
operator inspects and retires a daemon whose generation no longer
|
|
1253
|
+
matches theirs. Every other operation must declare this generation
|
|
1254
|
+
before its handler runs, so a client from an older install cannot
|
|
1255
|
+
mutate a current daemon and discover the mismatch only in the reply.
|
|
1256
|
+
"""
|
|
1257
|
+
if not isinstance(message, dict):
|
|
1258
|
+
raise TypeError("codexd request must be a JSON object")
|
|
1259
|
+
if message.get("op") in CODEXD_RECOVERY_OPS:
|
|
1260
|
+
return self.handle(message)
|
|
1261
|
+
declared = message.get("rpcGeneration")
|
|
1262
|
+
if isinstance(declared, bool) or not isinstance(declared, int):
|
|
1263
|
+
declared = None
|
|
1264
|
+
if declared != CODEXD_RPC_GENERATION:
|
|
1265
|
+
return {
|
|
1266
|
+
"ok": False,
|
|
1267
|
+
"incompatible": True,
|
|
1268
|
+
"error": (
|
|
1269
|
+
f"codexd speaks RPC generation {CODEXD_RPC_GENERATION}, but "
|
|
1270
|
+
f"the client declared "
|
|
1271
|
+
f"{declared if declared is not None else 'none (legacy)'}. "
|
|
1272
|
+
f"Upgrade the caller, or run `ccx shutdown --engine codex` to "
|
|
1273
|
+
f"replace this daemon."
|
|
1274
|
+
),
|
|
1275
|
+
}
|
|
1276
|
+
return self.handle(message)
|
|
1277
|
+
|
|
1278
|
+
def _open_attach(self, message: dict[str, Any]) -> tuple[dict, Worker | None]:
|
|
1279
|
+
"""Load the original native thread and hold codexd's subscription."""
|
|
1280
|
+
from ccx.store.identity import IdentityError
|
|
1281
|
+
try:
|
|
1282
|
+
owner = self._uuid_store.resolve(message.get("short"))
|
|
1283
|
+
if owner.engine != "codex":
|
|
1284
|
+
raise ValueError("attach requires an original Codex worker")
|
|
1285
|
+
result = self._load_original_native_worker(owner.worker_id, 90)
|
|
1286
|
+
if result.get("ok") is not True or result.get("loaded") is not True:
|
|
1287
|
+
return {**result, "ok": False,
|
|
1288
|
+
"error": result.get("error") or "original thread not loaded"}, None
|
|
1289
|
+
with self.lock:
|
|
1290
|
+
worker = self.workers.get(owner.worker_id)
|
|
1291
|
+
if worker is None or worker.teardown_claimed or self.stopping:
|
|
1292
|
+
raise RuntimeError("Codex worker unloaded during attach")
|
|
1293
|
+
with worker.lock:
|
|
1294
|
+
if (not worker.loaded
|
|
1295
|
+
or worker.record["lifecycle"]["state"] not in {"idle", "busy", "waiting"}):
|
|
1296
|
+
raise RuntimeError("Codex worker no longer attachable")
|
|
1297
|
+
worker.attach_count += 1
|
|
1298
|
+
native = worker.record["native"]
|
|
1299
|
+
reply = {"ok": True, "loaded": True,
|
|
1300
|
+
"threadId": native["threadId"], "cwd": worker.record["cwd"],
|
|
1301
|
+
"socket": str(self.app_server_path)}
|
|
1302
|
+
return reply, worker
|
|
1303
|
+
except (IdentityError, ValueError, RuntimeError, OSError) as error:
|
|
1304
|
+
return {"ok": False, "error": str(error)}, None
|
|
1305
|
+
|
|
1306
|
+
def _serve_connection(self, connection: socket.socket) -> None:
|
|
1307
|
+
"""Require same-connection full-root/gen/role handshake before RPC.
|
|
1308
|
+
|
|
1309
|
+
The existing original daemon dispatch stays authoritative. No legacy
|
|
1310
|
+
singleton socket is accepted merely because the caller shares a UID.
|
|
1311
|
+
"""
|
|
1312
|
+
from ccx.rpc.transport import handshake_payload
|
|
1313
|
+
store = self._uuid_store
|
|
1314
|
+
plan = self._uuid_plan
|
|
1315
|
+
expected = handshake_payload(
|
|
1316
|
+
plan, daemon_nonce=self._uuid_daemon_lease.nonce,
|
|
1317
|
+
)
|
|
1318
|
+
with connection:
|
|
1319
|
+
held_worker: Worker | None = None
|
|
1320
|
+
connection.settimeout(30)
|
|
1321
|
+
data = bytearray()
|
|
1322
|
+
|
|
1323
|
+
def read_one() -> dict:
|
|
1324
|
+
while b"\n" not in data:
|
|
1325
|
+
chunk = connection.recv(65536)
|
|
1326
|
+
if not chunk:
|
|
1327
|
+
raise EOFError("native daemon request ended before frame")
|
|
1328
|
+
data.extend(chunk)
|
|
1329
|
+
if len(data) > 1024 * 1024:
|
|
1330
|
+
raise ValueError("Codex daemon RPC frame too large")
|
|
1331
|
+
line, rest = data.split(b"\n", 1)
|
|
1332
|
+
data[:] = rest
|
|
1333
|
+
parsed = json.loads(line)
|
|
1334
|
+
if not isinstance(parsed, dict):
|
|
1335
|
+
raise ValueError("Codex daemon request must be JSON object")
|
|
1336
|
+
return parsed
|
|
1337
|
+
|
|
1338
|
+
try:
|
|
1339
|
+
first = read_one()
|
|
1340
|
+
store.revalidate()
|
|
1341
|
+
if (first.get("op") != "handshake"
|
|
1342
|
+
or first.get("identity") != {
|
|
1343
|
+
"protocolVersion": 2, **plan.evidence(),
|
|
1344
|
+
}):
|
|
1345
|
+
raise ValueError(
|
|
1346
|
+
"missing exact full-root/generation/Codex role handshake"
|
|
1347
|
+
)
|
|
1348
|
+
connection.sendall((json.dumps({
|
|
1349
|
+
"ok": True, "identity": expected,
|
|
1350
|
+
}) + "\n").encode())
|
|
1351
|
+
request = read_one()
|
|
1352
|
+
store.revalidate()
|
|
1353
|
+
if request.get("op") == "handshake":
|
|
1354
|
+
raise ValueError("duplicate Codex singleton handshake")
|
|
1355
|
+
if (request.get("op") == "attach"
|
|
1356
|
+
and request.get("rpcGeneration") == CODEXD_RPC_GENERATION):
|
|
1357
|
+
reply, held_worker = self._open_attach(request)
|
|
1358
|
+
else:
|
|
1359
|
+
reply = self.dispatch(request)
|
|
1360
|
+
except Exception as error:
|
|
1361
|
+
reply = {"ok": False, "error": str(error)}
|
|
1362
|
+
reply.setdefault("rpcGeneration", CODEXD_RPC_GENERATION)
|
|
1363
|
+
try:
|
|
1364
|
+
connection.settimeout(None)
|
|
1365
|
+
connection.sendall((json.dumps(reply) + "\n").encode())
|
|
1366
|
+
if held_worker is not None:
|
|
1367
|
+
# This is the attach lifetime: EOF releases the hold.
|
|
1368
|
+
# No further protocol messages, tokens or heartbeat needed.
|
|
1369
|
+
while connection.recv(4096):
|
|
1370
|
+
pass
|
|
1371
|
+
except OSError:
|
|
1372
|
+
pass
|
|
1373
|
+
finally:
|
|
1374
|
+
if held_worker is not None:
|
|
1375
|
+
with held_worker.lock:
|
|
1376
|
+
held_worker.attach_count -= 1
|
|
1377
|
+
held_worker.last_activity = time.monotonic()
|
|
1378
|
+
|
|
1379
|
+
def _accept_loop(self) -> None:
|
|
1380
|
+
server = self.server
|
|
1381
|
+
assert server is not None
|
|
1382
|
+
# Serve until the socket itself is closed, not merely until `stop`:
|
|
1383
|
+
# between the two a client would otherwise connect into an unserved
|
|
1384
|
+
# backlog and time out, instead of being told `stopping`.
|
|
1385
|
+
while True:
|
|
1386
|
+
try:
|
|
1387
|
+
connection = accept_same_user(server)
|
|
1388
|
+
except socket.timeout:
|
|
1389
|
+
if server.fileno() == -1:
|
|
1390
|
+
return
|
|
1391
|
+
continue
|
|
1392
|
+
except OSError:
|
|
1393
|
+
if server.fileno() == -1:
|
|
1394
|
+
return
|
|
1395
|
+
continue
|
|
1396
|
+
if connection is None:
|
|
1397
|
+
continue
|
|
1398
|
+
threading.Thread(
|
|
1399
|
+
target=self._serve_connection,
|
|
1400
|
+
args=(connection,),
|
|
1401
|
+
name="ccx-codexd-rpc",
|
|
1402
|
+
daemon=True,
|
|
1403
|
+
).start()
|
|
1404
|
+
|
|
1405
|
+
def run(self) -> None:
|
|
1406
|
+
"""Own the singleton socket and app-server under the active generation.
|
|
1407
|
+
|
|
1408
|
+
The transport and role lease authenticate the full root and generation.
|
|
1409
|
+
"""
|
|
1410
|
+
from ccx import constants
|
|
1411
|
+
from ccx.store.root import require_ccx_active
|
|
1412
|
+
from ccx.rpc.transport import (
|
|
1413
|
+
daemon_process_lease, ensure_transport_parent, plan_transport,
|
|
1414
|
+
)
|
|
1415
|
+
store = require_ccx_active(constants.CCX_ROOT)
|
|
1416
|
+
plan = plan_transport(store, role="codexd")
|
|
1417
|
+
ensure_transport_parent(store, plan)
|
|
1418
|
+
self._uuid_store = store
|
|
1419
|
+
self._uuid_plan = plan
|
|
1420
|
+
with daemon_process_lease(store, plan) as lease:
|
|
1421
|
+
self._uuid_daemon_lease = lease
|
|
1422
|
+
path = plan.final
|
|
1423
|
+
try:
|
|
1424
|
+
# A crash can leave a stale socket inode. The singleton role
|
|
1425
|
+
# flock plus private root bind allows removing only an owned
|
|
1426
|
+
# socket; never unlink an arbitrary filesystem entry.
|
|
1427
|
+
try:
|
|
1428
|
+
old = os.lstat(path)
|
|
1429
|
+
except FileNotFoundError:
|
|
1430
|
+
old = None
|
|
1431
|
+
if old is not None:
|
|
1432
|
+
import stat
|
|
1433
|
+
if (not stat.S_ISSOCK(old.st_mode)
|
|
1434
|
+
or old.st_uid != os.getuid()):
|
|
1435
|
+
raise RuntimeError("untrusted old Codex daemon socket")
|
|
1436
|
+
path.unlink()
|
|
1437
|
+
with store.lease():
|
|
1438
|
+
self.server = socket.socket(
|
|
1439
|
+
socket.AF_UNIX, socket.SOCK_STREAM,
|
|
1440
|
+
)
|
|
1441
|
+
bind_private_unix_socket(self.server, path)
|
|
1442
|
+
self.server.listen(32)
|
|
1443
|
+
self.server.settimeout(1)
|
|
1444
|
+
self.accepter = threading.Thread(
|
|
1445
|
+
target=self._accept_loop,
|
|
1446
|
+
name="ccx-codexd-accept", daemon=True,
|
|
1447
|
+
)
|
|
1448
|
+
self.accepter.start()
|
|
1449
|
+
self._start_client()
|
|
1450
|
+
self._reconcile_orphan_records()
|
|
1451
|
+
self.ready.set()
|
|
1452
|
+
threading.Thread(
|
|
1453
|
+
target=self._janitor, name="ccx-codexd-janitor",
|
|
1454
|
+
daemon=True,
|
|
1455
|
+
).start()
|
|
1456
|
+
while not self.stop.wait(1):
|
|
1457
|
+
failure = self.runtime_failure()
|
|
1458
|
+
if failure is not None:
|
|
1459
|
+
raise RuntimeError(failure)
|
|
1460
|
+
except BaseException as error:
|
|
1461
|
+
if not isinstance(error, (KeyboardInterrupt, SystemExit)):
|
|
1462
|
+
with self.lock:
|
|
1463
|
+
crashed = list(self.workers.values())
|
|
1464
|
+
for worker in crashed:
|
|
1465
|
+
if worker.state() in LIVE_STATES:
|
|
1466
|
+
worker._state("crashed", error=str(error))
|
|
1467
|
+
print(f"ccx codexd: {error}", file=sys.stderr, flush=True)
|
|
1468
|
+
finally:
|
|
1469
|
+
with self.lock:
|
|
1470
|
+
self.stopping = True
|
|
1471
|
+
self.stop.set()
|
|
1472
|
+
if self.server:
|
|
1473
|
+
self.server.close()
|
|
1474
|
+
if self.accepter:
|
|
1475
|
+
self.accepter.join(timeout=2)
|
|
1476
|
+
try:
|
|
1477
|
+
self._retire_workers()
|
|
1478
|
+
finally:
|
|
1479
|
+
try:
|
|
1480
|
+
if self.client:
|
|
1481
|
+
self.client.close()
|
|
1482
|
+
finally:
|
|
1483
|
+
if self.app_server_path and self.app_server_inode:
|
|
1484
|
+
try:
|
|
1485
|
+
inode = os.lstat(self.app_server_path)
|
|
1486
|
+
if (inode.st_dev, inode.st_ino) == self.app_server_inode:
|
|
1487
|
+
os.unlink(self.app_server_path)
|
|
1488
|
+
except FileNotFoundError:
|
|
1489
|
+
pass
|
|
1490
|
+
try:
|
|
1491
|
+
# Do not unlink a replacement socket belonging to a
|
|
1492
|
+
# different inode/process lifetime.
|
|
1493
|
+
if path.exists() and path.is_socket():
|
|
1494
|
+
path.unlink()
|
|
1495
|
+
except OSError:
|
|
1496
|
+
pass
|
|
1497
|
+
|
|
1498
|
+
def _adopt_original_native_worker(self, target: str, *, read_timeout: float=1.0) -> 'Worker':
|
|
1499
|
+
return daemon_native._adopt_original_native_worker(self, target, read_timeout=read_timeout)
|
|
1500
|
+
|
|
1501
|
+
def _await_thread_release(self, thread_id: str | None) -> None:
|
|
1502
|
+
return daemon_release._await_thread_release(self, thread_id)
|
|
1503
|
+
|
|
1504
|
+
def _janitor(self) -> None:
|
|
1505
|
+
return daemon_release._janitor(self)
|
|
1506
|
+
|
|
1507
|
+
def _janitor_daemon_pass(self) -> bool:
|
|
1508
|
+
return daemon_release._janitor_daemon_pass(self)
|
|
1509
|
+
|
|
1510
|
+
def _janitor_native_start_pass(self) -> None:
|
|
1511
|
+
return daemon_native._janitor_native_start_pass(self)
|
|
1512
|
+
|
|
1513
|
+
def _janitor_worker(self, worker: Worker) -> None:
|
|
1514
|
+
return daemon_release._janitor_worker(self, worker)
|
|
1515
|
+
|
|
1516
|
+
def _load_original_native_worker(self, worker_id: str, timeout: float | None) -> dict[str, Any]:
|
|
1517
|
+
return daemon_native._load_original_native_worker(self, worker_id, timeout)
|
|
1518
|
+
|
|
1519
|
+
def _native_start_counts(self) -> dict[str, int]:
|
|
1520
|
+
return daemon_native._native_start_counts(self)
|
|
1521
|
+
|
|
1522
|
+
def _observe_original_codex_thread(self, message: dict[str, Any]) -> dict[str, Any]:
|
|
1523
|
+
return daemon_native._observe_original_codex_thread(self, message)
|
|
1524
|
+
|
|
1525
|
+
def _original_full_uuid_native(self, message: dict[str, Any]) -> dict[str, Any]:
|
|
1526
|
+
return daemon_native._original_full_uuid_native(self, message)
|
|
1527
|
+
|
|
1528
|
+
def _release_native_threads(self, worker: Worker, releases: list['_ThreadRelease']) -> None:
|
|
1529
|
+
return daemon_release._release_native_threads(self, worker, releases)
|
|
1530
|
+
|
|
1531
|
+
def _retire_workers(self) -> None:
|
|
1532
|
+
return daemon_release._retire_workers(self)
|
|
1533
|
+
|
|
1534
|
+
def _save_held_codex_candidate(self, hold_key: tuple[str, str, str]) -> dict[str, Any]:
|
|
1535
|
+
return daemon_native._save_held_codex_candidate(self, hold_key)
|
|
1536
|
+
|
|
1537
|
+
def _submit_original_codex_thread(self, worker_id: str, record: dict, source: str, token: str, *, admission_deadline: float | None=None) -> dict[str, Any]:
|
|
1538
|
+
return daemon_native._submit_original_codex_thread(self, worker_id, record, source, token, admission_deadline=admission_deadline)
|
|
1539
|
+
|
|
1540
|
+
def _unload(self, worker: Worker, *, terminal_state: str, owns_lifecycle: bool=False) -> None:
|
|
1541
|
+
return daemon_release._unload(self, worker, terminal_state=terminal_state, owns_lifecycle=owns_lifecycle)
|
|
1542
|
+
|
|
1543
|
+
|
|
1544
|
+
def main(argv=None) -> None:
|
|
1545
|
+
parser = argparse.ArgumentParser(
|
|
1546
|
+
description="CCX shared Codex app-server daemon (codexd)."
|
|
1547
|
+
)
|
|
1548
|
+
parser.parse_args(argv)
|
|
1549
|
+
from ccx.system.engine_binary import resolve_engine_binary
|
|
1550
|
+
from ccx.errors import SessionUnavailable
|
|
1551
|
+
try:
|
|
1552
|
+
resolve_engine_binary("codex")
|
|
1553
|
+
except SessionUnavailable as error:
|
|
1554
|
+
parser.exit(error.exit_code, f"ccx: {error}\n")
|
|
1555
|
+
signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
|
|
1556
|
+
CodexDaemon().run()
|