Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion src/specify_cli/workflows/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -982,7 +982,16 @@ def _execute_steps(
from .expressions import evaluate_condition

max_iters = step_config.get("max_iterations")
if not isinstance(max_iters, int) or max_iters < 1:
# bool is an int subclass, so `max_iterations: true` would
# otherwise pass isinstance(..., int) and silently cap the
# loop at a single iteration (True - 1 == 0 re-iterations).
# Reject it explicitly and fall back to the safe default,
# matching While/DoWhileStep.validate() for unvalidated runs.
if (
isinstance(max_iters, bool)
or not isinstance(max_iters, int)
or max_iters < 1
):
max_iters = 10
condition = step_config.get("condition", False)
for _loop_iter in range(max_iters - 1):
Expand Down
50 changes: 50 additions & 0 deletions tests/test_workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -3872,6 +3872,56 @@ def test_while_loop_runs_to_max_when_condition_stays_true(self, project_dir):
assert "retry-loop:tick:1" in state.step_results
assert "retry-loop:tick:2" in state.step_results

def test_while_loop_bool_max_iterations_falls_back_to_default(self, project_dir):
"""`max_iterations: true` must fall back to the default of 10, not be
treated as a valid 1.

bool is an int subclass, so the engine's re-eval guard used to accept
`True` and cap the loop at a single iteration (True - 1 == 0
re-iterations). The step validators already reject a bool here; the
engine guard must match so an unvalidated run degrades to the safe
default rather than silently running the body just once.
"""
from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition
from specify_cli.workflows.base import RunStatus

import sys

counter_file = project_dir / ".counter"
counter_file.write_text("0", encoding="utf-8")
py = sys.executable
script_file = project_dir / "_tick.py"
script_file.write_text(
f"import pathlib; p = pathlib.Path(r'{counter_file}')\n"
"n = int(p.read_text()) + 1; p.write_text(str(n))\n"
"print('pending', end='')\n",
encoding="utf-8",
)

yaml_str = f"""
schema_version: "1.0"
workflow:
id: "while-bool-max-iterations"
name: "While Bool Max Iterations"
version: "1.0.0"
steps:
- id: retry-loop
type: while
condition: "{{{{ 'done' not in steps.tick.output.stdout }}}}"
max_iterations: true
steps:
- id: tick
type: shell
run: '"{py}" "{script_file}"'
"""
definition = WorkflowDefinition.from_string(yaml_str)
engine = WorkflowEngine(project_dir)
state = engine.execute(definition)

assert state.status == RunStatus.COMPLETED
# Fell back to the default of 10 (iteration 0 + 9 re-iterations), not 1.
assert counter_file.read_text(encoding="utf-8").strip() == "10"

def test_do_while_loop_runs_to_max_when_condition_stays_true(self, project_dir):
"""Do-while loop must still run to max_iterations when the condition
never becomes false.
Expand Down