Skip to content
Merged
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
24 changes: 24 additions & 0 deletions .claude/PRPs/issues/completed/issue-31.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Issue 31

**Title**: [Critical] while loop step type for dynamic iteration
**Type**: BUG
**Investigated**: 2026-06-27T19:16:56Z

## Summary

`for` loops snapshot their input before iteration, so workflows that create new work during the loop do not pick it up in the same run.

## Implementation Plan

- Add a `while` step model with `condition` and nested `steps`.
- Render `while` as a Bash loop that re-evaluates the condition each iteration.
- Add parser, render, and end-to-end tests.
- Document `while` in the README.

## Validation

- `make qa`

## Notes

This archive reflects the investigation comment used as the implementation plan.
30 changes: 18 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,18 +52,23 @@ workflows:
expandPrompt: true
prompt: |
Review issue ${ISSUE_NUMBER} and summarize the repository state.
- type: parallel
steps:
- type: bash
run: echo "worker A"
- type: bash
run: echo "worker B"
- type: for
in: ITEMS
item: ITEM
steps:
- type: bash
run: echo "$ITEM"
- type: parallel
steps:
- type: bash
run: echo "worker A"
- type: bash
run: echo "worker B"
- type: for
in: ITEMS
item: ITEM
steps:
- type: bash
run: echo "$ITEM"
- type: while
condition: '[ -n "$(ls doc/plan/steps/planned/ 2>/dev/null)" ]'
steps:
- type: bash
run: echo "keep looping until the queue is empty"
```

Harness paths are derived from workflow ids. `wf_example` becomes `example.sh` in the current working directory.
Expand All @@ -76,6 +81,7 @@ Harness paths are derived from workflow ids. `wf_example` becomes `example.sh` i
| `bash` | Run shell commands | Runs with `bash -euo pipefail`. |
| `agent` | Call OpenCode | Supports `agent`, `model`, `command`, `expandPrompt`, and `dangerouslySkipPermissions`. |
| `for` | Iterate over newline-delimited values from a previous `vars` step | Flat iteration only; nested `for` steps are not supported. |
| `while` | Re-evaluate a Bash condition before each iteration | Use for dynamic queues or other stateful loops that must keep discovering new work. |
| `parallel` | Run child steps concurrently | Children run as separate branches and the parent waits for all of them. |

## Agent Behavior
Expand Down
26 changes: 25 additions & 1 deletion src/flowsh_cli/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -196,18 +196,42 @@ def validate_var_name(cls, value: str) -> str:
return value


class WhileStep(BaseStep):
type: Literal["while"]
condition: str
steps: list[Step] = Field(min_length=1)

@field_validator("condition")
@classmethod
def validate_condition(cls, value: str) -> str:
if value.strip() == "":
raise ValueError("must not be empty")
if has_unsafe_control_characters(value):
raise ValueError("must not contain unsafe control characters")
return value

@field_validator("steps")
@classmethod
def reject_nested_while_steps(cls, value: list[Step]) -> list[Step]:
for step in value:
if isinstance(step, WhileStep):
raise ValueError("nested while steps are not supported")
return value


class ParallelStep(BaseStep):
type: Literal["parallel"]
steps: list[Step] = Field(min_length=1)


Step = Annotated[
VarsStep | BashStep | AgentStep | ForStep | ParallelStep,
VarsStep | BashStep | AgentStep | ForStep | WhileStep | ParallelStep,
Field(discriminator="type"),
]


ForStep.model_rebuild()
WhileStep.model_rebuild()
ParallelStep.model_rebuild()


Expand Down
29 changes: 28 additions & 1 deletion src/flowsh_cli/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
ParallelStep,
Step,
VarsStep,
WhileStep,
Workflow,
WorkflowParam,
)
Expand Down Expand Up @@ -251,6 +252,30 @@ def render_step(index: int, step: Step, used_function_names: set[str] | None = N
*[f" run_step {fn}" for fn in inner_fns],
f' done <<< "${{{step.in_}}}"',
]
elif isinstance(step, WhileStep):
child_fns: list[str] = []
for i, child in enumerate(step.steps, start=1):
child_title = child.name or default_step_title(i, child)
child_fn = step_function_name(i, child.name, used_function_names)
child_fns.append(child_fn)
prefix_lines.append(section(f"While child {i} ({child.type}): {child_title}"))
prefix_lines.append(f"{child_fn}() {{")
prefix_lines.extend(_render_step_body(child, child_title))
prefix_lines.append("}")
prefix_lines.append("")

body_lines = []
if step.when is not None:
body_lines.append(f" if ! ({step.when}); then")
body_lines.append(f" log INFO {bash_quote(f'Step skipped (when): {title}')}")
body_lines.append(" return 0")
body_lines.append(" fi")
body_lines.append("")

body_lines.append(f" while ({step.condition}); do")
for child_fn in child_fns:
body_lines.append(f" run_step {child_fn} || return $?")
body_lines.append(" done")
elif isinstance(step, ParallelStep):
child_fns: list[str] = []
for i, child in enumerate(step.steps, start=1):
Expand All @@ -273,7 +298,7 @@ def render_step(index: int, step: Step, used_function_names: set[str] | None = N
else:
body_lines = _render_step_body(step, title)

runner = "run_stateful_step" if isinstance(step, (VarsStep, ForStep)) else "run_step"
runner = "run_stateful_step" if isinstance(step, (VarsStep, ForStep, WhileStep)) else "run_step"
outer_lines = [
section(f"Step {index} ({step.type}): {title}"),
f"{function_name}() {{",
Expand Down Expand Up @@ -393,6 +418,8 @@ def default_step_title(index: int, step: Step) -> str:
return truncate_one_line(step.prompt)
if isinstance(step, ForStep):
return f"for {step.item} in {step.in_}"
if isinstance(step, WhileStep):
return f"while {truncate_one_line(step.condition)}"
if isinstance(step, ParallelStep):
return f"parallel ({len(step.steps)} steps)"
return f"step {index}"
Expand Down
Loading
Loading