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.
- {harness_sdk_python-0.4.3 → harness_sdk_python-0.4.5}/PKG-INFO +1 -1
- {harness_sdk_python-0.4.3 → harness_sdk_python-0.4.5}/pyproject.toml +1 -1
- {harness_sdk_python-0.4.3 → harness_sdk_python-0.4.5}/src/harness_sdk/run_manager.py +46 -41
- {harness_sdk_python-0.4.3 → harness_sdk_python-0.4.5}/tests/test_edit_dispatched.py +13 -4
- {harness_sdk_python-0.4.3 → harness_sdk_python-0.4.5}/tests/test_edit_reload.py +10 -13
- {harness_sdk_python-0.4.3 → harness_sdk_python-0.4.5}/tests/test_input_required.py +126 -20
- {harness_sdk_python-0.4.3 → harness_sdk_python-0.4.5}/tests/test_outcomes.py +19 -0
- {harness_sdk_python-0.4.3 → harness_sdk_python-0.4.5}/tests/test_rewind_during_run.py +30 -8
- {harness_sdk_python-0.4.3 → harness_sdk_python-0.4.5}/tests/test_settle.py +30 -0
- {harness_sdk_python-0.4.3 → harness_sdk_python-0.4.5}/tests/test_stop_continue.py +3 -3
- {harness_sdk_python-0.4.3 → harness_sdk_python-0.4.5}/.gitignore +0 -0
- {harness_sdk_python-0.4.3 → harness_sdk_python-0.4.5}/README.md +0 -0
- {harness_sdk_python-0.4.3 → harness_sdk_python-0.4.5}/src/harness_sdk/__init__.py +0 -0
- {harness_sdk_python-0.4.3 → harness_sdk_python-0.4.5}/src/harness_sdk/fenced_postgres.py +0 -0
- {harness_sdk_python-0.4.3 → harness_sdk_python-0.4.5}/tests/run_helpers.py +0 -0
- {harness_sdk_python-0.4.3 → harness_sdk_python-0.4.5}/tests/test_batches.py +0 -0
- {harness_sdk_python-0.4.3 → harness_sdk_python-0.4.5}/tests/test_branch_anchor.py +0 -0
- {harness_sdk_python-0.4.3 → harness_sdk_python-0.4.5}/tests/test_enqueue.py +0 -0
- {harness_sdk_python-0.4.3 → harness_sdk_python-0.4.5}/tests/test_facade.py +0 -0
- {harness_sdk_python-0.4.3 → harness_sdk_python-0.4.5}/tests/test_fenced_postgres.py +0 -0
- {harness_sdk_python-0.4.3 → harness_sdk_python-0.4.5}/tests/test_meta.py +0 -0
- {harness_sdk_python-0.4.3 → harness_sdk_python-0.4.5}/tests/test_placement.py +0 -0
- {harness_sdk_python-0.4.3 → harness_sdk_python-0.4.5}/tests/test_run_leaf.py +0 -0
- {harness_sdk_python-0.4.3 → harness_sdk_python-0.4.5}/tests/test_steer.py +0 -0
|
@@ -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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
57
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
assert "inputRequests"
|
|
217
|
-
|
|
218
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
271
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
97
|
-
|
|
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
|
|
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
|
-
|
|
99
|
-
|
|
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():
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|