harness-sdk-python 0.25.2__tar.gz → 0.26.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 (39) hide show
  1. {harness_sdk_python-0.25.2 → harness_sdk_python-0.26.0}/PKG-INFO +1 -1
  2. {harness_sdk_python-0.25.2 → harness_sdk_python-0.26.0}/pyproject.toml +1 -1
  3. {harness_sdk_python-0.25.2 → harness_sdk_python-0.26.0}/src/harness_sdk/__init__.py +2 -0
  4. harness_sdk_python-0.26.0/src/harness_sdk/deepagents.py +210 -0
  5. {harness_sdk_python-0.25.2 → harness_sdk_python-0.26.0}/src/harness_sdk/run_manager.py +19 -6
  6. harness_sdk_python-0.26.0/tests/test_deepagents_messages.py +238 -0
  7. harness_sdk_python-0.26.0/tests/test_mint_concurrency.py +59 -0
  8. {harness_sdk_python-0.25.2 → harness_sdk_python-0.26.0}/.gitignore +0 -0
  9. {harness_sdk_python-0.25.2 → harness_sdk_python-0.26.0}/README.md +0 -0
  10. {harness_sdk_python-0.25.2 → harness_sdk_python-0.26.0}/examples/__init__.py +0 -0
  11. {harness_sdk_python-0.25.2 → harness_sdk_python-0.26.0}/examples/runs_app.py +0 -0
  12. {harness_sdk_python-0.25.2 → harness_sdk_python-0.26.0}/src/harness_sdk/checkpoints.py +0 -0
  13. {harness_sdk_python-0.25.2 → harness_sdk_python-0.26.0}/src/harness_sdk/fenced_postgres.py +0 -0
  14. {harness_sdk_python-0.25.2 → harness_sdk_python-0.26.0}/src/harness_sdk/linear_thread.py +0 -0
  15. {harness_sdk_python-0.25.2 → harness_sdk_python-0.26.0}/tests/run_helpers.py +0 -0
  16. {harness_sdk_python-0.25.2 → harness_sdk_python-0.26.0}/tests/test_admit_steer.py +0 -0
  17. {harness_sdk_python-0.25.2 → harness_sdk_python-0.26.0}/tests/test_applied_visibility.py +0 -0
  18. {harness_sdk_python-0.25.2 → harness_sdk_python-0.26.0}/tests/test_batches.py +0 -0
  19. {harness_sdk_python-0.25.2 → harness_sdk_python-0.26.0}/tests/test_branch_anchor.py +0 -0
  20. {harness_sdk_python-0.25.2 → harness_sdk_python-0.26.0}/tests/test_checkpoints.py +0 -0
  21. {harness_sdk_python-0.25.2 → harness_sdk_python-0.26.0}/tests/test_dispatching.py +0 -0
  22. {harness_sdk_python-0.25.2 → harness_sdk_python-0.26.0}/tests/test_durability.py +0 -0
  23. {harness_sdk_python-0.25.2 → harness_sdk_python-0.26.0}/tests/test_edit_dispatched.py +0 -0
  24. {harness_sdk_python-0.25.2 → harness_sdk_python-0.26.0}/tests/test_edit_reload.py +0 -0
  25. {harness_sdk_python-0.25.2 → harness_sdk_python-0.26.0}/tests/test_enqueue.py +0 -0
  26. {harness_sdk_python-0.25.2 → harness_sdk_python-0.26.0}/tests/test_fenced_postgres.py +0 -0
  27. {harness_sdk_python-0.25.2 → harness_sdk_python-0.26.0}/tests/test_input_required.py +0 -0
  28. {harness_sdk_python-0.25.2 → harness_sdk_python-0.26.0}/tests/test_linear_thread.py +0 -0
  29. {harness_sdk_python-0.25.2 → harness_sdk_python-0.26.0}/tests/test_meta.py +0 -0
  30. {harness_sdk_python-0.25.2 → harness_sdk_python-0.26.0}/tests/test_outcomes.py +0 -0
  31. {harness_sdk_python-0.25.2 → harness_sdk_python-0.26.0}/tests/test_placement.py +0 -0
  32. {harness_sdk_python-0.25.2 → harness_sdk_python-0.26.0}/tests/test_prepare_hooks.py +0 -0
  33. {harness_sdk_python-0.25.2 → harness_sdk_python-0.26.0}/tests/test_restore.py +0 -0
  34. {harness_sdk_python-0.25.2 → harness_sdk_python-0.26.0}/tests/test_rewind_during_run.py +0 -0
  35. {harness_sdk_python-0.25.2 → harness_sdk_python-0.26.0}/tests/test_run_crash_sweep.py +0 -0
  36. {harness_sdk_python-0.25.2 → harness_sdk_python-0.26.0}/tests/test_settle.py +0 -0
  37. {harness_sdk_python-0.25.2 → harness_sdk_python-0.26.0}/tests/test_steer.py +0 -0
  38. {harness_sdk_python-0.25.2 → harness_sdk_python-0.26.0}/tests/test_stop_continue.py +0 -0
  39. {harness_sdk_python-0.25.2 → harness_sdk_python-0.26.0}/tests/test_stopping_window.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: harness-sdk-python
3
- Version: 0.25.2
3
+ Version: 0.26.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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "harness-sdk-python"
3
- version = "0.25.2"
3
+ version = "0.26.0"
4
4
  description = "RunManager: the harness-sdk runs subsystem for Python Statewire hosts"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -11,6 +11,7 @@ from .checkpoints import (
11
11
  langgraph_snapshot,
12
12
  langgraph_switch_values,
13
13
  )
14
+ from .deepagents import DeepagentsMessages
14
15
  from .linear_thread import linear_thread
15
16
  from .run_manager import RunManager, RunManagerDescriptor, run_manager
16
17
 
@@ -18,6 +19,7 @@ __all__ = [
18
19
  "CheckpointIndex",
19
20
  "CheckpointStore",
20
21
  "CheckpointThread",
22
+ "DeepagentsMessages",
21
23
  "MemoryCheckpointIndex",
22
24
  "Plan",
23
25
  "PostgresCheckpointIndex",
@@ -0,0 +1,210 @@
1
+ """DeepagentsMessages: the ``messages`` slice of a Statewire host, projected
2
+ from a langgraph checkpointer in the deepagents dialect. The checkpointer is
3
+ the source of truth and the wire state is its projection, restamped with
4
+ ``branch: {id, siblings}`` metadata after every ingest. A rewind restores the
5
+ projection from the target message's anchor checkpoint; a branch switch clones
6
+ the target branch's tip as the checkpointer's latest checkpoint, so resumption
7
+ and reboot both land on the switched world.
8
+ """
9
+
10
+ from datetime import datetime, timezone
11
+ from typing import Any
12
+
13
+ from statepatch import plain
14
+
15
+ from .checkpoints import (
16
+ CheckpointIndex,
17
+ CheckpointStore,
18
+ Snapshot,
19
+ _values_snapshot,
20
+ checkpoint_thread,
21
+ langgraph_snapshot,
22
+ )
23
+
24
+
25
+ def _role(message: dict[str, Any]) -> str:
26
+ return "user" if message["type"] == "human" else "assistant"
27
+
28
+
29
+ def _user_text(message: dict[str, Any]) -> str:
30
+ return "\n\n".join(
31
+ part["text"] for part in message["parts"] if part["type"] == "text"
32
+ )
33
+
34
+
35
+ def _checkpointer_store(
36
+ checkpointer: Any, thread_id: str, checkpoint_ns: str
37
+ ) -> CheckpointStore:
38
+ class _Store:
39
+ async def get(self, checkpoint_id: str | None) -> Snapshot | None:
40
+ configurable: dict[str, Any] = {
41
+ "thread_id": thread_id,
42
+ "checkpoint_ns": checkpoint_ns,
43
+ }
44
+ if checkpoint_id is not None:
45
+ configurable["checkpoint_id"] = checkpoint_id
46
+ saved = await checkpointer.aget_tuple({"configurable": configurable})
47
+ if saved is None:
48
+ return None
49
+ return _values_snapshot(
50
+ saved.checkpoint["id"],
51
+ saved.checkpoint["channel_values"],
52
+ "messages",
53
+ "lineage",
54
+ )
55
+
56
+ return _Store()
57
+
58
+
59
+ class DeepagentsMessages:
60
+ """A host's ``state["messages"]`` over a langgraph checkpointer: boot, turn streaming, rewind, and branch switching for the deepagents dialect."""
61
+
62
+ def __init__(self, checkpointer: Any) -> None:
63
+ if not hasattr(checkpointer, "aget_tuple") or not hasattr(checkpointer, "aput"):
64
+ raise TypeError(
65
+ f"checkpointer must be a langgraph checkpoint saver, got {checkpointer!r}"
66
+ )
67
+ self._checkpointer = checkpointer
68
+ self._captured: dict[str, Any] | None = None
69
+
70
+ async def boot(
71
+ self, state: Any, *, index: CheckpointIndex, thread_id: str
72
+ ) -> None:
73
+ """Adopt ``state["messages"]`` and load the thread's history into it with branch stamps."""
74
+ self._state = state
75
+ self._thread_id = thread_id
76
+ self._store = _checkpointer_store(self._checkpointer, thread_id, "")
77
+ self._thread = await checkpoint_thread(
78
+ store=self._store, index=index, thread_id=thread_id, role=_role
79
+ )
80
+ if self._thread.transcript:
81
+ # the graph carries no lineage channel: re-derive the world path from the tail row
82
+ row = await index.get_message(
83
+ thread_id, "", self._thread.transcript[-1]["id"]
84
+ )
85
+ if row is not None and row["branch_id"] != 0:
86
+ await self._thread.switch_state(row["branch_id"])
87
+ self._restamp()
88
+
89
+ async def get_message_meta(self, message_id: str | None) -> dict[str, Any] | None:
90
+ """The thread projection's message meta, for RunManager."""
91
+ return await self._thread.get_message_meta(message_id)
92
+
93
+ async def get_message_child_id(self, parent_id: str) -> str | None:
94
+ """The thread projection's child id, for RunManager."""
95
+ return await self._thread.get_message_child_id(parent_id)
96
+
97
+ def config(self) -> dict[str, Any]:
98
+ """The langgraph config addressing this thread."""
99
+ return {"configurable": {"thread_id": self._thread_id}}
100
+
101
+ async def plan_turn(
102
+ self, ctx: Any
103
+ ) -> tuple[dict[str, Any] | None, dict[str, Any]]:
104
+ """The dispatch's langgraph (payload, config): plans the resumption, restores a rewind's window, appends the batch to the wire state, and confirms it applied."""
105
+ config = self.config()
106
+ if ctx.resume:
107
+ return None, config
108
+ plan = await self._thread.plan_run(ctx)
109
+ if ctx.has_rollback:
110
+ base = (
111
+ (await self._snapshot(plan.checkpoint_id)).messages
112
+ if plan.checkpoint_id is not None
113
+ else []
114
+ )
115
+ self._replace([*base, *plan.replay])
116
+ turn: list[Any] = [*plan.replay]
117
+ for message in ctx.messages:
118
+ turn.append(self._append_user(message))
119
+ ctx.applied()
120
+ if plan.fork:
121
+ if plan.checkpoint_id is not None:
122
+ config["configurable"]["checkpoint_id"] = plan.checkpoint_id
123
+ else:
124
+ from langchain_core.messages import RemoveMessage
125
+ from langgraph.graph.message import REMOVE_ALL_MESSAGES
126
+
127
+ turn = [RemoveMessage(id=REMOVE_ALL_MESSAGES), *turn]
128
+ return ({"messages": turn} if turn else None), config
129
+
130
+ def append_event(self, namespace: tuple, mode: str, event: Any) -> None:
131
+ """Fold one ``astream`` event into the wire state; the root checkpoint stream is captured for the turn's ingest."""
132
+ if mode == "checkpoints":
133
+ if namespace == ():
134
+ self._captured = event
135
+ return
136
+ from statewire.langgraph import append_langgraph_event
137
+
138
+ append_langgraph_event(self._state, namespace, mode, event)
139
+
140
+ async def ingest_turn(self, *, refetch: bool = False) -> None:
141
+ """Ingest the turn's captured checkpoint and restamp; ``refetch`` discards the capture and reads the checkpointer's latest instead."""
142
+ captured, self._captured = self._captured, None
143
+ if refetch or captured is None:
144
+ await self._thread.ingest_latest()
145
+ else:
146
+ await self._thread.ingest(langgraph_snapshot(captured))
147
+ self._restamp()
148
+
149
+ def append_users(self, messages: Any) -> list[dict[str, Any]]:
150
+ """Append harness user messages to the wire state as human entries, deduplicated by id; returns the langgraph turn entries."""
151
+ return [self._append_user(message) for message in messages]
152
+
153
+ async def switch_branch(self, branch_id: int) -> None:
154
+ """Switch to ``branch_id``: its tip becomes the checkpointer's latest checkpoint and the wire state rebaselines."""
155
+ transcript, lineage = await self._thread.switch_state(branch_id)
156
+ checkpoint_id = await self._clone_tip(self._thread.checkpoint_id)
157
+ await self._thread.ingest(Snapshot(checkpoint_id, transcript, lineage))
158
+ self._restamp()
159
+
160
+ async def _clone_tip(self, checkpoint_id: str) -> str:
161
+ from langgraph.checkpoint.base.id import uuid6
162
+
163
+ parent = {
164
+ "configurable": {
165
+ "thread_id": self._thread_id,
166
+ "checkpoint_ns": "",
167
+ "checkpoint_id": checkpoint_id,
168
+ }
169
+ }
170
+ saved = await self._checkpointer.aget_tuple(parent)
171
+ if saved is None:
172
+ raise LookupError(f"checkpoint {checkpoint_id!r} is not in the checkpointer")
173
+ clone = {
174
+ **saved.checkpoint,
175
+ "id": str(uuid6(clock_seq=-2)),
176
+ "ts": datetime.now(timezone.utc).isoformat(),
177
+ }
178
+ metadata = {
179
+ "source": "update",
180
+ "step": (saved.metadata or {}).get("step", -1) + 1,
181
+ "parents": {},
182
+ }
183
+ written = await self._checkpointer.aput(parent, clone, metadata, {})
184
+ return written["configurable"]["checkpoint_id"]
185
+
186
+ async def _snapshot(self, checkpoint_id: str) -> Snapshot:
187
+ snapshot = await self._store.get(checkpoint_id)
188
+ if snapshot is None:
189
+ raise LookupError(
190
+ f"checkpoint {checkpoint_id!r} is not in the checkpointer"
191
+ )
192
+ return snapshot
193
+
194
+ def _append_user(self, message: dict[str, Any]) -> dict[str, Any]:
195
+ entry = {"id": message["id"], "type": "human", "content": _user_text(message)}
196
+ if not any(m["id"] == entry["id"] for m in plain(self._state["messages"])):
197
+ self._state["messages"].append(entry)
198
+ return entry
199
+
200
+ def _replace(self, messages: list[dict[str, Any]]) -> None:
201
+ from statewire.langgraph import replace_messages
202
+
203
+ replace_messages(self._state, messages)
204
+
205
+ def _restamp(self) -> None:
206
+ stamped = []
207
+ for message in self._thread.transcript:
208
+ branch, siblings = self._thread.sibling_info(message["id"])
209
+ stamped.append({**message, "branch": {"id": branch, "siblings": siblings}})
210
+ self._replace(stamped)
@@ -48,6 +48,7 @@ window.
48
48
  """
49
49
 
50
50
  import asyncio
51
+ import contextlib
51
52
  import logging
52
53
  from dataclasses import dataclass, field
53
54
  from typing import Any, AsyncIterator, Callable, Coroutine, Iterable, Protocol
@@ -1610,7 +1611,7 @@ class RunManagerNamespace(TrayMapNamespace):
1610
1611
  f"{type(api).__name__} must implement get_message_meta "
1611
1612
  "(RunManager.Thread) to host @run_manager"
1612
1613
  )
1613
- self._mint_lock = asyncio.Lock()
1614
+ self._mint: "asyncio.Task[Any] | None" = None
1614
1615
  self._mounted: list[RunManager] = []
1615
1616
  api.state["runs"] = []
1616
1617
  api.state["status"] = "ready"
@@ -1628,14 +1629,26 @@ class RunManagerNamespace(TrayMapNamespace):
1628
1629
  return tray
1629
1630
 
1630
1631
  async def _machine(self, run_id: str) -> tuple[RunManager, bool]:
1631
- tray = self.live()
1632
- if tray is not None:
1633
- return tray, False
1634
- async with self._mint_lock:
1632
+ # No await between the live() check and the mint's publication, and no
1633
+ # lock held across storage I/O: same-tick callers await the shared mint
1634
+ # task and merge into the machine it mounts; the creator raises its
1635
+ # mint's failure while followers re-mint and progress.
1636
+ while True:
1635
1637
  tray = self.live()
1636
1638
  if tray is not None:
1637
1639
  return tray, False
1638
- return await self.get_or_create(run_id), True # type: ignore[return-value]
1640
+ pending = self._mint
1641
+ if pending is None:
1642
+ pending = self._api.create_task(self.get_or_create(run_id))
1643
+ self._mint = pending
1644
+ pending.add_done_callback(self._mint_settled)
1645
+ return await pending, True
1646
+ with contextlib.suppress(Exception):
1647
+ await pending
1648
+
1649
+ def _mint_settled(self, task: "asyncio.Task[Any]") -> None:
1650
+ if self._mint is task:
1651
+ self._mint = None
1639
1652
 
1640
1653
  # ─── Mounted view ───────────────────────────────────────
1641
1654
 
@@ -0,0 +1,238 @@
1
+ """Contract: DeepagentsMessages at its public seam — a langgraph checkpointer
2
+ projected onto ``state["messages"]``, driven the way the host's stream loop
3
+ drives it (plan_turn, append_event per stream event, ingest_turn per turn)."""
4
+
5
+ import itertools
6
+ from dataclasses import dataclass
7
+
8
+ import pytest
9
+
10
+ from harness_sdk import DeepagentsMessages, MemoryCheckpointIndex
11
+
12
+ pytest.importorskip("langgraph.graph")
13
+
14
+ from langchain_core.messages import AIMessage, RemoveMessage # noqa: E402
15
+ from langgraph.checkpoint.memory import InMemorySaver # noqa: E402
16
+ from langgraph.graph import START, StateGraph # noqa: E402
17
+ from langgraph.graph.message import REMOVE_ALL_MESSAGES, add_messages # noqa: E402
18
+
19
+ THREAD = "t1"
20
+
21
+
22
+ def _graph(saver):
23
+ from typing import Annotated, TypedDict
24
+
25
+ counter = itertools.count()
26
+
27
+ class State(TypedDict):
28
+ messages: Annotated[list, add_messages]
29
+
30
+ def node(state):
31
+ n = next(counter)
32
+ return {"messages": [AIMessage(id=f"a{n}", content=f"reply {n}")]}
33
+
34
+ builder = StateGraph(State)
35
+ builder.add_node("node", node)
36
+ builder.add_edge(START, "node")
37
+ return builder.compile(checkpointer=saver)
38
+
39
+
40
+ @dataclass
41
+ class Ctx:
42
+ trigger: str
43
+ messages: tuple = ()
44
+ resume: bool = False
45
+ has_rollback: bool = False
46
+ rollback_to: str | None = None
47
+ applied_count: int = 0
48
+
49
+ def applied(self):
50
+ self.applied_count += 1
51
+
52
+
53
+ def user(id, text):
54
+ return {"id": id, "role": "user", "parts": [{"type": "text", "text": text}]}
55
+
56
+
57
+ async def booted(saver, index):
58
+ state = {"messages": []}
59
+ messages = DeepagentsMessages(saver)
60
+ await messages.boot(state, index=index, thread_id=THREAD)
61
+ return messages, state
62
+
63
+
64
+ async def stream(graph, messages, payload, config):
65
+ async for namespace, mode, event in graph.astream(
66
+ payload,
67
+ config=config,
68
+ stream_mode=["messages", "updates", "checkpoints"],
69
+ subgraphs=True,
70
+ ):
71
+ messages.append_event(namespace, mode, event)
72
+
73
+
74
+ async def turn(graph, messages, ctx):
75
+ payload, config = await messages.plan_turn(ctx)
76
+ await stream(graph, messages, payload, config)
77
+ await messages.ingest_turn()
78
+
79
+
80
+ def ids(state):
81
+ return [m["id"] for m in state["messages"]]
82
+
83
+
84
+ def branch(state, message_id):
85
+ return next(m["branch"] for m in state["messages"] if m["id"] == message_id)
86
+
87
+
88
+ async def seeded():
89
+ """Two turns: [u1, a0, u2, a1] on branch 0."""
90
+ saver, index = InMemorySaver(), MemoryCheckpointIndex()
91
+ graph = _graph(saver)
92
+ messages, state = await booted(saver, index)
93
+ await turn(graph, messages, Ctx("message-send", (user("u1", "one"),)))
94
+ await turn(graph, messages, Ctx("message-send", (user("u2", "two"),)))
95
+ return saver, index, graph, messages, state
96
+
97
+
98
+ # ─── Boot ───────────────────────────────────────────────────
99
+
100
+
101
+ async def test_boot_fresh_thread_projects_empty():
102
+ _, state = await booted(InMemorySaver(), MemoryCheckpointIndex())
103
+ assert state["messages"] == []
104
+
105
+
106
+ async def test_boot_rejects_a_non_saver():
107
+ with pytest.raises(TypeError, match="checkpoint saver"):
108
+ DeepagentsMessages(object())
109
+
110
+
111
+ async def test_reboot_restores_history_with_stamps():
112
+ saver, index, _, _, state = await seeded()
113
+ _, rebooted = await booted(saver, index)
114
+ assert rebooted["messages"] == state["messages"]
115
+ assert ids(rebooted) == ["u1", "a0", "u2", "a1"]
116
+
117
+
118
+ # ─── Turn streaming ─────────────────────────────────────────
119
+
120
+
121
+ async def test_send_turn_projects_and_stamps():
122
+ saver, index = InMemorySaver(), MemoryCheckpointIndex()
123
+ graph = _graph(saver)
124
+ messages, state = await booted(saver, index)
125
+ ctx = Ctx("message-send", (user("u1", "one"),))
126
+ await turn(graph, messages, ctx)
127
+ assert ctx.applied_count == 1
128
+ assert ids(state) == ["u1", "a0"]
129
+ assert [m["type"] for m in state["messages"]] == ["human", "ai"]
130
+ assert branch(state, "a0") == {"id": 0, "siblings": [0]}
131
+
132
+
133
+ async def test_plan_turn_resume_carries_no_payload():
134
+ _, _, _, messages, _ = await seeded()
135
+ ctx = Ctx("message-send", resume=True)
136
+ payload, config = await messages.plan_turn(ctx)
137
+ assert payload is None
138
+ assert config == {"configurable": {"thread_id": THREAD}}
139
+ assert ctx.applied_count == 0
140
+
141
+
142
+ async def test_append_users_dedupes_by_id():
143
+ _, _, _, messages, state = await seeded()
144
+ entries = messages.append_users([user("u3", "three")])
145
+ assert entries == [{"id": "u3", "type": "human", "content": "three"}]
146
+ messages.append_users([user("u3", "three")])
147
+ assert ids(state).count("u3") == 1
148
+
149
+
150
+ async def test_ingest_turn_refetch_discards_the_capture():
151
+ saver, index = InMemorySaver(), MemoryCheckpointIndex()
152
+ graph = _graph(saver)
153
+ messages, state = await booted(saver, index)
154
+ payload, config = await messages.plan_turn(
155
+ Ctx("message-send", (user("u1", "one"),))
156
+ )
157
+ await stream(graph, messages, payload, config)
158
+ # the stopped run persisted a further exchange after the capture
159
+ await graph.ainvoke(
160
+ {"messages": [{"id": "u2", "type": "human", "content": "two"}]},
161
+ {"configurable": {"thread_id": THREAD}},
162
+ )
163
+ await messages.ingest_turn(refetch=True)
164
+ assert ids(state) == ["u1", "a0", "u2", "a1"]
165
+
166
+
167
+ # ─── Rewind ─────────────────────────────────────────────────
168
+
169
+
170
+ async def test_reload_restores_the_window_from_the_anchor_checkpoint():
171
+ _, _, graph, messages, state = await seeded()
172
+ ctx = Ctx("message-reload", has_rollback=True, rollback_to="u2")
173
+ payload, config = await messages.plan_turn(ctx)
174
+ assert ids(state) == ["u1", "a0", "u2"]
175
+ assert config["configurable"]["checkpoint_id"] is not None
176
+ assert [m["id"] for m in payload["messages"]] == ["u2"]
177
+ await stream(graph, messages, payload, config)
178
+ await messages.ingest_turn()
179
+ assert ids(state) == ["u1", "a0", "u2", "a2"]
180
+ assert branch(state, "a2") == {"id": 1, "siblings": [0, 1]}
181
+ assert branch(state, "u2") == {"id": 0, "siblings": [0]}
182
+
183
+
184
+ async def test_edit_of_the_first_message_starts_fresh():
185
+ _, _, graph, messages, state = await seeded()
186
+ ctx = Ctx(
187
+ "message-edit", (user("u1b", "redo"),), has_rollback=True, rollback_to=None
188
+ )
189
+ payload, config = await messages.plan_turn(ctx)
190
+ assert ids(state) == ["u1b"]
191
+ assert payload["messages"][0] == RemoveMessage(id=REMOVE_ALL_MESSAGES)
192
+ assert "checkpoint_id" not in config["configurable"]
193
+ await stream(graph, messages, payload, config)
194
+ await messages.ingest_turn()
195
+ assert ids(state) == ["u1b", "a2"]
196
+ assert branch(state, "u1b") == {"id": 1, "siblings": [0, 1]}
197
+
198
+
199
+ # ─── Branch switch ──────────────────────────────────────────
200
+
201
+
202
+ async def forked():
203
+ """seeded() plus a reload at u2: branch 1 replaces a1 with a2."""
204
+ saver, index, graph, messages, state = await seeded()
205
+ await turn(
206
+ graph, messages, Ctx("message-reload", has_rollback=True, rollback_to="u2")
207
+ )
208
+ assert ids(state) == ["u1", "a0", "u2", "a2"]
209
+ return saver, index, graph, messages, state
210
+
211
+
212
+ async def test_switch_branch_rebaselines_and_stamps():
213
+ _, _, _, messages, state = await forked()
214
+ await messages.switch_branch(0)
215
+ assert ids(state) == ["u1", "a0", "u2", "a1"]
216
+ assert branch(state, "a1") == {"id": 0, "siblings": [0, 1]}
217
+
218
+
219
+ async def test_switch_branch_survives_a_reboot():
220
+ saver, index, _, messages, _ = await forked()
221
+ await messages.switch_branch(0)
222
+ _, state = await booted(saver, index)
223
+ assert ids(state) == ["u1", "a0", "u2", "a1"]
224
+ assert branch(state, "a1") == {"id": 0, "siblings": [0, 1]}
225
+
226
+
227
+ async def test_send_after_a_switch_continues_the_switched_world():
228
+ _, _, graph, messages, state = await forked()
229
+ await messages.switch_branch(0)
230
+ await turn(graph, messages, Ctx("message-send", (user("u3", "three"),)))
231
+ assert ids(state) == ["u1", "a0", "u2", "a1", "u3", "a3"]
232
+ assert branch(state, "u3") == {"id": 0, "siblings": [0]}
233
+
234
+
235
+ async def test_switch_branch_unknown_raises():
236
+ _, _, _, messages, _ = await seeded()
237
+ with pytest.raises(KeyError, match="unknown branch_id"):
238
+ await messages.switch_branch(7)
@@ -0,0 +1,59 @@
1
+ import asyncio
2
+
3
+ import pytest
4
+ from pinned.testing import MemoryStateStore
5
+ from run_helpers import Script, run_host
6
+
7
+
8
+ class WedgeStore(MemoryStateStore):
9
+ """get_tray parks for flagged tray ids until ``fail()`` fires the store's
10
+ op deadline, the way a half-open conn parks a postgres transaction."""
11
+
12
+ def __init__(self) -> None:
13
+ super().__init__()
14
+ self.wedged: set[str] = set()
15
+ self.parked = asyncio.Event()
16
+ self._deadline = asyncio.Event()
17
+
18
+ def fail(self) -> None:
19
+ self._deadline.set()
20
+
21
+ async def get_tray(self, ns, id, tray_ns, tray_id, epoch):
22
+ if tray_id in self.wedged:
23
+ self.wedged.discard(tray_id)
24
+ self.parked.set()
25
+ await self._deadline.wait()
26
+ raise TimeoutError("store op deadline")
27
+ return await super().get_tray(ns, id, tray_ns, tray_id, epoch)
28
+
29
+
30
+ async def test_a_parked_mint_fails_at_the_deadline_and_followers_progress():
31
+ """No lock is held across mint storage I/O: a wedged mint fails loudly at
32
+ the store deadline, the waiting command re-mints its own machine, and the
33
+ wedged command surfaces the store error."""
34
+ script = Script()
35
+ store = WedgeStore()
36
+ async with run_host(script, store=store) as (drv, host):
37
+ api = host.live
38
+ store.wedged.add("run-a")
39
+ parked = asyncio.create_task(api.runs.for_run("run-a"))
40
+ await asyncio.wait_for(store.parked.wait(), 5)
41
+ follower = asyncio.create_task(api.runs.for_run("run-b"))
42
+ await asyncio.sleep(0.05)
43
+ assert not follower.done() # same-tick mints merge: followers await the pending mint
44
+ store.fail()
45
+ tray = await asyncio.wait_for(follower, 5)
46
+ assert tray.id == "run-b"
47
+ with pytest.raises(TimeoutError):
48
+ await parked
49
+
50
+
51
+ async def test_concurrent_same_run_id_mints_resolve_to_one_machine():
52
+ script = Script()
53
+ async with run_host(script) as (drv, host):
54
+ api = host.live
55
+ trays = await asyncio.gather(
56
+ *(asyncio.create_task(api.runs.for_run("run-x")) for _ in range(3))
57
+ )
58
+ assert all(tray is trays[0] for tray in trays)
59
+ assert list(api.runs.instances) == ["run-x"]