Why AI Agents Restart Instead of Resuming (and How to Fix It in Six Frameworks)
A multi-agent coordinator that re-classifies every message will restart mid-flow workflows the moment a human pauses them. The same fix — an active-workflow marker checked before routing — across ADK 2.0, LangGraph, LangChain, CrewAI, AutoGen, and n8n.
Table Of Contents
Here's the problem. In a multi-agent system, a coordinator agent looks at each incoming message and routes it to the right workflow — support, sales, onboarding, whatever the domains are. That routing step is easy for the first message in a conversation.
It breaks the moment a workflow is already in progress and waiting on a human, an approval, a form, a confirmation — anything with a pause built in. If the user sends a follow-up message while that workflow is sitting mid-flow, a naive coordinator has no way to tell "this session is already partway through something" from "this is a brand new request." So it does what it always does: classifies the message from scratch and restarts the whole workflow, throwing away whatever progress and context existed a moment ago.
We hit this hard while migrating our sales agent system onto ADK 2.0 Workflow graphs. The classifier was correct. The domain workflows were correct. The failure was upstream of both: nothing told the coordinator that a session was already mid-flow.
The fix is to give the coordinator a memory of what's already active for that session, checked before any classification runs.
Across every framework, that fix takes the same shape: persist an explicit "active workflow" marker per session, check it before doing any classification, and only fall through to fresh routing when nothing is active. What differs is where that marker lives and how "pause and wait for a human" is expressed natively.
Below is that same pattern in ADK 2.0, LangGraph, LangChain, CrewAI, AutoGen, and n8n, followed by a comparison table and some honest tradeoffs.
1. Google ADK 2.0
ADK 2.0's Workflow Runtime is graph-based, and state lives in the session object, addressed by key. The fix here was literally introducing a dedicated state key and checking it first.
from google.adk.events import Event
ACTIVE_DOMAIN_STATE_KEY = "active_domain"
class DomainResult:
def __init__(self, domain: str, workflow_run_id: str):
self.domain = domain
self.workflow_run_id = workflow_run_id
async def coordinator_node(session, message):
active = session.state.get(ACTIVE_DOMAIN_STATE_KEY)
if active:
# A workflow is already mid-flow for this session. Resume it,
# don't re-classify the incoming message.
return await resume_domain_workflow(session, active, message)
# No active domain: run the single-turn classifier and start fresh.
domain = await classify_domain(message)
session.state[ACTIVE_DOMAIN_STATE_KEY] = DomainResult(
domain=domain, workflow_run_id=session.id
)
return await start_domain_workflow(session, domain, message)
When a workflow hits a NodeInterruptedError for a HITL step, the interrupt itself doesn't clear ACTIVE_DOMAIN_STATE_KEY. Only the workflow completing (or being explicitly cancelled) clears it. That's the whole fix: the state key is the source of truth for "are we mid-flow," and it's checked before routing logic ever runs.
2. LangGraph
LangGraph's answer to this is closer to a first-class primitive than a pattern you build yourself. Every graph run is scoped to a thread_id, and a checkpointer snapshots state after every node. When a node calls interrupt(), execution pauses and the checkpoint holds everything needed to resume — no separate "active domain" flag required, because resuming a thread is resuming the graph itself.
from langgraph.graph import StateGraph, START
from langgraph.types import interrupt, Command
from langgraph.checkpoint.postgres import PostgresSaver
def coordinator(state):
# If we're resuming into a node that already exists on this thread,
# LangGraph routes back into it automatically via the checkpoint.
# This node only runs for a thread with no prior checkpoint activity.
domain = classify_domain(state["messages"][-1])
return {"active_domain": domain}
def approval_gate(state):
decision = interrupt({"question": "Approve this action?", "payload": state["draft"]})
return {"approved": decision == "approve"}
graph = StateGraph(State)
graph.add_node("coordinator", coordinator)
graph.add_node("approval_gate", approval_gate)
graph.add_edge(START, "coordinator")
graph.add_edge("coordinator", "approval_gate")
app = graph.compile(checkpointer=PostgresSaver.from_conn_string(DB_URL))
# First message
app.invoke({"messages": [msg]}, config={"configurable": {"thread_id": session_id}})
# Follow-up message while paused at approval_gate: resumed via Command,
# never re-enters "coordinator"
app.invoke(Command(resume="approve"), config={"configurable": {"thread_id": session_id}})
The difference from ADK worth naming: in ADK we had to build the "is this session mid-flow" check explicitly. In LangGraph, the checkpointer plus thread_id plus interrupt() give you that for free, because the framework's model of a "conversation" already is a resumable checkpoint chain. The tradeoff is that you're now trusting LangGraph's checkpoint/interrupt machinery instead of your own state key, and debugging a stuck thread means reading checkpoint history instead of one field.
3. LangChain (without LangGraph)
Worth stating plainly: plain LangChain (LCEL chains, or the now-deprecated AgentExecutor) does not have a native answer to this problem. It has no built-in concept of "pause a chain mid-execution and resume days later." The common pattern was to store conversation state externally with RunnableWithMessageHistory or a session-keyed memory store, and hand-roll the "is there an active workflow" check yourself — similar to the ADK approach, but without a graph runtime underneath it.
from langchain_core.runnables.history import RunnableWithMessageHistory
active_workflows = {} # session_id -> workflow name, needs a real DB in production
def route(session_id: str, message: str):
if session_id in active_workflows:
workflow = active_workflows[session_id]
return resume_workflow(workflow, session_id, message)
domain = classifier_chain.invoke({"input": message})
active_workflows[session_id] = domain
return start_workflow(domain, session_id, message)
This works, but every piece of "resume where we left off" logic is yours to write and yours to get wrong. As of LangChain 1.0, this is also no longer really the recommended path: LangChain's own create_agent runs on the LangGraph runtime underneath, and the LangChain team's stated position is that anything needing loops, durable state, or human-in-the-loop should be on LangGraph, with LangChain supplying the model, tool, and integration layer on top. In other words, this isn't really "LangChain vs LangGraph" — it's "LangChain for the parts that don't need this problem solved, LangGraph for the part that does."
4. CrewAI (Flows)
CrewAI's Crew abstraction is stateless by default, so this pattern lives in Flows, which carry a shared state object across steps and can persist it. The @human_feedback decorator raises a HumanFeedbackPending result when it needs a human, and a resume() call continues the same flow instance rather than starting a new one.
from crewai.flow.flow import Flow, start, listen, persist
from crewai import human_feedback
class SupportFlow(Flow):
@start()
def coordinator(self):
# Only reached on a fresh flow kickoff for this session_id.
# A flow already paused on human_feedback resumes directly
# into the review step below, never back through here.
self.state["domain"] = classify_domain(self.state["message"])
@listen(coordinator)
@human_feedback(prompt="Approve this draft?")
def review(self, feedback):
return route_to_domain(self.state["domain"], feedback)
flow = SupportFlow()
result = flow.kickoff(inputs={"message": incoming_message, "session_id": session_id})
# result is a HumanFeedbackPending if it's waiting on a human.
# The webhook handler that receives the human's answer calls:
flow.resume(feedback_id=result.feedback_id, feedback="approve")
State is saved automatically the moment HumanFeedbackPending is raised (SQLite by default, swappable for a real backend), so a session_id-keyed lookup of "which flow instance is paused" is what stands in for ADK's ACTIVE_DOMAIN_STATE_KEY. One documented gotcha worth flagging: put @persist on a single terminal step rather than the whole Flow class, because class-level persistence saves after every method and load_state picks up the latest row, which can be a mid-run snapshot that misses updates from the same turn.
5. AutoGen (AgentChat, 0.4+)
AutoGen's teams (RoundRobinGroupChat, SelectorGroupChat, etc.) expose save_state() and load_state() on the whole team object. There's no built-in "active domain" concept, so the same session-keyed check has to be written by hand, then wired to loading the right team state before running.
team_states = {} # session_id -> saved team state, needs real storage in production
async def handle_message(session_id: str, message: str):
if session_id in team_states:
team = build_team()
await team.load_state(team_states[session_id])
result = await team.run(task=message)
else:
domain = await classify_domain(message)
team = build_team_for_domain(domain)
result = await team.run(task=message)
team_states[session_id] = await team.save_state()
return result
The catch that shows up constantly in AutoGen's own issue tracker: save_state() raises if the team is still running, so this only works cleanly for a request/response cycle, not a long-held-open pause. For an approval gate that might sit open for hours, the practical pattern is to end the run at the approval point (via a termination condition), save state, and treat the human's later reply as a fresh run() after load_state(), rather than expecting AutoGen to hold a paused coroutine alive. Worth noting for anyone picking a framework today: Microsoft has said newer strategic investment is going into the broader Microsoft Agent Framework, with AutoGen itself now mostly in maintenance mode.
6. n8n
n8n solves the "pause and wait for a human" half with the Wait node, which offloads the execution to the database and gives you a resumeUrl to call later. What it doesn't give you for free is routing a new incoming message to the correct paused execution among many concurrent sessions — that part is on you, same as everywhere else.
The workable pattern: a lookup table (a database or n8n's execution data) mapping session_id to the resumeUrl of that session's paused execution.
- Incoming message webhook fires first, always.
- It looks up
session_idin the state table. - If a
resumeUrlis stored and still valid, it calls that URL with the new message, which resumes the paused workflow at the Wait node rather than starting a new execution. - If nothing is stored, it starts a fresh execution, classifies the domain, and when that execution later hits a Wait node (an approval step), it writes its own
resumeUrlback into the same table before pausing.
// Code node, in the "new message" entry workflow
const state = await getSessionState(session_id); // your DB lookup
if (state && state.resumeUrl) {
await $http.post(state.resumeUrl, { body: { message } });
return; // resumed the existing paused workflow, no re-classification
}
// no active workflow: let this execution continue into the classifier node
return { session_id, message, fresh: true };
n8n v2.0 also fixed a real footgun here: sub-workflows containing a Wait node used to hand control back to the parent immediately, with the parent carrying stale state instead of waiting for the human step to actually resolve. If you're on an older version and gating anything sensitive (payments, outbound email) through a sub-workflow's Wait node, that's worth checking before you trust it.
Comparison
ACTIVE_DOMAIN_STATE_KEY in session state, checked manuallyNodeInterruptedError + RequestInputthread_idinterrupt() / Command(resume=...)@human_feedback raises HumanFeedbackPending, resume()save_state() / load_state()resumeUrl mapping, stored yourself| Framework | Native "mid-flow" primitive | Where the session-active check lives | HITL pause mechanism |
|---|---|---|---|
| ADK 2.0 | None built in | ACTIVE_DOMAIN_STATE_KEY in session state, checked manually | NodeInterruptedError + RequestInput |
| LangGraph | Yes — checkpoint chain per thread_id | Implicit: resuming a thread re-enters the graph at its last checkpoint | interrupt() / Command(resume=...) |
| LangChain (no LangGraph) | None | Hand-rolled session store, same shape as ADK | None native — roll your own or drop to LangGraph |
| CrewAI (Flows) | Partial: flow state persists, but lookup is manual | session_id → paused flow instance mapping | @human_feedback raises HumanFeedbackPending, resume() |
| AutoGen | None | Hand-rolled session store keyed to save_state() / load_state() | Termination condition + reload, not a true live pause |
| n8n | Partial: Wait node handles the pause | session_id → resumeUrl mapping, stored yourself | Wait node (webhook, form, or timer resume) |
What actually differs
Only LangGraph treats "is this conversation mid-flow" as something the framework already knows, because its unit of execution is a resumable thread rather than a stateless call-in-call-out. Everywhere else — ADK, plain LangChain, CrewAI, AutoGen, and n8n — the fix is structurally the same one we landed on in the ADK migration: a session-keyed marker, checked before any classification happens, cleared only when the workflow actually finishes.
The frameworks differ in how well they hand you the pause-and-resume half of that. LangGraph and n8n's Wait node are genuinely first-class. CrewAI's @human_feedback is close. ADK, AutoGen, and plain LangChain leave more of it to you. But none of them except LangGraph remove the need for the state key itself.
If the goal is minimizing custom plumbing for exactly this pattern, LangGraph is the framework built around not needing it. If the goal is staying inside an ecosystem you already have reasons to use — ADK for Vertex AI deployment, n8n for existing automation, CrewAI for role-based crews — the fix is a day of work, not a framework migration.

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.