langgraph-runtime-inmem 0.37.0.dev2__tar.gz → 0.37.0.dev4__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 (19) hide show
  1. {langgraph_runtime_inmem-0.37.0.dev2 → langgraph_runtime_inmem-0.37.0.dev4}/Makefile +1 -1
  2. {langgraph_runtime_inmem-0.37.0.dev2 → langgraph_runtime_inmem-0.37.0.dev4}/PKG-INFO +1 -2
  3. {langgraph_runtime_inmem-0.37.0.dev2 → langgraph_runtime_inmem-0.37.0.dev4}/README.md +0 -1
  4. {langgraph_runtime_inmem-0.37.0.dev2 → langgraph_runtime_inmem-0.37.0.dev4}/langgraph_runtime_inmem/__init__.py +1 -1
  5. {langgraph_runtime_inmem-0.37.0.dev2 → langgraph_runtime_inmem-0.37.0.dev4}/langgraph_runtime_inmem/checkpoint.py +0 -47
  6. {langgraph_runtime_inmem-0.37.0.dev2 → langgraph_runtime_inmem-0.37.0.dev4}/langgraph_runtime_inmem/database.py +48 -2
  7. {langgraph_runtime_inmem-0.37.0.dev2 → langgraph_runtime_inmem-0.37.0.dev4}/langgraph_runtime_inmem/ops.py +230 -121
  8. {langgraph_runtime_inmem-0.37.0.dev2 → langgraph_runtime_inmem-0.37.0.dev4}/uv.lock +3 -3
  9. {langgraph_runtime_inmem-0.37.0.dev2 → langgraph_runtime_inmem-0.37.0.dev4}/.gitignore +0 -0
  10. {langgraph_runtime_inmem-0.37.0.dev2 → langgraph_runtime_inmem-0.37.0.dev4}/langgraph_runtime_inmem/_persistence.py +0 -0
  11. {langgraph_runtime_inmem-0.37.0.dev2 → langgraph_runtime_inmem-0.37.0.dev4}/langgraph_runtime_inmem/encryption.py +0 -0
  12. {langgraph_runtime_inmem-0.37.0.dev2 → langgraph_runtime_inmem-0.37.0.dev4}/langgraph_runtime_inmem/inmem_stream.py +0 -0
  13. {langgraph_runtime_inmem-0.37.0.dev2 → langgraph_runtime_inmem-0.37.0.dev4}/langgraph_runtime_inmem/lifespan.py +0 -0
  14. {langgraph_runtime_inmem-0.37.0.dev2 → langgraph_runtime_inmem-0.37.0.dev4}/langgraph_runtime_inmem/metrics.py +0 -0
  15. {langgraph_runtime_inmem-0.37.0.dev2 → langgraph_runtime_inmem-0.37.0.dev4}/langgraph_runtime_inmem/queue.py +0 -0
  16. {langgraph_runtime_inmem-0.37.0.dev2 → langgraph_runtime_inmem-0.37.0.dev4}/langgraph_runtime_inmem/retry.py +0 -0
  17. {langgraph_runtime_inmem-0.37.0.dev2 → langgraph_runtime_inmem-0.37.0.dev4}/langgraph_runtime_inmem/routes.py +0 -0
  18. {langgraph_runtime_inmem-0.37.0.dev2 → langgraph_runtime_inmem-0.37.0.dev4}/langgraph_runtime_inmem/store.py +0 -0
  19. {langgraph_runtime_inmem-0.37.0.dev2 → langgraph_runtime_inmem-0.37.0.dev4}/pyproject.toml +0 -0
@@ -16,4 +16,4 @@ bump-version:
16
16
  uv run --with hatch hatch version $(VERSION_KIND)
17
17
 
18
18
  check-base-imports:
19
- DATABASE_URI="foo" REDIS_URI="bar" uv run python -c "from langgraph_runtime_inmem import *"
19
+ DATABASE_URI="foo" REDIS_URI="bar" uv run python -c "from langgraph_runtime_inmem import *"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: langgraph-runtime-inmem
3
- Version: 0.37.0.dev2
3
+ Version: 0.37.0.dev4
4
4
  Summary: Inmem implementation for the LangGraph API server.
5
5
  Author-email: Will Fu-Hinthorn <will@langchain.dev>
6
6
  License: Elastic-2.0
@@ -17,4 +17,3 @@ Description-Content-Type: text/markdown
17
17
  # LangGraph Runtime Inmem
18
18
 
19
19
  This is the inmem implementation of the LangGraph Runtime API.
20
-
@@ -1,4 +1,3 @@
1
1
  # LangGraph Runtime Inmem
2
2
 
3
3
  This is the inmem implementation of the LangGraph Runtime API.
4
-
@@ -11,7 +11,7 @@ from langgraph_runtime_inmem import (
11
11
  store,
12
12
  )
13
13
 
14
- __version__ = "0.37.0.dev2"
14
+ __version__ = "0.37.0.dev4"
15
15
  __all__ = [
16
16
  "ops",
17
17
  "database",
@@ -308,50 +308,3 @@ def Checkpointer(*args, unpack_hook=None, **kwargs):
308
308
 
309
309
 
310
310
  __all__ = ["Checkpointer"]
311
-
312
-
313
- def _nbytes(value: Any) -> int:
314
- if isinstance(value, bytes | bytearray | memoryview):
315
- return len(value)
316
- if isinstance(value, tuple | list):
317
- return sum(_nbytes(item) for item in value)
318
- return 0
319
-
320
-
321
- async def thread_storage(conn: Any, thread_id: str) -> dict[str, Any]:
322
- """Bytes a thread's checkpoints occupy in memory, per table and per channel.
323
-
324
- Same shape as the postgres runtime's report, so one endpoint serves both.
325
- """
326
- saver = Checkpointer()
327
- by_channel: dict[str, dict[str, int]] = {}
328
- for (tid, _ns, channel, _version), blob in list(saver.blobs.items()):
329
- if tid != thread_id:
330
- continue
331
- entry = by_channel.setdefault(channel, {"versions": 0, "bytes": 0})
332
- entry["versions"] += 1
333
- entry["bytes"] += _nbytes(blob)
334
- checkpoint_count = checkpoint_bytes = 0
335
- if thread_id in saver.storage:
336
- for checkpoints in saver.storage[thread_id].values():
337
- for stored in checkpoints.values():
338
- checkpoint_count += 1
339
- checkpoint_bytes += _nbytes(stored)
340
- write_count = write_bytes = 0
341
- for (tid, _ns, _checkpoint_id), writes in list(saver.writes.items()):
342
- if tid != thread_id:
343
- continue
344
- for write in writes.values():
345
- write_count += 1
346
- write_bytes += _nbytes(write)
347
- channel_bytes = sum(entry["bytes"] for entry in by_channel.values())
348
- return {
349
- "total_bytes": checkpoint_bytes + channel_bytes + write_bytes,
350
- "checkpoints": {"count": checkpoint_count, "bytes": checkpoint_bytes},
351
- "channel_values": {
352
- "count": sum(entry["versions"] for entry in by_channel.values()),
353
- "bytes": channel_bytes,
354
- "by_channel": by_channel,
355
- },
356
- "pending_writes": {"count": write_count, "bytes": write_bytes},
357
- }
@@ -97,6 +97,8 @@ class GlobalStore(PersistentDict):
97
97
  ]
98
98
  self["assistant_versions"] = []
99
99
  self["crons"] = []
100
+ self["thread_has_next"] = {}
101
+ self["thread_failed"] = {}
100
102
 
101
103
 
102
104
  OPS_FILENAME = os.path.join(".langgraph_api", ".langgraph_ops.pckl")
@@ -158,7 +160,10 @@ async def connect(
158
160
 
159
161
 
160
162
  def _load_ops_file() -> None:
161
- """Load the saved ops file into ``GLOBAL_STORE``, setting it aside if unloadable."""
163
+ """Load the saved ops file into ``GLOBAL_STORE``, setting it aside if unloadable.
164
+
165
+ Runs the previous process left ``running`` are reset to ``pending`` to be retried.
166
+ """
162
167
  if not os.path.exists(OPS_FILENAME):
163
168
  return
164
169
 
@@ -175,6 +180,44 @@ def _load_ops_file() -> None:
175
180
  return
176
181
  GLOBAL_STORE.update(data)
177
182
 
183
+ # No worker has started yet, so a run saved as running was cut off by a crash.
184
+ # Left running, it blocks every later run on its thread (Runs.next skips the thread).
185
+ orphaned = [r for r in GLOBAL_STORE.get("runs") or [] if r["status"] == "running"]
186
+ now = datetime.now(UTC)
187
+ for run in orphaned:
188
+ run["status"] = "pending"
189
+ run["updated_at"] = now
190
+ if orphaned:
191
+ logger.warning(
192
+ "Retrying runs left running by a previous process that did not shut down cleanly",
193
+ run_ids=[str(r["run_id"]) for r in orphaned],
194
+ )
195
+
196
+
197
+ def upgrade_thread_status_facts(store: dict[str, Any]) -> None:
198
+ """Bring threads from ops files saved by older versions in line with how
199
+ status is derived now, as migration 000066 does for postgres.
200
+
201
+ Interrupted threads are recorded as having next steps; other threads count as
202
+ having none until their next snapshot. Threads whose status was `error` are
203
+ recorded as having failed. Cancels, which older versions stored in the thread's
204
+ error, are cleared when the error reads as exactly one; anything else, encrypted
205
+ errors included, stays.
206
+ """
207
+ has_next = store.setdefault("thread_has_next", {})
208
+ failed = store.setdefault("thread_failed", {})
209
+ for thread in store["threads"]:
210
+ if thread.get("status") == "interrupted":
211
+ has_next.setdefault(thread["thread_id"], True)
212
+ failed.setdefault(thread["thread_id"], thread.get("status") == "error")
213
+ error = thread.get("error")
214
+ if (
215
+ isinstance(error, dict)
216
+ and "__encryption_context__" not in error
217
+ and error.get("error") in ("UserInterrupt", "UserRollback")
218
+ ):
219
+ thread["error"] = None
220
+
178
221
 
179
222
  def _set_aside_ops_file(reason: Exception) -> None:
180
223
  """Rename an unloadable ops file to ``<file>.bak-<UTC timestamp>`` and log why."""
@@ -262,6 +305,7 @@ async def start_pool() -> None:
262
305
  for k in ["runs", "threads", "assistant_versions", "assistants"]:
263
306
  if not GLOBAL_STORE.get(k):
264
307
  GLOBAL_STORE[k] = []
308
+ upgrade_thread_status_facts(GLOBAL_STORE)
265
309
  # Remove agents that were created by the system to avoid removed graphs
266
310
  if GLOBAL_STORE.get("assistants"):
267
311
  for a in GLOBAL_STORE["assistants"]:
@@ -272,8 +316,10 @@ async def start_pool() -> None:
272
316
  for k in ["crons"]:
273
317
  if not GLOBAL_STORE.get(k):
274
318
  GLOBAL_STORE[k] = []
275
- register_persistent_dict(GLOBAL_STORE)
319
+ # Flushed in this order: a crash between the two writes then over-counts an
320
+ # attempt rather than letting a retried run skip one.
276
321
  register_persistent_dict(GLOBAL_RETRY_COUNTER._counters)
322
+ register_persistent_dict(GLOBAL_STORE)
277
323
  await start_stream()
278
324
 
279
325
 
@@ -93,6 +93,120 @@ def _using_encryption() -> bool:
93
93
  return using_encryption()
94
94
 
95
95
 
96
+ def _thread_has_next(conn: InMemConnectionProto) -> dict[UUID, bool]:
97
+ """Whether each thread's graph has next steps, as of its latest snapshot.
98
+
99
+ Kept beside the threads rather than on them, because thread dicts are returned
100
+ to callers as they are and this is internal. A missing entry means unknown.
101
+ """
102
+ return conn.store.setdefault("thread_has_next", {})
103
+
104
+
105
+ def _thread_failed(conn: InMemConnectionProto) -> dict[UUID, bool]:
106
+ """Whether each thread's last reported run failed.
107
+
108
+ Kept beside the threads for the same reason as ``_thread_has_next``: it is
109
+ internal, and a missing entry means unknown.
110
+ """
111
+ return conn.store.setdefault("thread_failed", {})
112
+
113
+
114
+ def _thread_failure(exception: BaseException | None) -> BaseException | None:
115
+ """The exception if the run really failed, and None for a user cancel or rollback.
116
+
117
+ Those are not failures, so they don't belong in the thread's error; the reason
118
+ for a cancel is recorded on the run instead (``cancel_reason``).
119
+ """
120
+ from langgraph_api.errors import UserInterrupt # noqa: PLC0415
121
+
122
+ return None if isinstance(exception, UserInterrupt) else exception
123
+
124
+
125
+ def _thread_status(
126
+ conn: InMemConnectionProto,
127
+ thread_id: UUID,
128
+ *,
129
+ failed: bool | None = None,
130
+ has_next: bool | None = None,
131
+ ) -> ThreadStatus:
132
+ """The one rule for a thread's status, used by every write that changes it.
133
+
134
+ busy if a run on the thread is pending or running; otherwise error if the last
135
+ run failed; otherwise interrupted if the graph has next steps (an interrupt or a
136
+ breakpoint); otherwise idle.
137
+
138
+ A nil ``failed`` or ``has_next`` means the caller doesn't know the fact, so it is
139
+ read from what is stored on the thread; an unknown fact counts as no.
140
+ """
141
+ if any(
142
+ r["thread_id"] == thread_id and r["status"] in ("pending", "running")
143
+ for r in conn.store["runs"]
144
+ ):
145
+ return "busy"
146
+ if failed is None:
147
+ failed = _thread_failed(conn).get(thread_id, False)
148
+ if failed:
149
+ return "error"
150
+ if has_next is None:
151
+ has_next = _thread_has_next(conn).get(thread_id, False)
152
+ if has_next:
153
+ return "interrupted"
154
+ return "idle"
155
+
156
+
157
+ async def _save_thread_status(
158
+ conn: InMemConnectionProto,
159
+ thread_id: UUID,
160
+ thread: dict[str, Any],
161
+ stored_thread: dict[str, Any],
162
+ checkpoint: CheckpointPayload | None,
163
+ exception: BaseException | None,
164
+ now: datetime,
165
+ ) -> None:
166
+ """Write a thread's status and error from the facts this update knows.
167
+
168
+ Every writer derives the status from the thread's facts with the same rule.
169
+ Without a checkpoint there is nothing to replace values, interrupts and
170
+ has_next with, so the thread keeps the ones it already has. has_next is
171
+ recorded only once the thread is saved, so a failed save can't leave the two
172
+ apart.
173
+ """
174
+ from langgraph_api.serde import json_dumpb, json_loads # noqa: PLC0415
175
+
176
+ failure = _thread_failure(exception)
177
+ has_next = None if checkpoint is None else bool(checkpoint["next"])
178
+
179
+ update: dict = {
180
+ "updated_at": now,
181
+ "state_updated_at": now,
182
+ "status": _thread_status(
183
+ conn, thread_id, failed=failure is not None, has_next=has_next
184
+ ),
185
+ "error": json_loads(json_dumpb(failure)) if failure else None,
186
+ }
187
+ if checkpoint is not None:
188
+ update["values"] = json_loads(json_dumpb(checkpoint["values"]))
189
+ update["interrupts"] = json_loads(
190
+ json_dumpb(
191
+ {
192
+ t["id"]: [_patch_interrupt(i) for i in t["interrupts"]]
193
+ for t in checkpoint["tasks"]
194
+ if t.get("interrupts")
195
+ }
196
+ )
197
+ )
198
+ thread.update(update)
199
+ if _using_encryption():
200
+ encrypted_thread = await _encrypt_for_storage(thread, "thread")
201
+ stored_thread.clear()
202
+ stored_thread.update(encrypted_thread)
203
+ # Recorded only once the thread is saved, so a failed save can't leave the
204
+ # facts apart from the thread.
205
+ if has_next is not None:
206
+ _thread_has_next(conn)[thread_id] = has_next
207
+ _thread_failed(conn)[thread_id] = failure is not None
208
+
209
+
96
210
  def _run_stream_mode_matches(event_mode: str, stream_mode: list[str] | None) -> bool:
97
211
  """Return True if a published run-stream event matches the join filter."""
98
212
  if not stream_mode:
@@ -920,6 +1034,14 @@ def _patch_interrupt(
920
1034
  }
921
1035
 
922
1036
 
1037
+ def _nbytes(value: Any) -> int:
1038
+ if isinstance(value, bytes | bytearray | memoryview):
1039
+ return len(value)
1040
+ if isinstance(value, tuple | list):
1041
+ return sum(_nbytes(item) for item in value)
1042
+ return 0
1043
+
1044
+
923
1045
  class Threads(Authenticated):
924
1046
  resource = "threads"
925
1047
 
@@ -1110,6 +1232,52 @@ class Threads(Authenticated):
1110
1232
 
1111
1233
  return _yield_result()
1112
1234
 
1235
+ @staticmethod
1236
+ async def storage(
1237
+ conn: InMemConnectionProto,
1238
+ thread_id: UUID | str,
1239
+ ctx: Auth.types.BaseAuthContext | None = None,
1240
+ ) -> dict[str, Any]:
1241
+ """Bytes a thread's checkpoints occupy in memory, per table and per channel.
1242
+
1243
+ Same shape as the postgres runtime's report, so one endpoint serves both.
1244
+ Raises 404 if the thread doesn't exist or the auth handler hides it.
1245
+ """
1246
+ await Threads.get(conn, thread_id, ctx=ctx)
1247
+ thread_id = str(thread_id)
1248
+ saver = Checkpointer()
1249
+ by_channel: dict[str, dict[str, int]] = {}
1250
+ for (tid, _ns, channel, _version), blob in list(saver.blobs.items()):
1251
+ if tid != thread_id:
1252
+ continue
1253
+ entry = by_channel.setdefault(channel, {"versions": 0, "bytes": 0})
1254
+ entry["versions"] += 1
1255
+ entry["bytes"] += _nbytes(blob)
1256
+ checkpoint_count = checkpoint_bytes = 0
1257
+ if thread_id in saver.storage:
1258
+ for checkpoints in saver.storage[thread_id].values():
1259
+ for stored in checkpoints.values():
1260
+ checkpoint_count += 1
1261
+ checkpoint_bytes += _nbytes(stored)
1262
+ write_count = write_bytes = 0
1263
+ for (tid, _ns, _checkpoint_id), writes in list(saver.writes.items()):
1264
+ if tid != thread_id:
1265
+ continue
1266
+ for write in writes.values():
1267
+ write_count += 1
1268
+ write_bytes += _nbytes(write)
1269
+ channel_bytes = sum(entry["bytes"] for entry in by_channel.values())
1270
+ return {
1271
+ "total_bytes": checkpoint_bytes + channel_bytes + write_bytes,
1272
+ "checkpoints": {"count": checkpoint_count, "bytes": checkpoint_bytes},
1273
+ "channel_values": {
1274
+ "count": sum(entry["versions"] for entry in by_channel.values()),
1275
+ "bytes": channel_bytes,
1276
+ "by_channel": by_channel,
1277
+ },
1278
+ "pending_writes": {"count": write_count, "bytes": write_bytes},
1279
+ }
1280
+
1113
1281
  @staticmethod
1114
1282
  async def put(
1115
1283
  conn: InMemConnectionProto | Any,
@@ -1253,17 +1421,8 @@ class Threads(Authenticated):
1253
1421
  # This does not accept the auth context since it's only used internally
1254
1422
  ) -> None:
1255
1423
  """Set the status of a thread."""
1256
- from langgraph_api.serde import json_dumpb, json_loads # noqa: PLC0415
1257
-
1258
1424
  thread_id = _ensure_uuid(thread_id)
1259
1425
 
1260
- async def has_pending_runs(conn_: InMemConnectionProto, tid: UUID) -> bool:
1261
- """Check if thread has any pending runs."""
1262
- return any(
1263
- run["status"] in ("pending", "running") and run["thread_id"] == tid
1264
- for run in conn_.store["runs"]
1265
- )
1266
-
1267
1426
  # Find the thread
1268
1427
  thread = next(
1269
1428
  (
@@ -1282,47 +1441,15 @@ class Threads(Authenticated):
1282
1441
  if _using_encryption():
1283
1442
  thread = await _decrypt_from_storage(thread, "thread")
1284
1443
 
1285
- # Determine has_next from checkpoint
1286
- has_next = False if checkpoint is None else bool(checkpoint["next"])
1287
-
1288
- # Determine base status
1289
- if exception:
1290
- status = "error"
1291
- elif has_next:
1292
- status = "interrupted"
1293
- else:
1294
- status = "idle"
1295
-
1296
- # Check for pending runs and update to busy if found
1297
- if await has_pending_runs(conn, thread_id):
1298
- status = "busy"
1299
-
1300
- # Update thread
1301
- now = datetime.now(UTC)
1302
- update: dict = {
1303
- "updated_at": now,
1304
- "state_updated_at": now,
1305
- "status": status,
1306
- "error": json_loads(json_dumpb(exception)) if exception else None,
1307
- }
1308
- # Without a checkpoint there is nothing to replace values and interrupts
1309
- # with, so keep the ones the thread already has.
1310
- if checkpoint is not None:
1311
- update["values"] = json_loads(json_dumpb(checkpoint["values"]))
1312
- update["interrupts"] = json_loads(
1313
- json_dumpb(
1314
- {
1315
- t["id"]: [_patch_interrupt(i) for i in t["interrupts"]]
1316
- for t in checkpoint["tasks"]
1317
- if t.get("interrupts")
1318
- }
1319
- )
1320
- )
1321
- thread.update(update)
1322
- if _using_encryption():
1323
- encrypted_thread = await _encrypt_for_storage(thread, "thread")
1324
- stored_thread.clear()
1325
- stored_thread.update(encrypted_thread)
1444
+ await _save_thread_status(
1445
+ conn,
1446
+ thread_id,
1447
+ thread,
1448
+ stored_thread,
1449
+ checkpoint,
1450
+ exception,
1451
+ datetime.now(UTC),
1452
+ )
1326
1453
 
1327
1454
  @staticmethod
1328
1455
  async def set_joint_status(
@@ -1348,18 +1475,9 @@ class Threads(Authenticated):
1348
1475
  exception: Exception that occurred (affects thread status)
1349
1476
  """
1350
1477
  # No auth since it's internal
1351
- from langgraph_api.errors import UserInterrupt, UserRollback # noqa: PLC0415
1352
- from langgraph_api.serde import json_dumpb, json_loads # noqa: PLC0415
1353
-
1354
1478
  thread_id = _ensure_uuid(thread_id)
1355
1479
  run_id = _ensure_uuid(run_id)
1356
1480
 
1357
- def _thread_has_active_runs() -> bool:
1358
- return any(
1359
- r["thread_id"] == thread_id and r["status"] in ("pending", "running")
1360
- for r in conn.store["runs"]
1361
- )
1362
-
1363
1481
  thread = next(
1364
1482
  (t for t in conn.store["threads"] if t["thread_id"] == thread_id), None
1365
1483
  )
@@ -1380,55 +1498,16 @@ class Threads(Authenticated):
1380
1498
  if run is None:
1381
1499
  raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
1382
1500
 
1383
- has_next = bool(checkpoint and checkpoint["next"])
1384
- if exception and not isinstance(exception, UserInterrupt | UserRollback):
1385
- base_thread_status: ThreadStatus = "error"
1386
- elif has_next:
1387
- base_thread_status = "interrupted"
1388
- else:
1389
- base_thread_status = "idle"
1390
-
1391
1501
  now = datetime.now(UTC)
1392
1502
 
1393
1503
  if run_status == "rollback":
1394
1504
  await Runs.delete(conn, run_id, thread_id=run["thread_id"])
1395
- final_thread_status: ThreadStatus = (
1396
- "busy" if _thread_has_active_runs() else base_thread_status
1397
- )
1398
-
1399
1505
  else:
1400
1506
  run.update({"status": run_status, "updated_at": now})
1401
-
1402
- if run_status in ("pending", "running") or _thread_has_active_runs():
1403
- final_thread_status = "busy"
1404
- else:
1405
- final_thread_status = base_thread_status
1406
1507
  thread["metadata"]["graph_id"] = graph_id
1407
- update: dict = {
1408
- "updated_at": now,
1409
- "state_updated_at": now,
1410
- "status": final_thread_status,
1411
- "error": json_loads(json_dumpb(exception)) if exception else None,
1412
- }
1413
- # Without a checkpoint (a run that ended before writing one, or a
1414
- # rollback) there is nothing to replace values and interrupts with, so
1415
- # keep the ones the thread already has.
1416
- if checkpoint is not None:
1417
- update["values"] = json_loads(json_dumpb(checkpoint["values"]))
1418
- update["interrupts"] = json_loads(
1419
- json_dumpb(
1420
- {
1421
- t["id"]: [_patch_interrupt(i) for i in t["interrupts"]]
1422
- for t in checkpoint["tasks"]
1423
- if t.get("interrupts")
1424
- }
1425
- )
1426
- )
1427
- thread.update(update)
1428
- if _using_encryption():
1429
- encrypted_thread = await _encrypt_for_storage(thread, "thread")
1430
- stored_thread.clear()
1431
- stored_thread.update(encrypted_thread)
1508
+ await _save_thread_status(
1509
+ conn, thread_id, thread, stored_thread, checkpoint, exception, now
1510
+ )
1432
1511
 
1433
1512
  @staticmethod
1434
1513
  async def delete(
@@ -1472,6 +1551,8 @@ class Threads(Authenticated):
1472
1551
  for index, candidate in enumerate(thread_list):
1473
1552
  if candidate["thread_id"] == thread_id:
1474
1553
  thread_list.pop(index)
1554
+ _thread_has_next(conn).pop(thread_id, None)
1555
+ _thread_failed(conn).pop(thread_id, None)
1475
1556
 
1476
1557
  async def id_iterator() -> AsyncIterator[UUID]:
1477
1558
  yield thread_id
@@ -1598,7 +1679,10 @@ class Threads(Authenticated):
1598
1679
  ):
1599
1680
  return _empty_generator()
1600
1681
 
1601
- # Create new thread with copied metadata
1682
+ # The copy gets the original's checkpoints below, so it starts where the
1683
+ # original stands: same values, interrupts and error, and the same fact
1684
+ # about whether its graph has next steps. Its status comes from those,
1685
+ # by the usual rule: the original's runs are not the copy's.
1602
1686
  now = datetime.now(tz=UTC)
1603
1687
  new_thread: Thread = {
1604
1688
  "thread_id": new_thread_id,
@@ -1606,16 +1690,29 @@ class Threads(Authenticated):
1606
1690
  "updated_at": now,
1607
1691
  "state_updated_at": now,
1608
1692
  "metadata": copy.deepcopy(original_thread["metadata"]),
1609
- "status": "idle",
1693
+ "status": _thread_status(
1694
+ conn,
1695
+ new_thread_id,
1696
+ failed=_thread_failed(conn).get(thread_id, False),
1697
+ has_next=_thread_has_next(conn).get(thread_id, False),
1698
+ ),
1610
1699
  "config": {},
1700
+ "values": copy.deepcopy(original_thread.get("values")),
1701
+ "interrupts": copy.deepcopy(original_thread.get("interrupts", {})),
1702
+ "error": copy.deepcopy(original_thread.get("error")),
1611
1703
  }
1612
-
1613
1704
  # Add new thread to store
1614
1705
  conn.store["threads"].append(
1615
1706
  await _encrypt_for_storage(new_thread, "thread")
1616
1707
  if _using_encryption()
1617
1708
  else new_thread
1618
1709
  )
1710
+ if thread_id in _thread_has_next(conn):
1711
+ _thread_has_next(conn)[new_thread_id] = _thread_has_next(conn)[
1712
+ thread_id
1713
+ ]
1714
+ if thread_id in _thread_failed(conn):
1715
+ _thread_failed(conn)[new_thread_id] = _thread_failed(conn)[thread_id]
1619
1716
 
1620
1717
  from langgraph_api import config as api_config # noqa: PLC0415
1621
1718
 
@@ -2544,10 +2641,7 @@ class Runs(Authenticated):
2544
2641
  if run["thread_id"] == thread_id
2545
2642
  ):
2546
2643
  continue
2547
- # Increment attempt counter
2548
- attempt = await conn.retry_counter.increment(run_id)
2549
- # Set run as "running"
2550
- run["status"] = "running"
2644
+ stored_run = run
2551
2645
  encryption_context = None
2552
2646
  if _using_encryption():
2553
2647
  from langgraph_runtime_inmem.encryption import ( # noqa: PLC0415
@@ -2558,6 +2652,9 @@ class Runs(Authenticated):
2558
2652
  run.get("kwargs", {}).get("config")
2559
2653
  )
2560
2654
  run = await _decrypt_from_storage(run, "run")
2655
+ # Claim only after decryption succeeds, so failures remain retryable.
2656
+ attempt = await conn.retry_counter.increment(run_id)
2657
+ stored_run["status"] = "running"
2561
2658
  yield run, attempt, encryption_context
2562
2659
 
2563
2660
  @asynccontextmanager
@@ -2850,6 +2947,7 @@ class Runs(Authenticated):
2850
2947
  created_at=datetime.now(UTC) + timedelta(seconds=after_seconds),
2851
2948
  updated_at=datetime.now(UTC),
2852
2949
  langsmith_session_name=langsmith_session_name,
2950
+ cancel_reason=None,
2853
2951
  )
2854
2952
  stored_run = new_run
2855
2953
  if encryption_enabled:
@@ -3071,6 +3169,7 @@ class Runs(Authenticated):
3071
3169
  stream_manager = get_stream_manager()
3072
3170
  coros = []
3073
3171
  cancelable_runs = []
3172
+ pending_thread_ids: set[UUID] = set()
3074
3173
 
3075
3174
  for run in candidate_runs:
3076
3175
  run_id = run["run_id"]
@@ -3085,20 +3184,11 @@ class Runs(Authenticated):
3085
3184
 
3086
3185
  if run["status"] in ("pending", "running"):
3087
3186
  cancelable_runs.append(run)
3187
+ if run["status"] == "pending":
3188
+ pending_thread_ids.add(run["thread_id"])
3088
3189
  if queues or action != "rollback":
3089
- if run["status"] == "pending":
3090
- thread = next(
3091
- (
3092
- t
3093
- for t in conn.store["threads"]
3094
- if t["thread_id"] == run["thread_id"]
3095
- ),
3096
- None,
3097
- )
3098
- if thread:
3099
- thread["status"] = "idle"
3100
- thread["updated_at"] = datetime.now(tz=UTC)
3101
3190
  run["status"] = "interrupted"
3191
+ run["cancel_reason"] = reason
3102
3192
  run["updated_at"] = datetime.now(tz=UTC)
3103
3193
  else:
3104
3194
  await logger.ainfo(
@@ -3126,6 +3216,17 @@ class Runs(Authenticated):
3126
3216
  if coros:
3127
3217
  await asyncio.gather(*coros)
3128
3218
 
3219
+ # Cancelling a pending run changes which runs are active on its thread, so
3220
+ # re-derive the thread's status. Nothing else about the thread changed.
3221
+ if pending_thread_ids:
3222
+ for thread in conn.store["threads"]:
3223
+ if thread["thread_id"] in pending_thread_ids:
3224
+ thread["status"] = _thread_status(
3225
+ conn,
3226
+ thread["thread_id"],
3227
+ )
3228
+ thread["updated_at"] = datetime.now(tz=UTC)
3229
+
3129
3230
  await logger.ainfo(
3130
3231
  "Cancelled runs",
3131
3232
  run_ids=[str(r["run_id"]) for r in cancelable_runs],
@@ -3853,7 +3954,15 @@ class Crons(Authenticated):
3853
3954
  if _using_encryption()
3854
3955
  else cron
3855
3956
  )
3856
- yield {**plaintext, "now": now}, encryption_context
3957
+ # The scheduler mutates the payload to build each run; handing out the stored dict breaks every later fire.
3958
+ yield (
3959
+ {
3960
+ **plaintext,
3961
+ "payload": copy.deepcopy(plaintext["payload"]),
3962
+ "now": now,
3963
+ },
3964
+ encryption_context,
3965
+ )
3857
3966
 
3858
3967
  @staticmethod
3859
3968
  async def set_next_run_date(
@@ -1034,11 +1034,11 @@ wheels = [
1034
1034
 
1035
1035
  [[package]]
1036
1036
  name = "urllib3"
1037
- version = "2.7.0"
1037
+ version = "2.8.0"
1038
1038
  source = { registry = "https://pypi.org/simple" }
1039
- sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
1039
+ sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" }
1040
1040
  wheels = [
1041
- { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
1041
+ { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" },
1042
1042
  ]
1043
1043
 
1044
1044
  [[package]]