harness-sdk-python 0.7.3__tar.gz → 0.9.0__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.7.3 → harness_sdk_python-0.9.0}/PKG-INFO +1 -1
- {harness_sdk_python-0.7.3 → harness_sdk_python-0.9.0}/pyproject.toml +1 -1
- {harness_sdk_python-0.7.3 → harness_sdk_python-0.9.0}/src/harness_sdk/linear_thread.py +0 -4
- {harness_sdk_python-0.7.3 → harness_sdk_python-0.9.0}/src/harness_sdk/run_manager.py +38 -46
- {harness_sdk_python-0.7.3 → harness_sdk_python-0.9.0}/tests/run_helpers.py +1 -5
- {harness_sdk_python-0.7.3 → harness_sdk_python-0.9.0}/tests/test_ack_visibility.py +2 -1
- {harness_sdk_python-0.7.3 → harness_sdk_python-0.9.0}/tests/test_dispatching.py +59 -9
- {harness_sdk_python-0.7.3 → harness_sdk_python-0.9.0}/tests/test_input_required.py +30 -0
- {harness_sdk_python-0.7.3 → harness_sdk_python-0.9.0}/tests/test_linear_thread.py +2 -11
- {harness_sdk_python-0.7.3 → harness_sdk_python-0.9.0}/tests/test_meta.py +7 -3
- {harness_sdk_python-0.7.3 → harness_sdk_python-0.9.0}/tests/test_settle.py +4 -3
- {harness_sdk_python-0.7.3 → harness_sdk_python-0.9.0}/tests/test_steer.py +2 -0
- {harness_sdk_python-0.7.3 → harness_sdk_python-0.9.0}/tests/test_stop_continue.py +1 -0
- harness_sdk_python-0.7.3/tests/test_run_leaf.py +0 -162
- {harness_sdk_python-0.7.3 → harness_sdk_python-0.9.0}/.gitignore +0 -0
- {harness_sdk_python-0.7.3 → harness_sdk_python-0.9.0}/README.md +0 -0
- {harness_sdk_python-0.7.3 → harness_sdk_python-0.9.0}/src/harness_sdk/__init__.py +0 -0
- {harness_sdk_python-0.7.3 → harness_sdk_python-0.9.0}/src/harness_sdk/fenced_postgres.py +0 -0
- {harness_sdk_python-0.7.3 → harness_sdk_python-0.9.0}/tests/test_batches.py +0 -0
- {harness_sdk_python-0.7.3 → harness_sdk_python-0.9.0}/tests/test_branch_anchor.py +0 -0
- {harness_sdk_python-0.7.3 → harness_sdk_python-0.9.0}/tests/test_edit_dispatched.py +0 -0
- {harness_sdk_python-0.7.3 → harness_sdk_python-0.9.0}/tests/test_edit_reload.py +0 -0
- {harness_sdk_python-0.7.3 → harness_sdk_python-0.9.0}/tests/test_enqueue.py +0 -0
- {harness_sdk_python-0.7.3 → harness_sdk_python-0.9.0}/tests/test_facade.py +0 -0
- {harness_sdk_python-0.7.3 → harness_sdk_python-0.9.0}/tests/test_fenced_postgres.py +0 -0
- {harness_sdk_python-0.7.3 → harness_sdk_python-0.9.0}/tests/test_outcomes.py +0 -0
- {harness_sdk_python-0.7.3 → harness_sdk_python-0.9.0}/tests/test_placement.py +0 -0
- {harness_sdk_python-0.7.3 → harness_sdk_python-0.9.0}/tests/test_prepare_hooks.py +0 -0
- {harness_sdk_python-0.7.3 → harness_sdk_python-0.9.0}/tests/test_rewind_during_run.py +0 -0
|
@@ -21,10 +21,6 @@ class LinearThread:
|
|
|
21
21
|
}
|
|
22
22
|
return None
|
|
23
23
|
|
|
24
|
-
async def get_leaf_message_id(self) -> str | None:
|
|
25
|
-
items = self._messages()
|
|
26
|
-
return items[-1].get("id") if items else None
|
|
27
|
-
|
|
28
24
|
async def get_message_child_id(self, parent_id: str) -> str | None:
|
|
29
25
|
"""The id of the first non-tool message after parent_id; None when parent_id is unknown or only tools follow."""
|
|
30
26
|
items = self._messages()
|
|
@@ -14,10 +14,12 @@ and settle when their run ends — accepted once the run has acked its
|
|
|
14
14
|
messages, rejected otherwise; ``run/stop`` awaits a future the drain resolves
|
|
15
15
|
once the in-flight run has ended.
|
|
16
16
|
|
|
17
|
-
``
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
``entry["dispatching"]`` is the dispatch record (or None): the payload the
|
|
18
|
+
run callback receives. ``ctx.ack()`` clears it; ``steering.take()`` may
|
|
19
|
+
refill it — at most one unacked batch ever exists. A run that ends with an
|
|
20
|
+
unacked record reverts its items
|
|
21
|
+
by provenance: lane items to the front of their lane, direct dispatches to
|
|
22
|
+
the front of the queue, rewind replacements dropped.
|
|
21
23
|
"""
|
|
22
24
|
|
|
23
25
|
import asyncio
|
|
@@ -51,6 +53,7 @@ _TRIGGERS = (
|
|
|
51
53
|
"input-resume",
|
|
52
54
|
"error-continue",
|
|
53
55
|
"stop-continue",
|
|
56
|
+
"steer",
|
|
54
57
|
)
|
|
55
58
|
|
|
56
59
|
_DECISIONS = ("approve", "reject", "edit", "respond")
|
|
@@ -152,10 +155,6 @@ class RunManager:
|
|
|
152
155
|
"""Meta for a known id ({parentId, role, isLeaf, onActiveBranch}), None for an unknown one; a None id probes the root ({isLeaf})."""
|
|
153
156
|
...
|
|
154
157
|
|
|
155
|
-
async def get_leaf_message_id(self) -> str | None:
|
|
156
|
-
"""The active branch's current leaf id, None while the thread is empty."""
|
|
157
|
-
...
|
|
158
|
-
|
|
159
158
|
def __init__(
|
|
160
159
|
self,
|
|
161
160
|
*,
|
|
@@ -197,7 +196,6 @@ class RunManager:
|
|
|
197
196
|
self._staged_rewinds: list[_Rewind] = []
|
|
198
197
|
self._dispatching: list[tuple[str, dict[str, Any]]] = []
|
|
199
198
|
self._run_acked = False
|
|
200
|
-
self._leaf_confirmed: str | None = None
|
|
201
199
|
self._run_futures: list["asyncio.Future[Any]"] = []
|
|
202
200
|
self._input_requests: list[dict[str, Any]] = []
|
|
203
201
|
self._input_answers: dict[str, Any] = {}
|
|
@@ -219,7 +217,6 @@ class RunManager:
|
|
|
219
217
|
"queue",
|
|
220
218
|
"steerQueue",
|
|
221
219
|
"runId",
|
|
222
|
-
"runLeafMessageId",
|
|
223
220
|
"dispatch",
|
|
224
221
|
"inputRequests",
|
|
225
222
|
):
|
|
@@ -242,9 +239,8 @@ class RunManager:
|
|
|
242
239
|
"status": "ready",
|
|
243
240
|
"queue": [],
|
|
244
241
|
"steerQueue": [],
|
|
245
|
-
"dispatching":
|
|
242
|
+
"dispatching": None,
|
|
246
243
|
"error": None,
|
|
247
|
-
"runLeafMessageId": None,
|
|
248
244
|
}
|
|
249
245
|
)
|
|
250
246
|
return self._state["runs"][0]
|
|
@@ -435,28 +431,26 @@ class RunManager:
|
|
|
435
431
|
if self._task is not None:
|
|
436
432
|
raise RuntimeError("a run is already in flight")
|
|
437
433
|
message_meta = {m["id"]: m.get("meta") for m in messages}
|
|
438
|
-
|
|
434
|
+
items = list(messages)
|
|
439
435
|
messages = [
|
|
440
436
|
{k: v for k, v in message.items() if k != "meta"} for message in messages
|
|
441
437
|
]
|
|
442
|
-
record: dict[str, Any] = {"trigger": trigger, "messages":
|
|
438
|
+
record: dict[str, Any] = {"trigger": trigger, "messages": items}
|
|
443
439
|
if rollback_to is not _ABSENT:
|
|
444
440
|
record["rollbackTo"] = rollback_to
|
|
445
441
|
if input_outcomes:
|
|
446
|
-
record["inputOutcomes"] =
|
|
447
|
-
|
|
442
|
+
record["inputOutcomes"] = [
|
|
443
|
+
{"request": request, "response": response, "meta": meta}
|
|
444
|
+
for request, response, meta in input_outcomes
|
|
445
|
+
]
|
|
446
|
+
self._dispatch_record = dict(record)
|
|
448
447
|
self._stop_reason = None
|
|
449
448
|
self._run_acked = False
|
|
450
449
|
entry = self._ensure_entry()
|
|
451
|
-
|
|
452
|
-
entry["dispatching"] = dispatching
|
|
450
|
+
entry["dispatching"] = record
|
|
453
451
|
entry["error"] = None
|
|
454
452
|
if messages:
|
|
455
453
|
self._dispatched_ids = tuple(m["id"] for m in messages)
|
|
456
|
-
if rollback_to is not _ABSENT:
|
|
457
|
-
entry["runLeafMessageId"] = rollback_to
|
|
458
|
-
if messages:
|
|
459
|
-
entry["runLeafMessageId"] = messages[-1]["id"]
|
|
460
454
|
entry["runId"] = uuid.uuid4().hex
|
|
461
455
|
self._set_status("running")
|
|
462
456
|
ctx = RunManager.RunContext(
|
|
@@ -491,7 +485,6 @@ class RunManager:
|
|
|
491
485
|
f"{type(outcome).__name__}"
|
|
492
486
|
)
|
|
493
487
|
except asyncio.CancelledError:
|
|
494
|
-
await self._pull_leaf()
|
|
495
488
|
self._settle(ctx)
|
|
496
489
|
self._set_status("stopped")
|
|
497
490
|
self._entry()["runId"] = None
|
|
@@ -502,7 +495,6 @@ class RunManager:
|
|
|
502
495
|
self._idle.set()
|
|
503
496
|
raise # no drain, no freeze
|
|
504
497
|
except Exception as exc:
|
|
505
|
-
await self._pull_leaf()
|
|
506
498
|
self._settle(ctx)
|
|
507
499
|
message = str(exc) or type(exc).__name__
|
|
508
500
|
if isinstance(exc, StatewireReject):
|
|
@@ -516,14 +508,13 @@ class RunManager:
|
|
|
516
508
|
self._revert_dispatching()
|
|
517
509
|
self._drain()
|
|
518
510
|
return
|
|
519
|
-
await self._pull_leaf()
|
|
520
511
|
self._settle(ctx)
|
|
521
512
|
if self._run_acked:
|
|
522
513
|
error = None
|
|
523
514
|
elif isinstance(outcome, RunManager.Error):
|
|
524
515
|
error = _reject("run-error", "run ended in error")
|
|
525
516
|
elif isinstance(outcome, RunManager.Stop):
|
|
526
|
-
error = _reject("stopped", "run stopped before the
|
|
517
|
+
error = _reject("stopped", "run stopped before the ack")
|
|
527
518
|
else:
|
|
528
519
|
error = None
|
|
529
520
|
self._settle_initiators(error)
|
|
@@ -531,12 +522,6 @@ class RunManager:
|
|
|
531
522
|
self._outcome = outcome
|
|
532
523
|
self._drain()
|
|
533
524
|
|
|
534
|
-
async def _pull_leaf(self) -> None:
|
|
535
|
-
# An end with unacked messages reverts instead of recording a leaf.
|
|
536
|
-
if plain(self._entry()["dispatching"]):
|
|
537
|
-
return
|
|
538
|
-
self._entry()["runLeafMessageId"] = await self._thread.get_leaf_message_id()
|
|
539
|
-
|
|
540
525
|
def _settle(self, ctx: "RunManager.RunContext") -> None:
|
|
541
526
|
if self._ctx is ctx:
|
|
542
527
|
self._ctx = None
|
|
@@ -553,16 +538,19 @@ class RunManager:
|
|
|
553
538
|
else:
|
|
554
539
|
future.set_exception(error)
|
|
555
540
|
|
|
556
|
-
def
|
|
541
|
+
def _ack(self) -> None:
|
|
542
|
+
entry = self._entry()
|
|
543
|
+
record = plain(entry["dispatching"])
|
|
544
|
+
if record is None:
|
|
545
|
+
raise RuntimeError("ack() with no unacked batch")
|
|
557
546
|
self._dispatching = []
|
|
558
|
-
|
|
547
|
+
entry["dispatching"] = None
|
|
559
548
|
self._run_acked = True
|
|
560
|
-
self._leaf_confirmed = plain(self._entry()["runLeafMessageId"])
|
|
561
549
|
|
|
562
550
|
def _revert_dispatching(self) -> None:
|
|
563
551
|
taken, self._dispatching = self._dispatching, []
|
|
564
552
|
entry = self._ensure_entry()
|
|
565
|
-
entry["dispatching"] =
|
|
553
|
+
entry["dispatching"] = None
|
|
566
554
|
if not taken:
|
|
567
555
|
return
|
|
568
556
|
for lane in ("steerQueue", "queue"):
|
|
@@ -571,8 +559,6 @@ class RunManager:
|
|
|
571
559
|
entry[lane] = front + self._lane_items(lane)
|
|
572
560
|
ids = {item["id"] for _, item in taken}
|
|
573
561
|
self._dispatched_ids = tuple(id for id in self._dispatched_ids if id not in ids)
|
|
574
|
-
if plain(entry["runLeafMessageId"]) in ids:
|
|
575
|
-
entry["runLeafMessageId"] = self._leaf_confirmed
|
|
576
562
|
|
|
577
563
|
def _dispatchable(self, lane: str) -> bool:
|
|
578
564
|
if self._task is not None or self._staged_rewinds:
|
|
@@ -601,7 +587,10 @@ class RunManager:
|
|
|
601
587
|
self._entry()["steerQueue"] = []
|
|
602
588
|
self._dispatching.extend(("steerQueue", item) for item in steer)
|
|
603
589
|
self._dispatching.append((e.lane, self._stamped(e.message, e.meta)))
|
|
604
|
-
self._ensure_entry()["dispatching"] =
|
|
590
|
+
self._ensure_entry()["dispatching"] = {
|
|
591
|
+
"trigger": "message-send",
|
|
592
|
+
"messages": [item for _, item in self._dispatching],
|
|
593
|
+
}
|
|
605
594
|
|
|
606
595
|
def _dispatch_staged(
|
|
607
596
|
self, *, input_outcomes: tuple[tuple[dict[str, Any], Any, Any], ...] = ()
|
|
@@ -1233,16 +1222,18 @@ class RunManager:
|
|
|
1233
1222
|
_ctx: "RunManager.RunContext"
|
|
1234
1223
|
|
|
1235
1224
|
def take(self) -> tuple[dict[str, Any], ...]:
|
|
1236
|
-
"""Drain the steer lane into
|
|
1225
|
+
"""Drain the steer lane into a fresh dispatch record; requires the current batch to be acked."""
|
|
1237
1226
|
self._ctx._ensure_active()
|
|
1238
1227
|
manager = self._ctx._manager
|
|
1239
|
-
items = manager._lane_items("steerQueue")
|
|
1240
1228
|
entry = manager._entry()
|
|
1229
|
+
if plain(entry["dispatching"]) is not None:
|
|
1230
|
+
raise RuntimeError("take before the current batch is acked")
|
|
1231
|
+
items = manager._lane_items("steerQueue")
|
|
1232
|
+
if not items:
|
|
1233
|
+
return ()
|
|
1241
1234
|
entry["steerQueue"] = []
|
|
1242
1235
|
manager._dispatching.extend(("steerQueue", item) for item in items)
|
|
1243
|
-
entry["dispatching"] =
|
|
1244
|
-
if items:
|
|
1245
|
-
entry["runLeafMessageId"] = items[-1]["id"]
|
|
1236
|
+
entry["dispatching"] = {"trigger": "steer", "messages": items}
|
|
1246
1237
|
self._ctx._message_meta.update(
|
|
1247
1238
|
{item["id"]: item.get("meta") for item in items}
|
|
1248
1239
|
)
|
|
@@ -1301,9 +1292,10 @@ class RunManager:
|
|
|
1301
1292
|
if self._manager._ctx is not self:
|
|
1302
1293
|
raise RuntimeError("this run has already settled")
|
|
1303
1294
|
|
|
1304
|
-
def
|
|
1295
|
+
def ack(self) -> None:
|
|
1296
|
+
"""Ack the current batch: clears the dispatch record."""
|
|
1305
1297
|
self._ensure_active()
|
|
1306
|
-
self._manager.
|
|
1298
|
+
self._manager._ack()
|
|
1307
1299
|
|
|
1308
1300
|
def set_recovery_state(self, value: Any) -> None:
|
|
1309
1301
|
self._ensure_active()
|
|
@@ -16,7 +16,7 @@ class Call:
|
|
|
16
16
|
outcome: "asyncio.Future[Any]"
|
|
17
17
|
|
|
18
18
|
def ack(self) -> None:
|
|
19
|
-
self.ctx.
|
|
19
|
+
self.ctx.ack()
|
|
20
20
|
|
|
21
21
|
def finish(self, outcome: Any) -> None:
|
|
22
22
|
self.outcome.set_result(outcome)
|
|
@@ -32,7 +32,6 @@ class Script:
|
|
|
32
32
|
def __init__(self) -> None:
|
|
33
33
|
self.calls: asyncio.Queue[Call] = asyncio.Queue()
|
|
34
34
|
self.thread: dict[str, dict[str, Any]] = {}
|
|
35
|
-
self.leaf: str | None = None
|
|
36
35
|
|
|
37
36
|
async def run(self, ctx: RunManager.RunContext) -> Any:
|
|
38
37
|
call = Call(ctx, asyncio.get_running_loop().create_future())
|
|
@@ -44,9 +43,6 @@ class Script:
|
|
|
44
43
|
return {"isLeaf": not self.thread}
|
|
45
44
|
return self.thread.get(message_id)
|
|
46
45
|
|
|
47
|
-
async def get_leaf_message_id(self) -> str | None:
|
|
48
|
-
return self.leaf
|
|
49
|
-
|
|
50
46
|
async def next_call(self, timeout: float = 5) -> Call:
|
|
51
47
|
return await asyncio.wait_for(self.calls.get(), timeout)
|
|
52
48
|
|
|
@@ -24,7 +24,8 @@ def covered_state(drv, seq):
|
|
|
24
24
|
|
|
25
25
|
def dispatching_ids(state):
|
|
26
26
|
runs = state.get("runs") or [{}]
|
|
27
|
-
|
|
27
|
+
record = runs[0].get("dispatching")
|
|
28
|
+
return [item["id"] for item in record["messages"]] if record else []
|
|
28
29
|
|
|
29
30
|
|
|
30
31
|
def assert_never_queued(drv, message_id):
|
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
"""Contract: replicated dispatch
|
|
2
|
-
the current
|
|
3
|
-
|
|
4
|
-
``
|
|
5
|
-
atomic: dispatch
|
|
6
|
-
|
|
7
|
-
returns lane items to the front of their lane."""
|
|
1
|
+
"""Contract: the replicated dispatch record. ``dispatching`` on the run entry
|
|
2
|
+
is the current unacked batch — a record ``{trigger, messages, rollbackTo?,
|
|
3
|
+
inputOutcomes?}`` or None. A message item lives in exactly one of the lanes,
|
|
4
|
+
the record's ``messages``, or the thread, and every transition is
|
|
5
|
+
single-envelope atomic: dispatch fills the record, ``ctx.ack()`` clears it as
|
|
6
|
+
the canonical message appears, a steer take refills it, and an unacked run
|
|
7
|
+
end returns lane items to the front of their lane."""
|
|
8
8
|
|
|
9
9
|
import copy
|
|
10
10
|
|
|
11
|
+
import pytest
|
|
11
12
|
from run_helpers import Script, add, msg, queue_ids, run_host
|
|
12
13
|
|
|
13
14
|
from harness_sdk import RunManager
|
|
@@ -16,16 +17,20 @@ from statewire_helpers import apply_ops
|
|
|
16
17
|
|
|
17
18
|
def dispatching_ids(replica):
|
|
18
19
|
runs = replica["runs"]
|
|
19
|
-
|
|
20
|
+
record = runs[0]["dispatching"] if runs else None
|
|
21
|
+
return [item["id"] for item in record["messages"]] if record else []
|
|
20
22
|
|
|
21
23
|
|
|
22
24
|
def places(state, message_id):
|
|
23
25
|
entry = (state.get("runs") or [{}])[0]
|
|
24
26
|
found = {
|
|
25
27
|
name
|
|
26
|
-
for name in ("queue", "steerQueue"
|
|
28
|
+
for name in ("queue", "steerQueue")
|
|
27
29
|
if any(item["id"] == message_id for item in entry.get(name, []))
|
|
28
30
|
}
|
|
31
|
+
record = entry.get("dispatching")
|
|
32
|
+
if record and any(item["id"] == message_id for item in record["messages"]):
|
|
33
|
+
found.add("dispatching")
|
|
29
34
|
if any(m["id"] == message_id for m in state.get("messages", [])):
|
|
30
35
|
found.add("messages")
|
|
31
36
|
return found
|
|
@@ -53,6 +58,7 @@ async def test_idle_send_dispatches_straight_into_dispatching():
|
|
|
53
58
|
drv.post("run/enqueue", add("m1"))
|
|
54
59
|
await script.next_call()
|
|
55
60
|
await drv.wait_status("running")
|
|
61
|
+
assert drv.run["dispatching"]["trigger"] == "message-send"
|
|
56
62
|
assert dispatching_ids(drv.replica) == ["m1"]
|
|
57
63
|
assert queue_ids(drv.replica, "queue") == []
|
|
58
64
|
assert residence(drv, "m1") == ["dispatching"]
|
|
@@ -97,6 +103,7 @@ async def test_steering_take_moves_the_lane_into_dispatching():
|
|
|
97
103
|
await drv.command("run/steer", add("s1"), terminal=False)
|
|
98
104
|
call.ctx.steering.take()
|
|
99
105
|
await drv.wait(lambda s: dispatching_ids(s) == ["s1"])
|
|
106
|
+
assert drv.run["dispatching"]["trigger"] == "steer"
|
|
100
107
|
assert queue_ids(drv.replica, "steerQueue") == []
|
|
101
108
|
assert residence(drv, "s1") == ["steerQueue", "dispatching"]
|
|
102
109
|
host.live.state["messages"] = [msg("m1"), msg("s1")]
|
|
@@ -121,3 +128,46 @@ async def test_edit_replacement_rides_dispatching_until_ack():
|
|
|
121
128
|
assert residence(drv, "u2") == ["dispatching", "messages"]
|
|
122
129
|
rerun.finish(RunManager.Complete())
|
|
123
130
|
assert (await drv.res(seq))["type"] == "accepted"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
async def test_take_before_the_ack_raises():
|
|
134
|
+
script = Script()
|
|
135
|
+
async with run_host(script) as (drv, host):
|
|
136
|
+
drv.post("run/enqueue", add("m1"))
|
|
137
|
+
call = await script.next_call()
|
|
138
|
+
await drv.side("run/steer", add("s1"))
|
|
139
|
+
await drv.wait(lambda s: queue_ids(s, "steerQueue") == ["s1"])
|
|
140
|
+
with pytest.raises(RuntimeError, match="before the current batch is acked"):
|
|
141
|
+
call.ctx.steering.take()
|
|
142
|
+
call.ack()
|
|
143
|
+
assert [m["id"] for m in call.ctx.steering.take()] == ["s1"]
|
|
144
|
+
call.ack()
|
|
145
|
+
call.finish(RunManager.Complete())
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
async def test_ack_with_no_unacked_batch_raises():
|
|
149
|
+
script = Script()
|
|
150
|
+
async with run_host(script) as (drv, host):
|
|
151
|
+
drv.post("run/enqueue", add("m1"))
|
|
152
|
+
call = await script.next_call()
|
|
153
|
+
call.ack()
|
|
154
|
+
with pytest.raises(RuntimeError, match="no unacked batch"):
|
|
155
|
+
call.ack()
|
|
156
|
+
call.finish(RunManager.Complete())
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
async def test_second_take_after_the_reack_drains_the_next_batch():
|
|
160
|
+
script = Script()
|
|
161
|
+
async with run_host(script) as (drv, host):
|
|
162
|
+
drv.post("run/enqueue", add("m1"))
|
|
163
|
+
call = await script.next_call()
|
|
164
|
+
call.ack()
|
|
165
|
+
await drv.command("run/steer", add("s1"), terminal=False)
|
|
166
|
+
assert [m["id"] for m in call.ctx.steering.take()] == ["s1"]
|
|
167
|
+
call.ack()
|
|
168
|
+
await drv.command("run/steer", add("s2", anchor="m1"), terminal=False)
|
|
169
|
+
assert [m["id"] for m in call.ctx.steering.take()] == ["s2"]
|
|
170
|
+
await drv.wait(lambda s: dispatching_ids(s) == ["s2"])
|
|
171
|
+
assert drv.run["dispatching"]["trigger"] == "steer"
|
|
172
|
+
call.ack()
|
|
173
|
+
call.finish(RunManager.Complete())
|
|
@@ -79,6 +79,36 @@ async def test_completing_answer_resumes_with_ordered_responses():
|
|
|
79
79
|
await drv.wait_status("ready")
|
|
80
80
|
|
|
81
81
|
|
|
82
|
+
async def test_input_outcomes_ride_the_dispatch_record_until_the_ack():
|
|
83
|
+
script = Script()
|
|
84
|
+
async with run_host(script) as (drv, host):
|
|
85
|
+
await park(drv, script, [tool_call("r1"), approval("r2")])
|
|
86
|
+
await drv.command(
|
|
87
|
+
"run/input", {"requestId": "r2", "response": {"decision": "approve"}}
|
|
88
|
+
)
|
|
89
|
+
await drv.command(
|
|
90
|
+
"run/input", {"requestId": "r1", "response": {"output": "ok"}}
|
|
91
|
+
)
|
|
92
|
+
resumed = await script.next_call()
|
|
93
|
+
await drv.wait(lambda s: run_of(s).get("dispatching") is not None)
|
|
94
|
+
assert drv.run["dispatching"] == {
|
|
95
|
+
"trigger": "input-resume",
|
|
96
|
+
"messages": [],
|
|
97
|
+
"inputOutcomes": [
|
|
98
|
+
{"request": tool_call("r1"), "response": {"output": "ok"}, "meta": None},
|
|
99
|
+
{
|
|
100
|
+
"request": approval("r2"),
|
|
101
|
+
"response": {"decision": "approve"},
|
|
102
|
+
"meta": None,
|
|
103
|
+
},
|
|
104
|
+
],
|
|
105
|
+
}
|
|
106
|
+
resumed.ack()
|
|
107
|
+
await drv.wait(lambda s: run_of(s).get("dispatching") is None)
|
|
108
|
+
resumed.finish(RunManager.Complete())
|
|
109
|
+
await drv.wait_status("ready")
|
|
110
|
+
|
|
111
|
+
|
|
82
112
|
async def test_completing_batch_resumes_once_in_the_same_envelope():
|
|
83
113
|
script = Script()
|
|
84
114
|
async with run_host(script) as (drv, host):
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
"""Contract: ``linear_thread`` projects a linear message list for RunManager —
|
|
2
2
|
``get_message_meta(None)`` probes the root (isLeaf iff empty), a known id gets
|
|
3
3
|
``{parentId, role, isLeaf, onActiveBranch}`` chained by list order, an unknown
|
|
4
|
-
id gets ``None``;
|
|
5
|
-
|
|
4
|
+
id gets ``None``; the extra ``get_message_child_id`` resolves the first
|
|
5
|
+
non-tool successor."""
|
|
6
6
|
|
|
7
7
|
import pytest
|
|
8
8
|
|
|
@@ -69,14 +69,6 @@ async def test_unknown_id_returns_none():
|
|
|
69
69
|
assert await thread.get_message_meta("nope") is None
|
|
70
70
|
|
|
71
71
|
|
|
72
|
-
async def test_leaf_is_the_last_message_id():
|
|
73
|
-
assert await _thread_for(THREAD).get_leaf_message_id() == "u2"
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
async def test_leaf_of_an_empty_thread_is_none():
|
|
77
|
-
assert await _thread_for([]).get_leaf_message_id() is None
|
|
78
|
-
|
|
79
|
-
|
|
80
72
|
async def test_child_id_skips_tool_messages():
|
|
81
73
|
thread = _thread_for(
|
|
82
74
|
[
|
|
@@ -109,7 +101,6 @@ async def test_reads_the_live_list():
|
|
|
109
101
|
assert await thread.get_message_meta(None) == {"isLeaf": True}
|
|
110
102
|
messages.append({"id": "u1", "type": "human"})
|
|
111
103
|
assert await thread.get_message_meta(None) == {"isLeaf": False}
|
|
112
|
-
assert await thread.get_leaf_message_id() == "u1"
|
|
113
104
|
|
|
114
105
|
|
|
115
106
|
def test_non_callable_messages_raises():
|
|
@@ -131,10 +131,14 @@ async def test_meta_dies_with_a_dequeued_entry():
|
|
|
131
131
|
async def test_unacked_run_end_reprojects_entries_with_meta():
|
|
132
132
|
script = Script()
|
|
133
133
|
async with run_host(script) as (drv, _):
|
|
134
|
-
drv.
|
|
134
|
+
drv.batch(
|
|
135
|
+
[
|
|
136
|
+
("run/enqueue", add("m1", meta={"clientId": "c1"})),
|
|
137
|
+
("run/steer", add("s1", meta={"clientId": "c2"})),
|
|
138
|
+
]
|
|
139
|
+
)
|
|
135
140
|
call = await script.next_call()
|
|
136
|
-
|
|
137
|
-
call.ctx.steering.take()
|
|
141
|
+
assert [m["id"] for m in call.ctx.messages] == ["m1", "s1"]
|
|
138
142
|
call.fail(RuntimeError("boom"))
|
|
139
143
|
await drv.wait_status("error")
|
|
140
144
|
assert drv.run["queue"][0]["meta"] == {"clientId": "c1"}
|
|
@@ -101,10 +101,10 @@ async def test_unacked_run_end_reverts_entries_to_the_lane_front():
|
|
|
101
101
|
await drv.wait_status("error")
|
|
102
102
|
assert queue_ids(drv.replica, "queue") == ["m1", "m2"]
|
|
103
103
|
assert drv.run["error"] == {"message": "boom"}
|
|
104
|
-
assert drv.run["runLeafMessageId"] is None
|
|
105
104
|
await drv.command("run/steer", {"messageId": "m1"}, terminal=False)
|
|
106
105
|
continued = await script.next_call()
|
|
107
106
|
assert continued.ctx.trigger == "error-continue"
|
|
107
|
+
continued.ack()
|
|
108
108
|
assert [m["id"] for m in continued.ctx.steering.take()] == ["m1"]
|
|
109
109
|
|
|
110
110
|
|
|
@@ -113,12 +113,14 @@ async def test_unacked_run_end_reverts_taken_steered_items():
|
|
|
113
113
|
async with run_host(script) as (drv, host):
|
|
114
114
|
drv.post("run/enqueue", add("m1"))
|
|
115
115
|
call = await script.next_call()
|
|
116
|
+
call.ack()
|
|
116
117
|
await drv.side("run/steer", add("s1"))
|
|
118
|
+
await drv.wait(lambda s: queue_ids(s, "steerQueue") == ["s1"])
|
|
117
119
|
call.ctx.steering.take()
|
|
118
120
|
call.fail(RuntimeError("boom"))
|
|
119
121
|
await drv.wait_status("error")
|
|
120
122
|
assert queue_ids(drv.replica, "steerQueue") == ["s1"]
|
|
121
|
-
assert queue_ids(drv.replica, "queue") == [
|
|
123
|
+
assert queue_ids(drv.replica, "queue") == []
|
|
122
124
|
|
|
123
125
|
|
|
124
126
|
async def test_ack_removes_the_entries_for_good():
|
|
@@ -208,7 +210,6 @@ async def test_task_cancellation_reverts_entries_and_settles_sends():
|
|
|
208
210
|
assert (await drv.res(seq))["type"] == "accepted"
|
|
209
211
|
await drv.wait_status("stopped")
|
|
210
212
|
assert queue_ids(drv.replica, "queue") == ["m1", "m2"]
|
|
211
|
-
assert drv.run["runLeafMessageId"] is None
|
|
212
213
|
assert task.cancelled()
|
|
213
214
|
|
|
214
215
|
|
|
@@ -55,6 +55,7 @@ async def test_steer_add_continues_in_error_and_stop(end, continue_type):
|
|
|
55
55
|
assert continued.ctx.messages == ()
|
|
56
56
|
assert queue_ids(drv.replica, "steerQueue") == ["s1"]
|
|
57
57
|
assert continued.ctx.steering.available.is_set()
|
|
58
|
+
continued.ack()
|
|
58
59
|
assert [m["id"] for m in continued.ctx.steering.take()] == ["s1"]
|
|
59
60
|
|
|
60
61
|
|
|
@@ -86,6 +87,7 @@ async def test_steer_edit_continues_in_error_with_replacement():
|
|
|
86
87
|
await drv.command("run/steer", {"message": msg("m2", "edited")}, terminal=False)
|
|
87
88
|
continued = await script.next_call()
|
|
88
89
|
assert continued.ctx.trigger == "error-continue"
|
|
90
|
+
continued.ack()
|
|
89
91
|
taken = continued.ctx.steering.take()
|
|
90
92
|
assert [m["parts"][0]["text"] for m in taken] == ["edited"]
|
|
91
93
|
|
|
@@ -234,6 +234,7 @@ async def test_continue_with_steer_lane_needs_no_capability():
|
|
|
234
234
|
assert res["type"] == "pending"
|
|
235
235
|
continued = await script.next_call()
|
|
236
236
|
assert continued.ctx.trigger == "error-continue"
|
|
237
|
+
continued.ack()
|
|
237
238
|
assert [m["id"] for m in continued.ctx.steering.take()] == ["s1"]
|
|
238
239
|
continued.ack()
|
|
239
240
|
continued.finish(RunManager.Complete())
|
|
@@ -1,162 +0,0 @@
|
|
|
1
|
-
from run_helpers import Script, add, msg, run_host, run_of
|
|
2
|
-
|
|
3
|
-
from harness_sdk import RunManager
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
def thread_with_turn(script: Script) -> None:
|
|
7
|
-
script.thread.update(
|
|
8
|
-
{
|
|
9
|
-
"u1": {
|
|
10
|
-
"parentId": None,
|
|
11
|
-
"role": "user",
|
|
12
|
-
"isLeaf": False,
|
|
13
|
-
"onActiveBranch": True,
|
|
14
|
-
},
|
|
15
|
-
"a1": {
|
|
16
|
-
"parentId": "u1",
|
|
17
|
-
"role": "assistant",
|
|
18
|
-
"isLeaf": True,
|
|
19
|
-
"onActiveBranch": True,
|
|
20
|
-
},
|
|
21
|
-
}
|
|
22
|
-
)
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
async def test_ready_state_has_no_run_entry():
|
|
26
|
-
script = Script()
|
|
27
|
-
thread_with_turn(script)
|
|
28
|
-
async with run_host(script) as (drv, host):
|
|
29
|
-
assert drv.replica["status"] == "ready"
|
|
30
|
-
assert drv.replica["runs"] == []
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
async def test_enqueue_dispatch_sets_the_leaf():
|
|
34
|
-
script = Script()
|
|
35
|
-
async with run_host(script) as (drv, host):
|
|
36
|
-
drv.post("run/enqueue", add("m1"))
|
|
37
|
-
await script.next_call()
|
|
38
|
-
await drv.wait(lambda s: run_of(s).get("runLeafMessageId") == "m1")
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
async def test_steer_dispatch_sets_the_leaf_to_the_last_lane_item():
|
|
42
|
-
script = Script()
|
|
43
|
-
async with run_host(script) as (drv, host):
|
|
44
|
-
drv.post("run/enqueue", add("m1"))
|
|
45
|
-
call = await script.next_call()
|
|
46
|
-
call.ack()
|
|
47
|
-
await drv.command("run/steer", add("s1", anchor="m1"), terminal=False)
|
|
48
|
-
await drv.command("run/steer", add("s2", anchor="s1"), terminal=False)
|
|
49
|
-
call.finish(RunManager.Complete())
|
|
50
|
-
steered = await script.next_call()
|
|
51
|
-
assert steered.ctx.messages[-1]["id"] == "s2"
|
|
52
|
-
await drv.wait(lambda s: run_of(s).get("runLeafMessageId") == "s2")
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
async def test_steering_take_sets_the_leaf():
|
|
56
|
-
script = Script()
|
|
57
|
-
async with run_host(script) as (drv, host):
|
|
58
|
-
drv.post("run/enqueue", add("m1"))
|
|
59
|
-
call = await script.next_call()
|
|
60
|
-
call.ack()
|
|
61
|
-
await drv.command("run/steer", add("s1", anchor="m1"), terminal=False)
|
|
62
|
-
await drv.wait(lambda s: len(run_of(s).get("steerQueue", [])) == 1)
|
|
63
|
-
call.ctx.steering.take()
|
|
64
|
-
await drv.wait(lambda s: run_of(s).get("runLeafMessageId") == "s1")
|
|
65
|
-
call.ack()
|
|
66
|
-
call.finish(RunManager.Complete())
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
async def test_settle_pulls_the_thread_leaf():
|
|
70
|
-
for outcome in [
|
|
71
|
-
RunManager.Error(dispatch_queue=False),
|
|
72
|
-
RunManager.Stop(dispatch_queue=False),
|
|
73
|
-
]:
|
|
74
|
-
script = Script()
|
|
75
|
-
async with run_host(script) as (drv, host):
|
|
76
|
-
drv.post("run/enqueue", add("m1"))
|
|
77
|
-
call = await script.next_call()
|
|
78
|
-
call.ack()
|
|
79
|
-
script.leaf = "a1"
|
|
80
|
-
call.finish(outcome)
|
|
81
|
-
await drv.wait_status("error", "stopped")
|
|
82
|
-
assert drv.run["runLeafMessageId"] == "a1"
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
async def test_input_required_settle_pulls_the_thread_leaf():
|
|
86
|
-
script = Script()
|
|
87
|
-
async with run_host(script) as (drv, host):
|
|
88
|
-
drv.post("run/enqueue", add("m1"))
|
|
89
|
-
call = await script.next_call()
|
|
90
|
-
call.ack()
|
|
91
|
-
script.leaf = "a1"
|
|
92
|
-
call.finish(
|
|
93
|
-
RunManager.InputRequired(
|
|
94
|
-
requests=({"id": "r1", "type": "tool-call", "toolCallId": "t1"},)
|
|
95
|
-
)
|
|
96
|
-
)
|
|
97
|
-
await drv.wait_status("input-required")
|
|
98
|
-
assert drv.run["runLeafMessageId"] == "a1"
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
async def test_unacked_end_reverts_instead_of_pulling():
|
|
102
|
-
script = Script()
|
|
103
|
-
async with run_host(script) as (drv, host):
|
|
104
|
-
drv.post("run/enqueue", add("m1"))
|
|
105
|
-
call = await script.next_call()
|
|
106
|
-
script.leaf = "a1"
|
|
107
|
-
call.fail(RuntimeError("boom"))
|
|
108
|
-
await drv.wait_status("error")
|
|
109
|
-
assert drv.run["runLeafMessageId"] is None
|
|
110
|
-
assert [item["id"] for item in drv.run["queue"]] == ["m1"]
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
async def test_complete_clears_the_leaf_on_ready():
|
|
114
|
-
script = Script()
|
|
115
|
-
async with run_host(script) as (drv, host):
|
|
116
|
-
drv.post("run/enqueue", add("m1"))
|
|
117
|
-
call = await script.next_call()
|
|
118
|
-
call.ack()
|
|
119
|
-
script.leaf = "a1"
|
|
120
|
-
await drv.wait_status("running")
|
|
121
|
-
call.finish(RunManager.Complete())
|
|
122
|
-
await drv.wait_status("ready")
|
|
123
|
-
assert drv.replica["runs"] == []
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
async def test_complete_with_a_queued_item_keeps_the_leaf_flowing():
|
|
127
|
-
script = Script()
|
|
128
|
-
async with run_host(script) as (drv, host):
|
|
129
|
-
drv.post("run/enqueue", add("m1"))
|
|
130
|
-
call = await script.next_call()
|
|
131
|
-
call.ack()
|
|
132
|
-
await drv.command("run/enqueue", add("m2", anchor="m1"), terminal=False)
|
|
133
|
-
script.leaf = "a1"
|
|
134
|
-
call.finish(RunManager.Complete())
|
|
135
|
-
drain = await script.next_call()
|
|
136
|
-
await drv.wait(lambda s: run_of(s).get("runLeafMessageId") == "m2")
|
|
137
|
-
drain.ack()
|
|
138
|
-
drain.finish(RunManager.Complete())
|
|
139
|
-
await drv.wait_status("ready")
|
|
140
|
-
assert drv.replica["runs"] == []
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
async def test_edit_dispatch_sets_the_leaf_to_the_replacement():
|
|
144
|
-
script = Script()
|
|
145
|
-
thread_with_turn(script)
|
|
146
|
-
async with run_host(script, capabilities=("rewind",)) as (drv, host):
|
|
147
|
-
drv.post("run/edit", {"sourceId": "u1", "message": msg("u2")})
|
|
148
|
-
await script.next_call()
|
|
149
|
-
await drv.wait(lambda s: run_of(s).get("runLeafMessageId") == "u2")
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
async def test_reload_dispatch_sets_the_leaf_to_the_rollback_target():
|
|
153
|
-
script = Script()
|
|
154
|
-
thread_with_turn(script)
|
|
155
|
-
async with run_host(script, capabilities=("rewind",)) as (drv, host):
|
|
156
|
-
drv.post("run/reload", {"sourceId": "a1"})
|
|
157
|
-
call = await script.next_call()
|
|
158
|
-
await drv.wait(lambda s: run_of(s).get("runLeafMessageId") == "u1")
|
|
159
|
-
call.ack()
|
|
160
|
-
script.leaf = "a2"
|
|
161
|
-
call.finish(RunManager.Stop(dispatch_queue=False))
|
|
162
|
-
await drv.wait(lambda s: run_of(s).get("runLeafMessageId") == "a2")
|
|
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
|
|
File without changes
|