harness-sdk-python 0.7.1__tar.gz → 0.7.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 (29) hide show
  1. {harness_sdk_python-0.7.1 → harness_sdk_python-0.7.2}/PKG-INFO +2 -1
  2. {harness_sdk_python-0.7.1 → harness_sdk_python-0.7.2}/pyproject.toml +2 -1
  3. {harness_sdk_python-0.7.1 → harness_sdk_python-0.7.2}/src/harness_sdk/run_manager.py +90 -74
  4. {harness_sdk_python-0.7.1 → harness_sdk_python-0.7.2}/tests/test_ack_visibility.py +26 -23
  5. {harness_sdk_python-0.7.1 → harness_sdk_python-0.7.2}/tests/test_batches.py +18 -3
  6. {harness_sdk_python-0.7.1 → harness_sdk_python-0.7.2}/tests/test_dispatching.py +4 -4
  7. {harness_sdk_python-0.7.1 → harness_sdk_python-0.7.2}/tests/test_facade.py +9 -2
  8. {harness_sdk_python-0.7.1 → harness_sdk_python-0.7.2}/tests/test_settle.py +15 -13
  9. {harness_sdk_python-0.7.1 → harness_sdk_python-0.7.2}/tests/test_stop_continue.py +2 -0
  10. {harness_sdk_python-0.7.1 → harness_sdk_python-0.7.2}/.gitignore +0 -0
  11. {harness_sdk_python-0.7.1 → harness_sdk_python-0.7.2}/README.md +0 -0
  12. {harness_sdk_python-0.7.1 → harness_sdk_python-0.7.2}/src/harness_sdk/__init__.py +0 -0
  13. {harness_sdk_python-0.7.1 → harness_sdk_python-0.7.2}/src/harness_sdk/fenced_postgres.py +0 -0
  14. {harness_sdk_python-0.7.1 → harness_sdk_python-0.7.2}/src/harness_sdk/linear_thread.py +0 -0
  15. {harness_sdk_python-0.7.1 → harness_sdk_python-0.7.2}/tests/run_helpers.py +0 -0
  16. {harness_sdk_python-0.7.1 → harness_sdk_python-0.7.2}/tests/test_branch_anchor.py +0 -0
  17. {harness_sdk_python-0.7.1 → harness_sdk_python-0.7.2}/tests/test_edit_dispatched.py +0 -0
  18. {harness_sdk_python-0.7.1 → harness_sdk_python-0.7.2}/tests/test_edit_reload.py +0 -0
  19. {harness_sdk_python-0.7.1 → harness_sdk_python-0.7.2}/tests/test_enqueue.py +0 -0
  20. {harness_sdk_python-0.7.1 → harness_sdk_python-0.7.2}/tests/test_fenced_postgres.py +0 -0
  21. {harness_sdk_python-0.7.1 → harness_sdk_python-0.7.2}/tests/test_input_required.py +0 -0
  22. {harness_sdk_python-0.7.1 → harness_sdk_python-0.7.2}/tests/test_linear_thread.py +0 -0
  23. {harness_sdk_python-0.7.1 → harness_sdk_python-0.7.2}/tests/test_meta.py +0 -0
  24. {harness_sdk_python-0.7.1 → harness_sdk_python-0.7.2}/tests/test_outcomes.py +0 -0
  25. {harness_sdk_python-0.7.1 → harness_sdk_python-0.7.2}/tests/test_placement.py +0 -0
  26. {harness_sdk_python-0.7.1 → harness_sdk_python-0.7.2}/tests/test_prepare_hooks.py +0 -0
  27. {harness_sdk_python-0.7.1 → harness_sdk_python-0.7.2}/tests/test_rewind_during_run.py +0 -0
  28. {harness_sdk_python-0.7.1 → harness_sdk_python-0.7.2}/tests/test_run_leaf.py +0 -0
  29. {harness_sdk_python-0.7.1 → harness_sdk_python-0.7.2}/tests/test_steer.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: harness-sdk-python
3
- Version: 0.7.1
3
+ Version: 0.7.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
@@ -9,6 +9,7 @@ Requires-Dist: statewire<0.5,>=0.4.0
9
9
  Provides-Extra: deepagents
10
10
  Requires-Dist: deepagents>=0.6.12; extra == 'deepagents'
11
11
  Requires-Dist: langchain-core>=0.3; extra == 'deepagents'
12
+ Requires-Dist: langchain-openai>=0.3; extra == 'deepagents'
12
13
  Requires-Dist: langgraph>=1.0.0; extra == 'deepagents'
13
14
  Requires-Dist: uvicorn[standard]>=0.30.0; extra == 'deepagents'
14
15
  Provides-Extra: postgres
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "harness-sdk-python"
3
- version = "0.7.1"
3
+ version = "0.7.2"
4
4
  description = "RunManager: the harness-sdk runs subsystem for Python Statewire hosts"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -13,6 +13,7 @@ deepagents = [
13
13
  "deepagents>=0.6.12",
14
14
  "langgraph>=1.0.0",
15
15
  "langchain-core>=0.3",
16
+ "langchain-openai>=0.3",
16
17
  "uvicorn[standard]>=0.30.0",
17
18
  ]
18
19
 
@@ -4,16 +4,20 @@ Implements the full state x command matrix from runs.mdx on a single
4
4
  ``run(ctx)`` executor entrypoint, per the RunManager design (d4987).
5
5
 
6
6
  Commands stage entries into an intake list and schedule a drain; the drain
7
- reduces the whole intake into the lanes in order (pure placement), then takes
8
- exactly one action based on status and net effect (interrupt, dispatch, or
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
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).
7
+ reduces the whole intake in order, then takes exactly one action based on
8
+ status and net effect (interrupt, dispatch, or continue). A dispatchable send
9
+ appends straight into ``dispatching`` it never transits a queue lane — and
10
+ everything staged there by the end of the drain dispatches as one run. Every
11
+ send acks and settles accepted with its state-visible insert (lane or
12
+ ``dispatching``); edit, reload, and continue initiators ack at their dispatch
13
+ and settle when their run ends accepted once the run has acked its
14
+ messages, rejected otherwise; ``run/stop`` awaits a future the drain resolves
15
+ once the in-flight run has ended.
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
21
  """
18
22
 
19
23
  import asyncio
@@ -130,10 +134,9 @@ class _Rewind:
130
134
  @dataclass
131
135
  class _Effects:
132
136
  steer_added: bool = False
133
- new_added: bool = False
134
137
  continue_requested: bool = False
135
138
  continue_meta: Any = None
136
- staged_sends: list[tuple[str, "asyncio.Future[Any]", Callable[[], None]]] = field(
139
+ staged_sends: list[tuple["asyncio.Future[Any]", Callable[[], None]]] = field(
137
140
  default_factory=list
138
141
  )
139
142
  continues: list["asyncio.Future[Any]"] = field(default_factory=list)
@@ -193,13 +196,8 @@ class RunManager:
193
196
  self._stop_reason: str | None = None
194
197
  self._staged_rewinds: list[_Rewind] = []
195
198
  self._dispatching: list[tuple[str, dict[str, Any]]] = []
196
- # Deferred covering acks: they only ride an envelope whose state shows
197
- # the command's effect (message-ack append, or the revert re-insert).
198
- self._deferred_acks: dict[str, Callable[[], None]] = {}
199
- self._dispatch_ack: Callable[[], None] | None = None
200
- self._run_acked = True
199
+ self._run_acked = False
201
200
  self._leaf_confirmed: str | None = None
202
- self._send_futures: list["asyncio.Future[Any]"] = []
203
201
  self._run_futures: list["asyncio.Future[Any]"] = []
204
202
  self._input_requests: list[dict[str, Any]] = []
205
203
  self._input_answers: dict[str, Any] = {}
@@ -294,7 +292,6 @@ class RunManager:
294
292
  entries, self._intake = self._intake, []
295
293
  outcome, self._outcome = self._outcome, None
296
294
  fx = _Effects()
297
- pre_empty = not self._lane_items("steerQueue") and not self._lane_items("queue")
298
295
  for entry in entries:
299
296
  self._apply(entry, fx)
300
297
  if self._task is None:
@@ -307,31 +304,28 @@ class RunManager:
307
304
  if self._staged_rewinds:
308
305
  rewind = self._staged_rewinds.pop(0)
309
306
  self._clear_input()
307
+ if self._dispatching:
308
+ self._revert_dispatching()
310
309
  self._dispatch(
311
310
  rewind.type,
312
311
  rewind.messages,
313
312
  rollback_to=rewind.rollback_to,
314
313
  root_meta=rewind.root_meta,
315
314
  )
316
- self._dispatch_ack = rewind.ack
315
+ rewind.ack()
317
316
  if not rewind.future.done():
318
317
  self._run_futures.append(rewind.future)
319
318
  elif outcome is not None:
320
319
  self._settle_outcome(outcome)
321
320
  else:
322
- self._idle_action(fx, pre_empty)
321
+ self._idle_action(fx)
323
322
  if self._task is not None and (self._staged_stops or self._staged_rewinds):
324
323
  assert self._ctx is not None
325
324
  self._ctx.stop_requested.set()
326
- dispatching = {item["id"] for _, item in self._dispatching}
327
- for message_id, future, ack in fx.staged_sends:
328
- if message_id in dispatching:
329
- self._deferred_acks[message_id] = ack
330
- self._send_futures.append(future)
331
- else:
332
- ack()
333
- if not future.done():
334
- future.set_result(None)
325
+ for future, ack in fx.staged_sends:
326
+ ack()
327
+ if not future.done():
328
+ future.set_result(None)
335
329
  for future in fx.continues:
336
330
  if not future.done():
337
331
  future.set_result(None)
@@ -366,19 +360,22 @@ class RunManager:
366
360
  if result is not _PARKED and not entry.future.done():
367
361
  entry.future.set_result(result)
368
362
 
369
- def _idle_action(self, fx: _Effects, pre_empty: bool) -> None:
363
+ def _idle_action(self, fx: _Effects) -> None:
370
364
  status = self._status()
371
365
  if status == "ready":
372
- self._pop_dispatchable()
366
+ if self._dispatching:
367
+ self._dispatch_staged()
373
368
  elif status in ("error", "stopped"):
374
369
  if fx.continue_requested or fx.steer_added:
375
370
  self._dispatch(self._continue_type(), [], root_meta=fx.continue_meta)
376
371
  self._run_futures.extend(fx.continues)
377
372
  fx.continues.clear()
378
- elif fx.new_added and pre_empty:
379
- self._pop_dispatchable()
373
+ elif self._dispatching:
374
+ self._dispatch_staged()
380
375
  elif status == "input-required":
381
- if fx.steer_added and self._lane_items("steerQueue"):
376
+ if self._dispatching:
377
+ self._dispatch_staged(input_outcomes=self._take_input_outcomes())
378
+ elif fx.steer_added and self._lane_items("steerQueue"):
382
379
  self._pop_dispatchable(input_outcomes=self._take_input_outcomes())
383
380
  elif self._input_requests and len(self._input_answers) == len(
384
381
  self._input_requests
@@ -493,18 +490,14 @@ class RunManager:
493
490
  "run must return a RunManager outcome, got "
494
491
  f"{type(outcome).__name__}"
495
492
  )
496
- if isinstance(
497
- outcome, (RunManager.Complete, RunManager.InputRequired)
498
- ) and (self._dispatching or not self._run_acked):
499
- raise RuntimeError(
500
- "run settled without acking its messages (call ctx.ack_messages())"
501
- )
502
493
  except asyncio.CancelledError:
503
494
  await self._pull_leaf()
504
495
  self._settle(ctx)
505
496
  self._set_status("stopped")
506
497
  self._entry()["runId"] = None
507
- self._settle_initiators(_reject("stopped", "run cancelled"))
498
+ self._settle_initiators(
499
+ None if self._run_acked else _reject("stopped", "run cancelled")
500
+ )
508
501
  self._revert_dispatching()
509
502
  self._idle.set()
510
503
  raise # no drain, no freeze
@@ -514,16 +507,20 @@ class RunManager:
514
507
  message = str(exc) or type(exc).__name__
515
508
  if isinstance(exc, StatewireReject):
516
509
  self._freeze(exc.message, exc.payload)
517
- self._settle_initiators(exc)
510
+ self._settle_initiators(None if self._run_acked else exc)
518
511
  else:
519
512
  self._freeze(message)
520
- self._settle_initiators(_reject("run-error", message))
513
+ self._settle_initiators(
514
+ None if self._run_acked else _reject("run-error", message)
515
+ )
521
516
  self._revert_dispatching()
522
517
  self._drain()
523
518
  return
524
519
  await self._pull_leaf()
525
520
  self._settle(ctx)
526
- if isinstance(outcome, RunManager.Error):
521
+ if self._run_acked:
522
+ error = None
523
+ elif isinstance(outcome, RunManager.Error):
527
524
  error = _reject("run-error", "run ended in error")
528
525
  elif isinstance(outcome, RunManager.Stop):
529
526
  error = _reject("stopped", "run stopped before the messages-ack")
@@ -535,8 +532,8 @@ class RunManager:
535
532
  self._drain()
536
533
 
537
534
  async def _pull_leaf(self) -> None:
538
- # An unacked end reverts instead of recording a leaf.
539
- if self._dispatching or not self._run_acked:
535
+ # An end with unacked messages reverts instead of recording a leaf.
536
+ if plain(self._entry()["dispatching"]):
540
537
  return
541
538
  self._entry()["runLeafMessageId"] = await self._thread.get_leaf_message_id()
542
539
 
@@ -545,7 +542,6 @@ class RunManager:
545
542
  self._ctx = None
546
543
  self._task = None
547
544
  self._dispatch_record = None
548
- self._dispatch_ack = None
549
545
 
550
546
  def _settle_initiators(self, error: StatewireReject | None) -> None:
551
547
  futures, self._run_futures = self._run_futures, []
@@ -557,29 +553,13 @@ class RunManager:
557
553
  else:
558
554
  future.set_exception(error)
559
555
 
560
- def _settle_sends(self) -> None:
561
- futures, self._send_futures = self._send_futures, []
562
- for future in futures:
563
- if not future.done():
564
- future.set_result(None)
565
-
566
556
  def _ack_messages(self) -> None:
567
- if self._dispatch_ack is not None:
568
- self._dispatch_ack()
569
- self._dispatch_ack = None
570
- for _, item in self._dispatching:
571
- ack = self._deferred_acks.pop(item["id"], None)
572
- if ack is not None:
573
- ack()
574
557
  self._dispatching = []
575
558
  self._entry()["dispatching"] = []
576
559
  self._run_acked = True
577
560
  self._leaf_confirmed = plain(self._entry()["runLeafMessageId"])
578
- self._settle_sends()
579
- self._settle_initiators(None)
580
561
 
581
562
  def _revert_dispatching(self) -> None:
582
- self._settle_sends()
583
563
  taken, self._dispatching = self._dispatching, []
584
564
  entry = self._ensure_entry()
585
565
  entry["dispatching"] = []
@@ -589,15 +569,49 @@ class RunManager:
589
569
  front = [item for taken_lane, item in taken if taken_lane == lane]
590
570
  if front:
591
571
  entry[lane] = front + self._lane_items(lane)
592
- for _, item in taken:
593
- ack = self._deferred_acks.pop(item["id"], None)
594
- if ack is not None:
595
- ack()
596
572
  ids = {item["id"] for _, item in taken}
597
573
  self._dispatched_ids = tuple(id for id in self._dispatched_ids if id not in ids)
598
574
  if plain(entry["runLeafMessageId"]) in ids:
599
575
  entry["runLeafMessageId"] = self._leaf_confirmed
600
576
 
577
+ def _dispatchable(self, lane: str) -> bool:
578
+ if self._task is not None or self._staged_rewinds:
579
+ return False
580
+ if self._dispatching:
581
+ return True
582
+ status = self._status()
583
+ if status == "ready":
584
+ return True
585
+ if status == "input-required":
586
+ return lane == "steerQueue"
587
+ if status in ("error", "stopped"):
588
+ return (
589
+ lane == "queue"
590
+ and not self._lane_items("queue")
591
+ and not self._lane_items("steerQueue")
592
+ )
593
+ return False
594
+
595
+ def _stage_dispatch(self, e: _Send) -> None:
596
+ assert e.message is not None
597
+ steer = self._lane_items("steerQueue") if e.lane == "steerQueue" else []
598
+ if len(self._dispatching) + len(steer) >= self._max_queued:
599
+ raise _reject("queue-full", f"queue is full ({self._max_queued} messages)")
600
+ if steer:
601
+ self._entry()["steerQueue"] = []
602
+ 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]
605
+
606
+ def _dispatch_staged(
607
+ self, *, input_outcomes: tuple[tuple[dict[str, Any], Any, Any], ...] = ()
608
+ ) -> None:
609
+ self._dispatch(
610
+ "message-send",
611
+ [item for _, item in self._dispatching],
612
+ input_outcomes=input_outcomes,
613
+ )
614
+
601
615
  def _pop_dispatchable(
602
616
  self, *, input_outcomes: tuple[tuple[dict[str, Any], Any, Any], ...] = ()
603
617
  ) -> bool:
@@ -751,7 +765,7 @@ class RunManager:
751
765
  return
752
766
  if self._lane_of(e.anchor) is not None:
753
767
  return
754
- if e.anchor in self._dispatched_ids:
768
+ if e.anchor in self._dispatched_ids or self._is_dispatching(e.anchor):
755
769
  return
756
770
  if e.anchor_meta is None:
757
771
  raise _reject("unknown-id", f"anchor {e.anchor} names nothing")
@@ -829,11 +843,13 @@ class RunManager:
829
843
  if e.anchor is _ABSENT:
830
844
  raise _reject("invalid-message", "anchorMessageId is required")
831
845
  self._check_anchor(e)
832
- self._insert_new(e.lane, e.message, e.params, e.meta)
833
- fx.staged_sends.append((e.message_id, e.future, e.ack))
834
- fx.new_added = True
835
- if e.lane == "steerQueue":
836
- fx.steer_added = True
846
+ if self._dispatchable(e.lane):
847
+ self._stage_dispatch(e)
848
+ else:
849
+ self._insert_new(e.lane, e.message, e.params, e.meta)
850
+ if e.lane == "steerQueue":
851
+ fx.steer_added = True
852
+ fx.staged_sends.append((e.future, e.ack))
837
853
  return _PARKED
838
854
 
839
855
  def _apply_move(self, e: _Send, fx: _Effects) -> Any:
@@ -1,8 +1,8 @@
1
- """Contract: settle-after-visibility. A send or rewind's covering ack only
2
- rides an envelope whose state carries the command's effect — the message in
3
- ``state.messages`` (the executor appends it before ``ctx.ack_messages()``) or
4
- in a published queue lane. A parked send acks with its insert; a dispatched
5
- entry whose run ends unacked acks with the revert that re-publishes it."""
1
+ """Contract: acks ride visibility. A send's covering ack only rides an
2
+ envelope whose state carries the command's effect — the item in a published
3
+ queue lane or in ``dispatching``. An idle send's insert lands in
4
+ ``dispatching`` directly, so its ack rides the dispatch envelope and the queue
5
+ lane never holds the item; a rewind's ack rides its dispatch the same way."""
6
6
 
7
7
  import copy
8
8
 
@@ -22,26 +22,28 @@ def covered_state(drv, seq):
22
22
  raise AssertionError(f"no envelope covers seq {seq}")
23
23
 
24
24
 
25
- def lane_and_message_ids(state):
25
+ def dispatching_ids(state):
26
26
  runs = state.get("runs") or [{}]
27
- return {
28
- *(m["id"] for m in state.get("messages", [])),
29
- *(item["id"] for item in runs[0].get("queue", [])),
30
- *(item["id"] for item in runs[0].get("steerQueue", [])),
31
- }
27
+ return [item["id"] for item in runs[0].get("dispatching", [])]
32
28
 
33
29
 
34
- async def test_dispatched_send_acks_with_the_message_ack_append():
30
+ def assert_never_queued(drv, message_id):
31
+ state: dict = {}
32
+ for env in copy.deepcopy(drv.envelopes):
33
+ apply_ops(state, env)
34
+ assert message_id not in queue_ids(state, "queue")
35
+
36
+
37
+ async def test_idle_send_acks_with_its_dispatching_insert():
35
38
  script = Script()
36
39
  async with run_host(script) as (drv, host):
37
40
  seq = drv.post("run/enqueue", add("m1"))
38
41
  call = await script.next_call()
39
- await drv.wait_status("running")
40
- assert all("ack" not in env for env in drv.envelopes)
42
+ assert (await drv.res(seq))["type"] == "accepted"
43
+ assert dispatching_ids(covered_state(drv, seq)) == ["m1"]
44
+ assert_never_queued(drv, "m1")
41
45
  host.live.state["messages"] = [msg("m1")]
42
46
  call.ack()
43
- assert (await drv.res(seq))["type"] == "accepted"
44
- assert "m1" in lane_and_message_ids(covered_state(drv, seq))
45
47
  call.finish(RunManager.Complete())
46
48
 
47
49
 
@@ -57,17 +59,19 @@ async def test_parked_send_acks_with_its_queue_insert():
57
59
  call.finish(RunManager.Complete())
58
60
 
59
61
 
60
- async def test_unacked_run_end_acks_with_the_revert():
62
+ async def test_unacked_run_end_reverts_the_direct_dispatch_to_the_queue():
61
63
  script = Script()
62
64
  async with run_host(script) as (drv, host):
63
65
  seq = drv.post("run/enqueue", add("m1"))
64
66
  call = await script.next_call()
65
- call.fail(RuntimeError("boom"))
66
67
  assert (await drv.res(seq))["type"] == "accepted"
67
- assert "m1" in queue_ids(covered_state(drv, seq), "queue")
68
+ assert dispatching_ids(covered_state(drv, seq)) == ["m1"]
69
+ call.fail(RuntimeError("boom"))
70
+ await drv.wait_status("error")
71
+ assert queue_ids(drv.replica, "queue") == ["m1"]
68
72
 
69
73
 
70
- async def test_rewind_edit_acks_with_the_message_ack_append():
74
+ async def test_rewind_edit_acks_with_its_dispatch():
71
75
  script = Script()
72
76
  script.thread["u1"] = {"parentId": None, "role": "user", "isLeaf": True}
73
77
  async with run_host(script, capabilities=("rewind",)) as (drv, host):
@@ -75,9 +79,8 @@ async def test_rewind_edit_acks_with_the_message_ack_append():
75
79
  rerun = await script.next_call()
76
80
  assert rerun.ctx.trigger == "message-edit"
77
81
  await drv.wait_status("running")
78
- assert all("ack" not in env for env in drv.envelopes)
82
+ assert dispatching_ids(covered_state(drv, seq)) == ["u2"]
79
83
  host.live.state["messages"] = [msg("u2")]
80
84
  rerun.ack()
81
- assert (await drv.res(seq))["type"] == "accepted"
82
- assert "u2" in lane_and_message_ids(covered_state(drv, seq))
83
85
  rerun.finish(RunManager.Complete())
86
+ assert (await drv.res(seq))["type"] == "accepted"
@@ -31,6 +31,21 @@ async def test_multi_steer_batch_places_all_and_dispatches_once():
31
31
  assert (await drv.res(first + offset))["type"] == "accepted"
32
32
 
33
33
 
34
+ async def test_same_tick_sends_dispatch_as_one_run():
35
+ script = Script()
36
+ async with run_host(script) as (drv, host):
37
+ first = drv.batch(
38
+ [("run/enqueue", add("m1")), ("run/enqueue", add("m2", anchor="m1"))]
39
+ )
40
+ call = await script.next_call()
41
+ assert [m["id"] for m in call.ctx.messages] == ["m1", "m2"]
42
+ script.no_call()
43
+ assert (await drv.res(first))["type"] == "accepted"
44
+ assert (await drv.res(first + 1))["type"] == "accepted"
45
+ call.ack()
46
+ call.finish(RunManager.Complete())
47
+
48
+
34
49
  async def test_steer_and_stop_in_one_batch_nets_to_stop():
35
50
  script = Script()
36
51
  async with run_host(script) as (drv, host):
@@ -92,7 +107,7 @@ async def test_handler_tail_staging_decides_in_its_own_envelope():
92
107
  )
93
108
  )
94
109
  # The decide ran outside the batch segment: its ops flush in an
95
- # envelope of their own; the covering ack waits for the message-ack.
96
- assert "ack" not in dispatch_env and "res" not in dispatch_env
97
- call.ack()
110
+ # envelope of their own, carrying the send's covering ack.
111
+ assert dispatch_env.get("ack", 0) >= seq
98
112
  assert (await drv.res(seq))["type"] == "accepted"
113
+ call.ack()
@@ -64,10 +64,10 @@ async def test_ack_clears_dispatching_with_the_message_append():
64
64
  seq = drv.post("run/enqueue", add("m1"))
65
65
  call = await script.next_call()
66
66
  await drv.wait_status("running")
67
+ assert (await drv.res(seq))["type"] == "accepted"
67
68
  host.live.state["messages"] = [msg("m1")]
68
69
  call.ack()
69
- assert (await drv.res(seq))["type"] == "accepted"
70
- assert dispatching_ids(drv.replica) == []
70
+ await drv.wait(lambda s: dispatching_ids(s) == [])
71
71
  assert residence(drv, "m1") == ["dispatching", "messages"]
72
72
  call.finish(RunManager.Complete())
73
73
 
@@ -117,7 +117,7 @@ async def test_edit_replacement_rides_dispatching_until_ack():
117
117
  assert dispatching_ids(drv.replica) == ["u2"]
118
118
  host.live.state["messages"] = [msg("u2")]
119
119
  rerun.ack()
120
- assert (await drv.res(seq))["type"] == "accepted"
121
- assert dispatching_ids(drv.replica) == []
120
+ await drv.wait(lambda s: dispatching_ids(s) == [])
122
121
  assert residence(drv, "u2") == ["dispatching", "messages"]
123
122
  rerun.finish(RunManager.Complete())
123
+ assert (await drv.res(seq))["type"] == "accepted"
@@ -8,7 +8,7 @@ import asyncio
8
8
 
9
9
  from run_helpers import Script
10
10
  from statewire_helpers import statewire_client
11
- from test_assistant_transport import _PostStream, data_stream_frames
11
+ from test_assistant_transport import _PostStream, data_stream_frames, state_ops
12
12
 
13
13
  from statewire import AssistantTransport, StatewireReject, command
14
14
 
@@ -63,13 +63,20 @@ async def test_chat_holds_until_the_entity_run_settles_then_eofs():
63
63
  assert stream.status == 200
64
64
  call = await script.next_call()
65
65
  assert call.ctx.trigger == "message-send"
66
+ instance = (await app.state.pinned_host.directory.get("t1")).instance
67
+ instance.state["messages"] = [dict(call.ctx.messages[0])]
66
68
  call.ack()
67
69
  await asyncio.sleep(0.05)
68
70
  assert stream._task is not None and not stream._task.done()
69
71
  call.finish(RunManager.Complete())
70
72
  tail = await stream.until_closed()
71
73
  frames = data_stream_frames(tail.decode())
72
- assert all(prefix == "aui-state" for prefix, _ in frames)
74
+ assert frames and all(prefix == "aui-state" for prefix, _ in frames)
75
+ ops = state_ops(frames)
76
+ assert ops[0]["type"] == "set" and ops[0]["path"] == []
77
+ assert [m["id"] for m in ops[0]["value"]["messages"]] == [
78
+ call.ctx.messages[0]["id"]
79
+ ]
73
80
 
74
81
 
75
82
  async def test_chat_run_reject_is_one_error_frame_then_eof():
@@ -1,10 +1,10 @@
1
- """Contract: command settlement. A parked send settles accepted at staging and
2
- stays projected in its lane; a run-starting command's receipt and covering ack
3
- are held until the executor's ``ctx.ack_messages()`` publishes its messages. A
4
- run that ends before the ack settles then: enqueue/steer accepted, their
5
- entries back at the front of their lane (the covering ack riding that revert)
6
- and ``state.error`` carrying any failure; edit/reload/continue rejected
7
- (``stopped`` or the failure)."""
1
+ """Contract: command settlement. Every send settles accepted with its
2
+ state-visible insert a lane entry or a direct ``dispatching`` entry. Edit,
3
+ reload, and continue settle when their run ends: accepted once the run has
4
+ acked its messages, rejected (``stopped``, ``run-error``, or the failure)
5
+ otherwise. A run that ends with unacked entries returns lane-origin items to
6
+ the front of their lane, direct dispatches to the front of the queue, and
7
+ lands any failure on ``state.error``."""
8
8
 
9
9
  import asyncio
10
10
 
@@ -132,15 +132,17 @@ async def test_ack_removes_the_entries_for_good():
132
132
  assert queue_ids(drv.replica, "queue") == []
133
133
 
134
134
 
135
- async def test_complete_without_ack_is_a_run_error():
135
+ async def test_complete_without_ack_reverts_and_redispatches():
136
136
  script = Script()
137
137
  async with run_host(script) as (drv, host):
138
138
  seq = drv.post("run/enqueue", add("m1"))
139
139
  (await script.next_call()).finish(RunManager.Complete())
140
- await drv.wait_status("error")
141
- assert "ack_messages" in drv.run["error"]["message"]
142
- assert queue_ids(drv.replica, "queue") == ["m1"]
140
+ redo = await script.next_call()
141
+ assert [m["id"] for m in redo.ctx.messages] == ["m1"]
143
142
  assert (await drv.res(seq))["type"] == "accepted"
143
+ redo.ack()
144
+ redo.finish(RunManager.Complete())
145
+ await drv.wait_status("ready")
144
146
 
145
147
 
146
148
  async def test_dequeue_removes_a_parked_entry():
@@ -172,7 +174,7 @@ async def test_stop_lands_while_a_steer_is_parked():
172
174
  assert queue_ids(drv.replica, "queue") == ["m1"]
173
175
 
174
176
 
175
- async def test_rewind_edit_settles_at_the_reruns_ack():
177
+ async def test_rewind_edit_settles_at_the_reruns_end():
176
178
  script = Script()
177
179
  script.thread["u1"] = {"parentId": None, "role": "user", "isLeaf": True}
178
180
  async with run_host(script, capabilities=("rewind",)) as (drv, host):
@@ -180,8 +182,8 @@ async def test_rewind_edit_settles_at_the_reruns_ack():
180
182
  rerun = await script.next_call()
181
183
  assert rerun.ctx.trigger == "message-edit"
182
184
  rerun.ack()
183
- assert (await drv.res(seq))["type"] == "accepted"
184
185
  rerun.finish(RunManager.Complete())
186
+ assert (await drv.res(seq))["type"] == "accepted"
185
187
 
186
188
 
187
189
  async def test_rewind_edit_failure_settles_with_the_payload():
@@ -209,6 +209,7 @@ async def test_bare_continue_with_incomplete_continuation(end, continue_type):
209
209
  assert continued.ctx.messages == ()
210
210
  assert not continued.ctx.has_rollback
211
211
  continued.ack()
212
+ continued.finish(RunManager.Complete())
212
213
  assert (await drv.res(res["seq"]))["type"] == "accepted"
213
214
 
214
215
 
@@ -235,6 +236,7 @@ async def test_continue_with_steer_lane_needs_no_capability():
235
236
  assert continued.ctx.trigger == "error-continue"
236
237
  assert [m["id"] for m in continued.ctx.steering.take()] == ["s1"]
237
238
  continued.ack()
239
+ continued.finish(RunManager.Complete())
238
240
  assert (await drv.res(res["seq"]))["type"] == "accepted"
239
241
 
240
242