From 2127cddbbd8ae26c850ee9deec5b9b1be5be27d3 Mon Sep 17 00:00:00 2001 From: unsupportedpastels Date: Mon, 26 Oct 2026 14:26:54 +0000 Subject: [PATCH] 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. --- gateway/run_idle_exit.py | 13 ++++++++++-- gateway/session_authorities.py | 4 ++++ tests/gateway/test_unmanaged_idle_exit.py | 24 +++++++++++++++++++++++ 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/gateway/run_idle_exit.py b/gateway/run_idle_exit.py index 2bf9c70008..2ee4909acf 100644 --- a/gateway/run_idle_exit.py +++ b/gateway/run_idle_exit.py @@ -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__})" diff --git a/gateway/session_authorities.py b/gateway/session_authorities.py index 4c7032b66d..94579ec6a3 100644 --- a/gateway/session_authorities.py +++ b/gateway/session_authorities.py @@ -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).""" diff --git a/tests/gateway/test_unmanaged_idle_exit.py b/tests/gateway/test_unmanaged_idle_exit.py index 24852808e8..35463e709b 100644 --- a/tests/gateway/test_unmanaged_idle_exit.py +++ b/tests/gateway/test_unmanaged_idle_exit.py @@ -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)