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,711 @@
|
|
|
1
|
+
"""Settled UUID fork planning, lineage, and original-host Claude copy proof."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import contextlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import pathlib
|
|
8
|
+
import subprocess
|
|
9
|
+
import stat
|
|
10
|
+
import time
|
|
11
|
+
|
|
12
|
+
from ccx.errors import InvalidRequest, OperationConflict, OperationTimeout, SessionUnavailable
|
|
13
|
+
|
|
14
|
+
MATERIALIZE_TIMEOUT_S = 180.0
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def now_ms() -> int:
|
|
18
|
+
return int(time.time() * 1000)
|
|
19
|
+
|
|
20
|
+
def _remaining(deadline: float | None) -> float | None:
|
|
21
|
+
if deadline is None:
|
|
22
|
+
return None
|
|
23
|
+
left = deadline - time.monotonic()
|
|
24
|
+
if left <= 0:
|
|
25
|
+
raise OperationTimeout("fork deadline expired")
|
|
26
|
+
return left
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# -- Claude -----------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
def _rows(path: str | os.PathLike, deadline: float | None = None, *,
|
|
32
|
+
messages: bool = True, before_offset: int | None = None,
|
|
33
|
+
stats: dict | None = None):
|
|
34
|
+
"""Main-chain rows of a Claude transcript (only user/assistant messages
|
|
35
|
+
unless ``messages`` is false), checking ``deadline`` as it scans."""
|
|
36
|
+
from ccx.claude.transcript import _iter_transcript
|
|
37
|
+
for index, row in enumerate(_iter_transcript(path, stats=stats, before_offset=before_offset)):
|
|
38
|
+
if deadline is not None and index % 256 == 0:
|
|
39
|
+
_remaining(deadline)
|
|
40
|
+
if not messages or (row.get("type") in ("user", "assistant")
|
|
41
|
+
and isinstance(row.get("uuid"), str)):
|
|
42
|
+
yield row
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _contains_fork_point(path: pathlib.Path, session_id: str, resume_at: str) -> bool:
|
|
52
|
+
return any(row.get("uuid") == resume_at and row.get("sessionId") == session_id
|
|
53
|
+
for row in _rows(path))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _claude_copy_proof(record: dict | None) -> dict | None:
|
|
57
|
+
"""Only a child/native/anchor-matched immutable-copy fact counts as copied.
|
|
58
|
+
|
|
59
|
+
Pure projection: the later F1b host publishes lifecycle.nativeForkProof
|
|
60
|
+
through existing fresh-record CAS. A short, timestamp, or copied file alone
|
|
61
|
+
is not authority to start or recopy a managed native session.
|
|
62
|
+
"""
|
|
63
|
+
if not isinstance(record, dict):
|
|
64
|
+
return None
|
|
65
|
+
fork = record.get("forkedFrom")
|
|
66
|
+
anchor = record.get("forkAnchor")
|
|
67
|
+
proof = (record.get("lifecycle") or {}).get("nativeForkProof")
|
|
68
|
+
native = record.get("native")
|
|
69
|
+
if not all(isinstance(part, dict) for part in (fork, anchor, proof, native)):
|
|
70
|
+
return None
|
|
71
|
+
from ccx.store.identity import InvalidTarget, canonical_worker_id
|
|
72
|
+
try:
|
|
73
|
+
worker_id = canonical_worker_id(record.get("workerId"))
|
|
74
|
+
source_id = canonical_worker_id(anchor.get("sourceWorkerId"))
|
|
75
|
+
sid = canonical_worker_id(native.get("sessionId"))
|
|
76
|
+
except InvalidTarget:
|
|
77
|
+
return None
|
|
78
|
+
if (record.get("engine") != "claude" or anchor.get("engine") != "claude"
|
|
79
|
+
or source_id == worker_id or sid == worker_id
|
|
80
|
+
or not isinstance(anchor.get("resumeAt"), str) or not anchor["resumeAt"]
|
|
81
|
+
or not isinstance(anchor.get("sourceNativeSessionId"), str)
|
|
82
|
+
or not anchor["sourceNativeSessionId"]
|
|
83
|
+
or any(fork.get(key) != anchor.get(key) for key in (
|
|
84
|
+
"engine", "sourceWorkerId", "sourceNativeSessionId", "resumeAt",
|
|
85
|
+
"forkedAtMs",
|
|
86
|
+
))
|
|
87
|
+
or proof.get("workerId") != worker_id
|
|
88
|
+
or proof.get("nativeSessionId") != sid
|
|
89
|
+
or proof.get("forkAnchor") != anchor
|
|
90
|
+
or type(proof.get("copiedAtMs")) is not int
|
|
91
|
+
or proof["copiedAtMs"] <= 0
|
|
92
|
+
or type(proof.get("inheritedBytes")) is not int
|
|
93
|
+
or proof["inheritedBytes"] <= 0):
|
|
94
|
+
return None
|
|
95
|
+
tasks = proof.get("inheritedOpenTaskIds")
|
|
96
|
+
if (not isinstance(tasks, list) or len(tasks) > 1024
|
|
97
|
+
or any(not isinstance(x, str) or not x or len(x) > 256 for x in tasks)):
|
|
98
|
+
return None
|
|
99
|
+
return proof
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def claude_fork_pending(record: dict) -> bool:
|
|
103
|
+
"""An unproven owned Claude fork is NEVER a recopy authorization."""
|
|
104
|
+
return isinstance(record, dict) and record.get("engine") == "claude" and bool(
|
|
105
|
+
record.get("forkAnchor")
|
|
106
|
+
) and _claude_copy_proof(record) is None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def ensure_claude_fork_materialized(worker_id: str, *, deadline: float | None = None) -> dict:
|
|
110
|
+
"""Refuse unproven UUID fork history; only F1b's original host may copy.
|
|
111
|
+
|
|
112
|
+
rpc._respawn and historical creation still call this name. They must not
|
|
113
|
+
enter the retired short-keyed rename/quarantine/kill/recopy operation.
|
|
114
|
+
"""
|
|
115
|
+
from ccx.store.records import read_record
|
|
116
|
+
record = read_record(worker_id)
|
|
117
|
+
if claude_fork_pending(record):
|
|
118
|
+
raise SessionUnavailable(
|
|
119
|
+
f"Conversation copy for fork {worker_id} is not verified; "
|
|
120
|
+
"keep its worker ID and inspect status before retrying",
|
|
121
|
+
short=worker_id,
|
|
122
|
+
)
|
|
123
|
+
return record
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _run_materializer(record: dict, fork: dict, *, deadline: float | None = None) -> None:
|
|
127
|
+
import signal
|
|
128
|
+
from ccx.constants import CLAUDE_BIN
|
|
129
|
+
from ccx.claude.host.session import worker_env
|
|
130
|
+
worker_id = record["workerId"]
|
|
131
|
+
session_id = record["native"]["sessionId"]
|
|
132
|
+
budget = MATERIALIZE_TIMEOUT_S
|
|
133
|
+
if deadline is not None:
|
|
134
|
+
budget = min(budget, deadline - time.time())
|
|
135
|
+
if budget <= 0:
|
|
136
|
+
raise OperationTimeout(f"deadline expired before Claude fork {worker_id} was copied",
|
|
137
|
+
short=worker_id)
|
|
138
|
+
argv = [
|
|
139
|
+
CLAUDE_BIN, "-p",
|
|
140
|
+
"--session-id", session_id, "--fork-session",
|
|
141
|
+
"--resume", fork.get("transcript") or fork["sourceNativeSessionId"],
|
|
142
|
+
"--resume-session-at", fork["resumeAt"],
|
|
143
|
+
"--output-format", "json",
|
|
144
|
+
# A copy, not a session start: no MCP servers and no user hooks.
|
|
145
|
+
"--strict-mcp-config", "--settings", json.dumps({"disableAllHooks": True}),
|
|
146
|
+
# A local command: appends without a model call and sets the title.
|
|
147
|
+
"/rename " + record.get("name", f"ccx-{worker_id[:8]}"),
|
|
148
|
+
]
|
|
149
|
+
try:
|
|
150
|
+
child = subprocess.Popen(
|
|
151
|
+
argv, cwd=fork["cwd"], env=worker_env(worker_id=worker_id),
|
|
152
|
+
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
153
|
+
text=True, start_new_session=True,
|
|
154
|
+
)
|
|
155
|
+
except OSError as error:
|
|
156
|
+
raise SessionUnavailable(f"Claude fork copy failed for {worker_id}: {error}",
|
|
157
|
+
short=worker_id) from error
|
|
158
|
+
try:
|
|
159
|
+
stdout, stderr = child.communicate(timeout=budget)
|
|
160
|
+
except subprocess.TimeoutExpired:
|
|
161
|
+
with contextlib.suppress(OSError):
|
|
162
|
+
os.killpg(child.pid, signal.SIGKILL)
|
|
163
|
+
child.communicate()
|
|
164
|
+
raise OperationTimeout(f"Claude fork copy for {worker_id} did not finish in time",
|
|
165
|
+
short=worker_id) from None
|
|
166
|
+
result = None
|
|
167
|
+
for line in reversed(stdout.strip().splitlines()):
|
|
168
|
+
with contextlib.suppress(ValueError):
|
|
169
|
+
result = json.loads(line)
|
|
170
|
+
break
|
|
171
|
+
if (
|
|
172
|
+
child.returncode != 0 or not isinstance(result, dict)
|
|
173
|
+
or result.get("is_error") or result.get("type") != "result"
|
|
174
|
+
or result.get("session_id") != session_id
|
|
175
|
+
or result.get("num_turns") != 0
|
|
176
|
+
or result.get("local_command") != "rename"
|
|
177
|
+
):
|
|
178
|
+
detail = (stderr or stdout or "").strip()[-400:]
|
|
179
|
+
raise SessionUnavailable(f"Claude fork copy failed for {worker_id} "
|
|
180
|
+
f"(rc={child.returncode}): {detail}", short=worker_id)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def copy_original_claude_fork(store, record: dict, *, submission_id: str,
|
|
184
|
+
host_pid: int, host_start: str) -> dict:
|
|
185
|
+
"""Only the submitted original host may admit ONE copy, after socket.listen.
|
|
186
|
+
|
|
187
|
+
This record CAS is copy submission admission, not a new native-create
|
|
188
|
+
mechanism. A crash anywhere after it is indeterminate, never a recopy.
|
|
189
|
+
"""
|
|
190
|
+
from ccx.claude.transcript import extract_background_tasks
|
|
191
|
+
from ccx.store.root import CreationIndeterminate
|
|
192
|
+
from ccx.store.store import fingerprint_v2
|
|
193
|
+
|
|
194
|
+
worker_id = record["workerId"]
|
|
195
|
+
current = store.record(f"claude:{worker_id}")
|
|
196
|
+
anchor = current.get("forkAnchor")
|
|
197
|
+
native = current.get("native") or {}
|
|
198
|
+
lifecycle = current.get("lifecycle") or {}
|
|
199
|
+
if (not isinstance(anchor, dict) or anchor != record.get("forkAnchor")
|
|
200
|
+
or current.get("forkedFrom") != record.get("forkedFrom")
|
|
201
|
+
or native.get("sessionId") != (record.get("native") or {}).get("sessionId")
|
|
202
|
+
or not claude_fork_pending(current)
|
|
203
|
+
or lifecycle.get("nativeForkCopyAttempt") is not None
|
|
204
|
+
or lifecycle.get("nativeForkProof") is not None
|
|
205
|
+
or lifecycle.get("hostPid") != host_pid
|
|
206
|
+
or str(lifecycle.get("hostStart")) != str(host_start)):
|
|
207
|
+
raise CreationIndeterminate(f"Claude fork {worker_id} copy admission unproven; never recopy")
|
|
208
|
+
with store.lease():
|
|
209
|
+
owner, claim = store._owner(worker_id), store._claim(worker_id)
|
|
210
|
+
if (not isinstance(owner, dict) or not isinstance(claim, dict)
|
|
211
|
+
or (owner.get("createIntent") or {}).get("forkAnchor") != anchor
|
|
212
|
+
or claim.get("forkAnchor") != anchor
|
|
213
|
+
or claim.get("nativeBinding") != {"sessionId": native["sessionId"]}
|
|
214
|
+
or claim.get("createSha256") != fingerprint_v2(
|
|
215
|
+
engine="claude", cwd=anchor["cwd"],
|
|
216
|
+
create_args=anchor["createArgs"], fork_anchor=anchor)):
|
|
217
|
+
raise CreationIndeterminate(f"Claude fork {worker_id} original anchor/claim differs")
|
|
218
|
+
activation = store.native_activation(f"claude:{worker_id}")
|
|
219
|
+
if (activation.get("state") != "submitted-unknown"
|
|
220
|
+
or activation.get("source") != f"claude-session:{native['sessionId']}"
|
|
221
|
+
or activation.get("submissionId") != submission_id
|
|
222
|
+
or not isinstance(submission_id, str) or not submission_id):
|
|
223
|
+
raise CreationIndeterminate(f"Claude fork {worker_id} lacks original submitted host")
|
|
224
|
+
|
|
225
|
+
child_path = pathlib.Path(current["transcript"])
|
|
226
|
+
from ccx.store.paths import transcript_path_for
|
|
227
|
+
if (current["transcript"] != str(transcript_path_for(anchor["cwd"], native["sessionId"]))
|
|
228
|
+
or child_path.exists() or child_path.is_symlink()):
|
|
229
|
+
raise CreationIndeterminate(
|
|
230
|
+
f"Claude fork {worker_id} already has unproven native bytes; never adopt or recopy")
|
|
231
|
+
store.mutate_record(
|
|
232
|
+
f"claude:{worker_id}",
|
|
233
|
+
patches={"lifecycle.nativeForkCopyAttempt": submission_id},
|
|
234
|
+
expect={"lifecycle.nativeForkCopyAttempt": None,
|
|
235
|
+
"lifecycle.nativeForkProof": None,
|
|
236
|
+
"lifecycle.hostPid": host_pid,
|
|
237
|
+
"lifecycle.hostStart": host_start},
|
|
238
|
+
)
|
|
239
|
+
_run_materializer(current, anchor)
|
|
240
|
+
try:
|
|
241
|
+
st = child_path.lstat()
|
|
242
|
+
if not stat.S_ISREG(st.st_mode) or st.st_size == 0:
|
|
243
|
+
raise ValueError("Forked Claude conversation is missing; inspect the worker")
|
|
244
|
+
with child_path.open("rb") as stream:
|
|
245
|
+
stream.seek(-1, os.SEEK_END)
|
|
246
|
+
if stream.read(1) != b"\n":
|
|
247
|
+
raise ValueError("incomplete child native JSONL")
|
|
248
|
+
malformed = {}
|
|
249
|
+
found = False
|
|
250
|
+
for row in _rows(child_path, messages=False, stats=malformed):
|
|
251
|
+
if row.get("type") in ("user", "assistant") and row.get("uuid"):
|
|
252
|
+
if row.get("sessionId") != native["sessionId"]:
|
|
253
|
+
raise ValueError("foreign child native SID in copied history")
|
|
254
|
+
if row["uuid"] == anchor["resumeAt"]:
|
|
255
|
+
found = True
|
|
256
|
+
if malformed.get("malformedLines") or not found:
|
|
257
|
+
raise ValueError("child transcript lacks complete exact original fork message")
|
|
258
|
+
tasks = sorted({task["task_id"] for task in extract_background_tasks(child_path)
|
|
259
|
+
if task.get("status") in ("running", "unknown")
|
|
260
|
+
and task.get("task_id")})
|
|
261
|
+
if (len(tasks) > 1024 or any(not isinstance(task, str) or len(task) > 256
|
|
262
|
+
for task in tasks)):
|
|
263
|
+
raise ValueError("inherited open task identifiers exceed proof bound")
|
|
264
|
+
if child_path.stat().st_size != st.st_size:
|
|
265
|
+
raise ValueError("Forked Claude conversation changed during copy; retry the fork")
|
|
266
|
+
except (OSError, ValueError, TypeError, KeyError) as exc:
|
|
267
|
+
raise SessionUnavailable(
|
|
268
|
+
f"Claude fork {worker_id} has no exact copied child history: {exc}",
|
|
269
|
+
short=worker_id,
|
|
270
|
+
) from exc
|
|
271
|
+
|
|
272
|
+
proof = {"workerId": worker_id, "nativeSessionId": native["sessionId"],
|
|
273
|
+
"forkAnchor": anchor, "copiedAtMs": now_ms(),
|
|
274
|
+
"inheritedBytes": st.st_size, "inheritedOpenTaskIds": tasks}
|
|
275
|
+
committed = store.mutate_record(
|
|
276
|
+
f"claude:{worker_id}",
|
|
277
|
+
patches={"lifecycle.nativeForkProof": proof},
|
|
278
|
+
expect={"lifecycle.nativeForkProof": None,
|
|
279
|
+
"lifecycle.nativeForkCopyAttempt": submission_id,
|
|
280
|
+
"lifecycle.hostPid": host_pid,
|
|
281
|
+
"lifecycle.hostStart": host_start},
|
|
282
|
+
)
|
|
283
|
+
if _claude_copy_proof(committed) != proof:
|
|
284
|
+
raise CreationIndeterminate(f"Claude fork {worker_id} copy proof was not authenticated")
|
|
285
|
+
return committed
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def inherited_task_filter(record: dict | None, tasks: list[dict]) -> list[dict]:
|
|
289
|
+
"""Source-owned open tasks do not become the child's live work."""
|
|
290
|
+
proof = _claude_copy_proof(record)
|
|
291
|
+
inherited = set(proof["inheritedOpenTaskIds"]) if proof else set()
|
|
292
|
+
return [task for task in tasks if task.get("task_id") not in inherited] if inherited else tasks
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def inherited_offset(record: dict | None) -> int:
|
|
296
|
+
"""Token/usage accounting starts after independently proven native copy."""
|
|
297
|
+
proof = _claude_copy_proof(record)
|
|
298
|
+
return proof["inheritedBytes"] if proof else 0
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
# -- source and original child plan -----------------------------------------
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _conflict(record: dict, reason: str, text: str) -> OperationConflict:
|
|
305
|
+
return OperationConflict(text, short=record.get("short"), reason=reason,
|
|
306
|
+
worker_id=record.get("workerId"))
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _settled(record: dict) -> None:
|
|
310
|
+
"""Refuse an observable turn, input, queue, or compact before choosing R."""
|
|
311
|
+
lifecycle = record.get("lifecycle") or {}
|
|
312
|
+
state = lifecycle.get("state")
|
|
313
|
+
if (state == "waiting" or record.get("waitingFor")
|
|
314
|
+
or record.get("pendingApprovals")):
|
|
315
|
+
raise _conflict(record, "source_waiting", "source has unresolved native input")
|
|
316
|
+
if (state not in {"idle", "idle-killed", "crashed", "dead", "budget-stopped"}
|
|
317
|
+
or lifecycle.get("activeTurnId") or record.get("activeCompact")
|
|
318
|
+
or record.get("pendingSend")):
|
|
319
|
+
raise _conflict(record, "source_busy", "source turn, queue or compact not settled")
|
|
320
|
+
if record.get("terminalOnly"):
|
|
321
|
+
raise SessionUnavailable("Source worker has no verified conversation; inspect it before forking")
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _no_pending_effects(owner, record: dict) -> None:
|
|
325
|
+
"""Existing original semantic receipts/compact journal, no new tracker."""
|
|
326
|
+
if owner.engine == "claude":
|
|
327
|
+
from ccx.claude.delivery import iter_receipts
|
|
328
|
+
from ccx.ops.compact_ops import PENDING_STATUSES, read_journal
|
|
329
|
+
if any(op["status"] in PENDING_STATUSES for op in
|
|
330
|
+
read_journal(owner.worker)["operations"]):
|
|
331
|
+
raise _conflict(record, "source_busy", "source has native compact pending")
|
|
332
|
+
else:
|
|
333
|
+
from ccx.codex.delivery import iter_receipts
|
|
334
|
+
# An accepted receipt's settlement is optional observation metadata (steer
|
|
335
|
+
# and unobserved async sends never get one); the native settled point
|
|
336
|
+
# below is the authority for accepted work, so only admission refuses.
|
|
337
|
+
if any(row["state"] in {"prepared", "owned", "submitted"}
|
|
338
|
+
for row in iter_receipts(owner.worker)):
|
|
339
|
+
raise _conflict(record, "source_busy", "source has a pending original send")
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _claude_point(store, owner, record: dict, deadline: float | None) -> dict:
|
|
343
|
+
"""Pin accepted original native file; terminal JSONL row is the fork point."""
|
|
344
|
+
from ccx.claude.transcript import open_original_claude_source
|
|
345
|
+
from ccx.claude.managed_turns import turn_settlements
|
|
346
|
+
with open_original_claude_source(store, owner, record) as source:
|
|
347
|
+
if source is None:
|
|
348
|
+
raise InvalidRequest("Source Claude worker has no conversation to fork")
|
|
349
|
+
pinned, before, complete_end, native_path, _dir_fd = source
|
|
350
|
+
if not complete_end or complete_end != before.st_size:
|
|
351
|
+
raise _conflict(record, "source_busy", "source native transcript is incomplete")
|
|
352
|
+
malformed = {}
|
|
353
|
+
last = None
|
|
354
|
+
sid = record["native"]["sessionId"]
|
|
355
|
+
for row in _rows(pinned, deadline, before_offset=complete_end, stats=malformed):
|
|
356
|
+
if row.get("sessionId") != sid:
|
|
357
|
+
raise SessionUnavailable("Claude original native transcript has a foreign session")
|
|
358
|
+
last = row
|
|
359
|
+
if malformed.get("malformedLines"):
|
|
360
|
+
raise SessionUnavailable("Claude history is unreadable; inspect the source worker")
|
|
361
|
+
if last is None:
|
|
362
|
+
raise InvalidRequest("source has no Claude conversation to fork")
|
|
363
|
+
if _turn_open(last):
|
|
364
|
+
raise _conflict(
|
|
365
|
+
record, "source_busy",
|
|
366
|
+
"Source worker has an unfinished turn; wait for it to end or "
|
|
367
|
+
"stop the worker before forking",
|
|
368
|
+
)
|
|
369
|
+
now = os.fstat(pinned.fd)
|
|
370
|
+
if (now.st_size, now.st_mtime_ns) != (before.st_size, before.st_mtime_ns):
|
|
371
|
+
raise _conflict(record, "source_busy", "source native transcript changed during fork planning")
|
|
372
|
+
terminals = turn_settlements(owner.worker)
|
|
373
|
+
if terminals:
|
|
374
|
+
latest = terminals[-1]
|
|
375
|
+
count = (record.get("stats") or {}).get("turns")
|
|
376
|
+
if (type(count) is int and latest["turn"] < count
|
|
377
|
+
or (type(record.get("lastSettledAt")) is int
|
|
378
|
+
and latest["settledAtMs"] < record["lastSettledAt"])):
|
|
379
|
+
raise _conflict(record, "source_busy", "Claude original turn settlement is stale")
|
|
380
|
+
return {
|
|
381
|
+
"sourceNativeSessionId": sid, "resumeAt": last["uuid"],
|
|
382
|
+
"transcript": str(native_path),
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _turn_open(row: dict) -> bool:
|
|
387
|
+
"""An incomplete assistant/tool continuation is not a settled fork point."""
|
|
388
|
+
if row.get("type") == "assistant":
|
|
389
|
+
return (row.get("message") or {}).get("stop_reason") not in {
|
|
390
|
+
"end_turn", "stop_sequence", "max_tokens", "refusal",
|
|
391
|
+
}
|
|
392
|
+
if row.get("isMeta"):
|
|
393
|
+
return False
|
|
394
|
+
content = (row.get("message") or {}).get("content")
|
|
395
|
+
if isinstance(content, list):
|
|
396
|
+
content = " ".join(x.get("text") or "" for x in content if isinstance(x, dict))
|
|
397
|
+
return not isinstance(content, str) or not content.lstrip().startswith((
|
|
398
|
+
"[Request interrupted by user", "<command-name>", "<local-command-",
|
|
399
|
+
"<command-message>",
|
|
400
|
+
))
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _codex_point(store, owner, record: dict, deadline: float | None) -> dict:
|
|
404
|
+
"""Read the exact owned native thread without starting a model turn."""
|
|
405
|
+
from ccx.codex.runtime import read_thread
|
|
406
|
+
native = record.get("native") or {}
|
|
407
|
+
tid = native.get("threadId")
|
|
408
|
+
source = store.codex_source(owner.worker)
|
|
409
|
+
if (native.get("nativeState") != "thread-bound"
|
|
410
|
+
or native.get("threadSource") != source
|
|
411
|
+
or not isinstance(tid, str) or not tid):
|
|
412
|
+
raise SessionUnavailable("Codex source conversation cannot be verified; inspect it before forking")
|
|
413
|
+
thread = read_thread(
|
|
414
|
+
tid, cwd=record["cwd"], worker_id=owner.worker_id,
|
|
415
|
+
expected_source=source, limit=1, newest_first=True,
|
|
416
|
+
timeout=min(30., _remaining(deadline)) if deadline is not None else 30.,
|
|
417
|
+
)
|
|
418
|
+
if (thread.get("id") != tid
|
|
419
|
+
or thread.get("threadSource") != source):
|
|
420
|
+
raise SessionUnavailable("Codex source conversation changed; inspect it before forking")
|
|
421
|
+
turns = thread.get("turns")
|
|
422
|
+
native_state = thread.get("status")
|
|
423
|
+
if isinstance(native_state, dict):
|
|
424
|
+
native_state = native_state.get("type")
|
|
425
|
+
if native_state not in (None, "idle"):
|
|
426
|
+
raise _conflict(record, "source_busy", "Codex original native thread is not idle")
|
|
427
|
+
if not isinstance(turns, list) or not turns:
|
|
428
|
+
raise InvalidRequest("Source Codex worker has no turn to fork")
|
|
429
|
+
last = turns[-1]
|
|
430
|
+
if (not isinstance(last, dict) or not isinstance(last.get("id"), str)
|
|
431
|
+
or not last["id"] or last.get("status") != "completed"):
|
|
432
|
+
raise _conflict(record, "source_busy", "last native Codex turn is not completed")
|
|
433
|
+
recorded = record.get("lastTurn") or {}
|
|
434
|
+
if recorded.get("turnId") not in (None, last["id"]):
|
|
435
|
+
raise _conflict(record, "source_busy", "Codex source record/last native turn disagree")
|
|
436
|
+
return {"sourceNativeThreadId": tid, "lastTurnId": last["id"]}
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _launch(engine: str, args: dict) -> dict:
|
|
440
|
+
"""The original normalized creation arguments, preserved for retry."""
|
|
441
|
+
if engine == "claude":
|
|
442
|
+
return {
|
|
443
|
+
"model": args.get("model"), "effort": args.get("effort"),
|
|
444
|
+
"agent": args.get("agent"),
|
|
445
|
+
"append_system_prompt": args.get("append_system_prompt"),
|
|
446
|
+
"allowed_tools": args.get("allowed_tools"),
|
|
447
|
+
"disallowed_tools": args.get("disallowed_tools"),
|
|
448
|
+
"add_dir": args.get("add_dir") or None,
|
|
449
|
+
"permission_mode": args.get("permission_mode"),
|
|
450
|
+
"permissions": "full" if args.get("skip_permissions") is True else "restricted",
|
|
451
|
+
"idle_timeout": args.get("idle_timeout_s"),
|
|
452
|
+
}
|
|
453
|
+
return {
|
|
454
|
+
"model": args.get("model"), "effort": args.get("effort"),
|
|
455
|
+
"sandbox": args.get("sandbox"), "approval_policy": args.get("approvalPolicy"),
|
|
456
|
+
"idle_timeout": args.get("idleTimeout"),
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _retry(store, target, child_id: str, overrides: dict) -> dict | None:
|
|
461
|
+
"""Inspect only existing immutable owner/claim/record; NEVER reread source."""
|
|
462
|
+
from ccx.store.root import CreationIndeterminate, CreationConflict
|
|
463
|
+
from ccx.store.store import fingerprint_v2
|
|
464
|
+
from ccx.store.identity import parse_managed_target
|
|
465
|
+
source = parse_managed_target(target)
|
|
466
|
+
with store.lease():
|
|
467
|
+
owner = store._owner(child_id)
|
|
468
|
+
if owner is None:
|
|
469
|
+
return None
|
|
470
|
+
if not isinstance(owner, dict):
|
|
471
|
+
raise CreationIndeterminate(f"fork {child_id} original owner is unreadable")
|
|
472
|
+
intent = owner.get("createIntent")
|
|
473
|
+
if not isinstance(intent, dict):
|
|
474
|
+
raise CreationIndeterminate(f"fork {child_id} original immutable intent unavailable")
|
|
475
|
+
anchor = intent.get("forkAnchor")
|
|
476
|
+
if (not isinstance(anchor, dict)
|
|
477
|
+
or anchor.get("sourceWorkerId") != source.worker_id
|
|
478
|
+
or (source.qualified_engine is not None
|
|
479
|
+
and source.qualified_engine != anchor.get("engine"))
|
|
480
|
+
or anchor.get("overrides") != overrides):
|
|
481
|
+
raise OperationConflict(
|
|
482
|
+
f"worker_id {child_id} belongs to another original creation/fork",
|
|
483
|
+
worker_id=child_id, reason="same_worker_id_create_spec_mismatch",
|
|
484
|
+
)
|
|
485
|
+
engine = anchor.get("engine")
|
|
486
|
+
original_args = anchor.get("createArgs")
|
|
487
|
+
cwd = anchor.get("cwd")
|
|
488
|
+
claim = store._claim(child_id)
|
|
489
|
+
record = store._record(engine, child_id) if engine in {"claude", "codex"} else None
|
|
490
|
+
binding = claim.get("nativeBinding") if isinstance(claim, dict) else None
|
|
491
|
+
if (owner.get("status") not in {"worker", "indeterminate-reserved"}
|
|
492
|
+
or not isinstance(binding, dict)
|
|
493
|
+
or not isinstance(claim, dict) or not isinstance(record, dict)
|
|
494
|
+
or not isinstance(original_args, dict) or not isinstance(cwd, str)
|
|
495
|
+
or record.get("forkAnchor") != anchor
|
|
496
|
+
or record.get("createSha256") != claim.get("createSha256")
|
|
497
|
+
or record.get("fingerprintVersion") != 2
|
|
498
|
+
or record.get("workerId") != child_id or record.get("engine") != engine
|
|
499
|
+
or record.get("cwd") != cwd
|
|
500
|
+
or not isinstance(record.get("native"), dict)
|
|
501
|
+
or any(record["native"].get(k) != v for k, v in
|
|
502
|
+
binding.items())):
|
|
503
|
+
raise CreationIndeterminate(
|
|
504
|
+
f"fork {child_id} lacks original immutable child record proof"
|
|
505
|
+
)
|
|
506
|
+
lineage = record.get("forkedFrom")
|
|
507
|
+
lineage_keys = ("engine", "sourceWorkerId", "forkedAtMs") + (
|
|
508
|
+
("sourceNativeSessionId", "resumeAt") if engine == "claude"
|
|
509
|
+
else ("sourceNativeThreadId", "lastTurnId")
|
|
510
|
+
)
|
|
511
|
+
if not isinstance(lineage, dict) or any(
|
|
512
|
+
lineage.get(key) != anchor.get(key) for key in lineage_keys
|
|
513
|
+
):
|
|
514
|
+
raise CreationIndeterminate(
|
|
515
|
+
f"fork {child_id} first-record native point differs from original claim"
|
|
516
|
+
)
|
|
517
|
+
if (claim.get("forkAnchor") != anchor
|
|
518
|
+
or claim.get("workerId") != child_id
|
|
519
|
+
or claim.get("engine") != engine
|
|
520
|
+
or claim.get("generation") != store.generation
|
|
521
|
+
or claim.get("fingerprintVersion") != 2
|
|
522
|
+
or not isinstance(claim.get("createSha256"), str)
|
|
523
|
+
or fingerprint_v2(engine=engine, cwd=cwd, create_args=original_args,
|
|
524
|
+
fork_anchor=anchor) != claim["createSha256"]):
|
|
525
|
+
raise CreationIndeterminate(
|
|
526
|
+
f"fork {child_id} immutable first-create fingerprint changed"
|
|
527
|
+
)
|
|
528
|
+
try:
|
|
529
|
+
store._check_original_create_intent(
|
|
530
|
+
owner, engine=engine, worker_id=child_id,
|
|
531
|
+
fingerprint=claim["createSha256"],
|
|
532
|
+
native=claim["nativeBinding"], anchor=anchor,
|
|
533
|
+
)
|
|
534
|
+
except CreationConflict as exc:
|
|
535
|
+
raise CreationIndeterminate(
|
|
536
|
+
f"fork {child_id} has contradictory original identity"
|
|
537
|
+
) from exc
|
|
538
|
+
return {
|
|
539
|
+
"engine": engine, "source": None, "cwd": cwd,
|
|
540
|
+
"fork_anchor": anchor, "launch": _launch(engine, original_args),
|
|
541
|
+
"name": record.get("name"), "seed": anchor.get("seed") or {}, "existing": True,
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def plan(target: str, *, worker_id: str | None = None,
|
|
546
|
+
model: str | None = None, effort: str | None = None,
|
|
547
|
+
deadline: float | None = None,
|
|
548
|
+
expected_generation: str | None = None,
|
|
549
|
+
require_binary: bool = False) -> dict:
|
|
550
|
+
"""Prepare one frozen native fork point, without child creation or effects.
|
|
551
|
+
|
|
552
|
+
A supplied child UUID is checked FIRST: if it exists, only its original
|
|
553
|
+
full-ID owner/claim/record and frozen anchor can define a retry.
|
|
554
|
+
"""
|
|
555
|
+
from ccx import constants
|
|
556
|
+
from ccx.store.root import require_ccx_active
|
|
557
|
+
from ccx.store.identity import canonical_worker_id
|
|
558
|
+
from ccx.ops.creation_steps import validated_create_spec
|
|
559
|
+
store = require_ccx_active(
|
|
560
|
+
constants.CCX_ROOT, expected_generation=expected_generation,
|
|
561
|
+
)
|
|
562
|
+
for value in (model, effort):
|
|
563
|
+
if value is not None and (not isinstance(value, str) or not value.strip()):
|
|
564
|
+
raise InvalidRequest("fork model/effort overrides must be nonempty strings")
|
|
565
|
+
overrides = {"model": model, "effort": effort}
|
|
566
|
+
if worker_id is not None:
|
|
567
|
+
child_id = canonical_worker_id(worker_id)
|
|
568
|
+
previous = _retry(store, target, child_id, overrides)
|
|
569
|
+
if previous is not None:
|
|
570
|
+
store.revalidate()
|
|
571
|
+
return previous
|
|
572
|
+
_remaining(deadline)
|
|
573
|
+
owner = store.resolve(target)
|
|
574
|
+
if require_binary:
|
|
575
|
+
# After child-first conflict checks, but before any Codex native read.
|
|
576
|
+
from ccx.system.engine_binary import resolve_engine_binary
|
|
577
|
+
resolve_engine_binary(owner.engine)
|
|
578
|
+
record = store.record(owner.worker)
|
|
579
|
+
_settled(record)
|
|
580
|
+
store.authorize_native_action(owner.worker, "send") # original claim, no provider call
|
|
581
|
+
_no_pending_effects(owner, record)
|
|
582
|
+
if owner.engine == "claude":
|
|
583
|
+
point = _claude_point(store, owner, record, deadline)
|
|
584
|
+
else:
|
|
585
|
+
if record.get("historyKnownEmpty") is True:
|
|
586
|
+
raise InvalidRequest(
|
|
587
|
+
"cannot fork: source Codex thread has no completed conversation "
|
|
588
|
+
"turn; send its first prompt and retry"
|
|
589
|
+
)
|
|
590
|
+
point = _codex_point(store, owner, record, deadline)
|
|
591
|
+
_remaining(deadline)
|
|
592
|
+
# Fresh re-read refuses a source that acquired an in-flight turn meanwhile;
|
|
593
|
+
# native point itself is immutable, even if later activity starts.
|
|
594
|
+
current = store.record(owner.worker)
|
|
595
|
+
_settled(current)
|
|
596
|
+
_no_pending_effects(owner, current)
|
|
597
|
+
if (current.get("recordRevision") != record.get("recordRevision")
|
|
598
|
+
or current.get("native") != record.get("native")):
|
|
599
|
+
raise _conflict(record, "source_busy", "source changed while planning its fork")
|
|
600
|
+
original = record.get("createArgs")
|
|
601
|
+
if not isinstance(original, dict):
|
|
602
|
+
raise SessionUnavailable("Source worker settings are missing; inspect it before forking")
|
|
603
|
+
launch = _launch(owner.engine, original)
|
|
604
|
+
launch.update({key: value for key, value in overrides.items() if value is not None})
|
|
605
|
+
cwd, normalized = validated_create_spec(
|
|
606
|
+
engine=owner.engine, cwd=record["cwd"],
|
|
607
|
+
model=launch["model"], effort=launch["effort"],
|
|
608
|
+
idle_timeout=launch["idle_timeout"],
|
|
609
|
+
permissions=launch.get("permissions"),
|
|
610
|
+
sandbox=launch.get("sandbox"), approval_policy=launch.get("approval_policy"),
|
|
611
|
+
agent=launch.get("agent"), append_system_prompt=launch.get("append_system_prompt"),
|
|
612
|
+
allowed_tools=launch.get("allowed_tools"),
|
|
613
|
+
disallowed_tools=launch.get("disallowed_tools"),
|
|
614
|
+
add_dir=launch.get("add_dir"), permission_mode=launch.get("permission_mode"),
|
|
615
|
+
)
|
|
616
|
+
# Only settled/observed source metadata may seed the child's ordinal;
|
|
617
|
+
# subsequent source reconfiguration cannot change this original snapshot.
|
|
618
|
+
stats = record.get("stats") or {}
|
|
619
|
+
turns = stats.get("turns")
|
|
620
|
+
seed = {"stats": {"turns": turns if type(turns) is int and turns >= 0 else 0},
|
|
621
|
+
"lastTurnAt": record.get("lastTurnAt")}
|
|
622
|
+
if owner.engine == "codex":
|
|
623
|
+
last = record.get("lastTurn") or {}
|
|
624
|
+
if last.get("turnId") == point["lastTurnId"]:
|
|
625
|
+
seed["lastTurn"] = dict(last)
|
|
626
|
+
anchor = {
|
|
627
|
+
"engine": owner.engine, "sourceWorkerId": owner.worker_id,
|
|
628
|
+
**point, "cwd": cwd, "createArgs": normalized, "seed": seed,
|
|
629
|
+
"overrides": overrides, "forkedAtMs": now_ms(),
|
|
630
|
+
}
|
|
631
|
+
store.revalidate()
|
|
632
|
+
return {
|
|
633
|
+
"engine": owner.engine, "source": record, "cwd": cwd,
|
|
634
|
+
"fork_anchor": anchor, "launch": _launch(owner.engine, normalized),
|
|
635
|
+
"name": f"{record.get('name') or owner.short} (fork)",
|
|
636
|
+
"seed": seed, "existing": False,
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def fork(
|
|
641
|
+
target: str, *, worker_id: str | None = None,
|
|
642
|
+
name: str | None = None, model: str | None = None,
|
|
643
|
+
effort: str | None = None, created_via: str = "local-json-v1",
|
|
644
|
+
prompt: str | None = None,
|
|
645
|
+
caps: dict | None = None, wait: bool = False,
|
|
646
|
+
attachment=None, delivery_id: str | None = None,
|
|
647
|
+
parent: dict | None = None, settle: bool = False,
|
|
648
|
+
deadline: float | None = None, expected_generation: str | None = None,
|
|
649
|
+
start_timeout: float | None = None,
|
|
650
|
+
) -> dict:
|
|
651
|
+
"""Original-native fork with optional first turn through creation.create.
|
|
652
|
+
|
|
653
|
+
A caller-known child is checked by the planner against its original intent
|
|
654
|
+
before any source observation. Native replay belongs solely to creation.
|
|
655
|
+
"""
|
|
656
|
+
from ccx.ops.creation import create
|
|
657
|
+
|
|
658
|
+
_remaining(deadline)
|
|
659
|
+
planned = plan(
|
|
660
|
+
target, worker_id=worker_id, model=model, effort=effort,
|
|
661
|
+
deadline=deadline, expected_generation=expected_generation,
|
|
662
|
+
require_binary=True,
|
|
663
|
+
)
|
|
664
|
+
# Only an explicit fork --model override requires a new live provider
|
|
665
|
+
# validation. An inherited model is frozen in the original fork spec.
|
|
666
|
+
if planned["engine"] == "codex" and model is not None:
|
|
667
|
+
from ccx.codex.runtime import validate_model
|
|
668
|
+
validate_model(model, cwd=planned["cwd"])
|
|
669
|
+
remaining = _remaining(deadline)
|
|
670
|
+
native_budget = 90.0 if start_timeout is None else start_timeout
|
|
671
|
+
if remaining is not None:
|
|
672
|
+
native_budget = min(native_budget, remaining)
|
|
673
|
+
result = create(
|
|
674
|
+
engine=planned["engine"], cwd=planned["cwd"],
|
|
675
|
+
worker_id=worker_id, name=name if name is not None else planned["name"],
|
|
676
|
+
_fork_anchor=planned["fork_anchor"], **planned["launch"],
|
|
677
|
+
created_via=created_via, start_timeout=native_budget,
|
|
678
|
+
expected_generation=expected_generation, deadline=deadline,
|
|
679
|
+
prompt=prompt, caps=caps, wait=wait,
|
|
680
|
+
attachment=attachment, delivery_id=delivery_id,
|
|
681
|
+
parent=parent, settle=settle,
|
|
682
|
+
)
|
|
683
|
+
return {**result, "forked_from": public_lineage(result["record"])}
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def public_lineage(record: dict | None) -> dict | None:
|
|
687
|
+
"""Original full-UUID source handle; native session/thread IDs stay separate."""
|
|
688
|
+
if not isinstance(record, dict):
|
|
689
|
+
return None
|
|
690
|
+
fork = record.get("forkedFrom")
|
|
691
|
+
if not isinstance(fork, dict):
|
|
692
|
+
return None
|
|
693
|
+
from ccx.store.identity import InvalidTarget, canonical_worker_id
|
|
694
|
+
engine = fork.get("engine")
|
|
695
|
+
try:
|
|
696
|
+
source_id = canonical_worker_id(fork.get("sourceWorkerId"))
|
|
697
|
+
except InvalidTarget:
|
|
698
|
+
source_id = None
|
|
699
|
+
if engine not in {"claude", "codex"}:
|
|
700
|
+
return None
|
|
701
|
+
return {
|
|
702
|
+
"worker": f"{engine}:{source_id}" if source_id else None,
|
|
703
|
+
"engine": engine, "short": source_id[:8] if source_id else None,
|
|
704
|
+
"worker_id": source_id,
|
|
705
|
+
"session_id": fork.get("sourceNativeSessionId") if engine == "claude" else None,
|
|
706
|
+
"thread_id": fork.get("sourceNativeThreadId") if engine == "codex" else None,
|
|
707
|
+
"forked_at_ms": fork.get("forkedAtMs"),
|
|
708
|
+
**({"source_kind": "codex-thread"}
|
|
709
|
+
if engine == "codex" and fork.get("sourceKind") == "codex-thread"
|
|
710
|
+
and fork.get("sourceWorkerId") is None else {}),
|
|
711
|
+
}
|