Files
pi-agent-config/shared/lib/py/tests/test_pi_rpc_smoke.py
T
Kai 943f631966 fix(pi_rpc): detect a dead pi process immediately, not at the turn deadline
When pi died mid-turn, _read_stdout saw EOF and logged it, but nothing woke
_consume -- it sat on the event queue until asyncio.timeout(budget) fired at the
turn deadline (180 s). A dead process at second 3 was therefore reported as "no
reply" at second 180, and the fallback that launched a fresh pi hid the death
entirely. To the user this read as a hard hang.

_read_stdout now enqueues a "_process_exited" sentinel on EOF, and _consume
raises PiRpcError on it, so the failure path (same-session fallback model, then
rotation, then a fresh process) starts within milliseconds.

Smoke-tested: SIGKILL the child during a command and the client reports it in
0.0 s instead of the full budget, with no orphan left behind.
2026-08-28 21:17:21 -07:00

144 lines
6.0 KiB
Python

"""Zero-token smoke test for pi_rpc against a real pi process.
Sends only `get_state`, so no model call is made. Verifies that the launch
contract produces a live process, that the isolation flags land, and that the
process group is cleaned up.
Run: python3 shared/lib/py/tests/test_pi_rpc_smoke.py
"""
from __future__ import annotations
import asyncio
import os
import signal
import os
import subprocess
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from dataclasses import replace # noqa: E402
import pathlib # noqa: E402
import tempfile # noqa: E402
from pi_rpc import PiLaunchConfig, PiRpcClient, PiRpcError # noqa: E402
REPO = Path(__file__).resolve().parents[4]
HARNESS = REPO / "docs" / "evidence" / "probe-harness"
PI_BIN = os.environ.get("PI_BIN", "/home/claw/.npm-global/bin/pi")
def check(label: str, ok: bool, detail: str = "") -> bool:
print(f" {'PASS' if ok else 'FAIL'} {label}{(' — ' + detail) if detail else ''}")
return ok
async def main() -> int:
failures = 0
print("== build_args reflects the isolation baseline ==")
cfg = PiLaunchConfig(
pi_bin=PI_BIN,
workspace=HARNESS,
session_dir=Path("/tmp/pi-rpc-smoke-sessions"),
provider="zenmux",
model="openai/gpt-5.6-luna",
thinking="low",
display_name="pi_rpc smoke",
extensions=(HARNESS / "probe-ext.ts",),
skills=(HARNESS / ".pi" / "skills" / "probe-skill",),
session_id_prefix="", # ephemeral for the smoke test
receipt_tools=frozenset({"probe_plain_schema"}),
turn_deadline_seconds=45.0,
)
args = cfg.build_args(None)
for flag in ("--no-builtin-tools", "--no-extensions", "--no-skills",
"--no-prompt-templates", "--no-themes", "--approve", "--no-session"):
failures += not check(f"{flag} present", flag in args)
failures += not check("--tools absent (registry allowlist would block dynamic tools)",
"--tools" not in args)
print("== system prompt and the skills/read interaction ==")
sp = pathlib.Path(tempfile.mkdtemp()) / "SYSTEM.md"
sp.write_text("marker", encoding="utf-8")
withprompt = replace(cfg, system_prompt=sp, append_system_prompt=sp)
prompt_args = withprompt.build_args(None)
failures += not check("--system-prompt passed through", "--system-prompt" in prompt_args)
failures += not check("--append-system-prompt passed through",
"--append-system-prompt" in prompt_args)
# A dedicated agent whose tools are all domain-specific has no 'read', and pi
# then discards every --skill argument silently. Measured, not assumed.
noread = replace(cfg, skills=(sp.parent,), no_builtin_tools=True,
extension_registers_read=False)
failures += not check("skills without a read tool are flagged as ineffective",
not noread._read_reachable())
failures += not check("an extension that registers read is trusted",
replace(noread, extension_registers_read=True)._read_reachable())
failures += not check("an explicit allowlist naming read counts",
replace(noread, tools=("read", "counts"))._read_reachable())
failures += not check("an allowlist without read does not",
not replace(noread, tools=("counts",))._read_reachable())
print("== env is minimal ==")
os.environ["SMOKE_FAKE_SECRET"] = "must-not-propagate"
env = cfg.build_env()
failures += not check("secret withheld", "SMOKE_FAKE_SECRET" not in env)
failures += not check("PATH forwarded", "PATH" in env)
failures += not check(f"small env ({len(env)} vars)", len(env) < 20)
print("== live process: start, get_state, stop ==")
client = PiRpcClient(cfg)
try:
await client.start()
pid = client.pid
failures += not check("process running", client.running, f"pid={pid}")
state = await client.get_state()
failures += not check("get_state returned a model", bool(state.get("model")),
str(state.get("model", {}).get("id", "?")))
failures += not check("not streaming", state.get("isStreaming") is False)
failures += not check("thinkingLevel honoured", state.get("thinkingLevel") == "low",
str(state.get("thinkingLevel")))
stats = await client.get_session_stats()
failures += not check("get_session_stats works", "tokens" in stats)
finally:
await client.stop()
failures += not check("process stopped", not client.running)
if pid:
alive = subprocess.run(["ps", "-p", str(pid)], capture_output=True).returncode == 0
failures += not check("no orphan process", not alive)
# A process that dies mid-command must be reported immediately, not at the
# turn deadline. Before the sentinel this sat on the event queue for the full
# 180 s budget; a fallback that launches a fresh pi then hid the death.
print("== process exit is detected immediately ==")
client2 = PiRpcClient(cfg)
await client2.start()
import time as _time
try:
os.killpg(os.getpgid(client2.pid), signal.SIGKILL)
started = _time.monotonic()
try:
await asyncio.wait_for(client2.get_session_stats(), timeout=10)
failures += not check("dead process reported", False, "command succeeded after SIGKILL")
except PiRpcError:
elapsed = _time.monotonic() - started
failures += not check("dead process reported promptly", elapsed < 10,
f"{elapsed:.1f}s (must be well under the 180 s budget)")
finally:
try:
await client2.stop()
except Exception:
pass
failures += not check("no orphan from killed client", not client2.running)
print()
print("RESULT:", "all checks passed" if failures == 0 else f"{failures} check(s) failed")
return 1 if failures else 0
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))