5858
5959from __future__ import annotations
6060
61- from typing import Any
61+ import operator
62+ from typing import Annotated, Any
6263
6364from pydantic import BaseModel
6465
8788
8889class 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
117124def _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
132139def _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 ────────────────────────────────────────────────────
0 commit comments