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.
Files changed (34) hide show
  1. {harness_sdk_python-0.19.0 → harness_sdk_python-0.20.0}/PKG-INFO +1 -1
  2. {harness_sdk_python-0.19.0 → harness_sdk_python-0.20.0}/pyproject.toml +1 -1
  3. {harness_sdk_python-0.19.0 → harness_sdk_python-0.20.0}/src/harness_sdk/run_manager.py +54 -31
  4. {harness_sdk_python-0.19.0 → harness_sdk_python-0.20.0}/tests/test_durability.py +13 -7
  5. {harness_sdk_python-0.19.0 → harness_sdk_python-0.20.0}/tests/test_outcomes.py +31 -0
  6. {harness_sdk_python-0.19.0 → harness_sdk_python-0.20.0}/tests/test_run_crash_sweep.py +39 -8
  7. {harness_sdk_python-0.19.0 → harness_sdk_python-0.20.0}/tests/test_stopping_window.py +1 -1
  8. {harness_sdk_python-0.19.0 → harness_sdk_python-0.20.0}/.gitignore +0 -0
  9. {harness_sdk_python-0.19.0 → harness_sdk_python-0.20.0}/README.md +0 -0
  10. {harness_sdk_python-0.19.0 → harness_sdk_python-0.20.0}/examples/__init__.py +0 -0
  11. {harness_sdk_python-0.19.0 → harness_sdk_python-0.20.0}/examples/runs_app.py +0 -0
  12. {harness_sdk_python-0.19.0 → harness_sdk_python-0.20.0}/src/harness_sdk/__init__.py +0 -0
  13. {harness_sdk_python-0.19.0 → harness_sdk_python-0.20.0}/src/harness_sdk/fenced_postgres.py +0 -0
  14. {harness_sdk_python-0.19.0 → harness_sdk_python-0.20.0}/src/harness_sdk/linear_thread.py +0 -0
  15. {harness_sdk_python-0.19.0 → harness_sdk_python-0.20.0}/tests/run_helpers.py +0 -0
  16. {harness_sdk_python-0.19.0 → harness_sdk_python-0.20.0}/tests/test_admit_steer.py +0 -0
  17. {harness_sdk_python-0.19.0 → harness_sdk_python-0.20.0}/tests/test_applied_visibility.py +0 -0
  18. {harness_sdk_python-0.19.0 → harness_sdk_python-0.20.0}/tests/test_batches.py +0 -0
  19. {harness_sdk_python-0.19.0 → harness_sdk_python-0.20.0}/tests/test_branch_anchor.py +0 -0
  20. {harness_sdk_python-0.19.0 → harness_sdk_python-0.20.0}/tests/test_dispatching.py +0 -0
  21. {harness_sdk_python-0.19.0 → harness_sdk_python-0.20.0}/tests/test_edit_dispatched.py +0 -0
  22. {harness_sdk_python-0.19.0 → harness_sdk_python-0.20.0}/tests/test_edit_reload.py +0 -0
  23. {harness_sdk_python-0.19.0 → harness_sdk_python-0.20.0}/tests/test_enqueue.py +0 -0
  24. {harness_sdk_python-0.19.0 → harness_sdk_python-0.20.0}/tests/test_fenced_postgres.py +0 -0
  25. {harness_sdk_python-0.19.0 → harness_sdk_python-0.20.0}/tests/test_input_required.py +0 -0
  26. {harness_sdk_python-0.19.0 → harness_sdk_python-0.20.0}/tests/test_linear_thread.py +0 -0
  27. {harness_sdk_python-0.19.0 → harness_sdk_python-0.20.0}/tests/test_meta.py +0 -0
  28. {harness_sdk_python-0.19.0 → harness_sdk_python-0.20.0}/tests/test_placement.py +0 -0
  29. {harness_sdk_python-0.19.0 → harness_sdk_python-0.20.0}/tests/test_prepare_hooks.py +0 -0
  30. {harness_sdk_python-0.19.0 → harness_sdk_python-0.20.0}/tests/test_restore.py +0 -0
  31. {harness_sdk_python-0.19.0 → harness_sdk_python-0.20.0}/tests/test_rewind_during_run.py +0 -0
  32. {harness_sdk_python-0.19.0 → harness_sdk_python-0.20.0}/tests/test_settle.py +0 -0
  33. {harness_sdk_python-0.19.0 → harness_sdk_python-0.20.0}/tests/test_steer.py +0 -0
  34. {harness_sdk_python-0.19.0 → harness_sdk_python-0.20.0}/tests/test_stop_continue.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: harness-sdk-python
3
- Version: 0.19.0
3
+ Version: 0.20.0
4
4
  Summary: RunManager: the harness-sdk runs subsystem for Python Statewire hosts
5
5
  Project-URL: Repository, https://github.com/assistant-ui/harness-sdk
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "harness-sdk-python"
3
- version = "0.19.0"
3
+ version = "0.20.0"
4
4
  description = "RunManager: the harness-sdk runs subsystem for Python Statewire hosts"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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, the
10
- stopping window, a staged rewind (``nextDispatch``), and pending input
11
- requests all hydrate back.
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 effect
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, stopping
196
- window, staged rewind, and input requests, driven by the tray pump."""
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
- stopping = persistent(None)
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._set_status(
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 _status(self) -> str:
348
- return self.status.value
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 _set_status(self, status: str) -> None:
351
- self.status.value = status
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._set_status("error" if kind == "error" else "stopped")
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.stopping.value is not None or self._stop_staged:
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._set_status("stopped")
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._set_status("error")
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
- if self._in_flight() or self._staged_rewind is not None:
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 = (self.stopping.value or {}).get("reason") == "stop"
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 — the stopping window with its
4
- staged rewind, the applied batch, an unconfirmed steering take, and
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 test_the_stopping_status_survives_a_crash_in_the_stop_window():
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"] == "stopping"
191
- assert run_of(drv2.replica)["stopping"] == {"reason": "stop"}
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; status
8
- "input-required" always carries inputRequests; an open stop window and status
9
- "stopping" imply each other; and a fresh send still reaches a run.
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
- if runs.status.value == "input-required":
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 runs.stopping.value is not None:
270
- assert runs.status.value == "stopping", "an open stop window without its status"
271
- elif runs.status.value == "stopping":
272
- raise AssertionError("stopping status without an open stop window")
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 — write
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