harness-sdk-python 0.6.0__tar.gz → 0.7.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.1}/.gitignore +2 -0
- {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.1}/PKG-INFO +6 -1
- {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.1}/pyproject.toml +7 -1
- {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.1}/src/harness_sdk/fenced_postgres.py +58 -18
- {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.1}/src/harness_sdk/run_manager.py +34 -13
- {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.1}/tests/run_helpers.py +47 -11
- harness_sdk_python-0.7.1/tests/test_ack_visibility.py +83 -0
- {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.1}/tests/test_batches.py +12 -11
- {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.1}/tests/test_branch_anchor.py +12 -17
- harness_sdk_python-0.7.1/tests/test_dispatching.py +123 -0
- {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.1}/tests/test_edit_dispatched.py +14 -23
- {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.1}/tests/test_edit_reload.py +19 -28
- {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.1}/tests/test_enqueue.py +27 -29
- {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.1}/tests/test_fenced_postgres.py +64 -0
- {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.1}/tests/test_input_required.py +16 -18
- {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.1}/tests/test_meta.py +16 -22
- {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.1}/tests/test_outcomes.py +14 -13
- {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.1}/tests/test_placement.py +2 -2
- {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.1}/tests/test_prepare_hooks.py +11 -14
- {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.1}/tests/test_rewind_during_run.py +13 -27
- {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.1}/tests/test_run_leaf.py +14 -14
- {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.1}/tests/test_settle.py +36 -49
- {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.1}/tests/test_steer.py +16 -12
- {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.1}/tests/test_stop_continue.py +28 -24
- {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.1}/README.md +0 -0
- {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.1}/src/harness_sdk/__init__.py +0 -0
- {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.1}/src/harness_sdk/linear_thread.py +0 -0
- {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.1}/tests/test_facade.py +0 -0
- {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.1}/tests/test_linear_thread.py +0 -0
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: harness-sdk-python
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.1
|
|
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: langgraph>=1.0.0; extra == 'deepagents'
|
|
13
|
+
Requires-Dist: uvicorn[standard]>=0.30.0; extra == 'deepagents'
|
|
9
14
|
Provides-Extra: postgres
|
|
10
15
|
Requires-Dist: langgraph-checkpoint-postgres>=2.0.0; extra == 'postgres'
|
|
11
16
|
Description-Content-Type: text/markdown
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "harness-sdk-python"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.7.1"
|
|
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,12 @@ 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
|
+
"uvicorn[standard]>=0.30.0",
|
|
17
|
+
]
|
|
12
18
|
|
|
13
19
|
[project.urls]
|
|
14
20
|
Repository = "https://github.com/assistant-ui/harness-sdk"
|
|
@@ -7,7 +7,7 @@ try:
|
|
|
7
7
|
from langchain_core.runnables import RunnableConfig
|
|
8
8
|
from langgraph.checkpoint.base import SerializerProtocol
|
|
9
9
|
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver, _ainternal
|
|
10
|
-
from psycopg import AsyncPipeline, errors
|
|
10
|
+
from psycopg import AsyncConnection, AsyncPipeline, errors
|
|
11
11
|
from psycopg.pq import TransactionStatus
|
|
12
12
|
from psycopg.cursor_async import AsyncCursor
|
|
13
13
|
from psycopg.rows import DictRow, dict_row
|
|
@@ -126,6 +126,57 @@ class AsyncFencedPostgresSaver(AsyncPostgresSaver):
|
|
|
126
126
|
self.on_fence_lost = on_fence_lost
|
|
127
127
|
self._fence_lost_notified = False
|
|
128
128
|
|
|
129
|
+
@staticmethod
|
|
130
|
+
async def _validate_and_bump(
|
|
131
|
+
cur: "AsyncCursor[DictRow]",
|
|
132
|
+
thread_id: str,
|
|
133
|
+
*,
|
|
134
|
+
create_fence_table: bool,
|
|
135
|
+
fence_table: str,
|
|
136
|
+
) -> int:
|
|
137
|
+
if create_fence_table:
|
|
138
|
+
await cur.execute(_create_sql(fence_table))
|
|
139
|
+
await cur.execute(_create_fn_sql(fence_table))
|
|
140
|
+
else:
|
|
141
|
+
await cur.execute(
|
|
142
|
+
"SELECT to_regclass(%s) AS tbl, to_regprocedure(%s) AS fn",
|
|
143
|
+
(fence_table, f"{_assert_fn(fence_table)}(text, bigint)"),
|
|
144
|
+
)
|
|
145
|
+
row = await cur.fetchone()
|
|
146
|
+
if row["tbl"] is None:
|
|
147
|
+
raise _missing_fence_error(f"fence table {fence_table!r}")
|
|
148
|
+
if row["fn"] is None:
|
|
149
|
+
raise _missing_fence_error(
|
|
150
|
+
f"fence function {_assert_fn(fence_table)!r}"
|
|
151
|
+
)
|
|
152
|
+
await cur.execute(_bump_sql(fence_table), (thread_id,))
|
|
153
|
+
return (await cur.fetchone())["epoch"]
|
|
154
|
+
|
|
155
|
+
@classmethod
|
|
156
|
+
async def bump_epoch(
|
|
157
|
+
cls,
|
|
158
|
+
conn: "AsyncConnection[Any]",
|
|
159
|
+
thread_id: str,
|
|
160
|
+
*,
|
|
161
|
+
create_fence_table: bool,
|
|
162
|
+
fence_table: str = DEFAULT_FENCE_TABLE,
|
|
163
|
+
) -> int:
|
|
164
|
+
"""Bump ``thread_id``'s epoch inside the caller's open transaction; the epoch is effective only once the caller commits, which must happen before it is used for writes."""
|
|
165
|
+
_require_thread_id(thread_id)
|
|
166
|
+
_require_fence_table(fence_table)
|
|
167
|
+
if conn.info.transaction_status != TransactionStatus.INTRANS:
|
|
168
|
+
raise RuntimeError(
|
|
169
|
+
"bump_epoch requires a connection with a transaction in "
|
|
170
|
+
"progress; use acquire for idle connections"
|
|
171
|
+
)
|
|
172
|
+
async with conn.cursor(binary=True, row_factory=dict_row) as cur:
|
|
173
|
+
return await cls._validate_and_bump(
|
|
174
|
+
cur,
|
|
175
|
+
thread_id,
|
|
176
|
+
create_fence_table=create_fence_table,
|
|
177
|
+
fence_table=fence_table,
|
|
178
|
+
)
|
|
179
|
+
|
|
129
180
|
@classmethod
|
|
130
181
|
async def acquire(
|
|
131
182
|
cls,
|
|
@@ -149,23 +200,12 @@ class AsyncFencedPostgresSaver(AsyncPostgresSaver):
|
|
|
149
200
|
c.transaction(),
|
|
150
201
|
c.cursor(binary=True, row_factory=dict_row) as cur,
|
|
151
202
|
):
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
(fence_table, f"{_assert_fn(fence_table)}(text, bigint)"),
|
|
159
|
-
)
|
|
160
|
-
row = await cur.fetchone()
|
|
161
|
-
if row["tbl"] is None:
|
|
162
|
-
raise _missing_fence_error(f"fence table {fence_table!r}")
|
|
163
|
-
if row["fn"] is None:
|
|
164
|
-
raise _missing_fence_error(
|
|
165
|
-
f"fence function {_assert_fn(fence_table)!r}"
|
|
166
|
-
)
|
|
167
|
-
await cur.execute(_bump_sql(fence_table), (thread_id,))
|
|
168
|
-
epoch = (await cur.fetchone())["epoch"]
|
|
203
|
+
epoch = await cls._validate_and_bump(
|
|
204
|
+
cur,
|
|
205
|
+
thread_id,
|
|
206
|
+
create_fence_table=create_fence_table,
|
|
207
|
+
fence_table=fence_table,
|
|
208
|
+
)
|
|
169
209
|
if pipe is not None:
|
|
170
210
|
await pipe.sync()
|
|
171
211
|
return cls(
|
|
@@ -125,7 +125,6 @@ class _Rewind:
|
|
|
125
125
|
ack: Callable[[], None]
|
|
126
126
|
future: "asyncio.Future[Any]"
|
|
127
127
|
root_meta: Any = None
|
|
128
|
-
acked: bool = False
|
|
129
128
|
|
|
130
129
|
|
|
131
130
|
@dataclass
|
|
@@ -134,7 +133,9 @@ class _Effects:
|
|
|
134
133
|
new_added: bool = False
|
|
135
134
|
continue_requested: bool = False
|
|
136
135
|
continue_meta: Any = None
|
|
137
|
-
staged_sends: list[tuple[str, "asyncio.Future[Any]"]] = field(
|
|
136
|
+
staged_sends: list[tuple[str, "asyncio.Future[Any]", Callable[[], None]]] = field(
|
|
137
|
+
default_factory=list
|
|
138
|
+
)
|
|
138
139
|
continues: list["asyncio.Future[Any]"] = field(default_factory=list)
|
|
139
140
|
|
|
140
141
|
|
|
@@ -192,6 +193,10 @@ class RunManager:
|
|
|
192
193
|
self._stop_reason: str | None = None
|
|
193
194
|
self._staged_rewinds: list[_Rewind] = []
|
|
194
195
|
self._dispatching: list[tuple[str, dict[str, Any]]] = []
|
|
196
|
+
# Deferred covering acks: they only ride an envelope whose state shows
|
|
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
|
|
195
200
|
self._run_acked = True
|
|
196
201
|
self._leaf_confirmed: str | None = None
|
|
197
202
|
self._send_futures: list["asyncio.Future[Any]"] = []
|
|
@@ -239,6 +244,7 @@ class RunManager:
|
|
|
239
244
|
"status": "ready",
|
|
240
245
|
"queue": [],
|
|
241
246
|
"steerQueue": [],
|
|
247
|
+
"dispatching": [],
|
|
242
248
|
"error": None,
|
|
243
249
|
"runLeafMessageId": None,
|
|
244
250
|
}
|
|
@@ -307,7 +313,7 @@ class RunManager:
|
|
|
307
313
|
rollback_to=rewind.rollback_to,
|
|
308
314
|
root_meta=rewind.root_meta,
|
|
309
315
|
)
|
|
310
|
-
rewind.ack
|
|
316
|
+
self._dispatch_ack = rewind.ack
|
|
311
317
|
if not rewind.future.done():
|
|
312
318
|
self._run_futures.append(rewind.future)
|
|
313
319
|
elif outcome is not None:
|
|
@@ -317,16 +323,15 @@ class RunManager:
|
|
|
317
323
|
if self._task is not None and (self._staged_stops or self._staged_rewinds):
|
|
318
324
|
assert self._ctx is not None
|
|
319
325
|
self._ctx.stop_requested.set()
|
|
320
|
-
for rewind in self._staged_rewinds:
|
|
321
|
-
if not rewind.acked:
|
|
322
|
-
rewind.ack()
|
|
323
|
-
rewind.acked = True
|
|
324
326
|
dispatching = {item["id"] for _, item in self._dispatching}
|
|
325
|
-
for message_id, future in fx.staged_sends:
|
|
327
|
+
for message_id, future, ack in fx.staged_sends:
|
|
326
328
|
if message_id in dispatching:
|
|
329
|
+
self._deferred_acks[message_id] = ack
|
|
327
330
|
self._send_futures.append(future)
|
|
328
|
-
|
|
329
|
-
|
|
331
|
+
else:
|
|
332
|
+
ack()
|
|
333
|
+
if not future.done():
|
|
334
|
+
future.set_result(None)
|
|
330
335
|
for future in fx.continues:
|
|
331
336
|
if not future.done():
|
|
332
337
|
future.set_result(None)
|
|
@@ -433,6 +438,7 @@ class RunManager:
|
|
|
433
438
|
if self._task is not None:
|
|
434
439
|
raise RuntimeError("a run is already in flight")
|
|
435
440
|
message_meta = {m["id"]: m.get("meta") for m in messages}
|
|
441
|
+
dispatching = list(messages)
|
|
436
442
|
messages = [
|
|
437
443
|
{k: v for k, v in message.items() if k != "meta"} for message in messages
|
|
438
444
|
]
|
|
@@ -446,6 +452,7 @@ class RunManager:
|
|
|
446
452
|
self._run_acked = False
|
|
447
453
|
entry = self._ensure_entry()
|
|
448
454
|
self._leaf_confirmed = plain(entry["runLeafMessageId"])
|
|
455
|
+
entry["dispatching"] = dispatching
|
|
449
456
|
entry["error"] = None
|
|
450
457
|
if messages:
|
|
451
458
|
self._dispatched_ids = tuple(m["id"] for m in messages)
|
|
@@ -538,6 +545,7 @@ class RunManager:
|
|
|
538
545
|
self._ctx = None
|
|
539
546
|
self._task = None
|
|
540
547
|
self._dispatch_record = None
|
|
548
|
+
self._dispatch_ack = None
|
|
541
549
|
|
|
542
550
|
def _settle_initiators(self, error: StatewireReject | None) -> None:
|
|
543
551
|
futures, self._run_futures = self._run_futures, []
|
|
@@ -556,7 +564,15 @@ class RunManager:
|
|
|
556
564
|
future.set_result(None)
|
|
557
565
|
|
|
558
566
|
def _ack_messages(self) -> None:
|
|
567
|
+
if self._dispatch_ack is not None:
|
|
568
|
+
self._dispatch_ack()
|
|
569
|
+
self._dispatch_ack = None
|
|
570
|
+
for _, item in self._dispatching:
|
|
571
|
+
ack = self._deferred_acks.pop(item["id"], None)
|
|
572
|
+
if ack is not None:
|
|
573
|
+
ack()
|
|
559
574
|
self._dispatching = []
|
|
575
|
+
self._entry()["dispatching"] = []
|
|
560
576
|
self._run_acked = True
|
|
561
577
|
self._leaf_confirmed = plain(self._entry()["runLeafMessageId"])
|
|
562
578
|
self._settle_sends()
|
|
@@ -565,13 +581,18 @@ class RunManager:
|
|
|
565
581
|
def _revert_dispatching(self) -> None:
|
|
566
582
|
self._settle_sends()
|
|
567
583
|
taken, self._dispatching = self._dispatching, []
|
|
584
|
+
entry = self._ensure_entry()
|
|
585
|
+
entry["dispatching"] = []
|
|
568
586
|
if not taken:
|
|
569
587
|
return
|
|
570
|
-
entry = self._ensure_entry()
|
|
571
588
|
for lane in ("steerQueue", "queue"):
|
|
572
589
|
front = [item for taken_lane, item in taken if taken_lane == lane]
|
|
573
590
|
if front:
|
|
574
591
|
entry[lane] = front + self._lane_items(lane)
|
|
592
|
+
for _, item in taken:
|
|
593
|
+
ack = self._deferred_acks.pop(item["id"], None)
|
|
594
|
+
if ack is not None:
|
|
595
|
+
ack()
|
|
575
596
|
ids = {item["id"] for _, item in taken}
|
|
576
597
|
self._dispatched_ids = tuple(id for id in self._dispatched_ids if id not in ids)
|
|
577
598
|
if plain(entry["runLeafMessageId"]) in ids:
|
|
@@ -809,8 +830,7 @@ class RunManager:
|
|
|
809
830
|
raise _reject("invalid-message", "anchorMessageId is required")
|
|
810
831
|
self._check_anchor(e)
|
|
811
832
|
self._insert_new(e.lane, e.message, e.params, e.meta)
|
|
812
|
-
fx.staged_sends.append((e.message_id, e.future))
|
|
813
|
-
e.ack()
|
|
833
|
+
fx.staged_sends.append((e.message_id, e.future, e.ack))
|
|
814
834
|
fx.new_added = True
|
|
815
835
|
if e.lane == "steerQueue":
|
|
816
836
|
fx.steer_added = True
|
|
@@ -1204,6 +1224,7 @@ class RunManager:
|
|
|
1204
1224
|
entry = manager._entry()
|
|
1205
1225
|
entry["steerQueue"] = []
|
|
1206
1226
|
manager._dispatching.extend(("steerQueue", item) for item in items)
|
|
1227
|
+
entry["dispatching"] = list(plain(entry["dispatching"])) + items
|
|
1207
1228
|
if items:
|
|
1208
1229
|
entry["runLeafMessageId"] = items[-1]["id"]
|
|
1209
1230
|
self._ctx._message_meta.update(
|
|
@@ -5,7 +5,7 @@ from dataclasses import dataclass
|
|
|
5
5
|
from typing import Any
|
|
6
6
|
|
|
7
7
|
from statewire import Statewire, command
|
|
8
|
-
from statewire_helpers import apply_ops, post_command, statewire_client, stream_of
|
|
8
|
+
from statewire_helpers import apply_ops, attach, post_command, statewire_client, stream_of
|
|
9
9
|
|
|
10
10
|
from harness_sdk import RunManager
|
|
11
11
|
|
|
@@ -127,20 +127,47 @@ class RunDriver:
|
|
|
127
127
|
"""One SSE-attached statewire client: sends commands, mirrors the state
|
|
128
128
|
replica from ops, and returns each command's settled response."""
|
|
129
129
|
|
|
130
|
-
def __init__(self, client, stream) -> None:
|
|
130
|
+
def __init__(self, client, stream, app=None) -> None:
|
|
131
131
|
self._client = client
|
|
132
132
|
self._stream = stream
|
|
133
|
+
self._app = app
|
|
134
|
+
self._side_seqs: dict[str, int] = {}
|
|
133
135
|
self._seq = 0
|
|
134
136
|
self._acked = 0
|
|
135
137
|
self._res: dict[int, list[dict[str, Any]]] = {}
|
|
138
|
+
self._posts: list["asyncio.Task[None]"] = []
|
|
136
139
|
self.replica: dict[str, Any] = {}
|
|
137
140
|
self.envelopes: list[dict[str, Any]] = []
|
|
138
141
|
|
|
142
|
+
def _spawn(self, body: dict[str, Any], seq: int) -> None:
|
|
143
|
+
async def _post() -> None:
|
|
144
|
+
response = await post_command(self._client, body, seq=seq)
|
|
145
|
+
assert response.status_code == 200, response.text
|
|
146
|
+
|
|
147
|
+
self._posts.append(asyncio.create_task(_post()))
|
|
148
|
+
|
|
149
|
+
def post(self, method: str, params: Any = None) -> int:
|
|
150
|
+
"""Background POST for a command whose receipt is held until its effect
|
|
151
|
+
publishes (a dispatching send or rewind); settle via ``res(seq)``."""
|
|
152
|
+
self._seq += 1
|
|
153
|
+
body = {"method": method, "params": [] if params is None else [params]}
|
|
154
|
+
self._spawn(body, self._seq)
|
|
155
|
+
return self._seq
|
|
156
|
+
|
|
157
|
+
async def side(self, method: str, params: Any = None, *, client_id: str = "c2"):
|
|
158
|
+
"""A second client's command: its own seq stream stays admissible while
|
|
159
|
+
this client's receipt is deferred; its verdicts ride the other stream."""
|
|
160
|
+
await attach(self._app, client_id=client_id)
|
|
161
|
+
seq = self._side_seqs[client_id] = self._side_seqs.get(client_id, 0) + 1
|
|
162
|
+
body = {"method": method, "params": [] if params is None else [params]}
|
|
163
|
+
response = await post_command(self._client, body, seq=seq, client_id=client_id)
|
|
164
|
+
assert response.status_code == 200, response.text
|
|
165
|
+
|
|
139
166
|
async def pump(self, timeout: float = 5) -> None:
|
|
140
167
|
env = await self._stream.next_event(timeout=timeout)
|
|
141
168
|
assert env is not None, "stream ended unexpectedly"
|
|
142
169
|
self.envelopes.append(env)
|
|
143
|
-
apply_ops(self.replica, env)
|
|
170
|
+
apply_ops(self.replica, copy.deepcopy(env))
|
|
144
171
|
if "ack" in env:
|
|
145
172
|
self._acked = max(self._acked, env["ack"])
|
|
146
173
|
for rsp in env.get("res", []):
|
|
@@ -156,9 +183,9 @@ class RunDriver:
|
|
|
156
183
|
assert response.status_code == 200, response.text
|
|
157
184
|
return await self.res(seq, terminal=terminal)
|
|
158
185
|
|
|
159
|
-
|
|
160
|
-
"""POST one batch; returns the first member's seq
|
|
161
|
-
consecutive). Responses via ``res(first + i)``."""
|
|
186
|
+
def batch(self, commands: list[tuple[str, Any]]) -> int:
|
|
187
|
+
"""Background POST of one batch; returns the first member's seq
|
|
188
|
+
(members are consecutive). Responses via ``res(first + i)``."""
|
|
162
189
|
first = self._seq + 1
|
|
163
190
|
self._seq += len(commands)
|
|
164
191
|
body = {
|
|
@@ -167,8 +194,7 @@ class RunDriver:
|
|
|
167
194
|
for method, params in commands
|
|
168
195
|
]
|
|
169
196
|
}
|
|
170
|
-
|
|
171
|
-
assert response.status_code == 200, response.text
|
|
197
|
+
self._spawn(body, first)
|
|
172
198
|
return first
|
|
173
199
|
|
|
174
200
|
async def context(self, value: dict[str, Any]) -> dict[str, Any]:
|
|
@@ -250,9 +276,19 @@ async def run_host(
|
|
|
250
276
|
async with statewire_client(host_cls) as (app, client):
|
|
251
277
|
async with stream_of(app) as stream:
|
|
252
278
|
snapshot = await stream.next_event()
|
|
253
|
-
driver = RunDriver(client, stream)
|
|
254
|
-
|
|
255
|
-
|
|
279
|
+
driver = RunDriver(client, stream, app)
|
|
280
|
+
driver.envelopes.append(snapshot)
|
|
281
|
+
apply_ops(driver.replica, copy.deepcopy(snapshot))
|
|
282
|
+
try:
|
|
283
|
+
yield driver, host_cls
|
|
284
|
+
finally:
|
|
285
|
+
for task in driver._posts:
|
|
286
|
+
task.cancel()
|
|
287
|
+
for result in await asyncio.gather(
|
|
288
|
+
*driver._posts, return_exceptions=True
|
|
289
|
+
):
|
|
290
|
+
if isinstance(result, AssertionError):
|
|
291
|
+
raise result
|
|
256
292
|
|
|
257
293
|
|
|
258
294
|
def msg(id: str, text: str | None = None) -> dict[str, Any]:
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Contract: settle-after-visibility. A send or rewind's covering ack only
|
|
2
|
+
rides an envelope whose state carries the command's effect — the message in
|
|
3
|
+
``state.messages`` (the executor appends it before ``ctx.ack_messages()``) or
|
|
4
|
+
in a published queue lane. A parked send acks with its insert; a dispatched
|
|
5
|
+
entry whose run ends unacked acks with the revert that re-publishes it."""
|
|
6
|
+
|
|
7
|
+
import copy
|
|
8
|
+
|
|
9
|
+
from run_helpers import Script, add, msg, queue_ids, run_host
|
|
10
|
+
|
|
11
|
+
from harness_sdk import RunManager
|
|
12
|
+
from statewire_helpers import apply_ops
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def covered_state(drv, seq):
|
|
16
|
+
"""The replica as of the envelope that first covers ``seq``."""
|
|
17
|
+
state: dict = {}
|
|
18
|
+
for env in copy.deepcopy(drv.envelopes):
|
|
19
|
+
apply_ops(state, env)
|
|
20
|
+
if env.get("ack", 0) >= seq:
|
|
21
|
+
return state
|
|
22
|
+
raise AssertionError(f"no envelope covers seq {seq}")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def lane_and_message_ids(state):
|
|
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
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def test_dispatched_send_acks_with_the_message_ack_append():
|
|
35
|
+
script = Script()
|
|
36
|
+
async with run_host(script) as (drv, host):
|
|
37
|
+
seq = drv.post("run/enqueue", add("m1"))
|
|
38
|
+
call = await script.next_call()
|
|
39
|
+
await drv.wait_status("running")
|
|
40
|
+
assert all("ack" not in env for env in drv.envelopes)
|
|
41
|
+
host.live.state["messages"] = [msg("m1")]
|
|
42
|
+
call.ack()
|
|
43
|
+
assert (await drv.res(seq))["type"] == "accepted"
|
|
44
|
+
assert "m1" in lane_and_message_ids(covered_state(drv, seq))
|
|
45
|
+
call.finish(RunManager.Complete())
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
async def test_parked_send_acks_with_its_queue_insert():
|
|
49
|
+
script = Script()
|
|
50
|
+
async with run_host(script) as (drv, host):
|
|
51
|
+
drv.post("run/enqueue", add("m1"))
|
|
52
|
+
call = await script.next_call()
|
|
53
|
+
call.ack()
|
|
54
|
+
res = await drv.command("run/enqueue", add("m2"))
|
|
55
|
+
assert res["type"] == "accepted"
|
|
56
|
+
assert "m2" in queue_ids(covered_state(drv, res["seq"]), "queue")
|
|
57
|
+
call.finish(RunManager.Complete())
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def test_unacked_run_end_acks_with_the_revert():
|
|
61
|
+
script = Script()
|
|
62
|
+
async with run_host(script) as (drv, host):
|
|
63
|
+
seq = drv.post("run/enqueue", add("m1"))
|
|
64
|
+
call = await script.next_call()
|
|
65
|
+
call.fail(RuntimeError("boom"))
|
|
66
|
+
assert (await drv.res(seq))["type"] == "accepted"
|
|
67
|
+
assert "m1" in queue_ids(covered_state(drv, seq), "queue")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
async def test_rewind_edit_acks_with_the_message_ack_append():
|
|
71
|
+
script = Script()
|
|
72
|
+
script.thread["u1"] = {"parentId": None, "role": "user", "isLeaf": True}
|
|
73
|
+
async with run_host(script, capabilities=("rewind",)) as (drv, host):
|
|
74
|
+
seq = drv.post("run/edit", {"sourceId": "u1", "message": msg("u2")})
|
|
75
|
+
rerun = await script.next_call()
|
|
76
|
+
assert rerun.ctx.trigger == "message-edit"
|
|
77
|
+
await drv.wait_status("running")
|
|
78
|
+
assert all("ack" not in env for env in drv.envelopes)
|
|
79
|
+
host.live.state["messages"] = [msg("u2")]
|
|
80
|
+
rerun.ack()
|
|
81
|
+
assert (await drv.res(seq))["type"] == "accepted"
|
|
82
|
+
assert "u2" in lane_and_message_ids(covered_state(drv, seq))
|
|
83
|
+
rerun.finish(RunManager.Complete())
|
|
@@ -13,15 +13,13 @@ from harness_sdk import RunManager
|
|
|
13
13
|
async def test_multi_steer_batch_places_all_and_dispatches_once():
|
|
14
14
|
script = Script()
|
|
15
15
|
async with run_host(script) as (drv, host):
|
|
16
|
-
first =
|
|
16
|
+
first = drv.batch(
|
|
17
17
|
[
|
|
18
18
|
("run/steer", add("s1")),
|
|
19
19
|
("run/steer", add("s2")),
|
|
20
20
|
("run/steer", add("s3")),
|
|
21
21
|
]
|
|
22
22
|
)
|
|
23
|
-
for offset in range(3):
|
|
24
|
-
assert (await drv.res(first + offset, terminal=False))["type"] == "pending"
|
|
25
23
|
call = await script.next_call()
|
|
26
24
|
assert call.ctx.trigger == "message-send"
|
|
27
25
|
assert [m["id"] for m in call.ctx.messages] == ["s1", "s2", "s3"]
|
|
@@ -36,9 +34,10 @@ async def test_multi_steer_batch_places_all_and_dispatches_once():
|
|
|
36
34
|
async def test_steer_and_stop_in_one_batch_nets_to_stop():
|
|
37
35
|
script = Script()
|
|
38
36
|
async with run_host(script) as (drv, host):
|
|
39
|
-
|
|
37
|
+
drv.post("run/enqueue", add("m1"))
|
|
40
38
|
call = await script.next_call()
|
|
41
|
-
|
|
39
|
+
call.ack()
|
|
40
|
+
first = drv.batch(
|
|
42
41
|
[("run/steer", add("s1")), ("run/stop", {"runId": await drv.run_id()})]
|
|
43
42
|
)
|
|
44
43
|
assert (await drv.res(first))["type"] == "accepted"
|
|
@@ -54,8 +53,9 @@ async def test_steer_and_stop_in_one_batch_nets_to_stop():
|
|
|
54
53
|
async def test_stop_settles_only_after_the_run_task_ends():
|
|
55
54
|
script = Script()
|
|
56
55
|
async with run_host(script) as (drv, host):
|
|
57
|
-
|
|
56
|
+
drv.post("run/enqueue", add("m1"))
|
|
58
57
|
call = await script.next_call()
|
|
58
|
+
call.ack()
|
|
59
59
|
pending = await drv.stop(terminal=False)
|
|
60
60
|
assert pending["type"] == "pending"
|
|
61
61
|
for _ in range(5):
|
|
@@ -79,10 +79,10 @@ class TailScript(Script):
|
|
|
79
79
|
async def test_handler_tail_staging_decides_in_its_own_envelope():
|
|
80
80
|
script = TailScript()
|
|
81
81
|
async with run_host(script) as (drv, host):
|
|
82
|
-
|
|
83
|
-
assert res["type"] == "pending"
|
|
82
|
+
seq = drv.post("run/enqueue", add("m1"))
|
|
84
83
|
call = await script.next_call()
|
|
85
84
|
assert [m["id"] for m in call.ctx.messages] == ["m1"]
|
|
85
|
+
await drv.wait_status("running")
|
|
86
86
|
dispatch_env = next(
|
|
87
87
|
env
|
|
88
88
|
for env in drv.envelopes
|
|
@@ -92,6 +92,7 @@ async def test_handler_tail_staging_decides_in_its_own_envelope():
|
|
|
92
92
|
)
|
|
93
93
|
)
|
|
94
94
|
# The decide ran outside the batch segment: its ops flush in an
|
|
95
|
-
# envelope of their own
|
|
96
|
-
assert
|
|
97
|
-
|
|
95
|
+
# envelope of their own; the covering ack waits for the message-ack.
|
|
96
|
+
assert "ack" not in dispatch_env and "res" not in dispatch_env
|
|
97
|
+
call.ack()
|
|
98
|
+
assert (await drv.res(seq))["type"] == "accepted"
|
|
@@ -19,9 +19,8 @@ async def test_add_requires_a_branch_anchor():
|
|
|
19
19
|
async def test_null_anchor_valid_only_on_an_empty_thread():
|
|
20
20
|
script = Script()
|
|
21
21
|
async with run_host(script) as (drv, host):
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
await script.next_call()
|
|
22
|
+
drv.post("run/enqueue", add("m1"))
|
|
23
|
+
(await script.next_call()).ack()
|
|
25
24
|
script.thread["m1"] = {
|
|
26
25
|
"parentId": None, "role": "user", "isLeaf": True, "onActiveBranch": True
|
|
27
26
|
}
|
|
@@ -42,9 +41,8 @@ async def test_active_branch_anchor_accepted_off_branch_and_unknown_rejected():
|
|
|
42
41
|
}
|
|
43
42
|
)
|
|
44
43
|
async with run_host(script) as (drv, host):
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
await script.next_call()
|
|
44
|
+
drv.post("run/enqueue", add("m1", anchor="a1"))
|
|
45
|
+
(await script.next_call()).ack()
|
|
48
46
|
assert_rejected(
|
|
49
47
|
await drv.command("run/enqueue", add("m2", anchor="old"), terminal=False),
|
|
50
48
|
"wrong-anchor",
|
|
@@ -58,8 +56,8 @@ async def test_active_branch_anchor_accepted_off_branch_and_unknown_rejected():
|
|
|
58
56
|
async def test_queued_item_and_dispatched_ids_are_valid_anchors():
|
|
59
57
|
script = Script()
|
|
60
58
|
async with run_host(script) as (drv, host):
|
|
61
|
-
|
|
62
|
-
await script.next_call()
|
|
59
|
+
drv.post("run/enqueue", add("m1"))
|
|
60
|
+
(await script.next_call()).ack()
|
|
63
61
|
await drv.command("run/enqueue", add("m2", anchor="m1"), terminal=False)
|
|
64
62
|
await drv.command("run/steer", add("s1", anchor="m2"), terminal=False)
|
|
65
63
|
assert queue_ids(drv.replica, "queue") == ["m2"]
|
|
@@ -74,11 +72,8 @@ async def test_replaced_tail_anchor_rejects_after_an_edit_rewrite():
|
|
|
74
72
|
}
|
|
75
73
|
)
|
|
76
74
|
async with run_host(script, capabilities=("rewind",)) as (drv, host):
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
)
|
|
80
|
-
assert res["type"] == "pending"
|
|
81
|
-
await script.next_call()
|
|
75
|
+
drv.post("run/edit", {"sourceId": "u1", "message": msg("m2")})
|
|
76
|
+
(await script.next_call()).ack()
|
|
82
77
|
script.thread["u1"] = {
|
|
83
78
|
"parentId": None, "role": "user", "isLeaf": True, "onActiveBranch": False
|
|
84
79
|
}
|
|
@@ -96,8 +91,8 @@ async def test_replaced_tail_anchor_rejects_after_an_edit_rewrite():
|
|
|
96
91
|
async def test_anchor_on_the_queued_edit_form_is_validated_never_ignored():
|
|
97
92
|
script = Script()
|
|
98
93
|
async with run_host(script) as (drv, host):
|
|
99
|
-
|
|
100
|
-
await script.next_call()
|
|
94
|
+
drv.post("run/enqueue", add("m1"))
|
|
95
|
+
(await script.next_call()).ack()
|
|
101
96
|
await drv.command("run/enqueue", add("m2", anchor="m1"), terminal=False)
|
|
102
97
|
assert_rejected(
|
|
103
98
|
await drv.command(
|
|
@@ -124,10 +119,10 @@ async def test_post_reload_sends_anchor_on_the_reload_targets_parent():
|
|
|
124
119
|
}
|
|
125
120
|
)
|
|
126
121
|
async with run_host(script, capabilities=("rewind",)) as (drv, host):
|
|
127
|
-
|
|
128
|
-
assert res["type"] == "pending"
|
|
122
|
+
drv.post("run/reload", {"sourceId": "a1"})
|
|
129
123
|
call = await script.next_call()
|
|
130
124
|
assert call.ctx.trigger == "message-reload"
|
|
125
|
+
call.ack()
|
|
131
126
|
script.thread["a1"] = {
|
|
132
127
|
"parentId": "u1", "role": "assistant", "isLeaf": True, "onActiveBranch": False
|
|
133
128
|
}
|