diff --git a/src/forge/orchestrator/worker.py b/src/forge/orchestrator/worker.py index 8bf061b3..dc1a0da9 100644 --- a/src/forge/orchestrator/worker.py +++ b/src/forge/orchestrator/worker.py @@ -1156,7 +1156,19 @@ async def _handle_resume_event( } prev_error = current_state.get("last_error") is_paused_at_gate = current_state.get("is_paused") and current_node in approval_gates - if is_paused_at_gate and not prev_error: + if current_node == "triage_gate": + logger.info("Retry at triage_gate — re-running triage_check") + updated_state["is_paused"] = False + updated_state["is_blocked"] = False + updated_state["last_error"] = None + updated_state["auto_retry_cap_notified"] = False + updated_state["retry_count"] = 0 + updated_state["current_node"] = "triage_check" + updated_state["context"] = { + **updated_state.get("context", {}), + "force_fresh_invoke": True, + } + elif is_paused_at_gate: logger.info( f"Retry at approval gate {current_node} — triggering regeneration " f"via revision request" diff --git a/tests/unit/orchestrator/test_worker.py b/tests/unit/orchestrator/test_worker.py index 1bc5bbfb..30a7fe62 100644 --- a/tests/unit/orchestrator/test_worker.py +++ b/tests/unit/orchestrator/test_worker.py @@ -330,6 +330,84 @@ async def test_retry_at_task_approval_gate_clears_stale_epic_and_task_keys( assert result["current_task_key"] is None assert result["revision_requested"] is True + @pytest.mark.asyncio + async def test_retry_at_triage_gate_reenters_triage_check( + self, worker: OrchestratorWorker, base_message: QueueMessage, base_state: dict + ): + state = { + **base_state, + "current_node": "triage_gate", + "is_paused": True, + "last_error": "model backend unavailable", + "retry_count": 1, + } + payload = { + **base_message.payload, + "changelog": { + "items": [ + { + "field": "labels", + "fromString": "forge:managed", + "toString": "forge:managed forge:retry", + } + ] + }, + } + message = QueueMessage( + message_id=base_message.message_id, + event_id=base_message.event_id, + source=base_message.source, + event_type="jira:issue_updated", + ticket_key=base_message.ticket_key, + payload=payload, + ) + + result = await worker._handle_resume_event(message, state) + + assert result["current_node"] == "triage_check" + assert result["is_paused"] is False + assert result["last_error"] is None + assert result["context"]["force_fresh_invoke"] is True + + @pytest.mark.asyncio + async def test_retry_at_approval_gate_with_error_triggers_regeneration( + self, worker: OrchestratorWorker, base_message: QueueMessage, base_state: dict + ): + state = { + **base_state, + "current_node": "prd_approval_gate", + "is_paused": True, + "last_error": "PRD publish pending", + } + payload = { + **base_message.payload, + "changelog": { + "items": [ + { + "field": "labels", + "fromString": "forge:managed", + "toString": "forge:managed forge:retry", + } + ] + }, + } + message = QueueMessage( + message_id=base_message.message_id, + event_id=base_message.event_id, + source=base_message.source, + event_type="jira:issue_updated", + ticket_key=base_message.ticket_key, + payload=payload, + ) + + result = await worker._handle_resume_event(message, state) + + assert result["current_node"] == "prd_approval_gate" + assert result["is_paused"] is False + assert result["last_error"] is None + assert result["revision_requested"] is True + assert result["feedback_comment"] == "Regeneration requested via retry." + @pytest.mark.asyncio async def test_prd_label_change_to_approved_sets_approved_flag( self, worker: OrchestratorWorker, base_message: QueueMessage, base_state: dict