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.
- {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/PKG-INFO +1 -1
- {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/pyproject.toml +1 -1
- {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/src/harness_sdk/deepagents.py +47 -12
- {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/src/harness_sdk/run_manager.py +108 -17
- {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/tests/test_deepagents_messages.py +132 -0
- {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/tests/test_run_admission.py +49 -0
- {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/tests/test_run_crash_sweep.py +10 -1
- {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/tests/test_settle.py +1 -1
- {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/tests/test_stop_continue.py +1 -1
- harness_sdk_python-0.36.6/tests/test_undo.py +301 -0
- {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/.gitignore +0 -0
- {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/README.md +0 -0
- {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/examples/__init__.py +0 -0
- {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/examples/runs_app.py +0 -0
- {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/src/harness_sdk/__init__.py +0 -0
- {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/src/harness_sdk/checkpoints.py +0 -0
- {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/src/harness_sdk/fenced_postgres.py +0 -0
- {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/src/harness_sdk/langgraph.py +0 -0
- {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/src/harness_sdk/linear_thread.py +0 -0
- {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/src/harness_sdk/protocol.py +0 -0
- {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/src/harness_sdk/voice.py +0 -0
- {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/tests/run_helpers.py +0 -0
- {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/tests/test_admit_steer.py +0 -0
- {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/tests/test_applied_visibility.py +0 -0
- {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/tests/test_batches.py +0 -0
- {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/tests/test_branch_anchor.py +0 -0
- {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/tests/test_checkpoints.py +0 -0
- {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/tests/test_dispatching.py +0 -0
- {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/tests/test_durability.py +0 -0
- {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/tests/test_edit_dispatched.py +0 -0
- {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/tests/test_edit_reload.py +0 -0
- {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/tests/test_enqueue.py +0 -0
- {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/tests/test_fenced_postgres.py +0 -0
- {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/tests/test_input_required.py +0 -0
- {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/tests/test_langgraph.py +0 -0
- {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/tests/test_linear_thread.py +0 -0
- {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/tests/test_meta.py +0 -0
- {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/tests/test_mint_concurrency.py +0 -0
- {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/tests/test_outcomes.py +0 -0
- {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/tests/test_placement.py +0 -0
- {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/tests/test_prepare_hooks.py +0 -0
- {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/tests/test_restore.py +0 -0
- {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/tests/test_rewind_during_run.py +0 -0
- {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/tests/test_run_eviction.py +0 -0
- {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/tests/test_run_ownership.py +0 -0
- {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/tests/test_run_sync.py +0 -0
- {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/tests/test_steer.py +0 -0
- {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/tests/test_stopping_window.py +0 -0
- {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/tests/test_voice.py +0 -0
- {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/tests/test_voice_delegation.py +0 -0
- {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/tests/test_voice_frontend_tools.py +0 -0
- {harness_sdk_python-0.36.4 → harness_sdk_python-0.36.6}/tests/voice_helpers.py +0 -0
|
@@ -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
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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(
|
|
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(
|
|
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 = {
|
|
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.
|
|
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
|
-
|
|
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
|
|
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.
|
|
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()``
|
|
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
|
-
"""
|
|
1686
|
+
"""Publish the current intent durably; idempotent until the dispatch settles."""
|
|
1640
1687
|
self._ctx._ensure_active()
|
|
1641
|
-
|
|
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
|
-
|
|
1646
|
-
|
|
1647
|
-
if
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
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
|
-
|
|
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)
|
|
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)
|
|
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") == []
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|