Skip to content

Commit 75efbd0

Browse files
A parallel plan crashed the scaffold: shared fields become reducers (#91)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 5e2774d commit 75efbd0

4 files changed

Lines changed: 92 additions & 11 deletions

File tree

grapharc/cli/init_cmd.py

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,8 @@
5858
5959
from __future__ import annotations
6060
61-
from typing import Any
61+
import operator
62+
from typing import Annotated, Any
6263
6364
from pydantic import BaseModel
6465
@@ -87,7 +88,13 @@
8788
8889
class State(BaseModel):
8990
goal: str = "" # filled from the CLI argument; the planner reads it
90-
notes: list[str] = [] # the working record every kind appends to
91+
# `notes` is a REDUCER (Annotated + operator.add): each writer returns just
92+
# its own lines and LangGraph merges them, so two nodes — or two
93+
# planner-named instances of ONE kind — may write it in the same parallel
94+
# step. A plain `list[str]` here crashes the first time a planner runs two
95+
# writers concurrently (InvalidUpdateError); keep the pattern for any field
96+
# more than one node may write.
97+
notes: Annotated[list[str], operator.add] = []
9198
report: str = "" # the deliverable; the goal check below watches it
9299
93100
@@ -111,7 +118,7 @@ def _gather(state: State) -> dict:
111118
f"({', '.join(dirs[:12]) or 'none'}) and {len(files)} file(s) "
112119
f"({', '.join(files[:12]) or 'none'})"
113120
)
114-
return {"notes": [*state.notes, note]}
121+
return {"notes": [note]}
115122
116123
117124
def _analyse(state: State) -> dict:
@@ -126,7 +133,7 @@ def _analyse(state: State) -> dict:
126133
note = "analyse: file types by count — " + ", ".join(
127134
f"{ext} x{count}" for ext, count in top
128135
)
129-
return {"notes": [*state.notes, note]}
136+
return {"notes": [note]}
130137
131138
132139
def _report_for(model: Any):
@@ -142,7 +149,7 @@ def body(state: State) -> dict:
142149
if model is None or scripted or not hasattr(model, "invoke"):
143150
return {
144151
"report": "report: run with --model SPEC for a model-written report",
145-
"notes": [*state.notes, "report: written without a model"],
152+
"notes": ["report: written without a model"],
146153
}
147154
try:
148155
reply = model.invoke(
@@ -153,7 +160,7 @@ def body(state: State) -> dict:
153160
text = str(getattr(reply, "content", reply)).strip()[:2000]
154161
except Exception as exc: # a failed call is a note, not a crash
155162
text = f"report: model call failed ({exc}); notes stand"
156-
return {"report": text, "notes": [*state.notes, "report: written"]}
163+
return {"report": text, "notes": ["report: written"]}
157164
158165
return body
159166
@@ -164,7 +171,7 @@ def _apply(state: State) -> dict:
164171
propose it) and DENIED by the edge policy below (no admitted graph may
165172
reach it) until you decide otherwise. Keep the pattern even after you
166173
rename it: a gate with nothing to refuse proves nothing."""
167-
return {"notes": [*state.notes, "apply: this should not have run"]}
174+
return {"notes": ["apply: this should not have run"]}
168175
169176
170177
# ── 3. Write permissions ────────────────────────────────────────────────────

grapharc/examples/plan_incident.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@
2020
from __future__ import annotations
2121

2222
import json
23-
from typing import Any
23+
import operator
24+
from typing import Annotated, Any
2425

2526
from pydantic import BaseModel
2627

@@ -44,10 +45,17 @@
4445

4546

4647
class IncidentState(BaseModel):
47-
"""One state contract for the whole run, however the topology changes."""
48+
"""One state contract for the whole run, however the topology changes.
49+
50+
`notes` is a reducer (`Annotated` + `operator.add`): each writer returns
51+
only its own lines and LangGraph merges them, so a planner that runs two
52+
writers in the same parallel step — including two instances of one kind —
53+
composes instead of colliding. A plain `list[str]` here raises
54+
`InvalidUpdateError` the first time that happens.
55+
"""
4856

4957
goal: str = ""
50-
notes: list[str] = []
58+
notes: Annotated[list[str], operator.add] = []
5159

5260

5361
def _step_factory(spec: NodeSpec) -> Any:
@@ -59,7 +67,7 @@ def _step_factory(spec: NodeSpec) -> Any:
5967
"""
6068

6169
def body(state: IncidentState) -> dict:
62-
return {"notes": [*state.notes, f"{spec.name} ran"]}
70+
return {"notes": [f"{spec.name} ran"]}
6371

6472
body.writes = {"notes"}
6573
return body

tests/test_cli.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2198,6 +2198,43 @@ def test_an_init_scaffold_plans_end_to_end(tmp_path, monkeypatch, capsys):
21982198
assert "goal_met" in printed
21992199

22002200

2201+
def test_the_scaffold_state_merges_parallel_writers(tmp_path, monkeypatch):
2202+
"""Two kinds writing `notes` in the same superstep compose via the reducer.
2203+
2204+
The shape any real planner eventually proposes: `gather` and `analyse`
2205+
both fanned out of START, joining at `report`. With a plain `list[str]`
2206+
this run died on LangGraph's InvalidUpdateError before `report` ever ran;
2207+
the scaffold's `notes` is a reducer now, and this test is what keeps it
2208+
one.
2209+
"""
2210+
monkeypatch.chdir(tmp_path)
2211+
from grapharc.cli.init_cmd import REGISTRY_TEMPLATE
2212+
from grapharc.testing import ScriptedChatModel
2213+
2214+
module = ModuleType("scaffold_registry")
2215+
# The path-form loader registers the module before executing it, and
2216+
# pydantic needs that to resolve the template's deferred annotations.
2217+
monkeypatch.setitem(sys.modules, "scaffold_registry", module)
2218+
exec(compile(REGISTRY_TEMPLATE, "registry.py", "exec"), module.__dict__)
2219+
plan = json.dumps(
2220+
{
2221+
"nodes": [{"name": "gather"}, {"name": "analyse"}, {"name": "report"}],
2222+
"edges": [
2223+
{"source": "__start__", "target": "gather"},
2224+
{"source": "__start__", "target": "analyse"},
2225+
{"source": "gather", "target": "report"},
2226+
{"source": "analyse", "target": "report"},
2227+
{"source": "report", "target": "__end__"},
2228+
],
2229+
}
2230+
)
2231+
loop = module.build_loop(ScriptedChatModel(responses=[plan]))
2232+
result = loop.run("report on this directory, twice over", module.State())
2233+
assert result.stop.value == "goal_met"
2234+
assert any(note.startswith("gather:") for note in result.state.notes)
2235+
assert any(note.startswith("analyse:") for note in result.state.notes)
2236+
2237+
22012238
def test_the_path_form_registry_shares_one_module_object(tmp_path, monkeypatch):
22022239
monkeypatch.chdir(tmp_path)
22032240
(tmp_path / "reg.py").write_text(

tests/test_planner_loop.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1632,3 +1632,32 @@ def test_the_disclosure_is_not_what_refuses_the_edge():
16321632
assert [r.model_dump() for r in with_disclosure.rejections()] == [
16331633
r.model_dump() for r in without.rejections()
16341634
]
1635+
1636+
1637+
def test_the_incident_example_state_merges_parallel_writers():
1638+
"""Three kinds writing `notes` in one superstep compose via the reducer.
1639+
1640+
The shipped example's state used a plain `list[str]`, so the first plan
1641+
that fanned kinds out of START died on LangGraph's InvalidUpdateError.
1642+
`IncidentState.notes` is a reducer now; this run is the shape that broke.
1643+
"""
1644+
from grapharc.examples.plan_incident import IncidentState
1645+
from grapharc.examples.plan_incident import build_loop as build_incident_loop
1646+
1647+
fan_out = json.dumps(
1648+
{
1649+
"nodes": [{"name": "triage"}, {"name": "patch"}, {"name": "verify"}],
1650+
"edges": [
1651+
{"source": "__start__", "target": "triage"},
1652+
{"source": "__start__", "target": "patch"},
1653+
{"source": "__start__", "target": "verify"},
1654+
{"source": "triage", "target": "__end__"},
1655+
{"source": "patch", "target": "__end__"},
1656+
{"source": "verify", "target": "__end__"},
1657+
],
1658+
}
1659+
)
1660+
loop = build_incident_loop(ScriptedChatModel(responses=[fan_out]))
1661+
result = loop.run("triage, patch and verify at once", IncidentState())
1662+
assert result.stop.value == "goal_met"
1663+
assert sorted(result.state.notes) == ["patch ran", "triage ran", "verify ran"]

0 commit comments

Comments
 (0)