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


##########
providers/standard/src/airflow/providers/standard/triggers/hitl.py:
##########
@@ -121,14 +121,18 @@ async def _handle_timeout(self) -> TriggerEvent:
         # 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 [])
+            responded_by_user = (
+                HITLUser(id=resp.responded_by_user.id, 
name=resp.responded_by_user.name)
+                if resp.responded_by_user
+                else None
+            )
             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,
+                responded_by_user["name"] if responded_by_user else None,

Review Comment:
   On the replay branch this guard exists for, the message now states the 
opposite of what happened: `[HITL] responded_by=None (id=None) 
options=['Approve'] at 2026-09-09 12:00:00+00:00 (timeout fallback skipped)`. 
The fallback was not skipped. It ran in an earlier incarnation of this trigger, 
which is exactly why the row has a response with no responder, and this run is 
resuming with the result it recorded. 
`test_run_replayed_after_timeout_fallback_has_no_responder` asserts that 
literal string (`test_hitl.py:245-251`), so merging pins the wrong text behind 
a test that reads as deliberate.
   
   Reachability is settled by the guard itself, and it needs only a lapsed 
heartbeat rather than a crash: `Trigger.assign_unassigned` reassigns any 
trigger whose triggerer's `latest_heartbeat` is older than 
`health_check_threshold` (`models/trigger.py:403-424`), and `run()` then 
restarts from the top and lands back in Case 1. It reaches shipped standard 
operators too, not just `common.ai`, since `HITLEntryOperator` defaults to 
`defaults=[OK]` (`operators/hitl.py:555-560`).
   
   Branching on the nullness already computed just above says the right thing 
and drops both `... if responded_by_user else None` re-tests:
   
   ```python
   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,
       )
   ```
   
   This is the trigger-side instance of the earlier point about an unattended 
approval being indistinguishable from a human one: this line is the only place 
an on-call reader could tell them apart, and right now it names no actor and 
gives the wrong cause.
   
   While you are in here, `_handle_response` still has the same shape at 
`:207-208` behind an `if TYPE_CHECKING: assert resp.responded_by_user is not 
None` (`:185`). `HITLDetailResponse.responded_by_user` is `HITLUser | None = 
None` (`task-sdk/src/airflow/sdk/api/datamodels/_generated.py:694`), so that 
assert is false, and it is what let the Case 1 dereference past mypy in the 
first place. I cannot reach it without clock skew between two triggerers, so it 
is not a defect on its own, but a shared `_responder(resp)` helper would cover 
both call sites and let the assert go.



##########
providers/standard/tests/unit/standard/triggers/test_hitl.py:
##########
@@ -206,6 +206,50 @@ async def 
test_run_should_check_response_in_timeout_handler(
             action_datetime,
         )
 
+    @pytest.mark.db_test
+    @pytest.mark.asyncio
+    @mock.patch.object(HITLTrigger, "log")
+    @mock.patch("airflow.sdk.execution_time.hitl.update_hitl_detail_response")

Review Comment:
   This patch target never binds, so `mock_update.assert_not_called()` at 
`:244` cannot fail. `triggers/hitl.py:36-40` imports 
`update_hitl_detail_response` at module load and `:159` calls the trigger 
module's own global, so patching the attribute on the source module leaves the 
caller pointing at the real function. Reproducing the import shape confirms it: 
the source module rebinds to the mock while the consumer's global does not, and 
the consumer still calls the original.
   
   That matters here more than in the tests above, because "the replay must not 
write a second default" is the invariant this test exists to document. The 
other two assertions do fail on main, so the regression coverage is real, but 
the headline guard is decorative. Either 
`@mock.patch("airflow.providers.standard.triggers.hitl.update_hitl_detail_response")`,
 or assert no `UpdateHITLDetail` shows up in 
`mock_supervisor_comms.send.call_args_list`. Worth doing in the same pass as 
the message change, since that also rewrites the `:245-251` assertion.



##########
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:
   This line makes `HITLTrigger` Case 3 reachable from `common.ai` for the 
first time, so the provider now leans on the Case 1 fix in the same PR, but 
`pyproject.toml:72` still floors `apache-airflow-providers-standard>=1.12.1`. I 
checked the released tags: 1.12.1, 1.15.0 and 1.18.0 all still carry the 
`TYPE_CHECKING`-only assert and the unguarded `resp.responded_by_user.name` / 
`.id`. So someone on a 3.1 or 3.2 core who upgrades only `common-ai` gets 
`on_approval_timeout` together with the unfixed trigger, and in the replay 
window the task fails in precisely the scenario `"approve"` exists to rescue.
   
   Bumping the floor to whichever standard release carries the trigger fix 
would close it, with the precedent one line up in the same block 
(`apache-airflow-providers-common-compat>=1.15.0`).



##########
providers/common/ai/src/airflow/providers/common/ai/mixins/approval.py:
##########
@@ -219,10 +229,16 @@ 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 automatically: approval_timeout 
expired with "
+                    "on_approval_timeout='reject'."
+                )
             raise HITLRejectException(f"Output was rejected by the reviewer 
{responded_by_user}.")
 
+        log.info("Output approved by %s.", responded_by_user or "the approval 
timeout default")

Review Comment:
   `responded_by_user` is a `HITLUser` TypedDict, so on the reviewer path this 
prints `Output approved by {'id': 'u1', 'name': 'alice'}.` rather than the 
name, and `llm_branch.py:160-163` has the same shape. The trigger change in 
this PR already formats it as `responded_by_user["name"]` 
(`triggers/hitl.py:134`); the new test passes a bare `"admin"` string, which is 
a shape no production path produces, so it does not surface there.



##########
providers/common/ai/src/airflow/providers/common/ai/operators/llm.py:
##########
@@ -135,8 +141,18 @@ def __init__(
         self._serialize_model_output = serialize_output or not _CORE_WALKER
         self.agent_params = agent_params or {}
         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):

Review Comment:
   `timedelta(0)` is falsy, so `approval_timeout=timedelta(0)` trips this check 
with a message saying the value is unset when it was passed. On 3.3+ that is a 
deadline the core honours: `TaskAwaitingInput(timeout=...)` reaches the 
execution API as `if ti_patch_payload.timeout is not None` 
(`execution_api/routes/task_instances.py:766-768`), so `trigger_timeout` lands 
on `utcnow()` and `check_awaiting_input_timeouts` applies the default on the 
next tick. Pre-3.3 the same value means the opposite, because line 195 tests it 
for truthiness and leaves `timeout_datetime=None`, so the review waits forever.
   
   With the default `"fail"` this guard never fires, so that split pre-dates 
the PR and is not yours to fix here. Swapping in `is not None` alone would 
widen it, so the call is which meaning `timedelta(0)` should carry. At minimum 
the message should not claim `approval_timeout` is missing when it is set.



-- 
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