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

bbovenzi 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 05981d3e2aa Fix task Clear and callback bundles after code-only Dag 
changes (#73915)
05981d3e2aa is described below

commit 05981d3e2aaa2a4ac4aa5c9a5fba350459b5803b
Author: Dheeraj Turaga <[email protected]>
AuthorDate: Tue Oct 20 18:04:29 2026 -0500

    Fix task Clear and callback bundles after code-only Dag changes (#73915)
    
    * Fix task Clear bundle selection after code-only Dag changes
    
    A deployed callable fix can leave serialization unchanged while an existing
    run stays pinned to an older bundle. Users need the latest-version clear
    option to remain available and an explicit selection to take effect for
    active runs. Scheduler callbacks also need to resolve the same bundle as
    task execution.
    
    * Pair task callback bundle manifest with the run's pinned version
    
    Callbacks and failure emails resolve their bundle version from the Dag run,
    but the manifest still came from the task instance's Dag version. When that
    version differs from the one the run was created with, a manifest-based
    bundle would initialize the run's bundle version from another version's
    manifest. Task execution and Dag-run callbacks already take both from the
    run's pinned version.
    
    * Run triggerer task callbacks on the run's pinned bundle
    
    A deferred task that finishes in the triggerer has its callback sent from
    there, and that path still took the bundle from the task instance's Dag
    version. A code-only update advances that version's bundle in place while
    the run stays pinned, so the callback would run newer code than the task
    did. Scheduler callbacks already resolve the bundle from the run.
    
    * Stop pairing a pinned bundle with an overwritten manifest
    
    A code-only update reuses the Dag version and overwrites its bundle version
    and manifest in place, while existing runs stay pinned to the older bundle
    version. Task execution and callbacks then shipped the run's older version
    with the newer manifest, so a manifest-based bundle would materialize code
    the run was never pinned to. Once the original manifest is gone, sending
    none is safer than sending one for a different version.
---
 .../src/airflow/jobs/scheduler_job_runner.py       |  60 ++-------
 airflow-core/src/airflow/models/dag_version.py     |  22 +++-
 airflow-core/src/airflow/models/serialized_dag.py  |   6 +-
 airflow-core/src/airflow/models/trigger.py         |  21 +---
 .../TaskInstance/ClearTaskInstanceDialog.test.tsx  | 137 ++++++++++++++++++---
 .../Clear/TaskInstance/ClearTaskInstanceDialog.tsx |   9 +-
 .../tests/unit/executors/test_workloads.py         |   2 +
 airflow-core/tests/unit/jobs/test_scheduler_job.py |  92 +++++++++++---
 airflow-core/tests/unit/models/test_dag_version.py |   8 +-
 airflow-core/tests/unit/models/test_trigger.py     |  63 +++++++++-
 10 files changed, 308 insertions(+), 112 deletions(-)

diff --git a/airflow-core/src/airflow/jobs/scheduler_job_runner.py 
b/airflow-core/src/airflow/jobs/scheduler_job_runner.py
index 7729ca789e5..7449c09e348 100644
--- a/airflow-core/src/airflow/jobs/scheduler_job_runner.py
+++ b/airflow-core/src/airflow/jobs/scheduler_job_runner.py
@@ -105,7 +105,7 @@ from airflow.models.connection_test import (
     ConnectionTestState,
 )
 from airflow.models.dag import DagModel
-from airflow.models.dag_version import DagVersion, _resolve_version_data
+from airflow.models.dag_version import DagVersion, 
_resolve_ti_callback_bundle_info
 from airflow.models.dagbag import CachedDBDagBag, DBDagBag
 from airflow.models.dagbundle import DagBundleModel
 from airflow.models.dagrun import DagRun
@@ -200,26 +200,6 @@ def _eager_load_dag_run_for_validation() -> 
tuple[LoaderOption, LoaderOption]:
     )
 
 
-def _resolve_ti_callback_bundle_info(ti: TaskInstance) -> tuple[str, str | 
None, Any]:
-    """
-    Resolve the bundle name/version/version-data needed to build a 
TaskCallbackRequest or EmailRequest.
-
-    Used by the heartbeat-timeout purge path. Encapsulates the bundle-pinning 
semantics: fall back
-    to ``dag_model`` for legacy tasks with no ``dag_version`` (pre-AIP-66 
migrations), and leave the
-    bundle version unpinned when the dag run itself wasn't pinned 
(``disable_bundle_versioning``),
-    so the callback runs against the same code as the task did. 
``process_executor_events`` inlines
-    the same resolution for its externally-killed-task path.
-    """
-    bundle_name = ti.dag_version.bundle_name if ti.dag_version else 
ti.dag_model.bundle_name
-    bundle_version = (
-        ti.dag_version.bundle_version
-        if ti.dag_version and ti.dag_run.bundle_version is not None
-        else ti.dag_run.bundle_version
-    )
-    version_data = _resolve_version_data(ti.dag_version, 
ti.dag_run.bundle_version)
-    return bundle_name, bundle_version, version_data
-
-
 def _ensure_ti_has_dag_version_id(ti: TaskInstance, session: Session, log: 
Logger) -> bool:
     """
     Ensure a TaskInstance has a valid dag_version_id for Pydantic 
serialisation.
@@ -1733,18 +1713,7 @@ class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
                     # Only log the error/extra info here, since the 
`ti.handle_failure()` path will log it
                     # too, which would lead to double logging
                     cls.logger().error(msg)
-                    # Safely extract bundle info: prefer dag_version when 
available,
-                    # fall back to dag_model/dag_run for legacy tasks migrated 
from
-                    # Airflow 2 where dag_version may be None (AIP-66).
-                    _bundle_name = ti.dag_version.bundle_name if 
ti.dag_version else ti.dag_model.bundle_name
-                    # Mirror dag_run pinning: if the run wasn't pinned (e.g. 
dag.disable_bundle_versioning=True),
-                    # leave the callback unpinned so it runs against the same 
code as the task.
-                    _bundle_version = (
-                        ti.dag_version.bundle_version
-                        if ti.dag_version and ti.dag_run.bundle_version is not 
None
-                        else ti.dag_run.bundle_version
-                    )
-                    _version_data = _resolve_version_data(ti.dag_version, 
ti.dag_run.bundle_version)
+                    _bundle_name, _bundle_version, _version_data = 
_resolve_ti_callback_bundle_info(ti)
                     # Backfill dag_version_id for legacy tasks (Pydantic 
requires uuid.UUID).
                     if not _ensure_ti_has_dag_version_id(ti, session, 
cls.logger()):
                         continue
@@ -1778,15 +1747,9 @@ class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
                         "Sending email request for task %s to DAG Processor",
                         ti,
                     )
-                    # Safely extract bundle info with fallback for legacy tasks
-                    # (dag_version may be None after Airflow 2 → 3 migration).
-                    _email_bundle_name = (
-                        ti.dag_version.bundle_name if ti.dag_version else 
ti.dag_model.bundle_name
-                    )
-                    _email_bundle_version = (
-                        ti.dag_version.bundle_version if ti.dag_version else 
ti.dag_run.bundle_version
+                    _email_bundle_name, _email_bundle_version, 
_email_version_data = (
+                        _resolve_ti_callback_bundle_info(ti)
                     )
-                    _email_version_data = 
_resolve_version_data(ti.dag_version, ti.dag_run.bundle_version)
                     # Backfill dag_version_id for legacy tasks (Pydantic 
requires uuid.UUID).
                     if not _ensure_ti_has_dag_version_id(ti, session, 
cls.logger()):
                         continue
@@ -3496,19 +3459,9 @@ class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
                 if task.has_on_failure_callback:
                     if inspect(ti).detached:
                         ti = session.merge(ti)
-                    # Safely extract bundle info with fallback for legacy tasks
-                    # (dag_version may be None after Airflow 2 → 3 migration).
-                    _stuck_bundle_name = (
-                        ti.dag_version.bundle_name if ti.dag_version else 
ti.dag_model.bundle_name
+                    _stuck_bundle_name, _stuck_bundle_version, 
_stuck_version_data = (
+                        _resolve_ti_callback_bundle_info(ti)
                     )
-                    # Mirror dag_run pinning: if the run wasn't pinned (e.g. 
dag.disable_bundle_versioning=True),
-                    # leave the callback unpinned so it runs against the same 
code as the task.
-                    _stuck_bundle_version = (
-                        ti.dag_version.bundle_version
-                        if ti.dag_version and ti.dag_run.bundle_version is not 
None
-                        else ti.dag_run.bundle_version
-                    )
-                    _stuck_version_data = 
_resolve_version_data(ti.dag_version, ti.dag_run.bundle_version)
                     # Backfill dag_version_id for legacy tasks (Pydantic 
requires uuid.UUID).
                     # Note: we cannot use `continue` here because this method 
is not
                     # inside a loop.  If backfilling fails we simply skip the 
callback.
@@ -3980,6 +3933,7 @@ class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
             .options(selectinload(TI.dag_model))
             .options(asset_loader)
             .options(alias_loader)
+            
.options(joinedload(TI.dag_run).selectinload(DagRun.created_dag_version))
             .options(selectinload(TI.dag_version))
             .with_hint(TI, "USE INDEX (ti_state)", dialect_name="mysql")
             .join(DM, TI.dag_id == DM.dag_id)
diff --git a/airflow-core/src/airflow/models/dag_version.py 
b/airflow-core/src/airflow/models/dag_version.py
index ff4971f4ba8..8dc6376a185 100644
--- a/airflow-core/src/airflow/models/dag_version.py
+++ b/airflow-core/src/airflow/models/dag_version.py
@@ -41,6 +41,7 @@ if TYPE_CHECKING:
     from sqlalchemy.sql import Select
 
     from airflow.models.serialized_dag import SerializedDagModel
+    from airflow.models.taskinstance import TaskInstance
 
 
 log = logging.getLogger(__name__)
@@ -416,7 +417,24 @@ def _resolve_version_data(
     """Return a bundle version's ``version_data`` manifest, but only for 
pinned runs."""
     # Expose version_data only when the run is pinned (bundle_version set) and 
a DagVersion is
     # present, so the bundle initializes against the exact version the run 
used. Unpinned runs
-    # follow the latest bundle state, and legacy rows have no DagVersion.
-    if dag_version is not None and bundle_version is not None:
+    # follow the latest bundle state, and legacy rows have no DagVersion. A 
code-only update
+    # overwrites a reused DagVersion's bundle metadata in place, so its 
manifest describes the
+    # pinned bundle only while the two bundle versions still match.
+    if (
+        dag_version is not None
+        and bundle_version is not None
+        and dag_version.bundle_version == bundle_version
+    ):
         return dag_version.version_data
     return None
+
+
+def _resolve_ti_callback_bundle_info(
+    ti: TaskInstance,
+) -> tuple[str, str | None, dict[str, Any] | None]:
+    """Resolve bundle metadata for task callbacks and emails using the run's 
execution bundle."""
+    bundle_name = ti.dag_version.bundle_name if ti.dag_version else 
ti.dag_model.bundle_name
+    # A code-only update can advance the DagVersion's bundle while the run 
stays pinned.
+    bundle_version = ti.dag_run.bundle_version
+    version_data = _resolve_version_data(ti.dag_run.created_dag_version, 
ti.dag_run.bundle_version)
+    return bundle_name, bundle_version, version_data
diff --git a/airflow-core/src/airflow/models/serialized_dag.py 
b/airflow-core/src/airflow/models/serialized_dag.py
index aa2e801d7d5..6cab8defb81 100644
--- a/airflow-core/src/airflow/models/serialized_dag.py
+++ b/airflow-core/src/airflow/models/serialized_dag.py
@@ -705,10 +705,8 @@ class SerializedDagModel(Base):
         new_dag_hash = cls.hash(dag.data)
 
         if serialized_dag_hash == new_dag_hash and dag_version and 
dag_version.bundle_name == bundle_name:
-            # Serialized content is unchanged, so we don't create a new 
DagVersion.
-            # But if the bundle advanced, refresh the latest version's pointer 
in place — tasks resolve
-            # their code from ``ti.dag_version.bundle_version`` at run time, 
so a stale
-            # pointer makes runs execute an outdated commit.
+            # Reuse the structural version with the latest bundle metadata. 
Existing runs
+            # keep their execution bundle pinned separately in 
DagRun.bundle_version.
             bundle_metadata_changed = (
                 dag_version.bundle_version != bundle_version or 
dag_version.version_data != version_data
             )
diff --git a/airflow-core/src/airflow/models/trigger.py 
b/airflow-core/src/airflow/models/trigger.py
index a7193074f6f..656a2a474a1 100644
--- a/airflow-core/src/airflow/models/trigger.py
+++ b/airflow-core/src/airflow/models/trigger.py
@@ -722,24 +722,9 @@ def _(event: BaseTaskEndEvent, *, task_instance: 
TaskInstance, session: Session)
         ):
             if task_instance.dag_model.relative_fileloc is None:
                 raise RuntimeError("relative_fileloc should not be None for a 
finished task")
-            from airflow.models.dag_version import _resolve_version_data
-
-            # Derive bundle identity from the TI's dag_version (falling back 
to dag_run/dag_model
-            # for legacy/unpinned runs), mirroring the other callback sites so 
bundle_version and
-            # version_data always describe the same version.
-            bundle_name = (
-                task_instance.dag_version.bundle_name
-                if task_instance.dag_version
-                else task_instance.dag_model.bundle_name
-            )
-            bundle_version = (
-                task_instance.dag_version.bundle_version
-                if task_instance.dag_version and 
task_instance.dag_run.bundle_version is not None
-                else task_instance.dag_run.bundle_version
-            )
-            version_data = _resolve_version_data(
-                task_instance.dag_version, task_instance.dag_run.bundle_version
-            )
+            from airflow.models.dag_version import 
_resolve_ti_callback_bundle_info
+
+            bundle_name, bundle_version, version_data = 
_resolve_ti_callback_bundle_info(task_instance)
             request = TaskCallbackRequest(
                 filepath=task_instance.dag_model.relative_fileloc,
                 ti=task_instance_to_runtime(
diff --git 
a/airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/ClearTaskInstanceDialog.test.tsx
 
b/airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/ClearTaskInstanceDialog.test.tsx
index 0d5135cedf5..b585bd6a5ec 100644
--- 
a/airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/ClearTaskInstanceDialog.test.tsx
+++ 
b/airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/ClearTaskInstanceDialog.test.tsx
@@ -21,7 +21,7 @@ import { fireEvent, render, screen, waitFor, within } from 
"@testing-library/rea
 import { http, HttpResponse } from "msw";
 import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
 
-import type { TaskInstanceResponse } from "openapi/requests/types.gen";
+import type { DagVersionResponse, TaskInstanceResponse } from 
"openapi/requests/types.gen";
 
 import BulkClearTaskInstancesButton from 
"src/pages/TaskInstances/BulkClearTaskInstancesButton";
 
@@ -93,6 +93,18 @@ const loopMember: TaskInstanceResponse = {
   in_loop: true,
 };
 
+// A code-only change advanced the Dag version's bundle in place; the run 
stays pinned to its bundle.
+const latestVersion: DagVersionResponse = {
+  bundle_name: "git-bundle",
+  bundle_url: null,
+  bundle_version: "latest-sha",
+  created_at: "2025-01-02T00:00:00Z",
+  dag_display_name: "Test DAG",
+  dag_id: DAG_ID,
+  id: "version-21",
+  version_number: 21,
+};
+
 const mutateMock = vi.fn();
 const dryRunsMock = vi.fn();
 const confirmationDryRun = { isFetching: false };
@@ -131,6 +143,29 @@ afterEach(() => {
   localStorage.clear();
 });
 
+const serveBundleOnlyUpdate = (runLoaded: Promise<void> = Promise.resolve()) =>
+  server.use(
+    http.get(`/api/v2/dags/${DAG_ID}/details`, () =>
+      HttpResponse.json({
+        bundle_version: "latest-sha",
+        latest_dag_version: latestVersion,
+        rerun_with_latest_version: null,
+      }),
+    ),
+    http.get(`/api/v2/dags/${DAG_ID}/dagRuns/${DAG_RUN_ID}`, async () => {
+      await runLoaded;
+
+      return HttpResponse.json({ bundle_version: "historical-sha", 
dag_versions: [latestVersion] });
+    }),
+  );
+
+const clickConfirm = async () => {
+  const confirmButton = await screen.findByRole("button", { name: 
/modal\.confirm/iu });
+
+  await waitFor(() => expect(confirmButton).toBeEnabled());
+  fireEvent.click(confirmButton);
+};
+
 describe("ClearTaskInstanceDialog", () => {
   it("seeds the keep-task-state checkbox from the stored default and sends it 
on confirm", async () => {
     localStorage.setItem(CLEAR_KEEP_TASK_STATE_KEY, JSON.stringify(true));
@@ -143,9 +178,7 @@ describe("ClearTaskInstanceDialog", () => {
 
     expect(keepTaskStateCheckbox).toBeChecked();
 
-    const confirmButton = await screen.findByRole("button", { name: 
/modal\.confirm/iu });
-
-    fireEvent.click(confirmButton);
+    await clickConfirm();
 
     await waitFor(() => expect(mutateMock).toHaveBeenCalled());
 
@@ -165,7 +198,7 @@ describe("ClearTaskInstanceDialog", () => {
       },
     );
 
-    fireEvent.click(await screen.findByRole("button", { name: 
/modal\.confirm/iu }));
+    await clickConfirm();
     // Let the clear dialog finish closing while the running-task check is 
still in flight.
     await new Promise((resolve) => {
       setTimeout(resolve, 0);
@@ -186,7 +219,7 @@ describe("ClearTaskInstanceDialog", () => {
       wrapper: Wrapper,
     });
 
-    fireEvent.click(await screen.findByRole("button", { name: 
/modal\.confirm/iu }));
+    await clickConfirm();
 
     expect(await 
screen.findByText(/confirmationDialog\.title/iu)).toBeVisible();
     expect(mutateMock).not.toHaveBeenCalled();
@@ -202,7 +235,7 @@ describe("ClearTaskInstanceDialog", () => {
     const rows = await screen.findAllByRole("row");
 
     fireEvent.click(within(rows[1] as HTMLElement).getByRole("checkbox"));
-    fireEvent.click(await screen.findByRole("button", { name: 
/modal\.confirm/iu }));
+    await clickConfirm();
 
     await waitFor(() => expect(mutateMock).toHaveBeenCalledTimes(1));
     const [{ requestBody }] = mutateMock.mock.calls[0] as [
@@ -226,7 +259,7 @@ describe("ClearTaskInstanceDialog", () => {
     affectedTasks.task_instances = [loopPass("pass-0-retried", 0), 
loopPass("pass-2", 2)];
     rerender(dialog);
 
-    fireEvent.click(await screen.findByRole("button", { name: 
/modal\.confirm/iu }));
+    await clickConfirm();
 
     await waitFor(() => expect(mutateMock).toHaveBeenCalledTimes(1));
     const [{ requestBody }] = mutateMock.mock.calls[0] as [
@@ -280,7 +313,7 @@ describe("ClearTaskInstanceDialog", () => {
     });
 
     fireEvent.click(await screen.findByRole("button", { name: 
/options\.laterIterations/iu }));
-    fireEvent.click(await screen.findByRole("button", { name: 
/modal\.confirm/iu }));
+    await clickConfirm();
 
     await waitFor(() => expect(mutateMock).toHaveBeenCalledTimes(1));
     const [{ requestBody }] = mutateMock.mock.calls[0] as [{ requestBody: 
Record<string, unknown> }];
@@ -304,7 +337,7 @@ describe("ClearTaskInstanceDialog", () => {
     });
 
     fireEvent.click(await screen.findByRole("button", { name: 
/options\.wholeExpansion/iu }));
-    fireEvent.click(await screen.findByRole("button", { name: 
/modal\.confirm/iu }));
+    await clickConfirm();
 
     await waitFor(() => expect(mutateMock).toHaveBeenCalledTimes(1));
     const [{ requestBody }] = mutateMock.mock.calls[0] as [{ requestBody: 
Record<string, unknown> }];
@@ -344,7 +377,7 @@ describe("ClearTaskInstanceDialog", () => {
       wrapper: Wrapper,
     });
 
-    fireEvent.click(await screen.findByRole("button", { name: 
/modal\.confirm/iu }));
+    await clickConfirm();
 
     await waitFor(() => expect(mutateMock).toHaveBeenCalledTimes(1));
     const [{ requestBody }] = mutateMock.mock.calls[0] as [{ requestBody: 
Record<string, unknown> }];
@@ -366,7 +399,7 @@ describe("ClearTaskInstanceDialog", () => {
       wrapper: Wrapper,
     });
 
-    fireEvent.click(await screen.findByRole("button", { name: 
/modal\.confirm/iu }));
+    await clickConfirm();
 
     await waitFor(() => expect(mutateMock).toHaveBeenCalledTimes(2));
     expect(
@@ -393,7 +426,7 @@ describe("ClearTaskInstanceDialog", () => {
     const note = await screen.findByRole("textbox", { hidden: true });
 
     expect(note).toHaveValue("existing note");
-    fireEvent.click(await screen.findByRole("button", { name: 
/modal\.confirm/iu }));
+    await clickConfirm();
     await waitFor(() => expect(mutateMock).toHaveBeenCalledTimes(1));
     expect((mutateMock.mock.calls[0] as [{ requestBody: { note?: string } 
}])[0].requestBody.note).toBe(
       undefined,
@@ -408,7 +441,7 @@ describe("ClearTaskInstanceDialog", () => {
     fireEvent.change(await screen.findByRole("textbox", { hidden: true }), {
       target: { value: "new reason" },
     });
-    fireEvent.click(await screen.findByRole("button", { name: 
/modal\.confirm/iu }));
+    await clickConfirm();
     await waitFor(() => expect(mutateMock).toHaveBeenCalledTimes(1));
     expect((mutateMock.mock.calls[0] as [{ requestBody: { note?: string } 
}])[0].requestBody.note).toBe(
       "new reason",
@@ -494,6 +527,82 @@ describe("ClearTaskInstanceDialog", () => {
     },
   );
 
+  it.each([false, true])(
+    "offers the run-on-latest option when only the run's bundle is outdated 
and sends the choice: %s",
+    async (runOnLatest) => {
+      serveBundleOnlyUpdate();
+      render(
+        <ClearTaskInstanceDialog
+          onClose={vi.fn()}
+          open
+          taskInstance={{ ...taskInstance, dag_version: latestVersion }}
+        />,
+        { wrapper: Wrapper },
+      );
+
+      const checkbox = await screen.findByRole("checkbox", { name: 
/runOnLatestVersion/iu });
+
+      expect(checkbox).not.toBeChecked();
+      if (runOnLatest) {
+        fireEvent.click(checkbox);
+        await waitFor(() => expect(checkbox).toBeChecked());
+      }
+      await clickConfirm();
+
+      await waitFor(() => expect(mutateMock).toHaveBeenCalledTimes(1));
+      expect((mutateMock.mock.calls[0] as [{ requestBody: unknown 
}])[0].requestBody).toMatchObject({
+        dag_run_id: DAG_RUN_ID,
+        dry_run: false,
+        run_on_latest_version: runOnLatest,
+      });
+    },
+  );
+
+  it("offers the run-on-latest option when clearing all mapped instances on an 
outdated bundle", async () => {
+    serveBundleOnlyUpdate();
+    render(
+      <ClearTaskInstanceDialog
+        allMapped
+        dagId={DAG_ID}
+        dagRunId={DAG_RUN_ID}
+        onClose={vi.fn()}
+        open
+        taskId={TASK_ID}
+      />,
+      { wrapper: Wrapper },
+    );
+
+    expect(await screen.findByRole("checkbox", { name: /runOnLatestVersion/iu 
})).toBeVisible();
+  });
+
+  it("keeps confirm disabled until the run's bundle has loaded", async () => {
+    let loadRun: (() => void) | undefined;
+
+    serveBundleOnlyUpdate(
+      new Promise<void>((resolve) => {
+        loadRun = resolve;
+      }),
+    );
+    render(
+      <ClearTaskInstanceDialog
+        onClose={vi.fn()}
+        open
+        taskInstance={{ ...taskInstance, dag_version: latestVersion }}
+      />,
+      { wrapper: Wrapper },
+    );
+
+    const confirmButton = await screen.findByRole("button", { name: 
/modal\.confirm/iu });
+
+    expect(confirmButton).toBeDisabled();
+    expect(screen.queryByRole("checkbox", { name: /runOnLatestVersion/iu 
})).not.toBeInTheDocument();
+
+    loadRun?.();
+
+    expect(await screen.findByRole("checkbox", { name: /runOnLatestVersion/iu 
})).toBeVisible();
+    await waitFor(() => expect(confirmButton).toBeEnabled());
+  });
+
   it.each([
     {
       entry: "the task instance page",
diff --git 
a/airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/ClearTaskInstanceDialog.tsx
 
b/airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/ClearTaskInstanceDialog.tsx
index ddc6d22acd3..b97a9a6247e 100644
--- 
a/airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/ClearTaskInstanceDialog.tsx
+++ 
b/airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/ClearTaskInstanceDialog.tsx
@@ -201,7 +201,6 @@ const ClearTaskInstanceDialog = (props: Props) => {
     closeDialog();
   };
 
-  // Get current DAG's bundle version to compare with task instance's DAG 
version bundle version
   const { data: dagDetails } = useDagServiceGetDagDetails({ dagId }, 
undefined, { enabled: singleRun });
 
   const { data: dagRun } = useDagRunServiceGetDagRun({ dagId, dagRunId }, 
undefined, {
@@ -227,7 +226,7 @@ const ClearTaskInstanceDialog = (props: Props) => {
     getRunOnLatestVersionState({
       latestBundleVersion: dagDetails?.bundle_version,
       latestDagVersionNumber: dagDetails?.latest_dag_version?.version_number,
-      selectedBundleVersion: selectedVersion?.bundle_version,
+      selectedBundleVersion: dagRun?.bundle_version,
       selectedDagVersionNumber: selectedVersion?.version_number,
       selectedVersionMissing: dagRun?.dag_versions.length === 0,
     });
@@ -375,7 +374,11 @@ const ClearTaskInstanceDialog = (props: Props) => {
       <Modal
         footerActions={
           <Button
-            disabled={data.total_entries === 0 || keptTaskInstances.length === 
0}
+            disabled={
+              (singleRun && (!dagRun || !dagDetails)) ||
+              data.total_entries === 0 ||
+              keptTaskInstances.length === 0
+            }
             loading={isPending}
             onClick={gateRequest === undefined ? confirmClear : onOpen}
           >
diff --git a/airflow-core/tests/unit/executors/test_workloads.py 
b/airflow-core/tests/unit/executors/test_workloads.py
index dafcbbdede6..68bdb914744 100644
--- a/airflow-core/tests/unit/executors/test_workloads.py
+++ b/airflow-core/tests/unit/executors/test_workloads.py
@@ -458,6 +458,7 @@ class TestExecuteTaskMakeVersionData:
         ti.dag_version.version_data = ti_dag_version_data
 
         if has_created_dag_version:
+            ti.dag_run.created_dag_version.bundle_version = bundle_version
             ti.dag_run.created_dag_version.version_data = version_data
         else:
             ti.dag_run.created_dag_version = None
@@ -533,6 +534,7 @@ class TestExecuteCallbackMakeVersionData:
         dag_run.dag_model.bundle_name = "test-bundle"
         dag_run.dag_model.relative_fileloc = "dags/test_dag.py"
         if has_created_dag_version:
+            dag_run.created_dag_version.bundle_version = bundle_version
             dag_run.created_dag_version.version_data = version_data
         else:
             dag_run.created_dag_version = None
diff --git a/airflow-core/tests/unit/jobs/test_scheduler_job.py 
b/airflow-core/tests/unit/jobs/test_scheduler_job.py
index 9363b694775..a90b8bd00fa 100644
--- a/airflow-core/tests/unit/jobs/test_scheduler_job.py
+++ b/airflow-core/tests/unit/jobs/test_scheduler_job.py
@@ -10496,10 +10496,12 @@ class TestSchedulerJob:
         [
             pytest.param(None, "abc123-sha", None, 
id="disable_bundle_versioning"),
             pytest.param("abc123-sha", "abc123-sha", "abc123-sha", 
id="versioning_enabled"),
+            pytest.param("old-sha", "abc123-sha", "old-sha", 
id="bundle_only_update"),
         ],
     )
+    @pytest.mark.parametrize("request_type", [TaskCallbackRequest, 
EmailRequest])
     def test_external_kill_callback_bundle_version_follows_dag_run(
-        self, dag_maker, session, dag_run_bv, dag_version_bv, expected_bv
+        self, dag_maker, session, dag_run_bv, dag_version_bv, expected_bv, 
request_type
     ):
         """
         TaskCallbackRequest.bundle_version must mirror dag_run.bundle_version, 
not
@@ -10509,7 +10511,12 @@ class TestSchedulerJob:
         on-disk code as the task did.
         """
         with dag_maker(dag_id=f"ext_kill_bv_{dag_run_bv or 'none'}", 
fileloc="/test_path1/"):
-            EmptyOperator(task_id="t1", on_failure_callback=lambda ctx: None)
+            EmptyOperator(
+                task_id="t1",
+                on_failure_callback=(lambda ctx: None) if request_type is 
TaskCallbackRequest else None,
+                email="[email protected]" if request_type is EmailRequest else 
None,
+                email_on_failure=True,
+            )
         dr = dag_maker.create_dagrun(state=DagRunState.RUNNING)
 
         ti = dr.get_task_instance(task_id="t1", session=session)
@@ -10531,27 +10538,80 @@ class TestSchedulerJob:
 
         self.job_runner.executor.callback_sink.send.assert_called_once()
         request = self.job_runner.executor.callback_sink.send.call_args[0][0]
-        assert isinstance(request, TaskCallbackRequest)
+        assert isinstance(request, request_type)
         assert request.bundle_version == expected_bv
 
-    def test_heartbeat_timeout_callback_bundle_version_follows_dag_run(self, 
dag_maker, session):
+    @pytest.mark.parametrize("request_type", [TaskCallbackRequest, 
EmailRequest])
+    def test_external_kill_callback_version_data_follows_dag_run(self, 
dag_maker, session, request_type):
+        """
+        The callback must ship the manifest of the run's pinned Dag version, 
matching
+        bundle_version, even when the TI was moved to a newer Dag version 
mid-run.
+        """
+        with dag_maker(dag_id="ext_kill_version_data", fileloc="/test_path1/"):
+            EmptyOperator(
+                task_id="t1",
+                on_failure_callback=(lambda ctx: None) if request_type is 
TaskCallbackRequest else None,
+                email="[email protected]" if request_type is EmailRequest else 
None,
+                email_on_failure=True,
+            )
+        dr = dag_maker.create_dagrun(state=DagRunState.RUNNING)
+
+        run_manifest = {"files": {"dags/my_dag.py": "v1-object-id"}}
+        run_dag_version = dr.created_dag_version
+        run_dag_version.bundle_version = "v1-sha"
+        run_dag_version.version_data = run_manifest
+        newer_dag_version = DagVersion(
+            dag_id=dr.dag_id,
+            version_number=run_dag_version.version_number + 1,
+            bundle_name=run_dag_version.bundle_name,
+            bundle_version="v2-sha",
+            version_data={"files": {"dags/my_dag.py": "v2-object-id"}},
+        )
+        session.add(newer_dag_version)
+        session.flush()
+        dr.bundle_version = "v1-sha"
+        ti = dr.get_task_instance(task_id="t1", session=session)
+        ti.dag_version_id = newer_dag_version.id
+        ti.state = State.QUEUED
+        session.merge(run_dag_version)
+        session.merge(dr)
+        session.merge(ti)
+        session.commit()
+
+        executor = MockExecutor(do_update=False)
+        scheduler_job = Job()
+        self.job_runner = SchedulerJobRunner(scheduler_job, 
executors=[executor])
+        executor.event_buffer[TaskInstanceUuid(ti.id)] = State.FAILED, None
+
+        self.job_runner._process_executor_events(executor=executor, 
session=session)
+
+        self.job_runner.executor.callback_sink.send.assert_called_once()
+        request = self.job_runner.executor.callback_sink.send.call_args[0][0]
+        assert isinstance(request, request_type)
+        assert request.bundle_version == "v1-sha"
+        assert request.version_data == run_manifest
+
+    @pytest.mark.parametrize("bundle_version", [None, "old-sha"])
+    @time_machine.travel(DEFAULT_DATE, tick=False)
+    def test_heartbeat_timeout_callback_bundle_version_follows_dag_run(
+        self, dag_maker, session, bundle_version
+    ):
         """
         Same invariant as the external-kill path, exercised through
         _find_and_purge_task_instances_without_heartbeats.
         """
         with dag_maker(dag_id="hb_timeout_bv", fileloc="/test_path1/"):
-            EmptyOperator(task_id="t1")
+            EmptyOperator(task_id="t1", email="[email protected]", 
email_on_failure=True)
         dr = dag_maker.create_dagrun(state=DagRunState.RUNNING)
 
-        executor = MagicMock()
+        executor = MockExecutor(do_update=False)
         scheduler_job = Job()
         self.job_runner = SchedulerJobRunner(scheduler_job, 
executors=[executor])
 
         ti = dr.get_task_instance(task_id="t1", session=session)
         dag_version = ti.dag_version
-        # disable_bundle_versioning state: DagVersion has a SHA, dag_run is 
unpinned.
         dag_version.bundle_version = "abc123-sha"
-        dr.bundle_version = None
+        dr.bundle_version = bundle_version
         ti.state = TaskInstanceState.RUNNING
         ti.queued_by_job_id = scheduler_job.id
         ti.last_heartbeat_at = timezone.utcnow() - timedelta(seconds=600)
@@ -10562,10 +10622,10 @@ class TestSchedulerJob:
 
         self.job_runner._find_and_purge_task_instances_without_heartbeats()
 
-        executor.send_callback.assert_called_once()
-        request = executor.send_callback.call_args[0][0]
-        assert isinstance(request, TaskCallbackRequest)
-        assert request.bundle_version is None
+        requests = [call.args[0] for call in 
executor.callback_sink.send.call_args_list]
+        assert len(requests) == 2
+        assert {type(request) for request in requests} == 
{TaskCallbackRequest, EmailRequest}
+        assert all(request.bundle_version == bundle_version for request in 
requests)
 
     @time_machine.travel(DEFAULT_DATE, tick=False)
     def test_heartbeat_timeout_preserves_failure_email(self, dag_maker, 
session):
@@ -10886,8 +10946,10 @@ class TestSchedulerJob:
         assert ti_lock_calls[0].kwargs["session"] is session
 
     @conf_vars({("scheduler", "num_stuck_in_queued_retries"): "1"})
+    @pytest.mark.parametrize("bundle_version", [None, "old-sha"])
+    @time_machine.travel(DEFAULT_DATE, tick=False)
     def test_stuck_in_queued_callback_bundle_version_follows_dag_run(
-        self, dag_maker, session, mock_executors
+        self, dag_maker, session, mock_executors, bundle_version
     ):
         """
         Same invariant as the external-kill path, exercised through
@@ -10902,7 +10964,7 @@ class TestSchedulerJob:
         ti = dr.get_task_instance(task_id="op1", session=session)
         dag_version = ti.dag_version
         dag_version.bundle_version = "abc123-sha"
-        dr.bundle_version = None
+        dr.bundle_version = bundle_version
         ti.state = State.QUEUED
         ti.queued_dttm = timezone.utcnow()
         session.merge(dag_version)
@@ -10928,7 +10990,7 @@ class TestSchedulerJob:
         mock_executors[0].send_callback.assert_called_once()
         request = mock_executors[0].send_callback.call_args[0][0]
         assert isinstance(request, TaskCallbackRequest)
-        assert request.bundle_version is None
+        assert request.bundle_version == bundle_version
 
     def test_stuck_in_queued_failure_callback_for_removed_mapped_task(
         self, dag_maker, session, mock_executors
diff --git a/airflow-core/tests/unit/models/test_dag_version.py 
b/airflow-core/tests/unit/models/test_dag_version.py
index 69c1703da2d..e5042be9dc8 100644
--- a/airflow-core/tests/unit/models/test_dag_version.py
+++ b/airflow-core/tests/unit/models/test_dag_version.py
@@ -274,11 +274,17 @@ class TestResolveVersionData:
         ("dag_version", "bundle_version", "expected"),
         [
             pytest.param(
-                mock.Mock(version_data={"schema_version": 1}),
+                mock.Mock(bundle_version="abc123", 
version_data={"schema_version": 1}),
                 "abc123",
                 {"schema_version": 1},
                 id="pinned-with-data",
             ),
+            pytest.param(
+                mock.Mock(bundle_version="def456", 
version_data={"schema_version": 1}),
+                "abc123",
+                None,
+                id="pinned-to-overwritten-bundle-version",
+            ),
             pytest.param(
                 mock.Mock(version_data={"schema_version": 1}),
                 None,
diff --git a/airflow-core/tests/unit/models/test_trigger.py 
b/airflow-core/tests/unit/models/test_trigger.py
index 54a89a8ce69..d03ccd58330 100644
--- a/airflow-core/tests/unit/models/test_trigger.py
+++ b/airflow-core/tests/unit/models/test_trigger.py
@@ -36,6 +36,7 @@ from airflow.jobs.triggerer_job_runner import 
TriggererJobRunner
 from airflow.models import TaskInstance, Trigger
 from airflow.models.asset import AssetEvent, AssetModel, AssetWatcherModel
 from airflow.models.callback import Callback, TriggererCallback
+from airflow.models.dag_version import DagVersion
 from airflow.models.trigger import handle_event_submit
 from airflow.models.xcom import XComModel
 from airflow.providers.standard.operators.empty import EmptyOperator
@@ -690,8 +691,7 @@ def test_submit_event_task_end(mock_utcnow, session, 
create_task_instance, event
 
 @patch("airflow.callbacks.database_callback_sink.DatabaseCallbackSink.send")
 def test_submit_event_task_end_callback_includes_version_data(mock_send, 
session, create_task_instance):
-    """A finished deferred task's callback carries version_data for a pinned 
run, and its
-    bundle_version is derived from the same dag_version so the two cannot 
diverge."""
+    """A finished deferred task's callback carries version_data for a pinned 
run."""
     version_data = {"schema_version": 1, "files": {"dags/my_dag.py": "ver123"}}
 
     trigger = Trigger(classpath="does.not.matter", kwargs={})
@@ -715,6 +715,65 @@ def 
test_submit_event_task_end_callback_includes_version_data(mock_send, session
     assert request.version_data == version_data
 
 
+@patch("airflow.callbacks.database_callback_sink.DatabaseCallbackSink.send")
+def test_submit_event_task_end_callback_uses_run_bundle_after_code_only_update(
+    mock_send, session, create_task_instance
+):
+    """A code-only update advances the reused Dag version's bundle while the 
run stays pinned."""
+    trigger = Trigger(classpath="does.not.matter", kwargs={})
+    session.add(trigger)
+    task_instance = create_task_instance(
+        session=session, logical_date=timezone.utcnow(), state=State.DEFERRED
+    )
+    task_instance.trigger_id = trigger.id
+    task_instance.dag_run.bundle_version = "v1-sha"
+    task_instance.dag_version.bundle_version = "v2-sha"
+    session.commit()
+
+    Trigger.submit_event(trigger.id, TaskSuccessEvent(), session=session)
+    session.flush()
+
+    mock_send.assert_called_once()
+    assert mock_send.call_args.kwargs["callback"].bundle_version == "v1-sha"
+
+
+@patch("airflow.callbacks.database_callback_sink.DatabaseCallbackSink.send")
+def 
test_submit_event_task_end_callback_uses_run_manifest_when_ti_on_newer_version(
+    mock_send, session, create_task_instance
+):
+    """The callback ships the run's pinned bundle and manifest after the TI 
moved to a newer Dag version."""
+    trigger = Trigger(classpath="does.not.matter", kwargs={})
+    session.add(trigger)
+    task_instance = create_task_instance(
+        session=session, logical_date=timezone.utcnow(), state=State.DEFERRED
+    )
+    task_instance.trigger_id = trigger.id
+    run_manifest = {"files": {"dags/my_dag.py": "v1-object-id"}}
+    run_dag_version = task_instance.dag_run.created_dag_version
+    run_dag_version.bundle_version = "v1-sha"
+    run_dag_version.version_data = run_manifest
+    newer_dag_version = DagVersion(
+        dag_id=task_instance.dag_id,
+        version_number=run_dag_version.version_number + 1,
+        bundle_name=run_dag_version.bundle_name,
+        bundle_version="v2-sha",
+        version_data={"files": {"dags/my_dag.py": "v2-object-id"}},
+    )
+    session.add(newer_dag_version)
+    session.flush()
+    task_instance.dag_run.bundle_version = "v1-sha"
+    task_instance.dag_version_id = newer_dag_version.id
+    session.commit()
+
+    Trigger.submit_event(trigger.id, TaskSuccessEvent(), session=session)
+    session.flush()
+
+    mock_send.assert_called_once()
+    request = mock_send.call_args.kwargs["callback"]
+    assert request.bundle_version == "v1-sha"
+    assert request.version_data == run_manifest
+
+
 @pytest.mark.parametrize(
     ("retries", "expected_state", "expected_callback_type", 
"expect_history_row"),
     [

Reply via email to