ccx-native 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +241 -4
- package/bin/ccx +55 -2
- package/ccx/__init__.py +163 -0
- package/ccx/__main__.py +28 -0
- package/ccx/claude/__init__.py +0 -0
- package/ccx/claude/analytics.py +466 -0
- package/ccx/claude/attach.py +762 -0
- package/ccx/claude/context.py +465 -0
- package/ccx/claude/delivery.py +806 -0
- package/ccx/claude/external.py +253 -0
- package/ccx/claude/host/__init__.py +0 -0
- package/ccx/claude/host/events.py +297 -0
- package/ccx/claude/host/hooks.py +438 -0
- package/ccx/claude/host/pty.py +250 -0
- package/ccx/claude/host/session.py +5184 -0
- package/ccx/claude/host/subagents.py +169 -0
- package/ccx/claude/host/successor.py +267 -0
- package/ccx/claude/managed_turns.py +240 -0
- package/ccx/claude/oauth.py +176 -0
- package/ccx/claude/settings.py +339 -0
- package/ccx/claude/spawn.py +557 -0
- package/ccx/claude/tasks.py +72 -0
- package/ccx/claude/telemetry.py +282 -0
- package/ccx/claude/transcript.py +2281 -0
- package/ccx/claude/usage.py +837 -0
- package/ccx/claude/usage_control.py +152 -0
- package/ccx/cli/__init__.py +0 -0
- package/ccx/cli/codex.py +1009 -0
- package/ccx/cli/commands/__init__.py +0 -0
- package/ccx/cli/commands/_common.py +407 -0
- package/ccx/cli/commands/control.py +161 -0
- package/ccx/cli/commands/create.py +669 -0
- package/ccx/cli/commands/read.py +1020 -0
- package/ccx/cli/commands/turns.py +1081 -0
- package/ccx/cli/controller.py +757 -0
- package/ccx/cli/main.py +301 -0
- package/ccx/cli/parser.py +1324 -0
- package/ccx/cli/skills.py +215 -0
- package/ccx/cli/statusline.py +160 -0
- package/ccx/client.py +1900 -0
- package/ccx/codex/__init__.py +0 -0
- package/ccx/codex/attach_tty.py +267 -0
- package/ccx/codex/client.py +732 -0
- package/ccx/codex/completion.py +121 -0
- package/ccx/codex/control.py +288 -0
- package/ccx/codex/daemon/__init__.py +0 -0
- package/ccx/codex/daemon/__main__.py +4 -0
- package/ccx/codex/daemon/daemon.py +1556 -0
- package/ccx/codex/daemon/models.py +140 -0
- package/ccx/codex/daemon/native.py +1590 -0
- package/ccx/codex/daemon/release.py +367 -0
- package/ccx/codex/daemon/worker.py +2396 -0
- package/ccx/codex/delivery.py +758 -0
- package/ccx/codex/delivery_recovery.py +232 -0
- package/ccx/codex/evidence.py +827 -0
- package/ccx/codex/history.py +158 -0
- package/ccx/codex/notify_history.py +140 -0
- package/ccx/codex/ops.py +666 -0
- package/ccx/codex/paths.py +70 -0
- package/ccx/codex/protocol.py +805 -0
- package/ccx/codex/provenance.py +105 -0
- package/ccx/codex/read.py +29 -0
- package/ccx/codex/records.py +51 -0
- package/ccx/codex/reports.py +177 -0
- package/ccx/codex/runtime.py +1412 -0
- package/ccx/codex/settle.py +363 -0
- package/ccx/codex/telemetry.py +792 -0
- package/ccx/codex/types.py +659 -0
- package/ccx/codex/worker_environment.py +25 -0
- package/ccx/constants.py +288 -0
- package/ccx/controllers/__init__.py +0 -0
- package/ccx/controllers/controller.py +1449 -0
- package/ccx/controllers/live_vars.py +1706 -0
- package/ccx/controllers/live_vars_runner.py +216 -0
- package/ccx/controllers/notify.py +213 -0
- package/ccx/controllers/orchestrator.py +193 -0
- package/ccx/controllers/policy.py +57 -0
- package/ccx/controllers/store.py +681 -0
- package/ccx/errors.py +634 -0
- package/ccx/notify/__init__.py +0 -0
- package/ccx/notify/__main__.py +5 -0
- package/ccx/notify/client.py +116 -0
- package/ccx/notify/daemon.py +480 -0
- package/ccx/notify/deliver.py +304 -0
- package/ccx/notify/paths.py +51 -0
- package/ccx/notify/registry.py +649 -0
- package/ccx/notify/select.py +750 -0
- package/ccx/notify/settle.py +684 -0
- package/ccx/ops/__init__.py +0 -0
- package/ccx/ops/background.py +115 -0
- package/ccx/ops/cancel.py +289 -0
- package/ccx/ops/codex_import.py +96 -0
- package/ccx/ops/common.py +397 -0
- package/ccx/ops/compact.py +337 -0
- package/ccx/ops/compact_ops.py +550 -0
- package/ccx/ops/context_events.py +48 -0
- package/ccx/ops/conversation.py +1540 -0
- package/ccx/ops/conversation_paging.py +1338 -0
- package/ccx/ops/create.py +56 -0
- package/ccx/ops/creation.py +952 -0
- package/ccx/ops/creation_steps.py +579 -0
- package/ccx/ops/event_feed.py +710 -0
- package/ccx/ops/forking.py +711 -0
- package/ccx/ops/input.py +556 -0
- package/ccx/ops/maintenance.py +295 -0
- package/ccx/ops/report.py +770 -0
- package/ccx/ops/semantics.py +490 -0
- package/ccx/ops/send.py +961 -0
- package/ccx/ops/steer_ops.py +867 -0
- package/ccx/ops/terminate.py +383 -0
- package/ccx/ops/turn_reports.py +306 -0
- package/ccx/ops/wait.py +762 -0
- package/ccx/ops/worker_events.py +636 -0
- package/ccx/rpc/__init__.py +0 -0
- package/ccx/rpc/local_rpc.py +1602 -0
- package/ccx/rpc/rpc.py +559 -0
- package/ccx/rpc/serve.py +374 -0
- package/ccx/rpc/transport.py +409 -0
- package/ccx/rpc/view.py +125 -0
- package/ccx/store/__init__.py +0 -0
- package/ccx/store/discovery.py +170 -0
- package/ccx/store/identity.py +105 -0
- package/ccx/store/journal_io.py +79 -0
- package/ccx/store/liveness.py +194 -0
- package/ccx/store/paths.py +254 -0
- package/ccx/store/record_cache.py +40 -0
- package/ccx/store/records.py +309 -0
- package/ccx/store/root.py +804 -0
- package/ccx/store/runtime_presence.py +137 -0
- package/ccx/store/store.py +2332 -0
- package/ccx/store/worker_state.py +86 -0
- package/ccx/system/__init__.py +0 -0
- package/ccx/system/bounded.py +296 -0
- package/ccx/system/cwd_paths.py +25 -0
- package/ccx/system/engine_binary.py +59 -0
- package/ccx/system/executable.py +50 -0
- package/ccx/system/oslayer.py +667 -0
- package/ccx/system/pipes.py +46 -0
- package/ccx/system/sanitize.py +43 -0
- package/ccx/types.py +705 -0
- package/ccx/version.py +29 -0
- package/ccx/workers.py +2081 -0
- package/ccx.py +9 -0
- package/package.json +39 -7
- package/pyproject.toml +87 -0
- package/skills/ccx/SKILL.md +155 -0
- package/skills/ccx/references/controllers.md +244 -0
- package/skills/ccx/references/troubleshooting.md +94 -0
- package/skills/ccx/references/variables.md +62 -0
|
@@ -0,0 +1,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()
|