ccx-native 0.0.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +240 -4
  3. package/bin/ccx +55 -2
  4. package/ccx/__init__.py +167 -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 +807 -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 +269 -0
  16. package/ccx/claude/host/session.py +5244 -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 +2535 -0
  26. package/ccx/claude/usage.py +796 -0
  27. package/ccx/claude/usage_control.py +204 -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/models.py +53 -0
  35. package/ccx/cli/commands/read.py +1104 -0
  36. package/ccx/cli/commands/turns.py +1081 -0
  37. package/ccx/cli/controller.py +757 -0
  38. package/ccx/cli/main.py +302 -0
  39. package/ccx/cli/parser.py +1439 -0
  40. package/ccx/cli/skills.py +215 -0
  41. package/ccx/cli/statusline.py +160 -0
  42. package/ccx/client.py +1904 -0
  43. package/ccx/codex/__init__.py +0 -0
  44. package/ccx/codex/attach_tty.py +267 -0
  45. package/ccx/codex/client.py +738 -0
  46. package/ccx/codex/completion.py +121 -0
  47. package/ccx/codex/control.py +288 -0
  48. package/ccx/codex/daemon/__init__.py +0 -0
  49. package/ccx/codex/daemon/__main__.py +4 -0
  50. package/ccx/codex/daemon/daemon.py +1558 -0
  51. package/ccx/codex/daemon/models.py +140 -0
  52. package/ccx/codex/daemon/native.py +1590 -0
  53. package/ccx/codex/daemon/release.py +367 -0
  54. package/ccx/codex/daemon/worker.py +2424 -0
  55. package/ccx/codex/delivery.py +758 -0
  56. package/ccx/codex/delivery_recovery.py +232 -0
  57. package/ccx/codex/evidence.py +827 -0
  58. package/ccx/codex/history.py +158 -0
  59. package/ccx/codex/history_store.py +131 -0
  60. package/ccx/codex/notify_history.py +140 -0
  61. package/ccx/codex/ops.py +666 -0
  62. package/ccx/codex/paths.py +70 -0
  63. package/ccx/codex/protocol.py +805 -0
  64. package/ccx/codex/provenance.py +105 -0
  65. package/ccx/codex/read.py +29 -0
  66. package/ccx/codex/records.py +51 -0
  67. package/ccx/codex/reports.py +177 -0
  68. package/ccx/codex/runtime.py +1454 -0
  69. package/ccx/codex/settle.py +363 -0
  70. package/ccx/codex/telemetry.py +792 -0
  71. package/ccx/codex/types.py +659 -0
  72. package/ccx/codex/worker_environment.py +25 -0
  73. package/ccx/constants.py +288 -0
  74. package/ccx/controllers/__init__.py +0 -0
  75. package/ccx/controllers/controller.py +1449 -0
  76. package/ccx/controllers/live_vars.py +1706 -0
  77. package/ccx/controllers/live_vars_runner.py +216 -0
  78. package/ccx/controllers/notify.py +213 -0
  79. package/ccx/controllers/orchestrator.py +193 -0
  80. package/ccx/controllers/policy.py +57 -0
  81. package/ccx/controllers/store.py +681 -0
  82. package/ccx/errors.py +634 -0
  83. package/ccx/models.py +225 -0
  84. package/ccx/notify/__init__.py +0 -0
  85. package/ccx/notify/__main__.py +5 -0
  86. package/ccx/notify/client.py +116 -0
  87. package/ccx/notify/daemon.py +480 -0
  88. package/ccx/notify/deliver.py +304 -0
  89. package/ccx/notify/paths.py +51 -0
  90. package/ccx/notify/registry.py +649 -0
  91. package/ccx/notify/select.py +750 -0
  92. package/ccx/notify/settle.py +684 -0
  93. package/ccx/ops/__init__.py +0 -0
  94. package/ccx/ops/background.py +115 -0
  95. package/ccx/ops/cancel.py +296 -0
  96. package/ccx/ops/codex_import.py +96 -0
  97. package/ccx/ops/common.py +397 -0
  98. package/ccx/ops/compact.py +337 -0
  99. package/ccx/ops/compact_ops.py +550 -0
  100. package/ccx/ops/context_events.py +48 -0
  101. package/ccx/ops/conversation.py +1593 -0
  102. package/ccx/ops/conversation_paging.py +1339 -0
  103. package/ccx/ops/create.py +56 -0
  104. package/ccx/ops/creation.py +952 -0
  105. package/ccx/ops/creation_steps.py +575 -0
  106. package/ccx/ops/event_feed.py +710 -0
  107. package/ccx/ops/forking.py +711 -0
  108. package/ccx/ops/history.py +1041 -0
  109. package/ccx/ops/input.py +556 -0
  110. package/ccx/ops/maintenance.py +295 -0
  111. package/ccx/ops/report.py +773 -0
  112. package/ccx/ops/semantics.py +490 -0
  113. package/ccx/ops/send.py +961 -0
  114. package/ccx/ops/steer_ops.py +867 -0
  115. package/ccx/ops/terminate.py +383 -0
  116. package/ccx/ops/turn_reports.py +348 -0
  117. package/ccx/ops/wait.py +762 -0
  118. package/ccx/ops/worker_events.py +640 -0
  119. package/ccx/rpc/__init__.py +0 -0
  120. package/ccx/rpc/local_rpc.py +1602 -0
  121. package/ccx/rpc/rpc.py +559 -0
  122. package/ccx/rpc/serve.py +374 -0
  123. package/ccx/rpc/transport.py +409 -0
  124. package/ccx/rpc/view.py +125 -0
  125. package/ccx/store/__init__.py +0 -0
  126. package/ccx/store/discovery.py +170 -0
  127. package/ccx/store/identity.py +105 -0
  128. package/ccx/store/journal_io.py +79 -0
  129. package/ccx/store/liveness.py +194 -0
  130. package/ccx/store/paths.py +250 -0
  131. package/ccx/store/record_cache.py +40 -0
  132. package/ccx/store/records.py +309 -0
  133. package/ccx/store/root.py +789 -0
  134. package/ccx/store/runtime_presence.py +137 -0
  135. package/ccx/store/store.py +2332 -0
  136. package/ccx/store/worker_state.py +86 -0
  137. package/ccx/system/__init__.py +0 -0
  138. package/ccx/system/bounded.py +296 -0
  139. package/ccx/system/cwd_paths.py +25 -0
  140. package/ccx/system/engine_binary.py +59 -0
  141. package/ccx/system/executable.py +50 -0
  142. package/ccx/system/oslayer.py +667 -0
  143. package/ccx/system/pipes.py +46 -0
  144. package/ccx/system/private_json.py +54 -0
  145. package/ccx/system/sanitize.py +43 -0
  146. package/ccx/types.py +809 -0
  147. package/ccx/version.py +29 -0
  148. package/ccx/workers.py +2093 -0
  149. package/ccx.py +9 -0
  150. package/package.json +39 -7
  151. package/pyproject.toml +87 -0
  152. package/skills/ccx/SKILL.md +166 -0
  153. package/skills/ccx/references/controllers.md +244 -0
  154. package/skills/ccx/references/history.md +50 -0
  155. package/skills/ccx/references/troubleshooting.md +94 -0
  156. package/skills/ccx/references/variables.md +62 -0
@@ -0,0 +1,1558 @@
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
+ # A presence probe needs no worker states, and each takes
784
+ # that worker's lock, which a slow disk write can hold.
785
+ workers = None if message.get("workers") is False else {
786
+ short: worker.state()
787
+ for short, worker in self.workers.items()
788
+ }
789
+ empty_for = (
790
+ time.monotonic() - self.empty_since
791
+ if self.empty_since is not None
792
+ else None
793
+ )
794
+ return {
795
+ "ok": True,
796
+ "state": (
797
+ "stopping" if self.stopping
798
+ else "ready" if self.ready.is_set() else "booting"
799
+ ),
800
+ "runtimeId": self.instance_id,
801
+ "pid": os.getpid(),
802
+ "appServerPid": (
803
+ self.client.process.pid if self.client else None
804
+ ),
805
+ "codexVersion": self.codex_version,
806
+ "diagnostics": list(self.diagnostics),
807
+ "workers": workers,
808
+ "lingerSeconds": daemon_models.linger_seconds(),
809
+ "emptyForSeconds": empty_for,
810
+ "nativeStartEvidence": self._native_start_counts(),
811
+ }
812
+ if op == "daemon-stop":
813
+ with self.lock:
814
+ states = {
815
+ short: worker.state()
816
+ for short, worker in self.workers.items()
817
+ }
818
+ active = {
819
+ short: state for short, state in states.items()
820
+ if state in {"booting", "resuming", "busy", "waiting"}
821
+ }
822
+ # Admission reserves its slot under this same lock, so a new
823
+ # original start is either counted here or sees `stopping`.
824
+ # Unsafe-only diagnostics are unrecoverable and do not pin.
825
+ evidence = self._native_start_counts()
826
+ native_unsettled = evidence["pending"] + evidence["held"]
827
+ if (active or native_unsettled) and not message.get("force"):
828
+ reasons = []
829
+ if active:
830
+ reasons.append("workers are still active: " + ", ".join(
831
+ f"{short} ({state})"
832
+ for short, state in sorted(active.items())
833
+ ))
834
+ if native_unsettled:
835
+ reasons.append(
836
+ "original Codex native starts are unsettled "
837
+ f"({evidence['pending']} pending, {evidence['held']} "
838
+ "held unsaved); stopping would lose their only "
839
+ "recovery evidence"
840
+ )
841
+ return {
842
+ "ok": False,
843
+ "error": "; ".join(reasons),
844
+ "workers": states,
845
+ "nativeStartEvidence": evidence,
846
+ }
847
+ # One decision with the activity check: a load can no longer
848
+ # register behind it.
849
+ self.stopping = True
850
+ self.stop.set()
851
+ return {"ok": True, "stopped": True, "workers": states,
852
+ "nativeStartEvidence": evidence}
853
+ if op == "import-source-point":
854
+ # Read-only unmanaged native source: no CCX worker/source binding.
855
+ # Never start/resume/alter the user's original thread.
856
+ from ccx.codex.history import read_fork_source_point
857
+ try:
858
+ if not self.ready.is_set() or self.client is None:
859
+ raise RuntimeError("Codex app-server not ready")
860
+ source = read_fork_source_point(
861
+ self.client.request, message.get("threadId"),
862
+ deadline=time.monotonic() + _native_history_budget(message),
863
+ )
864
+ self._uuid_store.revalidate()
865
+ return {"ok": True, **source}
866
+ except (ValueError, RuntimeError, ProtocolError, TimeoutError, OSError) as error:
867
+ return {"ok": False, "error": str(error)}
868
+ if op == "thread-history":
869
+ from ccx.codex.history import read_original_thread
870
+ from ccx.store.root import GenerationError
871
+ from ccx.store.identity import IdentityError
872
+ try:
873
+ owner, tid, source = daemon_native._original_history_binding(self, message)
874
+ if not self.ready.is_set() or self.client is None:
875
+ raise RuntimeError("Codex app-server not ready")
876
+ deadline = time.monotonic() + _native_history_budget(message)
877
+ thread = read_original_thread(
878
+ self.client.request, tid, deadline=deadline,
879
+ exact_turn_id=message.get("turnId"), expected_source=source,
880
+ limit=message.get("limit"), newest_first=message.get("newestFirst") is True,
881
+ )
882
+ if thread.get("threadSource") != source:
883
+ raise ValueError("native source mismatch, no provider thread data")
884
+ self._uuid_store.revalidate()
885
+ return {"ok": True, "workerId": owner.worker_id,
886
+ "threadId": tid, "thread": thread}
887
+ except (GenerationError, IdentityError, ValueError, RuntimeError,
888
+ ProtocolError, TimeoutError) as error:
889
+ return {"ok": False, "error": str(error)}
890
+ if op == "thread-read":
891
+ # Shared immutable original-owner proof; thread/read additionally
892
+ # checks the returned native thread object before exposing turns.
893
+ from ccx.store.root import GenerationError
894
+ from ccx.store.identity import IdentityError
895
+ try:
896
+ owner, tid, source = daemon_native._original_history_binding(self, message)
897
+ if not self.ready.is_set() or self.client is None:
898
+ raise RuntimeError("Codex app-server not ready")
899
+ deadline = time.monotonic() + _native_history_budget(message)
900
+ reply = self.client.request("thread/read", {
901
+ "threadId": tid,
902
+ "includeTurns": False,
903
+ }, timeout=max(0.001, deadline - time.monotonic()))
904
+ thread = reply.get("thread") if isinstance(reply, dict) else None
905
+ if (not isinstance(thread, dict)
906
+ or thread.get("id") != tid
907
+ or thread.get("threadSource") != source):
908
+ raise ValueError("native source mismatch, no provider thread data")
909
+ # Older editable-install clients still use thread-read and
910
+ # default includeTurns to true. Reconstruct those turns from
911
+ # pages without issuing deprecated full-history reads.
912
+ if message.get("includeTurns", True):
913
+ from ccx.codex.history import fill_thread
914
+ thread = fill_thread(thread, self.client.request, deadline=deadline)
915
+ self._uuid_store.revalidate()
916
+ return {"ok": True, "workerId": owner.worker_id,
917
+ "threadId": tid, "thread": thread}
918
+ except (GenerationError, IdentityError, ValueError,
919
+ RuntimeError, ProtocolError, TimeoutError) as error:
920
+ return {"ok": False, "error": str(error)}
921
+ if op in {"native-activate", "native-send", "native-observe",
922
+ "native-activation-status"}:
923
+ try:
924
+ if op == "native-activation-status":
925
+ return self._observe_original_codex_thread(message)
926
+ return self._original_full_uuid_native(message)
927
+ except Exception as error:
928
+ return {"ok": False, "error": str(error)}
929
+ if op == "thread-list":
930
+ # A provider-native thread catalogue is never a CCX ownership index.
931
+ return {"ok": False, "error":
932
+ "unscoped native Codex thread discovery forbidden; "
933
+ "use original full UUID workerId"}
934
+ if op in {"thread-items-list", "thread-turns-list"}:
935
+ from ccx.store.root import GenerationError
936
+ from ccx.store.identity import IdentityError, UnknownWorker
937
+ try:
938
+ params = message.get("params")
939
+ if not isinstance(params, dict):
940
+ raise ValueError("thread-items-list requires native item params")
941
+ turns = op == "thread-turns-list"
942
+ allowed = ({"threadId", "limit", "sortDirection", "cursor", "itemsView"}
943
+ if turns else {"threadId", "limit", "sortDirection", "cursor", "turnId"})
944
+ if (set(params) - allowed
945
+ or type(params.get("limit")) is not int
946
+ or not 1 <= params["limit"] <= 100
947
+ or (not isinstance(params.get("sortDirection"), str)
948
+ or params["sortDirection"] not in {"asc", "desc"})
949
+ or ("cursor" in params and
950
+ (not isinstance(params["cursor"], str) or not params["cursor"]))
951
+ or (turns and (not isinstance(params.get("itemsView"), str)
952
+ or params["itemsView"] not in {"notLoaded", "summary", "full"}))
953
+ or (not turns and "turnId" in params and
954
+ (not isinstance(params["turnId"], str) or not params["turnId"]))):
955
+ raise ValueError("invalid bounded Codex native item parameters")
956
+ owner, tid, source = daemon_native._original_history_binding(self, {
957
+ "workerId": message.get("workerId"),
958
+ "threadId": params.get("threadId"),
959
+ })
960
+ if not self.ready.is_set() or self.client is None:
961
+ raise RuntimeError("Codex app-server not ready")
962
+ page = self.client.request(
963
+ "thread/turns/list" if turns else "thread/items/list",
964
+ params, timeout=2,
965
+ )
966
+ if (not isinstance(page, dict)
967
+ or not isinstance(page.get("data"), list)
968
+ or (page.get("nextCursor") is not None
969
+ and not isinstance(page.get("nextCursor"), str))):
970
+ raise ValueError("Codex native item page malformed")
971
+ self._uuid_store.revalidate()
972
+ return {"ok": True, "workerId": owner.worker_id,
973
+ "threadId": tid, "threadSource": source, "data": page}
974
+ except UnknownWorker as error:
975
+ return {"ok": False, "reason": "worker-not-found", "error": str(error)}
976
+ except ProtocolError as error:
977
+ return _protocol_failure(error)
978
+ except (GenerationError, IdentityError, ValueError, RuntimeError) as error:
979
+ return {"ok": False, "error": str(error)}
980
+ if op == "account-read":
981
+ if not self.ready.is_set() or self.client is None:
982
+ return {"ok": False, "error": "codexd is booting"}
983
+ try:
984
+ if message.get("tokens"):
985
+ result = self.client.request("account/usage/read", timeout=30)
986
+ else:
987
+ result = self._read_rate_limits()
988
+ return {"ok": True, "data": result}
989
+ except ProtocolError as error:
990
+ return _protocol_failure(error)
991
+ short = message.get("short")
992
+ from ccx.store.identity import IdentityError
993
+ try:
994
+ owner = self._uuid_store.resolve(short)
995
+ if owner.engine != "codex":
996
+ raise ValueError("Codex daemon operation needs Codex full UUID")
997
+ short = owner.worker_id
998
+ except (IdentityError, ValueError, TypeError) as error:
999
+ return {"ok":False,"error":
1000
+ f"globally owned full-UUID Codex worker required: {error}"}
1001
+ if op == "load":
1002
+ return self._load_original_native_worker(short, message.get("timeout"))
1003
+ if op == "set-config":
1004
+ return self.set_config(short, message)
1005
+ if op == "set-name":
1006
+ return self.set_name(short, message)
1007
+ with self.lock:
1008
+ worker = self.workers.get(short)
1009
+ if worker is None and op == "kill":
1010
+ # Promptless original native creation has a live thread but no
1011
+ # daemon Worker until its first semantic send. Read-only adoption
1012
+ # proves exact native threadSource; no start or resume.
1013
+ try:
1014
+ worker = self._adopt_original_native_worker(f"codex:{short}")
1015
+ except Exception as error:
1016
+ return {"ok": False, "error": f"original native kill adoption unconfirmed: {error}"}
1017
+ if op == "background-refresh":
1018
+ if worker is None:
1019
+ return self._not_loaded(short)
1020
+ return {"ok": True, "changed": worker.refresh_parent_notifications()}
1021
+ if worker is None:
1022
+ if op in {"parent-notify", "machine-wake"}:
1023
+ return {"ok": False, "definitive": True,
1024
+ "error": "original parent is unloaded; notification did not reach native"}
1025
+ return self._not_loaded(short)
1026
+ if op == "parent-notify":
1027
+ worker.refresh_parent_notifications()
1028
+ from ccx.notify.registry import read_registration
1029
+ from ccx.notify.select import _original_notification_registration
1030
+ from ccx.notify.deliver import payload_for_registration
1031
+ event = message.get("eventId")
1032
+ delivery = message.get("deliveryId")
1033
+ payload = message.get("payload")
1034
+ if not isinstance(event, str) or not event:
1035
+ return {"ok": False, "error": "original parent notification needs event ID"}
1036
+ reg = read_registration(event)
1037
+ if not isinstance(reg, dict):
1038
+ return {"ok": False, "error": "original notification registration missing"}
1039
+ try:
1040
+ store, parent, _child = _original_notification_registration(reg)
1041
+ store.revalidate()
1042
+ final = (reg.get("state") == "ready" and payload == reg.get("payload")
1043
+ and isinstance(reg.get("settlement"), dict)
1044
+ and payload == payload_for_registration(reg, reg["settlement"])
1045
+ and delivery == f"{event}:settled"
1046
+ and isinstance(payload, dict) and payload.get("parent") == parent)
1047
+ if (parent != f"codex:{short}" or not final
1048
+ or not isinstance(payload, dict)
1049
+ or payload.get("eventId") != event
1050
+ or payload.get("deliveryId") != delivery):
1051
+ raise ValueError("original notification parent/event/payload mismatch")
1052
+ with worker.lock:
1053
+ if (worker.killed.is_set() or worker.kill_intent.is_set()
1054
+ or not worker.loaded
1055
+ or worker.record.get("lifecycle", {}).get("state")
1056
+ not in ("idle", "busy", "waiting")
1057
+ or worker._original_notification_thread()
1058
+ != reg["parent"]["threadId"]):
1059
+ raise ValueError("original parent not live, subscribed or source verified")
1060
+ except Exception as error:
1061
+ return {"ok": False, "definitive": True,
1062
+ "error": f"original notification admission refused: {error}"}
1063
+ if op == "machine-wake":
1064
+ payload = message.get("payload")
1065
+ event, delivery = message.get("eventId"), message.get("deliveryId")
1066
+ namespace, name = message.get("namespace"), message.get("name")
1067
+ if (not isinstance(event, str) or not event or len(event) > 256
1068
+ or not isinstance(delivery, str) or not delivery or len(delivery) > 512
1069
+ or not isinstance(payload, dict)
1070
+ or payload.get("eventId") != event
1071
+ or payload.get("deliveryId") != delivery
1072
+ or not isinstance(namespace, str) or not 0 < len(namespace) <= 128
1073
+ or namespace == "ccx"
1074
+ or not isinstance(name, str) or not 0 < len(name) <= 128
1075
+ or type(message.get("reconcile")) is not bool):
1076
+ return {"ok": False, "definitive": True,
1077
+ "error": "invalid original machine wake identity/output"}
1078
+ try:
1079
+ with worker.lock:
1080
+ native = worker.record.get("native") or {}
1081
+ if (worker.killed.is_set() or worker.kill_intent.is_set()
1082
+ or not worker.loaded
1083
+ or worker.record.get("lifecycle", {}).get("state")
1084
+ not in ("idle", "busy", "waiting")
1085
+ or message.get("threadId") != worker._original_notification_thread()
1086
+ or native.get("threadId") != message["threadId"]):
1087
+ raise ValueError("original machine wake not subscribed/owned/active")
1088
+ except Exception as error:
1089
+ return {"ok": False, "definitive": True,
1090
+ "error": f"original machine wake admission refused: {error}"}
1091
+ if op == "status":
1092
+ return {"ok": True, "loaded": True, **worker.public_status()}
1093
+ if op == "compact-observe":
1094
+ # No load, settlement, native request or operation execution lock.
1095
+ with worker.lock:
1096
+ active = worker.record.get("activeCompact")
1097
+ last = worker.record.get("lastCompact") or {}
1098
+ owner = "held" if active else (
1099
+ "unknown" if (last.get("status") == "unknown"
1100
+ and not last.get("nativeTurnSettled"))
1101
+ or worker.record["lifecycle"]["state"] not in {"idle", "busy", "waiting"}
1102
+ else "released"
1103
+ )
1104
+ return json.loads(json.dumps({
1105
+ "ok": True, "state": worker.record["lifecycle"]["state"],
1106
+ "activeCompact": active, "lastCompact": last,
1107
+ "input_surface": {"compaction_ownership": owner,
1108
+ "operation_id": (active or {}).get("operationId"),
1109
+ "runtime_id": self.instance_id},
1110
+ }))
1111
+ # Native control/parent receipt families below still lack the
1112
+ # generation-scoped prepared/submitted native-effect fence. They
1113
+ # must not descend into the old short-keyed handler just because
1114
+ # this original worker is now correctly indexed. `interrupt` is fenced
1115
+ # by the caller's GenerationStore cancel intent and expectedTurnId.
1116
+ if op not in ("status", "interrupt", "approve", "reject", "kill",
1117
+ "parent-notify", "machine-wake", "compact"):
1118
+ return {"ok":False,"error":
1119
+ f"{op}: original full-UUID Codex effect receipt unavailable; "
1120
+ "no native call performed"}
1121
+ if op == "kill":
1122
+ return self._kill_worker(worker, message.get("observeSeconds"))
1123
+ history = None
1124
+ if (
1125
+ op in {"parent-notify", "machine-wake"}
1126
+ and message.get("reconcile", True)
1127
+ and isinstance(message.get("payload"), dict)
1128
+ and isinstance(message.get("deliveryId") or message.get("eventId"), str)
1129
+ and not (worker.kill_intent.is_set() or worker.killed.is_set())
1130
+ ):
1131
+ # The read-only history walk can page a long parent thread; it runs
1132
+ # before the operation lock so the parent's own sends and kills do
1133
+ # not queue behind it.
1134
+ try:
1135
+ history = (worker.find_parent_notification(
1136
+ message.get("deliveryId") or message["eventId"],
1137
+ message["payload"],
1138
+ name=(message["name"] if op == "machine-wake" else "worker_notification"),
1139
+ ),)
1140
+ except ProtocolError as error:
1141
+ return _protocol_failure(error)
1142
+ except RuntimeError as error:
1143
+ if op != "machine-wake":
1144
+ raise
1145
+ return {"ok": False, "pending": True, "error": str(error)}
1146
+ for _ in range(3):
1147
+ if worker.kill_intent.is_set() or worker.killed.is_set():
1148
+ # Refused without waiting for `operation_lock`: a kill may be
1149
+ # sitting on that lock waiting for an earlier operation to
1150
+ # settle, and queueing new work behind it is exactly what a
1151
+ # kill intent must stop.
1152
+ return {"ok": False, "definitive": True,
1153
+ "error": f"worker {short} is being killed", "state": worker.state()}
1154
+ with worker.operation_lock:
1155
+ if self._still_registered(worker):
1156
+ return worker.handle_operation(message, history=history)
1157
+ # The object was unloaded (idle-kill, kill, crash) while this
1158
+ # request waited for its lock. Acting on it would write a stale
1159
+ # record and drive a thread nothing listens to; follow the worker
1160
+ # to the object that is registered now, if any.
1161
+ with self.lock:
1162
+ current = self.workers.get(short)
1163
+ if current is None or current is worker:
1164
+ break
1165
+ worker = current
1166
+ return self._not_loaded(short)
1167
+
1168
+ def _not_loaded(self, short: str) -> dict[str, Any]:
1169
+ return {
1170
+ "ok": False,
1171
+ "loaded": False,
1172
+ "runtime_id": self.instance_id,
1173
+ "error": f"worker {short} is not loaded",
1174
+ }
1175
+
1176
+ def _still_registered(self, worker: "Worker") -> bool:
1177
+ """Is this object still the daemon's live worker for its short?"""
1178
+ with self.lock:
1179
+ return (
1180
+ self.workers.get(worker.short) is worker
1181
+ and not worker.teardown_claimed
1182
+ )
1183
+
1184
+ def _kill_worker(self, worker: "Worker",
1185
+ observe_seconds: Any = None) -> dict[str, Any]:
1186
+ """Kill one worker without either blocking indefinitely or racing.
1187
+
1188
+ `kill` must be serialized against an in-flight steer/approve — bypassing
1189
+ `operation_lock` would let it interrupt a turn while another thread is
1190
+ mid-`turn/steer`, and the two would write the same record from opposite
1191
+ directions. But taking that lock unconditionally is how a kill silently
1192
+ outlives its confirmation budget.
1193
+
1194
+ So the intent is published first (new operations are refused
1195
+ immediately, above), and the lock is then awaited only inside the
1196
+ caller's own budget. If the earlier operation cannot settle in time, the
1197
+ kill is reported explicitly unresolved and the worker is deliberately
1198
+ left registered and nonterminal — `confirmed_kill` reconciles that into
1199
+ a raise rather than a false success, and a retry after the operation
1200
+ settles completes normally. The budget is never extended: the wait for
1201
+ the lock and `kill`'s own launch-settle window come out of the same
1202
+ allowance.
1203
+ """
1204
+ budget = (_LAUNCH_SETTLE_CAP_S if observe_seconds is None
1205
+ else max(0.0, min(float(observe_seconds),
1206
+ _LAUNCH_SETTLE_CAP_S)))
1207
+ worker.kill_intent.set()
1208
+ started = time.monotonic()
1209
+ if budget > 0:
1210
+ acquired = worker.operation_lock.acquire(timeout=budget)
1211
+ else:
1212
+ acquired = worker.operation_lock.acquire(blocking=False)
1213
+ if not acquired:
1214
+ # unresolved, not killed: another operation still owns this worker,
1215
+ # so nothing has been interrupted or terminalized. Saying "killed"
1216
+ # here would be the lie this whole path exists to avoid.
1217
+ #
1218
+ # Nor was anything accepted. No kill ran, and nobody is guaranteed
1219
+ # to retry, so leaving the intent set would strand a perfectly live
1220
+ # worker refusing every operation forever. Withdraw it — unless a
1221
+ # real kill (`killed`) is already in progress, whose sticky marker
1222
+ # is the one that must survive.
1223
+ if not worker.killed.is_set():
1224
+ worker.kill_intent.clear()
1225
+ return {
1226
+ "ok": False,
1227
+ "pending": True,
1228
+ "error": (f"worker {worker.short} has an operation in flight "
1229
+ f"that did not settle within {budget:g}s; the kill "
1230
+ f"was NOT completed and is not confirmed — retry "
1231
+ f"kill once that operation settles"),
1232
+ "state": worker.state(),
1233
+ }
1234
+ remaining = max(0.0, budget - (time.monotonic() - started))
1235
+ if self._still_registered(worker):
1236
+ try:
1237
+ return worker.kill(remaining)
1238
+ finally:
1239
+ worker.operation_lock.release()
1240
+ worker.operation_lock.release()
1241
+ # The operation we waited behind unloaded this object. Killing it now
1242
+ # would confirm a kill against whatever has since been loaded under the
1243
+ # same short and write `dead` over its record; kill that one instead.
1244
+ with self.lock:
1245
+ current = self.workers.get(worker.short)
1246
+ if current is not None and current is not worker:
1247
+ return self._kill_worker(current, remaining)
1248
+ return self._not_loaded(worker.short)
1249
+
1250
+ def dispatch(self, message: dict[str, Any]) -> dict[str, Any]:
1251
+ """Fence one request on the client's declared RPC generation.
1252
+
1253
+ Recovery and control operations answer any client: they are how an
1254
+ operator inspects and retires a daemon whose generation no longer
1255
+ matches theirs. Every other operation must declare this generation
1256
+ before its handler runs, so a client from an older install cannot
1257
+ mutate a current daemon and discover the mismatch only in the reply.
1258
+ """
1259
+ if not isinstance(message, dict):
1260
+ raise TypeError("codexd request must be a JSON object")
1261
+ if message.get("op") in CODEXD_RECOVERY_OPS:
1262
+ return self.handle(message)
1263
+ declared = message.get("rpcGeneration")
1264
+ if isinstance(declared, bool) or not isinstance(declared, int):
1265
+ declared = None
1266
+ if declared != CODEXD_RPC_GENERATION:
1267
+ return {
1268
+ "ok": False,
1269
+ "incompatible": True,
1270
+ "error": (
1271
+ f"codexd speaks RPC generation {CODEXD_RPC_GENERATION}, but "
1272
+ f"the client declared "
1273
+ f"{declared if declared is not None else 'none (legacy)'}. "
1274
+ f"Upgrade the caller, or run `ccx shutdown --engine codex` to "
1275
+ f"replace this daemon."
1276
+ ),
1277
+ }
1278
+ return self.handle(message)
1279
+
1280
+ def _open_attach(self, message: dict[str, Any]) -> tuple[dict, Worker | None]:
1281
+ """Load the original native thread and hold codexd's subscription."""
1282
+ from ccx.store.identity import IdentityError
1283
+ try:
1284
+ owner = self._uuid_store.resolve(message.get("short"))
1285
+ if owner.engine != "codex":
1286
+ raise ValueError("attach requires an original Codex worker")
1287
+ result = self._load_original_native_worker(owner.worker_id, 90)
1288
+ if result.get("ok") is not True or result.get("loaded") is not True:
1289
+ return {**result, "ok": False,
1290
+ "error": result.get("error") or "original thread not loaded"}, None
1291
+ with self.lock:
1292
+ worker = self.workers.get(owner.worker_id)
1293
+ if worker is None or worker.teardown_claimed or self.stopping:
1294
+ raise RuntimeError("Codex worker unloaded during attach")
1295
+ with worker.lock:
1296
+ if (not worker.loaded
1297
+ or worker.record["lifecycle"]["state"] not in {"idle", "busy", "waiting"}):
1298
+ raise RuntimeError("Codex worker no longer attachable")
1299
+ worker.attach_count += 1
1300
+ native = worker.record["native"]
1301
+ reply = {"ok": True, "loaded": True,
1302
+ "threadId": native["threadId"], "cwd": worker.record["cwd"],
1303
+ "socket": str(self.app_server_path)}
1304
+ return reply, worker
1305
+ except (IdentityError, ValueError, RuntimeError, OSError) as error:
1306
+ return {"ok": False, "error": str(error)}, None
1307
+
1308
+ def _serve_connection(self, connection: socket.socket) -> None:
1309
+ """Require same-connection full-root/gen/role handshake before RPC.
1310
+
1311
+ The existing original daemon dispatch stays authoritative. No legacy
1312
+ singleton socket is accepted merely because the caller shares a UID.
1313
+ """
1314
+ from ccx.rpc.transport import handshake_payload
1315
+ store = self._uuid_store
1316
+ plan = self._uuid_plan
1317
+ expected = handshake_payload(
1318
+ plan, daemon_nonce=self._uuid_daemon_lease.nonce,
1319
+ )
1320
+ with connection:
1321
+ held_worker: Worker | None = None
1322
+ connection.settimeout(30)
1323
+ data = bytearray()
1324
+
1325
+ def read_one() -> dict:
1326
+ while b"\n" not in data:
1327
+ chunk = connection.recv(65536)
1328
+ if not chunk:
1329
+ raise EOFError("native daemon request ended before frame")
1330
+ data.extend(chunk)
1331
+ if len(data) > 1024 * 1024:
1332
+ raise ValueError("Codex daemon RPC frame too large")
1333
+ line, rest = data.split(b"\n", 1)
1334
+ data[:] = rest
1335
+ parsed = json.loads(line)
1336
+ if not isinstance(parsed, dict):
1337
+ raise ValueError("Codex daemon request must be JSON object")
1338
+ return parsed
1339
+
1340
+ try:
1341
+ first = read_one()
1342
+ store.revalidate()
1343
+ if (first.get("op") != "handshake"
1344
+ or first.get("identity") != {
1345
+ "protocolVersion": 2, **plan.evidence(),
1346
+ }):
1347
+ raise ValueError(
1348
+ "missing exact full-root/generation/Codex role handshake"
1349
+ )
1350
+ connection.sendall((json.dumps({
1351
+ "ok": True, "identity": expected,
1352
+ }) + "\n").encode())
1353
+ request = read_one()
1354
+ store.revalidate()
1355
+ if request.get("op") == "handshake":
1356
+ raise ValueError("duplicate Codex singleton handshake")
1357
+ if (request.get("op") == "attach"
1358
+ and request.get("rpcGeneration") == CODEXD_RPC_GENERATION):
1359
+ reply, held_worker = self._open_attach(request)
1360
+ else:
1361
+ reply = self.dispatch(request)
1362
+ except Exception as error:
1363
+ reply = {"ok": False, "error": str(error)}
1364
+ reply.setdefault("rpcGeneration", CODEXD_RPC_GENERATION)
1365
+ try:
1366
+ connection.settimeout(None)
1367
+ connection.sendall((json.dumps(reply) + "\n").encode())
1368
+ if held_worker is not None:
1369
+ # This is the attach lifetime: EOF releases the hold.
1370
+ # No further protocol messages, tokens or heartbeat needed.
1371
+ while connection.recv(4096):
1372
+ pass
1373
+ except OSError:
1374
+ pass
1375
+ finally:
1376
+ if held_worker is not None:
1377
+ with held_worker.lock:
1378
+ held_worker.attach_count -= 1
1379
+ held_worker.last_activity = time.monotonic()
1380
+
1381
+ def _accept_loop(self) -> None:
1382
+ server = self.server
1383
+ assert server is not None
1384
+ # Serve until the socket itself is closed, not merely until `stop`:
1385
+ # between the two a client would otherwise connect into an unserved
1386
+ # backlog and time out, instead of being told `stopping`.
1387
+ while True:
1388
+ try:
1389
+ connection = accept_same_user(server)
1390
+ except socket.timeout:
1391
+ if server.fileno() == -1:
1392
+ return
1393
+ continue
1394
+ except OSError:
1395
+ if server.fileno() == -1:
1396
+ return
1397
+ continue
1398
+ if connection is None:
1399
+ continue
1400
+ threading.Thread(
1401
+ target=self._serve_connection,
1402
+ args=(connection,),
1403
+ name="ccx-codexd-rpc",
1404
+ daemon=True,
1405
+ ).start()
1406
+
1407
+ def run(self) -> None:
1408
+ """Own the singleton socket and app-server under the active generation.
1409
+
1410
+ The transport and role lease authenticate the full root and generation.
1411
+ """
1412
+ from ccx import constants
1413
+ from ccx.store.root import require_ccx_active
1414
+ from ccx.rpc.transport import (
1415
+ daemon_process_lease, ensure_transport_parent, plan_transport,
1416
+ )
1417
+ store = require_ccx_active(constants.CCX_ROOT)
1418
+ plan = plan_transport(store, role="codexd")
1419
+ ensure_transport_parent(store, plan)
1420
+ self._uuid_store = store
1421
+ self._uuid_plan = plan
1422
+ with daemon_process_lease(store, plan) as lease:
1423
+ self._uuid_daemon_lease = lease
1424
+ path = plan.final
1425
+ try:
1426
+ # A crash can leave a stale socket inode. The singleton role
1427
+ # flock plus private root bind allows removing only an owned
1428
+ # socket; never unlink an arbitrary filesystem entry.
1429
+ try:
1430
+ old = os.lstat(path)
1431
+ except FileNotFoundError:
1432
+ old = None
1433
+ if old is not None:
1434
+ import stat
1435
+ if (not stat.S_ISSOCK(old.st_mode)
1436
+ or old.st_uid != os.getuid()):
1437
+ raise RuntimeError("untrusted old Codex daemon socket")
1438
+ path.unlink()
1439
+ with store.lease():
1440
+ self.server = socket.socket(
1441
+ socket.AF_UNIX, socket.SOCK_STREAM,
1442
+ )
1443
+ bind_private_unix_socket(self.server, path)
1444
+ self.server.listen(32)
1445
+ self.server.settimeout(1)
1446
+ self.accepter = threading.Thread(
1447
+ target=self._accept_loop,
1448
+ name="ccx-codexd-accept", daemon=True,
1449
+ )
1450
+ self.accepter.start()
1451
+ self._start_client()
1452
+ self._reconcile_orphan_records()
1453
+ self.ready.set()
1454
+ threading.Thread(
1455
+ target=self._janitor, name="ccx-codexd-janitor",
1456
+ daemon=True,
1457
+ ).start()
1458
+ while not self.stop.wait(1):
1459
+ failure = self.runtime_failure()
1460
+ if failure is not None:
1461
+ raise RuntimeError(failure)
1462
+ except BaseException as error:
1463
+ if not isinstance(error, (KeyboardInterrupt, SystemExit)):
1464
+ with self.lock:
1465
+ crashed = list(self.workers.values())
1466
+ for worker in crashed:
1467
+ if worker.state() in LIVE_STATES:
1468
+ worker._state("crashed", error=str(error))
1469
+ print(f"ccx codexd: {error}", file=sys.stderr, flush=True)
1470
+ finally:
1471
+ with self.lock:
1472
+ self.stopping = True
1473
+ self.stop.set()
1474
+ if self.server:
1475
+ self.server.close()
1476
+ if self.accepter:
1477
+ self.accepter.join(timeout=2)
1478
+ try:
1479
+ self._retire_workers()
1480
+ finally:
1481
+ try:
1482
+ if self.client:
1483
+ self.client.close()
1484
+ finally:
1485
+ if self.app_server_path and self.app_server_inode:
1486
+ try:
1487
+ inode = os.lstat(self.app_server_path)
1488
+ if (inode.st_dev, inode.st_ino) == self.app_server_inode:
1489
+ os.unlink(self.app_server_path)
1490
+ except FileNotFoundError:
1491
+ pass
1492
+ try:
1493
+ # Do not unlink a replacement socket belonging to a
1494
+ # different inode/process lifetime.
1495
+ if path.exists() and path.is_socket():
1496
+ path.unlink()
1497
+ except OSError:
1498
+ pass
1499
+
1500
+ def _adopt_original_native_worker(self, target: str, *, read_timeout: float=1.0) -> 'Worker':
1501
+ return daemon_native._adopt_original_native_worker(self, target, read_timeout=read_timeout)
1502
+
1503
+ def _await_thread_release(self, thread_id: str | None) -> None:
1504
+ return daemon_release._await_thread_release(self, thread_id)
1505
+
1506
+ def _janitor(self) -> None:
1507
+ return daemon_release._janitor(self)
1508
+
1509
+ def _janitor_daemon_pass(self) -> bool:
1510
+ return daemon_release._janitor_daemon_pass(self)
1511
+
1512
+ def _janitor_native_start_pass(self) -> None:
1513
+ return daemon_native._janitor_native_start_pass(self)
1514
+
1515
+ def _janitor_worker(self, worker: Worker) -> None:
1516
+ return daemon_release._janitor_worker(self, worker)
1517
+
1518
+ def _load_original_native_worker(self, worker_id: str, timeout: float | None) -> dict[str, Any]:
1519
+ return daemon_native._load_original_native_worker(self, worker_id, timeout)
1520
+
1521
+ def _native_start_counts(self) -> dict[str, int]:
1522
+ return daemon_native._native_start_counts(self)
1523
+
1524
+ def _observe_original_codex_thread(self, message: dict[str, Any]) -> dict[str, Any]:
1525
+ return daemon_native._observe_original_codex_thread(self, message)
1526
+
1527
+ def _original_full_uuid_native(self, message: dict[str, Any]) -> dict[str, Any]:
1528
+ return daemon_native._original_full_uuid_native(self, message)
1529
+
1530
+ def _release_native_threads(self, worker: Worker, releases: list['_ThreadRelease']) -> None:
1531
+ return daemon_release._release_native_threads(self, worker, releases)
1532
+
1533
+ def _retire_workers(self) -> None:
1534
+ return daemon_release._retire_workers(self)
1535
+
1536
+ def _save_held_codex_candidate(self, hold_key: tuple[str, str, str]) -> dict[str, Any]:
1537
+ return daemon_native._save_held_codex_candidate(self, hold_key)
1538
+
1539
+ def _submit_original_codex_thread(self, worker_id: str, record: dict, source: str, token: str, *, admission_deadline: float | None=None) -> dict[str, Any]:
1540
+ return daemon_native._submit_original_codex_thread(self, worker_id, record, source, token, admission_deadline=admission_deadline)
1541
+
1542
+ def _unload(self, worker: Worker, *, terminal_state: str, owns_lifecycle: bool=False) -> None:
1543
+ return daemon_release._unload(self, worker, terminal_state=terminal_state, owns_lifecycle=owns_lifecycle)
1544
+
1545
+
1546
+ def main(argv=None) -> None:
1547
+ parser = argparse.ArgumentParser(
1548
+ description="CCX shared Codex app-server daemon (codexd)."
1549
+ )
1550
+ parser.parse_args(argv)
1551
+ from ccx.system.engine_binary import resolve_engine_binary
1552
+ from ccx.errors import SessionUnavailable
1553
+ try:
1554
+ resolve_engine_binary("codex")
1555
+ except SessionUnavailable as error:
1556
+ parser.exit(error.exit_code, f"ccx: {error}\n")
1557
+ signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
1558
+ CodexDaemon().run()