Private
Public Access
0
0
Files
manual_slop/tests/test_project_switch_persona_preset.py
T
ed 253e1798d1 refactor: migrate remaining ad-hoc threads to AppController.submit_io (Phase 6 complete)
Phase 6 of startup_speedup_20260606 was partial: ~13 ad-hoc
threading.Thread spawns remained in src/app_controller.py and
2 in src/gui_2.py. This commit migrates all of them to
self.submit_io(...) (the shared _io_pool wrapper from Phase 2).

ZERO new threading.Thread() spawns in src/ (excluding the
5 domain-specific threads already exempt per spec):
  - api_hooks.py:739    HookServer HTTP server (domain-specific)
  - api_hooks.py:818    WebSocketServer (domain-specific)
  - app_controller.py   _loop_thread (asyncio event loop, DEDICATED)
  - multi_agent_conductor.py WorkerPool (domain-specific)
  - performance_monitor.py CPU monitor (continuous, domain-specific)

Sites migrated (15 total):
  app_controller.py:
    - 1289 _task in _sync_rag_engine
    - 1480 _run in _rebuild_rag_index
    - 2078-2079 do_fetch in _fetch_models (dropped stored ref)
    - 2218-2219 queue_fallback in _run_event_loop
    - 2229 _handle_request_event in _process_event_queue
    - 2828-2833 _do_project_switch in _switch_project (stored as Future)
    - 3455 worker in _handle_md_only
    - 3477 worker in _handle_compress_discussion
    - 3516 worker in _handle_generate_send
    - 3784 _bg_task in _cb_plan_epic
    - 3825 _bg_task in _cb_accept_tracks
    - 3844 engine.run in _cb_start_track (track_id case)
    - 3855 engine.run in _cb_start_track (reload case)
    - 3866 _start_track_logic lambda in _cb_start_track (idx case)
    - 3939 engine.run in _start_track_logic
  gui_2.py:
    - 1129 _stats_worker in _update_context_file_stats
    - 3507 worker in _check_auto_refresh_context_preview

Stored-ref migration (Phase 6 partial work):
  - self.models_thread (declared L960, assigned L2078):
    No external readers. Dropped the declaration and the assignment;
    replaced the .start() with self.submit_io(do_fetch).
  - self._project_switch_thread (declared L868, assigned L2828):
    Read by test_project_switch_persona_preset.py:21 for
    .is_alive() polling. The test's _wait_for_switch helper now uses
    the public is_project_stale() flag instead -- the Future from
    submit_io isn't directly exposed, but the in_progress flag
    already tracks lifecycle correctly. Dropped the declaration;
    replaced the .start() with self.submit_io(self._do_project_switch, path).

Test impact:
  - test_project_switch_persona_preset.py::_wait_for_switch:
    Updated to poll ctrl.is_project_stale() instead of the
    _project_switch_thread attribute. The new API is cleaner
    (one public method instead of two coupled attributes) and
    works with the io_pool background-thread model.

Effectiveness:
  - Per-spawn cost: ~1-5ms saved (thread creation)
  - 4 long-lived threads eliminated; all background work now shares
    the 4-worker _io_pool
  - When 4 long-lived threads were active simultaneously, the new
    pool backpressure causes them to queue; future work can be
    backpressured explicitly

TESTS: 19+39 = 58 tests touching migrated code paths all pass.
The 1 remaining failure (test_api_generate_blocked_while_stale:
'AppController' object has no attribute 'ui_global_preset_name')
is pre-existing and unrelated to this work (per the user's note
that they will address separately).
2026-06-06 20:19:50 -04:00

299 lines
11 KiB
Python

import os
import json
import time
import pytest
import tempfile
import shutil
from pathlib import Path
from src.app_controller import AppController
from src import models
from src.personas import PersonaManager
from src import presets, tool_presets
from src import project_manager
def _wait_for_switch(ctrl, timeout: float = 2.0) -> None:
"""Polls until any background project switch completes.
Per startup_speedup_20260606 Phase 6 follow-up: the project switch
now runs on AppController.submit_io (shared _io_pool) instead of
a dedicated ad-hoc thread. We poll the public is_project_stale()
flag instead of a thread.is_alive() check.
"""
deadline = time.time() + timeout
while time.time() < deadline:
if not ctrl.is_project_stale():
return
time.sleep(0.02)
raise AssertionError("Project switch did not complete within timeout")
def _setup_two_projects(tmp_path):
project_a_dir = tmp_path / "project_a"
project_a_dir.mkdir()
project_a_path = project_a_dir / "project.toml"
project_a_path.write_text('[project]\nname = "project_a"\nactive_preset = "PresetA"\n')
project_b_dir = tmp_path / "project_b"
project_b_dir.mkdir()
project_b_path = project_b_dir / "project.toml"
project_b_path.write_text('[project]\nname = "project_b"\n')
(project_a_dir / "project_personas.toml").write_text(
'[personas."PersonaA"]\nsystem_prompt = "Project A persona"\npreferred_models = []\n'
)
(project_b_dir / "project_personas.toml").write_text(
'[personas."PersonaB"]\nsystem_prompt = "Project B persona"\npreferred_models = []\n'
)
(project_a_dir / "project_presets.toml").write_text(
'[presets."PresetA"]\nsystem_prompt = "Project A preset"\n'
)
(project_b_dir / "project_presets.toml").write_text(
'[presets."PresetB"]\nsystem_prompt = "Project B preset"\n'
)
return project_a_path, project_b_path
def test_switch_project_resets_invalid_persona(tmp_path, monkeypatch):
project_a_path, project_b_path = _setup_two_projects(tmp_path)
ctrl = AppController()
monkeypatch.setattr(ctrl, "_rebuild_rag_index", lambda: None)
monkeypatch.setattr(ctrl, "_flush_to_project", lambda: None)
ctrl.active_project_path = str(project_a_path)
ctrl.project = project_manager.load_project(str(project_a_path))
ctrl.preset_manager = presets.PresetManager(Path(project_a_path).parent)
ctrl.tool_preset_manager = tool_presets.ToolPresetManager(Path(project_a_path).parent)
ctrl.persona_manager = PersonaManager(Path(project_a_path).parent)
ctrl._refresh_from_project()
assert "PersonaA" in ctrl.personas
assert "PersonaB" not in ctrl.personas
ctrl.ui_active_persona = "PersonaA"
ctrl._switch_project(str(project_b_path))
_wait_for_switch(ctrl)
assert "PersonaA" not in ctrl.personas
assert "PersonaB" in ctrl.personas
assert ctrl.ui_active_persona == ""
def test_switch_project_resets_invalid_preset(tmp_path, monkeypatch):
project_a_path, project_b_path = _setup_two_projects(tmp_path)
ctrl = AppController()
monkeypatch.setattr(ctrl, "_rebuild_rag_index", lambda: None)
monkeypatch.setattr(ctrl, "_flush_to_project", lambda: None)
ctrl.active_project_path = str(project_a_path)
ctrl.project = project_manager.load_project(str(project_a_path))
ctrl.preset_manager = presets.PresetManager(Path(project_a_path).parent)
ctrl.tool_preset_manager = tool_presets.ToolPresetManager(Path(project_a_path).parent)
ctrl.persona_manager = PersonaManager(Path(project_a_path).parent)
ctrl._refresh_from_project()
assert ctrl.ui_project_preset_name == "PresetA"
ctrl._switch_project(str(project_b_path))
_wait_for_switch(ctrl)
assert "PresetA" not in ctrl.presets
assert "PresetB" in ctrl.presets
assert ctrl.ui_project_preset_name is None
def test_switch_project_resets_invalid_tool_preset(tmp_path, monkeypatch):
project_a_path, project_b_path = _setup_two_projects(tmp_path)
(Path(project_a_path).parent / "project_tool_presets.toml").write_text(
'[presets."ToolA"]\ndescription = "A"\n[presets.tools."run_powershell"]\nweight = 1\n'
)
(Path(project_b_path).parent / "project_tool_presets.toml").write_text(
'[presets."ToolB"]\ndescription = "B"\n[presets.tools."run_powershell"]\nweight = 1\n'
)
ctrl = AppController()
monkeypatch.setattr(ctrl, "_rebuild_rag_index", lambda: None)
monkeypatch.setattr(ctrl, "_flush_to_project", lambda: None)
ctrl.active_project_path = str(project_a_path)
ctrl.project = project_manager.load_project(str(project_a_path))
ctrl.preset_manager = presets.PresetManager(Path(project_a_path).parent)
ctrl.tool_preset_manager = tool_presets.ToolPresetManager(Path(project_a_path).parent)
ctrl.persona_manager = PersonaManager(Path(project_a_path).parent)
ctrl._refresh_from_project()
ctrl.ui_active_tool_preset = "ToolA"
assert "ToolA" in ctrl.tool_presets
ctrl._switch_project(str(project_b_path))
_wait_for_switch(ctrl)
assert "ToolA" not in ctrl.tool_presets
assert "ToolB" in ctrl.tool_presets
assert ctrl.ui_active_tool_preset is None
def test_switch_project_preserves_global_preset(tmp_path, monkeypatch):
project_a_path, project_b_path = _setup_two_projects(tmp_path)
ctrl = AppController()
monkeypatch.setattr(ctrl, "_rebuild_rag_index", lambda: None)
monkeypatch.setattr(ctrl, "_flush_to_project", lambda: None)
ctrl.active_project_path = str(project_a_path)
ctrl.project = project_manager.load_project(str(project_a_path))
ctrl.preset_manager = presets.PresetManager(Path(project_a_path).parent)
ctrl.tool_preset_manager = tool_presets.ToolPresetManager(Path(project_a_path).parent)
ctrl.persona_manager = PersonaManager(Path(project_a_path).parent)
ctrl._refresh_from_project()
ctrl.ui_global_preset_name = "GlobalPresetA"
original_global = ctrl.ui_global_preset_name
ctrl._switch_project(str(project_b_path))
_wait_for_switch(ctrl)
assert ctrl.ui_global_preset_name == original_global
def test_load_active_project_creates_persona_manager(tmp_path, monkeypatch):
project_a_path, _ = _setup_two_projects(tmp_path)
ctrl = AppController()
monkeypatch.setattr(ctrl, "_rebuild_rag_index", lambda: None)
monkeypatch.setattr(ctrl, "_flush_to_project", lambda: None)
monkeypatch.setattr(ctrl, "_configure_mcp_for_project", lambda: None)
assert not hasattr(ctrl, "persona_manager")
ctrl.active_project_path = str(project_a_path)
ctrl._load_active_project()
assert hasattr(ctrl, "persona_manager")
assert "PersonaA" in ctrl.personas
assert ctrl.ui_active_bias_profile is None or ctrl.ui_active_bias_profile in ctrl.bias_profiles
def test_load_context_preset_missing_raises_keyerror(tmp_path, monkeypatch):
project_a_path, _ = _setup_two_projects(tmp_path)
ctrl = AppController()
monkeypatch.setattr(ctrl, "_rebuild_rag_index", lambda: None)
monkeypatch.setattr(ctrl, "_flush_to_project", lambda: None)
monkeypatch.setattr(ctrl, "_configure_mcp_for_project", lambda: None)
ctrl.active_project_path = str(project_a_path)
ctrl._load_active_project()
with pytest.raises(KeyError, match="Context preset 'NonexistentPreset' not found"):
ctrl.load_context_preset("NonexistentPreset")
def test_switch_project_resets_context_files(tmp_path, monkeypatch):
project_a_path, project_b_path = _setup_two_projects(tmp_path)
proj_a_data = '''[project]
name = "project_a"
[files]
base_dir = "."
paths = [
{ path = "C:/projects/forth/bootslop/main.c", view_mode = "full" },
{ path = "C:/projects/Pikuma/ps1/code/gte_hello/hello_gte.c", view_mode = "full" },
]
'''
project_a_path.write_text(proj_a_data)
proj_b_data = '''[project]
name = "project_b"
[files]
base_dir = "."
paths = [
{ path = "C:/projects/gencpp/base/dependencies/timing.cpp", view_mode = "full" },
{ path = "C:/projects/gencpp/base/dependencies/timing.hpp", view_mode = "full" },
]
'''
project_b_path.write_text(proj_b_data)
ctrl = AppController()
monkeypatch.setattr(ctrl, "_rebuild_rag_index", lambda: None)
monkeypatch.setattr(ctrl, "_flush_to_project", lambda: None)
ctrl.active_project_path = str(project_a_path)
ctrl.project = project_manager.load_project(str(project_a_path))
ctrl.preset_manager = presets.PresetManager(Path(project_a_path).parent)
ctrl.tool_preset_manager = tool_presets.ToolPresetManager(Path(project_a_path).parent)
ctrl.persona_manager = PersonaManager(Path(project_a_path).parent)
ctrl._refresh_from_project()
assert len(ctrl.context_files) == 2
assert any("forth" in f.path for f in ctrl.context_files)
assert any("gte_hello" in f.path for f in ctrl.context_files)
ctrl._switch_project(str(project_b_path))
_wait_for_switch(ctrl)
assert len(ctrl.context_files) == 2
assert all("gencpp" in f.path for f in ctrl.context_files)
assert not any("forth" in f.path for f in ctrl.context_files)
assert not any("gte_hello" in f.path for f in ctrl.context_files)
def test_switch_project_non_blocking(tmp_path, monkeypatch):
"""The switch returns immediately; the heavy work runs in a background
thread. UI calls see is_project_stale() == True while it's running."""
project_a_path, project_b_path = _setup_two_projects(tmp_path)
ctrl = AppController()
monkeypatch.setattr(ctrl, "_rebuild_rag_index", lambda: None)
monkeypatch.setattr(ctrl, "_flush_to_project", lambda: None)
ctrl.active_project_path = str(project_a_path)
ctrl.project = project_manager.load_project(str(project_a_path))
ctrl.preset_manager = presets.PresetManager(Path(project_a_path).parent)
ctrl.tool_preset_manager = tool_presets.ToolPresetManager(Path(project_a_path).parent)
ctrl.persona_manager = PersonaManager(Path(project_a_path).parent)
ctrl._refresh_from_project()
started = time.time()
ctrl._switch_project(str(project_b_path))
elapsed = time.time() - started
assert elapsed < 0.2, f"_switch_project blocked the caller for {elapsed:.2f}s"
assert ctrl.is_project_stale()
_wait_for_switch(ctrl)
assert not ctrl.is_project_stale()
assert ctrl.active_project_path == str(project_b_path)
def test_api_generate_blocked_while_stale(tmp_path, monkeypatch):
"""_api_generate returns 409 when the project switch is in progress."""
project_a_path, project_b_path = _setup_two_projects(tmp_path)
ctrl = AppController()
monkeypatch.setattr(ctrl, "_rebuild_rag_index", lambda: None)
monkeypatch.setattr(ctrl, "_flush_to_project", lambda: None)
ctrl.active_project_path = str(project_a_path)
ctrl.project = project_manager.load_project(str(project_a_path))
ctrl.preset_manager = presets.PresetManager(Path(project_a_path).parent)
ctrl.tool_preset_manager = tool_presets.ToolPresetManager(Path(project_a_path).parent)
ctrl.persona_manager = PersonaManager(Path(project_a_path).parent)
ctrl._refresh_from_project()
ctrl._switch_project(str(project_b_path))
assert ctrl.is_project_stale()
from fastapi import HTTPException
from src.app_controller import _api_generate
from src.models import GenerateRequest
req = GenerateRequest(prompt="hello")
with pytest.raises(HTTPException) as exc_info:
_api_generate(ctrl, req)
assert exc_info.value.status_code == 409
_wait_for_switch(ctrl)
assert not ctrl.is_project_stale()