ccx-native 0.0.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +240 -4
  3. package/bin/ccx +55 -2
  4. package/ccx/__init__.py +167 -0
  5. package/ccx/__main__.py +28 -0
  6. package/ccx/claude/__init__.py +0 -0
  7. package/ccx/claude/analytics.py +466 -0
  8. package/ccx/claude/attach.py +807 -0
  9. package/ccx/claude/context.py +465 -0
  10. package/ccx/claude/delivery.py +806 -0
  11. package/ccx/claude/external.py +253 -0
  12. package/ccx/claude/host/__init__.py +0 -0
  13. package/ccx/claude/host/events.py +297 -0
  14. package/ccx/claude/host/hooks.py +438 -0
  15. package/ccx/claude/host/pty.py +269 -0
  16. package/ccx/claude/host/session.py +5244 -0
  17. package/ccx/claude/host/subagents.py +169 -0
  18. package/ccx/claude/host/successor.py +267 -0
  19. package/ccx/claude/managed_turns.py +240 -0
  20. package/ccx/claude/oauth.py +176 -0
  21. package/ccx/claude/settings.py +339 -0
  22. package/ccx/claude/spawn.py +557 -0
  23. package/ccx/claude/tasks.py +72 -0
  24. package/ccx/claude/telemetry.py +282 -0
  25. package/ccx/claude/transcript.py +2535 -0
  26. package/ccx/claude/usage.py +796 -0
  27. package/ccx/claude/usage_control.py +204 -0
  28. package/ccx/cli/__init__.py +0 -0
  29. package/ccx/cli/codex.py +1009 -0
  30. package/ccx/cli/commands/__init__.py +0 -0
  31. package/ccx/cli/commands/_common.py +407 -0
  32. package/ccx/cli/commands/control.py +161 -0
  33. package/ccx/cli/commands/create.py +669 -0
  34. package/ccx/cli/commands/models.py +53 -0
  35. package/ccx/cli/commands/read.py +1104 -0
  36. package/ccx/cli/commands/turns.py +1081 -0
  37. package/ccx/cli/controller.py +757 -0
  38. package/ccx/cli/main.py +302 -0
  39. package/ccx/cli/parser.py +1439 -0
  40. package/ccx/cli/skills.py +215 -0
  41. package/ccx/cli/statusline.py +160 -0
  42. package/ccx/client.py +1904 -0
  43. package/ccx/codex/__init__.py +0 -0
  44. package/ccx/codex/attach_tty.py +267 -0
  45. package/ccx/codex/client.py +738 -0
  46. package/ccx/codex/completion.py +121 -0
  47. package/ccx/codex/control.py +288 -0
  48. package/ccx/codex/daemon/__init__.py +0 -0
  49. package/ccx/codex/daemon/__main__.py +4 -0
  50. package/ccx/codex/daemon/daemon.py +1558 -0
  51. package/ccx/codex/daemon/models.py +140 -0
  52. package/ccx/codex/daemon/native.py +1590 -0
  53. package/ccx/codex/daemon/release.py +367 -0
  54. package/ccx/codex/daemon/worker.py +2424 -0
  55. package/ccx/codex/delivery.py +758 -0
  56. package/ccx/codex/delivery_recovery.py +232 -0
  57. package/ccx/codex/evidence.py +827 -0
  58. package/ccx/codex/history.py +158 -0
  59. package/ccx/codex/history_store.py +131 -0
  60. package/ccx/codex/notify_history.py +140 -0
  61. package/ccx/codex/ops.py +666 -0
  62. package/ccx/codex/paths.py +70 -0
  63. package/ccx/codex/protocol.py +805 -0
  64. package/ccx/codex/provenance.py +105 -0
  65. package/ccx/codex/read.py +29 -0
  66. package/ccx/codex/records.py +51 -0
  67. package/ccx/codex/reports.py +177 -0
  68. package/ccx/codex/runtime.py +1454 -0
  69. package/ccx/codex/settle.py +363 -0
  70. package/ccx/codex/telemetry.py +792 -0
  71. package/ccx/codex/types.py +659 -0
  72. package/ccx/codex/worker_environment.py +25 -0
  73. package/ccx/constants.py +288 -0
  74. package/ccx/controllers/__init__.py +0 -0
  75. package/ccx/controllers/controller.py +1449 -0
  76. package/ccx/controllers/live_vars.py +1706 -0
  77. package/ccx/controllers/live_vars_runner.py +216 -0
  78. package/ccx/controllers/notify.py +213 -0
  79. package/ccx/controllers/orchestrator.py +193 -0
  80. package/ccx/controllers/policy.py +57 -0
  81. package/ccx/controllers/store.py +681 -0
  82. package/ccx/errors.py +634 -0
  83. package/ccx/models.py +225 -0
  84. package/ccx/notify/__init__.py +0 -0
  85. package/ccx/notify/__main__.py +5 -0
  86. package/ccx/notify/client.py +116 -0
  87. package/ccx/notify/daemon.py +480 -0
  88. package/ccx/notify/deliver.py +304 -0
  89. package/ccx/notify/paths.py +51 -0
  90. package/ccx/notify/registry.py +649 -0
  91. package/ccx/notify/select.py +750 -0
  92. package/ccx/notify/settle.py +684 -0
  93. package/ccx/ops/__init__.py +0 -0
  94. package/ccx/ops/background.py +115 -0
  95. package/ccx/ops/cancel.py +296 -0
  96. package/ccx/ops/codex_import.py +96 -0
  97. package/ccx/ops/common.py +397 -0
  98. package/ccx/ops/compact.py +337 -0
  99. package/ccx/ops/compact_ops.py +550 -0
  100. package/ccx/ops/context_events.py +48 -0
  101. package/ccx/ops/conversation.py +1593 -0
  102. package/ccx/ops/conversation_paging.py +1339 -0
  103. package/ccx/ops/create.py +56 -0
  104. package/ccx/ops/creation.py +952 -0
  105. package/ccx/ops/creation_steps.py +575 -0
  106. package/ccx/ops/event_feed.py +710 -0
  107. package/ccx/ops/forking.py +711 -0
  108. package/ccx/ops/history.py +1041 -0
  109. package/ccx/ops/input.py +556 -0
  110. package/ccx/ops/maintenance.py +295 -0
  111. package/ccx/ops/report.py +773 -0
  112. package/ccx/ops/semantics.py +490 -0
  113. package/ccx/ops/send.py +961 -0
  114. package/ccx/ops/steer_ops.py +867 -0
  115. package/ccx/ops/terminate.py +383 -0
  116. package/ccx/ops/turn_reports.py +348 -0
  117. package/ccx/ops/wait.py +762 -0
  118. package/ccx/ops/worker_events.py +640 -0
  119. package/ccx/rpc/__init__.py +0 -0
  120. package/ccx/rpc/local_rpc.py +1602 -0
  121. package/ccx/rpc/rpc.py +559 -0
  122. package/ccx/rpc/serve.py +374 -0
  123. package/ccx/rpc/transport.py +409 -0
  124. package/ccx/rpc/view.py +125 -0
  125. package/ccx/store/__init__.py +0 -0
  126. package/ccx/store/discovery.py +170 -0
  127. package/ccx/store/identity.py +105 -0
  128. package/ccx/store/journal_io.py +79 -0
  129. package/ccx/store/liveness.py +194 -0
  130. package/ccx/store/paths.py +250 -0
  131. package/ccx/store/record_cache.py +40 -0
  132. package/ccx/store/records.py +309 -0
  133. package/ccx/store/root.py +789 -0
  134. package/ccx/store/runtime_presence.py +137 -0
  135. package/ccx/store/store.py +2332 -0
  136. package/ccx/store/worker_state.py +86 -0
  137. package/ccx/system/__init__.py +0 -0
  138. package/ccx/system/bounded.py +296 -0
  139. package/ccx/system/cwd_paths.py +25 -0
  140. package/ccx/system/engine_binary.py +59 -0
  141. package/ccx/system/executable.py +50 -0
  142. package/ccx/system/oslayer.py +667 -0
  143. package/ccx/system/pipes.py +46 -0
  144. package/ccx/system/private_json.py +54 -0
  145. package/ccx/system/sanitize.py +43 -0
  146. package/ccx/types.py +809 -0
  147. package/ccx/version.py +29 -0
  148. package/ccx/workers.py +2093 -0
  149. package/ccx.py +9 -0
  150. package/package.json +39 -7
  151. package/pyproject.toml +87 -0
  152. package/skills/ccx/SKILL.md +166 -0
  153. package/skills/ccx/references/controllers.md +244 -0
  154. package/skills/ccx/references/history.md +50 -0
  155. package/skills/ccx/references/troubleshooting.md +94 -0
  156. package/skills/ccx/references/variables.md +62 -0
@@ -0,0 +1,363 @@
1
+ """Read-only recursive settlement of original CCX-owned Codex work."""
2
+ from __future__ import annotations
3
+
4
+ import time
5
+ from typing import Any
6
+
7
+ from ccx.codex import evidence as codex_evidence
8
+ from ccx.notify.registry import TERMINAL_REGISTRATION_STATES, iter_registrations
9
+
10
+ DEFAULT_WAKE_GRACE_S = 120.0
11
+ SETTLE_POLL_S = 0.35
12
+ _PRESENTATION_LIMIT = 20
13
+ _TERMINAL_TURNS = frozenset({"completed", "failed", "interrupted", "cancelled"})
14
+
15
+
16
+ def _record(worker: str, store) -> dict[str, Any]:
17
+ """One original full owner; the provider thread is a separate identity."""
18
+ owner = store.resolve(worker)
19
+ if owner.engine != "codex":
20
+ from ccx.errors import InvalidRequest
21
+ raise InvalidRequest("recursive settle requires a managed Codex UUID")
22
+ result = store.record(owner.worker)
23
+ native = result.get("native") or {}
24
+ thread = native.get("threadId")
25
+ if (not isinstance(thread, str) or not thread
26
+ or native.get("threadSource") != store.codex_source(owner.worker)):
27
+ thread = None
28
+ return {**result, "threadId": thread, "generation": store.generation}
29
+
30
+
31
+ def _parent_registrations(record: dict[str, Any], source: list[dict[str, Any]]) -> list[dict[str, Any]]:
32
+ """A native thread alone never confers parent ownership."""
33
+ thread = record.get("threadId")
34
+ worker_id = record.get("workerId")
35
+ generation = record.get("generation")
36
+ if not thread:
37
+ return []
38
+ return [row for row in source if (
39
+ (row.get("parent") or {}).get("kind") == "ccx-codex"
40
+ and (row["parent"]).get("workerId") == worker_id
41
+ and row["parent"].get("generation") == generation
42
+ and row["parent"].get("threadId") == thread)]
43
+
44
+
45
+ def _worker_summary(reg: dict[str, Any]) -> dict[str, Any]:
46
+ worker = reg.get("worker") or {}
47
+ return {"eventId": reg.get("eventId"),
48
+ "worker": (f"{worker.get('engine')}:{worker['workerId']}"
49
+ if worker.get("engine") and worker.get("workerId") else None),
50
+ "workerName": worker.get("name"), "state": reg.get("state"),
51
+ "updatedAtMs": reg.get("updatedAtMs"),
52
+ "deliveredAtMs": reg.get("deliveredAtMs")}
53
+
54
+
55
+ def _completed_turn_ids(record: dict[str, Any]) -> set[str]:
56
+ """Only source-complete native terminal events on the original parent thread."""
57
+ thread = record.get("threadId")
58
+ worker_id = record.get("workerId")
59
+ if not thread or not worker_id:
60
+ return set()
61
+ try:
62
+ journal = codex_evidence.read_journal(f"codex:{worker_id}")
63
+ index = codex_evidence.build_index(journal, health=record.get("evidence"))
64
+ except (OSError, ValueError):
65
+ return set()
66
+ if not index["complete"] or not journal.exists:
67
+ return set()
68
+ completed: set[str] = set()
69
+ for event in journal.events:
70
+ if (event.get("method") != "turn/completed" or
71
+ event.get("threadId") != thread):
72
+ continue
73
+ turn_id = event.get("turnId")
74
+ turn = (event.get("params") or {}).get("turn")
75
+ facts = index["turns"].get(turn_id)
76
+ if (isinstance(turn_id, str) and isinstance(turn, dict)
77
+ and turn.get("id") == turn_id
78
+ and turn.get("status") in _TERMINAL_TURNS
79
+ and facts and facts["sawTurnStarted"] and facts["sawTurnCompleted"]):
80
+ completed.add(turn_id)
81
+ return completed
82
+
83
+
84
+ def _pending_wake(registrations: list[dict[str, Any]], record: dict[str, Any]) -> list[dict[str, Any]]:
85
+ active = (record.get("lifecycle") or {}).get("activeTurnId")
86
+ last = record.get("lastTurn") or {}
87
+ done = None
88
+ pending = []
89
+ for row in registrations:
90
+ if row.get("state") != "delivered":
91
+ continue
92
+ wake = (row.get("delivery") or {}).get("turnId")
93
+ if not isinstance(wake, str) or not wake or wake == active:
94
+ pending.append(row)
95
+ continue
96
+ if last.get("turnId") == wake and last.get("status") in _TERMINAL_TURNS:
97
+ continue
98
+ if done is None:
99
+ done = _completed_turn_ids(record)
100
+ if wake not in done:
101
+ pending.append(row)
102
+ return pending
103
+
104
+
105
+ def _deadline_after(value: float | None) -> float | None:
106
+ if value is None:
107
+ return None
108
+ return time.monotonic() + value
109
+
110
+
111
+ def _expired(deadline: float | None) -> bool:
112
+ return deadline is not None and time.monotonic() >= deadline
113
+
114
+
115
+ def _result(record: dict[str, Any], *, verdict: str,
116
+ registrations: list[dict[str, Any]] = (),
117
+ live: list[dict[str, Any]] = (), pending_wake: list[dict[str, Any]] = (),
118
+ wake_grace_s: float = DEFAULT_WAKE_GRACE_S,
119
+ error: str | None = None, observation: dict[str, Any] | None = None) -> dict[str, Any]:
120
+ lc = record.get("lifecycle") or {}
121
+ last = record.get("lastTurn") or {}
122
+ report = record.get("lastReport") or {}
123
+ # A cached report about another native turn is not this worker's answer.
124
+ message = (report.get("text") if last.get("status") == "completed"
125
+ and report.get("turnId") == last.get("turnId")
126
+ and isinstance(report.get("text"), str) else None)
127
+ worker_id = record.get("workerId")
128
+ observed = observation or {}
129
+ return {"ok": verdict == "settled", "worker": f"codex:{worker_id}",
130
+ "worker_id": worker_id, "short": worker_id[:8] if worker_id else None,
131
+ "threadId": record.get("threadId"), "name": record.get("name"),
132
+ "cwd": record.get("cwd"), "state": observed.get("state") or lc.get("state"),
133
+ "activeTurnId": lc.get("activeTurnId"),
134
+ "pendingApprovals": observed.get("pendingApprovals") or [],
135
+ "lastTurn": record.get("lastTurn"), "lastReport": None,
136
+ "activeTurnError": record.get("activeTurnError"),
137
+ "evidence": record.get("evidence"),
138
+ "settle": True, "settlementVerdict": verdict,
139
+ "settled": verdict == "settled", "outstandingCount": len(live),
140
+ "outstanding": [_worker_summary(r) for r in live[:_PRESENTATION_LIMIT]],
141
+ "outstandingTruncated": len(live) > _PRESENTATION_LIMIT,
142
+ "pendingWakeCount": len(pending_wake),
143
+ "pendingWake": [_worker_summary(r) for r in pending_wake[:_PRESENTATION_LIMIT]],
144
+ "pendingWakeTruncated": len(pending_wake) > _PRESENTATION_LIMIT,
145
+ "registrationCount": len(registrations),
146
+ "wakeGraceSeconds": wake_grace_s,
147
+ "lastMessage": message, "error": error}
148
+
149
+
150
+ def _settlement_cycle(record: dict[str, Any], registrations: list[dict[str, Any]], store) -> list[str] | None:
151
+ root = record.get("threadId")
152
+ if not root:
153
+ return None
154
+ generation = store.generation
155
+ edges: dict[str, set[str]] = {}
156
+ for row in registrations:
157
+ if row.get("state") in TERMINAL_REGISTRATION_STATES or row.get("settlementMode") != "orchestration":
158
+ continue
159
+ parent, child = row.get("parent") or {}, row.get("worker") or {}
160
+ if (parent.get("kind") != "ccx-codex" or parent.get("generation") != generation
161
+ or child.get("engine") != "codex" or child.get("generation") != generation):
162
+ continue
163
+ target = _record(f"codex:{child['workerId']}", store)
164
+ if target.get("threadId"):
165
+ edges.setdefault(parent["threadId"], set()).add(target["threadId"])
166
+ stack = [(root, [root])]
167
+ seen = set()
168
+ while stack:
169
+ node, path = stack.pop()
170
+ if node in seen:
171
+ continue
172
+ seen.add(node)
173
+ for next_thread in edges.get(node, ()):
174
+ if next_thread == root:
175
+ return [*path, root]
176
+ if next_thread not in path:
177
+ stack.append((next_thread, [*path, next_thread]))
178
+ return None
179
+
180
+
181
+ def settlement_progress(worker: str, *,
182
+ wake_grace_s: float = DEFAULT_WAKE_GRACE_S,
183
+ registrations: list[dict[str, Any]] | None = None,
184
+ store=None, observation: dict[str, Any] | None = None) -> dict[str, Any]:
185
+ """One original full-ID registry census and nonblocking recursive verdict."""
186
+ from ccx import constants
187
+ from ccx.errors import SessionUnavailable
188
+ from ccx.ops.wait import wait_worker
189
+ from ccx.store.root import require_ccx_active
190
+
191
+ store = store or require_ccx_active(constants.CCX_ROOT)
192
+ store.revalidate()
193
+ record = _record(worker, store)
194
+ if observation is None:
195
+ observation = wait_worker(worker, timeout=.25,
196
+ expected_generation=store.generation)
197
+ # A supplied non-strict snapshot is not evidence of a complete inventory.
198
+ try:
199
+ rows = iter_registrations(include_terminal=True, strict=True)
200
+ except SessionUnavailable as error:
201
+ if not str(error).startswith("notification inventory incomplete:"):
202
+ raise
203
+ return _result(record, verdict="inventory_incomplete", error=str(error),
204
+ observation=observation, wake_grace_s=wake_grace_s)
205
+ parent_rows = _parent_registrations(record, rows)
206
+ live = [r for r in parent_rows if r.get("state") not in TERMINAL_REGISTRATION_STATES]
207
+ pending = _pending_wake(parent_rows, record)
208
+ broken = next((r for r in parent_rows if r.get("state") == "undeliverable"), None)
209
+ cycle = _settlement_cycle(record, rows, store)
210
+ status = observation.get("status")
211
+ state = observation.get("state")
212
+ lc = record.get("lifecycle") or {}
213
+ if status in {"dead", "crashed"} and lc.get("state") == status:
214
+ verdict = status
215
+ elif status == "waiting" and observation.get("pendingApprovals"):
216
+ verdict = "approval_waiting"
217
+ elif broken:
218
+ verdict = "undeliverable_child"
219
+ elif cycle:
220
+ verdict = "cycle"
221
+ else:
222
+ active = observation.get("active_turn_ref")
223
+ idle = (status == "idle" and state == "idle"
224
+ and observation.get("reason") == "worker is idle" and active is None)
225
+ unloaded = (status == "stopped" and state in {"idle", "idle-killed"}
226
+ and lc.get("state") in {"idle", "idle-killed"}
227
+ and observation.get("reason", "").startswith("Codex runtime is absent")
228
+ and active is None)
229
+ killed = (status == "idle-killed" and lc.get("state") == "idle-killed")
230
+ verdict = ("settled" if record.get("threadId") and (idle or unloaded or killed)
231
+ and not live and not pending else "pending")
232
+ error = (f"child notification {broken['eventId']} is undeliverable" if broken else
233
+ "recursive settlement cycle: " + " -> ".join(cycle) if cycle else None)
234
+ store.revalidate()
235
+ return _result(record, verdict=verdict, registrations=parent_rows,
236
+ live=live, pending_wake=pending, observation=observation,
237
+ wake_grace_s=wake_grace_s, error=error)
238
+
239
+
240
+ def settlement_snapshot(worker: str, *,
241
+ wake_grace_s: float = DEFAULT_WAKE_GRACE_S,
242
+ registrations: list[dict[str, Any]] | None = None) -> dict[str, Any] | None:
243
+ result = settlement_progress(worker, wake_grace_s=wake_grace_s,
244
+ registrations=registrations)
245
+ return None if result["settlementVerdict"] == "pending" else result
246
+
247
+
248
+ def wait_for_settlement(worker: str, *, timeout: float | None = None,
249
+ wake_grace_s: float = DEFAULT_WAKE_GRACE_S,
250
+ expected_generation: str | None = None) -> dict[str, Any]:
251
+ """The same graph reader, held until true completion or a bounded verdict."""
252
+ from math import isfinite
253
+ from ccx import constants
254
+ from ccx.ops.wait import wait_worker
255
+ from ccx.store.root import require_ccx_active
256
+ from ccx.errors import InvalidRequest
257
+
258
+ if any(type(value) not in {int, float} or not isfinite(value) or value < 0
259
+ for value in (timeout, wake_grace_s) if value is not None):
260
+ raise InvalidRequest("settle timeout and wake grace must be nonnegative finite seconds")
261
+ store = require_ccx_active(constants.CCX_ROOT,
262
+ expected_generation=expected_generation)
263
+ owner = store.resolve(worker)
264
+ if owner.engine != "codex":
265
+ raise InvalidRequest("recursive settle requires a Codex target")
266
+ worker = owner.worker
267
+ timeout_at = _deadline_after(timeout)
268
+ wake_since = None
269
+ last_turn = None
270
+ while True:
271
+ budget = (SETTLE_POLL_S if timeout_at is None
272
+ else min(SETTLE_POLL_S, max(0.0, timeout_at - time.monotonic())))
273
+ observed = wait_worker(worker, timeout=budget, expected_generation=store.generation)
274
+ result = settlement_progress(worker, store=store, observation=observed,
275
+ wake_grace_s=wake_grace_s)
276
+ if result["settlementVerdict"] != "pending":
277
+ return result
278
+ # As main: missing-wake grace runs only while the parent is quiet. An
279
+ # observed active turn, or a newly completed one, restarts the clock.
280
+ running = bool(observed.get("active_turn_ref") or result.get("activeTurnId"))
281
+ settled_turn = (result.get("lastTurn") or {}).get("turnId")
282
+ if running or settled_turn != last_turn:
283
+ wake_since = None
284
+ last_turn = settled_turn
285
+ wake_only = (result["pendingWakeCount"] and not result["outstandingCount"]
286
+ and not running)
287
+ wake_since = (wake_since or time.monotonic()) if wake_only else None
288
+ if wake_since is not None and time.monotonic() - wake_since >= wake_grace_s:
289
+ return {**result, "ok": False, "settled": False,
290
+ "settlementVerdict": "delivery_gap",
291
+ "error": "original final child delivery lacks its exact completed parent wake within grace"}
292
+ if _expired(timeout_at):
293
+ return {**result, "ok": False, "settled": False,
294
+ "settlementVerdict": "timeout", "error": "settle timed out"}
295
+ time.sleep(SETTLE_POLL_S if timeout_at is None else
296
+ min(SETTLE_POLL_S, max(0.0, timeout_at - time.monotonic())))
297
+
298
+
299
+ def send_settle_failure(worker: str, observation: dict) -> dict | None:
300
+ """An exact delivery can fail while its receipt still reads 'accepted'.
301
+
302
+ Consult the same stable-state observer as `wait`, without treating an
303
+ ordinary idle (including an owned-but-unpasted Claude delivery) as done.
304
+ """
305
+ receipt = observation.get("delivery") or {}
306
+ if receipt.get("evidence_status") in {"pty-write-failed", "pty-submit-failed"}:
307
+ # A stalled PTY write is not a crashed worker, and partial input may
308
+ # still exist. Do not block --wait indefinitely or invite a new send.
309
+ return {"status": "unknown", "error":
310
+ "Claude PTY input stalled; this original delivery may be partial. "
311
+ "Worker left running; reconcile this delivery ID, do not resend"}
312
+ from ccx.ops.wait import wait_worker
313
+ verdict = wait_worker(worker, timeout=0)
314
+ state = verdict["status"]
315
+ original = observation.get("turn_ref") or receipt.get("turn_ref")
316
+ last = verdict.get("lastTurn") or {}
317
+ failures = {"failed", "interrupted", "cancelled"}
318
+ proven_failure = (
319
+ observation.get("native_status") in failures
320
+ or (original == f"ccx-turn:codex:v1:{last.get('turnId')}"
321
+ and last.get("status") in failures)
322
+ )
323
+ if worker.startswith("codex:") and isinstance(original, str) and proven_failure:
324
+ from ccx.codex.records import read_record
325
+ from ccx.store.root import GenerationError
326
+ try:
327
+ cap_stop = read_record(worker).get("quotaStop") or {}
328
+ except (GenerationError, OSError, ValueError):
329
+ cap_stop = {} # Recorded quota stop absent/unreadable: ordinary failure.
330
+ if (original == f"ccx-turn:codex:v1:{cap_stop.get('turnId')}"
331
+ and cap_stop.get("turnId")):
332
+ return {"status": "budget-stopped", "error": cap_stop.get("reason")}
333
+ if (original == f"ccx-turn:codex:v1:{last.get('turnId')}"
334
+ and last.get("status") in failures):
335
+ return {"status": last["status"], "error": last.get("error")}
336
+ if (worker.startswith("codex:") and isinstance(original, str)
337
+ and original.startswith("ccx-turn:codex:v1:")
338
+ and original != f"ccx-turn:codex:v1:{last.get('turnId')}"):
339
+ from ccx.codex.records import read_record
340
+ from ccx.ops.turn_reports import codex_native_turn_evidence
341
+
342
+ # A later turn replaces the latest projection, but cannot replace
343
+ # the original delivery's retained terminal evidence.
344
+ record = read_record(worker)
345
+ thread_id = (record.get("native") or {}).get("threadId")
346
+ proof = codex_native_turn_evidence(
347
+ worker, record, thread_id, original.removeprefix("ccx-turn:codex:v1:"),
348
+ ) if thread_id else None
349
+ if proof is not None and proof["status"] in failures:
350
+ return proof
351
+ settled = verdict.get("last_settled_turn") or {}
352
+ if (original and original == settled.get("turn_ref")
353
+ and settled.get("outcome") in failures):
354
+ return {"status": settled["outcome"], "error": settled.get("error")}
355
+ if state in {"crashed", "dead", "idle-killed", "host-dead",
356
+ "budget-stopped", "unavailable", "waiting"}:
357
+ return {"status": state, "error": verdict.get("error") or verdict.get("reason"),
358
+ "apiError": verdict.get("apiError"),
359
+ "pending_input": verdict.get("pending_input")}
360
+ # A native thread/read failure can be a transient resume snapshot.
361
+ # Only the original recorded terminal turn or settled receipt may end
362
+ # --wait; a later turn/completed can still prove this delivery succeeded.
363
+ return None