harness-sdk-python 0.4.4__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.4 → harness_sdk_python-0.4.5}/PKG-INFO +1 -1
  2. {harness_sdk_python-0.4.4 → harness_sdk_python-0.4.5}/pyproject.toml +1 -1
  3. {harness_sdk_python-0.4.4 → harness_sdk_python-0.4.5}/src/harness_sdk/run_manager.py +27 -18
  4. {harness_sdk_python-0.4.4 → harness_sdk_python-0.4.5}/tests/test_input_required.py +88 -12
  5. {harness_sdk_python-0.4.4 → harness_sdk_python-0.4.5}/tests/test_stop_continue.py +3 -3
  6. {harness_sdk_python-0.4.4 → harness_sdk_python-0.4.5}/.gitignore +0 -0
  7. {harness_sdk_python-0.4.4 → harness_sdk_python-0.4.5}/README.md +0 -0
  8. {harness_sdk_python-0.4.4 → harness_sdk_python-0.4.5}/src/harness_sdk/__init__.py +0 -0
  9. {harness_sdk_python-0.4.4 → harness_sdk_python-0.4.5}/src/harness_sdk/fenced_postgres.py +0 -0
  10. {harness_sdk_python-0.4.4 → harness_sdk_python-0.4.5}/tests/run_helpers.py +0 -0
  11. {harness_sdk_python-0.4.4 → harness_sdk_python-0.4.5}/tests/test_batches.py +0 -0
  12. {harness_sdk_python-0.4.4 → harness_sdk_python-0.4.5}/tests/test_branch_anchor.py +0 -0
  13. {harness_sdk_python-0.4.4 → harness_sdk_python-0.4.5}/tests/test_edit_dispatched.py +0 -0
  14. {harness_sdk_python-0.4.4 → harness_sdk_python-0.4.5}/tests/test_edit_reload.py +0 -0
  15. {harness_sdk_python-0.4.4 → harness_sdk_python-0.4.5}/tests/test_enqueue.py +0 -0
  16. {harness_sdk_python-0.4.4 → harness_sdk_python-0.4.5}/tests/test_facade.py +0 -0
  17. {harness_sdk_python-0.4.4 → harness_sdk_python-0.4.5}/tests/test_fenced_postgres.py +0 -0
  18. {harness_sdk_python-0.4.4 → harness_sdk_python-0.4.5}/tests/test_meta.py +0 -0
  19. {harness_sdk_python-0.4.4 → harness_sdk_python-0.4.5}/tests/test_outcomes.py +0 -0
  20. {harness_sdk_python-0.4.4 → harness_sdk_python-0.4.5}/tests/test_placement.py +0 -0
  21. {harness_sdk_python-0.4.4 → harness_sdk_python-0.4.5}/tests/test_rewind_during_run.py +0 -0
  22. {harness_sdk_python-0.4.4 → harness_sdk_python-0.4.5}/tests/test_run_leaf.py +0 -0
  23. {harness_sdk_python-0.4.4 → harness_sdk_python-0.4.5}/tests/test_settle.py +0 -0
  24. {harness_sdk_python-0.4.4 → 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.4
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.4"
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"
@@ -326,17 +326,20 @@ class RunManager:
326
326
  fx.continues.clear()
327
327
  elif fx.new_added and pre_empty:
328
328
  self._pop_dispatchable()
329
- elif (
330
- status == "input-required"
331
- and self._input_requests
332
- and len(self._input_answers) == len(self._input_requests)
333
- ):
334
- responses = tuple(
335
- (request, *self._input_answers[request["id"]])
336
- for request in self._input_requests
337
- )
338
- self._clear_input()
339
- 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)
340
343
 
341
344
  def _settle_outcome(self, outcome: Any) -> None:
342
345
  if isinstance(outcome, RunManager.Complete):
@@ -372,6 +375,7 @@ class RunManager:
372
375
  rollback_to: Any = _ABSENT,
373
376
  responses: Any = _ABSENT,
374
377
  root_meta: Any = None,
378
+ abandoned_input: tuple[dict[str, Any], ...] = (),
375
379
  ) -> None:
376
380
  if type not in _ENTRY_TYPES:
377
381
  raise ValueError(f"invalid entry type: {type!r}")
@@ -407,6 +411,7 @@ class RunManager:
407
411
  _responses=responses,
408
412
  _message_meta=message_meta,
409
413
  _root_meta=root_meta,
414
+ _abandoned_input=abandoned_input,
410
415
  )
411
416
  self._ctx = ctx
412
417
  self._sync_steering()
@@ -509,18 +514,20 @@ class RunManager:
509
514
  if plain(self._state["runLeafMessageId"]) in ids:
510
515
  self._state["runLeafMessageId"] = self._leaf_confirmed
511
516
 
512
- def _pop_dispatchable(self) -> bool:
517
+ def _pop_dispatchable(
518
+ self, *, abandoned_input: tuple[dict[str, Any], ...] = ()
519
+ ) -> bool:
513
520
  steer = self._lane_items("steerQueue")
514
521
  if steer:
515
522
  self._state["steerQueue"] = []
516
523
  self._dispatching = [("steerQueue", item) for item in steer]
517
- self._dispatch("message-send", steer)
524
+ self._dispatch("message-send", steer, abandoned_input=abandoned_input)
518
525
  return True
519
526
  queue = self._lane_items("queue")
520
527
  if queue:
521
528
  self._state["queue"].pop(0)
522
529
  self._dispatching = [("queue", queue[0])]
523
- self._dispatch("message-send", [queue[0]])
530
+ self._dispatch("message-send", [queue[0]], abandoned_input=abandoned_input)
524
531
  return True
525
532
  return False
526
533
 
@@ -990,10 +997,6 @@ class RunManager:
990
997
  ack()
991
998
  self._schedule(self._drain)
992
999
  return await entry.future
993
- if status == "input-required":
994
- self._clear_input()
995
- self._state["status"] = "stopped"
996
- return None
997
1000
  raise _reject("wrong-state", f"run/stop is rejected in {status}")
998
1001
 
999
1002
  def _validated_response(self, request_type: str, response: Any) -> dict[str, Any]:
@@ -1133,6 +1136,7 @@ class RunManager:
1133
1136
  _responses: Any
1134
1137
  _message_meta: dict[str, Any]
1135
1138
  _root_meta: Any
1139
+ _abandoned_input: tuple[dict[str, Any], ...]
1136
1140
  steering: "RunManager.Steering" = field(init=False)
1137
1141
 
1138
1142
  def __post_init__(self) -> None:
@@ -1148,6 +1152,11 @@ class RunManager:
1148
1152
  raise ValueError(f"message {message_id} was not dispatched to this run")
1149
1153
  return self._message_meta[message_id]
1150
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
+
1151
1160
  @property
1152
1161
  def stop_reason(self) -> str | None:
1153
1162
  return self._manager._stop_reason
@@ -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():
@@ -307,10 +308,85 @@ async def test_reload_while_parked_clears_requests_and_reruns():
307
308
  await drv.wait_status("ready")
308
309
 
309
310
 
310
- async def test_steer_while_parked_stays_queued():
311
+ async def test_steer_add_while_parked_abandons_input_and_dispatches():
311
312
  script = Script()
312
313
  async with run_host(script) as (drv, host):
313
- 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()
314
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")
315
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"]
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}]
316
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")
@@ -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():