harness-sdk-python 0.4.3__tar.gz → 0.4.5__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 (24) hide show
  1. {harness_sdk_python-0.4.3 → harness_sdk_python-0.4.5}/PKG-INFO +1 -1
  2. {harness_sdk_python-0.4.3 → harness_sdk_python-0.4.5}/pyproject.toml +1 -1
  3. {harness_sdk_python-0.4.3 → harness_sdk_python-0.4.5}/src/harness_sdk/run_manager.py +46 -41
  4. {harness_sdk_python-0.4.3 → harness_sdk_python-0.4.5}/tests/test_edit_dispatched.py +13 -4
  5. {harness_sdk_python-0.4.3 → harness_sdk_python-0.4.5}/tests/test_edit_reload.py +10 -13
  6. {harness_sdk_python-0.4.3 → harness_sdk_python-0.4.5}/tests/test_input_required.py +126 -20
  7. {harness_sdk_python-0.4.3 → harness_sdk_python-0.4.5}/tests/test_outcomes.py +19 -0
  8. {harness_sdk_python-0.4.3 → harness_sdk_python-0.4.5}/tests/test_rewind_during_run.py +30 -8
  9. {harness_sdk_python-0.4.3 → harness_sdk_python-0.4.5}/tests/test_settle.py +30 -0
  10. {harness_sdk_python-0.4.3 → harness_sdk_python-0.4.5}/tests/test_stop_continue.py +3 -3
  11. {harness_sdk_python-0.4.3 → harness_sdk_python-0.4.5}/.gitignore +0 -0
  12. {harness_sdk_python-0.4.3 → harness_sdk_python-0.4.5}/README.md +0 -0
  13. {harness_sdk_python-0.4.3 → harness_sdk_python-0.4.5}/src/harness_sdk/__init__.py +0 -0
  14. {harness_sdk_python-0.4.3 → harness_sdk_python-0.4.5}/src/harness_sdk/fenced_postgres.py +0 -0
  15. {harness_sdk_python-0.4.3 → harness_sdk_python-0.4.5}/tests/run_helpers.py +0 -0
  16. {harness_sdk_python-0.4.3 → harness_sdk_python-0.4.5}/tests/test_batches.py +0 -0
  17. {harness_sdk_python-0.4.3 → harness_sdk_python-0.4.5}/tests/test_branch_anchor.py +0 -0
  18. {harness_sdk_python-0.4.3 → harness_sdk_python-0.4.5}/tests/test_enqueue.py +0 -0
  19. {harness_sdk_python-0.4.3 → harness_sdk_python-0.4.5}/tests/test_facade.py +0 -0
  20. {harness_sdk_python-0.4.3 → harness_sdk_python-0.4.5}/tests/test_fenced_postgres.py +0 -0
  21. {harness_sdk_python-0.4.3 → harness_sdk_python-0.4.5}/tests/test_meta.py +0 -0
  22. {harness_sdk_python-0.4.3 → harness_sdk_python-0.4.5}/tests/test_placement.py +0 -0
  23. {harness_sdk_python-0.4.3 → harness_sdk_python-0.4.5}/tests/test_run_leaf.py +0 -0
  24. {harness_sdk_python-0.4.3 → harness_sdk_python-0.4.5}/tests/test_steer.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: harness-sdk-python
3
- Version: 0.4.3
3
+ Version: 0.4.5
4
4
  Summary: RunManager: the harness-sdk runs subsystem for Python Statewire hosts
5
5
  Project-URL: Repository, https://github.com/assistant-ui/harness-sdk
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "harness-sdk-python"
3
- version = "0.4.3"
3
+ version = "0.4.5"
4
4
  description = "RunManager: the harness-sdk runs subsystem for Python Statewire hosts"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -184,8 +184,15 @@ class RunManager:
184
184
  self._run_futures: list["asyncio.Future[Any]"] = []
185
185
  self._input_requests: list[dict[str, Any]] = []
186
186
  self._input_answers: dict[str, Any] = {}
187
+ self._idle = asyncio.Event()
188
+ self._idle.set()
187
189
  self._init_state()
188
190
 
191
+ @property
192
+ def idle(self) -> asyncio.Event:
193
+ """Set while no run is active and nothing is about to dispatch; cleared for the whole drain chain."""
194
+ return self._idle
195
+
189
196
  def _init_state(self) -> None:
190
197
  # Runs state is write-only and not durable: overwrite whatever is there.
191
198
  self._state["status"] = "ready"
@@ -247,6 +254,7 @@ class RunManager:
247
254
  self._dispatched_ids = ()
248
255
  if self._staged_rewinds:
249
256
  rewind = self._staged_rewinds.pop(0)
257
+ self._clear_input()
250
258
  self._dispatch(
251
259
  rewind.type,
252
260
  rewind.messages,
@@ -276,6 +284,8 @@ class RunManager:
276
284
  for future in fx.continues:
277
285
  if not future.done():
278
286
  future.set_result(None)
287
+ if self._task is None:
288
+ self._idle.set()
279
289
 
280
290
  def _apply(self, entry: Any, fx: _Effects) -> None:
281
291
  if isinstance(entry, _Stop):
@@ -316,17 +326,20 @@ class RunManager:
316
326
  fx.continues.clear()
317
327
  elif fx.new_added and pre_empty:
318
328
  self._pop_dispatchable()
319
- elif (
320
- status == "input-required"
321
- and self._input_requests
322
- and len(self._input_answers) == len(self._input_requests)
323
- ):
324
- responses = tuple(
325
- (request, *self._input_answers[request["id"]])
326
- for request in self._input_requests
327
- )
328
- self._clear_input()
329
- self._dispatch("input-resume", [], responses=responses)
329
+ elif status == "input-required":
330
+ if fx.steer_added and self._lane_items("steerQueue"):
331
+ abandoned = tuple(self._input_requests)
332
+ self._clear_input()
333
+ self._pop_dispatchable(abandoned_input=abandoned)
334
+ elif self._input_requests and len(self._input_answers) == len(
335
+ self._input_requests
336
+ ):
337
+ responses = tuple(
338
+ (request, *self._input_answers[request["id"]])
339
+ for request in self._input_requests
340
+ )
341
+ self._clear_input()
342
+ self._dispatch("input-resume", [], responses=responses)
330
343
 
331
344
  def _settle_outcome(self, outcome: Any) -> None:
332
345
  if isinstance(outcome, RunManager.Complete):
@@ -362,6 +375,7 @@ class RunManager:
362
375
  rollback_to: Any = _ABSENT,
363
376
  responses: Any = _ABSENT,
364
377
  root_meta: Any = None,
378
+ abandoned_input: tuple[dict[str, Any], ...] = (),
365
379
  ) -> None:
366
380
  if type not in _ENTRY_TYPES:
367
381
  raise ValueError(f"invalid entry type: {type!r}")
@@ -397,9 +411,11 @@ class RunManager:
397
411
  _responses=responses,
398
412
  _message_meta=message_meta,
399
413
  _root_meta=root_meta,
414
+ _abandoned_input=abandoned_input,
400
415
  )
401
416
  self._ctx = ctx
402
417
  self._sync_steering()
418
+ self._idle.clear()
403
419
  self._task = self._create_task(self._run(ctx))
404
420
 
405
421
  async def _run(self, ctx: "RunManager.StartContext") -> None:
@@ -424,6 +440,13 @@ class RunManager:
424
440
  raise RuntimeError(
425
441
  "run settled without acking its messages (call ctx.ack_messages())"
426
442
  )
443
+ except asyncio.CancelledError:
444
+ self._settle(ctx)
445
+ self._state["status"] = "stopped"
446
+ self._settle_initiators(_reject("stopped", "run cancelled"))
447
+ self._revert_dispatching()
448
+ self._idle.set()
449
+ raise # no drain, no freeze
427
450
  except Exception as exc:
428
451
  self._settle(ctx)
429
452
  message = str(exc) or type(exc).__name__
@@ -491,18 +514,20 @@ class RunManager:
491
514
  if plain(self._state["runLeafMessageId"]) in ids:
492
515
  self._state["runLeafMessageId"] = self._leaf_confirmed
493
516
 
494
- def _pop_dispatchable(self) -> bool:
517
+ def _pop_dispatchable(
518
+ self, *, abandoned_input: tuple[dict[str, Any], ...] = ()
519
+ ) -> bool:
495
520
  steer = self._lane_items("steerQueue")
496
521
  if steer:
497
522
  self._state["steerQueue"] = []
498
523
  self._dispatching = [("steerQueue", item) for item in steer]
499
- self._dispatch("message-send", steer)
524
+ self._dispatch("message-send", steer, abandoned_input=abandoned_input)
500
525
  return True
501
526
  queue = self._lane_items("queue")
502
527
  if queue:
503
528
  self._state["queue"].pop(0)
504
529
  self._dispatching = [("queue", queue[0])]
505
- self._dispatch("message-send", [queue[0]])
530
+ self._dispatch("message-send", [queue[0]], abandoned_input=abandoned_input)
506
531
  return True
507
532
  return False
508
533
 
@@ -741,8 +766,6 @@ class RunManager:
741
766
  raise _reject("capability-missing", "the rewind capability is not enabled")
742
767
  if e.source_meta is None:
743
768
  raise _reject("unknown-id", f"message {e.message_id} is unknown")
744
- if self._task is not None and not e.source_meta["isLeaf"]:
745
- raise _reject("not-leaf", "only the leaf may be edited during a run")
746
769
  self._check_anchor(e)
747
770
  self._staged_rewinds.append(
748
771
  _Rewind(
@@ -788,7 +811,6 @@ class RunManager:
788
811
  return None
789
812
 
790
813
  def _apply_edit(self, e: _Edit) -> Any:
791
- self._check_leaf_lanes(e.source_meta, "run/edit")
792
814
  if (
793
815
  e.message["id"] != e.source_id
794
816
  and self._lane_of(e.message["id"]) is not None
@@ -808,7 +830,6 @@ class RunManager:
808
830
  return _PARKED
809
831
 
810
832
  def _apply_reload(self, e: _Reload) -> Any:
811
- self._check_leaf_lanes(e.source_meta, "run/reload")
812
833
  self._staged_rewinds.append(
813
834
  _Rewind(
814
835
  "message-reload",
@@ -821,15 +842,6 @@ class RunManager:
821
842
  )
822
843
  return _PARKED
823
844
 
824
- def _check_leaf_lanes(self, meta: dict[str, Any], command: str) -> None:
825
- if meta["isLeaf"]:
826
- return
827
- if self._lane_items("steerQueue") or self._lane_items("queue"):
828
- raise _reject(
829
- "not-leaf",
830
- f"only the leaf accepts {command} while the queue is non-empty",
831
- )
832
-
833
845
  # ─── Command handlers ───────────────────────────────────
834
846
 
835
847
  async def _send(
@@ -912,10 +924,7 @@ class RunManager:
912
924
  return await self._stage(_Dequeue(message_id))
913
925
 
914
926
  def _check_rewind_gate(self, command: str) -> None:
915
- status = self._status()
916
- if status == "input-required":
917
- raise _reject("wrong-state", f"{command} is rejected in input-required")
918
- if status == "running":
927
+ if self._status() == "running":
919
928
  if "rewind-during-run" not in self._capabilities:
920
929
  raise _reject(
921
930
  "capability-missing",
@@ -924,10 +933,6 @@ class RunManager:
924
933
  elif "rewind" not in self._capabilities:
925
934
  raise _reject("capability-missing", "the rewind capability is not enabled")
926
935
 
927
- def _check_leaf_running(self, meta: dict[str, Any], command: str) -> None:
928
- if not meta["isLeaf"] and self._status() == "running":
929
- raise _reject("not-leaf", f"only the leaf accepts {command} during a run")
930
-
931
936
  async def edit(
932
937
  self, params: Any, *, meta: Any = None, ack: Callable[[], None]
933
938
  ) -> Any:
@@ -938,7 +943,6 @@ class RunManager:
938
943
  source_meta = await self._get_message_meta(source_id)
939
944
  if source_meta is None:
940
945
  raise _reject("unknown-id", f"message {source_id} is unknown")
941
- self._check_leaf_running(source_meta, "run/edit")
942
946
  if source_meta["role"] != "user" and "assistant-edit" not in self._capabilities:
943
947
  raise _reject(
944
948
  "capability-missing", "the assistant-edit capability is not enabled"
@@ -965,7 +969,6 @@ class RunManager:
965
969
  raise _reject("unknown-id", f"message {source_id} is unknown")
966
970
  if source_meta["role"] != "assistant":
967
971
  raise _reject("invalid-message", "sourceId must name an assistant message")
968
- self._check_leaf_running(source_meta, "run/reload")
969
972
  if source_meta["parentId"] is not None:
970
973
  parent = await self._get_message_meta(source_meta["parentId"])
971
974
  if (
@@ -994,10 +997,6 @@ class RunManager:
994
997
  ack()
995
998
  self._schedule(self._drain)
996
999
  return await entry.future
997
- if status == "input-required":
998
- self._clear_input()
999
- self._state["status"] = "stopped"
1000
- return None
1001
1000
  raise _reject("wrong-state", f"run/stop is rejected in {status}")
1002
1001
 
1003
1002
  def _validated_response(self, request_type: str, response: Any) -> dict[str, Any]:
@@ -1137,6 +1136,7 @@ class RunManager:
1137
1136
  _responses: Any
1138
1137
  _message_meta: dict[str, Any]
1139
1138
  _root_meta: Any
1139
+ _abandoned_input: tuple[dict[str, Any], ...]
1140
1140
  steering: "RunManager.Steering" = field(init=False)
1141
1141
 
1142
1142
  def __post_init__(self) -> None:
@@ -1152,6 +1152,11 @@ class RunManager:
1152
1152
  raise ValueError(f"message {message_id} was not dispatched to this run")
1153
1153
  return self._message_meta[message_id]
1154
1154
 
1155
+ @property
1156
+ def abandoned_input(self) -> tuple[dict[str, Any], ...]:
1157
+ """Input requests pending when a dispatching send abandoned them; empty on every other start."""
1158
+ return self._abandoned_input
1159
+
1155
1160
  @property
1156
1161
  def stop_reason(self) -> str | None:
1157
1162
  return self._manager._stop_reason
@@ -47,15 +47,24 @@ async def test_edit_of_dispatched_item_requires_rewind_during_run(capabilities):
47
47
 
48
48
 
49
49
  @pytest.mark.parametrize("command", ["run/enqueue", "run/steer"])
50
- async def test_edit_of_dispatched_non_leaf_rejects(command):
50
+ async def test_edit_of_dispatched_non_leaf_stops_and_reruns(command):
51
51
  script = Script()
52
52
  async with run_host(script, capabilities=("rewind", "rewind-during-run")) as (drv, host):
53
53
  await drv.command("run/enqueue", add("m1"), terminal=False)
54
- await script.next_call()
54
+ call = await script.next_call()
55
55
  script.thread["m1"] = {"parentId": None, "role": "user", "isLeaf": False}
56
- assert_rejected(
57
- await drv.command(command, {"message": msg("m1", "edited")}), "not-leaf"
56
+ call.ack()
57
+ pending = await drv.command(
58
+ command, {"message": msg("m1", "edited")}, terminal=False
58
59
  )
60
+ assert pending["type"] == "pending"
61
+ call.finish(RunManager.Stop(dispatch_queue=False))
62
+ rerun = await script.next_call()
63
+ assert rerun.ctx.type == "message-edit"
64
+ assert [m["parts"][0]["text"] for m in rerun.ctx.messages] == ["edited"]
65
+ rerun.ack()
66
+ rerun.finish(RunManager.Stop(dispatch_queue=False))
67
+ assert (await drv.res(pending["seq"]))["type"] == "accepted"
59
68
 
60
69
 
61
70
  async def test_edit_of_dispatched_item_with_queued_items_stops_and_reruns():
@@ -49,7 +49,7 @@ async def test_edit_while_running_requires_rewind_during_run():
49
49
  )
50
50
 
51
51
 
52
- async def test_deep_edit_rejected_while_queue_non_empty():
52
+ async def test_deep_edit_with_queue_non_empty_reruns_and_keeps_queue():
53
53
  script = Script()
54
54
  thread_with_turn(script)
55
55
  async with run_host(script, capabilities=("rewind",)) as (drv, host):
@@ -59,14 +59,12 @@ async def test_deep_edit_rejected_while_queue_non_empty():
59
59
  call.ack()
60
60
  call.fail(RuntimeError("boom"))
61
61
  await drv.wait_status("error")
62
- assert_rejected(
63
- await drv.command("run/edit", {"sourceId": "u1", "message": msg("u2")}, terminal=False),
64
- "not-leaf",
65
- )
66
- await drv.command("run/dequeue", {"messageId": "m2"})
67
62
  res = await drv.command("run/edit", {"sourceId": "u1", "message": msg("u2")}, terminal=False)
68
63
  assert res["type"] == "pending"
69
- assert (await script.next_call()).ctx.type == "message-edit"
64
+ rerun = await script.next_call()
65
+ assert rerun.ctx.type == "message-edit"
66
+ assert rerun.ctx.rollback_to is None
67
+ assert [item["id"] for item in drv.replica["queue"]] == ["m2"]
70
68
 
71
69
 
72
70
  async def test_leaf_edit_in_error_accepted_with_queue_non_empty():
@@ -164,7 +162,7 @@ async def test_reload_without_rewind_rejects():
164
162
  )
165
163
 
166
164
 
167
- async def test_reload_leaf_only_while_queue_non_empty():
165
+ async def test_deep_reload_with_queue_non_empty_reruns_and_keeps_queue():
168
166
  script = Script()
169
167
  script.thread.update(
170
168
  {
@@ -181,13 +179,12 @@ async def test_reload_leaf_only_while_queue_non_empty():
181
179
  call.ack()
182
180
  call.fail(RuntimeError("boom"))
183
181
  await drv.wait_status("error")
184
- assert_rejected(
185
- await drv.command("run/reload", {"sourceId": "a0"}, terminal=False), "not-leaf"
186
- )
187
- res = await drv.command("run/reload", {"sourceId": "a1"}, terminal=False)
182
+ res = await drv.command("run/reload", {"sourceId": "a0"}, terminal=False)
188
183
  assert res["type"] == "pending"
189
184
  call = await script.next_call()
190
- assert call.ctx.rollback_to == "m1"
185
+ assert call.ctx.type == "message-reload"
186
+ assert call.ctx.rollback_to == "u0"
187
+ assert [item["id"] for item in drv.replica["queue"]] == ["m2"]
191
188
 
192
189
 
193
190
  async def test_reload_of_assistant_parented_response_needs_continuation():
@@ -207,20 +207,21 @@ async def test_custom_request_type_passes_through():
207
207
  await drv.wait_status("ready")
208
208
 
209
209
 
210
- async def test_stop_while_parked_clears_the_pending_set():
210
+ async def test_stop_while_parked_rejects_and_keeps_the_pending_set():
211
211
  script = Script()
212
212
  async with run_host(script) as (drv, host):
213
213
  await park(drv, script, [tool_call("r1")])
214
- assert (await drv.command("run/stop"))["type"] == "accepted"
215
- await drv.wait_status("stopped")
216
- assert "inputRequests" not in drv.replica
217
- assert_rejected(
218
- await drv.command(
219
- "run/input", {"requestId": "r1", "response": {"output": "x"}}
220
- ),
221
- "wrong-state",
214
+ assert_rejected(await drv.command("run/stop"), "wrong-state")
215
+ assert drv.replica["status"] == "input-required"
216
+ assert drv.replica["inputRequests"] == [{**tool_call("r1"), "response": None}]
217
+ await drv.command(
218
+ "run/input", {"requestId": "r1", "response": {"output": "x"}}
222
219
  )
223
- script.no_call()
220
+ resumed = await script.next_call()
221
+ assert resumed.ctx.type == "input-resume"
222
+ resumed.ack()
223
+ resumed.finish(RunManager.Complete())
224
+ await drv.wait_status("ready")
224
225
 
225
226
 
226
227
  async def test_invalid_requests_raise_at_construction():
@@ -257,30 +258,135 @@ async def test_queue_while_parked_dispatches_after_resume():
257
258
  await drv.wait_status("ready")
258
259
 
259
260
 
260
- async def test_lifecycle_commands_rejected_while_parked():
261
+ async def test_continue_rejected_while_parked():
261
262
  script = Script()
263
+ async with run_host(script) as (drv, host):
264
+ await park(drv, script, [tool_call("r1")])
265
+ assert_rejected(await drv.command("run/continue"), "wrong-state")
266
+
267
+
268
+ def parked_thread(script):
262
269
  script.thread.update(
263
270
  {
264
271
  "u1": {"parentId": None, "role": "user", "onActiveBranch": True},
265
272
  "a1": {"parentId": "u1", "role": "assistant", "isLeaf": True, "onActiveBranch": True},
266
273
  }
267
274
  )
275
+
276
+
277
+ async def test_edit_while_parked_clears_requests_and_reruns():
278
+ script = Script()
279
+ parked_thread(script)
268
280
  async with run_host(script, capabilities=("rewind",)) as (drv, host):
269
281
  await park(drv, script, [tool_call("r1")], anchor="a1")
270
- assert_rejected(await drv.command("run/continue"), "wrong-state")
271
- assert_rejected(
272
- await drv.command("run/edit", {"sourceId": "u1", "message": msg("u2")}, terminal=False),
273
- "wrong-state",
274
- )
275
- assert_rejected(
276
- await drv.command("run/reload", {"sourceId": "a1"}, terminal=False), "wrong-state"
282
+ res = await drv.command(
283
+ "run/edit", {"sourceId": "u1", "message": msg("u2")}, terminal=False
277
284
  )
285
+ assert res["type"] == "pending"
286
+ rerun = await script.next_call()
287
+ assert rerun.ctx.type == "message-edit"
288
+ assert rerun.ctx.rollback_to is None
289
+ await drv.wait(lambda s: "inputRequests" not in s)
290
+ rerun.ack()
291
+ rerun.finish(RunManager.Complete())
292
+ await drv.wait_status("ready")
278
293
 
279
294
 
280
- async def test_steer_while_parked_stays_queued():
295
+ async def test_reload_while_parked_clears_requests_and_reruns():
296
+ script = Script()
297
+ parked_thread(script)
298
+ async with run_host(script, capabilities=("rewind",)) as (drv, host):
299
+ await park(drv, script, [tool_call("r1")], anchor="a1")
300
+ res = await drv.command("run/reload", {"sourceId": "a1"}, terminal=False)
301
+ assert res["type"] == "pending"
302
+ rerun = await script.next_call()
303
+ assert rerun.ctx.type == "message-reload"
304
+ assert rerun.ctx.rollback_to == "u1"
305
+ await drv.wait(lambda s: "inputRequests" not in s)
306
+ rerun.ack()
307
+ rerun.finish(RunManager.Complete())
308
+ await drv.wait_status("ready")
309
+
310
+
311
+ async def test_steer_add_while_parked_abandons_input_and_dispatches():
281
312
  script = Script()
282
313
  async with run_host(script) as (drv, host):
283
- await park(drv, script, [tool_call("r1")])
314
+ await park(drv, script, [tool_call("r1"), approval("r2")])
315
+ res = await drv.command("run/steer", add("s1"), terminal=False)
316
+ assert res["type"] == "pending"
317
+ run = await script.next_call()
318
+ assert run.ctx.type == "message-send"
319
+ assert [m["id"] for m in run.ctx.messages] == ["s1"]
320
+ assert run.ctx.abandoned_input == (tool_call("r1"), approval("r2"))
321
+ assert "inputRequests" not in drv.replica
322
+ run.ack()
323
+ run.finish(RunManager.Complete())
324
+ await drv.wait_status("ready")
325
+
326
+
327
+ async def test_steer_add_onto_nonempty_lane_while_parked_dispatches_whole_lane():
328
+ script = Script()
329
+ async with run_host(script) as (drv, host):
330
+ await drv.command("run/enqueue", add("m1"), terminal=False)
331
+ call = await script.next_call()
284
332
  await drv.command("run/steer", add("s1"), terminal=False)
333
+ call.ack()
334
+ call.finish(RunManager.InputRequired([tool_call("r1")]))
335
+ await drv.wait_status("input-required")
285
336
  assert queue_ids(drv.replica, "steerQueue") == ["s1"]
337
+ await drv.command("run/steer", add("s2"), terminal=False)
338
+ run = await script.next_call()
339
+ assert [m["id"] for m in run.ctx.messages] == ["s1", "s2"]
340
+ assert run.ctx.abandoned_input == (tool_call("r1"),)
341
+ assert "inputRequests" not in drv.replica
342
+ run.ack()
343
+ run.finish(RunManager.Complete())
344
+ await drv.wait_status("ready")
345
+
346
+
347
+ async def test_promote_to_steer_while_parked_abandons_input_and_dispatches():
348
+ script = Script()
349
+ async with run_host(script) as (drv, host):
350
+ await park(drv, script, [tool_call("r1")])
351
+ await drv.command("run/enqueue", add("m2"), terminal=False)
352
+ assert queue_ids(drv.replica, "queue") == ["m2"]
286
353
  script.no_call()
354
+ await drv.command("run/steer", {"messageId": "m2"})
355
+ run = await script.next_call()
356
+ assert run.ctx.type == "message-send"
357
+ assert [m["id"] for m in run.ctx.messages] == ["m2"]
358
+ assert run.ctx.abandoned_input == (tool_call("r1"),)
359
+ assert "inputRequests" not in drv.replica
360
+ run.ack()
361
+ run.finish(RunManager.Complete())
362
+ await drv.wait_status("ready")
363
+
364
+
365
+ async def test_enqueue_while_parked_parks_and_keeps_the_pending_set():
366
+ script = Script()
367
+ async with run_host(script) as (drv, host):
368
+ await park(drv, script, [tool_call("r1")])
369
+ await drv.command("run/enqueue", add("m2"), terminal=False)
370
+ assert queue_ids(drv.replica, "queue") == ["m2"]
371
+ assert drv.replica["status"] == "input-required"
372
+ assert drv.replica["inputRequests"] == [{**tool_call("r1"), "response": None}]
373
+ script.no_call()
374
+
375
+
376
+ async def test_abandoned_input_is_empty_on_ordinary_starts():
377
+ script = Script()
378
+ async with run_host(script) as (drv, host):
379
+ await drv.command("run/enqueue", add("m1"), terminal=False)
380
+ call = await script.next_call()
381
+ assert call.ctx.abandoned_input == ()
382
+ call.ack()
383
+ call.finish(RunManager.InputRequired([tool_call("r1")]))
384
+ await drv.wait_status("input-required")
385
+ await drv.command(
386
+ "run/input", {"requestId": "r1", "response": {"output": "ok"}}
387
+ )
388
+ resumed = await script.next_call()
389
+ assert resumed.ctx.abandoned_input == ()
390
+ resumed.ack()
391
+ resumed.finish(RunManager.Complete())
392
+ await drv.wait_status("ready")
@@ -138,3 +138,22 @@ async def test_unknown_capability_rejects_at_construction():
138
138
  schedule=lambda fn: None,
139
139
  capabilities=("time-travel",),
140
140
  )
141
+
142
+
143
+ async def test_idle_spans_the_drain_chain():
144
+ script = Script()
145
+ async with run_host(script) as (drv, host):
146
+ runs = host.live.runs
147
+ assert runs.idle.is_set()
148
+ await drv.command("run/enqueue", add("m1"), terminal=False)
149
+ first = await script.next_call()
150
+ assert not runs.idle.is_set()
151
+ await drv.command("run/enqueue", add("m2", anchor="m1"), terminal=False)
152
+ first.ack()
153
+ first.finish(RunManager.Complete())
154
+ second = await script.next_call()
155
+ assert not runs.idle.is_set()
156
+ second.ack()
157
+ second.finish(RunManager.Complete())
158
+ await drv.wait_status("ready")
159
+ assert runs.idle.is_set()
@@ -89,17 +89,39 @@ async def test_running_edit_and_reload_reject_without_capability(capabilities):
89
89
  )
90
90
 
91
91
 
92
- async def test_running_deeper_than_leaf_edit_and_reload_reject():
92
+ async def test_running_non_leaf_edit_stops_settles_then_rewinds():
93
93
  script = Script()
94
94
  async with run_host(script, capabilities=("rewind", "rewind-during-run")) as (drv, host):
95
- await start_run(drv, script, leaf=False)
96
- assert_rejected(
97
- await drv.command("run/edit", {"sourceId": "m1", "message": msg("m2")}, terminal=False),
98
- "not-leaf",
99
- )
100
- assert_rejected(
101
- await drv.command("run/reload", {"sourceId": "a1"}, terminal=False), "not-leaf"
95
+ call = await start_run(drv, script, leaf=False)
96
+ pending = await drv.command(
97
+ "run/edit", {"sourceId": "m1", "message": msg("m2")}, terminal=False
102
98
  )
99
+ assert pending["type"] == "pending"
100
+ await asyncio.wait_for(call.ctx.stop_requested.wait(), 5)
101
+ call.finish(RunManager.Stop(dispatch_queue=False))
102
+ rerun = await script.next_call()
103
+ assert rerun.ctx.type == "message-edit"
104
+ assert [m["id"] for m in rerun.ctx.messages] == ["m2"]
105
+ assert rerun.ctx.rollback_to is None
106
+ rerun.ack()
107
+ rerun.finish(RunManager.Stop(dispatch_queue=False))
108
+ assert (await drv.res(pending["seq"]))["type"] == "accepted"
109
+
110
+
111
+ async def test_running_non_leaf_reload_stops_settles_then_rewinds():
112
+ script = Script()
113
+ async with run_host(script, capabilities=("rewind", "rewind-during-run")) as (drv, host):
114
+ call = await start_run(drv, script, leaf=False)
115
+ pending = await drv.command("run/reload", {"sourceId": "a1"}, terminal=False)
116
+ assert pending["type"] == "pending"
117
+ await asyncio.wait_for(call.ctx.stop_requested.wait(), 5)
118
+ call.finish(RunManager.Stop(dispatch_queue=False))
119
+ rerun = await script.next_call()
120
+ assert rerun.ctx.type == "message-reload"
121
+ assert rerun.ctx.rollback_to == "m1"
122
+ rerun.ack()
123
+ rerun.finish(RunManager.Stop(dispatch_queue=False))
124
+ assert (await drv.res(pending["seq"]))["type"] == "accepted"
103
125
 
104
126
 
105
127
  async def test_rewind_during_run_requires_rewind():
@@ -215,3 +215,33 @@ async def test_rewind_edit_failure_settles_with_the_payload():
215
215
  settled = await drv.res(pending["seq"])
216
216
  assert settled["type"] == "rejected"
217
217
  assert settled["payload"] == {"code": 402}
218
+
219
+
220
+ async def test_task_cancellation_reverts_entries_and_settles_sends():
221
+ script = Script()
222
+ async with run_host(script) as (drv, host):
223
+ pending = await drv.command("run/enqueue", add("m1"), terminal=False)
224
+ await script.next_call()
225
+ await drv.command("run/enqueue", add("m2"))
226
+ task = host.live.runs._task
227
+ task.cancel()
228
+ assert (await drv.res(pending["seq"]))["type"] == "accepted"
229
+ await drv.wait_status("stopped")
230
+ assert queue_ids(drv.replica, "queue") == ["m1", "m2"]
231
+ assert drv.replica["runLeafMessageId"] is None
232
+ assert task.cancelled()
233
+
234
+
235
+ async def test_task_cancellation_rejects_a_rewind_initiator():
236
+ script = Script()
237
+ script.thread["u1"] = {"parentId": None, "role": "user", "isLeaf": True}
238
+ async with run_host(script, capabilities=("rewind",)) as (drv, host):
239
+ pending = await drv.command(
240
+ "run/edit", {"sourceId": "u1", "message": msg("u2")}, terminal=False
241
+ )
242
+ await script.next_call()
243
+ host.live.runs._task.cancel()
244
+ settled = await drv.res(pending["seq"])
245
+ assert settled["type"] == "rejected"
246
+ assert settled["payload"] == {"reason": "stopped"}
247
+ await drv.wait_status("stopped")
@@ -87,7 +87,7 @@ async def test_stop_rejected_outside_active_runs(state):
87
87
  assert_rejected(await drv.command("run/stop"), "wrong-state")
88
88
 
89
89
 
90
- async def test_stop_in_input_required_cancels_the_parked_run():
90
+ async def test_stop_in_input_required_rejects_wrong_state():
91
91
  script = Script()
92
92
  async with run_host(script) as (drv, host):
93
93
  await drv.command("run/enqueue", add("m1"), terminal=False)
@@ -95,8 +95,8 @@ async def test_stop_in_input_required_cancels_the_parked_run():
95
95
  call.ack()
96
96
  call.finish(RunManager.InputRequired([{"type": "free-form", "id": "r1"}]))
97
97
  await drv.wait_status("input-required")
98
- assert (await drv.command("run/stop"))["type"] == "accepted"
99
- await drv.wait_status("stopped")
98
+ assert_rejected(await drv.command("run/stop"), "wrong-state")
99
+ assert drv.replica["status"] == "input-required"
100
100
 
101
101
 
102
102
  async def test_stop_settles_even_when_the_run_completes_normally():