harness-sdk-python 0.25.1__tar.gz → 0.26.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.25.1 → harness_sdk_python-0.26.0}/PKG-INFO +1 -1
- {harness_sdk_python-0.25.1 → harness_sdk_python-0.26.0}/pyproject.toml +1 -1
- {harness_sdk_python-0.25.1 → harness_sdk_python-0.26.0}/src/harness_sdk/__init__.py +2 -0
- {harness_sdk_python-0.25.1 → harness_sdk_python-0.26.0}/src/harness_sdk/checkpoints.py +74 -52
- harness_sdk_python-0.26.0/src/harness_sdk/deepagents.py +210 -0
- {harness_sdk_python-0.25.1 → harness_sdk_python-0.26.0}/src/harness_sdk/run_manager.py +19 -6
- {harness_sdk_python-0.25.1 → harness_sdk_python-0.26.0}/tests/test_checkpoints.py +22 -7
- harness_sdk_python-0.26.0/tests/test_deepagents_messages.py +238 -0
- harness_sdk_python-0.26.0/tests/test_mint_concurrency.py +59 -0
- {harness_sdk_python-0.25.1 → harness_sdk_python-0.26.0}/.gitignore +0 -0
- {harness_sdk_python-0.25.1 → harness_sdk_python-0.26.0}/README.md +0 -0
- {harness_sdk_python-0.25.1 → harness_sdk_python-0.26.0}/examples/__init__.py +0 -0
- {harness_sdk_python-0.25.1 → harness_sdk_python-0.26.0}/examples/runs_app.py +0 -0
- {harness_sdk_python-0.25.1 → harness_sdk_python-0.26.0}/src/harness_sdk/fenced_postgres.py +0 -0
- {harness_sdk_python-0.25.1 → harness_sdk_python-0.26.0}/src/harness_sdk/linear_thread.py +0 -0
- {harness_sdk_python-0.25.1 → harness_sdk_python-0.26.0}/tests/run_helpers.py +0 -0
- {harness_sdk_python-0.25.1 → harness_sdk_python-0.26.0}/tests/test_admit_steer.py +0 -0
- {harness_sdk_python-0.25.1 → harness_sdk_python-0.26.0}/tests/test_applied_visibility.py +0 -0
- {harness_sdk_python-0.25.1 → harness_sdk_python-0.26.0}/tests/test_batches.py +0 -0
- {harness_sdk_python-0.25.1 → harness_sdk_python-0.26.0}/tests/test_branch_anchor.py +0 -0
- {harness_sdk_python-0.25.1 → harness_sdk_python-0.26.0}/tests/test_dispatching.py +0 -0
- {harness_sdk_python-0.25.1 → harness_sdk_python-0.26.0}/tests/test_durability.py +0 -0
- {harness_sdk_python-0.25.1 → harness_sdk_python-0.26.0}/tests/test_edit_dispatched.py +0 -0
- {harness_sdk_python-0.25.1 → harness_sdk_python-0.26.0}/tests/test_edit_reload.py +0 -0
- {harness_sdk_python-0.25.1 → harness_sdk_python-0.26.0}/tests/test_enqueue.py +0 -0
- {harness_sdk_python-0.25.1 → harness_sdk_python-0.26.0}/tests/test_fenced_postgres.py +0 -0
- {harness_sdk_python-0.25.1 → harness_sdk_python-0.26.0}/tests/test_input_required.py +0 -0
- {harness_sdk_python-0.25.1 → harness_sdk_python-0.26.0}/tests/test_linear_thread.py +0 -0
- {harness_sdk_python-0.25.1 → harness_sdk_python-0.26.0}/tests/test_meta.py +0 -0
- {harness_sdk_python-0.25.1 → harness_sdk_python-0.26.0}/tests/test_outcomes.py +0 -0
- {harness_sdk_python-0.25.1 → harness_sdk_python-0.26.0}/tests/test_placement.py +0 -0
- {harness_sdk_python-0.25.1 → harness_sdk_python-0.26.0}/tests/test_prepare_hooks.py +0 -0
- {harness_sdk_python-0.25.1 → harness_sdk_python-0.26.0}/tests/test_restore.py +0 -0
- {harness_sdk_python-0.25.1 → harness_sdk_python-0.26.0}/tests/test_rewind_during_run.py +0 -0
- {harness_sdk_python-0.25.1 → harness_sdk_python-0.26.0}/tests/test_run_crash_sweep.py +0 -0
- {harness_sdk_python-0.25.1 → harness_sdk_python-0.26.0}/tests/test_settle.py +0 -0
- {harness_sdk_python-0.25.1 → harness_sdk_python-0.26.0}/tests/test_steer.py +0 -0
- {harness_sdk_python-0.25.1 → harness_sdk_python-0.26.0}/tests/test_stop_continue.py +0 -0
- {harness_sdk_python-0.25.1 → harness_sdk_python-0.26.0}/tests/test_stopping_window.py +0 -0
|
@@ -11,6 +11,7 @@ from .checkpoints import (
|
|
|
11
11
|
langgraph_snapshot,
|
|
12
12
|
langgraph_switch_values,
|
|
13
13
|
)
|
|
14
|
+
from .deepagents import DeepagentsMessages
|
|
14
15
|
from .linear_thread import linear_thread
|
|
15
16
|
from .run_manager import RunManager, RunManagerDescriptor, run_manager
|
|
16
17
|
|
|
@@ -18,6 +19,7 @@ __all__ = [
|
|
|
18
19
|
"CheckpointIndex",
|
|
19
20
|
"CheckpointStore",
|
|
20
21
|
"CheckpointThread",
|
|
22
|
+
"DeepagentsMessages",
|
|
21
23
|
"MemoryCheckpointIndex",
|
|
22
24
|
"Plan",
|
|
23
25
|
"PostgresCheckpointIndex",
|
|
@@ -11,6 +11,7 @@ Rows and entries are insert-only; ``last_checkpoint_id`` is the only update.
|
|
|
11
11
|
"""
|
|
12
12
|
|
|
13
13
|
import asyncio
|
|
14
|
+
from contextlib import asynccontextmanager
|
|
14
15
|
from dataclasses import dataclass, field
|
|
15
16
|
from typing import Any, Callable, Protocol
|
|
16
17
|
|
|
@@ -158,50 +159,69 @@ class MemoryCheckpointIndex:
|
|
|
158
159
|
|
|
159
160
|
|
|
160
161
|
class PostgresCheckpointIndex:
|
|
161
|
-
"""psycopg-backed CheckpointIndex
|
|
162
|
+
"""psycopg-backed CheckpointIndex over an ``AsyncConnection`` or ``AsyncConnectionPool``; ``setup()`` creates the two tables."""
|
|
162
163
|
|
|
163
164
|
def __init__(self, conn: Any) -> None:
|
|
164
165
|
try:
|
|
165
|
-
import psycopg
|
|
166
|
+
import psycopg
|
|
167
|
+
import psycopg_pool
|
|
166
168
|
except ImportError as exc:
|
|
167
169
|
raise ImportError(
|
|
168
170
|
"harness_sdk.checkpoints.PostgresCheckpointIndex requires the "
|
|
169
171
|
"'postgres' extra: pip install 'harness-sdk-python[postgres]'"
|
|
170
172
|
) from exc
|
|
173
|
+
if not isinstance(
|
|
174
|
+
conn, (psycopg.AsyncConnection, psycopg_pool.AsyncConnectionPool)
|
|
175
|
+
):
|
|
176
|
+
raise TypeError(
|
|
177
|
+
f"conn must be an AsyncConnection or AsyncConnectionPool, got {conn!r}"
|
|
178
|
+
)
|
|
171
179
|
self._conn = conn
|
|
172
180
|
|
|
181
|
+
@asynccontextmanager
|
|
182
|
+
async def _connection(self):
|
|
183
|
+
import psycopg
|
|
184
|
+
|
|
185
|
+
if isinstance(self._conn, psycopg.AsyncConnection):
|
|
186
|
+
yield self._conn
|
|
187
|
+
else:
|
|
188
|
+
async with self._conn.connection() as conn:
|
|
189
|
+
yield conn
|
|
190
|
+
|
|
173
191
|
async def setup(self) -> None:
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
192
|
+
async with self._connection() as conn:
|
|
193
|
+
await conn.execute(
|
|
194
|
+
"CREATE TABLE IF NOT EXISTS checkpoint_threads ("
|
|
195
|
+
" thread_id TEXT NOT NULL,"
|
|
196
|
+
" checkpoint_ns TEXT NOT NULL,"
|
|
197
|
+
" branches JSONB NOT NULL,"
|
|
198
|
+
" PRIMARY KEY (thread_id, checkpoint_ns))"
|
|
199
|
+
)
|
|
200
|
+
await conn.execute(
|
|
201
|
+
"CREATE TABLE IF NOT EXISTS checkpoint_messages ("
|
|
202
|
+
" thread_id TEXT NOT NULL,"
|
|
203
|
+
" checkpoint_ns TEXT NOT NULL,"
|
|
204
|
+
" message_id TEXT NOT NULL,"
|
|
205
|
+
" anchor_checkpoint_id TEXT,"
|
|
206
|
+
" branch_id INTEGER NOT NULL,"
|
|
207
|
+
" PRIMARY KEY (thread_id, checkpoint_ns, message_id))"
|
|
208
|
+
)
|
|
190
209
|
|
|
191
210
|
async def read_thread(
|
|
192
211
|
self, thread_id: str, checkpoint_ns: str
|
|
193
212
|
) -> list[dict[str, Any]]:
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
213
|
+
async with self._connection() as conn:
|
|
214
|
+
cur = await conn.execute(
|
|
215
|
+
"SELECT branches FROM checkpoint_threads WHERE thread_id = %s AND checkpoint_ns = %s",
|
|
216
|
+
(thread_id, checkpoint_ns),
|
|
217
|
+
)
|
|
218
|
+
row = await cur.fetchone()
|
|
199
219
|
return row[0] if row is not None else []
|
|
200
220
|
|
|
201
|
-
async def _seed_root(self, thread_id: str, checkpoint_ns: str) -> None:
|
|
221
|
+
async def _seed_root(self, conn: Any, thread_id: str, checkpoint_ns: str) -> None:
|
|
202
222
|
from psycopg.types.json import Jsonb
|
|
203
223
|
|
|
204
|
-
await
|
|
224
|
+
await conn.execute(
|
|
205
225
|
"INSERT INTO checkpoint_threads (thread_id, checkpoint_ns, branches)"
|
|
206
226
|
" VALUES (%s, %s, %s) ON CONFLICT DO NOTHING",
|
|
207
227
|
(thread_id, checkpoint_ns, Jsonb([_ROOT_ENTRY])),
|
|
@@ -223,9 +243,9 @@ class PostgresCheckpointIndex:
|
|
|
223
243
|
"fork_branch": fork_branch,
|
|
224
244
|
"last_checkpoint_id": last_checkpoint_id,
|
|
225
245
|
}
|
|
226
|
-
async with self.
|
|
227
|
-
await self._seed_root(thread_id, checkpoint_ns)
|
|
228
|
-
cur = await
|
|
246
|
+
async with self._connection() as conn, conn.transaction():
|
|
247
|
+
await self._seed_root(conn, thread_id, checkpoint_ns)
|
|
248
|
+
cur = await conn.execute(
|
|
229
249
|
"UPDATE checkpoint_threads SET branches = branches || %s"
|
|
230
250
|
" WHERE thread_id = %s AND checkpoint_ns = %s"
|
|
231
251
|
" RETURNING jsonb_array_length(branches)",
|
|
@@ -240,26 +260,27 @@ class PostgresCheckpointIndex:
|
|
|
240
260
|
branch_ids: list[int],
|
|
241
261
|
checkpoint_id: str,
|
|
242
262
|
) -> None:
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
263
|
+
async with self._connection() as conn:
|
|
264
|
+
await self._seed_root(conn, thread_id, checkpoint_ns)
|
|
265
|
+
cur = await conn.execute(
|
|
266
|
+
"SELECT jsonb_array_length(branches) FROM checkpoint_threads"
|
|
267
|
+
" WHERE thread_id = %s AND checkpoint_ns = %s",
|
|
268
|
+
(thread_id, checkpoint_ns),
|
|
269
|
+
)
|
|
270
|
+
row = await cur.fetchone()
|
|
271
|
+
_check_branch_ids(branch_ids, row[0] if row is not None else 0)
|
|
272
|
+
await conn.execute(
|
|
273
|
+
"UPDATE checkpoint_threads SET branches = (SELECT jsonb_agg("
|
|
274
|
+
" CASE WHEN idx::int - 1 = ANY(%s)"
|
|
275
|
+
" THEN elem || jsonb_build_object('last_checkpoint_id', %s::text)"
|
|
276
|
+
" ELSE elem END ORDER BY idx)"
|
|
277
|
+
" FROM jsonb_array_elements(branches) WITH ORDINALITY AS t(elem, idx))"
|
|
278
|
+
" WHERE thread_id = %s AND checkpoint_ns = %s",
|
|
279
|
+
(branch_ids, checkpoint_id, thread_id, checkpoint_ns),
|
|
280
|
+
)
|
|
260
281
|
|
|
261
282
|
async def upsert_messages(self, rows: list[dict[str, Any]]) -> None:
|
|
262
|
-
async with self.
|
|
283
|
+
async with self._connection() as conn, conn.cursor() as cur:
|
|
263
284
|
await cur.executemany(
|
|
264
285
|
"INSERT INTO checkpoint_messages"
|
|
265
286
|
" (thread_id, checkpoint_ns, message_id, anchor_checkpoint_id, branch_id)"
|
|
@@ -272,12 +293,13 @@ class PostgresCheckpointIndex:
|
|
|
272
293
|
async def get_message(
|
|
273
294
|
self, thread_id: str, checkpoint_ns: str, message_id: str
|
|
274
295
|
) -> dict[str, Any] | None:
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
296
|
+
async with self._connection() as conn:
|
|
297
|
+
cur = await conn.execute(
|
|
298
|
+
"SELECT anchor_checkpoint_id, branch_id FROM checkpoint_messages"
|
|
299
|
+
" WHERE thread_id = %s AND checkpoint_ns = %s AND message_id = %s",
|
|
300
|
+
(thread_id, checkpoint_ns, message_id),
|
|
301
|
+
)
|
|
302
|
+
row = await cur.fetchone()
|
|
281
303
|
if row is None:
|
|
282
304
|
return None
|
|
283
305
|
return {"anchor_checkpoint_id": row[0], "branch_id": row[1]}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""DeepagentsMessages: the ``messages`` slice of a Statewire host, projected
|
|
2
|
+
from a langgraph checkpointer in the deepagents dialect. The checkpointer is
|
|
3
|
+
the source of truth and the wire state is its projection, restamped with
|
|
4
|
+
``branch: {id, siblings}`` metadata after every ingest. A rewind restores the
|
|
5
|
+
projection from the target message's anchor checkpoint; a branch switch clones
|
|
6
|
+
the target branch's tip as the checkpointer's latest checkpoint, so resumption
|
|
7
|
+
and reboot both land on the switched world.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from statepatch import plain
|
|
14
|
+
|
|
15
|
+
from .checkpoints import (
|
|
16
|
+
CheckpointIndex,
|
|
17
|
+
CheckpointStore,
|
|
18
|
+
Snapshot,
|
|
19
|
+
_values_snapshot,
|
|
20
|
+
checkpoint_thread,
|
|
21
|
+
langgraph_snapshot,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _role(message: dict[str, Any]) -> str:
|
|
26
|
+
return "user" if message["type"] == "human" else "assistant"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _user_text(message: dict[str, Any]) -> str:
|
|
30
|
+
return "\n\n".join(
|
|
31
|
+
part["text"] for part in message["parts"] if part["type"] == "text"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _checkpointer_store(
|
|
36
|
+
checkpointer: Any, thread_id: str, checkpoint_ns: str
|
|
37
|
+
) -> CheckpointStore:
|
|
38
|
+
class _Store:
|
|
39
|
+
async def get(self, checkpoint_id: str | None) -> Snapshot | None:
|
|
40
|
+
configurable: dict[str, Any] = {
|
|
41
|
+
"thread_id": thread_id,
|
|
42
|
+
"checkpoint_ns": checkpoint_ns,
|
|
43
|
+
}
|
|
44
|
+
if checkpoint_id is not None:
|
|
45
|
+
configurable["checkpoint_id"] = checkpoint_id
|
|
46
|
+
saved = await checkpointer.aget_tuple({"configurable": configurable})
|
|
47
|
+
if saved is None:
|
|
48
|
+
return None
|
|
49
|
+
return _values_snapshot(
|
|
50
|
+
saved.checkpoint["id"],
|
|
51
|
+
saved.checkpoint["channel_values"],
|
|
52
|
+
"messages",
|
|
53
|
+
"lineage",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
return _Store()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class DeepagentsMessages:
|
|
60
|
+
"""A host's ``state["messages"]`` over a langgraph checkpointer: boot, turn streaming, rewind, and branch switching for the deepagents dialect."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, checkpointer: Any) -> None:
|
|
63
|
+
if not hasattr(checkpointer, "aget_tuple") or not hasattr(checkpointer, "aput"):
|
|
64
|
+
raise TypeError(
|
|
65
|
+
f"checkpointer must be a langgraph checkpoint saver, got {checkpointer!r}"
|
|
66
|
+
)
|
|
67
|
+
self._checkpointer = checkpointer
|
|
68
|
+
self._captured: dict[str, Any] | None = None
|
|
69
|
+
|
|
70
|
+
async def boot(
|
|
71
|
+
self, state: Any, *, index: CheckpointIndex, thread_id: str
|
|
72
|
+
) -> None:
|
|
73
|
+
"""Adopt ``state["messages"]`` and load the thread's history into it with branch stamps."""
|
|
74
|
+
self._state = state
|
|
75
|
+
self._thread_id = thread_id
|
|
76
|
+
self._store = _checkpointer_store(self._checkpointer, thread_id, "")
|
|
77
|
+
self._thread = await checkpoint_thread(
|
|
78
|
+
store=self._store, index=index, thread_id=thread_id, role=_role
|
|
79
|
+
)
|
|
80
|
+
if self._thread.transcript:
|
|
81
|
+
# the graph carries no lineage channel: re-derive the world path from the tail row
|
|
82
|
+
row = await index.get_message(
|
|
83
|
+
thread_id, "", self._thread.transcript[-1]["id"]
|
|
84
|
+
)
|
|
85
|
+
if row is not None and row["branch_id"] != 0:
|
|
86
|
+
await self._thread.switch_state(row["branch_id"])
|
|
87
|
+
self._restamp()
|
|
88
|
+
|
|
89
|
+
async def get_message_meta(self, message_id: str | None) -> dict[str, Any] | None:
|
|
90
|
+
"""The thread projection's message meta, for RunManager."""
|
|
91
|
+
return await self._thread.get_message_meta(message_id)
|
|
92
|
+
|
|
93
|
+
async def get_message_child_id(self, parent_id: str) -> str | None:
|
|
94
|
+
"""The thread projection's child id, for RunManager."""
|
|
95
|
+
return await self._thread.get_message_child_id(parent_id)
|
|
96
|
+
|
|
97
|
+
def config(self) -> dict[str, Any]:
|
|
98
|
+
"""The langgraph config addressing this thread."""
|
|
99
|
+
return {"configurable": {"thread_id": self._thread_id}}
|
|
100
|
+
|
|
101
|
+
async def plan_turn(
|
|
102
|
+
self, ctx: Any
|
|
103
|
+
) -> tuple[dict[str, Any] | None, dict[str, Any]]:
|
|
104
|
+
"""The dispatch's langgraph (payload, config): plans the resumption, restores a rewind's window, appends the batch to the wire state, and confirms it applied."""
|
|
105
|
+
config = self.config()
|
|
106
|
+
if ctx.resume:
|
|
107
|
+
return None, config
|
|
108
|
+
plan = await self._thread.plan_run(ctx)
|
|
109
|
+
if ctx.has_rollback:
|
|
110
|
+
base = (
|
|
111
|
+
(await self._snapshot(plan.checkpoint_id)).messages
|
|
112
|
+
if plan.checkpoint_id is not None
|
|
113
|
+
else []
|
|
114
|
+
)
|
|
115
|
+
self._replace([*base, *plan.replay])
|
|
116
|
+
turn: list[Any] = [*plan.replay]
|
|
117
|
+
for message in ctx.messages:
|
|
118
|
+
turn.append(self._append_user(message))
|
|
119
|
+
ctx.applied()
|
|
120
|
+
if plan.fork:
|
|
121
|
+
if plan.checkpoint_id is not None:
|
|
122
|
+
config["configurable"]["checkpoint_id"] = plan.checkpoint_id
|
|
123
|
+
else:
|
|
124
|
+
from langchain_core.messages import RemoveMessage
|
|
125
|
+
from langgraph.graph.message import REMOVE_ALL_MESSAGES
|
|
126
|
+
|
|
127
|
+
turn = [RemoveMessage(id=REMOVE_ALL_MESSAGES), *turn]
|
|
128
|
+
return ({"messages": turn} if turn else None), config
|
|
129
|
+
|
|
130
|
+
def append_event(self, namespace: tuple, mode: str, event: Any) -> None:
|
|
131
|
+
"""Fold one ``astream`` event into the wire state; the root checkpoint stream is captured for the turn's ingest."""
|
|
132
|
+
if mode == "checkpoints":
|
|
133
|
+
if namespace == ():
|
|
134
|
+
self._captured = event
|
|
135
|
+
return
|
|
136
|
+
from statewire.langgraph import append_langgraph_event
|
|
137
|
+
|
|
138
|
+
append_langgraph_event(self._state, namespace, mode, event)
|
|
139
|
+
|
|
140
|
+
async def ingest_turn(self, *, refetch: bool = False) -> None:
|
|
141
|
+
"""Ingest the turn's captured checkpoint and restamp; ``refetch`` discards the capture and reads the checkpointer's latest instead."""
|
|
142
|
+
captured, self._captured = self._captured, None
|
|
143
|
+
if refetch or captured is None:
|
|
144
|
+
await self._thread.ingest_latest()
|
|
145
|
+
else:
|
|
146
|
+
await self._thread.ingest(langgraph_snapshot(captured))
|
|
147
|
+
self._restamp()
|
|
148
|
+
|
|
149
|
+
def append_users(self, messages: Any) -> list[dict[str, Any]]:
|
|
150
|
+
"""Append harness user messages to the wire state as human entries, deduplicated by id; returns the langgraph turn entries."""
|
|
151
|
+
return [self._append_user(message) for message in messages]
|
|
152
|
+
|
|
153
|
+
async def switch_branch(self, branch_id: int) -> None:
|
|
154
|
+
"""Switch to ``branch_id``: its tip becomes the checkpointer's latest checkpoint and the wire state rebaselines."""
|
|
155
|
+
transcript, lineage = await self._thread.switch_state(branch_id)
|
|
156
|
+
checkpoint_id = await self._clone_tip(self._thread.checkpoint_id)
|
|
157
|
+
await self._thread.ingest(Snapshot(checkpoint_id, transcript, lineage))
|
|
158
|
+
self._restamp()
|
|
159
|
+
|
|
160
|
+
async def _clone_tip(self, checkpoint_id: str) -> str:
|
|
161
|
+
from langgraph.checkpoint.base.id import uuid6
|
|
162
|
+
|
|
163
|
+
parent = {
|
|
164
|
+
"configurable": {
|
|
165
|
+
"thread_id": self._thread_id,
|
|
166
|
+
"checkpoint_ns": "",
|
|
167
|
+
"checkpoint_id": checkpoint_id,
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
saved = await self._checkpointer.aget_tuple(parent)
|
|
171
|
+
if saved is None:
|
|
172
|
+
raise LookupError(f"checkpoint {checkpoint_id!r} is not in the checkpointer")
|
|
173
|
+
clone = {
|
|
174
|
+
**saved.checkpoint,
|
|
175
|
+
"id": str(uuid6(clock_seq=-2)),
|
|
176
|
+
"ts": datetime.now(timezone.utc).isoformat(),
|
|
177
|
+
}
|
|
178
|
+
metadata = {
|
|
179
|
+
"source": "update",
|
|
180
|
+
"step": (saved.metadata or {}).get("step", -1) + 1,
|
|
181
|
+
"parents": {},
|
|
182
|
+
}
|
|
183
|
+
written = await self._checkpointer.aput(parent, clone, metadata, {})
|
|
184
|
+
return written["configurable"]["checkpoint_id"]
|
|
185
|
+
|
|
186
|
+
async def _snapshot(self, checkpoint_id: str) -> Snapshot:
|
|
187
|
+
snapshot = await self._store.get(checkpoint_id)
|
|
188
|
+
if snapshot is None:
|
|
189
|
+
raise LookupError(
|
|
190
|
+
f"checkpoint {checkpoint_id!r} is not in the checkpointer"
|
|
191
|
+
)
|
|
192
|
+
return snapshot
|
|
193
|
+
|
|
194
|
+
def _append_user(self, message: dict[str, Any]) -> dict[str, Any]:
|
|
195
|
+
entry = {"id": message["id"], "type": "human", "content": _user_text(message)}
|
|
196
|
+
if not any(m["id"] == entry["id"] for m in plain(self._state["messages"])):
|
|
197
|
+
self._state["messages"].append(entry)
|
|
198
|
+
return entry
|
|
199
|
+
|
|
200
|
+
def _replace(self, messages: list[dict[str, Any]]) -> None:
|
|
201
|
+
from statewire.langgraph import replace_messages
|
|
202
|
+
|
|
203
|
+
replace_messages(self._state, messages)
|
|
204
|
+
|
|
205
|
+
def _restamp(self) -> None:
|
|
206
|
+
stamped = []
|
|
207
|
+
for message in self._thread.transcript:
|
|
208
|
+
branch, siblings = self._thread.sibling_info(message["id"])
|
|
209
|
+
stamped.append({**message, "branch": {"id": branch, "siblings": siblings}})
|
|
210
|
+
self._replace(stamped)
|
|
@@ -48,6 +48,7 @@ window.
|
|
|
48
48
|
"""
|
|
49
49
|
|
|
50
50
|
import asyncio
|
|
51
|
+
import contextlib
|
|
51
52
|
import logging
|
|
52
53
|
from dataclasses import dataclass, field
|
|
53
54
|
from typing import Any, AsyncIterator, Callable, Coroutine, Iterable, Protocol
|
|
@@ -1610,7 +1611,7 @@ class RunManagerNamespace(TrayMapNamespace):
|
|
|
1610
1611
|
f"{type(api).__name__} must implement get_message_meta "
|
|
1611
1612
|
"(RunManager.Thread) to host @run_manager"
|
|
1612
1613
|
)
|
|
1613
|
-
self.
|
|
1614
|
+
self._mint: "asyncio.Task[Any] | None" = None
|
|
1614
1615
|
self._mounted: list[RunManager] = []
|
|
1615
1616
|
api.state["runs"] = []
|
|
1616
1617
|
api.state["status"] = "ready"
|
|
@@ -1628,14 +1629,26 @@ class RunManagerNamespace(TrayMapNamespace):
|
|
|
1628
1629
|
return tray
|
|
1629
1630
|
|
|
1630
1631
|
async def _machine(self, run_id: str) -> tuple[RunManager, bool]:
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1632
|
+
# No await between the live() check and the mint's publication, and no
|
|
1633
|
+
# lock held across storage I/O: same-tick callers await the shared mint
|
|
1634
|
+
# task and merge into the machine it mounts; the creator raises its
|
|
1635
|
+
# mint's failure while followers re-mint and progress.
|
|
1636
|
+
while True:
|
|
1635
1637
|
tray = self.live()
|
|
1636
1638
|
if tray is not None:
|
|
1637
1639
|
return tray, False
|
|
1638
|
-
|
|
1640
|
+
pending = self._mint
|
|
1641
|
+
if pending is None:
|
|
1642
|
+
pending = self._api.create_task(self.get_or_create(run_id))
|
|
1643
|
+
self._mint = pending
|
|
1644
|
+
pending.add_done_callback(self._mint_settled)
|
|
1645
|
+
return await pending, True
|
|
1646
|
+
with contextlib.suppress(Exception):
|
|
1647
|
+
await pending
|
|
1648
|
+
|
|
1649
|
+
def _mint_settled(self, task: "asyncio.Task[Any]") -> None:
|
|
1650
|
+
if self._mint is task:
|
|
1651
|
+
self._mint = None
|
|
1639
1652
|
|
|
1640
1653
|
# ─── Mounted view ───────────────────────────────────────
|
|
1641
1654
|
|
|
@@ -663,11 +663,12 @@ def postgres_dsn():
|
|
|
663
663
|
subprocess.run([docker, "rm", "-f", container], capture_output=True)
|
|
664
664
|
|
|
665
665
|
|
|
666
|
-
@pytest.fixture
|
|
667
|
-
async def pg_index(postgres_dsn):
|
|
666
|
+
@pytest.fixture(params=["conn", "pool"])
|
|
667
|
+
async def pg_index(request, postgres_dsn):
|
|
668
668
|
import uuid
|
|
669
669
|
|
|
670
670
|
import psycopg
|
|
671
|
+
from psycopg_pool import AsyncConnectionPool
|
|
671
672
|
|
|
672
673
|
from harness_sdk import PostgresCheckpointIndex
|
|
673
674
|
|
|
@@ -675,15 +676,29 @@ async def pg_index(postgres_dsn):
|
|
|
675
676
|
with psycopg.connect(postgres_dsn, autocommit=True) as admin:
|
|
676
677
|
admin.execute(f'CREATE DATABASE "{dbname}"')
|
|
677
678
|
dsn = postgres_dsn.rsplit("/", 1)[0] + f"/{dbname}"
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
679
|
+
if request.param == "conn":
|
|
680
|
+
conn = await psycopg.AsyncConnection.connect(dsn, autocommit=True)
|
|
681
|
+
index = PostgresCheckpointIndex(conn)
|
|
682
|
+
await index.setup()
|
|
683
|
+
yield index
|
|
684
|
+
await conn.close()
|
|
685
|
+
else:
|
|
686
|
+
async with AsyncConnectionPool(dsn, open=False) as pool:
|
|
687
|
+
index = PostgresCheckpointIndex(pool)
|
|
688
|
+
await index.setup()
|
|
689
|
+
yield index
|
|
683
690
|
with psycopg.connect(postgres_dsn, autocommit=True) as admin:
|
|
684
691
|
admin.execute(f'DROP DATABASE "{dbname}" WITH (FORCE)')
|
|
685
692
|
|
|
686
693
|
|
|
694
|
+
def test_postgres_index_rejects_other_conn_types():
|
|
695
|
+
pytest.importorskip("psycopg")
|
|
696
|
+
from harness_sdk import PostgresCheckpointIndex
|
|
697
|
+
|
|
698
|
+
with pytest.raises(TypeError, match="AsyncConnection or AsyncConnectionPool"):
|
|
699
|
+
PostgresCheckpointIndex(object())
|
|
700
|
+
|
|
701
|
+
|
|
687
702
|
async def test_postgres_branches_round_trip(pg_index):
|
|
688
703
|
assert await pg_index.read_thread(THREAD, "") == []
|
|
689
704
|
first = await pg_index.append_branch(
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""Contract: DeepagentsMessages at its public seam — a langgraph checkpointer
|
|
2
|
+
projected onto ``state["messages"]``, driven the way the host's stream loop
|
|
3
|
+
drives it (plan_turn, append_event per stream event, ingest_turn per turn)."""
|
|
4
|
+
|
|
5
|
+
import itertools
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from harness_sdk import DeepagentsMessages, MemoryCheckpointIndex
|
|
11
|
+
|
|
12
|
+
pytest.importorskip("langgraph.graph")
|
|
13
|
+
|
|
14
|
+
from langchain_core.messages import AIMessage, RemoveMessage # noqa: E402
|
|
15
|
+
from langgraph.checkpoint.memory import InMemorySaver # noqa: E402
|
|
16
|
+
from langgraph.graph import START, StateGraph # noqa: E402
|
|
17
|
+
from langgraph.graph.message import REMOVE_ALL_MESSAGES, add_messages # noqa: E402
|
|
18
|
+
|
|
19
|
+
THREAD = "t1"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _graph(saver):
|
|
23
|
+
from typing import Annotated, TypedDict
|
|
24
|
+
|
|
25
|
+
counter = itertools.count()
|
|
26
|
+
|
|
27
|
+
class State(TypedDict):
|
|
28
|
+
messages: Annotated[list, add_messages]
|
|
29
|
+
|
|
30
|
+
def node(state):
|
|
31
|
+
n = next(counter)
|
|
32
|
+
return {"messages": [AIMessage(id=f"a{n}", content=f"reply {n}")]}
|
|
33
|
+
|
|
34
|
+
builder = StateGraph(State)
|
|
35
|
+
builder.add_node("node", node)
|
|
36
|
+
builder.add_edge(START, "node")
|
|
37
|
+
return builder.compile(checkpointer=saver)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class Ctx:
|
|
42
|
+
trigger: str
|
|
43
|
+
messages: tuple = ()
|
|
44
|
+
resume: bool = False
|
|
45
|
+
has_rollback: bool = False
|
|
46
|
+
rollback_to: str | None = None
|
|
47
|
+
applied_count: int = 0
|
|
48
|
+
|
|
49
|
+
def applied(self):
|
|
50
|
+
self.applied_count += 1
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def user(id, text):
|
|
54
|
+
return {"id": id, "role": "user", "parts": [{"type": "text", "text": text}]}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def booted(saver, index):
|
|
58
|
+
state = {"messages": []}
|
|
59
|
+
messages = DeepagentsMessages(saver)
|
|
60
|
+
await messages.boot(state, index=index, thread_id=THREAD)
|
|
61
|
+
return messages, state
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def stream(graph, messages, payload, config):
|
|
65
|
+
async for namespace, mode, event in graph.astream(
|
|
66
|
+
payload,
|
|
67
|
+
config=config,
|
|
68
|
+
stream_mode=["messages", "updates", "checkpoints"],
|
|
69
|
+
subgraphs=True,
|
|
70
|
+
):
|
|
71
|
+
messages.append_event(namespace, mode, event)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async def turn(graph, messages, ctx):
|
|
75
|
+
payload, config = await messages.plan_turn(ctx)
|
|
76
|
+
await stream(graph, messages, payload, config)
|
|
77
|
+
await messages.ingest_turn()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def ids(state):
|
|
81
|
+
return [m["id"] for m in state["messages"]]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def branch(state, message_id):
|
|
85
|
+
return next(m["branch"] for m in state["messages"] if m["id"] == message_id)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
async def seeded():
|
|
89
|
+
"""Two turns: [u1, a0, u2, a1] on branch 0."""
|
|
90
|
+
saver, index = InMemorySaver(), MemoryCheckpointIndex()
|
|
91
|
+
graph = _graph(saver)
|
|
92
|
+
messages, state = await booted(saver, index)
|
|
93
|
+
await turn(graph, messages, Ctx("message-send", (user("u1", "one"),)))
|
|
94
|
+
await turn(graph, messages, Ctx("message-send", (user("u2", "two"),)))
|
|
95
|
+
return saver, index, graph, messages, state
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# ─── Boot ───────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
async def test_boot_fresh_thread_projects_empty():
|
|
102
|
+
_, state = await booted(InMemorySaver(), MemoryCheckpointIndex())
|
|
103
|
+
assert state["messages"] == []
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
async def test_boot_rejects_a_non_saver():
|
|
107
|
+
with pytest.raises(TypeError, match="checkpoint saver"):
|
|
108
|
+
DeepagentsMessages(object())
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
async def test_reboot_restores_history_with_stamps():
|
|
112
|
+
saver, index, _, _, state = await seeded()
|
|
113
|
+
_, rebooted = await booted(saver, index)
|
|
114
|
+
assert rebooted["messages"] == state["messages"]
|
|
115
|
+
assert ids(rebooted) == ["u1", "a0", "u2", "a1"]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# ─── Turn streaming ─────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
async def test_send_turn_projects_and_stamps():
|
|
122
|
+
saver, index = InMemorySaver(), MemoryCheckpointIndex()
|
|
123
|
+
graph = _graph(saver)
|
|
124
|
+
messages, state = await booted(saver, index)
|
|
125
|
+
ctx = Ctx("message-send", (user("u1", "one"),))
|
|
126
|
+
await turn(graph, messages, ctx)
|
|
127
|
+
assert ctx.applied_count == 1
|
|
128
|
+
assert ids(state) == ["u1", "a0"]
|
|
129
|
+
assert [m["type"] for m in state["messages"]] == ["human", "ai"]
|
|
130
|
+
assert branch(state, "a0") == {"id": 0, "siblings": [0]}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
async def test_plan_turn_resume_carries_no_payload():
|
|
134
|
+
_, _, _, messages, _ = await seeded()
|
|
135
|
+
ctx = Ctx("message-send", resume=True)
|
|
136
|
+
payload, config = await messages.plan_turn(ctx)
|
|
137
|
+
assert payload is None
|
|
138
|
+
assert config == {"configurable": {"thread_id": THREAD}}
|
|
139
|
+
assert ctx.applied_count == 0
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
async def test_append_users_dedupes_by_id():
|
|
143
|
+
_, _, _, messages, state = await seeded()
|
|
144
|
+
entries = messages.append_users([user("u3", "three")])
|
|
145
|
+
assert entries == [{"id": "u3", "type": "human", "content": "three"}]
|
|
146
|
+
messages.append_users([user("u3", "three")])
|
|
147
|
+
assert ids(state).count("u3") == 1
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
async def test_ingest_turn_refetch_discards_the_capture():
|
|
151
|
+
saver, index = InMemorySaver(), MemoryCheckpointIndex()
|
|
152
|
+
graph = _graph(saver)
|
|
153
|
+
messages, state = await booted(saver, index)
|
|
154
|
+
payload, config = await messages.plan_turn(
|
|
155
|
+
Ctx("message-send", (user("u1", "one"),))
|
|
156
|
+
)
|
|
157
|
+
await stream(graph, messages, payload, config)
|
|
158
|
+
# the stopped run persisted a further exchange after the capture
|
|
159
|
+
await graph.ainvoke(
|
|
160
|
+
{"messages": [{"id": "u2", "type": "human", "content": "two"}]},
|
|
161
|
+
{"configurable": {"thread_id": THREAD}},
|
|
162
|
+
)
|
|
163
|
+
await messages.ingest_turn(refetch=True)
|
|
164
|
+
assert ids(state) == ["u1", "a0", "u2", "a1"]
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# ─── Rewind ─────────────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
async def test_reload_restores_the_window_from_the_anchor_checkpoint():
|
|
171
|
+
_, _, graph, messages, state = await seeded()
|
|
172
|
+
ctx = Ctx("message-reload", has_rollback=True, rollback_to="u2")
|
|
173
|
+
payload, config = await messages.plan_turn(ctx)
|
|
174
|
+
assert ids(state) == ["u1", "a0", "u2"]
|
|
175
|
+
assert config["configurable"]["checkpoint_id"] is not None
|
|
176
|
+
assert [m["id"] for m in payload["messages"]] == ["u2"]
|
|
177
|
+
await stream(graph, messages, payload, config)
|
|
178
|
+
await messages.ingest_turn()
|
|
179
|
+
assert ids(state) == ["u1", "a0", "u2", "a2"]
|
|
180
|
+
assert branch(state, "a2") == {"id": 1, "siblings": [0, 1]}
|
|
181
|
+
assert branch(state, "u2") == {"id": 0, "siblings": [0]}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
async def test_edit_of_the_first_message_starts_fresh():
|
|
185
|
+
_, _, graph, messages, state = await seeded()
|
|
186
|
+
ctx = Ctx(
|
|
187
|
+
"message-edit", (user("u1b", "redo"),), has_rollback=True, rollback_to=None
|
|
188
|
+
)
|
|
189
|
+
payload, config = await messages.plan_turn(ctx)
|
|
190
|
+
assert ids(state) == ["u1b"]
|
|
191
|
+
assert payload["messages"][0] == RemoveMessage(id=REMOVE_ALL_MESSAGES)
|
|
192
|
+
assert "checkpoint_id" not in config["configurable"]
|
|
193
|
+
await stream(graph, messages, payload, config)
|
|
194
|
+
await messages.ingest_turn()
|
|
195
|
+
assert ids(state) == ["u1b", "a2"]
|
|
196
|
+
assert branch(state, "u1b") == {"id": 1, "siblings": [0, 1]}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# ─── Branch switch ──────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
async def forked():
|
|
203
|
+
"""seeded() plus a reload at u2: branch 1 replaces a1 with a2."""
|
|
204
|
+
saver, index, graph, messages, state = await seeded()
|
|
205
|
+
await turn(
|
|
206
|
+
graph, messages, Ctx("message-reload", has_rollback=True, rollback_to="u2")
|
|
207
|
+
)
|
|
208
|
+
assert ids(state) == ["u1", "a0", "u2", "a2"]
|
|
209
|
+
return saver, index, graph, messages, state
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
async def test_switch_branch_rebaselines_and_stamps():
|
|
213
|
+
_, _, _, messages, state = await forked()
|
|
214
|
+
await messages.switch_branch(0)
|
|
215
|
+
assert ids(state) == ["u1", "a0", "u2", "a1"]
|
|
216
|
+
assert branch(state, "a1") == {"id": 0, "siblings": [0, 1]}
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
async def test_switch_branch_survives_a_reboot():
|
|
220
|
+
saver, index, _, messages, _ = await forked()
|
|
221
|
+
await messages.switch_branch(0)
|
|
222
|
+
_, state = await booted(saver, index)
|
|
223
|
+
assert ids(state) == ["u1", "a0", "u2", "a1"]
|
|
224
|
+
assert branch(state, "a1") == {"id": 0, "siblings": [0, 1]}
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
async def test_send_after_a_switch_continues_the_switched_world():
|
|
228
|
+
_, _, graph, messages, state = await forked()
|
|
229
|
+
await messages.switch_branch(0)
|
|
230
|
+
await turn(graph, messages, Ctx("message-send", (user("u3", "three"),)))
|
|
231
|
+
assert ids(state) == ["u1", "a0", "u2", "a1", "u3", "a3"]
|
|
232
|
+
assert branch(state, "u3") == {"id": 0, "siblings": [0]}
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
async def test_switch_branch_unknown_raises():
|
|
236
|
+
_, _, _, messages, _ = await seeded()
|
|
237
|
+
with pytest.raises(KeyError, match="unknown branch_id"):
|
|
238
|
+
await messages.switch_branch(7)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from pinned.testing import MemoryStateStore
|
|
5
|
+
from run_helpers import Script, run_host
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class WedgeStore(MemoryStateStore):
|
|
9
|
+
"""get_tray parks for flagged tray ids until ``fail()`` fires the store's
|
|
10
|
+
op deadline, the way a half-open conn parks a postgres transaction."""
|
|
11
|
+
|
|
12
|
+
def __init__(self) -> None:
|
|
13
|
+
super().__init__()
|
|
14
|
+
self.wedged: set[str] = set()
|
|
15
|
+
self.parked = asyncio.Event()
|
|
16
|
+
self._deadline = asyncio.Event()
|
|
17
|
+
|
|
18
|
+
def fail(self) -> None:
|
|
19
|
+
self._deadline.set()
|
|
20
|
+
|
|
21
|
+
async def get_tray(self, ns, id, tray_ns, tray_id, epoch):
|
|
22
|
+
if tray_id in self.wedged:
|
|
23
|
+
self.wedged.discard(tray_id)
|
|
24
|
+
self.parked.set()
|
|
25
|
+
await self._deadline.wait()
|
|
26
|
+
raise TimeoutError("store op deadline")
|
|
27
|
+
return await super().get_tray(ns, id, tray_ns, tray_id, epoch)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def test_a_parked_mint_fails_at_the_deadline_and_followers_progress():
|
|
31
|
+
"""No lock is held across mint storage I/O: a wedged mint fails loudly at
|
|
32
|
+
the store deadline, the waiting command re-mints its own machine, and the
|
|
33
|
+
wedged command surfaces the store error."""
|
|
34
|
+
script = Script()
|
|
35
|
+
store = WedgeStore()
|
|
36
|
+
async with run_host(script, store=store) as (drv, host):
|
|
37
|
+
api = host.live
|
|
38
|
+
store.wedged.add("run-a")
|
|
39
|
+
parked = asyncio.create_task(api.runs.for_run("run-a"))
|
|
40
|
+
await asyncio.wait_for(store.parked.wait(), 5)
|
|
41
|
+
follower = asyncio.create_task(api.runs.for_run("run-b"))
|
|
42
|
+
await asyncio.sleep(0.05)
|
|
43
|
+
assert not follower.done() # same-tick mints merge: followers await the pending mint
|
|
44
|
+
store.fail()
|
|
45
|
+
tray = await asyncio.wait_for(follower, 5)
|
|
46
|
+
assert tray.id == "run-b"
|
|
47
|
+
with pytest.raises(TimeoutError):
|
|
48
|
+
await parked
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
async def test_concurrent_same_run_id_mints_resolve_to_one_machine():
|
|
52
|
+
script = Script()
|
|
53
|
+
async with run_host(script) as (drv, host):
|
|
54
|
+
api = host.live
|
|
55
|
+
trays = await asyncio.gather(
|
|
56
|
+
*(asyncio.create_task(api.runs.for_run("run-x")) for _ in range(3))
|
|
57
|
+
)
|
|
58
|
+
assert all(tray is trays[0] for tray in trays)
|
|
59
|
+
assert list(api.runs.instances) == ["run-x"]
|
|
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
|
|
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
|