harness-sdk-python 0.36.3__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.
Files changed (51) hide show
  1. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.4}/PKG-INFO +2 -2
  2. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.4}/examples/runs_app.py +1 -1
  3. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.4}/pyproject.toml +2 -2
  4. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.4}/src/harness_sdk/deepagents.py +31 -3
  5. harness_sdk_python-0.36.4/src/harness_sdk/langgraph.py +78 -0
  6. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.4}/src/harness_sdk/run_manager.py +160 -13
  7. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.4}/tests/run_helpers.py +1 -1
  8. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.4}/tests/test_deepagents_messages.py +82 -0
  9. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.4}/tests/test_fenced_postgres.py +1 -1
  10. harness_sdk_python-0.36.4/tests/test_langgraph.py +170 -0
  11. harness_sdk_python-0.36.4/tests/test_run_admission.py +146 -0
  12. harness_sdk_python-0.36.4/tests/test_run_sync.py +121 -0
  13. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.4}/.gitignore +0 -0
  14. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.4}/README.md +0 -0
  15. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.4}/examples/__init__.py +0 -0
  16. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.4}/src/harness_sdk/__init__.py +0 -0
  17. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.4}/src/harness_sdk/checkpoints.py +0 -0
  18. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.4}/src/harness_sdk/fenced_postgres.py +0 -0
  19. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.4}/src/harness_sdk/linear_thread.py +0 -0
  20. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.4}/src/harness_sdk/protocol.py +0 -0
  21. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.4}/src/harness_sdk/voice.py +0 -0
  22. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.4}/tests/test_admit_steer.py +0 -0
  23. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.4}/tests/test_applied_visibility.py +0 -0
  24. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.4}/tests/test_batches.py +0 -0
  25. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.4}/tests/test_branch_anchor.py +0 -0
  26. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.4}/tests/test_checkpoints.py +0 -0
  27. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.4}/tests/test_dispatching.py +0 -0
  28. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.4}/tests/test_durability.py +0 -0
  29. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.4}/tests/test_edit_dispatched.py +0 -0
  30. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.4}/tests/test_edit_reload.py +0 -0
  31. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.4}/tests/test_enqueue.py +0 -0
  32. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.4}/tests/test_input_required.py +0 -0
  33. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.4}/tests/test_linear_thread.py +0 -0
  34. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.4}/tests/test_meta.py +0 -0
  35. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.4}/tests/test_mint_concurrency.py +0 -0
  36. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.4}/tests/test_outcomes.py +0 -0
  37. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.4}/tests/test_placement.py +0 -0
  38. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.4}/tests/test_prepare_hooks.py +0 -0
  39. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.4}/tests/test_restore.py +0 -0
  40. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.4}/tests/test_rewind_during_run.py +0 -0
  41. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.4}/tests/test_run_crash_sweep.py +0 -0
  42. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.4}/tests/test_run_eviction.py +0 -0
  43. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.4}/tests/test_run_ownership.py +0 -0
  44. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.4}/tests/test_settle.py +0 -0
  45. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.4}/tests/test_steer.py +0 -0
  46. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.4}/tests/test_stop_continue.py +0 -0
  47. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.4}/tests/test_stopping_window.py +0 -0
  48. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.4}/tests/test_voice.py +0 -0
  49. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.4}/tests/test_voice_delegation.py +0 -0
  50. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.4}/tests/test_voice_frontend_tools.py +0 -0
  51. {harness_sdk_python-0.36.3 → 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
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
8
  Requires-Dist: pinned<0.16,>=0.15.2
9
- Requires-Dist: statewire<0.18,>=0.17.3
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'
@@ -106,7 +106,7 @@ class RunsHost(Statewire):
106
106
  "content": user_text(message),
107
107
  }
108
108
  )
109
- ctx.applied()
109
+ ctx.applied().dur()
110
110
  outcome = await self.stream_turn(ctx)
111
111
  if outcome is not None:
112
112
  return outcome
@@ -1,11 +1,11 @@
1
1
  [project]
2
2
  name = "harness-sdk-python"
3
- version = "0.36.3"
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.3,<0.18", "pinned>=0.15.2,<0.16"]
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._captured = None
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 unconfirmed batch record; ``ctx.applied()`` confirms it durably,
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
- unconfirmed batch ever exists. Settle consumes a confirmed batch; a batch
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 self.dispatching.value is not None:
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 = self.dispatching.value
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) -> None:
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
- if self._pending_takes:
1075
- self.confirmedTakes.value = self.confirmedTakes.value + self._pending_takes
1076
- self._pending_takes = 0
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) -> None:
1695
- """Durably confirms the current batch: clears the dispatch record so settle consumes it."""
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.
@@ -19,7 +19,7 @@ class Call:
19
19
  outcome: "asyncio.Future[Any]"
20
20
 
21
21
  def applied(self) -> None:
22
- self.ctx.applied()
22
+ self.ctx.applied().dur()
23
23
 
24
24
  def finish(self, outcome: Any) -> None:
25
25
  self.outcome.set_result(outcome)
@@ -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()
@@ -513,7 +513,7 @@ class GraphHost(Statewire):
513
513
  if step == self.park_after:
514
514
  self.parked.set()
515
515
  await asyncio.Event().wait()
516
- ctx.applied()
516
+ ctx.applied().dur()
517
517
  return RunManager.Complete()
518
518
 
519
519
 
@@ -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)