harness-sdk-python 0.2.0__tar.gz → 0.3.1__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 (23) hide show
  1. {harness_sdk_python-0.2.0 → harness_sdk_python-0.3.1}/.gitignore +3 -0
  2. {harness_sdk_python-0.2.0 → harness_sdk_python-0.3.1}/PKG-INFO +3 -3
  3. {harness_sdk_python-0.2.0 → harness_sdk_python-0.3.1}/README.md +1 -1
  4. {harness_sdk_python-0.2.0 → harness_sdk_python-0.3.1}/pyproject.toml +2 -2
  5. {harness_sdk_python-0.2.0 → harness_sdk_python-0.3.1}/src/harness_sdk/fenced_postgres.py +53 -26
  6. {harness_sdk_python-0.2.0 → harness_sdk_python-0.3.1}/src/harness_sdk/run_manager.py +544 -159
  7. {harness_sdk_python-0.2.0 → harness_sdk_python-0.3.1}/tests/run_helpers.py +37 -23
  8. harness_sdk_python-0.3.1/tests/test_batches.py +96 -0
  9. {harness_sdk_python-0.2.0 → harness_sdk_python-0.3.1}/tests/test_caller.py +26 -25
  10. {harness_sdk_python-0.2.0 → harness_sdk_python-0.3.1}/tests/test_edit_dispatched.py +13 -11
  11. {harness_sdk_python-0.2.0 → harness_sdk_python-0.3.1}/tests/test_edit_reload.py +37 -37
  12. {harness_sdk_python-0.2.0 → harness_sdk_python-0.3.1}/tests/test_enqueue.py +53 -53
  13. harness_sdk_python-0.3.1/tests/test_facade.py +84 -0
  14. {harness_sdk_python-0.2.0 → harness_sdk_python-0.3.1}/tests/test_fenced_postgres.py +109 -2
  15. harness_sdk_python-0.3.1/tests/test_input_required.py +277 -0
  16. {harness_sdk_python-0.2.0 → harness_sdk_python-0.3.1}/tests/test_outcomes.py +22 -21
  17. {harness_sdk_python-0.2.0 → harness_sdk_python-0.3.1}/tests/test_placement.py +28 -28
  18. {harness_sdk_python-0.2.0 → harness_sdk_python-0.3.1}/tests/test_rewind_during_run.py +20 -16
  19. harness_sdk_python-0.3.1/tests/test_settle.py +131 -0
  20. {harness_sdk_python-0.2.0 → harness_sdk_python-0.3.1}/tests/test_steer.py +23 -23
  21. {harness_sdk_python-0.2.0 → harness_sdk_python-0.3.1}/tests/test_stop_continue.py +56 -14
  22. harness_sdk_python-0.2.0/tests/test_input_required.py +0 -65
  23. {harness_sdk_python-0.2.0 → harness_sdk_python-0.3.1}/src/harness_sdk/__init__.py +0 -0
@@ -9,6 +9,9 @@ dist-test
9
9
  .dev.vars
10
10
  *.tsbuildinfo
11
11
  .harness-runs
12
+ *.rdb
13
+ .source
14
+ next-env.d.ts
12
15
  __pycache__
13
16
  .venv
14
17
  .pytest_cache
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: harness-sdk-python
3
- Version: 0.2.0
3
+ Version: 0.3.1
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.11
8
- Requires-Dist: statewire<0.3,>=0.2.0
8
+ Requires-Dist: statewire<0.4,>=0.3.0
9
9
  Provides-Extra: postgres
10
10
  Requires-Dist: langgraph-checkpoint-postgres>=2.0.0; extra == 'postgres'
11
11
  Description-Content-Type: text/markdown
@@ -21,7 +21,7 @@ class MyHost(Statewire):
21
21
  async def lifespan(self):
22
22
  self.state = initial_state()
23
23
  self.runs = RunManager(
24
- state=self.state["runs"],
24
+ state=self.state,
25
25
  start=self._start,
26
26
  get_message_meta=self._get_message_meta,
27
27
  create_task=self.create_task,
@@ -9,7 +9,7 @@ class MyHost(Statewire):
9
9
  async def lifespan(self):
10
10
  self.state = initial_state()
11
11
  self.runs = RunManager(
12
- state=self.state["runs"],
12
+ state=self.state,
13
13
  start=self._start,
14
14
  get_message_meta=self._get_message_meta,
15
15
  create_task=self.create_task,
@@ -1,11 +1,11 @@
1
1
  [project]
2
2
  name = "harness-sdk-python"
3
- version = "0.2.0"
3
+ version = "0.3.1"
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.11,<4.0"
8
- dependencies = ["statewire>=0.2.0,<0.3"]
8
+ dependencies = ["statewire>=0.3.0,<0.4"]
9
9
 
10
10
  [project.optional-dependencies]
11
11
  postgres = ["langgraph-checkpoint-postgres>=2.0.0"]
@@ -8,6 +8,7 @@ try:
8
8
  from langgraph.checkpoint.base import SerializerProtocol
9
9
  from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver, _ainternal
10
10
  from psycopg import AsyncPipeline, errors
11
+ from psycopg.pq import TransactionStatus
11
12
  from psycopg.cursor_async import AsyncCursor
12
13
  from psycopg.rows import DictRow, dict_row
13
14
  from psycopg.sql import SQL, Identifier, Literal
@@ -20,7 +21,7 @@ except ImportError as exc:
20
21
  DEFAULT_FENCE_TABLE = "checkpoint_fence"
21
22
  FENCE_LOST_SQLSTATE = "FL001"
22
23
 
23
- _IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
24
+ _IDENTIFIER_RE = re.compile(r"^[a-z_][a-z0-9_]*$")
24
25
 
25
26
 
26
27
  class FenceLost(RuntimeError):
@@ -41,7 +42,7 @@ def _require_thread_id(thread_id: str) -> str:
41
42
  def _require_fence_table(fence_table: str) -> str:
42
43
  if not isinstance(fence_table, str) or not _IDENTIFIER_RE.match(fence_table):
43
44
  raise ValueError(
44
- f"fence_table must be an unquoted SQL identifier, got {fence_table!r}"
45
+ f"fence_table must be a lowercase SQL identifier, got {fence_table!r}"
45
46
  )
46
47
  return fence_table
47
48
 
@@ -89,8 +90,14 @@ def _assert_sql(fence_table: str) -> SQL:
89
90
  return SQL("SELECT {}(%s, %s)").format(Identifier(_assert_fn(fence_table)))
90
91
 
91
92
 
92
- def _is_fence_lost(exc: errors.Error) -> bool:
93
- return exc.sqlstate == FENCE_LOST_SQLSTATE
93
+ def _is_fence_lost(exc: BaseException | None) -> bool:
94
+ seen: set[int] = set()
95
+ while exc is not None and id(exc) not in seen:
96
+ seen.add(id(exc))
97
+ if isinstance(exc, errors.Error) and exc.sqlstate == FENCE_LOST_SQLSTATE:
98
+ return True
99
+ exc = exc.__cause__ or exc.__context__
100
+ return False
94
101
 
95
102
 
96
103
  def _missing_fence_error(missing: str) -> RuntimeError:
@@ -133,27 +140,32 @@ class AsyncFencedPostgresSaver(AsyncPostgresSaver):
133
140
  ) -> "AsyncFencedPostgresSaver":
134
141
  _require_thread_id(thread_id)
135
142
  _require_fence_table(fence_table)
136
- async with (
137
- _ainternal.get_connection(conn) as c,
138
- c.cursor(binary=True, row_factory=dict_row) as cur,
139
- ):
140
- if create_fence_table:
141
- await cur.execute(_create_sql(fence_table))
142
- await cur.execute(_create_fn_sql(fence_table))
143
- else:
144
- await cur.execute(
145
- "SELECT to_regclass(%s) AS tbl, to_regprocedure(%s) AS fn",
146
- (fence_table, f"{_assert_fn(fence_table)}(text, bigint)"),
143
+ async with _ainternal.get_connection(conn) as c:
144
+ if c.info.transaction_status != TransactionStatus.IDLE:
145
+ raise RuntimeError(
146
+ "acquire requires a connection with no transaction in progress"
147
147
  )
148
- row = await cur.fetchone()
149
- if row["tbl"] is None:
150
- raise _missing_fence_error(f"fence table {fence_table!r}")
151
- if row["fn"] is None:
152
- raise _missing_fence_error(
153
- f"fence function {_assert_fn(fence_table)!r}"
148
+ async with (
149
+ c.transaction(),
150
+ c.cursor(binary=True, row_factory=dict_row) as cur,
151
+ ):
152
+ if create_fence_table:
153
+ await cur.execute(_create_sql(fence_table))
154
+ await cur.execute(_create_fn_sql(fence_table))
155
+ else:
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)"),
154
159
  )
155
- await cur.execute(_bump_sql(fence_table), (thread_id,))
156
- epoch = (await cur.fetchone())["epoch"]
160
+ row = await cur.fetchone()
161
+ if row["tbl"] is None:
162
+ raise _missing_fence_error(f"fence table {fence_table!r}")
163
+ if row["fn"] is None:
164
+ raise _missing_fence_error(
165
+ f"fence function {_assert_fn(fence_table)!r}"
166
+ )
167
+ await cur.execute(_bump_sql(fence_table), (thread_id,))
168
+ epoch = (await cur.fetchone())["epoch"]
157
169
  if pipe is not None:
158
170
  await pipe.sync()
159
171
  return cls(
@@ -191,9 +203,24 @@ class AsyncFencedPostgresSaver(AsyncPostgresSaver):
191
203
  await cur.execute(_assert_sql(self.fence_table), (self.thread_id, self.epoch))
192
204
  yield cur
193
205
  except errors.Error as exc:
194
- if not _is_fence_lost(exc):
195
- raise
196
- raise self._fence_lost() from exc
206
+ if _is_fence_lost(exc):
207
+ raise self._fence_lost() from exc
208
+ # An aborted pipeline can surface before the assert's FL001 does;
209
+ # the fence row decides which it was.
210
+ if isinstance(exc, errors.PipelineAborted) and await self._fence_stale():
211
+ raise self._fence_lost() from exc
212
+ raise
213
+
214
+ async def _fence_stale(self) -> bool:
215
+ async with super()._cursor(pipeline=False) as cur:
216
+ await cur.execute(
217
+ SQL("SELECT epoch FROM {} WHERE thread_id = %s").format(
218
+ Identifier(self.fence_table)
219
+ ),
220
+ (self.thread_id,),
221
+ )
222
+ row = await cur.fetchone()
223
+ return row is None or row["epoch"] != self.epoch
197
224
 
198
225
  async def aput(
199
226
  self,