feat(backends,api,ui): process trees with cpu and stderr per session, interrupt, close and kill from the system page
This commit is contained in:
1 parent
9875f08016
commit
712c24de4e
12 files changed
+996
-127
No files matched your search
@@ -396,6 +396,54 @@ async def test_describe_snapshots_open_tools_and_records_rate_limit(
|
||||
assert page["offset"] == 0 and len(page["entries"]) == 1
|
||||
|
||||
|
||||
async def test_sessions_show_their_processes_and_take_orders(world: World) -> None:
|
||||
world.backend._factory = ToolClient # noqa: SLF001
|
||||
api = Api(world)
|
||||
api.store.grant("api-only", "weak", scope="api")
|
||||
weak = {"Authorization": "Bearer weak"}
|
||||
conv = await world.conversations.create(kind="master", agent="a", origin="test")
|
||||
key = conv.external_id
|
||||
ScriptedClient.hold = asyncio.Event()
|
||||
await world.conversations.post(conv, "go")
|
||||
await asyncio.sleep(0.3)
|
||||
census = await api.get("/sessions")
|
||||
(row,) = census["sessions"]
|
||||
assert row["key"] == key and row["busy"]
|
||||
assert row["processes"] == [] and row["cpu"] is None
|
||||
assert isinstance(census["others"], list)
|
||||
live = world.pool.get(key)
|
||||
assert live is not None
|
||||
live.stderr.append("warning: something")
|
||||
stderr = await api.get(f"/sessions/{key}/stderr")
|
||||
assert stderr["lines"] == ["warning: something"]
|
||||
for path in (f"/sessions/{key}/close", "/processes/1/kill"):
|
||||
assert (await api.http.post(path, headers=weak)).status_code == 403
|
||||
assert (
|
||||
await api.http.get(f"/sessions/{key}/stderr", headers=weak)
|
||||
).status_code == 403
|
||||
|
||||
closed = await api.http.post(f"/sessions/{key}/close", headers=HEADERS)
|
||||
assert closed.json() == {"key": key, "closed": True}
|
||||
assert world.pool.get(key) is None
|
||||
deadline = asyncio.get_running_loop().time() + 5
|
||||
while ("user", "failed") not in await world.statuses(conv):
|
||||
assert asyncio.get_running_loop().time() < deadline, "the cut turn hangs"
|
||||
await asyncio.sleep(0.02)
|
||||
again = await api.http.post(f"/sessions/{key}/close", headers=HEADERS)
|
||||
assert again.status_code == 404
|
||||
gone = await api.http.post("/processes/999999999/kill", headers=HEADERS)
|
||||
assert gone.status_code == 404
|
||||
|
||||
ScriptedClient.hold = asyncio.Event()
|
||||
await world.conversations.post(conv, "once more")
|
||||
await asyncio.sleep(0.3)
|
||||
interrupted = await api.http.post(f"/sessions/{key}/interrupt", headers=weak)
|
||||
assert interrupted.json() == {"key": key, "interrupted": True}
|
||||
assert ScriptedClient.instances[-1].interrupted
|
||||
kinds = [r["kind"] for r in (await api.get("/audit"))["records"]]
|
||||
assert kinds == ["session_interrupt", "session_close"]
|
||||
|
||||
|
||||
async def test_tokens_and_audit_need_admin_scope(world: World) -> None:
|
||||
api = Api(world)
|
||||
api.store.grant("api-only", "weak", scope="api")
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
from beaver_gateway.backends.processes import Processes, _command, subtree
|
||||
|
||||
SLEEP = "import time; time.sleep(60)"
|
||||
NEST = (
|
||||
"import subprocess, sys, time; "
|
||||
f"subprocess.Popen([sys.executable, '-c', {SLEEP!r}]); time.sleep(60)"
|
||||
)
|
||||
|
||||
|
||||
def spawn(code: str) -> subprocess.Popen[bytes]:
|
||||
return subprocess.Popen([sys.executable, "-c", code])
|
||||
|
||||
|
||||
def rows_under(processes: Processes, pid: int, count: int) -> list[dict]:
|
||||
deadline = time.monotonic() + 5
|
||||
while time.monotonic() < deadline:
|
||||
rows = subtree(processes.tree_parents_first(), pid)
|
||||
if len(rows) >= count:
|
||||
return rows
|
||||
time.sleep(0.05)
|
||||
msg = f"{pid} never grew to {count} processes"
|
||||
raise AssertionError(msg)
|
||||
|
||||
|
||||
def test_the_tree_shows_what_the_gateway_started_and_what_it_costs() -> None:
|
||||
child = spawn(SLEEP)
|
||||
try:
|
||||
processes = Processes()
|
||||
(row,) = rows_under(processes, child.pid, 1)
|
||||
assert row["parent"] == os.getpid()
|
||||
assert row["depth"] == 0
|
||||
assert row["rss"] > 0
|
||||
assert row["cpu"] is None
|
||||
assert "time.sleep" in row["command"]
|
||||
(again,) = rows_under(processes, child.pid, 1)
|
||||
assert again["cpu"] is not None
|
||||
finally:
|
||||
child.kill()
|
||||
child.wait()
|
||||
|
||||
|
||||
def test_kill_takes_the_branch_and_nothing_the_gateway_did_not_start() -> None:
|
||||
parent = spawn(NEST)
|
||||
try:
|
||||
processes = Processes()
|
||||
top, nested = rows_under(processes, parent.pid, 2)
|
||||
assert (top["depth"], nested["depth"]) == (0, 1)
|
||||
assert not processes.kill_own_descendant(os.getpid())
|
||||
assert not processes.kill_own_descendant(os.getppid())
|
||||
assert processes.kill_own_descendant(parent.pid)
|
||||
parent.wait(timeout=5)
|
||||
alive = {row["pid"] for row in processes.tree_parents_first()}
|
||||
assert not alive & {top["pid"], nested["pid"]}
|
||||
finally:
|
||||
parent.kill()
|
||||
parent.wait()
|
||||
|
||||
|
||||
def test_a_subtree_ends_at_the_next_sibling() -> None:
|
||||
rows = [
|
||||
{"pid": 1, "depth": 0},
|
||||
{"pid": 2, "depth": 1},
|
||||
{"pid": 3, "depth": 2},
|
||||
{"pid": 4, "depth": 1},
|
||||
{"pid": 5, "depth": 0},
|
||||
]
|
||||
assert [(r["pid"], r["depth"]) for r in subtree(rows, 2)] == [(2, 0), (3, 1)]
|
||||
assert [r["pid"] for r in subtree(rows, 1)] == [1, 2, 3, 4]
|
||||
assert subtree(rows, 9) == []
|
||||
assert subtree(rows, None) == []
|
||||
|
||||
|
||||
def test_a_command_line_keeps_prompts_and_configs_to_itself() -> None:
|
||||
argv = [
|
||||
"/opt/claude/claude",
|
||||
"--model",
|
||||
"m",
|
||||
"--system-prompt",
|
||||
"you are a beaver",
|
||||
"--mcp-config",
|
||||
'{"gateway": {"headers": {"Authorization": "Bearer abc"}}}',
|
||||
"x" * 100,
|
||||
]
|
||||
assert _command(argv) == (
|
||||
"claude --model m --system-prompt … --mcp-config … " + "x" * 80 + "…"
|
||||
)
|
||||
assert _command([]) == ""
|
||||
Reference in new issue
Block a user