From 2b8c2747b4399933577a56e68e9706646773e0a5 Mon Sep 17 00:00:00 2001 From: bboonnee Date: Fri, 14 Aug 2026 10:55:36 +0800 Subject: [PATCH] fix: record the final attempt's own rejection feedback when a loop_back exhausts its retry budget --- src/test_work_graph_orchestrate.py | 35 +++++++++++++++++++++++++++++++++++ src/work_graph_orchestrate.py | 14 ++++++++++++++ 2 files changed, 49 insertions(+), 0 deletions(-) diff --git a/src/test_work_graph_orchestrate.py b/src/test_work_graph_orchestrate.py index f2e634f..8e0cd64 100644 --- a/src/test_work_graph_orchestrate.py +++ b/src/test_work_graph_orchestrate.py @@ -1151,6 +1151,41 @@ class RunGraphLoopBackTest(RunGraphTestBase): status = self.conn.execute("SELECT status FROM work_graph_run WHERE run_id = 'run-1'").fetchone()[0] self.assertEqual(status, "blocked") + def test_exceeding_limit_still_records_the_final_rejections_own_feedback(self): + # Real incident (2026-08-13, task b243dfe8, diagnosed live with the + # user): the exceeded-limit branch used to raise + # ConsistencyCheckFailedError WITHOUT ever calling + # _record_loop_back_event() -- the judge's real reason for + # rejecting the FINAL attempt (the one that actually exhausted the + # budget) was silently discarded. Only a human/arbiter reading + # loop_back_triggered execution_events, expecting one per real + # rejection, would notice the last one is simply missing. + manifest = { + "nodes": [{ + "node_id": "benchmark", + "judgment": {"kind": "mechanical", "check_ref": "compiles"}, + "on_fail": {"action": "retry_self", "max_attempts": 2}, + }], + "edges": [], + } + registry = {"compiles": lambda content: (False, "specific reason attempt 3 was rejected")} + with patch.object(org_node_trigger, "trigger_org_node_structured", return_value={"content": "always bad"}): + with self.assertRaises(wgo.ConsistencyCheckFailedError): + wgo.run_graph( + self.conn, "run-1", "planner", "planner-doc-writing", "/tmp/proj-1", manifest, + lambda node, outputs: "p", _IN_SCHEMA, _OUT_SCHEMA, mechanical_check_registry=registry, + ) + events = self.conn.execute( + "SELECT payload FROM execution_event WHERE run_id = 'run-1' AND event_type = 'loop_back_triggered' " + "ORDER BY created_at" + ).fetchall() + # on_fail.max_attempts=2 -> attempts 1 and 2 loop back normally, + # attempt 3 exhausts the budget -- all THREE must be recorded, not + # just the first two. + self.assertEqual(len(events), 3) + last_feedback = json.loads(events[-1][0])["feedback"] + self.assertIn("specific reason attempt 3 was rejected", str(last_feedback)) + def test_hard_fail_raises_immediately_with_no_retry(self): manifest = { "nodes": [{ diff --git a/src/work_graph_orchestrate.py b/src/work_graph_orchestrate.py index 95c61b4..ecee81a 100644 --- a/src/work_graph_orchestrate.py +++ b/src/work_graph_orchestrate.py @@ -1164,6 +1164,20 @@ def _run_layers( total_attempts = _loop_attempt_count_from_events_total(conn, run_id, sig.source_node_id) + 1 total_limit = _resolve_total_loop_limit(conn, org_node, sig.source_node_id) if attempt_number > limit or total_attempts > total_limit: + # 2026-08-13 (real incident, task b243dfe8, diagnosed live + # with the user): this branch used to jump straight to + # raise, skipping _record_loop_back_event() below entirely + # -- sig.feedback (the judge's REAL reason THIS FINAL + # attempt was rejected, the one that actually exhausted the + # budget) was silently discarded. Nothing in the DB could + # answer "what was specifically wrong with the last try", + # only "it happened N times" (this exception's own summary + # message). The attempt that exhausted the budget is + # recorded the same way every other rejected attempt already + # is, before giving up -- so a human (or a future arbiter) + # diagnosing the block can see the real judgment, not just a + # count. + _record_loop_back_event(conn, run_id, sig, attempt_number) conn.execute("UPDATE work_graph_run SET status = 'blocked' WHERE run_id = ?", (run_id,)) conn.commit() raise ConsistencyCheckFailedError( -- libgit2 0.21.4