Skip to content

Commit 0672c10

Browse files
authored
fix(coding integrations): add catching for integration not found error (#111691)
When a coding agent integration (Cursor, Claude Code) is deleted or marked PENDING_DELETION, Seer's stored automation_handoff preference still references the old integration_id. On every subsequent automated autofix run, Seer calls Sentry's trigger_coding_agent_launch RPC with the stale ID. Sentry validates the integration, finds it gone, and raises NotFound. There are two paths that affect this, one is through the seer side root cause step and the other is after root cause completed first. To fix the first, I pulled out the NotFound error when returning to Seer and used an error code to identify it. When this is caught we clear the handoff preferences. For the second, there has been a matching change for the calling path in a separate [Seer PR](getsentry/seer#5504). Tests were added and I ran it locally to make sure it works.
1 parent 57acfd4 commit 0672c10

File tree

8 files changed

+374
-10
lines changed

8 files changed

+374
-10
lines changed

src/sentry/seer/autofix/coding_agent.py

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,24 @@
1212
from rest_framework.exceptions import APIException, NotFound, PermissionDenied, ValidationError
1313

1414
from sentry import features
15+
16+
17+
class IntegrationNotFound(NotFound):
18+
pass
19+
20+
21+
class OrganizationNotFound(NotFound):
22+
pass
23+
24+
25+
class AutofixStateNotFound(NotFound):
26+
pass
27+
28+
29+
class StateReposNotFound(NotFound):
30+
pass
31+
32+
1533
from sentry.constants import ENABLE_SEER_CODING_DEFAULT, ObjectStatus
1634
from sentry.integrations.claude_code.integration import (
1735
ClaudeCodeIntegrationMetadata,
@@ -118,15 +136,15 @@ def _validate_and_get_integration(organization, integration_id: int):
118136
)
119137

120138
if not org_integration or org_integration.status != ObjectStatus.ACTIVE:
121-
raise NotFound("Integration not found")
139+
raise IntegrationNotFound("Integration not found")
122140

123141
integration = integration_service.get_integration(
124142
organization_integration_id=org_integration.id,
125143
status=ObjectStatus.ACTIVE,
126144
)
127145

128146
if not integration:
129-
raise NotFound("Integration not found")
147+
raise IntegrationNotFound("Integration not found")
130148

131149
# Verify it's a coding agent integration
132150
if integration.provider not in get_coding_agent_providers():
@@ -252,7 +270,7 @@ def _launch_agents_for_repos(
252270
repos_to_launch = validated_repos or autofix_state_repos
253271

254272
if not repos_to_launch:
255-
raise NotFound(
273+
raise StateReposNotFound(
256274
"There are no repos in the Seer state to launch coding agents with, make sure you have repos connected to Seer and rerun this Issue Fix."
257275
)
258276

@@ -426,7 +444,7 @@ def launch_coding_agents_for_run(
426444
try:
427445
organization = Organization.objects.get(id=organization_id)
428446
except Organization.DoesNotExist:
429-
raise NotFound("Organization not found")
447+
raise OrganizationNotFound("Organization not found")
430448

431449
if not organization.get_option("sentry:enable_seer_coding", default=ENABLE_SEER_CODING_DEFAULT):
432450
raise PermissionDenied("Code generation is disabled for this organization")
@@ -457,7 +475,7 @@ def launch_coding_agents_for_run(
457475

458476
autofix_state = _get_autofix_state(run_id, organization)
459477
if autofix_state is None:
460-
raise NotFound("Autofix state not found")
478+
raise AutofixStateNotFound("Autofix state not found")
461479

462480
logger.info(
463481
"coding_agent.launch_request",

src/sentry/seer/autofix/on_completion_hook.py

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,22 @@
88
from sentry import features
99
from sentry.models.group import Group
1010
from sentry.models.organization import Organization
11+
from sentry.models.project import Project
1112
from sentry.seer.autofix.autofix_agent import (
1213
AutofixStep,
1314
trigger_autofix_explorer,
1415
trigger_coding_agent_handoff,
1516
trigger_push_changes,
1617
)
18+
from sentry.seer.autofix.coding_agent import IntegrationNotFound
1719
from sentry.seer.autofix.constants import AutofixReferrer
18-
from sentry.seer.autofix.utils import AutofixStoppingPoint, get_project_seer_preferences
20+
from sentry.seer.autofix.utils import (
21+
AutofixStoppingPoint,
22+
get_project_seer_preferences,
23+
resolve_repository_ids,
24+
set_project_seer_preference,
25+
write_preference_to_sentry_db,
26+
)
1927
from sentry.seer.entrypoints.operator import SeerAutofixOperator, process_autofix_updates
2028
from sentry.seer.explorer.client_models import Artifact
2129
from sentry.seer.explorer.client_utils import fetch_run_status
@@ -25,6 +33,7 @@
2533
SeerApiResponseValidationError,
2634
SeerAutomationHandoffConfiguration,
2735
)
36+
from sentry.seer.models.seer_api_models import SeerProjectPreference
2837
from sentry.seer.supergroups.embeddings import trigger_supergroups_embedding
2938
from sentry.sentry_apps.metrics import SentryAppEventType
3039
from sentry.sentry_apps.tasks.sentry_apps import broadcast_webhooks_for_organization
@@ -472,6 +481,35 @@ def _get_handoff_config_if_applicable(
472481

473482
return handoff_config
474483

484+
@classmethod
485+
def _clear_handoff_preference(
486+
cls, project: Project, run_id: int, organization: Organization
487+
) -> None:
488+
"""Clear automation_handoff from project preferences after integration is not found."""
489+
try:
490+
preference_response = get_project_seer_preferences(project.id)
491+
if preference_response and preference_response.preference:
492+
updated_preference = preference_response.preference.copy(
493+
update={"automation_handoff": None}
494+
)
495+
set_project_seer_preference(updated_preference)
496+
497+
if features.has("organizations:seer-project-settings-dual-write", organization):
498+
try:
499+
validated_pref = SeerProjectPreference.validate(updated_preference)
500+
resolved_pref = resolve_repository_ids(organization.id, [validated_pref])
501+
write_preference_to_sentry_db(project, resolved_pref[0])
502+
except Exception:
503+
logger.exception(
504+
"seer.write_preferences.failed",
505+
extra={"project_id": project.id, "organization_id": organization.id},
506+
)
507+
except (SeerApiError, SeerApiResponseValidationError):
508+
logger.exception(
509+
"autofix.on_completion_hook.clear_handoff_preference_failed",
510+
extra={"run_id": run_id, "organization_id": organization.id},
511+
)
512+
475513
@classmethod
476514
def _trigger_coding_agent_handoff(
477515
cls,
@@ -508,6 +546,16 @@ def _trigger_coding_agent_handoff(
508546
"failures": len(result.get("failures", [])),
509547
},
510548
)
549+
except IntegrationNotFound:
550+
logger.exception(
551+
"autofix.on_completion_hook.coding_agent_handoff_integration_not_found",
552+
extra={
553+
"run_id": run_id,
554+
"organization_id": organization.id,
555+
"integration_id": handoff_config.integration_id,
556+
},
557+
)
558+
cls._clear_handoff_preference(group.project, run_id, organization)
511559
except Exception:
512560
logger.exception(
513561
"autofix.on_completion_hook.coding_agent_handoff_failed",

src/sentry/seer/endpoints/seer_rpc.py

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
from sentry_protos.snuba.v1.trace_item_attribute_pb2 import AttributeKey, AttributeValue, StrArray
3737
from sentry_protos.snuba.v1.trace_item_filter_pb2 import ComparisonFilter, TraceItemFilter
3838

39+
from sentry import features
3940
from sentry.api.api_owners import ApiOwner
4041
from sentry.api.api_publish_status import ApiPublishStatus
4142
from sentry.api.authentication import AuthenticationSiloLimit, StandardAuthentication
@@ -73,8 +74,19 @@
7374
get_attribute_values_with_substring,
7475
)
7576
from sentry.seer.autofix.autofix_tools import get_error_event_details, get_profile_details
76-
from sentry.seer.autofix.coding_agent import launch_coding_agents_for_run
77-
from sentry.seer.autofix.utils import AutofixTriggerSource
77+
from sentry.seer.autofix.coding_agent import (
78+
AutofixStateNotFound,
79+
IntegrationNotFound,
80+
OrganizationNotFound,
81+
StateReposNotFound,
82+
launch_coding_agents_for_run,
83+
)
84+
from sentry.seer.autofix.utils import (
85+
AutofixTriggerSource,
86+
get_project_seer_preferences,
87+
resolve_repository_ids,
88+
write_preference_to_sentry_db,
89+
)
7890
from sentry.seer.constants import SEER_SUPPORTED_SCM_PROVIDERS, SeerSCMProvider
7991
from sentry.seer.entrypoints.operator import SeerAutofixOperator, process_autofix_updates
8092
from sentry.seer.explorer.custom_tool_utils import call_custom_tool
@@ -104,6 +116,7 @@
104116
)
105117
from sentry.seer.fetch_issues import by_error_type, by_function_name, by_text_query, utils
106118
from sentry.seer.issue_detection import create_issue_occurrence
119+
from sentry.seer.models.seer_api_models import SeerProjectPreference
107120
from sentry.seer.utils import filter_repo_by_provider
108121
from sentry.sentry_apps.metrics import SentryAppEventType
109122
from sentry.sentry_apps.tasks.sentry_apps import broadcast_webhooks_for_organization
@@ -565,6 +578,7 @@ def send_seer_webhook(*, event_name: str, organization_id: int, payload: dict) -
565578
def trigger_coding_agent_launch(
566579
*,
567580
organization_id: int,
581+
project_id: int | None = None,
568582
integration_id: int,
569583
run_id: int,
570584
trigger_source: str = "solution",
@@ -579,7 +593,7 @@ def trigger_coding_agent_launch(
579593
trigger_source: Either "root_cause" or "solution" (default: "solution")
580594
581595
Returns:
582-
dict: {"success": bool}
596+
dict: {"success": bool, "error_code": str | None}
583597
"""
584598
try:
585599
launch_coding_agents_for_run(
@@ -589,7 +603,45 @@ def trigger_coding_agent_launch(
589603
trigger_source=AutofixTriggerSource(trigger_source),
590604
)
591605
return {"success": True}
592-
except (NotFound, PermissionDenied, ValidationError, APIException):
606+
except IntegrationNotFound:
607+
logger.exception(
608+
"coding_agent.rpc_launch_error",
609+
extra={
610+
"organization_id": organization_id,
611+
"integration_id": integration_id,
612+
"run_id": run_id,
613+
},
614+
)
615+
try:
616+
project = Project.objects.get_from_cache(id=project_id)
617+
organization = Organization.objects.get_from_cache(id=organization_id)
618+
if features.has("organizations:seer-project-settings-dual-write", organization):
619+
preference_response = get_project_seer_preferences(project.id)
620+
if preference_response and preference_response.preference:
621+
updated_preference = preference_response.preference.copy(
622+
update={"automation_handoff": None}
623+
)
624+
validated_pref = SeerProjectPreference.validate(updated_preference)
625+
resolved_pref = resolve_repository_ids(organization.id, [validated_pref])
626+
write_preference_to_sentry_db(project, resolved_pref[0])
627+
except Exception:
628+
logger.exception(
629+
"coding_agent.clear_handoff_preference_failed",
630+
extra={
631+
"project_id": project_id,
632+
"organization_id": organization_id,
633+
"run_id": run_id,
634+
},
635+
)
636+
return {"success": False, "error_code": "integration_not_found"}
637+
except (
638+
OrganizationNotFound,
639+
AutofixStateNotFound,
640+
StateReposNotFound,
641+
PermissionDenied,
642+
ValidationError,
643+
APIException,
644+
):
593645
logger.exception(
594646
"coding_agent.rpc_launch_error",
595647
extra={

tests/sentry/seer/autofix/__init__.py

Whitespace-only changes.

tests/sentry/seer/autofix/test_autofix_on_completion_hook.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -608,6 +608,54 @@ def test_maybe_continue_pipeline_triggers_handoff_when_configured(
608608

609609
mock_trigger_handoff.assert_called_once()
610610

611+
@patch("sentry.seer.autofix.on_completion_hook.set_project_seer_preference")
612+
@patch("sentry.seer.autofix.on_completion_hook.get_project_seer_preferences")
613+
@patch("sentry.seer.autofix.on_completion_hook.trigger_coding_agent_handoff")
614+
def test_trigger_coding_agent_handoff_clears_preference_on_not_found(
615+
self, mock_trigger, mock_get_prefs, mock_set_pref
616+
):
617+
"""When IntegrationNotFound is raised, automation_handoff is cleared from preferences."""
618+
from sentry.seer.autofix.coding_agent import IntegrationNotFound
619+
620+
mock_trigger.side_effect = IntegrationNotFound()
621+
handoff_config = self._make_handoff_config()
622+
mock_get_prefs.return_value = self._make_preference_response(handoff_config=handoff_config)
623+
624+
AutofixOnCompletionHook._trigger_coding_agent_handoff(
625+
organization=self.organization,
626+
run_id=123,
627+
group=self.group,
628+
handoff_config=handoff_config,
629+
)
630+
631+
mock_set_pref.assert_called_once()
632+
updated = mock_set_pref.call_args.args[0]
633+
assert updated.automation_handoff is None
634+
635+
@patch("sentry.seer.autofix.on_completion_hook.set_project_seer_preference")
636+
@patch("sentry.seer.autofix.on_completion_hook.get_project_seer_preferences")
637+
@patch("sentry.seer.autofix.on_completion_hook.trigger_coding_agent_handoff")
638+
def test_trigger_coding_agent_handoff_not_found_seer_api_error_does_not_raise(
639+
self, mock_trigger, mock_get_prefs, mock_set_pref
640+
):
641+
"""A SeerApiError during preference-clearing after IntegrationNotFound should not propagate."""
642+
from sentry.seer.autofix.coding_agent import IntegrationNotFound
643+
from sentry.seer.models import SeerApiError
644+
645+
mock_trigger.side_effect = IntegrationNotFound()
646+
mock_get_prefs.side_effect = SeerApiError("seer unavailable", 503)
647+
handoff_config = self._make_handoff_config()
648+
649+
# Should not raise
650+
AutofixOnCompletionHook._trigger_coding_agent_handoff(
651+
organization=self.organization,
652+
run_id=123,
653+
group=self.group,
654+
handoff_config=handoff_config,
655+
)
656+
657+
mock_set_pref.assert_not_called()
658+
611659
@patch("sentry.seer.autofix.on_completion_hook.trigger_coding_agent_handoff")
612660
def test_trigger_coding_agent_handoff_calls_function(self, mock_trigger):
613661
"""Test _trigger_coding_agent_handoff calls the trigger function correctly."""
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
from __future__ import annotations
2+
3+
from unittest.mock import MagicMock, patch
4+
5+
from sentry.seer.autofix.coding_agent import IntegrationNotFound
6+
from sentry.seer.autofix.utils import CodingAgentProviderType
7+
from sentry.seer.models.seer_api_models import SeerAutomationHandoffConfiguration
8+
from sentry.testutils.cases import TestCase
9+
10+
11+
class TestTriggerCodingAgentHandoff(TestCase):
12+
def setUp(self) -> None:
13+
super().setUp()
14+
self.organization = self.create_organization()
15+
self.project = self.create_project(organization=self.organization)
16+
self.group = self.create_group(project=self.project)
17+
18+
def _make_handoff_config(self, integration_id: int = 789) -> SeerAutomationHandoffConfiguration:
19+
return SeerAutomationHandoffConfiguration(
20+
handoff_point="root_cause",
21+
target=CodingAgentProviderType.CURSOR_BACKGROUND_AGENT,
22+
integration_id=integration_id,
23+
)
24+
25+
@patch("sentry.seer.autofix.on_completion_hook.set_project_seer_preference")
26+
@patch("sentry.seer.autofix.on_completion_hook.get_project_seer_preferences")
27+
@patch("sentry.seer.autofix.on_completion_hook.trigger_coding_agent_handoff")
28+
def test_not_found_clears_automation_handoff(
29+
self, mock_trigger, mock_get_prefs, mock_set_pref
30+
) -> None:
31+
from sentry.seer.autofix.on_completion_hook import AutofixOnCompletionHook
32+
33+
mock_trigger.side_effect = IntegrationNotFound("Integration not found")
34+
35+
mock_pref = MagicMock()
36+
mock_pref.automation_handoff = self._make_handoff_config()
37+
mock_pref.copy.return_value = mock_pref
38+
mock_get_prefs.return_value = MagicMock(preference=mock_pref)
39+
40+
handoff_config = self._make_handoff_config()
41+
42+
AutofixOnCompletionHook._trigger_coding_agent_handoff(
43+
organization=self.organization,
44+
run_id=1,
45+
group=self.group,
46+
handoff_config=handoff_config,
47+
)
48+
49+
mock_get_prefs.assert_called_once_with(self.group.project_id)
50+
mock_pref.copy.assert_called_once_with(update={"automation_handoff": None})
51+
mock_set_pref.assert_called_once_with(mock_pref)
52+
53+
@patch("sentry.seer.autofix.on_completion_hook.set_project_seer_preference")
54+
@patch("sentry.seer.autofix.on_completion_hook.get_project_seer_preferences")
55+
@patch("sentry.seer.autofix.on_completion_hook.trigger_coding_agent_handoff")
56+
def test_not_found_no_preference_response_does_not_call_set(
57+
self, mock_trigger, mock_get_prefs, mock_set_pref
58+
) -> None:
59+
from sentry.seer.autofix.on_completion_hook import AutofixOnCompletionHook
60+
61+
mock_trigger.side_effect = IntegrationNotFound("Integration not found")
62+
mock_get_prefs.return_value = None
63+
64+
AutofixOnCompletionHook._trigger_coding_agent_handoff(
65+
organization=self.organization,
66+
run_id=1,
67+
group=self.group,
68+
handoff_config=self._make_handoff_config(),
69+
)
70+
71+
mock_set_pref.assert_not_called()

0 commit comments

Comments
 (0)