PregelRunner: in-superstep task executor
Responsibilities
PregelRunner does one thing: actually run the batch of PregelExecutableTask prepared by PregelLoop.tick, collect their writes, and hand them back to PregelLoop. It does not care what the graph looks like, does not care about the checkpoint, does not care how channels reduce — it only handles the three things "concurrent execution + failure handling + writes persisted".
Its position is very narrow: inside the main loop of Pregel.astream (sync main loop:2964-2984), right after while loop.tick() returns True, comes for _ in runner.tick([t for t in loop.tasks.values() if not t.writes], ...):, and after that finishes, loop.after_tick(). In other words, the runner's input is "the tasks computed by tick that have not yet written writes", and its output is "those tasks' writes are now in checkpoint_pending_writes" — with no second-layer buffer in between.
PregelRunner itself is very thin: two public methods tick (tick:176) and commit (commit:574), plus an async atick. The heavy lifting is all in the two low-level callers _call / _acall (_call:700) / (_acall:789) — they wrap the node function into a schedulable future, handle retry policy, sub-task Send fan-out, and streaming chunk callbacks, and ultimately write writes back to the task object.
Design motivation
Why is the runner a generator instead of a normal method?
- Streaming yield: the runner takes the generator form
for _ in runner.tick(...)(yield return type:188); each completed task yields once and hands control back to the upper layer, which can immediately forward streaming chunks to the client instead of waiting for the whole superstep to finish. This is the foundation of LangGraph streaming. - Single-task fast path: when
len(tasks) == 1 and timeout is None and get_waiter is None, a synchronous direct call is taken without spinning up a thread pool (fast path:203-254). Most single-branch agent scenarios hit this path, avoiding thread-pool overhead. commitas a Future callback:FuturesDict.on_done(on_done:116) is triggered when each future completes, and internally it isweakref.WeakMethod(self.commit)—commitis a per-task write-back hook, not a batch wrap-up. This means writes enterpending_writesin completion order, and the checkpoint can be written as early as possible.- Error handler routing:
_should_route_to_error_handler(_should_route_to_error_handler:171-174) checks whether a failed node has anerror_handlernode configured. On a hit, the exception id is added to_handled_exception_idsand a handler task is scheduled in place of the original task — so a failure does not immediately panic, but flows through another node for wrap-up. _should_stop_otherscancellation: when any task raises a non-GraphBubbleUpexception, the runner cancels all other in-flight tasks in the same batch (_should_stop_others:616-634) — enforcing the within-superstep semantics of "either all succeed, or enter the handler".GraphInterruptis explicitly excluded, because interrupts are not failures.
Key files
class PregelRunner:135-138— class definition; the docstring states its responsibility in one line: execute tasks, commit writes, yield control, interrupt other tasks when needed.__init__:140-169— holds weakrefs tosubmit/put_writes, thenode_error_handler_map, theschedule_error_handler/aschedule_error_handlercallbacks, and_handled_exception_ids— a cross-tick exception dedup set.FuturesDict:75-134— a custom dict subclass; theon_donecallback firescommitper future completion; theshould_stopfield holds the_should_stop_otherspartial.tick signature:176-188— takesIterable[PregelExecutableTask], returnsIterator[None]; the signature exposes the generator semantics.fast path:203-254— runs synchronously when single task + no timeout + no waiter; schedules an error handler on failure as needed.schedule tasks:259-276— multi-task path: each task gets a future, scheduled viaself.submit()(which is actuallyPregelLoop.submit).concurrent wait loop:282-323— aconcurrent.futures.wait(FIRST_COMPLETED)loop; each completed task triggers acommit, emits output, and schedules a handler task if needed.commit method:574-613— per-task wrap-up: cancelled writes ERROR,GraphInterruptwrites INTERRUPT, ordinary exception writesERROR+ERROR_SOURCE_NODE, normal completion writestask.writes+ aNO_WRITESmarker._should_stop_others:616-634— decides whether to cancel other tasks; explicitly excludesGraphBubbleUpand already-handled exceptions._panic_or_proceed:650-697— wrap-up function; cancels all in-flight futures, merges multipleGraphInterrupts into one, and raisesTimeoutErroron timeout._call:700-787— low-level wrapper for node calls: retry, stream chunk emit,Sendfan-out viaschedule_taskcallback, accumulatestask.writes._should_route_to_error_handler:171-174— returns True whentask.name in self.node_error_handler_map; error-handler nodes themselves cannot be routed (prevents recursion).
Data flow
The start of tick constructs a FuturesDict, an enhanced dict that automatically calls commit when each future completes:
def tick(
self,
tasks: Iterable[PregelExecutableTask],
*,
reraise: bool = True,
timeout: float | None = None,
retry_policy: Sequence[RetryPolicy] | None = None,
get_waiter: Callable[[], concurrent.futures.Future[None]] | None = None,
schedule_task: Callable[
[PregelExecutableTask, int, Call | None],
PregelExecutableTask | None,
],
) -> Iterator[None]:
tasks = tuple(tasks)
futures = FuturesDict(
callback=weakref.WeakMethod(self.commit),
event=threading.Event(),
should_stop=partial(
_should_stop_others, handled_exception_ids=self._handled_exception_ids
),
future_type=concurrent.futures.Future,
)
# give control back to the caller
yieldThis comes from tick head:176-199. Note that the first yield immediately hands control back — the upper layer's first iteration of for _ in runner.tick(...) is just "starting"; actual task execution happens in subsequent iterations. This is a simplified form of generator coroutine, avoiding the introduction of the async keyword into the sync path.
commit is the per-task wrap-up function that lands a task's writes into pending_writes:
def commit(
self,
task: PregelExecutableTask,
exception: BaseException | None,
) -> None:
if isinstance(exception, asyncio.CancelledError):
task.writes.append((ERROR, exception))
self.put_writes()(task.id, task.writes)
elif exception:
if isinstance(exception, GraphInterrupt):
if exception.args[0]:
writes = [(INTERRUPT, exception.args[0])]
if resumes := [w for w in task.writes if w[0] == RESUME]:
writes.extend(resumes)
self.put_writes()(task.id, writes)
elif isinstance(exception, GraphBubbleUp):
pass
else:
task.writes.append((ERROR, exception))
if self._should_route_to_error_handler(task) and not isinstance(
exception, GraphBubbleUp
):
task.writes.append((ERROR_SOURCE_NODE, task.name))
self._handled_exception_ids.add(id(exception))
self.put_writes()(task.id, task.writes)
else:
if self.node_finished and (
task.config is None or TAG_HIDDEN not in task.config.get("tags", [])
):
self.node_finished(task.name)
if not task.writes:
task.writes.append((NO_WRITES, None))
self.put_writes()(task.id, task.writes)This comes from commit:574-613. put_writes is a weakref to PregelLoop.put_writes (put_writes:415); it stuffs writes into checkpoint_pending_writes and starts a checkpointer future to persist them. Note several special writes:
CancelledError-> writes(ERROR, exception), so thatafter_tickwrap-up counts the task as "failed but recorded" without panicking.GraphInterrupt-> writes(INTERRUPT, ...)+ anyRESUMEwrites; the interrupt's resume value is persisted alongside the interrupt so the next resume reads them together.- Ordinary exception + configured error handler -> writes an additional
(ERROR_SOURCE_NODE, task.name)marker; this marker is scanned byPregelLoop._resume_error_handlers_if_applicable(ERROR_SOURCE_NODE scan:775) on resume to schedule the corresponding handler node. - Normal completion with no writes -> a
(NO_WRITES, None)marker is added, preventingprepare_next_tasksfrom treating it as "not yet run" (NO_WRITES marker:609-611).
The runner's execution flow can be drawn like this:
Boundaries and failure modes
_handled_exception_idspersists across ticks: this set in__init__is instance-level, not per-tick (_handled_exception_ids:169). Once an exception object id enters the set,_should_stop_otherswill no longer treat it as a failure, preventing the original exception from being re-raised after the error handler completes.GraphBubbleUpis not a failure:commitexplicitlypasses onGraphBubbleUpand writes nothing (GraphBubbleUp pass:592-594). Such exceptions (e.g.ParentCommand) are signals to the parent graph, not real errors.NO_WRITESprevents duplicate task execution: when a task completes normally but writes nothing, a(NO_WRITES, None)is still appended (NO_WRITES:609-611). Otherwise,prepare_next_tasksafterafter_tickwould see that this task has no writes, assume it never ran, and run it again on resume.schedule_error_handlermay return None: the handler node itself may not be configured or cannot be constructed, in which case the task takes the normal panic path (handler optional:230-248). TheERROR_SOURCE_NODEwrite incommitcoexists with this path;_resume_error_handlers_if_applicablewill retry scheduling on resume.- A timeout does not raise GraphInterrupt:
_panic_or_proceed'sif inflight: raise timeout_exc_cls("Timed out")(timeout:691-697) — a timeout is a real exception and bubbles out of Pregel, unlikeGraphInterruptwhich is swallowed. - Traceback trimming: before reraise, frames in the
EXCLUDED_FRAME_FNAMESlist are skipped (tb trim:241-247), filtering out langgraph internal stack frames so the traceback the user sees points directly at their node code.
Summary
PregelRunner is a thin shell: the generator tick schedules concurrent execution + per-task commit lands writes into pending_writes, with error-handler routing and cancellation in between. Understanding it means understanding "how tasks actually run within a superstep". See /pregel/loop for the loop driver on the upper layer, /pregel/algo for the three algorithm functions prepare_next_tasks / apply_writes / should_interrupt, /pregel/pregel for overall assembly, and /stream/run-stream for how streaming output is produced between runner yields.
See official docs: LangGraph docs · README.