fix(webapp): count a profile that is still starting as busy for idle exit

An auto-started gateway whose built ledgers were empty could idle-exit while hot-serve was still building a profile: the registry walk skips a reserved slot, so busy_reason saw nothing and the stop cancelled the start before that profile's queued admissions were recovered. busy_reason now reports a pending start (SessionAuthorities.starting_homes); the hold ends when the start is filled or fails.
This commit is contained in:
unsupportedpastels authored and Mark S. committed 2026-10-26 18:14:07 -04:00
1 parent 422f2f8456
commit 2127cddbbd
3 files changed
+39 -2

No files matched your search

+11 -2
View File
@@ -138,6 +138,15 @@ def _ledger_busy(runner) -> str | None:
return None
def _profile_start_busy(runner) -> str | None:
"""A profile hot-serve is still building has no authority to walk yet, but its queued admissions
are recovered once it starts; exiting now would cancel that start. A filled or failed start
leaves the registry's starting set, so the hold ends with it."""
registry = getattr(runner, "session_authorities", None)
starting = registry.starting_homes() if registry is not None else ()
return f"profile starting in {starting[0]}" if starting else None
def _store_gates_busy(homes) -> str | None:
from gateway.run_idle_gates import (
profile_has_active_heartbeat, profile_has_active_loop, profile_has_pending_handoff)
@@ -186,8 +195,8 @@ def busy_reason(runner, *, window: float, count_clients: bool = True) -> str | N
if runner._scale_to_zero_has_live_background_work():
return "background work in flight"
homes = _served_homes(runner)
return (_messaging_busy(runner) or _ledger_busy(runner) or _cron_busy(homes) or _kanban_busy()
or _store_gates_busy(homes))
return (_messaging_busy(runner) or _ledger_busy(runner) or _profile_start_busy(runner)
or _cron_busy(homes) or _kanban_busy() or _store_gates_busy(homes))
except Exception as exc:
logger.debug("idle-exit probe failed; staying up", exc_info=True)
return f"idle probe failed ({type(exc).__name__})"
+4
View File
@@ -75,6 +75,10 @@ class SessionAuthorities:
return next(((key, started) for key, started in list(self._starting.items())
if profile_matches_home(profile, Path(key))), None)
def starting_homes(self) -> list[str]:
"""Keys of every slot whose authority is still being built (a snapshot, as in ``serving``)."""
return list(self._starting)
def profile_name(self, authority):
"""Served profile name for *authority*; None for the launch profile (its adapters are
``runner.adapters``, its session keys the historical ``agent:main`` namespace)."""
+24
View File
@@ -108,6 +108,30 @@ def test_a_profile_still_starting_leaves_the_served_ledgers_readable(tmp_path, m
db.close()
def test_a_profile_still_starting_keeps_an_otherwise_idle_gateway_up(tmp_path, monkeypatch):
"""With every built ledger empty, a profile hot-serve is still building holds the gateway: its
queued admissions are recovered only once it starts. The hold ends with the start, filled or failed."""
runner, _home, db, _stops = _runner(tmp_path, monkeypatch)
registry = runner.session_authorities
starting = tmp_path / "starting"
starting.mkdir()
try:
assert run_idle_exit.busy_reason(runner, window=0.2) is None
registry.add(starting, None, name="starting") # as serve_profile_runtime does
assert "profile starting" in run_idle_exit.busy_reason(runner, window=0.2)
registry.remove(starting) # the build failed
assert run_idle_exit.busy_reason(runner, window=0.2) is None
registry.add(starting, None, name="starting")
starting_db = SessionDB(db_path=starting / "state.db")
registry.replace(starting, SimpleNamespace(profile_id=str(starting), db=starting_db))
try:
assert run_idle_exit.busy_reason(runner, window=0.2) is None
finally:
starting_db.close()
finally:
db.close()
def test_secondary_local_session_adapter_does_not_count_as_messaging(tmp_path, monkeypatch):
"""A secondary local session leaves an adapter map behind after the client disconnects."""
runner, _home, db, _stops = _runner(tmp_path, monkeypatch)