Commit 5d6a9a68b79ddb8e3e60d0ec6e2dc9883b236ee6

Authored by bboonnee
1 parent 169d1bae

fix: node completion never permanently blocked from artifact promotion

Real incident (2026-08-13, task b243dfe8), diagnosed live with the user after
they correctly challenged the architecture: "every node should have its own
output, and node-to-node communication IS input/output flowing through
edges -- why doesn't a node have an artifact?"

They were right to push on this. work_node_execution.output_payload (the
real Work Graph data-flow layer) was never the problem -- it's populated the
instant any node completes, exactly as expected. The actual bug was one
level up: _write_completed_node_artifacts's OLD `only_node_ids` parameter
skipped any node not in "this specific append call's own newly-planned
nodes", assuming "not new to this call" means "an earlier stage already
promoted it". That assumption breaks the instant an earlier call's OWN
promotion attempt never actually ran (e.g. it crashed even earlier in that
same call, before reaching this function) -- which is exactly what happened
to b243dfe8 across multiple retries today: brd/prd/sdd/benchmark nodes kept
completing successfully ('done', real content), but zero artifact rows and
zero .graphlemon/runs/<task_id>/ files were EVER created, going back to the
task's original 2026-08-04 run. Every later append-mode retry kept treating
those nodes as "not new, must already be handled" and excluded them forever.

Renamed to `new_node_ids`, semantics inverted to check the real source of
truth: a node whose artifact_type already has a genuine artifact row for
this run_id is skipped UNLESS the node itself is newly-planned by this call
(which correctly allows a retry-loop's superseding content to still promote
a fresh artifact over a rejected one -- the scenario the previous attempt at
this fix broke, caught by the existing
test_rejected_first_attempt_replans_into_same_run_not_a_second_row test).

New tests cover both real scenarios directly, plus a fix to
test_orphaned_preexisting_run_with_no_artifacts_appends_not_collides's own
setup (the orphaned node needs to be seeded as already-done, matching the
real incident, not left for run_graph() to execute for real a second time).

822/822 tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
src/e2e_pipeline.py
@@ -552,7 +552,7 @@ def _resolve_node_artifact_type(node, node_artifact_types): @@ -552,7 +552,7 @@ def _resolve_node_artifact_type(node, node_artifact_types):
552 552
553 def _write_completed_node_artifacts( 553 def _write_completed_node_artifacts(
554 conn, target_folder_path, task_id, run_id, graph_manifest, node_artifact_types, ext_for, 554 conn, target_folder_path, task_id, run_id, graph_manifest, node_artifact_types, ext_for,
555 - run_graph_error=None, only_node_ids=None, 555 + run_graph_error=None, new_node_ids=None,
556 ): 556 ):
557 """Writes an artifact for every node that's genuinely done AND 557 """Writes an artifact for every node that's genuinely done AND
558 verify_fn-accepted, sourced from `work_node_execution` in the DB -- 558 verify_fn-accepted, sourced from `work_node_execution` in the DB --
@@ -580,12 +580,42 @@ def _write_completed_node_artifacts( @@ -580,12 +580,42 @@ def _write_completed_node_artifacts(
580 2026-08-12 (Phase 2 §2.3/§6): a node with no resolvable artifact_type 580 2026-08-12 (Phase 2 §2.3/§6): a node with no resolvable artifact_type
581 (see `_resolve_node_artifact_type`) is silently skipped, not an 581 (see `_resolve_node_artifact_type`) is silently skipped, not an
582 error -- most Coder/Validator self-planned nodes are internal graph 582 error -- most Coder/Validator self-planned nodes are internal graph
583 - steps, not final deliverables. `only_node_ids` (default None = every  
584 - node in `graph_manifest`, unchanged behavior) restricts processing to  
585 - a subset -- needed once a caller works over a MERGED graph (Coder's  
586 - `append_to_run_id`) and must avoid re-processing/re-promoting nodes an  
587 - EARLIER stage already wrote artifacts for.  
588 - """ 583 + steps, not final deliverables.
  584 +
  585 + 2026-08-13 (real incident, task b243dfe8 -- diagnosed live with the
  586 + user, correcting an architectural misunderstanding they caught): the
  587 + OLD `only_node_ids` param skipped any node not in "this call's own
  588 + newly-planned nodes", on the assumption that "not new to this call"
  589 + means "an earlier stage already promoted it". That assumption breaks
  590 + the instant an earlier append's OWN promotion attempt never actually
  591 + ran -- e.g. this exact function being reached only via a code path
  592 + that crashed earlier in THAT call, before ever getting here. The
  593 + node's real, valid, already-'done' content in work_node_execution.
  594 + output_payload then becomes permanently unpromotable: every LATER
  595 + retry (in append mode) kept excluding it as "not new", forever, even
  596 + though zero artifact of that type had EVER actually been written --
  597 + exactly what happened to this task (multiple 'done' brd/prd/sdd/
  598 + benchmark nodes across retries, zero artifact rows, zero
  599 + .graphlemon/runs/<task_id>/ files ever created, going back to the
  600 + task's original 2026-08-04 run).
  601 +
  602 + Renamed to `new_node_ids` and its role inverted: a node whose type
  603 + ALREADY has a real artifact row for this run_id is skipped UNLESS the
  604 + node itself is in `new_node_ids` (default None -- treated as "nothing
  605 + is new", the safe default for a caller with no such concept). This
  606 + covers both real scenarios correctly, which a pure "already promoted
  607 + -> always skip" rule (tried first, broke the retry-loop's own
  608 + already-passing regression test -- a rejected attempt's superseding
  609 + content MUST be able to promote a fresh artifact of the same type)
  610 + can't distinguish on its own:
  611 + - an OLD node (not new to this call) whose type was never actually
  612 + promoted (this task's real bug): not in already_promoted_types ->
  613 + promoted normally, regardless of new_node_ids.
  614 + - an OLD node whose type genuinely was already promoted by an earlier,
  615 + successful call: skipped -- no duplicate.
  616 + - a NEW node (in new_node_ids) whose type happens to already have an
  617 + artifact from a REJECTED earlier attempt (the retry-loop case):
  618 + promoted anyway, correctly superseding the rejected one."""
589 done_rows = conn.execute( 619 done_rows = conn.execute(
590 "SELECT node_id, output_payload FROM work_node_execution WHERE run_id = ? AND status = 'done'", 620 "SELECT node_id, output_payload FROM work_node_execution WHERE run_id = ? AND status = 'done'",
591 (run_id,), 621 (run_id,),
@@ -598,16 +628,23 @@ def _write_completed_node_artifacts( @@ -598,16 +628,23 @@ def _write_completed_node_artifacts(
598 for excluded in _failed_node_ids(run_graph_error): 628 for excluded in _failed_node_ids(run_graph_error):
599 done_outputs.pop(excluded, None) 629 done_outputs.pop(excluded, None)
600 630
  631 + already_promoted_types = {
  632 + row[0] for row in conn.execute(
  633 + "SELECT artifact_type FROM artifact WHERE task_id = ? AND produced_by_run_id = ?", (task_id, run_id)
  634 + ).fetchall()
  635 + }
  636 + new_node_ids = new_node_ids if new_node_ids is not None else set()
  637 +
601 artifact_ids = {} 638 artifact_ids = {}
602 for node in work_graph_orchestrate.topological_order(graph_manifest): 639 for node in work_graph_orchestrate.topological_order(graph_manifest):
603 node_id = node["node_id"] 640 node_id = node["node_id"]
604 if node_id not in done_outputs: 641 if node_id not in done_outputs:
605 continue 642 continue
606 - if only_node_ids is not None and node_id not in only_node_ids:  
607 - continue  
608 artifact_type = _resolve_node_artifact_type(node, node_artifact_types) 643 artifact_type = _resolve_node_artifact_type(node, node_artifact_types)
609 if artifact_type is None: 644 if artifact_type is None:
610 continue 645 continue
  646 + if artifact_type in already_promoted_types and node_id not in new_node_ids:
  647 + continue
611 deps = [ 648 deps = [
612 artifact_ids[node_artifact_types[dep_id]] 649 artifact_ids[node_artifact_types[dep_id]]
613 for dep_id in (node.get("depends_on") or []) 650 for dep_id in (node.get("depends_on") or [])
@@ -619,6 +656,7 @@ def _write_completed_node_artifacts( @@ -619,6 +656,7 @@ def _write_completed_node_artifacts(
619 done_outputs[node_id]["content"], depends_on_artifact_ids=deps, 656 done_outputs[node_id]["content"], depends_on_artifact_ids=deps,
620 ) 657 )
621 artifact_ids[artifact_type] = result["artifact_id"] 658 artifact_ids[artifact_type] = result["artifact_id"]
  659 + already_promoted_types.add(artifact_type)
622 return artifact_ids 660 return artifact_ids
623 661
624 662
@@ -680,10 +718,13 @@ def plan_documents(conn, task_id, goal_text, target_folder_path, timeout=300, ap @@ -680,10 +718,13 @@ def plan_documents(conn, task_id, goal_text, target_folder_path, timeout=300, ap
680 `ux_work_graph_run_active` allows only one non-terminal row per 718 `ux_work_graph_run_active` allows only one non-terminal row per
681 task_id, and the earlier attempt's row is never finalized to 719 task_id, and the earlier attempt's row is never finalized to
682 'done'/'failed' just because its content was rejected (Phase 2c: one 720 'done'/'failed' just because its content was rejected (Phase 2c: one
683 - run spans a task's whole lifecycle). `only_node_ids` is threaded into  
684 - `_write_completed_node_artifacts` the same way `implement_code()`  
685 - already does, so a re-plan only (re-)promotes the nodes THIS call  
686 - just planned, not the earlier attempt's already-processed ones again. 721 + run spans a task's whole lifecycle). `_write_completed_node_artifacts`
  722 + itself now decides what to (re-)promote by checking real artifact rows
  723 + for this run_id (2026-08-13 fix, see its own docstring) rather than by
  724 + which call planned which node, so a re-plan naturally promotes
  725 + whatever hasn't been genuinely promoted yet -- including an earlier
  726 + attempt's own already-'done' nodes if THEIR promotion never actually
  727 + ran.
687 """ 728 """
688 plan_result = work_graph_plan.plan_work_graph( 729 plan_result = work_graph_plan.plan_work_graph(
689 conn, "planner", task_id, goal_text, target_folder_path, timeout=timeout, 730 conn, "planner", task_id, goal_text, target_folder_path, timeout=timeout,
@@ -692,7 +733,6 @@ def plan_documents(conn, task_id, goal_text, target_folder_path, timeout=300, ap @@ -692,7 +733,6 @@ def plan_documents(conn, task_id, goal_text, target_folder_path, timeout=300, ap
692 run_id = plan_result["run_id"] 733 run_id = plan_result["run_id"]
693 graph_manifest = plan_result["graph_manifest"] 734 graph_manifest = plan_result["graph_manifest"]
694 node_artifact_types = {n["node_id"]: n.get("artifact_type", n["node_id"]) for n in graph_manifest["nodes"]} 735 node_artifact_types = {n["node_id"]: n.get("artifact_type", n["node_id"]) for n in graph_manifest["nodes"]}
695 - only_node_ids = set(plan_result["new_node_ids"]) if append_to_run_id else None  
696 736
697 run_graph_error = None 737 run_graph_error = None
698 try: 738 try:
@@ -706,7 +746,7 @@ def plan_documents(conn, task_id, goal_text, target_folder_path, timeout=300, ap @@ -706,7 +746,7 @@ def plan_documents(conn, task_id, goal_text, target_folder_path, timeout=300, ap
706 746
707 artifact_ids = _write_completed_node_artifacts( 747 artifact_ids = _write_completed_node_artifacts(
708 conn, target_folder_path, task_id, run_id, graph_manifest, node_artifact_types, 748 conn, target_folder_path, task_id, run_id, graph_manifest, node_artifact_types,
709 - _planner_ext_for, run_graph_error, only_node_ids=only_node_ids, 749 + _planner_ext_for, run_graph_error, new_node_ids=set(plan_result["new_node_ids"]),
710 ) 750 )
711 751
712 if run_graph_error is not None: 752 if run_graph_error is not None:
@@ -1060,7 +1100,7 @@ def implement_code( @@ -1060,7 +1100,7 @@ def implement_code(
1060 1100
1061 artifact_ids = _write_completed_node_artifacts( 1101 artifact_ids = _write_completed_node_artifacts(
1062 conn, target_folder_path, task_id, run_id, graph_manifest, node_artifact_types, 1102 conn, target_folder_path, task_id, run_id, graph_manifest, node_artifact_types,
1063 - _planner_ext_for, run_graph_error, only_node_ids=new_node_ids, 1103 + _planner_ext_for, run_graph_error, new_node_ids=new_node_ids,
1064 ) 1104 )
1065 1105
1066 if run_graph_error is not None: 1106 if run_graph_error is not None:
@@ -1362,7 +1402,7 @@ def validate_implementation_with_own_graph( @@ -1362,7 +1402,7 @@ def validate_implementation_with_own_graph(
1362 1402
1363 artifact_ids = _write_completed_node_artifacts( 1403 artifact_ids = _write_completed_node_artifacts(
1364 conn, target_folder_path, task_id, run_id, graph_manifest, node_artifact_types, 1404 conn, target_folder_path, task_id, run_id, graph_manifest, node_artifact_types,
1365 - _planner_ext_for, run_graph_error, only_node_ids=new_node_ids, 1405 + _planner_ext_for, run_graph_error, new_node_ids=new_node_ids,
1366 ) 1406 )
1367 1407
1368 if run_graph_error is not None: 1408 if run_graph_error is not None:
src/test_e2e_pipeline.py
@@ -209,9 +209,9 @@ class PipelineTestBase(unittest.TestCase): @@ -209,9 +209,9 @@ class PipelineTestBase(unittest.TestCase):
209 209
210 class WriteCompletedNodeArtifactsTest(PipelineTestBase): 210 class WriteCompletedNodeArtifactsTest(PipelineTestBase):
211 """Phase 2 §2.3/§6 (2026-08-12) direct coverage: promotes_to_artifact 211 """Phase 2 §2.3/§6 (2026-08-12) direct coverage: promotes_to_artifact
212 - resolution and only_node_ids scoping, tested directly rather than only  
213 - incidentally through plan_documents()/implement_code() integration  
214 - tests.""" 212 + resolution and (2026-08-13) already-promoted-type dedup, tested
  213 + directly rather than only incidentally through plan_documents()/
  214 + implement_code() integration tests."""
215 215
216 def setUp(self): 216 def setUp(self):
217 super().setUp() 217 super().setUp()
@@ -270,11 +270,10 @@ class WriteCompletedNodeArtifactsTest(PipelineTestBase): @@ -270,11 +270,10 @@ class WriteCompletedNodeArtifactsTest(PipelineTestBase):
270 with open(os.path.join(self.tmp.name, content)) as f: 270 with open(os.path.join(self.tmp.name, content)) as f:
271 self.assertEqual(f.read(), "# Real report content") 271 self.assertEqual(f.read(), "# Real report content")
272 272
273 - def test_only_node_ids_scopes_to_new_nodes_skipping_earlier_promotable_ones(self):  
274 - # a merged graph (Planner's already-artifact'd nodes + Coder's just-  
275 - # appended ones) must not re-promote/re-process nodes an EARLIER  
276 - # stage already wrote artifacts for, even though they'd otherwise  
277 - # be individually promotable. 273 + def test_node_with_type_already_promoted_this_run_is_not_duplicated(self):
  274 + # a node whose artifact_type genuinely already has a real artifact
  275 + # row for this run_id must not be re-promoted (no duplicate rows/
  276 + # git commits for unchanged content).
278 manifest = { 277 manifest = {
279 "nodes": [ 278 "nodes": [
280 {"node_id": "sdd", "artifact_type": "sdd", "depends_on": []}, 279 {"node_id": "sdd", "artifact_type": "sdd", "depends_on": []},
@@ -288,11 +287,54 @@ class WriteCompletedNodeArtifactsTest(PipelineTestBase): @@ -288,11 +287,54 @@ class WriteCompletedNodeArtifactsTest(PipelineTestBase):
288 self._mark_done("sdd", "# SDD content") 287 self._mark_done("sdd", "# SDD content")
289 self._mark_done("impl", "# Report content") 288 self._mark_done("impl", "# Report content")
290 node_artifact_types = {"sdd": "sdd", "impl": "test_report"} 289 node_artifact_types = {"sdd": "sdd", "impl": "test_report"}
  290 + e2e_pipeline._write_completed_node_artifacts(
  291 + self.conn, self.tmp.name, "task-1", "run-1", manifest, node_artifact_types, lambda t: "md",
  292 + )
  293 + count_after_first_call = self.conn.execute(
  294 + "SELECT COUNT(*) FROM artifact WHERE task_id = 'task-1' AND artifact_type = 'sdd'"
  295 + ).fetchone()[0]
  296 + self.assertEqual(count_after_first_call, 1)
  297 +
  298 + # a second call (e.g. a later append-mode retry) over the SAME
  299 + # manifest/run_id must not create a second "sdd" artifact.
291 artifact_ids = e2e_pipeline._write_completed_node_artifacts( 300 artifact_ids = e2e_pipeline._write_completed_node_artifacts(
292 self.conn, self.tmp.name, "task-1", "run-1", manifest, node_artifact_types, lambda t: "md", 301 self.conn, self.tmp.name, "task-1", "run-1", manifest, node_artifact_types, lambda t: "md",
293 - only_node_ids={"impl"},  
294 ) 302 )
295 - self.assertEqual(set(artifact_ids), {"test_report"}) # NOT "sdd" -- outside only_node_ids 303 + self.assertEqual(artifact_ids, {})
  304 + count_after_second_call = self.conn.execute(
  305 + "SELECT COUNT(*) FROM artifact WHERE task_id = 'task-1' AND artifact_type = 'sdd'"
  306 + ).fetchone()[0]
  307 + self.assertEqual(count_after_second_call, 1)
  308 +
  309 + def test_earlier_done_node_whose_promotion_never_ran_is_still_promotable_later(self):
  310 + # Real incident (2026-08-13, task b243dfe8, diagnosed live with the
  311 + # user): a node completed ('done', real output_payload) in an
  312 + # EARLIER append-mode call, but that call's OWN
  313 + # _write_completed_node_artifacts invocation was never reached
  314 + # (e.g. it crashed even earlier in that same call, before getting
  315 + # here) -- so no artifact was ever actually written for it. A LATER
  316 + # call, over the same merged manifest/run_id, must still be able to
  317 + # promote it: real content sitting in work_node_execution.
  318 + # output_payload must not become permanently unpromotable just
  319 + # because it wasn't part of THIS specific call's own newly-planned
  320 + # nodes.
  321 + manifest = {
  322 + "nodes": [
  323 + {"node_id": "sdd", "artifact_type": "sdd", "depends_on": []},
  324 + {"node_id": "benchmark", "artifact_type": "benchmark", "depends_on": ["sdd"]},
  325 + ],
  326 + "edges": [],
  327 + }
  328 + self._mark_done("sdd", "# SDD content") # done long ago; genuinely never promoted
  329 + self._mark_done("benchmark", "# Benchmark content") # newly planned by THIS call
  330 + node_artifact_types = {"sdd": "sdd", "benchmark": "benchmark"}
  331 +
  332 + self.assertEqual(self.conn.execute("SELECT COUNT(*) FROM artifact WHERE task_id = 'task-1'").fetchone()[0], 0)
  333 +
  334 + artifact_ids = e2e_pipeline._write_completed_node_artifacts(
  335 + self.conn, self.tmp.name, "task-1", "run-1", manifest, node_artifact_types, lambda t: "md",
  336 + )
  337 + self.assertEqual(set(artifact_ids), {"sdd", "benchmark"})
296 338
297 339
298 class PlanDocumentsTest(PipelineTestBase): 340 class PlanDocumentsTest(PipelineTestBase):
@@ -714,12 +756,28 @@ class ReviewPlannerOutputWithRetriesTest(PipelineTestBase): @@ -714,12 +756,28 @@ class ReviewPlannerOutputWithRetriesTest(PipelineTestBase):
714 with this leftover row via the exact same IntegrityError. Seeding 756 with this leftover row via the exact same IntegrityError. Seeding
715 prior_run_id from _existing_active_run_id() (queries 757 prior_run_id from _existing_active_run_id() (queries
716 work_graph_run directly) fixes this.""" 758 work_graph_run directly) fixes this."""
  759 + # orphan_sdd is marked 'done' up front (matching the REAL b243dfe8
  760 + # scenario: the node genuinely completed in an earlier, crashed
  761 + # attempt) -- without this, run_graph() would treat it as never-
  762 + # executed and run it for real via the same mocked
  763 + # trigger_org_node_structured() as the "sdd" node below, producing
  764 + # byte-identical content for two different node_ids of the same
  765 + # artifact_type and hitting a real (but different, not what this
  766 + # test is about) "nothing to commit" GitCommitFailedError.
717 orphaned_manifest = {"nodes": [{"node_id": "orphan_sdd", "artifact_type": "sdd", "depends_on": []}], "edges": []} 767 orphaned_manifest = {"nodes": [{"node_id": "orphan_sdd", "artifact_type": "sdd", "depends_on": []}], "edges": []}
718 self.conn.execute( 768 self.conn.execute(
719 "INSERT INTO work_graph_run (run_id, task_id, graph_manifest, status) " 769 "INSERT INTO work_graph_run (run_id, task_id, graph_manifest, status) "
720 "VALUES ('run-orphan', ?, ?, 'planning')", 770 "VALUES ('run-orphan', ?, ?, 'planning')",
721 (self.task_id, json.dumps(orphaned_manifest)), 771 (self.task_id, json.dumps(orphaned_manifest)),
722 ) 772 )
  773 + work_graph_orchestrate.ensure_placeholder_skill(self.conn, "planner-doc-writing", "planner")
  774 + self.conn.execute(
  775 + "INSERT INTO work_node_execution (node_exec_id, run_id, node_id, subagent_type, skill_ref, "
  776 + "input_schema_ref, output_schema_ref, input_payload, output_payload, status) "
  777 + "VALUES ('node-exec-orphan-sdd', 'run-orphan', 'orphan_sdd', 'planner', 'planner-doc-writing', "
  778 + "'in.json', 'out.json', '{}', ?, 'done')",
  779 + (json.dumps({"content": "# SDD -- orphaned, never promoted"}),),
  780 + )
723 self.conn.commit() 781 self.conn.commit()
724 782
725 def fake_plan_subprocess_run(*args, **kwargs): 783 def fake_plan_subprocess_run(*args, **kwargs):