harness-sdk-python 0.36.1__tar.gz → 0.36.3__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.
- {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.3}/PKG-INFO +3 -3
- {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.3}/pyproject.toml +2 -2
- {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.3}/src/harness_sdk/run_manager.py +54 -27
- {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.3}/tests/test_meta.py +5 -3
- harness_sdk_python-0.36.3/tests/test_run_eviction.py +265 -0
- {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.3}/.gitignore +0 -0
- {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.3}/README.md +0 -0
- {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.3}/examples/__init__.py +0 -0
- {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.3}/examples/runs_app.py +0 -0
- {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.3}/src/harness_sdk/__init__.py +0 -0
- {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.3}/src/harness_sdk/checkpoints.py +0 -0
- {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.3}/src/harness_sdk/deepagents.py +0 -0
- {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.3}/src/harness_sdk/fenced_postgres.py +0 -0
- {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.3}/src/harness_sdk/linear_thread.py +0 -0
- {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.3}/src/harness_sdk/protocol.py +0 -0
- {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.3}/src/harness_sdk/voice.py +0 -0
- {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.3}/tests/run_helpers.py +0 -0
- {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.3}/tests/test_admit_steer.py +0 -0
- {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.3}/tests/test_applied_visibility.py +0 -0
- {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.3}/tests/test_batches.py +0 -0
- {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.3}/tests/test_branch_anchor.py +0 -0
- {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.3}/tests/test_checkpoints.py +0 -0
- {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.3}/tests/test_deepagents_messages.py +0 -0
- {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.3}/tests/test_dispatching.py +0 -0
- {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.3}/tests/test_durability.py +0 -0
- {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.3}/tests/test_edit_dispatched.py +0 -0
- {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.3}/tests/test_edit_reload.py +0 -0
- {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.3}/tests/test_enqueue.py +0 -0
- {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.3}/tests/test_fenced_postgres.py +0 -0
- {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.3}/tests/test_input_required.py +0 -0
- {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.3}/tests/test_linear_thread.py +0 -0
- {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.3}/tests/test_mint_concurrency.py +0 -0
- {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.3}/tests/test_outcomes.py +0 -0
- {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.3}/tests/test_placement.py +0 -0
- {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.3}/tests/test_prepare_hooks.py +0 -0
- {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.3}/tests/test_restore.py +0 -0
- {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.3}/tests/test_rewind_during_run.py +0 -0
- {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.3}/tests/test_run_crash_sweep.py +0 -0
- {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.3}/tests/test_run_ownership.py +0 -0
- {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.3}/tests/test_settle.py +0 -0
- {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.3}/tests/test_steer.py +0 -0
- {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.3}/tests/test_stop_continue.py +0 -0
- {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.3}/tests/test_stopping_window.py +0 -0
- {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.3}/tests/test_voice.py +0 -0
- {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.3}/tests/test_voice_delegation.py +0 -0
- {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.3}/tests/test_voice_frontend_tools.py +0 -0
- {harness_sdk_python-0.36.1 → harness_sdk_python-0.36.3}/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.
|
|
3
|
+
Version: 0.36.3
|
|
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.
|
|
9
|
-
Requires-Dist: statewire<0.18,>=0.17.
|
|
8
|
+
Requires-Dist: pinned<0.16,>=0.15.2
|
|
9
|
+
Requires-Dist: statewire<0.18,>=0.17.3
|
|
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.
|
|
3
|
+
version = "0.36.3"
|
|
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.
|
|
8
|
+
dependencies = ["statewire>=0.17.3,<0.18", "pinned>=0.15.2,<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=
|
|
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
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
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
|
-
|
|
928
|
-
|
|
929
|
-
self.
|
|
930
|
-
|
|
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
|
|
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()
|
|
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()
|
|
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()
|
|
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
|
|
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
|