Commit 9c8deec29627cee965b4070ab7ddfd512e6cce97

Authored by bboonnee
1 parent 1a2baa02

fix: append-mode loop_back validation must allow targeting already-persisted nodes

Real incident (2026-08-13, task b243dfe8): _normalize_graph_manifest() calls
_validate_loop_back_targets() on the newly-planned manifest BEFORE it's merged
with the existing persisted one (append mode). A new node's on_failure edge
legitimately targeting an already-persisted node from an earlier append (e.g.
a new verification node looping back to an earlier SDD node) was wrongly
rejected as "does not exist in this graph_manifest" -- it genuinely doesn't
exist in the brand-new manifest by itself, only once merged.

_validate_loop_back_targets() gains additional_valid_target_ids, threaded from
plan_work_graph()'s existing_manifest through _normalize_graph_manifest().
Ancestor-chain validation is skipped for a target in this set -- an
already-persisted node's ancestry was validated when it was originally
planned.

New unit test for the validation function directly, plus a real end-to-end
test through plan_work_graph() in append mode.

817/817 tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
src/test_work_graph_plan.py
@@ -286,6 +286,24 @@ class ValidateLoopBackTargetsTest(unittest.TestCase): @@ -286,6 +286,24 @@ class ValidateLoopBackTargetsTest(unittest.TestCase):
286 with self.assertRaises(work_graph_plan.PlanningFailedError): 286 with self.assertRaises(work_graph_plan.PlanningFailedError):
287 work_graph_plan._normalize_graph_manifest(manifest) 287 work_graph_plan._normalize_graph_manifest(manifest)
288 288
  289 + def test_target_in_additional_valid_target_ids_passes(self):
  290 + # Real incident (2026-08-13, task b243dfe8): append mode validates a
  291 + # newly-planned manifest BEFORE merging it with the already-persisted
  292 + # one -- a new node legitimately looping back to an earlier,
  293 + # already-persisted node (from a prior append) must not be rejected
  294 + # just because that node isn't part of THIS manifest yet.
  295 + manifest = {
  296 + "nodes": [
  297 + {"node_id": "test_script_verify", "depends_on": [], "on_fail": {"action": "loop_back"}},
  298 + ],
  299 + "edges": [{"from": "test_script_verify", "to": "sdd", "edge_type": "on_failure"}],
  300 + }
  301 + work_graph_plan._validate_loop_back_targets(
  302 + manifest, additional_valid_target_ids={"sdd"}
  303 + ) # must not raise
  304 + with self.assertRaises(work_graph_plan.PlanningFailedError):
  305 + work_graph_plan._validate_loop_back_targets(manifest) # without it, still rejected
  306 +
289 307
290 class HappyPathTest(PlanWorkGraphTestBase): 308 class HappyPathTest(PlanWorkGraphTestBase):
291 def test_returns_run_id_and_graph_manifest(self): 309 def test_returns_run_id_and_graph_manifest(self):
@@ -509,6 +527,30 @@ class AppendToRunIdTest(PlanWorkGraphTestBase): @@ -509,6 +527,30 @@ class AppendToRunIdTest(PlanWorkGraphTestBase):
509 ) 527 )
510 self.assertEqual([n["node_id"] for n in persisted["nodes"]], ["sdd"]) 528 self.assertEqual([n["node_id"] for n in persisted["nodes"]], ["sdd"])
511 529
  530 + def test_new_node_loop_back_targeting_an_existing_node_is_allowed(self):
  531 + # Real incident (2026-08-13, task b243dfe8): a genuinely valid
  532 + # append -- a newly-planned verification node's on_failure edge
  533 + # targets an already-persisted node from an earlier append. This
  534 + # must NOT be rejected just because the earlier node isn't part of
  535 + # the brand-new manifest by itself (it only exists once merged).
  536 + self._seed_run("run-1", {"nodes": [{"node_id": "sdd", "artifact_type": "sdd", "depends_on": []}], "edges": []})
  537 + new_manifest = {
  538 + "nodes": [{
  539 + "node_id": "verify", "depends_on": ["sdd"], "task_prompt": "verify it",
  540 + "on_fail": {"action": "loop_back"},
  541 + }],
  542 + "edges": [{"from": "verify", "to": "sdd", "edge_type": "on_failure"}],
  543 + }
  544 + with patch("src.work_graph_plan.subprocess.run") as run:
  545 + run.return_value = _completed_process(
  546 + stdout_obj={"is_error": False, "result": _fenced_result(new_manifest)}
  547 + )
  548 + result = work_graph_plan.plan_work_graph( # must not raise
  549 + self.conn, "coder", "task-1", "goal", "/tmp/proj-1", append_to_run_id="run-1",
  550 + )
  551 + merged_node_ids = {n["node_id"] for n in result["graph_manifest"]["nodes"]}
  552 + self.assertEqual(merged_node_ids, {"sdd", "verify"})
  553 +
512 def test_append_to_nonexistent_run_id_raises(self): 554 def test_append_to_nonexistent_run_id_raises(self):
513 new_manifest = {"nodes": [{"node_id": "impl", "depends_on": [], "task_prompt": "do it"}], "edges": []} 555 new_manifest = {"nodes": [{"node_id": "impl", "depends_on": [], "task_prompt": "do it"}], "edges": []}
514 with patch("src.work_graph_plan.subprocess.run") as run: 556 with patch("src.work_graph_plan.subprocess.run") as run:
src/work_graph_plan.py
@@ -420,7 +420,7 @@ def _transitive_ancestors(graph_manifest, node_id): @@ -420,7 +420,7 @@ def _transitive_ancestors(graph_manifest, node_id):
420 return ancestors 420 return ancestors
421 421
422 422
423 -def _validate_loop_back_targets(manifest): 423 +def _validate_loop_back_targets(manifest, additional_valid_target_ids=()):
424 """2026-08-08 (Work Graph self-planning + loop-back): every node 424 """2026-08-08 (Work Graph self-planning + loop-back): every node
425 declaring `on_fail.action == 'loop_back'` must have exactly one 425 declaring `on_fail.action == 'loop_back'` must have exactly one
426 `edge_type: 'on_failure'` edge originating from it (see 426 `edge_type: 'on_failure'` edge originating from it (see
@@ -430,8 +430,22 @@ def _validate_loop_back_targets(manifest): @@ -430,8 +430,22 @@ def _validate_loop_back_targets(manifest):
430 PlanningFailedError (not a generic ValueError) so this surfaces the 430 PlanningFailedError (not a generic ValueError) so this surfaces the
431 same way every other planning failure does, before any node ever runs 431 same way every other planning failure does, before any node ever runs
432 -- catching a malformed loop_back here is far cheaper than discovering 432 -- catching a malformed loop_back here is far cheaper than discovering
433 - it mid-execution.""" 433 + it mid-execution.
  434 +
  435 + `additional_valid_target_ids` (2026-08-13, real incident task b243dfe8):
  436 + append mode (see plan_work_graph's own docstring) calls this on the
  437 + NEWLY-planned manifest alone, before it's merged with the existing
  438 + persisted one -- a new node legitimately looping back to an
  439 + already-persisted node from an earlier append is completely valid
  440 + (e.g. a new verification node targeting an earlier SDD node), but
  441 + without this, that target genuinely isn't in THIS manifest's own
  442 + node_ids yet, and got wrongly rejected as nonexistent. Ancestor-chain
  443 + validation below is skipped for a target in this set -- an
  444 + already-persisted node's ancestry was already validated when IT was
  445 + planned, and re-deriving `_transitive_ancestors` across a manifest that
  446 + doesn't yet contain those nodes isn't meaningful anyway."""
434 node_ids = {n.get("node_id") for n in manifest.get("nodes", [])} 447 node_ids = {n.get("node_id") for n in manifest.get("nodes", [])}
  448 + additional_valid_target_ids = set(additional_valid_target_ids)
435 for node in manifest.get("nodes", []): 449 for node in manifest.get("nodes", []):
436 if (node.get("on_fail") or {}).get("action") != "loop_back": 450 if (node.get("on_fail") or {}).get("action") != "loop_back":
437 continue 451 continue
@@ -446,6 +460,8 @@ def _validate_loop_back_targets(manifest): @@ -446,6 +460,8 @@ def _validate_loop_back_targets(manifest):
446 "expected exactly 1".format(node_id, len(on_failure_edges)) 460 "expected exactly 1".format(node_id, len(on_failure_edges))
447 ) 461 )
448 target = on_failure_edges[0].get("to") 462 target = on_failure_edges[0].get("to")
  463 + if target in additional_valid_target_ids:
  464 + continue
449 if target not in node_ids: 465 if target not in node_ids:
450 raise PlanningFailedError( 466 raise PlanningFailedError(
451 "node {}'s on_failure edge targets {!r}, which does not exist in this graph_manifest".format( 467 "node {}'s on_failure edge targets {!r}, which does not exist in this graph_manifest".format(
@@ -459,7 +475,7 @@ def _validate_loop_back_targets(manifest): @@ -459,7 +475,7 @@ def _validate_loop_back_targets(manifest):
459 ) 475 )
460 476
461 477
462 -def _normalize_graph_manifest(manifest): 478 +def _normalize_graph_manifest(manifest, additional_valid_loop_back_target_ids=()):
463 """Real LLM output has been observed (2026-08-01, U-031 integration 479 """Real LLM output has been observed (2026-08-01, U-031 integration
464 validation) to use `id` instead of the documented `node_id` key, and to 480 validation) to use `id` instead of the documented `node_id` key, and to
465 express dependencies purely via `edges` (from/to) without ever 481 express dependencies purely via `edges` (from/to) without ever
@@ -505,7 +521,7 @@ def _normalize_graph_manifest(manifest): @@ -505,7 +521,7 @@ def _normalize_graph_manifest(manifest):
505 if node.get("node_id") == to_id and from_id not in node["depends_on"]: 521 if node.get("node_id") == to_id and from_id not in node["depends_on"]:
506 node["depends_on"].append(from_id) 522 node["depends_on"].append(from_id)
507 523
508 - _validate_loop_back_targets(manifest) 524 + _validate_loop_back_targets(manifest, additional_valid_loop_back_target_ids)
509 return manifest 525 return manifest
510 526
511 527
@@ -592,7 +608,10 @@ def plan_work_graph(conn, org_node, task_id, goal_text, target_folder_path, time @@ -592,7 +608,10 @@ def plan_work_graph(conn, org_node, task_id, goal_text, target_folder_path, time
592 new_manifest = _extract_graph_manifest(payload.get("result", "")) 608 new_manifest = _extract_graph_manifest(payload.get("result", ""))
593 if not new_manifest: 609 if not new_manifest:
594 raise PlanningFailedError("could not extract a graph_manifest for task {}: {}".format(task_id, payload)) 610 raise PlanningFailedError("could not extract a graph_manifest for task {}: {}".format(task_id, payload))
595 - new_manifest = _normalize_graph_manifest(new_manifest) 611 + existing_node_ids = (
  612 + {n["node_id"] for n in existing_manifest["nodes"]} if existing_manifest is not None else ()
  613 + )
  614 + new_manifest = _normalize_graph_manifest(new_manifest, existing_node_ids)
596 615
597 if existing_manifest is None: 616 if existing_manifest is None:
598 run_id = str(uuid.uuid4()) 617 run_id = str(uuid.uuid4())