From d9d0e3ac27a6c7dc3e9ccee88093b828fce0b3ae Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:05:31 +0000 Subject: [PATCH 1/6] fix(seer): Keep one failed Seer Agent reply edit from blocking the rest Editing a posted Seer Agent answer now goes through its own lifecycle, like send_thread_update. Halt-category Slack errors and a deleted message (message_not_found) are recorded as halts instead of being raised for task retries. Other edit failures no longer stop the loop: the remaining answers are still edited and cached, then the first error is raised so the refresh retries only what failed. A reply that didn't post now logs its run and reply key. Refs CW-2180 --- src/sentry/integrations/slack/utils/errors.py | 1 + src/sentry/seer/entrypoints/metrics.py | 1 + .../seer/entrypoints/slack/entrypoint.py | 51 +++++-- .../seer/entrypoints/slack/messaging.py | 46 +++++-- .../seer/entrypoints/slack/test_slack.py | 128 +++++++++++++++++- 5 files changed, 202 insertions(+), 25 deletions(-) diff --git a/src/sentry/integrations/slack/utils/errors.py b/src/sentry/integrations/slack/utils/errors.py index f07f0b248969..c9a52a9b4bad 100644 --- a/src/sentry/integrations/slack/utils/errors.py +++ b/src/sentry/integrations/slack/utils/errors.py @@ -39,6 +39,7 @@ class SlackSdkErrorCategory: INVALID_BLOCKS := SlackSdkErrorCategory("invalid_blocks"), INVALID_ATTACHMENTS := SlackSdkErrorCategory("invalid_attachments"), INVALID_AUTH := SlackSdkErrorCategory("invalid_auth"), + MESSAGE_NOT_FOUND := SlackSdkErrorCategory("message_not_found"), NON_JSON_RESPONSE := SlackSdkErrorCategory("non_json_response"), ) diff --git a/src/sentry/seer/entrypoints/metrics.py b/src/sentry/seer/entrypoints/metrics.py index 1c8f8e910a4e..87f8f4e7223a 100644 --- a/src/sentry/seer/entrypoints/metrics.py +++ b/src/sentry/seer/entrypoints/metrics.py @@ -59,6 +59,7 @@ def get_metric_tags(self) -> Mapping[str, str]: class SlackEntrypointInteractionType(StrEnum): SEND_THREAD_UPDATE = "send_thread_update" + EDIT_THREAD_MESSAGE = "edit_thread_message" PROCESS_THREAD_UPDATE = "process_thread_update" SCHEDULE_ALL_THREAD_UPDATES = "schedule_all_thread_updates" UPDATE_EXISTING_MESSAGE = "update_existing_message" diff --git a/src/sentry/seer/entrypoints/slack/entrypoint.py b/src/sentry/seer/entrypoints/slack/entrypoint.py index ed24d5c8ef98..faff4b599b02 100644 --- a/src/sentry/seer/entrypoints/slack/entrypoint.py +++ b/src/sentry/seer/entrypoints/slack/entrypoint.py @@ -689,6 +689,18 @@ def on_agent_update( reply["pr_status_hash"] = _hash_pr_status(data.repo_pr_states) reply["missing_scope_settings_url"] = data.missing_scope_settings_url SlackSeerAgentReplyCache.set(run_id=run_id, key=key, reply=reply) + else: + # send_thread_update records why as a halt; this ties it to the run, which retries the + # reply on its next refresh. + logger.warning( + "seer.entrypoint.slack.agent_update.reply_not_posted", + extra={ + "organization_id": organization_id, + "integration_id": integration_id, + "run_id": run_id, + "reply_key": key, + }, + ) replies = SlackSeerAgentReplyCache.get(run_id=run_id) answers = [answer for turn in state.get_turns() if (answer := turn.answer) is not None] @@ -701,6 +713,7 @@ def on_agent_update( None, ) repo_pr_states = _get_repo_pr_states(state) + edit_error: IntegrationError | None = None for answer in answers: posted = replies.get(answer.id) if posted is None or "pr_status_hash" not in posted: @@ -710,21 +723,29 @@ def on_agent_update( status_hash = _hash_pr_status(prs) if status_hash == posted["pr_status_hash"]: continue - edit_thread_message( - install=install, - channel_id=thread["channel_id"], - message_ts=posted["message_ts"], - data=SeerAgentResponse( - run_id=state.run_id, - organization_id=organization_id, - summary=answer.message.content or "", - repo_pr_states=prs, - missing_scope_settings_url=posted.get("missing_scope_settings_url"), - ), - ) - SlackSeerAgentReplyCache.set( - run_id=run_id, key=answer.id, reply={**posted, "pr_status_hash": status_hash} - ) + try: + edited = edit_thread_message( + install=install, + channel_id=thread["channel_id"], + message_ts=posted["message_ts"], + data=SeerAgentResponse( + run_id=state.run_id, + organization_id=organization_id, + summary=answer.message.content or "", + repo_pr_states=prs, + missing_scope_settings_url=posted.get("missing_scope_settings_url"), + ), + ) + except IntegrationError as e: + # Keep editing the other answers, then raise so the refresh retries this one. + edit_error = edit_error or e + continue + if edited: + SlackSeerAgentReplyCache.set( + run_id=run_id, key=answer.id, reply={**posted, "pr_status_hash": status_hash} + ) + if edit_error: + raise edit_error def _get_repo_pr_states(state: SeerRunState) -> list[RepoPRState]: diff --git a/src/sentry/seer/entrypoints/slack/messaging.py b/src/sentry/seer/entrypoints/slack/messaging.py index f5c7948f2241..de330200818b 100644 --- a/src/sentry/seer/entrypoints/slack/messaging.py +++ b/src/sentry/seer/entrypoints/slack/messaging.py @@ -147,14 +147,44 @@ def send_thread_update( def edit_thread_message( *, install: SlackIntegration, channel_id: str, message_ts: str, data: NotificationData -) -> None: - """Re-render a message we already posted from new data.""" - renderable, _ = NotificationService.render_template( - data=data, - template=template_registry.get(data.source)(), - provider=provider_registry.get(NotificationProviderKey.SLACK), - ) - install.update_message(channel_id=channel_id, message_ts=message_ts, renderable=renderable) +) -> bool: + """ + Re-render a message we already posted from new data. + Returns whether the message was edited. Raises IntegrationError when a retry may succeed. + """ + from sentry.integrations.slack.utils.errors import MESSAGE_NOT_FOUND + + with SlackEntrypointEventLifecycleMetric( + interaction_type=SlackEntrypointInteractionType.EDIT_THREAD_MESSAGE, + integration_id=install.model.id, + organization_id=install.organization_id, + ).capture() as lifecycle: + lifecycle.add_extras( + {"channel_id": channel_id, "message_ts": message_ts, "data_source": data.source} + ) + renderable, _ = NotificationService.render_template( + data=data, + template=template_registry.get(data.source)(), + provider=provider_registry.get(NotificationProviderKey.SLACK), + ) + try: + install.update_message( + channel_id=channel_id, message_ts=message_ts, renderable=renderable + ) + except IntegrationConfigurationError as e: + lifecycle.record_halt(halt_reason=e) + return False + except TimeoutError as e: + lifecycle.record_failure(failure_reason=e) + raise IntegrationError("Slack request timed out") from e + except IntegrationError as e: + if e.message == MESSAGE_NOT_FOUND.message: + # Someone deleted the message, so retrying can't succeed. + lifecycle.record_halt(halt_reason=e) + return False + lifecycle.record_failure(failure_reason=e) + raise + return True @instrumented_task( diff --git a/tests/sentry/seer/entrypoints/slack/test_slack.py b/tests/sentry/seer/entrypoints/slack/test_slack.py index 5820211315a9..f9dd4519699d 100644 --- a/tests/sentry/seer/entrypoints/slack/test_slack.py +++ b/tests/sentry/seer/entrypoints/slack/test_slack.py @@ -45,7 +45,11 @@ SeerOperatorInvestigationCache, ) from sentry.seer.entrypoints.operator import SeerAutofixOperator, process_autofix_updates -from sentry.seer.entrypoints.slack.cache import SlackSeerAgentMessageCache +from sentry.seer.entrypoints.slack.cache import ( + SlackSeerAgentMessageCache, + SlackSeerAgentReply, + SlackSeerAgentReplyCache, +) from sentry.seer.entrypoints.slack.entrypoint import ( EntrypointSetupError, SlackAgentCachePayload, @@ -67,7 +71,7 @@ from sentry.seer.entrypoints.types import SeerEntrypointKey from sentry.seer.models import SeerAutomationHandoffConfiguration, SeerProjectPreference from sentry.sentry_apps.event_types import SentryAppEventType -from sentry.shared_integrations.exceptions import IntegrationError +from sentry.shared_integrations.exceptions import IntegrationConfigurationError, IntegrationError from sentry.testutils.asserts import assert_halt_metric from sentry.testutils.cases import TestCase from sentry.testutils.helpers.options import override_options @@ -1261,6 +1265,126 @@ def blocks(summary: str, *footers: str) -> list[dict[str, Any]]: ), ] + def _two_posted_answers_with_stale_footers(self) -> SeerRunState: + state = _agent_state("First answer.") + state.blocks += [ + MemoryBlock( + id="b3", + message=Message(role="user", content="Open a PR."), + timestamp="2024-01-01T00:00:02Z", + ), + MemoryBlock( + id="b4", + message=Message(role="assistant", content="Second answer."), + timestamp="2024-01-01T00:00:03Z", + ), + ] + state.repo_pr_states["acme/web"] = RepoPRState( + repo_name="acme/web", pr_creation_status="creating" + ) + for key, ts in (("b2", "1.1"), ("b4", "2.2")): + SlackSeerAgentReplyCache.set( + run_id=state.run_id, + key=key, + reply=SlackSeerAgentReply(message_ts=ts, pr_status_hash="stale"), + ) + return state + + @patch("slack_sdk.web.client.WebClient.chat_postMessage") + @patch("slack_sdk.web.client.WebClient.chat_update") + def test_on_agent_update_failed_edit_does_not_block_other_answers( + self, update: Mock, post: Mock + ) -> None: + state = self._two_posted_answers_with_stale_footers() + update.side_effect = [ + SlackApiError("error", _slack_response({"ok": False, "error": "invalid_blocks"})), + _slack_response({"ok": True}), + ] + + with pytest.raises(IntegrationError): + SlackAgentEntrypoint.on_agent_update( + cache_payload=self._get_entrypoint().create_agent_cache_payload(), + run_id=state.run_id, + state=state, + ) + + post.assert_not_called() + assert [call.kwargs["ts"] for call in update.call_args_list] == ["1.1", "2.2"] + replies = SlackSeerAgentReplyCache.get(run_id=state.run_id) + assert replies["b2"]["pr_status_hash"] == "stale" + assert replies["b4"]["pr_status_hash"] != "stale" + + @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") + @patch("slack_sdk.web.client.WebClient.chat_update") + def test_on_agent_update_deleted_answer_halts_edit( + self, update: Mock, mock_record: Mock + ) -> None: + state = self._two_posted_answers_with_stale_footers() + update.side_effect = [ + SlackApiError("error", _slack_response({"ok": False, "error": "message_not_found"})), + _slack_response({"ok": True}), + ] + + SlackAgentEntrypoint.on_agent_update( + cache_payload=self._get_entrypoint().create_agent_cache_payload(), + run_id=state.run_id, + state=state, + ) + + assert update.call_count == 2 + assert_halt_metric(mock_record, IntegrationError()) + replies = SlackSeerAgentReplyCache.get(run_id=state.run_id) + assert replies["b2"]["pr_status_hash"] == "stale" + assert replies["b4"]["pr_status_hash"] != "stale" + + @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") + @patch("slack_sdk.web.client.WebClient.chat_update") + def test_on_agent_update_archived_channel_halts_edits( + self, update: Mock, mock_record: Mock + ) -> None: + state = self._two_posted_answers_with_stale_footers() + update.side_effect = SlackApiError( + "error", _slack_response({"ok": False, "error": "is_archived"}) + ) + + SlackAgentEntrypoint.on_agent_update( + cache_payload=self._get_entrypoint().create_agent_cache_payload(), + run_id=state.run_id, + state=state, + ) + + assert update.call_count == 2 + halts = [ + call for call in mock_record.mock_calls if call.args[0] == EventLifecycleOutcome.HALTED + ] + assert len(halts) == 2 + assert all(isinstance(call.args[1], IntegrationConfigurationError) for call in halts) + + @patch("sentry.seer.entrypoints.slack.entrypoint.logger") + @patch("sentry.seer.entrypoints.slack.entrypoint.send_thread_update", return_value=None) + def test_on_agent_update_logs_reply_not_posted( + self, mock_send: Mock, mock_logger: Mock + ) -> None: + state = _agent_state("Test summary") + + SlackAgentEntrypoint.on_agent_update( + cache_payload=self._get_entrypoint().create_agent_cache_payload(), + run_id=state.run_id, + state=state, + ) + + mock_send.assert_called_once() + mock_logger.warning.assert_called_once_with( + "seer.entrypoint.slack.agent_update.reply_not_posted", + extra={ + "organization_id": self.organization.id, + "integration_id": self.integration.id, + "run_id": state.run_id, + "reply_key": "b2", + }, + ) + assert SlackSeerAgentReplyCache.get(run_id=state.run_id) == {} + def _slack_response(data: dict[str, Any]) -> SlackResponse: return SlackResponse( From 3861734dafcac4cb8a581438ebc68d64bdcb5576 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:11:48 +0000 Subject: [PATCH 2/6] ref(slack): Treat message_not_found as a halt instead of special-casing it Adding it to SLACK_SDK_HALT_ERROR_CATEGORIES makes translate_slack_api_error raise IntegrationConfigurationError, so edit_thread_message's existing halt branch covers deleted messages without an inline import or message comparison. Refs CW-2180 --- src/sentry/integrations/slack/utils/errors.py | 1 + .../seer/entrypoints/slack/messaging.py | 6 ----- .../seer/entrypoints/slack/test_slack.py | 25 +------------------ 3 files changed, 2 insertions(+), 30 deletions(-) diff --git a/src/sentry/integrations/slack/utils/errors.py b/src/sentry/integrations/slack/utils/errors.py index c9a52a9b4bad..8c44694178ab 100644 --- a/src/sentry/integrations/slack/utils/errors.py +++ b/src/sentry/integrations/slack/utils/errors.py @@ -56,6 +56,7 @@ class SlackSdkErrorCategory: FATAL_ERROR, INTERNAL_ERROR, INVALID_AUTH, + MESSAGE_NOT_FOUND, NON_JSON_RESPONSE, ) diff --git a/src/sentry/seer/entrypoints/slack/messaging.py b/src/sentry/seer/entrypoints/slack/messaging.py index de330200818b..faa83714474b 100644 --- a/src/sentry/seer/entrypoints/slack/messaging.py +++ b/src/sentry/seer/entrypoints/slack/messaging.py @@ -152,8 +152,6 @@ def edit_thread_message( Re-render a message we already posted from new data. Returns whether the message was edited. Raises IntegrationError when a retry may succeed. """ - from sentry.integrations.slack.utils.errors import MESSAGE_NOT_FOUND - with SlackEntrypointEventLifecycleMetric( interaction_type=SlackEntrypointInteractionType.EDIT_THREAD_MESSAGE, integration_id=install.model.id, @@ -178,10 +176,6 @@ def edit_thread_message( lifecycle.record_failure(failure_reason=e) raise IntegrationError("Slack request timed out") from e except IntegrationError as e: - if e.message == MESSAGE_NOT_FOUND.message: - # Someone deleted the message, so retrying can't succeed. - lifecycle.record_halt(halt_reason=e) - return False lifecycle.record_failure(failure_reason=e) raise return True diff --git a/tests/sentry/seer/entrypoints/slack/test_slack.py b/tests/sentry/seer/entrypoints/slack/test_slack.py index f9dd4519699d..3ecc711e2947 100644 --- a/tests/sentry/seer/entrypoints/slack/test_slack.py +++ b/tests/sentry/seer/entrypoints/slack/test_slack.py @@ -1332,34 +1332,11 @@ def test_on_agent_update_deleted_answer_halts_edit( ) assert update.call_count == 2 - assert_halt_metric(mock_record, IntegrationError()) + assert_halt_metric(mock_record, IntegrationConfigurationError()) replies = SlackSeerAgentReplyCache.get(run_id=state.run_id) assert replies["b2"]["pr_status_hash"] == "stale" assert replies["b4"]["pr_status_hash"] != "stale" - @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") - @patch("slack_sdk.web.client.WebClient.chat_update") - def test_on_agent_update_archived_channel_halts_edits( - self, update: Mock, mock_record: Mock - ) -> None: - state = self._two_posted_answers_with_stale_footers() - update.side_effect = SlackApiError( - "error", _slack_response({"ok": False, "error": "is_archived"}) - ) - - SlackAgentEntrypoint.on_agent_update( - cache_payload=self._get_entrypoint().create_agent_cache_payload(), - run_id=state.run_id, - state=state, - ) - - assert update.call_count == 2 - halts = [ - call for call in mock_record.mock_calls if call.args[0] == EventLifecycleOutcome.HALTED - ] - assert len(halts) == 2 - assert all(isinstance(call.args[1], IntegrationConfigurationError) for call in halts) - @patch("sentry.seer.entrypoints.slack.entrypoint.logger") @patch("sentry.seer.entrypoints.slack.entrypoint.send_thread_update", return_value=None) def test_on_agent_update_logs_reply_not_posted( From ead53fed1f9eb1adcc64f4bb72438409c221b108 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:19:20 +0000 Subject: [PATCH 3/6] fix(seer): Retry transient Slack edit halts and stop editing deleted replies Only a missing message or channel ends a Seer Agent reply edit. Slack's update_message now raises IntegrationResourceNotFoundError for message_not_found, channel_not_found and is_archived, and on_agent_update caches the new hash so later refreshes skip the reply. Other halt categories, like ratelimited, are still recorded as halts but raised so refresh_agent_run retries the edit. message_not_found is no longer in the shared halt list. Refs CW-2180 --- src/sentry/integrations/slack/integration.py | 3 +- src/sentry/integrations/slack/metrics.py | 19 +++++++++-- src/sentry/integrations/slack/utils/errors.py | 8 ++++- .../seer/entrypoints/slack/entrypoint.py | 9 +++--- .../seer/entrypoints/slack/messaging.py | 19 +++++++---- .../seer/entrypoints/slack/test_slack.py | 32 +++++++++++-------- 6 files changed, 61 insertions(+), 29 deletions(-) diff --git a/src/sentry/integrations/slack/integration.py b/src/sentry/integrations/slack/integration.py index 11b2772c5ff4..4e997d7bad93 100644 --- a/src/sentry/integrations/slack/integration.py +++ b/src/sentry/integrations/slack/integration.py @@ -27,6 +27,7 @@ from sentry.integrations.slack.sdk_client import SlackSdkClient from sentry.integrations.slack.tasks.link_slack_user_identities import link_slack_user_identities from sentry.integrations.slack.utils.constants import SlackScope +from sentry.integrations.slack.utils.errors import SLACK_SDK_MESSAGE_GONE_ERROR_CATEGORIES from sentry.integrations.types import IntegrationProviderSlug from sentry.notifications.platform.provider import ( IntegrationNotificationClient, @@ -214,7 +215,7 @@ def update_message( unfurl_media=False, ) except SlackApiError as e: - translate_slack_api_error(e) + translate_slack_api_error(e, not_found=SLACK_SDK_MESSAGE_GONE_ERROR_CATEGORIES) def has_scope(self, scope: SlackScope) -> bool: """Check whether this integration was granted the given OAuth scope. diff --git a/src/sentry/integrations/slack/metrics.py b/src/sentry/integrations/slack/metrics.py index 0d692c3c4047..840270d11350 100644 --- a/src/sentry/integrations/slack/metrics.py +++ b/src/sentry/integrations/slack/metrics.py @@ -1,13 +1,20 @@ # metrics constants +from collections.abc import Collection + from slack_sdk.errors import SlackApiError from sentry.integrations.slack.utils.errors import ( SLACK_SDK_HALT_ERROR_CATEGORIES, + SlackSdkErrorCategory, unpack_slack_api_error, ) from sentry.integrations.utils.metrics import EventLifecycle -from sentry.shared_integrations.exceptions import IntegrationConfigurationError, IntegrationError +from sentry.shared_integrations.exceptions import ( + IntegrationConfigurationError, + IntegrationError, + IntegrationResourceNotFoundError, +) # Utils SLACK_UTILS_GET_USER_LIST_SUCCESS_DATADOG_METRIC = "sentry.integrations.slack.utils.users.success" @@ -25,10 +32,16 @@ def record_lifecycle_termination_level(lifecycle: EventLifecycle, error: SlackAp lifecycle.record_failure(e) -def translate_slack_api_error(error: SlackApiError) -> None: +def translate_slack_api_error( + error: SlackApiError, not_found: Collection[SlackSdkErrorCategory] = () +) -> None: reason = unpack_slack_api_error(error) if reason is not None: - if reason in SLACK_SDK_HALT_ERROR_CATEGORIES: + if reason in not_found: + raise IntegrationResourceNotFoundError( + error_code=error.response.status_code, message=reason.message + ) from error + elif reason in SLACK_SDK_HALT_ERROR_CATEGORIES: raise IntegrationConfigurationError( error_code=error.response.status_code, message=reason.message ) from error diff --git a/src/sentry/integrations/slack/utils/errors.py b/src/sentry/integrations/slack/utils/errors.py index 8c44694178ab..8769b21d5e51 100644 --- a/src/sentry/integrations/slack/utils/errors.py +++ b/src/sentry/integrations/slack/utils/errors.py @@ -56,10 +56,16 @@ class SlackSdkErrorCategory: FATAL_ERROR, INTERNAL_ERROR, INVALID_AUTH, - MESSAGE_NOT_FOUND, NON_JSON_RESPONSE, ) +# Errors that mean the message being edited no longer exists, so retrying can't succeed. +SLACK_SDK_MESSAGE_GONE_ERROR_CATEGORIES = ( + MESSAGE_NOT_FOUND, + CHANNEL_NOT_FOUND, + CHANNEL_ARCHIVED, +) + _CATEGORIES_BY_MESSAGE = {c.message: c for c in SLACK_SDK_ERROR_CATEGORIES} diff --git a/src/sentry/seer/entrypoints/slack/entrypoint.py b/src/sentry/seer/entrypoints/slack/entrypoint.py index faff4b599b02..8900908f4c3d 100644 --- a/src/sentry/seer/entrypoints/slack/entrypoint.py +++ b/src/sentry/seer/entrypoints/slack/entrypoint.py @@ -724,7 +724,7 @@ def on_agent_update( if status_hash == posted["pr_status_hash"]: continue try: - edited = edit_thread_message( + edit_thread_message( install=install, channel_id=thread["channel_id"], message_ts=posted["message_ts"], @@ -740,10 +740,9 @@ def on_agent_update( # Keep editing the other answers, then raise so the refresh retries this one. edit_error = edit_error or e continue - if edited: - SlackSeerAgentReplyCache.set( - run_id=run_id, key=answer.id, reply={**posted, "pr_status_hash": status_hash} - ) + SlackSeerAgentReplyCache.set( + run_id=run_id, key=answer.id, reply={**posted, "pr_status_hash": status_hash} + ) if edit_error: raise edit_error diff --git a/src/sentry/seer/entrypoints/slack/messaging.py b/src/sentry/seer/entrypoints/slack/messaging.py index faa83714474b..99d08fe71b6a 100644 --- a/src/sentry/seer/entrypoints/slack/messaging.py +++ b/src/sentry/seer/entrypoints/slack/messaging.py @@ -42,7 +42,11 @@ SlackSeerAgentMessageCache, SlackSeerAgentMessageCachePayload, ) -from sentry.shared_integrations.exceptions import IntegrationConfigurationError, IntegrationError +from sentry.shared_integrations.exceptions import ( + IntegrationConfigurationError, + IntegrationError, + IntegrationResourceNotFoundError, +) from sentry.silo.base import all_silo_function from sentry.tasks.base import instrumented_task from sentry.taskworker.namespaces import integrations_tasks @@ -147,10 +151,11 @@ def send_thread_update( def edit_thread_message( *, install: SlackIntegration, channel_id: str, message_ts: str, data: NotificationData -) -> bool: +) -> None: """ Re-render a message we already posted from new data. - Returns whether the message was edited. Raises IntegrationError when a retry may succeed. + Returns once there's nothing left to do, including when the message is gone. Raises + IntegrationError otherwise, so the caller can retry. """ with SlackEntrypointEventLifecycleMetric( interaction_type=SlackEntrypointInteractionType.EDIT_THREAD_MESSAGE, @@ -169,16 +174,18 @@ def edit_thread_message( install.update_message( channel_id=channel_id, message_ts=message_ts, renderable=renderable ) - except IntegrationConfigurationError as e: + except IntegrationResourceNotFoundError as e: lifecycle.record_halt(halt_reason=e) - return False except TimeoutError as e: lifecycle.record_failure(failure_reason=e) raise IntegrationError("Slack request timed out") from e + except IntegrationConfigurationError as e: + # Halts like rate limits can clear, and the edit may carry the run's final PR status. + lifecycle.record_halt(halt_reason=e) + raise except IntegrationError as e: lifecycle.record_failure(failure_reason=e) raise - return True @instrumented_task( diff --git a/tests/sentry/seer/entrypoints/slack/test_slack.py b/tests/sentry/seer/entrypoints/slack/test_slack.py index 3ecc711e2947..b3eeb2366b9d 100644 --- a/tests/sentry/seer/entrypoints/slack/test_slack.py +++ b/tests/sentry/seer/entrypoints/slack/test_slack.py @@ -71,7 +71,11 @@ from sentry.seer.entrypoints.types import SeerEntrypointKey from sentry.seer.models import SeerAutomationHandoffConfiguration, SeerProjectPreference from sentry.sentry_apps.event_types import SentryAppEventType -from sentry.shared_integrations.exceptions import IntegrationConfigurationError, IntegrationError +from sentry.shared_integrations.exceptions import ( + IntegrationConfigurationError, + IntegrationError, + IntegrationResourceNotFoundError, +) from sentry.testutils.asserts import assert_halt_metric from sentry.testutils.cases import TestCase from sentry.testutils.helpers.options import override_options @@ -1290,18 +1294,19 @@ def _two_posted_answers_with_stale_footers(self) -> SeerRunState: ) return state + @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @patch("slack_sdk.web.client.WebClient.chat_postMessage") @patch("slack_sdk.web.client.WebClient.chat_update") - def test_on_agent_update_failed_edit_does_not_block_other_answers( - self, update: Mock, post: Mock + def test_on_agent_update_rate_limited_edit_retries_without_blocking_other_answers( + self, update: Mock, post: Mock, mock_record: Mock ) -> None: state = self._two_posted_answers_with_stale_footers() update.side_effect = [ - SlackApiError("error", _slack_response({"ok": False, "error": "invalid_blocks"})), + SlackApiError("error", _slack_response({"ok": False, "error": "ratelimited"})), _slack_response({"ok": True}), ] - with pytest.raises(IntegrationError): + with pytest.raises(IntegrationConfigurationError): SlackAgentEntrypoint.on_agent_update( cache_payload=self._get_entrypoint().create_agent_cache_payload(), run_id=state.run_id, @@ -1310,13 +1315,14 @@ def test_on_agent_update_failed_edit_does_not_block_other_answers( post.assert_not_called() assert [call.kwargs["ts"] for call in update.call_args_list] == ["1.1", "2.2"] + assert_halt_metric(mock_record, IntegrationConfigurationError()) replies = SlackSeerAgentReplyCache.get(run_id=state.run_id) assert replies["b2"]["pr_status_hash"] == "stale" assert replies["b4"]["pr_status_hash"] != "stale" @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") @patch("slack_sdk.web.client.WebClient.chat_update") - def test_on_agent_update_deleted_answer_halts_edit( + def test_on_agent_update_deleted_answer_is_not_edited_again( self, update: Mock, mock_record: Mock ) -> None: state = self._two_posted_answers_with_stale_footers() @@ -1324,17 +1330,17 @@ def test_on_agent_update_deleted_answer_halts_edit( SlackApiError("error", _slack_response({"ok": False, "error": "message_not_found"})), _slack_response({"ok": True}), ] + cache_payload = self._get_entrypoint().create_agent_cache_payload() - SlackAgentEntrypoint.on_agent_update( - cache_payload=self._get_entrypoint().create_agent_cache_payload(), - run_id=state.run_id, - state=state, - ) + for _ in range(2): + SlackAgentEntrypoint.on_agent_update( + cache_payload=cache_payload, run_id=state.run_id, state=state + ) assert update.call_count == 2 - assert_halt_metric(mock_record, IntegrationConfigurationError()) + assert_halt_metric(mock_record, IntegrationResourceNotFoundError()) replies = SlackSeerAgentReplyCache.get(run_id=state.run_id) - assert replies["b2"]["pr_status_hash"] == "stale" + assert replies["b2"]["pr_status_hash"] != "stale" assert replies["b4"]["pr_status_hash"] != "stale" @patch("sentry.seer.entrypoints.slack.entrypoint.logger") From b8939dc222f28dd85bd3375b4ac93a855a70ad69 Mon Sep 17 00:00:00 2001 From: joseph-sentry Date: Wed, 28 Oct 2026 11:51:15 -0400 Subject: [PATCH 4/6] test(seer): Cover Slack reply edit errors with one end-to-end test Replace the three edit-error tests with one test that runs refreshes until nothing is left to edit. A rate-limited edit doesn't block the next answer and is retried, and a deleted reply halts once and isn't edited again. The reply_not_posted log no longer has its own test. --- .../seer/entrypoints/slack/test_slack.py | 83 +++++-------------- 1 file changed, 19 insertions(+), 64 deletions(-) diff --git a/tests/sentry/seer/entrypoints/slack/test_slack.py b/tests/sentry/seer/entrypoints/slack/test_slack.py index b3eeb2366b9d..76699f84924d 100644 --- a/tests/sentry/seer/entrypoints/slack/test_slack.py +++ b/tests/sentry/seer/entrypoints/slack/test_slack.py @@ -1269,7 +1269,11 @@ def blocks(summary: str, *footers: str) -> list[dict[str, Any]]: ), ] - def _two_posted_answers_with_stale_footers(self) -> SeerRunState: + @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") + @patch("slack_sdk.web.client.WebClient.chat_update") + def test_on_agent_update_retries_failed_edits_and_skips_deleted_replies( + self, update: Mock, mock_record: Mock + ) -> None: state = _agent_state("First answer.") state.blocks += [ MemoryBlock( @@ -1286,87 +1290,38 @@ def _two_posted_answers_with_stale_footers(self) -> SeerRunState: state.repo_pr_states["acme/web"] = RepoPRState( repo_name="acme/web", pr_creation_status="creating" ) + # Both answers were posted before the PR status changed, so both footers need an edit. for key, ts in (("b2", "1.1"), ("b4", "2.2")): SlackSeerAgentReplyCache.set( run_id=state.run_id, key=key, reply=SlackSeerAgentReply(message_ts=ts, pr_status_hash="stale"), ) - return state - - @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") - @patch("slack_sdk.web.client.WebClient.chat_postMessage") - @patch("slack_sdk.web.client.WebClient.chat_update") - def test_on_agent_update_rate_limited_edit_retries_without_blocking_other_answers( - self, update: Mock, post: Mock, mock_record: Mock - ) -> None: - state = self._two_posted_answers_with_stale_footers() update.side_effect = [ SlackApiError("error", _slack_response({"ok": False, "error": "ratelimited"})), - _slack_response({"ok": True}), - ] - - with pytest.raises(IntegrationConfigurationError): - SlackAgentEntrypoint.on_agent_update( - cache_payload=self._get_entrypoint().create_agent_cache_payload(), - run_id=state.run_id, - state=state, - ) - - post.assert_not_called() - assert [call.kwargs["ts"] for call in update.call_args_list] == ["1.1", "2.2"] - assert_halt_metric(mock_record, IntegrationConfigurationError()) - replies = SlackSeerAgentReplyCache.get(run_id=state.run_id) - assert replies["b2"]["pr_status_hash"] == "stale" - assert replies["b4"]["pr_status_hash"] != "stale" - - @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") - @patch("slack_sdk.web.client.WebClient.chat_update") - def test_on_agent_update_deleted_answer_is_not_edited_again( - self, update: Mock, mock_record: Mock - ) -> None: - state = self._two_posted_answers_with_stale_footers() - update.side_effect = [ SlackApiError("error", _slack_response({"ok": False, "error": "message_not_found"})), _slack_response({"ok": True}), ] cache_payload = self._get_entrypoint().create_agent_cache_payload() - for _ in range(2): + def refresh() -> None: SlackAgentEntrypoint.on_agent_update( cache_payload=cache_payload, run_id=state.run_id, state=state ) - assert update.call_count == 2 - assert_halt_metric(mock_record, IntegrationResourceNotFoundError()) - replies = SlackSeerAgentReplyCache.get(run_id=state.run_id) - assert replies["b2"]["pr_status_hash"] != "stale" - assert replies["b4"]["pr_status_hash"] != "stale" - - @patch("sentry.seer.entrypoints.slack.entrypoint.logger") - @patch("sentry.seer.entrypoints.slack.entrypoint.send_thread_update", return_value=None) - def test_on_agent_update_logs_reply_not_posted( - self, mock_send: Mock, mock_logger: Mock - ) -> None: - state = _agent_state("Test summary") - - SlackAgentEntrypoint.on_agent_update( - cache_payload=self._get_entrypoint().create_agent_cache_payload(), - run_id=state.run_id, - state=state, - ) + # The rate-limited edit doesn't block the next one, and raises so the refresh task retries. + with pytest.raises(IntegrationError): + refresh() + # The retry edits only the rate-limited answer; the deleted one is never edited again. + refresh() + refresh() - mock_send.assert_called_once() - mock_logger.warning.assert_called_once_with( - "seer.entrypoint.slack.agent_update.reply_not_posted", - extra={ - "organization_id": self.organization.id, - "integration_id": self.integration.id, - "run_id": state.run_id, - "reply_key": "b2", - }, - ) - assert SlackSeerAgentReplyCache.get(run_id=state.run_id) == {} + assert [call.kwargs["ts"] for call in update.call_args_list] == ["1.1", "2.2", "1.1"] + assert [ + type(call.args[1]) + for call in mock_record.mock_calls + if call.args[0] == EventLifecycleOutcome.HALTED + ] == [IntegrationConfigurationError, IntegrationResourceNotFoundError] def _slack_response(data: dict[str, Any]) -> SlackResponse: From f8af5c27557a935399509504c7ae5805d0d7dd36 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 3 Nov 2026 16:39:21 +0000 Subject: [PATCH 5/6] ref(seer): Say an unposted Seer Agent reply waits for the next hook send_thread_update swallows halts, so refresh_agent_run isn't retried and the reply only posts if Seer sends another completion hook. Refs CW-2180 --- src/sentry/seer/entrypoints/slack/entrypoint.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/sentry/seer/entrypoints/slack/entrypoint.py b/src/sentry/seer/entrypoints/slack/entrypoint.py index 8900908f4c3d..4d816c880a8f 100644 --- a/src/sentry/seer/entrypoints/slack/entrypoint.py +++ b/src/sentry/seer/entrypoints/slack/entrypoint.py @@ -690,8 +690,8 @@ def on_agent_update( reply["missing_scope_settings_url"] = data.missing_scope_settings_url SlackSeerAgentReplyCache.set(run_id=run_id, key=key, reply=reply) else: - # send_thread_update records why as a halt; this ties it to the run, which retries the - # reply on its next refresh. + # send_thread_update records why as a halt and doesn't raise, so this refresh isn't + # retried. The reply is posted on the run's next Seer completion hook, if there is one. logger.warning( "seer.entrypoint.slack.agent_update.reply_not_posted", extra={ From 88ff0543d1c545ca31cc0cbb99ab4aa259143cc2 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 3 Nov 2026 16:47:42 +0000 Subject: [PATCH 6/6] test(seer): Build Slack edit errors with TypedSlackApiError Master replaced SlackApiError with TypedSlackApiError in this test file under strict mypy, so the merged test lost its SlackApiError import. --- tests/sentry/seer/entrypoints/slack/test_slack.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/sentry/seer/entrypoints/slack/test_slack.py b/tests/sentry/seer/entrypoints/slack/test_slack.py index 76699f84924d..c04cdf0fdf02 100644 --- a/tests/sentry/seer/entrypoints/slack/test_slack.py +++ b/tests/sentry/seer/entrypoints/slack/test_slack.py @@ -1298,8 +1298,10 @@ def test_on_agent_update_retries_failed_edits_and_skips_deleted_replies( reply=SlackSeerAgentReply(message_ts=ts, pr_status_hash="stale"), ) update.side_effect = [ - SlackApiError("error", _slack_response({"ok": False, "error": "ratelimited"})), - SlackApiError("error", _slack_response({"ok": False, "error": "message_not_found"})), + TypedSlackApiError("error", _slack_response({"ok": False, "error": "ratelimited"})), + TypedSlackApiError( + "error", _slack_response({"ok": False, "error": "message_not_found"}) + ), _slack_response({"ok": True}), ] cache_payload = self._get_entrypoint().create_agent_cache_payload()