feat(workflows): make shell step timeout configurable (#3327)

The `shell` step hardcoded a 300s subprocess timeout, so any command
that legitimately runs longer than five minutes (a full build, a linter
aggregator, an integration-test target) was killed with TimeoutExpired
and failed the whole run, with no YAML knob to raise the limit.

Add an optional `timeout` field (seconds) that defaults to 300 for
backward compatibility and is threaded through to `subprocess.run`. The
timeout failure message now reports the configured value instead of a
hardcoded 300. `validate` rejects a `timeout` that is not a positive
number (bool is rejected explicitly, since it is an int subclass but a
config error rather than a duration).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Noor-ul-ain001
2026-07-04 10:29:48 +05:00
parent bba473c223
commit 063c29ec1b
2 changed files with 109 additions and 2 deletions

View File

@@ -26,6 +26,11 @@ class ShellStep(StepBase):
cwd = context.project_root or "."
# Per-step execution timeout in seconds; defaults to 300 for backward
# compatibility. ``validate`` guarantees a positive number when the
# field is present, so ``execute`` can pass it straight through.
timeout = config.get("timeout", 300)
# NOTE: shell=True is required to support pipes, redirects, and
# multi-command expressions in workflow YAML. Workflow authors
# control commands; catalog-installed workflows should be reviewed
@@ -37,7 +42,7 @@ class ShellStep(StepBase):
capture_output=True,
text=True,
cwd=cwd,
timeout=300,
timeout=timeout,
)
output = {
"exit_code": proc.returncode,
@@ -74,7 +79,7 @@ class ShellStep(StepBase):
except subprocess.TimeoutExpired:
return StepResult(
status=StepStatus.FAILED,
error="Shell command timed out after 300 seconds.",
error=f"Shell command timed out after {timeout} seconds.",
output={"exit_code": -1, "stdout": "", "stderr": "timeout"},
)
except OSError as exc:
@@ -96,4 +101,17 @@ class ShellStep(StepBase):
f"Shell step {config.get('id', '?')!r}: 'output_format' must "
f"be 'json' when present, got {output_format!r}."
)
if "timeout" in config:
timeout = config["timeout"]
# bool is a subclass of int, but ``timeout: true`` is a config
# error rather than a duration — reject it explicitly.
if (
isinstance(timeout, bool)
or not isinstance(timeout, (int, float))
or timeout <= 0
):
errors.append(
f"Shell step {config.get('id', '?')!r}: 'timeout' must be a "
f"positive number of seconds, got {timeout!r}."
)
return errors