harness-sdk-python 0.4.1__tar.gz → 0.4.2__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 (25) hide show
  1. {harness_sdk_python-0.4.1 → harness_sdk_python-0.4.2}/PKG-INFO +1 -1
  2. {harness_sdk_python-0.4.1 → harness_sdk_python-0.4.2}/pyproject.toml +1 -1
  3. {harness_sdk_python-0.4.1 → harness_sdk_python-0.4.2}/src/harness_sdk/run_manager.py +103 -32
  4. {harness_sdk_python-0.4.1 → harness_sdk_python-0.4.2}/tests/run_helpers.py +5 -2
  5. {harness_sdk_python-0.4.1 → harness_sdk_python-0.4.2}/tests/test_batches.py +2 -1
  6. {harness_sdk_python-0.4.1 → harness_sdk_python-0.4.2}/tests/test_caller.py +8 -0
  7. {harness_sdk_python-0.4.1 → harness_sdk_python-0.4.2}/tests/test_edit_dispatched.py +5 -0
  8. {harness_sdk_python-0.4.1 → harness_sdk_python-0.4.2}/tests/test_edit_reload.py +4 -0
  9. {harness_sdk_python-0.4.1 → harness_sdk_python-0.4.2}/tests/test_enqueue.py +13 -1
  10. {harness_sdk_python-0.4.1 → harness_sdk_python-0.4.2}/tests/test_facade.py +5 -3
  11. {harness_sdk_python-0.4.1 → harness_sdk_python-0.4.2}/tests/test_input_required.py +10 -1
  12. {harness_sdk_python-0.4.1 → harness_sdk_python-0.4.2}/tests/test_outcomes.py +11 -2
  13. {harness_sdk_python-0.4.1 → harness_sdk_python-0.4.2}/tests/test_rewind_during_run.py +4 -0
  14. {harness_sdk_python-0.4.1 → harness_sdk_python-0.4.2}/tests/test_run_leaf.py +5 -0
  15. harness_sdk_python-0.4.2/tests/test_settle.py +218 -0
  16. {harness_sdk_python-0.4.1 → harness_sdk_python-0.4.2}/tests/test_steer.py +5 -0
  17. {harness_sdk_python-0.4.1 → harness_sdk_python-0.4.2}/tests/test_stop_continue.py +32 -5
  18. harness_sdk_python-0.4.1/tests/test_settle.py +0 -131
  19. {harness_sdk_python-0.4.1 → harness_sdk_python-0.4.2}/.gitignore +0 -0
  20. {harness_sdk_python-0.4.1 → harness_sdk_python-0.4.2}/README.md +0 -0
  21. {harness_sdk_python-0.4.1 → harness_sdk_python-0.4.2}/src/harness_sdk/__init__.py +0 -0
  22. {harness_sdk_python-0.4.1 → harness_sdk_python-0.4.2}/src/harness_sdk/fenced_postgres.py +0 -0
  23. {harness_sdk_python-0.4.1 → harness_sdk_python-0.4.2}/tests/test_branch_anchor.py +0 -0
  24. {harness_sdk_python-0.4.1 → harness_sdk_python-0.4.2}/tests/test_fenced_postgres.py +0 -0
  25. {harness_sdk_python-0.4.1 → harness_sdk_python-0.4.2}/tests/test_placement.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: harness-sdk-python
3
- Version: 0.4.1
3
+ Version: 0.4.2
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.4.1"
3
+ version = "0.4.2"
4
4
  description = "RunManager: the harness-sdk runs subsystem for Python Statewire hosts"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -6,8 +6,14 @@ Implements the full state x command matrix from runs.mdx on a single
6
6
  Commands stage entries into an intake list and schedule a drain; the drain
7
7
  reduces the whole intake into the lanes in order (pure placement), then takes
8
8
  exactly one action based on status and net effect (interrupt, dispatch, or
9
- continue). Late-settling commands (rewinds, stop) await a future the drain
9
+ continue). A parked send settles at staging; run-starting commands settle at
10
+ the executor's ``ctx.ack_messages()``; ``run/stop`` awaits a future the drain
10
11
  resolves once the in-flight run has ended.
12
+
13
+ Dispatched entries leave the queue projection but are retained until the
14
+ executor acks. A run that ends before the ack puts them back at the front of
15
+ their lane and settles their sends accepted; edit, reload, and continue
16
+ initiators settle rejected (``stopped`` or the failure).
11
17
  """
12
18
 
13
19
  import asyncio
@@ -101,7 +107,7 @@ class _Stop:
101
107
 
102
108
  @dataclass
103
109
  class _Continue:
104
- pass
110
+ future: "asyncio.Future[Any]" = field(default_factory=_future)
105
111
 
106
112
 
107
113
  @dataclass
@@ -126,6 +132,8 @@ class _Effects:
126
132
  steer_added: bool = False
127
133
  new_added: bool = False
128
134
  continue_requested: bool = False
135
+ staged_sends: list[tuple[str, "asyncio.Future[Any]"]] = field(default_factory=list)
136
+ continues: list["asyncio.Future[Any]"] = field(default_factory=list)
129
137
 
130
138
 
131
139
  class RunManager:
@@ -170,7 +178,10 @@ class RunManager:
170
178
  self._staged_stops: list["asyncio.Future[Any]"] = []
171
179
  self._stop_reason: str | None = None
172
180
  self._staged_rewinds: list[_Rewind] = []
173
- self._entity_futures: dict[str, "asyncio.Future[Any]"] = {}
181
+ self._dispatching: list[tuple[str, dict[str, Any]]] = []
182
+ self._run_acked = True
183
+ self._leaf_confirmed = leaf_message_id
184
+ self._send_futures: list["asyncio.Future[Any]"] = []
174
185
  self._run_futures: list["asyncio.Future[Any]"] = []
175
186
  self._input_requests: list[dict[str, Any]] = []
176
187
  self._input_answers: dict[str, Any] = {}
@@ -203,6 +214,9 @@ class RunManager:
203
214
  def _continue_type(self) -> str:
204
215
  return "error-continue" if self._status() == "error" else "stop-continue"
205
216
 
217
+ def _is_dispatching(self, message_id: str) -> bool:
218
+ return any(item["id"] == message_id for _, item in self._dispatching)
219
+
206
220
  # ─── Staging and drain ──────────────────────────────────
207
221
 
208
222
  async def _stage(self, entry: Any) -> Any:
@@ -231,6 +245,8 @@ class RunManager:
231
245
  rewind.messages,
232
246
  rollback_to=rewind.rollback_to,
233
247
  )
248
+ for message in rewind.messages:
249
+ self._callers.pop(message["id"], None)
234
250
  rewind.ack()
235
251
  if not rewind.future.done():
236
252
  self._run_futures.append(rewind.future)
@@ -245,6 +261,15 @@ class RunManager:
245
261
  if not rewind.acked:
246
262
  rewind.ack()
247
263
  rewind.acked = True
264
+ dispatching = {item["id"] for _, item in self._dispatching}
265
+ for message_id, future in fx.staged_sends:
266
+ if message_id in dispatching:
267
+ self._send_futures.append(future)
268
+ elif not future.done():
269
+ future.set_result(None)
270
+ for future in fx.continues:
271
+ if not future.done():
272
+ future.set_result(None)
248
273
 
249
274
  def _apply(self, entry: Any, fx: _Effects) -> None:
250
275
  if isinstance(entry, _Stop):
@@ -252,6 +277,7 @@ class RunManager:
252
277
  return
253
278
  if isinstance(entry, _Continue):
254
279
  fx.continue_requested = True
280
+ fx.continues.append(entry.future)
255
281
  return
256
282
  try:
257
283
  if isinstance(entry, _Send):
@@ -278,6 +304,8 @@ class RunManager:
278
304
  elif status in ("error", "stopped"):
279
305
  if fx.continue_requested or fx.steer_added:
280
306
  self._dispatch(self._continue_type(), [])
307
+ self._run_futures.extend(fx.continues)
308
+ fx.continues.clear()
281
309
  elif fx.new_added and pre_empty:
282
310
  self._pop_dispatchable()
283
311
  elif status == "input-required" and self._input_requests and len(
@@ -331,9 +359,6 @@ class RunManager:
331
359
  messages = [
332
360
  {k: v for k, v in message.items() if k != "caller"} for message in messages
333
361
  ]
334
- for message in messages:
335
- self._callers.pop(message["id"], None)
336
- self._adopt_entities(message["id"] for message in messages)
337
362
  record: dict[str, Any] = {"type": type, "messages": list(messages)}
338
363
  if rollback_to is not _ABSENT:
339
364
  record["rollbackTo"] = rollback_to
@@ -341,6 +366,8 @@ class RunManager:
341
366
  record["responses"] = responses
342
367
  self._dispatch_record = record
343
368
  self._stop_reason = None
369
+ self._run_acked = False
370
+ self._leaf_confirmed = plain(self._state["runLeafMessageId"])
344
371
  self._state["error"] = None
345
372
  if messages:
346
373
  self._dispatched_ids = tuple(m["id"] for m in messages)
@@ -377,23 +404,34 @@ class RunManager:
377
404
  "start must return a RunManager outcome, got "
378
405
  f"{type(outcome).__name__}"
379
406
  )
407
+ if isinstance(
408
+ outcome, (RunManager.Complete, RunManager.InputRequired)
409
+ ) and (self._dispatching or not self._run_acked):
410
+ raise RuntimeError(
411
+ "run settled without acking its messages"
412
+ " (call ctx.ack_messages())"
413
+ )
380
414
  except Exception as exc:
381
415
  self._settle(ctx)
382
416
  message = str(exc) or type(exc).__name__
383
417
  if isinstance(exc, StatewireReject):
384
418
  self._freeze(exc.message, exc.payload)
385
- self._settle_entities(exc)
419
+ self._settle_initiators(exc)
386
420
  else:
387
421
  self._freeze(message)
388
- self._settle_entities(_reject("run-error", message))
422
+ self._settle_initiators(_reject("run-error", message))
423
+ self._revert_dispatching()
389
424
  self._drain()
390
425
  return
391
426
  self._settle(ctx)
392
- self._settle_entities(
393
- _reject("run-error", "run ended in error")
394
- if isinstance(outcome, RunManager.Error)
395
- else None
396
- )
427
+ if isinstance(outcome, RunManager.Error):
428
+ error = _reject("run-error", "run ended in error")
429
+ elif isinstance(outcome, RunManager.Stop):
430
+ error = _reject("stopped", "run stopped before the messages-ack")
431
+ else:
432
+ error = None
433
+ self._settle_initiators(error)
434
+ self._revert_dispatching()
397
435
  self._outcome = outcome
398
436
  self._drain()
399
437
 
@@ -403,7 +441,7 @@ class RunManager:
403
441
  self._task = None
404
442
  self._dispatch_record = None
405
443
 
406
- def _settle_entities(self, error: StatewireReject | None) -> None:
444
+ def _settle_initiators(self, error: StatewireReject | None) -> None:
407
445
  futures, self._run_futures = self._run_futures, []
408
446
  for future in futures:
409
447
  if future.done():
@@ -413,21 +451,48 @@ class RunManager:
413
451
  else:
414
452
  future.set_exception(error)
415
453
 
416
- def _adopt_entities(self, ids: Iterable[str]) -> None:
417
- for id in ids:
418
- future = self._entity_futures.pop(id, None)
419
- if future is not None:
420
- self._run_futures.append(future)
454
+ def _settle_sends(self) -> None:
455
+ futures, self._send_futures = self._send_futures, []
456
+ for future in futures:
457
+ if not future.done():
458
+ future.set_result(None)
459
+
460
+ def _ack_messages(self) -> None:
461
+ taken, self._dispatching = self._dispatching, []
462
+ self._run_acked = True
463
+ for _, item in taken:
464
+ self._callers.pop(item["id"], None)
465
+ self._leaf_confirmed = plain(self._state["runLeafMessageId"])
466
+ self._settle_sends()
467
+ self._settle_initiators(None)
468
+
469
+ def _revert_dispatching(self) -> None:
470
+ self._settle_sends()
471
+ taken, self._dispatching = self._dispatching, []
472
+ if not taken:
473
+ return
474
+ for lane in ("steerQueue", "queue"):
475
+ front = [item for l, item in taken if l == lane]
476
+ if front:
477
+ self._state[lane] = front + self._lane_items(lane)
478
+ ids = {item["id"] for _, item in taken}
479
+ self._dispatched_ids = tuple(
480
+ id for id in self._dispatched_ids if id not in ids
481
+ )
482
+ if plain(self._state["runLeafMessageId"]) in ids:
483
+ self._state["runLeafMessageId"] = self._leaf_confirmed
421
484
 
422
485
  def _pop_dispatchable(self) -> bool:
423
486
  steer = self._lane_items("steerQueue")
424
487
  if steer:
425
488
  self._state["steerQueue"] = []
489
+ self._dispatching = [("steerQueue", item) for item in steer]
426
490
  self._dispatch("message-send", steer)
427
491
  return True
428
492
  queue = self._lane_items("queue")
429
493
  if queue:
430
494
  self._state["queue"].pop(0)
495
+ self._dispatching = [("queue", queue[0])]
431
496
  self._dispatch("message-send", [queue[0]])
432
497
  return True
433
498
  return False
@@ -619,7 +684,7 @@ class RunManager:
619
684
  if e.lane == "steerQueue":
620
685
  fx.steer_added = True
621
686
  return None
622
- if e.message_id in self._dispatched_ids:
687
+ if e.message_id in self._dispatched_ids or self._is_dispatching(e.message_id):
623
688
  return self._park_dispatched_edit(e)
624
689
  if e.meta is not None:
625
690
  raise _reject(
@@ -630,7 +695,7 @@ class RunManager:
630
695
  self._check_anchor(e)
631
696
  with self._caller_stamp(e.message_id, e.caller):
632
697
  self._insert_new(e.lane, e.message, e.params)
633
- self._entity_futures[e.message_id] = e.future
698
+ fx.staged_sends.append((e.message_id, e.future))
634
699
  e.ack()
635
700
  fx.new_added = True
636
701
  if e.lane == "steerQueue":
@@ -640,7 +705,9 @@ class RunManager:
640
705
  def _apply_move(self, e: _Send, fx: _Effects) -> Any:
641
706
  current = self._lane_of(e.message_id)
642
707
  if current is None:
643
- if e.message_id in self._dispatched_ids:
708
+ if e.message_id in self._dispatched_ids or self._is_dispatching(
709
+ e.message_id
710
+ ):
644
711
  if e.lane == "steerQueue":
645
712
  return None
646
713
  raise _reject(
@@ -685,11 +752,6 @@ class RunManager:
685
752
  self._state[lane] = [
686
753
  item for item in self._lane_items(lane) if item["id"] != e.message_id
687
754
  ]
688
- entity = self._entity_futures.pop(e.message_id, None)
689
- if entity is not None and not entity.done():
690
- entity.set_exception(
691
- _reject("removed", f"message {e.message_id} was removed from the queue")
692
- )
693
755
  return None
694
756
 
695
757
  def _apply_input(self, e: _Input) -> Any:
@@ -972,7 +1034,7 @@ class RunManager:
972
1034
  response = self._validated_response(request["type"], params["response"])
973
1035
  return await self._stage(_Input(request_id, response))
974
1036
 
975
- def continue_run(self) -> None:
1037
+ async def continue_run(self, *, ack: Callable[[], None]) -> Any:
976
1038
  status = self._status()
977
1039
  if status not in ("error", "stopped"):
978
1040
  raise _reject("wrong-state", f"run/continue is rejected in {status}")
@@ -983,8 +1045,11 @@ class RunManager:
983
1045
  "capability-missing",
984
1046
  "bare continue requires the incomplete-continuation capability",
985
1047
  )
986
- self._intake.append(_Continue())
1048
+ entry = _Continue()
1049
+ self._intake.append(entry)
1050
+ ack()
987
1051
  self._schedule(self._drain)
1052
+ return await entry.future
988
1053
 
989
1054
  # ─── Outcomes and context ───────────────────────────────
990
1055
 
@@ -1075,19 +1140,25 @@ class RunManager:
1075
1140
  self._ensure_active()
1076
1141
  items = self._manager._lane_items("steerQueue")
1077
1142
  self._manager._state["steerQueue"] = []
1078
- for item in items:
1079
- self._manager._callers.pop(item["id"], None)
1080
- self._manager._adopt_entities(item["id"] for item in items)
1143
+ self._manager._dispatching.extend(("steerQueue", item) for item in items)
1081
1144
  if items:
1082
1145
  self._manager._state["runLeafMessageId"] = items[-1]["id"]
1083
1146
  return tuple(
1084
1147
  {k: v for k, v in item.items() if k != "caller"} for item in items
1085
1148
  )
1086
1149
 
1150
+ def ack_messages(self) -> None:
1151
+ self._ensure_active()
1152
+ self._manager._ack_messages()
1153
+
1087
1154
  def set_leaf_message_id(self, message_id: str) -> None:
1088
1155
  self._ensure_active()
1089
1156
  if not isinstance(message_id, str) or message_id == "":
1090
1157
  raise ValueError("message_id must be a non-empty string")
1158
+ if self._manager._dispatching or not self._manager._run_acked:
1159
+ raise RuntimeError(
1160
+ "ack_messages must precede set_leaf_message_id"
1161
+ )
1091
1162
  self._manager._state["runLeafMessageId"] = message_id
1092
1163
 
1093
1164
  def set_recovery_state(self, value: Any) -> None:
@@ -15,6 +15,9 @@ class Call:
15
15
  ctx: RunManager.StartContext
16
16
  outcome: "asyncio.Future[Any]"
17
17
 
18
+ def ack(self) -> None:
19
+ self.ctx.ack_messages()
20
+
18
21
  def finish(self, outcome: Any) -> None:
19
22
  self.outcome.set_result(outcome)
20
23
 
@@ -103,8 +106,8 @@ def make_host(script: Script, capabilities=(), initial_runs=None, max_queued=50)
103
106
  return await self.runs.stop(params, ack=ctx.ack)
104
107
 
105
108
  @command("run/continue")
106
- async def run_continue(self):
107
- self.runs.continue_run()
109
+ async def run_continue(self, *, ctx):
110
+ return await self.runs.continue_run(ack=ctx.ack)
108
111
 
109
112
  @command("run/input")
110
113
  async def run_input(self, params):
@@ -27,6 +27,7 @@ async def test_multi_steer_batch_places_all_and_dispatches_once():
27
27
  assert [m["id"] for m in call.ctx.messages] == ["s1", "s2", "s3"]
28
28
  script.no_call()
29
29
  assert queue_ids(drv.replica, "steerQueue") == []
30
+ call.ack()
30
31
  call.finish(RunManager.Complete())
31
32
  for offset in range(3):
32
33
  assert (await drv.res(first + offset))["type"] == "accepted"
@@ -40,7 +41,7 @@ async def test_steer_and_stop_in_one_batch_nets_to_stop():
40
41
  first = await drv.batch(
41
42
  [("run/steer", add("s1")), ("run/stop", None)]
42
43
  )
43
- assert (await drv.res(first, terminal=False))["type"] == "pending"
44
+ assert (await drv.res(first))["type"] == "accepted"
44
45
  assert (await drv.res(first + 1, terminal=False))["type"] == "pending"
45
46
  await asyncio.wait_for(call.ctx.stop_requested.wait(), 5)
46
47
  call.finish(RunManager.Stop(dispatch_queue=False))
@@ -13,6 +13,7 @@ async def test_immediate_dispatch_carries_caller():
13
13
  call = await script.next_call()
14
14
  assert call.ctx.caller is not None
15
15
  assert call.ctx.caller.client_id == "c1"
16
+ call.ack()
16
17
  call.finish(RunManager.Complete())
17
18
 
18
19
 
@@ -24,6 +25,7 @@ async def test_queue_entry_projects_caller_client_id():
24
25
  await drv.command("run/enqueue", add("m2"), terminal=False)
25
26
  await drv.wait(lambda s: len(s["queue"]) == 1)
26
27
  assert drv.replica["queue"][0]["caller"] == {"clientId": "c1"}
28
+ call.ack()
27
29
  call.finish(RunManager.Complete())
28
30
 
29
31
 
@@ -34,12 +36,14 @@ async def test_caller_context_is_read_at_dispatch_time():
34
36
  call = await script.next_call()
35
37
  await drv.command("run/enqueue", add("m2"), terminal=False)
36
38
  assert (await drv.context({"user": "simon"}))["type"] == "accepted"
39
+ call.ack()
37
40
  call.finish(RunManager.Complete())
38
41
  dispatched = await script.next_call()
39
42
  assert dispatched.ctx.caller is not None
40
43
  assert dispatched.ctx.caller.context == {"user": "simon"}
41
44
  assert dispatched.ctx.messages[0]["id"] == "m2"
42
45
  assert "caller" not in dispatched.ctx.messages[0]
46
+ dispatched.ack()
43
47
  dispatched.finish(RunManager.Complete())
44
48
 
45
49
 
@@ -52,6 +56,7 @@ async def test_dispatch_without_queue_entry_has_no_caller():
52
56
  ] == "pending"
53
57
  call = await script.next_call()
54
58
  assert call.ctx.caller is None
59
+ call.ack()
55
60
  call.finish(RunManager.Complete())
56
61
 
57
62
 
@@ -65,6 +70,7 @@ async def test_take_steered_strips_the_caller_stamp():
65
70
  assert drv.replica["steerQueue"][0]["caller"] == {"clientId": "c1"}
66
71
  [taken] = call.ctx.take_steered()
67
72
  assert "caller" not in taken
73
+ call.ack()
68
74
  call.finish(RunManager.Complete())
69
75
 
70
76
 
@@ -85,6 +91,7 @@ async def test_max_queued_caps_each_lane():
85
91
  ] == "pending"
86
92
  steer_full = await drv.command("run/steer", add("s2"), terminal=False)
87
93
  assert steer_full["payload"] == {"reason": "queue-full"}
94
+ call.ack()
88
95
  call.finish(RunManager.Complete())
89
96
 
90
97
 
@@ -97,6 +104,7 @@ async def test_lane_change_into_a_full_lane_rejects():
97
104
  await drv.command("run/steer", add("s1"), terminal=False)
98
105
  moved = await drv.command("run/steer", {"messageId": "m2"}, terminal=False)
99
106
  assert moved["payload"] == {"reason": "queue-full"}
107
+ call.ack()
100
108
  call.finish(RunManager.Complete())
101
109
 
102
110
 
@@ -13,6 +13,7 @@ async def test_edit_of_dispatched_item_stops_rewinds_reruns(command):
13
13
  await drv.command("run/enqueue", add("m1"), terminal=False)
14
14
  call = await script.next_call()
15
15
  script.thread["m1"] = {"parentId": "p0", "role": "user", "isLeaf": True}
16
+ call.ack()
16
17
  pending = await drv.command(
17
18
  command, {"message": msg("m1", "edited")}, terminal=False
18
19
  )
@@ -26,6 +27,7 @@ async def test_edit_of_dispatched_item_stops_rewinds_reruns(command):
26
27
  assert rerun.ctx.has_rollback
27
28
  assert rerun.ctx.rollback_to == "p0"
28
29
  await drv.wait_status("running")
30
+ rerun.ack()
29
31
  rerun.finish(RunManager.Stop(dispatch_queue=False))
30
32
  settled = await drv.res(pending["seq"])
31
33
  assert settled["type"] == "accepted"
@@ -62,6 +64,7 @@ async def test_edit_of_dispatched_item_with_queued_items_stops_and_reruns():
62
64
  await drv.command("run/enqueue", add("m1"), terminal=False)
63
65
  call = await script.next_call()
64
66
  script.thread["m1"] = {"parentId": None, "role": "user", "isLeaf": True}
67
+ call.ack()
65
68
  await drv.command("run/enqueue", add("m2", anchor="m1"), terminal=False)
66
69
  pending = await drv.command(
67
70
  "run/enqueue", {"message": msg("m1", "edited")}, terminal=False
@@ -70,6 +73,7 @@ async def test_edit_of_dispatched_item_with_queued_items_stops_and_reruns():
70
73
  call.finish(RunManager.Stop(dispatch_queue=False))
71
74
  rerun = await script.next_call()
72
75
  assert rerun.ctx.type == "message-edit"
76
+ rerun.ack()
73
77
  rerun.finish(RunManager.Stop(dispatch_queue=False))
74
78
  assert (await drv.res(pending["seq"]))["type"] == "accepted"
75
79
  assert [item["id"] for item in drv.replica["queue"]] == ["m2"]
@@ -81,6 +85,7 @@ async def test_edit_of_dispatched_item_in_error_reruns_without_stop():
81
85
  await drv.command("run/enqueue", add("m1"), terminal=False)
82
86
  call = await script.next_call()
83
87
  script.thread["m1"] = {"parentId": None, "role": "user", "isLeaf": True}
88
+ call.ack()
84
89
  call.fail(RuntimeError("boom"))
85
90
  await drv.wait_status("error")
86
91
  await drv.command("run/enqueue", {"message": msg("m1", "edited")}, terminal=False)
@@ -56,6 +56,7 @@ async def test_deep_edit_rejected_while_queue_non_empty():
56
56
  await drv.command("run/enqueue", add("m1", anchor="a1"), terminal=False)
57
57
  call = await script.next_call()
58
58
  await drv.command("run/enqueue", add("m2", anchor="a1"), terminal=False)
59
+ call.ack()
59
60
  call.fail(RuntimeError("boom"))
60
61
  await drv.wait_status("error")
61
62
  assert_rejected(
@@ -75,6 +76,7 @@ async def test_leaf_edit_in_error_accepted_with_queue_non_empty():
75
76
  call = await script.next_call()
76
77
  script.thread["m1"] = {"parentId": None, "role": "user", "isLeaf": True}
77
78
  await drv.command("run/enqueue", add("m2", anchor="m1"), terminal=False)
79
+ call.ack()
78
80
  call.fail(RuntimeError("boom"))
79
81
  await drv.wait_status("error")
80
82
  res = await drv.command("run/edit", {"sourceId": "m1", "message": msg("u2")}, terminal=False)
@@ -131,6 +133,7 @@ async def test_reload_dispatches_with_empty_messages():
131
133
  assert call.ctx.type == "message-reload"
132
134
  assert call.ctx.messages == ()
133
135
  assert call.ctx.rollback_to == "u1"
136
+ call.ack()
134
137
  call.finish(RunManager.Complete())
135
138
  await drv.wait_status("ready")
136
139
 
@@ -175,6 +178,7 @@ async def test_reload_leaf_only_while_queue_non_empty():
175
178
  script.thread["m1"] = {"parentId": "a0", "role": "user", "isLeaf": False, "onActiveBranch": True}
176
179
  script.thread["a1"] = {"parentId": "m1", "role": "assistant", "isLeaf": True, "onActiveBranch": True}
177
180
  await drv.command("run/enqueue", add("m2", anchor="a1"), terminal=False)
181
+ call.ack()
178
182
  call.fail(RuntimeError("boom"))
179
183
  await drv.wait_status("error")
180
184
  assert_rejected(
@@ -15,6 +15,7 @@ async def test_ready_add_dispatches_message_send():
15
15
  assert not call.ctx.has_rollback
16
16
  await drv.wait_status("running")
17
17
  assert "dispatch" not in drv.replica
18
+ call.ack()
18
19
  call.finish(RunManager.Complete())
19
20
  await drv.wait_status("ready")
20
21
 
@@ -27,10 +28,12 @@ async def test_running_add_becomes_queue_item():
27
28
  await drv.command("run/enqueue", add("m2"), terminal=False)
28
29
  assert queue_ids(drv.replica, "queue") == ["m2"]
29
30
  script.no_call()
31
+ call.ack()
30
32
  call.finish(RunManager.Complete())
31
33
  drain = await script.next_call()
32
34
  assert drain.ctx.type == "message-send"
33
35
  assert [m["id"] for m in drain.ctx.messages] == ["m2"]
36
+ drain.ack()
34
37
  drain.finish(RunManager.Complete())
35
38
  await drv.wait_status("ready")
36
39
 
@@ -42,12 +45,15 @@ async def test_queue_drains_fifo_one_item_per_run():
42
45
  call = await script.next_call()
43
46
  await drv.command("run/enqueue", add("m2"), terminal=False)
44
47
  await drv.command("run/enqueue", add("m3"), terminal=False)
48
+ call.ack()
45
49
  call.finish(RunManager.Complete())
46
50
  second = await script.next_call()
47
51
  assert [m["id"] for m in second.ctx.messages] == ["m2"]
52
+ second.ack()
48
53
  second.finish(RunManager.Complete())
49
54
  third = await script.next_call()
50
55
  assert [m["id"] for m in third.ctx.messages] == ["m3"]
56
+ third.ack()
51
57
  third.finish(RunManager.Complete())
52
58
  await drv.wait_status("ready")
53
59
 
@@ -60,13 +66,16 @@ async def test_steer_lane_drains_first_and_whole():
60
66
  await drv.command("run/enqueue", add("q1"), terminal=False)
61
67
  await drv.command("run/steer", add("s1"), terminal=False)
62
68
  await drv.command("run/steer", add("s2"), terminal=False)
69
+ call.ack()
63
70
  call.finish(RunManager.Complete())
64
71
  steered = await script.next_call()
65
72
  assert steered.ctx.type == "message-send"
66
73
  assert [m["id"] for m in steered.ctx.messages] == ["s1", "s2"]
74
+ steered.ack()
67
75
  steered.finish(RunManager.Complete())
68
76
  regular = await script.next_call()
69
77
  assert [m["id"] for m in regular.ctx.messages] == ["q1"]
78
+ regular.ack()
70
79
  regular.finish(RunManager.Complete())
71
80
  await drv.wait_status("ready")
72
81
 
@@ -75,7 +84,9 @@ async def test_error_add_with_empty_lanes_dispatches_immediately():
75
84
  script = Script()
76
85
  async with run_host(script) as (drv, host):
77
86
  await drv.command("run/enqueue", add("m1"), terminal=False)
78
- (await script.next_call()).fail(RuntimeError("boom"))
87
+ call = await script.next_call()
88
+ call.ack()
89
+ call.fail(RuntimeError("boom"))
79
90
  await drv.wait_status("error")
80
91
  await drv.command("run/enqueue", add("m2"), terminal=False)
81
92
  call = await script.next_call()
@@ -90,6 +101,7 @@ async def test_error_add_with_queued_items_stays_queued():
90
101
  await drv.command("run/enqueue", add("m1"), terminal=False)
91
102
  call = await script.next_call()
92
103
  await drv.command("run/enqueue", add("m2"), terminal=False)
104
+ call.ack()
93
105
  call.fail(RuntimeError("boom"))
94
106
  await drv.wait_status("error")
95
107
  await drv.command("run/enqueue", add("m3"), terminal=False)
@@ -1,6 +1,7 @@
1
- """Contract: entity-lifetime settle composed with the legacy facade. The
2
- ``/chat`` response stays open while the steer entity is pending and EOFs once
3
- its run settles; a run rejection is a single error frame then EOF.
1
+ """Contract: run lifetime composed with the legacy facade. The ``/chat``
2
+ response stays open through the run past the send settling at the ack —
3
+ and EOFs once the run task ends; a run rejection is a single error frame
4
+ then EOF.
4
5
  """
5
6
 
6
7
  import asyncio
@@ -63,6 +64,7 @@ async def test_chat_holds_until_the_entity_run_settles_then_eofs():
63
64
  assert stream.status == 200
64
65
  call = await script.next_call()
65
66
  assert call.ctx.type == "message-send"
67
+ call.ack()
66
68
  await asyncio.sleep(0.05)
67
69
  assert stream._task is not None and not stream._task.done()
68
70
  call.finish(RunManager.Complete())
@@ -18,7 +18,9 @@ def approval(id: str, tool_call_id: str = "tc1") -> dict:
18
18
 
19
19
  async def park(drv, script, requests, *, anchor=None):
20
20
  await drv.command("run/enqueue", add("m1", anchor=anchor), terminal=False)
21
- (await script.next_call()).finish(RunManager.InputRequired(requests))
21
+ call = await script.next_call()
22
+ call.ack()
23
+ call.finish(RunManager.InputRequired(requests))
22
24
  await drv.wait_status("input-required")
23
25
 
24
26
 
@@ -70,6 +72,7 @@ async def test_completing_answer_resumes_with_ordered_responses():
70
72
  (approval("r2"), {"decision": "approve"}),
71
73
  )
72
74
  assert "inputRequests" not in drv.replica
75
+ resumed.ack()
73
76
  resumed.finish(RunManager.Complete())
74
77
  await drv.wait_status("ready")
75
78
 
@@ -94,6 +97,7 @@ async def test_completing_batch_resumes_once_in_the_same_envelope():
94
97
  assert resumed.ctx.type == "input-resume"
95
98
  assert [r["id"] for r, _ in resumed.ctx.responses] == ["r1", "r2"]
96
99
  script.no_call()
100
+ resumed.ack()
97
101
  resumed.finish(RunManager.Complete())
98
102
  await drv.wait_status("ready")
99
103
 
@@ -113,6 +117,7 @@ async def test_same_batch_duplicate_first_applies_second_rejects():
113
117
  resumed = await script.next_call()
114
118
  assert resumed.ctx.responses == ((tool_call("r1"), {"output": "a"}),)
115
119
  script.no_call()
120
+ resumed.ack()
116
121
  resumed.finish(RunManager.Complete())
117
122
  await drv.wait_status("ready")
118
123
 
@@ -134,6 +139,7 @@ async def test_input_rejections():
134
139
  ),
135
140
  "wrong-state",
136
141
  )
142
+ call.ack()
137
143
  call.finish(RunManager.InputRequired([tool_call("r1"), tool_call("r2")]))
138
144
  await drv.wait_status("input-required")
139
145
  assert_rejected(await drv.command("run/input", "nope"), "invalid-message")
@@ -196,6 +202,7 @@ async def test_custom_request_type_passes_through():
196
202
  )["type"] == "accepted"
197
203
  resumed = await script.next_call()
198
204
  assert resumed.ctx.responses == ((request, response),)
205
+ resumed.ack()
199
206
  resumed.finish(RunManager.Complete())
200
207
  await drv.wait_status("ready")
201
208
 
@@ -241,9 +248,11 @@ async def test_queue_while_parked_dispatches_after_resume():
241
248
  )
242
249
  resumed = await script.next_call()
243
250
  assert resumed.ctx.type == "input-resume"
251
+ resumed.ack()
244
252
  resumed.finish(RunManager.Complete())
245
253
  drain = await script.next_call()
246
254
  assert [m["id"] for m in drain.ctx.messages] == ["m2"]
255
+ drain.ack()
247
256
  drain.finish(RunManager.Complete())
248
257
  await drv.wait_status("ready")
249
258
 
@@ -33,6 +33,7 @@ async def test_set_recovery_state_stays_out_of_replicated_state():
33
33
  call = await script.next_call()
34
34
  call.ctx.set_recovery_state({"checkpoint": 7})
35
35
  assert "dispatch" not in drv.replica
36
+ call.ack()
36
37
  call.finish(RunManager.Complete())
37
38
  await drv.wait_status("ready")
38
39
  with pytest.raises(RuntimeError, match="already settled"):
@@ -45,6 +46,7 @@ async def test_uncaught_raise_freezes_and_sets_state_error():
45
46
  await drv.command("run/enqueue", add("m1"), terminal=False)
46
47
  call = await script.next_call()
47
48
  await drv.command("run/enqueue", add("m2"), terminal=False)
49
+ call.ack()
48
50
  call.fail(RuntimeError("boom"))
49
51
  await drv.wait_status("error")
50
52
  assert drv.replica["error"] == {"message": "boom"}
@@ -57,7 +59,9 @@ async def test_state_error_cleared_by_next_user_initiated_entry():
57
59
  script = Script()
58
60
  async with run_host(script) as (drv, host):
59
61
  await drv.command("run/enqueue", add("m1"), terminal=False)
60
- (await script.next_call()).fail(RuntimeError("boom"))
62
+ call = await script.next_call()
63
+ call.ack()
64
+ call.fail(RuntimeError("boom"))
61
65
  await drv.wait_status("error")
62
66
  await drv.command("run/enqueue", add("m2"), terminal=False)
63
67
  await script.next_call()
@@ -81,6 +85,7 @@ async def test_error_outcome_freezes_without_state_error():
81
85
  await drv.command("run/enqueue", add("m1"), terminal=False)
82
86
  call = await script.next_call()
83
87
  await drv.command("run/enqueue", add("m2"), terminal=False)
88
+ call.ack()
84
89
  call.finish(RunManager.Error(dispatch_queue=False))
85
90
  await drv.wait_status("error")
86
91
  assert drv.replica["error"] is None
@@ -94,9 +99,11 @@ async def test_error_outcome_with_dispatch_queue_drains():
94
99
  await drv.command("run/enqueue", add("m1"), terminal=False)
95
100
  call = await script.next_call()
96
101
  await drv.command("run/enqueue", add("m2"), terminal=False)
102
+ call.ack()
97
103
  call.finish(RunManager.Error(dispatch_queue=True))
98
104
  drain = await script.next_call()
99
105
  assert [m["id"] for m in drain.ctx.messages] == ["m2"]
106
+ drain.ack()
100
107
  drain.finish(RunManager.Complete())
101
108
  await drv.wait_status("ready")
102
109
 
@@ -105,7 +112,9 @@ async def test_error_outcome_with_dispatch_queue_and_empty_lanes_freezes():
105
112
  script = Script()
106
113
  async with run_host(script) as (drv, host):
107
114
  await drv.command("run/enqueue", add("m1"), terminal=False)
108
- (await script.next_call()).finish(RunManager.Error(dispatch_queue=True))
115
+ call = await script.next_call()
116
+ call.ack()
117
+ call.finish(RunManager.Error(dispatch_queue=True))
109
118
  await drv.wait_status("error")
110
119
  script.no_call()
111
120
 
@@ -11,6 +11,7 @@ async def start_run(drv, script, *, leaf=True):
11
11
  call = await script.next_call()
12
12
  script.thread["m1"] = {"parentId": None, "role": "user", "isLeaf": leaf, "onActiveBranch": True}
13
13
  script.thread["a1"] = {"parentId": "m1", "role": "assistant", "isLeaf": leaf, "onActiveBranch": True}
14
+ call.ack()
14
15
  return call
15
16
 
16
17
 
@@ -29,6 +30,7 @@ async def test_running_leaf_edit_stops_settles_then_rewinds():
29
30
  assert rerun.ctx.type == "message-edit"
30
31
  assert [m["id"] for m in rerun.ctx.messages] == ["m2"]
31
32
  assert rerun.ctx.rollback_to is None
33
+ rerun.ack()
32
34
  rerun.finish(RunManager.Stop(dispatch_queue=False))
33
35
  assert (await drv.res(pending["seq"]))["type"] == "accepted"
34
36
 
@@ -46,6 +48,7 @@ async def test_running_leaf_edit_accepted_with_non_empty_queues():
46
48
  call.finish(RunManager.Stop(dispatch_queue=False))
47
49
  rerun = await script.next_call()
48
50
  assert rerun.ctx.type == "message-edit"
51
+ rerun.ack()
49
52
  rerun.finish(RunManager.Stop(dispatch_queue=False))
50
53
  assert (await drv.res(pending["seq"]))["type"] == "accepted"
51
54
  assert queue_ids(drv.replica, "queue") == ["q1"]
@@ -66,6 +69,7 @@ async def test_running_leaf_reload_stops_settles_then_rewinds():
66
69
  rerun = await script.next_call()
67
70
  assert rerun.ctx.type == "message-reload"
68
71
  assert rerun.ctx.rollback_to == "m1"
72
+ rerun.ack()
69
73
  rerun.finish(RunManager.Stop(dispatch_queue=False))
70
74
  assert (await drv.res(pending["seq"]))["type"] == "accepted"
71
75
  assert queue_ids(drv.replica, "queue") == ["q1"]
@@ -41,6 +41,7 @@ async def test_steer_dispatch_sets_the_leaf_to_the_last_lane_item():
41
41
  call = await script.next_call()
42
42
  await drv.command("run/steer", add("s1", anchor="m1"), terminal=False)
43
43
  await drv.command("run/steer", add("s2", anchor="s1"), terminal=False)
44
+ call.ack()
44
45
  call.finish(RunManager.Complete())
45
46
  steered = await script.next_call()
46
47
  assert steered.ctx.messages[-1]["id"] == "s2"
@@ -56,6 +57,7 @@ async def test_take_steered_sets_the_leaf():
56
57
  await drv.wait(lambda s: len(s["steerQueue"]) == 1)
57
58
  call.ctx.take_steered()
58
59
  await drv.wait(lambda s: s["runLeafMessageId"] == "s1")
60
+ call.ack()
59
61
  call.finish(RunManager.Complete())
60
62
 
61
63
 
@@ -64,6 +66,7 @@ async def test_executor_report_sets_the_leaf():
64
66
  async with run_host(script) as (drv, host):
65
67
  await drv.command("run/enqueue", add("m1"), terminal=False)
66
68
  call = await script.next_call()
69
+ call.ack()
67
70
  call.ctx.set_leaf_message_id("a1")
68
71
  await drv.wait(lambda s: s["runLeafMessageId"] == "a1")
69
72
  call.finish(RunManager.Complete())
@@ -89,6 +92,7 @@ async def test_reload_dispatch_sets_the_leaf_to_the_rollback_target():
89
92
  await drv.command("run/reload", {"sourceId": "a1"}, terminal=False)
90
93
  call = await script.next_call()
91
94
  await drv.wait(lambda s: s["runLeafMessageId"] == "u1")
95
+ call.ack()
92
96
  call.ctx.set_leaf_message_id("a2")
93
97
  await drv.wait(lambda s: s["runLeafMessageId"] == "a2")
94
98
 
@@ -100,6 +104,7 @@ async def test_report_rejects_an_empty_id():
100
104
  call = await script.next_call()
101
105
  with pytest.raises(ValueError, match="non-empty"):
102
106
  call.ctx.set_leaf_message_id("")
107
+ call.ack()
103
108
  call.finish(RunManager.Complete())
104
109
 
105
110
 
@@ -0,0 +1,218 @@
1
+ """Contract: command settlement. A parked send settles accepted at staging and
2
+ stays projected in its lane; a run-starting command answers pending at
3
+ placement and settles at the executor's ``ctx.ack_messages()``. A run that
4
+ ends before the ack settles then: enqueue/steer accepted, their entries back
5
+ at the front of their lane and ``state.error`` carrying any failure;
6
+ edit/reload/continue rejected (``stopped`` or the failure)."""
7
+
8
+ import asyncio
9
+
10
+ import pytest
11
+ from run_helpers import Script, add, msg, queue_ids, run_host
12
+
13
+ from statewire import StatewireReject
14
+
15
+ from harness_sdk import RunManager
16
+
17
+
18
+ async def test_enqueue_settles_accepted_at_the_ack():
19
+ script = Script()
20
+ async with run_host(script) as (drv, host):
21
+ pending = await drv.command("run/enqueue", add("m1"), terminal=False)
22
+ assert pending["type"] == "pending"
23
+ call = await script.next_call()
24
+ call.ack()
25
+ assert (await drv.res(pending["seq"]))["type"] == "accepted"
26
+ call.finish(RunManager.Complete())
27
+
28
+
29
+ async def test_parked_send_settles_accepted_at_staging():
30
+ script = Script()
31
+ async with run_host(script) as (drv, host):
32
+ await drv.command("run/enqueue", add("m1"), terminal=False)
33
+ call = await script.next_call()
34
+ assert (await drv.command("run/enqueue", add("m2")))["type"] == "accepted"
35
+ assert (await drv.command("run/steer", add("s1")))["type"] == "accepted"
36
+ assert queue_ids(drv.replica, "queue") == ["m2"]
37
+ assert queue_ids(drv.replica, "steerQueue") == ["s1"]
38
+ call.ack()
39
+ call.finish(RunManager.Complete())
40
+
41
+
42
+ async def test_run_reject_lands_the_payload_on_state_error():
43
+ script = Script()
44
+ async with run_host(script) as (drv, host):
45
+ pending = await drv.command("run/enqueue", add("m1"), terminal=False)
46
+ call = await script.next_call()
47
+ call.fail(
48
+ StatewireReject(
49
+ "payment required",
50
+ payload={"reason": "payment-required", "url": "https://pay.example"},
51
+ )
52
+ )
53
+ assert (await drv.res(pending["seq"]))["type"] == "accepted"
54
+ await drv.wait_status("error")
55
+ assert queue_ids(drv.replica, "queue") == ["m1"]
56
+ assert drv.replica["error"] == {
57
+ "message": "payment required",
58
+ "reason": "payment-required",
59
+ "url": "https://pay.example",
60
+ }
61
+
62
+
63
+ async def test_unacked_run_end_settles_the_send_accepted():
64
+ script = Script()
65
+ async with run_host(script) as (drv, host):
66
+ for outcome in [
67
+ lambda call: call.fail(RuntimeError("boom")),
68
+ lambda call: call.finish(RunManager.Error(dispatch_queue=False)),
69
+ ]:
70
+ pending = await drv.command("run/enqueue", add("m1"), terminal=False)
71
+ outcome(await script.next_call())
72
+ assert (await drv.res(pending["seq"]))["type"] == "accepted"
73
+ await drv.wait_status("error")
74
+ assert queue_ids(drv.replica, "queue") == ["m1"]
75
+ await drv.command("run/dequeue", {"messageId": "m1"})
76
+
77
+
78
+ async def test_unacked_run_end_rejects_a_rewind_initiator():
79
+ for end, reason in [("crash", "run-error"), ("stop", "stopped")]:
80
+ script = Script()
81
+ script.thread["u1"] = {"parentId": None, "role": "user", "isLeaf": True}
82
+ async with run_host(script, capabilities=("rewind",)) as (drv, host):
83
+ pending = await drv.command(
84
+ "run/edit", {"sourceId": "u1", "message": msg("u2")}, terminal=False
85
+ )
86
+ rerun = await script.next_call()
87
+ if end == "crash":
88
+ rerun.fail(RuntimeError("boom"))
89
+ else:
90
+ await drv.command("run/stop", terminal=False)
91
+ rerun.finish(RunManager.Stop(dispatch_queue=False))
92
+ settled = await drv.res(pending["seq"])
93
+ assert settled["type"] == "rejected"
94
+ assert settled["payload"] == {"reason": reason}
95
+
96
+
97
+ async def test_unacked_run_end_reverts_entries_to_the_lane_front():
98
+ script = Script()
99
+ async with run_host(script) as (drv, host):
100
+ await drv.command("run/enqueue", add("m1"), terminal=False)
101
+ call = await script.next_call()
102
+ await drv.command("run/enqueue", add("m2"))
103
+ call.fail(RuntimeError("boom"))
104
+ await drv.wait_status("error")
105
+ assert queue_ids(drv.replica, "queue") == ["m1", "m2"]
106
+ assert drv.replica["error"] == {"message": "boom"}
107
+ assert drv.replica["queue"][0]["caller"] == {"clientId": "c1"}
108
+ assert drv.replica["runLeafMessageId"] is None
109
+ await drv.command("run/steer", {"messageId": "m1"}, terminal=False)
110
+ continued = await script.next_call()
111
+ assert continued.ctx.type == "error-continue"
112
+ assert [m["id"] for m in continued.ctx.take_steered()] == ["m1"]
113
+
114
+
115
+ async def test_unacked_run_end_reverts_taken_steered_items():
116
+ script = Script()
117
+ async with run_host(script) as (drv, host):
118
+ await drv.command("run/enqueue", add("m1"), terminal=False)
119
+ call = await script.next_call()
120
+ await drv.command("run/steer", add("s1"))
121
+ call.ctx.take_steered()
122
+ call.fail(RuntimeError("boom"))
123
+ await drv.wait_status("error")
124
+ assert queue_ids(drv.replica, "steerQueue") == ["s1"]
125
+ assert queue_ids(drv.replica, "queue") == ["m1"]
126
+
127
+
128
+ async def test_ack_removes_the_entries_for_good():
129
+ script = Script()
130
+ async with run_host(script) as (drv, host):
131
+ await drv.command("run/enqueue", add("m1"), terminal=False)
132
+ call = await script.next_call()
133
+ call.ack()
134
+ call.fail(RuntimeError("boom"))
135
+ await drv.wait_status("error")
136
+ assert queue_ids(drv.replica, "queue") == []
137
+
138
+
139
+ async def test_complete_without_ack_is_a_run_error():
140
+ script = Script()
141
+ async with run_host(script) as (drv, host):
142
+ pending = await drv.command("run/enqueue", add("m1"), terminal=False)
143
+ (await script.next_call()).finish(RunManager.Complete())
144
+ await drv.wait_status("error")
145
+ assert "ack_messages" in drv.replica["error"]["message"]
146
+ assert queue_ids(drv.replica, "queue") == ["m1"]
147
+ assert (await drv.res(pending["seq"]))["type"] == "accepted"
148
+
149
+
150
+ async def test_set_leaf_before_ack_raises():
151
+ script = Script()
152
+ async with run_host(script) as (drv, host):
153
+ await drv.command("run/enqueue", add("m1"), terminal=False)
154
+ call = await script.next_call()
155
+ with pytest.raises(RuntimeError, match="ack_messages"):
156
+ call.ctx.set_leaf_message_id("a1")
157
+ call.ack()
158
+ call.finish(RunManager.Complete())
159
+
160
+
161
+ async def test_dequeue_removes_a_parked_entry():
162
+ script = Script()
163
+ async with run_host(script) as (drv, host):
164
+ await drv.command("run/enqueue", add("m1"), terminal=False)
165
+ call = await script.next_call()
166
+ await drv.command("run/enqueue", add("m2"))
167
+ assert (await drv.command("run/dequeue", {"messageId": "m2"}))[
168
+ "type"
169
+ ] == "accepted"
170
+ assert queue_ids(drv.replica, "queue") == []
171
+ call.ack()
172
+ call.finish(RunManager.Complete())
173
+
174
+
175
+ async def test_stop_lands_while_a_steer_is_parked():
176
+ script = Script()
177
+ async with run_host(script) as (drv, host):
178
+ await drv.command("run/enqueue", add("m1"), terminal=False)
179
+ call = await script.next_call()
180
+ assert (await drv.command("run/steer", add("s1")))["type"] == "accepted"
181
+ stop = await drv.command("run/stop", terminal=False)
182
+ assert stop["type"] == "pending"
183
+ await asyncio.wait_for(call.ctx.stop_requested.wait(), 5)
184
+ call.finish(RunManager.Stop(dispatch_queue=False))
185
+ assert (await drv.res(stop["seq"]))["type"] == "accepted"
186
+ await drv.wait_status("stopped")
187
+ # The unconsumed steer stays parked; the unacked dispatch reverts.
188
+ assert queue_ids(drv.replica, "steerQueue") == ["s1"]
189
+ assert queue_ids(drv.replica, "queue") == ["m1"]
190
+
191
+
192
+ async def test_rewind_edit_settles_at_the_reruns_ack():
193
+ script = Script()
194
+ script.thread["u1"] = {"parentId": None, "role": "user", "isLeaf": True}
195
+ async with run_host(script, capabilities=("rewind",)) as (drv, host):
196
+ pending = await drv.command(
197
+ "run/edit", {"sourceId": "u1", "message": msg("u2")}, terminal=False
198
+ )
199
+ assert pending["type"] == "pending"
200
+ rerun = await script.next_call()
201
+ assert rerun.ctx.type == "message-edit"
202
+ rerun.ack()
203
+ assert (await drv.res(pending["seq"]))["type"] == "accepted"
204
+ rerun.finish(RunManager.Complete())
205
+
206
+
207
+ async def test_rewind_edit_failure_settles_with_the_payload():
208
+ script = Script()
209
+ script.thread["u1"] = {"parentId": None, "role": "user", "isLeaf": True}
210
+ async with run_host(script, capabilities=("rewind",)) as (drv, host):
211
+ pending = await drv.command(
212
+ "run/edit", {"sourceId": "u1", "message": msg("u2")}, terminal=False
213
+ )
214
+ rerun = await script.next_call()
215
+ rerun.fail(StatewireReject("payment required", payload={"code": 402}))
216
+ settled = await drv.res(pending["seq"])
217
+ assert settled["type"] == "rejected"
218
+ assert settled["payload"] == {"code": 402}
@@ -26,6 +26,7 @@ async def test_running_steer_parks_until_take_steered():
26
26
  taken = call.ctx.take_steered()
27
27
  assert [m["id"] for m in taken] == ["s1", "s2"]
28
28
  assert call.ctx.has_steered() is False
29
+ call.ack()
29
30
  call.finish(RunManager.Complete())
30
31
  await drv.wait_status("ready")
31
32
  assert queue_ids(drv.replica, "steerQueue") == []
@@ -40,6 +41,7 @@ async def test_steer_add_continues_in_error_and_stop(end, continue_type):
40
41
  async with run_host(script) as (drv, host):
41
42
  await drv.command("run/enqueue", add("m1"), terminal=False)
42
43
  call = await script.next_call()
44
+ call.ack()
43
45
  if end == "error":
44
46
  call.fail(RuntimeError("boom"))
45
47
  else:
@@ -60,6 +62,7 @@ async def test_steer_move_continues_in_error():
60
62
  await drv.command("run/enqueue", add("m1"), terminal=False)
61
63
  call = await script.next_call()
62
64
  await drv.command("run/enqueue", add("m2"), terminal=False)
65
+ call.ack()
63
66
  call.fail(RuntimeError("boom"))
64
67
  await drv.wait_status("error")
65
68
  await drv.command("run/steer", {"messageId": "m2"}, terminal=False)
@@ -75,6 +78,7 @@ async def test_steer_edit_continues_in_error_with_replacement():
75
78
  await drv.command("run/enqueue", add("m1"), terminal=False)
76
79
  call = await script.next_call()
77
80
  await drv.command("run/enqueue", add("m2"), terminal=False)
81
+ call.ack()
78
82
  call.fail(RuntimeError("boom"))
79
83
  await drv.wait_status("error")
80
84
  await drv.command("run/steer", {"message": msg("m2", "edited")}, terminal=False)
@@ -101,6 +105,7 @@ async def test_stale_context_steer_access_raises():
101
105
  async with run_host(script) as (drv, host):
102
106
  await drv.command("run/enqueue", add("m1"), terminal=False)
103
107
  call = await script.next_call()
108
+ call.ack()
104
109
  call.finish(RunManager.Complete())
105
110
  await drv.wait_status("ready")
106
111
  with pytest.raises(RuntimeError, match="already settled"):
@@ -29,6 +29,7 @@ async def test_stop_outcome_can_keep_the_queue_draining():
29
29
  call = await script.next_call()
30
30
  await drv.command("run/enqueue", add("m2"), terminal=False)
31
31
  await drv.command("run/stop", terminal=False)
32
+ call.ack()
32
33
  call.finish(RunManager.Stop(dispatch_queue=True))
33
34
  drain = await script.next_call()
34
35
  assert drain.ctx.type == "message-send"
@@ -59,6 +60,7 @@ async def test_stop_reason_resets_for_the_next_run():
59
60
  call = await script.next_call()
60
61
  await drv.command("run/enqueue", add("m2"), terminal=False)
61
62
  await drv.command("run/stop", {"reason": "user-stop"}, terminal=False)
63
+ call.ack()
62
64
  call.finish(RunManager.Stop(dispatch_queue=True))
63
65
  drain = await script.next_call()
64
66
  assert drain.ctx.stop_reason is None
@@ -91,9 +93,9 @@ async def test_stop_in_input_required_cancels_the_parked_run():
91
93
  script = Script()
92
94
  async with run_host(script) as (drv, host):
93
95
  await drv.command("run/enqueue", add("m1"), terminal=False)
94
- (await script.next_call()).finish(
95
- RunManager.InputRequired([{"type": "free-form", "id": "r1"}])
96
- )
96
+ call = await script.next_call()
97
+ call.ack()
98
+ call.finish(RunManager.InputRequired([{"type": "free-form", "id": "r1"}]))
97
99
  await drv.wait_status("input-required")
98
100
  assert (await drv.command("run/stop"))["type"] == "accepted"
99
101
  await drv.wait_status("stopped")
@@ -105,6 +107,7 @@ async def test_stop_settles_even_when_the_run_completes_normally():
105
107
  await drv.command("run/enqueue", add("m1"), terminal=False)
106
108
  call = await script.next_call()
107
109
  pending = await drv.command("run/stop", terminal=False)
110
+ call.ack()
108
111
  call.finish(RunManager.Complete())
109
112
  assert (await drv.res(pending["seq"]))["type"] == "accepted"
110
113
  await drv.wait_status("ready")
@@ -128,11 +131,14 @@ async def test_bare_continue_with_incomplete_continuation(end, continue_type):
128
131
  await drv.command("run/stop", terminal=False)
129
132
  call.finish(RunManager.Stop(dispatch_queue=False))
130
133
  await drv.wait_status(end)
131
- assert (await drv.command("run/continue"))["type"] == "accepted"
134
+ res = await drv.command("run/continue", terminal=False)
135
+ assert res["type"] == "pending"
132
136
  continued = await script.next_call()
133
137
  assert continued.ctx.type == continue_type
134
138
  assert continued.ctx.messages == ()
135
139
  assert not continued.ctx.has_rollback
140
+ continued.ack()
141
+ assert (await drv.res(res["seq"]))["type"] == "accepted"
136
142
 
137
143
 
138
144
  async def test_bare_continue_without_capability_rejects():
@@ -152,10 +158,13 @@ async def test_continue_with_steer_lane_needs_no_capability():
152
158
  await drv.command("run/steer", add("s1"), terminal=False)
153
159
  call.fail(RuntimeError("boom"))
154
160
  await drv.wait_status("error")
155
- assert (await drv.command("run/continue"))["type"] == "accepted"
161
+ res = await drv.command("run/continue", terminal=False)
162
+ assert res["type"] == "pending"
156
163
  continued = await script.next_call()
157
164
  assert continued.ctx.type == "error-continue"
158
165
  assert [m["id"] for m in continued.ctx.take_steered()] == ["s1"]
166
+ continued.ack()
167
+ assert (await drv.res(res["seq"]))["type"] == "accepted"
159
168
 
160
169
 
161
170
  @pytest.mark.parametrize("state", ["ready", "running"])
@@ -166,3 +175,21 @@ async def test_continue_rejected_outside_error_stop(state):
166
175
  await drv.command("run/enqueue", add("m1"), terminal=False)
167
176
  await script.next_call()
168
177
  assert_rejected(await drv.command("run/continue"), "wrong-state")
178
+
179
+
180
+ async def test_unacked_continued_run_end_rejects_the_continue():
181
+ script = Script()
182
+ async with run_host(script, capabilities=("incomplete-continuation",)) as (
183
+ drv,
184
+ host,
185
+ ):
186
+ await drv.command("run/enqueue", add("m1"), terminal=False)
187
+ call = await script.next_call()
188
+ call.ack()
189
+ call.fail(RuntimeError("boom"))
190
+ await drv.wait_status("error")
191
+ res = await drv.command("run/continue", terminal=False)
192
+ (await script.next_call()).fail(RuntimeError("boom again"))
193
+ settled = await drv.res(res["seq"])
194
+ assert settled["type"] == "rejected"
195
+ assert settled["payload"] == {"reason": "run-error"}
@@ -1,131 +0,0 @@
1
- """Contract: entity-lifetime settle. A run-creating command answers pending at
2
- placement and settles when its entity's run reaches a terminal state; discard
3
- before dispatch rejects with ``removed``.
4
- """
5
-
6
- import asyncio
7
-
8
- from run_helpers import Script, add, msg, run_host
9
-
10
- from statewire import StatewireReject
11
-
12
- from harness_sdk import RunManager
13
-
14
-
15
- async def test_enqueue_settles_accepted_when_its_run_completes():
16
- script = Script()
17
- async with run_host(script) as (drv, host):
18
- pending = await drv.command("run/enqueue", add("m1"), terminal=False)
19
- assert pending["type"] == "pending"
20
- call = await script.next_call()
21
- call.finish(RunManager.Complete())
22
- assert (await drv.res(pending["seq"]))["type"] == "accepted"
23
-
24
-
25
- async def test_run_reject_settles_the_entity_with_the_payload():
26
- script = Script()
27
- async with run_host(script) as (drv, host):
28
- pending = await drv.command("run/enqueue", add("m1"), terminal=False)
29
- call = await script.next_call()
30
- call.fail(
31
- StatewireReject(
32
- "payment required",
33
- payload={"reason": "payment-required", "url": "https://pay.example"},
34
- )
35
- )
36
- settled = await drv.res(pending["seq"])
37
- assert settled["type"] == "rejected"
38
- assert settled["message"] == "payment required"
39
- assert settled["payload"] == {
40
- "reason": "payment-required",
41
- "url": "https://pay.example",
42
- }
43
- await drv.wait_status("error")
44
- assert drv.replica["error"] == {
45
- "message": "payment required",
46
- "reason": "payment-required",
47
- "url": "https://pay.example",
48
- }
49
-
50
-
51
- async def test_run_crash_settles_the_entity_as_run_error():
52
- script = Script()
53
- async with run_host(script) as (drv, host):
54
- pending = await drv.command("run/enqueue", add("m1"), terminal=False)
55
- (await script.next_call()).fail(RuntimeError("boom"))
56
- settled = await drv.res(pending["seq"])
57
- assert settled["type"] == "rejected"
58
- assert settled["message"] == "boom"
59
- assert settled["payload"] == {"reason": "run-error"}
60
-
61
-
62
- async def test_error_outcome_settles_the_entity_as_run_error():
63
- script = Script()
64
- async with run_host(script) as (drv, host):
65
- pending = await drv.command("run/enqueue", add("m1"), terminal=False)
66
- (await script.next_call()).finish(RunManager.Error(dispatch_queue=False))
67
- settled = await drv.res(pending["seq"])
68
- assert settled["type"] == "rejected"
69
- assert settled["payload"] == {"reason": "run-error"}
70
-
71
-
72
- async def test_steer_settles_with_the_run_that_consumed_it():
73
- script = Script()
74
- async with run_host(script) as (drv, host):
75
- await drv.command("run/enqueue", add("m1"), terminal=False)
76
- call = await script.next_call()
77
- pending = await drv.command("run/steer", add("s1"), terminal=False)
78
- assert pending["type"] == "pending"
79
- call.ctx.take_steered()
80
- call.finish(RunManager.Complete())
81
- assert (await drv.res(pending["seq"]))["type"] == "accepted"
82
-
83
-
84
- async def test_dequeue_settles_the_queued_entity_as_removed():
85
- script = Script()
86
- async with run_host(script) as (drv, host):
87
- await drv.command("run/enqueue", add("m1"), terminal=False)
88
- call = await script.next_call()
89
- pending = await drv.command("run/enqueue", add("m2"), terminal=False)
90
- assert pending["type"] == "pending"
91
- assert (await drv.command("run/dequeue", {"messageId": "m2"}))["type"] == "accepted"
92
- settled = await drv.res(pending["seq"])
93
- assert settled["type"] == "rejected"
94
- assert settled["payload"] == {"reason": "removed"}
95
- call.finish(RunManager.Complete())
96
-
97
-
98
- async def test_stop_lands_while_a_steer_is_parked():
99
- script = Script()
100
- async with run_host(script) as (drv, host):
101
- await drv.command("run/enqueue", add("m1"), terminal=False)
102
- call = await script.next_call()
103
- steer = await drv.command("run/steer", add("s1"), terminal=False)
104
- assert steer["type"] == "pending"
105
- stop = await drv.command("run/stop", terminal=False)
106
- assert stop["type"] == "pending"
107
- await asyncio.wait_for(call.ctx.stop_requested.wait(), 5)
108
- call.finish(RunManager.Stop(dispatch_queue=False))
109
- assert (await drv.res(stop["seq"]))["type"] == "accepted"
110
- await drv.wait_status("stopped")
111
- # The unconsumed steer entity stays parked in its lane.
112
- assert [item["id"] for item in drv.replica["steerQueue"]] == ["s1"]
113
- assert all(
114
- rsp["type"] == "pending" for rsp in drv._res.get(steer["seq"], [])
115
- )
116
-
117
-
118
- async def test_rewind_edit_settles_when_the_rerun_ends():
119
- script = Script()
120
- script.thread["u1"] = {"parentId": None, "role": "user", "isLeaf": True}
121
- async with run_host(script, capabilities=("rewind",)) as (drv, host):
122
- pending = await drv.command(
123
- "run/edit", {"sourceId": "u1", "message": msg("u2")}, terminal=False
124
- )
125
- assert pending["type"] == "pending"
126
- rerun = await script.next_call()
127
- assert rerun.ctx.type == "message-edit"
128
- rerun.fail(StatewireReject("payment required", payload={"code": 402}))
129
- settled = await drv.res(pending["seq"])
130
- assert settled["type"] == "rejected"
131
- assert settled["payload"] == {"code": 402}