harness-sdk-python 0.36.2__tar.gz → 0.36.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.
- {harness_sdk_python-0.36.2 → harness_sdk_python-0.36.4}/PKG-INFO +3 -3
- {harness_sdk_python-0.36.2 → harness_sdk_python-0.36.4}/examples/runs_app.py +1 -1
- {harness_sdk_python-0.36.2 → harness_sdk_python-0.36.4}/pyproject.toml +2 -2
- {harness_sdk_python-0.36.2 → harness_sdk_python-0.36.4}/src/harness_sdk/deepagents.py +31 -3
- harness_sdk_python-0.36.4/src/harness_sdk/langgraph.py +78 -0
- {harness_sdk_python-0.36.2 → harness_sdk_python-0.36.4}/src/harness_sdk/run_manager.py +160 -13
- {harness_sdk_python-0.36.2 → harness_sdk_python-0.36.4}/tests/run_helpers.py +1 -1
- {harness_sdk_python-0.36.2 → harness_sdk_python-0.36.4}/tests/test_deepagents_messages.py +82 -0
- {harness_sdk_python-0.36.2 → harness_sdk_python-0.36.4}/tests/test_fenced_postgres.py +1 -1
- harness_sdk_python-0.36.4/tests/test_langgraph.py +170 -0
- harness_sdk_python-0.36.4/tests/test_run_admission.py +146 -0
- harness_sdk_python-0.36.4/tests/test_run_sync.py +121 -0
- {harness_sdk_python-0.36.2 → harness_sdk_python-0.36.4}/.gitignore +0 -0
- {harness_sdk_python-0.36.2 → harness_sdk_python-0.36.4}/README.md +0 -0
- {harness_sdk_python-0.36.2 → harness_sdk_python-0.36.4}/examples/__init__.py +0 -0
- {harness_sdk_python-0.36.2 → harness_sdk_python-0.36.4}/src/harness_sdk/__init__.py +0 -0
- {harness_sdk_python-0.36.2 → harness_sdk_python-0.36.4}/src/harness_sdk/checkpoints.py +0 -0
- {harness_sdk_python-0.36.2 → harness_sdk_python-0.36.4}/src/harness_sdk/fenced_postgres.py +0 -0
- {harness_sdk_python-0.36.2 → harness_sdk_python-0.36.4}/src/harness_sdk/linear_thread.py +0 -0
- {harness_sdk_python-0.36.2 → harness_sdk_python-0.36.4}/src/harness_sdk/protocol.py +0 -0
- {harness_sdk_python-0.36.2 → harness_sdk_python-0.36.4}/src/harness_sdk/voice.py +0 -0
- {harness_sdk_python-0.36.2 → harness_sdk_python-0.36.4}/tests/test_admit_steer.py +0 -0
- {harness_sdk_python-0.36.2 → harness_sdk_python-0.36.4}/tests/test_applied_visibility.py +0 -0
- {harness_sdk_python-0.36.2 → harness_sdk_python-0.36.4}/tests/test_batches.py +0 -0
- {harness_sdk_python-0.36.2 → harness_sdk_python-0.36.4}/tests/test_branch_anchor.py +0 -0
- {harness_sdk_python-0.36.2 → harness_sdk_python-0.36.4}/tests/test_checkpoints.py +0 -0
- {harness_sdk_python-0.36.2 → harness_sdk_python-0.36.4}/tests/test_dispatching.py +0 -0
- {harness_sdk_python-0.36.2 → harness_sdk_python-0.36.4}/tests/test_durability.py +0 -0
- {harness_sdk_python-0.36.2 → harness_sdk_python-0.36.4}/tests/test_edit_dispatched.py +0 -0
- {harness_sdk_python-0.36.2 → harness_sdk_python-0.36.4}/tests/test_edit_reload.py +0 -0
- {harness_sdk_python-0.36.2 → harness_sdk_python-0.36.4}/tests/test_enqueue.py +0 -0
- {harness_sdk_python-0.36.2 → harness_sdk_python-0.36.4}/tests/test_input_required.py +0 -0
- {harness_sdk_python-0.36.2 → harness_sdk_python-0.36.4}/tests/test_linear_thread.py +0 -0
- {harness_sdk_python-0.36.2 → harness_sdk_python-0.36.4}/tests/test_meta.py +0 -0
- {harness_sdk_python-0.36.2 → harness_sdk_python-0.36.4}/tests/test_mint_concurrency.py +0 -0
- {harness_sdk_python-0.36.2 → harness_sdk_python-0.36.4}/tests/test_outcomes.py +0 -0
- {harness_sdk_python-0.36.2 → harness_sdk_python-0.36.4}/tests/test_placement.py +0 -0
- {harness_sdk_python-0.36.2 → harness_sdk_python-0.36.4}/tests/test_prepare_hooks.py +0 -0
- {harness_sdk_python-0.36.2 → harness_sdk_python-0.36.4}/tests/test_restore.py +0 -0
- {harness_sdk_python-0.36.2 → harness_sdk_python-0.36.4}/tests/test_rewind_during_run.py +0 -0
- {harness_sdk_python-0.36.2 → harness_sdk_python-0.36.4}/tests/test_run_crash_sweep.py +0 -0
- {harness_sdk_python-0.36.2 → harness_sdk_python-0.36.4}/tests/test_run_eviction.py +0 -0
- {harness_sdk_python-0.36.2 → harness_sdk_python-0.36.4}/tests/test_run_ownership.py +0 -0
- {harness_sdk_python-0.36.2 → harness_sdk_python-0.36.4}/tests/test_settle.py +0 -0
- {harness_sdk_python-0.36.2 → harness_sdk_python-0.36.4}/tests/test_steer.py +0 -0
- {harness_sdk_python-0.36.2 → harness_sdk_python-0.36.4}/tests/test_stop_continue.py +0 -0
- {harness_sdk_python-0.36.2 → harness_sdk_python-0.36.4}/tests/test_stopping_window.py +0 -0
- {harness_sdk_python-0.36.2 → harness_sdk_python-0.36.4}/tests/test_voice.py +0 -0
- {harness_sdk_python-0.36.2 → harness_sdk_python-0.36.4}/tests/test_voice_delegation.py +0 -0
- {harness_sdk_python-0.36.2 → harness_sdk_python-0.36.4}/tests/test_voice_frontend_tools.py +0 -0
- {harness_sdk_python-0.36.2 → harness_sdk_python-0.36.4}/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.4
|
|
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.4
|
|
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.4"
|
|
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.4,<0.18", "pinned>=0.15.2,<0.16"]
|
|
9
9
|
|
|
10
10
|
[project.optional-dependencies]
|
|
11
11
|
postgres = ["langgraph-checkpoint-postgres>=2.0.0"]
|
|
@@ -146,6 +146,8 @@ class DeepagentsMessages:
|
|
|
146
146
|
)
|
|
147
147
|
self._checkpointer = checkpointer
|
|
148
148
|
self._captured: dict[str, Any] | None = None
|
|
149
|
+
self._admission: Any | None = None
|
|
150
|
+
self._input_checkpoint_seen = False
|
|
149
151
|
self._ephemeral: dict[str, dict[str, Any]] = {}
|
|
150
152
|
self._write_lock = asyncio.Lock()
|
|
151
153
|
|
|
@@ -190,8 +192,7 @@ class DeepagentsMessages:
|
|
|
190
192
|
self, ctx: Any
|
|
191
193
|
) -> tuple[dict[str, Any] | None, dict[str, Any]]:
|
|
192
194
|
"""The dispatch's langgraph (payload, config): plans the resumption, restores a rewind's window, appends the batch to the wire state, and confirms it applied."""
|
|
193
|
-
self.
|
|
194
|
-
self._thread.abandon_plan()
|
|
195
|
+
self.abandon_turn()
|
|
195
196
|
config = self.config()
|
|
196
197
|
if ctx.resume:
|
|
197
198
|
return None, config
|
|
@@ -206,7 +207,7 @@ class DeepagentsMessages:
|
|
|
206
207
|
turn: list[Any] = [*plan.replay]
|
|
207
208
|
for message in ctx.messages:
|
|
208
209
|
turn.append(self._append_user(message))
|
|
209
|
-
ctx.applied()
|
|
210
|
+
self.admit(ctx.applied())
|
|
210
211
|
if plan.fork:
|
|
211
212
|
if plan.checkpoint_id is not None:
|
|
212
213
|
config["configurable"]["checkpoint_id"] = plan.checkpoint_id
|
|
@@ -217,11 +218,34 @@ class DeepagentsMessages:
|
|
|
217
218
|
turn = [RemoveMessage(id=REMOVE_ALL_MESSAGES), *turn]
|
|
218
219
|
return ({"messages": turn} if turn else None), config
|
|
219
220
|
|
|
221
|
+
def admit(self, admission: Any) -> None:
|
|
222
|
+
"""Retain a visibly applied batch until its root checkpoint is saved."""
|
|
223
|
+
if self._admission is not None:
|
|
224
|
+
raise RuntimeError("previous input admission is still pending")
|
|
225
|
+
self._admission = admission
|
|
226
|
+
|
|
227
|
+
def checkpoint_saved(self, config: Any, checkpoint: Any, metadata: Any) -> None:
|
|
228
|
+
"""Confirm this turn's input from a FrontierCheckpointer checkpoint callback."""
|
|
229
|
+
configurable = config["configurable"]
|
|
230
|
+
if (
|
|
231
|
+
configurable["thread_id"] == self._thread_id
|
|
232
|
+
and configurable.get("checkpoint_ns", "") == ""
|
|
233
|
+
and metadata["source"] in ("input", "loop")
|
|
234
|
+
):
|
|
235
|
+
self._confirm_admission()
|
|
236
|
+
|
|
237
|
+
def _confirm_admission(self) -> None:
|
|
238
|
+
if self._admission is not None:
|
|
239
|
+
self._admission.dur()
|
|
240
|
+
self._admission = None
|
|
241
|
+
|
|
220
242
|
def append_event(self, namespace: tuple, mode: str, event: Any) -> None:
|
|
221
243
|
"""Fold one ``astream`` event into the wire state; the root checkpoint stream is captured for the turn's ingest."""
|
|
222
244
|
if mode == "checkpoints":
|
|
223
245
|
if namespace == ():
|
|
224
246
|
self._captured = event
|
|
247
|
+
if event["metadata"]["source"] in ("input", "loop"):
|
|
248
|
+
self._input_checkpoint_seen = True
|
|
225
249
|
return
|
|
226
250
|
from statewire.langgraph import append_langgraph_event
|
|
227
251
|
|
|
@@ -231,11 +255,15 @@ class DeepagentsMessages:
|
|
|
231
255
|
"""Ingest the turn's captured checkpoint and restamp; ``refetch`` discards the capture and reads the checkpointer's latest instead."""
|
|
232
256
|
captured, self._captured = self._captured, None
|
|
233
257
|
await deepagents_ingest(self._thread, captured, refetch=refetch)
|
|
258
|
+
if self._input_checkpoint_seen:
|
|
259
|
+
self._confirm_admission()
|
|
234
260
|
self._restamp()
|
|
235
261
|
|
|
236
262
|
def abandon_turn(self) -> None:
|
|
237
263
|
"""Discard a stream capture and any fork planned for the abandoned turn."""
|
|
238
264
|
self._captured = None
|
|
265
|
+
self._admission = None
|
|
266
|
+
self._input_checkpoint_seen = False
|
|
239
267
|
self._thread.abandon_plan()
|
|
240
268
|
|
|
241
269
|
def append_users(self, messages: Any) -> list[dict[str, Any]]:
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from typing import Any, AsyncIterator, Awaitable, Callable
|
|
2
|
+
|
|
3
|
+
from langgraph.checkpoint.base import BaseCheckpointSaver
|
|
4
|
+
from pinned import Frontier
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class FrontierCheckpointer(BaseCheckpointSaver):
|
|
8
|
+
"""Order a LangGraph saver's async writes with the owning instance's durable effects."""
|
|
9
|
+
|
|
10
|
+
def __init__(
|
|
11
|
+
self,
|
|
12
|
+
saver: BaseCheckpointSaver,
|
|
13
|
+
*,
|
|
14
|
+
frontier: Frontier,
|
|
15
|
+
on_checkpoint: Callable[[Any, Any, Any], None] | None = None,
|
|
16
|
+
) -> None:
|
|
17
|
+
if isinstance(saver, FrontierCheckpointer):
|
|
18
|
+
raise ValueError("a checkpointer can participate in only one frontier")
|
|
19
|
+
super().__init__(serde=saver.serde)
|
|
20
|
+
self.saver = saver
|
|
21
|
+
self.frontier = frontier
|
|
22
|
+
self.on_checkpoint = on_checkpoint
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def config_specs(self):
|
|
26
|
+
return self.saver.config_specs
|
|
27
|
+
|
|
28
|
+
def get_next_version(self, current, channel):
|
|
29
|
+
return self.saver.get_next_version(current, channel)
|
|
30
|
+
|
|
31
|
+
async def _persist(self, effect: Callable[[], Awaitable[Any]]) -> Any:
|
|
32
|
+
result = None
|
|
33
|
+
|
|
34
|
+
async def write() -> None:
|
|
35
|
+
nonlocal result
|
|
36
|
+
result = await effect()
|
|
37
|
+
|
|
38
|
+
pending = write()
|
|
39
|
+
try:
|
|
40
|
+
position = self.frontier.enlist(pending)
|
|
41
|
+
except BaseException:
|
|
42
|
+
pending.close()
|
|
43
|
+
raise
|
|
44
|
+
await self.frontier.wait(position)
|
|
45
|
+
return result
|
|
46
|
+
|
|
47
|
+
async def aget_tuple(self, config):
|
|
48
|
+
await self.frontier.receipt()
|
|
49
|
+
return await self.saver.aget_tuple(config)
|
|
50
|
+
|
|
51
|
+
async def aget_delta_channel_history(self, *, config, channels):
|
|
52
|
+
await self.frontier.receipt()
|
|
53
|
+
return await self.saver.aget_delta_channel_history(config=config, channels=channels)
|
|
54
|
+
|
|
55
|
+
async def alist(
|
|
56
|
+
self, config, *, filter=None, before=None, limit=None
|
|
57
|
+
) -> AsyncIterator[Any]:
|
|
58
|
+
await self.frontier.receipt()
|
|
59
|
+
async for checkpoint in self.saver.alist(
|
|
60
|
+
config, filter=filter, before=before, limit=limit
|
|
61
|
+
):
|
|
62
|
+
yield checkpoint
|
|
63
|
+
|
|
64
|
+
async def aput(self, config, checkpoint, metadata, new_versions):
|
|
65
|
+
result = await self._persist(
|
|
66
|
+
lambda: self.saver.aput(config, checkpoint, metadata, new_versions)
|
|
67
|
+
)
|
|
68
|
+
if self.on_checkpoint is not None:
|
|
69
|
+
self.on_checkpoint(config, checkpoint, metadata)
|
|
70
|
+
return result
|
|
71
|
+
|
|
72
|
+
async def aput_writes(self, config, writes, task_id, task_path="") -> None:
|
|
73
|
+
await self._persist(
|
|
74
|
+
lambda: self.saver.aput_writes(config, writes, task_id, task_path)
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
async def adelete_thread(self, thread_id) -> None:
|
|
78
|
+
await self._persist(lambda: self.saver.adelete_thread(thread_id))
|
|
@@ -26,9 +26,10 @@ order, then takes exactly one action based on the derived status and net
|
|
|
26
26
|
effect (rewind > send-dispatch > continue > input-resume). Every initiator applies
|
|
27
27
|
and settles accepted with its state-visible application, and every verb's
|
|
28
28
|
verdict follows the admission's durable flush. ``entry["dispatching"]`` is
|
|
29
|
-
the
|
|
29
|
+
the visible batch record; ``ctx.applied()`` clears it at memory speed and
|
|
30
|
+
returns an admission whose ``dur()`` confirms consumption behind prior frontier effects,
|
|
30
31
|
``steering.take()`` may refill it via deferred durable pops — at most one
|
|
31
|
-
|
|
32
|
+
visibly unapplied batch exists. Admission handles retain unconfirmed batches. Settle consumes a confirmed batch; a batch
|
|
32
33
|
still unconfirmed at settle returns to the front of its lanes (after a crash,
|
|
33
34
|
unconfirmed taken entries re-present there on their own). A batch already
|
|
34
35
|
durably confirmed redelivers in resume mode instead (``ctx.resume``): the
|
|
@@ -68,6 +69,7 @@ from dataclasses import dataclass, field
|
|
|
68
69
|
from typing import Any, AsyncIterator, Callable, Coroutine, Iterable, Protocol
|
|
69
70
|
|
|
70
71
|
from pinned import EvictionRequested
|
|
72
|
+
from pinned.durability import FenceLost
|
|
71
73
|
from pinned.trays import Tray, TrayDescriptor, TrayCtx, TrayMapNamespace, inbox
|
|
72
74
|
from pinned.trays import persistent, persistent_outbox, persistent_queue
|
|
73
75
|
from statepatch import observable
|
|
@@ -396,6 +398,7 @@ class RunManager(Tray):
|
|
|
396
398
|
self._staged_rewind: _Rewind | None = None
|
|
397
399
|
self._batch: list[tuple[str, dict[str, Any]]] = []
|
|
398
400
|
self._pending_takes = 0
|
|
401
|
+
self._admissions: list[RunManager.Admission] = []
|
|
399
402
|
self._input_requests: list[dict[str, Any]] = []
|
|
400
403
|
self._input_answers: dict[str, Any] = {}
|
|
401
404
|
self._run_live: "RunManager.RunContext | None" = None
|
|
@@ -914,7 +917,9 @@ class RunManager(Tray):
|
|
|
914
917
|
"run must return a RunManager outcome, got "
|
|
915
918
|
f"{type(outcome).__name__}"
|
|
916
919
|
)
|
|
917
|
-
if isinstance(outcome, RunManager.Complete) and
|
|
920
|
+
if isinstance(outcome, RunManager.Complete) and (
|
|
921
|
+
self.dispatching.value is not None or self._admissions
|
|
922
|
+
):
|
|
918
923
|
raise RuntimeError("run returned Complete with an unconfirmed batch")
|
|
919
924
|
except EvictionRequested:
|
|
920
925
|
self._advance()
|
|
@@ -1028,11 +1033,33 @@ class RunManager(Tray):
|
|
|
1028
1033
|
def _settle(self, ctx: "RunManager.RunContext") -> None:
|
|
1029
1034
|
if self._run_live is ctx:
|
|
1030
1035
|
self._run_live = None
|
|
1036
|
+
takes = self._pending_takes + sum(a._takes for a in self._admissions)
|
|
1037
|
+
if takes:
|
|
1038
|
+
self.steerQueue.untake(takes)
|
|
1039
|
+
self._drop_dispatched(
|
|
1040
|
+
message["id"]
|
|
1041
|
+
for admission in self._admissions
|
|
1042
|
+
if admission._takes
|
|
1043
|
+
for _, message in admission._batch
|
|
1044
|
+
)
|
|
1045
|
+
if self._pending_takes:
|
|
1046
|
+
self._drop_dispatched(message["id"] for _, message in self._batch)
|
|
1047
|
+
self._batch = []
|
|
1048
|
+
self._pending_takes = 0
|
|
1049
|
+
self._admissions.clear()
|
|
1050
|
+
recorded = self.outbox.value
|
|
1051
|
+
if recorded is not None and not self.batchApplied.value:
|
|
1052
|
+
item = recorded["item"]
|
|
1053
|
+
self._batch = list(zip(item["lanes"], item["record"]["messages"]))
|
|
1031
1054
|
if self._batch:
|
|
1032
1055
|
self._drop_dispatched(item["id"] for _, item in self._batch)
|
|
1033
1056
|
self._unstage_batch()
|
|
1034
1057
|
else:
|
|
1035
|
-
record =
|
|
1058
|
+
record = (
|
|
1059
|
+
recorded["item"]["record"]
|
|
1060
|
+
if recorded is not None and not self.batchApplied.value
|
|
1061
|
+
else self.dispatching.value
|
|
1062
|
+
)
|
|
1036
1063
|
if (
|
|
1037
1064
|
record is not None
|
|
1038
1065
|
and record["trigger"] != "steer"
|
|
@@ -1068,16 +1095,15 @@ class RunManager(Tray):
|
|
|
1068
1095
|
self.dispatching.value = None
|
|
1069
1096
|
self._finish_settle()
|
|
1070
1097
|
|
|
1071
|
-
def _applied(self) ->
|
|
1098
|
+
def _applied(self, ctx: "RunManager.RunContext") -> "RunManager.Admission":
|
|
1072
1099
|
if self.dispatching.value is None:
|
|
1073
1100
|
raise RuntimeError("applied() with no unapplied batch")
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
else:
|
|
1078
|
-
self.batchApplied.value = True
|
|
1101
|
+
admission = RunManager.Admission(ctx, list(self._batch), self._pending_takes)
|
|
1102
|
+
self._admissions.append(admission)
|
|
1103
|
+
self._pending_takes = 0
|
|
1079
1104
|
self._batch = []
|
|
1080
1105
|
self.dispatching.value = None
|
|
1106
|
+
return admission
|
|
1081
1107
|
|
|
1082
1108
|
def _unstage_batch(self) -> None:
|
|
1083
1109
|
taken, self._batch = self._batch, []
|
|
@@ -1600,6 +1626,29 @@ class RunManager(Tray):
|
|
|
1600
1626
|
for item in items
|
|
1601
1627
|
)
|
|
1602
1628
|
|
|
1629
|
+
@dataclass(eq=False)
|
|
1630
|
+
class Admission:
|
|
1631
|
+
"""One visibly applied batch; ``dur()`` orders its consumption behind enlisted checkpoint writes."""
|
|
1632
|
+
|
|
1633
|
+
_ctx: "RunManager.RunContext"
|
|
1634
|
+
_batch: list[tuple[str, dict[str, Any]]]
|
|
1635
|
+
_takes: int
|
|
1636
|
+
_confirmed: bool = False
|
|
1637
|
+
|
|
1638
|
+
def dur(self) -> None:
|
|
1639
|
+
"""Enlist this batch's durable consumption; idempotent until the dispatch settles."""
|
|
1640
|
+
self._ctx._ensure_active()
|
|
1641
|
+
if self._confirmed:
|
|
1642
|
+
return
|
|
1643
|
+
self._confirmed = True
|
|
1644
|
+
manager = self._ctx._manager
|
|
1645
|
+
while manager._admissions and manager._admissions[0]._confirmed:
|
|
1646
|
+
admission = manager._admissions.pop(0)
|
|
1647
|
+
if admission._takes:
|
|
1648
|
+
manager.confirmedTakes.value += admission._takes
|
|
1649
|
+
else:
|
|
1650
|
+
manager.batchApplied.value = True
|
|
1651
|
+
|
|
1603
1652
|
@dataclass(frozen=True, eq=False)
|
|
1604
1653
|
class RunContext:
|
|
1605
1654
|
trigger: str
|
|
@@ -1691,10 +1740,10 @@ class RunManager(Tray):
|
|
|
1691
1740
|
if self._manager._run_live is not self:
|
|
1692
1741
|
raise RuntimeError("this run has already settled")
|
|
1693
1742
|
|
|
1694
|
-
def applied(self) ->
|
|
1695
|
-
"""
|
|
1743
|
+
def applied(self) -> "RunManager.Admission":
|
|
1744
|
+
"""Clear the visible dispatch immediately; call the returned admission's ``dur()`` after enlisting its checkpoint."""
|
|
1696
1745
|
self._ensure_active()
|
|
1697
|
-
self._manager._applied()
|
|
1746
|
+
return self._manager._applied(self)
|
|
1698
1747
|
|
|
1699
1748
|
|
|
1700
1749
|
class RunManagerNamespace(TrayMapNamespace):
|
|
@@ -1709,6 +1758,8 @@ class RunManagerNamespace(TrayMapNamespace):
|
|
|
1709
1758
|
"(RunManager.Thread) to host @run_manager"
|
|
1710
1759
|
)
|
|
1711
1760
|
self._mint: "asyncio.Task[Any] | None" = None
|
|
1761
|
+
self._loading: "asyncio.Task[None] | None" = None
|
|
1762
|
+
self._loaded = False
|
|
1712
1763
|
self._mounted: list[RunManager] = []
|
|
1713
1764
|
api.state["runs"] = []
|
|
1714
1765
|
api.state["status"] = "ready"
|
|
@@ -1725,7 +1776,103 @@ class RunManagerNamespace(TrayMapNamespace):
|
|
|
1725
1776
|
tray, _minted = await self._machine(run_id, principal)
|
|
1726
1777
|
return tray
|
|
1727
1778
|
|
|
1779
|
+
async def _load_existing(self) -> None:
|
|
1780
|
+
if self._loaded:
|
|
1781
|
+
return
|
|
1782
|
+
if self._loading is None:
|
|
1783
|
+
self._loading = self._api.create_task(self._restore_existing())
|
|
1784
|
+
await asyncio.shield(self._loading)
|
|
1785
|
+
|
|
1786
|
+
async def _restore_existing(self) -> None:
|
|
1787
|
+
durability = self._durability()
|
|
1788
|
+
try:
|
|
1789
|
+
rows = await durability.store.list(
|
|
1790
|
+
durability.ns, durability.id, durability.epoch, tray_ns=self._spec.name
|
|
1791
|
+
)
|
|
1792
|
+
except FenceLost:
|
|
1793
|
+
self._api.abort()
|
|
1794
|
+
raise
|
|
1795
|
+
for _, run_id, marked, doc in rows:
|
|
1796
|
+
await self._obtain(run_id, create=False, row=(marked, doc))
|
|
1797
|
+
self._loaded = True
|
|
1798
|
+
|
|
1799
|
+
async def maybe_sync(
|
|
1800
|
+
self,
|
|
1801
|
+
*,
|
|
1802
|
+
pending_inputs: Iterable[dict[str, Any]],
|
|
1803
|
+
has_pending_tasks: bool,
|
|
1804
|
+
principal: Any,
|
|
1805
|
+
meta: Any = None,
|
|
1806
|
+
) -> bool:
|
|
1807
|
+
"""Import external pending work into an inactive run; return whether its state changed."""
|
|
1808
|
+
if type(has_pending_tasks) is not bool:
|
|
1809
|
+
raise TypeError("has_pending_tasks must be a bool")
|
|
1810
|
+
requests = tuple(pending_inputs)
|
|
1811
|
+
if requests:
|
|
1812
|
+
RunManager.InputRequired(requests)
|
|
1813
|
+
await self._load_existing()
|
|
1814
|
+
if self._mint is not None:
|
|
1815
|
+
await asyncio.shield(self._mint)
|
|
1816
|
+
tray = self.live()
|
|
1817
|
+
if tray is None:
|
|
1818
|
+
if not requests and not has_pending_tasks:
|
|
1819
|
+
return False
|
|
1820
|
+
tray, _ = await self._machine(self._api.domain.token_hex(16), principal)
|
|
1821
|
+
tray._check_run_owner(principal)
|
|
1822
|
+
if (
|
|
1823
|
+
tray._in_flight()
|
|
1824
|
+
or tray._intake
|
|
1825
|
+
or tray._batch
|
|
1826
|
+
or tray.queue.value
|
|
1827
|
+
or tray.steerQueue.value
|
|
1828
|
+
or tray.nextDispatch.value is not None
|
|
1829
|
+
or tray._staged_rewind is not None
|
|
1830
|
+
or tray.outcome.value is not None
|
|
1831
|
+
):
|
|
1832
|
+
return False
|
|
1833
|
+
previous = {r["id"]: r for r in tray.inputRequests.value or ()}
|
|
1834
|
+
synchronized = []
|
|
1835
|
+
for request in requests:
|
|
1836
|
+
item = {k: v for k, v in request.items() if k not in ("response", "meta")}
|
|
1837
|
+
old = previous.get(request["id"], {})
|
|
1838
|
+
if "response" in old:
|
|
1839
|
+
item.update(response=old["response"], meta=old.get("meta"))
|
|
1840
|
+
synchronized.append(item)
|
|
1841
|
+
if requests:
|
|
1842
|
+
if (
|
|
1843
|
+
tray.inputRequests.value == synchronized
|
|
1844
|
+
and tray.halted.value is None
|
|
1845
|
+
and tray.error.value is None
|
|
1846
|
+
):
|
|
1847
|
+
return False
|
|
1848
|
+
tray.inputRequests.value = synchronized
|
|
1849
|
+
tray._input_requests = [
|
|
1850
|
+
{k: v for k, v in r.items() if k not in ("response", "meta")}
|
|
1851
|
+
for r in synchronized
|
|
1852
|
+
]
|
|
1853
|
+
tray._input_answers = {
|
|
1854
|
+
r["id"]: (r["response"], r.get("meta"))
|
|
1855
|
+
for r in synchronized if "response" in r
|
|
1856
|
+
}
|
|
1857
|
+
tray.halted.value = None
|
|
1858
|
+
tray.error.value = None
|
|
1859
|
+
tray._publish_status()
|
|
1860
|
+
tray._schedule(tray._advance)
|
|
1861
|
+
return True
|
|
1862
|
+
changed = bool(tray.inputRequests.value)
|
|
1863
|
+
tray._clear_input()
|
|
1864
|
+
if has_pending_tasks:
|
|
1865
|
+
tray._dispatch("stop-continue", [], root_meta=meta)
|
|
1866
|
+
tray.batchApplied.value = True
|
|
1867
|
+
tray.dispatching.value = None
|
|
1868
|
+
tray._publish_status()
|
|
1869
|
+
return True
|
|
1870
|
+
if changed:
|
|
1871
|
+
tray._publish_status()
|
|
1872
|
+
return changed
|
|
1873
|
+
|
|
1728
1874
|
async def _machine(self, run_id: str, principal: Any) -> tuple[RunManager, bool]:
|
|
1875
|
+
await self._load_existing()
|
|
1729
1876
|
# No await between the live() check and the mint's publication, and no
|
|
1730
1877
|
# lock held across storage I/O: same-tick callers await the shared mint
|
|
1731
1878
|
# task and merge into the machine it mounts, or raise its failure.
|
|
@@ -46,9 +46,14 @@ class Ctx:
|
|
|
46
46
|
has_rollback: bool = False
|
|
47
47
|
rollback_to: str | None = None
|
|
48
48
|
applied_count: int = 0
|
|
49
|
+
durable_count: int = 0
|
|
49
50
|
|
|
50
51
|
def applied(self):
|
|
51
52
|
self.applied_count += 1
|
|
53
|
+
return self
|
|
54
|
+
|
|
55
|
+
def dur(self):
|
|
56
|
+
self.durable_count += 1
|
|
52
57
|
|
|
53
58
|
|
|
54
59
|
def user(id, text):
|
|
@@ -145,6 +150,7 @@ async def test_send_turn_projects_and_stamps():
|
|
|
145
150
|
ctx = Ctx("message-send", (user("u1", "one"),))
|
|
146
151
|
await turn(graph, messages, ctx)
|
|
147
152
|
assert ctx.applied_count == 1
|
|
153
|
+
assert ctx.durable_count == 1
|
|
148
154
|
assert ids(state) == ["u1", "a0"]
|
|
149
155
|
assert [m["type"] for m in state["messages"]] == ["human", "ai"]
|
|
150
156
|
assert branch(state, "a0") == {"id": 0, "siblings": [0]}
|
|
@@ -426,3 +432,79 @@ async def test_switch_branch_unknown_raises():
|
|
|
426
432
|
_, _, _, messages, _ = await seeded()
|
|
427
433
|
with pytest.raises(KeyError, match="unknown branch_id"):
|
|
428
434
|
await messages.switch_branch(7)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
async def test_frontier_confirms_root_input_before_graph_execution():
|
|
438
|
+
from pinned import Frontier, InstanceDurability
|
|
439
|
+
from pinned.domain import RealDomain
|
|
440
|
+
from pinned.testing import MemoryStateStore
|
|
441
|
+
|
|
442
|
+
from harness_sdk.langgraph import FrontierCheckpointer
|
|
443
|
+
|
|
444
|
+
durability = InstanceDurability(
|
|
445
|
+
MemoryStateStore(), "threads", THREAD, 0, RealDomain()
|
|
446
|
+
)
|
|
447
|
+
boundary = Frontier(durability, lambda: None, asyncio.create_task)
|
|
448
|
+
saver = InMemorySaver()
|
|
449
|
+
callbacks = []
|
|
450
|
+
messages = None
|
|
451
|
+
|
|
452
|
+
def checkpoint_saved(config, checkpoint, metadata):
|
|
453
|
+
callbacks.append(metadata["source"])
|
|
454
|
+
messages.checkpoint_saved(config, checkpoint, metadata)
|
|
455
|
+
|
|
456
|
+
wrapper = FrontierCheckpointer(
|
|
457
|
+
saver, frontier=boundary, on_checkpoint=checkpoint_saved
|
|
458
|
+
)
|
|
459
|
+
messages, state = await booted(wrapper, MemoryCheckpointIndex())
|
|
460
|
+
ctx = Ctx("message-send", (user("u1", "one"),))
|
|
461
|
+
payload, config = await messages.plan_turn(ctx)
|
|
462
|
+
assert ctx.applied_count == 1
|
|
463
|
+
assert ctx.durable_count == 0
|
|
464
|
+
assert ids(state) == ["u1"]
|
|
465
|
+
await stream(_graph(wrapper), messages, payload, config)
|
|
466
|
+
assert ctx.durable_count == 1
|
|
467
|
+
assert callbacks[0] == "input"
|
|
468
|
+
await messages.ingest_turn()
|
|
469
|
+
assert ctx.durable_count == 1
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
async def test_checkpoint_confirmation_ignores_other_threads_subgraphs_and_updates():
|
|
473
|
+
messages, _ = await booted(InMemorySaver(), MemoryCheckpointIndex())
|
|
474
|
+
ctx = Ctx("message-send", (user("u1", "one"),))
|
|
475
|
+
await messages.plan_turn(ctx)
|
|
476
|
+
for thread_id, namespace, source in (
|
|
477
|
+
("another", "", "input"),
|
|
478
|
+
(THREAD, "child:1", "input"),
|
|
479
|
+
(THREAD, "", "update"),
|
|
480
|
+
):
|
|
481
|
+
messages.checkpoint_saved(
|
|
482
|
+
{"configurable": {"thread_id": thread_id, "checkpoint_ns": namespace}},
|
|
483
|
+
{},
|
|
484
|
+
{"source": source},
|
|
485
|
+
)
|
|
486
|
+
assert ctx.durable_count == 0
|
|
487
|
+
messages.checkpoint_saved(
|
|
488
|
+
{"configurable": {"thread_id": THREAD}}, {}, {"source": "loop"}
|
|
489
|
+
)
|
|
490
|
+
assert ctx.durable_count == 1
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
async def test_abandoned_turn_cannot_confirm_input_from_a_later_checkpoint():
|
|
494
|
+
messages, _ = await booted(InMemorySaver(), MemoryCheckpointIndex())
|
|
495
|
+
ctx = Ctx("message-send", (user("u1", "one"),))
|
|
496
|
+
await messages.plan_turn(ctx)
|
|
497
|
+
messages.abandon_turn()
|
|
498
|
+
messages.checkpoint_saved(
|
|
499
|
+
{"configurable": {"thread_id": THREAD}}, {}, {"source": "input"}
|
|
500
|
+
)
|
|
501
|
+
assert ctx.durable_count == 0
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
async def test_refetch_before_input_checkpoint_does_not_confirm_new_input():
|
|
505
|
+
_, _, _, messages, _ = await seeded()
|
|
506
|
+
ctx = Ctx("message-send", (user("u3", "three"),))
|
|
507
|
+
await messages.plan_turn(ctx)
|
|
508
|
+
await messages.ingest_turn(refetch=True)
|
|
509
|
+
assert ctx.durable_count == 0
|
|
510
|
+
messages.abandon_turn()
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from types import SimpleNamespace
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
from langgraph.checkpoint.base import empty_checkpoint
|
|
6
|
+
from langgraph.checkpoint.memory import InMemorySaver
|
|
7
|
+
from pinned import Frontier, InstanceDurability
|
|
8
|
+
from pinned.domain import RealDomain
|
|
9
|
+
from pinned.testing import MemoryStateStore
|
|
10
|
+
|
|
11
|
+
from harness_sdk.langgraph import FrontierCheckpointer
|
|
12
|
+
|
|
13
|
+
CONFIG = {"configurable": {"thread_id": "t", "checkpoint_ns": ""}}
|
|
14
|
+
METADATA = {"source": "input", "step": -1, "parents": {}}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def frontier(spawn=asyncio.create_task):
|
|
18
|
+
durability = InstanceDurability(MemoryStateStore(), "threads", "t", 0, RealDomain())
|
|
19
|
+
return Frontier(durability, lambda: None, spawn)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def test_checkpoint_and_pending_writes_obey_frontier_and_preserve_results():
|
|
23
|
+
log = []
|
|
24
|
+
gate = asyncio.Event()
|
|
25
|
+
started = asyncio.Event()
|
|
26
|
+
boundary = frontier()
|
|
27
|
+
saver = InMemorySaver()
|
|
28
|
+
checkpoint = empty_checkpoint()
|
|
29
|
+
|
|
30
|
+
async def begin():
|
|
31
|
+
log.append("begin")
|
|
32
|
+
started.set()
|
|
33
|
+
await gate.wait()
|
|
34
|
+
|
|
35
|
+
def admitted(config, saved, metadata):
|
|
36
|
+
assert config == CONFIG
|
|
37
|
+
assert saved is checkpoint
|
|
38
|
+
assert metadata is METADATA
|
|
39
|
+
log.append("checkpoint")
|
|
40
|
+
boundary.enlist(confirmation())
|
|
41
|
+
|
|
42
|
+
async def confirmation():
|
|
43
|
+
log.append("admitted")
|
|
44
|
+
|
|
45
|
+
wrapper = FrontierCheckpointer(saver, frontier=boundary, on_checkpoint=admitted)
|
|
46
|
+
boundary.enlist(begin())
|
|
47
|
+
put = asyncio.create_task(wrapper.aput(CONFIG, checkpoint, METADATA, {}))
|
|
48
|
+
await started.wait()
|
|
49
|
+
assert await saver.aget_tuple(CONFIG) is None
|
|
50
|
+
gate.set()
|
|
51
|
+
config = await put
|
|
52
|
+
await wrapper.aput_writes(config, [("messages", "hello")], "task", "path")
|
|
53
|
+
saved = await wrapper.aget_tuple(CONFIG)
|
|
54
|
+
assert saved.config == config
|
|
55
|
+
assert saved.pending_writes == [("task", "messages", "hello")]
|
|
56
|
+
assert [item.config async for item in wrapper.alist(CONFIG)] == [config]
|
|
57
|
+
assert log == ["begin", "checkpoint", "admitted"]
|
|
58
|
+
await wrapper.adelete_thread("t")
|
|
59
|
+
assert await wrapper.aget_tuple(CONFIG) is None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def test_failed_checkpoint_poisons_later_effects_without_acknowledging():
|
|
63
|
+
boundary = frontier()
|
|
64
|
+
confirmed = []
|
|
65
|
+
started = asyncio.Event()
|
|
66
|
+
gate = asyncio.Event()
|
|
67
|
+
|
|
68
|
+
class FailingSaver(InMemorySaver):
|
|
69
|
+
async def aput(self, *args):
|
|
70
|
+
started.set()
|
|
71
|
+
await gate.wait()
|
|
72
|
+
raise OSError("checkpoint unavailable")
|
|
73
|
+
|
|
74
|
+
wrapper = FrontierCheckpointer(
|
|
75
|
+
FailingSaver(),
|
|
76
|
+
frontier=boundary,
|
|
77
|
+
on_checkpoint=lambda *args: confirmed.append(True),
|
|
78
|
+
)
|
|
79
|
+
put = asyncio.create_task(wrapper.aput(CONFIG, empty_checkpoint(), METADATA, {}))
|
|
80
|
+
await started.wait()
|
|
81
|
+
gate.set()
|
|
82
|
+
with pytest.raises(OSError, match="checkpoint unavailable"):
|
|
83
|
+
await put
|
|
84
|
+
assert confirmed == []
|
|
85
|
+
with pytest.raises(RuntimeError, match="poisoned"):
|
|
86
|
+
await wrapper.aput_writes(CONFIG, [], "task")
|
|
87
|
+
boundary.close()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
async def test_cancelled_caller_leaves_owned_checkpoint_effect_in_frontier():
|
|
91
|
+
boundary = frontier()
|
|
92
|
+
started = asyncio.Event()
|
|
93
|
+
gate = asyncio.Event()
|
|
94
|
+
confirmed = []
|
|
95
|
+
|
|
96
|
+
class GatedSaver(InMemorySaver):
|
|
97
|
+
async def aput(self, *args):
|
|
98
|
+
started.set()
|
|
99
|
+
await gate.wait()
|
|
100
|
+
return await super().aput(*args)
|
|
101
|
+
|
|
102
|
+
saver = GatedSaver()
|
|
103
|
+
wrapper = FrontierCheckpointer(
|
|
104
|
+
saver, frontier=boundary, on_checkpoint=lambda *args: confirmed.append(True)
|
|
105
|
+
)
|
|
106
|
+
put = asyncio.create_task(wrapper.aput(CONFIG, empty_checkpoint(), METADATA, {}))
|
|
107
|
+
await started.wait()
|
|
108
|
+
put.cancel()
|
|
109
|
+
with pytest.raises(asyncio.CancelledError):
|
|
110
|
+
await put
|
|
111
|
+
gate.set()
|
|
112
|
+
await boundary.receipt()
|
|
113
|
+
assert await saver.aget_tuple(CONFIG) is not None
|
|
114
|
+
assert confirmed == []
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
async def test_unstarted_effect_does_not_create_underlying_coroutine():
|
|
118
|
+
pending = []
|
|
119
|
+
calls = []
|
|
120
|
+
boundary = frontier(
|
|
121
|
+
lambda coroutine: (
|
|
122
|
+
pending.append(coroutine) or SimpleNamespace(done=lambda: False)
|
|
123
|
+
)
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
class TrackingSaver(InMemorySaver):
|
|
127
|
+
def aput(self, *args):
|
|
128
|
+
calls.append(True)
|
|
129
|
+
return super().aput(*args)
|
|
130
|
+
|
|
131
|
+
wrapper = FrontierCheckpointer(TrackingSaver(), frontier=boundary)
|
|
132
|
+
put = asyncio.create_task(wrapper.aput(CONFIG, empty_checkpoint(), METADATA, {}))
|
|
133
|
+
await asyncio.sleep(0)
|
|
134
|
+
put.cancel()
|
|
135
|
+
with pytest.raises(asyncio.CancelledError):
|
|
136
|
+
await put
|
|
137
|
+
boundary.close()
|
|
138
|
+
for coroutine in pending:
|
|
139
|
+
coroutine.close()
|
|
140
|
+
assert calls == []
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
async def test_delta_history_uses_saver_specialization_after_frontier():
|
|
144
|
+
boundary = frontier()
|
|
145
|
+
history = {"messages": {"seed": [], "writes": []}}
|
|
146
|
+
committed = False
|
|
147
|
+
|
|
148
|
+
async def write():
|
|
149
|
+
nonlocal committed
|
|
150
|
+
committed = True
|
|
151
|
+
|
|
152
|
+
class DeltaSaver(InMemorySaver):
|
|
153
|
+
async def aget_delta_channel_history(self, *, config, channels):
|
|
154
|
+
assert committed
|
|
155
|
+
assert config == CONFIG
|
|
156
|
+
assert channels == ["messages"]
|
|
157
|
+
return history
|
|
158
|
+
|
|
159
|
+
wrapper = FrontierCheckpointer(DeltaSaver(), frontier=boundary)
|
|
160
|
+
boundary.enlist(write())
|
|
161
|
+
assert await wrapper.aget_delta_channel_history(
|
|
162
|
+
config=CONFIG, channels=["messages"]
|
|
163
|
+
) is history
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_nested_frontier_checkpointers_are_rejected_before_they_can_deadlock():
|
|
167
|
+
boundary = frontier()
|
|
168
|
+
wrapper = FrontierCheckpointer(InMemorySaver(), frontier=boundary)
|
|
169
|
+
with pytest.raises(ValueError, match="only one frontier"):
|
|
170
|
+
FrontierCheckpointer(wrapper, frontier=boundary)
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from pinned.testing import MemoryStateStore, instance
|
|
5
|
+
from run_helpers import Script, add, edit_params, make_host, msg
|
|
6
|
+
from statepatch import plain
|
|
7
|
+
|
|
8
|
+
from harness_sdk import RunManager
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def enqueue(host, message_id="m1"):
|
|
12
|
+
await host.runs.enqueue(add(message_id), applied=lambda: None, auth_result=None)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@pytest.mark.parametrize("confirmed", [False, True])
|
|
16
|
+
async def test_visible_application_only_resumes_after_durable_confirmation(confirmed):
|
|
17
|
+
store = MemoryStateStore()
|
|
18
|
+
script = Script()
|
|
19
|
+
host_type = make_host(script)
|
|
20
|
+
async with instance(host_type, state_store=store) as host:
|
|
21
|
+
await enqueue(host)
|
|
22
|
+
call = await script.next_call()
|
|
23
|
+
admission = call.ctx.applied()
|
|
24
|
+
assert plain(host.state["runs"][0]["dispatching"]) is None
|
|
25
|
+
if confirmed:
|
|
26
|
+
call.ctx.set_state({"checkpoint": "c1"})
|
|
27
|
+
admission.dur()
|
|
28
|
+
admission.dur()
|
|
29
|
+
await host.frontier.wait()
|
|
30
|
+
async with instance(host_type, state_store=store):
|
|
31
|
+
replay = await script.next_call()
|
|
32
|
+
assert replay.ctx.resume is confirmed
|
|
33
|
+
if confirmed:
|
|
34
|
+
assert replay.ctx.state == {"checkpoint": "c1"}
|
|
35
|
+
else:
|
|
36
|
+
assert [m["id"] for m in replay.ctx.messages] == ["m1"]
|
|
37
|
+
replay.ctx.applied().dur()
|
|
38
|
+
replay.finish(RunManager.Complete())
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def test_visible_application_does_not_wait_for_checkpoint_storage():
|
|
42
|
+
script = Script()
|
|
43
|
+
async with instance(make_host(script)) as host:
|
|
44
|
+
await enqueue(host)
|
|
45
|
+
call = await script.next_call()
|
|
46
|
+
admission = call.ctx.applied()
|
|
47
|
+
entered = asyncio.Event()
|
|
48
|
+
release = asyncio.Event()
|
|
49
|
+
|
|
50
|
+
async def checkpoint():
|
|
51
|
+
entered.set()
|
|
52
|
+
await release.wait()
|
|
53
|
+
|
|
54
|
+
call.ctx.enlist(checkpoint())
|
|
55
|
+
admission.dur()
|
|
56
|
+
await entered.wait()
|
|
57
|
+
receipt = asyncio.ensure_future(host.frontier.wait())
|
|
58
|
+
await asyncio.sleep(0)
|
|
59
|
+
assert not receipt.done()
|
|
60
|
+
assert plain(host.state["runs"][0]["dispatching"]) is None
|
|
61
|
+
release.set()
|
|
62
|
+
await receipt
|
|
63
|
+
call.finish(RunManager.Complete())
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
async def test_delayed_handles_confirm_their_original_batches():
|
|
67
|
+
script = Script()
|
|
68
|
+
store = MemoryStateStore()
|
|
69
|
+
host_type = make_host(script)
|
|
70
|
+
async with instance(host_type, state_store=store) as host:
|
|
71
|
+
await enqueue(host)
|
|
72
|
+
call = await script.next_call()
|
|
73
|
+
first = call.ctx.applied()
|
|
74
|
+
await host.runs.steer(add("m2"), applied=lambda: None, auth_result=None)
|
|
75
|
+
assert [m["id"] for m in call.ctx.steering.take()] == ["m2"]
|
|
76
|
+
second = call.ctx.applied()
|
|
77
|
+
second.dur()
|
|
78
|
+
first.dur()
|
|
79
|
+
await host.frontier.wait()
|
|
80
|
+
async with instance(host_type, state_store=store):
|
|
81
|
+
replay = await script.next_call()
|
|
82
|
+
assert replay.ctx.resume
|
|
83
|
+
assert replay.ctx.steering.take() == ()
|
|
84
|
+
replay.finish(RunManager.Complete())
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
async def test_unconfirmed_visible_steering_returns_to_its_lane_at_stop():
|
|
88
|
+
script = Script()
|
|
89
|
+
async with instance(make_host(script)) as host:
|
|
90
|
+
await enqueue(host)
|
|
91
|
+
call = await script.next_call()
|
|
92
|
+
call.ctx.applied().dur()
|
|
93
|
+
await host.runs.steer(add("m2"), applied=lambda: None, auth_result=None)
|
|
94
|
+
call.ctx.steering.take()
|
|
95
|
+
delayed = call.ctx.applied()
|
|
96
|
+
call.finish(RunManager.Stop(dispatch_queue=False))
|
|
97
|
+
await host.runs.live().idle.wait()
|
|
98
|
+
assert [m["id"] for m in host.state["runs"][0]["steerQueue"]] == ["m2"]
|
|
99
|
+
with pytest.raises(RuntimeError, match="settled"):
|
|
100
|
+
delayed.dur()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
async def test_complete_requires_durable_consumption():
|
|
104
|
+
script = Script()
|
|
105
|
+
async with instance(make_host(script)) as host:
|
|
106
|
+
await enqueue(host)
|
|
107
|
+
call = await script.next_call()
|
|
108
|
+
call.ctx.applied()
|
|
109
|
+
call.finish(RunManager.Complete())
|
|
110
|
+
await host.runs.live().idle.wait()
|
|
111
|
+
assert host.state["status"] == "error"
|
|
112
|
+
assert [m["id"] for m in host.state["runs"][0]["queue"]] == ["m1"]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
async def test_unconfirmed_rewind_replacement_can_be_enqueued_after_stop():
|
|
116
|
+
script = Script()
|
|
117
|
+
async with instance(
|
|
118
|
+
make_host(script, capabilities=("rewind", "rewind-during-run"))
|
|
119
|
+
) as host:
|
|
120
|
+
await enqueue(host)
|
|
121
|
+
first = await script.next_call()
|
|
122
|
+
first.applied()
|
|
123
|
+
script.thread["m1"] = {
|
|
124
|
+
"parentId": None,
|
|
125
|
+
"role": "user",
|
|
126
|
+
"isLeaf": True,
|
|
127
|
+
"onActiveBranch": True,
|
|
128
|
+
}
|
|
129
|
+
await host.runs.edit(
|
|
130
|
+
edit_params("m1", msg("m2"), anchor=None),
|
|
131
|
+
applied=lambda: None,
|
|
132
|
+
auth_result=None,
|
|
133
|
+
)
|
|
134
|
+
first.finish(RunManager.Stop(dispatch_queue=False))
|
|
135
|
+
replacement = await script.next_call()
|
|
136
|
+
replacement.ctx.applied()
|
|
137
|
+
replacement.finish(RunManager.Stop(dispatch_queue=False))
|
|
138
|
+
await host.runs.live().idle.wait()
|
|
139
|
+
|
|
140
|
+
await host.runs.enqueue(
|
|
141
|
+
add("m2", anchor="m1"), applied=lambda: None, auth_result=None
|
|
142
|
+
)
|
|
143
|
+
retry = await script.next_call()
|
|
144
|
+
assert [m["id"] for m in retry.ctx.messages] == ["m2"]
|
|
145
|
+
retry.applied()
|
|
146
|
+
retry.finish(RunManager.Complete())
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from pinned.testing import MemoryStateStore, instance
|
|
5
|
+
from run_helpers import Script, add, make_host
|
|
6
|
+
from statewire import StatewireReject
|
|
7
|
+
|
|
8
|
+
from harness_sdk import RunManager
|
|
9
|
+
|
|
10
|
+
R1 = {"id": "r1", "type": "interrupt", "payload": "approve"}
|
|
11
|
+
R2 = {"id": "r2", "type": "interrupt", "payload": "continue"}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
async def sync(host, inputs=(), tasks=False, principal="alice", meta=None):
|
|
15
|
+
return await host.runs.maybe_sync(
|
|
16
|
+
pending_inputs=inputs, has_pending_tasks=tasks, principal=principal, meta=meta
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def test_sync_creates_pending_requests_and_repeating_it_is_unchanged():
|
|
21
|
+
script = Script()
|
|
22
|
+
async with instance(make_host(script)) as host:
|
|
23
|
+
assert await sync(host, [R1])
|
|
24
|
+
assert host.state["status"] == "input-required"
|
|
25
|
+
run_id = host.state["runs"][0]["runId"]
|
|
26
|
+
assert not await sync(host, [R1])
|
|
27
|
+
assert host.state["runs"][0]["runId"] == run_id
|
|
28
|
+
script.no_call()
|
|
29
|
+
assert await sync(host)
|
|
30
|
+
assert host.state["status"] == "ready"
|
|
31
|
+
assert not await sync(host)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def test_sync_preserves_answers_to_matching_requests():
|
|
35
|
+
script = Script()
|
|
36
|
+
async with instance(make_host(script)) as host:
|
|
37
|
+
await sync(host, [R1, R2])
|
|
38
|
+
await host.runs.input(
|
|
39
|
+
{"requestId": "r1", "response": {"answer": "yes"}}, auth_result="alice"
|
|
40
|
+
)
|
|
41
|
+
assert await sync(host, [R1])
|
|
42
|
+
call = await script.next_call()
|
|
43
|
+
assert call.ctx.input_outcomes == ((R1, {"answer": "yes"}, None),)
|
|
44
|
+
call.ctx.applied().dur()
|
|
45
|
+
call.finish(RunManager.Complete())
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
async def test_sync_drops_obsolete_answers_and_resumes_pending_graph_work():
|
|
49
|
+
script = Script()
|
|
50
|
+
async with instance(make_host(script)) as host:
|
|
51
|
+
await sync(host, [R1, R2])
|
|
52
|
+
await host.runs.input(
|
|
53
|
+
{"requestId": "r1", "response": {"answer": "yes"}}, auth_result="alice"
|
|
54
|
+
)
|
|
55
|
+
assert await sync(host, tasks=True, meta={"user": "alice"})
|
|
56
|
+
call = await script.next_call()
|
|
57
|
+
assert call.ctx.resume
|
|
58
|
+
assert call.ctx.meta() == {"user": "alice"}
|
|
59
|
+
assert call.ctx.input_outcomes == ()
|
|
60
|
+
call.finish(RunManager.Complete())
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def test_sync_is_guarded_by_recorded_execution_and_ownership():
|
|
64
|
+
script = Script()
|
|
65
|
+
store = MemoryStateStore()
|
|
66
|
+
host_type = make_host(script)
|
|
67
|
+
async with instance(host_type, state_store=store) as host:
|
|
68
|
+
await sync(host, [R1])
|
|
69
|
+
with pytest.raises(StatewireReject, match="another caller"):
|
|
70
|
+
await sync(host, tasks=True, principal="mallory")
|
|
71
|
+
assert await sync(host, tasks=True)
|
|
72
|
+
await script.next_call()
|
|
73
|
+
assert not await sync(host, [R2])
|
|
74
|
+
await host.frontier.wait()
|
|
75
|
+
async with instance(host_type, state_store=store) as host:
|
|
76
|
+
assert not await sync(host, [R2])
|
|
77
|
+
call = await script.next_call()
|
|
78
|
+
assert call.ctx.resume
|
|
79
|
+
call.finish(RunManager.Complete())
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
async def test_sync_during_lifespan_loads_existing_records_before_deciding():
|
|
83
|
+
script = Script()
|
|
84
|
+
store = MemoryStateStore()
|
|
85
|
+
base = make_host(script)
|
|
86
|
+
async with instance(base, state_store=store) as host:
|
|
87
|
+
await host.runs.enqueue(add("m1"), applied=lambda: None, auth_result=None)
|
|
88
|
+
await script.next_call()
|
|
89
|
+
await host.frontier.wait()
|
|
90
|
+
|
|
91
|
+
class Host(base):
|
|
92
|
+
async def lifespan(self):
|
|
93
|
+
self.state = {}
|
|
94
|
+
self.changed = await sync(self, [R1], principal=None)
|
|
95
|
+
yield
|
|
96
|
+
|
|
97
|
+
async with instance(Host, state_store=store) as host:
|
|
98
|
+
assert not host.changed
|
|
99
|
+
call = await script.next_call()
|
|
100
|
+
assert [m["id"] for m in call.ctx.messages] == ["m1"]
|
|
101
|
+
call.ctx.applied().dur()
|
|
102
|
+
call.finish(RunManager.Complete())
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
async def test_concurrent_sync_calls_create_one_run():
|
|
106
|
+
script = Script()
|
|
107
|
+
async with instance(make_host(script)) as host:
|
|
108
|
+
results = await asyncio.gather(sync(host, tasks=True), sync(host, tasks=True))
|
|
109
|
+
assert sorted(results) == [False, True]
|
|
110
|
+
call = await script.next_call()
|
|
111
|
+
script.no_call()
|
|
112
|
+
call.finish(RunManager.Complete())
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
async def test_empty_sync_does_not_create_a_run():
|
|
116
|
+
script = Script()
|
|
117
|
+
async with instance(make_host(script)) as host:
|
|
118
|
+
assert not await sync(host)
|
|
119
|
+
assert host.state["runs"] == []
|
|
120
|
+
with pytest.raises(TypeError, match="bool"):
|
|
121
|
+
await sync(host, tasks=1)
|
|
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
|