Commit 1402836df0443efe12252306e3b8f05db8e94c0b

Authored by bboonnee
1 parent 378fccd8

fix: promote a retried node's latest accepted attempt, not its first

Real incident (2026-08-15, task b243dfe8): a node that went through
loop_back/retry_self gets a fresh "<base>-attemptN" work_node_execution row
each retry (work_graph_orchestrate._next_exec_node_id, N counted from real
loop_back_triggered events) -- but graph_manifest only ever names the BASE
node_id, never any "-attemptN" variant. _write_completed_node_artifacts's
lookup (topological_order yields base node_ids) always resolved to
done_outputs[base] -- attempt 1's REJECTED content, the only one filed under
the bare node_id -- permanently stale the instant a node was ever
rejected-then-retried-then-accepted. First surfaced today because this task
is the first to actually reach "retried, then eventually accepted" for a
node with real downstream artifact promotion.

Fixed by resolving each base node_id to its highest-numbered "-attemptN"
sibling's content before the promotion loop runs -- the highest attempt
number is the most recently executed one, which is only still 'done' in the
DB if the graph actually moved past it (a still-rejected final attempt would
have either retried again or raised, both already handled by the existing
_failed_node_ids() exclusion, which operates on base node_ids and is
unaffected by this remap since only the dict VALUE changes, not the key).

New tests: one drives the real 3-attempt scenario and confirms the promoted
artifact holds attempt 3's content, not attempt 1's (verified failing on the
pre-fix code with the exact real content mismatch); another confirms the
exhausted-retry exclusion still works correctly after the remap.

847 tests total in the suite (24 new ones landed via a concurrent, unrelated
self-hosting run -- see conversation); the 8 files this fix and today's
other fixes actually touch are 476/476 green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
src/e2e_pipeline.py
@@ -625,6 +625,30 @@ def _write_completed_node_artifacts( @@ -625,6 +625,30 @@ def _write_completed_node_artifacts(
625 reflect_id = node_id + "-reflect" 625 reflect_id = node_id + "-reflect"
626 if reflect_id in done_outputs: 626 if reflect_id in done_outputs:
627 done_outputs[node_id] = done_outputs[reflect_id] 627 done_outputs[node_id] = done_outputs[reflect_id]
  628 + # 2026-08-15 (real incident, task b243dfe8): a node that went through
  629 + # loop_back/retry_self gets a fresh "<base>-attemptN" work_node_execution
  630 + # row each time (work_graph_orchestrate._next_exec_node_id, N counted
  631 + # from real loop_back_triggered events) -- graph_manifest itself only
  632 + # ever names the BASE node_id, never any "-attemptN" variant. Without
  633 + # this remap, the lookup two lines below (topological_order yields base
  634 + # node_ids) always resolved to done_outputs[base] -- attempt 1's
  635 + # content, the FIRST one, permanently stale the instant a node is ever
  636 + # rejected-then-retried-then-accepted. This node judged 'ok' via its own
  637 + # verify_fn is real content sitting in the DB; it was simply never
  638 + # findable under the id this function looks it up by. Highest attempt
  639 + # number = most recently executed = the one the graph actually moved
  640 + # past (if the LATEST attempt were still rejected, run_graph() would
  641 + # either have retried again or raised -- caught by the existing
  642 + # _failed_node_ids() exclusion below either way, which already operates
  643 + # on base node_ids and is unaffected by this remap).
  644 + attempts_by_base = {}
  645 + for node_id in done_outputs:
  646 + base, sep, suffix = node_id.rpartition("-attempt")
  647 + if sep and suffix.isdigit() and base in done_outputs:
  648 + attempts_by_base.setdefault(base, []).append((int(suffix), node_id))
  649 + for base, entries in attempts_by_base.items():
  650 + _, latest_id = max(entries)
  651 + done_outputs[base] = done_outputs[latest_id]
628 for excluded in _failed_node_ids(run_graph_error): 652 for excluded in _failed_node_ids(run_graph_error):
629 done_outputs.pop(excluded, None) 653 done_outputs.pop(excluded, None)
630 654
src/test_e2e_pipeline.py
@@ -336,6 +336,58 @@ class WriteCompletedNodeArtifactsTest(PipelineTestBase): @@ -336,6 +336,58 @@ class WriteCompletedNodeArtifactsTest(PipelineTestBase):
336 ) 336 )
337 self.assertEqual(set(artifact_ids), {"sdd", "benchmark"}) 337 self.assertEqual(set(artifact_ids), {"sdd", "benchmark"})
338 338
  339 + def test_retried_node_promotes_its_latest_attempts_content_not_the_first(self):
  340 + # Real incident (2026-08-15, task b243dfe8): a node that went
  341 + # through loop_back/retry_self gets a fresh "<base>-attemptN"
  342 + # work_node_execution row each retry -- graph_manifest only ever
  343 + # names the base node_id. Without resolving to the highest attempt
  344 + # number, this function always found attempt 1's REJECTED content
  345 + # (the only one filed under the bare node_id), never the later
  346 + # attempt that actually got accepted -- permanently stale the
  347 + # instant a node was ever rejected-then-retried-then-accepted.
  348 + manifest = {
  349 + "nodes": [{"node_id": "benchmark", "artifact_type": "benchmark", "depends_on": []}],
  350 + "edges": [],
  351 + }
  352 + self._mark_done("benchmark", "# REJECTED first attempt")
  353 + self._mark_done("benchmark-attempt2", "# also rejected")
  354 + self._mark_done("benchmark-attempt3", "# finally accepted -- this is the real content")
  355 + node_artifact_types = {"benchmark": "benchmark"}
  356 +
  357 + artifact_ids = e2e_pipeline._write_completed_node_artifacts(
  358 + self.conn, self.tmp.name, "task-1", "run-1", manifest, node_artifact_types, lambda t: "md",
  359 + )
  360 + self.assertEqual(set(artifact_ids), {"benchmark"})
  361 + content = self.conn.execute(
  362 + "SELECT file_path FROM artifact WHERE artifact_id = ?", (artifact_ids["benchmark"],)
  363 + ).fetchone()[0]
  364 + with open(os.path.join(self.tmp.name, content)) as f:
  365 + self.assertEqual(f.read(), "# finally accepted -- this is the real content")
  366 +
  367 + def test_exhausted_retry_still_excluded_even_with_multiple_attempts(self):
  368 + # The _failed_node_ids(run_graph_error) exclusion (existing
  369 + # behavior) must still work correctly after the attempt-number
  370 + # remap above -- it operates on base node_ids, which the remap
  371 + # preserves as dict keys (only the VALUE/content changes).
  372 + manifest = {
  373 + "nodes": [{"node_id": "benchmark", "artifact_type": "benchmark", "depends_on": []}],
  374 + "edges": [],
  375 + }
  376 + self._mark_done("benchmark", "# rejected")
  377 + self._mark_done("benchmark-attempt2", "# rejected again, exhausted budget")
  378 + run_graph_error = work_graph_orchestrate.ConsistencyCheckFailedError(
  379 + "loop_back from benchmark to benchmark exceeded max_attempts", node_id="benchmark",
  380 + )
  381 + node_artifact_types = {"benchmark": "benchmark"}
  382 +
  383 + artifact_ids = e2e_pipeline._write_completed_node_artifacts(
  384 + self.conn, self.tmp.name, "task-1", "run-1", manifest, node_artifact_types, lambda t: "md",
  385 + run_graph_error=run_graph_error,
  386 + )
  387 + self.assertEqual(artifact_ids, {})
  388 + count = self.conn.execute("SELECT COUNT(*) FROM artifact WHERE task_id = 'task-1'").fetchone()[0]
  389 + self.assertEqual(count, 0)
  390 +
339 391
340 class PlanDocumentsTest(PipelineTestBase): 392 class PlanDocumentsTest(PipelineTestBase):
341 def test_produces_five_artifacts_with_correct_lineage(self): 393 def test_produces_five_artifacts_with_correct_lineage(self):