Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
41a5b32
fold: trigger feedback model + storage/read (-> trigger)
joseph-sentry Jun 12, 2026
6ae5b2d
feat(autofix): Handle PR iteration completion and introspection
joseph-sentry Jun 8, 2026
4328d0a
feat(autofix): Add pr_iteration endpoint step and operator activity
joseph-sentry Jun 8, 2026
6fd0dd2
fold: get_autofix_run_state + endpoint feedback wiring (-> entrypoints)
joseph-sentry Jun 12, 2026
d395c21
fix
joseph-sentry Jun 15, 2026
53c66ba
fixes and serialize users
joseph-sentry Jun 15, 2026
7494be6
fix(seer): Guard against null message metadata in feedback hydration
joseph-sentry Jun 15, 2026
06bce41
fix(seer): Fix autofix pr_iteration test mocks and feedback kwarg
joseph-sentry Jun 15, 2026
416d4c4
address bugbot comment
joseph-sentry Jun 15, 2026
8286e22
simplify
joseph-sentry Jun 15, 2026
8b754c1
feat(autofix): Expose pr_iteration_enabled in autofix state response
joseph-sentry Jun 15, 2026
d9087c3
fold: trigger feedback model + storage/read (-> trigger)
joseph-sentry Jun 12, 2026
a1a4bc9
feat(autofix): Handle PR iteration completion and introspection
joseph-sentry Jun 8, 2026
4dc8529
feat(autofix): Add pr_iteration endpoint step and operator activity
joseph-sentry Jun 8, 2026
99bb7f7
fold: get_autofix_run_state + endpoint feedback wiring (-> entrypoints)
joseph-sentry Jun 12, 2026
c00e6a7
fixes and serialize users
joseph-sentry Jun 15, 2026
5bf75bc
fix(seer): Fix autofix pr_iteration test mocks and feedback kwarg
joseph-sentry Jun 15, 2026
374e5a0
fold: trigger feedback model + storage/read (-> trigger)
joseph-sentry Jun 12, 2026
6265cab
feat(autofix): Handle PR iteration completion and introspection
joseph-sentry Jun 8, 2026
c1f248e
fixes and serialize users
joseph-sentry Jun 15, 2026
cd736c2
fold: trigger feedback model + storage/read (-> trigger)
joseph-sentry Jun 12, 2026
f8ab817
feat(autofix): Handle PR iteration completion and introspection
joseph-sentry Jun 8, 2026
af16717
fold: trigger feedback model + storage/read (-> trigger)
joseph-sentry Jun 12, 2026
eb47b86
feat(autofix): Handle PR iteration completion and introspection
joseph-sentry Jun 8, 2026
eee7e93
feat(autofix): Add iteration_index and pr_iteration_enabled to analytics
joseph-sentry Jun 9, 2026
b13daf5
add "run_id" to autofix "phase events"
joseph-sentry Jun 15, 2026
d90f742
fold: trigger feedback model + storage/read (-> trigger)
joseph-sentry Jun 12, 2026
f28db17
feat(autofix): Handle PR iteration completion and introspection
joseph-sentry Jun 8, 2026
1ef4ba8
fold: trigger feedback model + storage/read (-> trigger)
joseph-sentry Jun 12, 2026
be5ebf7
feat(autofix): Handle PR iteration completion and introspection
joseph-sentry Jun 8, 2026
2ac3489
feat(autofix): Push PR iteration changes and sync completion webhook
joseph-sentry Jun 9, 2026
1786047
fold: terminal errored-repo webhook gate (-> hook-push)
joseph-sentry Jun 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/sentry/analytics/events/autofix_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ 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


@analytics.eventclass("ai.autofix.root_cause.started")
Expand Down
48 changes: 36 additions & 12 deletions src/sentry/seer/autofix/autofix_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,10 @@ class NoSeerQuotaException(Exception):
pass


class PrIterationNoPullRequestException(Exception):
pass


class AutofixStep(StrEnum):
"""Available autofix steps."""

Expand Down Expand Up @@ -242,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 {}
Expand Down Expand Up @@ -274,6 +282,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:
Expand Down Expand Up @@ -374,23 +387,14 @@ 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:
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,
)
)

prompt = build_step_prompt(step, group, user_context, run_state=run_state)
prompt_metadata = {
"step": step.value,
Expand Down Expand Up @@ -443,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,
Expand Down Expand Up @@ -487,6 +506,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,
},
)

Expand All @@ -496,6 +517,7 @@ def trigger_autofix_agent(
"step": step.value,
"referrer": referrer.value,
"iteration_index": iteration_index,
"pr_iteration_enabled": pr_iteration_enabled,
},
)

Expand Down Expand Up @@ -706,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,
)
)
Expand Down Expand Up @@ -751,6 +774,7 @@ def trigger_push_changes(
project_id=group.project_id,
group_id=group.id,
referrer=referrer.value,
run_id=run_id,
)
)

Expand Down
81 changes: 64 additions & 17 deletions src/sentry/seer/autofix/on_completion_hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -177,18 +178,24 @@ 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:
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 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 and not cls._iteration_terminal_errored_repos(state):
return
webhook_action_type = SeerActionType.ITERATION_COMPLETED
iteration_index = get_latest_iteration_index(state)
webhook_payload["pull_requests"] = cls._format_pull_requests_payload(state)
Expand Down Expand Up @@ -240,8 +247,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:
Expand All @@ -251,6 +266,9 @@ 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,
)
)

Expand Down Expand Up @@ -373,16 +391,19 @@ def _maybe_continue_pipeline(
)
if decision is not None:
iteration_index = get_latest_iteration_index(state)
pr_iteration_enabled = is_pr_iteration_enabled(state)
analytics.record(
AiAutofixIntrospectionEvent(
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,
reached_stopping_point=reached_stopping_point,
iteration_index=iteration_index,
pr_iteration_enabled=pr_iteration_enabled,
)
)
logger.info(
Expand All @@ -396,9 +417,19 @@ 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,
},
)

# 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
Expand Down Expand Up @@ -450,6 +481,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(
Expand Down Expand Up @@ -479,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."""
Expand All @@ -497,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={
Expand Down
Loading
Loading