@@ -102,6 +102,14 @@ class _LoopState(DynamicNodeState):
102102 pending_tasks : dict [str , asyncio .Task [Context ]] = field (default_factory = dict )
103103 """Running static node tasks."""
104104
105+ replayed_nodes : set [str ] = field (default_factory = set )
106+ """Names of nodes whose in-flight run is a replayed history fast-forward.
107+
108+ A node is added when it is scheduled as a no-op replay and removed when
109+ that run completes, so completion handling can tell a genuine execution
110+ from a fast-forward when deciding whether to emit a checkpoint.
111+ """
112+
105113 trigger_buffer : dict [str , list [Trigger ]] = field (default_factory = dict )
106114 """Queued triggers waiting to be dispatched, keyed by target node name.
107115
@@ -259,6 +267,11 @@ async def _run_impl(
259267 if self ._has_terminal_output (loop_state ):
260268 ctx ._output_delegated = True
261269 self ._finalize (loop_state , ctx )
270+
271+ # On a clean finish (no pending interrupts), record an end-of-agent marker
272+ # so a resumable session can tell the workflow ran to completion.
273+ if not loop_state .interrupt_ids :
274+ await self ._emit_end_of_agent (ctx )
262275 return
263276 yield # required to keep _run_impl as async generator
264277
@@ -278,7 +291,7 @@ async def _run_loop(self, loop_state: _LoopState, ctx: Context) -> None:
278291 }
279292
280293 while True :
281- self ._schedule_ready_nodes (loop_state , ctx )
294+ await self ._schedule_ready_nodes (loop_state , ctx )
282295
283296 if not loop_state .pending_tasks :
284297 break
@@ -340,7 +353,7 @@ def get_recovered_sequence_index(t):
340353 ctx ._error_node_path = child_ctx .error_node_path
341354 error_to_raise = child_ctx .error
342355 else :
343- self ._handle_completion (loop_state , name , node , child_ctx )
356+ await self ._handle_completion (loop_state , name , node , child_ctx , ctx )
344357
345358 if error_to_raise :
346359 loop_state .error_shut_down = True
@@ -389,7 +402,9 @@ def _has_waiting_task_agent(self, loop_state: _LoopState) -> bool:
389402 return True
390403 return False
391404
392- def _schedule_ready_nodes (self , loop_state : _LoopState , ctx : Context ) -> None :
405+ async def _schedule_ready_nodes (
406+ self , loop_state : _LoopState , ctx : Context
407+ ) -> None :
393408 """Pop triggers from buffer and schedule ready nodes."""
394409 if self ._has_waiting_task_agent (loop_state ):
395410 return
@@ -421,7 +436,11 @@ def _schedule_ready_nodes(self, loop_state: _LoopState, ctx: Context) -> None:
421436 continue
422437
423438 self ._prepare_node_state_for_starting (loop_state , node_name , trigger )
424- self ._start_node_task (loop_state , ctx , node_name , trigger )
439+ started = self ._start_node_task (loop_state , ctx , node_name , trigger )
440+ # Only a genuinely-executing node marks a new step; a replayed node
441+ # being fast-forwarded from history should not re-announce itself.
442+ if started :
443+ await self ._emit_node_checkpoint (loop_state , ctx )
425444
426445 def _at_concurrency_limit (self , loop_state : _LoopState ) -> bool :
427446 """Check if max_concurrency has been reached."""
@@ -519,8 +538,12 @@ def _start_node_task(
519538 ctx : Context ,
520539 node_name : str ,
521540 trigger : Trigger ,
522- ) -> None :
523- """Start asyncio task for scheduling and executing a node."""
541+ ) -> bool :
542+ """Start asyncio task for scheduling and executing a node.
543+
544+ Returns True if the node was scheduled for real execution, or False if
545+ it was fast-forwarded from recovered history (a replayed no-op run).
546+ """
524547
525548 assert self .graph is not None
526549
@@ -561,14 +584,17 @@ def _start_node_task(
561584 ancestors = ancestors ,
562585 branch = recovered .branch ,
563586 )
587+ # Mark this as a replayed no-op run so completion handling does not
588+ # emit a fresh checkpoint for a node that only fast-forwarded history.
589+ loop_state .replayed_nodes .add (node_name )
564590
565591 async def return_ctx ():
566592 if loop_state .sequence_barrier :
567593 await loop_state .sequence_barrier .wait (key )
568594 return mock_ctx
569595
570596 loop_state .pending_tasks [node_name ] = asyncio .create_task (return_ctx ())
571- return
597+ return False
572598
573599 node_state .resume_inputs = result .resume_inputs or {}
574600
@@ -603,29 +629,103 @@ async def return_ctx():
603629 skip_run_id_validation = True ,
604630 )
605631 )
632+ return True
606633
607634 def _make_schedule_dynamic_node (
608635 self , loop_state : _LoopState
609636 ) -> ScheduleDynamicNode :
610637 """Create a DynamicNodeScheduler for this Workflow's loop state."""
611638 return DynamicNodeScheduler (state = loop_state )
612639
640+ # --- Resumability checkpoints ---
641+
642+ async def _emit_node_checkpoint (
643+ self , loop_state : _LoopState , ctx : Context
644+ ) -> None :
645+ """Record a snapshot of node statuses on the resumable event stream.
646+
647+ A resumable session persists these so a later run can see how far the
648+ workflow progressed. Non-resumable sessions reconstruct the same state
649+ by replaying prior events, so the snapshot is skipped for them.
650+ """
651+ ic = ctx ._invocation_context
652+ if not ic .is_resumable :
653+ return
654+ from ..events .event import Event
655+ from ..events .event_actions import EventActions
656+
657+ nodes = {
658+ name : node_state .model_dump (
659+ mode = "json" , include = {"status" , "interrupts" , "resume_inputs" }
660+ )
661+ for name , node_state in loop_state .nodes .items ()
662+ }
663+ await ic ._enqueue_event (
664+ Event (
665+ invocation_id = ic .invocation_id ,
666+ author = self .name ,
667+ branch = ic .branch ,
668+ actions = EventActions (agent_state = {"nodes" : nodes }),
669+ )
670+ )
671+
672+ async def _maybe_reemit_replayed_output (
673+ self , child_ctx : Context , ctx : Context
674+ ) -> None :
675+ """Re-surface a fast-forwarded node's output on a resumable stream."""
676+ ic = ctx ._invocation_context
677+ if not ic .is_resumable or child_ctx .output is None :
678+ return
679+ from ..events .event import Event
680+ from ..events .event import NodeInfo
681+
682+ await ic ._enqueue_event (
683+ Event (
684+ invocation_id = ic .invocation_id ,
685+ author = self .name ,
686+ branch = ic .branch ,
687+ output = child_ctx .output ,
688+ node_info = NodeInfo (path = child_ctx .node_path ),
689+ )
690+ )
691+
692+ async def _emit_end_of_agent (self , ctx : Context ) -> None :
693+ """Record an end-of-agent marker for resumable sessions."""
694+ ic = ctx ._invocation_context
695+ if not ic .is_resumable :
696+ return
697+ from ..events .event import Event
698+ from ..events .event_actions import EventActions
699+
700+ await ic ._enqueue_event (
701+ Event (
702+ invocation_id = ic .invocation_id ,
703+ author = self .name ,
704+ branch = ic .branch ,
705+ actions = EventActions (end_of_agent = True ),
706+ )
707+ )
708+
613709 # --- Completion handling ---
614710
615- def _handle_completion (
711+ async def _handle_completion (
616712 self ,
617713 loop_state : _LoopState ,
618714 node_name : str ,
619715 node : BaseNode ,
620716 child_ctx : Context ,
717+ ctx : Context ,
621718 ) -> None :
622719 """Update state and trigger downstream after node completes."""
623720 node_state = loop_state .nodes [node_name ]
721+ replayed = node_name in loop_state .replayed_nodes
722+ loop_state .replayed_nodes .discard (node_name )
624723
625724 if child_ctx .interrupt_ids :
626725 node_state .status = NodeStatus .WAITING
627726 node_state .interrupts = list (child_ctx .interrupt_ids )
628727 loop_state .interrupt_ids .update (child_ctx .interrupt_ids )
728+ await self ._emit_node_checkpoint (loop_state , ctx )
629729 return
630730
631731 if (
@@ -634,6 +734,7 @@ def _handle_completion(
634734 and child_ctx .route is None
635735 ):
636736 node_state .status = NodeStatus .WAITING
737+ await self ._emit_node_checkpoint (loop_state , ctx )
637738 return
638739
639740 node_state .status = NodeStatus .COMPLETED
@@ -645,6 +746,14 @@ def _handle_completion(
645746 child_ctx ._invocation_context .branch or ""
646747 )
647748
749+ # A genuine execution records a checkpoint on completion. A replayed
750+ # fast-forward instead re-surfaces its recovered output so a resumable
751+ # stream stays complete, without a redundant checkpoint.
752+ if not replayed :
753+ await self ._emit_node_checkpoint (loop_state , ctx )
754+ else :
755+ await self ._maybe_reemit_replayed_output (child_ctx , ctx )
756+
648757 # Buffer downstream triggers.
649758 self ._buffer_downstream_triggers (
650759 loop_state ,
0 commit comments