diff --git a/backend/apps/agents/manager/session/SessionPersistence.py b/backend/apps/agents/manager/session/SessionPersistence.py index f2531de8..9809167f 100644 --- a/backend/apps/agents/manager/session/SessionPersistence.py +++ b/backend/apps/agents/manager/session/SessionPersistence.py @@ -4,6 +4,7 @@ on boot). Split from SessionLifecycle (which handles ONE session at a time) so e one concern. self.sessions resolves across the MRO as before.""" import logging +import os import sys from typeguard import typechecked @@ -22,7 +23,15 @@ logger = logging.getLogger(__name__) def running_under_test() -> bool: """Auto-resume dispatches REAL turns: live credentials, live Bash, in whatever tree the process was started from. A test that boots the app lifespan must never do that to the developer's own - chats, so this is the one gate that keeps a suite run from becoming an agent run.""" + chats, so this is the one gate that keeps a suite run from becoming an agent run. + + The env var is the DECLARED signal and conftest sets it. `pytest in sys.modules` is kept only as + a belt-and-braces fallback, and deliberately not as the primary: an incidental signal fails in + the worst direction, because the day pytest becomes importable in a packaged build every + crash-interrupted turn stops resuming and NOTHING says so. Work vanishing quietly is the worst + bug this codebase can ship; a suite that loudly refuses to resume is merely annoying.""" + if os.environ.get("OSW_DISABLE_AUTO_RESUME") == "1": + return True return "pytest" in sys.modules diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index cc1cbe36..ec57954a 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -33,6 +33,10 @@ def _isolate_browser_state(monkeypatch): monkeypatch.setenv("OPENSWARM_BROWSER_METRICS_DIR", metrics_dir) monkeypatch.setenv("OPENSWARM_BROWSER_PLAYBOOK_DIR", playbook_dir) # The speed levers are default-ON in prod; pin them off for the suite so mocked loop tests keep exact aux-call/turn expectations (same pattern as OPENSWARM_PERSISTENT_CLIENT). The levers are exercised by their own live gates + targeted tests that set the flag explicitly. + # DECLARED, not inferred: auto-resume sends real turns with live credentials and live Bash into + # whatever tree the suite runs from (ENG-388). The pytest-in-sys.modules fallback still exists, + # but a gate that depends on an accident fails silently the day the accident changes. + monkeypatch.setenv("OSW_DISABLE_AUTO_RESUME", "1") monkeypatch.setenv("OSW_PRESTAGE", "0") monkeypatch.setenv("OSW_FASTREAD_HOP", "0") monkeypatch.setenv("OSW_PRELUDE_TRIM", "0") diff --git a/backend/tests/test_no_real_turns_under_test.py b/backend/tests/test_no_real_turns_under_test.py index 3d085b84..3ce6e17a 100644 --- a/backend/tests/test_no_real_turns_under_test.py +++ b/backend/tests/test_no_real_turns_under_test.py @@ -47,3 +47,11 @@ def test_the_gate_is_what_stops_it(monkeypatch): def test_the_gate_sees_pytest(): assert p_sp.running_under_test() is True + + +def test_the_declared_signal_works_without_the_accidental_one(monkeypatch): + """The env var must stand on its own: the day `pytest in sys.modules` stops being true in some + runner, the gate has to keep holding, or crash-resume silently reactivates against real chats.""" + monkeypatch.setenv("OSW_DISABLE_AUTO_RESUME", "1") + monkeypatch.setitem(__import__("sys").modules, "pytest", None) + assert p_sp.running_under_test() is True diff --git a/frontend/src/app/Main.tsx b/frontend/src/app/Main.tsx index 79dcb934..d396b181 100644 --- a/frontend/src/app/Main.tsx +++ b/frontend/src/app/Main.tsx @@ -408,7 +408,19 @@ const DefaultModelGuard: React.FC<{ children: React.ReactNode }> = ({ children } const fallback = pickFallbackModel(byProvider); if (!fallback) return; // A model absent from the catalog is genuinely gone; one merely absent from today's list is a provider we cannot reach this second, and moving the chat off it is never ours to do silently. - if (!catalogComplete || knownValues.length === 0) return; + // Going quiet here is itself a failure mode: a genuinely retired model then never heals and the + // user just gets an error on every send. So when the catalog cannot be vouched for, say so once + // for the sessions it would have touched instead of disabling the heal in silence. + if (!catalogComplete || knownValues.length === 0) { + const p_stranded = Object.values(store.getState().agents.sessions) + .filter((s2) => s2.model && !valid.has(s2.model)); + if (p_stranded.length && !warnedSessionsRef.current.has('catalog-unverified')) { + warnedSessionsRef.current.add('catalog-unverified'); + console.warn(`[models] catalog unverified (complete=${catalogComplete}, known=${knownValues.length}); ` + + `${p_stranded.length} session(s) hold a model that is not currently available and will NOT be auto-switched`); + } + return; + } const known = new Set(knownValues); const target = valid.has(settings.default_model) ? settings.default_model : fallback.value; let switched = false;