Skip to content

Make local Karma fast: normal-priority autostart, git cache, subagent parse cache, production frontend - #112

Merged
JayantDevkar merged 7 commits into
mainfrom
perf/fast-local-servers
Oct 12, 2026
Merged

JayantDevkar merged 7 commits into
mainfrom
perf/fast-local-servers

Conversation

@JayantDevkar

Copy link
Copy Markdown
Owner

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: /projects ran ~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

  • Autostart priority (macOS): new login items carry none of the three keys; the launcher repairs an existing one in place on every launch, effective at the next login.
  • Git lookups: one git rev-parse call per path instead of two, cached 10 minutes; the indexer warms the cache, so /projects never waits on git.
  • Session pages: one cached digest per subagent file keyed by path, mtime and size; concurrent requests wait for a single parse. Dead ToolCall records removed.
  • Production frontend: the launcher serves node build on 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; --dev forces it.
  • Docs: README, SETUP, settings guide and installer text.

Component

  • API (FastAPI backend)
  • Frontend (SvelteKit dashboard)
  • Documentation
  • CI / Build
  • Desktop launcher (scripts/karma_desktop)

Type

  • Performance improvement

Testing

  • API tests pass: 1928 passed, 6 skipped (HOME isolated); top-level suite also run without FastAPI, as CI does: 1373 passed
  • Frontend type-checks (0 errors), lints clean (0 errors), vitest 274 passed
  • Launcher: 94 passed, 4 skipped; ruff check/format clean; stdlib-only import check passes
  • Benchmark against a copy of a real index (1,000 sessions; largest session 14 MB, 216 subagents):
main, autostarted this PR
Projects page, first / warm 79.5 s / 12.4 s 0.05 s / 0.02 s
Largest session page, warm 19.4 s 1.32 s
Index pass, nothing changed 4–10 s 0.03 s
Frontend ready after start 12 s 0.6 s
Idle memory vite dev, past 1 GB after use ~230 MB
  • End to end on macOS: the repaired login item started both servers at nice 0 in ~2 s; touching a frontend file and launching rebuilt and swapped servers in 24 s; a launch with nothing stale took 0.26 s.

Follow-ups

  • api/parallel.py keeps a third per-subagent loop that could use the digest cache.
  • Windows can't prove a running frontend is the launcher's, so a stale build is rebuilt at the next cold start rather than restarted live.
  • The API still doesn't restart after a git pull (unchanged from main).

Related Issues

None filed.

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 JayantDevkar left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread api/utils.py
)
if result.returncode != 0:
return None
# A submodule prints its superproject first; any other repo prints only its toplevel

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread api/collectors.py
return (str(path), stat.st_mtime_ns, stat.st_size)


def _subagent_digest(subagent) -> _SubagentDigest:

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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"},

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@JayantDevkar
JayantDevkar merged commit aa0b912 into main Oct 12, 2026
28 checks passed
@JayantDevkar

Copy link
Copy Markdown
Owner Author

@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.

@JayantDevkar
JayantDevkar deleted the perf/fast-local-servers branch October 12, 2026 16:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant