Files
ai-agent/backend/githist.py
Gabriel Vidal ebae75df94 perf(ai-agent): stop the backend re-walking, re-parsing and re-forking everything
Idle CPU sat at ~58-110% and the catalogs took seconds, because the hot paths
all did O(everything) work on a timer or per request:

- the indexer os.walk'd the whole workspace every 10s to find ~280 files, and
  the watcher rglob'd ~2600 transcripts every 2s (pathlib allocates a Path per
  entry) -> fsscan.walk_files (os.scandir) + a cached discovery walk, refreshed
  every WALK_INTERVAL_SECS and forced on save;
- a live session's growing transcript was re-parsed from byte 0 on every tick
  (O(n^2) over a session) -> conversations.ParserState is now resumable, and the
  indexer feeds it only the appended lines from a stored byte offset;
- the watcher already knew which transcripts changed but threw it away ->
  scan_transcripts(only=…) imports + re-parses just those, instead of re-walking
  both source dirs and the whole archive;
- /api/services forked two `git log`s per service per request (11s for 34) ->
  githist.BucketedHistory walks each repo's log once, buckets commits per dir and
  caches on the repo HEAD; standalone project repos get a TTL cache;
- every list request json.loads'd ~900 summary blobs and deep-copied the meta
  sidecar per card -> decoded summaries are cached in the store (read-only) with
  a version counter, and MetaStore.peek() skips the copy on hot paths.

Also: SQLite in WAL, file word/char stats stored on change, and the skills
rollup no longer SELECT *'s every summary blob to read one column.

Verified: parse output is byte-identical to the old parser across all 905
archived transcripts (full and incremental), and idle CPU drops ~58% -> ~7%
measured against the live container on the same workload.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:34:34 +02:00

144 lines
5.3 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Bucketed git history, cached.
The services/projects catalogs used to fork two ``git log -- <prefix>/<name>``
subprocesses per directory on every request — 34 services × ~160 ms of
history-walk each made ``/api/services`` an 11-second endpoint. Instead we walk
the superproject's history **once** (``git log --name-only -- <prefix>``),
bucket the commits by ``<prefix>/<name>``, and cache the result keyed by the
repo's HEAD: a request pays one cheap ``rev-parse`` (TTL-gated) and a dict
lookup, and the walk re-runs only after a new commit.
Standalone project repos (their own ``.git``) can't join the bucketed walk;
:func:`repo_log` gives them a per-repo TTL cache instead.
"""
import datetime
import subprocess
import threading
import time
# Re-check HEAD at most this often; between checks the cache is trusted as-is.
HEAD_TTL_SECS = 15.0
# Standalone repos: re-run their (cheap, small-repo) log at most this often.
REPO_TTL_SECS = 60.0
_LOG_FORMAT = "%x01%h%x1f%ct%x1f%cI%x1f%s"
def _git(args: list[str], cwd, timeout: int = 30) -> str | None:
cmd = ["git", "-c", "safe.directory=*", "-C", str(cwd), *args]
try:
out = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
except (OSError, subprocess.SubprocessError):
return None
return out.stdout if out.returncode == 0 else None
def _parse_log(out: str | None) -> list[dict]:
"""``--format=_LOG_FORMAT --name-only`` output → [{hash, ts, date, subject,
files}] newest-first."""
commits: list[dict] = []
cur: dict | None = None
for line in (out or "").splitlines():
if line.startswith("\x01"):
parts = line[1:].split("\x1f")
if len(parts) == 4:
try:
ts = int(parts[1])
except ValueError:
ts = None
cur = {"hash": parts[0], "ts": ts, "date": parts[2],
"subject": parts[3], "files": []}
commits.append(cur)
else:
cur = None
elif line.strip() and cur is not None:
cur["files"].append(line.strip())
return commits
class BucketedHistory:
"""Per-directory commit history under ``<repo>/<prefix>/``, one walk."""
def __init__(self, repo_dir, prefix: str, per_bucket: int = 8):
self.repo_dir = repo_dir
self.prefix = prefix.strip("/")
self.per_bucket = per_bucket
self._lock = threading.Lock()
self._head: str | None = None
self._checked = 0.0
self._buckets: dict[str, dict] = {}
def _walk(self) -> dict[str, dict]:
out = _git(["log", f"--format={_LOG_FORMAT}", "--name-only",
"--", self.prefix], self.repo_dir)
buckets: dict[str, dict] = {}
pre = self.prefix + "/"
for c in _parse_log(out):
names = set()
for f in c["files"]:
if f.startswith(pre):
tail = f[len(pre):]
name = tail.split("/", 1)[0]
if name:
names.add(name)
for name in names:
b = buckets.setdefault(name, {"ts": c["ts"], "iso": c["date"],
"commits": []})
if len(b["commits"]) < self.per_bucket:
b["commits"].append({"hash": c["hash"], "date": c["date"],
"subject": c["subject"]})
return buckets
def get(self, name: str) -> dict | None:
"""{ts, iso, commits} for one directory, or None (no commits yet)."""
now = time.time()
with self._lock:
if self._head is not None and now - self._checked < HEAD_TTL_SECS:
return self._buckets.get(name)
head = (_git(["rev-parse", "HEAD"], self.repo_dir) or "?").strip()
self._checked = now
if head != self._head:
self._buckets = self._walk()
self._head = head
return self._buckets.get(name)
class RepoLogCache:
"""TTL-cached ``git log`` for standalone repos (a project's own .git)."""
def __init__(self, per_repo: int = 8):
self.per_repo = per_repo
self._lock = threading.Lock()
self._cache: dict[str, tuple[float, dict | None]] = {}
def get(self, repo_dir) -> dict | None:
"""{ts, iso, commits} for the repo's recent history, or None."""
key = str(repo_dir)
now = time.time()
with self._lock:
hit = self._cache.get(key)
if hit and now - hit[0] < REPO_TTL_SECS:
return hit[1]
out = _git(["log", f"-{self.per_repo}", f"--format={_LOG_FORMAT}"],
repo_dir)
rec: dict | None = None
commits = _parse_log(out)
if commits:
newest = commits[0]
rec = {"ts": newest["ts"], "iso": newest["date"],
"commits": [{"hash": c["hash"], "date": c["date"],
"subject": c["subject"]} for c in commits]}
with self._lock:
self._cache[key] = (now, rec)
return rec
def parse_iso_ts(iso: str | None) -> int | None:
if not iso:
return None
try:
return int(datetime.datetime.fromisoformat(iso).timestamp())
except ValueError:
return None