harness-sdk-python 0.7.0__tar.gz → 0.7.2__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.7.0 → harness_sdk_python-0.7.2}/.gitignore +1 -0
- {harness_sdk_python-0.7.0 → harness_sdk_python-0.7.2}/PKG-INFO +7 -1
- {harness_sdk_python-0.7.0 → harness_sdk_python-0.7.2}/pyproject.toml +8 -1
- {harness_sdk_python-0.7.0 → harness_sdk_python-0.7.2}/src/harness_sdk/run_manager.py +97 -75
- {harness_sdk_python-0.7.0 → harness_sdk_python-0.7.2}/tests/test_ack_visibility.py +26 -23
- {harness_sdk_python-0.7.0 → harness_sdk_python-0.7.2}/tests/test_batches.py +18 -3
- harness_sdk_python-0.7.2/tests/test_dispatching.py +123 -0
- {harness_sdk_python-0.7.0 → harness_sdk_python-0.7.2}/tests/test_facade.py +9 -2
- {harness_sdk_python-0.7.0 → harness_sdk_python-0.7.2}/tests/test_settle.py +15 -13
- {harness_sdk_python-0.7.0 → harness_sdk_python-0.7.2}/tests/test_stop_continue.py +2 -0
- {harness_sdk_python-0.7.0 → harness_sdk_python-0.7.2}/README.md +0 -0
- {harness_sdk_python-0.7.0 → harness_sdk_python-0.7.2}/src/harness_sdk/__init__.py +0 -0
- {harness_sdk_python-0.7.0 → harness_sdk_python-0.7.2}/src/harness_sdk/fenced_postgres.py +0 -0
- {harness_sdk_python-0.7.0 → harness_sdk_python-0.7.2}/src/harness_sdk/linear_thread.py +0 -0
- {harness_sdk_python-0.7.0 → harness_sdk_python-0.7.2}/tests/run_helpers.py +0 -0
- {harness_sdk_python-0.7.0 → harness_sdk_python-0.7.2}/tests/test_branch_anchor.py +0 -0
- {harness_sdk_python-0.7.0 → harness_sdk_python-0.7.2}/tests/test_edit_dispatched.py +0 -0
- {harness_sdk_python-0.7.0 → harness_sdk_python-0.7.2}/tests/test_edit_reload.py +0 -0
- {harness_sdk_python-0.7.0 → harness_sdk_python-0.7.2}/tests/test_enqueue.py +0 -0
- {harness_sdk_python-0.7.0 → harness_sdk_python-0.7.2}/tests/test_fenced_postgres.py +0 -0
- {harness_sdk_python-0.7.0 → harness_sdk_python-0.7.2}/tests/test_input_required.py +0 -0
- {harness_sdk_python-0.7.0 → harness_sdk_python-0.7.2}/tests/test_linear_thread.py +0 -0
- {harness_sdk_python-0.7.0 → harness_sdk_python-0.7.2}/tests/test_meta.py +0 -0
- {harness_sdk_python-0.7.0 → harness_sdk_python-0.7.2}/tests/test_outcomes.py +0 -0
- {harness_sdk_python-0.7.0 → harness_sdk_python-0.7.2}/tests/test_placement.py +0 -0
- {harness_sdk_python-0.7.0 → harness_sdk_python-0.7.2}/tests/test_prepare_hooks.py +0 -0
- {harness_sdk_python-0.7.0 → harness_sdk_python-0.7.2}/tests/test_rewind_during_run.py +0 -0
- {harness_sdk_python-0.7.0 → harness_sdk_python-0.7.2}/tests/test_run_leaf.py +0 -0
- {harness_sdk_python-0.7.0 → harness_sdk_python-0.7.2}/tests/test_steer.py +0 -0
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: harness-sdk-python
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.2
|
|
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
|
|
7
7
|
Requires-Python: <4.0,>=3.11
|
|
8
8
|
Requires-Dist: statewire<0.5,>=0.4.0
|
|
9
|
+
Provides-Extra: deepagents
|
|
10
|
+
Requires-Dist: deepagents>=0.6.12; extra == 'deepagents'
|
|
11
|
+
Requires-Dist: langchain-core>=0.3; extra == 'deepagents'
|
|
12
|
+
Requires-Dist: langchain-openai>=0.3; extra == 'deepagents'
|
|
13
|
+
Requires-Dist: langgraph>=1.0.0; extra == 'deepagents'
|
|
14
|
+
Requires-Dist: uvicorn[standard]>=0.30.0; extra == 'deepagents'
|
|
9
15
|
Provides-Extra: postgres
|
|
10
16
|
Requires-Dist: langgraph-checkpoint-postgres>=2.0.0; extra == 'postgres'
|
|
11
17
|
Description-Content-Type: text/markdown
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "harness-sdk-python"
|
|
3
|
-
version = "0.7.
|
|
3
|
+
version = "0.7.2"
|
|
4
4
|
description = "RunManager: the harness-sdk runs subsystem for Python Statewire hosts"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -9,6 +9,13 @@ dependencies = ["statewire>=0.4.0,<0.5"]
|
|
|
9
9
|
|
|
10
10
|
[project.optional-dependencies]
|
|
11
11
|
postgres = ["langgraph-checkpoint-postgres>=2.0.0"]
|
|
12
|
+
deepagents = [
|
|
13
|
+
"deepagents>=0.6.12",
|
|
14
|
+
"langgraph>=1.0.0",
|
|
15
|
+
"langchain-core>=0.3",
|
|
16
|
+
"langchain-openai>=0.3",
|
|
17
|
+
"uvicorn[standard]>=0.30.0",
|
|
18
|
+
]
|
|
12
19
|
|
|
13
20
|
[project.urls]
|
|
14
21
|
Repository = "https://github.com/assistant-ui/harness-sdk"
|
|
@@ -4,16 +4,20 @@ Implements the full state x command matrix from runs.mdx on a single
|
|
|
4
4
|
``run(ctx)`` executor entrypoint, per the RunManager design (d4987).
|
|
5
5
|
|
|
6
6
|
Commands stage entries into an intake list and schedule a drain; the drain
|
|
7
|
-
reduces the whole intake
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
7
|
+
reduces the whole intake in order, then takes exactly one action based on
|
|
8
|
+
status and net effect (interrupt, dispatch, or continue). A dispatchable send
|
|
9
|
+
appends straight into ``dispatching`` — it never transits a queue lane — and
|
|
10
|
+
everything staged there by the end of the drain dispatches as one run. Every
|
|
11
|
+
send acks and settles accepted with its state-visible insert (lane or
|
|
12
|
+
``dispatching``); edit, reload, and continue initiators ack at their dispatch
|
|
13
|
+
and settle when their run ends — accepted once the run has acked its
|
|
14
|
+
messages, rejected otherwise; ``run/stop`` awaits a future the drain resolves
|
|
15
|
+
once the in-flight run has ended.
|
|
16
|
+
|
|
17
|
+
``ctx.ack_messages()`` is bookkeeping: it clears ``dispatching`` and confirms
|
|
18
|
+
``runLeafMessageId``. A run that ends with entries still in ``dispatching``
|
|
19
|
+
reverts them by provenance: lane items to the front of their lane, direct
|
|
20
|
+
dispatches to the front of the queue, rewind replacements dropped.
|
|
17
21
|
"""
|
|
18
22
|
|
|
19
23
|
import asyncio
|
|
@@ -130,10 +134,9 @@ class _Rewind:
|
|
|
130
134
|
@dataclass
|
|
131
135
|
class _Effects:
|
|
132
136
|
steer_added: bool = False
|
|
133
|
-
new_added: bool = False
|
|
134
137
|
continue_requested: bool = False
|
|
135
138
|
continue_meta: Any = None
|
|
136
|
-
staged_sends: list[tuple[
|
|
139
|
+
staged_sends: list[tuple["asyncio.Future[Any]", Callable[[], None]]] = field(
|
|
137
140
|
default_factory=list
|
|
138
141
|
)
|
|
139
142
|
continues: list["asyncio.Future[Any]"] = field(default_factory=list)
|
|
@@ -193,13 +196,8 @@ class RunManager:
|
|
|
193
196
|
self._stop_reason: str | None = None
|
|
194
197
|
self._staged_rewinds: list[_Rewind] = []
|
|
195
198
|
self._dispatching: list[tuple[str, dict[str, Any]]] = []
|
|
196
|
-
|
|
197
|
-
# the command's effect (message-ack append, or the revert re-insert).
|
|
198
|
-
self._deferred_acks: dict[str, Callable[[], None]] = {}
|
|
199
|
-
self._dispatch_ack: Callable[[], None] | None = None
|
|
200
|
-
self._run_acked = True
|
|
199
|
+
self._run_acked = False
|
|
201
200
|
self._leaf_confirmed: str | None = None
|
|
202
|
-
self._send_futures: list["asyncio.Future[Any]"] = []
|
|
203
201
|
self._run_futures: list["asyncio.Future[Any]"] = []
|
|
204
202
|
self._input_requests: list[dict[str, Any]] = []
|
|
205
203
|
self._input_answers: dict[str, Any] = {}
|
|
@@ -244,6 +242,7 @@ class RunManager:
|
|
|
244
242
|
"status": "ready",
|
|
245
243
|
"queue": [],
|
|
246
244
|
"steerQueue": [],
|
|
245
|
+
"dispatching": [],
|
|
247
246
|
"error": None,
|
|
248
247
|
"runLeafMessageId": None,
|
|
249
248
|
}
|
|
@@ -293,7 +292,6 @@ class RunManager:
|
|
|
293
292
|
entries, self._intake = self._intake, []
|
|
294
293
|
outcome, self._outcome = self._outcome, None
|
|
295
294
|
fx = _Effects()
|
|
296
|
-
pre_empty = not self._lane_items("steerQueue") and not self._lane_items("queue")
|
|
297
295
|
for entry in entries:
|
|
298
296
|
self._apply(entry, fx)
|
|
299
297
|
if self._task is None:
|
|
@@ -306,31 +304,28 @@ class RunManager:
|
|
|
306
304
|
if self._staged_rewinds:
|
|
307
305
|
rewind = self._staged_rewinds.pop(0)
|
|
308
306
|
self._clear_input()
|
|
307
|
+
if self._dispatching:
|
|
308
|
+
self._revert_dispatching()
|
|
309
309
|
self._dispatch(
|
|
310
310
|
rewind.type,
|
|
311
311
|
rewind.messages,
|
|
312
312
|
rollback_to=rewind.rollback_to,
|
|
313
313
|
root_meta=rewind.root_meta,
|
|
314
314
|
)
|
|
315
|
-
|
|
315
|
+
rewind.ack()
|
|
316
316
|
if not rewind.future.done():
|
|
317
317
|
self._run_futures.append(rewind.future)
|
|
318
318
|
elif outcome is not None:
|
|
319
319
|
self._settle_outcome(outcome)
|
|
320
320
|
else:
|
|
321
|
-
self._idle_action(fx
|
|
321
|
+
self._idle_action(fx)
|
|
322
322
|
if self._task is not None and (self._staged_stops or self._staged_rewinds):
|
|
323
323
|
assert self._ctx is not None
|
|
324
324
|
self._ctx.stop_requested.set()
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
if
|
|
328
|
-
|
|
329
|
-
self._send_futures.append(future)
|
|
330
|
-
else:
|
|
331
|
-
ack()
|
|
332
|
-
if not future.done():
|
|
333
|
-
future.set_result(None)
|
|
325
|
+
for future, ack in fx.staged_sends:
|
|
326
|
+
ack()
|
|
327
|
+
if not future.done():
|
|
328
|
+
future.set_result(None)
|
|
334
329
|
for future in fx.continues:
|
|
335
330
|
if not future.done():
|
|
336
331
|
future.set_result(None)
|
|
@@ -365,19 +360,22 @@ class RunManager:
|
|
|
365
360
|
if result is not _PARKED and not entry.future.done():
|
|
366
361
|
entry.future.set_result(result)
|
|
367
362
|
|
|
368
|
-
def _idle_action(self, fx: _Effects
|
|
363
|
+
def _idle_action(self, fx: _Effects) -> None:
|
|
369
364
|
status = self._status()
|
|
370
365
|
if status == "ready":
|
|
371
|
-
self.
|
|
366
|
+
if self._dispatching:
|
|
367
|
+
self._dispatch_staged()
|
|
372
368
|
elif status in ("error", "stopped"):
|
|
373
369
|
if fx.continue_requested or fx.steer_added:
|
|
374
370
|
self._dispatch(self._continue_type(), [], root_meta=fx.continue_meta)
|
|
375
371
|
self._run_futures.extend(fx.continues)
|
|
376
372
|
fx.continues.clear()
|
|
377
|
-
elif
|
|
378
|
-
self.
|
|
373
|
+
elif self._dispatching:
|
|
374
|
+
self._dispatch_staged()
|
|
379
375
|
elif status == "input-required":
|
|
380
|
-
if
|
|
376
|
+
if self._dispatching:
|
|
377
|
+
self._dispatch_staged(input_outcomes=self._take_input_outcomes())
|
|
378
|
+
elif fx.steer_added and self._lane_items("steerQueue"):
|
|
381
379
|
self._pop_dispatchable(input_outcomes=self._take_input_outcomes())
|
|
382
380
|
elif self._input_requests and len(self._input_answers) == len(
|
|
383
381
|
self._input_requests
|
|
@@ -437,6 +435,7 @@ class RunManager:
|
|
|
437
435
|
if self._task is not None:
|
|
438
436
|
raise RuntimeError("a run is already in flight")
|
|
439
437
|
message_meta = {m["id"]: m.get("meta") for m in messages}
|
|
438
|
+
dispatching = list(messages)
|
|
440
439
|
messages = [
|
|
441
440
|
{k: v for k, v in message.items() if k != "meta"} for message in messages
|
|
442
441
|
]
|
|
@@ -450,6 +449,7 @@ class RunManager:
|
|
|
450
449
|
self._run_acked = False
|
|
451
450
|
entry = self._ensure_entry()
|
|
452
451
|
self._leaf_confirmed = plain(entry["runLeafMessageId"])
|
|
452
|
+
entry["dispatching"] = dispatching
|
|
453
453
|
entry["error"] = None
|
|
454
454
|
if messages:
|
|
455
455
|
self._dispatched_ids = tuple(m["id"] for m in messages)
|
|
@@ -490,18 +490,14 @@ class RunManager:
|
|
|
490
490
|
"run must return a RunManager outcome, got "
|
|
491
491
|
f"{type(outcome).__name__}"
|
|
492
492
|
)
|
|
493
|
-
if isinstance(
|
|
494
|
-
outcome, (RunManager.Complete, RunManager.InputRequired)
|
|
495
|
-
) and (self._dispatching or not self._run_acked):
|
|
496
|
-
raise RuntimeError(
|
|
497
|
-
"run settled without acking its messages (call ctx.ack_messages())"
|
|
498
|
-
)
|
|
499
493
|
except asyncio.CancelledError:
|
|
500
494
|
await self._pull_leaf()
|
|
501
495
|
self._settle(ctx)
|
|
502
496
|
self._set_status("stopped")
|
|
503
497
|
self._entry()["runId"] = None
|
|
504
|
-
self._settle_initiators(
|
|
498
|
+
self._settle_initiators(
|
|
499
|
+
None if self._run_acked else _reject("stopped", "run cancelled")
|
|
500
|
+
)
|
|
505
501
|
self._revert_dispatching()
|
|
506
502
|
self._idle.set()
|
|
507
503
|
raise # no drain, no freeze
|
|
@@ -511,16 +507,20 @@ class RunManager:
|
|
|
511
507
|
message = str(exc) or type(exc).__name__
|
|
512
508
|
if isinstance(exc, StatewireReject):
|
|
513
509
|
self._freeze(exc.message, exc.payload)
|
|
514
|
-
self._settle_initiators(exc)
|
|
510
|
+
self._settle_initiators(None if self._run_acked else exc)
|
|
515
511
|
else:
|
|
516
512
|
self._freeze(message)
|
|
517
|
-
self._settle_initiators(
|
|
513
|
+
self._settle_initiators(
|
|
514
|
+
None if self._run_acked else _reject("run-error", message)
|
|
515
|
+
)
|
|
518
516
|
self._revert_dispatching()
|
|
519
517
|
self._drain()
|
|
520
518
|
return
|
|
521
519
|
await self._pull_leaf()
|
|
522
520
|
self._settle(ctx)
|
|
523
|
-
if
|
|
521
|
+
if self._run_acked:
|
|
522
|
+
error = None
|
|
523
|
+
elif isinstance(outcome, RunManager.Error):
|
|
524
524
|
error = _reject("run-error", "run ended in error")
|
|
525
525
|
elif isinstance(outcome, RunManager.Stop):
|
|
526
526
|
error = _reject("stopped", "run stopped before the messages-ack")
|
|
@@ -532,8 +532,8 @@ class RunManager:
|
|
|
532
532
|
self._drain()
|
|
533
533
|
|
|
534
534
|
async def _pull_leaf(self) -> None:
|
|
535
|
-
# An unacked
|
|
536
|
-
if self.
|
|
535
|
+
# An end with unacked messages reverts instead of recording a leaf.
|
|
536
|
+
if plain(self._entry()["dispatching"]):
|
|
537
537
|
return
|
|
538
538
|
self._entry()["runLeafMessageId"] = await self._thread.get_leaf_message_id()
|
|
539
539
|
|
|
@@ -542,7 +542,6 @@ class RunManager:
|
|
|
542
542
|
self._ctx = None
|
|
543
543
|
self._task = None
|
|
544
544
|
self._dispatch_record = None
|
|
545
|
-
self._dispatch_ack = None
|
|
546
545
|
|
|
547
546
|
def _settle_initiators(self, error: StatewireReject | None) -> None:
|
|
548
547
|
futures, self._run_futures = self._run_futures, []
|
|
@@ -554,45 +553,65 @@ class RunManager:
|
|
|
554
553
|
else:
|
|
555
554
|
future.set_exception(error)
|
|
556
555
|
|
|
557
|
-
def _settle_sends(self) -> None:
|
|
558
|
-
futures, self._send_futures = self._send_futures, []
|
|
559
|
-
for future in futures:
|
|
560
|
-
if not future.done():
|
|
561
|
-
future.set_result(None)
|
|
562
|
-
|
|
563
556
|
def _ack_messages(self) -> None:
|
|
564
|
-
if self._dispatch_ack is not None:
|
|
565
|
-
self._dispatch_ack()
|
|
566
|
-
self._dispatch_ack = None
|
|
567
|
-
for _, item in self._dispatching:
|
|
568
|
-
ack = self._deferred_acks.pop(item["id"], None)
|
|
569
|
-
if ack is not None:
|
|
570
|
-
ack()
|
|
571
557
|
self._dispatching = []
|
|
558
|
+
self._entry()["dispatching"] = []
|
|
572
559
|
self._run_acked = True
|
|
573
560
|
self._leaf_confirmed = plain(self._entry()["runLeafMessageId"])
|
|
574
|
-
self._settle_sends()
|
|
575
|
-
self._settle_initiators(None)
|
|
576
561
|
|
|
577
562
|
def _revert_dispatching(self) -> None:
|
|
578
|
-
self._settle_sends()
|
|
579
563
|
taken, self._dispatching = self._dispatching, []
|
|
564
|
+
entry = self._ensure_entry()
|
|
565
|
+
entry["dispatching"] = []
|
|
580
566
|
if not taken:
|
|
581
567
|
return
|
|
582
|
-
entry = self._ensure_entry()
|
|
583
568
|
for lane in ("steerQueue", "queue"):
|
|
584
569
|
front = [item for taken_lane, item in taken if taken_lane == lane]
|
|
585
570
|
if front:
|
|
586
571
|
entry[lane] = front + self._lane_items(lane)
|
|
587
|
-
for _, item in taken:
|
|
588
|
-
ack = self._deferred_acks.pop(item["id"], None)
|
|
589
|
-
if ack is not None:
|
|
590
|
-
ack()
|
|
591
572
|
ids = {item["id"] for _, item in taken}
|
|
592
573
|
self._dispatched_ids = tuple(id for id in self._dispatched_ids if id not in ids)
|
|
593
574
|
if plain(entry["runLeafMessageId"]) in ids:
|
|
594
575
|
entry["runLeafMessageId"] = self._leaf_confirmed
|
|
595
576
|
|
|
577
|
+
def _dispatchable(self, lane: str) -> bool:
|
|
578
|
+
if self._task is not None or self._staged_rewinds:
|
|
579
|
+
return False
|
|
580
|
+
if self._dispatching:
|
|
581
|
+
return True
|
|
582
|
+
status = self._status()
|
|
583
|
+
if status == "ready":
|
|
584
|
+
return True
|
|
585
|
+
if status == "input-required":
|
|
586
|
+
return lane == "steerQueue"
|
|
587
|
+
if status in ("error", "stopped"):
|
|
588
|
+
return (
|
|
589
|
+
lane == "queue"
|
|
590
|
+
and not self._lane_items("queue")
|
|
591
|
+
and not self._lane_items("steerQueue")
|
|
592
|
+
)
|
|
593
|
+
return False
|
|
594
|
+
|
|
595
|
+
def _stage_dispatch(self, e: _Send) -> None:
|
|
596
|
+
assert e.message is not None
|
|
597
|
+
steer = self._lane_items("steerQueue") if e.lane == "steerQueue" else []
|
|
598
|
+
if len(self._dispatching) + len(steer) >= self._max_queued:
|
|
599
|
+
raise _reject("queue-full", f"queue is full ({self._max_queued} messages)")
|
|
600
|
+
if steer:
|
|
601
|
+
self._entry()["steerQueue"] = []
|
|
602
|
+
self._dispatching.extend(("steerQueue", item) for item in steer)
|
|
603
|
+
self._dispatching.append(("queue", self._stamped(e.message, e.meta)))
|
|
604
|
+
self._ensure_entry()["dispatching"] = [item for _, item in self._dispatching]
|
|
605
|
+
|
|
606
|
+
def _dispatch_staged(
|
|
607
|
+
self, *, input_outcomes: tuple[tuple[dict[str, Any], Any, Any], ...] = ()
|
|
608
|
+
) -> None:
|
|
609
|
+
self._dispatch(
|
|
610
|
+
"message-send",
|
|
611
|
+
[item for _, item in self._dispatching],
|
|
612
|
+
input_outcomes=input_outcomes,
|
|
613
|
+
)
|
|
614
|
+
|
|
596
615
|
def _pop_dispatchable(
|
|
597
616
|
self, *, input_outcomes: tuple[tuple[dict[str, Any], Any, Any], ...] = ()
|
|
598
617
|
) -> bool:
|
|
@@ -746,7 +765,7 @@ class RunManager:
|
|
|
746
765
|
return
|
|
747
766
|
if self._lane_of(e.anchor) is not None:
|
|
748
767
|
return
|
|
749
|
-
if e.anchor in self._dispatched_ids:
|
|
768
|
+
if e.anchor in self._dispatched_ids or self._is_dispatching(e.anchor):
|
|
750
769
|
return
|
|
751
770
|
if e.anchor_meta is None:
|
|
752
771
|
raise _reject("unknown-id", f"anchor {e.anchor} names nothing")
|
|
@@ -824,11 +843,13 @@ class RunManager:
|
|
|
824
843
|
if e.anchor is _ABSENT:
|
|
825
844
|
raise _reject("invalid-message", "anchorMessageId is required")
|
|
826
845
|
self._check_anchor(e)
|
|
827
|
-
self.
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
846
|
+
if self._dispatchable(e.lane):
|
|
847
|
+
self._stage_dispatch(e)
|
|
848
|
+
else:
|
|
849
|
+
self._insert_new(e.lane, e.message, e.params, e.meta)
|
|
850
|
+
if e.lane == "steerQueue":
|
|
851
|
+
fx.steer_added = True
|
|
852
|
+
fx.staged_sends.append((e.future, e.ack))
|
|
832
853
|
return _PARKED
|
|
833
854
|
|
|
834
855
|
def _apply_move(self, e: _Send, fx: _Effects) -> Any:
|
|
@@ -1219,6 +1240,7 @@ class RunManager:
|
|
|
1219
1240
|
entry = manager._entry()
|
|
1220
1241
|
entry["steerQueue"] = []
|
|
1221
1242
|
manager._dispatching.extend(("steerQueue", item) for item in items)
|
|
1243
|
+
entry["dispatching"] = list(plain(entry["dispatching"])) + items
|
|
1222
1244
|
if items:
|
|
1223
1245
|
entry["runLeafMessageId"] = items[-1]["id"]
|
|
1224
1246
|
self._ctx._message_meta.update(
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
"""Contract:
|
|
2
|
-
|
|
3
|
-
``
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
"""Contract: acks ride visibility. A send's covering ack only rides an
|
|
2
|
+
envelope whose state carries the command's effect — the item in a published
|
|
3
|
+
queue lane or in ``dispatching``. An idle send's insert lands in
|
|
4
|
+
``dispatching`` directly, so its ack rides the dispatch envelope and the queue
|
|
5
|
+
lane never holds the item; a rewind's ack rides its dispatch the same way."""
|
|
6
6
|
|
|
7
7
|
import copy
|
|
8
8
|
|
|
@@ -22,26 +22,28 @@ def covered_state(drv, seq):
|
|
|
22
22
|
raise AssertionError(f"no envelope covers seq {seq}")
|
|
23
23
|
|
|
24
24
|
|
|
25
|
-
def
|
|
25
|
+
def dispatching_ids(state):
|
|
26
26
|
runs = state.get("runs") or [{}]
|
|
27
|
-
return
|
|
28
|
-
*(m["id"] for m in state.get("messages", [])),
|
|
29
|
-
*(item["id"] for item in runs[0].get("queue", [])),
|
|
30
|
-
*(item["id"] for item in runs[0].get("steerQueue", [])),
|
|
31
|
-
}
|
|
27
|
+
return [item["id"] for item in runs[0].get("dispatching", [])]
|
|
32
28
|
|
|
33
29
|
|
|
34
|
-
|
|
30
|
+
def assert_never_queued(drv, message_id):
|
|
31
|
+
state: dict = {}
|
|
32
|
+
for env in copy.deepcopy(drv.envelopes):
|
|
33
|
+
apply_ops(state, env)
|
|
34
|
+
assert message_id not in queue_ids(state, "queue")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def test_idle_send_acks_with_its_dispatching_insert():
|
|
35
38
|
script = Script()
|
|
36
39
|
async with run_host(script) as (drv, host):
|
|
37
40
|
seq = drv.post("run/enqueue", add("m1"))
|
|
38
41
|
call = await script.next_call()
|
|
39
|
-
await drv.
|
|
40
|
-
assert
|
|
42
|
+
assert (await drv.res(seq))["type"] == "accepted"
|
|
43
|
+
assert dispatching_ids(covered_state(drv, seq)) == ["m1"]
|
|
44
|
+
assert_never_queued(drv, "m1")
|
|
41
45
|
host.live.state["messages"] = [msg("m1")]
|
|
42
46
|
call.ack()
|
|
43
|
-
assert (await drv.res(seq))["type"] == "accepted"
|
|
44
|
-
assert "m1" in lane_and_message_ids(covered_state(drv, seq))
|
|
45
47
|
call.finish(RunManager.Complete())
|
|
46
48
|
|
|
47
49
|
|
|
@@ -57,17 +59,19 @@ async def test_parked_send_acks_with_its_queue_insert():
|
|
|
57
59
|
call.finish(RunManager.Complete())
|
|
58
60
|
|
|
59
61
|
|
|
60
|
-
async def
|
|
62
|
+
async def test_unacked_run_end_reverts_the_direct_dispatch_to_the_queue():
|
|
61
63
|
script = Script()
|
|
62
64
|
async with run_host(script) as (drv, host):
|
|
63
65
|
seq = drv.post("run/enqueue", add("m1"))
|
|
64
66
|
call = await script.next_call()
|
|
65
|
-
call.fail(RuntimeError("boom"))
|
|
66
67
|
assert (await drv.res(seq))["type"] == "accepted"
|
|
67
|
-
assert
|
|
68
|
+
assert dispatching_ids(covered_state(drv, seq)) == ["m1"]
|
|
69
|
+
call.fail(RuntimeError("boom"))
|
|
70
|
+
await drv.wait_status("error")
|
|
71
|
+
assert queue_ids(drv.replica, "queue") == ["m1"]
|
|
68
72
|
|
|
69
73
|
|
|
70
|
-
async def
|
|
74
|
+
async def test_rewind_edit_acks_with_its_dispatch():
|
|
71
75
|
script = Script()
|
|
72
76
|
script.thread["u1"] = {"parentId": None, "role": "user", "isLeaf": True}
|
|
73
77
|
async with run_host(script, capabilities=("rewind",)) as (drv, host):
|
|
@@ -75,9 +79,8 @@ async def test_rewind_edit_acks_with_the_message_ack_append():
|
|
|
75
79
|
rerun = await script.next_call()
|
|
76
80
|
assert rerun.ctx.trigger == "message-edit"
|
|
77
81
|
await drv.wait_status("running")
|
|
78
|
-
assert
|
|
82
|
+
assert dispatching_ids(covered_state(drv, seq)) == ["u2"]
|
|
79
83
|
host.live.state["messages"] = [msg("u2")]
|
|
80
84
|
rerun.ack()
|
|
81
|
-
assert (await drv.res(seq))["type"] == "accepted"
|
|
82
|
-
assert "u2" in lane_and_message_ids(covered_state(drv, seq))
|
|
83
85
|
rerun.finish(RunManager.Complete())
|
|
86
|
+
assert (await drv.res(seq))["type"] == "accepted"
|
|
@@ -31,6 +31,21 @@ async def test_multi_steer_batch_places_all_and_dispatches_once():
|
|
|
31
31
|
assert (await drv.res(first + offset))["type"] == "accepted"
|
|
32
32
|
|
|
33
33
|
|
|
34
|
+
async def test_same_tick_sends_dispatch_as_one_run():
|
|
35
|
+
script = Script()
|
|
36
|
+
async with run_host(script) as (drv, host):
|
|
37
|
+
first = drv.batch(
|
|
38
|
+
[("run/enqueue", add("m1")), ("run/enqueue", add("m2", anchor="m1"))]
|
|
39
|
+
)
|
|
40
|
+
call = await script.next_call()
|
|
41
|
+
assert [m["id"] for m in call.ctx.messages] == ["m1", "m2"]
|
|
42
|
+
script.no_call()
|
|
43
|
+
assert (await drv.res(first))["type"] == "accepted"
|
|
44
|
+
assert (await drv.res(first + 1))["type"] == "accepted"
|
|
45
|
+
call.ack()
|
|
46
|
+
call.finish(RunManager.Complete())
|
|
47
|
+
|
|
48
|
+
|
|
34
49
|
async def test_steer_and_stop_in_one_batch_nets_to_stop():
|
|
35
50
|
script = Script()
|
|
36
51
|
async with run_host(script) as (drv, host):
|
|
@@ -92,7 +107,7 @@ async def test_handler_tail_staging_decides_in_its_own_envelope():
|
|
|
92
107
|
)
|
|
93
108
|
)
|
|
94
109
|
# The decide ran outside the batch segment: its ops flush in an
|
|
95
|
-
# envelope of their own
|
|
96
|
-
assert "ack"
|
|
97
|
-
call.ack()
|
|
110
|
+
# envelope of their own, carrying the send's covering ack.
|
|
111
|
+
assert dispatch_env.get("ack", 0) >= seq
|
|
98
112
|
assert (await drv.res(seq))["type"] == "accepted"
|
|
113
|
+
call.ack()
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Contract: replicated dispatch limbo. ``dispatching`` on the run entry is
|
|
2
|
+
the current dispatch's unacked messages — queue pops, steer-lane takes, and
|
|
3
|
+
rewind dispatches alike. A message item lives in exactly one of the lanes,
|
|
4
|
+
``dispatching``, or the thread, and every transition is single-envelope
|
|
5
|
+
atomic: dispatch moves it lane → ``dispatching``, the messages-ack clears
|
|
6
|
+
``dispatching`` as the canonical message appears, and an unacked run end
|
|
7
|
+
returns lane items to the front of their lane."""
|
|
8
|
+
|
|
9
|
+
import copy
|
|
10
|
+
|
|
11
|
+
from run_helpers import Script, add, msg, queue_ids, run_host
|
|
12
|
+
|
|
13
|
+
from harness_sdk import RunManager
|
|
14
|
+
from statewire_helpers import apply_ops
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def dispatching_ids(replica):
|
|
18
|
+
runs = replica["runs"]
|
|
19
|
+
return [item["id"] for item in runs[0]["dispatching"]] if runs else []
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def places(state, message_id):
|
|
23
|
+
entry = (state.get("runs") or [{}])[0]
|
|
24
|
+
found = {
|
|
25
|
+
name
|
|
26
|
+
for name in ("queue", "steerQueue", "dispatching")
|
|
27
|
+
if any(item["id"] == message_id for item in entry.get(name, []))
|
|
28
|
+
}
|
|
29
|
+
if any(m["id"] == message_id for m in state.get("messages", [])):
|
|
30
|
+
found.add("messages")
|
|
31
|
+
return found
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def residence(drv, message_id):
|
|
35
|
+
"""The item's place after each envelope, deduped: exactly one place per
|
|
36
|
+
envelope once it exists — the one-of-three invariant."""
|
|
37
|
+
state: dict = {}
|
|
38
|
+
trail: list[str] = []
|
|
39
|
+
for env in copy.deepcopy(drv.envelopes):
|
|
40
|
+
apply_ops(state, env)
|
|
41
|
+
found = places(state, message_id)
|
|
42
|
+
assert len(found) <= 1, found
|
|
43
|
+
if trail:
|
|
44
|
+
assert found, "item vanished between envelopes"
|
|
45
|
+
if found and (not trail or trail[-1] != next(iter(found))):
|
|
46
|
+
trail.append(next(iter(found)))
|
|
47
|
+
return trail
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
async def test_idle_send_dispatches_straight_into_dispatching():
|
|
51
|
+
script = Script()
|
|
52
|
+
async with run_host(script) as (drv, host):
|
|
53
|
+
drv.post("run/enqueue", add("m1"))
|
|
54
|
+
await script.next_call()
|
|
55
|
+
await drv.wait_status("running")
|
|
56
|
+
assert dispatching_ids(drv.replica) == ["m1"]
|
|
57
|
+
assert queue_ids(drv.replica, "queue") == []
|
|
58
|
+
assert residence(drv, "m1") == ["dispatching"]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def test_ack_clears_dispatching_with_the_message_append():
|
|
62
|
+
script = Script()
|
|
63
|
+
async with run_host(script) as (drv, host):
|
|
64
|
+
seq = drv.post("run/enqueue", add("m1"))
|
|
65
|
+
call = await script.next_call()
|
|
66
|
+
await drv.wait_status("running")
|
|
67
|
+
assert (await drv.res(seq))["type"] == "accepted"
|
|
68
|
+
host.live.state["messages"] = [msg("m1")]
|
|
69
|
+
call.ack()
|
|
70
|
+
await drv.wait(lambda s: dispatching_ids(s) == [])
|
|
71
|
+
assert residence(drv, "m1") == ["dispatching", "messages"]
|
|
72
|
+
call.finish(RunManager.Complete())
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def test_unacked_run_end_returns_the_item_to_the_lane_front():
|
|
76
|
+
script = Script()
|
|
77
|
+
async with run_host(script) as (drv, host):
|
|
78
|
+
drv.post("run/enqueue", add("m1"))
|
|
79
|
+
call = await script.next_call()
|
|
80
|
+
await drv.side("run/enqueue", add("m2"))
|
|
81
|
+
await drv.wait(lambda s: queue_ids(s, "queue") == ["m2"])
|
|
82
|
+
await drv.side("run/stop", {"runId": await drv.run_id()})
|
|
83
|
+
call.finish(RunManager.Stop(dispatch_queue=False))
|
|
84
|
+
await drv.wait_status("stopped")
|
|
85
|
+
assert queue_ids(drv.replica, "queue") == ["m1", "m2"]
|
|
86
|
+
assert dispatching_ids(drv.replica) == []
|
|
87
|
+
assert residence(drv, "m1") == ["dispatching", "queue"]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
async def test_steering_take_moves_the_lane_into_dispatching():
|
|
91
|
+
script = Script()
|
|
92
|
+
async with run_host(script) as (drv, host):
|
|
93
|
+
drv.post("run/enqueue", add("m1"))
|
|
94
|
+
call = await script.next_call()
|
|
95
|
+
host.live.state["messages"] = [msg("m1")]
|
|
96
|
+
call.ack()
|
|
97
|
+
await drv.command("run/steer", add("s1"), terminal=False)
|
|
98
|
+
call.ctx.steering.take()
|
|
99
|
+
await drv.wait(lambda s: dispatching_ids(s) == ["s1"])
|
|
100
|
+
assert queue_ids(drv.replica, "steerQueue") == []
|
|
101
|
+
assert residence(drv, "s1") == ["steerQueue", "dispatching"]
|
|
102
|
+
host.live.state["messages"] = [msg("m1"), msg("s1")]
|
|
103
|
+
call.ack()
|
|
104
|
+
await drv.wait(lambda s: dispatching_ids(s) == [])
|
|
105
|
+
assert residence(drv, "s1") == ["steerQueue", "dispatching", "messages"]
|
|
106
|
+
call.finish(RunManager.Complete())
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
async def test_edit_replacement_rides_dispatching_until_ack():
|
|
110
|
+
script = Script()
|
|
111
|
+
script.thread["u1"] = {"parentId": None, "role": "user", "isLeaf": True}
|
|
112
|
+
async with run_host(script, capabilities=("rewind",)) as (drv, host):
|
|
113
|
+
seq = drv.post("run/edit", {"sourceId": "u1", "message": msg("u2")})
|
|
114
|
+
rerun = await script.next_call()
|
|
115
|
+
assert rerun.ctx.trigger == "message-edit"
|
|
116
|
+
await drv.wait_status("running")
|
|
117
|
+
assert dispatching_ids(drv.replica) == ["u2"]
|
|
118
|
+
host.live.state["messages"] = [msg("u2")]
|
|
119
|
+
rerun.ack()
|
|
120
|
+
await drv.wait(lambda s: dispatching_ids(s) == [])
|
|
121
|
+
assert residence(drv, "u2") == ["dispatching", "messages"]
|
|
122
|
+
rerun.finish(RunManager.Complete())
|
|
123
|
+
assert (await drv.res(seq))["type"] == "accepted"
|
|
@@ -8,7 +8,7 @@ import asyncio
|
|
|
8
8
|
|
|
9
9
|
from run_helpers import Script
|
|
10
10
|
from statewire_helpers import statewire_client
|
|
11
|
-
from test_assistant_transport import _PostStream, data_stream_frames
|
|
11
|
+
from test_assistant_transport import _PostStream, data_stream_frames, state_ops
|
|
12
12
|
|
|
13
13
|
from statewire import AssistantTransport, StatewireReject, command
|
|
14
14
|
|
|
@@ -63,13 +63,20 @@ async def test_chat_holds_until_the_entity_run_settles_then_eofs():
|
|
|
63
63
|
assert stream.status == 200
|
|
64
64
|
call = await script.next_call()
|
|
65
65
|
assert call.ctx.trigger == "message-send"
|
|
66
|
+
instance = (await app.state.pinned_host.directory.get("t1")).instance
|
|
67
|
+
instance.state["messages"] = [dict(call.ctx.messages[0])]
|
|
66
68
|
call.ack()
|
|
67
69
|
await asyncio.sleep(0.05)
|
|
68
70
|
assert stream._task is not None and not stream._task.done()
|
|
69
71
|
call.finish(RunManager.Complete())
|
|
70
72
|
tail = await stream.until_closed()
|
|
71
73
|
frames = data_stream_frames(tail.decode())
|
|
72
|
-
assert all(prefix == "aui-state" for prefix, _ in frames)
|
|
74
|
+
assert frames and all(prefix == "aui-state" for prefix, _ in frames)
|
|
75
|
+
ops = state_ops(frames)
|
|
76
|
+
assert ops[0]["type"] == "set" and ops[0]["path"] == []
|
|
77
|
+
assert [m["id"] for m in ops[0]["value"]["messages"]] == [
|
|
78
|
+
call.ctx.messages[0]["id"]
|
|
79
|
+
]
|
|
73
80
|
|
|
74
81
|
|
|
75
82
|
async def test_chat_run_reject_is_one_error_frame_then_eof():
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
"""Contract: command settlement.
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
1
|
+
"""Contract: command settlement. Every send settles accepted with its
|
|
2
|
+
state-visible insert — a lane entry or a direct ``dispatching`` entry. Edit,
|
|
3
|
+
reload, and continue settle when their run ends: accepted once the run has
|
|
4
|
+
acked its messages, rejected (``stopped``, ``run-error``, or the failure)
|
|
5
|
+
otherwise. A run that ends with unacked entries returns lane-origin items to
|
|
6
|
+
the front of their lane, direct dispatches to the front of the queue, and
|
|
7
|
+
lands any failure on ``state.error``."""
|
|
8
8
|
|
|
9
9
|
import asyncio
|
|
10
10
|
|
|
@@ -132,15 +132,17 @@ async def test_ack_removes_the_entries_for_good():
|
|
|
132
132
|
assert queue_ids(drv.replica, "queue") == []
|
|
133
133
|
|
|
134
134
|
|
|
135
|
-
async def
|
|
135
|
+
async def test_complete_without_ack_reverts_and_redispatches():
|
|
136
136
|
script = Script()
|
|
137
137
|
async with run_host(script) as (drv, host):
|
|
138
138
|
seq = drv.post("run/enqueue", add("m1"))
|
|
139
139
|
(await script.next_call()).finish(RunManager.Complete())
|
|
140
|
-
await
|
|
141
|
-
assert "
|
|
142
|
-
assert queue_ids(drv.replica, "queue") == ["m1"]
|
|
140
|
+
redo = await script.next_call()
|
|
141
|
+
assert [m["id"] for m in redo.ctx.messages] == ["m1"]
|
|
143
142
|
assert (await drv.res(seq))["type"] == "accepted"
|
|
143
|
+
redo.ack()
|
|
144
|
+
redo.finish(RunManager.Complete())
|
|
145
|
+
await drv.wait_status("ready")
|
|
144
146
|
|
|
145
147
|
|
|
146
148
|
async def test_dequeue_removes_a_parked_entry():
|
|
@@ -172,7 +174,7 @@ async def test_stop_lands_while_a_steer_is_parked():
|
|
|
172
174
|
assert queue_ids(drv.replica, "queue") == ["m1"]
|
|
173
175
|
|
|
174
176
|
|
|
175
|
-
async def
|
|
177
|
+
async def test_rewind_edit_settles_at_the_reruns_end():
|
|
176
178
|
script = Script()
|
|
177
179
|
script.thread["u1"] = {"parentId": None, "role": "user", "isLeaf": True}
|
|
178
180
|
async with run_host(script, capabilities=("rewind",)) as (drv, host):
|
|
@@ -180,8 +182,8 @@ async def test_rewind_edit_settles_at_the_reruns_ack():
|
|
|
180
182
|
rerun = await script.next_call()
|
|
181
183
|
assert rerun.ctx.trigger == "message-edit"
|
|
182
184
|
rerun.ack()
|
|
183
|
-
assert (await drv.res(seq))["type"] == "accepted"
|
|
184
185
|
rerun.finish(RunManager.Complete())
|
|
186
|
+
assert (await drv.res(seq))["type"] == "accepted"
|
|
185
187
|
|
|
186
188
|
|
|
187
189
|
async def test_rewind_edit_failure_settles_with_the_payload():
|
|
@@ -209,6 +209,7 @@ async def test_bare_continue_with_incomplete_continuation(end, continue_type):
|
|
|
209
209
|
assert continued.ctx.messages == ()
|
|
210
210
|
assert not continued.ctx.has_rollback
|
|
211
211
|
continued.ack()
|
|
212
|
+
continued.finish(RunManager.Complete())
|
|
212
213
|
assert (await drv.res(res["seq"]))["type"] == "accepted"
|
|
213
214
|
|
|
214
215
|
|
|
@@ -235,6 +236,7 @@ async def test_continue_with_steer_lane_needs_no_capability():
|
|
|
235
236
|
assert continued.ctx.trigger == "error-continue"
|
|
236
237
|
assert [m["id"] for m in continued.ctx.steering.take()] == ["s1"]
|
|
237
238
|
continued.ack()
|
|
239
|
+
continued.finish(RunManager.Complete())
|
|
238
240
|
assert (await drv.res(res["seq"]))["type"] == "accepted"
|
|
239
241
|
|
|
240
242
|
|
|
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
|