Add persistent workspace shell sessions

Let agents inhabit a workspace across separate calls instead of only submitting one-shot execs.

Add workspace shell open/read/write/signal/close across the CLI, Python SDK, and MCP server, with persisted shell records, a local PTY-backed mock implementation, and guest-agent support for real Firecracker workspaces.

Mark the 2.5.0 roadmap milestone done, refresh docs/examples and the release metadata, and verify with uv lock, UV_CACHE_DIR=.uv-cache make check, and UV_CACHE_DIR=.uv-cache make dist-check.
This commit is contained in:
Thales Maciel 2026-03-12 02:31:57 -03:00
parent 2de31306b6
commit 3f8293ad24
28 changed files with 3265 additions and 81 deletions

View file

@ -0,0 +1,220 @@
from __future__ import annotations
import os
import subprocess
import time
from pathlib import Path
from typing import cast
import pytest
from pyro_mcp.workspace_shells import (
create_local_shell,
get_local_shell,
remove_local_shell,
shell_signal_arg_help,
shell_signal_names,
)
def _read_until(
workspace_id: str,
shell_id: str,
text: str,
*,
timeout_seconds: float = 5.0,
) -> dict[str, object]:
deadline = time.time() + timeout_seconds
payload = get_local_shell(workspace_id=workspace_id, shell_id=shell_id).read(
cursor=0,
max_chars=65536,
)
while text not in str(payload["output"]) and time.time() < deadline:
time.sleep(0.05)
payload = get_local_shell(workspace_id=workspace_id, shell_id=shell_id).read(
cursor=0,
max_chars=65536,
)
return payload
def test_workspace_shells_round_trip(tmp_path: Path) -> None:
session = create_local_shell(
workspace_id="workspace-1",
shell_id="shell-1",
cwd=tmp_path,
display_cwd="/workspace",
cols=120,
rows=30,
)
try:
assert session.summary()["state"] == "running"
write = session.write("printf 'hello\\n'", append_newline=True)
assert write["input_length"] == 16
payload = _read_until("workspace-1", "shell-1", "hello")
assert "hello" in str(payload["output"])
assert cast(int, payload["next_cursor"]) >= cast(int, payload["cursor"])
assert isinstance(payload["truncated"], bool)
session.write("sleep 60", append_newline=True)
signaled = session.send_signal("INT")
assert signaled["signal"] == "INT"
finally:
closed = session.close()
assert closed["closed"] is True
def test_workspace_shell_registry_helpers(tmp_path: Path) -> None:
session = create_local_shell(
workspace_id="workspace-2",
shell_id="shell-2",
cwd=tmp_path,
display_cwd="/workspace/subdir",
cols=80,
rows=24,
)
assert get_local_shell(workspace_id="workspace-2", shell_id="shell-2") is session
assert shell_signal_names() == ("HUP", "INT", "TERM", "KILL")
assert "HUP" in shell_signal_arg_help()
with pytest.raises(RuntimeError, match="already exists"):
create_local_shell(
workspace_id="workspace-2",
shell_id="shell-2",
cwd=tmp_path,
display_cwd="/workspace/subdir",
cols=80,
rows=24,
)
removed = remove_local_shell(workspace_id="workspace-2", shell_id="shell-2")
assert removed is session
assert remove_local_shell(workspace_id="workspace-2", shell_id="shell-2") is None
with pytest.raises(ValueError, match="does not exist"):
get_local_shell(workspace_id="workspace-2", shell_id="shell-2")
closed = session.close()
assert closed["closed"] is True
def test_workspace_shells_error_after_exit(tmp_path: Path) -> None:
session = create_local_shell(
workspace_id="workspace-3",
shell_id="shell-3",
cwd=tmp_path,
display_cwd="/workspace",
cols=120,
rows=30,
)
session.write("exit", append_newline=True)
deadline = time.time() + 5
while session.summary()["state"] != "stopped" and time.time() < deadline:
time.sleep(0.05)
assert session.summary()["state"] == "stopped"
with pytest.raises(RuntimeError, match="not running"):
session.write("pwd", append_newline=True)
with pytest.raises(RuntimeError, match="not running"):
session.send_signal("INT")
closed = session.close()
assert closed["closed"] is True
def test_workspace_shells_reject_invalid_signal(tmp_path: Path) -> None:
session = create_local_shell(
workspace_id="workspace-4",
shell_id="shell-4",
cwd=tmp_path,
display_cwd="/workspace",
cols=120,
rows=30,
)
try:
with pytest.raises(ValueError, match="unsupported shell signal"):
session.send_signal("BOGUS")
finally:
session.close()
def test_workspace_shells_init_failure_closes_ptys(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
def _boom(*args: object, **kwargs: object) -> object:
raise RuntimeError("boom")
monkeypatch.setattr(subprocess, "Popen", _boom)
with pytest.raises(RuntimeError, match="boom"):
create_local_shell(
workspace_id="workspace-5",
shell_id="shell-5",
cwd=tmp_path,
display_cwd="/workspace",
cols=120,
rows=30,
)
def test_workspace_shells_write_and_signal_runtime_errors(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
session = create_local_shell(
workspace_id="workspace-6",
shell_id="shell-6",
cwd=tmp_path,
display_cwd="/workspace",
cols=120,
rows=30,
)
try:
with session._lock: # noqa: SLF001
session._master_fd = None # noqa: SLF001
with pytest.raises(RuntimeError, match="transport is unavailable"):
session.write("pwd", append_newline=True)
with session._lock: # noqa: SLF001
master_fd, slave_fd = os.pipe()
os.close(slave_fd)
session._master_fd = master_fd # noqa: SLF001
def _raise_write(fd: int, data: bytes) -> int:
del fd, data
raise OSError("broken")
monkeypatch.setattr("pyro_mcp.workspace_shells.os.write", _raise_write)
with pytest.raises(RuntimeError, match="failed to write"):
session.write("pwd", append_newline=True)
def _raise_killpg(pid: int, signum: int) -> None:
del pid, signum
raise ProcessLookupError()
monkeypatch.setattr("pyro_mcp.workspace_shells.os.killpg", _raise_killpg)
with pytest.raises(RuntimeError, match="not running"):
session.send_signal("INT")
finally:
try:
session.close()
except Exception:
pass
def test_workspace_shells_refresh_process_state_updates_exit_code(tmp_path: Path) -> None:
session = create_local_shell(
workspace_id="workspace-7",
shell_id="shell-7",
cwd=tmp_path,
display_cwd="/workspace",
cols=120,
rows=30,
)
try:
class StubProcess:
def poll(self) -> int:
return 7
session._process = StubProcess() # type: ignore[assignment] # noqa: SLF001
session._refresh_process_state() # noqa: SLF001
assert session.summary()["state"] == "stopped"
assert session.summary()["exit_code"] == 7
finally:
try:
session.close()
except Exception:
pass