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-12628-ea3d6df4a0721ecf9f42811a9cf3e00d82651700
in repository https://gitbox.apache.org/repos/asf/seatunnel.git

commit ee90b50012d44d7b96ee47f07afe995a475a79d2
Author: SEZ <[email protected]>
AuthorDate: Thu Oct 8 13:35:43 2026 +0000

    [Improve][seatunnel-cli] Keep the root cause when reporting a job failure 
(#12628)
---
 seatunnel-cli/seatunnel_cli/cli.py         |  44 +++++++++++-
 seatunnel-cli/tests/test_failure_report.py | 103 +++++++++++++++++++++++++++++
 2 files changed, 144 insertions(+), 3 deletions(-)

diff --git a/seatunnel-cli/seatunnel_cli/cli.py 
b/seatunnel-cli/seatunnel_cli/cli.py
index fe77068fa2..83db23a9e8 100644
--- a/seatunnel-cli/seatunnel_cli/cli.py
+++ b/seatunnel-cli/seatunnel_cli/cli.py
@@ -39,6 +39,7 @@ from prompt_toolkit.completion import WordCompleter
 from prompt_toolkit.history import FileHistory
 
 from . import __version__, get_data_dir
+from .diagnostics import ParsedError, parse_error
 from .llm_provider import create_provider, format_llm_error
 from .agents import Orchestrator
 
@@ -123,6 +124,28 @@ def _restore_creds_from_placeholders(config: str, 
cred_map: dict[str, str]) -> s
     return config
 
 
+_ERROR_EXCERPT_LIMIT = 3000
+_OMITTED_MARKER = "...(earlier frames omitted)...\n"
+
+
+def _build_failure_report(error_text: str) -> tuple[ParsedError, str]:
+    """Parse a raw job failure and render the excerpt that is sent to the 
model.
+
+    Two separate things happen here on purpose. The parse runs on the *whole*
+    text, because the structured fields are cheap and we want them even when
+    the trace is far too long to forward. The excerpt keeps the *end* of the
+    text, because a Java trace prints the outer wrapper first and the innermost
+    ``Caused by`` last -- so cutting the front discards the root cause, the one
+    field a repair prompt actually needs. `_run_local` already tail-truncates
+    its captured output for the same reason.
+    """
+    parsed = parse_error(error_text)
+    text = error_text or ""
+    if len(text) <= _ERROR_EXCERPT_LIMIT:
+        return parsed, text
+    return parsed, _OMITTED_MARKER + text[-_ERROR_EXCERPT_LIMIT:]
+
+
 class SeaTunnelCLI:
     """Interactive CLI for SeaTunnel config generation."""
 
@@ -1411,7 +1434,9 @@ class SeaTunnelCLI:
     def _show_error_and_diagnose(self, error_text: str):
         """Diagnose error and directly patch the existing config, with 
conversation memory."""
         from .memory import redact_credentials
-        truncated = error_text[:3000]
+        parsed, excerpt = _build_failure_report(error_text)
+        if parsed.code or parsed.exception:
+            self.console.print(f"  Root cause: {parsed.headline()}", 
style="info")
         self.console.print("  Diagnosing and fixing config...", style="info")
 
         repair_system = (
@@ -1435,9 +1460,22 @@ class SeaTunnelCLI:
         safe_config, cred_map = _replace_creds_with_placeholders(
             self.last_config
         ) if self.last_config else ("", {})
-        safe_error = redact_credentials(truncated)
+        safe_error = redact_credentials(excerpt)
+        # The parsed fields come from the whole trace, so they survive even 
when
+        # the excerpt below had to drop frames. Stating them separately also 
stops
+        # the model from having to re-derive the error code from the raw text.
+        # They are redacted as well: they are cut from that same trace, so a
+        # root_cause line can carry a JDBC URL with a password in it. 
"signature"
+        # is dropped because it is an internal dedupe key, not a diagnosis.
+        facts = "\n".join(
+            f"- {k}: {redact_credentials(str(v))}"
+            for k, v in parsed.as_dict().items()
+            if k != "signature"
+        )
         repair_msg = (
-            f"Job execution failed with this 
error:\n\n```\n{safe_error}\n```\n\n"
+            f"Job execution failed.\n\n"
+            f"Parsed failure:\n{facts}\n\n"
+            f"Raw error:\n\n```\n{safe_error}\n```\n\n"
             f"The config that failed:\n```hocon\n{safe_config}\n```\n\n"
             f"Fix this config. Only change what's needed to resolve the error."
         )
diff --git a/seatunnel-cli/tests/test_failure_report.py 
b/seatunnel-cli/tests/test_failure_report.py
new file mode 100644
index 0000000000..6f2d566aaa
--- /dev/null
+++ b/seatunnel-cli/tests/test_failure_report.py
@@ -0,0 +1,103 @@
+#
+# 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.
+#
+
+"""Tests for the failure excerpt handed to the config-repair prompt."""
+
+from seatunnel_cli.cli import (
+    _ERROR_EXCERPT_LIMIT,
+    _OMITTED_MARKER,
+    _build_failure_report,
+)
+
+
+def _long_trace() -> str:
+    """A trace longer than the excerpt limit whose root cause is at the very 
end.
+
+    This is the realistic shape: Zeta wraps the connector failure, so the
+    generic wrapper is printed first and the specific `Caused by` last. Long
+    traces are produced by deep frame chains, not by a long first line.
+    """
+    head = (
+        "org.apache.seatunnel.core.starter.exception.CommandExecuteException: "
+        "SeaTunnel job executed failed\n"
+    )
+    filler = "".join(
+        "\tat org.apache.seatunnel.engine.server.task.SeaTunnelTask."
+        f"stateProcess(SeaTunnelTask.java:{line})\n"
+        for line in range(100, 140)
+    )
+    while len(head + filler) <= _ERROR_EXCERPT_LIMIT:
+        filler += filler
+    tail = (
+        "Caused by: 
org.apache.seatunnel.common.exception.SeaTunnelRuntimeException: "
+        "ErrorCode:[JDBC-05], ErrorDescription:[Connect to database failed]\n"
+        "Caused by: java.sql.SQLException: Access denied for user 'bench'\n"
+    )
+    return head + filler + tail
+
+
+def test_root_cause_is_parsed_from_the_end_of_an_oversized_trace():
+    # The whole point of parsing before truncating: the error code lives past
+    # the excerpt limit, so a parser fed the truncated text would see nothing.
+    trace = _long_trace()
+    assert len(trace) > _ERROR_EXCERPT_LIMIT
+
+    parsed, _ = _build_failure_report(trace)
+
+    assert parsed.code == "JDBC-05"
+    assert parsed.description == "Connect to database failed"
+    assert parsed.component == "JDBC"
+
+
+def test_excerpt_keeps_the_tail_rather_than_the_head():
+    trace = _long_trace()
+
+    _, excerpt = _build_failure_report(trace)
+
+    assert excerpt.startswith(_OMITTED_MARKER)
+    assert len(excerpt) == len(_OMITTED_MARKER) + _ERROR_EXCERPT_LIMIT
+    # The root cause must survive; the outer wrapper is what may be dropped.
+    assert "Access denied for user" in excerpt
+    assert "ErrorCode:[JDBC-05]" in excerpt
+    assert "CommandExecuteException" not in excerpt
+
+
+def test_short_trace_is_forwarded_unchanged_and_unmarked():
+    trace = "java.lang.ClassNotFoundException: com.mysql.cj.jdbc.Driver\n"
+
+    parsed, excerpt = _build_failure_report(trace)
+
+    assert excerpt == trace
+    assert _OMITTED_MARKER not in excerpt
+    assert parsed.exception == "java.lang.ClassNotFoundException"
+
+
+def test_a_trace_exactly_at_the_limit_is_not_marked_as_cut():
+    trace = "x" * _ERROR_EXCERPT_LIMIT
+
+    _, excerpt = _build_failure_report(trace)
+
+    assert excerpt == trace
+
+
+def test_empty_and_non_string_input_do_not_raise():
+    # This runs while the CLI is already reporting a failure; raising here 
would
+    # replace the user's real error with ours.
+    for value in ("", None):
+        parsed, excerpt = _build_failure_report(value)
+        assert excerpt == ""
+        assert parsed.signature == "empty"

Reply via email to