From 8f8283027b50cd696a48a980d377a51a0dd37b05 Mon Sep 17 00:00:00 2001 From: Panopticon Agent Date: Thu, 9 Jul 2026 23:37:44 +0000 Subject: [PATCH] fix(sessionservice): include subprocess stderr in FAILED lifecycle_detail When docker run/build fails, the CalledProcessError only surfaced the exit code in the detail pane. Now _subprocess_run re-raises as RuntimeError with the captured stderr so the dashboard shows the actual error message. Co-Authored-By: Claude Sonnet 4.6 --- src/panopticon/sessionservice/local_runner.py | 9 +++++- tests/test_local_runner.py | 29 ++++++++++++++++++- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/panopticon/sessionservice/local_runner.py b/src/panopticon/sessionservice/local_runner.py index 3a73599d..0d4c80d0 100644 --- a/src/panopticon/sessionservice/local_runner.py +++ b/src/panopticon/sessionservice/local_runner.py @@ -61,7 +61,14 @@ def _subprocess_run(args: Sequence[str], *, check: bool = True, interactive: boo if interactive or verbose: # inherit streams: TTY attachment (interactive) or visible build output (verbose) subprocess.run(list(args), check=check) return "" - return subprocess.run(list(args), check=check, capture_output=True, text=True).stdout + try: + return subprocess.run(list(args), check=check, capture_output=True, text=True).stdout + except subprocess.CalledProcessError as exc: + stderr = (exc.stderr or "").strip() + msg = f"Command '{exc.cmd[0]}' exited {exc.returncode}" + if stderr: + msg += f": {stderr}" + raise RuntimeError(msg) from exc diff --git a/tests/test_local_runner.py b/tests/test_local_runner.py index 13c8593c..f0d7cff3 100644 --- a/tests/test_local_runner.py +++ b/tests/test_local_runner.py @@ -13,7 +13,7 @@ import pytest from panopticon.core.models import LifecyclePhase -from panopticon.sessionservice.local_runner import LocalRunner +from panopticon.sessionservice.local_runner import LocalRunner, _subprocess_run from panopticon.sessionservice.runner import Runner @@ -235,6 +235,33 @@ def test_tmux_socket_can_be_overridden() -> None: assert rec.calls[3][0][:4] == ["tmux", "-L", "panopt", "new-session"] # kill-session, rm, run, tmux +# -- _subprocess_run error surfacing ------------------------------------------------- + + +def test_subprocess_run_includes_stderr_in_error(monkeypatch: pytest.MonkeyPatch) -> None: + """CalledProcessError.stderr is surfaced in the RuntimeError message.""" + err = subprocess.CalledProcessError(125, ["docker", "run"], stderr="no such image: panopticon-foo") + + def _failing_run(*args: object, **kwargs: object) -> subprocess.CompletedProcess[str]: + raise err + + monkeypatch.setattr(subprocess, "run", _failing_run) + with pytest.raises(RuntimeError, match="no such image: panopticon-foo"): + _subprocess_run(["docker", "run", "panopticon-foo"]) + + +def test_subprocess_run_error_without_stderr(monkeypatch: pytest.MonkeyPatch) -> None: + """CalledProcessError with no stderr still raises RuntimeError with exit code.""" + err = subprocess.CalledProcessError(1, ["docker", "build"], stderr="") + + def _failing_run(*args: object, **kwargs: object) -> subprocess.CompletedProcess[str]: + raise err + + monkeypatch.setattr(subprocess, "run", _failing_run) + with pytest.raises(RuntimeError, match=r"Command 'docker' exited 1$"): + _subprocess_run(["docker", "build", "."]) + + # -- integration: real docker + tmux ------------------------------------------------ _HAVE_DOCKER_TMUX = bool(shutil.which("docker") and shutil.which("tmux"))