agent-runtime-py 0.2.0__tar.gz → 0.2.2__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.
Files changed (24) hide show
  1. agent_runtime_py-0.2.2/CHANGELOG.md +21 -0
  2. {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.2}/PKG-INFO +1 -1
  3. {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.2}/pyproject.toml +1 -1
  4. {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.2}/src/agent_runtime/claude.py +24 -8
  5. {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.2}/src/agent_runtime/worker.py +36 -2
  6. agent_runtime_py-0.2.2/tests/test_claude_runtime.py +345 -0
  7. {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.2}/uv.lock +3 -3
  8. agent_runtime_py-0.2.0/CHANGELOG.md +0 -8
  9. agent_runtime_py-0.2.0/tests/test_claude_runtime.py +0 -166
  10. {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.2}/.github/workflows/ci.yml +0 -0
  11. {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.2}/.github/workflows/release.yml +0 -0
  12. {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.2}/.gitignore +0 -0
  13. {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.2}/LICENSE +0 -0
  14. {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.2}/README.md +0 -0
  15. {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.2}/src/agent_runtime/__init__.py +0 -0
  16. {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.2}/src/agent_runtime/bridge.py +0 -0
  17. {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.2}/src/agent_runtime/hooks.py +0 -0
  18. {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.2}/src/agent_runtime/monitor_worker.py +0 -0
  19. {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.2}/src/agent_runtime/monitors.py +0 -0
  20. {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.2}/src/agent_runtime/operations.py +0 -0
  21. {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.2}/src/agent_runtime/sessions.py +0 -0
  22. {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.2}/src/agent_runtime/store.py +0 -0
  23. {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.2}/tests/test_hooks.py +0 -0
  24. {agent_runtime_py-0.2.0 → agent_runtime_py-0.2.2}/tests/test_monitors.py +0 -0
@@ -0,0 +1,21 @@
1
+ # Changelog
2
+
3
+ ## 0.2.2
4
+
5
+ - Add `LaunchOptions.effort` (`low`, `medium`, `high`, `xhigh` or `max`), passed to native Claude.
6
+ - A worker that fails adds `native_stderr` to its result: the last lines (at most 20, each at most 500 characters) that native Claude wrote to stderr since its last result. They are often the reason of a failed start, where the SDK's error only says to check stderr. They are not redacted; a consumer decides whether to show them.
7
+ - Fix: `LaunchOptions.claude` and the `claude` found on PATH are used as given (made absolute), not as their link target. A version manager's shim (a link to the manager) was resolved to the manager, which then did not run Claude. A path that is not a file, also a directory, now raises `ValueError`; a missing path raised `FileNotFoundError` before.
8
+ - A repeated launch ID no longer compares the path of the Claude executable, so a launch that 0.2.1 recorded can be repeated.
9
+
10
+ ## 0.2.1
11
+
12
+ - Fix: a Claude input that native Claude folded into a turn started by something else (a peer message, a background task) never settled, because that turn's result carries the other origin. The worker then took no further input and never exited on idle. A result now settles the active input when the input's echo arrived before it.
13
+ - Fix: a turn that native Claude runs for another origin (a peer message) counts as work: the worker reports `turn.started` when that message arrives, not at the first assistant text, and does not exit on idle while the turn runs.
14
+ - Fix: the Claude worker starts with `-P`, so the session's working folder is not on its import path.
15
+
16
+ ## 0.2.0
17
+
18
+ - Breaking: requires typed-agent-hooks 0.3. Run the hooks with `agent_runtime.hooks.app.main()` and the host name (`codex` or `claude`) as the only argument; the host hook file sets the timeout. The `agent-runtime-hooks` console script is removed.
19
+ - Breaking: the Claude Agent SDK moves to the `claude` extra. `ClaudeRuntime` needs `agent-runtime-py[claude]`.
20
+ - Fix: a monitor worker whose command exceeded the output limit never exited and ignored SIGTERM.
21
+ - Fix: a Claude SessionStart, which carries no permission mode, kept the mode empty; it now keeps the mode that the same live Claude process reported.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agent-runtime-py
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Reconnectable native agent workers with durable input receipts and events
5
5
  Project-URL: Repository, https://github.com/nimashoghi/agent-runtime
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "agent-runtime-py"
7
- version = "0.2.0"
7
+ version = "0.2.2"
8
8
  description = "Reconnectable native agent workers with durable input receipts and events"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -33,6 +33,7 @@ class LaunchOptions:
33
33
  permission_mode: str = "default"
34
34
  allowed_tools: tuple[str, ...] = ()
35
35
  model: str | None = None
36
+ effort: str | None = None
36
37
  metadata: dict[str, Any] = field(default_factory=dict)
37
38
 
38
39
  def configuration(self) -> dict[str, Any]:
@@ -48,18 +49,34 @@ class LaunchOptions:
48
49
  "auto",
49
50
  }:
50
51
  raise ValueError("unknown Claude permission mode")
52
+ if self.effort is not None and self.effort not in ("low", "medium", "high", "xhigh", "max"):
53
+ raise ValueError("unknown Claude effort level")
51
54
  cwd = self.cwd.expanduser().resolve(strict=True)
52
55
  if not cwd.is_dir():
53
56
  raise ValueError("cwd must be a directory")
54
57
  executable = str(self.claude) if self.claude else shutil.which("claude")
55
58
  if not executable:
56
59
  raise ValueError("Claude Code executable not found")
60
+ # The path as given, not its link target: a version manager's shim is a link to the
61
+ # manager, which chooses the program by the name it was called with.
62
+ claude = Path(executable).expanduser().absolute()
63
+ if not claude.is_file():
64
+ raise ValueError(f"Claude Code executable not found: {claude}")
57
65
  value = asdict(self)
58
66
  value.pop("environment")
59
- value.update(cwd=str(cwd), claude=str(Path(executable).expanduser().resolve(strict=True)))
67
+ if self.effort is None:
68
+ value.pop("effort") # as a record of a version without the field
69
+ value.update(cwd=str(cwd), claude=str(claude))
60
70
  return json.loads(json.dumps(value))
61
71
 
62
72
 
73
+ def launch_identity(configuration: dict[str, Any]) -> dict[str, Any]:
74
+ """The part of a launch configuration that a repeated launch ID must repeat. The path of
75
+ the Claude executable is not part of it: its spelling changes with the installation and
76
+ between versions of this package (0.2.1 stored the link's target)."""
77
+ return {key: value for key, value in configuration.items() if key != "claude"}
78
+
79
+
63
80
  def process_alive(value: dict[str, Any]) -> bool:
64
81
  """PID reuse must never attach a client to an unrelated process."""
65
82
  if not value.get("pid") or not value.get("process_created_at"):
@@ -145,15 +162,11 @@ class ClaudeRuntime:
145
162
  }
146
163
  if not self.store.create_execution(value):
147
164
  existing = self.inspect(execution_id)
148
- for key in (
149
- "initial_message_id",
150
- "initial_digest",
151
- "resume",
152
- "fork",
153
- "configuration",
154
- ):
165
+ for key in ("initial_message_id", "initial_digest", "resume", "fork"):
155
166
  if existing[key] != value[key]:
156
167
  raise ValueError(f"launch ID already used with different {key}")
168
+ if launch_identity(existing["configuration"]) != launch_identity(configuration):
169
+ raise ValueError("launch ID already used with different configuration")
157
170
  return existing
158
171
  self.store.accept(session_id, execution_id, message_id, text)
159
172
  environment = {**os.environ, **options.environment}
@@ -177,6 +190,9 @@ class ClaudeRuntime:
177
190
  child = subprocess.Popen(
178
191
  [
179
192
  sys.executable,
193
+ # The worker's cwd is the session's folder, which the agent
194
+ # writes; it must not be on the worker's import path.
195
+ "-P",
180
196
  "-m",
181
197
  "agent_runtime.worker",
182
198
  str(self.store.root),
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import asyncio
6
+ import collections
6
7
  import logging
7
8
  import os
8
9
  import signal
@@ -45,6 +46,14 @@ class Worker:
45
46
  self.last_activity = time.monotonic()
46
47
  self.background_tasks: set[str] = set()
47
48
  self.presentation_started = False
49
+ # The last lines that native Claude wrote to stderr since its last result. When the
50
+ # worker fails, they go into its result apart from the error: they are often the
51
+ # reason (the SDK's error only says to check stderr), but not always.
52
+ self.stderr: collections.deque[str] = collections.deque(maxlen=20)
53
+
54
+ def native_stderr(self, line: str) -> None:
55
+ log.debug("claude: %s", line)
56
+ self.stderr.append(line[:500])
48
57
 
49
58
  def emit(self, kind: str, **fields: Any) -> None:
50
59
  self.store.append(self.execution_id, self.session_id, {"type": kind, **fields})
@@ -100,6 +109,17 @@ class Worker:
100
109
  if isinstance(message, UserMessage) and message.uuid:
101
110
  if admitted := self.store.admit(self.execution_id, message.uuid):
102
111
  self.emit("input.delivered", message_id=admitted, native_uuid=message.uuid)
112
+ if self.active and self.active["id"] == admitted:
113
+ self.active["acknowledged"] = True
114
+ elif (
115
+ (message.origin or {}).get("kind", "human") != "human"
116
+ and message.parent_tool_use_id is None
117
+ and not self.presentation_started
118
+ ):
119
+ # A message from another origin (a peer session, a background task)
120
+ # starts a turn that no input of this runtime asked for.
121
+ self.emit("turn.started", origin="native")
122
+ self.presentation_started = True
103
123
  elif isinstance(message, AssistantMessage) and message.parent_tool_use_id is None:
104
124
  if not self.presentation_started:
105
125
  self.emit("turn.started", origin="native")
@@ -112,8 +132,17 @@ class Worker:
112
132
  elif isinstance(message, ResultMessage):
113
133
  if message.session_id != self.session_id:
114
134
  raise RuntimeError("native Claude returned a different session ID")
135
+ self.stderr.clear() # what it wrote before a result is not about a later failure
115
136
  human = message.origin is None or message.origin.get("kind") == "human"
116
- active = self.active if human else None
137
+ # Native Claude can fold an input into a turn that something else started
138
+ # (a peer message, a background task); that turn's result carries the other
139
+ # origin. The turn that follows the input's echo is the one that consumed it.
140
+ # A non-human result before the echo belongs to an earlier turn.
141
+ active = (
142
+ self.active
143
+ if self.active and (human or self.active.get("acknowledged"))
144
+ else None
145
+ )
117
146
  status = "failed" if message.is_error else "completed"
118
147
  if message.terminal_reason in {"aborted_streaming", "aborted_tools"}:
119
148
  status = "interrupted"
@@ -170,6 +199,7 @@ class Worker:
170
199
  permission_mode=policy["permission_mode"],
171
200
  allowed_tools=policy["allowed_tools"],
172
201
  model=policy["model"],
202
+ effort=policy.get("effort"), # absent in the record of an older version
173
203
  setting_sources=["user", "project", "local"],
174
204
  system_prompt={
175
205
  "type": "preset",
@@ -177,7 +207,7 @@ class Worker:
177
207
  "append": policy["instructions"] or "",
178
208
  },
179
209
  can_use_tool=self.permission,
180
- stderr=lambda line: log.debug("claude: %s", line),
210
+ stderr=self.native_stderr,
181
211
  extra_args={"replay-user-messages": None},
182
212
  session_id=self.session_id
183
213
  if not self.execution["resume"] or self.execution["fork"]
@@ -243,6 +273,8 @@ class Worker:
243
273
  if (
244
274
  timeout
245
275
  and self.active is None
276
+ # A turn that native Claude runs for another origin is work too.
277
+ and not self.presentation_started
246
278
  and not self.background_tasks
247
279
  and time.monotonic() - self.last_activity >= timeout
248
280
  and not any(
@@ -256,6 +288,8 @@ class Worker:
256
288
  except Exception as error:
257
289
  log.exception("Claude worker failed")
258
290
  result = {"status": "failed", "error": str(error)}
291
+ if self.stderr:
292
+ result["native_stderr"] = list(self.stderr)
259
293
  finally:
260
294
  self.stopping.set()
261
295
  try:
@@ -0,0 +1,345 @@
1
+ """Exercise the real SDK and detached processes against a deterministic native boundary."""
2
+
3
+ import asyncio
4
+ import json
5
+ import os
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+
11
+ from agent_runtime import ClaudeRuntime, LaunchOptions
12
+
13
+
14
+ @pytest.fixture
15
+ def native(tmp_path: Path) -> Path:
16
+ cli = tmp_path / "claude"
17
+ cli.write_text(
18
+ f"#!{sys.executable}\n"
19
+ + r"""
20
+ import json, os, sys
21
+ if "--version" in sys.argv:
22
+ print("2.1.281 (Claude Code)")
23
+ raise SystemExit(0)
24
+ if os.environ.get("FAKE_CLAUDE_ARGV"):
25
+ with open(os.environ["FAKE_CLAUDE_ARGV"], "w") as log:
26
+ json.dump(sys.argv, log)
27
+ if os.environ.get("FAKE_CLAUDE_FAILS"):
28
+ for number in range(30):
29
+ print(f"line {number} " + "x" * 600, file=sys.stderr, flush=True)
30
+ print("Invalid API key - Please run /login", file=sys.stderr, flush=True)
31
+ raise SystemExit(1)
32
+ print("warning: a line at the start", file=sys.stderr, flush=True)
33
+ def emit(value):
34
+ print(json.dumps(value), flush=True)
35
+ session = next(a.split("=", 1)[1] for a in sys.argv if a.startswith(("--session-id=", "--resume=")))
36
+ PEER = {"kind":"peer","from":"unknown"}
37
+ def result(text, origin={"kind":"human"}):
38
+ emit({"type":"result", "subtype":"success", "is_error":False,
39
+ "duration_ms":1,"duration_api_ms":1,"num_turns":1,"session_id":session,
40
+ "result":text,"origin":origin,"usage":{}})
41
+ pending = None
42
+ for line in sys.stdin:
43
+ message = json.loads(line)
44
+ if message["type"] == "control_request":
45
+ emit({"type":"control_response","response":{"subtype":"success",
46
+ "request_id":message["request_id"],"response":{}}})
47
+ elif message["type"] == "user":
48
+ text = message["message"]["content"]
49
+ with open(os.environ["FAKE_CLAUDE_LOG"], "a") as log:
50
+ log.write(json.dumps({"text": text, "uuid":message["uuid"], "session": session}) + "\n")
51
+ if text == "crash-before-echo":
52
+ raise SystemExit(23)
53
+ if text == "folded-into-peer-turn":
54
+ # Native Claude, observed 2026-11-10: a peer message's turn ends, a second peer
55
+ # message starts a turn, the queued human input joins that turn, and the turn's
56
+ # result carries the peer origin.
57
+ result("first peer turn", PEER)
58
+ emit({"type":"user","uuid":"peer-message","message":{"role":"user","content":"peer"},"origin":PEER})
59
+ emit({"type":"user","uuid":message["uuid"],"message":{"role":"user","content":text},"origin":{"kind":"human"}})
60
+ result("answer to both", PEER)
61
+ continue
62
+ emit({"type":"user","uuid":message["uuid"],"message":{"role":"user","content":text},"origin":{"kind":"human"}})
63
+ if text == "wrong-session":
64
+ # A failure of the worker's own check, late in a session, with no stderr line.
65
+ emit({"type":"result", "subtype":"success", "is_error":False, "duration_ms":1,
66
+ "duration_api_ms":1, "num_turns":1, "session_id":"another-session",
67
+ "result":"x", "origin":{"kind":"human"}, "usage":{}})
68
+ continue
69
+ if text == "then-a-peer-turn":
70
+ # The input's own turn ends; a peer message then starts a turn that works
71
+ # in silence for longer than the idle timeout.
72
+ import time
73
+ result("echo: " + text)
74
+ emit({"type":"user","uuid":"peer-message","message":{"role":"user","content":"peer"},"origin":PEER})
75
+ time.sleep(1.5)
76
+ result("peer turn done", PEER)
77
+ continue
78
+ if text == "permission":
79
+ pending = text
80
+ emit({"type":"control_request","request_id":"permission-1",
81
+ "request":{"subtype":"can_use_tool","tool_name":"Bash",
82
+ "input":{"command":"touch example"},"tool_use_id":"tool-1"}})
83
+ elif text != "hold":
84
+ result("echo: " + text)
85
+ elif message["type"] == "control_response" and pending:
86
+ result(json.dumps(message["response"]["response"]))
87
+ pending = None
88
+ """
89
+ )
90
+ cli.chmod(0o700)
91
+ return cli
92
+
93
+
94
+ async def wait_for(predicate):
95
+ for _ in range(200):
96
+ if value := predicate():
97
+ return value
98
+ await asyncio.sleep(0.05)
99
+ raise AssertionError("condition did not become true")
100
+
101
+
102
+ def options(tmp_path: Path, native: Path) -> LaunchOptions:
103
+ return LaunchOptions(
104
+ cwd=tmp_path,
105
+ claude=native,
106
+ idle_timeout_seconds=0,
107
+ environment={
108
+ "FAKE_CLAUDE_LOG": str(tmp_path / "inputs.jsonl"),
109
+ "PRIVATE_TEST_SECRET": "do-not-persist-this",
110
+ },
111
+ )
112
+
113
+
114
+ async def test_reconnect_duplicate_admission_and_explicit_resume(tmp_path, native):
115
+ root = tmp_path / "runtime"
116
+ runtime = ClaudeRuntime(root)
117
+ opts = options(tmp_path, native)
118
+ first = await runtime.start("hello", message_id="m1", options=opts, launch_id="launch1")
119
+ sid, eid = first["session_id"], first["execution_id"]
120
+ try:
121
+ await wait_for(
122
+ lambda: any(e["event"]["type"] == "work.settled" for e in runtime.store.events(sid))
123
+ )
124
+ # A new client is the entire application-service restart contract.
125
+ runtime = ClaudeRuntime(root)
126
+ assert runtime.inspect(eid)["result"] is None
127
+ assert runtime.store.receipt(sid, "m1")["status"] == "delivered"
128
+ assert (await runtime.start("hello", message_id="m1", options=opts, launch_id="launch1"))[
129
+ "execution_id"
130
+ ] == eid
131
+ with pytest.raises(ValueError, match="different"):
132
+ await runtime.start("changed", message_id="m1", options=opts, launch_id="launch1")
133
+ await runtime.submit(sid, "next", message_id="m2", options=opts, resume=False)
134
+ await wait_for(lambda: runtime.store.receipt(sid, "m2")["status"] == "delivered")
135
+ receipt = await runtime.submit(sid, "next", message_id="m2", options=opts, resume=False)
136
+ assert receipt["status"] == "delivered"
137
+ with pytest.raises(ValueError):
138
+ await runtime.submit(sid, "different", message_id="m2", options=opts, resume=False)
139
+ assert "do-not-persist-this" not in runtime.store.path.read_bytes().decode(errors="ignore")
140
+ assert os.stat(root).st_mode & 0o777 == 0o700
141
+ assert os.stat(runtime.store.path).st_mode & 0o777 == 0o600
142
+ assert (await runtime.stop(eid))["result"]["status"] == "stopped"
143
+ rejected = await runtime.submit(sid, "later", message_id="m3", options=opts, resume=False)
144
+ assert rejected["status"] == "rejected"
145
+ resumed = await runtime.submit(sid, "later", message_id="m3", options=opts, resume=True)
146
+ eid = resumed["execution_id"]
147
+ assert eid != first["execution_id"]
148
+ await wait_for(lambda: runtime.store.receipt(sid, "m3")["status"] == "delivered")
149
+ recorded = [
150
+ json.loads(line) for line in (tmp_path / "inputs.jsonl").read_text().splitlines()
151
+ ]
152
+ assert [r["text"] for r in recorded] == ["hello", "next", "later"]
153
+ assert {r["session"] for r in recorded} == {sid}
154
+ finally:
155
+ await runtime.stop(eid)
156
+
157
+
158
+ async def test_lost_native_ack_is_uncertain_and_never_replayed(tmp_path, native):
159
+ runtime = ClaudeRuntime(tmp_path / "runtime")
160
+ opts = options(tmp_path, native)
161
+ value = await runtime.start(
162
+ "crash-before-echo", message_id="m1", options=opts, launch_id="crash"
163
+ )
164
+ sid, eid = value["session_id"], value["execution_id"]
165
+ try:
166
+ await wait_for(lambda: runtime.inspect(eid)["result"])
167
+ assert runtime.store.receipt(sid, "m1")["status"] == "uncertain"
168
+ saved = await runtime.submit(
169
+ sid, "crash-before-echo", message_id="m1", options=opts, resume=True
170
+ )
171
+ assert saved["status"] == "uncertain"
172
+ assert len((tmp_path / "inputs.jsonl").read_text().splitlines()) == 1
173
+ finally:
174
+ await runtime.stop(eid)
175
+
176
+
177
+ async def test_permission_waits_for_durable_explicit_answer(tmp_path, native):
178
+ runtime = ClaudeRuntime(tmp_path / "runtime")
179
+ value = await runtime.start(
180
+ "permission", message_id="m1", options=options(tmp_path, native), launch_id="permission"
181
+ )
182
+ sid, eid = value["session_id"], value["execution_id"]
183
+ try:
184
+ requests = await wait_for(lambda: runtime.store.interactions(eid))
185
+ assert requests[0]["params"]["tool"] == "Bash"
186
+ assert not any(e["event"]["type"] == "work.settled" for e in runtime.store.events(sid))
187
+ runtime = ClaudeRuntime(tmp_path / "runtime")
188
+ runtime.store.answer(eid, requests[0]["id"], {"decision": "deny"})
189
+ settled = await wait_for(
190
+ lambda: [
191
+ e["event"]
192
+ for e in runtime.store.events(sid)
193
+ if e["event"]["type"] == "work.settled"
194
+ ]
195
+ )
196
+ assert json.loads(settled[0]["message"])["behavior"] == "deny"
197
+ with pytest.raises(ValueError):
198
+ runtime.store.answer(eid, requests[0]["id"], {"decision": "allow"})
199
+ finally:
200
+ await runtime.stop(eid)
201
+
202
+
203
+ async def test_input_folded_into_a_peer_turn_settles_with_that_turn(tmp_path, native):
204
+ runtime = ClaudeRuntime(tmp_path / "runtime")
205
+ opts = options(tmp_path, native)
206
+ value = await runtime.start(
207
+ "folded-into-peer-turn", message_id="m1", options=opts, launch_id="folded"
208
+ )
209
+ sid, eid = value["session_id"], value["execution_id"]
210
+
211
+ def settled():
212
+ return [
213
+ e["event"] for e in runtime.store.events(sid) if e["event"]["type"] == "work.settled"
214
+ ]
215
+
216
+ try:
217
+ await wait_for(lambda: len(settled()) >= 2)
218
+ # The first result came before the input's echo, so it is not the input's result.
219
+ assert [event["message_ids"] for event in settled()] == [[], ["m1"]]
220
+ assert settled()[1]["message"] == "answer to both"
221
+ # The worker is free again: it takes the next input.
222
+ await runtime.submit(sid, "next", message_id="m2", options=opts, resume=False)
223
+ await wait_for(lambda: len(settled()) >= 3)
224
+ assert settled()[2]["message_ids"] == ["m2"]
225
+ finally:
226
+ await runtime.stop(eid)
227
+
228
+
229
+ async def test_a_turn_for_another_origin_is_not_ended_by_the_idle_timeout(tmp_path, native):
230
+ runtime = ClaudeRuntime(tmp_path / "runtime")
231
+ opts = LaunchOptions(
232
+ cwd=tmp_path,
233
+ claude=native,
234
+ idle_timeout_seconds=0.5,
235
+ environment={"FAKE_CLAUDE_LOG": str(tmp_path / "inputs.jsonl")},
236
+ )
237
+ value = await runtime.start("then-a-peer-turn", message_id="m1", options=opts, launch_id="peer")
238
+ sid, eid = value["session_id"], value["execution_id"]
239
+ try:
240
+ await wait_for(lambda: runtime.inspect(eid)["result"])
241
+ events = [e["event"] for e in runtime.store.events(sid)]
242
+ kinds = [
243
+ e["type"]
244
+ for e in events
245
+ if e["type"] in {"turn.started", "work.settled", "worker.exited"}
246
+ ]
247
+ # The worker saw the peer turn start and waited for its result before its idle exit.
248
+ assert kinds == [
249
+ "turn.started",
250
+ "work.settled",
251
+ "turn.started",
252
+ "work.settled",
253
+ "worker.exited",
254
+ ]
255
+ assert events[-1]["reason"] == "idle_timeout"
256
+ settled = [e for e in events if e["type"] == "work.settled"]
257
+ assert [e["message_ids"] for e in settled] == [["m1"], []]
258
+ assert settled[1]["message"] == "peer turn done"
259
+ finally:
260
+ await runtime.stop(eid)
261
+
262
+
263
+ async def test_effort_reaches_native_claude_and_a_link_to_it_is_not_resolved(tmp_path, native):
264
+ link = tmp_path / "shim"
265
+ link.symlink_to(native)
266
+ opts = LaunchOptions(
267
+ cwd=tmp_path,
268
+ claude=link,
269
+ effort="high",
270
+ idle_timeout_seconds=0,
271
+ environment={
272
+ "FAKE_CLAUDE_LOG": str(tmp_path / "inputs.jsonl"),
273
+ "FAKE_CLAUDE_ARGV": str(tmp_path / "argv.json"),
274
+ },
275
+ )
276
+ assert opts.configuration()["claude"] == str(link)
277
+ with pytest.raises(ValueError, match="effort"):
278
+ LaunchOptions(cwd=tmp_path, claude=native, effort="extreme").configuration()
279
+ with pytest.raises(ValueError, match="not found"):
280
+ LaunchOptions(cwd=tmp_path, claude=tmp_path / "missing").configuration()
281
+ runtime = ClaudeRuntime(tmp_path / "runtime")
282
+ value = await runtime.start("hello", message_id="m1", options=opts, launch_id="effort")
283
+ try:
284
+ await wait_for(
285
+ lambda: runtime.store.receipt(value["session_id"], "m1")["status"] == "delivered"
286
+ )
287
+ argv = json.loads((tmp_path / "argv.json").read_text())
288
+ assert argv[0] == str(link) and argv[argv.index("--effort") + 1] == "high"
289
+ finally:
290
+ await runtime.stop(value["execution_id"])
291
+
292
+
293
+ async def test_a_native_claude_that_fails_at_its_start_reports_what_it_wrote(tmp_path, native):
294
+ runtime = ClaudeRuntime(tmp_path / "runtime")
295
+ opts = options(tmp_path, native)
296
+ opts.environment["FAKE_CLAUDE_FAILS"] = "1"
297
+ value = await runtime.start("hello", message_id="m1", options=opts, launch_id="fails")
298
+ result = await wait_for(lambda: runtime.inspect(value["execution_id"])["result"])
299
+ assert result["status"] == "failed" and "Invalid API key" not in result["error"]
300
+ # The last 20 lines, each cut to 500 characters, apart from the error.
301
+ lines = result["native_stderr"]
302
+ assert len(lines) == 20 and lines[-1] == "Invalid API key - Please run /login"
303
+ assert all(len(line) <= 500 for line in lines) and lines[0].startswith("line 11 ")
304
+ assert runtime.store.receipt(value["session_id"], "m1")["status"] == "rejected"
305
+
306
+
307
+ async def test_stderr_from_before_a_result_is_not_reported_with_a_later_failure(tmp_path, native):
308
+ runtime = ClaudeRuntime(tmp_path / "runtime")
309
+ opts = options(tmp_path, native)
310
+ value = await runtime.start("hello", message_id="m1", options=opts, launch_id="late")
311
+ sid, eid = value["session_id"], value["execution_id"]
312
+ await wait_for(lambda: runtime.store.receipt(sid, "m1")["status"] == "delivered")
313
+ await runtime.submit(sid, "wrong-session", message_id="m2", options=opts, resume=False)
314
+ result = await wait_for(lambda: runtime.inspect(eid)["result"])
315
+ assert result["status"] == "failed" and "different session ID" in result["error"]
316
+ assert "native_stderr" not in result # the warning at the start came before the first result
317
+
318
+
319
+ async def test_a_launch_id_from_an_older_version_can_be_repeated(tmp_path, native):
320
+ """0.2.1 stored no effort and the link target of the executable; a repeat of that launch
321
+ by this version must find the same execution."""
322
+ link = tmp_path / "shim"
323
+ link.symlink_to(native)
324
+ runtime = ClaudeRuntime(tmp_path / "runtime")
325
+ opts = LaunchOptions(
326
+ cwd=tmp_path,
327
+ claude=link,
328
+ idle_timeout_seconds=0,
329
+ environment={"FAKE_CLAUDE_LOG": str(tmp_path / "inputs.jsonl")},
330
+ )
331
+ assert "effort" not in opts.configuration()
332
+ first = await runtime.start("hello", message_id="m1", options=opts, launch_id="old")
333
+ eid = first["execution_id"]
334
+ try:
335
+ stored = dict(runtime.store.execution(eid)["configuration"], claude=str(native.resolve()))
336
+ runtime.store.update(eid, configuration=stored)
337
+ again = await runtime.start("hello", message_id="m1", options=opts, launch_id="old")
338
+ assert again["execution_id"] == eid
339
+ with pytest.raises(ValueError, match="configuration"):
340
+ changed = LaunchOptions(
341
+ cwd=tmp_path, claude=link, effort="high", idle_timeout_seconds=0
342
+ )
343
+ await runtime.start("hello", message_id="m1", options=changed, launch_id="old")
344
+ finally:
345
+ await runtime.stop(eid)
@@ -1,15 +1,15 @@
1
1
  version = 1
2
- revision = 3
2
+ revision = 5
3
3
  requires-python = ">=3.11"
4
4
  resolution-markers = [
5
5
  "python_full_version >= '3.14'",
6
6
  "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'",
7
- "(python_full_version < '3.14' and sys_platform != 'emscripten') or (python_full_version < '3.12' and sys_platform == 'emscripten')",
7
+ "python_full_version < '3.12' or (python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten')",
8
8
  ]
9
9
 
10
10
  [[package]]
11
11
  name = "agent-runtime-py"
12
- version = "0.2.0"
12
+ version = "0.2.2"
13
13
  source = { editable = "." }
14
14
  dependencies = [
15
15
  { name = "filelock" },
@@ -1,8 +0,0 @@
1
- # Changelog
2
-
3
- ## 0.2.0
4
-
5
- - Breaking: requires typed-agent-hooks 0.3. Run the hooks with `agent_runtime.hooks.app.main()` and the host name (`codex` or `claude`) as the only argument; the host hook file sets the timeout. The `agent-runtime-hooks` console script is removed.
6
- - Breaking: the Claude Agent SDK moves to the `claude` extra. `ClaudeRuntime` needs `agent-runtime-py[claude]`.
7
- - Fix: a monitor worker whose command exceeded the output limit never exited and ignored SIGTERM.
8
- - Fix: a Claude SessionStart, which carries no permission mode, kept the mode empty; it now keeps the mode that the same live Claude process reported.
@@ -1,166 +0,0 @@
1
- """Exercise the real SDK and detached processes against a deterministic native boundary."""
2
-
3
- import asyncio
4
- import json
5
- import os
6
- import sys
7
- from pathlib import Path
8
-
9
- import pytest
10
-
11
- from agent_runtime import ClaudeRuntime, LaunchOptions
12
-
13
-
14
- @pytest.fixture
15
- def native(tmp_path: Path) -> Path:
16
- cli = tmp_path / "claude"
17
- cli.write_text(
18
- f"#!{sys.executable}\n"
19
- + r"""
20
- import json, os, sys
21
- if "--version" in sys.argv:
22
- print("2.1.281 (Claude Code)")
23
- raise SystemExit(0)
24
- def emit(value):
25
- print(json.dumps(value), flush=True)
26
- session = next(a.split("=", 1)[1] for a in sys.argv if a.startswith(("--session-id=", "--resume=")))
27
- def result(text):
28
- emit({"type":"result", "subtype":"success", "is_error":False,
29
- "duration_ms":1,"duration_api_ms":1,"num_turns":1,"session_id":session,
30
- "result":text,"origin":{"kind":"human"},"usage":{}})
31
- pending = None
32
- for line in sys.stdin:
33
- message = json.loads(line)
34
- if message["type"] == "control_request":
35
- emit({"type":"control_response","response":{"subtype":"success",
36
- "request_id":message["request_id"],"response":{}}})
37
- elif message["type"] == "user":
38
- text = message["message"]["content"]
39
- with open(os.environ["FAKE_CLAUDE_LOG"], "a") as log:
40
- log.write(json.dumps({"text": text, "uuid":message["uuid"], "session": session}) + "\n")
41
- if text == "crash-before-echo":
42
- raise SystemExit(23)
43
- emit({"type":"user","uuid":message["uuid"],"message":{"role":"user","content":text},"origin":{"kind":"human"}})
44
- if text == "permission":
45
- pending = text
46
- emit({"type":"control_request","request_id":"permission-1",
47
- "request":{"subtype":"can_use_tool","tool_name":"Bash",
48
- "input":{"command":"touch example"},"tool_use_id":"tool-1"}})
49
- elif text != "hold":
50
- result("echo: " + text)
51
- elif message["type"] == "control_response" and pending:
52
- result(json.dumps(message["response"]["response"]))
53
- pending = None
54
- """
55
- )
56
- cli.chmod(0o700)
57
- return cli
58
-
59
-
60
- async def wait_for(predicate):
61
- for _ in range(200):
62
- if value := predicate():
63
- return value
64
- await asyncio.sleep(0.05)
65
- raise AssertionError("condition did not become true")
66
-
67
-
68
- def options(tmp_path: Path, native: Path) -> LaunchOptions:
69
- return LaunchOptions(
70
- cwd=tmp_path,
71
- claude=native,
72
- idle_timeout_seconds=0,
73
- environment={
74
- "FAKE_CLAUDE_LOG": str(tmp_path / "inputs.jsonl"),
75
- "PRIVATE_TEST_SECRET": "do-not-persist-this",
76
- },
77
- )
78
-
79
-
80
- async def test_reconnect_duplicate_admission_and_explicit_resume(tmp_path, native):
81
- root = tmp_path / "runtime"
82
- runtime = ClaudeRuntime(root)
83
- opts = options(tmp_path, native)
84
- first = await runtime.start("hello", message_id="m1", options=opts, launch_id="launch1")
85
- sid, eid = first["session_id"], first["execution_id"]
86
- try:
87
- await wait_for(
88
- lambda: any(e["event"]["type"] == "work.settled" for e in runtime.store.events(sid))
89
- )
90
- # A new client is the entire application-service restart contract.
91
- runtime = ClaudeRuntime(root)
92
- assert runtime.inspect(eid)["result"] is None
93
- assert runtime.store.receipt(sid, "m1")["status"] == "delivered"
94
- assert (await runtime.start("hello", message_id="m1", options=opts, launch_id="launch1"))[
95
- "execution_id"
96
- ] == eid
97
- with pytest.raises(ValueError, match="different"):
98
- await runtime.start("changed", message_id="m1", options=opts, launch_id="launch1")
99
- await runtime.submit(sid, "next", message_id="m2", options=opts, resume=False)
100
- await wait_for(lambda: runtime.store.receipt(sid, "m2")["status"] == "delivered")
101
- receipt = await runtime.submit(sid, "next", message_id="m2", options=opts, resume=False)
102
- assert receipt["status"] == "delivered"
103
- with pytest.raises(ValueError):
104
- await runtime.submit(sid, "different", message_id="m2", options=opts, resume=False)
105
- assert "do-not-persist-this" not in runtime.store.path.read_bytes().decode(errors="ignore")
106
- assert os.stat(root).st_mode & 0o777 == 0o700
107
- assert os.stat(runtime.store.path).st_mode & 0o777 == 0o600
108
- assert (await runtime.stop(eid))["result"]["status"] == "stopped"
109
- rejected = await runtime.submit(sid, "later", message_id="m3", options=opts, resume=False)
110
- assert rejected["status"] == "rejected"
111
- resumed = await runtime.submit(sid, "later", message_id="m3", options=opts, resume=True)
112
- eid = resumed["execution_id"]
113
- assert eid != first["execution_id"]
114
- await wait_for(lambda: runtime.store.receipt(sid, "m3")["status"] == "delivered")
115
- recorded = [
116
- json.loads(line) for line in (tmp_path / "inputs.jsonl").read_text().splitlines()
117
- ]
118
- assert [r["text"] for r in recorded] == ["hello", "next", "later"]
119
- assert {r["session"] for r in recorded} == {sid}
120
- finally:
121
- await runtime.stop(eid)
122
-
123
-
124
- async def test_lost_native_ack_is_uncertain_and_never_replayed(tmp_path, native):
125
- runtime = ClaudeRuntime(tmp_path / "runtime")
126
- opts = options(tmp_path, native)
127
- value = await runtime.start(
128
- "crash-before-echo", message_id="m1", options=opts, launch_id="crash"
129
- )
130
- sid, eid = value["session_id"], value["execution_id"]
131
- try:
132
- await wait_for(lambda: runtime.inspect(eid)["result"])
133
- assert runtime.store.receipt(sid, "m1")["status"] == "uncertain"
134
- saved = await runtime.submit(
135
- sid, "crash-before-echo", message_id="m1", options=opts, resume=True
136
- )
137
- assert saved["status"] == "uncertain"
138
- assert len((tmp_path / "inputs.jsonl").read_text().splitlines()) == 1
139
- finally:
140
- await runtime.stop(eid)
141
-
142
-
143
- async def test_permission_waits_for_durable_explicit_answer(tmp_path, native):
144
- runtime = ClaudeRuntime(tmp_path / "runtime")
145
- value = await runtime.start(
146
- "permission", message_id="m1", options=options(tmp_path, native), launch_id="permission"
147
- )
148
- sid, eid = value["session_id"], value["execution_id"]
149
- try:
150
- requests = await wait_for(lambda: runtime.store.interactions(eid))
151
- assert requests[0]["params"]["tool"] == "Bash"
152
- assert not any(e["event"]["type"] == "work.settled" for e in runtime.store.events(sid))
153
- runtime = ClaudeRuntime(tmp_path / "runtime")
154
- runtime.store.answer(eid, requests[0]["id"], {"decision": "deny"})
155
- settled = await wait_for(
156
- lambda: [
157
- e["event"]
158
- for e in runtime.store.events(sid)
159
- if e["event"]["type"] == "work.settled"
160
- ]
161
- )
162
- assert json.loads(settled[0]["message"])["behavior"] == "deny"
163
- with pytest.raises(ValueError):
164
- runtime.store.answer(eid, requests[0]["id"], {"decision": "allow"})
165
- finally:
166
- await runtime.stop(eid)