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
22 changes: 20 additions & 2 deletions src/agent_runtime/labs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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()

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand Down
17 changes: 16 additions & 1 deletion tests/test_labs.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Tests for named lab actions (agent_runtime.labs, /labs) and pairing scopes."""

import re
import subprocess
import sys
import time
Expand Down Expand Up @@ -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"] == ""

Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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"
Loading