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

guan404ming 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 2a1ba5e960f Support timeout defaults in LLM approval reviews (#72155)
2a1ba5e960f is described below

commit 2a1ba5e960f555483a31e8779e0dae4be3e9805c
Author: Guan-Ming Chiu <[email protected]>
AuthorDate: Wed Sep 16 22:47:08 2026 +0800

    Support timeout defaults in LLM approval reviews (#72155)
    
    * Support timeout defaults in LLM approval reviews
    
    The approval review always passed no HITL defaults, so approval_timeout
    could only ever fail the task. An unattended pipeline had no way to say
    "nobody reviewed this in time, treat it as approved" (or rejected), which
    is what a deadline on a review is usually for.
    
    * Address review on LLM approval timeout defaults
    
    Dropping the defer timeout keeps the trigger deadline as the single
    authority on pre-3.3 cores, so the scheduler sweep cannot fail the task
    before the timeout default is applied. A timed-out review has no
    reviewer, so stale params from an earlier attempt must not be returned.
    The guard now also rejects on_approval_timeout without require_approval.
    
    * Clarify timeout defaults and guard replayed HITL fallback
    
    "reject" only fails plain LLM operators, so the docs and example now
    show "approve" as the value that keeps a pipeline moving. A replayed
    HITLTrigger after its own timeout fallback has no responder, which
    crashed the trigger on cores below 3.3. Timeout-driven outcomes are
    now named in the exception message and in the approval log.
    
    * Name the actor behind unattended HITL approvals
    
    A replayed HITL trigger that resumed with a stored timeout default logged
    that the fallback was skipped, so an on-call reader could not tell an
    unattended approval from a human one. The common.ai reject and approve
    messages also printed the raw HITLUser dict instead of the reviewer name,
    and the standard provider floor let a common.ai upgrade pair the new
    on_approval_timeout defaults with a trigger that still dereferenced a
    missing responder.
    
    * Reject non-positive approval timeouts for timeout defaults
    
    * Fail zero approval timeouts on pre-3.3 cores instead of polling forever
    
    Dropping timeout= from defer() left the trigger's timeout_datetime as the 
only deadline on cores older than 3.3. The truthiness check skipped 
timedelta(0), so the default on_approval_timeout configuration polled forever 
where it used to fail at the deadline. The guard message also claimed the 
setting had no effect, but the review form still pre-highlights the option; 
only the deadline needs a positive approval_timeout.
---
 providers/common/ai/docs/operators/llm.rst         |  14 ++-
 providers/common/ai/docs/operators/llm_branch.rst  |  11 +-
 .../common/ai/docs/operators/llm_file_analysis.rst |   4 +-
 .../ai/docs/operators/llm_schema_compare.rst       |  10 +-
 providers/common/ai/docs/operators/llm_sql.rst     |   4 +-
 providers/common/ai/pyproject.toml                 |   2 +-
 .../common/ai/example_dags/example_llm.py          |   1 +
 .../airflow/providers/common/ai/mixins/approval.py |  50 +++++++--
 .../airflow/providers/common/ai/operators/llm.py   |  25 ++++-
 .../providers/common/ai/operators/llm_branch.py    |   5 +-
 .../common/ai/operators/llm_file_analysis.py       |   3 +-
 .../common/ai/operators/llm_schema_compare.py      |   3 +-
 .../providers/common/ai/operators/llm_sql.py       |   3 +-
 .../tests/unit/common/ai/mixins/test_approval.py   | 124 +++++++++++++++++----
 .../ai/tests/unit/common/ai/operators/test_llm.py  |  47 +++++++-
 .../unit/common/ai/operators/test_llm_branch.py    |  37 ++++--
 .../common/ai/operators/test_llm_file_analysis.py  |  13 ++-
 .../common/ai/operators/test_llm_schema_compare.py |   4 +-
 .../tests/unit/common/ai/operators/test_llm_sql.py |  15 ++-
 .../airflow/providers/standard/triggers/hitl.py    |  71 +++++++-----
 .../tests/unit/standard/triggers/test_hitl.py      |  43 +++++++
 21 files changed, 387 insertions(+), 102 deletions(-)

diff --git a/providers/common/ai/docs/operators/llm.rst 
b/providers/common/ai/docs/operators/llm.rst
index b9537e2f301..50f8ae56216 100644
--- a/providers/common/ai/docs/operators/llm.rst
+++ b/providers/common/ai/docs/operators/llm.rst
@@ -238,7 +238,16 @@ Set ``require_approval=True`` to pause the task after the 
LLM generates its
 output and wait for a human reviewer to approve or reject it via the Airflow
 HITL interface.  Optionally allow the reviewer to edit the output before
 approving with ``allow_modifications=True``, and set a deadline with
-``approval_timeout``:
+``approval_timeout``.
+
+When ``approval_timeout`` expires without a review, the task fails by default.
+Set ``on_approval_timeout="approve"`` to return the generated output instead, 
so
+an unattended pipeline keeps moving.  ``"reject"`` answers the review with a
+rejection, which still fails this operator; only
+:class:`~airflow.providers.common.ai.operators.llm_branch.LLMBranchOperator`
+turns a rejection into a downstream skip.  The chosen option is also
+pre-highlighted as the default in the review form, so ``"reject"`` makes
+Reject the primary button:
 
 .. exampleinclude:: 
/../../ai/src/airflow/providers/common/ai/example_dags/example_llm.py
     :language: python
@@ -265,6 +274,9 @@ Parameters
   for human review.  Default ``False``.
 - ``approval_timeout``: Maximum time to wait for a review (``timedelta``).  
``None``
   means wait indefinitely.  Default ``None``.
+- ``on_approval_timeout``: Outcome when ``approval_timeout`` expires without a
+  review: ``"fail"`` (default), ``"approve"``, or ``"reject"``.  Requires
+  ``require_approval=True`` and a positive ``approval_timeout``.
 - ``allow_modifications``: If ``True``, the reviewer can edit the output before
   approving.  Default ``False``.
 
diff --git a/providers/common/ai/docs/operators/llm_branch.rst 
b/providers/common/ai/docs/operators/llm_branch.rst
index d1f11a7309c..eba21bd3e55 100644
--- a/providers/common/ai/docs/operators/llm_branch.rst
+++ b/providers/common/ai/docs/operators/llm_branch.rst
@@ -102,15 +102,17 @@ task on rejection instead (generally discouraged), or
 ``ignore_downstream_trigger_rules=True`` to skip every downstream task rather
 than only the direct ones, so a task whose trigger rule would still run it is
 skipped too. Letting ``approval_timeout`` expire fails the task
-(``HITLTimeoutError``).
+(``HITLTimeoutError``) unless ``on_approval_timeout`` answers the review for
+you; a timeout-driven rejection then skips downstream like any other rejection.
 
 ``require_approval=True`` requires a string prompt: a decorated callable
 returning a ``Sequence[UserContent]`` raises ``TypeError`` before the LLM
 call.
 
 Apart from ``fail_on_reject`` and ``ignore_downstream_trigger_rules``, which
-are specific to this operator, ``approval_timeout`` and the rest of the
-approval behaviour are inherited from :ref:`LLMOperator <howto/operator:llm>`.
+are specific to this operator, ``approval_timeout``, ``on_approval_timeout``,
+and the rest of the approval behaviour are inherited from
+:ref:`LLMOperator <howto/operator:llm>`.
 
 How It Works
 ------------
@@ -140,6 +142,9 @@ Parameters
   branch(es) and waits for human review before branching.  Default ``False``.
 - ``approval_timeout``: Maximum time to wait for a review (``timedelta``).  
``None``
   means wait indefinitely.  Default ``None``.
+- ``on_approval_timeout``: Outcome when ``approval_timeout`` expires without a
+  review: ``"fail"`` (default), ``"approve"``, or ``"reject"``.  Requires
+  ``require_approval=True`` and a positive ``approval_timeout``.
 - ``allow_modifications``: If ``True``, the reviewer can change the chosen
   branch(es) before approving.  Default ``False``.
 - ``fail_on_reject``: If ``True``, a rejected review fails the task instead of
diff --git a/providers/common/ai/docs/operators/llm_file_analysis.rst 
b/providers/common/ai/docs/operators/llm_file_analysis.rst
index ac688ebfaf4..dc65e31bde0 100644
--- a/providers/common/ai/docs/operators/llm_file_analysis.rst
+++ b/providers/common/ai/docs/operators/llm_file_analysis.rst
@@ -159,8 +159,8 @@ Parameters
   downstream consumer needs the dict shape.
 
 This operator also inherits ``LLMOperator``'s HITL review parameters --
-``require_approval``, ``approval_timeout``, and ``allow_modifications`` -- see
-:doc:`llm` for details.
+``require_approval``, ``approval_timeout``, ``on_approval_timeout``, and
+``allow_modifications`` -- see :doc:`llm` for details.
 
 Supported Formats
 -----------------
diff --git a/providers/common/ai/docs/operators/llm_schema_compare.rst 
b/providers/common/ai/docs/operators/llm_schema_compare.rst
index 768d96280be..a8c3c29deec 100644
--- a/providers/common/ai/docs/operators/llm_schema_compare.rst
+++ b/providers/common/ai/docs/operators/llm_schema_compare.rst
@@ -121,7 +121,7 @@ Set ``require_approval=True`` to pause the task after the 
comparison and wait
 for a human reviewer to approve the result before it is returned. The review
 body shows the compatibility verdict, a mismatch severity summary, and the
 full result JSON. Rejecting the review, or letting ``approval_timeout``
-expire, fails the task:
+expire with the default ``on_approval_timeout="fail"``, fails the task:
 
 .. exampleinclude:: 
/../../ai/src/airflow/providers/common/ai/example_dags/example_llm_schema_compare.py
     :language: python
@@ -132,8 +132,9 @@ expire, fails the task:
 returning a ``Sequence[UserContent]`` raises ``TypeError`` before the LLM
 call.
 
-``approval_timeout``, ``allow_modifications``, and the rest of the approval
-behaviour are inherited from :ref:`LLMOperator <howto/operator:llm>`.
+``approval_timeout``, ``on_approval_timeout``, ``allow_modifications``, and
+the rest of the approval behaviour are inherited from
+:ref:`LLMOperator <howto/operator:llm>`.
 
 Conditional ETL Based on Schema Compatibility
 ----------------------------------------------
@@ -191,6 +192,9 @@ Parameters
   waits for human review before returning the result.  Default ``False``.
 - ``approval_timeout``: Maximum time to wait for a review (``timedelta``).  
``None``
   means wait indefinitely.  Default ``None``.
+- ``on_approval_timeout``: Outcome when ``approval_timeout`` expires without a
+  review: ``"fail"`` (default), ``"approve"``, or ``"reject"``.  Requires
+  ``require_approval=True`` and a positive ``approval_timeout``.
 - ``allow_modifications``: If ``True``, the reviewer can edit the result JSON
   before approving.  Default ``False``.
 
diff --git a/providers/common/ai/docs/operators/llm_sql.rst 
b/providers/common/ai/docs/operators/llm_sql.rst
index 233f7262a95..bb328f1e67f 100644
--- a/providers/common/ai/docs/operators/llm_sql.rst
+++ b/providers/common/ai/docs/operators/llm_sql.rst
@@ -141,7 +141,9 @@ Human-in-the-Loop Approval
 Set ``require_approval=True`` to pause the task after SQL generation and wait
 for a human reviewer to approve the query before it is returned.
 When ``allow_modifications=True``, the reviewer can also edit the SQL — the
-modified query is re-validated against the same safety rules automatically:
+modified query is re-validated against the same safety rules automatically.
+``approval_timeout`` and ``on_approval_timeout`` behave as on
+:ref:`LLMOperator <howto/operator:llm>`:
 
 .. exampleinclude:: 
/../../ai/src/airflow/providers/common/ai/example_dags/example_llm_sql.py
     :language: python
diff --git a/providers/common/ai/pyproject.toml 
b/providers/common/ai/pyproject.toml
index 78dbde5c959..a27011e7f69 100644
--- a/providers/common/ai/pyproject.toml
+++ b/providers/common/ai/pyproject.toml
@@ -69,7 +69,7 @@ requires-python = ">=3.10"
 dependencies = [
     "apache-airflow>=3.0.0",
     "apache-airflow-providers-common-compat>=1.15.0",
-    "apache-airflow-providers-standard>=1.12.1",
+    "apache-airflow-providers-standard>=1.12.1",  # use next version
     # Requires the pydantic-ai cost API (RunUsage.cost, 
UsageLimits.cost_limit),
     # landed in 2.23.0 via https://github.com/pydantic/pydantic-ai/pull/2684.
     "pydantic-ai-slim>=2.23.0",
diff --git 
a/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_llm.py
 
b/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_llm.py
index 833d4b09645..ee1a678ce89 100644
--- 
a/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_llm.py
+++ 
b/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_llm.py
@@ -190,6 +190,7 @@ def example_llm_operator_approval():
         system_prompt="You are a financial analyst. Be concise and accurate.",
         require_approval=True,
         approval_timeout=timedelta(hours=24),
+        on_approval_timeout="approve",
         allow_modifications=True,
     )
 
diff --git 
a/providers/common/ai/src/airflow/providers/common/ai/mixins/approval.py 
b/providers/common/ai/src/airflow/providers/common/ai/mixins/approval.py
index cccff32bfd1..3153eb3a999 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/mixins/approval.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/mixins/approval.py
@@ -20,7 +20,7 @@ from __future__ import annotations
 import json
 import logging
 from datetime import timedelta
-from typing import TYPE_CHECKING, Any, Protocol
+from typing import TYPE_CHECKING, Any, ClassVar, Literal, Protocol
 
 from pydantic import BaseModel, TypeAdapter
 
@@ -42,6 +42,7 @@ class DeferForApprovalProtocol(Protocol):
 
     approval_timeout: timedelta | None
     allow_modifications: bool
+    on_approval_timeout: Literal["fail", "approve", "reject"]
     prompt: str
     task_id: str
     defer: Any
@@ -62,16 +63,24 @@ class LLMApprovalMixin:
     before approving.  The (possibly modified) output is then returned as the
     task result.
 
+    ``on_approval_timeout`` decides what happens when ``approval_timeout``
+    expires without a response: ``"fail"`` raises ``HITLTimeoutError``, while
+    ``"approve"`` and ``"reject"`` answer the review with that option so the
+    task resumes as if a reviewer had chosen it.  The chosen option is also
+    pre-highlighted for the reviewer in the HITL form.
+
     Operators that use this mixin must set the following attributes:
 
     - ``require_approval`` (``bool``)
     - ``allow_modifications`` (``bool``)
     - ``approval_timeout`` (``timedelta | None``)
+    - ``on_approval_timeout`` (``Literal["fail", "approve", "reject"]``)
     - ``prompt`` (``str``)
     """
 
     APPROVE = "Approve"
     REJECT = "Reject"
+    TIMEOUT_DEFAULTS: ClassVar[dict[str, list[str]]] = {"approve": [APPROVE], 
"reject": [REJECT]}
 
     def validate_approval_prompt(self: DeferForApprovalProtocol) -> None:
         """Fail fast when the prompt cannot be rendered as text in the 
approval review body."""
@@ -99,7 +108,8 @@ class LLMApprovalMixin:
 
         On Airflow 3.3+ the task parks in the ``awaiting_input`` state (no 
trigger or triggerer
         involved); on older versions it defers to :class:`HITLTrigger`. Either 
way it resumes in
-        ``execute_complete`` once a response (or timeout default) arrives.
+        ``execute_complete`` once a response (or timeout default) arrives. 
``on_approval_timeout``
+        supplies that timeout default; ``"fail"`` supplies none, so the review 
times out as an error.
 
         :param context: Airflow task context.
         :param output: The generated output to present for review.
@@ -130,6 +140,7 @@ class LLMApprovalMixin:
             output = TypeAdapter(type(output)).dump_json(output).decode()
 
         ti_id = context["task_instance"].id
+        timeout_defaults = 
LLMApprovalMixin.TIMEOUT_DEFAULTS.get(self.on_approval_timeout)
 
         if subject is None:
             subject = f"Review output for task `{self.task_id}`"
@@ -160,7 +171,7 @@ class LLMApprovalMixin:
             options=[LLMApprovalMixin.APPROVE, LLMApprovalMixin.REJECT],
             subject=subject,
             body=body,
-            defaults=None,
+            defaults=timeout_defaults,
             multiple=False,
             params=hitl_params,
         )
@@ -179,16 +190,24 @@ class LLMApprovalMixin:
             trigger=HITLTrigger(
                 ti_id=ti_id,
                 options=[LLMApprovalMixin.APPROVE, LLMApprovalMixin.REJECT],
-                defaults=None,
+                defaults=timeout_defaults,
                 params=hitl_params,
                 multiple=False,
-                timeout_datetime=utcnow() + self.approval_timeout if 
self.approval_timeout else None,
+                timeout_datetime=(
+                    utcnow() + self.approval_timeout if self.approval_timeout 
is not None else None
+                ),
             ),
             method_name="execute_complete",
             kwargs={"generated_output": output},
-            timeout=self.approval_timeout,
         )
 
+    @staticmethod
+    def _describe_responder(event: dict[str, Any]) -> str:
+        responded_by_user = event.get("responded_by_user")
+        if responded_by_user is None:
+            return "the approval timeout default"
+        return responded_by_user["name"]
+
     def execute_complete(self, context: Context, generated_output: str, event: 
dict[str, Any]) -> str:
         """
         Resume after human review.
@@ -199,8 +218,9 @@ class LLMApprovalMixin:
         :param context: Airflow task context.
         :param generated_output: The output that was deferred for review.
         :param event: Trigger event payload containing ``chosen_options``,
-            ``params_input``, and ``responded_by_user``.
-        :raises HITLRejectException: If the reviewer rejected the output.
+            ``params_input``, ``responded_by_user``, and ``timedout``.
+        :raises HITLRejectException: If the reviewer, or the
+            ``on_approval_timeout="reject"`` default, rejected the output.
         :raises HITLTriggerEventError: If the trigger reported an error.
         :raises HITLTimeoutError: If the approval timed out.
         """
@@ -216,13 +236,19 @@ class LLMApprovalMixin:
                 raise HITLTimeoutError(f"Approval timed out: {event['error']}")
             raise HITLTriggerEventError(event)
 
-        responded_by_user = event.get("responded_by_user")
+        responder = self._describe_responder(event)
         chosen = event["chosen_options"]
         if self.APPROVE not in chosen:
-            raise HITLRejectException(f"Output was rejected by the reviewer 
{responded_by_user}.")
+            if event.get("timedout"):
+                raise HITLRejectException(
+                    "Output was rejected automatically: approval_timeout 
expired with "
+                    "on_approval_timeout='reject'."
+                )
+            raise HITLRejectException(f"Output was rejected by the reviewer 
{responder}.")
 
+        log.info("Output approved by %s.", responder)
         output = generated_output
-        params_input: dict[str, Any] = event.get("params_input") or {}
+        params_input: dict[str, Any] = {} if event.get("timedout") else 
event.get("params_input") or {}
 
         # Only accept modified output when the operator explicitly allows 
modifications.
         # Without this guard a reviewer could craft a request with 
params_input even
@@ -260,7 +286,7 @@ class LLMApprovalMixin:
                     }
                 )
             if modified is not None and modified != generated_output:
-                log.info("output=%s modified by the reviewer=%s ", modified, 
responded_by_user)
+                log.info("output=%s modified by the reviewer=%s ", modified, 
responder)
                 return modified
 
         return output
diff --git 
a/providers/common/ai/src/airflow/providers/common/ai/operators/llm.py 
b/providers/common/ai/src/airflow/providers/common/ai/operators/llm.py
index 6b28dbf7232..2de3762de6a 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/operators/llm.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/operators/llm.py
@@ -21,7 +21,7 @@ from __future__ import annotations
 from collections.abc import Sequence
 from datetime import timedelta
 from functools import cached_property
-from typing import TYPE_CHECKING, Any, ClassVar
+from typing import TYPE_CHECKING, Any, ClassVar, Literal
 
 from pydantic import BaseModel
 
@@ -100,7 +100,14 @@ class LLMOperator(BaseOperator, LLMApprovalMixin):
         output and waits for a human reviewer to approve or reject via the
         HITL interface.  Default ``False``.
     :param approval_timeout: Maximum time to wait for a review.  When
-        exceeded, the task fails with ``TimeoutError``.
+        exceeded, ``on_approval_timeout`` decides the outcome.
+    :param on_approval_timeout: What to do when ``approval_timeout`` expires
+        without a review.  ``"fail"`` (default) fails the task with
+        ``HITLTimeoutError``; ``"approve"`` and ``"reject"`` answer the review
+        with that option, so the task resumes as if a reviewer had chosen it.
+        The chosen option is also pre-highlighted for the reviewer in the HITL
+        form.  Requires ``require_approval=True`` and a positive
+        ``approval_timeout``.
     :param allow_modifications: If ``True``, the reviewer can edit the output
         before approving.  The modified value is returned as the task result.
         Default ``False``.
@@ -135,6 +142,7 @@ class LLMOperator(BaseOperator, LLMApprovalMixin):
         usage_limits: UsageLimits | dict[str, Any] | None = None,
         require_approval: bool = False,
         approval_timeout: timedelta | None = None,
+        on_approval_timeout: Literal["fail", "approve", "reject"] = "fail",
         allow_modifications: bool = False,
         serialize_output: bool = False,
         **kwargs: Any,
@@ -153,8 +161,21 @@ class LLMOperator(BaseOperator, LLMApprovalMixin):
         self.agent_params = agent_params or {}
         # No validation here -- see coerce_usage_limits() docstring for why.
         self.usage_limits = usage_limits
+        if on_approval_timeout not in ("fail", 
*LLMApprovalMixin.TIMEOUT_DEFAULTS):
+            raise ValueError(
+                f"on_approval_timeout must be 'fail', 'approve', or 'reject', 
got {on_approval_timeout!r}."
+            )
+        if on_approval_timeout != "fail" and not (
+            require_approval and approval_timeout is not None and 
approval_timeout > timedelta(0)
+        ):
+            raise ValueError(
+                f"on_approval_timeout={on_approval_timeout!r} needs 
require_approval=True and "
+                "a positive approval_timeout to fire. "
+                "Set both, or leave on_approval_timeout as 'fail'."
+            )
         self.require_approval = require_approval
         self.approval_timeout = approval_timeout
+        self.on_approval_timeout = on_approval_timeout
         self.allow_modifications = allow_modifications
 
     @cached_property
diff --git 
a/providers/common/ai/src/airflow/providers/common/ai/operators/llm_branch.py 
b/providers/common/ai/src/airflow/providers/common/ai/operators/llm_branch.py
index 1cec2f83e4d..e646bc0d8c8 100644
--- 
a/providers/common/ai/src/airflow/providers/common/ai/operators/llm_branch.py
+++ 
b/providers/common/ai/src/airflow/providers/common/ai/operators/llm_branch.py
@@ -61,7 +61,8 @@ class LLMBranchOperator(LLMOperator, BranchMixIn):
 
     Human-in-the-Loop approval parameters are inherited from
     :class:`~airflow.providers.common.ai.operators.llm.LLMOperator`
-    (``require_approval``, ``approval_timeout``, ``allow_modifications``).
+    (``require_approval``, ``approval_timeout``, ``on_approval_timeout``,
+    ``allow_modifications``).
     The task pauses after the LLM chooses the branch(es) and only skips the
     unselected downstream tasks once a reviewer approves. Rejecting the
     review skips the direct downstream tasks except teardowns, matching
@@ -160,7 +161,7 @@ class LLMBranchOperator(LLMOperator, BranchMixIn):
         except HITLRejectException:
             if self.fail_on_reject:
                 raise
-            self.log.info("Rejected by %s. Skipping downstream tasks...", 
event.get("responded_by_user"))
+            self.log.info("Rejected by %s. Skipping downstream tasks...", 
self._describe_responder(event))
             task = context["task"]
             tasks = (
                 task.get_flat_relatives(upstream=False)
diff --git 
a/providers/common/ai/src/airflow/providers/common/ai/operators/llm_file_analysis.py
 
b/providers/common/ai/src/airflow/providers/common/ai/operators/llm_file_analysis.py
index 7767e5a534d..c671587a3d0 100644
--- 
a/providers/common/ai/src/airflow/providers/common/ai/operators/llm_file_analysis.py
+++ 
b/providers/common/ai/src/airflow/providers/common/ai/operators/llm_file_analysis.py
@@ -72,7 +72,8 @@ class LLMFileAnalysisOperator(LLMOperator):
 
     Human-in-the-Loop approval parameters are inherited from
     :class:`~airflow.providers.common.ai.operators.llm.LLMOperator`
-    (``require_approval``, ``approval_timeout``, ``allow_modifications``).
+    (``require_approval``, ``approval_timeout``, ``on_approval_timeout``,
+    ``allow_modifications``).
     The task pauses after the file analysis and only returns the result once a
     reviewer approves.
     """
diff --git 
a/providers/common/ai/src/airflow/providers/common/ai/operators/llm_schema_compare.py
 
b/providers/common/ai/src/airflow/providers/common/ai/operators/llm_schema_compare.py
index 8fc82982d16..9e5127f8da9 100644
--- 
a/providers/common/ai/src/airflow/providers/common/ai/operators/llm_schema_compare.py
+++ 
b/providers/common/ai/src/airflow/providers/common/ai/operators/llm_schema_compare.py
@@ -111,7 +111,8 @@ class LLMSchemaCompareOperator(LLMOperator):
 
     Human-in-the-Loop approval parameters are inherited from
     :class:`~airflow.providers.common.ai.operators.llm.LLMOperator`
-    (``require_approval``, ``approval_timeout``, ``allow_modifications``).
+    (``require_approval``, ``approval_timeout``, ``on_approval_timeout``,
+    ``allow_modifications``).
     The task pauses after the comparison and only returns the result once a
     reviewer approves. The review body shows the compatibility verdict, a
     mismatch severity summary, and the full result JSON.
diff --git 
a/providers/common/ai/src/airflow/providers/common/ai/operators/llm_sql.py 
b/providers/common/ai/src/airflow/providers/common/ai/operators/llm_sql.py
index 2ac0cbe40dc..87a81e0882c 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/operators/llm_sql.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/operators/llm_sql.py
@@ -84,7 +84,8 @@ class LLMSQLQueryOperator(LLMOperator):
 
     Human-in-the-Loop approval parameters are inherited from
     :class:`~airflow.providers.common.ai.operators.llm.LLMOperator`
-    (``require_approval``, ``approval_timeout``, ``allow_modifications``).
+    (``require_approval``, ``approval_timeout``, ``on_approval_timeout``,
+    ``allow_modifications``).
     When ``allow_modifications=True`` and the reviewer edits the SQL, the
     modified query is re-validated against the same safety rules before being
     returned.
diff --git a/providers/common/ai/tests/unit/common/ai/mixins/test_approval.py 
b/providers/common/ai/tests/unit/common/ai/mixins/test_approval.py
index ace3ce7d3d9..6c9f55224a6 100644
--- a/providers/common/ai/tests/unit/common/ai/mixins/test_approval.py
+++ b/providers/common/ai/tests/unit/common/ai/mixins/test_approval.py
@@ -52,11 +52,13 @@ class FakeOperator(LLMApprovalMixin):
         prompt: str = "Summarize this",
         task_id: str = "test_task",
         approval_timeout: timedelta | None = None,
+        on_approval_timeout: str = "fail",
         allow_modifications: bool = False,
     ):
         self.prompt = prompt
         self.task_id = task_id
         self.approval_timeout = approval_timeout
+        self.on_approval_timeout = on_approval_timeout
         self.allow_modifications = allow_modifications
 
         self.defer = MagicMock()
@@ -203,8 +205,39 @@ class TestDeferForApproval:
         trigger_call_kwargs = mock_trigger_cls.call_args[1]
         assert trigger_call_kwargs["timeout_datetime"] == fake_now + timeout
 
-        defer_kwargs = op.defer.call_args[1]
-        assert defer_kwargs["timeout"] == timeout
+        assert "timeout" not in op.defer.call_args[1]
+
+    @patch(UTCNOW_PATH)
+    @patch(HITL_TRIGGER_PATH, autospec=True)
+    @patch(UPSERT_HITL_PATH)
+    def test_zero_timeout_sets_timeout_datetime_to_now(
+        self, mock_upsert, mock_trigger_cls, mock_utcnow, context
+    ):
+        from datetime import datetime
+
+        fake_now = datetime(2025, 1, 1, 12, 0, 0)
+        mock_utcnow.return_value = fake_now
+        op = FakeOperator(approval_timeout=timedelta(0))
+
+        op.defer_for_approval(context, "output")
+
+        assert mock_trigger_cls.call_args[1]["timeout_datetime"] == fake_now
+
+    @pytest.mark.parametrize(
+        ("on_approval_timeout", "expected_defaults"),
+        [("fail", None), ("approve", ["Approve"]), ("reject", ["Reject"])],
+    )
+    @patch(HITL_TRIGGER_PATH, autospec=True)
+    @patch(UPSERT_HITL_PATH)
+    def test_on_approval_timeout_sets_hitl_defaults(
+        self, mock_upsert, mock_trigger_cls, context, on_approval_timeout, 
expected_defaults
+    ):
+        op = FakeOperator(approval_timeout=timedelta(hours=1), 
on_approval_timeout=on_approval_timeout)
+
+        op.defer_for_approval(context, "output")
+
+        assert mock_upsert.call_args[1]["defaults"] == expected_defaults
+        assert mock_trigger_cls.call_args[1]["defaults"] == expected_defaults
 
     @patch(HITL_TRIGGER_PATH, autospec=True)
     @patch(UPSERT_HITL_PATH)
@@ -213,9 +246,7 @@ class TestDeferForApproval:
 
         trigger_call_kwargs = mock_trigger_cls.call_args[1]
         assert trigger_call_kwargs["timeout_datetime"] is None
-
-        defer_kwargs = approval_op.defer.call_args[1]
-        assert defer_kwargs["timeout"] is None
+        assert "timeout" not in approval_op.defer.call_args[1]
 
     @patch(HITL_TRIGGER_PATH, autospec=True)
     @patch(UPSERT_HITL_PATH)
@@ -248,20 +279,20 @@ class TestDeferForApproval:
         assert "Paris is the capital of France." in call_kwargs["body"]
 
     def test_approved_returns_generated_output(self, approval_op):
-        event = {"chosen_options": ["Approve"], "responded_by_user": "admin"}
+        event = {"chosen_options": ["Approve"], "responded_by_user": {"id": 
"u1", "name": "admin"}}
 
         result = approval_op.execute_complete({}, generated_output="hello 
world", event=event)
 
         assert result == "hello world"
 
     def test_rejected_raises_rejection_exception(self, approval_op):
-        event = {"chosen_options": ["Reject"], "responded_by_user": "admin"}
+        event = {"chosen_options": ["Reject"], "responded_by_user": {"id": 
"u1", "name": "admin"}}
 
         with pytest.raises(HITLRejectException, match="Output was rejected by 
the reviewer admin."):
             approval_op.execute_complete({}, generated_output="output", 
event=event)
 
     def test_empty_chosen_options_raises_rejection(self, approval_op):
-        event = {"chosen_options": [], "responded_by_user": "admin"}
+        event = {"chosen_options": [], "responded_by_user": {"id": "u1", 
"name": "admin"}}
 
         with pytest.raises(HITLRejectException, match="Output was rejected by 
the reviewer admin."):
             approval_op.execute_complete({}, generated_output="output", 
event=event)
@@ -283,7 +314,7 @@ class TestDeferForApproval:
     def test_approved_with_modified_output(self, 
approval_op_with_modifications):
         event = {
             "chosen_options": ["Approve"],
-            "responded_by_user": "editor",
+            "responded_by_user": {"id": "u1", "name": "editor"},
             "params_input": {"output": "modified output"},
         }
 
@@ -299,7 +330,7 @@ class TestDeferForApproval:
         # string contract instead of returning a dict as the task's output.
         event = {
             "chosen_options": ["Approve"],
-            "responded_by_user": "editor",
+            "responded_by_user": {"id": "u1", "name": "editor"},
             "params_input": {"output": {"sneaky": "dict"}},
         }
 
@@ -311,7 +342,7 @@ class TestDeferForApproval:
     def test_approved_with_list_modified_output_is_serialized(self, 
approval_op_with_modifications):
         event = {
             "chosen_options": ["Approve"],
-            "responded_by_user": "editor",
+            "responded_by_user": {"id": "u1", "name": "editor"},
             "params_input": {"output": ["task_b", "task_c"]},
         }
 
@@ -324,7 +355,7 @@ class TestDeferForApproval:
     def test_approved_with_unmodified_list_output_returns_original(self, 
approval_op_with_modifications):
         event = {
             "chosen_options": ["Approve"],
-            "responded_by_user": "editor",
+            "responded_by_user": {"id": "u1", "name": "editor"},
             "params_input": {"output": ["task_a"]},
         }
 
@@ -337,7 +368,7 @@ class TestDeferForApproval:
     def test_approved_with_non_string_list_items_raises(self, 
approval_op_with_modifications):
         event = {
             "chosen_options": ["Approve"],
-            "responded_by_user": "editor",
+            "responded_by_user": {"id": "u1", "name": "editor"},
             "params_input": {"output": ["task_a", 2]},
         }
 
@@ -347,7 +378,7 @@ class TestDeferForApproval:
     def test_approved_with_cleared_output_raises(self, 
approval_op_with_modifications):
         event = {
             "chosen_options": ["Approve"],
-            "responded_by_user": "editor",
+            "responded_by_user": {"id": "u1", "name": "editor"},
             "params_input": {"output": None},
         }
 
@@ -357,7 +388,7 @@ class TestDeferForApproval:
     def test_approved_with_unmodified_output(self, 
approval_op_with_modifications):
         event = {
             "chosen_options": ["Approve"],
-            "responded_by_user": "editor",
+            "responded_by_user": {"id": "u1", "name": "editor"},
             "params_input": {"output": "same output"},
         }
 
@@ -370,7 +401,7 @@ class TestDeferForApproval:
     def test_approved_modifications_allowed_but_no_params_input(self, 
approval_op_with_modifications):
         event = {
             "chosen_options": ["Approve"],
-            "responded_by_user": "editor",
+            "responded_by_user": {"id": "u1", "name": "editor"},
             "params_input": None,
         }
 
@@ -381,7 +412,7 @@ class TestDeferForApproval:
     def test_approved_modifications_allowed_empty_output_key(self, 
approval_op_with_modifications):
         event = {
             "chosen_options": ["Approve"],
-            "responded_by_user": "editor",
+            "responded_by_user": {"id": "u1", "name": "editor"},
             "params_input": {"output": "original"},
         }
 
@@ -393,7 +424,7 @@ class TestDeferForApproval:
         """When allow_modifications=False, params will be empty so 
params_input is empty too."""
         event = {
             "chosen_options": ["Approve"],
-            "responded_by_user": "editor",
+            "responded_by_user": {"id": "u1", "name": "editor"},
             "params_input": {},
         }
 
@@ -405,7 +436,7 @@ class TestDeferForApproval:
         """When allow_modifications=False, tampered params_input with output 
must be ignored."""
         event = {
             "chosen_options": ["Approve"],
-            "responded_by_user": "reviewer",
+            "responded_by_user": {"id": "u1", "name": "reviewer"},
             "params_input": {"output": "tampered output"},
         }
 
@@ -420,8 +451,46 @@ class TestDeferForApproval:
 
         assert result == "output"
 
+    def test_timed_out_rejection_names_the_timeout_default(self, approval_op):
+        event = {"chosen_options": ["Reject"], "responded_by_user": None, 
"timedout": True}
+
+        with pytest.raises(
+            HITLRejectException,
+            match="Output was rejected automatically: approval_timeout expired 
with on_approval_timeout='reject'.",
+        ):
+            approval_op.execute_complete({}, generated_output="output", 
event=event)
+
+    @pytest.mark.parametrize(
+        ("event", "expected_approver"),
+        [
+            ({"chosen_options": ["Approve"], "responded_by_user": {"id": "u1", 
"name": "admin"}}, "admin"),
+            (
+                {"chosen_options": ["Approve"], "responded_by_user": None, 
"timedout": True},
+                "the approval timeout default",
+            ),
+        ],
+        ids=["reviewer", "timeout_default"],
+    )
+    @patch("airflow.providers.common.ai.mixins.approval.log", autospec=True)
+    def test_approval_logs_who_approved(self, mock_log, approval_op, event, 
expected_approver):
+        approval_op.execute_complete({}, generated_output="output", 
event=event)
+
+        mock_log.info.assert_called_once_with("Output approved by %s.", 
expected_approver)
+
+    def test_timed_out_approval_ignores_stale_params_input(self, 
approval_op_with_modifications):
+        event = {
+            "chosen_options": ["Approve"],
+            "params_input": {"output": "stale output"},
+            "responded_by_user": None,
+            "timedout": True,
+        }
+
+        result = approval_op_with_modifications.execute_complete({}, 
generated_output="output", event=event)
+
+        assert result == "output"
+
     def test_rejection_message_includes_username(self, approval_op):
-        event = {"chosen_options": ["Reject"], "responded_by_user": "alice"}
+        event = {"chosen_options": ["Reject"], "responded_by_user": {"id": 
"u1", "name": "alice"}}
 
         with pytest.raises(HITLRejectException, match="alice"):
             approval_op.execute_complete({}, generated_output="output", 
event=event)
@@ -453,6 +522,21 @@ class TestAwaitInputForApproval:
 
         assert exc_info.value.timeout == timeout
 
+    @pytest.mark.parametrize(
+        ("on_approval_timeout", "expected_defaults"),
+        [("fail", None), ("approve", ["Approve"]), ("reject", ["Reject"])],
+    )
+    @patch(UPSERT_HITL_PATH)
+    def test_on_approval_timeout_sets_hitl_defaults_on_await(
+        self, mock_upsert, context, on_approval_timeout, expected_defaults
+    ):
+        op = FakeOperator(approval_timeout=timedelta(hours=1), 
on_approval_timeout=on_approval_timeout)
+
+        with pytest.raises(TaskAwaitingInput):
+            op.defer_for_approval(context, "output")
+
+        assert mock_upsert.call_args[1]["defaults"] == expected_defaults
+
     @patch(UPSERT_HITL_PATH)
     def test_pydantic_output_stringified_on_await(self, mock_upsert, 
approval_op, context):
         class Answer(BaseModel):
diff --git a/providers/common/ai/tests/unit/common/ai/operators/test_llm.py 
b/providers/common/ai/tests/unit/common/ai/operators/test_llm.py
index 95f0e569672..5ed69546493 100644
--- a/providers/common/ai/tests/unit/common/ai/operators/test_llm.py
+++ b/providers/common/ai/tests/unit/common/ai/operators/test_llm.py
@@ -326,6 +326,38 @@ class TestLLMOperatorApproval:
         assert op.require_approval is False
         assert op.allow_modifications is False
         assert op.approval_timeout is None
+        assert op.on_approval_timeout == "fail"
+
+    def test_unknown_on_approval_timeout_raises(self):
+        with pytest.raises(ValueError, match="on_approval_timeout must be"):
+            LLMOperator(
+                task_id="t",
+                prompt="p",
+                llm_conn_id="c",
+                approval_timeout=timedelta(hours=1),
+                on_approval_timeout="skip",
+            )
+
+    @pytest.mark.parametrize(
+        "kwargs",
+        [
+            {"require_approval": True},
+            {"require_approval": True, "approval_timeout": timedelta(0)},
+            {"require_approval": True, "approval_timeout": 
timedelta(hours=-1)},
+            {"approval_timeout": timedelta(hours=1)},
+        ],
+        ids=[
+            "no_approval_timeout",
+            "zero_approval_timeout",
+            "negative_approval_timeout",
+            "no_require_approval",
+        ],
+    )
+    def test_on_approval_timeout_without_prerequisites_raises(self, kwargs):
+        with pytest.raises(
+            ValueError, match="needs require_approval=True and a positive 
approval_timeout to fire"
+        ):
+            LLMOperator(task_id="t", prompt="p", llm_conn_id="c", 
on_approval_timeout="approve", **kwargs)
 
     @patch("airflow.providers.standard.triggers.hitl.HITLTrigger", 
autospec=True)
     @patch("airflow.sdk.execution_time.hitl.upsert_hitl_detail")
@@ -430,7 +462,10 @@ class TestLLMOperatorApproval:
         with pytest.raises(ApprovalPauseSignal) as exc_info:
             op.execute(context=ctx)
 
-        assert exc_info.value.timeout == timeout
+        if AIRFLOW_V_3_3_PLUS:
+            assert exc_info.value.timeout == timeout
+        else:
+            assert mock_trigger_cls.call_args[1]["timeout_datetime"] is not 
None
 
     @patch("airflow.providers.standard.triggers.hitl.HITLTrigger", 
autospec=True)
     @patch("airflow.sdk.execution_time.hitl.upsert_hitl_detail")
@@ -472,7 +507,7 @@ class TestLLMOperatorApproval:
     def test_execute_complete_approved(self):
         """execute_complete returns output when approved."""
         op = LLMOperator(task_id="t", prompt="p", llm_conn_id="c")
-        event = {"chosen_options": ["Approve"], "responded_by_user": "admin"}
+        event = {"chosen_options": ["Approve"], "responded_by_user": {"id": 
"u1", "name": "admin"}}
 
         result = op.execute_complete({}, generated_output="the output", 
event=event)
 
@@ -483,7 +518,7 @@ class TestLLMOperatorApproval:
         from airflow.providers.standard.exceptions import HITLRejectException
 
         op = LLMOperator(task_id="t", prompt="p", llm_conn_id="c")
-        event = {"chosen_options": ["Reject"], "responded_by_user": "admin"}
+        event = {"chosen_options": ["Reject"], "responded_by_user": {"id": 
"u1", "name": "admin"}}
 
         with pytest.raises(HITLRejectException):
             op.execute_complete({}, generated_output="output", event=event)
@@ -503,7 +538,7 @@ class TestLLMOperatorApproval:
         op = LLMOperator(task_id="t", prompt="p", llm_conn_id="c", 
allow_modifications=True)
         event = {
             "chosen_options": ["Approve"],
-            "responded_by_user": "editor",
+            "responded_by_user": {"id": "u1", "name": "editor"},
             "params_input": {"output": "edited"},
         }
 
@@ -515,7 +550,7 @@ class TestLLMOperatorApproval:
     def test_execute_complete_rehydrates_pydantic_for_structured_output(self):
         """When output_type is a BaseModel, execute_complete returns the 
model, not the JSON string."""
         op = LLMOperator(task_id="t", prompt="p", llm_conn_id="c", 
output_type=Summary)
-        event = {"chosen_options": ["Approve"], "responded_by_user": "admin"}
+        event = {"chosen_options": ["Approve"], "responded_by_user": {"id": 
"u1", "name": "admin"}}
 
         result = op.execute_complete({}, generated_output='{"text":"hello"}', 
event=event)
 
@@ -535,7 +570,7 @@ class TestLLMOperatorApproval:
         op = LLMOperator(
             task_id="t", prompt="p", llm_conn_id="c", output_type=output_type, 
require_approval=True
         )
-        event = {"chosen_options": ["Approve"], "responded_by_user": "admin"}
+        event = {"chosen_options": ["Approve"], "responded_by_user": {"id": 
"u1", "name": "admin"}}
 
         result = op.execute_complete({}, generated_output=generated_output, 
event=event)
 
diff --git 
a/providers/common/ai/tests/unit/common/ai/operators/test_llm_branch.py 
b/providers/common/ai/tests/unit/common/ai/operators/test_llm_branch.py
index d6f05dd2b7d..a4cdaee9879 100644
--- a/providers/common/ai/tests/unit/common/ai/operators/test_llm_branch.py
+++ b/providers/common/ai/tests/unit/common/ai/operators/test_llm_branch.py
@@ -396,7 +396,7 @@ class TestLLMBranchOperatorApproval:
         mock_do_branch.return_value = "task_a"
         op = LLMBranchOperator(task_id="t", prompt="p", llm_conn_id="c")
         op.downstream_task_ids = {"task_a", "task_b"}
-        event = {"chosen_options": ["Approve"], "responded_by_user": "admin"}
+        event = {"chosen_options": ["Approve"], "responded_by_user": {"id": 
"u1", "name": "admin"}}
         ctx = _make_context()
 
         result = op.execute_complete(ctx, generated_output="task_a", 
event=event)
@@ -410,7 +410,7 @@ class TestLLMBranchOperatorApproval:
         mock_do_branch.return_value = ["task_a", "task_c"]
         op = LLMBranchOperator(task_id="t", prompt="p", llm_conn_id="c", 
allow_multiple_branches=True)
         op.downstream_task_ids = {"task_a", "task_b", "task_c"}
-        event = {"chosen_options": ["Approve"], "responded_by_user": "admin"}
+        event = {"chosen_options": ["Approve"], "responded_by_user": {"id": 
"u1", "name": "admin"}}
         ctx = _make_context()
 
         result = op.execute_complete(ctx, 
generated_output='["task_a","task_c"]', event=event)
@@ -447,7 +447,7 @@ class TestLLMBranchOperatorApproval:
             if with_teardown:
                 op4.as_teardown()
             op1 >> op2 >> op3 >> op4
-        event = {"chosen_options": ["Reject"], "responded_by_user": "admin"}
+        event = {"chosen_options": ["Reject"], "responded_by_user": {"id": 
"u1", "name": "admin"}}
         ti = MagicMock()
         ctx = MagicMock(**{"__getitem__": lambda self, key: {"task": op1, 
"ti": ti}[key]})
 
@@ -459,11 +459,30 @@ class TestLLMBranchOperatorApproval:
         assert {t.task_id for t in mock_skip.call_args.kwargs["tasks"]} == 
expected
         mock_do_branch.assert_not_called()
 
+    @patch.object(LLMBranchOperator, "log")
+    @patch.object(LLMBranchOperator, "skip")
+    @patch.object(LLMBranchOperator, "do_branch")
+    def test_execute_complete_timed_out_reject_names_the_timeout_default(
+        self, mock_do_branch, mock_skip, mock_log
+    ):
+        op = LLMBranchOperator(task_id="t", prompt="p", llm_conn_id="c")
+        op.downstream_task_ids = {"task_a"}
+        event = {"chosen_options": ["Reject"], "responded_by_user": None, 
"timedout": True}
+        task = MagicMock()
+        task.get_direct_relatives.return_value = []
+        ctx = MagicMock(**{"__getitem__": lambda self, key: {"task": task, 
"ti": MagicMock()}[key]})
+
+        op.execute_complete(ctx, generated_output="task_a", event=event)
+
+        mock_log.info.assert_called_once_with(
+            "Rejected by %s. Skipping downstream tasks...", "the approval 
timeout default"
+        )
+
     @patch.object(LLMBranchOperator, "do_branch")
     def test_execute_complete_reject_fails_with_fail_on_reject(self, 
mock_do_branch):
         op = LLMBranchOperator(task_id="t", prompt="p", llm_conn_id="c", 
fail_on_reject=True)
         op.downstream_task_ids = {"task_a", "task_b"}
-        event = {"chosen_options": ["Reject"], "responded_by_user": "admin"}
+        event = {"chosen_options": ["Reject"], "responded_by_user": {"id": 
"u1", "name": "admin"}}
 
         with pytest.raises(HITLRejectException, match="rejected"):
             op.execute_complete(_make_context(), generated_output="task_a", 
event=event)
@@ -478,7 +497,7 @@ class TestLLMBranchOperatorApproval:
         op.downstream_task_ids = {"task_a", "task_b"}
         event = {
             "chosen_options": ["Approve"],
-            "responded_by_user": "admin",
+            "responded_by_user": {"id": "u1", "name": "admin"},
             "params_input": {"output": "task_b"},
         }
         ctx = _make_context()
@@ -502,7 +521,7 @@ class TestLLMBranchOperatorApproval:
         op.downstream_task_ids = {"task_a", "task_b", "task_c"}
         event = {
             "chosen_options": ["Approve"],
-            "responded_by_user": "admin",
+            "responded_by_user": {"id": "u1", "name": "admin"},
             "params_input": {"output": ["task_b", "task_c"]},
         }
         ctx = _make_context()
@@ -519,7 +538,7 @@ class TestLLMBranchOperatorApproval:
         op.downstream_task_ids = {"task_a", "task_b"}
         event = {
             "chosen_options": ["Approve"],
-            "responded_by_user": "admin",
+            "responded_by_user": {"id": "u1", "name": "admin"},
             "params_input": {"output": "task_x"},
         }
 
@@ -541,7 +560,7 @@ class TestLLMBranchOperatorApproval:
         op.downstream_task_ids = {"task_a", "task_b"}
         event = {
             "chosen_options": ["Approve"],
-            "responded_by_user": "admin",
+            "responded_by_user": {"id": "u1", "name": "admin"},
             "params_input": {"output": "[]"},
         }
 
@@ -568,7 +587,7 @@ class TestLLMBranchOperatorApproval:
         op.downstream_task_ids = {"task_a", "task_b"}
         event = {
             "chosen_options": ["Approve"],
-            "responded_by_user": "admin",
+            "responded_by_user": {"id": "u1", "name": "admin"},
             "params_input": {"output": modified},
         }
 
diff --git 
a/providers/common/ai/tests/unit/common/ai/operators/test_llm_file_analysis.py 
b/providers/common/ai/tests/unit/common/ai/operators/test_llm_file_analysis.py
index 74d9901ab18..ff511183b16 100644
--- 
a/providers/common/ai/tests/unit/common/ai/operators/test_llm_file_analysis.py
+++ 
b/providers/common/ai/tests/unit/common/ai/operators/test_llm_file_analysis.py
@@ -315,7 +315,11 @@ class TestLLMFileAnalysisOperatorApproval:
             output_type=Summary,
             require_approval=True,
         )
-        event = {"chosen_options": [op.APPROVE], "params_input": {}, 
"responded_by_user": "reviewer"}
+        event = {
+            "chosen_options": [op.APPROVE],
+            "params_input": {},
+            "responded_by_user": {"id": "u1", "name": "reviewer"},
+        }
 
         result = op.execute_complete({}, generated_output='{"findings":["error 
spike"]}', event=event)
 
@@ -336,7 +340,7 @@ class TestLLMFileAnalysisOperatorApproval:
         event = {
             "chosen_options": [op.APPROVE],
             "params_input": {"output": '{"findings":["reviewed output"]}'},
-            "responded_by_user": "reviewer",
+            "responded_by_user": {"id": "u1", "name": "reviewer"},
         }
 
         result = op.execute_complete({}, generated_output='{"findings":["error 
spike"]}', event=event)
@@ -375,4 +379,7 @@ class TestLLMFileAnalysisOperatorApproval:
         with pytest.raises(ApprovalPauseSignal) as exc_info:
             op.execute(context=_make_context())
 
-        assert exc_info.value.timeout == timeout
+        if AIRFLOW_V_3_3_PLUS:
+            assert exc_info.value.timeout == timeout
+        else:
+            assert mock_trigger_cls.call_args[1]["timeout_datetime"] is not 
None
diff --git 
a/providers/common/ai/tests/unit/common/ai/operators/test_llm_schema_compare.py 
b/providers/common/ai/tests/unit/common/ai/operators/test_llm_schema_compare.py
index 21ac1a41aef..1443ce54661 100644
--- 
a/providers/common/ai/tests/unit/common/ai/operators/test_llm_schema_compare.py
+++ 
b/providers/common/ai/tests/unit/common/ai/operators/test_llm_schema_compare.py
@@ -668,7 +668,7 @@ class TestLLMSchemaCompareOperatorApproval:
     def test_execute_complete_approved_returns_dict(self):
         result = SchemaCompareResult(compatible=True, mismatches=[], 
summary="All good")
         op = LLMSchemaCompareOperator(**_BASE_KWARGS, **self._APPROVAL_KWARGS)
-        event = {"chosen_options": ["Approve"], "responded_by_user": "admin"}
+        event = {"chosen_options": ["Approve"], "responded_by_user": {"id": 
"u1", "name": "admin"}}
 
         resumed = op.execute_complete({}, 
generated_output=result.model_dump_json(), event=event)
 
@@ -684,7 +684,7 @@ class TestLLMSchemaCompareOperatorApproval:
         op = LLMSchemaCompareOperator(**_BASE_KWARGS, **self._APPROVAL_KWARGS, 
allow_modifications=True)
         event = {
             "chosen_options": ["Approve"],
-            "responded_by_user": "admin",
+            "responded_by_user": {"id": "u1", "name": "admin"},
             "params_input": {"output": modified},
         }
 
diff --git a/providers/common/ai/tests/unit/common/ai/operators/test_llm_sql.py 
b/providers/common/ai/tests/unit/common/ai/operators/test_llm_sql.py
index 1742959c519..b96d836c490 100644
--- a/providers/common/ai/tests/unit/common/ai/operators/test_llm_sql.py
+++ b/providers/common/ai/tests/unit/common/ai/operators/test_llm_sql.py
@@ -718,7 +718,10 @@ class TestLLMSQLQueryOperatorApproval:
         with pytest.raises(ApprovalPauseSignal) as exc_info:
             op.execute(context=ctx)
 
-        assert exc_info.value.timeout == timeout
+        if AIRFLOW_V_3_3_PLUS:
+            assert exc_info.value.timeout == timeout
+        else:
+            assert mock_trigger_cls.call_args[1]["timeout_datetime"] is not 
None
 
     @patch("airflow.providers.common.ai.operators.llm.PydanticAIHook", 
autospec=True)
     def test_execute_without_approval_returns_sql(self, mock_hook_cls, 
make_mock_run_result):
@@ -765,7 +768,7 @@ class TestLLMSQLQueryOperatorApproval:
     def test_execute_complete_approved(self):
         """execute_complete returns SQL when approved."""
         op = LLMSQLQueryOperator(task_id="t", prompt="p", llm_conn_id="c")
-        event = {"chosen_options": ["Approve"], "responded_by_user": "admin"}
+        event = {"chosen_options": ["Approve"], "responded_by_user": {"id": 
"u1", "name": "admin"}}
 
         result = op.execute_complete({}, generated_output="SELECT * FROM 
orders", event=event)
 
@@ -774,7 +777,7 @@ class TestLLMSQLQueryOperatorApproval:
     def test_execute_complete_rejected(self):
         """execute_complete raises HITLRejectException when SQL is rejected."""
         op = LLMSQLQueryOperator(task_id="t", prompt="p", llm_conn_id="c")
-        event = {"chosen_options": ["Reject"], "responded_by_user": "dba"}
+        event = {"chosen_options": ["Reject"], "responded_by_user": {"id": 
"u1", "name": "dba"}}
         from airflow.providers.standard.exceptions import HITLRejectException
 
         with pytest.raises(HITLRejectException, match="Output was rejected by 
the reviewer"):
@@ -795,7 +798,7 @@ class TestLLMSQLQueryOperatorApproval:
         op = LLMSQLQueryOperator(task_id="t", prompt="p", llm_conn_id="c", 
allow_modifications=True)
         event = {
             "chosen_options": ["Approve"],
-            "responded_by_user": "dba",
+            "responded_by_user": {"id": "u1", "name": "dba"},
             "params_input": {"output": "SELECT id, name FROM users LIMIT 10"},
         }
 
@@ -808,7 +811,7 @@ class TestLLMSQLQueryOperatorApproval:
         op = LLMSQLQueryOperator(task_id="t", prompt="p", llm_conn_id="c", 
allow_modifications=True)
         event = {
             "chosen_options": ["Approve"],
-            "responded_by_user": "john",
+            "responded_by_user": {"id": "u1", "name": "john"},
             "params_input": {"output": "DROP TABLE users"},
         }
 
@@ -820,7 +823,7 @@ class TestLLMSQLQueryOperatorApproval:
         op = LLMSQLQueryOperator(task_id="t", prompt="p", llm_conn_id="c")
         event = {
             "chosen_options": ["Approve"],
-            "responded_by_user": "john",
+            "responded_by_user": {"id": "u1", "name": "john"},
             "params_input": {},
         }
 
diff --git a/providers/standard/src/airflow/providers/standard/triggers/hitl.py 
b/providers/standard/src/airflow/providers/standard/triggers/hitl.py
index d7aff2e389c..a48e7c0226d 100644
--- a/providers/standard/src/airflow/providers/standard/triggers/hitl.py
+++ b/providers/standard/src/airflow/providers/standard/triggers/hitl.py
@@ -41,6 +41,9 @@ from airflow.sdk.execution_time.hitl import (
 from airflow.sdk.timezone import utcnow
 from airflow.triggers.base import BaseTrigger, TriggerEvent
 
+if TYPE_CHECKING:
+    from airflow.sdk.api.datamodels._generated import HITLDetailResponse
+
 
 class HITLTriggerEventSuccessPayload(TypedDict, total=False):
     """Minimum required keys for a success Human-in-the-loop TriggerEvent."""
@@ -114,6 +117,12 @@ class HITLTrigger(BaseTrigger):
             },
         )
 
+    @staticmethod
+    def _get_responder(resp: HITLDetailResponse) -> HITLUser | None:
+        if resp.responded_by_user is None:
+            return None
+        return HITLUser(id=resp.responded_by_user.id, 
name=resp.responded_by_user.name)
+
     async def _handle_timeout(self) -> TriggerEvent:
         """Handle HITL timeout logic and yield appropriate event."""
         resp = await 
sync_to_async(get_hitl_detail_content_detail)(ti_id=self.ti_id)
@@ -121,27 +130,32 @@ class HITLTrigger(BaseTrigger):
         # Case 1: Response arrived just before timeout
         if resp.response_received and resp.chosen_options:
             if TYPE_CHECKING:
-                assert resp.responded_by_user is not None
                 assert resp.responded_at is not None
 
             chosen_options_list = list(resp.chosen_options or [])
-            self.log.info(
-                "[HITL] responded_by=%s (id=%s) options=%s at %s (timeout 
fallback skipped)",
-                resp.responded_by_user.name,
-                resp.responded_by_user.id,
-                chosen_options_list,
-                resp.responded_at,
-            )
+            responded_by_user = self._get_responder(resp)
+            if responded_by_user is None:
+                self.log.info(
+                    "[HITL] resuming with the timeout default %s recorded at 
%s "
+                    "(applied by an earlier run of this trigger)",
+                    chosen_options_list,
+                    resp.responded_at,
+                )
+            else:
+                self.log.info(
+                    "[HITL] responded_by=%s (id=%s) options=%s at %s (timeout 
fallback skipped)",
+                    responded_by_user["name"],
+                    responded_by_user["id"],
+                    chosen_options_list,
+                    resp.responded_at,
+                )
             return TriggerEvent(
                 HITLTriggerEventSuccessPayload(
                     chosen_options=chosen_options_list,
                     params_input=resp.params_input or {},
                     responded_at=resp.responded_at,
-                    responded_by_user=HITLUser(
-                        id=resp.responded_by_user.id,
-                        name=resp.responded_by_user.name,
-                    ),
-                    timedout=False,
+                    responded_by_user=responded_by_user,
+                    timedout=responded_by_user is None,
                 )
             )
 
@@ -181,7 +195,6 @@ class HITLTrigger(BaseTrigger):
         """Check if HITL response is ready and yield success if so."""
         resp = await 
sync_to_async(get_hitl_detail_content_detail)(ti_id=self.ti_id)
         if TYPE_CHECKING:
-            assert resp.responded_by_user is not None
             assert resp.responded_at is not None
 
         if not (resp.response_received and resp.chosen_options):
@@ -201,23 +214,29 @@ class HITLTrigger(BaseTrigger):
                 )
 
         chosen_options_list = list(resp.chosen_options or [])
-        self.log.info(
-            "[HITL] responded_by=%s (id=%s) options=%s at %s",
-            resp.responded_by_user.name,
-            resp.responded_by_user.id,
-            chosen_options_list,
-            resp.responded_at,
-        )
+        responded_by_user = self._get_responder(resp)
+        if responded_by_user is None:
+            self.log.info(
+                "[HITL] resuming with the timeout default %s recorded at %s "
+                "(applied by an earlier run of this trigger)",
+                chosen_options_list,
+                resp.responded_at,
+            )
+        else:
+            self.log.info(
+                "[HITL] responded_by=%s (id=%s) options=%s at %s",
+                responded_by_user["name"],
+                responded_by_user["id"],
+                chosen_options_list,
+                resp.responded_at,
+            )
         return TriggerEvent(
             HITLTriggerEventSuccessPayload(
                 chosen_options=chosen_options_list,
                 params_input=params_input or {},
                 responded_at=resp.responded_at,
-                responded_by_user=HITLUser(
-                    id=resp.responded_by_user.id,
-                    name=resp.responded_by_user.name,
-                ),
-                timedout=False,
+                responded_by_user=responded_by_user,
+                timedout=responded_by_user is None,
             )
         )
 
diff --git a/providers/standard/tests/unit/standard/triggers/test_hitl.py 
b/providers/standard/tests/unit/standard/triggers/test_hitl.py
index 7166d4428bb..bd4a50e73af 100644
--- a/providers/standard/tests/unit/standard/triggers/test_hitl.py
+++ b/providers/standard/tests/unit/standard/triggers/test_hitl.py
@@ -206,6 +206,49 @@ class TestHITLTrigger:
             action_datetime,
         )
 
+    @pytest.mark.db_test
+    @pytest.mark.asyncio
+    @mock.patch.object(HITLTrigger, "log")
+    
@mock.patch("airflow.providers.standard.triggers.hitl.update_hitl_detail_response")
+    async def test_run_replayed_after_timeout_fallback_has_no_responder(
+        self, mock_update, mock_log, mock_supervisor_comms, 
default_trigger_args
+    ):
+        fallback_datetime = utcnow()
+        trigger = HITLTrigger(
+            defaults=["1"],
+            timeout_datetime=utcnow() + timedelta(seconds=0.1),
+            poke_interval=5,
+            **default_trigger_args,
+        )
+        mock_supervisor_comms.send.return_value = HITLDetailResponse(
+            response_received=True,
+            responded_by_user=None,
+            responded_at=fallback_datetime,
+            chosen_options=["1"],
+            params_input={},
+        )
+
+        gen = trigger.run()
+        await asyncio.sleep(0.3)
+        event = await asyncio.create_task(gen.__anext__())
+
+        assert event == TriggerEvent(
+            HITLTriggerEventSuccessPayload(
+                chosen_options=["1"],
+                params_input={},
+                responded_at=fallback_datetime,
+                responded_by_user=None,
+                timedout=True,
+            )
+        )
+        mock_update.assert_not_called()
+        assert mock_log.info.call_args == mock.call(
+            "[HITL] resuming with the timeout default %s recorded at %s "
+            "(applied by an earlier run of this trigger)",
+            ["1"],
+            fallback_datetime,
+        )
+
     @pytest.mark.db_test
     @pytest.mark.asyncio
     @mock.patch.object(HITLTrigger, "log")

Reply via email to