Persisting HITL payloads

Hi @warrenseine

afaik you are right that moving data to a different state key doesn’t help. the issue isn’t where the data lives, it’s when writes are presisted relative to interrupt().

It’s not that send() is async and ureliable. It’s that writes from an interrupted node are intentionally discarded by design.

When interrupt() raises GraphInterrupt inside your node, the runner’s commit() method deliberately creates a new writes list containing only INTERRUPT and RESUME channel data:

# langgraph/pregel/_runner.py, lines 436-442
if isinstance(exception, GraphInterrupt):
    if exception.args[0]:
        writes = [(INTERRUPT, exception.args[0])]
        if resumes := [w for w in task.writes if w[0] == RESUME]:
            writes.extend(resumes)
        self.put_writes()(task.id, writes)  # ← only INTERRUPT + RESUME

Everything your node wrote via CONFIG_KEY_SEND before the interrupt() call is still in task.writes, but commit() ignores it. Then put_writes() (langgraph/libs/langgraph/langgraph/pregel/_loop.py at main · langchain-ai/langgraph · GitHub) replaces all prior pending writes for that task:

# langgraph/pregel/_loop.py, lines 322-326
self.checkpoint_pending_writes = [
    w for w in self.checkpoint_pending_writes if w[0] != task_id
]

This happens regardless of durability mode - it’s not a timing issue, it’s a filtering issue. The writes never reach the checkpointer because they’re stripped out before any persistence logic runs.

IMHO this is intentional because of how interrupt() works: when you resume with Command(resume=...), the entire node re-executes from scratch (source). On the second run, interrupt() returns the resume value instead of raising. So any writes the node made before interrupt() would be duplicated if they were persisted - the node will produce them again on re-execution.

That’s why LangGraph discards them: the node didn’t complete, so its writes are treated as uncommitted.

You referenced PR #5432 and suggested "exit" should be a separate boolean. Looking at the implementation, durability="exit" does behave differently at the _suppress_interrupt() (langgraph/libs/langgraph/langgraph/pregel/_loop.py at main · langchain-ai/langgraph · GitHub) boundary - it calls _put_checkpoint() + _put_pending_writes() when the graph exits. However, even with "exit", the writes were already filtered in commit(), so the checkpoint doesn’t actually contain your send() data. If it appeared to work in your test, it’s likely due to a timing artifact with InMemorySaver rather than the data actually being persisted.

The fix isn’t about durability modes - it’s about restructuring your graph so that the state update is committed by a completed node, not one that gets interrupted mid-execution:

Option A - split the node:

def prepare_hitl(state: State) -> dict:
    """Runs to completion → writes are committed in after_tick()."""
    last_msg = state["messages"][-1]
    updated = AIMessage(
        content=last_msg.content,
        id=last_msg.id,
        additional_kwargs={**last_msg.additional_kwargs, "hitl_payload": "payload"},
    )
    return {"messages": [updated]}

def pause_node(state: State) -> dict:
    """Separate node for the interrupt."""
    answer = interrupt("Review and approve/reject")
    return {"messages": [HumanMessage(content=answer)]}

builder = StateGraph(State)
builder.add_node("prepare_hitl", prepare_hitl)
builder.add_node("pause_node", pause_node)
builder.add_edge(START, "prepare_hitl")
builder.add_edge("prepare_hitl", "pause_node")
builder.add_edge("pause_node", END)
graph = builder.compile(checkpointer=InMemorySaver())

prepare_hitl completes normally → after_tick() calls apply_writes() → _put_checkpoint() persists the updated message. Then pause_node runs and interrupts. The message update is already safely checkpointed at this point, with any durability mode.

Option B - use interrupt_before on the next node:

graph = builder.compile(
    checkpointer=InMemorySaver(),
    interrupt_before=["pause_node"],
)

With interrupt_before, the checkpoint is saved at the end of after_tick() with all completed writes, and then the interrupt fires in the next tick() before the node starts. Same result - your data is persisted before the interrupt.

Option C - use update_state() while paused:

If you need to attach metadata after the interrupt fires (e.g., from the client side):

snapshot = graph.get_state(config)
graph.update_state(config, {"messages": [updated_message]})
graph.invoke(Command(resume="approved"), config)