harness-sdk-python 0.6.0__tar.gz → 0.7.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.0}/.gitignore +1 -0
- {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.0}/PKG-INFO +1 -1
- {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.0}/pyproject.toml +1 -1
- {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.0}/src/harness_sdk/fenced_postgres.py +58 -18
- {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.0}/src/harness_sdk/run_manager.py +27 -12
- {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.0}/tests/run_helpers.py +47 -11
- harness_sdk_python-0.7.0/tests/test_ack_visibility.py +83 -0
- {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.0}/tests/test_batches.py +12 -11
- {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.0}/tests/test_branch_anchor.py +12 -17
- {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.0}/tests/test_edit_dispatched.py +14 -23
- {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.0}/tests/test_edit_reload.py +19 -28
- {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.0}/tests/test_enqueue.py +27 -29
- {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.0}/tests/test_fenced_postgres.py +64 -0
- {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.0}/tests/test_input_required.py +16 -18
- {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.0}/tests/test_meta.py +16 -22
- {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.0}/tests/test_outcomes.py +14 -13
- {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.0}/tests/test_placement.py +2 -2
- {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.0}/tests/test_prepare_hooks.py +11 -14
- {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.0}/tests/test_rewind_during_run.py +13 -27
- {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.0}/tests/test_run_leaf.py +14 -14
- {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.0}/tests/test_settle.py +36 -49
- {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.0}/tests/test_steer.py +16 -12
- {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.0}/tests/test_stop_continue.py +28 -24
- {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.0}/README.md +0 -0
- {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.0}/src/harness_sdk/__init__.py +0 -0
- {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.0}/src/harness_sdk/linear_thread.py +0 -0
- {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.0}/tests/test_facade.py +0 -0
- {harness_sdk_python-0.6.0 → harness_sdk_python-0.7.0}/tests/test_linear_thread.py +0 -0
|
@@ -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]"] = []
|
|
@@ -307,7 +312,7 @@ class RunManager:
|
|
|
307
312
|
rollback_to=rewind.rollback_to,
|
|
308
313
|
root_meta=rewind.root_meta,
|
|
309
314
|
)
|
|
310
|
-
rewind.ack
|
|
315
|
+
self._dispatch_ack = rewind.ack
|
|
311
316
|
if not rewind.future.done():
|
|
312
317
|
self._run_futures.append(rewind.future)
|
|
313
318
|
elif outcome is not None:
|
|
@@ -317,16 +322,15 @@ class RunManager:
|
|
|
317
322
|
if self._task is not None and (self._staged_stops or self._staged_rewinds):
|
|
318
323
|
assert self._ctx is not None
|
|
319
324
|
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
325
|
dispatching = {item["id"] for _, item in self._dispatching}
|
|
325
|
-
for message_id, future in fx.staged_sends:
|
|
326
|
+
for message_id, future, ack in fx.staged_sends:
|
|
326
327
|
if message_id in dispatching:
|
|
328
|
+
self._deferred_acks[message_id] = ack
|
|
327
329
|
self._send_futures.append(future)
|
|
328
|
-
|
|
329
|
-
|
|
330
|
+
else:
|
|
331
|
+
ack()
|
|
332
|
+
if not future.done():
|
|
333
|
+
future.set_result(None)
|
|
330
334
|
for future in fx.continues:
|
|
331
335
|
if not future.done():
|
|
332
336
|
future.set_result(None)
|
|
@@ -538,6 +542,7 @@ class RunManager:
|
|
|
538
542
|
self._ctx = None
|
|
539
543
|
self._task = None
|
|
540
544
|
self._dispatch_record = None
|
|
545
|
+
self._dispatch_ack = None
|
|
541
546
|
|
|
542
547
|
def _settle_initiators(self, error: StatewireReject | None) -> None:
|
|
543
548
|
futures, self._run_futures = self._run_futures, []
|
|
@@ -556,6 +561,13 @@ class RunManager:
|
|
|
556
561
|
future.set_result(None)
|
|
557
562
|
|
|
558
563
|
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()
|
|
559
571
|
self._dispatching = []
|
|
560
572
|
self._run_acked = True
|
|
561
573
|
self._leaf_confirmed = plain(self._entry()["runLeafMessageId"])
|
|
@@ -572,6 +584,10 @@ class RunManager:
|
|
|
572
584
|
front = [item for taken_lane, item in taken if taken_lane == lane]
|
|
573
585
|
if front:
|
|
574
586
|
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()
|
|
575
591
|
ids = {item["id"] for _, item in taken}
|
|
576
592
|
self._dispatched_ids = tuple(id for id in self._dispatched_ids if id not in ids)
|
|
577
593
|
if plain(entry["runLeafMessageId"]) in ids:
|
|
@@ -809,8 +825,7 @@ class RunManager:
|
|
|
809
825
|
raise _reject("invalid-message", "anchorMessageId is required")
|
|
810
826
|
self._check_anchor(e)
|
|
811
827
|
self._insert_new(e.lane, e.message, e.params, e.meta)
|
|
812
|
-
fx.staged_sends.append((e.message_id, e.future))
|
|
813
|
-
e.ack()
|
|
828
|
+
fx.staged_sends.append((e.message_id, e.future, e.ack))
|
|
814
829
|
fx.new_added = True
|
|
815
830
|
if e.lane == "steerQueue":
|
|
816
831
|
fx.steer_added = True
|
|
@@ -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
|
}
|
|
@@ -10,16 +10,13 @@ from harness_sdk import RunManager
|
|
|
10
10
|
async def test_edit_of_dispatched_item_stops_rewinds_reruns(command):
|
|
11
11
|
script = Script()
|
|
12
12
|
async with run_host(script, capabilities=("rewind", "rewind-during-run")) as (drv, host):
|
|
13
|
-
|
|
13
|
+
drv.post("run/enqueue", add("m1"))
|
|
14
14
|
call = await script.next_call()
|
|
15
15
|
script.thread["m1"] = {"parentId": "p0", "role": "user", "isLeaf": True}
|
|
16
16
|
call.ack()
|
|
17
|
-
|
|
18
|
-
command, {"message": msg("m1", "edited")}, terminal=False
|
|
19
|
-
)
|
|
20
|
-
assert pending["type"] == "pending"
|
|
17
|
+
seq = drv.post(command, {"message": msg("m1", "edited")})
|
|
21
18
|
await asyncio.wait_for(call.ctx.stop_requested.wait(), 5)
|
|
22
|
-
|
|
19
|
+
await drv.wait_status("running")
|
|
23
20
|
call.finish(RunManager.Stop(dispatch_queue=False))
|
|
24
21
|
rerun = await script.next_call()
|
|
25
22
|
assert rerun.ctx.trigger == "message-edit"
|
|
@@ -29,7 +26,7 @@ async def test_edit_of_dispatched_item_stops_rewinds_reruns(command):
|
|
|
29
26
|
await drv.wait_status("running")
|
|
30
27
|
rerun.ack()
|
|
31
28
|
rerun.finish(RunManager.Stop(dispatch_queue=False))
|
|
32
|
-
settled = await drv.res(
|
|
29
|
+
settled = await drv.res(seq)
|
|
33
30
|
assert settled["type"] == "accepted"
|
|
34
31
|
|
|
35
32
|
|
|
@@ -37,8 +34,8 @@ async def test_edit_of_dispatched_item_stops_rewinds_reruns(command):
|
|
|
37
34
|
async def test_edit_of_dispatched_item_requires_rewind_during_run(capabilities):
|
|
38
35
|
script = Script()
|
|
39
36
|
async with run_host(script, capabilities=capabilities) as (drv, host):
|
|
40
|
-
|
|
41
|
-
await script.next_call()
|
|
37
|
+
drv.post("run/enqueue", add("m1"))
|
|
38
|
+
(await script.next_call()).ack()
|
|
42
39
|
script.thread["m1"] = {"parentId": None, "role": "user", "isLeaf": True}
|
|
43
40
|
assert_rejected(
|
|
44
41
|
await drv.command("run/enqueue", {"message": msg("m1", "edited")}, terminal=False),
|
|
@@ -50,54 +47,48 @@ async def test_edit_of_dispatched_item_requires_rewind_during_run(capabilities):
|
|
|
50
47
|
async def test_edit_of_dispatched_non_leaf_stops_and_reruns(command):
|
|
51
48
|
script = Script()
|
|
52
49
|
async with run_host(script, capabilities=("rewind", "rewind-during-run")) as (drv, host):
|
|
53
|
-
|
|
50
|
+
drv.post("run/enqueue", add("m1"))
|
|
54
51
|
call = await script.next_call()
|
|
55
52
|
script.thread["m1"] = {"parentId": None, "role": "user", "isLeaf": False}
|
|
56
53
|
call.ack()
|
|
57
|
-
|
|
58
|
-
command, {"message": msg("m1", "edited")}, terminal=False
|
|
59
|
-
)
|
|
60
|
-
assert pending["type"] == "pending"
|
|
54
|
+
seq = drv.post(command, {"message": msg("m1", "edited")})
|
|
61
55
|
call.finish(RunManager.Stop(dispatch_queue=False))
|
|
62
56
|
rerun = await script.next_call()
|
|
63
57
|
assert rerun.ctx.trigger == "message-edit"
|
|
64
58
|
assert [m["parts"][0]["text"] for m in rerun.ctx.messages] == ["edited"]
|
|
65
59
|
rerun.ack()
|
|
66
60
|
rerun.finish(RunManager.Stop(dispatch_queue=False))
|
|
67
|
-
assert (await drv.res(
|
|
61
|
+
assert (await drv.res(seq))["type"] == "accepted"
|
|
68
62
|
|
|
69
63
|
|
|
70
64
|
async def test_edit_of_dispatched_item_with_queued_items_stops_and_reruns():
|
|
71
65
|
script = Script()
|
|
72
66
|
async with run_host(script, capabilities=("rewind", "rewind-during-run")) as (drv, host):
|
|
73
|
-
|
|
67
|
+
drv.post("run/enqueue", add("m1"))
|
|
74
68
|
call = await script.next_call()
|
|
75
69
|
script.thread["m1"] = {"parentId": None, "role": "user", "isLeaf": True}
|
|
76
70
|
call.ack()
|
|
77
71
|
await drv.command("run/enqueue", add("m2", anchor="m1"), terminal=False)
|
|
78
|
-
|
|
79
|
-
"run/enqueue", {"message": msg("m1", "edited")}, terminal=False
|
|
80
|
-
)
|
|
81
|
-
assert pending["type"] == "pending"
|
|
72
|
+
seq = drv.post("run/enqueue", {"message": msg("m1", "edited")})
|
|
82
73
|
call.finish(RunManager.Stop(dispatch_queue=False))
|
|
83
74
|
rerun = await script.next_call()
|
|
84
75
|
assert rerun.ctx.trigger == "message-edit"
|
|
85
76
|
rerun.ack()
|
|
86
77
|
rerun.finish(RunManager.Stop(dispatch_queue=False))
|
|
87
|
-
assert (await drv.res(
|
|
78
|
+
assert (await drv.res(seq))["type"] == "accepted"
|
|
88
79
|
assert [item["id"] for item in drv.run["queue"]] == ["m2"]
|
|
89
80
|
|
|
90
81
|
|
|
91
82
|
async def test_edit_of_dispatched_item_in_error_reruns_without_stop():
|
|
92
83
|
script = Script()
|
|
93
84
|
async with run_host(script, capabilities=("rewind",)) as (drv, host):
|
|
94
|
-
|
|
85
|
+
drv.post("run/enqueue", add("m1"))
|
|
95
86
|
call = await script.next_call()
|
|
96
87
|
script.thread["m1"] = {"parentId": None, "role": "user", "isLeaf": True}
|
|
97
88
|
call.ack()
|
|
98
89
|
call.fail(RuntimeError("boom"))
|
|
99
90
|
await drv.wait_status("error")
|
|
100
|
-
|
|
91
|
+
drv.post("run/enqueue", {"message": msg("m1", "edited")})
|
|
101
92
|
rerun = await script.next_call()
|
|
102
93
|
assert rerun.ctx.trigger == "message-edit"
|
|
103
94
|
assert rerun.ctx.rollback_to is None
|