Skip to content

feat(langgraph): opt-in inclusive release for waiting edges - #2699

Open
Serhiy Bzhezytskyy (serhiy-bzhezytskyy) wants to merge 12 commits into
langchain-ai:mainfrom
serhiy-bzhezytskyy:feat/inclusive-release
Open

feat(langgraph): opt-in inclusive release for waiting edges#2699
Serhiy Bzhezytskyy (serhiy-bzhezytskyy) wants to merge 12 commits into
langchain-ai:mainfrom
serhiy-bzhezytskyy:feat/inclusive-release

Conversation

@serhiy-bzhezytskyy

Copy link
Copy Markdown

Implements #2695. Builds on #2685 — the three commits at the base are that PR, and this diff includes them until it lands.

Summary

addEdge([...], target, { inclusive: true }) — the edge waits while anything in the graph is running or scheduled; once the run settles it releases with the listed nodes that completed, running the target exactly once. A Send in flight is a scheduled task, so it holds the release. Defaults are untouched: edges without the option keep the documented wait-for-all contract.

Changes

  • channels/named_barrier_value.tsInclusiveNamedBarrierValue: release is a flag the loop may set once the run settles; the checkpoint carries [seen, released] and restore tolerates every earlier shape, so toggling the option on an existing thread is safe in both directions.
  • pregel/loop.ts — the release hook at the settle point (nothing running, nothing scheduled); a drain still stops the run without releasing.
  • pregel/index.ts — a paused run holding writes reports the edge's target in next, so it does not read as a finished one.
  • waiting_edge_release.tswaitingEdgeRelease() inside the target names the listed nodes that arrived and the ones that never ran.
  • graph/state.ts — the inclusive option, its JSDoc, and a compile-time error when combined with defer (which already postpones the node to the end of the run).

Test plan

  • 25 tests in inclusive_waiting_edges.test.ts — each boundary Feature: opt-in inclusive release for waiting edges — run the target once with the nodes that arrived #2695 lists, pinned: the Send hold; one run per release cycle; two mutual inclusive edges ending at the recursion limit; interrupt at the release point keeping next honest; both toggle directions on an existing thread; waitingEdgeRelease() naming arrived and missing
  • A seeded property sweep (inclusive_waiting_edges.fuzz.test.ts: 2,400 seeds over random shapes — loops, uneven chains, Send dispatchers), re-run after the rebase onto current main
  • Full langgraph-core suite: 1,712 passed, typecheck clean; changeset for @langchain/langgraph

Related

`StateGraph.addEdge`'s array overload had no JSDoc, so the only statement
of its contract was Python's `add_edge` docstring, and when a listed node
never runs the discarded writes leave no trace: no error is raised and
`getState()` reports `next: []` with no tasks, while the checkpoint holds
`join:a+b:merge: ["a"]` under an internal channel name. Document what the
overload guarantees and how it differs from separate edges into the same
target, and expose the unreleased edges as `waitingEdges` on the snapshot,
naming the target, the nodes that completed and the nodes that never ran.
Read the sets off the barrier rather than tracking anything new, and omit
the key when every edge released so healthy snapshots keep their shape.
getState describes one checkpoint namespace, so an edge left waiting inside a
subgraph was invisible from the parent even though the child's checkpoint
holds it. Collect those under subgraphs: true, tagged with the stable node
path and the exact namespace; missing is derived from the channel name, and a
looped subgraph reports once per edge rather than once per invocation. A
graph with no subgraph node skips the search entirely. Also guards a nested
edge that holds every write — it waits for its target, nothing was dropped —
and pins defer, dynamic interrupt, stream and non-memory savers.
stateSnapshotToThreadState maps snapshots with an explicit field list, so
waitingEdges stopped at the process boundary and client.threads.getState()
could not see it. Map it as waiting_edges and add the field to the SDK's
ThreadState; the key is omitted when every edge released, so an existing
payload keeps its shape. Verified over real HTTP: the integration suite boots
the server and reads the wire through the SDK.
…its own

Separate edges fire a fan-in once per superstep in which any listed node
completes; a waiting edge fires once or never. Between them lies the
semantics every report reaches for — run once, with whichever nodes arrived —
and it is spellable today as separate edges plus defer: true on the target,
measured identical on Python 1.2.11. Name it in the addEdge JSDoc with the
two costs found by attacking it, and add the StateSnapshot bullet to the
persistence page.
…t arrived

A waiting edge whose listed node is never selected holds its writes forever,
so the target is silently skipped while the run resolves. Add
addEdge([...], target, { inclusive: true }): at quiescence — the last
superstep's writes are applied and task derivation produced nothing, so
nothing is running and nothing is scheduled — the loop feeds the barrier its
missing names and the target runs once with the writes that arrived.

Quiescence is what the two earlier release designs lacked: both released at
the finish() hook, which fires under the '(tentatively) last superstep'
predicate and knows nothing about pending PUSH tasks, so a Send to a listed
node double-fired the target. Here a Send in flight is a pending task, so
there is no quiescence to release at and the barrier completes normally.

The release goes through the barrier's own completeness rule, so consume()
clears it as usual: single-shot, re-arms in loops. Opt-in only — the default
barrier is untouched, an inclusive edge nobody wrote to stays silent, and
combining with defer: true throws at compile. Twelve tests: the reported
shape, the Send hold, uneven depths at three selection mixes, a cascade, a
loop's final incomplete pass, interrupt/resume, a subgraph, and both
rejections.
…un does not read as finished

Found by a 2,400-seed property sweep over random graph shapes (conditional
entry subsets, chains at different depths, bounded loops, Send dispatchers,
one or two joins), with every write into an inclusive barrier trapped:
interruptAfter can land on the quiescent superstep, where nothing is scheduled
and the edge is armed. getState() then reported next: [] — the documented
end-of-run signal — so a client following it never resumed, and the release
never happened. Report the armed edge's target in next when nothing else is
scheduled: it will run, at latest when the resumed run settles.

Two more properties the sweep surfaced, pinned and documented rather than
changed: a listed node that completes again after a release re-arms the edge
and runs the target once per arming, the default barrier's own accumulation
rule; and two inclusive edges listing each other's targets never settle — the
OR-join vicious circle — ending the run at the recursion limit where the
default shape stalls silently. The seeded fuzz stays in the suite at 300+60
seeds; invariants: a completed run leaves no inclusive edge holding writes,
single-arrival joins run exactly once, and interrupt+resume reproduces the
uninterrupted result through next alone.
…gainst the inclusive edge

The remaining unprobed seams, each measured before being pinned. A fork from
the pre-release checkpoint releases once in the fork, and the armed history
row carries next=["join"]. updateState as the missing node completes the
barrier and the join runs once — the manual write and the quiescence release
do not stack. An unrelated updateState keeps the edge armed and next naming
the target through the update-created checkpoint. The fuzz's single loop
shape generalizes to a random re-entry point and 2-3 passes; 2,000 invariant
seeds and 400 interrupt/resume seeds pass at that shape too, committed at
300+60.

Also measured, recorded as a probe rather than a test: a child process
killed -9 mid-superstep with SqliteSaver and durability sync recovers on
resume — the sibling's persisted write survives, the unfinished task re-runs,
the release happens exactly once.
AC4.4 was reinterpreted rather than met: the release fed the barrier its
missing names, so a checkpoint claimed nodes wrote that never ran and the
released target had no way to tell a partial run from a full one — a consumer
wanting 'mark this document partially indexed' had no hook.

Release by flag instead: seen keeps only the nodes that actually wrote, so
persistence stays truthful, and consume() clears the flag with the writes,
keeping the edge single-shot. Surface the record to the target through the
task scratchpad, read with waitingEdgeRelease(): the arrived and missing
names on a quiescence release, undefined on an ordinary completeness release.
The record is derived from the barrier at task preparation, not captured at
release, so it survives an interruptBefore on the target and a resume from a
checkpoint written in between — pinned by test. A released-but-unconsumed
barrier is skipped by the snapshot collectors: its target is a scheduled
task, not a stall.
… a finished run

Measured before believed: without this, a drain landing on the quiescent
superstep resolved the run with the join silently dropped — the original
disease through the drain door, and inconsistent with a drain that lands
while tasks remain, which throws 'Graph drained'. An armed inclusive edge is
remaining work — it releases once the run truly settles — so the loop now
reports draining instead of done. The state was already honest (next names
the target, waitingEdges shows the armed edge); resume without the control
releases once. Pinned.
…ds, stop scanning every superstep

The four-hats red team (red team / reviewer / architect / user), all findings
measured before fixed. Removing { inclusive: true } from an edge and resuming
an armed thread fed the [seen, released] tuple to the default barrier, whose
seen became a two-element garbage set — unreleasable and invisible to
waitingEdges at once; the same hazard pre-existed for the defer flag in both
directions, so the fix is restore tolerance in all three barrier classes, and
both flag flips now behave honestly (pinned both ways). Two inclusive edges
into one target release as one task, but the release record kept only the
first edge's arrivals — now the union. A Send straight at the armed target
runs it separately (edges, not sends) — measured, documented, pinned. And the
per-tick release-record scan walked every channel of every graph whether or
not an inclusive edge existed — gated behind a flag computed once at loop
construction, with the five string-literal class checks replaced by one
exported guard.
…uiescence and armed

Comment-and-JSDoc-only pass: 'quiescence' and 'armed/arming' appear
nowhere in this codebase's existing text, while 'settle' and holding
writes are its native register; 're-arm' stays, timeout.ts uses it.
@changeset-bot

changeset-bot Bot commented Aug 16, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 4728159

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 9 packages
Name Type
@langchain/langgraph Minor
@langchain/langgraph-api Patch
@langchain/langgraph-sdk Patch
@langchain/vue Patch
@langchain/langgraph-cli Patch
@langchain/angular Patch
@langchain/react Patch
@langchain/svelte Patch
@langchain/langgraph-ui Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Aug 16, 2026

Copy link
Copy Markdown

Open in StackBlitz

@langchain/langgraph-checkpoint

npm i https://pkg.pr.new/langchain-ai/langgraphjs/@langchain/langgraph-checkpoint@2699

@langchain/langgraph-checkpoint-mongodb

npm i https://pkg.pr.new/langchain-ai/langgraphjs/@langchain/langgraph-checkpoint-mongodb@2699

@langchain/langgraph-checkpoint-postgres

npm i https://pkg.pr.new/langchain-ai/langgraphjs/@langchain/langgraph-checkpoint-postgres@2699

@langchain/langgraph-checkpoint-redis

npm i https://pkg.pr.new/langchain-ai/langgraphjs/@langchain/langgraph-checkpoint-redis@2699

@langchain/langgraph-checkpoint-sqlite

npm i https://pkg.pr.new/langchain-ai/langgraphjs/@langchain/langgraph-checkpoint-sqlite@2699

@langchain/langgraph-checkpoint-validation

npm i https://pkg.pr.new/langchain-ai/langgraphjs/@langchain/langgraph-checkpoint-validation@2699

create-langgraph

npm i https://pkg.pr.new/langchain-ai/langgraphjs/create-langgraph@2699

@langchain/langgraph-api

npm i https://pkg.pr.new/langchain-ai/langgraphjs/@langchain/langgraph-api@2699

@langchain/langgraph-cli

npm i https://pkg.pr.new/langchain-ai/langgraphjs/@langchain/langgraph-cli@2699

@langchain/langgraph

npm i https://pkg.pr.new/langchain-ai/langgraphjs/@langchain/langgraph@2699

@langchain/langgraph-cua

npm i https://pkg.pr.new/langchain-ai/langgraphjs/@langchain/langgraph-cua@2699

@langchain/langgraph-supervisor

npm i https://pkg.pr.new/langchain-ai/langgraphjs/@langchain/langgraph-supervisor@2699

@langchain/langgraph-swarm

npm i https://pkg.pr.new/langchain-ai/langgraphjs/@langchain/langgraph-swarm@2699

@langchain/langgraph-ui

npm i https://pkg.pr.new/langchain-ai/langgraphjs/@langchain/langgraph-ui@2699

@langchain/langgraph-sdk

npm i https://pkg.pr.new/langchain-ai/langgraphjs/@langchain/langgraph-sdk@2699

@langchain/angular

npm i https://pkg.pr.new/langchain-ai/langgraphjs/@langchain/angular@2699

@langchain/react

npm i https://pkg.pr.new/langchain-ai/langgraphjs/@langchain/react@2699

@langchain/svelte

npm i https://pkg.pr.new/langchain-ai/langgraphjs/@langchain/svelte@2699

@langchain/vue

npm i https://pkg.pr.new/langchain-ai/langgraphjs/@langchain/vue@2699

commit: 4728159

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant