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,649 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import fcntl
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import time
|
|
8
|
+
import uuid
|
|
9
|
+
from typing import Any, Callable
|
|
10
|
+
from ccx.errors import InvalidRequest, SessionUnavailable
|
|
11
|
+
from ccx.notify.paths import (
|
|
12
|
+
ensure_dirs,
|
|
13
|
+
registration_path,
|
|
14
|
+
registrations_dir,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
NOTIFY_REPORT_MAX_CHARS = int(os.environ.get("CCX_NOTIFY_REPORT_MAX_CHARS", "12000"))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
TERMINAL_REGISTRATION_STATES = {"delivered", "cancelled", "undeliverable"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def now_ms() -> int:
|
|
25
|
+
return int(time.time() * 1000)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _delivery_id(event_id: str) -> str:
|
|
29
|
+
return f"{event_id}:settled"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _current_worker_name(worker: dict[str, Any]) -> str | None:
|
|
33
|
+
"""Prefer current durable display metadata over registration-time snapshots."""
|
|
34
|
+
engine = worker.get("engine")
|
|
35
|
+
short = worker.get("workerId") or worker.get("short")
|
|
36
|
+
if not isinstance(short, str) or not short:
|
|
37
|
+
return worker.get("name")
|
|
38
|
+
try:
|
|
39
|
+
if engine == "codex":
|
|
40
|
+
from ccx.codex.records import read_record as read_codex_record
|
|
41
|
+
record = read_codex_record(short)
|
|
42
|
+
elif engine == "claude":
|
|
43
|
+
from ccx.store.records import read_record as read_claude_record
|
|
44
|
+
record = read_claude_record(short)
|
|
45
|
+
else:
|
|
46
|
+
record = None
|
|
47
|
+
except Exception:
|
|
48
|
+
record = None
|
|
49
|
+
current = record.get("name") if isinstance(record, dict) else None
|
|
50
|
+
return current if isinstance(current, str) and current else worker.get("name")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _atomic_write(path: Path, value: dict[str, Any]) -> None:
|
|
54
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
tmp = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
|
56
|
+
with open(tmp, "w", encoding="utf-8") as handle:
|
|
57
|
+
json.dump(value, handle, indent=2)
|
|
58
|
+
handle.write("\n")
|
|
59
|
+
handle.flush()
|
|
60
|
+
os.fsync(handle.fileno())
|
|
61
|
+
os.replace(tmp, path)
|
|
62
|
+
# The event ID is not durably armed until the rename's directory entry
|
|
63
|
+
# also survives a crash. This is the existing registry, not another log.
|
|
64
|
+
directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
|
|
65
|
+
try:
|
|
66
|
+
os.fsync(directory)
|
|
67
|
+
finally:
|
|
68
|
+
os.close(directory)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _lock_path(event_id: str) -> Path:
|
|
72
|
+
return registration_path(event_id).with_suffix(".lock")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def read_registration(event_id: str) -> dict[str, Any] | None:
|
|
76
|
+
try:
|
|
77
|
+
value = json.loads(registration_path(event_id).read_text(encoding="utf-8"))
|
|
78
|
+
except (OSError, ValueError):
|
|
79
|
+
return None
|
|
80
|
+
return value if isinstance(value, dict) else None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _hint_managed_parent_background(reg: dict[str, Any]) -> None:
|
|
84
|
+
"""Best-effort projection hint to an already-running managed codexd.
|
|
85
|
+
|
|
86
|
+
The registry write precedes this hint. No native wake, daemon startup,
|
|
87
|
+
journal append from notifyd, or retry ledger is involved.
|
|
88
|
+
"""
|
|
89
|
+
parent = reg.get("parent") or {}
|
|
90
|
+
if parent.get("kind") != "ccx-codex":
|
|
91
|
+
return
|
|
92
|
+
worker_id = parent.get("workerId")
|
|
93
|
+
if not isinstance(worker_id, str) or not worker_id:
|
|
94
|
+
return
|
|
95
|
+
try:
|
|
96
|
+
from ccx.codex.runtime import daemon_rpc
|
|
97
|
+
from ccx.constants import CODEXD_RPC_GENERATION
|
|
98
|
+
daemon_rpc({
|
|
99
|
+
"op": "background-refresh",
|
|
100
|
+
"short": worker_id,
|
|
101
|
+
"rpcGeneration": CODEXD_RPC_GENERATION,
|
|
102
|
+
}, timeout=0.25)
|
|
103
|
+
except Exception:
|
|
104
|
+
pass # Notifications do not depend on observer availability.
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def mutate_registration(
|
|
108
|
+
event_id: str, mutate: Callable[[dict[str, Any]], None]
|
|
109
|
+
) -> dict[str, Any] | None:
|
|
110
|
+
from ccx.notify.select import _original_notification_registration
|
|
111
|
+
ensure_dirs()
|
|
112
|
+
with open(_lock_path(event_id), "a+") as lock:
|
|
113
|
+
fcntl.flock(lock, fcntl.LOCK_EX)
|
|
114
|
+
value = read_registration(event_id)
|
|
115
|
+
if value is None:
|
|
116
|
+
return None
|
|
117
|
+
_original_notification_registration(value)
|
|
118
|
+
previous = value.get("state")
|
|
119
|
+
mutate(value)
|
|
120
|
+
value["updatedAtMs"] = now_ms()
|
|
121
|
+
_atomic_write(registration_path(event_id), value)
|
|
122
|
+
# Never call codexd while the registration file is locked.
|
|
123
|
+
if value.get("state") != previous and value.get("state") in {
|
|
124
|
+
"cancelled", "undeliverable", "delivered",
|
|
125
|
+
}:
|
|
126
|
+
_hint_managed_parent_background(value)
|
|
127
|
+
return value
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _settlement_child_thread(reg: dict[str, Any], store) -> str | None:
|
|
131
|
+
worker = reg.get("worker") or {}
|
|
132
|
+
if worker.get("engine") != "codex" or worker.get("generation") != store.generation:
|
|
133
|
+
return None
|
|
134
|
+
owner = store.resolve(f"codex:{worker['workerId']}")
|
|
135
|
+
native = (store.record(owner.worker).get("native") or {})
|
|
136
|
+
thread = native.get("threadId")
|
|
137
|
+
return (thread if isinstance(thread, str) and thread
|
|
138
|
+
and native.get("threadSource") == store.codex_source(owner.worker)
|
|
139
|
+
else None)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _assert_no_settlement_cycle(parent: dict[str, Any], child_id: str, store) -> None:
|
|
143
|
+
"""One original managed parent→child graph under its publication lock."""
|
|
144
|
+
child = _settlement_child_thread(
|
|
145
|
+
{"worker": {"engine": "codex", "workerId": child_id,
|
|
146
|
+
"generation": store.generation}}, store)
|
|
147
|
+
if child is None:
|
|
148
|
+
raise InvalidRequest("Child Codex worker cannot be verified; inspect its status")
|
|
149
|
+
if parent["kind"] == "codex-native-thread":
|
|
150
|
+
return # A captured interactive return address is no managed graph node.
|
|
151
|
+
parent_thread = parent["threadId"]
|
|
152
|
+
if child == parent_thread:
|
|
153
|
+
raise InvalidRequest("recursive settlement cannot wait on the parent thread itself")
|
|
154
|
+
edges: dict[str, set[str]] = {}
|
|
155
|
+
for reg in iter_registrations(strict=True):
|
|
156
|
+
if reg.get("settlementMode") != "orchestration":
|
|
157
|
+
continue
|
|
158
|
+
edge_parent = reg.get("parent") or {}
|
|
159
|
+
if (edge_parent.get("kind") != "ccx-codex"
|
|
160
|
+
or edge_parent.get("generation") != store.generation):
|
|
161
|
+
continue
|
|
162
|
+
edge_child = _settlement_child_thread(reg, store)
|
|
163
|
+
if edge_child:
|
|
164
|
+
edges.setdefault(edge_parent["threadId"], set()).add(edge_child)
|
|
165
|
+
stack = [child]
|
|
166
|
+
visited = set()
|
|
167
|
+
while stack:
|
|
168
|
+
thread = stack.pop()
|
|
169
|
+
if thread == parent_thread:
|
|
170
|
+
raise InvalidRequest("recursive settlement would create a notification cycle")
|
|
171
|
+
if thread not in visited:
|
|
172
|
+
visited.add(thread)
|
|
173
|
+
stack.extend(edges.get(thread, ()))
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def create_registration(
|
|
177
|
+
*, parent: dict[str, Any], worker_engine: str, worker_id: str,
|
|
178
|
+
worker_name: str | None, operation: dict[str, Any],
|
|
179
|
+
source: str, settle: bool = False,
|
|
180
|
+
registration_key: str | None = None,
|
|
181
|
+
) -> dict[str, Any]:
|
|
182
|
+
"""Stage one strict v3 send receipt or independent read-only notify arm."""
|
|
183
|
+
from ccx.notify.select import _active_compact_selection, _compact_effect_receipt, _controller_attempt_at_arm, _original_notification_registration
|
|
184
|
+
from ccx.notify.daemon import _retire_incompatible_notifyd, ensure_notify_daemon
|
|
185
|
+
from ccx import constants
|
|
186
|
+
from ccx.store.root import require_ccx_active
|
|
187
|
+
from ccx.claude.delivery import valid_delivery_id
|
|
188
|
+
|
|
189
|
+
hashed_input = (source == "input" and worker_engine == "claude"
|
|
190
|
+
and operation.get("kind") in {"claude-input-answer", "claude-input-respond"}
|
|
191
|
+
if isinstance(operation, dict) else False)
|
|
192
|
+
if (settle and worker_engine != "codex"
|
|
193
|
+
or worker_engine not in {"codex", "claude", "controller"}
|
|
194
|
+
or not isinstance(operation, dict)
|
|
195
|
+
or source not in ({"controller.run", "controller.resume", "controller.notify"}
|
|
196
|
+
if worker_engine == "controller" else {"send", "notify", "new", "input", "compact"})
|
|
197
|
+
or (source == "input" and (
|
|
198
|
+
set(operation) != ({"kind", "gateId", "responseId",
|
|
199
|
+
"nativeSourceId", "intentSha256", "selectedTurnRef"}
|
|
200
|
+
| ({"payloadSha256"} if hashed_input
|
|
201
|
+
else ({"decision", "reason"} if worker_engine == "claude"
|
|
202
|
+
else {"decision", "turnId"})))
|
|
203
|
+
or (not hashed_input and (
|
|
204
|
+
operation.get("kind") != f"{worker_engine}-input-decision"
|
|
205
|
+
or operation.get("decision") not in {"approve", "reject"}))))
|
|
206
|
+
or (source == "send" and operation.get("kind") != f"{worker_engine}-turn")
|
|
207
|
+
or (source == "compact" and operation.get("kind") != f"{worker_engine}-compact")
|
|
208
|
+
or (source == "new" and (worker_engine not in {"claude", "codex"}
|
|
209
|
+
or type(operation.get("expectTurn")) is not bool
|
|
210
|
+
or operation != {"kind": f"{worker_engine}-stable", "expectTurn": False,
|
|
211
|
+
"selectionMode": "status-only", "stableState": "idle"}))
|
|
212
|
+
or (source == "notify" and operation.get("kind") not in {
|
|
213
|
+
f"{worker_engine}-observed-turn", f"{worker_engine}-stable",
|
|
214
|
+
f"{worker_engine}-compact",
|
|
215
|
+
*({"claude-next-turn"} if worker_engine == "claude" else set()),
|
|
216
|
+
})):
|
|
217
|
+
raise InvalidRequest("This notification type is unsupported; choose a valid source")
|
|
218
|
+
if (source == "notify" and operation.get("kind") == f"{worker_engine}-compact"):
|
|
219
|
+
if (set(operation) != {"kind", "selectionMode", "operationId", "nativeSourceId"}
|
|
220
|
+
or operation.get("selectionMode") != "active-compact"):
|
|
221
|
+
raise InvalidRequest("standalone compact needs one original active operation")
|
|
222
|
+
if source == "compact":
|
|
223
|
+
token = operation.get("operationId")
|
|
224
|
+
native_id = operation.get("nativeSourceId")
|
|
225
|
+
if (set(operation) != {"kind", "operationId", "nativeSourceId"}
|
|
226
|
+
or not isinstance(token, str) or not 0 < len(token) <= 128
|
|
227
|
+
or "\x00" in token or not isinstance(native_id, str) or not native_id):
|
|
228
|
+
raise InvalidRequest("compact notification needs an exact native operation and source")
|
|
229
|
+
delivery_id = operation.get("deliveryId")
|
|
230
|
+
if (source == "notify" and operation.get("kind") == "claude-next-turn"
|
|
231
|
+
and operation.get("selectedTurnRef") is not None):
|
|
232
|
+
raise InvalidRequest("future turn must arm before its first R is selected")
|
|
233
|
+
if source == "send" and not valid_delivery_id(delivery_id):
|
|
234
|
+
raise InvalidRequest("notify_parent requires a valid delivery_id")
|
|
235
|
+
store = require_ccx_active(constants.CCX_ROOT)
|
|
236
|
+
# No caller-provided registration key can create an alternate event for the
|
|
237
|
+
# same semantic operation. It also excludes parent intentionally: a foreign
|
|
238
|
+
# parent retry must hit the same event and fail its identity comparison.
|
|
239
|
+
from ccx.store.identity import canonical_worker_id
|
|
240
|
+
wid = canonical_worker_id(worker_id)
|
|
241
|
+
key = (f"full-uuid-{worker_engine}-send:{store.generation}:{wid}:{delivery_id}"
|
|
242
|
+
if source == "send" else
|
|
243
|
+
f"full-uuid-{worker_engine}-new:{store.generation}:{wid}:promptless"
|
|
244
|
+
if source == "new" else
|
|
245
|
+
f"full-uuid-{worker_engine}-input:{store.generation}:{wid}:{operation['gateId']}"
|
|
246
|
+
if source == "input" else
|
|
247
|
+
f"full-uuid-{worker_engine}-compact:{store.generation}:{wid}:{operation['operationId']}"
|
|
248
|
+
if source == "compact" else None)
|
|
249
|
+
if registration_key is not None and registration_key != key:
|
|
250
|
+
raise InvalidRequest("Notification ID does not match its delivery; inspect the request")
|
|
251
|
+
if not isinstance(parent, dict):
|
|
252
|
+
raise InvalidRequest("notification needs a captured parent return address")
|
|
253
|
+
if parent.get("kind") == "ccx-codex":
|
|
254
|
+
parent_id = canonical_worker_id(parent.get("workerId"))
|
|
255
|
+
if parent.get("generation") != store.generation:
|
|
256
|
+
raise InvalidRequest("notification parent original generation changed")
|
|
257
|
+
parent_value = {
|
|
258
|
+
"kind": "ccx-codex", "workerId": parent_id,
|
|
259
|
+
"threadId": parent.get("threadId"), "generation": store.generation,
|
|
260
|
+
}
|
|
261
|
+
elif parent.get("kind") == "codex-native-thread":
|
|
262
|
+
parent_value = dict(parent) # captured once, no env reread on retry
|
|
263
|
+
else:
|
|
264
|
+
raise InvalidRequest("unsupported notification parent return-address kind")
|
|
265
|
+
if worker_engine == "controller" or source in {"notify", "new", "input", "compact"}:
|
|
266
|
+
# Input reuses the original global answer intent (not a new receipt).
|
|
267
|
+
# Notify has independent UUIDv4; new/input have keyed UUIDv5.
|
|
268
|
+
receipt = None
|
|
269
|
+
receipt_fields = dict(operation)
|
|
270
|
+
immutable = tuple(receipt_fields)
|
|
271
|
+
elif worker_engine == "codex":
|
|
272
|
+
from ccx.codex.delivery import read_receipt
|
|
273
|
+
receipt = read_receipt(f"codex:{wid}", delivery_id)
|
|
274
|
+
receipt_fields = {
|
|
275
|
+
"kind": "codex-turn", "deliveryId": delivery_id,
|
|
276
|
+
"requestedMode": (receipt or {}).get("requestedMode"),
|
|
277
|
+
"promptSha256": (receipt or {}).get("promptSha256"),
|
|
278
|
+
"callerMetadata": (receipt or {}).get("callerMetadata"),
|
|
279
|
+
"clientUserMessageId": (receipt or {}).get("clientUserMessageId"),
|
|
280
|
+
"turnId": None,
|
|
281
|
+
}
|
|
282
|
+
immutable = ("kind", "deliveryId", "requestedMode", "promptSha256",
|
|
283
|
+
"callerMetadata", "clientUserMessageId")
|
|
284
|
+
else:
|
|
285
|
+
from ccx.claude.delivery import read_receipt
|
|
286
|
+
receipt = read_receipt(f"claude:{wid}", delivery_id)
|
|
287
|
+
receipt_fields = {
|
|
288
|
+
"kind": "claude-turn", "deliveryId": delivery_id,
|
|
289
|
+
"requestedMode": (receipt or {}).get("requestedMode"),
|
|
290
|
+
"promptSha256": (receipt or {}).get("promptSha256"),
|
|
291
|
+
"promptByteCount": (receipt or {}).get("promptByteCount"),
|
|
292
|
+
"callerMetadata": (receipt or {}).get("callerMetadata"),
|
|
293
|
+
"cap": (receipt or {}).get("cap"),
|
|
294
|
+
}
|
|
295
|
+
immutable = tuple(receipt_fields)
|
|
296
|
+
if source == "send":
|
|
297
|
+
if receipt is None:
|
|
298
|
+
raise InvalidRequest("prepare original child receipt before notification registration")
|
|
299
|
+
if (receipt_fields["requestedMode"] != "new_turn_only"
|
|
300
|
+
or any(k in operation and operation[k] != receipt_fields[k]
|
|
301
|
+
for k in immutable)):
|
|
302
|
+
raise InvalidRequest("notification operation conflicts with original child receipt")
|
|
303
|
+
event_id = (str(uuid.uuid5(uuid.NAMESPACE_URL, "ccx-notify:" + key))
|
|
304
|
+
if key is not None else str(uuid.uuid4()))
|
|
305
|
+
timestamp = now_ms()
|
|
306
|
+
|
|
307
|
+
value = {
|
|
308
|
+
"version": 3, "generation": store.generation,
|
|
309
|
+
"eventId": event_id, "state": "armed", "source": source,
|
|
310
|
+
"parent": parent_value,
|
|
311
|
+
"worker": {"engine": worker_engine, "workerId": wid,
|
|
312
|
+
"generation": store.generation, "name": worker_name},
|
|
313
|
+
"operation": receipt_fields,
|
|
314
|
+
"settlementMode": "orchestration" if settle else None,
|
|
315
|
+
"registrationKeySha256": (hashlib.sha256(key.encode("utf-8")).hexdigest()
|
|
316
|
+
if key is not None else None),
|
|
317
|
+
"createdAtMs": timestamp, "updatedAtMs": timestamp,
|
|
318
|
+
"attempts": 0, "nextAttemptAtMs": 0,
|
|
319
|
+
}
|
|
320
|
+
_original_notification_registration(value) # no file or daemon effect on refusal
|
|
321
|
+
if worker_engine == "controller":
|
|
322
|
+
_controller_attempt_at_arm(wid, receipt_fields, source)
|
|
323
|
+
ensure_dirs()
|
|
324
|
+
# An incompatible supervisor cannot safely interpret this registration.
|
|
325
|
+
# Retire it before publishing; arming does not start a replacement.
|
|
326
|
+
_retire_incompatible_notifyd(timeout=5.0)
|
|
327
|
+
def publish() -> dict[str, Any]:
|
|
328
|
+
with open(_lock_path(event_id), "a+") as lock:
|
|
329
|
+
fcntl.flock(lock, fcntl.LOCK_EX)
|
|
330
|
+
prior = read_registration(event_id)
|
|
331
|
+
if prior is not None:
|
|
332
|
+
if source == "notify":
|
|
333
|
+
raise SessionUnavailable("independent notification event UUID collision")
|
|
334
|
+
# Do not reset progressed/ready/cancelled/delivered registration.
|
|
335
|
+
_original_notification_registration(prior)
|
|
336
|
+
if (prior.get("parent") != value["parent"]
|
|
337
|
+
or any((prior.get("worker") or {}).get(field) != value["worker"][field]
|
|
338
|
+
for field in ("engine", "workerId", "generation"))
|
|
339
|
+
or prior.get("registrationKeySha256") != value["registrationKeySha256"]
|
|
340
|
+
or prior.get("settlementMode") != value["settlementMode"]
|
|
341
|
+
or any((prior.get("operation") or {}).get(k) != receipt_fields[k]
|
|
342
|
+
for k in immutable)):
|
|
343
|
+
raise InvalidRequest("notification operation is owned by a different parent or input")
|
|
344
|
+
return prior
|
|
345
|
+
if source == "notify" and operation.get("kind") == f"{worker_engine}-compact":
|
|
346
|
+
owner = store.resolve(f"{worker_engine}:{wid}")
|
|
347
|
+
if _active_compact_selection(store, owner, store.record(owner.worker)) != operation:
|
|
348
|
+
raise SessionUnavailable("original active compact changed before publication")
|
|
349
|
+
if source == "compact":
|
|
350
|
+
# An ordinary compact may already own this operation ID. It
|
|
351
|
+
# cannot acquire a new "pre-effect" parent event retroactively.
|
|
352
|
+
effect = _compact_effect_receipt(
|
|
353
|
+
store, f"{worker_engine}:{wid}", worker_engine, wid, operation)
|
|
354
|
+
if effect is not None and effect.get("state") != "prepared":
|
|
355
|
+
raise InvalidRequest("cannot retroactively arm an existing native compact")
|
|
356
|
+
if worker_engine == "claude":
|
|
357
|
+
from ccx.ops.compact_ops import get_operation
|
|
358
|
+
marker = get_operation(wid, operation["operationId"])
|
|
359
|
+
else:
|
|
360
|
+
record = store.record(f"codex:{wid}")
|
|
361
|
+
marker = next((
|
|
362
|
+
row for field in ("activeCompact", "lastCompact")
|
|
363
|
+
if isinstance(row := record.get(field), dict)
|
|
364
|
+
and row.get("operationId") == operation["operationId"]
|
|
365
|
+
), None)
|
|
366
|
+
if marker is not None:
|
|
367
|
+
raise InvalidRequest("cannot retroactively arm a recorded native compact")
|
|
368
|
+
if source == "send" and receipt.get("state") != "prepared":
|
|
369
|
+
raise InvalidRequest("cannot retroactively arm an accepted original Codex delivery")
|
|
370
|
+
if source == "input":
|
|
371
|
+
input_intent = store.read_receipt(f"{worker_engine}:{wid}", "answer", operation["responseId"])
|
|
372
|
+
if input_intent is None or input_intent.get("state") != "prepared":
|
|
373
|
+
raise InvalidRequest("cannot retroactively arm an already submitted original input")
|
|
374
|
+
store.revalidate()
|
|
375
|
+
_atomic_write(registration_path(event_id), value) # fsync before return
|
|
376
|
+
return value
|
|
377
|
+
if settle:
|
|
378
|
+
graph_lock = registrations_dir() / ".settlement-graph.lock"
|
|
379
|
+
with graph_lock.open("a+") as lock:
|
|
380
|
+
fcntl.flock(lock, fcntl.LOCK_EX)
|
|
381
|
+
if read_registration(event_id) is None:
|
|
382
|
+
_assert_no_settlement_cycle(parent_value, wid, store)
|
|
383
|
+
published = publish()
|
|
384
|
+
_hint_managed_parent_background(published)
|
|
385
|
+
return published
|
|
386
|
+
published = publish()
|
|
387
|
+
_hint_managed_parent_background(published)
|
|
388
|
+
if worker_engine == "controller":
|
|
389
|
+
# A preflighted, empty supervisor may have retired before this arm.
|
|
390
|
+
# The disk registration is authoritative even if restart is unavailable.
|
|
391
|
+
try:
|
|
392
|
+
ensure_notify_daemon()
|
|
393
|
+
except (SessionUnavailable, OSError):
|
|
394
|
+
pass
|
|
395
|
+
return published
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def compact_notification_failure(reg: dict[str, Any], reason: str) -> tuple[bool, str]:
|
|
399
|
+
"""On core failure, cancel only when its original receipt proves no submission."""
|
|
400
|
+
from ccx import constants
|
|
401
|
+
from ccx.store.root import require_ccx_active
|
|
402
|
+
|
|
403
|
+
try:
|
|
404
|
+
store = require_ccx_active(constants.CCX_ROOT)
|
|
405
|
+
worker = reg["worker"]
|
|
406
|
+
family = "compact" if worker["engine"] == "claude" else "codex-compact"
|
|
407
|
+
receipt = store.read_receipt(
|
|
408
|
+
f"{worker['engine']}:{worker['workerId']}", family,
|
|
409
|
+
reg["operation"]["operationId"])
|
|
410
|
+
except (ValueError, OSError):
|
|
411
|
+
receipt = {"state": "unreadable"} # Never cancel an ambiguous effect.
|
|
412
|
+
if receipt is None or receipt.get("state") in {"prepared", "rejected"}:
|
|
413
|
+
cancel_registration(reg["eventId"], reason)
|
|
414
|
+
uncertain = False
|
|
415
|
+
else:
|
|
416
|
+
uncertain = True
|
|
417
|
+
current = read_registration(reg["eventId"])
|
|
418
|
+
return uncertain, (current or reg)["state"]
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def cancel_registration(event_id: str, reason: str) -> None:
|
|
422
|
+
def mutate(value: dict[str, Any]) -> None:
|
|
423
|
+
if value.get("state") in TERMINAL_REGISTRATION_STATES:
|
|
424
|
+
return
|
|
425
|
+
value["state"] = "cancelled"
|
|
426
|
+
value["cancelReason"] = reason
|
|
427
|
+
mutate_registration(event_id, mutate)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def set_codex_turn_id(event_id: str, turn_id: str | None) -> None:
|
|
431
|
+
"""Bind only this registration's original accepted semantic turn."""
|
|
432
|
+
if not isinstance(turn_id, str) or not turn_id:
|
|
433
|
+
return
|
|
434
|
+
def mutate(value: dict[str, Any]) -> None:
|
|
435
|
+
from ccx.codex.delivery import read_receipt
|
|
436
|
+
worker = value["worker"]["workerId"]
|
|
437
|
+
delivery_id = value["operation"]["deliveryId"]
|
|
438
|
+
receipt = read_receipt(f"codex:{worker}", delivery_id)
|
|
439
|
+
if (receipt is None or receipt.get("state") != "accepted"
|
|
440
|
+
or receipt.get("turnId") != turn_id):
|
|
441
|
+
raise InvalidRequest("native turn differs from original Codex delivery receipt")
|
|
442
|
+
operation = value["operation"]
|
|
443
|
+
if operation.get("turnId") not in (None, turn_id):
|
|
444
|
+
raise InvalidRequest("original notification turn identity already bound")
|
|
445
|
+
operation["turnId"] = turn_id
|
|
446
|
+
mutate_registration(event_id, mutate)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def public_registration(value: dict[str, Any]) -> dict[str, Any]:
|
|
450
|
+
"""Project the original event and native wake status, not parent completion."""
|
|
451
|
+
from ccx.notify.select import _original_notification_registration
|
|
452
|
+
from ccx.notify.daemon import _notifyd_alive
|
|
453
|
+
_store, parent, child = _original_notification_registration(value)
|
|
454
|
+
operation = value["operation"]
|
|
455
|
+
state = value["state"]
|
|
456
|
+
instruction = (
|
|
457
|
+
"Parent machine notification was placed; parent model completion is unproven."
|
|
458
|
+
if state == "delivered" else
|
|
459
|
+
("Notification cancelled; the controller run is unaffected."
|
|
460
|
+
if value["worker"]["engine"] == "controller" else
|
|
461
|
+
"Notification cancelled; the child delivery is unaffected.")
|
|
462
|
+
if state == "cancelled" else
|
|
463
|
+
("Parent notification undeliverable; the controller attempt remains recorded."
|
|
464
|
+
if value["worker"]["engine"] == "controller" else
|
|
465
|
+
"Parent notification undeliverable; the child receipt remains recorded.")
|
|
466
|
+
if state == "undeliverable" else
|
|
467
|
+
("Controller parent notification is durable; a machine wake can follow "
|
|
468
|
+
"this attempt's outcome."
|
|
469
|
+
if value["worker"]["engine"] == "controller" else
|
|
470
|
+
"Notification saved; CCX will try to notify the calling Codex worker "
|
|
471
|
+
"once this worker's turn outcome is confirmed.")
|
|
472
|
+
)
|
|
473
|
+
turn_ref = operation.get("selectedTurnRef")
|
|
474
|
+
if (value["worker"]["engine"] == "claude" and turn_ref is None
|
|
475
|
+
and operation.get("deliveryId")):
|
|
476
|
+
from ccx.claude.delivery import read_receipt
|
|
477
|
+
original = read_receipt(child, operation["deliveryId"])
|
|
478
|
+
turn_ref = (original.get("turnRef") if isinstance(original, dict)
|
|
479
|
+
and original.get("deliveryId") == operation["deliveryId"] else None)
|
|
480
|
+
turn_ref = turn_ref or (value.get("settlement") or {}).get("turnRef")
|
|
481
|
+
result = {
|
|
482
|
+
"event_id": value["eventId"], "state": value["state"],
|
|
483
|
+
"parent": parent, "parent_kind": value["parent"]["kind"],
|
|
484
|
+
"worker": child, "worker_id": value["worker"]["workerId"],
|
|
485
|
+
"worker_name": value["worker"].get("name"),
|
|
486
|
+
"short": value["worker"]["workerId"][:8],
|
|
487
|
+
"delivery_id": operation.get("deliveryId"),
|
|
488
|
+
"turn_id": operation.get("turnId") or (value.get("settlement") or {}).get("turnId"),
|
|
489
|
+
**({"selection_mode": operation["selectionMode"],
|
|
490
|
+
"selected_turn_ref": operation.get("selectedTurnRef"),
|
|
491
|
+
"selected_original_delivery_id": operation.get("deliveryId")}
|
|
492
|
+
if value["source"] in {"notify", "new"} else {}),
|
|
493
|
+
**({"selected_turn_ref": operation["selectedTurnRef"]}
|
|
494
|
+
if value["source"] == "input" else {}),
|
|
495
|
+
**({"turn_ref": turn_ref} if value["worker"]["engine"] == "claude" else {}),
|
|
496
|
+
"source": value["source"],
|
|
497
|
+
"settle": value.get("settlementMode") == "orchestration",
|
|
498
|
+
"resume_automatically": False, "polling_required": False,
|
|
499
|
+
"delivery_available": state in {"armed", "ready", "delivered"},
|
|
500
|
+
"instruction": instruction,
|
|
501
|
+
"caller_metadata": operation.get("callerMetadata"),
|
|
502
|
+
"created_at_ms": value.get("createdAtMs"),
|
|
503
|
+
"updated_at_ms": value.get("updatedAtMs"),
|
|
504
|
+
"ready_at_ms": value.get("readyAtMs"),
|
|
505
|
+
"delivered_at_ms": value.get("deliveredAtMs"),
|
|
506
|
+
"attempts": int(value.get("attempts") or 0),
|
|
507
|
+
"last_attempt_at_ms": value.get("lastAttemptAtMs"),
|
|
508
|
+
"next_attempt_at_ms": value.get("nextAttemptAtMs"),
|
|
509
|
+
"last_error": value.get("lastError"),
|
|
510
|
+
"operation_kind": operation["kind"],
|
|
511
|
+
"settlement": value.get("settlement"),
|
|
512
|
+
}
|
|
513
|
+
if operation.get("kind") in {"claude-compact", "codex-compact"}:
|
|
514
|
+
result.update(operation_type="compact",
|
|
515
|
+
operation_id=operation["operationId"],
|
|
516
|
+
operation_status=(value.get("operationSettlement") or
|
|
517
|
+
value.get("settlement") or {}).get("operationStatus"))
|
|
518
|
+
if value["worker"]["engine"] == "controller":
|
|
519
|
+
result.update(operation_type="controller-attempt",
|
|
520
|
+
run_id=value["worker"]["workerId"], attempt=operation["attempt"],
|
|
521
|
+
notifier_available=_notifyd_alive())
|
|
522
|
+
return result
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
_VALID_TERMINAL_ROWS: set[tuple[str, int, int, int]] = set()
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def iter_registrations(*, include_terminal: bool = False,
|
|
529
|
+
strict: bool = False) -> list[dict[str, Any]]:
|
|
530
|
+
"""Read current notification rows without hiding damaged inventory."""
|
|
531
|
+
from ccx.notify.select import _original_notification_registration
|
|
532
|
+
ensure_dirs()
|
|
533
|
+
values: list[dict[str, Any]] = []
|
|
534
|
+
if strict:
|
|
535
|
+
from ccx import constants
|
|
536
|
+
from ccx.store.root import require_ccx_active
|
|
537
|
+
generation = require_ccx_active(constants.CCX_ROOT).generation
|
|
538
|
+
|
|
539
|
+
directory = registrations_dir().absolute() # entry paths, never symlink targets
|
|
540
|
+
invalid: list[str] = []
|
|
541
|
+
for path in sorted(directory.glob("*.json")):
|
|
542
|
+
try:
|
|
543
|
+
st = path.stat()
|
|
544
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
545
|
+
except (OSError, ValueError):
|
|
546
|
+
invalid.append(f"{path} (unreadable JSON)")
|
|
547
|
+
continue
|
|
548
|
+
version_key = (str(path), st.st_ino, st.st_mtime_ns, st.st_size)
|
|
549
|
+
if not isinstance(value, dict):
|
|
550
|
+
invalid.append(f"{path} (invalid row)")
|
|
551
|
+
continue
|
|
552
|
+
version, recorded = value.get("version"), value.get("generation")
|
|
553
|
+
if strict and ((type(version) is int and version in (1, 2)) or
|
|
554
|
+
(version == 3 and isinstance(recorded, str) and
|
|
555
|
+
recorded != generation)):
|
|
556
|
+
continue # Recognizable pre-migration or foreign generation: inert.
|
|
557
|
+
terminal = value.get("state") in TERMINAL_REGISTRATION_STATES
|
|
558
|
+
if terminal and not include_terminal and not strict:
|
|
559
|
+
continue # strict callers must still see damaged settled rows
|
|
560
|
+
# A settled row never changes again, so one successful validation per
|
|
561
|
+
# file version is enough; notifyd re-reads every row each 0.35 s tick.
|
|
562
|
+
if not (terminal and version_key in _VALID_TERMINAL_ROWS):
|
|
563
|
+
try:
|
|
564
|
+
_original_notification_registration(value)
|
|
565
|
+
except Exception:
|
|
566
|
+
# Normal notify supervision remains permissive to legacy rows.
|
|
567
|
+
invalid.append(f"{path} (invalid active row)")
|
|
568
|
+
continue
|
|
569
|
+
if terminal:
|
|
570
|
+
if len(_VALID_TERMINAL_ROWS) > 4096:
|
|
571
|
+
_VALID_TERMINAL_ROWS.clear()
|
|
572
|
+
_VALID_TERMINAL_ROWS.add(version_key)
|
|
573
|
+
if terminal and not include_terminal:
|
|
574
|
+
continue
|
|
575
|
+
values.append(value)
|
|
576
|
+
if strict and invalid:
|
|
577
|
+
raise SessionUnavailable(
|
|
578
|
+
"notification inventory incomplete: " + "; ".join(invalid)
|
|
579
|
+
+ f". Move these files out of {directory} to unblock settle.")
|
|
580
|
+
return values
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def has_pending_registrations() -> bool:
|
|
584
|
+
return bool(iter_registrations())
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def terminal_retention_s() -> float:
|
|
588
|
+
"""How long completed notification receipts remain on disk.
|
|
589
|
+
|
|
590
|
+
The receipts are useful for debugging and ambiguous-delivery reconciliation,
|
|
591
|
+
but they are one file per awaited operation and must not accumulate forever.
|
|
592
|
+
Invalid configuration falls back to seven days; zero prunes terminal receipts
|
|
593
|
+
on the next notifyd start.
|
|
594
|
+
"""
|
|
595
|
+
raw = os.environ.get("CCX_NOTIFY_TERMINAL_RETENTION_S", str(7 * 24 * 60 * 60))
|
|
596
|
+
try:
|
|
597
|
+
value = float(raw)
|
|
598
|
+
except (TypeError, ValueError):
|
|
599
|
+
value = float(7 * 24 * 60 * 60)
|
|
600
|
+
return max(0.0, value)
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def prune_terminal_registrations(*, now_ms_value: int | None = None) -> int:
|
|
604
|
+
"""Remove terminal receipts older than the configured retention window.
|
|
605
|
+
|
|
606
|
+
Each candidate is re-read while holding its per-event flock so a concurrent
|
|
607
|
+
mutation cannot be pruned from a stale snapshot. Lock sidecars are removed
|
|
608
|
+
with the terminal JSON while the old inode remains locked by this process.
|
|
609
|
+
"""
|
|
610
|
+
ensure_dirs()
|
|
611
|
+
current = now_ms() if now_ms_value is None else int(now_ms_value)
|
|
612
|
+
cutoff = current - int(terminal_retention_s() * 1000)
|
|
613
|
+
removed = 0
|
|
614
|
+
for path in sorted(registrations_dir().glob("*.json")):
|
|
615
|
+
event_id = path.stem
|
|
616
|
+
lock_path = _lock_path(event_id)
|
|
617
|
+
try:
|
|
618
|
+
lock = open(lock_path, "a+")
|
|
619
|
+
except OSError:
|
|
620
|
+
continue
|
|
621
|
+
try:
|
|
622
|
+
try:
|
|
623
|
+
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
624
|
+
except BlockingIOError:
|
|
625
|
+
continue
|
|
626
|
+
value = read_registration(event_id)
|
|
627
|
+
if not isinstance(value, dict):
|
|
628
|
+
continue
|
|
629
|
+
if value.get("state") not in TERMINAL_REGISTRATION_STATES:
|
|
630
|
+
continue
|
|
631
|
+
terminal_at = (
|
|
632
|
+
value.get("deliveredAtMs")
|
|
633
|
+
or value.get("updatedAtMs")
|
|
634
|
+
or value.get("createdAtMs")
|
|
635
|
+
)
|
|
636
|
+
if not isinstance(terminal_at, (int, float)) or terminal_at > cutoff:
|
|
637
|
+
continue
|
|
638
|
+
try:
|
|
639
|
+
registration_path(event_id).unlink()
|
|
640
|
+
except FileNotFoundError:
|
|
641
|
+
pass
|
|
642
|
+
try:
|
|
643
|
+
lock_path.unlink()
|
|
644
|
+
except FileNotFoundError:
|
|
645
|
+
pass
|
|
646
|
+
removed += 1
|
|
647
|
+
finally:
|
|
648
|
+
lock.close()
|
|
649
|
+
return removed
|