harness-sdk-python 0.3.0__tar.gz → 0.4.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.3.0 → harness_sdk_python-0.4.0}/.gitignore +3 -0
- {harness_sdk_python-0.3.0 → harness_sdk_python-0.4.0}/PKG-INFO +2 -2
- {harness_sdk_python-0.3.0 → harness_sdk_python-0.4.0}/pyproject.toml +2 -2
- {harness_sdk_python-0.3.0 → harness_sdk_python-0.4.0}/src/harness_sdk/run_manager.py +157 -12
- {harness_sdk_python-0.3.0 → harness_sdk_python-0.4.0}/tests/run_helpers.py +8 -1
- {harness_sdk_python-0.3.0 → harness_sdk_python-0.4.0}/tests/test_batches.py +19 -16
- harness_sdk_python-0.4.0/tests/test_branch_anchor.py +145 -0
- {harness_sdk_python-0.3.0 → harness_sdk_python-0.4.0}/tests/test_caller.py +22 -22
- {harness_sdk_python-0.3.0 → harness_sdk_python-0.4.0}/tests/test_edit_dispatched.py +12 -10
- {harness_sdk_python-0.3.0 → harness_sdk_python-0.4.0}/tests/test_edit_reload.py +43 -43
- {harness_sdk_python-0.3.0 → harness_sdk_python-0.4.0}/tests/test_enqueue.py +42 -42
- harness_sdk_python-0.4.0/tests/test_facade.py +84 -0
- {harness_sdk_python-0.3.0 → harness_sdk_python-0.4.0}/tests/test_input_required.py +11 -11
- {harness_sdk_python-0.3.0 → harness_sdk_python-0.4.0}/tests/test_outcomes.py +12 -12
- {harness_sdk_python-0.3.0 → harness_sdk_python-0.4.0}/tests/test_placement.py +19 -19
- {harness_sdk_python-0.3.0 → harness_sdk_python-0.4.0}/tests/test_rewind_during_run.py +18 -15
- harness_sdk_python-0.4.0/tests/test_settle.py +131 -0
- {harness_sdk_python-0.3.0 → harness_sdk_python-0.4.0}/tests/test_steer.py +17 -17
- {harness_sdk_python-0.3.0 → harness_sdk_python-0.4.0}/tests/test_stop_continue.py +16 -16
- {harness_sdk_python-0.3.0 → harness_sdk_python-0.4.0}/README.md +0 -0
- {harness_sdk_python-0.3.0 → harness_sdk_python-0.4.0}/src/harness_sdk/__init__.py +0 -0
- {harness_sdk_python-0.3.0 → harness_sdk_python-0.4.0}/src/harness_sdk/fenced_postgres.py +0 -0
- {harness_sdk_python-0.3.0 → harness_sdk_python-0.4.0}/tests/test_fenced_postgres.py +0 -0
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: harness-sdk-python
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
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
|
|
7
7
|
Requires-Python: <4.0,>=3.11
|
|
8
|
-
Requires-Dist: statewire<0.
|
|
8
|
+
Requires-Dist: statewire<0.5,>=0.4.0
|
|
9
9
|
Provides-Extra: postgres
|
|
10
10
|
Requires-Dist: langgraph-checkpoint-postgres>=2.0.0; extra == 'postgres'
|
|
11
11
|
Description-Content-Type: text/markdown
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "harness-sdk-python"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.4.0"
|
|
4
4
|
description = "RunManager: the harness-sdk runs subsystem for Python Statewire hosts"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
7
7
|
requires-python = ">=3.11,<4.0"
|
|
8
|
-
dependencies = ["statewire>=0.
|
|
8
|
+
dependencies = ["statewire>=0.4.0,<0.5"]
|
|
9
9
|
|
|
10
10
|
[project.optional-dependencies]
|
|
11
11
|
postgres = ["langgraph-checkpoint-postgres>=2.0.0"]
|
|
@@ -45,7 +45,8 @@ _ENTRY_TYPES = (
|
|
|
45
45
|
|
|
46
46
|
_DECISIONS = ("approve", "reject", "edit", "respond")
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
# Called with None it answers for the thread root: isLeaf True iff the thread is empty.
|
|
49
|
+
GetMessageMeta = Callable[[str | None], Awaitable[dict[str, Any] | None]]
|
|
49
50
|
|
|
50
51
|
|
|
51
52
|
def _reject(reason: str, message: str) -> StatewireReject:
|
|
@@ -65,6 +66,9 @@ class _Send:
|
|
|
65
66
|
meta: dict[str, Any] | None
|
|
66
67
|
caller: StatewireClientHandle | None
|
|
67
68
|
ack: Callable[[], None]
|
|
69
|
+
anchor: Any = _ABSENT
|
|
70
|
+
anchor_meta: dict[str, Any] | None = None
|
|
71
|
+
thread_empty: bool = False
|
|
68
72
|
future: "asyncio.Future[Any]" = field(default_factory=_future)
|
|
69
73
|
|
|
70
74
|
|
|
@@ -86,6 +90,7 @@ class _Edit:
|
|
|
86
90
|
@dataclass
|
|
87
91
|
class _Reload:
|
|
88
92
|
source_meta: dict[str, Any]
|
|
93
|
+
message_id: str
|
|
89
94
|
ack: Callable[[], None]
|
|
90
95
|
future: "asyncio.Future[Any]" = field(default_factory=_future)
|
|
91
96
|
|
|
@@ -114,6 +119,7 @@ class _Rewind:
|
|
|
114
119
|
rollback_to: Any
|
|
115
120
|
ack: Callable[[], None]
|
|
116
121
|
future: "asyncio.Future[Any]"
|
|
122
|
+
message_id: str | None = None
|
|
117
123
|
acked: bool = False
|
|
118
124
|
|
|
119
125
|
|
|
@@ -154,6 +160,7 @@ class RunManager:
|
|
|
154
160
|
self._task: "asyncio.Task[Any] | None" = None
|
|
155
161
|
self._ctx: "RunManager.StartContext | None" = None
|
|
156
162
|
self._dispatched_ids: tuple[str, ...] = ()
|
|
163
|
+
self._reload_message_id: str | None = None
|
|
157
164
|
self._dispatch_record: dict[str, Any] | None = None
|
|
158
165
|
self._callers: dict[str, StatewireClientHandle] = {}
|
|
159
166
|
self._intake: list[Any] = []
|
|
@@ -161,6 +168,8 @@ class RunManager:
|
|
|
161
168
|
self._staged_stops: list["asyncio.Future[Any]"] = []
|
|
162
169
|
self._stop_reason: str | None = None
|
|
163
170
|
self._staged_rewinds: list[_Rewind] = []
|
|
171
|
+
self._entity_futures: dict[str, "asyncio.Future[Any]"] = {}
|
|
172
|
+
self._run_futures: list["asyncio.Future[Any]"] = []
|
|
164
173
|
self._input_requests: list[dict[str, Any]] = []
|
|
165
174
|
self._input_answers: dict[str, Any] = {}
|
|
166
175
|
self._init_state()
|
|
@@ -212,13 +221,18 @@ class RunManager:
|
|
|
212
221
|
self._staged_stops.clear()
|
|
213
222
|
if isinstance(outcome, RunManager.Complete):
|
|
214
223
|
self._dispatched_ids = ()
|
|
224
|
+
self._reload_message_id = None
|
|
215
225
|
if self._staged_rewinds:
|
|
216
226
|
rewind = self._staged_rewinds.pop(0)
|
|
217
227
|
self._dispatch(
|
|
218
|
-
rewind.type,
|
|
228
|
+
rewind.type,
|
|
229
|
+
rewind.messages,
|
|
230
|
+
rollback_to=rewind.rollback_to,
|
|
231
|
+
message_id=rewind.message_id,
|
|
219
232
|
)
|
|
233
|
+
rewind.ack()
|
|
220
234
|
if not rewind.future.done():
|
|
221
|
-
rewind.future
|
|
235
|
+
self._run_futures.append(rewind.future)
|
|
222
236
|
elif outcome is not None:
|
|
223
237
|
self._settle_outcome(outcome)
|
|
224
238
|
else:
|
|
@@ -307,6 +321,7 @@ class RunManager:
|
|
|
307
321
|
*,
|
|
308
322
|
rollback_to: Any = _ABSENT,
|
|
309
323
|
responses: Any = _ABSENT,
|
|
324
|
+
message_id: str | None = None,
|
|
310
325
|
) -> None:
|
|
311
326
|
if type not in _ENTRY_TYPES:
|
|
312
327
|
raise ValueError(f"invalid entry type: {type!r}")
|
|
@@ -318,16 +333,20 @@ class RunManager:
|
|
|
318
333
|
]
|
|
319
334
|
for message in messages:
|
|
320
335
|
self._callers.pop(message["id"], None)
|
|
336
|
+
self._adopt_entities(message["id"] for message in messages)
|
|
321
337
|
record: dict[str, Any] = {"type": type, "messages": list(messages)}
|
|
322
338
|
if rollback_to is not _ABSENT:
|
|
323
339
|
record["rollbackTo"] = rollback_to
|
|
324
340
|
if responses is not _ABSENT:
|
|
325
341
|
record["responses"] = responses
|
|
342
|
+
if message_id is not None:
|
|
343
|
+
record["messageId"] = message_id
|
|
326
344
|
self._dispatch_record = record
|
|
327
345
|
self._stop_reason = None
|
|
328
346
|
self._state["error"] = None
|
|
329
347
|
if messages:
|
|
330
348
|
self._dispatched_ids = tuple(m["id"] for m in messages)
|
|
349
|
+
self._reload_message_id = message_id
|
|
331
350
|
self._state["status"] = "running"
|
|
332
351
|
ctx = RunManager.StartContext(
|
|
333
352
|
type=type,
|
|
@@ -337,6 +356,7 @@ class RunManager:
|
|
|
337
356
|
_manager=self,
|
|
338
357
|
_rollback_to=rollback_to,
|
|
339
358
|
_responses=responses,
|
|
359
|
+
_message_id=message_id,
|
|
340
360
|
)
|
|
341
361
|
self._ctx = ctx
|
|
342
362
|
self._task = self._create_task(self._run(ctx))
|
|
@@ -359,10 +379,21 @@ class RunManager:
|
|
|
359
379
|
)
|
|
360
380
|
except Exception as exc:
|
|
361
381
|
self._settle(ctx)
|
|
362
|
-
|
|
382
|
+
message = str(exc) or type(exc).__name__
|
|
383
|
+
if isinstance(exc, StatewireReject):
|
|
384
|
+
self._freeze(exc.message, exc.payload)
|
|
385
|
+
self._settle_entities(exc)
|
|
386
|
+
else:
|
|
387
|
+
self._freeze(message)
|
|
388
|
+
self._settle_entities(_reject("run-error", message))
|
|
363
389
|
self._drain()
|
|
364
390
|
return
|
|
365
391
|
self._settle(ctx)
|
|
392
|
+
self._settle_entities(
|
|
393
|
+
_reject("run-error", "run ended in error")
|
|
394
|
+
if isinstance(outcome, RunManager.Error)
|
|
395
|
+
else None
|
|
396
|
+
)
|
|
366
397
|
self._outcome = outcome
|
|
367
398
|
self._drain()
|
|
368
399
|
|
|
@@ -372,6 +403,22 @@ class RunManager:
|
|
|
372
403
|
self._task = None
|
|
373
404
|
self._dispatch_record = None
|
|
374
405
|
|
|
406
|
+
def _settle_entities(self, error: StatewireReject | None) -> None:
|
|
407
|
+
futures, self._run_futures = self._run_futures, []
|
|
408
|
+
for future in futures:
|
|
409
|
+
if future.done():
|
|
410
|
+
continue
|
|
411
|
+
if error is None:
|
|
412
|
+
future.set_result(None)
|
|
413
|
+
else:
|
|
414
|
+
future.set_exception(error)
|
|
415
|
+
|
|
416
|
+
def _adopt_entities(self, ids: Iterable[str]) -> None:
|
|
417
|
+
for id in ids:
|
|
418
|
+
future = self._entity_futures.pop(id, None)
|
|
419
|
+
if future is not None:
|
|
420
|
+
self._run_futures.append(future)
|
|
421
|
+
|
|
375
422
|
def _pop_dispatchable(self) -> bool:
|
|
376
423
|
steer = self._lane_items("steerQueue")
|
|
377
424
|
if steer:
|
|
@@ -385,9 +432,14 @@ class RunManager:
|
|
|
385
432
|
return True
|
|
386
433
|
return False
|
|
387
434
|
|
|
388
|
-
def _freeze(self, message: str) -> None:
|
|
435
|
+
def _freeze(self, message: str, payload: Any = None) -> None:
|
|
389
436
|
self._state["status"] = "error"
|
|
390
|
-
|
|
437
|
+
if isinstance(payload, dict):
|
|
438
|
+
self._state["error"] = {**payload, "message": message}
|
|
439
|
+
elif payload is not None:
|
|
440
|
+
self._state["error"] = {"message": message, "payload": payload}
|
|
441
|
+
else:
|
|
442
|
+
self._state["error"] = {"message": message}
|
|
391
443
|
|
|
392
444
|
# ─── Message and placement validation ───────────────────
|
|
393
445
|
|
|
@@ -468,6 +520,24 @@ class RunManager:
|
|
|
468
520
|
raise _reject("not-adjacent", "insertAfter and insertBefore are not adjacent")
|
|
469
521
|
return slot
|
|
470
522
|
|
|
523
|
+
def _check_anchor(self, e: _Send) -> None:
|
|
524
|
+
if e.anchor is _ABSENT:
|
|
525
|
+
return
|
|
526
|
+
if e.anchor is None:
|
|
527
|
+
if not e.thread_empty:
|
|
528
|
+
raise _reject("wrong-anchor", "a null anchor asserts an empty thread")
|
|
529
|
+
return
|
|
530
|
+
if self._lane_of(e.anchor) is not None:
|
|
531
|
+
return
|
|
532
|
+
if e.anchor in self._dispatched_ids or e.anchor == self._reload_message_id:
|
|
533
|
+
return
|
|
534
|
+
if e.anchor_meta is None:
|
|
535
|
+
raise _reject("unknown-id", f"anchor {e.anchor} names nothing")
|
|
536
|
+
if not e.anchor_meta["onActiveBranch"]:
|
|
537
|
+
raise _reject(
|
|
538
|
+
"wrong-anchor", f"anchor {e.anchor} is off the active branch"
|
|
539
|
+
)
|
|
540
|
+
|
|
471
541
|
# ─── Queue mutations ────────────────────────────────────
|
|
472
542
|
|
|
473
543
|
def _stamped(self, message: dict[str, Any]) -> dict[str, Any]:
|
|
@@ -543,6 +613,7 @@ class RunManager:
|
|
|
543
613
|
return self._apply_move(e, fx)
|
|
544
614
|
current = self._lane_of(e.message_id)
|
|
545
615
|
if current is not None:
|
|
616
|
+
self._check_anchor(e)
|
|
546
617
|
with self._caller_stamp(e.message_id, e.caller):
|
|
547
618
|
self._place(e.lane, current, e.message, e.params)
|
|
548
619
|
if e.lane == "steerQueue":
|
|
@@ -550,16 +621,21 @@ class RunManager:
|
|
|
550
621
|
return None
|
|
551
622
|
if e.message_id in self._dispatched_ids:
|
|
552
623
|
return self._park_dispatched_edit(e)
|
|
553
|
-
if e.meta is not None:
|
|
624
|
+
if e.meta is not None or e.message_id == self._reload_message_id:
|
|
554
625
|
raise _reject(
|
|
555
626
|
"duplicate-id", f"message id {e.message_id} is already used"
|
|
556
627
|
)
|
|
628
|
+
if e.anchor is _ABSENT:
|
|
629
|
+
raise _reject("invalid-message", "anchorMessageId is required")
|
|
630
|
+
self._check_anchor(e)
|
|
557
631
|
with self._caller_stamp(e.message_id, e.caller):
|
|
558
632
|
self._insert_new(e.lane, e.message, e.params)
|
|
633
|
+
self._entity_futures[e.message_id] = e.future
|
|
634
|
+
e.ack()
|
|
559
635
|
fx.new_added = True
|
|
560
636
|
if e.lane == "steerQueue":
|
|
561
637
|
fx.steer_added = True
|
|
562
|
-
return
|
|
638
|
+
return _PARKED
|
|
563
639
|
|
|
564
640
|
def _apply_move(self, e: _Send, fx: _Effects) -> Any:
|
|
565
641
|
current = self._lane_of(e.message_id)
|
|
@@ -592,6 +668,7 @@ class RunManager:
|
|
|
592
668
|
raise _reject("unknown-id", f"message {e.message_id} is unknown")
|
|
593
669
|
if self._task is not None and not e.meta["isLeaf"]:
|
|
594
670
|
raise _reject("not-leaf", "only the leaf may be edited during a run")
|
|
671
|
+
self._check_anchor(e)
|
|
595
672
|
with self._caller_stamp(e.message_id, e.caller):
|
|
596
673
|
self._staged_rewinds.append(
|
|
597
674
|
_Rewind(
|
|
@@ -608,6 +685,11 @@ class RunManager:
|
|
|
608
685
|
self._state[lane] = [
|
|
609
686
|
item for item in self._lane_items(lane) if item["id"] != e.message_id
|
|
610
687
|
]
|
|
688
|
+
entity = self._entity_futures.pop(e.message_id, None)
|
|
689
|
+
if entity is not None and not entity.done():
|
|
690
|
+
entity.set_exception(
|
|
691
|
+
_reject("removed", f"message {e.message_id} was removed from the queue")
|
|
692
|
+
)
|
|
611
693
|
return None
|
|
612
694
|
|
|
613
695
|
def _apply_input(self, e: _Input) -> Any:
|
|
@@ -653,8 +735,23 @@ class RunManager:
|
|
|
653
735
|
|
|
654
736
|
def _apply_reload(self, e: _Reload) -> Any:
|
|
655
737
|
self._check_leaf_lanes(e.source_meta, "run/reload")
|
|
738
|
+
if (
|
|
739
|
+
self._lane_of(e.message_id) is not None
|
|
740
|
+
or e.message_id in self._dispatched_ids
|
|
741
|
+
or e.message_id == self._reload_message_id
|
|
742
|
+
):
|
|
743
|
+
raise _reject(
|
|
744
|
+
"duplicate-id", f"message id {e.message_id} is already used"
|
|
745
|
+
)
|
|
656
746
|
self._staged_rewinds.append(
|
|
657
|
-
_Rewind(
|
|
747
|
+
_Rewind(
|
|
748
|
+
"message-reload",
|
|
749
|
+
[],
|
|
750
|
+
e.source_meta["parentId"],
|
|
751
|
+
e.ack,
|
|
752
|
+
e.future,
|
|
753
|
+
message_id=e.message_id,
|
|
754
|
+
)
|
|
658
755
|
)
|
|
659
756
|
return _PARKED
|
|
660
757
|
|
|
@@ -684,17 +781,50 @@ class RunManager:
|
|
|
684
781
|
raise _reject(
|
|
685
782
|
"invalid-message", "exactly one of message and messageId is required"
|
|
686
783
|
)
|
|
784
|
+
anchor = params.get("anchorMessageId", _ABSENT)
|
|
785
|
+
anchor_meta: dict[str, Any] | None = None
|
|
786
|
+
thread_empty = False
|
|
787
|
+
if anchor is None:
|
|
788
|
+
root = await self._get_message_meta(None)
|
|
789
|
+
assert root is not None, "get_message_meta(None) must answer for the root"
|
|
790
|
+
thread_empty = bool(root["isLeaf"])
|
|
791
|
+
elif anchor is not _ABSENT:
|
|
792
|
+
if not isinstance(anchor, str):
|
|
793
|
+
raise _reject("invalid-message", "anchorMessageId must be an id or null")
|
|
794
|
+
anchor_meta = await self._get_message_meta(anchor)
|
|
687
795
|
if not has_message:
|
|
688
796
|
message_id = params["messageId"]
|
|
689
797
|
if not isinstance(message_id, str):
|
|
690
798
|
raise _reject("invalid-message", "messageId must be a string")
|
|
691
799
|
return await self._stage(
|
|
692
|
-
_Send(
|
|
800
|
+
_Send(
|
|
801
|
+
lane,
|
|
802
|
+
params,
|
|
803
|
+
None,
|
|
804
|
+
message_id,
|
|
805
|
+
None,
|
|
806
|
+
None,
|
|
807
|
+
ack,
|
|
808
|
+
anchor=anchor,
|
|
809
|
+
anchor_meta=anchor_meta,
|
|
810
|
+
thread_empty=thread_empty,
|
|
811
|
+
)
|
|
693
812
|
)
|
|
694
813
|
message = self._validated_message(params["message"])
|
|
695
814
|
meta = await self._get_message_meta(message["id"])
|
|
696
815
|
return await self._stage(
|
|
697
|
-
_Send(
|
|
816
|
+
_Send(
|
|
817
|
+
lane,
|
|
818
|
+
params,
|
|
819
|
+
message,
|
|
820
|
+
message["id"],
|
|
821
|
+
meta,
|
|
822
|
+
caller,
|
|
823
|
+
ack,
|
|
824
|
+
anchor=anchor,
|
|
825
|
+
anchor_meta=anchor_meta,
|
|
826
|
+
thread_empty=thread_empty,
|
|
827
|
+
)
|
|
698
828
|
)
|
|
699
829
|
|
|
700
830
|
async def enqueue(
|
|
@@ -766,6 +896,11 @@ class RunManager:
|
|
|
766
896
|
source_id = params.get("sourceId") if isinstance(params, dict) else None
|
|
767
897
|
if not isinstance(source_id, str):
|
|
768
898
|
raise _reject("invalid-message", "sourceId must be a string")
|
|
899
|
+
message_id = params.get("messageId")
|
|
900
|
+
if not isinstance(message_id, str) or message_id == "":
|
|
901
|
+
raise _reject("invalid-message", "messageId must be a non-empty string")
|
|
902
|
+
if await self._get_message_meta(message_id) is not None:
|
|
903
|
+
raise _reject("duplicate-id", f"message id {message_id} is already used")
|
|
769
904
|
meta = await self._get_message_meta(source_id)
|
|
770
905
|
if meta is None:
|
|
771
906
|
raise _reject("unknown-id", f"message {source_id} is unknown")
|
|
@@ -783,7 +918,7 @@ class RunManager:
|
|
|
783
918
|
"capability-missing",
|
|
784
919
|
"the assistant-continuation capability is not enabled",
|
|
785
920
|
)
|
|
786
|
-
return await self._stage(_Reload(meta, ack))
|
|
921
|
+
return await self._stage(_Reload(meta, message_id, ack))
|
|
787
922
|
|
|
788
923
|
async def stop(self, params: Any = None, *, ack: Callable[[], None]) -> Any:
|
|
789
924
|
if params is not None and not isinstance(params, dict):
|
|
@@ -917,11 +1052,20 @@ class RunManager:
|
|
|
917
1052
|
_manager: "RunManager"
|
|
918
1053
|
_rollback_to: Any
|
|
919
1054
|
_responses: Any
|
|
1055
|
+
_message_id: str | None
|
|
920
1056
|
|
|
921
1057
|
@property
|
|
922
1058
|
def stop_reason(self) -> str | None:
|
|
923
1059
|
return self._manager._stop_reason
|
|
924
1060
|
|
|
1061
|
+
@property
|
|
1062
|
+
def message_id(self) -> str:
|
|
1063
|
+
if self._message_id is None:
|
|
1064
|
+
raise AttributeError(
|
|
1065
|
+
"message_id is only present on message-reload entries"
|
|
1066
|
+
)
|
|
1067
|
+
return self._message_id
|
|
1068
|
+
|
|
925
1069
|
@property
|
|
926
1070
|
def has_rollback(self) -> bool:
|
|
927
1071
|
return self._rollback_to is not _ABSENT
|
|
@@ -956,6 +1100,7 @@ class RunManager:
|
|
|
956
1100
|
self._manager._state["steerQueue"] = []
|
|
957
1101
|
for item in items:
|
|
958
1102
|
self._manager._callers.pop(item["id"], None)
|
|
1103
|
+
self._manager._adopt_entities(item["id"] for item in items)
|
|
959
1104
|
return tuple(
|
|
960
1105
|
{k: v for k, v in item.items() if k != "caller"} for item in items
|
|
961
1106
|
)
|
|
@@ -35,7 +35,9 @@ class Script:
|
|
|
35
35
|
self.calls.put_nowait(call)
|
|
36
36
|
return await call.outcome
|
|
37
37
|
|
|
38
|
-
async def get_message_meta(self, message_id: str) -> dict[str, Any] | None:
|
|
38
|
+
async def get_message_meta(self, message_id: str | None) -> dict[str, Any] | None:
|
|
39
|
+
if message_id is None:
|
|
40
|
+
return {"isLeaf": not self.thread}
|
|
39
41
|
return self.thread.get(message_id)
|
|
40
42
|
|
|
41
43
|
async def next_call(self, timeout: float = 5) -> Call:
|
|
@@ -202,6 +204,11 @@ def msg(id: str, text: str | None = None) -> dict[str, Any]:
|
|
|
202
204
|
return {"id": id, "role": "user", "parts": [{"type": "text", "text": text or id}]}
|
|
203
205
|
|
|
204
206
|
|
|
207
|
+
def add(id: str, text: str | None = None, *, anchor: str | None = None, **fields: Any) -> dict[str, Any]:
|
|
208
|
+
"""Params for an enqueue/steer add: message + the required branch anchor."""
|
|
209
|
+
return {"message": msg(id, text), "anchorMessageId": anchor, **fields}
|
|
210
|
+
|
|
211
|
+
|
|
205
212
|
def assert_rejected(rsp: dict[str, Any], reason: str) -> None:
|
|
206
213
|
assert rsp["type"] == "rejected", rsp
|
|
207
214
|
assert rsp["payload"] == {"reason": reason}, rsp
|
|
@@ -5,7 +5,7 @@ drain takes exactly one action; late-settling commands ride a later envelope.
|
|
|
5
5
|
import asyncio
|
|
6
6
|
from typing import Any
|
|
7
7
|
|
|
8
|
-
from run_helpers import Script,
|
|
8
|
+
from run_helpers import Script, add, queue_ids, run_host
|
|
9
9
|
|
|
10
10
|
from harness_sdk import RunManager
|
|
11
11
|
|
|
@@ -15,29 +15,32 @@ async def test_multi_steer_batch_places_all_and_dispatches_once():
|
|
|
15
15
|
async with run_host(script) as (drv, host):
|
|
16
16
|
first = await drv.batch(
|
|
17
17
|
[
|
|
18
|
-
("run/steer",
|
|
19
|
-
("run/steer",
|
|
20
|
-
("run/steer",
|
|
18
|
+
("run/steer", add("s1")),
|
|
19
|
+
("run/steer", add("s2")),
|
|
20
|
+
("run/steer", add("s3")),
|
|
21
21
|
]
|
|
22
22
|
)
|
|
23
23
|
for offset in range(3):
|
|
24
|
-
assert (await drv.res(first + offset))["type"] == "
|
|
24
|
+
assert (await drv.res(first + offset, terminal=False))["type"] == "pending"
|
|
25
25
|
call = await script.next_call()
|
|
26
26
|
assert call.ctx.type == "message-send"
|
|
27
27
|
assert [m["id"] for m in call.ctx.messages] == ["s1", "s2", "s3"]
|
|
28
28
|
script.no_call()
|
|
29
29
|
assert queue_ids(drv.replica, "steerQueue") == []
|
|
30
|
+
call.finish(RunManager.Complete())
|
|
31
|
+
for offset in range(3):
|
|
32
|
+
assert (await drv.res(first + offset))["type"] == "accepted"
|
|
30
33
|
|
|
31
34
|
|
|
32
35
|
async def test_steer_and_stop_in_one_batch_nets_to_stop():
|
|
33
36
|
script = Script()
|
|
34
37
|
async with run_host(script) as (drv, host):
|
|
35
|
-
await drv.command("run/enqueue",
|
|
38
|
+
await drv.command("run/enqueue", add("m1"), terminal=False)
|
|
36
39
|
call = await script.next_call()
|
|
37
40
|
first = await drv.batch(
|
|
38
|
-
[("run/steer",
|
|
41
|
+
[("run/steer", add("s1")), ("run/stop", None)]
|
|
39
42
|
)
|
|
40
|
-
assert (await drv.res(first))["type"] == "
|
|
43
|
+
assert (await drv.res(first, terminal=False))["type"] == "pending"
|
|
41
44
|
assert (await drv.res(first + 1, terminal=False))["type"] == "pending"
|
|
42
45
|
await asyncio.wait_for(call.ctx.stop_requested.wait(), 5)
|
|
43
46
|
call.finish(RunManager.Stop(dispatch_queue=False))
|
|
@@ -50,7 +53,7 @@ async def test_steer_and_stop_in_one_batch_nets_to_stop():
|
|
|
50
53
|
async def test_stop_settles_only_after_the_run_task_ends():
|
|
51
54
|
script = Script()
|
|
52
55
|
async with run_host(script) as (drv, host):
|
|
53
|
-
await drv.command("run/enqueue",
|
|
56
|
+
await drv.command("run/enqueue", add("m1"), terminal=False)
|
|
54
57
|
call = await script.next_call()
|
|
55
58
|
pending = await drv.command("run/stop", terminal=False)
|
|
56
59
|
assert pending["type"] == "pending"
|
|
@@ -67,16 +70,16 @@ async def test_stop_settles_only_after_the_run_task_ends():
|
|
|
67
70
|
class TailScript(Script):
|
|
68
71
|
"""get_message_meta suspends, pushing staging past the handler prefix."""
|
|
69
72
|
|
|
70
|
-
async def get_message_meta(self, message_id: str) -> dict[str, Any] | None:
|
|
73
|
+
async def get_message_meta(self, message_id: str | None) -> dict[str, Any] | None:
|
|
71
74
|
await asyncio.sleep(0)
|
|
72
|
-
return
|
|
75
|
+
return await super().get_message_meta(message_id)
|
|
73
76
|
|
|
74
77
|
|
|
75
78
|
async def test_handler_tail_staging_decides_in_its_own_envelope():
|
|
76
79
|
script = TailScript()
|
|
77
80
|
async with run_host(script) as (drv, host):
|
|
78
|
-
res = await drv.command("run/enqueue",
|
|
79
|
-
assert res["type"] == "
|
|
81
|
+
res = await drv.command("run/enqueue", add("m1"), terminal=False)
|
|
82
|
+
assert res["type"] == "pending"
|
|
80
83
|
call = await script.next_call()
|
|
81
84
|
assert [m["id"] for m in call.ctx.messages] == ["m1"]
|
|
82
85
|
dispatch_env = next(
|
|
@@ -88,6 +91,6 @@ async def test_handler_tail_staging_decides_in_its_own_envelope():
|
|
|
88
91
|
)
|
|
89
92
|
)
|
|
90
93
|
# The decide ran outside the batch segment: its ops flush in an
|
|
91
|
-
# envelope of their own,
|
|
92
|
-
assert "res"
|
|
93
|
-
assert dispatch_env
|
|
94
|
+
# envelope of their own, alongside the pending verdict and its ack.
|
|
95
|
+
assert dispatch_env["res"] == [{"seq": res["seq"], "type": "pending"}]
|
|
96
|
+
assert dispatch_env["ack"] >= res["seq"]
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
from run_helpers import Script, add, assert_rejected, msg, queue_ids, run_host
|
|
2
|
+
|
|
3
|
+
from harness_sdk import RunManager
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
async def test_add_requires_a_branch_anchor():
|
|
7
|
+
script = Script()
|
|
8
|
+
async with run_host(script) as (drv, host):
|
|
9
|
+
assert_rejected(
|
|
10
|
+
await drv.command("run/enqueue", {"message": msg("m1")}, terminal=False),
|
|
11
|
+
"invalid-message",
|
|
12
|
+
)
|
|
13
|
+
assert_rejected(
|
|
14
|
+
await drv.command("run/steer", {"message": msg("m1")}, terminal=False),
|
|
15
|
+
"invalid-message",
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def test_null_anchor_valid_only_on_an_empty_thread():
|
|
20
|
+
script = Script()
|
|
21
|
+
async with run_host(script) as (drv, host):
|
|
22
|
+
res = await drv.command("run/enqueue", add("m1"), terminal=False)
|
|
23
|
+
assert res["type"] == "pending"
|
|
24
|
+
await script.next_call()
|
|
25
|
+
script.thread["m1"] = {
|
|
26
|
+
"parentId": None, "role": "user", "isLeaf": True, "onActiveBranch": True
|
|
27
|
+
}
|
|
28
|
+
assert_rejected(
|
|
29
|
+
await drv.command("run/enqueue", add("m2"), terminal=False), "wrong-anchor"
|
|
30
|
+
)
|
|
31
|
+
res = await drv.command("run/enqueue", add("m2", anchor="m1"), terminal=False)
|
|
32
|
+
assert res["type"] == "pending"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def test_active_branch_anchor_accepted_off_branch_and_unknown_rejected():
|
|
36
|
+
script = Script()
|
|
37
|
+
script.thread.update(
|
|
38
|
+
{
|
|
39
|
+
"u1": {"parentId": None, "role": "user", "isLeaf": False, "onActiveBranch": True},
|
|
40
|
+
"a1": {"parentId": "u1", "role": "assistant", "isLeaf": True, "onActiveBranch": True},
|
|
41
|
+
"old": {"parentId": "u1", "role": "assistant", "isLeaf": True, "onActiveBranch": False},
|
|
42
|
+
}
|
|
43
|
+
)
|
|
44
|
+
async with run_host(script) as (drv, host):
|
|
45
|
+
res = await drv.command("run/enqueue", add("m1", anchor="a1"), terminal=False)
|
|
46
|
+
assert res["type"] == "pending"
|
|
47
|
+
await script.next_call()
|
|
48
|
+
assert_rejected(
|
|
49
|
+
await drv.command("run/enqueue", add("m2", anchor="old"), terminal=False),
|
|
50
|
+
"wrong-anchor",
|
|
51
|
+
)
|
|
52
|
+
assert_rejected(
|
|
53
|
+
await drv.command("run/enqueue", add("m2", anchor="zz"), terminal=False),
|
|
54
|
+
"unknown-id",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
async def test_queued_item_and_dispatched_ids_are_valid_anchors():
|
|
59
|
+
script = Script()
|
|
60
|
+
async with run_host(script) as (drv, host):
|
|
61
|
+
await drv.command("run/enqueue", add("m1"), terminal=False)
|
|
62
|
+
await script.next_call()
|
|
63
|
+
await drv.command("run/enqueue", add("m2", anchor="m1"), terminal=False)
|
|
64
|
+
await drv.command("run/steer", add("s1", anchor="m2"), terminal=False)
|
|
65
|
+
assert queue_ids(drv.replica, "queue") == ["m2"]
|
|
66
|
+
assert queue_ids(drv.replica, "steerQueue") == ["s1"]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
async def test_replaced_tail_anchor_rejects_after_an_edit_rewrite():
|
|
70
|
+
script = Script()
|
|
71
|
+
script.thread.update(
|
|
72
|
+
{
|
|
73
|
+
"u1": {"parentId": None, "role": "user", "isLeaf": True, "onActiveBranch": True},
|
|
74
|
+
}
|
|
75
|
+
)
|
|
76
|
+
async with run_host(script, capabilities=("rewind",)) as (drv, host):
|
|
77
|
+
res = await drv.command(
|
|
78
|
+
"run/edit", {"sourceId": "u1", "message": msg("m2")}, terminal=False
|
|
79
|
+
)
|
|
80
|
+
assert res["type"] == "pending"
|
|
81
|
+
await script.next_call()
|
|
82
|
+
script.thread["u1"] = {
|
|
83
|
+
"parentId": None, "role": "user", "isLeaf": True, "onActiveBranch": False
|
|
84
|
+
}
|
|
85
|
+
script.thread["m2"] = {
|
|
86
|
+
"parentId": None, "role": "user", "isLeaf": True, "onActiveBranch": True
|
|
87
|
+
}
|
|
88
|
+
assert_rejected(
|
|
89
|
+
await drv.command("run/enqueue", add("q1", anchor="u1"), terminal=False),
|
|
90
|
+
"wrong-anchor",
|
|
91
|
+
)
|
|
92
|
+
res = await drv.command("run/enqueue", add("q1", anchor="m2"), terminal=False)
|
|
93
|
+
assert res["type"] == "pending"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
async def test_anchor_on_the_queued_edit_form_is_validated_never_ignored():
|
|
97
|
+
script = Script()
|
|
98
|
+
async with run_host(script) as (drv, host):
|
|
99
|
+
await drv.command("run/enqueue", add("m1"), terminal=False)
|
|
100
|
+
await script.next_call()
|
|
101
|
+
await drv.command("run/enqueue", add("m2", anchor="m1"), terminal=False)
|
|
102
|
+
assert_rejected(
|
|
103
|
+
await drv.command(
|
|
104
|
+
"run/enqueue",
|
|
105
|
+
{"message": msg("m2", "edited"), "anchorMessageId": "zz"},
|
|
106
|
+
terminal=False,
|
|
107
|
+
),
|
|
108
|
+
"unknown-id",
|
|
109
|
+
)
|
|
110
|
+
await drv.command(
|
|
111
|
+
"run/enqueue",
|
|
112
|
+
{"message": msg("m2", "edited"), "anchorMessageId": "m1"},
|
|
113
|
+
terminal=False,
|
|
114
|
+
)
|
|
115
|
+
assert drv.replica["queue"][0]["parts"][0]["text"] == "edited"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
async def test_reload_requires_a_message_id_and_delivers_it_on_the_start_context():
|
|
119
|
+
script = Script()
|
|
120
|
+
script.thread.update(
|
|
121
|
+
{
|
|
122
|
+
"u1": {"parentId": None, "role": "user", "isLeaf": False, "onActiveBranch": True},
|
|
123
|
+
"a1": {"parentId": "u1", "role": "assistant", "isLeaf": True, "onActiveBranch": True},
|
|
124
|
+
}
|
|
125
|
+
)
|
|
126
|
+
async with run_host(script, capabilities=("rewind",)) as (drv, host):
|
|
127
|
+
assert_rejected(
|
|
128
|
+
await drv.command("run/reload", {"sourceId": "a1"}, terminal=False),
|
|
129
|
+
"invalid-message",
|
|
130
|
+
)
|
|
131
|
+
assert_rejected(
|
|
132
|
+
await drv.command(
|
|
133
|
+
"run/reload", {"sourceId": "a1", "messageId": "u1"}, terminal=False
|
|
134
|
+
),
|
|
135
|
+
"duplicate-id",
|
|
136
|
+
)
|
|
137
|
+
res = await drv.command(
|
|
138
|
+
"run/reload", {"sourceId": "a1", "messageId": "r9"}, terminal=False
|
|
139
|
+
)
|
|
140
|
+
assert res["type"] == "pending"
|
|
141
|
+
call = await script.next_call()
|
|
142
|
+
assert call.ctx.type == "message-reload"
|
|
143
|
+
assert call.ctx.message_id == "r9"
|
|
144
|
+
call.finish(RunManager.Complete())
|
|
145
|
+
await drv.wait_status("ready")
|