Skip to content

Commit e1233ab

Browse files
Kasper JungeRalphify
authored andcommitted
fix: route echo output through emitter to prevent Live tearing and double-print
Remove _echo_output's direct sys.stdout/sys.stderr writes that tore the Rich Live spinner and caused every line to appear twice when --log-dir and peek were both active. Captured output now flows through the iteration-ended event: - AgentResult carries captured_stdout/captured_stderr - The engine includes echo data only when peek was off (on_output_line is None) and logging captured the output - ConsoleEmitter prints the echo after stopping Live, inside _console_lock, so no tearing is possible - When peek was on, lines were already shown live — no echo, no double-print Task: high-01-echo-output-live-spinner-coordination.md Co-authored-by: Ralphify <noreply@ralphify.co>
1 parent d49949a commit e1233ab

8 files changed

Lines changed: 360 additions & 53 deletions

File tree

src/ralphify/_agent.py

Lines changed: 4 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@
3535
SUBPROCESS_TEXT_KWARGS,
3636
ProcessResult,
3737
collect_output,
38-
ensure_str,
3938
)
4039

4140
# Typed constants for the OutputStream literal so the type checker enforces
@@ -215,6 +214,8 @@ class AgentResult(ProcessResult):
215214
elapsed: float = 0.0
216215
log_file: Path | None = None
217216
result_text: str | None = None
217+
captured_stdout: str | None = None
218+
captured_stderr: str | None = None
218219

219220

220221
@dataclass(frozen=True)
@@ -246,21 +247,6 @@ def _write_log(
246247
return log_file
247248

248249

249-
def _echo_output(
250-
stdout: str | bytes | None,
251-
stderr: str | bytes | None,
252-
) -> None:
253-
"""Echo captured output to the terminal so the user still sees it.
254-
255-
Called after output has been written to a log file — without this,
256-
captured output would be silently swallowed when logging is enabled.
257-
"""
258-
if stdout:
259-
sys.stdout.write(ensure_str(stdout))
260-
if stderr:
261-
sys.stderr.write(ensure_str(stderr))
262-
263-
264250
def _supports_stream_json(cmd: list[str]) -> bool:
265251
"""Return True if the agent command supports ``--output-format stream-json``.
266252
@@ -629,17 +615,14 @@ def _run_agent_blocking(
629615
stderr = "".join(stderr_lines) if stderr_lines is not None else None
630616

631617
log_file = _write_log(log_path_dir, iteration, stdout, stderr)
632-
# When logging is enabled, output is diverted into the log file; echo it
633-
# so the user still sees what ran. When logging is disabled, live peek
634-
# (if enabled) has already shown the lines as they arrived.
635-
if log_path_dir is not None:
636-
_echo_output(stdout, stderr)
637618

638619
return AgentResult(
639620
returncode=None if timed_out else returncode,
640621
elapsed=time.monotonic() - start,
641622
log_file=log_file,
642623
timed_out=timed_out,
624+
captured_stdout=stdout,
625+
captured_stderr=stderr,
643626
)
644627

645628

src/ralphify/_console_emitter.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,16 @@ def _on_iteration_ended(
226226
detail = data["detail"]
227227
log_file = data["log_file"]
228228
result_text = data["result_text"]
229+
echo_stdout = data.get("echo_stdout")
230+
echo_stderr = data.get("echo_stderr")
229231
with self._console_lock:
232+
# Echo captured output before the status line — Live is already
233+
# stopped so this cannot tear the spinner. Only present when
234+
# peek was off and logging captured the output.
235+
if echo_stdout:
236+
self._console.print(Text(echo_stdout), end="")
237+
if echo_stderr:
238+
self._console.print(Text(echo_stderr), end="")
230239
self._console.print(f"[{color}]{icon} Iteration {iteration} {detail}[/]")
231240
if log_file:
232241
self._console.print(

src/ralphify/_events.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,8 @@ class IterationEndedData(TypedDict):
118118
detail: str
119119
log_file: str | None
120120
result_text: str | None
121+
echo_stdout: NotRequired[str | None]
122+
echo_stderr: NotRequired[str | None]
121123

122124

123125
class CommandsStartedData(TypedDict):

src/ralphify/engine.py

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -217,18 +217,24 @@ def _run_agent_phase(
217217
event_type = EventType.ITERATION_FAILED
218218
state_detail = f"failed with exit code {agent.returncode} ({duration})"
219219

220-
emit(
221-
event_type,
222-
IterationEndedData(
223-
iteration=state.iteration,
224-
returncode=agent.returncode,
225-
duration=agent.elapsed,
226-
duration_formatted=duration,
227-
detail=state_detail,
228-
log_file=str(agent.log_file) if agent.log_file else None,
229-
result_text=agent.result_text,
230-
),
220+
ended_data = IterationEndedData(
221+
iteration=state.iteration,
222+
returncode=agent.returncode,
223+
duration=agent.elapsed,
224+
duration_formatted=duration,
225+
detail=state_detail,
226+
log_file=str(agent.log_file) if agent.log_file else None,
227+
result_text=agent.result_text,
231228
)
229+
# When logging is enabled and live peek was off (on_output_line is None),
230+
# include captured output so the emitter can echo it after stopping the
231+
# Live spinner — avoiding the terminal tear that direct sys.stdout writes
232+
# would cause. When peek was on, lines were already rendered live.
233+
if on_output_line is None and config.log_dir is not None:
234+
ended_data["echo_stdout"] = agent.captured_stdout
235+
ended_data["echo_stderr"] = agent.captured_stderr
236+
237+
emit(event_type, ended_data)
232238
return agent.success
233239

234240

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
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.

tests/test_agent.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -309,23 +309,22 @@ def test_writes_log_on_timeout(self, mock_popen, tmp_path):
309309
assert result.log_file.exists()
310310

311311
@patch(MOCK_SUBPROCESS)
312-
def test_timeout_echoes_captured_output(self, mock_popen, tmp_path, capsys):
312+
def test_timeout_captures_partial_output(self, mock_popen, tmp_path):
313313
"""When logging is enabled and the agent times out, partial output
314-
should be echoed to the terminal — same as on normal completion."""
314+
is captured on the AgentResult for the engine to echo."""
315315
mock_popen.return_value = timeout_proc(
316316
stdout_text="partial stdout", stderr_text="partial stderr"
317317
)
318-
execute_agent(
318+
result = execute_agent(
319319
["echo"],
320320
"prompt",
321321
timeout=5,
322322
log_path_dir=tmp_path,
323323
iteration=1,
324324
)
325325

326-
captured = capsys.readouterr()
327-
assert "partial stdout" in captured.out
328-
assert "partial stderr" in captured.err
326+
assert result.captured_stdout == "partial stdout"
327+
assert result.captured_stderr == "partial stderr"
329328

330329
@patch(MOCK_SUBPROCESS, side_effect=ok_proc)
331330
def test_no_log_when_dir_not_set(self, mock_popen):

0 commit comments

Comments
 (0)