harness-sdk-python 0.36.4__tar.gz → 0.36.6__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 (52) hide show
  1. {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/PKG-INFO +1 -1
  2. {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/pyproject.toml +1 -1
  3. {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/src/harness_sdk/deepagents.py +47 -12
  4. {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/src/harness_sdk/run_manager.py +108 -17
  5. {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/tests/test_deepagents_messages.py +132 -0
  6. {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/tests/test_run_admission.py +49 -0
  7. {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/tests/test_run_crash_sweep.py +10 -1
  8. {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/tests/test_settle.py +1 -1
  9. {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/tests/test_stop_continue.py +1 -1
  10. harness_sdk_python-0.36.6/tests/test_undo.py +301 -0
  11. {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/.gitignore +0 -0
  12. {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/README.md +0 -0
  13. {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/examples/__init__.py +0 -0
  14. {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/examples/runs_app.py +0 -0
  15. {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/src/harness_sdk/__init__.py +0 -0
  16. {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/src/harness_sdk/checkpoints.py +0 -0
  17. {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/src/harness_sdk/fenced_postgres.py +0 -0
  18. {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/src/harness_sdk/langgraph.py +0 -0
  19. {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/src/harness_sdk/linear_thread.py +0 -0
  20. {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/src/harness_sdk/protocol.py +0 -0
  21. {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/src/harness_sdk/voice.py +0 -0
  22. {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/tests/run_helpers.py +0 -0
  23. {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/tests/test_admit_steer.py +0 -0
  24. {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/tests/test_applied_visibility.py +0 -0
  25. {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/tests/test_batches.py +0 -0
  26. {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/tests/test_branch_anchor.py +0 -0
  27. {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/tests/test_checkpoints.py +0 -0
  28. {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/tests/test_dispatching.py +0 -0
  29. {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/tests/test_durability.py +0 -0
  30. {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/tests/test_edit_dispatched.py +0 -0
  31. {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/tests/test_edit_reload.py +0 -0
  32. {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/tests/test_enqueue.py +0 -0
  33. {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/tests/test_fenced_postgres.py +0 -0
  34. {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/tests/test_input_required.py +0 -0
  35. {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/tests/test_langgraph.py +0 -0
  36. {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/tests/test_linear_thread.py +0 -0
  37. {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/tests/test_meta.py +0 -0
  38. {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/tests/test_mint_concurrency.py +0 -0
  39. {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/tests/test_outcomes.py +0 -0
  40. {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/tests/test_placement.py +0 -0
  41. {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/tests/test_prepare_hooks.py +0 -0
  42. {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/tests/test_restore.py +0 -0
  43. {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/tests/test_rewind_during_run.py +0 -0
  44. {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/tests/test_run_eviction.py +0 -0
  45. {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/tests/test_run_ownership.py +0 -0
  46. {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/tests/test_run_sync.py +0 -0
  47. {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/tests/test_steer.py +0 -0
  48. {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/tests/test_stopping_window.py +0 -0
  49. {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/tests/test_voice.py +0 -0
  50. {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/tests/test_voice_delegation.py +0 -0
  51. {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/tests/test_voice_frontend_tools.py +0 -0
  52. {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/tests/voice_helpers.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: harness-sdk-python
3
- Version: 0.36.4
3
+ Version: 0.36.6
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "harness-sdk-python"
3
- version = "0.36.4"
3
+ version = "0.36.6"
4
4
  description = "RunManager: the harness-sdk runs subsystem for Python Statewire hosts"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -148,6 +148,8 @@ class DeepagentsMessages:
148
148
  self._captured: dict[str, Any] | None = None
149
149
  self._admission: Any | None = None
150
150
  self._input_checkpoint_seen = False
151
+ self._handle: Any = None
152
+ self._output_seen = False
151
153
  self._ephemeral: dict[str, dict[str, Any]] = {}
152
154
  self._write_lock = asyncio.Lock()
153
155
 
@@ -208,6 +210,8 @@ class DeepagentsMessages:
208
210
  for message in ctx.messages:
209
211
  turn.append(self._append_user(message))
210
212
  self.admit(ctx.applied())
213
+ if plan.fork or ctx.input_outcomes:
214
+ self._handle = None
211
215
  if plan.fork:
212
216
  if plan.checkpoint_id is not None:
213
217
  config["configurable"]["checkpoint_id"] = plan.checkpoint_id
@@ -223,6 +227,34 @@ class DeepagentsMessages:
223
227
  if self._admission is not None:
224
228
  raise RuntimeError("previous input admission is still pending")
225
229
  self._admission = admission
230
+ self._handle = admission
231
+ self._output_seen = False
232
+
233
+ @property
234
+ def retractable(self) -> bool:
235
+ """Whether the current turn can still be retracted: a tip dispatch's admitted batch before any graph output."""
236
+ return self._handle is not None and not self._output_seen
237
+
238
+ async def retract_turn(self) -> None:
239
+ """Retract the current turn: ``undo().dur()`` its batch, drop it from the wire, and fork the checkpointer tip back to the pre-turn checkpoint."""
240
+ if not self.retractable:
241
+ raise RuntimeError("the turn is not retractable")
242
+ handle = self._handle
243
+ self.abandon_turn()
244
+ handle.undo().dur()
245
+ self._restamp()
246
+ async with self._write_lock:
247
+ latest = await self._store.get(None)
248
+ if (
249
+ latest is not None
250
+ and latest.checkpoint_id != self._thread.checkpoint_id
251
+ ):
252
+ checkpoint_id = await self._clone_tip(self._thread.checkpoint_id)
253
+ await self._thread.ingest(
254
+ Snapshot(
255
+ checkpoint_id, self._thread.transcript, self._thread.lineage
256
+ )
257
+ )
226
258
 
227
259
  def checkpoint_saved(self, config: Any, checkpoint: Any, metadata: Any) -> None:
228
260
  """Confirm this turn's input from a FrontierCheckpointer checkpoint callback."""
@@ -246,7 +278,10 @@ class DeepagentsMessages:
246
278
  self._captured = event
247
279
  if event["metadata"]["source"] in ("input", "loop"):
248
280
  self._input_checkpoint_seen = True
281
+ if event["metadata"]["source"] == "loop":
282
+ self._output_seen = True
249
283
  return
284
+ self._output_seen = True
250
285
  from statewire.langgraph import append_langgraph_event
251
286
 
252
287
  append_langgraph_event(self._state, namespace, mode, event)
@@ -263,6 +298,7 @@ class DeepagentsMessages:
263
298
  """Discard a stream capture and any fork planned for the abandoned turn."""
264
299
  self._captured = None
265
300
  self._admission = None
301
+ self._handle = None
266
302
  self._input_checkpoint_seen = False
267
303
  self._thread.abandon_plan()
268
304
 
@@ -329,21 +365,20 @@ class DeepagentsMessages:
329
365
  await self._thread.ingest(Snapshot(checkpoint_id, transcript, lineage))
330
366
  self._restamp()
331
367
 
332
- async def _clone_tip(self, checkpoint_id: str) -> str:
368
+ async def _clone_tip(self, checkpoint_id: str | None) -> str:
369
+ from langgraph.checkpoint.base import empty_checkpoint
333
370
  from langgraph.checkpoint.base.id import uuid6
334
371
 
335
- parent = {
336
- "configurable": {
337
- "thread_id": self._thread_id,
338
- "checkpoint_ns": "",
339
- "checkpoint_id": checkpoint_id,
340
- }
341
- }
342
- saved = await self._checkpointer.aget_tuple(parent)
343
- if saved is None:
344
- raise LookupError(f"checkpoint {checkpoint_id!r} is not in the checkpointer")
372
+ parent = {"configurable": {"thread_id": self._thread_id, "checkpoint_ns": ""}}
373
+ if checkpoint_id is None:
374
+ saved = None
375
+ else:
376
+ parent["configurable"]["checkpoint_id"] = checkpoint_id
377
+ saved = await self._checkpointer.aget_tuple(parent)
378
+ if saved is None:
379
+ raise LookupError(f"checkpoint {checkpoint_id!r} is not in the checkpointer")
345
380
  clone = {
346
- **saved.checkpoint,
381
+ **(empty_checkpoint() if saved is None else saved.checkpoint),
347
382
  "id": str(uuid6(clock_seq=-2)),
348
383
  "ts": datetime.now(timezone.utc).isoformat(),
349
384
  }
@@ -36,7 +36,12 @@ durably confirmed redelivers in resume mode instead (``ctx.resume``): the
36
36
  input batch must not be re-dispatched (``messages`` and the rollback surface
37
37
  raise; meta and input outcomes stay readable) and the run continues from its
38
38
  own continuation state (``ctx.state`` or an external checkpointer).
39
- ``Complete`` with an unconfirmed batch settles as an error.
39
+ ``Complete`` with an unconfirmed batch settles as an error. ``undo()`` on an
40
+ admission handle retracts its batch and every later one (newest first);
41
+ ``dur()`` then publishes the retraction. Retracted items re-present as
42
+ ``dispatching`` and return to their lane fronts at settle, the ``run/stop``
43
+ verdict carries them, and the run must end ``Stop``/``Error`` with
44
+ ``dispatch_queue=False``.
40
45
 
41
46
  ``epoch`` counts the entry's dispatches (not redeliveries); a ``run/stop``
42
47
  carrying an ``epoch`` other than the live one rejects. A mid-run stop or
@@ -399,6 +404,7 @@ class RunManager(Tray):
399
404
  self._batch: list[tuple[str, dict[str, Any]]] = []
400
405
  self._pending_takes = 0
401
406
  self._admissions: list[RunManager.Admission] = []
407
+ self._retracted: list[dict[str, Any]] = []
402
408
  self._input_requests: list[dict[str, Any]] = []
403
409
  self._input_answers: dict[str, Any] = {}
404
410
  self._run_live: "RunManager.RunContext | None" = None
@@ -455,6 +461,12 @@ class RunManager(Tray):
455
461
  self._replay_settle()
456
462
  self._outcome = settled
457
463
  recorded = self.outbox.value
464
+ if recorded is not None and not recorded["item"].get("durableAdmission", False):
465
+ self.steerQueue.untake(self.confirmedTakes.value)
466
+ self.confirmedTakes.value = 0
467
+ self.batchApplied.value = False
468
+ recorded = {**recorded, "item": {**recorded["item"], "durableAdmission": True}}
469
+ self.outbox.value = recorded
458
470
  if recorded is not None and not self.batchApplied.value:
459
471
  record = recorded["item"]["record"]
460
472
  lanes = recorded["item"]["lanes"]
@@ -611,9 +623,10 @@ class RunManager(Tray):
611
623
 
612
624
  def _resolve_stop_waiters(self) -> None:
613
625
  waiters, self._stop_waiters = self._stop_waiters, []
626
+ retracted, self._retracted = self._retracted, []
614
627
  for future in waiters:
615
628
  if not future.done():
616
- future.set_result(None)
629
+ future.set_result(retracted)
617
630
 
618
631
  def _settle_applied(self, fx: _Effects) -> None:
619
632
  for future, applied, result in fx.staged_sends:
@@ -681,7 +694,7 @@ class RunManager(Tray):
681
694
  self._stop_staged = True
682
695
  self._stop_waiters.append(entry.future) # the verdict waits for the settle
683
696
  elif not entry.future.done():
684
- entry.future.set_result(None)
697
+ entry.future.set_result([])
685
698
  return
686
699
  if isinstance(entry, _Continue):
687
700
  if not fx.continue_requested:
@@ -801,7 +814,11 @@ class RunManager(Tray):
801
814
  root_meta=root_meta,
802
815
  input_outcomes=input_outcomes,
803
816
  )
804
- item = {"record": record, "lanes": [lane for lane, _ in self._batch]}
817
+ item = {
818
+ "record": record,
819
+ "lanes": [lane for lane, _ in self._batch],
820
+ "durableAdmission": True,
821
+ }
805
822
  self.kick()
806
823
  self.stopReason.value = None
807
824
  self.epoch.value = self.epoch.value + 1
@@ -918,9 +935,17 @@ class RunManager(Tray):
918
935
  f"{type(outcome).__name__}"
919
936
  )
920
937
  if isinstance(outcome, RunManager.Complete) and (
921
- self.dispatching.value is not None or self._admissions
938
+ self.dispatching.value is not None or self._unconsumed()
922
939
  ):
923
940
  raise RuntimeError("run returned Complete with an unconfirmed batch")
941
+ if (
942
+ isinstance(outcome, (RunManager.Stop, RunManager.Error))
943
+ and outcome.dispatch_queue
944
+ and self._undone()
945
+ ):
946
+ raise RuntimeError(
947
+ "dispatch_queue=True would redispatch an undone batch"
948
+ )
924
949
  except EvictionRequested:
925
950
  self._advance()
926
951
  if self._stop_staged or ctx._tray_ctx.consumed:
@@ -1033,12 +1058,19 @@ class RunManager(Tray):
1033
1058
  def _settle(self, ctx: "RunManager.RunContext") -> None:
1034
1059
  if self._run_live is ctx:
1035
1060
  self._run_live = None
1036
- takes = self._pending_takes + sum(a._takes for a in self._admissions)
1061
+ unconsumed = self._unconsumed()
1062
+ self._retracted = [
1063
+ _public(message)
1064
+ for admission in self._admissions
1065
+ if admission._undone
1066
+ for _, message in admission._batch
1067
+ ]
1068
+ takes = self._pending_takes + sum(a._takes for a in unconsumed)
1037
1069
  if takes:
1038
1070
  self.steerQueue.untake(takes)
1039
1071
  self._drop_dispatched(
1040
1072
  message["id"]
1041
- for admission in self._admissions
1073
+ for admission in unconsumed
1042
1074
  if admission._takes
1043
1075
  for _, message in admission._batch
1044
1076
  )
@@ -1046,6 +1078,8 @@ class RunManager(Tray):
1046
1078
  self._drop_dispatched(message["id"] for _, message in self._batch)
1047
1079
  self._batch = []
1048
1080
  self._pending_takes = 0
1081
+ if any(not a._takes for a in unconsumed):
1082
+ self.batchApplied.value = False # an undone dispatch settles unapplied
1049
1083
  self._admissions.clear()
1050
1084
  recorded = self.outbox.value
1051
1085
  if recorded is not None and not self.batchApplied.value:
@@ -1095,7 +1129,15 @@ class RunManager(Tray):
1095
1129
  self.dispatching.value = None
1096
1130
  self._finish_settle()
1097
1131
 
1132
+ def _unconsumed(self) -> list["RunManager.Admission"]:
1133
+ return [a for a in self._admissions if not a._published or a._undone]
1134
+
1135
+ def _undone(self) -> bool:
1136
+ return any(a._undone for a in self._admissions)
1137
+
1098
1138
  def _applied(self, ctx: "RunManager.RunContext") -> "RunManager.Admission":
1139
+ if self._undone():
1140
+ raise RuntimeError("applied() after undo()")
1099
1141
  if self.dispatching.value is None:
1100
1142
  raise RuntimeError("applied() with no unapplied batch")
1101
1143
  admission = RunManager.Admission(ctx, list(self._batch), self._pending_takes)
@@ -1413,7 +1455,8 @@ class RunManager(Tray):
1413
1455
  lane = self._lane_of(e.message_id)
1414
1456
  if lane is None:
1415
1457
  raise _reject("unknown-id", f"message {e.message_id} is not queued")
1416
- self._check_author(e.message_id, e.principal)
1458
+ if e.principal != self.principal.value or self.principal.value is None:
1459
+ self._check_author(e.message_id, e.principal)
1417
1460
  self._lane(lane).value = [
1418
1461
  item for item in self._lane_items(lane) if item["id"] != e.message_id
1419
1462
  ]
@@ -1603,6 +1646,8 @@ class RunManager(Tray):
1603
1646
  """Drain the steer lane into a fresh dispatch record (deferred durable pops); requires the current batch to be applied."""
1604
1647
  self._ctx._ensure_active()
1605
1648
  manager = self._ctx._manager
1649
+ if manager._undone():
1650
+ raise RuntimeError("take() after undo()")
1606
1651
  if manager.dispatching.value is not None:
1607
1652
  raise RuntimeError("take before the current batch is applied")
1608
1653
  items = manager.steerQueue.take_now()
@@ -1628,26 +1673,72 @@ class RunManager(Tray):
1628
1673
 
1629
1674
  @dataclass(eq=False)
1630
1675
  class Admission:
1631
- """One visibly applied batch; ``dur()`` orders its consumption behind enlisted checkpoint writes."""
1676
+ """One visibly applied batch; ``dur()`` publishes its intent (consumed, or unconsumed after ``undo()``) behind enlisted checkpoint writes."""
1632
1677
 
1633
1678
  _ctx: "RunManager.RunContext"
1634
1679
  _batch: list[tuple[str, dict[str, Any]]]
1635
1680
  _takes: int
1636
1681
  _confirmed: bool = False
1682
+ _published: bool = False
1683
+ _undone: bool = False
1637
1684
 
1638
1685
  def dur(self) -> None:
1639
- """Enlist this batch's durable consumption; idempotent until the dispatch settles."""
1686
+ """Publish the current intent durably; idempotent until the dispatch settles."""
1640
1687
  self._ctx._ensure_active()
1641
- if self._confirmed:
1688
+ manager = self._ctx._manager
1689
+ if self._undone:
1690
+ for admission in reversed(manager._admissions):
1691
+ if admission._undone and admission._published:
1692
+ admission._publish(False)
1642
1693
  return
1643
1694
  self._confirmed = True
1695
+ for admission in manager._admissions:
1696
+ if admission._published:
1697
+ continue
1698
+ if not admission._confirmed or admission._undone:
1699
+ break
1700
+ admission._publish(True)
1701
+
1702
+ def undo(self) -> "RunManager.Admission":
1703
+ """Retract this batch and every later one, newest first; ``dur()`` then publishes the retraction. The record re-presents as ``dispatching`` until settle."""
1704
+ self._ctx._ensure_active()
1644
1705
  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
1706
+ if not self._takes:
1707
+ record = manager.outbox.value["item"]["record"]
1708
+ if "rollbackTo" in record or "inputOutcomes" in record:
1709
+ raise RuntimeError(
1710
+ "undo() of a rewind or input-carrying dispatch is not supported"
1711
+ )
1712
+ for admission in reversed(manager._admissions):
1713
+ admission._undone = True
1714
+ if admission is self:
1715
+ break
1716
+ first = next(a for a in manager._admissions if a._undone)
1717
+ retracted = [
1718
+ item
1719
+ for admission in manager._admissions
1720
+ if admission._undone
1721
+ for _, item in admission._batch
1722
+ ] + [item for _, item in manager._batch]
1723
+ trigger = (
1724
+ "steer"
1725
+ if first._takes
1726
+ else manager.outbox.value["item"]["record"]["trigger"]
1727
+ )
1728
+ manager.dispatching.value = _public_record(
1729
+ manager._record_of(trigger, retracted)
1730
+ )
1731
+ return self
1732
+
1733
+ def _publish(self, consumed: bool) -> None:
1734
+ manager = self._ctx._manager
1735
+ self._published = consumed
1736
+ if self._takes:
1737
+ manager.confirmedTakes.value += (
1738
+ self._takes if consumed else -self._takes
1739
+ )
1740
+ else:
1741
+ manager.batchApplied.value = consumed
1651
1742
 
1652
1743
  @dataclass(frozen=True, eq=False)
1653
1744
  class RunContext:
@@ -45,8 +45,10 @@ class Ctx:
45
45
  resume: bool = False
46
46
  has_rollback: bool = False
47
47
  rollback_to: str | None = None
48
+ input_outcomes: tuple = ()
48
49
  applied_count: int = 0
49
50
  durable_count: int = 0
51
+ undo_count: int = 0
50
52
 
51
53
  def applied(self):
52
54
  self.applied_count += 1
@@ -55,6 +57,10 @@ class Ctx:
55
57
  def dur(self):
56
58
  self.durable_count += 1
57
59
 
60
+ def undo(self):
61
+ self.undo_count += 1
62
+ return self
63
+
58
64
 
59
65
  def user(id, text):
60
66
  return {"id": id, "role": "user", "parts": [{"type": "text", "text": text}]}
@@ -508,3 +514,129 @@ async def test_refetch_before_input_checkpoint_does_not_confirm_new_input():
508
514
  await messages.ingest_turn(refetch=True)
509
515
  assert ctx.durable_count == 0
510
516
  messages.abandon_turn()
517
+
518
+
519
+ # ─── Retraction ─────────────────────────────────────────────
520
+
521
+
522
+ def _gated_graph(saver, gate: asyncio.Event):
523
+ from typing import Annotated, TypedDict
524
+
525
+ class State(TypedDict):
526
+ messages: Annotated[list, add_messages]
527
+
528
+ async def node(state):
529
+ await gate.wait()
530
+ return {"messages": [AIMessage(id="late", content="late")]}
531
+
532
+ builder = StateGraph(State)
533
+ builder.add_node("node", node)
534
+ builder.add_edge(START, "node")
535
+ return builder.compile(checkpointer=saver)
536
+
537
+
538
+ async def _tip_id(saver):
539
+ saved = await saver.aget_tuple({"configurable": {"thread_id": THREAD}})
540
+ return None if saved is None else saved.checkpoint["id"]
541
+
542
+
543
+ async def _stop_after_input(saver, messages, ctx):
544
+ """Start the turn, let the input checkpoint land, then stop the stream."""
545
+ before = await _tip_id(saver)
546
+ gate = asyncio.Event()
547
+ payload, config = await messages.plan_turn(ctx)
548
+ task = asyncio.create_task(
549
+ stream(_gated_graph(saver, gate), messages, payload, config)
550
+ )
551
+ while await _tip_id(saver) == before:
552
+ await asyncio.sleep(0)
553
+ task.cancel()
554
+ with pytest.raises(asyncio.CancelledError):
555
+ await task
556
+ return before
557
+
558
+
559
+ async def test_retract_turn_before_output_forks_the_tip_back_and_drops_the_batch():
560
+ saver, index, graph, messages, state = await seeded()
561
+ ctx = Ctx("message-send", (user("u3", "three"),))
562
+ before = await _stop_after_input(saver, messages, ctx)
563
+ assert ids(state) == ["u1", "a0", "u2", "a1", "u3"]
564
+ assert messages.retractable
565
+ await messages.retract_turn()
566
+ assert (ctx.undo_count, ctx.durable_count) == (1, 1)
567
+ assert not messages.retractable
568
+ assert ids(state) == ["u1", "a0", "u2", "a1"]
569
+ assert await _tip_id(saver) != before
570
+ await turn(graph, messages, Ctx("message-send", (user("u4", "four"),)))
571
+ assert ids(state) == ["u1", "a0", "u2", "a1", "u4", "a2"]
572
+ _, rebooted = await booted(saver, index)
573
+ assert ids(rebooted) == ["u1", "a0", "u2", "a1", "u4", "a2"]
574
+
575
+
576
+ async def test_retract_turn_on_a_fresh_thread_leaves_it_empty():
577
+ saver, index = InMemorySaver(), MemoryCheckpointIndex()
578
+ messages, state = await booted(saver, index)
579
+ ctx = Ctx("message-send", (user("u1", "one"),))
580
+ await _stop_after_input(saver, messages, ctx)
581
+ await messages.retract_turn()
582
+ assert ids(state) == []
583
+ await turn(_graph(saver), messages, Ctx("message-send", (user("u2", "two"),)))
584
+ assert ids(state) == ["u2", "a0"]
585
+ _, rebooted = await booted(saver, index)
586
+ assert ids(rebooted) == ["u2", "a0"]
587
+
588
+
589
+ async def test_retract_turn_without_a_written_checkpoint_only_retracts_the_batch():
590
+ saver, index, graph, messages, state = await seeded()
591
+ before = await _tip_id(saver)
592
+ ctx = Ctx("message-send", (user("u3", "three"),))
593
+ await messages.plan_turn(ctx)
594
+ await messages.retract_turn()
595
+ assert ctx.undo_count == 1
596
+ assert ids(state) == ["u1", "a0", "u2", "a1"]
597
+ assert await _tip_id(saver) == before
598
+
599
+
600
+ async def test_turns_with_output_resumes_and_forks_are_not_retractable():
601
+ saver, index, graph, messages, state = await seeded()
602
+ ctx = Ctx("message-send", (user("u3", "three"),))
603
+ payload, config = await messages.plan_turn(ctx)
604
+ await stream(graph, messages, payload, config)
605
+ assert not messages.retractable
606
+ with pytest.raises(RuntimeError, match="not retractable"):
607
+ await messages.retract_turn()
608
+ await messages.ingest_turn()
609
+
610
+ await messages.plan_turn(Ctx("stop-continue", resume=True))
611
+ assert not messages.retractable
612
+ messages.abandon_turn()
613
+
614
+ await messages.plan_turn(Ctx("message-reload", has_rollback=True, rollback_to="u2"))
615
+ assert not messages.retractable
616
+ messages.abandon_turn()
617
+
618
+
619
+ async def test_frontier_confirmed_input_is_retracted_with_a_second_dur():
620
+ from pinned import Frontier, InstanceDurability
621
+ from pinned.domain import RealDomain
622
+ from pinned.testing import MemoryStateStore
623
+
624
+ from harness_sdk.langgraph import FrontierCheckpointer
625
+
626
+ durability = InstanceDurability(
627
+ MemoryStateStore(), "threads", THREAD, 0, RealDomain()
628
+ )
629
+ boundary = Frontier(durability, lambda: None, asyncio.create_task)
630
+ messages = None
631
+ wrapper = FrontierCheckpointer(
632
+ InMemorySaver(),
633
+ frontier=boundary,
634
+ on_checkpoint=lambda *args: messages.checkpoint_saved(*args),
635
+ )
636
+ messages, state = await booted(wrapper, MemoryCheckpointIndex())
637
+ ctx = Ctx("message-send", (user("u1", "one"),))
638
+ await _stop_after_input(wrapper, messages, ctx)
639
+ assert ctx.durable_count == 1
640
+ await messages.retract_turn()
641
+ assert (ctx.undo_count, ctx.durable_count) == (1, 2)
642
+ assert ids(state) == []
@@ -144,3 +144,52 @@ async def test_unconfirmed_rewind_replacement_can_be_enqueued_after_stop():
144
144
  assert [m["id"] for m in retry.ctx.messages] == ["m2"]
145
145
  retry.applied()
146
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())
@@ -48,6 +48,7 @@ class SinglesStore:
48
48
  self._inner.trays = dict(seed.trays)
49
49
  self.limit = limit
50
50
  self.crashed = False
51
+ self.died = asyncio.Event()
51
52
  self.log: list[tuple[Any, ...]] = []
52
53
 
53
54
  @property
@@ -57,6 +58,7 @@ class SinglesStore:
57
58
  def _mutate(self) -> None:
58
59
  if self.limit is not None and len(self.log) >= self.limit:
59
60
  self.crashed = True
61
+ self.died.set()
60
62
  raise RuntimeError(f"crashed at mutation {self.limit}")
61
63
 
62
64
  async def set_tray(self, ns, id, tray_ns, tray_id, marked, doc, epoch):
@@ -415,7 +417,14 @@ async def _drive(store, trace: Trace, drive, capabilities) -> None:
415
417
  async with instance(
416
418
  make_host(script, capabilities=capabilities), state_store=store
417
419
  ) as host:
418
- await drive(host.runs, script, trace, spawn)
420
+ # A crashed process stops driving: don't wait out the drive's timeouts.
421
+ driving = spawn(drive(host.runs, script, trace, spawn))
422
+ await asyncio.wait(
423
+ {driving, spawn(store.died.wait())},
424
+ return_when=asyncio.FIRST_COMPLETED,
425
+ )
426
+ if driving.done():
427
+ driving.result()
419
428
  finally:
420
429
  for task in tasks:
421
430
  task.cancel()
@@ -237,7 +237,7 @@ async def test_task_cancellation_settles_staged_intake():
237
237
  # The staged send merges into the cancelled run and settles with its id;
238
238
  # the staged stop settles accepted; the unconfirmed batch requeues ahead.
239
239
  assert await asyncio.wait_for(send2, 1) == {"runId": run_id}
240
- assert await asyncio.wait_for(stop, 1) is None
240
+ assert await asyncio.wait_for(stop, 1) == []
241
241
  await drv.wait_status("stopped")
242
242
  assert queue_ids(drv.replica, "queue") == ["m1", "m2"]
243
243
  assert runs.idle.is_set()
@@ -228,7 +228,7 @@ async def test_stop_verdict_awaits_the_settle_and_the_checkpoint_drain():
228
228
  await asyncio.sleep(0.02)
229
229
  assert not verdict.done() # the checkpoint drain is still open
230
230
  release.set()
231
- assert await asyncio.wait_for(verdict, 5) is None
231
+ assert await asyncio.wait_for(verdict, 5) == []
232
232
  assert durable.is_set() # every enlisted checkpoint durable before the verdict
233
233
 
234
234
 
@@ -0,0 +1,301 @@
1
+ """Contract: retraction of an applied batch. ``handle.undo()`` retracts a
2
+ visibly applied batch and every later one, newest first; ``dur()`` publishes
3
+ the retraction the way it publishes consumption. Settle returns retracted
4
+ items to the front of their lanes, the ``run/stop`` verdict carries them, and
5
+ the record re-presents as ``dispatching`` until the settle."""
6
+
7
+ import asyncio
8
+
9
+ import pytest
10
+ from pinned.testing import MemoryStateStore
11
+ from run_helpers import (
12
+ Script,
13
+ add,
14
+ assert_rejected,
15
+ edit_params,
16
+ msg,
17
+ queue_ids,
18
+ run_host,
19
+ )
20
+ from test_durability import flush, survivors
21
+ from test_run_ownership import ALICE, authorize, side
22
+
23
+ from harness_sdk import RunManager
24
+
25
+
26
+ def ids(record):
27
+ return [m["id"] for m in record["messages"]]
28
+
29
+
30
+ async def steer_and_take(drv, call, *message_ids):
31
+ for message_id in message_ids:
32
+ await drv.command("run/steer", add(message_id), terminal=False)
33
+ await drv.wait(lambda s: queue_ids(s, "steerQueue") == list(message_ids))
34
+ assert [m["id"] for m in call.ctx.steering.take()] == list(message_ids)
35
+ return call.ctx.applied()
36
+
37
+
38
+ async def test_undo_returns_the_dispatch_batch_to_the_queue_and_rides_the_stop_verdict():
39
+ script = Script()
40
+ async with run_host(script) as (drv, host):
41
+ drv.post("run/enqueue", add("m1"))
42
+ call = await script.next_call()
43
+ handle = call.ctx.applied()
44
+ handle.dur()
45
+ await drv.wait(lambda s: s["runs"] and s["runs"][0]["dispatching"] is None)
46
+ seq = drv.post("run/stop", {"runId": await drv.run_id()})
47
+ await asyncio.wait_for(call.ctx.stop_requested.wait(), 5)
48
+ handle.undo().dur()
49
+ await drv.wait(lambda s: s["runs"][0]["dispatching"] is not None)
50
+ assert ids(drv.run["dispatching"]) == ["m1"]
51
+ assert drv.run["dispatching"]["trigger"] == "message-send"
52
+ call.finish(RunManager.Stop(dispatch_queue=False))
53
+ verdict = await drv.res(seq)
54
+ assert verdict["type"] == "accepted"
55
+ assert [m["id"] for m in verdict["payload"]] == ["m1"]
56
+ await drv.wait_status("stopped")
57
+ assert queue_ids(drv.replica, "queue") == ["m1"]
58
+ assert drv.run["dispatching"] is None
59
+ script.no_call()
60
+
61
+
62
+ async def test_undo_cascades_over_later_takes_newest_first():
63
+ script = Script()
64
+ async with run_host(script) as (drv, host):
65
+ drv.post("run/enqueue", add("m1"))
66
+ call = await script.next_call()
67
+ first = call.ctx.applied()
68
+ first.dur()
69
+ second = await steer_and_take(drv, call, "s1")
70
+ second.dur()
71
+ third = await steer_and_take(drv, call, "s2")
72
+ third.dur()
73
+ await drv.command("run/steer", add("s3"), terminal=False)
74
+ first.undo().dur()
75
+ await drv.wait(lambda s: s["runs"][0]["dispatching"] is not None)
76
+ assert ids(drv.run["dispatching"]) == ["m1", "s1", "s2"]
77
+ seq = drv.post("run/stop", {"runId": await drv.run_id()})
78
+ await asyncio.wait_for(call.ctx.stop_requested.wait(), 5)
79
+ call.finish(RunManager.Stop(dispatch_queue=False))
80
+ assert [m["id"] for m in (await drv.res(seq))["payload"]] == ["m1", "s1", "s2"]
81
+ await drv.wait_status("stopped")
82
+ assert queue_ids(drv.replica, "queue") == ["m1"]
83
+ assert queue_ids(drv.replica, "steerQueue") == ["s1", "s2", "s3"]
84
+
85
+
86
+ async def test_undo_of_a_take_keeps_the_dispatch_and_frees_the_lane_for_a_continue():
87
+ script = Script()
88
+ async with run_host(script) as (drv, host):
89
+ drv.post("run/enqueue", add("m1"))
90
+ call = await script.next_call()
91
+ call.applied()
92
+ second = await steer_and_take(drv, call, "s1")
93
+ second.dur()
94
+ second.undo().dur()
95
+ await drv.wait(lambda s: s["runs"][0]["dispatching"] is not None)
96
+ assert drv.run["dispatching"]["trigger"] == "steer"
97
+ assert ids(drv.run["dispatching"]) == ["s1"]
98
+ call.finish(RunManager.Stop(dispatch_queue=False))
99
+ await drv.wait_status("stopped")
100
+ assert queue_ids(drv.replica, "queue") == []
101
+ assert queue_ids(drv.replica, "steerQueue") == ["s1"]
102
+ await drv.command("run/steer", add("s2"), terminal=False)
103
+ resumed = await script.next_call()
104
+ assert resumed.ctx.trigger == "stop-continue"
105
+ resumed.applied()
106
+ assert [m["id"] for m in resumed.ctx.steering.take()] == ["s1", "s2"]
107
+ resumed.applied()
108
+ resumed.finish(RunManager.Complete())
109
+ await drv.wait_status("ready")
110
+
111
+
112
+ async def test_an_undone_handle_settles_unconsumed_without_dur():
113
+ script = Script()
114
+ async with run_host(script) as (drv, host):
115
+ drv.post("run/enqueue", add("m1"))
116
+ call = await script.next_call()
117
+ handle = call.ctx.applied()
118
+ handle.dur()
119
+ handle.undo()
120
+ call.finish(RunManager.Error(dispatch_queue=False))
121
+ await drv.wait_status("error")
122
+ assert queue_ids(drv.replica, "queue") == ["m1"]
123
+
124
+
125
+ async def test_after_undo_applied_take_complete_and_dispatch_queue_raise():
126
+ script = Script()
127
+ async with run_host(script) as (drv, host):
128
+ drv.post("run/enqueue", add("m1"))
129
+ call = await script.next_call()
130
+ handle = call.ctx.applied()
131
+ handle.dur()
132
+ await drv.command("run/steer", add("s1"), terminal=False)
133
+ handle.undo().dur()
134
+ with pytest.raises(RuntimeError, match="undo"):
135
+ call.ctx.steering.take()
136
+ with pytest.raises(RuntimeError, match="undo"):
137
+ call.ctx.applied()
138
+ call.finish(RunManager.Stop(dispatch_queue=True))
139
+ await drv.wait_status("error")
140
+ assert drv.run["error"]["message"].startswith("dispatch_queue=True")
141
+ assert queue_ids(drv.replica, "queue") == ["m1"]
142
+ assert queue_ids(drv.replica, "steerQueue") == ["s1"]
143
+
144
+ script = Script()
145
+ async with run_host(script) as (drv, host):
146
+ drv.post("run/enqueue", add("m1"))
147
+ call = await script.next_call()
148
+ handle = call.ctx.applied()
149
+ handle.dur()
150
+ handle.undo().dur()
151
+ call.finish(RunManager.Complete())
152
+ await drv.wait_status("error")
153
+ assert queue_ids(drv.replica, "queue") == ["m1"]
154
+
155
+
156
+ async def test_undo_rejects_rewind_and_input_carrying_dispatches():
157
+ script = Script()
158
+ script.thread["u1"] = {"parentId": None, "role": "user", "isLeaf": True}
159
+ async with run_host(script, capabilities=("rewind",)) as (drv, host):
160
+ drv.post("run/edit", edit_params("u1", msg("u2"), anchor=None))
161
+ rerun = await script.next_call()
162
+ handle = rerun.ctx.applied()
163
+ with pytest.raises(RuntimeError, match="rewind"):
164
+ handle.undo()
165
+ handle.dur()
166
+ rerun.finish(RunManager.Complete())
167
+ await drv.wait_status("ready")
168
+
169
+ script = Script()
170
+ async with run_host(script) as (drv, host):
171
+ drv.post("run/enqueue", add("m1"))
172
+ call = await script.next_call()
173
+ call.applied()
174
+ call.finish(
175
+ RunManager.InputRequired(
176
+ ({"id": "r1", "type": "tool-approval", "toolCallId": "tc1"},)
177
+ )
178
+ )
179
+ await drv.wait_status("input-required")
180
+ await drv.command("run/steer", add("s1"), terminal=False)
181
+ resumed = await script.next_call()
182
+ assert resumed.ctx.trigger == "message-send"
183
+ handle = resumed.ctx.applied()
184
+ with pytest.raises(RuntimeError, match="input"):
185
+ handle.undo()
186
+ handle.dur()
187
+ resumed.finish(RunManager.Complete())
188
+ await drv.wait_status("ready")
189
+
190
+
191
+ async def test_a_published_full_retraction_redispatches_fresh_after_a_crash():
192
+ store = MemoryStateStore()
193
+ script = Script()
194
+ async with run_host(script, store=store) as (drv, host):
195
+ drv.post("run/enqueue", add("m1"))
196
+ call = await script.next_call()
197
+ first = call.ctx.applied()
198
+ first.dur()
199
+ second = await steer_and_take(drv, call, "s1")
200
+ second.dur()
201
+ first.undo().dur()
202
+ await flush(host)
203
+ crashed = survivors(store)
204
+
205
+ script2 = Script()
206
+ async with run_host(script2, store=crashed) as (drv2, host2):
207
+ redelivered = await script2.next_call()
208
+ assert redelivered.ctx.resume is False
209
+ assert [m["id"] for m in redelivered.ctx.messages] == ["m1"]
210
+ await drv2.wait(lambda s: queue_ids(s, "steerQueue") == ["s1"])
211
+ redelivered.applied()
212
+ assert [m["id"] for m in redelivered.ctx.steering.take()] == ["s1"]
213
+ redelivered.applied()
214
+ redelivered.finish(RunManager.Complete())
215
+ await drv2.wait_status("ready")
216
+
217
+
218
+ async def test_a_published_partial_retraction_resumes_with_the_take_re_presented():
219
+ store = MemoryStateStore()
220
+ script = Script()
221
+ async with run_host(script, store=store) as (drv, host):
222
+ drv.post("run/enqueue", add("m1"))
223
+ call = await script.next_call()
224
+ call.applied()
225
+ second = await steer_and_take(drv, call, "s1")
226
+ second.dur()
227
+ second.undo().dur()
228
+ await flush(host)
229
+ crashed = survivors(store)
230
+
231
+ script2 = Script()
232
+ async with run_host(script2, store=crashed) as (drv2, host2):
233
+ redelivered = await script2.next_call()
234
+ assert redelivered.ctx.resume is True
235
+ await drv2.wait(lambda s: queue_ids(s, "steerQueue") == ["s1"])
236
+ assert [m["id"] for m in redelivered.ctx.steering.take()] == ["s1"]
237
+ redelivered.applied()
238
+ redelivered.finish(RunManager.Complete())
239
+ await drv2.wait_status("ready")
240
+
241
+
242
+ async def test_a_retracted_id_is_queued_again_for_dequeue_and_resend():
243
+ script = Script()
244
+ async with run_host(script) as (drv, host):
245
+ drv.post("run/enqueue", add("m1"))
246
+ call = await script.next_call()
247
+ handle = call.ctx.applied()
248
+ handle.dur()
249
+ handle.undo().dur()
250
+ call.finish(RunManager.Stop(dispatch_queue=False))
251
+ await drv.wait_status("stopped")
252
+ run_id = drv.run["runId"]
253
+ assert (await drv.command("run/dequeue", {"runId": run_id, "messageId": "m1"}))[
254
+ "type"
255
+ ] == "accepted"
256
+ assert queue_ids(drv.replica, "queue") == []
257
+ await drv.command("run/enqueue", add("m1", run_id=run_id), terminal=False)
258
+ resent = await script.next_call()
259
+ assert resent.ctx.trigger == "stop-continue"
260
+ assert [m["id"] for m in resent.ctx.messages] == ["m1"]
261
+ resent.applied()
262
+ resent.finish(RunManager.Complete())
263
+ await drv.wait_status("ready")
264
+
265
+
266
+ async def test_the_run_principal_may_dequeue_another_authors_retracted_item():
267
+ script = Script()
268
+ async with run_host(script, authorize=authorize, headers=ALICE) as (drv, host):
269
+ drv.post("run/enqueue", add("m1"))
270
+ call = await script.next_call()
271
+ call.applied()
272
+ run_id = await drv.run_id()
273
+ rsp = await side(
274
+ drv._app,
275
+ drv._client,
276
+ "run/steer",
277
+ {"runId": run_id, "message": msg("s1")},
278
+ user="bob",
279
+ )
280
+ assert rsp["type"] == "result", rsp
281
+ await drv.wait(lambda s: queue_ids(s, "steerQueue") == ["s1"])
282
+ assert [m["id"] for m in call.ctx.steering.take()] == ["s1"]
283
+ take = call.ctx.applied()
284
+ take.dur()
285
+ take.undo().dur()
286
+ call.finish(RunManager.Stop(dispatch_queue=False))
287
+ await drv.wait_status("stopped")
288
+ assert_rejected(
289
+ await side(
290
+ drv._app,
291
+ drv._client,
292
+ "run/dequeue",
293
+ {"runId": run_id, "messageId": "s1"},
294
+ user="mallory",
295
+ ),
296
+ "not-owner",
297
+ )
298
+ assert (await drv.command("run/dequeue", {"runId": run_id, "messageId": "s1"}))[
299
+ "type"
300
+ ] == "accepted"
301
+ assert queue_ids(drv.replica, "steerQueue") == []