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:
@@ -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) }
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user