diff --git a/src/forge/orchestrator/worker.py b/src/forge/orchestrator/worker.py index 3b631222..8bf061b3 100644 --- a/src/forge/orchestrator/worker.py +++ b/src/forge/orchestrator/worker.py @@ -45,9 +45,7 @@ def _is_workflow_errored(state: dict) -> bool: def _has_new_reportable_error(result: dict, error_before_invoke: str | None) -> bool: """Return whether an invocation produced an error that should be reported.""" last_error = result.get("last_error") - return bool( - last_error and not result.get("is_paused", False) and last_error != error_before_invoke - ) + return bool(last_error and last_error != error_before_invoke) async def _report_new_workflow_error(result: dict, error_before_invoke: str | None) -> None: diff --git a/src/forge/workflow/bug/graph.py b/src/forge/workflow/bug/graph.py index 01eaf5ae..ed615dff 100644 --- a/src/forge/workflow/bug/graph.py +++ b/src/forge/workflow/bug/graph.py @@ -145,7 +145,7 @@ def route_entry(state: BugState) -> str: def _route_after_triage_check(state: BugState) -> str: """Route after triage_check based on what triage_check set as current_node.""" node = state.get("current_node", "triage_gate") - if node in ("analyze_bug", "triage_gate", "escalate_blocked"): + if node in ("triage_check", "analyze_bug", "triage_gate", "escalate_blocked"): return node return "triage_gate" @@ -491,6 +491,7 @@ def build_bug_graph() -> StateGraph: "triage_check", _route_after_triage_check, { + "triage_check": "triage_check", "triage_gate": "triage_gate", "analyze_bug": "analyze_bug", "escalate_blocked": "escalate_blocked", diff --git a/src/forge/workflow/task_takeover/graph.py b/src/forge/workflow/task_takeover/graph.py index 804837cb..04862246 100644 --- a/src/forge/workflow/task_takeover/graph.py +++ b/src/forge/workflow/task_takeover/graph.py @@ -96,6 +96,8 @@ def route_entry(state: TaskTakeoverState) -> str: def _route_after_triage_check(state: TaskTakeoverState) -> str: """Route after triage_check based on what triage_check set as current_node.""" node = state.get("current_node", "triage_gate") + if node == "triage_check": + return "triage_check" if node in ("analyze_bug", "generate_plan"): return "generate_plan" if node in ("triage_gate", "escalate_blocked"): @@ -282,6 +284,7 @@ def build_task_takeover_graph() -> StateGraph[TaskTakeoverState, Any, Any]: "triage_check", _route_after_triage_check, { + "triage_check": "triage_check", "triage_gate": "triage_gate", "generate_plan": "generate_plan", "escalate_blocked": "escalate_blocked", diff --git a/tests/unit/orchestrator/test_worker.py b/tests/unit/orchestrator/test_worker.py index 30a0abf6..1bc5bbfb 100644 --- a/tests/unit/orchestrator/test_worker.py +++ b/tests/unit/orchestrator/test_worker.py @@ -19,7 +19,7 @@ [ ({"last_error": "new failure", "is_paused": False}, None, True), ({"last_error": "same failure", "is_paused": False}, "same failure", False), - ({"last_error": "needs input", "is_paused": True}, None, False), + ({"last_error": "paused failure", "is_paused": True}, None, True), ({"last_error": None, "is_paused": False}, None, False), ], ) @@ -42,12 +42,26 @@ async def test_report_new_workflow_error_posts_once(): notify.assert_awaited_once_with(result, "clone failed", "setup_workspace") +@pytest.mark.asyncio +async def test_report_new_workflow_error_posts_when_failure_ends_at_pause_gate(): + result = { + "ticket_key": "TEST-123", + "current_node": "triage_gate", + "last_error": "model backend unavailable", + "is_paused": True, + } + + with patch("forge.orchestrator.worker.notify_error", new_callable=AsyncMock) as notify: + await _report_new_workflow_error(result, None) + + notify.assert_awaited_once_with(result, "model backend unavailable", "triage_gate") + + @pytest.mark.asyncio @pytest.mark.parametrize( ("result", "error_before_invoke"), [ ({"last_error": "same failure", "is_paused": False}, "same failure"), - ({"last_error": "needs input", "is_paused": True}, None), ({"last_error": None, "is_paused": False}, None), ], ) diff --git a/tests/unit/workflow/bug/test_graph.py b/tests/unit/workflow/bug/test_graph.py index 129529b7..a7783001 100644 --- a/tests/unit/workflow/bug/test_graph.py +++ b/tests/unit/workflow/bug/test_graph.py @@ -99,6 +99,10 @@ def test_empty_current_node_routes_to_triage(self): class TestTriageCheckRouting: """_route_after_triage_check proxies triage_check current_node output.""" + def test_routes_retryable_failure_back_to_triage_check(self): + state = _bug_state(current_node="triage_check", last_error="temporary failure") + assert _route_after_triage_check(state) == "triage_check" + def test_routes_to_analyze_bug(self): state = _bug_state(current_node="analyze_bug") assert _route_after_triage_check(state) == "analyze_bug" diff --git a/tests/unit/workflow/task_takeover/test_graph.py b/tests/unit/workflow/task_takeover/test_graph.py index 0f33d467..1e1cc188 100644 --- a/tests/unit/workflow/task_takeover/test_graph.py +++ b/tests/unit/workflow/task_takeover/test_graph.py @@ -1,6 +1,7 @@ """Unit tests for Task Takeover workflow state and graph structure.""" from typing import Any, cast + import pytest from langgraph.graph import END, StateGraph @@ -93,6 +94,7 @@ class TestTriageCheckRouting: @pytest.mark.parametrize( "current_node,expected", [ + ("triage_check", "triage_check"), ("analyze_bug", "generate_plan"), ("triage_gate", "triage_gate"), ("escalate_blocked", "escalate_blocked"), diff --git a/tests/workflow/test_task_takeover_graph.py b/tests/workflow/test_task_takeover_graph.py index a57873f8..a66711ac 100644 --- a/tests/workflow/test_task_takeover_graph.py +++ b/tests/workflow/test_task_takeover_graph.py @@ -111,6 +111,7 @@ def test_route_entry(self, current_node: str, expected_next: str) -> None: @pytest.mark.parametrize( "current_node, expected_next", [ + ("triage_check", "triage_check"), ("generate_plan", "generate_plan"), ("triage_gate", "triage_gate"), ("escalate_blocked", "escalate_blocked"),