diff --git a/api/routes.py b/api/routes.py index 01f4495c..11968653 100644 --- a/api/routes.py +++ b/api/routes.py @@ -235,6 +235,7 @@ except Exception: # Track job IDs currently being executed so the frontend can poll status. _RUNNING_CRON_JOBS: dict[str, float] = {} # job_id → start_timestamp _RUNNING_CRON_LOCK = threading.Lock() +_CRON_CREATE_SNAPSHOT_LOCK = threading.Lock() _MANUAL_COMPRESSION_JOBS: dict[str, dict] = {} _MANUAL_COMPRESSION_JOBS_LOCK = threading.Lock() _MANUAL_COMPRESSION_JOB_TTL_SECONDS = 10 * 60 @@ -18256,17 +18257,18 @@ def _selected_profile_snapshot_updates( return {} try: - with profile_env_for_background_worker( - selected_profile, - "cron create snapshot", - logger_override=logger, - ): - provider_snapshot, model_snapshot = _compute_provider_model_snapshots( - provider=provider, - model=model, - base_url=None, - no_agent=False, - ) + with _CRON_CREATE_SNAPSHOT_LOCK: + with profile_env_for_background_worker( + selected_profile, + "cron create snapshot", + logger_override=logger, + ): + provider_snapshot, model_snapshot = _compute_provider_model_snapshots( + provider=provider, + model=model, + base_url=None, + no_agent=False, + ) except Exception: logger.warning( "Selected-profile cron snapshot repair failed for %s; saving ambient snapshots", diff --git a/tests/test_issue5130_cron_profile_snapshot.py b/tests/test_issue5130_cron_profile_snapshot.py index f4c9edd1..91667d27 100644 --- a/tests/test_issue5130_cron_profile_snapshot.py +++ b/tests/test_issue5130_cron_profile_snapshot.py @@ -451,3 +451,49 @@ def test_selected_profile_snapshot_helper_never_repoints_cron_store_globals(monk original_paths[2], ), ] + + +def test_selected_profile_snapshot_helper_holds_lock_across_profile_env_and_compute(monkeypatch): + import api.profiles as profiles + import api.routes as routes + + events = [] + cron_jobs = types.ModuleType("cron.jobs") + + class RecorderLock: + def __enter__(self): + events.append("lock-enter") + + def __exit__(self, exc_type, exc, tb): + events.append("lock-exit") + + def compute_snapshots(*, provider, model, base_url, no_agent): + events.append(("compute", provider, model, base_url, no_agent)) + return "openai-codex", "gpt-5.4" + + @contextmanager + def fake_profile_env(profile, purpose, logger_override=None): + events.append(("profile-enter", profile, purpose, logger_override is routes.logger)) + yield + events.append(("profile-exit", profile, purpose)) + + cron_jobs._compute_provider_model_snapshots = compute_snapshots + + monkeypatch.setattr(routes, "_CRON_CREATE_SNAPSHOT_LOCK", RecorderLock()) + monkeypatch.setattr(profiles, "profile_env_for_background_worker", fake_profile_env) + _install_fake_cron_modules(monkeypatch, cron_jobs) + + updates = routes._selected_profile_snapshot_updates( + "research", + provider=None, + model="gpt-5.4", + ) + + assert updates == {"provider_snapshot": "openai-codex"} + assert events == [ + "lock-enter", + ("profile-enter", "research", "cron create snapshot", True), + ("compute", None, "gpt-5.4", None, False), + ("profile-exit", "research", "cron create snapshot"), + "lock-exit", + ]