harness-sdk-python 0.7.0__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.0 → harness_sdk_python-0.7.2}/.gitignore +1 -0
  2. {harness_sdk_python-0.7.0 → harness_sdk_python-0.7.2}/PKG-INFO +7 -1
  3. {harness_sdk_python-0.7.0 → harness_sdk_python-0.7.2}/pyproject.toml +8 -1
  4. {harness_sdk_python-0.7.0 → harness_sdk_python-0.7.2}/src/harness_sdk/run_manager.py +97 -75
  5. {harness_sdk_python-0.7.0 → harness_sdk_python-0.7.2}/tests/test_ack_visibility.py +26 -23
  6. {harness_sdk_python-0.7.0 → harness_sdk_python-0.7.2}/tests/test_batches.py +18 -3
  7. harness_sdk_python-0.7.2/tests/test_dispatching.py +123 -0
  8. {harness_sdk_python-0.7.0 → harness_sdk_python-0.7.2}/tests/test_facade.py +9 -2
  9. {harness_sdk_python-0.7.0 → harness_sdk_python-0.7.2}/tests/test_settle.py +15 -13
  10. {harness_sdk_python-0.7.0 → harness_sdk_python-0.7.2}/tests/test_stop_continue.py +2 -0
  11. {harness_sdk_python-0.7.0 → harness_sdk_python-0.7.2}/README.md +0 -0
  12. {harness_sdk_python-0.7.0 → harness_sdk_python-0.7.2}/src/harness_sdk/__init__.py +0 -0
  13. {harness_sdk_python-0.7.0 → harness_sdk_python-0.7.2}/src/harness_sdk/fenced_postgres.py +0 -0
  14. {harness_sdk_python-0.7.0 → harness_sdk_python-0.7.2}/src/harness_sdk/linear_thread.py +0 -0
  15. {harness_sdk_python-0.7.0 → harness_sdk_python-0.7.2}/tests/run_helpers.py +0 -0
  16. {harness_sdk_python-0.7.0 → harness_sdk_python-0.7.2}/tests/test_branch_anchor.py +0 -0
  17. {harness_sdk_python-0.7.0 → harness_sdk_python-0.7.2}/tests/test_edit_dispatched.py +0 -0
  18. {harness_sdk_python-0.7.0 → harness_sdk_python-0.7.2}/tests/test_edit_reload.py +0 -0
  19. {harness_sdk_python-0.7.0 → harness_sdk_python-0.7.2}/tests/test_enqueue.py +0 -0
  20. {harness_sdk_python-0.7.0 → harness_sdk_python-0.7.2}/tests/test_fenced_postgres.py +0 -0
  21. {harness_sdk_python-0.7.0 → harness_sdk_python-0.7.2}/tests/test_input_required.py +0 -0
  22. {harness_sdk_python-0.7.0 → harness_sdk_python-0.7.2}/tests/test_linear_thread.py +0 -0
  23. {harness_sdk_python-0.7.0 → harness_sdk_python-0.7.2}/tests/test_meta.py +0 -0
  24. {harness_sdk_python-0.7.0 → harness_sdk_python-0.7.2}/tests/test_outcomes.py +0 -0
  25. {harness_sdk_python-0.7.0 → harness_sdk_python-0.7.2}/tests/test_placement.py +0 -0
  26. {harness_sdk_python-0.7.0 → harness_sdk_python-0.7.2}/tests/test_prepare_hooks.py +0 -0
  27. {harness_sdk_python-0.7.0 → harness_sdk_python-0.7.2}/tests/test_rewind_during_run.py +0 -0
  28. {harness_sdk_python-0.7.0 → harness_sdk_python-0.7.2}/tests/test_run_leaf.py +0 -0
  29. {harness_sdk_python-0.7.0 → harness_sdk_python-0.7.2}/tests/test_steer.py +0 -0
@@ -21,3 +21,4 @@ __pycache__
21
21
  /d[0-9]*.md
22
22
  /doc_*.md
23
23
  apps/docs/.docs
24
+ /.agentdoc/
@@ -1,11 +1,17 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: harness-sdk-python
3
- Version: 0.7.0
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
7
7
  Requires-Python: <4.0,>=3.11
8
8
  Requires-Dist: statewire<0.5,>=0.4.0
9
+ Provides-Extra: deepagents
10
+ Requires-Dist: deepagents>=0.6.12; extra == 'deepagents'
11
+ Requires-Dist: langchain-core>=0.3; extra == 'deepagents'
12
+ Requires-Dist: langchain-openai>=0.3; extra == 'deepagents'
13
+ Requires-Dist: langgraph>=1.0.0; extra == 'deepagents'
14
+ Requires-Dist: uvicorn[standard]>=0.30.0; extra == 'deepagents'
9
15
  Provides-Extra: postgres
10
16
  Requires-Dist: langgraph-checkpoint-postgres>=2.0.0; extra == 'postgres'
11
17
  Description-Content-Type: text/markdown
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "harness-sdk-python"
3
- version = "0.7.0"
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"
@@ -9,6 +9,13 @@ dependencies = ["statewire>=0.4.0,<0.5"]
9
9
 
10
10
  [project.optional-dependencies]
11
11
  postgres = ["langgraph-checkpoint-postgres>=2.0.0"]
12
+ deepagents = [
13
+ "deepagents>=0.6.12",
14
+ "langgraph>=1.0.0",
15
+ "langchain-core>=0.3",
16
+ "langchain-openai>=0.3",
17
+ "uvicorn[standard]>=0.30.0",
18
+ ]
12
19
 
13
20
  [project.urls]
14
21
  Repository = "https://github.com/assistant-ui/harness-sdk"
@@ -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] = {}
@@ -244,6 +242,7 @@ class RunManager:
244
242
  "status": "ready",
245
243
  "queue": [],
246
244
  "steerQueue": [],
245
+ "dispatching": [],
247
246
  "error": None,
248
247
  "runLeafMessageId": None,
249
248
  }
@@ -293,7 +292,6 @@ class RunManager:
293
292
  entries, self._intake = self._intake, []
294
293
  outcome, self._outcome = self._outcome, None
295
294
  fx = _Effects()
296
- pre_empty = not self._lane_items("steerQueue") and not self._lane_items("queue")
297
295
  for entry in entries:
298
296
  self._apply(entry, fx)
299
297
  if self._task is None:
@@ -306,31 +304,28 @@ class RunManager:
306
304
  if self._staged_rewinds:
307
305
  rewind = self._staged_rewinds.pop(0)
308
306
  self._clear_input()
307
+ if self._dispatching:
308
+ self._revert_dispatching()
309
309
  self._dispatch(
310
310
  rewind.type,
311
311
  rewind.messages,
312
312
  rollback_to=rewind.rollback_to,
313
313
  root_meta=rewind.root_meta,
314
314
  )
315
- self._dispatch_ack = rewind.ack
315
+ rewind.ack()
316
316
  if not rewind.future.done():
317
317
  self._run_futures.append(rewind.future)
318
318
  elif outcome is not None:
319
319
  self._settle_outcome(outcome)
320
320
  else:
321
- self._idle_action(fx, pre_empty)
321
+ self._idle_action(fx)
322
322
  if self._task is not None and (self._staged_stops or self._staged_rewinds):
323
323
  assert self._ctx is not None
324
324
  self._ctx.stop_requested.set()
325
- dispatching = {item["id"] for _, item in self._dispatching}
326
- for message_id, future, ack in fx.staged_sends:
327
- if message_id in dispatching:
328
- self._deferred_acks[message_id] = ack
329
- self._send_futures.append(future)
330
- else:
331
- ack()
332
- if not future.done():
333
- future.set_result(None)
325
+ for future, ack in fx.staged_sends:
326
+ ack()
327
+ if not future.done():
328
+ future.set_result(None)
334
329
  for future in fx.continues:
335
330
  if not future.done():
336
331
  future.set_result(None)
@@ -365,19 +360,22 @@ class RunManager:
365
360
  if result is not _PARKED and not entry.future.done():
366
361
  entry.future.set_result(result)
367
362
 
368
- def _idle_action(self, fx: _Effects, pre_empty: bool) -> None:
363
+ def _idle_action(self, fx: _Effects) -> None:
369
364
  status = self._status()
370
365
  if status == "ready":
371
- self._pop_dispatchable()
366
+ if self._dispatching:
367
+ self._dispatch_staged()
372
368
  elif status in ("error", "stopped"):
373
369
  if fx.continue_requested or fx.steer_added:
374
370
  self._dispatch(self._continue_type(), [], root_meta=fx.continue_meta)
375
371
  self._run_futures.extend(fx.continues)
376
372
  fx.continues.clear()
377
- elif fx.new_added and pre_empty:
378
- self._pop_dispatchable()
373
+ elif self._dispatching:
374
+ self._dispatch_staged()
379
375
  elif status == "input-required":
380
- 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"):
381
379
  self._pop_dispatchable(input_outcomes=self._take_input_outcomes())
382
380
  elif self._input_requests and len(self._input_answers) == len(
383
381
  self._input_requests
@@ -437,6 +435,7 @@ class RunManager:
437
435
  if self._task is not None:
438
436
  raise RuntimeError("a run is already in flight")
439
437
  message_meta = {m["id"]: m.get("meta") for m in messages}
438
+ dispatching = list(messages)
440
439
  messages = [
441
440
  {k: v for k, v in message.items() if k != "meta"} for message in messages
442
441
  ]
@@ -450,6 +449,7 @@ class RunManager:
450
449
  self._run_acked = False
451
450
  entry = self._ensure_entry()
452
451
  self._leaf_confirmed = plain(entry["runLeafMessageId"])
452
+ entry["dispatching"] = dispatching
453
453
  entry["error"] = None
454
454
  if messages:
455
455
  self._dispatched_ids = tuple(m["id"] for m in messages)
@@ -490,18 +490,14 @@ class RunManager:
490
490
  "run must return a RunManager outcome, got "
491
491
  f"{type(outcome).__name__}"
492
492
  )
493
- if isinstance(
494
- outcome, (RunManager.Complete, RunManager.InputRequired)
495
- ) and (self._dispatching or not self._run_acked):
496
- raise RuntimeError(
497
- "run settled without acking its messages (call ctx.ack_messages())"
498
- )
499
493
  except asyncio.CancelledError:
500
494
  await self._pull_leaf()
501
495
  self._settle(ctx)
502
496
  self._set_status("stopped")
503
497
  self._entry()["runId"] = None
504
- self._settle_initiators(_reject("stopped", "run cancelled"))
498
+ self._settle_initiators(
499
+ None if self._run_acked else _reject("stopped", "run cancelled")
500
+ )
505
501
  self._revert_dispatching()
506
502
  self._idle.set()
507
503
  raise # no drain, no freeze
@@ -511,16 +507,20 @@ class RunManager:
511
507
  message = str(exc) or type(exc).__name__
512
508
  if isinstance(exc, StatewireReject):
513
509
  self._freeze(exc.message, exc.payload)
514
- self._settle_initiators(exc)
510
+ self._settle_initiators(None if self._run_acked else exc)
515
511
  else:
516
512
  self._freeze(message)
517
- self._settle_initiators(_reject("run-error", message))
513
+ self._settle_initiators(
514
+ None if self._run_acked else _reject("run-error", message)
515
+ )
518
516
  self._revert_dispatching()
519
517
  self._drain()
520
518
  return
521
519
  await self._pull_leaf()
522
520
  self._settle(ctx)
523
- if isinstance(outcome, RunManager.Error):
521
+ if self._run_acked:
522
+ error = None
523
+ elif isinstance(outcome, RunManager.Error):
524
524
  error = _reject("run-error", "run ended in error")
525
525
  elif isinstance(outcome, RunManager.Stop):
526
526
  error = _reject("stopped", "run stopped before the messages-ack")
@@ -532,8 +532,8 @@ class RunManager:
532
532
  self._drain()
533
533
 
534
534
  async def _pull_leaf(self) -> None:
535
- # An unacked end reverts instead of recording a leaf.
536
- 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"]):
537
537
  return
538
538
  self._entry()["runLeafMessageId"] = await self._thread.get_leaf_message_id()
539
539
 
@@ -542,7 +542,6 @@ class RunManager:
542
542
  self._ctx = None
543
543
  self._task = None
544
544
  self._dispatch_record = None
545
- self._dispatch_ack = None
546
545
 
547
546
  def _settle_initiators(self, error: StatewireReject | None) -> None:
548
547
  futures, self._run_futures = self._run_futures, []
@@ -554,45 +553,65 @@ class RunManager:
554
553
  else:
555
554
  future.set_exception(error)
556
555
 
557
- def _settle_sends(self) -> None:
558
- futures, self._send_futures = self._send_futures, []
559
- for future in futures:
560
- if not future.done():
561
- future.set_result(None)
562
-
563
556
  def _ack_messages(self) -> None:
564
- if self._dispatch_ack is not None:
565
- self._dispatch_ack()
566
- self._dispatch_ack = None
567
- for _, item in self._dispatching:
568
- ack = self._deferred_acks.pop(item["id"], None)
569
- if ack is not None:
570
- ack()
571
557
  self._dispatching = []
558
+ self._entry()["dispatching"] = []
572
559
  self._run_acked = True
573
560
  self._leaf_confirmed = plain(self._entry()["runLeafMessageId"])
574
- self._settle_sends()
575
- self._settle_initiators(None)
576
561
 
577
562
  def _revert_dispatching(self) -> None:
578
- self._settle_sends()
579
563
  taken, self._dispatching = self._dispatching, []
564
+ entry = self._ensure_entry()
565
+ entry["dispatching"] = []
580
566
  if not taken:
581
567
  return
582
- entry = self._ensure_entry()
583
568
  for lane in ("steerQueue", "queue"):
584
569
  front = [item for taken_lane, item in taken if taken_lane == lane]
585
570
  if front:
586
571
  entry[lane] = front + self._lane_items(lane)
587
- for _, item in taken:
588
- ack = self._deferred_acks.pop(item["id"], None)
589
- if ack is not None:
590
- ack()
591
572
  ids = {item["id"] for _, item in taken}
592
573
  self._dispatched_ids = tuple(id for id in self._dispatched_ids if id not in ids)
593
574
  if plain(entry["runLeafMessageId"]) in ids:
594
575
  entry["runLeafMessageId"] = self._leaf_confirmed
595
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
+
596
615
  def _pop_dispatchable(
597
616
  self, *, input_outcomes: tuple[tuple[dict[str, Any], Any, Any], ...] = ()
598
617
  ) -> bool:
@@ -746,7 +765,7 @@ class RunManager:
746
765
  return
747
766
  if self._lane_of(e.anchor) is not None:
748
767
  return
749
- if e.anchor in self._dispatched_ids:
768
+ if e.anchor in self._dispatched_ids or self._is_dispatching(e.anchor):
750
769
  return
751
770
  if e.anchor_meta is None:
752
771
  raise _reject("unknown-id", f"anchor {e.anchor} names nothing")
@@ -824,11 +843,13 @@ class RunManager:
824
843
  if e.anchor is _ABSENT:
825
844
  raise _reject("invalid-message", "anchorMessageId is required")
826
845
  self._check_anchor(e)
827
- self._insert_new(e.lane, e.message, e.params, e.meta)
828
- fx.staged_sends.append((e.message_id, e.future, e.ack))
829
- fx.new_added = True
830
- if e.lane == "steerQueue":
831
- 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))
832
853
  return _PARKED
833
854
 
834
855
  def _apply_move(self, e: _Send, fx: _Effects) -> Any:
@@ -1219,6 +1240,7 @@ class RunManager:
1219
1240
  entry = manager._entry()
1220
1241
  entry["steerQueue"] = []
1221
1242
  manager._dispatching.extend(("steerQueue", item) for item in items)
1243
+ entry["dispatching"] = list(plain(entry["dispatching"])) + items
1222
1244
  if items:
1223
1245
  entry["runLeafMessageId"] = items[-1]["id"]
1224
1246
  self._ctx._message_meta.update(
@@ -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()
@@ -0,0 +1,123 @@
1
+ """Contract: replicated dispatch limbo. ``dispatching`` on the run entry is
2
+ the current dispatch's unacked messages — queue 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."""
8
+
9
+ import copy
10
+
11
+ from run_helpers import Script, add, msg, queue_ids, run_host
12
+
13
+ from harness_sdk import RunManager
14
+ from statewire_helpers import apply_ops
15
+
16
+
17
+ def dispatching_ids(replica):
18
+ runs = replica["runs"]
19
+ return [item["id"] for item in runs[0]["dispatching"]] if runs else []
20
+
21
+
22
+ def places(state, message_id):
23
+ entry = (state.get("runs") or [{}])[0]
24
+ found = {
25
+ name
26
+ for name in ("queue", "steerQueue", "dispatching")
27
+ if any(item["id"] == message_id for item in entry.get(name, []))
28
+ }
29
+ if any(m["id"] == message_id for m in state.get("messages", [])):
30
+ found.add("messages")
31
+ return found
32
+
33
+
34
+ def residence(drv, message_id):
35
+ """The item's place after each envelope, deduped: exactly one place per
36
+ envelope once it exists — the one-of-three invariant."""
37
+ state: dict = {}
38
+ trail: list[str] = []
39
+ for env in copy.deepcopy(drv.envelopes):
40
+ apply_ops(state, env)
41
+ found = places(state, message_id)
42
+ assert len(found) <= 1, found
43
+ if trail:
44
+ assert found, "item vanished between envelopes"
45
+ if found and (not trail or trail[-1] != next(iter(found))):
46
+ trail.append(next(iter(found)))
47
+ return trail
48
+
49
+
50
+ async def test_idle_send_dispatches_straight_into_dispatching():
51
+ script = Script()
52
+ async with run_host(script) as (drv, host):
53
+ drv.post("run/enqueue", add("m1"))
54
+ await script.next_call()
55
+ await drv.wait_status("running")
56
+ assert dispatching_ids(drv.replica) == ["m1"]
57
+ assert queue_ids(drv.replica, "queue") == []
58
+ assert residence(drv, "m1") == ["dispatching"]
59
+
60
+
61
+ async def test_ack_clears_dispatching_with_the_message_append():
62
+ script = Script()
63
+ async with run_host(script) as (drv, host):
64
+ seq = drv.post("run/enqueue", add("m1"))
65
+ call = await script.next_call()
66
+ await drv.wait_status("running")
67
+ assert (await drv.res(seq))["type"] == "accepted"
68
+ host.live.state["messages"] = [msg("m1")]
69
+ call.ack()
70
+ await drv.wait(lambda s: dispatching_ids(s) == [])
71
+ assert residence(drv, "m1") == ["dispatching", "messages"]
72
+ call.finish(RunManager.Complete())
73
+
74
+
75
+ async def test_unacked_run_end_returns_the_item_to_the_lane_front():
76
+ script = Script()
77
+ async with run_host(script) as (drv, host):
78
+ drv.post("run/enqueue", add("m1"))
79
+ call = await script.next_call()
80
+ await drv.side("run/enqueue", add("m2"))
81
+ await drv.wait(lambda s: queue_ids(s, "queue") == ["m2"])
82
+ await drv.side("run/stop", {"runId": await drv.run_id()})
83
+ call.finish(RunManager.Stop(dispatch_queue=False))
84
+ await drv.wait_status("stopped")
85
+ assert queue_ids(drv.replica, "queue") == ["m1", "m2"]
86
+ assert dispatching_ids(drv.replica) == []
87
+ assert residence(drv, "m1") == ["dispatching", "queue"]
88
+
89
+
90
+ async def test_steering_take_moves_the_lane_into_dispatching():
91
+ script = Script()
92
+ async with run_host(script) as (drv, host):
93
+ drv.post("run/enqueue", add("m1"))
94
+ call = await script.next_call()
95
+ host.live.state["messages"] = [msg("m1")]
96
+ call.ack()
97
+ await drv.command("run/steer", add("s1"), terminal=False)
98
+ call.ctx.steering.take()
99
+ await drv.wait(lambda s: dispatching_ids(s) == ["s1"])
100
+ assert queue_ids(drv.replica, "steerQueue") == []
101
+ assert residence(drv, "s1") == ["steerQueue", "dispatching"]
102
+ host.live.state["messages"] = [msg("m1"), msg("s1")]
103
+ call.ack()
104
+ await drv.wait(lambda s: dispatching_ids(s) == [])
105
+ assert residence(drv, "s1") == ["steerQueue", "dispatching", "messages"]
106
+ call.finish(RunManager.Complete())
107
+
108
+
109
+ async def test_edit_replacement_rides_dispatching_until_ack():
110
+ script = Script()
111
+ script.thread["u1"] = {"parentId": None, "role": "user", "isLeaf": True}
112
+ async with run_host(script, capabilities=("rewind",)) as (drv, host):
113
+ seq = drv.post("run/edit", {"sourceId": "u1", "message": msg("u2")})
114
+ rerun = await script.next_call()
115
+ assert rerun.ctx.trigger == "message-edit"
116
+ await drv.wait_status("running")
117
+ assert dispatching_ids(drv.replica) == ["u2"]
118
+ host.live.state["messages"] = [msg("u2")]
119
+ rerun.ack()
120
+ await drv.wait(lambda s: dispatching_ids(s) == [])
121
+ assert residence(drv, "u2") == ["dispatching", "messages"]
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