Skip to content

Workflow: a node that returns None runs again when the workflow resumes after RequestInput #7666

Description

@AkshayShah03

🔴 Required Information

Describe the Bug:
A workflow node that finishes without an output, route or state change runs a second time when the workflow resumes after a RequestInput. If that node has a side effect (sending an email, charging a card, writing a record), the side effect happens twice.

docs/guides/workflow/workflow/index.md says a resumed workflow "rebuilds what already ran from the session history, so completed nodes do not run a second time", and dynamic_nodes/index.md recommends moving side effects into ctx.run_node children because completed children are replayed rather than re-executed. A node that returns None and changes no state is not covered:

  • _NodeRunner._flush_output_and_deltas (src/google/adk/workflow/_node_runner.py) returns without emitting an event when there is no output, route or delta. So the session has no record that the node ran.
  • On resume, Workflow looks up the node's recorded execution (loop_state.recovered_executions), finds none, and starts the node fresh. The "static nodes with no outcome (e.g. return None) should be fast-forwarded" branch in check_interception (workflow/utils/_replay_interceptor.py, Case 6) is never reached, because there is no recovered entry to pass to it.

The same happens for a dynamic child started with ctx.run_node that returns None: it runs again on resume, while a sibling that returns a value is replayed. It also happens with ResumabilityConfig(is_resumable=True).

Steps to Reproduce:

  1. pip install google-adk==2.11.0
  2. Run the snippet below. send_email returns nothing, then ask pauses for input, then the snippet answers it.

Expected Behavior:

after the first turn: ['email']
after answering:      ['email']

Observed Behavior:

after the first turn: ['email']
after answering:      ['email', 'email']

If send_email returns any value (e.g. return "sent"), it runs once, as expected.

Environment Details:

  • ADK Library Version: 2.11.0 (also reproduced on main at d324744)
  • Desktop OS: macOS
  • Python Version: 3.12.13

Model Information:

  • Are you using LiteLLM: No
  • Which model is being used: N/A (function nodes only)

🟡 Optional Information

Minimal Reproduction Code:

import asyncio

from google.adk.events._interrupts import extract_event_interrupt_ids
from google.adk.events.request_input import RequestInput
from google.adk.runners import Runner
from google.adk.sessions import InMemorySessionService
from google.adk.workflow import START, Workflow
from google.adk.workflow.utils._workflow_hitl_utils import create_request_input_response
from google.genai import types

emails_sent = []


def send_email(node_input):
  emails_sent.append("email")  # side effect, no return value


def ask(node_input):
  return RequestInput(message="Approve?")


def finish(node_input):
  return node_input


wf = Workflow(name="wf", edges=[(START, send_email, ask, finish)])


async def main():
  runner = Runner(app_name="app", node=wf, session_service=InMemorySessionService())
  session = await runner.session_service.create_session(app_name="app", user_id="u")

  async def run(message):
    events = []
    async for event in runner.run_async(
        user_id="u", session_id=session.id, new_message=message
    ):
      events.append(event)
    return events

  events = await run(types.UserContent("go"))
  request = next(e for e in events if extract_event_interrupt_ids(e))
  interrupt_id = next(iter(extract_event_interrupt_ids(request)))
  print("after the first turn:", emails_sent)

  await run(
      types.Content(
          role="user",
          parts=[create_request_input_response(interrupt_id, {"result": "yes"})],
      )
  )
  print("after answering:     ", emails_sent)


asyncio.run(main())

How often has this issue occurred?:

  • Always (100%)

Proposed fix (looking for guidance before I open a PR)

This changes what resume records, so I'd like to check the approach first.

  1. Record every completed node run (my preference). When a node finishes with nothing to flush, still emit a minimal completion event for it (no content, carrying its node_info path). Replay then finds a recovered entry with no output, and Case 6 fast-forwards it as the code already intends, for both static nodes and ctx.run_node children. The cost is one extra, empty event per such node in the session history, which UIs and event consumers would see. It could carry a marker so consumers can skip it.
  2. Infer completion from what came after. If a later node in the same run has recorded events, its static predecessors must have completed, so fast-forward them. This adds no events, but it only covers static nodes on a path to a recorded node. It doesn't cover parallel branches that finished without output or ctx.run_node children.
  3. Document it. retry_config/index.md already says this for retries ("A child that produced neither leaves nothing to replay and runs again on every attempt, so give a side-effecting node an output or a state change"). The same note could go in the workflow and dynamic-nodes guides for resume. This needs no code change, but the failure stays silent, and it's easy to hit with a node that only performs an action.

Questions:

  • Is an extra completion event per no-output node acceptable in the session history? If so, is there an existing event shape or marker you'd want it to use?
  • Or would you rather keep the history as it is and go with option 2 or 3?

Once a direction is agreed, I'll open a PR with regression tests for the static, parallel and ctx.run_node cases, resumable and non-resumable.

Activity

  1. xxwtiancai commented on Oct 22, 2026

    @xxwtiancai
    Contributor

    AI-assisted investigation for xxwtiancai. @AkshayShah03, I see your plan to implement the fix and that @sanketpatil06 is assigned. Would collaboration on offline regression tests and completion-replay coverage be useful? I won't duplicate the implementation.

    Static inspection supports the observation that _flush_output_and_deltas emits no event when there is no output, route, or delta; I haven't run the reproduction or tests yet. Dynamic replay and rehydration need checking together, so I wouldn't assume an empty event alone resolves every case.

    For the proposed minimal completion marker, how should replay distinguish successful completion with no output from an interrupted or failed execution? Once you agree on the event contract, I'd be happy to help test static nodes, parallel branches, and ctx.run_node children across RequestInput resume, with resumability both enabled and disabled.

    I'll wait for your preferred division of work before starting, since your proposed regression coverage already includes those cases.

  2. xxwtiancai commented on Oct 23, 2026

    @xxwtiancai
    Contributor

    @AkshayShah03 @sanketpatil06, with AI assistance I prepared a narrow local test case at 37b1ab3c for completion-event ownership. Six existing-behavior boundary checks passed, and a separate agent independently reran all six successfully.

    After Event JSON round-tripping, a descendant's empty event leaves an ancestor's failure intact and keeps it runnable through rehydration and replay. The checks cover full-path and direct-child grouping, including empty events with output_for metadata, with a direct ancestor event as the control. They do not reproduce or fix the missing-completion-event bug, and I have not run the Runner/RequestInput matrix.

    With you retaining the implementation and core static/parallel/ctx.run_node test matrix, would this boundary be useful as a test-only contribution, and where would you prefer it covered? I'll wait for agreement on that division before preparing an upstream change.

  3. AkshayShah03 commented on Oct 23, 2026

    @AkshayShah03
    ContributorAuthor

    Thanks @xxwtiancai. On distinguishing the cases: replay already tells them apart by what was recorded. A failed run carries an error_code (Case 3 in check_interception reruns it), and an interrupted one carries interrupt ids (Cases 2 and 5). A completion marker would carry neither, so it would land in Case 6 as a finished run. That said, the approach isn't settled yet. I'd like to wait for @sanketpatil06 / the maintainers to choose between the options before splitting up work or adding tests.

  4. sanketpatil06 commented on Oct 27, 2026

    @sanketpatil06

    Thanks @AkshayShah03, I reproduced this on google-adk==2.13.0: send_email runs twice after resume, and so does a ctx.run_node child that returns None.

    Option 1 sounds good, with a typed completion marker rather than an untyped empty event. Note that check_interception always re-runs non-static dynamic nodes that have no outcome (should_run = current_run is not None and not current_run.is_static). So ctx.run_node children also need an explicit completion flag from the scan, which could work like the existing finished_after_resume. Without it, they'll still run twice.

    Please go ahead with the PR and tests. @xxwtiancai's boundary tests are welcome too.

    Workaround for now: return a value or set a state key from side-effecting nodes.

  5. AkshayShah03 commented on Oct 27, 2026

    @AkshayShah03
    ContributorAuthor

    Thanks @sanketpatil06, I'm starting the PR with a typed completion marker for runs that finish without output, plus an explicit completion flag from the scan so ctx.run_node children are fast-forwarded too. Tests will cover sequential, parallel and ctx.run_node cases, with resumability on and off.

    @xxwtiancai your boundary tests would fit well as a follow-up on top of it. I'll link the PR here once it's up.

  6. xxwtiancai commented on Oct 27, 2026

    @xxwtiancai
    Contributor

    Thank you @sanketpatil06 for the decision and the should_run = current_run is not None and not current_run.is_static detail, and @AkshayShah03 for taking the implementation — I'll stay out of your way there.

    Happy to pick up the boundary tests as the follow-up once your PR is linked. The six checks I had prepared covered completion-event ownership under replay: a marker-only/empty descendant event leaving an ancestor's error_code failure intact and still runnable through rehydration and replay, full-path vs direct-child grouping, empty events carrying output_for metadata, and a direct-ancestor control. Since the typed completion marker changes what those events look like, I'll adapt them to the marker's actual shape rather than port them verbatim, and keep them compatible with your sequential/parallel/ctx.run_node matrix. Will link the follow-up PR here once yours is up.

  7. AkshayShah03 commented on Oct 27, 2026

    @AkshayShah03
    ContributorAuthor

    PR is up: #7771. It adds a typed NodeInfo.completed marker for runs that finish without leaving any other trace, and a completed flag on the scan state so check_interception replays such ctx.run_node children instead of rerunning them. One case is left out, noted in the PR: a ctx.run_node child that emits content but no output still reruns. @xxwtiancai your boundary tests can go on top of it.

  8. xxwtiancai commented on Oct 27, 2026

    @xxwtiancai
    Contributor

    Thanks @AkshayShah03 — reviewed #7771 and rebased the boundary tests on top of 604d0cc3: all invariants hold against the marker implementation, nothing to report back as a problem.

    The follow-up now carries twelve cases: the six ownership boundaries (a descendant's empty event leaves an ancestor's error_code intact and rerunnable through check_interception after a JSON round-trip, both scan groupings, output_for empty events, direct-ancestor control) plus six marker-specific ones: a descendant's marker does not complete an ancestor (mirroring the empty-event rule) in either grouping, a direct marker records completion after a round-trip, a later interrupt un-completes the run, a recorded completion is replayed instead of rerunning, and your documented gap — content without output still reruns — is pinned as a labeled characterization test that flips when the gap closes. Full tests/unittests/workflow/ on top of your branch: 1277 passed.

    Holding the branch locally until #7771 lands so the follow-up PR applies cleanly to main — will link it here right after.

  9. added a commit that references this issue on Nov 4, 2026
    d3f2698
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions