harness-sdk-python 0.10.1__tar.gz → 0.11.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. {harness_sdk_python-0.10.1 → harness_sdk_python-0.11.0}/PKG-INFO +1 -1
  2. {harness_sdk_python-0.10.1 → harness_sdk_python-0.11.0}/examples/runs_app.py +3 -5
  3. {harness_sdk_python-0.10.1 → harness_sdk_python-0.11.0}/pyproject.toml +1 -1
  4. {harness_sdk_python-0.10.1 → harness_sdk_python-0.11.0}/src/harness_sdk/run_manager.py +65 -25
  5. {harness_sdk_python-0.10.1 → harness_sdk_python-0.11.0}/tests/test_ack_visibility.py +4 -4
  6. {harness_sdk_python-0.10.1 → harness_sdk_python-0.11.0}/tests/test_branch_anchor.py +35 -0
  7. {harness_sdk_python-0.10.1 → harness_sdk_python-0.11.0}/tests/test_dispatching.py +5 -5
  8. {harness_sdk_python-0.10.1 → harness_sdk_python-0.11.0}/tests/test_meta.py +0 -17
  9. harness_sdk_python-0.11.0/tests/test_restore.py +136 -0
  10. {harness_sdk_python-0.10.1 → harness_sdk_python-0.11.0}/tests/test_settle.py +23 -27
  11. harness_sdk_python-0.10.1/tests/test_facade.py +0 -100
  12. {harness_sdk_python-0.10.1 → harness_sdk_python-0.11.0}/.gitignore +0 -0
  13. {harness_sdk_python-0.10.1 → harness_sdk_python-0.11.0}/README.md +0 -0
  14. {harness_sdk_python-0.10.1 → harness_sdk_python-0.11.0}/examples/__init__.py +0 -0
  15. {harness_sdk_python-0.10.1 → harness_sdk_python-0.11.0}/src/harness_sdk/__init__.py +0 -0
  16. {harness_sdk_python-0.10.1 → harness_sdk_python-0.11.0}/src/harness_sdk/fenced_postgres.py +0 -0
  17. {harness_sdk_python-0.10.1 → harness_sdk_python-0.11.0}/src/harness_sdk/linear_thread.py +0 -0
  18. {harness_sdk_python-0.10.1 → harness_sdk_python-0.11.0}/tests/run_helpers.py +0 -0
  19. {harness_sdk_python-0.10.1 → harness_sdk_python-0.11.0}/tests/test_batches.py +0 -0
  20. {harness_sdk_python-0.10.1 → harness_sdk_python-0.11.0}/tests/test_edit_dispatched.py +0 -0
  21. {harness_sdk_python-0.10.1 → harness_sdk_python-0.11.0}/tests/test_edit_reload.py +0 -0
  22. {harness_sdk_python-0.10.1 → harness_sdk_python-0.11.0}/tests/test_enqueue.py +0 -0
  23. {harness_sdk_python-0.10.1 → harness_sdk_python-0.11.0}/tests/test_fenced_postgres.py +0 -0
  24. {harness_sdk_python-0.10.1 → harness_sdk_python-0.11.0}/tests/test_input_required.py +0 -0
  25. {harness_sdk_python-0.10.1 → harness_sdk_python-0.11.0}/tests/test_linear_thread.py +0 -0
  26. {harness_sdk_python-0.10.1 → harness_sdk_python-0.11.0}/tests/test_outcomes.py +0 -0
  27. {harness_sdk_python-0.10.1 → harness_sdk_python-0.11.0}/tests/test_placement.py +0 -0
  28. {harness_sdk_python-0.10.1 → harness_sdk_python-0.11.0}/tests/test_prepare_hooks.py +0 -0
  29. {harness_sdk_python-0.10.1 → harness_sdk_python-0.11.0}/tests/test_rewind_during_run.py +0 -0
  30. {harness_sdk_python-0.10.1 → harness_sdk_python-0.11.0}/tests/test_steer.py +0 -0
  31. {harness_sdk_python-0.10.1 → harness_sdk_python-0.11.0}/tests/test_stop_continue.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: harness-sdk-python
3
- Version: 0.10.1
3
+ Version: 0.11.0
4
4
  Summary: RunManager: the harness-sdk runs subsystem for Python Statewire hosts
5
5
  Project-URL: Repository, https://github.com/assistant-ui/harness-sdk
6
6
  License-Expression: MIT
@@ -6,7 +6,7 @@ Run from python/harness-sdk-python:
6
6
 
7
7
  State is ``{"messages": [...]}`` in langchain format; rewind truncates the
8
8
  list. Threads live at /threads/{id}: ``GET .../stream`` (SSE), ``.../ws``,
9
- ``POST .../commands``, and ``POST .../assistant-transport/api/chat``.
9
+ ``POST .../commands``.
10
10
 
11
11
  Set PINBOARD_URL, PINBOARD_TOKEN, and ADVERTISE_URL to register with a
12
12
  pinboard control plane instead of local placement.
@@ -20,7 +20,7 @@ from typing import Any
20
20
  from fastapi import FastAPI
21
21
  from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
22
22
  from pinned import PinnedHost
23
- from statewire import AssistantTransport, command, plain
23
+ from statewire import Statewire, command, plain
24
24
  from statewire.langgraph import append_langgraph_event
25
25
 
26
26
  from harness_sdk import RunManager, linear_thread
@@ -72,9 +72,7 @@ def user_text(message: dict[str, Any]) -> str:
72
72
  )
73
73
 
74
74
 
75
- class RunsHost(AssistantTransport):
76
- assistant_transport_protocol = "assistant-transport"
77
-
75
+ class RunsHost(Statewire):
78
76
  async def lifespan(self):
79
77
  self.agent = build_agent()
80
78
  self.state = {"messages": []}
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "harness-sdk-python"
3
- version = "0.10.1"
3
+ version = "0.11.0"
4
4
  description = "RunManager: the harness-sdk runs subsystem for Python Statewire hosts"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -15,10 +15,9 @@ in-flight run has ended.
15
15
 
16
16
  ``entry["dispatching"]`` is the dispatch record (or None): the payload the
17
17
  run callback receives. ``ctx.ack()`` clears it; ``steering.take()`` may
18
- refill it — at most one unacked batch ever exists. A run that ends with an
19
- unacked record reverts its items
20
- by provenance: lane items to the front of their lane, direct dispatches to
21
- the front of the queue, rewind replacements dropped.
18
+ refill it — at most one unacked batch ever exists. Settle consumes the
19
+ delivered batch, acked or not; steer entries still undelivered in the lane
20
+ stay queued for the next run.
22
21
  """
23
22
 
24
23
  import asyncio
@@ -216,18 +215,60 @@ class RunManager:
216
215
  return self._idle
217
216
 
218
217
  def _init_state(self) -> None:
219
- # Runs state is write-only and not durable: overwrite whatever is there.
220
- self._state["status"] = "ready"
221
- self._state["runs"] = []
222
- for key in (
223
- "error",
224
- "queue",
225
- "steerQueue",
226
- "runId",
227
- "dispatch",
228
- "inputRequests",
229
- ):
230
- self._state.pop(key, None)
218
+ runs = plain(self._state.get("runs"))
219
+ if runs is None or runs == []:
220
+ self._state["status"] = "ready"
221
+ self._state["runs"] = []
222
+ for key in (
223
+ "error",
224
+ "queue",
225
+ "steerQueue",
226
+ "runId",
227
+ "dispatch",
228
+ "inputRequests",
229
+ ):
230
+ self._state.pop(key, None)
231
+ return
232
+ self._hydrate(runs)
233
+
234
+ def _hydrate(self, runs: Any) -> None:
235
+ if not isinstance(runs, list) or len(runs) != 1:
236
+ raise ValueError("state['runs'] must hold exactly one entry to restore")
237
+ entry = runs[0]
238
+ if not isinstance(entry, dict):
239
+ raise ValueError("the restored run entry must be an object")
240
+ if entry.get("status") != "input-required":
241
+ raise ValueError(
242
+ "only an input-required entry restores, "
243
+ f"got status {entry.get('status')!r}"
244
+ )
245
+ run_id = entry.get("runId")
246
+ if not isinstance(run_id, str) or run_id == "":
247
+ raise ValueError("the restored runId must be a non-empty string")
248
+ for lane in ("queue", "steerQueue"):
249
+ if not isinstance(entry.get(lane), list):
250
+ raise ValueError(f"the restored {lane} must be a list")
251
+ for key in ("dispatching", "error"):
252
+ if entry.get(key, _ABSENT) is not None:
253
+ raise ValueError(f"the restored entry must carry {key}: None")
254
+ requests = entry.get("inputRequests")
255
+ if not isinstance(requests, list) or not requests:
256
+ raise ValueError("the restored inputRequests must be a non-empty list")
257
+ for request in requests:
258
+ if not isinstance(request, dict):
259
+ raise ValueError("restored input requests must be objects")
260
+ if request.get("response") is not None:
261
+ raise ValueError(
262
+ "restored input requests must carry response: None"
263
+ )
264
+ outcome = RunManager.InputRequired(
265
+ tuple(
266
+ {k: v for k, v in request.items() if k != "response"}
267
+ for request in requests
268
+ )
269
+ )
270
+ self._input_requests = [dict(r) for r in outcome.requests]
271
+ self._state["status"] = "input-required"
231
272
 
232
273
  # ─── State access ───────────────────────────────────────
233
274
 
@@ -308,7 +349,7 @@ class RunManager:
308
349
  rewind = self._staged_rewinds.pop(0)
309
350
  self._clear_input()
310
351
  if self._dispatching:
311
- self._revert_dispatching()
352
+ self._unstage_dispatching()
312
353
  entry = self._ensure_entry()
313
354
  if entry["runId"] is None:
314
355
  entry["runId"] = rewind.run_id
@@ -492,7 +533,6 @@ class RunManager:
492
533
  except asyncio.CancelledError:
493
534
  self._settle(ctx)
494
535
  self._set_status("stopped")
495
- self._revert_dispatching()
496
536
  self._idle.set()
497
537
  raise # no drain, no freeze
498
538
  except Exception as exc:
@@ -502,11 +542,9 @@ class RunManager:
502
542
  self._freeze(exc.message, exc.payload)
503
543
  else:
504
544
  self._freeze(message)
505
- self._revert_dispatching()
506
545
  self._drain()
507
546
  return
508
547
  self._settle(ctx)
509
- self._revert_dispatching()
510
548
  self._outcome = outcome
511
549
  self._drain()
512
550
 
@@ -515,6 +553,12 @@ class RunManager:
515
553
  self._ctx = None
516
554
  self._task = None
517
555
  self._dispatch_record = None
556
+ self._dispatching = []
557
+ entry = self._ensure_entry()
558
+ record = plain(entry["dispatching"])
559
+ if record is not None and record["trigger"] != "steer" and record["messages"]:
560
+ self._dispatched_ids = ()
561
+ entry["dispatching"] = None
518
562
 
519
563
  def _ack(self) -> None:
520
564
  entry = self._entry()
@@ -524,18 +568,14 @@ class RunManager:
524
568
  self._dispatching = []
525
569
  entry["dispatching"] = None
526
570
 
527
- def _revert_dispatching(self) -> None:
571
+ def _unstage_dispatching(self) -> None:
528
572
  taken, self._dispatching = self._dispatching, []
529
573
  entry = self._ensure_entry()
530
574
  entry["dispatching"] = None
531
- if not taken:
532
- return
533
575
  for lane in ("steerQueue", "queue"):
534
576
  front = [item for taken_lane, item in taken if taken_lane == lane]
535
577
  if front:
536
578
  entry[lane] = front + self._lane_items(lane)
537
- ids = {item["id"] for _, item in taken}
538
- self._dispatched_ids = tuple(id for id in self._dispatched_ids if id not in ids)
539
579
 
540
580
  def _dispatchable(self, lane: str) -> bool:
541
581
  if self._task is not None or self._staged_rewinds:
@@ -60,7 +60,7 @@ async def test_parked_send_acks_with_its_queue_insert():
60
60
  call.finish(RunManager.Complete())
61
61
 
62
62
 
63
- async def test_unacked_run_end_reverts_the_direct_dispatch_to_the_queue():
63
+ async def test_unacked_run_end_consumes_the_direct_dispatch():
64
64
  script = Script()
65
65
  async with run_host(script) as (drv, host):
66
66
  seq = drv.post("run/enqueue", add("m1"))
@@ -69,10 +69,10 @@ async def test_unacked_run_end_reverts_the_direct_dispatch_to_the_queue():
69
69
  assert dispatching_ids(covered_state(drv, seq)) == ["m1"]
70
70
  call.fail(RuntimeError("boom"))
71
71
  await drv.wait_status("error")
72
- assert queue_ids(drv.replica, "queue") == ["m1"]
72
+ assert queue_ids(drv.replica, "queue") == []
73
73
 
74
74
 
75
- async def test_unacked_run_end_reverts_the_direct_steer_send_to_the_steer_lane():
75
+ async def test_unacked_run_end_consumes_the_direct_steer_send():
76
76
  script = Script()
77
77
  async with run_host(script) as (drv, host):
78
78
  seq = drv.post("run/steer", add("s1"))
@@ -81,7 +81,7 @@ async def test_unacked_run_end_reverts_the_direct_steer_send_to_the_steer_lane()
81
81
  assert dispatching_ids(covered_state(drv, seq)) == ["s1"]
82
82
  call.fail(RuntimeError("boom"))
83
83
  await drv.wait_status("error")
84
- assert queue_ids(drv.replica, "steerQueue") == ["s1"]
84
+ assert queue_ids(drv.replica, "steerQueue") == []
85
85
  assert queue_ids(drv.replica, "queue") == []
86
86
 
87
87
 
@@ -128,6 +128,41 @@ async def test_replaced_tail_anchor_rejects_after_an_edit_rewrite():
128
128
  assert res["type"] == "pending"
129
129
 
130
130
 
131
+ async def test_unacked_edit_crash_invalidates_the_replacement_anchor():
132
+ script = Script()
133
+ script.thread["u1"] = {
134
+ "parentId": None, "role": "user", "isLeaf": True, "onActiveBranch": True
135
+ }
136
+ async with run_host(script, capabilities=("rewind",)) as (drv, host):
137
+ drv.post("run/edit", edit_params("u1", msg("u2"), anchor=None))
138
+ (await script.next_call()).fail(RuntimeError("boom"))
139
+ await drv.wait_status("error")
140
+ assert_rejected(
141
+ await drv.command("run/enqueue", add("q1", anchor="u2"), terminal=False),
142
+ "unknown-id",
143
+ )
144
+ drv.post("run/enqueue", add("q1", anchor="u1"))
145
+ call = await script.next_call()
146
+ assert [m["id"] for m in call.ctx.messages] == ["q1"]
147
+
148
+
149
+ async def test_unacked_recovery_run_keeps_acked_dispatch_anchors():
150
+ script = Script()
151
+ async with run_host(script, capabilities=("incomplete-continuation",)) as (drv, host):
152
+ drv.post("run/enqueue", add("m1"))
153
+ call = await script.next_call()
154
+ call.ack()
155
+ call.fail(RuntimeError("boom"))
156
+ await drv.wait_status("error")
157
+ await drv.command("run/continue", terminal=False)
158
+ continued = await script.next_call()
159
+ assert continued.ctx.trigger == "error-continue"
160
+ continued.fail(RuntimeError("boom"))
161
+ await drv.wait_status("error")
162
+ res = await drv.command("run/steer", add("s1", anchor="m1"), terminal=False)
163
+ assert res["type"] == "pending"
164
+
165
+
131
166
  async def test_anchor_on_the_queued_edit_form_is_validated_never_ignored():
132
167
  script = Script()
133
168
  async with run_host(script) as (drv, host):
@@ -3,8 +3,8 @@ is the current unacked batch — a record ``{trigger, messages, rollbackTo?,
3
3
  inputOutcomes?}`` or None. A message item lives in exactly one of the lanes,
4
4
  the record's ``messages``, or the thread, and every transition is
5
5
  single-envelope atomic: dispatch fills the record, ``ctx.ack()`` clears it as
6
- the canonical message appears, a steer take refills it, and an unacked run
7
- end returns lane items to the front of their lane."""
6
+ the canonical message appears, a steer take refills it, and settle consumes
7
+ whatever is still unacked."""
8
8
 
9
9
  import copy
10
10
 
@@ -78,7 +78,7 @@ async def test_ack_clears_dispatching_with_the_message_append():
78
78
  call.finish(RunManager.Complete())
79
79
 
80
80
 
81
- async def test_unacked_run_end_returns_the_item_to_the_lane_front():
81
+ async def test_unacked_run_end_consumes_the_delivered_item():
82
82
  script = Script()
83
83
  async with run_host(script) as (drv, host):
84
84
  drv.post("run/enqueue", add("m1"))
@@ -88,9 +88,9 @@ async def test_unacked_run_end_returns_the_item_to_the_lane_front():
88
88
  await drv.side("run/stop", {"runId": await drv.run_id()})
89
89
  call.finish(RunManager.Stop(dispatch_queue=False))
90
90
  await drv.wait_status("stopped")
91
- assert queue_ids(drv.replica, "queue") == ["m1", "m2"]
91
+ assert queue_ids(drv.replica, "queue") == ["m2"]
92
92
  assert dispatching_ids(drv.replica) == []
93
- assert residence(drv, "m1") == ["dispatching", "queue"]
93
+ assert residence(drv, "m2") == ["queue"]
94
94
 
95
95
 
96
96
  async def test_steering_take_moves_the_lane_into_dispatching():
@@ -128,23 +128,6 @@ async def test_meta_dies_with_a_dequeued_entry():
128
128
  dispatched.finish(RunManager.Complete())
129
129
 
130
130
 
131
- async def test_unacked_run_end_reprojects_entries_with_meta():
132
- script = Script()
133
- async with run_host(script) as (drv, _):
134
- drv.batch(
135
- [
136
- ("run/enqueue", add("m1", meta={"clientId": "c1"})),
137
- ("run/steer", add("s1", meta={"clientId": "c2"})),
138
- ]
139
- )
140
- call = await script.next_call()
141
- assert [m["id"] for m in call.ctx.messages] == ["m1", "s1"]
142
- call.fail(RuntimeError("boom"))
143
- await drv.wait_status("error")
144
- assert drv.run["queue"][0]["meta"] == {"clientId": "c1"}
145
- assert drv.run["steerQueue"][0]["meta"] == {"clientId": "c2"}
146
-
147
-
148
131
  async def test_edit_meta_rides_the_replacement_message():
149
132
  script = Script()
150
133
  thread_with_turn(script)
@@ -0,0 +1,136 @@
1
+ """Contract: restore across eviction. Construction hydrates a pre-existing
2
+ input-required run entry from state — the host writes the parked entry before
3
+ constructing RunManager — and the resume paths (answer all, steer) work as if
4
+ the park never left memory. Empty runs state seeds ready; any other shape
5
+ raises."""
6
+
7
+ import asyncio
8
+
9
+ import pytest
10
+ from run_helpers import Script, add, msg, run_host
11
+
12
+ from harness_sdk import RunManager
13
+
14
+
15
+ def tool_call(id: str, tool_call_id: str = "tc1") -> dict:
16
+ return {"type": "tool-call", "id": id, "toolCallId": tool_call_id}
17
+
18
+
19
+ def parked_entry(**overrides) -> dict:
20
+ return {
21
+ "runId": "run-1",
22
+ "status": "input-required",
23
+ "queue": [],
24
+ "steerQueue": [],
25
+ "dispatching": None,
26
+ "error": None,
27
+ "inputRequests": [{**tool_call("r1"), "response": None}],
28
+ **overrides,
29
+ }
30
+
31
+
32
+ def construct(state: dict) -> RunManager:
33
+ script = Script()
34
+ return RunManager(
35
+ state=state,
36
+ run=script.run,
37
+ thread=script,
38
+ create_task=asyncio.ensure_future,
39
+ schedule=lambda fn: fn(),
40
+ )
41
+
42
+
43
+ async def test_empty_runs_state_seeds_ready():
44
+ script = Script()
45
+ stale = {"status": "input-required", "runs": []}
46
+ async with run_host(script, initial_runs=stale) as (drv, host):
47
+ assert drv.replica == {"status": "ready", "runs": []}
48
+ script.no_call()
49
+
50
+
51
+ async def test_hydrated_entry_rides_the_attach_snapshot():
52
+ script = Script()
53
+ async with run_host(script, initial_runs={"runs": [parked_entry()]}) as (
54
+ drv,
55
+ host,
56
+ ):
57
+ assert drv.replica["status"] == "input-required"
58
+ assert drv.run == parked_entry()
59
+ script.no_call()
60
+
61
+
62
+ async def test_answering_all_hydrated_requests_resumes():
63
+ script = Script()
64
+ entry = parked_entry(queue=[msg("m2")])
65
+ async with run_host(script, initial_runs={"runs": [entry]}) as (drv, host):
66
+ res = await drv.command(
67
+ "run/input", {"requestId": "r1", "response": {"output": "ok"}}
68
+ )
69
+ assert res["type"] == "accepted"
70
+ resumed = await script.next_call()
71
+ assert resumed.ctx.trigger == "input-resume"
72
+ assert resumed.ctx.messages == ()
73
+ assert resumed.ctx.input_outcomes == (
74
+ (tool_call("r1"), {"output": "ok"}, None),
75
+ )
76
+ assert drv.run["runId"] == "run-1"
77
+ resumed.ack()
78
+ resumed.finish(RunManager.Complete())
79
+ drain = await script.next_call()
80
+ assert [m["id"] for m in drain.ctx.messages] == ["m2"]
81
+ drain.ack()
82
+ drain.finish(RunManager.Complete())
83
+ await drv.wait_status("ready")
84
+
85
+
86
+ async def test_steer_while_hydrated_abandons_input_and_dispatches():
87
+ script = Script()
88
+ async with run_host(script, initial_runs={"runs": [parked_entry()]}) as (
89
+ drv,
90
+ host,
91
+ ):
92
+ drv.post("run/steer", add("s1"))
93
+ run = await script.next_call()
94
+ assert run.ctx.trigger == "message-send"
95
+ assert [m["id"] for m in run.ctx.messages] == ["s1"]
96
+ assert run.ctx.input_outcomes == ((tool_call("r1"), None, None),)
97
+ await drv.wait_status("running")
98
+ assert "inputRequests" not in drv.run
99
+ run.ack()
100
+ run.finish(RunManager.Complete())
101
+ await drv.wait_status("ready")
102
+
103
+
104
+ async def test_malformed_restore_states_raise():
105
+ for state, match in [
106
+ ({"runs": "nope"}, "exactly one entry"),
107
+ ({"runs": [parked_entry(), parked_entry()]}, "exactly one entry"),
108
+ ({"runs": ["nope"]}, "must be an object"),
109
+ ({"runs": [parked_entry(status="running")]}, "input-required"),
110
+ ({"runs": [parked_entry(runId="")]}, "runId"),
111
+ ({"runs": [parked_entry(runId=None)]}, "runId"),
112
+ ({"runs": [parked_entry(queue=None)]}, "queue"),
113
+ ({"runs": [parked_entry(steerQueue=None)]}, "steerQueue"),
114
+ ({"runs": [parked_entry(dispatching={"trigger": "steer"})]}, "dispatching"),
115
+ ({"runs": [parked_entry(error={"message": "x"})]}, "error"),
116
+ ({"runs": [parked_entry(inputRequests=[])]}, "non-empty list"),
117
+ ({"runs": [parked_entry(inputRequests=["nope"])]}, "objects"),
118
+ (
119
+ {
120
+ "runs": [
121
+ parked_entry(
122
+ inputRequests=[
123
+ {**tool_call("r1"), "response": {"output": "x"}}
124
+ ]
125
+ )
126
+ ]
127
+ },
128
+ "response",
129
+ ),
130
+ (
131
+ {"runs": [parked_entry(inputRequests=[{"type": "tool-call"}])]},
132
+ "non-empty string",
133
+ ),
134
+ ]:
135
+ with pytest.raises(ValueError, match=match):
136
+ construct(state)
@@ -1,14 +1,13 @@
1
1
  """Contract: command settlement. Every initiator settles accepted with its
2
2
  state-visible application — sends with their insert (a lane entry or a direct
3
3
  ``dispatching`` entry), edit/reload/continue at their dispatch. The run's
4
- outcome never travels through settlement: a run that ends with unacked
5
- entries returns lane-origin items to the front of their lane, direct
6
- dispatches to the front of the queue, and lands any failure on
7
- ``state.error``."""
4
+ outcome never travels through settlement: settle consumes every delivered
5
+ item, acked or not; steer entries still undelivered in the lane stay queued,
6
+ and any failure lands on ``state.error``."""
8
7
 
9
8
  import asyncio
10
9
 
11
- from run_helpers import Script, add, edit_params, msg, queue_ids, run_host, target
10
+ from run_helpers import Script, add, edit_params, msg, queue_ids, run_host
12
11
 
13
12
  from statewire import StatewireReject
14
13
 
@@ -51,7 +50,7 @@ async def test_run_reject_lands_the_payload_on_state_error():
51
50
  )
52
51
  assert (await drv.res(seq))["type"] == "accepted"
53
52
  await drv.wait_status("error")
54
- assert queue_ids(drv.replica, "queue") == ["m1"]
53
+ assert queue_ids(drv.replica, "queue") == []
55
54
  assert drv.run["error"] == {
56
55
  "message": "payment required",
57
56
  "reason": "payment-required",
@@ -62,16 +61,15 @@ async def test_run_reject_lands_the_payload_on_state_error():
62
61
  async def test_unacked_run_end_settles_the_send_accepted():
63
62
  script = Script()
64
63
  async with run_host(script) as (drv, host):
65
- for outcome in [
66
- lambda call: call.fail(RuntimeError("boom")),
67
- lambda call: call.finish(RunManager.Error(dispatch_queue=False)),
64
+ for name, outcome in [
65
+ ("m1", lambda call: call.fail(RuntimeError("boom"))),
66
+ ("m2", lambda call: call.finish(RunManager.Error(dispatch_queue=False))),
68
67
  ]:
69
- seq = drv.post("run/enqueue", add("m1"))
68
+ seq = drv.post("run/enqueue", add(name))
70
69
  outcome(await script.next_call())
71
70
  assert (await drv.res(seq))["type"] == "accepted"
72
71
  await drv.wait_status("error")
73
- assert queue_ids(drv.replica, "queue") == ["m1"]
74
- await drv.command("run/dequeue", {"messageId": "m1"})
72
+ assert queue_ids(drv.replica, "queue") == []
75
73
 
76
74
 
77
75
  async def test_rewind_settles_before_the_reruns_end():
@@ -90,23 +88,23 @@ async def test_rewind_settles_before_the_reruns_end():
90
88
  await drv.wait_status("stopped")
91
89
 
92
90
 
93
- async def test_unacked_run_end_reverts_entries_to_the_lane_front():
91
+ async def test_unacked_run_end_consumes_the_delivered_batch():
94
92
  script = Script()
95
93
  async with run_host(script) as (drv, host):
96
94
  drv.batch([("run/enqueue", add("m1")), ("run/enqueue", add("m2"))])
97
95
  call = await script.next_call()
98
96
  call.fail(RuntimeError("boom"))
99
97
  await drv.wait_status("error")
100
- assert queue_ids(drv.replica, "queue") == ["m1", "m2"]
98
+ assert queue_ids(drv.replica, "queue") == []
101
99
  assert drv.run["error"] == {"message": "boom"}
102
- await drv.command("run/steer", target(messageId="m1"), terminal=False)
100
+ await drv.command("run/steer", add("s1"), terminal=False)
103
101
  continued = await script.next_call()
104
102
  assert continued.ctx.trigger == "error-continue"
105
103
  continued.ack()
106
- assert [m["id"] for m in continued.ctx.steering.take()] == ["m1"]
104
+ assert [m["id"] for m in continued.ctx.steering.take()] == ["s1"]
107
105
 
108
106
 
109
- async def test_unacked_run_end_reverts_taken_steered_items():
107
+ async def test_unacked_run_end_consumes_taken_steered_items():
110
108
  script = Script()
111
109
  async with run_host(script) as (drv, host):
112
110
  drv.post("run/enqueue", add("m1"))
@@ -117,7 +115,7 @@ async def test_unacked_run_end_reverts_taken_steered_items():
117
115
  call.ctx.steering.take()
118
116
  call.fail(RuntimeError("boom"))
119
117
  await drv.wait_status("error")
120
- assert queue_ids(drv.replica, "steerQueue") == ["s1"]
118
+ assert queue_ids(drv.replica, "steerQueue") == []
121
119
  assert queue_ids(drv.replica, "queue") == []
122
120
 
123
121
 
@@ -132,17 +130,15 @@ async def test_ack_removes_the_entries_for_good():
132
130
  assert queue_ids(drv.replica, "queue") == []
133
131
 
134
132
 
135
- async def test_complete_without_ack_reverts_and_redispatches():
133
+ async def test_complete_without_ack_consumes_the_dispatch():
136
134
  script = Script()
137
135
  async with run_host(script) as (drv, host):
138
136
  seq = drv.post("run/enqueue", add("m1"))
139
137
  (await script.next_call()).finish(RunManager.Complete())
140
- redo = await script.next_call()
141
- assert [m["id"] for m in redo.ctx.messages] == ["m1"]
142
138
  assert (await drv.res(seq))["type"] == "accepted"
143
- redo.ack()
144
- redo.finish(RunManager.Complete())
145
139
  await drv.wait_status("ready")
140
+ assert drv.replica["runs"] == []
141
+ script.no_call()
146
142
 
147
143
 
148
144
  async def test_dequeue_removes_a_parked_entry():
@@ -169,9 +165,9 @@ async def test_stop_lands_while_a_steer_is_parked():
169
165
  await asyncio.wait_for(call.ctx.stop_requested.wait(), 5)
170
166
  call.finish(RunManager.Stop(dispatch_queue=False))
171
167
  await drv.wait_status("stopped")
172
- # The unconsumed steer stays parked; the unacked dispatch reverts.
168
+ # The undelivered steer stays parked; the delivered dispatch is consumed.
173
169
  assert queue_ids(drv.replica, "steerQueue") == ["s1"]
174
- assert queue_ids(drv.replica, "queue") == ["m1"]
170
+ assert queue_ids(drv.replica, "queue") == []
175
171
 
176
172
 
177
173
  async def test_rewind_edit_settles_at_its_dispatch():
@@ -199,7 +195,7 @@ async def test_rewind_rerun_failure_lands_on_state_error():
199
195
  assert drv.run["error"] == {"message": "payment required", "code": 402}
200
196
 
201
197
 
202
- async def test_task_cancellation_reverts_entries_and_settles_sends():
198
+ async def test_task_cancellation_consumes_entries_and_settles_sends():
203
199
  script = Script()
204
200
  async with run_host(script) as (drv, host):
205
201
  seq = drv.batch([("run/enqueue", add("m1")), ("run/enqueue", add("m2"))])
@@ -208,7 +204,7 @@ async def test_task_cancellation_reverts_entries_and_settles_sends():
208
204
  task.cancel()
209
205
  assert (await drv.res(seq))["type"] == "accepted"
210
206
  await drv.wait_status("stopped")
211
- assert queue_ids(drv.replica, "queue") == ["m1", "m2"]
207
+ assert queue_ids(drv.replica, "queue") == []
212
208
  assert task.cancelled()
213
209
 
214
210
 
@@ -1,100 +0,0 @@
1
- """Contract: run lifetime composed with the assistant-transport chat
2
- endpoint. The ``/chat`` hold releases once the send settles — the opening ``set`` carries the sent
3
- message as the ``dispatching`` record — then the response stays open through
4
- the run and EOFs once the run task ends; a run rejection is a single error
5
- frame then EOF.
6
- """
7
-
8
- import asyncio
9
-
10
- from run_helpers import Script
11
- from statewire_helpers import statewire_client
12
- from test_assistant_transport import _PostStream, data_stream_frames, state_ops
13
-
14
- from statewire import AssistantTransport, StatewireReject, command
15
-
16
- from harness_sdk import RunManager
17
-
18
-
19
- def make_facade_host(script: Script):
20
- class Host(AssistantTransport):
21
- async def lifespan(self):
22
- self.state = {}
23
- self.runs = RunManager(
24
- state=self.state,
25
- run=script.run,
26
- thread=script,
27
- create_task=self.create_task,
28
- schedule=self.schedule,
29
- )
30
- yield
31
-
32
- @command("run/steer")
33
- async def run_steer(self, params, *, ctx):
34
- return await self.runs.steer(params, ack=ctx.ack)
35
-
36
- @command("run/stop")
37
- async def run_stop(self, params=None, *, ctx):
38
- return await self.runs.stop(params, ack=ctx.ack)
39
-
40
- return Host
41
-
42
-
43
- def chat_body():
44
- return {
45
- "commands": [
46
- {
47
- "type": "run/steer",
48
- "runId": "run-1",
49
- "runAnchorMessageId": None,
50
- "message": {
51
- "id": "m1",
52
- "role": "user",
53
- "parts": [{"type": "text", "text": "hi"}],
54
- },
55
- }
56
- ],
57
- "state": {},
58
- "threadId": "t1",
59
- }
60
-
61
-
62
- async def test_chat_holds_until_the_entity_run_settles_then_eofs():
63
- script = Script()
64
- async with statewire_client(make_facade_host(script)) as (app, client):
65
- async with _PostStream(
66
- app, "/threads/t1/assistant-transport/api/chat", chat_body()
67
- ) as stream:
68
- assert stream.status == 200
69
- call = await script.next_call()
70
- assert call.ctx.trigger == "message-send"
71
- instance = (await app.state.pinned_host.directory.get("t1")).instance
72
- instance.state["messages"] = [dict(call.ctx.messages[0])]
73
- call.ack()
74
- await asyncio.sleep(0.05)
75
- assert stream._task is not None and not stream._task.done()
76
- call.finish(RunManager.Complete())
77
- tail = await stream.until_closed()
78
- frames = data_stream_frames(tail.decode())
79
- assert frames and all(prefix == "aui-state" for prefix, _ in frames)
80
- ops = state_ops(frames)
81
- assert ops[0]["type"] == "set" and ops[0]["path"] == []
82
- record = ops[0]["value"]["runs"][0]["dispatching"]
83
- assert [m["id"] for m in record["messages"]] == [call.ctx.messages[0]["id"]]
84
- assert any(
85
- op["type"] == "set" and op["path"] == ["messages"] for op in ops[1:]
86
- )
87
-
88
-
89
- async def test_chat_run_reject_is_one_error_frame_then_eof():
90
- script = Script()
91
- async with statewire_client(make_facade_host(script)) as (app, client):
92
- async with _PostStream(
93
- app, "/threads/t1/assistant-transport/api/chat", chat_body()
94
- ) as stream:
95
- call = await script.next_call()
96
- call.fail(StatewireReject("payment required", payload={"code": 402}))
97
- tail = await stream.until_closed()
98
- frames = data_stream_frames(tail.decode())
99
- errors = [frame for frame in frames if frame[0] == "3"]
100
- assert errors == [("3", "payment required")]