Repository: apache/airflow

Issue:
Setup/Teardown in nested Task Groups creating unwanted dependencies with downstream tasks
### Apache Airflow version

2.8.0

### If "Other Airflow 2 version" selected, which one?

_No response_

### What happened?

The code below, in the "How to reproduce" section, produces the following DAG, I think there is an extra connection rendered between `test_task` and `outer_end`.
![Screenshot from 2023-12-20 16-09-22](https://github.com/apache/airflow/assets/43483037/a96c96da-26b1-4e37-94fa-f771802f9e0f)


### What you think should happen instead?

I would expect that `middle_end` is executed after `test_group` and, that `outer_end` is executed after `test_group2`. Since `inner_end` is a teardown task it is not considered a leaf of `test_group`, thus the only leaf of `test_group` is `test_task`. So, I would expect only the connection between `test_task` and `middle_end`, and **not** the connection between `test_task` and `outer_end`.

### How to reproduce

Ddecorate the following function to become a DAG and call it to create the DAG:
```python
def nesting_groups_test():
    @task
    def test_task():
        print("Hello world!")

    @task_group
    def test_group():
        inner_start = EmptyOperator(task_id="inner_start")
        inner_end = EmptyOperator(task_id="inner_end")

        test_task_r = test_task()
        inner_start >> test_task_r >> inner_end.as_teardown(setups=inner_start)

    @task_group
    def test_group2():
        middle_end = EmptyOperator(task_id="middle_end")
        test_group_r = test_group()
        test_group_r >> middle_end

    outer_start = EmptyOperator(task_id="outer_start")
    outer_end = EmptyOperator(task_id="outer_end")
    test_group2_r = test_group2()
    outer_start >> test_group2_r >> outer_end
```

### Operating System

Ubuntu 22.04.3 LTS

### Versions of Apache Airflow Providers

_No response_

### Deployment

Docker-Compose

### Deployment details

_No response_

### Anything else?

This seems to be just a rendering issue since I've done some simple tests and the tasks have been executed in order, but I'm not 100% sure.

### Are you willing to submit PR?

- [ ] Yes I am willing to submit a PR!

### Code of Conduct

- [X] I agree to follow this project's [Code of Conduct](https://github.com/apache/airflow/blob/main/CODE_OF_CONDUCT.md)



Discussion:
Thanks for opening your first issue here! Be sure to follow the issue template! If you are willing to raise PR to address this issue please do so, no need to wait for approval.

CC: @dstandish - I might take a close look (I want to learn more about the setup/teardown implementation) - possibly after holidays but If you happen to be around and **know** by heart that could be also helpful :)
Looks like bug, thanks.

Resolving patch:
diff --git a/airflow/utils/task_group.py b/airflow/utils/task_group.py
--- a/airflow/utils/task_group.py
+++ b/airflow/utils/task_group.py
@@ -371,6 +371,16 @@ def get_leaves(self) -> Generator[BaseOperator, None, None]:
         tasks = list(self)
         ids = {x.task_id for x in tasks}
 
+        def has_non_teardown_downstream(task, exclude: str):
+            for down_task in task.downstream_list:
+                if down_task.task_id == exclude:
+                    continue
+                elif down_task.task_id not in ids:
+                    continue
+                elif not down_task.is_teardown:
+                    return True
+            return False
+
         def recurse_for_first_non_teardown(task):
             for upstream_task in task.upstream_list:
                 if upstream_task.task_id not in ids:
@@ -381,7 +391,8 @@ def recurse_for_first_non_teardown(task):
                 elif task.is_teardown and upstream_task.is_setup:
                     # don't go through the teardown-to-setup path
                     continue
-                else:
+                # return unless upstream task already has non-teardown downstream in group
+                elif not has_non_teardown_downstream(upstream_task, exclude=task.task_id):
                     yield upstream_task
 
         for task in tasks:


Test patch:
diff --git a/tests/utils/test_task_group.py b/tests/utils/test_task_group.py
--- a/tests/utils/test_task_group.py
+++ b/tests/utils/test_task_group.py
@@ -584,6 +584,53 @@ def test_dag_edges_setup_teardown():
     ]
 
 
+def test_dag_edges_setup_teardown_nested():
+    from airflow.decorators import task, task_group
+    from airflow.models.dag import DAG
+    from airflow.operators.empty import EmptyOperator
+
+    execution_date = pendulum.parse("20200101")
+
+    with DAG(dag_id="s_t_dag", start_date=execution_date) as dag:
+
+        @task
+        def test_task():
+            print("Hello world!")
+
+        @task_group
+        def inner():
+            inner_start = EmptyOperator(task_id="start")
+            inner_end = EmptyOperator(task_id="end")
+
+            test_task_r = test_task.override(task_id="work")()
+            inner_start >> test_task_r >> inner_end.as_teardown(setups=inner_start)
+
+        @task_group
+        def outer():
+            outer_work = EmptyOperator(task_id="work")
+            inner_group = inner()
+            inner_group >> outer_work
+
+        dag_start = EmptyOperator(task_id="dag_start")
+        dag_end = EmptyOperator(task_id="dag_end")
+        dag_start >> outer() >> dag_end
+
+    edges = dag_edges(dag)
+
+    actual = sorted((e["source_id"], e["target_id"], e.get("is_setup_teardown")) for e in edges)
+    assert actual == [
+        ("dag_start", "outer.upstream_join_id", None),
+        ("outer.downstream_join_id", "dag_end", None),
+        ("outer.inner.downstream_join_id", "outer.work", None),
+        ("outer.inner.start", "outer.inner.end", True),
+        ("outer.inner.start", "outer.inner.work", None),
+        ("outer.inner.work", "outer.inner.downstream_join_id", None),
+        ("outer.inner.work", "outer.inner.end", None),
+        ("outer.upstream_join_id", "outer.inner.start", None),
+        ("outer.work", "outer.downstream_join_id", None),
+    ]
+
+
 def test_duplicate_group_id():
     from airflow.exceptions import DuplicateTaskIdFound