ccx-native 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +241 -4
  3. package/bin/ccx +55 -2
  4. package/ccx/__init__.py +163 -0
  5. package/ccx/__main__.py +28 -0
  6. package/ccx/claude/__init__.py +0 -0
  7. package/ccx/claude/analytics.py +466 -0
  8. package/ccx/claude/attach.py +762 -0
  9. package/ccx/claude/context.py +465 -0
  10. package/ccx/claude/delivery.py +806 -0
  11. package/ccx/claude/external.py +253 -0
  12. package/ccx/claude/host/__init__.py +0 -0
  13. package/ccx/claude/host/events.py +297 -0
  14. package/ccx/claude/host/hooks.py +438 -0
  15. package/ccx/claude/host/pty.py +250 -0
  16. package/ccx/claude/host/session.py +5184 -0
  17. package/ccx/claude/host/subagents.py +169 -0
  18. package/ccx/claude/host/successor.py +267 -0
  19. package/ccx/claude/managed_turns.py +240 -0
  20. package/ccx/claude/oauth.py +176 -0
  21. package/ccx/claude/settings.py +339 -0
  22. package/ccx/claude/spawn.py +557 -0
  23. package/ccx/claude/tasks.py +72 -0
  24. package/ccx/claude/telemetry.py +282 -0
  25. package/ccx/claude/transcript.py +2281 -0
  26. package/ccx/claude/usage.py +837 -0
  27. package/ccx/claude/usage_control.py +152 -0
  28. package/ccx/cli/__init__.py +0 -0
  29. package/ccx/cli/codex.py +1009 -0
  30. package/ccx/cli/commands/__init__.py +0 -0
  31. package/ccx/cli/commands/_common.py +407 -0
  32. package/ccx/cli/commands/control.py +161 -0
  33. package/ccx/cli/commands/create.py +669 -0
  34. package/ccx/cli/commands/read.py +1020 -0
  35. package/ccx/cli/commands/turns.py +1081 -0
  36. package/ccx/cli/controller.py +757 -0
  37. package/ccx/cli/main.py +301 -0
  38. package/ccx/cli/parser.py +1324 -0
  39. package/ccx/cli/skills.py +215 -0
  40. package/ccx/cli/statusline.py +160 -0
  41. package/ccx/client.py +1900 -0
  42. package/ccx/codex/__init__.py +0 -0
  43. package/ccx/codex/attach_tty.py +267 -0
  44. package/ccx/codex/client.py +732 -0
  45. package/ccx/codex/completion.py +121 -0
  46. package/ccx/codex/control.py +288 -0
  47. package/ccx/codex/daemon/__init__.py +0 -0
  48. package/ccx/codex/daemon/__main__.py +4 -0
  49. package/ccx/codex/daemon/daemon.py +1556 -0
  50. package/ccx/codex/daemon/models.py +140 -0
  51. package/ccx/codex/daemon/native.py +1590 -0
  52. package/ccx/codex/daemon/release.py +367 -0
  53. package/ccx/codex/daemon/worker.py +2396 -0
  54. package/ccx/codex/delivery.py +758 -0
  55. package/ccx/codex/delivery_recovery.py +232 -0
  56. package/ccx/codex/evidence.py +827 -0
  57. package/ccx/codex/history.py +158 -0
  58. package/ccx/codex/notify_history.py +140 -0
  59. package/ccx/codex/ops.py +666 -0
  60. package/ccx/codex/paths.py +70 -0
  61. package/ccx/codex/protocol.py +805 -0
  62. package/ccx/codex/provenance.py +105 -0
  63. package/ccx/codex/read.py +29 -0
  64. package/ccx/codex/records.py +51 -0
  65. package/ccx/codex/reports.py +177 -0
  66. package/ccx/codex/runtime.py +1412 -0
  67. package/ccx/codex/settle.py +363 -0
  68. package/ccx/codex/telemetry.py +792 -0
  69. package/ccx/codex/types.py +659 -0
  70. package/ccx/codex/worker_environment.py +25 -0
  71. package/ccx/constants.py +288 -0
  72. package/ccx/controllers/__init__.py +0 -0
  73. package/ccx/controllers/controller.py +1449 -0
  74. package/ccx/controllers/live_vars.py +1706 -0
  75. package/ccx/controllers/live_vars_runner.py +216 -0
  76. package/ccx/controllers/notify.py +213 -0
  77. package/ccx/controllers/orchestrator.py +193 -0
  78. package/ccx/controllers/policy.py +57 -0
  79. package/ccx/controllers/store.py +681 -0
  80. package/ccx/errors.py +634 -0
  81. package/ccx/notify/__init__.py +0 -0
  82. package/ccx/notify/__main__.py +5 -0
  83. package/ccx/notify/client.py +116 -0
  84. package/ccx/notify/daemon.py +480 -0
  85. package/ccx/notify/deliver.py +304 -0
  86. package/ccx/notify/paths.py +51 -0
  87. package/ccx/notify/registry.py +649 -0
  88. package/ccx/notify/select.py +750 -0
  89. package/ccx/notify/settle.py +684 -0
  90. package/ccx/ops/__init__.py +0 -0
  91. package/ccx/ops/background.py +115 -0
  92. package/ccx/ops/cancel.py +289 -0
  93. package/ccx/ops/codex_import.py +96 -0
  94. package/ccx/ops/common.py +397 -0
  95. package/ccx/ops/compact.py +337 -0
  96. package/ccx/ops/compact_ops.py +550 -0
  97. package/ccx/ops/context_events.py +48 -0
  98. package/ccx/ops/conversation.py +1540 -0
  99. package/ccx/ops/conversation_paging.py +1338 -0
  100. package/ccx/ops/create.py +56 -0
  101. package/ccx/ops/creation.py +952 -0
  102. package/ccx/ops/creation_steps.py +579 -0
  103. package/ccx/ops/event_feed.py +710 -0
  104. package/ccx/ops/forking.py +711 -0
  105. package/ccx/ops/input.py +556 -0
  106. package/ccx/ops/maintenance.py +295 -0
  107. package/ccx/ops/report.py +770 -0
  108. package/ccx/ops/semantics.py +490 -0
  109. package/ccx/ops/send.py +961 -0
  110. package/ccx/ops/steer_ops.py +867 -0
  111. package/ccx/ops/terminate.py +383 -0
  112. package/ccx/ops/turn_reports.py +306 -0
  113. package/ccx/ops/wait.py +762 -0
  114. package/ccx/ops/worker_events.py +636 -0
  115. package/ccx/rpc/__init__.py +0 -0
  116. package/ccx/rpc/local_rpc.py +1602 -0
  117. package/ccx/rpc/rpc.py +559 -0
  118. package/ccx/rpc/serve.py +374 -0
  119. package/ccx/rpc/transport.py +409 -0
  120. package/ccx/rpc/view.py +125 -0
  121. package/ccx/store/__init__.py +0 -0
  122. package/ccx/store/discovery.py +170 -0
  123. package/ccx/store/identity.py +105 -0
  124. package/ccx/store/journal_io.py +79 -0
  125. package/ccx/store/liveness.py +194 -0
  126. package/ccx/store/paths.py +254 -0
  127. package/ccx/store/record_cache.py +40 -0
  128. package/ccx/store/records.py +309 -0
  129. package/ccx/store/root.py +804 -0
  130. package/ccx/store/runtime_presence.py +137 -0
  131. package/ccx/store/store.py +2332 -0
  132. package/ccx/store/worker_state.py +86 -0
  133. package/ccx/system/__init__.py +0 -0
  134. package/ccx/system/bounded.py +296 -0
  135. package/ccx/system/cwd_paths.py +25 -0
  136. package/ccx/system/engine_binary.py +59 -0
  137. package/ccx/system/executable.py +50 -0
  138. package/ccx/system/oslayer.py +667 -0
  139. package/ccx/system/pipes.py +46 -0
  140. package/ccx/system/sanitize.py +43 -0
  141. package/ccx/types.py +705 -0
  142. package/ccx/version.py +29 -0
  143. package/ccx/workers.py +2081 -0
  144. package/ccx.py +9 -0
  145. package/package.json +39 -7
  146. package/pyproject.toml +87 -0
  147. package/skills/ccx/SKILL.md +155 -0
  148. package/skills/ccx/references/controllers.md +244 -0
  149. package/skills/ccx/references/troubleshooting.md +94 -0
  150. package/skills/ccx/references/variables.md +62 -0
@@ -0,0 +1,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()