perf: main-process reconcile cache — stop refetching the repo on every tool call

A full reconcile is ~2N gitea calls (deps + timelines per issue). Every agent
tool call (query_project) was doing a fresh one; the UI reconcile and the agent
didn't share anything. Now a single in-memory snapshot cache backs both.

- gitea.ts: getSnapshot(client, { maxAgeMs }) — reads within the window reuse the
  cache; maxAgeMs:0 forces fresh. invalidateSnapshot() drops it. The explicit UI
  reconcile forces fresh (and warms the cache); agent tool calls tolerate a 30s
  TTL to stay responsive; applyChange + createIssues invalidate so the board and
  forecast reflect the write immediately.
- model.ts: query_project reads getSnapshot (30s TTL) instead of reconciling live.

This is the SQLite mirror's cache semantics in memory — rebuildable, the durable
truth stays in gitea (purity split, D4). Persistent SQLite (offline + instant
boot) is a separate slice: Electron 34's Node 20 has no node:sqlite, so it needs
better-sqlite3 + electron-rebuild or sql.js/WASM — deferred as its own decision.

Verified: desktop typecheck clean, 14 fixture e2e green, live Reginald still
answers correctly from the cache (writes invalidate → board stays correct).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Croissant Le Doux
2026-07-08 23:18:47 -04:00
parent aab7fd6eeb
commit fca36f9075
2 changed files with 43 additions and 3 deletions

View File

@@ -126,6 +126,36 @@ export async function reconcileSnapshot(
return { issues, milestones, deps, timelines }
}
type Snapshot = Awaited<ReturnType<typeof reconcileSnapshot>>
/**
* A single in-memory reconcile cache shared across the app. A full reconcile is
* ~2N gitea calls (deps + timelines per issue); without this, every agent tool
* call refetched the whole repo. Reads within `maxAgeMs` reuse the cache;
* `getSnapshot({ maxAgeMs: 0 })` forces a fresh pull (the explicit UI reconcile),
* and any write calls `invalidateSnapshot()` so the next read sees it. The cache
* is rebuildable — the durable truth stays in gitea (the purity split, D4).
*/
let snapshotCache: { snap: Snapshot; at: number } | null = null
export async function getSnapshot(client: GiteaClient, opts?: { maxAgeMs?: number }): Promise<Snapshot> {
const maxAgeMs = opts?.maxAgeMs ?? 0
if (snapshotCache && maxAgeMs > 0 && Date.now() - snapshotCache.at <= maxAgeMs) {
return snapshotCache.snap
}
const snap = await reconcileSnapshot(client)
snapshotCache = { snap, at: Date.now() }
return snap
}
/** Drop the cache so the next read reflects a just-made write. */
export function invalidateSnapshot(): void {
snapshotCache = null
}
/** Agent tool calls tolerate a slightly stale snapshot (seconds) to stay responsive. */
export const AGENT_SNAPSHOT_TTL_MS = 30_000
export function registerGiteaIpc(): void {
const client = getGiteaClient()
const repo = client ? `${process.env.GITEA_OWNER ?? 'christian'}/${process.env.GITEA_REPO ?? 'commitea'}` : null
@@ -134,7 +164,8 @@ export function registerGiteaIpc(): void {
ipcMain.handle('gitea:reconcile', async () => {
if (!client) return { configured: false, issues: [], milestones: [], deps: [], timelines: {} }
const snap = await reconcileSnapshot(client)
// explicit UI sync — force fresh, and warm the cache for agent tool calls
const snap = await getSnapshot(client, { maxAgeMs: 0 })
return { configured: true, ...snap }
})
@@ -167,6 +198,7 @@ export function registerGiteaIpc(): void {
const ids = await resolveLabelIds(plan.labels)
await client.setIssueLabels(change.issue, ids)
const issue = await client.getIssue(change.issue)
invalidateSnapshot() // the board + forecast must reflect the label change
return { ok: true as const, plan, issue }
})
@@ -183,6 +215,7 @@ export function registerGiteaIpc(): void {
const issue = await client.createIssue({ title: it.title, body: it.body, labelIds })
created.push({ number: issue.number, title: issue.title })
}
if (created.length) invalidateSnapshot() // new issues enter the board/scope
return { ok: true as const, created }
},
)

View File

@@ -26,7 +26,13 @@ import {
} from '@commitea/core'
import { ipcMain } from 'electron'
import { appendDirectiveEntry, getGiteaClient, getPmStateClient, reconcileSnapshot } from './gitea.js'
import {
AGENT_SNAPSHOT_TTL_MS,
appendDirectiveEntry,
getGiteaClient,
getPmStateClient,
getSnapshot,
} from './gitea.js'
/** Small local model for prose + the read tool; big model reserved for later decomposition. */
function resolveModelRouter(): ModelRouter | null {
@@ -93,7 +99,8 @@ export function registerModelIpc(): void {
const execute = async (name: string, args: unknown) => {
if (!client) return { error: 'gitea is not configured' }
if (name === 'query_project') {
const snap = await reconcileSnapshot(client)
// reuse a recent reconcile — a multi-tool turn shouldn't refetch the repo each call
const snap = await getSnapshot(client, { maxAgeMs: AGENT_SNAPSHOT_TTL_MS })
const a = (args ?? {}) as { view: ProjectView; filters?: QueryFilters }
return buildProjectView(a.view, a.filters, snap, new Date())
}