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

github-merge-queue[bot] pushed a commit to branch 
gh-readonly-queue/dev/pr-12898-f3381ba41361cd39ec15dc40cd19239dd470e411
in repository https://gitbox.apache.org/repos/asf/seatunnel.git

commit c389c24b0ec24c55dafbf2d0e9d98189474d3e2f
Author: zhiweiniu <[email protected]>
AuthorDate: Tue Oct 27 12:09:05 2026 +0000

    [Fix][Core] Fix application CLI validation and status handling (#12898)
---
 docs/en/getting-started/application/cli.md         |  32 ++--
 .../application/kubernetes/commands.md             |  15 +-
 .../introduction/concepts/incompatible-changes.md  |   6 +
 docs/zh/getting-started/application/cli.md         |  29 +++-
 .../application/kubernetes/commands.md             |  15 +-
 .../introduction/concepts/incompatible-changes.md  |   6 +
 .../core/starter/utils/CommandLineUtils.java       |  31 +++-
 .../starter/seatunnel/SeaTunnelApplication.java    |  15 +-
 .../core/starter/seatunnel/SeaTunnelClient.java    |   5 +-
 .../seatunnel/args/ApplicationCommandArgs.java     |  54 +++++--
 .../command/ApplicationExecuteCommand.java         |  17 ++-
 .../starter/seatunnel/SeaTunnelClientOOMTest.java  |   3 +-
 .../seatunnel/args/ClientCommandArgsTest.java      |  23 +++
 .../command/ApplicationExecuteCommandTest.java     | 166 ++++++++++++++++++++-
 .../engine/common/runtime/ApplicationStatus.java   |   4 +-
 15 files changed, 362 insertions(+), 59 deletions(-)

diff --git a/docs/en/getting-started/application/cli.md 
b/docs/en/getting-started/application/cli.md
index cae235e005..cdcf9229c0 100644
--- a/docs/en/getting-started/application/cli.md
+++ b/docs/en/getting-started/application/cli.md
@@ -6,13 +6,13 @@ title: Command Line
 # Application command line
 
 `bin/seatunnel-application.sh` is the only entry point of Application Mode. It 
exposes three
-operations, all of which target one external resource platform and read the 
same deployment file,
+operations, all of which target one external resource platform using matching 
deployment options,
 so `status` and `cancel` see exactly the application `submit` created.
 
 ## Synopsis
 
 ```bash
-bin/seatunnel-application.sh submit -t <target> -a application.config [-c 
job.conf]
+bin/seatunnel-application.sh submit -t <target> [-a application.config] 
[-Dkey=value ...] [-c job.conf]
 bin/seatunnel-application.sh status -t <target> --id APPLICATION_ID -a 
application.config [--wait] \
   [--wait-timeout <ms>] [--wait-interval <ms>]
 bin/seatunnel-application.sh cancel -t <target> --id APPLICATION_ID -a 
application.config
@@ -57,7 +57,7 @@ Usage: seatunnel-application.sh [options] submit | status | 
cancel
 | `submit` / `status` / `cancel` | all | Operation, the first positional 
argument |
 | `-t`, `--target` | all | Resource platform that runs the application |
 | `-c`, `--config` | submit | SeaTunnel job configuration file; optional when 
the platform carries the job, such as a mounted `job.conf` |
-| `-a`, `--application-config` | all | Optional deployment file with common 
and platform options |
+| `-a`, `--application-config` | all | Optional deployment file with common 
and platform options; options can also be supplied through `-D` |
 | `-Dkey=value` | all | Override one non-sensitive `application.*` or 
`kubernetes.*` deployment option, for example `-Dapplication.worker-count=4`; 
repeatable. Job `${...}` variables are resolved inside the Pod, not on the 
submitting host |
 | `--id` | status, cancel | Platform application ID printed by `submit` |
 | `--wait` | submit, status | Poll until a terminal status |
@@ -71,7 +71,7 @@ Usage: seatunnel-application.sh [options] submit | status | 
cancel
 | Operation | What it does | Result |
 | --- | --- | --- |
 | `submit` | Localizes the job, creates the application on the platform and 
starts it | Prints the application ID and the native job ID |
-| `status` | Reads the platform state and maps it to an `ApplicationStatus` | 
Prints the application ID and status; exits non-zero when the application 
failed |
+| `status` | Reads the platform state and maps it to an `ApplicationStatus` | 
Prints the application ID and status; a successful plain query exits `0`; with 
`--wait`, follows the [waiting exit codes](#status-and-waiting) |
 | `cancel` | Removes the application and the resources it owns | Prints the 
application ID and the cancellation result |
 
 ## Submit returns immediately
@@ -89,6 +89,10 @@ The job comes from `-c` or from a file the platform mounts 
inside the applicatio
 resource is created. Values written as `${VAR}` are resolved inside the 
running application, never
 on the submitting host, so platform-managed credentials can be injected 
through the environment.
 
+The deployment file (`-a`) is optional when `-D` supplies the required 
deployment options.
+When both are given, `-D` overrides file values. Platform validation still 
applies: omitting
+`-a` does not make incomplete deployment settings valid.
+
 ## Status and waiting
 
 `status` reports one snapshot by default and polls that snapshot until it 
becomes terminal when
@@ -106,14 +110,24 @@ often the status is polled. The platform-independent 
states are:
 | `CANCELED` | The platform reported a cancellation |
 | `UNKNOWN` | A known application has a platform state that cannot yet be 
mapped |
 
-`SUCCEEDED`, `FAILED` and `CANCELED` are terminal; `--wait` stops there. 
`FAILED`, `CANCELED` and
-`UNKNOWN` make `status` exit non-zero, which turns the command into a 
completion gate for
-schedulers and CI:
+`SUCCEEDED`, `FAILED` and `CANCELED` are terminal; `--wait` stops there. It 
also stops at
+`UNKNOWN` when the platform cannot determine the application outcome. For both 
`status --wait`
+and `submit --wait`, `FAILED` exits with code `1`, while `UNKNOWN` exits with 
code `2`: the result
+is indeterminate, not a confirmed failure or success. `SUCCEEDED` and 
`CANCELED` still exit `0`.
+A plain `status` prints the snapshot and exits `0`, including for `FAILED` and 
`UNKNOWN`;
+query errors still exit non-zero. Do not interpret every non-zero exit as a 
failed application:
 
 ```bash
-if ! bin/seatunnel-application.sh status -t <target> --id "$APP_ID" -a 
application.config --wait; then
-  echo "application $APP_ID did not succeed"
+if bin/seatunnel-application.sh status -t <target> --id "$APP_ID" -a 
application.config --wait; then
+  result=0
+else
+  result=$?
 fi
+case "$result" in
+  0) echo "application status reported; inspect the printed status" ;;
+  2) echo "application $APP_ID outcome is unknown" ;;
+  *) echo "application failed or the command could not complete (exit 
$result)" ;;
+esac
 ```
 
 ## Cancel
diff --git a/docs/en/getting-started/application/kubernetes/commands.md 
b/docs/en/getting-started/application/kubernetes/commands.md
index 378eae110b..29bcf1d16f 100644
--- a/docs/en/getting-started/application/kubernetes/commands.md
+++ b/docs/en/getting-started/application/kubernetes/commands.md
@@ -44,8 +44,11 @@ Application ID: example-9f2c41a0b7d3
 Status: RUNNING
 ```
 
-`--wait` keeps polling until a terminal status is reported; `FAILED`, 
`CANCELED` and `UNKNOWN` make
-the command exit non-zero, which turns it into a completion gate for 
schedulers and CI.
+A plain `status` prints the snapshot and exits `0` when the query succeeds, 
including for `FAILED`
+and `UNKNOWN`; query errors exit non-zero. `--wait` keeps polling until a 
terminal status or `UNKNOWN`
+is reported. Both `status --wait` and `submit --wait` exit `1` for `FAILED`, 
`2` for `UNKNOWN`
+(an indeterminate outcome, not a confirmed success or failure), and `0` for 
`SUCCEEDED` or `CANCELED`.
+See [Status and waiting](../cli.md#status-and-waiting) for the shared 
exit-code contract.
 `--wait-timeout` bounds that polling and fails with the last observed status 
when the limit is
 reached, while the default `0` keeps waiting indefinitely; `--wait-interval` 
sets the poll interval
 (500 ms by default).
@@ -54,10 +57,10 @@ reached, while the default `0` keeps waiting indefinitely; 
`--wait-interval` set
 
 `cancel` deletes the owner Job of the application. Owned Pods, Services, 
Secrets and ConfigMaps are
 garbage-collected through the owner reference; referenced objects created 
outside SeaTunnel are
-never modified or deleted. After cancellation the application reports 
`CANCELED`, and a finished
-Job is retained until `kubernetes.finished-job-retention-seconds` expires. A 
canceled master exits
-with code 1, because a Kubernetes Job cannot express `CANCELED`, so the Job 
object ends in the
-`Failed` state while `status` reports `CANCELED`.
+never modified or deleted. The command reports `Cancellation requested` once 
the delete is accepted.
+Because the owner Job is removed, a later `status` returns `UNKNOWN`; use 
`kubectl` to confirm that
+the Pods are gone. If the delete call fails before the Job is removed, retry 
`cancel` after the API
+outage is resolved.
 
 ## Inspecting in Kubernetes
 
diff --git a/docs/en/introduction/concepts/incompatible-changes.md 
b/docs/en/introduction/concepts/incompatible-changes.md
index 29958c414f..3984ea546a 100644
--- a/docs/en/introduction/concepts/incompatible-changes.md
+++ b/docs/en/introduction/concepts/incompatible-changes.md
@@ -41,6 +41,12 @@ The same strictness applies wherever the shared JSON-to-row 
conversion is used w
   - **Impact**: With the defaults, timestamps are written as `yyyy-MM-dd 
HH:mm:ss` (a space instead of `T`, fractional seconds dropped) and times as 
`HH:mm:ss`. Readers that expect the previous ISO layout must be adjusted.
   - **Migration Guide**: To keep an ISO layout, set `datetime_format` on the 
sink, for example `YYYY_MM_DD_HH_MM_SS_ISO8601` (`yyyy-MM-dd'T'HH:mm:ss`) or 
`YYYY_MM_DD_HH_MM_SS_SSS_ISO8601` (`yyyy-MM-dd'T'HH:mm:ss.SSS`) to keep 
milliseconds, and read the files with the matching source `datetime_format`.
 
+### Native CLI help exit code
+
+`seatunnel.sh -h` now exits with `0` instead of `234`.
+Update scripts that recognize native help by exit code `234`. The Spark/Flink 
Java launcher
+protocol still uses `234`; its wrapper scripts continue to translate help to 
exit `0`.
+
 ### CDC: nullable columns are no longer used as the snapshot split column
 
 - **Behavior change: JDBC-based CDC sources (MySQL, PostgreSQL, Oracle, SQL 
Server, DB2) no longer split the snapshot on a nullable column**
diff --git a/docs/zh/getting-started/application/cli.md 
b/docs/zh/getting-started/application/cli.md
index da7433d3d7..1c19c11f4b 100644
--- a/docs/zh/getting-started/application/cli.md
+++ b/docs/zh/getting-started/application/cli.md
@@ -6,13 +6,13 @@ title: 命令行
 # Application 命令行
 
 `bin/seatunnel-application.sh` 是 Application Mode 的唯一入口,只提供三个操作。三者都面向同一个
-外部资源平台并读取同一份部署文件,因此 `status`、`cancel` 看到的正是 `submit` 创建的
+外部资源平台并使用一致的部署选项,因此 `status`、`cancel` 看到的正是 `submit` 创建的
 application。
 
 ## 命令格式
 
 ```bash
-bin/seatunnel-application.sh submit -t <target> -a application.config [-c 
job.conf]
+bin/seatunnel-application.sh submit -t <target> [-a application.config] 
[-Dkey=value ...] [-c job.conf]
 bin/seatunnel-application.sh status -t <target> --id APPLICATION_ID -a 
application.config [--wait] \
   [--wait-timeout <ms>] [--wait-interval <ms>]
 bin/seatunnel-application.sh cancel -t <target> --id APPLICATION_ID -a 
application.config
@@ -57,7 +57,7 @@ Usage: seatunnel-application.sh [options] submit | status | 
cancel
 | `submit` / `status` / `cancel` | 全部 | 操作类型,位于第一个位置参数 |
 | `-t`、`--target` | 全部 | 运行 application 的资源平台 |
 | `-c`、`--config` | submit | SeaTunnel 作业配置文件;作业由平台提供(如挂载的 `job.conf`)时可省略 |
-| `-a`、`--application-config` | 全部 | 可选部署文件,包含公共选项和平台选项 |
+| `-a`、`--application-config` | 全部 | 包含公共选项和平台选项的可选部署文件;也可通过 `-D` 提供选项 |
 | `-Dkey=value` | 全部 | 覆盖单个非敏感的 `application.*` / `kubernetes.*` 部署选项(例如 
`-Dapplication.worker-count=4`),可重复;作业 `${...}` 变量在 Pod 内解析,不在提交机替换 |
 | `--id` | status、cancel | `submit` 打印的平台 application ID |
 | `--wait` | submit、status | 轮询直到终态 |
@@ -71,7 +71,7 @@ Usage: seatunnel-application.sh [options] submit | status | 
cancel
 | 操作 | 做什么 | 结果 |
 | --- | --- | --- |
 | `submit` | 完成作业本地化,在平台上创建 application 并启动 | 打印 application ID 与原生作业 ID |
-| `status` | 读取平台状态并映射为 `ApplicationStatus` | 打印 application ID 
与状态;application 失败时以非零码退出 |
+| `status` | 读取平台状态并映射为 `ApplicationStatus` | 打印 application ID 与状态;普通查询成功时返回 
`0`;带 `--wait` 时遵循[等待退出码规则](#状态与等待) |
 | `cancel` | 删除 application 及其自有资源 | 打印 application ID 与取消结果 |
 
 ## submit 立即返回
@@ -88,6 +88,9 @@ bin/seatunnel-application.sh submit -t <target> -a 
application.config --wait
 的值在运行中的 application 内解析,不在提交机解析,因此可以通过环境变量注入平台管理的
 凭据。
 
+通过 `-D` 提供所需部署选项时,可以省略部署文件(`-a`)。两者同时提供时,`-D` 覆盖文件值。
+平台配置校验仍然生效:省略 `-a` 不代表不完整的部署配置可以提交成功。
+
 ## 状态与等待
 
 `status` 默认只读一次快照;加 `--wait` 后持续轮询直到终态。`--wait-timeout` 可以限制轮询时长,
@@ -104,13 +107,23 @@ bin/seatunnel-application.sh submit -t <target> -a 
application.config --wait
 | `CANCELED` | 平台报告已取消 |
 | `UNKNOWN` | 已知 application 的平台状态暂时无法映射 |
 
-`SUCCEEDED`、`FAILED`、`CANCELED` 为终态,`--wait` 在终态停止。`FAILED`、`CANCELED` 和
-`UNKNOWN` 会让 `status` 以非零码退出,因此可以直接作为调度器或 CI 的完成判定:
+`SUCCEEDED`、`FAILED`、`CANCELED` 为终态,`--wait` 在终态停止;平台无法确定 application
+结果时(`UNKNOWN`)也会停止。`status --wait` 和 `submit --wait` 在 `FAILED` 时退出码为 `1`,
+在 `UNKNOWN` 时退出码为 `2`,表示无法确定成功或失败,而不是认定应用失败。
+`SUCCEEDED` 和 `CANCELED` 仍返回 `0`。不带 `--wait` 的 `status` 打印快照后返回 `0`,
+包括 `FAILED` 和 `UNKNOWN`;查询异常仍以非零码退出。不要把所有非零退出码都当成应用失败:
 
 ```bash
-if ! bin/seatunnel-application.sh status -t <target> --id "$APP_ID" -a 
application.config --wait; then
-  echo "application $APP_ID did not succeed"
+if bin/seatunnel-application.sh status -t <target> --id "$APP_ID" -a 
application.config --wait; then
+  result=0
+else
+  result=$?
 fi
+case "$result" in
+  0) echo "application status reported; inspect the printed status" ;;
+  2) echo "application $APP_ID outcome is unknown" ;;
+  *) echo "application failed or the command could not complete (exit 
$result)" ;;
+esac
 ```
 
 ## cancel
diff --git a/docs/zh/getting-started/application/kubernetes/commands.md 
b/docs/zh/getting-started/application/kubernetes/commands.md
index a0af9d9aea..2d47a9fb11 100644
--- a/docs/zh/getting-started/application/kubernetes/commands.md
+++ b/docs/zh/getting-started/application/kubernetes/commands.md
@@ -40,17 +40,20 @@ Application ID: example-9f2c41a0b7d3
 Status: RUNNING
 ```
 
-加 `--wait` 会持续轮询直到终态;`FAILED`、`CANCELED` 和 `UNKNOWN` 会让命令以非零码退出,可直接
-作为调度器或 CI 的完成判定。`--wait-timeout` 可以限制轮询时长,超时后带最后状态报错退出;
+不带 `--wait` 的 `status` 打印状态快照,查询成功时返回 `0`,包括 `FAILED` 和 `UNKNOWN`;
+查询异常以非零码退出。加 `--wait` 会持续轮询直到终态或 `UNKNOWN`。
+`status --wait` 和 `submit --wait` 在 `FAILED` 时退出码为 `1`,在 `UNKNOWN` 时为 `2`
+(表示无法确定成功或失败),在 `SUCCEEDED` 或 `CANCELED` 时为 `0`。
+共享退出码规则见[状态与等待](../cli.md#状态与等待)。
+`--wait-timeout` 可以限制轮询时长,超时后带最后状态报错退出;
 默认 `0` 表示一直等待。`--wait-interval` 设置轮询间隔(默认 500 ms)。
 
 ## cancel
 
 `cancel` 删除 application 的 owner Job,自有 Pod、Service、Secret 和 ConfigMap 通过 owner
-reference 一并回收;SeaTunnel 之外创建的引用对象不会被修改或删除。取消后 application 状态为
-`CANCELED`,已完成的 Job 保留到 `kubernetes.finished-job-retention-seconds` 到期。
-被取消的 Master 以退出码 1 结束(Kubernetes Job 无法表达 `CANCELED`),因此 Job 对象最终是
-`Failed` 状态,而 `status` 报告 `CANCELED`。
+reference 一并回收;SeaTunnel 之外创建的引用对象不会被修改或删除。删除请求被接受后命令输出
+`Cancellation requested`。由于 owner Job 已被删除,之后再执行 `status` 会返回 `UNKNOWN`;
+可以用 `kubectl` 确认 Pod 已经消失。如果删除前 API 调用失败,请在 API 恢复后重试 `cancel`。
 
 ## 用 kubectl 排查
 
diff --git a/docs/zh/introduction/concepts/incompatible-changes.md 
b/docs/zh/introduction/concepts/incompatible-changes.md
index f00ffe9c47..3a654648af 100644
--- a/docs/zh/introduction/concepts/incompatible-changes.md
+++ b/docs/zh/introduction/concepts/incompatible-changes.md
@@ -40,6 +40,12 @@
   - **影响**:使用默认配置时,时间戳写成 `yyyy-MM-dd HH:mm:ss`(`T` 改为空格,丢弃小数秒),时间写成 
`HH:mm:ss`。依赖原 ISO 格式的下游需要调整。
   - **迁移指南**:如需保留 ISO 格式,请在 Sink 上配置 `datetime_format`,例如 
`YYYY_MM_DD_HH_MM_SS_ISO8601`(`yyyy-MM-dd'T'HH:mm:ss`),或使用 
`YYYY_MM_DD_HH_MM_SS_SSS_ISO8601`(`yyyy-MM-dd'T'HH:mm:ss.SSS`)保留毫秒,并在读取时为 
Source 配置对应的 `datetime_format`。
 
+### 原生 CLI 帮助退出码
+
+`seatunnel.sh -h` 现在以 `0` 而非 `234` 退出。
+依赖退出码 `234` 识别原生帮助的脚本需要调整。Spark/Flink Java 启动器仍使用 `234` 协议,
+对应包装脚本仍将帮助请求转换为退出码 `0`。
+
 ### CDC:可空列不再用作快照分片列
 
 - **行为变更:基于 JDBC 的 CDC 源(MySQL、PostgreSQL、Oracle、SQL Server、DB2)不再使用可空列切分快照**
diff --git 
a/seatunnel-core/seatunnel-core-starter/src/main/java/org/apache/seatunnel/core/starter/utils/CommandLineUtils.java
 
b/seatunnel-core/seatunnel-core-starter/src/main/java/org/apache/seatunnel/core/starter/utils/CommandLineUtils.java
index 01d82302fc..9b884e660f 100644
--- 
a/seatunnel-core/seatunnel-core-starter/src/main/java/org/apache/seatunnel/core/starter/utils/CommandLineUtils.java
+++ 
b/seatunnel-core/seatunnel-core-starter/src/main/java/org/apache/seatunnel/core/starter/utils/CommandLineUtils.java
@@ -55,6 +55,22 @@ public class CommandLineUtils {
             String programName,
             boolean acceptUnknownOptions,
             Consumer<T> commandValidator) {
+        return parse(
+                args, obj, programName, acceptUnknownOptions, 
commandValidator, USAGE_EXIT_CODE);
+    }
+
+    /**
+     * Parses arguments with an explicit help exit code. The default overloads 
retain the usage exit
+     * code expected by the Spark and Flink launchers; direct entrypoints may 
opt in to a zero exit
+     * code for help. Invalid arguments always use the usage exit code.
+     */
+    public static <T extends CommandArgs> T parse(
+            String[] args,
+            T obj,
+            String programName,
+            boolean acceptUnknownOptions,
+            Consumer<T> commandValidator,
+            int helpExitCode) {
         List<String> list = Arrays.asList(args);
         if (list.contains("-can")
                 || list.contains("--cancel")
@@ -76,21 +92,22 @@ public class CommandLineUtils {
             jCommander.parse(args);
             // The args is not belongs to SeaTunnel, add into engine original 
parameters
             obj.setOriginalParameters(jCommander.getUnknownOptions());
+            // Help must short-circuit before command validation: a bare 
--help has no
+            // operation/id, and validating it would report an unrelated 
argument error.
+            if (obj.isHelp()) {
+                exit(jCommander, helpExitCode);
+            }
             commandValidator.accept(obj);
         } catch (ParameterException e) {
             System.err.println(e.getLocalizedMessage());
-            exit(jCommander);
-        }
-
-        if (obj.isHelp()) {
-            exit(jCommander);
+            exit(jCommander, USAGE_EXIT_CODE);
         }
         return obj;
     }
 
-    private static void exit(JCommander jCommander) {
+    private static void exit(JCommander jCommander, int exitCode) {
         jCommander.setUsageFormatter(new UsageFormatter(jCommander));
         jCommander.usage();
-        System.exit(USAGE_EXIT_CODE);
+        System.exit(exitCode);
     }
 }
diff --git 
a/seatunnel-core/seatunnel-starter/src/main/java/org/apache/seatunnel/core/starter/seatunnel/SeaTunnelApplication.java
 
b/seatunnel-core/seatunnel-starter/src/main/java/org/apache/seatunnel/core/starter/seatunnel/SeaTunnelApplication.java
index 76f4f37e98..a736a97f05 100644
--- 
a/seatunnel-core/seatunnel-starter/src/main/java/org/apache/seatunnel/core/starter/seatunnel/SeaTunnelApplication.java
+++ 
b/seatunnel-core/seatunnel-starter/src/main/java/org/apache/seatunnel/core/starter/seatunnel/SeaTunnelApplication.java
@@ -20,10 +20,12 @@ package org.apache.seatunnel.core.starter.seatunnel;
 import org.apache.seatunnel.common.constants.EngineType;
 import org.apache.seatunnel.core.starter.SeaTunnel;
 import org.apache.seatunnel.core.starter.seatunnel.args.ApplicationCommandArgs;
+import 
org.apache.seatunnel.core.starter.seatunnel.command.ApplicationExecuteCommand;
 import org.apache.seatunnel.core.starter.utils.CommandLineUtils;
 
 /** User-facing entrypoint for native Zeta application deployment and 
management. */
 public final class SeaTunnelApplication {
+    private static final int HELP_EXIT_CODE = 0;
 
     public static void main(String[] args) {
         ApplicationCommandArgs applicationCommandArgs =
@@ -32,13 +34,22 @@ public final class SeaTunnelApplication {
                         new ApplicationCommandArgs(),
                         EngineType.SEATUNNEL_APPLICATION.getStarterShellName(),
                         false,
-                        ApplicationCommandArgs::validateCommandOptions);
+                        ApplicationCommandArgs::validateCommandOptions,
+                        HELP_EXIT_CODE);
+        final int exitCode;
         try {
-            SeaTunnel.run(applicationCommandArgs.buildCommand());
+            ApplicationExecuteCommand command =
+                    (ApplicationExecuteCommand) 
applicationCommandArgs.buildCommand();
+            SeaTunnel.run(command);
+            exitCode = command.getExitCode();
         } catch (Exception e) {
             System.err.println("Application command failed: " + 
e.getMessage());
             e.printStackTrace(System.err);
             System.exit(1);
+            return;
+        }
+        if (exitCode != 0) {
+            System.exit(exitCode);
         }
     }
 }
diff --git 
a/seatunnel-core/seatunnel-starter/src/main/java/org/apache/seatunnel/core/starter/seatunnel/SeaTunnelClient.java
 
b/seatunnel-core/seatunnel-starter/src/main/java/org/apache/seatunnel/core/starter/seatunnel/SeaTunnelClient.java
index e9972e8f34..0213d49e2c 100644
--- 
a/seatunnel-core/seatunnel-starter/src/main/java/org/apache/seatunnel/core/starter/seatunnel/SeaTunnelClient.java
+++ 
b/seatunnel-core/seatunnel-starter/src/main/java/org/apache/seatunnel/core/starter/seatunnel/SeaTunnelClient.java
@@ -29,6 +29,8 @@ import lombok.extern.slf4j.Slf4j;
 
 @Slf4j
 public class SeaTunnelClient {
+    private static final int HELP_EXIT_CODE = 0;
+
     public static void main(String[] args) throws CommandException {
         ClientCommandArgs clientCommandArgs =
                 CommandLineUtils.parse(
@@ -36,7 +38,8 @@ public class SeaTunnelClient {
                         new ClientCommandArgs(),
                         EngineType.SEATUNNEL.getStarterShellName(),
                         true,
-                        ClientCommandArgs::validateCommandOptions);
+                        ClientCommandArgs::validateCommandOptions,
+                        HELP_EXIT_CODE);
         try {
             SeaTunnel.run(clientCommandArgs.buildCommand());
         } catch (Error e) {
diff --git 
a/seatunnel-core/seatunnel-starter/src/main/java/org/apache/seatunnel/core/starter/seatunnel/args/ApplicationCommandArgs.java
 
b/seatunnel-core/seatunnel-starter/src/main/java/org/apache/seatunnel/core/starter/seatunnel/args/ApplicationCommandArgs.java
index 16c5b6dcd9..472a0f742f 100644
--- 
a/seatunnel-core/seatunnel-starter/src/main/java/org/apache/seatunnel/core/starter/seatunnel/args/ApplicationCommandArgs.java
+++ 
b/seatunnel-core/seatunnel-starter/src/main/java/org/apache/seatunnel/core/starter/seatunnel/args/ApplicationCommandArgs.java
@@ -118,24 +118,32 @@ public class ApplicationCommandArgs extends CommandArgs {
         if (applicationConfig != null) {
             requireFile(applicationConfig, "--application-config");
         }
-        if (waitTimeoutMillis != null && waitTimeoutMillis < 0) {
-            throw new ParameterException("--wait-timeout must not be 
negative");
-        }
-        if (waitIntervalMillis != null && waitIntervalMillis < 1) {
-            throw new ParameterException("--wait-interval must be positive");
+        validateWaitOptions();
+        if (operation == ApplicationOperation.SUBMIT) {
+            validateSubmitOptions();
+        } else {
+            validateStatusOrCancelOptions();
         }
-        if (!wait && (waitTimeoutMillis != null || waitIntervalMillis != 
null)) {
-            throw new ParameterException("--wait-timeout and --wait-interval 
require --wait");
+    }
+
+    /**
+     * Checks an explicitly supplied job file and rejects an application ID 
for submission.
+     * Deployment configuration remains optional here so complete {@code -D} 
options can supply it.
+     */
+    private void validateSubmitOptions() {
+        if (config != null) {
+            requireFile(config, "--config");
         }
-        if (operation == ApplicationOperation.SUBMIT) {
-            if (config != null) {
-                requireFile(config, "--config");
-            }
-            if (id != null) {
-                throw new ParameterException("submit does not accept --id");
-            }
-            return;
+        if (id != null) {
+            throw new ParameterException("submit does not accept --id");
         }
+    }
+
+    /**
+     * Requires a nonblank application ID and rejects submit-only options for 
status and cancel.
+     * Only status may wait for completion; cancel does not accept {@code 
--wait}.
+     */
+    private void validateStatusOrCancelOptions() {
         if (id == null || id.trim().isEmpty()) {
             throw new ParameterException("status/cancel require --id");
         }
@@ -151,6 +159,22 @@ public class ApplicationCommandArgs extends CommandArgs {
         }
     }
 
+    /**
+     * Requires a nonnegative timeout and a positive poll interval when 
explicitly supplied. Both
+     * timing options require {@code --wait}; a zero timeout allows indefinite 
waiting.
+     */
+    private void validateWaitOptions() {
+        if (waitTimeoutMillis != null && waitTimeoutMillis < 0) {
+            throw new ParameterException("--wait-timeout must not be 
negative");
+        }
+        if (waitIntervalMillis != null && waitIntervalMillis < 1) {
+            throw new ParameterException("--wait-interval must be positive");
+        }
+        if (!wait && (waitTimeoutMillis != null || waitIntervalMillis != 
null)) {
+            throw new ParameterException("--wait-timeout and --wait-interval 
require --wait");
+        }
+    }
+
     /** @return configured wait timeout in milliseconds, or {@code 0} to wait 
indefinitely */
     public long resolvedWaitTimeoutMillis() {
         return waitTimeoutMillis == null ? 0L : waitTimeoutMillis;
diff --git 
a/seatunnel-core/seatunnel-starter/src/main/java/org/apache/seatunnel/core/starter/seatunnel/command/ApplicationExecuteCommand.java
 
b/seatunnel-core/seatunnel-starter/src/main/java/org/apache/seatunnel/core/starter/seatunnel/command/ApplicationExecuteCommand.java
index dfdebb28d7..025a3adfaa 100644
--- 
a/seatunnel-core/seatunnel-starter/src/main/java/org/apache/seatunnel/core/starter/seatunnel/command/ApplicationExecuteCommand.java
+++ 
b/seatunnel-core/seatunnel-starter/src/main/java/org/apache/seatunnel/core/starter/seatunnel/command/ApplicationExecuteCommand.java
@@ -40,7 +40,16 @@ import java.util.concurrent.TimeUnit;
  */
 public class ApplicationExecuteCommand implements 
Command<ApplicationCommandArgs> {
 
+    /** The platform cannot determine whether the application succeeded or 
failed. */
+    public static final int UNKNOWN_STATUS_EXIT_CODE = 2;
+
     private final ApplicationCommandArgs applicationCommandArgs;
+    private int exitCode;
+
+    /** Returns the CLI outcome after execution without changing the reported 
application status. */
+    public int getExitCode() {
+        return exitCode;
+    }
 
     public ApplicationExecuteCommand(ApplicationCommandArgs 
applicationCommandArgs) {
         this.applicationCommandArgs = applicationCommandArgs;
@@ -48,6 +57,7 @@ public class ApplicationExecuteCommand implements 
Command<ApplicationCommandArgs
 
     @Override
     public void execute() throws CommandExecuteException {
+        exitCode = 0;
         try {
             Map<String, String> options = resolveOptions();
             ClusterClientServiceLoader clientServiceLoader = new 
ClusterClientServiceLoader();
@@ -98,7 +108,12 @@ public class ApplicationExecuteCommand implements 
Command<ApplicationCommandArgs
         try (ClusterDescriptor<ID> descriptor = factory.create(options)) {
             ApplicationStatus status = getStatus(descriptor, applicationId);
             System.out.println("Status: " + status);
-            if (status.isFailure()) {
+            if (applicationCommandArgs.isWait() && status == 
ApplicationStatus.UNKNOWN) {
+                exitCode = UNKNOWN_STATUS_EXIT_CODE;
+                System.err.println(
+                        "Application outcome is unknown; success or failure 
cannot be determined.");
+            }
+            if (applicationCommandArgs.isWait() && status.isFailure()) {
                 throw new CommandExecuteException(
                         "Application finished with an unsuccessful status");
             }
diff --git 
a/seatunnel-core/seatunnel-starter/src/test/java/org/apache/seatunnel/core/starter/seatunnel/SeaTunnelClientOOMTest.java
 
b/seatunnel-core/seatunnel-starter/src/test/java/org/apache/seatunnel/core/starter/seatunnel/SeaTunnelClientOOMTest.java
index 6e348a59f6..024c1ec0a2 100644
--- 
a/seatunnel-core/seatunnel-starter/src/test/java/org/apache/seatunnel/core/starter/seatunnel/SeaTunnelClientOOMTest.java
+++ 
b/seatunnel-core/seatunnel-starter/src/test/java/org/apache/seatunnel/core/starter/seatunnel/SeaTunnelClientOOMTest.java
@@ -50,7 +50,8 @@ public class SeaTunnelClientOOMTest {
                                             
Mockito.any(ClientCommandArgs.class),
                                             Mockito.anyString(),
                                             Mockito.anyBoolean(),
-                                            Mockito.any()))
+                                            Mockito.any(),
+                                            Mockito.eq(0)))
                     .thenReturn(clientCommandArgs);
 
             // Mock SeaTunnel.run to throw OutOfMemoryError
diff --git 
a/seatunnel-core/seatunnel-starter/src/test/java/org/apache/seatunnel/core/starter/seatunnel/args/ClientCommandArgsTest.java
 
b/seatunnel-core/seatunnel-starter/src/test/java/org/apache/seatunnel/core/starter/seatunnel/args/ClientCommandArgsTest.java
index 2d1bb9d9da..3e1fd3504c 100644
--- 
a/seatunnel-core/seatunnel-starter/src/test/java/org/apache/seatunnel/core/starter/seatunnel/args/ClientCommandArgsTest.java
+++ 
b/seatunnel-core/seatunnel-starter/src/test/java/org/apache/seatunnel/core/starter/seatunnel/args/ClientCommandArgsTest.java
@@ -21,6 +21,7 @@ import org.apache.seatunnel.core.starter.SeaTunnel;
 import org.apache.seatunnel.core.starter.enums.DryRun;
 import org.apache.seatunnel.core.starter.enums.MasterType;
 import org.apache.seatunnel.core.starter.exception.CommandExecuteException;
+import org.apache.seatunnel.core.starter.seatunnel.SeaTunnelClient;
 import 
org.apache.seatunnel.core.starter.seatunnel.command.ClientExecuteCommand;
 import 
org.apache.seatunnel.core.starter.seatunnel.multitable.MultiTableSinkTest;
 import org.apache.seatunnel.core.starter.utils.CommandLineUtils;
@@ -168,6 +169,28 @@ public class ClientCommandArgsTest {
         Assertions.assertEquals(USAGE_EXIT_CODE, statusCode);
     }
 
+    @Test
+    public void testHelpExitsZeroBeforeValidation() throws Exception {
+        int statusCode = catchSystemExit(() -> SeaTunnelClient.main(new 
String[] {"--help"}));
+
+        Assertions.assertEquals(0, statusCode);
+    }
+
+    @Test
+    public void testDefaultHelpRetainsUsageExitCodeBeforeValidation() throws 
Exception {
+        int statusCode =
+                catchSystemExit(
+                        () ->
+                                CommandLineUtils.parse(
+                                        new String[] {"--help"},
+                                        new ClientCommandArgs(),
+                                        "legacy-starter",
+                                        true,
+                                        ignored -> Assertions.fail("Help must 
skip validation")));
+
+        Assertions.assertEquals(USAGE_EXIT_CODE, statusCode);
+    }
+
     @Test
     public void testSampleDryRunRejectsJobControlOptions() {
         String[] args = {"-c", "app.conf", "--master", "local", "--dry-run", 
"sample", "--check"};
diff --git 
a/seatunnel-core/seatunnel-starter/src/test/java/org/apache/seatunnel/core/starter/seatunnel/command/ApplicationExecuteCommandTest.java
 
b/seatunnel-core/seatunnel-starter/src/test/java/org/apache/seatunnel/core/starter/seatunnel/command/ApplicationExecuteCommandTest.java
index 5478b3367f..0a0dec0b56 100644
--- 
a/seatunnel-core/seatunnel-starter/src/test/java/org/apache/seatunnel/core/starter/seatunnel/command/ApplicationExecuteCommandTest.java
+++ 
b/seatunnel-core/seatunnel-starter/src/test/java/org/apache/seatunnel/core/starter/seatunnel/command/ApplicationExecuteCommandTest.java
@@ -21,7 +21,9 @@ import 
org.apache.seatunnel.shade.com.typesafe.config.ConfigFactory;
 
 import org.apache.seatunnel.common.constants.ApplicationOperation;
 import org.apache.seatunnel.core.starter.exception.CommandExecuteException;
+import org.apache.seatunnel.core.starter.seatunnel.SeaTunnelApplication;
 import org.apache.seatunnel.core.starter.seatunnel.args.ApplicationCommandArgs;
+import org.apache.seatunnel.core.starter.utils.CommandLineUtils;
 import 
org.apache.seatunnel.engine.client.deployment.ApplicationClusterDeployer;
 import 
org.apache.seatunnel.engine.client.deployment.ApplicationClusterDescriptorFactory;
 import 
org.apache.seatunnel.engine.client.deployment.ClusterClientServiceLoader;
@@ -49,12 +51,15 @@ import java.util.Collections;
 import java.util.Map;
 import java.util.ServiceLoader;
 
+import static 
com.github.stefanbirkner.systemlambda.SystemLambda.catchSystemExit;
+import static 
org.apache.seatunnel.core.starter.constants.SeaTunnelStarterConstants.USAGE_EXIT_CODE;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertSame;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doReturn;
 import static org.mockito.Mockito.doThrow;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.mockConstruction;
@@ -310,6 +315,121 @@ class ApplicationExecuteCommandTest {
         verify(descriptor, never()).retrieve(any());
     }
 
+    @Test
+    void statusWithoutWaitReportsUnknownWithoutFailing() throws Exception {
+        assertEquals(0, executeStatus(ApplicationStatus.UNKNOWN, false));
+    }
+
+    @Test
+    void statusWithWaitReportsUnknownWithDistinctExitCode() throws Exception {
+        assertEquals(
+                ApplicationExecuteCommand.UNKNOWN_STATUS_EXIT_CODE,
+                executeStatus(ApplicationStatus.UNKNOWN, true));
+    }
+
+    @Test
+    void statusWithWaitReportsCanceledWithoutFailing() throws Exception {
+        assertEquals(0, executeStatus(ApplicationStatus.CANCELED, true));
+    }
+
+    @Test
+    void statusWithoutWaitReportsFailedWithoutFailing() throws Exception {
+        assertEquals(0, executeStatus(ApplicationStatus.FAILED, false));
+    }
+
+    @Test
+    void statusWithWaitFailsOnFailed() {
+        assertThrows(
+                CommandExecuteException.class, () -> 
executeStatus(ApplicationStatus.FAILED, true));
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"status", "submit"})
+    void unknownWaitExitsTwoFromApplicationEntrypoint(String operation) throws 
Exception {
+        Path application = temporary.resolve("application.config");
+        Files.write(application, "application.name = 
demo".getBytes(StandardCharsets.UTF_8));
+        ClusterDescriptor<String> descriptor = mock(ClusterDescriptor.class);
+        ApplicationClusterDescriptorFactory<String> factory =
+                mock(ApplicationClusterDescriptorFactory.class);
+        when(factory.create(any())).thenReturn(descriptor);
+        
when(factory.parseApplicationId("application_1")).thenReturn("native-id");
+        when(descriptor.deployApplication(any())).thenReturn("application_1");
+        
when(descriptor.getApplicationStatus("native-id")).thenReturn(ApplicationStatus.UNKNOWN);
+        String[] arguments =
+                "submit".equals(operation)
+                        ? new String[] {
+                            "submit", "-t", "kubernetes", "-a", 
application.toString(), "--wait"
+                        }
+                        : new String[] {
+                            "status", "-t", "kubernetes", "--id", 
"application_1", "--wait"
+                        };
+        try (MockedConstruction<ClusterClientServiceLoader> loaders =
+                mockConstruction(
+                        ClusterClientServiceLoader.class,
+                        (loader, context) ->
+                                
when(loader.<String>getClusterClientFactory(DeployType.KUBERNETES))
+                                        .thenReturn(factory))) {
+            assertEquals(2, catchSystemExit(() -> 
SeaTunnelApplication.main(arguments)));
+        }
+        verify(descriptor).getApplicationStatus("native-id");
+        verify(descriptor, times("submit".equals(operation) ? 2 : 1)).close();
+        verify(descriptor, never()).cancelApplication(any());
+    }
+
+    @Test
+    void helpExitsZeroBeforeCommandValidation() throws Exception {
+        int statusCode =
+                catchSystemExit(
+                        () ->
+                                SeaTunnelApplication.main(
+                                        new String[] {"status", "-t", 
"kubernetes", "--help"}));
+        assertEquals(0, statusCode);
+    }
+
+    @Test
+    void submitWithMissingApplicationConfigExitsThroughUsagePath() throws 
Exception {
+        int statusCode =
+                catchSystemExit(
+                        () ->
+                                CommandLineUtils.parse(
+                                        new String[] {
+                                            "submit",
+                                            "-t",
+                                            "kubernetes",
+                                            "-a",
+                                            
temporary.resolve("missing.config").toString()
+                                        },
+                                        new ApplicationCommandArgs(),
+                                        "seatunnel-application.sh",
+                                        false,
+                                        
ApplicationCommandArgs::validateCommandOptions,
+                                        0));
+        assertEquals(USAGE_EXIT_CODE, statusCode);
+    }
+
+    private int executeStatus(ApplicationStatus status, boolean wait) throws 
Exception {
+        ClusterDescriptor<String> descriptor = mock(ClusterDescriptor.class);
+        ApplicationClusterDescriptorFactory<String> factory =
+                mock(ApplicationClusterDescriptorFactory.class);
+        
when(factory.parseApplicationId("application_1")).thenReturn("native-id");
+        doReturn(descriptor).when(factory).create(any());
+        when(descriptor.getApplicationStatus("native-id")).thenReturn(status);
+        try (MockedConstruction<ClusterClientServiceLoader> loaders =
+                mockConstruction(
+                        ClusterClientServiceLoader.class,
+                        (loader, context) ->
+                                
when(loader.<String>getClusterClientFactory(DeployType.KUBERNETES))
+                                        .thenReturn(factory))) {
+            ApplicationCommandArgs args =
+                    parse("status", "-t", "kubernetes", "--id", 
"application_1");
+            args.setWait(wait);
+            ApplicationExecuteCommand command = (ApplicationExecuteCommand) 
args.buildCommand();
+            command.execute();
+            verify(descriptor).close();
+            return command.getExitCode();
+        }
+    }
+
     @Test
     void waitsForPlatformTerminalState() throws Exception {
         ClusterDescriptor<String> descriptor = mock(ClusterDescriptor.class);
@@ -376,7 +496,7 @@ class ApplicationExecuteCommandTest {
         ApplicationClusterDescriptorFactory<String> factory =
                 mock(ApplicationClusterDescriptorFactory.class);
         
when(factory.parseApplicationId("application_1")).thenReturn("native-id");
-        org.mockito.Mockito.doReturn(descriptor).when(factory).create(any());
+        doReturn(descriptor).when(factory).create(any());
         
when(descriptor.getApplicationStatus("native-id")).thenReturn(ApplicationStatus.SUCCEEDED);
         try (MockedConstruction<ClusterClientServiceLoader> loaders =
                 mockConstruction(
@@ -405,6 +525,50 @@ class ApplicationExecuteCommandTest {
         verify(descriptor, times(2)).close();
     }
 
+    @ParameterizedTest
+    @ValueSource(booleans = {false, true})
+    void submitsWithDeploymentOverridesOnly(boolean wait) throws Exception {
+        ClusterDescriptor<String> descriptor = mock(ClusterDescriptor.class);
+        ApplicationClusterDescriptorFactory<String> factory =
+                mock(ApplicationClusterDescriptorFactory.class);
+        when(factory.create(any())).thenReturn(descriptor);
+        when(descriptor.deployApplication(any())).thenReturn("application_1");
+        
when(factory.parseApplicationId("application_1")).thenReturn("native-id");
+        
when(descriptor.getApplicationStatus("native-id")).thenReturn(ApplicationStatus.SUCCEEDED);
+        try (MockedConstruction<ClusterClientServiceLoader> loaders =
+                mockConstruction(
+                        ClusterClientServiceLoader.class,
+                        (loader, context) ->
+                                
when(loader.<String>getClusterClientFactory(DeployType.KUBERNETES))
+                                        .thenReturn(factory))) {
+            ApplicationCommandArgs args =
+                    parse(
+                            "submit",
+                            "-t",
+                            "kubernetes",
+                            "-Dapplication.name=overrides-only",
+                            "-Dapplication.worker-count=3",
+                            "-Dkubernetes.image=example:latest",
+                            
"-Dkubernetes.job-config-file=/etc/seatunnel/job.conf");
+            args.setWait(wait);
+            args.buildCommand().execute();
+        }
+        ArgumentCaptor<ApplicationSpecification> specification =
+                ArgumentCaptor.forClass(ApplicationSpecification.class);
+        verify(descriptor).deployApplication(specification.capture());
+        assertEquals("overrides-only", specification.getValue().getName());
+        assertEquals(3, specification.getValue().getWorkerCount());
+        assertEquals("", specification.getValue().getJobConfig());
+        ArgumentCaptor<Map<String, String>> deploymentOptions = 
ArgumentCaptor.forClass(Map.class);
+        verify(factory, times(wait ? 2 : 
1)).create(deploymentOptions.capture());
+        assertEquals("example:latest", 
deploymentOptions.getValue().get("kubernetes.image"));
+        assertEquals(
+                "/etc/seatunnel/job.conf",
+                
deploymentOptions.getValue().get("kubernetes.job-config-file"));
+        verify(descriptor, times(wait ? 1 : 
0)).getApplicationStatus("native-id");
+        verify(descriptor, times(wait ? 2 : 1)).close();
+    }
+
     @Test
     void validatesCommandBeforeLoadingAnyPlatformProvider() throws Exception {
         Path deploymentConfig = temporary.resolve("deployment.conf");
diff --git 
a/seatunnel-engine/seatunnel-engine-common/src/main/java/org/apache/seatunnel/engine/common/runtime/ApplicationStatus.java
 
b/seatunnel-engine/seatunnel-engine-common/src/main/java/org/apache/seatunnel/engine/common/runtime/ApplicationStatus.java
index 80b43607fb..0193e524db 100644
--- 
a/seatunnel-engine/seatunnel-engine-common/src/main/java/org/apache/seatunnel/engine/common/runtime/ApplicationStatus.java
+++ 
b/seatunnel-engine/seatunnel-engine-common/src/main/java/org/apache/seatunnel/engine/common/runtime/ApplicationStatus.java
@@ -39,8 +39,8 @@ public enum ApplicationStatus {
         return this == SUCCEEDED || this == FAILED || this == CANCELED;
     }
 
-    /** @return whether this state represents a failed, canceled, or unknown 
application */
+    /** @return whether this state represents a failed application */
     public boolean isFailure() {
-        return this == FAILED || this == CANCELED || this == UNKNOWN;
+        return this == FAILED;
     }
 }

Reply via email to