work_graph_plan.py
35.9 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
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
"""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" (Planner's fixed-catalog nodes) and "task_prompt"
# (Coder/Validator's generic self-planned nodes, _generic_prompt_
# template()) are mutually exclusive node shapes in practice --
# "required" above only reflects Planner's shape (this schema
# predates the generic path); see work_graph_plan.py's own dispatch
# helpers for how callers tell the two apart per-node.
"artifact_type": {
"type": "string",
"enum": ["brd", "prd", "sdd", "benchmark", "test_dataset", "test_script"],
},
"task_prompt": {"type": "string"},
"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", "dynamic_loop_back"],
},
"max_attempts": {"type": "integer"},
},
},
# 2026-08-12 (Phase 2 §2.3/§4): also optional/documentation-only.
"promotes_to_artifact": {
"type": "object",
"properties": {"artifact_type": {"type": "string"}},
},
"subagent_type": {"type": "string"},
"skill_ref": {"type": "string"},
# 2026-08-12 (Phase 2c): echoed-back catalog label, e.g.
# "test_script"/"final_verdict" -- see _generic_prompt_
# template's own comment for why this exists (finding
# role-specific nodes among arbitrarily-named
# self-planned ones).
"node_type": {"type": "string"},
},
},
},
"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 --
# 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.
# 2026-08-12: _generic_prompt_template() (below) now follows this SAME
# convention for the same reason -- its own catalog JSON has the same
# literal-brace problem .format() would choke on, which the original
# _GENERIC_PROMPT_TEMPLATE constant used to dodge by having no embedded
# JSON at all; now that it does (Phase 2 §3's node catalogs), it needed
# the same fix this template already had.
_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. 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.
#
# 2026-08-12 (Phase 2 §3, user-confirmed 2026-08-08 -- reverses this
# comment's own prior "no fixed small vocabulary of node types" framing):
# Coder/Validator's sub-tasks DO get a suggested (not required) catalog
# now, same "starting points, not requirements" contract _PLANNER_NODE_
# CATALOG already uses -- the LLM can ignore every entry and invent its
# own node_ids/task_prompts freely, same as before this change.
_NODE_REVIEW_JUDGE_PROMPT = (
"Judge whether this node's output is genuinely what it claims to be, not a narrative "
"description of an action taken (e.g. 'I ran/produced/completed X') standing in for the real "
"content itself -- this is a known recurring failure mode in nodes that explore with tools "
"before producing a final answer. ok=false if the content is narration/progress-reporting "
"prose rather than the real deliverable (code, test output, structured analysis, etc.), or if "
"it's otherwise clearly incomplete/wrong for what was asked. List each concrete problem in "
"issues.\n\nContent:\n\n{content}"
)
_CODER_NODE_CATALOG = [
{"node_type": "impl", "description": "Actual code changes implementing the SDD."},
{
"node_type": "unit_test",
"description": "New/updated unit tests covering the change -- same repo convention as existing tests.",
"suggested_judgment": {"kind": "llm", "judge_prompt": _NODE_REVIEW_JUDGE_PROMPT},
},
{
"node_type": "integration_check",
"description": "Actually runs the real test suite/benchmark script against the change and reports "
"pass/fail -- not a description of doing so.",
"suggested_judgment": {"kind": "llm", "judge_prompt": _NODE_REVIEW_JUDGE_PROMPT},
"suggested_promotes_to_artifact": {"artifact_type": "test_report"},
},
]
_VALIDATOR_NODE_CATALOG = [
{
"node_type": "independent_benchmark",
"description": "Validator's own held-out acceptance scenarios -- NOT reused from Planner's/Coder's "
"benchmark, a genuine second opinion.",
},
{"node_type": "test_dataset", "description": "Concrete test data, if this goal has a natural one."},
{
"node_type": "test_script",
"description": "A real, runnable script mechanically checking the independent benchmark's scenarios. "
"It will actually be EXECUTED against the real repository after you plan it -- write "
"real commands, not a description of what a script would do.",
"suggested_judgment": {"kind": "mechanical", "check_ref": "check_script_compiles"},
},
{
"node_type": "final_verdict",
"description": "Exactly ONE node of this type, depending on every other node whose content your "
"verdict needs. Its content must be your final judgment: a line 'VERDICT: PASS', "
"'VERDICT: FAIL', or 'VERDICT: INCONCLUSIVE', followed by your reasoning (and, if not "
"PASS, a root_cause_category of planning|coder|skill_defect with detail). This is the "
"ONLY node in a Validator graph allowed to use on_fail.action='dynamic_loop_back' -- "
"see the on_fail instructions below for why.",
"suggested_judgment": {"kind": "mechanical", "check_ref": "verdict_says_pass"},
"suggested_on_fail": {"action": "dynamic_loop_back", "max_attempts": 3},
},
]
_GENERIC_NODE_CATALOGS = {"coder": _CODER_NODE_CATALOG, "validator": _VALIDATOR_NODE_CATALOG}
def _generic_prompt_template(org_node):
"""Builds _GENERIC_PROMPT_TEMPLATE's replacement, parameterized by
org_node so each gets its own suggested catalog (or none, for any
future org_node this hasn't been extended for yet -- catalog_section
is simply empty then, same as before this change existed).
2026-08-12 (Phase 2c): `node_type` is now asked to be ECHOED BACK onto
each planned node (not just a label in the suggestion data) -- e2e_
pipeline.py's Validator rework needs to find "the test_script node"/
"the final_verdict node" among self-planned, arbitrarily-named nodes,
and node_type is the one signal that survives self-planning for this
(node_id is freeform, task_prompt is prose). `dynamic_loop_back` is
now allowed, but ONLY for org_node='validator' AND only on a node
that also declares node_type='final_verdict' -- this reverses the
Phase 2a/2b instruction ("reserved for the terminal review stage
outside this graph, not for nodes you plan yourself"), because
Validator's terminal review IS now planned inside this graph, not a
flat call after it (see _VALIDATOR_NODE_CATALOG's own 'final_verdict'
entry for why: only a real graph node can use the loop_back mechanism
_run_one_node already implements). Every OTHER org_node still can't
use it -- Coder's catalog has no comparable terminal-judgment role."""
catalog = _GENERIC_NODE_CATALOGS.get(org_node, [])
catalog_section = (
"Suggested node types for this goal (use any subset, in any order, invent your own if "
"none fit -- these are starting points, not requirements):\n" + json.dumps(catalog) + "\n"
if catalog else ""
)
dynamic_loop_back_rule = (
"{\"action\": \"dynamic_loop_back\"} ONLY for the node also declaring "
"node_type='final_verdict' (see the final_verdict catalog entry above) -- no on_failure "
"edge needed for this one, the target is chosen dynamically at failure time, not declared "
"now.\n"
if org_node == "validator" else
"do NOT use \"dynamic_loop_back\" -- that's reserved for Validator's terminal review node, "
"not for nodes you plan yourself.\n"
)
return (
"Plan (do not execute) a Work Graph for this goal: {goal_text}\n"
+ catalog_section +
"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), plus:\n"
" - node_type (optional but STRONGLY suggested when a node matches one of the suggested "
"types above): echo back the matching node_type string (e.g. \"test_script\") so it's "
"identifiable later -- omit only for a node that doesn't match any suggested type.\n"
" - judgment (optional): how to tell if this node's output is acceptable -- "
"{\"kind\": \"none\"} if nothing needs checking (the default if omitted), {\"kind\": "
"\"llm\", \"judge_prompt\": \"...{content}...\"} for your own judgment prompt with a "
"{content} placeholder.\n"
" - on_fail (only meaningful if judgment.kind != \"none\"): {\"action\": \"retry_self\"}, "
"{\"action\": \"hard_fail\"}, or {\"action\": \"loop_back\"} with a matching on_failure "
"edge to a genuine upstream dependency (same rule _PLANNER_NODE_CATALOG's own nodes "
"follow); " + dynamic_loop_back_rule +
" - promotes_to_artifact (optional): {\"artifact_type\": \"test_report\"} ONLY for a "
"node producing this task's actual final deliverable text -- most nodes should omit "
"this; it marks a node's content for persistence beyond this graph's own execution. "
"(code_diff, if relevant, is derived separately from real file changes, not from any "
"node's own content -- never declare artifact_type: \"code_diff\" here. The final_verdict "
"node, if any, should also NOT declare promotes_to_artifact -- a verdict is a judgment, "
"not a persisted deliverable.)\n"
"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_json_payload(text, required_keys=()):
"""--json-schema doesn't constrain output when --agent is set (see module
docstring) -- pulls a JSON payload out of free-form response text
instead: try the whole text as JSON, then each ```json fenced block;
accept the first candidate that parses to a dict containing every key in
required_keys. Generalized (2026-08, work-graph-node-execution-
deblackbox) from what used to be `_extract_graph_manifest`'s own inline
algorithm, so `org_node_trigger.trigger_org_node_structured` can reuse it
for arbitrary output_schemas, not just graph_manifest's {nodes, edges}
shape. Wrapper-key fallbacks (like _extract_graph_manifest's own
"graph_manifest" key below) are caller-specific and deliberately left
out of this shared layer."""
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) and all(key in parsed for key in required_keys):
return parsed
return None
def _extract_graph_manifest(text):
"""Thin wrapper around extract_json_payload: also accepts the payload
wrapped one level deep under a "graph_manifest" key (models sometimes
add that wrapper despite being asked not to) -- kept as its own function
rather than folding the wrapper-key check into extract_json_payload
itself, since no other caller needs this specific wrapper-key name."""
result = extract_json_payload(text, required_keys=["nodes", "edges"])
if result is not None:
return result
for candidate in [text] + _JSON_FENCE_RE.findall(text):
try:
parsed = json.loads(candidate)
except (json.JSONDecodeError, TypeError):
continue
if isinstance(parsed, dict):
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, append_to_run_id=None):
"""Returns {"run_id", "graph_manifest", "new_node_ids"}. 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(org_node)` -- 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.
2026-08-12 (Phase 2 §6.1, user-confirmed 2026-08-08: a parameter on
this function, not a separate plan_work_graph_append()): with
`append_to_run_id=None` (default), behavior is unchanged from before
this parameter existed -- a fresh INSERT. With `append_to_run_id=
<existing run_id>`, this call plans ADDITIONAL nodes for an
already-existing work_graph_run (e.g. Coder appending its own
implementation nodes onto Planner's run) instead of creating a new
row: the existing manifest's node_ids are injected into the prompt so
new nodes can `depends_on` them, a `node_id` collision between the
newly-planned nodes and the existing ones raises PlanningFailedError
(never silently renamed or overwritten), and the row is UPDATEd with
the merged manifest instead of a new row being INSERTed.
`new_node_ids` (only meaningfully non-empty in append mode, though
always present for a uniform return shape) is the set of node_ids this
specific call added -- callers that promote node output to `artifact`
rows (e2e_pipeline.py's `_write_completed_node_artifacts`) need this
to avoid re-processing nodes an EARLIER stage already promoted; the
merged manifest itself doesn't distinguish "planned this call" from
"already existed" once merged."""
existing_manifest = None
if append_to_run_id:
row = conn.execute(
"SELECT graph_manifest FROM work_graph_run WHERE run_id = ?", (append_to_run_id,)
).fetchone()
if row is None:
raise ValueError("no such work_graph_run to append to: " + append_to_run_id)
existing_manifest = json.loads(row[0])
if org_node == "planner":
prompt = _PROMPT_TEMPLATE.replace("{goal_text}", goal_text)
else:
prompt = _generic_prompt_template(org_node).replace("{goal_text}", goal_text)
if existing_manifest is not None:
existing_ids = [n["node_id"] for n in existing_manifest["nodes"]]
prompt = prompt.replace(
"Plan (do not execute)",
"This graph already has these nodes from earlier stages (you may depends_on them, but do "
"NOT redeclare or duplicate them -- pick different node_ids for your own new nodes): "
"{}. Plan (do not execute) ADDITIONAL nodes only".format(existing_ids),
1,
)
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))
new_manifest = _extract_graph_manifest(payload.get("result", ""))
if not new_manifest:
raise PlanningFailedError("could not extract a graph_manifest for task {}: {}".format(task_id, payload))
new_manifest = _normalize_graph_manifest(new_manifest)
if existing_manifest is None:
run_id = str(uuid.uuid4())
# org_node column removed (Org Graph unification Phase 2c §2.1,
# 2026-08-12) -- this run's real per-node ownership lives in each
# node's own work_node_execution.subagent_type (set per node in
# _run_one_node()), not a single run-level value; a merged run
# genuinely spans several org_nodes by design (Coder/Validator
# append their own nodes into this SAME run via append_to_run_id).
conn.execute(
"INSERT INTO work_graph_run (run_id, task_id, graph_manifest, status) VALUES (?, ?, ?, 'planning')",
(run_id, task_id, json.dumps(new_manifest)),
)
conn.commit()
return {"run_id": run_id, "graph_manifest": new_manifest, "new_node_ids": []}
new_node_ids = [n["node_id"] for n in new_manifest["nodes"]]
existing_ids_set = {n["node_id"] for n in existing_manifest["nodes"]}
collision = existing_ids_set & set(new_node_ids)
if collision:
raise PlanningFailedError(
"append_to_run_id={} node_id collision with existing nodes: {}".format(append_to_run_id, collision)
)
merged = {
"nodes": existing_manifest["nodes"] + new_manifest["nodes"],
"edges": existing_manifest.get("edges", []) + new_manifest.get("edges", []),
}
# _validate_loop_back_targets over the MERGED graph, not just the new
# nodes -- a new node's on_fail.loop_back could (in principle) target
# an existing node, and _normalize_graph_manifest already validated
# new_manifest in isolation above, not against the merged shape.
_validate_loop_back_targets(merged)
conn.execute(
"UPDATE work_graph_run SET graph_manifest = ? WHERE run_id = ?",
(json.dumps(merged), append_to_run_id),
)
conn.commit()
return {"run_id": append_to_run_id, "graph_manifest": merged, "new_node_ids": new_node_ids}
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"]
def dispatch_build_prompt(planner_builder, generic_builder):
"""2026-08-12 (Phase 2 §6.1): a build_prompt(node, outputs) that picks
per-NODE between planner_builder (Planner's artifact_type-keyed nodes,
e.g. e2e_pipeline._planner_prompt_builder's return value) and
generic_builder (task_prompt-keyed nodes, e.g. generic_node_prompt_
builder above) -- needed once a single graph_manifest can genuinely
contain BOTH shapes side by side, which append_to_run_id makes real
for the first time (Planner's original nodes + Coder's/Validator's
later self-planned ones, merged into one run). Every pre-Phase-2
caller only ever had ONE shape for a whole graph and dispatched once
up front; this replaces that with a per-node check so a caller doesn't
need to know in advance whether ANY given node in a merged graph is
Planner-shaped or generic-shaped. Shared here (not duplicated in
e2e_pipeline.py/work_graph_fork.py, its two real callers) so the two
don't drift on what "neither shape" should do."""
def build_prompt(node, outputs):
if "artifact_type" in node:
return planner_builder(node, outputs)
if "task_prompt" in node:
return generic_builder(node, outputs)
raise ValueError(
"node {} has neither 'artifact_type' nor 'task_prompt' -- can't dispatch a build_prompt".format(
node.get("node_id")
)
)
return build_prompt