feat(hosts): bind replica and session lifetime to origin authority

This commit is contained in:
Mohamed Boudra
2026-10-20 13:23:04 +02:00
parent 702bc942b7
commit 7c4aa058ff
43 changed files with 3796 additions and 2275 deletions
+41 -22
View File
@@ -27,23 +27,19 @@ import {
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
import { getHostRuntimeStore, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useHostRuntimeStore, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useVoiceAudioEngineOptional, useVoiceRuntimeOptional } from "@/contexts/voice-context";
import type { AudioPlaybackSource } from "@/audio";
import {
selectAgentTimelineState,
toDaemonServerInfo,
useSessionStore,
useSessionStoreApi,
type SessionState,
} from "@/stores/session-store";
import { sendOsNotification } from "@/utils/os-notifications";
import { getIsAppActivelyVisible, getIsAppVisible } from "@/utils/app-visibility";
import {
getInitKey,
getInitDeferred,
createInitDeferred,
rejectInitDeferred,
} from "@/utils/agent-initialization";
import { getInitKey } from "@/utils/agent-initialization";
import { derivePendingPermissionKey } from "@/utils/agent-snapshots";
import { useToast } from "@/contexts/toast-context";
import { toErrorMessage } from "@/utils/error-messages";
@@ -201,6 +197,8 @@ export function SessionProvider(props: SessionProviderProps) {
}
function SessionProviderInternal({ children, serverId, client }: SessionProviderClientProps) {
const sessionStore = useSessionStoreApi();
const runtime = useHostRuntimeStore();
const { t } = useTranslation();
const voiceRuntime = useVoiceRuntimeOptional();
const voiceAudioEngine = useVoiceAudioEngineOptional();
@@ -265,7 +263,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
notification?: AgentAttentionNotificationPayload;
}) => {
const appState = appStateRef.current;
const session = useSessionStore.getState().sessions[serverId];
const session = sessionStore.getState().sessions[serverId];
const attentionFocusedAgentId = session?.focusedAgentId ?? null;
if (params.reason === "error") {
return;
@@ -309,7 +307,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
data: notification.data,
});
},
[serverId],
[serverId, sessionStore],
);
useEffect(() => {
@@ -377,7 +375,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
{ event: streamEvent, seq, epoch, timestamp: parsedTimestamp },
]);
if (turnLiveness.length > 0) {
getHostRuntimeStore().applyAgentTurnLiveness(serverId, agentId, turnLiveness);
runtime.applyAgentTurnLiveness(serverId, agentId, turnLiveness);
}
sync.enqueueStreamEvent(agentId, {
event: streamEvent,
@@ -395,14 +393,14 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
void requestTimelineReplacement(
{
fetchAgentTimeline: (agentId, request) =>
getHostRuntimeStore().fetchAgentTimeline(serverId, agentId, request),
runtime.fetchAgentTimeline(serverId, agentId, request),
},
message.payload.agentId,
).catch((error: unknown) => {
console.warn("[Session] timeline replacement refresh failed", { serverId, error });
});
};
const sync = getHostRuntimeStore().createViewedTimelineOwner(serverId, {
const sync = runtime.createViewedTimelineOwner(serverId, {
observe: (agentIds) => {
const observation = client.observeTimeline(agentIds);
observation.subscribe({
@@ -416,7 +414,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
},
readCursor: (agentId) => {
const timeline = selectAgentTimelineState(
useSessionStore.getState().sessions[serverId],
sessionStore.getState().sessions[serverId],
agentId,
);
return timeline.status === "synced" && timeline.range
@@ -424,15 +422,19 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
: undefined;
},
fetchPage: async (agentId, request) => {
const session = useSessionStore.getState().sessions[serverId];
const session = sessionStore.getState().sessions[serverId];
const initKey = getInitKey(serverId, agentId);
const shouldInitialize = selectAgentTimelineState(session, agentId).status !== "synced";
if (shouldInitialize) {
if (!getInitDeferred(initKey)) {
const deferred = createInitDeferred(initKey, request.direction ?? "tail");
if (!sessionStore.initializations.getInitDeferred(initKey)) {
const deferred = sessionStore.initializations.createInitDeferred(
initKey,
request.direction ?? "tail",
);
void deferred.promise.catch(() => undefined);
}
refreshAgentInitializationTimeout({
store: sessionStore,
key: initKey,
agentId,
setAgentInitializing,
@@ -440,21 +442,29 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
setAgentInitializing(agentId, true);
}
try {
const page = await getHostRuntimeStore().fetchAgentTimeline(serverId, agentId, request);
if (shouldInitialize && getInitDeferred(initKey)) {
refreshAgentInitializationTimeout({ key: initKey, agentId, setAgentInitializing });
const page = await runtime.fetchAgentTimeline(serverId, agentId, request);
if (shouldInitialize && sessionStore.initializations.getInitDeferred(initKey)) {
refreshAgentInitializationTimeout({
store: sessionStore,
key: initKey,
agentId,
setAgentInitializing,
});
}
return page;
} catch (error) {
if (shouldInitialize) {
setAgentInitializing(agentId, false);
rejectInitDeferred(initKey, error instanceof Error ? error : new Error(String(error)));
sessionStore.initializations.rejectInitDeferred(
initKey,
error instanceof Error ? error : new Error(String(error)),
);
}
throw error;
}
},
fetchLatestTail: (agentId) =>
getHostRuntimeStore().fetchAgentTimeline(serverId, agentId, planTimelineTailFetch()),
runtime.fetchAgentTimeline(serverId, agentId, planTimelineTailFetch()),
reportError: (error) => {
console.warn("[Session] viewed timeline synchronization failed", { serverId, error });
},
@@ -478,7 +488,16 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
setViewedTimelineSync(serverId, null);
sync.dispose();
};
}, [client, serverId, setInitializingAgents, setViewedTimelineSync, voiceRuntime]);
}, [
client,
serverId,
sessionStore,
runtime,
sessionStore.initializations,
setInitializingAgents,
setViewedTimelineSync,
voiceRuntime,
]);
useEffect(() => {
viewedTimelineSyncRef.current?.setConnected(isConnected);
+12 -9
View File
@@ -1,12 +1,15 @@
import { QueryClient } from "@tanstack/react-query";
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: Infinity,
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
export const createQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: Infinity,
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
},
},
},
});
});
export const queryClient = createQueryClient();
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { useSessionStore } from "@/stores/session-store";
import { createSessionStore, useSessionStore } from "@/stores/session-store";
import { TIMELINE_FETCH_PAGE_SIZE } from "@/timeline/timeline-fetch-policy";
import type { HostRuntimeStore } from "@/runtime/host-runtime";
import { getInitDeferred, getInitKey, resolveInitDeferred } from "@/utils/agent-initialization";
@@ -288,3 +288,45 @@ describe("refreshAgent", () => {
]);
});
});
it.each(["complete", "fail"] as const)(
"keeps routed initialization pending when direct initialization for the same address %s",
async (outcome) => {
const direct = createSessionStore();
const routed = createSessionStore();
for (const store of [direct, routed]) store.getState().initializeSession(serverId, null);
const directRuntime = new FakeTimelineRuntime();
const routedRuntime = new FakeTimelineRuntime();
const client = new FakeDaemonClient();
const start = (store: typeof direct, runtime: FakeTimelineRuntime) =>
ensureAgentIsInitialized({
store,
serverId,
agentId,
client: client as never,
runtime,
setAgentInitializing: createSetAgentInitializing(
serverId,
store.getState().setInitializingAgents,
),
});
const directReady = start(direct, directRuntime);
const routedReady = start(routed, routedRuntime);
void directReady.catch(() => {});
void routedReady.catch(() => {});
try {
expect(directReady).not.toBe(routedReady);
const key = getInitKey(serverId, agentId);
if (outcome === "complete") direct.initializations.resolveInitDeferred(key);
else direct.initializations.rejectInitDeferred(key, new Error("Direct history failed"));
await Promise.allSettled([directReady]);
expect(routed.initializations.getInitDeferred(key)?.promise).toBe(routedReady);
routed.initializations.resolveInitDeferred(key);
await routedReady;
} finally {
direct.initializations.resolveInitDeferred(getInitKey(serverId, agentId));
routed.initializations.resolveInitDeferred(getInitKey(serverId, agentId));
resolveInitDeferred(getInitKey(serverId, agentId));
}
},
);
@@ -1,16 +1,14 @@
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { selectAgentTimelineState, useSessionStore } from "@/stores/session-store";
import {
createInitDeferred,
getInitDeferred,
getInitKey,
INIT_TIMEOUT_MS,
rejectInitDeferred,
refreshInitTimeout,
} from "@/utils/agent-initialization";
import { getHostRuntimeStore, type HostRuntimeStore } from "@/runtime/host-runtime";
selectAgentTimelineState,
useSessionStore,
useSessionStoreApi,
type SessionStoreApi,
} from "@/stores/session-store";
import { getInitKey, INIT_TIMEOUT_MS } from "@/utils/agent-initialization";
import { useHostRuntimeStore, type HostRuntimeStore } from "@/runtime/host-runtime";
import { planTimelineResumeFetch, planTimelineTailFetch } from "@/timeline/timeline-sync-plan";
import { i18n } from "@/i18n/i18next";
@@ -22,19 +20,22 @@ export function createHistorySyncTimeoutError(): Error {
export function refreshAgentInitializationTimeout(input: {
key: string;
store?: SessionStoreApi;
agentId: string;
setAgentInitializing: SetAgentInitializing;
}): void {
refreshInitTimeout({
const initializations = (input.store ?? useSessionStore).initializations;
initializations.refreshInitTimeout({
key: input.key,
onTimeout: () => {
input.setAgentInitializing(input.agentId, false);
rejectInitDeferred(input.key, createHistorySyncTimeoutError());
initializations.rejectInitDeferred(input.key, createHistorySyncTimeoutError());
},
});
}
export interface EnsureAgentIsInitializedInput {
store?: SessionStoreApi;
serverId: string;
agentId: string;
client: Pick<DaemonClient, "fetchAgentTimeline"> | null;
@@ -45,25 +46,27 @@ export interface EnsureAgentIsInitializedInput {
export function ensureAgentIsInitialized(input: EnsureAgentIsInitializedInput): Promise<void> {
const { serverId, agentId, client, setAgentInitializing } = input;
const store = input.store ?? useSessionStore;
const initializations = store.initializations;
const key = getInitKey(serverId, agentId);
const existing = getInitDeferred(key);
const existing = initializations.getInitDeferred(key);
if (existing) {
return existing.promise;
}
const timeline = selectAgentTimelineState(useSessionStore.getState().sessions[serverId], agentId);
const timeline = selectAgentTimelineState(store.getState().sessions[serverId], agentId);
const timelineRequest = planTimelineResumeFetch(
timeline.status === "synced" ? timeline.range : null,
);
const deferred = createInitDeferred(key, timelineRequest.direction);
refreshAgentInitializationTimeout({ key, agentId, setAgentInitializing });
const deferred = initializations.createInitDeferred(key, timelineRequest.direction);
refreshAgentInitializationTimeout({ key, store, agentId, setAgentInitializing });
setAgentInitializing(agentId, true);
if (!client) {
setAgentInitializing(agentId, false);
rejectInitDeferred(
initializations.rejectInitDeferred(
key,
new Error(input.hostDisconnectedMessage ?? i18n.t("workspace.terminal.hostDisconnected")),
);
@@ -72,7 +75,10 @@ export function ensureAgentIsInitialized(input: EnsureAgentIsInitializedInput):
input.runtime.fetchAgentTimeline(serverId, agentId, timelineRequest).catch((error) => {
setAgentInitializing(agentId, false);
rejectInitDeferred(key, error instanceof Error ? error : new Error(String(error)));
initializations.rejectInitDeferred(
key,
error instanceof Error ? error : new Error(String(error)),
);
});
return deferred.promise;
@@ -127,6 +133,8 @@ export function useAgentInitialization({
client: DaemonClient | null;
}) {
const { t } = useTranslation();
const store = useSessionStoreApi();
const runtime = useHostRuntimeStore();
const setInitializingAgents = useSessionStore((state) => state.setInitializingAgents);
const setAgentInitializing = useMemo(
() => createSetAgentInitializing(serverId, setInitializingAgents),
@@ -136,14 +144,15 @@ export function useAgentInitialization({
const ensureAgentIsInitializedCallback = useCallback(
(agentId: string): Promise<void> =>
ensureAgentIsInitialized({
store,
serverId,
agentId,
client,
runtime: getHostRuntimeStore(),
runtime,
setAgentInitializing,
hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"),
}),
[client, serverId, setAgentInitializing, t],
[client, runtime, serverId, setAgentInitializing, store, t],
);
const refreshAgentCallback = useCallback(
@@ -152,11 +161,11 @@ export function useAgentInitialization({
serverId,
agentId,
client,
runtime: getHostRuntimeStore(),
runtime,
setAgentInitializing,
hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"),
}),
[client, serverId, setAgentInitializing, t],
[client, runtime, serverId, setAgentInitializing, t],
);
return {
@@ -1,3 +1,4 @@
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { seedSessionHosts } from "@/test/seed-session";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { QueryClient } from "@tanstack/react-query";
@@ -161,6 +162,7 @@ describe("useArchiveAgent", () => {
});
applyArchivedAgentCloseResults({
runtime: getHostRuntimeStore(),
queryClient,
serverId: "server-a",
results: [{ agentId: "agent-1", archivedAt: "2026-04-01T04:00:00.000Z" }],
@@ -233,6 +235,7 @@ describe("useArchiveAgent", () => {
});
applyArchivedAgentCloseResults({
runtime: getHostRuntimeStore(),
queryClient,
serverId: "server-a",
results: [{ agentId: "agent-1", archivedAt: "2026-04-01T04:00:00.000Z" }],
+72 -95
View File
@@ -1,14 +1,8 @@
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useHostRuntimeStore, type HostRuntimeStore } from "@/runtime/host-runtime";
import { useCallback, useMemo } from "react";
import {
useMutation,
useQuery,
useQueryClient,
type QueryClient,
type QueryKey,
} from "@tanstack/react-query";
import { useQuery, useQueryClient, type QueryClient, type QueryKey } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { useSessionStore } from "@/stores/session-store";
import { useSessionStoreApi, type SessionStoreApi } from "@/stores/session-store";
import { agentHistoryQueryKey, allAgentHistoryQueryRootKey } from "./agent-history-query-key";
export const ARCHIVE_AGENT_PENDING_QUERY_KEY = ["archive-agent-pending"] as const;
@@ -241,14 +235,15 @@ interface ArchiveAgentMutationContext {
lists: ArchivedAgentListCacheSnapshot;
}
function getStoredAgentSnapshot(input: ArchiveAgentInput) {
return useSessionStore.getState().sessions[input.serverId]?.agents.get(input.agentId);
function getStoredAgentSnapshot(store: SessionStoreApi, input: ArchiveAgentInput) {
return store.getState().sessions[input.serverId]?.agents.get(input.agentId);
}
function restoreAgentSnapshot(
runtime: HostRuntimeStore,
input: ArchiveAgentInput & { agent: ReturnType<typeof getStoredAgentSnapshot> },
): void {
getHostRuntimeStore().restoreAgentSnapshot(input.serverId, input.agentId, input.agent);
runtime.restoreAgentSnapshot(input.serverId, input.agentId, input.agent);
}
function getArchivedAgentListCacheSnapshot(
@@ -299,20 +294,20 @@ function restoreArchivedAgentListCacheSnapshot(
}
}
function markAgentArchivedInStore(input: ArchiveAgentInput & { archivedAt: string }): void {
function markAgentArchivedInStore(
runtime: HostRuntimeStore,
input: ArchiveAgentInput & { archivedAt: string },
): void {
const archivedAt = new Date(input.archivedAt);
if (Number.isNaN(archivedAt.getTime())) {
return;
}
getHostRuntimeStore().archiveAgentSnapshot(
input.serverId,
input.agentId,
archivedAt.toISOString(),
);
runtime.archiveAgentSnapshot(input.serverId, input.agentId, archivedAt.toISOString());
}
interface ApplyArchivedAgentCloseResultsInput {
runtime: HostRuntimeStore;
queryClient: QueryClient;
serverId: string;
results: ArchivedAgentCloseResult[];
@@ -325,7 +320,7 @@ export function applyArchivedAgentCloseResults(input: ApplyArchivedAgentCloseRes
}
for (const result of input.results) {
markAgentArchivedInStore({
markAgentArchivedInStore(input.runtime, {
serverId: input.serverId,
agentId: result.agentId,
archivedAt: result.archivedAt,
@@ -375,87 +370,69 @@ export function usePendingArchiveAgentIds(serverId: string): ReadonlySet<string>
);
}
/** The action owns optimistic state and rollback within its captured authority. */
export function createAgentArchiveAction({
runtime,
sessionStore,
queryClient,
unavailableMessage,
}: {
runtime: HostRuntimeStore;
sessionStore: SessionStoreApi;
queryClient: QueryClient;
unavailableMessage: string;
}): (input: ArchiveAgentInput) => Promise<void> {
return async (input) => {
const context: ArchiveAgentMutationContext = {
agent: getStoredAgentSnapshot(sessionStore, input),
lists: getArchivedAgentListCacheSnapshot(queryClient, input.serverId),
};
applyArchivedAgentCloseResults({
runtime,
queryClient,
serverId: input.serverId,
results: [{ agentId: input.agentId, archivedAt: new Date().toISOString() }],
invalidateQueries: false,
});
setAgentArchiving({ ...input, queryClient, isArchiving: true });
try {
const client = runtime.getClient(input.serverId);
if (!client) throw new Error(unavailableMessage);
const result = await client.archiveAgent(input.agentId);
markAgentArchivedInStore(runtime, { ...input, archivedAt: result.archivedAt });
} catch (error) {
restoreAgentSnapshot(runtime, { ...input, agent: context.agent });
restoreArchivedAgentListCacheSnapshot(queryClient, input.serverId, context.lists);
throw error;
} finally {
clearArchiveAgentPending({ ...input, queryClient });
for (const queryKey of [
["sidebarAgentsList", input.serverId],
["allAgents", input.serverId],
agentHistoryQueryKey(input.serverId),
allAgentHistoryQueryRootKey(),
])
void queryClient.invalidateQueries({ queryKey });
}
};
}
export function useArchiveAgent() {
const queryClient = useQueryClient();
const runtime = useHostRuntimeStore();
const sessionStore = useSessionStoreApi();
const { t } = useTranslation();
const pendingQuery = useArchiveAgentPendingQuery();
const archiveMutation = useMutation({
mutationFn: async (input: ArchiveAgentInput): Promise<{ archivedAt: string }> => {
const client = useSessionStore.getState().sessions[input.serverId]?.client ?? null;
if (!client) {
throw new Error(t("common.errors.daemonClientUnavailable"));
}
return await client.archiveAgent(input.agentId);
},
onMutate: (input) => {
const context: ArchiveAgentMutationContext = {
agent: getStoredAgentSnapshot(input),
lists: getArchivedAgentListCacheSnapshot(queryClient, input.serverId),
};
const archivedAt = new Date().toISOString();
applyArchivedAgentCloseResults({
const unavailableMessage = t("common.errors.daemonClientUnavailable");
const archiveAgent = useMemo(
() =>
createAgentArchiveAction({
runtime,
sessionStore,
queryClient,
serverId: input.serverId,
results: [{ agentId: input.agentId, archivedAt }],
invalidateQueries: false,
});
setAgentArchiving({
queryClient,
serverId: input.serverId,
agentId: input.agentId,
isArchiving: true,
});
return context;
},
onSuccess: (result, input) => {
markAgentArchivedInStore({
serverId: input.serverId,
agentId: input.agentId,
archivedAt: result.archivedAt,
});
},
onError: (_error, input, context) => {
if (!context) {
return;
}
restoreAgentSnapshot({
serverId: input.serverId,
agentId: input.agentId,
agent: context.agent,
});
restoreArchivedAgentListCacheSnapshot(queryClient, input.serverId, context.lists);
},
onSettled: (_result, _error, input) => {
clearArchiveAgentPending({
queryClient,
serverId: input.serverId,
agentId: input.agentId,
});
void queryClient.invalidateQueries({
queryKey: ["sidebarAgentsList", input.serverId],
});
void queryClient.invalidateQueries({
queryKey: ["allAgents", input.serverId],
});
void queryClient.invalidateQueries({
queryKey: agentHistoryQueryKey(input.serverId),
});
void queryClient.invalidateQueries({
queryKey: allAgentHistoryQueryRootKey(),
});
},
});
const archiveMutateAsync = archiveMutation.mutateAsync;
const archiveAgent = useCallback(
async (input: ArchiveAgentInput): Promise<void> => {
await archiveMutateAsync(input);
},
[archiveMutateAsync],
unavailableMessage,
}),
[runtime, sessionStore, queryClient, unavailableMessage],
);
const isArchivingAgent = useCallback(
+39 -16
View File
@@ -1,4 +1,4 @@
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useHostRuntimeStore, type HostRuntimeStore } from "@/runtime/host-runtime";
import { Button } from "@/components/ui/button";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
@@ -87,6 +87,8 @@ import {
selectAgentTurnPresentation,
type Agent,
useSessionStore,
useSessionStoreApi,
type SessionStoreApi,
} from "@/stores/session-store";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
@@ -97,7 +99,7 @@ import type { PendingPermission } from "@/types/shared";
import type { StreamItem, TodoEntry } from "@/types/stream";
import type { ViewedTimelineStatus, ViewedTimelineUiBridge } from "@/timeline/viewed-timeline-sync";
import { useArchiveFinishedSubagents, useSubagentsForParent } from "@/subagents";
import { getInitDeferred, getInitKey } from "@/utils/agent-initialization";
import { getInitKey } from "@/utils/agent-initialization";
import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { applyLegacyDaemonWorkspaceOwnership } from "@/workspace/legacy-daemon-workspaces";
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
@@ -288,6 +290,8 @@ function shouldStoreFetchedAgentInActiveDirectory(agent: Agent): boolean {
type FetchAgentResult = Awaited<ReturnType<DaemonClient["fetchAgent"]>>;
function storeFetchedAgentDetail(input: {
runtime: HostRuntimeStore;
sessionStore: SessionStoreApi;
serverId: string;
result: NonNullable<FetchAgentResult>;
}): Agent {
@@ -299,10 +303,10 @@ function storeFetchedAgentDetail(input: {
projectPlacement: input.result.project,
},
});
const store = useSessionStore.getState();
const store = input.sessionStore.getState();
if (shouldStoreFetchedAgentInActiveDirectory(hydrated)) {
getHostRuntimeStore().acceptAgentSnapshot(input.serverId, hydrated);
input.runtime.acceptAgentSnapshot(input.serverId, hydrated);
} else {
store.setAgentDetails(input.serverId, (previous) => {
const next = new Map(previous);
@@ -388,6 +392,7 @@ function AgentPanel() {
}
function DraftPanel() {
const runtime = useHostRuntimeStore();
const {
serverId,
workspaceId,
@@ -404,10 +409,10 @@ function DraftPanel() {
(agentSnapshot: Parameters<typeof normalizeAgentSnapshot>[0]) => {
const normalized = normalizeAgentSnapshot(agentSnapshot, serverId);
const agent = applyLegacyDaemonWorkspaceOwnership({ serverId, agent: normalized });
getHostRuntimeStore().acceptAgentSnapshot(serverId, agent);
runtime.acceptAgentSnapshot(serverId, agent);
retargetCurrentTab({ kind: "agent", agentId: agentSnapshot.id });
},
[retargetCurrentTab, serverId],
[retargetCurrentTab, runtime, serverId],
);
return (
@@ -593,10 +598,12 @@ function AgentPanelBody({
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
}) {
const { t } = useTranslation();
const sessionStore = useSessionStoreApi();
const runtime = useHostRuntimeStore();
const { isArchivingAgent: _isArchivingAgent } = useArchiveAgent();
const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId]));
const projectPlacement = useStoreWithEqualityFn(
useSessionStore,
useSessionStoreApi(),
(state) => {
if (!agentId) {
return null;
@@ -656,7 +663,7 @@ function AgentPanelBody({
return;
}
storeFetchedAgentDetail({ serverId, result });
storeFetchedAgentDetail({ serverId, result, sessionStore, runtime });
setLookupState({ tag: "idle" });
return;
})
@@ -671,7 +678,17 @@ function AgentPanelBody({
}
setLookupState({ tag: "error", message });
});
}, [agentId, agentState.id, client, hasSession, isConnected, lookupState.tag, serverId]);
}, [
agentId,
agentState.id,
client,
hasSession,
isConnected,
lookupState.tag,
runtime,
serverId,
sessionStore,
]);
if (lookupState.tag === "not_found") {
return (
@@ -748,6 +765,8 @@ function ChatAgentContent({
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
}) {
const { t } = useTranslation();
const sessionStore = useSessionStoreApi();
const runtime = useHostRuntimeStore();
const isPaneVisible = useRetainedPanelActive();
const { api: toastApi, toast: toastState, dismiss: dismissToast } = useToastHost();
const { isArchivingAgent } = useArchiveAgent();
@@ -763,7 +782,7 @@ function ChatAgentContent({
useShallow((state) => selectChatAgentState(state, serverId, agentId)),
);
const projectPlacement = useStoreWithEqualityFn(
useSessionStore,
useSessionStoreApi(),
(state) => {
if (!agentId) {
return null;
@@ -863,8 +882,8 @@ function ChatAgentContent({
return false;
}
const initKey = getInitKey(serverId, agentId);
return Boolean(getInitDeferred(initKey));
}, [agentId, isInitializing, serverId]);
return Boolean(sessionStore.initializations.getInitDeferred(initKey));
}, [agentId, isInitializing, serverId, sessionStore]);
const needsAuthoritativeSync = useMemo(() => {
if (!agentId) {
return false;
@@ -1005,7 +1024,7 @@ function ChatAgentContent({
if (attemptToken !== initAttemptTokenRef.current) {
return;
}
const currentSession = useSessionStore.getState().sessions[serverId];
const currentSession = sessionStore.getState().sessions[serverId];
const currentAgent =
currentSession?.agents.get(agentId) ?? currentSession?.agentDetails.get(agentId);
if (!currentAgent) {
@@ -1020,7 +1039,7 @@ function ChatAgentContent({
});
return;
}
storeFetchedAgentDetail({ serverId, result });
storeFetchedAgentDetail({ serverId, result, sessionStore, runtime });
}
if (attemptToken !== initAttemptTokenRef.current) {
return;
@@ -1050,6 +1069,8 @@ function ChatAgentContent({
isPaneVisible,
missingAgentState.kind,
serverId,
sessionStore,
runtime,
viewedTimelineSync,
visibilityCatchUpStatus,
]);
@@ -1440,7 +1461,7 @@ const AgentStreamSection = memo(function AgentStreamSection({
);
const streamItems = streamItemsRaw ?? EMPTY_STREAM_ITEMS;
const pendingPermissionList = useStoreWithEqualityFn(
useSessionStore,
useSessionStoreApi(),
(state) => {
if (!agentId) {
return EMPTY_PENDING_PERMISSION_LIST;
@@ -1573,6 +1594,7 @@ function ActiveAgentComposer({
{ initialIsBelow: isCompactFormFactor },
);
const paneContext = usePaneContext();
const sessionStore = useSessionStoreApi();
const openInSidePane = useSettings((settings) => settings.openInSidePane);
const { workspaceId, tabId, retargetCurrentTab } = paneContext;
const { archiveAgent } = useArchiveAgent();
@@ -1604,7 +1626,7 @@ function ActiveAgentComposer({
const handleClientSlashCommand = useCallback(
async (command: ClientSlashCommand) => {
const agent = resolveChatAgentFromSession(useSessionStore.getState(), serverId, agentId);
const agent = resolveChatAgentFromSession(sessionStore.getState(), serverId, agentId);
if (!agent) {
throw new Error("Agent not found");
}
@@ -1634,6 +1656,7 @@ function ActiveAgentComposer({
hideWorkspaceAgent,
retargetCurrentTab,
serverId,
sessionStore,
tabId,
unpinWorkspaceAgent,
workspaceId,
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import type { DaemonClient, FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client";
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
import {
createSessionStore,
selectAgentTimelineState,
selectAgentTurnPresentation,
useSessionStore,
@@ -391,3 +392,34 @@ describe("AgentDirectoryReplica", () => {
store.clearSession(serverId);
});
});
it("partitions equal destination identities by the owning authority store, including late snapshots", () => {
const direct = createSessionStore();
const routed = createSessionStore();
const serverId = "srv_B";
direct.getState().initializeSession(serverId, null);
routed.getState().initializeSession(serverId, null);
const directReplica = new AgentDirectoryReplica(
serverId,
() => {},
() => {},
direct,
);
const routedReplica = new AgentDirectoryReplica(
serverId,
() => {},
() => {},
routed,
);
directReplica.commitSnapshot([entry(payload("Broader direct data"))], []);
expect(routed.getState().sessions[serverId]?.agents.size).toBe(0);
routedReplica.commitSnapshot([entry(payload("Origin-authorized data"))], []);
directReplica.commitSnapshot([entry(payload("Late direct snapshot"))], []);
expect(routed.getState().sessions[serverId]?.agents.get("agent")?.title).toBe(
"Origin-authorized data",
);
expect(direct.getState().sessions[serverId]?.agents.get("agent")?.title).toBe(
"Late direct snapshot",
);
expect(routedReplica.snapshot().get("agent")?.title).toBe("Origin-authorized data");
});
@@ -2,7 +2,7 @@ import type { FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client";
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
import { clearArchiveAgentPending } from "@/hooks/use-archive-agent";
import { queryClient } from "@/data/query-client";
import type { Agent } from "@/stores/session-store";
import { useSessionStore, type SessionStoreApi, 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";
@@ -38,8 +38,9 @@ export class AgentDirectoryReplica {
private readonly serverId: string,
private readonly onStoppedRunning: (agentId: string) => void,
private readonly persist: (mutations: readonly DirectoryReplicaMutation[]) => void,
store: SessionStoreApi = useSessionStore,
) {
this.storeProjection = new AgentStoreProjection(serverId);
this.storeProjection = new AgentStoreProjection(serverId, store);
}
captureTimeline(agentId: string): AgentLifecycleToken {
@@ -9,6 +9,7 @@ import {
normalizeProjectDescriptor,
normalizeWorkspaceDescriptor,
useSessionStore,
type SessionStoreApi,
type Agent,
type ProjectDescriptor,
type WorkspaceDescriptor,
@@ -172,11 +173,17 @@ export class DirectorySync {
markAgentError: (error: string) => void;
},
private readonly checkpoints?: DirectoryCheckpointStorage,
private readonly store: SessionStoreApi = useSessionStore,
) {
const persist = (mutations: readonly DirectoryReplicaMutation[]) =>
this.checkpoints?.commitDirectoryMutations(this.serverId, mutations);
this.agents = new AgentDirectoryReplica(serverId, callbacks.onAgentStoppedRunning, persist);
this.workspaces = new WorkspaceDirectoryReplica(serverId);
this.agents = new AgentDirectoryReplica(
serverId,
callbacks.onAgentStoppedRunning,
persist,
store,
);
this.workspaces = new WorkspaceDirectoryReplica(serverId, store);
}
connectionChanged(connection: DirectoryConnection): boolean {
@@ -401,18 +408,18 @@ export class DirectorySync {
async loadCachedAgent(agentId: string): Promise<void> {
if (!this.checkpoints) return;
if (useSessionStore.getState().sessions[this.serverId]?.agents.has(agentId)) return;
if (this.store.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];
const session = this.store.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);
const agent = this.store.getState().sessions[this.serverId]?.agents.get(agentId);
if (agent?.workspaceId) await this.loadCachedWorkspace(agent.workspaceId);
}
@@ -422,12 +429,12 @@ export class DirectorySync {
private async loadCachedWorkspace(workspaceId: string): Promise<void> {
if (!this.checkpoints) return;
if (useSessionStore.getState().sessions[this.serverId]?.workspaces.has(workspaceId)) return;
if (this.store.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];
const session = this.store.getState().sessions[this.serverId];
if (!session) return;
this.workspaces.commitCachedWorkspace(cached.workspace, cached.project);
}
@@ -439,7 +446,7 @@ export class DirectorySync {
const revision = this.revision;
const cached = await checkpoints.readDirectory(this.serverId);
if (this.cacheAccepted) return;
if (!useSessionStore.getState().sessions[this.serverId]) return;
if (!this.store.getState().sessions[this.serverId]) return;
this.agents.commitCached(cached.agents);
this.workspaces.commitCached(cached);
if (this.revision === revision) this.cursors = cached.checkpoint ?? {};
@@ -495,7 +502,7 @@ export class DirectorySync {
if (!onlineConnection) {
this.callbacks.markAgentReady();
return {
agents: new Map(useSessionStore.getState().sessions[this.serverId]?.agents),
agents: new Map(this.store.getState().sessions[this.serverId]?.agents),
subscriptionId: null,
};
}
@@ -560,7 +567,7 @@ export class DirectorySync {
});
try {
await this.waitForSessionMetadata(client, source);
const serverInfo = useSessionStore.getState().sessions[this.serverId]?.serverInfo;
const serverInfo = this.store.getState().sessions[this.serverId]?.serverInfo;
const supportsProjectList = serverInfo?.features?.projectList === true;
const supportsDirectorySync = serverInfo?.features?.directorySync === true;
const projectsReady = supportsProjectList
@@ -690,7 +697,7 @@ export class DirectorySync {
}
markWorkspacesHydrated(hydrated: boolean): void {
useSessionStore.getState().setHasHydratedWorkspaces(this.serverId, hydrated);
this.store.getState().setHasHydratedWorkspaces(this.serverId, hydrated);
}
private async fetchAgents(
@@ -783,8 +790,7 @@ export class DirectorySync {
private supportsDirectorySync(): boolean {
return (
useSessionStore.getState().sessions[this.serverId]?.serverInfo?.features?.directorySync ===
true
this.store.getState().sessions[this.serverId]?.serverInfo?.features?.directorySync === true
);
}
@@ -964,7 +970,7 @@ export class DirectorySync {
source: DirectorySourceToken,
): Promise<void> {
await this.waitForSessionState(client, source, () => {
const session = useSessionStore.getState().sessions[this.serverId];
const session = this.store.getState().sessions[this.serverId];
return this.hasMatchingSession(client, source) && session?.serverInfo !== null;
});
}
@@ -995,13 +1001,13 @@ export class DirectorySync {
}
};
this.abortSessionWaits.add(abort);
unsubscribe = useSessionStore.subscribe(check);
unsubscribe = this.store.subscribe(check);
check();
});
}
private hasMatchingSession(client: DaemonClient, source: DirectorySourceToken): boolean {
const session = useSessionStore.getState().sessions[this.serverId];
const session = this.store.getState().sessions[this.serverId];
return session?.client === client && session.clientGeneration === source.clientGeneration;
}
@@ -1,6 +1,6 @@
import equal from "fast-deep-equal";
import type { FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client";
import { useSessionStore, type Agent } from "@/stores/session-store";
import { useSessionStore, type SessionStoreApi, type Agent } from "@/stores/session-store";
import { acceptAgentDirectoryUpdate } from "@/utils/agent-directory-update-policy";
import { buildAgentDirectoryState, type AgentDirectoryDelta } from "@/utils/agent-directory-sync";
import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots";
@@ -9,7 +9,7 @@ import { clearArchiveAgentPending } from "@/hooks/use-archive-agent";
import { queryClient } from "@/data/query-client";
import { buildDraftStoreKey } from "@/stores/draft-keys";
import { useDraftStore } from "@/stores/draft-store";
import { getInitDeferred, getInitKey, rejectInitDeferred } from "@/utils/agent-initialization";
import { getInitKey } from "@/utils/agent-initialization";
import { reduceTurnLiveness, type TurnLivenessTransition } from "@/timeline/turn-liveness";
function withoutAgent(agentId: string) {
@@ -32,14 +32,17 @@ function mergeSnapshotTurn(previous: Agent | undefined, incoming: Agent): Agent
}
export class AgentStoreProjection {
constructor(private readonly serverId: string) {}
constructor(
private readonly serverId: string,
private readonly store: SessionStoreApi = useSessionStore,
) {}
snapshot(): Map<string, Agent> {
return useSessionStore.getState().sessions[this.serverId]?.agents ?? new Map();
return this.store.getState().sessions[this.serverId]?.agents ?? new Map();
}
get(agentId: string): Agent | undefined {
const session = useSessionStore.getState().sessions[this.serverId];
const session = this.store.getState().sessions[this.serverId];
return session?.agents.get(agentId) ?? session?.agentDetails.get(agentId);
}
@@ -47,17 +50,17 @@ export class AgentStoreProjection {
const merged = new Map(agents);
for (const [agentId, agent] of this.snapshot()) merged.set(agentId, agent);
for (const agent of merged.values()) this.publishActivity(agent);
useSessionStore.getState().setAgents(this.serverId, merged);
this.store.getState().setAgents(this.serverId, merged);
return merged;
}
publishActivity(agent: Agent): void {
useSessionStore.getState().setAgentLastActivity(agent.id, agent.lastActivityAt);
this.store.getState().setAgentLastActivity(agent.id, agent.lastActivityAt);
}
accept(agent: Agent): Agent {
let accepted = agent;
useSessionStore.getState().setAgents(this.serverId, (current) => {
this.store.getState().setAgents(this.serverId, (current) => {
const previous = current.get(agent.id);
accepted = acceptAgentDirectoryUpdate(previous, mergeSnapshotTurn(previous, agent));
if (accepted === previous) return current;
@@ -78,7 +81,7 @@ export class AgentStoreProjection {
return { agentId: delta.agentId, stoppedRunning: false };
}
const normalized = normalizeAgentSnapshot(delta.agent, this.serverId);
const session = useSessionStore.getState().sessions[this.serverId];
const session = this.store.getState().sessions[this.serverId];
const previous = session?.agents.get(normalized.id) ?? session?.agentDetails.get(normalized.id);
const legacyWorkspaceId =
previous?.workspaceId ??
@@ -98,7 +101,7 @@ export class AgentStoreProjection {
clearArchiveAgentPending({ queryClient, serverId: this.serverId, agentId: accepted.id });
}
this.replacePendingPermissions(accepted);
useSessionStore.getState().setAgentLastActivity(accepted.id, accepted.lastActivityAt);
this.store.getState().setAgentLastActivity(accepted.id, accepted.lastActivityAt);
return {
agentId: accepted.id,
stoppedRunning: previous?.turn.phase === "open" && accepted.turn.phase === "idle",
@@ -111,7 +114,7 @@ export class AgentStoreProjection {
serverId: this.serverId,
entries,
});
const store = useSessionStore.getState();
const store = this.store.getState();
const current = store.sessions[this.serverId]?.agents ?? new Map<string, Agent>();
const agents = new Map<string, Agent>();
for (const [agentId, fetchedAgent] of fetched) {
@@ -141,17 +144,17 @@ export class AgentStoreProjection {
}
replacePendingPermissions(agent: Agent): void {
const pending = new Map(useSessionStore.getState().sessions[this.serverId]?.pendingPermissions);
const pending = new Map(this.store.getState().sessions[this.serverId]?.pendingPermissions);
for (const [key, entry] of pending) if (entry.agentId === agent.id) pending.delete(key);
for (const request of agent.pendingPermissions) {
const key = derivePendingPermissionKey(agent.id, request);
pending.set(key, { key, agentId: agent.id, request });
}
useSessionStore.getState().setPendingPermissions(this.serverId, pending);
this.store.getState().setPendingPermissions(this.serverId, pending);
}
removeFromDirectory(agentId: string): void {
const store = useSessionStore.getState();
const store = this.store.getState();
const removeKey = withoutAgent(agentId);
clearArchiveAgentPending({ queryClient, serverId: this.serverId, agentId });
store.setAgents(this.serverId, removeKey);
@@ -163,7 +166,7 @@ export class AgentStoreProjection {
for (const [key, pending] of next) if (pending.agentId === agentId) next.delete(key);
return next.size === current.size ? current : next;
});
useSessionStore.setState((state) => {
this.store.setState((state) => {
if (!state.agentLastActivity.has(agentId)) return state;
const agentLastActivity = new Map(state.agentLastActivity);
agentLastActivity.delete(agentId);
@@ -173,8 +176,11 @@ export class AgentStoreProjection {
draftKey: buildDraftStoreKey({ serverId: this.serverId, agentId }),
});
const initKey = getInitKey(this.serverId, agentId);
if (getInitDeferred(initKey)) {
rejectInitDeferred(initKey, new Error("Agent was removed during initialization"));
if (this.store.initializations.getInitDeferred(initKey)) {
this.store.initializations.rejectInitDeferred(
initKey,
new Error("Agent was removed during initialization"),
);
}
}
@@ -182,7 +188,7 @@ export class AgentStoreProjection {
this.removeFromDirectory(agentId);
// Only entity deletion destroys the transcript. A filtered active-list
// response can arrive after a fresh history response for an archived agent.
const store = useSessionStore.getState();
const store = this.store.getState();
const removeKey = withoutAgent(agentId);
store.setAgentTimelineCursor(this.serverId, removeKey);
store.setAgentAuthoritativeHistoryApplied(this.serverId, agentId, false);
@@ -192,7 +198,7 @@ export class AgentStoreProjection {
archive(agentId: string, archivedAt: string): Agent | null {
let archived: Agent | null = null;
useSessionStore.getState().setAgents(this.serverId, (current) => {
this.store.getState().setAgents(this.serverId, (current) => {
const agent = current.get(agentId);
if (!agent) return current;
archived = {
@@ -213,7 +219,7 @@ export class AgentStoreProjection {
): Agent | null {
const transitions = Array.isArray(transition) ? transition : [transition];
let accepted: Agent | null = null;
useSessionStore.getState().setAgents(this.serverId, (current) => {
this.store.getState().setAgents(this.serverId, (current) => {
const agent = current.get(agentId);
if (!agent) return current;
const turn = transitions.reduce(reduceTurnLiveness, agent.turn);
@@ -7,6 +7,7 @@ import {
normalizeProjectDescriptor,
normalizeWorkspaceDescriptor,
useSessionStore,
type SessionStoreApi,
type ProjectDescriptor,
type WorkspaceDescriptor,
} from "@/stores/session-store";
@@ -38,7 +39,10 @@ export class WorkspaceDirectoryReplica {
private projects = new Map<string, ProjectDescriptor>();
private workspaceIdsByProject = new Map<string, Set<string>>();
constructor(private readonly serverId: string) {}
constructor(
private readonly serverId: string,
private readonly store: SessionStoreApi = useSessionStore,
) {}
applyDelta(delta: WorkspaceDirectoryDelta): DirectoryReplicaMutation[] {
if (delta.kind === "script_status") return this.applyScriptStatus(delta.update);
@@ -55,7 +59,7 @@ export class WorkspaceDirectoryReplica {
workspaces: new Map([...input.workspaces, ...this.workspaces]),
projects: new Map([...input.projects, ...this.projects]),
});
useSessionStore.getState().setHasWorkspaceDirectorySnapshot(this.serverId, true);
this.store.getState().setHasWorkspaceDirectorySnapshot(this.serverId, true);
}
commitCachedWorkspace(
@@ -73,7 +77,7 @@ export class WorkspaceDirectoryReplica {
): DirectoryReplicaMutation[] {
this.replace(snapshot);
const mutations = deltas.flatMap((delta) => this.applyDelta(delta));
useSessionStore.getState().setHasHydratedWorkspaces(this.serverId, true);
this.store.getState().setHasHydratedWorkspaces(this.serverId, true);
return mutations;
}
@@ -106,7 +110,7 @@ export class WorkspaceDirectoryReplica {
this.workspaces = workspaces;
this.projects = new Map(snapshot.projects);
this.rebuildProjectIndex();
const store = useSessionStore.getState();
const store = this.store.getState();
store.setWorkspaces(this.serverId, this.workspaces);
store.setProjects(this.serverId, this.projects.values());
}
@@ -133,7 +137,7 @@ export class WorkspaceDirectoryReplica {
{ kind: "project", type: "delete", id: delta.projectId },
];
this.projects.delete(delta.projectId);
useSessionStore.getState().removeProject(this.serverId, delta.projectId);
this.store.getState().removeProject(this.serverId, delta.projectId);
for (const workspaceId of this.workspaceIdsByProject.get(delta.projectId) ?? []) {
this.deleteWorkspace(workspaceId);
mutations.push({ kind: "workspace", type: "delete", id: workspaceId });
@@ -179,7 +183,7 @@ export class WorkspaceDirectoryReplica {
}
if (delta.removedProjectId) {
this.projects.delete(delta.removedProjectId);
useSessionStore.getState().removeProject(this.serverId, delta.removedProjectId);
this.store.getState().removeProject(this.serverId, delta.removedProjectId);
mutations.push({ kind: "project", type: "delete", id: delta.removedProjectId });
}
clearWorkspaceArchivePending({ serverId: this.serverId, workspaceId: delta.id });
@@ -217,19 +221,19 @@ export class WorkspaceDirectoryReplica {
const projectWorkspaces = this.workspaceIdsByProject.get(workspace.projectId) ?? new Set();
projectWorkspaces.add(workspace.id);
this.workspaceIdsByProject.set(workspace.projectId, projectWorkspaces);
useSessionStore.getState().mergeWorkspaces(this.serverId, [workspace]);
this.store.getState().mergeWorkspaces(this.serverId, [workspace]);
}
private deleteWorkspace(workspaceId: string): void {
const workspace = this.workspaces.get(workspaceId);
if (workspace) this.workspaceIdsByProject.get(workspace.projectId)?.delete(workspaceId);
this.workspaces.delete(workspaceId);
useSessionStore.getState().removeWorkspace(this.serverId, workspaceId);
this.store.getState().removeWorkspace(this.serverId, workspaceId);
}
private setProject(project: ProjectDescriptor): void {
this.projects.set(project.projectId, project);
useSessionStore.getState().upsertProject(this.serverId, project);
this.store.getState().upsertProject(this.serverId, project);
}
private rebuildProjectIndex(): void {
@@ -0,0 +1,44 @@
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { expect, it } from "vitest";
import { QueryClient, useQueryClient } from "@tanstack/react-query";
import { createSessionStore, useSessionStoreApi } from "@/stores/session-store";
import { useCreateFlowStoreApi } from "@/stores/create-flow-store";
import { HostRuntimeStore, useHostRuntimeStore, type HostAuthorityBinding } from "./host-runtime";
import { HostAuthorityProvider } from "./host-authority-provider";
it("ordinary consumer hooks retain the runtime, store and creation owner of each authority", () => {
const binding = (originServerId: string): HostAuthorityBinding => {
const sessionStore = createSessionStore();
return {
reference: { originServerId, serverId: "B" },
namespace: JSON.stringify(["host-authority-v1", originServerId, "B"]),
sessionStore,
queries: new QueryClient(),
runtime: new HostRuntimeStore({ sessionStore }),
ready: Promise.resolve(),
};
};
const direct = binding("B");
const routed = binding("A");
function OrdinaryConsumer({ expected }: { expected: HostAuthorityBinding }) {
const runtime = useHostRuntimeStore();
const store = useSessionStoreApi();
const creates = useCreateFlowStoreApi();
expect(useQueryClient()).toBe(expected.queries);
expect(runtime).toBe(expected.runtime);
expect(store).toBe(expected.sessionStore);
expect(creates).toBe(expected.sessionStore.createFlows);
return null;
}
renderToStaticMarkup(
<>
<HostAuthorityProvider binding={direct}>
<OrdinaryConsumer expected={direct} />
</HostAuthorityProvider>
<HostAuthorityProvider binding={routed}>
<OrdinaryConsumer expected={routed} />
</HostAuthorityProvider>
</>,
);
});
@@ -0,0 +1,26 @@
import type { ReactNode } from "react";
import { QueryClientProvider } from "@tanstack/react-query";
import { SessionStoreContext } from "@/stores/session-store";
import { CreateFlowStoreContext } from "@/stores/create-flow-store";
import { HostRuntimeContext, type HostAuthorityBinding } from "./host-runtime";
/** The retained screen owns acquisition/release; descendants receive one bound capability. */
export function HostAuthorityProvider({
binding,
children,
}: {
binding: HostAuthorityBinding;
children: ReactNode;
}) {
return (
<QueryClientProvider client={binding.queries}>
<HostRuntimeContext.Provider value={binding.runtime}>
<SessionStoreContext.Provider value={binding.sessionStore}>
<CreateFlowStoreContext.Provider value={binding.sessionStore.createFlows}>
{children}
</CreateFlowStoreContext.Provider>
</SessionStoreContext.Provider>
</HostRuntimeContext.Provider>
</QueryClientProvider>
);
}
+330 -2
View File
@@ -1,4 +1,6 @@
import { indexedDB as fakeIndexedDb, IDBKeyRange as FakeIDBKeyRange } from "fake-indexeddb";
import { afterEach, describe, expect, it, vi } from "vitest";
import { QueryObserver } from "@tanstack/react-query";
import type { AppStateStatus } from "react-native";
import {
bindHostRuntimeAppState,
@@ -13,12 +15,15 @@ import type {
import { DaemonAuthenticationError } from "@getpaseo/client/internal/daemon-client";
import type { ConnectionOffer } from "@getpaseo/protocol/connection-offer";
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
import { MutableDaemonConfigSchema } from "@getpaseo/protocol/messages";
import { daemonConfigQueryKey } from "@/data/daemon-config";
import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types";
import type { HostConnection, HostProfile } from "@/types/host-connection";
import { defaultHostAppearance } from "@/hosts/appearance";
import { useSessionStore, type Agent } from "@/stores/session-store";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { isAgentArchiving, setAgentArchiving } from "@/hooks/use-archive-agent";
import { createAgentArchiveAction } from "@/hooks/use-archive-agent";
import { queryClient } from "@/data/query-client";
import {
HostRuntimeController,
@@ -52,6 +57,16 @@ it("requests the managed connection credential through desktop main without a we
});
class FakeDaemonClient {
private readonly eventListeners = new Set<(message: SessionOutboundMessage) => void>();
publishEvent(message: SessionOutboundMessage): void {
for (const receive of this.eventListeners) receive(message);
}
readonly archiveCalls: string[] = [];
archiveResponse: Promise<{ archivedAt: string }> | null = null;
async archiveAgent(agentId: string): Promise<{ archivedAt: string }> {
this.archiveCalls.push(agentId);
return this.archiveResponse ?? { archivedAt: new Date().toISOString() };
}
supportsBackgroundWorkspaces(): boolean {
return false;
}
@@ -96,7 +111,12 @@ class FakeDaemonClient {
}
observeEvents() {
return subscriptionFixture(Promise.resolve({}), () => () => {});
return subscriptionFixture(Promise.resolve({}), (receive) => {
this.eventListeners.add(receive);
return () => {
this.eventListeners.delete(receive);
};
});
}
async waitForAgentUpdates(): Promise<void> {
@@ -161,6 +181,29 @@ class FakeDaemonClient {
return this.state;
}
private readonly hostDirectoryListeners = new Set<(message: SessionOutboundMessage) => void>();
observeHosts(): ReturnType<DaemonClient["observeHosts"]> {
return subscriptionFixture(
Promise.resolve({ self: { serverId: this.serverId, label: "Origin" }, hosts: [] }),
(receive) => {
this.hostDirectoryListeners.add(receive);
return () => {
this.hostDirectoryListeners.delete(receive);
};
},
);
}
hostsChanged(): void {
for (const receive of this.hostDirectoryListeners)
receive({
type: "hosts.changed",
payload: {
subscriptionId: "hosts",
directory: { self: { serverId: this.serverId, label: "Origin" }, hosts: [] },
},
});
}
public serverId = "srv_test";
public ownedSubscriptions = true;
private daemonVersion: string | null = "0.8.0";
@@ -168,7 +211,7 @@ class FakeDaemonClient {
if (this.daemonVersion === null) return null;
return {
status: "server_info",
serverId: "srv_test",
serverId: this.serverId,
hostname: "test",
version: this.daemonVersion,
features: { ownedSubscriptions: this.ownedSubscriptions },
@@ -4440,3 +4483,288 @@ describe("HostRuntimeStore initial connection hint bootstrap", () => {
expect(store.getHosts()).toHaveLength(0);
});
});
it("retains one authority binding for direct and routed consumers without changing a retained direct destination", async () => {
globalThis.indexedDB = fakeIndexedDb;
globalThis.IDBKeyRange = FakeIDBKeyRange;
const prefix = crypto.randomUUID();
const aId = `A-${prefix}`;
const bId = `B-${prefix}`;
const clients = new Map<string, FakeDaemonClient>();
const resolutions: Array<{ origin: DaemonClient; serverId: string }> = [];
const releases: string[] = [];
const routeClient = makeConnectedProbeClient(5);
routeClient.serverId = bId;
const store = new HostRuntimeStore({
storage: createMemoryHostRuntimeStorage(),
replicaRowStore: createMemoryReplicaRowStore(),
deps: {
createClient: ({ host }) => {
const client = makeConnectedProbeClient(5);
client.serverId = host.serverId;
clients.set(host.serverId, client);
return client as unknown as DaemonClient;
},
connectToDaemon: async () => {
throw new Error("Probe is not needed");
},
getClientId: async () => "binding-client",
},
resolveHostConnection: async (origin, serverId) => {
resolutions.push({ origin, serverId });
const client =
origin === (clients.get(aId) as unknown as DaemonClient) ? routeClient : origin;
return {
client: client as DaemonClient,
release: async () => {
releases.push(serverId);
},
};
},
});
const hosts = [aId, bId].map((serverId) =>
makeHost({ serverId, connections: [makeHost().connections[0]!] }),
);
const initial = new Map<string, { connectionId: string; existingClient: DaemonClient }>();
for (const host of hosts) {
const client = makeConnectedProbeClient(5);
client.serverId = host.serverId;
clients.set(host.serverId, client);
initial.set(host.serverId, {
connectionId: host.connections[0]!.id,
existingClient: client as unknown as DaemonClient,
});
}
store.syncHosts(hosts, { initialConnectionByServerId: initial });
await onceHostSnapshotMatches(store, aId, (snapshot) => snapshot?.connectionStatus === "online");
await onceHostSnapshotMatches(store, bId, (snapshot) => snapshot?.connectionStatus === "online");
const direct = store.acquireBinding({ originServerId: bId, serverId: bId });
const routed = store.acquireBinding({ originServerId: aId, serverId: bId });
const sibling = store.acquireBinding({ originServerId: aId, serverId: bId });
try {
await Promise.all([direct.binding.ready, routed.binding.ready]);
expect(sibling.binding).toBe(routed.binding);
expect(direct.binding.runtime.getClient(bId)).toBe(clients.get(bId));
expect(routed.binding.runtime.getClient(bId)).toBe(routeClient);
expect(direct.binding.sessionStore).not.toBe(routed.binding.sessionStore);
expect(direct.binding.namespace).not.toBe(routed.binding.namespace);
expect(routed.binding.queries.getDefaultOptions()).toEqual(queryClient.getDefaultOptions());
const key = ["schedules", bId];
let directFetches = 0;
let routedFetches = 0;
direct.binding.queries.setQueryData(key, "Direct cache");
routed.binding.queries.setQueryData(key, "Routed cache");
queryClient.setQueryData(key, "Registry cache");
const directObserver = new QueryObserver(direct.binding.queries, {
queryKey: key,
queryFn: async () => {
directFetches++;
return "Direct recovered";
},
});
const routedObserver = new QueryObserver(routed.binding.queries, {
queryKey: key,
queryFn: async () => {
routedFetches++;
return "Routed recovered";
},
});
const stopDirect = directObserver.subscribe(() => {});
const stopRouted = routedObserver.subscribe(() => {});
try {
expect([directFetches, routedFetches]).toEqual([0, 0]);
routeClient.setConnectionState({ status: "disconnected", reason: "Recover routed cache" });
routeClient.setConnectionState({ status: "connected" });
await expect.poll(() => routed.binding.queries.getQueryData(key)).toBe("Routed recovered");
expect([directFetches, routedFetches]).toEqual([0, 1]);
expect(direct.binding.queries.getQueryData(key)).toBe("Direct cache");
expect(queryClient.getQueryState(key)?.isInvalidated).toBe(false);
clients
.get(bId)!
.setConnectionState({ status: "disconnected", reason: "Recover direct cache" });
clients.get(bId)!.setConnectionState({ status: "connected" });
await expect.poll(() => direct.binding.queries.getQueryData(key)).toBe("Direct recovered");
expect([directFetches, routedFetches]).toEqual([1, 1]);
} finally {
stopDirect();
stopRouted();
queryClient.removeQueries({ queryKey: key });
}
const configKey = daemonConfigQueryKey(bId);
const config = MutableDaemonConfigSchema.parse({
relay: { enabled: false },
mcp: { injectIntoAgents: true },
});
const directConfig = MutableDaemonConfigSchema.parse({
relay: { enabled: true },
mcp: { injectIntoAgents: true },
});
direct.binding.queries.setQueryData(configKey, directConfig);
routeClient.publishEvent({
type: "status",
payload: { status: "daemon_config_changed", config },
});
await expect.poll(() => routed.binding.queries.getQueryData(configKey)).toEqual(config);
expect(direct.binding.queries.getQueryData(configKey)).toEqual(directConfig);
const agent = replicaAgent(
makeFetchAgentsEntry({
id: "same-agent",
cwd: "/repo",
title: "Direct",
updatedAt: new Date().toISOString(),
}).agent,
bId,
);
direct.binding.runtime.acceptAgentSnapshot(bId, agent);
expect(routed.binding.sessionStore.getState().sessions[bId]?.agents.size).toBe(0);
routed.binding.runtime.acceptAgentSnapshot(bId, { ...agent, title: "Routed" });
direct.binding.runtime.acceptAgentSnapshot(bId, { ...agent, title: "Late direct" });
expect(routed.binding.sessionStore.getState().sessions[bId]?.agents.get(agent.id)?.title).toBe(
"Routed",
);
const archive = createAgentArchiveAction({
runtime: routed.binding.runtime,
sessionStore: routed.binding.sessionStore,
queryClient: routed.binding.queries,
unavailableMessage: "Authority unavailable",
});
const gate = createDeferred<{ archivedAt: string }>();
routeClient.archiveResponse = gate.promise;
const archiving = archive({ serverId: bId, agentId: agent.id });
void archiving.catch(() => {});
expect(routeClient.archiveCalls).toEqual([agent.id]);
expect(clients.get(bId)!.archiveCalls).toEqual([]);
expect(
isAgentArchiving({ queryClient: routed.binding.queries, serverId: bId, agentId: agent.id }),
).toBe(true);
expect(
isAgentArchiving({ queryClient: direct.binding.queries, serverId: bId, agentId: agent.id }),
).toBe(false);
expect(
direct.binding.sessionStore.getState().sessions[bId]?.agents.get(agent.id)?.archivedAt,
).toBeNull();
gate.reject(new Error("Rejected by selected authority"));
await expect(archiving).rejects.toThrow("Rejected by selected authority");
expect(
routed.binding.sessionStore.getState().sessions[bId]?.agents.get(agent.id)?.archivedAt,
).toBeNull();
routeClient.setConnectionState({ status: "disconnected", reason: "Route revoked" });
expect(routed.binding.runtime.getSnapshot(bId)?.connectionStatus).toBe("error");
expect(direct.binding.runtime.getSnapshot(bId)?.connectionStatus).toBe("online");
expect(routed.binding.runtime.getClient(bId)).toBe(routeClient);
expect(resolutions).toHaveLength(2);
// Withdrawal must retire command sources even if the old client stays alive.
routeClient.setConnectionState({ status: "connected" });
store.syncHosts(hosts.filter((host) => host.serverId === bId));
expect(routed.binding.runtime.getClient(bId)).toBeNull();
expect(routed.binding.sessionStore.getState().sessions[bId]?.client).toBeNull();
expect(routeClient.getConnectionState().status).toBe("connected");
routeClient.publishEvent({
type: "status",
payload: { status: "daemon_config_changed", config: directConfig },
});
expect(routed.binding.queries.getQueryData(configKey)).toEqual(config);
await expect(archive({ serverId: bId, agentId: agent.id })).rejects.toThrow(
"Authority unavailable",
);
expect(routeClient.archiveCalls).toEqual([agent.id]);
expect(clients.get(bId)!.archiveCalls).toEqual([]);
expect(
routed.binding.sessionStore.getState().sessions[bId]?.agents.get(agent.id)?.archivedAt,
).toBeNull();
expect(direct.binding.runtime.getSnapshot(bId)?.connectionStatus).toBe("online");
expect(direct.binding.sessionStore.getState().sessions[bId]?.client).toBe(clients.get(bId));
await routed.release();
expect(releases).toEqual([bId]);
expect(sibling.binding.sessionStore.getState().sessions[bId]?.agents.get(agent.id)?.title).toBe(
"Routed",
);
} finally {
await Promise.all([direct.release(), routed.release(), sibling.release()]);
store.syncHosts([]);
}
expect(releases).toHaveLength(2);
});
it.each(["after failure", "during acquisition"])(
"recovers a cold offline authority when the same origin enables the destination %s",
async (timing) => {
globalThis.indexedDB = fakeIndexedDb;
globalThis.IDBKeyRange = FakeIDBKeyRange;
const aId = `cold-A-${crypto.randomUUID()}`;
const bId = `cold-B-${crypto.randomUUID()}`;
const origin = makeConnectedProbeClient(5);
origin.serverId = aId;
const target = makeConnectedProbeClient(5);
target.serverId = bId;
const gate = createDeferred<void>();
let attempts = 0;
let enabled = false;
const store = new HostRuntimeStore({
storage: createMemoryHostRuntimeStorage(),
replicaRowStore: createMemoryReplicaRowStore(),
resolveHostConnection: async (source, destination) => {
expect(source).toBe(origin);
expect(destination).toBe(bId);
attempts++;
if (attempts === 1) {
await gate.promise;
throw new Error("Destination disabled");
}
if (!enabled) throw new Error("Destination disabled");
return { client: target as unknown as DaemonClient, release: async () => {} };
},
deps: {
createClient: () => {
throw new Error("Origin must remain the same client");
},
connectToDaemon: async () => {
throw new Error("No new connection");
},
getClientId: async () => "cold-authority",
},
});
const host = makeHost({ serverId: aId, connections: [makeHost().connections[0]!] });
store.syncHosts([host], {
initialConnectionByServerId: new Map([
[
aId,
{
connectionId: host.connections[0]!.id,
existingClient: origin as unknown as DaemonClient,
},
],
]),
});
await onceHostSnapshotMatches(
store,
aId,
(snapshot) => snapshot?.connectionStatus === "online",
);
origin.setConnectionState({ status: "disconnected", reason: "Cold offline origin" });
const retained = store.acquireBinding({ originServerId: aId, serverId: bId });
try {
origin.setConnectionState({ status: "connected" });
await expect.poll(() => attempts).toBe(1);
if (timing === "after failure") {
gate.resolve();
await expect
.poll(() => retained.binding.runtime.getSnapshot(bId)?.connectionStatus)
.toBe("error");
}
enabled = true;
origin.hostsChanged();
gate.resolve();
await expect
.poll(() => retained.binding.runtime.getSnapshot(bId)?.connectionStatus)
.toBe("online");
expect(store.getClient(aId)).toBe(origin);
expect(retained.binding.runtime.getClient(bId)).toBe(target);
expect(attempts).toBeGreaterThanOrEqual(2);
} finally {
gate.resolve();
await retained.release();
store.syncHosts([]);
}
},
);
+400 -84
View File
@@ -1,4 +1,9 @@
import { useSyncExternalStore, useMemo } from "react";
import {
acquireHostConnection,
type HostConnectionLease,
} from "@getpaseo/client/internal/host-connection";
import { createContext, useContext, useSyncExternalStore, useMemo } from "react";
import { QueryClient } from "@tanstack/react-query";
import AsyncStorage from "@react-native-async-storage/async-storage";
import equal from "fast-deep-equal/es6";
import {
@@ -52,6 +57,8 @@ import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities";
import { BROWSER_AUTOMATION_COMMAND_NAMES } from "@getpaseo/protocol/browser-automation/rpc-schemas";
import {
useSessionStore,
type SessionStoreApi,
createSessionStore,
toDaemonServerInfo,
type Agent,
type WorkspaceDescriptor,
@@ -60,7 +67,7 @@ import {
import type { TurnLivenessTransition } from "@/timeline/turn-liveness";
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
import { invalidateCheckoutGitQueriesForServer } from "@/git/query-keys";
import { queryClient } from "@/data/query-client";
import { queryClient, createQueryClient } from "@/data/query-client";
import {
invalidateServerDataQueriesAfterReconnect,
mountServerDataPushRouter,
@@ -608,21 +615,8 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
}),
getClientId: () => getOrCreateClientId(),
mountClientHandlers: ({ client, host }) => {
const unmountServerData = mountServerDataPushRouter({
client,
queryClient,
serverId: host.serverId,
});
if (!browserAutomationCapabilities) {
return unmountServerData;
}
const unmountBrowserAutomation = mountBrowserAutomationDaemonClientHandler(client, {
serverId: host.serverId,
});
return () => {
unmountBrowserAutomation();
unmountServerData();
};
if (!browserAutomationCapabilities) return () => {};
return mountBrowserAutomationDaemonClientHandler(client, { serverId: host.serverId });
},
};
}
@@ -1486,8 +1480,54 @@ interface AgentDirectoryRefreshInput {
page?: FetchAgentsOptions["page"];
}
export interface HostAuthorityReference {
originServerId: string;
serverId: string;
}
export interface HostAuthorityBinding {
readonly reference: HostAuthorityReference;
readonly namespace: string;
readonly sessionStore: SessionStoreApi;
readonly queries: QueryClient;
readonly runtime: HostRuntimeStore;
/** Initial acquisition only. Current availability comes from runtime snapshots. */
readonly ready: Promise<void>;
}
export interface OwnedHostAuthorityBinding {
readonly binding: HostAuthorityBinding;
release(): Promise<void>;
}
function authorityNamespace(reference: HostAuthorityReference): string {
return JSON.stringify(["host-authority-v1", reference.originServerId, reference.serverId]);
}
function initialAuthoritySnapshot(serverId: string): HostRuntimeSnapshot {
return {
serverId,
activeConnectionId: null,
activeConnection: null,
connectionStatus: "connecting",
client: null,
lastError: null,
lastOnlineAt: null,
agentDirectoryStatus: "idle",
agentDirectoryError: null,
hasEverLoadedAgentDirectory: false,
probeByConnectionId: new Map(),
clientGeneration: 0,
connectionEpoch: 0,
};
}
export class HostRuntimeStore {
private controllers = new Map<string, HostRuntimeController>();
private readonly authorityBindings = new Map<
string,
{ binding: HostAuthorityBinding; refs: number; close(): Promise<void> }
>();
private serverListeners = new Map<string, Set<() => void>>();
private globalListeners = new Set<() => void>();
private hostListListeners = new Set<() => void>();
@@ -1510,20 +1550,280 @@ export class HostRuntimeStore {
private readonly hostConfirmations = new HostConfirmations();
private storage: HostRuntimeStorage;
private replicaCache: ReplicaCache;
private readonly sessionStore: SessionStoreApi;
private readonly queries: QueryClient;
private readonly serverDataClients = new Map<string, { client: DaemonClient; release(): void }>();
private readonly resolveHostConnection: typeof acquireHostConnection;
private readonly snapshots = new Map<string, HostRuntimeSnapshot>();
private readonly revokePushNotifications: typeof revokePushNotifications;
constructor(input?: {
deps?: HostRuntimeControllerDeps;
storage?: HostRuntimeStorage;
replicaRowStore?: ReplicaRowStore;
sessionStore?: SessionStoreApi;
queries?: QueryClient;
resolveHostConnection?: typeof acquireHostConnection;
revokePushNotifications?: typeof revokePushNotifications;
}) {
this.deps = input?.deps ?? createDefaultDeps();
this.sessionStore = input?.sessionStore ?? useSessionStore;
this.queries = input?.queries ?? queryClient;
this.resolveHostConnection = input?.resolveHostConnection ?? acquireHostConnection;
this.storage = input?.storage ?? AsyncStorage;
this.replicaCache = new ReplicaCache(input?.replicaRowStore ?? createReplicaRowStore());
this.revokePushNotifications = input?.revokePushNotifications ?? revokePushNotifications;
}
/** A retained screen owns a lease; route identity never grants access by itself. */
acquireBinding(reference: HostAuthorityReference): OwnedHostAuthorityBinding {
const normalized = { originServerId: reference.originServerId, serverId: reference.serverId };
const namespace = authorityNamespace(normalized);
let entry = this.authorityBindings.get(namespace);
if (!entry) {
const queries = createQueryClient();
const scope = this.createAuthorityScope(normalized, namespace, queries);
entry = {
binding: {
reference: normalized,
namespace,
sessionStore: scope.runtime.sessionStore,
queries,
runtime: scope.runtime,
ready: scope.ready,
},
refs: 0,
close: async () => {
try {
await scope.close();
} finally {
queries.clear();
}
},
};
this.authorityBindings.set(namespace, entry);
}
entry.refs++;
const retained = entry;
let released: Promise<void> | null = null;
return {
binding: retained.binding,
release: () => {
if (!released) {
retained.refs--;
if (retained.refs === 0) {
if (this.authorityBindings.get(namespace) === retained)
this.authorityBindings.delete(namespace);
released = retained.close();
} else released = Promise.resolve();
}
return released;
},
};
}
private createAuthorityScope(
reference: HostAuthorityReference,
namespace: string,
queries: QueryClient,
): {
runtime: HostRuntimeStore;
ready: Promise<void>;
close(): Promise<void>;
} {
const scoped = new HostRuntimeStore({
sessionStore: createSessionStore(),
queries,
replicaRowStore: createReplicaRowStore(namespace),
storage: this.storage,
deps: this.deps,
});
const { serverId, originServerId } = reference;
scoped.sessionStore.getState().initializeSession(serverId, null);
scoped.snapshots.set(serverId, initialAuthoritySnapshot(serverId));
const patch = (change: Partial<HostRuntimeSnapshot>) => {
const previous = scoped.snapshots.get(serverId)!;
scoped.snapshots.set(serverId, { ...previous, ...change });
scoped.emit(serverId);
};
scoped.initializeReplica(serverId, {
onAgentStoppedRunning: (agentId) => scoped.drainQueuedAgentMessage(serverId, agentId),
markAgentLoading: () =>
patch({ agentDirectoryStatus: "initial_loading", agentDirectoryError: null }),
markAgentReady: () =>
patch({
agentDirectoryStatus: "ready",
agentDirectoryError: null,
hasEverLoadedAgentDirectory: true,
}),
markAgentError: (failure) =>
patch({
agentDirectoryStatus: scoped.getSnapshot(serverId)?.hasEverLoadedAgentDirectory
? "error_after_ready"
: "error_before_first_success",
agentDirectoryError: failure,
}),
});
let stopped = false;
let origin: DaemonClient | null = null;
let controller = new AbortController();
let lease: HostConnectionLease | null = null;
let stopTarget: (() => void) | null = null;
let directory: ReturnType<DaemonClient["observeHosts"]> | null = null;
let acquiring: Promise<void> | null = null;
let directoryRevision = 0;
let resolveReady!: () => void;
let rejectReady!: (failure: unknown) => void;
const ready = new Promise<void>((resolve, reject) => {
resolveReady = resolve;
rejectReady = reject;
});
void ready.catch(() => {});
let generation = 0;
const publish = (
client: DaemonClient | null,
state: ConnectionState,
failure: string | null = null,
) => {
const previous = scoped.snapshots.get(serverId)!;
const online = state.status === "connected";
let connectionStatus: HostRuntimeConnectionStatus = failure ? "error" : "offline";
if (state.status === "connecting") connectionStatus = "connecting";
if (online) connectionStatus = "online";
const snapshot: HostRuntimeSnapshot = {
...previous,
client,
connectionStatus,
lastError: failure,
clientGeneration: generation,
connectionEpoch:
online && previous.connectionStatus !== "online"
? previous.connectionEpoch + 1
: previous.connectionEpoch,
lastOnlineAt: online ? new Date().toISOString() : previous.lastOnlineAt,
};
scoped.syncSessionReplica(serverId, snapshot);
scoped.syncDirectoryConnection(serverId);
scoped.emit(serverId);
};
const acquire = () => {
if (
stopped ||
acquiring ||
lease ||
!origin ||
origin.getConnectionState().status !== "connected"
)
return;
const source = origin;
const signal = controller.signal;
const attempt = generation;
const observedRevision = directoryRevision;
publish(null, { status: "connecting", attempt: 1 });
acquiring = (async () => {
try {
const acquired = await this.resolveHostConnection(source, serverId, signal);
if (stopped || signal.aborted || generation !== attempt) {
await acquired.release();
return;
}
lease = acquired;
stopTarget = acquired.client.subscribeConnectionStatus((state) => {
if (!stopped && generation === attempt)
publish(acquired.client, state, acquired.client.lastError);
});
resolveReady();
} catch (failure) {
if (!stopped && generation === attempt) {
publish(null, { status: "disconnected" }, toErrorMessage(failure));
rejectReady(failure);
}
} finally {
if (generation === attempt) {
acquiring = null;
if (!lease && directoryRevision !== observedRevision) acquire();
}
}
})();
};
const retired = new Set<Promise<unknown>>();
const retire = (release: Promise<unknown>) => {
retired.add(release);
void release.then(
() => retired.delete(release),
() => retired.delete(release),
);
};
const syncOrigin = () => {
if (stopped) return;
const snapshot = this.getSnapshot(originServerId);
const nextOrigin = snapshot?.client ?? null;
if (origin !== nextOrigin) {
generation++;
controller.abort();
stopTarget?.();
stopTarget = null;
if (lease) retire(lease.release());
if (directory) retire(directory.release());
if (acquiring) retire(acquiring);
lease = null;
directory = null;
acquiring = null;
controller = new AbortController();
origin = nextOrigin;
publish(null, { status: "disconnected" });
}
if (origin && origin.getConnectionState().status === "connected" && !directory) {
try {
directory = origin.observeHosts();
const changed = () => {
directoryRevision++;
acquire();
};
directory.subscribe({ snapshot: changed, update: changed });
void directory.ready.catch(() => {});
} catch (failure) {
publish(null, { status: "disconnected" }, toErrorMessage(failure));
}
}
if (snapshot?.connectionStatus === "online") acquire();
else if (!lease)
publish(
null,
{ status: "disconnected" },
snapshot?.lastError ?? "Origin authority is unavailable",
);
};
const stopOrigin = this.subscribe(originServerId, syncOrigin);
syncOrigin();
return {
runtime: scoped,
ready,
close: async () => {
stopped = true;
generation++;
stopOrigin();
controller.abort();
stopTarget?.();
rejectReady(new Error("Host authority binding is disposed"));
scoped.syncServerDataClient(serverId, null);
scoped.directorySyncByServer.get(serverId)?.dispose();
scoped.sessionStore.getState().sessions[serverId]?.viewedTimelineSync?.dispose();
for (const agentId of scoped.sessionStore
.getState()
.sessions[serverId]?.initializingAgents.keys() ?? []) {
scoped.sessionStore.initializations.rejectInitDeferred(
scoped.sessionStore.initializations.getInitKey(serverId, agentId),
new Error("Host authority binding is disposed"),
);
}
await Promise.allSettled([lease?.release(), directory?.release(), acquiring, ...retired]);
await scoped.replicaCache.flush();
scoped.sessionStore.getState().clearSession(serverId);
},
};
}
// --- Host registry ---
getHosts(): HostProfile[] {
@@ -1772,6 +2072,7 @@ export class HostRuntimeStore {
}
rekeyMap(this.controllers, oldServerId, newServerId);
this.snapshots.delete(oldServerId);
rekeyMap(this.lastConnectionStatusByServer, oldServerId, newServerId);
rekeyMap(this.connectionStatusStartedAtByServer, oldServerId, newServerId);
this.replicaCache.reconcileServerId(oldServerId, newServerId);
@@ -1779,25 +2080,12 @@ export class HostRuntimeStore {
this.directorySyncByServer.get(oldServerId)?.dispose();
this.directorySyncByServer.delete(oldServerId);
this.timelineReplicaByServer.delete(oldServerId);
const directory = new DirectorySync(
newServerId,
{
onAgentStoppedRunning: (agentId) => this.drainQueuedAgentMessage(newServerId, agentId),
markAgentLoading: () => controller.markAgentDirectorySyncLoading(),
markAgentReady: () => controller.markAgentDirectorySyncReady(),
markAgentError: (error) => controller.markAgentDirectorySyncError(error),
},
this.replicaCache,
);
this.directorySyncByServer.set(newServerId, directory);
this.timelineReplicaByServer.set(
newServerId,
createTimelineReplica({
serverId: newServerId,
storage: this.replicaCache,
prepareAgent: (agentId) => directory.prepareAgentRoute(agentId),
}),
);
const directory = this.initializeReplica(newServerId, {
onAgentStoppedRunning: (agentId) => this.drainQueuedAgentMessage(newServerId, agentId),
markAgentLoading: () => controller.markAgentDirectorySyncLoading(),
markAgentReady: () => controller.markAgentDirectorySyncReady(),
markAgentError: (error) => controller.markAgentDirectorySyncError(error),
});
controller.adoptReconciledServerId(newServerId);
const snapshot = controller.getSnapshot();
this.clearHostReplica(oldServerId);
@@ -2259,6 +2547,7 @@ export class HostRuntimeStore {
continue;
}
this.controllers.delete(serverId);
this.snapshots.delete(serverId);
this.lastConnectionStatusByServer.delete(serverId);
this.connectionStatusStartedAtByServer.delete(serverId);
this.directorySyncByServer.get(serverId)?.dispose();
@@ -2287,27 +2576,15 @@ export class HostRuntimeStore {
onReconcileServerId: (oldId, newId) => this.reconcileServerId(oldId, newId),
});
this.controllers.set(host.serverId, controller);
useSessionStore.getState().initializeSession(host.serverId, null);
const directory = new DirectorySync(
host.serverId,
{
onAgentStoppedRunning: (agentId) => this.drainQueuedAgentMessage(host.serverId, agentId),
markAgentLoading: () => controller.markAgentDirectorySyncLoading(),
markAgentReady: () => controller.markAgentDirectorySyncReady(),
markAgentError: (error) => controller.markAgentDirectorySyncError(error),
},
this.replicaCache,
);
this.directorySyncByServer.set(host.serverId, directory);
this.timelineReplicaByServer.set(
host.serverId,
createTimelineReplica({
serverId: host.serverId,
storage: this.replicaCache,
prepareAgent: (agentId) => directory.prepareAgentRoute(agentId),
}),
);
this.sessionStore.getState().initializeSession(host.serverId, null);
this.initializeReplica(host.serverId, {
onAgentStoppedRunning: (agentId) => this.drainQueuedAgentMessage(host.serverId, agentId),
markAgentLoading: () => controller.markAgentDirectorySyncLoading(),
markAgentReady: () => controller.markAgentDirectorySyncReady(),
markAgentError: (error) => controller.markAgentDirectorySyncError(error),
});
const initialSnapshot = controller.getSnapshot();
this.syncSessionReplica(host.serverId, initialSnapshot);
this.lastConnectionStatusByServer.set(host.serverId, initialSnapshot.connectionStatus);
this.connectionStatusStartedAtByServer.set(host.serverId, Date.now());
controller.subscribe(() => {
@@ -2332,13 +2609,31 @@ export class HostRuntimeStore {
}
}
private initializeReplica(
serverId: string,
callbacks: ConstructorParameters<typeof DirectorySync>[1],
): DirectorySync {
const directory = new DirectorySync(serverId, callbacks, this.replicaCache, this.sessionStore);
this.directorySyncByServer.set(serverId, directory);
this.timelineReplicaByServer.set(
serverId,
createTimelineReplica({
serverId,
store: this.sessionStore,
storage: this.replicaCache,
prepareAgent: (agentId) => directory.prepareAgentRoute(agentId),
}),
);
return directory;
}
private syncSessionReplica(serverId: string, snapshot: HostRuntimeSnapshot): void {
if (!snapshot.client) {
return;
}
const sessionStore = useSessionStore.getState();
this.snapshots.set(serverId, snapshot);
const sessionStore = this.sessionStore.getState();
sessionStore.initializeSession(serverId, snapshot.client, snapshot.clientGeneration);
sessionStore.updateSessionClient(serverId, snapshot.client, snapshot.clientGeneration);
this.syncServerDataClient(serverId, snapshot.client);
if (!snapshot.client) return;
// A reconnect keeps the same client, so the daemon's handshake (a restart or upgrade can
// change its version and features) only reaches the store here. The client clears it while
// disconnected; keep the last known value until the next handshake.
@@ -2348,19 +2643,31 @@ export class HostRuntimeStore {
}
}
private syncServerDataClient(serverId: string, client: DaemonClient | null): void {
const current = this.serverDataClients.get(serverId);
if (current?.client === client) return;
current?.release();
this.serverDataClients.delete(serverId);
if (client)
this.serverDataClients.set(serverId, {
client,
release: mountServerDataPushRouter({ client, serverId, queryClient: this.queries }),
});
}
private clearHostReplica(serverId: string): void {
useSessionStore.getState().clearSession(serverId);
this.syncServerDataClient(serverId, null);
this.sessionStore.getState().clearSession(serverId);
useWorkspaceSetupStore.getState().clearServer(serverId);
}
private syncDirectoryConnection(serverId: string): void {
const controller = this.controllers.get(serverId);
if (!controller) {
const snapshot = this.snapshots.get(serverId);
if (!snapshot) {
this.lastConnectionStatusByServer.delete(serverId);
this.connectionStatusStartedAtByServer.delete(serverId);
return;
}
const snapshot = controller.getSnapshot();
const directory = this.directorySyncByServer.get(serverId);
directory?.connectionChanged({
client: snapshot.client,
@@ -2383,20 +2690,20 @@ export class HostRuntimeStore {
const didTransitionOnline =
snapshot.connectionStatus === "online" && previousStatus !== "online";
if (didTransitionOnline) {
useSessionStore.getState().bumpHistorySyncGeneration(serverId);
this.sessionStore.getState().bumpHistorySyncGeneration(serverId);
// Checkout git data is push-driven; pushes emitted while disconnected are gone for
// good (the daemon dedupes by snapshot fingerprint). Mark the caches stale so active
// queries refetch now and evicted ones on their next mount.
void invalidateCheckoutGitQueriesForServer(queryClient, serverId);
invalidateServerDataQueriesAfterReconnect({ queryClient, serverId });
void queryClient.invalidateQueries({ queryKey: schedulesQueryBaseKey });
void invalidateCheckoutGitQueriesForServer(this.queries, serverId);
invalidateServerDataQueriesAfterReconnect({ queryClient: this.queries, serverId });
void this.queries.invalidateQueries({ queryKey: schedulesQueryBaseKey });
}
}
drainQueuedAgentMessage(serverId: string, agentId: string): void {
const drainKey = `${serverId}:${agentId}`;
if (this.queuedAgentDrainInFlight.has(drainKey)) return;
const store = useSessionStore.getState();
const store = this.sessionStore.getState();
const session = store.sessions[serverId];
const queue = session?.queuedMessages.get(agentId);
const client = session?.client;
@@ -2410,12 +2717,13 @@ export class HostRuntimeStore {
messageId: next.id,
queue: {
read: (queuedAgentId) =>
useSessionStore.getState().sessions[serverId]?.queuedMessages.get(queuedAgentId) ?? [],
write: (update) => useSessionStore.getState().setQueuedMessages(serverId, update),
this.sessionStore.getState().sessions[serverId]?.queuedMessages.get(queuedAgentId) ?? [],
write: (update) => this.sessionStore.getState().setQueuedMessages(serverId, update),
},
submitMessage: async ({ text, attachments }) => {
const supportsForgeAttachments =
useSessionStore.getState().sessions[serverId]?.serverInfo?.features?.forgeSearch === true;
this.sessionStore.getState().sessions[serverId]?.serverInfo?.features?.forgeSearch ===
true;
await dispatchComposerAgentMessage({
client,
agentId,
@@ -2500,7 +2808,7 @@ export class HostRuntimeStore {
}
getSnapshot(serverId: string): HostRuntimeSnapshot | null {
return this.controllers.get(serverId)?.getSnapshot() ?? null;
return this.snapshots.get(serverId) ?? null;
}
getConnectionStatusSince(serverId: string): number | null {
@@ -2512,7 +2820,7 @@ export class HostRuntimeStore {
}
getClient(serverId: string): DaemonClient | null {
return this.controllers.get(serverId)?.getClient() ?? null;
return this.snapshots.get(serverId)?.client ?? null;
}
subscribe(serverId: string, listener: () => void): () => void {
@@ -2639,9 +2947,9 @@ export class HostRuntimeStore {
): Promise<Awaited<ReturnType<DaemonClient["fetchAgentTimeline"]>>> {
const directory = this.directorySyncByServer.get(serverId);
if (!directory) throw new Error(`Unknown host runtime for serverId ${serverId}`);
const owner = useSessionStore.getState().sessions[serverId]?.viewedTimelineSync;
const owner = this.sessionStore.getState().sessions[serverId]?.viewedTimelineSync;
const page = await directory.fetchTimeline(agentId, request);
if (owner && useSessionStore.getState().sessions[serverId]?.viewedTimelineSync === owner) {
if (owner && this.sessionStore.getState().sessions[serverId]?.viewedTimelineSync === owner) {
owner.flushStreamAgent(agentId);
owner.applyTimelineResponse(page);
}
@@ -2657,6 +2965,7 @@ export class HostRuntimeStore {
const replica = this.timelineReplicaByServer.get(serverId);
if (!replica) throw new Error(`Unknown host runtime for serverId ${serverId}`);
return createViewedTimelineOwner({
store: this.sessionStore,
serverId,
replica,
replaceDemandedAgentIds: (agentIds) => directory.setAgentRouteDemand(agentIds),
@@ -2706,8 +3015,15 @@ export function getHostRuntimeConnectionStatusSince(serverId: string): number |
return getHostRuntimeStore().getConnectionStatusSince(serverId);
}
export const HostRuntimeContext = createContext<HostRuntimeStore | null>(null);
/** Ordinary conversation hooks read the capability bound to their retained screen. */
export function useHostRuntimeStore(): HostRuntimeStore {
return useContext(HostRuntimeContext) ?? getHostRuntimeStore();
}
export function useHostRuntimeSnapshot(serverId: string): HostRuntimeSnapshot | null {
const store = getHostRuntimeStore();
const store = useHostRuntimeStore();
return useSyncExternalStore(
(onStoreChange) => store.subscribe(serverId, onStoreChange),
() => store.getSnapshot(serverId),
@@ -2716,7 +3032,7 @@ export function useHostRuntimeSnapshot(serverId: string): HostRuntimeSnapshot |
}
export function useHostRuntimeClient(serverId: string): DaemonClient | null {
const store = getHostRuntimeStore();
const store = useHostRuntimeStore();
return useSyncExternalStore(
(onStoreChange) => store.subscribe(serverId, onStoreChange),
() => store.getSnapshot(serverId)?.client ?? null,
@@ -2725,7 +3041,7 @@ export function useHostRuntimeClient(serverId: string): DaemonClient | null {
}
export function useHostRuntimeIsConnected(serverId: string): boolean {
const store = getHostRuntimeStore();
const store = useHostRuntimeStore();
return useSyncExternalStore(
(onStoreChange) => store.subscribe(serverId, onStoreChange),
() => isHostRuntimeConnected(store.getSnapshot(serverId)),
@@ -2734,7 +3050,7 @@ export function useHostRuntimeIsConnected(serverId: string): boolean {
}
export function useHostRuntimeConnectionStatus(serverId: string): HostRuntimeConnectionStatus {
const store = getHostRuntimeStore();
const store = useHostRuntimeStore();
return useSyncExternalStore(
(onStoreChange) => store.subscribe(serverId, onStoreChange),
() => store.getSnapshot(serverId)?.connectionStatus ?? "connecting",
@@ -2745,7 +3061,7 @@ export function useHostRuntimeConnectionStatus(serverId: string): HostRuntimeCon
export function useHostRuntimeConnectionStatuses(
serverIds: readonly string[],
): ReadonlyMap<string, HostRuntimeConnectionStatus> {
const store = getHostRuntimeStore();
const store = useHostRuntimeStore();
// The snapshot is the statuses themselves, joined into a string so React compares by
// value. A version counter read only for reactivity is dropped by the React Compiler.
const readStatuses = () =>
@@ -2765,7 +3081,7 @@ export function useHostRuntimeConnectionStatuses(
}
export function useHostRuntimeLastError(serverId: string): string | null {
const store = getHostRuntimeStore();
const store = useHostRuntimeStore();
return useSyncExternalStore(
(onStoreChange) => store.subscribe(serverId, onStoreChange),
() => store.getSnapshot(serverId)?.lastError ?? null,
@@ -2776,7 +3092,7 @@ export function useHostRuntimeLastError(serverId: string): string | null {
export function useHostRuntimeAgentDirectoryStatus(
serverId: string,
): HostRuntimeAgentDirectoryStatus {
const store = getHostRuntimeStore();
const store = useHostRuntimeStore();
return useSyncExternalStore(
(onStoreChange) => store.subscribe(serverId, onStoreChange),
() => store.getSnapshot(serverId)?.agentDirectoryStatus ?? "idle",
@@ -2785,7 +3101,7 @@ export function useHostRuntimeAgentDirectoryStatus(
}
export function useHostRuntimeIsDirectoryLoading(serverId: string): boolean {
const store = getHostRuntimeStore();
const store = useHostRuntimeStore();
return useSyncExternalStore(
(onStoreChange) => store.subscribe(serverId, onStoreChange),
() => isHostRuntimeDirectoryLoading(store.getSnapshot(serverId)),
@@ -38,12 +38,14 @@ function createConnection(database: SQLiteDatabase): ReplicaSqliteConnection {
};
}
const expoSqliteDriver: ReplicaSqliteDriver = {
async open() {
return createConnection(await openDatabaseAsync(DATABASE_NAME));
},
};
export function createReplicaRowStore(): ReplicaRowStore {
return createSqliteReplicaRowStore(expoSqliteDriver, REPLICA_ROW_STORE_SCHEMA_VERSION);
export function createReplicaRowStore(authorityNamespace?: string): ReplicaRowStore {
const databaseName = authorityNamespace
? `paseo-replica-${encodeURIComponent(authorityNamespace)}.db`
: DATABASE_NAME;
const driver: ReplicaSqliteDriver = {
async open() {
return createConnection(await openDatabaseAsync(databaseName));
},
};
return createSqliteReplicaRowStore(driver, REPLICA_ROW_STORE_SCHEMA_VERSION);
}
@@ -5,7 +5,7 @@ import {
} from "fake-indexeddb";
import { expect, it, vi } from "vitest";
import { runReplicaRowStoreContract } from "./row-store.contract";
import { createIndexedDbReplicaRowStore } from "./row-store.web";
import { createReplicaRowStore, createIndexedDbReplicaRowStore } from "./row-store.web";
globalThis.indexedDB = fakeIndexedDb;
globalThis.IDBKeyRange = FakeIDBKeyRange;
@@ -49,3 +49,27 @@ it("uses exact IndexedDB keys for targeted rows instead of scanning the host", a
get.mockRestore();
getAll.mockRestore();
});
it("restores only rows in the selected authority namespace while retaining the destination server ID", async () => {
const serverId = `B-${crypto.randomUUID()}`;
const origin = `A-${crypto.randomUUID()}`;
const direct = createReplicaRowStore();
const selected = createReplicaRowStore(origin);
await Promise.all([direct.open(), selected.open()]);
const row = {
serverId,
kind: "agent" as const,
id: "colliding-agent",
payload: "broader-direct",
};
await direct.apply({ upserts: [row], deletes: [] });
expect(await selected.read(serverId, ["agent"])).toEqual([]);
await selected.apply({ upserts: [{ ...row, payload: "selected-authority" }], deletes: [] });
const restored = createReplicaRowStore(origin);
await restored.open();
expect(await restored.read(serverId, ["agent"])).toEqual([
{ ...row, payload: "selected-authority" },
]);
expect(await direct.read(serverId, ["agent"])).toEqual([row]);
await Promise.all([selected.clear(), direct.deleteHost(serverId)]);
});
@@ -210,9 +210,11 @@ export function createIndexedDbReplicaRowStore(
return { open, read, readAll, apply, deleteHost, renameHost, clear };
}
export function createReplicaRowStore(): ReplicaRowStore {
export function createReplicaRowStore(authorityNamespace?: string): ReplicaRowStore {
return createIndexedDbReplicaRowStore({
databaseName: DATABASE_NAME,
databaseName: authorityNamespace
? `${DATABASE_NAME}:${encodeURIComponent(authorityNamespace)}`
: DATABASE_NAME,
schemaVersion: REPLICA_ROW_STORE_SCHEMA_VERSION,
});
}
+101 -84
View File
@@ -1,4 +1,5 @@
import { create } from "zustand";
import { create, useStore } from "zustand";
import { createContext, useContext } from "react";
import type { UserMessageImageAttachment } from "@/types/stream";
import type { AgentAttachment } from "@getpaseo/protocol/messages";
@@ -48,89 +49,105 @@ interface CreateFlowState {
clearAll: () => void;
}
export const useCreateFlowStore = create<CreateFlowState>((set, get) => ({
pendingByDraftId: {},
setPending: (pending) =>
set((state) => ({
pendingByDraftId: {
...state.pendingByDraftId,
[pending.draftId]: {
...pending,
lifecycle: "active",
},
},
})),
trySetPending: (pending) => {
const existing = get().pendingByDraftId[pending.draftId];
if (existing?.serverId === pending.serverId && existing.lifecycle !== "abandoned") return false;
get().setPending(pending);
return true;
},
updateAgentId: ({ draftId, agentId }) =>
set((state) => {
const current = state.pendingByDraftId[draftId];
if (!current || current.agentId === agentId) {
return state;
}
return {
export const createCreateFlowStore = () =>
create<CreateFlowState>((set, get) => ({
pendingByDraftId: {},
setPending: (pending) =>
set((state) => ({
pendingByDraftId: {
...state.pendingByDraftId,
[draftId]: { ...current, agentId },
[pending.draftId]: {
...pending,
lifecycle: "active",
},
},
};
}),
markLifecycle: ({ draftId, lifecycle, errorMessage }) =>
set((state) => {
const current = state.pendingByDraftId[draftId];
if (!current || current.lifecycle === lifecycle) {
return state;
}
return {
pendingByDraftId: {
...state.pendingByDraftId,
[draftId]: { ...current, lifecycle, errorMessage },
},
};
}),
rekeyDraft: ({ fromDraftId, toDraftId }) =>
set((state) => {
const current = state.pendingByDraftId[fromDraftId];
if (!current) {
return state;
}
if (fromDraftId === toDraftId) {
return state;
}
const { [fromDraftId]: _removed, ...rest } = state.pendingByDraftId;
return {
pendingByDraftId: {
...rest,
[toDraftId]: { ...current, draftId: toDraftId },
},
};
}),
clear: ({ draftId }) =>
set((state) => {
if (!state.pendingByDraftId[draftId]) {
return state;
}
const { [draftId]: _removed, ...rest } = state.pendingByDraftId;
return { pendingByDraftId: rest };
}),
clearByAgent: ({ serverId, agentId }) =>
set((state) => {
const next = Object.fromEntries(
Object.entries(state.pendingByDraftId).filter(
([, pending]) =>
pending.lifecycle !== "sent" ||
pending.serverId !== serverId ||
pending.agentId !== agentId,
),
);
if (Object.keys(next).length === Object.keys(state.pendingByDraftId).length) {
return state;
}
return { pendingByDraftId: next };
}),
clearAll: () => set({ pendingByDraftId: {} }),
}));
})),
trySetPending: (pending) => {
const existing = get().pendingByDraftId[pending.draftId];
if (existing?.serverId === pending.serverId && existing.lifecycle !== "abandoned")
return false;
get().setPending(pending);
return true;
},
updateAgentId: ({ draftId, agentId }) =>
set((state) => {
const current = state.pendingByDraftId[draftId];
if (!current || current.agentId === agentId) {
return state;
}
return {
pendingByDraftId: {
...state.pendingByDraftId,
[draftId]: { ...current, agentId },
},
};
}),
markLifecycle: ({ draftId, lifecycle, errorMessage }) =>
set((state) => {
const current = state.pendingByDraftId[draftId];
if (!current || current.lifecycle === lifecycle) {
return state;
}
return {
pendingByDraftId: {
...state.pendingByDraftId,
[draftId]: { ...current, lifecycle, errorMessage },
},
};
}),
rekeyDraft: ({ fromDraftId, toDraftId }) =>
set((state) => {
const current = state.pendingByDraftId[fromDraftId];
if (!current) {
return state;
}
if (fromDraftId === toDraftId) {
return state;
}
const { [fromDraftId]: _removed, ...rest } = state.pendingByDraftId;
return {
pendingByDraftId: {
...rest,
[toDraftId]: { ...current, draftId: toDraftId },
},
};
}),
clear: ({ draftId }) =>
set((state) => {
if (!state.pendingByDraftId[draftId]) {
return state;
}
const { [draftId]: _removed, ...rest } = state.pendingByDraftId;
return { pendingByDraftId: rest };
}),
clearByAgent: ({ serverId, agentId }) =>
set((state) => {
const next = Object.fromEntries(
Object.entries(state.pendingByDraftId).filter(
([, pending]) =>
pending.lifecycle !== "sent" ||
pending.serverId !== serverId ||
pending.agentId !== agentId,
),
);
if (Object.keys(next).length === Object.keys(state.pendingByDraftId).length) {
return state;
}
return { pendingByDraftId: next };
}),
clearAll: () => set({ pendingByDraftId: {} }),
}));
export type CreateFlowStoreApi = ReturnType<typeof createCreateFlowStore>;
export const defaultCreateFlowStore = createCreateFlowStore();
export const CreateFlowStoreContext = createContext<CreateFlowStoreApi | null>(null);
export function useCreateFlowStoreApi(): CreateFlowStoreApi {
return useContext(CreateFlowStoreContext) ?? defaultCreateFlowStore;
}
export const useCreateFlowStore: CreateFlowStoreApi = Object.assign(function useCreateFlowStore<T>(
selector: (state: CreateFlowState) => T = ((state: CreateFlowState) => state) as (
state: CreateFlowState,
) => T,
): T {
return useStore(useCreateFlowStoreApi(), selector);
}, defaultCreateFlowStore);
File diff suppressed because it is too large Load Diff
+107 -6
View File
@@ -8,7 +8,12 @@ import {
import { afterEach, describe, expect, it } from "vitest";
import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages";
import type { CachedTimeline } from "@/runtime/replica-cache";
import { selectAgentTimelineState, useSessionStore } from "@/stores/session-store";
import {
createSessionStore,
type SessionStoreApi,
selectAgentTimelineState,
useSessionStore,
} from "@/stores/session-store";
import { useCreateFlowStore, type PendingCreateAttempt } from "@/stores/create-flow-store";
import type { StreamItem } from "@/types/stream";
import {
@@ -40,13 +45,18 @@ function cachedTimeline(): CachedTimeline {
};
}
function createOwner(storage: TimelineReplicaStorage): ViewedTimelineOwner {
function createOwner(
storage: TimelineReplicaStorage,
store: SessionStoreApi = useSessionStore,
): ViewedTimelineOwner {
const replica = createTimelineReplica({
store,
serverId: SERVER_ID,
storage,
prepareAgent: async () => undefined,
});
return createViewedTimelineOwner({
store,
serverId: SERVER_ID,
replica,
replaceDemandedAgentIds: () => undefined,
@@ -533,8 +543,8 @@ describe("create handoff lifetime", () => {
afterEach(() => useCreateFlowStore.getState().clearAll());
function sentCreateHandoff(): void {
useCreateFlowStore.getState().setPending({
function sentCreateHandoff(flow = useCreateFlowStore): void {
flow.getState().setPending({
draftId: DRAFT_ID,
serverId: SERVER_ID,
agentId: AGENT_ID,
@@ -542,7 +552,7 @@ describe("create handoff lifetime", () => {
text: "Create this chat.",
timestamp: 0,
});
useCreateFlowStore.getState().markLifecycle({ draftId: DRAFT_ID, lifecycle: "sent" });
flow.getState().markLifecycle({ draftId: DRAFT_ID, lifecycle: "sent" });
}
function createHandoff(): PendingCreateAttempt | undefined {
@@ -575,18 +585,22 @@ describe("create handoff lifetime", () => {
release: () => void;
}
async function ownerWithHeldCatchUp(): Promise<HeldCatchUpOwner> {
async function ownerWithHeldCatchUp(
store: SessionStoreApi = useSessionStore,
): Promise<HeldCatchUpOwner> {
let releaseTailFetch!: () => void;
let tailFetchStarted = false;
const tailFetched = new Promise<void>((resolve) => {
releaseTailFetch = resolve;
});
const replica = createTimelineReplica({
store,
serverId: SERVER_ID,
storage: { readTimeline: async () => undefined, commitTimeline: () => undefined },
prepareAgent: async () => undefined,
});
const owner = createViewedTimelineOwner({
store,
serverId: SERVER_ID,
replica,
replaceDemandedAgentIds: () => undefined,
@@ -611,6 +625,30 @@ describe("create handoff lifetime", () => {
return { owner, release: releaseTailFetch };
}
it("clears only the completed authority's creation handoff for equal destination identities", async () => {
const direct = createSessionStore();
const routed = createSessionStore();
for (const store of [direct, routed]) {
store.getState().initializeSession(SERVER_ID, null);
sentCreateHandoff(store.createFlows);
}
const { owner, release } = await ownerWithHeldCatchUp(direct);
try {
owner.applyTimelineResponse(syncedTailPage());
release();
await expect
.poll(() => direct.createFlows.getState().pendingByDraftId[DRAFT_ID])
.toBeUndefined();
expect(routed.createFlows.getState().pendingByDraftId[DRAFT_ID]).toMatchObject({
agentId: AGENT_ID,
lifecycle: "sent",
});
} finally {
release();
owner.dispose();
}
});
it("keeps the handoff through the first synchronized page and releases it when the chat is current", async () => {
useSessionStore.getState().initializeSession(SERVER_ID, null);
sentCreateHandoff();
@@ -749,3 +787,66 @@ describe("SQLite baseline restoration", () => {
}
});
});
it.each([null, "Direct history failed"])(
"keeps initialization readiness scoped when another authority returns %s for the same address",
async (failureMessage) => {
const direct = createSessionStore();
const routed = createSessionStore();
const key = direct.initializations.getInitKey(SERVER_ID, AGENT_ID);
const directReady = direct.initializations.createInitDeferred(key, "tail").promise;
const routedReady = routed.initializations.createInitDeferred(key, "tail").promise;
void directReady.catch(() => {});
let routedSettled = false;
void routedReady.then(
() => {
routedSettled = true;
return undefined;
},
() => {
routedSettled = true;
return undefined;
},
);
for (const store of [direct, routed]) {
store.getState().initializeSession(SERVER_ID, null);
store.getState().setInitializingAgents(SERVER_ID, new Map([[AGENT_ID, true]]));
}
const storage: TimelineReplicaStorage = {
readTimeline: async () => undefined,
commitTimeline: () => {},
};
const owners = [createOwner(storage, direct), createOwner(storage, routed)];
const response: Parameters<ViewedTimelineOwner["applyTimelineResponse"]>[0] = {
requestId: "direct-tail",
agentId: AGENT_ID,
agent: null,
direction: "tail",
projection: "projected",
reset: false,
epoch: "epoch-1",
window: { minSeq: 1, maxSeq: 0, nextSeq: 1 },
startCursor: null,
endCursor: null,
entries: [],
error: failureMessage,
hasNewer: false,
hasOlder: false,
staleCursor: false,
gap: false,
};
try {
owners[0]!.applyTimelineResponse(response);
const directResult = await Promise.allSettled([directReady]);
expect(directResult[0]?.status).toBe(failureMessage ? "rejected" : "fulfilled");
expect(routedSettled).toBe(false);
expect(routed.initializations.getInitDeferred(key)?.promise).toBe(routedReady);
owners[1]!.applyTimelineResponse({ ...response, requestId: "routed-tail", error: null });
await routedReady;
expect(routedSettled).toBe(true);
} finally {
for (const store of [direct, routed]) store.initializations.resolveInitDeferred(key);
for (const owner of owners) owner.dispose();
}
},
);
@@ -1,6 +1,10 @@
import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types";
import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages";
import { selectAgentTimelineState, useSessionStore } from "@/stores/session-store";
import {
selectAgentTimelineState,
useSessionStore,
type SessionStoreApi,
} from "@/stores/session-store";
import type { AssistantMessageItem, StreamItem, TodoEntry } from "@/types/stream";
import type { TurnLivenessTransition } from "@/timeline/turn-liveness";
import {
@@ -1829,6 +1833,7 @@ export function deriveAgentStreamTurnLiveness(
}
export interface CreateSessionAgentStreamReducerQueueInput {
store?: SessionStoreApi;
serverId: string;
setAgentStreamState: (serverId: string, agentId: string, state: StreamStatePatch) => void;
setAgentTimelineCursor: (
@@ -1895,7 +1900,7 @@ export function createSessionAgentStreamReducerQueue(
return createAgentStreamReducerQueue({
getSnapshot: (agentId) => {
const session = useSessionStore.getState().sessions[serverId];
const session = (input.store ?? useSessionStore).getState().sessions[serverId];
const timeline = selectAgentTimelineState(session, agentId);
return {
currentTail: timeline.status === "cold" ? [] : timeline.items,
@@ -7,18 +7,13 @@ import type { CachedTimeline } from "@/runtime/replica-cache";
import {
selectAgentTimelineState,
useSessionStore,
type SessionStoreApi,
type AgentTimelineCursorState,
type AgentTimelineState,
} from "@/stores/session-store";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
import { getSendingClientMessageIds } from "@/composer/submission/model";
import {
getInitDeferred,
getInitKey,
rejectInitDeferred,
resolveInitDeferred,
} from "@/utils/agent-initialization";
import { getInitKey } from "@/utils/agent-initialization";
import {
createSessionAgentStreamReducerQueue,
type AgentStreamReducerEvent,
@@ -34,12 +29,13 @@ export interface TimelineReplicaStorage {
}
async function prepareCachedTimeline(input: {
store: SessionStoreApi;
serverId: string;
agentId: string;
storage: TimelineReplicaStorage;
prepareAgent: (agentId: string) => Promise<void>;
}): Promise<CachedTimeline | undefined> {
const before = useSessionStore.getState().sessions[input.serverId];
const before = input.store.getState().sessions[input.serverId];
const beforeTimeline = selectAgentTimelineState(before, input.agentId);
if (beforeTimeline.status === "synced") return undefined;
const [stored] = await Promise.all([
@@ -47,7 +43,7 @@ async function prepareCachedTimeline(input: {
input.prepareAgent(input.agentId),
]);
if (!stored) return undefined;
const session = useSessionStore.getState().sessions[input.serverId];
const session = input.store.getState().sessions[input.serverId];
const currentTimeline = selectAgentTimelineState(session, input.agentId);
const currentHead = session?.agentStreamHead.get(input.agentId);
if (currentTimeline.status === "synced") return undefined;
@@ -77,7 +73,7 @@ async function prepareCachedTimeline(input: {
endSeq: null,
},
});
useSessionStore.getState().applyAgentTimelineResponseState(input.serverId, input.agentId, {
input.store.getState().applyAgentTimelineResponseState(input.serverId, input.agentId, {
items: replacement.tail,
head: replacement.head,
range: stored.range,
@@ -104,6 +100,7 @@ class TimelineReplicaOwner implements TimelineReplica {
private readonly serverId: string,
private readonly storage: TimelineReplicaStorage,
private readonly prepareAgent: (agentId: string) => Promise<void>,
private readonly store: SessionStoreApi,
) {}
async prepare(agentId: string): Promise<void> {
@@ -123,6 +120,7 @@ class TimelineReplicaOwner implements TimelineReplica {
serverId: this.serverId,
agentId,
storage: this.storage,
store: this.store,
prepareAgent: this.prepareAgent,
});
if (!stored) return;
@@ -141,7 +139,7 @@ class TimelineReplicaOwner implements TimelineReplica {
}
timelineUpdated(agentId: string): void {
const session = useSessionStore.getState().sessions[this.serverId];
const session = this.store.getState().sessions[this.serverId];
const timeline = selectAgentTimelineState(session, agentId);
if (timeline.status === "cold") return;
if (timeline.status === "synced") this.cachedRanges.delete(agentId);
@@ -155,11 +153,17 @@ class TimelineReplicaOwner implements TimelineReplica {
}
export function createTimelineReplica(input: {
store?: SessionStoreApi;
serverId: string;
storage: TimelineReplicaStorage;
prepareAgent: (agentId: string) => Promise<void>;
}): TimelineReplica {
return new TimelineReplicaOwner(input.serverId, input.storage, input.prepareAgent);
return new TimelineReplicaOwner(
input.serverId,
input.storage,
input.prepareAgent,
input.store ?? useSessionStore,
);
}
export interface TimelinePageResult {
@@ -181,8 +185,12 @@ export function consumeForcedTimelineTailReplacement(
return { ...payload, reset: true };
}
function clearAgentInitializingFlag(serverId: string, agentId: string): void {
useSessionStore.getState().setInitializingAgents(serverId, (previous) => {
function clearAgentInitializingFlag(
store: SessionStoreApi,
serverId: string,
agentId: string,
): void {
store.getState().setInitializingAgents(serverId, (previous) => {
if (previous.get(agentId) !== true) return previous;
const next = new Map(previous);
next.set(agentId, false);
@@ -191,6 +199,7 @@ function clearAgentInitializingFlag(serverId: string, agentId: string): void {
}
function commitProcessedTimeline(input: {
store: SessionStoreApi;
serverId: string;
payload: TimelineResponsePayload;
result: ProcessTimelineResponseOutput;
@@ -200,7 +209,7 @@ function commitProcessedTimeline(input: {
}): void {
const { serverId, payload, result, timeline, currentCursor, synchronized } = input;
const agentId = payload.agentId;
const store = useSessionStore.getState();
const store = input.store.getState();
if (result.commit !== "discard") {
store.applyAgentTimelineResponseState(serverId, agentId, {
items: result.tail,
@@ -232,6 +241,7 @@ function commitProcessedTimeline(input: {
}
function finalizeProcessedTimeline(input: {
store: SessionStoreApi;
serverId: string;
agentId: string;
initKey: string;
@@ -244,17 +254,19 @@ function finalizeProcessedTimeline(input: {
if (effect.type === "catch_up") input.recoverGap(input.agentId, effect.cursor);
}
if (input.result.clearInitializing) {
clearAgentInitializingFlag(input.serverId, input.agentId);
clearAgentInitializingFlag(input.store, input.serverId, input.agentId);
}
if (input.synchronized) {
const session = useSessionStore.getState().sessions[input.serverId];
const session = input.store.getState().sessions[input.serverId];
const agent = session?.agents.get(input.agentId) ?? session?.agentDetails.get(input.agentId);
if (agent && agent.turn.phase === "idle") input.drainQueuedAgentMessage(input.agentId);
}
if (input.result.initResolution === "resolve") resolveInitDeferred(input.initKey);
if (input.result.initResolution === "resolve")
input.store.initializations.resolveInitDeferred(input.initKey);
}
function applyAuthoritativeTimelineResponse(input: {
store: SessionStoreApi;
serverId: string;
payload: TimelineResponsePayload;
cachedCursor?: AgentTimelineCursorState;
@@ -264,9 +276,9 @@ function applyAuthoritativeTimelineResponse(input: {
const { serverId, payload } = input;
const agentId = payload.agentId;
const initKey = getInitKey(serverId, agentId);
const session = useSessionStore.getState().sessions[serverId];
const session = input.store.getState().sessions[serverId];
const timeline = selectAgentTimelineState(session, agentId);
const activeInitDeferred = getInitDeferred(initKey);
const activeInitDeferred = input.store.initializations.getInitDeferred(initKey);
const currentCursor =
timeline.status === "synced" ? (timeline.range ?? undefined) : input.cachedCursor;
const result = processTimelineResponse({
@@ -281,8 +293,9 @@ function applyAuthoritativeTimelineResponse(input: {
});
if (result.error) {
if (result.clearInitializing) clearAgentInitializingFlag(serverId, agentId);
if (result.initResolution === "reject") rejectInitDeferred(initKey, new Error(result.error));
if (result.clearInitializing) clearAgentInitializingFlag(input.store, serverId, agentId);
if (result.initResolution === "reject")
input.store.initializations.rejectInitDeferred(initKey, new Error(result.error));
return false;
}
@@ -291,8 +304,17 @@ function applyAuthoritativeTimelineResponse(input: {
hasNewer: payload.hasNewer,
error: payload.error,
});
commitProcessedTimeline({ serverId, payload, result, timeline, currentCursor, synchronized });
commitProcessedTimeline({
store: input.store,
serverId,
payload,
result,
timeline,
currentCursor,
synchronized,
});
finalizeProcessedTimeline({
store: input.store,
serverId,
agentId,
initKey,
@@ -358,12 +380,14 @@ export interface ViewedTimelineOwner extends ViewedTimelineSync {
}
export function createViewedTimelineOwner(input: {
store?: SessionStoreApi;
serverId: string;
replica: TimelineReplica;
replaceDemandedAgentIds: (agentIds: string[]) => void;
drainQueuedAgentMessage: (agentId: string) => void;
ports: ViewedTimelineOwnerPorts;
}): ViewedTimelineOwner {
const store = input.store ?? useSessionStore;
const forcedTailReplacements = new Set<string>();
const sync = createViewedTimelineSync({
...input.ports,
@@ -379,12 +403,13 @@ export function createViewedTimelineOwner(input: {
readCursor: (agentId) => input.replica.readCursor(agentId) ?? input.ports.readCursor(agentId),
replaceDemandedAgentIds: input.replaceDemandedAgentIds,
onCatchUpEnded: (agentId) =>
useCreateFlowStore.getState().clearByAgent({ serverId: input.serverId, agentId }),
store.createFlows.getState().clearByAgent({ serverId: input.serverId, agentId }),
});
const streamQueue = createSessionAgentStreamReducerQueue({
serverId: input.serverId,
setAgentStreamState: (...args) => useSessionStore.getState().setAgentStreamState(...args),
setAgentTimelineCursor: (...args) => useSessionStore.getState().setAgentTimelineCursor(...args),
store,
setAgentStreamState: (...args) => store.getState().setAgentStreamState(...args),
setAgentTimelineCursor: (...args) => store.getState().setAgentTimelineCursor(...args),
recoverTimelineGap: (agentId, cursor) => sync.recoverGap(agentId, cursor),
onCommitted: (agentId) => input.replica.timelineUpdated(agentId),
});
@@ -393,6 +418,7 @@ export function createViewedTimelineOwner(input: {
applyTimelineResponse(receivedPayload) {
const payload = consumeForcedTimelineTailReplacement(receivedPayload, forcedTailReplacements);
const accepted = applyAuthoritativeTimelineResponse({
store,
serverId: input.serverId,
payload,
cachedCursor: input.replica.readRange(payload.agentId),
+84 -62
View File
@@ -6,74 +6,96 @@ export interface DeferredInit {
requestDirection: "tail" | "after";
}
const initPromises = new Map<string, DeferredInit>();
export const INIT_TIMEOUT_MS = 65_000;
export function getInitKey(serverId: string, agentId: string): string {
return `${serverId}:${agentId}`;
}
export function createAgentInitializations() {
const initPromises = new Map<string, DeferredInit>();
export function getInitDeferred(key: string): DeferredInit | undefined {
return initPromises.get(key);
}
function getInitKey(serverId: string, agentId: string): string {
return `${serverId}:${agentId}`;
}
export function createInitDeferred(key: string, requestDirection: "tail" | "after"): DeferredInit {
let resolve!: () => void;
let reject!: (error: Error) => void;
function getInitDeferred(key: string): DeferredInit | undefined {
return initPromises.get(key);
}
const promise = new Promise<void>((res, rej) => {
resolve = res;
reject = rej;
});
function createInitDeferred(key: string, requestDirection: "tail" | "after"): DeferredInit {
let resolve!: () => void;
let reject!: (error: Error) => void;
const deferred: DeferredInit = {
promise,
resolve,
reject,
timeoutId: null,
requestDirection,
const promise = new Promise<void>((res, rej) => {
resolve = res;
reject = rej;
});
const deferred: DeferredInit = {
promise,
resolve,
reject,
timeoutId: null,
requestDirection,
};
initPromises.set(key, deferred);
return deferred;
}
function refreshInitTimeout(input: {
key: string;
onTimeout: () => void;
timeoutMs?: number;
}): void {
const timeoutId = setTimeout(input.onTimeout, input.timeoutMs ?? INIT_TIMEOUT_MS);
const deferred = initPromises.get(input.key);
if (!deferred) {
clearTimeout(timeoutId);
return;
}
if (deferred.timeoutId) {
clearTimeout(deferred.timeoutId);
}
deferred.timeoutId = timeoutId;
}
function resolveInitDeferred(key: string): void {
const deferred = initPromises.get(key);
if (!deferred) {
return;
}
if (deferred.timeoutId) {
clearTimeout(deferred.timeoutId);
}
initPromises.delete(key);
deferred.resolve();
}
function rejectInitDeferred(key: string, error: Error): void {
const deferred = initPromises.get(key);
if (!deferred) {
return;
}
if (deferred.timeoutId) {
clearTimeout(deferred.timeoutId);
}
initPromises.delete(key);
deferred.reject(error);
}
return {
getInitKey,
getInitDeferred,
createInitDeferred,
refreshInitTimeout,
resolveInitDeferred,
rejectInitDeferred,
};
initPromises.set(key, deferred);
return deferred;
}
export function refreshInitTimeout(input: {
key: string;
onTimeout: () => void;
timeoutMs?: number;
}): void {
const timeoutId = setTimeout(input.onTimeout, input.timeoutMs ?? INIT_TIMEOUT_MS);
const deferred = initPromises.get(input.key);
if (!deferred) {
clearTimeout(timeoutId);
return;
}
if (deferred.timeoutId) {
clearTimeout(deferred.timeoutId);
}
deferred.timeoutId = timeoutId;
}
export function resolveInitDeferred(key: string): void {
const deferred = initPromises.get(key);
if (!deferred) {
return;
}
if (deferred.timeoutId) {
clearTimeout(deferred.timeoutId);
}
initPromises.delete(key);
deferred.resolve();
}
export function rejectInitDeferred(key: string, error: Error): void {
const deferred = initPromises.get(key);
if (!deferred) {
return;
}
if (deferred.timeoutId) {
clearTimeout(deferred.timeoutId);
}
initPromises.delete(key);
deferred.reject(error);
}
export const defaultAgentInitializations = createAgentInitializations();
export const {
getInitKey,
getInitDeferred,
createInitDeferred,
refreshInitTimeout,
resolveInitDeferred,
rejectInitDeferred,
} = defaultAgentInitializations;