harness-sdk-python 0.23.1__tar.gz → 0.24.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.23.1 → harness_sdk_python-0.24.0}/PKG-INFO +1 -1
- {harness_sdk_python-0.23.1 → harness_sdk_python-0.24.0}/pyproject.toml +1 -1
- {harness_sdk_python-0.23.1 → harness_sdk_python-0.24.0}/src/harness_sdk/checkpoints.py +88 -75
- {harness_sdk_python-0.23.1 → harness_sdk_python-0.24.0}/tests/test_checkpoints.py +118 -32
- {harness_sdk_python-0.23.1 → harness_sdk_python-0.24.0}/.gitignore +0 -0
- {harness_sdk_python-0.23.1 → harness_sdk_python-0.24.0}/README.md +0 -0
- {harness_sdk_python-0.23.1 → harness_sdk_python-0.24.0}/examples/__init__.py +0 -0
- {harness_sdk_python-0.23.1 → harness_sdk_python-0.24.0}/examples/runs_app.py +0 -0
- {harness_sdk_python-0.23.1 → harness_sdk_python-0.24.0}/src/harness_sdk/__init__.py +0 -0
- {harness_sdk_python-0.23.1 → harness_sdk_python-0.24.0}/src/harness_sdk/fenced_postgres.py +0 -0
- {harness_sdk_python-0.23.1 → harness_sdk_python-0.24.0}/src/harness_sdk/linear_thread.py +0 -0
- {harness_sdk_python-0.23.1 → harness_sdk_python-0.24.0}/src/harness_sdk/run_manager.py +0 -0
- {harness_sdk_python-0.23.1 → harness_sdk_python-0.24.0}/tests/run_helpers.py +0 -0
- {harness_sdk_python-0.23.1 → harness_sdk_python-0.24.0}/tests/test_admit_steer.py +0 -0
- {harness_sdk_python-0.23.1 → harness_sdk_python-0.24.0}/tests/test_applied_visibility.py +0 -0
- {harness_sdk_python-0.23.1 → harness_sdk_python-0.24.0}/tests/test_batches.py +0 -0
- {harness_sdk_python-0.23.1 → harness_sdk_python-0.24.0}/tests/test_branch_anchor.py +0 -0
- {harness_sdk_python-0.23.1 → harness_sdk_python-0.24.0}/tests/test_dispatching.py +0 -0
- {harness_sdk_python-0.23.1 → harness_sdk_python-0.24.0}/tests/test_durability.py +0 -0
- {harness_sdk_python-0.23.1 → harness_sdk_python-0.24.0}/tests/test_edit_dispatched.py +0 -0
- {harness_sdk_python-0.23.1 → harness_sdk_python-0.24.0}/tests/test_edit_reload.py +0 -0
- {harness_sdk_python-0.23.1 → harness_sdk_python-0.24.0}/tests/test_enqueue.py +0 -0
- {harness_sdk_python-0.23.1 → harness_sdk_python-0.24.0}/tests/test_fenced_postgres.py +0 -0
- {harness_sdk_python-0.23.1 → harness_sdk_python-0.24.0}/tests/test_input_required.py +0 -0
- {harness_sdk_python-0.23.1 → harness_sdk_python-0.24.0}/tests/test_linear_thread.py +0 -0
- {harness_sdk_python-0.23.1 → harness_sdk_python-0.24.0}/tests/test_meta.py +0 -0
- {harness_sdk_python-0.23.1 → harness_sdk_python-0.24.0}/tests/test_outcomes.py +0 -0
- {harness_sdk_python-0.23.1 → harness_sdk_python-0.24.0}/tests/test_placement.py +0 -0
- {harness_sdk_python-0.23.1 → harness_sdk_python-0.24.0}/tests/test_prepare_hooks.py +0 -0
- {harness_sdk_python-0.23.1 → harness_sdk_python-0.24.0}/tests/test_restore.py +0 -0
- {harness_sdk_python-0.23.1 → harness_sdk_python-0.24.0}/tests/test_rewind_during_run.py +0 -0
- {harness_sdk_python-0.23.1 → harness_sdk_python-0.24.0}/tests/test_run_crash_sweep.py +0 -0
- {harness_sdk_python-0.23.1 → harness_sdk_python-0.24.0}/tests/test_settle.py +0 -0
- {harness_sdk_python-0.23.1 → harness_sdk_python-0.24.0}/tests/test_steer.py +0 -0
- {harness_sdk_python-0.23.1 → harness_sdk_python-0.24.0}/tests/test_stop_continue.py +0 -0
- {harness_sdk_python-0.23.1 → harness_sdk_python-0.24.0}/tests/test_stopping_window.py +0 -0
|
@@ -3,11 +3,11 @@ checkpoint store (``get(checkpoint_id)`` only), designed for langgraph.
|
|
|
3
3
|
|
|
4
4
|
A branch is a resumption point plus its continuation; edits, regenerations,
|
|
5
5
|
reloads, and compactions all create one. ``branch_id`` is the entry's array
|
|
6
|
-
position
|
|
7
|
-
|
|
8
|
-
the state's ``branch_ids`` path
|
|
9
|
-
|
|
10
|
-
``last_checkpoint_id`` is the only update.
|
|
6
|
+
position; entry 0 is the root branch and every fork entry records the branch
|
|
7
|
+
it forked from as ``fork_branch``. Every ingested checkpoint advances
|
|
8
|
+
``last_checkpoint_id`` for the state's ``branch_ids`` path's leaf branch, so
|
|
9
|
+
a branch's pointer is always its own continuation's most recent checkpoint.
|
|
10
|
+
Rows and entries are insert-only; ``last_checkpoint_id`` is the only update.
|
|
11
11
|
"""
|
|
12
12
|
|
|
13
13
|
import asyncio
|
|
@@ -16,6 +16,8 @@ from typing import Any, Callable, Protocol
|
|
|
16
16
|
|
|
17
17
|
from .linear_thread import linear_thread
|
|
18
18
|
|
|
19
|
+
_ROOT_ENTRY = {"fork_message_id": None, "fork_branch": None, "last_checkpoint_id": None}
|
|
20
|
+
|
|
19
21
|
_FORK_TRIGGERS = ("message-edit", "message-reload")
|
|
20
22
|
_TIP_TRIGGERS = (
|
|
21
23
|
"message-send",
|
|
@@ -65,6 +67,7 @@ class CheckpointIndex(Protocol):
|
|
|
65
67
|
checkpoint_ns: str,
|
|
66
68
|
*,
|
|
67
69
|
fork_message_id: str | None,
|
|
70
|
+
fork_branch: int,
|
|
68
71
|
last_checkpoint_id: str,
|
|
69
72
|
) -> int: ...
|
|
70
73
|
|
|
@@ -85,7 +88,7 @@ class CheckpointIndex(Protocol):
|
|
|
85
88
|
|
|
86
89
|
def _check_branch_ids(branch_ids: list[int], count: int) -> None:
|
|
87
90
|
for branch_id in branch_ids:
|
|
88
|
-
if not isinstance(branch_id, int) or not
|
|
91
|
+
if not isinstance(branch_id, int) or not 0 <= branch_id < count:
|
|
89
92
|
raise KeyError(f"unknown branch_id {branch_id!r} (index holds {count})")
|
|
90
93
|
|
|
91
94
|
|
|
@@ -107,16 +110,20 @@ class MemoryCheckpointIndex:
|
|
|
107
110
|
checkpoint_ns: str,
|
|
108
111
|
*,
|
|
109
112
|
fork_message_id: str | None,
|
|
113
|
+
fork_branch: int,
|
|
110
114
|
last_checkpoint_id: str,
|
|
111
115
|
) -> int:
|
|
112
116
|
branches = self._threads.setdefault((thread_id, checkpoint_ns), [])
|
|
117
|
+
if not branches:
|
|
118
|
+
branches.append(dict(_ROOT_ENTRY))
|
|
113
119
|
branches.append(
|
|
114
120
|
{
|
|
115
121
|
"fork_message_id": fork_message_id,
|
|
122
|
+
"fork_branch": fork_branch,
|
|
116
123
|
"last_checkpoint_id": last_checkpoint_id,
|
|
117
124
|
}
|
|
118
125
|
)
|
|
119
|
-
return len(branches)
|
|
126
|
+
return len(branches) - 1
|
|
120
127
|
|
|
121
128
|
async def bump_tips(
|
|
122
129
|
self,
|
|
@@ -125,10 +132,12 @@ class MemoryCheckpointIndex:
|
|
|
125
132
|
branch_ids: list[int],
|
|
126
133
|
checkpoint_id: str,
|
|
127
134
|
) -> None:
|
|
128
|
-
branches = self._threads.
|
|
135
|
+
branches = self._threads.setdefault((thread_id, checkpoint_ns), [])
|
|
136
|
+
if not branches:
|
|
137
|
+
branches.append(dict(_ROOT_ENTRY))
|
|
129
138
|
_check_branch_ids(branch_ids, len(branches))
|
|
130
139
|
for branch_id in branch_ids:
|
|
131
|
-
branches[branch_id
|
|
140
|
+
branches[branch_id]["last_checkpoint_id"] = checkpoint_id
|
|
132
141
|
|
|
133
142
|
async def upsert_messages(self, rows: list[dict[str, Any]]) -> None:
|
|
134
143
|
for row in rows:
|
|
@@ -189,33 +198,40 @@ class PostgresCheckpointIndex:
|
|
|
189
198
|
row = await cur.fetchone()
|
|
190
199
|
return row[0] if row is not None else []
|
|
191
200
|
|
|
201
|
+
async def _seed_root(self, thread_id: str, checkpoint_ns: str) -> None:
|
|
202
|
+
from psycopg.types.json import Jsonb
|
|
203
|
+
|
|
204
|
+
await self._conn.execute(
|
|
205
|
+
"INSERT INTO checkpoint_threads (thread_id, checkpoint_ns, branches)"
|
|
206
|
+
" VALUES (%s, %s, %s) ON CONFLICT DO NOTHING",
|
|
207
|
+
(thread_id, checkpoint_ns, Jsonb([_ROOT_ENTRY])),
|
|
208
|
+
)
|
|
209
|
+
|
|
192
210
|
async def append_branch(
|
|
193
211
|
self,
|
|
194
212
|
thread_id: str,
|
|
195
213
|
checkpoint_ns: str,
|
|
196
214
|
*,
|
|
197
215
|
fork_message_id: str | None,
|
|
216
|
+
fork_branch: int,
|
|
198
217
|
last_checkpoint_id: str,
|
|
199
218
|
) -> int:
|
|
200
219
|
from psycopg.types.json import Jsonb
|
|
201
220
|
|
|
202
221
|
entry = {
|
|
203
222
|
"fork_message_id": fork_message_id,
|
|
223
|
+
"fork_branch": fork_branch,
|
|
204
224
|
"last_checkpoint_id": last_checkpoint_id,
|
|
205
225
|
}
|
|
206
226
|
async with self._conn.transaction():
|
|
207
|
-
await self.
|
|
208
|
-
"INSERT INTO checkpoint_threads (thread_id, checkpoint_ns, branches)"
|
|
209
|
-
" VALUES (%s, %s, '[]'::jsonb) ON CONFLICT DO NOTHING",
|
|
210
|
-
(thread_id, checkpoint_ns),
|
|
211
|
-
)
|
|
227
|
+
await self._seed_root(thread_id, checkpoint_ns)
|
|
212
228
|
cur = await self._conn.execute(
|
|
213
229
|
"UPDATE checkpoint_threads SET branches = branches || %s"
|
|
214
230
|
" WHERE thread_id = %s AND checkpoint_ns = %s"
|
|
215
231
|
" RETURNING jsonb_array_length(branches)",
|
|
216
232
|
(Jsonb([entry]), thread_id, checkpoint_ns),
|
|
217
233
|
)
|
|
218
|
-
return (await cur.fetchone())[0]
|
|
234
|
+
return (await cur.fetchone())[0] - 1
|
|
219
235
|
|
|
220
236
|
async def bump_tips(
|
|
221
237
|
self,
|
|
@@ -224,6 +240,7 @@ class PostgresCheckpointIndex:
|
|
|
224
240
|
branch_ids: list[int],
|
|
225
241
|
checkpoint_id: str,
|
|
226
242
|
) -> None:
|
|
243
|
+
await self._seed_root(thread_id, checkpoint_ns)
|
|
227
244
|
cur = await self._conn.execute(
|
|
228
245
|
"SELECT jsonb_array_length(branches) FROM checkpoint_threads"
|
|
229
246
|
" WHERE thread_id = %s AND checkpoint_ns = %s",
|
|
@@ -233,9 +250,9 @@ class PostgresCheckpointIndex:
|
|
|
233
250
|
_check_branch_ids(branch_ids, row[0] if row is not None else 0)
|
|
234
251
|
await self._conn.execute(
|
|
235
252
|
"UPDATE checkpoint_threads SET branches = (SELECT jsonb_agg("
|
|
236
|
-
" CASE WHEN idx::int = ANY(%s)"
|
|
253
|
+
" CASE WHEN idx::int - 1 = ANY(%s)"
|
|
237
254
|
" THEN elem || jsonb_build_object('last_checkpoint_id', %s::text)"
|
|
238
|
-
" ELSE elem END)"
|
|
255
|
+
" ELSE elem END ORDER BY idx)"
|
|
239
256
|
" FROM jsonb_array_elements(branches) WITH ORDINALITY AS t(elem, idx))"
|
|
240
257
|
" WHERE thread_id = %s AND checkpoint_ns = %s",
|
|
241
258
|
(branch_ids, checkpoint_id, thread_id, checkpoint_ns),
|
|
@@ -358,7 +375,7 @@ class CheckpointThread:
|
|
|
358
375
|
return await self._projection().get_message_child_id(parent_id)
|
|
359
376
|
|
|
360
377
|
def sibling_branches(self, message_id: str) -> list[dict[str, Any]]:
|
|
361
|
-
"""
|
|
378
|
+
"""Fork entries at ``message_id``'s parent, as {branch_id, **entry} in allocation order; the source branch's continuation has no entry."""
|
|
362
379
|
index = next(
|
|
363
380
|
(i for i, m in enumerate(self.transcript) if m["id"] == message_id), None
|
|
364
381
|
)
|
|
@@ -366,15 +383,15 @@ class CheckpointThread:
|
|
|
366
383
|
raise KeyError(f"message {message_id!r} is not in the current window")
|
|
367
384
|
parent_id = self.transcript[index - 1]["id"] if index > 0 else None
|
|
368
385
|
return [
|
|
369
|
-
{"branch_id": i
|
|
386
|
+
{"branch_id": i, **entry}
|
|
370
387
|
for i, entry in enumerate(self.branches)
|
|
371
|
-
if entry["fork_message_id"] == parent_id
|
|
388
|
+
if i and entry["fork_message_id"] == parent_id
|
|
372
389
|
]
|
|
373
390
|
|
|
374
391
|
def _branch_at(self, index: int) -> int:
|
|
375
392
|
branch = 0
|
|
376
393
|
for b in self._path:
|
|
377
|
-
fork_id = self.branches[b
|
|
394
|
+
fork_id = self.branches[b]["fork_message_id"] if b != 0 else None
|
|
378
395
|
start = next(
|
|
379
396
|
(i + 1 for i, m in enumerate(self.transcript) if m["id"] == fork_id), 0
|
|
380
397
|
)
|
|
@@ -383,11 +400,16 @@ class CheckpointThread:
|
|
|
383
400
|
return branch
|
|
384
401
|
|
|
385
402
|
def sibling_info(self, message_id: str) -> tuple[int, list[int]]:
|
|
386
|
-
"""``message_id``'s own branch id and its
|
|
403
|
+
"""``message_id``'s own branch id and its full fan-out's branch ids (source branch included) in creation order."""
|
|
387
404
|
entries = self.sibling_branches(message_id)
|
|
388
405
|
index = next(i for i, m in enumerate(self.transcript) if m["id"] == message_id)
|
|
389
406
|
branch = self._branch_at(index)
|
|
390
|
-
|
|
407
|
+
sources = {e["fork_branch"] for e in entries}
|
|
408
|
+
if len(sources) > 1:
|
|
409
|
+
raise ValueError(
|
|
410
|
+
f"fork entries at one fan-out disagree on fork_branch: {sorted(sources)}"
|
|
411
|
+
)
|
|
412
|
+
return branch, sorted({e["branch_id"] for e in entries} | sources | {branch})
|
|
391
413
|
|
|
392
414
|
async def plan(self, trigger: str, *, rollback_to: str | None = None) -> Plan:
|
|
393
415
|
"""The resumption for ``trigger``; a fork trigger names its last surviving message via ``rollback_to``."""
|
|
@@ -427,14 +449,25 @@ class CheckpointThread:
|
|
|
427
449
|
}
|
|
428
450
|
return Plan(anchor, survivors, True)
|
|
429
451
|
|
|
452
|
+
async def plan_run(self, ctx: Any) -> Plan:
|
|
453
|
+
"""The resumption for a RunManager dispatch: trigger and rollback surface read off the run context."""
|
|
454
|
+
rollback = ctx.rollback_to if ctx.has_rollback else None
|
|
455
|
+
return await self.plan(ctx.trigger, rollback_to=rollback)
|
|
456
|
+
|
|
457
|
+
async def ingest_latest(self) -> None:
|
|
458
|
+
"""Ingest the store's latest checkpoint."""
|
|
459
|
+
await self.ingest(await self._get(None))
|
|
460
|
+
|
|
430
461
|
async def ingest(self, snapshot: Snapshot) -> None:
|
|
431
|
-
"""Index one new checkpoint: bump the path's
|
|
462
|
+
"""Index one new checkpoint: bump the path leaf's tip, upsert the message diff, and append the pending fork's branch entry; idempotent."""
|
|
432
463
|
pending, self._pending_fork = self._pending_fork, None
|
|
433
464
|
path = (
|
|
434
465
|
list(snapshot.lineage["branch_ids"])
|
|
435
466
|
if snapshot.lineage is not None
|
|
436
467
|
else list(self._path)
|
|
437
468
|
)
|
|
469
|
+
if not path:
|
|
470
|
+
raise ValueError("lineage branch_ids must not be empty")
|
|
438
471
|
anchor = self.checkpoint_id
|
|
439
472
|
if pending is not None:
|
|
440
473
|
# the new world descends from the fork message's branch, not the window's leaf
|
|
@@ -444,25 +477,29 @@ class CheckpointThread:
|
|
|
444
477
|
self._thread_id,
|
|
445
478
|
self._ns,
|
|
446
479
|
fork_message_id=pending["fork_message_id"],
|
|
480
|
+
fork_branch=pending["fork_branch"],
|
|
447
481
|
last_checkpoint_id=snapshot.checkpoint_id,
|
|
448
482
|
)
|
|
483
|
+
if not self.branches:
|
|
484
|
+
self.branches = [dict(_ROOT_ENTRY)]
|
|
449
485
|
self.branches = [
|
|
450
486
|
*self.branches,
|
|
451
487
|
{
|
|
452
488
|
"fork_message_id": pending["fork_message_id"],
|
|
489
|
+
"fork_branch": pending["fork_branch"],
|
|
453
490
|
"last_checkpoint_id": snapshot.checkpoint_id,
|
|
454
491
|
},
|
|
455
492
|
]
|
|
456
493
|
path = [*path, branch_id]
|
|
457
494
|
anchor = pending["anchor_checkpoint_id"]
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
495
|
+
tip = path[-1]
|
|
496
|
+
await self._index.bump_tips(
|
|
497
|
+
self._thread_id, self._ns, [tip], snapshot.checkpoint_id
|
|
498
|
+
)
|
|
499
|
+
if not self.branches:
|
|
500
|
+
self.branches = [dict(_ROOT_ENTRY)]
|
|
501
|
+
if tip < len(self.branches):
|
|
502
|
+
self.branches[tip]["last_checkpoint_id"] = snapshot.checkpoint_id
|
|
466
503
|
known = {m["id"] for m in self.transcript}
|
|
467
504
|
rows = [
|
|
468
505
|
{
|
|
@@ -470,7 +507,7 @@ class CheckpointThread:
|
|
|
470
507
|
"checkpoint_ns": self._ns,
|
|
471
508
|
"message_id": message["id"],
|
|
472
509
|
"anchor_checkpoint_id": anchor,
|
|
473
|
-
"branch_id": path[-1]
|
|
510
|
+
"branch_id": path[-1],
|
|
474
511
|
}
|
|
475
512
|
for message in snapshot.messages
|
|
476
513
|
if message["id"] not in known
|
|
@@ -493,11 +530,12 @@ class CheckpointThread:
|
|
|
493
530
|
)
|
|
494
531
|
return self
|
|
495
532
|
|
|
496
|
-
def _tip_of(self, branch_id: int) -> str
|
|
497
|
-
if branch_id == 0:
|
|
498
|
-
return None
|
|
533
|
+
def _tip_of(self, branch_id: int) -> str:
|
|
499
534
|
_check_branch_ids([branch_id], len(self.branches))
|
|
500
|
-
|
|
535
|
+
tip = self.branches[branch_id]["last_checkpoint_id"]
|
|
536
|
+
if tip is None:
|
|
537
|
+
raise LookupError(f"branch {branch_id} has no recorded tip")
|
|
538
|
+
return tip
|
|
501
539
|
|
|
502
540
|
async def _get(self, checkpoint_id: str | None) -> Snapshot:
|
|
503
541
|
snapshot = await self._store.get(checkpoint_id)
|
|
@@ -506,20 +544,14 @@ class CheckpointThread:
|
|
|
506
544
|
return snapshot
|
|
507
545
|
|
|
508
546
|
async def switch_branch(self, branch_id: int) -> "CheckpointThread":
|
|
509
|
-
"""Load the branch's
|
|
547
|
+
"""Load the branch's own continuation tip as the current window."""
|
|
510
548
|
return self._load(await self._get(self._tip_of(branch_id)))
|
|
511
549
|
|
|
512
|
-
|
|
550
|
+
def _world_path(self, branch_id: int) -> list[int]:
|
|
513
551
|
path = [0]
|
|
514
552
|
while branch_id != 0:
|
|
515
553
|
path.insert(1, branch_id)
|
|
516
|
-
|
|
517
|
-
if fork_id is None:
|
|
518
|
-
break
|
|
519
|
-
row = await self._index.get_message(self._thread_id, self._ns, fork_id)
|
|
520
|
-
if row is None:
|
|
521
|
-
raise KeyError(f"fork message {fork_id!r} is not indexed")
|
|
522
|
-
branch_id = row["branch_id"]
|
|
554
|
+
branch_id = self.branches[branch_id]["fork_branch"]
|
|
523
555
|
return path
|
|
524
556
|
|
|
525
557
|
async def switch_state(
|
|
@@ -528,7 +560,7 @@ class CheckpointThread:
|
|
|
528
560
|
"""Switch like ``switch_branch`` and return (transcript, lineage) for the host to write back into graph state."""
|
|
529
561
|
await self.switch_branch(branch_id)
|
|
530
562
|
lineage = self.lineage or {
|
|
531
|
-
"branch_ids":
|
|
563
|
+
"branch_ids": self._world_path(branch_id),
|
|
532
564
|
"parent_checkpoint_id": None,
|
|
533
565
|
}
|
|
534
566
|
self._path = list(lineage["branch_ids"])
|
|
@@ -547,44 +579,25 @@ class CheckpointThread:
|
|
|
547
579
|
raise ValueError("the current window has no tail message")
|
|
548
580
|
tail = self.transcript[-1]["id"]
|
|
549
581
|
candidates = [
|
|
550
|
-
i
|
|
582
|
+
i
|
|
551
583
|
for i, entry in enumerate(self.branches)
|
|
552
|
-
if entry["fork_message_id"] == tail
|
|
584
|
+
if i and entry["fork_message_id"] == tail
|
|
553
585
|
]
|
|
554
586
|
if not candidates:
|
|
555
587
|
raise LookupError(f"no world forks at tail message {tail!r}")
|
|
556
588
|
return self._load(await self._get(self._tip_of(candidates[-1])))
|
|
557
589
|
|
|
558
590
|
async def locate(self, message_id: str) -> "CheckpointThread":
|
|
559
|
-
"""Load
|
|
591
|
+
"""Load ``message_id``'s own branch's tip as the current window."""
|
|
560
592
|
row = await self._index.get_message(self._thread_id, self._ns, message_id)
|
|
561
593
|
if row is None:
|
|
562
594
|
raise KeyError(f"message {message_id!r} is not indexed")
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
if any(m["id"] == message_id for m in snapshot.messages):
|
|
570
|
-
return self._load(snapshot)
|
|
571
|
-
for i, entry in enumerate(self.branches):
|
|
572
|
-
candidate = i + 1
|
|
573
|
-
if candidate in checked or candidate in queue:
|
|
574
|
-
continue
|
|
575
|
-
fork_id = entry["fork_message_id"]
|
|
576
|
-
if fork_id is None:
|
|
577
|
-
fork_branch = 0
|
|
578
|
-
else:
|
|
579
|
-
fork_row = await self._index.get_message(
|
|
580
|
-
self._thread_id, self._ns, fork_id
|
|
581
|
-
)
|
|
582
|
-
if fork_row is None:
|
|
583
|
-
continue
|
|
584
|
-
fork_branch = fork_row["branch_id"]
|
|
585
|
-
if fork_branch in checked:
|
|
586
|
-
queue.append(candidate)
|
|
587
|
-
raise LookupError(f"no branch tip contains message {message_id!r}")
|
|
595
|
+
snapshot = await self._get(self._tip_of(row["branch_id"]))
|
|
596
|
+
if not any(m["id"] == message_id for m in snapshot.messages):
|
|
597
|
+
raise LookupError(
|
|
598
|
+
f"branch {row['branch_id']} tip does not contain message {message_id!r}"
|
|
599
|
+
)
|
|
600
|
+
return self._load(snapshot)
|
|
588
601
|
|
|
589
602
|
|
|
590
603
|
async def checkpoint_thread(
|
|
@@ -30,6 +30,22 @@ def msg(id, type="human"):
|
|
|
30
30
|
U1, A1, U2, A2 = msg("u1"), msg("a1", "ai"), msg("u2"), msg("a2", "ai")
|
|
31
31
|
|
|
32
32
|
|
|
33
|
+
def root(last_checkpoint_id=None):
|
|
34
|
+
return {
|
|
35
|
+
"fork_message_id": None,
|
|
36
|
+
"fork_branch": None,
|
|
37
|
+
"last_checkpoint_id": last_checkpoint_id,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def fork(fork_message_id, fork_branch, last_checkpoint_id):
|
|
42
|
+
return {
|
|
43
|
+
"fork_message_id": fork_message_id,
|
|
44
|
+
"fork_branch": fork_branch,
|
|
45
|
+
"last_checkpoint_id": last_checkpoint_id,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
33
49
|
@dataclass
|
|
34
50
|
class FakeStore:
|
|
35
51
|
snapshots: dict[str, Snapshot] = field(default_factory=dict)
|
|
@@ -79,7 +95,7 @@ async def test_boot_fresh_thread_is_empty():
|
|
|
79
95
|
async def test_boot_loads_latest_snapshot_and_branches():
|
|
80
96
|
store, index = FakeStore(), MemoryCheckpointIndex()
|
|
81
97
|
await index.append_branch(
|
|
82
|
-
THREAD, "", fork_message_id="u1", last_checkpoint_id="cp2"
|
|
98
|
+
THREAD, "", fork_message_id="u1", fork_branch=0, last_checkpoint_id="cp2"
|
|
83
99
|
)
|
|
84
100
|
lineage = {"branch_ids": [1], "parent_checkpoint_id": None}
|
|
85
101
|
store.put("cp2", [U1, A1], lineage)
|
|
@@ -87,9 +103,7 @@ async def test_boot_loads_latest_snapshot_and_branches():
|
|
|
87
103
|
assert thread.transcript == [U1, A1]
|
|
88
104
|
assert thread.checkpoint_id == "cp2"
|
|
89
105
|
assert thread.lineage == lineage
|
|
90
|
-
assert thread.branches == [
|
|
91
|
-
{"fork_message_id": "u1", "last_checkpoint_id": "cp2"}
|
|
92
|
-
]
|
|
106
|
+
assert thread.branches == [root(), fork("u1", 0, "cp2")]
|
|
93
107
|
|
|
94
108
|
|
|
95
109
|
async def test_boot_rejects_empty_thread_id():
|
|
@@ -143,7 +157,7 @@ async def test_ingest_is_idempotent():
|
|
|
143
157
|
store, index, thread = await seeded()
|
|
144
158
|
await thread.ingest(store.snapshots["cp2"])
|
|
145
159
|
assert await row(index, "u2") == {"anchor_checkpoint_id": "cp1", "branch_id": 0}
|
|
146
|
-
assert thread.branches == []
|
|
160
|
+
assert thread.branches == [root("cp2")]
|
|
147
161
|
assert thread.transcript == [U1, A1, U2, A2]
|
|
148
162
|
|
|
149
163
|
|
|
@@ -153,13 +167,11 @@ async def test_fork_ingest_appends_the_branch_entry_once():
|
|
|
153
167
|
a2b = msg("a2b", "ai")
|
|
154
168
|
snapshot = store.put("cp3", [U1, A1, U2, a2b])
|
|
155
169
|
await thread.ingest(snapshot)
|
|
156
|
-
assert thread.branches == [
|
|
157
|
-
{"fork_message_id": "u2", "last_checkpoint_id": "cp3"}
|
|
158
|
-
]
|
|
170
|
+
assert thread.branches == [root("cp2"), fork("u2", 0, "cp3")]
|
|
159
171
|
assert await index.read_thread(THREAD, "") == thread.branches
|
|
160
172
|
assert await row(index, "a2b") == {"anchor_checkpoint_id": "cp1", "branch_id": 1}
|
|
161
173
|
await thread.ingest(snapshot) # redelivery: the pending fork is consumed
|
|
162
|
-
assert len(await index.read_thread(THREAD, "")) ==
|
|
174
|
+
assert len(await index.read_thread(THREAD, "")) == 2
|
|
163
175
|
|
|
164
176
|
|
|
165
177
|
async def test_ingest_after_a_fork_bumps_the_forked_branch():
|
|
@@ -169,7 +181,7 @@ async def test_ingest_after_a_fork_bumps_the_forked_branch():
|
|
|
169
181
|
await thread.ingest(store.put("cp3", [U1, A1, U2, a2b]))
|
|
170
182
|
u3 = msg("u3")
|
|
171
183
|
await thread.ingest(store.put("cp4", [U1, A1, U2, a2b, u3]))
|
|
172
|
-
assert (await index.read_thread(THREAD, ""))[
|
|
184
|
+
assert (await index.read_thread(THREAD, ""))[1]["last_checkpoint_id"] == "cp4"
|
|
173
185
|
assert await row(index, "u3") == {"anchor_checkpoint_id": "cp3", "branch_id": 1}
|
|
174
186
|
|
|
175
187
|
|
|
@@ -183,8 +195,8 @@ async def test_fork_from_a_shared_message_descends_from_its_branch():
|
|
|
183
195
|
u2c, a2c = msg("u2c"), msg("a2c", "ai")
|
|
184
196
|
await thread.ingest(store.put("cp4", [U1, A1, u2c, a2c]))
|
|
185
197
|
branches = await index.read_thread(THREAD, "")
|
|
186
|
-
assert branches[
|
|
187
|
-
assert branches[
|
|
198
|
+
assert branches[1]["last_checkpoint_id"] == "cp3" # branch 1 untouched
|
|
199
|
+
assert branches[2] == fork("a1", 0, "cp4")
|
|
188
200
|
assert await row(index, "u2c") == {"anchor_checkpoint_id": None, "branch_id": 2}
|
|
189
201
|
|
|
190
202
|
|
|
@@ -225,6 +237,60 @@ async def test_plan_rejects_unknown_rollback_and_trigger():
|
|
|
225
237
|
await thread.plan("bogus")
|
|
226
238
|
|
|
227
239
|
|
|
240
|
+
# ─── Run resumption seam ────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
@dataclass(frozen=True)
|
|
244
|
+
class Ctx:
|
|
245
|
+
trigger: str
|
|
246
|
+
has_rollback: bool = False
|
|
247
|
+
rollback_to: str | None = None
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
async def test_plan_run_edit_allocates_a_fork_entry():
|
|
251
|
+
store, index, thread = await seeded()
|
|
252
|
+
plan = await thread.plan_run(Ctx("message-edit", True, "a1"))
|
|
253
|
+
assert plan == Plan(None, [U1, A1], True)
|
|
254
|
+
await thread.ingest(store.put("cp3", [U1, A1, msg("u2b"), msg("a2b", "ai")]))
|
|
255
|
+
assert await index.read_thread(THREAD, "") == [
|
|
256
|
+
root("cp2"),
|
|
257
|
+
{"fork_message_id": "a1", "fork_branch": 0, "last_checkpoint_id": "cp3"},
|
|
258
|
+
]
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
async def test_plan_run_reload_allocates_a_fork_entry():
|
|
262
|
+
store, index, thread = await seeded()
|
|
263
|
+
plan = await thread.plan_run(Ctx("message-reload", True, "u2"))
|
|
264
|
+
assert plan == Plan("cp1", [U2], True)
|
|
265
|
+
await thread.ingest(store.put("cp3", [U1, A1, U2, msg("a2b", "ai")]))
|
|
266
|
+
assert await index.read_thread(THREAD, "") == [
|
|
267
|
+
root("cp2"),
|
|
268
|
+
{"fork_message_id": "u2", "fork_branch": 0, "last_checkpoint_id": "cp3"},
|
|
269
|
+
]
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
async def test_plan_run_tip_triggers_allocate_nothing():
|
|
273
|
+
store, index, thread = await seeded()
|
|
274
|
+
for trigger in (
|
|
275
|
+
"message-send",
|
|
276
|
+
"input-resume",
|
|
277
|
+
"error-continue",
|
|
278
|
+
"stop-continue",
|
|
279
|
+
"steer",
|
|
280
|
+
):
|
|
281
|
+
assert await thread.plan_run(Ctx(trigger)) == Plan("cp2", [], False)
|
|
282
|
+
await thread.ingest(store.put("cp3", [U1, A1, U2, A2, msg("u3")]))
|
|
283
|
+
assert await index.read_thread(THREAD, "") == [root("cp3")]
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
async def test_ingest_latest_reads_the_store_tip():
|
|
287
|
+
store, _, thread = await seeded()
|
|
288
|
+
store.put("cp3", [U1, A1, U2, A2, msg("u3")])
|
|
289
|
+
await thread.ingest_latest()
|
|
290
|
+
assert thread.checkpoint_id == "cp3"
|
|
291
|
+
assert thread.transcript[-1] == msg("u3")
|
|
292
|
+
|
|
293
|
+
|
|
228
294
|
# ─── Sibling fan-out ────────────────────────────────────────
|
|
229
295
|
|
|
230
296
|
|
|
@@ -235,8 +301,8 @@ async def test_sibling_branches_order_by_allocation():
|
|
|
235
301
|
await thread.plan("message-reload", rollback_to="u2")
|
|
236
302
|
await thread.ingest(store.put("cp4", [U1, A1, U2, msg("a2c", "ai")]))
|
|
237
303
|
assert thread.sibling_branches("a2c") == [
|
|
238
|
-
{"branch_id": 1, "
|
|
239
|
-
{"branch_id": 2, "
|
|
304
|
+
{"branch_id": 1, **fork("u2", 0, "cp3")},
|
|
305
|
+
{"branch_id": 2, **fork("u2", 0, "cp4")},
|
|
240
306
|
]
|
|
241
307
|
assert thread.sibling_branches("u1") == []
|
|
242
308
|
with pytest.raises(KeyError, match="not in the current window"):
|
|
@@ -248,18 +314,28 @@ async def test_sibling_info_lists_the_fan_out_ids_in_creation_order():
|
|
|
248
314
|
for cid, reply in [("cp3", "a2b"), ("cp4", "a2c"), ("cp5", "a2d")]:
|
|
249
315
|
await thread.plan("message-reload", rollback_to="u2")
|
|
250
316
|
await thread.ingest(store.put(cid, [U1, A1, U2, msg(reply, "ai")]))
|
|
251
|
-
assert thread.sibling_info("a2d") == (3, [1, 2, 3])
|
|
317
|
+
assert thread.sibling_info("a2d") == (3, [0, 1, 2, 3])
|
|
252
318
|
assert thread.sibling_info("u2") == (0, [0])
|
|
253
319
|
lineage = {"branch_ids": [0, 1], "parent_checkpoint_id": None}
|
|
254
320
|
await thread.ingest(store.put("cp6", [U1, A1, U2, msg("a2b", "ai")], lineage))
|
|
255
|
-
assert thread.sibling_info("a2b") == (1, [1, 2, 3])
|
|
321
|
+
assert thread.sibling_info("a2b") == (1, [0, 1, 2, 3])
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
async def test_sibling_info_is_branch_invariant():
|
|
325
|
+
store, _, thread = await seeded()
|
|
326
|
+
await thread.plan("message-reload", rollback_to="u2")
|
|
327
|
+
await thread.ingest(store.put("cp3", [U1, A1, U2, msg("a2b", "ai")]))
|
|
328
|
+
from_fork = thread.sibling_info("a2b")[1]
|
|
329
|
+
await thread.switch_state(0)
|
|
330
|
+
from_source = thread.sibling_info("a2")[1]
|
|
331
|
+
assert from_fork == from_source == [0, 1]
|
|
256
332
|
|
|
257
333
|
|
|
258
334
|
async def test_sibling_info_counts_the_window_timeline_among_the_forks():
|
|
259
335
|
store, _, thread = await seeded()
|
|
260
336
|
await thread.plan("message-edit", rollback_to="a1")
|
|
261
337
|
await thread.ingest(store.put("cp3", [U1, A1, msg("u2c"), msg("a2c", "ai")]))
|
|
262
|
-
assert thread.sibling_info("u2c") == (1, [1])
|
|
338
|
+
assert thread.sibling_info("u2c") == (1, [0, 1])
|
|
263
339
|
lineage = {"branch_ids": [0], "parent_checkpoint_id": None}
|
|
264
340
|
await thread.ingest(store.put("cp4", [U1, A1, U2, A2], lineage))
|
|
265
341
|
assert thread.sibling_info("u2") == (0, [0, 1]) # branch 0 before the edit fork
|
|
@@ -269,7 +345,7 @@ async def test_sibling_info_counts_the_window_timeline_among_the_forks():
|
|
|
269
345
|
# ─── Switch branch ──────────────────────────────────────────
|
|
270
346
|
|
|
271
347
|
|
|
272
|
-
async def
|
|
348
|
+
async def test_switch_branch_loads_the_branch_tip():
|
|
273
349
|
store, index, thread = await seeded()
|
|
274
350
|
await thread.plan("message-reload", rollback_to="u2")
|
|
275
351
|
a2b = msg("a2b", "ai")
|
|
@@ -279,10 +355,14 @@ async def test_switch_branch_loads_the_subtree_tip():
|
|
|
279
355
|
assert thread.transcript == [U1, A1, U2, a2b]
|
|
280
356
|
|
|
281
357
|
|
|
282
|
-
async def
|
|
358
|
+
async def test_switch_branch_round_trips_to_the_source_branch():
|
|
283
359
|
store, _, thread = await seeded()
|
|
360
|
+
await thread.plan("message-reload", rollback_to="u2")
|
|
361
|
+
await thread.ingest(store.put("cp3", [U1, A1, U2, msg("a2b", "ai")]))
|
|
362
|
+
assert thread.checkpoint_id == "cp3"
|
|
284
363
|
await thread.switch_branch(0)
|
|
285
364
|
assert thread.checkpoint_id == "cp2"
|
|
365
|
+
assert thread.transcript == [U1, A1, U2, A2]
|
|
286
366
|
|
|
287
367
|
|
|
288
368
|
async def test_switch_branch_unknown_raises():
|
|
@@ -329,11 +409,12 @@ async def test_switch_state_ingest_round_trip_keeps_the_index_consistent():
|
|
|
329
409
|
await thread.ingest(store.put("cp5", transcript, lineage))
|
|
330
410
|
assert len(index._messages) == rows_before
|
|
331
411
|
branches = await index.read_thread(THREAD, "")
|
|
332
|
-
assert branches[0]["last_checkpoint_id"] == "
|
|
333
|
-
assert branches[1]["last_checkpoint_id"] == "
|
|
412
|
+
assert branches[0]["last_checkpoint_id"] == "cp2"
|
|
413
|
+
assert branches[1]["last_checkpoint_id"] == "cp5"
|
|
414
|
+
assert branches[2]["last_checkpoint_id"] == "cp4"
|
|
334
415
|
assert await row(index, "a2b") == {"anchor_checkpoint_id": "cp1", "branch_id": 1}
|
|
335
416
|
assert thread.checkpoint_id == "cp5"
|
|
336
|
-
assert thread.sibling_info("a2b") == (1, [1, 2])
|
|
417
|
+
assert thread.sibling_info("a2b") == (1, [0, 1, 2])
|
|
337
418
|
|
|
338
419
|
|
|
339
420
|
# ─── Paging across a compaction ─────────────────────────────
|
|
@@ -342,7 +423,7 @@ async def test_switch_state_ingest_round_trip_keeps_the_index_consistent():
|
|
|
342
423
|
async def compacted():
|
|
343
424
|
store, index, old = await seeded()
|
|
344
425
|
branch_id = await index.append_branch(
|
|
345
|
-
THREAD, "", fork_message_id="a2", last_checkpoint_id="cp3"
|
|
426
|
+
THREAD, "", fork_message_id="a2", fork_branch=0, last_checkpoint_id="cp3"
|
|
346
427
|
)
|
|
347
428
|
summary = msg("sum", "ai")
|
|
348
429
|
store.put(
|
|
@@ -365,7 +446,7 @@ async def compacted():
|
|
|
365
446
|
async def test_compaction_window_restarts_the_branch_path():
|
|
366
447
|
_, index, thread, window = await compacted()
|
|
367
448
|
assert thread.transcript == window
|
|
368
|
-
assert (await index.read_thread(THREAD, ""))[
|
|
449
|
+
assert (await index.read_thread(THREAD, ""))[1]["last_checkpoint_id"] == "cp4"
|
|
369
450
|
assert await row(index, "u3") == {"anchor_checkpoint_id": "cp3", "branch_id": 1}
|
|
370
451
|
|
|
371
452
|
|
|
@@ -407,17 +488,19 @@ async def test_locate_unknown_message_raises():
|
|
|
407
488
|
await thread.locate("nope")
|
|
408
489
|
|
|
409
490
|
|
|
410
|
-
async def
|
|
491
|
+
async def test_locate_loads_the_own_branch_tip_across_forks():
|
|
411
492
|
store, index, thread = await seeded()
|
|
412
493
|
await thread.plan("message-reload", rollback_to="u2")
|
|
413
494
|
a2b = msg("a2b", "ai")
|
|
414
495
|
await thread.ingest(store.put("cp3", [U1, A1, U2, a2b]))
|
|
415
496
|
await thread.plan("message-edit", rollback_to="a1")
|
|
416
497
|
await thread.ingest(store.put("cp4", [U1, A1, msg("u2c"), msg("a2c", "ai")]))
|
|
417
|
-
# u2 rides branch 0, whose tip
|
|
498
|
+
# u2 rides branch 0, whose own tip stays cp2 while forks advance the thread
|
|
418
499
|
await thread.locate("u2")
|
|
419
|
-
assert thread.checkpoint_id == "
|
|
500
|
+
assert thread.checkpoint_id == "cp2"
|
|
420
501
|
assert any(m["id"] == "u2" for m in thread.transcript)
|
|
502
|
+
await thread.locate("a2b")
|
|
503
|
+
assert thread.checkpoint_id == "cp3"
|
|
421
504
|
|
|
422
505
|
|
|
423
506
|
# ─── langgraph adapter ──────────────────────────────────────
|
|
@@ -571,19 +654,22 @@ async def pg_index(postgres_dsn):
|
|
|
571
654
|
async def test_postgres_branches_round_trip(pg_index):
|
|
572
655
|
assert await pg_index.read_thread(THREAD, "") == []
|
|
573
656
|
first = await pg_index.append_branch(
|
|
574
|
-
THREAD, "", fork_message_id="u2", last_checkpoint_id="cp1"
|
|
657
|
+
THREAD, "", fork_message_id="u2", fork_branch=0, last_checkpoint_id="cp1"
|
|
575
658
|
)
|
|
576
659
|
second = await pg_index.append_branch(
|
|
577
|
-
THREAD, "", fork_message_id=None, last_checkpoint_id="cp2"
|
|
660
|
+
THREAD, "", fork_message_id=None, fork_branch=0, last_checkpoint_id="cp2"
|
|
578
661
|
)
|
|
579
662
|
assert (first, second) == (1, 2)
|
|
580
|
-
await pg_index.bump_tips(THREAD, "", [2], "cp3")
|
|
663
|
+
await pg_index.bump_tips(THREAD, "", [0, 2], "cp3")
|
|
581
664
|
assert await pg_index.read_thread(THREAD, "") == [
|
|
582
|
-
|
|
583
|
-
|
|
665
|
+
root("cp3"),
|
|
666
|
+
fork("u2", 0, "cp1"),
|
|
667
|
+
fork(None, 0, "cp3"),
|
|
584
668
|
]
|
|
585
669
|
with pytest.raises(KeyError, match="unknown branch_id"):
|
|
586
670
|
await pg_index.bump_tips(THREAD, "", [3], "cp4")
|
|
671
|
+
await pg_index.bump_tips(THREAD, "ns2", [0], "cp1")
|
|
672
|
+
assert await pg_index.read_thread(THREAD, "ns2") == [root("cp1")]
|
|
587
673
|
|
|
588
674
|
|
|
589
675
|
async def test_postgres_messages_upsert_keeps_the_first_row(pg_index):
|
|
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
|
|
File without changes
|
|
File without changes
|