mirror of
https://github.com/github/spec-kit.git
synced 2026-07-07 06:35:06 +08:00
feat(workflows): add continue_on_error step field
Adds an optional `continue_on_error: bool` field on every step.
When set to `true` and the step fails, the engine records the
result (`exit_code`, `stderr` on `steps.<id>.output` plus `status`
as a sibling key on `steps.<id>`) and continues to the next sibling
step instead of halting the run. Downstream `if`, `switch`, or
`gate` steps can then branch on
`{{ steps.<id>.output.exit_code }}` to route the recovery path.
Engine details
--------------
`WorkflowEngine._execute_steps` now consults the step config when a
step returns `StepStatus.FAILED`:
- Gate aborts (`output.aborted`) always halt the run — operator
decisions take precedence over the flag.
- Otherwise, if `continue_on_error` is the literal `True`, log a
`step_continue_on_error` event and proceed to the next sibling.
The runtime check uses identity comparison (`is True`) rather
than truthiness, so truthy non-bool values like the string
`"true"` cannot silently change run semantics even if a caller
bypasses `validate_workflow()`.
- Otherwise, behave as before: log `step_failed`, set
`RunStatus.FAILED`, and return.
Validation
----------
`_validate_steps` rejects non-bool values for `continue_on_error`.
Coerced strings like `"true"` are not accepted so authoring
mistakes surface at validation time rather than silently changing
run semantics.
Tests
-----
`TestContinueOnError` in `tests/test_workflows.py` (8 tests):
- `test_undeclared_failure_halts_run` — default halt behaviour.
- `test_declared_and_fired_continues_run` — flag + fail → continue.
- `test_declared_but_step_succeeded_is_noop` — flag + success → no-op.
- `test_if_branch_routes_around_failure` — end-to-end recovery.
- `test_gate_abort_still_halts_with_continue_on_error` — abort
always halts.
- `test_validation_rejects_non_bool_continue_on_error` — `"true"`
rejected at validation.
- `test_validation_accepts_bool_continue_on_error` — `true`/`false`
pass cleanly.
- `test_engine_ignores_truthy_non_bool_continue_on_error` —
defense-in-depth: engine ignores string `"true"` even when
validation is bypassed.
Rebased onto current upstream/main (post #2664 merge); the new
`TestContinueOnError` class sits immediately after upstream's
`TestContextRunId` so the two feature suites coexist cleanly.
Closes #2591.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -231,6 +231,20 @@ def _validate_steps(
|
||||
step_errors = step_impl.validate(step_config)
|
||||
errors.extend(step_errors)
|
||||
|
||||
# Validate optional `continue_on_error` field. The engine honours
|
||||
# this on any step that returns FAILED so the pipeline can route
|
||||
# around the failure via downstream `if`/`switch`/`gate`. The
|
||||
# field must be a literal boolean — coercion from truthy strings
|
||||
# is deliberately not supported so authoring mistakes surface
|
||||
# at validation time rather than silently changing run semantics.
|
||||
if "continue_on_error" in step_config:
|
||||
coe = step_config["continue_on_error"]
|
||||
if not isinstance(coe, bool):
|
||||
errors.append(
|
||||
f"Step {step_id!r}: 'continue_on_error' must be a "
|
||||
f"boolean, got {type(coe).__name__}."
|
||||
)
|
||||
|
||||
# Recursively validate nested steps
|
||||
for nested_key in ("then", "else", "steps"):
|
||||
nested = step_config.get(nested_key)
|
||||
@@ -622,7 +636,10 @@ class WorkflowEngine:
|
||||
|
||||
# Handle failures
|
||||
if result.status == StepStatus.FAILED:
|
||||
# Gate abort (output.aborted) maps to ABORTED status
|
||||
# Gate abort (output.aborted) maps to ABORTED status.
|
||||
# Aborts are deliberate operator decisions, so
|
||||
# `continue_on_error` does NOT override them — that flag
|
||||
# is for transient/expected step failures only.
|
||||
if result.output.get("aborted"):
|
||||
state.status = RunStatus.ABORTED
|
||||
state.append_log(
|
||||
@@ -631,15 +648,48 @@ class WorkflowEngine:
|
||||
"step_id": step_id,
|
||||
}
|
||||
)
|
||||
else:
|
||||
state.status = RunStatus.FAILED
|
||||
state.save()
|
||||
return
|
||||
|
||||
# `continue_on_error: true` lets the pipeline route
|
||||
# around the failure instead of halting. The step
|
||||
# result (including exit_code, stderr, status) is
|
||||
# still recorded so downstream `if`/`switch`/`gate`
|
||||
# steps can branch on it. Log a single, unambiguous
|
||||
# event per failure resolution — either the run
|
||||
# continued past it, or it halted.
|
||||
#
|
||||
# Use identity comparison (`is True`) rather than
|
||||
# truthiness so that only a literal boolean enables
|
||||
# the behaviour, even if validation was skipped.
|
||||
# Validation rejects non-bool values at parse time,
|
||||
# but `WorkflowEngine.execute()` does not auto-validate
|
||||
# (see `WorkflowEngine.load_workflow`, whose docstring
|
||||
# explicitly notes "not yet validated; call
|
||||
# `validate_workflow()` or `engine.validate()`
|
||||
# separately"), so a caller passing an unvalidated
|
||||
# definition could otherwise see truthy non-bool
|
||||
# values like the string `"true"` silently change
|
||||
# run semantics.
|
||||
if step_config.get("continue_on_error") is True:
|
||||
state.append_log(
|
||||
{
|
||||
"event": "step_failed",
|
||||
"event": "step_continue_on_error",
|
||||
"step_id": step_id,
|
||||
"error": result.error,
|
||||
}
|
||||
)
|
||||
state.save()
|
||||
continue
|
||||
|
||||
state.status = RunStatus.FAILED
|
||||
state.append_log(
|
||||
{
|
||||
"event": "step_failed",
|
||||
"step_id": step_id,
|
||||
"error": result.error,
|
||||
}
|
||||
)
|
||||
state.save()
|
||||
return
|
||||
|
||||
|
||||
Reference in New Issue
Block a user