harness-sdk-python 0.7.2__tar.gz → 0.8.0__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 (29) hide show
  1. {harness_sdk_python-0.7.2 → harness_sdk_python-0.8.0}/PKG-INFO +1 -1
  2. {harness_sdk_python-0.7.2 → harness_sdk_python-0.8.0}/pyproject.toml +1 -1
  3. {harness_sdk_python-0.7.2 → harness_sdk_python-0.8.0}/src/harness_sdk/run_manager.py +49 -31
  4. {harness_sdk_python-0.7.2 → harness_sdk_python-0.8.0}/tests/run_helpers.py +1 -1
  5. {harness_sdk_python-0.7.2 → harness_sdk_python-0.8.0}/tests/test_ack_visibility.py +15 -1
  6. {harness_sdk_python-0.7.2 → harness_sdk_python-0.8.0}/tests/test_dispatching.py +59 -9
  7. {harness_sdk_python-0.7.2 → harness_sdk_python-0.8.0}/tests/test_input_required.py +30 -0
  8. {harness_sdk_python-0.7.2 → harness_sdk_python-0.8.0}/tests/test_meta.py +7 -3
  9. {harness_sdk_python-0.7.2 → harness_sdk_python-0.8.0}/tests/test_run_leaf.py +39 -10
  10. {harness_sdk_python-0.7.2 → harness_sdk_python-0.8.0}/tests/test_settle.py +4 -1
  11. {harness_sdk_python-0.7.2 → harness_sdk_python-0.8.0}/tests/test_steer.py +2 -0
  12. {harness_sdk_python-0.7.2 → harness_sdk_python-0.8.0}/tests/test_stop_continue.py +1 -0
  13. {harness_sdk_python-0.7.2 → harness_sdk_python-0.8.0}/.gitignore +0 -0
  14. {harness_sdk_python-0.7.2 → harness_sdk_python-0.8.0}/README.md +0 -0
  15. {harness_sdk_python-0.7.2 → harness_sdk_python-0.8.0}/src/harness_sdk/__init__.py +0 -0
  16. {harness_sdk_python-0.7.2 → harness_sdk_python-0.8.0}/src/harness_sdk/fenced_postgres.py +0 -0
  17. {harness_sdk_python-0.7.2 → harness_sdk_python-0.8.0}/src/harness_sdk/linear_thread.py +0 -0
  18. {harness_sdk_python-0.7.2 → harness_sdk_python-0.8.0}/tests/test_batches.py +0 -0
  19. {harness_sdk_python-0.7.2 → harness_sdk_python-0.8.0}/tests/test_branch_anchor.py +0 -0
  20. {harness_sdk_python-0.7.2 → harness_sdk_python-0.8.0}/tests/test_edit_dispatched.py +0 -0
  21. {harness_sdk_python-0.7.2 → harness_sdk_python-0.8.0}/tests/test_edit_reload.py +0 -0
  22. {harness_sdk_python-0.7.2 → harness_sdk_python-0.8.0}/tests/test_enqueue.py +0 -0
  23. {harness_sdk_python-0.7.2 → harness_sdk_python-0.8.0}/tests/test_facade.py +0 -0
  24. {harness_sdk_python-0.7.2 → harness_sdk_python-0.8.0}/tests/test_fenced_postgres.py +0 -0
  25. {harness_sdk_python-0.7.2 → harness_sdk_python-0.8.0}/tests/test_linear_thread.py +0 -0
  26. {harness_sdk_python-0.7.2 → harness_sdk_python-0.8.0}/tests/test_outcomes.py +0 -0
  27. {harness_sdk_python-0.7.2 → harness_sdk_python-0.8.0}/tests/test_placement.py +0 -0
  28. {harness_sdk_python-0.7.2 → harness_sdk_python-0.8.0}/tests/test_prepare_hooks.py +0 -0
  29. {harness_sdk_python-0.7.2 → harness_sdk_python-0.8.0}/tests/test_rewind_during_run.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: harness-sdk-python
3
- Version: 0.7.2
3
+ Version: 0.8.0
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.7.2"
3
+ version = "0.8.0"
4
4
  description = "RunManager: the harness-sdk runs subsystem for Python Statewire hosts"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -14,10 +14,12 @@ and settle when their run ends — accepted once the run has acked its
14
14
  messages, rejected otherwise; ``run/stop`` awaits a future the drain resolves
15
15
  once the in-flight run has ended.
16
16
 
17
- ``ctx.ack_messages()`` is bookkeeping: it clears ``dispatching`` and confirms
18
- ``runLeafMessageId``. A run that ends with entries still in ``dispatching``
19
- reverts them by provenance: lane items to the front of their lane, direct
20
- dispatches to the front of the queue, rewind replacements dropped.
17
+ ``entry["dispatching"]`` is the dispatch record (or None): the payload the
18
+ run callback receives. ``ctx.ack()`` clears it and confirms
19
+ ``runLeafMessageId``; ``steering.take()`` may refill it at most one unacked
20
+ batch ever exists. A run that ends with an unacked record reverts its items
21
+ by provenance: lane items to the front of their lane, direct dispatches to
22
+ the front of the queue, rewind replacements dropped.
21
23
  """
22
24
 
23
25
  import asyncio
@@ -51,6 +53,7 @@ _TRIGGERS = (
51
53
  "input-resume",
52
54
  "error-continue",
53
55
  "stop-continue",
56
+ "steer",
54
57
  )
55
58
 
56
59
  _DECISIONS = ("approve", "reject", "edit", "respond")
@@ -242,7 +245,7 @@ class RunManager:
242
245
  "status": "ready",
243
246
  "queue": [],
244
247
  "steerQueue": [],
245
- "dispatching": [],
248
+ "dispatching": None,
246
249
  "error": None,
247
250
  "runLeafMessageId": None,
248
251
  }
@@ -435,28 +438,30 @@ class RunManager:
435
438
  if self._task is not None:
436
439
  raise RuntimeError("a run is already in flight")
437
440
  message_meta = {m["id"]: m.get("meta") for m in messages}
438
- dispatching = list(messages)
441
+ items = list(messages)
439
442
  messages = [
440
443
  {k: v for k, v in message.items() if k != "meta"} for message in messages
441
444
  ]
442
- record: dict[str, Any] = {"trigger": trigger, "messages": list(messages)}
445
+ record: dict[str, Any] = {"trigger": trigger, "messages": items}
443
446
  if rollback_to is not _ABSENT:
444
447
  record["rollbackTo"] = rollback_to
445
448
  if input_outcomes:
446
- record["inputOutcomes"] = input_outcomes
447
- self._dispatch_record = record
449
+ record["inputOutcomes"] = [
450
+ {"request": request, "response": response, "meta": meta}
451
+ for request, response, meta in input_outcomes
452
+ ]
453
+ self._dispatch_record = dict(record)
448
454
  self._stop_reason = None
449
455
  self._run_acked = False
450
456
  entry = self._ensure_entry()
451
457
  self._leaf_confirmed = plain(entry["runLeafMessageId"])
452
- entry["dispatching"] = dispatching
458
+ entry["dispatching"] = record
453
459
  entry["error"] = None
454
460
  if messages:
455
461
  self._dispatched_ids = tuple(m["id"] for m in messages)
456
462
  if rollback_to is not _ABSENT:
457
- entry["runLeafMessageId"] = rollback_to
458
- if messages:
459
- entry["runLeafMessageId"] = messages[-1]["id"]
463
+ # The true leaf is the not-yet-created message; ack records it.
464
+ entry["runLeafMessageId"] = None
460
465
  entry["runId"] = uuid.uuid4().hex
461
466
  self._set_status("running")
462
467
  ctx = RunManager.RunContext(
@@ -523,7 +528,7 @@ class RunManager:
523
528
  elif isinstance(outcome, RunManager.Error):
524
529
  error = _reject("run-error", "run ended in error")
525
530
  elif isinstance(outcome, RunManager.Stop):
526
- error = _reject("stopped", "run stopped before the messages-ack")
531
+ error = _reject("stopped", "run stopped before the ack")
527
532
  else:
528
533
  error = None
529
534
  self._settle_initiators(error)
@@ -532,8 +537,8 @@ class RunManager:
532
537
  self._drain()
533
538
 
534
539
  async def _pull_leaf(self) -> None:
535
- # An end with unacked messages reverts instead of recording a leaf.
536
- if plain(self._entry()["dispatching"]):
540
+ # An end with an unacked batch reverts instead of recording a leaf.
541
+ if plain(self._entry()["dispatching"]) is not None:
537
542
  return
538
543
  self._entry()["runLeafMessageId"] = await self._thread.get_leaf_message_id()
539
544
 
@@ -553,16 +558,25 @@ class RunManager:
553
558
  else:
554
559
  future.set_exception(error)
555
560
 
556
- def _ack_messages(self) -> None:
561
+ def _ack(self) -> None:
562
+ entry = self._entry()
563
+ record = plain(entry["dispatching"])
564
+ if record is None:
565
+ raise RuntimeError("ack() with no unacked batch")
557
566
  self._dispatching = []
558
- self._entry()["dispatching"] = []
567
+ entry["dispatching"] = None
559
568
  self._run_acked = True
560
- self._leaf_confirmed = plain(self._entry()["runLeafMessageId"])
569
+ if record["messages"]:
570
+ entry["runLeafMessageId"] = record["messages"][-1]["id"]
571
+ self._leaf_confirmed = plain(entry["runLeafMessageId"])
561
572
 
562
573
  def _revert_dispatching(self) -> None:
563
574
  taken, self._dispatching = self._dispatching, []
564
575
  entry = self._ensure_entry()
565
- entry["dispatching"] = []
576
+ record = plain(entry["dispatching"])
577
+ entry["dispatching"] = None
578
+ if record is not None and "rollbackTo" in record:
579
+ entry["runLeafMessageId"] = self._leaf_confirmed
566
580
  if not taken:
567
581
  return
568
582
  for lane in ("steerQueue", "queue"):
@@ -571,8 +585,6 @@ class RunManager:
571
585
  entry[lane] = front + self._lane_items(lane)
572
586
  ids = {item["id"] for _, item in taken}
573
587
  self._dispatched_ids = tuple(id for id in self._dispatched_ids if id not in ids)
574
- if plain(entry["runLeafMessageId"]) in ids:
575
- entry["runLeafMessageId"] = self._leaf_confirmed
576
588
 
577
589
  def _dispatchable(self, lane: str) -> bool:
578
590
  if self._task is not None or self._staged_rewinds:
@@ -600,8 +612,11 @@ class RunManager:
600
612
  if steer:
601
613
  self._entry()["steerQueue"] = []
602
614
  self._dispatching.extend(("steerQueue", item) for item in steer)
603
- self._dispatching.append(("queue", self._stamped(e.message, e.meta)))
604
- self._ensure_entry()["dispatching"] = [item for _, item in self._dispatching]
615
+ self._dispatching.append((e.lane, self._stamped(e.message, e.meta)))
616
+ self._ensure_entry()["dispatching"] = {
617
+ "trigger": "message-send",
618
+ "messages": [item for _, item in self._dispatching],
619
+ }
605
620
 
606
621
  def _dispatch_staged(
607
622
  self, *, input_outcomes: tuple[tuple[dict[str, Any], Any, Any], ...] = ()
@@ -1233,16 +1248,18 @@ class RunManager:
1233
1248
  _ctx: "RunManager.RunContext"
1234
1249
 
1235
1250
  def take(self) -> tuple[dict[str, Any], ...]:
1236
- """Drain the steer lane into this run; items leave the projection, retained until the ack."""
1251
+ """Drain the steer lane into a fresh dispatch record; requires the current batch to be acked."""
1237
1252
  self._ctx._ensure_active()
1238
1253
  manager = self._ctx._manager
1239
- items = manager._lane_items("steerQueue")
1240
1254
  entry = manager._entry()
1255
+ if plain(entry["dispatching"]) is not None:
1256
+ raise RuntimeError("take before the current batch is acked")
1257
+ items = manager._lane_items("steerQueue")
1258
+ if not items:
1259
+ return ()
1241
1260
  entry["steerQueue"] = []
1242
1261
  manager._dispatching.extend(("steerQueue", item) for item in items)
1243
- entry["dispatching"] = list(plain(entry["dispatching"])) + items
1244
- if items:
1245
- entry["runLeafMessageId"] = items[-1]["id"]
1262
+ entry["dispatching"] = {"trigger": "steer", "messages": items}
1246
1263
  self._ctx._message_meta.update(
1247
1264
  {item["id"]: item.get("meta") for item in items}
1248
1265
  )
@@ -1301,9 +1318,10 @@ class RunManager:
1301
1318
  if self._manager._ctx is not self:
1302
1319
  raise RuntimeError("this run has already settled")
1303
1320
 
1304
- def ack_messages(self) -> None:
1321
+ def ack(self) -> None:
1322
+ """Ack the current batch: clears the dispatch record and confirms the run leaf."""
1305
1323
  self._ensure_active()
1306
- self._manager._ack_messages()
1324
+ self._manager._ack()
1307
1325
 
1308
1326
  def set_recovery_state(self, value: Any) -> None:
1309
1327
  self._ensure_active()
@@ -16,7 +16,7 @@ class Call:
16
16
  outcome: "asyncio.Future[Any]"
17
17
 
18
18
  def ack(self) -> None:
19
- self.ctx.ack_messages()
19
+ self.ctx.ack()
20
20
 
21
21
  def finish(self, outcome: Any) -> None:
22
22
  self.outcome.set_result(outcome)
@@ -24,7 +24,8 @@ def covered_state(drv, seq):
24
24
 
25
25
  def dispatching_ids(state):
26
26
  runs = state.get("runs") or [{}]
27
- return [item["id"] for item in runs[0].get("dispatching", [])]
27
+ record = runs[0].get("dispatching")
28
+ return [item["id"] for item in record["messages"]] if record else []
28
29
 
29
30
 
30
31
  def assert_never_queued(drv, message_id):
@@ -71,6 +72,19 @@ async def test_unacked_run_end_reverts_the_direct_dispatch_to_the_queue():
71
72
  assert queue_ids(drv.replica, "queue") == ["m1"]
72
73
 
73
74
 
75
+ async def test_unacked_run_end_reverts_the_direct_steer_send_to_the_steer_lane():
76
+ script = Script()
77
+ async with run_host(script) as (drv, host):
78
+ seq = drv.post("run/steer", add("s1"))
79
+ call = await script.next_call()
80
+ assert (await drv.res(seq))["type"] == "accepted"
81
+ assert dispatching_ids(covered_state(drv, seq)) == ["s1"]
82
+ call.fail(RuntimeError("boom"))
83
+ await drv.wait_status("error")
84
+ assert queue_ids(drv.replica, "steerQueue") == ["s1"]
85
+ assert queue_ids(drv.replica, "queue") == []
86
+
87
+
74
88
  async def test_rewind_edit_acks_with_its_dispatch():
75
89
  script = Script()
76
90
  script.thread["u1"] = {"parentId": None, "role": "user", "isLeaf": True}
@@ -1,13 +1,14 @@
1
- """Contract: replicated dispatch limbo. ``dispatching`` on the run entry is
2
- the current dispatch's unacked messagesqueue pops, steer-lane takes, and
3
- rewind dispatches alike. A message item lives in exactly one of the lanes,
4
- ``dispatching``, or the thread, and every transition is single-envelope
5
- atomic: dispatch moves it lane ``dispatching``, the messages-ack clears
6
- ``dispatching`` as the canonical message appears, and an unacked run end
7
- returns lane items to the front of their lane."""
1
+ """Contract: the replicated dispatch record. ``dispatching`` on the run entry
2
+ is the current unacked batcha record ``{trigger, messages, rollbackTo?,
3
+ inputOutcomes?}`` or None. A message item lives in exactly one of the lanes,
4
+ the record's ``messages``, or the thread, and every transition is
5
+ single-envelope atomic: dispatch fills the record, ``ctx.ack()`` clears it as
6
+ the canonical message appears, a steer take refills it, and an unacked run
7
+ end returns lane items to the front of their lane."""
8
8
 
9
9
  import copy
10
10
 
11
+ import pytest
11
12
  from run_helpers import Script, add, msg, queue_ids, run_host
12
13
 
13
14
  from harness_sdk import RunManager
@@ -16,16 +17,20 @@ from statewire_helpers import apply_ops
16
17
 
17
18
  def dispatching_ids(replica):
18
19
  runs = replica["runs"]
19
- return [item["id"] for item in runs[0]["dispatching"]] if runs else []
20
+ record = runs[0]["dispatching"] if runs else None
21
+ return [item["id"] for item in record["messages"]] if record else []
20
22
 
21
23
 
22
24
  def places(state, message_id):
23
25
  entry = (state.get("runs") or [{}])[0]
24
26
  found = {
25
27
  name
26
- for name in ("queue", "steerQueue", "dispatching")
28
+ for name in ("queue", "steerQueue")
27
29
  if any(item["id"] == message_id for item in entry.get(name, []))
28
30
  }
31
+ record = entry.get("dispatching")
32
+ if record and any(item["id"] == message_id for item in record["messages"]):
33
+ found.add("dispatching")
29
34
  if any(m["id"] == message_id for m in state.get("messages", [])):
30
35
  found.add("messages")
31
36
  return found
@@ -53,6 +58,7 @@ async def test_idle_send_dispatches_straight_into_dispatching():
53
58
  drv.post("run/enqueue", add("m1"))
54
59
  await script.next_call()
55
60
  await drv.wait_status("running")
61
+ assert drv.run["dispatching"]["trigger"] == "message-send"
56
62
  assert dispatching_ids(drv.replica) == ["m1"]
57
63
  assert queue_ids(drv.replica, "queue") == []
58
64
  assert residence(drv, "m1") == ["dispatching"]
@@ -97,6 +103,7 @@ async def test_steering_take_moves_the_lane_into_dispatching():
97
103
  await drv.command("run/steer", add("s1"), terminal=False)
98
104
  call.ctx.steering.take()
99
105
  await drv.wait(lambda s: dispatching_ids(s) == ["s1"])
106
+ assert drv.run["dispatching"]["trigger"] == "steer"
100
107
  assert queue_ids(drv.replica, "steerQueue") == []
101
108
  assert residence(drv, "s1") == ["steerQueue", "dispatching"]
102
109
  host.live.state["messages"] = [msg("m1"), msg("s1")]
@@ -121,3 +128,46 @@ async def test_edit_replacement_rides_dispatching_until_ack():
121
128
  assert residence(drv, "u2") == ["dispatching", "messages"]
122
129
  rerun.finish(RunManager.Complete())
123
130
  assert (await drv.res(seq))["type"] == "accepted"
131
+
132
+
133
+ async def test_take_before_the_ack_raises():
134
+ script = Script()
135
+ async with run_host(script) as (drv, host):
136
+ drv.post("run/enqueue", add("m1"))
137
+ call = await script.next_call()
138
+ await drv.side("run/steer", add("s1"))
139
+ await drv.wait(lambda s: queue_ids(s, "steerQueue") == ["s1"])
140
+ with pytest.raises(RuntimeError, match="before the current batch is acked"):
141
+ call.ctx.steering.take()
142
+ call.ack()
143
+ assert [m["id"] for m in call.ctx.steering.take()] == ["s1"]
144
+ call.ack()
145
+ call.finish(RunManager.Complete())
146
+
147
+
148
+ async def test_ack_with_no_unacked_batch_raises():
149
+ script = Script()
150
+ async with run_host(script) as (drv, host):
151
+ drv.post("run/enqueue", add("m1"))
152
+ call = await script.next_call()
153
+ call.ack()
154
+ with pytest.raises(RuntimeError, match="no unacked batch"):
155
+ call.ack()
156
+ call.finish(RunManager.Complete())
157
+
158
+
159
+ async def test_second_take_after_the_reack_drains_the_next_batch():
160
+ script = Script()
161
+ async with run_host(script) as (drv, host):
162
+ drv.post("run/enqueue", add("m1"))
163
+ call = await script.next_call()
164
+ call.ack()
165
+ await drv.command("run/steer", add("s1"), terminal=False)
166
+ assert [m["id"] for m in call.ctx.steering.take()] == ["s1"]
167
+ call.ack()
168
+ await drv.command("run/steer", add("s2", anchor="m1"), terminal=False)
169
+ assert [m["id"] for m in call.ctx.steering.take()] == ["s2"]
170
+ await drv.wait(lambda s: dispatching_ids(s) == ["s2"])
171
+ assert drv.run["dispatching"]["trigger"] == "steer"
172
+ call.ack()
173
+ call.finish(RunManager.Complete())
@@ -79,6 +79,36 @@ async def test_completing_answer_resumes_with_ordered_responses():
79
79
  await drv.wait_status("ready")
80
80
 
81
81
 
82
+ async def test_input_outcomes_ride_the_dispatch_record_until_the_ack():
83
+ script = Script()
84
+ async with run_host(script) as (drv, host):
85
+ await park(drv, script, [tool_call("r1"), approval("r2")])
86
+ await drv.command(
87
+ "run/input", {"requestId": "r2", "response": {"decision": "approve"}}
88
+ )
89
+ await drv.command(
90
+ "run/input", {"requestId": "r1", "response": {"output": "ok"}}
91
+ )
92
+ resumed = await script.next_call()
93
+ await drv.wait(lambda s: run_of(s).get("dispatching") is not None)
94
+ assert drv.run["dispatching"] == {
95
+ "trigger": "input-resume",
96
+ "messages": [],
97
+ "inputOutcomes": [
98
+ {"request": tool_call("r1"), "response": {"output": "ok"}, "meta": None},
99
+ {
100
+ "request": approval("r2"),
101
+ "response": {"decision": "approve"},
102
+ "meta": None,
103
+ },
104
+ ],
105
+ }
106
+ resumed.ack()
107
+ await drv.wait(lambda s: run_of(s).get("dispatching") is None)
108
+ resumed.finish(RunManager.Complete())
109
+ await drv.wait_status("ready")
110
+
111
+
82
112
  async def test_completing_batch_resumes_once_in_the_same_envelope():
83
113
  script = Script()
84
114
  async with run_host(script) as (drv, host):
@@ -131,10 +131,14 @@ async def test_meta_dies_with_a_dequeued_entry():
131
131
  async def test_unacked_run_end_reprojects_entries_with_meta():
132
132
  script = Script()
133
133
  async with run_host(script) as (drv, _):
134
- drv.post("run/enqueue", add("m1", meta={"clientId": "c1"}))
134
+ drv.batch(
135
+ [
136
+ ("run/enqueue", add("m1", meta={"clientId": "c1"})),
137
+ ("run/steer", add("s1", meta={"clientId": "c2"})),
138
+ ]
139
+ )
135
140
  call = await script.next_call()
136
- await drv.side("run/steer", add("s1", meta={"clientId": "c2"}))
137
- call.ctx.steering.take()
141
+ assert [m["id"] for m in call.ctx.messages] == ["m1", "s1"]
138
142
  call.fail(RuntimeError("boom"))
139
143
  await drv.wait_status("error")
140
144
  assert drv.run["queue"][0]["meta"] == {"clientId": "c1"}
@@ -30,15 +30,18 @@ async def test_ready_state_has_no_run_entry():
30
30
  assert drv.replica["runs"] == []
31
31
 
32
32
 
33
- async def test_enqueue_dispatch_sets_the_leaf():
33
+ async def test_ack_sets_the_leaf_to_the_last_batch_message():
34
34
  script = Script()
35
35
  async with run_host(script) as (drv, host):
36
36
  drv.post("run/enqueue", add("m1"))
37
- await script.next_call()
37
+ call = await script.next_call()
38
+ await drv.wait_status("running")
39
+ assert drv.run["runLeafMessageId"] is None
40
+ call.ack()
38
41
  await drv.wait(lambda s: run_of(s).get("runLeafMessageId") == "m1")
39
42
 
40
43
 
41
- async def test_steer_dispatch_sets_the_leaf_to_the_last_lane_item():
44
+ async def test_steer_batch_ack_sets_the_leaf_to_the_last_lane_item():
42
45
  script = Script()
43
46
  async with run_host(script) as (drv, host):
44
47
  drv.post("run/enqueue", add("m1"))
@@ -49,10 +52,12 @@ async def test_steer_dispatch_sets_the_leaf_to_the_last_lane_item():
49
52
  call.finish(RunManager.Complete())
50
53
  steered = await script.next_call()
51
54
  assert steered.ctx.messages[-1]["id"] == "s2"
55
+ assert drv.run["runLeafMessageId"] == "m1"
56
+ steered.ack()
52
57
  await drv.wait(lambda s: run_of(s).get("runLeafMessageId") == "s2")
53
58
 
54
59
 
55
- async def test_steering_take_sets_the_leaf():
60
+ async def test_steering_take_moves_the_leaf_at_its_ack():
56
61
  script = Script()
57
62
  async with run_host(script) as (drv, host):
58
63
  drv.post("run/enqueue", add("m1"))
@@ -61,8 +66,9 @@ async def test_steering_take_sets_the_leaf():
61
66
  await drv.command("run/steer", add("s1", anchor="m1"), terminal=False)
62
67
  await drv.wait(lambda s: len(run_of(s).get("steerQueue", [])) == 1)
63
68
  call.ctx.steering.take()
64
- await drv.wait(lambda s: run_of(s).get("runLeafMessageId") == "s1")
69
+ assert drv.run["runLeafMessageId"] == "m1"
65
70
  call.ack()
71
+ await drv.wait(lambda s: run_of(s).get("runLeafMessageId") == "s1")
66
72
  call.finish(RunManager.Complete())
67
73
 
68
74
 
@@ -133,30 +139,53 @@ async def test_complete_with_a_queued_item_keeps_the_leaf_flowing():
133
139
  script.leaf = "a1"
134
140
  call.finish(RunManager.Complete())
135
141
  drain = await script.next_call()
136
- await drv.wait(lambda s: run_of(s).get("runLeafMessageId") == "m2")
137
142
  drain.ack()
143
+ await drv.wait(lambda s: run_of(s).get("runLeafMessageId") == "m2")
138
144
  drain.finish(RunManager.Complete())
139
145
  await drv.wait_status("ready")
140
146
  assert drv.replica["runs"] == []
141
147
 
142
148
 
143
- async def test_edit_dispatch_sets_the_leaf_to_the_replacement():
149
+ async def test_rewind_dispatch_nulls_the_leaf_and_the_ack_records_the_replacement():
144
150
  script = Script()
145
151
  thread_with_turn(script)
146
152
  async with run_host(script, capabilities=("rewind",)) as (drv, host):
147
153
  drv.post("run/edit", {"sourceId": "u1", "message": msg("u2")})
148
- await script.next_call()
154
+ call = await script.next_call()
155
+ await drv.wait_status("running")
156
+ assert drv.run["runLeafMessageId"] is None
157
+ call.ack()
149
158
  await drv.wait(lambda s: run_of(s).get("runLeafMessageId") == "u2")
150
159
 
151
160
 
152
- async def test_reload_dispatch_sets_the_leaf_to_the_rollback_target():
161
+ async def test_reload_dispatch_nulls_the_leaf_until_the_settle_pull():
153
162
  script = Script()
154
163
  thread_with_turn(script)
155
164
  async with run_host(script, capabilities=("rewind",)) as (drv, host):
156
165
  drv.post("run/reload", {"sourceId": "a1"})
157
166
  call = await script.next_call()
158
- await drv.wait(lambda s: run_of(s).get("runLeafMessageId") == "u1")
167
+ await drv.wait_status("running")
168
+ assert drv.run["runLeafMessageId"] is None
159
169
  call.ack()
170
+ assert drv.run["runLeafMessageId"] is None
160
171
  script.leaf = "a2"
161
172
  call.finish(RunManager.Stop(dispatch_queue=False))
162
173
  await drv.wait(lambda s: run_of(s).get("runLeafMessageId") == "a2")
174
+
175
+
176
+ async def test_unacked_rewind_end_restores_the_previous_leaf():
177
+ script = Script()
178
+ thread_with_turn(script)
179
+ async with run_host(script, capabilities=("rewind",)) as (drv, host):
180
+ drv.post("run/enqueue", add("m1", anchor="a1"))
181
+ call = await script.next_call()
182
+ call.ack()
183
+ script.leaf = "a2"
184
+ call.finish(RunManager.Stop(dispatch_queue=False))
185
+ await drv.wait(lambda s: run_of(s).get("runLeafMessageId") == "a2")
186
+ drv.post("run/edit", {"sourceId": "u1", "message": msg("u2")})
187
+ rerun = await script.next_call()
188
+ await drv.wait(lambda s: run_of(s).get("runLeafMessageId") is None)
189
+ rerun.fail(RuntimeError("boom"))
190
+ await drv.wait_status("error")
191
+ assert drv.run["runLeafMessageId"] == "a2"
@@ -105,6 +105,7 @@ async def test_unacked_run_end_reverts_entries_to_the_lane_front():
105
105
  await drv.command("run/steer", {"messageId": "m1"}, terminal=False)
106
106
  continued = await script.next_call()
107
107
  assert continued.ctx.trigger == "error-continue"
108
+ continued.ack()
108
109
  assert [m["id"] for m in continued.ctx.steering.take()] == ["m1"]
109
110
 
110
111
 
@@ -113,12 +114,14 @@ async def test_unacked_run_end_reverts_taken_steered_items():
113
114
  async with run_host(script) as (drv, host):
114
115
  drv.post("run/enqueue", add("m1"))
115
116
  call = await script.next_call()
117
+ call.ack()
116
118
  await drv.side("run/steer", add("s1"))
119
+ await drv.wait(lambda s: queue_ids(s, "steerQueue") == ["s1"])
117
120
  call.ctx.steering.take()
118
121
  call.fail(RuntimeError("boom"))
119
122
  await drv.wait_status("error")
120
123
  assert queue_ids(drv.replica, "steerQueue") == ["s1"]
121
- assert queue_ids(drv.replica, "queue") == ["m1"]
124
+ assert queue_ids(drv.replica, "queue") == []
122
125
 
123
126
 
124
127
  async def test_ack_removes_the_entries_for_good():
@@ -55,6 +55,7 @@ async def test_steer_add_continues_in_error_and_stop(end, continue_type):
55
55
  assert continued.ctx.messages == ()
56
56
  assert queue_ids(drv.replica, "steerQueue") == ["s1"]
57
57
  assert continued.ctx.steering.available.is_set()
58
+ continued.ack()
58
59
  assert [m["id"] for m in continued.ctx.steering.take()] == ["s1"]
59
60
 
60
61
 
@@ -86,6 +87,7 @@ async def test_steer_edit_continues_in_error_with_replacement():
86
87
  await drv.command("run/steer", {"message": msg("m2", "edited")}, terminal=False)
87
88
  continued = await script.next_call()
88
89
  assert continued.ctx.trigger == "error-continue"
90
+ continued.ack()
89
91
  taken = continued.ctx.steering.take()
90
92
  assert [m["parts"][0]["text"] for m in taken] == ["edited"]
91
93
 
@@ -234,6 +234,7 @@ async def test_continue_with_steer_lane_needs_no_capability():
234
234
  assert res["type"] == "pending"
235
235
  continued = await script.next_call()
236
236
  assert continued.ctx.trigger == "error-continue"
237
+ continued.ack()
237
238
  assert [m["id"] for m in continued.ctx.steering.take()] == ["s1"]
238
239
  continued.ack()
239
240
  continued.finish(RunManager.Complete())