Skip to content

Commit cecc1f9

Browse files
GWealecopybara-github
authored andcommitted
fix: emit resumability checkpoints from workflow graph nodes
In resumable mode the workflow node now records node-status checkpoint events and an end-of-agent marker as it runs, so a paused human-in-the-loop workflow can be resumed and its progress is visible on the event stream. Co-authored-by: George Weale <gweale@google.com> PiperOrigin-RevId: 949594038
1 parent fd006db commit cecc1f9

3 files changed

Lines changed: 152 additions & 101 deletions

File tree

src/google/adk/workflow/_workflow.py

Lines changed: 117 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -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,

tests/unittests/workflow/test_workflow_failures.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1102,10 +1102,12 @@ async def failing_node(ctx: Context):
11021102
original_handle_completion = Workflow._handle_completion
11031103
handle_completion_calls = []
11041104

1105-
def spy_handle_completion(self, loop_state, node_name, node_obj, child_ctx):
1105+
def spy_handle_completion(
1106+
self, loop_state, node_name, node_obj, child_ctx, ctx
1107+
):
11061108
handle_completion_calls.append(node_name)
11071109
return original_handle_completion(
1108-
self, loop_state, node_name, node_obj, child_ctx
1110+
self, loop_state, node_name, node_obj, child_ctx, ctx
11091111
)
11101112

11111113
app = App(name=request.function.__name__, root_agent=wf)

0 commit comments

Comments
 (0)