Files
teknium1 71971e9205 feat(bot-desktop): memory gate and idle auto-stop for small hosts
Hosted instances come in 4 GB and 8 GB packages; a headed desktop plus the
browser a human opens during a takeover costs 1.1-1.5 GB on top of the
gateway (measured in the official image: gateway 304 MiB, +Xvnc/Xfce 520,
+Chromium on one page 1,073). Left unguarded, the loser of that squeeze is
Chromium mid-login or the gateway itself.

- runtime.start() refuses while the host, or its cgroup when tighter, has
  less than bot_desktop.min_free_memory_mb free (default 1536); status()
  carries `blocker` plus memory_available_mb/memory_limit_mb so the pane
  shows the reason in place of Start and the CLI prints it. A running
  screen is never reported blocked: the gate guards the allocation.
- A screen nobody used for bot_desktop.idle_stop_minutes (default 30) is
  stopped by the gateway's display watcher and comes back on the next
  use. "Use" = a computer_use dispatch, a browser/cua spawn onto the
  screen, an attached viewer (stamped from the RFB bridge at most once a
  minute), or start itself; a human holding the lease is never timed out.

Both knobs are 0-disableable; the contract gains the three status fields.
2026-09-16 17:22:14 -07:00

184 lines
6.4 KiB
Python

"""Bot Screen (``methods_display.py`` / ``methods_display_watch.py``): a profile's headless Xfce
desktop, its takeover lease, the noVNC ticket and the on-host package install.
Every method rides ``profile`` (the desktop routes through ``requestGatewayForProfile``) and answers
for THAT profile's screen. The lease is the whole control model: ``holder`` is ``agent`` or
``human``; ``epoch`` moves on every real transition, so a client (or the agent's fence) can tell an
in-flight result was produced under a lease that has since changed.
"""
from __future__ import annotations
from .base import Params, Payload, Result, WireEnum
from .common import ProfileParams
from .registry import event, method, server_request
from .server_requests import ServerRequestParams, ValueResult
# ── shapes ────────────────────────────────────────────────────────────────────────────────────
class LeaseHolder(WireEnum):
agent = "agent"
human = "human"
class DisplayLease(Result):
"""``tools/bot_desktop/lease.py::Lease`` as clients may see it: the holder's viewer id is a
capability and never leaves the gateway; ``viewer_hash`` lets the holder recognise itself."""
holder: LeaseHolder
viewer_id: None = None
viewer_hash: str | None = None
since: float
epoch: int
reason: str = ""
class DisplayStatus(Result):
"""``tools/bot_desktop/runtime.py::DesktopStatus`` plus the lease and the profile it speaks for."""
profile: str
supported: bool
installed: bool
missing: list[str]
running: bool
pid: int | None = None
display: str | None = None
socket: str | None = None
geometry: str
install_command: str | None = None
browser: str | None = None
blocker: str | None = None # why display.start would refuse now (host memory); the pane shows it instead of Start
memory_available_mb: int | None = None
memory_limit_mb: int | None = None
lease: DisplayLease
profile_key: str
# ── methods ───────────────────────────────────────────────────────────────────────────────────
method("display.status", params=ProfileParams, result=DisplayStatus,
doc="Runtime + lease snapshot for this profile's screen.")
class DisplayThumbnailResult(Result):
"""``data_url`` is null while the screen is stopped or while a human holds the lease
(``suppressed``): the frame may show what they are typing."""
data_url: str | None = None
suppressed: str | None = None
method("display.thumbnail", params=ProfileParams, result=DisplayThumbnailResult,
doc="One JPEG grab of the bot's screen; read-only, never changes the lease.")
method("display.start", params=ProfileParams, result=DisplayStatus,
doc="Start this profile's Xvnc + Xfce (idempotent); blocks until the display is published.")
class DisplayStopParams(ProfileParams):
force: bool | None = None # stop even while a human holds control
class DisplayStopResult(DisplayStatus):
stopped: bool
method("display.stop", params=DisplayStopParams, result=DisplayStopResult,
doc="Stop the screen. Refused (5300, code viewer_mismatch) while a human holds unless force.")
class DisplayObserveParams(ProfileParams):
viewer_id: str | None = None # an id THIS connection minted earlier keeps its identity across a reconnect
class DisplayObserveResult(DisplayStatus):
ticket: str
path: str
viewer_id: str
method("display.observe", params=DisplayObserveParams, result=DisplayObserveResult,
doc="Mint a single-use ticket for /api/display/ws and the server-minted viewer id for this connection.")
class DisplayInstallResult(Result):
started: bool
command: str | None = None
profile_key: str
method("display.install", params=ProfileParams, result=DisplayInstallResult,
doc="Run the distro package install on the gateway host; progress streams as display.install.log/.done.")
class DisplayLeaseAcquireParams(ProfileParams):
viewer_id: str
reason: str | None = None
class DisplayLeaseResult(Result):
lease: DisplayLease
method("display.lease.acquire", params=DisplayLeaseAcquireParams, result=DisplayLeaseResult,
doc="Take over: the human named by a viewer id this connection minted controls the screen.")
class DisplayLeaseReleaseParams(ProfileParams):
viewer_id: str | None = None
force: bool | None = None
method("display.lease.release", params=DisplayLeaseReleaseParams, result=DisplayLeaseResult,
doc="Hand back. Without a viewer id the release is refused while a human holds unless force.")
# ── server → client ───────────────────────────────────────────────────────────────────────────
class DisplayInstallSudoParams(ServerRequestParams):
profile_key: str
server_request("display.install.sudo", params=DisplayInstallSudoParams, result=ValueResult,
doc="Masked sudo password for the Bot Screen package install; app-level (empty session).")
# ── events ────────────────────────────────────────────────────────────────────────────────────
class DisplayStatusPayload(DisplayStatus, Payload):
pass
event("display.status", DisplayStatusPayload,
doc="This profile's screen started or stopped (also for transitions made outside hermes serve).")
class DisplayLeasePayload(Payload):
profile_key: str
lease: DisplayLease
event("display.lease", DisplayLeasePayload, doc="The takeover lease changed hands; every client repaints.")
class DisplayInstallLogPayload(Payload):
profile_key: str
line: str
event("display.install.log", DisplayInstallLogPayload, doc="One line of package-manager output.")
class DisplayInstallDonePayload(Payload):
profile_key: str
code: int
status: DisplayStatus
event("display.install.done", DisplayInstallDonePayload,
doc="The install ended (0 ok, -1 cancelled, -2 no sudo: the command to run by hand was streamed).")