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 286 with self.assertRaises(work_graph_plan.PlanningFailedError):
287 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 308 class HappyPathTest(PlanWorkGraphTestBase):
291 309 def test_returns_run_id_and_graph_manifest(self):
... ... @@ -509,6 +527,30 @@ class AppendToRunIdTest(PlanWorkGraphTestBase):
509 527 )
510 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 554 def test_append_to_nonexistent_run_id_raises(self):
513 555 new_manifest = {"nodes": [{"node_id": "impl", "depends_on": [], "task_prompt": "do it"}], "edges": []}
514 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 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 424 """2026-08-08 (Work Graph self-planning + loop-back): every node
425 425 declaring `on_fail.action == 'loop_back'` must have exactly one
426 426 `edge_type: 'on_failure'` edge originating from it (see
... ... @@ -430,8 +430,22 @@ def _validate_loop_back_targets(manifest):
430 430 PlanningFailedError (not a generic ValueError) so this surfaces the
431 431 same way every other planning failure does, before any node ever runs
432 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 447 node_ids = {n.get("node_id") for n in manifest.get("nodes", [])}
  448 + additional_valid_target_ids = set(additional_valid_target_ids)
435 449 for node in manifest.get("nodes", []):
436 450 if (node.get("on_fail") or {}).get("action") != "loop_back":
437 451 continue
... ... @@ -446,6 +460,8 @@ def _validate_loop_back_targets(manifest):
446 460 "expected exactly 1".format(node_id, len(on_failure_edges))
447 461 )
448 462 target = on_failure_edges[0].get("to")
  463 + if target in additional_valid_target_ids:
  464 + continue
449 465 if target not in node_ids:
450 466 raise PlanningFailedError(
451 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 475 )
460 476  
461 477  
462   -def _normalize_graph_manifest(manifest):
  478 +def _normalize_graph_manifest(manifest, additional_valid_loop_back_target_ids=()):
463 479 """Real LLM output has been observed (2026-08-01, U-031 integration
464 480 validation) to use `id` instead of the documented `node_id` key, and to
465 481 express dependencies purely via `edges` (from/to) without ever
... ... @@ -505,7 +521,7 @@ def _normalize_graph_manifest(manifest):
505 521 if node.get("node_id") == to_id and from_id not in node["depends_on"]:
506 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 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 608 new_manifest = _extract_graph_manifest(payload.get("result", ""))
593 609 if not new_manifest:
594 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 616 if existing_manifest is None:
598 617 run_id = str(uuid.uuid4())
... ...