From 02ed142e56634a4ad79f256690c7541a30dceb7d Mon Sep 17 00:00:00 2001 From: Ryan Brooks Date: Mon, 20 Jul 2026 10:53:16 -0700 Subject: [PATCH] feat(seer): route root-cause autofix through the RCA feature Behind the organizations:autofix-rca-in-seer flag (already registered on master), route new ROOT_CAUSE runs in trigger_autofix_agent to the autofix_rca feature instead of the legacy explorer root-cause step, so every entrypoint (post-process, night shift, manual, operator) picks it up. The feature is RCA-only; deliver_autofix_rca_result surfaces the result back into the existing webhooks/analytics. --- src/sentry/seer/autofix/autofix_agent.py | 36 ++++++- .../sentry/seer/autofix/test_autofix_agent.py | 102 ++++++++++++++++++ 2 files changed, 137 insertions(+), 1 deletion(-) diff --git a/src/sentry/seer/autofix/autofix_agent.py b/src/sentry/seer/autofix/autofix_agent.py index b43dfb5b553a..cef27aabc3fa 100644 --- a/src/sentry/seer/autofix/autofix_agent.py +++ b/src/sentry/seer/autofix/autofix_agent.py @@ -49,7 +49,7 @@ read_preference_from_sentry_db, ) from sentry.seer.entrypoints.operator import SeerAutofixOperator, process_autofix_updates -from sentry.seer.models import SeerRepoDefinition +from sentry.seer.models import SeerApiError, SeerRepoDefinition from sentry.seer.models.run import SeerRun from sentry.seer.models.seer_api_models import SeerPermissionError from sentry.sentry_apps.event_types import SentryAppEventType @@ -531,6 +531,40 @@ def trigger_autofix_agent( if not has_budget: raise NoSeerQuotaException() + use_seer_rca_feature = features.has( + "organizations:autofix-rca-in-seer", group.organization, actor=user + ) + if step == AutofixStep.ROOT_CAUSE and run_id is None and use_seer_rca_feature: + # Local import avoids a circular import (dispatch imports this module). + from sentry.seer.autofix_rca.dispatch import trigger_autofix_rca_feature + + feature_run = trigger_autofix_rca_feature( + group, referrer=referrer, user_context=user_context + ) + feature_run_id = feature_run.seer_run_state_id + if feature_run_id is None: + # flush=True populates this on success; guard defensively. + raise SeerApiError("autofix_rca feature run has no run id", 500) + + logger.info( + "autofix.trigger.routed_to_rca_feature", + extra={ + "group_id": group.id, + "organization_id": group.organization.id, + "run_id": feature_run_id, + "referrer": referrer.value, + }, + ) + + handle_step_started_events( + group, + AutofixStep.ROOT_CAUSE, + feature_run_id, + str(feature_run.uuid), + referrer, + ) + return feature_run_id + config = STEP_CONFIGS[step] pr_iteration_enabled = features.has("organizations:autofix-pr-iteration", group.organization) diff --git a/tests/sentry/seer/autofix/test_autofix_agent.py b/tests/sentry/seer/autofix/test_autofix_agent.py index 8790f8874b94..9f1d819d6360 100644 --- a/tests/sentry/seer/autofix/test_autofix_agent.py +++ b/tests/sentry/seer/autofix/test_autofix_agent.py @@ -498,6 +498,108 @@ def test_trigger_autofix_agent_passes_project_and_group_to_client( assert call_kwargs["project"] == self.group.project assert call_kwargs["group"] == self.group + @patch("sentry.quotas.backend.record_seer_run") + @patch("sentry.quotas.backend.check_seer_quota", return_value=True) + @patch("sentry.seer.autofix.autofix_agent.broadcast_webhooks_for_organization.delay") + @patch("sentry.seer.autofix_rca.dispatch.trigger_autofix_rca_feature") + @patch("sentry.seer.autofix.autofix_agent.SeerAgentClient") + def test_root_cause_routes_to_rca_feature_when_flagged( + self, mock_client_class, mock_feature, mock_broadcast, mock_check_quota, mock_record_run + ): + """With the flag on, a new root-cause run dispatches the autofix_rca feature + instead of a legacy explorer run, still emits the started webhook, and + returns the feature run's id.""" + feature_run = self.create_seer_run( + organization=self.group.organization, type="feature_run", seer_run_state_id=777 + ) + mock_feature.return_value = feature_run + + with self.feature("organizations:autofix-rca-in-seer"): + result = trigger_autofix_agent( + group=self.group, + step=AutofixStep.ROOT_CAUSE, + referrer=AutofixReferrer.UNKNOWN, + run_id=None, + ) + + assert result == 777 + mock_feature.assert_called_once() + assert mock_feature.call_args.args[0] == self.group + # legacy explorer run is not started for a flagged root-cause kickoff + mock_client_class.return_value.start_run.assert_not_called() + # the started webhook still fires, pointing at the feature run + mock_broadcast.assert_called_once() + payload = mock_broadcast.call_args.kwargs["payload"] + assert ( + mock_broadcast.call_args.kwargs["event_name"] == SeerActionType.ROOT_CAUSE_STARTED.value + ) + assert payload["run_id"] == 777 + assert payload["sentry_run_id"] == str(feature_run.uuid) + + @patch("sentry.quotas.backend.record_seer_run") + @patch("sentry.quotas.backend.check_seer_quota", return_value=True) + @patch("sentry.seer.autofix_rca.dispatch.trigger_autofix_rca_feature") + @patch("sentry.seer.autofix.autofix_agent.SeerAgentClient") + def test_solution_step_does_not_route_to_rca_feature( + self, mock_client_class, mock_feature, mock_check_quota, mock_record_run + ): + """Only the root-cause step routes; solution kickoffs stay on the legacy flow.""" + mock_client = MagicMock() + mock_client_class.return_value = mock_client + mock_client.start_run.return_value = MagicMock(seer_run_state_id=5) + + with self.feature("organizations:autofix-rca-in-seer"): + trigger_autofix_agent( + group=self.group, + step=AutofixStep.SOLUTION, + referrer=AutofixReferrer.UNKNOWN, + run_id=None, + ) + + mock_feature.assert_not_called() + mock_client.start_run.assert_called_once() + + @patch("sentry.quotas.backend.record_seer_run") + @patch("sentry.quotas.backend.check_seer_quota", return_value=True) + @patch("sentry.seer.autofix_rca.dispatch.trigger_autofix_rca_feature") + @patch("sentry.seer.autofix.autofix_agent.SeerAgentClient") + def test_root_cause_uses_legacy_flow_without_flag( + self, mock_client_class, mock_feature, mock_check_quota, mock_record_run + ): + """Without the flag, root-cause kickoffs use the legacy explorer run.""" + mock_client = MagicMock() + mock_client_class.return_value = mock_client + mock_client.start_run.return_value = MagicMock(seer_run_state_id=9) + + result = trigger_autofix_agent( + group=self.group, + step=AutofixStep.ROOT_CAUSE, + referrer=AutofixReferrer.UNKNOWN, + run_id=None, + ) + + assert result == 9 + mock_feature.assert_not_called() + mock_client.start_run.assert_called_once() + + @patch("sentry.quotas.backend.check_seer_quota", return_value=False) + @patch("sentry.seer.autofix_rca.dispatch.trigger_autofix_rca_feature") + @patch("sentry.seer.autofix.autofix_agent.SeerAgentClient") + def test_flagged_root_cause_still_enforces_quota( + self, mock_client_class, mock_feature, mock_check_quota + ): + """The upstream quota check runs before routing to the feature.""" + with self.feature("organizations:autofix-rca-in-seer"): + with pytest.raises(NoSeerQuotaException): + trigger_autofix_agent( + group=self.group, + step=AutofixStep.ROOT_CAUSE, + referrer=AutofixReferrer.UNKNOWN, + run_id=None, + ) + + mock_feature.assert_not_called() + @patch("sentry.quotas.backend.record_seer_run") @patch("sentry.quotas.backend.check_seer_quota", return_value=True) @patch("sentry.seer.autofix.autofix_agent.broadcast_webhooks_for_organization.delay")