harness-sdk-python 0.12.0__tar.gz → 0.13.1__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.1}/PKG-INFO +1 -1
- {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.1}/pyproject.toml +1 -1
- {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.1}/src/harness_sdk/run_manager.py +125 -50
- {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.1}/tests/test_batches.py +69 -1
- {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.1}/tests/test_dispatching.py +1 -1
- {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.1}/tests/test_enqueue.py +4 -3
- {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.1}/tests/test_input_required.py +26 -10
- {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.1}/tests/test_outcomes.py +2 -2
- {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.1}/tests/test_restore.py +46 -5
- {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.1}/tests/test_stop_continue.py +59 -2
- harness_sdk_python-0.13.1/tests/test_stopping_window.py +263 -0
- {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.1}/.gitignore +0 -0
- {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.1}/README.md +0 -0
- {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.1}/examples/__init__.py +0 -0
- {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.1}/examples/runs_app.py +0 -0
- {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.1}/src/harness_sdk/__init__.py +0 -0
- {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.1}/src/harness_sdk/fenced_postgres.py +0 -0
- {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.1}/src/harness_sdk/linear_thread.py +0 -0
- {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.1}/tests/run_helpers.py +0 -0
- {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.1}/tests/test_ack_visibility.py +0 -0
- {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.1}/tests/test_branch_anchor.py +0 -0
- {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.1}/tests/test_edit_dispatched.py +0 -0
- {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.1}/tests/test_edit_reload.py +0 -0
- {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.1}/tests/test_fenced_postgres.py +0 -0
- {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.1}/tests/test_linear_thread.py +0 -0
- {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.1}/tests/test_meta.py +0 -0
- {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.1}/tests/test_placement.py +0 -0
- {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.1}/tests/test_prepare_hooks.py +0 -0
- {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.1}/tests/test_rewind_during_run.py +0 -0
- {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.1}/tests/test_settle.py +0 -0
- {harness_sdk_python-0.12.0 → harness_sdk_python-0.13.1}/tests/test_steer.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
|
|
@@ -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 "message-send"
|
|
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
|
)
|
|
@@ -635,6 +683,30 @@ class RunManager:
|
|
|
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,7 +987,7 @@ 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
992
|
"message-edit",
|
|
921
993
|
[self._stamped(e.message, e.meta)],
|
|
@@ -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,7 +1042,7 @@ 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
1047
|
"message-edit",
|
|
973
1048
|
[self._stamped(e.message, e.meta)],
|
|
@@ -980,7 +1055,7 @@ 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
1060
|
"message-reload",
|
|
986
1061
|
[],
|
|
@@ -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:
|
|
@@ -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
|
|
|
@@ -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 == "message-edit"
|
|
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 == "message-send"
|
|
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")
|
|
@@ -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
|
|
|
@@ -80,7 +80,7 @@ async def test_steer_lane_drains_first_and_whole():
|
|
|
80
80
|
await drv.wait_status("ready")
|
|
81
81
|
|
|
82
82
|
|
|
83
|
-
async def
|
|
83
|
+
async def test_error_add_with_empty_lanes_dispatches_error_continue_with_messages():
|
|
84
84
|
script = Script()
|
|
85
85
|
async with run_host(script) as (drv, host):
|
|
86
86
|
drv.post("run/enqueue", add("m1"))
|
|
@@ -90,9 +90,10 @@ async def test_error_add_with_empty_lanes_dispatches_immediately():
|
|
|
90
90
|
await drv.wait_status("error")
|
|
91
91
|
drv.post("run/enqueue", add("m2"))
|
|
92
92
|
call = await script.next_call()
|
|
93
|
-
assert call.ctx.trigger == "
|
|
93
|
+
assert call.ctx.trigger == "error-continue"
|
|
94
94
|
assert [m["id"] for m in call.ctx.messages] == ["m2"]
|
|
95
|
-
await drv.wait(lambda s:
|
|
95
|
+
await drv.wait(lambda s: "error" not in run_of(s))
|
|
96
|
+
assert drv.run["dispatching"]["trigger"] == "error-continue"
|
|
96
97
|
|
|
97
98
|
|
|
98
99
|
async def test_error_add_with_queued_items_stays_queued():
|
|
@@ -37,15 +37,12 @@ async def park(drv, script, requests, *, anchor=None):
|
|
|
37
37
|
await drv.wait_status("input-required")
|
|
38
38
|
|
|
39
39
|
|
|
40
|
-
async def
|
|
40
|
+
async def test_park_replicates_pending_requests_without_response_keys():
|
|
41
41
|
script = Script()
|
|
42
42
|
async with run_host(script) as (drv, host):
|
|
43
43
|
await park(drv, script, [tool_call("r1"), approval("r2", "tc2")])
|
|
44
44
|
assert "dispatch" not in drv.replica
|
|
45
|
-
assert drv.run["inputRequests"] == [
|
|
46
|
-
{**tool_call("r1"), "response": None},
|
|
47
|
-
{**approval("r2", "tc2"), "response": None},
|
|
48
|
-
]
|
|
45
|
+
assert drv.run["inputRequests"] == [tool_call("r1"), approval("r2", "tc2")]
|
|
49
46
|
|
|
50
47
|
|
|
51
48
|
async def test_partial_answer_stays_parked_and_echoes_response():
|
|
@@ -57,11 +54,12 @@ async def test_partial_answer_stays_parked_and_echoes_response():
|
|
|
57
54
|
)
|
|
58
55
|
assert res["type"] == "accepted"
|
|
59
56
|
await drv.wait(
|
|
60
|
-
lambda s: run_of(s)["inputRequests"][0]
|
|
57
|
+
lambda s: "response" in run_of(s)["inputRequests"][0]
|
|
61
58
|
)
|
|
62
59
|
assert drv.replica["status"] == "input-required"
|
|
63
60
|
assert drv.run["inputRequests"][0]["response"] == {"output": "ok"}
|
|
64
|
-
assert drv.run["inputRequests"][
|
|
61
|
+
assert "meta" not in drv.run["inputRequests"][0]
|
|
62
|
+
assert "response" not in drv.run["inputRequests"][1]
|
|
65
63
|
script.no_call()
|
|
66
64
|
|
|
67
65
|
|
|
@@ -238,7 +236,7 @@ async def test_custom_request_type_passes_through():
|
|
|
238
236
|
async with run_host(script) as (drv, host):
|
|
239
237
|
request = {"type": "free-form", "id": "r1", "prompt": "where to?"}
|
|
240
238
|
await park(drv, script, [request])
|
|
241
|
-
assert drv.run["inputRequests"] == [
|
|
239
|
+
assert drv.run["inputRequests"] == [request]
|
|
242
240
|
response = {"anything": [1, 2], "goes": True}
|
|
243
241
|
assert (
|
|
244
242
|
await drv.command("run/input", {"requestId": "r1", "response": response})
|
|
@@ -256,7 +254,7 @@ async def test_stop_while_parked_rejects_and_keeps_the_pending_set():
|
|
|
256
254
|
await park(drv, script, [tool_call("r1")])
|
|
257
255
|
assert_rejected(await drv.command("run/stop"), "wrong-state")
|
|
258
256
|
assert drv.replica["status"] == "input-required"
|
|
259
|
-
assert drv.run["inputRequests"] == [
|
|
257
|
+
assert drv.run["inputRequests"] == [tool_call("r1")]
|
|
260
258
|
await drv.command(
|
|
261
259
|
"run/input", {"requestId": "r1", "response": {"output": "x"}}
|
|
262
260
|
)
|
|
@@ -412,7 +410,7 @@ async def test_enqueue_while_parked_parks_and_keeps_the_pending_set():
|
|
|
412
410
|
await drv.command("run/enqueue", add("m2"), terminal=False)
|
|
413
411
|
assert queue_ids(drv.replica, "queue") == ["m2"]
|
|
414
412
|
assert drv.replica["status"] == "input-required"
|
|
415
|
-
assert drv.run["inputRequests"] == [
|
|
413
|
+
assert drv.run["inputRequests"] == [tool_call("r1")]
|
|
416
414
|
script.no_call()
|
|
417
415
|
|
|
418
416
|
|
|
@@ -447,3 +445,21 @@ async def test_steer_add_with_partial_answers_hands_over_the_answered_subset():
|
|
|
447
445
|
run.ack()
|
|
448
446
|
run.finish(RunManager.Complete())
|
|
449
447
|
await drv.wait_status("ready")
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
async def test_answer_meta_replicates_only_when_carried():
|
|
451
|
+
script = Script()
|
|
452
|
+
async with run_host(script) as (drv, host):
|
|
453
|
+
await park(drv, script, [tool_call("r1"), approval("r2")])
|
|
454
|
+
await drv.command(
|
|
455
|
+
"run/input",
|
|
456
|
+
{"requestId": "r1", "response": {"output": "ok"}, "meta": {"src": "c1"}},
|
|
457
|
+
)
|
|
458
|
+
await drv.wait(lambda s: "response" in run_of(s)["inputRequests"][0])
|
|
459
|
+
assert drv.run["inputRequests"][0] == {
|
|
460
|
+
**tool_call("r1"),
|
|
461
|
+
"response": {"output": "ok"},
|
|
462
|
+
"meta": {"src": "c1"},
|
|
463
|
+
}
|
|
464
|
+
assert drv.run["inputRequests"][1] == approval("r2")
|
|
465
|
+
script.no_call()
|
|
@@ -61,7 +61,7 @@ async def test_state_error_cleared_by_next_user_initiated_entry():
|
|
|
61
61
|
drv.post("run/enqueue", add("m2"))
|
|
62
62
|
await script.next_call()
|
|
63
63
|
await drv.wait(
|
|
64
|
-
lambda s: s["status"] == "running" and
|
|
64
|
+
lambda s: s["status"] == "running" and "error" not in run_of(s)
|
|
65
65
|
)
|
|
66
66
|
|
|
67
67
|
|
|
@@ -83,7 +83,7 @@ async def test_error_outcome_freezes_without_state_error():
|
|
|
83
83
|
await drv.command("run/enqueue", add("m2"), terminal=False)
|
|
84
84
|
call.finish(RunManager.Error(dispatch_queue=False))
|
|
85
85
|
await drv.wait_status("error")
|
|
86
|
-
assert
|
|
86
|
+
assert "error" not in drv.run
|
|
87
87
|
assert queue_ids(drv.replica, "queue") == ["m2"]
|
|
88
88
|
script.no_call()
|
|
89
89
|
|
|
@@ -7,7 +7,7 @@ raises."""
|
|
|
7
7
|
import asyncio
|
|
8
8
|
|
|
9
9
|
import pytest
|
|
10
|
-
from run_helpers import Script, add, msg, run_host
|
|
10
|
+
from run_helpers import Script, add, assert_rejected, msg, run_host
|
|
11
11
|
|
|
12
12
|
from harness_sdk import RunManager
|
|
13
13
|
|
|
@@ -22,9 +22,7 @@ def parked_entry(**overrides) -> dict:
|
|
|
22
22
|
"status": "input-required",
|
|
23
23
|
"queue": [],
|
|
24
24
|
"steerQueue": [],
|
|
25
|
-
"
|
|
26
|
-
"error": None,
|
|
27
|
-
"inputRequests": [{**tool_call("r1"), "response": None}],
|
|
25
|
+
"inputRequests": [tool_call("r1")],
|
|
28
26
|
**overrides,
|
|
29
27
|
}
|
|
30
28
|
|
|
@@ -112,9 +110,23 @@ async def test_malformed_restore_states_raise():
|
|
|
112
110
|
({"runs": [parked_entry(queue=None)]}, "queue"),
|
|
113
111
|
({"runs": [parked_entry(steerQueue=None)]}, "steerQueue"),
|
|
114
112
|
({"runs": [parked_entry(dispatching={"trigger": "steer"})]}, "dispatching"),
|
|
113
|
+
({"runs": [parked_entry(dispatching=None)]}, "dispatching"),
|
|
115
114
|
({"runs": [parked_entry(error={"message": "x"})]}, "error"),
|
|
115
|
+
({"runs": [parked_entry(error=None)]}, "error"),
|
|
116
|
+
({"runs": [parked_entry(stopping={"reason": "stop"})]}, "stopping"),
|
|
117
|
+
({"runs": [parked_entry(nextDispatch={"trigger": "message-edit"})]}, "nextDispatch"),
|
|
116
118
|
({"runs": [parked_entry(inputRequests=[])]}, "non-empty list"),
|
|
117
119
|
({"runs": [parked_entry(inputRequests=["nope"])]}, "objects"),
|
|
120
|
+
(
|
|
121
|
+
{
|
|
122
|
+
"runs": [
|
|
123
|
+
parked_entry(
|
|
124
|
+
inputRequests=[{**tool_call("r1"), "response": "nope"}]
|
|
125
|
+
)
|
|
126
|
+
]
|
|
127
|
+
},
|
|
128
|
+
"response",
|
|
129
|
+
),
|
|
118
130
|
(
|
|
119
131
|
{
|
|
120
132
|
"runs": [
|
|
@@ -125,7 +137,7 @@ async def test_malformed_restore_states_raise():
|
|
|
125
137
|
)
|
|
126
138
|
]
|
|
127
139
|
},
|
|
128
|
-
"
|
|
140
|
+
"fully answered",
|
|
129
141
|
),
|
|
130
142
|
(
|
|
131
143
|
{"runs": [parked_entry(inputRequests=[{"type": "tool-call"}])]},
|
|
@@ -134,3 +146,32 @@ async def test_malformed_restore_states_raise():
|
|
|
134
146
|
]:
|
|
135
147
|
with pytest.raises(ValueError, match=match):
|
|
136
148
|
construct(state)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
async def test_answered_entries_restore_and_the_last_answer_resumes():
|
|
152
|
+
script = Script()
|
|
153
|
+
entry = parked_entry(
|
|
154
|
+
inputRequests=[
|
|
155
|
+
{**tool_call("r1"), "response": {"output": "a"}, "meta": {"src": "c1"}},
|
|
156
|
+
tool_call("r2", "tc2"),
|
|
157
|
+
]
|
|
158
|
+
)
|
|
159
|
+
async with run_host(script, initial_runs={"runs": [entry]}) as (drv, host):
|
|
160
|
+
assert_rejected(
|
|
161
|
+
await drv.command(
|
|
162
|
+
"run/input", {"requestId": "r1", "response": {"output": "again"}}
|
|
163
|
+
),
|
|
164
|
+
"already-answered",
|
|
165
|
+
)
|
|
166
|
+
await drv.command(
|
|
167
|
+
"run/input", {"requestId": "r2", "response": {"output": "b"}}
|
|
168
|
+
)
|
|
169
|
+
resumed = await script.next_call()
|
|
170
|
+
assert resumed.ctx.trigger == "input-resume"
|
|
171
|
+
assert resumed.ctx.input_outcomes == (
|
|
172
|
+
(tool_call("r1"), {"output": "a"}, {"src": "c1"}),
|
|
173
|
+
(tool_call("r2", "tc2"), {"output": "b"}, None),
|
|
174
|
+
)
|
|
175
|
+
resumed.ack()
|
|
176
|
+
resumed.finish(RunManager.Complete())
|
|
177
|
+
await drv.wait_status("ready")
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
|
|
3
3
|
import pytest
|
|
4
|
-
from run_helpers import Script, add, assert_rejected, run_host, run_of
|
|
4
|
+
from run_helpers import Script, add, assert_rejected, queue_ids, run_host, run_of
|
|
5
5
|
|
|
6
6
|
from harness_sdk import RunManager
|
|
7
7
|
|
|
@@ -20,7 +20,7 @@ async def test_stop_is_not_optimistic():
|
|
|
20
20
|
settled = await drv.res(pending["seq"])
|
|
21
21
|
assert settled["type"] == "accepted"
|
|
22
22
|
await drv.wait_status("stopped")
|
|
23
|
-
assert
|
|
23
|
+
assert "error" not in drv.run
|
|
24
24
|
|
|
25
25
|
|
|
26
26
|
async def test_stop_outcome_can_keep_the_queue_draining():
|
|
@@ -279,3 +279,60 @@ async def test_continue_settles_before_the_continued_run_ends():
|
|
|
279
279
|
continued.fail(RuntimeError("boom again"))
|
|
280
280
|
await drv.wait_status("error")
|
|
281
281
|
assert drv.run["error"] == {"message": "boom again"}
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
@pytest.mark.parametrize(
|
|
285
|
+
"end,continue_type",
|
|
286
|
+
[("error", "error-continue"), ("stopped", "stop-continue")],
|
|
287
|
+
)
|
|
288
|
+
async def test_enqueue_send_in_error_stop_dispatches_continue_with_messages(
|
|
289
|
+
end, continue_type
|
|
290
|
+
):
|
|
291
|
+
script = Script()
|
|
292
|
+
async with run_host(script) as (drv, host):
|
|
293
|
+
drv.post("run/enqueue", add("m1"))
|
|
294
|
+
call = await script.next_call()
|
|
295
|
+
call.ack()
|
|
296
|
+
if end == "error":
|
|
297
|
+
call.fail(RuntimeError("boom"))
|
|
298
|
+
else:
|
|
299
|
+
await drv.stop(terminal=False)
|
|
300
|
+
call.finish(RunManager.Stop(dispatch_queue=False))
|
|
301
|
+
await drv.wait_status(end)
|
|
302
|
+
seq = drv.post("run/enqueue", add("m2"))
|
|
303
|
+
continued = await script.next_call()
|
|
304
|
+
assert continued.ctx.trigger == continue_type
|
|
305
|
+
assert [m["id"] for m in continued.ctx.messages] == ["m2"]
|
|
306
|
+
await drv.wait(
|
|
307
|
+
lambda s: (run_of(s).get("dispatching") or {}).get("trigger")
|
|
308
|
+
== continue_type
|
|
309
|
+
)
|
|
310
|
+
continued.ack()
|
|
311
|
+
assert (await drv.res(seq))["type"] == "accepted"
|
|
312
|
+
continued.finish(RunManager.Complete())
|
|
313
|
+
await drv.wait_status("ready")
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
async def test_same_tick_send_and_continue_dispatch_one_run_with_messages():
|
|
317
|
+
script = Script()
|
|
318
|
+
async with run_host(script, capabilities=("incomplete-continuation",)) as (
|
|
319
|
+
drv,
|
|
320
|
+
host,
|
|
321
|
+
):
|
|
322
|
+
drv.post("run/enqueue", add("m1"))
|
|
323
|
+
call = await script.next_call()
|
|
324
|
+
call.ack()
|
|
325
|
+
call.fail(RuntimeError("boom"))
|
|
326
|
+
await drv.wait_status("error")
|
|
327
|
+
first = drv.batch(
|
|
328
|
+
[("run/continue", None), ("run/enqueue", add("m2"))]
|
|
329
|
+
)
|
|
330
|
+
continued = await script.next_call()
|
|
331
|
+
assert continued.ctx.trigger == "error-continue"
|
|
332
|
+
assert [m["id"] for m in continued.ctx.messages] == ["m2"]
|
|
333
|
+
script.no_call()
|
|
334
|
+
assert (await drv.res(first))["type"] == "accepted"
|
|
335
|
+
assert (await drv.res(first + 1))["type"] == "accepted"
|
|
336
|
+
continued.ack()
|
|
337
|
+
continued.finish(RunManager.Complete())
|
|
338
|
+
await drv.wait_status("ready")
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""Contract: the stopping window. Three openers — run/stop, mid-run
|
|
2
|
+
edit/reload, and the enqueue/steer edit-of-dispatched form — write
|
|
3
|
+
``stopping: {reason}`` (plus the staged rewind as ``nextDispatch``) in the
|
|
4
|
+
same envelope as the opener's pending answer, ``status`` staying "running".
|
|
5
|
+
On run end one envelope promotes ``nextDispatch`` into ``dispatching`` and
|
|
6
|
+
clears the window; a later stop/rewind supersedes the staged command, which
|
|
7
|
+
settles rejected (``superseded``)."""
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import copy
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
from run_helpers import (
|
|
14
|
+
Script,
|
|
15
|
+
add,
|
|
16
|
+
assert_rejected,
|
|
17
|
+
edit_params,
|
|
18
|
+
msg,
|
|
19
|
+
queue_ids,
|
|
20
|
+
reload_params,
|
|
21
|
+
run_host,
|
|
22
|
+
target,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
from harness_sdk import RunManager
|
|
26
|
+
from statewire_helpers import apply_ops
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def states(drv):
|
|
30
|
+
state: dict = {}
|
|
31
|
+
for env in copy.deepcopy(drv.envelopes):
|
|
32
|
+
apply_ops(state, env)
|
|
33
|
+
yield env, copy.deepcopy(state)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def window_of(state):
|
|
37
|
+
runs = state.get("runs") or [{}]
|
|
38
|
+
return runs[0].get("stopping"), runs[0].get("nextDispatch")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def start_run(drv, script, *, parent="p0"):
|
|
42
|
+
drv.post("run/enqueue", add("m1"))
|
|
43
|
+
call = await script.next_call()
|
|
44
|
+
script.thread["m1"] = {
|
|
45
|
+
"parentId": parent,
|
|
46
|
+
"role": "user",
|
|
47
|
+
"isLeaf": True,
|
|
48
|
+
"onActiveBranch": True,
|
|
49
|
+
}
|
|
50
|
+
script.thread["a1"] = {
|
|
51
|
+
"parentId": "m1",
|
|
52
|
+
"role": "assistant",
|
|
53
|
+
"isLeaf": True,
|
|
54
|
+
"onActiveBranch": True,
|
|
55
|
+
}
|
|
56
|
+
call.ack()
|
|
57
|
+
return call
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def test_stop_opens_the_window_with_the_pending_answer():
|
|
61
|
+
script = Script()
|
|
62
|
+
async with run_host(script) as (drv, host):
|
|
63
|
+
call = await start_run(drv, script)
|
|
64
|
+
pending = await drv.stop(terminal=False)
|
|
65
|
+
assert pending["type"] == "pending"
|
|
66
|
+
assert drv.replica["status"] == "running"
|
|
67
|
+
assert drv.run["stopping"] == {"reason": "stop"}
|
|
68
|
+
assert "nextDispatch" not in drv.run
|
|
69
|
+
opened = next(
|
|
70
|
+
state
|
|
71
|
+
for env, state in states(drv)
|
|
72
|
+
if any(r["seq"] == pending["seq"] for r in env.get("res", []))
|
|
73
|
+
)
|
|
74
|
+
assert window_of(opened) == ({"reason": "stop"}, None)
|
|
75
|
+
call.finish(RunManager.Stop(dispatch_queue=False))
|
|
76
|
+
assert (await drv.res(pending["seq"]))["type"] == "accepted"
|
|
77
|
+
await drv.wait_status("stopped")
|
|
78
|
+
assert "stopping" not in drv.run
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
async def test_mid_run_edit_stages_the_window_and_promotes_in_one_envelope():
|
|
82
|
+
script = Script()
|
|
83
|
+
async with run_host(script, capabilities=("rewind", "rewind-during-run")) as (
|
|
84
|
+
drv,
|
|
85
|
+
host,
|
|
86
|
+
):
|
|
87
|
+
call = await start_run(drv, script)
|
|
88
|
+
seq = drv.post("run/edit", edit_params("m1", msg("m2"), anchor="p0"))
|
|
89
|
+
await asyncio.wait_for(call.ctx.stop_requested.wait(), 5)
|
|
90
|
+
await drv.wait(lambda s: "stopping" in (s["runs"] or [{}])[0])
|
|
91
|
+
assert drv.replica["status"] == "running"
|
|
92
|
+
assert drv.run["stopping"] == {"reason": "message-edit"}
|
|
93
|
+
staged = drv.run["nextDispatch"]
|
|
94
|
+
assert staged == {
|
|
95
|
+
"trigger": "message-edit",
|
|
96
|
+
"messages": [msg("m2")],
|
|
97
|
+
"rollbackTo": "p0",
|
|
98
|
+
}
|
|
99
|
+
opened = next(
|
|
100
|
+
state
|
|
101
|
+
for env, state in states(drv)
|
|
102
|
+
if any(r["seq"] == seq for r in env.get("res", []))
|
|
103
|
+
)
|
|
104
|
+
assert window_of(opened) == ({"reason": "message-edit"}, staged)
|
|
105
|
+
call.finish(RunManager.Stop(dispatch_queue=False))
|
|
106
|
+
rerun = await script.next_call()
|
|
107
|
+
assert rerun.ctx.trigger == "message-edit"
|
|
108
|
+
assert rerun.ctx.rollback_to == "p0"
|
|
109
|
+
await drv.wait(lambda s: (s["runs"][0].get("dispatching") or {}).get("trigger") == "message-edit")
|
|
110
|
+
trail = [
|
|
111
|
+
(window_of(state), (state["runs"] or [{}])[0].get("dispatching"))
|
|
112
|
+
for _, state in states(drv)
|
|
113
|
+
]
|
|
114
|
+
promoted = next(
|
|
115
|
+
i
|
|
116
|
+
for i, (_, record) in enumerate(trail)
|
|
117
|
+
if record is not None and record["trigger"] == "message-edit"
|
|
118
|
+
)
|
|
119
|
+
assert trail[promoted][0] == (None, None)
|
|
120
|
+
assert trail[promoted][1] == staged
|
|
121
|
+
assert trail[promoted - 1][0] == ({"reason": "message-edit"}, staged)
|
|
122
|
+
assert (await drv.res(seq))["type"] == "accepted"
|
|
123
|
+
rerun.ack()
|
|
124
|
+
rerun.finish(RunManager.Complete())
|
|
125
|
+
await drv.wait_status("ready")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
async def test_mid_run_reload_stages_reason_and_empty_messages():
|
|
129
|
+
script = Script()
|
|
130
|
+
async with run_host(script, capabilities=("rewind", "rewind-during-run")) as (
|
|
131
|
+
drv,
|
|
132
|
+
host,
|
|
133
|
+
):
|
|
134
|
+
call = await start_run(drv, script)
|
|
135
|
+
seq = drv.post("run/reload", reload_params("a1", anchor="m1", meta={"retry": 1}))
|
|
136
|
+
await drv.wait(lambda s: "stopping" in (s["runs"] or [{}])[0])
|
|
137
|
+
assert drv.run["stopping"] == {"reason": "message-reload"}
|
|
138
|
+
assert drv.run["nextDispatch"] == {
|
|
139
|
+
"trigger": "message-reload",
|
|
140
|
+
"messages": [],
|
|
141
|
+
"rollbackTo": "m1",
|
|
142
|
+
"rootMeta": {"retry": 1},
|
|
143
|
+
}
|
|
144
|
+
call.finish(RunManager.Stop(dispatch_queue=False))
|
|
145
|
+
rerun = await script.next_call()
|
|
146
|
+
assert rerun.ctx.trigger == "message-reload"
|
|
147
|
+
assert rerun.ctx.meta() == {"retry": 1}
|
|
148
|
+
await drv.wait(lambda s: "stopping" not in (s["runs"] or [{}])[0])
|
|
149
|
+
assert drv.run["dispatching"]["rootMeta"] == {"retry": 1}
|
|
150
|
+
assert (await drv.res(seq))["type"] == "accepted"
|
|
151
|
+
rerun.ack()
|
|
152
|
+
rerun.finish(RunManager.Complete())
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@pytest.mark.parametrize("command", ["run/enqueue", "run/steer"])
|
|
156
|
+
async def test_edit_of_dispatched_message_opens_the_window(command):
|
|
157
|
+
script = Script()
|
|
158
|
+
async with run_host(script, capabilities=("rewind", "rewind-during-run")) as (
|
|
159
|
+
drv,
|
|
160
|
+
host,
|
|
161
|
+
):
|
|
162
|
+
call = await start_run(drv, script)
|
|
163
|
+
seq = drv.post(command, target(message=msg("m1", "edited")))
|
|
164
|
+
await drv.wait(lambda s: "stopping" in (s["runs"] or [{}])[0])
|
|
165
|
+
assert drv.replica["status"] == "running"
|
|
166
|
+
assert drv.run["stopping"] == {"reason": "message-edit"}
|
|
167
|
+
assert drv.run["nextDispatch"] == {
|
|
168
|
+
"trigger": "message-edit",
|
|
169
|
+
"messages": [msg("m1", "edited")],
|
|
170
|
+
"rollbackTo": "p0",
|
|
171
|
+
}
|
|
172
|
+
call.finish(RunManager.Stop(dispatch_queue=False))
|
|
173
|
+
rerun = await script.next_call()
|
|
174
|
+
assert rerun.ctx.trigger == "message-edit"
|
|
175
|
+
assert rerun.ctx.rollback_to == "p0"
|
|
176
|
+
assert (await drv.res(seq))["type"] == "accepted"
|
|
177
|
+
rerun.ack()
|
|
178
|
+
rerun.finish(RunManager.Complete())
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
async def test_later_rewind_supersedes_the_staged_one():
|
|
182
|
+
script = Script()
|
|
183
|
+
async with run_host(script, capabilities=("rewind", "rewind-during-run")) as (
|
|
184
|
+
drv,
|
|
185
|
+
host,
|
|
186
|
+
):
|
|
187
|
+
call = await start_run(drv, script)
|
|
188
|
+
first = drv.post("run/edit", edit_params("m1", msg("m2"), anchor="p0"))
|
|
189
|
+
await drv.wait(lambda s: "nextDispatch" in (s["runs"] or [{}])[0])
|
|
190
|
+
second = drv.post("run/reload", reload_params("a1", anchor="m1"))
|
|
191
|
+
assert_rejected(await drv.res(first), "superseded")
|
|
192
|
+
await drv.wait(
|
|
193
|
+
lambda s: s["runs"][0]["nextDispatch"]["trigger"] == "message-reload"
|
|
194
|
+
)
|
|
195
|
+
assert drv.run["stopping"] == {"reason": "message-reload"}
|
|
196
|
+
call.finish(RunManager.Stop(dispatch_queue=False))
|
|
197
|
+
rerun = await script.next_call()
|
|
198
|
+
assert rerun.ctx.trigger == "message-reload"
|
|
199
|
+
assert (await drv.res(second))["type"] == "accepted"
|
|
200
|
+
rerun.ack()
|
|
201
|
+
rerun.finish(RunManager.Complete())
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
async def test_stop_supersedes_a_staged_rewind():
|
|
205
|
+
script = Script()
|
|
206
|
+
async with run_host(script, capabilities=("rewind", "rewind-during-run")) as (
|
|
207
|
+
drv,
|
|
208
|
+
host,
|
|
209
|
+
):
|
|
210
|
+
call = await start_run(drv, script)
|
|
211
|
+
seq = drv.post("run/edit", edit_params("m1", msg("m2"), anchor="p0"))
|
|
212
|
+
await drv.wait(lambda s: "nextDispatch" in (s["runs"] or [{}])[0])
|
|
213
|
+
pending = await drv.stop(terminal=False)
|
|
214
|
+
assert_rejected(await drv.res(seq), "superseded")
|
|
215
|
+
await drv.wait(lambda s: (s["runs"] or [{}])[0].get("stopping") == {"reason": "stop"})
|
|
216
|
+
assert "nextDispatch" not in drv.run
|
|
217
|
+
call.finish(RunManager.Stop(dispatch_queue=False))
|
|
218
|
+
assert (await drv.res(pending["seq"]))["type"] == "accepted"
|
|
219
|
+
await drv.wait_status("stopped")
|
|
220
|
+
script.no_call()
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
async def test_sends_during_the_window_park_in_their_lanes():
|
|
224
|
+
script = Script()
|
|
225
|
+
async with run_host(script, capabilities=("rewind", "rewind-during-run")) as (
|
|
226
|
+
drv,
|
|
227
|
+
host,
|
|
228
|
+
):
|
|
229
|
+
call = await start_run(drv, script)
|
|
230
|
+
seq = drv.post("run/edit", edit_params("m1", msg("m2"), anchor="p0"))
|
|
231
|
+
await drv.wait(lambda s: "nextDispatch" in (s["runs"] or [{}])[0])
|
|
232
|
+
await drv.command("run/enqueue", add("q1", anchor="a1"), terminal=False)
|
|
233
|
+
await drv.command("run/steer", add("s1", anchor="a1"), terminal=False)
|
|
234
|
+
assert queue_ids(drv.replica, "queue") == ["q1"]
|
|
235
|
+
assert queue_ids(drv.replica, "steerQueue") == ["s1"]
|
|
236
|
+
call.finish(RunManager.Stop(dispatch_queue=False))
|
|
237
|
+
rerun = await script.next_call()
|
|
238
|
+
assert rerun.ctx.trigger == "message-edit"
|
|
239
|
+
assert (await drv.res(seq))["type"] == "accepted"
|
|
240
|
+
assert queue_ids(drv.replica, "queue") == ["q1"]
|
|
241
|
+
assert queue_ids(drv.replica, "steerQueue") == ["s1"]
|
|
242
|
+
rerun.ack()
|
|
243
|
+
rerun.finish(RunManager.Stop(dispatch_queue=False))
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
async def test_entry_keys_are_absent_when_empty():
|
|
247
|
+
script = Script()
|
|
248
|
+
async with run_host(script) as (drv, host):
|
|
249
|
+
drv.post("run/enqueue", add("m1"))
|
|
250
|
+
call = await script.next_call()
|
|
251
|
+
await drv.wait_status("running")
|
|
252
|
+
assert set(drv.run.keys()) == {
|
|
253
|
+
"runId",
|
|
254
|
+
"status",
|
|
255
|
+
"queue",
|
|
256
|
+
"steerQueue",
|
|
257
|
+
"dispatching",
|
|
258
|
+
}
|
|
259
|
+
call.ack()
|
|
260
|
+
await drv.wait(lambda s: "dispatching" not in s["runs"][0])
|
|
261
|
+
call.fail(RuntimeError("boom"))
|
|
262
|
+
await drv.wait_status("error")
|
|
263
|
+
assert set(drv.run.keys()) == {"runId", "status", "queue", "steerQueue", "error"}
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|