optio-claudecode 0.2.2__tar.gz → 0.2.4__tar.gz
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.
- {optio_claudecode-0.2.2 → optio_claudecode-0.2.4}/PKG-INFO +2 -1
- {optio_claudecode-0.2.2 → optio_claudecode-0.2.4}/pyproject.toml +2 -1
- {optio_claudecode-0.2.2 → optio_claudecode-0.2.4}/src/optio_claudecode/host_actions.py +84 -9
- optio_claudecode-0.2.4/src/optio_claudecode/input_listener.py +64 -0
- {optio_claudecode-0.2.2 → optio_claudecode-0.2.4}/src/optio_claudecode/session.py +135 -36
- {optio_claudecode-0.2.2 → optio_claudecode-0.2.4}/src/optio_claudecode.egg-info/PKG-INFO +2 -1
- {optio_claudecode-0.2.2 → optio_claudecode-0.2.4}/src/optio_claudecode.egg-info/SOURCES.txt +6 -0
- {optio_claudecode-0.2.2 → optio_claudecode-0.2.4}/src/optio_claudecode.egg-info/requires.txt +1 -0
- optio_claudecode-0.2.4/tests/test_injection_serialize.py +21 -0
- optio_claudecode-0.2.4/tests/test_input_listener.py +49 -0
- optio_claudecode-0.2.4/tests/test_kill_ttyd_by_socket.py +45 -0
- optio_claudecode-0.2.4/tests/test_rescue_orphan.py +270 -0
- optio_claudecode-0.2.4/tests/test_teardown_session_tree.py +86 -0
- {optio_claudecode-0.2.2 → optio_claudecode-0.2.4}/README.md +0 -0
- {optio_claudecode-0.2.2 → optio_claudecode-0.2.4}/setup.cfg +0 -0
- {optio_claudecode-0.2.2 → optio_claudecode-0.2.4}/src/optio_claudecode/__init__.py +0 -0
- {optio_claudecode-0.2.2 → optio_claudecode-0.2.4}/src/optio_claudecode/account.py +0 -0
- {optio_claudecode-0.2.2 → optio_claudecode-0.2.4}/src/optio_claudecode/cred_watcher.py +0 -0
- {optio_claudecode-0.2.2 → optio_claudecode-0.2.4}/src/optio_claudecode/oauth.py +0 -0
- {optio_claudecode-0.2.2 → optio_claudecode-0.2.4}/src/optio_claudecode/oauth_redirect.py +0 -0
- {optio_claudecode-0.2.2 → optio_claudecode-0.2.4}/src/optio_claudecode/prompt.py +0 -0
- {optio_claudecode-0.2.2 → optio_claudecode-0.2.4}/src/optio_claudecode/seed_manifest.py +0 -0
- {optio_claudecode-0.2.2 → optio_claudecode-0.2.4}/src/optio_claudecode/snapshots.py +0 -0
- {optio_claudecode-0.2.2 → optio_claudecode-0.2.4}/src/optio_claudecode/types.py +0 -0
- {optio_claudecode-0.2.2 → optio_claudecode-0.2.4}/src/optio_claudecode.egg-info/dependency_links.txt +0 -0
- {optio_claudecode-0.2.2 → optio_claudecode-0.2.4}/src/optio_claudecode.egg-info/top_level.txt +0 -0
- {optio_claudecode-0.2.2 → optio_claudecode-0.2.4}/tests/test_account_summary.py +0 -0
- {optio_claudecode-0.2.2 → optio_claudecode-0.2.4}/tests/test_agent_sender_claudecode.py +0 -0
- {optio_claudecode-0.2.2 → optio_claudecode-0.2.4}/tests/test_await_claude_gone.py +0 -0
- {optio_claudecode-0.2.2 → optio_claudecode-0.2.4}/tests/test_cred_watcher.py +0 -0
- {optio_claudecode-0.2.2 → optio_claudecode-0.2.4}/tests/test_home_isolation.py +0 -0
- {optio_claudecode-0.2.2 → optio_claudecode-0.2.4}/tests/test_host_actions.py +0 -0
- {optio_claudecode-0.2.2 → optio_claudecode-0.2.4}/tests/test_launch_detached_checked.py +0 -0
- {optio_claudecode-0.2.2 → optio_claudecode-0.2.4}/tests/test_oauth.py +0 -0
- {optio_claudecode-0.2.2 → optio_claudecode-0.2.4}/tests/test_oauth_redirect.py +0 -0
- {optio_claudecode-0.2.2 → optio_claudecode-0.2.4}/tests/test_on_resume_refresh.py +0 -0
- {optio_claudecode-0.2.2 → optio_claudecode-0.2.4}/tests/test_prompt.py +0 -0
- {optio_claudecode-0.2.2 → optio_claudecode-0.2.4}/tests/test_purge_seed.py +0 -0
- {optio_claudecode-0.2.2 → optio_claudecode-0.2.4}/tests/test_rekey_projects.py +0 -0
- {optio_claudecode-0.2.2 → optio_claudecode-0.2.4}/tests/test_resume_prompt.py +0 -0
- {optio_claudecode-0.2.2 → optio_claudecode-0.2.4}/tests/test_resume_sentence_claudecode.py +0 -0
- {optio_claudecode-0.2.2 → optio_claudecode-0.2.4}/tests/test_runtime_cache.py +0 -0
- {optio_claudecode-0.2.2 → optio_claudecode-0.2.4}/tests/test_sanity.py +0 -0
- {optio_claudecode-0.2.2 → optio_claudecode-0.2.4}/tests/test_seed_config.py +0 -0
- {optio_claudecode-0.2.2 → optio_claudecode-0.2.4}/tests/test_seed_provider.py +0 -0
- {optio_claudecode-0.2.2 → optio_claudecode-0.2.4}/tests/test_send_text_to_claude.py +0 -0
- {optio_claudecode-0.2.2 → optio_claudecode-0.2.4}/tests/test_session_blob_hooks.py +0 -0
- {optio_claudecode-0.2.2 → optio_claudecode-0.2.4}/tests/test_session_hooks.py +0 -0
- {optio_claudecode-0.2.2 → optio_claudecode-0.2.4}/tests/test_session_local.py +0 -0
- {optio_claudecode-0.2.2 → optio_claudecode-0.2.4}/tests/test_session_resume.py +0 -0
- {optio_claudecode-0.2.2 → optio_claudecode-0.2.4}/tests/test_session_resume_decrypt_failure.py +0 -0
- {optio_claudecode-0.2.2 → optio_claudecode-0.2.4}/tests/test_session_seed_capture.py +0 -0
- {optio_claudecode-0.2.2 → optio_claudecode-0.2.4}/tests/test_session_seed_consume.py +0 -0
- {optio_claudecode-0.2.2 → optio_claudecode-0.2.4}/tests/test_session_seed_saveback.py +0 -0
- {optio_claudecode-0.2.2 → optio_claudecode-0.2.4}/tests/test_session_seed_unknown_id.py +0 -0
- {optio_claudecode-0.2.2 → optio_claudecode-0.2.4}/tests/test_snapshots.py +0 -0
- {optio_claudecode-0.2.2 → optio_claudecode-0.2.4}/tests/test_tmux_persistence.py +0 -0
- {optio_claudecode-0.2.2 → optio_claudecode-0.2.4}/tests/test_tmux_socket_path.py +0 -0
- {optio_claudecode-0.2.2 → optio_claudecode-0.2.4}/tests/test_types.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: optio-claudecode
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
4
4
|
Summary: Run Anthropic Claude Code as an optio task; local subprocess or remote via SSH; ttyd-served TUI iframe.
|
|
5
5
|
Author-email: Kristof Csillag <kristof.csillag@deai-labs.com>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -24,6 +24,7 @@ Requires-Dist: optio-core<0.3,>=0.2
|
|
|
24
24
|
Requires-Dist: optio-host<0.3,>=0.2
|
|
25
25
|
Requires-Dist: optio-agents<0.3,>=0.2
|
|
26
26
|
Requires-Dist: asyncssh>=2.14
|
|
27
|
+
Requires-Dist: aiohttp>=3.9
|
|
27
28
|
Provides-Extra: dev
|
|
28
29
|
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
29
30
|
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "optio-claudecode"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.4"
|
|
8
8
|
description = "Run Anthropic Claude Code as an optio task; local subprocess or remote via SSH; ttyd-served TUI iframe."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "Apache-2.0"
|
|
@@ -30,6 +30,7 @@ dependencies = [
|
|
|
30
30
|
"optio-host>=0.2,<0.3",
|
|
31
31
|
"optio-agents>=0.2,<0.3",
|
|
32
32
|
"asyncssh>=2.14",
|
|
33
|
+
"aiohttp>=3.9",
|
|
33
34
|
]
|
|
34
35
|
|
|
35
36
|
[project.optional-dependencies]
|
|
@@ -676,18 +676,41 @@ def _claude_pgrep_pattern(claude_path: str) -> str:
|
|
|
676
676
|
return "^" + body
|
|
677
677
|
|
|
678
678
|
|
|
679
|
+
def _socket_pkill_pattern(socket_path: str) -> str:
|
|
680
|
+
"""Anchored pkill -f pattern matching the orphan ttyd that carries
|
|
681
|
+
``socket_path`` in its cmdline (``ttyd ... -- tmux -S <socket> attach``).
|
|
682
|
+
|
|
683
|
+
The ``ttyd`` binary token is bracket-escaped (``[t]tyd``) so pkill's own
|
|
684
|
+
argv does not self-match — same trick as ``_claude_pgrep_pattern``'s
|
|
685
|
+
``[c]laude``. The full ``socket_path`` is kept verbatim so the match is
|
|
686
|
+
scoped to this task's private socket (and not some other ttyd)."""
|
|
687
|
+
if not socket_path:
|
|
688
|
+
return socket_path
|
|
689
|
+
return f"[t]tyd.*{socket_path}"
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
async def _kill_ttyd_by_socket(host: "Host", socket_path: str) -> None:
|
|
693
|
+
"""Reap a detached orphan ttyd that has no tracked launch handle.
|
|
694
|
+
|
|
695
|
+
Normal teardown kills ttyd via ``terminate_subprocess(launched_handle)``.
|
|
696
|
+
A crash orphan's ttyd is re-parented to init with no handle, so it is
|
|
697
|
+
reaped host-side by an anchored ``pkill -f`` on the private socket path it
|
|
698
|
+
carries in its cmdline. Best-effort: pkill exits non-zero when nothing
|
|
699
|
+
matches."""
|
|
700
|
+
pattern = _socket_pkill_pattern(socket_path)
|
|
701
|
+
await host.run_command(f"pkill -KILL -f {shlex.quote(pattern)} || true")
|
|
702
|
+
|
|
703
|
+
|
|
679
704
|
async def kill_claude_processes(
|
|
680
705
|
host: "Host", claude_path: str, *, signal: str = "KILL",
|
|
681
706
|
) -> None:
|
|
682
|
-
"""
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
reaches it. Best-effort: pkill exits non-zero when nothing matches.
|
|
690
|
-
"""
|
|
707
|
+
"""Kill the per-task claude via an anchored host-side ``pkill``.
|
|
708
|
+
|
|
709
|
+
claude ignores the tmux pane SIGHUP, and MAY run under a pasta netns
|
|
710
|
+
wrapper (only when ``OPTIO_CLAUDECODE_NETNS`` is set AND the host is
|
|
711
|
+
local). The anchored pkill on claude's argv[0] reaches it regardless of
|
|
712
|
+
whether pasta wraps it, because pasta isolates the network namespace, not
|
|
713
|
+
PID. Best-effort: pkill exits non-zero when nothing matches."""
|
|
691
714
|
pattern = _claude_pgrep_pattern(claude_path)
|
|
692
715
|
await host.run_command(f"pkill -{signal} -f {shlex.quote(pattern)} || true")
|
|
693
716
|
|
|
@@ -721,6 +744,58 @@ async def await_claude_gone(
|
|
|
721
744
|
waited += poll_s
|
|
722
745
|
|
|
723
746
|
|
|
747
|
+
async def teardown_session_tree(
|
|
748
|
+
host: "Host",
|
|
749
|
+
*,
|
|
750
|
+
tmux_path: str,
|
|
751
|
+
tmux_socket: str,
|
|
752
|
+
tmux_session: str,
|
|
753
|
+
claude_path: str,
|
|
754
|
+
ttyd_handle: "ProcessHandle | None" = None,
|
|
755
|
+
aggressive: bool,
|
|
756
|
+
) -> None:
|
|
757
|
+
"""Kill a full claudecode session tree (ttyd + tmux + claude), reused by
|
|
758
|
+
both normal teardown and crash-orphan rescue.
|
|
759
|
+
|
|
760
|
+
Four best-effort steps, each isolated so one failure does not abort the
|
|
761
|
+
rest:
|
|
762
|
+
1. ttyd — via the tracked launch handle (normal teardown) or, when no
|
|
763
|
+
handle exists (a crash orphan re-parented to init), an anchored
|
|
764
|
+
host-side pkill on the socket path.
|
|
765
|
+
2. ``kill-session`` — SIGHUPs the tmux pane.
|
|
766
|
+
3. ``kill_claude_processes`` — claude ignores the pane SIGHUP (and may
|
|
767
|
+
run under a pasta netns wrapper), so it is killed explicitly via an
|
|
768
|
+
anchored host-side pkill on its argv[0]; this reaches it whether or
|
|
769
|
+
not pasta wraps it (pasta isolates the network namespace, not PID).
|
|
770
|
+
4. ``await_claude_gone`` — waits for quiescence so a subsequent capture
|
|
771
|
+
tar does not race a dying claude."""
|
|
772
|
+
if ttyd_handle is not None:
|
|
773
|
+
try:
|
|
774
|
+
await host.terminate_subprocess(ttyd_handle, aggressive=aggressive)
|
|
775
|
+
except Exception:
|
|
776
|
+
_LOG.exception("terminate_subprocess (ttyd) failed")
|
|
777
|
+
else:
|
|
778
|
+
try:
|
|
779
|
+
await _kill_ttyd_by_socket(host, tmux_socket)
|
|
780
|
+
except Exception:
|
|
781
|
+
_LOG.exception("orphan ttyd reap failed (socket=%s)", tmux_socket)
|
|
782
|
+
|
|
783
|
+
try:
|
|
784
|
+
await _kill_tmux_session(host, tmux_path, tmux_socket, tmux_session)
|
|
785
|
+
except Exception:
|
|
786
|
+
_LOG.exception("tmux session teardown failed")
|
|
787
|
+
|
|
788
|
+
try:
|
|
789
|
+
await kill_claude_processes(host, claude_path)
|
|
790
|
+
except Exception:
|
|
791
|
+
_LOG.exception("kill_claude_processes failed")
|
|
792
|
+
|
|
793
|
+
try:
|
|
794
|
+
await await_claude_gone(host, claude_path)
|
|
795
|
+
except Exception:
|
|
796
|
+
_LOG.exception("await_claude_gone failed; proceeding")
|
|
797
|
+
|
|
798
|
+
|
|
724
799
|
async def tmux_session_alive(
|
|
725
800
|
host: "Host", tmux_path: str, socket_path: str, session_name: str,
|
|
726
801
|
) -> bool:
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""In-session HTTP listener that receives human-typed messages and a small
|
|
2
|
+
lock helper that serializes them against system-message injection.
|
|
3
|
+
|
|
4
|
+
The listener runs INSIDE the session's asyncio loop (engine-side), so its
|
|
5
|
+
handler natively holds the session's injector and lock — no registry, no RPC,
|
|
6
|
+
no Mongo poll. It is reached through the API widget proxy exactly as ttyd is
|
|
7
|
+
(registered as controlUpstream). See
|
|
8
|
+
docs/2026-06-08-claudecode-input-channel-design.md.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
from typing import Awaitable, Callable
|
|
14
|
+
|
|
15
|
+
from aiohttp import web
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def serialized(
|
|
19
|
+
lock: asyncio.Lock, send: Callable[[str], Awaitable[None]],
|
|
20
|
+
) -> Callable[[str], Awaitable[None]]:
|
|
21
|
+
"""Wrap `send` so every call holds `lock` for the whole injection burst.
|
|
22
|
+
Both the system path (_agent_sender) and the human path go through one
|
|
23
|
+
such wrapper sharing one lock → bursts never interleave."""
|
|
24
|
+
async def _send(text: str) -> None:
|
|
25
|
+
async with lock:
|
|
26
|
+
await send(text)
|
|
27
|
+
return _send
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def start_input_listener(
|
|
31
|
+
*,
|
|
32
|
+
bind_iface: str,
|
|
33
|
+
on_input: Callable[[str], Awaitable[None]],
|
|
34
|
+
) -> tuple[web.AppRunner, int]:
|
|
35
|
+
"""Start a one-route aiohttp app: POST /input {text} -> on_input(text).
|
|
36
|
+
|
|
37
|
+
Returns (runner, port). Bind on port 0 (OS-assigned); the actual port is
|
|
38
|
+
read back from the bound socket. on_input raises on injection failure;
|
|
39
|
+
that becomes a 502 {ok:false, reason:"send-failed"}.
|
|
40
|
+
"""
|
|
41
|
+
async def handle(request: web.Request) -> web.Response:
|
|
42
|
+
try:
|
|
43
|
+
payload = await request.json()
|
|
44
|
+
except Exception:
|
|
45
|
+
return web.json_response({"ok": False, "reason": "bad-json"}, status=400)
|
|
46
|
+
text = payload.get("text")
|
|
47
|
+
if not isinstance(text, str) or not text:
|
|
48
|
+
return web.json_response({"ok": False, "reason": "bad-text"}, status=400)
|
|
49
|
+
try:
|
|
50
|
+
await on_input(text)
|
|
51
|
+
except Exception:
|
|
52
|
+
return web.json_response({"ok": False, "reason": "send-failed"}, status=502)
|
|
53
|
+
return web.json_response({"ok": True})
|
|
54
|
+
|
|
55
|
+
app = web.Application()
|
|
56
|
+
app.router.add_post("/input", handle)
|
|
57
|
+
runner = web.AppRunner(app)
|
|
58
|
+
await runner.setup()
|
|
59
|
+
site = web.TCPSite(runner, bind_iface, 0)
|
|
60
|
+
await site.start()
|
|
61
|
+
# Read the OS-assigned port back from the bound server socket.
|
|
62
|
+
server = site._server # aiohttp exposes the asyncio.Server here
|
|
63
|
+
port = server.sockets[0].getsockname()[1]
|
|
64
|
+
return runner, port
|
|
@@ -33,6 +33,7 @@ from optio_agents import get_protocol
|
|
|
33
33
|
|
|
34
34
|
from optio_claudecode import cred_watcher
|
|
35
35
|
from optio_claudecode import host_actions
|
|
36
|
+
from optio_claudecode.input_listener import serialized, start_input_listener
|
|
36
37
|
from optio_claudecode.account import resolve_account_summary
|
|
37
38
|
from optio_claudecode.oauth_redirect import rewrite_oauth_redirect
|
|
38
39
|
from optio_claudecode.seed_manifest import (
|
|
@@ -93,6 +94,8 @@ async def run_claudecode_session(
|
|
|
93
94
|
tmux_path: str | None = None
|
|
94
95
|
tmux_socket: str | None = None
|
|
95
96
|
tmux_session: str | None = None
|
|
97
|
+
injection_lock = asyncio.Lock()
|
|
98
|
+
input_runner = None # aiohttp AppRunner | None
|
|
96
99
|
cancelled = False
|
|
97
100
|
# Set by _prepare (the driver runs it after the workdir wipe, before the
|
|
98
101
|
# optio.log tail); read by the body and the teardown finally.
|
|
@@ -110,6 +113,11 @@ async def run_claudecode_session(
|
|
|
110
113
|
|
|
111
114
|
await host.connect()
|
|
112
115
|
|
|
116
|
+
# Crash-orphan rescue: if a non-graceful host death left this task's
|
|
117
|
+
# tmux/ttyd/claude tree running with unsaved state, harvest it into a fresh
|
|
118
|
+
# snapshot and kill it BEFORE the driver wipes the workdir. No-op otherwise.
|
|
119
|
+
await _rescue_orphan_if_present(ctx, config=config, host=host)
|
|
120
|
+
|
|
113
121
|
async def _prepare(host: Host, hook_ctx: HookContext) -> None:
|
|
114
122
|
"""Install the claude+ttyd runtime and restore a resume snapshot.
|
|
115
123
|
|
|
@@ -176,7 +184,7 @@ async def run_claudecode_session(
|
|
|
176
184
|
)
|
|
177
185
|
|
|
178
186
|
async def _claudecode_body(host: Host, hook_ctx: HookContext) -> None:
|
|
179
|
-
nonlocal launched_handle, tmux_path, tmux_socket, tmux_session
|
|
187
|
+
nonlocal launched_handle, tmux_path, tmux_socket, tmux_session, input_runner
|
|
180
188
|
nonlocal cred_baseline, cred_watch_task
|
|
181
189
|
nonlocal resolved_seed_id, lease_holder
|
|
182
190
|
|
|
@@ -303,6 +311,18 @@ async def run_claudecode_session(
|
|
|
303
311
|
await ctx.set_widget_data({
|
|
304
312
|
"iframeSrc": "{widgetProxyUrl}/",
|
|
305
313
|
})
|
|
314
|
+
# In-session input listener: receives human messages from the
|
|
315
|
+
# iframe-input widget via the API widget-control proxy and injects
|
|
316
|
+
# them under the same lock as system messages (no garbling).
|
|
317
|
+
async def _inject_raw(text: str) -> None:
|
|
318
|
+
await host_actions.send_text_to_claude(
|
|
319
|
+
host, tmux_path, tmux_socket, tmux_session, text,
|
|
320
|
+
)
|
|
321
|
+
input_runner, input_port = await start_input_listener(
|
|
322
|
+
bind_iface=ttyd_iface,
|
|
323
|
+
on_input=serialized(injection_lock, _inject_raw),
|
|
324
|
+
)
|
|
325
|
+
await ctx.set_control_upstream(f"http://{upstream_host}:{input_port}")
|
|
306
326
|
ctx.report_progress(None, "Claude Code is live")
|
|
307
327
|
|
|
308
328
|
# Await the claude process inside tmux (NOT the ttyd connection). ttyd
|
|
@@ -334,12 +354,12 @@ async def run_claudecode_session(
|
|
|
334
354
|
cred_watch_task = None
|
|
335
355
|
|
|
336
356
|
async def _agent_sender(message: str) -> None:
|
|
337
|
-
#
|
|
338
|
-
#
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
357
|
+
# Serialized against the human-input listener via injection_lock so a
|
|
358
|
+
# system message can never interleave with a human burst.
|
|
359
|
+
async with injection_lock:
|
|
360
|
+
await host_actions.send_text_to_claude(
|
|
361
|
+
host, tmux_path, tmux_socket, tmux_session, message,
|
|
362
|
+
)
|
|
343
363
|
|
|
344
364
|
try:
|
|
345
365
|
await run_log_protocol_session(
|
|
@@ -358,41 +378,37 @@ async def run_claudecode_session(
|
|
|
358
378
|
if not ctx.should_continue():
|
|
359
379
|
cancelled = True
|
|
360
380
|
_trace("finally: ENTER cancelled=%s resuming=%s", cancelled, resuming)
|
|
361
|
-
if
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
if tmux_path is not None and tmux_socket is not None and tmux_session is not None:
|
|
381
|
+
if (
|
|
382
|
+
launched_handle is not None
|
|
383
|
+
and tmux_path is not None
|
|
384
|
+
and tmux_socket is not None
|
|
385
|
+
and tmux_session is not None
|
|
386
|
+
and claude_path
|
|
387
|
+
):
|
|
388
|
+
_trace("finally: teardown_session_tree START aggressive=%s", cancelled)
|
|
370
389
|
try:
|
|
371
|
-
await host_actions.
|
|
372
|
-
host,
|
|
390
|
+
await host_actions.teardown_session_tree(
|
|
391
|
+
host,
|
|
392
|
+
tmux_path=tmux_path,
|
|
393
|
+
tmux_socket=tmux_socket,
|
|
394
|
+
tmux_session=tmux_session,
|
|
395
|
+
claude_path=claude_path,
|
|
396
|
+
ttyd_handle=launched_handle,
|
|
397
|
+
aggressive=cancelled,
|
|
373
398
|
)
|
|
374
399
|
except Exception:
|
|
375
|
-
_LOG.exception("
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
# claude and returns; claude may still be flushing settings / mcp-cache
|
|
380
|
-
# / locks as it dies, which would make the capture tar fail with
|
|
381
|
-
# "file changed as we read it". Best-effort + bounded; the strict tar
|
|
382
|
-
# exit check in _archive_home_claude is the backstop.
|
|
383
|
-
if launched_handle is not None and claude_path:
|
|
384
|
-
# claude runs under pasta in its own process group and ignores the
|
|
385
|
-
# tmux pane's SIGHUP from kill-session, so it is orphaned by the
|
|
386
|
-
# ttyd/tmux teardown above. Kill it explicitly, else await_claude_gone
|
|
387
|
-
# waits on a process nothing kills and the cancel grace is exceeded.
|
|
400
|
+
_LOG.exception("teardown_session_tree failed")
|
|
401
|
+
_trace("finally: teardown_session_tree DONE")
|
|
402
|
+
|
|
403
|
+
if input_runner is not None:
|
|
388
404
|
try:
|
|
389
|
-
await
|
|
405
|
+
await input_runner.cleanup()
|
|
390
406
|
except Exception:
|
|
391
|
-
_LOG.exception("
|
|
407
|
+
_LOG.exception("input listener cleanup failed")
|
|
392
408
|
try:
|
|
393
|
-
await
|
|
409
|
+
await ctx.clear_control_upstream()
|
|
394
410
|
except Exception:
|
|
395
|
-
_LOG.exception("
|
|
411
|
+
_LOG.exception("clear control upstream failed")
|
|
396
412
|
|
|
397
413
|
if cred_watch_task is not None:
|
|
398
414
|
cred_watch_task.cancel()
|
|
@@ -566,6 +582,89 @@ async def _extract_home_claude(host: Host, plain: bytes) -> None:
|
|
|
566
582
|
await host.run_command(f"rm -f {shlex.quote(tmpfile)}")
|
|
567
583
|
|
|
568
584
|
|
|
585
|
+
_RESCUE_MARKER = ".optio-rescue-pending"
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _claude_bin_path(host: "Host") -> str:
|
|
589
|
+
"""Deterministic launch path of claude inside the isolated HOME."""
|
|
590
|
+
return f"{host.workdir.rstrip('/')}/home/.local/bin/claude"
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
async def _marker_present(host: "Host", marker_path: str) -> bool:
|
|
594
|
+
r = await host.run_command(
|
|
595
|
+
f"test -e {shlex.quote(marker_path)} && echo YES || true"
|
|
596
|
+
)
|
|
597
|
+
return "YES" in r.stdout
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
async def _rescue_orphan_if_present(
|
|
601
|
+
ctx: ProcessContext, host: Host, config: ClaudeCodeTaskConfig,
|
|
602
|
+
) -> None:
|
|
603
|
+
"""Before the driver wipes the workdir, recover a crash-surviving orphan.
|
|
604
|
+
|
|
605
|
+
A non-graceful host death (disk-full, OOM, power loss) leaves the
|
|
606
|
+
tmux/ttyd/claude sub-tree running, re-parented to init, with unsaved state
|
|
607
|
+
on disk — but no snapshot. This bracket, run before
|
|
608
|
+
``run_log_protocol_session`` (hence before ``setup_workdir``), detects that
|
|
609
|
+
orphan on the deterministic per-task socket, kills it, and captures its
|
|
610
|
+
live state into a fresh snapshot that the unchanged resume path then
|
|
611
|
+
restores. No-op unless an orphan (or a leftover rescue marker) is found.
|
|
612
|
+
|
|
613
|
+
Kill-before-capture is deliberate: a dead, static workdir prevents a live
|
|
614
|
+
claude from repopulating ``home/.claude`` into the plaintext workdir blob
|
|
615
|
+
after the expunge, and yields a race-free tar. See spec decisions D3/D4."""
|
|
616
|
+
if not getattr(config, "supports_resume", True):
|
|
617
|
+
return
|
|
618
|
+
if not bool(getattr(ctx, "resume", False)):
|
|
619
|
+
return
|
|
620
|
+
|
|
621
|
+
socket = host_actions._tmux_socket_path(host)
|
|
622
|
+
session = "optio"
|
|
623
|
+
marker_path = f"{host.workdir.rstrip('/')}/{_RESCUE_MARKER}"
|
|
624
|
+
|
|
625
|
+
tmux_path = await host_actions._require_tmux(host)
|
|
626
|
+
alive = await host_actions.tmux_session_alive(
|
|
627
|
+
host, tmux_path, socket, session,
|
|
628
|
+
)
|
|
629
|
+
if not alive and not await _marker_present(host, marker_path):
|
|
630
|
+
return # normal resume; nothing to rescue
|
|
631
|
+
|
|
632
|
+
_LOG.warning(
|
|
633
|
+
"crash-orphan rescue: live=%s socket=%s — capturing live state before wipe",
|
|
634
|
+
alive, socket,
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
# 1. Durable marker (retry guard: kill removes the has-session signal).
|
|
638
|
+
await host.write_text(_RESCUE_MARKER, "")
|
|
639
|
+
|
|
640
|
+
# 2. Kill the orphan tree (handle-less: orphan ttyd reaped by socket).
|
|
641
|
+
claude_path = _claude_bin_path(host)
|
|
642
|
+
await host_actions.teardown_session_tree(
|
|
643
|
+
host,
|
|
644
|
+
tmux_path=tmux_path,
|
|
645
|
+
tmux_socket=socket,
|
|
646
|
+
tmux_session=session,
|
|
647
|
+
claude_path=claude_path,
|
|
648
|
+
ttyd_handle=None,
|
|
649
|
+
aggressive=True,
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
# 3. Capture the now-static workdir — identical artifacts to a normal
|
|
653
|
+
# teardown capture. Exclude the marker so a restored workdir cannot
|
|
654
|
+
# re-trigger rescue in a loop.
|
|
655
|
+
exclude = [*(config.workdir_exclude or []), _RESCUE_MARKER]
|
|
656
|
+
await _capture_snapshot(
|
|
657
|
+
ctx, host,
|
|
658
|
+
end_state="rescued",
|
|
659
|
+
workdir_exclude=exclude,
|
|
660
|
+
session_blob_encrypt=config.session_blob_encrypt,
|
|
661
|
+
)
|
|
662
|
+
|
|
663
|
+
# 4. Capture durable — clear the marker.
|
|
664
|
+
await host.run_command(f"rm -f {shlex.quote(marker_path)}")
|
|
665
|
+
_LOG.warning("crash-orphan rescue: fresh snapshot captured; orphan killed")
|
|
666
|
+
|
|
667
|
+
|
|
569
668
|
async def _capture_snapshot(
|
|
570
669
|
ctx: ProcessContext,
|
|
571
670
|
host: Host,
|
|
@@ -783,7 +882,7 @@ def create_claudecode_task(
|
|
|
783
882
|
process_id=process_id,
|
|
784
883
|
name=name,
|
|
785
884
|
description=description,
|
|
786
|
-
ui_widget="iframe",
|
|
885
|
+
ui_widget="iframe-input",
|
|
787
886
|
supports_resume=config.supports_resume,
|
|
788
887
|
metadata=metadata or {},
|
|
789
888
|
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: optio-claudecode
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
4
4
|
Summary: Run Anthropic Claude Code as an optio task; local subprocess or remote via SSH; ttyd-served TUI iframe.
|
|
5
5
|
Author-email: Kristof Csillag <kristof.csillag@deai-labs.com>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -24,6 +24,7 @@ Requires-Dist: optio-core<0.3,>=0.2
|
|
|
24
24
|
Requires-Dist: optio-host<0.3,>=0.2
|
|
25
25
|
Requires-Dist: optio-agents<0.3,>=0.2
|
|
26
26
|
Requires-Dist: asyncssh>=2.14
|
|
27
|
+
Requires-Dist: aiohttp>=3.9
|
|
27
28
|
Provides-Extra: dev
|
|
28
29
|
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
29
30
|
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
@@ -4,6 +4,7 @@ src/optio_claudecode/__init__.py
|
|
|
4
4
|
src/optio_claudecode/account.py
|
|
5
5
|
src/optio_claudecode/cred_watcher.py
|
|
6
6
|
src/optio_claudecode/host_actions.py
|
|
7
|
+
src/optio_claudecode/input_listener.py
|
|
7
8
|
src/optio_claudecode/oauth.py
|
|
8
9
|
src/optio_claudecode/oauth_redirect.py
|
|
9
10
|
src/optio_claudecode/prompt.py
|
|
@@ -22,6 +23,9 @@ tests/test_await_claude_gone.py
|
|
|
22
23
|
tests/test_cred_watcher.py
|
|
23
24
|
tests/test_home_isolation.py
|
|
24
25
|
tests/test_host_actions.py
|
|
26
|
+
tests/test_injection_serialize.py
|
|
27
|
+
tests/test_input_listener.py
|
|
28
|
+
tests/test_kill_ttyd_by_socket.py
|
|
25
29
|
tests/test_launch_detached_checked.py
|
|
26
30
|
tests/test_oauth.py
|
|
27
31
|
tests/test_oauth_redirect.py
|
|
@@ -29,6 +33,7 @@ tests/test_on_resume_refresh.py
|
|
|
29
33
|
tests/test_prompt.py
|
|
30
34
|
tests/test_purge_seed.py
|
|
31
35
|
tests/test_rekey_projects.py
|
|
36
|
+
tests/test_rescue_orphan.py
|
|
32
37
|
tests/test_resume_prompt.py
|
|
33
38
|
tests/test_resume_sentence_claudecode.py
|
|
34
39
|
tests/test_runtime_cache.py
|
|
@@ -46,6 +51,7 @@ tests/test_session_seed_consume.py
|
|
|
46
51
|
tests/test_session_seed_saveback.py
|
|
47
52
|
tests/test_session_seed_unknown_id.py
|
|
48
53
|
tests/test_snapshots.py
|
|
54
|
+
tests/test_teardown_session_tree.py
|
|
49
55
|
tests/test_tmux_persistence.py
|
|
50
56
|
tests/test_tmux_socket_path.py
|
|
51
57
|
tests/test_types.py
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""serialized() prevents two injection bursts from overlapping."""
|
|
2
|
+
import asyncio
|
|
3
|
+
|
|
4
|
+
from optio_claudecode.input_listener import serialized
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
async def test_serialized_no_interleave():
|
|
8
|
+
lock = asyncio.Lock()
|
|
9
|
+
active = 0
|
|
10
|
+
max_active = 0
|
|
11
|
+
|
|
12
|
+
async def raw_send(text):
|
|
13
|
+
nonlocal active, max_active
|
|
14
|
+
active += 1
|
|
15
|
+
max_active = max(max_active, active)
|
|
16
|
+
await asyncio.sleep(0.02) # force overlap if unlocked
|
|
17
|
+
active -= 1
|
|
18
|
+
|
|
19
|
+
send = serialized(lock, raw_send)
|
|
20
|
+
await asyncio.gather(*(send(f"m{i}") for i in range(5)))
|
|
21
|
+
assert max_active == 1 # never two bursts at once
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""POST /input delivers to on_input and maps results to acks."""
|
|
2
|
+
import aiohttp
|
|
3
|
+
|
|
4
|
+
from optio_claudecode.input_listener import start_input_listener
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
async def _post(port, payload):
|
|
8
|
+
async with aiohttp.ClientSession() as s:
|
|
9
|
+
async with s.post(f"http://127.0.0.1:{port}/input", json=payload) as r:
|
|
10
|
+
return r.status, await r.json()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
async def test_listener_delivers_text_and_acks_ok():
|
|
14
|
+
seen = []
|
|
15
|
+
|
|
16
|
+
async def on_input(text):
|
|
17
|
+
seen.append(text)
|
|
18
|
+
|
|
19
|
+
runner, port = await start_input_listener(bind_iface="127.0.0.1", on_input=on_input)
|
|
20
|
+
try:
|
|
21
|
+
status, body = await _post(port, {"text": "hello world"})
|
|
22
|
+
assert status == 200 and body == {"ok": True}
|
|
23
|
+
assert seen == ["hello world"]
|
|
24
|
+
finally:
|
|
25
|
+
await runner.cleanup()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def test_listener_502_on_injection_failure():
|
|
29
|
+
async def on_input(text):
|
|
30
|
+
raise RuntimeError("tmux boom")
|
|
31
|
+
|
|
32
|
+
runner, port = await start_input_listener(bind_iface="127.0.0.1", on_input=on_input)
|
|
33
|
+
try:
|
|
34
|
+
status, body = await _post(port, {"text": "x"})
|
|
35
|
+
assert status == 502 and body["reason"] == "send-failed"
|
|
36
|
+
finally:
|
|
37
|
+
await runner.cleanup()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def test_listener_400_on_empty_text():
|
|
41
|
+
async def on_input(text):
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
runner, port = await start_input_listener(bind_iface="127.0.0.1", on_input=on_input)
|
|
45
|
+
try:
|
|
46
|
+
status, _ = await _post(port, {"text": ""})
|
|
47
|
+
assert status == 400
|
|
48
|
+
finally:
|
|
49
|
+
await runner.cleanup()
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
import optio_claudecode.host_actions as H
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class _Result:
|
|
7
|
+
def __init__(self, stdout=""):
|
|
8
|
+
self.stdout = stdout
|
|
9
|
+
self.stderr = ""
|
|
10
|
+
self.exit_code = 0
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class _Host:
|
|
14
|
+
def __init__(self):
|
|
15
|
+
self.commands = []
|
|
16
|
+
|
|
17
|
+
async def run_command(self, cmd, **kwargs):
|
|
18
|
+
self.commands.append(cmd)
|
|
19
|
+
return _Result()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@pytest.mark.asyncio
|
|
23
|
+
async def test_kill_ttyd_by_socket_anchored_pkill():
|
|
24
|
+
host = _Host()
|
|
25
|
+
await H._kill_ttyd_by_socket(host, "/tmp/optio-cc-deadbeef0badcafe.sock")
|
|
26
|
+
assert len(host.commands) == 1
|
|
27
|
+
cmd = host.commands[0]
|
|
28
|
+
# Targets ttyd processes by the socket path they carry in their cmdline.
|
|
29
|
+
assert "pkill" in cmd
|
|
30
|
+
assert "/tmp/optio-cc-deadbeef0badcafe.sock" in cmd
|
|
31
|
+
# Anchored so the rescue's own command line is not matched (mirrors the
|
|
32
|
+
# [c]laude self-match guard used for claude).
|
|
33
|
+
assert "optio-cc-deadbeef0badcafe.sock" in cmd
|
|
34
|
+
# Best-effort: never fails the caller when nothing matches.
|
|
35
|
+
assert "|| true" in cmd
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@pytest.mark.asyncio
|
|
39
|
+
async def test_kill_ttyd_by_socket_does_not_self_match():
|
|
40
|
+
# The emitted pattern must contain a bracket-escape so pkill -f does not
|
|
41
|
+
# match its own argv. We assert the socket digest is bracket-split.
|
|
42
|
+
host = _Host()
|
|
43
|
+
await H._kill_ttyd_by_socket(host, "/tmp/optio-cc-abc123.sock")
|
|
44
|
+
cmd = host.commands[0]
|
|
45
|
+
assert "[" in cmd and "]" in cmd
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
import optio_claudecode.session as S
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class _Result:
|
|
7
|
+
def __init__(self, stdout=""):
|
|
8
|
+
self.stdout = stdout
|
|
9
|
+
self.stderr = ""
|
|
10
|
+
self.exit_code = 0
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class _Host:
|
|
14
|
+
"""Stub host. ``existing`` is a set of marker paths reported present."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, workdir, existing=None):
|
|
17
|
+
self.workdir = workdir
|
|
18
|
+
self.existing = set(existing or ())
|
|
19
|
+
self.written = []
|
|
20
|
+
self.removed = []
|
|
21
|
+
self.commands = []
|
|
22
|
+
|
|
23
|
+
async def run_command(self, cmd, **kwargs):
|
|
24
|
+
self.commands.append(cmd)
|
|
25
|
+
# Emulate `test -e <path> && echo YES || true` marker probes.
|
|
26
|
+
if cmd.startswith("test -e "):
|
|
27
|
+
path = cmd.split("test -e ", 1)[1].split(" ", 1)[0].strip("'\"")
|
|
28
|
+
return _Result("YES\n" if path in self.existing else "")
|
|
29
|
+
if cmd.startswith("rm -f "):
|
|
30
|
+
path = cmd.split("rm -f ", 1)[1].strip().strip("'\"")
|
|
31
|
+
self.removed.append(path)
|
|
32
|
+
self.existing.discard(path)
|
|
33
|
+
return _Result()
|
|
34
|
+
|
|
35
|
+
async def write_text(self, rel, text):
|
|
36
|
+
self.written.append(rel)
|
|
37
|
+
self.existing.add(f"{self.workdir.rstrip('/')}/{rel}")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class _Config:
|
|
41
|
+
workdir_exclude = None
|
|
42
|
+
session_blob_encrypt = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class _Ctx:
|
|
46
|
+
process_id = "pid-1"
|
|
47
|
+
resume = True
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@pytest.fixture
|
|
51
|
+
def patched(monkeypatch):
|
|
52
|
+
rec = {"alive": False, "teardown": [], "capture": [], "fail_capture": False}
|
|
53
|
+
|
|
54
|
+
async def _alive(host, tmux_path, socket, session):
|
|
55
|
+
return rec["alive"]
|
|
56
|
+
|
|
57
|
+
async def _require_tmux(host):
|
|
58
|
+
return "tmux"
|
|
59
|
+
|
|
60
|
+
def _socket(host):
|
|
61
|
+
return "/tmp/optio-cc-deadbeef.sock"
|
|
62
|
+
|
|
63
|
+
async def _teardown(host, **kw):
|
|
64
|
+
rec["teardown"].append(kw)
|
|
65
|
+
|
|
66
|
+
async def _capture(ctx, host, **kw):
|
|
67
|
+
rec["capture"].append(kw)
|
|
68
|
+
if rec["fail_capture"]:
|
|
69
|
+
raise RuntimeError("capture failed")
|
|
70
|
+
|
|
71
|
+
monkeypatch.setattr(S.host_actions, "tmux_session_alive", _alive)
|
|
72
|
+
monkeypatch.setattr(S.host_actions, "_require_tmux", _require_tmux)
|
|
73
|
+
monkeypatch.setattr(S.host_actions, "_tmux_socket_path", _socket)
|
|
74
|
+
monkeypatch.setattr(S.host_actions, "teardown_session_tree", _teardown)
|
|
75
|
+
monkeypatch.setattr(S, "_capture_snapshot", _capture)
|
|
76
|
+
return rec
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@pytest.mark.asyncio
|
|
80
|
+
async def test_noop_when_no_session_and_no_marker(patched, tmp_path):
|
|
81
|
+
patched["alive"] = False
|
|
82
|
+
host = _Host(str(tmp_path))
|
|
83
|
+
await S._rescue_orphan_if_present(_Ctx(), host, _Config())
|
|
84
|
+
assert patched["teardown"] == []
|
|
85
|
+
assert patched["capture"] == []
|
|
86
|
+
assert host.written == []
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@pytest.mark.asyncio
|
|
90
|
+
async def test_triggers_on_live_session_kill_before_capture(patched, tmp_path):
|
|
91
|
+
patched["alive"] = True
|
|
92
|
+
host = _Host(str(tmp_path))
|
|
93
|
+
await S._rescue_orphan_if_present(_Ctx(), host, _Config())
|
|
94
|
+
# Marker written, orphan killed, then captured — kill strictly before capture.
|
|
95
|
+
assert host.written == [".optio-rescue-pending"]
|
|
96
|
+
assert len(patched["teardown"]) == 1
|
|
97
|
+
assert patched["teardown"][0]["ttyd_handle"] is None
|
|
98
|
+
assert len(patched["capture"]) == 1
|
|
99
|
+
# end_state marks the rescue for forensics.
|
|
100
|
+
assert patched["capture"][0]["end_state"] == "rescued"
|
|
101
|
+
# Marker excluded from the snapshot so a restored workdir does not loop.
|
|
102
|
+
assert ".optio-rescue-pending" in (patched["capture"][0]["workdir_exclude"] or [])
|
|
103
|
+
# Marker cleared after capture success.
|
|
104
|
+
marker = f"{str(tmp_path).rstrip('/')}/.optio-rescue-pending"
|
|
105
|
+
assert marker in host.removed
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@pytest.mark.asyncio
|
|
109
|
+
async def test_triggers_on_marker_even_without_session(patched, tmp_path):
|
|
110
|
+
patched["alive"] = False
|
|
111
|
+
marker = f"{str(tmp_path).rstrip('/')}/.optio-rescue-pending"
|
|
112
|
+
host = _Host(str(tmp_path), existing={marker})
|
|
113
|
+
await S._rescue_orphan_if_present(_Ctx(), host, _Config())
|
|
114
|
+
# Detect-by-marker path still rescues (mid-rescue retry).
|
|
115
|
+
assert len(patched["teardown"]) == 1
|
|
116
|
+
assert len(patched["capture"]) == 1
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@pytest.mark.asyncio
|
|
120
|
+
async def test_capture_failure_persists_marker_and_reraises(patched, tmp_path):
|
|
121
|
+
patched["alive"] = True
|
|
122
|
+
patched["fail_capture"] = True
|
|
123
|
+
host = _Host(str(tmp_path))
|
|
124
|
+
with pytest.raises(RuntimeError):
|
|
125
|
+
await S._rescue_orphan_if_present(_Ctx(), host, _Config())
|
|
126
|
+
marker = f"{str(tmp_path).rstrip('/')}/.optio-rescue-pending"
|
|
127
|
+
# Marker NOT removed — a retried resume re-enters rescue.
|
|
128
|
+
assert marker not in host.removed
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@pytest.mark.asyncio
|
|
132
|
+
async def test_skipped_when_not_resuming(patched, tmp_path):
|
|
133
|
+
patched["alive"] = True
|
|
134
|
+
|
|
135
|
+
class _FreshCtx:
|
|
136
|
+
process_id = "pid-1"
|
|
137
|
+
resume = False
|
|
138
|
+
|
|
139
|
+
host = _Host(str(tmp_path))
|
|
140
|
+
await S._rescue_orphan_if_present(_FreshCtx(), host, _Config())
|
|
141
|
+
assert patched["teardown"] == []
|
|
142
|
+
assert patched["capture"] == []
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# --------------------------------------------------------------------------
|
|
146
|
+
# Integration: real (shim) detached session -> orphan -> rescue.
|
|
147
|
+
#
|
|
148
|
+
# Exercises the REAL teardown_session_tree + _capture_snapshot against a real
|
|
149
|
+
# local tmux tree: launch a detached tmux session whose "claude" is a long-lived
|
|
150
|
+
# sleep shim at the deterministic <workdir>/home/.local/bin/claude path, abandon
|
|
151
|
+
# it (no teardown) so it is the orphan, then run _rescue_orphan_if_present and
|
|
152
|
+
# assert the orphan is gone, a fresh "rescued" snapshot exists, and the marker is
|
|
153
|
+
# cleared. Mirrors the launch scaffolding in test_tmux_persistence.py.
|
|
154
|
+
# --------------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
import os # noqa: E402
|
|
157
|
+
import shutil # noqa: E402
|
|
158
|
+
|
|
159
|
+
from optio_claudecode import ClaudeCodeTaskConfig # noqa: E402
|
|
160
|
+
from optio_claudecode import host_actions as H # noqa: E402
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
_NO_TMUX = shutil.which("tmux") is None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
async def _launch_orphan_session(host):
|
|
167
|
+
"""Start a detached tmux session whose 'claude' records its pid then sleeps,
|
|
168
|
+
on the deterministic per-task socket the rescue probes. Returns
|
|
169
|
+
(tmux_path, socket)."""
|
|
170
|
+
workdir = host.workdir
|
|
171
|
+
os.makedirs(f"{workdir}/home/.local/bin", exist_ok=True)
|
|
172
|
+
claude = f"{workdir}/home/.local/bin/claude"
|
|
173
|
+
marker = f"{workdir}/claude.pid"
|
|
174
|
+
with open(claude, "w") as f:
|
|
175
|
+
f.write(f"#!/bin/bash\necho $$ > {marker}\nexec sleep 60\n")
|
|
176
|
+
os.chmod(claude, 0o755)
|
|
177
|
+
|
|
178
|
+
# Plant credentials so the capture credentials-present guard passes.
|
|
179
|
+
os.makedirs(f"{workdir}/home/.claude", exist_ok=True)
|
|
180
|
+
with open(f"{workdir}/home/.claude/.credentials.json", "w") as f:
|
|
181
|
+
f.write('{"token": "test"}')
|
|
182
|
+
|
|
183
|
+
tmux_path = await H._require_tmux(host)
|
|
184
|
+
socket = H._tmux_socket_path(host)
|
|
185
|
+
argv = H.build_tmux_session_argv(
|
|
186
|
+
tmux_path=tmux_path, claude_path=claude, workdir=workdir,
|
|
187
|
+
socket_path=socket, session_name="optio",
|
|
188
|
+
extra_env=None, claude_flags=[],
|
|
189
|
+
)
|
|
190
|
+
import shlex
|
|
191
|
+
cmd = " ".join(shlex.quote(a) for a in argv)
|
|
192
|
+
r = await host.run_command(cmd)
|
|
193
|
+
assert r.exit_code == 0, r.stderr
|
|
194
|
+
return tmux_path, socket
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@pytest.mark.skipif(_NO_TMUX, reason="tmux not installed on the worker")
|
|
198
|
+
@pytest.mark.asyncio
|
|
199
|
+
async def test_rescue_end_to_end_kills_orphan_and_snapshots(
|
|
200
|
+
mongo_db, tmp_path, ctx_and_captures,
|
|
201
|
+
):
|
|
202
|
+
"""Launch a real (shim) detached session, abandon it so it becomes an
|
|
203
|
+
orphan, then run _rescue_orphan_if_present and assert: the tmux session is
|
|
204
|
+
gone, no claude shim remains, and a fresh 'rescued' snapshot was inserted.
|
|
205
|
+
|
|
206
|
+
Manual run:
|
|
207
|
+
.venv/bin/python -m pytest \\
|
|
208
|
+
packages/optio-claudecode/tests/test_rescue_orphan.py \\
|
|
209
|
+
-k end_to_end -v
|
|
210
|
+
"""
|
|
211
|
+
import asyncio
|
|
212
|
+
|
|
213
|
+
from optio_claudecode.snapshots import load_latest_snapshot
|
|
214
|
+
from optio_host.host import LocalHost
|
|
215
|
+
|
|
216
|
+
ctx, _cap, _flag = ctx_and_captures
|
|
217
|
+
ctx.resume = True
|
|
218
|
+
|
|
219
|
+
# (a)+(b) Build the LocalHost and launch the detached (shim) session on the
|
|
220
|
+
# deterministic per-task socket.
|
|
221
|
+
taskdir = str(tmp_path / "task")
|
|
222
|
+
os.makedirs(taskdir, exist_ok=True)
|
|
223
|
+
host = LocalHost(taskdir=taskdir)
|
|
224
|
+
os.makedirs(host.workdir, exist_ok=True)
|
|
225
|
+
|
|
226
|
+
tmux_path, socket = await _launch_orphan_session(host)
|
|
227
|
+
# Confirm the orphan is actually alive before we rescue it.
|
|
228
|
+
assert await H.tmux_session_alive(host, tmux_path, socket, "optio")
|
|
229
|
+
pid_file = f"{host.workdir}/claude.pid"
|
|
230
|
+
for _ in range(30):
|
|
231
|
+
if os.path.exists(pid_file):
|
|
232
|
+
break
|
|
233
|
+
await asyncio.sleep(0.1)
|
|
234
|
+
assert os.path.exists(pid_file)
|
|
235
|
+
child_pid = int(open(pid_file).read().strip())
|
|
236
|
+
|
|
237
|
+
# (c) Crash simulation: we never captured an in-process handle, and we do
|
|
238
|
+
# not tear the session down. The tmux/claude tree is the orphan.
|
|
239
|
+
|
|
240
|
+
config = ClaudeCodeTaskConfig(
|
|
241
|
+
consumer_instructions="(rescue e2e)",
|
|
242
|
+
supports_resume=True,
|
|
243
|
+
session_blob_encrypt=lambda b: b,
|
|
244
|
+
session_blob_decrypt=lambda b: b,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
# (d) Rescue.
|
|
248
|
+
await S._rescue_orphan_if_present(ctx, host, config)
|
|
249
|
+
|
|
250
|
+
# (e) Assertions.
|
|
251
|
+
# The tmux session is gone (orphan killed before capture).
|
|
252
|
+
assert (await H.tmux_session_alive(host, tmux_path, socket, "optio")) is False
|
|
253
|
+
# The sleep shim child is reaped (kill-session SIGHUPs the pane tree).
|
|
254
|
+
await asyncio.sleep(0.3)
|
|
255
|
+
with pytest.raises(ProcessLookupError):
|
|
256
|
+
os.kill(child_pid, 0)
|
|
257
|
+
|
|
258
|
+
# A fresh 'rescued' snapshot was inserted.
|
|
259
|
+
snap = await load_latest_snapshot(
|
|
260
|
+
mongo_db, prefix=ctx._prefix, process_id=ctx.process_id,
|
|
261
|
+
)
|
|
262
|
+
assert snap is not None
|
|
263
|
+
assert snap["endState"] == "rescued"
|
|
264
|
+
|
|
265
|
+
# Marker cleared on success.
|
|
266
|
+
marker = f"{host.workdir.rstrip('/')}/.optio-rescue-pending"
|
|
267
|
+
r = await host.run_command(
|
|
268
|
+
f"test -e {marker} && echo Y || true"
|
|
269
|
+
)
|
|
270
|
+
assert "Y" not in r.stdout
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
import optio_claudecode.host_actions as H
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@pytest.fixture
|
|
7
|
+
def calls(monkeypatch):
|
|
8
|
+
rec = []
|
|
9
|
+
|
|
10
|
+
async def _kill_ttyd(host, socket):
|
|
11
|
+
rec.append(("ttyd_socket", socket))
|
|
12
|
+
|
|
13
|
+
async def _kill_session(host, tmux_path, socket, session):
|
|
14
|
+
rec.append(("kill_session", session))
|
|
15
|
+
|
|
16
|
+
async def _kill_claude(host, claude_path, **kw):
|
|
17
|
+
rec.append(("kill_claude", claude_path))
|
|
18
|
+
|
|
19
|
+
async def _await_gone(host, claude_path, **kw):
|
|
20
|
+
rec.append(("await_gone", claude_path))
|
|
21
|
+
return True
|
|
22
|
+
|
|
23
|
+
monkeypatch.setattr(H, "_kill_ttyd_by_socket", _kill_ttyd)
|
|
24
|
+
monkeypatch.setattr(H, "_kill_tmux_session", _kill_session)
|
|
25
|
+
monkeypatch.setattr(H, "kill_claude_processes", _kill_claude)
|
|
26
|
+
monkeypatch.setattr(H, "await_claude_gone", _await_gone)
|
|
27
|
+
return rec
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class _FakeHandle:
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _Host:
|
|
35
|
+
def __init__(self):
|
|
36
|
+
self.terminated = []
|
|
37
|
+
|
|
38
|
+
async def terminate_subprocess(self, handle, *, aggressive):
|
|
39
|
+
self.terminated.append((handle, aggressive))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@pytest.mark.asyncio
|
|
43
|
+
async def test_orphan_branch_uses_kill_ttyd_by_socket(calls):
|
|
44
|
+
host = _Host()
|
|
45
|
+
await H.teardown_session_tree(
|
|
46
|
+
host, tmux_path="tmux", tmux_socket="/tmp/s.sock",
|
|
47
|
+
tmux_session="optio", claude_path="/w/home/.local/bin/claude",
|
|
48
|
+
ttyd_handle=None, aggressive=True,
|
|
49
|
+
)
|
|
50
|
+
# All four steps, in order; orphan ttyd path; no terminate_subprocess.
|
|
51
|
+
assert [c[0] for c in calls] == [
|
|
52
|
+
"ttyd_socket", "kill_session", "kill_claude", "await_gone",
|
|
53
|
+
]
|
|
54
|
+
assert host.terminated == []
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@pytest.mark.asyncio
|
|
58
|
+
async def test_handle_branch_uses_terminate_subprocess(calls):
|
|
59
|
+
host = _Host()
|
|
60
|
+
handle = _FakeHandle()
|
|
61
|
+
await H.teardown_session_tree(
|
|
62
|
+
host, tmux_path="tmux", tmux_socket="/tmp/s.sock",
|
|
63
|
+
tmux_session="optio", claude_path="/w/home/.local/bin/claude",
|
|
64
|
+
ttyd_handle=handle, aggressive=False,
|
|
65
|
+
)
|
|
66
|
+
assert host.terminated == [(handle, False)]
|
|
67
|
+
# ttyd-by-socket NOT used when a handle is present.
|
|
68
|
+
assert [c[0] for c in calls] == ["kill_session", "kill_claude", "await_gone"]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@pytest.mark.asyncio
|
|
72
|
+
async def test_steps_are_best_effort(calls, monkeypatch):
|
|
73
|
+
# A failure in one step does not abort the rest.
|
|
74
|
+
async def _boom(host, socket):
|
|
75
|
+
raise RuntimeError("ttyd kill blew up")
|
|
76
|
+
|
|
77
|
+
monkeypatch.setattr(H, "_kill_ttyd_by_socket", _boom)
|
|
78
|
+
host = _Host()
|
|
79
|
+
# Should not raise.
|
|
80
|
+
await H.teardown_session_tree(
|
|
81
|
+
host, tmux_path="tmux", tmux_socket="/tmp/s.sock",
|
|
82
|
+
tmux_session="optio", claude_path="/w/home/.local/bin/claude",
|
|
83
|
+
ttyd_handle=None, aggressive=True,
|
|
84
|
+
)
|
|
85
|
+
# The remaining three steps still ran.
|
|
86
|
+
assert [c[0] for c in calls] == ["kill_session", "kill_claude", "await_gone"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{optio_claudecode-0.2.2 → optio_claudecode-0.2.4}/src/optio_claudecode.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{optio_claudecode-0.2.2 → optio_claudecode-0.2.4}/src/optio_claudecode.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{optio_claudecode-0.2.2 → optio_claudecode-0.2.4}/tests/test_session_resume_decrypt_failure.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|