From a2bf24f9c9fd7ee3d131913c5da2be326b9943b3 Mon Sep 17 00:00:00 2001 From: Noor-ul-ain001 Date: Sat, 11 Jul 2026 18:11:34 +0500 Subject: [PATCH] fix(workflows): reject bool max_iterations in engine re-eval guard The While/DoWhile step validators already reject `max_iterations: true` as a type error (bool is an int subclass, so it would otherwise pass the `isinstance(..., int)` check and, since `True - 1 == 0`, silently cap the loop at a single iteration). The engine's re-evaluation guard used the older `not isinstance(max_iters, int) or max_iters < 1` shape, so on an unvalidated run a boolean slipped through and ran the loop body just once instead of falling back to the safe default of 10. Add the explicit `isinstance(max_iters, bool)` rejection so the engine guard matches the step validators and an unvalidated run degrades to the default rather than silently under-running the loop. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/specify_cli/workflows/engine.py | 11 ++++++- tests/test_workflows.py | 50 +++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/workflows/engine.py b/src/specify_cli/workflows/engine.py index 6025c30c5c..eaad835122 100644 --- a/src/specify_cli/workflows/engine.py +++ b/src/specify_cli/workflows/engine.py @@ -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): diff --git a/tests/test_workflows.py b/tests/test_workflows.py index d7cff20f6d..9629d44f44 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -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.