mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-10 02:15:02 +00:00
* 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:
@@ -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
@@ -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[],
|
||||
|
||||
Reference in New Issue
Block a user