From e2eb3c222040c6dd75379fd082c73c539af17905 Mon Sep 17 00:00:00 2001 From: thinkinginmath Date: Sat, 19 Sep 2026 07:14:07 +0000 Subject: [PATCH] Lab runs: end every step with a result line, so quiet commands visibly finish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Suggestion from the first real use of the scimigo.com lab panel. After "$ python -m pip install --quiet ..." there was no way to tell whether pip was still running or had finished; a quiet command prints nothing either way. Every step now ends with a line of its own: [done · 2.1 s] [exit 3 · 0.4 s] [stopped · 5.0 s] [timed out · 1.0 s] It starts a new line even after output without a trailing newline (Run.append_line). The watchdog's separate "[runtime] timed out" line is gone, since the step's line now says it. Empty appends are ignored: the decoder's final flush added one, which hid whether the output ended a line. Tests: result lines for success, a failing step, stop and timeout, and a result line after output without a newline. 172 passed; ruff, format and mypy clean. Co-Authored-By: Claude Opus 5 (1M context) --- src/agent_runtime/labs.py | 22 ++++++++++++++++++++-- tests/test_labs.py | 17 ++++++++++++++++- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/src/agent_runtime/labs.py b/src/agent_runtime/labs.py index 6833a72..f2cd939 100644 --- a/src/agent_runtime/labs.py +++ b/src/agent_runtime/labs.py @@ -394,6 +394,8 @@ class Run: _task: asyncio.Task[None] | None = None def append(self, text: str) -> None: + if not text: # e.g. the decoder's final flush; would hide whether output ends a line + return self._chunks.append(text) self._length += len(text) limit = settings.lab_output_limit @@ -404,6 +406,11 @@ def append(self, text: str) -> None: self._base += drop self._length = limit + def append_line(self, line: str) -> None: + """Append `line` on a line of its own, even after output without a final newline.""" + ends_clean = not self._chunks or self._chunks[-1].endswith("\n") + self.append(("" if ends_clean else "\n") + line + "\n") + def read(self, offset: int) -> tuple[str, int, bool]: """Output from `offset` on, the next offset, and whether earlier output was dropped.""" text = "".join(self._chunks) @@ -412,6 +419,15 @@ def read(self, offset: int) -> tuple[str, int, bool]: return text[start:], self._base + len(text), truncated +def _step_result(code: int, seconds: float, stop_reason: str | None) -> str: + """How a step ended, e.g. [done · 2.1 s], [exit 3 · 0.4 s], [stopped · 5.0 s].""" + if stop_reason: + outcome = stop_reason.replace("_", " ") + else: + outcome = "done" if code == 0 else f"exit {code}" + return f"[{outcome} · {seconds:.1f} s]" + + def _now() -> str: return datetime.now(UTC).isoformat() @@ -497,7 +513,10 @@ async def _execute(self, run: Run, lab: PreparedLab, action: Action) -> None: if run._stop_reason: break run.append(f"$ {shlex.join(argv)}\n") + started = time.monotonic() code = await self._step(run, lab, argv, env) + # A line per step, so a quiet command (pip install --quiet) visibly ends. + run.append_line(_step_result(code, time.monotonic() - started, run._stop_reason)) if run._stop_reason or code != 0: run.exit_code = code break @@ -542,8 +561,7 @@ async def _step( async def _watch(self, run: Run, deadline: float) -> None: await asyncio.sleep(max(deadline - time.monotonic(), 0)) - run.append("\n[runtime] timed out\n") - await self.stop(run, reason="timed_out") + await self.stop(run, reason="timed_out") # the step's result line says so async def shutdown(self) -> None: """Stop every running action and wait for it to finish, so no process outlives us.""" diff --git a/tests/test_labs.py b/tests/test_labs.py index 99a7dfc..d6677da 100644 --- a/tests/test_labs.py +++ b/tests/test_labs.py @@ -1,5 +1,6 @@ """Tests for named lab actions (agent_runtime.labs, /labs) and pairing scopes.""" +import re import subprocess import sys import time @@ -253,6 +254,7 @@ def test_runs_steps_in_order_and_reads_output_by_offset(self, client, lab_repo): assert done["status"] == "succeeded" and done["exit_code"] == 0 assert done["output"].startswith("$ python -c") assert "hello from the lab\n" in done["output"] + assert re.search(r"\n\[done · \d+\.\d s\]\n$", done["output"]), done["output"] later = client.get(f"/labs/runs/{run['run_id']}", params={"offset": done["next_offset"]}) assert later.json()["output"] == "" @@ -261,6 +263,9 @@ def test_first_failing_step_ends_the_action(self, client, lab_repo): done = _wait(client, _start(client, lab, "two").json()["run_id"]) assert done["status"] == "failed" and done["exit_code"] == 3 assert "first" in done["output"] and "never" not in done["output"] + lines = done["output"].splitlines() + assert re.fullmatch(r"\[done · \d+\.\d s\]", lines[2]), lines # after "$ ..." and "first" + assert re.fullmatch(r"\[exit 3 · \d+\.\d s\]", lines[-1]) def test_stop_and_one_run_at_a_time(self, client, lab_repo): lab = _prepare(client, lab_repo) @@ -271,12 +276,14 @@ def test_stop_and_one_run_at_a_time(self, client, lab_repo): stopped = client.post(f"/labs/runs/{run['run_id']}/stop").json() assert stopped["status"] == "stopped" assert "399" not in stopped["output"] + assert re.search(r"\[stopped · \d+\.\d s\]\n$", stopped["output"]) assert _start(client, lab, "hello").status_code == 200 # the lab is free again def test_timeout(self, client, lab_repo): lab = _prepare(client, lab_repo) done = _wait(client, _start(client, lab, "hangs").json()["run_id"]) - assert done["status"] == "timed_out" and "timed out" in done["output"] + assert done["status"] == "timed_out" + assert re.search(r"\[timed out · 1\.\d s\]\n$", done["output"]), done["output"] def test_unknown_action(self, client, lab_repo): lab = _prepare(client, lab_repo) @@ -413,3 +420,11 @@ def test_a_missing_commit_is_not_retried(self, lab_settings, tmp_path): ): labs._fetch("https://github.com/SciMigo/x", "a" * 40, tmp_path) assert run.call_count == 1 and sleep.call_count == 0 + + +def test_step_line_starts_on_its_own_line(): + run = labs.Run("r", "o", "lab", "c", "a", "now") + run.append("no trailing newline") + run.append_line("[done · 0.1 s]") + run.append_line("[done · 0.2 s]") + assert run.read(0)[0] == "no trailing newline\n[done · 0.1 s]\n[done · 0.2 s]\n"