diff --git a/docs/communities.md b/docs/communities.md index 66479b7a3..c9e8713bf 100644 --- a/docs/communities.md +++ b/docs/communities.md @@ -114,7 +114,10 @@ community origin and viewer; channel IDs alone are not sufficient keys. ## Session lifetime Selecting a saved membership lazily acquires its session. Sessions survive page -navigation and switching; startup opens only the selected one. Opened communities +navigation and switching; startup opens only the selected one. Once its agent +list loads, Agents2 also opens each joined community where the viewer has an +agent, through `open`, which connects without selecting so those agents keep +running, and retries one whose connection failed while it is not selected. Opened communities retain their existing per-session query/cache budgets. Arbitrary destinations mean there is **no longer a two-session maximum**: retained session count grows with communities opened until app disposal. This slice does not add background eviction diff --git a/src/app/services.ts b/src/app/services.ts index 287dd7e29..7193908fb 100644 --- a/src/app/services.ts +++ b/src/app/services.ts @@ -76,7 +76,7 @@ export function createServices() { subscribe: communities.subscribe, }); const relay = communities.relay; - const agents2 = new Agents2Service(ctx, relay); + const agents2 = new Agents2Service(ctx, communities); ctx.effect(() => bindAgentMentions(agentControl, communities)); ctx.effect(() => bindTeamTextSync(agentControl, communities)); const notifications = new NotificationsService( diff --git a/src/bundled/agents2/Agents2Page.test.tsx b/src/bundled/agents2/Agents2Page.test.tsx index 1e54dbb7e..3eb719e8a 100644 --- a/src/bundled/agents2/Agents2Page.test.tsx +++ b/src/bundled/agents2/Agents2Page.test.tsx @@ -49,7 +49,7 @@ const agent = (name: string, n: number): Agent => ({ }); function setup(agents: Agent[], { shown = true } = {}) { - let snapshot: AgentsSnapshot = { status: "ready", agents }; + let snapshot: AgentsSnapshot = { status: "ready", agents, running: agents }; const listeners = new Set<() => void>(); const type = { key: "test/echo", @@ -73,6 +73,7 @@ function setup(agents: Agent[], { shown = true } = {}) { return () => listeners.delete(listener); }, find: (pubkey: string) => snapshot.agents.find((a) => a.pubkey === pubkey), + relay: () => undefined, register: vi.fn(), create: vi.fn(async () => { // `shown: false`: it was made for a community no longer selected. diff --git a/src/bundled/builderlab/index.test.tsx b/src/bundled/builderlab/index.test.tsx index 900d6310d..c07d2ad43 100644 --- a/src/bundled/builderlab/index.test.tsx +++ b/src/bundled/builderlab/index.test.tsx @@ -6,7 +6,10 @@ import { act, cleanup, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { HostService } from "../../features/host/service"; import { SettingsCardsService } from "../../features/settings/service"; -import { Agents2Service } from "../../features/agents2/service"; +import { + Agents2Service, + type AgentCommunities, +} from "../../features/agents2/service"; import type { RelayData } from "../../features/relay/service"; import { Agents2Page } from "../agents2/Agents2Page"; import * as builderlab from "./index"; @@ -42,7 +45,12 @@ afterEach(() => { }); function provideAgents2(root: Context, relay: RelayData) { - return new Agents2Service(root, relay, { + const communities = { + snapshot: () => ({ memberships: [], selected: null }), + subscribe: () => () => {}, + open: () => relay, + } as unknown as AgentCommunities; + return new Agents2Service(root, communities, { list: async () => [], create: vi.fn(), rename: vi.fn(), diff --git a/src/bundled/claude-code/claude.ts b/src/bundled/claude-code/claude.ts index 048b44225..c163acc52 100644 --- a/src/bundled/claude-code/claude.ts +++ b/src/bundled/claude-code/claude.ts @@ -89,7 +89,7 @@ export class ClaudeProcess { private constructor( private readonly process: HostProcess, resume: string | undefined, - private readonly tools: ToolServer | undefined, + private tools: ToolServer | undefined, ) { this.sessionId = resume; this.exited = process.exited.then((code) => { @@ -104,6 +104,14 @@ export class ClaudeProcess { } static async start(spawn: Spawn, launch: ClaudeLaunch) { + const claude = await ClaudeProcess.spawn(spawn, launch); + await claude.initialize(launch); + return claude; + } + + /** Starts `claude` without its prompt. This is most of what a start costs, + * and fixes nothing about the agent it serves but its folder and model. */ + static async spawn(spawn: Spawn, launch: Omit) { let claude: ClaudeProcess | undefined; const early: string[] = []; const earlyErrors: string[] = []; @@ -150,8 +158,14 @@ export class ClaudeProcess { ); for (const data of earlyErrors) claude.readError(data); for (const data of early) claude.read(data); - // Loads settings, tools and MCP servers now rather than on the first message. - await claude.request({ + return claude; + } + + /** Gives a started process its prompt, and its tools to whoever it now + * works for. Loads settings and tools now rather than on the first message. */ + async initialize(launch: Pick) { + if (launch.tools) this.tools = launch.tools; + await this.request({ subtype: "initialize", appendSystemPrompt: launch.systemPrompt, ...(launch.tools @@ -165,7 +179,6 @@ export class ClaudeProcess { } : {}), }); - return claude; } get running() { diff --git a/src/bundled/claude-code/index.ts b/src/bundled/claude-code/index.ts index 17c510d96..a31de4171 100644 --- a/src/bundled/claude-code/index.ts +++ b/src/bundled/claude-code/index.ts @@ -11,23 +11,24 @@ export const TYPE_ID = "claude-code"; /** A turn's run returns once Claude has it, so this bounds only the hand-over. */ const HANDOVER_MS = 2 * 60_000; -export const inject = ["agents2", "host", "relay"]; +export const inject = ["agents2", "host"]; export const apply: PluginModule["apply"] = (ctx) => { // Only the desktop app can start `claude`, and setup and file uploads run // `bash` and `base64`, which stock Windows lacks; no unusable type elsewhere. if (!isTauri() || !/Mac|Linux/i.test(navigator.platform)) return; - const { agents2, host, relay } = ctx; + const { agents2, host } = ctx; const typeKey = `${ctx.pluginOwner?.id ?? "buzz.claude-code"}/${TYPE_ID}`; const spawn = host.spawn?.bind(host); const setup = new ClaudeSetup(spawn); - const runtime = new ClaudeRuntime(host, relay); + const runtime = new ClaudeRuntime(host, agents2.relay); const sync = () => { const snapshot = agents2.snapshot(); - // Agents leaving view, the app signing out included, stop their processes. + // Agents that stop running, the app signing out included, stop their + // processes; switching communities does not. if (snapshot.status === "loading") return; - runtime.sync(snapshot.agents.filter((agent) => agent.type === typeKey)); + runtime.sync(snapshot.running.filter((agent) => agent.type === typeKey)); }; - // Agents come and go with the community; a spare session is warmed for each. + // One spare session waits for whichever running agent needs it next. if (spawn) { const unsubscribe = agents2.subscribe(sync); sync(); diff --git a/src/bundled/claude-code/runtime.test.ts b/src/bundled/claude-code/runtime.test.ts index 437c2410f..00e8eac8b 100644 --- a/src/bundled/claude-code/runtime.test.ts +++ b/src/bundled/claude-code/runtime.test.ts @@ -58,7 +58,7 @@ function setup(thread: readonly EventData[] = []) { getItem: (key: string) => data.get(key) ?? null, setItem: (key: string, value: string) => void data.set(key, value), } as Storage; - const runtime = new ClaudeRuntime(host, relay, storage); + const runtime = new ClaudeRuntime(host, () => relay, storage); const published: { kind: number; content: string; tags: string[][] }[] = []; const publish = vi.fn(async (template: (typeof published)[number]) => { published.push(template); @@ -294,7 +294,8 @@ it("shows the whole thread to a session started over from a lost one", async () ); await deliver({ type: "mention", event: mention }); await flush(20); - const [lost, fresh] = claudes(); + const [lost] = claudes(); + const fresh = claudes().find((process) => process.prompts.length); expect(lost?.options.args).toContain("lost"); expect(lost?.prompts).toEqual([]); expect(fresh?.prompts[0]).toContain(' { expect(claude?.prompts[0]).toContain("Timer: watch/daily"); }); -it("warms a spare for each agent of its type and stops those that go away", async () => { +it("keeps one spare for all its agents, and none once they are gone", async () => { const { runtime, claudes } = setup(); - const agent = { pubkey: self, config: DEFAULT_CONFIG } as Agent; - runtime.sync([agent]); + const agent = (pubkey: string) => + ({ pubkey, config: DEFAULT_CONFIG }) as Agent; + runtime.sync([agent(self), agent(alice)]); await flush(10); expect(claudes()).toHaveLength(1); + expect(claudes()[0]?.options.cwd).toBe(DEFAULT_CONFIG.workspace); runtime.sync([]); await flush(10); expect(claudes().every((process) => process.killed)).toBe(true); }); + +it("replaces the spare when no agent runs where it was started", async () => { + const { runtime, claudes } = setup(); + runtime.sync([{ pubkey: self, config: DEFAULT_CONFIG } as Agent]); + await flush(10); + runtime.sync([ + { pubkey: self, config: { ...DEFAULT_CONFIG, model: "sonnet" } } as Agent, + ]); + await flush(10); + const [old, next] = claudes(); + expect(old?.killed).toBe(true); + expect(next?.options.args).toEqual( + expect.arrayContaining(["--model", "sonnet"]), + ); + expect(claudes()).toHaveLength(2); +}); diff --git a/src/bundled/claude-code/runtime.ts b/src/bundled/claude-code/runtime.ts index 874fc8251..c0f2b271e 100644 --- a/src/bundled/claude-code/runtime.ts +++ b/src/bundled/claude-code/runtime.ts @@ -25,7 +25,7 @@ import { timerPrompt, turnPrompt, } from "./prompt"; -import { AgentSessions, localSessions } from "./sessions"; +import { AgentSessions, localSessions, Spare } from "./sessions"; export type Config = Readonly<{ /** A Claude model or alias; empty uses Claude Code's own default. */ @@ -67,6 +67,12 @@ type Entry = { contexts: Map; }; +/** Where an agent's processes run, and with which model. */ +const where = ({ workspace, model }: Config) => ({ + cwd: workspace, + ...(model.trim() ? { model: model.trim() } : {}), +}); + export class ClaudeRuntime { private readonly agents = new Map(); private readonly reading = new Map>(); @@ -77,10 +83,16 @@ export class ClaudeRuntime { string, ReturnType >(); + /** One process started ahead of need, for any agent's next new conversation. */ + private readonly spare = new Spare( + (id, options) => this.spawn(id, options), + (_conversation, message) => respond(undefined, {}, message), + ); constructor( private readonly host: Host, - private readonly relay: RelayData, + /** The owner's connection to each agent's own community. */ + private readonly relay: (pubkey: string) => RelayData | undefined, private readonly storage: Storage = globalThis.localStorage, ) {} @@ -91,11 +103,12 @@ export class ClaudeRuntime { return spawn; } - /** Brings the runtime in line with this type's agents: new ones get a warm - * session, changed settings restart idle sessions, removed agents stop. */ + /** Brings the runtime in line with this type's agents: changed settings + * restart idle sessions, removed agents stop, and a spare waits while any + * agent is here. */ sync(agents: readonly Agent[]) { const current = new Set(agents.map((agent) => agent.pubkey)); - // Out of view (another community, offline) or deleted: either way its + // Deleted, signed out or its community disconnected: either way its // processes stop. Saved sessions stay so it resumes when it is back. for (const [pubkey, entry] of this.agents) if (!current.has(pubkey)) { @@ -105,12 +118,13 @@ export class ClaudeRuntime { for (const agent of agents) { const next = config(agent.config); const entry = this.agents.get(agent.pubkey); - if (!entry) this.entry(agent.pubkey, next).sessions.warm(); + if (!entry) this.entry(agent.pubkey, next); else if (JSON.stringify(entry.config) !== JSON.stringify(next)) { entry.config = next; entry.sessions.reconfigure(); } } + this.spare.warm(...agents.map((agent) => where(config(agent.config)))); this.notify(); } @@ -135,6 +149,7 @@ export class ClaudeRuntime { } dispose() { + this.spare.dispose(); for (const entry of this.agents.values()) entry.sessions.dispose(); this.agents.clear(); } @@ -149,6 +164,9 @@ export class ClaudeRuntime { return; const entry = this.entry(agent.pubkey, settings); entry.handle = agent; + // Read while the turn's prompt is built, not after: a new session's prompt + // needs it, and a spare is only faster if nothing else is awaited. + void this.memory(agent.pubkey, entry); if (trigger.type === "timer") { const done = entry.sessions.deliver( `timer/${trigger.slug}`, @@ -242,8 +260,8 @@ export class ClaudeRuntime { channelId: string, fresh = false, ) { - const snapshot = this.relay.snapshot(); - const session = snapshot.status === "ready" ? snapshot.session : undefined; + const snapshot = this.relay(self)?.snapshot(); + const session = snapshot?.status === "ready" ? snapshot.session : undefined; const summary = session?.channels.list().channels.find((item) => item.id === channelId) ?? session?.channels.get?.(channelId); @@ -327,8 +345,6 @@ export class ClaudeRuntime { const tools = (cwd: string): ToolServer => async (conversation, message) => { - // A spare starts before any delivery: it can list the tools, and has a - // handle to call them with by the time it is given a turn. const handle = entry.handle; const client = handle && { ...appClient(handle, { @@ -347,6 +363,7 @@ export class ClaudeRuntime { }; entry.sessions = new AgentSessions({ spawn: (id, options) => this.spawn(id, options), + spare: this.spare, store: localSessions(this.storage, pubkey), onChange: () => this.notify(), fingerprint: () => @@ -357,12 +374,11 @@ export class ClaudeRuntime { entry.config.scope, ]), launch: async () => { - const { model, workspace, instructions, scope } = entry.config; + const { workspace, instructions, scope } = entry.config; const memory = await this.memory(pubkey, entry); return { - cwd: workspace, + ...where(entry.config), tools: tools(workspace), - ...(model.trim() ? { model: model.trim() } : {}), systemPrompt: systemPrompt({ scope, cwd: workspace, @@ -406,8 +422,8 @@ export class ClaudeRuntime { return reading; } private async readMemories(pubkey: string): Promise { - const snapshot = this.relay.snapshot(); - if (snapshot.status !== "ready") throw new Error("Buzz is not connected"); + const snapshot = this.relay(pubkey)?.snapshot(); + if (snapshot?.status !== "ready") throw new Error("Buzz is not connected"); const view = snapshot.session.agentMemories.open(pubkey); try { const done = new Promise((resolve) => { diff --git a/src/bundled/claude-code/sessions.test.ts b/src/bundled/claude-code/sessions.test.ts index b96e5724a..478c70500 100644 --- a/src/bundled/claude-code/sessions.test.ts +++ b/src/bundled/claude-code/sessions.test.ts @@ -1,11 +1,12 @@ import { afterEach, expect, it, vi } from "vitest"; import { fakeSpawn, flush } from "./claude-testing"; +import type { ToolServer } from "./claude"; import { AgentSessions, IDLE_MS, localSessions, - MAX_LIVE, type SavedSession, + Spare, type SessionStore, } from "./sessions"; @@ -31,25 +32,35 @@ function sessions( hold: boolean; /** Holds each launch after it reads the settings, as a memory read does. */ launched: Promise; + /** The shared spare its new conversations look for. */ + spare: Spare; + tools: ToolServer; }> = {}, ) { const fake = fakeSpawn(options.known); const store = options.store ?? memoryStore(); let model = "opus"; let ids = 0; + const spawn = (id: string, spawnOptions?: object) => { + // The fake records each process as the call begins. + const process = fake.spawn(id, spawnOptions); + const started = fake.processes.at(-1); + if (options.hold && started) started.hold = true; + return process; + }; const pool = new AgentSessions({ - spawn: (id, spawnOptions) => { - // The fake records each process as the call begins. - const process = fake.spawn(id, spawnOptions); - const started = fake.processes.at(-1); - if (options.hold && started) started.hold = true; - return process; - }, + spawn, + ...(options.spare ? { spare: options.spare } : {}), store, launch: async () => { const current = model; await options.launched; - return { cwd: "/work", systemPrompt: "prompt", model: current }; + return { + cwd: "/work", + systemPrompt: "prompt", + model: current, + ...(options.tools ? { tools: options.tools } : {}), + }; }, fingerprint: () => model, newId: () => `new-${++ids}`, @@ -59,6 +70,7 @@ function sessions( return { pool, store, + spawn, claudes, live: () => claudes().filter((process) => !process.killed), setModel: (next: string) => { @@ -67,21 +79,165 @@ function sessions( }; } -it("starts a new conversation on the warm spare and warms another", async () => { - const { pool, store, claudes } = sessions(); - pool.warm(); +/** Sessions with a spare, spawned like their own processes. */ +function withSpare(options: Parameters[0] = {}) { + let ids = 0; + let parts: ReturnType | undefined; + const spare = new Spare( + (id, spawnOptions) => { + if (!parts) throw new Error("not ready"); + return parts.spawn(id, spawnOptions); + }, + async (_conversation, message) => ({ from: "spare", message }), + () => `spare-${++ids}`, + ); + parts = sessions({ ...options, spare }); + return { ...parts, spare }; +} + +const tool = (process: { emit(message: object): void }, id: string) => + process.emit({ + type: "control_request", + request_id: id, + request: { + subtype: "mcp_message", + server_name: "buzz", + message: { jsonrpc: "2.0", id: 1, method: "tools/list" }, + }, + }); +const answered = ( + process: { received: Record[] }, + id: string, +) => + process.received.find( + (message) => + message.type === "control_response" && + (message.response as { request_id?: string }).request_id === id, + )?.response; + +it("starts a new conversation on the shared spare and warms another", async () => { + const { pool, spare, store, claudes } = withSpare(); + spare.warm({ cwd: "/work", model: "opus" }); await flush(); expect(claudes()).toHaveLength(1); + // Started before any agent asked: no prompt yet. + expect(claudes()[0]?.received).toEqual([]); await expect(pool.deliver("c/root", "hello", 10)).resolves.toEqual({ ok: true, }); await flush(); expect(claudes()).toHaveLength(2); - expect(claudes()[0]?.prompts).toEqual(["hello"]); - expect(store.get("c/root")).toMatchObject({ id: "new-1", seen: 10 }); + const [used] = claudes(); + expect(used?.received[0]).toMatchObject({ + type: "control_request", + request: { subtype: "initialize", appendSystemPrompt: "prompt" }, + }); + expect(used?.prompts).toEqual(["hello"]); + expect(store.get("c/root")).toMatchObject({ id: "spare-1", seen: 10 }); expect(pool.snapshot()).toEqual([{ key: "c/root", busy: false }]); }); +it("hands the spare's tools to the agent that takes it", async () => { + const agentTools = vi.fn(async () => ({ from: "agent" })); + const { pool, spare, claudes } = withSpare({ tools: agentTools }); + spare.warm({ cwd: "/work", model: "opus" }); + await flush(); + const [process] = claudes(); + if (!process) throw new Error("no spare"); + // Claude lists the tools as it starts, before any agent has the spare. + tool(process, "early"); + await flush(); + expect(answered(process, "early")).toMatchObject({ + response: { mcp_response: { from: "spare" } }, + }); + await pool.deliver("c/root", "hi", 1); + tool(process, "later"); + await flush(); + expect(answered(process, "later")).toMatchObject({ + response: { mcp_response: { from: "agent" } }, + }); +}); + +it("serves whichever agent starts the next conversation", async () => { + const one = withSpare(); + const two = sessions({ spare: one.spare }); + one.spare.warm({ cwd: "/work", model: "opus" }); + await flush(); + await one.pool.deliver("c/a", "for one", 1); + await flush(); + await two.pool.deliver("c/b", "for two", 2); + // Each took the spare (all started by the first's spawn); neither agent + // started a process of its own. + expect(one.claudes().map((process) => process.prompts)).toEqual([ + ["for one"], + ["for two"], + [], + ]); + expect(two.claudes()).toEqual([]); +}); + +it("starts a conversation elsewhere afresh, and the spare follows it", async () => { + const { pool, spare, claudes } = withSpare(); + spare.warm({ cwd: "/other", model: "opus" }, { cwd: "/work", model: "opus" }); + await flush(); + const [other] = claudes(); + await pool.deliver("c/root", "hi", 1); + await flush(); + expect(other?.killed).toBe(true); + expect(other?.prompts).toEqual([]); + const used = claudes().filter((process) => process.prompts.length); + expect(used[0]?.options.args).toContain("new-1"); + const next = claudes().find((process) => + process.options.args?.includes("spare-2"), + ); + expect(next?.options.cwd).toBe("/work"); + expect(next?.killed).toBe(false); +}); + +it("does not wait for a spare started elsewhere", async () => { + // The spare for another folder never finishes starting. + const spare = new Spare( + () => new Promise(() => undefined), + async () => ({}), + ); + const { pool, claudes } = sessions({ spare }); + spare.warm({ cwd: "/other", model: "opus" }); + await expect(pool.deliver("c/root", "hi", 1)).resolves.toEqual({ ok: true }); + expect(claudes()[0]?.prompts).toEqual(["hi"]); +}); + +it("keeps a spare that fits any agent, and replaces one that fits none", async () => { + const { spare, claudes } = withSpare(); + const work = { cwd: "/work", model: "opus" }; + const other = { cwd: "/other", model: "opus" }; + spare.warm(work); + await flush(); + spare.warm(other, work); + await flush(); + expect(claudes()).toHaveLength(1); + spare.warm(other); + await flush(); + expect(claudes()).toHaveLength(2); + expect(claudes()[0]?.killed).toBe(true); + expect(claudes()[1]?.options.cwd).toBe("/other"); +}); + +it("starts no spare again after one dies, until a conversation starts", async () => { + const { pool, spare, claudes } = withSpare(); + const where = { cwd: "/work", model: "opus" }; + spare.warm(where); + await flush(); + claudes()[0]?.exit(1, "not signed in"); + await flush(); + spare.warm(where); + await flush(); + expect(claudes()).toHaveLength(1); + await pool.deliver("c/root", "hi", 1); + await flush(); + // One for the conversation, and a spare again. + expect(claudes()).toHaveLength(3); +}); + it("continues a conversation in its own process, apart from others", async () => { const { pool, claudes } = sessions(); await pool.deliver("c/one", "first", 1); @@ -93,14 +249,11 @@ it("continues a conversation in its own process, apart from others", async () => }); it("steers a conversation that is still working instead of queueing", async () => { - const { pool, claudes } = sessions(); - pool.warm(); - await flush(); - const process = claudes()[0]; - if (!process) throw new Error("no spare"); - process.hold = true; + const { pool, claudes } = sessions({ hold: true }); const first = pool.deliver("c/root", "first", 1); await flush(); + const process = claudes()[0]; + if (!process) throw new Error("no process"); const second = pool.deliver("c/root", "plain", 2, { steer: "steer" }); await flush(); expect(process.prompts).toEqual(["first", "steer"]); @@ -154,47 +307,13 @@ it("starts over when a saved session no longer loads", async () => { expect(store.get("c/root")).toMatchObject({ id: "new-1", seen: 9 }); }); -it("stops the longest-idle conversation to stay within its process limit", async () => { - const { pool, live } = sessions(); - for (let index = 0; index < MAX_LIVE + 2; index++) - await pool.deliver(`c/${index}`, "hi", index); - await flush(); - expect(live().length).toBeLessThanOrEqual(MAX_LIVE); - const keys = pool.snapshot().map((session) => session.key); - expect(keys).not.toContain("c/0"); - expect(keys).toContain(`c/${MAX_LIVE + 1}`); -}); - -it("says it is busy rather than start a process past its limit", async () => { +it("runs any number of conversations at once", async () => { const { pool, live } = sessions({ hold: true }); - pool.warm(); - for (let index = 0; index < MAX_LIVE; index++) + for (let index = 0; index < 12; index++) void pool.deliver(`c/${index}`, "hi", index); await flush(); - expect(live()).toHaveLength(MAX_LIVE); - await expect(pool.deliver("c/extra", "hi", 9)).resolves.toEqual({ - ok: false, - error: `Claude Code is already working in ${MAX_LIVE} conversations; try again when one finishes`, - }); - expect(live()).toHaveLength(MAX_LIVE); - expect(pool.snapshot().map((session) => session.key)).not.toContain( - "c/extra", - ); -}); - -it("counts conversations still starting toward its limit", async () => { - let release!: () => void; - const launched = new Promise((resolve) => { - release = resolve; - }); - const { pool, live } = sessions({ hold: true, launched }); - const results = Array.from({ length: MAX_LIVE + 1 }, (_, index) => - pool.deliver(`c/${index}`, "hi", index), - ); - await expect(results[MAX_LIVE]).resolves.toMatchObject({ ok: false }); - release(); - await flush(); - expect(live()).toHaveLength(MAX_LIVE); + expect(live()).toHaveLength(12); + expect(pool.snapshot().every((session) => session.busy)).toBe(true); }); it("starts again with settings saved while a conversation's process started", async () => { @@ -217,17 +336,15 @@ it("starts again with settings saved while a conversation's process started", as expect(used[0]?.options.args).toContain("sonnet"); }); -it("restarts idle processes and the spare when settings change", async () => { +it("restarts idle processes when settings change", async () => { const { pool, claudes, setModel } = sessions(); await pool.deliver("c/root", "hi", 1); await flush(); - const [conversation, spare] = claudes(); + const [conversation] = claudes(); setModel("sonnet"); pool.reconfigure(); await flush(); expect(conversation?.killed).toBe(true); - expect(spare?.killed).toBe(true); - expect(claudes().at(-1)?.options.args).toContain("sonnet"); await pool.deliver("c/root", "again", 2); const resumed = claudes().at(-1); expect(resumed?.options.args).toEqual( @@ -236,16 +353,37 @@ it("restarts idle processes and the spare when settings change", async () => { }); it("stops every process when disposed", async () => { - const { pool, claudes } = sessions(); - pool.warm(); + const { pool, spare, claudes } = withSpare(); + spare.warm({ cwd: "/work", model: "opus" }); await pool.deliver("c/root", "hi", 1); await flush(); pool.dispose(); + spare.dispose(); await flush(); + expect(claudes()).toHaveLength(2); expect(claudes().every((process) => process.killed)).toBe(true); await expect(pool.deliver("c/root", "late", 2)).resolves.toMatchObject({ ok: false, }); + expect(claudes()).toHaveLength(2); +}); + +it("starts no spare for a conversation that starts after its agents are gone", async () => { + let release!: () => void; + const launched = new Promise((resolve) => { + release = resolve; + }); + const { pool, spare, live } = withSpare({ launched }); + spare.warm({ cwd: "/work", model: "opus" }); + const done = pool.deliver("c/root", "hi", 1); + await flush(); + // As the runtime does when its last agent goes, mid-launch. + pool.dispose(); + spare.warm(); + release(); + await expect(done).resolves.toMatchObject({ ok: false }); + await flush(); + expect(live()).toEqual([]); }); it("keeps saved sessions per agent in storage", () => { diff --git a/src/bundled/claude-code/sessions.ts b/src/bundled/claude-code/sessions.ts index ca1ea59f5..0b80ab674 100644 --- a/src/bundled/claude-code/sessions.ts +++ b/src/bundled/claude-code/sessions.ts @@ -1,17 +1,16 @@ // One agent's Claude sessions: one per conversation (a thread, or a whole DM), // each continuing across messages and app restarts. A conversation in use keeps // its process; a quiet one is stopped and resumed from its saved session when it -// is next addressed. A spare process waits for the next new conversation, so -// that starts without a spawn. +// is next addressed. One spare process, shared by every agent, waits for the +// next new conversation, so that starts without a spawn. import { ClaudeProcess, type ClaudeLaunch, type Settled, type Spawn, + type ToolServer, } from "./claude"; -/** Processes an agent keeps, the spare included. */ -export const MAX_LIVE = 6; /** A conversation's process stops after this long without a message. */ export const IDLE_MS = 15 * 60_000; /** Saved sessions per agent; the least recently used are forgotten. */ @@ -32,6 +31,8 @@ export type SessionStore = { export type SessionsOptions = Readonly<{ spawn: Spawn; + /** Where a new conversation looks for a process already started. */ + spare?: Spare; store: SessionStore; /** How to start a process with the agent's current settings. */ launch(): Promise>; @@ -53,7 +54,6 @@ type Live = { export class AgentSessions { private readonly live = new Map(); private readonly opening = new Map>(); - private spare: Promise | undefined; private disposed = false; private readonly now: () => number; private readonly newId: () => string; @@ -75,22 +75,6 @@ export class AgentSessions { })); } - /** Starts a spare process unless one exists or the agent is at its limit. */ - warm() { - if (this.disposed || this.spare || this.count() >= MAX_LIVE) return; - const spare = this.start(undefined).catch((error) => { - console.warn("Claude Code could not start a spare session", error); - return undefined; - }); - this.spare = spare; - void spare.then((live) => { - // A spare that dies, or was started with old settings, is not kept. - live?.process.exited.then(() => { - if (this.spare === spare) this.spare = undefined; - }); - }); - } - /** Sends `text` into the conversation `key`, and resolves when the session * has answered it. `steer` replaces `text` when the session is mid-turn; * `fresh` builds it again for a new session when the saved one is lost. */ @@ -122,22 +106,10 @@ export class AgentSessions { return this.answer(key, live, live.process.busy ? steer : text); } const saved = this.options.store.get(key); - // A new conversation takes the spare's place; any other start needs room, - // reserved here, before anything is awaited. - if ((saved || !this.spare) && !this.room()) - return { - ok: false, - error: `Claude Code is already working in ${MAX_LIVE} conversations; try again when one finishes`, - }; - const starting = (async () => - saved - ? this.start(saved.id) - : ((await this.claimSpare()) ?? this.start(undefined)))().catch( - (error) => { - console.warn("Claude Code could not start", error); - return undefined; - }, - ); + const starting = this.start(saved?.id).catch((error) => { + console.warn("Claude Code could not start", error); + return undefined; + }); this.opening.set(key, starting); live = await starting; if (this.opening.get(key) === starting) this.opening.delete(key); @@ -158,51 +130,47 @@ export class AgentSessions { return delivered; } - /** Stops idle processes started with old settings and replaces the spare; - * busy ones stop when they next fall idle. */ + /** Stops idle processes started with old settings; busy ones stop when they + * next fall idle. */ reconfigure() { const fingerprint = this.options.fingerprint(); for (const [key, live] of this.live) if (live.fingerprint !== fingerprint && !live.process.busy) this.drop(key, live); - const spare = this.spare; - this.spare = undefined; - void spare?.then((live) => live?.process.kill()); - this.warm(); } dispose() { this.disposed = true; for (const [key, live] of this.live) this.drop(key, live); - void this.spare?.then((live) => live?.process.kill()); - this.spare = undefined; } private async start(resume: string | undefined): Promise { // Read before `launch` reads the settings, so it never describes newer ones. const fingerprint = this.options.fingerprint(); const launch = await this.options.launch(); - const process = await ClaudeProcess.start(this.options.spawn, { - ...launch, - ...(resume ? { resume } : { sessionId: this.newId() }), - }); + const process = resume + ? await ClaudeProcess.start(this.options.spawn, { ...launch, resume }) + : await this.fresh(launch); if (fingerprint === this.options.fingerprint() || this.disposed) return { process, fingerprint, used: this.now() }; // Settings saved while it started: it would work with the old ones. void process.kill(); return this.start(resume); } - private async claimSpare() { - const spare = this.spare; - this.spare = undefined; - const live = await spare; - if ( - live?.process.running && - live.fingerprint === this.options.fingerprint() - ) - return live; - void live?.process.kill(); - return undefined; + /** A process for a new session: the spare when it fits, else a new one. */ + private async fresh(launch: Omit) { + const spare = await this.options.spare?.take(launch); + if (spare) + try { + await spare.initialize(launch); + return spare; + } catch { + void spare.kill(); + } + return ClaudeProcess.start(this.options.spawn, { + ...launch, + sessionId: this.newId(), + }); } private adopt(key: string, live: Live, seen: number) { live.process.conversation = key; @@ -212,7 +180,6 @@ export class AgentSessions { void live.process.exited.then(() => { if (this.live.get(key) === live) this.drop(key, live); }); - this.warm(); } private async answer(key: string, live: Live, text: string) { const settled = await live.process.send(text); @@ -240,25 +207,6 @@ export class AgentSessions { at: live.used, }); } - /** Processes running or starting, the spare included. */ - private count() { - return this.live.size + this.opening.size + (this.spare ? 1 : 0); - } - /** Makes room for one more process by stopping the longest-idle - * conversation, else the spare. False when every process is working. */ - private room() { - while (this.count() >= MAX_LIVE) { - const idle = [...this.live] - .filter(([, live]) => !live.process.busy) - .sort(([, a], [, b]) => a.used - b.used)[0]; - if (idle) this.drop(...idle); - else if (this.spare) { - void this.spare.then((live) => live?.process.kill()); - this.spare = undefined; - } else return false; - } - return true; - } private drop(key: string, live: Live) { clearTimeout(live.timer); if (this.live.get(key) === live) { @@ -269,6 +217,92 @@ export class AgentSessions { } } +/** Where a process runs and its model: all a spare is started with. */ +type Where = Pick; +const place = ({ cwd, model }: Where) => JSON.stringify([cwd, model ?? ""]); + +/** One process started ahead of need, for whichever agent next starts a + * conversation where it runs and with its model. Starting is most of the wait + * before a new conversation's first reply; giving the process its prompt is not. + * It is kept only where the runtime's agents run, and follows the latest new + * conversation among those places, the likeliest place for the next. */ +export class Spare { + private next: + | { place: string; process: Promise } + | undefined; + /** Where a spare is wanted: set by the runtime alone. */ + private wanted: readonly Where[] = []; + /** Not started again after it failed to start or died, until a conversation + * asks for one, so a `claude` that cannot run is not started over and over. */ + private stopped = false; + + constructor( + private readonly spawn: Spawn, + /** Answers the tool server before any agent has it: it lists the tools. */ + private readonly tools: ToolServer, + private readonly newId: () => string = () => crypto.randomUUID(), + ) {} + + /** Wants a spare for one of `wheres`, or none when there are none. */ + warm(...wheres: Where[]) { + this.wanted = wheres; + if (!wheres.length) this.stopped = false; + this.fill(wheres); + } + + /** The spare, when it was started for `where`. Another is started for + * `where` when a spare is wanted there, else where one is wanted. */ + async take(where: Where) { + const next = this.next; + this.next = undefined; + this.stopped = false; + const here = this.wanted.some((wanted) => place(wanted) === place(where)); + this.fill(here ? [where] : this.wanted); + if (next?.place !== place(where)) { + void next?.process.then((process) => process?.kill()); + return undefined; + } + const process = await next.process; + if (process?.running) return process; + void process?.kill(); + return undefined; + } + + dispose() { + this.warm(); + } + + /** Keeps the spare when it fits one of `wheres`, else starts one for the + * first; with none, keeps none. */ + private fill(wheres: readonly Where[]) { + const current = this.next; + if (current && wheres.some((where) => place(where) === current.place)) + return; + this.next = undefined; + void current?.process.then((process) => process?.kill()); + const [first] = wheres; + if (this.stopped || !first) return; + const next = { + place: place(first), + process: ClaudeProcess.spawn(this.spawn, { + ...first, + sessionId: this.newId(), + tools: this.tools, + }).catch((error) => { + console.warn("Claude Code could not start a spare session", error); + return undefined; + }), + }; + this.next = next; + void next.process.then(async (process) => { + await process?.exited; + if (this.next !== next) return; + this.next = undefined; + this.stopped = true; + }); + } +} + /** Sessions saved in local storage, by agent and then conversation. */ export function localSessions( storage: Storage, diff --git a/src/bundled/claude-code/tabs.test.tsx b/src/bundled/claude-code/tabs.test.tsx index 5d1a128ea..d20ce78e5 100644 --- a/src/bundled/claude-code/tabs.test.tsx +++ b/src/bundled/claude-code/tabs.test.tsx @@ -61,12 +61,12 @@ function applied({ loggedIn = true } = {}) { const ctx = { pluginOwner: { id: "buzz.claude-code", revision: "r1" }, host: { spawn, request: vi.fn() }, - relay: { snapshot: () => ({ status: "signed-out" }) }, agents2: { register: (registered: AgentType) => { type = registered; }, - snapshot: () => ({ status: "ready", agents: [] }), + snapshot: () => ({ status: "ready", agents: [], running: [] }), + relay: () => undefined, subscribe: () => () => undefined, }, effect: vi.fn(), @@ -91,7 +91,7 @@ async function choose(label: string, option: string) { } it("is an agent type on the desktop app", () => { - expect(inject).toEqual(["agents2", "host", "relay"]); + expect(inject).toEqual(["agents2", "host"]); const { type } = installed(); expect(type).toMatchObject({ id: "claude-code", title: "Claude Code" }); expect(type.defaults()).toEqual({ config: DEFAULT_CONFIG }); diff --git a/src/bundled/codex/README.md b/src/bundled/codex/README.md index 72edcd631..dd92278f5 100644 --- a/src/bundled/codex/README.md +++ b/src/bundled/codex/README.md @@ -2,8 +2,10 @@ A bundled desktop plugin, on by default on macOS and Linux, using the native process host introduced in #731. Agents2 owns the identity, admission, configuration, and signed Buzz actions. This plugin -owns Codex work after delivery handover. It starts one `codex app-server` per agent -and uses independent Codex threads for Buzz conversations. +owns Codex work after delivery handover. It starts one `codex app-server` for all +agents and uses an independent Codex thread for each agent's Buzz conversation. +Each thread carries its agent's workspace, instructions and tools, so one crash +fails every agent's current turn; the next mention reopens the server. ## Try it @@ -47,7 +49,10 @@ current turn finishes. Expect the marker. Thread scope is the default. Channel scope shares a session across channel threads; DMs always share a conversation. Workspace changes start a fresh session. Bindings persist locally under the community and agent identity. Earlier bindings without dynamic tools are ignored; new conversations start fresh. Switching -communities, removing an agent, or disabling the plugin ends its server. +communities does not end an agent's work. Removing the agent, leaving or +disconnecting from its community interrupts its turns and ends shells they left +running; the server ends when no +Codex agent that has worked is still running, or when the plugin is disabled. If a saved Codex thread no longer exists, the next mention starts a fresh one. To inspect a saved session, see [View a Codex session](../../../docs/view-codex-session.md). @@ -76,8 +81,9 @@ workspace and a simulated relay. It sends no Buzz messages. It checks native she and file tools, the native coding prompt, conversation context, default steering, idle follow-ups, process shutdown, saved-thread reuse, changed and cleared instructions, missing-thread recovery, and disabled inherited MCP tools. It also -checks real dynamic Buzz callbacks, memory, workspace file uploads, and normal -final sends without aborted-turn markers. Set +checks real dynamic Buzz callbacks, memory, workspace file uploads, normal +final sends without aborted-turn markers, and two agents sharing one server with +their own workspace, instructions and replies. Set `BUZZ_CODEX_TEST_MODEL` to another available model if needed. It prints only sanitized outcomes; it does not save raw protocol or session logs. diff --git a/src/bundled/codex/index.test.ts b/src/bundled/codex/index.test.ts index 8bb490d70..8ca97b59a 100644 --- a/src/bundled/codex/index.test.ts +++ b/src/bundled/codex/index.test.ts @@ -2,6 +2,7 @@ import * as React from "react"; import { afterEach, beforeEach, expect, it, vi } from "vitest"; import type { AgentType } from "../../features/agents2/service"; +import { CodexRuntime } from "./runtime"; import { apply } from "./index"; const desktop = vi.hoisted(() => ({ tauri: true })); @@ -23,13 +24,12 @@ function applied() { react: React, pluginOwner: { id: "buzz.codex", revision: "r1" }, host: { spawn: vi.fn(), request: vi.fn() }, - relay: { snapshot: () => ({ status: "signed-out" }), subscribe }, - communityReader: { snapshot: () => ({}), subscribe }, agents2: { register: (registered: AgentType) => { type = registered; }, - snapshot: () => ({ status: "ready", agents: [] }), + snapshot: () => ({ status: "ready", agents: [], running: [] }), + relay: () => undefined, subscribe, }, effect: vi.fn(), @@ -48,3 +48,30 @@ it("registers only where the desktop app can run codex and base64", () => { desktop.tauri = false; expect(applied()).toBeUndefined(); }); + +it("stops agents that stop running even when the agent list could not be read", () => { + const sync = vi.spyOn(CodexRuntime.prototype, "sync"); + const agent = { pubkey: "agent", type: "buzz.codex/codex" }; + let state = { status: "ready", agents: [agent], running: [agent] }; + let notify = () => undefined; + apply({ + react: React, + pluginOwner: { id: "buzz.codex" }, + host: { spawn: vi.fn(), request: vi.fn() }, + agents2: { + register: () => undefined, + snapshot: () => state, + relay: () => undefined, + subscribe: (listener: () => undefined) => { + notify = listener; + return () => undefined; + }, + }, + effect: vi.fn(), + } as never); + expect(sync).toHaveBeenLastCalledWith([agent]); + // A failed refresh keeps the list; leaving the community then empties it. + state = { ...state, status: "error", agents: [], running: [] }; + notify(); + expect(sync).toHaveBeenLastCalledWith([]); +}); diff --git a/src/bundled/codex/index.ts b/src/bundled/codex/index.ts index 1cdb8405d..37195bef2 100644 --- a/src/bundled/codex/index.ts +++ b/src/bundled/codex/index.ts @@ -3,7 +3,7 @@ import type { PluginModule } from "../../plugins/api"; import { defaults, config } from "./config"; import { CodexRuntime } from "./runtime"; import { createTabs } from "./tabs"; -export const inject = ["react", "agents2", "host", "relay", "communityReader"]; +export const inject = ["react", "agents2", "host"]; export const apply: PluginModule["apply"] = (ctx) => { // Only the desktop app can start `codex`, and file uploads run `base64`, // which stock Windows lacks; no unusable type elsewhere. @@ -14,36 +14,19 @@ export const apply: PluginModule["apply"] = (ctx) => { (async () => { throw new Error("Codex agents run only in the desktop app"); }); - const runtime = new CodexRuntime(start, ctx.relay); + const runtime = new CodexRuntime(start, ctx.agents2.relay); const typeKey = `${ctx.pluginOwner?.id ?? "buzz.codex"}/codex`; - let selection = ""; + // Agents that stop running stop their turns, and the last one stops the + // server; switching communities or a reconnect does not. const sync = () => { - const community = ctx.communityReader.snapshot(); - const selected = JSON.stringify([community.selected, community.viewer]); - if (selected !== selection) { - runtime.dispose(); - selection = selected; - } const snapshot = ctx.agents2.snapshot(); - const relay = ctx.relay.snapshot(); - if (relay.status === "disconnected") { - runtime.sync([], ""); - return; - } - if (snapshot.status !== "ready" || relay.status !== "ready") return; - runtime.sync( - snapshot.agents.filter((agent) => agent.type === typeKey), - relay.scope ?? "", - ); + if (snapshot.status === "loading") return; + runtime.sync(snapshot.running.filter((agent) => agent.type === typeKey)); }; const offAgents = ctx.agents2.subscribe(sync); - const offRelay = ctx.relay.subscribe(sync); - const offCommunity = ctx.communityReader.subscribe(sync); sync(); ctx.effect(() => () => { offAgents(); - offRelay(); - offCommunity(); runtime.dispose(); }); const { CodexTab, SettingsTab } = createTabs(ctx.react, start, runtime); diff --git a/src/bundled/codex/live.mjs b/src/bundled/codex/live.mjs index fe197614b..dceae90ae 100644 --- a/src/bundled/codex/live.mjs +++ b/src/bundled/codex/live.mjs @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import { spawn } from "node:child_process"; import { access, mkdtemp, readFile, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; import { build } from "rolldown"; import { JSDOM } from "jsdom"; @@ -20,6 +20,7 @@ const { CodexRuntime } = await import(join(workspace, "runtime.mjs")); const { AppServer } = await import(join(workspace, "rpc.mjs")); const children = new Set(); const notices = []; +let servers = 0; const sent = []; let acceptedSteers = 0; const nativeSpawn = async (_id, options = {}) => { @@ -46,6 +47,7 @@ const nativeSpawn = async (_id, options = {}) => { exited, }; } + servers++; const child = spawn( "codex", [ @@ -58,7 +60,8 @@ const nativeSpawn = async (_id, options = {}) => { `mcp_servers={"fixture.tools"={command="sh",args=["-c",${JSON.stringify(`touch '${join(workspace, "mcp-must-not-start")}'`)}]}}`, ], { - cwd: options.cwd, + // Like the desktop host, expand ~ in a process's working directory. + cwd: options.cwd?.replace(/^~(?=\/|$)/, homedir()), detached: true, env: Object.fromEntries( Object.entries(process.env).filter( @@ -169,7 +172,7 @@ const relay = { }, }), }; -const runtime = new CodexRuntime(nativeSpawn, relay, store); +const runtime = new CodexRuntime(nativeSpawn, () => relay, store); runtime.sync([{ pubkey }], scope); let id = 0; const delivery = (content) => { @@ -469,6 +472,52 @@ try { console.log( "PASS real dynamic callbacks, resumed tools, shared reads, memory, workspace file read/upload and final send without duplicate replies", ); + const other = "9".repeat(64); + const otherWorkspace = await mkdtemp(join(tmpdir(), "buzz-codex-other-")); + const otherPublished = []; + runtime.sync([{ pubkey }, { pubkey: other }]); + const before = servers; + const first = delivery( + "Reply exactly FIRST_AGENT_OK. No coding tools. Send the reply with buzz.send.", + ); + const second = delivery( + "Run pwd, then reply with its output. Send the reply with buzz.send.", + ); + await runtime.run(first); + await runtime.run({ + ...second, + agent: { + ...second.agent, + pubkey: other, + name: "Other", + publish: async (event) => { + otherPublished.push(event); + return { ...event, id: "8".repeat(64) }; + }, + }, + config: { + ...second.config, + workspace: otherWorkspace, + instructions: `${second.config.instructions} Every final answer MUST contain OTHER_AGENT.`, + }, + }); + await condition( + () => + published.length === 8 && + otherPublished.length === 1 && + runtime.sessions(pubkey)[0]?.status === "Idle" && + runtime.sessions(other)[0]?.status === "Idle", + "two agents on one server", + ); + // The previous step's server is still open and serves both agents. + assert.equal(servers, before); + assert.match(published.at(-1).content, /FIRST_AGENT_OK/); + assert.doesNotMatch(published.at(-1).content, /OTHER_AGENT/); + assert.match(otherPublished[0].content, /OTHER_AGENT/); + assert(otherPublished[0].content.includes(otherWorkspace.split("/").at(-1))); + console.log( + "PASS two agents share one server with their own workspace, instructions and replies", + ); } finally { await inspect?.close(); runtime.dispose(); diff --git a/src/bundled/codex/runtime.test.ts b/src/bundled/codex/runtime.test.ts index 7931b6875..b2d6599e0 100644 --- a/src/bundled/codex/runtime.test.ts +++ b/src/bundled/codex/runtime.test.ts @@ -186,7 +186,7 @@ function fixture( storage.set(key, value); }, } as Storage; - const runtime = new CodexRuntime(spawn, relay, store); + const runtime = new CodexRuntime(spawn, () => relay, store); runtimes.push(runtime); const events: EventData[] = []; const query = vi.fn(async (filters: readonly Record[]) => @@ -226,7 +226,7 @@ function fixture( upload, remember, }; - runtime.sync([{ pubkey } as Agent], scope); + runtime.sync([{ pubkey } as Agent]); let id = 0; const delivery = (content: string, threadRoot = root): Delivery => { const result: Delivery = { @@ -258,7 +258,8 @@ function fixture( events.push(result.trigger.event as EventData); return result; }; - const selection = { selected: "community", viewer: owner }; + /** Agents2's running agents, whichever community is selected. */ + const running = [{ ...agent, type: "buzz.codex/codex" }]; const listeners: (() => void)[] = []; let runPlugin: (delivery: Delivery) => Promise = (delivery) => runtime.run(delivery); @@ -269,25 +270,9 @@ function fixture( apply({ react: React, host: { spawn }, - communityReader: { - snapshot: () => selection, - subscribe: (listener: () => void) => { - listeners.push(listener); - return () => {}; - }, - }, - relay: { - ...relay, - subscribe: (listener: () => void) => { - listeners.push(listener); - return () => {}; - }, - }, agents2: { - snapshot: () => ({ - status: snapshot.status === "ready" ? "ready" : "loading", - agents: [{ ...agent, type: "buzz.codex/codex" }], - }), + snapshot: () => ({ status: "ready", agents: [], running }), + relay: () => relay, subscribe: (listener: () => void) => { listeners.push(listener); return () => {}; @@ -370,7 +355,7 @@ function fixture( memoryView, remember, upload, - selection, + running, runPlugin: (delivery: Delivery) => runPlugin(delivery), notify: () => listeners.forEach((listener) => { @@ -436,6 +421,135 @@ it("hands over promptly and runs conversations independently on one server", asy tags: expect.arrayContaining([["e", root, "", "reply"]]), }); }); +/** A second agent on the same runtime, with its own identity and publisher. */ +function second(f: ReturnType) { + const other = "9".repeat(64); + const base = f.delivery("unused").agent; + const agent = { + ...base, + pubkey: other, + name: "Other", + publish: vi.fn(async (event: unknown) => ({ + ...(event as object), + id: "8".repeat(64), + })), + } as unknown as Delivery["agent"]; + f.runtime.sync([{ pubkey } as Agent, { pubkey: other } as Agent]); + return { + agent, + delivery: (content: string) => ({ + ...f.delivery(content, "7".repeat(64)), + agent, + }), + }; +} +const contents = (publish: { mock: { calls: unknown[][] } }) => + publish.mock.calls.map(([event]) => (event as { content: string }).content); +const servers = (f: ReturnType) => + f.spawn.mock.calls.filter(([id]) => id === "app-server"); +it("serves every agent from one server and routes each tool call to its own agent", async () => { + const f = fixture(); + const b = second(f); + await f.runtime.run(f.delivery("@Codex first")); + await f.runtime.run(b.delivery("@Other second")); + await vi.waitFor(() => expect(f.starts()).toHaveLength(2)); + expect(servers(f)).toHaveLength(1); + expect(f.starts()[1]?.params).toMatchObject({ + additionalContext: { + "buzz.instructions": { value: expect.stringContaining("Other") }, + }, + }); + await f.complete(1, "FROM OTHER"); + await f.complete(0, "FROM CODEX"); + await vi.waitFor(() => expect(f.publish).toHaveBeenCalledTimes(1)); + await vi.waitFor(() => expect(b.agent.publish).toHaveBeenCalledTimes(1)); + expect(contents(f.publish)).toEqual(["FROM CODEX"]); + expect(contents(b.agent.publish as never)).toEqual(["FROM OTHER"]); + expect(f.runtime.sessions("9".repeat(64))[0]?.status).toBe("Idle"); +}); +it("stops a removed agent's turn without ending the server the others share", async () => { + const f = fixture(); + const b = second(f); + await f.runtime.run(f.delivery("work")); + await f.runtime.run(b.delivery("other work")); + await vi.waitFor(() => expect(f.starts()).toHaveLength(2)); + const removed = (f.starts()[1]?.params as { threadId: string } | undefined) + ?.threadId; + f.runtime.sync([{ pubkey } as Agent]); + await vi.waitFor(() => + expect( + f.sent.some( + (wire) => + wire.method === "thread/unsubscribe" && + (wire.params as { threadId: string }).threadId === removed, + ), + ).toBe(true), + ); + for (const method of ["turn/interrupt", "thread/backgroundTerminals/clean"]) + expect(f.sent.find((wire) => wire.method === method)?.params).toMatchObject( + { threadId: removed }, + ); + expect(f.process.end).not.toHaveBeenCalled(); + expect(f.runtime.sessions("9".repeat(64))).toHaveLength(0); + await f.complete(0, "STILL HERE"); + await vi.waitFor(() => expect(f.publish).toHaveBeenCalledTimes(1)); + expect(contents(f.publish)).toEqual(["STILL HERE"]); + expect(b.agent.publish).not.toHaveBeenCalled(); + f.runtime.sync([]); + await f.exits.promise; + expect(f.process.end).toHaveBeenCalled(); +}); +it("ends a stopped agent's leftover shells and lets it start again only after letting go", async () => { + const f = fixture(); + const b = second(f); + await f.runtime.run(f.delivery("work")); + await f.runtime.run(b.delivery("start a dev server")); + await vi.waitFor(() => expect(f.starts()).toHaveLength(2)); + const thread = (f.starts()[1]?.params as { threadId: string } | undefined) + ?.threadId; + await f.complete(1, "SERVER RUNNING"); + await vi.waitFor(() => expect(b.agent.publish).toHaveBeenCalledTimes(1)); + await f.runtime.run(b.delivery("busy again")); + await vi.waitFor(() => expect(f.starts()).toHaveLength(3)); + const index = (method: string) => + f.sent + .map( + (wire) => + wire.method === method && + (wire.params as { threadId: string }).threadId === thread, + ) + .lastIndexOf(true); + const before = index("thread/unsubscribe"); + f.runtime.sync([{ pubkey } as Agent]); + f.runtime.sync([{ pubkey } as Agent, { pubkey: "9".repeat(64) } as Agent]); + await f.runtime.run(b.delivery("back")); + await vi.waitFor(() => expect(f.starts()).toHaveLength(4)); + expect(index("thread/unsubscribe")).toBeGreaterThan(before); + expect(index("thread/backgroundTerminals/clean")).toBeGreaterThan( + index("thread/unsubscribe"), + ); + expect(index("thread/resume")).toBeGreaterThan( + index("thread/backgroundTerminals/clean"), + ); + expect(f.process.end).not.toHaveBeenCalled(); + await f.complete(3, "BACK"); + await f.complete(0, "STILL HERE"); + await vi.waitFor(() => expect(f.publish).toHaveBeenCalledTimes(1)); + await vi.waitFor(() => expect(b.agent.publish).toHaveBeenCalledTimes(2)); + expect(contents(b.agent.publish as never)).toEqual([ + "SERVER RUNNING", + "BACK", + ]); +}); +it("reopens the shared server after it exits", async () => { + const f = fixture(); + await f.runtime.run(f.delivery("work")); + await vi.waitFor(() => expect(f.starts()).toHaveLength(1)); + f.exits.resolve(3); + await vi.waitFor(() => expect(f.publish).toHaveBeenCalledTimes(1)); + await f.runtime.run(f.delivery("again")); + await vi.waitFor(() => expect(servers(f)).toHaveLength(2)); +}); it("serializes owner follow-ups and routes tool replies to the latest accepted request", async () => { const f = fixture(); await f.runtime.run(f.delivery("first")); @@ -655,11 +769,11 @@ it("publishes unexpected exit and empty-completion failures instead of silently content: expect.stringContaining("Codex exited"), }); }); -it("ends the server on community change and suppresses a late final answer", async () => { +it("ends the server when its agent stops running and suppresses a late final answer", async () => { const f = fixture(); await f.runtime.run(f.delivery("work")); await vi.waitFor(() => expect(f.starts()).toHaveLength(1)); - f.runtime.sync([], "other-community"); + f.runtime.sync([]); f.complete(); await f.exits.promise; expect(f.process.end).toHaveBeenCalled(); @@ -880,7 +994,7 @@ it("delivers a plain follow-up after startup without waiting for the running tur await vi.waitFor(() => expect(f.publish).toHaveBeenCalledTimes(1)); }); -it("keeps plugin work alive through relay reconnect and disposes on real community change", async () => { +it("keeps plugin work alive through relay reconnect and ends it when its agent stops running", async () => { const f = fixture([], undefined, true); try { await f.runPlugin(f.delivery("work")); @@ -896,9 +1010,7 @@ it("keeps plugin work alive through relay reconnect and disposes on real communi expect(f.process.end).not.toHaveBeenCalled(); await f.runPlugin(f.delivery("more work")); await vi.waitFor(() => expect(f.starts()).toHaveLength(2)); - f.selection.selected = "other-community"; - f.snapshot.status = "connecting"; - f.snapshot.scope = undefined; + f.running.length = 0; f.notify(); f.complete(); await f.exits.promise; diff --git a/src/bundled/codex/runtime.ts b/src/bundled/codex/runtime.ts index 5804a7177..7f0f9cef7 100644 --- a/src/bundled/codex/runtime.ts +++ b/src/bundled/codex/runtime.ts @@ -9,6 +9,7 @@ import { appClient } from "../../buzz-mcp/app-client"; import { TOOLS, callTool } from "../../buzz-mcp/tools"; import { config, + defaults, absoluteWorkspace, validWorkspace, type Config, @@ -30,6 +31,8 @@ import { } from "./rpc"; type Request = { + /** The agent's community and owner, as `origin:viewer`. */ + scope: string; event: EventData; agent: AgentHandle; settings: Config; @@ -40,6 +43,8 @@ type Request = { }; type Saved = { threadId: string; workspace: string }; type Active = { + /** The server this turn runs on; steering must reach the same process. */ + rpc: AppServer; threadId?: string; turnId?: string; ready: Promise; @@ -58,11 +63,17 @@ type Lane = { type Entry = { storageKey: string; abort: AbortController; - rpc: AppServer; - opening: Promise; + /** Settles once the same agent's previous run has let go of its threads. */ + after: Promise; lanes: Map; saved: Record; }; +type Server = { + rpc: AppServer; + opening: Promise; + /** Threads this process has loaded, and the agent each belongs to. */ + threads: Map; +}; export type SessionView = { key: string; status: string; detail: string }; const input = (text: string) => [{ type: "text", text, text_elements: [] }]; const message = (error: unknown) => @@ -74,15 +85,19 @@ function textOf(event: EventData, name: string) { } /** Agents2 owns admission/identity. This plugin owns work after hand-over, and - * uses one app-server per agent with independent Codex threads per conversation. */ + * uses one app-server for every agent. Each conversation is its own Codex + * thread carrying its agent's workspace, instructions and tools, and tool + * calls find their agent by thread and turn. */ export class CodexRuntime { private entries = new Map(); + private server: Server | undefined; + private stopping = new Map>(); private views = new Map(); private listeners = new Set<() => void>(); - private scope = ""; constructor( private readonly spawn: Spawn, - private readonly relay: RelayData, + /** The owner's connection to each agent's own community. */ + private readonly relay: (pubkey: string) => RelayData | undefined, private readonly storage: Storage = localStorage, ) {} subscribe = (listener: () => void) => { @@ -94,6 +109,8 @@ export class CodexRuntime { sessions = (pubkey: string): readonly SessionView[] => this.views.get(pubkey) ?? EMPTY; private status(pubkey: string, key: string, status: string, detail = "") { + // A stopped agent's draining turns don't bring its sessions back. + if (!this.entries.has(pubkey)) return; const others = this.sessions(pubkey).filter((view) => view.key !== key); this.views.set(pubkey, [ ...others.slice(-31), @@ -101,28 +118,73 @@ export class CodexRuntime { ]); for (const listener of this.listeners) listener(); } - sync(agents: readonly Agent[], scope: string) { - if (scope !== this.scope) { - this.dispose(); - this.scope = scope; - } + sync(agents: readonly Agent[]) { const present = new Set(agents.map((agent) => agent.pubkey)); for (const [pubkey, entry] of this.entries) if (!present.has(pubkey)) { entry.abort.abort(); - void entry.rpc.close(); this.entries.delete(pubkey); this.views.delete(pubkey); + if (this.server) this.stop(this.server, pubkey, entry); } + if (!this.entries.size) this.close(); for (const listener of this.listeners) listener(); } + /** Other agents share the server, so a stopped agent's turns interrupt + * themselves. Then the shells its finished turns left are ended, which its + * own process used to do by exiting. Until then, the same agent starting + * again waits rather than resuming a thread that is still letting go. */ + private stop(server: Server, pubkey: string, entry: Entry) { + const stopped = (async () => { + await Promise.all( + [...entry.lanes.values()].map((lane) => + lane.tail.catch(() => undefined), + ), + ); + for (const [threadId, owner] of server.threads) + if (owner === pubkey) { + server.threads.delete(threadId); + await server.rpc + .request("thread/backgroundTerminals/clean", { threadId }) + .catch(() => undefined); + } + })(); + this.stopping.set(pubkey, stopped); + void stopped.finally(() => { + if (this.stopping.get(pubkey) === stopped) this.stopping.delete(pubkey); + }); + } dispose() { - for (const entry of this.entries.values()) { - entry.abort.abort(); - void entry.rpc.close(); - } + for (const entry of this.entries.values()) entry.abort.abort(); this.entries.clear(); this.views.clear(); + this.stopping.clear(); + this.close(); + } + private close() { + void this.server?.rpc.close(); + this.server = undefined; + } + private open() { + if (this.server) return this.server; + const rpc = new AppServer((call) => this.tool(call)); + // Threads set their own cwd; the process just needs a directory that exists. + const server: Server = { + rpc, + opening: rpc.open(this.spawn, defaults.workspace), + threads: new Map(), + }; + // An exit fails current work and permits the next ordinary mention to reopen. + rpc.onMessage((wire) => { + if (wire.method === "connection/closed" && this.server === server) + this.server = undefined; + }); + void server.opening.catch(() => { + if (this.server === server) this.server = undefined; + void rpc.close(); + }); + this.server = server; + return server; } private entry(request: Request) { const pubkey = request.agent.pubkey; @@ -131,7 +193,7 @@ export class CodexRuntime { let saved: Record = {}; try { const stored: unknown = JSON.parse( - this.storage.getItem(this.storageKey(pubkey)) ?? "{}", + this.storage.getItem(this.storageKey(request)) ?? "{}", ); if (stored && typeof stored === "object" && !Array.isArray(stored)) saved = Object.fromEntries( @@ -145,34 +207,19 @@ export class CodexRuntime { } catch { /* Start fresh after malformed local data. */ } - const rpc = new AppServer((call) => this.tool(entry, call)); const entry: Entry = { - storageKey: this.storageKey(pubkey), + storageKey: this.storageKey(request), abort: new AbortController(), - rpc, + after: this.stopping.get(pubkey) ?? Promise.resolve(), saved, lanes: new Map(), - opening: rpc.open(this.spawn, request.settings.workspace), }; - // An exit fails current work and permits the next ordinary mention to reopen. - rpc.onMessage((wire) => { - if ( - wire.method === "connection/closed" && - this.entries.get(pubkey) === entry - ) { - this.entries.delete(pubkey); - } - }); - void entry.opening.catch(() => { - if (this.entries.get(pubkey) === entry) this.entries.delete(pubkey); - void rpc.close(); - }); this.entries.set(pubkey, entry); return entry; } - private storageKey(pubkey: string) { + private storageKey(request: Request) { // Resume restores the persisted tool schema; pre-tools bindings start fresh. - return `buzz.codex.sessions.v3:${this.scope}:${pubkey}`; + return `buzz.codex.sessions.v3:${request.scope}:${request.agent.pubkey}`; } private save(request: Request, entry: Entry, key: string, saved: Saved) { entry.saved[key] = saved; @@ -197,8 +244,8 @@ export class CodexRuntime { "Codex timers are not supported yet; use mentions or event watches.", ); if (trigger.event.pubkey !== agent.owner) return; - const snapshot = this.relay.snapshot(); - if (snapshot.status !== "ready") + const snapshot = this.relay(agent.pubkey)?.snapshot(); + if (snapshot?.status !== "ready") throw new Error("Connect to the agent's community first"); const channelId = delivery.channelId; if (!channelId) throw new Error("The event has no unambiguous channel"); @@ -210,6 +257,7 @@ export class CodexRuntime { channel?.channelType === "dm" ? undefined : rootOf(trigger.event); const root = settings.scope === "channel" ? undefined : toolRoot; const request: Request = { + scope: snapshot.scope ?? "", event: trigger.event, agent, settings, @@ -234,7 +282,7 @@ export class CodexRuntime { const entry = this.entry(request); let lane = entry.lanes.get(key); if (!lane) { - lane = { tail: Promise.resolve() }; + lane = { tail: entry.after }; entry.lanes.set(key, lane); } if (lane.active) { @@ -254,7 +302,7 @@ export class CodexRuntime { this.start(request, text, key, lane, entry); return; } - await entry.rpc.request("turn/steer", { + await active.rpc.request("turn/steer", { threadId: active.threadId, expectedTurnId: active.turnId, input: input( @@ -308,8 +356,8 @@ export class CodexRuntime { } private async history(request: Request, signal: AbortSignal) { signal.throwIfAborted(); - const snapshot = this.relay.snapshot(); - if (snapshot.status !== "ready" || snapshot.scope !== this.scope) + const snapshot = this.relay(request.agent.pubkey)?.snapshot(); + if (snapshot?.status !== "ready" || snapshot.scope !== request.scope) throw new Error("The community changed"); return conversationHistory(request.event, request.conversation, (filters) => snapshot.session.read(filters, { signal }), @@ -328,8 +376,9 @@ export class CodexRuntime { ], }); } - private async tool(entry: Entry, call: ToolCall): Promise { - const active = [...entry.lanes.values()] + private async tool(call: ToolCall): Promise { + const active = [...this.entries.values()] + .flatMap((entry) => [...entry.lanes.values()]) .map((lane) => lane.active) .find( (active) => @@ -362,8 +411,8 @@ export class CodexRuntime { check, signal: active.signal, memories: async () => { - const snapshot = this.relay.snapshot(); - if (snapshot.status !== "ready" || snapshot.scope !== this.scope) + const snapshot = this.relay(request.agent.pubkey)?.snapshot(); + if (snapshot?.status !== "ready" || snapshot.scope !== request.scope) throw new Error("Buzz is not connected to this community"); const view = snapshot.session.agentMemories.open(request.agent.pubkey); const abort = () => view.dispose(); @@ -448,12 +497,14 @@ export class CodexRuntime { let ready!: () => void; let notReady!: (error: unknown) => void; const turnAbort = new AbortController(); + const server = this.open(); const signal = AbortSignal.any([ entry.abort.signal, turnAbort.signal, AbortSignal.timeout(29 * 60_000), ]); const active: Active = { + rpc: server.rpc, ready: new Promise((resolve, reject) => { ready = resolve; notReady = reject; @@ -477,7 +528,7 @@ export class CodexRuntime { void completed.catch(() => undefined); const abort = () => fail(signal.reason); signal.addEventListener("abort", abort, { once: true }); - const off = entry.rpc.onMessage((wire) => { + const off = server.rpc.onMessage((wire) => { const p = wire.params as | { threadId?: string; @@ -518,7 +569,7 @@ export class CodexRuntime { } }); try { - const rpc = await entry.opening; + const rpc = await server.opening; signal.throwIfAborted(); // The process host expands ~/.buzz and creates it; Codex's RPC needs // an absolute path rather than shell-style home notation. @@ -611,6 +662,7 @@ export class CodexRuntime { }); } active.threadId = started.thread.id; + server.threads.set(active.threadId, agent.pubkey); signal.throwIfAborted(); await rpc.request("thread/name/set", { threadId: active.threadId, @@ -660,23 +712,21 @@ export class CodexRuntime { this.status(agent.pubkey, key, "Idle"); } } catch (error) { + const ended = active.finished; active.finished = true; notReady(error); await active.steering; - if ( - signal.aborted && - active.threadId && - active.turnId && - !entry.abort.signal.aborted - ) { + // Other agents share the server, so even a stopped agent interrupts + // its turn rather than ending the process. + if (signal.aborted && !ended && active.threadId && active.turnId) { try { - await entry.rpc.request("turn/interrupt", { + await server.rpc.request("turn/interrupt", { threadId: active.threadId, turnId: active.turnId, }); - await this.terminals(entry.rpc, active.threadId); + await this.terminals(server.rpc, active.threadId); } catch { - await entry.rpc.close(); + await server.rpc.close(); } } if (!entry.abort.signal.aborted) { @@ -692,10 +742,10 @@ export class CodexRuntime { off(); // Release the subscription before the next resume: loaded-thread rejoin // ignores instruction/config overrides while a client is subscribed. - if (active.threadId && !entry.abort.signal.aborted) - await entry.rpc + if (active.threadId) + await server.rpc .request("thread/unsubscribe", { threadId: active.threadId }) - .catch(() => entry.rpc.close()); + .catch(() => server.rpc.close()); if (lane.active === active) delete lane.active; } } diff --git a/src/bundled/codex/tabs.test.tsx b/src/bundled/codex/tabs.test.tsx index 4a7aacd24..0034f5c92 100644 --- a/src/bundled/codex/tabs.test.tsx +++ b/src/bundled/codex/tabs.test.tsx @@ -11,7 +11,6 @@ import { import { afterEach, expect, it, vi } from "vitest"; import type { AgentViewProps } from "../../features/agents2/service"; import type { HostProcessOptions } from "../../features/host/service"; -import type { RelayData } from "../../features/relay/service"; import { defaults, type Config } from "./config"; import { createTabs } from "./tabs"; import { CodexRuntime } from "./runtime"; @@ -72,7 +71,7 @@ function fixture() { processes.push(process); return process; }); - const runtime = new CodexRuntime(spawn, {} as RelayData, {} as Storage); + const runtime = new CodexRuntime(spawn, () => undefined, {} as Storage); const tabs = createTabs(React, spawn, runtime); const props = (settings = defaults, save = vi.fn(async () => {})) => ({ diff --git a/src/features/agents2/service.test.ts b/src/features/agents2/service.test.ts index 19fe94cb7..6a48bf26e 100644 --- a/src/features/agents2/service.test.ts +++ b/src/features/agents2/service.test.ts @@ -5,7 +5,12 @@ import type { LiveBatch, LiveListener } from "../relay/incoming"; import type { RelayData, RelaySnapshot } from "../relay/service"; import type { RelaySession } from "../relay/session"; import type { AgentIdentity, AgentsNative } from "./native"; -import { Agents2Service, type AgentType, type Delivery } from "./service"; +import { + Agents2Service, + type AgentCommunities, + type AgentType, + type Delivery, +} from "./service"; import { memoryStorage } from "./test-fakes"; // Cleanup's channel step has its own coverage (relay-removal); here only its @@ -33,7 +38,7 @@ const event = (id: string, patch: Partial = {}) => ...patch, }) as unknown as RelayEvent; -function fakeRelay() { +function fakeRelay(origin = "https://relay.example.test") { const live = new Set(); const changes = new Set<() => void>(); const archived = new Set(); @@ -76,8 +81,8 @@ function fakeRelay() { status: "ready", generation: 1, viewer, - origin: "https://relay.example.test", - scope: `https://relay.example.test:${viewer}`, + origin, + scope: `${origin}:${viewer}`, session, }; const connected_ = snapshot; @@ -88,6 +93,7 @@ function fakeRelay() { changes.add(listener); return () => changes.delete(listener); }, + retry: vi.fn(), } as unknown as RelayData, /** Drops the connection, or restores it with a new session. */ connect(connected: boolean) { @@ -96,6 +102,16 @@ function fakeRelay() { : { status: "disconnected", generation: 2, session }; for (const listener of changes) listener(); }, + /** Fails the connection, as a first connect that timed out does. */ + fail() { + snapshot = { status: "error", generation: 4, session, error: "down" }; + for (const listener of changes) listener(); + }, + /** Replaces the session, as after the transport was lost. */ + reconnecting() { + snapshot = { status: "connecting", generation: 3, session }; + for (const listener of changes) listener(); + }, /** Drops and restores the socket inside the same session, as live.ts does. */ recover() { for (const next of ["retrying", "connected"]) { @@ -107,6 +123,39 @@ function fakeRelay() { for (const listener of live) listener(batch); }, archives, + origin, + }; +} + +/** The viewer's joined communities, one selected; `open` connects any of them. */ +function fakeCommunities(...joined: ReturnType[]) { + let relays = joined; + const changes = new Set<() => void>(); + let selected: string | null = relays[0]?.origin ?? null; + return { + communities: { + snapshot: () => ({ + viewer, + selected, + memberships: relays.map(({ origin }) => ({ id: origin, name: origin })), + }), + subscribe: (listener: () => void) => { + changes.add(listener); + return () => changes.delete(listener); + }, + open: vi.fn( + (id: string) => relays.find((fake) => fake.origin === id)?.relay, + ), + } as unknown as AgentCommunities, + select(next: string | null) { + selected = next; + for (const listener of changes) listener(); + }, + leave(origin: string) { + relays = relays.filter((fake) => fake.origin !== origin); + if (selected === origin) selected = null; + for (const listener of changes) listener(); + }, }; } @@ -171,8 +220,10 @@ async function setup({ subscribe: () => () => {}, }); const fake = fakeRelay(); + const second = fakeRelay("https://other.example.test"); + const client = fakeCommunities(fake, second); const native = fakeNative(identities); - const service = new Agents2Service(ctx, fake.relay, native, storage); + const service = new Agents2Service(ctx, client.communities, native, storage); const plugin = ctx.extend({ pluginOwner: Object.freeze({ id: "example", revision: "one" }), }); @@ -206,8 +257,15 @@ async function setup({ run, emit: fake.emit, connect: fake.connect, + fail: fake.fail, + relay: fake.relay, + reconnecting: fake.reconnecting, recover: fake.recover, archives: fake.archives, + second, + select: client.select, + leave: client.leave, + communities: client.communities, ctx, }; } @@ -424,6 +482,121 @@ it("claims an agent as it comes into view, and releases it as it leaves, without await vi.waitFor(() => expect(native.claim).toHaveBeenCalledWith(bot)); }); +it("keeps running an agent while another community is selected, and gives it only its community's events", async () => { + const { service, run, emit, second, select } = await setup(); + await service.create({ type: "example/echo", name: "Echo" }); + select(second.origin); + expect(service.find(bot)).toBeUndefined(); + expect(service.snapshot().running.map((agent) => agent.pubkey)).toEqual([ + bot, + ]); + expect(service.relay(bot)?.snapshot().origin).toBe( + "https://relay.example.test", + ); + second.emit({ events: [event("2", { tags: [["p", bot]] })] }); + const mention = event("1", { tags: [["p", bot]] }); + emit({ events: [mention] }); + await vi.waitFor(() => expect(run).toHaveBeenCalledTimes(1)); + expect(run.mock.calls[0]?.[0].trigger).toEqual({ + type: "mention", + event: mention, + }); + await settle(); + expect(run).toHaveBeenCalledTimes(1); +}); + +it("connects an agent's community at start without selecting it", async () => { + const { service, run, second, communities } = await setup({ + identities: [ + { + pubkey: bot, + relay: "wss://other.example.test", + owner: viewer, + type: "example/echo", + name: "Echo", + deleted: false, + }, + ], + }); + expect(communities.open).toHaveBeenCalledWith("https://other.example.test"); + expect(service.find(bot)).toBeUndefined(); + second.emit({ events: [event("1", { tags: [["p", bot]] })] }); + await vi.waitFor(() => expect(run).toHaveBeenCalledTimes(1)); +}); + +it("opens no community for an agent list that arrives after it stopped", async () => { + const ctx = new Context(); + ctx.provide("pluginStatus", { + isActive: () => true, + subscribe: () => () => {}, + }); + const client = fakeCommunities( + fakeRelay(), + fakeRelay("https://other.example.test"), + ); + client.select(null); + const native = fakeNative(); + let list!: (identities: AgentIdentity[]) => void; + native.list.mockReturnValueOnce( + new Promise((resolve) => { + list = resolve; + }), + ); + const service = new Agents2Service( + ctx, + client.communities, + native, + memoryStorage(), + ); + await vi.waitFor(() => expect(list).toBeDefined()); + await ctx.fiber.dispose(); + list([ + { + pubkey: bot, + relay: "wss://other.example.test", + owner: viewer, + type: "example/echo", + name: "Echo", + deleted: false, + }, + ]); + await settle(); + expect(client.communities.open).not.toHaveBeenCalled(); + expect(service.snapshot().running).toEqual([]); +}); + +it("keeps an agent running while its community reconnects, and stops it on disconnect or leave", async () => { + const { service, connect, reconnecting, leave } = await setup(); + await service.create({ type: "example/echo", name: "Echo" }); + const running = () => service.snapshot().running.map((agent) => agent.pubkey); + reconnecting(); + expect(service.find(bot)).toBeUndefined(); + expect(running()).toEqual([bot]); + connect(false); + expect(running()).toEqual([]); + connect(true); + expect(running()).toEqual([bot]); + leave("https://relay.example.test"); + expect(running()).toEqual([]); + expect(service.relay(bot)).toBeUndefined(); +}); + +it("tries a failed unselected community again every so often, and leaves the selected one's Retry to the viewer", async () => { + vi.useFakeTimers(); + const { service, relay, fail, second, select } = await setup(); + await service.create({ type: "example/echo", name: "Echo" }); + fail(); + await vi.advanceTimersByTimeAsync(10_000); + expect(relay.retry).not.toHaveBeenCalled(); + select(second.origin); + await vi.advanceTimersByTimeAsync(5_000); + expect(relay.retry).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(20_000); + expect(relay.retry).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(10_000); + expect(relay.retry).toHaveBeenCalledTimes(2); +}); + it("is not woken by reactions, deletions or DMs", async () => { const { service, run, emit } = await setup(); await service.create({ type: "example/echo", name: "Echo" }); @@ -639,7 +812,12 @@ it("keeps an agent created while identities are still loading", async () => { native.list.mockImplementationOnce( () => new Promise((resolve) => (release = resolve)), ); - const service = new Agents2Service(ctx, fakeRelay().relay, native, storage); + const service = new Agents2Service( + ctx, + fakeCommunities(fakeRelay()).communities, + native, + storage, + ); ctx .extend({ pluginOwner: Object.freeze({ id: "example", revision: "one" }) }) .agents2.register({ diff --git a/src/features/agents2/service.ts b/src/features/agents2/service.ts index dfe86bfa9..9c9fe592c 100644 --- a/src/features/agents2/service.ts +++ b/src/features/agents2/service.ts @@ -2,8 +2,8 @@ // type (its summary line, peek view, settings tabs and `run`); each agent made // from it has its own native-held key, an `agent-attention/v1` configuration the // app owns and edits, and a config blob the type owns. The app delivers matching live events to -// `run` from the stream the owner already receives, so no plugin opens a socket -// or a REQ. +// `run` from the stream the owner already receives in the agent's community, +// selected or not, so no plugin opens a socket or a REQ. import { Service, type Context } from "@deepseek-ai/cordis"; import type { ComponentType } from "react"; import { @@ -11,7 +11,8 @@ import { type Contribution, } from "../../plugins/contributions"; import { removeAgentFromChannels } from "../agents/relay-removal"; -import { relayOrigin } from "../communities/destination"; +import { communityDestination, relayOrigin } from "../communities/destination"; +import type { Communities } from "../communities/service"; import type { RelayEvent } from "../relay/events"; import type { LiveBatch } from "../relay/incoming"; import { relayPartition } from "../relay/partition"; @@ -48,7 +49,7 @@ import { type SkippedObject, } from "./store"; -/** One agent in the selected community. */ +/** One of the viewer's agents, in the community at `relay`. */ export type Agent = Readonly<{ pubkey: string; name: string; @@ -154,7 +155,11 @@ export type AgentType = { export type RegisteredAgentType = Contribution; export type AgentsSnapshot = Readonly<{ status: "unavailable" | "loading" | "ready" | "error"; + /** The selected community's agents, while it is connected. */ agents: readonly Agent[]; + /** Every agent kept running here, whichever community is selected: the + * viewer's agents in each joined community not disconnected. */ + running: readonly Agent[]; error?: string; }>; export type Agents2 = { @@ -164,6 +169,9 @@ export type Agents2 = { subscribe(listener: () => void): () => void; /** The agent of this pubkey in the selected community, if it is an Agents2 agent. */ find(pubkey: string): Agent | undefined; + /** The owner's connection to a running agent's community, which need not be + * the selected one: read the agent's conversations through this. */ + relay(pubkey: string): RelayData | undefined; create(input: Readonly<{ type: string; name: string }>): Promise; save(pubkey: string, change: AgentChange): Promise; /** Deletes its key at once. Leaving its channels and archiving it follow in @@ -186,6 +194,8 @@ const RATE_LIMIT = 60; const RATE_WINDOW_MS = 60_000; const TIMEOUT_LIMIT_MS = 30 * 60_000; const TIMER_TICK_MS = 5_000; +/** How often a failed connection to an unselected community is tried again. */ +const RETRY_MS = 30_000; const EMPTY = Object.freeze({}); const blank = (pubkey: string): AgentRecord => ({ pubkey, @@ -207,6 +217,12 @@ const bounded = (set: Set, id: string) => { if (set.size > SEEN_LIMIT) set.delete(set.values().next().value as string); }; +/** What Agents2 needs of the communities: the viewer, the selected one, and + * a connection to any joined one without selecting it. */ +export type AgentCommunities = Pick< + Communities, + "snapshot" | "subscribe" | "open" +>; type Binding = { scope: string; origin: string; @@ -214,6 +230,23 @@ type Binding = { session: RelaySession; /** Aborts when this binding is replaced, so no work outlives its session. */ controller: AbortController; + stopLive: () => void; +}; +/** A community the viewer has an agent in, and its session while live. */ +type Bound = { + relay: RelayData; + stop: () => void; + binding?: Binding | undefined; + /** When its failed connection was last tried again. */ + retried?: number; +}; +/** The origin a relay URL or community ID names, if it is a valid one. */ +const originOf = (value: string) => { + try { + return communityDestination(value).url; + } catch { + return undefined; + } }; type Job = { trigger: Trigger; channelId?: string }; type WatchTrigger = Extract; @@ -253,8 +286,10 @@ export class Agents2Service extends Service implements Agents2 { private identities: readonly AgentIdentity[] = []; private records: Record; private state: AgentsSnapshot; - private binding: Binding | undefined; - private stopLive: (() => void) | undefined; + /** By origin, so each agent listens in its own community, selected or not. */ + private bound = new Map(); + /** Agents whose community was live at the last update. */ + private connected = new Set(); private runners = new Map(); /** Counts list requests, so only the latest one is applied. */ private listed = 0; @@ -264,7 +299,7 @@ export class Agents2Service extends Service implements Agents2 { constructor( ctx: Context, - private readonly relay: RelayData, + private readonly communities: AgentCommunities, private readonly native: AgentsNative | undefined = nativeAgents(), private readonly storage: Storage = globalThis.localStorage, ) { @@ -274,6 +309,7 @@ export class Agents2Service extends Service implements Agents2 { this.state = Object.freeze({ status: native ? "loading" : "unavailable", agents: [], + running: [], }); ctx.effect(() => { const stops = [ @@ -281,27 +317,31 @@ export class Agents2Service extends Service implements Agents2 { this.update(); this.notify(); }), - relay.subscribe(() => this.bind()), + communities.subscribe(() => { + this.watch(); + this.update(); + }), ]; - this.bind(); + this.watch(); void this.refresh(); // Wakes each runner, which starts any timer that is due. const tick = setInterval(() => { for (const runner of this.runners.values()) void this.drain(runner); + this.reconnect(); }, TIMER_TICK_MS); return () => { clearInterval(tick); + // A list still being read is stale: it must not reopen communities. + this.listed++; for (const stop of stops) stop(); - this.stopLive?.(); - this.stopLive = undefined; - this.binding?.controller.abort(); - this.binding = undefined; + for (const origin of [...this.bound.keys()]) this.unbind(origin); for (const runner of this.runners.values()) this.retire(runner); this.runners.clear(); // Clear what is shown without reconciling, which would make new runners. this.state = Object.freeze({ ...this.state, agents: Object.freeze([]), + running: Object.freeze([]), }); this.notify(); }; @@ -338,10 +378,17 @@ export class Agents2Service extends Service implements Agents2 { }; find = (pubkey: string) => this.state.agents.find((agent) => agent.pubkey === pubkey); + relay = (pubkey: string) => { + const origin = originOf(this.agent(pubkey)?.relay ?? ""); + return origin ? this.bound.get(origin)?.relay : undefined; + }; async create({ type, name }: Readonly<{ type: string; name: string }>) { const native = this.native; - const binding = this.binding; + const selected = this.communities.snapshot().selected; + const binding = selected + ? this.bound.get(originOf(selected) ?? "")?.binding + : undefined; const kind = this.types().find((entry) => entry.key === type); if (!native) throw new Error("Agents run only in the desktop app"); if (!binding) throw new Error("Connect to a community first"); @@ -401,7 +448,7 @@ export class Agents2Service extends Service implements Agents2 { async remove(pubkey: string) { if (!this.native) throw new Error("Agents run only in the desktop app"); await this.native.remove(pubkey); - // Drops its settings, and starts its cleanup if its community is open. + // Drops its settings, and starts its cleanup if its community is connected. await this.refresh(); } @@ -442,16 +489,33 @@ export class Agents2Service extends Service implements Agents2 { config: record.config, }); } - private inScope(identity: AgentIdentity, binding: Binding) { - try { - return ( - identity.owner === binding.viewer && - relayPartition(relayOrigin(identity.relay), binding.viewer) === - binding.scope - ); - } catch { - return false; - } + /** The live binding of the agent's community, if it has one now. */ + private bindingOf({ relay, owner }: Pick) { + const origin = originOf(relay); + const binding = origin ? this.bound.get(origin)?.binding : undefined; + return binding?.scope === relayPartition(origin ?? "", owner) + ? binding + : undefined; + } + /** Whether the agent stays running: it is the viewer's, and its community is + * joined and not disconnected. A reconnect does not stop it. */ + private alive({ relay, owner }: AgentIdentity) { + const origin = originOf(relay); + const entry = origin ? this.bound.get(origin) : undefined; + return ( + owner === this.communities.snapshot().viewer && + !!entry && + entry.relay.snapshot().status !== "disconnected" + ); + } + /** A running agent, in any community. */ + private agent(pubkey: string) { + return this.state.running.find((agent) => agent.pubkey === pubkey); + } + /** The agent, while its community's session is live to run it in. */ + private ready(pubkey: string) { + const agent = this.agent(pubkey); + return agent && this.bindingOf(agent) ? agent : undefined; } /** Reads the agent list from native. Every change is made there first and @@ -485,17 +549,17 @@ export class Agents2Service extends Service implements Agents2 { error: message(error), }); } + this.watch(); this.update(); this.notify(); this.sync(); } /** Brings the relay in line with the saved identities: publishes each name - * its community lacks, and finishes Delete for the open community. Each step + * its community lacks, and finishes Delete where connected. Each step * is safe to repeat; what fails is tried again on the next refresh or connect. */ private sync() { const native = this.native; - const binding = this.binding; for (const identity of this.identities) if (!identity.deleted) native @@ -506,8 +570,10 @@ export class Agents2Service extends Service implements Agents2 { error, ), ); - else if (binding && this.inScope(identity, binding)) - void this.cleanup(identity.pubkey, binding); + else { + const binding = this.bindingOf(identity); + if (binding) void this.cleanup(identity.pubkey, binding); + } } /** As harness Delete does: leave every channel, then archive the identity so * it drops out of member lists and mention suggestions. The owner signs both, @@ -554,8 +620,59 @@ export class Agents2Service extends Service implements Agents2 { } } - private bind() { - const snapshot = this.relay.snapshot(); + /** Connects the selected community, where agents are made, and each one + * the viewer has an agent in, deleted ones included so their cleanup can + * finish; lets go of the rest. */ + private watch() { + const { viewer, selected } = this.communities.snapshot(); + const origins = new Set(); + const shown = viewer && originOf(selected ?? ""); + if (shown) origins.add(shown); + for (const identity of this.identities) { + const origin = identity.owner === viewer && originOf(identity.relay); + if (origin) origins.add(origin); + } + // Left, or left and joined again with a new session. + for (const [origin, entry] of this.bound) + if (!origins.has(origin) || this.communities.open(origin) !== entry.relay) + this.unbind(origin); + for (const origin of origins) { + if (this.bound.has(origin)) continue; + const relay = this.communities.open(origin); + if (!relay) continue; + this.bound.set(origin, { + relay, + stop: relay.subscribe(() => this.bind(origin)), + }); + this.bind(origin); + } + } + /** Tries each unselected community whose connection failed again, since + * nobody is there to press its Retry; its agents wait on it. */ + private reconnect() { + const selected = originOf(this.communities.snapshot().selected ?? ""); + const now = Date.now(); + for (const [origin, entry] of this.bound) + if ( + origin !== selected && + entry.relay.snapshot().status === "error" && + now - (entry.retried ?? 0) >= RETRY_MS + ) { + entry.retried = now; + entry.relay.retry(); + } + } + private unbind(origin: string) { + const entry = this.bound.get(origin); + this.bound.delete(origin); + entry?.stop(); + entry?.binding?.stopLive(); + entry?.binding?.controller.abort(); + } + private bind(origin: string) { + const entry = this.bound.get(origin); + if (!entry) return; + const snapshot = entry.relay.snapshot(); const live = snapshot.status === "ready" && !snapshot.cached && @@ -570,24 +687,29 @@ export class Agents2Service extends Service implements Agents2 { controller: new AbortController(), } : undefined; - if ( - this.binding?.session === live?.session && - this.binding?.scope === live?.scope - ) - return; - this.stopLive?.(); - this.binding?.controller.abort(); - this.stopLive = live && this.listen(live.session); - this.binding = live; + const changed = + entry.binding?.session !== live?.session || + entry.binding?.scope !== live?.scope; + if (changed) { + entry.binding?.stopLive(); + entry.binding?.controller.abort(); + entry.binding = live && { + ...live, + stopLive: this.listen(live.session, origin), + }; + } + // Disconnecting stops its agents even when no binding changed. this.update(); - if (live) this.sync(); + if (changed && live) this.sync(); } /** Delivers the session's live events, and retries what failed whenever its * connection recovers, which happens within the same session. */ - private listen(session: RelaySession) { + private listen(session: RelaySession, origin: string) { let connected = session.live.snapshot().status === "connected"; - const stopEvents = session.subscribeLive((batch) => this.dispatch(batch)); + const stopEvents = session.subscribeLive((batch) => + this.dispatch(batch, origin), + ); const stopStatus = session.live.subscribe(() => { const now = session.live.snapshot().status === "connected"; if (now && !connected) this.sync(); @@ -599,22 +721,20 @@ export class Agents2Service extends Service implements Agents2 { }; } - // Joins identities, records and the connection into the visible agents, and - // keeps one runner per saved agent. Runners outlive edits and reconnects; one is - // retired only when its agent is deleted, and its run is aborted when the - // agent's type is replaced. + // Joins identities, records and the connections into the running and visible + // agents, and keeps one runner per saved agent. Runners outlive edits and + // reconnects; one is retired only when its agent is deleted, and its run is + // aborted when the agent's type is replaced. private update() { - const binding = this.binding; - const wasShown = new Set(this.state.agents.map((agent) => agent.pubkey)); const identities = this.identities.filter((identity) => !identity.deleted); - const agents: Agent[] = []; + const running: Agent[] = []; for (const identity of identities) { - if (!binding || !this.inScope(identity, binding)) continue; + if (!this.alive(identity)) continue; const record = this.recordOf(identity); // Reuse the frozen agent while nothing visible changed, so subscribers see // a stable snapshot. - const prior = this.find(identity.pubkey); - agents.push( + const prior = this.agent(identity.pubkey); + running.push( prior && prior.name === identity.name && prior.attention === record.attention && @@ -625,6 +745,10 @@ export class Agents2Service extends Service implements Agents2 { : this.view(identity, record), ); } + const selected = originOf(this.communities.snapshot().selected ?? ""); + const agents = running.filter( + (agent) => originOf(agent.relay) === selected && this.bindingOf(agent), + ); const types = new Map(this.types().map((type) => [type.key, type])); for (const [pubkey, runner] of this.runners) if (!identities.some((identity) => identity.pubkey === pubkey)) { @@ -643,26 +767,33 @@ export class Agents2Service extends Service implements Agents2 { void this.drain(runner); } } - const same = - agents.length === this.state.agents.length && - agents.every((agent, index) => agent === this.state.agents[index]); - if (!same) { + const same = (next: readonly Agent[], prior: readonly Agent[]) => + next.length === prior.length && + next.every((agent, index) => agent === prior[index]); + if ( + !same(agents, this.state.agents) || + !same(running, this.state.running) + ) { this.state = Object.freeze({ ...this.state, agents: Object.freeze(agents), + running: Object.freeze(running), }); this.notify(); } - // Resume runners whose queue paused while their agent was out of view, and - // claim or release each agent as it comes into or leaves view, so a copy of - // the app takes over without waiting for an event or tick. - const shown = new Set(agents.map((agent) => agent.pubkey)); + // Resume runners whose queue paused while their community was not live, + // and claim or release each agent as its community goes live or not, so a + // copy of the app takes over without waiting for an event or tick. + const ready = new Set( + running.filter((agent) => this.bindingOf(agent)).map((a) => a.pubkey), + ); for (const runner of this.runners.values()) if ( runner.queue.length || - shown.has(runner.pubkey) !== wasShown.has(runner.pubkey) + ready.has(runner.pubkey) !== this.connected.has(runner.pubkey) ) void this.drain(runner); + this.connected = ready; } private notify() { for (const listener of this.listeners) listener(); @@ -711,10 +842,12 @@ export class Agents2Service extends Service implements Agents2 { return watches; } - private dispatch(batch: LiveBatch) { + /** Gives a community's live events to the agents in it. */ + private dispatch(batch: LiveBatch, origin: string) { for (const runner of this.runners.values()) { - const agent = this.find(runner.pubkey); - if (!agent || !runner.type?.run) continue; + const agent = this.ready(runner.pubkey); + if (!agent || originOf(agent.relay) !== origin || !runner.type?.run) + continue; for (const event of batch.events) { if (runner.seen.has(event.id)) continue; bounded(runner.seen, event.id); @@ -818,7 +951,7 @@ export class Agents2Service extends Service implements Agents2 { runner.admitted = 0; } if (runner.queue.length >= QUEUE_LIMIT || runner.admitted >= RATE_LIMIT) { - console.warn(`Agent ${this.find(runner.pubkey)?.name} dropped an event`); + console.warn(`Agent ${this.agent(runner.pubkey)?.name} dropped an event`); return; } runner.admitted++; @@ -827,16 +960,15 @@ export class Agents2Service extends Service implements Agents2 { } // One run at a time per agent: events in arrival order, then a due timer. - // Each job runs the agent as it is when the job starts. While the agent is out - // of view (disconnected, another community) the queue pauses and update() - // resumes it; a type that cannot run drops what is queued. A run that ignores + // Each job runs the agent as it is when the job starts. While its community's + // session is not live the queue pauses and update() resumes it; a type that cannot run drops what is queued. A run that ignores // its deadline stops holding the queue when the deadline passes; it is not // otherwise fenced. private async drain(runner: Runner) { if (runner.running) return; runner.running = true; while (this.runners.get(runner.pubkey) === runner) { - const agent = this.find(runner.pubkey); + const agent = this.ready(runner.pubkey); const type = runner.type; const run = type?.run; // An agent this copy cannot run is left to another copy of the app. @@ -844,7 +976,7 @@ export class Agents2Service extends Service implements Agents2 { if (agent) runner.queue.length = 0; await this.release(runner); // It may have come back into view, or gained its type, meanwhile. - if (this.find(runner.pubkey) !== agent || runner.type !== type) + if (this.ready(runner.pubkey) !== agent || runner.type !== type) continue; break; } @@ -856,7 +988,7 @@ export class Agents2Service extends Service implements Agents2 { break; } // The agent or its type may have changed meanwhile. - if (this.find(runner.pubkey) !== agent || runner.type !== type) continue; + if (this.ready(runner.pubkey) !== agent || runner.type !== type) continue; const job = runner.queue.shift() ?? this.dueTimer(agent); if (!job) break; const lifetime = runner.controller.signal; @@ -912,7 +1044,7 @@ export class Agents2Service extends Service implements Agents2 { runner.mayHold = true; // Answering twice beats not answering at all. console.warn( - `Agent ${this.find(runner.pubkey)?.name} was not claimed`, + `Agent ${this.agent(runner.pubkey)?.name} was not claimed`, error, ); return true; diff --git a/src/features/communities/service.test.ts b/src/features/communities/service.test.ts index 4d952c1b5..c0a82f3d7 100644 --- a/src/features/communities/service.test.ts +++ b/src/features/communities/service.test.ts @@ -133,6 +133,28 @@ it("restores only the selected membership on launch", async () => { ]); }); +it("opens a joined community without selecting it, and never one that is not joined", async () => { + const client = setup({ + profile: { name: "Local", picture: "" }, + memberships: [ + { id: "primary", name: "Primary" }, + { id: "secondary", name: "Secondary" }, + ], + selected: "secondary", + }); + await flush(); + await flush(); + const primary = client.open("primary"); + await flush(); + expect(client.snapshot().selected).toBe("secondary"); + expect(primary?.snapshot().status).toBe("ready"); + expect(client.open("primary")).toBe(primary); + expect(client.open("https://unjoined.example.test")).toBeUndefined(); + expect(requests.filter((url) => url.endsWith("/session"))).toEqual([ + "/api/relay/secondary/session", + "/api/relay/primary/session", + ]); +}); it("a failing community does not replace its healthy sibling or the local profile", async () => { const client = setup(); await flush(); diff --git a/src/features/communities/service.ts b/src/features/communities/service.ts index 7345bad55..c15e88918 100644 --- a/src/features/communities/service.ts +++ b/src/features/communities/service.ts @@ -291,6 +291,14 @@ export function createCommunities( if (id) acquire(id); update({ selected: id }); }, + /** A joined community's session, connected without selecting it, for + * background work such as agents; it lives until the community is left. */ + open(id: string): RelayData | undefined { + id = communityDestination(id).id; + return state.memberships.some((m) => m.id === id) + ? acquire(id) + : undefined; + }, saveProfile(profile: PersonalProfile) { update({ profile }); },