Revert #4421 and narrow the cache restore fix (#4436)

* Revert "Restore cached conversations before reconnecting (#4421)"

This reverts commit 1c51b52d07.

* fix(app): restore provider loading on native

* fix(app): preserve native navigators across appearance changes

Reapply the isolated appearance boundaries and native menu transform origin from #4421 so appearance hydration preserves native navigator and gesture host lifetimes.

* fix(app): retain cached baselines during live updates

Read the timeline concurrently with agent preparation and reconcile display-only cache rows beneath the live head using the existing reducer. Merge cached directory entries beneath live state while preserving the existing checkpoint race guard.
This commit is contained in:
Mohamed Boudra
2026-09-07 19:18:54 +02:00
committed by GitHub
parent 9c8d775895
commit c46aee4e6a
52 changed files with 3422 additions and 8638 deletions
@@ -61,7 +61,6 @@ describe("AgentDirectoryReplica", () => {
serverId,
() => undefined,
() => undefined,
(agentId) => useSessionStore.getState().removeAgentTimeline(serverId, agentId),
);
replica.commitSnapshot([entry(payload("live"))], []);
replica.applyTurnLiveness("agent", {
@@ -85,7 +84,6 @@ describe("AgentDirectoryReplica", () => {
serverId,
() => undefined,
() => undefined,
(agentId) => useSessionStore.getState().removeAgentTimeline(serverId, agentId),
);
expect(replica.submitTimelineAgent(replica.captureTimeline("agent"), payload("network"))).toBe(
@@ -106,7 +104,6 @@ describe("AgentDirectoryReplica", () => {
serverId,
() => undefined,
(mutations) => commits.push([...mutations]),
(agentId) => useSessionStore.getState().removeAgentTimeline(serverId, agentId),
);
replica.commitSnapshot([entry(payload("agent"))], []);
commits.length = 0;
@@ -147,7 +144,6 @@ describe("AgentDirectoryReplica", () => {
serverId,
(agentId) => stopped.push(agentId),
() => undefined,
(agentId) => useSessionStore.getState().removeAgentTimeline(serverId, agentId),
);
replica.commitSnapshot([entry(payload("agent"))], []);
replica.applyTurnLiveness("agent", {
@@ -170,7 +166,6 @@ describe("AgentDirectoryReplica", () => {
serverId,
() => undefined,
() => undefined,
(agentId) => useSessionStore.getState().removeAgentTimeline(serverId, agentId),
);
replica.commitSnapshot(
[
@@ -224,7 +219,6 @@ describe("AgentDirectoryReplica", () => {
serverId,
(agentId) => stopped.push(agentId),
() => undefined,
(agentId) => useSessionStore.getState().removeAgentTimeline(serverId, agentId),
);
replica.commitSnapshot(
[
@@ -268,7 +262,6 @@ describe("AgentDirectoryReplica", () => {
serverId,
() => undefined,
() => undefined,
(agentId) => useSessionStore.getState().removeAgentTimeline(serverId, agentId),
);
replica.commitSnapshot(
[
@@ -296,16 +289,10 @@ describe("AgentDirectoryReplica", () => {
const serverId = "agent-replica";
const store = useSessionStore.getState();
store.initializeSession(serverId, null as unknown as DaemonClient);
const timelineLifetimes: string[] = [];
const replica = new AgentDirectoryReplica(
serverId,
() => undefined,
() => undefined,
(agentId) => {
timelineLifetimes.push(`remove:${agentId}`);
useSessionStore.getState().removeAgentTimeline(serverId, agentId);
},
(agentId) => timelineLifetimes.push(`accept:${agentId}`),
);
replica.commitSnapshot([entry(payload("directory"))], []);
const directoryPlacement = useSessionStore
@@ -332,7 +319,6 @@ describe("AgentDirectoryReplica", () => {
expect(
useSessionStore.getState().sessions[serverId]?.agents.get("agent")?.projectPlacement,
).toEqual(directoryPlacement);
expect(timelineLifetimes).toEqual(["accept:agent", "remove:agent", "accept:agent"]);
store.clearSession(serverId);
});
});
@@ -4,7 +4,7 @@ import { clearArchiveAgentPending } from "@/hooks/use-archive-agent";
import { queryClient } from "@/data/query-client";
import type { Agent } from "@/stores/session-store";
import { normalizeAgentSnapshot, projectAgentSnapshot } from "@/utils/agent-snapshots";
import { type AgentDirectoryDelta, type AgentRemovalReason } from "@/utils/agent-directory-sync";
import { type AgentDirectoryDelta } from "@/utils/agent-directory-sync";
import { reconcileAgentDirectory } from "@/utils/agent-directory-reconciliation";
import { applyLegacyDaemonWorkspaceOwnership } from "@/workspace/legacy-daemon-workspaces";
import type { DirectoryReplicaMutation } from "@/runtime/replica-cache";
@@ -25,47 +25,52 @@ export interface AgentLifecycleToken {
export class AgentDirectoryReplica {
private readonly lifecycleVersions = new Map<string, number>();
private readonly members = new Set<string>();
private readonly pendingCacheReads = new Set<string>();
private readonly storeProjection: AgentStoreProjection;
constructor(
private readonly serverId: string,
private readonly onStoppedRunning: (agentId: string) => void,
private readonly persist: (mutations: readonly DirectoryReplicaMutation[]) => void,
removeTimeline: (agentId: string, reason: AgentRemovalReason) => void,
private readonly acceptTimeline: (agentId: string) => void = () => undefined,
) {
this.storeProjection = new AgentStoreProjection(serverId, removeTimeline);
this.storeProjection = new AgentStoreProjection(serverId);
}
captureTimeline(agentId: string): AgentLifecycleToken {
return { agentId, version: this.lifecycleVersions.get(agentId) ?? 0 };
}
isTimelineCurrent(token: AgentLifecycleToken): boolean {
return token.version === (this.lifecycleVersions.get(token.agentId) ?? 0);
}
snapshot(): Map<string, Agent> {
return this.storeProjection.snapshot();
}
// Saved rows sit under whatever the live stream already delivered for this connection.
captureCache(agentId: string): AgentLifecycleToken {
this.pendingCacheReads.add(agentId);
return this.captureTimeline(agentId);
}
commitCached(agents: Map<string, Agent>): void {
const current = new Map(
[...agents].filter(([id]) => !this.lifecycleVersions.has(id) || this.members.has(id)),
);
const merged = this.storeProjection.commitCached(current);
const merged = this.storeProjection.commitCached(agents);
this.members.clear();
for (const agentId of merged.keys()) {
this.members.add(agentId);
}
}
commitCachedAgent(token: AgentLifecycleToken, agent: Agent): boolean {
this.pendingCacheReads.delete(token.agentId);
if (token.version !== (this.lifecycleVersions.get(token.agentId) ?? 0)) return false;
if (this.members.has(agent.id)) return false;
this.members.add(agent.id);
this.storeProjection.accept(agent);
this.storeProjection.publishActivity(agent);
return true;
}
submitTimelineAgent(token: AgentLifecycleToken, payload: AgentSnapshotPayload): boolean {
if (!this.isTimelineCurrent(token)) {
if (token.version !== (this.lifecycleVersions.get(token.agentId) ?? 0)) {
return false;
}
const startsLifetime = !this.members.has(token.agentId);
const existing = this.storeProjection.get(token.agentId);
const timelineAgent = applyLegacyDaemonWorkspaceOwnership({
serverId: this.serverId,
@@ -76,10 +81,6 @@ export class AgentDirectoryReplica {
projectPlacement: timelineAgent.projectPlacement ?? existing?.projectPlacement,
};
const accepted = this.storeProjection.accept(normalized);
if (startsLifetime) {
this.resume(accepted.id);
this.acceptTimeline(accepted.id);
}
this.members.add(accepted.id);
this.storeProjection.replacePendingPermissions(accepted);
this.storeProjection.publishActivity(accepted);
@@ -98,10 +99,7 @@ export class AgentDirectoryReplica {
this.advance(delta.agentId);
} else {
this.members.add(delta.agent.id);
if (!before) {
this.resume(delta.agent.id);
this.acceptTimeline(delta.agent.id);
}
if (!before) this.advance(delta.agent.id);
}
if (result.stoppedRunning) this.onStoppedRunning(result.agentId);
this.persist(
@@ -112,12 +110,7 @@ export class AgentDirectoryReplica {
}
accept(agent: Agent): Agent {
const startsLifetime = !this.members.has(agent.id);
const accepted = this.storeProjection.accept(agent);
if (startsLifetime) {
this.resume(accepted.id);
this.acceptTimeline(accepted.id);
}
this.members.add(accepted.id);
this.persist([this.agentUpsert(accepted)]);
return accepted;
@@ -131,23 +124,22 @@ export class AgentDirectoryReplica {
const previous = this.storeProjection.snapshot();
const reconciled = reconcileAgentDirectory({ snapshot: entries, deltas });
const nextIds = new Set(reconciled.map((entry) => entry.agent.id));
const startedLifetimes = new Set<string>();
for (const agentId of this.pendingCacheReads) {
if (!nextIds.has(agentId)) this.advance(agentId);
}
for (const agentId of this.members) {
if (!nextIds.has(agentId)) this.advance(agentId);
}
for (const agentId of nextIds) {
if (!this.members.has(agentId)) {
this.resume(agentId);
startedLifetimes.add(agentId);
}
if (!this.members.has(agentId)) this.advance(agentId);
}
for (const agentId of previous.keys()) {
if (!nextIds.has(agentId)) this.storeProjection.remove(agentId, "scope");
if (!nextIds.has(agentId)) this.storeProjection.remove(agentId);
}
this.members.clear();
this.pendingCacheReads.clear();
for (const agentId of nextIds) this.members.add(agentId);
const agents = this.storeProjection.replaceFetched(reconciled);
for (const agentId of startedLifetimes) this.acceptTimeline(agentId);
for (const [agentId, previousAgent] of previous) {
if (previousAgent.turn.phase === "open" && agents.get(agentId)?.turn.phase === "idle") {
this.onStoppedRunning(agentId);
@@ -209,7 +201,7 @@ export class AgentDirectoryReplica {
remove(agentId: string): void {
this.members.delete(agentId);
this.advance(agentId);
this.storeProjection.remove(agentId, "deleted");
this.storeProjection.remove(agentId);
this.persist([{ kind: "agent", type: "delete", id: agentId }]);
}
@@ -233,12 +225,6 @@ export class AgentDirectoryReplica {
};
}
private resume(agentId: string): void {
// First admission keeps requests already in flight. Re-entry invalidates work issued
// during the excluded lifetime as well as work issued before its removal.
if (this.lifecycleVersions.has(agentId)) this.advance(agentId);
}
private advance(agentId: string): void {
this.lifecycleVersions.set(agentId, (this.lifecycleVersions.get(agentId) ?? 0) + 1);
}
File diff suppressed because it is too large Load Diff
+166 -86
View File
@@ -3,6 +3,7 @@ import type {
FetchAgentsEntry,
FetchAgentsOptions,
} from "@getpaseo/client/internal/daemon-client";
import { fetchAgentTimelineOnce } from "@/timeline/fetch-agent-timeline-once";
import {
normalizeProjectDescriptor,
normalizeWorkspaceDescriptor,
@@ -17,7 +18,7 @@ import {
shouldUseLegacyDaemonWorkspaceDirectory,
stampLegacyWorkspaceIds,
} from "@/workspace/legacy-daemon-workspaces";
import type { AgentDirectoryDelta, AgentRemovalReason } from "@/utils/agent-directory-sync";
import type { AgentDirectoryDelta } from "@/utils/agent-directory-sync";
import { AgentDirectoryReplica } from "./agent-replica";
import {
WorkspaceDirectoryReplica,
@@ -32,14 +33,13 @@ import {
import { workspaceLabels } from "@/workspace-labels";
import type {
CachedDirectory,
CachedWorkspace,
DirectoryCheckpoint,
DirectoryCursor,
DirectoryReplicaMutation,
} from "@/runtime/replica-cache";
import type { TurnLivenessTransition } from "@/timeline/turn-liveness";
type TimelinePage = Awaited<ReturnType<DaemonClient["fetchAgentTimeline"]>>;
const PAGE_LIMIT = 200;
const AGENT_SORT: NonNullable<FetchAgentsOptions["sort"]> = [
{ key: "updated_at", direction: "desc" },
@@ -108,6 +108,8 @@ export interface DirectoryConnection {
}
export interface DirectoryCheckpointStorage {
readAgent(serverId: string, agentId: string): Promise<Agent | undefined>;
readWorkspace(serverId: string, workspaceId: string): Promise<CachedWorkspace | undefined>;
readDirectory(serverId: string): Promise<CachedDirectory>;
commitDirectoryMutations(
serverId: string,
@@ -137,7 +139,6 @@ export class DirectorySync {
WorkspaceDirectorySnapshot,
WorkspaceDirectoryDelta
>();
private readonly timelineRequests = new Map<string, Promise<TimelinePage>>();
private readonly agents: AgentDirectoryReplica;
private readonly workspaces: WorkspaceDirectoryReplica;
private connection: DirectoryConnection = {
@@ -147,10 +148,10 @@ export class DirectorySync {
};
private unsubscribe: (() => void) | null = null;
private readonly abortSessionWaits = new Set<() => void>();
// The saved baseline is restored once, at construction. Live mutations apply as they arrive and
// the baseline merges under them; network refreshes wait for it so their transactions and
// cursors start from the saved state.
private readonly baseline: Promise<void>;
private cacheLoad: Promise<void> | null = null;
private cacheAccepted = false;
private revision = 0;
private workspaceRevision = 0;
private readonly routeDemandIds = new Set<string>();
private readonly fullDemandSources = new Set<object>();
private demandRefresh: Promise<void> | null = null;
@@ -160,10 +161,7 @@ export class DirectorySync {
constructor(
private readonly serverId: string,
private readonly callbacks: {
onTimelineRequest: (agentId: string, replaceTail: boolean) => (page: TimelinePage) => void;
onAgentStoppedRunning: (agentId: string) => void;
onAgentRemoved: (agentId: string, reason: AgentRemovalReason) => void;
onAgentAccepted?: (agentId: string) => void;
markAgentLoading: () => void;
markAgentReady: () => void;
markAgentError: (error: string) => void;
@@ -172,19 +170,8 @@ export class DirectorySync {
) {
const persist = (mutations: readonly DirectoryReplicaMutation[]) =>
this.checkpoints?.commitDirectoryMutations(this.serverId, mutations);
this.agents = new AgentDirectoryReplica(
serverId,
callbacks.onAgentStoppedRunning,
persist,
callbacks.onAgentRemoved,
callbacks.onAgentAccepted,
);
this.agents = new AgentDirectoryReplica(serverId, callbacks.onAgentStoppedRunning, persist);
this.workspaces = new WorkspaceDirectoryReplica(serverId);
this.baseline = this.restoreBaseline().catch(() => undefined);
}
ready(): Promise<void> {
return this.baseline;
}
connectionChanged(connection: DirectoryConnection): boolean {
@@ -198,7 +185,6 @@ export class DirectorySync {
return false;
}
this.flushAbortedTransactions();
this.timelineRequests.clear();
this.unsubscribe?.();
this.unsubscribe = null;
workspaceLabels.disconnect(this.serverId);
@@ -210,39 +196,63 @@ export class DirectorySync {
const subscriptions = [
client.on("agent_update", (message) => {
if (message.type !== "agent_update" || !this.isCurrent(client, source)) return;
if (!this.agentTransactions.record(source, message.payload)) {
this.revision += 1;
const recorded = this.agentTransactions.record(source, message.payload);
if (!recorded) {
this.agents.applyDelta(message.payload);
this.noteLiveCursor("agents", message.payload);
this.persistCheckpoint();
}
}),
client.on("workspace_update", (message) => {
if (message.type !== "workspace_update" || !this.isCurrent(client, source)) return;
if (!this.workspaceTransactions.record(source, message.payload)) {
this.revision += 1;
this.workspaceRevision += 1;
const recorded = this.workspaceTransactions.record(source, message.payload);
if (!recorded) {
this.applyWorkspaceDelta(message.payload);
this.noteLiveCursor("workspaces", message.payload);
this.persistCheckpoint();
}
}),
client.on("project.update", (message) => {
if (message.type !== "project.update" || !this.isCurrent(client, source)) return;
if (!this.workspaceTransactions.record(source, message.payload)) {
this.revision += 1;
this.workspaceRevision += 1;
const recorded = this.workspaceTransactions.record(source, message.payload);
if (!recorded) {
this.applyWorkspaceDelta(message.payload);
this.noteLiveCursor("projects", message.payload);
this.persistCheckpoint();
}
}),
client.on("script_status_update", (message) => {
if (message.type !== "script_status_update" || !this.isCurrent(client, source)) return;
this.revision += 1;
this.workspaceRevision += 1;
const delta: WorkspaceDirectoryDelta = {
kind: "script_status",
update: message.payload,
};
if (!this.workspaceTransactions.record(source, delta)) {
const recorded = this.workspaceTransactions.record(source, delta);
if (!recorded) {
this.applyWorkspaceDelta(delta);
this.persistCheckpoint();
}
}),
client.on("agent_deleted", (message) => {
if (message.type !== "agent_deleted" || !this.isCurrent(client, source)) return;
this.agents.remove(message.payload.agentId);
if (message.type === "agent_deleted" && this.isCurrent(client, source)) {
this.revision += 1;
this.agents.remove(message.payload.agentId);
this.persistCheckpoint();
}
}),
client.on("agent_archived", (message) => {
if (message.type !== "agent_archived" || !this.isCurrent(client, source)) return;
this.agents.archive(message.payload.agentId, message.payload.archivedAt);
if (message.type === "agent_archived" && this.isCurrent(client, source)) {
this.revision += 1;
this.agents.archive(message.payload.agentId, message.payload.archivedAt);
this.persistCheckpoint();
}
}),
];
this.unsubscribe = () => {
@@ -256,8 +266,9 @@ export class DirectorySync {
const wasDemanded = this.fullDemandSources.size > 0;
if (demanded) this.fullDemandSources.add(source);
else this.fullDemandSources.delete(source);
if (!wasDemanded && this.fullDemandSources.size > 0 && this.getOnlineConnection()) {
void this.requestDemandRefresh().catch(() => undefined);
if (!wasDemanded && this.fullDemandSources.size > 0) {
void this.loadCachedDirectory().catch(() => undefined);
if (this.getOnlineConnection()) void this.requestDemandRefresh().catch(() => undefined);
}
}
@@ -277,8 +288,6 @@ export class DirectorySync {
}
dispose(): void {
this.connection = { ...this.connection, status: "offline" };
this.timelineRequests.clear();
this.flushAbortedTransactions();
this.abortPendingSessionWaits();
this.unsubscribe?.();
@@ -295,7 +304,9 @@ export class DirectorySync {
private requestDemandRefresh(force = false): Promise<void> {
if (this.demandRefresh) return this.demandRefresh;
if (!this.hasDemand()) return Promise.resolve();
if (!this.getOnlineConnection()) return this.baseline;
if (!this.getOnlineConnection()) {
return this.fullDemandSources.size > 0 ? this.loadCachedDirectory() : Promise.resolve();
}
const source = this.connection.source;
if (
!force &&
@@ -308,8 +319,8 @@ export class DirectorySync {
this.fullDemandSources.size > 0
? this.refreshAll()
: Promise.all([
this.refreshAgents({ subscribe: {} }),
this.refreshWorkspaces({ subscribe: true }),
this.refreshAgentsInternal({ subscribe: {} }, false),
this.refreshWorkspacesInternal({ subscribe: true }, false),
]).then(() => undefined);
this.demandRefresh = refresh
.then(() => {
@@ -334,13 +345,57 @@ export class DirectorySync {
return this.requestDemandRefresh(true);
}
private async restoreBaseline(): Promise<void> {
async loadCachedAgent(agentId: string): Promise<void> {
if (!this.checkpoints) return;
const cached = await this.checkpoints.readDirectory(this.serverId);
if (!useSessionStore.getState().sessions[this.serverId]) return;
this.agents.commitCached(cached.agents);
this.workspaces.commitCached(cached);
this.cursors = cached.checkpoint ?? {};
if (useSessionStore.getState().sessions[this.serverId]?.agents.has(agentId)) return;
const token = this.agents.captureCache(agentId);
const agent = await this.checkpoints.readAgent(this.serverId, agentId);
if (!agent) return;
const session = useSessionStore.getState().sessions[this.serverId];
if (!session || session.agents.has(agentId)) return;
this.agents.commitCachedAgent(token, agent);
}
async prepareAgentRoute(agentId: string): Promise<void> {
await this.loadCachedAgent(agentId);
const agent = useSessionStore.getState().sessions[this.serverId]?.agents.get(agentId);
if (agent?.workspaceId) await this.loadCachedWorkspace(agent.workspaceId);
}
async prepareWorkspaceRoute(workspaceId: string): Promise<void> {
await this.loadCachedWorkspace(workspaceId);
}
private async loadCachedWorkspace(workspaceId: string): Promise<void> {
if (!this.checkpoints) return;
if (useSessionStore.getState().sessions[this.serverId]?.workspaces.has(workspaceId)) return;
const revision = this.workspaceRevision;
const cached = await this.checkpoints.readWorkspace(this.serverId, workspaceId);
if (!cached) return;
if (this.workspaceRevision !== revision) return;
const session = useSessionStore.getState().sessions[this.serverId];
if (!session) return;
this.workspaces.commitCachedWorkspace(cached.workspace, cached.project);
}
private loadCachedDirectory(): Promise<void> {
const checkpoints = this.checkpoints;
if (!checkpoints) return Promise.resolve();
this.cacheLoad ??= (async () => {
const revision = this.revision;
const cached = await checkpoints.readDirectory(this.serverId);
if (this.cacheAccepted) return;
if (!useSessionStore.getState().sessions[this.serverId]) return;
this.agents.commitCached(cached.agents);
this.workspaces.commitCached(cached);
if (this.revision === revision) this.cursors = cached.checkpoint ?? {};
this.cacheAccepted = true;
})();
return this.cacheLoad;
}
restoreCachedDirectory(): Promise<void> {
return this.loadCachedDirectory();
}
applyAgentTurnLiveness(
@@ -357,36 +412,33 @@ export class DirectorySync {
async fetchTimeline(
agentId: string,
request: Parameters<DaemonClient["fetchAgentTimeline"]>[1],
replaceTail = false,
): Promise<TimelinePage> {
const { client, source } = this.requireOnline();
): Promise<Awaited<ReturnType<DaemonClient["fetchAgentTimeline"]>>> {
const { client } = this.requireOnline();
const token = this.agents.captureTimeline(agentId);
const key = JSON.stringify({ agentId, lifetime: token.version, request, replaceTail });
const existing = this.timelineRequests.get(key);
if (existing) return existing;
const apply = this.callbacks.onTimelineRequest(agentId, replaceTail);
const fetching = client.fetchAgentTimeline(agentId, request).then((page) => {
if (!this.isCurrent(client, source) || !this.agents.isTimelineCurrent(token)) {
throw new DirectoryRefreshSupersededError("timeline page no longer current");
}
if (page.agent) this.agents.submitTimelineAgent(token, page.agent);
apply(page);
return page;
});
this.timelineRequests.set(key, fetching);
const clear = () => {
if (this.timelineRequests.get(key) === fetching) this.timelineRequests.delete(key);
};
void fetching.then(clear, clear);
return fetching;
const page = await fetchAgentTimelineOnce(client, agentId, request);
if (page.agent && this.agents.submitTimelineAgent(token, page.agent)) {
this.revision += 1;
this.persistCheckpoint();
}
return page;
}
async refreshAgents(
input: RefreshAgentDirectoryInput = {},
): Promise<RefreshAgentDirectoryResult> {
return this.refreshAgentsInternal(input, true);
}
private async refreshAgentsInternal(
input: RefreshAgentDirectoryInput,
loadDirectoryCache: boolean,
): Promise<RefreshAgentDirectoryResult> {
if (loadDirectoryCache) {
this.primeAgentTransactionForCacheLoad();
await this.loadCachedDirectory();
}
const onlineConnection = this.getOnlineConnection();
if (!onlineConnection) {
await this.baseline;
this.callbacks.markAgentReady();
return {
agents: new Map(useSessionStore.getState().sessions[this.serverId]?.agents),
@@ -394,8 +446,6 @@ export class DirectorySync {
};
}
const { client, source } = onlineConnection;
// The transaction spans the whole refresh, so a live delta that arrives while the saved
// baseline or the session is still pending is replayed on top of whatever snapshot lands.
const transaction = this.agentTransactions.begin(source, () => ({
entries: [],
subscriptionId: null,
@@ -404,7 +454,6 @@ export class DirectorySync {
}));
this.callbacks.markAgentLoading();
try {
await this.baseline;
await this.waitForSession(client, source);
const session = useSessionStore.getState().sessions[this.serverId];
if (!input.filter && shouldUseLegacyDaemonWorkspaceDirectory(session?.serverInfo)) {
@@ -461,8 +510,9 @@ export class DirectorySync {
: delta,
)
: completion.deltas;
this.revision += 1;
const agents = this.commitAgentSnapshot(completion.snapshot, deltas);
this.persistAgentCursors(completion.snapshot);
this.persistAgentCursors(completion.snapshot, completion.deltas);
this.persistCheckpoint();
this.callbacks.markAgentReady();
return { agents, subscriptionId: completion.snapshot.subscriptionId };
@@ -477,27 +527,26 @@ export class DirectorySync {
}
async refreshWorkspaces(input?: { subscribe?: boolean }): Promise<void> {
return this.refreshWorkspacesInternal(input, true);
}
private async refreshWorkspacesInternal(
input: { subscribe?: boolean } | undefined,
loadDirectoryCache: boolean,
): Promise<void> {
if (loadDirectoryCache) await this.loadCachedDirectory();
const onlineConnection = this.getOnlineConnection();
if (!onlineConnection) {
await this.baseline;
return;
}
if (!onlineConnection) return;
const { client, source } = onlineConnection;
// The transaction spans the whole refresh. Its base for a changes-mode page is the store once
// the saved baseline is in it, so it is captured after that wait rather than at begin.
const transaction = this.workspaceTransactions.begin(source, () => ({
workspaces: new Map(),
projects: new Map(),
workspaces: new Map(useSessionStore.getState().sessions[this.serverId]?.workspaces),
projects: new Map(useSessionStore.getState().sessions[this.serverId]?.projects),
syncCursors: {},
syncModes: {},
touchedWorkspaceIds: new Set(),
touchedProjectIds: new Set(),
}));
try {
await this.baseline;
const session = useSessionStore.getState().sessions[this.serverId];
transaction.snapshot.workspaces = new Map(session?.workspaces);
transaction.snapshot.projects = new Map(session?.projects);
await this.waitForSessionMetadata(client, source);
const serverInfo = useSessionStore.getState().sessions[this.serverId]?.serverInfo;
if (serverInfo?.features?.workspaceMultiplicity !== true) {
@@ -676,6 +725,17 @@ export class DirectorySync {
return { client: this.connection.client, source: this.connection.source };
}
private primeAgentTransactionForCacheLoad(): void {
const connection = this.getOnlineConnection();
if (!connection) return;
this.agentTransactions.begin(connection.source, () => ({
entries: [],
subscriptionId: null,
legacy: false,
syncRemovals: [],
}));
}
private supportsDirectorySync(): boolean {
return (
useSessionStore.getState().sessions[this.serverId]?.serverInfo?.features?.directorySync ===
@@ -692,8 +752,12 @@ export class DirectorySync {
: this.agents.commitSnapshot(snapshot.entries, deltas);
}
private persistAgentCursors(snapshot: AgentSnapshot): void {
private persistAgentCursors(
snapshot: AgentSnapshot,
deltas: readonly AgentDirectoryDelta[],
): void {
if (snapshot.syncCursor) this.writeCursor("agents", snapshot.syncCursor);
for (const delta of deltas) this.noteLiveCursor("agents", delta);
}
private async fetchProjectSnapshot(
@@ -737,6 +801,8 @@ export class DirectorySync {
if (completion.kind === "stale") {
throw new DirectoryRefreshSupersededError("workspace completion was superseded");
}
this.revision += 1;
this.workspaceRevision += 1;
const previous = this.readWorkspaceState();
const deltaMutations = this.workspaces.commitSnapshot(completion.snapshot, completion.deltas);
const next = this.workspaces.snapshot();
@@ -756,11 +822,14 @@ export class DirectorySync {
for (const [entity, cursor] of Object.entries(completion.snapshot.syncCursors ?? {})) {
if (cursor) this.writeCursor(entity as "projects" | "workspaces", cursor);
}
for (const delta of completion.deltas) {
if (delta.kind === "script_status") continue;
const entity = "projectId" in delta || "project" in delta ? "projects" : "workspaces";
this.noteLiveCursor(entity, delta);
}
this.persistCheckpoint();
}
// Only a completed catch-up may advance the saved cursor. A live sequence can follow an offline
// gap, so saving it would claim intermediate changes the client never received.
private persistCheckpoint(): void {
this.checkpoints?.commitDirectoryMutations(this.serverId, [], this.cursors);
}
@@ -805,6 +874,17 @@ export class DirectorySync {
snapshot.syncCursor = { generation: sync.generation, afterSeq: sync.headSeq };
}
private noteLiveCursor(
entity: keyof DirectoryCheckpoint,
payload: { generation?: string; seq?: number },
): void {
if (!payload.generation || payload.seq === undefined) return;
this.writeCursor(entity, {
generation: payload.generation,
afterSeq: payload.seq,
});
}
private readCursors(): DirectoryCheckpoint {
return this.cursors;
}
@@ -2,11 +2,7 @@ import equal from "fast-deep-equal";
import type { FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client";
import { useSessionStore, type Agent } from "@/stores/session-store";
import { acceptAgentDirectoryUpdate } from "@/utils/agent-directory-update-policy";
import {
buildAgentDirectoryState,
type AgentDirectoryDelta,
type AgentRemovalReason,
} from "@/utils/agent-directory-sync";
import { buildAgentDirectoryState, type AgentDirectoryDelta } from "@/utils/agent-directory-sync";
import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { resolveProjectPlacement } from "@/utils/project-placement";
import { clearArchiveAgentPending } from "@/hooks/use-archive-agent";
@@ -27,10 +23,7 @@ function mergeSnapshotTurn(previous: Agent | undefined, incoming: Agent): Agent
}
export class AgentStoreProjection {
constructor(
private readonly serverId: string,
private readonly removeTimeline: (agentId: string, reason: AgentRemovalReason) => void,
) {}
constructor(private readonly serverId: string) {}
snapshot(): Map<string, Agent> {
return useSessionStore.getState().sessions[this.serverId]?.agents ?? new Map();
@@ -72,7 +65,7 @@ export class AgentStoreProjection {
agent?: Agent;
} {
if (delta.kind === "remove") {
this.remove(delta.agentId, "scope");
this.remove(delta.agentId);
return { agentId: delta.agentId, stoppedRunning: false };
}
const normalized = normalizeAgentSnapshot(delta.agent, this.serverId);
@@ -148,7 +141,7 @@ export class AgentStoreProjection {
useSessionStore.getState().setPendingPermissions(this.serverId, pending);
}
remove(agentId: string, reason: AgentRemovalReason): void {
remove(agentId: string): void {
const store = useSessionStore.getState();
const removeKey = <T>(current: Map<string, T>): Map<string, T> => {
if (!current.has(agentId)) return current;
@@ -160,13 +153,16 @@ export class AgentStoreProjection {
store.setAgents(this.serverId, removeKey);
store.setAgentDetails(this.serverId, removeKey);
store.setQueuedMessages(this.serverId, removeKey);
this.removeTimeline(agentId, reason);
store.setAgentTimelineCursor(this.serverId, removeKey);
store.setInitializingAgents(this.serverId, removeKey);
store.setPendingPermissions(this.serverId, (current) => {
const next = new Map(current);
for (const [key, pending] of next) if (pending.agentId === agentId) next.delete(key);
return next.size === current.size ? current : next;
});
store.setAgentAuthoritativeHistoryApplied(this.serverId, agentId, false);
store.setAgentStreamTail(this.serverId, removeKey);
store.clearAgentStreamHead(this.serverId, agentId);
useSessionStore.setState((state) => {
if (!state.agentLastActivity.has(agentId)) return state;
const agentLastActivity = new Map(state.agentLastActivity);
@@ -213,7 +213,7 @@ it("does not invent a null-key project from a workspace update", () => {
store.clearSession(serverId);
});
it("does not restore a cached workspace while its archive is pending", () => {
it("does not restore a targeted cached workspace while its archive is pending", () => {
const serverId = "cached-workspace-during-archive";
const workspaceId = "archived-workspace";
const store = useSessionStore.getState();
@@ -222,10 +222,7 @@ it("does not restore a cached workspace while its archive is pending", () => {
markWorkspaceArchivePending({ serverId, workspaceId });
try {
replica.commitCached({
workspaces: new Map([[workspaceId, normalizeWorkspaceDescriptor(workspace(workspaceId))]]),
projects: new Map(),
});
replica.commitCachedWorkspace(normalizeWorkspaceDescriptor(workspace(workspaceId)), undefined);
expect(useSessionStore.getState().sessions[serverId]?.workspaces.has(workspaceId)).toBe(false);
} finally {
@@ -58,6 +58,15 @@ export class WorkspaceDirectoryReplica {
useSessionStore.getState().setHasWorkspaceDirectorySnapshot(this.serverId, true);
}
commitCachedWorkspace(
workspace: WorkspaceDescriptor,
project: ProjectDescriptor | undefined,
): void {
if (shouldSuppressWorkspaceForLocalArchive({ serverId: this.serverId, workspace })) return;
if (project) this.setProject(project);
this.setWorkspace(workspace);
}
commitSnapshot(
snapshot: WorkspaceDirectorySnapshot,
deltas: readonly WorkspaceDirectoryDelta[],