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;
+4
View File
@@ -28,6 +28,10 @@
"./node": {
"types": "./dist/node/index.d.ts",
"default": "./dist/node/index.js"
},
"./internal/host-connection": {
"types": "./dist/hosts/connection.d.ts",
"default": "./dist/hosts/connection.js"
}
},
"publishConfig": {
+171
View File
@@ -0,0 +1,171 @@
import { safeRandomId } from "../daemon-client-transport-utils.js";
import { DaemonClient } from "../daemon-client.js";
import type { DaemonTransportFactory } from "../daemon-client-transport-types.js";
export function createHostChannelTransport(
origin: DaemonClient,
serverId: string,
scopeId: string,
initialRouteId: string | null,
link = false,
): DaemonTransportFactory {
let first: string | null = initialRouteId;
return () => {
const opens = new Set<() => void>();
const closes = new Set<(event?: unknown) => void>();
const messages = new Set<(data: unknown, binary: boolean) => void>();
const errors = new Set<(error: unknown) => void>();
let routeId: string | null = null;
let closed = false;
const cleanups: (() => void)[] = [];
const close = (error?: unknown) => {
if (closed) return;
closed = true;
if (error instanceof Error) Object.assign(error, { serverId });
for (const cleanup of cleanups) cleanup();
try {
if (routeId) origin.closeHostRoute(routeId);
else origin.cancelHostRoute(scopeId);
} catch {
/* Transport already closed. */
}
if (error) for (const handler of errors) handler(error);
for (const handler of closes) handler(error);
};
cleanups.push(
origin.subscribeRawMessages((message) => {
if (message.type === "hosts.route.frame" && message.payload.subscriptionId === routeId) {
for (const handler of messages) handler(message.payload.data, false);
}
if (message.type === "hosts.route.closed" && message.payload.subscriptionId === routeId)
close(
Object.assign(new Error(message.payload.message), {
code: message.payload.code,
serverId,
}),
);
}),
);
cleanups.push(
origin.subscribeHostFrames((frame) => {
if (frame.routeId === routeId) for (const handler of messages) handler(frame.payload, true);
}),
);
cleanups.push(
origin.subscribeConnectionStatus((connectionState) => {
if (connectionState.status !== "connected") close();
}),
);
let opening: Promise<string>;
if (first) opening = Promise.resolve(first);
else opening = link ? origin.openHostLink(scopeId) : origin.openHostRoute(serverId, scopeId);
first = null;
void opening
.then((id) => {
if (closed) {
origin.closeHostRoute(id);
return;
}
routeId = id;
for (const handler of opens) handler();
return undefined;
})
.catch(close);
return {
send(data) {
if (closed || !routeId)
throw Object.assign(new Error(`Host ${serverId} is offline`), {
code: "host_offline",
serverId,
});
origin.sendHostFrame(
routeId,
typeof data === "string" || data instanceof Uint8Array ? data : new Uint8Array(data),
);
},
close: () => close(),
onOpen(handler) {
opens.add(handler);
return () => opens.delete(handler);
},
onClose(handler) {
closes.add(handler);
return () => closes.delete(handler);
},
onError(handler) {
errors.add(handler);
return () => errors.delete(handler);
},
onMessage(handler) {
messages.add(handler);
return () => messages.delete(handler);
},
};
};
}
/** Internal adapter for clients that own a low-level replica pipeline. */
export interface HostConnectionLease {
readonly client: DaemonClient;
release(): Promise<void>;
}
/** The origin alone resolves the destination; leases never probe another authority. */
export async function acquireHostConnection(
origin: DaemonClient,
serverId: string,
signal: AbortSignal,
): Promise<HostConnectionLease> {
const assertActive = () => {
if (signal.aborted) throw new Error("Host connection acquisition is disposed");
};
assertActive();
origin.requireConnectedHosts();
if (
origin.getServerId() === serverId &&
origin.getLastServerInfoMessage()?.features?.hostSessionScopes !== true
)
throw Object.assign(new Error("Update the managing daemon to acquire owned session scopes"), {
code: "unsupported_capability",
serverId,
});
const scopeId = safeRandomId();
let client: DaemonClient | null = null;
let release: Promise<void> | null = null;
const cancel = () => {
origin.cancelHostRoute(scopeId);
if (client) void client.close().catch(() => {});
};
signal.addEventListener("abort", cancel, { once: true });
const close = () => {
if (!release) {
signal.removeEventListener("abort", cancel);
release = client ? client.close() : Promise.resolve();
}
return release;
};
try {
const routeId = await origin.openHostRoute(serverId, scopeId);
if (signal.aborted) {
origin.closeHostRoute(routeId);
assertActive();
}
client = new DaemonClient({
url: "ws://routed-host/ws",
clientId: scopeId,
transportFactory: createHostChannelTransport(origin, serverId, scopeId, routeId),
reconnect: { enabled: false },
});
await client.connect();
assertActive();
client.setReconnectEnabled(true);
return { client, release: close };
} catch (error) {
await close().catch(() => {});
throw Object.assign(error instanceof Error ? error : new Error("Host handshake failed"), {
code: error instanceof Error && "code" in error ? error.code : "host_offline",
serverId,
});
}
}
+16 -155
View File
@@ -1,10 +1,10 @@
import { safeRandomId } from "../daemon-client-transport-utils.js";
import { DaemonClient } from "../daemon-client.js";
import type { DaemonTransportFactory } from "../daemon-client-transport-types.js";
import type { OwnedSubscription } from "../connection/index.js";
import type { PaseoApi, createPaseoApi } from "../index.js";
import type { HostAdd, HostUpdate, HostSummary, HostDirectory } from "@getpaseo/protocol/hosts";
import { HostRuntime } from "./runtime.js";
import { acquireHostConnection } from "./connection.js";
export { createHostChannelTransport } from "./connection.js";
export interface PaseoHostActions {
list(): Promise<HostDirectory>;
@@ -16,108 +16,6 @@ export interface PaseoHostActions {
runtime(): HostRuntime;
}
export function createHostChannelTransport(
origin: DaemonClient,
serverId: string,
scopeId: string,
initialRouteId: string | null,
link = false,
): DaemonTransportFactory {
let first: string | null = initialRouteId;
return () => {
const opens = new Set<() => void>();
const closes = new Set<(event?: unknown) => void>();
const messages = new Set<(data: unknown, binary: boolean) => void>();
const errors = new Set<(error: unknown) => void>();
let routeId: string | null = null;
let closed = false;
const cleanups: (() => void)[] = [];
const close = (error?: unknown) => {
if (closed) return;
closed = true;
if (error instanceof Error) Object.assign(error, { serverId });
for (const cleanup of cleanups) cleanup();
try {
if (routeId) origin.closeHostRoute(routeId);
else origin.cancelHostRoute(scopeId);
} catch {
/* Transport already closed. */
}
if (error) for (const handler of errors) handler(error);
for (const handler of closes) handler(error);
};
cleanups.push(
origin.subscribeRawMessages((message) => {
if (message.type === "hosts.route.frame" && message.payload.subscriptionId === routeId) {
for (const handler of messages) handler(message.payload.data, false);
}
if (message.type === "hosts.route.closed" && message.payload.subscriptionId === routeId)
close(
Object.assign(new Error(message.payload.message), {
code: message.payload.code,
serverId,
}),
);
}),
);
cleanups.push(
origin.subscribeHostFrames((frame) => {
if (frame.routeId === routeId) for (const handler of messages) handler(frame.payload, true);
}),
);
cleanups.push(
origin.subscribeConnectionStatus((connectionState) => {
if (connectionState.status !== "connected") close();
}),
);
let opening: Promise<string>;
if (first) opening = Promise.resolve(first);
else opening = link ? origin.openHostLink(scopeId) : origin.openHostRoute(serverId, scopeId);
first = null;
void opening
.then((id) => {
if (closed) {
origin.closeHostRoute(id);
return;
}
routeId = id;
for (const handler of opens) handler();
return undefined;
})
.catch(close);
return {
send(data) {
if (closed || !routeId)
throw Object.assign(new Error(`Host ${serverId} is offline`), {
code: "host_offline",
serverId,
});
origin.sendHostFrame(
routeId,
typeof data === "string" || data instanceof Uint8Array ? data : new Uint8Array(data),
);
},
close: () => close(),
onOpen(handler) {
opens.add(handler);
return () => opens.delete(handler);
},
onClose(handler) {
closes.add(handler);
return () => closes.delete(handler);
},
onError(handler) {
errors.add(handler);
return () => errors.delete(handler);
},
onMessage(handler) {
messages.add(handler);
return () => messages.delete(handler);
},
};
};
}
export function createHostActions(
origin: DaemonClient,
owner: {
@@ -150,60 +48,23 @@ export function createHostActions(
async client(serverId) {
assertActive();
origin.requireConnectedHosts();
if (origin.getLastServerInfoMessage()?.serverId === serverId) {
const api = owner.api(origin, { signal: owner.signal });
const scope = owner.own(() => ({ release: () => api.dispose() }));
return { ...api, dispose: () => scope.release() };
}
const scopeId = safeRandomId();
const cancel = () => origin.cancelHostRoute(scopeId);
owner.signal.addEventListener("abort", cancel, { once: true });
let routeId: string;
const lease = await acquireHostConnection(origin, serverId, owner.signal);
try {
routeId = await origin.openHostRoute(serverId, scopeId);
} catch (error) {
throw Object.assign(error instanceof Error ? error : new Error(String(error)), {
serverId,
});
} finally {
owner.signal.removeEventListener("abort", cancel);
}
if (owner.signal.aborted) {
origin.closeHostRoute(routeId);
assertActive();
}
const client = new DaemonClient({
url: "ws://routed-host/ws",
clientId: scopeId,
transportFactory: createHostChannelTransport(origin, serverId, scopeId, routeId),
// Initial acquisition has a deadline; established scopes restore demand on loss.
reconnect: { enabled: false },
});
const api = owner.api(client, { signal: owner.signal });
const scope = owner.own(() => ({
release: async () => {
try {
await api.dispose();
} finally {
await client.close();
}
},
}));
try {
await client.connect();
client.setReconnectEnabled(true);
assertActive();
return { ...api, dispose: () => scope.release() };
} catch (error) {
const failure = Object.assign(
error instanceof Error ? error : new Error("Host handshake failed"),
{
code: error instanceof Error && "code" in error ? error.code : "host_offline",
serverId,
const api = owner.api(lease.client, { signal: owner.signal });
const scope = owner.own(() => ({
release: async () => {
try {
await api.dispose();
} finally {
await lease.release();
}
},
);
await scope.release();
throw failure;
}));
return { ...api, dispose: () => scope.release() };
} catch (error) {
await lease.release().catch(() => {});
throw error;
}
},
runtime() {
+9 -9
View File
@@ -23,14 +23,14 @@ export function createScheduleActions(
return method(...args);
};
return {
create: action(client.scheduleCreate.bind(client)),
list: action(client.scheduleList.bind(client)),
inspect: action(client.scheduleInspect.bind(client)),
update: action(client.scheduleUpdate.bind(client)),
pause: action(client.schedulePause.bind(client)),
resume: action(client.scheduleResume.bind(client)),
delete: action(client.scheduleDelete.bind(client)),
logs: action(client.scheduleLogs.bind(client)),
runOnce: action(client.scheduleRunOnce.bind(client)),
create: action((...args) => client.scheduleCreate(...args)),
list: action((...args) => client.scheduleList(...args)),
inspect: action((...args) => client.scheduleInspect(...args)),
update: action((...args) => client.scheduleUpdate(...args)),
pause: action((...args) => client.schedulePause(...args)),
resume: action((...args) => client.scheduleResume(...args)),
delete: action((...args) => client.scheduleDelete(...args)),
logs: action((...args) => client.scheduleLogs(...args)),
runOnce: action((...args) => client.scheduleRunOnce(...args)),
};
}
+1
View File
@@ -3767,6 +3767,7 @@ export const ServerInfoStatusPayloadSchema = z
connectedHosts: z.boolean().optional(),
fleetSubagents: z.boolean().optional(),
hostMultiplexing: z.boolean().optional(),
hostSessionScopes: z.boolean().optional(),
usageSources: z.boolean().optional(),
// COMPAT(providersSnapshotCwd): added in v0.3.2, remove gate after 2027-02-10.
providersSnapshotCwd: z.boolean().optional(),
@@ -768,6 +768,7 @@ export class AgentManager {
private appendSystemPrompt: string;
private onAgentAttention?: AgentAttentionCallback;
private readonly agentArchivedListeners = new Set<AgentArchivedCallback>();
private managedChildLifecycleOwner: AgentArchivedCallback | null = null;
private onWorkspaceStateMayHaveChanged?: (params: { cwd: string }) => void;
private logger: Logger;
private readonly rescueTimeouts: Required<AgentManagerRescueTimeouts>;
@@ -874,10 +875,19 @@ export class AgentManager {
return this.runJournal.get(agentId, submissionId);
}
onAgentArchived(callback: AgentArchivedCallback): () => void {
onAgentArchived(
callback: AgentArchivedCallback,
options?: { ownsManagedChildren: boolean },
): () => void {
if (options?.ownsManagedChildren) {
if (this.managedChildLifecycleOwner)
throw new Error("Managed child lifecycle already has an owner");
this.managedChildLifecycleOwner = callback;
}
this.agentArchivedListeners.add(callback);
return () => {
this.agentArchivedListeners.delete(callback);
if (this.managedChildLifecycleOwner === callback) this.managedChildLifecycleOwner = null;
};
}
@@ -1904,6 +1914,7 @@ export class AgentManager {
const parentAgentId = parent.id;
const records = await registry.list();
for (const record of records) {
if (this.managedChildLifecycleOwner && !record.internal && !record.owner) continue;
if (record.archivedAt) {
continue;
}
@@ -3683,6 +3694,7 @@ export class AgentManager {
}
}
await this.runJournal.restore(resolvedAgentId);
this.assertAcceptingAgentRegistrations();
this.agents.set(resolvedAgentId, managed);
registered = true;
@@ -4395,22 +4407,14 @@ export class AgentManager {
const flags: StreamEventFlags = { shouldDispatchEvent: true, shouldNotifyWaiters: true };
const runResult = this.getRunResult(
agent.id,
eventTurnId,
const checkpoint = this.captureLiveRunCheckpoint(
agent,
event,
options?.fromHistory === true,
eventTurnId,
options,
terminalDisposition,
);
if (!options?.fromHistory) {
if (terminalDisposition !== "stale")
await this.runJournal.capture(agent.id, eventTurnId, event, runResult).catch((error) => {
this.logger.error(
{ err: error, agentId: agent.id, turnId: eventTurnId },
"Failed to persist provider checkpoint",
);
});
}
if (checkpoint) await checkpoint;
const dispatchPromise = this.dispatchStreamEventByType({
agent,
event,
@@ -4442,6 +4446,23 @@ export class AgentManager {
return flags.shouldNotifyWaiters;
}
private captureLiveRunCheckpoint(
agent: ActiveManagedAgent,
event: AgentStreamEvent,
turnId: string | undefined,
options: HandleStreamEventOptions | undefined,
disposition: ActiveTurnTerminalDisposition,
): Promise<void> | undefined {
if (options?.fromHistory || disposition === "stale") return undefined;
const result = this.getRunResult(agent.id, turnId, event, false, disposition);
return this.runJournal.capture(agent.id, turnId, event, result)?.catch((error) => {
this.logger.error(
{ err: error, agentId: agent.id, turnId },
"Failed to persist provider checkpoint",
);
});
}
private getRunResult(
agentId: string,
turnId: string | undefined,
@@ -289,6 +289,7 @@ test("finish notifications tell the parent the child's last assistant message",
source: {
kind: "agent-notification",
agentId: "child-agent",
serverId: "srv_test",
title: "Child Agent",
event: "finished",
},
@@ -325,6 +326,7 @@ test("closing a watched child notifies the caller", async () => {
source: {
kind: "agent-notification",
agentId: "child-agent",
serverId: "srv_test",
title: "Child Agent",
event: "closed",
},
@@ -1386,3 +1386,129 @@ test("MCP provider discovery uses the selected host profiles and models", async
await b.close();
}
});
test("real MCP discovery preserves custom catalog modes, disabled-provider isolation, and draft settings", async () => {
const clients = createTestAgentClients();
const base = clients.codex!;
let disabledAvailabilityChecks = 0;
let disabledCatalogFetches = 0;
const drafts: AgentSessionConfig[] = [];
const dynamicModes = [{ id: "dynamic", label: "Dynamic", description: "Runtime mode" }];
const custom: AgentClient = {
...base,
provider: "zai",
capabilities: base.capabilities,
createSession: (...args) => base.createSession(...args),
resumeSession: (...args) => base.resumeSession(...args),
isAvailable: async () => true,
fetchCatalog: async () => ({
models: [{ provider: "zai", id: "custom-model", label: "Custom model", isDefault: true }],
modes: dynamicModes,
}),
listFeatures: async (config) => {
drafts.push(config);
return [
{
type: "toggle",
id: "fast_mode",
label: "Fast mode",
value: config.featureValues?.fast_mode === true,
},
];
},
};
const disabled: AgentClient = {
...base,
provider: "codex",
capabilities: base.capabilities,
createSession: (...args) => base.createSession(...args),
resumeSession: (...args) => base.resumeSession(...args),
isAvailable: async () => {
disabledAvailabilityChecks++;
return true;
},
fetchCatalog: async (options) => {
disabledCatalogFetches++;
return base.fetchCatalog(options);
},
};
const destination = await createTestPaseoDaemon({
agentClients: { ...clients, codex: disabled, zai: custom },
providerOverrides: {
codex: { enabled: false },
zai: { extends: "codex", label: "ZAI", enabled: true },
},
agentProfiles: [],
});
const mcp = await createMcpClient(`http://127.0.0.1:${destination.port}/mcp/agents`);
try {
const response = await mcp.callTool({ name: "list_providers", args: {} });
expect(response.isError, JSON.stringify(response)).not.toBe(true);
const providers = recordArr(getStructuredContent(response)?.providers);
expect(providers).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "zai",
label: "ZAI",
enabled: true,
status: "available",
modes: dynamicModes,
}),
expect.objectContaining({ id: "codex", enabled: false, status: "unavailable", modes: [] }),
]),
);
const modelText =
response.content?.map((item) => Reflect.get(item, "text") ?? "").join("\n") ?? "";
expect(modelText).toContain(`providers_count=${providers.length}`);
expect(modelText).toContain(
`providers_ids=${providers.map((provider) => provider.id).join(",")}`,
);
expect(modelText).toContain('"providers"');
expect(await callToolStructured(mcp, "list_profiles")).toMatchObject({ profiles: [] });
await expectToolError(mcp, "list_models", { provider: "codex" }, /disabled/i);
await expectToolError(
mcp,
"inspect_provider",
{ provider: "codex", cwd: destination.staticDir },
/disabled/i,
);
expect(disabledAvailabilityChecks).toBe(0);
expect(disabledCatalogFetches).toBe(0);
const inspected = await callToolStructured(mcp, "inspect_provider", {
provider: "zai/custom-model",
cwd: destination.staticDir,
settings: { modeId: "dynamic", thinkingOptionId: "high", features: { fast_mode: true } },
});
expect(inspected).toMatchObject({
provider: "zai",
label: "ZAI",
selectedModel: "custom-model",
modes: dynamicModes,
features: [{ type: "toggle", id: "fast_mode", label: "Fast mode", value: true }],
});
expect(drafts).toContainEqual(
expect.objectContaining({
provider: "zai",
cwd: destination.staticDir,
model: "custom-model",
modeId: "dynamic",
thinkingOptionId: "high",
featureValues: { fast_mode: true },
}),
);
} finally {
await mcp.close();
await destination.close();
}
});
test("real MCP without configured profiles returns an empty list", async () => {
const destination = await createTestPaseoDaemon();
const mcp = await createMcpClient(`http://127.0.0.1:${destination.port}/mcp/agents`);
try {
expect(await callToolStructured(mcp, "list_profiles")).toMatchObject({ profiles: [] });
} finally {
await mcp.close();
await destination.close();
}
});
@@ -67,8 +67,6 @@ import { createTerminalManager, type TerminalManager } from "../../terminal/term
import { createWorkerTerminalManager } from "../../terminal/worker-terminal-manager.js";
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import { PASEO_TOOL_NAMES } from "@getpaseo/protocol/paseo-tool-presentation/index";
import { MutableDaemonConfigSchema, type AgentProfile } from "@getpaseo/protocol/messages";
import type { DaemonConfigStore } from "../daemon-config-store.js";
import type { BrowserToolsBroker, BrowserToolsExecuteInput } from "../browser-tools/broker.js";
import type { BrowserToolsResponsePayload } from "../browser-tools/errors.js";
import { readPaseoWorktreeMetadata } from "../../utils/worktree-metadata.js";
@@ -4844,7 +4842,7 @@ describe("send_agent_prompt MCP tool", () => {
});
expect(childClient.sessions[0]!.prompts).toHaveLength(1);
expect(parseAgentMessage(childClient.sessions[0]!.prompts[0])).toMatchObject({
source: { kind: "agent-message", agentId: parent.id },
source: { kind: "agent-message", serverId: "srv_test", agentId: parent.id },
text: "Ongoing task",
});
expect(parentClient.sessions[0]!.prompts).toHaveLength(0);
@@ -5039,7 +5037,7 @@ describe("send_agent_prompt MCP tool", () => {
await vi.waitFor(() => expect(childSession.prompts).toHaveLength(1));
expect(parseAgentMessage(childSession.prompts[0])).toEqual({
id: expect.any(String),
source: { kind: "agent-message", agentId: parent.id },
source: { kind: "agent-message", serverId: "srv_test", agentId: parent.id },
text: "Follow up",
});
acknowledgeTurnStart();
@@ -6273,202 +6271,6 @@ describe("schedule_logs MCP tool", () => {
});
});
describe("provider listing MCP tool", () => {
const logger = createTestLogger();
it("returns providers from the registry, including custom providers", async () => {
const { agentManager, agentStorage } = createTestDeps();
const provStub = createProviderSnapshotManagerStub();
provStub.listRegisteredProviderIds.mockReturnValue(["claude", "zai"]);
provStub.listProviders.mockResolvedValue([
buildSnapshotEntry({
provider: "claude",
label: "Claude",
description: "Test provider",
modes: [{ id: "default", label: "Default", description: "Built-in mode" }],
}),
buildSnapshotEntry({
provider: "zai" as AgentProvider,
label: "ZAI",
description: "Custom Claude profile",
defaultModeId: "default",
modes: [{ id: "default", label: "Default", description: "Custom mode" }],
}),
]);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: provStub.manager,
logger,
});
const tool = registeredTool(server, "list_providers");
const response = await tool.handler({});
const modelVisibleText = String(response.content[0]?.text);
expect(response.structuredContent).toEqual({
providers: [
{
id: "claude",
label: "Claude",
description: "Test provider",
enabled: true,
status: "available",
modes: [{ id: "default", label: "Default", description: "Built-in mode" }],
},
{
id: "zai",
label: "ZAI",
status: "available",
description: "Custom Claude profile",
enabled: true,
modes: [{ id: "default", label: "Default", description: "Custom mode" }],
},
],
});
expect(modelVisibleText).toContain("providers_count=2");
expect(modelVisibleText).toContain("providers_ids=claude,zai");
expect(modelVisibleText).toContain('"providers"');
});
it("returns provider modes from the shared snapshot catalog", async () => {
const { agentManager, agentStorage } = createTestDeps();
const provStub = createProviderSnapshotManagerStub();
provStub.listRegisteredProviderIds.mockReturnValue(["codex"]);
provStub.listProviders.mockResolvedValue([
buildSnapshotEntry({
provider: "codex",
label: "Codex",
modes: [{ id: "dynamic", label: "Dynamic", description: "Runtime mode" }],
}),
]);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: provStub.manager,
logger,
});
const tool = registeredTool(server, "list_providers");
const response = await tool.handler({});
expect(response.structuredContent.providers).toEqual([
expect.objectContaining({
id: "codex",
modes: [{ id: "dynamic", label: "Dynamic", description: "Runtime mode" }],
}),
]);
});
it("returns disabled providers with metadata without checking availability", async () => {
const { agentManager, agentStorage } = createTestDeps();
const provStub = createProviderSnapshotManagerStub();
provStub.listRegisteredProviderIds.mockReturnValue(["codex"]);
provStub.listProviders.mockResolvedValue([
buildSnapshotEntry({
provider: "codex",
label: "Codex",
description: "OpenAI coding agent",
enabled: false,
modes: [{ id: "read-only", label: "Read Only", description: "No edits" }],
}),
]);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: provStub.manager,
logger,
});
const tool = registeredTool(server, "list_providers");
const response = await tool.handler({});
expect(response.structuredContent).toEqual({
providers: [
{
id: "codex",
label: "Codex",
description: "OpenAI coding agent",
enabled: false,
status: "unavailable",
modes: [],
},
],
});
});
});
function daemonConfigStoreStub(agentProfiles?: AgentProfile[]): Pick<DaemonConfigStore, "get"> {
const config = MutableDaemonConfigSchema.parse({
relay: { enabled: true },
mcp: { injectIntoAgents: true },
...(agentProfiles !== undefined ? { agentProfiles } : {}),
});
return { get: () => config };
}
describe("agent profile listing MCP tool", () => {
const logger = createTestLogger();
it("returns configured profiles, including notes", async () => {
const { agentManager, agentStorage } = createTestDeps();
const profiles: AgentProfile[] = [
{
id: "ui-profile",
name: "UI work",
provider: "claude",
model: "claude-test-model",
modeId: "bypassPermissions",
thinkingOptionId: "high",
featureValues: { fast_mode: true },
notes: "Use for UI work: components, layout, design tokens. Not for backend.",
},
];
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
daemonConfigStore: daemonConfigStoreStub(profiles),
logger,
});
const tool = registeredTool(server, "list_profiles");
const response = await tool.handler({});
expect(response.structuredContent).toEqual({ profiles });
});
it("returns an empty array when no profiles are configured", async () => {
const { agentManager, agentStorage } = createTestDeps();
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
daemonConfigStore: daemonConfigStoreStub(),
logger,
});
const tool = registeredTool(server, "list_profiles");
const response = await tool.handler({});
expect(response.structuredContent).toEqual({ profiles: [] });
});
it("returns an empty array when no daemon config store is provided", async () => {
const { agentManager, agentStorage } = createTestDeps();
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
logger,
});
const tool = registeredTool(server, "list_profiles");
const response = await tool.handler({});
expect(response.structuredContent).toEqual({ profiles: [] });
});
});
describe("provider MCP tools", () => {
const logger = createTestLogger();
@@ -6483,113 +6285,6 @@ describe("provider MCP tools", () => {
expect(lookupTool(server, "list_provider_features")).toBeUndefined();
});
it("inspects provider features for a draft agent configuration", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.listDraftFeatures.mockResolvedValue([
{
type: "toggle",
id: "fast_mode",
label: "Fast mode",
value: false,
},
]);
const provStub = createProviderSnapshotManagerStub();
provStub.listRegisteredProviderIds.mockReturnValue(["codex"]);
const codexEntry = buildSnapshotEntry({
provider: "codex",
label: "Codex",
description: "OpenAI coding agent",
modes: [{ id: "full-access", label: "Full Access", description: "Can edit files" }],
});
provStub.listProviders.mockResolvedValue([codexEntry]);
provStub.getProvider.mockResolvedValue(codexEntry);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: provStub.manager,
logger,
});
const tool = registeredTool(server, "inspect_provider");
const input = {
provider: "codex/gpt-5.4",
cwd: "~/repo",
settings: {
modeId: "full-access",
thinkingOptionId: "high",
features: { fast_mode: true },
},
};
const parsed = await tool.inputSchema.safeParseAsync(input);
expect(parsed.success).toBe(true);
const response = await tool.handler(input);
expect(spies.agentManager.listDraftFeatures).toHaveBeenCalledWith({
provider: "codex",
cwd: expect.stringContaining("repo"),
modeId: "full-access",
model: "gpt-5.4",
thinkingOptionId: "high",
featureValues: { fast_mode: true },
});
expect(response.structuredContent).toEqual({
provider: "codex",
label: "Codex",
description: "OpenAI coding agent",
enabled: true,
status: "available",
modes: [{ id: "full-access", label: "Full Access", description: "Can edit files" }],
selectedModel: "gpt-5.4",
features: [
{
type: "toggle",
id: "fast_mode",
label: "Fast mode",
value: false,
},
],
});
});
it("rejects disabled providers without fetching models", async () => {
const { agentManager, agentStorage } = createTestDeps();
const provStub = createProviderSnapshotManagerStub();
provStub.listRegisteredProviderIds.mockReturnValue(["codex"]);
provStub.listModels.mockRejectedValue(new Error("Provider 'codex' is disabled"));
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: provStub.manager,
logger,
});
const tool = registeredTool(server, "list_models");
await expect(tool.handler({ provider: "codex" })).rejects.toThrow(
"Provider 'codex' is disabled",
);
});
it("inspect_provider rejects disabled providers without fetching models", async () => {
const { agentManager, agentStorage } = createTestDeps();
const provStub = createProviderSnapshotManagerStub();
provStub.listRegisteredProviderIds.mockReturnValue(["codex"]);
provStub.getProvider.mockResolvedValue(
buildSnapshotEntry({ provider: "codex", label: "Codex", enabled: false }),
);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: provStub.manager,
logger,
});
const tool = registeredTool(server, "inspect_provider");
await expect(tool.handler({ provider: "codex", cwd: "~/repo" })).rejects.toThrow(
"Provider 'codex' is disabled",
);
});
});
describe("speak MCP tool", () => {
+12 -3
View File
@@ -60,6 +60,10 @@ export class AgentRuns {
private identity(agentId: string, submissionId: string): string {
return JSON.stringify([agentId, submissionId]);
}
/** Restore turn ownership before subscribing to live provider evidence. */
restore(agentId: string): Promise<void> {
return this.mutate(() => this.load(agentId));
}
private async load(agentId: string): Promise<void> {
if (this.loadedAgents.has(agentId)) return;
let records: StoredRun[] = [];
@@ -240,10 +244,15 @@ export class AgentRuns {
turnId: string | undefined,
event: AgentStreamEvent,
result: string,
): Promise<void> {
if (!turnId) return Promise.resolve();
): Promise<void> | undefined {
if (!turnId) return undefined;
if (
this.loadedAgents.has(agentId) &&
!this.submissionsByTurn.has(this.identity(agentId, turnId))
)
return undefined;
const checkpoint = checkpointFor(event, result);
if (!checkpoint) return Promise.resolve();
if (!checkpoint) return undefined;
return this.mutate(async () => {
await this.load(agentId);
const submissions = this.submissionsByTurn.get(this.identity(agentId, turnId));
+1
View File
@@ -1779,6 +1779,7 @@ export async function createPaseoDaemon(
orchestrationSkills,
workspaceLabelService,
);
await wsServer.initializeRelationships();
pluginRuntime.bindPaseoSessionHost(wsServer);
await pluginRuntime.start();
providerSnapshotManager.settlePluginProviders();
@@ -1708,6 +1708,180 @@ test("parent capability owns local and fleet membership, idempotent creation and
expect(recovered.address).toEqual(first.address);
});
test("startup upgrade adopts existing ordinary local children once without treating later labels as membership", async () => {
const { AgentStorage } = await import("../agent/agent-storage.js");
const { default: pino } = await import("pino");
const root = await mkdtemp(join(tmpdir(), "paseo-legacy-membership-"));
cleanup.push(() => rm(root, { recursive: true, force: true }));
const storage = new AgentStorage(join(root, ".paseo", "agents"), pino({ level: "silent" }));
const parentId = crypto.randomUUID();
const childId = crypto.randomUUID();
const sameWorkspaceId = crypto.randomUUID();
const otherWorkspaceId = crypto.randomUUID();
const openTabId = crypto.randomUUID();
const foreignId = crypto.randomUUID();
const nullParentId = crypto.randomUUID();
const executedId = crypto.randomUUID();
const internalParentId = crypto.randomUUID();
const internalChildId = crypto.randomUUID();
const seed = async (
id: string,
overrides: Partial<import("../agent/agent-storage.js").StoredAgentRecord> = {},
) => {
await storage.upsert({
id,
provider: "codex",
cwd: root,
workspaceId: "legacy-workspace",
config: { model: "test" },
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
lastStatus: "closed",
labels: {},
...overrides,
});
};
const labels = { "paseo.parent-agent-id": parentId };
await seed(parentId);
await seed(childId, { labels });
await seed(sameWorkspaceId, { labels });
await seed(otherWorkspaceId, { labels, workspaceId: "other-workspace" });
await seed(openTabId, { labels: { ...labels, "paseo.open-agent-tab.retained": "true" } });
await seed(foreignId, { labels, parent: { serverId: "another-server", agentId: parentId } });
await seed(nullParentId, { labels, parent: null });
await seed(executedId, {
labels,
owner: { kind: "daemon", daemonId: "hub", executionId: "execution" },
});
await seed(internalParentId, { internal: true });
await seed(internalChildId, { labels: { "paseo.parent-agent-id": internalParentId } });
const first = await createTestPaseoDaemon({ paseoHomeRoot: root, cleanup: false });
cleanup.push(() => first.close());
const origin = await api(first);
const parent = origin.paseo.agents.ref(parentId);
const rows = (await parent.subagents.list()).rows;
expect(rows.map((row) => row.child.agentId).sort()).toEqual(
[childId, sameWorkspaceId, otherWorkspaceId, openTabId].sort(),
);
expect(
rows.every((row) => row.child.serverId === parent.serverId && row.phase === "attached"),
).toBe(true);
expect((await first.daemon.agentStorage.get(childId))?.parent).toEqual(parent.address);
await parent.subagents.forget({ serverId: parent.serverId, agentId: childId });
await parent.archive();
await expect
.poll(async () => (await first.daemon.agentStorage.get(sameWorkspaceId))?.archivedAt)
.toEqual(expect.any(String));
await expect
.poll(async () => (await first.daemon.agentStorage.get(otherWorkspaceId))?.parent)
.toBeNull();
await expect
.poll(async () => (await first.daemon.agentStorage.get(openTabId))?.parent)
.toBeNull();
// Forget leaves destination lifecycle alone; the old label cascade must not run too.
expect((await first.daemon.agentStorage.get(childId))?.archivedAt).toBeUndefined();
expect((await first.daemon.agentStorage.get(otherWorkspaceId))?.archivedAt).toBeUndefined();
expect((await first.daemon.agentStorage.get(openTabId))?.archivedAt).toBeUndefined();
const injectedId = crypto.randomUUID();
await first.daemon.agentStorage.upsert({
...(await first.daemon.agentStorage.get(childId))!,
id: injectedId,
parent: undefined,
});
await first.close();
const restarted = await createTestPaseoDaemon({ paseoHomeRoot: root, cleanup: false });
cleanup.push(() => restarted.close());
const restored = await api(restarted);
const restoredRows = (await restored.paseo.agents.ref(parentId).subagents.list()).rows;
expect(restoredRows.map((row) => row.child.agentId).sort()).toEqual(
[sameWorkspaceId, otherWorkspaceId, openTabId].sort(),
);
expect(restoredRows.every((row) => ["archived", "detached"].includes(row.phase))).toBe(true);
expect((await restarted.daemon.agentStorage.get(injectedId))?.parent).toBeUndefined();
});
test("an interrupted legacy upgrade resumes its bounded candidates before admitting new writes", async () => {
const { AgentStorage } = await import("../agent/agent-storage.js");
const { Subagents } = await import("../subagents/index.js");
const { getOrCreateServerId } = await import("../server-id.js");
const { default: pino } = await import("pino");
const root = await mkdtemp(join(tmpdir(), "paseo-interrupted-legacy-"));
cleanup.push(() => rm(root, { recursive: true, force: true }));
const home = join(root, ".paseo");
await mkdir(home);
const serverId = getOrCreateServerId(home);
const storage = new AgentStorage(join(home, "agents"), pino({ level: "silent" }));
const parentId = crypto.randomUUID();
const childIds = [crypto.randomUUID(), crypto.randomUUID()];
for (const id of [parentId, ...childIds])
await storage.upsert({
id,
provider: "codex",
cwd: root,
workspaceId: "legacy-workspace",
config: { model: "test" },
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
lastStatus: "closed",
labels: id === parentId ? {} : { "paseo.parent-agent-id": parentId },
});
let qualifications = 0;
const interrupted = new Subagents({
home,
serverId,
agents: {
list: () => storage.list(),
get: (id) => storage.get(id),
upsert: async (record) => {
if (++qualifications === 2) throw new Error("Injected qualification write failure");
await storage.upsert(record);
},
},
resolveAuthority: async () => ({
principalId: "owner",
permissions: ["daemon.read", "workspace.read", "workspace.write"],
}),
runtime: async () => {
throw new Error("Upgrade must never acquire a client or submit creation");
},
});
await expect(interrupted.initialize()).rejects.toThrow("Injected qualification write failure");
const pending = JSON.parse(await readFile(join(home, "subagents.json"), "utf8"));
expect(pending.legacyUpgrade).toMatchObject({ complete: false, candidates: expect.any(Array) });
expect(pending.legacyUpgrade.candidates).toHaveLength(2);
expect((await storage.list()).filter((record) => record.parent)).toHaveLength(1);
const injectedId = crypto.randomUUID();
await storage.upsert({
...(await storage.get(childIds[0]!))!,
id: injectedId,
parent: undefined,
});
const restarted = await createTestPaseoDaemon({ paseoHomeRoot: root, cleanup: false });
cleanup.push(() => restarted.close());
const origin = await api(restarted);
const parent = origin.paseo.agents.ref(parentId);
expect((await parent.subagents.list()).rows.map((row) => row.child.agentId).sort()).toEqual(
childIds.toSorted(),
);
for (const id of childIds)
expect((await restarted.daemon.agentStorage.get(id))?.parent).toEqual(parent.address);
expect((await restarted.daemon.agentStorage.get(injectedId))?.parent).toBeUndefined();
const complete = JSON.parse(await readFile(join(home, "subagents.json"), "utf8"));
expect(complete.legacyUpgrade.complete).toBe(true);
expect(complete.legacyUpgrade.candidates).toEqual(pending.legacyUpgrade.candidates);
const archived: string[] = [];
const release = restarted.daemon.agentManager.onAgentArchived((id) => {
archived.push(id);
});
try {
await parent.archive();
await expect.poll(() => archived.toSorted()).toEqual([parentId, ...childIds].toSorted());
expect((await restarted.daemon.agentStorage.get(injectedId))?.archivedAt).toBeUndefined();
} finally {
release();
}
});
test("retained derived sessions cannot supply durable principal authority", async () => {
const a = await daemon();
const owner = await api(a);
@@ -3188,15 +3362,30 @@ test.each([false, true])(
};
for (const attempt of [1, 2]) {
const requestId = "legacy-original";
socket.send(JSON.stringify({ type: "session", message: { ...request, requestId } }));
await expect
.poll(
() =>
const receipt = new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
socket.off("message", received);
reject(
new Error(
`Timed out waiting for legacy creation receipt ${attempt}: ${JSON.stringify(messages)}`,
),
);
}, 20_000);
function received() {
if (
messages.filter(
(message) => message.type === "status" && message.payload.requestId === requestId,
).length,
)
.toBe(attempt);
).length !== attempt
)
return;
clearTimeout(timeout);
socket.off("message", received);
resolve();
}
socket.on("message", received);
});
socket.send(JSON.stringify({ type: "session", message: { ...request, requestId } }));
await receipt;
const response = messages.find(
(message) => message.type === "status" && message.payload.requestId === requestId,
);
@@ -3225,3 +3414,158 @@ test.each([false, true])(
expect(prompts).toBe(1);
},
);
test("internal connection leases share ordinary host resolution and preserve borrowed origin and sibling lifetime", async () => {
const { acquireHostConnection } = await import("@getpaseo/client/internal/host-connection");
const a = await daemon();
const b = await daemon();
const origin = await api(a);
const direct = await api(b);
await origin.paseo.hosts.add({ connection: `127.0.0.1:${b.port}` });
const directory = await online(origin.paseo);
const controller = new AbortController();
const [self, first, sibling] = await Promise.all([
acquireHostConnection(origin.client, directory.self.serverId, controller.signal),
acquireHostConnection(origin.client, b.daemon.getServerId(), controller.signal),
acquireHostConnection(origin.client, b.daemon.getServerId(), controller.signal),
]);
cleanup.push(() => sibling.release());
cleanup.push(() => first.release());
expect(self.client.getServerId()).toBe(origin.client.getServerId());
await self.release();
expect(await origin.paseo.hosts.list()).toMatchObject({
self: { serverId: directory.self.serverId },
});
const firstApi = createPaseoApi(first.client);
const siblingApi = createPaseoApi(sibling.client);
cleanup.push(() => siblingApi.dispose());
cleanup.push(() => firstApi.dispose());
await first.release();
await expect(firstApi.workspaces.list()).rejects.toThrow();
expect((await siblingApi.workspaces.list()).entries).toEqual([]);
await origin.paseo.hosts.update(directory.hosts[0]!.registrationId, { enabled: false });
await expect(siblingApi.workspaces.list()).rejects.toThrow();
expect((await direct.paseo.workspaces.list()).entries).toEqual([]);
controller.abort();
await sibling.release();
const aborted = new AbortController();
aborted.abort();
await expect(
acquireHostConnection(origin.client, directory.self.serverId, aborted.signal),
).rejects.toThrow("disposed");
expect((await origin.paseo.hosts.list()).self.serverId).toBe(directory.self.serverId);
});
test("released internal self connection capability rejects captured operations while the borrowed origin stays usable", async () => {
const { acquireHostConnection } = await import("@getpaseo/client/internal/host-connection");
const a = await daemon();
const origin = await api(a);
const control = new AbortController();
const self = await acquireHostConnection(origin.client, a.daemon.getServerId(), control.signal);
const scoped = createPaseoApi(self.client);
cleanup.push(() => scoped.dispose());
await self.release();
await expect(scoped.workspaces.list()).rejects.toMatchObject({ code: "DAEMON_CONNECTION_LOST" });
expect((await origin.paseo.workspaces.list()).entries).toEqual([]);
});
test("nested self scopes preserve the consumed hop while a direct self scope reaches its registered destination", async () => {
const a = await daemon();
const b = await daemon();
const c = await daemon();
const origin = await api(a);
const directB = await api(b);
const wire = await hostWire(b);
await origin.paseo.hosts.add({ connection: wire.connection });
await online(origin.paseo);
await directB.paseo.hosts.add({ connection: `127.0.0.1:${c.port}` });
await online(directB.paseo);
const selfA = await origin.paseo.hosts.client(a.daemon.getServerId());
cleanup.push(() => selfA.dispose());
const viaB = await selfA.hosts.client(b.daemon.getServerId());
cleanup.push(() => viaB.dispose());
const nestedB = await viaB.hosts.client(b.daemon.getServerId());
cleanup.push(() => nestedB.dispose());
const twiceB = await nestedB.hosts.client(b.daemon.getServerId());
cleanup.push(() => twiceB.dispose());
expect((await twiceB.workspaces.list()).entries).toEqual([]);
await expect(twiceB.hosts.client(c.daemon.getServerId())).rejects.toMatchObject({
code: "unsupported_capability",
});
const directSelfB = await directB.paseo.hosts.client(b.daemon.getServerId());
cleanup.push(() => directSelfB.dispose());
const allowedC = await directSelfB.hosts.client(c.daemon.getServerId());
cleanup.push(() => allowedC.dispose());
expect((await allowedC.workspaces.list()).entries).toEqual([]);
expect(wire.connections()).toBe(1);
await nestedB.dispose();
expect((await viaB.workspaces.list()).entries).toEqual([]);
expect((await origin.paseo.workspaces.list()).entries).toEqual([]);
});
test("owned self session close settles pending RPC and observation and leaves sibling and physical origin usable", async () => {
const { acquireHostConnection } = await import("@getpaseo/client/internal/host-connection");
const a = await daemon();
const wire = await hostWire(a);
const origin = createHostClient(wire.connection, {
clientId: crypto.randomUUID(),
reconnect: { enabled: false },
});
cleanup.push(() => origin.close());
await origin.connect();
const originApi = createPaseoApi(origin);
cleanup.push(() => originApi.dispose());
const control = new AbortController();
const self = await acquireHostConnection(origin, a.daemon.getServerId(), control.signal);
cleanup.push(() => self.release());
const sibling = await acquireHostConnection(
origin,
a.daemon.getServerId(),
new AbortController().signal,
);
cleanup.push(() => sibling.release());
const observed = self.client.observeHosts();
const siblingObserved = sibling.client.observeHosts();
let updates = 0;
let siblingUpdates = 0;
observed.subscribe({
snapshot() {},
update() {
updates++;
},
});
siblingObserved.subscribe({
snapshot() {},
update() {
siblingUpdates++;
},
});
await Promise.all([observed.ready, siblingObserved.ready]);
const states: string[] = [];
self.client.subscribeConnectionStatus((state) => states.push(state.status));
const identity = self.client.getServerId();
const capturedFetch = self.client.fetchWorkspaces.bind(self.client);
const held = wire.holdResponses((message) => message.type === "fetch_workspaces_response");
const pending = capturedFetch();
void pending.catch(() => {});
await held.ready;
try {
await self.release();
await expect(pending).rejects.toThrow();
expect(states.at(-1)).toBe("disposed");
expect(self.client.getServerId()).toBe(identity);
const rejected = capturedFetch();
expect(rejected).toBeInstanceOf(Promise);
await expect(rejected).rejects.toMatchObject({ code: "DAEMON_CONNECTION_LOST" });
held.release();
await originApi.hosts.add({ connection: "127.0.0.1:1", label: "After scope release" });
await expect.poll(() => siblingUpdates).toBeGreaterThan(0);
expect(updates).toBe(0);
expect((await sibling.client.fetchWorkspaces()).entries).toEqual([]);
expect((await originApi.workspaces.list()).entries).toEqual([]);
control.abort();
await self.release();
} finally {
held.release();
}
});
+134 -100
View File
@@ -16,16 +16,23 @@ export class DaemonLinkEndpoint {
constructor(
private readonly parent: {
identity: string;
serverId?: string;
assertScopeAdmission?(): void;
permissions(): readonly DaemonPermission[];
routed(): boolean;
bufferedAmount(): number | null;
delivery: SessionDelivery;
admit(socket: VirtualSessionSocket, permissions: readonly DaemonPermission[]): Promise<void>;
admit(
socket: VirtualSessionSocket,
permissions: readonly DaemonPermission[],
context: { kind: "scope" | "destination-link" },
): Promise<void>;
},
) {}
handles(message: SessionInboundMessage): boolean {
return (
message.type === "hosts.link.open.request" ||
(message.type === "hosts.route.open.request" && message.serverId === this.parent.serverId) ||
(message.type === "hosts.route.frame.request" && this.channels.has(message.routeId)) ||
(message.type === "hosts.route.close.request" &&
((!!message.routeId && this.channels.has(message.routeId)) ||
@@ -33,105 +40,11 @@ export class DaemonLinkEndpoint {
);
}
async dispatch(message: SessionInboundMessage): Promise<void> {
if (message.type === "hosts.link.open.request") {
const error = (code: string, text: string) =>
this.parent.delivery.reply({
type: "rpc_error",
payload: {
requestId: message.requestId,
requestType: message.type,
code,
error: text,
},
});
if (this.closed || this.parent.routed()) {
error("unsupported_capability", "Connected hosts support one hop");
return;
}
if (this.channels.size >= 128) {
error("resource_limit", "Too many host channels");
return;
}
let socket: VirtualSessionSocket;
const owner = this.parent.delivery.begin("host-link", undefined, () => {
this.channels.delete(owner.id);
socket?.close();
});
const id = owner.id;
// Capture actual authenticated authority. The caller hello can only reduce it.
const ceiling = [...this.parent.permissions()];
socket = new VirtualSessionSocket(
async (data) => {
const output: SessionOutboundMessage | null =
typeof data === "string"
? {
type: "hosts.route.frame",
payload: { subscriptionId: id, data, binary: false },
}
: null;
const frame = typeof data === "string" ? null : encodeHostRouteFrame(id, data);
const bytes = output
? Buffer.byteLength(JSON.stringify({ type: "session", message: output }))
: frame!.byteLength;
const buffered = this.parent.bufferedAmount();
// Account for the exact escaped outer frame, retaining control-frame headroom.
if (buffered === null || buffered + bytes > 64 * 1024 * 1024 - 65536) {
socket.close(1009, "Host channel output limit reached");
throw new Error("Host channel output limit reached");
}
try {
if (output) await owner.emitAndWait(output);
else await owner.emitBinaryAndWait(frame!);
} catch (failure) {
socket.close();
throw failure;
}
},
(code, reason) => {
this.channels.delete(id);
let failure = "host_offline";
if (code === 1009) failure = "resource_limit";
if (code === 1008) failure = "permission_denied";
owner.emit({
type: "hosts.route.closed",
payload: {
subscriptionId: id,
code: failure,
message: reason,
},
});
void owner.release();
},
() => this.parent.bufferedAmount() ?? 0,
(data) => {
const hello = JSON.parse(data);
if (hello.type !== "hello" || typeof hello.clientId !== "string")
throw new Error("Expected hello");
return JSON.stringify({
...hello,
auth: undefined,
routed: true,
clientId: `link-${createHash("sha256")
.update(JSON.stringify([this.parent.identity, hello.clientId]))
.digest("hex")}`,
});
},
);
this.channels.set(id, { scopeId: message.channelId, socket });
try {
await this.parent.admit(socket, ceiling);
if (this.closed || socket.readyState !== 1) {
socket.close();
return;
}
this.parent.delivery.reply({
type: "hosts.link.open.response",
payload: { requestId: message.requestId, routeId: id },
});
} catch {
socket.close();
error("host_offline", "Could not admit host channel");
}
if (
message.type === "hosts.link.open.request" ||
(message.type === "hosts.route.open.request" && message.serverId === this.parent.serverId)
) {
await this.openChannel(message);
return;
}
if (message.type === "hosts.route.frame.request" && this.channels.has(message.routeId)) {
@@ -146,6 +59,127 @@ export class DaemonLinkEndpoint {
}
return;
}
private async openChannel(
message: Extract<
SessionInboundMessage,
{ type: "hosts.link.open.request" | "hosts.route.open.request" }
>,
): Promise<void> {
const selfScope = message.type === "hosts.route.open.request";
const error = (code: string, text: string) =>
this.parent.delivery.reply({
type: "rpc_error",
payload: {
requestId: message.requestId,
requestType: message.type,
code,
error: text,
},
});
if (this.closed || (!selfScope && this.parent.routed())) {
error("unsupported_capability", "Connected hosts support one hop");
return;
}
try {
this.parent.assertScopeAdmission?.();
} catch (failure) {
error(
"unsupported_capability",
failure instanceof Error ? failure.message : "Unsupported scoped admission",
);
return;
}
if (this.channels.size >= 128) {
error("resource_limit", "Too many host channels");
return;
}
let socket: VirtualSessionSocket;
const owner = this.parent.delivery.begin("host-link", undefined, () => {
this.channels.delete(owner.id);
socket?.close();
});
const id = owner.id;
// Capture actual authenticated authority. The caller hello can only reduce it.
const ceiling = [...this.parent.permissions()];
socket = new VirtualSessionSocket(
async (data) => {
const output: SessionOutboundMessage | null =
typeof data === "string"
? {
type: "hosts.route.frame",
payload: { subscriptionId: id, data, binary: false },
}
: null;
const frame = typeof data === "string" ? null : encodeHostRouteFrame(id, data);
const bytes = output
? Buffer.byteLength(JSON.stringify({ type: "session", message: output }))
: frame!.byteLength;
const buffered = this.parent.bufferedAmount();
// Account for the exact escaped outer frame, retaining control-frame headroom.
if (buffered === null || buffered + bytes > 64 * 1024 * 1024 - 65536) {
socket.close(1009, "Host channel output limit reached");
throw new Error("Host channel output limit reached");
}
try {
if (output) await owner.emitAndWait(output);
else await owner.emitBinaryAndWait(frame!);
} catch (failure) {
socket.close();
throw failure;
}
},
(code, reason) => {
this.channels.delete(id);
let failure = "host_offline";
if (code === 1009) failure = "resource_limit";
if (code === 1008) failure = "permission_denied";
owner.emit({
type: "hosts.route.closed",
payload: {
subscriptionId: id,
code: failure,
message: reason,
},
});
void owner.release();
},
() => this.parent.bufferedAmount() ?? 0,
(data) => {
const hello = JSON.parse(data);
if (hello.type !== "hello" || typeof hello.clientId !== "string")
throw new Error("Expected hello");
return JSON.stringify({
...hello,
auth: undefined,
routed: selfScope ? this.parent.routed() : true,
sessionPermissions: ceiling.filter(
(permission) =>
!hello.sessionPermissions || hello.sessionPermissions.includes(permission),
),
clientId: `link-${createHash("sha256")
.update(JSON.stringify([this.parent.identity, hello.clientId]))
.digest("hex")}`,
});
},
);
this.channels.set(id, { scopeId: selfScope ? message.scopeId : message.channelId, socket });
try {
await this.parent.admit(socket, ceiling, {
kind: selfScope ? "scope" : "destination-link",
});
if (this.closed || socket.readyState !== 1) {
socket.close();
return;
}
this.parent.delivery.reply({
type: selfScope ? "hosts.route.open.response" : "hosts.link.open.response",
payload: { requestId: message.requestId, routeId: id },
});
} catch {
socket.close();
error("host_offline", "Could not admit host channel");
}
}
binary(frame: HostRouteFrame): boolean {
const socket = this.channels.get(frame.routeId)?.socket;
if (!socket) return false;
@@ -62,6 +62,24 @@ test("Hub execute can steer ordinary agents while unrelated administration stays
expect(hub.serverInfoPermissions()).toEqual([["hub.execute"]]);
});
test("execution-scoped admission cannot acquire an ordinary self session", async () => {
const hub = await launchRelationship();
expect(
await hub.requestOrdinary({
type: "hosts.route.open.request",
requestId: "execution-self-scope",
serverId: hub.serverId(),
scopeId: "execution-self-scope",
}),
).toMatchObject({
type: "rpc_error",
payload: { requestId: "execution-self-scope", code: "unsupported_capability" },
});
// Rejection leaves the originating execution capability usable.
const created = await hub.createOwnedConcurrently();
expect(created.duplicate.agentId).toBe(created.first.agentId);
});
test("Hub execute can title the workspace created with an agent", async () => {
const hub = await launchRelationship();
const created = await hub.requestOrdinary({
@@ -165,11 +183,23 @@ test("ordinary Hub create and message retries do not duplicate agents or prompts
};
expect(await hub.requestOrdinary({ ...message, requestId: "message-first" })).toEqual({
type: "send_agent_message_response",
payload: { requestId: "message-first", agentId, accepted: true, error: null },
payload: {
requestId: "message-first",
agentId,
accepted: true,
error: null,
dispatch: { disposition: "turn_started", tracking: "turn" },
},
});
expect(await hub.requestOrdinary({ ...message, requestId: "message-duplicate" })).toEqual({
type: "send_agent_message_response",
payload: { requestId: "message-duplicate", agentId, accepted: true, error: null },
payload: {
requestId: "message-duplicate",
agentId,
accepted: true,
error: null,
dispatch: { disposition: "turn_started", tracking: "turn" },
},
});
expect(
hub
@@ -1212,6 +1212,10 @@ export class HubRelationshipHarness {
);
}
serverId(): string {
return this.daemon!.getServerId();
}
probeTrustedHello(): number | null {
const socket = this.latestSocket().socket;
socket.receiveEnvelope({
+133 -14
View File
@@ -1,6 +1,6 @@
import type { HostDirectory } from "@getpaseo/protocol/hosts";
import { shouldDetachFromArchivedParent } from "@getpaseo/protocol/agent-lifecycle";
import { hasOpenAgentTab } from "@getpaseo/protocol/agent-labels";
import { hasOpenAgentTab, getParentAgentIdFromLabels } from "@getpaseo/protocol/agent-labels";
import { WorkspaceAddressSchema, type WorkspaceAddress } from "@getpaseo/protocol/addresses";
import type { AgentSnapshotPayload, CreationSnapshot } from "@getpaseo/protocol/messages";
import { createHash, randomUUID } from "node:crypto";
@@ -19,6 +19,8 @@ import {
} from "@getpaseo/protocol/messages";
import { writeJsonFileAtomic } from "../atomic-file.js";
import type { HostRuntimeAuthority } from "../host-runtime/index.js";
import type { AgentStorage, StoredAgentRecord } from "../agent/agent-storage.js";
import { buildStoredAgentPayload } from "../agent/agent-projections.js";
/** Stored authority contains identity and a ceiling; live admission belongs to the host. */
export interface SubagentAuthority {
@@ -31,11 +33,9 @@ const AuthoritySchema = z.object({
sourceId: z.string(),
permissionCeiling: z.array(DaemonPermissionSchema),
});
const RecordSchema = z.object({
const RelationshipStateSchema = z.object({
row: SubagentRelationshipSchema,
authority: AuthoritySchema,
creation: SubagentCreateInputSchema,
fingerprint: z.string(),
confirmed: z.enum(["attached", "detached", "archived", "canceled"]).nullable(),
intent: z.object({
revision: z.number().int().nonnegative(),
@@ -44,8 +44,24 @@ const RecordSchema = z.object({
authority: AuthoritySchema,
}),
});
const RecordSchema = z.discriminatedUnion("provenance", [
RelationshipStateSchema.extend({
provenance: z.literal("creation"),
creation: SubagentCreateInputSchema,
fingerprint: z.string(),
}),
RelationshipStateSchema.extend({
provenance: z.literal("legacy-local-lifecycle"),
creation: z.null(),
fingerprint: z.null(),
}),
]);
type RelationshipRecord = z.infer<typeof RecordSchema>;
type RelationshipIntent = RelationshipRecord["intent"];
const LedgerSchema = z.object({
records: z.array(RecordSchema),
legacyUpgrade: z.object({ complete: z.boolean(), candidates: z.array(z.string()) }),
});
/** Authoritative parent membership, independent of views and destination metadata. */
export class Subagents {
@@ -69,11 +85,13 @@ export class Subagents {
private mutation: Promise<unknown> = Promise.resolve();
private readonly ready: Promise<void>;
private closed = false;
private legacyUpgrade = { complete: false, candidates: [] as string[] };
constructor(
private readonly options: {
home: string;
serverId: string;
agents: Pick<AgentStorage, "list" | "get" | "upsert">;
runtime(authority: HostRuntimeAuthority): Promise<HostRuntime>;
resolveAuthority(principalId: string): ReturnType<HostRuntimeAuthority["resolveAdmission"]>;
},
@@ -83,17 +101,99 @@ export class Subagents {
}
private async load(): Promise<void> {
let records: RelationshipRecord[];
let records: RelationshipRecord[] = [];
let existing = false;
try {
records = z.array(RecordSchema).parse(JSON.parse(await readFile(this.path(), "utf8")));
const ledger = LedgerSchema.parse(JSON.parse(await readFile(this.path(), "utf8")));
records = ledger.records;
this.legacyUpgrade = ledger.legacyUpgrade;
existing = true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
throw error;
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
for (const record of records) {
record.row.freshness = "stale";
this.records.set(record.row.id, record);
}
if (!this.legacyUpgrade.complete) await this.upgradeLegacyMembership(existing);
}
initialize(): Promise<void> {
return this.ready;
}
private async upgradeLegacyMembership(existing: boolean): Promise<void> {
if (!existing) {
const agents = await this.options.agents.list();
const byId = new Map(agents.map((agent) => [agent.id, agent]));
const admission = await this.options.resolveAuthority("owner");
const authority: z.infer<typeof AuthoritySchema> = {
principalId: "owner",
sourceId: "owner",
permissionCeiling: admission.permissions.filter((permission) =>
["daemon.read", "workspace.read", "workspace.write"].includes(permission),
),
};
for (const child of agents) {
const parentId = getParentAgentIdFromLabels(child.labels);
const parent = parentId ? byId.get(parentId) : undefined;
if (
!legacyManagedAgent(child) ||
child.archivedAt ||
!parent ||
!legacyManagedAgent(parent) ||
child.parent !== undefined
)
continue;
if (
[...this.records.values()].some(
(record) =>
record.row.child.serverId === this.options.serverId &&
record.row.child.agentId === child.id,
)
)
continue;
const id = createHash("sha256")
.update(JSON.stringify(["legacy-local-lifecycle", this.options.serverId, child.id]))
.digest("hex");
const address = { serverId: this.options.serverId, agentId: parent.id };
this.records.set(id, {
row: {
id,
parent: address,
child: { serverId: this.options.serverId, agentId: child.id },
...(child.workspaceId
? { workspace: { serverId: this.options.serverId, workspaceId: child.workspaceId } }
: {}),
phase: "attached",
freshness: "stale",
observedAt: null,
error: null,
snapshot: buildStoredAgentPayload({ ...child, parent: address }, [child.provider]),
},
provenance: "legacy-local-lifecycle",
creation: null,
fingerprint: null,
authority,
confirmed: "attached",
intent: { kind: "attached", revision: 0, authority },
});
this.legacyUpgrade.candidates.push(id);
}
// Persist the bounded candidate set before qualifying agent metadata. A restart
// resumes these candidates, never a new scan of subsequently edited labels.
await this.persist();
}
for (const id of this.legacyUpgrade.candidates) {
const record = this.records.get(id)!;
const child = await this.options.agents.get(record.row.child.agentId);
if (!child) continue;
if (child.parent === undefined)
await this.options.agents.upsert({ ...child, parent: record.row.parent });
else if (!child.parent || !sameAddress(child.parent, record.row.parent))
throw new Error("Legacy child parent changed during membership upgrade");
}
this.legacyUpgrade.complete = true;
await this.persist();
}
private path(): string {
return join(this.options.home, "subagents.json");
@@ -109,7 +209,10 @@ export class Subagents {
}
private async persist(): Promise<void> {
for (const record of this.records.values()) record.row.phase = relationshipPhase(record);
await writeJsonFileAtomic(this.path(), [...this.records.values()]);
await writeJsonFileAtomic(this.path(), {
records: [...this.records.values()],
legacyUpgrade: this.legacyUpgrade,
});
for (const changed of this.listeners) changed();
}
async assertHostRemovable(serverId: string): Promise<void> {
@@ -262,6 +365,7 @@ export class Subagents {
return existing;
}
const fresh: RelationshipRecord = {
provenance: "creation",
row: {
id,
parent,
@@ -309,10 +413,11 @@ export class Subagents {
return running;
}
private creationKey(record: RelationshipRecord): string {
return record.creation.input.idempotencyKey ?? `subagent:${record.row.id}`;
return creationRequest(record).input.idempotencyKey ?? `subagent:${record.row.id}`;
}
private async createChild(record: RelationshipRecord): Promise<AgentAddress> {
const { row, creation } = record;
const { row } = record;
const creation = creationRequest(record);
const creationIntent = record.intent;
try {
const runtime = await this.runtime(record.authority);
@@ -655,7 +760,7 @@ export class Subagents {
return null;
}
const observation = api.creations.observe({
kind: record.creation.workspace ? "workspace" : "agent",
kind: creationRequest(record).workspace ? "workspace" : "agent",
idempotencyKey: this.creationKey(record),
});
let latest: CreationSnapshot | null = null;
@@ -705,12 +810,13 @@ export class Subagents {
const child = await runtime.agent(record.row.child);
let current = await child.refresh();
if (!current) {
const creation = creationRequest(record);
const destination = await runtime.client(record.row.child.serverId);
try {
if (record.intent !== intent) return;
const cancellation = await destination.creations.cancel({
...(record.creation.workspace
? { kind: "workspace" as const, workspaceId: record.creation.input.workspaceId! }
...(creation.workspace
? { kind: "workspace" as const, workspaceId: creation.input.workspaceId! }
: { kind: "agent" as const }),
idempotencyKey: this.creationKey(record),
agentId: record.row.child.agentId,
@@ -842,3 +948,16 @@ function terminalPhase(phase: RelationshipRecord["row"]["phase"]): boolean {
phase === "detached" || phase === "archived" || phase === "forgotten" || phase === "canceled"
);
}
function legacyManagedAgent(agent: StoredAgentRecord): boolean {
return !agent.internal && !agent.owner;
}
function creationRequest(record: RelationshipRecord): SubagentCreateInput {
if (record.provenance !== "creation" || !record.creation)
throw failure(
"agent_not_found",
"The adopted existing child is absent; there is no creation to replay or cancel",
);
return record.creation;
}
+35 -11
View File
@@ -755,6 +755,7 @@ export class VoiceAssistantWebSocketServer {
this.subagents = new Subagents({
home: paseoHome,
serverId,
agents: this.agentStorage,
runtime: (authority) => this.createHostRuntime(authority),
resolveAuthority: (principalId) => this.resolveRelationshipAdmission(principalId),
});
@@ -766,13 +767,16 @@ export class VoiceAssistantWebSocketServer {
this.logger.warn({ err: error }, "Failed to reconcile subagent host availability"),
);
});
this.stopAgentArchived = this.agentManager.onAgentArchived(async (agentId) => {
const record = await this.agentStorage.get(agentId);
await this.subagents.parentArchived(
{ serverId, agentId },
record?.workspaceId ? { serverId, workspaceId: record.workspaceId } : undefined,
);
});
this.stopAgentArchived = this.agentManager.onAgentArchived(
async (agentId) => {
const record = await this.agentStorage.get(agentId);
await this.subagents.parentArchived(
{ serverId, agentId },
record?.workspaceId ? { serverId, workspaceId: record.workspaceId } : undefined,
);
},
{ ownsManagedChildren: true },
);
this.passwordHash = auth?.password;
this.worktreesRoot = daemonRuntimeConfig?.worktreesRoot;
this.daemonConfigStore = daemonConfigStore;
@@ -1158,6 +1162,10 @@ export class VoiceAssistantWebSocketServer {
this.connectionLifecycle = "stopping";
}
public initializeRelationships(): Promise<void> {
return this.subagents.initialize();
}
public beginAcceptingConnections(): void {
if (this.connectionLifecycle === "starting") {
this.connectionLifecycle = "accepting";
@@ -2029,6 +2037,7 @@ export class VoiceAssistantWebSocketServer {
connectedHosts: true,
fleetSubagents: true,
hostMultiplexing: true,
hostSessionScopes: true,
usageSources: true,
ownedSubscriptions: true,
agentRequestReceipts: true,
@@ -2632,15 +2641,30 @@ export class VoiceAssistantWebSocketServer {
if (!link) {
link = new DaemonLinkEndpoint({
identity: JSON.stringify([connection.principalId, connection.clientId]),
serverId: this.serverId,
assertScopeAdmission: () => {
if (connection.hubExecutionAgents)
throw Object.assign(new Error("Execution-scoped host acquisition is not supported"), {
code: "unsupported_capability",
});
},
permissions: () => connection.session.getPermissions(),
routed: () => connection.session.isRouted(),
bufferedAmount: () => ws.bufferedAmount ?? null,
delivery: connection.session.delivery,
admit: (socket, permissions) =>
this.attachSocket(socket, undefined, undefined, false, {
admit: (socket, permissions, context) => {
if (connection.hubExecutionAgents)
throw new Error("Execution-scoped host acquisition is not supported");
this.ownedSockets.add(socket);
if (context.kind === "scope")
this.ownedSocketSources.set(socket, connection.session.getHostSourceId());
return this.attachSocket(socket, undefined, undefined, false, {
principalId: connection.principalId,
permissions,
}),
permissions: permissions.filter((permission) =>
connection.session.getPermissions().includes(permission),
),
});
},
});
this.hostLinks.set(ws, link);
}