ccx-native 0.0.1 → 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.
Files changed (150) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +241 -4
  3. package/bin/ccx +55 -2
  4. package/ccx/__init__.py +163 -0
  5. package/ccx/__main__.py +28 -0
  6. package/ccx/claude/__init__.py +0 -0
  7. package/ccx/claude/analytics.py +466 -0
  8. package/ccx/claude/attach.py +762 -0
  9. package/ccx/claude/context.py +465 -0
  10. package/ccx/claude/delivery.py +806 -0
  11. package/ccx/claude/external.py +253 -0
  12. package/ccx/claude/host/__init__.py +0 -0
  13. package/ccx/claude/host/events.py +297 -0
  14. package/ccx/claude/host/hooks.py +438 -0
  15. package/ccx/claude/host/pty.py +250 -0
  16. package/ccx/claude/host/session.py +5184 -0
  17. package/ccx/claude/host/subagents.py +169 -0
  18. package/ccx/claude/host/successor.py +267 -0
  19. package/ccx/claude/managed_turns.py +240 -0
  20. package/ccx/claude/oauth.py +176 -0
  21. package/ccx/claude/settings.py +339 -0
  22. package/ccx/claude/spawn.py +557 -0
  23. package/ccx/claude/tasks.py +72 -0
  24. package/ccx/claude/telemetry.py +282 -0
  25. package/ccx/claude/transcript.py +2281 -0
  26. package/ccx/claude/usage.py +837 -0
  27. package/ccx/claude/usage_control.py +152 -0
  28. package/ccx/cli/__init__.py +0 -0
  29. package/ccx/cli/codex.py +1009 -0
  30. package/ccx/cli/commands/__init__.py +0 -0
  31. package/ccx/cli/commands/_common.py +407 -0
  32. package/ccx/cli/commands/control.py +161 -0
  33. package/ccx/cli/commands/create.py +669 -0
  34. package/ccx/cli/commands/read.py +1020 -0
  35. package/ccx/cli/commands/turns.py +1081 -0
  36. package/ccx/cli/controller.py +757 -0
  37. package/ccx/cli/main.py +301 -0
  38. package/ccx/cli/parser.py +1324 -0
  39. package/ccx/cli/skills.py +215 -0
  40. package/ccx/cli/statusline.py +160 -0
  41. package/ccx/client.py +1900 -0
  42. package/ccx/codex/__init__.py +0 -0
  43. package/ccx/codex/attach_tty.py +267 -0
  44. package/ccx/codex/client.py +732 -0
  45. package/ccx/codex/completion.py +121 -0
  46. package/ccx/codex/control.py +288 -0
  47. package/ccx/codex/daemon/__init__.py +0 -0
  48. package/ccx/codex/daemon/__main__.py +4 -0
  49. package/ccx/codex/daemon/daemon.py +1556 -0
  50. package/ccx/codex/daemon/models.py +140 -0
  51. package/ccx/codex/daemon/native.py +1590 -0
  52. package/ccx/codex/daemon/release.py +367 -0
  53. package/ccx/codex/daemon/worker.py +2396 -0
  54. package/ccx/codex/delivery.py +758 -0
  55. package/ccx/codex/delivery_recovery.py +232 -0
  56. package/ccx/codex/evidence.py +827 -0
  57. package/ccx/codex/history.py +158 -0
  58. package/ccx/codex/notify_history.py +140 -0
  59. package/ccx/codex/ops.py +666 -0
  60. package/ccx/codex/paths.py +70 -0
  61. package/ccx/codex/protocol.py +805 -0
  62. package/ccx/codex/provenance.py +105 -0
  63. package/ccx/codex/read.py +29 -0
  64. package/ccx/codex/records.py +51 -0
  65. package/ccx/codex/reports.py +177 -0
  66. package/ccx/codex/runtime.py +1412 -0
  67. package/ccx/codex/settle.py +363 -0
  68. package/ccx/codex/telemetry.py +792 -0
  69. package/ccx/codex/types.py +659 -0
  70. package/ccx/codex/worker_environment.py +25 -0
  71. package/ccx/constants.py +288 -0
  72. package/ccx/controllers/__init__.py +0 -0
  73. package/ccx/controllers/controller.py +1449 -0
  74. package/ccx/controllers/live_vars.py +1706 -0
  75. package/ccx/controllers/live_vars_runner.py +216 -0
  76. package/ccx/controllers/notify.py +213 -0
  77. package/ccx/controllers/orchestrator.py +193 -0
  78. package/ccx/controllers/policy.py +57 -0
  79. package/ccx/controllers/store.py +681 -0
  80. package/ccx/errors.py +634 -0
  81. package/ccx/notify/__init__.py +0 -0
  82. package/ccx/notify/__main__.py +5 -0
  83. package/ccx/notify/client.py +116 -0
  84. package/ccx/notify/daemon.py +480 -0
  85. package/ccx/notify/deliver.py +304 -0
  86. package/ccx/notify/paths.py +51 -0
  87. package/ccx/notify/registry.py +649 -0
  88. package/ccx/notify/select.py +750 -0
  89. package/ccx/notify/settle.py +684 -0
  90. package/ccx/ops/__init__.py +0 -0
  91. package/ccx/ops/background.py +115 -0
  92. package/ccx/ops/cancel.py +289 -0
  93. package/ccx/ops/codex_import.py +96 -0
  94. package/ccx/ops/common.py +397 -0
  95. package/ccx/ops/compact.py +337 -0
  96. package/ccx/ops/compact_ops.py +550 -0
  97. package/ccx/ops/context_events.py +48 -0
  98. package/ccx/ops/conversation.py +1540 -0
  99. package/ccx/ops/conversation_paging.py +1338 -0
  100. package/ccx/ops/create.py +56 -0
  101. package/ccx/ops/creation.py +952 -0
  102. package/ccx/ops/creation_steps.py +579 -0
  103. package/ccx/ops/event_feed.py +710 -0
  104. package/ccx/ops/forking.py +711 -0
  105. package/ccx/ops/input.py +556 -0
  106. package/ccx/ops/maintenance.py +295 -0
  107. package/ccx/ops/report.py +770 -0
  108. package/ccx/ops/semantics.py +490 -0
  109. package/ccx/ops/send.py +961 -0
  110. package/ccx/ops/steer_ops.py +867 -0
  111. package/ccx/ops/terminate.py +383 -0
  112. package/ccx/ops/turn_reports.py +306 -0
  113. package/ccx/ops/wait.py +762 -0
  114. package/ccx/ops/worker_events.py +636 -0
  115. package/ccx/rpc/__init__.py +0 -0
  116. package/ccx/rpc/local_rpc.py +1602 -0
  117. package/ccx/rpc/rpc.py +559 -0
  118. package/ccx/rpc/serve.py +374 -0
  119. package/ccx/rpc/transport.py +409 -0
  120. package/ccx/rpc/view.py +125 -0
  121. package/ccx/store/__init__.py +0 -0
  122. package/ccx/store/discovery.py +170 -0
  123. package/ccx/store/identity.py +105 -0
  124. package/ccx/store/journal_io.py +79 -0
  125. package/ccx/store/liveness.py +194 -0
  126. package/ccx/store/paths.py +254 -0
  127. package/ccx/store/record_cache.py +40 -0
  128. package/ccx/store/records.py +309 -0
  129. package/ccx/store/root.py +804 -0
  130. package/ccx/store/runtime_presence.py +137 -0
  131. package/ccx/store/store.py +2332 -0
  132. package/ccx/store/worker_state.py +86 -0
  133. package/ccx/system/__init__.py +0 -0
  134. package/ccx/system/bounded.py +296 -0
  135. package/ccx/system/cwd_paths.py +25 -0
  136. package/ccx/system/engine_binary.py +59 -0
  137. package/ccx/system/executable.py +50 -0
  138. package/ccx/system/oslayer.py +667 -0
  139. package/ccx/system/pipes.py +46 -0
  140. package/ccx/system/sanitize.py +43 -0
  141. package/ccx/types.py +705 -0
  142. package/ccx/version.py +29 -0
  143. package/ccx/workers.py +2081 -0
  144. package/ccx.py +9 -0
  145. package/package.json +39 -7
  146. package/pyproject.toml +87 -0
  147. package/skills/ccx/SKILL.md +155 -0
  148. package/skills/ccx/references/controllers.md +244 -0
  149. package/skills/ccx/references/troubleshooting.md +94 -0
  150. 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}