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,116 @@
1
+ """Public observation and cancellation of durable parent continuations.
2
+
3
+ The notification supervisor owns persistence and delivery. This module exposes a
4
+ small supported surface so dashboards and orchestrators can inspect armed
5
+ registrations and cancel one explicitly without importing parent_notify internals
6
+ or reading its state files.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+ from uuid import UUID
13
+
14
+ from ccx.errors import InvalidRequest
15
+ from ccx.notify import registry as parent_notify
16
+
17
+
18
+ def _event_id(value: str) -> str:
19
+ if not isinstance(value, str) or not value:
20
+ raise InvalidRequest("notification event_id must be a canonical UUID")
21
+ try:
22
+ if str(UUID(value)) != value:
23
+ raise ValueError("noncanonical event id")
24
+ except (ValueError, AttributeError) as error:
25
+ raise InvalidRequest("notification event_id must be a canonical UUID") from error
26
+ return value
27
+
28
+
29
+ class ParentNotificationClient:
30
+ """Inspect durable parent notifications and explicitly cancel one."""
31
+
32
+ @staticmethod
33
+ def _public(value: dict[str, Any]) -> dict[str, Any]:
34
+ return parent_notify.public_registration(value)
35
+
36
+ def get(self, event_id: str) -> dict[str, Any] | None:
37
+ event_id = _event_id(event_id)
38
+ value = parent_notify.read_registration(event_id)
39
+ if not isinstance(value, dict):
40
+ return None
41
+ return self._public(value)
42
+
43
+ def cancel(self, event_id: str, *, reason: str = "cancelled by operator") -> dict[str, Any] | None:
44
+ event_id = _event_id(event_id)
45
+ if not isinstance(reason, str) or not reason.strip():
46
+ raise InvalidRequest("notification cancel reason must be a non-empty string")
47
+ value = parent_notify.read_registration(event_id)
48
+ if not isinstance(value, dict):
49
+ return None
50
+ parent_notify.cancel_registration(event_id, reason.strip())
51
+ updated = parent_notify.read_registration(event_id)
52
+ return self._public(updated) if isinstance(updated, dict) else None
53
+
54
+ def list(
55
+ self,
56
+ *,
57
+ parent: str | None = None,
58
+ worker: str | None = None,
59
+ source: str | None = None,
60
+ state: str | None = None,
61
+ include_terminal: bool = False,
62
+ limit: int | None = None,
63
+ ) -> dict[str, Any]:
64
+ if limit is not None and (
65
+ isinstance(limit, bool) or not isinstance(limit, int) or limit < 1
66
+ ):
67
+ raise InvalidRequest("notification limit must be a positive integer")
68
+
69
+ from ccx.store.identity import canonical_worker_id, IdentityError
70
+ filters = []
71
+ for candidate, kinds in ((parent, {"codex"}),
72
+ (worker, {"codex", "claude", "controller"})):
73
+ if candidate is None:
74
+ filters.append(None)
75
+ continue
76
+ try:
77
+ kind, separator, identity = candidate.partition(":")
78
+ if separator:
79
+ if kind not in kinds:
80
+ raise ValueError("unsupported notification subject kind")
81
+ else:
82
+ identity, kind = candidate, None
83
+ filters.append((kind, canonical_worker_id(identity)))
84
+ except (IdentityError, ValueError, AttributeError) as error:
85
+ raise InvalidRequest("notification filter needs FULL_UUID or KIND:FULL_UUID with a supported kind") from error
86
+ parent_filter, worker_filter = filters
87
+ rows: list[dict[str, Any]] = []
88
+ for value in parent_notify.iter_registrations(
89
+ include_terminal=include_terminal
90
+ ):
91
+ row = self._public(value)
92
+ if any(
93
+ selected is not None and (
94
+ not isinstance(row.get(field), str)
95
+ or row[field].split(":", 1)[-1] != selected[1]
96
+ or (selected[0] is not None and row[field] != f"{selected[0]}:{selected[1]}")
97
+ )
98
+ for field, selected in (("parent", parent_filter), ("worker", worker_filter))
99
+ ):
100
+ continue
101
+ if source is not None and value.get("source") != source:
102
+ continue
103
+ if state is not None and row.get("state") != state:
104
+ continue
105
+ rows.append(row)
106
+
107
+ rows.sort(
108
+ key=lambda row: (
109
+ int(row.get("created_at_ms") or 0),
110
+ str(row.get("event_id") or ""),
111
+ ),
112
+ reverse=True,
113
+ )
114
+ if limit is not None:
115
+ rows = rows[:limit]
116
+ return {"count": len(rows), "notifications": rows}
@@ -0,0 +1,480 @@
1
+ from __future__ import annotations
2
+ import fcntl
3
+ import os
4
+ import signal
5
+ from pathlib import Path
6
+ import subprocess
7
+ import sys
8
+ import threading
9
+ import time
10
+ from typing import Any
11
+ from ccx.system import oslayer
12
+ from ccx.constants import NOTIFYD_GENERATION
13
+ from ccx.errors import SessionUnavailable
14
+ from ccx.notify.paths import (
15
+ daemon_generation_path,
16
+ daemon_log_path,
17
+ daemon_spawn_lock_path,
18
+ ensure_dirs,
19
+ )
20
+ from ccx.notify.registry import has_pending_registrations, iter_registrations
21
+
22
+
23
+ def _read_notifyd_stamp() -> tuple[int, int, str, str] | None:
24
+ """Read the sole published PID/protocol/ACTIVE/start identity.
25
+
26
+ The daemon publishes all four fields after acquiring the role flock.
27
+ Readers must not compete for that flock: even a momentary probe could
28
+ prevent a starting daemon from acquiring its nonblocking role lease.
29
+ """
30
+ try:
31
+ fields = daemon_generation_path().read_text(encoding="utf-8").split()
32
+ if len(fields) == 4:
33
+ pid, version = int(fields[0]), int(fields[1])
34
+ generation, start = fields[2:]
35
+ if (pid > 0 and version > 0 and generation
36
+ and oslayer.valid_kernel_start_token(start)):
37
+ return pid, version, generation, start
38
+ except (OSError, UnicodeError, ValueError):
39
+ pass
40
+ return None
41
+
42
+
43
+ def _notifyd_alive() -> bool:
44
+ from ccx import constants
45
+ from ccx.store.root import GenerationError, require_ccx_active
46
+ from ccx.store.liveness import owned_process
47
+
48
+ stamp = _read_notifyd_stamp()
49
+ if stamp is None or stamp[1] != NOTIFYD_GENERATION:
50
+ return False
51
+ try:
52
+ return (stamp[2] == require_ccx_active(constants.CCX_ROOT).generation
53
+ and owned_process(stamp[0], stamp[3]) == "alive")
54
+ except (GenerationError, OSError):
55
+ return False
56
+
57
+
58
+ def _retire_incompatible_notifyd(*, timeout: float = 5.0) -> bool:
59
+ """Three-state original identity: dead is stale, uncertain refuses signal.
60
+
61
+ Return True if the current live daemon must be retained by the spawner.
62
+
63
+ Registrations are durable authorities: restarting between a Codex write
64
+ and receipt update is safe because the next supervisor reconciles the
65
+ delivery ID against native parent history. A live old-protocol daemon
66
+ may be retired only on full PID/start proof in the current ACTIVE
67
+ generation; an unprovable process must never receive SIGTERM.
68
+ """
69
+ from ccx import constants
70
+ from ccx.store.root import GenerationError, require_ccx_active
71
+ from ccx.store.liveness import owned_process
72
+
73
+ stamp = _read_notifyd_stamp()
74
+ if stamp is None:
75
+ return False # no published identity, including a half-written start
76
+ pid, version, generation, start = stamp
77
+ presence = owned_process(pid, start)
78
+ if presence == "dead":
79
+ return False
80
+ if presence != "alive":
81
+ raise SessionUnavailable("notifyd PID/start presence unprovable")
82
+ try:
83
+ if generation != require_ccx_active(constants.CCX_ROOT).generation:
84
+ raise SessionUnavailable("live notifyd belongs to another ACTIVE generation")
85
+ except (GenerationError, OSError) as exc:
86
+ raise SessionUnavailable("notifyd ACTIVE generation not proven") from exc
87
+ if version == NOTIFYD_GENERATION:
88
+ return True # current live owner must never have its stamp unlinked
89
+ try:
90
+ os.kill(pid, signal.SIGTERM)
91
+ except ProcessLookupError:
92
+ return False
93
+ except OSError as error:
94
+ raise SessionUnavailable(f"cannot retire incompatible notifyd {pid}: {error}") from error
95
+ deadline = time.monotonic() + max(0.0, timeout)
96
+ while time.monotonic() < deadline:
97
+ presence = owned_process(pid, start)
98
+ if presence == "dead":
99
+ return False
100
+ if presence == "unprovable":
101
+ raise SessionUnavailable(f"notifyd {pid} exit is unprovable after SIGTERM")
102
+ time.sleep(0.05)
103
+ raise SessionUnavailable(f"incompatible CCX notification daemon {pid} did not stop")
104
+
105
+
106
+ def _daemon_env() -> dict[str, str]:
107
+ env = os.environ.copy()
108
+ # A detached notifyd can start from an arbitrary caller cwd. Pin the CCX
109
+ # package that is starting it, just like codexd, so a worktree never imports
110
+ # a different installed/adjacent checkout after detaching.
111
+ package_parent = str(Path(__file__).resolve().parents[2])
112
+ inherited = env.get("PYTHONPATH")
113
+ env["PYTHONPATH"] = (
114
+ package_parent
115
+ if not inherited
116
+ else package_parent + os.pathsep + inherited
117
+ )
118
+ # A singleton notifyd must never inherit any managed/short/native caller
119
+ # identity; the exact original parent is established by durable receipts.
120
+ for key in (
121
+ "CCX_SELF_SHORT", "CCX_SELF_WORKER_ID", "CCX_SELF_ENGINE",
122
+ "CLAUDE_CODE_SESSION_ID", "CODEX_THREAD_ID",
123
+ ):
124
+ env.pop(key, None)
125
+ return env
126
+
127
+
128
+ def ensure_notify_daemon(*, timeout: float = 5.0) -> None:
129
+ """Ensure the one shared notification daemon for this CCX_ROOT is alive."""
130
+ ensure_dirs()
131
+ if _notifyd_alive():
132
+ return
133
+ with open(daemon_spawn_lock_path(), "a+") as spawn_lock:
134
+ fcntl.flock(spawn_lock, fcntl.LOCK_EX)
135
+ if _notifyd_alive():
136
+ return
137
+ if _retire_incompatible_notifyd(timeout=min(timeout, 5.0)):
138
+ # It became current between the outer readiness check and the
139
+ # spawn lock; preserve that daemon's identity rather than spawn.
140
+ return
141
+ daemon_generation_path().unlink(missing_ok=True)
142
+ log_path = daemon_log_path()
143
+ if log_path.exists() and log_path.stat().st_size > 10 * 1024 * 1024:
144
+ log_path.replace(log_path.with_name(log_path.name + ".1"))
145
+ log = open(log_path, "a", buffering=1)
146
+ try:
147
+ process = subprocess.Popen(
148
+ [sys.executable, "-m", "ccx.notify"],
149
+ cwd=str(Path(__file__).resolve().parents[2]),
150
+ stdin=subprocess.DEVNULL,
151
+ stdout=log,
152
+ stderr=log,
153
+ start_new_session=True,
154
+ close_fds=True,
155
+ env=_daemon_env(),
156
+ )
157
+ except OSError as error:
158
+ raise SessionUnavailable(f"could not start CCX notification daemon: {error}") from error
159
+ finally:
160
+ log.close()
161
+ threading.Thread(
162
+ target=process.wait, name="ccx-notifyd-reaper", daemon=True
163
+ ).start()
164
+ deadline = time.monotonic() + timeout
165
+ while time.monotonic() < deadline:
166
+ if _notifyd_alive():
167
+ return
168
+ if process.poll() is not None:
169
+ break
170
+ time.sleep(0.05)
171
+ raise SessionUnavailable("CCX notification daemon did not become ready")
172
+
173
+
174
+ def ensure_pending_notify_daemon() -> None:
175
+ """Best-effort self-heal used by ordinary CLI invocations."""
176
+ try:
177
+ if has_pending_registrations():
178
+ ensure_notify_daemon()
179
+ except Exception:
180
+ # A normal unrelated CCX command must not fail because a pending wake's
181
+ # supervisor couldn't restart. The registration itself remains durable.
182
+ pass
183
+
184
+
185
+ _revive_warning_lock = threading.Lock()
186
+
187
+
188
+ _revive_warned = False
189
+
190
+
191
+ def _log_revive_failure(error: Exception) -> None:
192
+ """One bounded diagnostic per process, never an exception in a turn path."""
193
+ global _revive_warned
194
+ with _revive_warning_lock:
195
+ if _revive_warned:
196
+ return
197
+ _revive_warned = True
198
+ try:
199
+ detail = f"{type(error).__name__}: {error}".replace("\n", " ")[:200]
200
+ print(f"[ccx notifyd] turn-end revival failed: {detail}",
201
+ file=sys.stderr, flush=True)
202
+ except Exception:
203
+ pass
204
+
205
+
206
+ def _revive_for_worker(engine: str, worker_id: str) -> None:
207
+ try:
208
+ # Terminal rows do not need delivery. Match the original full worker
209
+ # identity, never a display name or prefix, before starting notifyd.
210
+ if any((reg.get("worker") or {}).get("engine") == engine
211
+ and (reg.get("worker") or {}).get("workerId") == worker_id
212
+ for reg in iter_registrations()):
213
+ ensure_notify_daemon()
214
+ except Exception as error:
215
+ _log_revive_failure(error)
216
+
217
+
218
+ def revive_notify_daemon_after_turn(engine: str, worker_id: str) -> None:
219
+ """Recheck this worker's pending wakes off the host/provider event loop."""
220
+ try:
221
+ threading.Thread(target=_revive_for_worker, args=(engine, worker_id),
222
+ name="ccx-notifyd-revive", daemon=True).start()
223
+ except Exception as error:
224
+ _log_revive_failure(error)
225
+
226
+
227
+ def prepare_notify_parent(parent: dict[str, Any]) -> dict[str, Any]:
228
+ """Start the durable delivery supervisor before a worker operation begins."""
229
+ ensure_notify_daemon()
230
+ return parent
231
+
232
+ import fcntl
233
+ import os
234
+ import signal
235
+ import time
236
+ from typing import Any
237
+ from ccx.constants import NOTIFYD_GENERATION
238
+ from ccx.store.liveness import proc_start_token
239
+ from ccx.notify.paths import (
240
+ daemon_generation_path,
241
+ daemon_lock_path,
242
+ ensure_dirs,
243
+ )
244
+ from ccx.notify.deliver import DeliveryDeferred, deliver_to_parent, outstanding_for_parent, payload_for_registration
245
+ from ccx.notify.registry import TERMINAL_REGISTRATION_STATES, _current_worker_name, iter_registrations, mutate_registration, now_ms, prune_terminal_registrations
246
+ from ccx.notify.settle import settlement_for_registration
247
+
248
+
249
+ POLL_S = 0.35
250
+
251
+
252
+ IDLE_EXIT_S = 30.0
253
+
254
+
255
+ MAX_BACKOFF_S = 30.0
256
+
257
+
258
+ EXTERNAL_PARENT_EXPIRY_MS = 60 * 60 * 1000
259
+
260
+
261
+ def _retry_delay(attempts: int) -> float:
262
+ return min(MAX_BACKOFF_S, max(1.0, 2 ** min(max(attempts - 1, 0), 5)))
263
+
264
+
265
+ def _mark_ready(event_id: str, settlement: dict[str, Any]) -> dict[str, Any] | None:
266
+ def mutate(value: dict[str, Any]) -> None:
267
+ if value.get("state") in TERMINAL_REGISTRATION_STATES:
268
+ return
269
+ if value.get("payload") is None:
270
+ ready_settlement = settlement
271
+ if value["worker"]["engine"] == "controller":
272
+ from ccx.controllers.notify import progress as controller_progress
273
+ ready_settlement = {
274
+ **settlement,
275
+ "activity": controller_progress(value),
276
+ "outstanding": outstanding_for_parent(value),
277
+ "workerName": _current_worker_name(value["worker"]),
278
+ }
279
+ value["settlement"] = ready_settlement
280
+ value["payload"] = payload_for_registration(value, ready_settlement)
281
+ value["readyAtMs"] = now_ms()
282
+ value["state"] = "ready"
283
+ return mutate_registration(event_id, mutate)
284
+
285
+
286
+ def _mark_delivered(event_id: str, result: dict[str, Any]) -> None:
287
+ def mutate(value: dict[str, Any]) -> None:
288
+ if value.get("state") in TERMINAL_REGISTRATION_STATES:
289
+ return
290
+ value["state"] = "delivered"
291
+ value["deliveredAtMs"] = now_ms()
292
+ value["delivery"] = result
293
+ value["lastError"] = None
294
+ value["nextAttemptAtMs"] = 0
295
+ mutate_registration(event_id, mutate)
296
+
297
+
298
+ def _mark_deferred(event_id: str, error: BaseException) -> None:
299
+ def mutate(value: dict[str, Any]) -> None:
300
+ if value.get("state") in TERMINAL_REGISTRATION_STATES:
301
+ return
302
+ attempts = int(value.get("attempts") or 0) + 1
303
+ value["state"] = "ready"
304
+ if getattr(error, "no_effect", False):
305
+ # Positive no-write refusal: same event can be attempted after the
306
+ # original parent becomes ready. An ambiguous write keeps this
307
+ # marker and can only reconcile the original machine delivery.
308
+ value["deliveryAttemptedAtMs"] = None
309
+ value["lastDeliveryAttemptStartedAtMs"] = None
310
+ value["attempts"] = attempts
311
+ value["lastAttemptAtMs"] = now_ms()
312
+ value["lastError"] = f"{error.__class__.__name__}: {error}"[:1000]
313
+ # A user's own Codex UI may close for good (or its daemon go away), so
314
+ # an undeliverable external notification must not retry forever and
315
+ # hold up every other one. Managed parents still retry.
316
+ ready_at = int(value.get("readyAtMs") or value.get("createdAtMs") or now_ms())
317
+ if (value["parent"]["kind"] == "codex-native-thread"
318
+ and now_ms() - ready_at >= EXTERNAL_PARENT_EXPIRY_MS):
319
+ value["state"] = "undeliverable"
320
+ value["nextAttemptAtMs"] = 0
321
+ return
322
+ value["nextAttemptAtMs"] = now_ms() + int(_retry_delay(attempts) * 1000)
323
+ mutate_registration(event_id, mutate)
324
+
325
+
326
+ def _mark_final_attempt_started(event_id: str, delivery_id: str) -> None:
327
+ def mutate(value: dict[str, Any]) -> None:
328
+ payload = value.get("payload")
329
+ if not isinstance(payload, dict):
330
+ return
331
+ current_delivery = payload.get("deliveryId") or payload.get("eventId")
332
+ if current_delivery != delivery_id:
333
+ return
334
+ current = now_ms()
335
+ if value.get("deliveryAttemptedAtMs") is None:
336
+ value["deliveryAttemptedAtMs"] = current
337
+ value["lastDeliveryAttemptStartedAtMs"] = current
338
+ mutate_registration(event_id, mutate)
339
+
340
+
341
+ def _deliver_final(reg: dict[str, Any], payload: dict[str, Any]) -> None:
342
+ event_id = str(reg["eventId"])
343
+ if int(reg.get("nextAttemptAtMs") or 0) > now_ms():
344
+ return
345
+ delivery_id = str(payload.get("deliveryId") or payload.get("eventId") or event_id)
346
+ # Persist intent before the network call. The in-memory ``reg`` remains the
347
+ # pre-attempt snapshot, so the first delivery goes straight to turn/start.
348
+ # A retry/restart reloads the marker and reconciles native Codex history
349
+ # before attempting the same delivery id again.
350
+ _mark_final_attempt_started(event_id, delivery_id)
351
+ try:
352
+ result = deliver_to_parent(reg, payload)
353
+ except DeliveryDeferred as error:
354
+ _mark_deferred(event_id, error)
355
+ return
356
+ except Exception as error:
357
+ # Unknown transport/runtime failures remain retryable. A completion
358
+ # event is more valuable duplicated-and-reconciled than silently lost.
359
+ _mark_deferred(event_id, error)
360
+ return
361
+ _mark_delivered(event_id, result)
362
+
363
+
364
+ def process_registration(
365
+ reg: dict[str, Any],
366
+ registration_snapshot: list[dict[str, Any]] | None = None,
367
+ ) -> None:
368
+ event_id = reg.get("eventId")
369
+ if not isinstance(event_id, str) or not event_id:
370
+ return
371
+ if reg.get("state") in TERMINAL_REGISTRATION_STATES:
372
+ return
373
+ from ccx.notify.select import _original_notification_registration
374
+ try:
375
+ _original_notification_registration(reg)
376
+ except Exception:
377
+ # Never run an old short/generation registration through native paths.
378
+ return
379
+
380
+ # A frozen final payload is the only permissible parent delivery.
381
+ payload = reg.get("payload")
382
+ if isinstance(payload, dict):
383
+ _deliver_final(reg, payload)
384
+ return
385
+
386
+ # An unresolved operation stays armed without periodic parent wakes.
387
+ # Freeze its first actionable outcome for durable final delivery.
388
+ from ccx.errors import CcxError
389
+ from ccx.store.root import GenerationError
390
+ from ccx.store.identity import IdentityError
391
+ try:
392
+ settlement = (
393
+ settlement_for_registration(reg)
394
+ if registration_snapshot is None
395
+ else settlement_for_registration(
396
+ reg, registration_snapshot=registration_snapshot
397
+ )
398
+ )
399
+ except (CcxError, GenerationError, IdentityError, OSError):
400
+ # One unreadable or inconsistent row stays armed for the next poll;
401
+ # it must not stop the supervisor loop for every other registration.
402
+ return
403
+ if settlement is not None:
404
+ reg = _mark_ready(event_id, settlement) or reg
405
+ payload = reg.get("payload")
406
+ if isinstance(payload, dict):
407
+ _deliver_final(reg, payload)
408
+ return
409
+
410
+
411
+ def run() -> int:
412
+ from ccx import constants
413
+ from ccx.store.root import require_ccx_active
414
+ # One immutable ACTIVE generation for the entire daemon lifetime. An
415
+ # offline pointer switch must end this notifier, never let it process a
416
+ # new generation's registrations with an old process identity.
417
+ store = require_ccx_active(constants.CCX_ROOT)
418
+ ensure_dirs()
419
+ lock = open(daemon_lock_path(), "a+")
420
+ try:
421
+ fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
422
+ except BlockingIOError:
423
+ return 0
424
+ # Publish the complete identity only after claiming the singleton lock.
425
+ # Readers do not take that flock: a temporary reader lease could make a
426
+ # new daemon's nonblocking lock acquisition fail and suppress delivery.
427
+ start = proc_start_token(os.getpid())
428
+ if start is None:
429
+ lock.close()
430
+ raise RuntimeError("notification supervisor cannot prove its original PID start")
431
+ identity_stamp = f"{os.getpid()} {NOTIFYD_GENERATION} {store.generation} {start}"
432
+ daemon_generation_path().write_text(identity_stamp + "\n", encoding="utf-8")
433
+ # Best-effort housekeeping under the singleton daemon lock. A malformed or
434
+ # unreadable historical receipt must never prevent current notifications.
435
+ try:
436
+ prune_terminal_registrations()
437
+ except Exception:
438
+ pass
439
+ stopping = False
440
+
441
+ def stop(_signum, _frame):
442
+ nonlocal stopping
443
+ stopping = True
444
+
445
+ signal.signal(signal.SIGTERM, stop)
446
+ signal.signal(signal.SIGINT, stop)
447
+ empty_since: float | None = None
448
+ try:
449
+ while not stopping:
450
+ store.revalidate()
451
+ registration_snapshot = iter_registrations(
452
+ include_terminal=True
453
+ )
454
+ registrations = [
455
+ reg for reg in registration_snapshot
456
+ if reg.get("state") not in TERMINAL_REGISTRATION_STATES
457
+ ]
458
+ if registrations:
459
+ empty_since = None
460
+ for reg in registrations:
461
+ store.revalidate()
462
+ process_registration(
463
+ reg, registration_snapshot=registration_snapshot
464
+ )
465
+ else:
466
+ if empty_since is None:
467
+ empty_since = time.monotonic()
468
+ elif time.monotonic() - empty_since >= IDLE_EXIT_S:
469
+ return 0
470
+ time.sleep(POLL_S)
471
+ return 0
472
+ finally:
473
+ try:
474
+ current_stamp = daemon_generation_path().read_text(encoding="utf-8").strip()
475
+ # A replaced daemon must never unlink another original's stamp.
476
+ if current_stamp == identity_stamp:
477
+ daemon_generation_path().unlink()
478
+ except OSError:
479
+ pass
480
+ lock.close()