harness-sdk-python 0.19.0__tar.gz → 0.20.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.19.0 → harness_sdk_python-0.20.0}/PKG-INFO +1 -1
- {harness_sdk_python-0.19.0 → harness_sdk_python-0.20.0}/pyproject.toml +1 -1
- {harness_sdk_python-0.19.0 → harness_sdk_python-0.20.0}/src/harness_sdk/run_manager.py +54 -31
- {harness_sdk_python-0.19.0 → harness_sdk_python-0.20.0}/tests/test_durability.py +13 -7
- {harness_sdk_python-0.19.0 → harness_sdk_python-0.20.0}/tests/test_outcomes.py +31 -0
- {harness_sdk_python-0.19.0 → harness_sdk_python-0.20.0}/tests/test_run_crash_sweep.py +39 -8
- {harness_sdk_python-0.19.0 → harness_sdk_python-0.20.0}/tests/test_stopping_window.py +1 -1
- {harness_sdk_python-0.19.0 → harness_sdk_python-0.20.0}/.gitignore +0 -0
- {harness_sdk_python-0.19.0 → harness_sdk_python-0.20.0}/README.md +0 -0
- {harness_sdk_python-0.19.0 → harness_sdk_python-0.20.0}/examples/__init__.py +0 -0
- {harness_sdk_python-0.19.0 → harness_sdk_python-0.20.0}/examples/runs_app.py +0 -0
- {harness_sdk_python-0.19.0 → harness_sdk_python-0.20.0}/src/harness_sdk/__init__.py +0 -0
- {harness_sdk_python-0.19.0 → harness_sdk_python-0.20.0}/src/harness_sdk/fenced_postgres.py +0 -0
- {harness_sdk_python-0.19.0 → harness_sdk_python-0.20.0}/src/harness_sdk/linear_thread.py +0 -0
- {harness_sdk_python-0.19.0 → harness_sdk_python-0.20.0}/tests/run_helpers.py +0 -0
- {harness_sdk_python-0.19.0 → harness_sdk_python-0.20.0}/tests/test_admit_steer.py +0 -0
- {harness_sdk_python-0.19.0 → harness_sdk_python-0.20.0}/tests/test_applied_visibility.py +0 -0
- {harness_sdk_python-0.19.0 → harness_sdk_python-0.20.0}/tests/test_batches.py +0 -0
- {harness_sdk_python-0.19.0 → harness_sdk_python-0.20.0}/tests/test_branch_anchor.py +0 -0
- {harness_sdk_python-0.19.0 → harness_sdk_python-0.20.0}/tests/test_dispatching.py +0 -0
- {harness_sdk_python-0.19.0 → harness_sdk_python-0.20.0}/tests/test_edit_dispatched.py +0 -0
- {harness_sdk_python-0.19.0 → harness_sdk_python-0.20.0}/tests/test_edit_reload.py +0 -0
- {harness_sdk_python-0.19.0 → harness_sdk_python-0.20.0}/tests/test_enqueue.py +0 -0
- {harness_sdk_python-0.19.0 → harness_sdk_python-0.20.0}/tests/test_fenced_postgres.py +0 -0
- {harness_sdk_python-0.19.0 → harness_sdk_python-0.20.0}/tests/test_input_required.py +0 -0
- {harness_sdk_python-0.19.0 → harness_sdk_python-0.20.0}/tests/test_linear_thread.py +0 -0
- {harness_sdk_python-0.19.0 → harness_sdk_python-0.20.0}/tests/test_meta.py +0 -0
- {harness_sdk_python-0.19.0 → harness_sdk_python-0.20.0}/tests/test_placement.py +0 -0
- {harness_sdk_python-0.19.0 → harness_sdk_python-0.20.0}/tests/test_prepare_hooks.py +0 -0
- {harness_sdk_python-0.19.0 → harness_sdk_python-0.20.0}/tests/test_restore.py +0 -0
- {harness_sdk_python-0.19.0 → harness_sdk_python-0.20.0}/tests/test_rewind_during_run.py +0 -0
- {harness_sdk_python-0.19.0 → harness_sdk_python-0.20.0}/tests/test_settle.py +0 -0
- {harness_sdk_python-0.19.0 → harness_sdk_python-0.20.0}/tests/test_steer.py +0 -0
- {harness_sdk_python-0.19.0 → harness_sdk_python-0.20.0}/tests/test_stop_continue.py +0 -0
|
@@ -6,13 +6,21 @@ tray's durable records are the machine's authoritative state, and the mounted
|
|
|
6
6
|
view rides ``state["runs"][0]`` (plus a top-level ``status`` mirror). A crash
|
|
7
7
|
at any point resumes the machine from its records: the in-flight dispatch
|
|
8
8
|
redelivers from the outbox (``ctx.attempts`` counts durable deliveries,
|
|
9
|
-
``ctx.state``/``set_state`` is the run's durable checkpoint), lanes,
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
``ctx.state``/``set_state`` is the run's durable checkpoint), and lanes, a
|
|
10
|
+
staged rewind (``nextDispatch``), and pending input requests all hydrate
|
|
11
|
+
back.
|
|
12
|
+
|
|
13
|
+
``status`` is derived, never stored: a recorded dispatch (the outbox) means
|
|
14
|
+
``running`` — ``stopping`` while a live stop intent (a staged stop or staged
|
|
15
|
+
rewind) is open — and with no recorded dispatch the durable facts decide:
|
|
16
|
+
``halted`` reads ``error``/``stopped``, pending ``inputRequests`` read
|
|
17
|
+
``input-required``, and bare facts read ``ready``. A crash drops live stop
|
|
18
|
+
intent; the pooled ``run/stop`` redelivers and re-stages it, and a staged
|
|
19
|
+
rewind hydrates back from ``nextDispatch``.
|
|
12
20
|
|
|
13
21
|
Commands stage entries into an intake list and an advance applies them in
|
|
14
|
-
order, then takes exactly one action based on status and net
|
|
15
|
-
(rewind > send-dispatch > continue > input-resume). Every initiator applies
|
|
22
|
+
order, then takes exactly one action based on the derived status and net
|
|
23
|
+
effect (rewind > send-dispatch > continue > input-resume). Every initiator applies
|
|
16
24
|
and settles accepted with its state-visible application, and every verb's
|
|
17
25
|
verdict follows the admission's durable flush. ``entry["dispatching"]`` is
|
|
18
26
|
the unconfirmed batch record; ``ctx.applied()`` confirms it durably,
|
|
@@ -192,8 +200,8 @@ class _Effects:
|
|
|
192
200
|
|
|
193
201
|
|
|
194
202
|
class RunManager(Tray):
|
|
195
|
-
"""One thread's run machine: durable lanes, dispatch outbox,
|
|
196
|
-
|
|
203
|
+
"""One thread's run machine: durable lanes, dispatch outbox, staged
|
|
204
|
+
rewind, and input requests, driven by the tray pump; status is derived."""
|
|
197
205
|
|
|
198
206
|
capabilities: frozenset[str] = frozenset()
|
|
199
207
|
max_queued: int = 50
|
|
@@ -207,14 +215,15 @@ class RunManager(Tray):
|
|
|
207
215
|
batchApplied = persistent(False)
|
|
208
216
|
confirmedTakes = persistent(0)
|
|
209
217
|
runId = persistent(None)
|
|
210
|
-
status = persistent("ready")
|
|
211
218
|
epoch = persistent(0)
|
|
212
|
-
|
|
219
|
+
halted = persistent(None) # off-wire settle fact: "error" | "stop" | None
|
|
213
220
|
nextDispatch = persistent(None)
|
|
214
221
|
inputRequests = persistent(None)
|
|
215
222
|
error = persistent(None)
|
|
216
223
|
stopReason = persistent(None)
|
|
217
224
|
dispatchedIds = persistent([])
|
|
225
|
+
status = observable("ready") # view: derived by _publish_status
|
|
226
|
+
stopping = observable(None) # view: the open stop window, derived
|
|
218
227
|
dispatching = observable(None) # view: the unconfirmed batch record
|
|
219
228
|
steerView = observable([]) # view: the steer lane without admission tags
|
|
220
229
|
|
|
@@ -302,9 +311,7 @@ class RunManager(Tray):
|
|
|
302
311
|
# heal a dispatch torn after its outbox write landed
|
|
303
312
|
self._clear_input()
|
|
304
313
|
self.error.value = None
|
|
305
|
-
self.
|
|
306
|
-
"stopping" if self.stopping.value is not None else "running"
|
|
307
|
-
)
|
|
314
|
+
self.halted.value = None
|
|
308
315
|
staged = self.nextDispatch.value
|
|
309
316
|
if (
|
|
310
317
|
staged is not None
|
|
@@ -337,6 +344,7 @@ class RunManager(Tray):
|
|
|
337
344
|
}
|
|
338
345
|
if not self._in_flight():
|
|
339
346
|
self._idle.set()
|
|
347
|
+
self._publish_status()
|
|
340
348
|
self._schedule(self._advance) # reconcile a torn advance segment
|
|
341
349
|
|
|
342
350
|
# ─── State access ───────────────────────────────────────
|
|
@@ -344,17 +352,36 @@ class RunManager(Tray):
|
|
|
344
352
|
def _in_flight(self) -> bool:
|
|
345
353
|
return self.outbox.value is not None
|
|
346
354
|
|
|
347
|
-
def
|
|
348
|
-
|
|
355
|
+
def _stop_intent(self) -> str | None:
|
|
356
|
+
if self._stop_staged:
|
|
357
|
+
return "stop"
|
|
358
|
+
if self._staged_rewind is not None:
|
|
359
|
+
return self._staged_rewind.type
|
|
360
|
+
return None
|
|
349
361
|
|
|
350
|
-
def
|
|
351
|
-
self.
|
|
362
|
+
def _status(self) -> str:
|
|
363
|
+
if self._in_flight():
|
|
364
|
+
return "running" if self._stop_intent() is None else "stopping"
|
|
365
|
+
if self.halted.value == "error":
|
|
366
|
+
return "error"
|
|
367
|
+
if self.halted.value == "stop":
|
|
368
|
+
return "stopped"
|
|
369
|
+
if self.inputRequests.value:
|
|
370
|
+
return "input-required"
|
|
371
|
+
return "ready"
|
|
372
|
+
|
|
373
|
+
def _publish_status(self) -> None:
|
|
374
|
+
status = self._status()
|
|
375
|
+
if self.status.value != status:
|
|
376
|
+
self.status.value = status
|
|
377
|
+
window = {"reason": self._stop_intent()} if status == "stopping" else None
|
|
378
|
+
if self.stopping.value != window:
|
|
379
|
+
self.stopping.value = window
|
|
352
380
|
|
|
353
381
|
def _to_ready(self) -> None:
|
|
354
382
|
self.runId.value = None
|
|
355
383
|
self.epoch.value = 0
|
|
356
384
|
self.dispatchedIds.value = []
|
|
357
|
-
self.status.value = "ready"
|
|
358
385
|
|
|
359
386
|
def _lane(self, lane: str) -> Any:
|
|
360
387
|
return self.queue if lane == "queue" else self.steerQueue
|
|
@@ -452,6 +479,7 @@ class RunManager(Tray):
|
|
|
452
479
|
):
|
|
453
480
|
if self._run_live is not None:
|
|
454
481
|
self._run_live.stop_requested.set()
|
|
482
|
+
self._publish_status()
|
|
455
483
|
self._settle_applied(fx)
|
|
456
484
|
if not self._in_flight():
|
|
457
485
|
self._idle.set()
|
|
@@ -463,8 +491,6 @@ class RunManager(Tray):
|
|
|
463
491
|
if self._staged_rewind is not None:
|
|
464
492
|
self._staged_rewind = None
|
|
465
493
|
self.nextDispatch.value = None
|
|
466
|
-
self.stopping.value = {"reason": "stop"}
|
|
467
|
-
self._set_status("stopping")
|
|
468
494
|
self._stop_waiters.append(entry.future) # the verdict waits for the settle
|
|
469
495
|
elif not entry.future.done():
|
|
470
496
|
entry.future.set_result(None)
|
|
@@ -526,14 +552,13 @@ class RunManager(Tray):
|
|
|
526
552
|
self._input_requests = [dict(r) for r in requests]
|
|
527
553
|
self._input_answers = {}
|
|
528
554
|
self.inputRequests.value = [dict(r) for r in requests]
|
|
529
|
-
self._set_status("input-required")
|
|
530
555
|
return
|
|
531
556
|
if outcome["dispatchQueue"] and self._pop_dispatchable():
|
|
532
557
|
return
|
|
533
558
|
error = outcome.get("error")
|
|
534
559
|
if error is not None:
|
|
535
560
|
self.error.value = error
|
|
536
|
-
self.
|
|
561
|
+
self.halted.value = "error" if kind == "error" else "stop"
|
|
537
562
|
|
|
538
563
|
# ─── Dispatch and settle ────────────────────────────────
|
|
539
564
|
|
|
@@ -596,10 +621,10 @@ class RunManager(Tray):
|
|
|
596
621
|
self._fresh = True
|
|
597
622
|
self._reclaim(item) # the record owns its sources: consume them behind it
|
|
598
623
|
self.nextDispatch.value = None
|
|
599
|
-
self.stopping.value = None
|
|
600
624
|
self._clear_input()
|
|
601
625
|
self.batchApplied.value = False
|
|
602
626
|
self.error.value = None
|
|
627
|
+
self.halted.value = None
|
|
603
628
|
stripped = [
|
|
604
629
|
{k: v for k, v in message.items() if k != "meta"} for message in messages
|
|
605
630
|
]
|
|
@@ -608,7 +633,6 @@ class RunManager(Tray):
|
|
|
608
633
|
added = [m["id"] for m in stripped if m["id"] not in known]
|
|
609
634
|
if added:
|
|
610
635
|
self.dispatchedIds.value = [*known, *added]
|
|
611
|
-
self._set_status("running")
|
|
612
636
|
self.dispatching.value = _public_record(record)
|
|
613
637
|
self._idle.clear()
|
|
614
638
|
self.kick()
|
|
@@ -660,7 +684,7 @@ class RunManager(Tray):
|
|
|
660
684
|
),
|
|
661
685
|
_tray_ctx=self._make_ctx(),
|
|
662
686
|
)
|
|
663
|
-
if self.
|
|
687
|
+
if self._stop_intent() is not None:
|
|
664
688
|
ctx.stop_requested.set()
|
|
665
689
|
return ctx
|
|
666
690
|
|
|
@@ -722,9 +746,10 @@ class RunManager(Tray):
|
|
|
722
746
|
)
|
|
723
747
|
rewind.applied()
|
|
724
748
|
rewind.applied_sent = True
|
|
725
|
-
self.
|
|
749
|
+
self.halted.value = "stop"
|
|
726
750
|
self.outcome.value = None
|
|
727
751
|
self._resolve_stop_waiters()
|
|
752
|
+
self._publish_status()
|
|
728
753
|
self._settle_applied(fx)
|
|
729
754
|
self._idle.set()
|
|
730
755
|
raise # staged intake settled; a cancelled run starts no dispatch
|
|
@@ -743,7 +768,7 @@ class RunManager(Tray):
|
|
|
743
768
|
"error": error,
|
|
744
769
|
}
|
|
745
770
|
self._settle(ctx)
|
|
746
|
-
self.
|
|
771
|
+
self.halted.value = "error"
|
|
747
772
|
self.error.value = error
|
|
748
773
|
self._advance()
|
|
749
774
|
return
|
|
@@ -799,7 +824,6 @@ class RunManager(Tray):
|
|
|
799
824
|
self.dispatchedIds.value = kept
|
|
800
825
|
|
|
801
826
|
def _finish_settle(self) -> None:
|
|
802
|
-
self.stopping.value = None
|
|
803
827
|
self.confirmedTakes.value = 0
|
|
804
828
|
self.batchApplied.value = False
|
|
805
829
|
self._clear_dispatch_state() # commits confirmed takes; drops the run's checkpoint
|
|
@@ -844,7 +868,8 @@ class RunManager(Tray):
|
|
|
844
868
|
self._lane(lane).insert(0, item)
|
|
845
869
|
|
|
846
870
|
def _dispatchable(self, lane: str) -> bool:
|
|
847
|
-
|
|
871
|
+
# an unconsumed outcome means the settle is mid-drain: its follow-on decides
|
|
872
|
+
if self._in_flight() or self._staged_rewind is not None or self.outcome.value is not None:
|
|
848
873
|
return False
|
|
849
874
|
if self._batch:
|
|
850
875
|
return True
|
|
@@ -912,8 +937,6 @@ class RunManager(Tray):
|
|
|
912
937
|
def _stage_rewind(self, rewind: _Rewind) -> None:
|
|
913
938
|
self._staged_rewind = rewind
|
|
914
939
|
if self._in_flight():
|
|
915
|
-
self.stopping.value = {"reason": rewind.type}
|
|
916
|
-
self._set_status("stopping")
|
|
917
940
|
self.nextDispatch.value = self._record_of(
|
|
918
941
|
rewind.type,
|
|
919
942
|
list(rewind.messages),
|
|
@@ -1445,7 +1468,7 @@ class RunManager(Tray):
|
|
|
1445
1468
|
if epoch is not _ABSENT and (isinstance(epoch, bool) or not isinstance(epoch, int)):
|
|
1446
1469
|
raise _reject("invalid-message", "epoch must be an integer")
|
|
1447
1470
|
status = self._status()
|
|
1448
|
-
stop_held =
|
|
1471
|
+
stop_held = self._stop_staged
|
|
1449
1472
|
# a rewind-held window still admits a stop: it clears the staged rewind
|
|
1450
1473
|
if status not in ("running", "stopping") or (
|
|
1451
1474
|
status == "stopping" and stop_held
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
"""Contract: the durable seams of the run machine. A verdict never outruns the
|
|
2
2
|
admission's flush; a crash redelivers the in-flight dispatch with its checkpoint
|
|
3
|
-
and its epoch; and the rich mid-flight state —
|
|
4
|
-
|
|
5
|
-
half-answered input requests — all hydrate back on the restarted machine.
|
|
3
|
+
and its epoch; and the rich mid-flight state — a staged rewind with its
|
|
4
|
+
derived stopping window, the applied batch, an unconfirmed steering take, and
|
|
5
|
+
half-answered input requests — all hydrate back on the restarted machine. A
|
|
6
|
+
bare stop window is live intent: it drops at the crash and a re-issued stop
|
|
7
|
+
re-opens it on the redelivered run.
|
|
6
8
|
Torn storage segments are swept exhaustively in test_run_crash_sweep.py.
|
|
7
9
|
"""
|
|
8
10
|
|
|
@@ -172,7 +174,7 @@ async def test_a_staged_rewind_survives_a_crash_in_the_stopping_window():
|
|
|
172
174
|
await drv2.wait_status("ready")
|
|
173
175
|
|
|
174
176
|
|
|
175
|
-
async def
|
|
177
|
+
async def test_a_crash_in_the_stop_window_redelivers_the_run_without_the_window():
|
|
176
178
|
store = MemoryStateStore()
|
|
177
179
|
script = Script()
|
|
178
180
|
async with run_host(script, store=store) as (drv, host):
|
|
@@ -187,11 +189,15 @@ async def test_the_stopping_status_survives_a_crash_in_the_stop_window():
|
|
|
187
189
|
|
|
188
190
|
script2 = Script()
|
|
189
191
|
async with run_host(script2, store=crashed) as (drv2, host2):
|
|
190
|
-
assert drv2.replica["status"] == "
|
|
191
|
-
assert run_of(drv2.replica)["stopping"]
|
|
192
|
+
assert drv2.replica["status"] == "running"
|
|
193
|
+
assert run_of(drv2.replica)["stopping"] is None
|
|
192
194
|
redelivered = await script2.next_call()
|
|
193
|
-
assert redelivered.ctx.stop_requested.is_set()
|
|
195
|
+
assert not redelivered.ctx.stop_requested.is_set()
|
|
196
|
+
pending = await drv2.stop(terminal=False) # a re-issued stop re-opens it
|
|
197
|
+
await asyncio.wait_for(redelivered.ctx.stop_requested.wait(), 5)
|
|
198
|
+
await drv2.wait_status("stopping")
|
|
194
199
|
redelivered.finish(RunManager.Stop(dispatch_queue=False))
|
|
200
|
+
assert (await drv2.res(pending["seq"]))["type"] == "accepted"
|
|
195
201
|
await drv2.wait_status("stopped")
|
|
196
202
|
assert run_of(drv2.replica)["stopping"] is None
|
|
197
203
|
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
1
3
|
import pytest
|
|
2
4
|
from run_helpers import Script, add, machine, queue_ids, run_host, run_of
|
|
3
5
|
|
|
@@ -137,6 +139,35 @@ async def test_unknown_capability_rejects_at_decoration():
|
|
|
137
139
|
run_manager(capabilities=("time-travel",))
|
|
138
140
|
|
|
139
141
|
|
|
142
|
+
async def test_a_send_landing_in_the_settle_drain_parks_for_the_follow_on():
|
|
143
|
+
script = Script()
|
|
144
|
+
async with run_host(script) as (drv, host):
|
|
145
|
+
runs = await machine(host.live)
|
|
146
|
+
drv.post("run/enqueue", add("m1"))
|
|
147
|
+
call = await script.next_call()
|
|
148
|
+
call.applied()
|
|
149
|
+
held: list = []
|
|
150
|
+
real, runs._schedule = runs._schedule, held.append
|
|
151
|
+
try:
|
|
152
|
+
task = asyncio.ensure_future(
|
|
153
|
+
runs.enqueue(add("m2", anchor="m1"), applied=lambda: None)
|
|
154
|
+
)
|
|
155
|
+
for _ in range(100):
|
|
156
|
+
if runs._intake:
|
|
157
|
+
break
|
|
158
|
+
await asyncio.sleep(0)
|
|
159
|
+
assert runs._intake, "the enqueue never staged"
|
|
160
|
+
finally:
|
|
161
|
+
runs._schedule = real
|
|
162
|
+
call.finish(RunManager.Complete()) # the enqueue lands in the settle advance
|
|
163
|
+
follow = await script.next_call()
|
|
164
|
+
assert [m["id"] for m in follow.ctx.messages] == ["m2"]
|
|
165
|
+
await asyncio.wait_for(task, 5)
|
|
166
|
+
follow.applied()
|
|
167
|
+
follow.finish(RunManager.Complete())
|
|
168
|
+
await drv.wait_status("ready")
|
|
169
|
+
|
|
170
|
+
|
|
140
171
|
async def test_idle_spans_the_drain_chain():
|
|
141
172
|
script = Script()
|
|
142
173
|
async with run_host(script) as (drv, host):
|
|
@@ -4,9 +4,11 @@ A SinglesStore lands writes one key at a time and dies for good after its
|
|
|
4
4
|
limit; each scenario is swept over every crash point. Whatever prefix
|
|
5
5
|
survived, the restarted machine must satisfy: an accepted message or edit is
|
|
6
6
|
either consumed by a durably settled dispatch or delivered exactly once after
|
|
7
|
-
the restart; an accepted input answer reaches a run's input_outcomes;
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
the restart; an accepted input answer reaches a run's input_outcomes; the
|
|
8
|
+
published status matches the derivation facts (a recorded dispatch reads
|
|
9
|
+
running/stopping, "stopped"/"error" carry the halted fact, "input-required"
|
|
10
|
+
carries inputRequests, the stop window rides status "stopping"); and a fresh
|
|
11
|
+
send still reaches a run.
|
|
10
12
|
|
|
11
13
|
The redelivery lands on the correct arm of the run callback: a batch not yet
|
|
12
14
|
durably applied re-presents in dispatch mode with the full record, a durably
|
|
@@ -264,12 +266,20 @@ async def flush(runs) -> None:
|
|
|
264
266
|
|
|
265
267
|
|
|
266
268
|
def check_wire(runs) -> None:
|
|
267
|
-
|
|
269
|
+
status = runs.status.value
|
|
270
|
+
in_flight = runs.outbox.value is not None
|
|
271
|
+
assert (status in ("running", "stopping")) == in_flight, (
|
|
272
|
+
f"status {status!r} contradicts the dispatch record"
|
|
273
|
+
)
|
|
274
|
+
if status == "input-required":
|
|
268
275
|
assert runs.inputRequests.value, "input-required without inputRequests"
|
|
269
|
-
if
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
276
|
+
if status in ("stopped", "error"):
|
|
277
|
+
expected = "stop" if status == "stopped" else "error"
|
|
278
|
+
assert runs.halted.value == expected, f"{status} without its halted fact"
|
|
279
|
+
if status == "stopping":
|
|
280
|
+
assert runs.stopping.value is not None, "stopping without an open stop window"
|
|
281
|
+
else:
|
|
282
|
+
assert runs.stopping.value is None, "an open stop window without its status"
|
|
273
283
|
|
|
274
284
|
|
|
275
285
|
async def settle_call(runs, call, seen: set[str]) -> None:
|
|
@@ -568,6 +578,25 @@ async def verify_rewind(runs, script, trace, seen, calls):
|
|
|
568
578
|
await probe(runs, script, seen, "a1")
|
|
569
579
|
|
|
570
580
|
|
|
581
|
+
async def drive_stop(runs, script, trace, spawn):
|
|
582
|
+
send = spawn(runs.enqueue(add("m1"), applied=lambda: None))
|
|
583
|
+
call = await script.next_call(2)
|
|
584
|
+
await step(send)
|
|
585
|
+
trace.accepted.add("m1")
|
|
586
|
+
apply_batch(call, "batch")
|
|
587
|
+
stop = spawn(runs.stop({"reason": "user-stop"}, applied=lambda: None))
|
|
588
|
+
await step(call.ctx.stop_requested.wait())
|
|
589
|
+
call.finish(RunManager.Stop(dispatch_queue=False))
|
|
590
|
+
await step(stop)
|
|
591
|
+
await flush(runs)
|
|
592
|
+
assert runs.status.value == "stopped"
|
|
593
|
+
assert runs.stopReason.value == "user-stop"
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
async def verify_stop(runs, script, trace, seen, calls):
|
|
597
|
+
await probe(runs, script, seen, None)
|
|
598
|
+
|
|
599
|
+
|
|
571
600
|
async def drive_cancel(runs, script, trace, spawn):
|
|
572
601
|
send = spawn(runs.enqueue(add("m1"), applied=lambda: None))
|
|
573
602
|
call = await script.next_call(2)
|
|
@@ -618,6 +647,8 @@ test_rewind_promotion_survives_every_crash_point = sweep(
|
|
|
618
647
|
drive_rewind, verify_rewind, capabilities=REWIND, thread=True
|
|
619
648
|
)
|
|
620
649
|
|
|
650
|
+
test_stop_settle_survives_every_crash_point = sweep(drive_stop, verify_stop)
|
|
651
|
+
|
|
621
652
|
test_cancel_with_in_drain_rewind_survives_every_crash_point = sweep(
|
|
622
653
|
drive_cancel, verify_cancel, capabilities=REWIND, thread=True
|
|
623
654
|
)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"""Contract: the stopping window. Three openers — run/stop, mid-run
|
|
2
|
-
edit/reload, and the enqueue/steer edit-of-dispatched form —
|
|
2
|
+
edit/reload, and the enqueue/steer edit-of-dispatched form — publish
|
|
3
3
|
``stopping: {reason}`` (plus the staged rewind as ``nextDispatch``) and flip
|
|
4
4
|
``status`` to "stopping" in the same envelope. Rewind commands settle
|
|
5
5
|
accepted with that write; the run/stop verdict follows the run's settle. On
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|