harness-sdk-python 0.15.0__tar.gz → 0.16.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.15.0 → harness_sdk_python-0.16.0}/.gitignore +1 -0
- {harness_sdk_python-0.15.0 → harness_sdk_python-0.16.0}/PKG-INFO +3 -2
- {harness_sdk_python-0.15.0 → harness_sdk_python-0.16.0}/examples/runs_app.py +6 -7
- {harness_sdk_python-0.15.0 → harness_sdk_python-0.16.0}/pyproject.toml +3 -2
- harness_sdk_python-0.16.0/src/harness_sdk/__init__.py +11 -0
- {harness_sdk_python-0.15.0 → harness_sdk_python-0.16.0}/src/harness_sdk/fenced_postgres.py +97 -14
- {harness_sdk_python-0.15.0 → harness_sdk_python-0.16.0}/src/harness_sdk/run_manager.py +9 -1
- harness_sdk_python-0.16.0/src/harness_sdk/run_trays.py +363 -0
- {harness_sdk_python-0.15.0 → harness_sdk_python-0.16.0}/tests/test_fenced_postgres.py +159 -0
- harness_sdk_python-0.16.0/tests/test_run_tray_durability.py +339 -0
- harness_sdk_python-0.16.0/tests/test_run_trays.py +544 -0
- harness_sdk_python-0.15.0/src/harness_sdk/__init__.py +0 -4
- {harness_sdk_python-0.15.0 → harness_sdk_python-0.16.0}/README.md +0 -0
- {harness_sdk_python-0.15.0 → harness_sdk_python-0.16.0}/examples/__init__.py +0 -0
- {harness_sdk_python-0.15.0 → harness_sdk_python-0.16.0}/src/harness_sdk/linear_thread.py +0 -0
- {harness_sdk_python-0.15.0 → harness_sdk_python-0.16.0}/tests/run_helpers.py +0 -0
- {harness_sdk_python-0.15.0 → harness_sdk_python-0.16.0}/tests/test_applied_visibility.py +0 -0
- {harness_sdk_python-0.15.0 → harness_sdk_python-0.16.0}/tests/test_batches.py +0 -0
- {harness_sdk_python-0.15.0 → harness_sdk_python-0.16.0}/tests/test_branch_anchor.py +0 -0
- {harness_sdk_python-0.15.0 → harness_sdk_python-0.16.0}/tests/test_dispatching.py +0 -0
- {harness_sdk_python-0.15.0 → harness_sdk_python-0.16.0}/tests/test_edit_dispatched.py +0 -0
- {harness_sdk_python-0.15.0 → harness_sdk_python-0.16.0}/tests/test_edit_reload.py +0 -0
- {harness_sdk_python-0.15.0 → harness_sdk_python-0.16.0}/tests/test_enqueue.py +0 -0
- {harness_sdk_python-0.15.0 → harness_sdk_python-0.16.0}/tests/test_input_required.py +0 -0
- {harness_sdk_python-0.15.0 → harness_sdk_python-0.16.0}/tests/test_linear_thread.py +0 -0
- {harness_sdk_python-0.15.0 → harness_sdk_python-0.16.0}/tests/test_meta.py +0 -0
- {harness_sdk_python-0.15.0 → harness_sdk_python-0.16.0}/tests/test_outcomes.py +0 -0
- {harness_sdk_python-0.15.0 → harness_sdk_python-0.16.0}/tests/test_placement.py +0 -0
- {harness_sdk_python-0.15.0 → harness_sdk_python-0.16.0}/tests/test_prepare_hooks.py +0 -0
- {harness_sdk_python-0.15.0 → harness_sdk_python-0.16.0}/tests/test_restore.py +0 -0
- {harness_sdk_python-0.15.0 → harness_sdk_python-0.16.0}/tests/test_rewind_during_run.py +0 -0
- {harness_sdk_python-0.15.0 → harness_sdk_python-0.16.0}/tests/test_settle.py +0 -0
- {harness_sdk_python-0.15.0 → harness_sdk_python-0.16.0}/tests/test_steer.py +0 -0
- {harness_sdk_python-0.15.0 → harness_sdk_python-0.16.0}/tests/test_stop_continue.py +0 -0
- {harness_sdk_python-0.15.0 → harness_sdk_python-0.16.0}/tests/test_stopping_window.py +0 -0
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: harness-sdk-python
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.16.0
|
|
4
4
|
Summary: RunManager: the harness-sdk runs subsystem for Python Statewire hosts
|
|
5
5
|
Project-URL: Repository, https://github.com/assistant-ui/harness-sdk
|
|
6
6
|
License-Expression: MIT
|
|
7
7
|
Requires-Python: <4.0,>=3.12
|
|
8
|
-
Requires-Dist:
|
|
8
|
+
Requires-Dist: pinned<0.11,>=0.10.0
|
|
9
|
+
Requires-Dist: statewire<0.10,>=0.9.0
|
|
9
10
|
Provides-Extra: deepagents
|
|
10
11
|
Requires-Dist: deepagents>=0.6.12; extra == 'deepagents'
|
|
11
12
|
Requires-Dist: langchain-core>=0.3; extra == 'deepagents'
|
|
@@ -19,7 +19,7 @@ from typing import Any
|
|
|
19
19
|
|
|
20
20
|
from fastapi import FastAPI
|
|
21
21
|
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
|
|
22
|
-
from pinned import PinnedHost
|
|
22
|
+
from pinned import PinnedHost, PinnedWorker
|
|
23
23
|
from statewire import Statewire, command, plain
|
|
24
24
|
from statewire.langgraph import append_langgraph_event
|
|
25
25
|
|
|
@@ -189,13 +189,12 @@ class RunsHost(Statewire):
|
|
|
189
189
|
return await self.runs.input(params)
|
|
190
190
|
|
|
191
191
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
RunsHost,
|
|
195
|
-
pinboard_url=_pinboard_url,
|
|
192
|
+
worker = PinnedWorker(
|
|
193
|
+
pinboard_url=os.environ.get("PINBOARD_URL"),
|
|
196
194
|
token=os.environ.get("PINBOARD_TOKEN"),
|
|
197
195
|
advertise_url=os.environ.get("ADVERTISE_URL"),
|
|
198
|
-
namespace="/threads" if _pinboard_url else None,
|
|
199
196
|
)
|
|
197
|
+
router = PinnedHost(RunsHost, worker=worker, namespace="/threads")
|
|
200
198
|
app = FastAPI(lifespan=router.lifespan)
|
|
201
|
-
app.include_router(
|
|
199
|
+
app.include_router(worker)
|
|
200
|
+
app.include_router(router)
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "harness-sdk-python"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.16.0"
|
|
4
4
|
description = "RunManager: the harness-sdk runs subsystem for Python Statewire hosts"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
7
7
|
requires-python = ">=3.12,<4.0"
|
|
8
|
-
dependencies = ["statewire>=0.
|
|
8
|
+
dependencies = ["statewire>=0.9.0,<0.10", "pinned>=0.10.0,<0.11"]
|
|
9
9
|
|
|
10
10
|
[project.optional-dependencies]
|
|
11
11
|
postgres = ["langgraph-checkpoint-postgres>=2.0.0"]
|
|
@@ -22,6 +22,7 @@ Repository = "https://github.com/assistant-ui/harness-sdk"
|
|
|
22
22
|
|
|
23
23
|
[tool.uv.sources]
|
|
24
24
|
statewire = { workspace = true }
|
|
25
|
+
pinned = { workspace = true }
|
|
25
26
|
|
|
26
27
|
[build-system]
|
|
27
28
|
requires = ["hatchling"]
|
|
@@ -86,6 +86,21 @@ def _bump_sql(fence_table: str) -> SQL:
|
|
|
86
86
|
).format(t=Identifier(fence_table))
|
|
87
87
|
|
|
88
88
|
|
|
89
|
+
def _adopt_sql(fence_table: str) -> SQL:
|
|
90
|
+
return SQL(
|
|
91
|
+
"INSERT INTO {t} (thread_id, epoch) VALUES (%s, %s) "
|
|
92
|
+
"ON CONFLICT (thread_id) DO UPDATE SET epoch = excluded.epoch "
|
|
93
|
+
"WHERE {t}.epoch <= excluded.epoch "
|
|
94
|
+
"RETURNING epoch"
|
|
95
|
+
).format(t=Identifier(fence_table))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _require_epoch(epoch: int) -> int:
|
|
99
|
+
if isinstance(epoch, bool) or not isinstance(epoch, int) or epoch < 0:
|
|
100
|
+
raise ValueError(f"epoch must be a non-negative integer, got {epoch!r}")
|
|
101
|
+
return epoch
|
|
102
|
+
|
|
103
|
+
|
|
89
104
|
def _assert_sql(fence_table: str) -> SQL:
|
|
90
105
|
return SQL("SELECT {}(%s, %s)").format(Identifier(_assert_fn(fence_table)))
|
|
91
106
|
|
|
@@ -125,30 +140,43 @@ class AsyncFencedPostgresSaver(AsyncPostgresSaver):
|
|
|
125
140
|
self.fence_table = _require_fence_table(fence_table)
|
|
126
141
|
self.on_fence_lost = on_fence_lost
|
|
127
142
|
self._fence_lost_notified = False
|
|
143
|
+
self._fence_lost_terminal = False
|
|
128
144
|
|
|
129
145
|
@staticmethod
|
|
130
|
-
async def
|
|
146
|
+
async def _ensure_fence_objects(
|
|
131
147
|
cur: "AsyncCursor[DictRow]",
|
|
132
|
-
thread_id: str,
|
|
133
148
|
*,
|
|
134
149
|
create_fence_table: bool,
|
|
135
150
|
fence_table: str,
|
|
136
|
-
) ->
|
|
151
|
+
) -> None:
|
|
137
152
|
if create_fence_table:
|
|
138
153
|
await cur.execute(_create_sql(fence_table))
|
|
139
154
|
await cur.execute(_create_fn_sql(fence_table))
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
155
|
+
return
|
|
156
|
+
await cur.execute(
|
|
157
|
+
"SELECT to_regclass(%s) AS tbl, to_regprocedure(%s) AS fn",
|
|
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}"
|
|
144
166
|
)
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
167
|
+
|
|
168
|
+
@classmethod
|
|
169
|
+
async def _validate_and_bump(
|
|
170
|
+
cls,
|
|
171
|
+
cur: "AsyncCursor[DictRow]",
|
|
172
|
+
thread_id: str,
|
|
173
|
+
*,
|
|
174
|
+
create_fence_table: bool,
|
|
175
|
+
fence_table: str,
|
|
176
|
+
) -> int:
|
|
177
|
+
await cls._ensure_fence_objects(
|
|
178
|
+
cur, create_fence_table=create_fence_table, fence_table=fence_table
|
|
179
|
+
)
|
|
152
180
|
await cur.execute(_bump_sql(fence_table), (thread_id,))
|
|
153
181
|
return (await cur.fetchone())["epoch"]
|
|
154
182
|
|
|
@@ -218,7 +246,54 @@ class AsyncFencedPostgresSaver(AsyncPostgresSaver):
|
|
|
218
246
|
on_fence_lost=on_fence_lost,
|
|
219
247
|
)
|
|
220
248
|
|
|
249
|
+
@classmethod
|
|
250
|
+
async def adopt(
|
|
251
|
+
cls,
|
|
252
|
+
conn: _ainternal.Conn,
|
|
253
|
+
thread_id: str,
|
|
254
|
+
epoch: int,
|
|
255
|
+
*,
|
|
256
|
+
create_fence_table: bool,
|
|
257
|
+
pipe: AsyncPipeline | None = None,
|
|
258
|
+
serde: SerializerProtocol | None = None,
|
|
259
|
+
fence_table: str = DEFAULT_FENCE_TABLE,
|
|
260
|
+
on_fence_lost: Callable[[], None] | None = None,
|
|
261
|
+
) -> "AsyncFencedPostgresSaver":
|
|
262
|
+
"""Install a caller-owned epoch (the placement epoch) into the fence row, so one epoch fences the tray store and the checkpoints; a newer stored epoch raises ``FenceLost``."""
|
|
263
|
+
_require_thread_id(thread_id)
|
|
264
|
+
_require_epoch(epoch)
|
|
265
|
+
_require_fence_table(fence_table)
|
|
266
|
+
async with _ainternal.get_connection(conn) as c:
|
|
267
|
+
if c.info.transaction_status != TransactionStatus.IDLE:
|
|
268
|
+
raise RuntimeError(
|
|
269
|
+
"adopt requires a connection with no transaction in progress"
|
|
270
|
+
)
|
|
271
|
+
async with (
|
|
272
|
+
c.transaction(),
|
|
273
|
+
c.cursor(binary=True, row_factory=dict_row) as cur,
|
|
274
|
+
):
|
|
275
|
+
await cls._ensure_fence_objects(
|
|
276
|
+
cur,
|
|
277
|
+
create_fence_table=create_fence_table,
|
|
278
|
+
fence_table=fence_table,
|
|
279
|
+
)
|
|
280
|
+
await cur.execute(_adopt_sql(fence_table), (thread_id, epoch))
|
|
281
|
+
if await cur.fetchone() is None:
|
|
282
|
+
raise FenceLost(thread_id, epoch)
|
|
283
|
+
if pipe is not None:
|
|
284
|
+
await pipe.sync()
|
|
285
|
+
return cls(
|
|
286
|
+
conn,
|
|
287
|
+
thread_id,
|
|
288
|
+
epoch,
|
|
289
|
+
pipe=pipe,
|
|
290
|
+
serde=serde,
|
|
291
|
+
fence_table=fence_table,
|
|
292
|
+
on_fence_lost=on_fence_lost,
|
|
293
|
+
)
|
|
294
|
+
|
|
221
295
|
def _fence_lost(self) -> FenceLost:
|
|
296
|
+
self._fence_lost_terminal = True
|
|
222
297
|
if self.on_fence_lost is not None and not self._fence_lost_notified:
|
|
223
298
|
self._fence_lost_notified = True
|
|
224
299
|
self.on_fence_lost()
|
|
@@ -262,6 +337,12 @@ class AsyncFencedPostgresSaver(AsyncPostgresSaver):
|
|
|
262
337
|
row = await cur.fetchone()
|
|
263
338
|
return row is None or row["epoch"] != self.epoch
|
|
264
339
|
|
|
340
|
+
# Fence loss is terminal: the epoch can never become current again, so
|
|
341
|
+
# later writes fail before serializing or round-tripping to Postgres.
|
|
342
|
+
def _raise_if_fence_lost(self) -> None:
|
|
343
|
+
if self._fence_lost_terminal:
|
|
344
|
+
raise FenceLost(self.thread_id, self.epoch)
|
|
345
|
+
|
|
265
346
|
async def aput(
|
|
266
347
|
self,
|
|
267
348
|
config: RunnableConfig,
|
|
@@ -269,6 +350,7 @@ class AsyncFencedPostgresSaver(AsyncPostgresSaver):
|
|
|
269
350
|
metadata: Any,
|
|
270
351
|
new_versions: Any,
|
|
271
352
|
) -> RunnableConfig:
|
|
353
|
+
self._raise_if_fence_lost()
|
|
272
354
|
self._bound_to(config["configurable"]["thread_id"])
|
|
273
355
|
return await super().aput(config, checkpoint, metadata, new_versions)
|
|
274
356
|
|
|
@@ -279,6 +361,7 @@ class AsyncFencedPostgresSaver(AsyncPostgresSaver):
|
|
|
279
361
|
task_id: str,
|
|
280
362
|
task_path: str = "",
|
|
281
363
|
) -> None:
|
|
364
|
+
self._raise_if_fence_lost()
|
|
282
365
|
self._bound_to(config["configurable"]["thread_id"])
|
|
283
366
|
await super().aput_writes(config, writes, task_id, task_path)
|
|
284
367
|
|
|
@@ -36,11 +36,14 @@ staged dispatch, whose command already settled at its admission write.
|
|
|
36
36
|
"""
|
|
37
37
|
|
|
38
38
|
import asyncio
|
|
39
|
+
import logging
|
|
39
40
|
from dataclasses import dataclass, field
|
|
40
41
|
from typing import Any, Awaitable, Callable, Iterable, Protocol
|
|
41
42
|
|
|
42
43
|
from statewire import StatewireReject
|
|
43
|
-
from statewire
|
|
44
|
+
from statewire import plain
|
|
45
|
+
|
|
46
|
+
logger = logging.getLogger(__name__)
|
|
44
47
|
|
|
45
48
|
_ABSENT: Any = object()
|
|
46
49
|
_PARKED: Any = object()
|
|
@@ -590,6 +593,11 @@ class RunManager:
|
|
|
590
593
|
if isinstance(exc, StatewireReject):
|
|
591
594
|
self._freeze(exc.message, exc.payload)
|
|
592
595
|
else:
|
|
596
|
+
logger.exception(
|
|
597
|
+
"run %r crashed in %s",
|
|
598
|
+
self._entry().get("runId"),
|
|
599
|
+
getattr(self._run, "__qualname__", repr(self._run)),
|
|
600
|
+
)
|
|
593
601
|
self._freeze(message)
|
|
594
602
|
self._drain()
|
|
595
603
|
return
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
"""RunTray + ``@run_manager``: the durable run dispatch queue of a Statewire host.
|
|
2
|
+
|
|
3
|
+
A ``RunTray`` instance is one thread's run lane: sends queue (or steer into
|
|
4
|
+
the live run), the head of the queue moves into the ``dispatching`` outbox
|
|
5
|
+
and the handler runs it, the outcome settles into the next dispatch —
|
|
6
|
+
``input-required`` and un-dispatched ``error``/``stop`` park in
|
|
7
|
+
``nextDispatch`` until ``input``/``continueRun`` release them. Every move
|
|
8
|
+
records the dispatch before clearing its source in one storage segment, so
|
|
9
|
+
a crash replays the in-flight dispatch (at least once, never lost); a steer
|
|
10
|
+
dispatch carries the steer lane it drained as ``messages``. ``epoch`` counts
|
|
11
|
+
the lane's dispatches; ``stop(epoch=...)`` rejects any other value, so a
|
|
12
|
+
replayed stop never ends a later dispatch. In-flight run progress rides
|
|
13
|
+
``ctx.state``: graph runs bind ``{thread_id, checkpoint_ns, runId}`` and
|
|
14
|
+
resume from the checkpointer's latest checkpoint on redelivery; non-graph
|
|
15
|
+
handlers use ``ctx.state`` itself as the checkpoint.
|
|
16
|
+
``@run_manager`` declares the tray on a Statewire host, registers
|
|
17
|
+
the run commands routed by thread id, and mounts each tray's state into
|
|
18
|
+
``state["runs"]``.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from typing import Any, Callable, Coroutine
|
|
22
|
+
|
|
23
|
+
from pinned.trays import Tray, TrayDescriptor, TrayMapNamespace, inbox
|
|
24
|
+
from pinned.traystore import persistent, persistent_outbox, persistent_queue
|
|
25
|
+
from statepatch import observable
|
|
26
|
+
from statewire import command, plain
|
|
27
|
+
|
|
28
|
+
_OUTCOMES = ("complete", "input-required", "error", "stop")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class RunTray(Tray):
|
|
32
|
+
queue = persistent_queue()
|
|
33
|
+
steerQueue = persistent_queue()
|
|
34
|
+
dispatching = persistent_outbox()
|
|
35
|
+
nextDispatch = persistent(None)
|
|
36
|
+
epoch = persistent(0)
|
|
37
|
+
isStopping = observable(False)
|
|
38
|
+
|
|
39
|
+
state = observable(
|
|
40
|
+
{
|
|
41
|
+
"id": None,
|
|
42
|
+
"queue": queue,
|
|
43
|
+
"steerQueue": steerQueue,
|
|
44
|
+
"dispatching": dispatching,
|
|
45
|
+
"nextDispatch": nextDispatch,
|
|
46
|
+
"epoch": epoch,
|
|
47
|
+
"isStopping": isStopping,
|
|
48
|
+
}
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
async def lifecycle(self):
|
|
52
|
+
self.state["id"] = self.id
|
|
53
|
+
yield
|
|
54
|
+
|
|
55
|
+
def items(self):
|
|
56
|
+
"""Each dispatch: outbox record then source clear land in one atomic
|
|
57
|
+
segment, in that order, so a mid-segment crash duplicates instead of
|
|
58
|
+
losing (the outbox bump is the durable attempt counter); the
|
|
59
|
+
outcome settles ack + continuation in another segment."""
|
|
60
|
+
while True:
|
|
61
|
+
recorded = self.dispatching.value
|
|
62
|
+
if recorded is not None:
|
|
63
|
+
item = recorded["item"]
|
|
64
|
+
self.dispatching.dispatch(item) # redelivery bumps the attempt
|
|
65
|
+
# a crash between the record and the source clear leaves both
|
|
66
|
+
if self.queue.value and self.queue.peek() == item:
|
|
67
|
+
self.queue.popleft()
|
|
68
|
+
for message in item.get("messages", ()):
|
|
69
|
+
if self.steerQueue.value and self.steerQueue.peek() == message:
|
|
70
|
+
self.steerQueue.popleft()
|
|
71
|
+
pending = self.nextDispatch.value
|
|
72
|
+
if pending is not None and {
|
|
73
|
+
k: v for k, v in pending.items() if k != "ready"
|
|
74
|
+
} == item:
|
|
75
|
+
self.nextDispatch.value = None
|
|
76
|
+
else:
|
|
77
|
+
pending = self.nextDispatch.value
|
|
78
|
+
if pending is not None:
|
|
79
|
+
# an unreleased continuation parks the whole lane
|
|
80
|
+
if not pending.get("ready"):
|
|
81
|
+
return
|
|
82
|
+
item = {k: v for k, v in pending.items() if k != "ready"}
|
|
83
|
+
self._dispatch_fresh(item)
|
|
84
|
+
self.nextDispatch.value = None
|
|
85
|
+
elif self.queue.value:
|
|
86
|
+
item = self.queue.peek()
|
|
87
|
+
self._dispatch_fresh(item)
|
|
88
|
+
self.queue.popleft()
|
|
89
|
+
elif self.steerQueue.value:
|
|
90
|
+
item = {"trigger": "steer", "messages": self.steerQueue.value}
|
|
91
|
+
self._dispatch_fresh(item)
|
|
92
|
+
self.steerQueue.value = []
|
|
93
|
+
else:
|
|
94
|
+
return
|
|
95
|
+
yield item
|
|
96
|
+
|
|
97
|
+
def _dispatch_fresh(self, item: Any) -> None:
|
|
98
|
+
self.epoch.value = self.epoch.value + 1
|
|
99
|
+
self.dispatching.dispatch(item)
|
|
100
|
+
|
|
101
|
+
async def _run_item(
|
|
102
|
+
self, item: Any, fn: Callable[..., Coroutine[Any, Any, Any]]
|
|
103
|
+
) -> Any:
|
|
104
|
+
outcome = await super()._run_item(item, self._validated(fn))
|
|
105
|
+
self._settle(outcome)
|
|
106
|
+
return outcome
|
|
107
|
+
|
|
108
|
+
def _make_ctx(self) -> Any:
|
|
109
|
+
ctx = super()._make_ctx()
|
|
110
|
+
if self.isStopping.value:
|
|
111
|
+
ctx.cancelled.set()
|
|
112
|
+
return ctx
|
|
113
|
+
|
|
114
|
+
def _record_delivery(self) -> int:
|
|
115
|
+
return self.dispatching.attempt # the outbox dispatch() already counted durably
|
|
116
|
+
|
|
117
|
+
def _ack_delivery(self) -> None:
|
|
118
|
+
pass # the outbox ack in _settle is the durable reset
|
|
119
|
+
|
|
120
|
+
def _validated(
|
|
121
|
+
self, fn: Callable[..., Coroutine[Any, Any, Any]]
|
|
122
|
+
) -> Callable[..., Coroutine[Any, Any, Any]]:
|
|
123
|
+
# an invalid outcome aborts the instance with the outbox unacked;
|
|
124
|
+
# the dispatch redelivers on the next activation
|
|
125
|
+
async def run(item: Any, ctx: Any) -> Any:
|
|
126
|
+
outcome = plain(await fn(item, ctx))
|
|
127
|
+
if outcome is None:
|
|
128
|
+
return {"type": "complete"}
|
|
129
|
+
kind = outcome.get("type") if isinstance(outcome, dict) else None
|
|
130
|
+
if kind not in _OUTCOMES:
|
|
131
|
+
raise TypeError(
|
|
132
|
+
f"run handler must return an outcome dict with type in {_OUTCOMES}, got {outcome!r}"
|
|
133
|
+
)
|
|
134
|
+
if kind == "input-required" and not isinstance(
|
|
135
|
+
outcome.get("requests"), list
|
|
136
|
+
):
|
|
137
|
+
raise TypeError(
|
|
138
|
+
f"input-required outcome must carry a 'requests' list, got {outcome!r}"
|
|
139
|
+
)
|
|
140
|
+
return outcome
|
|
141
|
+
|
|
142
|
+
return run
|
|
143
|
+
|
|
144
|
+
def _settle(self, outcome: Any) -> None:
|
|
145
|
+
if outcome is None: # user cancel consumed the item without a result
|
|
146
|
+
outcome = {"type": "complete"}
|
|
147
|
+
kind = outcome["type"]
|
|
148
|
+
if kind == "input-required":
|
|
149
|
+
self.nextDispatch.value = {
|
|
150
|
+
"trigger": "input-resume",
|
|
151
|
+
"requests": outcome["requests"],
|
|
152
|
+
"responses": None,
|
|
153
|
+
"ready": False,
|
|
154
|
+
}
|
|
155
|
+
elif kind in ("error", "stop") and not outcome.get("dispatchQueue", False):
|
|
156
|
+
self.nextDispatch.value = {
|
|
157
|
+
"trigger": "error-continue" if kind == "error" else "stop-continue",
|
|
158
|
+
"ready": False,
|
|
159
|
+
}
|
|
160
|
+
self.isStopping.value = False
|
|
161
|
+
self._clear_dispatch_state() # the run's progress dies with its ack
|
|
162
|
+
self.dispatching.ack()
|
|
163
|
+
|
|
164
|
+
# ─── Verbs ──────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
def _known(self, message_id: str) -> bool:
|
|
167
|
+
recorded = self.dispatching.value
|
|
168
|
+
entries = [*self.queue.value, *self.steerQueue.value]
|
|
169
|
+
if recorded is not None:
|
|
170
|
+
entries.append(recorded["item"])
|
|
171
|
+
entries.extend(recorded["item"].get("messages", ()))
|
|
172
|
+
return any(
|
|
173
|
+
isinstance(entry, dict) and entry.get("id") == message_id
|
|
174
|
+
for entry in entries
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
def _admit(self, entry: dict[str, Any], *, steer: bool) -> None:
|
|
178
|
+
if steer and (self.dispatching.value is not None or self._active is not None):
|
|
179
|
+
self.steerQueue.append({k: v for k, v in entry.items() if k != "trigger"})
|
|
180
|
+
elif steer:
|
|
181
|
+
self.queue.insert(0, entry)
|
|
182
|
+
else:
|
|
183
|
+
self.queue.append(entry)
|
|
184
|
+
self.kick()
|
|
185
|
+
|
|
186
|
+
@inbox
|
|
187
|
+
def send(
|
|
188
|
+
self, message_id: str, parts: list[Any], behavior: str = "queue"
|
|
189
|
+
) -> None:
|
|
190
|
+
if behavior not in ("queue", "steer"):
|
|
191
|
+
raise ValueError(f"unknown send behavior {behavior!r}")
|
|
192
|
+
if self._known(message_id):
|
|
193
|
+
return
|
|
194
|
+
entry = {"id": message_id, "trigger": "message-send", "parts": parts}
|
|
195
|
+
self._admit(entry, steer=behavior == "steer")
|
|
196
|
+
|
|
197
|
+
@inbox
|
|
198
|
+
def edit(self, message_id: str, message: dict[str, Any]) -> None:
|
|
199
|
+
self._admit(
|
|
200
|
+
{"id": message_id, "trigger": "message-edit", "message": message},
|
|
201
|
+
steer=False,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
@inbox
|
|
205
|
+
def reload(self, message_id: str, params: Any = None) -> None:
|
|
206
|
+
self._admit(
|
|
207
|
+
{"id": message_id, "trigger": "message-reload", "params": params},
|
|
208
|
+
steer=False,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
@inbox
|
|
212
|
+
def dequeue(self, message_id: str) -> None:
|
|
213
|
+
self.queue.remove(message_id)
|
|
214
|
+
|
|
215
|
+
@inbox
|
|
216
|
+
def queue_steer(self, message_id: str) -> None:
|
|
217
|
+
entry = next(
|
|
218
|
+
(e for e in self.queue.value if e.get("id") == message_id), None
|
|
219
|
+
)
|
|
220
|
+
if entry is None:
|
|
221
|
+
raise KeyError(f"no queued message {message_id!r}")
|
|
222
|
+
self.queue.remove(message_id)
|
|
223
|
+
self._admit(entry, steer=True)
|
|
224
|
+
|
|
225
|
+
@inbox
|
|
226
|
+
def stop(self, epoch: int | None = None) -> None:
|
|
227
|
+
if self.dispatching.value is None and self._active is None:
|
|
228
|
+
raise RuntimeError("no active run to stop")
|
|
229
|
+
if epoch is not None and epoch != self.epoch.value:
|
|
230
|
+
raise RuntimeError(
|
|
231
|
+
f"epoch {epoch} does not name the live dispatch ({self.epoch.value})"
|
|
232
|
+
)
|
|
233
|
+
self.isStopping.value = True
|
|
234
|
+
if self._active is not None:
|
|
235
|
+
self._active.cancelled.set()
|
|
236
|
+
|
|
237
|
+
@inbox
|
|
238
|
+
def input(self, params: Any) -> None:
|
|
239
|
+
pending = self.nextDispatch.value
|
|
240
|
+
if pending is None or pending.get("trigger") != "input-resume":
|
|
241
|
+
raise RuntimeError("no input requested")
|
|
242
|
+
if pending.get("ready"):
|
|
243
|
+
raise RuntimeError("input already provided")
|
|
244
|
+
self.nextDispatch.value = {**pending, "responses": params, "ready": True}
|
|
245
|
+
self.kick()
|
|
246
|
+
|
|
247
|
+
@inbox
|
|
248
|
+
def continue_run(self) -> None:
|
|
249
|
+
pending = self.nextDispatch.value
|
|
250
|
+
if pending is None:
|
|
251
|
+
raise RuntimeError("nothing to continue")
|
|
252
|
+
if pending.get("trigger") == "input-resume":
|
|
253
|
+
raise RuntimeError("run is awaiting input; answer with input()")
|
|
254
|
+
self.nextDispatch.value = {**pending, "ready": True}
|
|
255
|
+
self.kick()
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
class RunManagerNamespace(TrayMapNamespace):
|
|
259
|
+
"""The RunManager: run verbs routed by thread id onto tray instances.
|
|
260
|
+
|
|
261
|
+
Every verb awaits its admission receipt, so the command's verdict follows
|
|
262
|
+
the admission's durability."""
|
|
263
|
+
|
|
264
|
+
async def enqueue(
|
|
265
|
+
self, thread_id: str, message_id: str, parts: list[Any]
|
|
266
|
+
) -> None:
|
|
267
|
+
await (await self.get_or_create(thread_id)).send(message_id, parts)
|
|
268
|
+
|
|
269
|
+
async def steer(self, thread_id: str, message_id: str, parts: list[Any]) -> None:
|
|
270
|
+
await (await self.get_or_create(thread_id)).send(
|
|
271
|
+
message_id, parts, behavior="steer"
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
async def dequeue(self, thread_id: str, message_id: str) -> None:
|
|
275
|
+
await (await self.get(thread_id)).dequeue(message_id)
|
|
276
|
+
|
|
277
|
+
async def edit(
|
|
278
|
+
self, thread_id: str, message_id: str, message: dict[str, Any]
|
|
279
|
+
) -> None:
|
|
280
|
+
await (await self.get_or_create(thread_id)).edit(message_id, message)
|
|
281
|
+
|
|
282
|
+
async def reload(self, thread_id: str, message_id: str, params: Any = None) -> None:
|
|
283
|
+
await (await self.get_or_create(thread_id)).reload(message_id, params)
|
|
284
|
+
|
|
285
|
+
async def stop(self, thread_id: str, epoch: int | None = None) -> None:
|
|
286
|
+
await (await self.get(thread_id)).stop(epoch)
|
|
287
|
+
|
|
288
|
+
async def continue_run(self, thread_id: str) -> None:
|
|
289
|
+
await (await self.get(thread_id)).continue_run()
|
|
290
|
+
|
|
291
|
+
async def input(self, thread_id: str, params: Any) -> None:
|
|
292
|
+
await (await self.get(thread_id)).input(params)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
async def _mount_runs(api: Any, id: str, tray: RunTray):
|
|
296
|
+
if "runs" not in api.state:
|
|
297
|
+
api.state["runs"] = []
|
|
298
|
+
api.state["runs"].append(tray.state)
|
|
299
|
+
yield
|
|
300
|
+
runs = plain(api.state["runs"])
|
|
301
|
+
index = next(i for i, entry in enumerate(runs) if entry.get("id") == id)
|
|
302
|
+
api.state["runs"].pop(index)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _commands(name: str) -> dict[str, Any]:
|
|
306
|
+
async def enqueue(self, threadId: str, message: dict[str, Any]) -> None:
|
|
307
|
+
await getattr(self, name).enqueue(threadId, message["messageId"], message["parts"])
|
|
308
|
+
|
|
309
|
+
async def steer(self, threadId: str, message: dict[str, Any]) -> None:
|
|
310
|
+
await getattr(self, name).steer(threadId, message["messageId"], message["parts"])
|
|
311
|
+
|
|
312
|
+
async def dequeue(self, threadId: str, messageId: str) -> None:
|
|
313
|
+
await getattr(self, name).dequeue(threadId, messageId)
|
|
314
|
+
|
|
315
|
+
async def edit(self, threadId: str, messageId: str, message: dict[str, Any]) -> None:
|
|
316
|
+
await getattr(self, name).edit(threadId, messageId, message)
|
|
317
|
+
|
|
318
|
+
async def reload(self, threadId: str, messageId: str, params: Any = None) -> None:
|
|
319
|
+
await getattr(self, name).reload(threadId, messageId, params)
|
|
320
|
+
|
|
321
|
+
async def stop(self, threadId: str, epoch: int | None = None) -> None:
|
|
322
|
+
await getattr(self, name).stop(threadId, epoch)
|
|
323
|
+
|
|
324
|
+
async def continueRun(self, threadId: str) -> None:
|
|
325
|
+
await getattr(self, name).continue_run(threadId)
|
|
326
|
+
|
|
327
|
+
async def input(self, threadId: str, params: Any) -> None:
|
|
328
|
+
await getattr(self, name).input(threadId, params)
|
|
329
|
+
|
|
330
|
+
commands = {
|
|
331
|
+
fn.__name__: command(fn)
|
|
332
|
+
for fn in (enqueue, steer, dequeue, edit, reload, stop, continueRun, input)
|
|
333
|
+
}
|
|
334
|
+
for fn in commands.values():
|
|
335
|
+
fn._run_manager_command = name
|
|
336
|
+
return commands
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
class RunManagerDescriptor(TrayDescriptor):
|
|
340
|
+
namespace_cls = RunManagerNamespace
|
|
341
|
+
|
|
342
|
+
def __init__(self, handler: Callable[..., Coroutine[Any, Any, Any]]) -> None:
|
|
343
|
+
super().__init__(RunTray, handler, always_open=True)
|
|
344
|
+
self._on_activate.append(_mount_runs)
|
|
345
|
+
|
|
346
|
+
def __set_name__(self, owner: type, name: str) -> None:
|
|
347
|
+
super().__set_name__(owner, name)
|
|
348
|
+
for verb, fn in _commands(name).items():
|
|
349
|
+
existing = getattr(owner, verb, None)
|
|
350
|
+
if existing is not None and getattr(
|
|
351
|
+
existing, "_run_manager_command", None
|
|
352
|
+
) != name:
|
|
353
|
+
raise TypeError(
|
|
354
|
+
f"{owner.__name__}.{verb} collides with a @run_manager command"
|
|
355
|
+
)
|
|
356
|
+
setattr(owner, verb, fn)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def run_manager(fn: Callable[..., Coroutine[Any, Any, Any]]) -> Any:
|
|
360
|
+
"""``@tray.map(RunTray)`` plus the interaction layer: registers the run
|
|
361
|
+
commands routed by thread id and mounts each run's state into
|
|
362
|
+
``state["runs"]``. The descriptor is the RunManager."""
|
|
363
|
+
return RunManagerDescriptor(fn)
|