From 2b4e70ec076e1278ca93cc9efd5b6ebfa07674dc Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Thu, 3 Sep 2026 01:42:05 +0530 Subject: [PATCH] refactor(cli): use the router's run_in_threadpool alias; offload profile model write The module already binds run_in_threadpool (used by list_profiles_endpoint) and every sibling router uses the same starlette helper; the nine new loop.run_in_executor(None, _run) sites now go through that alias so the file has one offload idiom. Behaviour-identical (both hand the callable to a worker thread). Also sweeps the one endpoint the PR left synchronous: update_profile_model_endpoint's _write_profile_model reads and rewrites the profile's config.yaml on the event loop. --- hermes_cli/web_routers/profiles.py | 34 ++++++++----------- .../hermes_cli/test_web_profiles_off_loop.py | 20 +++++++++++ 2 files changed, 35 insertions(+), 19 deletions(-) diff --git a/hermes_cli/web_routers/profiles.py b/hermes_cli/web_routers/profiles.py index fbcfda5107..bf8f463b20 100644 --- a/hermes_cli/web_routers/profiles.py +++ b/hermes_cli/web_routers/profiles.py @@ -12,7 +12,6 @@ late-binding seam in :mod:`hermes_cli.web_deps` so tests that ``monkeypatch.setattr(web_server, "_helper", ...)`` keep working. """ -import asyncio # noqa: F401 — used by handlers import copy import functools import inspect @@ -935,7 +934,7 @@ async def get_active_profile_endpoint(): current = "default" return {"active": active, "current": current} - return await asyncio.get_running_loop().run_in_executor(None, _run) + return await run_in_threadpool(_run) @router.post("/api/profiles/active") @@ -953,7 +952,7 @@ async def set_active_profile_endpoint(body: ProfileActiveUpdate): try: # set_active_profile() stats the target profile, creates the state # directory and writes active_profile through a temp file + replace. - await asyncio.get_running_loop().run_in_executor(None, _run) + await run_in_threadpool(_run) except FileNotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) except ValueError as e: @@ -1035,7 +1034,7 @@ async def rename_profile_endpoint(name: str, body: ProfileRename): # _stop_gateway_process() poll that delete does, then renames the # profile directory, rewrites the Honcho host blocks and regenerates # the wrapper script. - path = await asyncio.get_running_loop().run_in_executor(None, _run) + path = await run_in_threadpool(_run) except FileNotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) except (ValueError, FileExistsError) as e: @@ -1081,7 +1080,7 @@ async def delete_profile_endpoint(name: str): # gateway is up — which this path announces as "⚠ Gateway is running # — it will be stopped" — therefore parks the loop for a full ten # seconds, and the desktop's WebSocket ready-probe gives up at ten. - path = await asyncio.get_running_loop().run_in_executor(None, _run) + path = await run_in_threadpool(_run) except FileNotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) except ValueError as e: @@ -1104,7 +1103,7 @@ async def get_profile_soul(name: str): return soul_path.read_text(encoding="utf-8") try: - content = await asyncio.get_running_loop().run_in_executor(None, _run) + content = await run_in_threadpool(_run) except OSError as e: raise HTTPException(status_code=500, detail=f"Could not read SOUL.md: {e}") if content is _MISSING: @@ -1141,7 +1140,7 @@ async def update_profile_soul(name: str, body: ProfileSoulUpdate): # atomic_write_text() writes a temp file, fsyncs it and replaces the # original — three syscalls that block for as long as the filesystem # takes to durably commit the persona document. - await asyncio.get_running_loop().run_in_executor(None, _run) + await run_in_threadpool(_run) except OSError as e: _log.exception("PUT /api/profiles/%s/soul failed", name) raise HTTPException(status_code=500, detail=f"Could not write SOUL.md: {e}") @@ -1170,7 +1169,7 @@ async def update_profile_description_endpoint(name: str, body: ProfileDescriptio try: # write_profile_meta() reads profile.yaml, merges the new keys and # writes the document back out. - await asyncio.get_running_loop().run_in_executor(None, _run) + await run_in_threadpool(_run) except Exception as e: _log.exception("PUT /api/profiles/%s/description failed", name) raise HTTPException(status_code=500, detail=str(e)) @@ -1190,7 +1189,8 @@ async def update_profile_model_endpoint(name: str, body: ProfileModelUpdate): if not provider or not model: raise HTTPException(status_code=400, detail="provider and model are required") try: - _write_profile_model(profile_dir, provider, model) + # _write_profile_model() reads and rewrites the profile's config.yaml. + await run_in_threadpool(_write_profile_model, profile_dir, provider, model) except Exception as e: _log.exception("PUT /api/profiles/%s/model failed", name) raise HTTPException(status_code=500, detail=str(e)) @@ -1221,7 +1221,7 @@ async def describe_profile_auto_endpoint(name: str, body: ProfileDescribeAuto): # call_llm() — a synchronous provider round-trip with a 60 s ceiling, # six times the desktop's WebSocket disconnect threshold. Held on the # loop it stalls every other dashboard request for that whole window. - outcome = await asyncio.get_running_loop().run_in_executor(None, _run) + outcome = await run_in_threadpool(_run) except Exception as e: _log.exception("POST /api/profiles/%s/describe-auto failed", name) raise HTTPException(status_code=500, detail=str(e)) @@ -1256,11 +1256,9 @@ async def export_profile_endpoint(name: str, body: ProfileExport): except OSError as exc: raise HTTPException(status_code=500, detail=f"Could not create export directory: {exc}") - loop = asyncio.get_running_loop() try: - result = await loop.run_in_executor( - None, - lambda: profiles_mod.export_profile(name, output, extra_files=body.extra_files or None), + result = await run_in_threadpool( + profiles_mod.export_profile, name, output, extra_files=body.extra_files or None ) except FileNotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) @@ -1280,11 +1278,9 @@ async def import_profile_endpoint(body: ProfileImport): if not archive: raise HTTPException(status_code=400, detail="archive path is required") - loop = asyncio.get_running_loop() try: - profile_dir = await loop.run_in_executor( - None, - lambda: profiles_mod.import_profile(archive, name=(body.name or "").strip() or None), + profile_dir = await run_in_threadpool( + profiles_mod.import_profile, archive, name=(body.name or "").strip() or None ) except FileNotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) @@ -1335,7 +1331,7 @@ async def get_profile_desktop_overlay(name: str): return _json.loads(overlay_path.read_text(encoding="utf-8")) try: - overlay = await asyncio.get_running_loop().run_in_executor(None, _run) + overlay = await run_in_threadpool(_run) except Exception as e: raise HTTPException(status_code=500, detail=f"Could not read desktop.json: {e}") # _MISSING rather than None: an overlay file holding the document ``null`` diff --git a/tests/hermes_cli/test_web_profiles_off_loop.py b/tests/hermes_cli/test_web_profiles_off_loop.py index 5d11ff6322..0b19e56b97 100644 --- a/tests/hermes_cli/test_web_profiles_off_loop.py +++ b/tests/hermes_cli/test_web_profiles_off_loop.py @@ -400,3 +400,23 @@ def test_describe_auto_unknown_profile_is_still_404(client): assert ( client.post("/api/profiles/nope/describe-auto", json={}).status_code == 404 ) + + +# ── PUT /api/profiles/{name}/model — config.yaml read-modify-write ─────────── + + +def test_update_profile_model_runs_off_loop(client, monkeypatch, loop_probe): + seen, probe = loop_probe + from hermes_cli.web_routers import profiles as router_mod + + def fake_write_model(profile_dir, provider, model): + probe("write_profile_model") + + monkeypatch.setattr(router_mod, "_write_profile_model", fake_write_model) + + resp = client.put( + "/api/profiles/demo/model", json={"provider": "openrouter", "model": "x/y"} + ) + + assert resp.status_code == 200, resp.text + assert_off_loop(seen, "write_profile_model")