Private
Public Access
0
0

conductor(plan): flip track from lazy-loading to proactive warmup

Architectural shift driven by user clarification: lazy-loading on first
use causes user-perceptible lag when the user-triggered action (e.g.
provider switch) propagates to a controller method that triggers the
first import. The fix is to pre-import heavy modules on a bg thread
at startup and have functions access them via _require_warmed().

Old design (rejected):
  - from google import genai inside _send_gemini (lazy on first call)
  - First user action that triggers this pays the cost; UI feels laggy

New design (this commit):
  - Top-level heavy imports REMOVED from main-thread-reachable files
  - AppController.__init__ submits warmup jobs to _io_pool (4 threads,
    named 'controller-io-N')
  - Each warmup worker imports its module and updates a thread-safe
    warmup_status dict
  - Functions access modules via _require_warmed(name), which assumes
    the module is in sys.modules (warmed at startup)
  - When all jobs complete, _warmup_done_event is set and registered
    on_warmup_complete callbacks fire
  - GUI shows status indicator + toast when warmup completes
  - Hook API exposes /api/warmup_status and /api/warmup_wait
  - Tests can call controller.wait_for_warmup() before exercising
    warmup-dependent functionality

Phase 2 now bundles job pool + warmup (T2.3+T2.4 add warmup tests +
implementation). Phases 3-5 do 'remove top-level imports' instead of
'lazy-load'. Phase 7 is the notification surface (Hook API + GUI).
Definition of Done includes warmup-completion criteria, the
'no function-body imports' check, and an end-to-end 'provider switch
is INSTANT' smoke test.

No code changes; this is a planning update only.
This commit is contained in:
2026-06-06 13:45:05 -04:00
parent ca254bac41
commit f2f5ee1197
4 changed files with 597 additions and 269 deletions
@@ -11,12 +11,14 @@
"src/startup_profiler.py", "src/startup_profiler.py",
"scripts/audit_main_thread_imports.py", "scripts/audit_main_thread_imports.py",
"scripts/audit_gui2_imports.py", "scripts/audit_gui2_imports.py",
"tests/test_ai_client_lazy_imports.py", "tests/test_ai_client_no_top_level_sdk_imports.py",
"tests/test_hook_server_lazy_fastapi.py", "tests/test_hook_server_no_top_level_fastapi.py",
"tests/test_app_controller_io_pool.py", "tests/test_app_controller_io_pool.py",
"tests/test_command_palette_lazy.py", "tests/test_warmup_mechanism.py",
"tests/test_theme_nerv_lazy.py", "tests/test_command_palette_no_top_level_import.py",
"tests/test_markdown_helper_lazy.py", "tests/test_theme_nerv_no_top_level_import.py",
"tests/test_markdown_helper_no_top_level_import.py",
"tests/test_api_hooks_warmup.py",
"tests/test_main_thread_purity.py", "tests/test_main_thread_purity.py",
"tests/test_startup_profiler.py", "tests/test_startup_profiler.py",
"tests/test_io_pool_endpoint.py" "tests/test_io_pool_endpoint.py"
@@ -42,19 +44,26 @@
"estimated_phases": 9, "estimated_phases": 9,
"spec": "spec.md", "spec": "spec.md",
"plan": "plan.md", "plan": "plan.md",
"architectural_invariant": "The main thread (the one that enters immapp.run()) must NEVER import a module heavier than imgui_bundle and the lean gui_2 skeleton. Enforced by scripts/audit_main_thread_imports.py (static CI gate) and tests/test_main_thread_purity.py (runtime audit-hook test).", "architectural_invariant": "The main thread (the one that enters immapp.run()) must NEVER import a module heavier than imgui_bundle and the lean gui_2 skeleton. Heavy modules are removed from main-thread-reachable files entirely and accessed via _require_warmed(name) at use sites, which assumes the module is in sys.modules because AppController's warmup pre-loaded it on the _io_pool. Enforced by scripts/audit_main_thread_imports.py (static CI gate) and tests/test_main_thread_purity.py (runtime audit-hook test).",
"threading_constraint": "NO new threading.Thread(...) calls in src/. All background work must go through AppController._io_pool (ThreadPoolExecutor, max_workers=4, thread_name_prefix='controller-io').", "threading_constraint": "NO new threading.Thread(...) calls in src/. All background work must go through AppController._io_pool (ThreadPoolExecutor, max_workers=4, thread_name_prefix='controller-io'). The _io_pool is also the home of the heavy-module warmup jobs submitted in AppController.__init__.",
"warmup_mechanism": "AppController.__init__ submits one job per heavy module to _io_pool. Each job imports its module and updates a thread-safe warmup_status dict. When the last job completes, _warmup_done_event is set and registered on_warmup_complete callbacks fire. The GUI polls warmup_status() each frame for a status-bar indicator. /api/warmup_status and /api/warmup_wait expose the state to tests and external clients. The user is notified via a toast on completion: 'All providers ready (M modules).'",
"verification_criteria": [ "verification_criteria": [
"import src.ai_client < 50ms cold start (from ~1800ms)", "import src.ai_client < 50ms cold start (from ~1800ms)",
"import src.gui_2 < 500ms cold start (from ~3000ms)", "import src.gui_2 < 500ms cold start (from ~3000ms)",
"import src.app_controller < 300ms cold start (from ~700ms)", "import src.app_controller < 300ms cold start (from ~700ms)",
"uv run sloppy.py --enable-test-hooks reaches immapp.run() in < 1.5s", "uv run sloppy.py --enable-test-hooks reaches immapp.run() in < 1.5s",
"live_gui.wait_for_server(timeout=15) passes for all tests", "live_gui.wait_for_server(timeout=15) passes for all tests",
"scripts/audit_main_thread_imports.py exits 0 (no main-thread heavy imports)", "scripts/audit_main_thread_imports.py exits 0 (no heavy imports on main)",
"tests/test_main_thread_purity.py passes (runtime audit hook confirms invariant)", "tests/test_main_thread_purity.py passes (runtime audit hook confirms invariant)",
"controller.wait_for_warmup(timeout=10) returns True",
"All warmup modules in sys.modules after warmup completes",
"User-triggered provider switch is INSTANT (proves warmup worked)",
"GUI shows 'Warming up... (N/M)' then 'All imports ready' with green dot, then a toast",
"GET /api/warmup_status returns {pending: [], completed: [...], failed: []}",
"NO `import X` statements inside function bodies for heavy modules (grep-verified)",
"No regressions in 273+ existing tests", "No regressions in 273+ existing tests",
"ZERO new threading.Thread(...) calls in src/ (after Phase 6 migration)", "ZERO new threading.Thread(...) calls in src/ (after Phase 6 migration)",
"Startup profile + io_pool status visible via /api/startup_profile and /api/io_pool_status" "Startup profile + io_pool status visible via /api/startup_profile, /api/io_pool_status"
], ],
"links": { "links": {
"backlog_entry": "conductor/tracks.md:152", "backlog_entry": "conductor/tracks.md:152",
+112 -72
View File
@@ -19,11 +19,15 @@
--- ---
## Phase 2: Job Pool Foundation (the "no new threads" rule) ## Phase 2: Job Pool + Warmup Foundation (the "no new threads" + "no lazy-loading" rules)
The user constraint: no new `threading.Thread(...)` per task, per import, per Two user constraints, addressed together:
ad-hoc job. The codebase gets ONE shared `ThreadPoolExecutor` on `AppController`, 1. **No new `threading.Thread(...)`** per task, per import, per ad-hoc job.
named `_io_pool`, used by any subsystem that needs background work. 2. **No lazy-loading** in function bodies. Heavy imports are warmed on bg
threads at startup, not loaded on first use.
The codebase gets ONE shared `ThreadPoolExecutor` on `AppController` named
`_io_pool`, used for warmup AND any future background work.
- [ ] **T2.1 (Red)** `tests/test_app_controller_io_pool.py`: - [ ] **T2.1 (Red)** `tests/test_app_controller_io_pool.py`:
- `test_app_controller_has_io_pool`: instantiate `AppController`, assert `hasattr(controller, '_io_pool')` and it's a `ThreadPoolExecutor` - `test_app_controller_has_io_pool`: instantiate `AppController`, assert `hasattr(controller, '_io_pool')` and it's a `ThreadPoolExecutor`
@@ -32,81 +36,94 @@ named `_io_pool`, used by any subsystem that needs background work.
- `test_io_pool_shuts_down_on_close`: call `controller.shutdown()`, assert the pool is shut down - `test_io_pool_shuts_down_on_close`: call `controller.shutdown()`, assert the pool is shut down
- Confirm FAIL (no `_io_pool` yet) - Confirm FAIL (no `_io_pool` yet)
- [ ] **T2.2 (Green)** In `src/app_controller.py`: - [ ] **T2.2 (Green)** In `src/app_controller.py`:
- Add `from concurrent.futures import ThreadPoolExecutor` at top - Add `from concurrent.futures import ThreadPoolExecutor` and `import importlib` at top
- In `__init__`, after the asyncio loop starts and BEFORE the existing HookServer block: `self._io_pool = ThreadPoolExecutor(max_workers=4, thread_name_prefix="controller-io")` - In `__init__`, after the asyncio loop starts and BEFORE the existing HookServer block: `self._io_pool = ThreadPoolExecutor(max_workers=4, thread_name_prefix="controller-io")`
- Add warmup state: `self._warmup_lock`, `self._warmup_done_event`, `self._warmup_status` (dict with `pending`/`completed`/`failed` lists), `self._warmup_callbacks`
- Call `self._submit_warmup_jobs()` at the end of `__init__`
- In `shutdown()` (already exists in `App.shutdown` for the GUI; ensure the AppController has a matching shutdown that calls `self._io_pool.shutdown(wait=False)`) - In `shutdown()` (already exists in `App.shutdown` for the GUI; ensure the AppController has a matching shutdown that calls `self._io_pool.shutdown(wait=False)`)
- Add `controller.submit_io(fn, *args)` helper: `return self._io_pool.submit(fn, *args)` (with a docstring saying "use this instead of `threading.Thread` for new background work") - [ ] **T2.3 (Red)** `tests/test_warmup_mechanism.py`:
- [ ] **T2.3** Run T2.1 tests; confirm PASS - `test_warmup_jobs_submitted_on_init`: after `AppController.__init__`, assert `len(controller.warmup_status()['pending']) > 0`
- [ ] **T2.4** Commit: `feat(app_controller): add shared _io_pool ThreadPoolExecutor` + git note - `test_warmup_jobs_complete_within_timeout`: call `controller.wait_for_warmup(timeout=10.0)`, assert True
- `test_warmup_status_reflects_completion`: after `wait_for_warmup`, assert `controller.is_warmup_done() == True` and `len(warmup_status()['pending']) == 0`
- `test_warmup_callback_fires_on_completion`: register a callback via `controller.on_warmup_complete(cb)`, assert it was called once warmup done
- `test_warmup_does_not_block_init`: time `__init__` with a 4-worker pool, assert it returns in < 200ms even though warmup takes longer
- Confirm FAIL (no warmup yet)
- [ ] **T2.4 (Green)** Implement `_submit_warmup_jobs()`, `_compute_warmup_list()`, `_warmup_one()`, `warmup_status()`, `is_warmup_done()`, `wait_for_warmup()`, `on_warmup_complete()` per spec §3.2. Warmup list includes: `google.genai`, `anthropic`, `openai`, `requests`, `src.command_palette`, `src.theme_nerv`, `src.theme_nerv_fx`, `src.markdown_table`, `numpy`. Conditionally adds `fastapi`, `fastapi.security.api_key` if `enable_test_hooks` or `web_host` is set.
- [ ] **T2.5** Run T2.1 and T2.3 tests; confirm PASS
- [ ] **T2.6** Commit: `feat(app_controller): add _io_pool + proactive warmup mechanism` + git note
**Phase 2 checkpoint:** `AppController` owns a 4-thread named pool. `controller.submit_io(fn)` is the sanctioned way to do background work. Existing ad-hoc threads still exist (will be migrated in Phase 5). **Phase 2 checkpoint:** `AppController` owns a 4-thread named pool. Warmup jobs are submitted in `__init__` and complete in the background. `controller.wait_for_warmup()`, `controller.warmup_status()`, and `controller.on_warmup_complete(cb)` are the public API. Main thread does NOT block waiting for warmup.
--- ---
## Phase 3: Lazy-load AI Provider SDKs (TDD) ## Phase 3: Remove top-level heavy imports from `src/ai_client.py` (TDD)
- [ ] **T3.1 (Red)** Write `tests/test_ai_client_lazy_imports.py`: The current `src/ai_client.py` has `from google import genai` etc. at the top,
- `test_ai_client_does_not_import_genai_at_module_level`: spawn fresh subprocess, `import src.ai_client`, assert `'google.genai' not in sys.modules` (or `google.genai` in modules but `_gemini_client` is `None`) which puts the main thread in the import chain. Phase 3 removes these and
swaps to `_require_warmed(name)`.
- [ ] **T3.1 (Red)** Write `tests/test_ai_client_no_top_level_sdk_imports.py`:
- `test_ai_client_does_not_import_genai_at_module_level`: spawn fresh subprocess, `import src.ai_client`, assert `'google.genai' not in sys.modules` (warmup hasn't run in this subprocess)
- `test_ai_client_does_not_import_anthropic_at_module_level` - `test_ai_client_does_not_import_anthropic_at_module_level`
- `test_ai_client_does_not_import_openai_at_module_level` - `test_ai_client_does_not_import_openai_at_module_level`
- `test_ai_client_does_not_import_requests_at_module_level` - `test_ai_client_does_not_import_requests_at_module_level`
- Confirm tests FAIL (proves the imports are currently eager) - Confirm tests FAIL (proves the imports are currently eager)
- [ ] **T3.2 (Green)** In `src/ai_client.py`: - [ ] **T3.2 (Green)** In `src/ai_client.py`:
- Remove `from google import genai` from top - Add `import sys, importlib, threading` at top
- Remove `import anthropic` from top - Remove `from google import genai`, `import anthropic`, `import openai`, `import requests` from top
- Remove `import openai` from top - Add `_require_warmed(name)` helper: returns `sys.modules[name]` or raises `RuntimeError`
- Remove `import requests` from top - Each `_send_*` function calls `_require_warmed("google.genai")` etc. instead of using the module directly
- Add lazy imports inside `_send_gemini`, `_send_anthropic`, `_send_deepseek`, `_send_minimax` - Provider client globals stay as `None` until first `_send_*` initializes them via `_ensure_<provider>_client()` (extracted from current top-level logic, uses the warmed module)
- Provider client globals stay as `None` until first `_ensure_<provider>_client()` call - [ ] **T3.3** Run existing `tests/test_ai_client.py`; fix any breakage. Tests that relied on top-level import side effects need a fixture that warms the modules (or a fallback for test mode).
- [ ] **T3.3** Run existing `tests/test_ai_client.py`; fix any breakage. Most likely issue: tests that rely on top-level import side effects need a fixture that triggers lazy init.
- [ ] **T3.4** Re-run T3.1 tests, confirm PASS - [ ] **T3.4** Re-run T3.1 tests, confirm PASS
- [ ] **T3.5** Commit: `git commit -m "refactor(ai_client): lazy-load provider SDKs to defer ~1800ms off main thread"` + git note - [ ] **T3.5** Commit: `refactor(ai_client): remove top-level SDK imports; use _require_warmed` + git note
- [ ] **T3.6** Update `conductor/tracks.md` T3 row with SHA - [ ] **T3.6** Update `conductor/tracks.md` T3 row with SHA
**Phase 3 checkpoint:** `import src.ai_client` < 50ms cold. All 273 existing tests still pass. **Phase 3 checkpoint:** `import src.ai_client` < 50ms cold. When run inside an `AppController` whose warmup has completed, `_send_*` functions find the SDKs in `sys.modules` and execute instantly.
--- ---
## Phase 4: Lazy-load FastAPI in HookServer (TDD) ## Phase 4: Remove top-level FastAPI imports from `src/api_hooks.py` (TDD)
- [ ] **T4.1 (Red)** Write `tests/test_hook_server_lazy_fastapi.py`: Same pattern as Phase 3, for the FastAPI imports.
- [ ] **T4.1 (Red)** Write `tests/test_hook_server_no_top_level_fastapi.py`:
- `test_hook_server_does_not_import_fastapi_at_module_level`: subprocess test - `test_hook_server_does_not_import_fastapi_at_module_level`: subprocess test
- `test_hook_server_does_not_import_fastapi_security_at_module_level` - `test_hook_server_does_not_import_fastapi_security_at_module_level`
- Confirm FAIL - Confirm FAIL
- [ ] **T4.2 (Green)** In `src/api_hooks.py`: - [ ] **T4.2 (Green)** In `src/api_hooks.py`:
- Remove `from fastapi import ...` from top - Remove `from fastapi import ...`, `from fastapi.security.api_key import ...` from top
- Remove `from fastapi.security.api_key import APIKeyHeader` from top - Add `_require_warmed(name)` calls inside the methods that need them (FastAPI app construction, route registration)
- Add lazy imports inside the methods that need them (FastAPI app construction, route registration) - [ ] **T4.3** Run existing `tests/test_api_hooks.py`; fix breakage (similar fallback strategy as Phase 3)
- [ ] **T4.3** Run existing `tests/test_api_hooks.py`; fix breakage
- [ ] **T4.4** Confirm T4.1 tests PASS - [ ] **T4.4** Confirm T4.1 tests PASS
- [ ] **T4.5** Commit: `git commit -m "refactor(api_hooks): lazy-load fastapi to defer ~470ms off main thread"` + git note - [ ] **T4.5** Commit: `refactor(api_hooks): remove top-level fastapi imports; use _require_warmed` + git note
**Phase 4 checkpoint:** `from src.api_hooks import HookServer` does not import fastapi. **Phase 4 checkpoint:** `from src.api_hooks import HookServer` does not import fastapi. The HookServer is fully constructed only after AppController's warmup has loaded fastapi (or after `_require_warmed("fastapi")` triggers the import in test mode).
--- ---
## Phase 5: Lazy-load Feature-gated GUI Modules (TDD per module) ## Phase 5: Remove top-level imports for feature-gated GUI modules (TDD per module)
### 5A: Command Palette ### 5A: Command Palette
- [ ] **T5A.1 (Red)** `tests/test_command_palette_lazy.py`: `from src.commands import COMMANDS` (or whatever the eager import is) does not import `src.command_palette`. Confirm FAIL. - [ ] **T5A.1 (Red)** `tests/test_command_palette_no_top_level_import.py`: `from src.commands import COMMANDS` does not import `src.command_palette`. Confirm FAIL.
- [ ] **T5A.2 (Green)** In `src/commands.py`: move `from src.command_palette import ...` inside the command functions that open the palette (`_open_command_palette`, `_toggle_command_palette`). - [ ] **T5A.2 (Green)** In `src/commands.py`: remove `from src.command_palette import ...` from top. The command functions (`_open_command_palette`, `_toggle_command_palette`) call `_require_warmed("src.command_palette")` to access the module.
- [ ] **T5A.3** Run `tests/test_command_palette.py`; fix. - [ ] **T5A.3** Run `tests/test_command_palette.py`; fix.
- [ ] **T5A.4** Commit: `refactor(commands): lazy-load command_palette to defer 244ms` - [ ] **T5A.4** Commit: `refactor(commands): remove top-level command_palette import; use _require_warmed`
### 5B: NERV Theme ### 5B: NERV Theme
- [ ] **T5B.1 (Red)** `tests/test_theme_nerv_lazy.py`: `from src.theme_2 import *` (or whatever) does not import `src.theme_nerv` or `src.theme_nerv_fx`. Confirm FAIL. - [ ] **T5B.1 (Red)** `tests/test_theme_nerv_no_top_level_import.py`: `from src.theme_2 import *` does not import `src.theme_nerv` or `src.theme_nerv_fx`. Confirm FAIL.
- [ ] **T5B.2 (Green)** In `src/theme_2.py`: move `from src.theme_nerv import ...` and `from src.theme_nerv_fx import ...` inside `apply_nerv_theme()` (or whichever function activates the theme). - [ ] **T5B.2 (Green)** In `src/theme_2.py`: remove `from src.theme_nerv import ...` and `from src.theme_nerv_fx import ...` from top. `apply_nerv_theme()` (or whichever function activates the theme) calls `_require_warmed("src.theme_nerv")` and `_require_warmed("src.theme_nerv_fx")`.
- [ ] **T5B.3** Run `tests/test_theme_2.py` and `tests/test_theme_nerv.py`; fix. - [ ] **T5B.3** Run `tests/test_theme_2.py` and `tests/test_theme_nerv.py`; fix.
- [ ] **T5B.4** Commit: `refactor(theme): lazy-load nerv theme to defer 485ms off non-nerv path` - [ ] **T5B.4** Commit: `refactor(theme): remove top-level nerv theme imports; use _require_warmed`
### 5C: Markdown Table ### 5C: Markdown Table
- [ ] **T5C.1 (Red)** `tests/test_markdown_helper_lazy.py`: `from src.markdown_helper import MarkdownRenderer` does not import `src.markdown_table`. Confirm FAIL. - [ ] **T5C.1 (Red)** `tests/test_markdown_helper_no_top_level_import.py`: `from src.markdown_helper import MarkdownRenderer` does not import `src.markdown_table`. Confirm FAIL.
- [ ] **T5C.2 (Green)** In `src/markdown_helper.py`: move `from src.markdown_table import ...` inside the table-detection branch of `render()`. - [ ] **T5C.2 (Green)** In `src/markdown_helper.py`: remove `from src.markdown_table import ...` from top. The table-detection branch of `render()` calls `_require_warmed("src.markdown_table")`.
- [ ] **T5C.3** Run `tests/test_markdown_helper.py`; fix. - [ ] **T5C.3** Run `tests/test_markdown_helper.py`; fix.
- [ ] **T5C.4** Commit: `refactor(markdown): lazy-load markdown_table to defer 250ms off non-table markdown` - [ ] **T5C.4** Commit: `refactor(markdown): remove top-level markdown_table import; use _require_warmed`
### 5D: GUI module feature-gated imports ### 5D: GUI module feature-gated imports
@@ -115,16 +132,14 @@ named `_io_pool`, used by any subsystem that needs background work.
- [ ] **T5D.3** Run full GUI test suite; fix. - [ ] **T5D.3** Run full GUI test suite; fix.
- [ ] **T5D.4** Commit per feature group - [ ] **T5D.4** Commit per feature group
**Phase 5 checkpoint:** Feature-gated imports are lazy. Default-theme / non-palette / non-table path is lean. **Phase 5 checkpoint:** All heavy imports removed from main-thread-reachable source files. Default-theme / non-palette / non-table path is lean. Warmup pre-loads all of them in the background.
--- ---
## Phase 6: Migrate Ad-hoc Threads to `_io_pool` ## Phase 6: Migrate Ad-hoc Threads to `_io_pool`
The codebase has several ad-hoc `threading.Thread(...)` calls. Per the user The codebase has several ad-hoc `threading.Thread(...)` calls. Per the user
constraint, these should migrate to `controller.submit_io(fn)`. **This phase constraint, these should migrate to `controller.submit_io(fn)`.
audits and migrates them, but does NOT add new prefetch threads** (the heavy
SDKs are lazy-only per spec §2.2 Layer 3).
- [ ] **T6.1** Audit: `grep -rn "threading.Thread(" src/` to find all ad-hoc thread spawns. Document each in `state.toml` (a new `[ad_hoc_threads]` section). - [ ] **T6.1** Audit: `grep -rn "threading.Thread(" src/` to find all ad-hoc thread spawns. Document each in `state.toml` (a new `[ad_hoc_threads]` section).
- [ ] **T6.2** For each ad-hoc thread in `src/log_pruner.py`, `src/project_manager.py`, etc., refactor to use `controller.submit_io(fn)` instead. Wrap the callable body in a try/except (the pool's default behavior is to surface exceptions via the Future; preserve existing error logging). - [ ] **T6.2** For each ad-hoc thread in `src/log_pruner.py`, `src/project_manager.py`, etc., refactor to use `controller.submit_io(fn)` instead. Wrap the callable body in a try/except (the pool's default behavior is to surface exceptions via the Future; preserve existing error logging).
@@ -135,36 +150,55 @@ SDKs are lazy-only per spec §2.2 Layer 3).
--- ---
## Phase 7: Enforcement (Runtime Audit Hook) ## Phase 7: Warmup Notification (Hook API + GUI)
The user said: *"the app controller should post to test clients or the user
when its threads are warmed up with imports — that way the user knows 'hey
you have the ui first, but now you have all the functionality.'"* This phase
implements the notification surfaces.
### 7A: Hook API endpoints
- [ ] **T7A.1 (Red)** `tests/test_api_hooks_warmup.py`:
- `test_warmup_status_endpoint`: hit `GET /api/warmup_status`, assert response has `pending`/`completed`/`failed` keys
- `test_warmup_wait_endpoint`: hit `GET /api/warmup_wait?timeout=10`, assert response includes the completion state
- Confirm FAIL (endpoints don't exist yet)
- [ ] **T7A.2 (Green)** In `src/api_hooks.py`:
- Add `GET /api/warmup_status` returning `controller.warmup_status()`
- Add `GET /api/warmup_wait` accepting `?timeout=N` (default 30s), calling `controller.wait_for_warmup(timeout)` then returning the final status
- Register `warmup_status` in `_gettable_fields` so the existing Hook API client can fetch it
- [ ] **T7A.3** Run T7A.1 tests; confirm PASS
- [ ] **T7A.4** Commit: `feat(api_hooks): add /api/warmup_status and /api/warmup_wait` + git note
### 7B: GUI status indicator + toast
- [ ] **T7B.1** In `src/gui_2.py` (in the status bar render function), poll `controller.warmup_status()` once per frame. While `pending` is non-empty: show "Warming up... (N/M)" text. When `pending` is empty AND `failed` is empty: show "All imports ready" with a green dot. When `failed` is non-empty: show "Imports: N failed" with a yellow dot.
- [ ] **T7B.2** Register a callback via `controller.on_warmup_complete(cb)` that:
- On transition to done (with no failures): queue a toast notification "All providers ready (M modules)" via the existing toast system
- On transition to done (with failures): queue a warning toast "Warmup finished with N failures — see Diagnostics"
- [ ] **T7B.3** Update `docs/guide_gui_2.md` (or wherever status bar is documented) to describe the new indicator
- [ ] **T7B.4** Commit: `feat(gui_2): warmup status indicator + completion toast` + git note
**Phase 7 checkpoint:** Tests can poll `/api/warmup_status` to know when the system is fully ready. The GUI shows progress during startup and a toast when complete.
---
## Phase 8: Enforcement (Runtime Audit Hook)
The static gate (T1.4) catches known imports at audit time. This phase adds The static gate (T1.4) catches known imports at audit time. This phase adds
empirical enforcement: a test that spawns `sloppy.py` and verifies NO heavy empirical enforcement: a test that spawns `sloppy.py` and verifies NO heavy
import happens on the main thread at runtime. import happens on the main thread at runtime.
- [ ] **T7.1 (Red)** `tests/test_main_thread_purity.py`: - [ ] **T8.1 (Red)** `tests/test_main_thread_purity.py`:
- `test_headless_startup_no_heavy_imports_on_main`: spawn `uv run python sloppy.py --headless --enable-test-hooks` with a `sitecustomize.py` shim that installs `sys.addaudithook` to log every `import` event with the calling thread. The hook writes to a temp file as JSON-L. - `test_headless_startup_no_heavy_imports_on_main`: spawn `uv run python sloppy.py --headless --enable-test-hooks` with a `sitecustomize.py` shim that installs `sys.addaudithook` to log every `import` event with the calling thread. The hook writes to a temp file as JSON-L.
- Wait for headless server ready (5s timeout via `ApiHookClient`). - Wait for headless server ready (5s timeout via `ApiHookClient`).
- Read the audit log. Assert: no event with `thread_name == "MainThread"` for any module in the heavy denylist (`google.genai`, `anthropic`, `openai`, `fastapi`, `requests`, `numpy`, `tkinter`, `psutil`, `pydantic`, `tree_sitter_*`, `src.command_palette`, `src.theme_nerv`, `src.theme_nerv_fx`, `src.markdown_table`, `src.ai_client.send_*`-direct). - Read the audit log. Assert: no event with `thread_name == "MainThread"` for any module in the heavy denylist (`google.genai`, `anthropic`, `openai`, `fastapi`, `requests`, `numpy`, `tkinter`, `psutil`, `pydantic`, `tree_sitter_*`, `src.command_palette`, `src.theme_nerv`, `src.theme_nerv_fx`, `src.markdown_table`).
- Kill subprocess. Confirm FAIL (current state imports these on main). - Kill subprocess. Confirm FAIL (current state imports these on main).
- [ ] **T7.2** Once Phase 3-5 land and the static gate passes, this test should start passing. If it doesn't, debug and add more lazy imports. - [ ] **T8.2** Once Phase 3-5 land and the static gate passes, this test should start passing. If it doesn't, debug and add more top-level import removals.
- [ ] **T7.3** Wire `test_main_thread_purity.py` into CI as a gating test (it'll be slow, ~10s, so mark with `@pytest.mark.slow` and only run in batched CI). - [ ] **T8.3** Wire `test_main_thread_purity.py` into CI as a gating test (it'll be slow, ~10s, so mark with `@pytest.mark.slow` and only run in batched CI).
- [ ] **T7.4** Commit: `test: empirical main-thread purity check via sys.audit hook` + git note - [ ] **T8.4** Commit: `test: empirical main-thread purity check via sys.audit hook` + git note
**Phase 7 checkpoint:** CI fails if a future commit re-introduces a heavy main-thread import. **Phase 8 checkpoint:** CI fails if a future commit re-introduces a heavy main-thread import.
---
## Phase 8: Hook API + Diagnostics
- [ ] **T8.1** Add `/api/startup_profile` endpoint in `src/api_hooks.py` returning `controller.startup_profiler.snapshot()`
- [ ] **T8.2** Register `startup_profile` in `_gettable_fields`
- [ ] **T8.3** Add a "Startup Profile" section to the Diagnostics panel (`src/gui_2.py` `_render_diagnostics` or similar). Show: phase name, duration, % of total.
- [ ] **T8.4** Add `/api/io_pool_status` endpoint returning `{max_workers, active_threads, queued, completed}` so the user can see the job pool is alive.
- [ ] **T8.5** Update `docs/guide_api_hooks.md` with both new endpoints.
- [ ] **T8.6** Tests: extend `tests/test_api_hooks.py` + new `tests/test_startup_profiler.py` + new `tests/test_io_pool_endpoint.py`.
- [ ] **T8.7** Commit: `feat(diagnostics): expose startup profile and io_pool status via Hook API` + git note
**Phase 8 checkpoint:** User can see per-phase startup cost + job-pool liveness in the GUI.
--- ---
@@ -175,30 +209,36 @@ import happens on the main thread at runtime.
- `import src.gui_2` < 500ms - `import src.gui_2` < 500ms
- `import src.app_controller` < 300ms (includes `_io_pool` creation; should still be < 300ms) - `import src.app_controller` < 300ms (includes `_io_pool` creation; should still be < 300ms)
- [ ] **T9.2** Re-run `scripts/audit_main_thread_imports.py` (T1.4). Confirm exit 0. No violations. - [ ] **T9.2** Re-run `scripts/audit_main_thread_imports.py` (T1.4). Confirm exit 0. No violations.
- [ ] **T9.3** Run `live_gui` test batch (per `conductor/workflow.md:147-150`: max 4 test files per batch, long timeout): - [ ] **T9.3** Run `tests/test_warmup_mechanism.py` (T2.3); confirm warmup completes and notifications fire
- [ ] **T9.4** Run `live_gui` test batch (per `conductor/workflow.md:147-150`: max 4 test files per batch, long timeout):
- `uv run pytest tests/test_live_gui_*.py --timeout=60 -v` in batches - `uv run pytest tests/test_live_gui_*.py --timeout=60 -v` in batches
- Confirm `wait_for_server(timeout=15)` does not time out - Confirm `wait_for_server(timeout=15)` does not time out
- [ ] **T9.4** Manual smoke: - Optionally: tests can call `controller.wait_for_warmup()` before exercising functionality that depends on warmed modules
- `uv run sloppy.py` (normal mode): time-to-first-frame - [ ] **T9.5** Manual smoke:
- `uv run sloppy.py --enable-test-hooks` (test mode): time-to-first-frame - `uv run sloppy.py` (normal mode): time-to-first-frame, observe "Warming up... (N/M)" status, then "All imports ready" toast
- `uv run sloppy.py --enable-test-hooks` (test mode): same observations, plus `/api/warmup_status` returns `completed`
- `uv run sloppy.py --headless` (headless): time-to-server-ready - `uv run sloppy.py --headless` (headless): time-to-server-ready
- [ ] **T9.5** Phase checkpoint commit: `conductor(checkpoint): Phase 9 complete - sloppy.py startup speedup track` + git note with full verification report - Verify a user action that switches provider (or other warmup-dependent operation) is INSTANT, not 1s-delayed
- [ ] **T9.6** Update `conductor/tracks.md`: mark track complete, link to archived folder - [ ] **T9.6** Phase checkpoint commit: `conductor(checkpoint): Phase 9 complete - sloppy.py startup speedup track` + git note with full verification report
- [ ] **T9.7** Update `conductor/tracks.md`: mark track complete, link to archived folder
**Phase 9 checkpoint:** All verification criteria in `spec.md:6` met. **Phase 9 checkpoint:** All verification criteria in `spec.md:6` met. User can switch providers with zero perceptible lag because warmup already loaded the SDK.
--- ---
## Definition of Done ## Definition of Done
- [ ] All Phase 1-9 tasks checked - [ ] All Phase 1-9 tasks checked
- [ ] All tests pass (273+ existing + new TDD tests including `test_main_thread_purity`) - [ ] All tests pass (273+ existing + new TDD tests including `test_main_thread_purity` and `test_warmup_mechanism`)
- [ ] `uv run ruff check .` and `uv run mypy --explicit-package-bases .` clean (per `mma-tier2-tech-lead` skill) - [ ] `uv run ruff check .` and `uv run mypy --explicit-package-bases .` clean (per `mma-tier2-tech-lead` skill)
- [ ] `uv run python scripts/audit_main_thread_imports.py` exits 0 - [ ] `uv run python scripts/audit_main_thread_imports.py` exits 0
- [ ] `docs/startup_baseline_20260606.txt` and `docs/startup_after_20260606.txt` archived - [ ] `docs/startup_baseline_20260606.txt` and `docs/startup_after_20260606.txt` archived
- [ ] Phase 9 git note contains: baseline diff, audit script result, runtime audit hook result, full test batch results, manual smoke timings, file inventory - [ ] Phase 9 git note contains: baseline diff, audit script result, runtime audit hook result, full test batch results, manual smoke timings, file inventory
- [ ] Track moved to `conductor/tracks/archive/` - [ ] Track moved to `conductor/tracks/archive/`
- [ ] **NO new `threading.Thread(...)` calls in `src/`** (verified by `grep -rn "threading.Thread(" src/`) - [ ] **NO new `threading.Thread(...)` calls in `src/`** (verified by `grep -rn "threading.Thread(" src/`)
- [ ] **NO `import X` statements in function bodies for heavy modules** — verified by `grep -rn "^\s*import \(google\|anthropic\|openai\|fastapi\|src\.command_palette\|src\.theme_nerv\|src\.markdown_table\)" src/`
- [ ] **Warmup completion notification works** — GUI shows toast, Hook API returns `completed`, `controller.is_warmup_done()` returns True within 10s of startup
- [ ] **User action latency is zero for warmup-dependent operations** — manual smoke test switching providers / opening palette / rendering NERV is instant
--- ---
+403 -144
View File
@@ -88,9 +88,11 @@ mechanism that does not run on the main thread.
### 2.2 Four layers of protection ### 2.2 Four layers of protection
#### Layer 1 — Pure lazy loading (the load-bearing wall, non-negotiable) #### Layer 1 — Explicit warmup-aware module access (the load-bearing wall, non-negotiable)
Move heavy imports from module top-level into the function body that needs them: Remove heavy imports from the top of source files reachable from the main
thread. Functions that need them use a `_require_warmed(name)` helper that
assumes the module is already in `sys.modules` (because warmup put it there):
```python ```python
# BEFORE (src/ai_client.py, current) # BEFORE (src/ai_client.py, current)
@@ -100,18 +102,44 @@ import openai
# ... 5 provider SDKs loaded unconditionally # ... 5 provider SDKs loaded unconditionally
# AFTER # AFTER
def _send_gemini(md_content, user_message, ...): import sys
from google import genai # 955ms, paid once, on the first call's thread import importlib
... from typing import Any
def _send_anthropic(...): def _require_warmed(name: str) -> Any:
import anthropic """Get a module that AppController's warmup should have loaded.
...
Raises RuntimeError if the module is not in sys.modules. This is the
explicit contract: heavy modules MUST be warmed at startup. No lazy
loading on first use — the import is paid upfront on a bg thread.
"""
mod = sys.modules.get(name)
if mod is None:
raise RuntimeError(
f"Module {name!r} is not warmed. "
f"AppController.__init__ must have run first (which submits warmup jobs)."
)
return mod
def _send_gemini(md_content, user_message, ...):
genai = _require_warmed("google.genai")
# ... use genai ...
``` ```
**Main-thread cost: zero.** First call still pays the latency, but it happens on **Why no `import X` inside the function body?** Because that would be lazy
the asyncio worker thread (per `guide_architecture.md:215-234`), so the GUI never loading on first use. If the first use is triggered by a user UI action
sees it. (e.g. switching the provider from MiniMax to Gemini, the controller enqueues
an action that propagates to the first call), the user sees a 955ms lag
between their click and any visible response. That's the bad case the user
called out: *"lazy loading introduces latencies when interacting with the UI
state vs the bg state."*
By warming proactively, the first user-triggered call is instant. The cost
is paid during startup on a bg thread, before the user can interact.
**Main-thread cost: zero.** The main thread's import chain is fully lean
(none of the heavy modules are imported top-level). The warmup jobs run on
`_io_pool` workers in parallel with the main thread's remaining init.
#### Layer 2 — Shared job pool on AppController (no new threads per task) #### Layer 2 — Shared job pool on AppController (no new threads per task)
@@ -124,22 +152,22 @@ The codebase already has these dedicated / shared threads:
- Ad-hoc `threading.Thread` calls — used for one-off tasks; the user wants to - Ad-hoc `threading.Thread` calls — used for one-off tasks; the user wants to
**MINIMIZE** these **MINIMIZE** these
**User constraint:** no new daemon threads per import prefetch, per I/O task, per **User constraint:** no new daemon threads per import warmup, per I/O task, per
log-prune. We add ONE shared `ThreadPoolExecutor` to `AppController` named log-prune. We add ONE shared `ThreadPoolExecutor` to `AppController` named
`_io_pool`, and any subsystem that needs background work submits jobs to it. `_io_pool`, and any subsystem that needs background work submits jobs to it.
This includes: This includes:
- Initial RAG index warm-up (if applicable) - Initial RAG index warm-up (if applicable)
- Log pruning (currently a one-shot thread — refactor to use the pool) - Log pruning (currently a one-shot thread — refactor to use the pool)
- Disk-bound subsystem initialization (e.g., TOML re-read on persona switch) - Disk-bound subsystem initialization (e.g., TOML re-read on persona switch)
- Any other ad-hoc I/O - **Heavy module warmup (the primary use case for this track)**
```python ```python
# In AppController.__init__ # In AppController.__init__
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
self._io_pool = ThreadPoolExecutor( self._io_pool = ThreadPoolExecutor(
max_workers=4, max_workers=4,
thread_name_prefix="controller-io", thread_name_prefix="controller-io",
) )
``` ```
@@ -147,25 +175,95 @@ self._io_pool = ThreadPoolExecutor(
import, not 1 per subsystem. Just 4 long-lived threads that all background work import, not 1 per subsystem. Just 4 long-lived threads that all background work
shares. Future work that needs a bg thread should `controller._io_pool.submit(fn)`. shares. Future work that needs a bg thread should `controller._io_pool.submit(fn)`.
#### Layer 3 — NO prefetch of the heaviest SDKs (deliberate) #### Layer 3 — Proactive warmup + completion notification (the new mechanism)
The original Phase 5 of this plan proposed a `import-prefetch` daemon thread that This is the core of the track. In `AppController.__init__`, immediately after
warms `google.genai` (~955ms) on a background thread. **This has been explicitly `_io_pool` is created, the controller submits a job to the pool for each heavy
rejected** for the heavy SDKs, and the reasoning is sound: module that needs warming. The main thread does NOT wait for these to complete.
- A 955ms import on a background thread holds the GIL for ~10-50ms at a time ```python
during C extension init. Each hold stalls the main thread's render loop. # In AppController.__init__, right after self._io_pool is created
- The user pays 955ms total either way: prefetch = 955ms of background stutter self._warmup_status: dict[str, list[str]] = {
+ instant first call; lazy-only = 955ms of stutter on the first call only, "pending": [], "completed": [], "failed": [],
with the GUI fully interactive in between. }
- Prefetching wastes the import cost when the user never uses that provider self._warmup_lock = threading.Lock()
(e.g., default is Gemini but the user actually only uses Anthropic). self._warmup_done_event = threading.Event()
self._warmup_callbacks: list[Callable] = []
self._submit_warmup_jobs()
```
**Rule: heavy SDKs (`google.genai`, `anthropic`, `openai`, `fastapi`) are ```python
lazy-only, never prefetched.** Lighter modules (themes, command palette, def _submit_warmup_jobs(self) -> None:
markdown table) MAY be optionally warmed on the `_io_pool` if profiling shows """Submit bg jobs to import heavy modules. Notifies subscribers on completion."""
they're commonly used, but it's not a hard requirement and the default is heavy = self._compute_warmup_list()
"don't warm." with self._warmup_lock:
self._warmup_status["pending"] = list(heavy)
self._warmup_status["completed"] = []
self._warmup_status["failed"] = []
self._warmup_done_event.clear()
for module_name in heavy:
self._io_pool.submit(self._warmup_one, module_name)
def _compute_warmup_list(self) -> list[str]:
result = [
# AI provider SDKs
"google.genai", "anthropic", "openai", "requests",
# Feature-gated GUI (used by main thread but not on first frame)
"src.command_palette",
"src.theme_nerv", "src.theme_nerv_fx",
"src.markdown_table",
]
if self._enable_test_hooks or self._web_host:
result.extend(["fastapi", "fastapi.security.api_key"])
return result
def _warmup_one(self, module_name: str) -> None:
try:
importlib.import_module(module_name)
with self._warmup_lock:
self._warmup_status["pending"].remove(module_name)
self._warmup_status["completed"].append(module_name)
except Exception as e:
with self._warmup_lock:
self._warmup_status["pending"].remove(module_name)
self._warmup_status["failed"].append(module_name)
finally:
with self._warmup_lock:
done = not self._warmup_status["pending"]
callbacks = list(self._warmup_callbacks) if done else []
if done:
self._warmup_done_event.set()
for cb in callbacks:
try:
cb(self._warmup_status)
except Exception:
pass
```
**Completion notification** is critical for the user-visible UX. Three surfaces:
1. **GUI status indicator** — the status bar shows "Warming up... (5/8)" while
the bg jobs run, then "All imports ready" with a green dot when complete.
The GUI never blocks waiting; the indicator is updated by polling
`controller.warmup_status()` once per frame (cheap, lock-guarded).
2. **GUI toast notification** — when warmup completes, show a toast:
"All providers ready" with the count of modules loaded. User can dismiss.
3. **Hook API endpoint**`GET /api/warmup_status` returns the current state;
`GET /api/warmup_wait?timeout=N` blocks until done (for tests).
The user said: *"the app controller should post to test clients or the user
when its threads are warmed up with imports — that way the user knows 'hey
you have the ui first, but now you have all the functionality.'"* This is
exactly what the notification surfaces achieve.
**Why this beats lazy-loading:** if a user clicks "switch to Gemini" and the
controller lazy-loads `google.genai` on that action, the user sees ~1s of
nothing happening between the click and the visible response. With warmup,
the click is instant because `google.genai` is already in `sys.modules`. The
1s of cost was paid during startup, when the user was looking at a splash or
otherwise not waiting on input.
#### Layer 4 — Worker-process isolation (future, out of scope) #### Layer 4 — Worker-process isolation (future, out of scope)
@@ -181,18 +279,19 @@ the GUI's thread?"* The answer is:
| Scenario | Blocks GUI? | | Scenario | Blocks GUI? |
|---|---| |---|---|
| Module top-level import of heavy X, then main imports X | **YES** (X's import is in main's chain) | | Module top-level import of heavy X, then main imports X | **YES** (X's import is in main's chain). This is why we remove heavy imports from main-thread-reachable files. |
| Lazy import of X inside a function called from the asyncio thread | **NO** (asyncio thread blocks, not main) | | `_io_pool` worker warming X while main thread renders | **NO direct block, but GIL contention causes micro-stutters** (~5-50ms each). Acceptable because the pool is capped at 4 threads and the main thread is mostly idle in `immapp.run()`. |
| Lazy import of X inside a function called from the main thread | **YES** (first call only; the function caller blocks) | | `_io_pool` worker warms X; main thread later calls `_require_warmed("X")` (X already in `sys.modules`) | **NO** (the lookup is a `dict.get()` — instant, no import lock contention). |
| `_io_pool` worker importing X while main thread renders | **NO direct block, but GIL contention causes micro-stutters** (~5-50ms each). Acceptable because the pool is capped at 4 threads. | | User-triggered UI action (e.g. provider switch) propagates to controller which calls `_require_warmed` on a warmed module | **NO** (lookup is instant). This is the win the user explicitly called out: no user-perceptible lag. |
| `_io_pool` worker imports X; main thread later imports X (same module) | **YES** (main blocks on per-module import lock until worker finishes). This is why Layer 1 must come first. | | `wait_for_warmup()` blocks the asyncio thread waiting for warmup | **NO direct block on GUI** (different thread). Asyncio thread waits; main thread renders. Acceptable but rarely needed if user waits for warmup notification first. |
| Spawning a new `threading.Thread` for each import prefetch | **Wasteful** (thread creation ~1-5ms each; thread count explodes). Use the `_io_pool` instead. | | Spawning a new `threading.Thread` for each import warmup | **Wasteful** (thread creation ~1-5ms each; thread count explodes). Use the `_io_pool` instead. |
This means: **Layer 1 is non-negotiable.** Even with the `_io_pool`, if the This means: **Layer 1 is non-negotiable.** Even with warmup on `_io_pool`, if
heavy import is also in the main thread's import chain, the main thread will the heavy import is also in the main thread's import chain, the main thread
block on the import lock the moment it tries to use the module. Layer 1 removes will block on the import lock the moment it tries to use the module. Layer 1
the heavy imports from the main thread's chain; Layer 2 reuses threads removes the heavy imports from the main thread's chain; Layer 2 reuses
efficiently; Layer 3 deliberately avoids prefetching the heaviest. threads efficiently; Layer 3 proactively warms on bg threads so the FIRST
user-triggered use is instant.
### 2.4 Enforcement: the "main thread purity" audit ### 2.4 Enforcement: the "main thread purity" audit
@@ -236,95 +335,119 @@ not just at static analysis time.
### 3.1 Per-file import plan ### 3.1 Per-file import plan
For each source file reachable from the main thread's import chain, we
**remove top-level heavy imports** and have functions access them via
`_require_warmed(name)`. The warmup jobs (§3.2) put the modules in
`sys.modules` before any function is called.
#### `src/ai_client.py` (the biggest win: ~1800ms) #### `src/ai_client.py` (the biggest win: ~1800ms)
Top-level today: `from google import genai`, `import anthropic`, `import openai`, Top-level today: `from google import genai`, `import anthropic`, `import openai`,
`import requests` (used by deepseek/minimax). `import requests` (used by deepseek/minimax).
After: After:
- Drop `from google import genai` from top — lazy in `_send_gemini()` - **Drop all four heavy imports from the top.** Add `_require_warmed(name)`
- Drop `import anthropic` from top — lazy in `_send_anthropic()` helper at the top.
- Drop `import openai` from top — lazy in `_send_deepseek()` and `_send_minimax()` - `_send_gemini()` calls `_require_warmed("google.genai")` to get the module
- Drop `import requests` from top — lazy in those two providers' HTTP code - `_send_anthropic()` calls `_require_warmed("anthropic")`
- Provider client objects (`_gemini_client`, `_anthropic_client`, etc.) stay as - `_send_deepseek()` and `_send_minimax()` call `_require_warmed("openai")` and `_require_warmed("requests")`
module globals but are now `None` until first use - Provider client objects (`_gemini_client`, `_anthropic_client`, etc.) stay
- The `_send_*` functions check their provider client is initialized and call a as module globals but are now `None` until `_send_*` initializes them
new `_ensure_<provider>_client()` lazy initializer (extracted from the current (extracted from current top-level logic into a new
top-level logic) `_ensure_<provider>_client()` that uses the warmed module)
- The warmup list in `AppController._compute_warmup_list()` includes
`google.genai`, `anthropic`, `openai`, `requests` (always warmed)
**Result:** ~1800ms off the main thread. First AI call still pays it, but on **Result:** ~1800ms off the main thread. The bg threads pay this cost during
the asyncio worker. startup. By the time the first AI call happens (which is always async, on
the asyncio thread), the modules are in `sys.modules` and the lookup is
instant. No user-perceptible lag.
#### `src/app_controller.py` (FastAPI in headless/web only) #### `src/api_hooks.py` (FastAPI in headless/web only)
Top-level today: `from fastapi import ...`, `from fastapi.security.api_key import ...` Top-level today: `from fastapi import ...`, `from fastapi.security.api_key import ...`
(only needed if `--enable-test-hooks` or `--web-host`). (only needed if `--enable-test-hooks` or `--web-host`).
After: After:
- Drop these from top — lazy inside `HookServer.__init__` (which is itself lazy - **Drop these from top.** Add `_require_warmed(name)` calls inside the
in the controller: `if enable_test_hooks: from src.api_hooks import HookServer; ...`) methods that need them.
- The warmup list in `AppController._compute_warmup_list()` includes
`fastapi`, `fastapi.security.api_key` **conditionally** — only when
`enable_test_hooks` or `web_host` is set
**Result:** ~470ms off the main thread for non-test, non-web launches. Critical **Result:** ~470ms off the main thread for non-test, non-web launches.
because `live_gui` tests launch with `--enable-test-hooks` but the FastAPI work For `live_gui` tests (`--enable-test-hooks`), the warmup loads fastapi
can be deferred until the asyncio loop is ready. during the same startup window, so the hook server is ready when the
process announces readiness.
#### `src/commands.py` and `src/command_palette.py` (command palette lazy) #### `src/commands.py` (command palette warmup-aware)
Top-level today: `from src.command_palette import ...` at `src/commands.py:1`. Top-level today: `from src.command_palette import ...` at `src/commands.py:1`.
After: After:
- Lazy in each `_*_command()` function in `src/commands.py` that actually - **Drop the top-level import.** The command functions call
opens the palette `_require_warmed("src.command_palette")` to access the module
- The CommandRegistry decorator can keep module-level function references, but - The warmup list includes `src.command_palette`
the *body* of the command does the heavy import
**Result:** ~244ms off if user doesn't open palette during the first session. **Result:** ~244ms off the main thread's import chain. The bg thread
warms it during startup; the first `Ctrl+Shift+P` is instant.
#### `src/theme_2.py` and `src/theme_nerv.py` / `src/theme_nerv_fx.py` (NERV theme lazy) #### `src/theme_2.py` (NERV theme warmup-aware)
Top-level today: NERV modules imported at `src/theme_2.py` module top. Top-level today: `from src.theme_nerv import ...`, `from src.theme_nerv_fx import ...`
at the top of `src/theme_2.py`.
After: After:
- Lazy in `apply_nerv_theme()` (the function that activates NERV) - **Drop the top-level imports.** `apply_nerv_theme()` (or the function
- The default theme path stays lean (uses only `src/theme_2.py` + `src/theme_models.py`) that activates NERV) calls `_require_warmed("src.theme_nerv")` and
`_require_warmed("src.theme_nerv_fx")`
- The warmup list includes both NERV modules
**Result:** ~485ms off if user doesn't pick NERV theme (the default path). **Result:** ~485ms off the main thread's import chain (the default
non-NERV path is lean). User pays the cost during startup; theme switch
is instant when they pick NERV.
#### `src/markdown_helper.py` (markdown table lazy) #### `src/markdown_helper.py` (markdown table warmup-aware)
Top-level today: `from src.markdown_table import ...` at `src/markdown_helper.py:1`. Top-level today: `from src.markdown_table import ...` at `src/markdown_helper.py:1`.
After: After:
- Lazy in `_render_table_block()` (or wherever GFM table detection happens) - **Drop the top-level import.** The table-detection branch of `render()`
- The first markdown render that hits a table pays the 250ms; subsequent hits are calls `_require_warmed("src.markdown_table")`
cached in `sys.modules` - The warmup list includes `src.markdown_table`
**Result:** ~250ms off the first markdown render that lacks tables (typical). **Result:** ~250ms off the main thread's import chain. First markdown
table render is instant.
#### `src/imgui_scopes.py`, `src/gui_2.py`, `src/bg_shader.py` (KEEP `imgui_bundle`) #### `src/imgui_scopes.py`, `src/gui_2.py`, `src/bg_shader.py` (KEEP `imgui_bundle`)
These MUST keep `import imgui_bundle` at top — the ImGui render loop is the hot These MUST keep `import imgui_bundle` at top — the ImGui render loop is the
path and needs the module on first frame. There is no way to defer this without hot path and needs the module on first frame. There is no way to defer
breaking the render loop. this without breaking the render loop.
What CAN be deferred inside `src/gui_2.py`: What CAN be deferred inside `src/gui_2.py`:
- `import numpy` (only needed for `bg_shader`; the GUI itself doesn't need numpy - `import numpy` (only needed for `bg_shader`; the GUI itself doesn't
on the first frame) need numpy on the first frame) — move to `_require_warmed("numpy")` in
- Other feature-gated imports the bg shader call site, add `numpy` to the warmup list
- Other feature-gated imports — same pattern
#### `src/gui_2.py` direct heavy imports (audit) #### `src/gui_2.py` direct heavy imports (audit)
We will use AST to audit which `import X` statements at `src/gui_2.py` top-level We will use AST to audit which `import X` statements at `src/gui_2.py`
are reachable from the first-frame render path (`render_main_window`, top-level are reachable from the first-frame render path
`render_main_menu_bar`, etc.) and which are feature-gated. Feature-gated ones (`render_main_window`, `render_main_menu_bar`, etc.) and which are
move inside the function that gates them. feature-gated. First-frame imports stay top-level. Feature-gated ones
move to `_require_warmed(...)` calls at the use site, with the module
added to the warmup list.
### 3.2 Job pool scaffolding ### 3.2 Job pool + warmup scaffolding
New code in `src/app_controller.py`: New code in `src/app_controller.py`:
```python ```python
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
import importlib
import threading
# In AppController.__init__, after the asyncio loop starts: # In AppController.__init__, after the asyncio loop starts:
self._io_pool = ThreadPoolExecutor( self._io_pool = ThreadPoolExecutor(
@@ -332,16 +455,108 @@ self._io_pool = ThreadPoolExecutor(
thread_name_prefix="controller-io", thread_name_prefix="controller-io",
) )
def submit_io(self, fn, *args, **kwargs): # Warmup state
"""Submit a background job to the shared I/O pool. Use this instead of self._warmup_lock = threading.Lock()
threading.Thread for new background work. self._warmup_done_event = threading.Event()
self._warmup_status: dict[str, list[str]] = {
Returns a concurrent.futures.Future. Caller can .result() if they need "pending": [], "completed": [], "failed": [],
to block, or .add_done_callback for fire-and-forget with error handling. }
""" self._warmup_callbacks: list[Callable] = []
return self._io_pool.submit(fn, *args, **kwargs) self._submit_warmup_jobs()
``` ```
`_submit_warmup_jobs()` computes the warmup list and submits one job per
module to the pool:
```python
def _submit_warmup_jobs(self) -> None:
heavy = self._compute_warmup_list()
with self._warmup_lock:
self._warmup_status["pending"] = list(heavy)
self._warmup_status["completed"] = []
self._warmup_status["failed"] = []
self._warmup_done_event.clear()
for name in heavy:
self._io_pool.submit(self._warmup_one, name)
def _compute_warmup_list(self) -> list[str]:
result = [
"google.genai", "anthropic", "openai", "requests",
"src.command_palette",
"src.theme_nerv", "src.theme_nerv_fx",
"src.markdown_table",
"numpy", # used by bg_shader; warmed for first invocation
]
if self._enable_test_hooks or self._web_host:
result.extend(["fastapi", "fastapi.security.api_key"])
return result
```
Each warmup worker imports the module, updates the status, and on the
last one fires the completion callbacks (so the GUI status indicator and
toast notification can react):
```python
def _warmup_one(self, name: str) -> None:
try:
importlib.import_module(name)
with self._warmup_lock:
self._warmup_status["pending"].remove(name)
self._warmup_status["completed"].append(name)
except Exception:
with self._warmup_lock:
self._warmup_status["pending"].remove(name)
self._warmup_status["failed"].append(name)
finally:
with self._warmup_lock:
done = not self._warmup_status["pending"]
cbs = list(self._warmup_callbacks) if done else []
if done:
self._warmup_done_event.set()
for cb in cbs:
try:
cb(dict(self._warmup_status))
except Exception:
pass
```
Public API on `AppController`:
```python
def warmup_status(self) -> dict[str, list[str]]:
"""Snapshot the current warmup state. Cheap (lock-guarded copy)."""
with self._warmup_lock:
return {k: list(v) for k, v in self._warmup_status.items()}
def is_warmup_done(self) -> bool:
return self._warmup_done_event.is_set()
def wait_for_warmup(self, timeout: float | None = None) -> bool:
"""Block until warmup completes. Returns True on done, False on timeout."""
return self._warmup_done_event.wait(timeout=timeout)
def on_warmup_complete(self, callback: Callable[[dict], None]) -> None:
"""Register a callback for warmup completion. If already done, fires immediately."""
with self._warmup_lock:
if self._warmup_done_event.is_set():
snap = {k: list(v) for k, v in self._warmup_status.items()}
if "snap" in dir(): # already done
callback(snap)
else:
with self._warmup_lock:
self._warmup_callbacks.append(callback)
```
Hook API endpoints (added in `src/api_hooks.py`):
- `GET /api/warmup_status``controller.warmup_status()`
- `GET /api/warmup_wait?timeout=N` → blocks until done, returns final status
GUI integration (in `src/gui_2.py`):
- Status bar: "Warming up... (5/8)" while in flight, "All imports ready" + green dot when done. Polled once per frame from `controller.warmup_status()` (cheap, ~microseconds).
- On transition to done: show a toast notification "All providers ready (8 modules)" for 5 seconds.
In `AppController.shutdown()` (or wherever lifecycle cleanup lives): In `AppController.shutdown()` (or wherever lifecycle cleanup lives):
`self._io_pool.shutdown(wait=False)`. Non-blocking because the pool's `self._io_pool.shutdown(wait=False)`. Non-blocking because the pool's
workers are daemon threads and will die with the process anyway. workers are daemon threads and will die with the process anyway.
@@ -381,37 +596,50 @@ Used at every major init step in `AppController.__init__` and `App.__init__`.
- T1.4: Add `scripts/audit_main_thread_imports.py` (static gate) - T1.4: Add `scripts/audit_main_thread_imports.py` (static gate)
- T1.5: Commit baseline + audit script - T1.5: Commit baseline + audit script
### Phase 2: Job Pool Foundation (Day 1) — the "no new threads" rule ### Phase 2: Job Pool + Warmup Foundation (Day 1)
- T2.1 (TDD Red): Write `tests/test_app_controller_io_pool.py` asserting - T2.1 (TDD Red): `tests/test_app_controller_io_pool.py` assert
`AppController` has a `_io_pool: ThreadPoolExecutor` with 4 workers, named `AppController` has a 4-worker `_io_pool` named `controller-io-*`
`controller-io-*` - T2.2 (Green): Add `_io_pool` to `AppController.__init__` with named threads
- T2.2 (Green): Add `self._io_pool = ThreadPoolExecutor(max_workers=4, - T2.3 (TDD Red): `tests/test_warmup_mechanism.py` — assert warmup jobs are
thread_name_prefix="controller-io")` to `AppController.__init__`. Add submitted in `__init__`, complete within 10s, fire the done event, support
`submit_io(fn, *args)` helper. Wire shutdown into `controller.shutdown()`. callbacks, don't block init
- T2.3: Verify T2.1 tests pass + full suite still passes - T2.4 (Green): Implement `_submit_warmup_jobs()`, `_compute_warmup_list()`,
`_warmup_one()`, `warmup_status()`, `is_warmup_done()`, `wait_for_warmup()`,
`on_warmup_complete()` per spec §3.2
- T2.5: Run T2.1 + T2.3 tests, confirm PASS
- T2.6: Commit
### Phase 3: Lazy-load AI provider SDKs (Day 2) ### Phase 3: Remove top-level heavy SDK imports from `src/ai_client.py` (Day 2)
- T3.1 (TDD Red): Write `tests/test_ai_client_lazy_imports.py` asserting - T3.1 (TDD Red): `tests/test_ai_client_no_top_level_sdk_imports.py` assert
`import src.ai_client` does NOT import any provider SDK `import src.ai_client` does NOT load `google.genai` / `anthropic` / `openai` /
- T3.2 (Green): Move `from google import genai` / `import anthropic` / `requests` (warmup hasn't run in the subprocess)
`import openai` / `import requests` into their respective `_send_*` functions - T3.2 (Green): Remove the four heavy imports from the top of `ai_client.py`.
- T3.3: Verify existing `tests/test_ai_client.py` still passes Add `_require_warmed(name)` helper. Each `_send_*` uses
- T3.4: Commit, re-run benchmark, expect `import src.ai_client` < 50ms `_require_warmed("google.genai")` etc.
- T3.3: Run existing `tests/test_ai_client.py`; fix any breakage (tests
relying on top-level import side effects need a fixture that warms or a
fallback for test mode)
- T3.4: Confirm T3.1 tests PASS
- T3.5: Commit
### Phase 4: Lazy-load FastAPI in `HookServer` (Day 2) ### Phase 4: Remove top-level FastAPI imports from `src/api_hooks.py` (Day 2)
- T4.1 (TDD Red): Write `tests/test_hook_server_lazy_fastapi.py` asserting - T4.1 (TDD Red): `tests/test_hook_server_no_top_level_fastapi.py` assert
`from src.api_hooks import HookServer` does NOT import fastapi `from src.api_hooks import HookServer` does NOT import fastapi
- T4.2 (Green): Move `from fastapi import ...` inside the methods that need them - T4.2 (Green): Remove the fastapi imports from top. Use `_require_warmed`
- T4.3: Verify existing `tests/test_api_hooks.py` still passes inside the methods that need them
- T4.3: Run existing `tests/test_api_hooks.py`; fix
- T4.4: Commit - T4.4: Commit
### Phase 5: Lazy-load feature-gated GUI modules (Day 3) ### Phase 5: Remove top-level imports for feature-gated GUI modules (Day 3)
- T5.1: Lazy-load `src.command_palette` in `src/commands.py` - T5A: Command Palette`tests/test_command_palette_no_top_level_import.py`
- T5.2: Lazy-load `src.theme_nerv` and `src.theme_nerv_fx` in `src/theme_2.py` + remove from `src/commands.py` + use `_require_warmed("src.command_palette")`
- T5.3: Lazy-load `src.markdown_table` in `src/markdown_helper.py` - T5B: NERV Theme — `tests/test_theme_nerv_no_top_level_import.py` + remove
- T5.4: Audit and lazy-load feature-gated imports in `src/gui_2.py` from `src/theme_2.py` + use `_require_warmed("src.theme_nerv")` etc.
- T5.5: Run all GUI tests; fix any circular imports - T5C: Markdown Table — `tests/test_markdown_helper_no_top_level_import.py` +
- T5.6: Commit per task remove from `src/markdown_helper.py` + use `_require_warmed("src.markdown_table")`
- T5D: GUI feature-gated — audit `src/gui_2.py` via the T1.2 script, apply
same pattern. `numpy` migrates to `_require_warmed` in `bg_shader` call site.
- T5E: Commit per module (4 atomic commits)
### Phase 6: Migrate ad-hoc threads to `_io_pool` (Day 4) ### Phase 6: Migrate ad-hoc threads to `_io_pool` (Day 4)
- T6.1: Audit: `grep -rn "threading.Thread(" src/` to find all ad-hoc - T6.1: Audit: `grep -rn "threading.Thread(" src/` to find all ad-hoc
@@ -419,30 +647,49 @@ Used at every major init step in `AppController.__init__` and `App.__init__`.
- T6.2: Refactor each ad-hoc thread to use `controller.submit_io(fn)` instead - T6.2: Refactor each ad-hoc thread to use `controller.submit_io(fn)` instead
- T6.3: Per-migration commit - T6.3: Per-migration commit
- T6.4: Final `grep -rn "threading.Thread(" src/` shows ZERO new spawns - T6.4: Final `grep -rn "threading.Thread(" src/` shows ZERO new spawns
(the grep result should be identical to the T6.1 audit list, no new entries)
### Phase 7: Enforcement — Runtime Audit Hook (Day 4) ### Phase 7: Warmup Notification (Hook API + GUI) (Day 4)
- T7.1 (TDD Red): `tests/test_main_thread_purity.py` — spawn `sloppy.py - T7A.1 (TDD Red): `tests/test_api_hooks_warmup.py`assert
`GET /api/warmup_status` and `GET /api/warmup_wait` work
- T7A.2 (Green): Add the two endpoints in `src/api_hooks.py` and register
`warmup_status` in `_gettable_fields`
- T7B.1: In `src/gui_2.py`, add a status-bar indicator that polls
`controller.warmup_status()` each frame: "Warming up... (N/M)" while
pending, "All imports ready" with green dot on completion
- T7B.2: Register a callback via `controller.on_warmup_complete(cb)` that
shows a toast "All providers ready (M modules)" on success
- T7B.3: Update docs (status bar, toast, hook API)
- T7B.4: Commit
### Phase 8: Enforcement — Runtime Audit Hook (Day 4)
- T8.1 (TDD Red): `tests/test_main_thread_purity.py` — spawn `sloppy.py
--headless --enable-test-hooks` with a `sys.addaudithook` shim, verify no --headless --enable-test-hooks` with a `sys.addaudithook` shim, verify no
heavy import happens on the main thread heavy import happens on the main thread
- T7.2: Once Phase 3-5 land, this test should start passing. Wire into CI. - T8.2: Once Phase 3-5 land, this test should start passing. Wire into CI
- T7.3: Commit as a gating test (`@pytest.mark.slow`).
- T8.3: Commit
### Phase 8: Hook API + Diagnostics (Day 5)
- T8.1: Add `/api/startup_profile` endpoint
- T8.2: Add `/api/io_pool_status` endpoint
- T8.3: Add to `_gettable_fields` and the Diagnostics panel
- T8.4: Document in `docs/guide_api_hooks.md`
- T8.5: Tests + commit
### Phase 9: Verify + Checkpoint (Day 5) ### Phase 9: Verify + Checkpoint (Day 5)
- T9.1: Re-run `scripts/benchmark_imports.py`; confirm `import src.gui_2` and - T9.1: Re-run `scripts/benchmark_imports.py --runs=3`; confirm
`import src.ai_client` are now < 100ms each `import src.ai_client` < 50ms, `import src.gui_2` < 500ms,
`import src.app_controller` < 300ms
- T9.2: Re-run `scripts/audit_main_thread_imports.py`; exit 0 - T9.2: Re-run `scripts/audit_main_thread_imports.py`; exit 0
- T9.3: Run `tests/test_main_thread_purity.py`; pass - T9.3: Run `tests/test_warmup_mechanism.py`; warmup completes and notifications fire
- T9.4: Run full `live_gui` test batch; `wait_for_server(timeout=15)` no - T9.4: Run `tests/test_main_thread_purity.py`; pass
longer times out - T9.5: Run full `live_gui` test batch; `wait_for_server(timeout=15)` no
- T9.5: Manual smoke test: `uv run sloppy.py` and longer times out. Tests can call `controller.wait_for_warmup()` before
exercising warmup-dependent functionality.
- T9.6: Manual smoke:
- `uv run sloppy.py`: time-to-first-frame < 1.5s, observe status indicator
"Warming up... (N/M)" → "All imports ready" + toast
- `uv run sloppy.py --enable-test-hooks`: same, plus `/api/warmup_status`
returns `completed` after a brief wait
- `uv run sloppy.py --headless`: time-to-server-ready
- **Provider switch test**: switch from MiniMax to Gemini in the GUI
after warmup. The action must be INSTANT, not 1s-delayed (proves
warmup did its job)
- T9.7: Phase checkpoint commit + git note with full verification report
- T9.8: Update `conductor/tracks.md`; archive track
`uv run sloppy.py --enable-test-hooks` both feel snappier `uv run sloppy.py --enable-test-hooks` both feel snappier
- T9.6: Phase checkpoint commit with full verification report - T9.6: Phase checkpoint commit with full verification report
@@ -486,9 +733,22 @@ The track is complete when:
- [ ] `scripts/audit_main_thread_imports.py` exits 0 (no heavy imports on main) - [ ] `scripts/audit_main_thread_imports.py` exits 0 (no heavy imports on main)
- [ ] `tests/test_main_thread_purity.py` passes (runtime audit hook confirms invariant) - [ ] `tests/test_main_thread_purity.py` passes (runtime audit hook confirms invariant)
- [ ] `scripts/benchmark_imports.py` shows no new red entries in the top-20 - [ ] `scripts/benchmark_imports.py` shows no new red entries in the top-20
- [ ] First AI call latency on the asyncio thread is < 1500ms (pays the SDK load once, - [ ] **`controller.wait_for_warmup(timeout=10.0)` returns True** — warmup completed
then the user has a snappy first call forever after). Main thread sees ZERO within 10s of `AppController.__init__`
of this cost. - [ ] **All modules in the warmup list are in `sys.modules` after warmup** —
`controller.warmup_status()['pending']` is empty, `'completed'` contains
all expected module names
- [ ] **User-triggered actions on warmed modules are instant** — manual test
switching providers (e.g. MiniMax → Gemini) after warmup completes shows
NO perceptible lag (was ~1s with lazy-loading)
- [ ] **GUI status indicator transitions** — observe "Warming up... (N/M)" in
the status bar, then "All imports ready" with green dot, then a toast
notification fires via `controller.on_warmup_complete(...)`
- [ ] **Hook API exposes warmup state** — `GET /api/warmup_status` returns
`{pending: [], completed: [...], failed: []}`; `GET /api/warmup_wait?timeout=10`
returns the final state
- [ ] **NO `import X` statements inside function bodies for heavy modules** —
verified by `grep -rn "^\s*import \(google\|anthropic\|openai\|fastapi\|src\.command_palette\|src\.theme_nerv\|src\.markdown_table\)" src/`
- [ ] No regressions in the existing 272/273 passing tests - [ ] No regressions in the existing 272/273 passing tests
- [ ] `grep -rn "threading.Thread(" src/` shows ZERO new spawns after Phase 6 - [ ] `grep -rn "threading.Thread(" src/` shows ZERO new spawns after Phase 6
migration (only the existing project scaffolding threads like `HookServer` migration (only the existing project scaffolding threads like `HookServer`
@@ -505,9 +765,8 @@ The track is complete when:
- Importing on the main thread for the lean `gui_2` skeleton (~300ms unavoidable) - Importing on the main thread for the lean `gui_2` skeleton (~300ms unavoidable)
- `pydantic` lazy loading (used by `src/models.py` which is imported by 16 files; - `pydantic` lazy loading (used by `src/models.py` which is imported by 16 files;
the cost is already amortized and deferring it would cascade) the cost is already amortized and deferring it would cascade)
- Prefetch / warm-up of the heavy SDKs in the background (Layer 3 in §2.2 is - Lazy-loading heavy modules in function bodies (Layer 1 in §2.2 — explicitly
deliberately the "do nothing" layer; the user pays the import cost once on rejected by the user; warmup is the only mechanism)
first use, on the asyncio thread, not in the background)
--- ---
@@ -10,13 +10,13 @@ last_updated = "2026-06-06"
[phases] [phases]
phase_1 = { status = "in_progress", checkpoint_sha = "", name = "Audit + Benchmark + Foundation" } phase_1 = { status = "in_progress", checkpoint_sha = "", name = "Audit + Benchmark + Foundation" }
phase_2 = { status = "pending", checkpoint_sha = "", name = "Job Pool Foundation (no new threads)" } phase_2 = { status = "pending", checkpoint_sha = "", name = "Job Pool + Warmup Foundation" }
phase_3 = { status = "pending", checkpoint_sha = "", name = "Lazy-load AI provider SDKs" } phase_3 = { status = "pending", checkpoint_sha = "", name = "Remove top-level SDK imports (ai_client)" }
phase_4 = { status = "pending", checkpoint_sha = "", name = "Lazy-load FastAPI in HookServer" } phase_4 = { status = "pending", checkpoint_sha = "", name = "Remove top-level FastAPI imports" }
phase_5 = { status = "pending", checkpoint_sha = "", name = "Lazy-load feature-gated GUI modules" } phase_5 = { status = "pending", checkpoint_sha = "", name = "Remove top-level feature-gated GUI imports" }
phase_6 = { status = "pending", checkpoint_sha = "", name = "Migrate ad-hoc threads to _io_pool" } phase_6 = { status = "pending", checkpoint_sha = "", name = "Migrate ad-hoc threads to _io_pool" }
phase_7 = { status = "pending", checkpoint_sha = "", name = "Enforcement: runtime audit hook" } phase_7 = { status = "pending", checkpoint_sha = "", name = "Warmup Notification (Hook API + GUI)" }
phase_8 = { status = "pending", checkpoint_sha = "", name = "Hook API + Diagnostics" } phase_8 = { status = "pending", checkpoint_sha = "", name = "Enforcement: runtime audit hook" }
phase_9 = { status = "pending", checkpoint_sha = "", name = "Verify + Checkpoint" } phase_9 = { status = "pending", checkpoint_sha = "", name = "Verify + Checkpoint" }
[tasks] [tasks]
@@ -26,42 +26,41 @@ t1_2 = { status = "pending", commit_sha = "", description = "Audit src/gui_2.py
t1_3 = { status = "pending", commit_sha = "", description = "Add StartupProfiler and instrument init" } t1_3 = { status = "pending", commit_sha = "", description = "Add StartupProfiler and instrument init" }
t1_4 = { status = "pending", commit_sha = "", description = "Write scripts/audit_main_thread_imports.py (static CI gate)" } t1_4 = { status = "pending", commit_sha = "", description = "Write scripts/audit_main_thread_imports.py (static CI gate)" }
t1_5 = { status = "pending", commit_sha = "", description = "Commit baseline + audit script" } t1_5 = { status = "pending", commit_sha = "", description = "Commit baseline + audit script" }
# Phase 2: Job Pool Foundation # Phase 2: Job Pool + Warmup Foundation
t2_1 = { status = "pending", commit_sha = "", description = "Red: tests/test_app_controller_io_pool.py" } t2_1 = { status = "pending", commit_sha = "", description = "Red: tests/test_app_controller_io_pool.py" }
t2_2 = { status = "pending", commit_sha = "", description = "Green: add _io_pool ThreadPoolExecutor + submit_io helper to AppController" } t2_2 = { status = "pending", commit_sha = "", description = "Green: add _io_pool ThreadPoolExecutor to AppController" }
t2_3 = { status = "pending", commit_sha = "", description = "Confirm T2.1 tests pass + full suite still passes" } t2_3 = { status = "pending", commit_sha = "", description = "Red: tests/test_warmup_mechanism.py" }
t2_4 = { status = "pending", commit_sha = "", description = "Commit T2" } t2_4 = { status = "pending", commit_sha = "", description = "Green: implement _submit_warmup_jobs, _compute_warmup_list, _warmup_one, warmup_status, is_warmup_done, wait_for_warmup, on_warmup_complete" }
# Phase 3: Lazy-load AI Provider SDKs t2_5 = { status = "pending", commit_sha = "", description = "Confirm T2.1 + T2.3 tests pass" }
t3_1 = { status = "pending", commit_sha = "", description = "Red: tests/test_ai_client_lazy_imports.py" } t2_6 = { status = "pending", commit_sha = "", description = "Commit T2" }
t3_2 = { status = "pending", commit_sha = "", description = "Green: move provider SDK imports into _send_* funcs" } # Phase 3: Remove top-level SDK imports
t3_1 = { status = "pending", commit_sha = "", description = "Red: tests/test_ai_client_no_top_level_sdk_imports.py" }
t3_2 = { status = "pending", commit_sha = "", description = "Green: remove top-level SDK imports from src/ai_client.py; add _require_warmed helper; update _send_* to use it" }
t3_3 = { status = "pending", commit_sha = "", description = "Fix existing test_ai_client.py breakage" } t3_3 = { status = "pending", commit_sha = "", description = "Fix existing test_ai_client.py breakage" }
t3_4 = { status = "pending", commit_sha = "", description = "Confirm T3.1 tests PASS" } t3_4 = { status = "pending", commit_sha = "", description = "Confirm T3.1 tests PASS" }
t3_5 = { status = "pending", commit_sha = "", description = "Commit T3" } t3_5 = { status = "pending", commit_sha = "", description = "Commit T3" }
t3_6 = { status = "pending", commit_sha = "", description = "Update tracks.md T3 row" } t3_6 = { status = "pending", commit_sha = "", description = "Update tracks.md T3 row" }
# Phase 4: Lazy-load FastAPI # Phase 4: Remove top-level FastAPI imports
t4_1 = { status = "pending", commit_sha = "", description = "Red: tests/test_hook_server_lazy_fastapi.py" } t4_1 = { status = "pending", commit_sha = "", description = "Red: tests/test_hook_server_no_top_level_fastapi.py" }
t4_2 = { status = "pending", commit_sha = "", description = "Green: move fastapi imports into HookServer methods" } t4_2 = { status = "pending", commit_sha = "", description = "Green: remove fastapi imports from src/api_hooks.py; use _require_warmed" }
t4_3 = { status = "pending", commit_sha = "", description = "Fix existing test_api_hooks.py breakage" } t4_3 = { status = "pending", commit_sha = "", description = "Fix existing test_api_hooks.py breakage" }
t4_4 = { status = "pending", commit_sha = "", description = "Confirm T4.1 tests PASS" } t4_4 = { status = "pending", commit_sha = "", description = "Confirm T4.1 tests PASS" }
t4_5 = { status = "pending", commit_sha = "", description = "Commit T4" } t4_5 = { status = "pending", commit_sha = "", description = "Commit T4" }
# Phase 5A: Command Palette # Phase 5: Remove top-level feature-gated GUI imports
t5a_1 = { status = "pending", commit_sha = "", description = "Red: tests/test_command_palette_lazy.py" } t5a_1 = { status = "pending", commit_sha = "", description = "Red: tests/test_command_palette_no_top_level_import.py" }
t5a_2 = { status = "pending", commit_sha = "", description = "Green: lazy-load in src/commands.py" } t5a_2 = { status = "pending", commit_sha = "", description = "Green: remove from src/commands.py; use _require_warmed" }
t5a_3 = { status = "pending", commit_sha = "", description = "Fix existing test_command_palette.py" } t5a_3 = { status = "pending", commit_sha = "", description = "Fix existing test_command_palette.py" }
t5a_4 = { status = "pending", commit_sha = "", description = "Commit T5A" } t5a_4 = { status = "pending", commit_sha = "", description = "Commit T5A" }
# Phase 5B: NERV Theme t5b_1 = { status = "pending", commit_sha = "", description = "Red: tests/test_theme_nerv_no_top_level_import.py" }
t5b_1 = { status = "pending", commit_sha = "", description = "Red: tests/test_theme_nerv_lazy.py" } t5b_2 = { status = "pending", commit_sha = "", description = "Green: remove from src/theme_2.py; use _require_warmed" }
t5b_2 = { status = "pending", commit_sha = "", description = "Green: lazy-load in src/theme_2.py" }
t5b_3 = { status = "pending", commit_sha = "", description = "Fix existing test_theme_2.py + test_theme_nerv.py" } t5b_3 = { status = "pending", commit_sha = "", description = "Fix existing test_theme_2.py + test_theme_nerv.py" }
t5b_4 = { status = "pending", commit_sha = "", description = "Commit T5B" } t5b_4 = { status = "pending", commit_sha = "", description = "Commit T5B" }
# Phase 5C: Markdown Table t5c_1 = { status = "pending", commit_sha = "", description = "Red: tests/test_markdown_helper_no_top_level_import.py" }
t5c_1 = { status = "pending", commit_sha = "", description = "Red: tests/test_markdown_helper_lazy.py" } t5c_2 = { status = "pending", commit_sha = "", description = "Green: remove from src/markdown_helper.py; use _require_warmed" }
t5c_2 = { status = "pending", commit_sha = "", description = "Green: lazy-load in src/markdown_helper.py" }
t5c_3 = { status = "pending", commit_sha = "", description = "Fix existing test_markdown_helper.py" } t5c_3 = { status = "pending", commit_sha = "", description = "Fix existing test_markdown_helper.py" }
t5c_4 = { status = "pending", commit_sha = "", description = "Commit T5C" } t5c_4 = { status = "pending", commit_sha = "", description = "Commit T5C" }
# Phase 5D: gui_2 feature-gated imports
t5d_1 = { status = "pending", commit_sha = "", description = "Run audit_gui2_imports.py and collect feature-gated list" } t5d_1 = { status = "pending", commit_sha = "", description = "Run audit_gui2_imports.py and collect feature-gated list" }
t5d_2 = { status = "pending", commit_sha = "", description = "Apply TDD pattern per feature-gated import" } t5d_2 = { status = "pending", commit_sha = "", description = "Apply same pattern per feature-gated import in src/gui_2.py; numpy in bg_shader" }
t5d_3 = { status = "pending", commit_sha = "", description = "Run full GUI test suite; fix" } t5d_3 = { status = "pending", commit_sha = "", description = "Run full GUI test suite; fix" }
t5d_4 = { status = "pending", commit_sha = "", description = "Commit per feature group" } t5d_4 = { status = "pending", commit_sha = "", description = "Commit per feature group" }
# Phase 6: Migrate ad-hoc threads # Phase 6: Migrate ad-hoc threads
@@ -69,26 +68,29 @@ t6_1 = { status = "pending", commit_sha = "", description = "Audit threading.Thr
t6_2 = { status = "pending", commit_sha = "", description = "Refactor each ad-hoc thread to use controller.submit_io" } t6_2 = { status = "pending", commit_sha = "", description = "Refactor each ad-hoc thread to use controller.submit_io" }
t6_3 = { status = "pending", commit_sha = "", description = "Run full test suite; fix" } t6_3 = { status = "pending", commit_sha = "", description = "Run full test suite; fix" }
t6_4 = { status = "pending", commit_sha = "", description = "Commit per migration; final grep shows zero new spawns" } t6_4 = { status = "pending", commit_sha = "", description = "Commit per migration; final grep shows zero new spawns" }
# Phase 7: Enforcement - Runtime Audit Hook # Phase 7: Warmup Notification (Hook API + GUI)
t7_1 = { status = "pending", commit_sha = "", description = "Red: tests/test_main_thread_purity.py" } t7a_1 = { status = "pending", commit_sha = "", description = "Red: tests/test_api_hooks_warmup.py" }
t7_2 = { status = "pending", commit_sha = "", description = "Confirm test passes after Phase 3-5" } t7a_2 = { status = "pending", commit_sha = "", description = "Green: add /api/warmup_status and /api/warmup_wait endpoints" }
t7_3 = { status = "pending", commit_sha = "", description = "Wire into CI as @pytest.mark.slow gating test" } t7a_3 = { status = "pending", commit_sha = "", description = "Register warmup_status in _gettable_fields" }
t7_4 = { status = "pending", commit_sha = "", description = "Commit T7" } t7a_4 = { status = "pending", commit_sha = "", description = "Commit T7A" }
# Phase 8: Hook API + Diagnostics t7b_1 = { status = "pending", commit_sha = "", description = "Add status-bar indicator in src/gui_2.py that polls warmup_status each frame" }
t8_1 = { status = "pending", commit_sha = "", description = "Add /api/startup_profile endpoint" } t7b_2 = { status = "pending", commit_sha = "", description = "Register on_warmup_complete callback that shows toast on success" }
t8_2 = { status = "pending", commit_sha = "", description = "Add /api/io_pool_status endpoint" } t7b_3 = { status = "pending", commit_sha = "", description = "Update docs for status bar + hook API" }
t8_3 = { status = "pending", commit_sha = "", description = "Add startup profile + io_pool status to Diagnostics panel" } t7b_4 = { status = "pending", commit_sha = "", description = "Commit T7B" }
t8_4 = { status = "pending", commit_sha = "", description = "Update docs/guide_api_hooks.md" } # Phase 8: Enforcement - Runtime Audit Hook
t8_5 = { status = "pending", commit_sha = "", description = "Tests for endpoints + profiler round-trip" } t8_1 = { status = "pending", commit_sha = "", description = "Red: tests/test_main_thread_purity.py" }
t8_6 = { status = "pending", commit_sha = "", description = "Commit T8" } t8_2 = { status = "pending", commit_sha = "", description = "Confirm test passes after Phase 3-5" }
t8_3 = { status = "pending", commit_sha = "", description = "Wire into CI as @pytest.mark.slow gating test" }
t8_4 = { status = "pending", commit_sha = "", description = "Commit T8" }
# Phase 9: Verify + Checkpoint # Phase 9: Verify + Checkpoint
t9_1 = { status = "pending", commit_sha = "", description = "Re-run benchmark; diff vs baseline" } t9_1 = { status = "pending", commit_sha = "", description = "Re-run benchmark; diff vs baseline" }
t9_2 = { status = "pending", commit_sha = "", description = "Re-run audit_main_thread_imports.py; exit 0" } t9_2 = { status = "pending", commit_sha = "", description = "Re-run audit_main_thread_imports.py; exit 0" }
t9_3 = { status = "pending", commit_sha = "", description = "Run test_main_thread_purity.py; pass" } t9_3 = { status = "pending", commit_sha = "", description = "Run test_warmup_mechanism.py; warmup completes and notifications fire" }
t9_4 = { status = "pending", commit_sha = "", description = "Run live_gui test batch; confirm wait_for_server passes" } t9_4 = { status = "pending", commit_sha = "", description = "Run test_main_thread_purity.py; pass" }
t9_5 = { status = "pending", commit_sha = "", description = "Manual smoke (normal, test-hooks, headless modes)" } t9_5 = { status = "pending", commit_sha = "", description = "Run live_gui test batch; confirm wait_for_server passes" }
t9_6 = { status = "pending", commit_sha = "", description = "Phase checkpoint commit + git note" } t9_6 = { status = "pending", commit_sha = "", description = "Manual smoke: provider-switch is INSTANT after warmup" }
t9_7 = { status = "pending", commit_sha = "", description = "Update tracks.md; archive track" } t9_7 = { status = "pending", commit_sha = "", description = "Phase checkpoint commit + git note" }
t9_8 = { status = "pending", commit_sha = "", description = "Update tracks.md; archive track" }
[verification] [verification]
# To be filled at Phase 9 # To be filled at Phase 9
@@ -98,13 +100,31 @@ baseline_gui_2_ms = 0
after_gui_2_ms = 0 after_gui_2_ms = 0
baseline_app_controller_ms = 0 baseline_app_controller_ms = 0
after_app_controller_ms = 0 after_app_controller_ms = 0
warmup_completes_within_seconds = 0
warmup_modules_in_sys_modules = 0
provider_switch_latency_ms_after_warmup = 0
live_gui_passed = 0 live_gui_passed = 0
live_gui_failed = 0 live_gui_failed = 0
audit_main_thread_violations = 0 audit_main_thread_violations = 0
io_pool_max_workers = 4 io_pool_max_workers = 4
io_pool_thread_name_prefix = "controller-io" io_pool_thread_name_prefix = "controller-io"
new_threading_thread_calls = 0 new_threading_thread_calls = 0
function_body_heavy_imports = 0
[ad_hoc_threads] [ad_hoc_threads]
# Filled in Phase 6 T6.1 audit # Filled in Phase 6 T6.1 audit
# Format: {file = "src/foo.py", line = 42, current_target = "lambda", proposed_target = "controller.submit_io(...)"} # Format: {file = "src/foo.py", line = 42, current_target = "lambda", proposed_target = "controller.submit_io(...)"}
[warmup_list]
# Filled in Phase 2 T2.4 implementation
google_genai = true
anthropic = true
openai = true
requests = true
src_command_palette = true
src_theme_nerv = true
src_theme_nerv_fx = true
src_markdown_table = true
numpy = true
fastapi = "conditional" # only when enable_test_hooks or web_host
fastapi_security_api_key = "conditional"