Commit 1402836df0443efe12252306e3b8f05db8e94c0b
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>
Showing
2 changed files
with
76 additions
and
0 deletions
src/e2e_pipeline.py
| ... | ... | @@ -625,6 +625,30 @@ def _write_completed_node_artifacts( |
| 625 | 625 | reflect_id = node_id + "-reflect" |
| 626 | 626 | if reflect_id in done_outputs: |
| 627 | 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 | 652 | for excluded in _failed_node_ids(run_graph_error): |
| 629 | 653 | done_outputs.pop(excluded, None) |
| 630 | 654 | ... | ... |
src/test_e2e_pipeline.py
| ... | ... | @@ -336,6 +336,58 @@ class WriteCompletedNodeArtifactsTest(PipelineTestBase): |
| 336 | 336 | ) |
| 337 | 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 | 392 | class PlanDocumentsTest(PipelineTestBase): |
| 341 | 393 | def test_produces_five_artifacts_with_correct_lineage(self): | ... | ... |