harness-sdk-python 0.36.1__tar.gz → 0.36.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 (47) hide show
  1. {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.2}/PKG-INFO +3 -3
  2. {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.2}/pyproject.toml +2 -2
  3. {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.2}/src/harness_sdk/run_manager.py +54 -27
  4. {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.2}/tests/test_meta.py +5 -3
  5. harness_sdk_python-0.36.2/tests/test_run_eviction.py +265 -0
  6. {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.2}/.gitignore +0 -0
  7. {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.2}/README.md +0 -0
  8. {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.2}/examples/__init__.py +0 -0
  9. {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.2}/examples/runs_app.py +0 -0
  10. {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.2}/src/harness_sdk/__init__.py +0 -0
  11. {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.2}/src/harness_sdk/checkpoints.py +0 -0
  12. {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.2}/src/harness_sdk/deepagents.py +0 -0
  13. {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.2}/src/harness_sdk/fenced_postgres.py +0 -0
  14. {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.2}/src/harness_sdk/linear_thread.py +0 -0
  15. {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.2}/src/harness_sdk/protocol.py +0 -0
  16. {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.2}/src/harness_sdk/voice.py +0 -0
  17. {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.2}/tests/run_helpers.py +0 -0
  18. {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.2}/tests/test_admit_steer.py +0 -0
  19. {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.2}/tests/test_applied_visibility.py +0 -0
  20. {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.2}/tests/test_batches.py +0 -0
  21. {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.2}/tests/test_branch_anchor.py +0 -0
  22. {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.2}/tests/test_checkpoints.py +0 -0
  23. {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.2}/tests/test_deepagents_messages.py +0 -0
  24. {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.2}/tests/test_dispatching.py +0 -0
  25. {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.2}/tests/test_durability.py +0 -0
  26. {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.2}/tests/test_edit_dispatched.py +0 -0
  27. {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.2}/tests/test_edit_reload.py +0 -0
  28. {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.2}/tests/test_enqueue.py +0 -0
  29. {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.2}/tests/test_fenced_postgres.py +0 -0
  30. {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.2}/tests/test_input_required.py +0 -0
  31. {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.2}/tests/test_linear_thread.py +0 -0
  32. {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.2}/tests/test_mint_concurrency.py +0 -0
  33. {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.2}/tests/test_outcomes.py +0 -0
  34. {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.2}/tests/test_placement.py +0 -0
  35. {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.2}/tests/test_prepare_hooks.py +0 -0
  36. {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.2}/tests/test_restore.py +0 -0
  37. {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.2}/tests/test_rewind_during_run.py +0 -0
  38. {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.2}/tests/test_run_crash_sweep.py +0 -0
  39. {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.2}/tests/test_run_ownership.py +0 -0
  40. {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.2}/tests/test_settle.py +0 -0
  41. {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.2}/tests/test_steer.py +0 -0
  42. {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.2}/tests/test_stop_continue.py +0 -0
  43. {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.2}/tests/test_stopping_window.py +0 -0
  44. {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.2}/tests/test_voice.py +0 -0
  45. {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.2}/tests/test_voice_delegation.py +0 -0
  46. {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.2}/tests/test_voice_frontend_tools.py +0 -0
  47. {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.2}/tests/voice_helpers.py +0 -0
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: harness-sdk-python
3
- Version: 0.36.1
3
+ Version: 0.36.2
4
4
  Summary: RunManager: the harness-sdk runs subsystem for Python Statewire hosts
5
5
  Project-URL: Repository, https://github.com/assistant-ui/harness-sdk
6
6
  License-Expression: MIT
7
7
  Requires-Python: <4.0,>=3.12
8
- Requires-Dist: pinned<0.16,>=0.15.0
9
- Requires-Dist: statewire<0.18,>=0.17.0
8
+ Requires-Dist: pinned<0.16,>=0.15.1
9
+ Requires-Dist: statewire<0.18,>=0.17.2
10
10
  Provides-Extra: deepagents
11
11
  Requires-Dist: deepagents>=0.6.12; extra == 'deepagents'
12
12
  Requires-Dist: langchain-core>=0.3; extra == 'deepagents'
@@ -1,11 +1,11 @@
1
1
  [project]
2
2
  name = "harness-sdk-python"
3
- version = "0.36.1"
3
+ version = "0.36.2"
4
4
  description = "RunManager: the harness-sdk runs subsystem for Python Statewire hosts"
5
5
  readme = "README.md"
6
6
  license = "MIT"
7
7
  requires-python = ">=3.12,<4.0"
8
- dependencies = ["statewire>=0.17.0,<0.18", "pinned>=0.15.0,<0.16"]
8
+ dependencies = ["statewire>=0.17.2,<0.18", "pinned>=0.15.1,<0.16"]
9
9
 
10
10
  [project.optional-dependencies]
11
11
  postgres = ["langgraph-checkpoint-postgres>=2.0.0"]
@@ -67,6 +67,7 @@ import logging
67
67
  from dataclasses import dataclass, field
68
68
  from typing import Any, AsyncIterator, Callable, Coroutine, Iterable, Protocol
69
69
 
70
+ from pinned import EvictionRequested
70
71
  from pinned.trays import Tray, TrayDescriptor, TrayCtx, TrayMapNamespace, inbox
71
72
  from pinned.trays import persistent, persistent_outbox, persistent_queue
72
73
  from statepatch import observable
@@ -845,6 +846,12 @@ class RunManager(Tray):
845
846
 
846
847
  def _make_run_ctx(self, item: Any) -> "RunManager.RunContext":
847
848
  record = item["record"]
849
+ root_meta = record.get("rootMeta")
850
+ if "rootMeta" not in record:
851
+ if record["messages"]:
852
+ root_meta = record["messages"][0].get("meta")
853
+ elif record.get("inputOutcomes"):
854
+ root_meta = record["inputOutcomes"][0]["meta"]
848
855
  messages = [
849
856
  {k: v for k, v in message.items() if k not in ("meta", "~admit")}
850
857
  for message in record["messages"]
@@ -857,7 +864,7 @@ class RunManager(Tray):
857
864
  _manager=self,
858
865
  _rollback_to=record["rollbackTo"] if "rollbackTo" in record else _ABSENT,
859
866
  _message_meta={m["id"]: m.get("meta") for m in record["messages"]},
860
- _root_meta=record.get("rootMeta"),
867
+ _root_meta=root_meta,
861
868
  _input_outcomes=tuple(
862
869
  (o["request"], o["response"], o["meta"])
863
870
  for o in record.get("inputOutcomes", ())
@@ -877,6 +884,8 @@ class RunManager(Tray):
877
884
  self._sync_steering()
878
885
  try:
879
886
  await task
887
+ except EvictionRequested:
888
+ raise
880
889
  except asyncio.CancelledError:
881
890
  current = asyncio.current_task()
882
891
  if current is not None and current.cancelling():
@@ -886,6 +895,8 @@ class RunManager(Tray):
886
895
  # user cancel: _execute already converged on the settle discipline
887
896
  finally:
888
897
  self._active = None
898
+ if self._run_live is ctx:
899
+ self._run_live = None
889
900
 
890
901
  async def _execute(self, ctx: "RunManager.RunContext", fn: Any) -> None:
891
902
  try:
@@ -905,34 +916,25 @@ class RunManager(Tray):
905
916
  )
906
917
  if isinstance(outcome, RunManager.Complete) and self.dispatching.value is not None:
907
918
  raise RuntimeError("run returned Complete with an unconfirmed batch")
919
+ except EvictionRequested:
920
+ self._advance()
921
+ if self._stop_staged or ctx._tray_ctx.consumed:
922
+ self._cancel_run(ctx)
923
+ return
924
+ raise
908
925
  except asyncio.CancelledError:
909
- if self._closing or self._retirement is not None:
910
- raise # teardown: the un-acked outbox redelivers next activation
911
- self.outcome.value = {
912
- "kind": "cancel",
913
- "epoch": self.epoch.value,
914
- "dispatchQueue": False,
915
- }
916
- self._settle(ctx)
917
- fx = self._apply_intake()
918
- rewind = self._staged_rewind
919
- if rewind is not None and not rewind.applied_sent:
920
- # park the in-drain rewind durably; a cancel starts no dispatch
921
- self.nextDispatch.value = self._record_of(
922
- rewind.type,
923
- list(rewind.messages),
924
- rollback_to=rewind.rollback_to,
925
- root_meta=rewind.root_meta,
926
+ if (
927
+ self._closing
928
+ or self._retirement is not None
929
+ or (
930
+ self._evicting()
931
+ and not self._stop_staged
932
+ and not ctx._tray_ctx.consumed
926
933
  )
927
- rewind.applied()
928
- rewind.applied_sent = True
929
- self.halted.value = "stop"
930
- self.outcome.value = None
931
- self._resolve_stop_waiters()
932
- self._publish_status()
933
- self._settle_applied(fx)
934
- self._idle.set()
935
- raise # staged intake settled; a cancelled run starts no dispatch
934
+ ):
935
+ raise # teardown: the un-acked outbox redelivers next activation
936
+ self._cancel_run(ctx)
937
+ raise
936
938
  except Exception as exc:
937
939
  if not isinstance(exc, StatewireReject):
938
940
  logger.exception(
@@ -957,6 +959,31 @@ class RunManager(Tray):
957
959
  self._outcome = self.outcome.value
958
960
  self._advance()
959
961
 
962
+ def _cancel_run(self, ctx: "RunManager.RunContext") -> None:
963
+ self.outcome.value = {
964
+ "kind": "cancel",
965
+ "epoch": self.epoch.value,
966
+ "dispatchQueue": False,
967
+ }
968
+ self._settle(ctx)
969
+ fx = self._apply_intake()
970
+ rewind = self._staged_rewind
971
+ if rewind is not None and not rewind.applied_sent:
972
+ self.nextDispatch.value = self._record_of(
973
+ rewind.type,
974
+ list(rewind.messages),
975
+ rollback_to=rewind.rollback_to,
976
+ root_meta=rewind.root_meta,
977
+ )
978
+ rewind.applied()
979
+ rewind.applied_sent = True
980
+ self.halted.value = "stop"
981
+ self.outcome.value = None
982
+ self._resolve_stop_waiters()
983
+ self._publish_status()
984
+ self._settle_applied(fx)
985
+ self._idle.set()
986
+
960
987
  def _maybe_self_delete(self) -> None:
961
988
  """A settled machine with no durable obligations deletes its tray; the
962
989
  next send recreates it fresh."""
@@ -1,7 +1,7 @@
1
1
  """Contract: per-entry opaque meta. Message-carrying ops stamp it on the lane
2
2
  entry (client-visible, dies with the entry); dispatch delivers it through the
3
3
  start context — ``ctx.meta(id)`` per message, ``ctx.meta()`` for the op that
4
- started a reload/continue run, and the ``(request, response, meta)`` triple
4
+ started the run, and the ``(request, response, meta)`` triple
5
5
  for input."""
6
6
 
7
7
  import pytest
@@ -36,7 +36,7 @@ async def test_immediate_dispatch_delivers_message_meta():
36
36
  call = await script.next_call()
37
37
  assert call.ctx.meta("m1") == {"clientId": "c1"}
38
38
  assert "meta" not in call.ctx.messages[0]
39
- assert call.ctx.meta() is None
39
+ assert call.ctx.meta() == {"clientId": "c1"}
40
40
  call.applied()
41
41
  call.finish(RunManager.Complete())
42
42
 
@@ -55,6 +55,7 @@ async def test_queue_entry_projects_meta():
55
55
  call.finish(RunManager.Complete())
56
56
  dispatched = await script.next_call()
57
57
  assert dispatched.ctx.meta("m2") == {"tag": 7}
58
+ assert dispatched.ctx.meta() == {"tag": 7}
58
59
  assert "meta" not in dispatched.ctx.messages[0]
59
60
  dispatched.applied()
60
61
  dispatched.finish(RunManager.Complete())
@@ -140,7 +141,7 @@ async def test_edit_meta_rides_the_replacement_message():
140
141
  assert call.ctx.trigger == "message-edit"
141
142
  assert call.ctx.meta("u2") == {"edited": True}
142
143
  assert "meta" not in call.ctx.messages[0]
143
- assert call.ctx.meta() is None
144
+ assert call.ctx.meta() == {"edited": True}
144
145
  call.applied()
145
146
  call.finish(RunManager.Complete())
146
147
 
@@ -189,5 +190,6 @@ async def test_input_meta_rides_the_response_triple():
189
190
  resumed = await script.next_call()
190
191
  assert resumed.ctx.trigger == "input-resume"
191
192
  assert resumed.ctx.input_outcomes == ((request, {"output": "ok"}, {"src": "c1"}),)
193
+ assert resumed.ctx.meta() == {"src": "c1"}
192
194
  resumed.applied()
193
195
  resumed.finish(RunManager.Complete())
@@ -0,0 +1,265 @@
1
+ import asyncio
2
+ import contextlib
3
+
4
+ import pytest
5
+ from pinned import EvictionRequested
6
+ from pinned.testing import MemoryStateStore, instance
7
+ from run_helpers import Script, _statement, add, make_host, run_host
8
+ from statewire_helpers import post_frame
9
+
10
+ from harness_sdk import RunManager
11
+
12
+
13
+ async def enqueue(host, message_id, run_id):
14
+ return await asyncio.wait_for(
15
+ host.runs.enqueue(
16
+ add(message_id, run_id=run_id), applied=lambda: None, auth_result=None
17
+ ),
18
+ 2,
19
+ )
20
+
21
+
22
+ async def yield_run(call):
23
+ tray = call.ctx._manager
24
+ call.outcome.set_exception(EvictionRequested())
25
+ with contextlib.suppress(asyncio.CancelledError):
26
+ await asyncio.wait_for(asyncio.shield(tray._pump), 2)
27
+ await tray.storage._frontier.wait()
28
+
29
+
30
+ @pytest.mark.parametrize("confirmed", [False, True])
31
+ async def test_eviction_preserves_dispatch_and_admitted_queue_for_activation(confirmed):
32
+ store = MemoryStateStore()
33
+ script = Script()
34
+ async with instance(make_host(script), state_store=store) as host:
35
+ await enqueue(host, "first", "run")
36
+ call = await script.next_call()
37
+ call.ctx.set_state({"step": 3})
38
+ if confirmed:
39
+ call.applied()
40
+ tray = call.ctx._manager
41
+ tray._evicting = lambda: True
42
+ await enqueue(host, "queued", "run")
43
+ await yield_run(call)
44
+ await enqueue(host, "after-boundary", "run")
45
+ assert tray.status.value == "running"
46
+ assert tray.error.value is None
47
+ assert tray.halted.value is None
48
+ assert tray.outbox.value is not None
49
+ assert tray._run_live is None
50
+ script.no_call()
51
+
52
+ resumed = Script()
53
+ async with instance(make_host(resumed), state_store=store):
54
+ call = await resumed.next_call()
55
+ assert call.ctx.resume is confirmed
56
+ assert call.ctx.state == {"step": 3}
57
+ assert call.ctx.attempts == 1
58
+ if not confirmed:
59
+ assert [message["id"] for message in call.ctx.messages] == ["first"]
60
+ call.applied()
61
+ call.finish(RunManager.Complete())
62
+ for message_id in ["queued", "after-boundary"]:
63
+ queued = await resumed.next_call()
64
+ assert not queued.ctx.resume
65
+ assert [message["id"] for message in queued.ctx.messages] == [message_id]
66
+ queued.applied()
67
+ queued.finish(RunManager.Complete())
68
+ await asyncio.wait_for(queued.ctx._manager.idle.wait(), 2)
69
+ resumed.no_call()
70
+
71
+
72
+ async def test_repeated_evictions_do_not_exhaust_delivery_attempts():
73
+ store = MemoryStateStore()
74
+ for activation in range(RunManager.max_attempts + 2):
75
+ script = Script()
76
+ async with instance(make_host(script), state_store=store) as host:
77
+ if activation == 0:
78
+ await enqueue(host, "first", "run")
79
+ call = await script.next_call()
80
+ assert call.ctx.attempts == 1
81
+ assert call.ctx.resume is (activation > 0)
82
+ if activation == 0:
83
+ call.applied()
84
+ call.ctx.set_state({"step": activation})
85
+ await yield_run(call)
86
+ assert not call.ctx._manager.parked
87
+ script = Script()
88
+ async with instance(make_host(script), state_store=store):
89
+ call = await script.next_call()
90
+ assert call.ctx.state == {"step": RunManager.max_attempts + 1}
91
+ call.finish(RunManager.Complete())
92
+ await asyncio.wait_for(call.ctx._manager.idle.wait(), 2)
93
+
94
+
95
+ async def test_applied_send_retains_run_start_meta_after_eviction():
96
+ store = MemoryStateStore()
97
+ meta = {"user_id": "author", "runConfig": {"model": "test"}}
98
+ script = Script()
99
+ async with instance(make_host(script), state_store=store) as host:
100
+ await host.runs.enqueue(
101
+ add("first", run_id="run"),
102
+ meta=meta,
103
+ applied=lambda: None,
104
+ auth_result=None,
105
+ )
106
+ call = await script.next_call()
107
+ assert call.ctx.meta() == meta
108
+ call.applied()
109
+ await yield_run(call)
110
+
111
+ resumed = Script()
112
+ async with instance(make_host(resumed), state_store=store):
113
+ call = await resumed.next_call()
114
+ assert call.ctx.resume
115
+ with pytest.raises(RuntimeError, match="resume"):
116
+ _ = call.ctx.messages
117
+ assert call.ctx.meta() == meta
118
+ assert call.ctx.meta("first") == meta
119
+ call.finish(RunManager.Complete())
120
+ await asyncio.wait_for(call.ctx._manager.idle.wait(), 2)
121
+
122
+
123
+ async def test_completion_during_wind_down_records_next_dispatch_without_starting_it():
124
+ store = MemoryStateStore()
125
+ script = Script()
126
+ async with instance(make_host(script), state_store=store) as host:
127
+ await enqueue(host, "first", "run")
128
+ call = await script.next_call()
129
+ call.applied()
130
+ await enqueue(host, "queued", "run")
131
+ tray = call.ctx._manager
132
+ tray._evicting = lambda: True
133
+ call.finish(RunManager.Complete())
134
+ with contextlib.suppress(asyncio.CancelledError):
135
+ await asyncio.wait_for(asyncio.shield(tray._pump), 2)
136
+ await host.frontier.wait()
137
+ assert tray.outbox.value["item"]["record"]["messages"][0]["id"] == "queued"
138
+ script.no_call()
139
+
140
+ resumed = Script()
141
+ async with instance(make_host(resumed), state_store=store):
142
+ call = await resumed.next_call()
143
+ assert not call.ctx.resume
144
+ assert [message["id"] for message in call.ctx.messages] == ["queued"]
145
+ call.applied()
146
+ call.finish(RunManager.Complete())
147
+ await asyncio.wait_for(call.ctx._manager.idle.wait(), 2)
148
+ resumed.no_call()
149
+
150
+
151
+ async def test_command_admitted_over_wire_during_wind_down_runs_after_restart():
152
+ store = MemoryStateStore()
153
+ script = Script()
154
+ async with run_host(script, store=store) as (driver, host):
155
+ result = await driver.command("run/enqueue", add("first", run_id="run"))
156
+ assert result["type"] == "accepted"
157
+ call = await script.next_call()
158
+ call.ctx.set_state({"step": 3})
159
+ call.applied()
160
+ host.live.lease.wind_down()
161
+ response = await post_frame(
162
+ driver._client,
163
+ {"cmd": [_statement("run/enqueue", add("queued", run_id="run"), 2)]},
164
+ client_id=driver.client_id,
165
+ )
166
+ assert response.status_code == 200
167
+ await host.live.frontier.wait()
168
+ await yield_run(call)
169
+ await asyncio.wait_for(driver._app.state.pinned_host.directory.wait_empty(), 2)
170
+ script.no_call()
171
+
172
+ resumed = Script()
173
+ async with run_host(resumed, store=store):
174
+ call = await resumed.next_call()
175
+ assert call.ctx.resume
176
+ assert call.ctx.state == {"step": 3}
177
+ call.finish(RunManager.Complete())
178
+ queued = await resumed.next_call()
179
+ assert [message["id"] for message in queued.ctx.messages] == ["queued"]
180
+ queued.applied()
181
+ queued.finish(RunManager.Complete())
182
+ await asyncio.wait_for(queued.ctx._manager.idle.wait(), 2)
183
+ resumed.no_call()
184
+
185
+
186
+ async def test_user_stop_at_eviction_boundary_settles_without_resuming():
187
+ store = MemoryStateStore()
188
+ script = Script()
189
+ async with instance(make_host(script), state_store=store) as host:
190
+ await enqueue(host, "first", "run")
191
+ call = await script.next_call()
192
+ call.applied()
193
+ await enqueue(host, "queued", "run")
194
+ stop = asyncio.create_task(
195
+ host.runs.stop({"runId": "run"}, applied=lambda: None, auth_result=None)
196
+ )
197
+ await asyncio.wait_for(call.ctx.stop_requested.wait(), 2)
198
+ await yield_run(call)
199
+ await asyncio.wait_for(stop, 2)
200
+ assert call.ctx._manager.status.value == "stopped"
201
+ script.no_call()
202
+
203
+ resumed = Script()
204
+ async with instance(make_host(resumed), state_store=store) as host:
205
+ await host.frontier.wait()
206
+ tray = host.runs.live()
207
+ assert tray is not None
208
+ assert tray.status.value == "stopped"
209
+ assert [message["id"] for message in tray.queue.value] == ["queued"]
210
+ resumed.no_call()
211
+
212
+
213
+ async def test_forced_eviction_deadline_preserves_confirmed_run_and_queue():
214
+ store = MemoryStateStore()
215
+ script = Script()
216
+ async with run_host(script, store=store) as (driver, host):
217
+ await driver.command("run/enqueue", add("first", run_id="run"))
218
+ call = await script.next_call()
219
+ call.ctx.set_state({"step": 3})
220
+ call.applied()
221
+ await driver.command("run/enqueue", add("queued", run_id="run"))
222
+ host.live.lease.wind_down(0)
223
+ with contextlib.suppress(asyncio.CancelledError):
224
+ await asyncio.wait_for(asyncio.shield(call.ctx._manager._pump), 2)
225
+ assert call.outcome.cancelled()
226
+ assert call.ctx._manager.outbox.value is not None
227
+ assert call.ctx._manager.halted.value is None
228
+ await asyncio.wait_for(driver._app.state.pinned_host.directory.wait_empty(), 2)
229
+ script.no_call()
230
+
231
+ resumed = Script()
232
+ async with run_host(resumed, store=store):
233
+ call = await resumed.next_call()
234
+ assert call.ctx.resume
235
+ assert call.ctx.state == {"step": 3}
236
+ call.finish(RunManager.Complete())
237
+ queued = await resumed.next_call()
238
+ assert [message["id"] for message in queued.ctx.messages] == ["queued"]
239
+ queued.applied()
240
+ queued.finish(RunManager.Complete())
241
+ await asyncio.wait_for(queued.ctx._manager.idle.wait(), 2)
242
+ resumed.no_call()
243
+
244
+
245
+ async def test_pending_approval_survives_eviction_without_execution():
246
+ store = MemoryStateStore()
247
+ script = Script()
248
+ request = {"id": "approval", "type": "tool-approval", "toolCallId": "tool"}
249
+ async with instance(make_host(script), state_store=store) as host:
250
+ await enqueue(host, "first", "run")
251
+ call = await script.next_call()
252
+ call.applied()
253
+ tray = call.ctx._manager
254
+ tray._evicting = lambda: True
255
+ call.finish(RunManager.InputRequired((request,)))
256
+ await asyncio.wait_for(tray.idle.wait(), 2)
257
+ await host.frontier.wait()
258
+ script.no_call()
259
+
260
+ resumed = Script()
261
+ async with instance(make_host(resumed), state_store=store) as host:
262
+ tray = await host.runs.for_run("run", principal=None)
263
+ assert tray.status.value == "input-required"
264
+ assert tray.inputRequests.value == [request]
265
+ resumed.no_call()