coco-framework 0.1.0__py3-none-any.whl

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 (400) hide show
  1. coco/__init__.py +3 -0
  2. coco/agents/__init__.py +9 -0
  3. coco/agents/_subagent_transformer.py +307 -0
  4. coco/agents/factory.py +2164 -0
  5. coco/agents/middleware/__init__.py +100 -0
  6. coco/agents/middleware/_execution.py +385 -0
  7. coco/agents/middleware/_redaction.py +454 -0
  8. coco/agents/middleware/_retry.py +134 -0
  9. coco/agents/middleware/_trace_policy.py +61 -0
  10. coco/agents/middleware/context_editing.py +306 -0
  11. coco/agents/middleware/file_search.py +433 -0
  12. coco/agents/middleware/human_in_the_loop.py +680 -0
  13. coco/agents/middleware/internal_call_transformer.py +108 -0
  14. coco/agents/middleware/model_call_limit.py +267 -0
  15. coco/agents/middleware/model_fallback.py +417 -0
  16. coco/agents/middleware/model_retry.py +327 -0
  17. coco/agents/middleware/pii.py +878 -0
  18. coco/agents/middleware/provider_tool_search.py +307 -0
  19. coco/agents/middleware/shell_tool.py +949 -0
  20. coco/agents/middleware/summarization.py +910 -0
  21. coco/agents/middleware/todo.py +357 -0
  22. coco/agents/middleware/tool_call_limit.py +495 -0
  23. coco/agents/middleware/tool_emulator.py +251 -0
  24. coco/agents/middleware/tool_error.py +209 -0
  25. coco/agents/middleware/tool_retry.py +421 -0
  26. coco/agents/middleware/tool_selection.py +515 -0
  27. coco/agents/middleware/types.py +2201 -0
  28. coco/agents/structured_output.py +472 -0
  29. coco/chat_models/__init__.py +7 -0
  30. coco/chat_models/base.py +1082 -0
  31. coco/embeddings/__init__.py +18 -0
  32. coco/embeddings/base.py +279 -0
  33. coco/mcp/__init__.py +36 -0
  34. coco/mcp/adapter.py +245 -0
  35. coco/mcp/elicitation.py +394 -0
  36. coco/mcp/tools.py +307 -0
  37. coco/messages/__init__.py +73 -0
  38. coco/py.typed +0 -0
  39. coco/rate_limiters/__init__.py +13 -0
  40. coco/tools/__init__.py +22 -0
  41. coco/tools/tool_node.py +20 -0
  42. coco_anthropic/__init__.py +15 -0
  43. coco_anthropic/_client_utils.py +81 -0
  44. coco_anthropic/_compat.py +228 -0
  45. coco_anthropic/_sdk_compat.py +124 -0
  46. coco_anthropic/_version.py +3 -0
  47. coco_anthropic/chat_models.py +3349 -0
  48. coco_anthropic/data/__init__.py +1 -0
  49. coco_anthropic/data/_profiles.py +491 -0
  50. coco_anthropic/data/profile_augmentations.toml +69 -0
  51. coco_anthropic/experimental.py +140 -0
  52. coco_anthropic/llms.py +428 -0
  53. coco_anthropic/middleware/__init__.py +25 -0
  54. coco_anthropic/middleware/anthropic_tools.py +1197 -0
  55. coco_anthropic/middleware/bash.py +84 -0
  56. coco_anthropic/middleware/file_search.py +360 -0
  57. coco_anthropic/middleware/prompt_caching.py +276 -0
  58. coco_anthropic/output_parsers.py +93 -0
  59. coco_anthropic/py.typed +0 -0
  60. coco_core/__init__.py +20 -0
  61. coco_core/_api/__init__.py +87 -0
  62. coco_core/_api/beta_decorator.py +266 -0
  63. coco_core/_api/deprecation.py +637 -0
  64. coco_core/_api/internal.py +23 -0
  65. coco_core/_api/path.py +50 -0
  66. coco_core/_import_utils.py +41 -0
  67. coco_core/_protocol.py +625 -0
  68. coco_core/_security/__init__.py +36 -0
  69. coco_core/_security/_exceptions.py +9 -0
  70. coco_core/_security/_policy.py +313 -0
  71. coco_core/_security/_ssrf_protection.py +155 -0
  72. coco_core/_security/_transport.py +254 -0
  73. coco_core/agents.py +258 -0
  74. coco_core/caches.py +272 -0
  75. coco_core/callbacks/__init__.py +132 -0
  76. coco_core/callbacks/base.py +1229 -0
  77. coco_core/callbacks/file.py +266 -0
  78. coco_core/callbacks/manager.py +2826 -0
  79. coco_core/callbacks/stdout.py +123 -0
  80. coco_core/callbacks/streaming_stdout.py +154 -0
  81. coco_core/callbacks/usage.py +124 -0
  82. coco_core/chat_history.py +267 -0
  83. coco_core/chat_loaders.py +26 -0
  84. coco_core/chat_sessions.py +19 -0
  85. coco_core/cross_encoders.py +18 -0
  86. coco_core/document_loaders/__init__.py +36 -0
  87. coco_core/document_loaders/base.py +155 -0
  88. coco_core/document_loaders/blob_loaders.py +38 -0
  89. coco_core/documents/__init__.py +55 -0
  90. coco_core/documents/base.py +347 -0
  91. coco_core/documents/compressor.py +74 -0
  92. coco_core/documents/transformers.py +79 -0
  93. coco_core/embeddings/__init__.py +31 -0
  94. coco_core/embeddings/embeddings.py +78 -0
  95. coco_core/embeddings/fake.py +129 -0
  96. coco_core/env.py +22 -0
  97. coco_core/example_selectors/__init__.py +47 -0
  98. coco_core/example_selectors/base.py +60 -0
  99. coco_core/example_selectors/length_based.py +131 -0
  100. coco_core/example_selectors/semantic_similarity.py +366 -0
  101. coco_core/exceptions.py +166 -0
  102. coco_core/globals.py +72 -0
  103. coco_core/indexing/__init__.py +53 -0
  104. coco_core/indexing/api.py +954 -0
  105. coco_core/indexing/base.py +661 -0
  106. coco_core/indexing/in_memory.py +104 -0
  107. coco_core/language_models/__init__.py +116 -0
  108. coco_core/language_models/_compat_bridge.py +844 -0
  109. coco_core/language_models/_utils.py +343 -0
  110. coco_core/language_models/base.py +500 -0
  111. coco_core/language_models/chat_model_stream.py +1525 -0
  112. coco_core/language_models/chat_models.py +2748 -0
  113. coco_core/language_models/fake.py +137 -0
  114. coco_core/language_models/fake_chat_models.py +396 -0
  115. coco_core/language_models/llms.py +1569 -0
  116. coco_core/language_models/model_profile.py +183 -0
  117. coco_core/load/__init__.py +44 -0
  118. coco_core/load/_validation.py +191 -0
  119. coco_core/load/dump.py +120 -0
  120. coco_core/load/load.py +828 -0
  121. coco_core/load/mapping.py +1097 -0
  122. coco_core/load/serializable.py +406 -0
  123. coco_core/messages/__init__.py +198 -0
  124. coco_core/messages/ai.py +854 -0
  125. coco_core/messages/base.py +518 -0
  126. coco_core/messages/block_translators/__init__.py +112 -0
  127. coco_core/messages/block_translators/anthropic.py +534 -0
  128. coco_core/messages/block_translators/bedrock.py +108 -0
  129. coco_core/messages/block_translators/bedrock_converse.py +329 -0
  130. coco_core/messages/block_translators/coco_v0.py +303 -0
  131. coco_core/messages/block_translators/google_genai.py +610 -0
  132. coco_core/messages/block_translators/google_vertexai.py +21 -0
  133. coco_core/messages/block_translators/groq.py +157 -0
  134. coco_core/messages/block_translators/openai.py +1089 -0
  135. coco_core/messages/chat.py +64 -0
  136. coco_core/messages/content.py +1488 -0
  137. coco_core/messages/function.py +62 -0
  138. coco_core/messages/human.py +70 -0
  139. coco_core/messages/modifier.py +33 -0
  140. coco_core/messages/system.py +70 -0
  141. coco_core/messages/tool.py +418 -0
  142. coco_core/messages/utils.py +2442 -0
  143. coco_core/output_parsers/__init__.py +101 -0
  144. coco_core/output_parsers/base.py +361 -0
  145. coco_core/output_parsers/format_instructions.py +16 -0
  146. coco_core/output_parsers/json.py +139 -0
  147. coco_core/output_parsers/list.py +249 -0
  148. coco_core/output_parsers/openai_functions.py +324 -0
  149. coco_core/output_parsers/openai_tools.py +383 -0
  150. coco_core/output_parsers/pydantic.py +141 -0
  151. coco_core/output_parsers/string.py +63 -0
  152. coco_core/output_parsers/transform.py +175 -0
  153. coco_core/output_parsers/xml.py +301 -0
  154. coco_core/outputs/__init__.py +66 -0
  155. coco_core/outputs/chat_generation.py +147 -0
  156. coco_core/outputs/chat_result.py +39 -0
  157. coco_core/outputs/generation.py +80 -0
  158. coco_core/outputs/llm_result.py +112 -0
  159. coco_core/outputs/run_info.py +22 -0
  160. coco_core/prompt_values.py +161 -0
  161. coco_core/prompts/__init__.py +101 -0
  162. coco_core/prompts/base.py +508 -0
  163. coco_core/prompts/chat.py +1495 -0
  164. coco_core/prompts/dict.py +177 -0
  165. coco_core/prompts/few_shot.py +483 -0
  166. coco_core/prompts/few_shot_with_templates.py +239 -0
  167. coco_core/prompts/image.py +177 -0
  168. coco_core/prompts/loading.py +296 -0
  169. coco_core/prompts/message.py +97 -0
  170. coco_core/prompts/prompt.py +312 -0
  171. coco_core/prompts/string.py +399 -0
  172. coco_core/prompts/structured.py +214 -0
  173. coco_core/py.typed +0 -0
  174. coco_core/rate_limiters.py +254 -0
  175. coco_core/retrievers.py +328 -0
  176. coco_core/runnables/__init__.py +136 -0
  177. coco_core/runnables/base.py +6725 -0
  178. coco_core/runnables/branch.py +457 -0
  179. coco_core/runnables/config.py +712 -0
  180. coco_core/runnables/configurable.py +710 -0
  181. coco_core/runnables/fallbacks.py +663 -0
  182. coco_core/runnables/graph.py +741 -0
  183. coco_core/runnables/graph_ascii.py +366 -0
  184. coco_core/runnables/graph_mermaid.py +503 -0
  185. coco_core/runnables/graph_png.py +215 -0
  186. coco_core/runnables/history.py +631 -0
  187. coco_core/runnables/passthrough.py +849 -0
  188. coco_core/runnables/retry.py +379 -0
  189. coco_core/runnables/router.py +239 -0
  190. coco_core/runnables/schema.py +188 -0
  191. coco_core/runnables/utils.py +798 -0
  192. coco_core/stores.py +291 -0
  193. coco_core/structured_query.py +203 -0
  194. coco_core/sys_info.py +154 -0
  195. coco_core/tools/__init__.py +95 -0
  196. coco_core/tools/base.py +1948 -0
  197. coco_core/tools/convert.py +497 -0
  198. coco_core/tools/render.py +67 -0
  199. coco_core/tools/retriever.py +94 -0
  200. coco_core/tools/simple.py +208 -0
  201. coco_core/tools/structured.py +322 -0
  202. coco_core/tracers/__init__.py +47 -0
  203. coco_core/tracers/_compat.py +95 -0
  204. coco_core/tracers/_context.py +337 -0
  205. coco_core/tracers/_streaming.py +53 -0
  206. coco_core/tracers/base.py +955 -0
  207. coco_core/tracers/coco.py +279 -0
  208. coco_core/tracers/context.py +205 -0
  209. coco_core/tracers/core.py +766 -0
  210. coco_core/tracers/event_stream.py +1101 -0
  211. coco_core/tracers/log_stream.py +765 -0
  212. coco_core/tracers/memory_stream.py +200 -0
  213. coco_core/tracers/root_listeners.py +130 -0
  214. coco_core/tracers/run_collector.py +40 -0
  215. coco_core/tracers/schemas.py +14 -0
  216. coco_core/tracers/stdout.py +205 -0
  217. coco_core/utils/__init__.py +111 -0
  218. coco_core/utils/_gateway.py +274 -0
  219. coco_core/utils/_merge.py +218 -0
  220. coco_core/utils/aiter.py +357 -0
  221. coco_core/utils/env.py +86 -0
  222. coco_core/utils/formatting.py +80 -0
  223. coco_core/utils/function_calling.py +869 -0
  224. coco_core/utils/html.py +132 -0
  225. coco_core/utils/image.py +15 -0
  226. coco_core/utils/input.py +82 -0
  227. coco_core/utils/interactive_env.py +12 -0
  228. coco_core/utils/iter.py +229 -0
  229. coco_core/utils/json.py +230 -0
  230. coco_core/utils/json_schema.py +273 -0
  231. coco_core/utils/mustache.py +706 -0
  232. coco_core/utils/pydantic.py +631 -0
  233. coco_core/utils/strings.py +69 -0
  234. coco_core/utils/usage.py +61 -0
  235. coco_core/utils/utils.py +528 -0
  236. coco_core/utils/uuid.py +57 -0
  237. coco_core/vectorstores/__init__.py +53 -0
  238. coco_core/vectorstores/base.py +1111 -0
  239. coco_core/vectorstores/in_memory.py +546 -0
  240. coco_core/vectorstores/utils.py +163 -0
  241. coco_core/version.py +3 -0
  242. coco_framework-0.1.0.dist-info/METADATA +184 -0
  243. coco_framework-0.1.0.dist-info/RECORD +400 -0
  244. coco_framework-0.1.0.dist-info/WHEEL +4 -0
  245. coco_framework-0.1.0.dist-info/licenses/LICENSE +24 -0
  246. coco_framework-0.1.0.dist-info/licenses/NOTICE +15 -0
  247. coco_graph/_internal/__init__.py +4 -0
  248. coco_graph/_internal/_cache.py +31 -0
  249. coco_graph/_internal/_config.py +474 -0
  250. coco_graph/_internal/_constants.py +140 -0
  251. coco_graph/_internal/_fields.py +213 -0
  252. coco_graph/_internal/_future.py +220 -0
  253. coco_graph/_internal/_pydantic.py +275 -0
  254. coco_graph/_internal/_queue.py +124 -0
  255. coco_graph/_internal/_replay.py +90 -0
  256. coco_graph/_internal/_retry.py +29 -0
  257. coco_graph/_internal/_runnable.py +969 -0
  258. coco_graph/_internal/_scratchpad.py +19 -0
  259. coco_graph/_internal/_serde.py +253 -0
  260. coco_graph/_internal/_timeout.py +25 -0
  261. coco_graph/_internal/_typing.py +54 -0
  262. coco_graph/cache/base/__init__.py +48 -0
  263. coco_graph/cache/base/py.typed +0 -0
  264. coco_graph/cache/memory/__init__.py +73 -0
  265. coco_graph/cache/redis/__init__.py +144 -0
  266. coco_graph/cache/sqlite/__init__.py +120 -0
  267. coco_graph/callbacks.py +394 -0
  268. coco_graph/channels/__init__.py +29 -0
  269. coco_graph/channels/any_value.py +72 -0
  270. coco_graph/channels/base.py +121 -0
  271. coco_graph/channels/binop.py +155 -0
  272. coco_graph/channels/delta.py +202 -0
  273. coco_graph/channels/ephemeral_value.py +79 -0
  274. coco_graph/channels/last_value.py +151 -0
  275. coco_graph/channels/named_barrier_value.py +167 -0
  276. coco_graph/channels/topic.py +94 -0
  277. coco_graph/channels/untracked_value.py +73 -0
  278. coco_graph/checkpoint/base/__init__.py +861 -0
  279. coco_graph/checkpoint/base/id.py +109 -0
  280. coco_graph/checkpoint/base/py.typed +0 -0
  281. coco_graph/checkpoint/memory/__init__.py +698 -0
  282. coco_graph/checkpoint/memory/py.typed +0 -0
  283. coco_graph/checkpoint/postgres/__init__.py +602 -0
  284. coco_graph/checkpoint/postgres/_ainternal.py +23 -0
  285. coco_graph/checkpoint/postgres/_internal.py +21 -0
  286. coco_graph/checkpoint/postgres/aio.py +691 -0
  287. coco_graph/checkpoint/postgres/base.py +666 -0
  288. coco_graph/checkpoint/postgres/py.typed +0 -0
  289. coco_graph/checkpoint/postgres/shallow.py +967 -0
  290. coco_graph/checkpoint/serde/__init__.py +0 -0
  291. coco_graph/checkpoint/serde/_msgpack.py +97 -0
  292. coco_graph/checkpoint/serde/base.py +64 -0
  293. coco_graph/checkpoint/serde/encrypted.py +80 -0
  294. coco_graph/checkpoint/serde/event_hooks.py +52 -0
  295. coco_graph/checkpoint/serde/jsonplus.py +883 -0
  296. coco_graph/checkpoint/serde/py.typed +0 -0
  297. coco_graph/checkpoint/serde/types.py +68 -0
  298. coco_graph/checkpoint/sqlite/__init__.py +645 -0
  299. coco_graph/checkpoint/sqlite/_delta.py +172 -0
  300. coco_graph/checkpoint/sqlite/aio.py +738 -0
  301. coco_graph/checkpoint/sqlite/py.typed +0 -0
  302. coco_graph/checkpoint/sqlite/utils.py +116 -0
  303. coco_graph/config.py +196 -0
  304. coco_graph/constants.py +64 -0
  305. coco_graph/errors.py +241 -0
  306. coco_graph/func/__init__.py +620 -0
  307. coco_graph/graph/__init__.py +12 -0
  308. coco_graph/graph/_branch.py +225 -0
  309. coco_graph/graph/_node.py +103 -0
  310. coco_graph/graph/message.py +437 -0
  311. coco_graph/graph/state.py +1978 -0
  312. coco_graph/graph/ui.py +227 -0
  313. coco_graph/managed/__init__.py +3 -0
  314. coco_graph/managed/base.py +31 -0
  315. coco_graph/managed/is_last_step.py +24 -0
  316. coco_graph/prebuilt/__init__.py +23 -0
  317. coco_graph/prebuilt/_tool_call_stream.py +117 -0
  318. coco_graph/prebuilt/_tool_call_transformer.py +165 -0
  319. coco_graph/prebuilt/chat_agent_executor.py +1015 -0
  320. coco_graph/prebuilt/interrupt.py +105 -0
  321. coco_graph/prebuilt/py.typed +0 -0
  322. coco_graph/prebuilt/tool_node.py +2030 -0
  323. coco_graph/prebuilt/tool_validator.py +221 -0
  324. coco_graph/pregel/__init__.py +3 -0
  325. coco_graph/pregel/_algo.py +1460 -0
  326. coco_graph/pregel/_call.py +298 -0
  327. coco_graph/pregel/_checkpoint.py +331 -0
  328. coco_graph/pregel/_config.py +0 -0
  329. coco_graph/pregel/_draw.py +294 -0
  330. coco_graph/pregel/_executor.py +223 -0
  331. coco_graph/pregel/_io.py +174 -0
  332. coco_graph/pregel/_log.py +3 -0
  333. coco_graph/pregel/_loop.py +1988 -0
  334. coco_graph/pregel/_messages.py +461 -0
  335. coco_graph/pregel/_read.py +312 -0
  336. coco_graph/pregel/_retry.py +854 -0
  337. coco_graph/pregel/_runner.py +941 -0
  338. coco_graph/pregel/_tools.py +268 -0
  339. coco_graph/pregel/_utils.py +221 -0
  340. coco_graph/pregel/_validate.py +120 -0
  341. coco_graph/pregel/_write.py +192 -0
  342. coco_graph/pregel/debug.py +302 -0
  343. coco_graph/pregel/main.py +4364 -0
  344. coco_graph/pregel/protocol.py +288 -0
  345. coco_graph/pregel/types.py +38 -0
  346. coco_graph/py.typed +0 -0
  347. coco_graph/runtime.py +350 -0
  348. coco_graph/store/base/__init__.py +1322 -0
  349. coco_graph/store/base/batch.py +371 -0
  350. coco_graph/store/base/embed.py +436 -0
  351. coco_graph/store/base/py.typed +0 -0
  352. coco_graph/store/memory/__init__.py +592 -0
  353. coco_graph/store/memory/py.typed +0 -0
  354. coco_graph/store/postgres/__init__.py +4 -0
  355. coco_graph/store/postgres/aio.py +592 -0
  356. coco_graph/store/postgres/base.py +1480 -0
  357. coco_graph/store/postgres/py.typed +0 -0
  358. coco_graph/store/sqlite/__init__.py +4 -0
  359. coco_graph/store/sqlite/aio.py +632 -0
  360. coco_graph/store/sqlite/base.py +1514 -0
  361. coco_graph/stream/__init__.py +45 -0
  362. coco_graph/stream/_convert.py +32 -0
  363. coco_graph/stream/_mux.py +523 -0
  364. coco_graph/stream/_types.py +330 -0
  365. coco_graph/stream/run_stream.py +735 -0
  366. coco_graph/stream/stream_channel.py +341 -0
  367. coco_graph/stream/transformers.py +1039 -0
  368. coco_graph/types.py +1079 -0
  369. coco_graph/typing.py +48 -0
  370. coco_graph/utils/__init__.py +1 -0
  371. coco_graph/utils/config.py +4 -0
  372. coco_graph/utils/runnable.py +3 -0
  373. coco_graph/version.py +13 -0
  374. coco_graph/warnings.py +69 -0
  375. coco_openai/__init__.py +20 -0
  376. coco_openai/_compat.py +45 -0
  377. coco_openai/_version.py +3 -0
  378. coco_openai/chat_models/__init__.py +6 -0
  379. coco_openai/chat_models/_client_utils.py +684 -0
  380. coco_openai/chat_models/_compat.py +525 -0
  381. coco_openai/chat_models/azure.py +1227 -0
  382. coco_openai/chat_models/base.py +5654 -0
  383. coco_openai/chat_models/codex.py +557 -0
  384. coco_openai/chatgpt_oauth.py +1063 -0
  385. coco_openai/data/__init__.py +1 -0
  386. coco_openai/data/_profiles.py +1655 -0
  387. coco_openai/data/profile_augmentations.toml +127 -0
  388. coco_openai/embeddings/__init__.py +6 -0
  389. coco_openai/embeddings/azure.py +240 -0
  390. coco_openai/embeddings/base.py +820 -0
  391. coco_openai/llms/__init__.py +6 -0
  392. coco_openai/llms/azure.py +249 -0
  393. coco_openai/llms/base.py +919 -0
  394. coco_openai/middleware/__init__.py +11 -0
  395. coco_openai/middleware/openai_moderation.py +484 -0
  396. coco_openai/output_parsers/__init__.py +9 -0
  397. coco_openai/output_parsers/tools.py +9 -0
  398. coco_openai/py.typed +0 -0
  399. coco_openai/tools/__init__.py +5 -0
  400. coco_openai/tools/custom_tool.py +111 -0
@@ -0,0 +1,1988 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import binascii
5
+ import concurrent.futures
6
+ from collections import defaultdict, deque
7
+ from collections.abc import Callable, Iterator, Mapping, Sequence
8
+ from contextlib import (
9
+ AbstractAsyncContextManager,
10
+ AbstractContextManager,
11
+ AsyncExitStack,
12
+ ExitStack,
13
+ )
14
+ from datetime import datetime, timezone
15
+ from inspect import signature
16
+ from types import TracebackType
17
+ from typing import (
18
+ Any,
19
+ Literal,
20
+ TypeVar,
21
+ cast,
22
+ )
23
+
24
+ from coco_core.callbacks import AsyncParentRunManager, ParentRunManager
25
+ from coco_core.runnables import RunnableConfig
26
+ from coco_graph.cache.base import BaseCache
27
+ from coco_graph.checkpoint.base import (
28
+ WRITES_IDX_MAP,
29
+ BaseCheckpointSaver,
30
+ ChannelVersions,
31
+ Checkpoint,
32
+ CheckpointMetadata,
33
+ CheckpointTuple,
34
+ PendingWrite,
35
+ )
36
+ from coco_graph.store.base import BaseStore
37
+ from typing_extensions import ParamSpec, Self
38
+
39
+ from coco_graph._internal._config import patch_configurable
40
+ from coco_graph._internal._constants import (
41
+ CONF,
42
+ CONFIG_KEY_CHECKPOINT_ID,
43
+ CONFIG_KEY_CHECKPOINT_MAP,
44
+ CONFIG_KEY_CHECKPOINT_NS,
45
+ CONFIG_KEY_REPLAY_STATE,
46
+ CONFIG_KEY_RESUME_MAP,
47
+ CONFIG_KEY_RESUMING,
48
+ CONFIG_KEY_RUNTIME,
49
+ CONFIG_KEY_SCRATCHPAD,
50
+ CONFIG_KEY_STREAM,
51
+ CONFIG_KEY_TASK_ID,
52
+ CONFIG_KEY_THREAD_ID,
53
+ ERROR,
54
+ ERROR_SOURCE_NODE,
55
+ INPUT,
56
+ INTERRUPT,
57
+ NS_END,
58
+ NS_SEP,
59
+ NULL_TASK_ID,
60
+ PUSH,
61
+ RESUME,
62
+ TASKS,
63
+ )
64
+ from coco_graph._internal._replay import ReplayState
65
+ from coco_graph._internal._scratchpad import PregelScratchpad
66
+ from coco_graph._internal._typing import EMPTY_SEQ, MISSING
67
+ from coco_graph.callbacks import (
68
+ GraphInterruptEvent,
69
+ GraphLifecycleEvent,
70
+ GraphResumeEvent,
71
+ )
72
+ from coco_graph.channels.base import BaseChannel
73
+ from coco_graph.channels.binop import _get_overwrite
74
+ from coco_graph.channels.delta import DeltaChannel
75
+ from coco_graph.channels.untracked_value import UntrackedValue
76
+ from coco_graph.constants import TAG_HIDDEN
77
+ from coco_graph.errors import (
78
+ EmptyInputError,
79
+ GraphInterrupt,
80
+ )
81
+ from coco_graph.managed.base import (
82
+ ManagedValueMapping,
83
+ ManagedValueSpec,
84
+ )
85
+ from coco_graph.pregel._algo import (
86
+ Call,
87
+ GetNextVersion,
88
+ PregelTaskWrites,
89
+ apply_writes,
90
+ checkpoint_null_version,
91
+ increment,
92
+ prepare_next_tasks,
93
+ prepare_node_error_handler_task,
94
+ prepare_single_task,
95
+ sanitize_untracked_values_in_send,
96
+ should_interrupt,
97
+ task_path_str,
98
+ )
99
+ from coco_graph.pregel._checkpoint import (
100
+ achannels_from_checkpoint,
101
+ channels_from_checkpoint,
102
+ copy_checkpoint,
103
+ create_checkpoint,
104
+ delta_channels_to_snapshot,
105
+ empty_checkpoint,
106
+ exit_delta_task_id,
107
+ )
108
+ from coco_graph.pregel._executor import (
109
+ AsyncBackgroundExecutor,
110
+ BackgroundExecutor,
111
+ Submit,
112
+ )
113
+ from coco_graph.pregel._io import (
114
+ map_command,
115
+ map_input,
116
+ map_output_updates,
117
+ map_output_values,
118
+ read_channels,
119
+ )
120
+ from coco_graph.pregel._messages import ensure_message_ids
121
+ from coco_graph.pregel._read import PregelNode
122
+ from coco_graph.pregel._utils import get_new_channel_versions, is_xxh3_128_hexdigest
123
+ from coco_graph.pregel.debug import (
124
+ map_debug_checkpoint,
125
+ map_debug_task_results,
126
+ map_debug_tasks,
127
+ )
128
+ from coco_graph.pregel.protocol import StreamChunk, StreamProtocol
129
+ from coco_graph.runtime import RunControl, Runtime
130
+ from coco_graph.types import (
131
+ All,
132
+ CachePolicy,
133
+ Command,
134
+ Durability,
135
+ Interrupt,
136
+ PregelExecutableTask,
137
+ RetryPolicy,
138
+ Send,
139
+ StreamMode,
140
+ )
141
+
142
+ V = TypeVar("V")
143
+ P = ParamSpec("P")
144
+
145
+
146
+ WritesT = Sequence[tuple[str, Any]]
147
+
148
+
149
+ def DuplexStream(*streams: StreamProtocol) -> StreamProtocol:
150
+ def __call__(value: StreamChunk) -> None:
151
+ for stream in streams:
152
+ if value[1] in stream.modes:
153
+ stream(value)
154
+
155
+ return StreamProtocol(__call__, {mode for s in streams for mode in s.modes})
156
+
157
+
158
+ class PregelLoop:
159
+ config: RunnableConfig
160
+ store: BaseStore | None
161
+ stream: StreamProtocol | None
162
+ step: int
163
+ stop: int
164
+
165
+ input: Any | None
166
+ cache: BaseCache[WritesT] | None
167
+ checkpointer: BaseCheckpointSaver | None
168
+ nodes: Mapping[str, PregelNode]
169
+ specs: Mapping[str, BaseChannel | ManagedValueSpec]
170
+ input_keys: str | Sequence[str]
171
+ output_keys: str | Sequence[str]
172
+ stream_keys: str | Sequence[str]
173
+ is_replaying: bool
174
+ is_nested: bool
175
+ manager: None | AsyncParentRunManager | ParentRunManager
176
+ interrupt_after: All | Sequence[str]
177
+ interrupt_before: All | Sequence[str]
178
+ durability: Durability
179
+ retry_policy: Sequence[RetryPolicy]
180
+ cache_policy: CachePolicy | None
181
+
182
+ checkpointer_get_next_version: GetNextVersion
183
+ checkpointer_put_writes: Callable[[RunnableConfig, WritesT, str], Any] | None
184
+ checkpointer_put_writes_accepts_task_path: bool
185
+ _checkpointer_put_after_previous: (
186
+ Callable[
187
+ [
188
+ concurrent.futures.Future | None,
189
+ RunnableConfig,
190
+ Checkpoint,
191
+ str,
192
+ ChannelVersions,
193
+ ],
194
+ Any,
195
+ ]
196
+ | None
197
+ )
198
+ _migrate_checkpoint: Callable[[Checkpoint], None] | None
199
+ submit: Submit
200
+ channels: Mapping[str, BaseChannel]
201
+ # Futures from `checkpointer.put_writes` calls that produced delta-channel
202
+ # writes. `_checkpointer_put_after_previous` drains this list (swap to a
203
+ # local `futs` then reset to `[]` and wait/gather) before putting the
204
+ # next checkpoint, so a checkpoint never becomes durable before the
205
+ # writes that produced it. Initialised to `[]` in both sync and async
206
+ # `__enter__`; stays `None` only when no checkpointer.
207
+ _delta_write_futs: list[Any] | None = None
208
+
209
+ # Same pattern as `_delta_write_futs` but for error-handler writes.
210
+ # When `put_writes` persists an ERROR_SOURCE_NODE marker, the future is
211
+ # appended here. `schedule_error_handler` / `aschedule_error_handler`
212
+ # drain this list so the write is durable before the handler starts.
213
+ _error_handler_write_futs: list[Any] | None = None
214
+
215
+ # Exit-mode accumulator: every delta-channel write produced during this
216
+ # run (input writes from `_first` + per-superstep writes captured in
217
+ # `after_tick`). At exit, `_put_exit_delta_writes` filters out channels
218
+ # that will snapshot, then persists the rest under an anchor parent.
219
+ # `None` when not in exit mode (so the capture sites are no-ops).
220
+ # Each tuple is `(step, task_id, channel, value)` — `step` drives the
221
+ # synthetic step-prefixed task_id used to preserve chronological order
222
+ # under the saver's `ORDER BY task_id, idx` sorting.
223
+ _exit_delta_writes: list[tuple[int, str, str, Any]] | None = None
224
+
225
+ # Delta channels that saw an Overwrite since the last checkpoint. These
226
+ # channels must snapshot after live update applies overwrite semantics so
227
+ # sparse replay starts from the same post-overwrite value.
228
+ _delta_channels_with_overwrite: set[str]
229
+
230
+ # The checkpoint_config that points at the parent loaded at `__enter__`
231
+ # (or the synthetic-empty checkpoint, on first run). We capture it
232
+ # eagerly because every `_put_checkpoint` advances `self.checkpoint_config`
233
+ # to the newly-saved checkpoint's id — by exit time the original parent
234
+ # config would otherwise be lost. `_put_exit_delta_writes` uses this:
235
+ # on resumed runs as the anchor for exit delta writes; on first runs
236
+ # to derive the lazy stub's config (its `checkpoint_id` is the
237
+ # synthetic-empty id we want the stub persisted under).
238
+ _initial_checkpoint_config: RunnableConfig
239
+
240
+ # True iff the saver actually returned a tuple at `__enter__`. False
241
+ # on the first-ever run for a thread (no parent persisted yet).
242
+ # `_put_exit_delta_writes` uses this to decide between anchoring on
243
+ # the existing parent (True) or creating a lazy stub (False).
244
+ _has_persisted_parent: bool = False
245
+
246
+ managed: ManagedValueMapping
247
+ checkpoint: Checkpoint
248
+ checkpoint_id_saved: str
249
+ checkpoint_ns: tuple[str, ...]
250
+ checkpoint_config: RunnableConfig
251
+ checkpoint_metadata: CheckpointMetadata
252
+ checkpoint_pending_writes: list[PendingWrite]
253
+ checkpoint_previous_versions: dict[str, str | float | int]
254
+ prev_checkpoint_config: RunnableConfig | None
255
+
256
+ status: Literal[
257
+ "input",
258
+ "pending",
259
+ "done",
260
+ "draining",
261
+ "interrupt_before",
262
+ "interrupt_after",
263
+ "out_of_steps",
264
+ ]
265
+ control: RunControl | None
266
+ tasks: dict[str, PregelExecutableTask]
267
+ output: None | dict[str, Any] | Any = None
268
+ updated_channels: set[str] | None = None
269
+ _graph_lifecycle_events: deque[GraphLifecycleEvent]
270
+ _has_graph_lifecycle_callbacks: bool
271
+
272
+ # public
273
+
274
+ def __init__(
275
+ self,
276
+ input: Any | None,
277
+ *,
278
+ stream: StreamProtocol | None,
279
+ config: RunnableConfig,
280
+ store: BaseStore | None,
281
+ cache: BaseCache | None,
282
+ checkpointer: BaseCheckpointSaver | None,
283
+ nodes: Mapping[str, PregelNode],
284
+ specs: Mapping[str, BaseChannel | ManagedValueSpec],
285
+ input_keys: str | Sequence[str],
286
+ output_keys: str | Sequence[str],
287
+ stream_keys: str | Sequence[str],
288
+ trigger_to_nodes: Mapping[str, Sequence[str]],
289
+ durability: Durability,
290
+ interrupt_after: All | Sequence[str] = EMPTY_SEQ,
291
+ interrupt_before: All | Sequence[str] = EMPTY_SEQ,
292
+ manager: None | AsyncParentRunManager | ParentRunManager = None,
293
+ migrate_checkpoint: Callable[[Checkpoint], None] | None = None,
294
+ retry_policy: Sequence[RetryPolicy] = (),
295
+ cache_policy: CachePolicy | None = None,
296
+ has_graph_lifecycle_callbacks: bool = False,
297
+ ) -> None:
298
+ self.stream = stream
299
+ self.config = config
300
+ self.store = store
301
+ self.step = 0
302
+ self.stop = 0
303
+ self.input = input
304
+ self.checkpointer = checkpointer
305
+ self.cache = cache
306
+ self.nodes = nodes
307
+ self.specs = specs
308
+ self.input_keys = input_keys
309
+ self.output_keys = output_keys
310
+ self.stream_keys = stream_keys
311
+ self.interrupt_after = interrupt_after
312
+ self.interrupt_before = interrupt_before
313
+ self.manager = manager
314
+ self.is_nested = CONFIG_KEY_TASK_ID in self.config.get(CONF, {})
315
+ self.is_replaying = CONFIG_KEY_CHECKPOINT_ID in config[CONF]
316
+ self._migrate_checkpoint = migrate_checkpoint
317
+ self.trigger_to_nodes = trigger_to_nodes
318
+ self.retry_policy = retry_policy
319
+ self.cache_policy = cache_policy
320
+ self.durability = durability
321
+ self._has_graph_lifecycle_callbacks = has_graph_lifecycle_callbacks
322
+ self._graph_lifecycle_events = deque()
323
+ if self.stream is not None and CONFIG_KEY_STREAM in config[CONF]:
324
+ self.stream = DuplexStream(self.stream, config[CONF][CONFIG_KEY_STREAM])
325
+ scratchpad: PregelScratchpad | None = config[CONF].get(CONFIG_KEY_SCRATCHPAD)
326
+ if isinstance(scratchpad, PregelScratchpad):
327
+ # if count is > 0, append to checkpoint_ns
328
+ # if count is 0, leave as is
329
+ if cnt := scratchpad.subgraph_counter():
330
+ self.config = patch_configurable(
331
+ self.config,
332
+ {
333
+ CONFIG_KEY_CHECKPOINT_NS: NS_SEP.join(
334
+ (
335
+ config[CONF][CONFIG_KEY_CHECKPOINT_NS],
336
+ str(cnt),
337
+ )
338
+ )
339
+ },
340
+ )
341
+ if not self.is_nested and config[CONF].get(CONFIG_KEY_CHECKPOINT_NS):
342
+ self.config = patch_configurable(
343
+ self.config,
344
+ {CONFIG_KEY_CHECKPOINT_NS: "", CONFIG_KEY_CHECKPOINT_ID: None},
345
+ )
346
+ if (
347
+ CONFIG_KEY_CHECKPOINT_MAP in self.config[CONF]
348
+ and self.config[CONF].get(CONFIG_KEY_CHECKPOINT_NS)
349
+ in self.config[CONF][CONFIG_KEY_CHECKPOINT_MAP]
350
+ ):
351
+ self.checkpoint_config = patch_configurable(
352
+ self.config,
353
+ {
354
+ CONFIG_KEY_CHECKPOINT_ID: self.config[CONF][
355
+ CONFIG_KEY_CHECKPOINT_MAP
356
+ ][self.config[CONF][CONFIG_KEY_CHECKPOINT_NS]]
357
+ },
358
+ )
359
+ else:
360
+ self.checkpoint_config = self.config
361
+ if thread_id := self.checkpoint_config[CONF].get(CONFIG_KEY_THREAD_ID):
362
+ if not isinstance(thread_id, str):
363
+ self.checkpoint_config = patch_configurable(
364
+ self.checkpoint_config,
365
+ {CONFIG_KEY_THREAD_ID: str(thread_id)},
366
+ )
367
+ self.checkpoint_ns = (
368
+ tuple(cast(str, self.config[CONF][CONFIG_KEY_CHECKPOINT_NS]).split(NS_SEP))
369
+ if self.config[CONF].get(CONFIG_KEY_CHECKPOINT_NS)
370
+ else ()
371
+ )
372
+ self.prev_checkpoint_config = None
373
+ runtime = self.config[CONF].get(CONFIG_KEY_RUNTIME)
374
+ self.control = runtime.control if isinstance(runtime, Runtime) else None
375
+
376
+ def _push_graph_lifecycle_event(
377
+ self,
378
+ kind: Literal["resume", "interrupt"],
379
+ *,
380
+ interrupts: tuple[Interrupt, ...] = (),
381
+ ) -> None:
382
+ # drain status never reaches lifecycle events: tick() returns False
383
+ # before pushing, and interrupts are raised through GraphInterrupt
384
+ if self.status == "draining":
385
+ raise RuntimeError("Draining status cannot emit lifecycle events")
386
+ status = self.status
387
+ if kind == "resume":
388
+ self._graph_lifecycle_events.append(
389
+ GraphResumeEvent(
390
+ run_id=None,
391
+ status=status,
392
+ checkpoint_id=self.checkpoint["id"],
393
+ checkpoint_ns=self.checkpoint_ns,
394
+ )
395
+ )
396
+ elif kind == "interrupt":
397
+ self._graph_lifecycle_events.append(
398
+ GraphInterruptEvent(
399
+ run_id=None,
400
+ status=status,
401
+ checkpoint_id=self.checkpoint["id"],
402
+ checkpoint_ns=self.checkpoint_ns,
403
+ interrupts=interrupts,
404
+ )
405
+ )
406
+ else:
407
+ msg = f"Unknown graph lifecycle event type: {kind}"
408
+ raise AssertionError(msg)
409
+
410
+ def _pop_lifecycle_event(self) -> GraphLifecycleEvent | None:
411
+ if not self._graph_lifecycle_events:
412
+ return None
413
+ return self._graph_lifecycle_events.popleft()
414
+
415
+ def put_writes(self, task_id: str, writes: WritesT) -> None:
416
+ """Put writes for a task, to be read by the next tick."""
417
+ if not writes:
418
+ return
419
+ # deduplicate writes to special channels, last write wins
420
+ if all(w[0] in WRITES_IDX_MAP for w in writes):
421
+ writes = list({w[0]: w for w in writes}.values())
422
+ if task_id == NULL_TASK_ID:
423
+ # writes for the null task are accumulated
424
+ self.checkpoint_pending_writes = [
425
+ w
426
+ for w in self.checkpoint_pending_writes
427
+ if w[0] != task_id or w[1] not in WRITES_IDX_MAP
428
+ ]
429
+ writes_to_save: WritesT = [
430
+ w[1:] for w in self.checkpoint_pending_writes if w[0] == task_id
431
+ ] + list(writes)
432
+ else:
433
+ # remove existing writes for this task
434
+ self.checkpoint_pending_writes = [
435
+ w for w in self.checkpoint_pending_writes if w[0] != task_id
436
+ ]
437
+ writes_to_save = writes
438
+
439
+ # check if any writes are to an UntrackedValue channel
440
+ if any(
441
+ isinstance(channel, UntrackedValue) for channel in self.channels.values()
442
+ ):
443
+ # we do not persist untracked values in checkpoints
444
+ writes_to_save = [
445
+ # sanitize UntrackedValues that are nested within Send packets
446
+ (
447
+ (c, sanitize_untracked_values_in_send(v, self.channels))
448
+ if c == TASKS and isinstance(v, Send)
449
+ else (c, v)
450
+ )
451
+ for c, v in writes_to_save
452
+ # dont persist UntrackedValue channel writes
453
+ if not isinstance(self.specs.get(c), UntrackedValue)
454
+ ]
455
+
456
+ # save writes
457
+ self.checkpoint_pending_writes.extend((task_id, c, v) for c, v in writes)
458
+ # Assign stable IDs to any id=None BaseMessages in DeltaChannel writes
459
+ # before the background thread serialises them. Without this, reducers
460
+ # that assign IDs inside apply_writes() race with serialisation and
461
+ # store id=None, causing get_state() replays to produce a different UUID
462
+ # on every call.
463
+ for c, v in writes_to_save:
464
+ if isinstance(self.specs.get(c), DeltaChannel):
465
+ ensure_message_ids(v)
466
+ if self.durability != "exit" and self.checkpointer_put_writes is not None:
467
+ config = patch_configurable(
468
+ self.checkpoint_config,
469
+ {
470
+ CONFIG_KEY_CHECKPOINT_NS: self.config[CONF].get(
471
+ CONFIG_KEY_CHECKPOINT_NS, ""
472
+ ),
473
+ CONFIG_KEY_CHECKPOINT_ID: self.checkpoint["id"],
474
+ },
475
+ )
476
+ if self.checkpointer_put_writes_accepts_task_path:
477
+ if hasattr(self, "tasks"):
478
+ task = self.tasks.get(task_id)
479
+ else:
480
+ task = None
481
+ fut = self.submit(
482
+ self.checkpointer_put_writes,
483
+ config,
484
+ writes_to_save,
485
+ task_id,
486
+ task_path_str(task.path) if task else "",
487
+ )
488
+ else:
489
+ fut = self.submit(
490
+ self.checkpointer_put_writes,
491
+ config,
492
+ writes_to_save,
493
+ task_id,
494
+ )
495
+ if self._delta_write_futs is not None and any(
496
+ isinstance(self.specs.get(c), DeltaChannel) for c, _ in writes_to_save
497
+ ):
498
+ self._delta_write_futs.append(fut)
499
+ # ERROR_SOURCE_NODE is only appended by commit() when the task
500
+ # has an error handler (_should_route_to_error_handler), so this
501
+ # check naturally limits future collection to those tasks.
502
+ if self._error_handler_write_futs is not None and any(
503
+ c == ERROR_SOURCE_NODE for c, _ in writes
504
+ ):
505
+ self._error_handler_write_futs.append(fut)
506
+ # output writes
507
+ if hasattr(self, "tasks"):
508
+ self.output_writes(task_id, writes)
509
+
510
+ def _put_pending_writes(self) -> None:
511
+ if self.checkpointer_put_writes is None:
512
+ return
513
+ if not self.checkpoint_pending_writes:
514
+ return
515
+ # patch config
516
+ config = patch_configurable(
517
+ self.checkpoint_config,
518
+ {
519
+ CONFIG_KEY_CHECKPOINT_NS: self.config[CONF].get(
520
+ CONFIG_KEY_CHECKPOINT_NS, ""
521
+ ),
522
+ CONFIG_KEY_CHECKPOINT_ID: self.checkpoint["id"],
523
+ },
524
+ )
525
+ # group by task id
526
+ by_task = defaultdict(list)
527
+ for task_id, channel, value in self.checkpoint_pending_writes:
528
+ by_task[task_id].append((channel, value))
529
+ # submit writes to checkpointer
530
+ for task_id, writes in by_task.items():
531
+ if self.checkpointer_put_writes_accepts_task_path and hasattr(
532
+ self, "tasks"
533
+ ):
534
+ task = self.tasks.get(task_id)
535
+ self.submit(
536
+ self.checkpointer_put_writes,
537
+ config,
538
+ writes,
539
+ task_id,
540
+ task_path_str(task.path) if task else "",
541
+ )
542
+ else:
543
+ self.submit(
544
+ self.checkpointer_put_writes,
545
+ config,
546
+ writes,
547
+ task_id,
548
+ )
549
+
550
+ def accept_push(
551
+ self, task: PregelExecutableTask, write_idx: int, call: Call | None = None
552
+ ) -> PregelExecutableTask | None:
553
+ """Accept a PUSH from a task, potentially returning a new task to start."""
554
+ checkpoint_id_bytes = binascii.unhexlify(self.checkpoint["id"].replace("-", ""))
555
+ null_version = checkpoint_null_version(self.checkpoint)
556
+ if pushed := cast(
557
+ PregelExecutableTask | None,
558
+ prepare_single_task(
559
+ (PUSH, task.path, write_idx, task.id, call),
560
+ None,
561
+ checkpoint=self.checkpoint,
562
+ checkpoint_id_bytes=checkpoint_id_bytes,
563
+ checkpoint_null_version=null_version,
564
+ pending_writes=self.checkpoint_pending_writes,
565
+ processes=self.nodes,
566
+ channels=self.channels,
567
+ managed=self.managed,
568
+ config=task.config,
569
+ step=self.step,
570
+ stop=self.stop,
571
+ for_execution=True,
572
+ store=self.store,
573
+ checkpointer=self.checkpointer,
574
+ manager=self.manager,
575
+ retry_policy=self.retry_policy,
576
+ cache_policy=self.cache_policy,
577
+ ),
578
+ ):
579
+ # produce debug output
580
+ self._emit("tasks", map_debug_tasks, [pushed])
581
+ # save the new task
582
+ self.tasks[pushed.id] = pushed
583
+ # match any pending writes to the new task
584
+ if not self.is_replaying:
585
+ self._reapply_writes_to_succeeded_nodes({pushed.id: pushed})
586
+ # return the new task, to be started if not run before
587
+ return pushed
588
+
589
+ def schedule_error_handler(
590
+ self, failed_task: PregelExecutableTask, error: BaseException
591
+ ) -> PregelExecutableTask | None:
592
+ raise NotImplementedError
593
+
594
+ async def aschedule_error_handler(
595
+ self, failed_task: PregelExecutableTask, error: BaseException
596
+ ) -> PregelExecutableTask | None:
597
+ raise NotImplementedError
598
+
599
+ def tick(self) -> bool:
600
+ """Execute a single iteration of the Pregel loop.
601
+
602
+ Returns:
603
+ True if more iterations are needed.
604
+ """
605
+
606
+ # check if iteration limit is reached
607
+ if self.step > self.stop:
608
+ self.status = "out_of_steps"
609
+ return False
610
+
611
+ # prepare next tasks
612
+ self.tasks = prepare_next_tasks(
613
+ self.checkpoint,
614
+ self.checkpoint_pending_writes,
615
+ self.nodes,
616
+ self.channels,
617
+ self.managed,
618
+ self.config,
619
+ self.step,
620
+ self.stop,
621
+ for_execution=True,
622
+ manager=self.manager,
623
+ store=self.store,
624
+ checkpointer=self.checkpointer,
625
+ trigger_to_nodes=self.trigger_to_nodes,
626
+ updated_channels=self.updated_channels,
627
+ retry_policy=self.retry_policy,
628
+ cache_policy=self.cache_policy,
629
+ )
630
+
631
+ # produce debug output
632
+ if self._checkpointer_put_after_previous is not None:
633
+ self._emit(
634
+ "checkpoints",
635
+ map_debug_checkpoint,
636
+ {
637
+ **self.checkpoint_config,
638
+ CONF: {
639
+ **self.checkpoint_config[CONF],
640
+ CONFIG_KEY_CHECKPOINT_ID: self.checkpoint["id"],
641
+ },
642
+ },
643
+ self.channels,
644
+ self.stream_keys,
645
+ self.checkpoint_metadata,
646
+ self.tasks.values(),
647
+ self.checkpoint_pending_writes,
648
+ self.prev_checkpoint_config,
649
+ self.output_keys,
650
+ )
651
+
652
+ # if no more tasks, we're done
653
+ if not self.tasks:
654
+ self.status = "done"
655
+ return False
656
+
657
+ if self.control is not None and self.control.drain_requested:
658
+ self.status = "draining"
659
+ return False
660
+
661
+ # if there are pending writes from a previous loop, apply them
662
+ if not self.is_replaying and self.checkpoint_pending_writes:
663
+ self._reapply_writes_to_succeeded_nodes(self.tasks)
664
+ self._resume_error_handlers_if_applicable()
665
+
666
+ # before execution, check if we should interrupt
667
+ if self.interrupt_before and should_interrupt(
668
+ self.checkpoint, self.interrupt_before, self.tasks.values()
669
+ ):
670
+ self.status = "interrupt_before"
671
+ raise GraphInterrupt()
672
+
673
+ # produce debug output
674
+ self._emit("tasks", map_debug_tasks, self.tasks.values())
675
+
676
+ # print output for any tasks we applied previous writes to
677
+ for task in self.tasks.values():
678
+ if task.writes:
679
+ self.output_writes(task.id, task.writes, cached=True)
680
+
681
+ return True
682
+
683
+ def after_tick(self) -> None:
684
+ # finish superstep
685
+ writes = [w for t in self.tasks.values() for w in t.writes]
686
+ self._delta_channels_with_overwrite.update(
687
+ ch
688
+ for ch, v in writes
689
+ if isinstance(self.specs.get(ch), DeltaChannel) and _get_overwrite(v)[0]
690
+ )
691
+ # all tasks have finished
692
+ self.updated_channels = apply_writes(
693
+ self.checkpoint,
694
+ self.channels,
695
+ self.tasks.values(),
696
+ self.checkpointer_get_next_version,
697
+ self.trigger_to_nodes,
698
+ )
699
+ # produce values output
700
+ if not self.updated_channels.isdisjoint(
701
+ (self.output_keys,)
702
+ if isinstance(self.output_keys, str)
703
+ else self.output_keys
704
+ ):
705
+ self._emit(
706
+ "values", map_output_values, self.output_keys, writes, self.channels
707
+ )
708
+ # capture delta-channel writes for exit-mode accumulator before clearing
709
+ if self._exit_delta_writes is not None:
710
+ for tid, ch, v in self.checkpoint_pending_writes:
711
+ if isinstance(self.specs.get(ch), DeltaChannel):
712
+ self._exit_delta_writes.append((self.step, tid, ch, v))
713
+ # clear pending writes
714
+ self.checkpoint_pending_writes.clear()
715
+ # only replay (re-execute) done tasks on the first tick
716
+ self.is_replaying = False
717
+ # save checkpoint
718
+ self._put_checkpoint({"source": "loop"})
719
+ # after execution, check if we should interrupt
720
+ if self.interrupt_after and should_interrupt(
721
+ self.checkpoint, self.interrupt_after, self.tasks.values()
722
+ ):
723
+ self.status = "interrupt_after"
724
+ raise GraphInterrupt()
725
+ # unset resuming flag
726
+ self.config[CONF].pop(CONFIG_KEY_RESUMING, None)
727
+
728
+ def match_cached_writes(self) -> Sequence[PregelExecutableTask]:
729
+ raise NotImplementedError
730
+
731
+ async def amatch_cached_writes(self) -> Sequence[PregelExecutableTask]:
732
+ raise NotImplementedError
733
+
734
+ # private
735
+
736
+ def _reapply_writes_to_succeeded_nodes(
737
+ self, tasks: Mapping[str, PregelExecutableTask]
738
+ ) -> None:
739
+ """Restore successful channel writes from checkpoint to in-memory tasks.
740
+
741
+ Skips control signals (ERROR, ERROR_SOURCE_NODE, INTERRUPT, RESUME)
742
+ so that failed/interrupted tasks remain with empty writes and will be
743
+ re-executed (or routed to error handlers) by the runner.
744
+ """
745
+ for tid, k, v in self.checkpoint_pending_writes:
746
+ if k in (ERROR, ERROR_SOURCE_NODE, INTERRUPT, RESUME):
747
+ continue
748
+ if task := tasks.get(tid):
749
+ task.writes.append((k, v))
750
+
751
+ def _resume_error_handlers_if_applicable(self) -> None:
752
+ """On resume, schedule error handlers for tasks that failed in a prior run.
753
+
754
+ Called right after ``_reapply_writes_to_succeeded_nodes`` during ``tick()``.
755
+ At that point, ``_reapply_writes_to_succeeded_nodes`` has already skipped
756
+ ERROR / ERROR_SOURCE_NODE writes, so a previously-failed task still has
757
+ empty ``writes``. Without intervention the runner (which executes only
758
+ tasks where ``not t.writes``) would re-run the original node.
759
+
760
+ This method prevents that re-execution for nodes that have an error
761
+ handler:
762
+
763
+ 1. Scan ``checkpoint_pending_writes`` for ERROR_SOURCE_NODE markers
764
+ persisted by a prior ``commit()``. Each marker means "this task
765
+ already failed and was routed to an error handler".
766
+ 2. For each such task, write ``(ERROR, error)`` into ``task.writes``
767
+ so the task is no longer empty — the runner will skip it.
768
+ 3. Prepare a fresh error-handler task and add it to ``self.tasks``.
769
+ Because the handler task starts with empty ``writes``, the runner
770
+ will pick it up and execute it.
771
+ """
772
+ # Phase 1: collect task-ids that have ERROR_SOURCE_NODE + ERROR pairs.
773
+ failed: dict[str, BaseException] = {}
774
+ for tid, chan, val in self.checkpoint_pending_writes:
775
+ if chan == ERROR_SOURCE_NODE:
776
+ error = next(
777
+ (
778
+ v
779
+ for t, c, v in self.checkpoint_pending_writes
780
+ if t == tid and c == ERROR
781
+ ),
782
+ None,
783
+ )
784
+ if error is not None:
785
+ failed[tid] = error
786
+ # Phase 2: mark originals as done, schedule handler tasks.
787
+ for task_id, error in failed.items():
788
+ task = self.tasks.get(task_id)
789
+ if task is None:
790
+ continue
791
+ handler_node = self.nodes[task.name].error_handler_node
792
+ if not handler_node:
793
+ continue
794
+ # Non-empty writes → runner's `not t.writes` filter skips this task.
795
+ task.writes.append((ERROR, error))
796
+ # The handler task starts with empty writes → runner will execute it.
797
+ handler_task = prepare_node_error_handler_task(
798
+ task,
799
+ handler_node_name=handler_node,
800
+ failed_error=error,
801
+ checkpoint=self.checkpoint,
802
+ pending_writes=self.checkpoint_pending_writes,
803
+ processes=self.nodes,
804
+ channels=self.channels,
805
+ managed=self.managed,
806
+ config=task.config,
807
+ step=self.step,
808
+ stop=self.stop,
809
+ store=self.store,
810
+ checkpointer=self.checkpointer,
811
+ manager=self.manager,
812
+ retry_policy=self.retry_policy,
813
+ cache_policy=self.cache_policy,
814
+ )
815
+ if handler_task is not None:
816
+ self.tasks[handler_task.id] = handler_task
817
+
818
+ def _pending_interrupts(self) -> set[str]:
819
+ """Return the set of interrupt ids that are pending without corresponding resume values."""
820
+ # mapping of task ids to interrupt ids
821
+ pending_interrupts: dict[str, str] = {}
822
+
823
+ # set of resume task ids
824
+ pending_resumes: set[str] = set()
825
+
826
+ for task_id, write_type, value in self.checkpoint_pending_writes:
827
+ if write_type == INTERRUPT:
828
+ # interrupts is always a list, but there should only be one element
829
+ pending_interrupts[task_id] = value[0].id
830
+ elif write_type == RESUME:
831
+ pending_resumes.add(task_id)
832
+
833
+ resumed_interrupt_ids = {
834
+ pending_interrupts[task_id]
835
+ for task_id in pending_resumes
836
+ if task_id in pending_interrupts
837
+ }
838
+
839
+ # Keep only interrupts whose interrupt_id is not resumed
840
+ hanging_interrupts: set[str] = {
841
+ interrupt_id
842
+ for interrupt_id in pending_interrupts.values()
843
+ if interrupt_id not in resumed_interrupt_ids
844
+ }
845
+
846
+ return hanging_interrupts
847
+
848
+ def _first(
849
+ self, *, input_keys: str | Sequence[str], updated_channels: set[str] | None
850
+ ) -> set[str] | None:
851
+ # Resuming from a previous checkpoint requires two things:
852
+ # 1. A prior checkpoint exists (channel_versions is non-empty)
853
+ # 2. The input signals continuation (not a fresh run with new input)
854
+ # For subgraphs, the parent explicitly sets CONFIG_KEY_RESUMING.
855
+ # For the outer graph, we infer from the input:
856
+ # - None input: resume after interrupt (invoke(None, config))
857
+ # - Command input: any Command operates on existing state
858
+ # - Same run_id: re-entry into an ongoing run (e.g. stream reconnect)
859
+ configurable = self.config.get(CONF, {})
860
+ input_is_command = isinstance(self.input, Command)
861
+ is_resuming = bool(self.checkpoint["channel_versions"]) and bool(
862
+ configurable.get(
863
+ CONFIG_KEY_RESUMING,
864
+ self.input is None
865
+ or input_is_command
866
+ or (
867
+ not self.is_nested
868
+ and self.config.get("metadata", {}).get("run_id")
869
+ == self.checkpoint_metadata.get("run_id", MISSING)
870
+ ),
871
+ )
872
+ )
873
+
874
+ # When replaying from a specific checkpoint, drop cached RESUME
875
+ # writes so that interrupt() calls re-fire instead of returning
876
+ # stale values. But if we're actively resuming, keep them —
877
+ # multi-interrupt scenarios need previously resolved values preserved.
878
+ is_time_traveling = self.is_replaying and (
879
+ # Time-travel to a subgraph checkpoint: the parent sets
880
+ # RESUMING=True (it can't distinguish time-travel from resume),
881
+ # so we check if this subgraph's own ns is in checkpoint_map.
882
+ # Normally the map only has ancestor entries (_algo.py); the
883
+ # subgraph's own entry only appears via get_state(subgraphs=True).
884
+ (
885
+ self.is_nested
886
+ and configurable.get(CONFIG_KEY_CHECKPOINT_NS, "")
887
+ in configurable.get(CONFIG_KEY_CHECKPOINT_MAP, {})
888
+ )
889
+ or not (
890
+ # Outer graph: resume arrives as Command(resume=...)
891
+ (input_is_command and cast(Command, self.input).resume is not None)
892
+ # Subgraphs: resume arrives via config flag from parent
893
+ # (subgraph input is a Send arg, not a Command)
894
+ or configurable.get(CONFIG_KEY_RESUMING, False)
895
+ )
896
+ )
897
+ if is_time_traveling:
898
+ self.checkpoint_pending_writes = [
899
+ w for w in self.checkpoint_pending_writes if w[1] != RESUME
900
+ ]
901
+
902
+ # map command to writes
903
+ if input_is_command:
904
+ if (resume := cast(Command, self.input).resume) is not None:
905
+ if not self.checkpointer:
906
+ raise RuntimeError(
907
+ "Cannot use Command(resume=...) without checkpointer"
908
+ )
909
+
910
+ if resume_is_map := (
911
+ isinstance(resume, dict)
912
+ and all(is_xxh3_128_hexdigest(k) for k in resume)
913
+ ):
914
+ self.config[CONF][CONFIG_KEY_RESUME_MAP] = resume
915
+ else:
916
+ if len(self._pending_interrupts()) > 1:
917
+ raise RuntimeError(
918
+ "When there are multiple pending interrupts, you must specify the interrupt id when resuming. "
919
+ "Docs: https://docs.langchain.com/oss/python/langgraph/add-human-in-the-loop#resume-multiple-interrupts-with-one-invocation."
920
+ )
921
+
922
+ writes: defaultdict[str, list[tuple[str, Any]]] = defaultdict(list)
923
+ # group writes by task ID
924
+ for tid, c, v in map_command(cmd=cast(Command, self.input)):
925
+ if not (c == RESUME and resume_is_map):
926
+ writes[tid].append((c, v))
927
+ if not writes and not resume_is_map:
928
+ raise EmptyInputError("Received empty Command input")
929
+ # save writes
930
+ for tid, ws in writes.items():
931
+ self.put_writes(tid, ws)
932
+ # apply NULL writes
933
+ if null_writes := [
934
+ w[1:] for w in self.checkpoint_pending_writes if w[0] == NULL_TASK_ID
935
+ ]:
936
+ null_updated_channels = apply_writes(
937
+ self.checkpoint,
938
+ self.channels,
939
+ [PregelTaskWrites((), INPUT, null_writes, [])],
940
+ self.checkpointer_get_next_version,
941
+ self.trigger_to_nodes,
942
+ )
943
+ if updated_channels is not None:
944
+ updated_channels.update(null_updated_channels)
945
+ # proceed past previous checkpoint
946
+ if is_resuming:
947
+ self.checkpoint["versions_seen"].setdefault(INTERRUPT, {})
948
+ for k in self.channels:
949
+ if k in self.checkpoint["channel_versions"]:
950
+ version = self.checkpoint["channel_versions"][k]
951
+ self.checkpoint["versions_seen"][INTERRUPT][k] = version
952
+ # When time-traveling (replaying from a specific checkpoint),
953
+ # save a fork checkpoint so the replayed execution creates a
954
+ # new branch. Without this, if the execution hits an interrupt
955
+ # before after_tick() runs, no new checkpoint is created —
956
+ # the parent's latest checkpoint remains the old one and
957
+ # subsequent resumes load the wrong state.
958
+ # Skip for update_state forks (source=update/fork) since they
959
+ # already have their own fork checkpoint.
960
+ if is_time_traveling and self.checkpoint_metadata.get("source") not in (
961
+ "update",
962
+ "fork",
963
+ ):
964
+ # Clear old INTERRUPT writes from the loaded checkpoint.
965
+ # The fork will have a new checkpoint_id which changes
966
+ # task IDs — stale interrupt writes would accumulate and
967
+ # confuse the multiple-interrupt check in future resumes.
968
+ self.checkpoint_pending_writes = [
969
+ w for w in self.checkpoint_pending_writes if w[1] != INTERRUPT
970
+ ]
971
+ self._put_checkpoint({"source": "fork"})
972
+ # produce values output
973
+ self._emit(
974
+ "values", map_output_values, self.output_keys, True, self.channels
975
+ )
976
+ # map inputs to channel updates
977
+ elif input_writes := deque(map_input(input_keys, self.input)):
978
+ # discard any unfinished tasks from previous checkpoint
979
+ discard_tasks = prepare_next_tasks(
980
+ self.checkpoint,
981
+ self.checkpoint_pending_writes,
982
+ self.nodes,
983
+ self.channels,
984
+ self.managed,
985
+ self.config,
986
+ self.step,
987
+ self.stop,
988
+ for_execution=True,
989
+ store=None,
990
+ checkpointer=None,
991
+ manager=None,
992
+ updated_channels=updated_channels,
993
+ )
994
+ self._delta_channels_with_overwrite.update(
995
+ c
996
+ for c, v in input_writes
997
+ if isinstance(self.specs.get(c), DeltaChannel) and _get_overwrite(v)[0]
998
+ )
999
+ # apply input writes
1000
+ updated_channels = apply_writes(
1001
+ self.checkpoint,
1002
+ self.channels,
1003
+ [
1004
+ *discard_tasks.values(),
1005
+ PregelTaskWrites((), INPUT, input_writes, []),
1006
+ ],
1007
+ self.checkpointer_get_next_version,
1008
+ self.trigger_to_nodes,
1009
+ )
1010
+ # Input writes go through `apply_writes` directly (above) — they
1011
+ # never enter `checkpoint_pending_writes`, so the after_tick
1012
+ # capture site does not see them. In exit mode, capture them
1013
+ # here so `_exit_delta_writes` includes the input's delta writes
1014
+ # alongside per-superstep writes; otherwise the input would be
1015
+ # lost on read (it's not in final_checkpoint.channel_values for
1016
+ # sub-freq channels, and walks ignore target.pending_writes).
1017
+ if self._exit_delta_writes is not None:
1018
+ for c, v in input_writes:
1019
+ if isinstance(self.specs.get(c), DeltaChannel):
1020
+ self._exit_delta_writes.append((self.step, NULL_TASK_ID, c, v))
1021
+ # Persist delta-channel input writes so sub-freq inputs are
1022
+ # recoverable via ancestor walk (mirrors the Command input path).
1023
+ if self.durability != "exit":
1024
+ delta_input = [
1025
+ (c, v)
1026
+ for c, v in input_writes
1027
+ if isinstance(self.specs.get(c), DeltaChannel)
1028
+ ]
1029
+ if delta_input:
1030
+ self.put_writes(NULL_TASK_ID, delta_input)
1031
+ # save input checkpoint
1032
+ self.updated_channels = updated_channels
1033
+ self._put_checkpoint({"source": "input"})
1034
+ elif CONFIG_KEY_RESUMING not in configurable:
1035
+ raise EmptyInputError(f"Received no input for {input_keys}")
1036
+ # Propagate resuming and replaying flags to subgraphs.
1037
+ if not self.is_nested:
1038
+ # Pass the resolved before-bound checkpoint ID so subgraphs can
1039
+ # find their corresponding checkpoint without re-fetching the
1040
+ # parent. For forks (source=update/fork), use the fork's parent
1041
+ # checkpoint ID since the fork was created after the subgraph's
1042
+ # checkpoints from the original execution.
1043
+ #
1044
+ # Only gate on is_time_traveling (not is_replaying). When the
1045
+ # client resumes with an explicit checkpoint_id that happens to
1046
+ # point at the current head (e.g. CocoGraph Studio sending
1047
+ # `checkpoint: {checkpoint_id}` alongside Command(resume=...)),
1048
+ # is_replaying is True but is_time_traveling is False. In that
1049
+ # case subgraphs should load their latest checkpoint normally,
1050
+ # not go through ReplayState's before-bound lookup which would
1051
+ # miss subgraph checkpoints created during processing of the
1052
+ # current parent step.
1053
+ replay_state: ReplayState | None = None
1054
+ if is_time_traveling:
1055
+ replay_checkpoint_id = self.checkpoint["id"]
1056
+ if (
1057
+ self.checkpoint_metadata.get("source")
1058
+ in (
1059
+ "update",
1060
+ "fork",
1061
+ )
1062
+ and self.prev_checkpoint_config
1063
+ ):
1064
+ replay_checkpoint_id = self.prev_checkpoint_config[CONF].get(
1065
+ CONFIG_KEY_CHECKPOINT_ID, replay_checkpoint_id
1066
+ )
1067
+ replay_state = ReplayState(replay_checkpoint_id)
1068
+ self.config = patch_configurable(
1069
+ self.config,
1070
+ {
1071
+ CONFIG_KEY_RESUMING: is_resuming,
1072
+ CONFIG_KEY_REPLAY_STATE: replay_state,
1073
+ },
1074
+ )
1075
+ # set flag
1076
+ self.status = "pending"
1077
+ if is_resuming:
1078
+ self._push_graph_lifecycle_event("resume")
1079
+ return updated_channels
1080
+
1081
+ def _put_checkpoint(self, metadata: CheckpointMetadata) -> None:
1082
+ # `is` (object identity) — not `==`. Three of four call sites pass a
1083
+ # fresh dict ({"source":"input"|"loop"|"fork"}); only
1084
+ # `_suppress_interrupt`(will rename to _on_loop_exit soon)
1085
+ # at exit reuses the existing `self.checkpoint_metadata` instance. So
1086
+ # `metadata is self.checkpoint_metadata` is True only on the exit call,
1087
+ # which is what we use to gate exit-only behaviour (skip count-bump,
1088
+ # don't replace metadata). Could be replaced by an explicit
1089
+ # `exiting: bool = False` parameter; left as-is to match the existing
1090
+ # idiom in this file.
1091
+ # TODO: replace with an explicit `exiting: bool = False` parameter.
1092
+ exiting = metadata is self.checkpoint_metadata
1093
+ if exiting and self.checkpoint["id"] == self.checkpoint_id_saved:
1094
+ # checkpoint already saved
1095
+ return
1096
+ # Per-delta-channel counter bookkeeping.
1097
+ #
1098
+ # Each delta channel tracks a (updates, supersteps) tuple:
1099
+ # - `updates` increments only when the channel is written this step.
1100
+ # - `supersteps` increments every superstep regardless.
1101
+ #
1102
+ # `_put_checkpoint` is called once per superstep with a fresh
1103
+ # metadata dict (source="input"|"loop"|"fork") — those are the
1104
+ # intermediate calls that bump counters. In exit mode,
1105
+ # `_suppress_interrupt`(will rename to _on_loop_exit soon)
1106
+ # additionally calls `_put_checkpoint(self.checkpoint_metadata)` AT
1107
+ # EXIT to commit the final checkpoint — this runs *after* the last
1108
+ # intermediate call already counted the last superstep. So the
1109
+ # exit call must NOT bump again or it would double-count the last
1110
+ # superstep.
1111
+ if not exiting:
1112
+ prev_counters = dict(
1113
+ self.checkpoint_metadata.get("counters_since_delta_snapshot") or {}
1114
+ )
1115
+ new_counters: dict[str, tuple[int, int]] = {}
1116
+ updated = self.updated_channels or set()
1117
+ for ch_name, ch in self.channels.items():
1118
+ if not isinstance(ch, DeltaChannel):
1119
+ continue
1120
+ u, s = prev_counters.get(ch_name, (0, 0))
1121
+ s += 1
1122
+ if ch_name in updated:
1123
+ u += 1
1124
+ new_counters[ch_name] = (u, s)
1125
+ metadata["step"] = self.step
1126
+ metadata["parents"] = self.config[CONF].get(CONFIG_KEY_CHECKPOINT_MAP, {})
1127
+ self.checkpoint_metadata = metadata
1128
+ else:
1129
+ new_counters = dict(
1130
+ self.checkpoint_metadata.get("counters_since_delta_snapshot") or {}
1131
+ )
1132
+ # do checkpoint?
1133
+ do_checkpoint = self._checkpointer_put_after_previous is not None and (
1134
+ exiting or self.durability != "exit"
1135
+ )
1136
+ # create new checkpoint
1137
+ channels_to_snapshot = (
1138
+ delta_channels_to_snapshot(self.channels, new_counters)
1139
+ | self._delta_channels_with_overwrite
1140
+ if do_checkpoint
1141
+ else set()
1142
+ )
1143
+ self.checkpoint = create_checkpoint(
1144
+ self.checkpoint,
1145
+ self.channels if do_checkpoint else None,
1146
+ self.step,
1147
+ id=self.checkpoint["id"] if exiting else None,
1148
+ updated_channels=self.updated_channels,
1149
+ get_next_version=self.checkpointer_get_next_version
1150
+ if do_checkpoint
1151
+ else None,
1152
+ channels_to_snapshot=channels_to_snapshot,
1153
+ )
1154
+ for k in channels_to_snapshot:
1155
+ new_counters[k] = (0, 0)
1156
+ if do_checkpoint:
1157
+ self._delta_channels_with_overwrite.difference_update(channels_to_snapshot)
1158
+ non_zero = {k: v for k, v in new_counters.items() if v != (0, 0)}
1159
+ if non_zero:
1160
+ self.checkpoint_metadata["counters_since_delta_snapshot"] = non_zero
1161
+ elif "counters_since_delta_snapshot" in self.checkpoint_metadata:
1162
+ del self.checkpoint_metadata["counters_since_delta_snapshot"]
1163
+ # sanitize TASK channel in the checkpoint before saving (durability=="exit")
1164
+ if TASKS in self.checkpoint["channel_values"] and any(
1165
+ isinstance(channel, UntrackedValue) for channel in self.channels.values()
1166
+ ):
1167
+ sanitized_tasks = [
1168
+ sanitize_untracked_values_in_send(value, self.channels)
1169
+ if isinstance(value, Send)
1170
+ else value
1171
+ for value in self.checkpoint["channel_values"][TASKS]
1172
+ ]
1173
+ self.checkpoint["channel_values"][TASKS] = sanitized_tasks
1174
+ # bail if no checkpointer
1175
+
1176
+ if do_checkpoint and self._checkpointer_put_after_previous is not None:
1177
+ self.prev_checkpoint_config = (
1178
+ self.checkpoint_config
1179
+ if CONFIG_KEY_CHECKPOINT_ID in self.checkpoint_config[CONF]
1180
+ and self.checkpoint_config[CONF][CONFIG_KEY_CHECKPOINT_ID]
1181
+ else None
1182
+ )
1183
+ self.checkpoint_config = {
1184
+ **self.checkpoint_config,
1185
+ CONF: {
1186
+ **self.checkpoint_config[CONF],
1187
+ CONFIG_KEY_CHECKPOINT_NS: self.config[CONF].get(
1188
+ CONFIG_KEY_CHECKPOINT_NS, ""
1189
+ ),
1190
+ },
1191
+ }
1192
+
1193
+ channel_versions = self.checkpoint["channel_versions"].copy()
1194
+ new_versions = get_new_channel_versions(
1195
+ self.checkpoint_previous_versions, channel_versions
1196
+ )
1197
+ self.checkpoint_previous_versions = channel_versions
1198
+
1199
+ # save it, without blocking
1200
+ # if there's a previous checkpoint save in progress, wait for it
1201
+ # ensuring checkpointers receive checkpoints in order
1202
+ self._put_checkpoint_fut = self.submit(
1203
+ self._checkpointer_put_after_previous,
1204
+ getattr(self, "_put_checkpoint_fut", None),
1205
+ self.checkpoint_config,
1206
+ copy_checkpoint(self.checkpoint),
1207
+ self.checkpoint_metadata,
1208
+ new_versions,
1209
+ )
1210
+ self.checkpoint_config = {
1211
+ **self.checkpoint_config,
1212
+ CONF: {
1213
+ **self.checkpoint_config[CONF],
1214
+ CONFIG_KEY_CHECKPOINT_ID: self.checkpoint["id"],
1215
+ },
1216
+ }
1217
+ if not exiting:
1218
+ # increment step
1219
+ self.step += 1
1220
+
1221
+ def _put_exit_delta_writes(self) -> None:
1222
+ """Stage stub + accumulated delta writes so final_checkpoint's put
1223
+ waits on them (visibility invariant: both must be durable before
1224
+ final_checkpoint becomes visible to readers).
1225
+
1226
+ Stub is created lazily — only when no persisted parent exists AND at
1227
+ least one delta channel has writes that won't be snapshotted.
1228
+ """
1229
+ if (
1230
+ not self._exit_delta_writes
1231
+ or self.checkpointer is None
1232
+ or self._checkpointer_put_after_previous is None
1233
+ or self.checkpointer_put_writes is None
1234
+ ):
1235
+ return
1236
+
1237
+ counters = dict(
1238
+ self.checkpoint_metadata.get("counters_since_delta_snapshot") or {}
1239
+ )
1240
+ channels_to_snapshot = (
1241
+ delta_channels_to_snapshot(self.channels, counters)
1242
+ | self._delta_channels_with_overwrite
1243
+ )
1244
+
1245
+ pending = [
1246
+ (step, tid, ch, v)
1247
+ for (step, tid, ch, v) in self._exit_delta_writes
1248
+ if ch not in channels_to_snapshot
1249
+ ]
1250
+ if not pending:
1251
+ return
1252
+
1253
+ if self._has_persisted_parent:
1254
+ # _initial_checkpoint_config's checkpoint_id is the saved parent's
1255
+ # id (saver returned a real tuple at __enter__).
1256
+ anchor_config = self._initial_checkpoint_config
1257
+ else:
1258
+ stub_cp = empty_checkpoint()
1259
+ stub_cp["id"] = self.checkpoint_id_saved
1260
+ stub_cp["ts"] = datetime.now(timezone.utc).isoformat()
1261
+ # Stub has no parent (checkpoint_id=None in config).
1262
+ stub_put_config = patch_configurable(
1263
+ self._initial_checkpoint_config,
1264
+ {CONFIG_KEY_CHECKPOINT_ID: None},
1265
+ )
1266
+ # Anchor config for put_writes: checkpoint_id = stub's id.
1267
+ anchor_config = patch_configurable(
1268
+ self._initial_checkpoint_config,
1269
+ {CONFIG_KEY_CHECKPOINT_ID: stub_cp["id"]},
1270
+ )
1271
+ self._put_checkpoint_fut = self.submit(
1272
+ self._checkpointer_put_after_previous,
1273
+ getattr(self, "_put_checkpoint_fut", None),
1274
+ stub_put_config,
1275
+ stub_cp,
1276
+ {"step": -2},
1277
+ {},
1278
+ )
1279
+ # Set checkpoint_config so final_checkpoint's _put_checkpoint
1280
+ # sees the stub as its parent.
1281
+ self.checkpoint_config = anchor_config
1282
+
1283
+ # Step-prefixed synthetic task_id preserves chronological superstep
1284
+ # order under the saver's ORDER BY task_id, idx sorting.
1285
+ grouped: dict[tuple[int, str], list[tuple[str, Any]]] = {}
1286
+ for step, tid, ch, v in pending:
1287
+ grouped.setdefault((step, tid), []).append((ch, v))
1288
+ anchor_write_config = patch_configurable(
1289
+ anchor_config,
1290
+ {
1291
+ CONFIG_KEY_CHECKPOINT_NS: self.config[CONF].get(
1292
+ CONFIG_KEY_CHECKPOINT_NS, ""
1293
+ ),
1294
+ CONFIG_KEY_CHECKPOINT_ID: anchor_config[CONF][CONFIG_KEY_CHECKPOINT_ID],
1295
+ },
1296
+ )
1297
+ for (step, tid), entries in grouped.items():
1298
+ synth_tid = exit_delta_task_id(step, tid)
1299
+ if self.checkpointer_put_writes_accepts_task_path:
1300
+ fut = self.submit(
1301
+ self.checkpointer_put_writes,
1302
+ anchor_write_config,
1303
+ entries,
1304
+ synth_tid,
1305
+ "",
1306
+ )
1307
+ else:
1308
+ fut = self.submit(
1309
+ self.checkpointer_put_writes,
1310
+ anchor_write_config,
1311
+ entries,
1312
+ synth_tid,
1313
+ )
1314
+ if self._delta_write_futs is not None:
1315
+ self._delta_write_futs.append(fut)
1316
+
1317
+ def _suppress_interrupt(
1318
+ self,
1319
+ exc_type: type[BaseException] | None,
1320
+ exc_value: BaseException | None,
1321
+ traceback: TracebackType | None,
1322
+ ) -> bool | None:
1323
+ # persist current checkpoint and writes
1324
+ if self.durability == "exit" and (
1325
+ # if it's a top graph
1326
+ not self.is_nested
1327
+ # or a nested graph with error or interrupt
1328
+ or exc_value is not None
1329
+ # or a nested graph with checkpointer=True
1330
+ or all(NS_END not in part for part in self.checkpoint_ns)
1331
+ ):
1332
+ self._put_exit_delta_writes()
1333
+ self._put_checkpoint(self.checkpoint_metadata)
1334
+ self._put_pending_writes()
1335
+ # suppress interrupt
1336
+ if isinstance(exc_value, GraphInterrupt) and not self.is_nested:
1337
+ interrupt = exc_value
1338
+ interrupts = tuple(interrupt.args[0]) if interrupt.args else ()
1339
+ self._push_graph_lifecycle_event("interrupt", interrupts=interrupts)
1340
+ # emit one last "values" event, with pending writes applied
1341
+ if (
1342
+ hasattr(self, "tasks")
1343
+ and self.checkpoint_pending_writes
1344
+ and any(task.writes for task in self.tasks.values())
1345
+ ):
1346
+ updated_channels = apply_writes(
1347
+ self.checkpoint,
1348
+ self.channels,
1349
+ self.tasks.values(),
1350
+ self.checkpointer_get_next_version,
1351
+ self.trigger_to_nodes,
1352
+ )
1353
+ if not updated_channels.isdisjoint(
1354
+ (self.output_keys,)
1355
+ if isinstance(self.output_keys, str)
1356
+ else self.output_keys
1357
+ ):
1358
+ self._emit(
1359
+ "values",
1360
+ map_output_values,
1361
+ self.output_keys,
1362
+ [w for t in self.tasks.values() for w in t.writes],
1363
+ self.channels,
1364
+ )
1365
+ # emit INTERRUPT if exception is empty (otherwise emitted by put_writes)
1366
+ if not interrupt.args or not interrupt.args[0]:
1367
+ interrupt_payload = interrupt.args[0] if interrupt.args else ()
1368
+ self._emit(
1369
+ "updates",
1370
+ lambda: iter([{INTERRUPT: interrupt_payload}]),
1371
+ )
1372
+ # save final output
1373
+ self.output = read_channels(self.channels, self.output_keys)
1374
+ # suppress interrupt
1375
+ return True
1376
+ elif exc_type is None:
1377
+ # save final output
1378
+ self.output = read_channels(self.channels, self.output_keys)
1379
+
1380
+ def _emit(
1381
+ self,
1382
+ mode: StreamMode,
1383
+ values: Callable[P, Iterator[Any]],
1384
+ *args: P.args,
1385
+ **kwargs: P.kwargs,
1386
+ ) -> None:
1387
+ if self.stream is None:
1388
+ return
1389
+ debug_remap = mode in ("checkpoints", "tasks") and "debug" in self.stream.modes
1390
+ if mode not in self.stream.modes and not debug_remap:
1391
+ return
1392
+ for v in values(*args, **kwargs):
1393
+ if mode in self.stream.modes:
1394
+ self.stream((self.checkpoint_ns, mode, v))
1395
+ # "debug" mode is "checkpoints" or "tasks" with a wrapper dict
1396
+ if debug_remap:
1397
+ self.stream(
1398
+ (
1399
+ self.checkpoint_ns,
1400
+ "debug",
1401
+ {
1402
+ "step": self.step - 1
1403
+ if mode == "checkpoints"
1404
+ else self.step,
1405
+ "timestamp": datetime.now(timezone.utc).isoformat(),
1406
+ "type": "checkpoint"
1407
+ if mode == "checkpoints"
1408
+ else "task_result"
1409
+ if "result" in v
1410
+ else "task",
1411
+ "payload": v,
1412
+ },
1413
+ )
1414
+ )
1415
+
1416
+ def output_writes(
1417
+ self, task_id: str, writes: WritesT, *, cached: bool = False
1418
+ ) -> None:
1419
+ if task := self.tasks.get(task_id):
1420
+ if task.config is not None and TAG_HIDDEN in task.config.get(
1421
+ "tags", EMPTY_SEQ
1422
+ ):
1423
+ return
1424
+ if writes[0][0] == INTERRUPT:
1425
+ # in loop.py we append a bool to the PUSH task paths to indicate
1426
+ # whether or not a call was present. If so,
1427
+ # we don't emit the interrupt as it'll be emitted by the parent
1428
+ if task.path[0] == PUSH and task.path[-1] is True:
1429
+ return
1430
+ interrupts = [
1431
+ {
1432
+ INTERRUPT: tuple(
1433
+ v
1434
+ for w in writes
1435
+ if w[0] == INTERRUPT
1436
+ for v in (w[1] if isinstance(w[1], Sequence) else (w[1],))
1437
+ )
1438
+ }
1439
+ ]
1440
+ stream_modes = self.stream.modes if self.stream else []
1441
+ if "updates" in stream_modes:
1442
+ self._emit("updates", lambda: iter(interrupts))
1443
+ if "values" in stream_modes:
1444
+ current_values = read_channels(self.channels, self.output_keys)
1445
+ # self.output_keys is a sequence, stream chunk contains entire state and interrupts
1446
+ if isinstance(current_values, dict):
1447
+ current_values[INTERRUPT] = interrupts[0][INTERRUPT]
1448
+ self._emit("values", lambda: iter([current_values]))
1449
+ # self.output_keys is a string, stream chunk contains only interrupts
1450
+ else:
1451
+ self._emit("values", lambda: iter(interrupts))
1452
+ elif writes[0][0] != ERROR:
1453
+ self._emit(
1454
+ "updates",
1455
+ map_output_updates,
1456
+ self.output_keys,
1457
+ [(task, writes)],
1458
+ cached,
1459
+ )
1460
+ if not cached:
1461
+ self._emit(
1462
+ "tasks",
1463
+ map_debug_task_results,
1464
+ (task, writes),
1465
+ self.stream_keys,
1466
+ )
1467
+
1468
+
1469
+ class SyncPregelLoop(PregelLoop, AbstractContextManager):
1470
+ def __init__(
1471
+ self,
1472
+ input: Any | None,
1473
+ *,
1474
+ stream: StreamProtocol | None,
1475
+ config: RunnableConfig,
1476
+ store: BaseStore | None,
1477
+ cache: BaseCache | None,
1478
+ checkpointer: BaseCheckpointSaver | None,
1479
+ nodes: Mapping[str, PregelNode],
1480
+ specs: Mapping[str, BaseChannel | ManagedValueSpec],
1481
+ trigger_to_nodes: Mapping[str, Sequence[str]],
1482
+ durability: Durability,
1483
+ manager: None | AsyncParentRunManager | ParentRunManager = None,
1484
+ interrupt_after: All | Sequence[str] = EMPTY_SEQ,
1485
+ interrupt_before: All | Sequence[str] = EMPTY_SEQ,
1486
+ input_keys: str | Sequence[str] = EMPTY_SEQ,
1487
+ output_keys: str | Sequence[str] = EMPTY_SEQ,
1488
+ stream_keys: str | Sequence[str] = EMPTY_SEQ,
1489
+ migrate_checkpoint: Callable[[Checkpoint], None] | None = None,
1490
+ retry_policy: Sequence[RetryPolicy] = (),
1491
+ cache_policy: CachePolicy | None = None,
1492
+ has_graph_lifecycle_callbacks: bool = False,
1493
+ ) -> None:
1494
+ super().__init__(
1495
+ input,
1496
+ stream=stream,
1497
+ config=config,
1498
+ checkpointer=checkpointer,
1499
+ cache=cache,
1500
+ store=store,
1501
+ nodes=nodes,
1502
+ specs=specs,
1503
+ input_keys=input_keys,
1504
+ output_keys=output_keys,
1505
+ stream_keys=stream_keys,
1506
+ interrupt_after=interrupt_after,
1507
+ interrupt_before=interrupt_before,
1508
+ manager=manager,
1509
+ migrate_checkpoint=migrate_checkpoint,
1510
+ trigger_to_nodes=trigger_to_nodes,
1511
+ retry_policy=retry_policy,
1512
+ cache_policy=cache_policy,
1513
+ durability=durability,
1514
+ has_graph_lifecycle_callbacks=has_graph_lifecycle_callbacks,
1515
+ )
1516
+ self.stack = ExitStack()
1517
+ if checkpointer:
1518
+ self.checkpointer_get_next_version = checkpointer.get_next_version
1519
+ self.checkpointer_put_writes = checkpointer.put_writes
1520
+ self.checkpointer_put_writes_accepts_task_path = (
1521
+ signature(checkpointer.put_writes).parameters.get("task_path")
1522
+ is not None
1523
+ )
1524
+ else:
1525
+ self.checkpointer_get_next_version = increment
1526
+ self._checkpointer_put_after_previous = None # type: ignore[assignment]
1527
+ self.checkpointer_put_writes = None
1528
+ self.checkpointer_put_writes_accepts_task_path = False
1529
+
1530
+ def _checkpointer_put_after_previous(
1531
+ self,
1532
+ prev: concurrent.futures.Future | None,
1533
+ config: RunnableConfig,
1534
+ checkpoint: Checkpoint,
1535
+ metadata: CheckpointMetadata,
1536
+ new_versions: ChannelVersions,
1537
+ ) -> RunnableConfig:
1538
+ if self._delta_write_futs:
1539
+ futs, self._delta_write_futs = self._delta_write_futs, []
1540
+ concurrent.futures.wait(futs)
1541
+ try:
1542
+ if prev is not None:
1543
+ prev.result()
1544
+ finally:
1545
+ cast(BaseCheckpointSaver, self.checkpointer).put(
1546
+ config, checkpoint, metadata, new_versions
1547
+ )
1548
+
1549
+ def match_cached_writes(self) -> Sequence[PregelExecutableTask]:
1550
+ if self.cache is None:
1551
+ return ()
1552
+ matched: list[PregelExecutableTask] = []
1553
+ if cached := {
1554
+ (t.cache_key.ns, t.cache_key.key): t
1555
+ for t in self.tasks.values()
1556
+ if t.cache_key and not t.writes
1557
+ }:
1558
+ for key, values in self.cache.get(tuple(cached)).items():
1559
+ task = cached[key]
1560
+ task.writes.extend(values)
1561
+ matched.append(task)
1562
+ return matched
1563
+
1564
+ def accept_push(
1565
+ self, task: PregelExecutableTask, write_idx: int, call: Call | None = None
1566
+ ) -> PregelExecutableTask | None:
1567
+ if pushed := super().accept_push(task, write_idx, call):
1568
+ for task in self.match_cached_writes():
1569
+ self.output_writes(task.id, task.writes, cached=True)
1570
+ return pushed
1571
+
1572
+ def schedule_error_handler(
1573
+ self, failed_task: PregelExecutableTask, error: BaseException
1574
+ ) -> PregelExecutableTask | None:
1575
+ handler_node = self.nodes[failed_task.name].error_handler_node
1576
+ if not handler_node:
1577
+ return None
1578
+ # ensure error + ERROR_SOURCE_NODE writes are durable before handler runs
1579
+ if self._error_handler_write_futs:
1580
+ futs, self._error_handler_write_futs = self._error_handler_write_futs, []
1581
+ concurrent.futures.wait(futs)
1582
+ handler_task = prepare_node_error_handler_task(
1583
+ failed_task,
1584
+ handler_node_name=handler_node,
1585
+ failed_error=error,
1586
+ checkpoint=self.checkpoint,
1587
+ pending_writes=self.checkpoint_pending_writes,
1588
+ processes=self.nodes,
1589
+ channels=self.channels,
1590
+ managed=self.managed,
1591
+ config=failed_task.config,
1592
+ step=self.step,
1593
+ stop=self.stop,
1594
+ store=self.store,
1595
+ checkpointer=self.checkpointer,
1596
+ manager=self.manager,
1597
+ retry_policy=self.retry_policy,
1598
+ cache_policy=self.cache_policy,
1599
+ )
1600
+ if handler_task is None:
1601
+ return None
1602
+ self.tasks[handler_task.id] = handler_task
1603
+ if not self.is_replaying:
1604
+ self._reapply_writes_to_succeeded_nodes({handler_task.id: handler_task})
1605
+ for task in self.match_cached_writes():
1606
+ self.output_writes(task.id, task.writes, cached=True)
1607
+ return handler_task
1608
+
1609
+ def put_writes(self, task_id: str, writes: WritesT) -> None:
1610
+ """Put writes for a task, to be read by the next tick."""
1611
+ super().put_writes(task_id, writes)
1612
+ if not writes or self.cache is None or not hasattr(self, "tasks"):
1613
+ return
1614
+ task = self.tasks.get(task_id)
1615
+ if task is None or task.cache_key is None:
1616
+ return
1617
+ self.submit(
1618
+ self.cache.set,
1619
+ {
1620
+ (task.cache_key.ns, task.cache_key.key): (
1621
+ task.writes,
1622
+ task.cache_key.ttl,
1623
+ )
1624
+ },
1625
+ )
1626
+
1627
+ # context manager
1628
+
1629
+ def __enter__(self) -> Self:
1630
+ self._graph_lifecycle_events = deque()
1631
+ if not self.checkpointer:
1632
+ saved = None
1633
+ elif self.checkpoint_config[CONF].get(CONFIG_KEY_CHECKPOINT_ID):
1634
+ # Explicit checkpoint_id requested — fetch that exact checkpoint.
1635
+ # This covers both normal replay and subgraphs resolved via
1636
+ # checkpoint_map during time-travel.
1637
+ saved = self.checkpointer.get_tuple(self.checkpoint_config)
1638
+ elif replay_state := self.config[CONF].get(CONFIG_KEY_REPLAY_STATE):
1639
+ # Subgraph replay: the parent is replaying and passed us a
1640
+ # replay_state with its checkpoint_id. Look up our checkpoint
1641
+ # from the parent's checkpoint_map instead of fetching latest.
1642
+ saved = replay_state.get_checkpoint(
1643
+ self.config[CONF].get(CONFIG_KEY_CHECKPOINT_NS, ""),
1644
+ self.checkpointer,
1645
+ self.checkpoint_config,
1646
+ )
1647
+ # Clear RESUMING so _first re-applies input instead of resuming.
1648
+ # This recreates ephemeral routing channels so nodes trigger
1649
+ # naturally via version comparison.
1650
+ self.config[CONF].pop(CONFIG_KEY_RESUMING, None)
1651
+ else:
1652
+ # Normal case: fetch the most recent checkpoint for this
1653
+ # graph/thread. Returns None on first invocation.
1654
+ saved = self.checkpointer.get_tuple(self.checkpoint_config)
1655
+
1656
+ # Capture before the synthetic-empty fallback below overwrites `saved`.
1657
+ # `_put_exit_delta_writes` uses this on first run (no persisted parent)
1658
+ # to lazy-create a stub instead of anchoring delta writes on a parent.
1659
+ self._has_persisted_parent = saved is not None
1660
+ if saved is None:
1661
+ saved = CheckpointTuple(
1662
+ self.checkpoint_config, empty_checkpoint(), {"step": -2}, None, []
1663
+ )
1664
+ elif self._migrate_checkpoint is not None:
1665
+ self._migrate_checkpoint(saved.checkpoint)
1666
+ self.checkpoint_config = {
1667
+ **self.checkpoint_config,
1668
+ **saved.config,
1669
+ CONF: {
1670
+ CONFIG_KEY_CHECKPOINT_NS: "",
1671
+ **self.checkpoint_config.get(CONF, {}),
1672
+ **saved.config.get(CONF, {}),
1673
+ },
1674
+ }
1675
+ self._initial_checkpoint_config = self.checkpoint_config
1676
+ self.prev_checkpoint_config = saved.parent_config
1677
+ self.checkpoint_id_saved = saved.checkpoint["id"]
1678
+ self.checkpoint = saved.checkpoint
1679
+ self.checkpoint_metadata = saved.metadata
1680
+ self.checkpoint_pending_writes = (
1681
+ [(str(tid), k, v) for tid, k, v in saved.pending_writes]
1682
+ if saved.pending_writes is not None
1683
+ else []
1684
+ )
1685
+ self._delta_write_futs = []
1686
+ self._error_handler_write_futs = []
1687
+ self._delta_channels_with_overwrite = set()
1688
+ self._exit_delta_writes = (
1689
+ [] if self.durability == "exit" and self.checkpointer is not None else None
1690
+ )
1691
+ self.submit = self.stack.enter_context(BackgroundExecutor(self.config))
1692
+ self.channels, self.managed = channels_from_checkpoint(
1693
+ self.specs,
1694
+ self.checkpoint,
1695
+ saver=self.checkpointer,
1696
+ config=self.checkpoint_config,
1697
+ )
1698
+ self.stack.push(self._suppress_interrupt)
1699
+ self.status = "input"
1700
+ self.step = self.checkpoint_metadata["step"] + 1
1701
+ self.stop = self.step + self.config["recursion_limit"] + 1
1702
+ self.checkpoint_previous_versions = self.checkpoint["channel_versions"].copy()
1703
+ self.updated_channels = self._first(
1704
+ input_keys=self.input_keys,
1705
+ updated_channels=set(self.checkpoint.get("updated_channels")) # type: ignore[arg-type]
1706
+ if self.checkpoint.get("updated_channels")
1707
+ else None,
1708
+ )
1709
+
1710
+ return self
1711
+
1712
+ def __exit__(
1713
+ self,
1714
+ exc_type: type[BaseException] | None,
1715
+ exc_value: BaseException | None,
1716
+ traceback: TracebackType | None,
1717
+ ) -> bool | None:
1718
+ # unwind stack
1719
+ return self.stack.__exit__(exc_type, exc_value, traceback)
1720
+
1721
+
1722
+ class AsyncPregelLoop(PregelLoop, AbstractAsyncContextManager):
1723
+ def __init__(
1724
+ self,
1725
+ input: Any | None,
1726
+ *,
1727
+ stream: StreamProtocol | None,
1728
+ config: RunnableConfig,
1729
+ store: BaseStore | None,
1730
+ cache: BaseCache | None,
1731
+ checkpointer: BaseCheckpointSaver | None,
1732
+ nodes: Mapping[str, PregelNode],
1733
+ specs: Mapping[str, BaseChannel | ManagedValueSpec],
1734
+ trigger_to_nodes: Mapping[str, Sequence[str]],
1735
+ durability: Durability,
1736
+ interrupt_after: All | Sequence[str] = EMPTY_SEQ,
1737
+ interrupt_before: All | Sequence[str] = EMPTY_SEQ,
1738
+ manager: None | AsyncParentRunManager | ParentRunManager = None,
1739
+ input_keys: str | Sequence[str] = EMPTY_SEQ,
1740
+ output_keys: str | Sequence[str] = EMPTY_SEQ,
1741
+ stream_keys: str | Sequence[str] = EMPTY_SEQ,
1742
+ migrate_checkpoint: Callable[[Checkpoint], None] | None = None,
1743
+ retry_policy: Sequence[RetryPolicy] = (),
1744
+ cache_policy: CachePolicy | None = None,
1745
+ has_graph_lifecycle_callbacks: bool = False,
1746
+ ) -> None:
1747
+ super().__init__(
1748
+ input,
1749
+ stream=stream,
1750
+ config=config,
1751
+ checkpointer=checkpointer,
1752
+ cache=cache,
1753
+ store=store,
1754
+ nodes=nodes,
1755
+ specs=specs,
1756
+ input_keys=input_keys,
1757
+ output_keys=output_keys,
1758
+ stream_keys=stream_keys,
1759
+ interrupt_after=interrupt_after,
1760
+ interrupt_before=interrupt_before,
1761
+ manager=manager,
1762
+ migrate_checkpoint=migrate_checkpoint,
1763
+ trigger_to_nodes=trigger_to_nodes,
1764
+ retry_policy=retry_policy,
1765
+ cache_policy=cache_policy,
1766
+ durability=durability,
1767
+ has_graph_lifecycle_callbacks=has_graph_lifecycle_callbacks,
1768
+ )
1769
+ self.stack = AsyncExitStack()
1770
+ if checkpointer:
1771
+ self.checkpointer_get_next_version = checkpointer.get_next_version
1772
+ self.checkpointer_put_writes = checkpointer.aput_writes
1773
+ self.checkpointer_put_writes_accepts_task_path = (
1774
+ signature(checkpointer.aput_writes).parameters.get("task_path")
1775
+ is not None
1776
+ )
1777
+ else:
1778
+ self.checkpointer_get_next_version = increment
1779
+ self._checkpointer_put_after_previous = None # type: ignore[assignment]
1780
+ self.checkpointer_put_writes = None
1781
+ self.checkpointer_put_writes_accepts_task_path = False
1782
+
1783
+ async def _checkpointer_put_after_previous(
1784
+ self,
1785
+ prev: asyncio.Task | None,
1786
+ config: RunnableConfig,
1787
+ checkpoint: Checkpoint,
1788
+ metadata: CheckpointMetadata,
1789
+ new_versions: ChannelVersions,
1790
+ ) -> RunnableConfig:
1791
+ # Drain DeltaChannel write futures before committing the checkpoint so
1792
+ # ancestor walks never see a checkpoint without its backing writes.
1793
+ if self._delta_write_futs:
1794
+ futs, self._delta_write_futs = self._delta_write_futs, []
1795
+ await asyncio.gather(*futs)
1796
+ try:
1797
+ if prev is not None:
1798
+ await prev
1799
+ finally:
1800
+ await cast(BaseCheckpointSaver, self.checkpointer).aput(
1801
+ config, checkpoint, metadata, new_versions
1802
+ )
1803
+
1804
+ async def amatch_cached_writes(self) -> Sequence[PregelExecutableTask]:
1805
+ if self.cache is None:
1806
+ return []
1807
+ matched: list[PregelExecutableTask] = []
1808
+ if cached := {
1809
+ (t.cache_key.ns, t.cache_key.key): t
1810
+ for t in self.tasks.values()
1811
+ if t.cache_key and not t.writes
1812
+ }:
1813
+ for key, values in (await self.cache.aget(tuple(cached))).items():
1814
+ task = cached[key]
1815
+ task.writes.extend(values)
1816
+ matched.append(task)
1817
+ return matched
1818
+
1819
+ async def aaccept_push(
1820
+ self, task: PregelExecutableTask, write_idx: int, call: Call | None = None
1821
+ ) -> PregelExecutableTask | None:
1822
+ if pushed := super().accept_push(task, write_idx, call):
1823
+ for task in await self.amatch_cached_writes():
1824
+ self.output_writes(task.id, task.writes, cached=True)
1825
+ return pushed
1826
+
1827
+ async def aschedule_error_handler(
1828
+ self, failed_task: PregelExecutableTask, error: BaseException
1829
+ ) -> PregelExecutableTask | None:
1830
+ handler_node = self.nodes[failed_task.name].error_handler_node
1831
+ if not handler_node:
1832
+ return None
1833
+ # ensure error + ERROR_SOURCE_NODE writes are durable before handler runs
1834
+ if self._error_handler_write_futs:
1835
+ futs, self._error_handler_write_futs = self._error_handler_write_futs, []
1836
+ await asyncio.gather(*futs)
1837
+ handler_task = prepare_node_error_handler_task(
1838
+ failed_task,
1839
+ handler_node_name=handler_node,
1840
+ failed_error=error,
1841
+ checkpoint=self.checkpoint,
1842
+ pending_writes=self.checkpoint_pending_writes,
1843
+ processes=self.nodes,
1844
+ channels=self.channels,
1845
+ managed=self.managed,
1846
+ config=failed_task.config,
1847
+ step=self.step,
1848
+ stop=self.stop,
1849
+ store=self.store,
1850
+ checkpointer=self.checkpointer,
1851
+ manager=self.manager,
1852
+ retry_policy=self.retry_policy,
1853
+ cache_policy=self.cache_policy,
1854
+ )
1855
+ if handler_task is None:
1856
+ return None
1857
+ self.tasks[handler_task.id] = handler_task
1858
+ if not self.is_replaying:
1859
+ self._reapply_writes_to_succeeded_nodes({handler_task.id: handler_task})
1860
+ for task in await self.amatch_cached_writes():
1861
+ self.output_writes(task.id, task.writes, cached=True)
1862
+ return handler_task
1863
+
1864
+ def put_writes(self, task_id: str, writes: WritesT) -> None:
1865
+ """Put writes for a task, to be read by the next tick."""
1866
+ super().put_writes(task_id, writes)
1867
+ if not writes or self.cache is None or not hasattr(self, "tasks"):
1868
+ return
1869
+ task = self.tasks.get(task_id)
1870
+ if task is None or task.cache_key is None:
1871
+ return
1872
+ if writes[0][0] in (INTERRUPT, ERROR):
1873
+ # only cache successful tasks
1874
+ return
1875
+ self.submit(
1876
+ self.cache.aset,
1877
+ {
1878
+ (task.cache_key.ns, task.cache_key.key): (
1879
+ task.writes,
1880
+ task.cache_key.ttl,
1881
+ )
1882
+ },
1883
+ )
1884
+
1885
+ # context manager
1886
+
1887
+ async def __aenter__(self) -> Self:
1888
+ self._graph_lifecycle_events = deque()
1889
+ if not self.checkpointer:
1890
+ saved = None
1891
+ elif self.checkpoint_config[CONF].get(CONFIG_KEY_CHECKPOINT_ID):
1892
+ # Explicit checkpoint_id requested — fetch that exact checkpoint.
1893
+ # This covers both normal replay and subgraphs resolved via
1894
+ # checkpoint_map during time-travel.
1895
+ saved = await self.checkpointer.aget_tuple(self.checkpoint_config)
1896
+ elif replay_state := self.config[CONF].get(CONFIG_KEY_REPLAY_STATE):
1897
+ # Subgraph replay: the parent is replaying and passed us a
1898
+ # replay_state with its checkpoint_id. Look up our checkpoint
1899
+ # from the parent's checkpoint_map instead of fetching latest.
1900
+ saved = await replay_state.aget_checkpoint(
1901
+ self.config[CONF].get(CONFIG_KEY_CHECKPOINT_NS, ""),
1902
+ self.checkpointer,
1903
+ self.checkpoint_config,
1904
+ )
1905
+ # Clear RESUMING so _first re-applies input instead of resuming.
1906
+ # This recreates ephemeral routing channels so nodes trigger
1907
+ # naturally via version comparison.
1908
+ self.config[CONF].pop(CONFIG_KEY_RESUMING, None)
1909
+ else:
1910
+ # Normal case: fetch the most recent checkpoint for this
1911
+ # graph/thread. Returns None on first invocation.
1912
+ saved = await self.checkpointer.aget_tuple(self.checkpoint_config)
1913
+
1914
+ # Capture before the synthetic-empty fallback below overwrites `saved`.
1915
+ # `_put_exit_delta_writes` uses this on first run (no persisted parent)
1916
+ # to lazy-create a stub instead of anchoring delta writes on a parent.
1917
+ self._has_persisted_parent = saved is not None
1918
+ if saved is None:
1919
+ saved = CheckpointTuple(
1920
+ self.checkpoint_config, empty_checkpoint(), {"step": -2}, None, []
1921
+ )
1922
+ elif self._migrate_checkpoint is not None:
1923
+ self._migrate_checkpoint(saved.checkpoint)
1924
+ self.checkpoint_config = {
1925
+ **self.checkpoint_config,
1926
+ **saved.config,
1927
+ CONF: {
1928
+ CONFIG_KEY_CHECKPOINT_NS: "",
1929
+ **self.checkpoint_config.get(CONF, {}),
1930
+ **saved.config.get(CONF, {}),
1931
+ },
1932
+ }
1933
+ self._initial_checkpoint_config = self.checkpoint_config
1934
+ self.prev_checkpoint_config = saved.parent_config
1935
+ self.checkpoint_id_saved = saved.checkpoint["id"]
1936
+ self.checkpoint = saved.checkpoint
1937
+ self.checkpoint_metadata = saved.metadata
1938
+ self.checkpoint_pending_writes = (
1939
+ [(str(tid), k, v) for tid, k, v in saved.pending_writes]
1940
+ if saved.pending_writes is not None
1941
+ else []
1942
+ )
1943
+ self._delta_write_futs = []
1944
+ self._error_handler_write_futs = []
1945
+ self._delta_channels_with_overwrite = set()
1946
+ self._exit_delta_writes = (
1947
+ [] if self.durability == "exit" and self.checkpointer is not None else None
1948
+ )
1949
+ self.submit = await self.stack.enter_async_context(
1950
+ AsyncBackgroundExecutor(self.config)
1951
+ )
1952
+ self.channels, self.managed = await achannels_from_checkpoint(
1953
+ self.specs,
1954
+ self.checkpoint,
1955
+ saver=self.checkpointer,
1956
+ config=self.checkpoint_config,
1957
+ )
1958
+ self.stack.push(self._suppress_interrupt)
1959
+ self.status = "input"
1960
+ self.step = self.checkpoint_metadata["step"] + 1
1961
+ self.stop = self.step + self.config["recursion_limit"] + 1
1962
+ self.checkpoint_previous_versions = self.checkpoint["channel_versions"].copy()
1963
+ self.updated_channels = self._first(
1964
+ input_keys=self.input_keys,
1965
+ updated_channels=set(self.checkpoint.get("updated_channels")) # type: ignore[arg-type]
1966
+ if self.checkpoint.get("updated_channels")
1967
+ else None,
1968
+ )
1969
+
1970
+ return self
1971
+
1972
+ async def __aexit__(
1973
+ self,
1974
+ exc_type: type[BaseException] | None,
1975
+ exc_value: BaseException | None,
1976
+ traceback: TracebackType | None,
1977
+ ) -> bool | None:
1978
+ # unwind stack
1979
+ exit_task = asyncio.create_task(
1980
+ self.stack.__aexit__(exc_type, exc_value, traceback)
1981
+ )
1982
+ try:
1983
+ return await exit_task
1984
+ except asyncio.CancelledError as e:
1985
+ # Bubble up the exit task upon cancellation to permit the API
1986
+ # consumer to await it before e.g., reusing the DB connection.
1987
+ e.args = (*e.args, exit_task)
1988
+ raise