harness-sdk-python 0.13.3__tar.gz → 0.14.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 (31) hide show
  1. {harness_sdk_python-0.13.3 → harness_sdk_python-0.14.0}/PKG-INFO +1 -1
  2. {harness_sdk_python-0.13.3 → harness_sdk_python-0.14.0}/pyproject.toml +1 -1
  3. {harness_sdk_python-0.13.3 → harness_sdk_python-0.14.0}/src/harness_sdk/run_manager.py +31 -9
  4. {harness_sdk_python-0.13.3 → harness_sdk_python-0.14.0}/tests/test_applied_visibility.py +4 -4
  5. {harness_sdk_python-0.13.3 → harness_sdk_python-0.14.0}/tests/test_dispatching.py +2 -2
  6. {harness_sdk_python-0.13.3 → harness_sdk_python-0.14.0}/tests/test_settle.py +30 -25
  7. {harness_sdk_python-0.13.3 → harness_sdk_python-0.14.0}/tests/test_stop_continue.py +42 -0
  8. {harness_sdk_python-0.13.3 → harness_sdk_python-0.14.0}/tests/test_stopping_window.py +9 -1
  9. {harness_sdk_python-0.13.3 → harness_sdk_python-0.14.0}/.gitignore +0 -0
  10. {harness_sdk_python-0.13.3 → harness_sdk_python-0.14.0}/README.md +0 -0
  11. {harness_sdk_python-0.13.3 → harness_sdk_python-0.14.0}/examples/__init__.py +0 -0
  12. {harness_sdk_python-0.13.3 → harness_sdk_python-0.14.0}/examples/runs_app.py +0 -0
  13. {harness_sdk_python-0.13.3 → harness_sdk_python-0.14.0}/src/harness_sdk/__init__.py +0 -0
  14. {harness_sdk_python-0.13.3 → harness_sdk_python-0.14.0}/src/harness_sdk/fenced_postgres.py +0 -0
  15. {harness_sdk_python-0.13.3 → harness_sdk_python-0.14.0}/src/harness_sdk/linear_thread.py +0 -0
  16. {harness_sdk_python-0.13.3 → harness_sdk_python-0.14.0}/tests/run_helpers.py +0 -0
  17. {harness_sdk_python-0.13.3 → harness_sdk_python-0.14.0}/tests/test_batches.py +0 -0
  18. {harness_sdk_python-0.13.3 → harness_sdk_python-0.14.0}/tests/test_branch_anchor.py +0 -0
  19. {harness_sdk_python-0.13.3 → harness_sdk_python-0.14.0}/tests/test_edit_dispatched.py +0 -0
  20. {harness_sdk_python-0.13.3 → harness_sdk_python-0.14.0}/tests/test_edit_reload.py +0 -0
  21. {harness_sdk_python-0.13.3 → harness_sdk_python-0.14.0}/tests/test_enqueue.py +0 -0
  22. {harness_sdk_python-0.13.3 → harness_sdk_python-0.14.0}/tests/test_fenced_postgres.py +0 -0
  23. {harness_sdk_python-0.13.3 → harness_sdk_python-0.14.0}/tests/test_input_required.py +0 -0
  24. {harness_sdk_python-0.13.3 → harness_sdk_python-0.14.0}/tests/test_linear_thread.py +0 -0
  25. {harness_sdk_python-0.13.3 → harness_sdk_python-0.14.0}/tests/test_meta.py +0 -0
  26. {harness_sdk_python-0.13.3 → harness_sdk_python-0.14.0}/tests/test_outcomes.py +0 -0
  27. {harness_sdk_python-0.13.3 → harness_sdk_python-0.14.0}/tests/test_placement.py +0 -0
  28. {harness_sdk_python-0.13.3 → harness_sdk_python-0.14.0}/tests/test_prepare_hooks.py +0 -0
  29. {harness_sdk_python-0.13.3 → harness_sdk_python-0.14.0}/tests/test_restore.py +0 -0
  30. {harness_sdk_python-0.13.3 → harness_sdk_python-0.14.0}/tests/test_rewind_during_run.py +0 -0
  31. {harness_sdk_python-0.13.3 → harness_sdk_python-0.14.0}/tests/test_steer.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: harness-sdk-python
3
- Version: 0.13.3
3
+ Version: 0.14.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.13.3"
3
+ version = "0.14.0"
4
4
  description = "RunManager: the harness-sdk runs subsystem for Python Statewire hosts"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -17,9 +17,15 @@ or ``dispatching``), mid-run edit/reload with the stopping window,
17
17
 
18
18
  ``entry["dispatching"]`` is the dispatch record (absent between an applied and
19
19
  the next dispatch or take): the payload the run callback receives.
20
- ``ctx.applied()`` clears it; ``steering.take()`` may refill it — at most one
21
- unapplied batch ever exists. Settle consumes the delivered batch, applied or not;
22
- steer entries still undelivered in the lane stay queued for the next run.
20
+ ``ctx.applied()`` confirms it; ``steering.take()`` may refill it — at most one
21
+ unconfirmed batch ever exists. Settle consumes a confirmed batch; a batch
22
+ still unconfirmed at settle returns to the front of its lanes. ``Complete``
23
+ with an unconfirmed batch settles as an error. Steer entries still
24
+ undelivered in the lane stay queued for the next run.
25
+
26
+ ``entry["epoch"]`` counts the entry's dispatches; a ``run/stop`` carrying an
27
+ ``epoch`` other than the live one rejects, so a replayed stop never ends a
28
+ later leg of the same ``runId``.
23
29
 
24
30
  A mid-run stop or rewind opens a stopping window on the entry:
25
31
  ``stopping: {reason}`` plus, for rewinds, the staged dispatch as
@@ -534,6 +540,7 @@ class RunManager:
534
540
  self._dispatch_record = dict(record)
535
541
  self._stop_reason = None
536
542
  entry = self._ensure_entry()
543
+ entry["epoch"] = (plain(entry.get("epoch")) or 0) + 1
537
544
  entry["dispatching"] = record
538
545
  entry.pop("error", None)
539
546
  if messages:
@@ -570,6 +577,8 @@ class RunManager:
570
577
  "run must return a RunManager outcome, got "
571
578
  f"{type(outcome).__name__}"
572
579
  )
580
+ if isinstance(outcome, RunManager.Complete) and self._dispatching:
581
+ raise RuntimeError("run returned Complete with an unconfirmed batch")
573
582
  except asyncio.CancelledError:
574
583
  self._settle(ctx)
575
584
  self._set_status("stopped")
@@ -593,12 +602,18 @@ class RunManager:
593
602
  self._ctx = None
594
603
  self._task = None
595
604
  self._dispatch_record = None
596
- self._dispatching = []
597
605
  entry = self._ensure_entry()
598
- record = plain(entry.get("dispatching"))
599
- if record is not None and record["trigger"] != "steer" and record["messages"]:
600
- self._dispatched_ids = ()
601
- entry.pop("dispatching", None)
606
+ if self._dispatching:
607
+ self._unstage_dispatching()
608
+ else:
609
+ record = plain(entry.get("dispatching"))
610
+ if (
611
+ record is not None
612
+ and record["trigger"] != "steer"
613
+ and record["messages"]
614
+ ):
615
+ self._dispatched_ids = ()
616
+ entry.pop("dispatching", None)
602
617
  entry.pop("stopping", None)
603
618
 
604
619
  def _applied(self) -> None:
@@ -1212,6 +1227,9 @@ class RunManager:
1212
1227
  reason = (params or {}).get("reason", _ABSENT)
1213
1228
  if reason is not _ABSENT and not isinstance(reason, str):
1214
1229
  raise _reject("invalid-message", "reason must be a string")
1230
+ epoch = (params or {}).get("epoch", _ABSENT)
1231
+ if epoch is not _ABSENT and (isinstance(epoch, bool) or not isinstance(epoch, int)):
1232
+ raise _reject("invalid-message", "epoch must be an integer")
1215
1233
  status = self._status()
1216
1234
  if status != "running":
1217
1235
  raise _reject("wrong-state", f"run/stop is rejected in {status}")
@@ -1220,6 +1238,10 @@ class RunManager:
1220
1238
  raise _reject(
1221
1239
  "wrong-state", f"runId {run_id!r} does not name the live run"
1222
1240
  )
1241
+ if epoch is not _ABSENT and epoch != plain(self._entry()["epoch"]):
1242
+ raise _reject(
1243
+ "wrong-state", f"epoch {epoch} does not name the live dispatch"
1244
+ )
1223
1245
  if reason is not _ABSENT:
1224
1246
  self._stop_reason = reason
1225
1247
  entry = _Stop()
@@ -1411,7 +1433,7 @@ class RunManager:
1411
1433
  raise RuntimeError("this run has already settled")
1412
1434
 
1413
1435
  def applied(self) -> None:
1414
- """Applied the current batch: clears the dispatch record."""
1436
+ """Confirms the current batch durable: clears the dispatch record so settle consumes it."""
1415
1437
  self._ensure_active()
1416
1438
  self._manager._applied()
1417
1439
 
@@ -60,7 +60,7 @@ async def test_parked_send_applies_with_its_queue_insert():
60
60
  call.finish(RunManager.Complete())
61
61
 
62
62
 
63
- async def test_unapplied_run_end_consumes_the_direct_dispatch():
63
+ async def test_unconfirmed_run_end_requeues_the_direct_dispatch():
64
64
  script = Script()
65
65
  async with run_host(script) as (drv, host):
66
66
  seq = drv.post("run/enqueue", add("m1"))
@@ -69,10 +69,10 @@ async def test_unapplied_run_end_consumes_the_direct_dispatch():
69
69
  assert dispatching_ids(covered_state(drv, seq)) == ["m1"]
70
70
  call.fail(RuntimeError("boom"))
71
71
  await drv.wait_status("error")
72
- assert queue_ids(drv.replica, "queue") == []
72
+ assert queue_ids(drv.replica, "queue") == ["m1"]
73
73
 
74
74
 
75
- async def test_unapplied_run_end_consumes_the_direct_steer_send():
75
+ async def test_unconfirmed_run_end_requeues_the_direct_steer_send():
76
76
  script = Script()
77
77
  async with run_host(script) as (drv, host):
78
78
  seq = drv.post("run/steer", add("s1"))
@@ -81,7 +81,7 @@ async def test_unapplied_run_end_consumes_the_direct_steer_send():
81
81
  assert dispatching_ids(covered_state(drv, seq)) == ["s1"]
82
82
  call.fail(RuntimeError("boom"))
83
83
  await drv.wait_status("error")
84
- assert queue_ids(drv.replica, "steerQueue") == []
84
+ assert queue_ids(drv.replica, "steerQueue") == ["s1"]
85
85
  assert queue_ids(drv.replica, "queue") == []
86
86
 
87
87
 
@@ -78,7 +78,7 @@ async def test_applied_clears_dispatching_with_the_message_append():
78
78
  call.finish(RunManager.Complete())
79
79
 
80
80
 
81
- async def test_unapplied_run_end_consumes_the_delivered_item():
81
+ async def test_unconfirmed_run_end_requeues_the_delivered_item_ahead():
82
82
  script = Script()
83
83
  async with run_host(script) as (drv, host):
84
84
  drv.post("run/enqueue", add("m1"))
@@ -88,7 +88,7 @@ async def test_unapplied_run_end_consumes_the_delivered_item():
88
88
  await drv.side("run/stop", {"runId": await drv.run_id()})
89
89
  call.finish(RunManager.Stop(dispatch_queue=False))
90
90
  await drv.wait_status("stopped")
91
- assert queue_ids(drv.replica, "queue") == ["m2"]
91
+ assert queue_ids(drv.replica, "queue") == ["m1", "m2"]
92
92
  assert dispatching_ids(drv.replica) == []
93
93
  assert residence(drv, "m2") == ["queue"]
94
94
 
@@ -1,9 +1,10 @@
1
1
  """Contract: command settlement. Every initiator settles accepted with its
2
2
  state-visible application — sends with their insert (a lane entry or a direct
3
3
  ``dispatching`` entry), edit/reload/continue at their dispatch. The run's
4
- outcome never travels through settlement: settle consumes every delivered
5
- item, applied or not; steer entries still undelivered in the lane stay queued,
6
- and any failure lands on ``state.error``."""
4
+ outcome never travels through settlement: ``applied()`` confirms the delivered
5
+ batch and settle consumes it; a batch still unconfirmed at settle returns to
6
+ the front of its lanes, ``Complete`` with an unconfirmed batch settles as an
7
+ error, and any failure lands on ``state.error``."""
7
8
 
8
9
  import asyncio
9
10
 
@@ -50,7 +51,7 @@ async def test_run_reject_lands_the_payload_on_state_error():
50
51
  )
51
52
  assert (await drv.res(seq))["type"] == "accepted"
52
53
  await drv.wait_status("error")
53
- assert queue_ids(drv.replica, "queue") == []
54
+ assert queue_ids(drv.replica, "queue") == ["m1"]
54
55
  assert drv.run["error"] == {
55
56
  "message": "payment required",
56
57
  "reason": "payment-required",
@@ -58,18 +59,19 @@ async def test_run_reject_lands_the_payload_on_state_error():
58
59
  }
59
60
 
60
61
 
61
- async def test_unapplied_run_end_settles_the_send_accepted():
62
- script = Script()
63
- async with run_host(script) as (drv, host):
64
- for name, outcome in [
65
- ("m1", lambda call: call.fail(RuntimeError("boom"))),
66
- ("m2", lambda call: call.finish(RunManager.Error(dispatch_queue=False))),
67
- ]:
68
- seq = drv.post("run/enqueue", add(name))
69
- outcome(await script.next_call())
62
+ async def test_unconfirmed_run_end_requeues_the_send():
63
+ for end in [
64
+ lambda call: call.fail(RuntimeError("boom")),
65
+ lambda call: call.finish(RunManager.Error(dispatch_queue=False)),
66
+ ]:
67
+ script = Script()
68
+ async with run_host(script) as (drv, host):
69
+ seq = drv.post("run/enqueue", add("m1"))
70
+ end(await script.next_call())
70
71
  assert (await drv.res(seq))["type"] == "accepted"
71
72
  await drv.wait_status("error")
72
- assert queue_ids(drv.replica, "queue") == []
73
+ assert queue_ids(drv.replica, "queue") == ["m1"]
74
+ assert queue_ids(drv.replica, "steerQueue") == []
73
75
 
74
76
 
75
77
  async def test_rewind_settles_before_the_reruns_end():
@@ -88,14 +90,14 @@ async def test_rewind_settles_before_the_reruns_end():
88
90
  await drv.wait_status("stopped")
89
91
 
90
92
 
91
- async def test_unapplied_run_end_consumes_the_delivered_batch():
93
+ async def test_unconfirmed_run_end_requeues_the_delivered_batch():
92
94
  script = Script()
93
95
  async with run_host(script) as (drv, host):
94
96
  drv.batch([("run/enqueue", add("m1")), ("run/enqueue", add("m2"))])
95
97
  call = await script.next_call()
96
98
  call.fail(RuntimeError("boom"))
97
99
  await drv.wait_status("error")
98
- assert queue_ids(drv.replica, "queue") == []
100
+ assert queue_ids(drv.replica, "queue") == ["m1", "m2"]
99
101
  assert drv.run["error"] == {"message": "boom"}
100
102
  await drv.command("run/steer", add("s1"), terminal=False)
101
103
  continued = await script.next_call()
@@ -104,7 +106,7 @@ async def test_unapplied_run_end_consumes_the_delivered_batch():
104
106
  assert [m["id"] for m in continued.ctx.steering.take()] == ["s1"]
105
107
 
106
108
 
107
- async def test_unapplied_run_end_consumes_taken_steered_items():
109
+ async def test_unconfirmed_run_end_requeues_taken_steered_items():
108
110
  script = Script()
109
111
  async with run_host(script) as (drv, host):
110
112
  drv.post("run/enqueue", add("m1"))
@@ -115,7 +117,7 @@ async def test_unapplied_run_end_consumes_taken_steered_items():
115
117
  call.ctx.steering.take()
116
118
  call.fail(RuntimeError("boom"))
117
119
  await drv.wait_status("error")
118
- assert queue_ids(drv.replica, "steerQueue") == []
120
+ assert queue_ids(drv.replica, "steerQueue") == ["s1"]
119
121
  assert queue_ids(drv.replica, "queue") == []
120
122
 
121
123
 
@@ -130,14 +132,17 @@ async def test_applied_removes_the_entries_for_good():
130
132
  assert queue_ids(drv.replica, "queue") == []
131
133
 
132
134
 
133
- async def test_complete_without_applied_consumes_the_dispatch():
135
+ async def test_complete_with_unconfirmed_batch_settles_as_error():
134
136
  script = Script()
135
137
  async with run_host(script) as (drv, host):
136
138
  seq = drv.post("run/enqueue", add("m1"))
137
139
  (await script.next_call()).finish(RunManager.Complete())
138
140
  assert (await drv.res(seq))["type"] == "accepted"
139
- await drv.wait_status("ready")
140
- assert drv.replica["runs"] == []
141
+ await drv.wait_status("error")
142
+ assert queue_ids(drv.replica, "queue") == ["m1"]
143
+ assert drv.run["error"] == {
144
+ "message": "run returned Complete with an unconfirmed batch"
145
+ }
141
146
  script.no_call()
142
147
 
143
148
 
@@ -165,9 +170,9 @@ async def test_stop_lands_while_a_steer_is_parked():
165
170
  await asyncio.wait_for(call.ctx.stop_requested.wait(), 5)
166
171
  call.finish(RunManager.Stop(dispatch_queue=False))
167
172
  await drv.wait_status("stopped")
168
- # The undelivered steer stays parked; the delivered dispatch is consumed.
173
+ # The undelivered steer stays parked; the unconfirmed dispatch requeues.
169
174
  assert queue_ids(drv.replica, "steerQueue") == ["s1"]
170
- assert queue_ids(drv.replica, "queue") == []
175
+ assert queue_ids(drv.replica, "queue") == ["m1"]
171
176
 
172
177
 
173
178
  async def test_rewind_edit_settles_at_its_dispatch():
@@ -195,7 +200,7 @@ async def test_rewind_rerun_failure_lands_on_state_error():
195
200
  assert drv.run["error"] == {"message": "payment required", "code": 402}
196
201
 
197
202
 
198
- async def test_task_cancellation_consumes_entries_and_settles_sends():
203
+ async def test_task_cancellation_requeues_entries_and_settles_sends():
199
204
  script = Script()
200
205
  async with run_host(script) as (drv, host):
201
206
  seq = drv.batch([("run/enqueue", add("m1")), ("run/enqueue", add("m2"))])
@@ -204,7 +209,7 @@ async def test_task_cancellation_consumes_entries_and_settles_sends():
204
209
  task.cancel()
205
210
  assert (await drv.res(seq))["type"] == "accepted"
206
211
  await drv.wait_status("stopped")
207
- assert queue_ids(drv.replica, "queue") == []
212
+ assert queue_ids(drv.replica, "queue") == ["m1", "m2"]
208
213
  assert task.cancelled()
209
214
 
210
215
 
@@ -336,3 +336,45 @@ async def test_same_tick_send_and_continue_dispatch_one_run_with_messages():
336
336
  continued.applied()
337
337
  continued.finish(RunManager.Complete())
338
338
  await drv.wait_status("ready")
339
+
340
+
341
+ async def test_stop_with_the_live_epoch_is_accepted():
342
+ script = Script()
343
+ async with run_host(script) as (drv, host):
344
+ drv.post("run/enqueue", add("m1"))
345
+ call = await script.next_call()
346
+ call.applied()
347
+ await drv.wait(lambda s: bool(s["runs"]) and s["runs"][0].get("epoch") == 1)
348
+ pending = await drv.stop({"epoch": 1}, terminal=False)
349
+ call.finish(RunManager.Stop(dispatch_queue=False))
350
+ assert (await drv.res(pending["seq"]))["type"] == "accepted"
351
+ await drv.wait_status("stopped")
352
+
353
+
354
+ async def test_stale_epoch_stop_cannot_end_a_later_leg():
355
+ script = Script()
356
+ async with run_host(script) as (drv, host):
357
+ drv.post("run/enqueue", add("m1"))
358
+ call = await script.next_call()
359
+ call.applied()
360
+ await drv.stop(terminal=False)
361
+ call.finish(RunManager.Stop(dispatch_queue=False))
362
+ await drv.wait_status("stopped")
363
+ await drv.command("run/enqueue", add("m2"), terminal=False)
364
+ leg2 = await script.next_call()
365
+ assert leg2.ctx.trigger == "stop-continue"
366
+ await drv.wait(lambda s: s["runs"][0].get("epoch") == 2)
367
+ assert_rejected(await drv.stop({"epoch": 1}), "wrong-state")
368
+ assert drv.replica["status"] == "running"
369
+ leg2.applied()
370
+ leg2.finish(RunManager.Complete())
371
+
372
+
373
+ async def test_stop_rejects_a_non_integer_epoch():
374
+ script = Script()
375
+ async with run_host(script) as (drv, host):
376
+ drv.post("run/enqueue", add("m1"))
377
+ call = await script.next_call()
378
+ call.applied()
379
+ assert_rejected(await drv.stop({"epoch": "1"}), "invalid-message")
380
+ call.finish(RunManager.Complete())
@@ -249,6 +249,7 @@ async def test_entry_keys_are_absent_when_empty():
249
249
  await drv.wait_status("running")
250
250
  assert set(drv.run.keys()) == {
251
251
  "runId",
252
+ "epoch",
252
253
  "status",
253
254
  "queue",
254
255
  "steerQueue",
@@ -258,4 +259,11 @@ async def test_entry_keys_are_absent_when_empty():
258
259
  await drv.wait(lambda s: "dispatching" not in s["runs"][0])
259
260
  call.fail(RuntimeError("boom"))
260
261
  await drv.wait_status("error")
261
- assert set(drv.run.keys()) == {"runId", "status", "queue", "steerQueue", "error"}
262
+ assert set(drv.run.keys()) == {
263
+ "runId",
264
+ "epoch",
265
+ "status",
266
+ "queue",
267
+ "steerQueue",
268
+ "error",
269
+ }