mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-01 06:02:56 +00:00
fix(app): keep conversations in the sidebar across a sequenced catch-up (#5189)
When the client reconnects with a live sync cursor, the daemon answers with only the agents that changed, and the replica rebuilds the merged directory from what it already holds. That rebuild went through projectAgentDirectoryEntry, which returned null for any agent without a projectPlacement, so those members never reached the merged map, commitSnapshot treated them as absent, and removeFromDirectory deleted them from the store. An agent hydrated from its timeline or restored from the replica cache has no placement: neither normalizeAgentSnapshot nor deserializeAgent sets one. Every other path in the same flow derives a placement from the working directory instead of dropping the agent, so the projection does the same now via resolveProjectPlacement, and the function is total. The conversations were always intact on the daemon; only the client lost them, which is why a full restart brought them back until the next reconnect drained the sidebar again. Co-authored-by: Bag Raul Cristian <17720135+bagutzu@users.noreply.github.com>
This commit is contained in:
co-authored by
Bag Raul Cristian
parent
4c051388e1
commit
c8a7667a03
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, FetchAgentsEntry>();
|
||||
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 }) => ({
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user