How to partially stop the langgraph workflow

Hello, I am building a computational agent, the agent shold wait some long taskes. I want the workflow partially pause to wait the task, the remain part of the workflow executes normally. The interrupt() function of langgraph simply stop the whole graph.

Besides, are there data race and deadlock problems when the data is modified by multiple graph.ainvoke()? I have no idea about it, but I am sure that the data of a task will only be modified by ONLY ONE function at any time.

import time 
import random
import operator
from pydantic import BaseModel
from typing import List
from typing_extensions import Annotated

from langgraph.graph import END, START, StateGraph

class MyState(BaseModel):
    # Each List[str, int] is a state of a task
    # str is id, int is data
    data:Annotated[List[List[str, int]], operator.add] = []

# A virtual long task
# In the real program, there should be a callback function
# When the task is done, callback function is invoked to modify
# the state of task. But I also don't know how to do this
async def long_task(t:float)->int:
    time.sleep(t)
    return -int(random.random()*100)

# For each node, in fact I want something like a runtime state
# to identify which task is under processing. But I don't know how
# to pass this *runtime state*.
def node0(state:MyState):
    # So I just get the last entry. It is highly likely to be wrong
    last_value = state[-1][1]
    if last_value > 0:
        return "node1"
    else:
        return "node_long_task"

def node1(state:MyState):
    time.sleep(0.5)
    # Add the int of current task by +1
    # But I don't know how to get the id of current task

def node2(state:MyState):
    time.sleep(0.5)
    # Add the int of current task by +1
    # But I don't know how to get the id of current task

async def node_long_task(state:MyState):
    v = await long_task()
    # Update the data of the current task
    # And goto node2
    # In fact, what I really want is something like 'partially interrupt'
    # That is, a part of the workflow is paused due to waiting for tasks, 
    # while the other parts are executing normally. 
    # When the results are returned, the paused part continues to execute.

graph = StateGraph(MyState)

graph.add_node("node1", node1)
graph.add_node("node2", node2)
graph.add_node("node_long_task", node_long_task)

graph.add_conditional_edges(START, node0)
graph.add_edge("node1", "node2")
graph.add_edge("node_long_task", "node2")
graph.add_edge("node2", END)
graph.compile()

Hey @MSJavaScript!

Would using something like await / @task be what you’re looking for?

I think what’s tripping you up is that interrupt() pauses a run, and a run is LangGraph’s unit of work. You can’t park one node and let the rest of that same run carry on.

What I think you’re after is many tasks in flight at once, each waiting on its own slow work without holding up the others. That’s one run per task, with @task for the slow part:

import asyncio
import random
import time
from typing import Annotated, Literal

from pydantic import BaseModel

from langgraph.checkpoint.memory import InMemorySaver
from langgraph.func import task
from langgraph.graph import END, START, StateGraph


# One run handles one task, so the id is just a field. Every node can read
# state.task_id instead of guessing which entry of a list is the current one.
class TaskState(BaseModel):
    task_id: str
    value: int = 0
    steps: Annotated[list[str], lambda a, b: a + b] = []


# @task hands back a future. Calling it starts the work but does not block,
# so you can await it later, wherever you actually need the result.
@task
async def long_task(task_id: str, seconds: float) -> int:
    # asyncio.to_thread runs blocking code on a worker thread. Calling a bare
    # time.sleep() here would freeze the event loop and stall every other run.
    await asyncio.to_thread(time.sleep, seconds)
    return -random.randint(1, 100)


@task
async def cheap_work(task_id: str) -> str:
    await asyncio.sleep(0.1)
    return f"cheap({task_id})"


def route_start(state: TaskState) -> Literal["node1", "node_long_task"]:
    return "node1" if state.value > 0 else "node_long_task"


async def node_long_task(state: TaskState):
    pending = long_task(state.task_id, seconds=1.0)  # starts now
    cheap = await cheap_work(state.task_id)          # runs while it's in flight
    value = await pending                            # collect the result here

    return {"value": value, "steps": [cheap, f"long_task={value}"]}


async def node1(state: TaskState):
    await asyncio.sleep(0.05)
    return {"value": state.value + 1, "steps": ["node1"]}


async def node2(state: TaskState):
    await asyncio.sleep(0.05)
    return {"value": state.value + 1, "steps": ["node2"]}


builder = StateGraph(TaskState)
builder.add_node("node1", node1)
builder.add_node("node2", node2)
builder.add_node("node_long_task", node_long_task)

builder.add_conditional_edges(START, route_start, ["node1", "node_long_task"])
builder.add_edge("node1", "node2")
builder.add_edge("node_long_task", "node2")
builder.add_edge("node2", END)

# A checkpointer lets each run resume where it stopped, and gives @task results
# somewhere to live so a resumed run skips work that already finished.
graph = builder.compile(checkpointer=InMemorySaver())


async def main() -> None:
    task_ids = [f"task-{i}" for i in range(5)]

    started = time.perf_counter()
    # Each task gets its own thread_id, so the runs never share a checkpoint.
    results = await asyncio.gather(
        *[
            graph.ainvoke(
                {"task_id": tid},
                config={"configurable": {"thread_id": tid}},
            )
            for tid in task_ids
        ]
    )
    elapsed = time.perf_counter() - started

    print(f"{len(task_ids)} tasks, 1.0s of slow work each, {elapsed:.2f}s total\n")
    for r in results:
        print(f"  {r['task_id']}  value={r['value']:>4}  {' -> '.join(r['steps'])}")


asyncio.run(main())

Five seconds of slow work finishing in one, and cheap_work completing while long_task was still running.

Hope that helps!

hi,

@dariel.datoon’s answer is the right mental model: there is no partial interrupt() inside one run, and “many long tasks in flight” means one run / thread_id per task, with @task when you want overlapping work inside a node.

A few additions.

1. Your second question: races / deadlocks with multiple ainvoke()

Different thread_ids (what Dariel showed): safe for graph state. Each run has its own checkpoint lineage, so concurrent asyncio.gather(...) does not race on the same thread state. Your rule (“only one function modifies a given task”) maps cleanly onto “only that task’s thread runs that graph.”

Same thread_id concurrently: don’t. Two overlapping runs write to the same checkpoint lineage; behavior is undefined and you can get conflicting or clobbered writes. Serialize invokes per thread (queue them).

Parallel nodes inside one run: LangGraph merges channel updates with reducers. Use something like Annotated[list, operator.add] (or a custom reducer) for fan-in. Without a reducer you don’t get silent last-write-wins - two nodes writing the same key in one superstep raise InvalidUpdateError: Can receive only one value per step (INVALID_CONCURRENT_GRAPH_UPDATE). Across different supersteps, last write wins. Your operator.add on data is already the right instinct.

Shared BaseStore across threads: that is shared memory. Key by task_id and treat consistency as your problem (you don’t get free distributed locking).

Deadlocks: LangGraph won’t invent classic lock cycles across threads. App-level deadlocks are possible if thread A is parked waiting for callback X, and the only code that can emit X is stuck waiting on A. Keep the wait graph acyclic: external systems push Command(resume=...), they don’t call back into a blocked same-thread invoke.

Also: in async nodes prefer asyncio.sleep / asyncio.to_thread(...) over bare time.sleep. Blocking the event loop stalls every concurrent ainvoke in that process, which looks like a “global pause” even when threads are separate.

2. External callback (what your comments describe)

If the long work lives outside the process (queue worker, GPU job, webhook), don’t hold an open await for an indefinite wait. Park that task’s thread with interrupt(), then resume from the callback:

import asyncio

from langgraph.types import Command, interrupt

async def node_long_task(state: TaskState):
    job_id = f"job-{state.task_id}"
    # start_external_job(job_id, ...)  # must run before interrupt; see notes below

    result = interrupt({"task_id": state.task_id, "job_id": job_id})
    return {"value": int(result), "steps": [f"resumed:{result}"]}


# webhook / callback handler - other tasks keep running on their own threads
async def on_job_finished(task_id: str, result: int):
    # Don't await the whole remaining run inside the HTTP handler.
    # Hand resume to a background worker and return 200 immediately.
    asyncio.create_task(
        graph.ainvoke(
            Command(resume=result),
            config={"configurable": {"thread_id": task_id}},
        )
    )

Notes that matter in practice:

  • You need a checkpointer (durable in production) and a stable thread_id per task - same as Dariel’s setup.
  • On resume, the node restarts from the top. The docs’ recommended fixes are: make the “start job” call idempotent (use job_id as an idempotency key), or move it into its own node after the interrupt. Wrapping it in @task also works - a completed task’s result is replayed from the checkpoint rather than recomputed - but a task that started and didn’t finish can still re-run.
  • Don’t await the resume inside the HTTP handler itself - ainvoke(Command(resume=...)) runs until the next interrupt or END. Hand it to a background task/worker and return 200 immediately.
  • Make the callback tolerant of duplicate delivery; a second Command(resume=...) on a thread with no pending interrupt won’t do what you want.
  • Other tasks are unaffected because they are other runs.

Docs: Interrupts, Persistence.

3. If you really want one parent run + fan-out

Send can pass a per-task payload (including task_id) into parallel workers - useful for map-reduce style fan-out/fan-in:

from langgraph.types import Send

def continue_to_tasks(state: ParentState):
    return [Send("process_one", {"task_id": t, "value": 0}) for t in state.task_ids]

Docs: Map-reduce and the Send API.

Caveat: parallel nodes in the same superstep can run concurrently, but this is not “pause branch A forever while the rest of the same run keeps advancing.” For indefinite external waits, stick with Dariel’s one-thread-per-task model + interrupt() / Command(resume=...).

4. Tiny fixes to the sketch

Independent of the design:

  • Put task_id (and that task’s fields) in state - don’t infer “current task” from state[-1] on an append-only list.
  • For things that aren’t per-task state (DB handles, user id), use context_schema + runtime: Runtime[Context], or read config["configurable"] via an injected config: RunnableConfig.
  • Nodes must return updates, e.g. return {"value": state.value + 1}.
  • Assign the compiled graph: graph = builder.compile(checkpointer=...).

Add: serialize per thread_id, park external waits with interrupt() and resume from the webhook (off the request path), and use Send only for map-reduce - not as a substitute for per-task threads.

Thank you for your detailed reply. I need to spend some time to study them. AI also given me some code, but I haven’t understood them yet. After I work out a runnable example, I’ll put it here.

In my previous sketch, I want to use the same thread_id to maintain the memory. For example, the planner agent makes a plan for calculating the adsorption energy of carbon dioxide (CO2) on nickel (Ni) surface, there are severl steps:

  1. Preparing the inputs files of VASP (a software) calculation, for CO2, Ni and CO2 + Ni.
  2. Submit the three jobs to a job manager, the manager will submit the jobs to a slurm queue. When they are done, results are collected and returned by a HTTP request.
  3. These three tasks will complete in different time. I will preform some post-processing to the returned files, e.g. read the data and correct the data. Also, I don’t want the agent stop, it should do some following calculations which is related to the adsorption energy task.

So, I my case, the tasks should live in the same thread_id, so that the agent can analyse the result.

hi @MSJavaScript

Yes - for this use case, keep one thread_id. That’s your planner/session memory.

The earlier “one thread per task” advice was for independent runs, not an adsorption-energy plan that must analyse all VASP results together.

What’s possible to to do instead:

  1. On that thread: prepare inputs → submit the 3 Slurm jobs → fan-out 3 wait branches (Send), each calling interrupt() with its job_id.
  2. When an HTTP callback arrives, resume only that job’s interrupt:
    Command(resume={interrupt_id: {"files": ...}})
    (other waits stay parked, staggered completion is fine).
  3. Post-process in that branch, then fan-in → analyse on the same thread.
  4. Still serialize webhooks per thread_id (no concurrent ainvoke on the same thread).

“Don’t stop the agent” here means: the session is durably paused, not discarded. Each callback wakes one branch. Follow-up work that needs the energies runs after the join (or in parallel branches that don’t wait). While waiting, that same run cannot advance past the waits.

Docs: multiple interrupts.

Hi, I have another design issue. In real program, the TaskGroup will depend on each other, that is, the topology of workflow is nonlinear not just a list of TaskGroup. For example, the figure below. Should I still use one thread per TaskGroup ? To main the memory, I should store all the thread_ids of a session and given them to the LLM ?

hi @MSJavaScript

does it mean C-group depends on B-group? Where is the dependency located? D-group needs to wait for the B and C? Can D run twice?

Maybe something like this would help (defer argument):

do not map TaskGroups onto threads. A thread_id is a memory boundary, not a scheduling or dependency primitive. Your figure is a graph, and LangGraph is a graph engine: TaskGroups become nodes or subgraphs, arrows become edges, and B3 back into A2 is an ordinary cycle. Keep one thread per session, as in the previous post, and put the topology in the graph. Threads give you nothing here and cost you the joins: D1 waits for C2 and A3 would become your own cross-thread coordinator polling two checkpoint lineages, which is a hand-rolled version of what the superstep machinery already does.

Three things about this specific shape will bite you. I verified all of them on langgraph 1.2.9.

1. Your D1 will run twice.

D1 has two incoming arrows and the branches have very different lengths, because the A branch loops through B. Written the obvious way with two separate add_edge calls into d1, stream_mode=updates gives:

a1, c1, a2, c2, b1, [d1, d2], b2, b3, a2, a3, [d1, d2]

D1 and D2 execute twice: once the moment the short C branch lands, again after the A/B loop finishes. In your case the adsorption energy analysis would run once on a partial result set. Nothing raises. Two fixes:

builder.add_edge(["a3", "c2"], "d1")     # all-of join: wait for BOTH
# or
builder.add_node("d1", d1, defer=True)   # run only when nothing else is pending

Both give a3, d1, d2 - D1 once, after everything. The list form is documented in add_edge: when multiple start nodes are provided, the graph waits for ALL of them. defer=True is documented for exactly your case, branches of different lengths.

One difference matters for a planner-generated topology: if a branch can be skipped by a conditional edge, the list-form join never fires and D1 silently never runs, while defer=True still runs it once. I would use defer.

The cycle is native: a conditional edge out of A2 chooses b1 or a3, and b3 edges back to a2. Loops are bounded by recursion_limit, which counts supersteps, default 1000 since 1.0.6.

2. A refinement to what I said earlier about parked branches.

Staggered completion is fine for delivery of callbacks, but not for execution. While any interrupt is pending, the whole run is parked, including branches that have nothing to do with it. Probe with A parked on interrupt and an independent C1 to C2 chain:

after first invoke : ['c1']            # c1 ran, c2 did NOT
next               : ('a_wait',)
after resume       : ['a_wait->RESUMED', 'c1', 'a_post', 'c2']

c1 ran only because it was already scheduled in the same superstep. Supersteps are global barriers and interrupt stops the loop at the barrier. Same with three parked jobs plus per-branch post-processing: each callback is accepted individually and nothing is lost, but the post-processing node runs once, after the last resume.

For the adsorption energy plan this is usually acceptable, since you need all three energies before the analysis anyway. Just do not design expecting the C branch to keep computing during a 6 hour Slurm wait. In one run it will not.

3. When a second thread is actually right.

Rule: one thread per session, which is the memory and analysis unit, and one run per independently schedulable unit of work. Only split if some work must genuinely progress while jobs are parked. Then the session thread holds the planner and the analysis, each independent group is its own run started by a coordinator when its dependencies are satisfied, and group results go to a shared store rather than into another thread checkpoint. That is strictly more machinery, since the coordinator now owns the dependency graph LangGraph would otherwise own.

4. Storing all thread_ids and giving them to the LLM.

No. A thread_id is an opaque handle, so a list of them in the prompt gives the model nothing it can act on; it only becomes useful if you also give a tool that resolves it, and then the handle belongs inside the tool, not in the prompt. And checkpoints are execution state, not knowledge: the checkpointer stores graph state snapshots scoped to one thread, the store holds application data across threads. Do this instead:

await runtime.store.aput(
    (session_id, "tasks"), task_id,
    {"group": "A", "job_id": job_id, "energy_eV": -23.41, "files": [...]},
)

@tool
async def get_task_result(task_id: str) -> dict:
    """Return the collected/corrected result of a finished calculation task."""
    item = await store.aget((session_id, "tasks"), task_id)
    return item.value if item else {"status": "pending"}

If you do split into per-group runs, keep the id table in session state as your own bookkeeping and let the tool resolve it. Also keep VASP payloads out of the message history: a summary plus a handle in messages, files in the store or on disk.

5. Practical notes on the callbacks.

A resume map is mandatory once more than one interrupt is pending; a bare Command(resume=value) raises RuntimeError telling you to specify the interrupt id. Capture the job_id to interrupt.id mapping once at submit time and persist it: the ids are stable across the node re-execution, but after resuming one of three, get_state(…).interrupts still listed all three in my run while .next correctly shrank, so do not re-derive the map on every webhook. On resume the node restarts from the top, so make submission idempotent with job_id as the key, or move it into its own node before the wait. Serialize resumes per thread; on Agent Server multitask_strategy defaults to enqueue, which does this for you. Use a durable checkpointer and asyncio.to_thread for the VASP file I/O, otherwise one blocking node stalls every run in the process.

Sketch for your figure:

builder = StateGraph(PlanState, context_schema=Context)
builder.add_node("d1", d1, defer=True)          # 1
...
builder.add_edge(START, "a1")
builder.add_edge(START, "c1")
builder.add_edge("a1", "a2")
builder.add_conditional_edges("a2", route_a2)   # 2 -> "b1" or "a3"
builder.add_edge("b1", "b2")
builder.add_edge("b2", "b3")
builder.add_edge("b3", "a2")                    # 3 cycle back into A2
builder.add_edge("c1", "c2")
builder.add_edge("a3", "d1")
builder.add_edge("c2", "d1")
builder.add_edge("d1", "d2")
graph = builder.compile(checkpointer=checkpointer, store=store)
  1. defer so D1 runs exactly once, after both branches, including when one is skipped.
  2. Routing decides loop into B again versus move on to A3; keep it a pure function of state.
  3. A plain cycle, bounded by recursion_limit in supersteps, not iterations.

Nodes that wait on Slurm call interrupt with the job_id; the HTTP callback hands Command(resume={interrupt_id: files}) to a background worker on the session thread and returns 200 immediately.

Docs: defer node execution and loops, multiple interrupts, checkpointer vs store, stores, subgraphs.

Maybe my question is confusing :joy:.
My agent has a planner-execution-checker structure. The graph is fixed, but the state is dynamic, it will grow just like messages. The planner generates TaskGroups, for a specific task in TaskGroup, it may be replanned to generate another TaskGroup. It’s just like

Planner generates function A1() -> A2() -> A3().
When run A2(), Planner generates B1() -> B2(). When B2() is done, return back to A2()

Here is an example code given by GLM-5.2, it is not perfect but will demonstrate what I want.

"""
A langgraph agent that waits for long *remote* jobs with a "partial pause",
keeps session memory across related TaskGroups, AND updates state
**incrementally per job** (not in one batch at the end).

Architecture
============
* Agent  (FastAPI, port 8000)
    GET  /                     -> the web frontend (index.html)
    POST /api/create           -> create a new TaskGroup (optionally in an
                                  existing session), kick off the graph
    GET  /api/stream/{tg_id}   -> SSE stream of progress messages for one group
    POST /cb/{job_id}          -> callback the Job Manager POSTs results back to
* Job Manager (FastAPI, port 8001)  -- a *separate* service, as the readme asks
    POST /submit               -> {job_id, rand_num, callback_url};
                                  sleeps rand_num seconds, computes rand_num+1,
                                  then POSTs {job_id, result} back to callback_url

Two-level memory (the readme's "maintain the memory")
=====================================================
Different thread_id per TaskGroup => partial pause (parked runs never block
other runs).  A shared InMemoryStore keyed by session_id => related TaskGroups
(same session, different thread_ids) read each other's completed results.

Why per-job interrupt (Send fan-out) instead of one barrier interrupt
======================================================================
An earlier version parked the WHOLE TaskGroup on a single interrupt() and only
wrote state back when EVERY job was done, stashing partial results in an
in-memory JobRegistry meanwhile.  Two things break that in a real program:

  1. Partial results were NOT in the checkpoint -- they lived in a volatile
     side-channel dict.  An agent crash mid-wait lost them all (the raw files
     survive on the FS, but the agent's "which jobs are done / their parsed
     results" view was gone).
  2. The barrier blocked per-job downstream: the readme wants "when step 2 of a
     reaction is done, that reaction goes to step 3" -- impossible if nothing
     proceeds until ALL step-2 jobs finish.

The LangChain team's fix (Forum_Disscussion.txt) is Send fan-out with ONE
interrupt() per job:

    START -> node_plan --(Send per long job)--> node_wait_job --interrupt()
                                                       |  (each branch parks
                                                       |   on its OWN interrupt)
            resume only job X's interrupt:             v
            Command(resume={interrupt_id: value})    node_collect (fan-in) -> END

When job X's /cb arrives we read the thread state, find X's pending interrupt
id, and resume ONLY that branch with Command(resume={id: value}).  X's branch
wakes immediately, does X's post-processing, and its result is CHECKPOINTED
right then (durable).  Other branches stay parked; staggered completion is
fine.  When the last branch finishes, the fan-in node_collect runs and
publishes the group's final_data to the session store.

So: each job's result is durable the moment it lands; per-job downstream can
start immediately; the "partial pause" is real *within* a plan, not just
across plans.  (For the demo's plain "sum at end" task this is more machinery
than strictly needed, but it is the shape the real DFT program requires.)
"""

from __future__ import annotations

import asyncio
import json
import operator
import random
import uuid
from typing import Annotated, List, Literal, Optional, TypedDict

import httpx
import uvicorn
from fastapi import FastAPI
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
from pydantic import BaseModel, Field

from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import END, START, StateGraph
from langgraph.store.memory import InMemoryStore
from langgraph.types import Command, Send, interrupt

# --------------------------------------------------------------------------- #
#  Constants                                                                  #
# --------------------------------------------------------------------------- #
AGENT_HOST = "127.0.0.1"
AGENT_PORT = 8000
JOB_MANAGER_HOST = "127.0.0.1"
JOB_MANAGER_PORT = 8001
AGENT_URL = f"http://{AGENT_HOST}:{AGENT_PORT}"
JOB_MANAGER_URL = f"http://{JOB_MANAGER_HOST}:{JOB_MANAGER_PORT}"

HERE = __file__.rsplit("/", 1)[0]
STORE_KEY = "completed_groups"


# --------------------------------------------------------------------------- #
#  Data models (Task/TaskGroup stay pydantic; graph state is a TypedDict so   #
#  that Send() branch args -- plain dicts -- flow into nodes naturally.)      #
# --------------------------------------------------------------------------- #
class Task(BaseModel):
    id: str
    type: Literal["a", "b", "c"] = "a"
    rand_num: float
    job_result: float = -1.0


class TaskGroup(BaseModel):
    id: str
    final_data: float = -1.0
    tasks: List[Task] = []


class MyState(TypedDict):
    session_id: str
    task_group: TaskGroup
    short_results: List[dict]                              # node_plan only
    job_results: Annotated[List[dict], operator.add]      # one entry per branch
    log: Annotated[List[str], operator.add]


# --------------------------------------------------------------------------- #
#  Helpers                                                                    #
# --------------------------------------------------------------------------- #
def is_long_task(t: Task) -> bool:
    if t.type == "a":
        return True
    if t.type == "b":
        return t.rand_num > 0
    return False


def direct_result(t: Task) -> float:
    return t.rand_num + 1


def expected_final_data(tg: TaskGroup) -> float:
    return sum(t.rand_num + 1 for t in tg.tasks)


def get_taskgroup(task_group_id: str, ntask: int) -> TaskGroup:
    ntask = ntask if ntask <= 5 else 5
    task_type = ["a", "b", "c"]
    tasks = []
    for _ in range(ntask):
        j = int(random.random() * 3)
        if task_type[j] == "a":
            r = random.random() + 1
        elif task_type[j] == "b":
            r = random.random() * 0.3 - 0.15
        else:
            r = random.random()
        tasks.append(Task(id=str(uuid.uuid4()), type=task_type[j], rand_num=r))
    return TaskGroup(id=task_group_id, tasks=tasks)


def _short(jid: str) -> str:
    return jid[:8]


def _thread_id(session_id: str, tg_id: str) -> str:
    return f"{session_id}/{tg_id}"


# --------------------------------------------------------------------------- #
#  JobIndex: job_id -> (tg_id, session_id) so /cb can rebuild the thread_id.  #
#  Plus an `early_cache` for the race "a result arrives before its branch      #
#  reaches interrupt()" -- the branch pops it and skips the park entirely.    #
#  `resumed` guards against duplicate callbacks re-resolving an interrupt.    #
#  All sync => atomic w.r.t. the single asyncio event loop.                   #
# --------------------------------------------------------------------------- #
class JobIndex:
    def __init__(self) -> None:
        self._tg: dict[str, str] = {}
        self._session: dict[str, str] = {}
        self._early: dict[str, float] = {}
        self._resumed: set[str] = set()

    def register(self, job_id: str, tg_id: str, session_id: str) -> None:
        self._tg[job_id] = tg_id
        self._session[job_id] = session_id

    def tg_of(self, job_id: str) -> Optional[str]:
        return self._tg.get(job_id)

    def session_of(self, job_id: str) -> Optional[str]:
        return self._session.get(job_id)

    def stash_early(self, job_id: str, result: float) -> None:
        self._early[job_id] = result

    def pop_early(self, job_id: str) -> Optional[float]:
        return self._early.pop(job_id, None)

    def mark_resumed(self, job_id: str) -> bool:
        """True if this job was NOT already resumed (so the caller should
        proceed); False if it's a duplicate."""
        if job_id in self._resumed:
            return False
        self._resumed.add(job_id)
        return True


# --------------------------------------------------------------------------- #
#  MessageBus: tg_id -> asyncio.Queue[str]                                    #
# --------------------------------------------------------------------------- #
class MessageBus:
    def __init__(self) -> None:
        self._queues: dict[str, asyncio.Queue] = {}

    def _queue(self, tg_id: str) -> asyncio.Queue:
        if tg_id not in self._queues:
            self._queues[tg_id] = asyncio.Queue()
        return self._queues[tg_id]

    def put(self, tg_id: str, msg: str) -> None:
        self._queue(tg_id).put_nowait(msg)

    def close(self, tg_id: str) -> None:
        self._queue(tg_id).put_nowait(None)

    def queue(self, tg_id: str) -> asyncio.Queue:
        return self._queue(tg_id)


job_index = JobIndex()
bus = MessageBus()
session_store = InMemoryStore()
client: httpx.AsyncClient = httpx.AsyncClient(timeout=30.0)


def _read_completed(store, session_id: str) -> list:
    item = store.get(session_id, STORE_KEY)
    return list(item.value) if item and item.value else []


def _append_completed(store, session_id: str, entry: dict) -> None:
    prev = _read_completed(store, session_id)
    prev.append(entry)
    store.put(session_id, STORE_KEY, prev)


def _find_interrupt_id(graph, thread_id: str, job_id: str) -> Optional[str]:
    """Read the thread's checkpoint, return the id of the PENDING interrupt
    whose value carries `job_id`, or None if the branch hasn't parked yet.
    Called only for jobs not already resumed (see JobIndex.mark_resumed), so a
    match here is by construction the live, pending interrupt for this job."""
    st = graph.get_state({"configurable": {"thread_id": thread_id}})
    for t in st.tasks:
        for intr in getattr(t, "interrupts", []):
            v = intr.value
            if isinstance(v, dict) and v.get("job_id") == job_id:
                return intr.id
    return None


# --------------------------------------------------------------------------- #
#  The langgraph agent                                                        #
# --------------------------------------------------------------------------- #
async def _submit_to_manager(job_id: str, rand_num: float) -> None:
    await client.post(
        f"{JOB_MANAGER_URL}/submit",
        json={
            "job_id": job_id,
            "rand_num": rand_num,
            "callback_url": f"{AGENT_URL}/cb/{job_id}",
        },
    )


async def node_plan(state: MyState, *, store) -> dict:
    """Read session memory, compute short jobs directly, submit long jobs to
    the remote manager. Long jobs are NOT waited for here -- they fan out to
    one node_wait_job branch each via the conditional edge below."""
    tg = state["task_group"]
    session_id = state["session_id"]
    msgs: List[str] = []

    prior = _read_completed(store, session_id)
    if prior:
        summary = ", ".join(f"{_short(e['tg_id'])}={e['final_data']:.3f}" for e in prior)
        msgs.append(f"Session {_short(session_id)}: {len(prior)} prior completed "
                    f"group(s) [{summary}] -- memory loaded.")
    else:
        msgs.append(f"Session {_short(session_id)}: no prior groups (new session).")

    long_tasks = [t for t in tg.tasks if is_long_task(t)]
    short_results: List[dict] = []
    new_tasks: List[Task] = []
    for t in tg.tasks:
        if is_long_task(t):
            job_index.register(t.id, tg.id, session_id)     # sync, before any submit
            new_tasks.append(t.model_copy())
        else:
            res = direct_result(t)
            short_results.append({"job_id": t.id, "result": res})
            new_tasks.append(t.model_copy(update={"job_result": res}))
            msgs.append(f"Job {_short(t.id)} (type {t.type}, rand={t.rand_num:.3f}) "
                         f"is short, result={res:.3f}")

    n_long = len(long_tasks)
    n_short = len(tg.tasks) - n_long
    expected = expected_final_data(tg)
    msgs.append(f"There are {len(tg.tasks)} jobs ({n_long} long, {n_short} short). "
                f"The expected final_data is {expected:.3f}")

    # Submit long jobs concurrently; registration already happened (sync).
    if long_tasks:
        await asyncio.gather(*[_submit_to_manager(t.id, t.rand_num) for t in long_tasks])
        for t in long_tasks:
            msgs.append(f"Send job {_short(t.id)} (type {t.type}, "
                        f"rand={t.rand_num:.3f}) to remote...")

    for m in msgs:
        bus.put(tg.id, m)
    return {"task_group": tg.model_copy(update={"tasks": new_tasks}),
            "short_results": short_results, "log": msgs}


def fan_out(state: MyState):
    """One Send() per long job -> one node_wait_job branch each, parked on its
    own interrupt().  No long jobs -> straight to the fan-in node_collect."""
    tg = state["task_group"]
    longs = [t for t in tg.tasks if is_long_task(t)]
    if not longs:
        return "node_collect"
    return [
        Send("node_wait_job",
             {"job_id": t.id, "session_id": state["session_id"], "tg_id": tg.id})
        for t in longs
    ]


async def node_wait_job(state, *, store) -> dict:
    """One branch per long job.  Parks on its OWN interrupt(); the /cb for this
    job resumes only this branch with Command(resume={id: value}).  In a real
    program this is where per-job post-processing lives (parse OUTCAR, validate,
    publish artifact refs to the session store) -- and it runs the INSTANT this
    job finishes, regardless of the other (still-parked) jobs.

    Race: if this job's result arrived before the branch reached interrupt(),
    JobIndex.pop_early returns it and we skip the park entirely.
    """
    jid = state["job_id"]
    cached = job_index.pop_early(jid)
    if cached is not None:
        val = cached
    else:
        val = interrupt({"job_id": jid})     # parks here until /cb resumes
    return {"job_results": [{"job_id": jid, "result": float(val)}]}


async def node_collect(state: MyState, *, store) -> dict:
    """Fan-in: runs only after EVERY node_wait_job branch has completed (the
    parallel-branch barrier).  Merge short + long results, compute final_data,
    publish the group summary to the shared session store."""
    tg = state["task_group"]
    results = {r["job_id"]: r["result"]
               for r in list(state.get("short_results", [])) +
                        list(state.get("job_results", []))}
    new_tasks = []
    for t in tg.tasks:
        res = results.get(t.id, t.job_result)
        new_tasks.append(t.model_copy(update={"job_result": res}))
    final_data = sum(t.job_result for t in new_tasks)
    msg = f"The calculated final_data is {final_data:.3f}"
    bus.put(tg.id, msg)

    _append_completed(store, state["session_id"], {
        "tg_id": tg.id, "final_data": final_data,
        "n_tasks": len(tg.tasks),
    })
    return {"task_group": tg.model_copy(
                update={"tasks": new_tasks, "final_data": final_data}),
            "log": [msg]}


builder = StateGraph(MyState)
builder.add_node("node_plan", node_plan)
builder.add_node("node_wait_job", node_wait_job)
builder.add_node("node_collect", node_collect)
builder.add_edge(START, "node_plan")
builder.add_conditional_edges("node_plan", fan_out)
builder.add_edge("node_wait_job", "node_collect")
builder.add_edge("node_collect", END)

app_graph = builder.compile(checkpointer=InMemorySaver(), store=session_store)


async def run_task_group(tg_id: str, session_id: str, ntask: int) -> None:
    """First leg: invoke until the graph parks at the per-job interrupts (or, if
    there are no long jobs, runs straight to END).  Each /cb later resumes one
    branch; the last resume closes the SSE stream."""
    tg = get_taskgroup(tg_id, ntask)
    result = await app_graph.ainvoke(
        {"session_id": session_id, "task_group": tg,
         "short_results": [], "job_results": [], "log": []},
        config={"configurable": {"thread_id": _thread_id(session_id, tg_id)}},
    )
    if "__interrupt__" not in result:
        bus.close(tg_id)     # all-short or all-early -> already at END


async def _resume_one(tg_id: str, thread_id: str, iid: str, result: float) -> None:
    """Resume a single job's branch off the /cb request path, then close the
    SSE stream if the whole group has reached END."""
    await app_graph.ainvoke(
        Command(resume={iid: result}),
        config={"configurable": {"thread_id": thread_id}},
    )
    st = app_graph.get_state({"configurable": {"thread_id": thread_id}})
    if not st.next:                       # () == no pending branches == END
        bus.close(tg_id)


# --------------------------------------------------------------------------- #
#  Job Manager  (separate FastAPI service on JOB_MANAGER_PORT)               #
# --------------------------------------------------------------------------- #
class SubmitJob(BaseModel):
    job_id: str
    rand_num: float
    callback_url: str


job_manager_app = FastAPI(title="Remote Job Manager")


@job_manager_app.post("/submit")
async def submit_job(payload: SubmitJob) -> dict:
    asyncio.create_task(_run_job(payload.job_id, payload.rand_num, payload.callback_url))
    return {"ok": True, "job_id": payload.job_id}


async def _run_job(job_id: str, rand_num: float, callback_url: str) -> None:
    await asyncio.sleep(rand_num)
    result = rand_num + 1
    try:
        await client.post(callback_url, json={"job_id": job_id, "result": result})
    except Exception as exc:  # pragma: no cover
        print(f"[job-manager] post-back failed job={_short(job_id)}: {exc}")


# --------------------------------------------------------------------------- #
#  Agent web app  (FastAPI on AGENT_PORT)                                     #
# --------------------------------------------------------------------------- #
class CreateReq(BaseModel):
    ntask: int = Field(default=3, ge=1, le=5)
    session_id: Optional[str] = None


agent_app = FastAPI(title="TaskGroup Agent")


@agent_app.get("/")
async def index() -> FileResponse:
    return FileResponse(f"{HERE}/index.html")


@agent_app.post("/api/create")
async def create(req: CreateReq) -> JSONResponse:
    tg_id = str(uuid.uuid4())
    session_id = req.session_id or str(uuid.uuid4())
    asyncio.create_task(run_task_group(tg_id, session_id, req.ntask))
    return JSONResponse({"task_group_id": tg_id, "session_id": session_id})


@agent_app.post("/cb/{job_id}")
async def job_callback(job_id: str, payload: dict) -> JSONResponse:
    result = payload.get("result")
    if result is None:
        return JSONResponse({"ok": False, "job_id": job_id}, status_code=400)
    result = float(result)

    tg_id = job_index.tg_of(job_id)
    if tg_id is None:
        return JSONResponse({"ok": True, "job_id": job_id, "duplicate": True})
    session_id = job_index.session_of(job_id)
    thread_id = _thread_id(session_id, tg_id)

    if not job_index.mark_resumed(job_id):
        # already resumed this job -> idempotent no-op (duplicate callback)
        return JSONResponse({"ok": True, "job_id": job_id, "duplicate": True})

    bus.put(tg_id, f"job {_short(job_id)} is done, result is {result:.3f}")

    iid = _find_interrupt_id(app_graph, thread_id, job_id)
    if iid is None:
        # branch hasn't parked yet (fast job beat the graph to interrupt());
        # stash so node_wait_job pops it and skips the park.
        job_index.stash_early(job_id, result)
    else:
        # resume ONLY this job's branch, off the request path.
        asyncio.create_task(_resume_one(tg_id, thread_id, iid, result))

    return JSONResponse({"ok": True, "job_id": job_id})


@agent_app.get("/api/stream/{tg_id}")
async def stream(tg_id: str) -> StreamingResponse:
    queue = bus.queue(tg_id)

    async def event_gen():
        while True:
            msg = await queue.get()
            if msg is None:
                yield f"data: {json.dumps({'done': True})}\n\n"
                return
            yield f"data: {json.dumps({'msg': msg})}\n\n"

    return StreamingResponse(event_gen(), media_type="text/event-stream")


# --------------------------------------------------------------------------- #
#  Entry point                                                                #
# --------------------------------------------------------------------------- #
async def main() -> None:
    agent_cfg = uvicorn.Config(
        agent_app, host=AGENT_HOST, port=AGENT_PORT, log_level="warning"
    )
    jm_cfg = uvicorn.Config(
        job_manager_app, host=JOB_MANAGER_HOST, port=JOB_MANAGER_PORT, log_level="warning"
    )
    agent_server = uvicorn.Server(agent_cfg)
    jm_server = uvicorn.Server(jm_cfg)
    print(f"Agent:       http://{AGENT_HOST}:{AGENT_PORT}")
    print(f"Job Manager: http://{JOB_MANAGER_HOST}:{JOB_MANAGER_PORT}")
    print("Open the Agent URL in a browser and click 'Create TaskGroup'.")
    await asyncio.gather(agent_server.serve(), jm_server.serve())


if __name__ == "__main__":
    try:
        asyncio.run(main())
    except KeyboardInterrupt:
        pass

hi @MSJavaScript

Your last responses change my previous answer, so first a correction: I told you to encode the topology with defer=True or a list-form join. That only applies if you compile the task topology into a StateGraph. You are not doing that, and you should not - your graph is fixed (Planner, Executor, Checker, Summary) and the dependency structure is data. Drop that part; one thread per session, the interrupt semantics and checkpointer vs store still hold. With a fixed graph the join is a scheduling decision your Checker makes from state, not an edge.

Modelling the nested task tree. A2 spawning B1 to B2 and then resuming is not a cycle, it is a re-queue. Keep tasks in a dict keyed by id, not an append-only list:

def merge_tasks(left: dict, right: dict) -> dict:
    out = dict(left)
    for tid, patch in right.items():
        out[tid] = {**out.get(tid, {}), **patch}
    return out

class Task(TypedDict):
    id: str
    group_id: str
    parent_id: str | None      # A2, for B1 and B2
    deps: list[str]
    status: Literal["pending", "waiting", "done", "failed"]
    result: float | None

tasks: Annotated[dict[str, Task], merge_tasks]

Executor sends one branch per ready task, where ready means pending and all deps done. Replanning A2 inserts B1, B2 with parent_id A2 and patches A2 back to pending with deps=[B2]. That is your return back to A2, no extra edges. Since your state grows like messages, the dict plus per-id merge is much cheaper than operator.add over a list, and duplicate writes become idempotent instead of doubling entries. Also watch recursion_limit: it counts supersteps, not iterations, and a replanning loop burns them - check remaining_steps in the Checker rather than hitting GraphRecursionError after six hours of Slurm time.

Now the code in post 9. It gets the hard part right, and then three things will bite you in production. I reproduced all of them on langgraph 1.2.9.

1. Concurrent resumes on one thread silently skip the fan-in. /cb does asyncio.create_task(_resume_one(…)) per callback with no serialization, so jobs finishing close together run several ainvoke calls on the same thread_id. With your graph shape, three jobs:

concurrent: job_results=3  collect_runs=0  next=()      5 of 5 attempts
sequential: job_results=3  collect_runs=1  next=()      3 of 3 attempts

node_collect never runs. Nothing raises, all three invokes return normally still carrying interrupt, and the final state reports next=() so _resume_one closes the SSE stream as if the group succeeded. final_data is never computed and nothing reaches the session store. This is the concrete failure behind the rule about never running two ainvoke calls concurrently on one thread.

2. The early-callback cache deadlocks the branch. node_wait_job reads pop_early exactly once, at the top of the node, and /cb stashes only when _find_interrupt_id returns None. Those windows overlap. The window is not hairline - while the first-leg ainvoke is in flight the interrupt is invisible to get_state:

t= 50ms  find_iid(j1) -> None
t=150ms  find_iid(j1) -> None
t=250ms  find_iid(j1) -> None
after invoke returned: find_iid(j1) -> True

and a callback landing after the cache read but before the branch parks is lost for good:

callback: find_interrupt_id -> None
callback: stashed into early cache
run returned. next: ('wait',)
pending interrupts: [{'job_id': 'j1'}]
early cache still holds: {'j1': 42.0}

mark_resumed is called before the stash, so a retry from the job manager is rejected as a duplicate and the branch waits forever. Your type b jobs sleep under 0.15 s, so this is reachable in the demo, and a Postgres checkpointer widens the window by a DB round trip.

3. The volatile index loses jobs across a restart. JobIndex, MessageBus and the early cache are plain dicts - the same criticism your docstring makes of the earlier version. After a restart a callback hits tg_id is None and returns ok true duplicate true, so the result is dropped and the thread stays parked. The job_id to (session_id, tg_id) mapping is durable data; an unknown job_id should be a dead letter, not a silent OK. And InMemorySaver cannot deliver the durability the docstring claims.

Minor: _append_completed does read-modify-write on one store key, so two groups in the same session finishing concurrently clobber each other. Use one item per group, store.put((session_id, “groups”), tg_id, …) and store.search to list.

What the code gets right, since these are usually the parts people get wrong. Per-job post-processing inside node_wait_job after interrupt() returns is the only correct placement - anything moved to a downstream node waits for the last job. The resumed branch’s result really is durable immediately; I restarted the process with siblings still parked and got back {‘job_results’: [{‘job_id’: ‘j1’, ‘result’: 1.0}]} (do not set durability=exit if you rely on this). A duplicate resume with a stale interrupt id is a no-op, so job_results does not double up. And Task.id doubles as a stable idempotency key for resubmission.

The fix for all three is the same restructuring: the callback never resumes, it records and wakes.

@agent_app.post("/cb/{job_id}")
async def job_callback(job_id: str, payload: dict):
    rec = await job_table.get(job_id)                 # durable, not a dict
    if rec is None:
        return JSONResponse({"ok": False, "reason": "unknown job"}, status_code=404)
    await job_table.put_result(job_id, float(payload["result"]))   # idempotent
    wake_queue.put_nowait(rec["thread_id"])
    return JSONResponse({"ok": True})

async def waker(thread_id: str):
    async with thread_lock(thread_id):                # one resume at a time per thread
        for delay in (0.0, 0.2, 0.5, 1.0, 2.0, 5.0):  # the run may still be starting
            await asyncio.sleep(delay)
            st = await graph.aget_state(cfg(thread_id))
            pending = {i.value["job_id"]: i.id
                       for t in st.tasks for i in t.interrupts
                       if isinstance(i.value, dict)}
            ready = {iid: await job_table.result(jid)
                     for jid, iid in pending.items()
                     if await job_table.has_result(jid)}
            if ready:
                await graph.ainvoke(Command(resume=ready), cfg(thread_id))
                return
            if not st.next:
                return

One lock per thread means the fan-in is never skipped. Results are durable before any resume attempt and the lookup retries, so the gap becomes a retry instead of a deadlock, and resuming several ids in one call is fewer runs. A durable job table means a restart does not orphan in-flight jobs - sweep on boot for threads with pending interrupts and finished results. Keep the check of the durable table inside node_wait_job before interrupt() for jobs that finished before the branch was scheduled; with the table durable and the waker retrying, both orderings are covered. On Agent Server you can drop the lock entirely and let multitask_strategy=enqueue serialize for you.

Docs: graph API and recursion_limit, multiple interrupts, persistence and durability, stores, multitask_strategy.