harness-sdk-python 0.6.0__tar.gz → 0.7.1__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.6.0 → harness_sdk_python-0.7.1}/.gitignore +2 -0
  2. {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.1}/PKG-INFO +6 -1
  3. {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.1}/pyproject.toml +7 -1
  4. {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.1}/src/harness_sdk/fenced_postgres.py +58 -18
  5. {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.1}/src/harness_sdk/run_manager.py +34 -13
  6. {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.1}/tests/run_helpers.py +47 -11
  7. harness_sdk_python-0.7.1/tests/test_ack_visibility.py +83 -0
  8. {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.1}/tests/test_batches.py +12 -11
  9. {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.1}/tests/test_branch_anchor.py +12 -17
  10. harness_sdk_python-0.7.1/tests/test_dispatching.py +123 -0
  11. {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.1}/tests/test_edit_dispatched.py +14 -23
  12. {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.1}/tests/test_edit_reload.py +19 -28
  13. {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.1}/tests/test_enqueue.py +27 -29
  14. {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.1}/tests/test_fenced_postgres.py +64 -0
  15. {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.1}/tests/test_input_required.py +16 -18
  16. {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.1}/tests/test_meta.py +16 -22
  17. {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.1}/tests/test_outcomes.py +14 -13
  18. {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.1}/tests/test_placement.py +2 -2
  19. {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.1}/tests/test_prepare_hooks.py +11 -14
  20. {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.1}/tests/test_rewind_during_run.py +13 -27
  21. {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.1}/tests/test_run_leaf.py +14 -14
  22. {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.1}/tests/test_settle.py +36 -49
  23. {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.1}/tests/test_steer.py +16 -12
  24. {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.1}/tests/test_stop_continue.py +28 -24
  25. {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.1}/README.md +0 -0
  26. {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.1}/src/harness_sdk/__init__.py +0 -0
  27. {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.1}/src/harness_sdk/linear_thread.py +0 -0
  28. {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.1}/tests/test_facade.py +0 -0
  29. {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.1}/tests/test_linear_thread.py +0 -0
@@ -20,3 +20,5 @@ __pycache__
20
20
  # agentdoc mounted docs
21
21
  /d[0-9]*.md
22
22
  /doc_*.md
23
+ apps/docs/.docs
24
+ /.agentdoc/
@@ -1,11 +1,16 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: harness-sdk-python
3
- Version: 0.6.0
3
+ Version: 0.7.1
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: langgraph>=1.0.0; extra == 'deepagents'
13
+ Requires-Dist: uvicorn[standard]>=0.30.0; extra == 'deepagents'
9
14
  Provides-Extra: postgres
10
15
  Requires-Dist: langgraph-checkpoint-postgres>=2.0.0; extra == 'postgres'
11
16
  Description-Content-Type: text/markdown
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "harness-sdk-python"
3
- version = "0.6.0"
3
+ version = "0.7.1"
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,12 @@ 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
+ "uvicorn[standard]>=0.30.0",
17
+ ]
12
18
 
13
19
  [project.urls]
14
20
  Repository = "https://github.com/assistant-ui/harness-sdk"
@@ -7,7 +7,7 @@ try:
7
7
  from langchain_core.runnables import RunnableConfig
8
8
  from langgraph.checkpoint.base import SerializerProtocol
9
9
  from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver, _ainternal
10
- from psycopg import AsyncPipeline, errors
10
+ from psycopg import AsyncConnection, AsyncPipeline, errors
11
11
  from psycopg.pq import TransactionStatus
12
12
  from psycopg.cursor_async import AsyncCursor
13
13
  from psycopg.rows import DictRow, dict_row
@@ -126,6 +126,57 @@ class AsyncFencedPostgresSaver(AsyncPostgresSaver):
126
126
  self.on_fence_lost = on_fence_lost
127
127
  self._fence_lost_notified = False
128
128
 
129
+ @staticmethod
130
+ async def _validate_and_bump(
131
+ cur: "AsyncCursor[DictRow]",
132
+ thread_id: str,
133
+ *,
134
+ create_fence_table: bool,
135
+ fence_table: str,
136
+ ) -> int:
137
+ if create_fence_table:
138
+ await cur.execute(_create_sql(fence_table))
139
+ await cur.execute(_create_fn_sql(fence_table))
140
+ else:
141
+ await cur.execute(
142
+ "SELECT to_regclass(%s) AS tbl, to_regprocedure(%s) AS fn",
143
+ (fence_table, f"{_assert_fn(fence_table)}(text, bigint)"),
144
+ )
145
+ row = await cur.fetchone()
146
+ if row["tbl"] is None:
147
+ raise _missing_fence_error(f"fence table {fence_table!r}")
148
+ if row["fn"] is None:
149
+ raise _missing_fence_error(
150
+ f"fence function {_assert_fn(fence_table)!r}"
151
+ )
152
+ await cur.execute(_bump_sql(fence_table), (thread_id,))
153
+ return (await cur.fetchone())["epoch"]
154
+
155
+ @classmethod
156
+ async def bump_epoch(
157
+ cls,
158
+ conn: "AsyncConnection[Any]",
159
+ thread_id: str,
160
+ *,
161
+ create_fence_table: bool,
162
+ fence_table: str = DEFAULT_FENCE_TABLE,
163
+ ) -> int:
164
+ """Bump ``thread_id``'s epoch inside the caller's open transaction; the epoch is effective only once the caller commits, which must happen before it is used for writes."""
165
+ _require_thread_id(thread_id)
166
+ _require_fence_table(fence_table)
167
+ if conn.info.transaction_status != TransactionStatus.INTRANS:
168
+ raise RuntimeError(
169
+ "bump_epoch requires a connection with a transaction in "
170
+ "progress; use acquire for idle connections"
171
+ )
172
+ async with conn.cursor(binary=True, row_factory=dict_row) as cur:
173
+ return await cls._validate_and_bump(
174
+ cur,
175
+ thread_id,
176
+ create_fence_table=create_fence_table,
177
+ fence_table=fence_table,
178
+ )
179
+
129
180
  @classmethod
130
181
  async def acquire(
131
182
  cls,
@@ -149,23 +200,12 @@ class AsyncFencedPostgresSaver(AsyncPostgresSaver):
149
200
  c.transaction(),
150
201
  c.cursor(binary=True, row_factory=dict_row) as cur,
151
202
  ):
152
- if create_fence_table:
153
- await cur.execute(_create_sql(fence_table))
154
- await cur.execute(_create_fn_sql(fence_table))
155
- else:
156
- await cur.execute(
157
- "SELECT to_regclass(%s) AS tbl, to_regprocedure(%s) AS fn",
158
- (fence_table, f"{_assert_fn(fence_table)}(text, bigint)"),
159
- )
160
- row = await cur.fetchone()
161
- if row["tbl"] is None:
162
- raise _missing_fence_error(f"fence table {fence_table!r}")
163
- if row["fn"] is None:
164
- raise _missing_fence_error(
165
- f"fence function {_assert_fn(fence_table)!r}"
166
- )
167
- await cur.execute(_bump_sql(fence_table), (thread_id,))
168
- epoch = (await cur.fetchone())["epoch"]
203
+ epoch = await cls._validate_and_bump(
204
+ cur,
205
+ thread_id,
206
+ create_fence_table=create_fence_table,
207
+ fence_table=fence_table,
208
+ )
169
209
  if pipe is not None:
170
210
  await pipe.sync()
171
211
  return cls(
@@ -125,7 +125,6 @@ class _Rewind:
125
125
  ack: Callable[[], None]
126
126
  future: "asyncio.Future[Any]"
127
127
  root_meta: Any = None
128
- acked: bool = False
129
128
 
130
129
 
131
130
  @dataclass
@@ -134,7 +133,9 @@ class _Effects:
134
133
  new_added: bool = False
135
134
  continue_requested: bool = False
136
135
  continue_meta: Any = None
137
- staged_sends: list[tuple[str, "asyncio.Future[Any]"]] = field(default_factory=list)
136
+ staged_sends: list[tuple[str, "asyncio.Future[Any]", Callable[[], None]]] = field(
137
+ default_factory=list
138
+ )
138
139
  continues: list["asyncio.Future[Any]"] = field(default_factory=list)
139
140
 
140
141
 
@@ -192,6 +193,10 @@ class RunManager:
192
193
  self._stop_reason: str | None = None
193
194
  self._staged_rewinds: list[_Rewind] = []
194
195
  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
195
200
  self._run_acked = True
196
201
  self._leaf_confirmed: str | None = None
197
202
  self._send_futures: list["asyncio.Future[Any]"] = []
@@ -239,6 +244,7 @@ class RunManager:
239
244
  "status": "ready",
240
245
  "queue": [],
241
246
  "steerQueue": [],
247
+ "dispatching": [],
242
248
  "error": None,
243
249
  "runLeafMessageId": None,
244
250
  }
@@ -307,7 +313,7 @@ class RunManager:
307
313
  rollback_to=rewind.rollback_to,
308
314
  root_meta=rewind.root_meta,
309
315
  )
310
- rewind.ack()
316
+ self._dispatch_ack = rewind.ack
311
317
  if not rewind.future.done():
312
318
  self._run_futures.append(rewind.future)
313
319
  elif outcome is not None:
@@ -317,16 +323,15 @@ class RunManager:
317
323
  if self._task is not None and (self._staged_stops or self._staged_rewinds):
318
324
  assert self._ctx is not None
319
325
  self._ctx.stop_requested.set()
320
- for rewind in self._staged_rewinds:
321
- if not rewind.acked:
322
- rewind.ack()
323
- rewind.acked = True
324
326
  dispatching = {item["id"] for _, item in self._dispatching}
325
- for message_id, future in fx.staged_sends:
327
+ for message_id, future, ack in fx.staged_sends:
326
328
  if message_id in dispatching:
329
+ self._deferred_acks[message_id] = ack
327
330
  self._send_futures.append(future)
328
- elif not future.done():
329
- future.set_result(None)
331
+ else:
332
+ ack()
333
+ if not future.done():
334
+ future.set_result(None)
330
335
  for future in fx.continues:
331
336
  if not future.done():
332
337
  future.set_result(None)
@@ -433,6 +438,7 @@ class RunManager:
433
438
  if self._task is not None:
434
439
  raise RuntimeError("a run is already in flight")
435
440
  message_meta = {m["id"]: m.get("meta") for m in messages}
441
+ dispatching = list(messages)
436
442
  messages = [
437
443
  {k: v for k, v in message.items() if k != "meta"} for message in messages
438
444
  ]
@@ -446,6 +452,7 @@ class RunManager:
446
452
  self._run_acked = False
447
453
  entry = self._ensure_entry()
448
454
  self._leaf_confirmed = plain(entry["runLeafMessageId"])
455
+ entry["dispatching"] = dispatching
449
456
  entry["error"] = None
450
457
  if messages:
451
458
  self._dispatched_ids = tuple(m["id"] for m in messages)
@@ -538,6 +545,7 @@ class RunManager:
538
545
  self._ctx = None
539
546
  self._task = None
540
547
  self._dispatch_record = None
548
+ self._dispatch_ack = None
541
549
 
542
550
  def _settle_initiators(self, error: StatewireReject | None) -> None:
543
551
  futures, self._run_futures = self._run_futures, []
@@ -556,7 +564,15 @@ class RunManager:
556
564
  future.set_result(None)
557
565
 
558
566
  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()
559
574
  self._dispatching = []
575
+ self._entry()["dispatching"] = []
560
576
  self._run_acked = True
561
577
  self._leaf_confirmed = plain(self._entry()["runLeafMessageId"])
562
578
  self._settle_sends()
@@ -565,13 +581,18 @@ class RunManager:
565
581
  def _revert_dispatching(self) -> None:
566
582
  self._settle_sends()
567
583
  taken, self._dispatching = self._dispatching, []
584
+ entry = self._ensure_entry()
585
+ entry["dispatching"] = []
568
586
  if not taken:
569
587
  return
570
- entry = self._ensure_entry()
571
588
  for lane in ("steerQueue", "queue"):
572
589
  front = [item for taken_lane, item in taken if taken_lane == lane]
573
590
  if front:
574
591
  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()
575
596
  ids = {item["id"] for _, item in taken}
576
597
  self._dispatched_ids = tuple(id for id in self._dispatched_ids if id not in ids)
577
598
  if plain(entry["runLeafMessageId"]) in ids:
@@ -809,8 +830,7 @@ class RunManager:
809
830
  raise _reject("invalid-message", "anchorMessageId is required")
810
831
  self._check_anchor(e)
811
832
  self._insert_new(e.lane, e.message, e.params, e.meta)
812
- fx.staged_sends.append((e.message_id, e.future))
813
- e.ack()
833
+ fx.staged_sends.append((e.message_id, e.future, e.ack))
814
834
  fx.new_added = True
815
835
  if e.lane == "steerQueue":
816
836
  fx.steer_added = True
@@ -1204,6 +1224,7 @@ class RunManager:
1204
1224
  entry = manager._entry()
1205
1225
  entry["steerQueue"] = []
1206
1226
  manager._dispatching.extend(("steerQueue", item) for item in items)
1227
+ entry["dispatching"] = list(plain(entry["dispatching"])) + items
1207
1228
  if items:
1208
1229
  entry["runLeafMessageId"] = items[-1]["id"]
1209
1230
  self._ctx._message_meta.update(
@@ -5,7 +5,7 @@ from dataclasses import dataclass
5
5
  from typing import Any
6
6
 
7
7
  from statewire import Statewire, command
8
- from statewire_helpers import apply_ops, post_command, statewire_client, stream_of
8
+ from statewire_helpers import apply_ops, attach, post_command, statewire_client, stream_of
9
9
 
10
10
  from harness_sdk import RunManager
11
11
 
@@ -127,20 +127,47 @@ class RunDriver:
127
127
  """One SSE-attached statewire client: sends commands, mirrors the state
128
128
  replica from ops, and returns each command's settled response."""
129
129
 
130
- def __init__(self, client, stream) -> None:
130
+ def __init__(self, client, stream, app=None) -> None:
131
131
  self._client = client
132
132
  self._stream = stream
133
+ self._app = app
134
+ self._side_seqs: dict[str, int] = {}
133
135
  self._seq = 0
134
136
  self._acked = 0
135
137
  self._res: dict[int, list[dict[str, Any]]] = {}
138
+ self._posts: list["asyncio.Task[None]"] = []
136
139
  self.replica: dict[str, Any] = {}
137
140
  self.envelopes: list[dict[str, Any]] = []
138
141
 
142
+ def _spawn(self, body: dict[str, Any], seq: int) -> None:
143
+ async def _post() -> None:
144
+ response = await post_command(self._client, body, seq=seq)
145
+ assert response.status_code == 200, response.text
146
+
147
+ self._posts.append(asyncio.create_task(_post()))
148
+
149
+ def post(self, method: str, params: Any = None) -> int:
150
+ """Background POST for a command whose receipt is held until its effect
151
+ publishes (a dispatching send or rewind); settle via ``res(seq)``."""
152
+ self._seq += 1
153
+ body = {"method": method, "params": [] if params is None else [params]}
154
+ self._spawn(body, self._seq)
155
+ return self._seq
156
+
157
+ async def side(self, method: str, params: Any = None, *, client_id: str = "c2"):
158
+ """A second client's command: its own seq stream stays admissible while
159
+ this client's receipt is deferred; its verdicts ride the other stream."""
160
+ await attach(self._app, client_id=client_id)
161
+ seq = self._side_seqs[client_id] = self._side_seqs.get(client_id, 0) + 1
162
+ body = {"method": method, "params": [] if params is None else [params]}
163
+ response = await post_command(self._client, body, seq=seq, client_id=client_id)
164
+ assert response.status_code == 200, response.text
165
+
139
166
  async def pump(self, timeout: float = 5) -> None:
140
167
  env = await self._stream.next_event(timeout=timeout)
141
168
  assert env is not None, "stream ended unexpectedly"
142
169
  self.envelopes.append(env)
143
- apply_ops(self.replica, env)
170
+ apply_ops(self.replica, copy.deepcopy(env))
144
171
  if "ack" in env:
145
172
  self._acked = max(self._acked, env["ack"])
146
173
  for rsp in env.get("res", []):
@@ -156,9 +183,9 @@ class RunDriver:
156
183
  assert response.status_code == 200, response.text
157
184
  return await self.res(seq, terminal=terminal)
158
185
 
159
- async def batch(self, commands: list[tuple[str, Any]]) -> int:
160
- """POST one batch; returns the first member's seq (members are
161
- consecutive). Responses via ``res(first + i)``."""
186
+ def batch(self, commands: list[tuple[str, Any]]) -> int:
187
+ """Background POST of one batch; returns the first member's seq
188
+ (members are consecutive). Responses via ``res(first + i)``."""
162
189
  first = self._seq + 1
163
190
  self._seq += len(commands)
164
191
  body = {
@@ -167,8 +194,7 @@ class RunDriver:
167
194
  for method, params in commands
168
195
  ]
169
196
  }
170
- response = await post_command(self._client, body, seq=first)
171
- assert response.status_code == 200, response.text
197
+ self._spawn(body, first)
172
198
  return first
173
199
 
174
200
  async def context(self, value: dict[str, Any]) -> dict[str, Any]:
@@ -250,9 +276,19 @@ async def run_host(
250
276
  async with statewire_client(host_cls) as (app, client):
251
277
  async with stream_of(app) as stream:
252
278
  snapshot = await stream.next_event()
253
- driver = RunDriver(client, stream)
254
- apply_ops(driver.replica, snapshot)
255
- yield driver, host_cls
279
+ driver = RunDriver(client, stream, app)
280
+ driver.envelopes.append(snapshot)
281
+ apply_ops(driver.replica, copy.deepcopy(snapshot))
282
+ try:
283
+ yield driver, host_cls
284
+ finally:
285
+ for task in driver._posts:
286
+ task.cancel()
287
+ for result in await asyncio.gather(
288
+ *driver._posts, return_exceptions=True
289
+ ):
290
+ if isinstance(result, AssertionError):
291
+ raise result
256
292
 
257
293
 
258
294
  def msg(id: str, text: str | None = None) -> dict[str, Any]:
@@ -0,0 +1,83 @@
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."""
6
+
7
+ import copy
8
+
9
+ from run_helpers import Script, add, msg, queue_ids, run_host
10
+
11
+ from harness_sdk import RunManager
12
+ from statewire_helpers import apply_ops
13
+
14
+
15
+ def covered_state(drv, seq):
16
+ """The replica as of the envelope that first covers ``seq``."""
17
+ state: dict = {}
18
+ for env in copy.deepcopy(drv.envelopes):
19
+ apply_ops(state, env)
20
+ if env.get("ack", 0) >= seq:
21
+ return state
22
+ raise AssertionError(f"no envelope covers seq {seq}")
23
+
24
+
25
+ def lane_and_message_ids(state):
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
+ }
32
+
33
+
34
+ async def test_dispatched_send_acks_with_the_message_ack_append():
35
+ script = Script()
36
+ async with run_host(script) as (drv, host):
37
+ seq = drv.post("run/enqueue", add("m1"))
38
+ call = await script.next_call()
39
+ await drv.wait_status("running")
40
+ assert all("ack" not in env for env in drv.envelopes)
41
+ host.live.state["messages"] = [msg("m1")]
42
+ call.ack()
43
+ assert (await drv.res(seq))["type"] == "accepted"
44
+ assert "m1" in lane_and_message_ids(covered_state(drv, seq))
45
+ call.finish(RunManager.Complete())
46
+
47
+
48
+ async def test_parked_send_acks_with_its_queue_insert():
49
+ script = Script()
50
+ async with run_host(script) as (drv, host):
51
+ drv.post("run/enqueue", add("m1"))
52
+ call = await script.next_call()
53
+ call.ack()
54
+ res = await drv.command("run/enqueue", add("m2"))
55
+ assert res["type"] == "accepted"
56
+ assert "m2" in queue_ids(covered_state(drv, res["seq"]), "queue")
57
+ call.finish(RunManager.Complete())
58
+
59
+
60
+ async def test_unacked_run_end_acks_with_the_revert():
61
+ script = Script()
62
+ async with run_host(script) as (drv, host):
63
+ seq = drv.post("run/enqueue", add("m1"))
64
+ call = await script.next_call()
65
+ call.fail(RuntimeError("boom"))
66
+ assert (await drv.res(seq))["type"] == "accepted"
67
+ assert "m1" in queue_ids(covered_state(drv, seq), "queue")
68
+
69
+
70
+ async def test_rewind_edit_acks_with_the_message_ack_append():
71
+ script = Script()
72
+ script.thread["u1"] = {"parentId": None, "role": "user", "isLeaf": True}
73
+ async with run_host(script, capabilities=("rewind",)) as (drv, host):
74
+ seq = drv.post("run/edit", {"sourceId": "u1", "message": msg("u2")})
75
+ rerun = await script.next_call()
76
+ assert rerun.ctx.trigger == "message-edit"
77
+ await drv.wait_status("running")
78
+ assert all("ack" not in env for env in drv.envelopes)
79
+ host.live.state["messages"] = [msg("u2")]
80
+ rerun.ack()
81
+ assert (await drv.res(seq))["type"] == "accepted"
82
+ assert "u2" in lane_and_message_ids(covered_state(drv, seq))
83
+ rerun.finish(RunManager.Complete())
@@ -13,15 +13,13 @@ from harness_sdk import RunManager
13
13
  async def test_multi_steer_batch_places_all_and_dispatches_once():
14
14
  script = Script()
15
15
  async with run_host(script) as (drv, host):
16
- first = await drv.batch(
16
+ first = drv.batch(
17
17
  [
18
18
  ("run/steer", add("s1")),
19
19
  ("run/steer", add("s2")),
20
20
  ("run/steer", add("s3")),
21
21
  ]
22
22
  )
23
- for offset in range(3):
24
- assert (await drv.res(first + offset, terminal=False))["type"] == "pending"
25
23
  call = await script.next_call()
26
24
  assert call.ctx.trigger == "message-send"
27
25
  assert [m["id"] for m in call.ctx.messages] == ["s1", "s2", "s3"]
@@ -36,9 +34,10 @@ async def test_multi_steer_batch_places_all_and_dispatches_once():
36
34
  async def test_steer_and_stop_in_one_batch_nets_to_stop():
37
35
  script = Script()
38
36
  async with run_host(script) as (drv, host):
39
- await drv.command("run/enqueue", add("m1"), terminal=False)
37
+ drv.post("run/enqueue", add("m1"))
40
38
  call = await script.next_call()
41
- first = await drv.batch(
39
+ call.ack()
40
+ first = drv.batch(
42
41
  [("run/steer", add("s1")), ("run/stop", {"runId": await drv.run_id()})]
43
42
  )
44
43
  assert (await drv.res(first))["type"] == "accepted"
@@ -54,8 +53,9 @@ async def test_steer_and_stop_in_one_batch_nets_to_stop():
54
53
  async def test_stop_settles_only_after_the_run_task_ends():
55
54
  script = Script()
56
55
  async with run_host(script) as (drv, host):
57
- await drv.command("run/enqueue", add("m1"), terminal=False)
56
+ drv.post("run/enqueue", add("m1"))
58
57
  call = await script.next_call()
58
+ call.ack()
59
59
  pending = await drv.stop(terminal=False)
60
60
  assert pending["type"] == "pending"
61
61
  for _ in range(5):
@@ -79,10 +79,10 @@ class TailScript(Script):
79
79
  async def test_handler_tail_staging_decides_in_its_own_envelope():
80
80
  script = TailScript()
81
81
  async with run_host(script) as (drv, host):
82
- res = await drv.command("run/enqueue", add("m1"), terminal=False)
83
- assert res["type"] == "pending"
82
+ seq = drv.post("run/enqueue", add("m1"))
84
83
  call = await script.next_call()
85
84
  assert [m["id"] for m in call.ctx.messages] == ["m1"]
85
+ await drv.wait_status("running")
86
86
  dispatch_env = next(
87
87
  env
88
88
  for env in drv.envelopes
@@ -92,6 +92,7 @@ async def test_handler_tail_staging_decides_in_its_own_envelope():
92
92
  )
93
93
  )
94
94
  # The decide ran outside the batch segment: its ops flush in an
95
- # envelope of their own, alongside the pending verdict and its ack.
96
- assert dispatch_env["res"] == [{"seq": res["seq"], "type": "pending"}]
97
- assert dispatch_env["ack"] >= res["seq"]
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()
98
+ assert (await drv.res(seq))["type"] == "accepted"
@@ -19,9 +19,8 @@ async def test_add_requires_a_branch_anchor():
19
19
  async def test_null_anchor_valid_only_on_an_empty_thread():
20
20
  script = Script()
21
21
  async with run_host(script) as (drv, host):
22
- res = await drv.command("run/enqueue", add("m1"), terminal=False)
23
- assert res["type"] == "pending"
24
- await script.next_call()
22
+ drv.post("run/enqueue", add("m1"))
23
+ (await script.next_call()).ack()
25
24
  script.thread["m1"] = {
26
25
  "parentId": None, "role": "user", "isLeaf": True, "onActiveBranch": True
27
26
  }
@@ -42,9 +41,8 @@ async def test_active_branch_anchor_accepted_off_branch_and_unknown_rejected():
42
41
  }
43
42
  )
44
43
  async with run_host(script) as (drv, host):
45
- res = await drv.command("run/enqueue", add("m1", anchor="a1"), terminal=False)
46
- assert res["type"] == "pending"
47
- await script.next_call()
44
+ drv.post("run/enqueue", add("m1", anchor="a1"))
45
+ (await script.next_call()).ack()
48
46
  assert_rejected(
49
47
  await drv.command("run/enqueue", add("m2", anchor="old"), terminal=False),
50
48
  "wrong-anchor",
@@ -58,8 +56,8 @@ async def test_active_branch_anchor_accepted_off_branch_and_unknown_rejected():
58
56
  async def test_queued_item_and_dispatched_ids_are_valid_anchors():
59
57
  script = Script()
60
58
  async with run_host(script) as (drv, host):
61
- await drv.command("run/enqueue", add("m1"), terminal=False)
62
- await script.next_call()
59
+ drv.post("run/enqueue", add("m1"))
60
+ (await script.next_call()).ack()
63
61
  await drv.command("run/enqueue", add("m2", anchor="m1"), terminal=False)
64
62
  await drv.command("run/steer", add("s1", anchor="m2"), terminal=False)
65
63
  assert queue_ids(drv.replica, "queue") == ["m2"]
@@ -74,11 +72,8 @@ async def test_replaced_tail_anchor_rejects_after_an_edit_rewrite():
74
72
  }
75
73
  )
76
74
  async with run_host(script, capabilities=("rewind",)) as (drv, host):
77
- res = await drv.command(
78
- "run/edit", {"sourceId": "u1", "message": msg("m2")}, terminal=False
79
- )
80
- assert res["type"] == "pending"
81
- await script.next_call()
75
+ drv.post("run/edit", {"sourceId": "u1", "message": msg("m2")})
76
+ (await script.next_call()).ack()
82
77
  script.thread["u1"] = {
83
78
  "parentId": None, "role": "user", "isLeaf": True, "onActiveBranch": False
84
79
  }
@@ -96,8 +91,8 @@ async def test_replaced_tail_anchor_rejects_after_an_edit_rewrite():
96
91
  async def test_anchor_on_the_queued_edit_form_is_validated_never_ignored():
97
92
  script = Script()
98
93
  async with run_host(script) as (drv, host):
99
- await drv.command("run/enqueue", add("m1"), terminal=False)
100
- await script.next_call()
94
+ drv.post("run/enqueue", add("m1"))
95
+ (await script.next_call()).ack()
101
96
  await drv.command("run/enqueue", add("m2", anchor="m1"), terminal=False)
102
97
  assert_rejected(
103
98
  await drv.command(
@@ -124,10 +119,10 @@ async def test_post_reload_sends_anchor_on_the_reload_targets_parent():
124
119
  }
125
120
  )
126
121
  async with run_host(script, capabilities=("rewind",)) as (drv, host):
127
- res = await drv.command("run/reload", {"sourceId": "a1"}, terminal=False)
128
- assert res["type"] == "pending"
122
+ drv.post("run/reload", {"sourceId": "a1"})
129
123
  call = await script.next_call()
130
124
  assert call.ctx.trigger == "message-reload"
125
+ call.ack()
131
126
  script.thread["a1"] = {
132
127
  "parentId": "u1", "role": "assistant", "isLeaf": True, "onActiveBranch": False
133
128
  }