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 552  
553 553 def _write_completed_node_artifacts(
554 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 557 """Writes an artifact for every node that's genuinely done AND
558 558 verify_fn-accepted, sourced from `work_node_execution` in the DB --
... ... @@ -580,12 +580,42 @@ def _write_completed_node_artifacts(
580 580 2026-08-12 (Phase 2 §2.3/§6): a node with no resolvable artifact_type
581 581 (see `_resolve_node_artifact_type`) is silently skipped, not an
582 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 619 done_rows = conn.execute(
590 620 "SELECT node_id, output_payload FROM work_node_execution WHERE run_id = ? AND status = 'done'",
591 621 (run_id,),
... ... @@ -598,16 +628,23 @@ def _write_completed_node_artifacts(
598 628 for excluded in _failed_node_ids(run_graph_error):
599 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 638 artifact_ids = {}
602 639 for node in work_graph_orchestrate.topological_order(graph_manifest):
603 640 node_id = node["node_id"]
604 641 if node_id not in done_outputs:
605 642 continue
606   - if only_node_ids is not None and node_id not in only_node_ids:
607   - continue
608 643 artifact_type = _resolve_node_artifact_type(node, node_artifact_types)
609 644 if artifact_type is None:
610 645 continue
  646 + if artifact_type in already_promoted_types and node_id not in new_node_ids:
  647 + continue
611 648 deps = [
612 649 artifact_ids[node_artifact_types[dep_id]]
613 650 for dep_id in (node.get("depends_on") or [])
... ... @@ -619,6 +656,7 @@ def _write_completed_node_artifacts(
619 656 done_outputs[node_id]["content"], depends_on_artifact_ids=deps,
620 657 )
621 658 artifact_ids[artifact_type] = result["artifact_id"]
  659 + already_promoted_types.add(artifact_type)
622 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 718 `ux_work_graph_run_active` allows only one non-terminal row per
681 719 task_id, and the earlier attempt's row is never finalized to
682 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 729 plan_result = work_graph_plan.plan_work_graph(
689 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 733 run_id = plan_result["run_id"]
693 734 graph_manifest = plan_result["graph_manifest"]
694 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 737 run_graph_error = None
698 738 try:
... ... @@ -706,7 +746,7 @@ def plan_documents(conn, task_id, goal_text, target_folder_path, timeout=300, ap
706 746  
707 747 artifact_ids = _write_completed_node_artifacts(
708 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 752 if run_graph_error is not None:
... ... @@ -1060,7 +1100,7 @@ def implement_code(
1060 1100  
1061 1101 artifact_ids = _write_completed_node_artifacts(
1062 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 1106 if run_graph_error is not None:
... ... @@ -1362,7 +1402,7 @@ def validate_implementation_with_own_graph(
1362 1402  
1363 1403 artifact_ids = _write_completed_node_artifacts(
1364 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 1408 if run_graph_error is not None:
... ...
src/test_e2e_pipeline.py
... ... @@ -209,9 +209,9 @@ class PipelineTestBase(unittest.TestCase):
209 209  
210 210 class WriteCompletedNodeArtifactsTest(PipelineTestBase):
211 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 216 def setUp(self):
217 217 super().setUp()
... ... @@ -270,11 +270,10 @@ class WriteCompletedNodeArtifactsTest(PipelineTestBase):
270 270 with open(os.path.join(self.tmp.name, content)) as f:
271 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 277 manifest = {
279 278 "nodes": [
280 279 {"node_id": "sdd", "artifact_type": "sdd", "depends_on": []},
... ... @@ -288,11 +287,54 @@ class WriteCompletedNodeArtifactsTest(PipelineTestBase):
288 287 self._mark_done("sdd", "# SDD content")
289 288 self._mark_done("impl", "# Report content")
290 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 300 artifact_ids = e2e_pipeline._write_completed_node_artifacts(
292 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 340 class PlanDocumentsTest(PipelineTestBase):
... ... @@ -714,12 +756,28 @@ class ReviewPlannerOutputWithRetriesTest(PipelineTestBase):
714 756 with this leftover row via the exact same IntegrityError. Seeding
715 757 prior_run_id from _existing_active_run_id() (queries
716 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 767 orphaned_manifest = {"nodes": [{"node_id": "orphan_sdd", "artifact_type": "sdd", "depends_on": []}], "edges": []}
718 768 self.conn.execute(
719 769 "INSERT INTO work_graph_run (run_id, task_id, graph_manifest, status) "
720 770 "VALUES ('run-orphan', ?, ?, 'planning')",
721 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 781 self.conn.commit()
724 782  
725 783 def fake_plan_subprocess_run(*args, **kwargs):
... ...