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,383 @@
|
|
|
1
|
+
"""Confirmed forced termination of a CCX-owned runtime, shared by both engines.
|
|
2
|
+
|
|
3
|
+
``kill`` is the strong lifecycle verb, but what it must confirm differs by
|
|
4
|
+
engine, because the two runtimes are shaped differently:
|
|
5
|
+
|
|
6
|
+
* **Claude** owns a process tree per worker: a ``ccx host`` and the ``claude``
|
|
7
|
+
PTY child it spawns, which reparents to init if the host is SIGKILLed. Kill
|
|
8
|
+
confirms both are gone; ignoring the child would report a stopped worker while
|
|
9
|
+
a real engine kept burning quota.
|
|
10
|
+
* **Codex** owns no per-worker process. Every worker is one thread multiplexed
|
|
11
|
+
on the single shared ``codex app-server`` child of the single shared ``codexd``
|
|
12
|
+
daemon — and each worker record stamps that device-wide daemon PID and
|
|
13
|
+
app-server PID into its own lifecycle. Signalling or reaping them to "kill a
|
|
14
|
+
worker" would take down the daemon and every sibling worker with it. Codex
|
|
15
|
+
kill therefore confirms a per-worker **unload** from the shared daemon, and
|
|
16
|
+
never sends a signal to any process.
|
|
17
|
+
|
|
18
|
+
This is deliberately not `stop`. `stop` cancels the active turn and leaves the
|
|
19
|
+
runtime up. `kill` tears the runtime down while preserving resumability: the
|
|
20
|
+
durable Claude session id / Codex thread id survives, and a later `send` revives
|
|
21
|
+
the same conversation.
|
|
22
|
+
|
|
23
|
+
A busy worker is never refused: kill best-effort cancels the running turn,
|
|
24
|
+
then terminates. It has
|
|
25
|
+
to work for idle, busy, waiting, already-stopped, and partially dead process
|
|
26
|
+
trees, because that is exactly when a caller reaches for it.
|
|
27
|
+
|
|
28
|
+
Unconfirmable termination raises :class:`~ccx.errors.SessionUnavailable` rather
|
|
29
|
+
than lying.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import os
|
|
35
|
+
import signal
|
|
36
|
+
import time
|
|
37
|
+
|
|
38
|
+
from ccx.constants import S
|
|
39
|
+
from ccx.errors import InvalidRequest, SessionUnavailable
|
|
40
|
+
from ccx.store.liveness import owned_pid_alive, owned_process, pid_alive
|
|
41
|
+
|
|
42
|
+
# How long an unbounded kill lets a process exit on SIGTERM before SIGKILL.
|
|
43
|
+
# Only relevant when the caller passed timeout=None; a numeric budget is
|
|
44
|
+
# divided instead (see :func:`escalate_at`).
|
|
45
|
+
TERM_GRACE_S = 5.0
|
|
46
|
+
|
|
47
|
+
# Default confirmation budget for one kill, shared by both CLIs and
|
|
48
|
+
# ``WorkerClient.kill``.
|
|
49
|
+
KILL_TIMEOUT_DEFAULT_S = 90
|
|
50
|
+
|
|
51
|
+
# Per-step caps for the Codex path. `left()` returns the cap only when the
|
|
52
|
+
# caller passed no deadline at all; with a deadline it returns the exact
|
|
53
|
+
# remaining budget, including a genuine 0.0. Never coalesce that 0.0 away — a
|
|
54
|
+
# `or cap` there silently turns "no budget" into a fresh multi-second wait.
|
|
55
|
+
_CODEX_KILL_RPC_CAP_S = 30.0
|
|
56
|
+
_CODEX_PROBE_CAP_S = 3.0
|
|
57
|
+
# Slice of the kill RPC budget reserved for the daemon to answer. The daemon may
|
|
58
|
+
# spend the rest observing an in-flight turn launch, so the caller's budget
|
|
59
|
+
# bounds that wait too — it is never silently expanded by a fixed daemon window.
|
|
60
|
+
_CODEX_REPLY_MARGIN_S = 1.0
|
|
61
|
+
|
|
62
|
+
_TERMINAL_CLAUDE = (S.DEAD, S.IDLE_KILLED, S.CRASHED, S.BUDGET_STOPPED)
|
|
63
|
+
_TERMINAL_CODEX = ("dead", "idle-killed", "crashed", "budget-stopped")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# --- budgets and process primitives -----------------------------------------
|
|
67
|
+
|
|
68
|
+
def deadline_for(timeout: float | None) -> float | None:
|
|
69
|
+
if timeout is not None and timeout < 0:
|
|
70
|
+
raise InvalidRequest("timeout must be zero or greater")
|
|
71
|
+
return None if timeout is None else time.monotonic() + timeout
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def left(deadline: float | None, cap: float | None = None) -> float | None:
|
|
75
|
+
"""Remaining budget, optionally capped by a per-step maximum."""
|
|
76
|
+
if deadline is None:
|
|
77
|
+
return cap
|
|
78
|
+
rest = max(0.0, deadline - time.monotonic())
|
|
79
|
+
return rest if cap is None else min(cap, rest)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def signal_pid(pid, sig) -> None:
|
|
83
|
+
if not pid:
|
|
84
|
+
return
|
|
85
|
+
try:
|
|
86
|
+
os.kill(pid, sig)
|
|
87
|
+
except OSError:
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def escalate_at(deadline: float | None) -> float:
|
|
92
|
+
"""When to give up on a graceful exit and SIGKILL.
|
|
93
|
+
|
|
94
|
+
Half of whatever the caller's budget still allows, or — for an unbounded
|
|
95
|
+
kill, where there is no budget to divide — a fixed grace period. Escalation
|
|
96
|
+
timing is not a deadline extension: it only decides *when* SIGKILL happens
|
|
97
|
+
inside the wait the caller already asked for.
|
|
98
|
+
"""
|
|
99
|
+
if deadline is None:
|
|
100
|
+
return time.monotonic() + TERM_GRACE_S
|
|
101
|
+
return time.monotonic() + max(0.0, deadline - time.monotonic()) / 2
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def await_exit(pid, deadline: float | None, *, alive=pid_alive) -> bool:
|
|
105
|
+
"""Poll until this original process is gone, never past caller deadline."""
|
|
106
|
+
while alive(pid):
|
|
107
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
108
|
+
return False
|
|
109
|
+
time.sleep(0.05)
|
|
110
|
+
return True
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def reap(pid, deadline: float | None, *, alive=pid_alive) -> bool:
|
|
114
|
+
"""SIGTERM then SIGKILL only the recorded orphanable child."""
|
|
115
|
+
if not pid or not alive(pid):
|
|
116
|
+
return True
|
|
117
|
+
signal_pid(pid, signal.SIGTERM)
|
|
118
|
+
if await_exit(pid, escalate_at(deadline), alive=alive):
|
|
119
|
+
return True
|
|
120
|
+
if alive(pid):
|
|
121
|
+
signal_pid(pid, signal.SIGKILL)
|
|
122
|
+
return await_exit(pid, deadline, alive=alive)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _claude_confirmed_kill(
|
|
126
|
+
short: str, deadline: float | None, *, view: dict,
|
|
127
|
+
rpc_call, terminal, host_alive, child_alive,
|
|
128
|
+
) -> dict:
|
|
129
|
+
"""Confirm the original UUID owner's host and child using scoped PID guards."""
|
|
130
|
+
host_pid, child_pid = view["host_pid"], view["child_pid"]
|
|
131
|
+
was_terminal = view["state"] in _TERMINAL_CLAUDE
|
|
132
|
+
if not view["live"] and not host_alive(host_pid) and not child_alive(child_pid):
|
|
133
|
+
state = terminal() if not was_terminal else view["state"]
|
|
134
|
+
return {"engine": "claude", "short": short, "killed": True,
|
|
135
|
+
"changed": not was_terminal,
|
|
136
|
+
"interrupted": False, "resumable": view["resumable"], "state": state}
|
|
137
|
+
|
|
138
|
+
rpc_budget = left(deadline, 3.0)
|
|
139
|
+
reply: dict = {}
|
|
140
|
+
if view["live"] and (rpc_budget is None or rpc_budget > 0):
|
|
141
|
+
try:
|
|
142
|
+
reply = rpc_call(rpc_budget)
|
|
143
|
+
except Exception:
|
|
144
|
+
reply = {}
|
|
145
|
+
if not reply.get("ok") and host_alive(host_pid):
|
|
146
|
+
signal_pid(host_pid, signal.SIGTERM)
|
|
147
|
+
|
|
148
|
+
if not await_exit(host_pid, escalate_at(deadline), alive=host_alive):
|
|
149
|
+
if host_alive(host_pid):
|
|
150
|
+
signal_pid(host_pid, signal.SIGKILL)
|
|
151
|
+
await_exit(host_pid, deadline, alive=host_alive)
|
|
152
|
+
|
|
153
|
+
child_gone = reap(child_pid, deadline, alive=child_alive)
|
|
154
|
+
if host_alive(host_pid) or not child_gone:
|
|
155
|
+
raise SessionUnavailable(
|
|
156
|
+
f"kill could not confirm worker processes stopped for {short}",
|
|
157
|
+
short=short,
|
|
158
|
+
)
|
|
159
|
+
state = terminal()
|
|
160
|
+
return {"engine": "claude", "short": short, "killed": True,
|
|
161
|
+
"changed": True, "interrupted": False,
|
|
162
|
+
"resumable": view["resumable"], "state": state}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def confirmed_kill_uuid(
|
|
166
|
+
store, target: str, *, timeout: float | None = KILL_TIMEOUT_DEFAULT_S,
|
|
167
|
+
_respawn_lock_held: bool = False,
|
|
168
|
+
) -> dict:
|
|
169
|
+
"""Exact full-UUID kill: worker owner only; never a short-record fallback."""
|
|
170
|
+
from ccx.constants import CODEXD_RPC_GENERATION
|
|
171
|
+
from ccx.store.root import RecordConflict
|
|
172
|
+
from ccx.store.identity import UnknownWorker
|
|
173
|
+
|
|
174
|
+
deadline = deadline_for(timeout)
|
|
175
|
+
store.revalidate()
|
|
176
|
+
owner = store.resolve(target)
|
|
177
|
+
handle = owner.worker
|
|
178
|
+
record = store.record(handle)
|
|
179
|
+
native = record.get("native") or {}
|
|
180
|
+
worker_id = owner.worker_id
|
|
181
|
+
original = record.get("lifecycle") or {}
|
|
182
|
+
identity = {key: original.get(key) for key in (
|
|
183
|
+
"hostPid", "hostStart", "workerPid", "workerStart",
|
|
184
|
+
)} if owner.engine == "claude" else {}
|
|
185
|
+
if owner.engine == "claude":
|
|
186
|
+
from ccx.store.root import NativeActionDenied
|
|
187
|
+
session_id = native.get("sessionId")
|
|
188
|
+
if not isinstance(session_id, str) or not session_id:
|
|
189
|
+
raise SessionUnavailable("kill: Claude session is missing; inspect the worker", short=worker_id)
|
|
190
|
+
host_pid = identity["hostPid"]
|
|
191
|
+
child_pid = identity["workerPid"]
|
|
192
|
+
host_start = identity["hostStart"]
|
|
193
|
+
child_start = identity["workerStart"]
|
|
194
|
+
# Neither signals nor claims of absence may depend on an unproved PID.
|
|
195
|
+
for pid, start, role in ((host_pid,host_start,"host"),
|
|
196
|
+
(child_pid,child_start,"child")):
|
|
197
|
+
if owned_process(pid, start) == "unprovable":
|
|
198
|
+
raise SessionUnavailable(
|
|
199
|
+
f"kill: worker {role} process could not be verified; check status", short=worker_id)
|
|
200
|
+
def host_alive(pid):
|
|
201
|
+
return bool(pid and host_start and owned_pid_alive(pid,host_start))
|
|
202
|
+
def child_alive(pid):
|
|
203
|
+
return bool(pid and child_start and owned_pid_alive(pid,child_start))
|
|
204
|
+
from ccx.rpc.rpc import _rpc
|
|
205
|
+
status = _rpc(handle, {"op":"status"}, timeout=max(.05, left(deadline, .5)))
|
|
206
|
+
live = status.get("ok") is True
|
|
207
|
+
if live and not host_alive(host_pid):
|
|
208
|
+
raise SessionUnavailable("kill: Claude process changed; check worker status", short=worker_id)
|
|
209
|
+
if (original.get("state") not in _TERMINAL_CLAUDE and not live
|
|
210
|
+
and not (host_alive(host_pid) or child_alive(child_pid))
|
|
211
|
+
and not host_pid and not child_pid):
|
|
212
|
+
raise SessionUnavailable("kill: Claude process could not be verified; check worker status", short=worker_id)
|
|
213
|
+
def commit():
|
|
214
|
+
# A clean host exit may clear its own PID/start fields. Accept only
|
|
215
|
+
# that disappearance, never a replacement process or owner.
|
|
216
|
+
current = store.record(handle).get("lifecycle") or {}
|
|
217
|
+
for key, original_value in identity.items():
|
|
218
|
+
if current.get(key) not in (original_value, None):
|
|
219
|
+
raise SessionUnavailable("kill: worker host identity replaced", short=worker_id)
|
|
220
|
+
patches = {"lifecycle.state": S.DEAD, "lifecycle.since": int(time.time()*1000),
|
|
221
|
+
"lifecycle.apiError": None}
|
|
222
|
+
expect = {"lifecycle."+key: current.get(key) for key in identity}
|
|
223
|
+
try:
|
|
224
|
+
return store.mutate_record(handle, patches=patches, expect=expect)["lifecycle"]["state"]
|
|
225
|
+
except RecordConflict as error:
|
|
226
|
+
raise SessionUnavailable("kill: worker host identity changed; not marked terminal",short=worker_id) from error
|
|
227
|
+
interrupted = False
|
|
228
|
+
if live and original.get("state") == S.BUSY and not _respawn_lock_held:
|
|
229
|
+
# Interrupt first: Claude ends the running tool and its processes,
|
|
230
|
+
# which a plain SIGTERM of claude leaves running on macOS.
|
|
231
|
+
try:
|
|
232
|
+
from ccx.ops.cancel import stop_worker
|
|
233
|
+
budget = 10.0 if deadline is None else min(
|
|
234
|
+
10.0, (deadline - time.monotonic()) / 2)
|
|
235
|
+
stopped = stop_worker(handle, timeout=max(.05, budget),
|
|
236
|
+
expected_generation=store.generation)
|
|
237
|
+
interrupted = stopped["confirmed"] and stopped["outcome"] == "interrupted"
|
|
238
|
+
except Exception:
|
|
239
|
+
pass
|
|
240
|
+
view = {"state": original.get("state"),"host_pid": host_pid,
|
|
241
|
+
"child_pid": child_pid, "live": live, "resumable": True}
|
|
242
|
+
result = _claude_confirmed_kill(
|
|
243
|
+
worker_id, deadline, view=view,
|
|
244
|
+
rpc_call=lambda budget: _rpc(handle,{"op":"kill"},timeout=budget),
|
|
245
|
+
terminal=commit,host_alive=host_alive,child_alive=child_alive,
|
|
246
|
+
)
|
|
247
|
+
result["interrupted"] = interrupted
|
|
248
|
+
# Dead-child proof is independent of the kill's existing CLI result.
|
|
249
|
+
# An unreadable/source-swapped transcript fails closed, never masking
|
|
250
|
+
# the confirmed native death or changing kill's exit status.
|
|
251
|
+
try:
|
|
252
|
+
from ccx.rpc.rpc import _finalize_killed_steers
|
|
253
|
+
_finalize_killed_steers(
|
|
254
|
+
store, handle, child_pid=child_pid, child_start=child_start,
|
|
255
|
+
lock_held=_respawn_lock_held,
|
|
256
|
+
)
|
|
257
|
+
except Exception:
|
|
258
|
+
pass # uncertain journal/death/lock: keep pending for revive repair
|
|
259
|
+
else:
|
|
260
|
+
# A creation refused locally never reached Codex, so there is no
|
|
261
|
+
# provider thread to unload. (A prepared one may still be dispatched.)
|
|
262
|
+
if (native.get("nativeState") == "not-started-proven"
|
|
263
|
+
and native.get("threadId") is None
|
|
264
|
+
and store.native_activation(handle).get("state") == "local-refused"):
|
|
265
|
+
changed = original.get("state") != "dead"
|
|
266
|
+
if changed:
|
|
267
|
+
store.mutate_record(
|
|
268
|
+
handle,
|
|
269
|
+
patches={
|
|
270
|
+
"lifecycle.state": "dead",
|
|
271
|
+
"lifecycle.since": int(time.time() * 1000),
|
|
272
|
+
"lifecycle.activeTurnId": None,
|
|
273
|
+
},
|
|
274
|
+
expect={"lifecycle.state": original.get("state")},
|
|
275
|
+
)
|
|
276
|
+
store.revalidate()
|
|
277
|
+
return {
|
|
278
|
+
"engine": "codex", "short": worker_id[:8], "worker": handle,
|
|
279
|
+
"worker_id": worker_id, "killed": True, "changed": changed,
|
|
280
|
+
"interrupted": False, "native_state": "dead", "state": "stopped",
|
|
281
|
+
"parked": True, "history_retained": True,
|
|
282
|
+
"resume_available": False, "resumable": False,
|
|
283
|
+
}
|
|
284
|
+
from ccx.store.root import NativeActionDenied
|
|
285
|
+
try:
|
|
286
|
+
source = store.codex_source(handle)
|
|
287
|
+
except NativeActionDenied as error:
|
|
288
|
+
raise SessionUnavailable(
|
|
289
|
+
"kill: this Codex worker's start is not confirmed yet; wait for "
|
|
290
|
+
"`ccx new` to finish, then retry", short=worker_id) from error
|
|
291
|
+
thread_id = native.get("threadId")
|
|
292
|
+
if (not isinstance(thread_id,str) or not thread_id
|
|
293
|
+
or native.get("threadSource") != source):
|
|
294
|
+
raise SessionUnavailable("kill: Codex conversation cannot be verified; check worker status",short=worker_id)
|
|
295
|
+
from ccx.codex.runtime import daemon_rpc
|
|
296
|
+
from ccx.constants import CODEXD_RPC_GENERATION
|
|
297
|
+
probe = daemon_rpc({"op":"status","short":worker_id,
|
|
298
|
+
"rpcGeneration":CODEXD_RPC_GENERATION},timeout=max(.05,left(deadline,1)))
|
|
299
|
+
if probe.get("transport") == "absent":
|
|
300
|
+
# No original singleton can still hold an app-server subscription.
|
|
301
|
+
# An explicit kill always leaves `dead`, even over an unloaded
|
|
302
|
+
# idle-killed/crashed record, so nothing auto-reloads it later.
|
|
303
|
+
changed = original.get("state") != "dead"
|
|
304
|
+
if changed:
|
|
305
|
+
store.mutate_record(handle,patches={"lifecycle.state":"dead",
|
|
306
|
+
"lifecycle.since":int(time.time()*1000),"lifecycle.activeTurnId":None},
|
|
307
|
+
expect={"lifecycle.state":original.get("state")})
|
|
308
|
+
result = {"engine":"codex","short":worker_id,"killed":True,
|
|
309
|
+
"changed":changed,"interrupted":False,"state":"dead"}
|
|
310
|
+
elif probe.get("transport") != "served":
|
|
311
|
+
raise SessionUnavailable("kill: Codex status is unknown; retry after checking the worker",short=worker_id)
|
|
312
|
+
elif probe.get("loaded") is False and original.get("state") in _TERMINAL_CODEX:
|
|
313
|
+
changed = original.get("state") != "dead"
|
|
314
|
+
if changed:
|
|
315
|
+
store.mutate_record(handle,patches={"lifecycle.state":"dead",
|
|
316
|
+
"lifecycle.since":int(time.time()*1000),"lifecycle.activeTurnId":None},
|
|
317
|
+
expect={"lifecycle.state":original.get("state")})
|
|
318
|
+
result = {"engine":"codex","short":worker_id,"killed":True,
|
|
319
|
+
"changed":changed,"interrupted":False,"state":"dead"}
|
|
320
|
+
else:
|
|
321
|
+
budget = left(deadline, _CODEX_KILL_RPC_CAP_S)
|
|
322
|
+
if budget == 0:
|
|
323
|
+
raise SessionUnavailable("kill: no remaining runtime confirmation budget",short=worker_id)
|
|
324
|
+
answer = daemon_rpc({"op":"kill","short":worker_id,
|
|
325
|
+
"observeSeconds":max(0.0,budget-_CODEX_REPLY_MARGIN_S),
|
|
326
|
+
"rpcGeneration":CODEXD_RPC_GENERATION},timeout=budget)
|
|
327
|
+
if answer.get("ok") is True and answer.get("unloaded") is True:
|
|
328
|
+
# Existing daemon Worker.kill synchronously terminalizes and
|
|
329
|
+
# deregisters. Native thread/unsubscribe completion is async.
|
|
330
|
+
result={"engine":"codex","short":worker_id,"killed":True,
|
|
331
|
+
"changed":True,"interrupted":bool(answer.get("interrupted")),
|
|
332
|
+
"state":"dead"}
|
|
333
|
+
else:
|
|
334
|
+
remaining=left(deadline,_CODEX_PROBE_CAP_S)
|
|
335
|
+
if remaining == 0:
|
|
336
|
+
raise SessionUnavailable("kill: Codex did not confirm stopping; check worker status",short=worker_id)
|
|
337
|
+
observed=daemon_rpc({"op":"status","short":worker_id,
|
|
338
|
+
"rpcGeneration":CODEXD_RPC_GENERATION},timeout=remaining)
|
|
339
|
+
if (observed.get("transport") != "served"
|
|
340
|
+
or observed.get("loaded") is not False
|
|
341
|
+
or store.record(handle)["lifecycle"].get("state") not in _TERMINAL_CODEX):
|
|
342
|
+
raise SessionUnavailable("kill: worker may still be running; check its status before retrying",short=worker_id)
|
|
343
|
+
result={"engine":"codex","short":worker_id,"killed":True,
|
|
344
|
+
"changed":True,"interrupted":False,"state":"dead"}
|
|
345
|
+
store.revalidate()
|
|
346
|
+
# History is retained for both engines, but only verified original Codex
|
|
347
|
+
# threads with an actual prior rollout have a supported resume path.
|
|
348
|
+
# This is eligibility to try native resume, not proof it will succeed.
|
|
349
|
+
from ccx.store.root import NativeActionDenied, CreationIndeterminate
|
|
350
|
+
last = record.get("lastTurn")
|
|
351
|
+
turns = (record.get("stats") or {}).get("turns")
|
|
352
|
+
eligible_claude = False
|
|
353
|
+
if (owner.engine == "claude"
|
|
354
|
+
and record.get("terminalOnly") is not True
|
|
355
|
+
and native.get("nativeState") == "session-bound"
|
|
356
|
+
and isinstance(native.get("sessionId"), str)):
|
|
357
|
+
import os
|
|
358
|
+
import stat
|
|
359
|
+
original_transcript = record.get("transcript")
|
|
360
|
+
if isinstance(original_transcript, str):
|
|
361
|
+
try:
|
|
362
|
+
transcript_stat = os.stat(original_transcript)
|
|
363
|
+
eligible_claude = (stat.S_ISREG(transcript_stat.st_mode)
|
|
364
|
+
and transcript_stat.st_size > 0)
|
|
365
|
+
except OSError:
|
|
366
|
+
pass
|
|
367
|
+
eligible = (owner.engine == "codex"
|
|
368
|
+
and record.get("terminalOnly") is not True
|
|
369
|
+
and native.get("nativeState") == "thread-bound"
|
|
370
|
+
and isinstance(native.get("threadId"), str)
|
|
371
|
+
and native.get("threadSource") == store.codex_source(handle)
|
|
372
|
+
and (isinstance(turns, int) and turns > 0
|
|
373
|
+
or isinstance(last, dict) and isinstance(last.get("turnId"), str)))
|
|
374
|
+
eligible = eligible or eligible_claude
|
|
375
|
+
if eligible:
|
|
376
|
+
try:
|
|
377
|
+
store.authorize_native_action(handle, "resume")
|
|
378
|
+
except (NativeActionDenied, CreationIndeterminate):
|
|
379
|
+
eligible = False
|
|
380
|
+
return {**result,"worker":handle,"worker_id":worker_id,"engine":owner.engine,
|
|
381
|
+
"short":worker_id[:8],"history_retained":True,
|
|
382
|
+
"resume_available":eligible,"resumable":eligible,
|
|
383
|
+
"native_state":result["state"],"state":"stopped","parked":True}
|