diff --git a/CHANGELOG.md b/CHANGELOG.md index 42919130..64d15ad9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ ## [Unreleased] +### Fixed + +- Streaming checkpoint saves now run under the session's profile environment, so periodic background checkpoints no longer risk falling back to the process-global profile when a WebUI tab is using a non-default profile. + ## [v0.51.134] — 2026-05-25 — Release DF (stage-batch16 — single-PR Windows path defaults) ### Fixed diff --git a/api/streaming.py b/api/streaming.py index e18e8792..fe59cfce 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -2557,6 +2557,18 @@ def _drop_checkpointed_current_user_from_context(messages, msg_text): return history +def _save_streaming_checkpoint(session): + """Persist a streaming checkpoint under the session's profile context.""" + from api import profiles as profiles_api + + with profiles_api.profile_env_for_background_worker( + session, + "streaming checkpoint", + logger_override=logger, + ): + session.save(skip_index=True) + + def _normalize_fresh_chat_text(text): text = _strip_workspace_prefix(str(text or ''), include_legacy=True) text = re.sub(r"\s+", " ", text).strip().lower() @@ -4552,7 +4564,7 @@ def _run_agent_streaming( cur = _checkpoint_activity[0] if cur > last_saved_activity: with _agent_lock: - s.save(skip_index=True) + _save_streaming_checkpoint(s) last_saved_activity = cur except Exception as e: logger.debug("Periodic checkpoint save failed: %s", e) diff --git a/tests/test_issue2848_checkpoint_profile.py b/tests/test_issue2848_checkpoint_profile.py new file mode 100644 index 00000000..f4792f78 --- /dev/null +++ b/tests/test_issue2848_checkpoint_profile.py @@ -0,0 +1,37 @@ +"""Regression coverage for #2848 checkpoint saves in background threads.""" +from __future__ import annotations + +from pathlib import Path + + +def test_checkpoint_save_uses_session_profile_env(monkeypatch, tmp_path): + """Checkpoint saves run on their own thread, outside request TLS. + + They must route profile-scoped helpers through the session's profile instead + of falling back to the process-global/default profile. + """ + from api.models import Session + from api.streaming import _save_streaming_checkpoint + import api.config as config + import api.profiles as profiles + + profile_home = tmp_path / "profiles" / "maiko" + profile_home.mkdir(parents=True) + captured = {} + + monkeypatch.setattr(profiles, "get_hermes_home_for_profile", lambda profile: profile_home) + monkeypatch.setattr(profiles, "get_profile_runtime_env", lambda home: {"HERMES_CONFIG_PATH": str(Path(home) / "config.yaml")}) + + def fake_save(self, *args, **kwargs): + captured["kwargs"] = kwargs + captured["thread_env"] = dict(getattr(config._thread_ctx, "env", {}) or {}) + + monkeypatch.setattr(Session, "save", fake_save) + + session = Session(session_id="issue2848", profile="maiko") + + _save_streaming_checkpoint(session) + + assert captured["kwargs"] == {"skip_index": True} + assert captured["thread_env"]["HERMES_HOME"] == str(profile_home) + assert captured["thread_env"]["HERMES_CONFIG_PATH"] == str(profile_home / "config.yaml")