|
| 1 | +# High 01 — `_echo_output` + Live spinner coordination |
| 2 | + |
| 3 | +**Original findings:** H1 (`_echo_output` tears Rich Live) + M8 (double-print when `--log-dir` + peek both active) |
| 4 | +**Severity:** High — corrupts terminal output and can double-print every line |
| 5 | +**Files:** `src/ralphify/_agent.py`, `src/ralphify/_console_emitter.py`, `src/ralphify/engine.py` |
| 6 | + |
| 7 | +## Problem |
| 8 | + |
| 9 | +### 1. `_echo_output` bypasses the Rich Live lock |
| 10 | + |
| 11 | +`_echo_output` at `src/ralphify/_agent.py:153` writes directly to `sys.stdout` / `sys.stderr`: |
| 12 | + |
| 13 | +```python |
| 14 | +def _echo_output(stdout: str | bytes | None, stderr: str | bytes | None) -> None: |
| 15 | + if stdout: |
| 16 | + sys.stdout.write(ensure_str(stdout)) |
| 17 | + if stderr: |
| 18 | + sys.stderr.write(ensure_str(stderr)) |
| 19 | +``` |
| 20 | + |
| 21 | +It is called from **inside `_run_agent_blocking`**, which runs while `ConsoleEmitter._live` is still active (the iteration-started handler starts a Rich `Live` spinner and the iteration-ended handler stops it — echo happens *before* the iteration-ended event fires). |
| 22 | + |
| 23 | +Rich's `Live` assumes it owns all writes to the terminal while active. Raw writes to `sys.stdout` tear the spinner's refresh loop: you get garbled output, half-redrawn spinners, and sometimes cursor-position artifacts that persist after the Live stops. |
| 24 | + |
| 25 | +### 2. Double-print when `--log-dir` + peek are both on |
| 26 | + |
| 27 | +When the user runs `ralph run --log-dir logs/` in an interactive TTY: |
| 28 | +- Live peek is on by default → each line is printed as it streams from the agent (via `AGENT_OUTPUT_LINE` events). |
| 29 | +- At iteration end, `_echo_output` dumps the full captured stdout+stderr again. |
| 30 | + |
| 31 | +Result: **every line appears twice.** The CHANGELOG flags this as intended ("captured to the log and also echoed after each iteration"), but the combined effect with peek is visual noise nobody asked for. |
| 32 | + |
| 33 | +## Why it matters |
| 34 | + |
| 35 | +- Spinner tearing makes the CLI look broken for anyone using `--log-dir` in a TTY. |
| 36 | +- Double-printing every line turns a 1000-line iteration into 2000 lines of terminal output. Scrollback becomes useless. |
| 37 | +- The two issues compound: the tearing happens specifically in the code path where the double-print also happens. |
| 38 | + |
| 39 | +## Fix direction |
| 40 | + |
| 41 | +Two problems, one coordinated fix. Pick an approach: |
| 42 | + |
| 43 | +### Approach A — Route echo through the emitter (preferred) |
| 44 | + |
| 45 | +Replace the direct `sys.stdout.write` with an emitter event. Either: |
| 46 | +- **Reuse `AGENT_OUTPUT_LINE`** — emit one event per line of captured output at iteration-end. The `ConsoleEmitter` handler already knows how to render these under `_console_lock` and check peek state. |
| 47 | +- **Add `AGENT_OUTPUT_BATCH`** — a new event type for "here's the full captured output to echo." The handler stops Live, prints the batch, restarts Live. |
| 48 | + |
| 49 | +Reusing `AGENT_OUTPUT_LINE` is simpler and gives you the de-duplication for free: if peek was on for the iteration, the lines were already rendered, and the handler can skip them based on a "was peek on during this iteration?" flag the emitter maintains. |
| 50 | + |
| 51 | +### Approach B — Skip `_echo_output` when peek was on |
| 52 | + |
| 53 | +Track a per-iteration `peek_was_visible` flag in `ConsoleEmitter` (set when peek is on at iteration start, cleared when iteration ends). The engine passes this to `execute_agent` → `_run_agent_blocking`, which only calls `_echo_output` when the flag is false. Still needs to stop Live before calling `_echo_output` to fix the tearing. |
| 54 | + |
| 55 | +### Approach C — Drop `_echo_output` entirely |
| 56 | + |
| 57 | +Since `--log-dir` is supposed to *save* output to a file, maybe the user doesn't need it echoed at all. Document that `--log-dir` silences terminal output unless peek is on. |
| 58 | + |
| 59 | +**Prefer Approach A.** It unifies the "show agent output to the user" path and fixes both bugs with one event type. The handler is the natural place to know whether Live is active and coordinate the print. |
| 60 | + |
| 61 | +Whichever approach you take, `_echo_output` must **not** write to `sys.stdout`/`sys.stderr` directly while Live is active. The write must either: |
| 62 | +- go through `self._live.console.print(...)` (Rich's Live-aware print), or |
| 63 | +- be preceded by `self._live.stop()` and followed by `self._live.start()` (same `_console_lock` region). |
| 64 | + |
| 65 | +## Done when |
| 66 | + |
| 67 | +- [ ] No direct `sys.stdout.write` / `sys.stderr.write` from `_echo_output` while a Rich `Live` is active. Either delete `_echo_output`, or make it route through the emitter. |
| 68 | +- [ ] When `--log-dir` is set AND peek was on for the iteration, each line of agent output appears **exactly once** in the terminal. |
| 69 | +- [ ] When `--log-dir` is set AND peek was off (non-TTY, or user toggled off), the user still sees the output somewhere (either echoed at iteration end with Live properly coordinated, or streamed live during the iteration — pick one and make it consistent). |
| 70 | +- [ ] New test `tests/test_console_emitter.py::test_echo_does_not_tear_live_spinner` — use `Console(record=True)` with Live active, emit the echo path, assert the recorded output doesn't contain Live-region artifacts (specifically: no interleaved spinner frames in the echoed text). |
| 71 | +- [ ] New test `tests/test_engine.py::test_no_double_print_with_log_dir_and_peek` — run a fake agent that produces 3 lines, with `--log-dir` set and peek forced on; assert each line appears exactly once in captured console output. |
| 72 | +- [ ] `uv run pytest`, lint, format, ty check all pass. |
| 73 | + |
| 74 | +## Context |
| 75 | + |
| 76 | +- `_echo_output` definition: `src/ralphify/_agent.py:153-165`. Only caller is `_run_agent_blocking` at `:439`. |
| 77 | +- `ConsoleEmitter._start_live` / `_stop_live` wrap iteration-level spinner setup. See `src/ralphify/_console_emitter.py` — handlers for `EventType.ITERATION_STARTED` and `EventType.ITERATION_ENDED`. |
| 78 | +- Rich's `Live.console.print(...)` safely prints above the Live region without tearing. If you go that route, the emitter needs to know whether Live is currently running. |
| 79 | +- Related but out of scope: `medium-05` handles the lock-release gap between iteration handlers; that's a separate cosmetic issue and does not block this task. |
| 80 | +- If `critical-01` lands first and adopts the "inherit fds when no capture needed" path, `_echo_output` is only invoked when `log_path_dir is not None`. That narrows the scope of this fix to the logging-enabled case, which is cleaner. |
| 81 | +- **Task ordering:** this task is easier to land after `critical-01` but can be done standalone — the two-write-targets issue exists regardless. |
0 commit comments