work_graph_plan.py
20.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
"""c_work_graph_plan (03-C §3.4): produces a `graph_manifest`, does not execute.
U-011 scope: a real `claude --agent <org_node> -p` call, recorded as a
`work_graph_run` row with `status='planning'` (04-P `p_work_graph_execution`
start_state).
**Empirical finding (2026-07-31)**: `--json-schema` does NOT constrain output
when combined with `--agent` -- tried it for real, the response came back as
normal prose with an embedded ```json fenced block, `structured_output` was
entirely absent from the payload even though the identity itself came through
correctly (the response referenced "SOUL 原则 #10" by number, i.e. it was
genuinely reasoning as the migrated Planner). So this parses the
graph_manifest out of free-form response text instead of trusting
`structured_output` -- see `_extract_graph_manifest`. Not documented anywhere;
found by actually running it, not assumed.
Does NOT execute the resulting plan or produce artifacts -- that's
U-012 (`c_work_graph_execute` full)/U-013 (`c_work_node_run`), and nothing
today owns `c_artifact_write` either (REQ-02 lists it, no Unit in
development-plan.md builds it -- a real gap, flagged rather than silently
plugged, since it affects more than just this Unit's scope). REQ-02's happy
case (real BRD/PRD/SDD/Benchmark artifacts with lineage) needs all of that;
development-plan.md's U-011 Done line claimed it passes here alone, which
isn't accurate -- corrected in this session's notes, same pattern as
U-009/U-010.
"""
import json
import re
import subprocess
import uuid
GRAPH_MANIFEST_SCHEMA = {
"type": "object",
"required": ["nodes", "edges"],
"properties": {
"nodes": {
"type": "array",
"items": {
"type": "object",
"required": ["node_id", "artifact_type", "depends_on"],
"properties": {
"node_id": {"type": "string"},
"artifact_type": {
"type": "string",
"enum": ["brd", "prd", "sdd", "benchmark", "test_dataset", "test_script"],
},
"depends_on": {"type": "array", "items": {"type": "string"}},
# 2026-08-08 (Work Graph self-planning + loop-back) -- both optional,
# documentation only (this schema isn't jsonschema-enforced, see
# module docstring's --json-schema/--agent note).
"judgment": {
"type": "object",
"properties": {
"kind": {"type": "string", "enum": ["none", "mechanical", "llm"]},
"check_ref": {"type": "string"},
"judge_prompt": {"type": "string"},
},
},
"on_fail": {
"type": "object",
"properties": {
"action": {"type": "string", "enum": ["retry_self", "loop_back", "hard_fail"]},
"max_attempts": {"type": "integer"},
},
},
},
},
},
"edges": {
"type": "array",
"items": {
"type": "object",
"required": ["from", "to", "edge_type"],
"properties": {
"from": {"type": "string"},
"to": {"type": "string"},
# "on_failure" (2026-08-08): a node's failure path, expressed the
# same way its success path already is -- see _on_failure_target
# in work_graph_orchestrate.py.
"edge_type": {"type": "string", "enum": ["zero_token", "needs_judgment", "on_failure"]},
},
},
},
},
}
# 2026-08-08 (Work Graph self-planning + loop-back, docs/todo/work-graph-
# self-planning-and-loop-back-requirements.md, FR-B4 "全部自由规划"):
# Planner's doc-writing chain is no longer a fixed architectural constant
# (formally reverses docs/done/dynamic-work-graph-design-skill-design.md
# §3's decision) -- the LLM decides which node types this goal needs, in
# what order, and how each one's judgment/on_fail should work. This
# catalog is reference DATA the prompt hands the LLM, not a requirement
# that every entry appears -- FR-B2. test_script (disabled 2026-08-07,
# docs/todo/validator-test-script-narrative-output-issue.md) is back in
# this catalog per that decision's §8 item 7: Part B doesn't wait for the
# narrative-output structural fix, that fix is tracked separately
# alongside a future Coder-side test_script module.
_PRD_JUDGE_PROMPT = (
"Review the following PRD for internally CONTRADICTORY scenario assumptions -- e.g. two "
"requirements that cannot both hold at once, or the same scenario described two incompatible "
"ways. Do NOT flag mere incompleteness, ambiguity, or style issues, only genuine "
"self-contradiction. Reply via the required schema: ok=false only if you found a real "
"contradiction, and list each one in issues.\n\nPRD:\n\n{content}"
)
_PLANNER_NODE_CATALOG = [
{"artifact_type": "brd", "description": "Business Requirements Document -- why this goal matters, who it's for."},
{
"artifact_type": "prd", "description": "Product Requirements Document -- concrete scenarios/behavior.",
"suggested_judgment": {"kind": "llm", "judge_prompt": _PRD_JUDGE_PROMPT},
},
{"artifact_type": "sdd", "description": "Solution Design Document -- how it will be implemented."},
{"artifact_type": "benchmark", "description": "Concrete acceptance scenarios with observable pass criteria."},
{
"artifact_type": "test_dataset",
"description": "Concrete test data (inputs + expected outputs) if this goal has a natural one -- "
"many goals (UI wiring, config changes) don't, that's fine, omit this node then.",
},
{
"artifact_type": "test_script",
"description": "A real, runnable script that mechanically checks the Benchmark's scenarios -- "
"only include this if a genuine mechanical check is possible for this goal.",
"suggested_judgment": {"kind": "mechanical", "check_ref": "check_script_compiles"},
},
]
# NOTE: no `str.format()` involved in building or filling this template
# (unlike _GENERIC_PROMPT_TEMPLATE) -- the embedded catalog JSON and the
# instructional JSON examples below (e.g. {"kind": "none"}) are full of
# their own literal braces, which .format() would try to parse as format
# fields and crash on. plan_work_graph() fills in goal_text with a plain
# `.replace("{goal_text}", goal_text)` instead, which treats every other
# brace in this string as inert literal text -- no escaping needed at all.
_PROMPT_TEMPLATE = (
"Plan (do not execute) the Work Graph for this goal: {goal_text}\n"
"Available node types (use any subset, in any order that makes sense -- this goal may not "
"need all of them, and 'suggested_judgment' entries are starting points you can reuse or "
"override, not requirements):\n" + json.dumps(_PLANNER_NODE_CATALOG) + "\n"
"For each node you include, decide: node_id, artifact_type, depends_on (which earlier nodes' "
"output it genuinely needs), a judgment config (how to tell if this node's output is "
"acceptable -- {\"kind\": \"none\"} if nothing needs checking, {\"kind\": \"mechanical\", "
"\"check_ref\": ...} for a named mechanical check, or {\"kind\": \"llm\", \"judge_prompt\": "
"\"...{content}...\"} for your own judgment prompt with a {content} placeholder), and an "
"on_fail config (what happens if judgment fails: {\"action\": \"retry_self\"}, "
"{\"action\": \"loop_back\"}, or {\"action\": \"hard_fail\"}, each optionally with "
"\"max_attempts\"). If a node's on_fail.action is 'loop_back', you MUST also add a "
"corresponding edge in the top-level 'edges' array with edge_type 'on_failure', from that "
"node to the exact earlier node_id it should return to -- the target must be a real upstream "
"dependency of this node (something that genuinely produced input this node used), not an "
"arbitrary node. This mirrors how the success path already works: every node's 'yes, this "
"succeeded, what's next' is expressed as a normal dependency edge, and 'no, this failed, go "
"back to X' is expressed the same way, just with edge_type 'on_failure' instead. Every other "
"edge needs edge_type: 'zero_token' for pure aggregation/reduce steps, 'needs_judgment' for "
"anything requiring the agent to actually reason about content.\n"
"End your reply with the graph_manifest as a single fenced ```json code block "
"shaped exactly like {\"nodes\": [...], \"edges\": [...]} -- no extra wrapper key."
)
# E (2026-08-06): the generic, cross-Org-Agent path -- used for every
# org_node OTHER than 'planner'. Planner's own doc-writing chain stays on
# _PROMPT_TEMPLATE above unchanged (zero regression risk, see
# docs/done/dynamic-work-graph-design-skill-design.md §3): that chain is a
# fixed architectural constant (brd->prd->sdd->benchmark->...), not
# something that should vary call to call, whereas Coder/Validator's
# sub-tasks have no such fixed small vocabulary of node types. Each node
# here carries its own self-contained `task_prompt` (see the
# design-dynamic-work-graph Skill this prompt points at) instead of an
# `artifact_type` that a fixed lookup table like _DOC_PROMPTS could resolve.
_GENERIC_PROMPT_TEMPLATE = (
"Plan (do not execute) a Work Graph for this goal: {goal_text}\n"
"Follow the design-dynamic-work-graph Skill's methodology: only add a depends_on edge "
"when a node's task_prompt genuinely needs another node's output content -- nodes with "
"no real data dependency between them will be scheduled to run CONCURRENTLY, so adding "
"an edge just to make the graph look ordered actively slows execution down. If this goal "
"is small enough to finish in one call, output a single-node graph_manifest rather than "
"forcing a split.\n"
"Output a graph_manifest whose nodes each have: node_id (a short readable identifier), "
"depends_on (list of other node_ids), and task_prompt (the FULL, self-contained prompt "
"for that node's own execution -- assume the agent running it sees nothing except this "
"task_prompt and its dependencies' outputs, not this planning conversation). Every edge "
"needs edge_type: 'zero_token' for pure aggregation/reduce steps, 'needs_judgment' for "
"anything requiring the agent to actually reason about content.\n"
"End your reply with the graph_manifest as a single fenced ```json code block "
"shaped exactly like {{\"nodes\": [...], \"edges\": [...]}} -- no extra wrapper key."
)
_JSON_FENCE_RE = re.compile(r"```(?:json)?\s*(\{.*?\})\s*```", re.DOTALL)
class PlanningFailedError(Exception):
"""PLANNING_FAILED -- 03-C §3.4."""
def _extract_graph_manifest(text):
"""--json-schema doesn't constrain output when --agent is set (see module
docstring) -- pulls the graph_manifest out of free-form response text
instead: try the whole text as JSON, then each ```json fenced block;
accept either {"nodes":...,"edges":...} directly or wrapped one level
deep under a "graph_manifest" key (models sometimes add that wrapper
despite being asked not to)."""
candidates = [text] + _JSON_FENCE_RE.findall(text)
for candidate in candidates:
try:
parsed = json.loads(candidate)
except (json.JSONDecodeError, TypeError):
continue
if isinstance(parsed, dict):
if "nodes" in parsed and "edges" in parsed:
return parsed
inner = parsed.get("graph_manifest")
if isinstance(inner, dict) and "nodes" in inner and "edges" in inner:
return inner
return None
def _transitive_ancestors(graph_manifest, node_id):
"""node_id itself + every node it (directly or transitively) depends
on, via `depends_on`/normal data-dependency edges (`on_failure` edges
excluded -- they're control-flow, not data dependency, same reasoning
as work_graph_orchestrate.py's own `_transitive_dependents`/
`topological_layers`). Deliberately reimplemented here rather than
imported from work_graph_orchestrate.py -- the two modules don't
depend on each other today (work_graph_orchestrate.py is a lower-level
execution engine, work_graph_plan.py is planning-only) and this is a
small enough function that duplicating it doesn't justify introducing
that coupling."""
deps = {n.get("node_id"): set(n.get("depends_on") or []) for n in graph_manifest.get("nodes", [])}
for edge in graph_manifest.get("edges", []):
if edge.get("edge_type") == "on_failure":
continue
from_id, to_id = edge.get("from"), edge.get("to")
if to_id in deps and from_id in deps:
deps[to_id].add(from_id)
ancestors, stack = set(), [node_id]
while stack:
current = stack.pop()
if current in ancestors:
continue
ancestors.add(current)
stack.extend(deps.get(current, []))
return ancestors
def _validate_loop_back_targets(manifest):
"""2026-08-08 (Work Graph self-planning + loop-back): every node
declaring `on_fail.action == 'loop_back'` must have exactly one
`edge_type: 'on_failure'` edge originating from it (see
work_graph_orchestrate.py's `_on_failure_target`), and that edge's
target must be a genuine upstream dependency -- looping back to a node
that never influenced this one has no coherent meaning. Raises
PlanningFailedError (not a generic ValueError) so this surfaces the
same way every other planning failure does, before any node ever runs
-- catching a malformed loop_back here is far cheaper than discovering
it mid-execution."""
node_ids = {n.get("node_id") for n in manifest.get("nodes", [])}
for node in manifest.get("nodes", []):
if (node.get("on_fail") or {}).get("action") != "loop_back":
continue
node_id = node.get("node_id")
on_failure_edges = [
e for e in manifest.get("edges", [])
if e.get("from") == node_id and e.get("edge_type") == "on_failure"
]
if len(on_failure_edges) != 1:
raise PlanningFailedError(
"node {} declares on_fail.action='loop_back' but has {} on_failure edge(s), "
"expected exactly 1".format(node_id, len(on_failure_edges))
)
target = on_failure_edges[0].get("to")
if target not in node_ids:
raise PlanningFailedError(
"node {}'s on_failure edge targets {!r}, which does not exist in this graph_manifest".format(
node_id, target
)
)
if target not in _transitive_ancestors(manifest, node_id):
raise PlanningFailedError(
"node {}'s on_failure edge targets {!r}, which is not an upstream dependency -- "
"loop_back must target a node that genuinely influenced this one".format(node_id, target)
)
def _normalize_graph_manifest(manifest):
"""Real LLM output has been observed (2026-08-01, U-031 integration
validation) to use `id` instead of the documented `node_id` key, and to
express dependencies purely via `edges` (from/to) without ever
populating each node's own `depends_on` array -- even though this
schema requires both. Normalizing here, right after parsing and before
persisting, means every downstream consumer (work_graph_orchestrate.py's
topological sort, v_task_detail's to-do tree, v_graph_visualizer's DAG
layering) gets a manifest that actually matches the documented shape,
instead of each one needing its own defensive "id or node_id,
depends_on or edges" fallback logic repeated independently. Mutates and
returns `manifest`.
2026-08-08 (Work Graph self-planning + loop-back, FR-B4 "全部自由规划"):
the canonical-doc-chain forced-order enforcement that used to live here
(2026-08-03 incident: a real call omitted brd->prd->sdd->benchmark's
dependency entirely, scheduling nodes out of order) is REMOVED, not
extended -- Planner's node set/order is no longer a fixed architectural
constant (formally reverses docs/done/dynamic-work-graph-design-skill-
design.md §3). Robustness against that same failure mode now rests
entirely on the edge-derived depends_on backfill below (unchanged) plus
`_validate_loop_back_targets` (new, below) -- there is no longer a
hardcoded canonical chain to fall back on if an LLM response omits
dependency info, so a real reproduction of the 2026-08-03 incident
shape is a genuine, currently-unmitigated risk this design accepted
(see docs/done/work-graph-self-planning-and-loop-back-requirements.md
§7's risk list) -- FR-B3's own acceptance criterion only covers the
"wrong key names" failure mode this backfill already handles, not a
complete absence of dependency info with no canonical chain to guess from.
"""
nodes = manifest.get("nodes", [])
for node in nodes:
if "node_id" not in node and "id" in node:
node["node_id"] = node["id"]
node.setdefault("depends_on", [])
node_ids = {n.get("node_id") for n in nodes}
for edge in manifest.get("edges", []):
if edge.get("edge_type") == "on_failure":
continue # control-flow, not a data dependency -- see _transitive_ancestors
from_id, to_id = edge.get("from"), edge.get("to")
if from_id in node_ids and to_id in node_ids:
for node in nodes:
if node.get("node_id") == to_id and from_id not in node["depends_on"]:
node["depends_on"].append(from_id)
_validate_loop_back_targets(manifest)
return manifest
def plan_work_graph(conn, org_node, task_id, goal_text, target_folder_path, timeout=300):
"""Returns {"run_id", "graph_manifest"}. Creates one `work_graph_run` row
(status='planning') -- moving it to 'running'/executing it is out of scope.
2026-08-08: `org_node == 'planner'` gets `_PROMPT_TEMPLATE` (now a
genuine self-planning prompt over `_PLANNER_NODE_CATALOG`, see that
template's own comment for why goal_text is filled in via `.replace()`
rather than `.format()`). Every other org_node gets
`_GENERIC_PROMPT_TEMPLATE` -- the cross-Org-Agent dynamic planning path
described in the design-dynamic-work-graph Skill, whose nodes carry
their own task_prompt instead of a fixed artifact_type. Function
signature/return shape are unchanged either way."""
if org_node == "planner":
prompt = _PROMPT_TEMPLATE.replace("{goal_text}", goal_text)
else:
prompt = _GENERIC_PROMPT_TEMPLATE.format(goal_text=goal_text)
result = subprocess.run(
[
"claude", "--agent", org_node,
"-p", prompt,
"--output-format", "json",
"--permission-mode", "auto",
],
cwd=target_folder_path,
stdin=subprocess.DEVNULL,
capture_output=True,
text=True,
timeout=timeout,
)
if result.returncode != 0:
# 2026-07-31 fix (same bug as org_node_trigger.py): include stdout too.
raise PlanningFailedError(
"plan failed for task {}\nstderr: {}\nstdout (truncated): {}".format(
task_id, result.stderr, result.stdout[:4000]
)
)
payload = json.loads(result.stdout)
if payload.get("is_error"):
raise PlanningFailedError("plan failed for task {}: {}".format(task_id, payload))
graph_manifest = _extract_graph_manifest(payload.get("result", ""))
if not graph_manifest:
raise PlanningFailedError("could not extract a graph_manifest for task {}: {}".format(task_id, payload))
graph_manifest = _normalize_graph_manifest(graph_manifest)
run_id = str(uuid.uuid4())
conn.execute(
"INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) "
"VALUES (?, ?, ?, ?, 'planning')",
(run_id, task_id, org_node, json.dumps(graph_manifest)),
)
conn.commit()
return {"run_id": run_id, "graph_manifest": graph_manifest}
def generic_node_prompt_builder(node, outputs):
"""E (2026-08-06): the `build_prompt(node, outputs)` run_graph() needs
for a graph_manifest produced via _GENERIC_PROMPT_TEMPLATE -- lives
here, not in e2e_pipeline.py, because it's tightly coupled to the
`task_prompt` field convention that prompt asks for (keeping both in
the same module means a field rename can't silently desync producer
and consumer). No factory/closure needed: every node is self-contained
per the design-dynamic-work-graph Skill's contract, so `outputs`
(already-completed dependency content) isn't needed here."""
return node["task_prompt"]