Commit 9c8deec29627cee965b4070ab7ddfd512e6cce97
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>
Showing
2 changed files
with
66 additions
and
5 deletions
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()) |