← Back to Blog
For: AI Engineers, ML Engineers, Platform Engineers, AI Systems Architects

Graph Engineering Draws the Graph. Your Runtime Redraws It.

A recursion limit of 6 let 1,000 research tasks run. Renaming one reviewer node decided whether the run ever ended. A checkpointer handed a spent step budget back at full on every resume.

#graph-engineering#langgraph#multi-agent-systems#agent-reliability#claude-code#fan-out

Graph engineering's reliability checklists tell you to cap parallel workers, and LangGraph has a setting named for exactly that.

In a probe on LangGraph 1.2.11, I set max_concurrency to 4 on a fan-out of 40 async tasks, and all 40 ran at the same time.

I had put the key where LangGraph's documentation puts it. The tip box titled "Set max concurrency" reads, "You can control the maximum number of concurrent tasks by setting max_concurrency in the configuration when invoking the graph," and shows one line: graph.invoke({"value_1": "c"}, {"configurable": {"max_concurrency": 10}}). On 1.2.11 that placement behaves exactly like setting nothing. Moved out of configurable to the top level of the config, the same key held the peak at 4.

Nothing raised an exception or logged a warning. One page over, the same documentation warns that recursion_limit "should not be passed inside the configurable key", and says nothing of the kind about max_concurrency. I could not find this reported in LangGraph's issue tracker as of 14 September.

It is a small documentation bug with an easy fix. I open with it because it shows, in one line, a setting with exactly the right name leaving you without the property you set it for. Graph engineering, the practice that took over agent architecture discussion this July, is not built to see that gap.

Graph engineering gave agent reliability a checklist

On 18 July 2026 Peter Steinberger asked on X, "Are we still talking loops or did we shift to graphs yet?" Within two weeks "graph engineering" had guides from AI Builder Club, puppyone, QA Wolf, Analytics Vidhya and TrueFoundry, and LangChain published a retrospective called 3 Years of Graph Engineering with LangGraph. AI Builder Club traces an earlier use of the phrase to February 2024.

These guides are good. AI Builder Club's checklist closes with "Set a spend cap and a hard bound", puppyone wants explicit limits on "graph cycles; parallel workers; model or tool spend", and V12 Labs puts it in four words: "Cycles need hard limits." QA Wolf is the most precise of them. It warns that "Fan-out width is unbounded by default", tells you that "You can, however, cap the width before it spawns workers", and asks for a token budget over the whole subtree, which is close to the fix this article ends on. I measured each of these failures across the eight articles of my LangGraph series, and the guides name them correctly.

Critics of the practice are right as well. Future AGI's guide says "A correctly diagrammed graph is not a reliable one. Diagrams show topology, the boxes and arrows, and topology is not the layer where most production failures actually live. State is." Temporal made the same point in August 2025, a year before the term took off: "The picture is a lie." The AI Corner argued in September that a graph adds green lights without adding verification. I agree with all three and will not reargue their case.

What these pieces leave out is the setting. Guides are framework-neutral by design, so they stop at the property. "Cap parallel workers" is a sentence, and someone still has to turn it into a line of configuration on one specific runtime. This article is about that translation.

Where graph engineering checklists get translated wrong

Every runtime ships settings whose names contain the checklist's words. LangGraph has a recursion_limit for "hard limits", a max_concurrency for anything about parallel workers, reducers for "define reducers", and a checkpointer for "resume". Search the documentation for the checklist's word and you find a setting. Set it and the box is ticked.

On LangGraph, three of those settings leave you with something real and nearby instead of the property the checklist item was asking for. I call this a Namesake Fix: a checklist fix satisfied by a runtime setting whose name matches the item's words, when the setting enforces a different property, axis or unit.

My claim is narrower than "the checklists are wrong". Working through a reliability checklist on LangGraph by name can leave you with correctly configured settings, ticked boxes, and four failures those boxes were meant to prevent. Better wording in the checklist would help at the margin. What reliably fixes it is one test per item that exercises the property directly, because a test does not care what the setting is called.

I also ran the probes on a second runtime, the OpenAI Agents SDK. Its turn limit is blind to width exactly as LangGraph's step limit is. A run it pauses for approval, though, keeps its spent turns through one resume, where LangGraph grants them again.

People do make these translations, and they write them down:

  • CallSphere's LangGraph supervisor guide says "For paranoia, set recursion_limit on invoke() to bound the worst case." That holds for a supervisor that hands off one specialist at a time, and stops holding the moment a specialist fans out.
  • Asked about best practices for fan-outs on the LangChain Forum, a community answer says "There's no hard-coded fanout limit, but you should throttle concurrent tasks to match host resources and provider rate limits via max_concurrency in the call config." Throttling is the right advice for rate limits, and it is also the answer a fan-out question received.
  • My own book, where the learning objective and summary of the fan-out chapter both say "bounded the fan-out with max_concurrency" while the chapter body says the right thing. I wrote all three, and I only found the contradiction in an audit.

The last one is why I trust the pattern. I had spent eight articles measuring exactly these settings, had read their documentation closely, and still made the translation in the two places a reader skims.

None of these picks came from a graph engineering guide. CallSphere's guide, the forum answer and my book all predate or sit outside them. They show the translation habit that a checklist written at the level of properties leaves room for.

The wrong way: a LangGraph research graph that passes the checklist

Here is a research graph written to the checklist. A planner picks sources and Send fans out one research task per source. After a synthesis step two reviewers run in parallel, and a gate then either ends the run or loops back to the planner for another round.

python
# checklist_graph.pyimport operatorfrom typing import Annotated, TypedDictfrom langgraph.checkpoint.memory import InMemorySaverfrom langgraph.graph import END, START, StateGraphfrom langgraph.types import SendSPEND = []  # meter outside the graph: one entry per research task that actually ranclass State(TypedDict):    requested_width: int                              # stands in for the planner model's choice    targets: list[str]    findings: Annotated[list[str], operator.add]      # checklist: "define how parallel writes merge"    verdicts: Annotated[list[str], operator.add]    approved: booldef plan(state):    return {"targets": [f"source-{i}" for i in range(state["requested_width"])]}def fan_out(state):    return [Send("research", {"target": t}) for t in state["targets"]]def research(payload):    SPEND.append(payload["target"])    return {"findings": [payload["target"]]}def synthesise(state):    return {}def approve(state):    return {"verdicts": ["approve"]}def reject(state):    return {"verdicts": ["reject"]}def gate(state):    return {"approved": state["verdicts"][-1] == "approve"}   # the latest verdictdef decide(state):    return END if state["approved"] else "plan"def build(facts="facts_review", policy="policy_review", policy_votes=reject):    g = StateGraph(State)    g.add_node("plan", plan)    g.add_node("research", research)    g.add_node("synthesise", synthesise)    g.add_node(facts, approve)    g.add_node(policy, policy_votes)    g.add_node("gate", gate)    g.add_edge(START, "plan")    g.add_conditional_edges("plan", fan_out, ["research"])    g.add_edge("research", "synthesise")    g.add_edge("synthesise", facts)    g.add_edge("synthesise", policy)    g.add_edge([facts, policy], "gate")    g.add_conditional_edges("gate", decide, ["plan", END])    return g.compile(checkpointer=InMemorySaver())

No model is called anywhere in this file: requested_width stands in for the number a planner model would choose, and the reviewers return constants. Every claim below is about how the runtime counts, merges and resumes. A model would only add noise there, so each probe runs on langgraph==1.2.11 with no API key.

gate is deliberately naive: it reads only the last verdict. LangGraph's prebuilt tools_condition makes the same [-1] read on messages, where a single node wrote last and position is safe. Here it follows a parallel join, where position stops being safe. A real two-approver gate should read both verdicts, and I kept this one naive so that the only moving part in the merge probe is the order of writes.

Here is how the checklist maps onto the graph:

Checklist wordingSetting found by that wordHow it is set here
"hard bound", "bound the worst case"recursion_limit6, the tightest value one approving round runs under
bound the fan-out, once recursion_limit disappoints (my book's wording)max_concurrencymeasured in a separate probe
"Define reducers ... instead of hoping concurrent writes compose"Annotated[list[str], operator.add]on findings and verdicts
pause and resume, with the hard bound still in forcea checkpointer, with recursion_limit as the boundInMemorySaver on every compile

A reviewer would pass this graph, as I would have before I wrote the series. Even the diagram LangGraph draws for it is correct.

Namesake Fix 1: LangGraph's recursion_limit counts supersteps, not fan-out width

Run one approving round at recursion_limit=6 and raise the width the planner asks for:

python
# excerpt from probes.py: import checklist_graph as cg; from checklist_graph import build, approveINPUT = {"requested_width": 1, "findings": [], "verdicts": []}for w in (1, 10, 1000):    cg.SPEND.clear()    app = build(policy_votes=approve)    app.invoke({**INPUT, "requested_width": w},               {"recursion_limit": 6, "configurable": {"thread_id": f"w{w}"}})    print(f"width probe: recursion_limit=6 requested={w} research_ran={len(cg.SPEND)}")
text
width probe: recursion_limit=6 requested=1 research_ran=1width probe: recursion_limit=6 requested=10 research_ran=10width probe: recursion_limit=6 requested=1000 research_ran=1000

A limit of 5 raises GraphRecursionError on this graph. A limit of 6 runs 1 task or 1,000 tasks equally happily, and two sentences of the documentation, read together, say why: "The recursion limit sets the maximum number of super-steps the graph can execute during a single execution", and "Nodes that run in parallel are part of the same super-step". A Send fan-out of any width is one super-step.

This is where the runtime redraws the graph. LangGraph's draw_mermaid() renders this fan-out as one dotted edge, plan -.-> research, whether the planner asks for 1 task or 1,000. Drawing and limit agree, and both leave out the number that decides the bill.

In LangGraph's recursion_limit Bounds Depth, Not Width I measured the same thing on 1.2.9 with a different graph: a limit of 4 let 1,000 workers run, and the step counter read 3 at every width. I called it a Width-Blind Bound, a limit that is correctly enforced on the depth axis in a system where the model chooses the width. It reproduces on 1.2.11. On main the stop condition in pregel/_loop.py is still self.stop = self.step + self.config["recursion_limit"] + 1, and nothing in it knows what a task is.

Check the default too, since it sets the size of what you are not bounding. According to the documentation it is 1000 steps. The shipped source reads DEFAULT_RECURSION_LIMIT = int(getenv("LANGGRAPH_DEFAULT_RECURSION_LIMIT", "10007")), and in the width article a never-ending graph with no limit set halted at exactly 10,007 super-steps.

Namesake Fix 2: max_concurrency bounds simultaneity, and Claude Code's subagent cap bounds a burst

As a name, max_concurrency is honest. The setting is a semaphore on how many tasks run at the same moment and places no limit on how many are dispatched, so every task you send still runs, just fewer at once. My width article measured 200 workers executing under max_concurrency=10.

This is the probe from the opening, on 1.2.11, with 40 Send tasks each sleeping 50 milliseconds:

Modemax_concurrency=4 placedTasks runPeak running at once
synctop level of the config404
syncinside configurable4012
syncnot set4012
asynctop level of the config404
asyncinside configurable4040
asyncnot set4040

Why LangGraph's max_concurrency does nothing inside configurable

Placed where the tip box places it, the setting is identical to not setting it. The sync peak of 12 is ThreadPoolExecutor's default of min(32, cpu_count + 4) on this 8-CPU machine. Placed correctly, it caps simultaneity and still runs all 40. Issue #8517, open since 3 August, reports a second gap: ToolNode's async path ignores max_concurrency for multiple tool calls.

Against "cap parallel workers", top-level max_concurrency is the right setting and delivers exactly that. It becomes a Namesake Fix when used to "bound the fan-out", as in my book's summary and the forum answer to a fan-out question. Neither placement in the table bounds how much work a fan-out creates, and a setting that runs all 1,000 tasks four at a time does nothing for spend.

Claude Code draws the same line differently for subagents and documents it precisely. Its limit refuses rather than queues: "By default, when 20 subagents are running in a session, spawning another with the Agent tool fails with Concurrent subagent limit reached, and the error tells Claude not to retry." That bounds a single burst of spawns from one message, which is what the v2.1.217 release note said it was for. It does not bound a session. The same page says "There's no limit on the total number of subagents Claude can spawn over a session", and v2.1.224, released 7 August 2026, removed the old 200-per-session cap.

Its documentation also lists what the limit does not cover. "Sessions with ultracode active are exempt: the limit isn't enforced there." An in-session fork started with /subtask "takes a slot while it runs and is never blocked by the limit", and resuming a finished subagent "takes a fresh slot without checking the limit, so resumes can push the running count past it." The Workflow tool has its own setting, CLAUDE_CODE_WORKFLOW_MAX_CONCURRENT_AGENTS, added in v2.1.269. Issue #92311, open as of 14 September, reports a Workflow that ran 7 agents at once under CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS=3 with ultracode active, and I have not reproduced it. None of this is hidden. It becomes a Namesake Fix only when someone reads "concurrent subagent limit" as "subagent budget", and I did not find that mistake written down, so I am not claiming it.

Namesake Fix 3: an operator.add reducer defines a merge, not an order-independent one

Now hold everything else fixed and rename one reviewer node. In both variants the facts reviewer approves and the policy reviewer rejects.

python
# excerpt from probes.pyfor names in (("facts_review", "policy_review"), ("facts_review", "compliance_review")):    app = build(*names)    config = {"recursion_limit": 25, "configurable": {"thread_id": "m" + names[1]}}    try:        out = app.invoke(INPUT, config)        print("merge probe:", names, "verdicts", out["verdicts"], "-> ended")    except GraphRecursionError:        verdicts = app.get_state(config).values["verdicts"][:2]        print("merge probe:", names, "verdicts", verdicts, "-> never ends (recursion error)")
text
merge probe: ('facts_review', 'policy_review') verdicts ['approve', 'reject'] -> never ends (recursion error)merge probe: ('facts_review', 'compliance_review') verdicts ['reject', 'approve'] -> ended

With policy_review the graph loops until the recursion limit stops it. With compliance_review it ends after one round, approved. One string differs between the two runs: the name passed to add_node.

Both reviewers write verdicts in the same super-step, and operator.add concatenates their writes in the fixed order LangGraph applies writes from one super-step. apply_writes in pregel/_algo.py sorts the tasks by task path "to ensure deterministic order for update application", and for edge-triggered nodes that path is the node name. "facts_review" sorts before "policy_review", so the rejection lands last in the first run. "compliance_review" sorts before "facts_review", so in the second run the approval lands last.

The order is deterministic, but it is not promised. The documentation says "updates from a parallel superstep may not be ordered consistently", and tells you to write an explicit ordering field if you need one. In LangGraph Reducers Are a Concurrency Policy I called this Order-Coupled State: state whose value depends on the order its writes are folded in, where the runtime's task ordering decides that order and nothing in the data does. A non-commutative reducer makes a key a hazard, and two writers in one super-step plus a reader that cares about position make it a defect. Here gate is that reader.

A reducer does define a merge, so the first half of puppyone's item is satisfied as written. Its property sits in the second half: "Define reducers, immutable branch outputs, or deterministic merge nodes instead of hoping concurrent writes compose." operator.add is not order-independent, so with it you are still hoping. LangGraph also steers people to exactly this reducer: a same-super-step write to a key with no reducer raises InvalidUpdateError, and the INVALID_CONCURRENT_GRAPH_UPDATE troubleshooting page fixes it with Annotated[list, operator.add]. Future AGI's guide says that without a reducer a parallel write "silently overwrites" another. On LangGraph it raises, and the box gets ticked the moment operator.add silences that error.

Namesake Fix 4: a checkpointer makes the run resumable, including its step budget

Take the rejecting variant, which loops forever, and run it with recursion_limit=25 and a width of 3. One invocation runs five rounds and 15 research tasks, and then the hard stop does its job. Now do what a checkpointer exists for and resume the same thread with invoke(None, config) three more times:

text
resume probe: research tasks per invocation [15, 18, 15, 15] total 63

The limit was sized to allow 15 research tasks, and 63 ran. Each resume received a fresh allowance, because LangGraph computes the stop condition on entry to every invocation from the current step, not from a spent total. A resume also gets two more steps than a fresh run, which spends two applying its input. Whether those two steps land on a research super-step depends on where in the five-step round the previous invocation stopped. That is why the second invocation ran 18 tasks and the later ones 15.

I measured this in LangGraph Checkpoints Restore Your Limits, Not Just Your State and called it a Self-Restoring Bound: a guard re-derived from restored state on every entry, so it comes back at full after a resume, a retry, or an ordinary second turn, by working exactly as designed. No crash is required.

The checkpointer did precisely what "resumable" means: it resumed the run, and with it a step budget that recursion_limit counts per invocation rather than per thread. The obvious repair is a counter in graph state, which swarnendu.de's LangGraph best practices guide recommends ("Add hard stops: a max_steps counter") next to a checkpointer "so you can pause/resume". That counter survives a clean resume, and steps per thread is the unit the checklist means, so it is a better bound than recursion_limit and not a Namesake Fix. Its weakness is durability. My checkpoint article showed from the durability contract that it rolls back after a crash under the default durability="async", and a fork restores it the same way. LangChain's ToolCallLimitMiddleware keeps its thread counter in graph state too, and has the same gap.

Advice that fails when followed faithfully has a published precedent. The ACRFence paper notes that agent frameworks with checkpoint-restore advise "developers to make external tool calls safe to retry", and shows that the advice "assumes that a retried call will be identical to the original", which LLM agents do not guarantee. It is a close cousin of the Namesake Fix, with advice misleading instead of a setting's name.

OpenAI Agents SDK max_turns: width fails the same way, an approval pause does not

Four findings on one runtime could be one runtime's quirks, so I ran the width, concurrency and resume probes again on the OpenAI Agents SDK 0.22.2, released 9 September 2026. The SDK ships a ScriptedModel test double that stood in for the model, so these probes need no API key either. Its hard bound is max_turns, and the Runner.run docstring defines the unit: "A turn is defined as one AI invocation (including any tool calls that might occur)."

python
# width probe, the same test as in oai_r3.py (openai-agents 0.22.2)import asynciofrom agents import Agent, Runner, function_tool, set_tracing_disabledfrom agents.exceptions import MaxTurnsExceededfrom agents.testing import ScriptedModel, assistant_message, function_callset_tracing_disabled(True)RAN = []@function_toolasync def research(target: str) -> str:    RAN.append(target)    return f"notes on {target}"def fan_out_step(width, prefix="c"):    return [function_call("research", {"target": f"s{i}"}, call_id=f"{prefix}{i}")            for i in range(width)]model = ScriptedModel([fan_out_step(1000), [assistant_message("done")]])agent = Agent(name="researcher", model=model, tools=[research])try:    asyncio.run(Runner.run(agent, "go", max_turns=1))except MaxTurnsExceeded as e:    print(e)        # Max turns (1) exceededprint(len(RAN))     # 1000

The SDK probes, from oai_probes.py and oai_r3.py, printed:

text
width max_turns=1: research_ran=1000 (Max turns (1) exceeded)concurrency probe: max_function_tool_concurrency=None ran=40 peak=40concurrency probe: max_function_tool_concurrency=4 ran=40 peak=4resume probe: [('first run', 9, 1), ('resumed from JSON', 6, 'MaxTurnsExceeded: Max turns (5) exceeded')] total research 15 model calls 5snapshot twice: before pause 9 resume #1 (6, 'Max turns (5) exceeded') resume #2 of same JSON (6, 'Max turns (5) exceeded') total 21resume with max_turns=99 passed: (6, 'Max turns (5) exceeded')new-run probe: research per Runner.run with max_turns=5 [15, 15, 15] total 45

And the LangGraph probes that match them operation for operation, from lg_interrupt.py and lg_second_turn.py, with three research tasks per round in both runtimes:

text
langgraph interrupt probe (recursion_limit=15, 3 supersteps per round): [('until approval pause', 9, True), ('after approval resume', 15, 'GraphRecursionError')] total 24langgraph second-turn probe (new input each turn, recursion_limit=15): [15, 15, 15] total 45
Property the checklist asks forLangGraph 1.2.11OpenAI Agents SDK 0.22.2
A hard bound that covers fan-out widthrecursion_limit=6 let 1,000 research tasks runmax_turns=1 let 1,000 tool calls run before it raised
A cap on parallel workerstop-level max_concurrency=4 held the peak at 4 and ran all 40, and inside configurable it did nothingmax_function_tool_concurrency=4 held the peak at 4 and ran all 40
A bound that survives an approval pausere-granted: 9 tasks before the interrupt() pause and 15 after, against a limit sized for 15kept for one resume: 3 turns before the pause, 2 after, then MaxTurnsExceeded at 5, but resuming the same saved JSON again ran those 2 turns again
A run that already hit its boundresumable with invoke(None), with a fresh allowance: 63 tasks against 15not resumable, because MaxTurnsExceeded carries no RunState, so a retry is a new run
Starting again on the same conversationre-granted on every invocation: 15, 15 and 15 tasks with new input each timere-granted on every Runner.run: 15, 15 and 15 tasks against max_turns=5

Width fails identically. One model response carrying 1,000 tool calls is one turn, and even at max_turns=1 all 1,000 calls ran, because the limit is checked before the next model call rather than before any tool runs. The docstring's own definition of a turn includes "any tool calls that might occur", so the Width-Blind Bound is not a LangGraph quirk. ModelSettings.parallel_tool_calls exists as well, but the SDK's documentation says it "controls whether the model is allowed to emit multiple tool calls in a single response". The provider enforces it, not the SDK, so the scripted model cannot test it, and it bounds width by removing parallelism rather than by capping it. The concurrency setting is honestly named on both runtimes, and the SDK's running-agents documentation says its default "starts all emitted local function tool calls".

An approval pause is where the two runtimes part. The SDK's RunState serialises current_turn next to max_turns and restores both in from_json, so a run paused for approval and resumed from JSON finished its allowance instead of receiving a new one. I found that in run_state.py and confirmed it in the probe. The human-in-the-loop documentation describes resuming "the original top-level run" but does not state what happens to the turn count. On resume the SDK also takes max_turns from the snapshot, so passing max_turns=99 to the resuming Runner.run changed nothing: a small setting of its own that looks set and is not.

That kept count has the durability weakness of a counter in LangGraph state, though. It lives in a JSON snapshot the caller owns, and nothing marks a snapshot as used. Resuming the same saved JSON a second time ran the last two turns again, 21 research tasks against an allowance of 15. A process that crashes after resuming leaves you holding that same snapshot.

So a hard stop you trust to survive an approval pause is a Namesake Fix on LangGraph, as the wrong-way graph shows, and holds for one clean resume on the OpenAI Agents SDK. Both runtimes start over on a new invocation or a new run, and the SDK reaches that point for a run that hit its bound only because it refuses to resume one. Nothing in the names recursion_limit and max_turns tells you which behaviour you have, and knowing one runtime would have given you the wrong answer about the other. That is the strongest reason I have for testing the property instead of reading the setting. I did not port the merge probe, because the SDK has no reducer over shared state to put under the same test.

What a Namesake Fix is, and how it differs from a Scope-Blind Guarantee

A Namesake Fix has three parts:

  1. A checklist item names a property: bounded, capped, merged, resumable.
  2. A runtime setting whose name shares the item's words is offered, in writing or in code, as satisfying it.
  3. The setting enforces a different property, axis or unit.

The diagram translates one checklist item two ways, by name along the top path and by test along the bottom.

mermaid
flowchart LR
    A["Checklist item<br/>bound the worst case"] -->|"translated by name"| B["recursion_limit=6"]
    B -->|"enforces"| C["super-steps per invocation"]
    C -->|"box ticked"| F["1,000 research tasks run"]
    A -->|"translated by test"| D["check_width: request 1000, allow 8"]
    D -->|"fails until"| E["cap of 8 at the Send site"]
    E -->|"test passes"| G["8 research tasks run"]

    style A fill:#4A90E2,color:#FFFFFF,stroke:#2C6FB0
    style B fill:#FFD93D,color:#2C2C2A,stroke:#C9A800
    style C fill:#FFD93D,color:#2C2C2A,stroke:#C9A800
    style F fill:#E74C3C,color:#FFFFFF,stroke:#A93226
    style D fill:#7B68EE,color:#FFFFFF,stroke:#5A4BC4
    style E fill:#6BCF7F,color:#2C2C2A,stroke:#3E9E52
    style G fill:#6BCF7F,color:#2C2C2A,stroke:#3E9E52

A new term has to justify itself against the nearest one I already had. In LangGraph Names the Guarantee, Not the Unit I defined a Scope-Blind Guarantee: a runtime guarantee that is real and correctly enforced, whose unit of scope differs from the one the engineer assumes, and that appears on no artifact the framework renders that would correct the assumption. Whether a guarantee is Scope-Blind is a question about the runtime: does any rendered artifact, such as the setting's name, a type, the drawing or the checkpoint, state the unit?

Whether a fix is a Namesake Fix is a question about the translation: was a setting whose name matches the item offered as satisfying it? For third-party sources I can see the offer and not the reason, so "because of the name" is my inference, while for my own book it is not. These two questions are independent.

Checklist wordingSetting offered for itWhat it enforcesWhere the offer is written downScope-Blind Guarantee?
"bound the worst case"recursion_limitsuper-steps per invocation, so neither width (Fix 1) nor re-entry (Fix 4)CallSphere's supervisor guide; I found no written instance pairing it with resumeYes, Part 8's entries for Parts 4 and 6
"bounded the fan-out"max_concurrencytasks running at the same momentmy book's objective and summary, and the forum answer to a fan-out questionNo. The name states the unit
"Define reducers ... instead of hoping concurrent writes compose"operator.adda merge whose result depends on task-path orderpuppyone's checklist, and the INVALID_CONCURRENT_GRAPH_UPDATE page's exampleYes, Part 8's entry for Part 2

Fix 4's state counter is not in the table. Its name and its unit are both right, and Part 8 keeps the same pattern, thread_limit, out of the Scope-Blind category as a strength problem rather than a unit problem. It fails on durability, a real gap and a different one.

The max_concurrency row shows the two terms are not the same. Part 8 and my book audit both exclude it from the Scope-Blind category because its name carries its unit, and I still translated "bound the fan-out" into it after measuring it. The reverse happens too: Part 8's BaseStore and eval-suite entries are Scope-Blind Guarantees with no checklist translation involved at all. As for the configurable placement from the opening, it is neither. It is the right setting, picked for the right reason, silently ignored.

Part 8 also gave a cheap test for spotting a unit before you probe: "Take each guarantee and state its unit." The two-noun check points that test at a checklist. Write the noun the checklist item is about next to the noun the setting counts. "Bound the fan-out" is about dispatched tasks, while recursion_limit counts super-steps and max_concurrency counts running tasks. A hard bound that survives resume is about total work on a thread, and recursion_limit counts steps per invocation. Different nouns mean a unit mismatch, and if the setting was offered for the item because its name matched, that mismatch is a Namesake Fix. The check flags unit mismatches only, so it catches the first two rows and misses the reducer, whose gap is a missing property rather than a different unit. Better documentation cannot prevent the pick and a better diagram cannot show it. A test that counts the checklist's noun catches it.

Three objections to the Namesake Fix

"This is just misconfiguration, and reading the documentation fixes it"

Reading helps for some rows and not others. recursion_limit needs two sentences combined before it tells you width is free, and I could not find the resumed allowance documented anywhere. max_concurrency is the harder case for this objection. Its documentation and its name are both clear, and I had read both and measured the setting before my book's summary translated "bound the fan-out" into it anyway. The translation happens where people skim, in objectives, summaries and checklists, and more careful reading of the setting's page never reaches those. A test does.

"Nobody actually uses these settings to bound a fan-out"

The three examples above are all in writing: CallSphere's guide on recursion_limit, a community answer on LangChain's own forum pointing a fan-out question to max_concurrency, and my book, which said max_concurrency bounded the fan-out after the series it grew out of had measured that it does not. None of these writers is careless, and the translation still happened.

"Then write a better checklist that names the right settings"

A checklist that names settings is tied to a runtime version, and settings move under it. Digital Applied's guide to Claude Code's subagent guardrails, published 26 July 2026, listed a per-session total of 200 among them, and v2.1.224 removed it twelve days later. The tests below are tied to the runtime too: they call invoke, catch GraphRecursionError and pass a thread_id. They do not depend on what any setting means, because each one counts the property itself.

The right way: write each property as a test, then enforce it where the work happens

Start with the tests. Each one exercises a property from the checklist and never mentions the setting that claims to provide it.

python
# contract_tests.pyimport itertoolsfrom langgraph.errors import GraphRecursionErrorimport checklist_graph as cg_ids = itertools.count()INPUT = {"requested_width": 1, "findings": []}NAMES = ("policy_review", "compliance_review", "zz_review")def approved(app, inputs=INPUT, limit=25):    cfg = {"recursion_limit": limit, "configurable": {"thread_id": f"t{next(_ids)}"}}    try:        app.invoke(inputs, cfg)    except GraphRecursionError:        pass    snapshot = app.get_state(cfg)             # observe how the run actually finished    return snapshot.next == () and snapshot.values.get("approved") is Truedef check_width(module, max_fanout=8):    cg.SPEND.clear()    approved(module.build(policy_votes=cg.approve), {**INPUT, "requested_width": 1000})    return len(cg.SPEND) <= max_fanout, f"one dispatch ran {len(cg.SPEND)} of 1000 requested"def check_merge(module):    split = {approved(module.build("facts_review", n, cg.reject)) for n in NAMES}    agree = {approved(module.build("facts_review", n, cg.approve)) for n in NAMES}    ok = split == {False} and agree == {True}    return ok, f"approve+reject approved={sorted(split)}  approve+approve approved={sorted(agree)}"def check_resume(module, budget=20):    cg.SPEND.clear()    app = module.build()    cfg = {"recursion_limit": 25, "configurable": {"thread_id": f"t{next(_ids)}"}}    inputs = {**INPUT, "requested_width": 3}    for _ in range(4):        try:            app.invoke(inputs, cfg)        except GraphRecursionError:            pass        inputs = None    return len(cg.SPEND) <= budget, f"{len(cg.SPEND)} research tasks across 4 invocations"if __name__ == "__main__":    import checklist_graph, fixed_graph    for label, module in (("checklist graph", checklist_graph), ("fixed graph", fixed_graph)):        print(label)        for check in (check_width, check_merge, check_resume):            ok, detail = check(module)            print(f"  {'PASS' if ok else 'FAIL'}  {check.__name__:<13} {detail}")

check_width counts what one approving round dispatched after asking for 1,000, which makes it a per-dispatch test. check_resume covers the total by counting across four invocations of one thread. check_merge reads how each run actually finished, from the approved flag the gate recorded and whether the run reached its end. It requires the right decision rather than a consistent one: a split vote must never be approved under any reviewer name, and a unanimous vote must be approved under every name. Setting something with the right name satisfies none of them.

There is a fair objection to these tests. Nobody writes "rename each writer node" or "resume four times" without already knowing about the task-path sort and the per-invocation allowance, and I knew because I had run the probes. Their general forms need no such knowledge. For a merge, vary every input that should not matter, such as node names and completion order, and require the same decision. For a bound, count across every way work re-enters the graph: resume, retry, a second turn, a fork, a crash. These three tests are instances of those forms, not all of them. check_resume covers clean re-entry only, so it would pass a counter kept in graph state. A crash or a fork needs its own test, with a failure injected after the side effect.

The graph that passes them changes three things and keeps recursion_limit, which is a fine per-invocation guard as long as nobody treats it as a cost control.

python
# fixed_graph.pyimport operatorimport sqlite3import threadingfrom typing import Annotated, TypedDictfrom langgraph.checkpoint.memory import InMemorySaverfrom langgraph.graph import END, START, StateGraphfrom langgraph.types import Sendfrom checklist_graph import SPEND, approve, reject, synthesiseMAX_FANOUT = 8                     # most research tasks one dispatch may createRUN_BUDGET = 20                    # most research tasks one thread may ever executeREQUIRED_ROLES = ("facts", "policy")class State(TypedDict):    requested_width: int    targets: list[str]    findings: Annotated[list[str], operator.add]    verdicts: Annotated[dict[str, str], operator.or_]  # one key per reviewer role    approved: boolclass SpendLedger:    """Spend lives outside the checkpointer, so no resume can restore it.    Single-process demo: one SQLite connection guarded by a lock. The charge is one    conditional UPDATE, which stays atomic when this table moves to Postgres.    """    def __init__(self, path=":memory:"):        self.db = sqlite3.connect(path, isolation_level=None, check_same_thread=False)        self.lock = threading.Lock()        self.db.execute(            "CREATE TABLE IF NOT EXISTS spend (thread_id TEXT PRIMARY KEY, used INTEGER NOT NULL)"        )    def try_charge(self, thread_id: str, budget: int) -> bool:        with self.lock:            self.db.execute("INSERT OR IGNORE INTO spend VALUES (?, 0)", (thread_id,))            cur = self.db.execute(                "UPDATE spend SET used = used + 1 WHERE thread_id = ? AND used < ?",                (thread_id, budget),            )            return cur.rowcount == 1    def remaining(self, thread_id: str, budget: int) -> int:        with self.lock:            row = self.db.execute(                "SELECT used FROM spend WHERE thread_id = ?", (thread_id,)            ).fetchone()            return budget - (row[0] if row else 0)LEDGER = SpendLedger()def plan(state, config):    left = LEDGER.remaining(config["configurable"]["thread_id"], RUN_BUDGET)    width = max(0, min(state["requested_width"], MAX_FANOUT, left))    return {"targets": [f"source-{i}" for i in range(width)]}def fan_out(state):    if not state["targets"]:        return "budget_exhausted"    return [Send("research", {"target": t}) for t in state["targets"][:MAX_FANOUT]]def research(payload, config):    if not LEDGER.try_charge(config["configurable"]["thread_id"], RUN_BUDGET):        return {}                  # charged before the work: no charge, no work    SPEND.append(payload["target"])    return {"findings": [payload["target"]]}def as_role(role, vote):    return lambda state: {"verdicts": {role: vote(state)["verdicts"][0]}}def gate(state):    verdicts = state.get("verdicts", {})    return {"approved": all(verdicts.get(r) == "approve" for r in REQUIRED_ROLES)}def decide(state):    return END if state["approved"] else "plan"def build(facts="facts_review", policy="policy_review", policy_votes=reject):    g = StateGraph(State)    g.add_node("plan", plan)    g.add_node("research", research)    g.add_node("synthesise", synthesise)    g.add_node(facts, as_role("facts", approve))    g.add_node(policy, as_role("policy", policy_votes))    g.add_node("gate", gate)    g.add_node("budget_exhausted", lambda s: {"approved": False})    g.add_edge(START, "plan")    g.add_conditional_edges("plan", fan_out, ["research", "budget_exhausted"])    g.add_edge("research", "synthesise")    g.add_edge("synthesise", facts)    g.add_edge("synthesise", policy)    g.add_edge([facts, policy], "gate")    g.add_conditional_edges("gate", decide, ["plan", END])    g.add_edge("budget_exhausted", END)    return g.compile(checkpointer=InMemorySaver())
text
checklist graph  FAIL  check_width   one dispatch ran 1000 of 1000 requested  FAIL  check_merge   approve+reject approved=[False, True]  approve+approve approved=[True]  FAIL  check_resume  63 research tasks across 4 invocationsfixed graph  PASS  check_width   one dispatch ran 8 of 1000 requested  PASS  check_merge   approve+reject approved=[False]  approve+approve approved=[True]  PASS  check_resume  20 research tasks across 4 invocations

Width is capped where dispatch happens. fan_out creates the Send objects in this graph, so the slice there is the width bound, and plan also shrinks the request to what the budget has left. That covers one dispatch site. A node elsewhere that returns Command(goto=[Send(...)]) would be a second site that never passes through fan_out, so either route every dispatch through one function or give each site its own cap. If your planner is a model call, add max_length to its output schema as well, which bounds width for that one planner and not for every dispatch site.

The merge no longer depends on names, and the gate fails closed. Each reviewer writes its own key, so operator.or_ merges dictionaries whose keys differ, and no fold order can change the result. gate checks every required role by name, so a missing verdict, or a renamed role that never writes, is a refusal rather than an approval. Checking all() over whatever verdicts happen to be present would approve an empty dictionary, which is how a gate fails open. budget_exhausted records approved: False, so a caller can tell a run that ran out of budget from one that was approved. If you need order rather than independence from it, take the documentation's advice: write an explicit order field and sort at the join.

Spend is charged where it happens, outside the rollback surface. The checkpointer restores everything in graph state, so the spent total cannot live there. Each research task charges one unit with a single conditional UPDATE ... WHERE used < ? before doing any work and does nothing if the charge fails, so no more than RUN_BUDGET research tasks can ever execute on a thread in this process. A task re-run after a crash or a retry is charged again, which spends budget without doing new work but never lets execution pass the budget. This ledger records only the count. Telling a charge that did its work from one whose work was lost needs intent and outcome recorded separately, as the grant ledger in my checkpoint article does. A refused charge also leaves that source unresearched, and this demo's gate does not check completeness, so in production a refusal should send the run to budget_exhausted rather than on to review. It is a single-process demo, with an in-memory database and one lock-guarded connection. In Postgres the same conditional UPDATE stays atomic across processes. Check what you key it on, too: a fork to a new thread_id gets a fresh budget here, so if your system forks threads, key the ledger on the business task instead.

For Claude Code, the dispatch path is the Agent tool call. The hooks documentation lists PreToolUse as able to block a tool call and Agent among the tools it can match, so a spawn count there sits on the path every Agent-tool spawn takes. It does not see a /subtask fork, which is a slash command rather than an Agent tool call, and I have not checked whether resuming a subagent passes through it. As I argued in Skills vs Hooks in Claude Code, a hook is a gate the model cannot argue with. That same documentation says "SubagentStart hooks can't block subagent creation". In #92311, the reporter describes a SubagentStart hook that exits with code 2 as a working workaround, and I trust the documentation over a single report until someone reproduces it.

Three things belong in your test and not in your assumptions. I have not checked whether parallel Agent calls in one message fire their hooks one at a time, so assume they can overlap and give a hook's spawn counter the same atomic store as the ledger above: a file lock, or SQLite with one connection per process. A PreToolUse command hook that times out lets the tool call continue. Nor have I tested whether agents launched inside a Workflow run pass through a PreToolUse hook on Agent. Claude Code also has --max-budget-usd, which since v2.1.217 refuses new subagent spawns once the cap is reached. The CLI reference marks it "print mode only", and it counts dollars, not agents.

A checklist that turns each graph engineering fix into a test

Checklist itemSetting found by the checklist's wordTest that exercises the propertyEnforcement that passes it
Bound the fan-outrecursion_limit, or max_concurrencyrequest 1,000 in one dispatch and count what runsa cap at every Send site, plus max_length on a model planner's schema
Define reducers so concurrent writes composeoperator.addrename each writer node and require the right decision on split and unanimous votesone key per writer and a gate that checks required roles, or an explicit order field sorted at the join
Hard stops that survive pause/resumerecursion_limit with a checkpointer (a Namesake Fix on LangGraph, while the OpenAI Agents SDK keeps a paused run's turn count for one resume of its snapshot), or a counter in graph state (a durability gap)resume four times and count total executions, then inject a crash after a side effect and count againa charge per execution in a ledger outside the checkpointer
Cap parallel workerstop-level max_concurrency, which is the right settingmeasure the peak running count in a probetop-level max_concurrency, with the peak asserted in a test
Bound subagents in Claude Codethe concurrent subagent limit, as a risk only, since I found no one using it as a session budget in writingspawn under ultracode, /subtask and resumes, and counta tested PreToolUse hook on Agent with an atomic counter, which covers Agent-tool spawns only

For your own graph, this week:

  1. Take the reliability checklist you are working from, whichever one it is, and write next to each item the setting you used to satisfy it.
  2. Run the two-noun check on each pair: the noun the checklist item is about, and the noun the setting counts, such as super-steps, running tasks, list order or steps per invocation. If the nouns differ and you picked the setting by its name, you have a Namesake Fix.
  3. Write the test for the checklist's noun, not the setting's, and run it against the current graph before you change anything. The failing run is your evidence.
  4. Put each width cap at the place work is dispatched, and charge each unit of spend where the work runs, in a store that no checkpointer restores.
  5. Keep the settings, because recursion_limit still stops a routing loop inside one invocation and max_concurrency still protects a rate limit. Stop treating them as the answer to a checklist item they do not answer.

Limits of this analysis: LangGraph and the OpenAI Agents SDK measured, Claude Code from its documentation

The LangGraph reproductions are on 1.2.11, and I measured the configurable placement on 1.2.11 only. It may be fixed in the documentation or the runtime by the time you read this, and the probe will tell you. The OpenAI Agents SDK 0.22.2 probes use its ScriptedModel test double instead of a real model, which is sound for turn counting and tool dispatch but says nothing about how a real model chooses a width. I did not port the merge probe, and I did not test Google's Agent Development Kit (ADK) or Microsoft's agent framework. The approval-pause result already differs between the two runtimes I did test, so port the three tests to your own runtime rather than infer from either of mine.

My Claude Code claims come from its documentation, changelog, release notes and issue #92311, and I did not reproduce the ultracode bypass. Nor did I run a crash test against the ledger, so its behaviour under a crash is stated from the code, not measured. I found no public incident report of reducer order or a resumed step budget causing damage in a deployed LangGraph system. The graph above is constructed to show the mechanism, and its merge flip needs the naive last-verdict gate. My reducer article measured order changes, not changed model answers.

Graph engineering got the diagram right, and the checklists name the right failures. On LangGraph, the settings you find by searching for the checklist's words still enforce something nearby. What told them apart for me was a test that knew none of those settings' names.

References


Agentic AI

Follow for more technical deep dives on AI/ML systems, production engineering, and building real-world applications:


Get the next article by email

One email when a new piece goes up. No digest, no drip sequence.

One email per new article. Unsubscribe in one click.

Books by Ranjan Kumar

The 7 GenAI Architectures cover

The 7 GenAI Architectures

Building Real-World Agentic AI Systems with LangGraph cover

Building Real-World Agentic AI Systems

The ChatML Handbook cover

The ChatML Handbook

The Chat Templates Handbook cover

The Chat Templates Handbook

Comments