kaxil commented on code in PR #72155:
URL: https://github.com/apache/airflow/pull/72155#discussion_r3968181982


##########
providers/common/ai/src/airflow/providers/common/ai/mixins/approval.py:
##########
@@ -179,14 +189,13 @@ def defer_for_approval(
             trigger=HITLTrigger(
                 ti_id=ti_id,
                 options=[LLMApprovalMixin.APPROVE, LLMApprovalMixin.REJECT],
-                defaults=None,
+                defaults=timeout_defaults,

Review Comment:
   Separate from the `timeout=` kwarg thread: making `defaults` non-null also 
arms a `HITLTrigger` replay path that can turn the default into a hard failure 
on cores below 3.3. `_handle_timeout` Case 3 PATCHes the response, and the 
execution API sets `responded_by = None` with `responded_at = utcnow()` 
(`execution_api/routes/hitl.py:129-130`).
   
   If that trigger is replayed, which a triggerer restart or an 
`assign_unassigned` reassignment after a heartbeat lapse will do, `run()` 
re-enters `_handle_timeout` and now matches Case 1, since `response_received` 
is just `responded_at is not None` (`models/hitl.py:91`) and Case 3 wrote 
`chosen_options=self.defaults`. Case 1 then dereferences 
`resp.responded_by_user.name` (`standard/triggers/hitl.py:130`) where the value 
is `None`; the `assert` above it sits inside `if TYPE_CHECKING`, so nothing 
guards it at runtime. The `AttributeError` reaches `Trigger.submit_failure`, 
`next_method` becomes `__fail__`, and `execute_complete` never runs.
   
   At the merge base `defaults` was always `None` here, so this was unreachable 
before. Guarding those two log arguments in `HITLTrigger` fixes it for 
`HITLOperator` too, which has the same exposure whenever `defaults=` is set.



##########
providers/common/ai/docs/operators/llm.rst:
##########
@@ -198,7 +198,11 @@ 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"`` or ``"reject"`` to answer the review with
+that option instead, so an unattended pipeline keeps moving:

Review Comment:
   `"reject"` does not keep the pipeline moving on this operator. 
`HITLRejectException` is a plain `AirflowException` and only 
`LLMBranchOperator` catches it (`llm_branch.py:149`), so on `LLMOperator`, 
`LLMSQLQueryOperator`, `LLMFileAnalysisOperator` and 
`LLMSchemaCompareOperator`, `on_approval_timeout="reject"` reaches the same 
terminal state as the `"fail"` default: task failed. Only `"approve"` changes 
the outcome here.
   
   The `exampleinclude` right below makes it concrete: `example_llm.py:162` 
sets `on_approval_timeout="reject"` on a plain `LLMOperator` inside 
`howto_operator_llm_approval`, so the page demonstrates the one value that 
changes nothing. `llm_branch.rst:100-104` already words this correctly. Worth 
splitting the sentence so `"approve"` is described as returning the output and 
letting downstream run while `"reject"` still fails the task here, pointing at 
`LLMBranchOperator` for the skip behaviour, and switching the example to 
`"approve"`.
   
   Nearby: `llm_sql.py`'s docstring lists `on_approval_timeout` but 
`llm_sql.rst`'s approval section does not, unlike 
`llm_file_analysis.rst:161-163`.



##########
providers/common/ai/src/airflow/providers/common/ai/mixins/approval.py:
##########
@@ -219,10 +229,12 @@ def execute_complete(self, context: Context, 
generated_output: str, event: dict[
         responded_by_user = event.get("responded_by_user")
         chosen = event["chosen_options"]
         if self.APPROVE not in chosen:
+            if event.get("timedout"):
+                raise HITLRejectException("Output was rejected by the approval 
timeout default.")

Review Comment:
   This message names neither the setting nor the deadline. Something like 
`"Output was rejected automatically: approval_timeout expired with 
on_approval_timeout='reject'."` would tell the operator what happened.
   
   The approve side has the mirror gap: `execute_complete` returns silently, so 
a task that succeeded because nobody reviewed it looks exactly like one a human 
approved (`ApprovalOperator` logs `Approved. Proceeding with downstream 
tasks...` for this reason, `standard/operators/hitl.py:459`). A `log.info` 
naming `responded_by_user or "the approval timeout default"` would close it, 
via the module-level `log` at line 34 since the mixin is not a `BaseOperator`.
   
   Related: `llm_branch.py:152` now renders `Rejected by None. Skipping 
downstream tasks...`, since both wait paths leave `responded_by_user=None` on a 
timeout default.



##########
providers/common/ai/src/airflow/providers/common/ai/mixins/approval.py:
##########
@@ -42,6 +42,7 @@ class DeferForApprovalProtocol(Protocol):
 
     approval_timeout: timedelta | None
     allow_modifications: bool
+    on_approval_timeout: str

Review Comment:
   The Protocol types this `str` while `LLMOperator.__init__` types it 
`Literal["fail", "approve", "reject"]` (`llm.py:126`). Every other member 
mirrors its concrete type (`defer: Any` aside), and the value is only used as a 
dict key, so the same `Literal` here would let mypy catch a bad value at the 
mixin boundary.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to