diff --git a/apps/desktop/src/main/gitea.ts b/apps/desktop/src/main/gitea.ts index eaf5670..1ea869e 100644 --- a/apps/desktop/src/main/gitea.ts +++ b/apps/desktop/src/main/gitea.ts @@ -126,6 +126,36 @@ export async function reconcileSnapshot( return { issues, milestones, deps, timelines } } +type Snapshot = Awaited> + +/** + * 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 { + 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 } }, ) diff --git a/apps/desktop/src/main/model.ts b/apps/desktop/src/main/model.ts index 686a8a3..2859397 100644 --- a/apps/desktop/src/main/model.ts +++ b/apps/desktop/src/main/model.ts @@ -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()) }