harness-sdk-python 0.12.0__tar.gz → 0.13.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.12.0 → harness_sdk_python-0.13.0}/PKG-INFO +1 -1
- {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.0}/pyproject.toml +1 -1
- {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.0}/src/harness_sdk/run_manager.py +133 -58
- {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.0}/tests/test_ack_visibility.py +1 -1
- {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.0}/tests/test_batches.py +70 -2
- {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.0}/tests/test_branch_anchor.py +1 -1
- {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.0}/tests/test_dispatching.py +3 -3
- {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.0}/tests/test_edit_dispatched.py +4 -4
- {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.0}/tests/test_edit_reload.py +7 -7
- {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.0}/tests/test_enqueue.py +7 -6
- {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.0}/tests/test_input_required.py +31 -15
- {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.0}/tests/test_meta.py +2 -2
- {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.0}/tests/test_outcomes.py +3 -3
- {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.0}/tests/test_prepare_hooks.py +1 -1
- {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.0}/tests/test_restore.py +47 -6
- {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.0}/tests/test_rewind_during_run.py +6 -6
- {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.0}/tests/test_settle.py +1 -1
- {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.0}/tests/test_steer.py +1 -1
- {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.0}/tests/test_stop_continue.py +60 -3
- harness_sdk_python-0.13.0/tests/test_stopping_window.py +263 -0
- {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.0}/.gitignore +0 -0
- {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.0}/README.md +0 -0
- {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.0}/examples/__init__.py +0 -0
- {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.0}/examples/runs_app.py +0 -0
- {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.0}/src/harness_sdk/__init__.py +0 -0
- {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.0}/src/harness_sdk/fenced_postgres.py +0 -0
- {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.0}/src/harness_sdk/linear_thread.py +0 -0
- {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.0}/tests/run_helpers.py +0 -0
- {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.0}/tests/test_fenced_postgres.py +0 -0
- {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.0}/tests/test_linear_thread.py +0 -0
- {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.0}/tests/test_placement.py +0 -0
|
@@ -5,19 +5,28 @@ Implements the full state x command matrix from runs.mdx on a single
|
|
|
5
5
|
|
|
6
6
|
Commands stage entries into an intake list and schedule a drain; the drain
|
|
7
7
|
reduces the whole intake in order, then takes exactly one action based on
|
|
8
|
-
status and net effect (
|
|
9
|
-
appends straight into ``dispatching`` — it never transits a
|
|
10
|
-
everything staged there by the end of the drain dispatches
|
|
8
|
+
status and net effect (rewind > send-dispatch > continue > input-resume). A
|
|
9
|
+
dispatchable send appends straight into ``dispatching`` — it never transits a
|
|
10
|
+
queue lane — and everything staged there by the end of the drain dispatches
|
|
11
|
+
as one run; in error/stop the dispatch is the continuation
|
|
12
|
+
(``error-continue`` / ``stop-continue``) carrying the send's messages. Every
|
|
11
13
|
initiator acks and settles accepted with its state-visible application —
|
|
12
14
|
sends with their insert (lane or ``dispatching``), edit/reload/continue at
|
|
13
15
|
their dispatch; ``run/stop`` awaits a future the drain resolves once the
|
|
14
16
|
in-flight run has ended.
|
|
15
17
|
|
|
16
|
-
``entry["dispatching"]`` is the dispatch record (
|
|
17
|
-
|
|
18
|
-
refill it — at most one
|
|
19
|
-
|
|
20
|
-
stay queued for the next run.
|
|
18
|
+
``entry["dispatching"]`` is the dispatch record (absent between an ack and
|
|
19
|
+
the next dispatch or take): the payload the run callback receives.
|
|
20
|
+
``ctx.ack()`` clears it; ``steering.take()`` may refill it — at most one
|
|
21
|
+
unacked batch ever exists. Settle consumes the delivered batch, acked or not;
|
|
22
|
+
steer entries still undelivered in the lane stay queued for the next run.
|
|
23
|
+
|
|
24
|
+
A mid-run stop or rewind opens a stopping window on the entry:
|
|
25
|
+
``stopping: {reason}`` plus, for rewinds, the staged dispatch as
|
|
26
|
+
``nextDispatch`` — written in the same envelope as the opener's pending
|
|
27
|
+
answer. On run end one envelope promotes ``nextDispatch`` into
|
|
28
|
+
``dispatching`` and clears the window; a later stop/rewind supersedes the
|
|
29
|
+
staged one, which settles rejected (``superseded``).
|
|
21
30
|
"""
|
|
22
31
|
|
|
23
32
|
import asyncio
|
|
@@ -44,9 +53,9 @@ _CAPABILITIES = frozenset(
|
|
|
44
53
|
)
|
|
45
54
|
|
|
46
55
|
_TRIGGERS = (
|
|
47
|
-
"message
|
|
48
|
-
"message
|
|
49
|
-
"message
|
|
56
|
+
"send-message",
|
|
57
|
+
"edit-message",
|
|
58
|
+
"reload-message",
|
|
50
59
|
"input-resume",
|
|
51
60
|
"error-continue",
|
|
52
61
|
"stop-continue",
|
|
@@ -140,6 +149,7 @@ class _Rewind:
|
|
|
140
149
|
future: "asyncio.Future[Any]"
|
|
141
150
|
run_id: str
|
|
142
151
|
root_meta: Any = None
|
|
152
|
+
acked: bool = False
|
|
143
153
|
|
|
144
154
|
|
|
145
155
|
@dataclass
|
|
@@ -201,7 +211,7 @@ class RunManager:
|
|
|
201
211
|
self._outcome: Any = None
|
|
202
212
|
self._staged_stops: list["asyncio.Future[Any]"] = []
|
|
203
213
|
self._stop_reason: str | None = None
|
|
204
|
-
self.
|
|
214
|
+
self._staged_rewind: _Rewind | None = None
|
|
205
215
|
self._dispatching: list[tuple[str, dict[str, Any]]] = []
|
|
206
216
|
self._input_requests: list[dict[str, Any]] = []
|
|
207
217
|
self._input_answers: dict[str, Any] = {}
|
|
@@ -248,26 +258,33 @@ class RunManager:
|
|
|
248
258
|
for lane in ("queue", "steerQueue"):
|
|
249
259
|
if not isinstance(entry.get(lane), list):
|
|
250
260
|
raise ValueError(f"the restored {lane} must be a list")
|
|
251
|
-
for key in ("dispatching", "error"):
|
|
252
|
-
if
|
|
253
|
-
raise ValueError(f"the restored entry must carry {key}
|
|
261
|
+
for key in ("dispatching", "error", "stopping", "nextDispatch"):
|
|
262
|
+
if key in entry:
|
|
263
|
+
raise ValueError(f"the restored entry must not carry {key}")
|
|
254
264
|
requests = entry.get("inputRequests")
|
|
255
265
|
if not isinstance(requests, list) or not requests:
|
|
256
266
|
raise ValueError("the restored inputRequests must be a non-empty list")
|
|
267
|
+
answers: dict[str, Any] = {}
|
|
257
268
|
for request in requests:
|
|
258
269
|
if not isinstance(request, dict):
|
|
259
270
|
raise ValueError("restored input requests must be objects")
|
|
260
|
-
if
|
|
261
|
-
|
|
262
|
-
"restored
|
|
271
|
+
if "response" in request:
|
|
272
|
+
if not isinstance(request["response"], dict):
|
|
273
|
+
raise ValueError("a restored response must be an object")
|
|
274
|
+
answers[request.get("id")] = (
|
|
275
|
+
request["response"],
|
|
276
|
+
request.get("meta"),
|
|
263
277
|
)
|
|
264
278
|
outcome = RunManager.InputRequired(
|
|
265
279
|
tuple(
|
|
266
|
-
{k: v for k, v in request.items() if k
|
|
280
|
+
{k: v for k, v in request.items() if k not in ("response", "meta")}
|
|
267
281
|
for request in requests
|
|
268
282
|
)
|
|
269
283
|
)
|
|
284
|
+
if len(answers) == len(requests):
|
|
285
|
+
raise ValueError("a fully answered pending set cannot restore")
|
|
270
286
|
self._input_requests = [dict(r) for r in outcome.requests]
|
|
287
|
+
self._input_answers = answers
|
|
271
288
|
self._state["status"] = "input-required"
|
|
272
289
|
|
|
273
290
|
# ─── State access ───────────────────────────────────────
|
|
@@ -287,8 +304,6 @@ class RunManager:
|
|
|
287
304
|
"status": "ready",
|
|
288
305
|
"queue": [],
|
|
289
306
|
"steerQueue": [],
|
|
290
|
-
"dispatching": None,
|
|
291
|
-
"error": None,
|
|
292
307
|
}
|
|
293
308
|
)
|
|
294
309
|
return self._state["runs"][0]
|
|
@@ -345,12 +360,14 @@ class RunManager:
|
|
|
345
360
|
self._staged_stops.clear()
|
|
346
361
|
if isinstance(outcome, RunManager.Complete):
|
|
347
362
|
self._dispatched_ids = ()
|
|
348
|
-
if self.
|
|
349
|
-
rewind = self.
|
|
363
|
+
if self._staged_rewind is not None:
|
|
364
|
+
rewind, self._staged_rewind = self._staged_rewind, None
|
|
350
365
|
self._clear_input()
|
|
351
366
|
if self._dispatching:
|
|
352
367
|
self._unstage_dispatching()
|
|
353
368
|
entry = self._ensure_entry()
|
|
369
|
+
entry.pop("stopping", None)
|
|
370
|
+
entry.pop("nextDispatch", None)
|
|
354
371
|
if entry["runId"] is None:
|
|
355
372
|
entry["runId"] = rewind.run_id
|
|
356
373
|
self._dispatch(
|
|
@@ -359,14 +376,17 @@ class RunManager:
|
|
|
359
376
|
rollback_to=rewind.rollback_to,
|
|
360
377
|
root_meta=rewind.root_meta,
|
|
361
378
|
)
|
|
362
|
-
rewind.
|
|
379
|
+
if not rewind.acked:
|
|
380
|
+
rewind.ack()
|
|
363
381
|
if not rewind.future.done():
|
|
364
382
|
rewind.future.set_result(None)
|
|
365
383
|
elif outcome is not None:
|
|
366
384
|
self._settle_outcome(outcome)
|
|
367
385
|
else:
|
|
368
386
|
self._idle_action(fx)
|
|
369
|
-
if self._task is not None and (
|
|
387
|
+
if self._task is not None and (
|
|
388
|
+
self._staged_stops or self._staged_rewind is not None
|
|
389
|
+
):
|
|
370
390
|
assert self._ctx is not None
|
|
371
391
|
self._ctx.stop_requested.set()
|
|
372
392
|
for future, ack, result in fx.staged_sends:
|
|
@@ -382,6 +402,11 @@ class RunManager:
|
|
|
382
402
|
def _apply(self, entry: Any, fx: _Effects) -> None:
|
|
383
403
|
if isinstance(entry, _Stop):
|
|
384
404
|
self._staged_stops.append(entry.future)
|
|
405
|
+
if self._task is not None:
|
|
406
|
+
if self._staged_rewind is not None:
|
|
407
|
+
self._supersede()
|
|
408
|
+
self._entry().pop("nextDispatch", None)
|
|
409
|
+
self._entry()["stopping"] = {"reason": "stop"}
|
|
385
410
|
return
|
|
386
411
|
if isinstance(entry, _Continue):
|
|
387
412
|
if not fx.continue_requested:
|
|
@@ -413,10 +438,10 @@ class RunManager:
|
|
|
413
438
|
if self._dispatching:
|
|
414
439
|
self._dispatch_staged()
|
|
415
440
|
elif status in ("error", "stopped"):
|
|
416
|
-
if
|
|
417
|
-
self._dispatch(self._continue_type(), [], root_meta=fx.continue_meta)
|
|
418
|
-
elif self._dispatching:
|
|
441
|
+
if self._dispatching:
|
|
419
442
|
self._dispatch_staged()
|
|
443
|
+
elif fx.continue_requested or fx.steer_added:
|
|
444
|
+
self._dispatch(self._continue_type(), [], root_meta=fx.continue_meta)
|
|
420
445
|
elif status == "input-required":
|
|
421
446
|
if self._dispatching:
|
|
422
447
|
self._dispatch_staged(input_outcomes=self._take_input_outcomes())
|
|
@@ -438,9 +463,7 @@ class RunManager:
|
|
|
438
463
|
self._input_requests = [dict(r) for r in outcome.requests]
|
|
439
464
|
self._input_answers = {}
|
|
440
465
|
entry = self._entry()
|
|
441
|
-
entry["inputRequests"] = [
|
|
442
|
-
{**r, "response": None} for r in outcome.requests
|
|
443
|
-
]
|
|
466
|
+
entry["inputRequests"] = [dict(r) for r in outcome.requests]
|
|
444
467
|
self._set_status("input-required")
|
|
445
468
|
return
|
|
446
469
|
status = "error" if isinstance(outcome, RunManager.Error) else "stopped"
|
|
@@ -465,6 +488,27 @@ class RunManager:
|
|
|
465
488
|
self._clear_input()
|
|
466
489
|
return outcomes
|
|
467
490
|
|
|
491
|
+
def _record_of(
|
|
492
|
+
self,
|
|
493
|
+
trigger: str,
|
|
494
|
+
items: list[dict[str, Any]],
|
|
495
|
+
*,
|
|
496
|
+
rollback_to: Any = _ABSENT,
|
|
497
|
+
root_meta: Any = None,
|
|
498
|
+
input_outcomes: tuple[tuple[dict[str, Any], Any, Any], ...] = (),
|
|
499
|
+
) -> dict[str, Any]:
|
|
500
|
+
record: dict[str, Any] = {"trigger": trigger, "messages": items}
|
|
501
|
+
if rollback_to is not _ABSENT:
|
|
502
|
+
record["rollbackTo"] = rollback_to
|
|
503
|
+
if root_meta is not None:
|
|
504
|
+
record["rootMeta"] = root_meta
|
|
505
|
+
if input_outcomes:
|
|
506
|
+
record["inputOutcomes"] = [
|
|
507
|
+
{"request": request, "response": response, "meta": meta}
|
|
508
|
+
for request, response, meta in input_outcomes
|
|
509
|
+
]
|
|
510
|
+
return record
|
|
511
|
+
|
|
468
512
|
def _dispatch(
|
|
469
513
|
self,
|
|
470
514
|
trigger: str,
|
|
@@ -483,19 +527,18 @@ class RunManager:
|
|
|
483
527
|
messages = [
|
|
484
528
|
{k: v for k, v in message.items() if k != "meta"} for message in messages
|
|
485
529
|
]
|
|
486
|
-
record
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
]
|
|
530
|
+
record = self._record_of(
|
|
531
|
+
trigger,
|
|
532
|
+
items,
|
|
533
|
+
rollback_to=rollback_to,
|
|
534
|
+
root_meta=root_meta,
|
|
535
|
+
input_outcomes=input_outcomes,
|
|
536
|
+
)
|
|
494
537
|
self._dispatch_record = dict(record)
|
|
495
538
|
self._stop_reason = None
|
|
496
539
|
entry = self._ensure_entry()
|
|
497
540
|
entry["dispatching"] = record
|
|
498
|
-
entry
|
|
541
|
+
entry.pop("error", None)
|
|
499
542
|
if messages:
|
|
500
543
|
self._dispatched_ids = tuple(m["id"] for m in messages)
|
|
501
544
|
self._set_status("running")
|
|
@@ -555,30 +598,31 @@ class RunManager:
|
|
|
555
598
|
self._dispatch_record = None
|
|
556
599
|
self._dispatching = []
|
|
557
600
|
entry = self._ensure_entry()
|
|
558
|
-
record = plain(entry
|
|
601
|
+
record = plain(entry.get("dispatching"))
|
|
559
602
|
if record is not None and record["trigger"] != "steer" and record["messages"]:
|
|
560
603
|
self._dispatched_ids = ()
|
|
561
|
-
entry
|
|
604
|
+
entry.pop("dispatching", None)
|
|
605
|
+
entry.pop("stopping", None)
|
|
562
606
|
|
|
563
607
|
def _ack(self) -> None:
|
|
564
608
|
entry = self._entry()
|
|
565
|
-
record = plain(entry
|
|
609
|
+
record = plain(entry.get("dispatching"))
|
|
566
610
|
if record is None:
|
|
567
611
|
raise RuntimeError("ack() with no unacked batch")
|
|
568
612
|
self._dispatching = []
|
|
569
|
-
entry
|
|
613
|
+
entry.pop("dispatching", None)
|
|
570
614
|
|
|
571
615
|
def _unstage_dispatching(self) -> None:
|
|
572
616
|
taken, self._dispatching = self._dispatching, []
|
|
573
617
|
entry = self._ensure_entry()
|
|
574
|
-
entry
|
|
618
|
+
entry.pop("dispatching", None)
|
|
575
619
|
for lane in ("steerQueue", "queue"):
|
|
576
620
|
front = [item for taken_lane, item in taken if taken_lane == lane]
|
|
577
621
|
if front:
|
|
578
622
|
entry[lane] = front + self._lane_items(lane)
|
|
579
623
|
|
|
580
624
|
def _dispatchable(self, lane: str) -> bool:
|
|
581
|
-
if self._task is not None or self.
|
|
625
|
+
if self._task is not None or self._staged_rewind is not None:
|
|
582
626
|
return False
|
|
583
627
|
if self._dispatching:
|
|
584
628
|
return True
|
|
@@ -595,6 +639,10 @@ class RunManager:
|
|
|
595
639
|
)
|
|
596
640
|
return False
|
|
597
641
|
|
|
642
|
+
def _send_trigger(self) -> str:
|
|
643
|
+
status = self._status()
|
|
644
|
+
return self._continue_type() if status in ("error", "stopped") else "send-message"
|
|
645
|
+
|
|
598
646
|
def _stage_dispatch(self, e: _Send) -> None:
|
|
599
647
|
assert e.message is not None
|
|
600
648
|
steer = self._lane_items("steerQueue") if e.lane == "steerQueue" else []
|
|
@@ -605,7 +653,7 @@ class RunManager:
|
|
|
605
653
|
self._dispatching.extend(("steerQueue", item) for item in steer)
|
|
606
654
|
self._dispatching.append((e.lane, self._stamped(e.message, e.meta)))
|
|
607
655
|
self._ensure_entry()["dispatching"] = {
|
|
608
|
-
"trigger":
|
|
656
|
+
"trigger": self._send_trigger(),
|
|
609
657
|
"messages": [item for _, item in self._dispatching],
|
|
610
658
|
}
|
|
611
659
|
|
|
@@ -613,7 +661,7 @@ class RunManager:
|
|
|
613
661
|
self, *, input_outcomes: tuple[tuple[dict[str, Any], Any, Any], ...] = ()
|
|
614
662
|
) -> None:
|
|
615
663
|
self._dispatch(
|
|
616
|
-
|
|
664
|
+
self._send_trigger(),
|
|
617
665
|
[item for _, item in self._dispatching],
|
|
618
666
|
input_outcomes=input_outcomes,
|
|
619
667
|
)
|
|
@@ -625,16 +673,40 @@ class RunManager:
|
|
|
625
673
|
if steer:
|
|
626
674
|
self._entry()["steerQueue"] = []
|
|
627
675
|
self._dispatching = [("steerQueue", item) for item in steer]
|
|
628
|
-
self._dispatch("message
|
|
676
|
+
self._dispatch("send-message", steer, input_outcomes=input_outcomes)
|
|
629
677
|
return True
|
|
630
678
|
queue = self._lane_items("queue")
|
|
631
679
|
if queue:
|
|
632
680
|
self._entry()["queue"].pop(0)
|
|
633
681
|
self._dispatching = [("queue", queue[0])]
|
|
634
|
-
self._dispatch("message
|
|
682
|
+
self._dispatch("send-message", [queue[0]], input_outcomes=input_outcomes)
|
|
635
683
|
return True
|
|
636
684
|
return False
|
|
637
685
|
|
|
686
|
+
def _supersede(self) -> None:
|
|
687
|
+
rewind, self._staged_rewind = self._staged_rewind, None
|
|
688
|
+
assert rewind is not None
|
|
689
|
+
if not rewind.future.done():
|
|
690
|
+
rewind.future.set_exception(
|
|
691
|
+
_reject("superseded", "a later stop/rewind replaced this command")
|
|
692
|
+
)
|
|
693
|
+
|
|
694
|
+
def _stage_rewind(self, rewind: _Rewind) -> None:
|
|
695
|
+
if self._staged_rewind is not None:
|
|
696
|
+
self._supersede()
|
|
697
|
+
self._staged_rewind = rewind
|
|
698
|
+
if self._task is not None:
|
|
699
|
+
entry = self._entry()
|
|
700
|
+
entry["stopping"] = {"reason": rewind.type}
|
|
701
|
+
entry["nextDispatch"] = self._record_of(
|
|
702
|
+
rewind.type,
|
|
703
|
+
list(rewind.messages),
|
|
704
|
+
rollback_to=rewind.rollback_to,
|
|
705
|
+
root_meta=rewind.root_meta,
|
|
706
|
+
)
|
|
707
|
+
rewind.ack()
|
|
708
|
+
rewind.acked = True
|
|
709
|
+
|
|
638
710
|
def _freeze(self, message: str, payload: Any = None) -> None:
|
|
639
711
|
self._set_status("error")
|
|
640
712
|
entry = self._entry()
|
|
@@ -915,9 +987,9 @@ class RunManager:
|
|
|
915
987
|
if e.source_meta is None:
|
|
916
988
|
raise _reject("unknown-id", f"message {e.message_id} is unknown")
|
|
917
989
|
self._check_anchor(e)
|
|
918
|
-
self.
|
|
990
|
+
self._stage_rewind(
|
|
919
991
|
_Rewind(
|
|
920
|
-
"message
|
|
992
|
+
"edit-message",
|
|
921
993
|
[self._stamped(e.message, e.meta)],
|
|
922
994
|
e.source_meta["parentId"],
|
|
923
995
|
e.ack,
|
|
@@ -956,7 +1028,10 @@ class RunManager:
|
|
|
956
1028
|
f"input request {e.request_id} is already answered",
|
|
957
1029
|
)
|
|
958
1030
|
self._input_answers[e.request_id] = (e.response, e.meta)
|
|
959
|
-
self._entry()["inputRequests"][index]
|
|
1031
|
+
replicated = self._entry()["inputRequests"][index]
|
|
1032
|
+
replicated["response"] = e.response
|
|
1033
|
+
if e.meta is not None:
|
|
1034
|
+
replicated["meta"] = e.meta
|
|
960
1035
|
return None
|
|
961
1036
|
|
|
962
1037
|
def _apply_edit(self, e: _Edit) -> Any:
|
|
@@ -967,9 +1042,9 @@ class RunManager:
|
|
|
967
1042
|
raise _reject(
|
|
968
1043
|
"duplicate-id", f"message id {e.message['id']} is already used"
|
|
969
1044
|
)
|
|
970
|
-
self.
|
|
1045
|
+
self._stage_rewind(
|
|
971
1046
|
_Rewind(
|
|
972
|
-
"message
|
|
1047
|
+
"edit-message",
|
|
973
1048
|
[self._stamped(e.message, e.meta)],
|
|
974
1049
|
e.source_meta["parentId"],
|
|
975
1050
|
e.ack,
|
|
@@ -980,9 +1055,9 @@ class RunManager:
|
|
|
980
1055
|
return _PARKED
|
|
981
1056
|
|
|
982
1057
|
def _apply_reload(self, e: _Reload) -> Any:
|
|
983
|
-
self.
|
|
1058
|
+
self._stage_rewind(
|
|
984
1059
|
_Rewind(
|
|
985
|
-
"message
|
|
1060
|
+
"reload-message",
|
|
986
1061
|
[],
|
|
987
1062
|
e.source_meta["parentId"],
|
|
988
1063
|
e.ack,
|
|
@@ -1280,7 +1355,7 @@ class RunManager:
|
|
|
1280
1355
|
self._ctx._ensure_active()
|
|
1281
1356
|
manager = self._ctx._manager
|
|
1282
1357
|
entry = manager._entry()
|
|
1283
|
-
if plain(entry
|
|
1358
|
+
if plain(entry.get("dispatching")) is not None:
|
|
1284
1359
|
raise RuntimeError("take before the current batch is acked")
|
|
1285
1360
|
items = manager._lane_items("steerQueue")
|
|
1286
1361
|
if not items:
|
|
@@ -91,7 +91,7 @@ async def test_rewind_edit_acks_with_its_dispatch():
|
|
|
91
91
|
async with run_host(script, capabilities=("rewind",)) as (drv, host):
|
|
92
92
|
seq = drv.post("run/edit", edit_params("u1", msg("u2"), anchor=None))
|
|
93
93
|
rerun = await script.next_call()
|
|
94
|
-
assert rerun.ctx.trigger == "message
|
|
94
|
+
assert rerun.ctx.trigger == "edit-message"
|
|
95
95
|
await drv.wait_status("running")
|
|
96
96
|
assert dispatching_ids(covered_state(drv, seq)) == ["u2"]
|
|
97
97
|
host.live.state["messages"] = [msg("u2")]
|
|
@@ -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, add, queue_ids, run_host
|
|
8
|
+
from run_helpers import Script, add, edit_params, msg, queue_ids, run_host
|
|
9
9
|
|
|
10
10
|
from harness_sdk import RunManager
|
|
11
11
|
|
|
@@ -21,7 +21,7 @@ async def test_multi_steer_batch_places_all_and_dispatches_once():
|
|
|
21
21
|
]
|
|
22
22
|
)
|
|
23
23
|
call = await script.next_call()
|
|
24
|
-
assert call.ctx.trigger == "message
|
|
24
|
+
assert call.ctx.trigger == "send-message"
|
|
25
25
|
assert [m["id"] for m in call.ctx.messages] == ["s1", "s2", "s3"]
|
|
26
26
|
script.no_call()
|
|
27
27
|
assert queue_ids(drv.replica, "steerQueue") == []
|
|
@@ -114,3 +114,71 @@ async def test_handler_tail_staging_decides_in_its_own_envelope():
|
|
|
114
114
|
assert dispatch_env.get("ack", 0) >= seq
|
|
115
115
|
assert (await drv.res(seq))["type"] == "accepted"
|
|
116
116
|
call.ack()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
async def test_rewind_outranks_send_and_continue_in_one_batch():
|
|
120
|
+
script = Script()
|
|
121
|
+
async with run_host(
|
|
122
|
+
script, capabilities=("rewind", "incomplete-continuation")
|
|
123
|
+
) as (drv, host):
|
|
124
|
+
drv.post("run/enqueue", add("m1"))
|
|
125
|
+
call = await script.next_call()
|
|
126
|
+
script.thread["m1"] = {
|
|
127
|
+
"parentId": None,
|
|
128
|
+
"role": "user",
|
|
129
|
+
"isLeaf": True,
|
|
130
|
+
"onActiveBranch": True,
|
|
131
|
+
}
|
|
132
|
+
script.thread["a1"] = {
|
|
133
|
+
"parentId": "m1",
|
|
134
|
+
"role": "assistant",
|
|
135
|
+
"isLeaf": True,
|
|
136
|
+
"onActiveBranch": True,
|
|
137
|
+
}
|
|
138
|
+
call.ack()
|
|
139
|
+
call.fail(RuntimeError("boom"))
|
|
140
|
+
await drv.wait_status("error")
|
|
141
|
+
first = drv.batch(
|
|
142
|
+
[
|
|
143
|
+
("run/enqueue", add("q1", anchor="a1")),
|
|
144
|
+
("run/edit", edit_params("m1", msg("m2"), anchor=None)),
|
|
145
|
+
("run/continue", None),
|
|
146
|
+
]
|
|
147
|
+
)
|
|
148
|
+
rerun = await script.next_call()
|
|
149
|
+
assert rerun.ctx.trigger == "edit-message"
|
|
150
|
+
assert [m["id"] for m in rerun.ctx.messages] == ["m2"]
|
|
151
|
+
script.no_call()
|
|
152
|
+
assert (await drv.res(first))["type"] == "accepted"
|
|
153
|
+
assert (await drv.res(first + 1))["type"] == "accepted"
|
|
154
|
+
assert (await drv.res(first + 2))["type"] == "accepted"
|
|
155
|
+
assert queue_ids(drv.replica, "queue") == ["q1"]
|
|
156
|
+
rerun.ack()
|
|
157
|
+
rerun.finish(RunManager.Complete())
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
async def test_send_outranks_the_completing_input_answer():
|
|
161
|
+
script = Script()
|
|
162
|
+
async with run_host(script) as (drv, host):
|
|
163
|
+
drv.post("run/enqueue", add("m1"))
|
|
164
|
+
call = await script.next_call()
|
|
165
|
+
call.ack()
|
|
166
|
+
request = {"type": "tool-call", "id": "r1", "toolCallId": "tc1"}
|
|
167
|
+
call.finish(RunManager.InputRequired((request,)))
|
|
168
|
+
await drv.wait_status("input-required")
|
|
169
|
+
first = drv.batch(
|
|
170
|
+
[
|
|
171
|
+
("run/input", {"requestId": "r1", "response": {"output": "ok"}}),
|
|
172
|
+
("run/steer", add("s1")),
|
|
173
|
+
]
|
|
174
|
+
)
|
|
175
|
+
run = await script.next_call()
|
|
176
|
+
assert run.ctx.trigger == "send-message"
|
|
177
|
+
assert [m["id"] for m in run.ctx.messages] == ["s1"]
|
|
178
|
+
assert run.ctx.input_outcomes == ((request, {"output": "ok"}, None),)
|
|
179
|
+
script.no_call()
|
|
180
|
+
assert (await drv.res(first))["type"] == "accepted"
|
|
181
|
+
assert (await drv.res(first + 1))["type"] == "accepted"
|
|
182
|
+
run.ack()
|
|
183
|
+
run.finish(RunManager.Complete())
|
|
184
|
+
await drv.wait_status("ready")
|
|
@@ -196,7 +196,7 @@ async def test_post_reload_sends_anchor_on_the_reload_targets_parent():
|
|
|
196
196
|
async with run_host(script, capabilities=("rewind",)) as (drv, host):
|
|
197
197
|
drv.post("run/reload", reload_params("a1", anchor="u1"))
|
|
198
198
|
call = await script.next_call()
|
|
199
|
-
assert call.ctx.trigger == "message
|
|
199
|
+
assert call.ctx.trigger == "reload-message"
|
|
200
200
|
call.ack()
|
|
201
201
|
script.thread["a1"] = {
|
|
202
202
|
"parentId": "u1", "role": "assistant", "isLeaf": True, "onActiveBranch": False
|
|
@@ -17,7 +17,7 @@ from statewire_helpers import apply_ops
|
|
|
17
17
|
|
|
18
18
|
def dispatching_ids(replica):
|
|
19
19
|
runs = replica["runs"]
|
|
20
|
-
record = runs[0]
|
|
20
|
+
record = runs[0].get("dispatching") if runs else None
|
|
21
21
|
return [item["id"] for item in record["messages"]] if record else []
|
|
22
22
|
|
|
23
23
|
|
|
@@ -58,7 +58,7 @@ async def test_idle_send_dispatches_straight_into_dispatching():
|
|
|
58
58
|
drv.post("run/enqueue", add("m1"))
|
|
59
59
|
await script.next_call()
|
|
60
60
|
await drv.wait_status("running")
|
|
61
|
-
assert drv.run["dispatching"]["trigger"] == "message
|
|
61
|
+
assert drv.run["dispatching"]["trigger"] == "send-message"
|
|
62
62
|
assert dispatching_ids(drv.replica) == ["m1"]
|
|
63
63
|
assert queue_ids(drv.replica, "queue") == []
|
|
64
64
|
assert residence(drv, "m1") == ["dispatching"]
|
|
@@ -119,7 +119,7 @@ async def test_edit_replacement_rides_dispatching_until_ack():
|
|
|
119
119
|
async with run_host(script, capabilities=("rewind",)) as (drv, host):
|
|
120
120
|
seq = drv.post("run/edit", edit_params("u1", msg("u2"), anchor=None))
|
|
121
121
|
rerun = await script.next_call()
|
|
122
|
-
assert rerun.ctx.trigger == "message
|
|
122
|
+
assert rerun.ctx.trigger == "edit-message"
|
|
123
123
|
await drv.wait_status("running")
|
|
124
124
|
assert dispatching_ids(drv.replica) == ["u2"]
|
|
125
125
|
host.live.state["messages"] = [msg("u2")]
|
|
@@ -19,7 +19,7 @@ async def test_edit_of_dispatched_item_stops_rewinds_reruns(command):
|
|
|
19
19
|
await drv.wait_status("running")
|
|
20
20
|
call.finish(RunManager.Stop(dispatch_queue=False))
|
|
21
21
|
rerun = await script.next_call()
|
|
22
|
-
assert rerun.ctx.trigger == "message
|
|
22
|
+
assert rerun.ctx.trigger == "edit-message"
|
|
23
23
|
assert [m["parts"][0]["text"] for m in rerun.ctx.messages] == ["edited"]
|
|
24
24
|
assert rerun.ctx.has_rollback
|
|
25
25
|
assert rerun.ctx.rollback_to == "p0"
|
|
@@ -54,7 +54,7 @@ async def test_edit_of_dispatched_non_leaf_stops_and_reruns(command):
|
|
|
54
54
|
seq = drv.post(command, target(message=msg("m1", "edited")))
|
|
55
55
|
call.finish(RunManager.Stop(dispatch_queue=False))
|
|
56
56
|
rerun = await script.next_call()
|
|
57
|
-
assert rerun.ctx.trigger == "message
|
|
57
|
+
assert rerun.ctx.trigger == "edit-message"
|
|
58
58
|
assert [m["parts"][0]["text"] for m in rerun.ctx.messages] == ["edited"]
|
|
59
59
|
rerun.ack()
|
|
60
60
|
rerun.finish(RunManager.Stop(dispatch_queue=False))
|
|
@@ -72,7 +72,7 @@ async def test_edit_of_dispatched_item_with_queued_items_stops_and_reruns():
|
|
|
72
72
|
seq = drv.post("run/enqueue", target(message=msg("m1", "edited")))
|
|
73
73
|
call.finish(RunManager.Stop(dispatch_queue=False))
|
|
74
74
|
rerun = await script.next_call()
|
|
75
|
-
assert rerun.ctx.trigger == "message
|
|
75
|
+
assert rerun.ctx.trigger == "edit-message"
|
|
76
76
|
rerun.ack()
|
|
77
77
|
rerun.finish(RunManager.Stop(dispatch_queue=False))
|
|
78
78
|
assert (await drv.res(seq))["type"] == "accepted"
|
|
@@ -90,5 +90,5 @@ async def test_edit_of_dispatched_item_in_error_reruns_without_stop():
|
|
|
90
90
|
await drv.wait_status("error")
|
|
91
91
|
drv.post("run/enqueue", target(message=msg("m1", "edited")))
|
|
92
92
|
rerun = await script.next_call()
|
|
93
|
-
assert rerun.ctx.trigger == "message
|
|
93
|
+
assert rerun.ctx.trigger == "edit-message"
|
|
94
94
|
assert rerun.ctx.rollback_to is None
|
|
@@ -27,7 +27,7 @@ async def test_edit_in_ready_dispatches_message_edit():
|
|
|
27
27
|
async with run_host(script, capabilities=("rewind",)) as (drv, host):
|
|
28
28
|
drv.post("run/edit", edit_params("u1", msg("u2", "v2"), anchor=None))
|
|
29
29
|
call = await script.next_call()
|
|
30
|
-
assert call.ctx.trigger == "message
|
|
30
|
+
assert call.ctx.trigger == "edit-message"
|
|
31
31
|
assert [m["id"] for m in call.ctx.messages] == ["u2"]
|
|
32
32
|
assert call.ctx.has_rollback
|
|
33
33
|
assert call.ctx.rollback_to is None
|
|
@@ -71,7 +71,7 @@ async def test_deep_edit_with_queue_non_empty_reruns_and_keeps_queue():
|
|
|
71
71
|
await drv.wait_status("error")
|
|
72
72
|
drv.post("run/edit", edit_params("u1", msg("u2"), anchor=None))
|
|
73
73
|
rerun = await script.next_call()
|
|
74
|
-
assert rerun.ctx.trigger == "message
|
|
74
|
+
assert rerun.ctx.trigger == "edit-message"
|
|
75
75
|
assert rerun.ctx.rollback_to is None
|
|
76
76
|
assert [item["id"] for item in drv.run["queue"]] == ["m2"]
|
|
77
77
|
|
|
@@ -88,7 +88,7 @@ async def test_leaf_edit_in_error_accepted_with_queue_non_empty():
|
|
|
88
88
|
await drv.wait_status("error")
|
|
89
89
|
drv.post("run/edit", edit_params("m1", msg("u2"), anchor=None))
|
|
90
90
|
call = await script.next_call()
|
|
91
|
-
assert call.ctx.trigger == "message
|
|
91
|
+
assert call.ctx.trigger == "edit-message"
|
|
92
92
|
assert call.ctx.rollback_to is None
|
|
93
93
|
assert [item["id"] for item in drv.run["queue"]] == ["m2"]
|
|
94
94
|
|
|
@@ -140,7 +140,7 @@ async def test_reload_dispatches_with_empty_messages():
|
|
|
140
140
|
async with run_host(script, capabilities=("rewind",)) as (drv, host):
|
|
141
141
|
drv.post("run/reload", reload_params("a1", anchor="u1"))
|
|
142
142
|
call = await script.next_call()
|
|
143
|
-
assert call.ctx.trigger == "message
|
|
143
|
+
assert call.ctx.trigger == "reload-message"
|
|
144
144
|
assert call.ctx.messages == ()
|
|
145
145
|
assert call.ctx.rollback_to == "u1"
|
|
146
146
|
call.ack()
|
|
@@ -205,7 +205,7 @@ async def test_deep_reload_with_queue_non_empty_reruns_and_keeps_queue():
|
|
|
205
205
|
await drv.wait_status("error")
|
|
206
206
|
drv.post("run/reload", reload_params("a0", anchor="u0"))
|
|
207
207
|
call = await script.next_call()
|
|
208
|
-
assert call.ctx.trigger == "message
|
|
208
|
+
assert call.ctx.trigger == "reload-message"
|
|
209
209
|
assert call.ctx.rollback_to == "u0"
|
|
210
210
|
assert [item["id"] for item in drv.run["queue"]] == ["m2"]
|
|
211
211
|
|
|
@@ -260,7 +260,7 @@ async def test_edit_anchor_assertion():
|
|
|
260
260
|
script.no_call()
|
|
261
261
|
drv.post("run/edit", edit_params("u1", msg("u2"), anchor=None))
|
|
262
262
|
call = await script.next_call()
|
|
263
|
-
assert call.ctx.trigger == "message
|
|
263
|
+
assert call.ctx.trigger == "edit-message"
|
|
264
264
|
assert call.ctx.rollback_to is None
|
|
265
265
|
|
|
266
266
|
|
|
@@ -283,5 +283,5 @@ async def test_reload_anchor_assertion():
|
|
|
283
283
|
script.no_call()
|
|
284
284
|
drv.post("run/reload", reload_params("a1", anchor="u1"))
|
|
285
285
|
call = await script.next_call()
|
|
286
|
-
assert call.ctx.trigger == "message
|
|
286
|
+
assert call.ctx.trigger == "reload-message"
|
|
287
287
|
assert call.ctx.rollback_to == "u1"
|