diff --git a/src/sentry/seer/autofix/on_completion_hook.py b/src/sentry/seer/autofix/on_completion_hook.py index 8fee3464cd0c..ee40ac324c48 100644 --- a/src/sentry/seer/autofix/on_completion_hook.py +++ b/src/sentry/seer/autofix/on_completion_hook.py @@ -328,14 +328,14 @@ def _maybe_continue_pipeline( ) return - # Check if we've reached the stopping point - stopping_step = STOPPING_POINT_TO_STEP.get(stopping_point) - if stopping_step and current_step == stopping_step: - # We've reached the stopping point - return - - # Check if we should trigger coding agent handoff instead of continuing - handoff_config = cls._get_handoff_config_if_applicable(stopping_point, current_step, group) + # Check if we should trigger coding agent handoff before evaluating + # the stopping point. Handoff replaces the rest of the pipeline, so it + # must take precedence over the stopping_point early-return — matching + # the legacy seer-side order in + # seer/automation/autofix/steps/root_cause_step.py, where + # _check_and_trigger_coding_agent_handoff runs before + # _should_auto_run_solution_step. + handoff_config = cls._get_handoff_config_if_applicable(current_step, group) if handoff_config: cls._trigger_coding_agent_handoff( organization, @@ -346,6 +346,12 @@ def _maybe_continue_pipeline( ) return + # Check if we've reached the stopping point + stopping_step = STOPPING_POINT_TO_STEP.get(stopping_point) + if stopping_step and current_step == stopping_step: + # We've reached the stopping point + return + # Special case: if stopping_point is open_pr and we just finished code_changes, push changes if ( stopping_point == AutofixStoppingPoint.OPEN_PR @@ -449,7 +455,6 @@ def _push_changes(cls, group: Group, run_id: int, state: SeerRunState) -> None: @classmethod def _get_handoff_config_if_applicable( cls, - stopping_point: AutofixStoppingPoint, current_step: AutofixStep | None, group: Group, ) -> SeerAutomationHandoffConfiguration | None: @@ -458,21 +463,17 @@ def _get_handoff_config_if_applicable( Handoff is triggered when: - current_step is ROOT_CAUSE - - stopping_point is SOLUTION, CODE_CHANGES, or OPEN_PR - automation_handoff is configured with handoff_point = ROOT_CAUSE + + The legacy seer-side gate + (seer/automation/autofix/steps/root_cause_step.py + _check_and_trigger_coding_agent_handoff) does not look at the run's + stopping_point. "Stop seer at root cause, then hand off to an external + coding agent" is a coherent configuration that must trigger handoff. """ - # Only trigger handoff after root cause is completed if current_step != AutofixStep.ROOT_CAUSE: return None - # Only trigger handoff when continuing beyond root cause - if stopping_point not in [ - AutofixStoppingPoint.SOLUTION, - AutofixStoppingPoint.CODE_CHANGES, - AutofixStoppingPoint.OPEN_PR, - ]: - return None - return read_preference_from_sentry_db(group.project).automation_handoff @classmethod diff --git a/tests/sentry/seer/autofix/test_autofix_on_completion_hook.py b/tests/sentry/seer/autofix/test_autofix_on_completion_hook.py index 500b13c9ae25..cc76e57f4297 100644 --- a/tests/sentry/seer/autofix/test_autofix_on_completion_hook.py +++ b/tests/sentry/seer/autofix/test_autofix_on_completion_hook.py @@ -456,7 +456,6 @@ def _make_handoff_config( def test_get_handoff_config_returns_none_when_not_root_cause_step(self, mock_read_pref) -> None: """Returns None without reading preferences when current step is not ROOT_CAUSE.""" result = AutofixOnCompletionHook._get_handoff_config_if_applicable( - stopping_point=AutofixStoppingPoint.CODE_CHANGES, current_step=AutofixStep.SOLUTION, # Not ROOT_CAUSE group=self.group, ) @@ -464,24 +463,30 @@ def test_get_handoff_config_returns_none_when_not_root_cause_step(self, mock_rea assert result is None mock_read_pref.assert_not_called() - @patch("sentry.seer.autofix.on_completion_hook.read_preference_from_sentry_db") - def test_get_handoff_config_returns_none_when_stopping_at_root_cause( - self, mock_read_pref - ) -> None: - """Returns None without reading preferences when stopping point is ROOT_CAUSE.""" + def test_get_handoff_config_returns_config_when_stopping_at_root_cause(self) -> None: + """Returns handoff config when the project's stopping_point is ROOT_CAUSE. + + Mirrors the legacy seer-side gate, which does not look at + stopping_point when deciding to fire a handoff. The combination + "stop seer at root cause, then hand off to an external coding agent" + must trigger a handoff. + """ + self.project.update_option( + "sentry:seer_automated_run_stopping_point", + AutofixStoppingPoint.ROOT_CAUSE.value, + ) + expected_handoff_config = self._make_handoff_config() + result = AutofixOnCompletionHook._get_handoff_config_if_applicable( - stopping_point=AutofixStoppingPoint.ROOT_CAUSE, current_step=AutofixStep.ROOT_CAUSE, group=self.group, ) - assert result is None - mock_read_pref.assert_not_called() + assert result == expected_handoff_config def test_get_handoff_config_returns_none_when_no_handoff_configured(self) -> None: """Returns None when project has no automation handoff configured.""" result = AutofixOnCompletionHook._get_handoff_config_if_applicable( - stopping_point=AutofixStoppingPoint.CODE_CHANGES, current_step=AutofixStep.ROOT_CAUSE, group=self.group, ) @@ -493,7 +498,6 @@ def test_get_handoff_config_returns_config_when_applicable(self) -> None: expected_handoff_config = self._make_handoff_config() result = AutofixOnCompletionHook._get_handoff_config_if_applicable( - stopping_point=AutofixStoppingPoint.CODE_CHANGES, current_step=AutofixStep.ROOT_CAUSE, group=self.group, ) @@ -526,6 +530,38 @@ def test_maybe_continue_pipeline_triggers_handoff_when_configured(self, mock_tri == AutofixReferrer.ISSUE_SUMMARY_POST_PROCESS_FIXABILITY ) + @patch("sentry.seer.autofix.on_completion_hook.trigger_coding_agent_handoff") + def test_maybe_continue_pipeline_triggers_handoff_when_stopping_at_root_cause( + self, mock_trigger_handoff + ): + """Handoff fires even when stopping_point is ROOT_CAUSE. + + Regression: the previous order of if-blocks let the stopping_point + early-return short-circuit before the handoff check, dropping handoffs + for the coherent "stop seer at root cause, then external coding agent + does the fix" configuration. The handoff check must take precedence, + matching the legacy seer-side ordering in + seer/automation/autofix/steps/root_cause_step.py. + """ + self._make_handoff_config() + mock_trigger_handoff.return_value = {"successes": [], "failures": []} + + state = run_state( + blocks=[ + root_cause_memory_block( + referrer=AutofixReferrer.ISSUE_SUMMARY_POST_PROCESS_FIXABILITY.value + ) + ], + metadata={ + "group_id": self.group.id, + "stopping_point": AutofixStoppingPoint.ROOT_CAUSE.value, + }, + ) + + AutofixOnCompletionHook._maybe_continue_pipeline(self.organization, 123, state, self.group) + + mock_trigger_handoff.assert_called_once() + @patch("sentry.seer.autofix.on_completion_hook.trigger_coding_agent_handoff") def test_trigger_coding_agent_handoff_clears_preference_on_not_found(self, mock_trigger): """When IntegrationNotFound is raised, automation_handoff is cleared from preferences."""