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

vatsrahul1001 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 63cee22e9be Make airflow dags test wait for Human-in-the-loop input 
instead of hanging (#68492)
63cee22e9be is described below

commit 63cee22e9be3e1901b9002dc6be58198ed09eef1
Author: Kaxil Naik <[email protected]>
AuthorDate: Sun Jun 28 13:20:54 2026 +0100

    Make airflow dags test wait for Human-in-the-loop input instead of hanging 
(#68492)
    
    * Make airflow dags test wait for Human-in-the-loop input instead of hanging
    
    Previously a HITL task that parked in awaiting_input made `airflow dags
    test` loop "No tasks to run. unrunnable tasks: ..." once per second
    forever, with no way to make progress (the in-process runner also
    swallows SIGTERM, so even `timeout` could not stop it).
    
    dag.test() now treats parked HITL tasks as waiting rather than
    unrunnable, and never resolves them itself: the run stays alive, logging
    which tasks await input, until a response recorded from outside flips
    them back to SCHEDULED -- at which point the existing loop resumes them.
    This matches how a parked task behaves on a real deployment, and the
    existing response channels work unchanged: the Required Actions UI or
    the HITL REST API (PATCH .../hitlDetails) of an api-server sharing the
    metadata database (e.g. airflow standalone). Humans and AI agents can
    drive HITL pipelines locally by running dags test and submitting the
    response through that API.
    
    Interactive console prompting in the dags test CLI is left for a
    follow-up.
    
    * Document the HITL REST API calls for responding during dags test
---
 airflow-core/docs/tutorial/hitl.rst         |  31 +++++++++
 airflow-core/tests/unit/models/test_dag.py  | 101 ++++++++++++++++++++++++++++
 task-sdk/src/airflow/sdk/definitions/dag.py |  19 +++++-
 3 files changed, 149 insertions(+), 2 deletions(-)

diff --git a/airflow-core/docs/tutorial/hitl.rst 
b/airflow-core/docs/tutorial/hitl.rst
index 8e89b49d88d..aea843486f4 100644
--- a/airflow-core/docs/tutorial/hitl.rst
+++ b/airflow-core/docs/tutorial/hitl.rst
@@ -196,6 +196,37 @@ When the operator creates an HITL request that is waiting 
for a human response,
    :end-before: [END howto_hitl_entry_operator]
 
 
+Testing HITL Dags locally
+-------------------------
+
+``airflow dags test`` (and the underlying ``dag.test()``) supports HITL tasks. 
A task that reaches
+the ``awaiting_input`` state stays parked -- the test run never resolves it 
itself -- and the run
+waits, logging which tasks await input, until a response is recorded from 
outside. The response
+goes through the same channels as on a real deployment: the Required Actions 
page or the HITL REST
+API (``PATCH .../hitlDetails``) of an api-server sharing the metadata database 
(for example
+``airflow standalone``, or a separately started ``airflow api-server``). Once 
the response lands,
+the test run resumes the task and continues with downstream tasks.
+
+This also lets AI agents drive a HITL pipeline end-to-end locally: run 
``airflow dags test``, watch
+for the waiting log line, ask the human, and submit their answer through the 
HITL REST API. The two
+calls involved (``~`` works as a wildcard for ``dag_id`` and ``dag_run_id``):
+
+.. code-block:: text
+
+    # Discover pending requests (subject, options, params, run/task 
identifiers)
+    GET /api/v2/dags/~/dagRuns/~/hitlDetails?response_received=false
+
+    # Submit the response; the test run resumes the task on its next poll.
+    # map_index is -1 for non-mapped tasks.
+    PATCH 
/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/{map_index}/hitlDetails
+    {"chosen_options": ["Approve"], "params_input": {}}
+
+.. note::
+
+    ``response_timeout`` and timeout defaults are enforced by the scheduler, 
which does not run
+    under ``airflow dags test``. A parked task therefore waits indefinitely 
for a response; supply
+    one through the UI or REST API to let the run finish.
+
 Benefits and Common Use Cases
 -----------------------------
 
diff --git a/airflow-core/tests/unit/models/test_dag.py 
b/airflow-core/tests/unit/models/test_dag.py
index ad4ed2c134d..81198d8e418 100644
--- a/airflow-core/tests/unit/models/test_dag.py
+++ b/airflow-core/tests/unit/models/test_dag.py
@@ -22,6 +22,7 @@ import logging
 import os
 import pickle
 import re
+import time
 from contextlib import nullcontext
 from datetime import timedelta
 from pathlib import Path
@@ -62,8 +63,10 @@ from airflow.models.dagbag import DBDagBag
 from airflow.models.dagbundle import DagBundleModel
 from airflow.models.dagrun import DagRun
 from airflow.models.deadline_alert import DeadlineAlert as DeadlineAlertModel
+from airflow.models.hitl import HITLDetail
 from airflow.models.serialized_dag import SerializedDagModel
 from airflow.models.taskinstance import TaskInstance as TI
+from airflow.models.trigger import handle_event_submit
 from airflow.providers.standard.operators.bash import BashOperator
 from airflow.providers.standard.operators.empty import EmptyOperator
 from airflow.providers.standard.operators.python import PythonOperator
@@ -83,6 +86,8 @@ from airflow.sdk.definitions.asset import Asset, AssetAlias, 
AssetAll, AssetAny
 from airflow.sdk.definitions.callback import AsyncCallback
 from airflow.sdk.definitions.deadline import DeadlineAlert, DeadlineReference
 from airflow.sdk.definitions.param import Param
+from airflow.sdk.exceptions import TaskAwaitingInput
+from airflow.sdk.execution_time.hitl import upsert_hitl_detail
 from airflow.serialization.definitions.dag import SerializedDAG
 from airflow.serialization.encoders import coerce_to_core_timetable
 from airflow.serialization.serialized_objects import LazyDeserializedDAG
@@ -93,6 +98,7 @@ from airflow.timetables.simple import (
     NullTimetable,
     OnceTimetable,
 )
+from airflow.triggers.base import TriggerEvent
 from airflow.utils.file import list_py_file_paths
 from airflow.utils.session import create_session
 from airflow.utils.state import DagRunState, State, TaskInstanceState
@@ -1826,6 +1832,101 @@ class TestDag:
         mock_task_object_1.assert_called()
         mock_task_object_2.assert_not_called()
 
+    @staticmethod
+    def _make_awaiting_input_dag(dag_id, resume_calls):
+        """Build a Dag whose single task parks in AWAITING_INPUT 
(Human-in-the-loop)."""
+
+        class AskOperator(BaseOperator):
+            def execute(self, context):
+                upsert_hitl_detail(
+                    ti_id=context["task_instance"].id,
+                    options=["Approve", "Reject"],
+                    subject="Deploy?",
+                    multiple=False,
+                    params={},
+                )
+                raise TaskAwaitingInput(method_name="execute_complete")
+
+            def execute_complete(self, context, event):
+                resume_calls.append((event["chosen_options"], 
event["params_input"]))
+                return event["chosen_options"]
+
+        dag = DAG(dag_id=dag_id, schedule=None, start_date=DEFAULT_DATE)
+        with dag:
+            AskOperator(task_id="ask")
+        sync_dag_to_db(dag)
+        return dag
+
+    @pytest.mark.execution_timeout(60)
+    def test_dag_test_hitl_task_stays_parked_until_external_response(
+        self, testing_dag_bundle, monkeypatch, caplog
+    ):
+        """
+        The dag.test() contract for Human-in-the-loop: a task that parks in 
AWAITING_INPUT is
+        never resolved by dag.test() itself. The run waits until a response 
recorded from
+        outside (here through an independent session, the way the API response 
handler does)
+        flips it back to SCHEDULED, at which point the loop resumes it.
+
+        The loop's time.sleep is the synchronization point: patching it to 
deliver the
+        external response keeps the test deterministic, with no real-time 
waits.
+        """
+        resume_calls: list = []
+        dag = self._make_awaiting_input_dag("test_dag_test_hitl_external", 
resume_calls)
+
+        parked_states_seen = []
+        spins = 0
+
+        def deliver_external_response():
+            """Record an Approve through an independent session, as the API 
handler would."""
+            with create_session(scoped=False) as external_session:
+                parked_ti = external_session.scalar(
+                    select(TI).where(
+                        TI.dag_id == "test_dag_test_hitl_external",
+                        TI.task_id == "ask",
+                        TI.state == TaskInstanceState.AWAITING_INPUT,
+                    )
+                )
+                if parked_ti is None:
+                    return
+                parked_states_seen.append(parked_ti.state)
+                detail = external_session.get(HITLDetail, parked_ti.id)
+                detail.chosen_options = ["Approve"]
+                detail.params_input = {}
+                detail.responded_at = timezone.utcnow()
+                detail.responded_by = {"id": "external", "name": "external"}
+                external_session.add(detail)
+                handle_event_submit(
+                    TriggerEvent(detail.as_resume_event_payload()),
+                    task_instance=parked_ti,
+                    session=external_session,
+                )
+
+        def respond_once_waiting(seconds):
+            # Replaces the loop's real sleep to keep the test fast, and 
delivers the response
+            # only once dag.test() has logged that it is parked on the HITL 
task -- so the
+            # assertions below prove the task resumed through the new 
awaiting_input branch
+            # rather than any other path.
+            nonlocal spins
+            spins += 1
+            assert spins < 50, "dag.test() never logged that it was waiting on 
the parked task"
+            if "Waiting for Human-in-the-loop input" in caplog.text:
+                deliver_external_response()
+
+        monkeypatch.setattr(time, "sleep", respond_once_waiting)
+
+        with caplog.at_level(logging.INFO, 
logger="airflow.sdk.definitions.dag"):
+            dr = dag.test()
+
+        # The task must take the new "waiting for input" branch, not the old 
"unrunnable" one.
+        assert "Waiting for Human-in-the-loop input" in caplog.text
+        assert "No tasks to run" not in caplog.text
+
+        ti = dr.get_task_instance("ask")
+        assert ti is not None
+        assert ti.state == TaskInstanceState.SUCCESS
+        assert parked_states_seen == [TaskInstanceState.AWAITING_INPUT]
+        assert resume_calls == [(["Approve"], {})]
+
     def test_dag_connection_file(self, tmp_path, testing_dag_bundle):
         test_connections_string = """
 ---
diff --git a/task-sdk/src/airflow/sdk/definitions/dag.py 
b/task-sdk/src/airflow/sdk/definitions/dag.py
index 46794edbe4a..42924fb6aae 100644
--- a/task-sdk/src/airflow/sdk/definitions/dag.py
+++ b/task-sdk/src/airflow/sdk/definitions/dag.py
@@ -1404,8 +1404,23 @@ class DAG:
                 # triggerer may mark tasks scheduled so we read from DB
                 all_tis = set(dr.get_task_instances(session=session))
                 scheduled_tis = {x for x in all_tis if x.state == 
TaskInstanceState.SCHEDULED}
-                ids_unrunnable = {x for x in all_tis if x.state not in 
FINISHED_STATES} - scheduled_tis
-                if not scheduled_tis and ids_unrunnable:
+                awaiting_input_tis = {x for x in all_tis if x.state == 
TaskInstanceState.AWAITING_INPUT}
+                ids_unrunnable = (
+                    {x for x in all_tis if x.state not in FINISHED_STATES}
+                    - scheduled_tis
+                    - awaiting_input_tis
+                )
+                if not scheduled_tis and awaiting_input_tis:
+                    # Human-in-the-loop tasks stay parked in AWAITING_INPUT: 
dag.test() never
+                    # resolves them itself. Keep the run alive until a 
response recorded from
+                    # outside -- the Required Actions UI or the HITL REST API 
of an api-server
+                    # sharing this metadata DB -- flips them back to SCHEDULED.
+                    log.info(
+                        "Waiting for Human-in-the-loop input for tasks: %s",
+                        sorted(x.task_id for x in awaiting_input_tis),
+                    )
+                    time.sleep(1)
+                elif not scheduled_tis and ids_unrunnable:
                     log.warning("No tasks to run. unrunnable tasks: %s", 
ids_unrunnable)
                     time.sleep(1)
 

Reply via email to