RiverbornBook Call

Why Nested AI Workflows Sometimes Forget How to Resume

Engineering9 min read

Nested human-in-the-loop workflows fail intermittently when an interrupt matches the wrong sub-workflow, or when a resume reads state before the pause write lands. How ADK 2.0, LangGraph, LangChain, CrewAI, AutoGen, and n8n handle both halves.

Why Nested AI Workflows Sometimes Forget How to Resume

Here's a different, trickier problem than "the agent restarted instead of resuming." This one is about workflows that resume most of the time, then randomly don't.

The setup: a workflow isn't flat. It has sub-workflows nested inside it — a top-level process that kicks off a smaller process, which itself pauses for a human to approve something. When that approval comes back, two things have to happen correctly. First, the system has to match the incoming approval to the exact nested sub-workflow it belongs to — not the parent, not a sibling sub-workflow, that one specifically. Second, whatever state the resume depends on has to already be saved by the time the resume tries to read it.

Get either one wrong and you get an intermittent failure. Not "broken every time," which would have been easy to catch. Broken sometimes, depending on timing and depending on which nested workflow happened to be resuming. That combination — a matching problem plus a timing problem, stacked on top of each other — is what makes this bug category so much harder to pin down than a simple "we forgot to check for active state" bug. You have to isolate the two failures separately before either one makes sense.

We hit both halves while migrating our sales agent system onto ADK 2.0 Workflow graphs, where nested domain workflows pause for human approval and resume via prefixed interrupt IDs. Below is how six frameworks handle both halves of this: identifying which nested unit an interrupt belongs to, and guaranteeing that a write finishes before a resume reads it.

1. Google ADK 2.0

This is close to the actual bug. ADK matches a resuming interrupt back to its workflow using prefixed keys, RESUME_INTERRUPT_PREFIXES. For a nested sub-workflow, the prefix has to encode which parent it belongs to, not just which domain. If a sub-workflow's prefix doesn't carry that parent context correctly, an interrupt can resolve to the wrong workflow, or to none, depending on what else happens to match.

RESUME_INTERRUPT_PREFIXES = {
    "email_approval": "domain.email.",
    "gtm_review": "domain.email.gtm.",   # nested under email, prefix must reflect that
}

def match_interrupt_to_workflow(interrupt_id: str) -> str | None:
    for workflow_name, prefix in RESUME_INTERRUPT_PREFIXES.items():
        if interrupt_id.startswith(prefix):
            return workflow_name
    return None  # silent mismatch: the bug, before the fix

The second half, the write race, is separate: the session write that persists the paused state and the resume read that depends on it were not guaranteed to be ordered. A resume could fire before the write it needed had landed. Fixing it meant making the write complete, and be confirmed, before the interrupt is considered resumable, rather than firing both concurrently and hoping the write won the race.

2. LangGraph

LangGraph doesn't use prefixes for this. Subgraphs and their parent share the same thread_id but are distinguished by checkpoint_ns, a namespace that encodes exactly which subgraph (and which invocation of it, if it runs more than once) a given checkpoint belongs to. That namespace is the direct equivalent of ADK's prefix, and the same class of bug is possible if two nested subgraphs end up sharing a namespace, or if a namespace isn't propagated correctly when a subgraph is invoked more than once in a loop.

# Subgraph checkpoints are addressed by (thread_id, checkpoint_ns), not just thread_id.
# This is what lets nested interrupts resolve to the exact nested subgraph.
config = {
    "configurable": {
        "thread_id": session_id,
        # checkpoint_ns is set internally by the runtime when a subgraph node
        # is invoked, but a hand-rolled dynamic subgraph (e.g. built in a loop)
        # can end up reusing a namespace if you're not careful with how it's derived
    }
}

The write race is handled differently too. Because the checkpointer write happens synchronously as part of a superstep before the graph considers that step complete, there's no separate window where a resume can read stale state — the read is always against the last committed checkpoint. That said, this guarantee has visible edges: resuming multiple interrupts inside parallel subgraphs in a single call has been a genuinely open problem in LangGraph's own issue tracker, where a resume value meant for one interrupt gets applied to the wrong one. Nested and parallel interrupts are still the sharp corner of this framework, not a solved case.

3. LangChain (without LangGraph)

There's nothing to compare here, and that's the point worth making. Plain LangChain has no concept of a nested, resumable unit at all, so there's no prefix system and no write-ordering guarantee to get right or wrong. If you're hand-rolling nested pause points in LangChain, you're also hand-rolling both halves of this bug from scratch, with none of the scaffolding ADK or LangGraph give you by default.

4. CrewAI (Flows)

CrewAI's @human_feedback pause is built for a flow-level pause, not explicitly for a sub-flow nested inside another flow's step. Composing a Crew inside a Flow is well supported, but pausing and resuming a nested Flow — one Flow's step containing another Flow instance that itself needs to pause for a human — isn't a first-class pattern the way top-level flow pausing is. In practice, teams flatten it: instead of a true nested flow, the "nested" step is a Crew that raises its own feedback pending, and the resume target is a flow_id, which is unique per flow instance, not per node within it.

class ParentFlow(Flow):
    @start()
    def kickoff_review(self):
        # Rather than nesting a second Flow, this delegates to a Crew,
        # which is the pattern CrewAI actually supports well.
        self.state["draft"] = ReviewCrew().kickoff(inputs=self.state)

    @listen(kickoff_review)
    @human_feedback(prompt="Approve nested review output?")
    def approve(self, feedback):
        return feedback

Because there's no sub-flow-level address to get wrong, CrewAI mostly sidesteps the "matching an interrupt to the wrong nested unit" failure mode. It doesn't remove the write-ordering half though. State is auto-persisted the instant HumanFeedbackPending is raised, but that's still a write that has to complete before a webhook-triggered resume() call reads it back — the same race, just with fewer nesting levels for it to hide in.

5. AutoGen

Nesting here is explicit: a SocietyOfMindAgent wraps an entire inner team, and that inner team can itself contain another SocietyOfMindAgent. save_state() walks that structure recursively and returns a nested dictionary keyed by agent name at every level. As of v0.4.9, that key changed from an internal agent ID to the agent's name specifically so state would be portable across differently-configured runtimes, which is a tacit admission that ID-based matching was fragile here. If two nested agents anywhere in the tree end up with the same name, or a name changes between when state was saved and when it's loaded, load_state() will attach state to the wrong agent, or to none, silently.

state = await outer_team.save_state()
# {"agent_states": {"society_of_mind_1": {"agent_states": {"agent1": ..., "agent2": ...}}, "assistant3": ...}}

# Loading depends entirely on names matching at every nesting level.
# A renamed inner agent, or a duplicate name across nested teams,
# reproduces the same "wrong parent" mismatch as a bad prefix.
await outer_team.load_state(state)

The write race is sharper here than anywhere else on this list: save_state() raises outright if the team is still running. There's no soft race to lose — AutoGen just refuses the write, which pushes the ordering problem onto the caller to get right (stop the run cleanly, then save, then later reload before the next run) rather than the framework absorbing it for you.

6. n8n

Nesting in n8n means a workflow that calls a sub-workflow via Execute Sub-workflow, and that sub-workflow itself contains a Wait node. Before n8n v2.0, this was genuinely broken in the way this bug category describes: when the sub-workflow hit its Wait node, the parent didn't wait for it — it received the sub-workflow's input data back immediately and moved on, carrying stale state while the human step was still unresolved underneath it. Any node after the Wait in the sub-workflow ran later, orphaned from a parent that had already finished.

That's a matching failure in spirit even without a "prefix": the parent had no way to identify "my sub-workflow call is still pending on a nested pause" versus "my sub-workflow call finished," so it treated both the same way. n8n v2.0 fixed this at the engine level — the parent now genuinely waits for the sub-workflow's Wait node to resolve before continuing. If you're on an older version and gating anything sensitive through a nested sub-workflow's Wait node, that old failure mode is worth checking for directly.

Comparison

ADK 2.0
How nested resume targets are identified
RESUME_INTERRUPT_PREFIXES, string prefix per (nested) workflow
What guarantees the write lands before the read
Nothing by default — has to be enforced explicitly
LangGraph
How nested resume targets are identified
(thread_id, checkpoint_ns) pair, namespace per subgraph invocation
What guarantees the write lands before the read
Checkpoint write is part of the superstep itself, but parallel/nested interrupt resume has known open bugs
LangChain (no LangGraph)
How nested resume targets are identified
No concept of this
What guarantees the write lands before the read
No concept of this
CrewAI (Flows)
How nested resume targets are identified
Sidestepped: nesting is usually a Crew inside a Flow, not Flow-in-Flow, addressed by flow_id
What guarantees the write lands before the read
Auto-persist on HumanFeedbackPending, but still a write your resume handler depends on completing first
AutoGen
How nested resume targets are identified
Agent name, recursively, at every nesting level
What guarantees the write lands before the read
save_state() refuses to run while the team is active — no race, but no help resuming mid-run either
n8n
How nested resume targets are identified
Historically broken for nested Wait nodes pre-v2.0 — parent didn't track pending state at all
What guarantees the write lands before the read
Fixed at the engine level in v2.0 — parent now waits for the sub-workflow's pause to resolve

What actually differs

Every framework on this list has some version of a name, ID, or namespace that has to correctly identify which nested paused unit an incoming resume belongs to, and every one of them has a documented way that identity can drift or collide: a bad prefix in ADK, a shared checkpoint namespace in LangGraph, a duplicate agent name in AutoGen, a parent that couldn't tell "pending" from "done" in old n8n. None of them make this fully foolproof for arbitrarily deep nesting — it gets harder, not easier, the more layers you add, in every framework here.

The write-ordering half is where they diverge more. AutoGen refuses to let you get it wrong by blocking the write outright. LangGraph builds the write into the same step that would otherwise let you read stale data. ADK and CrewAI persist automatically but still leave you responsible for treating "saved" and "safe to resume" as two separate facts. Plain LangChain gives you neither guarantee, because it was never built to know what a paused nested unit is in the first place.

If nested human-in-the-loop workflows are a core requirement, not an edge case, LangGraph's namespace-per-subgraph model is the closest thing to a systematic answer here, even with its own open issues around parallel interrupts. Everywhere else, this is a spot worth adding a test for specifically — don't assume passing tests on a single-level workflow say anything about a nested one.

Moyenul Islam

Moyenul Islam

Lead Backend Engineer

Moyenul leads backend engineering at Riverborn, architecting the agent orchestration, session, and infrastructure layers behind our production AI systems. He works across the stack from database schema to runtime orchestration, with a focus on systems that hold up under real production load rather than just demo conditions.

Published by Moyenul Islam

Ready to ship production-grade AI?

Free. 30 minutes. No prep required.