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

jason810496 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git

commit c5f504366d5835d2028611ee871fe9b4fab4af9f
Author: Jason(Zhe-You) Liu <[email protected]>
AuthorDate: Fri Oct 9 14:30:53 2026 +0800

    Java SDK: Trigger another Dag's run from a native Java Dag (#74440)
    
    * Java SDK: Trigger another Dag's run from a native Java Dag
    
    A native Java Dag could not start another Dag's run. Airflow's
    TriggerDagRunOperator is Python, and a Java bundle has no Python Dag file to
    declare one in, so a Java Dag that fans out to another Dag needed a Python
    counterpart for that one task.
    
    A `TriggerDagRun` passed to `dag.task` declares a task that has no Java body
    to run; the Java runtime runs it as `TriggerDagRunOperator` does. Its
    settings are named as Python names the operator's parameters:
    
    ```java
    dag.task("trigger_downstream",
        new TriggerDagRun("downstream_etl")
            .config("wait_for_completion", true)
            .config("conf", Map.of("rows", 2)));
    ```
    
    An annotated Dag declares one from an ordinary `@Builder.Task` method that
    returns a `TriggerDagRun`. The serialized Dag has to carry what is
    triggered, so that method runs when the Dag is built rather than when the
    task runs, and therefore takes no parameters.
    
    The task serializes as Python's own `TriggerDagRunOperator`: its template
    fields, its UI colour and extra link, and a `dag_dependencies` entry, so the
    Airflow UI draws the dependency between the two Dags. Python writes only an
    operator's template fields and keeps the rest in the Dag file; a Java Dag
    has no such file, so every setting the author made is written too.
    
    The runtime records the triggered run's link and ID as XComs, skips or fails
    on a run that already exists, and waits for completion either by polling or,
    with `deferrable`, by deferring to the Python triggerer's DagStateTrigger 
and
    resuming from the event it fires. That makes this the first Java runtime to
    emit DeferTask and a skipped TaskState.
    
    `scripts/ci/lang_sdk_serialization/test_dags.yaml` now gives the shared
    trigger Dag a real logical date instead of a Jinja template, because the SDK
    takes one as a date-time. The Go SDK is making the same change in #74417;
    whichever lands second drops that one line, and until then the Go
    serialization conformance check reports that single difference.
    
    * Java SDK: Point both example triggers at a Dag that triggers nothing
    
    * Java SDK: Say why a switch cannot choose a task that triggers a Dag run
    
    * Java SDK: Snapshot a TriggerDagRun when its task is declared
    
    * Java SDK: Check a TriggerDagRun setting the way every other config key is 
checked
    
    * Java SDK: Describe what a trigger task pushes and how it names a run
    
    * Java SDK: Correct which runtimes already run trigger tasks
    
    * Java SDK: Format a trigger task's date-times in one place
    
    * Java SDK: Reject a trigger method that throws a checked exception
    
    * Java SDK: Give the trigger fixture a logical date every SDK can write
    
    * Java SDK: Run a trigger task through runTask in tests
    
    * Java SDK: Check a trigger task polls the run it started
---
 .pre-commit-config.yaml                            |   2 +-
 java-sdk/README.md                                 |   4 +-
 java-sdk/capabilities.yaml                         |  14 +-
 .../airflow/example/ExampleBundleBuilder.java      |   3 +-
 .../example/nativedag/AnnotationExample.java       |  11 +-
 .../example/nativedag/InterfaceExample.java        |   6 +
 .../airflow/example/nativedag/TargetExample.java   |  49 +++
 .../org/apache/airflow/sdk/BuilderProcessor.kt     |  58 ++-
 .../kotlin/org/apache/airflow/sdk/BuilderTest.kt   | 203 +++++++++
 .../ci/prek/check_serialization_conformance.py     |   2 +-
 java-sdk/sdk/module.md                             |   4 +-
 .../main/kotlin/org/apache/airflow/sdk/DagDef.kt   |  43 ++
 .../main/kotlin/org/apache/airflow/sdk/Switch.kt   |   8 +-
 .../kotlin/org/apache/airflow/sdk/TaskGroupRef.kt  |  19 +
 .../kotlin/org/apache/airflow/sdk/TriggerDagRun.kt | 189 ++++++++
 .../org/apache/airflow/sdk/execution/Client.kt     |  74 ++++
 .../org/apache/airflow/sdk/execution/Logger.kt     |   5 +
 .../org/apache/airflow/sdk/execution/Serde.kt      | 127 +++++-
 .../org/apache/airflow/sdk/execution/Task.kt       |  13 +
 .../apache/airflow/sdk/execution/TriggerRunner.kt  | 324 ++++++++++++++
 .../org/apache/airflow/sdk/internal/Fields.kt      |  33 ++
 .../org/apache/airflow/sdk/ArgTestSupport.kt       |  17 +
 .../kotlin/org/apache/airflow/sdk/ClientTest.kt    |  17 +
 .../kotlin/org/apache/airflow/sdk/SwitchTest.kt    |  17 +
 .../org/apache/airflow/sdk/TriggerDagRunTest.kt    | 275 ++++++++++++
 .../airflow/sdk/conformance/SerializeJava.kt       |  40 +-
 .../org/apache/airflow/sdk/execution/CommTest.kt   | 144 +++++++
 .../org/apache/airflow/sdk/execution/TaskTest.kt   |  71 ++++
 .../airflow/sdk/execution/TriggerRunnerTest.kt     | 473 +++++++++++++++++++++
 .../apache/airflow/sdk/internal/ArgValuesTest.kt   |  17 +
 scripts/ci/lang_sdk_serialization/test_dags.yaml   |   5 +-
 31 files changed, 2220 insertions(+), 47 deletions(-)

diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 836f020378a..3bbe42de850 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -461,7 +461,7 @@ repos:
           ^java-sdk/scripts/ci/prek/check_serialization_conformance\.py$|
           ^java-sdk/sdk/schema/dag-schema\.json$|
           ^java-sdk/sdk/build\.gradle\.kts$|
-          
^java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/(Switch|Bundle|Condition|DagDef|Decider|Deps|Arg|TaskGroupRef)\.kt$|
+          
^java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/(Switch|Bundle|Condition|DagDef|Decider|Deps|Arg|TaskGroupRef|TriggerDagRun)\.kt$|
           
^java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/execution/Serde\.kt$|
           
^java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/internal/(Refs|Fields)\.kt$|
           ^java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/conformance/.*$|
diff --git a/java-sdk/README.md b/java-sdk/README.md
index 9b9653a4974..fda035f80dc 100644
--- a/java-sdk/README.md
+++ b/java-sdk/README.md
@@ -620,8 +620,8 @@ prek hook regenerate it.
 | state: `success` | MUST | ✓ | 3.3 |  |
 | state: `failed` | MUST | ✓ | 3.3 |  |
 | state: `up_for_retry` | MUST | ✓ | 3.3 | RetryTask |
-| state: `skipped` | SHOULD | ✗ | – | runtime does not emit TaskState skipped 
yet |
-| state: `deferred` | MAY | ✗ | – | runtime does not emit DeferTask yet |
+| state: `skipped` | SHOULD | ✓ | 3.4 | a TriggerDagRun task that skips an 
existing run |
+| state: `deferred` | MAY | ✓ | 3.4 | a TriggerDagRun task that waits with 
deferrable |
 | state: `up_for_reschedule` | MAY | ✗ | – | runtime does not emit 
RescheduleTask yet |
 | state: `awaiting_input` | MAY | ✗ | – | runtime does not emit AwaitInputTask 
yet |
 | state: `removed` | MAY | ✓ | 3.3 |  |
diff --git a/java-sdk/capabilities.yaml b/java-sdk/capabilities.yaml
index 5a2ed1f5ae4..84ee13a9dda 100644
--- a/java-sdk/capabilities.yaml
+++ b/java-sdk/capabilities.yaml
@@ -23,8 +23,8 @@ min_airflow_version: "3.3"
 # JAR manifest. The render hook fails if the two disagree.
 supervisor_schema_version: "2026-10-30"
 
-# The runtime terminates a task with SucceedTask, RetryTask, or TaskState 
(failed/removed); it does
-# not yet emit skipped, DeferTask, RescheduleTask, or AwaitInputTask.
+# The runtime terminates a task with SucceedTask, RetryTask, DeferTask, or 
TaskState
+# (failed/skipped/removed); it does not yet emit RescheduleTask or 
AwaitInputTask.
 states:
   success:
     supported: true
@@ -37,11 +37,13 @@ states:
     since: "3.3"
     note: "RetryTask"
   skipped:
-    supported: false
-    note: "runtime does not emit TaskState skipped yet"
+    supported: true
+    since: "3.4"
+    note: "a TriggerDagRun task that skips an existing run"
   deferred:
-    supported: false
-    note: "runtime does not emit DeferTask yet"
+    supported: true
+    since: "3.4"
+    note: "a TriggerDagRun task that waits with deferrable"
   up_for_reschedule:
     supported: false
     note: "runtime does not emit RescheduleTask yet"
diff --git 
a/java-sdk/example/src/java/org/apache/airflow/example/ExampleBundleBuilder.java
 
b/java-sdk/example/src/java/org/apache/airflow/example/ExampleBundleBuilder.java
index 76175db8a6d..30da2aa8e11 100644
--- 
a/java-sdk/example/src/java/org/apache/airflow/example/ExampleBundleBuilder.java
+++ 
b/java-sdk/example/src/java/org/apache/airflow/example/ExampleBundleBuilder.java
@@ -30,7 +30,8 @@ public class ExampleBundleBuilder {
         .register(AnnotationExample.class)
         .register(XComCastingExample.class)
         .register(org.apache.airflow.example.nativedag.AnnotationExample.class)
-        
.register(org.apache.airflow.example.nativedag.InterfaceExample.build());
+        
.register(org.apache.airflow.example.nativedag.InterfaceExample.build())
+        .register(org.apache.airflow.example.nativedag.TargetExample.build());
   }
 
   public static void main(String[] args) {
diff --git 
a/java-sdk/example/src/java/org/apache/airflow/example/nativedag/AnnotationExample.java
 
b/java-sdk/example/src/java/org/apache/airflow/example/nativedag/AnnotationExample.java
index 16cdc555452..0bb55ceacd0 100644
--- 
a/java-sdk/example/src/java/org/apache/airflow/example/nativedag/AnnotationExample.java
+++ 
b/java-sdk/example/src/java/org/apache/airflow/example/nativedag/AnnotationExample.java
@@ -85,6 +85,13 @@ public class AnnotationExample {
         : AnnotationExampleBuilder.ReportShort.class;
   }
 
+  // A task that starts a run of another Dag. The method runs when this Dag is
+  // built, not when the task runs, so it takes no arguments.
+  @Builder.Task(id = "trigger_downstream")
+  public TriggerDagRun triggerDownstream() {
+    return new TriggerDagRun("java_native_target_example").config("note", 
"from the annotation example");
+  }
+
   // A task group: everything it declares is prefixed with its id, so this is
   // the task "checks.audit".
   @Builder.TaskGroup(id = "checks")
@@ -102,8 +109,10 @@ public class AnnotationExample {
     void depends() {
       var extracted = extract();
       var transformed = transform(extracted, lit(1.5));
-      hasRows(transformed).Then(load(transformed)).Else(loadEmpty());
+      var loaded = load(transformed);
+      hasRows(transformed).Then(loaded).Else(loadEmpty());
       pickReport(transformed).Case(reportLong()).Case(reportShort());
+      loaded.before(triggerDownstream());
       // Ordering-only edge: the checks group runs after extract, with no data
       // flowing.
       extracted.before(checks());
diff --git 
a/java-sdk/example/src/java/org/apache/airflow/example/nativedag/InterfaceExample.java
 
b/java-sdk/example/src/java/org/apache/airflow/example/nativedag/InterfaceExample.java
index b8099931b7a..fcf095ee72e 100644
--- 
a/java-sdk/example/src/java/org/apache/airflow/example/nativedag/InterfaceExample.java
+++ 
b/java-sdk/example/src/java/org/apache/airflow/example/nativedag/InterfaceExample.java
@@ -114,12 +114,18 @@ public class InterfaceExample {
 
     var reportLong = dag.task("report_long", ReportLong.class);
     var reportShort = dag.task("report_short", ReportShort.class);
+    // A task that starts a run of another Dag; it runs no Java code.
+    var trigger =
+        dag.task(
+            "trigger_downstream",
+            new 
TriggerDagRun("java_native_target_example").config("wait_for_completion", 
false));
 
     transform.after(extract);
     // With no task id given, a decider takes one from its class: "hasRows" and
     // "pickReport".
     dag.If(HasRows.class).after(transform).Then(load).Else(loadEmpty);
     
dag.Switch(PickReport.class).after(transform).Case(reportLong).Case(reportShort);
+    load.before(trigger);
     return dag;
   }
 }
diff --git 
a/java-sdk/example/src/java/org/apache/airflow/example/nativedag/TargetExample.java
 
b/java-sdk/example/src/java/org/apache/airflow/example/nativedag/TargetExample.java
new file mode 100644
index 00000000000..cf8d4514295
--- /dev/null
+++ 
b/java-sdk/example/src/java/org/apache/airflow/example/nativedag/TargetExample.java
@@ -0,0 +1,49 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+// "native" is a Java keyword, so the native-Dag examples live in "nativedag".
+package org.apache.airflow.example.nativedag;
+
+import static java.lang.System.Logger.Level.INFO;
+
+import java.util.List;
+import org.apache.airflow.sdk.*;
+
+// The Dag the other two native examples trigger. It triggers nothing itself,
+// so the examples cannot trigger each other in a loop.
+public class TargetExample {
+  private static final System.Logger log = 
System.getLogger(TargetExample.class.getName());
+
+  public static class Receive implements Task {
+    @Override
+    public void execute(Context context, Client client) {
+      log.log(INFO, "Triggered by another Dag");
+    }
+  }
+
+  public static DagDef build() {
+    var dag =
+        new DagDef("java_native_target_example")
+            .config("description", "Pure-Java Dag that the other native 
examples trigger")
+            .config("catchup", false)
+            .config("tags", List.of("example", "java-sdk"));
+    dag.task("receive", Receive.class);
+    return dag;
+  }
+}
diff --git 
a/java-sdk/processor/src/main/kotlin/org/apache/airflow/sdk/BuilderProcessor.kt 
b/java-sdk/processor/src/main/kotlin/org/apache/airflow/sdk/BuilderProcessor.kt
index 97b810358a0..e101c0e8572 100644
--- 
a/java-sdk/processor/src/main/kotlin/org/apache/airflow/sdk/BuilderProcessor.kt
+++ 
b/java-sdk/processor/src/main/kotlin/org/apache/airflow/sdk/BuilderProcessor.kt
@@ -278,7 +278,7 @@ class BuilderProcessor : AbstractProcessor() {
     )
     builderClass.addMethod(buildMethod.build())
 
-    declarations.forEach { builderClass.addType(buildTask(it)) }
+    declarations.filterNot { it.kind == TaskKind.TRIGGER }.forEach { 
builderClass.addType(buildTask(it)) }
     return builderClass.build()
   }
 
@@ -366,8 +366,11 @@ class BuilderProcessor : AbstractProcessor() {
         .methodBuilder(decl.method.simpleName.toString())
         .addModifiers(Modifier.PUBLIC, Modifier.DEFAULT)
         .returns(
-          decl.kind.refType
-            ?: ParameterizedTypeName.get(TASK_HANDLE_TYPE, 
TypeName.get(decl.method.returnType).boxIfPossible()),
+          when {
+            decl.kind.refType != null -> decl.kind.refType
+            decl.kind == TaskKind.TRIGGER -> 
ParameterizedTypeName.get(TASK_HANDLE_TYPE, VOID_TYPE)
+            else -> ParameterizedTypeName.get(TASK_HANDLE_TYPE, 
TypeName.get(decl.method.returnType).boxIfPossible())
+          },
         )
     decl.dataParams.forEach { method.addParameter(inType(it.type), it.name) }
     val def = taskDefCode(decl, CodeBlock.of($$"$T.$L", builderName, 
decl.className))
@@ -411,7 +414,13 @@ class BuilderProcessor : AbstractProcessor() {
     val taskDef =
       CodeBlock
         .builder()
-        .add($$"new $T($S, $L.class)", TASK_DEF_TYPE, decl.id, classRef)
+        .apply {
+          if (decl.kind == TaskKind.TRIGGER) {
+            add($$"new $T($S, new $T().$L())", TASK_DEF_TYPE, decl.id, 
ClassName.get(decl.owner), decl.method.simpleName)
+          } else {
+            add($$"new $T($S, $L.class)", TASK_DEF_TYPE, decl.id, classRef)
+          }
+        }
     explicitConfig(decl.method, decl.kind.annotation, 
TASK_STRUCTURAL_ATTRIBUTES, SchemaFields.TASK).forEach { (key, value) ->
       taskDef.add($$".config($S, $L)", key, value)
     }
@@ -438,17 +447,33 @@ class BuilderProcessor : AbstractProcessor() {
     for (inner in el.enclosedElements) {
       if (inner !is ExecutableElement) continue
       val declared =
-        TaskKind.entries.filter { kind -> inner.annotationMirrors.any { 
it.names(kind.annotation) } }
+        TaskKind.declaring.filter { kind -> inner.annotationMirrors.any { 
it.names(kind.annotation) } }
       if (declared.isEmpty()) continue
-      val kind =
+      val declaredKind =
         declared.singleOrNull()
           ?: throw IllegalArgumentException(
             "Method '${inner.simpleName}' carries ${declared.joinToString { 
it.spelling }}; a task is " +
               "declared by one of them alone",
           )
+      // A task method that hands back a TriggerDagRun declares what to trigger
+      // rather than a body to run, so it is read when the Dag is built.
+      val triggers =
+        declaredKind == TaskKind.TASK && with(processingEnv) { 
isType(inner.returnType, TRIGGER_TYPE) }
+      val kind = if (triggers) TaskKind.TRIGGER else declaredKind
       val annotated = declaredId(inner, kind)
       if (inner.isVarArgs) throw IllegalArgumentException("Cannot create task 
from vararg function ${inner.simpleName}")
       checkDeciderReturn(kind, inner)
+      if (kind == TaskKind.TRIGGER) {
+        require(inner.parameters.isEmpty()) {
+          "@Builder.Task method '${inner.simpleName}' returns a TriggerDagRun, 
so it runs when the Dag " +
+            "is built rather than when the task runs; it takes no parameters"
+        }
+        val checked = inner.thrownTypes.filterNot { isUnchecked(it) }
+        require(checked.isEmpty()) {
+          "@Builder.Task method '${inner.simpleName}' returns a TriggerDagRun, 
so it runs when the Dag " +
+            "is built, where a checked exception cannot be thrown; it must not 
throw: ${checked.joinToString()}"
+        }
+      }
       val localId = annotated.ifBlank { inner.simpleName.toString() }
       require(tasks.none { 
it.method.simpleName.contentEquals(inner.simpleName) }) {
         "Class ${el.simpleName} overloads task method '${inner.simpleName}'; a 
method's name is the " +
@@ -572,7 +597,8 @@ class BuilderProcessor : AbstractProcessor() {
       }
     }
     val byClassName = mutableMapOf<String, TaskDeclaration>()
-    declarations.forEach { decl ->
+    // A task that triggers a Dag run has no generated class to collide with.
+    declarations.filterNot { it.kind == TaskKind.TRIGGER }.forEach { decl ->
       byClassName.put(decl.className, decl)?.let { first ->
         throw IllegalArgumentException(
           "Task methods '${first.id}' and '${decl.id}' both generate the task 
class " +
@@ -694,6 +720,8 @@ class BuilderProcessor : AbstractProcessor() {
         parseTemporal(field, text) { OffsetDateTime.parse(text) }
         CodeBlock.of($$"$T.parse($S)", 
ClassName.get(OffsetDateTime::class.java), text)
       }
+      FieldType.DAG_RUN_STATES, FieldType.JSON_OBJECT ->
+        error("Field '${field.key}' is not an annotation attribute")
     }
 
   private fun parseTemporal(
@@ -718,7 +746,7 @@ class BuilderProcessor : AbstractProcessor() {
   ) {
     val returns = method.returnType
     when (kind) {
-      TaskKind.TASK -> return
+      TaskKind.TASK, TaskKind.TRIGGER -> return
       TaskKind.CONDITION ->
         require(returns.kind == TypeKind.BOOLEAN || with(processingEnv) { 
isType(returns, BOXED_BOOLEAN_TYPE) }) {
           "@Builder.If method '${method.simpleName}' returns $returns, but a 
condition returns boolean: " +
@@ -760,7 +788,7 @@ class BuilderProcessor : AbstractProcessor() {
         .addModifiers(Modifier.PUBLIC)
         .returns(
           when (decl.kind) {
-            TaskKind.TASK -> TypeName.VOID
+            TaskKind.TASK, TaskKind.TRIGGER -> TypeName.VOID
             TaskKind.CONDITION -> TypeName.BOOLEAN
             TaskKind.SWITCH -> TASK_CLASS_TYPE
           },
@@ -1008,6 +1036,8 @@ private val SWITCH_TASK_TYPE = 
ClassName.get(SwitchTask::class.java)
 private val SWITCH_REF_TYPE = ClassName.get(SwitchRef::class.java)
 private val TASK_CLASS_TYPE =
   ParameterizedTypeName.get(ClassName.get(Class::class.java), 
WildcardTypeName.subtypeOf(TASK_TYPE))
+private val TRIGGER_TYPE = ClassName.get(TriggerDagRun::class.java)
+private val VOID_TYPE = ClassName.get("java.lang", "Void")
 private val BOXED_BOOLEAN_TYPE = ClassName.get("java.lang", "Boolean")
 private val DEPS_TYPE = ClassName.get(Deps::class.java)
 private val GROUP_TYPE = DEPS_TYPE.nestedClass("TaskGroup")
@@ -1029,8 +1059,18 @@ private enum class TaskKind(
   val refType: ClassName?,
 ) {
   TASK("org.apache.airflow.sdk.Builder.Task", "@Builder.Task", "execute", 
TASK_TYPE, null),
+  TRIGGER("org.apache.airflow.sdk.Builder.Task", "@Builder.Task", "execute", 
TASK_TYPE, null),
   CONDITION("org.apache.airflow.sdk.Builder.If", "@Builder.If", "decide", 
CONDITION_TASK_TYPE, CONDITION_REF_TYPE),
   SWITCH("org.apache.airflow.sdk.Builder.Switch", "@Builder.Switch", "choose", 
SWITCH_TASK_TYPE, SWITCH_REF_TYPE),
+  ;
+
+  companion object {
+    /**
+     * The kinds an annotation declares. TRIGGER is left out: it shares
+     * [TASK]'s annotation and is told apart by the method's return type.
+     */
+    val declaring = listOf(TASK, CONDITION, SWITCH)
+  }
 }
 
 /** Whether this annotation is the one [name] qualifies. */
diff --git 
a/java-sdk/processor/src/test/kotlin/org/apache/airflow/sdk/BuilderTest.kt 
b/java-sdk/processor/src/test/kotlin/org/apache/airflow/sdk/BuilderTest.kt
index 2473ef9561a..5d8c89a058b 100644
--- a/java-sdk/processor/src/test/kotlin/org/apache/airflow/sdk/BuilderTest.kt
+++ b/java-sdk/processor/src/test/kotlin/org/apache/airflow/sdk/BuilderTest.kt
@@ -2452,6 +2452,209 @@ class BuilderTest {
       )
   }
 
+  @Test
+  @DisplayName("read a task that triggers a Dag run when the Dag is built")
+  fun generateTriggerDagRunTask() {
+    val compilation =
+      compile(
+        """
+        package org.apache.airflow.example;
+
+        import org.apache.airflow.sdk.Builder;
+        import org.apache.airflow.sdk.TriggerDagRun;
+
+        @Builder.Dag(id = "etl")
+        public class TestExample {
+          @Builder.Task(id = "trigger_downstream", retries = 2)
+          public TriggerDagRun triggerDownstream() {
+            return new 
TriggerDagRun("downstream_etl").config("wait_for_completion", true);
+          }
+
+          @Builder.Deps
+          static class Wiring implements TestExampleDeps {
+            void depends() {
+              triggerDownstream();
+            }
+          }
+        }
+      """,
+      )
+
+    assertThat(compilation).succeeded()
+    assertThat(compilation)
+      .generatedSourceFile("org.apache.airflow.example.TestExampleBuilder")
+      .hasSourceEquivalentTo(
+        "org.apache.airflow.example.TestExampleBuilder",
+        """
+         package org.apache.airflow.example;
+
+         import java.util.List;
+         import org.apache.airflow.sdk.DagDef;
+         import org.apache.airflow.sdk.internal.DagSource;
+         import org.apache.airflow.sdk.internal.Refs;
+
+         public final class TestExampleBuilder {
+           public static DagDef build() {
+             var dag = DagSource.declaredBy(new DagDef("etl"), 
TestExample.class);
+             return Refs.record(dag, List.of("trigger_downstream"), List.of(), 
new TestExample.Wiring()::depends);
+           }
+         }
+        """,
+      )
+    assertThat(compilation)
+      .generatedSourceFile("org.apache.airflow.example.TestExampleDeps")
+      .hasSourceEquivalentTo(
+        "org.apache.airflow.example.TestExampleDeps",
+        """
+         package org.apache.airflow.example;
+
+         import java.lang.Void;
+         import org.apache.airflow.sdk.Deps;
+         import org.apache.airflow.sdk.TaskDef;
+         import org.apache.airflow.sdk.TaskRef;
+         import org.apache.airflow.sdk.internal.Refs;
+
+         public interface TestExampleDeps extends Deps {
+           default TaskRef<Void> triggerDownstream() {
+             return Refs.node("", new TaskDef("trigger_downstream", new 
TestExample().triggerDownstream()).config("retries", 2));
+           }
+         }
+        """,
+      )
+  }
+
+  @Test
+  @DisplayName("build a Dag that reuses one task handle as a condition side 
and as an upstream")
+  fun buildADagThatReusesATaskHandle() {
+    val compilation =
+      compile(
+        """
+        package org.apache.airflow.example;
+
+        import org.apache.airflow.sdk.Builder;
+        import org.apache.airflow.sdk.TriggerDagRun;
+
+        @Builder.Dag(id = "etl")
+        public class TestExample {
+          @Builder.Task
+          public long extract() {
+            return 42L;
+          }
+
+          @Builder.Task
+          public void load(long rows) {}
+
+          @Builder.Task
+          public void loadEmpty() {}
+
+          @Builder.If(id = "has_rows")
+          public boolean hasRows(long rows) {
+            return rows > 0;
+          }
+
+          @Builder.Task(id = "trigger_downstream")
+          public TriggerDagRun triggerDownstream() {
+            return new TriggerDagRun("downstream_etl");
+          }
+
+          @Builder.Deps
+          static class Wiring implements TestExampleDeps {
+            void depends() {
+              var extracted = extract();
+              var loaded = load(extracted);
+              hasRows(extracted).Then(loaded).Else(loadEmpty());
+              loaded.before(triggerDownstream());
+            }
+          }
+        }
+      """,
+      )
+
+    assertThat(compilation).succeeded()
+    // The wiring only runs when the Dag is built, so load it and build it.
+    val classes =
+      compilation
+        .generatedFiles()
+        .filter { it.kind == JavaFileObject.Kind.CLASS }
+        .associate {
+          it.name
+            .removePrefix("/CLASS_OUTPUT/")
+            .removeSuffix(".class")
+            .replace('/', '.') to it.openInputStream().readBytes()
+        }
+    val loader =
+      object : ClassLoader(javaClass.classLoader) {
+        override fun findClass(name: String): Class<*> {
+          val bytes = classes[name] ?: throw ClassNotFoundException(name)
+          return defineClass(name, bytes, 0, bytes.size)
+        }
+      }
+
+    
Bundle().register(loader.loadClass("org.apache.airflow.example.TestExample"))
+  }
+
+  @Test
+  @DisplayName("reject a task that triggers a Dag run and takes parameters")
+  fun rejectTriggerDagRunWithParameters() {
+    val compilation =
+      compile(
+        """
+        package org.apache.airflow.example;
+        import org.apache.airflow.sdk.Builder;
+        import org.apache.airflow.sdk.TriggerDagRun;
+        @Builder.Dag
+        public class TestExample {
+          @Builder.Task
+          public TriggerDagRun triggerDownstream(long rows) {
+            return new TriggerDagRun("downstream_etl");
+          }
+
+          @Builder.Deps
+          static class Wiring implements TestExampleDeps {
+            void depends() {}
+          }
+        }
+      """,
+      )
+
+    assertThat(compilation).failed()
+    assertThat(compilation).hadErrorContaining(
+      "@Builder.Task method 'triggerDownstream' returns a TriggerDagRun, so it 
runs when the Dag is " +
+        "built rather than when the task runs; it takes no parameters",
+    )
+  }
+
+  @Test
+  @DisplayName("reject a task that triggers a Dag run and throws a checked 
exception")
+  fun rejectTriggerDagRunThrowingCheckedException() {
+    val compilation =
+      compile(
+        """
+        package org.apache.airflow.example;
+        import org.apache.airflow.sdk.Builder;
+        import org.apache.airflow.sdk.TriggerDagRun;
+        @Builder.Dag
+        public class TestExample {
+          @Builder.Task
+          public TriggerDagRun triggerDownstream() throws java.io.IOException {
+            return new TriggerDagRun("downstream_etl");
+          }
+
+          @Builder.Deps
+          static class Wiring implements TestExampleDeps {
+            void depends() {}
+          }
+        }
+      """,
+      )
+
+    assertThat(compilation).failed()
+    assertThat(compilation).hadErrorContaining(
+      "@Builder.Task method 'triggerDownstream' returns a TriggerDagRun, so it 
runs when the Dag is " +
+        "built, where a checked exception cannot be thrown; it must not throw: 
java.io.IOException",
+    )
+  }
+
   @Test
   @DisplayName("reject a switch that does not return the class of a task")
   fun rejectNonTaskClassSwitch() {
diff --git a/java-sdk/scripts/ci/prek/check_serialization_conformance.py 
b/java-sdk/scripts/ci/prek/check_serialization_conformance.py
index 01a16e34219..677ffcdb217 100755
--- a/java-sdk/scripts/ci/prek/check_serialization_conformance.py
+++ b/java-sdk/scripts/ci/prek/check_serialization_conformance.py
@@ -56,7 +56,7 @@ if __name__ == "__main__":
         "--sdk",
         "java",
         "--supports",
-        "literal_inputs,branch,switch",
+        "literal_inputs,branch,switch,trigger_dag_run",
         "--",
         *serializer,
     ]
diff --git a/java-sdk/sdk/module.md b/java-sdk/sdk/module.md
index f03b936d7b9..2cc58f52b4d 100644
--- a/java-sdk/sdk/module.md
+++ b/java-sdk/sdk/module.md
@@ -37,8 +37,8 @@ meaning of each dimension is defined in the
 | state: `success` | MUST | ✓ | 3.3 |  |
 | state: `failed` | MUST | ✓ | 3.3 |  |
 | state: `up_for_retry` | MUST | ✓ | 3.3 | RetryTask |
-| state: `skipped` | SHOULD | ✗ | – | runtime does not emit TaskState skipped 
yet |
-| state: `deferred` | MAY | ✗ | – | runtime does not emit DeferTask yet |
+| state: `skipped` | SHOULD | ✓ | 3.4 | a TriggerDagRun task that skips an 
existing run |
+| state: `deferred` | MAY | ✓ | 3.4 | a TriggerDagRun task that waits with 
deferrable |
 | state: `up_for_reschedule` | MAY | ✗ | – | runtime does not emit 
RescheduleTask yet |
 | state: `awaiting_input` | MAY | ✗ | – | runtime does not emit AwaitInputTask 
yet |
 | state: `removed` | MAY | ✓ | 3.3 |  |
diff --git a/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/DagDef.kt 
b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/DagDef.kt
index 9f79be81a6c..37015ca9197 100644
--- a/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/DagDef.kt
+++ b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/DagDef.kt
@@ -160,6 +160,31 @@ class DagDef(
     return this
   }
 
+  /**
+   * Declares a task that starts a run of another Dag.
+   *
+   * ```java
+   * dag.task("trigger_downstream", new TriggerDagRun("downstream_etl"));
+   * ```
+   *
+   * The task runs no Java code and takes no arguments, so it has no ID to
+   * derive and names one here.
+   *
+   * @param id Task identifier, unique within this Dag.
+   * @param trigger What to trigger, and how.
+   * @return The handle representing this task.
+   * @throws IllegalArgumentException if a task with the same ID is already
+   *    registered.
+   */
+  fun task(
+    id: String,
+    trigger: TriggerDagRun,
+  ): TaskRef<Void> {
+    val def = TaskDef(id, trigger)
+    addTask(def)
+    return TaskRef(def)
+  }
+
   /**
    * Declares a task whose boolean picks one of two tasks; the other is
    * skipped.
@@ -404,6 +429,21 @@ class TaskDef(
   val id: String,
   val definition: Class<out Task>,
 ) : Endpoint {
+  /**
+   * Declares a task that starts a run of another Dag instead of running Java
+   * code.
+   *
+   * [DagDef.task] is the spelling user code uses; this is what a generated
+   * wiring view calls.
+   *
+   * @param id Task identifier, unique within a [DagDef].
+   * @param trigger What to trigger, and how; settings made on it later do not
+   *    reach this task.
+   */
+  constructor(id: String, trigger: TriggerDagRun) : this(id, 
TriggerDagRunPlaceholder::class.java) {
+    this.trigger = trigger.snapshot()
+  }
+
   init {
     validateTaskInput(definition)
   }
@@ -419,6 +459,9 @@ class TaskDef(
   /** What this task decides to run, for a condition or a switch; null 
otherwise. */
   internal var decider: DeciderDef? = null
 
+  /** The Dag run this task starts, for a task declared from a 
[TriggerDagRun]; null otherwise. */
+  internal var trigger: TriggerDagRun? = null
+
   /**
    * Sets one task-level configuration value.
    *
diff --git a/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/Switch.kt 
b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/Switch.kt
index a668db89609..5bbd93b4da1 100644
--- a/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/Switch.kt
+++ b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/Switch.kt
@@ -134,7 +134,8 @@ class SwitchRef private constructor(
    * @param task Task of the same Dag.
    * @return This switch, for chaining.
    * @throws IllegalArgumentException if the task belongs to another Dag, is
-   *    already a case of this switch, or runs the same class as one.
+   *    already a case of this switch, triggers a Dag run, or runs the same 
class
+   *    as one.
    */
   @Suppress("ktlint:standard:function-naming")
   fun Case(task: TaskRef<*>): SwitchRef {
@@ -191,6 +192,11 @@ internal class SwitchDef : DeciderDef {
     require(options.none { it === case }) {
       "Switch '${decider.id}' already chooses between '${case.id}' and others; 
name each case once"
     }
+    require(case.trigger == null) {
+      "Switch '${decider.id}' cannot choose '${case.id}': a switch names its 
case by its class, and a " +
+        "task that triggers a Dag run has none. Put the trigger downstream of 
an ordinary task and " +
+        "let the switch choose that task, since Airflow skips the downstream 
of a skipped task"
+    }
     // A switch names its case by class, so two cases sharing one would be
     // indistinguishable.
     options.firstOrNull { it.definition == case.definition }?.let { first ->
diff --git 
a/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/TaskGroupRef.kt 
b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/TaskGroupRef.kt
index 60d43f84fcd..363f13072cd 100644
--- a/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/TaskGroupRef.kt
+++ b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/TaskGroupRef.kt
@@ -76,6 +76,25 @@ class TaskGroupRef internal constructor(
     return TaskRef(def)
   }
 
+  /**
+   * Declares a task in this group that starts a run of another Dag, as
+   * [DagDef.task] does for the Dag.
+   *
+   * @param id Task ID within this group; the task's ID is `<group ID>.<id>`.
+   * @param trigger What to trigger, and how.
+   * @return The handle representing this task.
+   * @throws IllegalArgumentException if the Dag already has a task or task
+   *    group with the resulting ID.
+   */
+  fun task(
+    id: String,
+    trigger: TriggerDagRun,
+  ): TaskRef<Void> {
+    val def = TaskDef(qualify(id), trigger)
+    adopt(def)
+    return TaskRef(def)
+  }
+
   /**
    * Declares a condition in this group, as [DagDef.If] does for the Dag.
    *
diff --git 
a/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/TriggerDagRun.kt 
b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/TriggerDagRun.kt
new file mode 100644
index 00000000000..c78d1a9fd5f
--- /dev/null
+++ b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/TriggerDagRun.kt
@@ -0,0 +1,189 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.airflow.sdk
+
+import org.apache.airflow.sdk.internal.Field
+import org.apache.airflow.sdk.internal.FieldType
+import org.apache.airflow.sdk.internal.checkConfigValue
+import java.time.Duration
+import java.time.Instant
+import java.time.OffsetDateTime
+
+/**
+ * Settings named as `TriggerDagRunOperator` names its parameters. The Dag ID
+ * is the constructor argument, so `trigger_dag_id` is not among them.
+ */
+private val TRIGGER_FIELDS: Map<String, Field> =
+  listOf(
+    Field("trigger_run_id", "triggerRunId", FieldType.STRING, null),
+    Field("conf", "conf", FieldType.JSON_OBJECT, null),
+    Field("logical_date", "logicalDate", FieldType.DATETIME, null),
+    Field("run_after", "runAfter", FieldType.DATETIME, null),
+    Field("reset_dag_run", "resetDagRun", FieldType.BOOLEAN, null),
+    Field("wait_for_completion", "waitForCompletion", FieldType.BOOLEAN, null),
+    Field("poke_interval", "pokeInterval", FieldType.TIMEDELTA, null),
+    Field("allowed_states", "allowedStates", FieldType.DAG_RUN_STATES, null),
+    Field("failed_states", "failedStates", FieldType.DAG_RUN_STATES, null),
+    Field("skip_when_already_exists", "skipWhenAlreadyExists", 
FieldType.BOOLEAN, null),
+    Field("fail_when_dag_is_paused", "failWhenDagIsPaused", FieldType.BOOLEAN, 
null),
+    Field("note", "note", FieldType.STRING, null),
+    Field("deferrable", "deferrable", FieldType.BOOLEAN, null),
+  ).associateBy { it.key }
+
+/**
+ * A task that starts a run of another Dag, in place of a task class.
+ *
+ * Pass one to [DagDef.task] and the Java runtime runs it as Airflow's
+ * `TriggerDagRunOperator` does, so the Dag needs no Python worker for it:
+ *
+ * ```java
+ * dag.task("trigger_downstream",
+ *     new TriggerDagRun("downstream_etl")
+ *         .config("wait_for_completion", true)
+ *         .config("conf", Map.of("rows", 2)));
+ * ```
+ *
+ * In an annotated Dag, a `@Builder.Task` method returns one instead; the
+ * method runs when the Dag is built, not when the task runs.
+ *
+ * The task runs no Java code and takes no arguments. It pushes the triggered
+ * run's ID, and the link the "Triggered DAG" extra link reads. It renders no
+ * templates, so a value such as `"{{ ds }}"` reaches the new run unchanged.
+ *
+ * @param dagId `trigger_dag_id`: the Dag to trigger.
+ * @throws IllegalArgumentException if [dagId] is empty.
+ */
+class TriggerDagRun(
+  val dagId: String,
+) {
+  init {
+    require(dagId.isNotEmpty()) { "TriggerDagRun needs the ID of the Dag to 
trigger" }
+  }
+
+  internal val settings = linkedMapOf<String, Any>()
+
+  /**
+   * Sets one `TriggerDagRunOperator` setting, named as Python names the
+   * parameter.
+   *
+   * | Key | Value |
+   * | --- | --- |
+   * | `trigger_run_id` | `String`; generated when unset, and for a run with 
no logical date from `run_after` plus a random eight-character suffix |
+   * | `conf` | `Map` with string keys and JSON values |
+   * | `logical_date` | [OffsetDateTime] or [Instant]; the trigger time when 
neither this nor `run_after` is set, and none when only `run_after` is |
+   * | `run_after` | [OffsetDateTime] or [Instant] |
+   * | `reset_dag_run` | `Boolean`: clear a run with the same ID instead of 
failing |
+   * | `wait_for_completion` | `Boolean`: hold this task open until the run 
finishes |
+   * | `poke_interval` | [Duration], whole seconds; 60 seconds when unset |
+   * | `allowed_states` | states that count as success; `success` when unset |
+   * | `failed_states` | states that count as failure; `failed` when unset |
+   * | `skip_when_already_exists` | `Boolean`: skip rather than fail when the 
run exists |
+   * | `fail_when_dag_is_paused` | `Boolean`: fail rather than trigger a 
paused Dag |
+   * | `note` | `String` recorded against the triggered run |
+   * | `deferrable` | `Boolean`: while waiting, defer instead of holding the 
worker |
+   *
+   * A `deferrable` left unset follows Airflow's `[operators] 
default_deferrable`.
+   *
+   * @param key One of the keys above.
+   * @param value Value of the shape that key takes.
+   * @return This task, for chaining.
+   * @throws IllegalArgumentException if the key is unknown or the value does
+   *    not match it.
+   */
+  fun config(
+    key: String,
+    value: Any?,
+  ): TriggerDagRun {
+    require(key != "trigger_dag_id") {
+      "The Dag to trigger is the TriggerDagRun argument, not the config key 
'trigger_dag_id'"
+    }
+    val checked = checkConfigValue("TriggerDagRun", TRIGGER_FIELDS, key, value)
+    settings[key] =
+      when (key) {
+        "poke_interval" -> {
+          val duration = checked as Duration
+          // Python's poke_interval is a number of seconds, and the serialized 
Dag carries it as one.
+          require(!duration.isNegative && duration.nano == 0) {
+            "Value for TriggerDagRun config key '$key' is $duration; it must 
be a whole, " +
+              "non-negative number of seconds"
+          }
+          duration
+        }
+        "conf" -> jsonValue(key, checked)!!
+        // Python generates a run ID for an empty one, which the runtime does 
not.
+        "trigger_run_id", "note" -> {
+          require((checked as String).isNotEmpty()) {
+            "Value for TriggerDagRun config key '$key' must not be empty"
+          }
+          checked
+        }
+        else -> checked
+      }
+    return this
+  }
+
+  /** A copy that later calls on this builder cannot change. */
+  internal fun snapshot(): TriggerDagRun = TriggerDagRun(dagId).also { 
it.settings.putAll(settings) }
+}
+
+/** [value] as plain JSON, as a fresh structure, rejecting anything that has 
no JSON form. */
+private fun jsonValue(
+  key: String,
+  value: Any?,
+): Any? =
+  when (value) {
+    null, is String, is Boolean, is Int, is Long, is Short, is Byte -> value
+    is Double ->
+      value.also {
+        require(it.isFinite()) {
+          "Value for TriggerDagRun config key '$key' must hold only finite 
numbers, got: $it"
+        }
+      }
+    is Float -> jsonValue(key, value.toDouble())
+    is Collection<*> -> value.map { jsonValue(key, it) }
+    is Array<*> -> value.map { jsonValue(key, it) }
+    is Map<*, *> ->
+      value.entries.associateTo(linkedMapOf<String, Any?>()) { (entryKey, 
entry) ->
+        require(entryKey is String) {
+          "Value for TriggerDagRun config key '$key' must have String keys, 
got: ${entryKey?.javaClass?.name}"
+        }
+        entryKey to jsonValue(key, entry)
+      }
+    else ->
+      throw IllegalArgumentException(
+        "Value for TriggerDagRun config key '$key' must hold only JSON values 
(null, String, " +
+          "Boolean, Number, List, Map), got: ${value.javaClass.name}",
+      )
+  }
+
+/**
+ * Stands in for the class of a task that triggers a Dag run. The runtime runs
+ * such a task itself and never instantiates this, so it exists only so that
+ * every [TaskDef] names a class.
+ */
+internal class TriggerDagRunPlaceholder : Task {
+  override fun execute(
+    context: Context,
+    client: Client,
+  ): Unit =
+    throw IllegalStateException(
+      "A task that triggers a Dag run is run by the SDK, so its placeholder is 
never executed",
+    )
+}
diff --git 
a/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/execution/Client.kt 
b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/execution/Client.kt
index f3174e77e2a..317dac7ebb0 100644
--- a/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/execution/Client.kt
+++ b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/execution/Client.kt
@@ -22,10 +22,14 @@ package org.apache.airflow.sdk.execution
 import kotlinx.coroutines.runBlocking
 import org.apache.airflow.sdk.execution.comm.ClearTaskStateStore
 import org.apache.airflow.sdk.execution.comm.ConnectionResult
+import org.apache.airflow.sdk.execution.comm.DagResult
+import org.apache.airflow.sdk.execution.comm.DagRunStateResult
 import org.apache.airflow.sdk.execution.comm.DeleteTaskStateStore
 import org.apache.airflow.sdk.execution.comm.DeleteVariable
 import org.apache.airflow.sdk.execution.comm.ErrorResponse
 import org.apache.airflow.sdk.execution.comm.GetConnection
+import org.apache.airflow.sdk.execution.comm.GetDag
+import org.apache.airflow.sdk.execution.comm.GetDagRunState
 import org.apache.airflow.sdk.execution.comm.GetTaskStateStore
 import org.apache.airflow.sdk.execution.comm.GetVariable
 import org.apache.airflow.sdk.execution.comm.GetXCom
@@ -35,6 +39,7 @@ import org.apache.airflow.sdk.execution.comm.SetTaskStateStore
 import org.apache.airflow.sdk.execution.comm.SetXCom
 import org.apache.airflow.sdk.execution.comm.SkipDownstreamTasks
 import org.apache.airflow.sdk.execution.comm.TaskStateStoreResult
+import org.apache.airflow.sdk.execution.comm.TriggerDagRun
 import org.apache.airflow.sdk.execution.comm.VariableResult
 import org.apache.airflow.sdk.execution.comm.XComResult
 import java.time.OffsetDateTime
@@ -110,6 +115,32 @@ interface Client {
    * succeeded or failed alone.
    */
   fun skipDownstreamTasks(taskIds: List<String>)
+
+  /**
+   * Starts a run of another Dag, as a task declared from a
+   * [org.apache.airflow.sdk.TriggerDagRun] does.
+   *
+   * @return True when a run with that ID already exists, which the task
+   *    decides what to do about, rather than an error.
+   */
+  fun triggerDagRun(
+    dagId: String,
+    runId: String,
+    logicalDate: OffsetDateTime?,
+    runAfter: OffsetDateTime?,
+    conf: Map<String, Any?>?,
+    resetDagRun: Boolean,
+    note: String?,
+  ): Boolean
+
+  /** State of one Dag run, which a task that waits polls. */
+  fun getDagRunState(
+    dagId: String,
+    runId: String,
+  ): String
+
+  /** Whether the Dag is paused, which a task may refuse to trigger. */
+  fun isDagPaused(dagId: String): Boolean
 }
 
 /**
@@ -245,4 +276,47 @@ class CoordinatorClient(
     val message = SkipDownstreamTasks().also { it.tasks = 
ArrayList<Any>(taskIds) }
     runBlocking { exec.communicate<Unit>(message) }
   }
+
+  override fun triggerDagRun(
+    dagId: String,
+    runId: String,
+    logicalDate: OffsetDateTime?,
+    runAfter: OffsetDateTime?,
+    conf: Map<String, Any?>?,
+    resetDagRun: Boolean,
+    note: String?,
+  ): Boolean {
+    val message =
+      TriggerDagRun().also {
+        it.dagId = dagId
+        it.runId = runId
+        it.logicalDate = logicalDate
+        it.runAfter = runAfter
+        it.conf = conf
+        it.resetDagRun = resetDagRun
+        it.note = note
+      }
+    // The supervisor answers a trigger with an OKResponse, and reports a run 
that already exists as
+    // the DAGRUN_ALREADY_EXISTS error, which the task decides what to do 
about.
+    val response =
+      runBlocking {
+        exec.communicateOrNullIf<OKResponse>(message, 
ErrorResponse.ErrorType.DAGRUN_ALREADY_EXISTS)
+      }
+    return response == null
+  }
+
+  override fun getDagRunState(
+    dagId: String,
+    runId: String,
+  ): String {
+    val message =
+      GetDagRunState().also {
+        it.dagId = dagId
+        it.runId = runId
+      }
+    return runBlocking { exec.communicate<DagRunStateResult>(message) 
}.state.toString()
+  }
+
+  override fun isDagPaused(dagId: String): Boolean =
+    runBlocking { exec.communicate<DagResult>(GetDag().also { it.dagId = dagId 
}) }.isPaused == true
 }
diff --git 
a/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/execution/Logger.kt 
b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/execution/Logger.kt
index 228d59e7240..04757386aa4 100644
--- a/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/execution/Logger.kt
+++ b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/execution/Logger.kt
@@ -198,6 +198,11 @@ internal class Logger(
     arguments: Map<String, Any> = emptyMap(),
   ) = log(Level.DEBUG, message, arguments)
 
+  fun info(
+    message: String,
+    arguments: Map<String, Any> = emptyMap(),
+  ) = log(Level.INFO, message, arguments)
+
   fun warning(
     message: String,
     arguments: Map<String, Any> = emptyMap(),
diff --git 
a/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/execution/Serde.kt 
b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/execution/Serde.kt
index bdc719a3a68..e5ad4d6eacf 100644
--- a/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/execution/Serde.kt
+++ b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/execution/Serde.kt
@@ -28,6 +28,7 @@ import org.apache.airflow.sdk.LiteralArg
 import org.apache.airflow.sdk.TaskDef
 import org.apache.airflow.sdk.TaskGroupRef
 import org.apache.airflow.sdk.TaskRef
+import org.apache.airflow.sdk.TriggerDagRun
 import org.apache.airflow.sdk.execution.comm.DagFileParseRequest
 import org.apache.airflow.sdk.internal.Field
 import org.apache.airflow.sdk.internal.SchemaFields
@@ -107,7 +108,7 @@ internal fun serializeDag(
       "timezone" to dagTimezone(dag.dagConfig),
       "timetable" to serializeTimetable(dag.id, dag.dagConfig),
       "tasks" to dag.tasks.map { (taskId, def) -> serializeTask(taskId, def, 
downstream[taskId]) },
-      "dag_dependencies" to emptyList<Any?>(),
+      "dag_dependencies" to serializeDagDependencies(dag),
       "task_group" to serializeTaskGroups(dag, expansion),
       "edge_info" to emptyMap<String, Any?>(),
       "params" to emptyList<Any?>(),
@@ -127,22 +128,24 @@ private fun serializeTask(
   def: TaskDef,
   downstream: List<String>?,
 ): Map<String, Any?> {
-  val data =
-    linkedMapOf<String, Any?>(
-      "task_id" to taskId,
-      "task_type" to def.definition.simpleName,
-      "_task_module" to def.definition.packageName,
-      "language" to "java",
-      // Python's operator serializer always emits template_fields (its list
-      // value never matches the tuple default it is compared against), so it
-      // is unconditional here too. Java tasks have no template fields.
-      "template_fields" to emptyList<Any?>(),
-      // What marks a task whose arguments Airflow resolves per instance for a
-      // runtime outside Python, as `@task.stub` does on the Python side.
-      // `get_arg_bindings` reads nothing without it.
-      "is_stub" to true,
-    )
-  argBindings(taskId, def)?.let { data["_arg_bindings"] = it }
+  val data = linkedMapOf<String, Any?>("task_id" to taskId)
+  val trigger = def.trigger
+  if (trigger != null) {
+    writeTriggerDagRun(data, trigger)
+  } else {
+    data["task_type"] = def.definition.simpleName
+    data["_task_module"] = def.definition.packageName
+    data["language"] = "java"
+    // Python's operator serializer always emits template_fields (its list
+    // value never matches the tuple default it is compared against), so it
+    // is unconditional here too. Java tasks have no template fields.
+    data["template_fields"] = emptyList<Any?>()
+    // What marks a task whose arguments Airflow resolves per instance for a
+    // runtime outside Python, as `@task.stub` does on the Python side.
+    // `get_arg_bindings` reads nothing without it.
+    data["is_stub"] = true
+    argBindings(taskId, def)?.let { data["_arg_bindings"] = it }
+  }
   // Lets NotPreviouslySkippedDep re-skip a cleared downstream, as Python's 
SkipMixin does.
   if (def.decider != null) data["_can_skip_downstream"] = true
   // Emit only config entries that differ from their schema default, mirroring
@@ -165,6 +168,96 @@ private fun serializeTask(
   )
 }
 
+/** What Python's serializer writes for a `TriggerDagRunOperator`. */
+private const val TRIGGER_TASK_TYPE = "TriggerDagRunOperator"
+private const val TRIGGER_TASK_MODULE = 
"airflow.providers.standard.operators.trigger_dagrun"
+private const val TRIGGER_UI_COLOR = "#ffefeb"
+
+private val TRIGGER_TEMPLATE_FIELDS =
+  listOf(
+    "trigger_dag_id",
+    "trigger_run_id",
+    "logical_date",
+    "conf",
+    "wait_for_completion",
+    "skip_when_already_exists",
+  )
+
+/**
+ * Writes a task declared from a [TriggerDagRun] as Python writes a
+ * `TriggerDagRunOperator`, so the Airflow UI and the Dag dependency graph
+ * treat it as one.
+ *
+ * Python writes an operator's template fields and leaves its other parameters
+ * out, because a Python Dag file holds them. A Java Dag has no such file, so
+ * every setting the author made is written under the name Python's parameter
+ * carries. The runtime runs the task from the Dag in its own bundle and reads
+ * none of this back.
+ */
+private fun writeTriggerDagRun(
+  data: MutableMap<String, Any?>,
+  trigger: TriggerDagRun,
+) {
+  data["task_type"] = TRIGGER_TASK_TYPE
+  data["_task_module"] = TRIGGER_TASK_MODULE
+  data["ui_color"] = TRIGGER_UI_COLOR
+  data["template_fields"] = TRIGGER_TEMPLATE_FIELDS
+  data["template_fields_renderers"] = mapOf("conf" to "py")
+  data["_operator_extra_links"] = mapOf("Triggered DAG" to 
"_link_TriggerDagRunLink")
+  data["trigger_dag_id"] = trigger.dagId
+
+  val settings = trigger.settings
+  // Python's logical_date defaults to NOTSET, which lets the operator pick the
+  // trigger time, and writes that sentinel as its name. A template field holds
+  // a datetime as str(datetime), not as a {"__type": "datetime"} object.
+  data["logical_date"] = asOffsetDateTime(settings["logical_date"])?.let { 
pythonIsoformat(it, ' ') } ?: "NOTSET"
+  // Python writes these two template fields whatever they hold.
+  data["wait_for_completion"] = settings["wait_for_completion"] ?: false
+  data["skip_when_already_exists"] = settings["skip_when_already_exists"] ?: 
false
+  settings["trigger_run_id"]?.let { data["trigger_run_id"] = it }
+  settings["conf"]?.let { data["conf"] = it }
+  // A field that is not a template field is read back with
+  // BaseSerialization.deserialize, which returns a datetime only for the
+  // type-encoded form.
+  settings["run_after"]?.let { data["run_after"] = serializeValue(it) }
+  settings["reset_dag_run"]?.takeIf { it == true }?.let { 
data["reset_dag_run"] = true }
+  settings["poke_interval"]?.let { data["poke_interval"] = (it as 
Duration).seconds.toInt() }
+  settings["allowed_states"]?.let { data["allowed_states"] = it }
+  settings["failed_states"]?.let { data["failed_states"] = it }
+  settings["fail_when_dag_is_paused"]?.takeIf { it == true }?.let { 
data["fail_when_dag_is_paused"] = true }
+  settings["note"]?.let { data["note"] = it }
+  settings["deferrable"]?.let { data["deferrable"] = it }
+}
+
+/**
+ * The Dags this one triggers, which is what the UI draws the dependency graph
+ * between Dags from, sorted as Python sorts them.
+ */
+private fun serializeDagDependencies(dag: DagDef): List<Map<String, Any?>> =
+  dag.tasks
+    .filterValues { it.trigger != null }
+    .map { (taskId, def) ->
+      mapOf(
+        "source" to dag.id,
+        "target" to def.trigger!!.dagId,
+        // Python labels the dependency with the task's display name, which a 
Java task cannot set
+        // yet, and falls back to the task ID as this does.
+        "label" to taskId,
+        "dependency_type" to "trigger",
+        "dependency_id" to taskId,
+      )
+    }
+    // Python sorts DagDependency as the ordered dataclass it is, field by 
field.
+    .sortedWith(
+      compareBy(
+        { it["source"] as String },
+        { it["target"] as String },
+        { it["label"] as String },
+        { it["dependency_type"] as String },
+        { it["dependency_id"] as String },
+      ),
+    )
+
 /**
  * The task's arguments as the binding spec Airflow records, one entry per
  * argument in the order the Dag's call passed them, as
diff --git 
a/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/execution/Task.kt 
b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/execution/Task.kt
index 182dfe1cee6..54cdb2b3911 100644
--- a/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/execution/Task.kt
+++ b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/execution/Task.kt
@@ -102,6 +102,19 @@ internal object TaskRunner {
     val taskDef =
       bundle.taskDef(request.ti.dagId, request.ti.taskId)
         ?: return TaskResult.of(TaskState.State.REMOVED)
+    taskDef.trigger?.let { trigger ->
+      return try {
+        TriggerRunner.run(trigger, request, client)
+      } catch (e: CancellationException) {
+        throw e
+      } catch (e: Throwable) {
+        logger.error(
+          "Error triggering Dag run",
+          mapOf("ti" to request.ti, "error" to e, "trace" to 
e.stackTraceToString()),
+        )
+        TaskResult.failure(request.tiContext.shouldRetry)
+      }
+    }
     val definition = taskDef.definition
     val instance =
       try {
diff --git 
a/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/execution/TriggerRunner.kt
 
b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/execution/TriggerRunner.kt
new file mode 100644
index 00000000000..3af0ce30a59
--- /dev/null
+++ 
b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/execution/TriggerRunner.kt
@@ -0,0 +1,324 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+// Mirrors TriggerDagRunOperator.execute and execute_complete in the standard
+// provider, as the TypeScript SDK runtime does.
+
+package org.apache.airflow.sdk.execution
+
+import org.apache.airflow.sdk.Client
+import org.apache.airflow.sdk.TriggerDagRun
+import org.apache.airflow.sdk.execution.comm.DeferTask
+import org.apache.airflow.sdk.execution.comm.StartupDetails
+import org.apache.airflow.sdk.execution.comm.TaskState
+import java.security.SecureRandom
+import java.time.Duration
+import java.time.Instant
+import java.time.OffsetDateTime
+import java.time.ZoneOffset
+import java.time.format.DateTimeFormatter
+import kotlin.random.asKotlinRandom
+
+/** `DagStateTrigger`, the Python triggerer runs the wait of a deferred task 
with. */
+private const val DAG_STATE_TRIGGER = 
"airflow.providers.standard.triggers.external_task.DagStateTrigger"
+
+/** `TriggerDagRunLink().xcom_key`: the XCom the "Triggered DAG" extra link 
reads. */
+private const val LINK_XCOM_KEY = "_link_TriggerDagRunLink"
+private const val RUN_ID_XCOM_KEY = "trigger_run_id"
+
+/** `TRIGGER_FAIL_REPR`: the `next_method` a failed or timed-out trigger 
resumes with. */
+private const val TRIGGER_FAIL = "__fail__"
+private const val EXECUTE_COMPLETE = "execute_complete"
+
+private val DEFAULT_POKE_INTERVAL = Duration.ofSeconds(60)
+
+internal object TriggerRunner {
+  private val logger = Logger(TriggerRunner::class)
+
+  /**
+   * Runs a task declared from a [TriggerDagRun], and reports the state it
+   * leaves the task instance in.
+   *
+   * A task resumed by the triggerer reads its outcome from the event the
+   * trigger fired with, rather than triggering a second run.
+   */
+  fun run(
+    trigger: TriggerDagRun,
+    request: StartupDetails,
+    client: Client,
+    env: (String) -> String? = System::getenv,
+    sleep: (Duration) -> Unit = { Thread.sleep(it.toMillis()) },
+    now: () -> OffsetDateTime = { OffsetDateTime.now(ZoneOffset.UTC) },
+    randomSuffix: () -> String = ::randomRunIdSuffix,
+  ): Any {
+    val nextMethod = request.tiContext?.nextMethod as String?
+    if (nextMethod != null) return resume(trigger, request, nextMethod)
+
+    val runAfter = asOffsetDateTime(trigger.settings["run_after"])
+    // `DagRun.generate_run_id`: a run the task gave only a run_after has no 
logical date, and is
+    // named after run_after plus a random suffix. Otherwise the logical date 
names it, and is the
+    // trigger time when the task set none.
+    val configuredLogicalDate = 
asOffsetDateTime(trigger.settings["logical_date"])
+    val logicalDate =
+      when {
+        configuredLogicalDate != null -> configuredLogicalDate
+        runAfter == null -> now()
+        else -> null
+      }
+    val namesRun = runAfter ?: logicalDate!!
+    val runId =
+      trigger.setting<String>("trigger_run_id")
+        ?: ("manual__${pythonIsoformat(namesRun)}" + if (logicalDate == null) 
"_${randomSuffix()}" else "")
+
+    if (trigger.flag("fail_when_dag_is_paused") && 
client.impl.isDagPaused(trigger.dagId)) {
+      return failed(request, "Dag ${trigger.dagId} is paused")
+    }
+
+    logger.info("Triggering Dag Run.", mapOf("trigger_dag_id" to 
trigger.dagId))
+    client.setXCom(LINK_XCOM_KEY, dagRunUrl(env, trigger.dagId, runId))
+    val alreadyExists =
+      client.impl.triggerDagRun(
+        dagId = trigger.dagId,
+        runId = runId,
+        logicalDate = logicalDate,
+        runAfter = runAfter,
+        conf = trigger.setting("conf"),
+        resetDagRun = trigger.flag("reset_dag_run"),
+        note = trigger.setting("note"),
+      )
+    if (alreadyExists) {
+      if (trigger.flag("skip_when_already_exists")) {
+        logger.info(
+          "Dag Run already exists, skipping task as skip_when_already_exists 
is set to True.",
+          mapOf("dag_id" to trigger.dagId),
+        )
+        return TaskResult.of(TaskState.State.SKIPPED)
+      }
+      logger.error("Dag Run already exists, marking task as failed.", 
mapOf("dag_id" to trigger.dagId))
+      return TaskResult.of(TaskState.State.FAILED)
+    }
+    logger.info("Dag Run triggered successfully.", mapOf("trigger_dag_id" to 
trigger.dagId))
+    client.setXCom(RUN_ID_XCOM_KEY, runId)
+
+    if (!trigger.flag("wait_for_completion")) {
+      if (deferrable(trigger, env)) {
+        logger.info(
+          "Ignoring deferrable=True because wait_for_completion=False. " +
+            "Task will complete immediately without waiting for the triggered 
DAG run.",
+          mapOf("trigger_dag_id" to trigger.dagId),
+        )
+      }
+      return TaskResult.success()
+    }
+
+    val pokeInterval = trigger.setting<Duration>("poke_interval") ?: 
DEFAULT_POKE_INTERVAL
+    if (deferrable(trigger, env)) {
+      logger.info("Pausing task as DEFERRED.", mapOf("trigger_dag_id" to 
trigger.dagId, "run_id" to runId))
+      return defer(trigger, request, runId, pokeInterval, env)
+    }
+
+    while (true) {
+      logger.info(
+        "Waiting for dag run to complete execution in allowed state.",
+        mapOf("dag_id" to trigger.dagId, "run_id" to runId, "allowed_state" to 
allowedStates(trigger)),
+      )
+      sleep(pokeInterval)
+      val state = client.impl.getDagRunState(trigger.dagId, runId)
+      if (state in failedStates(trigger)) {
+        logger.error("DagRun finished with failed state.", mapOf("dag_id" to 
trigger.dagId, "state" to state))
+        return failed(request, "${trigger.dagId} failed with failed state 
$state")
+      }
+      if (state in allowedStates(trigger)) {
+        logger.info("DagRun finished with allowed state.", mapOf("dag_id" to 
trigger.dagId, "state" to state))
+        return TaskResult.success()
+      }
+      logger.debug(
+        "DagRun not yet in allowed or failed state.",
+        mapOf("dag_id" to trigger.dagId, "state" to state),
+      )
+    }
+  }
+
+  /** `DagStateTrigger.serialize()`, key for key, so the Python triggerer runs 
the wait. */
+  private fun defer(
+    trigger: TriggerDagRun,
+    request: StartupDetails,
+    runId: String,
+    pokeInterval: Duration,
+    env: (String) -> String?,
+  ): DeferTask =
+    DeferTask().also {
+      it.state = "deferred"
+      it.classpath = DAG_STATE_TRIGGER
+      it.triggerKwargs =
+        mapOf(
+          "dag_id" to trigger.dagId,
+          "states" to allowedStates(trigger) + failedStates(trigger),
+          "poll_interval" to pokeInterval.seconds.toInt(),
+          "run_ids" to listOf(runId),
+          "execution_dates" to null,
+        )
+      it.triggerTimeout = null
+      // `_defer_task` hands the trigger the task's queue only when triggerer 
queues are enabled.
+      it.queue = if (booleanEnv(env, "AIRFLOW__TRIGGERER__QUEUES_ENABLED", 
false)) request.ti.queue else null
+      it.nextMethod = EXECUTE_COMPLETE
+      it.nextKwargs = emptyMap<String, Any?>()
+    }
+
+  /** `BaseOperator.resume_execution` for this task: `__fail__` or 
`execute_complete`. */
+  private fun resume(
+    trigger: TriggerDagRun,
+    request: StartupDetails,
+    nextMethod: String,
+  ): Any {
+    val kwargs = request.tiContext?.nextKwargs as? Map<*, *> ?: emptyMap<Any?, 
Any?>()
+    if (nextMethod == TRIGGER_FAIL) {
+      (kwargs["traceback"] as? List<*>)?.let { logger.error("Trigger 
failed:\n${it.joinToString("\n")}") }
+      return failed(request, (kwargs["error"] ?: "Unknown").toString())
+    }
+    if (nextMethod != EXECUTE_COMPLETE) {
+      return failed(request, "Task cannot resume with next_method 
\"$nextMethod\"")
+    }
+    val event =
+      decodeEvent(kwargs["event"])
+        ?: return failed(request, "Task resumed with an event it cannot read: 
${kwargs["event"]}")
+    val runIds =
+      event["run_ids"] as? List<*> ?: return failed(
+        request,
+        "Task resumed with an event it cannot read: ${kwargs["event"]}",
+      )
+    val failedRunIds =
+      runIds.filter { runId ->
+        val state = event[runId.toString()].toString()
+        if (state in failedStates(trigger)) return@filter true
+        if (state in allowedStates(trigger)) {
+          logger.info(
+            "Triggered Dag run finished with allowed state.",
+            mapOf("dag_id" to trigger.dagId, "state" to state, "run_id" to 
runId.toString()),
+          )
+        }
+        false
+      }
+    if (failedRunIds.isNotEmpty()) {
+      return failed(
+        request,
+        "${trigger.dagId} failed with failed states ${failedStates(trigger)} 
for run_ids $failedRunIds",
+      )
+    }
+    return TaskResult.success()
+  }
+
+  /**
+   * The payload `DagStateTrigger` fired with: `(classpath, data)`. The
+   * triggerer stores it with serde, which encodes a tuple as
+   * `{__classname__, __data__}`.
+   */
+  private fun decodeEvent(event: Any?): Map<String, Any?>? {
+    val pair =
+      if (event is Map<*, *> && event["__classname__"] == "builtins.tuple") 
event["__data__"] else event
+    val items = pair as? List<*> ?: return null
+    if (items.size != 2) return null
+    val data = items[1] as? Map<*, *> ?: return null
+    return data.entries.associate { (key, value) -> key.toString() to value }
+  }
+
+  private fun failed(
+    request: StartupDetails,
+    message: String,
+  ): Any {
+    logger.error(message)
+    return TaskResult.failure(request.tiContext?.shouldRetry == true)
+  }
+
+  private fun allowedStates(trigger: TriggerDagRun): List<String> =
+    trigger.setting<List<String>>("allowed_states")?.takeIf { it.isNotEmpty() 
} ?: listOf("success")
+
+  // An empty failed_states that was set means no state fails the task, so it 
is kept as written.
+  private fun failedStates(trigger: TriggerDagRun): List<String> = 
trigger.setting<List<String>>("failed_states") ?: listOf("failed")
+
+  private fun deferrable(
+    trigger: TriggerDagRun,
+    env: (String) -> String?,
+  ): Boolean =
+    trigger.setting<Boolean>("deferrable")
+      ?: booleanEnv(env, "AIRFLOW__OPERATORS__DEFAULT_DEFERRABLE", false)
+}
+
+@Suppress("UNCHECKED_CAST")
+private fun <T> TriggerDagRun.setting(key: String): T? = settings[key] as T?
+
+private fun TriggerDagRun.flag(key: String): Boolean = settings[key] == true
+
+/**
+ * An Airflow boolean option from the environment. It takes what Go's 
`strconv.ParseBool` takes
+ * rather than every spelling `configparser` accepts, so `yes` and `on` are 
rejected rather than
+ * read as true.
+ */
+internal fun booleanEnv(
+  env: (String) -> String?,
+  name: String,
+  fallback: Boolean,
+): Boolean {
+  val raw = env(name) ?: return fallback
+  return when (raw.trim().lowercase()) {
+    "t", "true", "1" -> true
+    "f", "false", "0" -> false
+    else -> throw IllegalArgumentException("$name is \"$raw\", which is not a 
boolean; use true or false")
+  }
+}
+
+/** `build_airflow_dagrun_url`, on `[api] base_url` from the environment. */
+internal fun dagRunUrl(
+  env: (String) -> String?,
+  dagId: String,
+  runId: String,
+): String {
+  val base = env("AIRFLOW__API__BASE_URL")?.takeIf { it.isNotEmpty() } ?: "/"
+  return "${base.trimEnd('/')}/dags/$dagId/runs/$runId"
+}
+
+/** The eight characters `get_random_string` adds to the ID of a run with no 
logical date. */
+private const val RUN_ID_SUFFIX_CHARS = 
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
+
+private val RUN_ID_RANDOM = SecureRandom().asKotlinRandom()
+
+internal fun randomRunIdSuffix(): String = (1..8).map { 
RUN_ID_SUFFIX_CHARS.random(RUN_ID_RANDOM) }.joinToString("")
+
+/**
+ * `datetime.isoformat(sep)` of a UTC instant, which is how Python spells a 
run ID's date. `str(datetime)` is the
+ * same with a space as [separator].
+ */
+internal fun pythonIsoformat(
+  moment: OffsetDateTime,
+  separator: Char = 'T',
+): String {
+  val utc = moment.withOffsetSameInstant(ZoneOffset.UTC)
+  val micros = utc.nano / 1000
+  val seconds = 
utc.format(DateTimeFormatter.ofPattern("uuuu-MM-dd'$separator'HH:mm:ss"))
+  return if (micros == 0) "$seconds+00:00" else 
"$seconds.%06d+00:00".format(micros)
+}
+
+/** Reads a setting that may be stored as either temporal type. */
+internal fun asOffsetDateTime(value: Any?): OffsetDateTime? =
+  when (value) {
+    null -> null
+    is OffsetDateTime -> value
+    is Instant -> value.atOffset(ZoneOffset.UTC)
+    else -> throw IllegalArgumentException("Not a date-time: $value")
+  }
diff --git 
a/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/internal/Fields.kt 
b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/internal/Fields.kt
index cc43db31865..5b813b5c733 100644
--- a/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/internal/Fields.kt
+++ b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/internal/Fields.kt
@@ -36,8 +36,17 @@ enum class FieldType {
   STRING_ARRAY,
   TIMEDELTA,
   DATETIME,
+
+  /** A list of Dag run states, as `allowed_states` takes. */
+  DAG_RUN_STATES,
+
+  /** A map with string keys, as `conf` takes. */
+  JSON_OBJECT,
 }
 
+/** A Dag run state, as a `DAG_RUN_STATES` field names one. */
+private val DAG_RUN_STATE_NAMES = listOf("queued", "running", "success", 
"failed")
+
 /**
  * One configuration key from the Dag serialization schema. Public so that the
  * annotation processor can lower `@Builder.Dag` / `@Builder.Task` attributes;
@@ -103,5 +112,29 @@ internal fun checkConfigValue(
         value is Array<*> && value.all { it is String } -> value.map { it as 
String }
         else -> mismatch("an Iterable of String")
       }
+    FieldType.DAG_RUN_STATES -> {
+      val states =
+        when (value) {
+          is Iterable<*> -> value.toList()
+          is Array<*> -> value.toList()
+          else -> mismatch("an Iterable of Dag run states")
+        }
+      states.map { state ->
+        require(state is String && state in DAG_RUN_STATE_NAMES) {
+          "Value for $scope config key '$key' holds $state, which is not a Dag 
run state; use one " +
+            "of ${DAG_RUN_STATE_NAMES.joinToString()}"
+        }
+        state as String
+      }
+    }
+    FieldType.JSON_OBJECT -> {
+      val map = value as? Map<*, *> ?: mismatch("a Map")
+      map.entries.associate { (mapKey, entry) ->
+        require(mapKey is String) {
+          "Value for $scope config key '$key' must have String keys, got: 
${mapKey?.javaClass?.name}"
+        }
+        mapKey to entry
+      }
+    }
   }
 }
diff --git 
a/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/ArgTestSupport.kt 
b/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/ArgTestSupport.kt
index 6c6af528765..95edc43bbcf 100644
--- a/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/ArgTestSupport.kt
+++ b/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/ArgTestSupport.kt
@@ -92,6 +92,23 @@ internal class FakeXComTransport(
   override fun clearTaskStateStore(tiId: UUID) = throw NotImplementedError()
 
   override fun skipDownstreamTasks(taskIds: List<String>) = throw 
NotImplementedError()
+
+  override fun triggerDagRun(
+    dagId: String,
+    runId: String,
+    logicalDate: OffsetDateTime?,
+    runAfter: OffsetDateTime?,
+    conf: Map<String, Any?>?,
+    resetDagRun: Boolean,
+    note: String?,
+  ): Boolean = throw NotImplementedError()
+
+  override fun getDagRunState(
+    dagId: String,
+    runId: String,
+  ): String = throw NotImplementedError()
+
+  override fun isDagPaused(dagId: String): Boolean = throw 
NotImplementedError()
 }
 
 internal fun startupDetails(argBindings: List<Map<String, Any?>>?): 
StartupDetails =
diff --git a/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/ClientTest.kt 
b/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/ClientTest.kt
index 2ff51df4f36..239e2bbf954 100644
--- a/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/ClientTest.kt
+++ b/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/ClientTest.kt
@@ -106,6 +106,23 @@ private class FakeTransport(
   }
 
   override fun skipDownstreamTasks(taskIds: List<String>) = Unit
+
+  override fun triggerDagRun(
+    dagId: String,
+    runId: String,
+    logicalDate: OffsetDateTime?,
+    runAfter: OffsetDateTime?,
+    conf: Map<String, Any?>?,
+    resetDagRun: Boolean,
+    note: String?,
+  ): Boolean = throw NotImplementedError()
+
+  override fun getDagRunState(
+    dagId: String,
+    runId: String,
+  ): String = throw NotImplementedError()
+
+  override fun isDagPaused(dagId: String): Boolean = throw 
NotImplementedError()
 }
 
 class ClientTest {
diff --git a/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/SwitchTest.kt 
b/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/SwitchTest.kt
index ef72edf3a4c..83bd8305d9f 100644
--- a/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/SwitchTest.kt
+++ b/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/SwitchTest.kt
@@ -89,6 +89,23 @@ internal class SwitchTest {
     )
   }
 
+  @Test
+  @DisplayName("Should reject a task that triggers a Dag run as a case")
+  fun shouldRejectTriggerTaskAsCase() {
+    val dag = DagDef("d")
+    val trigger = dag.task("trigger_x", TriggerDagRun("x"))
+    val switch = dag.Switch(PickPath::class.java)
+
+    val error = assertThrows(IllegalArgumentException::class.java) { 
switch.Case(trigger) }
+
+    assertEquals(
+      "Switch 'pickPath' cannot choose 'trigger_x': a switch names its case by 
its class, and a " +
+        "task that triggers a Dag run has none. Put the trigger downstream of 
an ordinary task and " +
+        "let the switch choose that task, since Airflow skips the downstream 
of a skipped task",
+      error.message,
+    )
+  }
+
   @Test
   @DisplayName("Should reject a case declared in another Dag")
   fun shouldRejectCaseFromAnotherDag() {
diff --git 
a/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/TriggerDagRunTest.kt 
b/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/TriggerDagRunTest.kt
new file mode 100644
index 00000000000..56c0bb410b7
--- /dev/null
+++ b/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/TriggerDagRunTest.kt
@@ -0,0 +1,275 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.airflow.sdk
+
+import org.apache.airflow.sdk.execution.serializeDag
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertFalse
+import org.junit.jupiter.api.Assertions.assertThrows
+import org.junit.jupiter.api.DisplayName
+import org.junit.jupiter.api.Test
+import java.time.Duration
+import java.time.OffsetDateTime
+
+internal class TriggerDagRunTest {
+  @Suppress("UNCHECKED_CAST")
+  private fun serializeTrigger(trigger: TriggerDagRun): Map<String, Any?> {
+    val dag = DagDef("d")
+    dag.task("trigger", trigger)
+    val tasks = serializeDag(dag, "", ".")["tasks"] as List<Map<String, Any?>>
+    return tasks.single()["__var"] as Map<String, Any?>
+  }
+
+  @Test
+  @DisplayName("Should reject a trigger that names no Dag")
+  fun shouldRejectEmptyDagId() {
+    val error = assertThrows(IllegalArgumentException::class.java) { 
TriggerDagRun("") }
+
+    assertEquals("TriggerDagRun needs the ID of the Dag to trigger", 
error.message)
+  }
+
+  @Test
+  @DisplayName("Should point at the constructor for the Dag being triggered")
+  fun shouldRejectTriggerDagIdAsConfig() {
+    val error =
+      assertThrows(IllegalArgumentException::class.java) {
+        TriggerDagRun("downstream").config("trigger_dag_id", "other")
+      }
+
+    assertEquals(
+      "The Dag to trigger is the TriggerDagRun argument, not the config key 
'trigger_dag_id'",
+      error.message,
+    )
+  }
+
+  @Test
+  @DisplayName("Should reject a poke interval that is not a whole number of 
seconds")
+  fun shouldRejectFractionalPokeInterval() {
+    val error =
+      assertThrows(IllegalArgumentException::class.java) {
+        TriggerDagRun("downstream").config("poke_interval", 
Duration.ofMillis(1500))
+      }
+
+    assertEquals(
+      "Value for TriggerDagRun config key 'poke_interval' is PT1.5S; it must 
be a whole, " +
+        "non-negative number of seconds",
+      error.message,
+    )
+  }
+
+  @Test
+  @DisplayName("Should reject a state no Dag run can be in")
+  fun shouldRejectUnknownState() {
+    val error =
+      assertThrows(IllegalArgumentException::class.java) {
+        TriggerDagRun("downstream").config("allowed_states", listOf("success", 
"done"))
+      }
+
+    assertEquals(
+      "Value for TriggerDagRun config key 'allowed_states' holds done, which 
is not a Dag run " +
+        "state; use one of queued, running, success, failed",
+      error.message,
+    )
+  }
+
+  @Test
+  @DisplayName("Should reject a conf value that has no JSON form when the 
setting is made")
+  fun shouldRejectConfWithoutJsonForm() {
+    val error =
+      assertThrows(IllegalArgumentException::class.java) {
+        TriggerDagRun("downstream").config("conf", mapOf("at" to 
Duration.ofSeconds(1)))
+      }
+
+    assertEquals(
+      "Value for TriggerDagRun config key 'conf' must hold only JSON values 
(null, String, " +
+        "Boolean, Number, List, Map), got: java.time.Duration",
+      error.message,
+    )
+  }
+
+  @Test
+  @DisplayName("Should reject a conf holding a number JSON has no form for")
+  fun shouldRejectNonFiniteConf() {
+    val error =
+      assertThrows(IllegalArgumentException::class.java) {
+        TriggerDagRun("downstream").config("conf", mapOf("ratio" to 
listOf(Double.NaN)))
+      }
+
+    assertEquals(
+      "Value for TriggerDagRun config key 'conf' must hold only finite 
numbers, got: NaN",
+      error.message,
+    )
+  }
+
+  @Test
+  @DisplayName("Should reject an empty run ID or note")
+  fun shouldRejectEmptyRunIdAndNote() {
+    listOf("trigger_run_id", "note").forEach { key ->
+      val error =
+        assertThrows(IllegalArgumentException::class.java) { 
TriggerDagRun("downstream").config(key, "") }
+
+      assertEquals("Value for TriggerDagRun config key '$key' must not be 
empty", error.message)
+    }
+  }
+
+  @Test
+  @DisplayName("Should check a value against its key's shape as other config 
does")
+  fun shouldReportMismatchInSharedShape() {
+    val error =
+      assertThrows(IllegalArgumentException::class.java) {
+        TriggerDagRun("downstream").config("wait_for_completion", "yes")
+      }
+
+    assertEquals(
+      "Value for TriggerDagRun config key 'wait_for_completion' must be a 
Boolean, got: java.lang.String",
+      error.message,
+    )
+  }
+
+  @Test
+  @DisplayName("Should reject an unknown setting")
+  fun shouldRejectUnknownKey() {
+    val error =
+      assertThrows(IllegalArgumentException::class.java) {
+        TriggerDagRun("downstream").config("waitForCompletion", true)
+      }
+
+    assertEquals("Unknown TriggerDagRun config key: 'waitForCompletion'", 
error.message)
+  }
+
+  @Test
+  @DisplayName("Should write a trigger task as Python writes a 
TriggerDagRunOperator")
+  fun shouldSerializeDefaults() {
+    val data = serializeTrigger(TriggerDagRun("downstream"))
+
+    assertEquals("TriggerDagRunOperator", data["task_type"])
+    assertEquals("airflow.providers.standard.operators.trigger_dagrun", 
data["_task_module"])
+    assertEquals("#ffefeb", data["ui_color"])
+    assertEquals(mapOf("Triggered DAG" to "_link_TriggerDagRunLink"), 
data["_operator_extra_links"])
+    assertEquals(mapOf("conf" to "py"), data["template_fields_renderers"])
+    assertEquals("downstream", data["trigger_dag_id"])
+    assertEquals("NOTSET", data["logical_date"])
+    assertEquals(false, data["wait_for_completion"])
+    assertEquals(false, data["skip_when_already_exists"])
+    // A Java task's markers belong to a task the Java runtime runs a body for.
+    assertFalse("language" in data)
+    assertFalse("is_stub" in data)
+    assertFalse("reset_dag_run" in data)
+  }
+
+  @Test
+  @DisplayName("Should write a logical date as Python writes a template field, 
and run_after type-encoded")
+  fun shouldSerializeTemporalSettings() {
+    val data =
+      serializeTrigger(
+        TriggerDagRun("downstream")
+          .config("logical_date", OffsetDateTime.parse("2026-09-30T01:02:03Z"))
+          .config("run_after", OffsetDateTime.parse("2026-09-30T00:00:00Z"))
+          .config("poke_interval", Duration.ofSeconds(30)),
+      )
+
+    assertEquals("2026-09-30 01:02:03+00:00", data["logical_date"])
+    assertEquals(mapOf("__type" to "datetime", "__var" to 1790726400.0), 
data["run_after"])
+    assertEquals(30, data["poke_interval"])
+  }
+
+  @Test
+  @DisplayName("Should record the Dags a Dag triggers, sorted as Python sorts 
them")
+  fun shouldRecordDagDependencies() {
+    val dag = DagDef("d")
+    dag.task("second", TriggerDagRun("beta"))
+    dag.task("first", TriggerDagRun("alpha"))
+
+    val dependencies = serializeDag(dag, "", ".")["dag_dependencies"] as 
List<*>
+
+    assertEquals(
+      listOf(
+        mapOf(
+          "source" to "d",
+          "target" to "alpha",
+          "label" to "first",
+          "dependency_type" to "trigger",
+          "dependency_id" to "first",
+        ),
+        mapOf(
+          "source" to "d",
+          "target" to "beta",
+          "label" to "second",
+          "dependency_type" to "trigger",
+          "dependency_id" to "second",
+        ),
+      ),
+      dependencies,
+    )
+  }
+
+  @Test
+  @DisplayName("Should carry a trigger task's own Airflow settings")
+  fun shouldCarryTaskConfig() {
+    val dag = DagDef("d")
+    dag.task("trigger", TriggerDagRun("downstream")).config("retries", 2)
+
+    @Suppress("UNCHECKED_CAST")
+    val tasks = serializeDag(dag, "", ".")["tasks"] as List<Map<String, Any?>>
+
+    assertEquals(2, (tasks.single()["__var"] as Map<*, *>)["retries"])
+  }
+
+  @Test
+  @DisplayName("Should declare a trigger task inside a task group under the 
group's prefix")
+  fun shouldDeclareTriggerInGroup() {
+    val dag = DagDef("d")
+    val ref = dag.taskGroup("downstream").task("trigger", 
TriggerDagRun("other"))
+
+    assertEquals("downstream.trigger", ref.def.id)
+    assertEquals(listOf("downstream.trigger"), dag.tasks.keys.toList())
+  }
+
+  @Test
+  @DisplayName("Should not let a setting made after registration reach the 
registered task")
+  fun shouldSnapshotTriggerOnRegistration() {
+    val dag = DagDef("d")
+    val nested = mutableMapOf<String, Any>("n" to 1)
+    val list = mutableListOf<Any>("a")
+    val trigger = TriggerDagRun("other").config("conf", mapOf("nested" to 
nested, "list" to list))
+    val ref = dag.task("trigger", trigger)
+
+    trigger.config("note", "late")
+    nested["n"] = 2
+    list += "b"
+
+    val registered = ref.def.trigger!!.settings
+    assertFalse("note" in registered)
+    assertEquals(mapOf("nested" to mapOf("n" to 1), "list" to listOf("a")), 
registered["conf"])
+  }
+
+  @Test
+  @DisplayName("Should keep two tasks registered from one builder apart")
+  fun shouldKeepTasksFromOneBuilderApart() {
+    val dag = DagDef("d")
+    val trigger = TriggerDagRun("other").config("note", "first")
+    val first = dag.task("first", trigger)
+    trigger.config("note", "second")
+    val second = dag.task("second", trigger)
+
+    assertEquals("first", first.def.trigger!!.settings["note"])
+    assertEquals("second", second.def.trigger!!.settings["note"])
+  }
+}
diff --git 
a/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/conformance/SerializeJava.kt
 
b/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/conformance/SerializeJava.kt
index e327afd661e..f7d11aa4284 100644
--- 
a/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/conformance/SerializeJava.kt
+++ 
b/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/conformance/SerializeJava.kt
@@ -44,6 +44,7 @@ import org.apache.airflow.sdk.SwitchTask
 import org.apache.airflow.sdk.Task
 import org.apache.airflow.sdk.TaskGroupRef
 import org.apache.airflow.sdk.TaskRef
+import org.apache.airflow.sdk.TriggerDagRun
 import org.apache.airflow.sdk.execution.serializeDag
 import org.apache.airflow.sdk.internal.Field
 import org.apache.airflow.sdk.internal.FieldType
@@ -202,6 +203,7 @@ private fun buildDag(case: JsonNode): DagDef {
     val groupId = task.path("group").asText("")
     val localId = task.path("task_id").asText()
     val branch = task.path("branch")
+    val trigger = task.path("trigger_dag_run")
     val definition =
       when {
         branch.isMissingNode -> {
@@ -214,10 +216,13 @@ private fun buildDag(case: JsonNode): DagDef {
         else -> ConformanceCondition::class.java
       }
     val ref =
-      if (groupId.isEmpty()) {
-        dag.task<Any?>(localId, definition)
-      } else {
-        groups.getValue(groupId).task<Any?>(localId, definition)
+      when {
+        !trigger.isMissingNode ->
+          triggerDagRun(trigger).let {
+            if (groupId.isEmpty()) dag.task(localId, it) else 
groups.getValue(groupId).task(localId, it)
+          }
+        groupId.isEmpty() -> dag.task<Any?>(localId, definition)
+        else -> groups.getValue(groupId).task<Any?>(localId, definition)
       }
     if (!branch.isMissingNode) {
       deciders += (if (branch.has("cases")) SwitchRef.of(ref) else 
asCondition(ref)) to branch
@@ -253,6 +258,30 @@ private fun buildDag(case: JsonNode): DagDef {
   return dag
 }
 
+/** Reads the template fields of a TriggerDagRunOperator from a task of 
test_dags.yaml. */
+private fun triggerDagRun(node: JsonNode): TriggerDagRun {
+  val trigger = TriggerDagRun(node.path("trigger_dag_id").asText())
+  node.fields().forEach { (key, value) ->
+    when (key) {
+      "trigger_dag_id" -> Unit
+      "logical_date", "run_after" -> trigger.config(key, 
triggerDateTime(value.asText()))
+      "conf" -> trigger.config(key, toJsonValue(value) as Map<*, *>)
+      "wait_for_completion", "skip_when_already_exists", "reset_dag_run", 
"fail_when_dag_is_paused", "deferrable" ->
+        trigger.config(key, value.asBoolean())
+      "poke_interval" -> trigger.config(key, 
Duration.ofSeconds(value.asLong()))
+      "allowed_states", "failed_states" -> trigger.config(key, value.map { 
it.asText() })
+      else -> trigger.config(key, value.asText())
+    }
+  }
+  return trigger
+}
+
+/**
+ * A trigger task's `logical_date` or `run_after` from the fixture. The 
fixture spells one the way
+ * Python spells a datetime, with a space, so the separator is normalised 
before parsing.
+ */
+private fun triggerDateTime(text: String): OffsetDateTime = 
OffsetDateTime.parse(text.replaceFirst(" ", "T"))
+
 /** The handle of a task declared as a decider, whose type argument no caller 
reads. */
 @Suppress("UNCHECKED_CAST")
 private fun asCondition(ref: TaskRef<*>): ConditionRef = ConditionRef.of(ref 
as TaskRef<Boolean>)
@@ -271,7 +300,8 @@ private fun toValue(
     // `!datetime` is an ISO 8601 timestamp, and `!timedelta` a number of 
seconds.
     FieldType.DATETIME -> OffsetDateTime.parse(node.asText())
     FieldType.TIMEDELTA -> Duration.ofNanos((node.asText().toDouble() * 
1e9).toLong())
-    FieldType.STRING_ARRAY -> node.map { it.asText() }
+    FieldType.STRING_ARRAY, FieldType.DAG_RUN_STATES -> node.map { it.asText() 
}
+    FieldType.JSON_OBJECT -> toJsonValue(node)!!
   }
 
 /** Reads a YAML literal as the plain value `Serde` writes out. */
diff --git 
a/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/execution/CommTest.kt 
b/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/execution/CommTest.kt
index 1d9b5cc4ace..20fba213948 100644
--- a/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/execution/CommTest.kt
+++ b/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/execution/CommTest.kt
@@ -253,6 +253,85 @@ class CommsTest {
     return (requests.single().rawBody as Map<*, *>) to failure
   }
 
+  /**
+   * Like [roundTrip], but on the transport client, which is where the calls a
+   * trigger or a decider makes live.
+   */
+  private fun <T> transportRoundTrip(
+    response: (Int) -> ByteArray,
+    call: (CoordinatorClient) -> T,
+  ): Triple<Map<*, *>, T?, Throwable?> {
+    val toClient = ByteChannel(autoFlush = true)
+    val fromClient = ByteChannel(autoFlush = true)
+    val comm = CoordinatorComm(toClient, fromClient)
+    val client = CoordinatorClient(comm)
+
+    val requests = ConcurrentLinkedQueue<RawFrame>()
+    val server =
+      Thread {
+        val request = readRequest(fromClient)
+        requests.add(request)
+        runBlocking { toClient.writeFrame(response(request.id)) }
+      }
+    server.start()
+    val outcome = runCatching { call(client) }
+    server.join()
+    comm.close()
+
+    return Triple(requests.single().rawBody as Map<*, *>, outcome.getOrNull(), 
outcome.exceptionOrNull())
+  }
+
+  private fun dagRunExistsResponseFrame(id: Int): ByteArray {
+    val out = ByteArrayOutputStream()
+    MessagePack.newDefaultPacker(out).use { packer ->
+      packer.packArrayHeader(3)
+      packer.packInt(id)
+      packer.packNil()
+      packer.packMapHeader(3)
+      packer.packString("type")
+      packer.packString("ErrorResponse")
+      packer.packString("error")
+      packer.packString("DAGRUN_ALREADY_EXISTS")
+      packer.packString("detail")
+      packer.packMapHeader(1)
+      packer.packString("dag_id")
+      packer.packString("downstream")
+    }
+    return out.toByteArray()
+  }
+
+  private fun dagRunStateResultFrame(id: Int): ByteArray {
+    val out = ByteArrayOutputStream()
+    MessagePack.newDefaultPacker(out).use { packer ->
+      packer.packArrayHeader(3)
+      packer.packInt(id)
+      packer.packMapHeader(2)
+      packer.packString("type")
+      packer.packString("DagRunStateResult")
+      packer.packString("state")
+      packer.packString("running")
+      packer.packNil()
+    }
+    return out.toByteArray()
+  }
+
+  private fun dagResultFrame(id: Int): ByteArray {
+    val out = ByteArrayOutputStream()
+    MessagePack.newDefaultPacker(out).use { packer ->
+      packer.packArrayHeader(3)
+      packer.packInt(id)
+      packer.packMapHeader(3)
+      packer.packString("type")
+      packer.packString("DagResult")
+      packer.packString("dag_id")
+      packer.packString("downstream")
+      packer.packString("is_paused")
+      packer.packBoolean(true)
+      packer.packNil()
+    }
+    return out.toByteArray()
+  }
+
   private suspend fun ByteChannel.writeFrame(payload: ByteArray) {
     writeByteArray(Frame.lengthPrefix(payload.size.toUInt()))
     writeByteArray(payload)
@@ -380,6 +459,71 @@ class CommsTest {
     comm.close()
   }
 
+  @Test
+  @DisplayName("triggerDagRun takes the OKResponse the supervisor answers a 
trigger with")
+  @Timeout(value = 30, unit = TimeUnit.SECONDS)
+  fun triggerDagRunAcceptsOkResponse() {
+    val (body, alreadyExists, failure) =
+      transportRoundTrip(::okResponseFrame) {
+        it.triggerDagRun(
+          dagId = "downstream",
+          runId = "manual__2026-09-30T00:00:00+00:00",
+          logicalDate = OffsetDateTime.parse("2026-09-30T00:00:00Z"),
+          runAfter = null,
+          conf = mapOf("rows" to 2),
+          resetDagRun = false,
+          note = null,
+        )
+      }
+
+    Assertions.assertNull(failure, "triggerDagRun should return normally on an 
OKResponse, got $failure")
+    Assertions.assertEquals(false, alreadyExists)
+    Assertions.assertEquals("TriggerDagRun", body["type"])
+    Assertions.assertEquals("downstream", body["dag_id"])
+    Assertions.assertEquals("manual__2026-09-30T00:00:00+00:00", 
body["run_id"])
+    // MessagePack packs a small integer as a byte, so the conf is compared by 
how it reads.
+    Assertions.assertEquals("{rows=2}", body["conf"].toString())
+  }
+
+  @Test
+  @DisplayName("triggerDagRun reports an existing run rather than failing")
+  @Timeout(value = 30, unit = TimeUnit.SECONDS)
+  fun triggerDagRunReportsAnExistingRun() {
+    val (_, alreadyExists, failure) =
+      transportRoundTrip(::dagRunExistsResponseFrame) {
+        it.triggerDagRun("downstream", "run", null, null, null, false, null)
+      }
+
+    Assertions.assertNull(failure, "an existing run is a value, not an error, 
got $failure")
+    Assertions.assertEquals(true, alreadyExists)
+  }
+
+  @Test
+  @DisplayName("getDagRunState reads the state out of a DagRunStateResult")
+  @Timeout(value = 30, unit = TimeUnit.SECONDS)
+  fun getDagRunStateReadsTheState() {
+    val (body, state, failure) =
+      transportRoundTrip(::dagRunStateResultFrame) { 
it.getDagRunState("downstream", "run") }
+
+    Assertions.assertNull(failure, "getDagRunState should return normally, got 
$failure")
+    Assertions.assertEquals("running", state)
+    Assertions.assertEquals("GetDagRunState", body["type"])
+    Assertions.assertEquals("downstream", body["dag_id"])
+    Assertions.assertEquals("run", body["run_id"])
+  }
+
+  @Test
+  @DisplayName("isDagPaused reads is_paused out of a DagResult")
+  @Timeout(value = 30, unit = TimeUnit.SECONDS)
+  fun isDagPausedReadsTheFlag() {
+    val (body, paused, failure) = transportRoundTrip(::dagResultFrame) { 
it.isDagPaused("downstream") }
+
+    Assertions.assertNull(failure, "isDagPaused should return normally, got 
$failure")
+    Assertions.assertEquals(true, paused)
+    Assertions.assertEquals("GetDag", body["type"])
+    Assertions.assertEquals("downstream", body["dag_id"])
+  }
+
   @Test
   @DisplayName("setVariable keeps a null description on the wire so the 
supervisor accepts the request")
   @Timeout(value = 30, unit = TimeUnit.SECONDS)
diff --git 
a/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/execution/TaskTest.kt 
b/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/execution/TaskTest.kt
index db1478661c1..a18907a4e25 100644
--- a/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/execution/TaskTest.kt
+++ b/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/execution/TaskTest.kt
@@ -27,6 +27,7 @@ import org.apache.airflow.sdk.DagDef
 import org.apache.airflow.sdk.SwitchTask
 import org.apache.airflow.sdk.Task
 import org.apache.airflow.sdk.TaskDef
+import org.apache.airflow.sdk.TriggerDagRun
 import org.apache.airflow.sdk.execution.comm.BundleInfo
 import org.apache.airflow.sdk.execution.comm.DagRun
 import org.apache.airflow.sdk.execution.comm.RetryTask
@@ -294,6 +295,36 @@ class TaskTest {
     Assertions.assertEquals(emptyList<Pair<String, Any>>(), transport.xComs)
   }
 
+  @Test
+  @DisplayName("Should run a trigger task itself and push the ID of the run it 
started")
+  fun shouldRunTriggerTask() {
+    val transport = RecordingTransport()
+
+    val result = runTask(triggerBundle(), startupDetails(taskId = "trigger"), 
Client(startupDetails("trigger"), transport))
+
+    Assertions.assertInstanceOf(SucceedTask::class.java, result)
+    Assertions.assertEquals(listOf("downstream"), transport.triggered)
+    Assertions.assertTrue("trigger_run_id" in transport.xComs.map { it.first 
}) { "pushed: ${transport.xComs}" }
+  }
+
+  @Test
+  @DisplayName("Should fail a trigger task whose client throws")
+  fun shouldFailTriggerTaskWhenClientThrows() {
+    val transport = RecordingTransport().also { it.triggerFailure = 
IllegalStateException("boom") }
+
+    val result = runTask(triggerBundle(), startupDetails(taskId = "trigger"), 
Client(startupDetails("trigger"), transport))
+
+    Assertions.assertInstanceOf(TaskState::class.java, result)
+    Assertions.assertEquals(TaskState.State.FAILED, (result as 
TaskState).state)
+    Assertions.assertFalse("trigger_run_id" in transport.xComs.map { it.first 
})
+  }
+
+  private fun triggerBundle(): Bundle {
+    val dag = DagDef("test_dag")
+    dag.task("trigger", TriggerDagRun("downstream"))
+    return Bundle(listOf(dag))
+  }
+
   private fun switchBundle(): Bundle {
     val dag = DagDef("test_dag")
     val long = dag.task<Unit>("handle_long", HandleLongTask::class.java)
@@ -402,6 +433,23 @@ class TaskTest {
         override fun clearTaskStateStore(tiId: UUID): Unit = throw 
UnsupportedOperationException("not used in test")
 
         override fun skipDownstreamTasks(taskIds: List<String>): Unit = throw 
UnsupportedOperationException("not used in test")
+
+        override fun triggerDagRun(
+          dagId: String,
+          runId: String,
+          logicalDate: OffsetDateTime?,
+          runAfter: OffsetDateTime?,
+          conf: Map<String, Any?>?,
+          resetDagRun: Boolean,
+          note: String?,
+        ): Boolean = throw UnsupportedOperationException("not used in test")
+
+        override fun getDagRunState(
+          dagId: String,
+          runId: String,
+        ): String = throw UnsupportedOperationException("not used in test")
+
+        override fun isDagPaused(dagId: String): Boolean = throw 
UnsupportedOperationException("not used in test")
       },
     )
 
@@ -463,6 +511,8 @@ class TaskTest {
     val xComs = mutableListOf<Pair<String, Any>>()
     val skipped = mutableListOf<String>()
     val events = mutableListOf<String>()
+    val triggered = mutableListOf<String>()
+    var triggerFailure: Throwable? = null
 
     override fun setXCom(
       key: String,
@@ -520,6 +570,27 @@ class TaskTest {
     ): Unit = throw UnsupportedOperationException("not used in test")
 
     override fun clearTaskStateStore(tiId: UUID): Unit = throw 
UnsupportedOperationException("not used in test")
+
+    override fun triggerDagRun(
+      dagId: String,
+      runId: String,
+      logicalDate: OffsetDateTime?,
+      runAfter: OffsetDateTime?,
+      conf: Map<String, Any?>?,
+      resetDagRun: Boolean,
+      note: String?,
+    ): Boolean {
+      triggered += dagId
+      triggerFailure?.let { throw it }
+      return false
+    }
+
+    override fun getDagRunState(
+      dagId: String,
+      runId: String,
+    ): String = throw UnsupportedOperationException("not used in test")
+
+    override fun isDagPaused(dagId: String): Boolean = throw 
UnsupportedOperationException("not used in test")
   }
 
   class SuccessTask : Task {
diff --git 
a/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/execution/TriggerRunnerTest.kt
 
b/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/execution/TriggerRunnerTest.kt
new file mode 100644
index 00000000000..ad0b629fa2a
--- /dev/null
+++ 
b/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/execution/TriggerRunnerTest.kt
@@ -0,0 +1,473 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.airflow.sdk.execution
+
+import org.apache.airflow.sdk.Client
+import org.apache.airflow.sdk.TriggerDagRun
+import org.apache.airflow.sdk.execution.comm.DagRun
+import org.apache.airflow.sdk.execution.comm.DeferTask
+import org.apache.airflow.sdk.execution.comm.RetryTask
+import org.apache.airflow.sdk.execution.comm.StartupDetails
+import org.apache.airflow.sdk.execution.comm.SucceedTask
+import org.apache.airflow.sdk.execution.comm.TIRunContext
+import org.apache.airflow.sdk.execution.comm.TaskInstance
+import org.apache.airflow.sdk.execution.comm.TaskState
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertInstanceOf
+import org.junit.jupiter.api.Assertions.assertNull
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.DisplayName
+import org.junit.jupiter.api.Test
+import java.time.Duration
+import java.time.OffsetDateTime
+import java.util.UUID
+
+/** One call to [FakeTrigger.triggerDagRun], so a test can read what the task 
asked Airflow for. */
+private class TriggerCall(
+  val dagId: String,
+  val runId: String,
+  val logicalDate: OffsetDateTime?,
+  val runAfter: OffsetDateTime?,
+  val conf: Map<String, Any?>?,
+  val resetDagRun: Boolean,
+  val note: String?,
+)
+
+private class FakeTrigger(
+  private val alreadyExists: Boolean = false,
+  private val paused: Boolean = false,
+  private val states: List<String> = emptyList(),
+) : org.apache.airflow.sdk.execution.Client {
+  val triggered = mutableListOf<TriggerCall>()
+  val xComs = mutableListOf<Pair<String, Any>>()
+  var polls = 0
+
+  override fun triggerDagRun(
+    dagId: String,
+    runId: String,
+    logicalDate: OffsetDateTime?,
+    runAfter: OffsetDateTime?,
+    conf: Map<String, Any?>?,
+    resetDagRun: Boolean,
+    note: String?,
+  ): Boolean {
+    triggered += TriggerCall(dagId, runId, logicalDate, runAfter, conf, 
resetDagRun, note)
+    return alreadyExists
+  }
+
+  override fun getDagRunState(
+    dagId: String,
+    runId: String,
+  ): String {
+    val started = triggered.last()
+    assertEquals(started.dagId, dagId)
+    assertEquals(started.runId, runId)
+    return states[polls++.coerceAtMost(states.lastIndex)]
+  }
+
+  override fun isDagPaused(dagId: String): Boolean = paused
+
+  override fun setXCom(
+    key: String,
+    value: Any,
+    dagId: String,
+    taskId: String,
+    runId: String,
+    mapIndex: Int,
+  ) {
+    xComs += key to value
+  }
+
+  override fun getConnection(id: String) = throw 
UnsupportedOperationException("not used in test")
+
+  override fun getVariable(key: String) = throw 
UnsupportedOperationException("not used in test")
+
+  override fun setVariable(
+    key: String,
+    value: String,
+    description: String?,
+  ): Unit = throw UnsupportedOperationException("not used in test")
+
+  override fun deleteVariable(key: String): Unit = throw 
UnsupportedOperationException("not used in test")
+
+  override fun getXCom(
+    key: String,
+    dagId: String,
+    taskId: String,
+    runId: String,
+    mapIndex: Int?,
+    includePriorDates: Boolean,
+  ) = throw UnsupportedOperationException("not used in test")
+
+  override fun getTaskStateStore(
+    tiId: UUID,
+    key: String,
+  ) = throw UnsupportedOperationException("not used in test")
+
+  override fun setTaskStateStore(
+    tiId: UUID,
+    key: String,
+    value: Any,
+    expiresAt: OffsetDateTime?,
+  ): Unit = throw UnsupportedOperationException("not used in test")
+
+  override fun deleteTaskStateStore(
+    tiId: UUID,
+    key: String,
+  ): Unit = throw UnsupportedOperationException("not used in test")
+
+  override fun clearTaskStateStore(tiId: UUID): Unit = throw 
UnsupportedOperationException("not used in test")
+
+  override fun skipDownstreamTasks(taskIds: List<String>): Unit = throw 
UnsupportedOperationException("not used in test")
+}
+
+/** The trigger time the tests run at, so a generated run ID is the same on 
every run. */
+private val NOW: OffsetDateTime = OffsetDateTime.parse("2026-10-08T05:00:00Z")
+
+internal class TriggerRunnerTest {
+  private fun startupDetails(
+    nextMethod: String? = null,
+    nextKwargs: Any? = null,
+    shouldRetry: Boolean = false,
+  ): StartupDetails =
+    StartupDetails().also {
+      it.ti =
+        TaskInstance().also { ti ->
+          ti.id = UUID.randomUUID()
+          ti.taskId = "trigger"
+          ti.dagId = "test_dag"
+          ti.runId = "manual__2026-03-31T00:00:00+00:00"
+          ti.tryNumber = 1
+          ti.dagVersionId = UUID.randomUUID()
+          ti.queue = "workers"
+        }
+      it.tiContext =
+        TIRunContext().apply {
+          dagRun = DagRun().apply { dagId = "test_dag" }
+          this.shouldRetry = shouldRetry
+          this.nextMethod = nextMethod
+          this.nextKwargs = nextKwargs
+        }
+    }
+
+  private fun run(
+    trigger: TriggerDagRun,
+    transport: FakeTrigger,
+    details: StartupDetails = startupDetails(),
+    env: Map<String, String> = emptyMap(),
+    slept: MutableList<Duration> = mutableListOf(),
+  ): Any =
+    TriggerRunner.run(
+      trigger,
+      details,
+      Client(details, transport),
+      env = env::get,
+      sleep = { slept += it },
+      now = { NOW },
+      randomSuffix = { "RaNd0m88" },
+    )
+
+  @Test
+  @DisplayName("Should trigger the run, record its link and succeed without 
waiting")
+  fun shouldTriggerAndSucceed() {
+    val transport = FakeTrigger()
+
+    val result =
+      run(
+        TriggerDagRun("downstream").config("conf", mapOf("rows" to 2)),
+        transport,
+        env = mapOf("AIRFLOW__API__BASE_URL" to 
"https://airflow.example.com/";),
+      )
+
+    assertInstanceOf(SucceedTask::class.java, result)
+    val call = transport.triggered.single()
+    assertEquals("downstream", call.dagId)
+    assertEquals(mapOf("rows" to 2), call.conf)
+    // With neither a logical date nor a run_after, the trigger time is both.
+    assertEquals("manual__2026-10-08T05:00:00+00:00", call.runId)
+    assertEquals(NOW, call.logicalDate)
+    assertNull(call.runAfter)
+    assertEquals(
+      listOf(
+        "_link_TriggerDagRunLink" to 
"https://airflow.example.com/dags/downstream/runs/${call.runId}";,
+        "trigger_run_id" to call.runId,
+      ),
+      transport.xComs,
+    )
+  }
+
+  @Test
+  @DisplayName("Should trigger the run under the ID and settings the task 
carries")
+  fun shouldUseConfiguredRunId() {
+    val transport = FakeTrigger()
+    val logicalDate = OffsetDateTime.parse("2026-09-30T00:00:00Z")
+
+    run(
+      TriggerDagRun("downstream")
+        .config("trigger_run_id", "fixed")
+        .config("logical_date", logicalDate)
+        .config("run_after", logicalDate)
+        .config("reset_dag_run", true)
+        .config("note", "from java"),
+      transport,
+    )
+
+    val call = transport.triggered.single()
+    assertEquals("fixed", call.runId)
+    assertEquals(logicalDate, call.logicalDate)
+    assertEquals(logicalDate, call.runAfter)
+    assertTrue(call.resetDagRun)
+    assertEquals("from java", call.note)
+  }
+
+  @Test
+  @DisplayName("Should name the run after the logical date the task set")
+  fun shouldNameTheRunAfterTheLogicalDate() {
+    val transport = FakeTrigger()
+    val logicalDate = OffsetDateTime.parse("2026-09-30T00:00:00Z")
+
+    run(TriggerDagRun("downstream").config("logical_date", logicalDate), 
transport)
+
+    val call = transport.triggered.single()
+    assertEquals("manual__2026-09-30T00:00:00+00:00", call.runId)
+    assertEquals(logicalDate, call.logicalDate)
+    assertNull(call.runAfter)
+  }
+
+  @Test
+  @DisplayName("Should leave a run with only a run_after no logical date, and 
add a random suffix")
+  fun shouldLeaveRunAfterOnlyRunWithoutALogicalDate() {
+    val transport = FakeTrigger()
+    val runAfter = OffsetDateTime.parse("2026-12-01T00:00:00Z")
+
+    run(TriggerDagRun("downstream").config("run_after", runAfter), transport)
+
+    val call = transport.triggered.single()
+    assertEquals("manual__2026-12-01T00:00:00+00:00_RaNd0m88", call.runId)
+    assertNull(call.logicalDate)
+    assertEquals(runAfter, call.runAfter)
+  }
+
+  @Test
+  @DisplayName("Should skip rather than fail an existing run when the task 
says so")
+  fun shouldSkipWhenAlreadyExists() {
+    val transport = FakeTrigger(alreadyExists = true)
+
+    val result = 
run(TriggerDagRun("downstream").config("skip_when_already_exists", true), 
transport)
+
+    assertEquals(TaskState.State.SKIPPED, (result as TaskState).state)
+  }
+
+  @Test
+  @DisplayName("Should fail an existing run the task does not skip")
+  fun shouldFailWhenAlreadyExists() {
+    val transport = FakeTrigger(alreadyExists = true)
+
+    val result = run(TriggerDagRun("downstream"), transport)
+
+    assertEquals(TaskState.State.FAILED, (result as TaskState).state)
+    assertEquals(listOf("_link_TriggerDagRunLink"), transport.xComs.map { 
it.first })
+  }
+
+  @Test
+  @DisplayName("Should refuse to trigger a paused Dag when the task says so")
+  fun shouldFailWhenDagIsPaused() {
+    val transport = FakeTrigger(paused = true)
+
+    val result = 
run(TriggerDagRun("downstream").config("fail_when_dag_is_paused", true), 
transport)
+
+    assertEquals(TaskState.State.FAILED, (result as TaskState).state)
+    assertTrue(transport.triggered.isEmpty())
+  }
+
+  @Test
+  @DisplayName("Should trigger a Dag that is not paused though the task would 
fail on a paused one")
+  fun shouldTriggerUnpausedDagWhenFailingOnPaused() {
+    val transport = FakeTrigger(paused = false)
+
+    val result = 
run(TriggerDagRun("downstream").config("fail_when_dag_is_paused", true), 
transport)
+
+    assertInstanceOf(SucceedTask::class.java, result)
+    assertEquals(listOf("downstream"), transport.triggered.map { it.dagId })
+  }
+
+  @Test
+  @DisplayName("Should wait until the triggered run reaches an allowed state")
+  fun shouldWaitForCompletion() {
+    val transport = FakeTrigger(states = listOf("running", "success"))
+    val slept = mutableListOf<Duration>()
+
+    val result =
+      run(
+        TriggerDagRun("downstream")
+          .config("wait_for_completion", true)
+          .config("poke_interval", Duration.ofSeconds(5)),
+        transport,
+        slept = slept,
+      )
+
+    assertInstanceOf(SucceedTask::class.java, result)
+    assertEquals(listOf(Duration.ofSeconds(5), Duration.ofSeconds(5)), slept)
+  }
+
+  @Test
+  @DisplayName("Should retry when the triggered run reaches a failed state and 
the task has tries left")
+  fun shouldFailWhenRunFails() {
+    val transport = FakeTrigger(states = listOf("failed"))
+
+    val result =
+      run(
+        TriggerDagRun("downstream").config("wait_for_completion", 
true).config("poke_interval", Duration.ZERO),
+        transport,
+        details = startupDetails(shouldRetry = true),
+      )
+
+    assertInstanceOf(RetryTask::class.java, result)
+  }
+
+  @Test
+  @DisplayName("Should defer the wait to the triggerer when the task is 
deferrable")
+  fun shouldDefer() {
+    val transport = FakeTrigger()
+
+    val result =
+      run(
+        TriggerDagRun("downstream")
+          .config("wait_for_completion", true)
+          .config("deferrable", true)
+          .config("poke_interval", Duration.ofSeconds(30))
+          .config("failed_states", listOf("failed", "queued")),
+        transport,
+        env = mapOf("AIRFLOW__TRIGGERER__QUEUES_ENABLED" to "true"),
+      )
+
+    val defer = assertInstanceOf(DeferTask::class.java, result)
+    
assertEquals("airflow.providers.standard.triggers.external_task.DagStateTrigger",
 defer.classpath)
+    assertEquals("execute_complete", defer.nextMethod)
+    assertEquals("workers", defer.queue)
+    assertEquals(
+      mapOf(
+        "dag_id" to "downstream",
+        "states" to listOf("success", "failed", "queued"),
+        "poll_interval" to 30,
+        "run_ids" to listOf(transport.triggered.single().runId),
+        "execution_dates" to null,
+      ),
+      defer.triggerKwargs,
+    )
+  }
+
+  @Test
+  @DisplayName("Should follow the default deferrable setting when the task 
sets none")
+  fun shouldDeferByDefaultWhenConfigured() {
+    val result =
+      run(
+        TriggerDagRun("downstream").config("wait_for_completion", true),
+        FakeTrigger(),
+        env = mapOf("AIRFLOW__OPERATORS__DEFAULT_DEFERRABLE" to "true"),
+      )
+
+    assertInstanceOf(DeferTask::class.java, result)
+  }
+
+  @Test
+  @DisplayName("Should leave the deferred task's queue unset when triggerer 
queues are not enabled")
+  fun shouldNotQueueDeferralWithoutTriggererQueues() {
+    val result =
+      run(
+        TriggerDagRun("downstream").config("wait_for_completion", 
true).config("deferrable", true),
+        FakeTrigger(),
+      )
+
+    assertNull(assertInstanceOf(DeferTask::class.java, result).queue)
+  }
+
+  @Test
+  @DisplayName("Should not defer a task that does not wait")
+  fun shouldNotDeferWithoutWaiting() {
+    val transport = FakeTrigger()
+
+    val result = run(TriggerDagRun("downstream").config("deferrable", true), 
transport)
+
+    assertInstanceOf(SucceedTask::class.java, result)
+  }
+
+  @Test
+  @DisplayName("Should succeed when the triggerer reports an allowed state")
+  fun shouldResumeWithAllowedState() {
+    val transport = FakeTrigger()
+    val event =
+      mapOf(
+        "__classname__" to "builtins.tuple",
+        "__data__" to listOf("unused", mapOf("run_ids" to listOf("r1"), "r1" 
to "success")),
+      )
+
+    val result =
+      run(
+        TriggerDagRun("downstream").config("wait_for_completion", true),
+        transport,
+        details = startupDetails(nextMethod = "execute_complete", nextKwargs = 
mapOf("event" to event)),
+      )
+
+    assertInstanceOf(SucceedTask::class.java, result)
+    assertTrue(transport.triggered.isEmpty())
+  }
+
+  @Test
+  @DisplayName("Should fail when the triggerer reports a failed state")
+  fun shouldResumeWithFailedState() {
+    val transport = FakeTrigger()
+    val event = listOf("unused", mapOf("run_ids" to listOf("r1"), "r1" to 
"failed"))
+
+    val result =
+      run(
+        TriggerDagRun("downstream").config("wait_for_completion", true),
+        transport,
+        details = startupDetails(nextMethod = "execute_complete", nextKwargs = 
mapOf("event" to event)),
+      )
+
+    assertEquals(TaskState.State.FAILED, (result as TaskState).state)
+  }
+
+  @Test
+  @DisplayName("Should fail when the trigger itself failed")
+  fun shouldResumeFromTriggerFailure() {
+    val result =
+      run(
+        TriggerDagRun("downstream"),
+        FakeTrigger(),
+        details =
+          startupDetails(
+            nextMethod = "__fail__",
+            nextKwargs = mapOf("error" to "boom", "traceback" to listOf("line 
one", "line two")),
+          ),
+      )
+
+    assertEquals(TaskState.State.FAILED, (result as TaskState).state)
+  }
+
+  @Test
+  @DisplayName("Should fail when resumed with a method it does not know")
+  fun shouldRejectUnknownResumeMethod() {
+    val result = run(TriggerDagRun("downstream"), FakeTrigger(), details = 
startupDetails(nextMethod = "other"))
+
+    assertEquals(TaskState.State.FAILED, (result as TaskState).state)
+  }
+}
diff --git 
a/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/internal/ArgValuesTest.kt 
b/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/internal/ArgValuesTest.kt
index c8ca101267c..cafbd5e40d2 100644
--- 
a/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/internal/ArgValuesTest.kt
+++ 
b/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/internal/ArgValuesTest.kt
@@ -130,6 +130,23 @@ internal class ArgValuesTest {
         override fun clearTaskStateStore(tiId: UUID): Unit = throw 
NotImplementedError()
 
         override fun skipDownstreamTasks(taskIds: List<String>): Unit = throw 
NotImplementedError()
+
+        override fun triggerDagRun(
+          dagId: String,
+          runId: String,
+          logicalDate: OffsetDateTime?,
+          runAfter: OffsetDateTime?,
+          conf: Map<String, Any?>?,
+          resetDagRun: Boolean,
+          note: String?,
+        ): Boolean = throw NotImplementedError()
+
+        override fun getDagRunState(
+          dagId: String,
+          runId: String,
+        ): String = throw NotImplementedError()
+
+        override fun isDagPaused(dagId: String): Boolean = throw 
NotImplementedError()
       },
     )
 
diff --git a/scripts/ci/lang_sdk_serialization/test_dags.yaml 
b/scripts/ci/lang_sdk_serialization/test_dags.yaml
index ae5a756426d..174accfdd4f 100644
--- a/scripts/ci/lang_sdk_serialization/test_dags.yaml
+++ b/scripts/ci/lang_sdk_serialization/test_dags.yaml
@@ -295,6 +295,9 @@ dags:
         group: checks
 
   # Tasks that trigger other Dags, one with every template field set and one 
with the defaults.
+  # logical_date is a template field, so Python writes whatever the author 
gave it. Spelling it the
+  # way Python spells a datetime keeps an SDK that takes a date-time and one 
that passes the field
+  # through as a string both writing what Python writes.
   - dag_id: conformance_trigger_dag_run
     requires: [trigger_dag_run]
     spec:
@@ -305,7 +308,7 @@ dags:
           trigger_dag_id: conformance_minimal
           trigger_run_id: "triggered_{{ run_id }}"
           conf: {rows: 2, ratio: 0.5, nested: {tags: [a, b]}}
-          logical_date: "{{ ds }}"
+          logical_date: "2026-09-30 00:00:00+00:00"
           wait_for_completion: true
           skip_when_already_exists: true
       - task_id: trigger_default

Reply via email to