fix(bot-screen): a screen started or stopped by another process reaches the Desktop as display.status

The portal fetched display.status once and then only listened for lease events, so a start via the
CLI, a gateway auto-start, or a crash stayed invisible until remount (jeremiah-k #23). The serve-side
watcher now also stats env + launcher.pid per served home and broadcasts a full display.status
snapshot when either moves; the portal applies it through the same connection-qualified predicate.
Live: start/stop from a separate process reached the socket in 0.12 s / 0.36 s.
This commit is contained in:
teknium1
2026-09-12 18:57:16 -07:00
parent 21d3c77300
commit bc36ddb5f9
3 changed files with 70 additions and 0 deletions

View File

@@ -119,6 +119,20 @@ export function useScreenPortalState(bot: RosterRow) {
[bot, profileKey]
)
// A start/stop/crash made outside this window (CLI, gateway auto-start, another Desktop) is
// pushed by the serve-side runtime watcher; without it the portal's status was one-shot.
useEffect(
() =>
host.onEvent('display.status', (event: RpcEvent) => {
const payload = event.payload as DisplayStatus | undefined
if (payload?.profile_key && isEventForBotScreen(bot, event, profileKey)) {
setScreenStatus(bot, payload)
}
}),
[bot, profileKey]
)
return { status, lease: state?.lease ?? null, tone: portalTone(status, state?.lease ?? null, state?.viewer ?? null, state?.unavailable) }
}

View File

@@ -78,3 +78,27 @@ def test_release_in_another_process_is_broadcast_and_local_transition_not_duplic
assert _wait_for(lambda: _lease_events(events, holder="agent")), events
assert len(_lease_events(events, holder="agent")) == 1
def test_screen_started_or_stopped_by_another_process_is_broadcast_as_status(tmp_path, monkeypatch):
"""A start/stop made by the CLI or gateway process must reach an open Desktop: the portal's
status was otherwise one-shot (fetched once, then only lease events)."""
import tui_gateway.server as server
home = tmp_path / "home"
(home / "bot-desktop").mkdir(parents=True)
events = _watching(server, home, monkeypatch)
server._poll_runtime_files() # seed
assert not [e for e in events if e[0] == "display.status"]
# what runtime.start() publishes from another process: launcher.pid then env
(home / "bot-desktop" / "launcher.pid").write_text("424242 1.5")
(home / "bot-desktop" / "env").write_text("DISPLAY=:77\n")
server._poll_runtime_files()
statuses = [p for e, p in events if e == "display.status"]
assert statuses and statuses[-1]["profile_key"] == str(home)
(home / "bot-desktop" / "env").unlink() # stop() from the other process
server._poll_runtime_files()
assert len([e for e in events if e[0] == "display.status"]) == 2
assert [p for e, p in events if e == "display.status"][-1]["running"] is False

View File

@@ -23,6 +23,9 @@ _lease_watcher_started = threading.Event()
# made by THIS process is not re-broadcast when its file write is noticed a tick later).
_lease_epochs: dict[str, int] = {}
_lease_mtimes: dict[str, int | None] = {}
# profile key → (env mtime, launcher.pid mtime): the screen's running/display identity. A start,
# stop or crash made by another process (CLI, gateway auto-start) moves one of these.
_runtime_marks: dict[str, tuple] = {}
def _lease_event_payload(profile_key: str, lease) -> dict:
@@ -35,6 +38,34 @@ def _watched_lease_homes() -> list[Path]:
return [Path(_hermes_home), *_served_profile_homes]
def _mtime(path: Path):
try:
return path.stat().st_mtime_ns
except OSError:
return None
def _poll_runtime_files() -> None:
"""Broadcast ``display.status`` when a home's screen started/stopped outside this process."""
from hermes_constants import hermes_home_key, reset_hermes_home_override, set_hermes_home_override
for home in _watched_lease_homes():
key = hermes_home_key(home)
sd = home / "bot-desktop"
mark = (_mtime(sd / "env"), _mtime(sd / "launcher.pid"))
first = key not in _runtime_marks
if _runtime_marks.get(key) == mark:
continue
_runtime_marks[key] = mark
if first: # seeding: display.status carries the current state
continue
token = set_hermes_home_override(home)
try:
payload = _display_snapshot()
finally:
reset_hermes_home_override(token)
_broadcast_global_event("display.status", payload)
def _poll_lease_files() -> None:
"""One pass: read a home's lease only when its file mtime moved; broadcast when the epoch did."""
from hermes_constants import hermes_home_key
@@ -77,6 +108,7 @@ def _ensure_lease_watcher() -> None:
while True:
try:
_poll_lease_files()
_poll_runtime_files()
except Exception: # noqa: BLE001 - a torn read must not kill the watcher
logger.debug("lease watcher poll failed", exc_info=True)
time.sleep(_LEASE_POLL_S)