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
|
File without changes
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""A small engine-neutral recorded snapshot of background work.
|
|
2
|
+
|
|
3
|
+
Producers own these facts; consumers only read the saved summary/event.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
KINDS = ("command", "monitor", "subagent", "notification")
|
|
11
|
+
OPEN = {"running", "pending"}
|
|
12
|
+
LABEL_LIMIT = 120
|
|
13
|
+
EVENT_LIMIT = 1000
|
|
14
|
+
ITEM_LIMIT = 100
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def label(value: object, fallback: str) -> str:
|
|
18
|
+
text = re.sub(r"\s+", " ", str(value)) if isinstance(value, str) else ""
|
|
19
|
+
text = "".join(ch for ch in text if ch.isprintable()).strip()
|
|
20
|
+
return text[:LABEL_LIMIT] or fallback
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def bounded_event(value: object) -> str | None:
|
|
24
|
+
"""Bound one sanitized Monitor notification; labels remain much shorter."""
|
|
25
|
+
if not isinstance(value, str):
|
|
26
|
+
return None
|
|
27
|
+
return re.sub(r"\s+", " ", value).strip()[:EVENT_LIMIT]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def summary(items: list[dict], *, as_of_ms: int | None = None,
|
|
32
|
+
available: bool = True) -> dict:
|
|
33
|
+
if as_of_ms is None:
|
|
34
|
+
as_of_ms = int(time.time() * 1000)
|
|
35
|
+
if not available:
|
|
36
|
+
return {
|
|
37
|
+
"running_count": None, "pending_count": None, "by_kind": None,
|
|
38
|
+
"items": [], "items_truncated": 0,
|
|
39
|
+
"as_of_ms": as_of_ms, "available": False,
|
|
40
|
+
}
|
|
41
|
+
active = [dict(item) for item in items
|
|
42
|
+
if item.get("status") in OPEN and item.get("kind") in KINDS]
|
|
43
|
+
by_kind = {kind: sum(item["kind"] == kind for item in active) for kind in KINDS}
|
|
44
|
+
# Sort once, before applying the display bound. Unknown start times are
|
|
45
|
+
# after known starts, then every item is ordered by stable ID.
|
|
46
|
+
active.sort(key=lambda item: (
|
|
47
|
+
item.get("started_at_ms") if type(item.get("started_at_ms")) is int
|
|
48
|
+
else float("inf"), item["id"]))
|
|
49
|
+
# Keep snapshots small and deterministic; avoid leaking native content.
|
|
50
|
+
preview = [{key: (bounded_event(item[key]) if key == "event" else item[key])
|
|
51
|
+
for key in ("id", "kind", "status", "label", "event", "event_count",
|
|
52
|
+
"expires_at_ms", "notification_id", "delivery_id")
|
|
53
|
+
if key in item} for item in active[:ITEM_LIMIT]]
|
|
54
|
+
return {
|
|
55
|
+
"running_count": sum(by_kind[kind] for kind in KINDS if kind != "notification"),
|
|
56
|
+
"pending_count": by_kind["notification"],
|
|
57
|
+
"by_kind": by_kind, "items": preview,
|
|
58
|
+
"items_truncated": max(0, len(active) - len(preview)),
|
|
59
|
+
"as_of_ms": as_of_ms, "available": True,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def materially_changed(previous: dict | None, current: dict) -> bool:
|
|
64
|
+
if previous is None:
|
|
65
|
+
return True
|
|
66
|
+
return {key: value for key, value in previous.items() if key != "as_of_ms"} != {
|
|
67
|
+
key: value for key, value in current.items() if key != "as_of_ms"}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def claude_task_item(task: dict) -> dict | None:
|
|
71
|
+
task_id = task.get("task_id")
|
|
72
|
+
if not isinstance(task_id, str) or not task_id:
|
|
73
|
+
return None # launching tool-result has not yet proved native task identity
|
|
74
|
+
native_kind = task.get("kind")
|
|
75
|
+
kind = "command" if native_kind == "bash" else native_kind
|
|
76
|
+
if kind not in ("command", "monitor", "subagent"):
|
|
77
|
+
return None
|
|
78
|
+
item = {
|
|
79
|
+
"id": f"{kind}:{task_id}", "kind": kind,
|
|
80
|
+
"status": task.get("status") or "unknown",
|
|
81
|
+
"label": label(task.get("description"), f"background {kind}"),
|
|
82
|
+
"tool_use_id": task.get("tool_use_id"),
|
|
83
|
+
"started_at_ms": task.get("launched_at"),
|
|
84
|
+
}
|
|
85
|
+
if kind == "monitor":
|
|
86
|
+
launched, timeout = task.get("launched_at"), task.get("timeout_ms")
|
|
87
|
+
item["expires_at_ms"] = (launched + timeout if type(launched) is int
|
|
88
|
+
and type(timeout) is int and timeout > 0 else None)
|
|
89
|
+
item["event_count"] = task.get("event_count") or 0
|
|
90
|
+
if task.get("last_event"):
|
|
91
|
+
item["event"] = bounded_event(task["last_event"])
|
|
92
|
+
if task.get("exit_code") is not None:
|
|
93
|
+
item["exit_code"] = task["exit_code"]
|
|
94
|
+
return item
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def diff_items(before: list[dict], after: list[dict]) -> list[tuple[str, dict]]:
|
|
98
|
+
"""One source-row transition, never a second invented native observation."""
|
|
99
|
+
old = {item["id"]: item for item in before}
|
|
100
|
+
out = []
|
|
101
|
+
for item in after:
|
|
102
|
+
prior = old.get(item["id"])
|
|
103
|
+
if item == prior:
|
|
104
|
+
continue
|
|
105
|
+
status = item.get("status")
|
|
106
|
+
if prior is None or (prior.get("status") not in OPEN and status in OPEN):
|
|
107
|
+
transition = "reopened" if prior is not None else "started"
|
|
108
|
+
elif status not in OPEN and (prior is None or prior.get("status") in OPEN):
|
|
109
|
+
transition = {"completed": "finished", "failed": "failed",
|
|
110
|
+
"stopped": "stopped"}.get(status, "event")
|
|
111
|
+
else:
|
|
112
|
+
transition = "event"
|
|
113
|
+
out.append((transition, item))
|
|
114
|
+
return out
|
|
115
|
+
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import time
|
|
3
|
+
from typing import Any
|
|
4
|
+
from ccx.errors import (
|
|
5
|
+
DeliveryRejected,
|
|
6
|
+
InvalidRequest,
|
|
7
|
+
OperationConflict,
|
|
8
|
+
SessionUnavailable,
|
|
9
|
+
)
|
|
10
|
+
from ccx.ops.common import split_target
|
|
11
|
+
from ccx.ops.wait import reconcile_turn
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _cancel_live_turn(handle: str, engine: str, worker_id: str,
|
|
15
|
+
*, timeout: float = 0.5) -> tuple[bool, str | None]:
|
|
16
|
+
"""Authenticated exact active turn, or UNKNOWN (never inferred from absence)."""
|
|
17
|
+
if engine == "claude":
|
|
18
|
+
from ccx.rpc.rpc import _rpc
|
|
19
|
+
reply = _rpc(handle, {"op": "status"}, timeout=timeout)
|
|
20
|
+
if reply.get("ok") is True and "activeTurnRef" in reply:
|
|
21
|
+
return True, reply["activeTurnRef"]
|
|
22
|
+
return False, None
|
|
23
|
+
from ccx.codex.runtime import daemon_rpc
|
|
24
|
+
from ccx.constants import CODEXD_RPC_GENERATION
|
|
25
|
+
reply = daemon_rpc({"op": "status", "short": worker_id,
|
|
26
|
+
"rpcGeneration": CODEXD_RPC_GENERATION}, timeout=timeout)
|
|
27
|
+
if (reply.get("ok") is True and reply.get("loaded") is True
|
|
28
|
+
and "activeTurnId" in reply):
|
|
29
|
+
return True, ("ccx-turn:codex:v1:" + reply["activeTurnId"]
|
|
30
|
+
if isinstance(reply["activeTurnId"], str) else None)
|
|
31
|
+
return False, None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def cancel_turn(
|
|
35
|
+
target: str, *, cancellation_id: str, turn_ref: str,
|
|
36
|
+
timeout: float | None = 90,
|
|
37
|
+
expected_generation: str | None = None,
|
|
38
|
+
) -> dict[str, Any]:
|
|
39
|
+
"""Request one exact full-UUID turn interrupt, never replay its native write.
|
|
40
|
+
|
|
41
|
+
protocol cancellation_id is caller-owned. The existing GenerationStore 'cancel'
|
|
42
|
+
intent is the only ledger, independent of legacy short-keyed turn_cancel.
|
|
43
|
+
A native ACK means requested, not that model activity stopped.
|
|
44
|
+
"""
|
|
45
|
+
from math import isfinite
|
|
46
|
+
from ccx import constants
|
|
47
|
+
from ccx.store.root import NativeEffectIndeterminate, require_ccx_active
|
|
48
|
+
from ccx.store.store import _digest
|
|
49
|
+
from ccx.store.identity import canonical_worker_id
|
|
50
|
+
from ccx.codex.delivery import receipt_for_turn as codex_receipt_for_turn
|
|
51
|
+
|
|
52
|
+
if (not isinstance(cancellation_id, str) or not cancellation_id
|
|
53
|
+
or len(cancellation_id) > 128 or "\x00" in cancellation_id):
|
|
54
|
+
raise InvalidRequest("cancellation_id must be nonempty and <=128 characters")
|
|
55
|
+
if (timeout is not None and (type(timeout) not in (int, float)
|
|
56
|
+
or not isfinite(timeout) or timeout <= 0)):
|
|
57
|
+
raise InvalidRequest("cancel timeout must be positive finite seconds")
|
|
58
|
+
handle, engine, worker_id = split_target(target)
|
|
59
|
+
canonical_worker_id(worker_id)
|
|
60
|
+
prefix = f"ccx-turn:{engine}:v1:"
|
|
61
|
+
if not isinstance(turn_ref, str) or not turn_ref.startswith(prefix) or not turn_ref[len(prefix):]:
|
|
62
|
+
raise InvalidRequest("turn_ref must name an exact managed turn for the selected engine")
|
|
63
|
+
native_turn_id = turn_ref[len(prefix):] if engine == "codex" else None
|
|
64
|
+
store = require_ccx_active(constants.CCX_ROOT,
|
|
65
|
+
expected_generation=expected_generation)
|
|
66
|
+
rec = store.record(handle)
|
|
67
|
+
source = rec.get("native") or {}
|
|
68
|
+
if (engine == "claude" and not isinstance(source.get("sessionId"), str)) or (
|
|
69
|
+
engine == "codex" and (not isinstance(source.get("threadId"), str)
|
|
70
|
+
or source.get("threadSource") != store.codex_source(handle))
|
|
71
|
+
):
|
|
72
|
+
raise SessionUnavailable("original native worker binding is not verified")
|
|
73
|
+
identity = _digest({"workerId": worker_id, "engine": engine,
|
|
74
|
+
"turnRef": turn_ref, "operation": "cancel"})
|
|
75
|
+
prior = store.read_receipt(handle, "cancel", cancellation_id)
|
|
76
|
+
if prior is not None and prior.get("intentSha256") != identity:
|
|
77
|
+
raise OperationConflict("cancellation_id already names a different turn",
|
|
78
|
+
short=worker_id[:8], worker_id=worker_id)
|
|
79
|
+
# In particular, an invented native id is never a cancel capability.
|
|
80
|
+
if engine == "codex":
|
|
81
|
+
opening = codex_receipt_for_turn(handle, native_turn_id)
|
|
82
|
+
else:
|
|
83
|
+
from ccx.claude.delivery import iter_receipts
|
|
84
|
+
opening = next((row for row in iter_receipts(handle)
|
|
85
|
+
if row.get("turnRef") == turn_ref
|
|
86
|
+
and row.get("route") == "new_turn"), None)
|
|
87
|
+
if prior is None and (not isinstance(opening, dict)
|
|
88
|
+
or opening.get("state") != "accepted"):
|
|
89
|
+
# Human input on the shared managed Claude PTY starts a genuine parent
|
|
90
|
+
# turn without a CCX delivery receipt. Do not fabricate one: the live
|
|
91
|
+
# authenticated host's exact activeTurnRef is sufficient to request a
|
|
92
|
+
# one-shot native Escape, under the same durable cancel intent. This
|
|
93
|
+
# exception never applies to Codex or an absent/invented/stale turn.
|
|
94
|
+
if engine != "claude":
|
|
95
|
+
raise DeliveryRejected("exact active turn has no original accepted delivery receipt",
|
|
96
|
+
short=worker_id[:8])
|
|
97
|
+
known, active = _cancel_live_turn(handle, engine, worker_id)
|
|
98
|
+
store.revalidate()
|
|
99
|
+
if not known or active != turn_ref:
|
|
100
|
+
raise DeliveryRejected("exact active turn has no original accepted delivery receipt",
|
|
101
|
+
short=worker_id[:8])
|
|
102
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
103
|
+
|
|
104
|
+
def public(state: str, *, outcome=None, reason=None, owned=False,
|
|
105
|
+
receipt=None) -> dict[str, Any]:
|
|
106
|
+
return {
|
|
107
|
+
"worker": {"worker": handle, "worker_id": worker_id,
|
|
108
|
+
"engine": engine, "short": worker_id[:8]},
|
|
109
|
+
"cancellation_id": cancellation_id, "turn_ref": turn_ref,
|
|
110
|
+
"status": state, "confirmed": state == "confirmed",
|
|
111
|
+
"owned": owned, "accepted": bool(receipt and receipt.get("state") == "accepted"),
|
|
112
|
+
"recommended_action": ("retry_same_id" if state == "prepared"
|
|
113
|
+
else "wait" if state == "submitted" else "none"),
|
|
114
|
+
"outcome": outcome, "reason": reason,
|
|
115
|
+
"receipt": receipt,
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
def observe(receipt: dict) -> dict | None:
|
|
119
|
+
# Original semantic receipt is the only proof of completed assistant
|
|
120
|
+
# text. A changed live ref proves INACTIVITY, not why the turn stopped.
|
|
121
|
+
def exact_claude_terminal() -> dict | None:
|
|
122
|
+
last = store.record(handle).get("lastSettledTurn") or {}
|
|
123
|
+
if (last.get("turnRef") == turn_ref
|
|
124
|
+
and last.get("outcome") in {"interrupted", "completed", "failed"}):
|
|
125
|
+
return public("confirmed", outcome=last["outcome"],
|
|
126
|
+
owned=True, receipt=receipt)
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
if engine == "codex":
|
|
130
|
+
seen = reconcile_turn(handle, turn_id=native_turn_id,
|
|
131
|
+
expected_generation=store.generation)
|
|
132
|
+
if seen.get("status") == "settled":
|
|
133
|
+
return public("confirmed", outcome="completed", owned=True,
|
|
134
|
+
receipt=receipt)
|
|
135
|
+
if seen.get("native_status") in ("interrupted", "cancelled"):
|
|
136
|
+
return public("confirmed", outcome="interrupted", owned=True,
|
|
137
|
+
receipt=receipt)
|
|
138
|
+
else:
|
|
139
|
+
from ccx.claude.delivery import read_receipt
|
|
140
|
+
latest = read_receipt(handle, opening["deliveryId"]) if opening else None
|
|
141
|
+
if (latest and latest.get("turnRef") == turn_ref
|
|
142
|
+
and isinstance(latest.get("settlement"), dict)):
|
|
143
|
+
return public("confirmed", outcome="completed", owned=True,
|
|
144
|
+
receipt=receipt)
|
|
145
|
+
# Positive completed-answer receipt wins above. Otherwise the
|
|
146
|
+
# exact original host finalizer proves this turn's outcome for
|
|
147
|
+
# both human and receipt-backed cancellations; it does not invent
|
|
148
|
+
# an opening delivery receipt or completed assistant text.
|
|
149
|
+
terminal = exact_claude_terminal()
|
|
150
|
+
if terminal is not None:
|
|
151
|
+
return terminal
|
|
152
|
+
known, active = _cancel_live_turn(handle, engine, worker_id)
|
|
153
|
+
store.revalidate()
|
|
154
|
+
if known and active != turn_ref:
|
|
155
|
+
if engine == "claude":
|
|
156
|
+
# The terminal CAS can land between our first record read and
|
|
157
|
+
# the host status RPC. Reread the same exact source before
|
|
158
|
+
# falling back to unknown; never report an invented outcome.
|
|
159
|
+
terminal = exact_claude_terminal()
|
|
160
|
+
if terminal is not None:
|
|
161
|
+
return terminal
|
|
162
|
+
return public("confirmed", outcome="unknown", owned=True,
|
|
163
|
+
receipt=receipt)
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
# No preflight can replace the provider's expected-turn check, but an
|
|
167
|
+
# observed IDLE/other turn can reject without any native effect at all.
|
|
168
|
+
if prior is None or prior.get("state") == "prepared":
|
|
169
|
+
store.authorize_native_action(handle, "cancel")
|
|
170
|
+
known, active = _cancel_live_turn(handle, engine, worker_id)
|
|
171
|
+
if not known:
|
|
172
|
+
return public("prepared", reason="live active turn not proven")
|
|
173
|
+
if active != turn_ref:
|
|
174
|
+
return public("rejected", reason="active turn changed or no active turn")
|
|
175
|
+
attempt = store.begin_native_intent(
|
|
176
|
+
handle, family="cancel", action="cancel", operation_id=cancellation_id,
|
|
177
|
+
intent_sha256=identity,
|
|
178
|
+
)
|
|
179
|
+
if attempt.replayed:
|
|
180
|
+
prior = store.read_receipt(handle, "cancel", cancellation_id)
|
|
181
|
+
else:
|
|
182
|
+
from ccx.constants import CODEXD_RPC_GENERATION
|
|
183
|
+
from ccx.codex.runtime import daemon_rpc
|
|
184
|
+
from ccx.rpc.rpc import _rpc
|
|
185
|
+
budget = min(2., max(.01, deadline - time.monotonic())) if deadline else 2.
|
|
186
|
+
def submit(seconds):
|
|
187
|
+
if engine == "claude":
|
|
188
|
+
return _rpc(handle, {"op": "interrupt",
|
|
189
|
+
"expected_turn_ref": turn_ref}, timeout=seconds)
|
|
190
|
+
return daemon_rpc({"op": "interrupt", "short": worker_id,
|
|
191
|
+
"expectedTurnId": native_turn_id,
|
|
192
|
+
"rpcGeneration": CODEXD_RPC_GENERATION}, timeout=seconds)
|
|
193
|
+
try:
|
|
194
|
+
reply = store.dispatch_native_intent_once(
|
|
195
|
+
attempt, bounded_submit=submit, dispatch_budget_s=budget)
|
|
196
|
+
except NativeEffectIndeterminate:
|
|
197
|
+
# Durable submitted-unknown; a same-ID caller only observes.
|
|
198
|
+
prior = store.read_receipt(handle, "cancel", cancellation_id)
|
|
199
|
+
return public("submitted", reason="original interrupt reply unknown",
|
|
200
|
+
owned=True, receipt=prior)
|
|
201
|
+
if (reply.get("ok") is True and
|
|
202
|
+
(engine == "codex" and reply.get("turnId") == native_turn_id
|
|
203
|
+
or engine == "claude" and reply.get("turnRef") == turn_ref
|
|
204
|
+
and (reply.get("sent") is True or
|
|
205
|
+
(reply.get("sent") is False and
|
|
206
|
+
reply.get("already_observing") is True)))):
|
|
207
|
+
# A prior same-turn Escape is accepted, never proof of completion.
|
|
208
|
+
state = "accepted"
|
|
209
|
+
elif reply.get("ok") is False and reply.get("definitive") is True:
|
|
210
|
+
state = "rejected"
|
|
211
|
+
else:
|
|
212
|
+
prior = store.read_receipt(handle, "cancel", cancellation_id)
|
|
213
|
+
return public("submitted", reason="native interrupt not positively acknowledged",
|
|
214
|
+
owned=True, receipt=prior)
|
|
215
|
+
prior = store.settle_native_intent(
|
|
216
|
+
attempt, state=state, native_evidence_sha256=_digest({
|
|
217
|
+
"workerId": worker_id, "turnRef": turn_ref, "reply": reply,
|
|
218
|
+
}),
|
|
219
|
+
)
|
|
220
|
+
if state == "rejected":
|
|
221
|
+
return public("rejected", reason=str(reply.get("error")), receipt=prior)
|
|
222
|
+
if prior is None:
|
|
223
|
+
prior = store.read_receipt(handle, "cancel", cancellation_id)
|
|
224
|
+
if prior.get("state") == "rejected":
|
|
225
|
+
return public("rejected", reason="original native interrupt refused", receipt=prior)
|
|
226
|
+
# Acceptance and caller timeout do not mutate the durable native effect:
|
|
227
|
+
# a later same-ID call may still observe terminality, but cannot re-dispatch.
|
|
228
|
+
while True:
|
|
229
|
+
store.revalidate()
|
|
230
|
+
try:
|
|
231
|
+
verified = observe(prior)
|
|
232
|
+
except (SessionUnavailable, OSError):
|
|
233
|
+
store.revalidate() # changed ACTIVE is not a temporary daemon loss
|
|
234
|
+
verified = None # temporary runtime loss is NOT changed-turn proof
|
|
235
|
+
if verified is not None:
|
|
236
|
+
return verified
|
|
237
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
238
|
+
return public("unknown", reason="exact target terminality not proven",
|
|
239
|
+
owned=True, receipt=prior)
|
|
240
|
+
time.sleep(.2 if deadline is None else
|
|
241
|
+
min(.2, max(0., deadline-time.monotonic())))
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def stop_worker(
|
|
245
|
+
target: str, *, timeout: float | None = 1.0,
|
|
246
|
+
expected_turn_ref: str | None = None,
|
|
247
|
+
expected_generation: str | None = None,
|
|
248
|
+
) -> dict[str, Any]:
|
|
249
|
+
"""One explicit stop request, fresh ID, exact ref; acceptance is not completion."""
|
|
250
|
+
from uuid import uuid4
|
|
251
|
+
from ccx import constants
|
|
252
|
+
from ccx.errors import TurnIncomplete
|
|
253
|
+
from ccx.store.root import require_ccx_active
|
|
254
|
+
|
|
255
|
+
handle, engine, worker_id = split_target(target)
|
|
256
|
+
store = require_ccx_active(constants.CCX_ROOT,
|
|
257
|
+
expected_generation=expected_generation)
|
|
258
|
+
if expected_turn_ref is None:
|
|
259
|
+
known, active = _cancel_live_turn(handle, engine, worker_id)
|
|
260
|
+
store.revalidate()
|
|
261
|
+
if not known:
|
|
262
|
+
raise SessionUnavailable("stop: active turn could not be observed",
|
|
263
|
+
short=worker_id[:8])
|
|
264
|
+
if not active:
|
|
265
|
+
raise DeliveryRejected("stop: worker has no active turn",
|
|
266
|
+
short=worker_id[:8])
|
|
267
|
+
else:
|
|
268
|
+
active = expected_turn_ref # cancel_turn validates the exact ref.
|
|
269
|
+
result = cancel_turn(handle, cancellation_id=str(uuid4()),
|
|
270
|
+
turn_ref=active, timeout=timeout,
|
|
271
|
+
expected_generation=store.generation)
|
|
272
|
+
if result["status"] == "rejected":
|
|
273
|
+
raise DeliveryRejected(result.get("reason") or "active turn changed",
|
|
274
|
+
short=worker_id[:8],
|
|
275
|
+
operation_id=result["cancellation_id"])
|
|
276
|
+
if result["status"] == "prepared":
|
|
277
|
+
raise SessionUnavailable("stop: original interrupt not submitted",
|
|
278
|
+
short=worker_id[:8])
|
|
279
|
+
if not result.get("accepted"):
|
|
280
|
+
error = TurnIncomplete(
|
|
281
|
+
"stop: native interrupt acceptance unconfirmed; "
|
|
282
|
+
"reconcile the original cancellation ID, do not repeat it",
|
|
283
|
+
short=worker_id[:8], turn_status=result["status"],
|
|
284
|
+
)
|
|
285
|
+
error.cancellation_id = result["cancellation_id"]
|
|
286
|
+
error.turn_ref = result["turn_ref"]
|
|
287
|
+
error.worker_id = worker_id
|
|
288
|
+
raise error
|
|
289
|
+
return result
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Fork a user-owned native Codex thread into a separate ordinary CCX worker."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import time
|
|
5
|
+
from uuid import UUID
|
|
6
|
+
|
|
7
|
+
from ccx.errors import InvalidRequest, SessionUnavailable
|
|
8
|
+
from ccx.ops.creation_steps import validated_create_spec
|
|
9
|
+
from ccx.ops.creation import create
|
|
10
|
+
from ccx.store.identity import mint_worker_id
|
|
11
|
+
from ccx.constants import DEFAULT_IDLE_TIMEOUT_S
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def import_thread(
|
|
15
|
+
thread_id: str, *, source: dict | None = None,
|
|
16
|
+
model: str | None = None, effort: str | None = None,
|
|
17
|
+
) -> dict:
|
|
18
|
+
"""Only the child is native-mutated; the original may remain open in Codex.
|
|
19
|
+
|
|
20
|
+
Importing a second time intentionally forks a second independent child.
|
|
21
|
+
"""
|
|
22
|
+
if not isinstance(thread_id, str):
|
|
23
|
+
raise InvalidRequest("Codex import needs an exact native thread UUID")
|
|
24
|
+
try:
|
|
25
|
+
if str(UUID(thread_id)) != thread_id:
|
|
26
|
+
raise ValueError("noncanonical")
|
|
27
|
+
except (ValueError, TypeError, AttributeError) as exc:
|
|
28
|
+
raise InvalidRequest("Codex import needs an exact native thread UUID") from exc
|
|
29
|
+
if any(value is not None and (not isinstance(value, str) or not value.strip())
|
|
30
|
+
for value in (model, effort)):
|
|
31
|
+
raise InvalidRequest("import model/effort overrides must be nonempty strings")
|
|
32
|
+
if source is None:
|
|
33
|
+
from ccx.codex.runtime import read_import_source
|
|
34
|
+
source = read_import_source(thread_id)
|
|
35
|
+
thread = source.get("thread") if isinstance(source, dict) else None
|
|
36
|
+
last = source.get("lastTurn") if isinstance(source, dict) else None
|
|
37
|
+
if not isinstance(thread, dict) or thread.get("id") != thread_id:
|
|
38
|
+
raise SessionUnavailable("Codex import native thread ID was not confirmed")
|
|
39
|
+
status = thread.get("status")
|
|
40
|
+
status = status.get("type") if isinstance(status, dict) else status
|
|
41
|
+
if status not in (None, "idle", "notLoaded"):
|
|
42
|
+
raise SessionUnavailable("Codex source has an active turn; wait for it to finish")
|
|
43
|
+
if (not isinstance(last, dict) or not isinstance(last.get("id"), str)
|
|
44
|
+
or not last["id"] or last.get("status") != "completed"):
|
|
45
|
+
raise SessionUnavailable(
|
|
46
|
+
"Codex import needs a latest completed native turn; no worker created"
|
|
47
|
+
)
|
|
48
|
+
cwd = thread.get("cwd")
|
|
49
|
+
if not isinstance(cwd, str) or not cwd:
|
|
50
|
+
raise SessionUnavailable("Codex source has no recorded workspace")
|
|
51
|
+
# Native metadata may omit the latest model context. Never infer it from
|
|
52
|
+
# prose/rollout files; leave omitted settings at the normal CCX defaults.
|
|
53
|
+
native_model = last.get("model") or thread.get("model")
|
|
54
|
+
native_effort = (last.get("reasoningEffort") or last.get("effort")
|
|
55
|
+
or thread.get("reasoningEffort"))
|
|
56
|
+
chosen_model = model if model is not None else (
|
|
57
|
+
native_model if isinstance(native_model, str) and native_model.strip() else None
|
|
58
|
+
)
|
|
59
|
+
chosen_effort = effort if effort is not None else (
|
|
60
|
+
native_effort if isinstance(native_effort, str) and native_effort.strip() else None
|
|
61
|
+
)
|
|
62
|
+
directory, args = validated_create_spec(
|
|
63
|
+
engine="codex", cwd=cwd, model=chosen_model, effort=chosen_effort,
|
|
64
|
+
idle_timeout=DEFAULT_IDLE_TIMEOUT_S, permissions="full",
|
|
65
|
+
sandbox=None, approval_policy=None, agent=None,
|
|
66
|
+
append_system_prompt=None, allowed_tools=None, disallowed_tools=None,
|
|
67
|
+
add_dir=None, permission_mode=None,
|
|
68
|
+
)
|
|
69
|
+
from ccx.codex.runtime import validate_model
|
|
70
|
+
if model is not None:
|
|
71
|
+
validate_model(model, cwd=directory)
|
|
72
|
+
name = thread.get("name") or thread.get("title")
|
|
73
|
+
if not isinstance(name, str) or not name.strip():
|
|
74
|
+
name = None
|
|
75
|
+
completed_at = last.get("completedAt")
|
|
76
|
+
last_at = (int(completed_at * 1000)
|
|
77
|
+
if type(completed_at) in (float, int) and completed_at > 0 else None)
|
|
78
|
+
worker_id = mint_worker_id()
|
|
79
|
+
anchor = {
|
|
80
|
+
"engine": "codex", "sourceKind": "codex-thread",
|
|
81
|
+
"sourceWorkerId": None, "sourceNativeThreadId": thread_id,
|
|
82
|
+
"lastTurnId": last["id"], "cwd": directory,
|
|
83
|
+
"createArgs": args,
|
|
84
|
+
# The newest page cannot attest the historical total; zero here
|
|
85
|
+
# counts managed submissions, NOT all source turns.
|
|
86
|
+
"seed": {"stats": {"turns": 0}, "lastTurnAt": last_at,
|
|
87
|
+
"lastTurn": {"turnId": last["id"], "status": "completed"}},
|
|
88
|
+
"overrides": {"model": model, "effort": effort},
|
|
89
|
+
"forkedAtMs": int(time.time() * 1000),
|
|
90
|
+
}
|
|
91
|
+
return create(
|
|
92
|
+
engine="codex", cwd=directory, worker_id=worker_id, name=name,
|
|
93
|
+
model=chosen_model, effort=chosen_effort,
|
|
94
|
+
permissions="full", idle_timeout=DEFAULT_IDLE_TIMEOUT_S,
|
|
95
|
+
created_via="import", _fork_anchor=anchor, start_timeout=90,
|
|
96
|
+
)
|