This is an automated email from the ASF dual-hosted git repository.

kaxil pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 2db44d00e6d Interrupt a running Anthropic session before archiving it 
(#71465)
2db44d00e6d is described below

commit 2db44d00e6db68f1dc1c04e17a9a6155f628b581
Author: Kaxil Naik <[email protected]>
AuthorDate: Thu Aug 13 14:48:44 2026 +0100

    Interrupt a running Anthropic session before archiving it (#71465)
    
    The API refuses to archive **or** delete a session while its status is
    ``running``, rejecting both with a 400. ``AnthropicAgentSessionOperator``
    treats archiving as best-effort teardown on every failure path, so a
    session that will not stop on its own is left behind with no way to
    release it -- and it keeps accruing ``active_seconds``, which the SDK
    describes as the duration the session's runtime cost is priced on.
    
    Observed directly: two sessions halted by a $0.01 budget sat at status
    ``running`` for over 40 minutes with model spend frozen at the ceiling
    while ``active_seconds`` kept climbing. ``archive`` and ``delete`` both
    returned 400. Sending ``user.interrupt`` moved them to ``idle``
    immediately, after which archiving succeeded.
    
    ``AnthropicHook.archive_session`` now interrupts and retries when the
    first archive attempt fails, and ``interrupt_session`` exposes the event
    on its own. The retry is bounded, so an unarchivable session still
    surfaces its error rather than looping.
    
    This is reachable well beyond budgets -- any session still working when a
    task fails, times out, or is killed hits the same 400 -- but a budget halt
    makes it routine, because that is a session which has stopped spending
    without stopping.
    
    The interrupt-then-archive sequence was verified by hand against the live
    API on two genuinely stuck sessions. Its wiring through
    ``archive_session`` is covered by unit tests rather than a live run.
---
 .../airflow/providers/anthropic/hooks/anthropic.py | 48 +++++++++++++++++++++-
 .../airflow/providers/anthropic/operators/agent.py | 17 ++++++--
 .../tests/unit/anthropic/hooks/test_anthropic.py   | 38 +++++++++++++++++
 .../tests/unit/anthropic/operators/test_agent.py   |  2 +-
 4 files changed, 98 insertions(+), 7 deletions(-)

diff --git 
a/providers/anthropic/src/airflow/providers/anthropic/hooks/anthropic.py 
b/providers/anthropic/src/airflow/providers/anthropic/hooks/anthropic.py
index 0096b95ff12..864a6bbb813 100644
--- a/providers/anthropic/src/airflow/providers/anthropic/hooks/anthropic.py
+++ b/providers/anthropic/src/airflow/providers/anthropic/hooks/anthropic.py
@@ -31,6 +31,7 @@ from anthropic import (
     AnthropicBedrock,
     AnthropicFoundry,
     AnthropicVertex,
+    BadRequestError,
     IdentityTokenFile,
     WorkloadIdentityCredentials,
 )
@@ -708,15 +709,58 @@ class AnthropicHook(BaseHook):
             session_id, events=cast("list[BetaManagedAgentsEventParams]", 
[event])
         )
 
-    def archive_session(self, session_id: str) -> BetaManagedAgentsSession:
+    def interrupt_session(self, session_id: str) -> Any:
+        """
+        Send ``user.interrupt`` to pause a running session.
+
+        The API refuses to archive or delete a session while it is 
``running``, so this is
+        the only way to release one that is not going to stop on its own -- see
+        :meth:`archive_session`.
+        """
+        self._require_first_party("Managed Agents")
+        return self.send_event(session_id, {"type": "user.interrupt"})
+
+    def archive_session(
+        self, session_id: str, *, attempts: int = 6, wait_seconds: float = 5
+    ) -> BetaManagedAgentsSession:
         """
         Archive a session (frees the server-side container). Best-effort 
teardown.
 
         Returns the archived session, which carries its final ``usage`` -- so 
a caller
         tearing a session down does not need a separate retrieve to report 
what it spent.
+
+        A ``running`` session cannot be archived (nor deleted): the API 
rejects both with a
+        400. Only then does this interrupt the session and retry, because a 
session that
+        will not stop on its own otherwise accrues billable runtime with no 
way to release
+        it. Any other failure is re-raised untouched, so a transient 5xx does 
not send
+        ``user.interrupt`` to a session that was working fine.
+
+        Retrying costs up to ``attempts`` further calls with ``wait_seconds`` 
between them
+        (about 25s at the defaults), which is longer than some callers have: a 
killed task's
+        ``on_kill`` is SIGKILLed a few seconds in, so it passes a much tighter 
budget.
         """
         self._require_first_party("Managed Agents")
-        return self._first_party_conn.beta.sessions.archive(session_id)
+        try:
+            return self._first_party_conn.beta.sessions.archive(session_id)
+        except BadRequestError as e:
+            # Catching the SDK's published error type, not matching on message 
text: a 400
+            # here is the documented "cannot archive while running" rejection.
+            self.log.info("Archiving session %s failed (%s); interrupting and 
retrying.", session_id, e)
+            self.interrupt_session(session_id)
+            return self._wait_for_archive(session_id, attempts=attempts, 
wait_seconds=wait_seconds)
+
+    def _wait_for_archive(
+        self, session_id: str, attempts: int = 6, wait_seconds: float = 5
+    ) -> BetaManagedAgentsSession:
+        """Retry archiving while the interrupt takes effect; the status change 
is not instant."""
+        for attempt in range(attempts):
+            try:
+                return self._first_party_conn.beta.sessions.archive(session_id)
+            except Exception:
+                if attempt == attempts - 1:
+                    raise
+                time.sleep(wait_seconds)
+        raise AnthropicError(f"Could not archive session {session_id}.")  # 
pragma: no cover
 
     def _latest_idle_reason(self, session_id: str, kickoff_event_id: str | 
None) -> str | None:
         """
diff --git 
a/providers/anthropic/src/airflow/providers/anthropic/operators/agent.py 
b/providers/anthropic/src/airflow/providers/anthropic/operators/agent.py
index afdd916d7ce..cf367d2197c 100644
--- a/providers/anthropic/src/airflow/providers/anthropic/operators/agent.py
+++ b/providers/anthropic/src/airflow/providers/anthropic/operators/agent.py
@@ -302,17 +302,20 @@ class AnthropicAgentSessionOperator(BaseOperator):
         except Exception:
             self.log.exception("Could not record usage for session %s", 
session_id)
 
-    def _archive_session(self, session_id: str | None) -> 
BetaManagedAgentsSession | None:
+    def _archive_session(
+        self, session_id: str | None, **archive_kwargs: Any
+    ) -> BetaManagedAgentsSession | None:
         """
         Best-effort teardown of the server-side session (frees its container).
 
         Returns the archived session, which carries its final usage, or 
``None`` if the
-        archive call failed.
+        archive call failed. ``archive_kwargs`` tightens the hook's 
interrupt-and-retry
+        budget for callers that do not have its full ~25s.
         """
         if not session_id:
             return None
         try:
-            return self.hook.archive_session(session_id)
+            return self.hook.archive_session(session_id, **archive_kwargs)
         except Exception as e:
             self.log.warning("Failed to archive session %s: %s", session_id, e)
             return None
@@ -325,5 +328,11 @@ class AnthropicAgentSessionOperator(BaseOperator):
         (``deferrable=False``). On Airflow 3.3+ a killed deferred task is 
archived by the
         trigger's ``on_kill``. On older Airflow the session of a killed 
deferred task is not
         archived automatically; archive it manually via the hook.
+
+        The supervisor escalates to SIGKILL a few seconds after asking the 
task to stop, so
+        the hook's default retry budget would never finish here -- the process 
dies inside
+        the first sleep and the session is never released. This budget gives 
the interrupt a
+        beat to land (the status change is not instant) while still fitting 
inside that
+        window: two attempts, one second apart.
         """
-        self._archive_session(self.session_id)
+        self._archive_session(self.session_id, attempts=2, wait_seconds=1)
diff --git a/providers/anthropic/tests/unit/anthropic/hooks/test_anthropic.py 
b/providers/anthropic/tests/unit/anthropic/hooks/test_anthropic.py
index dd0caaf96d9..816b238d401 100644
--- a/providers/anthropic/tests/unit/anthropic/hooks/test_anthropic.py
+++ b/providers/anthropic/tests/unit/anthropic/hooks/test_anthropic.py
@@ -45,6 +45,8 @@ from airflow.providers.anthropic.hooks.anthropic import (
 
 pytest.importorskip("anthropic")
 
+import httpx
+from anthropic import BadRequestError
 from anthropic.types import BetaMonetaryAmount
 from anthropic.types.beta import BetaManagedAgentsServerToolUsage, 
BetaManagedAgentsSessionUsage
 from anthropic.types.beta.beta_managed_agents_cache_creation_usage import (
@@ -279,6 +281,42 @@ class TestBuildBudget:
             build_budget(amount)
 
 
+def _make_bad_request(message="cannot be archived while its status is 
running") -> BadRequestError:
+    """A real SDK BadRequestError -- archive_session only interrupts on this, 
not on any error."""
+    request = httpx.Request("POST", 
"https://api.anthropic.com/v1/sessions/sess_1/archive";)
+    return BadRequestError(message, response=httpx.Response(400, 
request=request), body=None)
+
+
+class TestArchiveSession:
+    def test_archives_directly_when_the_session_is_stoppable(self):
+        hook, client = _make_hook()
+        hook.archive_session("sess_1")
+        client.beta.sessions.archive.assert_called_once_with("sess_1")
+        client.beta.sessions.events.send.assert_not_called()
+
+    @mock.patch(f"{HOOK_PATH}.time.sleep", autospec=True)
+    def test_interrupts_and_retries_when_the_session_is_running(self, 
mock_sleep):
+        # A running session is refused by both archive and delete, so without 
the interrupt
+        # it keeps accruing billable runtime with no way to release it.
+        hook, client = _make_hook()
+        archived = object()
+        client.beta.sessions.archive.side_effect = [_make_bad_request(), 
archived]
+        assert hook.archive_session("sess_1") is archived
+        client.beta.sessions.events.send.assert_called_once_with(
+            "sess_1", events=[{"type": "user.interrupt"}]
+        )
+        assert client.beta.sessions.archive.call_count == 2
+
+    @mock.patch(f"{HOOK_PATH}.time.sleep", autospec=True)
+    def test_gives_up_after_the_retry_budget(self, mock_sleep):
+        hook, client = _make_hook()
+        client.beta.sessions.archive.side_effect = _make_bad_request()
+        with pytest.raises(BadRequestError):
+            hook.archive_session("sess_1")
+        # one initial attempt plus the bounded retry loop
+        assert client.beta.sessions.archive.call_count == 7
+
+
 class TestUpdateSession:
     def test_only_passes_supplied_keys(self):
         # The API distinguishes omitted (preserve) from None (clear), so an 
unmentioned
diff --git a/providers/anthropic/tests/unit/anthropic/operators/test_agent.py 
b/providers/anthropic/tests/unit/anthropic/operators/test_agent.py
index d7a05e8217c..010e20d393e 100644
--- a/providers/anthropic/tests/unit/anthropic/operators/test_agent.py
+++ b/providers/anthropic/tests/unit/anthropic/operators/test_agent.py
@@ -473,7 +473,7 @@ class TestOnKill:
         op = AnthropicAgentSessionOperator(task_id="a", agent_id="ag", 
environment_id="env", message="hi")
         op.session_id = "sess_1"
         op.on_kill()
-        hook.archive_session.assert_called_once_with("sess_1")
+        hook.archive_session.assert_called_once_with("sess_1", attempts=2, 
wait_seconds=1)
 
     @mock.patch.object(AnthropicAgentSessionOperator, "hook", 
new_callable=mock.PropertyMock)
     def test_on_kill_noop_without_session(self, mock_hook_prop):

Reply via email to