From 7863cbbb1b67c8e7dbacc7f279ec1a0e4384c4b6 Mon Sep 17 00:00:00 2001 From: Tony Xiao Date: Thu, 26 Mar 2026 14:35:44 -0400 Subject: [PATCH 1/5] feat(autofix): Update seer explorer autofix last triggered on completion Instead of updating the timestamp when the job is triggered, wait until the completion webhook fires. This ensured that it only updates when we know there is at least a root cause. --- src/sentry/seer/autofix/autofix_agent.py | 3 - src/sentry/seer/autofix/on_completion_hook.py | 85 ++++++++++--------- .../sentry/seer/autofix/test_autofix_agent.py | 24 ------ .../test_autofix_on_completion_hook.py | 36 +++++--- 4 files changed, 69 insertions(+), 79 deletions(-) diff --git a/src/sentry/seer/autofix/autofix_agent.py b/src/sentry/seer/autofix/autofix_agent.py index 860cc17e880c23..ed5586562b212e 100644 --- a/src/sentry/seer/autofix/autofix_agent.py +++ b/src/sentry/seer/autofix/autofix_agent.py @@ -5,7 +5,6 @@ from enum import StrEnum from typing import TYPE_CHECKING, Literal -from django.utils import timezone from pydantic import BaseModel from sentry.seer.autofix.artifact_schemas import ( @@ -246,8 +245,6 @@ def trigger_autofix_explorer( artifact_schema=artifact_schema, ) - group.update(seer_explorer_autofix_last_triggered=timezone.now()) - payload = { "run_id": run_id, "group_id": group.id, diff --git a/src/sentry/seer/autofix/on_completion_hook.py b/src/sentry/seer/autofix/on_completion_hook.py index 6c10e988fa5867..9bb58cb87531f5 100644 --- a/src/sentry/seer/autofix/on_completion_hook.py +++ b/src/sentry/seer/autofix/on_completion_hook.py @@ -3,6 +3,8 @@ import logging from typing import TYPE_CHECKING +from django.utils import timezone + from sentry import features from sentry.models.group import Group from sentry.models.organization import Organization @@ -76,13 +78,25 @@ def execute(cls, organization: Organization, run_id: int) -> None: ) return + group_id = state.metadata.get("group_id") if state.metadata else None + if group_id is None: + group = None + else: + try: + group = Group.objects.get(id=group_id) + except Group.DoesNotExist: + group = None + # Send webhook for the completed step - cls._send_step_webhook(organization, run_id, state) + cls._send_step_webhook(organization, run_id, state, group) - cls._maybe_trigger_supergroups_embedding(organization, run_id, state) + cls._maybe_trigger_supergroups_embedding(organization, run_id, state, group) # Continue the automated pipeline if stopping_point hasn't been reached - cls._maybe_continue_pipeline(organization, run_id, state) + cls._maybe_continue_pipeline(organization, run_id, state, group) + + if group is not None: + group.update(seer_explorer_autofix_last_triggered=timezone.now()) @classmethod def find_latest_artifact_for_step(cls, state: SeerRunState, key: str) -> Artifact | None: @@ -95,19 +109,27 @@ def find_latest_artifact_for_step(cls, state: SeerRunState, key: str) -> Artifac return None @classmethod - def _send_step_webhook(cls, organization, run_id, state: SeerRunState): + def _send_step_webhook( + cls, + organization: Organization, + run_id: int, + state: SeerRunState, + group: Group | None, + ): """ Send webhook for the completed step. Determines which step just completed and sends the appropriate webhook event. """ - current_step = cls._get_current_step(state) + if group is None: + return - webhook_payload = {"run_id": run_id} + current_step = cls._get_current_step(state) - group_id = state.metadata.get("group_id") if state.metadata else None - if group_id is not None: - webhook_payload["group_id"] = group_id + webhook_payload = { + "run_id": run_id, + "group_id": group.id, + } # Iterate through blocks in reverse order (most recent first) # to find which step just completed @@ -213,19 +235,15 @@ def _maybe_trigger_supergroups_embedding( organization: Organization, run_id: int, state: SeerRunState, + group: Group | None, ) -> None: """Trigger supergroups embedding if feature flag is enabled.""" current_step = cls._get_current_step(state) if current_step != AutofixStep.ROOT_CAUSE: return - group_id = state.metadata.get("group_id") if state.metadata else None - if group_id is None: - return - - try: - group = Group.objects.get(id=group_id) - except Group.DoesNotExist: + if group is None: + group_id = state.metadata.get("group_id") if state.metadata else None logger.warning( "autofix.supergroup_embedding.group_not_found", extra={"group_id": group_id}, @@ -242,7 +260,7 @@ def _maybe_trigger_supergroups_embedding( try: trigger_supergroups_embedding( organization_id=organization.id, - group_id=group_id, + group_id=group.id, project_id=group.project_id, artifact_data=root_cause_artifact.data, ) @@ -252,7 +270,7 @@ def _maybe_trigger_supergroups_embedding( extra={ "run_id": run_id, "organization_id": organization.id, - "group_id": group_id, + "group_id": group.id, }, ) @@ -289,6 +307,7 @@ def _maybe_continue_pipeline( organization: Organization, run_id: int, state: SeerRunState, + group: Group | None, ) -> None: """ Continue to the next step if stopping_point hasn't been reached. @@ -307,12 +326,16 @@ def _maybe_continue_pipeline( return stopping_point = AutofixStoppingPoint(metadata["stopping_point"]) - group_id = metadata.get("group_id") - if not group_id: + if group is None: + group_id = state.metadata.get("group_id") if state.metadata else None logger.warning( - "autofix.on_completion_hook.no_group_id_in_metadata", - extra={"run_id": run_id, "organization_id": organization.id}, + "autofix.on_completion_hook.group_not_found", + extra={ + "run_id": run_id, + "organization_id": organization.id, + "group_id": group_id, + }, ) return @@ -331,10 +354,10 @@ def _maybe_continue_pipeline( # Check if we should trigger coding agent handoff instead of continuing handoff_config = cls._get_handoff_config_if_applicable( - stopping_point, current_step, group_id + stopping_point, current_step, group.id ) if handoff_config: - cls._trigger_coding_agent_handoff(organization, run_id, group_id, handoff_config) + cls._trigger_coding_agent_handoff(organization, run_id, group.id, handoff_config) return # Special case: if stopping_point is open_pr and we just finished code_changes, push changes @@ -363,20 +386,6 @@ def _maybe_continue_pipeline( ) return - # Get the group - try: - group = Group.objects.get(id=group_id, project__organization=organization) - except Group.DoesNotExist: - logger.warning( - "autofix.on_completion_hook.group_not_found", - extra={ - "run_id": run_id, - "organization_id": organization.id, - "group_id": group_id, - }, - ) - return - # Trigger the next step logger.info( "autofix.on_completion_hook.continuing_pipeline", diff --git a/tests/sentry/seer/autofix/test_autofix_agent.py b/tests/sentry/seer/autofix/test_autofix_agent.py index 5a2543f84d63b3..0ee1e6557878f3 100644 --- a/tests/sentry/seer/autofix/test_autofix_agent.py +++ b/tests/sentry/seer/autofix/test_autofix_agent.py @@ -354,30 +354,6 @@ def test_trigger_autofix_explorer_passes_group_id_in_metadata( call_kwargs = mock_client.start_run.call_args.kwargs assert call_kwargs["metadata"] == {"group_id": self.group.id, "referrer": "unknown"} - @patch("sentry.seer.autofix.autofix_agent.broadcast_webhooks_for_organization.delay") - @patch("sentry.seer.autofix.autofix_agent.SeerExplorerClient") - def test_trigger_autofix_explorer_updates_explorer_last_triggered( - self, mock_client_class, mock_broadcast - ): - """trigger_autofix_explorer sets seer_explorer_autofix_last_triggered on the group.""" - mock_client = MagicMock() - mock_client_class.return_value = mock_client - mock_client.start_run.return_value = 123 - - assert self.group.seer_explorer_autofix_last_triggered is None - - trigger_autofix_explorer( - group=self.group, - step=AutofixStep.ROOT_CAUSE, - referrer=AutofixReferrer.UNKNOWN, - run_id=None, - ) - - self.group.refresh_from_db() - - assert self.group.seer_autofix_last_triggered is None - assert self.group.seer_explorer_autofix_last_triggered is not None - class TestTriggerCodingAgentHandoff(TestCase): """Tests for trigger_coding_agent_handoff function.""" 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 0e540a9ceff578..a5c8fb66e9d1cb 100644 --- a/tests/sentry/seer/autofix/test_autofix_on_completion_hook.py +++ b/tests/sentry/seer/autofix/test_autofix_on_completion_hook.py @@ -199,14 +199,14 @@ def setUp(self): def test_maybe_continue_pipeline_no_metadata(self, mock_trigger): """Does not continue when metadata is missing.""" state = run_state(blocks=[root_cause_memory_block()]) - AutofixOnCompletionHook._maybe_continue_pipeline(self.organization, 123, state) + AutofixOnCompletionHook._maybe_continue_pipeline(self.organization, 123, state, self.group) mock_trigger.assert_not_called() @patch("sentry.seer.autofix.on_completion_hook.trigger_autofix_explorer") def test_maybe_continue_pipeline_no_stopping_point_in_metadata(self, mock_trigger): """Does not continue when stopping_point is missing from metadata.""" state = run_state(blocks=[root_cause_memory_block()], metadata={"group_id": self.group.id}) - AutofixOnCompletionHook._maybe_continue_pipeline(self.organization, 123, state) + AutofixOnCompletionHook._maybe_continue_pipeline(self.organization, 123, state, self.group) mock_trigger.assert_not_called() @patch("sentry.seer.autofix.on_completion_hook.trigger_autofix_explorer") @@ -219,7 +219,7 @@ def test_maybe_continue_pipeline_at_stopping_point(self, mock_trigger): "stopping_point": AutofixStoppingPoint.ROOT_CAUSE.value, }, ) - AutofixOnCompletionHook._maybe_continue_pipeline(self.organization, 123, state) + AutofixOnCompletionHook._maybe_continue_pipeline(self.organization, 123, state, self.group) mock_trigger.assert_not_called() @patch("sentry.seer.autofix.on_completion_hook.get_project_seer_preferences") @@ -236,7 +236,7 @@ def test_maybe_continue_pipeline_continues_to_next_step(self, mock_trigger, mock "stopping_point": AutofixStoppingPoint.CODE_CHANGES.value, }, ) - AutofixOnCompletionHook._maybe_continue_pipeline(self.organization, 123, state) + AutofixOnCompletionHook._maybe_continue_pipeline(self.organization, 123, state, self.group) mock_trigger.assert_called_once() call_kwargs = mock_trigger.call_args.kwargs assert call_kwargs["group"].id == self.group.id @@ -260,7 +260,7 @@ def test_maybe_continue_pipeline_pushes_changes_for_open_pr(self, mock_client_cl "stopping_point": AutofixStoppingPoint.OPEN_PR.value, }, ) - AutofixOnCompletionHook._maybe_continue_pipeline(self.organization, 123, state) + AutofixOnCompletionHook._maybe_continue_pipeline(self.organization, 123, state, self.group) mock_client.push_changes.assert_called_once_with(123, blocking=False) @@ -287,6 +287,8 @@ class TestAutofixOnCompletionHookWebhooks(TestCase): def setUp(self): super().setUp() self.organization = self.create_organization() + self.project = self.create_project(organization=self.organization) + self.group = self.create_group(project=self.project) @patch("sentry.seer.autofix.on_completion_hook.broadcast_webhooks_for_organization.delay") def test_send_step_webhook_artifact_types(self, mock_broadcast): @@ -325,7 +327,7 @@ class TestCaseDict(TypedDict): for i, test_case in enumerate(test_cases): mock_broadcast.reset_mock() state = run_state(blocks=[test_case["block"]]) - AutofixOnCompletionHook._send_step_webhook(self.organization, run_id, state) + AutofixOnCompletionHook._send_step_webhook(self.organization, run_id, state, self.group) mock_broadcast.assert_called_once() call_kwargs = mock_broadcast.call_args.kwargs @@ -349,7 +351,7 @@ def test_send_step_webhook_coding(self, mock_broadcast): code_changes_memory_block(), ] ) - AutofixOnCompletionHook._send_step_webhook(self.organization, 123, state) + AutofixOnCompletionHook._send_step_webhook(self.organization, 123, state, self.group) mock_broadcast.assert_called_once() call_kwargs = mock_broadcast.call_args.kwargs @@ -368,7 +370,7 @@ def test_send_step_webhook_no_artifacts_no_webhook(self, mock_broadcast): artifacts=[], ) state = run_state(blocks=[block]) - AutofixOnCompletionHook._send_step_webhook(self.organization, 123, state) + AutofixOnCompletionHook._send_step_webhook(self.organization, 123, state, self.group) mock_broadcast.assert_not_called() @@ -388,7 +390,9 @@ def test_triggers_embedding_on_root_cause(self, mock_trigger_sg): """Triggers supergroups embedding when root cause completes with feature flag enabled.""" block = root_cause_memory_block() state = run_state(blocks=[block], metadata={"group_id": self.group.id}) - AutofixOnCompletionHook._maybe_trigger_supergroups_embedding(self.organization, 123, state) + AutofixOnCompletionHook._maybe_trigger_supergroups_embedding( + self.organization, 123, state, self.group + ) mock_trigger_sg.assert_called_once_with( organization_id=self.organization.id, @@ -404,15 +408,19 @@ def test_skips_embedding_when_flag_disabled(self, mock_trigger_sg): blocks=[root_cause_memory_block()], metadata={"group_id": self.group.id}, ) - AutofixOnCompletionHook._maybe_trigger_supergroups_embedding(self.organization, 123, state) + AutofixOnCompletionHook._maybe_trigger_supergroups_embedding( + self.organization, 123, state, self.group + ) mock_trigger_sg.assert_not_called() @patch("sentry.seer.autofix.on_completion_hook.trigger_supergroups_embedding") - def test_skips_embedding_when_no_group_id(self, mock_trigger_sg): - """Does not trigger supergroups embedding when group_id is missing from metadata.""" + def test_skips_embedding_when_no_group(self, mock_trigger_sg): + """Does not trigger supergroups embedding when group is None.""" state = run_state(blocks=[root_cause_memory_block()]) - AutofixOnCompletionHook._maybe_trigger_supergroups_embedding(self.organization, 123, state) + AutofixOnCompletionHook._maybe_trigger_supergroups_embedding( + self.organization, 123, state, None + ) mock_trigger_sg.assert_not_called() @@ -556,7 +564,7 @@ def test_maybe_continue_pipeline_triggers_handoff_when_configured( }, ) - AutofixOnCompletionHook._maybe_continue_pipeline(self.organization, 123, state) + AutofixOnCompletionHook._maybe_continue_pipeline(self.organization, 123, state, self.group) mock_trigger_handoff.assert_called_once() From 4f0e02aea382b5a95226718f98f0a699745d48f7 Mon Sep 17 00:00:00 2001 From: Tony Xiao Date: Fri, 27 Mar 2026 12:44:53 -0400 Subject: [PATCH 2/5] filter on org id as well --- src/sentry/seer/autofix/on_completion_hook.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sentry/seer/autofix/on_completion_hook.py b/src/sentry/seer/autofix/on_completion_hook.py index 2fc1c087a10114..b21eed4bc6d7e2 100644 --- a/src/sentry/seer/autofix/on_completion_hook.py +++ b/src/sentry/seer/autofix/on_completion_hook.py @@ -83,7 +83,7 @@ def execute(cls, organization: Organization, run_id: int) -> None: group = None else: try: - group = Group.objects.get(id=group_id) + group = Group.objects.get(id=group_id, project__organization_id=organization.id) except Group.DoesNotExist: group = None From 564a21c31c592d9162e778ade36321e0eac65346 Mon Sep 17 00:00:00 2001 From: Tony Xiao Date: Fri, 27 Mar 2026 12:46:56 -0400 Subject: [PATCH 3/5] remove None type from group --- src/sentry/seer/autofix/on_completion_hook.py | 42 ++++--------------- 1 file changed, 7 insertions(+), 35 deletions(-) diff --git a/src/sentry/seer/autofix/on_completion_hook.py b/src/sentry/seer/autofix/on_completion_hook.py index b21eed4bc6d7e2..9bf55af2de8530 100644 --- a/src/sentry/seer/autofix/on_completion_hook.py +++ b/src/sentry/seer/autofix/on_completion_hook.py @@ -80,12 +80,10 @@ def execute(cls, organization: Organization, run_id: int) -> None: group_id = state.metadata.get("group_id") if state.metadata else None if group_id is None: - group = None - else: - try: - group = Group.objects.get(id=group_id, project__organization_id=organization.id) - except Group.DoesNotExist: - group = None + return + + group = Group.objects.get(id=group_id, project__organization_id=organization.id) + group.update(seer_explorer_autofix_last_triggered=timezone.now()) # Send webhook for the completed step cls._send_step_webhook(organization, run_id, state, group) @@ -95,9 +93,6 @@ def execute(cls, organization: Organization, run_id: int) -> None: # Continue the automated pipeline if stopping_point hasn't been reached cls._maybe_continue_pipeline(organization, run_id, state, group) - if group is not None: - group.update(seer_explorer_autofix_last_triggered=timezone.now()) - @classmethod def find_latest_artifact_for_step(cls, state: SeerRunState, key: str) -> Artifact | None: for block in reversed(state.blocks): @@ -114,16 +109,13 @@ def _send_step_webhook( organization: Organization, run_id: int, state: SeerRunState, - group: Group | None, + group: Group, ): """ Send webhook for the completed step. Determines which step just completed and sends the appropriate webhook event. """ - if group is None: - return - current_step = cls._get_current_step(state) webhook_payload = { @@ -235,21 +227,13 @@ def _maybe_trigger_supergroups_embedding( organization: Organization, run_id: int, state: SeerRunState, - group: Group | None, + group: Group, ) -> None: """Trigger supergroups embedding if feature flag is enabled.""" current_step = cls._get_current_step(state) if current_step != AutofixStep.ROOT_CAUSE: return - if group is None: - group_id = state.metadata.get("group_id") if state.metadata else None - logger.warning( - "autofix.supergroup_embedding.group_not_found", - extra={"group_id": group_id}, - ) - return - if not features.has("projects:supergroup-embeddings-explorer", group.project): return @@ -307,7 +291,7 @@ def _maybe_continue_pipeline( organization: Organization, run_id: int, state: SeerRunState, - group: Group | None, + group: Group, ) -> None: """ Continue to the next step if stopping_point hasn't been reached. @@ -327,18 +311,6 @@ def _maybe_continue_pipeline( stopping_point = AutofixStoppingPoint(metadata["stopping_point"]) - if group is None: - group_id = state.metadata.get("group_id") if state.metadata else None - logger.warning( - "autofix.on_completion_hook.group_not_found", - extra={ - "run_id": run_id, - "organization_id": organization.id, - "group_id": group_id, - }, - ) - return - if current_step is None: logger.warning( "autofix.on_completion_hook.no_current_step", From b156fece935a02910076bf5f09a3635a5cc0df11 Mon Sep 17 00:00:00 2001 From: Tony Xiao Date: Fri, 27 Mar 2026 13:02:59 -0400 Subject: [PATCH 4/5] fix tests --- .../seer/autofix/test_autofix_on_completion_hook.py | 10 ---------- 1 file changed, 10 deletions(-) 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 f159df73a73a39..abae54ba132eba 100644 --- a/tests/sentry/seer/autofix/test_autofix_on_completion_hook.py +++ b/tests/sentry/seer/autofix/test_autofix_on_completion_hook.py @@ -411,16 +411,6 @@ def test_skips_embedding_when_flag_disabled(self, mock_trigger_sg): mock_trigger_sg.assert_not_called() - @patch("sentry.seer.autofix.on_completion_hook.trigger_supergroups_embedding") - def test_skips_embedding_when_no_group(self, mock_trigger_sg): - """Does not trigger supergroups embedding when group is None.""" - state = run_state(blocks=[root_cause_memory_block()]) - AutofixOnCompletionHook._maybe_trigger_supergroups_embedding( - self.organization, 123, state, None - ) - - mock_trigger_sg.assert_not_called() - @with_feature("projects:supergroup-embeddings-explorer") @patch("sentry.seer.autofix.on_completion_hook.trigger_supergroups_embedding") @patch("sentry.seer.autofix.on_completion_hook.broadcast_webhooks_for_organization.delay") From 383d741f03a2d3ec5cfac1787b2bf084120ae3b4 Mon Sep 17 00:00:00 2001 From: Tony Xiao Date: Fri, 27 Mar 2026 17:22:31 -0400 Subject: [PATCH 5/5] bump automation dispatched ttl to 30 minutes --- src/sentry/models/group.py | 1 + src/sentry/seer/autofix/trigger.py | 9 ++++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/sentry/models/group.py b/src/sentry/models/group.py index 5c4684f92b0fe4..8171735a35426c 100644 --- a/src/sentry/models/group.py +++ b/src/sentry/models/group.py @@ -685,6 +685,7 @@ class Group(Model): priority_locked_at = models.DateTimeField(null=True) seer_fixability_score = models.FloatField(null=True) seer_autofix_last_triggered = models.DateTimeField(null=True) + # This actually represents the last timestamp when the explorer agent completes a step seer_explorer_autofix_last_triggered = models.DateTimeField(null=True) objects: ClassVar[GroupManager] = GroupManager(cache_fields=("id",)) diff --git a/src/sentry/seer/autofix/trigger.py b/src/sentry/seer/autofix/trigger.py index e0687c7e8fd43a..b52e3ca9478ae2 100644 --- a/src/sentry/seer/autofix/trigger.py +++ b/src/sentry/seer/autofix/trigger.py @@ -11,6 +11,13 @@ ) from sentry.utils.cache import cache +# This timeout should be longer than what we expect the seer agents to take. +# This is because we do not want to trigger another run while the previous +# run is still running. +# +# NOTE: The timeout on the seer job is 5 minutes. +SEER_AUTOMATION_TIMEOUT_SECONDS = 30 * 60 + if TYPE_CHECKING: from sentry.models.group import Group from sentry.utils.locking.manager import LockManager @@ -171,7 +178,7 @@ def get_seat_based_seer_automation_skip_reason( # Atomically set cache to prevent duplicate dispatches (returns False if key exists) automation_dispatch_cache_key = f"seer-automation-dispatched:{group.id}" - if not cache.add(automation_dispatch_cache_key, True, timeout=300): + if not cache.add(automation_dispatch_cache_key, True, timeout=SEER_AUTOMATION_TIMEOUT_SECONDS): return "automation_already_dispatched" # Another process already dispatched automation # Check if project has connected repositories - requirement for new pricing