fix(sync): centralize directory replica ordering

Own agent and workspace refresh transactions in HostRuntime so reconnect epochs, buffered updates, and lifecycle invalidation share one ordering boundary. Route timeline metadata and mixed-capability stream delivery through their authoritative runtime sources.
This commit is contained in:
Mohamed Boudra
2026-07-17 14:58:21 +02:00
parent 1774ce3c4c
commit 467fb47012
24 changed files with 1245 additions and 972 deletions
@@ -8,6 +8,7 @@ import { useRewindComposerRestore } from "./composer-restore";
import { useSessionStore } from "@/stores/session-store";
import { shouldRestoreComposerForRewindMode } from "./rewind-mode";
import { clearOptimisticUserMessages } from "@/types/stream";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
interface UseRewindAgentMutationInput {
serverId?: string;
@@ -47,7 +48,8 @@ export function useRewindAgentMutation(input: UseRewindAgentMutationInput): {
.getState()
.sessions[input.serverId]?.agentTimelineCursor.get(input.agentId)
: undefined;
await input.client.fetchAgentTimeline(input.agentId, {
if (!input.serverId) throw new Error(t("common.errors.daemonClientUnavailable"));
await getHostRuntimeStore().fetchAgentTimeline(input.serverId, input.agentId, {
direction: "tail",
projection: "projected",
...(cursor ? { cursor: { epoch: cursor.epoch, seq: cursor.endSeq } } : {}),
+5 -474
View File
@@ -6,7 +6,6 @@ import { useTranslation } from "react-i18next";
import { useClientActivity } from "@/hooks/use-client-activity";
import { useAppVisible } from "@/hooks/use-app-visible";
import { usePushTokenRegistration } from "@/hooks/use-push-token-registration";
import { clearArchiveAgentPending } from "@/hooks/use-archive-agent";
import {
createSetAgentInitializing,
refreshAgentInitializationTimeout,
@@ -21,7 +20,6 @@ import {
} from "@/timeline/session-stream-reducers";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { isTimelineCatchUpComplete } from "@/timeline/timeline-sync-plan";
import { fetchAgentTimelineOnce } from "@/timeline/fetch-agent-timeline-once";
import { createViewedTimelineSync, type ViewedTimelineSync } from "@/timeline/viewed-timeline-sync";
import type { AgentAttachment, SessionOutboundMessage } from "@getpaseo/protocol/messages";
import { parseServerInfoStatusPayload } from "@getpaseo/protocol/messages";
@@ -37,17 +35,7 @@ import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
import { getHostRuntimeStore, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useVoiceAudioEngineOptional, useVoiceRuntimeOptional } from "@/contexts/voice-context";
import type { AudioPlaybackSource } from "@/voice/audio-engine-types";
import {
useSessionStore,
type Agent,
type MessageEntry,
type SessionState,
type WorkspaceDescriptor,
type EmptyProjectDescriptor,
normalizeWorkspaceDescriptor,
normalizeEmptyProjectDescriptor,
} from "@/stores/session-store";
import { useDraftStore } from "@/stores/draft-store";
import { useSessionStore, type MessageEntry, type SessionState } from "@/stores/session-store";
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
import { sendOsNotification } from "@/utils/os-notifications";
import { getIsAppActivelyVisible } from "@/utils/app-visibility";
@@ -59,27 +47,15 @@ import {
rejectInitDeferred,
} from "@/utils/agent-initialization";
import { encodeImages } from "@/utils/encode-images";
import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { buildDraftStoreKey } from "@/stores/draft-keys";
import { derivePendingPermissionKey } from "@/utils/agent-snapshots";
import type { AttachmentMetadata } from "@/attachments/types";
import { patchWorkspaceScripts } from "@/contexts/session-workspace-scripts";
import {
clearWorkspaceArchivePending,
shouldSuppressWorkspaceForLocalArchive,
} from "@/contexts/session-workspace-upserts";
import { reconcileWorkspaceDirectory } from "@/contexts/workspace-directory-reconciliation";
import { useToast } from "@/contexts/toast-context";
import { toErrorMessage } from "@/utils/error-messages";
import { showProviderNoticeToast } from "@/utils/provider-notice-toast";
import { applyCheckoutStatusUpdateFromEvent } from "@/git/checkout-status-cache";
import {
applyLegacyDaemonWorkspaceOwnership,
backfillLegacyDaemonWorkspaceDirectoryIfEmpty,
} from "@/workspace/legacy-daemon-workspaces";
import { useProviderSubagentStore } from "@/subagents/provider-store";
import { revalidateSessionAfterResume } from "@/contexts/session-resume-revalidation";
import { inheritBufferedDirectoryDeltas } from "@/utils/buffered-directory-transaction";
import { shouldApplyTimelineAgentSnapshot } from "@/utils/agent-directory-sync";
// Re-export types from session-store and draft-store for backward compatibility
export type { DraftInput } from "@/stores/draft-store";
@@ -94,22 +70,6 @@ export type {
AgentFileExplorerState,
} from "@/stores/session-store";
function hasAgentUsageChanged(
incomingUsage: Agent["lastUsage"] | undefined,
currentUsage: Agent["lastUsage"] | undefined,
): boolean {
const keys: Array<keyof NonNullable<Agent["lastUsage"]>> = [
"inputTokens",
"outputTokens",
"cachedInputTokens",
"totalCostUsd",
"contextWindowMaxTokens",
"contextWindowUsedTokens",
];
return keys.some((key) => incomingUsage?.[key] !== currentUsage?.[key]);
}
type AudioOutputPayload = Extract<SessionOutboundMessage, { type: "audio_output" }>["payload"];
interface BufferedAudioChunk {
@@ -119,76 +79,6 @@ interface BufferedAudioChunk {
id: string;
}
interface WorkspaceHydrationSnapshot {
workspaces: Map<string, WorkspaceDescriptor>;
emptyProjects: Map<string, EmptyProjectDescriptor>;
}
interface WorkspaceHydrationTransaction {
id: symbol;
client: DaemonClient;
workspaces: Map<string, WorkspaceDescriptor>;
deltas: WorkspaceUpdatePayload[];
}
function isWorkspaceHydrationCancelled(isCancelled?: () => boolean): boolean {
return isCancelled?.() ?? false;
}
type WorkspaceUpdatePayload = Extract<
SessionOutboundMessage,
{ type: "workspace_update" }
>["payload"];
async function fetchWorkspaceHydrationSnapshot(input: {
client: DaemonClient;
serverId: string;
subscribe: boolean;
isCancelled?: () => boolean;
transaction: WorkspaceHydrationTransaction;
isCurrent: () => boolean;
}): Promise<WorkspaceHydrationSnapshot | null> {
const emptyProjects = new Map<string, EmptyProjectDescriptor>();
let cursor: string | null = null;
let includeSubscribe = input.subscribe;
while (true) {
const payload = await input.client.fetchWorkspaces({
sort: [{ key: "activity_at", direction: "desc" }],
...(includeSubscribe ? { subscribe: {} } : {}),
page: cursor ? { limit: 200, cursor } : { limit: 200 },
});
if (input.isCancelled?.() || !input.isCurrent()) {
return null;
}
for (const entry of payload.entries) {
const workspace = normalizeWorkspaceDescriptor(entry);
if (shouldSuppressWorkspaceForLocalArchive({ serverId: input.serverId, workspace })) {
continue;
}
input.transaction.workspaces.set(workspace.id, workspace);
}
// Project parents with no active workspaces only ride on the first page.
for (const project of payload.emptyProjects ?? []) {
const descriptor = normalizeEmptyProjectDescriptor(project);
emptyProjects.set(descriptor.projectId, descriptor);
}
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) {
break;
}
cursor = payload.pageInfo.nextCursor;
includeSubscribe = false;
}
return {
workspaces: new Map(input.transaction.workspaces),
emptyProjects,
};
}
function decodeBase64Chunk(base64: string): Uint8Array {
return Buffer.from(base64, "base64");
}
@@ -507,24 +397,14 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
const setAgentAuthoritativeHistoryApplied = useSessionStore(
(state) => state.setAgentAuthoritativeHistoryApplied,
);
const setHasHydratedAgents = useSessionStore((state) => state.setHasHydratedAgents);
const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces);
const setAgents = useSessionStore((state) => state.setAgents);
const setWorkspaces = useSessionStore((state) => state.setWorkspaces);
const setEmptyProjects = useSessionStore((state) => state.setEmptyProjects);
const addEmptyProject = useSessionStore((state) => state.addEmptyProject);
const removeEmptyProject = useSessionStore((state) => state.removeEmptyProject);
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
const removeWorkspace = useSessionStore((state) => state.removeWorkspace);
const setAgentLastActivity = useSessionStore((state) => state.setAgentLastActivity);
const flushAgentLastActivity = useSessionStore((state) => state.flushAgentLastActivity);
const setPendingPermissions = useSessionStore((state) => state.setPendingPermissions);
const clearDraftInput = useDraftStore((state) => state.clearDraftInput);
const updateSessionClient = useSessionStore((state) => state.updateSessionClient);
const updateSessionServerInfo = useSessionStore((state) => state.updateSessionServerInfo);
const setViewedTimelineSync = useSessionStore((state) => state.setViewedTimelineSync);
const upsertWorkspaceSetupProgress = useWorkspaceSetupStore((state) => state.upsertProgress);
const removeWorkspaceSetup = useWorkspaceSetupStore((state) => state.removeWorkspace);
const clearWorkspaceSetupServer = useWorkspaceSetupStore((state) => state.clearServer);
// Track focused agent for heartbeat
@@ -540,36 +420,8 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
const viewedTimelineSyncRef = useRef<ViewedTimelineSync | null>(null);
const audioOutputBuffersRef = useRef<Map<string, BufferedAudioChunk[]>>(new Map());
const activeAudioGroupsRef = useRef<Set<string>>(new Set());
const workspaceHydrationRef = useRef<WorkspaceHydrationTransaction | null>(null);
const isAppVisible = useAppVisible();
const applyWorkspaceUpdatePayload = useCallback(
(payload: WorkspaceUpdatePayload) => {
if (payload.kind === "remove") {
clearWorkspaceArchivePending({ serverId, workspaceId: payload.id });
removeWorkspaceSetup({ serverId, workspaceId: payload.id });
removeWorkspace(serverId, payload.id);
if (payload.emptyProject) {
addEmptyProject(serverId, normalizeEmptyProjectDescriptor(payload.emptyProject));
}
if (payload.removedProjectId) removeEmptyProject(serverId, payload.removedProjectId);
return;
}
const workspace = normalizeWorkspaceDescriptor(payload.workspace);
if (!shouldSuppressWorkspaceForLocalArchive({ serverId, workspace })) {
mergeWorkspaces(serverId, [workspace]);
}
},
[
addEmptyProject,
mergeWorkspaces,
removeEmptyProject,
removeWorkspace,
removeWorkspaceSetup,
serverId,
],
);
useEffect(() => {
const subscription = AppState.addEventListener("change", (nextState) => {
appStateRef.current = nextState;
@@ -584,173 +436,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
viewedTimelineSyncRef.current?.setActive(isAppVisible);
}, [isAppVisible]);
const hydrateWorkspaces = useCallback(
async (options?: { subscribe?: boolean; isCancelled?: () => boolean }) => {
if (!client || !isConnected) {
return;
}
const transaction: WorkspaceHydrationTransaction = {
id: Symbol("workspace hydration"),
client,
workspaces: new Map(),
deltas: inheritBufferedDirectoryDeltas({
client,
previous: workspaceHydrationRef.current,
}),
};
workspaceHydrationRef.current = transaction;
let snapshot: WorkspaceHydrationSnapshot | null;
try {
snapshot = await fetchWorkspaceHydrationSnapshot({
client,
serverId,
subscribe: options?.subscribe ?? false,
isCancelled: options?.isCancelled,
transaction,
isCurrent: () => workspaceHydrationRef.current?.id === transaction.id,
});
} catch (error) {
if (workspaceHydrationRef.current === transaction) {
workspaceHydrationRef.current = null;
for (const delta of transaction.deltas) applyWorkspaceUpdatePayload(delta);
}
throw error;
}
if (!snapshot || isWorkspaceHydrationCancelled(options?.isCancelled)) {
if (workspaceHydrationRef.current === transaction) workspaceHydrationRef.current = null;
return;
}
const didBackfillLegacy = await backfillLegacyDaemonWorkspaceDirectoryIfEmpty({
client,
serverId,
workspaces: snapshot.workspaces,
emptyProjects: snapshot.emptyProjects,
isCancelled: options?.isCancelled,
});
if (didBackfillLegacy) {
if (workspaceHydrationRef.current === transaction) workspaceHydrationRef.current = null;
return;
}
if (
workspaceHydrationRef.current !== transaction ||
isWorkspaceHydrationCancelled(options?.isCancelled)
) {
return;
}
const deltas = [...transaction.deltas];
workspaceHydrationRef.current = null;
setWorkspaces(
serverId,
reconcileWorkspaceDirectory({
serverId,
snapshot: snapshot.workspaces,
deltas,
}),
);
setEmptyProjects(serverId, snapshot.emptyProjects.values());
setHasHydratedWorkspaces(serverId, true);
for (const delta of deltas) applyWorkspaceUpdatePayload(delta);
},
[
applyWorkspaceUpdatePayload,
client,
isConnected,
serverId,
setEmptyProjects,
setHasHydratedWorkspaces,
setWorkspaces,
],
);
const applyAuthoritativeAgentSnapshot = useCallback(
(agent: Agent) => {
setAgents(serverId, (prev) => {
const current = prev.get(agent.id);
if (current && agent.updatedAt.getTime() < current.updatedAt.getTime()) {
const hasUsageUpdate = hasAgentUsageChanged(agent.lastUsage, current.lastUsage);
if (hasUsageUpdate) {
const next = new Map(prev);
next.set(agent.id, {
...current,
lastUsage: agent.lastUsage,
});
return next;
}
return prev;
}
const next = new Map(prev);
next.set(agent.id, agent);
return next;
});
if (agent.archivedAt) {
clearArchiveAgentPending({
queryClient,
serverId,
agentId: agent.id,
});
}
setAgentLastActivity(agent.id, agent.lastActivityAt);
setPendingPermissions(serverId, (prev) => {
const existingKeysForAgent: string[] = [];
for (const [key, pending] of prev.entries()) {
if (pending.agentId === agent.id) {
existingKeysForAgent.push(key);
}
}
const nextEntries = agent.pendingPermissions.map((request) => ({
key: derivePendingPermissionKey(agent.id, request),
agentId: agent.id,
request,
}));
let changed = existingKeysForAgent.length !== nextEntries.length;
if (!changed) {
const existingKeySet = new Set(existingKeysForAgent);
for (const entry of nextEntries) {
const existing = prev.get(entry.key);
if (!existingKeySet.has(entry.key) || !existing) {
changed = true;
break;
}
const currentRequest = existing.request;
if (
currentRequest.id !== entry.request.id ||
currentRequest.kind !== entry.request.kind ||
currentRequest.name !== entry.request.name ||
currentRequest.title !== entry.request.title ||
currentRequest.description !== entry.request.description
) {
changed = true;
break;
}
}
}
if (!changed) {
return prev;
}
const next = new Map(prev);
for (const key of existingKeysForAgent) {
next.delete(key);
}
for (const entry of nextEntries) {
next.set(entry.key, entry);
}
return next;
});
},
[queryClient, serverId, setAgentLastActivity, setAgents, setPendingPermissions],
);
const recoverTimelineGap = useCallback(
(agentId: string, cursor: { epoch: string; endSeq: number }) => {
viewedTimelineSyncRef.current?.recoverGap(agentId, cursor);
@@ -764,8 +449,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
awayMs,
serverId,
bumpHistorySyncGeneration,
refreshAgentDirectory: () => getHostRuntimeStore().refreshAgentDirectory({ serverId }),
hydrateWorkspaces,
refreshDirectories: () => getHostRuntimeStore().refreshDirectories(serverId),
}).catch((error) => {
console.error("[SessionProvider] resume revalidation failed", {
serverId,
@@ -773,7 +457,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
});
});
},
[bumpHistorySyncGeneration, hydrateWorkspaces, serverId],
[bumpHistorySyncGeneration, serverId],
);
// Client activity tracking (heartbeat, push token registration)
@@ -919,28 +603,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
}
}, [flushAgentLastActivity, serverId, isConnected, setInitializingAgents]);
useEffect(() => {
if (!client || !isConnected) {
return () => {};
}
let cancelled = false;
void (async () => {
try {
await hydrateWorkspaces({
subscribe: true,
isCancelled: () => cancelled,
});
} catch (error) {
console.error("[Session] Failed to hydrate workspaces:", error);
}
})();
return () => {
cancelled = true;
};
}, [client, hydrateWorkspaces, isConnected]);
const applyWorkspaceSetupProgress = useCallback(
(payload: WorkspaceSetupProgressPayload) => {
upsertWorkspaceSetupProgress({ serverId, payload });
@@ -983,19 +645,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
return next;
});
if (payload.agent && shouldApplyTimelineAgentSnapshot(serverId, agentId)) {
const normalized = normalizeAgentSnapshot(payload.agent, serverId);
applyAuthoritativeAgentSnapshot(
applyLegacyDaemonWorkspaceOwnership({
serverId,
agent: {
...normalized,
projectPlacement: session?.agents.get(agentId)?.projectPlacement ?? null,
},
}),
);
}
// Call pure reducer
const result = processTimelineResponse({
payload,
@@ -1048,7 +697,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
});
},
[
applyAuthoritativeAgentSnapshot,
clearAgentStreamHead,
markAgentHistorySynchronized,
recoverTimelineGap,
@@ -1088,7 +736,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
setAgentInitializing(agentId, true);
}
try {
const page = await fetchAgentTimelineOnce(client, agentId, request);
const page = await getHostRuntimeStore().fetchAgentTimeline(serverId, agentId, request);
if (getInitDeferred(initKey)) {
refreshAgentInitializationTimeout({ key: initKey, agentId, setAgentInitializing });
}
@@ -1177,16 +825,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
useProviderSubagentStore.getState().applyUpdate(serverId, message.payload);
});
const unsubWorkspaceUpdate = client.on("workspace_update", (message) => {
if (message.type !== "workspace_update") return;
const hydration = workspaceHydrationRef.current;
if (hydration) {
hydration.deltas.push(message.payload);
return;
}
applyWorkspaceUpdatePayload(message.payload);
});
const unsubScriptStatusUpdate = client.on("script_status_update", (message) => {
if (message.type !== "script_status_update") return;
setWorkspaces(serverId, (prev) => patchWorkspaceScripts(prev, message.payload));
@@ -1448,101 +1086,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
voiceRuntime?.onServerSpeechStateChanged(serverId, message.payload.isSpeaking);
});
const unsubAgentDeleted = client.on("agent_deleted", (message) => {
if (message.type !== "agent_deleted") {
return;
}
const { agentId } = message.payload;
clearArchiveAgentPending({ queryClient, serverId, agentId });
setAgents(serverId, (prev) => {
if (!prev.has(agentId)) {
return prev;
}
const next = new Map(prev);
next.delete(agentId);
return next;
});
// Remove from agentLastActivity slice (top-level)
useSessionStore.setState((state) => {
if (!state.agentLastActivity.has(agentId)) {
return state;
}
const nextActivity = new Map(state.agentLastActivity);
nextActivity.delete(agentId);
return {
...state,
agentLastActivity: nextActivity,
};
});
setAgentStreamTail(serverId, (prev) => {
if (!prev.has(agentId)) {
return prev;
}
const next = new Map(prev);
next.delete(agentId);
return next;
});
clearAgentStreamHead(serverId, agentId);
setAgentTimelineCursor(serverId, (prev) => {
if (!prev.has(agentId)) {
return prev;
}
const next = new Map(prev);
next.delete(agentId);
return next;
});
// Remove draft input
clearDraftInput({
draftKey: buildDraftStoreKey({ serverId, agentId }),
});
setPendingPermissions(serverId, (prev) => {
let changed = false;
const next = new Map(prev);
for (const [key, pending] of prev.entries()) {
if (pending.agentId === agentId) {
next.delete(key);
changed = true;
}
}
return changed ? next : prev;
});
setInitializingAgents(serverId, (prev) => {
if (!prev.has(agentId)) {
return prev;
}
const next = new Map(prev);
next.delete(agentId);
return next;
});
});
const unsubAgentArchived = client.on("agent_archived", (message) => {
if (message.type !== "agent_archived") {
return;
}
const { agentId, archivedAt } = message.payload;
clearArchiveAgentPending({ queryClient, serverId, agentId });
setAgents(serverId, (prev) => {
const existing = prev.get(agentId);
if (!existing) {
return prev;
}
const next = new Map(prev);
next.set(agentId, {
...existing,
archivedAt: new Date(archivedAt),
});
return next;
});
});
const unsubTerminalAttention = client.on("terminal_attention_required", (message) => {
if (message.type !== "terminal_attention_required") {
return;
@@ -1569,7 +1112,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
unsubAgentTimeline();
unsubProviderSubagentUpdate();
unsubAgentAttention();
unsubWorkspaceUpdate();
unsubScriptStatusUpdate();
unsubCheckoutStatusUpdate();
unsubWorkspaceSetupProgress();
@@ -1582,8 +1124,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
unsubChunk();
unsubTranscription();
unsubVoiceInputState();
unsubAgentDeleted();
unsubAgentArchived();
unsubTerminalAttention();
agentStreamReducerQueue.dispose({ flush: true });
};
@@ -1602,18 +1142,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
setInitializingAgents,
setAgents,
setWorkspaces,
mergeWorkspaces,
removeWorkspace,
removeWorkspaceSetup,
addEmptyProject,
removeEmptyProject,
setAgentLastActivity,
setPendingPermissions,
setHasHydratedAgents,
clearDraftInput,
notifyAgentAttention,
recoverTimelineGap,
applyWorkspaceUpdatePayload,
applyWorkspaceSetupProgress,
applyTimelineResponse,
updateSessionServerInfo,
@@ -12,14 +12,11 @@ describe("session resume revalidation", () => {
awayMs: SESSION_STALE_AFTER_MS,
serverId: "server",
bumpHistorySyncGeneration: (serverId) => calls.push(`history:${serverId}`),
refreshAgentDirectory: async () => calls.push("agents"),
hydrateWorkspaces: async () => {
calls.push("workspaces");
},
refreshDirectories: async () => calls.push("directories"),
});
expect(revalidated).toBe(true);
expect(calls).toEqual(["history:server", "agents", "workspaces"]);
expect(calls).toEqual(["history:server", "directories"]);
});
it("does nothing after a brief background interval", async () => {
@@ -29,10 +26,7 @@ describe("session resume revalidation", () => {
awayMs: SESSION_STALE_AFTER_MS - 1,
serverId: "server",
bumpHistorySyncGeneration: () => calls.push("history"),
refreshAgentDirectory: async () => calls.push("agents"),
hydrateWorkspaces: async () => {
calls.push("workspaces");
},
refreshDirectories: async () => calls.push("directories"),
});
expect(revalidated).toBe(false);
@@ -4,14 +4,13 @@ export async function revalidateSessionAfterResume(input: {
awayMs: number;
serverId: string;
bumpHistorySyncGeneration: (serverId: string) => void;
refreshAgentDirectory: () => Promise<unknown>;
hydrateWorkspaces: () => Promise<void>;
refreshDirectories: () => Promise<unknown>;
}): Promise<boolean> {
if (input.awayMs < SESSION_STALE_AFTER_MS) {
return false;
}
input.bumpHistorySyncGeneration(input.serverId);
await Promise.all([input.refreshAgentDirectory(), input.hydrateWorkspaces()]);
await input.refreshDirectories();
return true;
}
@@ -79,6 +79,7 @@ describe("app diagnostics report", () => {
["relay:relay.secret.test:443", { status: "available", latencyMs: 8 }],
]),
clientGeneration: 1,
connectionEpoch: 1,
};
const report = formatHostRuntimeSection({ host, snapshot });
@@ -10,7 +10,7 @@ import {
rejectInitDeferred,
refreshInitTimeout,
} from "@/utils/agent-initialization";
import { fetchAgentTimelineOnce } from "@/timeline/fetch-agent-timeline-once";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { planInitialAgentTimelineSync, planTimelineTailFetch } from "@/timeline/timeline-sync-plan";
import { i18n } from "@/i18n/i18next";
@@ -69,10 +69,12 @@ export function ensureAgentIsInitialized(input: EnsureAgentIsInitializedInput):
return deferred.promise;
}
fetchAgentTimelineOnce(client, agentId, timelineRequest).catch((error) => {
setAgentInitializing(agentId, false);
rejectInitDeferred(key, error instanceof Error ? error : new Error(String(error)));
});
getHostRuntimeStore()
.fetchAgentTimeline(serverId, agentId, timelineRequest)
.catch((error) => {
setAgentInitializing(agentId, false);
rejectInitDeferred(key, error instanceof Error ? error : new Error(String(error)));
});
return deferred.promise;
}
@@ -93,7 +95,11 @@ export async function refreshAgent(input: RefreshAgentInput): Promise<void> {
try {
await client.refreshAgent(agentId);
await client.fetchAgentTimeline(agentId, planTimelineTailFetch());
const serverId = Object.entries(useSessionStore.getState().sessions).find(
([, session]) => session.client === client,
)?.[0];
if (!serverId) throw new Error("Agent session is no longer connected");
await getHostRuntimeStore().fetchAgentTimeline(serverId, agentId, planTimelineTailFetch());
} catch (error) {
setAgentInitializing(agentId, false);
throw error;
@@ -4,6 +4,7 @@ import type { ToastApi } from "@/components/toast-host";
import { i18n } from "@/i18n/i18next";
import { useSessionStore, type AgentTimelineCursorState } from "@/stores/session-store";
import { planTimelineOlderFetch } from "@/timeline/timeline-sync-plan";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
export interface LoadOlderAgentHistoryClient {
fetchAgentTimeline: (
@@ -97,7 +98,12 @@ export function useLoadOlderAgentHistory({
const loadOlder = useCallback(() => {
const session = useSessionStore.getState().sessions[serverId];
void loadOlderAgentHistory(agentId, {
client: (session?.client ?? null) as LoadOlderAgentHistoryClient | null,
client: session?.client
? {
fetchAgentTimeline: (timelineAgentId, request) =>
getHostRuntimeStore().fetchAgentTimeline(serverId, timelineAgentId, request),
}
: null,
cursor: session?.agentTimelineCursor.get(agentId),
hasOlder: session?.agentTimelineHasOlder.get(agentId) === true,
isLoadingOlder: session?.agentTimelineOlderFetchInFlight.get(agentId) === true,
@@ -0,0 +1,79 @@
import { describe, expect, it } from "vitest";
import type { DaemonClient, FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client";
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
import { useSessionStore } from "@/stores/session-store";
import { AgentDirectoryReplica } from "./agent-replica";
function payload(title: string): AgentSnapshotPayload {
return {
id: "agent",
provider: "codex",
cwd: "/repo",
model: null,
createdAt: "2026-07-17T00:00:00.000Z",
updatedAt: "2026-07-17T00:01:00.000Z",
lastUserMessageAt: null,
status: "idle",
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: null,
title,
labels: {},
};
}
function entry(agent: AgentSnapshotPayload): FetchAgentsEntry {
return {
agent,
project: {
projectKey: "/repo",
projectName: "repo",
checkout: {
cwd: "/repo",
isGit: false,
currentBranch: null,
remoteUrl: null,
worktreeRoot: null,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
},
};
}
describe("AgentDirectoryReplica", () => {
it("keeps membership authoritative across remove, stale timeline, and re-add", () => {
const serverId = "agent-replica";
const store = useSessionStore.getState();
store.initializeSession(serverId, null as unknown as DaemonClient);
const replica = new AgentDirectoryReplica(serverId, () => undefined);
replica.commitSnapshot([entry(payload("directory"))], []);
const staleToken = replica.captureTimeline("agent");
replica.remove("agent");
expect(replica.submitTimelineAgent(staleToken, payload("stale"))).toBe(false);
expect(useSessionStore.getState().sessions[serverId]?.agents.has("agent")).toBe(false);
replica.applyDelta({
kind: "upsert",
agent: payload("re-added"),
project: entry(payload("x")).project,
});
expect(replica.submitTimelineAgent(staleToken, payload("still stale"))).toBe(false);
const currentToken = replica.captureTimeline("agent");
expect(replica.submitTimelineAgent(currentToken, payload("current"))).toBe(true);
expect(useSessionStore.getState().sessions[serverId]?.agents.get("agent")?.title).toBe(
"current",
);
store.clearSession(serverId);
});
});
@@ -0,0 +1,116 @@
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 { useSessionStore, type Agent } from "@/stores/session-store";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import {
applyAgentDirectoryDelta,
type AgentDirectoryDelta,
removeAgentDirectoryReplica,
replaceAgentPendingPermissions,
replaceFetchedAgentDirectory,
upsertAgentReplica,
} from "@/utils/agent-directory-sync";
import { reconcileAgentDirectory } from "@/utils/agent-directory-reconciliation";
import { applyLegacyDaemonWorkspaceOwnership } from "@/workspace/legacy-daemon-workspaces";
export interface AgentLifecycleToken {
readonly agentId: string;
readonly version: number;
}
export class AgentDirectoryReplica {
private readonly lifecycleVersions = new Map<string, number>();
private readonly members = new Set<string>();
constructor(
private readonly serverId: string,
private readonly onStoppedRunning: (agentId: string) => void,
) {}
captureTimeline(agentId: string): AgentLifecycleToken {
return { agentId, version: this.lifecycleVersions.get(agentId) ?? 0 };
}
submitTimelineAgent(token: AgentLifecycleToken, payload: AgentSnapshotPayload): boolean {
if (
!this.members.has(token.agentId) ||
token.version !== (this.lifecycleVersions.get(token.agentId) ?? 0)
) {
return false;
}
const normalized = applyLegacyDaemonWorkspaceOwnership({
serverId: this.serverId,
agent: normalizeAgentSnapshot(payload, this.serverId),
});
const accepted = upsertAgentReplica(this.serverId, normalized);
replaceAgentPendingPermissions(this.serverId, accepted);
useSessionStore.getState().setAgentLastActivity(accepted.id, accepted.lastActivityAt);
if (accepted.archivedAt) {
clearArchiveAgentPending({ queryClient, serverId: this.serverId, agentId: accepted.id });
}
return true;
}
applyDelta(delta: AgentDirectoryDelta): void {
const before = this.members.has(delta.kind === "remove" ? delta.agentId : delta.agent.id);
const result = applyAgentDirectoryDelta({ serverId: this.serverId, delta });
if (delta.kind === "remove") {
this.members.delete(delta.agentId);
this.advance(delta.agentId);
} else {
this.members.add(delta.agent.id);
if (!before) this.advance(delta.agent.id);
}
if (result.stoppedRunning) this.onStoppedRunning(result.agentId);
}
commitSnapshot(
entries: FetchAgentsEntry[],
deltas: readonly AgentDirectoryDelta[],
): Map<string, Agent> {
const previous = useSessionStore.getState().sessions[this.serverId]?.agents ?? new Map();
const reconciled = reconcileAgentDirectory({ previous, snapshot: entries, deltas });
const nextIds = new Set(reconciled.entries.map((entry) => entry.agent.id));
for (const agentId of this.members) {
if (!nextIds.has(agentId)) this.advance(agentId);
}
for (const agentId of nextIds) {
if (!this.members.has(agentId)) this.advance(agentId);
}
for (const agentId of previous.keys()) {
if (!nextIds.has(agentId)) removeAgentDirectoryReplica(this.serverId, agentId);
}
this.members.clear();
for (const agentId of nextIds) this.members.add(agentId);
const { agents } = replaceFetchedAgentDirectory({
serverId: this.serverId,
entries: reconciled.entries,
});
for (const agentId of reconciled.stoppedRunningAgentIds) this.onStoppedRunning(agentId);
return agents;
}
archive(agentId: string, archivedAt: string): void {
this.advance(agentId);
useSessionStore.getState().setAgents(this.serverId, (current) => {
const agent = current.get(agentId);
if (!agent) return current;
const next = new Map(current);
next.set(agentId, { ...agent, archivedAt: new Date(archivedAt) });
return next;
});
clearArchiveAgentPending({ queryClient, serverId: this.serverId, agentId });
}
remove(agentId: string): void {
this.members.delete(agentId);
this.advance(agentId);
removeAgentDirectoryReplica(this.serverId, agentId);
}
private advance(agentId: string): void {
this.lifecycleVersions.set(agentId, (this.lifecycleVersions.get(agentId) ?? 0) + 1);
}
}
@@ -0,0 +1,374 @@
import type {
DaemonClient,
FetchAgentsEntry,
FetchAgentsOptions,
} from "@getpaseo/client/internal/daemon-client";
import { fetchAgentTimelineOnce } from "@/timeline/fetch-agent-timeline-once";
import {
normalizeEmptyProjectDescriptor,
normalizeWorkspaceDescriptor,
useSessionStore,
type Agent,
} from "@/stores/session-store";
import {
readLegacyDaemonWorkspaceDirectory,
buildLegacyWorkspaces,
shouldUseLegacyDaemonWorkspaceDirectory,
stampLegacyWorkspaceIds,
} from "@/workspace/legacy-daemon-workspaces";
import type { AgentDirectoryDelta } from "@/utils/agent-directory-sync";
import { AgentDirectoryReplica } from "./agent-replica";
import {
WorkspaceDirectoryReplica,
type WorkspaceDirectoryDelta,
type WorkspaceDirectorySnapshot,
} from "./workspace-replica";
import {
DirectoryTransactionOwner,
type DirectorySourceToken,
type DirectoryTransaction,
} from "./transaction";
const PAGE_LIMIT = 200;
const AGENT_SORT: NonNullable<FetchAgentsOptions["sort"]> = [
{ key: "updated_at", direction: "desc" },
];
interface AgentSnapshot {
entries: FetchAgentsEntry[];
subscriptionId: string | null;
legacy: boolean;
}
export interface DirectoryConnection {
client: DaemonClient | null;
status: "online" | "offline";
source: DirectorySourceToken;
}
export interface RefreshAgentDirectoryInput {
filter?: FetchAgentsOptions["filter"];
subscribe?: FetchAgentsOptions["subscribe"];
page?: FetchAgentsOptions["page"];
}
export interface RefreshAgentDirectoryResult {
agents: Map<string, Agent>;
subscriptionId: string | null;
}
export class DirectorySync {
private readonly agentTransactions = new DirectoryTransactionOwner<
AgentSnapshot,
AgentDirectoryDelta
>();
private readonly workspaceTransactions = new DirectoryTransactionOwner<
WorkspaceDirectorySnapshot,
WorkspaceDirectoryDelta
>();
private readonly agents: AgentDirectoryReplica;
private readonly workspaces: WorkspaceDirectoryReplica;
private connection: DirectoryConnection = {
client: null,
status: "offline",
source: { clientGeneration: 0, connectionEpoch: 0 },
};
private unsubscribe: (() => void) | null = null;
constructor(
private readonly serverId: string,
private readonly callbacks: {
drainQueuedAgentMessage: (agentId: string) => void;
markAgentLoading: () => void;
markAgentReady: () => void;
markAgentError: (error: string) => void;
},
) {
this.agents = new AgentDirectoryReplica(serverId, callbacks.drainQueuedAgentMessage);
this.workspaces = new WorkspaceDirectoryReplica(serverId);
}
connectionChanged(connection: DirectoryConnection): void {
const changed =
this.connection.client !== connection.client ||
this.connection.source.clientGeneration !== connection.source.clientGeneration ||
this.connection.source.connectionEpoch !== connection.source.connectionEpoch;
const wentOffline = this.connection.status === "online" && connection.status === "offline";
if (!changed && !wentOffline) {
this.connection = connection;
return;
}
this.flushAbortedTransactions();
this.unsubscribe?.();
this.unsubscribe = null;
this.connection = connection;
if (!connection.client || connection.status !== "online") return;
const client = connection.client;
const source = connection.source;
const subscriptions = [
client.on("agent_update", (message) => {
if (message.type !== "agent_update" || !this.isCurrent(client, source)) return;
if (!this.agentTransactions.record(source, message.payload))
this.agents.applyDelta(message.payload);
}),
client.on("workspace_update", (message) => {
if (message.type !== "workspace_update" || !this.isCurrent(client, source)) return;
if (!this.workspaceTransactions.record(source, message.payload)) {
this.workspaces.applyDelta(message.payload);
}
}),
client.on("agent_deleted", (message) => {
if (message.type === "agent_deleted" && this.isCurrent(client, source)) {
this.agents.remove(message.payload.agentId);
}
}),
client.on("agent_archived", (message) => {
if (message.type === "agent_archived" && this.isCurrent(client, source)) {
this.agents.archive(message.payload.agentId, message.payload.archivedAt);
}
}),
];
this.unsubscribe = () => {
for (const unsubscribe of subscriptions) unsubscribe();
};
}
dispose(): void {
this.flushAbortedTransactions();
this.unsubscribe?.();
this.unsubscribe = null;
}
async fetchTimeline(
agentId: string,
request: Parameters<DaemonClient["fetchAgentTimeline"]>[1],
): Promise<Awaited<ReturnType<DaemonClient["fetchAgentTimeline"]>>> {
const { client } = this.requireOnline();
const token = this.agents.captureTimeline(agentId);
const page = await fetchAgentTimelineOnce(client, agentId, request);
if (page.agent) this.agents.submitTimelineAgent(token, page.agent);
return page;
}
async refreshAgents(
input: RefreshAgentDirectoryInput = {},
): Promise<RefreshAgentDirectoryResult> {
const { client, source } = this.requireOnline();
const transaction = this.agentTransactions.begin(source, () => ({
entries: [],
subscriptionId: null,
legacy: false,
}));
this.callbacks.markAgentLoading();
try {
await this.waitForSession(client, source);
const session = useSessionStore.getState().sessions[this.serverId];
if (!input.filter && shouldUseLegacyDaemonWorkspaceDirectory(session?.serverInfo)) {
const directory = await readLegacyDaemonWorkspaceDirectory({
client,
subscribe: input.subscribe,
page: input.page,
});
if (
!directory ||
!this.agentTransactions.isCurrent(transaction) ||
!this.isCurrent(client, source)
) {
throw new DirectoryRefreshSupersededError("legacy fetch no longer current");
}
transaction.snapshot.entries.push(...stampLegacyWorkspaceIds(directory.entries));
transaction.snapshot.subscriptionId = directory.subscriptionId;
transaction.snapshot.legacy = true;
} else {
await this.fetchAgents(client, source, transaction, input);
}
if (!this.isCurrent(client, source) || !this.hasMatchingSession(client, source)) {
throw new DirectoryRefreshSupersededError("agent completion no longer current");
}
const completion = this.agentTransactions.complete(transaction);
if (completion.kind === "stale") {
throw new DirectoryRefreshSupersededError("agent completion was superseded");
}
if (completion.snapshot.legacy) {
const store = useSessionStore.getState();
store.setWorkspaces(this.serverId, buildLegacyWorkspaces(completion.snapshot.entries));
store.setEmptyProjects(this.serverId, []);
store.setHasHydratedWorkspaces(this.serverId, true);
}
const deltas = completion.snapshot.legacy
? completion.deltas.map((delta) =>
delta.kind === "upsert"
? { ...delta, agent: { ...delta.agent, workspaceId: delta.agent.cwd } }
: delta,
)
: completion.deltas;
const agents = this.agents.commitSnapshot(completion.snapshot.entries, deltas);
this.callbacks.markAgentReady();
return { agents, subscriptionId: completion.snapshot.subscriptionId };
} catch (error) {
const deltas = this.agentTransactions.fail(transaction);
if (deltas) for (const delta of deltas) this.agents.applyDelta(delta);
if (!(error instanceof DirectoryRefreshSupersededError)) {
this.callbacks.markAgentError(error instanceof Error ? error.message : String(error));
}
throw error;
}
}
async refreshWorkspaces(input?: { subscribe?: boolean }): Promise<void> {
const serverInfo = useSessionStore.getState().sessions[this.serverId]?.serverInfo;
if (serverInfo?.features?.workspaceMultiplicity !== true) return;
const { client, source } = this.requireOnline();
const transaction = this.workspaceTransactions.begin(source, () => ({
workspaces: new Map(),
emptyProjects: new Map(),
}));
try {
await this.fetchWorkspaceSnapshot(client, source, transaction, input?.subscribe === true);
if (!this.isCurrent(client, source) || !this.hasMatchingSession(client, source)) {
throw new DirectoryRefreshSupersededError("workspace completion no longer current");
}
const completion = this.workspaceTransactions.complete(transaction);
if (completion.kind === "stale") {
throw new DirectoryRefreshSupersededError("workspace completion was superseded");
}
this.workspaces.commitSnapshot(completion.snapshot, completion.deltas);
} catch (error) {
const deltas = this.workspaceTransactions.fail(transaction);
if (deltas) for (const delta of deltas) this.workspaces.applyDelta(delta);
throw error;
}
}
private async fetchWorkspaceSnapshot(
client: DaemonClient,
source: DirectorySourceToken,
transaction: DirectoryTransaction<WorkspaceDirectorySnapshot, WorkspaceDirectoryDelta>,
initialSubscribe: boolean,
): Promise<void> {
let cursor: string | null = null;
let subscribe = initialSubscribe;
while (true) {
const payload = await client.fetchWorkspaces({
sort: [{ key: "activity_at", direction: "desc" }],
...(subscribe ? { subscribe: {} } : {}),
page: cursor ? { limit: PAGE_LIMIT, cursor } : { limit: PAGE_LIMIT },
});
this.assertWorkspaceTransactionCurrent(client, source, transaction);
for (const entry of payload.entries) {
const workspace = normalizeWorkspaceDescriptor(entry);
transaction.snapshot.workspaces.set(workspace.id, workspace);
}
for (const entry of payload.emptyProjects ?? []) {
const project = normalizeEmptyProjectDescriptor(entry);
transaction.snapshot.emptyProjects.set(project.projectId, project);
}
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) return;
cursor = payload.pageInfo.nextCursor;
subscribe = false;
}
}
async refreshAll(): Promise<void> {
await Promise.all([this.refreshAgents(), this.refreshWorkspaces({ subscribe: true })]);
}
private async fetchAgents(
client: DaemonClient,
source: DirectorySourceToken,
transaction: DirectoryTransaction<AgentSnapshot, AgentDirectoryDelta>,
input: RefreshAgentDirectoryInput,
): Promise<void> {
let cursor = input.page?.cursor ?? null;
let subscribe = input.subscribe;
while (true) {
const limit = input.page?.limit ?? PAGE_LIMIT;
const payload = await client.fetchAgents({
...(input.filter ? { filter: input.filter } : { scope: "active" as const }),
sort: AGENT_SORT,
...(subscribe ? { subscribe } : {}),
page: cursor ? { limit, cursor } : { limit },
});
this.assertAgentTransactionCurrent(client, source, transaction);
transaction.snapshot.entries.push(...payload.entries);
transaction.snapshot.subscriptionId ??= payload.subscriptionId ?? null;
const pageInfo = payload.pageInfo as {
hasMore?: boolean;
hasMoreAfter?: boolean;
nextCursor?: string | null;
afterCursor?: string | null;
};
const hasMore = pageInfo.hasMore ?? pageInfo.hasMoreAfter ?? false;
const nextCursor = pageInfo.nextCursor ?? pageInfo.afterCursor ?? null;
if (!hasMore || !nextCursor) break;
cursor = nextCursor;
subscribe = undefined;
}
}
private assertAgentTransactionCurrent(
client: DaemonClient,
source: DirectorySourceToken,
transaction: DirectoryTransaction<AgentSnapshot, AgentDirectoryDelta>,
): void {
if (!this.agentTransactions.isCurrent(transaction) || !this.isCurrent(client, source)) {
throw new DirectoryRefreshSupersededError("agent page no longer current");
}
}
private assertWorkspaceTransactionCurrent(
client: DaemonClient,
source: DirectorySourceToken,
transaction: DirectoryTransaction<WorkspaceDirectorySnapshot, WorkspaceDirectoryDelta>,
): void {
if (!this.workspaceTransactions.isCurrent(transaction) || !this.isCurrent(client, source)) {
throw new DirectoryRefreshSupersededError("workspace fetch no longer current");
}
}
private requireOnline(): { client: DaemonClient; source: DirectorySourceToken } {
if (!this.connection.client || this.connection.status !== "online") {
throw new Error(`Host ${this.serverId} is not connected`);
}
return { client: this.connection.client, source: this.connection.source };
}
private isCurrent(client: DaemonClient, source: DirectorySourceToken): boolean {
return (
this.connection.client === client &&
this.connection.status === "online" &&
this.connection.source.clientGeneration === source.clientGeneration &&
this.connection.source.connectionEpoch === source.connectionEpoch
);
}
private async waitForSession(client: DaemonClient, source: DirectorySourceToken): Promise<void> {
const matches = () => this.hasMatchingSession(client, source);
if (matches()) return;
await new Promise<void>((resolve, reject) => {
const unsubscribe = useSessionStore.subscribe(() => {
if (matches()) {
unsubscribe();
resolve();
} else if (!this.isCurrent(client, source)) {
unsubscribe();
reject(new DirectoryRefreshSupersededError("session wait no longer current"));
}
});
});
}
private hasMatchingSession(client: DaemonClient, source: DirectorySourceToken): boolean {
const session = useSessionStore.getState().sessions[this.serverId];
return session?.client === client && session.clientGeneration === source.clientGeneration;
}
private flushAbortedTransactions(): void {
for (const delta of this.agentTransactions.abort()) this.agents.applyDelta(delta);
for (const delta of this.workspaceTransactions.abort()) this.workspaces.applyDelta(delta);
}
}
export class DirectoryRefreshSupersededError extends Error {}
export type { DirectorySourceToken } from "./transaction";
@@ -0,0 +1,100 @@
import { describe, expect, it } from "vitest";
import { DirectoryTransactionOwner, type DirectorySourceToken } from "./transaction";
const epoch = (connectionEpoch: number): DirectorySourceToken => ({
clientGeneration: 1,
connectionEpoch,
});
type Event = "begin-new" | "first" | "second" | "finish-old" | "finish-new";
function permutations(events: Event[]): Event[][] {
if (events.length === 0) return [[]];
return events.flatMap((event, index) =>
permutations(events.filter((_, candidate) => candidate !== index)).map((tail) =>
[event].concat(tail),
),
);
}
describe("DirectoryTransactionOwner", () => {
it("applies every bounded two-refresh interleaving exactly once", () => {
const schedules = permutations([
"begin-new",
"first",
"second",
"finish-old",
"finish-new",
]).filter((events) => events.indexOf("begin-new") < events.indexOf("finish-new"));
expect(schedules).toHaveLength(60);
for (const events of schedules) {
const owner = new DirectoryTransactionOwner<null, string>();
const old = owner.begin(epoch(1), () => null);
let current = old;
const applied: string[] = [];
for (const event of events) {
if (event === "begin-new") current = owner.begin(epoch(1), () => null);
if (event === "first" || event === "second") {
if (!owner.record(epoch(1), event)) applied.push(event);
}
if (event === "finish-old") {
const result = owner.complete(old);
if (result.kind === "current") applied.push(...result.deltas);
}
if (event === "finish-new") {
const result = owner.complete(current);
if (result.kind === "current") applied.push(...result.deltas);
}
}
expect(applied.sort(), JSON.stringify(events)).toEqual(["first", "second"]);
}
});
it.each([
["success", false, false, ["delta"]],
["failure", true, false, ["delta"]],
["superseded success", false, true, ["delta"]],
["superseded failure", true, true, ["delta"]],
] as const)("preserves buffered deltas through %s", (_name, fail, supersede, expected) => {
const owner = new DirectoryTransactionOwner<string[], string>();
const first = owner.begin(epoch(1), () => []);
owner.record(epoch(1), "delta");
const current = supersede ? owner.begin(epoch(1), () => []) : first;
let deltas: readonly string[] | null;
if (fail) {
deltas = owner.fail(current);
} else {
const completion = owner.complete(current);
deltas = completion.kind === "current" ? completion.deltas : null;
}
expect(deltas).toEqual(expected);
if (supersede) expect(owner.complete(first)).toEqual({ kind: "stale" });
});
it("makes a stale completion inert", () => {
const owner = new DirectoryTransactionOwner<null, string>();
const first = owner.begin(epoch(1), () => null);
const second = owner.begin(epoch(1), () => null);
expect(owner.complete(first)).toEqual({ kind: "stale" });
expect(owner.isCurrent(second)).toBe(true);
});
it("isolates reconnect epochs", () => {
const owner = new DirectoryTransactionOwner<null, string>();
const disconnected = owner.begin(epoch(1), () => null);
owner.record(epoch(1), "old");
expect(owner.abort()).toEqual(["old"]);
const reconnected = owner.begin(epoch(2), () => null);
owner.record(epoch(2), "new");
expect(owner.complete(disconnected)).toEqual({ kind: "stale" });
expect(owner.complete(reconnected)).toEqual({
kind: "current",
snapshot: null,
deltas: ["new"],
});
});
});
@@ -0,0 +1,70 @@
export interface DirectorySourceToken {
clientGeneration: number;
connectionEpoch: number;
}
export interface DirectoryTransaction<TSnapshot, TDelta> {
readonly source: DirectorySourceToken;
readonly snapshot: TSnapshot;
readonly deltas: TDelta[];
}
export type DirectoryTransactionCompletion<TSnapshot, TDelta> =
| { kind: "stale" }
| { kind: "current"; snapshot: TSnapshot; deltas: readonly TDelta[] };
function isSameSource(left: DirectorySourceToken, right: DirectorySourceToken): boolean {
return (
left.clientGeneration === right.clientGeneration &&
left.connectionEpoch === right.connectionEpoch
);
}
/** Owns the shared begin/record/supersede/complete lifecycle for a directory replica. */
export class DirectoryTransactionOwner<TSnapshot, TDelta> {
private current: DirectoryTransaction<TSnapshot, TDelta> | null = null;
begin(
source: DirectorySourceToken,
createSnapshot: () => TSnapshot,
): DirectoryTransaction<TSnapshot, TDelta> {
const transaction: DirectoryTransaction<TSnapshot, TDelta> = {
source,
snapshot: createSnapshot(),
deltas:
this.current && isSameSource(this.current.source, source) ? [...this.current.deltas] : [],
};
this.current = transaction;
return transaction;
}
record(source: DirectorySourceToken, delta: TDelta): boolean {
if (!this.current || !isSameSource(this.current.source, source)) return false;
this.current.deltas.push(delta);
return true;
}
isCurrent(transaction: DirectoryTransaction<TSnapshot, TDelta>): boolean {
return this.current === transaction;
}
complete(
transaction: DirectoryTransaction<TSnapshot, TDelta>,
): DirectoryTransactionCompletion<TSnapshot, TDelta> {
if (this.current !== transaction) return { kind: "stale" };
this.current = null;
return { kind: "current", snapshot: transaction.snapshot, deltas: transaction.deltas };
}
fail(transaction: DirectoryTransaction<TSnapshot, TDelta>): readonly TDelta[] | null {
const completion = this.complete(transaction);
return completion.kind === "current" ? completion.deltas : null;
}
abort(): readonly TDelta[] {
if (!this.current) return [];
const deltas = [...this.current.deltas];
this.current = null;
return deltas;
}
}
@@ -0,0 +1,57 @@
import { expect, it } from "vitest";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages";
import {
normalizeEmptyProjectDescriptor,
normalizeWorkspaceDescriptor,
useSessionStore,
} from "@/stores/session-store";
import { WorkspaceDirectoryReplica } from "./workspace-replica";
function workspace(id: string, projectId = "project"): WorkspaceDescriptorPayload {
return {
id,
projectId,
projectDisplayName: projectId,
projectRootPath: `/repo/${projectId}`,
workspaceDirectory: `/repo/${projectId}/${id}`,
projectKind: "git",
workspaceKind: "worktree",
name: id,
title: id,
status: "done",
activityAt: null,
statusEnteredAt: null,
archivingAt: null,
diffStat: null,
scripts: [],
};
}
it("commits workspace and project-parent state with filtered removals", () => {
const serverId = "workspace-replica";
const store = useSessionStore.getState();
store.initializeSession(serverId, null as unknown as DaemonClient);
const replica = new WorkspaceDirectoryReplica(serverId);
const empty = normalizeEmptyProjectDescriptor({
projectId: "empty",
projectDisplayName: "Empty",
projectRootPath: "/repo/empty",
projectKind: "git",
});
replica.commitSnapshot(
{
workspaces: new Map([
["kept", normalizeWorkspaceDescriptor(workspace("kept"))],
["filtered", normalizeWorkspaceDescriptor(workspace("filtered", "filtered-project"))],
]),
emptyProjects: new Map([[empty.projectId, empty]]),
},
[{ kind: "remove", id: "filtered", removedProjectId: "filtered-project" }],
);
const session = useSessionStore.getState().sessions[serverId];
expect(Array.from(session?.workspaces.keys() ?? [])).toEqual(["kept"]);
expect(Array.from(session?.emptyProjects.keys() ?? [])).toEqual(["empty"]);
store.clearSession(serverId);
});
@@ -0,0 +1,93 @@
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
import {
normalizeEmptyProjectDescriptor,
normalizeWorkspaceDescriptor,
useSessionStore,
type EmptyProjectDescriptor,
type WorkspaceDescriptor,
} from "@/stores/session-store";
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
import {
clearWorkspaceArchivePending,
shouldSuppressWorkspaceForLocalArchive,
} from "@/contexts/session-workspace-upserts";
export type WorkspaceDirectoryDelta = Extract<
SessionOutboundMessage,
{ type: "workspace_update" }
>["payload"];
export interface WorkspaceDirectorySnapshot {
workspaces: Map<string, WorkspaceDescriptor>;
emptyProjects: Map<string, EmptyProjectDescriptor>;
}
export class WorkspaceDirectoryReplica {
constructor(private readonly serverId: string) {}
applyDelta(delta: WorkspaceDirectoryDelta): void {
const state = this.reconcile(this.read(), [delta]);
this.commit(state, delta.kind === "remove" ? [delta.id] : []);
}
commitSnapshot(
snapshot: WorkspaceDirectorySnapshot,
deltas: readonly WorkspaceDirectoryDelta[],
): void {
const removedWorkspaceIds = deltas.flatMap((delta) =>
delta.kind === "remove" ? [delta.id] : [],
);
this.commit(this.reconcile(snapshot, deltas), removedWorkspaceIds);
}
private read(): WorkspaceDirectorySnapshot {
const session = useSessionStore.getState().sessions[this.serverId];
return {
workspaces: new Map(session?.workspaces),
emptyProjects: new Map(session?.emptyProjects),
};
}
private reconcile(
snapshot: WorkspaceDirectorySnapshot,
deltas: readonly WorkspaceDirectoryDelta[],
): WorkspaceDirectorySnapshot {
const workspaces = new Map(snapshot.workspaces);
const emptyProjects = new Map(snapshot.emptyProjects);
for (const [workspaceId, workspace] of workspaces) {
if (shouldSuppressWorkspaceForLocalArchive({ serverId: this.serverId, workspace })) {
workspaces.delete(workspaceId);
}
}
for (const delta of deltas) {
if (delta.kind === "remove") {
workspaces.delete(delta.id);
if (delta.emptyProject) {
const project = normalizeEmptyProjectDescriptor(delta.emptyProject);
emptyProjects.set(project.projectId, project);
}
if (delta.removedProjectId) emptyProjects.delete(delta.removedProjectId);
continue;
}
const workspace = normalizeWorkspaceDescriptor(delta.workspace);
if (shouldSuppressWorkspaceForLocalArchive({ serverId: this.serverId, workspace })) {
workspaces.delete(workspace.id);
} else {
workspaces.set(workspace.id, workspace);
emptyProjects.delete(workspace.projectId);
}
}
return { workspaces, emptyProjects };
}
private commit(snapshot: WorkspaceDirectorySnapshot, removedWorkspaceIds: string[]): void {
const store = useSessionStore.getState();
store.setWorkspaces(this.serverId, snapshot.workspaces);
store.setEmptyProjects(this.serverId, snapshot.emptyProjects.values());
store.setHasHydratedWorkspaces(this.serverId, true);
for (const workspaceId of removedWorkspaceIds) {
clearWorkspaceArchivePending({ serverId: this.serverId, workspaceId });
useWorkspaceSetupStore.getState().removeWorkspace({ serverId: this.serverId, workspaceId });
}
}
}
+70 -8
View File
@@ -35,6 +35,7 @@ class FakeDaemonClient {
> = [];
public sentAgentMessages: Array<Parameters<DaemonClient["sendAgentMessage"]>> = [];
public sendAgentMessageFailures: Error[] = [];
public sendAgentMessageResponses: Promise<void>[] = [];
private agentUpdateListeners = new Set<
(message: Extract<SessionOutboundMessage, { type: "agent_update" }>) => void
>();
@@ -81,6 +82,8 @@ class FakeDaemonClient {
async sendAgentMessage(...args: Parameters<DaemonClient["sendAgentMessage"]>): Promise<void> {
this.sentAgentMessages.push(args);
for (const waiter of this.sentMessageWaiters) waiter();
const response = this.sendAgentMessageResponses.shift();
if (response) await response;
const failure = this.sendAgentMessageFailures.shift();
if (failure) throw failure;
}
@@ -630,7 +633,6 @@ describe("HostRuntimeController", () => {
});
await controller.start({ autoProbe: false });
const snapshot = controller.getSnapshot();
expect(snapshot.activeConnectionId).toBe("direct:lan:6767");
expect(snapshot.connectionStatus).toBe("online");
@@ -1111,6 +1113,7 @@ describe("HostRuntimeController", () => {
});
await controller.start({ autoProbe: false });
const firstEpoch = controller.getSnapshot().connectionEpoch;
controller.markAgentDirectorySyncReady();
expect(controller.getSnapshot().agentDirectoryStatus).toBe("ready");
expect(controller.getSnapshot().hasEverLoadedAgentDirectory).toBe(true);
@@ -1125,6 +1128,7 @@ describe("HostRuntimeController", () => {
clients[0]?.setConnectionState({ status: "connected" });
expect(controller.getSnapshot().connectionStatus).toBe("online");
expect(controller.getSnapshot().agentDirectoryStatus).toBe("ready");
expect(controller.getSnapshot().connectionEpoch).toBe(firstEpoch + 1);
});
it("stores directory sync errors as non-blocking after a successful directory load", async () => {
@@ -1577,7 +1581,10 @@ describe("HostRuntimeStore", () => {
});
it("drains legacy snapshot and buffered running transitions exactly once", async () => {
const host = makeHost({ serverId: "srv_legacy_transitions" });
const host = makeHost({
serverId: "srv_legacy_transitions",
connections: [{ id: "direct:lan:6767", type: "directTcp", endpoint: "lan:6767" }],
});
const fakeClient = new FakeDaemonClient();
fakeClient.setConnectionState({ status: "connected" });
const pageTwo = new Deferred<Awaited<ReturnType<DaemonClient["fetchAgents"]>>>();
@@ -1757,7 +1764,10 @@ describe("HostRuntimeStore", () => {
});
it("replays agent updates received while a later bootstrap page is loading", async () => {
const host = makeHost({ serverId: "srv_paged_delta" });
const host = makeHost({
serverId: "srv_paged_delta",
connections: [{ id: "direct:lan:6767", type: "directTcp", endpoint: "lan:6767" }],
});
const fakeClient = new FakeDaemonClient();
fakeClient.setConnectionState({ status: "connected" });
let finishPageTwo!: (payload: Awaited<ReturnType<DaemonClient["fetchAgents"]>>) => void;
@@ -1909,7 +1919,10 @@ describe("HostRuntimeStore", () => {
});
it("rejects a superseded refresh without overwriting the newer replica", async () => {
const host = makeHost({ serverId: "srv_overlap" });
const host = makeHost({
serverId: "srv_overlap",
connections: [{ id: "direct:lan:6767", type: "directTcp", endpoint: "lan:6767" }],
});
const fakeClient = new FakeDaemonClient();
fakeClient.setConnectionState({ status: "connected" });
fakeClient.fetchAgentsResponses.push(makeFetchAgentsPayload({ entries: [] }));
@@ -1974,7 +1987,10 @@ describe("HostRuntimeStore", () => {
});
it("replays inherited deltas when a superseding refresh fails", async () => {
const host = makeHost({ serverId: "srv_overlap_failure" });
const host = makeHost({
serverId: "srv_overlap_failure",
connections: [{ id: "direct:lan:6767", type: "directTcp", endpoint: "lan:6767" }],
});
const fakeClient = new FakeDaemonClient();
fakeClient.setConnectionState({ status: "connected" });
fakeClient.fetchAgentsResponses.push(makeFetchAgentsPayload({ entries: [] }));
@@ -2026,7 +2042,10 @@ describe("HostRuntimeStore", () => {
});
it("rejects a refresh when the session generation changes before commit", async () => {
const host = makeHost({ serverId: "srv_stale_generation" });
const host = makeHost({
serverId: "srv_stale_generation",
connections: [{ id: "direct:lan:6767", type: "directTcp", endpoint: "lan:6767" }],
});
const fakeClient = new FakeDaemonClient();
fakeClient.setConnectionState({ status: "connected" });
const existingEntry = makeFetchAgentsEntry({
@@ -2079,7 +2098,10 @@ describe("HostRuntimeStore", () => {
});
it("drains queued messages once for snapshot and buffered running transitions", async () => {
const host = makeHost({ serverId: "srv_queued_transitions" });
const host = makeHost({
serverId: "srv_queued_transitions",
connections: [{ id: "direct:lan:6767", type: "directTcp", endpoint: "lan:6767" }],
});
const fakeClient = new FakeDaemonClient();
fakeClient.setConnectionState({ status: "connected" });
const pageTwo = new Deferred<Awaited<ReturnType<DaemonClient["fetchAgents"]>>>();
@@ -2211,6 +2233,43 @@ describe("HostRuntimeStore", () => {
useSessionStore.getState().clearSession(host.serverId);
});
it("serializes queued-message drains for the same agent", async () => {
const host = makeHost({ serverId: "srv_serialized_queue_drain" });
const fakeClient = new FakeDaemonClient();
const send = new Deferred<void>();
fakeClient.sendAgentMessageResponses.push(send.promise);
const store = new HostRuntimeStore({
deps: {
createClient: () => fakeClient as unknown as DaemonClient,
connectToDaemon: async () => ({
client: fakeClient as unknown as DaemonClient,
serverId: host.serverId,
hostname: null,
}),
getClientId: async () => "cid_serialized_queue_drain",
},
});
const sessionStore = useSessionStore.getState();
sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
sessionStore.setQueuedMessages(
host.serverId,
new Map([["agent", [{ id: "first", text: "send once", attachments: [] }]]]),
);
store.drainQueuedAgentMessage(host.serverId, "agent");
store.drainQueuedAgentMessage(host.serverId, "agent");
await fakeClient.waitForSentMessages(1);
expect(fakeClient.sentAgentMessages).toHaveLength(1);
send.resolve();
await vi.waitFor(() => {
expect(
useSessionStore.getState().sessions[host.serverId]?.queuedMessages.get("agent"),
).toEqual([]);
});
useSessionStore.getState().clearSession(host.serverId);
});
it("uses legacy GitHub attachments when draining a queue for an old daemon", async () => {
const host = makeHost({ serverId: "srv_legacy_queue_attachment" });
const fakeClient = new FakeDaemonClient();
@@ -2283,7 +2342,10 @@ describe("HostRuntimeStore", () => {
});
it("applies buffered stale side effects from the accepted page agent", async () => {
const host = makeHost({ serverId: "srv_buffered_stale_side_effects" });
const host = makeHost({
serverId: "srv_buffered_stale_side_effects",
connections: [{ id: "direct:lan:6767", type: "directTcp", endpoint: "lan:6767" }],
});
const fakeClient = new FakeDaemonClient();
fakeClient.setConnectionState({ status: "connected" });
const pageTwo = new Deferred<Awaited<ReturnType<DaemonClient["fetchAgents"]>>>();
+119 -396
View File
@@ -4,7 +4,6 @@ import equal from "fast-deep-equal/es6";
import {
DaemonClient,
type ConnectionState,
type FetchAgentsEntry,
type FetchAgentsOptions,
} from "@getpaseo/client/internal/daemon-client";
import {
@@ -40,19 +39,7 @@ import {
import { getDesktopHost } from "@/desktop/host";
import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities";
import { BROWSER_AUTOMATION_COMMAND_NAMES } from "@getpaseo/protocol/browser-automation/rpc-schemas";
import {
applyAgentDirectoryDelta,
replaceFetchedAgentDirectory,
type AgentDirectoryDelta,
} from "@/utils/agent-directory-sync";
import { reconcileAgentDirectory } from "@/utils/agent-directory-reconciliation";
import { useSessionStore, type Agent } from "@/stores/session-store";
import {
readLegacyDaemonWorkspaceDirectory,
replaceLegacyDaemonWorkspaceDirectory,
shouldUseLegacyDaemonWorkspaceDirectory,
stampLegacyWorkspaceIds,
} from "@/workspace/legacy-daemon-workspaces";
import { useSessionStore } from "@/stores/session-store";
import { invalidateCheckoutGitQueriesForServer } from "@/git/query-keys";
import { queryClient } from "@/data/query-client";
import {
@@ -67,7 +54,7 @@ import {
splitComposerAttachmentsForSubmit,
} from "@/composer/attachments/submit";
import { encodeImages } from "@/utils/encode-images";
import { inheritBufferedDirectoryDeltas } from "@/utils/buffered-directory-transaction";
import { DirectorySync, type RefreshAgentDirectoryResult } from "@/runtime/directory-sync";
export type HostRuntimeConnectionStatus = "idle" | "connecting" | "online" | "offline" | "error";
export type HostRegistryStatus = "loading" | "ready";
@@ -99,6 +86,7 @@ export interface HostRuntimeSnapshot {
hasEverLoadedAgentDirectory: boolean;
probeByConnectionId: Map<string, ConnectionProbeState>;
clientGeneration: number;
connectionEpoch: number;
}
type HostRuntimeSnapshotPatch = Partial<Omit<HostRuntimeSnapshot, "serverId" | "clientGeneration">>;
@@ -190,97 +178,6 @@ const ADAPTIVE_SWITCH_CONSECUTIVE_PROBES = 3;
const DEFAULT_AGENT_DIRECTORY_PAGE_LIMIT = 200;
const CONFIGURED_OVERRIDE_BOOTSTRAP_RETRY_MS = 1_000;
const DEFAULT_AGENT_DIRECTORY_SORT: NonNullable<FetchAgentsOptions["sort"]> = [
{ key: "updated_at", direction: "desc" },
];
function readFetchAgentsHasMore(
pageInfo: Awaited<ReturnType<DaemonClient["fetchAgents"]>>["pageInfo"],
): boolean {
const page = pageInfo as {
hasMore?: boolean;
hasMoreAfter?: boolean;
};
if (typeof page.hasMore === "boolean") {
return page.hasMore;
}
if (typeof page.hasMoreAfter === "boolean") {
return page.hasMoreAfter;
}
return false;
}
function readFetchAgentsNextCursor(
pageInfo: Awaited<ReturnType<DaemonClient["fetchAgents"]>>["pageInfo"],
): string | null {
const page = pageInfo as {
nextCursor?: string | null;
afterCursor?: string | null;
};
if (typeof page.nextCursor === "string" && page.nextCursor.length > 0) {
return page.nextCursor;
}
if (typeof page.afterCursor === "string" && page.afterCursor.length > 0) {
return page.afterCursor;
}
return null;
}
interface AgentDirectoryFetchInput {
client: DaemonClient;
filter?: FetchAgentsOptions["filter"];
subscribe?: FetchAgentsOptions["subscribe"];
page?: FetchAgentsOptions["page"];
transaction: AgentDirectoryTransaction;
isCurrent: () => boolean;
}
interface AgentDirectoryFetchResult {
entries: FetchAgentsEntry[];
deltas: readonly AgentDirectoryDelta[];
subscriptionId: string | null;
}
async function fetchCurrentAgentDirectory(
input: AgentDirectoryFetchInput,
): Promise<AgentDirectoryFetchResult> {
const pageLimit = input.page?.limit ?? DEFAULT_AGENT_DIRECTORY_PAGE_LIMIT;
let cursor = input.page?.cursor ?? null;
let includeSubscribe = true;
let subscriptionId: string | null = null;
while (true) {
const payload = await input.client.fetchAgents({
scope: input.filter ? undefined : "active",
...(input.filter ? { filter: input.filter } : {}),
sort: DEFAULT_AGENT_DIRECTORY_SORT,
...(includeSubscribe && input.subscribe ? { subscribe: input.subscribe } : {}),
page: cursor ? { limit: pageLimit, cursor } : { limit: pageLimit },
});
if (!input.isCurrent()) throw new AgentDirectoryRefreshSupersededError();
input.transaction.entries.push(...payload.entries);
subscriptionId = subscriptionId ?? payload.subscriptionId ?? null;
includeSubscribe = false;
if (!readFetchAgentsHasMore(payload.pageInfo)) {
break;
}
const nextCursor = readFetchAgentsNextCursor(payload.pageInfo);
if (!nextCursor) {
break;
}
cursor = nextCursor;
}
return {
entries: input.transaction.entries,
deltas: input.transaction.deltas,
subscriptionId,
};
}
function toActiveConnection(connection: HostConnection): ActiveConnection {
if (connection.type === "directSocket") {
return {
@@ -466,9 +363,15 @@ function nextConnectionMachineState(input: {
function toSnapshotConnectionPatch(
state: HostRuntimeConnectionMachineState,
connectionEpoch: number,
): Pick<
HostRuntimeSnapshot,
"activeConnectionId" | "activeConnection" | "connectionStatus" | "lastError" | "lastOnlineAt"
| "activeConnectionId"
| "activeConnection"
| "connectionStatus"
| "lastError"
| "lastOnlineAt"
| "connectionEpoch"
> {
if (state.tag === "booting") {
return {
@@ -477,6 +380,7 @@ function toSnapshotConnectionPatch(
connectionStatus: "connecting",
lastError: null,
lastOnlineAt: null,
connectionEpoch,
};
}
if (state.tag === "connecting") {
@@ -486,6 +390,7 @@ function toSnapshotConnectionPatch(
connectionStatus: "connecting",
lastError: null,
lastOnlineAt: null,
connectionEpoch,
};
}
if (state.tag === "online") {
@@ -495,6 +400,7 @@ function toSnapshotConnectionPatch(
connectionStatus: "online",
lastError: null,
lastOnlineAt: state.lastOnlineAt,
connectionEpoch,
};
}
if (state.tag === "offline") {
@@ -504,6 +410,7 @@ function toSnapshotConnectionPatch(
connectionStatus: "offline",
lastError: null,
lastOnlineAt: null,
connectionEpoch,
};
}
return {
@@ -512,6 +419,7 @@ function toSnapshotConnectionPatch(
connectionStatus: "error",
lastError: state.message,
lastOnlineAt: null,
connectionEpoch,
};
}
@@ -639,6 +547,7 @@ export class HostRuntimeController {
private deps: HostRuntimeControllerDeps;
private onReconcileServerId: ((oldId: string, newId: string) => void) | null;
private connectionMachineState: HostRuntimeConnectionMachineState;
private connectionEpoch = 0;
private snapshot: HostRuntimeSnapshot;
private listeners = new Set<() => void>();
private activeClient: DaemonClient | null = null;
@@ -669,7 +578,7 @@ export class HostRuntimeController {
};
this.snapshot = {
serverId: this.host.serverId,
...toSnapshotConnectionPatch(this.connectionMachineState),
...toSnapshotConnectionPatch(this.connectionMachineState, this.connectionEpoch),
client: null,
agentDirectoryStatus: "idle",
agentDirectoryError: null,
@@ -737,7 +646,7 @@ export class HostRuntimeController {
}
this.applyConnectionEvent({ type: "stopped" });
this.updateSnapshot({
...toSnapshotConnectionPatch(this.connectionMachineState),
...toSnapshotConnectionPatch(this.connectionMachineState, this.connectionEpoch),
client: null,
});
}
@@ -801,7 +710,7 @@ export class HostRuntimeController {
markStartupError(message: string): void {
this.applyConnectionEvent({ type: "connect_failed", message });
this.updateSnapshot({
...toSnapshotConnectionPatch(this.connectionMachineState),
...toSnapshotConnectionPatch(this.connectionMachineState, this.connectionEpoch),
});
}
@@ -834,7 +743,7 @@ export class HostRuntimeController {
}
this.applyConnectionEvent({ type: "no_connections" });
this.updateSnapshot({
...toSnapshotConnectionPatch(this.connectionMachineState),
...toSnapshotConnectionPatch(this.connectionMachineState, this.connectionEpoch),
probeByConnectionId: new Map(),
});
return;
@@ -1120,6 +1029,9 @@ export class HostRuntimeController {
state: previousState,
event,
});
if (previousState.tag !== "online" && nextState.tag === "online") {
this.connectionEpoch += 1;
}
this.connectionMachineState = nextState;
this.logConnectionTransition({
from: previousState.tag,
@@ -1196,7 +1108,7 @@ export class HostRuntimeController {
message: `Failed to resolve client id: ${message}`,
});
this.updateSnapshot({
...toSnapshotConnectionPatch(this.connectionMachineState),
...toSnapshotConnectionPatch(this.connectionMachineState, this.connectionEpoch),
});
return null;
}
@@ -1294,7 +1206,7 @@ export class HostRuntimeController {
this.snapshot = {
...this.snapshot,
serverId: this.host.serverId,
...toSnapshotConnectionPatch(this.connectionMachineState),
...toSnapshotConnectionPatch(this.connectionMachineState, this.connectionEpoch),
client,
clientGeneration: nextGeneration,
};
@@ -1312,7 +1224,7 @@ export class HostRuntimeController {
lastError: client.lastError,
});
const patch: HostRuntimeSnapshotPatch = {
...toSnapshotConnectionPatch(this.connectionMachineState),
...toSnapshotConnectionPatch(this.connectionMachineState, this.connectionEpoch),
...this.buildAgentDirectoryStatusPatch(),
};
this.updateSnapshot(patch);
@@ -1332,7 +1244,7 @@ export class HostRuntimeController {
message,
});
this.updateSnapshot({
...toSnapshotConnectionPatch(this.connectionMachineState),
...toSnapshotConnectionPatch(this.connectionMachineState, this.connectionEpoch),
});
}
}
@@ -1418,14 +1330,6 @@ function rekeyMap<V>(map: Map<string, V>, oldKey: string, newKey: string): void
map.set(newKey, value);
}
interface AgentDirectoryTransaction {
id: symbol;
client: DaemonClient;
clientGeneration: number;
entries: FetchAgentsEntry[];
deltas: AgentDirectoryDelta[];
}
interface AgentDirectoryRefreshInput {
serverId: string;
filter?: FetchAgentsOptions["filter"];
@@ -1433,13 +1337,6 @@ interface AgentDirectoryRefreshInput {
page?: FetchAgentsOptions["page"];
}
interface AgentDirectoryRefreshResult {
agents: ReturnType<typeof replaceFetchedAgentDirectory>["agents"];
subscriptionId: string | null;
}
class AgentDirectoryRefreshSupersededError extends Error {}
export class HostRuntimeStore {
private controllers = new Map<string, HostRuntimeController>();
private serverListeners = new Map<string, Set<() => void>>();
@@ -1453,11 +1350,8 @@ export class HostRuntimeStore {
private deps: HostRuntimeControllerDeps;
private lastConnectionStatusByServer = new Map<string, HostRuntimeConnectionStatus>();
private agentDirectoryBootstrapInFlight = new Map<string, Promise<void>>();
private agentDirectoryTransactions = new Map<string, AgentDirectoryTransaction>();
private agentDirectoryListeners = new Map<
string,
{ client: DaemonClient; generation: number; unsubscribe: () => void }
>();
private queuedAgentDrainInFlight = new Set<string>();
private directorySyncByServer = new Map<string, DirectorySync>();
private configuredOverrideBootstrapInFlight: Promise<void> | null = null;
private bootStarted = false;
private storage: HostRuntimeStorage;
@@ -1688,6 +1582,27 @@ export class HostRuntimeStore {
rekeyMap(this.lastConnectionStatusByServer, oldServerId, newServerId);
rekeyMap(this.agentDirectoryBootstrapInFlight, oldServerId, newServerId);
this.directorySyncByServer.get(oldServerId)?.dispose();
this.directorySyncByServer.delete(oldServerId);
const directory = new DirectorySync(newServerId, {
drainQueuedAgentMessage: (agentId) => this.drainQueuedAgentMessage(newServerId, agentId),
markAgentLoading: () => controller.markAgentDirectorySyncLoading(),
markAgentReady: () => {
this.agentDirectoryBootstrapInFlight.delete(newServerId);
controller.markAgentDirectorySyncReady();
},
markAgentError: (error) => controller.markAgentDirectorySyncError(error),
});
this.directorySyncByServer.set(newServerId, directory);
const snapshot = controller.getSnapshot();
directory.connectionChanged({
client: snapshot.client,
status: snapshot.connectionStatus === "online" ? "online" : "offline",
source: {
clientGeneration: snapshot.clientGeneration,
connectionEpoch: snapshot.connectionEpoch,
},
});
const listeners = this.serverListeners.get(oldServerId);
if (listeners) {
@@ -1978,9 +1893,8 @@ export class HostRuntimeStore {
this.controllers.delete(serverId);
this.lastConnectionStatusByServer.delete(serverId);
this.agentDirectoryBootstrapInFlight.delete(serverId);
this.agentDirectoryTransactions.delete(serverId);
this.agentDirectoryListeners.get(serverId)?.unsubscribe();
this.agentDirectoryListeners.delete(serverId);
this.directorySyncByServer.get(serverId)?.dispose();
this.directorySyncByServer.delete(serverId);
void controller.stop();
this.emit(serverId);
}
@@ -2003,6 +1917,19 @@ export class HostRuntimeStore {
onReconcileServerId: (oldId, newId) => this.reconcileServerId(oldId, newId),
});
this.controllers.set(host.serverId, controller);
this.directorySyncByServer.set(
host.serverId,
new DirectorySync(host.serverId, {
drainQueuedAgentMessage: (agentId) =>
this.drainQueuedAgentMessage(host.serverId, agentId),
markAgentLoading: () => controller.markAgentDirectorySyncLoading(),
markAgentReady: () => {
this.agentDirectoryBootstrapInFlight.delete(host.serverId);
controller.markAgentDirectorySyncReady();
},
markAgentError: (error) => controller.markAgentDirectorySyncError(error),
}),
);
this.lastConnectionStatusByServer.set(
host.serverId,
controller.getSnapshot().connectionStatus,
@@ -2035,10 +1962,13 @@ export class HostRuntimeStore {
return;
}
const snapshot = controller.getSnapshot();
this.installAgentDirectoryListener({
serverId,
this.directorySyncByServer.get(serverId)?.connectionChanged({
client: snapshot.client,
generation: snapshot.clientGeneration,
status: snapshot.connectionStatus === "online" ? "online" : "offline",
source: {
clientGeneration: snapshot.clientGeneration,
connectionEpoch: snapshot.connectionEpoch,
},
});
const previousStatus = this.lastConnectionStatusByServer.get(serverId);
this.lastConnectionStatusByServer.set(serverId, snapshot.connectionStatus);
@@ -2066,11 +1996,17 @@ export class HostRuntimeStore {
return;
}
const bootstrap = this.refreshAgentDirectory({
serverId,
subscribe: { subscriptionId: `app:${serverId}` },
page: { limit: DEFAULT_AGENT_DIRECTORY_PAGE_LIMIT },
})
const bootstrap = Promise.resolve()
.then(() =>
Promise.all([
this.refreshAgentDirectory({
serverId,
subscribe: { subscriptionId: `app:${serverId}` },
page: { limit: DEFAULT_AGENT_DIRECTORY_PAGE_LIMIT },
}),
this.refreshWorkspaceDirectory({ serverId, subscribe: true }),
]),
)
.then(() => undefined)
.catch((error) => {
console.error("[HostRuntime] agent directory bootstrap failed", {
@@ -2088,46 +2024,9 @@ export class HostRuntimeStore {
this.agentDirectoryBootstrapInFlight.set(serverId, bootstrap);
}
private installAgentDirectoryListener(input: {
serverId: string;
client: DaemonClient | null;
generation: number;
}): void {
const current = this.agentDirectoryListeners.get(input.serverId);
if (current?.client === input.client && current.generation === input.generation) return;
current?.unsubscribe();
this.agentDirectoryListeners.delete(input.serverId);
this.agentDirectoryTransactions.delete(input.serverId);
if (!input.client) return;
const client = input.client;
const unsubscribe = client.on("agent_update", (message) => {
if (message.type !== "agent_update") return;
const installed = this.agentDirectoryListeners.get(input.serverId);
if (installed?.client !== client || installed.generation !== input.generation) return;
const transaction = this.agentDirectoryTransactions.get(input.serverId);
if (transaction?.client === client && transaction.clientGeneration === input.generation) {
transaction.deltas.push(message.payload);
return;
}
const session = useSessionStore.getState().sessions[input.serverId];
if (session?.client !== client || session.clientGeneration !== input.generation) return;
this.applyAgentDirectoryDelta(input.serverId, message.payload);
});
this.agentDirectoryListeners.set(input.serverId, {
client,
generation: input.generation,
unsubscribe,
});
}
private applyAgentDirectoryDelta(serverId: string, delta: AgentDirectoryDelta): void {
const result = applyAgentDirectoryDelta({ serverId, delta });
if (result.stoppedRunning) {
this.drainQueuedAgentMessage(serverId, result.agentId);
}
}
drainQueuedAgentMessage(serverId: string, agentId: string): void {
const drainKey = `${serverId}:${agentId}`;
if (this.queuedAgentDrainInFlight.has(drainKey)) return;
const store = useSessionStore.getState();
const session = store.sessions[serverId];
const queue = session?.queuedMessages.get(agentId);
@@ -2135,6 +2034,7 @@ export class HostRuntimeStore {
if (!client || !queue?.length || session.initializingAgents.get(agentId) === true) {
return;
}
this.queuedAgentDrainInFlight.add(drainKey);
const next = queue[0];
void sendQueuedComposerMessageNow({
agentId,
@@ -2157,16 +2057,20 @@ export class HostRuntimeStore {
attachments: wirePayload.attachments,
});
},
}).then((result) => {
if (result.status === "failed") {
console.error("[HostRuntime] failed to drain queued agent message", {
serverId,
agentId,
error: result.errorMessage,
});
}
return result;
});
})
.then((result) => {
if (result.status === "failed") {
console.error("[HostRuntime] failed to drain queued agent message", {
serverId,
agentId,
error: result.errorMessage,
});
}
return result;
})
.finally(() => {
this.queuedAgentDrainInFlight.delete(drainKey);
});
}
getSnapshot(serverId: string): HostRuntimeSnapshot | null {
@@ -2235,213 +2139,32 @@ export class HostRuntimeStore {
async refreshAgentDirectory(
input: AgentDirectoryRefreshInput,
): Promise<AgentDirectoryRefreshResult> {
const controller = this.controllers.get(input.serverId);
if (!controller) {
throw new Error(`Unknown host runtime for serverId ${input.serverId}`);
}
const snapshot = controller.getSnapshot();
const client = controller.getClient();
if (!client || snapshot.connectionStatus !== "online") {
throw new Error(`Host ${input.serverId} is not connected`);
}
const previousTransaction = this.agentDirectoryTransactions.get(input.serverId);
const transaction: AgentDirectoryTransaction = {
id: Symbol("agent directory refresh"),
client,
clientGeneration: snapshot.clientGeneration,
entries: [],
deltas:
previousTransaction?.clientGeneration === snapshot.clientGeneration
? inheritBufferedDirectoryDeltas({ client, previous: previousTransaction })
: [],
};
this.agentDirectoryTransactions.set(input.serverId, transaction);
const hasMatchingSession = () => {
const session = useSessionStore.getState().sessions[input.serverId];
return (
session?.client === client && session.clientGeneration === transaction.clientGeneration
);
};
if (!hasMatchingSession()) {
await new Promise<void>((resolve, reject) => {
const unsubscribeStore = useSessionStore.subscribe((state) => {
const session = state.sessions[input.serverId];
if (
session?.client === client &&
session.clientGeneration === transaction.clientGeneration
) {
unsubscribeStore();
unsubscribeController();
resolve();
}
});
const unsubscribeController = controller.subscribe(() => {
if (controller.getClient() !== client) {
unsubscribeStore();
unsubscribeController();
reject(new Error(`Host ${input.serverId} client changed before directory bootstrap`));
}
});
});
}
controller.markAgentDirectorySyncLoading();
try {
const session = useSessionStore.getState().sessions[input.serverId];
if (!input.filter && shouldUseLegacyDaemonWorkspaceDirectory(session?.serverInfo)) {
return await this.refreshLegacyAgentDirectory({
input,
controller,
client,
transaction,
hasMatchingSession,
});
}
return await this.refreshCurrentAgentDirectory({
input,
controller,
client,
transaction,
hasMatchingSession,
});
} catch (error) {
if (!(error instanceof AgentDirectoryRefreshSupersededError)) {
if (
this.agentDirectoryTransactions.get(input.serverId)?.id === transaction.id &&
hasMatchingSession()
) {
for (const delta of transaction.deltas) {
this.applyAgentDirectoryDelta(input.serverId, delta);
}
}
controller.markAgentDirectorySyncError(toErrorMessage(error));
}
throw error;
} finally {
if (this.agentDirectoryTransactions.get(input.serverId)?.id === transaction.id) {
this.agentDirectoryTransactions.delete(input.serverId);
}
}
): Promise<RefreshAgentDirectoryResult> {
const directory = this.directorySyncByServer.get(input.serverId);
if (!directory) throw new Error(`Unknown host runtime for serverId ${input.serverId}`);
return directory.refreshAgents(input);
}
private async refreshLegacyAgentDirectory(context: {
input: AgentDirectoryRefreshInput;
controller: HostRuntimeController;
client: DaemonClient;
transaction: AgentDirectoryTransaction;
hasMatchingSession: () => boolean;
}): Promise<AgentDirectoryRefreshResult> {
const { input, controller, client, transaction, hasMatchingSession } = context;
const directory = await readLegacyDaemonWorkspaceDirectory({
client,
subscribe: input.subscribe,
page: input.page,
});
if (
!directory ||
this.agentDirectoryTransactions.get(input.serverId)?.id !== transaction.id ||
!hasMatchingSession()
) {
throw new AgentDirectoryRefreshSupersededError();
}
const previous = useSessionStore.getState().sessions[input.serverId]?.agents ?? new Map();
const stampedSnapshot = stampLegacyWorkspaceIds(directory.entries);
const reconciled = reconcileAgentDirectory({
previous,
snapshot: stampedSnapshot,
deltas: transaction.deltas,
});
const result = replaceLegacyDaemonWorkspaceDirectory({
serverId: input.serverId,
entries: reconciled.entries,
});
this.applyAgentDirectoryCommitSideEffects({
serverId: input.serverId,
previous,
committedEntries: reconciled.entries,
directory: {
entries: stampedSnapshot,
deltas: transaction.deltas,
subscriptionId: directory.subscriptionId,
},
stoppedRunningAgentIds: reconciled.stoppedRunningAgentIds,
});
this.agentDirectoryBootstrapInFlight.delete(input.serverId);
controller.markAgentDirectorySyncReady();
return { agents: result.agents, subscriptionId: directory.subscriptionId };
async refreshWorkspaceDirectory(input: { serverId: string; subscribe?: boolean }): Promise<void> {
const directory = this.directorySyncByServer.get(input.serverId);
if (!directory) throw new Error(`Unknown host runtime for serverId ${input.serverId}`);
await directory.refreshWorkspaces({ subscribe: input.subscribe });
}
private async refreshCurrentAgentDirectory(context: {
input: AgentDirectoryRefreshInput;
controller: HostRuntimeController;
client: DaemonClient;
transaction: AgentDirectoryTransaction;
hasMatchingSession: () => boolean;
}): Promise<AgentDirectoryRefreshResult> {
const { input, controller, client, transaction, hasMatchingSession } = context;
const directory = await fetchCurrentAgentDirectory({
client,
filter: input.filter,
subscribe: input.subscribe,
page: input.page,
transaction,
isCurrent: () =>
this.agentDirectoryTransactions.get(input.serverId)?.id === transaction.id &&
controller.getSnapshot().client === client &&
controller.getSnapshot().clientGeneration === transaction.clientGeneration,
});
if (
this.agentDirectoryTransactions.get(input.serverId)?.id !== transaction.id ||
controller.getSnapshot().client !== client ||
controller.getSnapshot().clientGeneration !== transaction.clientGeneration ||
!hasMatchingSession()
) {
throw new AgentDirectoryRefreshSupersededError();
}
const previous = useSessionStore.getState().sessions[input.serverId]?.agents ?? new Map();
const reconciled = reconcileAgentDirectory({
previous,
snapshot: directory.entries,
deltas: directory.deltas,
});
const { agents } = replaceFetchedAgentDirectory({
serverId: input.serverId,
entries: reconciled.entries,
});
this.applyAgentDirectoryCommitSideEffects({
serverId: input.serverId,
previous,
committedEntries: reconciled.entries,
directory,
stoppedRunningAgentIds: reconciled.stoppedRunningAgentIds,
});
this.agentDirectoryBootstrapInFlight.delete(input.serverId);
controller.markAgentDirectorySyncReady();
return { agents, subscriptionId: directory.subscriptionId };
async refreshDirectories(serverId: string): Promise<void> {
const directory = this.directorySyncByServer.get(serverId);
if (!directory) throw new Error(`Unknown host runtime for serverId ${serverId}`);
await directory.refreshAll();
}
private applyAgentDirectoryCommitSideEffects(input: {
serverId: string;
previous: ReadonlyMap<string, Agent>;
committedEntries: FetchAgentsEntry[];
directory: AgentDirectoryFetchResult;
stoppedRunningAgentIds: string[];
}): void {
const snapshotAgentIds = new Set(input.committedEntries.map((entry) => entry.agent.id));
for (const agentId of input.previous.keys()) {
if (!snapshotAgentIds.has(agentId)) {
applyAgentDirectoryDelta({ serverId: input.serverId, delta: { kind: "remove", agentId } });
}
}
for (const delta of input.directory.deltas) {
applyAgentDirectoryDelta({ serverId: input.serverId, delta });
}
for (const agentId of input.stoppedRunningAgentIds) {
this.drainQueuedAgentMessage(input.serverId, agentId);
}
fetchAgentTimeline(
serverId: string,
agentId: string,
request: Parameters<DaemonClient["fetchAgentTimeline"]>[1],
): Promise<Awaited<ReturnType<DaemonClient["fetchAgentTimeline"]>>> {
const directory = this.directorySyncByServer.get(serverId);
if (!directory) throw new Error(`Unknown host runtime for serverId ${serverId}`);
return directory.fetchTimeline(agentId, request);
}
refreshAllAgentDirectories(input?: { serverIds?: string[] }): void {
@@ -2766,7 +2766,7 @@ function WorkspaceScreenContent({
// dropped against the stale cursor.
const sessionState = useSessionStore.getState().sessions[normalizedServerId];
const currentCursor = sessionState?.agentTimelineCursor.get(agentId);
await client.fetchAgentTimeline(agentId, {
await getHostRuntimeStore().fetchAgentTimeline(normalizedServerId, agentId, {
direction: "tail",
projection: "projected",
...(currentCursor
@@ -7,11 +7,7 @@ import { useSessionStore } from "@/stores/session-store";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { isAgentArchiving, setAgentArchiving } from "@/hooks/use-archive-agent";
import { queryClient } from "@/data/query-client";
import {
applyAgentDirectoryDelta,
replaceFetchedAgentDirectory,
shouldApplyTimelineAgentSnapshot,
} from "./agent-directory-sync";
import { applyAgentDirectoryDelta, replaceFetchedAgentDirectory } from "./agent-directory-sync";
function createAgentPayload(
input: Partial<Omit<AgentSnapshotPayload, "labels">> & {
@@ -158,7 +154,6 @@ describe("replaceFetchedAgentDirectory", () => {
cursor: session?.agentTimelineCursor.has(agentId),
permissions: session?.pendingPermissions.size,
initializing: session?.initializingAgents.has(agentId),
acceptsStaleTimelineSnapshot: shouldApplyTimelineAgentSnapshot(serverId, agentId),
archivePending: isAgentArchiving({ queryClient, serverId, agentId }),
}).toEqual({
agents: false,
@@ -167,7 +162,6 @@ describe("replaceFetchedAgentDirectory", () => {
cursor: false,
permissions: 0,
initializing: false,
acceptsStaleTimelineSnapshot: false,
archivePending: false,
});
+21 -8
View File
@@ -6,6 +6,9 @@ import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
import { clearArchiveAgentPending } from "@/hooks/use-archive-agent";
import { queryClient } from "@/data/query-client";
import { acceptAgentDirectoryUpdate } from "@/utils/agent-directory-update-policy";
import { buildDraftStoreKey } from "@/stores/draft-keys";
import { useDraftStore } from "@/stores/draft-store";
import { getInitDeferred, getInitKey, rejectInitDeferred } from "@/utils/agent-initialization";
type AgentDirectoryFetchEntry = FetchAgentsEntry;
export type AgentDirectoryDelta = Extract<
@@ -24,11 +27,6 @@ export function applyAgentDirectoryDelta(input: { serverId: string; delta: Agent
return upsertAgentDirectoryReplica(input.serverId, input.delta);
}
export function shouldApplyTimelineAgentSnapshot(serverId: string, agentId: string): boolean {
const session = useSessionStore.getState().sessions[serverId];
return session?.agents.has(agentId) === true || session?.initializingAgents.get(agentId) === true;
}
type AgentUpsertDelta = Extract<AgentDirectoryDelta, { kind: "upsert" }>;
function upsertAgentDirectoryReplica(
@@ -65,7 +63,7 @@ function upsertAgentDirectoryReplica(
};
}
function upsertAgentReplica(serverId: string, agent: Agent): Agent {
export function upsertAgentReplica(serverId: string, agent: Agent): Agent {
let acceptedAgent = agent;
useSessionStore.getState().setAgents(serverId, (current) => {
const currentAgent = current.get(agent.id);
@@ -78,7 +76,7 @@ function upsertAgentReplica(serverId: string, agent: Agent): Agent {
return acceptedAgent;
}
function replaceAgentPendingPermissions(serverId: string, agent: Agent): void {
export function replaceAgentPendingPermissions(serverId: string, agent: Agent): void {
const pendingPermissions = new Map(
useSessionStore.getState().sessions[serverId]?.pendingPermissions,
);
@@ -92,7 +90,7 @@ function replaceAgentPendingPermissions(serverId: string, agent: Agent): void {
useSessionStore.getState().setPendingPermissions(serverId, pendingPermissions);
}
function removeAgentDirectoryReplica(serverId: string, agentId: string): void {
export function removeAgentDirectoryReplica(serverId: string, agentId: string): void {
const store = useSessionStore.getState();
clearArchiveAgentPending({ queryClient, serverId, agentId });
const removeKey = <T>(current: Map<string, T>): Map<string, T> => {
@@ -114,6 +112,21 @@ function removeAgentDirectoryReplica(serverId: string, agentId: string): void {
return next.size === current.size ? current : next;
});
store.setAgentAuthoritativeHistoryApplied(serverId, agentId, false);
store.setAgentStreamTail(serverId, removeKey);
store.clearAgentStreamHead(serverId, agentId);
useSessionStore.setState((state) => {
if (!state.agentLastActivity.has(agentId)) return state;
const agentLastActivity = new Map(state.agentLastActivity);
agentLastActivity.delete(agentId);
return { ...state, agentLastActivity };
});
useDraftStore.getState().clearDraftInput({
draftKey: buildDraftStoreKey({ serverId, agentId }),
});
const initKey = getInitKey(serverId, agentId);
if (getInitDeferred(initKey)) {
rejectInitDeferred(initKey, new Error("Agent was removed during initialization"));
}
}
interface PendingPermissionEntry {
@@ -1,23 +0,0 @@
import { describe, expect, it } from "vitest";
import { inheritBufferedDirectoryDeltas } from "./buffered-directory-transaction";
describe("inheritBufferedDirectoryDeltas", () => {
it("hands buffered updates to a superseding transaction for the same client", () => {
const client = {};
const previous = { client, deltas: ["first"] };
const inherited = inheritBufferedDirectoryDeltas({ client, previous });
previous.deltas.push("later");
expect(inherited).toEqual(["first"]);
});
it("does not carry updates across clients", () => {
expect(
inheritBufferedDirectoryDeltas({
client: {},
previous: { client: {}, deltas: ["stale"] },
}),
).toEqual([]);
});
});
@@ -1,12 +0,0 @@
export interface BufferedDirectoryTransaction<TClient, TDelta> {
client: TClient;
deltas: TDelta[];
}
export function inheritBufferedDirectoryDeltas<TClient, TDelta>(input: {
client: TClient;
previous: BufferedDirectoryTransaction<TClient, TDelta> | null | undefined;
}): TDelta[] {
if (input.previous?.client !== input.client) return [];
return [...input.previous.deltas];
}
@@ -255,7 +255,9 @@ export function stampLegacyWorkspaceIds(entries: FetchAgentsEntry[]): FetchAgent
});
}
function buildLegacyWorkspaces(entries: FetchAgentsEntry[]): Map<string, WorkspaceDescriptor> {
export function buildLegacyWorkspaces(
entries: FetchAgentsEntry[],
): Map<string, WorkspaceDescriptor> {
const workspaces = new Map<string, WorkspaceDescriptor>();
for (const entry of entries) {
const workspaceId = entry.agent.workspaceId ?? resolveLegacyWorkspaceId(entry);
@@ -4721,6 +4721,58 @@ test("unions viewed timelines across socket sources and removes detached sources
).toEqual(["agent-b"]);
});
test("keeps selective delivery scoped per socket when a retained session also has a legacy socket", async () => {
const messages: SessionOutboundMessage[] = [];
const targetedMessages: Array<{ source: object; message: SessionOutboundMessage }> = [];
const agentEventListeners: Array<(event: AgentManagerEvent) => void> = [];
const session = createSessionForTest({
messages,
targetedMessages,
agentManager: {
subscribe: vi.fn((listener: (event: AgentManagerEvent) => void) => {
agentEventListeners.push(listener);
return () => {};
}),
},
});
const legacySocket = {};
const selectiveSocket = {};
session.updateClientCapabilities(null, legacySocket);
session.updateClientCapabilities({ selective_agent_timeline: true }, selectiveSocket);
await session.handleMessage(
{
type: "agent.timeline.set_subscription.request",
agentIds: ["viewed-agent"],
requestId: "timeline-subscription-selective",
},
selectiveSocket,
);
targetedMessages.length = 0;
const listener = agentEventListeners[0];
if (!listener) throw new Error("Agent event listener was not installed");
listener({
type: "agent_stream",
agentId: "not-viewed-agent",
event: {
type: "timeline",
provider: "mock",
item: { type: "assistant_message", messageId: "message-global", text: "global" },
},
});
expect(messages).toEqual([]);
expect(targetedMessages).toEqual([
{
source: legacySocket,
message: expect.objectContaining({
type: "agent_stream",
payload: expect.objectContaining({ agentId: "not-viewed-agent" }),
}),
},
]);
});
describe("agent config setters", () => {
test("set_agent_mode_request: success emits accepted response carrying the notice", async () => {
const messages: SessionOutboundMessage[] = [];
+56 -22
View File
@@ -1019,6 +1019,61 @@ export class Session {
return true;
}
private forwardAgentStream(
event: Extract<AgentManagerEvent, { type: "agent_stream" }>,
serializedEvent: Extract<SessionOutboundMessage, { type: "agent_stream" }>["payload"]["event"],
): void {
if (this.selectiveTimelineCapabilityBySource.size === 0 || !this.onMessageToSource) {
if (this.usesSelectiveTimelineDelivery() && serializedEvent.type === "attention_required") {
this.emit({
type: "agent_attention_required",
payload: {
agentId: event.agentId,
reason: serializedEvent.reason,
timestamp: serializedEvent.timestamp,
shouldNotify: serializedEvent.shouldNotify,
...(serializedEvent.notification ? { notification: serializedEvent.notification } : {}),
},
});
} else if (
!this.usesSelectiveTimelineDelivery() ||
this.viewedTimelineAgentIds.has(event.agentId)
) {
this.emit({
type: "agent_stream",
payload: this.buildAgentStreamPayload(event, serializedEvent),
});
}
return;
}
for (const [source, supportsSelectiveDelivery] of this.selectiveTimelineCapabilityBySource) {
if (supportsSelectiveDelivery && serializedEvent.type === "attention_required") {
this.onMessageToSource(source, {
type: "agent_attention_required",
payload: {
agentId: event.agentId,
reason: serializedEvent.reason,
timestamp: serializedEvent.timestamp,
shouldNotify: serializedEvent.shouldNotify,
...(serializedEvent.notification ? { notification: serializedEvent.notification } : {}),
},
});
continue;
}
if (
supportsSelectiveDelivery &&
!this.viewedTimelineAgentIdsBySource.get(source)?.has(event.agentId)
) {
continue;
}
this.onMessageToSource(source, {
type: "agent_stream",
payload: this.buildAgentStreamPayload(event, serializedEvent),
});
}
}
supports(capability: ClientCapability): boolean {
return this.clientCapabilities.has(capability);
}
@@ -1340,28 +1395,7 @@ export class Session {
"agent.session.forward_stream",
);
if (this.usesSelectiveTimelineDelivery() && serializedEvent.type === "attention_required") {
this.emit({
type: "agent_attention_required",
payload: {
agentId: event.agentId,
reason: serializedEvent.reason,
timestamp: serializedEvent.timestamp,
shouldNotify: serializedEvent.shouldNotify,
...(serializedEvent.notification
? { notification: serializedEvent.notification }
: {}),
},
});
} else if (
!this.usesSelectiveTimelineDelivery() ||
this.viewedTimelineAgentIds.has(event.agentId)
) {
this.emit({
type: "agent_stream",
payload: this.buildAgentStreamPayload(event, serializedEvent),
});
}
this.forwardAgentStream(event, serializedEvent);
if (event.event.type === "permission_requested") {
this.emit({