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:
@@ -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 }
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user