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

ferruzzi 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 27e807b5f1e Add Execution API endpoint for callback terminal state 
(#75253)
27e807b5f1e is described below

commit 27e807b5f1ee3471017954a41b349384763de42a
Author: D. Ferruzzi <[email protected]>
AuthorDate: Tue Nov 10 14:43:13 2026 -0700

    Add Execution API endpoint for callback terminal state (#75253)
    
    * Add Execution API endpoint for callback terminal state
    
    Callback state was written only by the scheduler, reading each executor's 
in-process event buffer. Executors whose workers report back over HTTP never 
reach that channel, so their callbacks sat in QUEUED forever.
    
    The executor-event path stays as a fallback, now narrowed to rows still in 
flight so a late event cannot overwrite an outcome the supervisor already 
reported, or the output captured with it.
    
    Replaces #66141, whose /run half landed separately in #71192.
    
    ---------
    
    Co-authored-by: wjddn279 <[email protected]>
---
 .../execution_api/datamodels/callback.py           |  35 ++++
 .../api_fastapi/execution_api/routes/callbacks.py  |  58 ++++++-
 .../api_fastapi/execution_api/versions/__init__.py |   2 +
 .../execution_api/versions/v2027_02_28.py          |  10 ++
 .../src/airflow/jobs/scheduler_job_runner.py       |  53 +++++-
 .../execution_api/versions/head/test_callbacks.py  | 188 +++++++++++++++++++++
 .../versions/v2027_02_28/test_callbacks.py         |  43 +++++
 airflow-core/tests/unit/jobs/test_scheduler_job.py | 137 ++++++++++++++-
 task-sdk/src/airflow/sdk/api/client.py             |   7 +
 .../src/airflow/sdk/api/datamodels/_generated.py   |  21 +++
 .../sdk/execution_time/callback_supervisor.py      |  26 ++-
 task-sdk/tests/task_sdk/api/test_client.py         |  34 ++++
 .../execution_time/test_callback_supervisor.py     |  88 +++++++++-
 13 files changed, 686 insertions(+), 16 deletions(-)

diff --git 
a/airflow-core/src/airflow/api_fastapi/execution_api/datamodels/callback.py 
b/airflow-core/src/airflow/api_fastapi/execution_api/datamodels/callback.py
new file mode 100644
index 00000000000..bf0154be92f
--- /dev/null
+++ b/airflow-core/src/airflow/api_fastapi/execution_api/datamodels/callback.py
@@ -0,0 +1,35 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+from enum import Enum
+
+from airflow.api_fastapi.core_api.base import StrictBaseModel
+
+
+class CallbackTerminalState(str, Enum):
+    """Terminal states a callback can transition to from RUNNING."""
+
+    SUCCESS = "success"
+    FAILED = "failed"
+
+
+class CallbackTerminalStatePayload(StrictBaseModel):
+    """Payload for transitioning a callback from RUNNING to a terminal 
state."""
+
+    state: CallbackTerminalState
+    output: str | None = None
diff --git 
a/airflow-core/src/airflow/api_fastapi/execution_api/routes/callbacks.py 
b/airflow-core/src/airflow/api_fastapi/execution_api/routes/callbacks.py
index 2f6e386b0b3..655967b665a 100644
--- a/airflow-core/src/airflow/api_fastapi/execution_api/routes/callbacks.py
+++ b/airflow-core/src/airflow/api_fastapi/execution_api/routes/callbacks.py
@@ -16,13 +16,15 @@
 # under the License.
 from __future__ import annotations
 
+from typing import Annotated
 from uuid import UUID
 
 from cadwyn import VersionedAPIRouter
-from fastapi import HTTPException, Response, Security, status
+from fastapi import Body, HTTPException, Response, Security, status
 
 from airflow.api_fastapi.common.db.common import SessionDep
 from airflow.api_fastapi.core_api.openapi.exceptions import 
create_openapi_http_exception_doc
+from airflow.api_fastapi.execution_api.datamodels.callback import 
CallbackTerminalStatePayload
 from airflow.api_fastapi.execution_api.deps import DepContainer
 from airflow.api_fastapi.execution_api.security import (
     ExecutionAPIRoute,
@@ -35,7 +37,7 @@ from airflow.utils.state import CallbackState
 router = VersionedAPIRouter(
     route_class=ExecutionAPIRoute,
     dependencies=[
-        Security(require_auth, scopes=["cb:self", "token:callback"]),
+        Security(require_auth, scopes=["cb:self"]),
     ],
 )
 
@@ -43,6 +45,7 @@ router = VersionedAPIRouter(
 @router.patch(
     "/{callback_id}/run",
     status_code=status.HTTP_204_NO_CONTENT,
+    dependencies=[Security(require_auth, scopes=["token:callback"])],
     responses=create_openapi_http_exception_doc(
         [
             (status.HTTP_404_NOT_FOUND, "Callback not found"),
@@ -83,3 +86,54 @@ def run_callback(
     callback.state = CallbackState.RUNNING
 
     issue_execution_token(services, response, sub=str(callback_id))
+
+
[email protected](
+    "/{callback_id}/state",
+    status_code=status.HTTP_204_NO_CONTENT,
+    responses=create_openapi_http_exception_doc(
+        [
+            (status.HTTP_404_NOT_FOUND, "Callback not found"),
+            (status.HTTP_409_CONFLICT, "The callback is not running and is not 
already in that state"),
+        ]
+    ),
+)
+def update_callback_state(
+    callback_id: UUID,
+    payload: Annotated[CallbackTerminalStatePayload, Body()],
+    session: SessionDep,
+) -> None:
+    """Report the final state of a callback that has finished running."""
+    callback = session.get(Callback, callback_id, with_for_update=True)
+    if callback is None:
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND,
+            detail={
+                "reason": "not_found",
+                "message": f"Callback {callback_id} not found",
+            },
+        )
+
+    # Accepting the state the row already holds keeps the report idempotent: 
the SDK client retries
+    # a lost response (Client.request defaults to retry=True), and a 409 there 
makes the supervisor
+    # log a failure for an outcome that is correct. Safe to replay because 
this route issues no
+    # token, unlike /run above.
+    reported_state = CallbackState(payload.state)
+    if callback.state not in (CallbackState.RUNNING, reported_state):
+        raise HTTPException(
+            status_code=status.HTTP_409_CONFLICT,
+            detail={
+                "reason": "invalid_state",
+                "message": (
+                    f"Callback {callback_id} is in state {callback.state}; a 
terminal state can only "
+                    f"be reported for a RUNNING callback, or for one already 
in {reported_state}."
+                ),
+                "previous_state": callback.state,
+            },
+        )
+
+    callback.state = reported_state
+    # Written on the idempotent path too: the scheduler may have reached this 
same terminal state
+    # first and stored its own placeholder output.
+    if payload.output is not None:
+        callback.output = payload.output
diff --git 
a/airflow-core/src/airflow/api_fastapi/execution_api/versions/__init__.py 
b/airflow-core/src/airflow/api_fastapi/execution_api/versions/__init__.py
index c0dd008cecf..65c0be914f1 100644
--- a/airflow-core/src/airflow/api_fastapi/execution_api/versions/__init__.py
+++ b/airflow-core/src/airflow/api_fastapi/execution_api/versions/__init__.py
@@ -69,6 +69,7 @@ from airflow.api_fastapi.execution_api.versions.v2026_10_30 
import (
     ReturnNotFoundForMissingDagRun,
 )
 from airflow.api_fastapi.execution_api.versions.v2027_02_28 import (
+    AddCallbackStateEndpoint,
     AddDagProcessorJobEndpoints,
     AddExecutionTimeoutToTIRunContext,
     MakeVariableValueNonNullable,
@@ -81,6 +82,7 @@ bundle = VersionBundle(
         AddDagProcessorJobEndpoints,
         AddExecutionTimeoutToTIRunContext,
         MakeVariableValueNonNullable,
+        AddCallbackStateEndpoint,
     ),
     Version(
         "2026-10-30",
diff --git 
a/airflow-core/src/airflow/api_fastapi/execution_api/versions/v2027_02_28.py 
b/airflow-core/src/airflow/api_fastapi/execution_api/versions/v2027_02_28.py
index 8447ed2756f..96cbfbab984 100644
--- a/airflow-core/src/airflow/api_fastapi/execution_api/versions/v2027_02_28.py
+++ b/airflow-core/src/airflow/api_fastapi/execution_api/versions/v2027_02_28.py
@@ -83,3 +83,13 @@ class MakeVariableValueNonNullable(VersionChange):
         """Keep the old behavior of storing ``str(None)`` when an older client 
sets null."""
         if "val" in request.body and request.body["val"] is None:
             request.body["val"] = "None"
+
+
+class AddCallbackStateEndpoint(VersionChange):
+    """Add the callback terminal-state endpoint."""
+
+    description = __doc__
+
+    instructions_to_migrate_to_previous_version = (
+        endpoint("/callbacks/{callback_id}/state", ["PATCH"]).didnt_exist,
+    )
diff --git a/airflow-core/src/airflow/jobs/scheduler_job_runner.py 
b/airflow-core/src/airflow/jobs/scheduler_job_runner.py
index 8566da6cb83..7cc8f06dd0e 100644
--- a/airflow-core/src/airflow/jobs/scheduler_job_runner.py
+++ b/airflow-core/src/airflow/jobs/scheduler_job_runner.py
@@ -1578,7 +1578,8 @@ class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
         # Handle callback state events
         for callback_id in callback_keys_with_events:
             state, info = event_buffer.pop(callback_id)
-            callback = session.get(Callback, UUID(str(callback_id)))
+            callback_uuid = UUID(str(callback_id))
+            callback = session.get(Callback, callback_uuid)
             if not callback:
                 # This should not normally happen - we just received an event 
for this callback.
                 # Only possible if callback was deleted mid-execution (e.g., 
cascade delete from DagRun deletion).
@@ -1587,14 +1588,52 @@ class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
                 )
                 continue
 
+            # The supervisor reports the outcome through PATCH 
/callbacks/{id}/state, this is
+            # in case a supervisor dies before reporting.
+            if callback.state not in (CallbackState.QUEUED, 
CallbackState.RUNNING):
+                cls.logger().debug(
+                    "Callback %s is already %s; ignoring executor event with 
state %s",
+                    callback_id,
+                    callback.state,
+                    state,
+                )
+                continue
+
+            new_values: dict[str, Any] = {"state": state}
+            if state == CallbackState.FAILED:
+                new_values["output"] = str(info) if info else "Execution 
failed"
+
+            # The QUEUED/RUNNING check above reads an ORM snapshot, and the 
supervisor can report a
+            # terminal state through the API before this write lands. 
Repeating it here stops a
+            # terminal row moving.
+            updated = cast(
+                "CursorResult",
+                session.execute(
+                    update(Callback)
+                    .where(
+                        Callback.id == callback_uuid,
+                        Callback.state.in_((CallbackState.QUEUED, 
CallbackState.RUNNING)),
+                    )
+                    .values(**new_values)
+                    .execution_options(synchronize_session=False)
+                ),
+            )
+            # The row moved under us, so the ORM copy is stale either way.
+            session.expire(callback)
+
+            if not updated.rowcount:
+                cls.logger().info(
+                    "Callback %s reached a terminal state before its executor 
event (%s) was handled; "
+                    "keeping the reported result",
+                    callback_id,
+                    state,
+                )
+                continue
+
             if state == CallbackState.SUCCESS:
-                callback.state = CallbackState.SUCCESS
                 cls.logger().info("Callback %s completed successfully", 
callback_id)
-            elif state == CallbackState.FAILED:
-                callback.state = CallbackState.FAILED
-                callback.output = str(info) if info else "Execution failed"
-                cls.logger().error("Callback %s failed: %s", callback_id, 
callback.output)
-            session.add(callback)
+            else:
+                cls.logger().error("Callback %s failed: %s", callback_id, 
new_values["output"])
 
         # Return if no finished tasks
         if not tis_with_right_state:
diff --git 
a/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_callbacks.py
 
b/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_callbacks.py
index da24c5c5f9f..d7a6cbafd4b 100644
--- 
a/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_callbacks.py
+++ 
b/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_callbacks.py
@@ -94,6 +94,148 @@ class TestRunCallback:
         assert response.json()["detail"]["reason"] == "not_found"
 
 
[email protected]
+def make_running_callback(session):
+    """Build a callback already in RUNNING, the only state the state endpoint 
accepts."""
+
+    def _make():
+        callback = ExecutorCallback(
+            callback_def=SyncCallback(sync_callback, kwargs={}),
+            fetch_method=CallbackFetchMethod.IMPORT_PATH,
+        )
+        callback.data["dag_id"] = "test_dag"
+        callback.state = CallbackState.RUNNING
+        session.add(callback)
+        session.commit()
+        return callback
+
+    return _make
+
+
[email protected]
+def running_callback(make_running_callback):
+    return make_running_callback()
+
+
[email protected]
+def other_callback(make_running_callback):
+    """A second, unrelated callback, so a token can name a real id that is not 
this one."""
+    return make_running_callback()
+
+
+class TestUpdateCallbackState:
+    @pytest.mark.parametrize(
+        ("state", "output"),
+        [
+            pytest.param("success", None, id="success-without-output"),
+            pytest.param("success", "all good", id="success-with-output"),
+            pytest.param("failed", "exited with code 3", 
id="failed-with-output"),
+        ],
+    )
+    def test_running_callback_reaches_its_terminal_state(
+        self, client, session, running_callback, state, output
+    ):
+        body = {"state": state}
+        if output is not None:
+            body["output"] = output
+
+        response = 
client.patch(f"/execution/callbacks/{running_callback.id}/state", json=body)
+
+        assert response.status_code == 204
+        session.expire_all()
+        session.refresh(running_callback)
+        assert running_callback.state == state
+        assert running_callback.output == output
+
+    def test_output_is_left_alone_when_omitted(self, client, session, 
running_callback):
+        running_callback.output = "captured earlier"
+        session.commit()
+
+        response = 
client.patch(f"/execution/callbacks/{running_callback.id}/state", 
json={"state": "failed"})
+
+        assert response.status_code == 204
+        session.expire_all()
+        session.refresh(running_callback)
+        assert running_callback.output == "captured earlier"
+
+    @pytest.mark.parametrize(
+        "state",
+        [CallbackState.QUEUED, CallbackState.PENDING, CallbackState.FAILED],
+    )
+    def test_only_a_running_callback_can_be_reported(self, client, session, 
running_callback, state):
+        """A row that is neither RUNNING nor already in the reported state is 
refused."""
+        running_callback.state = state
+        session.commit()
+
+        response = client.patch(
+            f"/execution/callbacks/{running_callback.id}/state", 
json={"state": "success"}
+        )
+
+        assert response.status_code == 409
+        detail = response.json()["detail"]
+        assert detail["reason"] == "invalid_state"
+        assert detail["previous_state"] == state
+
+        session.expire_all()
+        session.refresh(running_callback)
+        assert running_callback.state == state
+
+    @pytest.mark.parametrize("state", [CallbackState.SUCCESS, 
CallbackState.FAILED])
+    def test_repeat_report_of_the_same_state_is_accepted(self, client, 
session, running_callback, state):
+        """A retry after a lost response must not look like a failure to the 
supervisor."""
+        running_callback.state = state
+        running_callback.output = "reported on the first attempt"
+        session.commit()
+
+        response = client.patch(
+            f"/execution/callbacks/{running_callback.id}/state",
+            json={"state": str(state), "output": "reported on the first 
attempt"},
+        )
+
+        assert response.status_code == 204
+        session.expire_all()
+        session.refresh(running_callback)
+        assert running_callback.state == state
+        assert running_callback.output == "reported on the first attempt"
+
+    def test_repeat_report_replaces_output_written_by_the_scheduler(self, 
client, session, running_callback):
+        """The scheduler can reach FAILED first with a placeholder; the real 
output still wins."""
+        running_callback.state = CallbackState.FAILED
+        running_callback.output = "Execution failed"
+        session.commit()
+
+        response = client.patch(
+            f"/execution/callbacks/{running_callback.id}/state",
+            json={"state": "failed", "output": "exited with code 3"},
+        )
+
+        assert response.status_code == 204
+        session.expire_all()
+        session.refresh(running_callback)
+        assert running_callback.state == CallbackState.FAILED
+        assert running_callback.output == "exited with code 3"
+
+    def test_returns_404_for_nonexistent(self, client):
+        response = client.patch(
+            "/execution/callbacks/00000000-0000-0000-0000-000000000000/state",
+            json={"state": "success"},
+        )
+        assert response.status_code == 404
+        assert response.json()["detail"]["reason"] == "not_found"
+
+    @pytest.mark.parametrize(
+        "body",
+        [
+            pytest.param({"state": "running"}, id="non-terminal-state"),
+            pytest.param({"state": "success", "extra": "nope"}, 
id="unknown-field"),
+            pytest.param({}, id="missing-state"),
+        ],
+    )
+    def test_rejects_a_malformed_payload(self, client, running_callback, body):
+        response = 
client.patch(f"/execution/callbacks/{running_callback.id}/state", json=body)
+        assert response.status_code == 422
+
+
 @pytest.fixture
 def _use_real_jwt_bearer(exec_app):
     """Remove the mock require_auth override so the real JWT validation runs 
end-to-end."""
@@ -129,3 +271,49 @@ def test_run_validates_token_scope_and_sub(client, 
queued_callback, token_sub, t
 
     resp = client.patch(f"/execution/callbacks/{queued_callback.id}/run")
     assert resp.status_code == expected_status, resp.json()
+
+
[email protected]("_use_real_jwt_bearer")
[email protected](
+    ("token_sub", "token_scope", "expected_status"),
+    [
+        pytest.param("self", "execution", 204, 
id="execution-token-matching-sub-accepted"),
+        pytest.param("other", "execution", 403, 
id="execution-token-for-other-callback-rejected"),
+        pytest.param(
+            "00000000-0000-0000-0000-000000000000",
+            "execution",
+            404,
+            id="execution-token-for-unknown-subject-rejected",
+        ),
+        pytest.param("self", "callback", 403, 
id="callback-scope-token-rejected"),
+    ],
+)
+def test_state_validates_token_scope_and_sub(
+    client, running_callback, other_callback, token_sub, token_scope, 
expected_status
+):
+    """
+    Reporting state needs the execution token minted by /run, not the 
admission ticket.
+
+    A callback-scope token is refused here, which is the point of the swap: 
the single-use
+    ticket buys one transition into RUNNING and nothing else.
+
+    The two rejected execution-token cases differ by design. A sub naming 
another *existing*
+    callback clears the live-attempt admission check and is then refused 403 
by ``cb:self``,
+    while a sub naming nothing at all is refused 404 by the admission check 
itself, which runs
+    first because the parent ``authenticated_router`` declares 
``require_auth`` with no scopes
+    and FastAPI resolves the outermost dependency before the ``cb:self`` one.
+    """
+    subs = {"self": str(running_callback.id), "other": str(other_callback.id)}
+    sub = subs.get(token_sub, token_sub)
+    validator = mock.AsyncMock(spec=JWTValidator)
+    validator.avalidated_claims.return_value = {
+        "sub": sub,
+        "scope": token_scope,
+        "exp": 9999999999,
+        "iat": 1000000000,
+        "nbf": 1000000000,
+    }
+    lifespan.registry.register_value(JWTValidator, validator)
+
+    resp = client.patch(f"/execution/callbacks/{running_callback.id}/state", 
json={"state": "success"})
+    assert resp.status_code == expected_status, resp.json()
diff --git 
a/airflow-core/tests/unit/api_fastapi/execution_api/versions/v2027_02_28/test_callbacks.py
 
b/airflow-core/tests/unit/api_fastapi/execution_api/versions/v2027_02_28/test_callbacks.py
new file mode 100644
index 00000000000..4e85d24b3b4
--- /dev/null
+++ 
b/airflow-core/tests/unit/api_fastapi/execution_api/versions/v2027_02_28/test_callbacks.py
@@ -0,0 +1,43 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import pytest
+
+pytestmark = pytest.mark.db_test
+
+MISSING_CALLBACK_URL = 
"/execution/callbacks/00000000-0000-0000-0000-000000000000/state"
+
+
+class TestUpdateCallbackStateEndpointVersioning:
+    """The callbacks/{callback_id}/state endpoint didn't exist before the 
2027-02-28 API version."""
+
+    def test_old_version_returns_404(self, client):
+        """Before 2027-02-28 the route is absent, so routing itself 404s (no 
endpoint-shaped detail)."""
+        client.headers["Airflow-API-Version"] = "2026-10-30"
+
+        response = client.patch(MISSING_CALLBACK_URL, json={"state": 
"success"})
+
+        assert response.status_code == 404
+        assert response.json() == {"detail": "Not Found"}
+
+    def test_head_version_routes_to_endpoint(self, client):
+        """At head the route exists: the same request reaches the endpoint's 
own 404 handling."""
+        response = client.patch(MISSING_CALLBACK_URL, json={"state": 
"success"})
+
+        assert response.status_code == 404
+        assert response.json()["detail"]["reason"] == "not_found"
diff --git a/airflow-core/tests/unit/jobs/test_scheduler_job.py 
b/airflow-core/tests/unit/jobs/test_scheduler_job.py
index b15a82ace30..4104c8740f1 100644
--- a/airflow-core/tests/unit/jobs/test_scheduler_job.py
+++ b/airflow-core/tests/unit/jobs/test_scheduler_job.py
@@ -86,7 +86,7 @@ from airflow.models.asset import (
     PartitionedAssetKeyLog,
 )
 from airflow.models.backfill import Backfill, BackfillDagRun, 
ReprocessBehavior, _create_backfill
-from airflow.models.callback import Callback, ExecutorCallback
+from airflow.models.callback import Callback, CallbackKey, ExecutorCallback
 from airflow.models.connection_test import (
     ConnectionTestKey,
     ConnectionTestRequest,
@@ -223,6 +223,9 @@ DEFAULT_DATE = timezone.datetime(2016, 1, 1)
 DEFAULT_LOGICAL_DATE = timezone.coerce_datetime(DEFAULT_DATE)
 TRY_NUMBER = 1
 
+# Output already on a callback row before an executor event arrives for it.
+PRIOR_OUTPUT = "reported by the supervisor"
+
 
 def get_callback_ti(ti):
     return TIDataModel.model_validate(ti, from_attributes=True).model_copy(
@@ -1207,6 +1210,138 @@ class TestSchedulerJob:
         assert session.get(ExecutorCallback, queued_callback.id).state == 
CallbackState.QUEUED
         assert session.get(ExecutorCallback, running_callback.id).state == 
CallbackState.RUNNING
 
+    @pytest.mark.parametrize(
+        ("db_state", "event_state", "event_info", "expected_state", 
"expected_output"),
+        [
+            pytest.param(
+                CallbackState.RUNNING,
+                CallbackState.SUCCESS,
+                None,
+                CallbackState.SUCCESS,
+                PRIOR_OUTPUT,
+                id="running-advances-on-success",
+            ),
+            pytest.param(
+                CallbackState.QUEUED,
+                CallbackState.FAILED,
+                "worker died",
+                CallbackState.FAILED,
+                "worker died",
+                id="queued-advances-when-the-supervisor-never-reported",
+            ),
+            pytest.param(
+                CallbackState.SUCCESS,
+                CallbackState.FAILED,
+                "worker died",
+                CallbackState.SUCCESS,
+                PRIOR_OUTPUT,
+                id="terminal-row-keeps-its-state-and-output",
+            ),
+            pytest.param(
+                CallbackState.SCHEDULED,
+                CallbackState.FAILED,
+                "worker died",
+                CallbackState.SCHEDULED,
+                PRIOR_OUTPUT,
+                id="never-enqueued-row-is-left-alone",
+            ),
+        ],
+    )
+    def test_process_executor_events_only_advances_callbacks_still_in_flight(
+        self, dag_maker, session, db_state, event_state, event_info, 
expected_state, expected_output
+    ):
+        """
+        The executor event is a fallback behind PATCH /callbacks/{id}/state.
+
+        Both write terminal callback state, so the first writer wins: once a 
row is terminal, a
+        later executor event must not replace the outcome the supervisor 
reported, nor its output.
+        """
+
+        def test_callback():
+            pass
+
+        with dag_maker(dag_id="test_callback_forward_only"):
+            pass
+        dag_run = dag_maker.create_dagrun()
+
+        callback = Deadline(
+            deadline_time=timezone.utcnow(),
+            callback=SyncCallback(test_callback),
+            dagrun_id=dag_run.id,
+            deadline_alert_id=None,
+        ).callback
+        callback.state = db_state
+        callback.output = PRIOR_OUTPUT
+        callback.data["dag_run_id"] = dag_run.id
+        callback.data["dag_id"] = dag_run.dag_id
+        session.add(callback)
+        session.flush()
+
+        executor = MockExecutor(do_update=False)
+        self.job_runner = SchedulerJobRunner(Job(), executors=[executor])
+        executor.event_buffer[CallbackKey(str(callback.id))] = event_state, 
event_info
+
+        self.job_runner._process_executor_events(executor=executor, 
session=session)
+
+        # Flush before expiring: the scheduler must not commit, so an 
unflushed change would be
+        # discarded by expire_all() and every case would read as "not 
advanced".
+        session.flush()
+        session.expire_all()
+        stored = session.get(ExecutorCallback, callback.id)
+        assert stored.state == expected_state
+        assert stored.output == expected_output
+
+    def 
test_process_executor_events_does_not_overwrite_a_concurrent_terminal_report(
+        self, dag_maker, session
+    ):
+        """
+        The supervisor can report through the API between the scheduler's read 
and its write.
+
+        The in-flight check reads an ORM snapshot, so it cannot see that. Here 
the row is moved to
+        SUCCESS behind the session's back, leaving the snapshot saying RUNNING 
while the database
+        says otherwise -- the same divergence a committed `PATCH 
/callbacks/{id}/state` produces,
+        and the reason the write itself has to carry the in-flight predicate.
+        """
+
+        def test_callback():
+            pass
+
+        with dag_maker(dag_id="test_callback_concurrent_report"):
+            pass
+        dag_run = dag_maker.create_dagrun()
+
+        callback = Deadline(
+            deadline_time=timezone.utcnow(),
+            callback=SyncCallback(test_callback),
+            dagrun_id=dag_run.id,
+            deadline_alert_id=None,
+        ).callback
+        callback.state = CallbackState.RUNNING
+        callback.data["dag_run_id"] = dag_run.id
+        callback.data["dag_id"] = dag_run.dag_id
+        session.add(callback)
+        session.flush()
+
+        session.execute(
+            update(Callback)
+            .where(Callback.id == callback.id)
+            .values(state=CallbackState.SUCCESS, output=PRIOR_OUTPUT)
+            .execution_options(synchronize_session=False)
+        )
+        assert callback.state == CallbackState.RUNNING, "snapshot must still 
be stale"
+
+        executor = MockExecutor(do_update=False)
+        self.job_runner = SchedulerJobRunner(Job(), executors=[executor])
+        executor.event_buffer[CallbackKey(str(callback.id))] = 
CallbackState.FAILED, "worker died"
+
+        self.job_runner._process_executor_events(executor=executor, 
session=session)
+
+        session.flush()
+        session.expire_all()
+        stored = session.get(ExecutorCallback, callback.id)
+        assert stored.state == CallbackState.SUCCESS
+        assert stored.output == PRIOR_OUTPUT
+
     @mock.patch("airflow.jobs.scheduler_job_runner.TaskCallbackRequest")
     @mock.patch("airflow._shared.observability.metrics.stats._get_backend")
     def test_process_executor_events_with_callback(
diff --git a/task-sdk/src/airflow/sdk/api/client.py 
b/task-sdk/src/airflow/sdk/api/client.py
index 0eb04302736..008ae64f8ee 100644
--- a/task-sdk/src/airflow/sdk/api/client.py
+++ b/task-sdk/src/airflow/sdk/api/client.py
@@ -50,6 +50,8 @@ from airflow.sdk.api.datamodels._generated import (
     AssetResponse,
     AssetStateStorePutBody,
     AssetStateStoreResponse,
+    CallbackTerminalState,
+    CallbackTerminalStatePayload,
     ConnectionResponse,
     ConnectionTestConnectionResponse,
     ConnectionTestResultBody,
@@ -1201,6 +1203,11 @@ class CallbackOperations:
         """Exchange the single-use callback token for an execution token."""
         self.client.patch(f"callbacks/{callback_id}/run")
 
+    def finish(self, callback_id: uuid.UUID, state: CallbackTerminalState, 
output: str | None = None) -> None:
+        """Report the terminal state of a callback to the API server."""
+        body = CallbackTerminalStatePayload(state=state, output=output)
+        self.client.patch(f"callbacks/{callback_id}/state", 
content=body.model_dump_json())
+
 
 class BearerAuth(httpx2.Auth):
     def __init__(self, token: str):
diff --git a/task-sdk/src/airflow/sdk/api/datamodels/_generated.py 
b/task-sdk/src/airflow/sdk/api/datamodels/_generated.py
index 5351dd8ed37..a2d53f76159 100644
--- a/task-sdk/src/airflow/sdk/api/datamodels/_generated.py
+++ b/task-sdk/src/airflow/sdk/api/datamodels/_generated.py
@@ -64,6 +64,27 @@ class AssetProfile(BaseModel):
     type: Annotated[str, Field(title="Type")]
 
 
+class CallbackTerminalState(str, Enum):
+    """
+    Terminal states a callback can transition to from RUNNING.
+    """
+
+    SUCCESS = "success"
+    FAILED = "failed"
+
+
+class CallbackTerminalStatePayload(BaseModel):
+    """
+    Payload for transitioning a callback from RUNNING to a terminal state.
+    """
+
+    model_config = ConfigDict(
+        extra="forbid",
+    )
+    state: CallbackTerminalState
+    output: Annotated[str | None, Field(title="Output")] = None
+
+
 class ConnectionResponse(BaseModel):
     """
     Connection schema for responses with fields that are needed for Runtime.
diff --git a/task-sdk/src/airflow/sdk/execution_time/callback_supervisor.py 
b/task-sdk/src/airflow/sdk/execution_time/callback_supervisor.py
index a2861de413c..842aea9bfd2 100644
--- a/task-sdk/src/airflow/sdk/execution_time/callback_supervisor.py
+++ b/task-sdk/src/airflow/sdk/execution_time/callback_supervisor.py
@@ -33,6 +33,7 @@ import structlog
 from pydantic import Field, TypeAdapter
 
 from airflow.sdk._shared.module_loading import UNUSUAL_MODULE_PREFIX, 
accepts_context, accepts_keyword_args
+from airflow.sdk.api.datamodels._generated import CallbackTerminalState
 from airflow.sdk.exceptions import ErrorType
 from airflow.sdk.execution_time.comms import (
     ErrorResponse,
@@ -503,8 +504,14 @@ def supervise_callback(
             logger = structlog.get_logger(logger_name="callback").bind()
 
         # Swap the single-use callback token for an execution token before any 
context read.
+        # A failure here leaves the row QUEUED; the scheduler's executor-event 
fallback in
+        # _process_executor_events is what marks it failed, so finish() is 
deliberately not
+        # reached on this path.
         client.callbacks.run(UUID(id))
 
+        terminal_state = CallbackTerminalState.FAILED
+        terminal_output: str | None = None
+
         try:
             process = CallbackSubprocess.start(
                 id=id,
@@ -527,9 +534,22 @@ def supervise_callback(
                 exit_code=exit_code,
                 duration=end - start,
             )
-            if exit_code != 0:
-                raise RuntimeError(f"Callback subprocess exited with code 
{exit_code}")
-            return exit_code
+            if exit_code == 0:
+                terminal_state = CallbackTerminalState.SUCCESS
+            else:
+                terminal_output = f"Callback subprocess exited with code 
{exit_code}"
+        except Exception as e:
+            terminal_output = f"Callback supervisor error: {type(e).__name__}: 
{e}"
+            raise
         finally:
+            try:
+                client.callbacks.finish(UUID(id), state=terminal_state, 
output=terminal_output)
+            except Exception:
+                log.exception("Failed to report final callback state", 
workload_id=id)
+
             if log_path and log_file_descriptor:
                 log_file_descriptor.close()
+
+    if exit_code != 0:
+        raise RuntimeError(f"Callback subprocess exited with code {exit_code}")
+    return exit_code
diff --git a/task-sdk/tests/task_sdk/api/test_client.py 
b/task-sdk/tests/task_sdk/api/test_client.py
index ca0c2793a52..ab1441b0c7d 100644
--- a/task-sdk/tests/task_sdk/api/test_client.py
+++ b/task-sdk/tests/task_sdk/api/test_client.py
@@ -38,6 +38,7 @@ from airflow.sdk.api.datamodels._generated import (
     AssetEventsResponse,
     AssetResponse,
     AssetStateStoreResponse,
+    CallbackTerminalState,
     ConnectionResponse,
     DagResponse,
     DagRunState,
@@ -2637,3 +2638,36 @@ class TestCallbackOperations:
         with pytest.raises(ServerResponseError) as err:
             client.callbacks.run(callback_id)
         assert err.value.response.status_code == 409
+
+    @pytest.mark.parametrize(
+        ("state", "output"),
+        [
+            pytest.param(CallbackTerminalState.SUCCESS, None, 
id="success-without-output"),
+            pytest.param(CallbackTerminalState.FAILED, "exited with code 3", 
id="failed-with-output"),
+        ],
+    )
+    def test_finish_reports_the_terminal_state(self, state, output):
+        callback_id = uuid7()
+
+        def handle_request(request: httpx2.Request) -> httpx2.Response:
+            assert request.method == "PATCH"
+            assert request.url.path == f"/callbacks/{callback_id}/state"
+            assert json.loads(request.content) == {"state": state.value, 
"output": output}
+            return httpx2.Response(status_code=204)
+
+        client = make_client(transport=httpx2.MockTransport(handle_request))
+        client.callbacks.finish(callback_id, state=state, output=output)
+
+    def test_finish_raises_when_the_callback_is_not_running(self):
+        callback_id = uuid7()
+
+        def handle_request(request: httpx2.Request) -> httpx2.Response:
+            return httpx2.Response(
+                status_code=409,
+                json={"detail": {"reason": "invalid_state", "previous_state": 
"queued"}},
+            )
+
+        client = make_client(transport=httpx2.MockTransport(handle_request))
+        with pytest.raises(ServerResponseError) as err:
+            client.callbacks.finish(callback_id, 
state=CallbackTerminalState.FAILED)
+        assert err.value.response.status_code == 409
diff --git a/task-sdk/tests/task_sdk/execution_time/test_callback_supervisor.py 
b/task-sdk/tests/task_sdk/execution_time/test_callback_supervisor.py
index 36c0cf52ee8..564d9cd1b8d 100644
--- a/task-sdk/tests/task_sdk/execution_time/test_callback_supervisor.py
+++ b/task-sdk/tests/task_sdk/execution_time/test_callback_supervisor.py
@@ -31,7 +31,8 @@ from unittest.mock import ANY, Mock, patch
 import pytest
 import structlog
 
-from airflow.sdk.api.datamodels._generated import XComResponse
+from airflow.sdk.api.client import CallbackOperations, Client
+from airflow.sdk.api.datamodels._generated import CallbackTerminalState, 
XComResponse
 from airflow.sdk.execution_time.callback_supervisor import (
     CallbackSubprocess,
     Path,
@@ -731,6 +732,13 @@ class TestCallbackSubprocessStart:
         assert calls == ["import task runner", "fork"]
 
 
[email protected]
+def callback_client():
+    client = Mock(spec=Client)
+    client.callbacks = Mock(spec=CallbackOperations)
+    return client
+
+
 class TestSuperviseCallbackExchangesTokenFirst:
     """No callback code may run until the single-use token has been 
exchanged."""
 
@@ -764,14 +772,19 @@ class TestSuperviseCallbackExchangesTokenFirst:
 
     
@patch("airflow.sdk.execution_time.callback_supervisor._make_process_nondumpable")
     @patch.object(CallbackSubprocess, "start")
-    def test_rejected_exchange_prevents_the_subprocess_from_starting(self, 
mock_start, _nondumpable):
-        client = Mock()
+    def test_rejected_exchange_prevents_the_subprocess_from_starting(
+        self, mock_start, _nondumpable, callback_client
+    ):
+        client = callback_client
         client.callbacks.run.side_effect = RuntimeError("409 Conflict: token 
already exchanged")
 
         with pytest.raises(RuntimeError, match="already exchanged"):
             self._supervise(client)
 
         mock_start.assert_not_called()
+        # The row is still QUEUED, which the state endpoint rejects; the 
scheduler's
+        # executor-event fallback is what fails it.
+        client.callbacks.finish.assert_not_called()
 
     @pytest.mark.parametrize("callback_kwargs_encoding", [None, "serde"])
     
@patch("airflow.sdk.execution_time.callback_supervisor._make_process_nondumpable")
@@ -791,3 +804,72 @@ class TestSuperviseCallbackExchangesTokenFirst:
         )
 
         assert mock_start.call_args.kwargs["callback_kwargs_encoding"] == 
callback_kwargs_encoding
+
+
+class TestSuperviseCallbackReportsTerminalState:
+    """Every path that starts the subprocess must report a terminal state to 
the API."""
+
+    CALLBACK_ID = "01890a5d-ac70-7a5b-b7d5-0dd5b1c7be47"
+
+    def _supervise(self, client):
+        return supervise_callback(
+            id=self.CALLBACK_ID,
+            callback_path="does.not.matter",
+            callback_kwargs={},
+            dag_rel_path=Path("dag.py"),
+            client=client,
+        )
+
+    
@patch("airflow.sdk.execution_time.callback_supervisor._make_process_nondumpable")
+    @patch.object(CallbackSubprocess, "start")
+    def test_clean_exit_reports_success(self, mock_start, _nondumpable, 
callback_client):
+        client = callback_client
+        mock_start.return_value = Mock(wait=Mock(return_value=0))
+
+        assert self._supervise(client) == 0
+
+        client.callbacks.finish.assert_called_once_with(
+            uuid.UUID(self.CALLBACK_ID),
+            state=CallbackTerminalState.SUCCESS,
+            output=None,
+        )
+
+    
@patch("airflow.sdk.execution_time.callback_supervisor._make_process_nondumpable")
+    @patch.object(CallbackSubprocess, "start")
+    def test_nonzero_exit_reports_failed_before_raising(self, mock_start, 
_nondumpable, callback_client):
+        client = callback_client
+        mock_start.return_value = Mock(wait=Mock(return_value=3))
+
+        with pytest.raises(RuntimeError, match="exited with code 3"):
+            self._supervise(client)
+
+        client.callbacks.finish.assert_called_once_with(
+            uuid.UUID(self.CALLBACK_ID),
+            state=CallbackTerminalState.FAILED,
+            output="Callback subprocess exited with code 3",
+        )
+
+    
@patch("airflow.sdk.execution_time.callback_supervisor._make_process_nondumpable")
+    @patch.object(CallbackSubprocess, "start")
+    def test_supervisor_error_reports_failed_with_the_cause(self, mock_start, 
_nondumpable, callback_client):
+        client = callback_client
+        mock_start.side_effect = ValueError("boom")
+
+        with pytest.raises(ValueError, match="boom"):
+            self._supervise(client)
+
+        client.callbacks.finish.assert_called_once_with(
+            uuid.UUID(self.CALLBACK_ID),
+            state=CallbackTerminalState.FAILED,
+            output="Callback supervisor error: ValueError: boom",
+        )
+
+    
@patch("airflow.sdk.execution_time.callback_supervisor._make_process_nondumpable")
+    @patch.object(CallbackSubprocess, "start")
+    def test_a_failure_to_report_does_not_mask_the_outcome(self, mock_start, 
_nondumpable, callback_client):
+        """The scheduler fallback still has the executor event, so a reporting 
blip is not fatal."""
+        client = callback_client
+        client.callbacks.finish.side_effect = RuntimeError("API unreachable")
+        mock_start.return_value = Mock(wait=Mock(return_value=0))
+
+        assert self._supervise(client) == 0

Reply via email to