Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/switchyard/server/agent-previews.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
Expand Down
90 changes: 90 additions & 0 deletions apps/switchyard/server/machine-cache.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
113 changes: 113 additions & 0 deletions apps/switchyard/server/machine-cache.ts
Original file line number Diff line number Diff line change
@@ -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<T> {
value: Promise<T>;
expiresAt: number;
}

const entries = new Map<string, Entry<unknown>>();

/**
* 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<object, number>();
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<T>(key: string, load: () => Promise<T>, ttlFor: (value: T) => number, nowMs: number): Promise<T> {
const hit = entries.get(key);
if (hit && hit.expiresAt > nowMs) return hit.value as Promise<T>;
// 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<T> = { 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<ConversationSummary[]> {
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<string | null>, nowMs = Date.now()): Promise<string | null> {
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();
}
5 changes: 3 additions & 2 deletions apps/switchyard/server/previews.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
Expand Down Expand Up @@ -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;
Expand Down
33 changes: 16 additions & 17 deletions apps/switchyard/server/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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 } });

Expand All @@ -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 } });
}
Expand All @@ -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<Map<string, MachineState>> {
const out = new Map<string, MachineState>();
if (!rows.length || !ctx.fountain) return out;
let all: Awaited<ReturnType<Fountain["listConversations"]>>;
try {
all = await ctx.fountain.listConversations();
} catch {
return out;
}
const byAgent = new Map<string, (typeof all)[number][]>();
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];
Expand All @@ -535,7 +534,7 @@ async function machinesFor(ctx: AppContext, rows: ProjectRow[]): Promise<Map<str
}
: none(),
);
}
});
return out;
}

Expand Down
Loading
Loading