harness-sdk-python 0.36.3__tar.gz → 0.36.5__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.5}/PKG-INFO +2 -2
  2. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.5}/examples/runs_app.py +1 -1
  3. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.5}/pyproject.toml +2 -2
  4. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.5}/src/harness_sdk/deepagents.py +31 -3
  5. harness_sdk_python-0.36.5/src/harness_sdk/langgraph.py +78 -0
  6. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.5}/src/harness_sdk/run_manager.py +171 -14
  7. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.5}/tests/run_helpers.py +1 -1
  8. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.5}/tests/test_deepagents_messages.py +82 -0
  9. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.5}/tests/test_fenced_postgres.py +1 -1
  10. harness_sdk_python-0.36.5/tests/test_langgraph.py +170 -0
  11. harness_sdk_python-0.36.5/tests/test_run_admission.py +195 -0
  12. harness_sdk_python-0.36.5/tests/test_run_sync.py +121 -0
  13. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.5}/.gitignore +0 -0
  14. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.5}/README.md +0 -0
  15. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.5}/examples/__init__.py +0 -0
  16. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.5}/src/harness_sdk/__init__.py +0 -0
  17. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.5}/src/harness_sdk/checkpoints.py +0 -0
  18. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.5}/src/harness_sdk/fenced_postgres.py +0 -0
  19. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.5}/src/harness_sdk/linear_thread.py +0 -0
  20. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.5}/src/harness_sdk/protocol.py +0 -0
  21. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.5}/src/harness_sdk/voice.py +0 -0
  22. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.5}/tests/test_admit_steer.py +0 -0
  23. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.5}/tests/test_applied_visibility.py +0 -0
  24. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.5}/tests/test_batches.py +0 -0
  25. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.5}/tests/test_branch_anchor.py +0 -0
  26. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.5}/tests/test_checkpoints.py +0 -0
  27. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.5}/tests/test_dispatching.py +0 -0
  28. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.5}/tests/test_durability.py +0 -0
  29. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.5}/tests/test_edit_dispatched.py +0 -0
  30. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.5}/tests/test_edit_reload.py +0 -0
  31. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.5}/tests/test_enqueue.py +0 -0
  32. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.5}/tests/test_input_required.py +0 -0
  33. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.5}/tests/test_linear_thread.py +0 -0
  34. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.5}/tests/test_meta.py +0 -0
  35. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.5}/tests/test_mint_concurrency.py +0 -0
  36. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.5}/tests/test_outcomes.py +0 -0
  37. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.5}/tests/test_placement.py +0 -0
  38. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.5}/tests/test_prepare_hooks.py +0 -0
  39. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.5}/tests/test_restore.py +0 -0
  40. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.5}/tests/test_rewind_during_run.py +0 -0
  41. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.5}/tests/test_run_crash_sweep.py +0 -0
  42. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.5}/tests/test_run_eviction.py +0 -0
  43. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.5}/tests/test_run_ownership.py +0 -0
  44. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.5}/tests/test_settle.py +0 -0
  45. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.5}/tests/test_steer.py +0 -0
  46. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.5}/tests/test_stop_continue.py +0 -0
  47. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.5}/tests/test_stopping_window.py +0 -0
  48. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.5}/tests/test_voice.py +0 -0
  49. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.5}/tests/test_voice_delegation.py +0 -0
  50. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.5}/tests/test_voice_frontend_tools.py +0 -0
  51. {harness_sdk_python-0.36.3 → harness_sdk_python-0.36.5}/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.5
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.5"
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
@@ -452,6 +455,12 @@ class RunManager(Tray):
452
455
  self._replay_settle()
453
456
  self._outcome = settled
454
457
  recorded = self.outbox.value
458
+ if recorded is not None and not recorded["item"].get("durableAdmission", False):
459
+ self.steerQueue.untake(self.confirmedTakes.value)
460
+ self.confirmedTakes.value = 0
461
+ self.batchApplied.value = False
462
+ recorded = {**recorded, "item": {**recorded["item"], "durableAdmission": True}}
463
+ self.outbox.value = recorded
455
464
  if recorded is not None and not self.batchApplied.value:
456
465
  record = recorded["item"]["record"]
457
466
  lanes = recorded["item"]["lanes"]
@@ -798,7 +807,11 @@ class RunManager(Tray):
798
807
  root_meta=root_meta,
799
808
  input_outcomes=input_outcomes,
800
809
  )
801
- item = {"record": record, "lanes": [lane for lane, _ in self._batch]}
810
+ item = {
811
+ "record": record,
812
+ "lanes": [lane for lane, _ in self._batch],
813
+ "durableAdmission": True,
814
+ }
802
815
  self.kick()
803
816
  self.stopReason.value = None
804
817
  self.epoch.value = self.epoch.value + 1
@@ -914,7 +927,9 @@ class RunManager(Tray):
914
927
  "run must return a RunManager outcome, got "
915
928
  f"{type(outcome).__name__}"
916
929
  )
917
- if isinstance(outcome, RunManager.Complete) and self.dispatching.value is not None:
930
+ if isinstance(outcome, RunManager.Complete) and (
931
+ self.dispatching.value is not None or self._admissions
932
+ ):
918
933
  raise RuntimeError("run returned Complete with an unconfirmed batch")
919
934
  except EvictionRequested:
920
935
  self._advance()
@@ -1028,11 +1043,33 @@ class RunManager(Tray):
1028
1043
  def _settle(self, ctx: "RunManager.RunContext") -> None:
1029
1044
  if self._run_live is ctx:
1030
1045
  self._run_live = None
1046
+ takes = self._pending_takes + sum(a._takes for a in self._admissions)
1047
+ if takes:
1048
+ self.steerQueue.untake(takes)
1049
+ self._drop_dispatched(
1050
+ message["id"]
1051
+ for admission in self._admissions
1052
+ if admission._takes
1053
+ for _, message in admission._batch
1054
+ )
1055
+ if self._pending_takes:
1056
+ self._drop_dispatched(message["id"] for _, message in self._batch)
1057
+ self._batch = []
1058
+ self._pending_takes = 0
1059
+ self._admissions.clear()
1060
+ recorded = self.outbox.value
1061
+ if recorded is not None and not self.batchApplied.value:
1062
+ item = recorded["item"]
1063
+ self._batch = list(zip(item["lanes"], item["record"]["messages"]))
1031
1064
  if self._batch:
1032
1065
  self._drop_dispatched(item["id"] for _, item in self._batch)
1033
1066
  self._unstage_batch()
1034
1067
  else:
1035
- record = self.dispatching.value
1068
+ record = (
1069
+ recorded["item"]["record"]
1070
+ if recorded is not None and not self.batchApplied.value
1071
+ else self.dispatching.value
1072
+ )
1036
1073
  if (
1037
1074
  record is not None
1038
1075
  and record["trigger"] != "steer"
@@ -1068,16 +1105,15 @@ class RunManager(Tray):
1068
1105
  self.dispatching.value = None
1069
1106
  self._finish_settle()
1070
1107
 
1071
- def _applied(self) -> None:
1108
+ def _applied(self, ctx: "RunManager.RunContext") -> "RunManager.Admission":
1072
1109
  if self.dispatching.value is None:
1073
1110
  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
1111
+ admission = RunManager.Admission(ctx, list(self._batch), self._pending_takes)
1112
+ self._admissions.append(admission)
1113
+ self._pending_takes = 0
1079
1114
  self._batch = []
1080
1115
  self.dispatching.value = None
1116
+ return admission
1081
1117
 
1082
1118
  def _unstage_batch(self) -> None:
1083
1119
  taken, self._batch = self._batch, []
@@ -1600,6 +1636,29 @@ class RunManager(Tray):
1600
1636
  for item in items
1601
1637
  )
1602
1638
 
1639
+ @dataclass(eq=False)
1640
+ class Admission:
1641
+ """One visibly applied batch; ``dur()`` orders its consumption behind enlisted checkpoint writes."""
1642
+
1643
+ _ctx: "RunManager.RunContext"
1644
+ _batch: list[tuple[str, dict[str, Any]]]
1645
+ _takes: int
1646
+ _confirmed: bool = False
1647
+
1648
+ def dur(self) -> None:
1649
+ """Enlist this batch's durable consumption; idempotent until the dispatch settles."""
1650
+ self._ctx._ensure_active()
1651
+ if self._confirmed:
1652
+ return
1653
+ self._confirmed = True
1654
+ manager = self._ctx._manager
1655
+ while manager._admissions and manager._admissions[0]._confirmed:
1656
+ admission = manager._admissions.pop(0)
1657
+ if admission._takes:
1658
+ manager.confirmedTakes.value += admission._takes
1659
+ else:
1660
+ manager.batchApplied.value = True
1661
+
1603
1662
  @dataclass(frozen=True, eq=False)
1604
1663
  class RunContext:
1605
1664
  trigger: str
@@ -1691,10 +1750,10 @@ class RunManager(Tray):
1691
1750
  if self._manager._run_live is not self:
1692
1751
  raise RuntimeError("this run has already settled")
1693
1752
 
1694
- def applied(self) -> None:
1695
- """Durably confirms the current batch: clears the dispatch record so settle consumes it."""
1753
+ def applied(self) -> "RunManager.Admission":
1754
+ """Clear the visible dispatch immediately; call the returned admission's ``dur()`` after enlisting its checkpoint."""
1696
1755
  self._ensure_active()
1697
- self._manager._applied()
1756
+ return self._manager._applied(self)
1698
1757
 
1699
1758
 
1700
1759
  class RunManagerNamespace(TrayMapNamespace):
@@ -1709,6 +1768,8 @@ class RunManagerNamespace(TrayMapNamespace):
1709
1768
  "(RunManager.Thread) to host @run_manager"
1710
1769
  )
1711
1770
  self._mint: "asyncio.Task[Any] | None" = None
1771
+ self._loading: "asyncio.Task[None] | None" = None
1772
+ self._loaded = False
1712
1773
  self._mounted: list[RunManager] = []
1713
1774
  api.state["runs"] = []
1714
1775
  api.state["status"] = "ready"
@@ -1725,7 +1786,103 @@ class RunManagerNamespace(TrayMapNamespace):
1725
1786
  tray, _minted = await self._machine(run_id, principal)
1726
1787
  return tray
1727
1788
 
1789
+ async def _load_existing(self) -> None:
1790
+ if self._loaded:
1791
+ return
1792
+ if self._loading is None:
1793
+ self._loading = self._api.create_task(self._restore_existing())
1794
+ await asyncio.shield(self._loading)
1795
+
1796
+ async def _restore_existing(self) -> None:
1797
+ durability = self._durability()
1798
+ try:
1799
+ rows = await durability.store.list(
1800
+ durability.ns, durability.id, durability.epoch, tray_ns=self._spec.name
1801
+ )
1802
+ except FenceLost:
1803
+ self._api.abort()
1804
+ raise
1805
+ for _, run_id, marked, doc in rows:
1806
+ await self._obtain(run_id, create=False, row=(marked, doc))
1807
+ self._loaded = True
1808
+
1809
+ async def maybe_sync(
1810
+ self,
1811
+ *,
1812
+ pending_inputs: Iterable[dict[str, Any]],
1813
+ has_pending_tasks: bool,
1814
+ principal: Any,
1815
+ meta: Any = None,
1816
+ ) -> bool:
1817
+ """Import external pending work into an inactive run; return whether its state changed."""
1818
+ if type(has_pending_tasks) is not bool:
1819
+ raise TypeError("has_pending_tasks must be a bool")
1820
+ requests = tuple(pending_inputs)
1821
+ if requests:
1822
+ RunManager.InputRequired(requests)
1823
+ await self._load_existing()
1824
+ if self._mint is not None:
1825
+ await asyncio.shield(self._mint)
1826
+ tray = self.live()
1827
+ if tray is None:
1828
+ if not requests and not has_pending_tasks:
1829
+ return False
1830
+ tray, _ = await self._machine(self._api.domain.token_hex(16), principal)
1831
+ tray._check_run_owner(principal)
1832
+ if (
1833
+ tray._in_flight()
1834
+ or tray._intake
1835
+ or tray._batch
1836
+ or tray.queue.value
1837
+ or tray.steerQueue.value
1838
+ or tray.nextDispatch.value is not None
1839
+ or tray._staged_rewind is not None
1840
+ or tray.outcome.value is not None
1841
+ ):
1842
+ return False
1843
+ previous = {r["id"]: r for r in tray.inputRequests.value or ()}
1844
+ synchronized = []
1845
+ for request in requests:
1846
+ item = {k: v for k, v in request.items() if k not in ("response", "meta")}
1847
+ old = previous.get(request["id"], {})
1848
+ if "response" in old:
1849
+ item.update(response=old["response"], meta=old.get("meta"))
1850
+ synchronized.append(item)
1851
+ if requests:
1852
+ if (
1853
+ tray.inputRequests.value == synchronized
1854
+ and tray.halted.value is None
1855
+ and tray.error.value is None
1856
+ ):
1857
+ return False
1858
+ tray.inputRequests.value = synchronized
1859
+ tray._input_requests = [
1860
+ {k: v for k, v in r.items() if k not in ("response", "meta")}
1861
+ for r in synchronized
1862
+ ]
1863
+ tray._input_answers = {
1864
+ r["id"]: (r["response"], r.get("meta"))
1865
+ for r in synchronized if "response" in r
1866
+ }
1867
+ tray.halted.value = None
1868
+ tray.error.value = None
1869
+ tray._publish_status()
1870
+ tray._schedule(tray._advance)
1871
+ return True
1872
+ changed = bool(tray.inputRequests.value)
1873
+ tray._clear_input()
1874
+ if has_pending_tasks:
1875
+ tray._dispatch("stop-continue", [], root_meta=meta)
1876
+ tray.batchApplied.value = True
1877
+ tray.dispatching.value = None
1878
+ tray._publish_status()
1879
+ return True
1880
+ if changed:
1881
+ tray._publish_status()
1882
+ return changed
1883
+
1728
1884
  async def _machine(self, run_id: str, principal: Any) -> tuple[RunManager, bool]:
1885
+ await self._load_existing()
1729
1886
  # No await between the live() check and the mint's publication, and no
1730
1887
  # lock held across storage I/O: same-tick callers await the shared mint
1731
1888
  # 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,195 @@
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())
147
+
148
+
149
+ @pytest.mark.parametrize("takes", [0, 2])
150
+ @pytest.mark.parametrize("checkpoint", [None, {"graph_input_checkpointed": True}])
151
+ async def test_pre_split_records_replay_inputs_and_restore_confirmed_steers(
152
+ takes, checkpoint
153
+ ):
154
+ store = MemoryStateStore()
155
+ script = Script()
156
+ host_type = make_host(script)
157
+ async with instance(host_type, state_store=store) as host:
158
+ await host.runs.enqueue(
159
+ add("m1"), meta={"author": "first"}, applied=lambda: None, auth_result=None
160
+ )
161
+ call = await script.next_call()
162
+ call.ctx.set_state(checkpoint)
163
+ call.applied()
164
+ for index in range(takes):
165
+ await host.runs.steer(
166
+ add(f"s{index}"),
167
+ meta={"author": f"author{index}"},
168
+ applied=lambda: None,
169
+ auth_result=None,
170
+ )
171
+ if takes:
172
+ call.ctx.steering.take()
173
+ call.applied()
174
+ await host.frontier.wait()
175
+ for _, doc in store.trays.values():
176
+ if "outbox" in doc:
177
+ doc["outbox"]["item"].pop("durableAdmission")
178
+
179
+ async with instance(host_type, state_store=store) as host:
180
+ replay = await script.next_call()
181
+ assert not replay.ctx.resume
182
+ assert [m["id"] for m in replay.ctx.messages] == ["m1"]
183
+ assert replay.ctx.meta("m1") == {"author": "first"}
184
+ replay.applied()
185
+ if takes:
186
+ assert [m["id"] for m in replay.ctx.steering.take()] == ["s0", "s1"]
187
+ assert replay.ctx.meta("s0") == {"author": "author0"}
188
+ assert replay.ctx.meta("s1") == {"author": "author1"}
189
+ replay.applied()
190
+ await host.frontier.wait()
191
+ async with instance(host_type, state_store=store):
192
+ confirmed = await script.next_call()
193
+ assert confirmed.ctx.resume
194
+ assert confirmed.ctx.steering.take() == ()
195
+ confirmed.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)