diff --git a/packages/app/src/runtime/directory-sync/agent-replica.test.ts b/packages/app/src/runtime/directory-sync/agent-replica.test.ts index 316e8e03bf..648c66c590 100644 --- a/packages/app/src/runtime/directory-sync/agent-replica.test.ts +++ b/packages/app/src/runtime/directory-sync/agent-replica.test.ts @@ -369,4 +369,25 @@ describe("AgentDirectoryReplica", () => { ).toEqual(directoryPlacement); store.clearSession(serverId); }); + + it("keeps a timeline-hydrated agent when a sequenced catch-up carries no entries", () => { + const serverId = "agent-replica-timeline-membership"; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null as unknown as DaemonClient); + const replica = new AgentDirectoryReplica( + serverId, + () => undefined, + () => undefined, + ); + + // A conversation opened from history arrives over its timeline, without the + // project placement the directory carries, and is a member from then on. + replica.submitTimelineAgent(replica.captureTimeline("agent"), payload("timeline only")); + expect(useSessionStore.getState().sessions[serverId]?.agents.has("agent")).toBe(true); + + replica.commitChanges([], [], []); + + expect(useSessionStore.getState().sessions[serverId]?.agents.has("agent")).toBe(true); + store.clearSession(serverId); + }); }); diff --git a/packages/app/src/runtime/directory-sync/agent-replica.ts b/packages/app/src/runtime/directory-sync/agent-replica.ts index dac4ec5d28..37d5a1e4cb 100644 --- a/packages/app/src/runtime/directory-sync/agent-replica.ts +++ b/packages/app/src/runtime/directory-sync/agent-replica.ts @@ -6,15 +6,21 @@ import type { Agent } from "@/stores/session-store"; import { normalizeAgentSnapshot, projectAgentSnapshot } from "@/utils/agent-snapshots"; import { type AgentDirectoryDelta } from "@/utils/agent-directory-sync"; import { reconcileAgentDirectory } from "@/utils/agent-directory-reconciliation"; +import { resolveProjectPlacement } from "@/utils/project-placement"; import { applyLegacyDaemonWorkspaceOwnership } from "@/workspace/legacy-daemon-workspaces"; import type { DirectoryReplicaMutation } from "@/runtime/replica-cache"; import type { TurnLivenessTransition } from "@/timeline/turn-liveness"; import { AgentStoreProjection } from "./internal/agent-store"; -function projectAgentDirectoryEntry(agent: Agent): FetchAgentsEntry | null { - return agent.projectPlacement - ? { agent: projectAgentSnapshot(agent), project: agent.projectPlacement } - : null; +// A member hydrated from its timeline or restored from the replica cache carries +// no placement. Every other path in this flow derives one from the working +// directory, so this one does too; returning nothing here would drop the member +// from the merged directory and delete the conversation on the next catch-up. +function projectAgentDirectoryEntry(agent: Agent): FetchAgentsEntry { + return { + agent: projectAgentSnapshot(agent), + project: resolveProjectPlacement({ projectPlacement: agent.projectPlacement, cwd: agent.cwd }), + }; } export interface AgentLifecycleToken { @@ -164,8 +170,7 @@ export class AgentDirectoryReplica { const previous = this.storeProjection.snapshot(); const merged = new Map(); for (const agent of previous.values()) { - const entry = projectAgentDirectoryEntry(agent); - if (entry) merged.set(agent.id, entry); + merged.set(agent.id, projectAgentDirectoryEntry(agent)); } for (const entry of entries) merged.set(entry.agent.id, entry); const removalsAsDeltas: AgentDirectoryDelta[] = removals.map(({ id }) => ({ diff --git a/packages/app/src/runtime/directory-sync/index.test.ts b/packages/app/src/runtime/directory-sync/index.test.ts index 9f64a4fc60..ba8dc97b8e 100644 --- a/packages/app/src/runtime/directory-sync/index.test.ts +++ b/packages/app/src/runtime/directory-sync/index.test.ts @@ -640,6 +640,63 @@ describe("DirectorySync session readiness", () => { directory.dispose(); }); + it("reconciles agent changes on top of the accepted cached baseline", async () => { + const serverId = "cached-agent-changes"; + serverIds.add(serverId); + const client = new FakeDirectoryClient(); + const cachedAgent = createAgent(serverId, "cached-agent"); + const releaseNetwork = client.holdAgentFetch(); + const directory = new DirectorySync( + serverId, + { + onAgentStoppedRunning: () => undefined, + markAgentLoading: () => undefined, + markAgentReady: () => undefined, + markAgentError: () => undefined, + }, + { + readAgent: async () => undefined, + readWorkspace: async () => undefined, + readDirectory: async () => ({ + agents: new Map([[cachedAgent.id, cachedAgent]]), + workspaces: new Map(), + projects: new Map(), + checkpoint: { agents: { generation: "g", afterSeq: 7 } }, + }), + commitDirectoryMutations: () => undefined, + }, + ); + directory.connectionChanged({ + client: client as unknown as DaemonClient, + status: "online", + source: { clientGeneration: 1, connectionEpoch: 1 }, + }); + const store = useSessionStore.getState(); + store.initializeSession(serverId, client as unknown as DaemonClient, 1); + store.updateSessionServerInfo(serverId, { + serverId, + hostname: null, + version: "test", + features: { workspaceMultiplicity: true, directorySync: true }, + }); + + const refresh = directory.refreshAgents(); + await expect.poll(() => client.fetchAgentsCalls).toBe(1); + releaseNetwork({ + requestId: "agents", + entries: [], + pageInfo: { hasMore: false, nextCursor: null, prevCursor: null }, + sync: { generation: "g", headSeq: 7, mode: "changes", removals: [] }, + }); + await refresh; + + expect(client.lastAgentOptions).toMatchObject({ + sync: { generation: "g", afterSeq: 7 }, + }); + expect(useSessionStore.getState().sessions[serverId]?.agents.has(cachedAgent.id)).toBe(true); + directory.dispose(); + }); + it("does not use a cached checkpoint when the corresponding cache read loses its race", async () => { const serverId = "late-directory-cache"; serverIds.add(serverId);