Skip to content
28 changes: 23 additions & 5 deletions src/sentry/seer/autofix/coding_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,24 @@
from rest_framework.exceptions import APIException, NotFound, PermissionDenied, ValidationError

from sentry import features


class IntegrationNotFound(NotFound):
pass


class OrganizationNotFound(NotFound):
pass


class AutofixStateNotFound(NotFound):
pass


class StateReposNotFound(NotFound):
pass


from sentry.constants import ENABLE_SEER_CODING_DEFAULT, ObjectStatus
from sentry.integrations.claude_code.integration import (
ClaudeCodeIntegrationMetadata,
Expand Down Expand Up @@ -118,15 +136,15 @@ def _validate_and_get_integration(organization, integration_id: int):
)

if not org_integration or org_integration.status != ObjectStatus.ACTIVE:
raise NotFound("Integration not found")
raise IntegrationNotFound("Integration not found")

integration = integration_service.get_integration(
organization_integration_id=org_integration.id,
status=ObjectStatus.ACTIVE,
)

if not integration:
raise NotFound("Integration not found")
raise IntegrationNotFound("Integration not found")

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

if not repos_to_launch:
raise NotFound(
raise StateReposNotFound(
"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."
)

Expand Down Expand Up @@ -426,7 +444,7 @@ def launch_coding_agents_for_run(
try:
organization = Organization.objects.get(id=organization_id)
except Organization.DoesNotExist:
raise NotFound("Organization not found")
raise OrganizationNotFound("Organization not found")

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

autofix_state = _get_autofix_state(run_id, organization)
if autofix_state is None:
raise NotFound("Autofix state not found")
raise AutofixStateNotFound("Autofix state not found")

logger.info(
"coding_agent.launch_request",
Expand Down
50 changes: 49 additions & 1 deletion src/sentry/seer/autofix/on_completion_hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,22 @@
from sentry import features
from sentry.models.group import Group
from sentry.models.organization import Organization
from sentry.models.project import Project
from sentry.seer.autofix.autofix_agent import (
AutofixStep,
trigger_autofix_explorer,
trigger_coding_agent_handoff,
trigger_push_changes,
)
from sentry.seer.autofix.coding_agent import IntegrationNotFound
from sentry.seer.autofix.constants import AutofixReferrer
from sentry.seer.autofix.utils import AutofixStoppingPoint, get_project_seer_preferences
from sentry.seer.autofix.utils import (
AutofixStoppingPoint,
get_project_seer_preferences,
resolve_repository_ids,
set_project_seer_preference,
write_preference_to_sentry_db,
)
from sentry.seer.entrypoints.operator import SeerAutofixOperator, process_autofix_updates
from sentry.seer.explorer.client_models import Artifact
from sentry.seer.explorer.client_utils import fetch_run_status
Expand All @@ -25,6 +33,7 @@
SeerApiResponseValidationError,
SeerAutomationHandoffConfiguration,
)
from sentry.seer.models.seer_api_models import SeerProjectPreference
from sentry.seer.supergroups.embeddings import trigger_supergroups_embedding
from sentry.sentry_apps.metrics import SentryAppEventType
from sentry.sentry_apps.tasks.sentry_apps import broadcast_webhooks_for_organization
Expand Down Expand Up @@ -472,6 +481,35 @@ def _get_handoff_config_if_applicable(

return handoff_config

@classmethod
def _clear_handoff_preference(
cls, project: Project, run_id: int, organization: Organization
) -> None:
"""Clear automation_handoff from project preferences after integration is not found."""
try:
preference_response = get_project_seer_preferences(project.id)
if preference_response and preference_response.preference:
updated_preference = preference_response.preference.copy(
update={"automation_handoff": None}
)
set_project_seer_preference(updated_preference)
Comment thread
sehr-m marked this conversation as resolved.

if features.has("organizations:seer-project-settings-dual-write", organization):
try:
validated_pref = SeerProjectPreference.validate(updated_preference)
resolved_pref = resolve_repository_ids(organization.id, [validated_pref])
write_preference_to_sentry_db(project, resolved_pref[0])
Comment on lines +500 to +501
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit

Suggested change
resolved_pref = resolve_repository_ids(organization.id, [validated_pref])
write_preference_to_sentry_db(project, resolved_pref[0])
resolved_pref = resolve_repository_ids(organization.id, [validated_pref])[0]
write_preference_to_sentry_db(project, resolved_pref)

except Exception:
logger.exception(
"seer.write_preferences.failed",
Copy link
Copy Markdown
Member

@srest2021 srest2021 Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to make this a bit more distinguishable from other logs:

Suggested change
"seer.write_preferences.failed",
"seer.write_preferences.clear_handoff_preference_failed",

extra={"project_id": project.id, "organization_id": organization.id},
)
except (SeerApiError, SeerApiResponseValidationError):
logger.exception(
"autofix.on_completion_hook.clear_handoff_preference_failed",
extra={"run_id": run_id, "organization_id": organization.id},
)

@classmethod
def _trigger_coding_agent_handoff(
cls,
Expand Down Expand Up @@ -508,6 +546,16 @@ def _trigger_coding_agent_handoff(
"failures": len(result.get("failures", [])),
},
)
except IntegrationNotFound:
logger.exception(
"autofix.on_completion_hook.coding_agent_handoff_integration_not_found",
extra={
"run_id": run_id,
"organization_id": organization.id,
"integration_id": handoff_config.integration_id,
},
)
cls._clear_handoff_preference(group.project, run_id, organization)
except Exception:
logger.exception(
"autofix.on_completion_hook.coding_agent_handoff_failed",
Expand Down
60 changes: 56 additions & 4 deletions src/sentry/seer/endpoints/seer_rpc.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
from sentry_protos.snuba.v1.trace_item_attribute_pb2 import AttributeKey, AttributeValue, StrArray
from sentry_protos.snuba.v1.trace_item_filter_pb2 import ComparisonFilter, TraceItemFilter

from sentry import features
from sentry.api.api_owners import ApiOwner
from sentry.api.api_publish_status import ApiPublishStatus
from sentry.api.authentication import AuthenticationSiloLimit, StandardAuthentication
Expand Down Expand Up @@ -73,8 +74,19 @@
get_attribute_values_with_substring,
)
from sentry.seer.autofix.autofix_tools import get_error_event_details, get_profile_details
from sentry.seer.autofix.coding_agent import launch_coding_agents_for_run
from sentry.seer.autofix.utils import AutofixTriggerSource
from sentry.seer.autofix.coding_agent import (
AutofixStateNotFound,
IntegrationNotFound,
OrganizationNotFound,
StateReposNotFound,
launch_coding_agents_for_run,
)
from sentry.seer.autofix.utils import (
AutofixTriggerSource,
get_project_seer_preferences,
resolve_repository_ids,
write_preference_to_sentry_db,
)
from sentry.seer.constants import SEER_SUPPORTED_SCM_PROVIDERS, SeerSCMProvider
from sentry.seer.entrypoints.operator import SeerAutofixOperator, process_autofix_updates
from sentry.seer.explorer.custom_tool_utils import call_custom_tool
Expand All @@ -101,9 +113,10 @@
get_trace_item_attributes,
rpc_get_profile_flamegraph,
rpc_get_trace_waterfall,
)
from sentry.seer.fetch_issues import by_error_type, by_function_name, by_text_query, utils
from sentry.seer.issue_detection import create_issue_occurrence
from sentry.seer.models.seer_api_models import SeerProjectPreference

Check warning on line 119 in src/sentry/seer/endpoints/seer_rpc.py

View check run for this annotation

@sentry/warden / warden: sentry-security

Missing org-scoping when modifying project preferences in IntegrationNotFound handler

In the `trigger_coding_agent_launch` function, when handling `IntegrationNotFound`, the code fetches a project using `Project.objects.get_from_cache(id=project_id)` without validating that the project belongs to the specified `organization_id`. Subsequently, `write_preference_to_sentry_db(project, ...)` modifies that project's preferences. If Seer sends mismatched `project_id` and `organization_id` (due to a bug or data inconsistency), preferences for a project in a different organization could be modified. While this RPC endpoint is only accessible to the trusted Seer microservice, defense-in-depth requires validating the project belongs to the organization before modifying its state.
from sentry.seer.utils import filter_repo_by_provider
from sentry.sentry_apps.metrics import SentryAppEventType
from sentry.sentry_apps.tasks.sentry_apps import broadcast_webhooks_for_organization
Expand Down Expand Up @@ -565,6 +578,7 @@
def trigger_coding_agent_launch(
*,
organization_id: int,
project_id: int | None = None,
integration_id: int,
run_id: int,
trigger_source: str = "solution",
Expand All @@ -579,7 +593,7 @@
trigger_source: Either "root_cause" or "solution" (default: "solution")

Returns:
dict: {"success": bool}
dict: {"success": bool, "error_code": str | None}
"""
try:
launch_coding_agents_for_run(
Expand All @@ -589,7 +603,45 @@
trigger_source=AutofixTriggerSource(trigger_source),
)
return {"success": True}
except (NotFound, PermissionDenied, ValidationError, APIException):
except IntegrationNotFound:
logger.exception(
"coding_agent.rpc_launch_error",
extra={
"organization_id": organization_id,
"integration_id": integration_id,
"run_id": run_id,
},
)
try:
project = Project.objects.get_from_cache(id=project_id)
Comment on lines +615 to +616
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: If project_id is None in trigger_coding_agent_launch, a call to Project.objects.get_from_cache fails silently, preventing the handoff preference from being cleared.
Severity: MEDIUM

Suggested Fix

Add a guard clause to check if project_id is not None before calling Project.objects.get_from_cache(id=project_id). The logic to clear the handoff preference should only be executed when a valid project_id is provided.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: src/sentry/seer/endpoints/seer_rpc.py#L615-L616

Potential issue: In the `trigger_coding_agent_launch` function, the `project_id`
parameter is optional and can be `None`. Within the `except IntegrationNotFound` block,
the code attempts to fetch a project using
`Project.objects.get_from_cache(id=project_id)`. If `project_id` is `None`, this call
raises an exception. This exception is then silently caught by a broad `except
Exception` block, which prevents the intended logic—clearing the user's handoff
preference from the Sentry database—from running. This silent failure undermines the
fix's goal of cleaning up stale integration references, which can occur if the calling
service sends a request without a `project_id`.

Did we get this right? 👍 / 👎 to inform future reviews.

organization = Organization.objects.get_from_cache(id=organization_id)
if features.has("organizations:seer-project-settings-dual-write", organization):
preference_response = get_project_seer_preferences(project.id)
if preference_response and preference_response.preference:
updated_preference = preference_response.preference.copy(
update={"automation_handoff": None}
)
validated_pref = SeerProjectPreference.validate(updated_preference)
resolved_pref = resolve_repository_ids(organization.id, [validated_pref])
write_preference_to_sentry_db(project, resolved_pref[0])
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same nit here, just moving [0] next to resolve_repository_ids

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we leaving the Seer DB pref cleanup on Seer side? Wondering why not reuse _clear_handoff_preference here, to guarantee we always hit the same code path for both DBs, and remove the Seer side cleanup.

except Exception:
logger.exception(
"coding_agent.clear_handoff_preference_failed",
extra={
"project_id": project_id,
"organization_id": organization_id,
"run_id": run_id,
},
)
return {"success": False, "error_code": "integration_not_found"}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nullable project_id causes silent cleanup failure

Medium Severity

The project_id parameter defaults to None, but the IntegrationNotFound handler at line 616 calls Project.objects.get_from_cache(id=project_id) which will throw when project_id is None. The broad except Exception block silently swallows this error, meaning the Sentry DB preference cleanup — the core purpose of this fix — never executes. The function still returns the error_code, masking the fact that cleanup failed. Since this is an RPC endpoint with project_id newly added as optional, any caller that omits it will silently skip the stale preference cleanup.

Fix in Cursor Fix in Web

except (
OrganizationNotFound,
AutofixStateNotFound,
StateReposNotFound,
PermissionDenied,
ValidationError,
APIException,
):
Comment thread
sehr-m marked this conversation as resolved.
logger.exception(
"coding_agent.rpc_launch_error",
extra={
Expand Down
Empty file.
48 changes: 48 additions & 0 deletions tests/sentry/seer/autofix/test_autofix_on_completion_hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -608,6 +608,54 @@ def test_maybe_continue_pipeline_triggers_handoff_when_configured(

mock_trigger_handoff.assert_called_once()

@patch("sentry.seer.autofix.on_completion_hook.set_project_seer_preference")
@patch("sentry.seer.autofix.on_completion_hook.get_project_seer_preferences")
@patch("sentry.seer.autofix.on_completion_hook.trigger_coding_agent_handoff")
def test_trigger_coding_agent_handoff_clears_preference_on_not_found(
self, mock_trigger, mock_get_prefs, mock_set_pref
):
"""When IntegrationNotFound is raised, automation_handoff is cleared from preferences."""
from sentry.seer.autofix.coding_agent import IntegrationNotFound

mock_trigger.side_effect = IntegrationNotFound()
handoff_config = self._make_handoff_config()
Comment thread
sehr-m marked this conversation as resolved.
mock_get_prefs.return_value = self._make_preference_response(handoff_config=handoff_config)

AutofixOnCompletionHook._trigger_coding_agent_handoff(
organization=self.organization,
run_id=123,
group=self.group,
handoff_config=handoff_config,
)

mock_set_pref.assert_called_once()
updated = mock_set_pref.call_args.args[0]
assert updated.automation_handoff is None

@patch("sentry.seer.autofix.on_completion_hook.set_project_seer_preference")
@patch("sentry.seer.autofix.on_completion_hook.get_project_seer_preferences")
@patch("sentry.seer.autofix.on_completion_hook.trigger_coding_agent_handoff")
def test_trigger_coding_agent_handoff_not_found_seer_api_error_does_not_raise(
self, mock_trigger, mock_get_prefs, mock_set_pref
):
"""A SeerApiError during preference-clearing after IntegrationNotFound should not propagate."""
from sentry.seer.autofix.coding_agent import IntegrationNotFound
from sentry.seer.models import SeerApiError

mock_trigger.side_effect = IntegrationNotFound()
mock_get_prefs.side_effect = SeerApiError("seer unavailable", 503)
handoff_config = self._make_handoff_config()

# Should not raise
AutofixOnCompletionHook._trigger_coding_agent_handoff(
organization=self.organization,
run_id=123,
group=self.group,
handoff_config=handoff_config,
)

mock_set_pref.assert_not_called()

@patch("sentry.seer.autofix.on_completion_hook.trigger_coding_agent_handoff")
def test_trigger_coding_agent_handoff_calls_function(self, mock_trigger):
"""Test _trigger_coding_agent_handoff calls the trigger function correctly."""
Expand Down
71 changes: 71 additions & 0 deletions tests/sentry/seer/autofix/test_on_completion_hook.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
from __future__ import annotations

from unittest.mock import MagicMock, patch

from sentry.seer.autofix.coding_agent import IntegrationNotFound
from sentry.seer.autofix.utils import CodingAgentProviderType
from sentry.seer.models.seer_api_models import SeerAutomationHandoffConfiguration
from sentry.testutils.cases import TestCase


class TestTriggerCodingAgentHandoff(TestCase):
def setUp(self) -> None:
super().setUp()
self.organization = self.create_organization()
self.project = self.create_project(organization=self.organization)
self.group = self.create_group(project=self.project)

def _make_handoff_config(self, integration_id: int = 789) -> SeerAutomationHandoffConfiguration:
return SeerAutomationHandoffConfiguration(
handoff_point="root_cause",
target=CodingAgentProviderType.CURSOR_BACKGROUND_AGENT,
integration_id=integration_id,
)

@patch("sentry.seer.autofix.on_completion_hook.set_project_seer_preference")
@patch("sentry.seer.autofix.on_completion_hook.get_project_seer_preferences")
@patch("sentry.seer.autofix.on_completion_hook.trigger_coding_agent_handoff")
def test_not_found_clears_automation_handoff(
self, mock_trigger, mock_get_prefs, mock_set_pref
) -> None:
from sentry.seer.autofix.on_completion_hook import AutofixOnCompletionHook

mock_trigger.side_effect = IntegrationNotFound("Integration not found")

mock_pref = MagicMock()
mock_pref.automation_handoff = self._make_handoff_config()
mock_pref.copy.return_value = mock_pref
mock_get_prefs.return_value = MagicMock(preference=mock_pref)

handoff_config = self._make_handoff_config()

AutofixOnCompletionHook._trigger_coding_agent_handoff(
organization=self.organization,
run_id=1,
group=self.group,
handoff_config=handoff_config,
)

mock_get_prefs.assert_called_once_with(self.group.project_id)
mock_pref.copy.assert_called_once_with(update={"automation_handoff": None})
mock_set_pref.assert_called_once_with(mock_pref)

@patch("sentry.seer.autofix.on_completion_hook.set_project_seer_preference")
@patch("sentry.seer.autofix.on_completion_hook.get_project_seer_preferences")
@patch("sentry.seer.autofix.on_completion_hook.trigger_coding_agent_handoff")
def test_not_found_no_preference_response_does_not_call_set(
self, mock_trigger, mock_get_prefs, mock_set_pref
) -> None:
from sentry.seer.autofix.on_completion_hook import AutofixOnCompletionHook

mock_trigger.side_effect = IntegrationNotFound("Integration not found")
mock_get_prefs.return_value = None

AutofixOnCompletionHook._trigger_coding_agent_handoff(
organization=self.organization,
run_id=1,
group=self.group,
handoff_config=self._make_handoff_config(),
)

mock_set_pref.assert_not_called()
Loading
Loading