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
@@ -13,12 +13,15 @@ import contextlib
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Protocol
|
||||
|
||||
import psutil
|
||||
|
||||
from beaver_gateway.backends.processes import Processes, subtree
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import (
|
||||
AsyncIterable,
|
||||
@@ -43,6 +46,7 @@ DEFAULT_TTL: Mapping[str, float | None] = {
|
||||
|
||||
_EVICT_ORDER = {"fork": 0, "job": 0, "deep": 1, "branch": 2, "master": 3}
|
||||
_RSS_HEADROOM = 0.8
|
||||
STDERR_LINES = 200
|
||||
|
||||
|
||||
class SessionClient(Protocol):
|
||||
@@ -86,6 +90,7 @@ class Session:
|
||||
"""Summary of the last task notification, the pending turn's prompt."""
|
||||
observer: Callable[[Any], None] | None = None
|
||||
"""The last turn's observer; sees subagent traffic between turns."""
|
||||
stderr: deque[str] = field(default_factory=lambda: deque(maxlen=STDERR_LINES))
|
||||
|
||||
@property
|
||||
def busy(self) -> bool:
|
||||
@@ -121,6 +126,7 @@ class SessionPool:
|
||||
self._max_live = max_live
|
||||
self._ttl = dict(ttl)
|
||||
self._reap_interval = reap_interval
|
||||
self._processes = Processes()
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._sessions)
|
||||
@@ -168,19 +174,6 @@ class SessionPool:
|
||||
total += child.memory_info().rss
|
||||
return total
|
||||
|
||||
@staticmethod
|
||||
def rss_of(session: Session) -> int | None:
|
||||
pid = session.pid
|
||||
if pid is None:
|
||||
return None
|
||||
try:
|
||||
process = psutil.Process(pid)
|
||||
return process.memory_info().rss + sum(
|
||||
c.memory_info().rss for c in process.children(recursive=True)
|
||||
)
|
||||
except psutil.Error:
|
||||
return None
|
||||
|
||||
def over_limit(self) -> bool:
|
||||
if self._rss_limit is not None:
|
||||
return self.rss() > self._rss_limit * _RSS_HEADROOM
|
||||
@@ -214,11 +207,21 @@ class SessionPool:
|
||||
_log.info("closing session %s (%s, %s)", session.session_id, session.kind, key)
|
||||
if session.reader is not None and session.reader is not asyncio.current_task():
|
||||
session.reader.cancel()
|
||||
if session.attached:
|
||||
session.inbox.put_nowait(RuntimeError(f"session {key} was closed"))
|
||||
try:
|
||||
await session.client.disconnect()
|
||||
except Exception: # noqa: BLE001
|
||||
_log.exception("disconnect failed for session %s", session.session_id)
|
||||
|
||||
async def interrupt(self, key: str) -> bool:
|
||||
session = self._sessions.get(key)
|
||||
if session is None or not session.busy:
|
||||
return False
|
||||
session.interrupt_requested = True
|
||||
await session.client.interrupt()
|
||||
return True
|
||||
|
||||
async def close_all(self, *, agent: str | None = None) -> None:
|
||||
for session in list(self._sessions.values()):
|
||||
if agent is None or session.agent == agent:
|
||||
@@ -246,27 +249,57 @@ class SessionPool:
|
||||
_log.exception("session reaper failed")
|
||||
|
||||
def snapshot(self) -> list[dict[str, Any]]:
|
||||
return self.census()["sessions"]
|
||||
|
||||
def census(self) -> dict[str, Any]:
|
||||
now = time.monotonic()
|
||||
return [
|
||||
{
|
||||
"key": s.key,
|
||||
"agent": s.agent,
|
||||
"kind": s.kind,
|
||||
"session_id": s.session_id,
|
||||
"pid": s.pid,
|
||||
"rss": self.rss_of(s),
|
||||
"idle_seconds": round(now - s.last_used, 1),
|
||||
"age_seconds": round(now - s.created_at, 1),
|
||||
"turns": s.turns,
|
||||
"busy": s.busy,
|
||||
"running_turn": s.running_turn,
|
||||
"pending": len(s.pending),
|
||||
"pending_question": s.pending_question,
|
||||
"pinned": s.pinned,
|
||||
"dirty": s.dirty,
|
||||
}
|
||||
for s in self._sessions.values()
|
||||
]
|
||||
rows = self._processes.tree_parents_first()
|
||||
owned: set[int] = set()
|
||||
sessions: list[dict[str, Any]] = []
|
||||
for s in self._sessions.values():
|
||||
tree = subtree(rows, s.pid)
|
||||
owned.update(row["pid"] for row in tree)
|
||||
sessions.append(
|
||||
{
|
||||
"key": s.key,
|
||||
"agent": s.agent,
|
||||
"kind": s.kind,
|
||||
"session_id": s.session_id,
|
||||
"pid": s.pid,
|
||||
"rss": sum(row["rss"] for row in tree) if tree else None,
|
||||
"cpu": _cpu(tree),
|
||||
"idle_seconds": round(now - s.last_used, 1),
|
||||
"age_seconds": round(now - s.created_at, 1),
|
||||
"turns": s.turns,
|
||||
"busy": s.busy,
|
||||
"running_turn": s.running_turn,
|
||||
"pending": len(s.pending),
|
||||
"pending_question": s.pending_question,
|
||||
"pinned": s.pinned,
|
||||
"dirty": s.dirty,
|
||||
"processes": tree,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"rss": sum(row["rss"] for row in rows),
|
||||
"rss_limit": self._rss_limit,
|
||||
"cpu": _cpu(rows),
|
||||
"sessions": sessions,
|
||||
"others": [row for row in rows if row["pid"] not in owned],
|
||||
}
|
||||
|
||||
async def kill(self, pid: int) -> bool:
|
||||
if not self._processes.kill_own_descendant(pid):
|
||||
return False
|
||||
for session in list(self._sessions.values()):
|
||||
if session.pid == pid:
|
||||
await self.close(session.key)
|
||||
return True
|
||||
|
||||
|
||||
def _cpu(rows: list[dict[str, Any]]) -> float | None:
|
||||
rates = [row["cpu"] for row in rows if row["cpu"] is not None]
|
||||
return round(sum(rates), 1) if rates else None
|
||||
|
||||
|
||||
def cgroup_limit() -> int | None:
|
||||
|
||||
Reference in new issue
Block a user