From 6cddc46986a8aa62ef716f57c4a57f828072e8ab Mon Sep 17 00:00:00 2001 From: Trevor Elkins Date: Tue, 2 Jun 2026 18:09:20 -0400 Subject: [PATCH 01/19] feat(seer): Add deliver_feature_result RPC for Seer agent features Add a generic RPC endpoint that Seer can call when an agent feature run completes. Features register handlers in DELIVERY_HANDLERS keyed by feature_id. Co-Authored-By: Claude Opus 4.5 --- src/sentry/seer/agent/feature_delivery.py | 14 +++++++++++++ src/sentry/seer/endpoints/seer_rpc.py | 24 +++++++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 src/sentry/seer/agent/feature_delivery.py diff --git a/src/sentry/seer/agent/feature_delivery.py b/src/sentry/seer/agent/feature_delivery.py new file mode 100644 index 00000000000000..941bdf69cd55df --- /dev/null +++ b/src/sentry/seer/agent/feature_delivery.py @@ -0,0 +1,14 @@ +"""Registry for Seer feature result delivery handlers.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any, Literal + +FeatureRunStatus = Literal["completed", "error"] + +FeatureDeliveryFn = Callable[ + [int | str, FeatureRunStatus, dict[str, Any] | None, int, str | None, int], None +] + +DELIVERY_HANDLERS: dict[str, FeatureDeliveryFn] = {} diff --git a/src/sentry/seer/endpoints/seer_rpc.py b/src/sentry/seer/endpoints/seer_rpc.py index 5fea1f8c74abe2..2c5ffee73c94d2 100644 --- a/src/sentry/seer/endpoints/seer_rpc.py +++ b/src/sentry/seer/endpoints/seer_rpc.py @@ -60,6 +60,7 @@ from sentry.search.eap.types import SearchResolverConfig, SupportedTraceItemType from sentry.search.events.types import SnubaParams from sentry.seer.agent.custom_tool_utils import call_custom_tool +from sentry.seer.agent.feature_delivery import DELIVERY_HANDLERS, FeatureRunStatus from sentry.seer.agent.index_data import ( rpc_get_issues_for_transaction, rpc_get_profiles_for_trace, @@ -956,6 +957,28 @@ def bulk_get_project_preferences( return {str(project_id): pref.dict() for project_id, pref in preferences.items()} +def deliver_feature_result( + *, + feature_id: str, + ref: int | str, + status: FeatureRunStatus, + seer_run_id: int, + organization_id: int, + result: dict[str, Any] | None = None, + error: str | None = None, +) -> None: + """Dispatch a feature result from Seer to the registered handler.""" + handler = DELIVERY_HANDLERS.get(feature_id) + if handler is None: + logger.warning( + "feature_delivery.unknown_feature_id", + extra={"feature_id": feature_id, "ref": ref, "seer_run_id": seer_run_id}, + ) + return + + handler(ref, status, result, seer_run_id, error, organization_id) + + seer_method_registry: dict[str, Callable] = { # return type must be serialized # Common to Seer features "get_github_enterprise_integration_config": get_github_enterprise_integration_config, @@ -1013,6 +1036,7 @@ def bulk_get_project_preferences( "get_repository_definition": get_repository_definition, "call_custom_tool": call_custom_tool, "call_on_completion_hook": call_on_completion_hook, + "deliver_feature_result": deliver_feature_result, "get_log_attributes_for_trace": get_log_attributes_for_trace, "get_metric_attributes_for_trace": get_metric_attributes_for_trace, "get_baseline_tag_distribution": get_baseline_tag_distribution, From 3130d25369b5ef543266e98ec0b56f9b583110af Mon Sep 17 00:00:00 2001 From: Trevor Elkins Date: Tue, 2 Jun 2026 18:13:53 -0400 Subject: [PATCH 02/19] feat(seer): Add night_shift delivery handler Register the night_shift handler in DELIVERY_HANDLERS to receive triage results from Seer. This runs in parallel with the existing night shift code. Co-Authored-By: Claude Opus 4.5 --- src/sentry/seer/endpoints/seer_rpc.py | 1 + src/sentry/seer/night_shift/__init__.py | 3 + src/sentry/seer/night_shift/delivery.py | 239 ++++++++++++++++++++++++ src/sentry/seer/night_shift/models.py | 22 +++ 4 files changed, 265 insertions(+) create mode 100644 src/sentry/seer/night_shift/__init__.py create mode 100644 src/sentry/seer/night_shift/delivery.py create mode 100644 src/sentry/seer/night_shift/models.py diff --git a/src/sentry/seer/endpoints/seer_rpc.py b/src/sentry/seer/endpoints/seer_rpc.py index 2c5ffee73c94d2..3bbda2a4cdafda 100644 --- a/src/sentry/seer/endpoints/seer_rpc.py +++ b/src/sentry/seer/endpoints/seer_rpc.py @@ -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 +import sentry.seer.night_shift # noqa: F401 - registers delivery handler from sentry import features from sentry.api.api_owners import ApiOwner from sentry.api.api_publish_status import ApiPublishStatus diff --git a/src/sentry/seer/night_shift/__init__.py b/src/sentry/seer/night_shift/__init__.py new file mode 100644 index 00000000000000..3a2f0d1609dead --- /dev/null +++ b/src/sentry/seer/night_shift/__init__.py @@ -0,0 +1,3 @@ +"""Night shift feature delivery handler registration.""" + +from sentry.seer.night_shift import delivery # noqa: F401 diff --git a/src/sentry/seer/night_shift/delivery.py b/src/sentry/seer/night_shift/delivery.py new file mode 100644 index 00000000000000..bc067acfd6a06a --- /dev/null +++ b/src/sentry/seer/night_shift/delivery.py @@ -0,0 +1,239 @@ +"""Delivery handler for night_shift feature results from Seer.""" + +from __future__ import annotations + +import logging +from collections.abc import Mapping +from typing import Any + +import sentry_sdk + +from sentry.constants import SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT +from sentry.models.group import Group +from sentry.models.organization import Organization +from sentry.seer.agent.feature_delivery import DELIVERY_HANDLERS, FeatureRunStatus +from sentry.seer.autofix.autofix_agent import AutofixStep, trigger_autofix_agent +from sentry.seer.autofix.constants import SeerAutomationSource +from sentry.seer.autofix.issue_summary import referrer_map +from sentry.seer.autofix.utils import AutofixStoppingPoint, read_preference_from_sentry_db +from sentry.seer.models.night_shift import SeerNightShiftRun, SeerNightShiftRunResult +from sentry.seer.models.run import SeerRun +from sentry.seer.models.workflow import SeerWorkflowStrategy +from sentry.seer.night_shift.models import TriageResponse, TriageVerdict +from sentry.tasks.seer.night_shift.models import TriageAction +from sentry.tasks.seer.night_shift.skip_cache import mark_skipped + +logger = logging.getLogger(__name__) + +_FEATURE_ID = "night_shift" + + +def deliver_night_shift_result( + ref: int | str, + status: FeatureRunStatus, + result: dict[str, Any] | None, + seer_run_id: int, + error: str | None, + organization_id: int, +) -> None: + """Process a night_shift result from Seer.""" + try: + run = SeerNightShiftRun.objects.select_related("organization").get(id=int(ref)) + except SeerNightShiftRun.DoesNotExist: + logger.warning( + "night_shift.delivery.missing_run", + extra={"ref": ref, "seer_run_id": seer_run_id}, + ) + return + + if run.organization_id != organization_id: + logger.warning( + "night_shift.delivery.org_mismatch", + extra={ + "ref": ref, + "expected_org_id": run.organization_id, + "actual_org_id": organization_id, + }, + ) + return + + extras_update: dict[str, object] = { + **(run.extras or {}), + "agent_run_id": seer_run_id, + } + if error: + extras_update["error_message"] = error + run.update(extras=extras_update) + + log_extra: dict[str, object] = { + "organization_id": run.organization_id, + "run_id": run.id, + "agent_run_id": seer_run_id, + } + + if status == "error" or result is None: + sentry_sdk.metrics.incr( + "night_shift.triage_error", + tags={"error_type": "delivery_error" if status == "error" else "no_artifact"}, + ) + logger.warning("night_shift.delivery.no_result", extra={**log_extra, "status": status}) + return + + try: + triage_response = TriageResponse.parse_obj(result) + except Exception: + sentry_sdk.metrics.incr("night_shift.triage_error", tags={"error_type": "invalid_artifact"}) + logger.exception("night_shift.delivery.invalid_result", extra=log_extra) + return + + options = (run.extras or {}).get("options") or {} + dry_run = bool(options.get("dry_run", False)) + + _process_verdicts( + run=run, + organization=run.organization, + triage_response=triage_response, + dry_run=dry_run, + log_extra=log_extra, + ) + + +def _process_verdicts( + *, + run: SeerNightShiftRun, + organization: Organization, + triage_response: TriageResponse, + dry_run: bool, + log_extra: Mapping[str, object], +) -> None: + """Mark SKIPs, fire autofix for fixable verdicts, persist result rows.""" + group_ids = [v.group_id for v in triage_response.verdicts] + groups_by_id: dict[int, Group] = { + g.id: g + for g in Group.objects.filter( + id__in=group_ids, project__organization_id=organization.id + ).select_related("project") + } + + unknown_group_ids = [gid for gid in group_ids if gid not in groups_by_id] + if unknown_group_ids: + logger.warning( + "night_shift.delivery.unknown_group_ids", + extra={**log_extra, "unknown_group_ids": unknown_group_ids}, + ) + + for v in triage_response.verdicts: + if v.action == TriageAction.SKIP and v.group_id in groups_by_id: + mark_skipped(v.group_id) + + fixable_verdicts = [ + v + for v in triage_response.verdicts + if v.action in (TriageAction.AUTOFIX, TriageAction.ROOT_CAUSE_ONLY) + and v.group_id in groups_by_id + ] + + sentry_sdk.metrics.distribution( + "night_shift.candidates_selected", len(triage_response.verdicts) + ) + + results: list[SeerNightShiftRunResult] = [] + if not dry_run: + results = _trigger_autofix_for_fixable( + run=run, + organization=organization, + verdicts=fixable_verdicts, + groups_by_id=groups_by_id, + log_extra=log_extra, + ) + + seer_run_id_by_group = {r.group_id: r.seer_run_id for r in results} + logger.info( + "night_shift.candidates_selected", + extra={ + **log_extra, + "num_verdicts": len(triage_response.verdicts), + "dry_run": dry_run, + "candidates": [ + { + "group_id": v.group_id, + "action": v.action, + "seer_run_id": seer_run_id_by_group.get(v.group_id), + } + for v in triage_response.verdicts + ], + }, + ) + + +def _trigger_autofix_for_fixable( + *, + run: SeerNightShiftRun, + organization: Organization, + verdicts: list[TriageVerdict], + groups_by_id: dict[int, Group], + log_extra: Mapping[str, object], +) -> list[SeerNightShiftRunResult]: + if not verdicts: + return [] + + referrer = referrer_map[SeerAutomationSource.NIGHT_SHIFT] + project_ids = {groups_by_id[v.group_id].project_id for v in verdicts} + project_by_id = {g.project_id: g.project for g in groups_by_id.values()} + + for project in project_by_id.values(): + project.organization = organization + + stopping_point_by_project_id = { + pid: AutofixStoppingPoint( + read_preference_from_sentry_db(project_by_id[pid]).automated_run_stopping_point + or SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT + ) + for pid in project_ids + } + + results: list[SeerNightShiftRunResult] = [] + for v in verdicts: + group = groups_by_id[v.group_id] + stopping_point = ( + AutofixStoppingPoint.ROOT_CAUSE + if v.action == TriageAction.ROOT_CAUSE_ONLY + else stopping_point_by_project_id[group.project_id] + ) + user_context = ( + f"Night-shift triage already investigated this issue and concluded:\n{v.reason}" + if v.reason + else None + ) + try: + seer_run_id = trigger_autofix_agent( + group=group, + step=AutofixStep.ROOT_CAUSE, + referrer=referrer, + stopping_point=stopping_point, + user_context=user_context, + ) + except Exception: + logger.exception( + "night_shift.autofix_trigger_failed", + extra={**log_extra, "group_id": group.id}, + ) + continue + + result_seer_run = SeerRun.objects.filter(seer_run_state_id=seer_run_id).first() + results.append( + SeerNightShiftRunResult( + run=run, + kind=SeerWorkflowStrategy.AGENTIC_TRIAGE, + group=group, + seer_run_id=str(seer_run_id), + result_seer_run=result_seer_run, + ) + ) + + SeerNightShiftRunResult.objects.bulk_create(results) + sentry_sdk.metrics.incr("night_shift.autofix_triggered", amount=len(results)) + return results + + +DELIVERY_HANDLERS[_FEATURE_ID] = deliver_night_shift_result diff --git a/src/sentry/seer/night_shift/models.py b/src/sentry/seer/night_shift/models.py new file mode 100644 index 00000000000000..edbb21b25feb2b --- /dev/null +++ b/src/sentry/seer/night_shift/models.py @@ -0,0 +1,22 @@ +"""Wire types for the night_shift feature result payload from Seer.""" + +from __future__ import annotations + +from pydantic import BaseModel + +from sentry.tasks.seer.night_shift.models import TriageAction + + +class _Base(BaseModel): + class Config: + extra = "ignore" + + +class TriageVerdict(_Base): + group_id: int + action: TriageAction + reason: str + + +class TriageResponse(_Base): + verdicts: list[TriageVerdict] From 2e44223ebc4cd8033f2940e6f6711ac5acc46c19 Mon Sep 17 00:00:00 2001 From: Trevor Elkins Date: Tue, 2 Jun 2026 18:18:09 -0400 Subject: [PATCH 03/19] fix(seer): Look up night shift run by SeerRun UUID Use seer_run__uuid to find the SeerNightShiftRun since ref is the external idempotency key from SeerRun.uuid. Co-Authored-By: Claude Opus 4.5 --- src/sentry/seer/night_shift/delivery.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/sentry/seer/night_shift/delivery.py b/src/sentry/seer/night_shift/delivery.py index bc067acfd6a06a..8d54de2da21911 100644 --- a/src/sentry/seer/night_shift/delivery.py +++ b/src/sentry/seer/night_shift/delivery.py @@ -38,7 +38,9 @@ def deliver_night_shift_result( ) -> None: """Process a night_shift result from Seer.""" try: - run = SeerNightShiftRun.objects.select_related("organization").get(id=int(ref)) + run = SeerNightShiftRun.objects.select_related("organization", "seer_run").get( + seer_run__uuid=ref + ) except SeerNightShiftRun.DoesNotExist: logger.warning( "night_shift.delivery.missing_run", From 1c51a11ffbd0893a8aa0ee4a2f158e422577c93e Mon Sep 17 00:00:00 2001 From: Trevor Elkins Date: Tue, 2 Jun 2026 18:24:37 -0400 Subject: [PATCH 04/19] ref(seer): Make delivery handler registry explicit Register handlers directly in DELIVERY_HANDLERS instead of relying on import side effects. Co-Authored-By: Claude Opus 4.5 --- src/sentry/seer/agent/feature_delivery.py | 6 +++++- src/sentry/seer/endpoints/seer_rpc.py | 1 - src/sentry/seer/night_shift/__init__.py | 3 --- src/sentry/seer/night_shift/delivery.py | 7 +------ 4 files changed, 6 insertions(+), 11 deletions(-) delete mode 100644 src/sentry/seer/night_shift/__init__.py diff --git a/src/sentry/seer/agent/feature_delivery.py b/src/sentry/seer/agent/feature_delivery.py index 941bdf69cd55df..da0030a9b9655e 100644 --- a/src/sentry/seer/agent/feature_delivery.py +++ b/src/sentry/seer/agent/feature_delivery.py @@ -5,10 +5,14 @@ from collections.abc import Callable from typing import Any, Literal +from sentry.seer.night_shift.delivery import deliver_night_shift_result + FeatureRunStatus = Literal["completed", "error"] FeatureDeliveryFn = Callable[ [int | str, FeatureRunStatus, dict[str, Any] | None, int, str | None, int], None ] -DELIVERY_HANDLERS: dict[str, FeatureDeliveryFn] = {} +DELIVERY_HANDLERS: dict[str, FeatureDeliveryFn] = { + "night_shift": deliver_night_shift_result, +} diff --git a/src/sentry/seer/endpoints/seer_rpc.py b/src/sentry/seer/endpoints/seer_rpc.py index 3bbda2a4cdafda..2c5ffee73c94d2 100644 --- a/src/sentry/seer/endpoints/seer_rpc.py +++ b/src/sentry/seer/endpoints/seer_rpc.py @@ -36,7 +36,6 @@ 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 -import sentry.seer.night_shift # noqa: F401 - registers delivery handler from sentry import features from sentry.api.api_owners import ApiOwner from sentry.api.api_publish_status import ApiPublishStatus diff --git a/src/sentry/seer/night_shift/__init__.py b/src/sentry/seer/night_shift/__init__.py deleted file mode 100644 index 3a2f0d1609dead..00000000000000 --- a/src/sentry/seer/night_shift/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Night shift feature delivery handler registration.""" - -from sentry.seer.night_shift import delivery # noqa: F401 diff --git a/src/sentry/seer/night_shift/delivery.py b/src/sentry/seer/night_shift/delivery.py index 8d54de2da21911..a2cce0b5104c0f 100644 --- a/src/sentry/seer/night_shift/delivery.py +++ b/src/sentry/seer/night_shift/delivery.py @@ -11,7 +11,7 @@ from sentry.constants import SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT from sentry.models.group import Group from sentry.models.organization import Organization -from sentry.seer.agent.feature_delivery import DELIVERY_HANDLERS, FeatureRunStatus +from sentry.seer.agent.feature_delivery import FeatureRunStatus from sentry.seer.autofix.autofix_agent import AutofixStep, trigger_autofix_agent from sentry.seer.autofix.constants import SeerAutomationSource from sentry.seer.autofix.issue_summary import referrer_map @@ -25,8 +25,6 @@ logger = logging.getLogger(__name__) -_FEATURE_ID = "night_shift" - def deliver_night_shift_result( ref: int | str, @@ -236,6 +234,3 @@ def _trigger_autofix_for_fixable( SeerNightShiftRunResult.objects.bulk_create(results) sentry_sdk.metrics.incr("night_shift.autofix_triggered", amount=len(results)) return results - - -DELIVERY_HANDLERS[_FEATURE_ID] = deliver_night_shift_result From c07574c38f01dbb17ad567c60e318891ea0fc2a9 Mon Sep 17 00:00:00 2001 From: Trevor Elkins Date: Tue, 2 Jun 2026 18:26:38 -0400 Subject: [PATCH 05/19] fix(seer): Add seer prefix to feature_delivery log key Co-Authored-By: Claude Opus 4.5 --- src/sentry/seer/endpoints/seer_rpc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sentry/seer/endpoints/seer_rpc.py b/src/sentry/seer/endpoints/seer_rpc.py index 2c5ffee73c94d2..6a27674fba8005 100644 --- a/src/sentry/seer/endpoints/seer_rpc.py +++ b/src/sentry/seer/endpoints/seer_rpc.py @@ -971,7 +971,7 @@ def deliver_feature_result( handler = DELIVERY_HANDLERS.get(feature_id) if handler is None: logger.warning( - "feature_delivery.unknown_feature_id", + "seer.feature_delivery.unknown_feature_id", extra={"feature_id": feature_id, "ref": ref, "seer_run_id": seer_run_id}, ) return From 129553a20d23b7366b9efb60d68687675656dfff Mon Sep 17 00:00:00 2001 From: Trevor Elkins Date: Tue, 2 Jun 2026 18:27:14 -0400 Subject: [PATCH 06/19] ref(seer): Use Protocol for FeatureDeliveryFn type Co-Authored-By: Claude Opus 4.5 --- src/sentry/seer/agent/feature_delivery.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/sentry/seer/agent/feature_delivery.py b/src/sentry/seer/agent/feature_delivery.py index da0030a9b9655e..ca08d03af8c56a 100644 --- a/src/sentry/seer/agent/feature_delivery.py +++ b/src/sentry/seer/agent/feature_delivery.py @@ -2,16 +2,24 @@ from __future__ import annotations -from collections.abc import Callable -from typing import Any, Literal +from typing import Any, Literal, Protocol from sentry.seer.night_shift.delivery import deliver_night_shift_result FeatureRunStatus = Literal["completed", "error"] -FeatureDeliveryFn = Callable[ - [int | str, FeatureRunStatus, dict[str, Any] | None, int, str | None, int], None -] + +class FeatureDeliveryFn(Protocol): + def __call__( + self, + ref: int | str, + status: FeatureRunStatus, + result: dict[str, Any] | None, + seer_run_id: int, + error: str | None, + organization_id: int, + ) -> None: ... + DELIVERY_HANDLERS: dict[str, FeatureDeliveryFn] = { "night_shift": deliver_night_shift_result, From 0338b845b2d1f207176bf1df09b77adf4ca56055 Mon Sep 17 00:00:00 2001 From: Trevor Elkins Date: Tue, 2 Jun 2026 18:31:51 -0400 Subject: [PATCH 07/19] ref(seer): Simplify feature delivery params Rename ref to run_uuid and remove seer_run_id since we can get it from SeerRun if needed. Co-Authored-By: Claude Opus 4.5 --- src/sentry/seer/agent/feature_delivery.py | 3 +-- src/sentry/seer/endpoints/seer_rpc.py | 7 +++---- src/sentry/seer/night_shift/delivery.py | 17 +++++------------ 3 files changed, 9 insertions(+), 18 deletions(-) diff --git a/src/sentry/seer/agent/feature_delivery.py b/src/sentry/seer/agent/feature_delivery.py index ca08d03af8c56a..644ba243f5d94e 100644 --- a/src/sentry/seer/agent/feature_delivery.py +++ b/src/sentry/seer/agent/feature_delivery.py @@ -12,10 +12,9 @@ class FeatureDeliveryFn(Protocol): def __call__( self, - ref: int | str, + run_uuid: str, status: FeatureRunStatus, result: dict[str, Any] | None, - seer_run_id: int, error: str | None, organization_id: int, ) -> None: ... diff --git a/src/sentry/seer/endpoints/seer_rpc.py b/src/sentry/seer/endpoints/seer_rpc.py index 6a27674fba8005..c1041b71841f89 100644 --- a/src/sentry/seer/endpoints/seer_rpc.py +++ b/src/sentry/seer/endpoints/seer_rpc.py @@ -960,9 +960,8 @@ def bulk_get_project_preferences( def deliver_feature_result( *, feature_id: str, - ref: int | str, + run_uuid: str, status: FeatureRunStatus, - seer_run_id: int, organization_id: int, result: dict[str, Any] | None = None, error: str | None = None, @@ -972,11 +971,11 @@ def deliver_feature_result( if handler is None: logger.warning( "seer.feature_delivery.unknown_feature_id", - extra={"feature_id": feature_id, "ref": ref, "seer_run_id": seer_run_id}, + extra={"feature_id": feature_id, "run_uuid": run_uuid}, ) return - handler(ref, status, result, seer_run_id, error, organization_id) + handler(run_uuid, status, result, error, organization_id) seer_method_registry: dict[str, Callable] = { # return type must be serialized diff --git a/src/sentry/seer/night_shift/delivery.py b/src/sentry/seer/night_shift/delivery.py index a2cce0b5104c0f..816db7a1921e00 100644 --- a/src/sentry/seer/night_shift/delivery.py +++ b/src/sentry/seer/night_shift/delivery.py @@ -27,22 +27,21 @@ def deliver_night_shift_result( - ref: int | str, + run_uuid: str, status: FeatureRunStatus, result: dict[str, Any] | None, - seer_run_id: int, error: str | None, organization_id: int, ) -> None: """Process a night_shift result from Seer.""" try: run = SeerNightShiftRun.objects.select_related("organization", "seer_run").get( - seer_run__uuid=ref + seer_run__uuid=run_uuid ) except SeerNightShiftRun.DoesNotExist: logger.warning( "night_shift.delivery.missing_run", - extra={"ref": ref, "seer_run_id": seer_run_id}, + extra={"run_uuid": run_uuid}, ) return @@ -50,25 +49,19 @@ def deliver_night_shift_result( logger.warning( "night_shift.delivery.org_mismatch", extra={ - "ref": ref, + "run_uuid": run_uuid, "expected_org_id": run.organization_id, "actual_org_id": organization_id, }, ) return - extras_update: dict[str, object] = { - **(run.extras or {}), - "agent_run_id": seer_run_id, - } if error: - extras_update["error_message"] = error - run.update(extras=extras_update) + run.update(extras={**(run.extras or {}), "error_message": error}) log_extra: dict[str, object] = { "organization_id": run.organization_id, "run_id": run.id, - "agent_run_id": seer_run_id, } if status == "error" or result is None: From ccc50ca7ea2765ab30a536902de0dd03653222a9 Mon Sep 17 00:00:00 2001 From: Trevor Elkins Date: Tue, 2 Jun 2026 18:34:43 -0400 Subject: [PATCH 08/19] fix(seer): Reorder params and add org_id to query Put organization_id first and include it in the query for safety. Co-Authored-By: Claude Opus 4.5 --- src/sentry/seer/agent/feature_delivery.py | 2 +- src/sentry/seer/endpoints/seer_rpc.py | 4 ++-- src/sentry/seer/night_shift/delivery.py | 18 ++++-------------- 3 files changed, 7 insertions(+), 17 deletions(-) diff --git a/src/sentry/seer/agent/feature_delivery.py b/src/sentry/seer/agent/feature_delivery.py index 644ba243f5d94e..2617fb0cf157e6 100644 --- a/src/sentry/seer/agent/feature_delivery.py +++ b/src/sentry/seer/agent/feature_delivery.py @@ -12,11 +12,11 @@ class FeatureDeliveryFn(Protocol): def __call__( self, + organization_id: int, run_uuid: str, status: FeatureRunStatus, result: dict[str, Any] | None, error: str | None, - organization_id: int, ) -> None: ... diff --git a/src/sentry/seer/endpoints/seer_rpc.py b/src/sentry/seer/endpoints/seer_rpc.py index c1041b71841f89..9b60818035800a 100644 --- a/src/sentry/seer/endpoints/seer_rpc.py +++ b/src/sentry/seer/endpoints/seer_rpc.py @@ -959,10 +959,10 @@ def bulk_get_project_preferences( def deliver_feature_result( *, + organization_id: int, feature_id: str, run_uuid: str, status: FeatureRunStatus, - organization_id: int, result: dict[str, Any] | None = None, error: str | None = None, ) -> None: @@ -975,7 +975,7 @@ def deliver_feature_result( ) return - handler(run_uuid, status, result, error, organization_id) + handler(organization_id, run_uuid, status, result, error) seer_method_registry: dict[str, Callable] = { # return type must be serialized diff --git a/src/sentry/seer/night_shift/delivery.py b/src/sentry/seer/night_shift/delivery.py index 816db7a1921e00..6cd658acf42a4c 100644 --- a/src/sentry/seer/night_shift/delivery.py +++ b/src/sentry/seer/night_shift/delivery.py @@ -27,32 +27,22 @@ def deliver_night_shift_result( + organization_id: int, run_uuid: str, status: FeatureRunStatus, result: dict[str, Any] | None, error: str | None, - organization_id: int, ) -> None: """Process a night_shift result from Seer.""" try: run = SeerNightShiftRun.objects.select_related("organization", "seer_run").get( - seer_run__uuid=run_uuid + organization_id=organization_id, + seer_run__uuid=run_uuid, ) except SeerNightShiftRun.DoesNotExist: logger.warning( "night_shift.delivery.missing_run", - extra={"run_uuid": run_uuid}, - ) - return - - if run.organization_id != organization_id: - logger.warning( - "night_shift.delivery.org_mismatch", - extra={ - "run_uuid": run_uuid, - "expected_org_id": run.organization_id, - "actual_org_id": organization_id, - }, + extra={"organization_id": organization_id, "run_uuid": run_uuid}, ) return From 0c0baaaef1f2efd21495c50becfa5bcabe9b6e04 Mon Sep 17 00:00:00 2001 From: Trevor Elkins Date: Tue, 2 Jun 2026 18:43:00 -0400 Subject: [PATCH 09/19] fix(night_shift): Address review feedback for delivery handler - Use sentry_sdk.metrics.count() with attributes instead of incr() with tags - Add extras["action"] to SeerNightShiftRunResult for tracking - Fix circular import by defining FeatureRunStatus locally - Make TriageVerdict.reason optional with empty string default Co-Authored-By: Claude Opus 4.5 --- src/sentry/seer/night_shift/delivery.py | 17 +++++++++++------ src/sentry/seer/night_shift/models.py | 2 +- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/sentry/seer/night_shift/delivery.py b/src/sentry/seer/night_shift/delivery.py index 6cd658acf42a4c..2c9769a5a39838 100644 --- a/src/sentry/seer/night_shift/delivery.py +++ b/src/sentry/seer/night_shift/delivery.py @@ -4,14 +4,13 @@ import logging from collections.abc import Mapping -from typing import Any +from typing import Any, Literal import sentry_sdk from sentry.constants import SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT from sentry.models.group import Group from sentry.models.organization import Organization -from sentry.seer.agent.feature_delivery import FeatureRunStatus from sentry.seer.autofix.autofix_agent import AutofixStep, trigger_autofix_agent from sentry.seer.autofix.constants import SeerAutomationSource from sentry.seer.autofix.issue_summary import referrer_map @@ -23,6 +22,8 @@ from sentry.tasks.seer.night_shift.models import TriageAction from sentry.tasks.seer.night_shift.skip_cache import mark_skipped +FeatureRunStatus = Literal["completed", "error"] + logger = logging.getLogger(__name__) @@ -55,9 +56,10 @@ def deliver_night_shift_result( } if status == "error" or result is None: - sentry_sdk.metrics.incr( + sentry_sdk.metrics.count( "night_shift.triage_error", - tags={"error_type": "delivery_error" if status == "error" else "no_artifact"}, + 1, + attributes={"error_type": "delivery_error" if status == "error" else "no_artifact"}, ) logger.warning("night_shift.delivery.no_result", extra={**log_extra, "status": status}) return @@ -65,7 +67,9 @@ def deliver_night_shift_result( try: triage_response = TriageResponse.parse_obj(result) except Exception: - sentry_sdk.metrics.incr("night_shift.triage_error", tags={"error_type": "invalid_artifact"}) + sentry_sdk.metrics.count( + "night_shift.triage_error", 1, attributes={"error_type": "invalid_artifact"} + ) logger.exception("night_shift.delivery.invalid_result", extra=log_extra) return @@ -211,9 +215,10 @@ def _trigger_autofix_for_fixable( group=group, seer_run_id=str(seer_run_id), result_seer_run=result_seer_run, + extras={"action": str(v.action)}, ) ) SeerNightShiftRunResult.objects.bulk_create(results) - sentry_sdk.metrics.incr("night_shift.autofix_triggered", amount=len(results)) + sentry_sdk.metrics.count("night_shift.autofix_triggered", len(results)) return results diff --git a/src/sentry/seer/night_shift/models.py b/src/sentry/seer/night_shift/models.py index edbb21b25feb2b..34fb58e9695311 100644 --- a/src/sentry/seer/night_shift/models.py +++ b/src/sentry/seer/night_shift/models.py @@ -15,7 +15,7 @@ class Config: class TriageVerdict(_Base): group_id: int action: TriageAction - reason: str + reason: str = "" class TriageResponse(_Base): From 7dfbadf1234e8e76ea83bcefa3e4f0af76b91cc9 Mon Sep 17 00:00:00 2001 From: Trevor Elkins Date: Tue, 2 Jun 2026 18:58:42 -0400 Subject: [PATCH 10/19] fix(night_shift): Use fixable_verdicts count for candidates_selected metric Match existing cron.py behavior by counting only fixable verdicts (excluding SKIPs) in the candidates_selected distribution metric. Co-Authored-By: Claude Opus 4.5 --- src/sentry/seer/night_shift/delivery.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/sentry/seer/night_shift/delivery.py b/src/sentry/seer/night_shift/delivery.py index 2c9769a5a39838..dc69db9ed8590b 100644 --- a/src/sentry/seer/night_shift/delivery.py +++ b/src/sentry/seer/night_shift/delivery.py @@ -120,9 +120,7 @@ def _process_verdicts( and v.group_id in groups_by_id ] - sentry_sdk.metrics.distribution( - "night_shift.candidates_selected", len(triage_response.verdicts) - ) + sentry_sdk.metrics.distribution("night_shift.candidates_selected", len(fixable_verdicts)) results: list[SeerNightShiftRunResult] = [] if not dry_run: From e1d5fa84e58634c06d5b11db7a588c36cced4a2f Mon Sep 17 00:00:00 2001 From: Trevor Elkins Date: Tue, 2 Jun 2026 19:22:49 -0400 Subject: [PATCH 11/19] ref(night_shift): Remove duplicate FeatureRunStatus definition Import FeatureRunStatus from delivery.py instead of defining it twice. Co-Authored-By: Claude Opus 4.5 --- src/sentry/seer/agent/feature_delivery.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/sentry/seer/agent/feature_delivery.py b/src/sentry/seer/agent/feature_delivery.py index 2617fb0cf157e6..aff2c1472953ff 100644 --- a/src/sentry/seer/agent/feature_delivery.py +++ b/src/sentry/seer/agent/feature_delivery.py @@ -2,11 +2,9 @@ from __future__ import annotations -from typing import Any, Literal, Protocol +from typing import Any, Protocol -from sentry.seer.night_shift.delivery import deliver_night_shift_result - -FeatureRunStatus = Literal["completed", "error"] +from sentry.seer.night_shift.delivery import FeatureRunStatus, deliver_night_shift_result class FeatureDeliveryFn(Protocol): From 1ef723137b84b23f34347ea389d919798df6243d Mon Sep 17 00:00:00 2001 From: Trevor Elkins Date: Tue, 2 Jun 2026 19:24:24 -0400 Subject: [PATCH 12/19] ref(night_shift): Move FeatureRunStatus to shared types module Define FeatureRunStatus in sentry.seer.agent.types so handlers can import it without circular dependencies. Co-Authored-By: Claude Opus 4.5 --- src/sentry/seer/agent/feature_delivery.py | 3 ++- src/sentry/seer/agent/types.py | 5 +++++ src/sentry/seer/night_shift/delivery.py | 5 ++--- 3 files changed, 9 insertions(+), 4 deletions(-) create mode 100644 src/sentry/seer/agent/types.py diff --git a/src/sentry/seer/agent/feature_delivery.py b/src/sentry/seer/agent/feature_delivery.py index aff2c1472953ff..44fae7355d3d92 100644 --- a/src/sentry/seer/agent/feature_delivery.py +++ b/src/sentry/seer/agent/feature_delivery.py @@ -4,7 +4,8 @@ from typing import Any, Protocol -from sentry.seer.night_shift.delivery import FeatureRunStatus, deliver_night_shift_result +from sentry.seer.agent.types import FeatureRunStatus +from sentry.seer.night_shift.delivery import deliver_night_shift_result class FeatureDeliveryFn(Protocol): diff --git a/src/sentry/seer/agent/types.py b/src/sentry/seer/agent/types.py new file mode 100644 index 00000000000000..ccd3c26cf94665 --- /dev/null +++ b/src/sentry/seer/agent/types.py @@ -0,0 +1,5 @@ +"""Shared types for Seer agent features.""" + +from typing import Literal + +FeatureRunStatus = Literal["completed", "error"] diff --git a/src/sentry/seer/night_shift/delivery.py b/src/sentry/seer/night_shift/delivery.py index dc69db9ed8590b..91f76fa2294f27 100644 --- a/src/sentry/seer/night_shift/delivery.py +++ b/src/sentry/seer/night_shift/delivery.py @@ -4,13 +4,14 @@ import logging from collections.abc import Mapping -from typing import Any, Literal +from typing import Any import sentry_sdk from sentry.constants import SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT from sentry.models.group import Group from sentry.models.organization import Organization +from sentry.seer.agent.types import FeatureRunStatus from sentry.seer.autofix.autofix_agent import AutofixStep, trigger_autofix_agent from sentry.seer.autofix.constants import SeerAutomationSource from sentry.seer.autofix.issue_summary import referrer_map @@ -22,8 +23,6 @@ from sentry.tasks.seer.night_shift.models import TriageAction from sentry.tasks.seer.night_shift.skip_cache import mark_skipped -FeatureRunStatus = Literal["completed", "error"] - logger = logging.getLogger(__name__) From 0061638bd4e2c446c48a1cbd4ddf3bd68ec4ff44 Mon Sep 17 00:00:00 2001 From: Trevor Elkins Date: Tue, 2 Jun 2026 19:33:33 -0400 Subject: [PATCH 13/19] test(night_shift): Add tests for deliver_night_shift_result Add comprehensive test coverage for the night shift delivery handler: - Missing/invalid run handling - Error status and invalid result parsing - SKIP, AUTOFIX, and ROOT_CAUSE_ONLY verdict processing - Dry run mode skipping autofix - Error recovery when trigger fails for one group - Unknown group_id filtering - User context passing to autofix Co-Authored-By: Claude Opus 4.5 --- .../sentry/seer/night_shift/test_delivery.py | 351 ++++++++++++++++++ 1 file changed, 351 insertions(+) create mode 100644 tests/sentry/seer/night_shift/test_delivery.py diff --git a/tests/sentry/seer/night_shift/test_delivery.py b/tests/sentry/seer/night_shift/test_delivery.py new file mode 100644 index 00000000000000..77ddd1728403e9 --- /dev/null +++ b/tests/sentry/seer/night_shift/test_delivery.py @@ -0,0 +1,351 @@ +from unittest.mock import patch + +from sentry.seer.autofix.utils import AutofixStoppingPoint +from sentry.seer.models.night_shift import SeerNightShiftRun, SeerNightShiftRunResult +from sentry.seer.night_shift.delivery import deliver_night_shift_result +from sentry.tasks.seer.night_shift.models import TriageAction +from sentry.tasks.seer.night_shift.skip_cache import key as skip_cache_key +from sentry.testutils.cases import TestCase +from sentry.testutils.pytest.fixtures import django_db_all +from sentry.utils.redis import redis_clusters + + +@django_db_all +class TestDeliverNightShiftResult(TestCase): + def _create_night_shift_run(self, organization=None, **extras_overrides): + """Create a SeerNightShiftRun with associated SeerRun.""" + org = organization or self.create_organization() + seer_run = self.create_seer_run(organization=org) + extras = {"options": {}, **extras_overrides} + return SeerNightShiftRun.objects.create( + organization=org, + seer_run=seer_run, + extras=extras, + ) + + def test_missing_run_logs_warning(self): + """When run_uuid doesn't match any SeerNightShiftRun, log and return.""" + org = self.create_organization() + + with patch("sentry.seer.night_shift.delivery.logger") as mock_logger: + deliver_night_shift_result( + organization_id=org.id, + run_uuid="00000000-0000-0000-0000-000000000000", + status="completed", + result={"verdicts": []}, + error=None, + ) + + mock_logger.warning.assert_called_once() + assert "night_shift.delivery.missing_run" in mock_logger.warning.call_args.args[0] + + def test_error_status_records_error_and_returns(self): + """When status is 'error', record error message and return early.""" + run = self._create_night_shift_run() + + with patch("sentry.seer.night_shift.delivery.logger") as mock_logger: + deliver_night_shift_result( + organization_id=run.organization_id, + run_uuid=str(run.seer_run.uuid), + status="error", + result=None, + error="Seer exploded", + ) + + mock_logger.warning.assert_called() + assert "night_shift.delivery.no_result" in mock_logger.warning.call_args.args[0] + + run.refresh_from_db() + assert run.extras["error_message"] == "Seer exploded" + assert not SeerNightShiftRunResult.objects.filter(run=run).exists() + + def test_invalid_result_logs_exception(self): + """When result can't be parsed as TriageResponse, log and return.""" + run = self._create_night_shift_run() + + with patch("sentry.seer.night_shift.delivery.logger") as mock_logger: + deliver_night_shift_result( + organization_id=run.organization_id, + run_uuid=str(run.seer_run.uuid), + status="completed", + result={"invalid": "schema"}, + error=None, + ) + + mock_logger.exception.assert_called_once() + assert "night_shift.delivery.invalid_result" in mock_logger.exception.call_args.args[0] + + assert not SeerNightShiftRunResult.objects.filter(run=run).exists() + + def test_skip_verdict_marks_group_skipped(self): + """SKIP verdicts should mark the group in skip cache.""" + org = self.create_organization() + project = self.create_project(organization=org) + group = self.create_group(project=project) + run = self._create_night_shift_run(organization=org) + + result = { + "verdicts": [ + {"group_id": group.id, "action": TriageAction.SKIP.value, "reason": "not fixable"} + ] + } + + with patch("sentry.seer.night_shift.delivery.trigger_autofix_agent") as mock_trigger: + deliver_night_shift_result( + organization_id=org.id, + run_uuid=str(run.seer_run.uuid), + status="completed", + result=result, + error=None, + ) + + mock_trigger.assert_not_called() + + # Verify skip cache was set + redis = redis_clusters.get("default") + try: + assert redis.exists(skip_cache_key(group.id)) + finally: + redis.delete(skip_cache_key(group.id)) + + # No results persisted for SKIP verdicts + assert not SeerNightShiftRunResult.objects.filter(run=run).exists() + + def test_autofix_verdict_triggers_autofix(self): + """AUTOFIX verdicts should trigger autofix with project stopping point.""" + org = self.create_organization() + project = self.create_project(organization=org) + project.update_option( + "sentry:seer_automated_run_stopping_point", AutofixStoppingPoint.OPEN_PR.value + ) + group = self.create_group(project=project) + run = self._create_night_shift_run(organization=org) + + result = { + "verdicts": [ + {"group_id": group.id, "action": TriageAction.AUTOFIX.value, "reason": "looks good"} + ] + } + + with patch( + "sentry.seer.night_shift.delivery.trigger_autofix_agent", return_value=42 + ) as mock_trigger: + deliver_night_shift_result( + organization_id=org.id, + run_uuid=str(run.seer_run.uuid), + status="completed", + result=result, + error=None, + ) + + mock_trigger.assert_called_once() + assert mock_trigger.call_args.kwargs["group"].id == group.id + assert mock_trigger.call_args.kwargs["stopping_point"] == AutofixStoppingPoint.OPEN_PR + + results = list(SeerNightShiftRunResult.objects.filter(run=run)) + assert len(results) == 1 + assert results[0].group_id == group.id + assert results[0].seer_run_id == "42" + assert results[0].extras["action"] == TriageAction.AUTOFIX.value + + def test_root_cause_only_verdict_uses_root_cause_stopping_point(self): + """ROOT_CAUSE_ONLY verdicts should use ROOT_CAUSE stopping point.""" + org = self.create_organization() + project = self.create_project(organization=org) + project.update_option( + "sentry:seer_automated_run_stopping_point", AutofixStoppingPoint.OPEN_PR.value + ) + group = self.create_group(project=project) + run = self._create_night_shift_run(organization=org) + + result = { + "verdicts": [ + { + "group_id": group.id, + "action": TriageAction.ROOT_CAUSE_ONLY.value, + "reason": "needs investigation", + } + ] + } + + with patch( + "sentry.seer.night_shift.delivery.trigger_autofix_agent", return_value=99 + ) as mock_trigger: + deliver_night_shift_result( + organization_id=org.id, + run_uuid=str(run.seer_run.uuid), + status="completed", + result=result, + error=None, + ) + + mock_trigger.assert_called_once() + assert ( + mock_trigger.call_args.kwargs["stopping_point"] == AutofixStoppingPoint.ROOT_CAUSE + ) + + def test_dry_run_skips_autofix(self): + """Dry run mode should not trigger autofix or persist results.""" + org = self.create_organization() + project = self.create_project(organization=org) + group = self.create_group(project=project) + run = self._create_night_shift_run(organization=org, options={"dry_run": True}) + + result = { + "verdicts": [ + {"group_id": group.id, "action": TriageAction.AUTOFIX.value, "reason": "fixable"} + ] + } + + with patch("sentry.seer.night_shift.delivery.trigger_autofix_agent") as mock_trigger: + deliver_night_shift_result( + organization_id=org.id, + run_uuid=str(run.seer_run.uuid), + status="completed", + result=result, + error=None, + ) + + mock_trigger.assert_not_called() + + assert not SeerNightShiftRunResult.objects.filter(run=run).exists() + + def test_trigger_failure_continues_with_other_groups(self): + """If trigger fails for one group, continue processing others.""" + org = self.create_organization() + project = self.create_project(organization=org) + failing_group = self.create_group(project=project) + ok_group = self.create_group(project=project) + run = self._create_night_shift_run(organization=org) + + result = { + "verdicts": [ + { + "group_id": failing_group.id, + "action": TriageAction.AUTOFIX.value, + "reason": "will fail", + }, + { + "group_id": ok_group.id, + "action": TriageAction.AUTOFIX.value, + "reason": "will work", + }, + ] + } + + def trigger_side_effect(**kwargs): + if kwargs["group"].id == failing_group.id: + raise RuntimeError("trigger failed") + return 7 + + with ( + patch( + "sentry.seer.night_shift.delivery.trigger_autofix_agent", + side_effect=trigger_side_effect, + ), + patch("sentry.seer.night_shift.delivery.logger") as mock_logger, + ): + deliver_night_shift_result( + organization_id=org.id, + run_uuid=str(run.seer_run.uuid), + status="completed", + result=result, + error=None, + ) + + exception_calls = [call.args[0] for call in mock_logger.exception.call_args_list] + assert "night_shift.autofix_trigger_failed" in exception_calls + + results = list(SeerNightShiftRunResult.objects.filter(run=run)) + assert len(results) == 1 + assert results[0].group_id == ok_group.id + assert results[0].seer_run_id == "7" + + def test_unknown_group_ids_logged(self): + """Groups not belonging to the org should be logged and skipped.""" + org = self.create_organization() + other_org = self.create_organization() + other_project = self.create_project(organization=other_org) + other_group = self.create_group(project=other_project) + run = self._create_night_shift_run(organization=org) + + result = { + "verdicts": [ + { + "group_id": other_group.id, + "action": TriageAction.AUTOFIX.value, + "reason": "wrong org", + } + ] + } + + with ( + patch("sentry.seer.night_shift.delivery.trigger_autofix_agent") as mock_trigger, + patch("sentry.seer.night_shift.delivery.logger") as mock_logger, + ): + deliver_night_shift_result( + organization_id=org.id, + run_uuid=str(run.seer_run.uuid), + status="completed", + result=result, + error=None, + ) + + mock_trigger.assert_not_called() + warning_calls = [call.args[0] for call in mock_logger.warning.call_args_list] + assert "night_shift.delivery.unknown_group_ids" in warning_calls + + def test_user_context_passed_to_autofix(self): + """Verdict reason should be passed as user_context to autofix.""" + org = self.create_organization() + project = self.create_project(organization=org) + group = self.create_group(project=project) + run = self._create_night_shift_run(organization=org) + + result = { + "verdicts": [ + { + "group_id": group.id, + "action": TriageAction.AUTOFIX.value, + "reason": "This issue is caused by a null pointer", + } + ] + } + + with patch( + "sentry.seer.night_shift.delivery.trigger_autofix_agent", return_value=1 + ) as mock_trigger: + deliver_night_shift_result( + organization_id=org.id, + run_uuid=str(run.seer_run.uuid), + status="completed", + result=result, + error=None, + ) + + user_context = mock_trigger.call_args.kwargs["user_context"] + assert "This issue is caused by a null pointer" in user_context + + def test_empty_reason_no_user_context(self): + """Empty reason should result in no user_context.""" + org = self.create_organization() + project = self.create_project(organization=org) + group = self.create_group(project=project) + run = self._create_night_shift_run(organization=org) + + result = { + "verdicts": [{"group_id": group.id, "action": TriageAction.AUTOFIX.value, "reason": ""}] + } + + with patch( + "sentry.seer.night_shift.delivery.trigger_autofix_agent", return_value=1 + ) as mock_trigger: + deliver_night_shift_result( + organization_id=org.id, + run_uuid=str(run.seer_run.uuid), + status="completed", + result=result, + error=None, + ) + + assert mock_trigger.call_args.kwargs["user_context"] is None From e853a2cd21f5a3535709fe72bfd0ff37d388b738 Mon Sep 17 00:00:00 2001 From: Trevor Elkins Date: Wed, 3 Jun 2026 08:54:14 -0400 Subject: [PATCH 14/19] fix(night_shift): Add type annotations and re-export FeatureRunStatus - Add __all__ to feature_delivery.py to explicitly export FeatureRunStatus - Add type annotations to all test functions - Add assertions for nullable seer_run FK before accessing uuid Co-Authored-By: Claude Opus 4.5 --- src/sentry/seer/agent/feature_delivery.py | 2 + .../sentry/seer/night_shift/test_delivery.py | 40 +++++++++++++------ 2 files changed, 29 insertions(+), 13 deletions(-) diff --git a/src/sentry/seer/agent/feature_delivery.py b/src/sentry/seer/agent/feature_delivery.py index 44fae7355d3d92..27f270a802ee59 100644 --- a/src/sentry/seer/agent/feature_delivery.py +++ b/src/sentry/seer/agent/feature_delivery.py @@ -7,6 +7,8 @@ from sentry.seer.agent.types import FeatureRunStatus from sentry.seer.night_shift.delivery import deliver_night_shift_result +__all__ = ["DELIVERY_HANDLERS", "FeatureDeliveryFn", "FeatureRunStatus"] + class FeatureDeliveryFn(Protocol): def __call__( diff --git a/tests/sentry/seer/night_shift/test_delivery.py b/tests/sentry/seer/night_shift/test_delivery.py index 77ddd1728403e9..00d623c9e6d066 100644 --- a/tests/sentry/seer/night_shift/test_delivery.py +++ b/tests/sentry/seer/night_shift/test_delivery.py @@ -1,5 +1,7 @@ +from typing import Any from unittest.mock import patch +from sentry.models.organization import Organization from sentry.seer.autofix.utils import AutofixStoppingPoint from sentry.seer.models.night_shift import SeerNightShiftRun, SeerNightShiftRunResult from sentry.seer.night_shift.delivery import deliver_night_shift_result @@ -12,7 +14,9 @@ @django_db_all class TestDeliverNightShiftResult(TestCase): - def _create_night_shift_run(self, organization=None, **extras_overrides): + def _create_night_shift_run( + self, organization: Organization | None = None, **extras_overrides: Any + ) -> SeerNightShiftRun: """Create a SeerNightShiftRun with associated SeerRun.""" org = organization or self.create_organization() seer_run = self.create_seer_run(organization=org) @@ -23,7 +27,7 @@ def _create_night_shift_run(self, organization=None, **extras_overrides): extras=extras, ) - def test_missing_run_logs_warning(self): + def test_missing_run_logs_warning(self) -> None: """When run_uuid doesn't match any SeerNightShiftRun, log and return.""" org = self.create_organization() @@ -39,9 +43,10 @@ def test_missing_run_logs_warning(self): mock_logger.warning.assert_called_once() assert "night_shift.delivery.missing_run" in mock_logger.warning.call_args.args[0] - def test_error_status_records_error_and_returns(self): + def test_error_status_records_error_and_returns(self) -> None: """When status is 'error', record error message and return early.""" run = self._create_night_shift_run() + assert run.seer_run is not None with patch("sentry.seer.night_shift.delivery.logger") as mock_logger: deliver_night_shift_result( @@ -59,9 +64,10 @@ def test_error_status_records_error_and_returns(self): assert run.extras["error_message"] == "Seer exploded" assert not SeerNightShiftRunResult.objects.filter(run=run).exists() - def test_invalid_result_logs_exception(self): + def test_invalid_result_logs_exception(self) -> None: """When result can't be parsed as TriageResponse, log and return.""" run = self._create_night_shift_run() + assert run.seer_run is not None with patch("sentry.seer.night_shift.delivery.logger") as mock_logger: deliver_night_shift_result( @@ -77,7 +83,7 @@ def test_invalid_result_logs_exception(self): assert not SeerNightShiftRunResult.objects.filter(run=run).exists() - def test_skip_verdict_marks_group_skipped(self): + def test_skip_verdict_marks_group_skipped(self) -> None: """SKIP verdicts should mark the group in skip cache.""" org = self.create_organization() project = self.create_project(organization=org) @@ -90,6 +96,7 @@ def test_skip_verdict_marks_group_skipped(self): ] } + assert run.seer_run is not None with patch("sentry.seer.night_shift.delivery.trigger_autofix_agent") as mock_trigger: deliver_night_shift_result( organization_id=org.id, @@ -111,7 +118,7 @@ def test_skip_verdict_marks_group_skipped(self): # No results persisted for SKIP verdicts assert not SeerNightShiftRunResult.objects.filter(run=run).exists() - def test_autofix_verdict_triggers_autofix(self): + def test_autofix_verdict_triggers_autofix(self) -> None: """AUTOFIX verdicts should trigger autofix with project stopping point.""" org = self.create_organization() project = self.create_project(organization=org) @@ -127,6 +134,7 @@ def test_autofix_verdict_triggers_autofix(self): ] } + assert run.seer_run is not None with patch( "sentry.seer.night_shift.delivery.trigger_autofix_agent", return_value=42 ) as mock_trigger: @@ -148,7 +156,7 @@ def test_autofix_verdict_triggers_autofix(self): assert results[0].seer_run_id == "42" assert results[0].extras["action"] == TriageAction.AUTOFIX.value - def test_root_cause_only_verdict_uses_root_cause_stopping_point(self): + def test_root_cause_only_verdict_uses_root_cause_stopping_point(self) -> None: """ROOT_CAUSE_ONLY verdicts should use ROOT_CAUSE stopping point.""" org = self.create_organization() project = self.create_project(organization=org) @@ -168,6 +176,7 @@ def test_root_cause_only_verdict_uses_root_cause_stopping_point(self): ] } + assert run.seer_run is not None with patch( "sentry.seer.night_shift.delivery.trigger_autofix_agent", return_value=99 ) as mock_trigger: @@ -184,7 +193,7 @@ def test_root_cause_only_verdict_uses_root_cause_stopping_point(self): mock_trigger.call_args.kwargs["stopping_point"] == AutofixStoppingPoint.ROOT_CAUSE ) - def test_dry_run_skips_autofix(self): + def test_dry_run_skips_autofix(self) -> None: """Dry run mode should not trigger autofix or persist results.""" org = self.create_organization() project = self.create_project(organization=org) @@ -197,6 +206,7 @@ def test_dry_run_skips_autofix(self): ] } + assert run.seer_run is not None with patch("sentry.seer.night_shift.delivery.trigger_autofix_agent") as mock_trigger: deliver_night_shift_result( organization_id=org.id, @@ -210,7 +220,7 @@ def test_dry_run_skips_autofix(self): assert not SeerNightShiftRunResult.objects.filter(run=run).exists() - def test_trigger_failure_continues_with_other_groups(self): + def test_trigger_failure_continues_with_other_groups(self) -> None: """If trigger fails for one group, continue processing others.""" org = self.create_organization() project = self.create_project(organization=org) @@ -233,11 +243,12 @@ def test_trigger_failure_continues_with_other_groups(self): ] } - def trigger_side_effect(**kwargs): + def trigger_side_effect(**kwargs: Any) -> int: if kwargs["group"].id == failing_group.id: raise RuntimeError("trigger failed") return 7 + assert run.seer_run is not None with ( patch( "sentry.seer.night_shift.delivery.trigger_autofix_agent", @@ -261,7 +272,7 @@ def trigger_side_effect(**kwargs): assert results[0].group_id == ok_group.id assert results[0].seer_run_id == "7" - def test_unknown_group_ids_logged(self): + def test_unknown_group_ids_logged(self) -> None: """Groups not belonging to the org should be logged and skipped.""" org = self.create_organization() other_org = self.create_organization() @@ -279,6 +290,7 @@ def test_unknown_group_ids_logged(self): ] } + assert run.seer_run is not None with ( patch("sentry.seer.night_shift.delivery.trigger_autofix_agent") as mock_trigger, patch("sentry.seer.night_shift.delivery.logger") as mock_logger, @@ -295,7 +307,7 @@ def test_unknown_group_ids_logged(self): warning_calls = [call.args[0] for call in mock_logger.warning.call_args_list] assert "night_shift.delivery.unknown_group_ids" in warning_calls - def test_user_context_passed_to_autofix(self): + def test_user_context_passed_to_autofix(self) -> None: """Verdict reason should be passed as user_context to autofix.""" org = self.create_organization() project = self.create_project(organization=org) @@ -312,6 +324,7 @@ def test_user_context_passed_to_autofix(self): ] } + assert run.seer_run is not None with patch( "sentry.seer.night_shift.delivery.trigger_autofix_agent", return_value=1 ) as mock_trigger: @@ -326,7 +339,7 @@ def test_user_context_passed_to_autofix(self): user_context = mock_trigger.call_args.kwargs["user_context"] assert "This issue is caused by a null pointer" in user_context - def test_empty_reason_no_user_context(self): + def test_empty_reason_no_user_context(self) -> None: """Empty reason should result in no user_context.""" org = self.create_organization() project = self.create_project(organization=org) @@ -337,6 +350,7 @@ def test_empty_reason_no_user_context(self): "verdicts": [{"group_id": group.id, "action": TriageAction.AUTOFIX.value, "reason": ""}] } + assert run.seer_run is not None with patch( "sentry.seer.night_shift.delivery.trigger_autofix_agent", return_value=1 ) as mock_trigger: From 70541250d7231a8c344c2a20940e261da69fd7cc Mon Sep 17 00:00:00 2001 From: Trevor Elkins Date: Wed, 3 Jun 2026 08:59:15 -0400 Subject: [PATCH 15/19] ref(night_shift): Reuse _run_autofix_for_candidates from cron Share the autofix triggering logic between the delivery handler and the existing cron job. The delivery handler now: - Converts TriageVerdict to TriageResult - Calls the shared _run_autofix_for_candidates function from cron.py This reduces code duplication and makes it easier to switch over to the new feature delivery system without introducing bugs. Co-Authored-By: Claude Opus 4.5 --- src/sentry/seer/night_shift/delivery.py | 116 +++++------------- .../sentry/seer/night_shift/test_delivery.py | 18 +-- 2 files changed, 38 insertions(+), 96 deletions(-) diff --git a/src/sentry/seer/night_shift/delivery.py b/src/sentry/seer/night_shift/delivery.py index 91f76fa2294f27..4192e4b8cfb849 100644 --- a/src/sentry/seer/night_shift/delivery.py +++ b/src/sentry/seer/night_shift/delivery.py @@ -12,15 +12,10 @@ from sentry.models.group import Group from sentry.models.organization import Organization from sentry.seer.agent.types import FeatureRunStatus -from sentry.seer.autofix.autofix_agent import AutofixStep, trigger_autofix_agent -from sentry.seer.autofix.constants import SeerAutomationSource -from sentry.seer.autofix.issue_summary import referrer_map from sentry.seer.autofix.utils import AutofixStoppingPoint, read_preference_from_sentry_db from sentry.seer.models.night_shift import SeerNightShiftRun, SeerNightShiftRunResult -from sentry.seer.models.run import SeerRun -from sentry.seer.models.workflow import SeerWorkflowStrategy -from sentry.seer.night_shift.models import TriageResponse, TriageVerdict -from sentry.tasks.seer.night_shift.models import TriageAction +from sentry.seer.night_shift.models import TriageResponse +from sentry.tasks.seer.night_shift.models import TriageAction, TriageResult from sentry.tasks.seer.night_shift.skip_cache import mark_skipped logger = logging.getLogger(__name__) @@ -93,6 +88,9 @@ def _process_verdicts( log_extra: Mapping[str, object], ) -> None: """Mark SKIPs, fire autofix for fixable verdicts, persist result rows.""" + # Import here to avoid circular import + from sentry.tasks.seer.night_shift.cron import _run_autofix_for_candidates + group_ids = [v.group_id for v in triage_response.verdicts] groups_by_id: dict[int, Group] = { g.id: g @@ -112,23 +110,38 @@ def _process_verdicts( if v.action == TriageAction.SKIP and v.group_id in groups_by_id: mark_skipped(v.group_id) - fixable_verdicts = [ - v + # Convert verdicts to TriageResult objects for the shared function + fixable_candidates = [ + TriageResult(group=groups_by_id[v.group_id], action=v.action, reason=v.reason) for v in triage_response.verdicts if v.action in (TriageAction.AUTOFIX, TriageAction.ROOT_CAUSE_ONLY) and v.group_id in groups_by_id ] - sentry_sdk.metrics.distribution("night_shift.candidates_selected", len(fixable_verdicts)) + sentry_sdk.metrics.distribution("night_shift.candidates_selected", len(fixable_candidates)) results: list[SeerNightShiftRunResult] = [] - if not dry_run: - results = _trigger_autofix_for_fixable( + if not dry_run and fixable_candidates: + # Build stopping_point_by_project_id from project preferences + project_ids = {c.group.project_id for c in fixable_candidates} + project_by_id = {g.project_id: g.project for g in groups_by_id.values()} + + for project in project_by_id.values(): + project.organization = organization + + stopping_point_by_project_id = { + pid: AutofixStoppingPoint( + read_preference_from_sentry_db(project_by_id[pid]).automated_run_stopping_point + or SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT + ) + for pid in project_ids + } + + results = _run_autofix_for_candidates( run=run, - organization=organization, - verdicts=fixable_verdicts, - groups_by_id=groups_by_id, - log_extra=log_extra, + candidates=fixable_candidates, + stopping_point_by_project_id=stopping_point_by_project_id, + log_extra=dict(log_extra), ) seer_run_id_by_group = {r.group_id: r.seer_run_id for r in results} @@ -148,74 +161,3 @@ def _process_verdicts( ], }, ) - - -def _trigger_autofix_for_fixable( - *, - run: SeerNightShiftRun, - organization: Organization, - verdicts: list[TriageVerdict], - groups_by_id: dict[int, Group], - log_extra: Mapping[str, object], -) -> list[SeerNightShiftRunResult]: - if not verdicts: - return [] - - referrer = referrer_map[SeerAutomationSource.NIGHT_SHIFT] - project_ids = {groups_by_id[v.group_id].project_id for v in verdicts} - project_by_id = {g.project_id: g.project for g in groups_by_id.values()} - - for project in project_by_id.values(): - project.organization = organization - - stopping_point_by_project_id = { - pid: AutofixStoppingPoint( - read_preference_from_sentry_db(project_by_id[pid]).automated_run_stopping_point - or SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT - ) - for pid in project_ids - } - - results: list[SeerNightShiftRunResult] = [] - for v in verdicts: - group = groups_by_id[v.group_id] - stopping_point = ( - AutofixStoppingPoint.ROOT_CAUSE - if v.action == TriageAction.ROOT_CAUSE_ONLY - else stopping_point_by_project_id[group.project_id] - ) - user_context = ( - f"Night-shift triage already investigated this issue and concluded:\n{v.reason}" - if v.reason - else None - ) - try: - seer_run_id = trigger_autofix_agent( - group=group, - step=AutofixStep.ROOT_CAUSE, - referrer=referrer, - stopping_point=stopping_point, - user_context=user_context, - ) - except Exception: - logger.exception( - "night_shift.autofix_trigger_failed", - extra={**log_extra, "group_id": group.id}, - ) - continue - - result_seer_run = SeerRun.objects.filter(seer_run_state_id=seer_run_id).first() - results.append( - SeerNightShiftRunResult( - run=run, - kind=SeerWorkflowStrategy.AGENTIC_TRIAGE, - group=group, - seer_run_id=str(seer_run_id), - result_seer_run=result_seer_run, - extras={"action": str(v.action)}, - ) - ) - - SeerNightShiftRunResult.objects.bulk_create(results) - sentry_sdk.metrics.count("night_shift.autofix_triggered", len(results)) - return results diff --git a/tests/sentry/seer/night_shift/test_delivery.py b/tests/sentry/seer/night_shift/test_delivery.py index 00d623c9e6d066..df494ab1c6966c 100644 --- a/tests/sentry/seer/night_shift/test_delivery.py +++ b/tests/sentry/seer/night_shift/test_delivery.py @@ -97,7 +97,7 @@ def test_skip_verdict_marks_group_skipped(self) -> None: } assert run.seer_run is not None - with patch("sentry.seer.night_shift.delivery.trigger_autofix_agent") as mock_trigger: + with patch("sentry.tasks.seer.night_shift.cron.trigger_autofix_agent") as mock_trigger: deliver_night_shift_result( organization_id=org.id, run_uuid=str(run.seer_run.uuid), @@ -136,7 +136,7 @@ def test_autofix_verdict_triggers_autofix(self) -> None: assert run.seer_run is not None with patch( - "sentry.seer.night_shift.delivery.trigger_autofix_agent", return_value=42 + "sentry.tasks.seer.night_shift.cron.trigger_autofix_agent", return_value=42 ) as mock_trigger: deliver_night_shift_result( organization_id=org.id, @@ -178,7 +178,7 @@ def test_root_cause_only_verdict_uses_root_cause_stopping_point(self) -> None: assert run.seer_run is not None with patch( - "sentry.seer.night_shift.delivery.trigger_autofix_agent", return_value=99 + "sentry.tasks.seer.night_shift.cron.trigger_autofix_agent", return_value=99 ) as mock_trigger: deliver_night_shift_result( organization_id=org.id, @@ -207,7 +207,7 @@ def test_dry_run_skips_autofix(self) -> None: } assert run.seer_run is not None - with patch("sentry.seer.night_shift.delivery.trigger_autofix_agent") as mock_trigger: + with patch("sentry.tasks.seer.night_shift.cron.trigger_autofix_agent") as mock_trigger: deliver_night_shift_result( organization_id=org.id, run_uuid=str(run.seer_run.uuid), @@ -251,10 +251,10 @@ def trigger_side_effect(**kwargs: Any) -> int: assert run.seer_run is not None with ( patch( - "sentry.seer.night_shift.delivery.trigger_autofix_agent", + "sentry.tasks.seer.night_shift.cron.trigger_autofix_agent", side_effect=trigger_side_effect, ), - patch("sentry.seer.night_shift.delivery.logger") as mock_logger, + patch("sentry.tasks.seer.night_shift.cron.logger") as mock_logger, ): deliver_night_shift_result( organization_id=org.id, @@ -292,7 +292,7 @@ def test_unknown_group_ids_logged(self) -> None: assert run.seer_run is not None with ( - patch("sentry.seer.night_shift.delivery.trigger_autofix_agent") as mock_trigger, + patch("sentry.tasks.seer.night_shift.cron.trigger_autofix_agent") as mock_trigger, patch("sentry.seer.night_shift.delivery.logger") as mock_logger, ): deliver_night_shift_result( @@ -326,7 +326,7 @@ def test_user_context_passed_to_autofix(self) -> None: assert run.seer_run is not None with patch( - "sentry.seer.night_shift.delivery.trigger_autofix_agent", return_value=1 + "sentry.tasks.seer.night_shift.cron.trigger_autofix_agent", return_value=1 ) as mock_trigger: deliver_night_shift_result( organization_id=org.id, @@ -352,7 +352,7 @@ def test_empty_reason_no_user_context(self) -> None: assert run.seer_run is not None with patch( - "sentry.seer.night_shift.delivery.trigger_autofix_agent", return_value=1 + "sentry.tasks.seer.night_shift.cron.trigger_autofix_agent", return_value=1 ) as mock_trigger: deliver_night_shift_result( organization_id=org.id, From bb03aea32d405cad1b57129a903f90689179d537 Mon Sep 17 00:00:00 2001 From: Trevor Elkins Date: Wed, 3 Jun 2026 10:08:48 -0400 Subject: [PATCH 16/19] fix(night_shift): Avoid N+1 queries by caching org on each group's project Set organization on each group's project directly instead of building an intermediate project_by_id dict. This ensures all groups have the cached organization regardless of Django's object identity behavior with select_related. Co-Authored-By: Claude Opus 4.5 --- src/sentry/seer/night_shift/delivery.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/sentry/seer/night_shift/delivery.py b/src/sentry/seer/night_shift/delivery.py index 4192e4b8cfb849..6c219eea63faa2 100644 --- a/src/sentry/seer/night_shift/delivery.py +++ b/src/sentry/seer/night_shift/delivery.py @@ -122,19 +122,17 @@ def _process_verdicts( results: list[SeerNightShiftRunResult] = [] if not dry_run and fixable_candidates: - # Build stopping_point_by_project_id from project preferences - project_ids = {c.group.project_id for c in fixable_candidates} - project_by_id = {g.project_id: g.project for g in groups_by_id.values()} - - for project in project_by_id.values(): - project.organization = organization + # Cache organization on each group's project to avoid N+1 queries + for group in groups_by_id.values(): + group.project.organization = organization + # Build stopping_point_by_project_id from project preferences stopping_point_by_project_id = { - pid: AutofixStoppingPoint( - read_preference_from_sentry_db(project_by_id[pid]).automated_run_stopping_point + c.group.project_id: AutofixStoppingPoint( + read_preference_from_sentry_db(c.group.project).automated_run_stopping_point or SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT ) - for pid in project_ids + for c in fixable_candidates } results = _run_autofix_for_candidates( From 33763d95f81a7858c9f4429ae1b002ffb704e75f Mon Sep 17 00:00:00 2001 From: Trevor Elkins Date: Wed, 3 Jun 2026 10:24:02 -0400 Subject: [PATCH 17/19] fix(night_shift): Use bulk query for project preferences Replace per-candidate read_preference_from_sentry_db calls with a single bulk_read_preferences_from_sentry_db call to avoid N+1 queries. Co-Authored-By: Claude Opus 4.5 --- src/sentry/seer/night_shift/delivery.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/sentry/seer/night_shift/delivery.py b/src/sentry/seer/night_shift/delivery.py index 6c219eea63faa2..b53928ff121562 100644 --- a/src/sentry/seer/night_shift/delivery.py +++ b/src/sentry/seer/night_shift/delivery.py @@ -12,7 +12,7 @@ from sentry.models.group import Group from sentry.models.organization import Organization from sentry.seer.agent.types import FeatureRunStatus -from sentry.seer.autofix.utils import AutofixStoppingPoint, read_preference_from_sentry_db +from sentry.seer.autofix.utils import AutofixStoppingPoint, bulk_read_preferences_from_sentry_db from sentry.seer.models.night_shift import SeerNightShiftRun, SeerNightShiftRunResult from sentry.seer.night_shift.models import TriageResponse from sentry.tasks.seer.night_shift.models import TriageAction, TriageResult @@ -126,13 +126,15 @@ def _process_verdicts( for group in groups_by_id.values(): group.project.organization = organization - # Build stopping_point_by_project_id from project preferences + # Build stopping_point_by_project_id from project preferences (bulk query) + project_ids = {c.group.project_id for c in fixable_candidates} + preferences = bulk_read_preferences_from_sentry_db(organization.id, list(project_ids)) stopping_point_by_project_id = { - c.group.project_id: AutofixStoppingPoint( - read_preference_from_sentry_db(c.group.project).automated_run_stopping_point + pid: AutofixStoppingPoint( + preferences[pid].automated_run_stopping_point or SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT ) - for c in fixable_candidates + for pid in project_ids } results = _run_autofix_for_candidates( From 75de6cda93a279b2845d144889944f76cd7cb898 Mon Sep 17 00:00:00 2001 From: Trevor Elkins Date: Wed, 3 Jun 2026 16:00:42 -0400 Subject: [PATCH 18/19] fix(night_shift): Filter out inactive projects in groups query Add project__status=ObjectStatus.ACTIVE filter to match cron.py behavior and prevent potential KeyError if a project becomes inactive during triage. Co-Authored-By: Claude Opus 4.5 --- src/sentry/seer/night_shift/delivery.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/sentry/seer/night_shift/delivery.py b/src/sentry/seer/night_shift/delivery.py index b53928ff121562..982b1cfc6a634c 100644 --- a/src/sentry/seer/night_shift/delivery.py +++ b/src/sentry/seer/night_shift/delivery.py @@ -8,7 +8,7 @@ import sentry_sdk -from sentry.constants import SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT +from sentry.constants import SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT, ObjectStatus from sentry.models.group import Group from sentry.models.organization import Organization from sentry.seer.agent.types import FeatureRunStatus @@ -95,7 +95,9 @@ def _process_verdicts( groups_by_id: dict[int, Group] = { g.id: g for g in Group.objects.filter( - id__in=group_ids, project__organization_id=organization.id + id__in=group_ids, + project__organization_id=organization.id, + project__status=ObjectStatus.ACTIVE, ).select_related("project") } From 9bab14bebec6ea4dca6b333144705254bc14a1f7 Mon Sep 17 00:00:00 2001 From: Trevor Elkins Date: Wed, 3 Jun 2026 16:17:00 -0400 Subject: [PATCH 19/19] fix(night_shift): Handle missing project preferences defensively If a project is deleted or moved between the groups query and the preferences query (extremely unlikely race), fall back to the default stopping point instead of raising KeyError. Co-Authored-By: Claude Opus 4.5 --- src/sentry/seer/night_shift/delivery.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/sentry/seer/night_shift/delivery.py b/src/sentry/seer/night_shift/delivery.py index 982b1cfc6a634c..9f298b012ca4d0 100644 --- a/src/sentry/seer/night_shift/delivery.py +++ b/src/sentry/seer/night_shift/delivery.py @@ -131,11 +131,13 @@ def _process_verdicts( # Build stopping_point_by_project_id from project preferences (bulk query) project_ids = {c.group.project_id for c in fixable_candidates} preferences = bulk_read_preferences_from_sentry_db(organization.id, list(project_ids)) + default_stopping_point = AutofixStoppingPoint(SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT) stopping_point_by_project_id = { pid: AutofixStoppingPoint( - preferences[pid].automated_run_stopping_point - or SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT + pref.automated_run_stopping_point or SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT ) + if (pref := preferences.get(pid)) is not None + else default_stopping_point for pid in project_ids }