From 41a5b3236ec387e25fbd53f8870b48bb680e43c7 Mon Sep 17 00:00:00 2001 From: joseph-sentry Date: Fri, 12 Jun 2026 10:39:54 -0400 Subject: [PATCH 01/32] fold: trigger feedback model + storage/read (-> trigger) --- src/sentry/seer/autofix/autofix_agent.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/sentry/seer/autofix/autofix_agent.py b/src/sentry/seer/autofix/autofix_agent.py index 317f0e266c78..8b3a97702f28 100644 --- a/src/sentry/seer/autofix/autofix_agent.py +++ b/src/sentry/seer/autofix/autofix_agent.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json import logging import re from enum import StrEnum From 6ae5b2d008dc1427845ae7589e348dd78b0be1c5 Mon Sep 17 00:00:00 2001 From: joseph-sentry Date: Mon, 8 Jun 2026 14:19:29 -0400 Subject: [PATCH 02/32] feat(autofix): Handle PR iteration completion and introspection Send the ITERATION_COMPLETED webhook when a PR_ITERATION step finishes, including the pull request payload, code changes, and iteration index, and record the iteration index on the introspection analytics event. Add introspect_iteration to evaluate whether revised iteration changes are ready to update the existing pull request, and route PR_ITERATION through it. Extract the pull-request and code-changes payload builders into shared helpers. Co-Authored-By: Claude Opus 4 --- src/sentry/seer/autofix/autofix_agent.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/sentry/seer/autofix/autofix_agent.py b/src/sentry/seer/autofix/autofix_agent.py index 8b3a97702f28..317f0e266c78 100644 --- a/src/sentry/seer/autofix/autofix_agent.py +++ b/src/sentry/seer/autofix/autofix_agent.py @@ -1,6 +1,5 @@ from __future__ import annotations -import json import logging import re from enum import StrEnum From 4328d0aefc83f437f826f151999c20972cd67181 Mon Sep 17 00:00:00 2001 From: joseph-sentry Date: Mon, 8 Jun 2026 14:21:10 -0400 Subject: [PATCH 03/32] feat(autofix): Add pr_iteration endpoint step and operator activity Accept the pr_iteration step in the explorer autofix endpoint, gated behind the autofix-pr-iteration feature flag. The step requires an existing run with at least one created pull request, otherwise it returns a 400. Map the SEER_ITERATION_COMPLETED webhook to a SEER_ITERATION_COMPLETED group activity in the operator, carrying the pull requests and iteration index. Co-Authored-By: Claude Opus 4 --- src/sentry/seer/endpoints/group_ai_autofix.py | 25 ++++++ src/sentry/seer/entrypoints/operator.py | 10 +++ .../seer/endpoints/test_group_ai_autofix.py | 89 ++++++++++++++++++- .../sentry/seer/entrypoints/test_operator.py | 30 +++++++ 4 files changed, 153 insertions(+), 1 deletion(-) diff --git a/src/sentry/seer/endpoints/group_ai_autofix.py b/src/sentry/seer/endpoints/group_ai_autofix.py index dfcffd488c74..2e961863c407 100644 --- a/src/sentry/seer/endpoints/group_ai_autofix.py +++ b/src/sentry/seer/endpoints/group_ai_autofix.py @@ -10,6 +10,7 @@ from rest_framework.request import Request from rest_framework.response import Response +from sentry import features from sentry.api.api_owners import ApiOwner from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import cell_silo_endpoint @@ -94,6 +95,7 @@ class ExplorerAutofixRequestSerializer(CamelSnakeSerializer): "root_cause", "solution", "code_changes", + "pr_iteration", "open_pr", "coding_agent_handoff", ], @@ -295,6 +297,29 @@ def post( } return Response(open_pr_body, status=status.HTTP_202_ACCEPTED) + if step == "pr_iteration": + if not features.has("organizations:autofix-pr-iteration", group.organization): + return Response( + {"detail": "PR iteration is not enabled for this organization"}, + status=status.HTTP_403_FORBIDDEN, + ) + if not run_id: + return Response( + {"detail": "run_id is required for pr_iteration"}, + status=status.HTTP_400_BAD_REQUEST, + ) + try: + state = get_autofix_agent_state(group.organization, group.id) + except SeerPermissionError as e: + if _is_unknown_run_id_error(e): + return Response(status=status.HTTP_404_NOT_FOUND) + raise PermissionDenied(SEER_PERMISSION_DENIED) + if state is None or not state.repo_pr_states: + return Response( + {"detail": "Cannot iterate on a PR before one has been created"}, + status=status.HTTP_400_BAD_REQUEST, + ) + # Handle all built-in Seer steps. A missing run_id means this call starts a new # autofix run (the kickoff); a provided run_id is advancing an existing run. is_autofix_kickoff = resolved_run_id is None diff --git a/src/sentry/seer/entrypoints/operator.py b/src/sentry/seer/entrypoints/operator.py index e9e3a41c4c31..f5e149d3e4b0 100644 --- a/src/sentry/seer/entrypoints/operator.py +++ b/src/sentry/seer/entrypoints/operator.py @@ -587,6 +587,16 @@ def _create_seer_activity( pull_requests = event_payload.get("pull_requests", []) if pull_requests: activity_data["pull_requests"] = pull_requests + elif event_type == SentryAppEventType.SEER_ITERATION_COMPLETED: + pull_requests = event_payload.get("pull_requests", []) + if pull_requests: + activity_data["pull_requests"] = pull_requests + code_changes = event_payload.get("code_changes") + if code_changes: + activity_data["code_changes"] = code_changes + iteration_index = event_payload.get("iteration_index") + if iteration_index is not None: + activity_data["iteration_index"] = iteration_index Activity.objects.create_group_activity( group, diff --git a/tests/sentry/seer/endpoints/test_group_ai_autofix.py b/tests/sentry/seer/endpoints/test_group_ai_autofix.py index dc1e24ee37bb..53835cda2e87 100644 --- a/tests/sentry/seer/endpoints/test_group_ai_autofix.py +++ b/tests/sentry/seer/endpoints/test_group_ai_autofix.py @@ -2,7 +2,7 @@ from unittest.mock import Mock, patch from sentry.issues.action_log.types import TriggerAutofixAction -from sentry.seer.agent.client_models import SeerRunState +from sentry.seer.agent.client_models import RepoPRState, SeerRunState from sentry.seer.autofix.autofix_agent import AutofixStep, NoSeerQuotaException from sentry.seer.autofix.constants import AutofixReferrer from sentry.seer.autofix.utils import AutofixStoppingPoint @@ -254,6 +254,93 @@ def test_coding_agent_handoff_skips_action(self, mock_handoff, mock_publish): assert response.status_code == 202, response.data mock_publish.assert_not_called() + @with_feature("organizations:autofix-pr-iteration") + @patch("sentry.seer.endpoints.group_ai_autofix.get_autofix_agent_state") + @patch("sentry.seer.endpoints.group_ai_autofix.trigger_autofix_agent") + def test_pr_iteration(self, mock_trigger_explorer, mock_get_state): + group = self.create_group() + mock_trigger_explorer.return_value = 123 + mock_get_state.return_value = SeerRunState( + run_id=123, + blocks=[], + status="completed", + updated_at="2024-01-01T00:00:00Z", + repo_pr_states={ + "owner/repo": RepoPRState( + repo_name="owner/repo", pr_url="https://example.com/pull/7" + ) + }, + ) + + self.login_as(user=self.user) + response = self.client.post( + self._get_url(group.id), + data={"step": "pr_iteration", "run_id": 123}, + format="json", + ) + + assert response.status_code == 202, response.data + mock_trigger_explorer.assert_called_once_with( + group=group, + step=AutofixStep.PR_ITERATION, + referrer=AutofixReferrer.GROUP_AUTOFIX_ENDPOINT, + stopping_point=None, + run_id=123, + user_context=None, + insert_index=None, + ) + + @patch("sentry.seer.endpoints.group_ai_autofix.trigger_autofix_agent") + def test_pr_iteration_requires_feature_flag(self, mock_trigger_explorer): + group = self.create_group() + + self.login_as(user=self.user) + response = self.client.post( + self._get_url(group.id), + data={"step": "pr_iteration", "run_id": 123}, + format="json", + ) + + assert response.status_code == 403, response.data + mock_trigger_explorer.assert_not_called() + + @with_feature("organizations:autofix-pr-iteration") + @patch("sentry.seer.endpoints.group_ai_autofix.trigger_autofix_agent") + def test_pr_iteration_requires_run_id(self, mock_trigger_explorer): + group = self.create_group() + + self.login_as(user=self.user) + response = self.client.post( + self._get_url(group.id), + data={"step": "pr_iteration"}, + format="json", + ) + + assert response.status_code == 400, response.data + mock_trigger_explorer.assert_not_called() + + @with_feature("organizations:autofix-pr-iteration") + @patch("sentry.seer.endpoints.group_ai_autofix.get_autofix_agent_state") + @patch("sentry.seer.endpoints.group_ai_autofix.trigger_autofix_agent") + def test_pr_iteration_requires_existing_pr(self, mock_trigger_explorer, mock_get_state): + group = self.create_group() + mock_get_state.return_value = SeerRunState( + run_id=123, + blocks=[], + status="completed", + updated_at="2024-01-01T00:00:00Z", + ) + + self.login_as(user=self.user) + response = self.client.post( + self._get_url(group.id), + data={"step": "pr_iteration", "run_id": 123}, + format="json", + ) + + assert response.status_code == 400, response.data + mock_trigger_explorer.assert_not_called() + @patch("sentry.seer.endpoints.group_ai_autofix.trigger_autofix_agent") def test_post_continue_unknown_run_returns_404(self, mock_trigger_explorer): mock_trigger_explorer.side_effect = SeerPermissionError("Unknown run id for group") diff --git a/tests/sentry/seer/entrypoints/test_operator.py b/tests/sentry/seer/entrypoints/test_operator.py index 3685ccc481ad..b6966ae0eafa 100644 --- a/tests/sentry/seer/entrypoints/test_operator.py +++ b/tests/sentry/seer/entrypoints/test_operator.py @@ -644,6 +644,36 @@ def test_create_seer_activity_pr_created_with_pull_requests(self, _mock_has_acce == "https://github.com/owner/repo/pull/42" ) + @patch.object(SeerAutofixOperator, "has_access", return_value=True) + def test_create_seer_activity_iteration_completed(self, _mock_has_access): + event_payload = { + "run_id": MOCK_RUN_ID, + "group_id": self.group.id, + "code_changes": {"owner/repo": [{"diff": "...", "path": "foo.py"}]}, + "pull_requests": [ + { + "pull_request": { + "pr_number": 42, + "pr_url": "https://github.com/owner/repo/pull/42", + }, + "repo_name": "owner/repo", + "provider": "github", + } + ], + } + + process_autofix_updates( + event_type=SentryAppEventType.SEER_ITERATION_COMPLETED, + event_payload=event_payload, + organization_id=self.organization.id, + ) + + activity = Activity.objects.get( + group=self.group, type=ActivityType.SEER_ITERATION_COMPLETED.value + ) + assert activity.data["pull_requests"][0]["repo_name"] == "owner/repo" + assert activity.data["code_changes"]["owner/repo"][0]["path"] == "foo.py" + @patch("sentry.models.activity.invoke_workflow_activity_handlers") @patch.object(SeerAutofixOperator, "has_access", return_value=True) def test_create_seer_activity_invokes_workflow_activity_handlers( From 6fd0dd2344033b4b1567a8775acce5114143642c Mon Sep 17 00:00:00 2001 From: joseph-sentry Date: Fri, 12 Jun 2026 10:40:01 -0400 Subject: [PATCH 04/32] fold: get_autofix_run_state + endpoint feedback wiring (-> entrypoints) --- src/sentry/seer/endpoints/group_ai_autofix.py | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/src/sentry/seer/endpoints/group_ai_autofix.py b/src/sentry/seer/endpoints/group_ai_autofix.py index 2e961863c407..609cdfade8ce 100644 --- a/src/sentry/seer/endpoints/group_ai_autofix.py +++ b/src/sentry/seer/endpoints/group_ai_autofix.py @@ -43,8 +43,10 @@ from sentry.seer.autofix.autofix_agent import ( UNKNOWN_RUN_ID_FOR_GROUP, AutofixStep, + Feedback, NoSeerQuotaException, get_autofix_agent_state, + get_autofix_run_state, trigger_autofix_agent, trigger_coding_agent_handoff, trigger_push_changes, @@ -309,12 +311,12 @@ def post( status=status.HTTP_400_BAD_REQUEST, ) try: - state = get_autofix_agent_state(group.organization, group.id) + state = get_autofix_run_state(group, run_id) except SeerPermissionError as e: if _is_unknown_run_id_error(e): return Response(status=status.HTTP_404_NOT_FOUND) raise PermissionDenied(SEER_PERMISSION_DENIED) - if state is None or not state.repo_pr_states: + if not state.repo_pr_states: return Response( {"detail": "Cannot iterate on a PR before one has been created"}, status=status.HTTP_400_BAD_REQUEST, @@ -323,6 +325,21 @@ def post( # Handle all built-in Seer steps. A missing run_id means this call starts a new # autofix run (the kickoff); a provided run_id is advancing an existing run. is_autofix_kickoff = resolved_run_id is None + user_context = data.get("user_context") + feedback = None + if ( + step == "pr_iteration" + and user_context is not None + and request.user + and request.user.is_authenticated + ): + feedback = Feedback( + message=user_context, + source={ + "type": "user-ui", + "user_id": request.user.id, + }, + ) try: run_id = trigger_autofix_agent( group=group, @@ -330,8 +347,9 @@ def post( referrer=_parse_autofix_referrer(data.get("referrer")), stopping_point=AutofixStoppingPoint(stopping_point) if stopping_point else None, run_id=resolved_run_id, - user_context=data.get("user_context"), + user_context=user_context, insert_index=data.get("insert_index"), + feedback=feedback, ) if is_autofix_kickoff: # Record the trigger action only on kickoff, not on each subsequent From d395c21333500f31c3703f3396b6ff6078efe010 Mon Sep 17 00:00:00 2001 From: joseph-sentry Date: Sun, 14 Jun 2026 21:10:15 -0400 Subject: [PATCH 05/32] fix --- src/sentry/seer/autofix/autofix_agent.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/sentry/seer/autofix/autofix_agent.py b/src/sentry/seer/autofix/autofix_agent.py index 317f0e266c78..3a1b6faaf367 100644 --- a/src/sentry/seer/autofix/autofix_agent.py +++ b/src/sentry/seer/autofix/autofix_agent.py @@ -274,6 +274,11 @@ def get_autofix_agent_client( ) +def get_autofix_run_state(group: Group, run_id: int) -> SeerRunState: + client = get_autofix_agent_client(group) + return _get_group_run_state(client, group, run_id) + + def _validate_run_belongs_to_group(state: SeerRunState, group: Group) -> None: group_id = state.metadata.get("group_id") if state.metadata else None if group_id != group.id: From 53c66ba6e260e8eb14ef4d5ffeae031417d822f0 Mon Sep 17 00:00:00 2001 From: joseph-sentry Date: Mon, 15 Jun 2026 08:43:50 -0400 Subject: [PATCH 06/32] fixes and serialize users --- src/sentry/seer/endpoints/group_ai_autofix.py | 45 +++++++++++++++++-- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/src/sentry/seer/endpoints/group_ai_autofix.py b/src/sentry/seer/endpoints/group_ai_autofix.py index 609cdfade8ce..b8b471fe0c7c 100644 --- a/src/sentry/seer/endpoints/group_ai_autofix.py +++ b/src/sentry/seer/endpoints/group_ai_autofix.py @@ -68,6 +68,9 @@ from sentry.seer.endpoints.utils import get_seer_run, resolve_seer_run from sentry.seer.models import SeerPermissionError from sentry.types.ratelimit import RateLimit, RateLimitCategory +from sentry.users.services.user.serial import serialize_generic_user +from sentry.users.services.user.service import user_service +from sentry.utils import json logger = logging.getLogger(__name__) @@ -88,6 +91,40 @@ def _parse_autofix_referrer(raw: str | None) -> AutofixReferrer: return AutofixReferrer.UNKNOWN +def _hydrate_feedback_users(blocks: list[dict[str, Any]], request_user: Any) -> None: + parsed_by_index: dict[int, dict[str, Any]] = {} + user_ids: set[int] = set() + for index, block in enumerate(blocks): + raw = (block.get("message") or {}).get("metadata", {}).get("feedback") + if not raw: + continue + try: + feedback = json.loads(raw) + except (ValueError, TypeError): + continue + user_id = (feedback.get("source") or {}).get("user_id") + if not isinstance(user_id, int): + continue + parsed_by_index[index] = feedback + user_ids.add(user_id) + + if not user_ids: + return + + users = { + u["id"]: u + for u in user_service.serialize_many( + filter={"user_ids": list(user_ids)}, + as_user=serialize_generic_user(request_user), + ) + } + + for index, feedback in parsed_by_index.items(): + user_id = feedback["source"]["user_id"] + feedback["source"]["user"] = users.get(user_id) + blocks[index]["message"]["metadata"]["feedback"] = json.dumps(feedback) + + class ExplorerAutofixRequestSerializer(CamelSnakeSerializer): """Serializer for the agent-based autofix requests.""" @@ -305,13 +342,13 @@ def post( {"detail": "PR iteration is not enabled for this organization"}, status=status.HTTP_403_FORBIDDEN, ) - if not run_id: + if resolved_run_id is None: return Response( {"detail": "run_id is required for pr_iteration"}, status=status.HTTP_400_BAD_REQUEST, ) try: - state = get_autofix_run_state(group, run_id) + state = get_autofix_run_state(group, resolved_run_id) except SeerPermissionError as e: if _is_unknown_run_id_error(e): return Response(status=status.HTTP_404_NOT_FOUND) @@ -436,13 +473,15 @@ def get(self, request: Request, group: Group) -> Response[AutofixStateResponse]: ) run = get_seer_run(state.run_id, group.organization) + blocks = [block.dict() for block in state.blocks] + _hydrate_feedback_users(blocks, request.user) return Response( { "autofix": { "run_id": state.run_id, "sentry_run_id": str(run.uuid) if run else None, "status": state.status, - "blocks": [block.dict() for block in state.blocks], + "blocks": blocks, "updated_at": state.updated_at, "pending_user_input": ( state.pending_user_input.dict() if state.pending_user_input else None From 7494be6ee3342ceed1b70eed78a8f217025e42a1 Mon Sep 17 00:00:00 2001 From: joseph-sentry Date: Mon, 15 Jun 2026 10:06:40 -0400 Subject: [PATCH 07/32] fix(seer): Guard against null message metadata in feedback hydration _hydrate_feedback_users assumed every block message carried a metadata dict, but messages can have metadata set to null. Calling .get() on the result crashed when hydrating feedback users. Coalesce metadata to an empty dict before reading the feedback field. Co-Authored-By: Claude --- src/sentry/seer/endpoints/group_ai_autofix.py | 3 ++- .../seer/endpoints/test_group_ai_autofix.py | 24 ++++++++++++++++++- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/sentry/seer/endpoints/group_ai_autofix.py b/src/sentry/seer/endpoints/group_ai_autofix.py index b8b471fe0c7c..90b4a7916cb3 100644 --- a/src/sentry/seer/endpoints/group_ai_autofix.py +++ b/src/sentry/seer/endpoints/group_ai_autofix.py @@ -95,7 +95,8 @@ def _hydrate_feedback_users(blocks: list[dict[str, Any]], request_user: Any) -> parsed_by_index: dict[int, dict[str, Any]] = {} user_ids: set[int] = set() for index, block in enumerate(blocks): - raw = (block.get("message") or {}).get("metadata", {}).get("feedback") + metadata = (block.get("message") or {}).get("metadata") or {} + raw = metadata.get("feedback") if not raw: continue try: diff --git a/tests/sentry/seer/endpoints/test_group_ai_autofix.py b/tests/sentry/seer/endpoints/test_group_ai_autofix.py index 53835cda2e87..2a17f80a203d 100644 --- a/tests/sentry/seer/endpoints/test_group_ai_autofix.py +++ b/tests/sentry/seer/endpoints/test_group_ai_autofix.py @@ -2,7 +2,7 @@ from unittest.mock import Mock, patch from sentry.issues.action_log.types import TriggerAutofixAction -from sentry.seer.agent.client_models import RepoPRState, SeerRunState +from sentry.seer.agent.client_models import MemoryBlock, Message, RepoPRState, SeerRunState from sentry.seer.autofix.autofix_agent import AutofixStep, NoSeerQuotaException from sentry.seer.autofix.constants import AutofixReferrer from sentry.seer.autofix.utils import AutofixStoppingPoint @@ -58,6 +58,28 @@ def test_get_includes_sentry_run_id(self, mock_get_explorer_state): assert response.data["autofix"]["run_id"] == 888 assert response.data["autofix"]["sentry_run_id"] == str(run.uuid) + @patch("sentry.seer.endpoints.group_ai_autofix.get_autofix_agent_state") + def test_get_handles_block_with_null_metadata(self, mock_get_explorer_state): + group = self.create_group() + mock_get_explorer_state.return_value = SeerRunState( + run_id=888, + blocks=[ + MemoryBlock( + id="block-1", + message=Message(role="assistant", content="No metadata", metadata=None), + timestamp="2023-07-18T12:00:00Z", + ) + ], + status="completed", + updated_at="2023-07-18T12:00:00Z", + ) + + self.login_as(user=self.user) + response = self.client.get(self._get_url(group.id), format="json") + + assert response.status_code == 200, response.data + assert response.data["autofix"]["blocks"][0]["message"]["metadata"] is None + @patch("sentry.seer.endpoints.group_ai_autofix.trigger_autofix_agent") def test_post_triggers_autofix_agent(self, mock_trigger_explorer): group = self.create_group() From 06bce41ccb414c47ac2034d6d353d4092e8bd53a Mon Sep 17 00:00:00 2001 From: joseph-sentry Date: Mon, 15 Jun 2026 12:46:10 -0400 Subject: [PATCH 08/32] fix(seer): Fix autofix pr_iteration test mocks and feedback kwarg Patch get_autofix_run_state (not get_autofix_agent_state) in pr_iteration tests, and add the feedback=None kwarg to trigger_autofix_agent call assertions. --- tests/sentry/seer/endpoints/test_group_ai_autofix.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/sentry/seer/endpoints/test_group_ai_autofix.py b/tests/sentry/seer/endpoints/test_group_ai_autofix.py index 2a17f80a203d..b2623c02ed48 100644 --- a/tests/sentry/seer/endpoints/test_group_ai_autofix.py +++ b/tests/sentry/seer/endpoints/test_group_ai_autofix.py @@ -196,6 +196,7 @@ def test_stopping_point(self, mock_trigger_explorer): run_id=None, user_context=None, insert_index=None, + feedback=None, ) @patch("sentry.seer.endpoints.group_ai_autofix.trigger_autofix_agent") @@ -220,6 +221,7 @@ def test_insert_index_passed_through(self, mock_trigger_explorer): run_id=42, user_context=None, insert_index=3, + feedback=None, ) @patch("sentry.seer.endpoints.group_ai_autofix.publish_action", autospec=True) @@ -277,7 +279,7 @@ def test_coding_agent_handoff_skips_action(self, mock_handoff, mock_publish): mock_publish.assert_not_called() @with_feature("organizations:autofix-pr-iteration") - @patch("sentry.seer.endpoints.group_ai_autofix.get_autofix_agent_state") + @patch("sentry.seer.endpoints.group_ai_autofix.get_autofix_run_state") @patch("sentry.seer.endpoints.group_ai_autofix.trigger_autofix_agent") def test_pr_iteration(self, mock_trigger_explorer, mock_get_state): group = self.create_group() @@ -310,6 +312,7 @@ def test_pr_iteration(self, mock_trigger_explorer, mock_get_state): run_id=123, user_context=None, insert_index=None, + feedback=None, ) @patch("sentry.seer.endpoints.group_ai_autofix.trigger_autofix_agent") @@ -342,7 +345,7 @@ def test_pr_iteration_requires_run_id(self, mock_trigger_explorer): mock_trigger_explorer.assert_not_called() @with_feature("organizations:autofix-pr-iteration") - @patch("sentry.seer.endpoints.group_ai_autofix.get_autofix_agent_state") + @patch("sentry.seer.endpoints.group_ai_autofix.get_autofix_run_state") @patch("sentry.seer.endpoints.group_ai_autofix.trigger_autofix_agent") def test_pr_iteration_requires_existing_pr(self, mock_trigger_explorer, mock_get_state): group = self.create_group() From 416d4c44bbb4ea65b2c0a5c0ac74b2dced2793a3 Mon Sep 17 00:00:00 2001 From: joseph-sentry Date: Mon, 15 Jun 2026 14:43:00 -0400 Subject: [PATCH 09/32] address bugbot comment --- src/sentry/seer/autofix/on_completion_hook.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/sentry/seer/autofix/on_completion_hook.py b/src/sentry/seer/autofix/on_completion_hook.py index bdc57f4e4798..86b8843b3a1c 100644 --- a/src/sentry/seer/autofix/on_completion_hook.py +++ b/src/sentry/seer/autofix/on_completion_hook.py @@ -372,7 +372,11 @@ def _maybe_continue_pipeline( group, ) if decision is not None: - iteration_index = get_latest_iteration_index(state) + iteration_index = ( + get_latest_iteration_index(state) + if current_step == AutofixStep.PR_ITERATION + else None + ) analytics.record( AiAutofixIntrospectionEvent( organization_id=organization.id, From 8286e229fcc7b3faddb03e4b38d2a9318c7b068b Mon Sep 17 00:00:00 2001 From: joseph-sentry Date: Mon, 15 Jun 2026 15:03:15 -0400 Subject: [PATCH 10/32] simplify --- src/sentry/seer/autofix/autofix_agent.py | 8 ++++- src/sentry/seer/endpoints/group_ai_autofix.py | 18 ++++------ .../sentry/seer/autofix/test_autofix_agent.py | 34 +++++++++++++++++-- .../seer/endpoints/test_group_ai_autofix.py | 34 ++++++------------- 4 files changed, 55 insertions(+), 39 deletions(-) diff --git a/src/sentry/seer/autofix/autofix_agent.py b/src/sentry/seer/autofix/autofix_agent.py index 3a1b6faaf367..29faa0d84576 100644 --- a/src/sentry/seer/autofix/autofix_agent.py +++ b/src/sentry/seer/autofix/autofix_agent.py @@ -87,6 +87,10 @@ class NoSeerQuotaException(Exception): pass +class PrIterationNoPullRequestException(Exception): + pass + + class AutofixStep(StrEnum): """Available autofix steps.""" @@ -379,7 +383,9 @@ def trigger_autofix_agent( pr_iteration_enabled = run_state.metadata.get("pr_iteration_enabled", pr_iteration_enabled) iteration_index: int | None = None - if step == AutofixStep.PR_ITERATION and run_state is not None: + if step == AutofixStep.PR_ITERATION: + if run_state is None or not run_state.repo_pr_states: + raise PrIterationNoPullRequestException() if insert_index is not None: iteration_index = get_iteration_for_insert_index(run_state, insert_index) else: diff --git a/src/sentry/seer/endpoints/group_ai_autofix.py b/src/sentry/seer/endpoints/group_ai_autofix.py index 90b4a7916cb3..762fdf8ecadd 100644 --- a/src/sentry/seer/endpoints/group_ai_autofix.py +++ b/src/sentry/seer/endpoints/group_ai_autofix.py @@ -45,8 +45,8 @@ AutofixStep, Feedback, NoSeerQuotaException, + PrIterationNoPullRequestException, get_autofix_agent_state, - get_autofix_run_state, trigger_autofix_agent, trigger_coding_agent_handoff, trigger_push_changes, @@ -348,17 +348,6 @@ def post( {"detail": "run_id is required for pr_iteration"}, status=status.HTTP_400_BAD_REQUEST, ) - try: - state = get_autofix_run_state(group, resolved_run_id) - except SeerPermissionError as e: - if _is_unknown_run_id_error(e): - return Response(status=status.HTTP_404_NOT_FOUND) - raise PermissionDenied(SEER_PERMISSION_DENIED) - if not state.repo_pr_states: - return Response( - {"detail": "Cannot iterate on a PR before one has been created"}, - status=status.HTTP_400_BAD_REQUEST, - ) # Handle all built-in Seer steps. A missing run_id means this call starts a new # autofix run (the kickoff); a provided run_id is advancing an existing run. @@ -415,6 +404,11 @@ def post( return Response(kickoff_body, status=status.HTTP_202_ACCEPTED) except NoSeerQuotaException: return Response("No budget for Seer Autofix.", status=status.HTTP_402_PAYMENT_REQUIRED) + except PrIterationNoPullRequestException: + return Response( + {"detail": "Cannot iterate on a PR before one has been created"}, + status=status.HTTP_400_BAD_REQUEST, + ) except SeerPermissionError as e: if _is_unknown_run_id_error(e): return Response(status=status.HTTP_404_NOT_FOUND) diff --git a/tests/sentry/seer/autofix/test_autofix_agent.py b/tests/sentry/seer/autofix/test_autofix_agent.py index 24d05bc6fa45..dbc6e93f28a6 100644 --- a/tests/sentry/seer/autofix/test_autofix_agent.py +++ b/tests/sentry/seer/autofix/test_autofix_agent.py @@ -15,6 +15,7 @@ STEP_CONFIGS, AutofixStep, NoSeerQuotaException, + PrIterationNoPullRequestException, build_step_prompt, generate_autofix_handoff_prompt, get_iteration_for_insert_index, @@ -256,12 +257,17 @@ def _iteration_block(iteration_index: int | None = None) -> MemoryBlock: ) -def _state_with_blocks(blocks: list[MemoryBlock], group_id: int | None = None) -> SeerRunState: +def _state_with_blocks( + blocks: list[MemoryBlock], + group_id: int | None = None, + repo_pr_states: dict[str, RepoPRState] | None = None, +) -> SeerRunState: return SeerRunState( run_id=67890, blocks=blocks, status="completed", updated_at="2024-01-01T00:00:00Z", + repo_pr_states=repo_pr_states or {}, metadata={"group_id": group_id} if group_id is not None else None, ) @@ -459,7 +465,13 @@ def test_pr_iteration_continued_run_increments_iteration_index( mock_client = MagicMock() mock_client_class.return_value = mock_client mock_client.get_run.return_value = _state_with_blocks( - [_iteration_block(1)], group_id=self.group.id + [_iteration_block(1)], + group_id=self.group.id, + repo_pr_states={ + "owner/repo": RepoPRState( + repo_name="owner/repo", pr_url="https://example.com/pull/7" + ) + }, ) mock_client.continue_run.return_value = 67890 @@ -474,6 +486,24 @@ def test_pr_iteration_continued_run_increments_iteration_index( assert call_kwargs["event_name"] == SeerActionType.ITERATION_STARTED.value assert call_kwargs["payload"]["iteration_index"] == 2 + @patch("sentry.seer.autofix.autofix_agent.broadcast_webhooks_for_organization.delay") + @patch("sentry.seer.autofix.autofix_agent.SeerAgentClient") + def test_pr_iteration_requires_existing_pr(self, mock_client_class, mock_broadcast): + mock_client = MagicMock() + mock_client_class.return_value = mock_client + mock_client.get_run.return_value = _state_with_blocks([], group_id=self.group.id) + + with pytest.raises(PrIterationNoPullRequestException): + trigger_autofix_agent( + group=self.group, + step=AutofixStep.PR_ITERATION, + referrer=AutofixReferrer.UNKNOWN, + run_id=67890, + ) + + mock_client.continue_run.assert_not_called() + mock_broadcast.assert_not_called() + @patch("sentry.seer.autofix.autofix_agent.SeerAgentClient") @patch("sentry.quotas.backend.check_seer_quota", return_value=False) def test_when_no_quota(self, mock_check_quota, mock_client_class): diff --git a/tests/sentry/seer/endpoints/test_group_ai_autofix.py b/tests/sentry/seer/endpoints/test_group_ai_autofix.py index b2623c02ed48..41a5691c7657 100644 --- a/tests/sentry/seer/endpoints/test_group_ai_autofix.py +++ b/tests/sentry/seer/endpoints/test_group_ai_autofix.py @@ -2,8 +2,12 @@ from unittest.mock import Mock, patch from sentry.issues.action_log.types import TriggerAutofixAction -from sentry.seer.agent.client_models import MemoryBlock, Message, RepoPRState, SeerRunState -from sentry.seer.autofix.autofix_agent import AutofixStep, NoSeerQuotaException +from sentry.seer.agent.client_models import MemoryBlock, Message, SeerRunState +from sentry.seer.autofix.autofix_agent import ( + AutofixStep, + NoSeerQuotaException, + PrIterationNoPullRequestException, +) from sentry.seer.autofix.constants import AutofixReferrer from sentry.seer.autofix.utils import AutofixStoppingPoint from sentry.seer.models import SeerPermissionError @@ -279,22 +283,10 @@ def test_coding_agent_handoff_skips_action(self, mock_handoff, mock_publish): mock_publish.assert_not_called() @with_feature("organizations:autofix-pr-iteration") - @patch("sentry.seer.endpoints.group_ai_autofix.get_autofix_run_state") @patch("sentry.seer.endpoints.group_ai_autofix.trigger_autofix_agent") - def test_pr_iteration(self, mock_trigger_explorer, mock_get_state): + def test_pr_iteration(self, mock_trigger_explorer): group = self.create_group() mock_trigger_explorer.return_value = 123 - mock_get_state.return_value = SeerRunState( - run_id=123, - blocks=[], - status="completed", - updated_at="2024-01-01T00:00:00Z", - repo_pr_states={ - "owner/repo": RepoPRState( - repo_name="owner/repo", pr_url="https://example.com/pull/7" - ) - }, - ) self.login_as(user=self.user) response = self.client.post( @@ -345,16 +337,10 @@ def test_pr_iteration_requires_run_id(self, mock_trigger_explorer): mock_trigger_explorer.assert_not_called() @with_feature("organizations:autofix-pr-iteration") - @patch("sentry.seer.endpoints.group_ai_autofix.get_autofix_run_state") @patch("sentry.seer.endpoints.group_ai_autofix.trigger_autofix_agent") - def test_pr_iteration_requires_existing_pr(self, mock_trigger_explorer, mock_get_state): + def test_pr_iteration_requires_existing_pr(self, mock_trigger_explorer): group = self.create_group() - mock_get_state.return_value = SeerRunState( - run_id=123, - blocks=[], - status="completed", - updated_at="2024-01-01T00:00:00Z", - ) + mock_trigger_explorer.side_effect = PrIterationNoPullRequestException() self.login_as(user=self.user) response = self.client.post( @@ -364,7 +350,7 @@ def test_pr_iteration_requires_existing_pr(self, mock_trigger_explorer, mock_get ) assert response.status_code == 400, response.data - mock_trigger_explorer.assert_not_called() + mock_trigger_explorer.assert_called_once() @patch("sentry.seer.endpoints.group_ai_autofix.trigger_autofix_agent") def test_post_continue_unknown_run_returns_404(self, mock_trigger_explorer): From 8b754c1343ef7b729760a1248a91025f0a79d566 Mon Sep 17 00:00:00 2001 From: joseph-sentry Date: Mon, 15 Jun 2026 15:11:59 -0400 Subject: [PATCH 11/32] feat(autofix): Expose pr_iteration_enabled in autofix state response --- src/sentry/seer/endpoints/group_ai_autofix.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/sentry/seer/endpoints/group_ai_autofix.py b/src/sentry/seer/endpoints/group_ai_autofix.py index 762fdf8ecadd..5b3ee2d14fba 100644 --- a/src/sentry/seer/endpoints/group_ai_autofix.py +++ b/src/sentry/seer/endpoints/group_ai_autofix.py @@ -487,6 +487,9 @@ def get(self, request: Request, group: Group) -> Response[AutofixStateResponse]: "coding_agents": { agent_id: agent.dict() for agent_id, agent in state.coding_agents.items() }, + "pr_iteration_enabled": bool( + state.metadata.get("pr_iteration_enabled") if state.metadata else False + ), } } ) From d9087c38f723ee068b71864dac790893ac4848b2 Mon Sep 17 00:00:00 2001 From: joseph-sentry Date: Fri, 12 Jun 2026 10:39:54 -0400 Subject: [PATCH 12/32] fold: trigger feedback model + storage/read (-> trigger) --- src/sentry/seer/autofix/autofix_agent.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/sentry/seer/autofix/autofix_agent.py b/src/sentry/seer/autofix/autofix_agent.py index 29faa0d84576..8ceb98138d2c 100644 --- a/src/sentry/seer/autofix/autofix_agent.py +++ b/src/sentry/seer/autofix/autofix_agent.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json import logging import re from enum import StrEnum From a1a4bc97440dec5838a39a6ce148662c4e9b01e2 Mon Sep 17 00:00:00 2001 From: joseph-sentry Date: Mon, 8 Jun 2026 14:19:29 -0400 Subject: [PATCH 13/32] feat(autofix): Handle PR iteration completion and introspection Send the ITERATION_COMPLETED webhook when a PR_ITERATION step finishes, including the pull request payload, code changes, and iteration index, and record the iteration index on the introspection analytics event. Add introspect_iteration to evaluate whether revised iteration changes are ready to update the existing pull request, and route PR_ITERATION through it. Extract the pull-request and code-changes payload builders into shared helpers. Co-Authored-By: Claude Opus 4 --- src/sentry/seer/autofix/autofix_agent.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/sentry/seer/autofix/autofix_agent.py b/src/sentry/seer/autofix/autofix_agent.py index 8ceb98138d2c..29faa0d84576 100644 --- a/src/sentry/seer/autofix/autofix_agent.py +++ b/src/sentry/seer/autofix/autofix_agent.py @@ -1,6 +1,5 @@ from __future__ import annotations -import json import logging import re from enum import StrEnum From 4dc85296d41ccdf73a28055d9801936fba20b3ce Mon Sep 17 00:00:00 2001 From: joseph-sentry Date: Mon, 8 Jun 2026 14:21:10 -0400 Subject: [PATCH 14/32] feat(autofix): Add pr_iteration endpoint step and operator activity Accept the pr_iteration step in the explorer autofix endpoint, gated behind the autofix-pr-iteration feature flag. The step requires an existing run with at least one created pull request, otherwise it returns a 400. Map the SEER_ITERATION_COMPLETED webhook to a SEER_ITERATION_COMPLETED group activity in the operator, carrying the pull requests and iteration index. Co-Authored-By: Claude Opus 4 --- src/sentry/seer/endpoints/group_ai_autofix.py | 11 ++++++ .../seer/endpoints/test_group_ai_autofix.py | 34 +++++++++++++------ 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/src/sentry/seer/endpoints/group_ai_autofix.py b/src/sentry/seer/endpoints/group_ai_autofix.py index 5b3ee2d14fba..113ab4171caf 100644 --- a/src/sentry/seer/endpoints/group_ai_autofix.py +++ b/src/sentry/seer/endpoints/group_ai_autofix.py @@ -348,6 +348,17 @@ def post( {"detail": "run_id is required for pr_iteration"}, status=status.HTTP_400_BAD_REQUEST, ) + try: + state = get_autofix_agent_state(group.organization, group.id) + except SeerPermissionError as e: + if _is_unknown_run_id_error(e): + return Response(status=status.HTTP_404_NOT_FOUND) + raise PermissionDenied(SEER_PERMISSION_DENIED) + if state is None or not state.repo_pr_states: + return Response( + {"detail": "Cannot iterate on a PR before one has been created"}, + status=status.HTTP_400_BAD_REQUEST, + ) # Handle all built-in Seer steps. A missing run_id means this call starts a new # autofix run (the kickoff); a provided run_id is advancing an existing run. diff --git a/tests/sentry/seer/endpoints/test_group_ai_autofix.py b/tests/sentry/seer/endpoints/test_group_ai_autofix.py index 41a5691c7657..8c6d99955207 100644 --- a/tests/sentry/seer/endpoints/test_group_ai_autofix.py +++ b/tests/sentry/seer/endpoints/test_group_ai_autofix.py @@ -2,12 +2,8 @@ from unittest.mock import Mock, patch from sentry.issues.action_log.types import TriggerAutofixAction -from sentry.seer.agent.client_models import MemoryBlock, Message, SeerRunState -from sentry.seer.autofix.autofix_agent import ( - AutofixStep, - NoSeerQuotaException, - PrIterationNoPullRequestException, -) +from sentry.seer.agent.client_models import MemoryBlock, Message, RepoPRState, SeerRunState +from sentry.seer.autofix.autofix_agent import AutofixStep, NoSeerQuotaException from sentry.seer.autofix.constants import AutofixReferrer from sentry.seer.autofix.utils import AutofixStoppingPoint from sentry.seer.models import SeerPermissionError @@ -283,10 +279,22 @@ def test_coding_agent_handoff_skips_action(self, mock_handoff, mock_publish): mock_publish.assert_not_called() @with_feature("organizations:autofix-pr-iteration") + @patch("sentry.seer.endpoints.group_ai_autofix.get_autofix_agent_state") @patch("sentry.seer.endpoints.group_ai_autofix.trigger_autofix_agent") - def test_pr_iteration(self, mock_trigger_explorer): + def test_pr_iteration(self, mock_trigger_explorer, mock_get_state): group = self.create_group() mock_trigger_explorer.return_value = 123 + mock_get_state.return_value = SeerRunState( + run_id=123, + blocks=[], + status="completed", + updated_at="2024-01-01T00:00:00Z", + repo_pr_states={ + "owner/repo": RepoPRState( + repo_name="owner/repo", pr_url="https://example.com/pull/7" + ) + }, + ) self.login_as(user=self.user) response = self.client.post( @@ -337,10 +345,16 @@ def test_pr_iteration_requires_run_id(self, mock_trigger_explorer): mock_trigger_explorer.assert_not_called() @with_feature("organizations:autofix-pr-iteration") + @patch("sentry.seer.endpoints.group_ai_autofix.get_autofix_agent_state") @patch("sentry.seer.endpoints.group_ai_autofix.trigger_autofix_agent") - def test_pr_iteration_requires_existing_pr(self, mock_trigger_explorer): + def test_pr_iteration_requires_existing_pr(self, mock_trigger_explorer, mock_get_state): group = self.create_group() - mock_trigger_explorer.side_effect = PrIterationNoPullRequestException() + mock_get_state.return_value = SeerRunState( + run_id=123, + blocks=[], + status="completed", + updated_at="2024-01-01T00:00:00Z", + ) self.login_as(user=self.user) response = self.client.post( @@ -350,7 +364,7 @@ def test_pr_iteration_requires_existing_pr(self, mock_trigger_explorer): ) assert response.status_code == 400, response.data - mock_trigger_explorer.assert_called_once() + mock_trigger_explorer.assert_not_called() @patch("sentry.seer.endpoints.group_ai_autofix.trigger_autofix_agent") def test_post_continue_unknown_run_returns_404(self, mock_trigger_explorer): From 99bb7f74bc33136020ec5063a0143725dc7a21d2 Mon Sep 17 00:00:00 2001 From: joseph-sentry Date: Fri, 12 Jun 2026 10:40:01 -0400 Subject: [PATCH 15/32] fold: get_autofix_run_state + endpoint feedback wiring (-> entrypoints) --- src/sentry/seer/endpoints/group_ai_autofix.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/sentry/seer/endpoints/group_ai_autofix.py b/src/sentry/seer/endpoints/group_ai_autofix.py index 113ab4171caf..9abc857b9900 100644 --- a/src/sentry/seer/endpoints/group_ai_autofix.py +++ b/src/sentry/seer/endpoints/group_ai_autofix.py @@ -47,6 +47,7 @@ NoSeerQuotaException, PrIterationNoPullRequestException, get_autofix_agent_state, + get_autofix_run_state, trigger_autofix_agent, trigger_coding_agent_handoff, trigger_push_changes, @@ -349,12 +350,12 @@ def post( status=status.HTTP_400_BAD_REQUEST, ) try: - state = get_autofix_agent_state(group.organization, group.id) + state = get_autofix_run_state(group, run_id) except SeerPermissionError as e: if _is_unknown_run_id_error(e): return Response(status=status.HTTP_404_NOT_FOUND) raise PermissionDenied(SEER_PERMISSION_DENIED) - if state is None or not state.repo_pr_states: + if not state.repo_pr_states: return Response( {"detail": "Cannot iterate on a PR before one has been created"}, status=status.HTTP_400_BAD_REQUEST, From c00e6a73ae7706e7e3bfffe30deb7f62ec42aeae Mon Sep 17 00:00:00 2001 From: joseph-sentry Date: Mon, 15 Jun 2026 08:43:50 -0400 Subject: [PATCH 16/32] fixes and serialize users --- src/sentry/seer/endpoints/group_ai_autofix.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sentry/seer/endpoints/group_ai_autofix.py b/src/sentry/seer/endpoints/group_ai_autofix.py index 9abc857b9900..36815d196e47 100644 --- a/src/sentry/seer/endpoints/group_ai_autofix.py +++ b/src/sentry/seer/endpoints/group_ai_autofix.py @@ -350,7 +350,7 @@ def post( status=status.HTTP_400_BAD_REQUEST, ) try: - state = get_autofix_run_state(group, run_id) + state = get_autofix_run_state(group, resolved_run_id) except SeerPermissionError as e: if _is_unknown_run_id_error(e): return Response(status=status.HTTP_404_NOT_FOUND) From 5bf75bc6362c650a53b3300c6f275fdf48229409 Mon Sep 17 00:00:00 2001 From: joseph-sentry Date: Mon, 15 Jun 2026 12:46:10 -0400 Subject: [PATCH 17/32] fix(seer): Fix autofix pr_iteration test mocks and feedback kwarg Patch get_autofix_run_state (not get_autofix_agent_state) in pr_iteration tests, and add the feedback=None kwarg to trigger_autofix_agent call assertions. --- tests/sentry/seer/endpoints/test_group_ai_autofix.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/sentry/seer/endpoints/test_group_ai_autofix.py b/tests/sentry/seer/endpoints/test_group_ai_autofix.py index 8c6d99955207..b2623c02ed48 100644 --- a/tests/sentry/seer/endpoints/test_group_ai_autofix.py +++ b/tests/sentry/seer/endpoints/test_group_ai_autofix.py @@ -279,7 +279,7 @@ def test_coding_agent_handoff_skips_action(self, mock_handoff, mock_publish): mock_publish.assert_not_called() @with_feature("organizations:autofix-pr-iteration") - @patch("sentry.seer.endpoints.group_ai_autofix.get_autofix_agent_state") + @patch("sentry.seer.endpoints.group_ai_autofix.get_autofix_run_state") @patch("sentry.seer.endpoints.group_ai_autofix.trigger_autofix_agent") def test_pr_iteration(self, mock_trigger_explorer, mock_get_state): group = self.create_group() @@ -345,7 +345,7 @@ def test_pr_iteration_requires_run_id(self, mock_trigger_explorer): mock_trigger_explorer.assert_not_called() @with_feature("organizations:autofix-pr-iteration") - @patch("sentry.seer.endpoints.group_ai_autofix.get_autofix_agent_state") + @patch("sentry.seer.endpoints.group_ai_autofix.get_autofix_run_state") @patch("sentry.seer.endpoints.group_ai_autofix.trigger_autofix_agent") def test_pr_iteration_requires_existing_pr(self, mock_trigger_explorer, mock_get_state): group = self.create_group() From 374e5a0522769e156dbe359c4af4eb7381fc9730 Mon Sep 17 00:00:00 2001 From: joseph-sentry Date: Fri, 12 Jun 2026 10:39:54 -0400 Subject: [PATCH 18/32] fold: trigger feedback model + storage/read (-> trigger) --- src/sentry/seer/autofix/autofix_agent.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/sentry/seer/autofix/autofix_agent.py b/src/sentry/seer/autofix/autofix_agent.py index 29faa0d84576..8ceb98138d2c 100644 --- a/src/sentry/seer/autofix/autofix_agent.py +++ b/src/sentry/seer/autofix/autofix_agent.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json import logging import re from enum import StrEnum From 6265cabd4014dccf4e1b17caebf027a63287abb4 Mon Sep 17 00:00:00 2001 From: joseph-sentry Date: Mon, 8 Jun 2026 14:19:29 -0400 Subject: [PATCH 19/32] feat(autofix): Handle PR iteration completion and introspection Send the ITERATION_COMPLETED webhook when a PR_ITERATION step finishes, including the pull request payload, code changes, and iteration index, and record the iteration index on the introspection analytics event. Add introspect_iteration to evaluate whether revised iteration changes are ready to update the existing pull request, and route PR_ITERATION through it. Extract the pull-request and code-changes payload builders into shared helpers. Co-Authored-By: Claude Opus 4 --- src/sentry/seer/autofix/autofix_agent.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/sentry/seer/autofix/autofix_agent.py b/src/sentry/seer/autofix/autofix_agent.py index 8ceb98138d2c..29faa0d84576 100644 --- a/src/sentry/seer/autofix/autofix_agent.py +++ b/src/sentry/seer/autofix/autofix_agent.py @@ -1,6 +1,5 @@ from __future__ import annotations -import json import logging import re from enum import StrEnum From c1f248e92ae46bf66a37c19268c4da7590249153 Mon Sep 17 00:00:00 2001 From: joseph-sentry Date: Mon, 15 Jun 2026 08:43:50 -0400 Subject: [PATCH 20/32] fixes and serialize users --- src/sentry/seer/endpoints/group_ai_autofix.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/sentry/seer/endpoints/group_ai_autofix.py b/src/sentry/seer/endpoints/group_ai_autofix.py index 36815d196e47..ec08744e4e83 100644 --- a/src/sentry/seer/endpoints/group_ai_autofix.py +++ b/src/sentry/seer/endpoints/group_ai_autofix.py @@ -48,6 +48,7 @@ PrIterationNoPullRequestException, get_autofix_agent_state, get_autofix_run_state, + is_pr_iteration_enabled, trigger_autofix_agent, trigger_coding_agent_handoff, trigger_push_changes, @@ -499,9 +500,7 @@ def get(self, request: Request, group: Group) -> Response[AutofixStateResponse]: "coding_agents": { agent_id: agent.dict() for agent_id, agent in state.coding_agents.items() }, - "pr_iteration_enabled": bool( - state.metadata.get("pr_iteration_enabled") if state.metadata else False - ), + "pr_iteration_enabled": is_pr_iteration_enabled(state), } } ) From cd736c20e637d0dc9ef38c57b00b479eeaed31db Mon Sep 17 00:00:00 2001 From: joseph-sentry Date: Fri, 12 Jun 2026 10:39:54 -0400 Subject: [PATCH 21/32] fold: trigger feedback model + storage/read (-> trigger) --- src/sentry/seer/autofix/autofix_agent.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/sentry/seer/autofix/autofix_agent.py b/src/sentry/seer/autofix/autofix_agent.py index 29faa0d84576..8ceb98138d2c 100644 --- a/src/sentry/seer/autofix/autofix_agent.py +++ b/src/sentry/seer/autofix/autofix_agent.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json import logging import re from enum import StrEnum From f8ab817e40297d96f6ac6a8171cb369a4aa99a78 Mon Sep 17 00:00:00 2001 From: joseph-sentry Date: Mon, 8 Jun 2026 14:19:29 -0400 Subject: [PATCH 22/32] feat(autofix): Handle PR iteration completion and introspection Send the ITERATION_COMPLETED webhook when a PR_ITERATION step finishes, including the pull request payload, code changes, and iteration index, and record the iteration index on the introspection analytics event. Add introspect_iteration to evaluate whether revised iteration changes are ready to update the existing pull request, and route PR_ITERATION through it. Extract the pull-request and code-changes payload builders into shared helpers. Co-Authored-By: Claude Opus 4 --- src/sentry/seer/autofix/autofix_agent.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/sentry/seer/autofix/autofix_agent.py b/src/sentry/seer/autofix/autofix_agent.py index 8ceb98138d2c..29faa0d84576 100644 --- a/src/sentry/seer/autofix/autofix_agent.py +++ b/src/sentry/seer/autofix/autofix_agent.py @@ -1,6 +1,5 @@ from __future__ import annotations -import json import logging import re from enum import StrEnum From af1671786028bec5c324bcfd63cecb752702d5c0 Mon Sep 17 00:00:00 2001 From: joseph-sentry Date: Fri, 12 Jun 2026 10:39:54 -0400 Subject: [PATCH 23/32] fold: trigger feedback model + storage/read (-> trigger) --- src/sentry/seer/autofix/autofix_agent.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/sentry/seer/autofix/autofix_agent.py b/src/sentry/seer/autofix/autofix_agent.py index 29faa0d84576..8ceb98138d2c 100644 --- a/src/sentry/seer/autofix/autofix_agent.py +++ b/src/sentry/seer/autofix/autofix_agent.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json import logging import re from enum import StrEnum From eb47b864a9d5f2518b9e5dbec150ce96114de9a9 Mon Sep 17 00:00:00 2001 From: joseph-sentry Date: Mon, 8 Jun 2026 14:19:29 -0400 Subject: [PATCH 24/32] feat(autofix): Handle PR iteration completion and introspection Send the ITERATION_COMPLETED webhook when a PR_ITERATION step finishes, including the pull request payload, code changes, and iteration index, and record the iteration index on the introspection analytics event. Add introspect_iteration to evaluate whether revised iteration changes are ready to update the existing pull request, and route PR_ITERATION through it. Extract the pull-request and code-changes payload builders into shared helpers. Co-Authored-By: Claude Opus 4 --- src/sentry/seer/autofix/autofix_agent.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/sentry/seer/autofix/autofix_agent.py b/src/sentry/seer/autofix/autofix_agent.py index 8ceb98138d2c..29faa0d84576 100644 --- a/src/sentry/seer/autofix/autofix_agent.py +++ b/src/sentry/seer/autofix/autofix_agent.py @@ -1,6 +1,5 @@ from __future__ import annotations -import json import logging import re from enum import StrEnum From eee7e9355e0b91352f062fe7f97f369faa2dafc5 Mon Sep 17 00:00:00 2001 From: joseph-sentry Date: Tue, 9 Jun 2026 11:34:36 -0400 Subject: [PATCH 25/32] feat(autofix): Add iteration_index and pr_iteration_enabled to analytics Surface the PR iteration dimensions on the autofix analytics, metrics, and logs so iteration runs can be segmented and correlated. - Add pr_iteration_enabled to the AiAutofixPhaseEvent base so all phase events carry it, and populate it (plus iteration_index) on the started, completed, and introspection events. - Tag the autofix.explorer.trigger and autofix.explorer.complete metrics with iteration_index and pr_iteration_enabled. - Include both dimensions in the introspection, continuing_pipeline, and trigger webhook-failure logs. - Extract an is_pr_iteration_enabled(state) helper to avoid repeating the metadata lookup across emission sites. Co-Authored-By: Claude Opus 4.8 --- src/sentry/analytics/events/autofix_events.py | 1 + src/sentry/seer/autofix/autofix_agent.py | 8 ++++++ src/sentry/seer/autofix/on_completion_hook.py | 25 ++++++++++++++----- 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/src/sentry/analytics/events/autofix_events.py b/src/sentry/analytics/events/autofix_events.py index 265157b9611e..c0790e9f290c 100644 --- a/src/sentry/analytics/events/autofix_events.py +++ b/src/sentry/analytics/events/autofix_events.py @@ -8,6 +8,7 @@ class AiAutofixPhaseEvent(analytics.Event): group_id: int referrer: str | None iteration_index: int | None = None + pr_iteration_enabled: bool | None = None @analytics.eventclass("ai.autofix.root_cause.started") diff --git a/src/sentry/seer/autofix/autofix_agent.py b/src/sentry/seer/autofix/autofix_agent.py index 29faa0d84576..656023133078 100644 --- a/src/sentry/seer/autofix/autofix_agent.py +++ b/src/sentry/seer/autofix/autofix_agent.py @@ -246,6 +246,10 @@ def get_latest_iteration_index(state: SeerRunState) -> int: return 0 +def is_pr_iteration_enabled(state: SeerRunState) -> bool: + return bool(state.metadata.get("pr_iteration_enabled") if state.metadata else False) + + def get_iteration_for_insert_index(state: SeerRunState, insert_index: int) -> int: block = state.blocks[insert_index] metadata = block.message.metadata or {} @@ -399,6 +403,7 @@ def trigger_autofix_agent( group_id=group.id, referrer=referrer.value, iteration_index=iteration_index, + pr_iteration_enabled=pr_iteration_enabled, ) ) @@ -498,6 +503,8 @@ def trigger_autofix_agent( "step": step.value, "run_id": run_id, "group_id": group.id, + "iteration_index": iteration_index, + "pr_iteration_enabled": pr_iteration_enabled, }, ) @@ -507,6 +514,7 @@ def trigger_autofix_agent( "step": step.value, "referrer": referrer.value, "iteration_index": iteration_index, + "pr_iteration_enabled": pr_iteration_enabled, }, ) diff --git a/src/sentry/seer/autofix/on_completion_hook.py b/src/sentry/seer/autofix/on_completion_hook.py index 86b8843b3a1c..d47c1eec51b1 100644 --- a/src/sentry/seer/autofix/on_completion_hook.py +++ b/src/sentry/seer/autofix/on_completion_hook.py @@ -21,6 +21,7 @@ STEP_CONFIGS, AutofixStep, get_latest_iteration_index, + is_pr_iteration_enabled, trigger_autofix_agent, trigger_coding_agent_handoff, trigger_push_changes, @@ -240,8 +241,16 @@ def _send_step_webhook( if current_step is not None and not is_pr_created: referrer = current_referrer.value if current_referrer is not None else None + iteration_index = get_latest_iteration_index(state) + pr_iteration_enabled = is_pr_iteration_enabled(state) metrics.incr( - "autofix.explorer.complete", tags={"step": current_step.value, "referrer": referrer} + "autofix.explorer.complete", + tags={ + "step": current_step.value, + "referrer": referrer, + "iteration_index": iteration_index, + "pr_iteration_enabled": pr_iteration_enabled, + }, ) completed_event_cls = STEP_CONFIGS[current_step].completed_event if completed_event_cls is not None: @@ -251,6 +260,8 @@ def _send_step_webhook( project_id=group.project_id, group_id=group.id, referrer=referrer, + iteration_index=iteration_index, + pr_iteration_enabled=pr_iteration_enabled, ) ) @@ -372,11 +383,8 @@ def _maybe_continue_pipeline( group, ) if decision is not None: - iteration_index = ( - get_latest_iteration_index(state) - if current_step == AutofixStep.PR_ITERATION - else None - ) + iteration_index = get_latest_iteration_index(state) + pr_iteration_enabled = is_pr_iteration_enabled(state) analytics.record( AiAutofixIntrospectionEvent( organization_id=organization.id, @@ -387,6 +395,7 @@ def _maybe_continue_pipeline( action=decision.action.value, reached_stopping_point=reached_stopping_point, iteration_index=iteration_index, + pr_iteration_enabled=pr_iteration_enabled, ) ) logger.info( @@ -400,6 +409,8 @@ def _maybe_continue_pipeline( "action": decision.action.value, "reason": decision.reason, "reached_stopping_point": reached_stopping_point, + "iteration_index": iteration_index, + "pr_iteration_enabled": pr_iteration_enabled, }, ) @@ -454,6 +465,8 @@ def _maybe_continue_pipeline( "current_step": current_step, "next_step": next_step, "stopping_point": stopping_point, + "iteration_index": get_latest_iteration_index(state), + "pr_iteration_enabled": is_pr_iteration_enabled(state), }, ) trigger_autofix_agent( From b13daf53d129c243e43ab5a1ba65aad6aad34702 Mon Sep 17 00:00:00 2001 From: joseph-sentry Date: Mon, 15 Jun 2026 14:16:22 -0400 Subject: [PATCH 26/32] add "run_id" to autofix "phase events" we want to be able to trace through the execution of a given autofix run via these events, if we don't have run_id then it's more difficult --- src/sentry/analytics/events/autofix_events.py | 1 + src/sentry/seer/autofix/autofix_agent.py | 29 +++++++++++-------- src/sentry/seer/autofix/on_completion_hook.py | 3 ++ 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/src/sentry/analytics/events/autofix_events.py b/src/sentry/analytics/events/autofix_events.py index c0790e9f290c..c89f14ccf511 100644 --- a/src/sentry/analytics/events/autofix_events.py +++ b/src/sentry/analytics/events/autofix_events.py @@ -7,6 +7,7 @@ class AiAutofixPhaseEvent(analytics.Event): project_id: int group_id: int referrer: str | None + run_id: int | None = None iteration_index: int | None = None pr_iteration_enabled: bool | None = None diff --git a/src/sentry/seer/autofix/autofix_agent.py b/src/sentry/seer/autofix/autofix_agent.py index 656023133078..d04416e77111 100644 --- a/src/sentry/seer/autofix/autofix_agent.py +++ b/src/sentry/seer/autofix/autofix_agent.py @@ -395,18 +395,6 @@ def trigger_autofix_agent( else: iteration_index = get_latest_iteration_index(run_state) + 1 - if config.started_event is not None: - analytics.record( - config.started_event( - organization_id=group.organization.id, - project_id=group.project_id, - group_id=group.id, - referrer=referrer.value, - iteration_index=iteration_index, - pr_iteration_enabled=pr_iteration_enabled, - ) - ) - prompt = build_step_prompt(step, group, user_context, run_state=run_state) prompt_metadata = { "step": step.value, @@ -459,6 +447,21 @@ def trigger_autofix_agent( insert_index=insert_index, ) + # Emit the started event after run_id is resolved so it can be joined to + # downstream completed/PR events. + if config.started_event is not None: + analytics.record( + config.started_event( + organization_id=group.organization.id, + project_id=group.project_id, + group_id=group.id, + referrer=referrer.value, + run_id=run_id, + iteration_index=iteration_index, + pr_iteration_enabled=pr_iteration_enabled, + ) + ) + payload: dict[str, Any] = { "run_id": run_id, "group_id": group.id, @@ -725,6 +728,7 @@ def trigger_coding_agent_handoff( project_id=group.project_id, group_id=group.id, referrer=referrer.value, + run_id=run_id, coding_agent=coding_agent_name, ) ) @@ -770,6 +774,7 @@ def trigger_push_changes( project_id=group.project_id, group_id=group.id, referrer=referrer.value, + run_id=run_id, ) ) diff --git a/src/sentry/seer/autofix/on_completion_hook.py b/src/sentry/seer/autofix/on_completion_hook.py index d47c1eec51b1..43d0658a7dc4 100644 --- a/src/sentry/seer/autofix/on_completion_hook.py +++ b/src/sentry/seer/autofix/on_completion_hook.py @@ -178,6 +178,7 @@ def _send_step_webhook( project_id=group.project_id, group_id=group.id, referrer=None if current_referrer is None else current_referrer.value, + run_id=run_id, ) ) else: @@ -260,6 +261,7 @@ def _send_step_webhook( project_id=group.project_id, group_id=group.id, referrer=referrer, + run_id=run_id, iteration_index=iteration_index, pr_iteration_enabled=pr_iteration_enabled, ) @@ -390,6 +392,7 @@ def _maybe_continue_pipeline( organization_id=organization.id, project_id=group.project_id, group_id=group.id, + run_id=run_id, referrer=referrer.value, step=current_step.value, action=decision.action.value, From d90f742db2a7abf5236241773aebe7cdf2855d9b Mon Sep 17 00:00:00 2001 From: joseph-sentry Date: Fri, 12 Jun 2026 10:39:54 -0400 Subject: [PATCH 27/32] fold: trigger feedback model + storage/read (-> trigger) --- src/sentry/seer/autofix/autofix_agent.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/sentry/seer/autofix/autofix_agent.py b/src/sentry/seer/autofix/autofix_agent.py index d04416e77111..a2a391322543 100644 --- a/src/sentry/seer/autofix/autofix_agent.py +++ b/src/sentry/seer/autofix/autofix_agent.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json import logging import re from enum import StrEnum From f28db17777b70984f6202f5bfa430697a43453f7 Mon Sep 17 00:00:00 2001 From: joseph-sentry Date: Mon, 8 Jun 2026 14:19:29 -0400 Subject: [PATCH 28/32] feat(autofix): Handle PR iteration completion and introspection Send the ITERATION_COMPLETED webhook when a PR_ITERATION step finishes, including the pull request payload, code changes, and iteration index, and record the iteration index on the introspection analytics event. Add introspect_iteration to evaluate whether revised iteration changes are ready to update the existing pull request, and route PR_ITERATION through it. Extract the pull-request and code-changes payload builders into shared helpers. Co-Authored-By: Claude Opus 4 --- src/sentry/seer/autofix/autofix_agent.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/sentry/seer/autofix/autofix_agent.py b/src/sentry/seer/autofix/autofix_agent.py index a2a391322543..d04416e77111 100644 --- a/src/sentry/seer/autofix/autofix_agent.py +++ b/src/sentry/seer/autofix/autofix_agent.py @@ -1,6 +1,5 @@ from __future__ import annotations -import json import logging import re from enum import StrEnum From 1ef4ba8772542b41c4c9f9dc3e953812474f1283 Mon Sep 17 00:00:00 2001 From: joseph-sentry Date: Fri, 12 Jun 2026 10:39:54 -0400 Subject: [PATCH 29/32] fold: trigger feedback model + storage/read (-> trigger) --- src/sentry/seer/autofix/autofix_agent.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/sentry/seer/autofix/autofix_agent.py b/src/sentry/seer/autofix/autofix_agent.py index d04416e77111..a2a391322543 100644 --- a/src/sentry/seer/autofix/autofix_agent.py +++ b/src/sentry/seer/autofix/autofix_agent.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json import logging import re from enum import StrEnum From be5ebf7cded5e4ce3161698f1a5690ab7f28d693 Mon Sep 17 00:00:00 2001 From: joseph-sentry Date: Mon, 8 Jun 2026 14:19:29 -0400 Subject: [PATCH 30/32] feat(autofix): Handle PR iteration completion and introspection Send the ITERATION_COMPLETED webhook when a PR_ITERATION step finishes, including the pull request payload, code changes, and iteration index, and record the iteration index on the introspection analytics event. Add introspect_iteration to evaluate whether revised iteration changes are ready to update the existing pull request, and route PR_ITERATION through it. Extract the pull-request and code-changes payload builders into shared helpers. Co-Authored-By: Claude Opus 4 --- src/sentry/seer/autofix/autofix_agent.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/sentry/seer/autofix/autofix_agent.py b/src/sentry/seer/autofix/autofix_agent.py index a2a391322543..d04416e77111 100644 --- a/src/sentry/seer/autofix/autofix_agent.py +++ b/src/sentry/seer/autofix/autofix_agent.py @@ -1,6 +1,5 @@ from __future__ import annotations -import json import logging import re from enum import StrEnum From 2ac3489811758288cae72f5c88fe67c0fc415929 Mon Sep 17 00:00:00 2001 From: joseph-sentry Date: Tue, 9 Jun 2026 11:36:16 -0400 Subject: [PATCH 31/32] feat(autofix): Push PR iteration changes and sync completion webhook When a PR iteration run finishes, the agent's new file patches still need to be pushed to the existing PR. Trigger that push from the completion hook so the PR is updated, and only emit the iteration completion webhook on the synced pass so the payload reflects the updated PR and we don't double-fire. Co-Authored-By: Claude Opus 4.8 --- src/sentry/seer/autofix/on_completion_hook.py | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/src/sentry/seer/autofix/on_completion_hook.py b/src/sentry/seer/autofix/on_completion_hook.py index 43d0658a7dc4..e0f1be49d474 100644 --- a/src/sentry/seer/autofix/on_completion_hook.py +++ b/src/sentry/seer/autofix/on_completion_hook.py @@ -185,12 +185,17 @@ def _send_step_webhook( webhook_action_type = SeerActionType.CODING_COMPLETED webhook_payload["code_changes"] = cls._format_code_changes_payload(state) elif current_step == AutofixStep.PR_ITERATION: - # PR iteration only runs against an existing PR, so there should be pr states. - if not state.repo_pr_states: - logger.error( - "autofix.on_completion_hook.pr_iteration_missing_repo_pr_states", - extra={"run_id": run_id, "organization_id": organization.id}, - ) + # PR iteration only runs against an existing PR, so there must be pr states. + assert state.repo_pr_states, "PR iteration completed without any repo PR states" + # The iteration agent produces new file patches that still need to be + # pushed to the existing PR. The first hook fire (right after the agent + # finishes) has unsynced changes; _maybe_continue_pipeline triggers the + # push, which re-fires this hook once the PR is updated. Only emit the + # completion webhook on that synced pass so the payload reflects the + # updated PR and we don't double-fire. + _, is_synced = state.has_code_changes() + if not is_synced: + return webhook_action_type = SeerActionType.ITERATION_COMPLETED iteration_index = get_latest_iteration_index(state) webhook_payload["pull_requests"] = cls._format_pull_requests_payload(state) @@ -417,6 +422,14 @@ def _maybe_continue_pipeline( }, ) + # PR iteration runs against an existing PR rather than the automated + # pipeline. Once the agent finishes iterating, push the new changes to + # update that PR. _push_changes is a no-op once the repos are synced, so + # the hook re-fire after the push doesn't loop. + if current_step == AutofixStep.PR_ITERATION: + cls._push_changes(group, run_id, state) + return + if stopping_point is None or reached_stopping_point: # We've reached the stopping point return From 1786047033b2207abe8d67bb32b125db8aa35fa3 Mon Sep 17 00:00:00 2001 From: joseph-sentry Date: Fri, 12 Jun 2026 10:40:07 -0400 Subject: [PATCH 32/32] fold: terminal errored-repo webhook gate (-> hook-push) --- src/sentry/seer/autofix/on_completion_hook.py | 40 +++++++++++++------ 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/src/sentry/seer/autofix/on_completion_hook.py b/src/sentry/seer/autofix/on_completion_hook.py index e0f1be49d474..f177ef4dcd15 100644 --- a/src/sentry/seer/autofix/on_completion_hook.py +++ b/src/sentry/seer/autofix/on_completion_hook.py @@ -191,10 +191,10 @@ def _send_step_webhook( # pushed to the existing PR. The first hook fire (right after the agent # finishes) has unsynced changes; _maybe_continue_pipeline triggers the # push, which re-fires this hook once the PR is updated. Only emit the - # completion webhook on that synced pass so the payload reflects the - # updated PR and we don't double-fire. + # completion webhook once changes are synced, or when push failed + # terminally for errored repos so we don't wait forever. _, is_synced = state.has_code_changes() - if not is_synced: + if not is_synced and not cls._iteration_terminal_errored_repos(state): return webhook_action_type = SeerActionType.ITERATION_COMPLETED iteration_index = get_latest_iteration_index(state) @@ -512,6 +512,28 @@ def run_introspection( elif step == AutofixStep.PR_ITERATION: return introspect_iteration(organization, run_id, state, group) + @classmethod + def _iteration_terminal_errored_repos(cls, state: SeerRunState) -> list[str]: + """ + Return the errored repos when unsynced repos have terminal push failures. + + Returns an empty list when there are no errored repos, or when some + non-errored repo is still unsynced (i.e. a push can still make progress). + Used to stop waiting for a synced PR after push errors without retrying. + """ + diffs_by_repo = state.get_diffs_by_repo() + errored_repos = [ + repo + for repo in diffs_by_repo + if (pr_state := state.repo_pr_states.get(repo)) is not None + and pr_state.pr_creation_status == "error" + ] + if not errored_repos: + return [] + if all(state._is_repo_synced(repo) or repo in errored_repos for repo in diffs_by_repo): + return errored_repos + return [] + @classmethod def _push_changes(cls, group: Group, run_id: int, state: SeerRunState) -> None: """Push code changes to create PRs.""" @@ -530,16 +552,8 @@ def _push_changes(cls, group: Group, run_id: int, state: SeerRunState) -> None: return # Errored repos are terminal — re-pushing would re-fire this hook in a loop. - diffs_by_repo = state.get_diffs_by_repo() - errored_repos = [ - repo - for repo in diffs_by_repo - if (pr_state := state.repo_pr_states.get(repo)) is not None - and pr_state.pr_creation_status == "error" - ] - if errored_repos and all( - state._is_repo_synced(repo) or repo in errored_repos for repo in diffs_by_repo - ): + errored_repos = cls._iteration_terminal_errored_repos(state) + if errored_repos: logger.info( "autofix.on_completion_hook.skip_no_pushable_repos", extra={