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.
- package/LICENSE +21 -0
- package/README.md +240 -4
- package/bin/ccx +55 -2
- package/ccx/__init__.py +167 -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 +807 -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 +269 -0
- package/ccx/claude/host/session.py +5244 -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 +2535 -0
- package/ccx/claude/usage.py +796 -0
- package/ccx/claude/usage_control.py +204 -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/models.py +53 -0
- package/ccx/cli/commands/read.py +1104 -0
- package/ccx/cli/commands/turns.py +1081 -0
- package/ccx/cli/controller.py +757 -0
- package/ccx/cli/main.py +302 -0
- package/ccx/cli/parser.py +1439 -0
- package/ccx/cli/skills.py +215 -0
- package/ccx/cli/statusline.py +160 -0
- package/ccx/client.py +1904 -0
- package/ccx/codex/__init__.py +0 -0
- package/ccx/codex/attach_tty.py +267 -0
- package/ccx/codex/client.py +738 -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 +1558 -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 +2424 -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/history_store.py +131 -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 +1454 -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/models.py +225 -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 +296 -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 +1593 -0
- package/ccx/ops/conversation_paging.py +1339 -0
- package/ccx/ops/create.py +56 -0
- package/ccx/ops/creation.py +952 -0
- package/ccx/ops/creation_steps.py +575 -0
- package/ccx/ops/event_feed.py +710 -0
- package/ccx/ops/forking.py +711 -0
- package/ccx/ops/history.py +1041 -0
- package/ccx/ops/input.py +556 -0
- package/ccx/ops/maintenance.py +295 -0
- package/ccx/ops/report.py +773 -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 +348 -0
- package/ccx/ops/wait.py +762 -0
- package/ccx/ops/worker_events.py +640 -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 +250 -0
- package/ccx/store/record_cache.py +40 -0
- package/ccx/store/records.py +309 -0
- package/ccx/store/root.py +789 -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/private_json.py +54 -0
- package/ccx/system/sanitize.py +43 -0
- package/ccx/types.py +809 -0
- package/ccx/version.py +29 -0
- package/ccx/workers.py +2093 -0
- package/ccx.py +9 -0
- package/package.json +39 -7
- package/pyproject.toml +87 -0
- package/skills/ccx/SKILL.md +166 -0
- package/skills/ccx/references/controllers.md +244 -0
- package/skills/ccx/references/history.md +50 -0
- package/skills/ccx/references/troubleshooting.md +94 -0
- 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
|