Skip to content

Commit 5aa127c

Browse files
trevor-eclaude
andauthored
feat(night-shift): Trigger autofix for fixable candidates and add dry run mode (#113056)
Night shift triage now triggers autofix automation for candidates identified as fixable by the agentic triage strategy. Each autofix run carries a dedicated `NIGHT_SHIFT` referrer (`AutofixReferrer.NIGHT_SHIFT` + `auto_run_source="night_shift"`) so this cohort can be tracked and evaluated separately from the regular post-process/alert automation flow. Per candidate, we check for an existing autofix run and resolve the user's configured stopping point before dispatching `_trigger_autofix_task`. Unlike the regular automation flow, we skip the fixability-score-based stopping point since night shift already performed its own agentic assessment — only the user's project preference is honored as an upper bound. Quota and feature flag checks are handled downstream by `trigger_autofix`/`trigger_autofix_explorer`. Also adds a **dry run** toggle to the Seer admin page (`/_admin/seer/`). When enabled, night shift runs triage and logs candidates as usual but skips autofix triggering — useful for evaluating triage quality without side effects like autofix run timestamps that prevent re-runs. ### Changes - `issue_summary.py`: Wire `NIGHT_SHIFT` into `auto_run_source_map` and `referrer_map`. Extract `_get_user_stopping_point_preference` helper and add `get_user_stopping_point` for callers that need just the user preference without fixability scoring. - `cron.py`: Add `_trigger_autofix_for_candidate()` helper, loop in `run_night_shift_for_org`, accept `dry_run` kwarg, use `get_user_stopping_point` for stopping point. - `admin_night_shift_trigger.py`: Pass `dry_run` flag through to the task. - `seerAdminPage.tsx`: Add dry run checkbox to the night shift trigger form. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8156cbe commit 5aa127c

File tree

6 files changed

+164
-11
lines changed

6 files changed

+164
-11
lines changed

src/sentry/seer/autofix/issue_summary.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,12 +62,14 @@
6262
SeerAutomationSource.ISSUE_DETAILS: "issue_summary_fixability",
6363
SeerAutomationSource.ALERT: "issue_summary_on_alert_fixability",
6464
SeerAutomationSource.POST_PROCESS: "issue_summary_on_post_process_fixability",
65+
SeerAutomationSource.NIGHT_SHIFT: "night_shift",
6566
}
6667

6768
referrer_map = {
6869
SeerAutomationSource.ISSUE_DETAILS: AutofixReferrer.ISSUE_SUMMARY_FIXABILITY,
6970
SeerAutomationSource.ALERT: AutofixReferrer.ISSUE_SUMMARY_ALERT_FIXABILITY,
7071
SeerAutomationSource.POST_PROCESS: AutofixReferrer.ISSUE_SUMMARY_POST_PROCESS_FIXABILITY,
72+
SeerAutomationSource.NIGHT_SHIFT: AutofixReferrer.NIGHT_SHIFT,
7173
}
7274

7375
STOPPING_POINT_HIERARCHY = {

src/sentry/seer/endpoints/admin_night_shift_trigger.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,14 @@ def post(self, request: Request) -> Response:
2626
except (ValueError, TypeError):
2727
return Response({"detail": "organization_id must be a valid integer"}, status=400)
2828

29-
run_night_shift_for_org.apply_async(args=[organization_id])
29+
dry_run = bool(request.data.get("dry_run", False))
30+
31+
run_night_shift_for_org.apply_async(args=[organization_id], kwargs={"dry_run": dry_run})
3032

3133
return Response(
3234
{
3335
"success": True,
3436
"organization_id": organization_id,
37+
"dry_run": dry_run,
3538
}
3639
)

src/sentry/tasks/seer/night_shift/cron.py

Lines changed: 64 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,24 @@
99

1010
from sentry import features, options
1111
from sentry.constants import ObjectStatus
12+
from sentry.models.group import Group
1213
from sentry.models.organization import Organization, OrganizationStatus
1314
from sentry.models.project import Project
14-
from sentry.seer.autofix.constants import AutofixAutomationTuningSettings
15+
from sentry.seer.autofix.constants import (
16+
AutofixAutomationTuningSettings,
17+
SeerAutomationSource,
18+
)
19+
from sentry.seer.autofix.issue_summary import (
20+
_trigger_autofix_task,
21+
auto_run_source_map,
22+
referrer_map,
23+
)
1524
from sentry.seer.autofix.utils import bulk_read_preferences
1625
from sentry.seer.models.night_shift import SeerNightShiftRun, SeerNightShiftRunIssue
26+
from sentry.seer.models.seer_api_models import SeerProjectPreference
1727
from sentry.tasks.base import instrumented_task
1828
from sentry.tasks.seer.night_shift.agentic_triage import agentic_triage_strategy
29+
from sentry.tasks.seer.night_shift.models import TriageAction
1930
from sentry.taskworker.namespaces import seer_tasks
2031
from sentry.utils.iterators import chunked
2132
from sentry.utils.query import RangeQuerySetWrapper
@@ -76,7 +87,7 @@ def schedule_night_shift() -> None:
7687
namespace=seer_tasks,
7788
processing_deadline_duration=5 * 60,
7889
)
79-
def run_night_shift_for_org(organization_id: int) -> None:
90+
def run_night_shift_for_org(organization_id: int, dry_run: bool = False) -> None:
8091
try:
8192
organization = Organization.objects.get(
8293
id=organization_id, status=OrganizationStatus.ACTIVE
@@ -94,7 +105,7 @@ def run_night_shift_for_org(organization_id: int) -> None:
94105
start_time = time.monotonic()
95106

96107
try:
97-
eligible_projects = _get_eligible_projects(organization)
108+
eligible_projects, preferences = _get_eligible_projects(organization)
98109
if not eligible_projects:
99110
logger.info(
100111
"night_shift.no_eligible_projects",
@@ -160,6 +171,7 @@ def run_night_shift_for_org(organization_id: int) -> None:
160171
"run_id": run.id,
161172
"num_eligible_projects": len(eligible_projects),
162173
"num_candidates": len(candidates),
174+
"dry_run": dry_run,
163175
"candidates": [
164176
{
165177
"group_id": c.group.id,
@@ -170,6 +182,16 @@ def run_night_shift_for_org(organization_id: int) -> None:
170182
},
171183
)
172184

185+
autofix_triggered = 0
186+
if not dry_run:
187+
for c in candidates:
188+
if c.action == TriageAction.AUTOFIX:
189+
pref = preferences.get(c.group.project_id)
190+
stopping_point = pref.automated_run_stopping_point if pref else None
191+
if _trigger_autofix_for_candidate(c.group, organization, stopping_point):
192+
autofix_triggered += 1
193+
sentry_sdk.metrics.count("night_shift.autofix_triggered", autofix_triggered)
194+
173195

174196
def _get_eligible_orgs_from_batch(
175197
orgs: Sequence[Organization],
@@ -193,21 +215,57 @@ def _get_eligible_orgs_from_batch(
193215
return eligible
194216

195217

196-
def _get_eligible_projects(organization: Organization) -> list[Project]:
218+
def _trigger_autofix_for_candidate(
219+
group: Group, organization: Organization, stopping_point: str | None
220+
) -> bool:
221+
"""Trigger autofix for a single candidate identified as fixable by night shift triage.
222+
223+
Returns True if the autofix task was dispatched.
224+
"""
225+
try:
226+
event = group.get_latest_event()
227+
if not event:
228+
logger.warning(
229+
"night_shift.no_event_for_autofix",
230+
extra={"group_id": group.id, "organization_id": organization.id},
231+
)
232+
return False
233+
234+
_trigger_autofix_task.delay(
235+
group_id=group.id,
236+
event_id=event.event_id,
237+
user_id=None,
238+
auto_run_source=auto_run_source_map[SeerAutomationSource.NIGHT_SHIFT],
239+
referrer=referrer_map[SeerAutomationSource.NIGHT_SHIFT],
240+
stopping_point=stopping_point,
241+
)
242+
return True
243+
except Exception:
244+
logger.exception(
245+
"night_shift.autofix_trigger_failed",
246+
extra={"group_id": group.id, "organization_id": organization.id},
247+
)
248+
return False
249+
250+
251+
def _get_eligible_projects(
252+
organization: Organization,
253+
) -> tuple[list[Project], dict[int, SeerProjectPreference | None]]:
197254
"""Return active projects that have automation enabled and connected repos."""
198255
project_map = {
199256
p.id: p
200257
for p in Project.objects.filter(organization=organization, status=ObjectStatus.ACTIVE)
201258
}
202259
if not project_map:
203-
return []
260+
return [], {}
204261

205262
preferences = bulk_read_preferences(organization, list(project_map))
206263

207-
return [
264+
projects = [
208265
project_map[pid]
209266
for pid, pref in preferences.items()
210267
if pref is not None
211268
and pref.repositories
212269
and pref.autofix_automation_tuning != AutofixAutomationTuningSettings.OFF
213270
]
271+
return projects, preferences

static/gsAdmin/views/seerAdminPage.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {PageHeader} from 'admin/components/pageHeader';
1616

1717
export function SeerAdminPage() {
1818
const [organizationId, setOrganizationId] = useState<string>('');
19+
const [dryRun, setDryRun] = useState<boolean>(false);
1920
const regions = ConfigStore.get('regions');
2021
const [region, setRegion] = useState<Region | null>(regions[0] ?? null);
2122

@@ -24,12 +25,15 @@ export function SeerAdminPage() {
2425
return fetchMutation({
2526
url: '/internal/seer/night-shift/trigger/',
2627
method: 'POST',
27-
data: {organization_id: parseInt(organizationId, 10)},
28+
data: {organization_id: parseInt(organizationId, 10), dry_run: dryRun},
2829
options: {host: region?.url},
2930
});
3031
},
3132
onSuccess: () => {
32-
addSuccessMessage(`Night shift run triggered for organization ${organizationId}`);
33+
const mode = dryRun ? ' (dry run)' : '';
34+
addSuccessMessage(
35+
`Night shift run triggered for organization ${organizationId}${mode}`
36+
);
3337
setOrganizationId('');
3438
},
3539
onError: () => {
@@ -96,6 +100,14 @@ export function SeerAdminPage() {
96100
onChange={e => setOrganizationId(e.target.value)}
97101
placeholder="Enter organization ID"
98102
/>
103+
<Flex as="label" gap="sm" align="center">
104+
<input
105+
type="checkbox"
106+
checked={dryRun}
107+
onChange={e => setDryRun(e.target.checked)}
108+
/>
109+
<Text>Dry run (triage only, no autofix triggered)</Text>
110+
</Flex>
99111
<Button
100112
priority="primary"
101113
type="submit"

tests/sentry/seer/endpoints/test_admin_night_shift_trigger.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@ def test_trigger_night_shift(self) -> None:
2828

2929
assert response.data["success"] is True
3030
assert response.data["organization_id"] == self.organization.id
31-
mock_task.apply_async.assert_called_once_with(args=[self.organization.id])
31+
mock_task.apply_async.assert_called_once_with(
32+
args=[self.organization.id], kwargs={"dry_run": False}
33+
)
3234

3335
def test_missing_organization_id(self) -> None:
3436
response = self.get_response()

tests/sentry/tasks/seer/test_night_shift.py

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,9 @@ def test_filters_by_automation_and_repos(self) -> None:
123123
self.create_project(organization=org)
124124

125125
with self.feature("organizations:seer-project-settings-read-from-sentry"):
126-
assert _get_eligible_projects(org) == [eligible]
126+
projects, preferences = _get_eligible_projects(org)
127+
assert projects == [eligible]
128+
assert eligible.id in preferences
127129

128130

129131
@django_db_all
@@ -264,6 +266,80 @@ def test_triage_error_records_error_message(self) -> None:
264266
assert run.error_message == "Night shift run failed"
265267
assert not SeerNightShiftRunIssue.objects.filter(run=run).exists()
266268

269+
def test_triggers_autofix_for_fixable_candidates(self) -> None:
270+
org = self.create_organization()
271+
project = self.create_project(organization=org)
272+
self._make_eligible(project)
273+
274+
group = self._store_event_and_update_group(
275+
project, "fixable", seer_fixability_score=0.9, times_seen=5
276+
)
277+
278+
fake_client = FakeExplorerClient([group.id], action="autofix")
279+
with (
280+
self.feature("organizations:seer-project-settings-read-from-sentry"),
281+
patch(
282+
"sentry.tasks.seer.night_shift.agentic_triage.SeerExplorerClient",
283+
return_value=fake_client,
284+
),
285+
patch("sentry.tasks.seer.night_shift.cron._trigger_autofix_task") as mock_autofix_task,
286+
):
287+
run_night_shift_for_org(org.id)
288+
289+
mock_autofix_task.delay.assert_called_once()
290+
call_kwargs = mock_autofix_task.delay.call_args.kwargs
291+
assert call_kwargs["group_id"] == group.id
292+
assert call_kwargs["user_id"] is None
293+
assert call_kwargs["auto_run_source"] == "night_shift"
294+
295+
def test_dry_run_skips_autofix(self) -> None:
296+
org = self.create_organization()
297+
project = self.create_project(organization=org)
298+
self._make_eligible(project)
299+
300+
group = self._store_event_and_update_group(
301+
project, "fixable", seer_fixability_score=0.9, times_seen=5
302+
)
303+
304+
fake_client = FakeExplorerClient([group.id], action="autofix")
305+
with (
306+
self.feature("organizations:seer-project-settings-read-from-sentry"),
307+
patch(
308+
"sentry.tasks.seer.night_shift.agentic_triage.SeerExplorerClient",
309+
return_value=fake_client,
310+
),
311+
patch("sentry.tasks.seer.night_shift.cron._trigger_autofix_task") as mock_autofix_task,
312+
):
313+
run_night_shift_for_org(org.id, dry_run=True)
314+
315+
mock_autofix_task.delay.assert_not_called()
316+
317+
# Candidates should still be saved to DB
318+
run = SeerNightShiftRun.objects.get(organization=org)
319+
assert SeerNightShiftRunIssue.objects.filter(run=run).count() == 1
320+
321+
def test_skips_autofix_for_non_autofix_candidates(self) -> None:
322+
org = self.create_organization()
323+
project = self.create_project(organization=org)
324+
self._make_eligible(project)
325+
326+
group = self._store_event_and_update_group(
327+
project, "skip-me", seer_fixability_score=0.9, times_seen=5
328+
)
329+
330+
fake_client = FakeExplorerClient([group.id], action="root_cause_only")
331+
with (
332+
self.feature("organizations:seer-project-settings-read-from-sentry"),
333+
patch(
334+
"sentry.tasks.seer.night_shift.agentic_triage.SeerExplorerClient",
335+
return_value=fake_client,
336+
),
337+
patch("sentry.tasks.seer.night_shift.cron._trigger_autofix_task") as mock_autofix_task,
338+
):
339+
run_night_shift_for_org(org.id)
340+
341+
mock_autofix_task.delay.assert_not_called()
342+
267343
def test_empty_candidates_creates_run_with_no_issues(self) -> None:
268344
org = self.create_organization()
269345
project = self.create_project(organization=org)

0 commit comments

Comments
 (0)