diff --git a/docs/superpowers/plans/2026-05-16-hot-reloader-implementation-plan.md b/docs/superpowers/plans/2026-05-16-hot-reloader-implementation-plan.md new file mode 100644 index 00000000..c84b68c0 --- /dev/null +++ b/docs/superpowers/plans/2026-05-16-hot-reloader-implementation-plan.md @@ -0,0 +1,623 @@ +# Hot Reloader Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement selective, state-preserving hot-reload for `src/gui_2.py` with delegation pattern refactor, manual trigger via `Ctrl+Alt+R` and GUI button, and visual error tint feedback on failure. + +**Architecture:** Separating code into **delegators** (thin `App` wrappers in `gui_2.py`) and **delegation targets** (actual render logic as module-level functions). State preservation via `copy.deepcopy` capture/restore around `importlib.reload`. Error tint overlay on reload failure using ImGui draw list. + +**Tech Stack:** Python `importlib.reload`, `copy.deepcopy`, Dear PyGui / ImGui draw lists, `traceback` + +--- + +## File Structure + +| File | Responsibility | +|------|----------------| +| `src/hot_reloader.py` | New — `HotModule` dataclass and `HotReloader` class with registry, capture/restore, reload API | +| `src/gui_2.py` | Refactor render methods from `App._render_xxx` to module-level `render_xxx(app)` | +| `src/app_controller.py` | Delegation wrappers — `App._render_xxx` becomes `import src.gui_2 as g2; g2.render_xxx(self)` | +| `tests/test_hot_reloader.py` | Unit tests for `HotModule`, `HotReloader` registry and reload | +| `tests/test_hot_reload_integration.py` | Integration tests via `live_gui` fixture | + +--- + +## Task 1: Create `src/hot_reloader.py` — HotModule Dataclass and HotReloader Core + +**Files:** +- Create: `src/hot_reloader.py` +- Test: `tests/test_hot_reloader.py` + +- [ ] **Step 1: Write failing test for HotModule dataclass** + +```python +# tests/test_hot_reloader.py +import pytest +from dataclasses import dataclass +from src.hot_reloader import HotModule, HotReloader + +def test_hot_module_dataclass_fields(): + hm = HotModule( + name="test_module", + file_path="/path/to/test_module.py", + state_keys=["attr1", "attr2"], + delegation_targets=["method_a", "method_b"], + ) + assert hm.name == "test_module" + assert hm.file_path == "/path/to/test_module.py" + assert hm.state_keys == ["attr1", "attr2"] + assert hm.delegation_targets == ["method_a", "method_b"] + +def test_hot_reloader_register_and_get(): + HotReloader.HOT_MODULES.clear() + hm = HotModule(name="test_mod", file_path="/fake/path.py", state_keys=[], delegation_targets=[]) + HotReloader.register(hm) + assert "test_mod" in HotReloader.HOT_MODULES + assert HotReloader.HOT_MODULES["test_mod"] is hm + +def test_hot_reloader_register_duplicate_raises(): + HotReloader.HOT_MODULES.clear() + hm1 = HotModule(name="dup", file_path="/a.py", state_keys=[], delegation_targets=[]) + HotReloader.register(hm1) + with pytest.raises(ValueError, match="already registered"): + HotReloader.register(hm1) + +def test_hot_reloader_is_error_state(): + HotReloader.HOT_MODULES.clear() + HotReloader.last_error = None + HotReloader.is_error_state = False + assert HotReloader.is_error_state is False +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_hot_reloader.py -v` +Expected: FAIL — `src/hot_reloader.py` does not exist + +- [ ] **Step 3: Write minimal HotModule dataclass and HotReloader skeleton** + +```python +# src/hot_reloader.py +from __future__ import annotations +from dataclasses import dataclass, field +from typing import Any +import copy +import traceback + +@dataclass +class HotModule: + name: str + file_path: str + state_keys: list[str] = field(default_factory=list) + delegation_targets: list[str] = field(default_factory=list) + +class HotReloader: + HOT_MODULES: dict[str, HotModule] = {} + last_error: str | None = None + is_error_state: bool = False + + @classmethod + def register(cls, module: HotModule) -> None: + if module.name in cls.HOT_MODULES: + raise ValueError(f"Module {module.name} already registered") + cls.HOT_MODULES[module.name] = module + + @classmethod + def capture_state(cls, app: Any, state_keys: list[str]) -> dict[str, Any]: + return {key: copy.deepcopy(getattr(app, key, None)) for key in state_keys if hasattr(app, key)} + + @classmethod + def restore_state(cls, app: Any, state: dict[str, Any]) -> None: + for key, value in state.items(): + setattr(app, key, value) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/test_hot_reloader.py -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/hot_reloader.py tests/test_hot_reloader.py +git commit -m "feat(hot-reload): Add HotModule dataclass and HotReloader registry" +``` + +--- + +## Task 2: Implement `HotReloader.reload()` and `HotReloader.reload_all()` + +**Files:** +- Modify: `src/hot_reloader.py` +- Test: `tests/test_hot_reloader.py` + +- [ ] **Step 1: Write failing test for reload method** + +```python +# tests/test_hot_reloader.py (add these tests) +import importlib +from unittest.mock import MagicMock, patch + +def test_reload_unknown_module_returns_false(): + HotReloader.HOT_MODULES.clear() + HotReloader.register(HotModule(name="nonexistent_mod", file_path="/nonexistent.py", state_keys=[], delegation_targets=[])) + app = MagicMock() + result = HotReloader.reload("nonexistent_mod", app) + assert result is False + assert HotReloader.is_error_state is True + assert HotReloader.last_error is not None + +def test_reload_success_clears_error_state(): + HotReloader.HOT_MODULES.clear() + # Create a minimal test module that can be reloaded + import sys + import types + test_mod = types.ModuleType("src._test_reload_mod") + sys.modules["src._test_reload_mod"] = test_mod + HotReloader.register(HotModule(name="src._test_reload_mod", file_path=str(Path(__file__).parent.parent / "src" / "_test_reload_mod.py"), state_keys=[], delegation_targets=[])) + app = MagicMock() + result = HotReloader.reload("src._test_reload_mod", app) + assert result is True + assert HotReloader.is_error_state is False + assert HotReloader.last_error is None + del sys.modules["src._test_reload_mod"] + +def test_reload_captures_and_restores_state_on_failure(): + HotReloader.HOT_MODULES.clear() + HotReloader.register(HotModule(name="bad_mod", file_path="/bad.py", state_keys=["_test_attr"], delegation_targets=[])) + app = MagicMock() + app._test_attr = "preserved_value" + result = HotReloader.reload("bad_mod", app) + assert result is False + assert HotReloader.is_error_state is True + # State should be restored even on failure + +def test_reload_all(): + HotReloader.HOT_MODULES.clear() + # Register two modules and verify reload_all calls both +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_hot_reloader.py -v` +Expected: FAIL — `reload` method not implemented + +- [ ] **Step 3: Write `reload()` and `reload_all()` implementations** + +```python +# src/hot_reloader.py (add after existing methods) +@classmethod +def reload(cls, module_name: str, app: Any) -> bool: + if module_name not in cls.HOT_MODULES: + cls.last_error = f"Module {module_name} not registered" + cls.is_error_state = True + return False + + hm = cls.HOT_MODULES[module_name] + state = cls.capture_state(app, hm.state_keys) + + try: + import importlib + import sys + if module_name in sys.modules: + old_module = sys.modules[module_name] + importlib.reload(old_module) + else: + importlib.import_module(module_name) + cls.last_error = None + cls.is_error_state = False + return True + except Exception: + cls.restore_state(app, state) + cls.last_error = traceback.format_exc() + cls.is_error_state = True + return False + +@classmethod +def reload_all(cls, app: Any) -> bool: + success = True + for name in cls.HOT_MODULES: + if not cls.reload(name, app): + success = False + return success +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/test_hot_reloader.py -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/hot_reloader.py tests/test_hot_reloader.py +git commit -m "feat(hot-reload): Implement HotReloader.reload and reload_all" +``` + +--- + +## Task 3: Register `src.gui_2` with state_keys and delegation_targets + +**Files:** +- Modify: `src/app_controller.py` — add `HotModule` registration +- Test: `tests/test_hot_reloader.py` + +- [ ] **Step 1: Write test for src.gui_2 registration** + +```python +def test_gui_2_module_registered(): + from src.hot_reloader import HotReloader + assert "src.gui_2" in HotReloader.HOT_MODULES + hm = HotReloader.HOT_MODULES["src.gui_2"] + assert len(hm.state_keys) > 0 + assert len(hm.delegation_targets) > 0 + assert "_render_main_interface" in hm.delegation_targets +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_hot_reloader.py::test_gui_2_module_registered -v` +Expected: FAIL — registration not yet added + +- [ ] **Step 3: Add src.gui_2 HotModule registration in app_controller.py** + +Find the end of `AppController.__init__` or add a `_register_hot_modules()` function called at end of `__init__`: + +```python +# In src/app_controller.py, near end of AppController.__init__ or as standalone: +from src.hot_reloader import HotReloader, HotModule +from pathlib import Path + +def _register_hot_modules() -> None: + HotReloader.register(HotModule( + name="src.gui_2", + file_path=str(Path(__file__).parent / "gui_2.py"), + state_keys=[ + "_active_discussion", "_disc_entries", "_disc_roles", + "show_windows", "ui_discussion_split_h", "active_tickets", + "show_preset_manager_window", "show_tool_preset_manager_window", + "show_persona_editor_window", "_pending_patch", + "_tool_log", "_comms_log", + ], + delegation_targets=[ + "_render_main_interface", "_render_discussion_hub", + "_render_discussion_panel", "_render_discussion_selector", + "_render_discussion_entries", "_render_discussion_entry", + "_render_files_and_media", "_render_files_panel", + "_render_screenshots_panel", "_render_context_composition_panel", + "_render_ai_settings_hub", "_render_provider_panel", + "_render_persona_selector_panel", "_render_agent_tools_panel", + "_render_rag_panel", "_render_system_prompts_panel", + "_render_preset_manager_content", "_render_persona_editor_window", + "_render_tool_preset_manager_content", + "_render_operations_hub", "_render_tool_calls_panel", + "_render_comms_history_panel", "_render_mma_dashboard", + "_render_mma_modals", "_render_ticket_queue", + "_render_task_dag_panel", "_render_approve_script_modal", + "_render_patch_modal", + ], + )) +``` + +Call `_register_hot_modules()` at the end of `AppController.__init__` after all state is initialized. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/test_hot_reloader.py::test_gui_2_module_registered -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/app_controller.py +git commit -m "feat(hot-reload): Register src.gui_2 with state_keys and delegation_targets" +``` + +--- + +## Task 4: Phase 1 — Refactor gui_2 render methods to module-level functions + +**Files:** +- Modify: `src/gui_2.py` — extract `App._render_xxx` to `render_xxx(app)` +- Modify: `src/app_controller.py` — delegation wrappers +- Test: `tests/test_hot_reloader.py` + +**Refactor Pattern** (for each render method): + +```python +# BEFORE (in App class): +def _render_main_interface(self) -> None: + if self.perf_profiling_enabled: + self.perf_monitor.start_component("_render_main_interface") + with imscope.window("Main Interface", self.show_windows.get("main_interface", True)) as (exp, opened): + if not exp: return + # ... all render logic + +# AFTER — App method becomes thin delegation wrapper: +def _render_main_interface(self) -> None: + import src.gui_2 as g2 + g2.render_main_interface(self) + +# NEW module-level function: +def render_main_interface(app: App) -> None: + if app.perf_profiling_enabled: + app.perf_monitor.start_component("_render_main_interface") + with imscope.window("Main Interface", app.show_windows.get("main_interface", True)) as (exp, opened): + if not exp: return + # ... same render logic, replacing self with app +``` + +**Methods to refactor** (in order): +1. `_render_main_interface` (lines 769-857) +2. `_render_discussion_hub` (lines 3235-3245) +3. `_render_discussion_panel` (lines 3395-3415) +4. `_render_discussion_selector` (lines 3430-3473) +5. `_render_discussion_entries` (lines 3247-3259) +6. `_render_discussion_entry` (lines 3261-3304) +7. `_render_files_and_media` (lines 2529-2636) +8. `_render_files_panel` (lines 2638-2716) +9. `_render_screenshots_panel` (lines 2718-2750) +10. `_render_context_composition_panel` (lines 2752-2765) +11. `_render_ai_settings_hub` (lines 1754-1759) +12. `_render_provider_panel` (lines 2377-2441) +13. `_render_persona_selector_panel` (lines 2443-2523) +14. `_render_agent_tools_panel` (lines 1850-1913) +15. `_render_rag_panel` (lines 1761-1796) +16. `_render_system_prompts_panel` (lines 1798-1848) +17. `_render_preset_manager_content` (lines 1915-2002) +18. `_render_persona_editor_window` (lines 2205-2375) +19. `_render_tool_preset_manager_content` (lines 2014-2193) +20. `_render_operations_hub` (lines 3750-3788) +21. `_render_tool_calls_panel` (lines 3790-3853) +22. `_render_comms_history_panel` (lines 3855-3971) +23. `_render_mma_dashboard` (lines 4664-4695) +24. `_render_mma_modals` (lines 4711-4785) +25. `_render_ticket_queue` (lines 4365-4478) +26. `_render_task_dag_panel` (lines 4480-4621) +27. `_render_approve_script_modal` (lines 4269-4310) +28. `_render_patch_modal` (lines 4135-4177) + +- [ ] **Step 1: Write test for module-level function pattern** + +```python +def test_gui2_has_module_level_render_functions(): + import src.gui_2 as g2 + # After refactor, these should exist as module-level functions + assert hasattr(g2, 'render_main_interface') + assert hasattr(g2, 'render_discussion_hub') + assert hasattr(g2, 'render_discussion_panel') + # ... etc + # Each should accept app: App as first parameter + import inspect + sig = inspect.signature(g2.render_main_interface) + assert 'app' in sig.parameters +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_hot_reloader.py::test_gui2_has_module_level_render_functions -v` +Expected: FAIL — module-level functions don't exist yet + +- [ ] **Step 3: Refactor `_render_main_interface` first as pilot** + +Extract the method body, replace `self` with `app`, create module-level `render_main_interface(app: App) -> None`, and convert `App._render_main_interface` to delegation wrapper. + +- [ ] **Step 4: Run test after each method refactor** + +Run: `uv run pytest tests/test_hot_reloader.py::test_gui2_has_module_level_render_functions -v` +Continue until all module-level functions exist. + +- [ ] **Step 5: Commit after completing all methods** + +```bash +git add src/gui_2.py src/app_controller.py +git commit -m "refactor(hot-reload): Extract gui_2 render methods to module-level functions" +``` + +--- + +## Task 5: Add `Ctrl+Alt+R` trigger and GUI Hot Reload button + +**Files:** +- Modify: `src/gui_2.py:App.run` — capture keyboard shortcut +- Modify: `src/gui_2.py:_render_mma_dashboard` — add Hot Reload button +- Modify: `src/gui_2.py` — add `_trigger_hot_reload` and `_hot_reload_error` state +- Test: `tests/test_hot_reload_integration.py` + +- [ ] **Step 1: Write test for hot reload trigger** + +```python +def test_ctrl_alt_r_triggers_hot_reload(live_gui): + client = ApiHookClient(port=8999) + # Verify initial state + initial_state = client.get_value("_hot_reload_error") + # Note: Direct keyboard simulation via hooks not yet implemented + # Test will verify button-based trigger +``` + +- [ ] **Step 2: Write test for Hot Reload button in MMA dashboard** + +```python +def test_hot_reload_button_in_mma_dashboard(live_gui): + client = ApiHookClient(port=8999) + # Verify _render_mma_dashboard has a Hot Reload button + # via exposed component state or via behavior +``` + +- [ ] **Step 3: Add `_hot_reload_error` state to App.__init__** + +In `src/gui_2.py:App.__init__`, add: + +```python +self._hot_reload_error = False +``` + +- [ ] **Step 4: Add `_trigger_hot_reload()` method to App** + +```python +def _trigger_hot_reload(self) -> None: + from src.hot_reloader import HotReloader + success = HotReloader.reload_all(self) + self._hot_reload_error = not success + +def _render_hot_reload_button(self) -> None: + imgui.push_style_color(imgui.Col_.button, imgui.get_color_u32_rgba(0.2, 0.2, 0.2, 1.0)) + if imgui.button("Hot Reload"): + self._trigger_hot_reload() + imgui.pop_style_color() + if self._hot_reload_error: + imgui.same_line() + imgui.text_colored(imgui.get_color_u32_rgba(1.0, 0.3, 0.2, 1.0), "Reload failed") +``` + +- [ ] **Step 5: Add keyboard capture in `App.run` main loop** + +In `src/gui_2.py:App.run`, inside the main `while running:` loop: + +```python +# Check for Ctrl+Alt+R +io = imgui.get_io() +if io.key_ctrl and io.key_alt and imgui.is_key_down(imgui.KEY_R): + self._trigger_hot_reload() +``` + +- [ ] **Step 6: Integrate button into `_render_mma_global_controls` or `_render_mma_dashboard`** + +Add `self._render_hot_reload_button()` call in `_render_mma_dashboard` or a nearby section. + +- [ ] **Step 7: Run tests** + +Run: `uv run pytest tests/test_hot_reload_integration.py -v --timeout=60` +Expected: PASS + +- [ ] **Step 8: Commit** + +```bash +git add src/gui_2.py src/app_controller.py +git commit -m "feat(hot-reload): Add Ctrl+Alt+R trigger and GUI Hot Reload button" +``` + +--- + +## Task 6: Implement visual error tint on reload failure + +**Files:** +- Modify: `src/gui_2.py` — add `_render_error_tint()` and call from `render_main_interface` +- Test: `tests/test_hot_reload_integration.py` + +- [ ] **Step 1: Write test for error tint overlay** + +```python +def test_error_tint_renders_on_failure(live_gui): + # Inject a failure state and verify tint appears + pass +``` + +- [ ] **Step 2: Add `_render_error_tint(app: App)` function** + +```python +def _render_error_tint(app: App) -> None: + if not getattr(app, '_hot_reload_error', False): + return + # NERV theme error tint + tint_color = imgui.get_color_u32_rgba(1.0, 0.28, 0.25, 0.15) + draw_list = imgui.get_background_draw_list() + viewport = imgui.get_main_viewport() + draw_list.add_rect_filled( + viewport.pos, + viewport.pos + viewport.size, + tint_color, + ) +``` + +- [ ] **Step 3: Call `_render_error_tint(app)` at end of `render_main_interface`** + +Add at the very start of `render_main_interface` since it's an overlay: + +```python +def render_main_interface(app: App) -> None: + _render_error_tint(app) + if app.perf_profiling_enabled: + # ... +``` + +- [ ] **Step 4: Run test** + +Run: `uv run pytest tests/test_hot_reload_integration.py -v --timeout=60` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/gui_2.py +git commit -m "feat(hot-reload): Add visual error tint on reload failure" +``` + +--- + +## Task 7: End-to-End Integration Test + +**Files:** +- Create: `tests/test_hot_reload_integration.py` +- Test: `tests/test_hot_reload_integration.py` + +- [ ] **Step 1: Write e2e tests using live_gui fixture** + +```python +import pytest +from api_hook_client import ApiHookClient +from simulation.sim_base import BaseSimulation, run_sim + +class HotReloadSimulation(BaseSimulation): + def run(self) -> None: + # 1. Verify hot reload button is accessible + # 2. Trigger hot reload via API hook + # 3. Verify GUI still renders after reload + # 4. Verify error tint clears on success + pass + +def test_hot_reload_e2e(live_gui): + client = ApiHookClient(port=8999) + # Trigger hot reload + response = client.post("/api/gui", {"action": "hot_reload"}) + # Verify GUI still responds + state = client.get("/api/gui_state") + assert state is not None + +def test_hot_reload_preserves_state(live_gui): + client = ApiHookClient(port=8999) + # Set some state + # Trigger reload + # Verify state preserved +``` + +- [ ] **Step 2: Run e2e tests** + +Run: `uv run pytest tests/test_hot_reload_integration.py -v --timeout=120` +Expected: PASS + +- [ ] **Step 3: Commit** + +```bash +git add tests/test_hot_reload_integration.py +git commit -m "test(hot-reload): Add e2e integration test" +``` + +--- + +## Success Criteria Verification + +1. Pressing `Ctrl+Alt+R` reloads `src.gui_2` without losing App state — tested via manual trigger + state verification +2. GUI "Hot Reload" button triggers same reload — tested via button click in live_gui +3. Failed reload shows error tint, preserves last-good state — tested via mock failure injection +4. New hot modules can be added via `HotReloader.register()` without modifying core — tested in unit tests +5. No performance impact when not reloading (< 1ms overhead per frame) — verified via perf profiling + +--- + +## Out of Scope (Per Spec) + +- Automatic file watching (manual trigger only) +- Hot-reloading of C extensions or native code +- Cross-platform reload support (Windows focus) \ No newline at end of file