From 8975ae40e9710efbcba74a872b0dc53e26af9d6d Mon Sep 17 00:00:00 2001 From: Mihir Mavalankar Date: Wed, 19 Nov 2025 09:59:45 -0800 Subject: [PATCH 01/19] initial changes --- src/sentry/tasks/autofix.py | 23 +++++++++ src/sentry/tasks/post_process.py | 80 ++++++++++++++++++++++++++------ 2 files changed, 89 insertions(+), 14 deletions(-) diff --git a/src/sentry/tasks/autofix.py b/src/sentry/tasks/autofix.py index d59cf83f41b063..83b875a3d6281a 100644 --- a/src/sentry/tasks/autofix.py +++ b/src/sentry/tasks/autofix.py @@ -60,3 +60,26 @@ def generate_issue_summary_only(group_id: int) -> None: get_issue_summary( group=group, source=SeerAutomationSource.POST_PROCESS, should_run_automation=False ) + + +@instrumented_task( + name="sentry.tasks.autofix.run_automation_for_group", + namespace=ingest_errors_tasks, + processing_deadline_duration=35, + retry=Retry(times=1), +) +def run_automation_for_group(group_id: int) -> None: + """ + Run automation directly for a group (assumes summary and fixability already exist). + Used for triage signals flow when event count >= 10 and summary exists. + """ + from sentry.seer.autofix.issue_summary import _run_automation + + group = Group.objects.get(id=group_id) + event = group.get_latest_event() + + if not event: + logger.warning("run_automation_for_group.no_event_found", extra={"group_id": group_id}) + return + + _run_automation(group=group, user=None, event=event, source=SeerAutomationSource.POST_PROCESS) diff --git a/src/sentry/tasks/post_process.py b/src/sentry/tasks/post_process.py index 4b7452819278f0..5db4ddd78636ef 100644 --- a/src/sentry/tasks/post_process.py +++ b/src/sentry/tasks/post_process.py @@ -1600,28 +1600,80 @@ def kick_off_seer_automation(job: PostProcessJob) -> None: is_issue_eligible_for_seer_automation, is_seer_scanner_rate_limited, ) - from sentry.tasks.autofix import start_seer_automation + from sentry.tasks.autofix import ( + generate_issue_summary_only, + run_automation_for_group, + start_seer_automation, + ) event = job["event"] group = event.group - # Only run on issues with no existing scan - TODO: Update condition for triage signals V0 - if group.seer_fixability_score is not None: - return + # Default behaviour + if not features.has("projects:triage-signals-v0", group.project): + # Only run on issues with no existing scan + if group.seer_fixability_score is not None: + return - if is_issue_eligible_for_seer_automation(group) is False: - return + if is_issue_eligible_for_seer_automation(group) is False: + return - # Don't run if there's already a task in progress for this issue - lock_key, lock_name = get_issue_summary_lock_key(group.id) - lock = locks.get(lock_key, duration=1, name=lock_name) - if lock.locked(): - return + # Don't run if there's already a task in progress for this issue + lock_key, lock_name = get_issue_summary_lock_key(group.id) + lock = locks.get(lock_key, duration=1, name=lock_name) + if lock.locked(): + return - if is_seer_scanner_rate_limited(group.project, group.organization): - return + if is_seer_scanner_rate_limited(group.project, group.organization): + return + + start_seer_automation.delay(group.id) + else: + # Triage signals V0 behaviour + + # If event count < 10, only generate summary + fixability (no automation) + if group.times_seen_with_pending < 10: + # Check if fixability already exists (which means summary exists too) + if group.seer_fixability_score is not None: + return + + # Check if we're already processing this issue + lock_key, lock_name = get_issue_summary_lock_key(group.id) + lock = locks.get(lock_key, duration=5, name=lock_name) + if lock.locked(): + return + + # Generate summary + fixability (no automation) + if is_issue_eligible_for_seer_automation(group): + if not is_seer_scanner_rate_limited(group.project, group.organization): + generate_issue_summary_only.delay(group.id) + else: + # Event count >= 10: run automation + + # Check seer_last_triggered first (long-term check to avoid re-running) + if group.seer_last_triggered is not None: + return + + # Early returns for eligibility checks (cheap checks first) + if not is_issue_eligible_for_seer_automation(group): + return + + # Now acquire lock (only after all cheap checks pass) + lock_key, lock_name = get_issue_summary_lock_key(group.id) + lock = locks.get(lock_key, duration=10, name=lock_name) + if lock.locked(): + return + + if is_seer_scanner_rate_limited(group.project, group.organization): + return - start_seer_automation.delay(group.id) + # Check if fixability already exists (which means summary exists too) + if group.seer_fixability_score is not None: + # Summary exists, run automation directly + run_automation_for_group.delay(group.id) + else: + # No summary yet, generate summary + fixability + run automation in one go + start_seer_automation.delay(group.id) GROUP_CATEGORY_POST_PROCESS_PIPELINE = { From 23ac235e115ffa8c876e11c0e4f4a1f9b6b86fdd Mon Sep 17 00:00:00 2001 From: Mihir Mavalankar Date: Wed, 19 Nov 2025 11:12:13 -0800 Subject: [PATCH 02/19] feat(triage signals): New kick_off_seer_automation flow [feature flagged] --- src/sentry/tasks/autofix.py | 2 ++ src/sentry/tasks/post_process.py | 23 ++++++++++++++--------- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/src/sentry/tasks/autofix.py b/src/sentry/tasks/autofix.py index 83b875a3d6281a..d4638847846dcb 100644 --- a/src/sentry/tasks/autofix.py +++ b/src/sentry/tasks/autofix.py @@ -60,6 +60,8 @@ def generate_issue_summary_only(group_id: int) -> None: get_issue_summary( group=group, source=SeerAutomationSource.POST_PROCESS, should_run_automation=False ) + # TODO: Generate fixability score here and check for in run_automation for triage signals V0 + # Currently fixability will only be generated after 10 when run_automation is called @instrumented_task( diff --git a/src/sentry/tasks/post_process.py b/src/sentry/tasks/post_process.py index 5db4ddd78636ef..92849762dfde4a 100644 --- a/src/sentry/tasks/post_process.py +++ b/src/sentry/tasks/post_process.py @@ -1595,7 +1595,10 @@ def check_if_flags_sent(job: PostProcessJob) -> None: def kick_off_seer_automation(job: PostProcessJob) -> None: - from sentry.seer.autofix.issue_summary import get_issue_summary_lock_key + from sentry.seer.autofix.issue_summary import ( + get_issue_summary_cache_key, + get_issue_summary_lock_key, + ) from sentry.seer.autofix.utils import ( is_issue_eligible_for_seer_automation, is_seer_scanner_rate_limited, @@ -1631,10 +1634,12 @@ def kick_off_seer_automation(job: PostProcessJob) -> None: else: # Triage signals V0 behaviour - # If event count < 10, only generate summary + fixability (no automation) + # If event count < 10, only generate summary (no automation) if group.times_seen_with_pending < 10: - # Check if fixability already exists (which means summary exists too) - if group.seer_fixability_score is not None: + # Check if summary exists in cache + cache_key = get_issue_summary_cache_key(group.id) + if cache.get(cache_key) is not None: + # Summary already exists, nothing to do return # Check if we're already processing this issue @@ -1643,13 +1648,12 @@ def kick_off_seer_automation(job: PostProcessJob) -> None: if lock.locked(): return - # Generate summary + fixability (no automation) + # Generate summary (no automation) if is_issue_eligible_for_seer_automation(group): if not is_seer_scanner_rate_limited(group.project, group.organization): generate_issue_summary_only.delay(group.id) else: # Event count >= 10: run automation - # Check seer_last_triggered first (long-term check to avoid re-running) if group.seer_last_triggered is not None: return @@ -1667,12 +1671,13 @@ def kick_off_seer_automation(job: PostProcessJob) -> None: if is_seer_scanner_rate_limited(group.project, group.organization): return - # Check if fixability already exists (which means summary exists too) - if group.seer_fixability_score is not None: + # Check if summary exists in cache + cache_key = get_issue_summary_cache_key(group.id) + if cache.get(cache_key) is not None: # Summary exists, run automation directly run_automation_for_group.delay(group.id) else: - # No summary yet, generate summary + fixability + run automation in one go + # No summary yet, generate summary + run automation in one go start_seer_automation.delay(group.id) From 82589d8f17cd22d6104230fd915a3d4314502aaf Mon Sep 17 00:00:00 2001 From: Mihir Mavalankar Date: Wed, 19 Nov 2025 11:18:50 -0800 Subject: [PATCH 03/19] feat(triage signals): New kick_off_seer_automation flow [feature flagged] --- src/sentry/tasks/post_process.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/sentry/tasks/post_process.py b/src/sentry/tasks/post_process.py index 92849762dfde4a..91c4b44c3d4331 100644 --- a/src/sentry/tasks/post_process.py +++ b/src/sentry/tasks/post_process.py @@ -1639,10 +1639,9 @@ def kick_off_seer_automation(job: PostProcessJob) -> None: # Check if summary exists in cache cache_key = get_issue_summary_cache_key(group.id) if cache.get(cache_key) is not None: - # Summary already exists, nothing to do return - # Check if we're already processing this issue + # Check if we're already generating the summary lock_key, lock_name = get_issue_summary_lock_key(group.id) lock = locks.get(lock_key, duration=5, name=lock_name) if lock.locked(): @@ -1662,7 +1661,7 @@ def kick_off_seer_automation(job: PostProcessJob) -> None: if not is_issue_eligible_for_seer_automation(group): return - # Now acquire lock (only after all cheap checks pass) + # Now acquire a longer lock to avoid race conditions when starting the automation lock_key, lock_name = get_issue_summary_lock_key(group.id) lock = locks.get(lock_key, duration=10, name=lock_name) if lock.locked(): From f36bfd52113285a9a5f9f15684b2d0482d315c97 Mon Sep 17 00:00:00 2001 From: Mihir Mavalankar Date: Wed, 19 Nov 2025 11:30:00 -0800 Subject: [PATCH 04/19] small changes and tests --- src/sentry/tasks/post_process.py | 7 +- tests/sentry/tasks/test_post_process.py | 177 ++++++++++++++++++++++++ 2 files changed, 180 insertions(+), 4 deletions(-) diff --git a/src/sentry/tasks/post_process.py b/src/sentry/tasks/post_process.py index 91c4b44c3d4331..2a37ba2a19ddc7 100644 --- a/src/sentry/tasks/post_process.py +++ b/src/sentry/tasks/post_process.py @@ -1660,16 +1660,15 @@ def kick_off_seer_automation(job: PostProcessJob) -> None: # Early returns for eligibility checks (cheap checks first) if not is_issue_eligible_for_seer_automation(group): return + if is_seer_scanner_rate_limited(group.project, group.organization): + return # Now acquire a longer lock to avoid race conditions when starting the automation lock_key, lock_name = get_issue_summary_lock_key(group.id) - lock = locks.get(lock_key, duration=10, name=lock_name) + lock = locks.get(lock_key, duration=30, name=lock_name) if lock.locked(): return - if is_seer_scanner_rate_limited(group.project, group.organization): - return - # Check if summary exists in cache cache_key = get_issue_summary_cache_key(group.id) if cache.get(cache_key) is not None: diff --git a/tests/sentry/tasks/test_post_process.py b/tests/sentry/tasks/test_post_process.py index 4fdaddaca4abf3..cb65784a0dc8e2 100644 --- a/tests/sentry/tasks/test_post_process.py +++ b/tests/sentry/tasks/test_post_process.py @@ -3032,6 +3032,183 @@ def test_kick_off_seer_automation_with_hide_ai_features_enabled( mock_start_seer_automation.assert_not_called() +class TriageSignalsV0TestMixin(BasePostProgressGroupMixin): + """Tests for the triage signals V0 flow behind the projects:triage-signals-v0 feature flag.""" + + @patch( + "sentry.seer.seer_setup.get_seer_org_acknowledgement_for_scanner", + return_value=True, + ) + @patch("sentry.tasks.autofix.generate_issue_summary_only.delay") + @with_feature({"organizations:gen-ai-features": True, "projects:triage-signals-v0": True}) + def test_triage_signals_event_count_less_than_10_no_cache( + self, mock_generate_summary_only, mock_get_seer_org_acknowledgement + ): + """Test that with event count < 10 and no cached summary, we generate summary only (no automation).""" + self.project.update_option("sentry:seer_scanner_automation", True) + event = self.create_event( + data={"message": "testing"}, + project_id=self.project.id, + ) + + # Ensure event count < 10 + group = event.group + assert group.times_seen < 10 + + self.call_post_process_group( + is_new=True, + is_regression=False, + is_new_group_environment=True, + event=event, + ) + + # Should call generate_issue_summary_only (not start_seer_automation) + mock_generate_summary_only.assert_called_once_with(group.id) + + @patch( + "sentry.seer.seer_setup.get_seer_org_acknowledgement_for_scanner", + return_value=True, + ) + @patch("sentry.tasks.autofix.generate_issue_summary_only.delay") + @with_feature({"organizations:gen-ai-features": True, "projects:triage-signals-v0": True}) + def test_triage_signals_event_count_less_than_10_with_cache( + self, mock_generate_summary_only, mock_get_seer_org_acknowledgement + ): + """Test that with event count < 10 and cached summary exists, we do nothing.""" + self.project.update_option("sentry:seer_scanner_automation", True) + event = self.create_event( + data={"message": "testing"}, + project_id=self.project.id, + ) + + # Cache a summary for this group + from sentry.seer.autofix.issue_summary import get_issue_summary_cache_key + + group = event.group + cache_key = get_issue_summary_cache_key(group.id) + cache.set(cache_key, {"summary": "test summary"}, 3600) + + self.call_post_process_group( + is_new=True, + is_regression=False, + is_new_group_environment=True, + event=event, + ) + + # Should not call anything since summary exists + mock_generate_summary_only.assert_not_called() + + @patch( + "sentry.seer.seer_setup.get_seer_org_acknowledgement_for_scanner", + return_value=True, + ) + @patch("sentry.tasks.autofix.run_automation_for_group.delay") + @with_feature({"organizations:gen-ai-features": True, "projects:triage-signals-v0": True}) + def test_triage_signals_event_count_gte_10_with_cache( + self, mock_run_automation, mock_get_seer_org_acknowledgement + ): + """Test that with event count >= 10 and cached summary exists, we run automation directly.""" + self.project.update_option("sentry:seer_scanner_automation", True) + event = self.create_event( + data={"message": "testing"}, + project_id=self.project.id, + ) + + # Set event count >= 10 + from sentry import buffer + + group = event.group + buffer.backend.incr(Group, ["times_seen"], {"id": group.id}, count=10) + + # Cache a summary for this group + from sentry.seer.autofix.issue_summary import get_issue_summary_cache_key + + cache_key = get_issue_summary_cache_key(group.id) + cache.set(cache_key, {"summary": "test summary"}, 3600) + + self.call_post_process_group( + is_new=False, + is_regression=False, + is_new_group_environment=False, + event=event, + ) + + # Should call run_automation_for_group since summary exists + mock_run_automation.assert_called_once_with(group.id) + + @patch( + "sentry.seer.seer_setup.get_seer_org_acknowledgement_for_scanner", + return_value=True, + ) + @patch("sentry.tasks.autofix.start_seer_automation.delay") + @with_feature({"organizations:gen-ai-features": True, "projects:triage-signals-v0": True}) + def test_triage_signals_event_count_gte_10_no_cache( + self, mock_start_seer_automation, mock_get_seer_org_acknowledgement + ): + """Test that with event count >= 10 and no cached summary, we generate summary + run automation.""" + self.project.update_option("sentry:seer_scanner_automation", True) + event = self.create_event( + data={"message": "testing"}, + project_id=self.project.id, + ) + + # Set event count >= 10 + from sentry import buffer + + group = event.group + buffer.backend.incr(Group, ["times_seen"], {"id": group.id}, count=10) + + self.call_post_process_group( + is_new=False, + is_regression=False, + is_new_group_environment=False, + event=event, + ) + + # Should call start_seer_automation to generate summary + run automation + mock_start_seer_automation.assert_called_once_with(group.id) + + @patch( + "sentry.seer.seer_setup.get_seer_org_acknowledgement_for_scanner", + return_value=True, + ) + @patch("sentry.tasks.autofix.run_automation_for_group.delay") + @with_feature({"organizations:gen-ai-features": True, "projects:triage-signals-v0": True}) + def test_triage_signals_event_count_gte_10_skips_with_seer_last_triggered( + self, mock_run_automation, mock_get_seer_org_acknowledgement + ): + """Test that with event count >= 10 and seer_last_triggered set, we skip automation.""" + self.project.update_option("sentry:seer_scanner_automation", True) + event = self.create_event( + data={"message": "testing"}, + project_id=self.project.id, + ) + + # Set event count >= 10 and seer_last_triggered + from sentry import buffer + + group = event.group + buffer.backend.incr(Group, ["times_seen"], {"id": group.id}, count=10) + group.seer_last_triggered = timezone.now() + group.save() + + # Cache a summary for this group + from sentry.seer.autofix.issue_summary import get_issue_summary_cache_key + + cache_key = get_issue_summary_cache_key(group.id) + cache.set(cache_key, {"summary": "test summary"}, 3600) + + self.call_post_process_group( + is_new=False, + is_regression=False, + is_new_group_environment=False, + event=event, + ) + + # Should not call automation since seer_last_triggered is set + mock_run_automation.assert_not_called() + + class SeerAutomationHelperFunctionsTestMixin(BasePostProgressGroupMixin): """Unit tests for is_issue_eligible_for_seer_automation.""" From c22482355832931c32d4d1d35bbc2a2195adc098 Mon Sep 17 00:00:00 2001 From: Mihir Mavalankar Date: Wed, 19 Nov 2025 11:49:44 -0800 Subject: [PATCH 05/19] fix tests --- src/sentry/tasks/post_process.py | 4 +-- tests/sentry/tasks/test_post_process.py | 36 ++++++++++++++----------- 2 files changed, 23 insertions(+), 17 deletions(-) diff --git a/src/sentry/tasks/post_process.py b/src/sentry/tasks/post_process.py index 2a37ba2a19ddc7..b06c1246d9ee10 100644 --- a/src/sentry/tasks/post_process.py +++ b/src/sentry/tasks/post_process.py @@ -1635,7 +1635,7 @@ def kick_off_seer_automation(job: PostProcessJob) -> None: # Triage signals V0 behaviour # If event count < 10, only generate summary (no automation) - if group.times_seen_with_pending < 10: + if group.times_seen < 10: # Check if summary exists in cache cache_key = get_issue_summary_cache_key(group.id) if cache.get(cache_key) is not None: @@ -1654,7 +1654,7 @@ def kick_off_seer_automation(job: PostProcessJob) -> None: else: # Event count >= 10: run automation # Check seer_last_triggered first (long-term check to avoid re-running) - if group.seer_last_triggered is not None: + if group.seer_autofix_last_triggered is not None: return # Early returns for eligibility checks (cheap checks first) diff --git a/tests/sentry/tasks/test_post_process.py b/tests/sentry/tasks/test_post_process.py index cb65784a0dc8e2..2ee49a3738e1fe 100644 --- a/tests/sentry/tasks/test_post_process.py +++ b/tests/sentry/tasks/test_post_process.py @@ -3114,11 +3114,12 @@ def test_triage_signals_event_count_gte_10_with_cache( project_id=self.project.id, ) - # Set event count >= 10 - from sentry import buffer - + # Update group times_seen to simulate >= 10 events group = event.group - buffer.backend.incr(Group, ["times_seen"], {"id": group.id}, count=10) + group.times_seen = 10 + group.save() + # Also update the event's cached group reference + event.group.times_seen = 10 # Cache a summary for this group from sentry.seer.autofix.issue_summary import get_issue_summary_cache_key @@ -3152,11 +3153,12 @@ def test_triage_signals_event_count_gte_10_no_cache( project_id=self.project.id, ) - # Set event count >= 10 - from sentry import buffer - + # Update group times_seen to simulate >= 10 events group = event.group - buffer.backend.incr(Group, ["times_seen"], {"id": group.id}, count=10) + group.times_seen = 10 + group.save() + # Also update the event's cached group reference + event.group.times_seen = 10 self.call_post_process_group( is_new=False, @@ -3177,20 +3179,21 @@ def test_triage_signals_event_count_gte_10_no_cache( def test_triage_signals_event_count_gte_10_skips_with_seer_last_triggered( self, mock_run_automation, mock_get_seer_org_acknowledgement ): - """Test that with event count >= 10 and seer_last_triggered set, we skip automation.""" + """Test that with event count >= 10 and seer_autofix_last_triggered set, we skip automation.""" self.project.update_option("sentry:seer_scanner_automation", True) event = self.create_event( data={"message": "testing"}, project_id=self.project.id, ) - # Set event count >= 10 and seer_last_triggered - from sentry import buffer - + # Update group times_seen and seer_autofix_last_triggered group = event.group - buffer.backend.incr(Group, ["times_seen"], {"id": group.id}, count=10) - group.seer_last_triggered = timezone.now() + group.times_seen = 10 + group.seer_autofix_last_triggered = timezone.now() group.save() + # Also update the event's cached group reference + event.group.times_seen = 10 + event.group.seer_autofix_last_triggered = group.seer_autofix_last_triggered # Cache a summary for this group from sentry.seer.autofix.issue_summary import get_issue_summary_cache_key @@ -3205,7 +3208,7 @@ def test_triage_signals_event_count_gte_10_skips_with_seer_last_triggered( event=event, ) - # Should not call automation since seer_last_triggered is set + # Should not call automation since seer_autofix_last_triggered is set mock_run_automation.assert_not_called() @@ -3282,6 +3285,7 @@ class PostProcessGroupErrorTest( InboxTestMixin, ResourceChangeBoundsTestMixin, KickOffSeerAutomationTestMixin, + TriageSignalsV0TestMixin, SeerAutomationHelperFunctionsTestMixin, RuleProcessorTestMixin, ServiceHooksTestMixin, @@ -3371,6 +3375,7 @@ class PostProcessGroupPerformanceTest( SnoozeTestSkipSnoozeMixin, PerformanceIssueTestCase, KickOffSeerAutomationTestMixin, + TriageSignalsV0TestMixin, ): def create_event(self, data, project_id, assert_no_errors=True): fingerprint = data["fingerprint"][0] if data.get("fingerprint") else "some_group" @@ -3490,6 +3495,7 @@ class PostProcessGroupGenericTest( RuleProcessorTestMixin, SnoozeTestMixin, KickOffSeerAutomationTestMixin, + TriageSignalsV0TestMixin, ): def create_event(self, data, project_id, assert_no_errors=True): data["type"] = "generic" From 38d3365037de91922ae405496295bf6552fe1ef1 Mon Sep 17 00:00:00 2001 From: Mihir Mavalankar Date: Wed, 19 Nov 2025 12:09:09 -0800 Subject: [PATCH 06/19] made run_automation public and fixed typing --- src/sentry/seer/autofix/issue_summary.py | 4 +-- src/sentry/tasks/autofix.py | 8 +++-- .../sentry/seer/autofix/test_issue_summary.py | 30 +++++++++---------- 3 files changed, 23 insertions(+), 19 deletions(-) diff --git a/src/sentry/seer/autofix/issue_summary.py b/src/sentry/seer/autofix/issue_summary.py index c7e5c2b3442c10..a1c7544113e971 100644 --- a/src/sentry/seer/autofix/issue_summary.py +++ b/src/sentry/seer/autofix/issue_summary.py @@ -291,7 +291,7 @@ def _is_issue_fixable(group: Group, fixability_score: float) -> bool: return False -def _run_automation( +def run_automation( group: Group, user: User | RpcUser | AnonymousUser, event: GroupEvent, @@ -403,7 +403,7 @@ def _generate_summary( if should_run_automation: try: - _run_automation(group, user, event, source) + run_automation(group, user, event, source) except Exception: logger.exception( "Error auto-triggering autofix from issue summary", extra={"group_id": group.id} diff --git a/src/sentry/tasks/autofix.py b/src/sentry/tasks/autofix.py index d4638847846dcb..5354d6d76e8b24 100644 --- a/src/sentry/tasks/autofix.py +++ b/src/sentry/tasks/autofix.py @@ -75,7 +75,9 @@ def run_automation_for_group(group_id: int) -> None: Run automation directly for a group (assumes summary and fixability already exist). Used for triage signals flow when event count >= 10 and summary exists. """ - from sentry.seer.autofix.issue_summary import _run_automation + from django.contrib.auth.models import AnonymousUser + + from sentry.seer.autofix.issue_summary import run_automation group = Group.objects.get(id=group_id) event = group.get_latest_event() @@ -84,4 +86,6 @@ def run_automation_for_group(group_id: int) -> None: logger.warning("run_automation_for_group.no_event_found", extra={"group_id": group_id}) return - _run_automation(group=group, user=None, event=event, source=SeerAutomationSource.POST_PROCESS) + run_automation( + group=group, user=AnonymousUser(), event=event, source=SeerAutomationSource.POST_PROCESS + ) diff --git a/tests/sentry/seer/autofix/test_issue_summary.py b/tests/sentry/seer/autofix/test_issue_summary.py index a7b72b7eeb0363..6350c7abce40bc 100644 --- a/tests/sentry/seer/autofix/test_issue_summary.py +++ b/tests/sentry/seer/autofix/test_issue_summary.py @@ -18,8 +18,8 @@ _fetch_user_preference, _get_event, _get_stopping_point_from_fixability, - _run_automation, get_issue_summary, + run_automation, ) from sentry.seer.autofix.utils import AutofixStoppingPoint from sentry.seer.models import SummarizeIssueResponse, SummarizeIssueScores @@ -611,7 +611,7 @@ def test_get_issue_summary_with_web_vitals_issue( mock_trigger_autofix_task.assert_called_once() @patch("sentry.seer.autofix.issue_summary.get_seer_org_acknowledgement") - @patch("sentry.seer.autofix.issue_summary._run_automation") + @patch("sentry.seer.autofix.issue_summary.run_automation") @patch("sentry.seer.autofix.issue_summary._get_trace_tree_for_event") @patch("sentry.seer.autofix.issue_summary._call_seer") @patch("sentry.seer.autofix.issue_summary._get_event") @@ -623,7 +623,7 @@ def test_get_issue_summary_continues_when_automation_fails( mock_run_automation, mock_get_acknowledgement, ): - """Test that issue summary is still returned when _run_automation throws an exception.""" + """Test that issue summary is still returned when run_automation throws an exception.""" mock_get_acknowledgement.return_value = True # Set up event and seer response @@ -641,7 +641,7 @@ def test_get_issue_summary_continues_when_automation_fails( ) mock_call_seer.return_value = mock_summary - # Make _run_automation raise an exception + # Make run_automation raise an exception mock_run_automation.side_effect = Exception("Automation failed") # Call get_issue_summary and verify it still returns successfully @@ -652,7 +652,7 @@ def test_get_issue_summary_continues_when_automation_fails( expected_response["event_id"] = event.event_id assert summary_data == convert_dict_key_case(expected_response, snake_to_camel_case) - # Verify _run_automation was called and failed + # Verify run_automation was called and failed mock_run_automation.assert_called_once() mock_call_seer.assert_called_once() @@ -681,7 +681,7 @@ def test_get_issue_summary_handles_trace_tree_errors( possible_cause="cause", ), ) as mock_call_seer, - patch("sentry.seer.autofix.issue_summary._run_automation"), + patch("sentry.seer.autofix.issue_summary.run_automation"), patch( "sentry.seer.autofix.issue_summary.get_seer_org_acknowledgement", return_value=True, @@ -693,7 +693,7 @@ def test_get_issue_summary_handles_trace_tree_errors( mock_call_seer.assert_called_once_with(self.group, serialized_event, None) @patch("sentry.seer.autofix.issue_summary.get_seer_org_acknowledgement") - @patch("sentry.seer.autofix.issue_summary._run_automation") + @patch("sentry.seer.autofix.issue_summary.run_automation") @patch("sentry.seer.autofix.issue_summary._get_trace_tree_for_event") @patch("sentry.seer.autofix.issue_summary._call_seer") @patch("sentry.seer.autofix.issue_summary._get_event") @@ -705,7 +705,7 @@ def test_get_issue_summary_with_should_run_automation_false( mock_run_automation, mock_get_acknowledgement, ): - """Test that should_run_automation=False prevents _run_automation from being called.""" + """Test that should_run_automation=False prevents run_automation from being called.""" mock_get_acknowledgement.return_value = True event = Mock( event_id="test_event_id", @@ -743,7 +743,7 @@ def test_get_issue_summary_with_should_run_automation_false( mock_call_seer.assert_called_once_with(self.group, serialized_event, {"trace": "tree"}) mock_get_acknowledgement.assert_called_once_with(self.group.organization) - # Verify that _run_automation was NOT called + # Verify that run_automation was NOT called mock_run_automation.assert_not_called() # Check if the cache was set correctly @@ -798,7 +798,7 @@ def test_high_fixability_code_changes( possible_cause="c", scores=SummarizeIssueScores(fixability_score=0.70), ) - _run_automation(self.group, self.user, self.event, SeerAutomationSource.ALERT) + run_automation(self.group, self.user, self.event, SeerAutomationSource.ALERT) mock_trigger.assert_called_once() assert mock_trigger.call_args[1]["stopping_point"] == AutofixStoppingPoint.CODE_CHANGES @@ -822,7 +822,7 @@ def test_medium_fixability_solution( possible_cause="c", scores=SummarizeIssueScores(fixability_score=0.50), ) - _run_automation(self.group, self.user, self.event, SeerAutomationSource.ALERT) + run_automation(self.group, self.user, self.event, SeerAutomationSource.ALERT) mock_trigger.assert_called_once() assert mock_trigger.call_args[1]["stopping_point"] == AutofixStoppingPoint.SOLUTION @@ -848,7 +848,7 @@ def test_without_feature_flag(self, mock_gen, mock_budget, mock_state, mock_rate with self.feature( {"organizations:gen-ai-features": True, "projects:triage-signals-v0": False} ): - _run_automation(self.group, self.user, self.event, SeerAutomationSource.ALERT) + run_automation(self.group, self.user, self.event, SeerAutomationSource.ALERT) mock_trigger.assert_called_once() assert mock_trigger.call_args[1]["stopping_point"] is None @@ -1001,7 +1001,7 @@ def test_user_preference_limits_high_fixability( ) mock_fetch.return_value = "solution" - _run_automation(self.group, self.user, self.event, SeerAutomationSource.ALERT) + run_automation(self.group, self.user, self.event, SeerAutomationSource.ALERT) mock_trigger.assert_called_once() # Should be limited to SOLUTION by user preference @@ -1031,7 +1031,7 @@ def test_fixability_limits_permissive_user_preference( ) mock_fetch.return_value = "open_pr" - _run_automation(self.group, self.user, self.event, SeerAutomationSource.ALERT) + run_automation(self.group, self.user, self.event, SeerAutomationSource.ALERT) mock_trigger.assert_called_once() # Should use SOLUTION from fixability, not OPEN_PR from user @@ -1061,7 +1061,7 @@ def test_no_user_preference_uses_fixability_only( ) mock_fetch.return_value = None - _run_automation(self.group, self.user, self.event, SeerAutomationSource.ALERT) + run_automation(self.group, self.user, self.event, SeerAutomationSource.ALERT) mock_trigger.assert_called_once() # Should use OPEN_PR from fixability From d7f31272460e13f8cf46bf911ad173a00b7d81f0 Mon Sep 17 00:00:00 2001 From: Mihir Mavalankar Date: Wed, 19 Nov 2025 12:29:53 -0800 Subject: [PATCH 07/19] change lock for events >= 10 --- src/sentry/tasks/post_process.py | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/src/sentry/tasks/post_process.py b/src/sentry/tasks/post_process.py index b06c1246d9ee10..f0e6fb3db8e3ca 100644 --- a/src/sentry/tasks/post_process.py +++ b/src/sentry/tasks/post_process.py @@ -1663,21 +1663,24 @@ def kick_off_seer_automation(job: PostProcessJob) -> None: if is_seer_scanner_rate_limited(group.project, group.organization): return - # Now acquire a longer lock to avoid race conditions when starting the automation - lock_key, lock_name = get_issue_summary_lock_key(group.id) - lock = locks.get(lock_key, duration=30, name=lock_name) - if lock.locked(): + # Acquire a task queuing lock to avoid dispatching duplicate tasks + # This is separate from the work execution lock in get_issue_summary() + lock_key = f"post-process-seer-automation-dispatch:{group.id}" + lock = locks.get(lock_key, duration=60, name="post_process_seer_automation_dispatch") + try: + with lock.acquire(): + # Check if summary exists in cache + cache_key = get_issue_summary_cache_key(group.id) + if cache.get(cache_key) is not None: + # Summary exists, run automation directly + run_automation_for_group.delay(group.id) + else: + # No summary yet, generate summary + run automation in one go + start_seer_automation.delay(group.id) + except UnableToAcquireLock: + # Another process is already dispatching tasks for this group return - # Check if summary exists in cache - cache_key = get_issue_summary_cache_key(group.id) - if cache.get(cache_key) is not None: - # Summary exists, run automation directly - run_automation_for_group.delay(group.id) - else: - # No summary yet, generate summary + run automation in one go - start_seer_automation.delay(group.id) - GROUP_CATEGORY_POST_PROCESS_PIPELINE = { GroupCategory.ERROR: [ From 51ee208069825f7c9e6bec4b69ad0d7bf6b6a7c3 Mon Sep 17 00:00:00 2001 From: Mihir Mavalankar Date: Wed, 19 Nov 2025 13:03:13 -0800 Subject: [PATCH 08/19] simplified task lock --- src/sentry/tasks/post_process.py | 33 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/src/sentry/tasks/post_process.py b/src/sentry/tasks/post_process.py index f0e6fb3db8e3ca..e144469d2eea08 100644 --- a/src/sentry/tasks/post_process.py +++ b/src/sentry/tasks/post_process.py @@ -1663,23 +1663,22 @@ def kick_off_seer_automation(job: PostProcessJob) -> None: if is_seer_scanner_rate_limited(group.project, group.organization): return - # Acquire a task queuing lock to avoid dispatching duplicate tasks - # This is separate from the work execution lock in get_issue_summary() - lock_key = f"post-process-seer-automation-dispatch:{group.id}" - lock = locks.get(lock_key, duration=60, name="post_process_seer_automation_dispatch") - try: - with lock.acquire(): - # Check if summary exists in cache - cache_key = get_issue_summary_cache_key(group.id) - if cache.get(cache_key) is not None: - # Summary exists, run automation directly - run_automation_for_group.delay(group.id) - else: - # No summary yet, generate summary + run automation in one go - start_seer_automation.delay(group.id) - except UnableToAcquireLock: - # Another process is already dispatching tasks for this group - return + # Check if we're already processing automation for this group + automation_dispatch_cache_key = f"seer-automation-dispatched:{group.id}" + if cache.get(automation_dispatch_cache_key) is not None: + return # Another process already dispatched automation + + # Set cache with 5 minute TTL to prevent duplicate dispatches + cache.set(automation_dispatch_cache_key, True, timeout=300) + + # Check if summary exists in cache + cache_key = get_issue_summary_cache_key(group.id) + if cache.get(cache_key) is not None: + # Summary exists, run automation directly + run_automation_for_group.delay(group.id) + else: + # No summary yet, generate summary + run automation in one go + start_seer_automation.delay(group.id) GROUP_CATEGORY_POST_PROCESS_PIPELINE = { From e0dfbbc39f669c014bccc418114599fdd21d2a1e Mon Sep 17 00:00:00 2001 From: Mihir Mavalankar Date: Wed, 19 Nov 2025 13:30:15 -0800 Subject: [PATCH 09/19] cache add not cache set --- src/sentry/tasks/post_process.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/sentry/tasks/post_process.py b/src/sentry/tasks/post_process.py index e144469d2eea08..7d2234918d0d86 100644 --- a/src/sentry/tasks/post_process.py +++ b/src/sentry/tasks/post_process.py @@ -1663,14 +1663,11 @@ def kick_off_seer_automation(job: PostProcessJob) -> None: if is_seer_scanner_rate_limited(group.project, group.organization): return - # Check if we're already processing automation for this group + # Atomically set cache to prevent duplicate dispatches (returns False if key exists) automation_dispatch_cache_key = f"seer-automation-dispatched:{group.id}" - if cache.get(automation_dispatch_cache_key) is not None: + if not cache.add(automation_dispatch_cache_key, True, timeout=300): return # Another process already dispatched automation - # Set cache with 5 minute TTL to prevent duplicate dispatches - cache.set(automation_dispatch_cache_key, True, timeout=300) - # Check if summary exists in cache cache_key = get_issue_summary_cache_key(group.id) if cache.get(cache_key) is not None: From d6d3355da3aafcf870ba339c1c16cecd6b384adb Mon Sep 17 00:00:00 2001 From: Mihir Mavalankar Date: Wed, 19 Nov 2025 13:41:24 -0800 Subject: [PATCH 10/19] cache add for summary only too --- src/sentry/tasks/post_process.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/sentry/tasks/post_process.py b/src/sentry/tasks/post_process.py index 7d2234918d0d86..147401ee161ec9 100644 --- a/src/sentry/tasks/post_process.py +++ b/src/sentry/tasks/post_process.py @@ -1641,16 +1641,19 @@ def kick_off_seer_automation(job: PostProcessJob) -> None: if cache.get(cache_key) is not None: return - # Check if we're already generating the summary - lock_key, lock_name = get_issue_summary_lock_key(group.id) - lock = locks.get(lock_key, duration=5, name=lock_name) - if lock.locked(): + # Early returns for eligibility checks (cheap checks first) + if not is_issue_eligible_for_seer_automation(group): return + if is_seer_scanner_rate_limited(group.project, group.organization): + return + + # Atomically set cache to prevent duplicate summary generation + summary_dispatch_cache_key = f"seer-summary-dispatched:{group.id}" + if not cache.add(summary_dispatch_cache_key, True, timeout=30): + return # Another process already dispatched summary generation # Generate summary (no automation) - if is_issue_eligible_for_seer_automation(group): - if not is_seer_scanner_rate_limited(group.project, group.organization): - generate_issue_summary_only.delay(group.id) + generate_issue_summary_only.delay(group.id) else: # Event count >= 10: run automation # Check seer_last_triggered first (long-term check to avoid re-running) From 0aa797cbdd87faddebf3ef7f3a93e20d87adb74e Mon Sep 17 00:00:00 2001 From: Mihir Mavalankar Date: Thu, 20 Nov 2025 09:30:43 -0800 Subject: [PATCH 11/19] rename and small clarity changes --- src/sentry/tasks/autofix.py | 13 ++-- src/sentry/tasks/post_process.py | 12 ++-- tests/sentry/tasks/test_post_process.py | 90 ++++++++++++------------- 3 files changed, 58 insertions(+), 57 deletions(-) diff --git a/src/sentry/tasks/autofix.py b/src/sentry/tasks/autofix.py index 5354d6d76e8b24..f33181764c484b 100644 --- a/src/sentry/tasks/autofix.py +++ b/src/sentry/tasks/autofix.py @@ -31,12 +31,12 @@ def check_autofix_status(run_id: int, organization_id: int) -> None: @instrumented_task( - name="sentry.tasks.autofix.start_seer_automation", + name="sentry.tasks.autofix.generate_summary_and_run_automation", namespace=ingest_errors_tasks, processing_deadline_duration=35, retry=Retry(times=1), ) -def start_seer_automation(group_id: int) -> None: +def generate_summary_and_run_automation(group_id: int) -> None: from sentry.seer.autofix.issue_summary import get_issue_summary group = Group.objects.get(id=group_id) @@ -60,17 +60,18 @@ def generate_issue_summary_only(group_id: int) -> None: get_issue_summary( group=group, source=SeerAutomationSource.POST_PROCESS, should_run_automation=False ) - # TODO: Generate fixability score here and check for in run_automation for triage signals V0 + # TODO: Generate fixability score here and check for it in run_automation around line 316 + # That will make sure that even after adding fixability here it's not re-triggered. # Currently fixability will only be generated after 10 when run_automation is called @instrumented_task( - name="sentry.tasks.autofix.run_automation_for_group", + name="sentry.tasks.autofix.run_automation_only_task", namespace=ingest_errors_tasks, processing_deadline_duration=35, retry=Retry(times=1), ) -def run_automation_for_group(group_id: int) -> None: +def run_automation_only_task(group_id: int) -> None: """ Run automation directly for a group (assumes summary and fixability already exist). Used for triage signals flow when event count >= 10 and summary exists. @@ -83,7 +84,7 @@ def run_automation_for_group(group_id: int) -> None: event = group.get_latest_event() if not event: - logger.warning("run_automation_for_group.no_event_found", extra={"group_id": group_id}) + logger.warning("run_automation_only_task.no_event_found", extra={"group_id": group_id}) return run_automation( diff --git a/src/sentry/tasks/post_process.py b/src/sentry/tasks/post_process.py index 147401ee161ec9..d36989231ec01f 100644 --- a/src/sentry/tasks/post_process.py +++ b/src/sentry/tasks/post_process.py @@ -1605,8 +1605,8 @@ def kick_off_seer_automation(job: PostProcessJob) -> None: ) from sentry.tasks.autofix import ( generate_issue_summary_only, - run_automation_for_group, - start_seer_automation, + generate_summary_and_run_automation, + run_automation_only_task, ) event = job["event"] @@ -1618,7 +1618,7 @@ def kick_off_seer_automation(job: PostProcessJob) -> None: if group.seer_fixability_score is not None: return - if is_issue_eligible_for_seer_automation(group) is False: + if not is_issue_eligible_for_seer_automation(group): return # Don't run if there's already a task in progress for this issue @@ -1630,7 +1630,7 @@ def kick_off_seer_automation(job: PostProcessJob) -> None: if is_seer_scanner_rate_limited(group.project, group.organization): return - start_seer_automation.delay(group.id) + generate_summary_and_run_automation.delay(group.id) else: # Triage signals V0 behaviour @@ -1675,10 +1675,10 @@ def kick_off_seer_automation(job: PostProcessJob) -> None: cache_key = get_issue_summary_cache_key(group.id) if cache.get(cache_key) is not None: # Summary exists, run automation directly - run_automation_for_group.delay(group.id) + run_automation_only_task.delay(group.id) else: # No summary yet, generate summary + run automation in one go - start_seer_automation.delay(group.id) + generate_summary_and_run_automation.delay(group.id) GROUP_CATEGORY_POST_PROCESS_PIPELINE = { diff --git a/tests/sentry/tasks/test_post_process.py b/tests/sentry/tasks/test_post_process.py index 2ee49a3738e1fe..d90393bd031d2f 100644 --- a/tests/sentry/tasks/test_post_process.py +++ b/tests/sentry/tasks/test_post_process.py @@ -2675,10 +2675,10 @@ class KickOffSeerAutomationTestMixin(BasePostProgressGroupMixin): "sentry.seer.seer_setup.get_seer_org_acknowledgement_for_scanner", return_value=True, ) - @patch("sentry.tasks.autofix.start_seer_automation.delay") + @patch("sentry.tasks.autofix.generate_summary_and_run_automation.delay") @with_feature("organizations:gen-ai-features") def test_kick_off_seer_automation_with_features( - self, mock_start_seer_automation, mock_get_seer_org_acknowledgement + self, mock_generate_summary_and_run_automation, mock_get_seer_org_acknowledgement ): self.project.update_option("sentry:seer_scanner_automation", True) event = self.create_event( @@ -2693,15 +2693,15 @@ def test_kick_off_seer_automation_with_features( event=event, ) - mock_start_seer_automation.assert_called_once_with(event.group.id) + mock_generate_summary_and_run_automation.assert_called_once_with(event.group.id) @patch( "sentry.seer.seer_setup.get_seer_org_acknowledgement_for_scanner", return_value=True, ) - @patch("sentry.tasks.autofix.start_seer_automation.delay") + @patch("sentry.tasks.autofix.generate_summary_and_run_automation.delay") def test_kick_off_seer_automation_without_org_feature( - self, mock_start_seer_automation, mock_get_seer_org_acknowledgement + self, mock_generate_summary_and_run_automation, mock_get_seer_org_acknowledgement ): self.project.update_option("sentry:seer_scanner_automation", True) event = self.create_event( @@ -2715,16 +2715,16 @@ def test_kick_off_seer_automation_without_org_feature( event=event, ) - mock_start_seer_automation.assert_not_called() + mock_generate_summary_and_run_automation.assert_not_called() @patch( "sentry.seer.seer_setup.get_seer_org_acknowledgement_for_scanner", return_value=False, ) - @patch("sentry.tasks.autofix.start_seer_automation.delay") + @patch("sentry.tasks.autofix.generate_summary_and_run_automation.delay") @with_feature("organizations:gen-ai-features") def test_kick_off_seer_automation_without_seer_enabled( - self, mock_start_seer_automation, mock_get_seer_org_acknowledgement + self, mock_generate_summary_and_run_automation, mock_get_seer_org_acknowledgement ): self.project.update_option("sentry:seer_scanner_automation", True) event = self.create_event( @@ -2739,16 +2739,16 @@ def test_kick_off_seer_automation_without_seer_enabled( event=event, ) - mock_start_seer_automation.assert_not_called() + mock_generate_summary_and_run_automation.assert_not_called() @patch( "sentry.seer.seer_setup.get_seer_org_acknowledgement_for_scanner", return_value=True, ) - @patch("sentry.tasks.autofix.start_seer_automation.delay") + @patch("sentry.tasks.autofix.generate_summary_and_run_automation.delay") @with_feature("organizations:gen-ai-features") def test_kick_off_seer_automation_without_scanner_on( - self, mock_start_seer_automation, mock_get_seer_org_acknowledgement + self, mock_generate_summary_and_run_automation, mock_get_seer_org_acknowledgement ): self.project.update_option("sentry:seer_scanner_automation", True) event = self.create_event( @@ -2764,16 +2764,16 @@ def test_kick_off_seer_automation_without_scanner_on( event=event, ) - mock_start_seer_automation.assert_not_called() + mock_generate_summary_and_run_automation.assert_not_called() @patch( "sentry.seer.seer_setup.get_seer_org_acknowledgement_for_scanner", return_value=True, ) - @patch("sentry.tasks.autofix.start_seer_automation.delay") + @patch("sentry.tasks.autofix.generate_summary_and_run_automation.delay") @with_feature("organizations:gen-ai-features") def test_kick_off_seer_automation_skips_existing_fixability_score( - self, mock_start_seer_automation, mock_get_seer_org_acknowledgement + self, mock_generate_summary_and_run_automation, mock_get_seer_org_acknowledgement ): self.project.update_option("sentry:seer_scanner_automation", True) event = self.create_event( @@ -2793,16 +2793,16 @@ def test_kick_off_seer_automation_skips_existing_fixability_score( event=event, ) - mock_start_seer_automation.assert_not_called() + mock_generate_summary_and_run_automation.assert_not_called() @patch( "sentry.seer.seer_setup.get_seer_org_acknowledgement_for_scanner", return_value=True, ) - @patch("sentry.tasks.autofix.start_seer_automation.delay") + @patch("sentry.tasks.autofix.generate_summary_and_run_automation.delay") @with_feature("organizations:gen-ai-features") def test_kick_off_seer_automation_runs_with_missing_fixability_score( - self, mock_start_seer_automation, mock_get_seer_org_acknowledgement + self, mock_generate_summary_and_run_automation, mock_get_seer_org_acknowledgement ): self.project.update_option("sentry:seer_scanner_automation", True) event = self.create_event( @@ -2821,16 +2821,16 @@ def test_kick_off_seer_automation_runs_with_missing_fixability_score( event=event, ) - mock_start_seer_automation.assert_called_once_with(group.id) + mock_generate_summary_and_run_automation.assert_called_once_with(group.id) @patch( "sentry.seer.seer_setup.get_seer_org_acknowledgement_for_scanner", return_value=True, ) - @patch("sentry.tasks.autofix.start_seer_automation.delay") + @patch("sentry.tasks.autofix.generate_summary_and_run_automation.delay") @with_feature("organizations:gen-ai-features") def test_kick_off_seer_automation_skips_with_existing_fixability_score( - self, mock_start_seer_automation, mock_get_seer_org_acknowledgement + self, mock_generate_summary_and_run_automation, mock_get_seer_org_acknowledgement ): from sentry.seer.autofix.issue_summary import get_issue_summary_cache_key @@ -2856,16 +2856,16 @@ def test_kick_off_seer_automation_skips_with_existing_fixability_score( event=event, ) - mock_start_seer_automation.assert_not_called() + mock_generate_summary_and_run_automation.assert_not_called() @patch("sentry.seer.autofix.utils.is_seer_scanner_rate_limited") @patch("sentry.quotas.backend.has_available_reserved_budget") @patch("sentry.seer.seer_setup.get_seer_org_acknowledgement_for_scanner") - @patch("sentry.tasks.autofix.start_seer_automation.delay") + @patch("sentry.tasks.autofix.generate_summary_and_run_automation.delay") @with_feature("organizations:gen-ai-features") def test_rate_limit_only_checked_after_all_other_checks_pass( self, - mock_start_seer_automation, + mock_generate_summary_and_run_automation, mock_get_seer_org_acknowledgement, mock_has_budget, mock_is_rate_limited, @@ -2889,10 +2889,10 @@ def test_rate_limit_only_checked_after_all_other_checks_pass( event=event, ) mock_is_rate_limited.assert_called_once_with(event.project, event.group.organization) - mock_start_seer_automation.assert_called_once_with(event.group.id) + mock_generate_summary_and_run_automation.assert_called_once_with(event.group.id) mock_is_rate_limited.reset_mock() - mock_start_seer_automation.reset_mock() + mock_generate_summary_and_run_automation.reset_mock() # Test 2: When seer org acknowledgement fails, rate limit should NOT be checked mock_get_seer_org_acknowledgement.return_value = False @@ -2909,10 +2909,10 @@ def test_rate_limit_only_checked_after_all_other_checks_pass( event=event2, ) mock_is_rate_limited.assert_not_called() - mock_start_seer_automation.assert_not_called() + mock_generate_summary_and_run_automation.assert_not_called() mock_is_rate_limited.reset_mock() - mock_start_seer_automation.reset_mock() + mock_generate_summary_and_run_automation.reset_mock() mock_get_seer_org_acknowledgement.return_value = True # Reset to success # Test 3: When budget check fails, rate limit should NOT be checked @@ -2930,10 +2930,10 @@ def test_rate_limit_only_checked_after_all_other_checks_pass( event=event3, ) mock_is_rate_limited.assert_not_called() - mock_start_seer_automation.assert_not_called() + mock_generate_summary_and_run_automation.assert_not_called() mock_is_rate_limited.reset_mock() - mock_start_seer_automation.reset_mock() + mock_generate_summary_and_run_automation.reset_mock() mock_has_budget.return_value = True # Reset to success # Test 4: When project option is disabled, rate limit should NOT be checked @@ -2951,16 +2951,16 @@ def test_rate_limit_only_checked_after_all_other_checks_pass( event=event4, ) mock_is_rate_limited.assert_not_called() - mock_start_seer_automation.assert_not_called() + mock_generate_summary_and_run_automation.assert_not_called() @patch( "sentry.seer.seer_setup.get_seer_org_acknowledgement_for_scanner", return_value=True, ) - @patch("sentry.tasks.autofix.start_seer_automation.delay") + @patch("sentry.tasks.autofix.generate_summary_and_run_automation.delay") @with_feature("organizations:gen-ai-features") def test_kick_off_seer_automation_skips_when_lock_held( - self, mock_start_seer_automation, mock_get_seer_org_acknowledgement + self, mock_generate_summary_and_run_automation, mock_get_seer_org_acknowledgement ): """Test that seer automation is skipped when another task is already processing the same issue""" from sentry.seer.autofix.issue_summary import get_issue_summary_lock_key @@ -2986,7 +2986,7 @@ def test_kick_off_seer_automation_skips_when_lock_held( ) # Verify that seer automation was NOT started due to the lock - mock_start_seer_automation.assert_not_called() + mock_generate_summary_and_run_automation.assert_not_called() # Test that it works normally when lock is not held event2 = self.create_event( @@ -3002,16 +3002,16 @@ def test_kick_off_seer_automation_skips_when_lock_held( ) # Now it should be called since no lock is held - mock_start_seer_automation.assert_called_once_with(event2.group.id) + mock_generate_summary_and_run_automation.assert_called_once_with(event2.group.id) @patch( "sentry.seer.seer_setup.get_seer_org_acknowledgement_for_scanner", return_value=True, ) - @patch("sentry.tasks.autofix.start_seer_automation.delay") + @patch("sentry.tasks.autofix.generate_summary_and_run_automation.delay") @with_feature("organizations:gen-ai-features") def test_kick_off_seer_automation_with_hide_ai_features_enabled( - self, mock_start_seer_automation, mock_get_seer_org_acknowledgement + self, mock_generate_summary_and_run_automation, mock_get_seer_org_acknowledgement ): """Test that seer automation is not started when organization has hideAiFeatures set to True""" self.project.update_option("sentry:seer_scanner_automation", True) @@ -3029,7 +3029,7 @@ def test_kick_off_seer_automation_with_hide_ai_features_enabled( event=event, ) - mock_start_seer_automation.assert_not_called() + mock_generate_summary_and_run_automation.assert_not_called() class TriageSignalsV0TestMixin(BasePostProgressGroupMixin): @@ -3062,7 +3062,7 @@ def test_triage_signals_event_count_less_than_10_no_cache( event=event, ) - # Should call generate_issue_summary_only (not start_seer_automation) + # Should call generate_issue_summary_only (not generate_summary_and_run_automation) mock_generate_summary_only.assert_called_once_with(group.id) @patch( @@ -3102,7 +3102,7 @@ def test_triage_signals_event_count_less_than_10_with_cache( "sentry.seer.seer_setup.get_seer_org_acknowledgement_for_scanner", return_value=True, ) - @patch("sentry.tasks.autofix.run_automation_for_group.delay") + @patch("sentry.tasks.autofix.run_automation_only_task.delay") @with_feature({"organizations:gen-ai-features": True, "projects:triage-signals-v0": True}) def test_triage_signals_event_count_gte_10_with_cache( self, mock_run_automation, mock_get_seer_org_acknowledgement @@ -3134,17 +3134,17 @@ def test_triage_signals_event_count_gte_10_with_cache( event=event, ) - # Should call run_automation_for_group since summary exists + # Should call run_automation_only_task since summary exists mock_run_automation.assert_called_once_with(group.id) @patch( "sentry.seer.seer_setup.get_seer_org_acknowledgement_for_scanner", return_value=True, ) - @patch("sentry.tasks.autofix.start_seer_automation.delay") + @patch("sentry.tasks.autofix.generate_summary_and_run_automation.delay") @with_feature({"organizations:gen-ai-features": True, "projects:triage-signals-v0": True}) def test_triage_signals_event_count_gte_10_no_cache( - self, mock_start_seer_automation, mock_get_seer_org_acknowledgement + self, mock_generate_summary_and_run_automation, mock_get_seer_org_acknowledgement ): """Test that with event count >= 10 and no cached summary, we generate summary + run automation.""" self.project.update_option("sentry:seer_scanner_automation", True) @@ -3167,14 +3167,14 @@ def test_triage_signals_event_count_gte_10_no_cache( event=event, ) - # Should call start_seer_automation to generate summary + run automation - mock_start_seer_automation.assert_called_once_with(group.id) + # Should call generate_summary_and_run_automation to generate summary + run automation + mock_generate_summary_and_run_automation.assert_called_once_with(group.id) @patch( "sentry.seer.seer_setup.get_seer_org_acknowledgement_for_scanner", return_value=True, ) - @patch("sentry.tasks.autofix.run_automation_for_group.delay") + @patch("sentry.tasks.autofix.run_automation_only_task.delay") @with_feature({"organizations:gen-ai-features": True, "projects:triage-signals-v0": True}) def test_triage_signals_event_count_gte_10_skips_with_seer_last_triggered( self, mock_run_automation, mock_get_seer_org_acknowledgement From 96e5c1be03ec31e28969de4f6d1527803f7e3ffa Mon Sep 17 00:00:00 2001 From: Mihir Mavalankar Date: Thu, 20 Nov 2025 09:45:12 -0800 Subject: [PATCH 12/19] quota check at end and only if automation is needed --- src/sentry/tasks/post_process.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/sentry/tasks/post_process.py b/src/sentry/tasks/post_process.py index d36989231ec01f..0de415e4907515 100644 --- a/src/sentry/tasks/post_process.py +++ b/src/sentry/tasks/post_process.py @@ -1644,15 +1644,12 @@ def kick_off_seer_automation(job: PostProcessJob) -> None: # Early returns for eligibility checks (cheap checks first) if not is_issue_eligible_for_seer_automation(group): return - if is_seer_scanner_rate_limited(group.project, group.organization): - return # Atomically set cache to prevent duplicate summary generation summary_dispatch_cache_key = f"seer-summary-dispatched:{group.id}" if not cache.add(summary_dispatch_cache_key, True, timeout=30): return # Another process already dispatched summary generation - # Generate summary (no automation) generate_issue_summary_only.delay(group.id) else: # Event count >= 10: run automation @@ -1663,14 +1660,16 @@ def kick_off_seer_automation(job: PostProcessJob) -> None: # Early returns for eligibility checks (cheap checks first) if not is_issue_eligible_for_seer_automation(group): return - if is_seer_scanner_rate_limited(group.project, group.organization): - return # 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): return # Another process already dispatched automation + # Rate limit check must be last, after cache.add succeeds, to avoid wasting quota + if is_seer_scanner_rate_limited(group.project, group.organization): + return + # Check if summary exists in cache cache_key = get_issue_summary_cache_key(group.id) if cache.get(cache_key) is not None: From c8a2c45330b9b96409de760afa3ca42da7ecf137 Mon Sep 17 00:00:00 2001 From: Mihir Mavalankar Date: Thu, 20 Nov 2025 10:30:31 -0800 Subject: [PATCH 13/19] check for is automation on --- src/sentry/tasks/post_process.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/sentry/tasks/post_process.py b/src/sentry/tasks/post_process.py index 0de415e4907515..79e5062827c768 100644 --- a/src/sentry/tasks/post_process.py +++ b/src/sentry/tasks/post_process.py @@ -1595,6 +1595,7 @@ def check_if_flags_sent(job: PostProcessJob) -> None: def kick_off_seer_automation(job: PostProcessJob) -> None: + from sentry.seer.autofix.constants import AutofixAutomationTuningSettings from sentry.seer.autofix.issue_summary import ( get_issue_summary_cache_key, get_issue_summary_lock_key, @@ -1653,8 +1654,12 @@ def kick_off_seer_automation(job: PostProcessJob) -> None: generate_issue_summary_only.delay(group.id) else: # Event count >= 10: run automation - # Check seer_last_triggered first (long-term check to avoid re-running) - if group.seer_autofix_last_triggered is not None: + # Long-term check to avoid re-running + if ( + group.seer_autofix_last_triggered is not None + or group.project.get_option("sentry:autofix_automation_tuning") + == AutofixAutomationTuningSettings.OFF + ): return # Early returns for eligibility checks (cheap checks first) From d506aa30f4e0ea2de59a7c3502ee094b1e583ade Mon Sep 17 00:00:00 2001 From: Mihir Mavalankar Date: Thu, 20 Nov 2025 10:53:07 -0800 Subject: [PATCH 14/19] fix tests --- tests/sentry/tasks/test_post_process.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/sentry/tasks/test_post_process.py b/tests/sentry/tasks/test_post_process.py index d90393bd031d2f..912f4e75ab2ab2 100644 --- a/tests/sentry/tasks/test_post_process.py +++ b/tests/sentry/tasks/test_post_process.py @@ -3109,6 +3109,7 @@ def test_triage_signals_event_count_gte_10_with_cache( ): """Test that with event count >= 10 and cached summary exists, we run automation directly.""" self.project.update_option("sentry:seer_scanner_automation", True) + self.project.update_option("sentry:autofix_automation_tuning", "always") event = self.create_event( data={"message": "testing"}, project_id=self.project.id, @@ -3148,6 +3149,7 @@ def test_triage_signals_event_count_gte_10_no_cache( ): """Test that with event count >= 10 and no cached summary, we generate summary + run automation.""" self.project.update_option("sentry:seer_scanner_automation", True) + self.project.update_option("sentry:autofix_automation_tuning", "always") event = self.create_event( data={"message": "testing"}, project_id=self.project.id, From 66c92e5c758e8af3a12243ed2ce5c22145292359 Mon Sep 17 00:00:00 2001 From: Mihir Mavalankar Date: Thu, 20 Nov 2025 11:51:00 -0800 Subject: [PATCH 15/19] is_seer_scanner_rate_limited check before issue summary --- src/sentry/tasks/post_process.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/sentry/tasks/post_process.py b/src/sentry/tasks/post_process.py index 79e5062827c768..df6ef043748d19 100644 --- a/src/sentry/tasks/post_process.py +++ b/src/sentry/tasks/post_process.py @@ -1651,6 +1651,10 @@ def kick_off_seer_automation(job: PostProcessJob) -> None: if not cache.add(summary_dispatch_cache_key, True, timeout=30): return # Another process already dispatched summary generation + # Rate limit check must be last, after cache.add succeeds, to avoid wasting quota + if is_seer_scanner_rate_limited(group.project, group.organization): + return + generate_issue_summary_only.delay(group.id) else: # Event count >= 10: run automation @@ -1671,16 +1675,16 @@ def kick_off_seer_automation(job: PostProcessJob) -> None: if not cache.add(automation_dispatch_cache_key, True, timeout=300): return # Another process already dispatched automation - # Rate limit check must be last, after cache.add succeeds, to avoid wasting quota - if is_seer_scanner_rate_limited(group.project, group.organization): - return - # Check if summary exists in cache cache_key = get_issue_summary_cache_key(group.id) if cache.get(cache_key) is not None: # Summary exists, run automation directly run_automation_only_task.delay(group.id) else: + # Rate limit check must be last, after cache.add succeeds, to avoid wasting quota + if is_seer_scanner_rate_limited(group.project, group.organization): + return + # No summary yet, generate summary + run automation in one go generate_summary_and_run_automation.delay(group.id) From bb66cfd7b086ca0489397afcc89c161a75c5616b Mon Sep 17 00:00:00 2001 From: Mihir Mavalankar Date: Thu, 20 Nov 2025 11:52:13 -0800 Subject: [PATCH 16/19] is_seer_scanner_rate_limited check before issue summary --- src/sentry/tasks/post_process.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sentry/tasks/post_process.py b/src/sentry/tasks/post_process.py index df6ef043748d19..68689baae64b46 100644 --- a/src/sentry/tasks/post_process.py +++ b/src/sentry/tasks/post_process.py @@ -1681,7 +1681,7 @@ def kick_off_seer_automation(job: PostProcessJob) -> None: # Summary exists, run automation directly run_automation_only_task.delay(group.id) else: - # Rate limit check must be last, after cache.add succeeds, to avoid wasting quota + # Rate limit check before generating summary if is_seer_scanner_rate_limited(group.project, group.organization): return From 147b24e6a7481562228ff48e991a50d9338508b5 Mon Sep 17 00:00:00 2001 From: Mihir Mavalankar Date: Thu, 20 Nov 2025 11:53:43 -0800 Subject: [PATCH 17/19] comment fix --- src/sentry/tasks/autofix.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sentry/tasks/autofix.py b/src/sentry/tasks/autofix.py index f33181764c484b..926b649fbc2dde 100644 --- a/src/sentry/tasks/autofix.py +++ b/src/sentry/tasks/autofix.py @@ -62,7 +62,7 @@ def generate_issue_summary_only(group_id: int) -> None: ) # TODO: Generate fixability score here and check for it in run_automation around line 316 # That will make sure that even after adding fixability here it's not re-triggered. - # Currently fixability will only be generated after 10 when run_automation is called + # Currently fixability will only be generated after 10 events when run_automation is called @instrumented_task( From 1efbdf56f709a22ec55962b84aaedfbdae54ac95 Mon Sep 17 00:00:00 2001 From: Mihir Mavalankar Date: Thu, 20 Nov 2025 12:46:46 -0800 Subject: [PATCH 18/19] add fixability check --- src/sentry/tasks/post_process.py | 2 ++ tests/sentry/tasks/test_post_process.py | 42 +++++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/src/sentry/tasks/post_process.py b/src/sentry/tasks/post_process.py index 68689baae64b46..cac0bd507e2f07 100644 --- a/src/sentry/tasks/post_process.py +++ b/src/sentry/tasks/post_process.py @@ -1661,6 +1661,8 @@ def kick_off_seer_automation(job: PostProcessJob) -> None: # Long-term check to avoid re-running if ( group.seer_autofix_last_triggered is not None + or group.seer_fixability_score + is not None # TODO: Remove this once fixability is generated with generate_issue_summary_only or group.project.get_option("sentry:autofix_automation_tuning") == AutofixAutomationTuningSettings.OFF ): diff --git a/tests/sentry/tasks/test_post_process.py b/tests/sentry/tasks/test_post_process.py index 912f4e75ab2ab2..89c27cfbd059da 100644 --- a/tests/sentry/tasks/test_post_process.py +++ b/tests/sentry/tasks/test_post_process.py @@ -3213,6 +3213,48 @@ def test_triage_signals_event_count_gte_10_skips_with_seer_last_triggered( # Should not call automation since seer_autofix_last_triggered is set mock_run_automation.assert_not_called() + @patch( + "sentry.seer.seer_setup.get_seer_org_acknowledgement_for_scanner", + return_value=True, + ) + @patch("sentry.tasks.autofix.run_automation_only_task.delay") + @with_feature({"organizations:gen-ai-features": True, "projects:triage-signals-v0": True}) + def test_triage_signals_event_count_gte_10_skips_with_existing_fixability_score( + self, mock_run_automation, mock_get_seer_org_acknowledgement + ): + """Test that with event count >= 10 and seer_fixability_score exists, we skip automation.""" + self.project.update_option("sentry:seer_scanner_automation", True) + self.project.update_option("sentry:autofix_automation_tuning", "always") + event = self.create_event( + data={"message": "testing"}, + project_id=self.project.id, + ) + + # Update group times_seen and set seer_fixability_score (but not seer_autofix_last_triggered) + group = event.group + group.times_seen = 10 + group.seer_fixability_score = 0.5 + group.save() + # Also update the event's cached group reference + event.group.times_seen = 10 + event.group.seer_fixability_score = 0.5 + + # Cache a summary for this group + from sentry.seer.autofix.issue_summary import get_issue_summary_cache_key + + cache_key = get_issue_summary_cache_key(group.id) + cache.set(cache_key, {"summary": "test summary"}, 3600) + + self.call_post_process_group( + is_new=False, + is_regression=False, + is_new_group_environment=False, + event=event, + ) + + # Should not call automation since seer_fixability_score exists + mock_run_automation.assert_not_called() + class SeerAutomationHelperFunctionsTestMixin(BasePostProgressGroupMixin): """Unit tests for is_issue_eligible_for_seer_automation.""" From df4d3c9d46c3931da78059659d2d3a54c5ec24a6 Mon Sep 17 00:00:00 2001 From: Mihir Mavalankar Date: Thu, 20 Nov 2025 12:52:35 -0800 Subject: [PATCH 19/19] change times_seen to times_seen_with_pending --- src/sentry/tasks/post_process.py | 2 +- tests/sentry/tasks/test_post_process.py | 120 +++++++++++++++--------- 2 files changed, 76 insertions(+), 46 deletions(-) diff --git a/src/sentry/tasks/post_process.py b/src/sentry/tasks/post_process.py index cac0bd507e2f07..7a3ae2e8d1ead7 100644 --- a/src/sentry/tasks/post_process.py +++ b/src/sentry/tasks/post_process.py @@ -1636,7 +1636,7 @@ def kick_off_seer_automation(job: PostProcessJob) -> None: # Triage signals V0 behaviour # If event count < 10, only generate summary (no automation) - if group.times_seen < 10: + if group.times_seen_with_pending < 10: # Check if summary exists in cache cache_key = get_issue_summary_cache_key(group.id) if cache.get(cache_key) is not None: diff --git a/tests/sentry/tasks/test_post_process.py b/tests/sentry/tasks/test_post_process.py index 89c27cfbd059da..ae63fe9e4997cc 100644 --- a/tests/sentry/tasks/test_post_process.py +++ b/tests/sentry/tasks/test_post_process.py @@ -3053,7 +3053,9 @@ def test_triage_signals_event_count_less_than_10_no_cache( # Ensure event count < 10 group = event.group - assert group.times_seen < 10 + # Set times_seen_pending to 0 to ensure times_seen_with_pending < 10 + group.times_seen_pending = 0 + assert group.times_seen_with_pending < 10 self.call_post_process_group( is_new=True, @@ -3117,23 +3119,30 @@ def test_triage_signals_event_count_gte_10_with_cache( # Update group times_seen to simulate >= 10 events group = event.group - group.times_seen = 10 + group.times_seen = 1 group.save() # Also update the event's cached group reference - event.group.times_seen = 10 + event.group.times_seen = 1 - # Cache a summary for this group - from sentry.seer.autofix.issue_summary import get_issue_summary_cache_key + # Mock buffer backend to return pending increments + from sentry import buffer - cache_key = get_issue_summary_cache_key(group.id) - cache.set(cache_key, {"summary": "test summary"}, 3600) + def mock_buffer_get(model, columns, filters): + return {"times_seen": 9} - self.call_post_process_group( - is_new=False, - is_regression=False, - is_new_group_environment=False, - event=event, - ) + with patch.object(buffer.backend, "get", side_effect=mock_buffer_get): + # Cache a summary for this group + from sentry.seer.autofix.issue_summary import get_issue_summary_cache_key + + cache_key = get_issue_summary_cache_key(group.id) + cache.set(cache_key, {"summary": "test summary"}, 3600) + + self.call_post_process_group( + is_new=False, + is_regression=False, + is_new_group_environment=False, + event=event, + ) # Should call run_automation_only_task since summary exists mock_run_automation.assert_called_once_with(group.id) @@ -3157,17 +3166,24 @@ def test_triage_signals_event_count_gte_10_no_cache( # Update group times_seen to simulate >= 10 events group = event.group - group.times_seen = 10 + group.times_seen = 1 group.save() # Also update the event's cached group reference - event.group.times_seen = 10 + event.group.times_seen = 1 - self.call_post_process_group( - is_new=False, - is_regression=False, - is_new_group_environment=False, - event=event, - ) + # Mock buffer backend to return pending increments + from sentry import buffer + + def mock_buffer_get(model, columns, filters): + return {"times_seen": 9} + + with patch.object(buffer.backend, "get", side_effect=mock_buffer_get): + self.call_post_process_group( + is_new=False, + is_regression=False, + is_new_group_environment=False, + event=event, + ) # Should call generate_summary_and_run_automation to generate summary + run automation mock_generate_summary_and_run_automation.assert_called_once_with(group.id) @@ -3190,25 +3206,32 @@ def test_triage_signals_event_count_gte_10_skips_with_seer_last_triggered( # Update group times_seen and seer_autofix_last_triggered group = event.group - group.times_seen = 10 + group.times_seen = 1 group.seer_autofix_last_triggered = timezone.now() group.save() # Also update the event's cached group reference - event.group.times_seen = 10 + event.group.times_seen = 1 event.group.seer_autofix_last_triggered = group.seer_autofix_last_triggered - # Cache a summary for this group - from sentry.seer.autofix.issue_summary import get_issue_summary_cache_key + # Mock buffer backend to return pending increments + from sentry import buffer - cache_key = get_issue_summary_cache_key(group.id) - cache.set(cache_key, {"summary": "test summary"}, 3600) + def mock_buffer_get(model, columns, filters): + return {"times_seen": 9} - self.call_post_process_group( - is_new=False, - is_regression=False, - is_new_group_environment=False, - event=event, - ) + with patch.object(buffer.backend, "get", side_effect=mock_buffer_get): + # Cache a summary for this group + from sentry.seer.autofix.issue_summary import get_issue_summary_cache_key + + cache_key = get_issue_summary_cache_key(group.id) + cache.set(cache_key, {"summary": "test summary"}, 3600) + + self.call_post_process_group( + is_new=False, + is_regression=False, + is_new_group_environment=False, + event=event, + ) # Should not call automation since seer_autofix_last_triggered is set mock_run_automation.assert_not_called() @@ -3232,25 +3255,32 @@ def test_triage_signals_event_count_gte_10_skips_with_existing_fixability_score( # Update group times_seen and set seer_fixability_score (but not seer_autofix_last_triggered) group = event.group - group.times_seen = 10 + group.times_seen = 1 group.seer_fixability_score = 0.5 group.save() # Also update the event's cached group reference - event.group.times_seen = 10 + event.group.times_seen = 1 event.group.seer_fixability_score = 0.5 - # Cache a summary for this group - from sentry.seer.autofix.issue_summary import get_issue_summary_cache_key + # Mock buffer backend to return pending increments + from sentry import buffer - cache_key = get_issue_summary_cache_key(group.id) - cache.set(cache_key, {"summary": "test summary"}, 3600) + def mock_buffer_get(model, columns, filters): + return {"times_seen": 9} - self.call_post_process_group( - is_new=False, - is_regression=False, - is_new_group_environment=False, - event=event, - ) + with patch.object(buffer.backend, "get", side_effect=mock_buffer_get): + # Cache a summary for this group + from sentry.seer.autofix.issue_summary import get_issue_summary_cache_key + + cache_key = get_issue_summary_cache_key(group.id) + cache.set(cache_key, {"summary": "test summary"}, 3600) + + self.call_post_process_group( + is_new=False, + is_regression=False, + is_new_group_environment=False, + event=event, + ) # Should not call automation since seer_fixability_score exists mock_run_automation.assert_not_called()