harness-sdk-python 0.4.3__tar.gz → 0.4.4__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.4}/PKG-INFO +1 -1
  2. {harness_sdk_python-0.4.3 → harness_sdk_python-0.4.4}/pyproject.toml +1 -1
  3. {harness_sdk_python-0.4.3 → harness_sdk_python-0.4.4}/src/harness_sdk/run_manager.py +19 -23
  4. {harness_sdk_python-0.4.3 → harness_sdk_python-0.4.4}/tests/test_edit_dispatched.py +13 -4
  5. {harness_sdk_python-0.4.3 → harness_sdk_python-0.4.4}/tests/test_edit_reload.py +10 -13
  6. {harness_sdk_python-0.4.3 → harness_sdk_python-0.4.4}/tests/test_input_required.py +38 -8
  7. {harness_sdk_python-0.4.3 → harness_sdk_python-0.4.4}/tests/test_outcomes.py +19 -0
  8. {harness_sdk_python-0.4.3 → harness_sdk_python-0.4.4}/tests/test_rewind_during_run.py +30 -8
  9. {harness_sdk_python-0.4.3 → harness_sdk_python-0.4.4}/tests/test_settle.py +30 -0
  10. {harness_sdk_python-0.4.3 → harness_sdk_python-0.4.4}/.gitignore +0 -0
  11. {harness_sdk_python-0.4.3 → harness_sdk_python-0.4.4}/README.md +0 -0
  12. {harness_sdk_python-0.4.3 → harness_sdk_python-0.4.4}/src/harness_sdk/__init__.py +0 -0
  13. {harness_sdk_python-0.4.3 → harness_sdk_python-0.4.4}/src/harness_sdk/fenced_postgres.py +0 -0
  14. {harness_sdk_python-0.4.3 → harness_sdk_python-0.4.4}/tests/run_helpers.py +0 -0
  15. {harness_sdk_python-0.4.3 → harness_sdk_python-0.4.4}/tests/test_batches.py +0 -0
  16. {harness_sdk_python-0.4.3 → harness_sdk_python-0.4.4}/tests/test_branch_anchor.py +0 -0
  17. {harness_sdk_python-0.4.3 → harness_sdk_python-0.4.4}/tests/test_enqueue.py +0 -0
  18. {harness_sdk_python-0.4.3 → harness_sdk_python-0.4.4}/tests/test_facade.py +0 -0
  19. {harness_sdk_python-0.4.3 → harness_sdk_python-0.4.4}/tests/test_fenced_postgres.py +0 -0
  20. {harness_sdk_python-0.4.3 → harness_sdk_python-0.4.4}/tests/test_meta.py +0 -0
  21. {harness_sdk_python-0.4.3 → harness_sdk_python-0.4.4}/tests/test_placement.py +0 -0
  22. {harness_sdk_python-0.4.3 → harness_sdk_python-0.4.4}/tests/test_run_leaf.py +0 -0
  23. {harness_sdk_python-0.4.3 → harness_sdk_python-0.4.4}/tests/test_steer.py +0 -0
  24. {harness_sdk_python-0.4.3 → harness_sdk_python-0.4.4}/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.4.3
3
+ Version: 0.4.4
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.4"
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):
@@ -400,6 +410,7 @@ class RunManager:
400
410
  )
401
411
  self._ctx = ctx
402
412
  self._sync_steering()
413
+ self._idle.clear()
403
414
  self._task = self._create_task(self._run(ctx))
404
415
 
405
416
  async def _run(self, ctx: "RunManager.StartContext") -> None:
@@ -424,6 +435,13 @@ class RunManager:
424
435
  raise RuntimeError(
425
436
  "run settled without acking its messages (call ctx.ack_messages())"
426
437
  )
438
+ except asyncio.CancelledError:
439
+ self._settle(ctx)
440
+ self._state["status"] = "stopped"
441
+ self._settle_initiators(_reject("stopped", "run cancelled"))
442
+ self._revert_dispatching()
443
+ self._idle.set()
444
+ raise # no drain, no freeze
427
445
  except Exception as exc:
428
446
  self._settle(ctx)
429
447
  message = str(exc) or type(exc).__name__
@@ -741,8 +759,6 @@ class RunManager:
741
759
  raise _reject("capability-missing", "the rewind capability is not enabled")
742
760
  if e.source_meta is None:
743
761
  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
762
  self._check_anchor(e)
747
763
  self._staged_rewinds.append(
748
764
  _Rewind(
@@ -788,7 +804,6 @@ class RunManager:
788
804
  return None
789
805
 
790
806
  def _apply_edit(self, e: _Edit) -> Any:
791
- self._check_leaf_lanes(e.source_meta, "run/edit")
792
807
  if (
793
808
  e.message["id"] != e.source_id
794
809
  and self._lane_of(e.message["id"]) is not None
@@ -808,7 +823,6 @@ class RunManager:
808
823
  return _PARKED
809
824
 
810
825
  def _apply_reload(self, e: _Reload) -> Any:
811
- self._check_leaf_lanes(e.source_meta, "run/reload")
812
826
  self._staged_rewinds.append(
813
827
  _Rewind(
814
828
  "message-reload",
@@ -821,15 +835,6 @@ class RunManager:
821
835
  )
822
836
  return _PARKED
823
837
 
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
838
  # ─── Command handlers ───────────────────────────────────
834
839
 
835
840
  async def _send(
@@ -912,10 +917,7 @@ class RunManager:
912
917
  return await self._stage(_Dequeue(message_id))
913
918
 
914
919
  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":
920
+ if self._status() == "running":
919
921
  if "rewind-during-run" not in self._capabilities:
920
922
  raise _reject(
921
923
  "capability-missing",
@@ -924,10 +926,6 @@ class RunManager:
924
926
  elif "rewind" not in self._capabilities:
925
927
  raise _reject("capability-missing", "the rewind capability is not enabled")
926
928
 
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
929
  async def edit(
932
930
  self, params: Any, *, meta: Any = None, ack: Callable[[], None]
933
931
  ) -> Any:
@@ -938,7 +936,6 @@ class RunManager:
938
936
  source_meta = await self._get_message_meta(source_id)
939
937
  if source_meta is None:
940
938
  raise _reject("unknown-id", f"message {source_id} is unknown")
941
- self._check_leaf_running(source_meta, "run/edit")
942
939
  if source_meta["role"] != "user" and "assistant-edit" not in self._capabilities:
943
940
  raise _reject(
944
941
  "capability-missing", "the assistant-edit capability is not enabled"
@@ -965,7 +962,6 @@ class RunManager:
965
962
  raise _reject("unknown-id", f"message {source_id} is unknown")
966
963
  if source_meta["role"] != "assistant":
967
964
  raise _reject("invalid-message", "sourceId must name an assistant message")
968
- self._check_leaf_running(source_meta, "run/reload")
969
965
  if source_meta["parentId"] is not None:
970
966
  parent = await self._get_message_meta(source_meta["parentId"])
971
967
  if (
@@ -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():
@@ -257,24 +257,54 @@ async def test_queue_while_parked_dispatches_after_resume():
257
257
  await drv.wait_status("ready")
258
258
 
259
259
 
260
- async def test_lifecycle_commands_rejected_while_parked():
260
+ async def test_continue_rejected_while_parked():
261
261
  script = Script()
262
+ async with run_host(script) as (drv, host):
263
+ await park(drv, script, [tool_call("r1")])
264
+ assert_rejected(await drv.command("run/continue"), "wrong-state")
265
+
266
+
267
+ def parked_thread(script):
262
268
  script.thread.update(
263
269
  {
264
270
  "u1": {"parentId": None, "role": "user", "onActiveBranch": True},
265
271
  "a1": {"parentId": "u1", "role": "assistant", "isLeaf": True, "onActiveBranch": True},
266
272
  }
267
273
  )
274
+
275
+
276
+ async def test_edit_while_parked_clears_requests_and_reruns():
277
+ script = Script()
278
+ parked_thread(script)
268
279
  async with run_host(script, capabilities=("rewind",)) as (drv, host):
269
280
  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"
281
+ res = await drv.command(
282
+ "run/edit", {"sourceId": "u1", "message": msg("u2")}, terminal=False
277
283
  )
284
+ assert res["type"] == "pending"
285
+ rerun = await script.next_call()
286
+ assert rerun.ctx.type == "message-edit"
287
+ assert rerun.ctx.rollback_to is None
288
+ await drv.wait(lambda s: "inputRequests" not in s)
289
+ rerun.ack()
290
+ rerun.finish(RunManager.Complete())
291
+ await drv.wait_status("ready")
292
+
293
+
294
+ async def test_reload_while_parked_clears_requests_and_reruns():
295
+ script = Script()
296
+ parked_thread(script)
297
+ async with run_host(script, capabilities=("rewind",)) as (drv, host):
298
+ await park(drv, script, [tool_call("r1")], anchor="a1")
299
+ res = await drv.command("run/reload", {"sourceId": "a1"}, terminal=False)
300
+ assert res["type"] == "pending"
301
+ rerun = await script.next_call()
302
+ assert rerun.ctx.type == "message-reload"
303
+ assert rerun.ctx.rollback_to == "u1"
304
+ await drv.wait(lambda s: "inputRequests" not in s)
305
+ rerun.ack()
306
+ rerun.finish(RunManager.Complete())
307
+ await drv.wait_status("ready")
278
308
 
279
309
 
280
310
  async def test_steer_while_parked_stays_queued():
@@ -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")