diff --git a/docs/changelog.md b/docs/changelog.md index 59a9bcd..ae14242 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -8,6 +8,14 @@ keywords: ralphify changelog, release history, new features, version updates, br All notable changes to ralphify are documented here. +## 0.2.5 — 2026-03-22 + +### Added + +- **Idle detection with backoff** — configure via the `idle` frontmatter block (`delay`, `backoff`, `max_delay`, `max`). When an agent emits ``, the engine applies backoff delays and optionally stops after a cumulative idle time limit. + +--- + ## 0.2.4 — 2026-03-22 ### Fixed diff --git a/docs/cli.md b/docs/cli.md index f03bb8b..4c89d46 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -165,6 +165,7 @@ Your instructions here. Reference args with {{ args.dir }}. | `commands` | list | no | Commands to run each iteration (each has `name` and `run`) | | `args` | list of strings | no | Declared argument names for user arguments. Letters, digits, hyphens, and underscores only. | | `credit` | bool | no | Append co-author trailer instruction to prompt (default: `true`) | +| `idle` | mapping | no | Idle detection config — backoff delays when agent signals idle state (see [Idle detection](#idle-detection)) | ### Commands @@ -188,3 +189,31 @@ If a command exceeds its timeout, the process is killed and the captured output | `{{ args. }}` | Value of the named user argument | Unmatched placeholders resolve to an empty string. + +### Idle detection + +When an agent emits `` in its output, the engine applies backoff delays between iterations and optionally stops the loop after a cumulative idle time limit. + +Add an `idle` block to your frontmatter: + +```markdown +--- +agent: claude -p --dangerously-skip-permissions +idle: + delay: 30s + backoff: 2 + max_delay: 5m + max: 30m +--- +``` + +| Field | Type | Default | Description | +|---|---|---|---| +| `delay` | duration or number | `30` (seconds) | Initial delay after the first idle iteration | +| `backoff` | number | `2.0` | Multiplier applied each consecutive idle iteration | +| `max_delay` | duration or number | `300` (5 minutes) | Maximum delay cap | +| `max` | duration or number | none | Stop the loop after this cumulative idle time | + +Duration values accept numbers (seconds) or human-readable strings: `30s`, `5m`, `6h`, `1d`. + +A non-idle iteration resets all idle tracking. When no `idle` block is present, the loop runs exactly as before. diff --git a/docs/contributing/codebase-map.md b/docs/contributing/codebase-map.md index 3e8b45c..2b88897 100644 --- a/docs/contributing/codebase-map.md +++ b/docs/contributing/codebase-map.md @@ -81,7 +81,7 @@ The run loop communicates via structured events (`_events.py`). Each event has a Event data uses TypedDict classes — one per event type — rather than free-form dicts. The key types: -- **`RunStartedData`** / **`RunStoppedData`** — run lifecycle (stop reason is a `StopReason` literal: `"completed"`, `"error"`, `"user_requested"`) +- **`RunStartedData`** / **`RunStoppedData`** — run lifecycle (stop reason is a `StopReason` literal: `"completed"`, `"error"`, `"user_requested"`, `"max_idle"`) - **`IterationStartedData`** / **`IterationEndedData`** — per-iteration data (return code, duration, log path) - **`CommandsStartedData`** / **`CommandsCompletedData`** — command execution bookends - **`PromptAssembledData`** — prompt length after placeholder resolution @@ -113,7 +113,7 @@ The CLI uses a `ConsoleEmitter` (defined in `_console_emitter.py`) that renders 1. **`engine.py`** — The core run loop. Uses `RunConfig` and `RunState` (from `_run_types.py`) and `EventEmitter`. This is where iteration logic lives. 2. **`_run_types.py`** — `RunConfig`, `RunState`, `RunStatus`, and `Command`. These are the shared data types used by the engine, CLI, and manager. -3. **`cli.py`** — All CLI commands. Validates frontmatter fields via extracted helpers (`_validate_agent`, `_validate_commands`, `_validate_credit`, `_validate_run_options`, `_validate_declared_args`), builds a `RunConfig`, and delegates to `engine.run_loop()` for the actual loop. Terminal event rendering lives in `_console_emitter.py`. +3. **`cli.py`** — All CLI commands. Validates frontmatter fields via extracted helpers (`_validate_agent`, `_validate_commands`, `_validate_credit`, `_validate_idle`, `_validate_run_options`, `_validate_declared_args`), builds a `RunConfig`, and delegates to `engine.run_loop()` for the actual loop. Terminal event rendering lives in `_console_emitter.py`. 4. **`_frontmatter.py`** — YAML frontmatter parsing. Extracts `agent`, `commands`, `args` from the RALPH.md file. 5. **`_resolver.py`** — Template placeholder logic. Small file but critical. 6. **`_skills.py`** + **`skills/`** — The skill system behind `ralph new`. `_skills.py` handles agent detection, reads bundled skill definitions from `skills/`, installs them into the agent's skill directory, and builds the command to launch the agent. @@ -124,7 +124,7 @@ The CLI uses a `ConsoleEmitter` (defined in `_console_emitter.py`) that renders Frontmatter parsing is in `_frontmatter.py:parse_frontmatter()`, which returns a raw dict. Each field is then validated and coerced by a dedicated helper in `cli.py` — e.g. `_validate_agent()`, `_validate_commands()`, `_validate_credit()`. Adding a new frontmatter field means adding a new validator in `cli.py` and wiring it into `_build_run_config()`. -**Field name constants** (`FIELD_AGENT`, `FIELD_COMMANDS`, `FIELD_ARGS`, `FIELD_CREDIT`, `CMD_FIELD_NAME`, `CMD_FIELD_RUN`, `CMD_FIELD_TIMEOUT`) are centralized in `_frontmatter.py`. Always import these constants instead of hardcoding strings like `"agent"` or `"commands"` — this keeps error messages, validation, and placeholder resolution in sync when fields are renamed. +**Field name constants** (`FIELD_AGENT`, `FIELD_COMMANDS`, `FIELD_ARGS`, `FIELD_CREDIT`, `FIELD_IDLE`, `CMD_FIELD_NAME`, `CMD_FIELD_RUN`, `CMD_FIELD_TIMEOUT`) are centralized in `_frontmatter.py`. Always import these constants instead of hardcoding strings like `"agent"` or `"commands"` — this keeps error messages, validation, and placeholder resolution in sync when fields are renamed. ### If you add a new CLI command... @@ -134,6 +134,14 @@ Add it in `cli.py`. The CLI uses Typer. Update `docs/cli.md` to document the new Events are defined in `_events.py:EventType`, with a corresponding TypedDict payload class for each type. Adding a new event type requires a new `EventType` member, a new TypedDict payload class, adding it to the `EventData` union, and handling it in `ConsoleEmitter` (`_console_emitter.py`). +### Idle detection + +When an agent emits `` (the `IDLE_STATE_MARKER` constant in `_frontmatter.py`) in its output, the engine marks the iteration as idle instead of completed. Idle behavior is configured via the `idle` frontmatter block, parsed by `_validate_idle()` in `cli.py` into an `IdleConfig` dataclass (`_run_types.py`). + +The engine (`engine.py`) tracks idle state on `RunState` (`consecutive_idle`, `cumulative_idle_time`). Backoff delay is computed by `_idle_delay()`: `delay × backoff^(consecutive_idle - 1)`, capped at `max_delay`. A non-idle iteration calls `state.reset_idle()` to clear all idle tracking. When `idle.max` is set and cumulative idle time exceeds it, the loop stops with `RunStatus.IDLE_EXCEEDED`. + +The `ITERATION_IDLE` event type and `STOP_MAX_IDLE` stop reason are defined in `_events.py`. The console emitter renders idle iterations with a dimmed style. + ### Credit trailer When `credit` is `true` (the default), `engine.py:_assemble_prompt()` appends `_CREDIT_INSTRUCTION` to the prompt — a short instruction telling the agent to include a `Co-authored-by: Ralphify` trailer in git commits. Users can opt out with `credit: false` in frontmatter. diff --git a/docs/quick-reference.md b/docs/quick-reference.md index 817ab55..aa71b99 100644 --- a/docs/quick-reference.md +++ b/docs/quick-reference.md @@ -72,6 +72,7 @@ Your instructions here. Use {{ args.dir }} for user arguments. | `commands` | list | no | Commands to run each iteration | | `args` | list | no | User argument names. Letters, digits, hyphens, and underscores only. | | `credit` | bool | no | Append co-author trailer instruction to prompt (default: `true`) | +| `idle` | mapping | no | Idle detection: backoff delays when agent signals idle (see below) | ### Command fields @@ -107,6 +108,18 @@ Your instructions here. Use {{ args.dir }} for user arguments. - `--` ends flag parsing: `ralph run my-ralph -- --verbose ./src` treats `--verbose` as a positional value - Missing args resolve to empty string +### Idle detection + +```yaml +idle: + delay: 30s # Initial delay after first idle iteration (default: 30s) + backoff: 2 # Multiplier per consecutive idle iteration (default: 2) + max_delay: 5m # Delay cap (default: 5m) + max: 30m # Stop loop after this cumulative idle time (optional) +``` + +Agent emits `` → backoff delays kick in. Non-idle iteration resets tracking. Durations: `30s`, `5m`, `6h`, `1d`. + ## The loop Each iteration: diff --git a/docs/writing-prompts.md b/docs/writing-prompts.md index 642b9fd..d0be3c5 100644 --- a/docs/writing-prompts.md +++ b/docs/writing-prompts.md @@ -331,6 +331,17 @@ HTML comments in your RALPH.md are automatically stripped before the prompt is a You can freely add and edit comments while the loop runs — they're stripped every iteration, so they never waste the agent's context window. +## Idle detection + +If your agent signals when it has no work to do, you can avoid wasting tokens on idle iterations. Add an [`idle` block](cli.md#idle-detection) to your frontmatter and instruct the agent to emit the idle marker when there's nothing left to do: + +```markdown +If all tasks are complete, output exactly: + +``` + +When the agent emits ``, the engine applies increasing backoff delays before the next iteration. A non-idle iteration resets the backoff. If cumulative idle time reaches the `max` limit, the loop stops automatically. + ## Prompt size and context windows Keep your prompt focused. A long prompt with every possible instruction eats into the agent's context window, leaving less room for the actual codebase. diff --git a/src/ralphify/_agent.py b/src/ralphify/_agent.py index 29604c3..70ec800 100644 --- a/src/ralphify/_agent.py +++ b/src/ralphify/_agent.py @@ -51,6 +51,7 @@ class AgentResult(ProcessResult): elapsed: float = 0.0 log_file: Path | None = None result_text: str | None = None + stdout_text: str | None = None @dataclass(frozen=True) @@ -206,6 +207,7 @@ def _run_agent_streaming( log_file=log_file, result_text=stream.result_text, timed_out=stream.timed_out, + stdout_text="".join(stream.stdout_lines), ) @@ -254,6 +256,7 @@ def _run_agent_blocking( elapsed=time.monotonic() - start, log_file=log_file, timed_out=timed_out, + stdout_text=ensure_str(stdout) if stdout else None, ) diff --git a/src/ralphify/_console_emitter.py b/src/ralphify/_console_emitter.py index 20ee837..7c81b01 100644 --- a/src/ralphify/_console_emitter.py +++ b/src/ralphify/_console_emitter.py @@ -20,7 +20,10 @@ from ralphify._events import ( LOG_ERROR, STOP_COMPLETED, + STOP_MAX_IDLE, CommandsCompletedData, + DelayEndedData, + DelayStartedData, Event, EventType, IterationEndedData, @@ -34,6 +37,7 @@ _ICON_SUCCESS = "✓" _ICON_FAILURE = "✗" _ICON_TIMEOUT = "⏱" +_ICON_IDLE = "◇" _ICON_ARROW = "→" _ICON_DASH = "—" @@ -54,6 +58,20 @@ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderR yield text +class _DelayCountdown: + """Rich renderable that shows a countdown timer for inter-iteration delays.""" + + def __init__(self, total: float) -> None: + self._total = total + self._start = time.monotonic() + + def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult: + elapsed = time.monotonic() - self._start + remaining = max(0.0, self._total - elapsed) + text = Text(f" Waiting {format_duration(remaining)}…", style="dim") + yield text + + class ConsoleEmitter: """Renders engine events to the Rich console.""" @@ -66,7 +84,10 @@ def __init__(self, console: Console) -> None: EventType.ITERATION_COMPLETED: partial(self._on_iteration_ended, color="green", icon=_ICON_SUCCESS), EventType.ITERATION_FAILED: partial(self._on_iteration_ended, color="red", icon=_ICON_FAILURE), EventType.ITERATION_TIMED_OUT: partial(self._on_iteration_ended, color="yellow", icon=_ICON_TIMEOUT), + EventType.ITERATION_IDLE: partial(self._on_iteration_ended, color="dim", icon=_ICON_IDLE), EventType.COMMANDS_COMPLETED: self._on_commands_completed, + EventType.DELAY_STARTED: self._on_delay_started, + EventType.DELAY_ENDED: self._on_delay_ended, EventType.LOG_MESSAGE: self._on_log_message, EventType.RUN_STOPPED: self._on_run_stopped, } @@ -130,6 +151,19 @@ def _on_commands_completed(self, data: CommandsCompletedData) -> None: if count: self._console.print(f" [bold]Commands:[/bold] {count} ran") + def _on_delay_started(self, data: DelayStartedData) -> None: + countdown = _DelayCountdown(data["delay"]) + self._live = Live( + countdown, + console=self._console, + transient=True, + refresh_per_second=_LIVE_REFRESH_RATE, + ) + self._live.start() + + def _on_delay_ended(self, data: DelayEndedData) -> None: + self._stop_live() + def _on_log_message(self, data: LogMessageData) -> None: msg = escape_markup(data["message"]) level = data["level"] @@ -143,7 +177,8 @@ def _on_log_message(self, data: LogMessageData) -> None: def _on_run_stopped(self, data: RunStoppedData) -> None: self._stop_live() - if data["reason"] != STOP_COMPLETED: + reason = data["reason"] + if reason not in (STOP_COMPLETED, STOP_MAX_IDLE): return total = data["total"] @@ -161,4 +196,7 @@ def _on_run_stopped(self, data: RunStoppedData) -> None: parts.append(f"{timed_out_count} timed out") detail = ", ".join(parts) self._console.print(f"\n[bold blue]──────────────────────[/bold blue]") - self._console.print(f"[bold green]Done:[/bold green] {total} iteration(s) {_ICON_DASH} {detail}") + if reason == STOP_MAX_IDLE: + self._console.print(f"[bold yellow]Stopped (idle):[/bold yellow] {total} iteration(s) {_ICON_DASH} {detail}") + else: + self._console.print(f"[bold green]Done:[/bold green] {total} iteration(s) {_ICON_DASH} {detail}") diff --git a/src/ralphify/_events.py b/src/ralphify/_events.py index 6954e51..95c6baf 100644 --- a/src/ralphify/_events.py +++ b/src/ralphify/_events.py @@ -19,12 +19,13 @@ LOG_INFO: LogLevel = "info" LOG_ERROR: LogLevel = "error" -StopReason = Literal["completed", "error", "user_requested"] +StopReason = Literal["completed", "error", "user_requested", "max_idle"] """Valid reason strings for :class:`RunStoppedData` events.""" STOP_COMPLETED: StopReason = "completed" STOP_ERROR: StopReason = "error" STOP_USER_REQUESTED: StopReason = "user_requested" +STOP_MAX_IDLE: StopReason = "max_idle" class EventType(Enum): @@ -37,7 +38,7 @@ class EventType(Enum): **Iteration lifecycle** — emitted once per iteration: ``ITERATION_STARTED``, ``ITERATION_COMPLETED``, ``ITERATION_FAILED``, - ``ITERATION_TIMED_OUT``. + ``ITERATION_TIMED_OUT``, ``ITERATION_IDLE``. **Commands** — emitted around command execution: ``COMMANDS_STARTED``, ``COMMANDS_COMPLETED``. @@ -63,6 +64,7 @@ class EventType(Enum): ITERATION_COMPLETED = "iteration_completed" ITERATION_FAILED = "iteration_failed" ITERATION_TIMED_OUT = "iteration_timed_out" + ITERATION_IDLE = "iteration_idle" # ── Commands ──────────────────────────────────────────────── COMMANDS_STARTED = "commands_started" @@ -74,6 +76,10 @@ class EventType(Enum): # ── Agent activity (live streaming) ───────────────────────── AGENT_ACTIVITY = "agent_activity" + # ── Delay ───────────────────────────────────────────────────── + DELAY_STARTED = "delay_started" + DELAY_ENDED = "delay_ended" + # ── Other ─────────────────────────────────────────────────── LOG_MESSAGE = "log_message" @@ -131,6 +137,14 @@ class AgentActivityData(TypedDict): iteration: int +class DelayStartedData(TypedDict): + delay: float + + +class DelayEndedData(TypedDict): + pass + + class LogMessageData(TypedDict): message: str level: LogLevel @@ -146,6 +160,8 @@ class LogMessageData(TypedDict): | CommandsCompletedData | PromptAssembledData | AgentActivityData + | DelayStartedData + | DelayEndedData | LogMessageData ) """Union of all typed event data payloads.""" diff --git a/src/ralphify/_frontmatter.py b/src/ralphify/_frontmatter.py index 0d4f6f3..3522e4a 100644 --- a/src/ralphify/_frontmatter.py +++ b/src/ralphify/_frontmatter.py @@ -25,6 +25,7 @@ FIELD_COMMANDS = "commands" FIELD_ARGS = "args" FIELD_CREDIT = "credit" +FIELD_IDLE = "idle" # Sub-field names within each command mapping. CMD_FIELD_NAME = "name" @@ -41,9 +42,27 @@ # Human-readable description of allowed name characters, paired with CMD_NAME_RE. VALID_NAME_CHARS_MSG = "Names may only contain letters, digits, hyphens, and underscores." +# Marker that agents emit to signal idle state. +IDLE_STATE_MARKER = "" + # Pre-compiled pattern to strip HTML comments from body text. _HTML_COMMENT_RE = re.compile(r"", re.DOTALL) +# Pattern for human-readable duration strings (e.g. "30s", "5m", "6h", "1d"). +_DURATION_RE = re.compile(r"^\s*(\d+(?:\.\d+)?)\s*([smhd])\s*$") +_DURATION_MULTIPLIERS = {"s": 1, "m": 60, "h": 3600, "d": 86400} + + +def parse_duration(value: str) -> float: + """Parse a duration string (e.g. ``"30s"``, ``"5m"``) into seconds.""" + match = _DURATION_RE.match(value) + if not match: + raise ValueError( + f"Invalid duration '{value}'. Use a number with a suffix: " + f"s (seconds), m (minutes), h (hours), d (days). Examples: 30s, 5m, 6h." + ) + return float(match.group(1)) * _DURATION_MULTIPLIERS[match.group(2)] + def _extract_frontmatter_block(text: str) -> tuple[str, str]: """Split text into raw YAML frontmatter and body at ``---`` delimiters. diff --git a/src/ralphify/_run_types.py b/src/ralphify/_run_types.py index be74603..55109ce 100644 --- a/src/ralphify/_run_types.py +++ b/src/ralphify/_run_types.py @@ -14,7 +14,13 @@ from enum import Enum from pathlib import Path -from ralphify._events import STOP_COMPLETED, STOP_ERROR, STOP_USER_REQUESTED, StopReason +from ralphify._events import ( + STOP_COMPLETED, + STOP_ERROR, + STOP_MAX_IDLE, + STOP_USER_REQUESTED, + StopReason, +) DEFAULT_COMMAND_TIMEOUT: float = 60 @@ -43,6 +49,7 @@ class RunStatus(Enum): STOPPED = "stopped" COMPLETED = "completed" FAILED = "failed" + IDLE_EXCEEDED = "idle_exceeded" @property def reason(self) -> StopReason: @@ -64,9 +71,20 @@ def reason(self) -> StopReason: RunStatus.COMPLETED: STOP_COMPLETED, RunStatus.FAILED: STOP_ERROR, RunStatus.STOPPED: STOP_USER_REQUESTED, + RunStatus.IDLE_EXCEEDED: STOP_MAX_IDLE, } +@dataclass +class IdleConfig: + """Configuration for idle detection and backoff behavior.""" + + delay: float = 30 + backoff: float = 2.0 + max_delay: float = 300 + max: float | None = None + + @dataclass class Command: """A named command from RALPH.md frontmatter.""" @@ -97,6 +115,7 @@ class RunConfig: log_dir: Path | None = None project_root: Path = field(default=Path(".")) credit: bool = True + idle: IdleConfig | None = None @dataclass @@ -120,6 +139,8 @@ class RunState: failed: int = 0 timed_out: int = 0 started_at: datetime | None = None + consecutive_idle: int = 0 + cumulative_idle_time: float = 0.0 _stop_requested: bool = field(default=False, init=False, repr=False, compare=False) _resume_event: threading.Event = field(default_factory=threading.Event, init=False, repr=False, compare=False) @@ -174,3 +195,13 @@ def mark_timed_out(self) -> None: """Record a timed-out iteration (also counts as failed).""" self.timed_out += 1 self.mark_failed() + + def mark_idle(self) -> None: + """Record an idle iteration (counts as completed, increments idle tracking).""" + self.completed += 1 + self.consecutive_idle += 1 + + def reset_idle(self) -> None: + """Reset idle tracking after a non-idle iteration.""" + self.consecutive_idle = 0 + self.cumulative_idle_time = 0.0 diff --git a/src/ralphify/cli.py b/src/ralphify/cli.py index 8fe3b9b..8f4b6ab 100644 --- a/src/ralphify/cli.py +++ b/src/ralphify/cli.py @@ -29,11 +29,13 @@ FIELD_ARGS, FIELD_COMMANDS, FIELD_CREDIT, + FIELD_IDLE, RALPH_MARKER, VALID_NAME_CHARS_MSG, + parse_duration, parse_frontmatter, ) -from ralphify._run_types import Command, DEFAULT_COMMAND_TIMEOUT, RunConfig, RunState, generate_run_id +from ralphify._run_types import Command, DEFAULT_COMMAND_TIMEOUT, IdleConfig, RunConfig, RunState, generate_run_id from ralphify.engine import run_loop if sys.platform == "win32": @@ -373,6 +375,56 @@ def _validate_credit(raw_credit: Any) -> bool: return raw_credit +def _validate_idle(raw_idle: Any) -> IdleConfig | None: + """Validate the ``idle`` frontmatter block and return an IdleConfig. + + Returns ``None`` when *raw_idle* is ``None`` (field absent). + Exits with an error when the value is malformed. + """ + if raw_idle is None: + return None + if not isinstance(raw_idle, dict): + _exit_error(f"'{FIELD_IDLE}' must be a mapping, got {type(raw_idle).__name__}.") + + def _parse_duration_field(value: Any, field_name: str) -> float: + label = f"'{FIELD_IDLE}.{field_name}'" + if isinstance(value, bool): + _exit_error(f"{label} must be a number or duration string, got {value!r}.") + if isinstance(value, (int, float)): + if not math.isfinite(value) or value <= 0: + _exit_error(f"{label} must be positive, got {value!r}.") + return float(value) + if isinstance(value, str): + try: + result = parse_duration(value) + except ValueError as exc: + _exit_error(f"{label}: {exc}") + if result <= 0: + _exit_error(f"{label} must be positive.") + return result + _exit_error(f"{label} must be a number or duration string, got {type(value).__name__}.") + + kwargs: dict[str, Any] = {} + for field in ("delay", "max_delay", "max"): + if field in raw_idle: + kwargs[field] = _parse_duration_field(raw_idle[field], field) + + if "backoff" in raw_idle: + backoff = raw_idle["backoff"] + label = f"'{FIELD_IDLE}.backoff'" + if isinstance(backoff, bool) or not isinstance(backoff, (int, float)): + _exit_error(f"{label} must be a positive number, got {backoff!r}.") + if not math.isfinite(backoff) or backoff <= 0: + _exit_error(f"{label} must be positive, got {backoff!r}.") + kwargs["backoff"] = float(backoff) + + unknown = set(raw_idle.keys()) - {"delay", "backoff", "max_delay", "max"} + if unknown: + _exit_error(f"Unknown field(s) in '{FIELD_IDLE}': {', '.join(sorted(unknown))}.") + + return IdleConfig(**kwargs) + + def _validate_run_options( max_iterations: int | None, delay: float, @@ -416,6 +468,7 @@ def _build_run_config( ralph_args = _parse_user_args(extra_args, declared_names) credit = _validate_credit(fm.get(FIELD_CREDIT)) + idle = _validate_idle(fm.get(FIELD_IDLE)) return RunConfig( agent=agent, @@ -430,6 +483,7 @@ def _build_run_config( log_dir=Path(log_dir) if log_dir else None, project_root=Path.cwd(), credit=credit, + idle=idle, ) diff --git a/src/ralphify/engine.py b/src/ralphify/engine.py index 897e6fc..b028265 100644 --- a/src/ralphify/engine.py +++ b/src/ralphify/engine.py @@ -21,6 +21,8 @@ BoundEmitter, CommandsCompletedData, CommandsStartedData, + DelayEndedData, + DelayStartedData, EventEmitter, EventType, IterationEndedData, @@ -30,7 +32,7 @@ RunStartedData, RunStoppedData, ) -from ralphify._frontmatter import FIELD_AGENT, FIELD_COMMANDS, RALPH_MARKER, parse_frontmatter +from ralphify._frontmatter import FIELD_AGENT, FIELD_COMMANDS, IDLE_STATE_MARKER, RALPH_MARKER, parse_frontmatter from ralphify._output import format_duration from ralphify._run_types import ( Command, @@ -172,10 +174,19 @@ def _run_agent_phase( duration = format_duration(agent.elapsed) + is_idle = agent.success and ( + (agent.result_text is not None and IDLE_STATE_MARKER in agent.result_text) + or (agent.stdout_text is not None and IDLE_STATE_MARKER in agent.stdout_text) + ) + if agent.timed_out: state.mark_timed_out() event_type = EventType.ITERATION_TIMED_OUT state_detail = f"timed out after {duration}" + elif is_idle: + state.mark_idle() + event_type = EventType.ITERATION_IDLE + state_detail = f"idle ({duration})" elif agent.success: state.mark_completed() event_type = EventType.ITERATION_COMPLETED @@ -194,6 +205,7 @@ def _run_agent_phase( log_file=str(agent.log_file) if agent.log_file else None, result_text=agent.result_text, )) + return agent.success @@ -244,21 +256,35 @@ def _run_iteration( return True +def _idle_delay(config: RunConfig, state: RunState) -> float: + """Return idle backoff delay: ``delay * backoff^(streak-1)`` capped at ``max_delay``.""" + raw = config.idle.delay * (config.idle.backoff ** (state.consecutive_idle - 1)) + return min(raw, config.idle.max_delay) + + def _delay_if_needed(config: RunConfig, state: RunState, emit: BoundEmitter) -> None: """Sleep between iterations when a delay is configured. - The sleep is broken into small chunks so that stop requests are - respected promptly rather than blocking for the full delay. + When idle backoff is active, the idle delay takes precedence over + the base delay. The sleep is broken into small chunks so that stop + requests are respected promptly rather than blocking for the full delay. """ - if config.delay > 0 and ( + # Determine effective delay: idle backoff overrides base delay + if state.consecutive_idle > 0 and config.idle is not None: + delay = _idle_delay(config, state) + else: + delay = config.delay + + if delay > 0 and ( config.max_iterations is None or state.iteration < config.max_iterations ): - emit.log_info(f"Waiting {config.delay}s...") - remaining = config.delay + emit(EventType.DELAY_STARTED, DelayStartedData(delay=delay)) + remaining = delay while remaining > 0 and not state.stop_requested: chunk = min(remaining, _PAUSE_POLL_INTERVAL) time.sleep(chunk) remaining -= chunk + emit(EventType.DELAY_ENDED, DelayEndedData()) def run_loop( @@ -297,12 +323,29 @@ def run_loop( if config.max_iterations is not None and state.iteration > config.max_iterations: break + idle_before = state.consecutive_idle should_continue = _run_iteration(config, state, emit) if not should_continue: break + # Detect whether this iteration was idle (mark_idle increments + # consecutive_idle; mark_completed/mark_failed do not). + iteration_was_idle = state.consecutive_idle > idle_before + + if not iteration_was_idle and idle_before > 0: + state.reset_idle() + _delay_if_needed(config, state, emit) + # Track cumulative idle time and check max idle limit + if iteration_was_idle and config.idle is not None: + idle_delay = _idle_delay(config, state) + state.cumulative_idle_time += idle_delay + if config.idle.max is not None and state.cumulative_idle_time >= config.idle.max: + state.status = RunStatus.IDLE_EXCEEDED + emit.log_info("Max idle time exceeded, stopping.") + break + except KeyboardInterrupt: state.status = RunStatus.STOPPED except Exception as exc: diff --git a/src/ralphify/skills/new-ralph/SKILL.md b/src/ralphify/skills/new-ralph/SKILL.md index 32e4f0c..ebc63c4 100644 --- a/src/ralphify/skills/new-ralph/SKILL.md +++ b/src/ralphify/skills/new-ralph/SKILL.md @@ -83,6 +83,11 @@ If any tests are failing above, fix them before continuing. | `commands[].timeout` | No | Max seconds before the command is killed (default: 60) | | `args` | No | Declared argument names for positional CLI args. Letters, digits, hyphens, and underscores only. Must be unique. | | `credit` | No | Append co-author trailer instruction (default: `true`). Set to `false` to disable. | +| `idle` | No | Idle detection config. Mapping with `delay`, `backoff`, `max_delay`, `max` fields. When the agent emits ``, the engine applies backoff delays between iterations. | +| `idle.delay` | No | Initial delay after first idle iteration (default: `30s`). Accepts numbers (seconds) or duration strings (`30s`, `5m`). | +| `idle.backoff` | No | Multiplier per consecutive idle iteration (default: `2`). | +| `idle.max_delay` | No | Maximum delay cap (default: `5m`). | +| `idle.max` | No | Stop the loop after this cumulative idle time. | #### Body diff --git a/tests/test_agent.py b/tests/test_agent.py index a68f9a7..fd657f3 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -277,6 +277,42 @@ def test_not_success_when_timed_out(self): assert result.success is False +class TestStdoutTextPopulation: + """Tests for stdout_text field population in AgentResult.""" + + @patch(MOCK_POPEN) + def test_streaming_populates_stdout_text(self, mock_popen): + mock_popen.return_value = make_mock_popen( + stdout_lines='{"type": "status", "msg": "working"}\nplain line\n', + returncode=0, + ) + result = _run_agent_streaming( + ["claude", "-p"], "prompt", timeout=None, log_path_dir=None, iteration=1, + ) + + assert result.stdout_text is not None + assert "working" in result.stdout_text + assert "plain line" in result.stdout_text + + @patch(MOCK_SUBPROCESS) + def test_blocking_populates_stdout_text_when_logging(self, mock_run, tmp_path): + mock_run.return_value = ok_result(stdout="agent output\n") + result = execute_agent( + ["echo"], "prompt", timeout=None, log_path_dir=tmp_path, iteration=1, + ) + + assert result.stdout_text == "agent output\n" + + @patch(MOCK_SUBPROCESS) + def test_blocking_stdout_text_none_without_logging(self, mock_run): + mock_run.return_value = ok_result() + result = execute_agent( + ["echo"], "prompt", timeout=None, log_path_dir=None, iteration=1, + ) + + assert result.stdout_text is None + + class TestExecuteAgentDispatch: """Tests for execute_agent routing to streaming vs blocking mode.""" diff --git a/tests/test_cli.py b/tests/test_cli.py index d4050e0..26a52e6 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -10,7 +10,7 @@ from helpers import MOCK_ENGINE_SLEEP, MOCK_SKILLS_WHICH, MOCK_SUBPROCESS, MOCK_WHICH, ok_result, fail_result, make_ralph from ralphify import __version__ from ralphify._frontmatter import RALPH_MARKER -from ralphify.cli import app, _parse_command_items, _parse_user_args +from ralphify.cli import app, _parse_command_items, _parse_user_args, _validate_idle runner = CliRunner() @@ -773,3 +773,59 @@ def test_credit_invalid_value_errors(self, mock_which, tmp_path, monkeypatch): assert result.exit_code == 1 assert "credit" in result.output.lower() assert "true or false" in result.output.lower() + + +@patch(MOCK_WHICH, return_value="/usr/bin/claude") +class TestIdleFrontmatter: + @pytest.mark.parametrize("idle_yaml", [ + "idle:\n delay: 30s\n backoff: 2\n max_delay: 5m\n max: 1h", + "idle:\n delay: 30\n backoff: 1.5\n max_delay: 300\n max: 3600", + ]) + @patch(MOCK_SUBPROCESS, side_effect=ok_result) + def test_idle_config_accepted(self, mock_run, mock_which, tmp_path, monkeypatch, idle_yaml): + monkeypatch.chdir(tmp_path) + ralph_dir = tmp_path / "my-ralph" + ralph_dir.mkdir(exist_ok=True) + (ralph_dir / RALPH_MARKER).write_text( + f"---\nagent: claude -p --dangerously-skip-permissions\n{idle_yaml}\n---\ngo" + ) + result = runner.invoke(app, ["run", str(ralph_dir), "-n", "1"]) + assert result.exit_code == 0 + + + +class TestValidateIdle: + def test_none_returns_none(self): + assert _validate_idle(None) is None + + def test_empty_dict_returns_defaults(self): + config = _validate_idle({}) + assert config.delay == 30 + assert config.backoff == 2.0 + assert config.max_delay == 300 + assert config.max is None + + def test_duration_strings_parsed(self): + config = _validate_idle({"delay": "30s", "max_delay": "5m", "max": "1h"}) + assert config.delay == 30.0 + assert config.max_delay == 300.0 + assert config.max == 3600.0 + + def test_numeric_values_accepted(self): + config = _validate_idle({"delay": 10, "backoff": 1.5, "max_delay": 120, "max": 600}) + assert config.delay == 10.0 + assert config.backoff == 1.5 + assert config.max_delay == 120.0 + assert config.max == 600.0 + + @pytest.mark.parametrize("raw", [ + "30s", # not a dict + {"delay": "not-valid"}, # invalid duration string + {"delay": -5}, # negative delay + {"delay": 0}, # zero delay + {"delay": True}, # boolean delay + {"delay": "30s", "extra": "oops"}, # unknown field + ]) + def test_invalid_input_errors(self, raw): + with pytest.raises(typer.Exit): + _validate_idle(raw) diff --git a/tests/test_console_emitter.py b/tests/test_console_emitter.py index 136a0ea..b5de251 100644 --- a/tests/test_console_emitter.py +++ b/tests/test_console_emitter.py @@ -3,7 +3,7 @@ import pytest from rich.console import Console -from ralphify._console_emitter import ConsoleEmitter, _IterationSpinner +from ralphify._console_emitter import ConsoleEmitter, _DelayCountdown, _IterationSpinner from ralphify._events import Event, EventType @@ -217,6 +217,32 @@ def test_traceback_with_brackets_not_corrupted(self): assert "[red]missing[/red]" in output +class TestIterationIdle: + @pytest.mark.parametrize("iteration, detail, log_file, expected", [ + (3, "idle (2s)", None, ["Iteration 3", "idle (2s)"]), + (1, "idle (1s)", "/tmp/idle.log", ["/tmp/idle.log"]), + ]) + def test_idle_renders_details(self, iteration, detail, log_file, expected): + emitter, console = _capture_emitter() + emitter.emit(_make_event( + EventType.ITERATION_IDLE, + iteration=iteration, detail=detail, log_file=log_file, result_text=None, + )) + output = console.export_text() + for text in expected: + assert text in output + + def test_idle_stops_live_display(self): + emitter, console = _capture_emitter() + emitter.emit(_make_event(EventType.ITERATION_STARTED, iteration=1)) + assert emitter._live is not None + emitter.emit(_make_event( + EventType.ITERATION_IDLE, + iteration=1, detail="idle (1s)", log_file=None, result_text=None, + )) + assert emitter._live is None + + class TestRunStopped: def test_completed_shows_summary(self): emitter, console = _capture_emitter() @@ -281,6 +307,17 @@ def test_run_stopped_stops_active_live_display(self): )) assert emitter._live is None + def test_max_idle_shows_summary(self): + emitter, console = _capture_emitter() + emitter.emit(_make_event( + EventType.RUN_STOPPED, + reason="max_idle", total=4, completed=2, failed=0, timed_out=0, + )) + output = console.export_text() + assert "Stopped (idle):" in output + assert "4 iteration(s)" in output + assert "2 succeeded" in output + def test_completed_all_succeeded(self): emitter, console = _capture_emitter() emitter.emit(_make_event( @@ -293,6 +330,43 @@ def test_completed_all_succeeded(self): assert "timed out" not in output +class TestDelayCountdown: + def test_renders_remaining_time(self): + countdown = _DelayCountdown(10.0) + console = Console(record=True, width=80) + console.print(countdown) + output = console.export_text() + assert "Waiting" in output + # Should contain a duration string + assert "s" in output + + def test_delay_started_creates_live(self): + emitter, console = _capture_emitter() + assert emitter._live is None + emitter.emit(_make_event(EventType.DELAY_STARTED, delay=5.0)) + assert emitter._live is not None + emitter._stop_live() # clean up + + def test_delay_ended_stops_live(self): + emitter, console = _capture_emitter() + emitter.emit(_make_event(EventType.DELAY_STARTED, delay=5.0)) + assert emitter._live is not None + emitter.emit(_make_event(EventType.DELAY_ENDED)) + assert emitter._live is None + + def test_delay_lifecycle_full(self): + """DELAY_STARTED followed by DELAY_ENDED cleans up properly.""" + emitter, console = _capture_emitter() + emitter.emit(_make_event(EventType.DELAY_STARTED, delay=1.0)) + assert emitter._live is not None + emitter.emit(_make_event(EventType.DELAY_ENDED)) + assert emitter._live is None + # Should be safe to start another delay + emitter.emit(_make_event(EventType.DELAY_STARTED, delay=2.0)) + assert emitter._live is not None + emitter._stop_live() + + class TestIterationSpinner: def test_renders_elapsed_time(self): spinner = _IterationSpinner() diff --git a/tests/test_engine.py b/tests/test_engine.py index 29e7902..1eb6d49 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -8,16 +8,35 @@ import pytest from helpers import MOCK_RUN_COMMAND, MOCK_SUBPROCESS, drain_events, event_types, events_of_type, fail_result, make_config, make_state, ok_result, ok_run_result +from ralphify._agent import AgentResult from ralphify._events import BoundEmitter, EventType, NullEmitter, QueueEmitter -from ralphify._run_types import Command, RunStatus +from ralphify._frontmatter import IDLE_STATE_MARKER +from ralphify._run_types import Command, IdleConfig, RunStatus from ralphify.engine import ( _assemble_prompt, + _idle_delay, _delay_if_needed, _handle_control_signals, _run_commands, run_loop, ) +MOCK_EXECUTE_AGENT = "ralphify.engine.execute_agent" + + +def _idle_agent_result(**kwargs): + """Create an AgentResult that signals idle state.""" + defaults = dict(returncode=0, elapsed=1.0, result_text=IDLE_STATE_MARKER) + defaults.update(kwargs) + return AgentResult(**defaults) + + +def _active_agent_result(**kwargs): + """Create an AgentResult for a normal (non-idle) iteration.""" + defaults = dict(returncode=0, elapsed=1.0, result_text="did some work") + defaults.update(kwargs) + return AgentResult(**defaults) + class TestRunLoop: @patch(MOCK_SUBPROCESS, side_effect=ok_result) @@ -516,9 +535,10 @@ def test_delay_sleeps_between_iterations(self, tmp_path): assert elapsed >= 0.1 events = drain_events(q) - assert len(events) == 1 - assert events[0].type == EventType.LOG_MESSAGE - assert "Waiting" in events[0].data["message"] + assert len(events) == 2 + assert events[0].type == EventType.DELAY_STARTED + assert events[0].data["delay"] == 0.15 + assert events[1].type == EventType.DELAY_ENDED def test_no_delay_on_last_iteration(self, tmp_path): config = make_config(tmp_path, delay=0.5, max_iterations=3) @@ -894,3 +914,131 @@ def test_credit_false_no_trailer_in_agent_input(self, mock_run, tmp_path): call_input = mock_run.call_args.kwargs["input"] assert "Co-authored-by" not in call_input + + +class TestIdleDelay: + """Unit tests for _idle_delay — backoff math.""" + + @pytest.mark.parametrize("consecutive, max_delay, expected", [ + (1, 300, 30), # base delay + (2, 300, 60), # 30 * 2^1 + (3, 300, 120), # 30 * 2^2 + (10, 100, 100), # capped at max_delay + ]) + def test_backoff_progression(self, tmp_path, consecutive, max_delay, expected): + config = make_config(tmp_path, idle=IdleConfig(delay=30, backoff=2.0, max_delay=max_delay)) + state = make_state() + state.consecutive_idle = consecutive + + assert _idle_delay(config, state) == expected + + +class TestIdleDetection: + """Integration tests for idle detection in the run loop.""" + + @patch(MOCK_EXECUTE_AGENT, return_value=_idle_agent_result()) + def test_idle_marker_triggers_idle_event(self, mock_agent, tmp_path): + config = make_config(tmp_path, max_iterations=1, idle=IdleConfig()) + state = make_state() + q = QueueEmitter() + + run_loop(config, state, q) + + events = drain_events(q) + types = event_types(events) + assert EventType.ITERATION_IDLE in types + assert EventType.ITERATION_COMPLETED not in types + + @patch(MOCK_EXECUTE_AGENT, return_value=_idle_agent_result()) + def test_idle_increments_consecutive_idle(self, mock_agent, tmp_path): + config = make_config(tmp_path, max_iterations=2, idle=IdleConfig(delay=0)) + state = make_state() + + run_loop(config, state, NullEmitter()) + + assert state.consecutive_idle == 2 + assert state.completed == 2 + + @patch(MOCK_EXECUTE_AGENT) + def test_non_idle_resets_idle_tracking(self, mock_agent, tmp_path): + """After idle iterations, a normal iteration resets the idle counters.""" + mock_agent.side_effect = [ + _idle_agent_result(), + _idle_agent_result(), + _active_agent_result(), + ] + config = make_config(tmp_path, max_iterations=3, idle=IdleConfig(delay=0)) + state = make_state() + + run_loop(config, state, NullEmitter()) + + assert state.consecutive_idle == 0 + assert state.cumulative_idle_time == 0.0 + + @patch(MOCK_EXECUTE_AGENT, return_value=_idle_agent_result()) + def test_idle_without_config_still_marks_completed(self, mock_agent, tmp_path): + """When no idle config is set, idle marker in result_text still + triggers ITERATION_IDLE but no backoff delay is applied.""" + config = make_config(tmp_path, max_iterations=1, idle=None) + state = make_state() + q = QueueEmitter() + + run_loop(config, state, q) + + events = drain_events(q) + types = event_types(events) + assert EventType.ITERATION_IDLE in types + assert state.completed == 1 + + @patch(MOCK_EXECUTE_AGENT, return_value=_idle_agent_result()) + def test_max_idle_stops_loop(self, mock_agent, tmp_path): + """Loop stops when cumulative idle time exceeds idle.max.""" + config = make_config( + tmp_path, max_iterations=100, + idle=IdleConfig(delay=10, backoff=1.0, max_delay=10, max=25), + ) + state = make_state() + q = QueueEmitter() + + run_loop(config, state, q) + + assert state.status == RunStatus.IDLE_EXCEEDED + events = drain_events(q) + stop = events_of_type(events, EventType.RUN_STOPPED)[0] + assert stop.data["reason"] == "max_idle" + + @patch(MOCK_EXECUTE_AGENT, return_value=_idle_agent_result( + result_text="no marker here", stdout_text=IDLE_STATE_MARKER, + )) + def test_idle_detected_via_stdout_fallback(self, mock_agent, tmp_path): + """When result_text lacks the marker but stdout_text contains it, + idle is still detected.""" + config = make_config(tmp_path, max_iterations=1, idle=IdleConfig()) + state = make_state() + q = QueueEmitter() + + run_loop(config, state, q) + + events = drain_events(q) + types = event_types(events) + assert EventType.ITERATION_IDLE in types + assert EventType.ITERATION_COMPLETED not in types + + @pytest.mark.parametrize("agent_result, expected_type", [ + (AgentResult(returncode=0, elapsed=1.0, result_text=None), EventType.ITERATION_COMPLETED), + (AgentResult(returncode=1, elapsed=1.0, result_text=IDLE_STATE_MARKER), EventType.ITERATION_FAILED), + ]) + @patch(MOCK_EXECUTE_AGENT) + def test_non_idle_cases(self, mock_agent, tmp_path, agent_result, expected_type): + """result_text=None or failed agent should not be detected as idle.""" + mock_agent.return_value = agent_result + config = make_config(tmp_path, max_iterations=1, idle=IdleConfig()) + state = make_state() + q = QueueEmitter() + + run_loop(config, state, q) + + events = drain_events(q) + types = event_types(events) + assert expected_type in types + assert EventType.ITERATION_IDLE not in types diff --git a/tests/test_frontmatter.py b/tests/test_frontmatter.py index 6c60ed9..a561c8f 100644 --- a/tests/test_frontmatter.py +++ b/tests/test_frontmatter.py @@ -5,6 +5,7 @@ from ralphify._frontmatter import ( RALPH_MARKER, _extract_frontmatter_block, + parse_duration, parse_frontmatter, serialize_frontmatter, ) @@ -153,6 +154,28 @@ def test_scalar_frontmatter_raises_value_error(self): parse_frontmatter(text) +class TestParseDuration: + @pytest.mark.parametrize( + "value,expected", + [ + ("30s", 30.0), + ("5m", 300.0), + ("6h", 21600.0), + ("1d", 86400.0), + ("1.5h", 5400.0), + ("0.5m", 30.0), + (" 30s ", 30.0), + ], + ) + def test_valid_durations(self, value, expected): + assert parse_duration(value) == expected + + @pytest.mark.parametrize("value", ["", "30", "abc", "30x", "m5", "-5s"]) + def test_invalid_durations_raise(self, value): + with pytest.raises(ValueError, match="Invalid duration"): + parse_duration(value) + + class TestSerializeFrontmatter: def test_roundtrip(self): original_fm = {"agent": "claude"} diff --git a/tests/test_run_types.py b/tests/test_run_types.py index 4e3394f..f94dbd1 100644 --- a/tests/test_run_types.py +++ b/tests/test_run_types.py @@ -10,6 +10,7 @@ DEFAULT_COMMAND_TIMEOUT, RUN_ID_LENGTH, Command, + IdleConfig, RunConfig, RunState, RunStatus, @@ -41,6 +42,16 @@ def test_custom_timeout(self): assert cmd.timeout == 300 +class TestIdleConfig: + @pytest.mark.parametrize("kwargs, expected", [ + ({}, (30, 2.0, 300, None)), + ({"delay": 10, "backoff": 1.5, "max_delay": 120, "max": 3600}, (10, 1.5, 120, 3600)), + ]) + def test_values(self, kwargs, expected): + cfg = IdleConfig(**kwargs) + assert (cfg.delay, cfg.backoff, cfg.max_delay, cfg.max) == expected + + class TestRunConfig: def test_default_project_root_is_dot(self, tmp_path): config = RunConfig( @@ -64,6 +75,7 @@ def test_defaults(self, tmp_path): assert config.stop_on_error is False assert config.log_dir is None assert config.credit is True + assert config.idle is None class TestRunState: @@ -75,6 +87,8 @@ def test_initial_state(self): assert state.failed == 0 assert state.timed_out == 0 assert state.started_at is None + assert state.consecutive_idle == 0 + assert state.cumulative_idle_time == 0.0 def test_total_is_completed_plus_failed(self): state = RunState(run_id="r1") @@ -147,6 +161,20 @@ def test_wait_for_unpause_times_out(self): result = state.wait_for_unpause(timeout=0.01) assert result is False + def test_mark_and_reset_idle(self): + state = RunState(run_id="r1") + state.mark_idle() + assert state.completed == 1 + assert state.consecutive_idle == 1 + state.mark_idle() + assert state.completed == 2 + assert state.consecutive_idle == 2 + state.cumulative_idle_time = 120.0 + state.reset_idle() + assert state.consecutive_idle == 0 + assert state.cumulative_idle_time == 0.0 + assert state.completed == 2 # preserved + class TestRunStatus: @pytest.mark.parametrize( @@ -158,6 +186,7 @@ class TestRunStatus: (RunStatus.STOPPED, "stopped"), (RunStatus.COMPLETED, "completed"), (RunStatus.FAILED, "failed"), + (RunStatus.IDLE_EXCEEDED, "idle_exceeded"), ], ) def test_enum_values(self, status, value): @@ -169,6 +198,7 @@ def test_enum_values(self, status, value): (RunStatus.COMPLETED, "completed"), (RunStatus.FAILED, "error"), (RunStatus.STOPPED, "user_requested"), + (RunStatus.IDLE_EXCEEDED, "max_idle"), ], ) def test_reason_for_terminal_statuses(self, status, expected_reason):