Repository navigation
Make local Karma fast: normal-priority autostart, git cache, subagent parse cache, production frontend - #112
Conversation
The login item set ProcessType=Background, LowPriorityIO and Nice 5 to keep the login-time compile polite, but the API and frontend it spawns are long-lived children that inherit that QoS for as long as they run. Measured on a copy of a real index: /projects 9.7 s vs 2.4 s, a 14 MB session's subagents 4.4 s vs 0.6 s, the Projects page first load 79.5 s vs 3.3 s. - New login items carry none of the three keys - The launcher repairs an existing login item in place on every macOS launch, so upgrades pick it up at the next login without reinstalling
/projects ran up to two git subprocesses per existing project on every request (~140 for this index, 2.3 s at normal priority), and every index pass ran a git config subprocess per project. - resolve_git_root asks git once (--show-superproject-working-tree and --show-toplevel together; a submodule's superproject comes first) - Git roots and identities are cached per path for 10 minutes behind a lock; timeouts and missing dirs are not cached - The index pass resolves each project's git root, so /projects reads a warm cache - Tests start from a cold cache via an autouse fixture
A session page fires its API calls in parallel, and file-activity, tools and subagents each re-read every subagent file (subagents twice, counting the message-count pass). For the largest local session (108 subagents) that was ~0.68 s per collector pass plus ~0.94 s for subagent info. - One cached digest per subagent file, keyed by path, mtime and size, holds tool/skill/command counts, file operations, first prompt and message count - Concurrent requests wait for a single parse instead of racing - Finished subagents are never re-read; a growing one is re-read only when it changes, which also helps live-session polling - Callers get copies of the counters, so they cannot alter the cached digest - Drop ToolCall and SessionData.tool_calls: built for every tool use, read nowhere
The launcher ran `npm run dev`, so every page compiled on its first request (79 s for the Projects page under the old background priority, 3.3 s without) and the vite compiler stayed resident. It now serves `node build`. - frontend.py fingerprints the build inputs (src, static, package and config files, .env*) and rebuilds only when they change; the build is written to a staging dir via KARMA_BUILD_OUT and swapped in once complete, so a failed or interrupted build never replaces a working one - Any launch rebuilds a stale build; a running build server is restarted only when its port listener matches the process group the launcher recorded (lsof), so dev servers and other checkouts are never touched. Windows can't prove ownership and rebuilds at the next cold start - A failed cold build falls back to the vite dev server; --dev forces it - node build binds localhost:5180, never node's default 0.0.0.0 - The spawn lock's stale threshold now covers the build timeout - core: shared child startup, run_logged (kills the tree on timeout), stop_process_tree, port_listener_pids, wait_for_port_closed
… kills - Build into staging (prepare), stop the old server, then swap (activate) and start; a node server lazily loads chunks from build/, so swapping under a live server could serve a mix of two builds. If the old server won't release the port, keep it and discard the staged build - A dev-server fallback the launcher started is now replaceable, so one failed build no longer pins the slow dev server until reboot - A running frontend no longer needs the build toolchain to pass preflight - A timed-out build is killed with SIGKILL (Windows: taskkill /F), so a child ignoring SIGTERM can't hold the launcher and its spawn lock - A failed stamp write is a failed build instead of an uncaught error - A launch that lost the lock waits long enough for the owner's build - _start_and_record replaces four start-then-record pairs - Git caches share GIT_LOOKUP_TTL_SECONDS; docstrings say "not a repo" is cached; owned-record test skips Windows (no os.getpgid) - CI path filter covers every build fingerprint input
…te launcher tests - README, SETUP, settings guide and the installer's message describe the one-off build (about half a minute) instead of a minute-long dev compile, the --dev flag, and the measured idle memory (~200-250 MB: API ~190 MB, node build ~40 MB; the vite dev server it replaces grew past 1 GB) - README notes that older login items are repaired automatically - frontend/CLAUDE.md explains KARMA_BUILD_OUT - Launcher tests write logs, locks and the web-server record to a temp dir instead of the real ~/.claude_karma/launcher
JayantDevkar
left a comment
There was a problem hiding this comment.
Inline context for reviewers, one note per change area.
| "ProcessType": "Background", | ||
| "LowPriorityIO": True, | ||
| "Nice": 5, | ||
| # No ProcessType/LowPriorityIO/Nice: the servers inherit them for life. |
There was a problem hiding this comment.
These keys were meant for the login-time compile, but the API and frontend are long-lived children of the job and kept background QoS for their whole life. Reproduced with taskpolicy -b: the same /projects code took 18–28 s throttled vs 2.1 s normal, while nice -n 5 alone changed nothing.
| _THROTTLE_KEYS = ("ProcessType", "LowPriorityIO", "Nice") | ||
|
|
||
|
|
||
| def repair_autostart_priority() -> bool: |
There was a problem hiding this comment.
Only the plist file is rewritten, with no launchctl reload: the launcher calling this may itself be running inside the job, and unloading it would kill that launch. launchd reads the file at the next login.
| ) | ||
| if result.returncode != 0: | ||
| return None | ||
| # A submodule prints its superproject first; any other repo prints only its toplevel |
There was a problem hiding this comment.
Checked against real repos: with both flags git prints the superproject first for a submodule, and only the toplevel for anything else, so the first line is always the right answer and one subprocess replaces two.
| return (str(path), stat.st_mtime_ns, stat.st_size) | ||
|
|
||
|
|
||
| def _subagent_digest(subagent) -> _SubagentDigest: |
There was a problem hiding this comment.
The session page fires its requests in parallel, so a plain cache would miss in all of them at once and parse in parallel. The per-key lock makes concurrent callers wait for one parse; test_concurrent_requests_parse_each_file_once covers it.
| _start_and_record(root, port, "dev") | ||
| return | ||
|
|
||
| # Stop the old server before the swap: it lazily loads chunks from build/. |
There was a problem hiding this comment.
Order matters: node build lazily imports server chunks from build/, so swapping under a live server could serve a mix of two builds. If the old server won't release the port within 15 s, the staged build is discarded and the old server stays up.
| _record_web_server(start_web(root, port, dev=mode == "dev").pid, mode, port) | ||
|
|
||
|
|
||
| def _owned_web_server(port: int) -> tuple[int, str] | None: |
There was a problem hiding this comment.
Restart only touches a server whose port listener's process group matches the one the launcher recorded, so a hand-run npm run dev or another checkout's server is never killed. Windows has no getpgid, so it never restarts there and rebuilds at the next cold start.
| cwd=root / "frontend", | ||
| log_path=core.log_dir() / "web.log", | ||
| # Same address as vite dev; node's default host would be 0.0.0.0. | ||
| env={"PORT": str(port), "HOST": "localhost"}, |
There was a problem hiding this comment.
adapter-node defaults to HOST=0.0.0.0, which would put the dashboard on the network. localhost matches what vite dev bound, so the API's CORS entries and the launcher's port probe work unchanged.
…t import cachetools
|
@the-non-expert heads up: merged before your review so users get the speedup. The inline notes explain the load-bearing lines; if anything looks off, comment here or open an issue and I'll follow up in a new PR. |
Summary
The dashboard had become 6–1,500x slower since autostart was turned on. The macOS login item ran the API and frontend as a throttled background task (
ProcessType=Background,LowPriorityIO,Nice 5), and the servers it spawns inherit that for as long as they run. Three code costs then got multiplied by the throttling:/projectsran ~140 git processes per request, session pages re-parsed every subagent file 4–6 times, and the frontend was the vite dev server compiling pages on first request.Changes
git rev-parsecall per path instead of two, cached 10 minutes; the indexer warms the cache, so/projectsnever waits on git.ToolCallrecords removed.node buildon localhost:5180. It fingerprints the build inputs, builds into a staging dir (KARMA_BUILD_OUT), stops the old server, swaps, and restarts only a server it can prove it started. A failed cold build falls back to vite dev;--devforces it.Component
scripts/karma_desktop)Type
Testing
Follow-ups
api/parallel.pykeeps a third per-subagent loop that could use the digest cache.git pull(unchanged from main).Related Issues
None filed.