From b9afcae6ac471a5547f287d6d85c1cfc33e1e5ea Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 16 Aug 2026 08:11:09 -0700 Subject: [PATCH] [eric] router: a completed subscription connect bounces 9Router so its dispatch state rebuilds; new subs no longer dead until app restart (ENG-315) --- backend/apps/agents/agents.py | 3 ++ backend/apps/help/changelog.py | 1 + .../apps/nine_router/bounce_after_connect.py | 37 +++++++++++++++ backend/tests/test_bounce_after_connect.py | 46 +++++++++++++++++++ 4 files changed, 87 insertions(+) create mode 100644 backend/apps/nine_router/bounce_after_connect.py create mode 100644 backend/tests/test_bounce_after_connect.py diff --git a/backend/apps/agents/agents.py b/backend/apps/agents/agents.py index 2d9b5513..d1dce3b5 100644 --- a/backend/apps/agents/agents.py +++ b/backend/apps/agents/agents.py @@ -534,6 +534,9 @@ async def subscriptions_poll(body: dict): p_sync(load_settings().model_dump()) from backend.apps.subscription.free_trial import clear_free_trial_on_connect await clear_free_trial_on_connect() + # Background so the UI's "Connected" lands instantly; the bounce takes ~5-10s (ENG-315). + from backend.apps.nine_router.bounce_after_connect import bounce_router_after_connect + asyncio.create_task(bounce_router_after_connect(provider)) return result except Exception as e: raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/apps/help/changelog.py b/backend/apps/help/changelog.py index cc9baef5..8e6309dd 100644 --- a/backend/apps/help/changelog.py +++ b/backend/apps/help/changelog.py @@ -91,6 +91,7 @@ P_RELEASES: List[ReleaseNote] = [ "Starting a new chat no longer makes another agent's revealed subagents disappear from the canvas. Their cards were being cleaned up as strays by the same pass that places the new one.", "Workflow run history shows each run's workflow name instead of the word \"Workflow\" on every row. The name now travels with the run, so it survives renames and deleted workflows.", "An expired provider login heals itself mid-chat: the first failure rebuilds the connection and retries your message with zero clicks, and only a second failure asks you to reconnect. It used to take six manual steps every time a token aged out.", + "A newly connected ChatGPT or Gemini subscription works immediately. The routing layer restarts itself the moment a connect completes, so new subscriptions no longer sit dead behind rate-limit errors until you restart the app.", "Heavy sessions no longer vanish without a trace. When memory climbs past the safe line the app now sheds weight itself: preview thumbnails pause and refetchable caches drop, instead of growing until the operating system kills it mid-task.", ], ), diff --git a/backend/apps/nine_router/bounce_after_connect.py b/backend/apps/nine_router/bounce_after_connect.py new file mode 100644 index 00000000..005fb9f7 --- /dev/null +++ b/backend/apps/nine_router/bounce_after_connect.py @@ -0,0 +1,37 @@ +"""Restart 9Router right after a subscription connect completes (ENG-315). + +A freshly connected subscription can sit dead until OpenSwarm restarts, dying on rate-limit-shaped +errors. Every layer WE own reads live (picker payload, provider env, route resolution: all +verified no-restart on packaged exp.9), so the stale state is inside the router process itself: +0.3.60 stamps failing connections with `modelLock_` cooldowns + `testStatus:"unavailable"` +in its in-memory DB, and dispatch skips locked connections, which reads as a rate limit to the +caller. A router restart re-reads db.json and was measured to land every arm in a known-good state +within ~10s, which is exactly why "restart OpenSwarm" cures the reported bug: the app restart's +only relevant side effect IS the router restart. So do just that part, at the connect chokepoint. + +Safe mid-session: the CLI retries provider 5xx itself (10 attempts / 30s), and the kill-drill on +the packaged build showed an in-flight agent surviving a router bounce with the session resuming. +""" +import asyncio +import logging + +from typeguard import typechecked + +from backend.apps.nine_router import process as p_router + +logger = logging.getLogger(__name__) + + +@typechecked +async def bounce_router_after_connect(provider: str) -> bool: + """Sequential stop -> ensure_running. Never two live routers (a parallel pair once rotated one + token family to death); never raises, a failed bounce leaves the watchdog to revive.""" + try: + logger.info(f"bouncing 9Router after {provider} connect so its dispatch state is rebuilt") + p_router.stop() + await asyncio.sleep(0.5) + await p_router.ensure_running() + return p_router.is_running() + except Exception: + logger.warning("post-connect router bounce failed; watchdog will revive", exc_info=True) + return False diff --git a/backend/tests/test_bounce_after_connect.py b/backend/tests/test_bounce_after_connect.py new file mode 100644 index 00000000..edf9eaa7 --- /dev/null +++ b/backend/tests/test_bounce_after_connect.py @@ -0,0 +1,46 @@ +"""A freshly connected subscription must not stay dead until an app restart (ENG-315). + +Live evidence on packaged exp.9: with the router knowing the sub and the app booted without it, +picker payload, fresh GPT session, and a pre-connect session switched to GPT all worked with zero +restart, so every layer we own reads live. The remaining stale layer is the router process itself +(0.3.60 stamps modelLock cooldowns + testStatus into its in-memory DB and skips locked connections +at dispatch), and a router restart was the measured heal. These pin that connect-success actually +schedules that heal, sequentially, and that a failed bounce cannot take the poll route down. +""" +import asyncio +import inspect + +import pytest + +from backend.apps.nine_router import bounce_after_connect as b + + +@pytest.mark.asyncio +async def test_bounce_is_sequential_stop_then_start(monkeypatch): + order = [] + monkeypatch.setattr(b.p_router, "stop", lambda: order.append("stop")) + + async def p_start(): + order.append("start") + monkeypatch.setattr(b.p_router, "ensure_running", p_start) + monkeypatch.setattr(b.p_router, "is_running", lambda: True) + assert await b.bounce_router_after_connect("codex") is True + assert order == ["stop", "start"], "two live routers once rotated a token family to death; stop must fully precede start" + + +@pytest.mark.asyncio +async def test_a_failed_bounce_never_raises(monkeypatch): + def p_boom(): + raise RuntimeError("router dir vanished") + monkeypatch.setattr(b.p_router, "stop", p_boom) + assert await b.bounce_router_after_connect("codex") is False + + +def test_poll_success_schedules_the_bounce(): + # Wire-check both directions: the heal exists AND the connect chokepoint calls it (ENG-284 rule). + from backend.apps.agents import agents as agents_mod + src = inspect.getsource(agents_mod) + poll = src[src.index("async def subscriptions_poll"):src.index("async def subscriptions_exchange")] + assert "bounce_router_after_connect" in poll, "an unheal-ed connect is the whole ENG-315 bug" + assert 'result.get("success")' in poll.split("bounce_router_after_connect")[0], "the bounce must be gated on OAuth success, never on every poll tick" + assert "create_task" in poll, "the UI's Connected flash must not wait ~10s on the bounce"