ccx-native 0.0.0-stage → 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 -2
- package/bin/ccx +56 -0
- 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 +40 -4
- 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,952 @@
|
|
|
1
|
+
"""Identity-first creation: one durable claim and one semantic state machine.
|
|
2
|
+
|
|
3
|
+
Provider-specific hooks below do only native record preparation and activation.
|
|
4
|
+
No display-name search, reservation or compatibility create path lives here.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import time
|
|
12
|
+
import uuid
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from ccx import constants
|
|
16
|
+
from ccx.constants import DEFAULT_IDLE_TIMEOUT_S
|
|
17
|
+
from ccx.errors import (
|
|
18
|
+
BudgetStopped, CreatePartialFailure, InvalidRequest, OperationConflict,
|
|
19
|
+
SessionUnavailable, SessionWaiting, TurnIncomplete,
|
|
20
|
+
)
|
|
21
|
+
from ccx.controllers import live_vars
|
|
22
|
+
from ccx.store.root import CreationConflict
|
|
23
|
+
from ccx.store.identity import OwnerConflict, IdentityError
|
|
24
|
+
|
|
25
|
+
def new_worker_id() -> str:
|
|
26
|
+
"""Mint one complete, canonical, caller-persistable UUIDv4 without IO."""
|
|
27
|
+
return str(uuid.uuid4())
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _bind_intent(attachment: live_vars.PreparedAttachment | None) -> dict:
|
|
31
|
+
if attachment is None:
|
|
32
|
+
return {"mode": "none"}
|
|
33
|
+
if attachment.mode == "live":
|
|
34
|
+
return {
|
|
35
|
+
"mode": "live", "names": sorted(attachment.names),
|
|
36
|
+
"owner": attachment.owner.public_identity() if attachment.owner else None,
|
|
37
|
+
}
|
|
38
|
+
if attachment.state_id is not None:
|
|
39
|
+
return {"mode": "durable-shared", "stateId": attachment.state_id}
|
|
40
|
+
return {"mode": "durable", "values": attachment.values or {}}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _bind_once(store, engine: str, worker_id: str,
|
|
44
|
+
attachment: live_vars.PreparedAttachment | None) -> list[str]:
|
|
45
|
+
"""Commit the first variable intent after its worker record, before native effects.
|
|
46
|
+
|
|
47
|
+
This receipt is independent of the immutable creation fingerprint. A
|
|
48
|
+
matching create retry must never republish mutable durable or live values.
|
|
49
|
+
"""
|
|
50
|
+
from ccx.store.root import _read_json, _write_json
|
|
51
|
+
|
|
52
|
+
intent = _bind_intent(attachment)
|
|
53
|
+
digest = hashlib.sha256(json.dumps(
|
|
54
|
+
intent, sort_keys=True, separators=(",", ":"), allow_nan=False,
|
|
55
|
+
).encode("utf-8")).hexdigest()
|
|
56
|
+
with store.lease():
|
|
57
|
+
with store._claim_lock(worker_id):
|
|
58
|
+
_owner, record = store._owned(f"{engine}:{worker_id}")
|
|
59
|
+
claim = store._claim(worker_id)
|
|
60
|
+
if (claim is None or claim.get("workerId") != worker_id
|
|
61
|
+
or claim.get("engine") != engine
|
|
62
|
+
or claim.get("generation") != store.generation
|
|
63
|
+
or claim.get("createSha256") != record.get("createSha256")):
|
|
64
|
+
raise SessionUnavailable("The worker could not be created; inspect its saved worker ID",
|
|
65
|
+
short=worker_id[:8])
|
|
66
|
+
with store._gen("variables", "bindings", engine, create=True) as directory:
|
|
67
|
+
prior = _read_json(directory, worker_id + ".json")
|
|
68
|
+
if prior is not None:
|
|
69
|
+
if prior.get("schemaVersion") != 1 or prior.get("sha256") != digest:
|
|
70
|
+
raise OperationConflict(
|
|
71
|
+
"initial variables intent differs from first binding",
|
|
72
|
+
short=worker_id[:8], reason="initial_variables_mismatch",
|
|
73
|
+
worker_id=worker_id,
|
|
74
|
+
)
|
|
75
|
+
names = prior.get("names")
|
|
76
|
+
if (not isinstance(names, list) or any(not isinstance(n, str)
|
|
77
|
+
for n in names)):
|
|
78
|
+
raise SessionUnavailable("initial variable receipt has invalid names",
|
|
79
|
+
short=worker_id[:8])
|
|
80
|
+
return list(names)
|
|
81
|
+
if live_vars.read_sidecar(engine, worker_id) is not None:
|
|
82
|
+
# The first sidecar may have landed before its receipt, or
|
|
83
|
+
# a later SEND may own it. Never guess/reapply first values.
|
|
84
|
+
raise SessionUnavailable(
|
|
85
|
+
f"first variable binding for {engine}:{worker_id} lacks commit proof",
|
|
86
|
+
short=worker_id[:8],
|
|
87
|
+
)
|
|
88
|
+
if attachment is not None:
|
|
89
|
+
activation = claim.get("nativeActivation")
|
|
90
|
+
if (not isinstance(activation, dict)
|
|
91
|
+
or activation.get("state") not in {"unprepared", "prepared"}):
|
|
92
|
+
raise SessionUnavailable(
|
|
93
|
+
"Worker creation has already been submitted; "
|
|
94
|
+
"first variables cannot be attached retroactively",
|
|
95
|
+
short=worker_id[:8],
|
|
96
|
+
)
|
|
97
|
+
live_vars.publish_attachment(attachment, engine, worker_id)
|
|
98
|
+
names = sorted(attachment.names) if attachment else []
|
|
99
|
+
_write_json(directory, worker_id + ".json", {
|
|
100
|
+
"schemaVersion": 1, "sha256": digest, "names": names,
|
|
101
|
+
})
|
|
102
|
+
return names
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _require_codex_fork_ready(store, original, record: dict) -> dict:
|
|
106
|
+
"""Offline original fork proof; never require a live daemon/held reply."""
|
|
107
|
+
anchor = original.record.get("forkAnchor")
|
|
108
|
+
if anchor is None:
|
|
109
|
+
return record
|
|
110
|
+
import hashlib
|
|
111
|
+
from ccx.store.root import NativeEffectIndeterminate, _read_json, _flock_at, _HASH
|
|
112
|
+
from ccx.store.store import _digest, fingerprint_v2
|
|
113
|
+
|
|
114
|
+
wid = original.worker_id
|
|
115
|
+
native = record.get("native") or {}
|
|
116
|
+
lineage = record.get("forkedFrom")
|
|
117
|
+
from ccx.store.store import codex_fork_lineage
|
|
118
|
+
expected_lineage = codex_fork_lineage(anchor, child_id=original.worker_id)
|
|
119
|
+
activation = store.native_activation(original.worker)
|
|
120
|
+
bound = activation.get("nativeBinding") or {}
|
|
121
|
+
thread_id = bound.get("threadId")
|
|
122
|
+
if (not isinstance(anchor, dict) or anchor.get("engine") != "codex"
|
|
123
|
+
or record.get("forkAnchor") != anchor
|
|
124
|
+
or not isinstance(lineage, dict)
|
|
125
|
+
or lineage != expected_lineage
|
|
126
|
+
or not isinstance(anchor.get("createArgs"), dict)
|
|
127
|
+
or anchor.get("cwd") != record.get("cwd")
|
|
128
|
+
or fingerprint_v2(engine="codex", cwd=anchor["cwd"],
|
|
129
|
+
create_args=anchor["createArgs"],
|
|
130
|
+
fork_anchor=anchor) != record.get("createSha256")
|
|
131
|
+
or activation.get("state") != "confirmed"
|
|
132
|
+
or activation.get("source") != original.native_source
|
|
133
|
+
or not isinstance(activation.get("submissionId"), str)
|
|
134
|
+
or not activation["submissionId"]
|
|
135
|
+
or not isinstance(thread_id, str) or not thread_id
|
|
136
|
+
or thread_id == anchor.get("sourceNativeThreadId")
|
|
137
|
+
or native.get("threadId") != thread_id
|
|
138
|
+
or native.get("threadSource") != original.native_source
|
|
139
|
+
or native.get("nativeState") != "thread-bound"):
|
|
140
|
+
raise NativeEffectIndeterminate(
|
|
141
|
+
f"Codex fork {wid} lacks original child/anchor/activation proof; never re-fork"
|
|
142
|
+
)
|
|
143
|
+
token = activation["submissionId"]
|
|
144
|
+
key = hashlib.sha256(("activate\x00" + token).encode()).hexdigest()
|
|
145
|
+
with store.lease():
|
|
146
|
+
owner, claim = store._owner(wid), store._claim(wid)
|
|
147
|
+
if not isinstance(owner, dict) or not isinstance(claim, dict):
|
|
148
|
+
raise NativeEffectIndeterminate("Codex fork immutable original owner/claim absent")
|
|
149
|
+
store._check_original_create_intent(
|
|
150
|
+
owner, engine="codex", worker_id=wid,
|
|
151
|
+
fingerprint=record["createSha256"], native={}, anchor=anchor,
|
|
152
|
+
)
|
|
153
|
+
if (claim.get("forkAnchor") != anchor
|
|
154
|
+
or claim.get("createSha256") != record["createSha256"]
|
|
155
|
+
or claim.get("fingerprintVersion") != 2
|
|
156
|
+
or claim.get("nativeBinding") != {}
|
|
157
|
+
or claim.get("nativeActivation") != activation):
|
|
158
|
+
raise NativeEffectIndeterminate("Codex fork original immutable claim changed")
|
|
159
|
+
try:
|
|
160
|
+
with store._gen("runtime-metadata", "codex-native-effects", wid) as directory:
|
|
161
|
+
with _flock_at(directory, key + ".lock", exclusive=False,
|
|
162
|
+
create=False, timeout=.5):
|
|
163
|
+
effect = _read_json(directory, key + ".json")
|
|
164
|
+
except FileNotFoundError as exc:
|
|
165
|
+
raise NativeEffectIndeterminate("Codex fork original effect marker absent") from exc
|
|
166
|
+
candidate = effect.get("nativeCandidate") if isinstance(effect, dict) else None
|
|
167
|
+
if (not isinstance(effect, dict) or not isinstance(candidate, dict)
|
|
168
|
+
or any(effect.get(k) != v for k, v in {
|
|
169
|
+
"workerId": wid, "engine": "codex", "generation": store.generation,
|
|
170
|
+
"kind": "activate", "token": token, "state": "submitted-unknown",
|
|
171
|
+
"source": original.native_source,
|
|
172
|
+
"createSha256": record["createSha256"],
|
|
173
|
+
"nativeMethod": "thread/fork", "forkAnchor": anchor,
|
|
174
|
+
}.items())
|
|
175
|
+
or effect.get("nativeRejected") is not None
|
|
176
|
+
or effect.get("nativeNotSubmitted") is not None
|
|
177
|
+
or not isinstance(effect.get("daemonInstanceId"), str)
|
|
178
|
+
or not effect["daemonInstanceId"]
|
|
179
|
+
or type(effect.get("daemonPid")) is not int or effect["daemonPid"] <= 0
|
|
180
|
+
or type(effect.get("appServerPid")) is not int or effect["appServerPid"] <= 0
|
|
181
|
+
or candidate.get("threadId") != thread_id
|
|
182
|
+
or candidate.get("submittedSource") != original.native_source
|
|
183
|
+
or candidate.get("appServerPid") != effect["appServerPid"]
|
|
184
|
+
or not isinstance(candidate.get("nativeReplySha256"), str)
|
|
185
|
+
or not _HASH.fullmatch(candidate["nativeReplySha256"])):
|
|
186
|
+
raise NativeEffectIndeterminate("Codex fork records disagree; inspect the worker before retrying")
|
|
187
|
+
# All inputs are stable original marker/claim facts. A successful provider
|
|
188
|
+
# read was mandatory before the claim could receive this precise digest;
|
|
189
|
+
# no current PID, held-slot, native probe or mutable child setting is used.
|
|
190
|
+
evidence = {
|
|
191
|
+
"workerId": wid, "threadSource": original.native_source,
|
|
192
|
+
"threadId": thread_id, "submissionId": token,
|
|
193
|
+
"daemonPid": effect["daemonPid"],
|
|
194
|
+
"appServerPid": effect["appServerPid"],
|
|
195
|
+
"nativeMethod": "thread/fork", "forkAnchor": anchor,
|
|
196
|
+
"forkedFromId": anchor["sourceNativeThreadId"],
|
|
197
|
+
"lastTurnId": anchor["lastTurnId"], "lastTurnStatus": "completed",
|
|
198
|
+
"nativeReplySha256": candidate["nativeReplySha256"],
|
|
199
|
+
}
|
|
200
|
+
if (activation.get("nativeEvidenceSha256") != _digest(evidence)
|
|
201
|
+
or not _HASH.fullmatch(activation["nativeEvidenceSha256"])):
|
|
202
|
+
raise NativeEffectIndeterminate("Codex fork records changed; inspect the worker before retrying")
|
|
203
|
+
return record
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _complete_codex_create(store, original, info: dict, *, start_timeout, deadline):
|
|
207
|
+
"""Complete only the one original Codex start; submitted fate is observational."""
|
|
208
|
+
from ccx.codex.runtime import daemon_rpc, ensure_daemon
|
|
209
|
+
from ccx.system.engine_binary import resolve_engine_binary
|
|
210
|
+
from ccx.constants import CODEXD_RPC_GENERATION
|
|
211
|
+
from ccx.ops.creation_steps import dispatch_prepared_codex_native, reconcile_submitted_native
|
|
212
|
+
from ccx.store.root import CreationIndeterminate, NativeEffectIndeterminate
|
|
213
|
+
|
|
214
|
+
def terminal_proof():
|
|
215
|
+
# Durable provider errors and pre-IPC refusals are different fates.
|
|
216
|
+
rejected = store.reconcile_codex_native_rejection(original.worker)
|
|
217
|
+
if rejected is not None:
|
|
218
|
+
info["native_rejection"] = {
|
|
219
|
+
"code": rejected["code"], "message": rejected["message"],
|
|
220
|
+
}
|
|
221
|
+
info["activation_state"] = "rejected"
|
|
222
|
+
raise NativeEffectIndeterminate("original Codex provider rejected " + (
|
|
223
|
+
"thread/fork" if original.record.get("forkAnchor") else "thread/start"))
|
|
224
|
+
local = store.reconcile_codex_local_refusal(original.worker)
|
|
225
|
+
if local is not None:
|
|
226
|
+
info["native_local_refusal"] = {
|
|
227
|
+
"reason": local["reason"], "stage": local["stage"], "proven": True,
|
|
228
|
+
}
|
|
229
|
+
info["activation_state"] = "local-refused"
|
|
230
|
+
raise NativeEffectIndeterminate("Codex refused to start; create a new worker ID to retry")
|
|
231
|
+
|
|
232
|
+
def remaining():
|
|
233
|
+
return float('inf') if deadline is None else deadline - time.monotonic()
|
|
234
|
+
|
|
235
|
+
terminal_proof() # sealed terminal claims must replay without a daemon/binary
|
|
236
|
+
activation = store.native_activation(original.worker)
|
|
237
|
+
if activation["state"] == "confirmed":
|
|
238
|
+
return _require_codex_fork_ready(
|
|
239
|
+
store, original, store.bind_confirmed_native(original.worker))
|
|
240
|
+
try:
|
|
241
|
+
resolve_engine_binary("codex")
|
|
242
|
+
except SessionUnavailable as error:
|
|
243
|
+
raise NativeEffectIndeterminate(
|
|
244
|
+
"Codex executable not found; set CCX_CODEX_BIN or PATH and retry"
|
|
245
|
+
) from error
|
|
246
|
+
if remaining() <= 0:
|
|
247
|
+
raise NativeEffectIndeterminate("Codex startup timed out; inspect this worker instead of starting it twice")
|
|
248
|
+
# The shared app-server may need longer than four seconds to initialize on
|
|
249
|
+
# a cold, loaded machine. Both concurrent first users must wait for the
|
|
250
|
+
# *same* codexd within their caller-selected native start observation
|
|
251
|
+
# budget, rather than expiring an unrelated fixed four-second boot timer.
|
|
252
|
+
ensure_daemon(deadline=min(time.monotonic() + start_timeout,
|
|
253
|
+
deadline if deadline is not None else float('inf')))
|
|
254
|
+
|
|
255
|
+
def submit_original(budget):
|
|
256
|
+
admitted_until = min(time.monotonic() + budget,
|
|
257
|
+
deadline if deadline is not None else float('inf'))
|
|
258
|
+
if time.monotonic() >= admitted_until:
|
|
259
|
+
return {"ok": False, "error": "Codex startup timed out; check worker status"}
|
|
260
|
+
claim = store.native_activation(original.worker)
|
|
261
|
+
answer = daemon_rpc({
|
|
262
|
+
"op": "native-activate", "workerId": original.worker_id,
|
|
263
|
+
"submissionId": claim["submissionId"],
|
|
264
|
+
"nativeAdmissionDeadline": admitted_until,
|
|
265
|
+
"rpcGeneration": CODEXD_RPC_GENERATION,
|
|
266
|
+
}, timeout=max(0., admitted_until - time.monotonic()))
|
|
267
|
+
if (answer.get("transport") == "served"
|
|
268
|
+
and isinstance(answer.get("localRefusalReason"), str)):
|
|
269
|
+
info["native_local_refusal"] = {
|
|
270
|
+
"reason": answer["localRefusalReason"], "proven": False,
|
|
271
|
+
}
|
|
272
|
+
return answer
|
|
273
|
+
|
|
274
|
+
if activation["state"] != "submitted-unknown":
|
|
275
|
+
try:
|
|
276
|
+
budget = min(2., remaining())
|
|
277
|
+
if budget <= 0:
|
|
278
|
+
raise NativeEffectIndeterminate("Codex startup timed out; check worker status; no replay")
|
|
279
|
+
dispatch_prepared_codex_native(
|
|
280
|
+
store, original, bounded_native_submit=submit_original,
|
|
281
|
+
dispatch_budget_s=budget,
|
|
282
|
+
)
|
|
283
|
+
except CreationIndeterminate as contention:
|
|
284
|
+
if str(contention) not in {
|
|
285
|
+
"native activation already submitted or claimed by another caller",
|
|
286
|
+
"native activation already submitted/settled/ambiguous",
|
|
287
|
+
}:
|
|
288
|
+
raise
|
|
289
|
+
advanced = store.reserve_creation(
|
|
290
|
+
original.worker_id, engine="codex",
|
|
291
|
+
fingerprint=original.record["createSha256"], native_binding={},
|
|
292
|
+
fork_anchor=original.record.get("forkAnchor"),
|
|
293
|
+
)
|
|
294
|
+
claim = store.native_activation(original.worker)
|
|
295
|
+
if (advanced.activation_state not in {
|
|
296
|
+
"submitted-unknown", "confirmed", "rejected", "local-refused"
|
|
297
|
+
} or advanced.submission_id != claim.get("submissionId")
|
|
298
|
+
or claim.get("source") != original.native_source
|
|
299
|
+
or not isinstance(advanced.submission_id, str)
|
|
300
|
+
or not advanced.submission_id):
|
|
301
|
+
raise contention
|
|
302
|
+
|
|
303
|
+
observe_until = min(time.monotonic() + start_timeout,
|
|
304
|
+
deadline if deadline is not None else float('inf'))
|
|
305
|
+
while True:
|
|
306
|
+
claim = store.native_activation(original.worker)
|
|
307
|
+
if claim["state"] == "confirmed":
|
|
308
|
+
return _require_codex_fork_ready(
|
|
309
|
+
store, original, store.bind_confirmed_native(original.worker))
|
|
310
|
+
if claim["state"] in {"rejected", "local-refused"}:
|
|
311
|
+
terminal_proof()
|
|
312
|
+
remaining_observation = observe_until - time.monotonic()
|
|
313
|
+
if remaining_observation <= 0:
|
|
314
|
+
raise NativeEffectIndeterminate(
|
|
315
|
+
"original Codex " + (
|
|
316
|
+
"thread/fork" if original.record.get("forkAnchor") else "thread/start")
|
|
317
|
+
+ " is still submitted-unknown; "
|
|
318
|
+
"retain original full worker UUID; never replay"
|
|
319
|
+
)
|
|
320
|
+
observed = daemon_rpc({
|
|
321
|
+
"op": "native-activation-status", "workerId": original.worker_id,
|
|
322
|
+
"submissionId": claim["submissionId"],
|
|
323
|
+
"rpcGeneration": CODEXD_RPC_GENERATION,
|
|
324
|
+
}, timeout=min(3., remaining_observation))
|
|
325
|
+
if observed.get("localRefused") or observed.get("rejected"):
|
|
326
|
+
terminal_proof()
|
|
327
|
+
if observed.get("accepted") is True:
|
|
328
|
+
if time.monotonic() >= observe_until:
|
|
329
|
+
raise NativeEffectIndeterminate("Codex startup timed out; check the worker before retrying")
|
|
330
|
+
return _require_codex_fork_ready(
|
|
331
|
+
store, original, reconcile_submitted_native(
|
|
332
|
+
store, original, original_receipt=observed,
|
|
333
|
+
).record)
|
|
334
|
+
if observed.get("ok") is not True:
|
|
335
|
+
# A peer could have confirmed during status; never re-submit.
|
|
336
|
+
state = store.native_activation(original.worker)["state"]
|
|
337
|
+
if state == "confirmed":
|
|
338
|
+
return _require_codex_fork_ready(
|
|
339
|
+
store, original, store.bind_confirmed_native(original.worker))
|
|
340
|
+
if state in {"rejected", "local-refused"}:
|
|
341
|
+
terminal_proof()
|
|
342
|
+
raise NativeEffectIndeterminate(
|
|
343
|
+
"Codex did not confirm starting; check the worker: "
|
|
344
|
+
+ str(observed.get("error") or "native evidence unavailable")
|
|
345
|
+
)
|
|
346
|
+
if time.monotonic() >= observe_until:
|
|
347
|
+
raise NativeEffectIndeterminate(
|
|
348
|
+
"original Codex " + (
|
|
349
|
+
"thread/fork" if original.record.get("forkAnchor") else "thread/start")
|
|
350
|
+
+ " is still submitted-unknown; "
|
|
351
|
+
"retain original full worker UUID; never replay"
|
|
352
|
+
)
|
|
353
|
+
time.sleep(min(.1, max(0., observe_until - time.monotonic())))
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _require_claude_fork_ready(store, original, record: dict) -> dict:
|
|
357
|
+
"""A local transcript copy and a genuine original SessionStart are separate.
|
|
358
|
+
|
|
359
|
+
The original claim is only confirmed from the authenticated host proof;
|
|
360
|
+
retain its durable proof without requiring a still-live host on ID retry.
|
|
361
|
+
"""
|
|
362
|
+
if original.record.get("forkAnchor") is None:
|
|
363
|
+
return record
|
|
364
|
+
from ccx.ops.forking import _claude_copy_proof
|
|
365
|
+
from ccx.store.root import NativeEffectIndeterminate
|
|
366
|
+
from ccx.store.store import _digest
|
|
367
|
+
activation = store.native_activation(original.worker)
|
|
368
|
+
start = (record.get("lifecycle") or {}).get("nativeStartProof")
|
|
369
|
+
native_sid = (record.get("native") or {}).get("sessionId")
|
|
370
|
+
source = f"claude-session:{native_sid}"
|
|
371
|
+
if (record.get("forkAnchor") != original.record.get("forkAnchor")
|
|
372
|
+
or _claude_copy_proof(record) is None
|
|
373
|
+
or activation.get("state") != "confirmed"
|
|
374
|
+
or activation.get("source") != source
|
|
375
|
+
or (activation.get("nativeBinding") or {}).get("sessionId") != native_sid
|
|
376
|
+
or not isinstance(start, dict)
|
|
377
|
+
or start.get("workerId") != original.worker_id
|
|
378
|
+
or start.get("engine") != "claude"
|
|
379
|
+
or start.get("generation") != store.generation
|
|
380
|
+
or start.get("nativeSource") != source
|
|
381
|
+
or start.get("sessionId") != native_sid
|
|
382
|
+
or start.get("submissionId") != activation.get("submissionId")
|
|
383
|
+
or start.get("nativeEvidenceSha256") != _digest({
|
|
384
|
+
key: value for key, value in start.items()
|
|
385
|
+
if key != "nativeEvidenceSha256"
|
|
386
|
+
})):
|
|
387
|
+
raise NativeEffectIndeterminate(
|
|
388
|
+
f"Claude fork {original.worker_id} lacks exact copy and native SessionStart proofs; "
|
|
389
|
+
"retain original worker UUID, never recopy or restart"
|
|
390
|
+
)
|
|
391
|
+
return record
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _complete_claude_create(store, original, *, start_timeout, deadline):
|
|
395
|
+
"""Admit only the original Claude host, then verify its exact SessionStart."""
|
|
396
|
+
from ccx.claude.spawn import submit_original_claude_start, verified_original_claude_start
|
|
397
|
+
from ccx.ops.creation_steps import activate_prepared_worker, reconcile_submitted_native
|
|
398
|
+
from ccx.store.root import CreationIndeterminate, NativeEffectIndeterminate
|
|
399
|
+
|
|
400
|
+
activation = store.native_activation(original.worker)
|
|
401
|
+
if activation["state"] == "confirmed":
|
|
402
|
+
return _require_claude_fork_ready(
|
|
403
|
+
store, original, store.bind_confirmed_native(original.worker))
|
|
404
|
+
if activation["state"] not in {"unprepared", "prepared", "submitted-unknown"}:
|
|
405
|
+
raise NativeEffectIndeterminate("Claude is not ready to start; check worker status")
|
|
406
|
+
if activation["state"] != "submitted-unknown":
|
|
407
|
+
if original.record.get("origin") == "ccx-import":
|
|
408
|
+
from ccx.claude.external import exact_import_candidate, import_eligibility
|
|
409
|
+
from ccx.store.paths import transcript_path_for
|
|
410
|
+
sid = original.record["native"]["sessionId"]
|
|
411
|
+
source = exact_import_candidate(sid)
|
|
412
|
+
ok, _code, why = import_eligibility(source)
|
|
413
|
+
if (not ok or source["cwd"] != original.record["cwd"]
|
|
414
|
+
or source["transcript"] != original.record["transcript"]
|
|
415
|
+
or source["transcript"] != str(transcript_path_for(source["cwd"], sid))):
|
|
416
|
+
raise SessionUnavailable("import source changed before first managed resume: "
|
|
417
|
+
+ (why if not ok else "original transcript/cwd differs"))
|
|
418
|
+
from ccx.system.engine_binary import resolve_engine_binary
|
|
419
|
+
try:
|
|
420
|
+
resolve_engine_binary("claude")
|
|
421
|
+
except SessionUnavailable as error:
|
|
422
|
+
raise NativeEffectIndeterminate(
|
|
423
|
+
"Claude executable not found; set CCX_CLAUDE_BIN or PATH and retry"
|
|
424
|
+
) from error
|
|
425
|
+
rem = float('inf') if deadline is None else deadline - time.monotonic()
|
|
426
|
+
# A real host proof requires a bounded local status handshake. Do not
|
|
427
|
+
# deliberately enter a >.15s launcher on a .15s protocol deadline.
|
|
428
|
+
if rem < .3:
|
|
429
|
+
raise NativeEffectIndeterminate("Claude could not start before timeout; retry with more time")
|
|
430
|
+
from ccx.claude.settings import ensure_trusted_paths
|
|
431
|
+
from ccx.errors import SpawnFailed
|
|
432
|
+
try:
|
|
433
|
+
ensure_trusted_paths([
|
|
434
|
+
original.record["cwd"],
|
|
435
|
+
*((original.record.get("createArgs") or {}).get("add_dir") or []),
|
|
436
|
+
])
|
|
437
|
+
except OSError as exc:
|
|
438
|
+
raise SpawnFailed(f"Claude trust settings could not be written: {exc}", short=original.worker_id[:8],
|
|
439
|
+
phase="settings-io", detail=str(exc)) from exc
|
|
440
|
+
try:
|
|
441
|
+
accepted = activate_prepared_worker(
|
|
442
|
+
store, original,
|
|
443
|
+
bounded_native_submit=lambda budget: submit_original_claude_start(
|
|
444
|
+
store, original, budget, deadline=deadline),
|
|
445
|
+
dispatch_budget_s=min(2., rem),
|
|
446
|
+
)
|
|
447
|
+
except NativeEffectIndeterminate:
|
|
448
|
+
# Original submitted-unknown may already have produced an
|
|
449
|
+
# authentic host SessionStart after its short admission budget.
|
|
450
|
+
# Observe that same submission below, never dispatch again.
|
|
451
|
+
if store.native_activation(original.worker)["state"] != "submitted-unknown":
|
|
452
|
+
raise
|
|
453
|
+
except CreationIndeterminate as contention:
|
|
454
|
+
if str(contention) not in {
|
|
455
|
+
"native activation already submitted or claimed by another caller",
|
|
456
|
+
"native activation already submitted/settled/ambiguous",
|
|
457
|
+
}:
|
|
458
|
+
raise
|
|
459
|
+
binding = {"sessionId": original.record["native"]["sessionId"]}
|
|
460
|
+
advanced = store.reserve_creation(
|
|
461
|
+
original.worker_id, engine="claude",
|
|
462
|
+
fingerprint=original.record["createSha256"], native_binding=binding,
|
|
463
|
+
fork_anchor=original.record.get("forkAnchor"),
|
|
464
|
+
provenance=("ccx-import" if original.record.get("origin") == "ccx-import"
|
|
465
|
+
else "ccx-new"),
|
|
466
|
+
imported_from=original.record.get("importedFrom"),
|
|
467
|
+
)
|
|
468
|
+
claim = store.native_activation(original.worker)
|
|
469
|
+
if (advanced.activation_state not in {"submitted-unknown", "confirmed"}
|
|
470
|
+
or advanced.submission_id != claim.get("submissionId")
|
|
471
|
+
or claim.get("source") != original.native_source
|
|
472
|
+
or not isinstance(advanced.submission_id, str)
|
|
473
|
+
or not advanced.submission_id):
|
|
474
|
+
raise contention
|
|
475
|
+
else:
|
|
476
|
+
if accepted.activation_state == "confirmed":
|
|
477
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
478
|
+
raise NativeEffectIndeterminate(
|
|
479
|
+
"original Claude SessionStart confirmed after request deadline; no replay"
|
|
480
|
+
)
|
|
481
|
+
return _require_claude_fork_ready(store, original, accepted.record)
|
|
482
|
+
|
|
483
|
+
observe_until = min(time.monotonic() + start_timeout,
|
|
484
|
+
deadline if deadline is not None else float('inf'))
|
|
485
|
+
while True:
|
|
486
|
+
claim = store.native_activation(original.worker)
|
|
487
|
+
if claim["state"] == "confirmed":
|
|
488
|
+
return _require_claude_fork_ready(
|
|
489
|
+
store, original, store.bind_confirmed_native(original.worker))
|
|
490
|
+
remaining = observe_until - time.monotonic()
|
|
491
|
+
if remaining <= 0:
|
|
492
|
+
raise NativeEffectIndeterminate("Claude startup is not confirmed; check worker status before retrying")
|
|
493
|
+
proof = verified_original_claude_start(store, original, deadline=observe_until)
|
|
494
|
+
if proof:
|
|
495
|
+
if time.monotonic() >= observe_until:
|
|
496
|
+
raise NativeEffectIndeterminate("Claude startup timed out; inspect worker status before retrying")
|
|
497
|
+
bound = reconcile_submitted_native(
|
|
498
|
+
store, original, original_receipt=proof,
|
|
499
|
+
).record
|
|
500
|
+
return _require_claude_fork_ready(store, original, bound)
|
|
501
|
+
time.sleep(min(.025, max(0., observe_until - time.monotonic())))
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _finalize_empty_codex_create(ctx, result: dict, states=("prepared",)) -> None:
|
|
505
|
+
"""Apply the promptless Codex empty-history proof to a bound, turnless worker.
|
|
506
|
+
|
|
507
|
+
The native admission path may already have loaded the thread into idle
|
|
508
|
+
before refusing its first turn. Both states carry the same original
|
|
509
|
+
thread/start proof; neither is evidence that a turn was delivered.
|
|
510
|
+
"""
|
|
511
|
+
latest = ctx.record(result["worker"])
|
|
512
|
+
native = latest.get("native") or {}
|
|
513
|
+
lc = latest.get("lifecycle") or {}
|
|
514
|
+
state = lc.get("state")
|
|
515
|
+
if (ctx.native_activation(result["worker"])["state"] == "confirmed"
|
|
516
|
+
and native.get("nativeState") == "thread-bound"
|
|
517
|
+
and native.get("threadSource") == ctx.codex_source(result["worker"])
|
|
518
|
+
and state in states
|
|
519
|
+
and not lc.get("activeTurnId")
|
|
520
|
+
and not latest.get("pendingSend")
|
|
521
|
+
and not latest.get("activeCompact")
|
|
522
|
+
and not latest.get("nativeTurnStart")):
|
|
523
|
+
inherited = latest.get("forkAnchor") is not None
|
|
524
|
+
turns = (latest.get("stats") or {}).get("turns")
|
|
525
|
+
empty = (not inherited and turns == 0 and not latest.get("lastTurn"))
|
|
526
|
+
from ccx.store.root import RecordConflict
|
|
527
|
+
patches = {"historyKnownEmpty": empty}
|
|
528
|
+
if state == "prepared":
|
|
529
|
+
patches["lifecycle.state"] = "idle"
|
|
530
|
+
try:
|
|
531
|
+
latest = ctx.mutate_record(
|
|
532
|
+
result["worker"], patches=patches,
|
|
533
|
+
expect={"lifecycle.state": state,
|
|
534
|
+
"lifecycle.activeTurnId": None},
|
|
535
|
+
)
|
|
536
|
+
except RecordConflict:
|
|
537
|
+
# A concurrent original activation may already be idle.
|
|
538
|
+
latest = ctx.record(result["worker"])
|
|
539
|
+
if (latest.get("lifecycle") or {}).get("state") != "idle":
|
|
540
|
+
raise
|
|
541
|
+
result["record"] = latest
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def create(*, prompt: str | None = None,
|
|
545
|
+
caps: dict | None = None, wait: bool = False,
|
|
546
|
+
delivery_id: str | None = None,
|
|
547
|
+
parent: dict | None = None, settle: bool = False,
|
|
548
|
+
start_timeout: float | None = None,
|
|
549
|
+
expected_generation: str | None = None,
|
|
550
|
+
deadline: float | None = None,
|
|
551
|
+
**options: Any) -> dict:
|
|
552
|
+
"""One proven full-UUID native create for CLI, Python and local protocol."""
|
|
553
|
+
from math import isfinite
|
|
554
|
+
from ccx.store.root import require_ccx_active, GenerationError, NativeEffectIndeterminate
|
|
555
|
+
from ccx.ops.creation_steps import prepare_worker, validated_create_spec
|
|
556
|
+
from ccx.store.identity import InvalidTarget, canonical_worker_id, mint_worker_id
|
|
557
|
+
|
|
558
|
+
if prompt is not None and (not isinstance(prompt, str) or not prompt.strip()):
|
|
559
|
+
raise InvalidRequest("initial prompt must be nonblank text; no identity claimed")
|
|
560
|
+
cap = caps
|
|
561
|
+
if cap is not None:
|
|
562
|
+
from ccx.workers import _check_caps
|
|
563
|
+
_check_caps(cap, engine=options.get("engine"))
|
|
564
|
+
if not prompt:
|
|
565
|
+
raise InvalidRequest("initial cap requires a prompt; no identity claimed")
|
|
566
|
+
if delivery_id is not None and (prompt is None or delivery_id != "create:initial"):
|
|
567
|
+
raise InvalidRequest(
|
|
568
|
+
"initial delivery_id must be create:initial with a prompt; no identity claimed"
|
|
569
|
+
)
|
|
570
|
+
if wait and prompt is None:
|
|
571
|
+
raise InvalidRequest("initial-turn wait needs a prompt; no identity claimed")
|
|
572
|
+
imported_sid = options.get("import_session_id")
|
|
573
|
+
imported_from = options.get("imported_from")
|
|
574
|
+
if (imported_sid is not None or imported_from is not None) and (
|
|
575
|
+
options.get("engine") != "claude" or prompt is not None
|
|
576
|
+
or options.get("created_via") != "import"
|
|
577
|
+
or not isinstance(imported_sid, str)
|
|
578
|
+
or not isinstance(imported_from, dict)):
|
|
579
|
+
raise InvalidRequest("Import requires a Claude session without a pending prompt")
|
|
580
|
+
turn_timeout = options.get("timeout")
|
|
581
|
+
if turn_timeout is not None and (
|
|
582
|
+
not wait or type(turn_timeout) not in (int, float)
|
|
583
|
+
or not isfinite(turn_timeout) or turn_timeout <= 0):
|
|
584
|
+
raise InvalidRequest(
|
|
585
|
+
"initial-turn timeout requires wait and positive finite seconds; "
|
|
586
|
+
"no identity claimed"
|
|
587
|
+
)
|
|
588
|
+
if options.get("engine") == "claude" and settle:
|
|
589
|
+
raise InvalidRequest(
|
|
590
|
+
"recursive settlement currently requires a Codex target; no identity claimed"
|
|
591
|
+
)
|
|
592
|
+
if settle and parent is None:
|
|
593
|
+
raise InvalidRequest("--settle requires --notify-parent; no identity claimed")
|
|
594
|
+
if parent is not None and (wait or not isinstance(parent, dict)):
|
|
595
|
+
raise InvalidRequest("Notifications cannot be combined with waiting for creation; choose one")
|
|
596
|
+
if options.get("fork") is not None:
|
|
597
|
+
raise SessionUnavailable(
|
|
598
|
+
"This fork format is not supported; "
|
|
599
|
+
"no identity claimed"
|
|
600
|
+
)
|
|
601
|
+
# Preserve the old explicit preparation signature: never expose lower test hooks.
|
|
602
|
+
unexpected = sorted(set(options) - {
|
|
603
|
+
"engine", "cwd", "worker_id", "name", "model", "effort",
|
|
604
|
+
"idle_timeout", "permissions", "sandbox", "approval_policy", "agent",
|
|
605
|
+
"append_system_prompt", "allowed_tools", "disallowed_tools",
|
|
606
|
+
"add_dir", "permission_mode", "created_via", "import_session_id",
|
|
607
|
+
"imported_from", "fork", "_fork_anchor", "timeout", "attachment",
|
|
608
|
+
})
|
|
609
|
+
if unexpected:
|
|
610
|
+
raise TypeError(f"create() got unexpected keyword arguments: {unexpected}")
|
|
611
|
+
# Attachment is internal to creation; the lower builder accepts only its intent.
|
|
612
|
+
attachment = options.pop("attachment", None)
|
|
613
|
+
engine = options.get("engine")
|
|
614
|
+
if attachment is not None:
|
|
615
|
+
from ccx.errors import CapabilityUnsupported
|
|
616
|
+
if not isinstance(attachment, live_vars.PreparedAttachment):
|
|
617
|
+
raise InvalidRequest("initial attachment must be prepared before creation")
|
|
618
|
+
# Same normalizer and defaults as creation_steps.prepare_worker; a
|
|
619
|
+
# duplicate permission-policy interpretation would admit wrong shells.
|
|
620
|
+
_, create_args = validated_create_spec(
|
|
621
|
+
engine=engine, cwd=options.get("cwd"), model=options.get("model"),
|
|
622
|
+
effort=options.get("effort"),
|
|
623
|
+
idle_timeout=options.get("idle_timeout", DEFAULT_IDLE_TIMEOUT_S),
|
|
624
|
+
permissions=options.get("permissions"), sandbox=options.get("sandbox"),
|
|
625
|
+
approval_policy=options.get("approval_policy"), agent=options.get("agent"),
|
|
626
|
+
append_system_prompt=options.get("append_system_prompt"),
|
|
627
|
+
allowed_tools=options.get("allowed_tools"),
|
|
628
|
+
disallowed_tools=options.get("disallowed_tools"),
|
|
629
|
+
add_dir=options.get("add_dir"), permission_mode=options.get("permission_mode"),
|
|
630
|
+
)
|
|
631
|
+
if not (create_args.get("skip_permissions") is True if engine == "claude"
|
|
632
|
+
else create_args.get("sandbox") == "danger-full-access"
|
|
633
|
+
and create_args.get("approvalPolicy") == "never"):
|
|
634
|
+
raise CapabilityUnsupported(
|
|
635
|
+
'Variables require full permissions (permissions="full").',
|
|
636
|
+
engine=engine, capability="shared_python_variables",
|
|
637
|
+
)
|
|
638
|
+
if start_timeout is None:
|
|
639
|
+
start_timeout = 90.
|
|
640
|
+
if (type(start_timeout) not in (int, float)
|
|
641
|
+
or not isfinite(start_timeout) or not 0 < start_timeout <= 90):
|
|
642
|
+
raise InvalidRequest("start_timeout must be positive, finite and at most 90 seconds")
|
|
643
|
+
if deadline is not None and (type(deadline) not in (float, int)
|
|
644
|
+
or not isfinite(deadline)):
|
|
645
|
+
raise InvalidRequest("native creation deadline must be finite")
|
|
646
|
+
|
|
647
|
+
# Pin this original ACTIVE generation before minting or touching a native
|
|
648
|
+
# path. A different generation is never an implicit fallback for a retry.
|
|
649
|
+
try:
|
|
650
|
+
ctx = require_ccx_active(constants.CCX_ROOT,
|
|
651
|
+
expected_generation=expected_generation)
|
|
652
|
+
except (GenerationError, OSError) as exc:
|
|
653
|
+
raise SessionUnavailable(f"Worker records are unavailable; check CCX_ROOT: {exc}") from exc
|
|
654
|
+
pinned_generation = ctx.generation
|
|
655
|
+
supplied = options.get("worker_id")
|
|
656
|
+
if supplied is not None:
|
|
657
|
+
try:
|
|
658
|
+
supplied = canonical_worker_id(supplied)
|
|
659
|
+
except InvalidTarget as exc:
|
|
660
|
+
raise InvalidRequest(
|
|
661
|
+
f"worker_id must be canonical full UUIDv4: {exc}"
|
|
662
|
+
) from exc
|
|
663
|
+
# A *fresh* creation cannot claim a UUID without a native executable.
|
|
664
|
+
# Existing caller-owned UUID retries still reconcile their durable claim,
|
|
665
|
+
# including a terminal provider refusal when the CLI has since disappeared.
|
|
666
|
+
existing = False
|
|
667
|
+
if supplied is not None:
|
|
668
|
+
from ccx.store.identity import UnknownWorker
|
|
669
|
+
try:
|
|
670
|
+
existing = ctx.resolve(supplied).engine == engine
|
|
671
|
+
except UnknownWorker:
|
|
672
|
+
pass
|
|
673
|
+
if engine in {"claude", "codex"} and not existing:
|
|
674
|
+
from ccx.system.engine_binary import resolve_engine_binary
|
|
675
|
+
resolve_engine_binary(engine)
|
|
676
|
+
# Public new/fork (CLI, Python and protocol) must validate the explicit
|
|
677
|
+
# native model before minting an identity or writing any claim/record.
|
|
678
|
+
if (engine == "codex" and options.get("model") is not None
|
|
679
|
+
and options.get("_fork_anchor") is None):
|
|
680
|
+
from ccx.codex.runtime import validate_model
|
|
681
|
+
from ccx.system.cwd_paths import canonical_cwd
|
|
682
|
+
validate_model(options["model"], cwd=canonical_cwd(options["cwd"]))
|
|
683
|
+
# Only a CCX-owned full-UUID caller is a delegation edge. Native provider
|
|
684
|
+
# session/thread environment is not parent authority, and malformed/stale
|
|
685
|
+
# self stamps must never be promoted into one.
|
|
686
|
+
from ccx.store.records import spawned_by_from_env
|
|
687
|
+
try:
|
|
688
|
+
caller = spawned_by_from_env()
|
|
689
|
+
except SessionUnavailable:
|
|
690
|
+
caller = None
|
|
691
|
+
spawned_by = caller if caller and caller.get("kind") == "ccx-worker" else None
|
|
692
|
+
chosen = supplied if supplied is not None else mint_worker_id()
|
|
693
|
+
info = {
|
|
694
|
+
"worker_id": chosen, "worker": f"{engine}:{chosen}",
|
|
695
|
+
"engine": engine, "short": chosen[:8], "record": None,
|
|
696
|
+
"created": False, "reconciled": False,
|
|
697
|
+
"activation_state": "unproven", "native_ready": False,
|
|
698
|
+
}
|
|
699
|
+
# Only the recognized private frozen fork anchor is admitted.
|
|
700
|
+
options.pop("timeout", None) # Already validated; retained as turn_timeout for wait.
|
|
701
|
+
options.pop("fork", None) # Legacy fork was refused before the ACTIVE pin.
|
|
702
|
+
options["fork_anchor"] = options.pop("_fork_anchor", None)
|
|
703
|
+
options["worker_id"] = chosen
|
|
704
|
+
options.setdefault("idle_timeout", DEFAULT_IDLE_TIMEOUT_S)
|
|
705
|
+
try:
|
|
706
|
+
original = prepare_worker(ctx, spawned_by=spawned_by, **options)
|
|
707
|
+
except (CreationConflict, OwnerConflict) as exc:
|
|
708
|
+
raise OperationConflict(
|
|
709
|
+
f"Worker ID already belongs to another configuration; choose a new ID: {exc}",
|
|
710
|
+
short=chosen[:8], reason="global_worker_uuid_conflict", worker_id=chosen,
|
|
711
|
+
) from exc
|
|
712
|
+
except (GenerationError, IdentityError) as exc:
|
|
713
|
+
raise CreatePartialFailure(
|
|
714
|
+
"Worker setup is incomplete; check status before retrying; "
|
|
715
|
+
"retain and reconcile exact worker_id",
|
|
716
|
+
info=info, phase="record", cause=exc,
|
|
717
|
+
) from exc
|
|
718
|
+
info.update(original.as_dict())
|
|
719
|
+
info["record"] = original.record
|
|
720
|
+
try:
|
|
721
|
+
names = _bind_once(ctx, original.engine, original.worker_id, attachment)
|
|
722
|
+
except OperationConflict:
|
|
723
|
+
raise
|
|
724
|
+
except Exception as exc:
|
|
725
|
+
# The first record already belongs to this exact UUID. A failed
|
|
726
|
+
# bind is not a native failure, and could be on an activated retry.
|
|
727
|
+
activation_state = original.activation_state
|
|
728
|
+
try:
|
|
729
|
+
activation_state = ctx.native_activation(original.worker)["state"]
|
|
730
|
+
except Exception:
|
|
731
|
+
pass # Never mask the original missing/unsafe binding proof.
|
|
732
|
+
raise CreatePartialFailure(
|
|
733
|
+
"Worker record exists but variables could not be attached; "
|
|
734
|
+
"is not proven; retain and reconcile the same worker_id",
|
|
735
|
+
info={**info, "activation_state": activation_state,
|
|
736
|
+
"cause_type": type(exc).__name__},
|
|
737
|
+
phase="initial-variables", cause=exc,
|
|
738
|
+
) from exc
|
|
739
|
+
try:
|
|
740
|
+
ctx.revalidate()
|
|
741
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
742
|
+
raise NativeEffectIndeterminate("native creation request deadline expired; no replay")
|
|
743
|
+
if engine == "codex":
|
|
744
|
+
bound = _complete_codex_create(
|
|
745
|
+
ctx, original, info, start_timeout=start_timeout, deadline=deadline,
|
|
746
|
+
)
|
|
747
|
+
else:
|
|
748
|
+
bound = _complete_claude_create(
|
|
749
|
+
ctx, original, start_timeout=start_timeout, deadline=deadline,
|
|
750
|
+
)
|
|
751
|
+
ctx.revalidate()
|
|
752
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
753
|
+
raise NativeEffectIndeterminate(
|
|
754
|
+
"original native creation confirmed after request deadline; "
|
|
755
|
+
"retain the exact full worker UUID"
|
|
756
|
+
)
|
|
757
|
+
result = {
|
|
758
|
+
**original.as_dict(), "record": bound,
|
|
759
|
+
"activation_state": "confirmed", "native_ready": True,
|
|
760
|
+
"record_committed": True,
|
|
761
|
+
}
|
|
762
|
+
except Exception as exc:
|
|
763
|
+
# Once the original owner exists, even a stale generation, corrupt
|
|
764
|
+
# marker, or failed claim-CAS cannot lose its minted full UUID.
|
|
765
|
+
# Preserve the exact cause: corruption is NOT retryable unknown.
|
|
766
|
+
info["native_start_error"] = str(exc)
|
|
767
|
+
info["cause_type"] = type(exc).__name__
|
|
768
|
+
from ccx.store.root import StorageIntegrityError
|
|
769
|
+
if isinstance(exc, StorageIntegrityError):
|
|
770
|
+
info["integrity_error"] = True
|
|
771
|
+
# Keep the offline native-start reconciliation for genuinely
|
|
772
|
+
# indeterminate effects or transport failures, but never probe a
|
|
773
|
+
# suspect stale/corrupt store merely to decorate an integrity error.
|
|
774
|
+
if isinstance(exc, (NativeEffectIndeterminate, SessionUnavailable, OSError)):
|
|
775
|
+
try:
|
|
776
|
+
if engine == "codex" and "native_rejection" not in info:
|
|
777
|
+
rejected = ctx.reconcile_codex_native_rejection(original.worker)
|
|
778
|
+
if rejected is not None:
|
|
779
|
+
info["native_rejection"] = {
|
|
780
|
+
"code": rejected["code"], "message": rejected["message"],
|
|
781
|
+
}
|
|
782
|
+
if engine == "codex" and "native_rejection" not in info:
|
|
783
|
+
local = ctx.reconcile_codex_local_refusal(original.worker)
|
|
784
|
+
if local is not None:
|
|
785
|
+
info["native_local_refusal"] = {
|
|
786
|
+
"reason": local["reason"], "stage": local["stage"],
|
|
787
|
+
"proven": True,
|
|
788
|
+
}
|
|
789
|
+
except Exception:
|
|
790
|
+
pass # Optional terminal proof never replaces the cause.
|
|
791
|
+
if engine == "claude" and deadline is None and not info.get("integrity_error"):
|
|
792
|
+
# A signed-out Claude sits on its login screen until activation
|
|
793
|
+
# times out. Name the real cause; only checked after a failure,
|
|
794
|
+
# and never past a caller's deadline.
|
|
795
|
+
try:
|
|
796
|
+
from ccx.system.engine_binary import account_signed_out, resolve_engine_binary
|
|
797
|
+
if account_signed_out("claude", resolve_engine_binary("claude")):
|
|
798
|
+
info["signed_out"] = True
|
|
799
|
+
except Exception:
|
|
800
|
+
pass
|
|
801
|
+
try:
|
|
802
|
+
# Even after a stale/failed bind, the original immutable claim
|
|
803
|
+
# can sometimes be read safely. An unavailable or suspect
|
|
804
|
+
# store must never hide the original error or its UUID.
|
|
805
|
+
state = ctx.native_activation(original.worker)["state"]
|
|
806
|
+
if state in {"unprepared", "prepared", "submitted-unknown",
|
|
807
|
+
"confirmed", "rejected", "local-refused"}:
|
|
808
|
+
info["activation_state"] = state
|
|
809
|
+
except Exception:
|
|
810
|
+
pass
|
|
811
|
+
partial = CreatePartialFailure(
|
|
812
|
+
"Claude Code is not signed in: run `claude` once to sign in, then try again"
|
|
813
|
+
if info.get("signed_out") else
|
|
814
|
+
"full worker UUID and original first record are durable, but creation "
|
|
815
|
+
"did not complete cleanly; retain worker_id, do not remint",
|
|
816
|
+
info=info, phase="native-activation",
|
|
817
|
+
)
|
|
818
|
+
partial.cause = exc # Keep the original native-partial EXIT_UNAVAILABLE.
|
|
819
|
+
raise partial from exc
|
|
820
|
+
if prompt is None:
|
|
821
|
+
if engine == "codex":
|
|
822
|
+
_finalize_empty_codex_create(ctx, result)
|
|
823
|
+
if parent is None:
|
|
824
|
+
return result
|
|
825
|
+
# No first model turn exists. Arm exactly one create-keyed stable
|
|
826
|
+
# outcome after the original native binding is confirmed.
|
|
827
|
+
try:
|
|
828
|
+
# Codex may need an original-thread load to prove idle; a
|
|
829
|
+
# confirmed Claude SessionStart already proves its stable
|
|
830
|
+
# creation outcome without another host readiness action.
|
|
831
|
+
if (engine == "codex" and
|
|
832
|
+
(result["record"].get("lifecycle") or {}).get("state") == "prepared"):
|
|
833
|
+
from ccx.ops.send import ensure_ready
|
|
834
|
+
ensure_ready(result["worker"], expected_generation=pinned_generation)
|
|
835
|
+
from ccx.notify.registry import create_registration, public_registration
|
|
836
|
+
from ccx.notify.daemon import ensure_notify_daemon
|
|
837
|
+
registered = create_registration(
|
|
838
|
+
parent=parent, worker_engine=engine, worker_id=result["worker_id"],
|
|
839
|
+
worker_name=(result["record"] or {}).get("name"),
|
|
840
|
+
operation={"kind": f"{engine}-stable", "expectTurn": False,
|
|
841
|
+
"selectionMode": "status-only", "stableState": "idle"},
|
|
842
|
+
source="new", settle=settle,
|
|
843
|
+
)
|
|
844
|
+
ensure_notify_daemon()
|
|
845
|
+
return {**result, "notification": public_registration(registered)}
|
|
846
|
+
except Exception as exc:
|
|
847
|
+
# The original full UUID and native identity were already
|
|
848
|
+
# confirmed; failure to arm must never create a new worker.
|
|
849
|
+
raise CreatePartialFailure(
|
|
850
|
+
"worker created, but original promptless parent notification "
|
|
851
|
+
"is not proven armed; retain the exact worker_id",
|
|
852
|
+
info={**result, "cause_type": type(exc).__name__},
|
|
853
|
+
phase="notification-registration", cause=exc,
|
|
854
|
+
) from exc
|
|
855
|
+
# Native creation is always promptless. A first message uses precisely
|
|
856
|
+
# the ordinary semantic receipt: the full UUID + create:initial fixes
|
|
857
|
+
# one original prompt across process loss and all public adapters.
|
|
858
|
+
from ccx.ops.send import send_turn
|
|
859
|
+
from ccx.claude.delivery import ClaudeDeliveryConflict
|
|
860
|
+
from ccx.codex.delivery import DeliveryReceiptConflict
|
|
861
|
+
from ccx.store.root import NativeEffectIndeterminate
|
|
862
|
+
initial = "create:initial"
|
|
863
|
+
status = None
|
|
864
|
+
try:
|
|
865
|
+
delivered = send_turn(
|
|
866
|
+
result["worker"], live_vars.with_variable_hint(prompt, names),
|
|
867
|
+
delivery_id=initial,
|
|
868
|
+
mode="new_turn_only", expected_generation=pinned_generation,
|
|
869
|
+
notify_parent=parent is not None, settle=settle, cap=cap,
|
|
870
|
+
)
|
|
871
|
+
status = delivered.get("status")
|
|
872
|
+
receipt = delivered.get("delivery")
|
|
873
|
+
if status == "rejected" and isinstance(receipt, dict):
|
|
874
|
+
from ccx.errors import DeliveryRejected
|
|
875
|
+
raise DeliveryRejected(
|
|
876
|
+
receipt.get("rejection_message") or "original initial prompt rejected",
|
|
877
|
+
short=result["short"], refusal=receipt.get("rejection_code"),
|
|
878
|
+
delivery_id=initial, delivery_status="rejected",
|
|
879
|
+
)
|
|
880
|
+
# The real Claude host ACKs durable ownership before evidence of
|
|
881
|
+
# native turn placement; owned is PENDING, not accepted/completed.
|
|
882
|
+
valid = (
|
|
883
|
+
isinstance(receipt, dict)
|
|
884
|
+
and receipt.get("delivery_id") == initial
|
|
885
|
+
and receipt.get("route") == "new_turn"
|
|
886
|
+
and receipt.get("owned") is True
|
|
887
|
+
and (
|
|
888
|
+
(engine == "claude" and status in {"owned", "accepted"}
|
|
889
|
+
and receipt.get("status") == (
|
|
890
|
+
"pending" if status == "owned" else "accepted"))
|
|
891
|
+
or (engine == "codex" and status == "accepted"
|
|
892
|
+
and receipt.get("status") == "accepted"
|
|
893
|
+
and isinstance(receipt.get("turn_id"), str)
|
|
894
|
+
and bool(receipt["turn_id"]))
|
|
895
|
+
)
|
|
896
|
+
)
|
|
897
|
+
if not valid:
|
|
898
|
+
raise NativeEffectIndeterminate(
|
|
899
|
+
"initial prompt has no positive original delivery receipt; "
|
|
900
|
+
"reconcile create:initial, never send a new token"
|
|
901
|
+
)
|
|
902
|
+
ctx.revalidate()
|
|
903
|
+
result = {**result, "delivery_id": initial,
|
|
904
|
+
"delivery_status": status, "delivery": receipt,
|
|
905
|
+
**({"notification": delivered["notification"]}
|
|
906
|
+
if parent is not None and delivered.get("notification") is not None
|
|
907
|
+
else {})}
|
|
908
|
+
except Exception as exc:
|
|
909
|
+
# Identity/activation were positively bound. A delivery failure
|
|
910
|
+
# cannot roll them back or turn this into a fresh native creation.
|
|
911
|
+
from ccx.errors import DeliveryRejected
|
|
912
|
+
if (engine == "codex" and isinstance(exc, BudgetStopped)
|
|
913
|
+
and exc.phase == "preflight"):
|
|
914
|
+
# Original cap admission refused before any provider turn/start.
|
|
915
|
+
# This is the same promptless bound thread, now ready for peek.
|
|
916
|
+
_finalize_empty_codex_create(ctx, result, ("prepared", "idle"))
|
|
917
|
+
conflict = isinstance(exc, (ClaudeDeliveryConflict, DeliveryReceiptConflict, OperationConflict))
|
|
918
|
+
info = {**result, "delivery_id": initial,
|
|
919
|
+
"delivery_status": status,
|
|
920
|
+
"cause_type": type(exc).__name__,
|
|
921
|
+
"initial_delivery_conflict": conflict,
|
|
922
|
+
"initial_delivery_rejected": isinstance(exc, DeliveryRejected)}
|
|
923
|
+
raise CreatePartialFailure(
|
|
924
|
+
"worker created, but original initial prompt is not proven "
|
|
925
|
+
"delivered; retain worker_id and reconcile create:initial",
|
|
926
|
+
info=info, phase="initial-delivery", delivery_id=initial,
|
|
927
|
+
delivery_status=status, cause=exc,
|
|
928
|
+
) from exc
|
|
929
|
+
if not wait:
|
|
930
|
+
return result
|
|
931
|
+
from ccx.ops.wait import wait_delivery
|
|
932
|
+
try:
|
|
933
|
+
if deadline is not None:
|
|
934
|
+
left = max(0.0, deadline - time.monotonic())
|
|
935
|
+
turn_timeout = left if turn_timeout is None else min(turn_timeout, left)
|
|
936
|
+
observed = wait_delivery(
|
|
937
|
+
result["worker"], delivery_id=initial, timeout=turn_timeout,
|
|
938
|
+
expected_generation=pinned_generation,
|
|
939
|
+
)
|
|
940
|
+
return {**result, **observed, "waited": True}
|
|
941
|
+
except Exception as exc:
|
|
942
|
+
info = {**result, "cause_type": type(exc).__name__,
|
|
943
|
+
"turn_ref": getattr(exc, "turn_ref", None)}
|
|
944
|
+
status = getattr(exc, "delivery_status", result["delivery_status"])
|
|
945
|
+
info["delivery_status"] = status
|
|
946
|
+
raise CreatePartialFailure(
|
|
947
|
+
"original first turn did not complete with exact proof; "
|
|
948
|
+
"retain worker_id and reconcile create:initial",
|
|
949
|
+
info=info, phase="wait-report", delivery_id=initial,
|
|
950
|
+
delivery_status=status, cause=exc,
|
|
951
|
+
) from exc
|
|
952
|
+
raise RuntimeError("Worker startup is not confirmed; inspect worker status")
|