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.
Files changed (35) hide show
  1. {harness_sdk_python-0.15.0 → harness_sdk_python-0.16.0}/.gitignore +1 -0
  2. {harness_sdk_python-0.15.0 → harness_sdk_python-0.16.0}/PKG-INFO +3 -2
  3. {harness_sdk_python-0.15.0 → harness_sdk_python-0.16.0}/examples/runs_app.py +6 -7
  4. {harness_sdk_python-0.15.0 → harness_sdk_python-0.16.0}/pyproject.toml +3 -2
  5. harness_sdk_python-0.16.0/src/harness_sdk/__init__.py +11 -0
  6. {harness_sdk_python-0.15.0 → harness_sdk_python-0.16.0}/src/harness_sdk/fenced_postgres.py +97 -14
  7. {harness_sdk_python-0.15.0 → harness_sdk_python-0.16.0}/src/harness_sdk/run_manager.py +9 -1
  8. harness_sdk_python-0.16.0/src/harness_sdk/run_trays.py +363 -0
  9. {harness_sdk_python-0.15.0 → harness_sdk_python-0.16.0}/tests/test_fenced_postgres.py +159 -0
  10. harness_sdk_python-0.16.0/tests/test_run_tray_durability.py +339 -0
  11. harness_sdk_python-0.16.0/tests/test_run_trays.py +544 -0
  12. harness_sdk_python-0.15.0/src/harness_sdk/__init__.py +0 -4
  13. {harness_sdk_python-0.15.0 → harness_sdk_python-0.16.0}/README.md +0 -0
  14. {harness_sdk_python-0.15.0 → harness_sdk_python-0.16.0}/examples/__init__.py +0 -0
  15. {harness_sdk_python-0.15.0 → harness_sdk_python-0.16.0}/src/harness_sdk/linear_thread.py +0 -0
  16. {harness_sdk_python-0.15.0 → harness_sdk_python-0.16.0}/tests/run_helpers.py +0 -0
  17. {harness_sdk_python-0.15.0 → harness_sdk_python-0.16.0}/tests/test_applied_visibility.py +0 -0
  18. {harness_sdk_python-0.15.0 → harness_sdk_python-0.16.0}/tests/test_batches.py +0 -0
  19. {harness_sdk_python-0.15.0 → harness_sdk_python-0.16.0}/tests/test_branch_anchor.py +0 -0
  20. {harness_sdk_python-0.15.0 → harness_sdk_python-0.16.0}/tests/test_dispatching.py +0 -0
  21. {harness_sdk_python-0.15.0 → harness_sdk_python-0.16.0}/tests/test_edit_dispatched.py +0 -0
  22. {harness_sdk_python-0.15.0 → harness_sdk_python-0.16.0}/tests/test_edit_reload.py +0 -0
  23. {harness_sdk_python-0.15.0 → harness_sdk_python-0.16.0}/tests/test_enqueue.py +0 -0
  24. {harness_sdk_python-0.15.0 → harness_sdk_python-0.16.0}/tests/test_input_required.py +0 -0
  25. {harness_sdk_python-0.15.0 → harness_sdk_python-0.16.0}/tests/test_linear_thread.py +0 -0
  26. {harness_sdk_python-0.15.0 → harness_sdk_python-0.16.0}/tests/test_meta.py +0 -0
  27. {harness_sdk_python-0.15.0 → harness_sdk_python-0.16.0}/tests/test_outcomes.py +0 -0
  28. {harness_sdk_python-0.15.0 → harness_sdk_python-0.16.0}/tests/test_placement.py +0 -0
  29. {harness_sdk_python-0.15.0 → harness_sdk_python-0.16.0}/tests/test_prepare_hooks.py +0 -0
  30. {harness_sdk_python-0.15.0 → harness_sdk_python-0.16.0}/tests/test_restore.py +0 -0
  31. {harness_sdk_python-0.15.0 → harness_sdk_python-0.16.0}/tests/test_rewind_during_run.py +0 -0
  32. {harness_sdk_python-0.15.0 → harness_sdk_python-0.16.0}/tests/test_settle.py +0 -0
  33. {harness_sdk_python-0.15.0 → harness_sdk_python-0.16.0}/tests/test_steer.py +0 -0
  34. {harness_sdk_python-0.15.0 → harness_sdk_python-0.16.0}/tests/test_stop_continue.py +0 -0
  35. {harness_sdk_python-0.15.0 → harness_sdk_python-0.16.0}/tests/test_stopping_window.py +0 -0
@@ -22,5 +22,6 @@ __pycache__
22
22
  /doc_*.md
23
23
  apps/docs/.docs
24
24
  /.agentdoc/
25
+ /.deepsec/
25
26
  .vercel
26
27
  .env*
@@ -1,11 +1,12 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: harness-sdk-python
3
- Version: 0.15.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: statewire<0.9,>=0.8.1
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
- _pinboard_url = os.environ.get("PINBOARD_URL")
193
- router = PinnedHost(
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(router, prefix="/threads")
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.15.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.1,<0.9"]
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"]
@@ -0,0 +1,11 @@
1
+ from .linear_thread import linear_thread
2
+ from .run_manager import RunManager
3
+ from .run_trays import RunManagerNamespace, RunTray, run_manager
4
+
5
+ __all__ = [
6
+ "RunManager",
7
+ "RunManagerNamespace",
8
+ "RunTray",
9
+ "linear_thread",
10
+ "run_manager",
11
+ ]
@@ -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 _validate_and_bump(
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
- ) -> int:
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
- 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)"),
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
- 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
- )
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.state import plain
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)