diff --git a/apps/switchyard/server/agent-previews.ts b/apps/switchyard/server/agent-previews.ts index 24bb0b7..cbb6f9e 100644 --- a/apps/switchyard/server/agent-previews.ts +++ b/apps/switchyard/server/agent-previews.ts @@ -76,7 +76,9 @@ export async function agentPreviewRoute(ctx: AppContext, req: Request, trackId: const body = await readJson(req); const action = body?.action; if (!["configure", "start", "restart", "stop", "status", "logs"].includes(String(action))) throw new HttpError(422, "preview_action", "Unknown preview helper command."); - const machine = await machineOf(ctx.fountain!, project); + // Fresh, not memoised: this is the check that the helper's grant still names + // the machine that is there, and a memo could vouch for one that is gone. + const machine = await machineOf(ctx.fountain!, project, { fresh: true }); if (machine?.sandboxId !== grant.sandboxId || await spriteFor(ctx.fountain!, machine.sandboxId) !== grant.sprite) throw new HttpError(409, "preview_replaced", "The workspace changed. Send another message to renew the helper."); // Membership can change during provider reads. Never resurrect a revoked grant. if (!ctx.db.previews.agentGrant(grant.hash)) throw new HttpError(401, "preview_agent_auth", "Preview access ended."); diff --git a/apps/switchyard/server/machine-cache.test.ts b/apps/switchyard/server/machine-cache.test.ts new file mode 100644 index 0000000..524ca43 --- /dev/null +++ b/apps/switchyard/server/machine-cache.test.ts @@ -0,0 +1,90 @@ +import { beforeEach, expect, mock, test } from "bun:test"; +import type { ConversationSummary, Fountain } from "./fountain"; +import { forgetProject, liveConversations, resetMachineCache, spriteName, SPRITE_TTL_MS, TTL_MS } from "./machine-cache"; + +const project = { id: "p1", agentId: "a1" }; +const row = { id: "c1", sandbox_id: "s1", status: "idle", inserted_at: "2026-09-07" } as unknown as ConversationSummary; + +function fountain(rows: unknown[] = [row]) { + const listConversations = mock(async (_agentId?: string) => rows); + return { fountain: { listConversations } as unknown as Fountain, listConversations }; +} + +beforeEach(() => resetMachineCache()); + +test("a burst of reads within the TTL is one call, and concurrent misses share it", async () => { + const f = fountain(); + const [a, b, c] = await Promise.all([ + liveConversations(f.fountain, project, { nowMs: 0 }), + liveConversations(f.fountain, project, { nowMs: 0 }), + liveConversations(f.fountain, project, { nowMs: 1_000 }), + ]); + expect(f.listConversations).toHaveBeenCalledTimes(1); + // Narrowed to the project's agent, never the whole account. + expect(f.listConversations.mock.calls[0]?.[0]).toBe("a1"); + expect(a).toBe(b); + expect(b).toBe(c); +}); + +test("the memo expires", async () => { + const f = fountain(); + await liveConversations(f.fountain, project, { nowMs: 0 }); + await liveConversations(f.fountain, project, { nowMs: TTL_MS - 1 }); + expect(f.listConversations).toHaveBeenCalledTimes(1); + await liveConversations(f.fountain, project, { nowMs: TTL_MS }); + expect(f.listConversations).toHaveBeenCalledTimes(2); +}); + +test("a fresh read asks Fountain and refreshes the memo for everyone else", async () => { + const f = fountain(); + await liveConversations(f.fountain, project, { nowMs: 0 }); + await liveConversations(f.fountain, project, { nowMs: 1, fresh: true }); + expect(f.listConversations).toHaveBeenCalledTimes(2); + await liveConversations(f.fountain, project, { nowMs: 2 }); + expect(f.listConversations).toHaveBeenCalledTimes(2); +}); + +test("forgetting a project drops its memo and nobody else's", async () => { + const f = fountain(); + const other = { id: "p2", agentId: "a2" }; + await liveConversations(f.fountain, project, { nowMs: 0 }); + await liveConversations(f.fountain, other, { nowMs: 0 }); + forgetProject(project.id); + await liveConversations(f.fountain, project, { nowMs: 1 }); + await liveConversations(f.fountain, other, { nowMs: 1 }); + expect(f.listConversations).toHaveBeenCalledTimes(3); +}); + +test("two clients do not share an answer", async () => { + const f = fountain(); + const g = fountain([]); + expect(await liveConversations(f.fountain, project, { nowMs: 0 })).toEqual([row]); + expect(await liveConversations(g.fountain, project, { nowMs: 0 })).toEqual([]); +}); + +test("a failed read is not remembered", async () => { + const listConversations = mock(async () => { + throw new Error("boom"); + }); + const f = { listConversations } as unknown as Fountain; + await expect(liveConversations(f, project, { nowMs: 0 })).rejects.toThrow("boom"); + await expect(liveConversations(f, project, { nowMs: 1 })).rejects.toThrow("boom"); + expect(listConversations).toHaveBeenCalledTimes(2); +}); + +test("a sprite name stands for a minute; a missing one only briefly", async () => { + const f = fountain().fountain; + const named = mock(async () => "sprite-1"); + await spriteName(f, "s1", named, 0); + await spriteName(f, "s1", named, SPRITE_TTL_MS - 1); + expect(named).toHaveBeenCalledTimes(1); + await spriteName(f, "s1", named, SPRITE_TTL_MS); + expect(named).toHaveBeenCalledTimes(2); + + const missing = mock(async () => null); + await spriteName(f, "s2", missing, 0); + await spriteName(f, "s2", missing, TTL_MS - 1); + expect(missing).toHaveBeenCalledTimes(1); + await spriteName(f, "s2", missing, TTL_MS); + expect(missing).toHaveBeenCalledTimes(2); +}); diff --git a/apps/switchyard/server/machine-cache.ts b/apps/switchyard/server/machine-cache.ts new file mode 100644 index 0000000..e922ff0 --- /dev/null +++ b/apps/switchyard/server/machine-cache.ts @@ -0,0 +1,113 @@ +/** + * One Fountain call per burst, not one per request. + * + * A project's machine is derived from its conversations rather than stored + * (see `machineOf` in `tracks.ts` for why), and the derivation used to run on + * every request that needed it: the file, diff and listing routes, the + * terminal, the vitals readout every twenty seconds per viewer, the preview + * reconciler every fifteen, the shared browser, the native runner. Each one + * listed the agent's conversations afresh. Across the deployed apps that was + * part of ~50,000 conversation-list calls an hour against production and a + * four-day database-pool incident (2026-09-07). + * + * So the list is memoised, briefly, per Fountain client and project: + * + * - **Short.** `TTL_MS` is a few seconds — enough that one screen's burst of + * requests costs one call, short enough that nothing on screen is stale + * for long. + * - **Coalesced.** Concurrent misses share one in-flight promise. + * - **Invalidated on the writes that change the answer.** Opening a track + * (which may provision the machine), closing one, rebuilding or destroying + * the project: each calls `forgetProject`. + * - **Refreshed by whoever needs it fresh.** The sidebar's status dot must + * not lag a turn ending, so `tracks.list`/`show` read live and write the + * result through; everything that only needs the machine's identity reads + * from the memo. + * + * The sprite behind a sandbox never changes for a given sandbox id, so that + * lookup is memoised for longer; a "not a sprite" answer only briefly, since a + * sandbox mid-provisioning may not have one yet. + */ +import type { ConversationSummary, Fountain } from "./fountain"; + +/** How long a conversation list stands before it is re-read. */ +export const TTL_MS = 5_000; +/** How long a sandbox's sprite name stands. It does not change. */ +export const SPRITE_TTL_MS = 60_000; + +interface Entry { + value: Promise; + expiresAt: number; +} + +const entries = new Map>(); + +/** + * Which client an entry was read on. Tests build a Fountain per case, and a + * memo keyed on the project alone would hand one test another's answer; in + * production there is one client and one id. + */ +const clientIds = new WeakMap(); +let nextClientId = 1; + +function clientId(fountain: object): number { + let id = clientIds.get(fountain); + if (!id) { + id = nextClientId++; + clientIds.set(fountain, id); + } + return id; +} + +function memo(key: string, load: () => Promise, ttlFor: (value: T) => number, nowMs: number): Promise { + const hit = entries.get(key); + if (hit && hit.expiresAt > nowMs) return hit.value as Promise; + // Until the load settles it is held for the base TTL so concurrent misses + // share it; the value then decides how long it stands. + const value = load(); + const entry: Entry = { value, expiresAt: nowMs + TTL_MS }; + entries.set(key, entry); + value.then( + (v) => { + if (entries.get(key) === entry) entry.expiresAt = nowMs + ttlFor(v); + }, + () => { + // A failure is nobody's answer: the next caller retries. + if (entries.get(key) === entry) entries.delete(key); + }, + ); + return value; +} + +const listKey = (fountain: Fountain, project: { id: string; agentId: string }) => `${clientId(fountain)}:conversations:${project.id}:${project.agentId}`; + +/** + * The project's agent's conversations — from the memo while fresh, unless + * `fresh` is set, in which case Fountain is asked and the memo refreshed. + */ +export function liveConversations( + fountain: Fountain, + project: { id: string; agentId: string }, + opts: { fresh?: boolean; nowMs?: number } = {}, +): Promise { + const now = opts.nowMs ?? Date.now(); + const key = listKey(fountain, project); + if (opts.fresh) entries.delete(key); + return memo(key, () => fountain.listConversations(project.agentId), () => TTL_MS, now); +} + +/** The sprite behind one sandbox, or null when it is not on Sprites. */ +export function spriteName(fountain: Fountain, sandboxId: string, load: () => Promise, nowMs = Date.now()): Promise { + return memo(`${clientId(fountain)}:sprite:${sandboxId}`, load, (name) => (name ? SPRITE_TTL_MS : TTL_MS), nowMs); +} + +/** Forget what was derived for one project, on every client. */ +export function forgetProject(projectId: string): void { + const marker = `:conversations:${projectId}:`; + for (const key of entries.keys()) if (key.includes(marker)) entries.delete(key); +} + +/** For tests: forget everything. */ +export function resetMachineCache(): void { + entries.clear(); +} diff --git a/apps/switchyard/server/previews.ts b/apps/switchyard/server/previews.ts index d943554..4fc460f 100644 --- a/apps/switchyard/server/previews.ts +++ b/apps/switchyard/server/previews.ts @@ -165,7 +165,8 @@ export class Previews { const { track, project } = this.assertOpen(trackId); const config = row.config ?? this.ctx.db.previews.defaults(project.id); if (!config) throw new Error("Save a preview startup command and app directory first."); - const machine = await machineOf(this.ctx.fountain!, project); + // Fresh, not memoised: the reconciler is what notices a replaced machine. + const machine = await machineOf(this.ctx.fountain!, project, { fresh: true }); if (!machine) throw new Error("This project has no machine. Open a track first."); const sprite = await spriteFor(this.ctx.fountain!, machine.sandboxId); if (!sprite) throw new SpritesError(501, "This workspace does not expose a Sprite. Previews are unavailable."); @@ -212,7 +213,7 @@ export class Previews { if ((actual?.state?.restart_count ?? 0) >= 3) throw new Error("Preview crashed repeatedly. Fix the startup command, then restart. See logs below."); if (actual?.state?.status === "running" && await this.ready(row, config.readinessPath)) { // A machine replacement during startup cannot publish an old result. - const now = await machineOf(this.ctx.fountain!, project); + const now = await machineOf(this.ctx.fountain!, project, { fresh: true }); if (now?.sandboxId !== row.sandboxId) throw new Error("The workspace changed during startup. Open the preview again."); this.update(row, { state: "ready", error: null }); return; diff --git a/apps/switchyard/server/projects.ts b/apps/switchyard/server/projects.ts index 8bf5981..2f0c175 100644 --- a/apps/switchyard/server/projects.ts +++ b/apps/switchyard/server/projects.ts @@ -36,6 +36,7 @@ import { FountainHttpError, asHttpError } from "./fountain"; import { asHttpError as asGitHubError } from "./github"; import { HttpError, json, readJson, str } from "./http"; import { publish } from "./hub"; +import { forgetProject, liveConversations } from "./machine-cache"; import { previews } from "./previews"; import { browsers } from "./browsers"; @@ -466,6 +467,7 @@ export async function rebuild(ctx: AppContext, req: Request, id: string): Promis // The agent id is the identity, so it is the one column that ever moves — // and when it moves, every track on the old disk is gone. ctx.db.rebindAgent(project.id, agent.id); + forgetProject(project.id); for (const t of ctx.db.tracksOf(project.id)) ctx.db.closeTrack(t.id); publish(project.id, { event: "tracks", data: { projectId: project.id } }); @@ -487,6 +489,7 @@ export async function destroy(ctx: AppContext, req: Request, id: string): Promis } await unwind(fountain, { agentId: project.agentId, vaultId: project.vaultId, environmentId: project.environmentId }); ctx.db.archiveProject(project.id); + forgetProject(project.id); publish(project.id, { event: "tracks", data: { projectId: project.id } }); return json({ data: { ok: true } }); } @@ -499,26 +502,22 @@ export async function destroy(ctx: AppContext, req: Request, id: string): Promis * Nothing about a machine is stored: a sandbox id in a row is a claim that * goes stale the moment Fountain rebuilds anything, and a UI that confidently * shows a box that died an hour ago is worse than one that says it does not - * know. One list call answers for every project at once. + * know. One memoised, agent-narrowed list call answers for each project. */ async function machinesFor(ctx: AppContext, rows: ProjectRow[]): Promise> { const out = new Map(); if (!rows.length || !ctx.fountain) return out; - let all: Awaited>; - try { - all = await ctx.fountain.listConversations(); - } catch { - return out; - } - const byAgent = new Map(); - for (const c of all) { - if (!c.agent_id) continue; - const list = byAgent.get(c.agent_id) ?? []; - list.push(c); - byAgent.set(c.agent_id, list); - } - for (const row of rows) { - const mine = (byAgent.get(row.agentId) ?? []) + // One narrowed, memoised list per project rather than one unfiltered list of + // the whole account: the account's list is every conversation the key has + // ever had and an aggregate over each, and this ran on every load of the + // rail. The per-project reads are the same ones the track routes make, so + // they are usually already in the memo. + const fountain = ctx.fountain; + const lists = await Promise.all(rows.map((row) => liveConversations(fountain, row).catch(() => null))); + rows.forEach((row, i) => { + const all = lists[i]; + if (!all) return; + const mine = all .filter((c) => c.sandbox_id) .sort((a, b) => b.inserted_at.localeCompare(a.inserted_at)); const newest = mine[0]; @@ -535,7 +534,7 @@ async function machinesFor(ctx: AppContext, rows: ProjectRow[]): Promise { +export async function machineOf(fountain: Fountain, project: ProjectRow, opts: { fresh?: boolean } = {}): Promise<{ sandboxId: string } | null> { let all: ConversationSummary[]; try { - all = await fountain.listConversations(project.agentId); + all = await liveConversations(fountain, project, opts); } catch (err) { throw asHttpError(err, "find this project's machine"); } @@ -614,23 +627,32 @@ export async function machineOf(fountain: Fountain, project: ProjectRow): Promis /** * The sprite behind a sandbox, or null if it is not on Sprites at all. * - * One call, made only by the two panels that need a shell. A sandbox on - * another provider is a real answer rather than a failure — the terminal says - * so — which is why this returns null instead of throwing. + * Made only by the panels that need a shell, and memoised per sandbox for a + * minute: a sandbox id names one machine, and its sprite does not change. A + * sandbox on another provider is a real answer rather than a failure — the + * terminal says so — which is why this returns null instead of throwing. */ export async function spriteFor(fountain: Fountain, sandboxId: string): Promise { - try { - const sandbox = await fountain.sandbox(sandboxId); - return sandbox.sprite_name ?? null; - } catch { - return null; - } + return spriteName(fountain, sandboxId, async () => { + try { + const sandbox = await fountain.sandbox(sandboxId); + return sandbox.sprite_name ?? null; + } catch { + return null; + } + }); } +/** + * Every conversation on the project's agent, by id — read live, not from the + * memo, because this is what the sidebar's status comes from and a turn that + * ended must not show as running for another five seconds. The fresh answer + * is written through, so a burst of machine reads right after it is free. + */ async function conversationsOf(ctx: AppContext, project: ProjectRow): Promise> { const out = new Map(); if (!ctx.fountain) return out; - const all = await ctx.fountain.listConversations(project.agentId).catch(() => [] as ConversationSummary[]); + const all = await liveConversations(ctx.fountain, project, { fresh: true }).catch(() => [] as ConversationSummary[]); for (const c of all) out.set(c.id, c); return out; }