harness-sdk-python 0.15.0__tar.gz → 0.17.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 (34) hide show
  1. {harness_sdk_python-0.15.0 → harness_sdk_python-0.17.0}/.gitignore +1 -0
  2. {harness_sdk_python-0.15.0 → harness_sdk_python-0.17.0}/PKG-INFO +3 -2
  3. {harness_sdk_python-0.15.0 → harness_sdk_python-0.17.0}/examples/runs_app.py +21 -24
  4. {harness_sdk_python-0.15.0 → harness_sdk_python-0.17.0}/pyproject.toml +3 -2
  5. harness_sdk_python-0.17.0/src/harness_sdk/__init__.py +9 -0
  6. {harness_sdk_python-0.15.0 → harness_sdk_python-0.17.0}/src/harness_sdk/fenced_postgres.py +97 -14
  7. {harness_sdk_python-0.15.0 → harness_sdk_python-0.17.0}/src/harness_sdk/run_manager.py +481 -342
  8. {harness_sdk_python-0.15.0 → harness_sdk_python-0.17.0}/tests/run_helpers.py +40 -32
  9. harness_sdk_python-0.17.0/tests/test_durability.py +306 -0
  10. {harness_sdk_python-0.15.0 → harness_sdk_python-0.17.0}/tests/test_enqueue.py +3 -11
  11. {harness_sdk_python-0.15.0 → harness_sdk_python-0.17.0}/tests/test_fenced_postgres.py +196 -0
  12. {harness_sdk_python-0.15.0 → harness_sdk_python-0.17.0}/tests/test_input_required.py +8 -8
  13. {harness_sdk_python-0.15.0 → harness_sdk_python-0.17.0}/tests/test_outcomes.py +33 -32
  14. harness_sdk_python-0.17.0/tests/test_restore.py +124 -0
  15. {harness_sdk_python-0.15.0 → harness_sdk_python-0.17.0}/tests/test_rewind_during_run.py +2 -10
  16. {harness_sdk_python-0.15.0 → harness_sdk_python-0.17.0}/tests/test_settle.py +28 -4
  17. {harness_sdk_python-0.15.0 → harness_sdk_python-0.17.0}/tests/test_stop_continue.py +6 -5
  18. {harness_sdk_python-0.15.0 → harness_sdk_python-0.17.0}/tests/test_stopping_window.py +37 -29
  19. harness_sdk_python-0.15.0/src/harness_sdk/__init__.py +0 -4
  20. harness_sdk_python-0.15.0/tests/test_restore.py +0 -177
  21. {harness_sdk_python-0.15.0 → harness_sdk_python-0.17.0}/README.md +0 -0
  22. {harness_sdk_python-0.15.0 → harness_sdk_python-0.17.0}/examples/__init__.py +0 -0
  23. {harness_sdk_python-0.15.0 → harness_sdk_python-0.17.0}/src/harness_sdk/linear_thread.py +0 -0
  24. {harness_sdk_python-0.15.0 → harness_sdk_python-0.17.0}/tests/test_applied_visibility.py +0 -0
  25. {harness_sdk_python-0.15.0 → harness_sdk_python-0.17.0}/tests/test_batches.py +0 -0
  26. {harness_sdk_python-0.15.0 → harness_sdk_python-0.17.0}/tests/test_branch_anchor.py +0 -0
  27. {harness_sdk_python-0.15.0 → harness_sdk_python-0.17.0}/tests/test_dispatching.py +0 -0
  28. {harness_sdk_python-0.15.0 → harness_sdk_python-0.17.0}/tests/test_edit_dispatched.py +0 -0
  29. {harness_sdk_python-0.15.0 → harness_sdk_python-0.17.0}/tests/test_edit_reload.py +0 -0
  30. {harness_sdk_python-0.15.0 → harness_sdk_python-0.17.0}/tests/test_linear_thread.py +0 -0
  31. {harness_sdk_python-0.15.0 → harness_sdk_python-0.17.0}/tests/test_meta.py +0 -0
  32. {harness_sdk_python-0.15.0 → harness_sdk_python-0.17.0}/tests/test_placement.py +0 -0
  33. {harness_sdk_python-0.15.0 → harness_sdk_python-0.17.0}/tests/test_prepare_hooks.py +0 -0
  34. {harness_sdk_python-0.15.0 → harness_sdk_python-0.17.0}/tests/test_steer.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.17.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.12,>=0.11.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,11 +19,12 @@ 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
+ from pinned.testing import MemoryStateStore
23
24
  from statewire import Statewire, command, plain
24
25
  from statewire.langgraph import append_langgraph_event
25
26
 
26
- from harness_sdk import RunManager, linear_thread
27
+ from harness_sdk import RunManager, linear_thread, run_manager
27
28
 
28
29
 
29
30
  def select_model() -> str:
@@ -81,19 +82,15 @@ class RunsHost(Statewire):
81
82
  role=lambda m: "user" if m["type"] == "human" else "assistant",
82
83
  )
83
84
  self.turn = 0
84
- self.runs = RunManager(
85
- state=self.state,
86
- run=self.run,
87
- thread=self.thread,
88
- create_task=self.create_task,
89
- schedule=self.schedule,
90
- capabilities=("rewind",),
91
- )
92
85
  yield
93
86
 
87
+ async def get_message_meta(self, message_id: str | None) -> dict[str, Any] | None:
88
+ return await self.thread.get_message_meta(message_id)
89
+
94
90
  async def get_message_child_id(self, parent_id: str) -> str | None:
95
91
  return await self.thread.get_message_child_id(parent_id)
96
92
 
93
+ @run_manager(capabilities=("rewind",))
97
94
  async def run(self, ctx: RunManager.RunContext) -> Any:
98
95
  if ctx.has_rollback:
99
96
  self.truncate(ctx.rollback_to)
@@ -158,44 +155,44 @@ class RunsHost(Statewire):
158
155
 
159
156
  @command("run/enqueue")
160
157
  async def run_enqueue(self, params, *, ctx):
161
- return await self.runs.enqueue(params, applied=ctx.applied)
158
+ return await (await self.run.get()).enqueue(params, applied=ctx.applied)
162
159
 
163
160
  @command("run/steer")
164
161
  async def run_steer(self, params, *, ctx):
165
- return await self.runs.steer(params, applied=ctx.applied)
162
+ return await (await self.run.get()).steer(params, applied=ctx.applied)
166
163
 
167
164
  @command("run/dequeue")
168
165
  async def run_dequeue(self, params):
169
- return await self.runs.dequeue(params)
166
+ return await (await self.run.get()).dequeue(params)
170
167
 
171
168
  @command("run/edit")
172
169
  async def run_edit(self, params, *, ctx):
173
- return await self.runs.edit(params, applied=ctx.applied)
170
+ return await (await self.run.get()).edit(params, applied=ctx.applied)
174
171
 
175
172
  @command("run/reload")
176
173
  async def run_reload(self, params, *, ctx):
177
- return await self.runs.reload(params, applied=ctx.applied)
174
+ return await (await self.run.get()).reload(params, applied=ctx.applied)
178
175
 
179
176
  @command("run/stop")
180
177
  async def run_stop(self, params=None, *, ctx):
181
- return await self.runs.stop(params, applied=ctx.applied)
178
+ return await (await self.run.get()).stop(params, applied=ctx.applied)
182
179
 
183
180
  @command("run/continue")
184
181
  async def run_continue(self, params=None, *, ctx):
185
- return await self.runs.continue_run(applied=ctx.applied)
182
+ return await (await self.run.get()).continue_run(applied=ctx.applied)
186
183
 
187
184
  @command("run/input")
188
185
  async def run_input(self, params):
189
- return await self.runs.input(params)
186
+ return await (await self.run.get()).input(params)
190
187
 
191
188
 
192
- _pinboard_url = os.environ.get("PINBOARD_URL")
193
- router = PinnedHost(
194
- RunsHost,
195
- pinboard_url=_pinboard_url,
189
+ worker = PinnedWorker(
190
+ state_store=MemoryStateStore(),
191
+ pinboard_url=os.environ.get("PINBOARD_URL"),
196
192
  token=os.environ.get("PINBOARD_TOKEN"),
197
193
  advertise_url=os.environ.get("ADVERTISE_URL"),
198
- namespace="/threads" if _pinboard_url else None,
199
194
  )
195
+ router = PinnedHost(RunsHost, worker=worker, namespace="/threads")
200
196
  app = FastAPI(lifespan=router.lifespan)
201
- app.include_router(router, prefix="/threads")
197
+ app.include_router(worker)
198
+ 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.17.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.11.0,<0.12"]
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,9 @@
1
+ from .linear_thread import linear_thread
2
+ from .run_manager import RunManager, RunManagerDescriptor, run_manager
3
+
4
+ __all__ = [
5
+ "RunManager",
6
+ "RunManagerDescriptor",
7
+ "linear_thread",
8
+ "run_manager",
9
+ ]
@@ -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