diff --git a/docs/data-model.md b/docs/data-model.md index 4c86286c2a..5bd96f5252 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -574,8 +574,8 @@ Right-sidebar client state splits on whether it is determined by the directory o The durable client replica uses IndexedDB on browser/Electron and expo-sqlite on native. Rows use the compound key `(serverId, kind, id)`; kinds are `agent`, `workspace`, `project`, `timeline`, and -`checkpoint`. Directory entities have individual rows. Timeline and checkpoint use the singleton id -and have at most one row per host. +`checkpoint`. Directory entities have individual rows, timelines use the agent id, and the checkpoint +uses the singleton id. The store is a typed persistence boundary. It returns values to directory and timeline owners and accepts their explicit commits; it never reads or writes UI state. Reads are scoped to the requested diff --git a/docs/timeline-sync.md b/docs/timeline-sync.md index 3f0b6e790a..5d3464a30b 100644 --- a/docs/timeline-sync.md +++ b/docs/timeline-sync.md @@ -152,7 +152,7 @@ remaining page through the existing stream reducer. It must not append full proj live prefix. Every path that sends a message to an agent — composer send, dictation accept-and-send, queued -send-now, and the automatic queue drain in `HostRuntime` — goes through +send-now, and the host runtime's automatic queue drain — goes through `dispatchComposerAgentMessage` with a submission writer. There is no second transport for the same product action: calling `client.sendAgentMessage` directly skips the submitted row and the pending footer, and permanently drops attachments because the daemon does not echo them back. @@ -196,9 +196,9 @@ fork positions retain the canonical `turnId` boundaries. The compatibility boundary for older daemons is snapshot normalization: running/idle status becomes an anonymous active turn or idle state once, and downstream code consumes the same activity shape. The app does not combine anonymous lifecycle events, timestamps, timeline rows, and resume coverage to infer a -second running state. Disconnect and replica removal remain destructive close boundaries. Elapsed time -comes only from turn liveness, never from submission records or whichever timeline rows happen to be -mounted. +second running state. Disconnect preserves the last replicated turn until cache or network hydration +advances it; replica removal remains the destructive close boundary. Elapsed time comes only from turn +liveness, never from submission records or whichever timeline rows happen to be mounted. The daemon records one canonical submitted user row at acceptance. Its wire `messageId` is the submission's `clientMessageId`, so the row is born with its final identity and remains immutable on diff --git a/packages/app/e2e/browser/agent-message-submission.spec.ts b/packages/app/e2e/browser/agent-message-submission.spec.ts index 295e07ba88..194fe43d1d 100644 --- a/packages/app/e2e/browser/agent-message-submission.spec.ts +++ b/packages/app/e2e/browser/agent-message-submission.spec.ts @@ -5,6 +5,7 @@ import { expectAgentIdle, expectAgentReadyToInterrupt, expectAgentSurfacesIdle, + expectInlineWorkingIndicator, expectRunningAgentChrome, expectVisibleAgentSurfacesIdle, } from "../support/helpers/agent-stream"; @@ -549,9 +550,18 @@ async function expectProviderAcknowledgementBeforeRpcAcceptanceSettlesSubmission const userMessage = await submitMessageWithImage(page, prompt); await gate.waitForHeldServerMessage(); await gate.waitForAgentStreamItem("user_message"); + await gate.waitForAgentStreamEvent("turn_started"); gate.releaseHeldServerMessage(); + await expect(userMessage).toHaveAttribute("aria-busy", "false"); await gate.drop(); - await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0); + await expectInlineWorkingIndicator(page); + await expect(userMessage).toHaveAttribute("aria-busy", "false"); + + await agent.client.waitForFinish(agent.agentId, 30_000); + gate.setServerMessageSuppressed("agent_status", false); + gate.setServerMessageSuppressed("agent_update", false); + gate.restoreFresh(); + await expectVisibleAgentSurfacesIdle(page); await expect(userMessage).toHaveAttribute("aria-busy", "false"); } finally { gate.restore(); @@ -991,7 +1001,7 @@ test.describe("Agent message submission", () => { await openAgentRoute(page, agent); await expectComposerVisible(page); await submitMessage(page, "Keep running until the queued turn is ready."); - await expectAgentReadyToInterrupt(page); + await expectRunningAgentChrome(page, title); await queueMessage(page, secondPrompt); await expect(page.getByRole("button", { name: "Send queued message now" })).toBeVisible(); diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index c57dba4429..148d431921 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -69,6 +69,7 @@ import { resolveStartupNavigationReady, shouldRunStartupGiveUpTimer, startHostRuntimeBootstrap, + bindHostRuntimeAppState, type StartupBlocker, } from "@/navigation/host-runtime-bootstrap"; import { registerWorkspaceRouteNavigationRef } from "@/navigation/workspace-route-navigation"; @@ -372,6 +373,11 @@ async function shouldStartBuiltInDaemon(): Promise { } function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) { + useEffect(() => { + const store = getHostRuntimeStore(); + return bindHostRuntimeAppState(store, AppState); + }, []); + useEffect(() => { const store = getHostRuntimeStore(); const daemonStartService = getDaemonStartService({ store }); diff --git a/packages/app/src/client-slash-commands/index.test.ts b/packages/app/src/client-slash-commands/index.test.ts index 768602a3d8..c6e86be582 100644 --- a/packages/app/src/client-slash-commands/index.test.ts +++ b/packages/app/src/client-slash-commands/index.test.ts @@ -53,7 +53,7 @@ function createAgent(overrides: Partial = {}): Agent { parentAgentId: null, labels: {}, ...overrides, - activeTurn: overrides.activeTurn ?? null, + turn: overrides.turn ?? { phase: "idle", cancellationRequestId: null }, }; } diff --git a/packages/app/src/components/add-project-flow.tsx b/packages/app/src/components/add-project-flow.tsx index 80cc9075be..4dcd50b019 100644 --- a/packages/app/src/components/add-project-flow.tsx +++ b/packages/app/src/components/add-project-flow.tsx @@ -1,3 +1,4 @@ +import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { router } from "expo-router"; import type { WorkspaceProjectDescriptorPayload } from "@getpaseo/protocol/messages"; import { @@ -372,7 +373,15 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) { const recommendedPaths = useRecommendedProjectPaths(hostId); const openProject = useOpenProject(hostId); const cloneGithubProject = useCloneGithubProject(hostId); - const upsertProject = useSessionStore((store) => store.upsertProject); + const upsertProject = useCallback( + ( + targetServerId: string, + project: Parameters["acceptProjectSnapshot"]>[1], + ) => { + getHostRuntimeStore().acceptProjectSnapshot(targetServerId, project); + }, + [], + ); const setHasHydratedWorkspaces = useSessionStore((store) => store.setHasHydratedWorkspaces); const inputRef = useRef(null); const submissionInFlightRef = useRef(false); diff --git a/packages/app/src/components/sidebar-workspace-list.test.tsx b/packages/app/src/components/sidebar-workspace-list.test.tsx index 94e6f36466..73c008dd22 100644 --- a/packages/app/src/components/sidebar-workspace-list.test.tsx +++ b/packages/app/src/components/sidebar-workspace-list.test.tsx @@ -38,7 +38,7 @@ import { } from "@/runtime/host-runtime"; import type { HostProfile } from "@/types/host-connection"; import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store"; -import { seedSessionWorkspaces } from "@/test/seed-session"; +import { seedRuntimeWorkspaces } from "@/test/seed-session"; import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; import { useWorkspaceFields } from "@/stores/session-store-hooks"; import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; @@ -167,7 +167,7 @@ function initializeSidebarState(workspaces: WorkspaceDescriptor[]): void { act(() => { setHostProfiles([makeHost()]); useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient); - seedSessionWorkspaces(SERVER_ID, new Map(workspaces.map((entry) => [entry.id, entry]))); + seedRuntimeWorkspaces(SERVER_ID, new Map(workspaces.map((entry) => [entry.id, entry]))); useSessionStore.getState().setHasHydratedWorkspaces(SERVER_ID, true); useSidebarOrderStore.setState({ projectOrder: ["project-a", "project-b"], diff --git a/packages/app/src/components/workspace-setup-dialog.tsx b/packages/app/src/components/workspace-setup-dialog.tsx index 4dabf067ba..29cd7ea0b7 100644 --- a/packages/app/src/components/workspace-setup-dialog.tsx +++ b/packages/app/src/components/workspace-setup-dialog.tsx @@ -1,3 +1,4 @@ +import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { useCallback, useEffect, useMemo, useState } from "react"; import { Text, View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; @@ -12,7 +13,11 @@ import { useToast } from "@/contexts/toast-context"; import { useAgentInputDraft } from "@/composer/draft/input-draft"; import { useProjectIcon } from "@/projects/icons"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; -import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store"; +import { + normalizeWorkspaceDescriptor, + useSessionStore, + type WorkspaceDescriptor, +} from "@/stores/session-store"; import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store"; import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { applyLegacyDaemonWorkspaceOwnership } from "@/workspace/legacy-daemon-workspaces"; @@ -164,9 +169,13 @@ export function WorkspaceSetupDialog() { const toast = useToast(); const pendingWorkspaceSetup = useWorkspaceSetupStore((state) => state.pendingWorkspaceSetup); const clearWorkspaceSetup = useWorkspaceSetupStore((state) => state.clearWorkspaceSetup); - const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces); + const mergeWorkspaces = useCallback( + (targetServerId: string, workspaces: Iterable) => { + getHostRuntimeStore().acceptWorkspaceSnapshots(targetServerId, Array.from(workspaces)); + }, + [], + ); const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces); - const setAgents = useSessionStore((state) => state.setAgents); const [errorMessage, setErrorMessage] = useState(null); const [createdWorkspace, setCreatedWorkspace] = useState { - const next = new Map(previous); - next.set( - agent.id, - applyLegacyDaemonWorkspaceOwnership({ - serverId, - agent: normalizeAgentSnapshot(agent, serverId), - }), - ); - return next; - }); + getHostRuntimeStore().acceptAgentSnapshot( + serverId, + applyLegacyDaemonWorkspaceOwnership({ + serverId, + agent: normalizeAgentSnapshot(agent, serverId), + }), + ); navigateAfterCreation(ensuredWorkspace.id, { kind: "agent", agentId: agent.id }); } catch (error) { const message = toErrorMessage(error); @@ -368,7 +373,6 @@ export function WorkspaceSetupDialog() { getIsStillActive, navigateAfterCreation, serverId, - setAgents, ensureWorkspace, t, toast, diff --git a/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx b/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx index 2cceeae961..7124fd2f0a 100644 --- a/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx +++ b/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx @@ -9,7 +9,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store"; -import { seedSessionWorkspaces } from "@/test/seed-session"; +import { seedRuntimeWorkspaces } from "@/test/seed-session"; import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store"; import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; import { useSidebarViewStore } from "@/stores/sidebar-view-store"; @@ -96,7 +96,7 @@ describe("WorkspaceShortcutTargetsSubscriber", () => { act(() => { setHostProfiles([hostProfile()]); useSessionStore.getState().initializeSession("srv", null as unknown as DaemonClient); - seedSessionWorkspaces( + seedRuntimeWorkspaces( "srv", new Map([ ["ws-1", workspaceDescriptor({ id: "ws-1", name: "Workspace 1" })], @@ -142,7 +142,7 @@ describe("WorkspaceShortcutTargetsSubscriber", () => { it("publishes status-mode shortcut targets in visual status order", async () => { act(() => { useSidebarViewStore.getState().setGroupMode("status"); - seedSessionWorkspaces( + seedRuntimeWorkspaces( "srv", new Map([ [ @@ -214,11 +214,11 @@ describe("WorkspaceShortcutTargetsSubscriber", () => { setHostProfiles([hostProfile("host-a"), hostProfile("host-b")]); useSessionStore.getState().initializeSession("host-a", null as unknown as DaemonClient); useSessionStore.getState().initializeSession("host-b", null as unknown as DaemonClient); - seedSessionWorkspaces( + seedRuntimeWorkspaces( "host-a", new Map([["a-1", workspaceDescriptor({ id: "a-1", name: "Host A" })]]), ); - seedSessionWorkspaces( + seedRuntimeWorkspaces( "host-b", new Map([["b-1", workspaceDescriptor({ id: "b-1", name: "Host B" })]]), ); diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index e9047eb60a..9f1c2b172c 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -248,7 +248,7 @@ function buildDraftAgentSnapshot(input: { id: tabId, provider, status: "running", - activeTurn: null, + turn: { phase: "idle", cancellationRequestId: null }, createdAt: now, updatedAt: now, lastUserMessageAt: now, diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index 5a1d51f1fc..84de49a3b9 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -1,3 +1,4 @@ +import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { View, @@ -1487,8 +1488,10 @@ function ComposerContentImpl({ activeTurnBehavior, activeTurnId: activeTurnBehavior === "steer" - ? (useSessionStore.getState().sessions[serverId]?.agents.get(targetAgentId)?.activeTurn - ?.turnId ?? undefined) + ? (selectAgentTurnPresentation( + useSessionStore.getState().sessions[serverId], + targetAgentId, + ).turnId ?? undefined) : undefined, }); onAttentionPromptSend?.(); @@ -1505,8 +1508,6 @@ function ComposerContentImpl({ const isCancellingAgent = useSessionStore( (state) => selectAgentTurnPresentation(state.sessions[serverId], agentId).isCancelling, ); - const beginAgentCancellation = useSessionStore((state) => state.beginAgentCancellation); - const settleAgentCancellation = useSessionStore((state) => state.settleAgentCancellation); const isAgentRunning = hasActiveTurn; // Queueing behind a permission prompt would strand the message: the turn is // parked until the request is answered. @@ -1812,7 +1813,7 @@ function ComposerContentImpl({ isConnected, }); if (!cancellation) return; - const requestId = beginAgentCancellation(serverId, targetAgentId); + const requestId = getHostRuntimeStore().beginAgentCancellation(serverId, targetAgentId); void cancellation .catch((error) => { const message = resolveErrorMessage(error); @@ -1821,18 +1822,10 @@ function ComposerContentImpl({ } }) .finally(() => { - settleAgentCancellation(serverId, targetAgentId, requestId); + getHostRuntimeStore().settleAgentCancellation(serverId, targetAgentId, requestId); }); messageInputRef.current?.focus(); - }, [ - beginAgentCancellation, - client, - isAgentRunning, - isCancellingAgent, - isConnected, - serverId, - settleAgentCancellation, - ]); + }, [client, isAgentRunning, isCancellingAgent, isConnected, serverId]); const focusMessageInputForKeyboardAction = useCallback(() => { focusMessageInputWithPlatformStrategy(messageInputRef); diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 475eb26f27..fff3a0f496 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -231,8 +231,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider const setIsPlayingAudio = useSessionStore((state) => state.setIsPlayingAudio); const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail); const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead); - const applyAgentTurnLiveness = useSessionStore((state) => state.applyAgentTurnLiveness); - const clearAgentTurnLiveness = useSessionStore((state) => state.clearAgentTurnLiveness); const clearAgentStreamHead = useSessionStore((state) => state.clearAgentStreamHead); const setInitializingAgents = useSessionStore((state) => state.setInitializingAgents); const bumpHistorySyncGeneration = useSessionStore((state) => state.bumpHistorySyncGeneration); @@ -412,15 +410,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider } }, [flushAgentLastActivity, serverId, isConnected, setInitializingAgents]); - useEffect( - () => - client.subscribeConnectionStatus((connection) => { - if (connection.status === "connected") return; - clearAgentTurnLiveness(serverId); - }), - [clearAgentTurnLiveness, client, serverId], - ); - const applyWorkspaceSetupProgress = useCallback( (payload: WorkspaceSetupProgressPayload) => { upsertWorkspaceSetupProgress({ serverId, payload }); @@ -544,7 +533,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider { event: streamEvent, seq, epoch, timestamp: parsedTimestamp }, ]); if (turnLiveness.length > 0) { - applyAgentTurnLiveness(serverId, agentId, turnLiveness); + getHostRuntimeStore().applyAgentTurnLiveness(serverId, agentId, turnLiveness); } owner.enqueueStreamEvent(agentId, { event: streamEvent, @@ -797,7 +786,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider setIsPlayingAudio, setAgentStreamTail, setAgentStreamHead, - applyAgentTurnLiveness, clearAgentStreamHead, setInitializingAgents, setAgents, diff --git a/packages/app/src/contexts/session-status-tracking.test.ts b/packages/app/src/contexts/session-status-tracking.test.ts index 346127b1ff..dcd150d764 100644 --- a/packages/app/src/contexts/session-status-tracking.test.ts +++ b/packages/app/src/contexts/session-status-tracking.test.ts @@ -8,7 +8,7 @@ function createAgent(status: Agent["status"]): Agent { id: "agent-1", provider: "codex", status, - activeTurn: null, + turn: { phase: "idle", cancellationRequestId: null }, createdAt: new Date(0), updatedAt: new Date(0), lastUserMessageAt: null, diff --git a/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts b/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts index f9d192dbb9..637d3a035b 100644 --- a/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts +++ b/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts @@ -701,7 +701,10 @@ function agent(input: { id: input.id, provider: "claude" as Agent["provider"], status: input.status, - activeTurn: null, + turn: + input.status === "running" + ? { phase: "open", turnId: null, startedAt: null, cancellationRequestId: null } + : { phase: "idle", cancellationRequestId: null }, createdAt: new Date(0), updatedAt: input.updatedAt ?? new Date(1_000), lastUserMessageAt: null, diff --git a/packages/app/src/hooks/use-agent-history.ts b/packages/app/src/hooks/use-agent-history.ts index b24c5f8ee7..3ac397e719 100644 --- a/packages/app/src/hooks/use-agent-history.ts +++ b/packages/app/src/hooks/use-agent-history.ts @@ -134,6 +134,7 @@ export async function fetchAgentHistoryPage(input: { serverLabel: input.serverId, title: agent.title ?? null, status: agent.status, + turn: agent.turn, lastActivityAt: agent.lastActivityAt, cwd: agent.cwd, workspaceId: agent.workspaceId, diff --git a/packages/app/src/hooks/use-agent-screen-state-machine.test.ts b/packages/app/src/hooks/use-agent-screen-state-machine.test.ts index a7ed05e906..c23512758e 100644 --- a/packages/app/src/hooks/use-agent-screen-state-machine.test.ts +++ b/packages/app/src/hooks/use-agent-screen-state-machine.test.ts @@ -17,7 +17,7 @@ function createAgent(id: string): Agent { id, provider: "claude", status: "running", - activeTurn: { turnId: "turn-1", startedAt: now }, + turn: { phase: "open", turnId: "turn-1", startedAt: now, cancellationRequestId: null }, createdAt: now, updatedAt: now, lastUserMessageAt: now, diff --git a/packages/app/src/hooks/use-aggregated-agents.ts b/packages/app/src/hooks/use-aggregated-agents.ts index 1a016e9b13..78cca7fbb3 100644 --- a/packages/app/src/hooks/use-aggregated-agents.ts +++ b/packages/app/src/hooks/use-aggregated-agents.ts @@ -85,6 +85,7 @@ export function useAggregatedAgents(options?: { serverLabel, title: agent.title ?? null, status: agent.status, + turn: agent.turn, lastActivityAt: agent.lastActivityAt, cwd: agent.cwd, workspaceId: agent.workspaceId, @@ -108,8 +109,8 @@ export function useAggregatedAgents(options?: { // Sort by: running agents first, then by most recent activity allAgents.sort((left, right) => { - const leftRunning = left.status === "running"; - const rightRunning = right.status === "running"; + const leftRunning = left.turn.phase === "open"; + const rightRunning = right.turn.phase === "open"; if (leftRunning && !rightRunning) { return -1; } diff --git a/packages/app/src/hooks/use-archive-agent.test.ts b/packages/app/src/hooks/use-archive-agent.test.ts index cd4d433f74..df14f2df38 100644 --- a/packages/app/src/hooks/use-archive-agent.test.ts +++ b/packages/app/src/hooks/use-archive-agent.test.ts @@ -1,6 +1,7 @@ +import { seedSessionHosts } from "@/test/seed-session"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { QueryClient } from "@tanstack/react-query"; -import { beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import type { Agent } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store"; import { agentHistoryQueryKey, allAgentHistoryQueryKey } from "./agent-history-query-key"; @@ -41,13 +42,15 @@ function makeAgent(overrides: Partial = {}): Agent { labels: {}, archivedAt: null, ...overrides, - activeTurn: overrides.activeTurn ?? null, + turn: overrides.turn ?? { phase: "idle", cancellationRequestId: null }, }; } describe("useArchiveAgent", () => { + afterEach(() => seedSessionHosts([])); beforeEach(() => { useSessionStore.setState((state) => ({ ...state, sessions: {} })); + seedSessionHosts(["server-a"]); }); it("tracks pending archive state in shared react-query cache", () => { diff --git a/packages/app/src/hooks/use-archive-agent.ts b/packages/app/src/hooks/use-archive-agent.ts index 437ed6df49..92d8e4d2d6 100644 --- a/packages/app/src/hooks/use-archive-agent.ts +++ b/packages/app/src/hooks/use-archive-agent.ts @@ -1,3 +1,4 @@ +import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { useCallback, useMemo } from "react"; import { useMutation, @@ -247,27 +248,7 @@ function getStoredAgentSnapshot(input: ArchiveAgentInput) { function restoreAgentSnapshot( input: ArchiveAgentInput & { agent: ReturnType }, ): void { - const setAgents = useSessionStore.getState().setAgents; - setAgents(input.serverId, (prev) => { - const hasAgent = prev.has(input.agentId); - if (!input.agent) { - if (!hasAgent) { - return prev; - } - const next = new Map(prev); - next.delete(input.agentId); - return next; - } - - const current = prev.get(input.agentId); - if (current === input.agent) { - return prev; - } - - const next = new Map(prev); - next.set(input.agentId, input.agent); - return next; - }); + getHostRuntimeStore().restoreAgentSnapshot(input.serverId, input.agentId, input.agent); } function getArchivedAgentListCacheSnapshot( @@ -324,22 +305,11 @@ function markAgentArchivedInStore(input: ArchiveAgentInput & { archivedAt: strin return; } - const setAgents = useSessionStore.getState().setAgents; - setAgents(input.serverId, (prev) => { - const existing = prev.get(input.agentId); - if (!existing) { - return prev; - } - if (existing.archivedAt && existing.archivedAt.getTime() === archivedAt.getTime()) { - return prev; - } - const next = new Map(prev); - next.set(input.agentId, { - ...existing, - archivedAt, - }); - return next; - }); + getHostRuntimeStore().archiveAgentSnapshot( + input.serverId, + input.agentId, + archivedAt.toISOString(), + ); } interface ApplyArchivedAgentCloseResultsInput { diff --git a/packages/app/src/hooks/use-open-project.ts b/packages/app/src/hooks/use-open-project.ts index 71a0a78da2..18dc5064f3 100644 --- a/packages/app/src/hooks/use-open-project.ts +++ b/packages/app/src/hooks/use-open-project.ts @@ -1,3 +1,4 @@ +import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { useCallback } from "react"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useSessionStore } from "@/stores/session-store"; @@ -20,7 +21,15 @@ export function useOpenProject( state.sessions[normalizedServerId]?.serverInfo?.features?.stableProjectIdentity === true : false, ); - const upsertProject = useSessionStore((state) => state.upsertProject); + const upsertProject = useCallback( + ( + targetServerId: string, + project: Parameters["acceptProjectSnapshot"]>[1], + ) => { + getHostRuntimeStore().acceptProjectSnapshot(targetServerId, project); + }, + [], + ); const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces); return useCallback( @@ -57,7 +66,15 @@ export function useCloneGithubProject( const normalizedServerId = serverId?.trim() ?? ""; const client = useHostRuntimeClient(normalizedServerId); const isConnected = useHostRuntimeIsConnected(normalizedServerId); - const upsertProject = useSessionStore((state) => state.upsertProject); + const upsertProject = useCallback( + ( + targetServerId: string, + project: Parameters["acceptProjectSnapshot"]>[1], + ) => { + getHostRuntimeStore().acceptProjectSnapshot(targetServerId, project); + }, + [], + ); const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces); return useCallback( diff --git a/packages/app/src/navigation/host-runtime-bootstrap.test.ts b/packages/app/src/navigation/host-runtime-bootstrap.test.ts index ed6a8ccbe9..603532086d 100644 --- a/packages/app/src/navigation/host-runtime-bootstrap.test.ts +++ b/packages/app/src/navigation/host-runtime-bootstrap.test.ts @@ -6,6 +6,7 @@ import { resolveStartupRoute, shouldRunStartupGiveUpTimer, startHostRuntimeBootstrap, + bindHostRuntimeAppState, } from "./host-runtime-bootstrap"; import type { DaemonStartResult, StartDaemonIfEnabledInput } from "@/runtime/daemon-start-service"; @@ -393,3 +394,34 @@ describe("resolveHostIndexRoute", () => { ).toEqual("/open-project"); }); }); + +describe("host runtime app lifecycle", () => { + it.each(["inactive", "background"] as const)( + "starts with reconnect disabled when mounted %s", + (currentState) => { + const visibility: boolean[] = []; + let listener: ((state: "active" | "background") => void) | undefined; + let subscribed = true; + const dispose = bindHostRuntimeAppState( + { setAppVisible: (visible) => visibility.push(visible) }, + { + currentState, + addEventListener: (_event, handler) => { + listener = handler; + return { + remove: () => { + subscribed = false; + }, + }; + }, + }, + ); + expect(visibility).toEqual([false]); + listener?.("active"); + listener?.("background"); + expect(visibility).toEqual([false, true, false]); + dispose(); + expect(subscribed).toBe(false); + }, + ); +}); diff --git a/packages/app/src/navigation/host-runtime-bootstrap.ts b/packages/app/src/navigation/host-runtime-bootstrap.ts index 6fbceb8a21..8ee1f7bfb2 100644 --- a/packages/app/src/navigation/host-runtime-bootstrap.ts +++ b/packages/app/src/navigation/host-runtime-bootstrap.ts @@ -1,3 +1,4 @@ +import type { AppState } from "react-native"; import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import type { DaemonStartCondition, @@ -250,3 +251,14 @@ export function resolveStartupRoute(input: ResolveStartupRouteInput): StartupRou return resolveReadyIndexStartupRoute(input); } + +export function bindHostRuntimeAppState( + store: { setAppVisible: (visible: boolean) => void }, + appState: Pick, +): () => void { + const subscription = appState.addEventListener("change", (state) => { + store.setAppVisible(state === "active"); + }); + store.setAppVisible(appState.currentState === "active"); + return () => subscription.remove(); +} diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 254271aac3..c2129c654c 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -1,3 +1,4 @@ +import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { Button } from "@/components/ui/button"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; @@ -310,11 +311,7 @@ function storeFetchedAgentDetail(input: { const store = useSessionStore.getState(); if (shouldStoreFetchedAgentInActiveDirectory(hydrated)) { - store.setAgents(input.serverId, (previous) => { - const next = new Map(previous); - next.set(hydrated.id, hydrated); - return next; - }); + getHostRuntimeStore().acceptAgentSnapshot(input.serverId, hydrated); } else { store.setAgentDetails(input.serverId, (previous) => { const next = new Map(previous); @@ -414,11 +411,7 @@ function DraftPanel() { (agentSnapshot: Parameters[0]) => { const normalized = normalizeAgentSnapshot(agentSnapshot, serverId); const agent = applyLegacyDaemonWorkspaceOwnership({ serverId, agent: normalized }); - useSessionStore.getState().setAgents(serverId, (prev) => { - const next = new Map(prev); - next.set(agentSnapshot.id, agent); - return next; - }); + getHostRuntimeStore().acceptAgentSnapshot(serverId, agent); retargetCurrentTab({ kind: "agent", agentId: agentSnapshot.id }); }, [retargetCurrentTab, serverId], diff --git a/packages/app/src/plugins/client-state/snapshots.test.ts b/packages/app/src/plugins/client-state/snapshots.test.ts index 26ac4069dc..cbbbaaed4b 100644 --- a/packages/app/src/plugins/client-state/snapshots.test.ts +++ b/packages/app/src/plugins/client-state/snapshots.test.ts @@ -25,7 +25,7 @@ const agent: Agent = { id: "agent-1", provider: "codex", status: "running", - activeTurn: null, + turn: { phase: "idle", cancellationRequestId: null }, createdAt: new Date("2026-08-16T10:01:00.000Z"), updatedAt: new Date("2026-08-16T10:02:00.000Z"), lastUserMessageAt: null, diff --git a/packages/app/src/runtime/directory-sync/agent-replica.test.ts b/packages/app/src/runtime/directory-sync/agent-replica.test.ts index c791949827..d7deff40a7 100644 --- a/packages/app/src/runtime/directory-sync/agent-replica.test.ts +++ b/packages/app/src/runtime/directory-sync/agent-replica.test.ts @@ -1,7 +1,9 @@ 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 { selectAgentTurnPresentation, useSessionStore } from "@/stores/session-store"; +import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; +import type { DirectoryReplicaMutation } from "@/runtime/replica-cache"; import { AgentDirectoryReplica } from "./agent-replica"; function payload(title: string): AgentSnapshotPayload { @@ -51,11 +53,38 @@ function entry(agent: AgentSnapshotPayload): FetchAgentsEntry { } describe("AgentDirectoryReplica", () => { + it("does not let a late cache read replace newer live turn state", () => { + const serverId = "agent-replica-late-cache"; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null as unknown as DaemonClient); + const replica = new AgentDirectoryReplica( + serverId, + () => undefined, + () => undefined, + ); + replica.commitSnapshot([entry(payload("live"))], []); + replica.applyTurnLiveness("agent", { + type: "stream_open", + turn: { turnId: "turn-live", startedAt: null }, + }); + + replica.commitCached(new Map([["agent", normalizeAgentSnapshot(payload("cached"), serverId)]])); + + expect(selectAgentTurnPresentation(store.getSession(serverId), "agent").turnId).toBe( + "turn-live", + ); + store.clearSession(serverId); + }); + it("accepts the requested agent from the authoritative timeline after a cache miss", () => { const serverId = "agent-replica-cache-miss"; const store = useSessionStore.getState(); store.initializeSession(serverId, null as unknown as DaemonClient); - const replica = new AgentDirectoryReplica(serverId, () => undefined); + const replica = new AgentDirectoryReplica( + serverId, + () => undefined, + () => undefined, + ); expect(replica.submitTimelineAgent(replica.captureTimeline("agent"), payload("network"))).toBe( true, @@ -66,11 +95,174 @@ describe("AgentDirectoryReplica", () => { store.clearSession(serverId); }); + it("persists stream-only turn transitions through the agent owner", () => { + const serverId = "agent-replica-stream-turn"; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null as unknown as DaemonClient); + const commits: DirectoryReplicaMutation[][] = []; + const replica = new AgentDirectoryReplica( + serverId, + () => undefined, + (mutations) => commits.push([...mutations]), + ); + replica.commitSnapshot([entry(payload("agent"))], []); + commits.length = 0; + + replica.applyTurnLiveness("agent", { + type: "stream_open", + turn: { turnId: "turn-1", startedAt: new Date("2026-08-31T12:00:00.000Z") }, + }); + + expect(commits).toEqual([ + [ + expect.objectContaining({ + kind: "agent", + type: "upsert", + id: "agent", + value: expect.objectContaining({ + status: "idle", + turn: { + phase: "open", + cancellationRequestId: null, + turnId: "turn-1", + startedAt: new Date("2026-08-31T12:00:00.000Z"), + }, + }), + }), + ], + ]); + expect(selectAgentTurnPresentation(store.getSession(serverId), "agent").isActive).toBe(true); + store.clearSession(serverId); + }); + + it("notifies the queue owner exactly once when a stream turn closes", () => { + const serverId = "agent-replica-stream-stop"; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null as unknown as DaemonClient); + const stopped: string[] = []; + const replica = new AgentDirectoryReplica( + serverId, + (agentId) => stopped.push(agentId), + () => undefined, + ); + replica.commitSnapshot([entry(payload("agent"))], []); + replica.applyTurnLiveness("agent", { + type: "stream_open", + turn: { turnId: "turn-1", startedAt: null }, + }); + + replica.applyTurnLiveness("agent", { type: "stream_close", turnId: "turn-1" }); + replica.applyTurnLiveness("agent", { type: "stream_close", turnId: "turn-1" }); + + expect(stopped).toEqual(["agent"]); + store.clearSession(serverId); + }); + + it("preserves cancellation state when the same active turn is snapshotted", () => { + const serverId = "agent-replica-cancellation-snapshot"; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null as unknown as DaemonClient); + const replica = new AgentDirectoryReplica( + serverId, + () => undefined, + () => undefined, + ); + replica.commitSnapshot( + [ + entry({ + ...payload("running"), + activeTurn: { turnId: "turn-1", startedAt: null }, + }), + ], + [], + ); + replica.applyTurnLiveness("agent", { type: "cancellation_started", requestId: 7 }); + + replica.applyDelta({ + kind: "upsert", + agent: { + ...payload("same running turn"), + activeTurn: { turnId: "turn-1", startedAt: null }, + }, + project: entry(payload("project")).project, + }); + + expect(useSessionStore.getState().sessions[serverId]?.agents.get("agent")?.turn).toMatchObject({ + phase: "open", + turnId: "turn-1", + cancellationRequestId: 7, + }); + + replica.commitSnapshot( + [ + entry({ + ...payload("same turn from refresh"), + activeTurn: { turnId: "turn-1", startedAt: null }, + }), + ], + [], + ); + expect(useSessionStore.getState().sessions[serverId]?.agents.get("agent")?.turn).toMatchObject({ + phase: "open", + turnId: "turn-1", + cancellationRequestId: 7, + }); + store.clearSession(serverId); + }); + + it("uses turn liveness for stopped transitions even when protocol status disagrees", () => { + const serverId = "agent-replica-mismatched-status"; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null as unknown as DaemonClient); + const stopped: string[] = []; + const replica = new AgentDirectoryReplica( + serverId, + (agentId) => stopped.push(agentId), + () => undefined, + ); + replica.commitSnapshot( + [ + entry({ + ...payload("open despite idle"), + activeTurn: { turnId: "turn-1", startedAt: null }, + }), + ], + [], + ); + + expect(useSessionStore.getState().sessions[serverId]?.agents.get("agent")).toMatchObject({ + status: "idle", + turn: { phase: "open", turnId: "turn-1" }, + }); + + replica.applyDelta({ + kind: "upsert", + agent: { ...payload("idle turn despite running"), status: "running", activeTurn: null }, + project: entry(payload("project")).project, + }); + replica.applyDelta({ + kind: "upsert", + agent: { ...payload("status-only update"), status: "idle", activeTurn: null }, + project: entry(payload("project")).project, + }); + + expect(useSessionStore.getState().sessions[serverId]?.agents.get("agent")).toMatchObject({ + status: "idle", + turn: { phase: "idle" }, + }); + expect(stopped).toEqual(["agent"]); + store.clearSession(serverId); + }); + it("preserves an unchanged running agent's turn identity during catch-up", () => { const serverId = "agent-replica-catch-up"; const store = useSessionStore.getState(); store.initializeSession(serverId, null as unknown as DaemonClient); - const replica = new AgentDirectoryReplica(serverId, () => undefined); + const replica = new AgentDirectoryReplica( + serverId, + () => undefined, + () => undefined, + ); replica.commitSnapshot( [ entry({ @@ -84,9 +276,11 @@ describe("AgentDirectoryReplica", () => { replica.commitChanges([], [], []); - expect(useSessionStore.getState().sessions[serverId]?.agents.get("agent")?.activeTurn).toEqual({ + expect(useSessionStore.getState().sessions[serverId]?.agents.get("agent")?.turn).toEqual({ + phase: "open", turnId: "turn-1", startedAt: new Date("2026-07-17T00:01:00.000Z"), + cancellationRequestId: null, }); store.clearSession(serverId); }); @@ -95,7 +289,11 @@ describe("AgentDirectoryReplica", () => { const serverId = "agent-replica"; const store = useSessionStore.getState(); store.initializeSession(serverId, null as unknown as DaemonClient); - const replica = new AgentDirectoryReplica(serverId, () => undefined); + const replica = new AgentDirectoryReplica( + serverId, + () => undefined, + () => undefined, + ); replica.commitSnapshot([entry(payload("directory"))], []); const directoryPlacement = useSessionStore .getState() diff --git a/packages/app/src/runtime/directory-sync/agent-replica.ts b/packages/app/src/runtime/directory-sync/agent-replica.ts index c9ff7424a4..1bdf3ea3de 100644 --- a/packages/app/src/runtime/directory-sync/agent-replica.ts +++ b/packages/app/src/runtime/directory-sync/agent-replica.ts @@ -2,18 +2,14 @@ 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 type { Agent } from "@/stores/session-store"; import { normalizeAgentSnapshot, projectAgentSnapshot } from "@/utils/agent-snapshots"; -import { - applyAgentDirectoryDelta, - type AgentDirectoryDelta, - removeAgentDirectoryReplica, - replaceAgentPendingPermissions, - replaceFetchedAgentDirectory, - upsertAgentReplica, -} from "@/utils/agent-directory-sync"; +import { type AgentDirectoryDelta } from "@/utils/agent-directory-sync"; import { reconcileAgentDirectory } from "@/utils/agent-directory-reconciliation"; import { applyLegacyDaemonWorkspaceOwnership } from "@/workspace/legacy-daemon-workspaces"; +import type { DirectoryReplicaMutation } from "@/runtime/replica-cache"; +import type { TurnLivenessTransition } from "@/timeline/turn-liveness"; +import { AgentStoreProjection } from "./internal/agent-store"; function projectAgentDirectoryEntry(agent: Agent): FetchAgentsEntry | null { return agent.projectPlacement @@ -30,28 +26,35 @@ export class AgentDirectoryReplica { private readonly lifecycleVersions = new Map(); private readonly members = new Set(); private readonly pendingCacheReads = new Set(); + private readonly storeProjection: AgentStoreProjection; constructor( private readonly serverId: string, private readonly onStoppedRunning: (agentId: string) => void, - ) {} + private readonly persist: (mutations: readonly DirectoryReplicaMutation[]) => void, + ) { + this.storeProjection = new AgentStoreProjection(serverId); + } captureTimeline(agentId: string): AgentLifecycleToken { return { agentId, version: this.lifecycleVersions.get(agentId) ?? 0 }; } + snapshot(): Map { + return this.storeProjection.snapshot(); + } + captureCache(agentId: string): AgentLifecycleToken { this.pendingCacheReads.add(agentId); return this.captureTimeline(agentId); } commitCached(agents: Map): void { + const merged = this.storeProjection.commitCached(agents); this.members.clear(); - for (const [agentId, agent] of agents) { + for (const agentId of merged.keys()) { this.members.add(agentId); - useSessionStore.getState().setAgentLastActivity(agentId, agent.lastActivityAt); } - useSessionStore.getState().setAgents(this.serverId, agents); } commitCachedAgent(token: AgentLifecycleToken, agent: Agent): boolean { @@ -59,13 +62,8 @@ export class AgentDirectoryReplica { if (token.version !== (this.lifecycleVersions.get(token.agentId) ?? 0)) return false; if (this.members.has(agent.id)) return false; this.members.add(agent.id); - useSessionStore.getState().setAgents(this.serverId, (current) => { - if (current.has(agent.id)) return current; - const next = new Map(current); - next.set(agent.id, agent); - return next; - }); - useSessionStore.getState().setAgentLastActivity(agent.id, agent.lastActivityAt); + this.storeProjection.accept(agent); + this.storeProjection.publishActivity(agent); return true; } @@ -73,7 +71,7 @@ export class AgentDirectoryReplica { if (token.version !== (this.lifecycleVersions.get(token.agentId) ?? 0)) { return false; } - const existing = useSessionStore.getState().sessions[this.serverId]?.agents.get(token.agentId); + const existing = this.storeProjection.get(token.agentId); const timelineAgent = applyLegacyDaemonWorkspaceOwnership({ serverId: this.serverId, agent: normalizeAgentSnapshot(payload, this.serverId), @@ -82,19 +80,20 @@ export class AgentDirectoryReplica { ...timelineAgent, projectPlacement: timelineAgent.projectPlacement ?? existing?.projectPlacement, }; - const accepted = upsertAgentReplica(this.serverId, normalized); + const accepted = this.storeProjection.accept(normalized); this.members.add(accepted.id); - replaceAgentPendingPermissions(this.serverId, accepted); - useSessionStore.getState().setAgentLastActivity(accepted.id, accepted.lastActivityAt); + this.storeProjection.replacePendingPermissions(accepted); + this.storeProjection.publishActivity(accepted); if (accepted.archivedAt) { clearArchiveAgentPending({ queryClient, serverId: this.serverId, agentId: accepted.id }); } + this.persist([this.agentUpsert(accepted)]); 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 }); + const result = this.storeProjection.applyDelta(delta); if (delta.kind === "remove") { this.members.delete(delta.agentId); this.advance(delta.agentId); @@ -103,15 +102,28 @@ export class AgentDirectoryReplica { if (!before) this.advance(delta.agent.id); } if (result.stoppedRunning) this.onStoppedRunning(result.agentId); + this.persist( + result.agent + ? [this.agentUpsert(result.agent)] + : [{ kind: "agent", type: "delete", id: result.agentId }], + ); + } + + accept(agent: Agent): Agent { + const accepted = this.storeProjection.accept(agent); + this.members.add(accepted.id); + this.persist([this.agentUpsert(accepted)]); + return accepted; } commitSnapshot( entries: FetchAgentsEntry[], deltas: readonly AgentDirectoryDelta[], + persist = true, ): Map { - 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)); + const previous = this.storeProjection.snapshot(); + const reconciled = reconcileAgentDirectory({ snapshot: entries, deltas }); + const nextIds = new Set(reconciled.map((entry) => entry.agent.id)); for (const agentId of this.pendingCacheReads) { if (!nextIds.has(agentId)) this.advance(agentId); } @@ -122,16 +134,25 @@ export class AgentDirectoryReplica { if (!this.members.has(agentId)) this.advance(agentId); } for (const agentId of previous.keys()) { - if (!nextIds.has(agentId)) removeAgentDirectoryReplica(this.serverId, agentId); + if (!nextIds.has(agentId)) this.storeProjection.remove(agentId); } this.members.clear(); this.pendingCacheReads.clear(); for (const agentId of nextIds) this.members.add(agentId); - const { agents } = replaceFetchedAgentDirectory({ - serverId: this.serverId, - entries: reconciled.entries, - }); - for (const agentId of reconciled.stoppedRunningAgentIds) this.onStoppedRunning(agentId); + const agents = this.storeProjection.replaceFetched(reconciled); + for (const [agentId, previousAgent] of previous) { + if (previousAgent.turn.phase === "open" && agents.get(agentId)?.turn.phase === "idle") { + this.onStoppedRunning(agentId); + } + } + if (persist) { + this.persist([ + ...Array.from(previous.keys()) + .filter((agentId) => !agents.has(agentId)) + .map((id): DirectoryReplicaMutation => ({ kind: "agent", type: "delete", id })), + ...Array.from(agents.values(), (value) => this.agentUpsert(value)), + ]); + } return agents; } @@ -140,7 +161,7 @@ export class AgentDirectoryReplica { removals: readonly { id: string }[], deltas: readonly AgentDirectoryDelta[], ): Map { - const previous = useSessionStore.getState().sessions[this.serverId]?.agents ?? new Map(); + const previous = this.storeProjection.snapshot(); const merged = new Map(); for (const agent of previous.values()) { const entry = projectAgentDirectoryEntry(agent); @@ -151,25 +172,57 @@ export class AgentDirectoryReplica { kind: "remove", agentId: id, })); - return this.commitSnapshot(Array.from(merged.values()), [...removalsAsDeltas, ...deltas]); + const agents = this.commitSnapshot( + Array.from(merged.values()), + [...removalsAsDeltas, ...deltas], + false, + ); + const touchedIds = new Set([ + ...entries.map((entry) => entry.agent.id), + ...removals.map(({ id }) => id), + ...deltas.map((delta) => (delta.kind === "remove" ? delta.agentId : delta.agent.id)), + ]); + this.persist( + Array.from(touchedIds, (id): DirectoryReplicaMutation => { + const value = agents.get(id); + return value ? this.agentUpsert(value) : { kind: "agent", type: "delete", id }; + }), + ); + 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; - }); + const archived = this.storeProjection.archive(agentId, archivedAt); + if (archived) this.persist([this.agentUpsert(archived)]); clearArchiveAgentPending({ queryClient, serverId: this.serverId, agentId }); } remove(agentId: string): void { this.members.delete(agentId); this.advance(agentId); - removeAgentDirectoryReplica(this.serverId, agentId); + this.storeProjection.remove(agentId); + this.persist([{ kind: "agent", type: "delete", id: agentId }]); + } + + applyTurnLiveness( + agentId: string, + transition: TurnLivenessTransition | readonly TurnLivenessTransition[], + ): void { + const wasRunning = this.storeProjection.get(agentId)?.turn.phase === "open"; + const accepted = this.storeProjection.applyTurn(agentId, transition); + if (!accepted) return; + this.persist([this.agentUpsert(accepted)]); + if (wasRunning && accepted.turn.phase === "idle") this.onStoppedRunning(agentId); + } + + private agentUpsert(agent: Agent): DirectoryReplicaMutation { + return { + kind: "agent", + type: "upsert", + id: agent.id, + value: agent, + }; } private advance(agentId: string): void { diff --git a/packages/app/src/runtime/directory-sync/index.test.ts b/packages/app/src/runtime/directory-sync/index.test.ts index 2008abb6a1..1593c4752f 100644 --- a/packages/app/src/runtime/directory-sync/index.test.ts +++ b/packages/app/src/runtime/directory-sync/index.test.ts @@ -8,7 +8,7 @@ import { } from "@/stores/session-store"; import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { selectWorkspaceDirectoryServerIds } from "@/stores/session-store-hooks/selectors"; -import type { CachedDirectory } from "@/runtime/replica-cache"; +import type { DirectoryReplicaMutation } from "@/runtime/replica-cache"; import { DirectoryRefreshSupersededError, DirectorySync, @@ -182,7 +182,7 @@ afterEach(() => { }); describe("DirectorySync session readiness", () => { - it("paints a demanded cached directory while offline", async () => { + it("restores the cached directory before network demand", async () => { const serverId = "offline-cached-directory"; serverIds.add(serverId); const client = new FakeDirectoryClient(); @@ -224,12 +224,12 @@ describe("DirectorySync session readiness", () => { workspaces: new Map([[cachedWorkspace.id, cachedWorkspace]]), projects: new Map([[cachedProject.projectId, cachedProject]]), }), - commitDirectory: () => undefined, + commitDirectoryMutations: () => undefined, }, ); useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient, 1); - await directory.refreshAll(); + await directory.restoreCachedDirectory(); expect( useSessionStore.getState().sessions[serverId]?.workspaces.get(cachedWorkspace.id), @@ -280,7 +280,7 @@ describe("DirectorySync session readiness", () => { workspaces: new Map(), projects: new Map(), }), - commitDirectory: () => undefined, + commitDirectoryMutations: () => undefined, }, ); useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient, 1); @@ -301,7 +301,7 @@ describe("DirectorySync session readiness", () => { const serverId = "script-status-owner"; serverIds.add(serverId); const client = new FakeDirectoryClient(); - const commits: CachedDirectory[] = []; + const commits: DirectoryReplicaMutation[][] = []; const directory = new DirectorySync( serverId, { @@ -318,7 +318,7 @@ describe("DirectorySync session readiness", () => { workspaces: new Map(), projects: new Map(), }), - commitDirectory: (_serverId, value) => commits.push(value), + commitDirectoryMutations: (_serverId, mutations) => commits.push([...mutations]), }, ); directory.connectionChanged({ @@ -344,7 +344,8 @@ describe("DirectorySync session readiness", () => { }); const store = useSessionStore.getState(); store.initializeSession(serverId, client as unknown as DaemonClient, 1); - store.setWorkspaces(serverId, new Map([[workspace.id, workspace]])); + directory.acceptWorkspaces([workspace]); + commits.length = 0; client.emit({ type: "script_status_update", @@ -370,7 +371,17 @@ describe("DirectorySync session readiness", () => { useSessionStore.getState().sessions[serverId]?.workspaces.get(workspace.id)?.scripts[0] ?.lifecycle, ).toBe("running"); - expect(commits.at(-1)?.workspaces.get(workspace.id)?.scripts[0]?.lifecycle).toBe("running"); + const persisted = commits + .flat() + .find( + ( + mutation, + ): mutation is Extract => + mutation.kind === "workspace" && + mutation.type === "upsert" && + mutation.id === workspace.id, + ); + expect(persisted?.value.scripts[0]?.lifecycle).toBe("running"); directory.dispose(); }); @@ -476,7 +487,7 @@ describe("DirectorySync session readiness", () => { projects: new Map([[cachedProject.projectId, cachedProject]]), }; }, - commitDirectory: () => undefined, + commitDirectoryMutations: () => undefined, }, ); directory.connectionChanged({ @@ -556,7 +567,7 @@ describe("DirectorySync session readiness", () => { projects: new Map([[cachedProject.projectId, cachedProject]]), checkpoint: { workspaces: { generation: "g", afterSeq: 7 } }, }), - commitDirectory: () => undefined, + commitDirectoryMutations: () => undefined, }, ); directory.connectionChanged({ @@ -617,7 +628,7 @@ describe("DirectorySync session readiness", () => { readAgent: async () => undefined, readWorkspace: async () => undefined, readDirectory: () => cacheRead, - commitDirectory: () => undefined, + commitDirectoryMutations: () => undefined, }, ); directory.connectionChanged({ @@ -706,7 +717,7 @@ describe("DirectorySync session readiness", () => { workspaces: new Map(), projects: new Map(), }), - commitDirectory: () => undefined, + commitDirectoryMutations: () => undefined, }, ); directory.connectionChanged({ @@ -754,7 +765,7 @@ describe("DirectorySync session readiness", () => { workspaces: new Map(), projects: new Map(), }), - commitDirectory: () => undefined, + commitDirectoryMutations: () => undefined, }, ); directory.connectionChanged({ @@ -807,7 +818,7 @@ describe("DirectorySync session readiness", () => { projects: new Map(), checkpoint: { agents: { generation: "generation", afterSeq: 12 } }, }), - commitDirectory: () => undefined, + commitDirectoryMutations: () => undefined, }, ); directory.connectionChanged({ @@ -891,7 +902,10 @@ describe("DirectorySync session readiness", () => { const serverId = "project-list-sequence"; serverIds.add(serverId); const client = new FakeDirectoryClient(); - const writes: unknown[] = []; + const writes: Array<{ + mutations: readonly DirectoryReplicaMutation[]; + checkpoint: unknown; + }> = []; const cachedProjects = [ normalizeProjectDescriptor({ projectId: "project-1", @@ -905,6 +919,12 @@ describe("DirectorySync session readiness", () => { projectRootPath: "/repo/two", projectKind: "git", }), + normalizeProjectDescriptor({ + projectId: "untouched-project", + projectDisplayName: "Untouched", + projectRootPath: "/repo/untouched", + projectKind: "git", + }), ]; const directory = new DirectorySync( serverId, @@ -923,7 +943,8 @@ describe("DirectorySync session readiness", () => { projects: new Map(cachedProjects.map((project) => [project.projectId, project])), checkpoint: { projects: { generation: "generation", afterSeq: 4 } }, }), - commitDirectory: (_serverId, value) => writes.push(value.checkpoint), + commitDirectoryMutations: (_serverId, mutations, checkpoint) => + writes.push({ mutations: [...mutations], checkpoint }), }, ); directory.connectionChanged({ @@ -964,9 +985,17 @@ describe("DirectorySync session readiness", () => { sync: { generation: "generation", afterSeq: 4 }, }); const projects = useSessionStore.getState().sessions[serverId]?.projects; - expect(Array.from(projects?.keys() ?? [])).toEqual(["project-1"]); + expect(Array.from(projects?.keys() ?? [])).toEqual(["project-1", "untouched-project"]); expect(projects?.get("project-1")?.projectDisplayName).toBe("New name"); - expect(writes).toContainEqual({ projects: { generation: "generation", afterSeq: 6 } }); + expect(writes.map(({ checkpoint }) => checkpoint)).toContainEqual({ + projects: { generation: "generation", afterSeq: 6 }, + }); + const writtenProjectIds = writes + .flatMap(({ mutations }) => mutations) + .filter((mutation) => mutation.kind === "project") + .map((mutation) => mutation.id); + expect(writtenProjectIds).toEqual(["project-1", "project-2"]); + expect(writtenProjectIds).not.toContain("untouched-project"); directory.dispose(); }); diff --git a/packages/app/src/runtime/directory-sync/index.ts b/packages/app/src/runtime/directory-sync/index.ts index 90648ffd38..abcc98d5a7 100644 --- a/packages/app/src/runtime/directory-sync/index.ts +++ b/packages/app/src/runtime/directory-sync/index.ts @@ -36,7 +36,9 @@ import type { CachedWorkspace, DirectoryCheckpoint, DirectoryCursor, + DirectoryReplicaMutation, } from "@/runtime/replica-cache"; +import type { TurnLivenessTransition } from "@/timeline/turn-liveness"; const PAGE_LIMIT = 200; const AGENT_SORT: NonNullable = [ @@ -69,6 +71,36 @@ interface AgentPageInfo { afterCursor?: string | null; } +function applyWorkspaceSnapshotPage( + snapshot: WorkspaceDirectorySnapshot, + payload: Awaited>, + firstPage: boolean, +): void { + if (firstPage && payload.sync?.mode !== "changes") snapshot.workspaces.clear(); + if (firstPage) (snapshot.syncModes ??= {}).workspaces = payload.sync?.mode ?? "snapshot"; + for (const entry of payload.entries) { + const workspace = normalizeWorkspaceDescriptor(entry); + snapshot.workspaces.set(workspace.id, workspace); + (snapshot.touchedWorkspaceIds ??= new Set()).add(workspace.id); + (snapshot.touchedProjectIds ??= new Set()).add(workspace.projectId); + } + for (const removal of payload.sync?.removals ?? []) { + snapshot.workspaces.delete(removal.id); + (snapshot.touchedWorkspaceIds ??= new Set()).add(removal.id); + } + if (payload.sync) { + (snapshot.syncCursors ??= {}).workspaces = { + generation: payload.sync.generation, + afterSeq: payload.sync.headSeq, + }; + } + for (const entry of payload.emptyProjects ?? []) { + const project = normalizeProjectDescriptor(entry); + snapshot.projects.set(project.projectId, project); + (snapshot.touchedProjectIds ??= new Set()).add(project.projectId); + } +} + export interface DirectoryConnection { client: DaemonClient | null; status: "online" | "offline"; @@ -79,15 +111,12 @@ export interface DirectoryCheckpointStorage { readAgent(serverId: string, agentId: string): Promise; readWorkspace(serverId: string, workspaceId: string): Promise; readDirectory(serverId: string): Promise; - commitDirectory( + commitDirectoryMutations( serverId: string, - directory: { - agents: Map; - workspaces: Map; - projects: Map; - checkpoint?: DirectoryCheckpoint; - }, + mutations: readonly DirectoryReplicaMutation[], + checkpoint?: DirectoryCheckpoint, ): void; + replaceDirectoryBaseline?(serverId: string, directory: CachedDirectory): void; } export interface RefreshAgentDirectoryInput { @@ -139,7 +168,9 @@ export class DirectorySync { }, private readonly checkpoints?: DirectoryCheckpointStorage, ) { - this.agents = new AgentDirectoryReplica(serverId, callbacks.onAgentStoppedRunning); + const persist = (mutations: readonly DirectoryReplicaMutation[]) => + this.checkpoints?.commitDirectoryMutations(this.serverId, mutations); + this.agents = new AgentDirectoryReplica(serverId, callbacks.onAgentStoppedRunning, persist); this.workspaces = new WorkspaceDirectoryReplica(serverId); } @@ -170,7 +201,7 @@ export class DirectorySync { if (!recorded) { this.agents.applyDelta(message.payload); this.noteLiveCursor("agents", message.payload); - this.persistDirectory(); + this.persistCheckpoint(); } }), client.on("workspace_update", (message) => { @@ -179,9 +210,9 @@ export class DirectorySync { this.workspaceRevision += 1; const recorded = this.workspaceTransactions.record(source, message.payload); if (!recorded) { - this.workspaces.applyDelta(message.payload); + this.applyWorkspaceDelta(message.payload); this.noteLiveCursor("workspaces", message.payload); - this.persistDirectory(); + this.persistCheckpoint(); } }), client.on("project.update", (message) => { @@ -190,9 +221,9 @@ export class DirectorySync { this.workspaceRevision += 1; const recorded = this.workspaceTransactions.record(source, message.payload); if (!recorded) { - this.workspaces.applyDelta(message.payload); + this.applyWorkspaceDelta(message.payload); this.noteLiveCursor("projects", message.payload); - this.persistDirectory(); + this.persistCheckpoint(); } }), client.on("script_status_update", (message) => { @@ -205,22 +236,22 @@ export class DirectorySync { }; const recorded = this.workspaceTransactions.record(source, delta); if (!recorded) { - this.workspaces.applyDelta(delta); - this.persistDirectory(); + this.applyWorkspaceDelta(delta); + this.persistCheckpoint(); } }), client.on("agent_deleted", (message) => { if (message.type === "agent_deleted" && this.isCurrent(client, source)) { this.revision += 1; this.agents.remove(message.payload.agentId); - this.persistDirectory(); + this.persistCheckpoint(); } }), client.on("agent_archived", (message) => { if (message.type === "agent_archived" && this.isCurrent(client, source)) { this.revision += 1; this.agents.archive(message.payload.agentId, message.payload.archivedAt); - this.persistDirectory(); + this.persistCheckpoint(); } }), ]; @@ -352,24 +383,9 @@ export class DirectorySync { if (!checkpoints) return Promise.resolve(); this.cacheLoad ??= (async () => { const revision = this.revision; - const initial = useSessionStore.getState().sessions[this.serverId]; - if (!initial) return; - const initialAgents = initial.agents; - const initialWorkspaces = initial.workspaces; - const initialProjects = initial.projects; - const pristine = - initialAgents.size === 0 && initialWorkspaces.size === 0 && initialProjects.size === 0; const cached = await checkpoints.readDirectory(this.serverId); - if (this.cacheAccepted || !pristine || this.revision !== revision) return; - const session = useSessionStore.getState().sessions[this.serverId]; - if ( - !session || - session.agents !== initialAgents || - session.workspaces !== initialWorkspaces || - session.projects !== initialProjects - ) { - return; - } + if (this.cacheAccepted || this.revision !== revision) return; + if (!useSessionStore.getState().sessions[this.serverId]) return; this.agents.commitCached(cached.agents); this.workspaces.commitCached(cached); this.cursors = cached.checkpoint ?? {}; @@ -378,6 +394,21 @@ export class DirectorySync { return this.cacheLoad; } + restoreCachedDirectory(): Promise { + return this.loadCachedDirectory(); + } + + applyAgentTurnLiveness( + agentId: string, + transition: TurnLivenessTransition | readonly TurnLivenessTransition[], + ): void { + this.agents.applyTurnLiveness(agentId, transition); + } + + acceptAgent(agent: Agent): Agent { + return this.agents.accept(agent); + } + async fetchTimeline( agentId: string, request: Parameters[1], @@ -387,7 +418,7 @@ export class DirectorySync { const page = await fetchAgentTimelineOnce(client, agentId, request); if (page.agent && this.agents.submitTimelineAgent(token, page.agent)) { this.revision += 1; - this.persistDirectory(); + this.persistCheckpoint(); } return page; } @@ -452,14 +483,25 @@ export class DirectorySync { throw new DirectoryRefreshSupersededError("agent completion was superseded"); } if (completion.snapshot.legacy) { - const store = useSessionStore.getState(); const workspaces = buildLegacyWorkspaces(completion.snapshot.entries); - store.setWorkspaces(this.serverId, workspaces); - store.setProjects( - this.serverId, - Array.from(workspaces.values(), legacyProjectDescriptorFromWorkspace), + const previous = this.readWorkspaceState(); + this.workspaces.commitSnapshot( + { + workspaces, + projects: new Map( + Array.from(workspaces.values(), legacyProjectDescriptorFromWorkspace).map( + (project) => [project.projectId, project], + ), + ), + }, + [], + ); + const next = this.workspaces.snapshot(); + this.persistWorkspaceChanges( + previous, + new Set([...previous.workspaces.keys(), ...next.workspaces.keys()]), + new Set([...previous.projects.keys(), ...next.projects.keys()]), ); - store.setHasHydratedWorkspaces(this.serverId, true); } const deltas = completion.snapshot.legacy ? completion.deltas.map((delta) => @@ -471,7 +513,7 @@ export class DirectorySync { this.revision += 1; const agents = this.commitAgentSnapshot(completion.snapshot, deltas); this.persistAgentCursors(completion.snapshot, completion.deltas); - this.persistDirectory(); + this.persistCheckpoint(); this.callbacks.markAgentReady(); return { agents, subscriptionId: completion.snapshot.subscriptionId }; } catch (error) { @@ -500,13 +542,16 @@ export class DirectorySync { workspaces: new Map(useSessionStore.getState().sessions[this.serverId]?.workspaces), projects: new Map(useSessionStore.getState().sessions[this.serverId]?.projects), syncCursors: {}, + syncModes: {}, + touchedWorkspaceIds: new Set(), + touchedProjectIds: new Set(), })); try { await this.waitForSessionMetadata(client, source); const serverInfo = useSessionStore.getState().sessions[this.serverId]?.serverInfo; if (serverInfo?.features?.workspaceMultiplicity !== true) { const deltas = this.workspaceTransactions.fail(transaction); - if (deltas) for (const delta of deltas) this.workspaces.applyDelta(delta); + if (deltas) for (const delta of deltas) this.applyWorkspaceDelta(delta); return; } const supportsProjectList = serverInfo.features?.projectList === true; @@ -521,11 +566,15 @@ export class DirectorySync { input?.subscribe === true, supportsDirectorySync, ); - if (!supportsProjectList) this.buildLegacyProjectSnapshot(transaction.snapshot); + if (!supportsProjectList) { + this.buildLegacyProjectSnapshot(transaction.snapshot); + (transaction.snapshot.syncModes ??= {}).projects = + transaction.snapshot.syncModes?.workspaces; + } this.completeWorkspaceRefresh(client, source, transaction); } catch (error) { const deltas = this.workspaceTransactions.fail(transaction); - if (deltas) for (const delta of deltas) this.workspaces.applyDelta(delta); + if (deltas) for (const delta of deltas) this.applyWorkspaceDelta(delta); throw error; } } @@ -547,26 +596,7 @@ export class DirectorySync { ...(supportsDirectorySync ? { sync: this.readCursors().workspaces ?? {} } : {}), }); this.assertWorkspaceTransactionCurrent(client, source, transaction); - if (cursor === null && payload.sync?.mode !== "changes") { - transaction.snapshot.workspaces.clear(); - } - for (const entry of payload.entries) { - const workspace = normalizeWorkspaceDescriptor(entry); - transaction.snapshot.workspaces.set(workspace.id, workspace); - } - for (const removal of payload.sync?.removals ?? []) { - transaction.snapshot.workspaces.delete(removal.id); - } - if (payload.sync) { - (transaction.snapshot.syncCursors ??= {}).workspaces = { - generation: payload.sync.generation, - afterSeq: payload.sync.headSeq, - }; - } - for (const entry of payload.emptyProjects ?? []) { - const project = normalizeProjectDescriptor(entry); - transaction.snapshot.projects.set(project.projectId, project); - } + applyWorkspaceSnapshotPage(transaction.snapshot, payload, cursor === null); if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) return; cursor = payload.pageInfo.nextCursor; subscribe = false; @@ -587,6 +617,11 @@ export class DirectorySync { this.refreshAgents({ subscribe: {} }), this.refreshWorkspaces({ subscribe: true }), ]); + this.checkpoints?.replaceDirectoryBaseline?.(this.serverId, { + agents: this.agents.snapshot(), + ...this.workspaces.snapshot(), + checkpoint: this.cursors, + }); if (this.getOnlineConnection()) await this.connectWorkspaceLabels(); } @@ -600,6 +635,33 @@ export class DirectorySync { }); } + acceptWorkspaces(workspaces: readonly WorkspaceDescriptor[]): void { + const mutations = this.workspaces.acceptWorkspaces(workspaces); + this.checkpoints?.commitDirectoryMutations(this.serverId, mutations); + } + + archiveAgent(agentId: string, archivedAt: string): void { + this.agents.archive(agentId, archivedAt); + } + + removeAgent(agentId: string): void { + this.agents.remove(agentId); + } + + acceptProject(project: ProjectDescriptor): void { + const mutations = this.workspaces.acceptProject(project); + this.checkpoints?.commitDirectoryMutations(this.serverId, mutations); + } + + removeWorkspace(workspaceId: string): void { + const mutations = this.workspaces.removeWorkspaceSnapshot(workspaceId); + this.checkpoints?.commitDirectoryMutations(this.serverId, mutations); + } + + markWorkspacesHydrated(hydrated: boolean): void { + useSessionStore.getState().setHasHydratedWorkspaces(this.serverId, hydrated); + } + private async fetchAgents( client: DaemonClient, source: DirectorySourceToken, @@ -709,12 +771,15 @@ export class DirectorySync { ); this.assertWorkspaceTransactionCurrent(client, source, transaction); if (payload.sync?.mode !== "changes") transaction.snapshot.projects.clear(); + (transaction.snapshot.syncModes ??= {}).projects = payload.sync?.mode ?? "snapshot"; for (const entry of payload.projects) { const project = normalizeProjectDescriptor(entry); transaction.snapshot.projects.set(project.projectId, project); + (transaction.snapshot.touchedProjectIds ??= new Set()).add(project.projectId); } for (const removal of payload.sync?.removals ?? []) { transaction.snapshot.projects.delete(removal.id); + (transaction.snapshot.touchedProjectIds ??= new Set()).add(removal.id); } if (payload.sync) { (transaction.snapshot.syncCursors ??= {}).projects = { @@ -738,7 +803,22 @@ export class DirectorySync { } this.revision += 1; this.workspaceRevision += 1; - this.workspaces.commitSnapshot(completion.snapshot, completion.deltas); + const previous = this.readWorkspaceState(); + const deltaMutations = this.workspaces.commitSnapshot(completion.snapshot, completion.deltas); + const next = this.workspaces.snapshot(); + const workspaceIds = + completion.snapshot.syncModes?.workspaces === "changes" + ? new Set(completion.snapshot.touchedWorkspaceIds) + : new Set([...previous.workspaces.keys(), ...next.workspaces.keys()]); + const projectIds = + completion.snapshot.syncModes?.projects === "changes" + ? new Set(completion.snapshot.touchedProjectIds) + : new Set([...previous.projects.keys(), ...next.projects.keys()]); + for (const mutation of deltaMutations) { + if (mutation.kind === "workspace") workspaceIds.add(mutation.id); + if (mutation.kind === "project") projectIds.add(mutation.id); + } + this.persistWorkspaceChanges(previous, workspaceIds, projectIds); for (const [entity, cursor] of Object.entries(completion.snapshot.syncCursors ?? {})) { if (cursor) this.writeCursor(entity as "projects" | "workspaces", cursor); } @@ -747,19 +827,41 @@ export class DirectorySync { const entity = "projectId" in delta || "project" in delta ? "projects" : "workspaces"; this.noteLiveCursor(entity, delta); } - this.persistDirectory(); + this.persistCheckpoint(); } - private persistDirectory(): void { - if (!this.checkpoints) return; - const session = useSessionStore.getState().sessions[this.serverId]; - if (!session) return; - this.checkpoints.commitDirectory(this.serverId, { - agents: session.agents, - workspaces: session.workspaces, - projects: session.projects, - checkpoint: this.cursors, - }); + private persistCheckpoint(): void { + this.checkpoints?.commitDirectoryMutations(this.serverId, [], this.cursors); + } + + private readWorkspaceState(): WorkspaceDirectorySnapshot { + return this.workspaces.snapshot(); + } + + private applyWorkspaceDelta(delta: WorkspaceDirectoryDelta): void { + const mutations = this.workspaces.applyDelta(delta); + this.checkpoints?.commitDirectoryMutations(this.serverId, mutations); + } + + private persistWorkspaceChanges( + previous: WorkspaceDirectorySnapshot, + workspaceIds: Set, + projectIds: Set, + ): void { + const next = this.workspaces.snapshot(); + const mutations: DirectoryReplicaMutation[] = []; + for (const id of workspaceIds) { + const value = next.workspaces.get(id); + if (value) mutations.push({ kind: "workspace", type: "upsert", id, value }); + else if (previous.workspaces.has(id)) + mutations.push({ kind: "workspace", type: "delete", id }); + } + for (const id of projectIds) { + const value = next.projects.get(id); + if (value) mutations.push({ kind: "project", type: "upsert", id, value }); + else if (previous.projects.has(id)) mutations.push({ kind: "project", type: "delete", id }); + } + this.checkpoints?.commitDirectoryMutations(this.serverId, mutations); } private recordAgentSync( @@ -858,7 +960,7 @@ export class DirectorySync { 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); + for (const delta of this.workspaceTransactions.abort()) this.applyWorkspaceDelta(delta); } private abortPendingSessionWaits(): void { diff --git a/packages/app/src/runtime/directory-sync/internal/agent-store.ts b/packages/app/src/runtime/directory-sync/internal/agent-store.ts new file mode 100644 index 0000000000..0566da5aaf --- /dev/null +++ b/packages/app/src/runtime/directory-sync/internal/agent-store.ts @@ -0,0 +1,216 @@ +import equal from "fast-deep-equal"; +import type { FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client"; +import { useSessionStore, type Agent } from "@/stores/session-store"; +import { acceptAgentDirectoryUpdate } from "@/utils/agent-directory-update-policy"; +import { buildAgentDirectoryState, type AgentDirectoryDelta } from "@/utils/agent-directory-sync"; +import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots"; +import { resolveProjectPlacement } from "@/utils/project-placement"; +import { clearArchiveAgentPending } from "@/hooks/use-archive-agent"; +import { queryClient } from "@/data/query-client"; +import { buildDraftStoreKey } from "@/stores/draft-keys"; +import { useDraftStore } from "@/stores/draft-store"; +import { getInitDeferred, getInitKey, rejectInitDeferred } from "@/utils/agent-initialization"; +import { reduceTurnLiveness, type TurnLivenessTransition } from "@/timeline/turn-liveness"; + +function mergeSnapshotTurn(previous: Agent | undefined, incoming: Agent): Agent { + if (!previous) return incoming; + const activeTurn = + incoming.turn.phase === "open" + ? { turnId: incoming.turn.turnId, startedAt: incoming.turn.startedAt } + : null; + const turn = reduceTurnLiveness(previous.turn, { type: "snapshot", activeTurn }); + return turn === incoming.turn ? incoming : { ...incoming, turn }; +} + +export class AgentStoreProjection { + constructor(private readonly serverId: string) {} + + snapshot(): Map { + return useSessionStore.getState().sessions[this.serverId]?.agents ?? new Map(); + } + + get(agentId: string): Agent | undefined { + const session = useSessionStore.getState().sessions[this.serverId]; + return session?.agents.get(agentId) ?? session?.agentDetails.get(agentId); + } + + commitCached(agents: Map): Map { + const merged = new Map(agents); + for (const [agentId, agent] of this.snapshot()) merged.set(agentId, agent); + for (const agent of merged.values()) this.publishActivity(agent); + useSessionStore.getState().setAgents(this.serverId, merged); + return merged; + } + + publishActivity(agent: Agent): void { + useSessionStore.getState().setAgentLastActivity(agent.id, agent.lastActivityAt); + } + + accept(agent: Agent): Agent { + let accepted = agent; + useSessionStore.getState().setAgents(this.serverId, (current) => { + const previous = current.get(agent.id); + accepted = acceptAgentDirectoryUpdate(previous, mergeSnapshotTurn(previous, agent)); + if (accepted === previous) return current; + const next = new Map(current); + next.set(agent.id, accepted); + return next; + }); + return accepted; + } + + applyDelta(delta: AgentDirectoryDelta): { + agentId: string; + stoppedRunning: boolean; + agent?: Agent; + } { + if (delta.kind === "remove") { + this.remove(delta.agentId); + return { agentId: delta.agentId, stoppedRunning: false }; + } + const normalized = normalizeAgentSnapshot(delta.agent, this.serverId); + const session = useSessionStore.getState().sessions[this.serverId]; + const previous = session?.agents.get(normalized.id) ?? session?.agentDetails.get(normalized.id); + const legacyWorkspaceId = + previous?.workspaceId ?? + Array.from(session?.workspaces.values() ?? []).find( + (workspace) => + session?.serverInfo?.features?.workspaceMultiplicity !== true && + workspace.workspaceDirectory === normalized.cwd, + )?.id; + const accepted = this.accept({ + ...normalized, + workspaceId: normalized.workspaceId ?? legacyWorkspaceId, + projectPlacement: + resolveProjectPlacement({ projectPlacement: delta.project, cwd: normalized.cwd }) ?? + previous?.projectPlacement, + }); + if (accepted.archivedAt) { + clearArchiveAgentPending({ queryClient, serverId: this.serverId, agentId: accepted.id }); + } + this.replacePendingPermissions(accepted); + useSessionStore.getState().setAgentLastActivity(accepted.id, accepted.lastActivityAt); + return { + agentId: accepted.id, + stoppedRunning: previous?.turn.phase === "open" && accepted.turn.phase === "idle", + agent: accepted, + }; + } + + replaceFetched(entries: FetchAgentsEntry[]): Map { + const { agents: fetched, pendingPermissions } = buildAgentDirectoryState({ + serverId: this.serverId, + entries, + }); + const store = useSessionStore.getState(); + const current = store.sessions[this.serverId]?.agents ?? new Map(); + const agents = new Map(); + for (const [agentId, fetchedAgent] of fetched) { + const existing = current.get(agentId); + const merged = mergeSnapshotTurn(existing, fetchedAgent); + agents.set(agentId, existing && equal(existing, merged) ? existing : merged); + if (fetchedAgent.archivedAt) { + clearArchiveAgentPending({ queryClient, serverId: this.serverId, agentId }); + } + } + store.setAgents(this.serverId, agents); + store.setAgentDetails(this.serverId, (previous) => { + let next: Map | null = null; + for (const agentId of agents.keys()) { + if (!previous.has(agentId)) continue; + next ??= new Map(previous); + next.delete(agentId); + } + return next ?? previous; + }); + store.setAgentLastActivityBatch( + new Map(Array.from(agents.values(), (agent) => [agent.id, agent.lastActivityAt])), + ); + store.setPendingPermissions(this.serverId, new Map(pendingPermissions)); + store.setHasHydratedAgents(this.serverId, true); + return agents; + } + + replacePendingPermissions(agent: Agent): void { + const pending = new Map(useSessionStore.getState().sessions[this.serverId]?.pendingPermissions); + for (const [key, entry] of pending) if (entry.agentId === agent.id) pending.delete(key); + for (const request of agent.pendingPermissions) { + const key = derivePendingPermissionKey(agent.id, request); + pending.set(key, { key, agentId: agent.id, request }); + } + useSessionStore.getState().setPendingPermissions(this.serverId, pending); + } + + remove(agentId: string): void { + const store = useSessionStore.getState(); + const removeKey = (current: Map): Map => { + if (!current.has(agentId)) return current; + const next = new Map(current); + next.delete(agentId); + return next; + }; + clearArchiveAgentPending({ queryClient, serverId: this.serverId, agentId }); + store.setAgents(this.serverId, removeKey); + store.setAgentDetails(this.serverId, removeKey); + store.setQueuedMessages(this.serverId, removeKey); + store.setAgentTimelineCursor(this.serverId, removeKey); + store.setInitializingAgents(this.serverId, removeKey); + store.setPendingPermissions(this.serverId, (current) => { + const next = new Map(current); + for (const [key, pending] of next) if (pending.agentId === agentId) next.delete(key); + return next.size === current.size ? current : next; + }); + store.setAgentAuthoritativeHistoryApplied(this.serverId, agentId, false); + store.setAgentStreamTail(this.serverId, removeKey); + store.clearAgentStreamHead(this.serverId, agentId); + useSessionStore.setState((state) => { + if (!state.agentLastActivity.has(agentId)) return state; + const agentLastActivity = new Map(state.agentLastActivity); + agentLastActivity.delete(agentId); + return { ...state, agentLastActivity }; + }); + useDraftStore.getState().clearDraftInput({ + draftKey: buildDraftStoreKey({ serverId: this.serverId, agentId }), + }); + const initKey = getInitKey(this.serverId, agentId); + if (getInitDeferred(initKey)) { + rejectInitDeferred(initKey, new Error("Agent was removed during initialization")); + } + } + + archive(agentId: string, archivedAt: string): Agent | null { + let archived: Agent | null = null; + useSessionStore.getState().setAgents(this.serverId, (current) => { + const agent = current.get(agentId); + if (!agent) return current; + archived = { + ...agent, + turn: { phase: "idle", cancellationRequestId: null }, + archivedAt: new Date(archivedAt), + }; + const next = new Map(current); + next.set(agentId, archived); + return next; + }); + return archived; + } + + applyTurn( + agentId: string, + transition: TurnLivenessTransition | readonly TurnLivenessTransition[], + ): Agent | null { + const transitions = Array.isArray(transition) ? transition : [transition]; + let accepted: Agent | null = null; + useSessionStore.getState().setAgents(this.serverId, (current) => { + const agent = current.get(agentId); + if (!agent) return current; + const turn = transitions.reduce(reduceTurnLiveness, agent.turn); + if (turn === agent.turn) return current; + accepted = { ...agent, turn }; + const next = new Map(current); + next.set(agentId, accepted); + return next; + }); + return accepted; + } +} diff --git a/packages/app/src/runtime/directory-sync/workspace-replica.ts b/packages/app/src/runtime/directory-sync/workspace-replica.ts index bc3a36fd56..d229c196a2 100644 --- a/packages/app/src/runtime/directory-sync/workspace-replica.ts +++ b/packages/app/src/runtime/directory-sync/workspace-replica.ts @@ -16,11 +16,11 @@ import { shouldSuppressWorkspaceForLocalArchive, } from "@/contexts/session-workspace-upserts"; import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity"; +import type { DirectoryReplicaMutation } from "@/runtime/replica-cache"; export type WorkspaceDirectoryDelta = | Extract["payload"] | { kind: "script_status"; update: ScriptStatusUpdateMessage["payload"] }; -type ProjectDirectoryDelta = Extract["payload"]; export interface WorkspaceDirectorySnapshot { workspaces: Map; @@ -28,67 +28,33 @@ export interface WorkspaceDirectorySnapshot { syncCursors?: Partial< Record<"projects" | "workspaces", { generation: string; afterSeq: number }> >; -} - -function patchWorkspaceScripts( - workspaces: Map, - update: ScriptStatusUpdateMessage["payload"], -): Map { - const workspaceKey = resolveWorkspaceMapKeyByIdentity({ - workspaces, - workspaceId: update.workspaceId, - }); - if (!workspaceKey) return workspaces; - const existing = workspaces.get(workspaceKey); - if (!existing || equal(existing.scripts, update.scripts)) return workspaces; - const next = new Map(workspaces); - next.set(workspaceKey, { - ...existing, - scripts: update.scripts.map((script) => ({ ...script })), - }); - return next; -} - -function applyProjectDelta( - snapshot: WorkspaceDirectorySnapshot, - delta: ProjectDirectoryDelta, -): void { - if (delta.kind === "remove") { - snapshot.projects.delete(delta.projectId); - for (const [workspaceId, workspace] of snapshot.workspaces) { - if (workspace.projectId === delta.projectId) snapshot.workspaces.delete(workspaceId); - } - return; - } - - const project = normalizeProjectDescriptor(delta.project); - snapshot.projects.set(project.projectId, project); - for (const [workspaceId, workspace] of snapshot.workspaces) { - if (workspace.projectId !== project.projectId) continue; - snapshot.workspaces.set(workspaceId, { - ...workspace, - projectDisplayName: project.projectDisplayName, - projectCustomName: project.projectCustomName, - projectCustomIconRevision: project.projectCustomIconRevision, - projectRootPath: project.projectRootPath, - projectKind: project.projectKind, - }); - } + syncModes?: Partial>; + touchedWorkspaceIds?: Set; + touchedProjectIds?: Set; } export class WorkspaceDirectoryReplica { + private workspaces = new Map(); + private projects = new Map(); + private workspaceIdsByProject = new Map>(); + constructor(private readonly serverId: string) {} - applyDelta(delta: WorkspaceDirectoryDelta): void { - const state = this.reconcile(this.read(), [delta]); - this.commit(state, delta.kind === "remove" && "id" in delta ? [delta.id] : []); + applyDelta(delta: WorkspaceDirectoryDelta): DirectoryReplicaMutation[] { + if (delta.kind === "script_status") return this.applyScriptStatus(delta.update); + if ("projectId" in delta || "project" in delta) return this.applyProjectDelta(delta); + if (delta.kind === "remove") return this.removeWorkspace(delta); + return this.upsertWorkspace(normalizeWorkspaceDescriptor(delta.workspace)); } commitCached(input: { workspaces: Map; projects: Map; }): void { - this.commit(input, []); + this.replace({ + workspaces: new Map([...input.workspaces, ...this.workspaces]), + projects: new Map([...input.projects, ...this.projects]), + }); useSessionStore.getState().setHasWorkspaceDirectorySnapshot(this.serverId, true); } @@ -97,79 +63,171 @@ export class WorkspaceDirectoryReplica { project: ProjectDescriptor | undefined, ): void { if (shouldSuppressWorkspaceForLocalArchive({ serverId: this.serverId, workspace })) return; - const snapshot = this.read(); - snapshot.workspaces.set(workspace.id, workspace); - if (project) snapshot.projects.set(project.projectId, project); - this.commit(snapshot, []); + if (project) this.setProject(project); + this.setWorkspace(workspace); } commitSnapshot( snapshot: WorkspaceDirectorySnapshot, deltas: readonly WorkspaceDirectoryDelta[], - ): void { - const removedWorkspaceIds = deltas.flatMap((delta) => - delta.kind === "remove" && "id" in delta ? [delta.id] : [], - ); - this.commit(this.reconcile(snapshot, deltas), removedWorkspaceIds); + ): DirectoryReplicaMutation[] { + this.replace(snapshot); + const mutations = deltas.flatMap((delta) => this.applyDelta(delta)); useSessionStore.getState().setHasHydratedWorkspaces(this.serverId, true); + return mutations; } - private read(): WorkspaceDirectorySnapshot { - const session = useSessionStore.getState().sessions[this.serverId]; - return { - workspaces: new Map(session?.workspaces), - projects: new Map(session?.projects), - }; + snapshot(): WorkspaceDirectorySnapshot { + return { workspaces: this.workspaces, projects: this.projects }; } - private reconcile( - snapshot: WorkspaceDirectorySnapshot, - deltas: readonly WorkspaceDirectoryDelta[], - ): WorkspaceDirectorySnapshot { - let workspaces = new Map(snapshot.workspaces); - const projects = new Map(snapshot.projects); - for (const [workspaceId, workspace] of workspaces) { - if (shouldSuppressWorkspaceForLocalArchive({ serverId: this.serverId, workspace })) { - workspaces.delete(workspaceId); + acceptWorkspaces(workspaces: readonly WorkspaceDescriptor[]): DirectoryReplicaMutation[] { + return workspaces.flatMap((workspace) => this.upsertWorkspace(workspace)); + } + + acceptProject(project: ProjectDescriptor): DirectoryReplicaMutation[] { + this.setProject(project); + return [{ kind: "project", type: "upsert", id: project.projectId, value: project }]; + } + + removeWorkspaceSnapshot(workspaceId: string): DirectoryReplicaMutation[] { + this.deleteWorkspace(workspaceId); + return [{ kind: "workspace", type: "delete", id: workspaceId }]; + } + + private replace(snapshot: WorkspaceDirectorySnapshot): void { + const workspaces = new Map(); + for (const [workspaceId, workspace] of snapshot.workspaces) { + if (!shouldSuppressWorkspaceForLocalArchive({ serverId: this.serverId, workspace })) { + workspaces.set(workspaceId, workspace); } } - for (const delta of deltas) { - if (delta.kind === "script_status") { - workspaces = patchWorkspaceScripts(workspaces, delta.update); - continue; - } - if ("projectId" in delta || "project" in delta) { - applyProjectDelta({ workspaces, projects }, delta); - continue; - } - if (delta.kind === "remove") { - workspaces.delete(delta.id); - if (delta.emptyProject) { - const project = normalizeProjectDescriptor(delta.emptyProject); - projects.set(project.projectId, project); - } - if (delta.removedProjectId) { - projects.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); - } - } - return { workspaces, projects }; - } - - private commit(snapshot: WorkspaceDirectorySnapshot, removedWorkspaceIds: string[]): void { + this.workspaces = workspaces; + this.projects = new Map(snapshot.projects); + this.rebuildProjectIndex(); const store = useSessionStore.getState(); - store.setWorkspaces(this.serverId, snapshot.workspaces); - store.setProjects(this.serverId, snapshot.projects.values()); - for (const workspaceId of removedWorkspaceIds) { - clearWorkspaceArchivePending({ serverId: this.serverId, workspaceId }); - useWorkspaceSetupStore.getState().removeWorkspace({ serverId: this.serverId, workspaceId }); + store.setWorkspaces(this.serverId, this.workspaces); + store.setProjects(this.serverId, this.projects.values()); + } + + private applyScriptStatus( + update: ScriptStatusUpdateMessage["payload"], + ): DirectoryReplicaMutation[] { + const workspaceId = resolveWorkspaceMapKeyByIdentity({ + workspaces: this.workspaces, + workspaceId: update.workspaceId, + }); + const workspace = workspaceId ? this.workspaces.get(workspaceId) : undefined; + if (!workspace || equal(workspace.scripts, update.scripts)) return []; + const next = { ...workspace, scripts: update.scripts.map((script) => ({ ...script })) }; + this.setWorkspace(next); + return [{ kind: "workspace", type: "upsert", id: next.id, value: next }]; + } + + private applyProjectDelta( + delta: Extract["payload"], + ): DirectoryReplicaMutation[] { + if (delta.kind === "remove") { + const mutations: DirectoryReplicaMutation[] = [ + { kind: "project", type: "delete", id: delta.projectId }, + ]; + this.projects.delete(delta.projectId); + useSessionStore.getState().removeProject(this.serverId, delta.projectId); + for (const workspaceId of this.workspaceIdsByProject.get(delta.projectId) ?? []) { + this.deleteWorkspace(workspaceId); + mutations.push({ kind: "workspace", type: "delete", id: workspaceId }); + } + return mutations; + } + + const project = normalizeProjectDescriptor(delta.project); + this.setProject(project); + const mutations: DirectoryReplicaMutation[] = [ + { kind: "project", type: "upsert", id: project.projectId, value: project }, + ]; + for (const workspaceId of this.workspaceIdsByProject.get(project.projectId) ?? []) { + const workspace = this.workspaces.get(workspaceId); + if (!workspace) continue; + const next = { + ...workspace, + projectDisplayName: project.projectDisplayName, + projectCustomName: project.projectCustomName, + projectCustomIconRevision: project.projectCustomIconRevision, + projectRootPath: project.projectRootPath, + projectKind: project.projectKind, + }; + this.setWorkspace(next); + mutations.push({ kind: "workspace", type: "upsert", id: next.id, value: next }); + } + return mutations; + } + + private removeWorkspace( + delta: Extract["payload"] & { + kind: "remove"; + }, + ): DirectoryReplicaMutation[] { + this.deleteWorkspace(delta.id); + const mutations: DirectoryReplicaMutation[] = [ + { kind: "workspace", type: "delete", id: delta.id }, + ]; + if (delta.emptyProject) { + const project = normalizeProjectDescriptor(delta.emptyProject); + this.setProject(project); + mutations.push({ kind: "project", type: "upsert", id: project.projectId, value: project }); + } + if (delta.removedProjectId) { + this.projects.delete(delta.removedProjectId); + useSessionStore.getState().removeProject(this.serverId, delta.removedProjectId); + mutations.push({ kind: "project", type: "delete", id: delta.removedProjectId }); + } + clearWorkspaceArchivePending({ serverId: this.serverId, workspaceId: delta.id }); + useWorkspaceSetupStore + .getState() + .removeWorkspace({ serverId: this.serverId, workspaceId: delta.id }); + return mutations; + } + + private upsertWorkspace(workspace: WorkspaceDescriptor): DirectoryReplicaMutation[] { + if (shouldSuppressWorkspaceForLocalArchive({ serverId: this.serverId, workspace })) { + if (!this.workspaces.has(workspace.id)) return []; + this.deleteWorkspace(workspace.id); + return [{ kind: "workspace", type: "delete", id: workspace.id }]; + } + this.setWorkspace(workspace); + return [{ kind: "workspace", type: "upsert", id: workspace.id, value: workspace }]; + } + + private setWorkspace(workspace: WorkspaceDescriptor): void { + const previous = this.workspaces.get(workspace.id); + if (previous?.projectId !== workspace.projectId) { + this.workspaceIdsByProject.get(previous?.projectId ?? "")?.delete(workspace.id); + } + this.workspaces.set(workspace.id, workspace); + const projectWorkspaces = this.workspaceIdsByProject.get(workspace.projectId) ?? new Set(); + projectWorkspaces.add(workspace.id); + this.workspaceIdsByProject.set(workspace.projectId, projectWorkspaces); + useSessionStore.getState().mergeWorkspaces(this.serverId, [workspace]); + } + + private deleteWorkspace(workspaceId: string): void { + const workspace = this.workspaces.get(workspaceId); + if (workspace) this.workspaceIdsByProject.get(workspace.projectId)?.delete(workspaceId); + this.workspaces.delete(workspaceId); + useSessionStore.getState().removeWorkspace(this.serverId, workspaceId); + } + + private setProject(project: ProjectDescriptor): void { + this.projects.set(project.projectId, project); + useSessionStore.getState().upsertProject(this.serverId, project); + } + + private rebuildProjectIndex(): void { + this.workspaceIdsByProject = new Map(); + for (const workspace of this.workspaces.values()) { + const workspaceIds = this.workspaceIdsByProject.get(workspace.projectId) ?? new Set(); + workspaceIds.add(workspace.id); + this.workspaceIdsByProject.set(workspace.projectId, workspaceIds); } } } diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index 431b806aae..b77d53f646 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -31,6 +31,8 @@ class FakeDaemonClient { private latencyMeasurementFailure: Error | null = null; private latencyMeasurementsRequested: Array<{ timeoutMs?: number }> = []; public connectCalls = 0; + public ensureConnectedCalls = 0; + public reconnectEnabledChanges: boolean[] = []; public fetchAgentsCalls: FetchAgentsOptions[] = []; public fetchAgentsResponses: Array< Awaited> | ReturnType @@ -105,6 +107,7 @@ class FakeDaemonClient { } ensureConnected(): void { + this.ensureConnectedCalls += 1; if (this.state.status !== "connected") { this.setConnectionState({ status: "connected" }); } @@ -170,7 +173,9 @@ class FakeDaemonClient { return result.rttMs; } - setReconnectEnabled(_enabled: boolean): void {} + setReconnectEnabled(enabled: boolean): void { + this.reconnectEnabledChanges.push(enabled); + } getLastLivenessRttMs(): number | null { return this.heartbeatRttMs; @@ -815,6 +820,52 @@ describe("HostRuntimeController", () => { expect(activeClient.latencyMeasurements()).toEqual([]); }); + it("does not create a probe client for the selected connection while it reconnects", async () => { + useHostRuntimeClock(); + const relay: HostConnection = { + id: "relay:relay.paseo.sh:443", + type: "relay", + relayEndpoint: "relay.paseo.sh:443", + daemonPublicKeyB64: "pk_test", + }; + const host = makeHost({ connections: [relay], preferredConnectionId: relay.id }); + const activeClient = new FakeDaemonClient(); + activeClient.setConnectionState({ status: "connected" }); + const probeAttempts: string[] = []; + const controller = new HostRuntimeController({ + host, + deps: { + createClient: () => { + throw new Error("the existing client owns the selected connection"); + }, + connectToDaemon: async ({ connection }) => { + probeAttempts.push(connection.id); + return { + client: makeConnectedProbeClient(10) as unknown as DaemonClient, + serverId: host.serverId, + hostname: host.label ?? null, + }; + }, + getClientId: async () => "cid_test_runtime", + }, + }); + + await controller.start({ + autoProbe: false, + initialConnection: { + connectionId: relay.id, + existingClient: activeClient as unknown as DaemonClient, + }, + }); + activeClient.setConnectionState({ status: "disconnected", reason: "network lost" }); + await vi.advanceTimersByTimeAsync(10_000); + + await controller.runProbeCycleNow(); + + expect(probeAttempts).toEqual([]); + expect(controller.getSnapshot().client).toBe(activeClient as unknown as DaemonClient); + }); + it("rejects probes that resolve to a different server id", async () => { const host = makeHost({ serverId: "srv_old", @@ -1435,6 +1486,71 @@ describe("HostRuntimeController", () => { }); describe("HostRuntimeStore", () => { + it("reconnects every registered host immediately on app resume", async () => { + const relay = (suffix: string): HostConnection => ({ + id: `relay:relay-${suffix}.paseo.sh:443`, + type: "relay", + relayEndpoint: `relay-${suffix}.paseo.sh:443`, + daemonPublicKeyB64: `pk_${suffix}`, + }); + const hostAConnection = relay("a"); + const hostBConnection = relay("b"); + const hostA = makeHost({ + serverId: "srv_a", + connections: [hostAConnection], + preferredConnectionId: hostAConnection.id, + }); + const hostB = makeHost({ + serverId: "srv_b", + connections: [hostBConnection], + preferredConnectionId: hostBConnection.id, + }); + const clientA = new FakeDaemonClient(); + const clientB = new FakeDaemonClient(); + clientA.setConnectionState({ status: "connected" }); + clientB.setConnectionState({ status: "connected" }); + const store = new HostRuntimeStore({ + storage: createMemoryHostRuntimeStorage(), + deps: { + createClient: () => { + throw new Error("initial clients are supplied"); + }, + connectToDaemon: async () => { + throw new Error("single-connection hosts reuse their active clients"); + }, + getClientId: async () => "cid_test_runtime", + }, + }); + + store.syncHosts([hostA, hostB], { + initialConnectionByServerId: new Map([ + [ + hostA.serverId, + { connectionId: hostAConnection.id, existingClient: clientA as unknown as DaemonClient }, + ], + [ + hostB.serverId, + { connectionId: hostBConnection.id, existingClient: clientB as unknown as DaemonClient }, + ], + ]), + }); + await waitForHostOnline(store, hostA.serverId); + await waitForHostOnline(store, hostB.serverId); + store.setAppVisible(false); + expect(clientA.reconnectEnabledChanges.at(-1)).toBe(false); + expect(clientB.reconnectEnabledChanges.at(-1)).toBe(false); + clientA.setConnectionState({ status: "disconnected", reason: "backgrounded" }); + clientB.setConnectionState({ status: "disconnected", reason: "backgrounded" }); + + store.setAppVisible(true); + expect(clientA.reconnectEnabledChanges.at(-1)).toBe(true); + expect(clientB.reconnectEnabledChanges.at(-1)).toBe(true); + expect(clientA.ensureConnectedCalls).toBe(1); + expect(clientB.ensureConnectedCalls).toBe(1); + + store.syncHosts([]); + }); + it("revokes push notifications before removing a host", async () => { const host = makeHost({ connections: [makeHost().connections[0]!] }); const revocation = createDeferred(); @@ -1483,16 +1599,16 @@ describe("HostRuntimeStore", () => { expect(store.getHosts()).toEqual([]); }); - it("loads the host registry without scanning or installing replica rows", async () => { + it("loads the host registry and restores its cached directory", async () => { const host = makeHost(); const storage = createMemoryHostRuntimeStorage(); - const backingStore = createMemoryReplicaRowStore(); let fullScans = 0; + const backingStore = createMemoryReplicaRowStore(); const replicaRowStore: ReplicaRowStore = { ...backingStore, - readAll: async () => { + read: async (...args) => { fullScans += 1; - return backingStore.readAll(); + return backingStore.read(...args); }, }; await storage.setItem("@paseo:daemon-registry", JSON.stringify([host])); @@ -1516,7 +1632,7 @@ describe("HostRuntimeStore", () => { store.boot(); await registryLoaded; - expect(fullScans).toBe(0); + await vi.waitFor(() => expect(fullScans).toBe(1)); expect(useSessionStore.getState().sessions[host.serverId]).toMatchObject({ client: null, hasHydratedAgents: false, @@ -2006,11 +2122,27 @@ describe("HostRuntimeStore", () => { new Map([ [ "legacy-snapshot", - { ...replicaAgent(snapshotAgent.agent, host.serverId), status: "running" }, + { + ...replicaAgent(snapshotAgent.agent, host.serverId), + turn: { + phase: "open", + turnId: null, + startedAt: null, + cancellationRequestId: null, + }, + }, ], [ "legacy-buffered", - { ...replicaAgent(bufferedAgent.agent, host.serverId), status: "running" }, + { + ...replicaAgent(bufferedAgent.agent, host.serverId), + turn: { + phase: "open", + turnId: null, + startedAt: null, + cancellationRequestId: null, + }, + }, ], ]), ); @@ -2545,11 +2677,27 @@ describe("HostRuntimeStore", () => { new Map([ [ "snapshot-transition", - { ...replicaAgent(snapshotAgent.agent, host.serverId), status: "running" }, + { + ...replicaAgent(snapshotAgent.agent, host.serverId), + turn: { + phase: "open", + turnId: null, + startedAt: null, + cancellationRequestId: null, + }, + }, ], [ "buffered-transition", - { ...replicaAgent(bufferedAgent.agent, host.serverId), status: "running" }, + { + ...replicaAgent(bufferedAgent.agent, host.serverId), + turn: { + phase: "open", + turnId: null, + startedAt: null, + cancellationRequestId: null, + }, + }, ], ]), ); @@ -3037,12 +3185,14 @@ describe("HostRuntimeStore", () => { }).agent; const staleAgent: Agent = { ...stale, - activeTurn: stale.activeTurn + turn: stale.activeTurn ? { + phase: "open", turnId: stale.activeTurn.turnId, startedAt: stale.activeTurn.startedAt ? new Date(stale.activeTurn.startedAt) : null, + cancellationRequestId: null, } - : null, + : { phase: "idle", cancellationRequestId: null }, serverId: host.serverId, createdAt: new Date(stale.createdAt), updatedAt: new Date(stale.updatedAt), diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index fc434365dd..20fb842e06 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -45,7 +45,13 @@ 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 { useSessionStore } from "@/stores/session-store"; +import { + useSessionStore, + type Agent, + type WorkspaceDescriptor, + type ProjectDescriptor, +} from "@/stores/session-store"; +import type { TurnLivenessTransition } from "@/timeline/turn-liveness"; import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store"; import { invalidateCheckoutGitQueriesForServer } from "@/git/query-keys"; import { queryClient } from "@/data/query-client"; @@ -612,6 +618,7 @@ export class HostRuntimeController { private switchRequestVersion = 0; private probeRequestVersion = 0; private probeCycleInFlight: Promise | null = null; + private reconnectEnabled = true; constructor(input: { host: HostProfile; @@ -721,6 +728,11 @@ export class HostRuntimeController { this.activeClient?.ensureConnected(); } + setReconnectEnabled(enabled: boolean): void { + this.reconnectEnabled = enabled; + this.activeClient?.setReconnectEnabled(enabled); + } + markAgentDirectorySyncLoading(): void { const status = this.snapshot.hasEverLoadedAgentDirectory ? "revalidating" : "initial_loading"; this.updateSnapshot({ @@ -970,10 +982,7 @@ export class HostRuntimeController { let shouldCloseClient = false; try { const activeClient = - this.snapshot.connectionStatus === "online" && - this.snapshot.activeConnectionId === connection.id - ? this.snapshot.client - : null; + this.snapshot.activeConnectionId === connection.id ? this.snapshot.client : null; if (activeClient) { connectedClient = activeClient; @@ -1226,9 +1235,6 @@ export class HostRuntimeController { } const nextGeneration = this.snapshot.clientGeneration + 1; - if (existingClient) { - existingClient.setReconnectEnabled(true); - } const client = existingClient ?? this.deps.createClient({ @@ -1237,6 +1243,7 @@ export class HostRuntimeController { clientId, runtimeGeneration: nextGeneration, }); + client.setReconnectEnabled(this.reconnectEnabled); if (!this.isSwitchStillValid(requestVersion, expectedProbeVersion)) { await client.close().catch(() => undefined); @@ -1394,9 +1401,11 @@ export class HostRuntimeStore { private connectionStatusStartedAtByServer = new Map(); private queuedAgentDrainInFlight = new Set(); private directorySyncByServer = new Map(); + private nextCancellationRequestId = 0; private timelineReplicaByServer = new Map(); private configuredOverrideBootstrapInFlight: Promise | null = null; private bootPromise: Promise | null = null; + private appVisible = true; private storage: HostRuntimeStorage; private replicaCache: ReplicaCache; private readonly revokePushNotifications: typeof revokePushNotifications; @@ -1511,6 +1520,12 @@ export class HostRuntimeStore { projectIconCache.setHosts(profiles.map((profile) => profile.serverId)); await projectIconCache.restore(); this.syncHosts(profiles); + for (const profile of profiles) { + void this.directorySyncByServer + .get(profile.serverId) + ?.restoreCachedDirectory() + .catch(() => undefined); + } } catch (error) { console.error("[HostRuntime] Failed to load host registry from storage", error); } finally { @@ -2064,6 +2079,7 @@ export class HostRuntimeStore { deps: this.deps, onReconcileServerId: (oldId, newId) => this.reconcileServerId(oldId, newId), }); + controller.setReconnectEnabled(this.appVisible); this.controllers.set(host.serverId, controller); useSessionStore.getState().initializeSession(host.serverId, null); const directory = new DirectorySync( @@ -2215,6 +2231,56 @@ export class HostRuntimeStore { }); } + applyAgentTurnLiveness( + serverId: string, + agentId: string, + transition: TurnLivenessTransition | readonly TurnLivenessTransition[], + ): void { + this.directorySyncByServer.get(serverId)?.applyAgentTurnLiveness(agentId, transition); + } + + beginAgentCancellation(serverId: string, agentId: string): number { + const requestId = ++this.nextCancellationRequestId; + this.applyAgentTurnLiveness(serverId, agentId, { type: "cancellation_started", requestId }); + return requestId; + } + + settleAgentCancellation(serverId: string, agentId: string, requestId: number): void { + this.applyAgentTurnLiveness(serverId, agentId, { type: "cancellation_settled", requestId }); + } + + acceptAgentSnapshot(serverId: string, agent: Agent): Agent { + return this.requireDirectory(serverId).acceptAgent(agent); + } + + archiveAgentSnapshot(serverId: string, agentId: string, archivedAt: string): void { + this.requireDirectory(serverId).archiveAgent(agentId, archivedAt); + } + + restoreAgentSnapshot(serverId: string, agentId: string, agent: Agent | undefined): void { + const directory = this.requireDirectory(serverId); + if (agent) directory.acceptAgent(agent); + else directory.removeAgent(agentId); + } + + acceptWorkspaceSnapshots(serverId: string, workspaces: readonly WorkspaceDescriptor[]): void { + this.requireDirectory(serverId).acceptWorkspaces(workspaces); + } + + acceptProjectSnapshot(serverId: string, project: ProjectDescriptor): void { + this.requireDirectory(serverId).acceptProject(project); + } + + removeWorkspaceSnapshot(serverId: string, workspaceId: string): void { + this.requireDirectory(serverId).removeWorkspace(workspaceId); + } + + private requireDirectory(serverId: string): DirectorySync { + const directory = this.directorySyncByServer.get(serverId); + if (!directory) throw new Error(`Unknown host runtime for serverId ${serverId}`); + return directory; + } + getSnapshot(serverId: string): HostRuntimeSnapshot | null { return this.controllers.get(serverId)?.getSnapshot() ?? null; } @@ -2274,6 +2340,19 @@ export class HostRuntimeStore { } } + setAppVisible(visible: boolean): void { + this.appVisible = visible; + for (const controller of this.controllers.values()) { + controller.setReconnectEnabled(visible); + } + if (!visible) { + void this.replicaCache.flush(); + return; + } + + this.ensureConnectedAll(); + } + runProbeCycleNow(serverId?: string): Promise { if (serverId) { return this.controllers.get(serverId)?.runProbeCycleNow() ?? Promise.resolve(); diff --git a/packages/app/src/runtime/replica-cache/index.test.ts b/packages/app/src/runtime/replica-cache/index.test.ts index 56e4546507..df6c3a0aa0 100644 --- a/packages/app/src/runtime/replica-cache/index.test.ts +++ b/packages/app/src/runtime/replica-cache/index.test.ts @@ -7,7 +7,8 @@ import { } from "@/stores/session-store"; import type { StreamItem } from "@/types/stream"; import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; -import { ReplicaCache, type DirectoryCheckpoint } from "."; +import { ReplicaCache } from "."; +import type { DirectoryCheckpoint } from "@/runtime/replica-cache"; import type { ReplicaHostRows, ReplicaRow, ReplicaRowChanges, ReplicaRowStore } from "./row-store"; const SERVER_ID = "cached-host"; @@ -200,6 +201,45 @@ function timeline(text = "Cached") { }; } +function commitDirectory( + cache: ReplicaCache, + serverId: string, + value: ReturnType, +): void { + cache.commitDirectoryMutations( + serverId, + [ + ...Array.from(value.agents.values(), (cachedAgent) => ({ + kind: "agent" as const, + type: "upsert" as const, + id: cachedAgent.id, + value: cachedAgent, + })), + ...Array.from(value.workspaces.values(), (workspace) => ({ + kind: "workspace" as const, + type: "upsert" as const, + id: workspace.id, + value: workspace, + })), + ...Array.from(value.projects.values(), (project) => ({ + kind: "project" as const, + type: "upsert" as const, + id: project.projectId, + value: project, + })), + ], + value.checkpoint, + ); +} + +function deleteDirectory(cache: ReplicaCache, serverId: string): void { + cache.commitDirectoryMutations(serverId, [ + { kind: "agent", type: "delete", id: "agent-1" }, + { kind: "workspace", type: "delete", id: "workspace-1" }, + { kind: "project", type: "delete", id: "project-1" }, + ]); +} + describe("ReplicaCache", () => { it("does nothing until an owner explicitly commits data", async () => { const storage = new MemoryStorage(); @@ -214,7 +254,7 @@ describe("ReplicaCache", () => { it("round-trips explicit directory and timeline commits", async () => { const storage = new MemoryStorage(); const writer = createCache(storage); - writer.commitDirectory(SERVER_ID, directory()); + commitDirectory(writer, SERVER_ID, directory()); writer.commitTimeline(SERVER_ID, "agent-1", timeline()); await writer.flush(); @@ -229,17 +269,145 @@ describe("ReplicaCache", () => { expect(restoredTimeline).toEqual(timeline()); }); + it("preserves pending timeline updates across directory baseline replacement", async () => { + const storage = new MemoryStorage(); + const writer = createCache(storage); + writer.commitTimeline(SERVER_ID, "agent-1", timeline("Latest visible reply")); + writer.replaceDirectoryBaseline(SERVER_ID, directory()); + await writer.flush(); + + const reader = createCache(storage); + expect(await reader.readTimeline(SERVER_ID, "agent-1")).toEqual( + timeline("Latest visible reply"), + ); + }); + + it("preserves clearing a timeline across directory baseline replacement", async () => { + const storage = new MemoryStorage(); + const writer = createCache(storage); + writer.commitTimeline(SERVER_ID, "agent-1", timeline()); + await writer.flush(); + writer.commitTimeline(SERVER_ID, "agent-1", { ...timeline(), items: [], range: null }); + writer.replaceDirectoryBaseline(SERVER_ID, directory()); + await writer.flush(); + + const reader = createCache(storage); + expect(await reader.readTimeline(SERVER_ID, "agent-1")).toEqual({ + ...timeline(), + items: [], + range: null, + hasOlder: false, + }); + }); + + it("serializes and writes only keyed directory mutations", async () => { + const storage = new MemoryStorage(); + const cache = createCache(storage); + const changed = agent("changed-agent"); + let titleReads = 0; + Object.defineProperty(changed, "title", { + configurable: true, + enumerable: true, + get: () => { + titleReads += 1; + return "Changed agent"; + }, + }); + + cache.commitDirectoryMutations( + SERVER_ID, + [{ kind: "agent", type: "upsert", id: changed.id, value: changed }], + { agents: { generation: "g", afterSeq: 13 } }, + ); + + expect(titleReads).toBe(0); + await cache.flush(); + expect(titleReads).toBe(1); + expect(storage.changes).toHaveLength(1); + expect(storage.changes[0]?.upserts.map(({ kind, id }) => ({ kind, id }))).toEqual([ + { kind: "agent", id: "changed-agent" }, + { kind: "checkpoint", id: "singleton" }, + ]); + expect(storage.changes[0]?.deletes).toEqual([]); + }); + + it("round-trips identified and anonymous open turns without changing protocol status", async () => { + const storage = new MemoryStorage(); + const writer = createCache(storage); + const identified = agent("identified"); + identified.turn = { + phase: "open", + turnId: "turn-1", + startedAt: new Date("2026-08-31T12:00:00.000Z"), + cancellationRequestId: null, + }; + const anonymous = agent("anonymous"); + anonymous.turn = { + phase: "open", + turnId: null, + startedAt: null, + cancellationRequestId: null, + }; + writer.commitDirectoryMutations(SERVER_ID, [ + { kind: "agent", type: "upsert", id: identified.id, value: identified }, + { kind: "agent", type: "upsert", id: anonymous.id, value: anonymous }, + ]); + await writer.flush(); + + const restored = await createCache(storage).readDirectory(SERVER_ID); + + expect(restored.agents.get("identified")).toMatchObject({ + status: "idle", + turn: { + phase: "open", + turnId: "turn-1", + startedAt: new Date("2026-08-31T12:00:00.000Z"), + cancellationRequestId: null, + }, + }); + expect(restored.agents.get("anonymous")).toMatchObject({ + status: "idle", + turn: { + phase: "open", + turnId: null, + startedAt: null, + cancellationRequestId: null, + }, + }); + }); + + it("coalesces timeline values before serialization", async () => { + const storage = new MemoryStorage(); + const cache = createCache(storage); + let textReads = 0; + const changedTimeline = timeline(); + Object.defineProperty(changedTimeline.items[0], "text", { + configurable: true, + enumerable: true, + get: () => { + textReads += 1; + return "Latest"; + }, + }); + + cache.commitTimeline(SERVER_ID, "agent-1", timeline("Old")); + cache.commitTimeline(SERVER_ID, "agent-1", changedTimeline); + + expect(textReads).toBe(0); + await cache.flush(); + expect(textReads).toBe(1); + expect(storage.changes[0]?.upserts.map(({ kind, id }) => ({ kind, id }))).toEqual([ + { kind: "timeline", id: "agent-1" }, + ]); + }); + it("never reads directory rows older than an accepted deferred deletion", async () => { const storage = new MemoryStorage(); const cache = createCache(storage); - cache.commitDirectory(SERVER_ID, directory()); + commitDirectory(cache, SERVER_ID, directory()); await cache.flush(); - cache.commitDirectory(SERVER_ID, { - agents: new Map(), - workspaces: new Map(), - projects: new Map(), - }); + deleteDirectory(cache, SERVER_ID); expect(await cache.readAgent(SERVER_ID, "agent-1")).toBeUndefined(); expect(await cache.readWorkspace(SERVER_ID, "workspace-1")).toBeUndefined(); @@ -249,15 +417,11 @@ describe("ReplicaCache", () => { it("fails closed when an accepted deletion cannot be persisted before a read", async () => { const storage = new MemoryStorage(); const cache = createCache(storage); - cache.commitDirectory(SERVER_ID, directory()); + commitDirectory(cache, SERVER_ID, directory()); await cache.flush(); storage.nextWriteFailure = new Error("disk busy"); - cache.commitDirectory(SERVER_ID, { - agents: new Map(), - workspaces: new Map(), - projects: new Map(), - }); + deleteDirectory(cache, SERVER_ID); expect(await cache.readWorkspace(SERVER_ID, "workspace-1")).toBeUndefined(); }); @@ -265,7 +429,7 @@ describe("ReplicaCache", () => { it("discards a durable read when the host changes while it is in flight", async () => { const storage = new MemoryStorage(); const cache = createCache(storage); - cache.commitDirectory(SERVER_ID, directory()); + commitDirectory(cache, SERVER_ID, directory()); await cache.flush(); const started = deferred(); const release = deferred(); @@ -274,11 +438,7 @@ describe("ReplicaCache", () => { const reading = cache.readAgent(SERVER_ID, "agent-1"); await started.promise; - cache.commitDirectory(SERVER_ID, { - agents: new Map(), - workspaces: new Map(), - projects: new Map(), - }); + deleteDirectory(cache, SERVER_ID); release.resolve(); expect(await reading).toBeUndefined(); @@ -356,7 +516,7 @@ describe("ReplicaCache", () => { it("reads one requested agent and the focused timeline without scanning directory rows", async () => { const storage = new MemoryStorage(); const writer = createCache(storage); - writer.commitDirectory(SERVER_ID, directory()); + commitDirectory(writer, SERVER_ID, directory()); writer.commitTimeline(SERVER_ID, "agent-1", timeline()); await writer.flush(); @@ -372,7 +532,7 @@ describe("ReplicaCache", () => { it("reads one requested workspace and its project without scanning the directory", async () => { const storage = new MemoryStorage(); const writer = createCache(storage); - writer.commitDirectory(SERVER_ID, directory()); + commitDirectory(writer, SERVER_ID, directory()); await writer.flush(); const reader = createCache(storage); @@ -422,9 +582,9 @@ describe("ReplicaCache", () => { const storage = new MemoryStorage(); const writer = new ReplicaCache(storage, noLegacyCleanup); writer.setHosts([SERVER_ID, otherServerId]); - writer.commitDirectory(SERVER_ID, directory()); + commitDirectory(writer, SERVER_ID, directory()); writer.commitTimeline(SERVER_ID, "agent-1", timeline()); - writer.commitDirectory(otherServerId, directory()); + commitDirectory(writer, otherServerId, directory()); await writer.flush(); storage.rows.set(`${SERVER_ID}:agent:agent-1`, { serverId: SERVER_ID, @@ -438,7 +598,7 @@ describe("ReplicaCache", () => { ); const cache = new ReplicaCache(storage, { ...noLegacyCleanup, maxBytes: initialBytes + 100 }); cache.setHosts([SERVER_ID, otherServerId]); - cache.commitDirectory(otherServerId, directory()); + commitDirectory(cache, otherServerId, directory()); await cache.flush(); expect(await cache.readAgent(SERVER_ID, "agent-1")).toBeUndefined(); @@ -451,7 +611,8 @@ describe("ReplicaCache", () => { it("atomically removes a targeted corrupt row and its matching persisted cursor", async () => { const storage = new MemoryStorage(); const writer = createCache(storage); - writer.commitDirectory( + commitDirectory( + writer, SERVER_ID, directory({ agents: { generation: "g", afterSeq: 12 }, @@ -480,7 +641,8 @@ describe("ReplicaCache", () => { it("drops only the cursor whose cached entity baseline is corrupt", async () => { const storage = new MemoryStorage(); const writer = createCache(storage); - writer.commitDirectory( + commitDirectory( + writer, SERVER_ID, directory({ agents: { generation: "g", afterSeq: 12 }, @@ -514,7 +676,7 @@ describe("ReplicaCache", () => { const storage = new MemoryStorage(); const cache = createCache(storage); - cache.commitDirectory(SERVER_ID, directory()); + commitDirectory(cache, SERVER_ID, directory()); await cache.flush(); expect(storage.changes).toHaveLength(1); @@ -548,7 +710,7 @@ describe("ReplicaCache", () => { await cache.flush(); storage.onRead = () => { storage.onRead = null; - cache.commitDirectory(SERVER_ID, directory()); + commitDirectory(cache, SERVER_ID, directory()); }; expect((await cache.readTimeline(SERVER_ID, "agent-1"))?.items).toEqual([ @@ -561,13 +723,23 @@ describe("ReplicaCache", () => { const cache = new ReplicaCache(storage, { ...noLegacyCleanup, maxBytes: 2_500 }); cache.setHosts([SERVER_ID, "other-host"]); const cachedDirectory = directory(); - cache.commitDirectory(SERVER_ID, cachedDirectory); + commitDirectory(cache, SERVER_ID, cachedDirectory); await cache.flush(); - cache.commitDirectory("other-host", cachedDirectory); + commitDirectory(cache, "other-host", cachedDirectory); await cache.flush(); expect([...storage.rows.values()].some((row) => row.serverId === SERVER_ID)).toBe(false); - cache.commitDirectory(SERVER_ID, cachedDirectory); + cache.commitDirectoryMutations( + SERVER_ID, + [{ kind: "agent", type: "upsert", id: "agent-1", value: agent() }], + { agents: { generation: "new-generation", afterSeq: 99 } }, + ); + await cache.flush(); + + expect([...storage.rows.values()].some((row) => row.serverId === SERVER_ID)).toBe(false); + expect((await cache.readDirectory(SERVER_ID)).checkpoint).toBeUndefined(); + + cache.replaceDirectoryBaseline(SERVER_ID, cachedDirectory); await cache.flush(); expect( @@ -588,7 +760,7 @@ describe("ReplicaCache", () => { cache.setHosts([SERVER_ID]); await cache.readAgent(SERVER_ID, "missing"); - cache.commitDirectory(SERVER_ID, directory()); + commitDirectory(cache, SERVER_ID, directory()); await cache.flush(); expect(storage.cleanups).toBe(1); diff --git a/packages/app/src/runtime/replica-cache/index.ts b/packages/app/src/runtime/replica-cache/index.ts index 26c1f3b5d6..c1db2f8418 100644 --- a/packages/app/src/runtime/replica-cache/index.ts +++ b/packages/app/src/runtime/replica-cache/index.ts @@ -30,7 +30,45 @@ import { type ReplicaRowStore, } from "./row-store"; -const PERSIST_DELAY_MS = 5_000; +export type DirectoryReplicaMutation = + | { kind: "agent"; type: "upsert"; id: string; value: Agent } + | { kind: "agent"; type: "delete"; id: string } + | { kind: "workspace"; type: "upsert"; id: string; value: WorkspaceDescriptor } + | { kind: "workspace"; type: "delete"; id: string } + | { kind: "project"; type: "upsert"; id: string; value: ProjectDescriptor } + | { kind: "project"; type: "delete"; id: string }; + +export interface CachedDirectory { + agents: Map; + workspaces: Map; + projects: Map; + checkpoint?: DirectoryCheckpoint; +} + +export interface CachedWorkspace { + workspace: WorkspaceDescriptor; + project?: ProjectDescriptor; +} + +export interface DirectoryCursor { + generation: string; + afterSeq: number; +} + +export interface DirectoryCheckpoint { + projects?: DirectoryCursor; + workspaces?: DirectoryCursor; + agents?: DirectoryCursor; +} + +export interface CachedTimeline { + agentId: string; + items: StreamItem[]; + range: AgentTimelineCursorState | null; + hasOlder: boolean; +} + +const PERSIST_DELAY_MS = 1_000; const MAX_TIMELINE_ITEMS = 50; const MAX_CACHE_BYTES = 32 * 1024 * 1024; const IsoDateSchema = z.iso.datetime(); @@ -217,6 +255,16 @@ const StoredAgentSnapshotSchema = z.strictObject({ const StoredAgentSchema = z.strictObject({ snapshot: StoredAgentSnapshotSchema, + turn: z + .discriminatedUnion("phase", [ + z.strictObject({ phase: z.literal("idle") }), + z.strictObject({ + phase: z.literal("open"), + turnId: z.string().nullable(), + startedAt: IsoDateSchema.nullable(), + }), + ]) + .optional(), projectPlacement: StoredProjectPlacementSchema.nullable(), lastActivityAt: IsoDateSchema, }); @@ -313,28 +361,12 @@ interface ReplicaCacheOptions { clearLegacyCache?: () => Promise; } -export interface CachedDirectory { - agents: Map; - workspaces: Map; - projects: Map; - checkpoint?: DirectoryCheckpoint; -} - -export interface CachedWorkspace { - workspace: WorkspaceDescriptor; - project?: ProjectDescriptor; -} - -export interface DirectoryCursor { - generation: string; - afterSeq: number; -} - -export interface DirectoryCheckpoint { - projects?: DirectoryCursor; - workspaces?: DirectoryCursor; - agents?: DirectoryCursor; -} +type StructuredReplicaUpsert = + | { serverId: string; kind: "agent"; id: string; value: Agent } + | { serverId: string; kind: "workspace"; id: string; value: WorkspaceDescriptor } + | { serverId: string; kind: "project"; id: string; value: ProjectDescriptor } + | { serverId: string; kind: "timeline"; id: string; value: CachedTimeline } + | { serverId: string; kind: "checkpoint"; id: string; value: DirectoryCheckpoint }; const DirectoryCursorSchema = z.strictObject({ generation: z.string(), @@ -347,13 +379,6 @@ const DirectoryCheckpointSchema = z.strictObject({ agents: DirectoryCursorSchema.optional(), }); -export interface CachedTimeline { - agentId: string; - items: StreamItem[]; - range: AgentTimelineCursorState | null; - hasOlder: boolean; -} - function deserializeTimeline(stored: StoredTimeline | null): CachedTimeline | null { if (!stored) { return null; @@ -560,6 +585,15 @@ function serializeProjectPlacement(agent: Agent): StoredAgent["projectPlacement" return agent.projectPlacement ?? null; } +function serializeAgentTurn(agent: Agent): NonNullable { + if (agent.turn.phase === "idle") return { phase: "idle" }; + return { + phase: "open", + turnId: agent.turn.turnId, + startedAt: agent.turn.startedAt?.toISOString() ?? null, + }; +} + function serializeAgent(agent: Agent): StoredAgent { const snapshot = { id: agent.id, @@ -572,11 +606,11 @@ function serializeAgent(agent: Agent): StoredAgent { updatedAt: agent.updatedAt.toISOString(), lastUserMessageAt: agent.lastUserMessageAt?.toISOString() ?? null, status: agent.status, - ...(agent.activeTurn?.turnId + ...(agent.turn.phase === "open" && agent.turn.turnId ? { activeTurn: { - turnId: agent.activeTurn.turnId, - startedAt: agent.activeTurn.startedAt?.toISOString() ?? null, + turnId: agent.turn.turnId, + startedAt: agent.turn.startedAt?.toISOString() ?? null, }, } : {}), @@ -614,14 +648,27 @@ function serializeAgent(agent: Agent): StoredAgent { }; return { snapshot, + turn: serializeAgentTurn(agent), projectPlacement: serializeProjectPlacement(agent), lastActivityAt: agent.lastActivityAt.toISOString(), }; } function deserializeAgent(serverId: string, stored: StoredAgent): Agent { + const normalized = normalizeAgentSnapshot(stored.snapshot, serverId); + let turn = normalized.turn; + if (stored.turn?.phase === "idle") turn = { phase: "idle", cancellationRequestId: null }; + if (stored.turn?.phase === "open") { + turn = { + phase: "open", + turnId: stored.turn.turnId, + startedAt: stored.turn.startedAt ? new Date(stored.turn.startedAt) : null, + cancellationRequestId: null, + }; + } return { - ...normalizeAgentSnapshot(stored.snapshot, serverId), + ...normalized, + turn, lastActivityAt: new Date(stored.lastActivityAt), projectPlacement: stored.projectPlacement, }; @@ -773,8 +820,10 @@ interface DirectoryReadAccumulator { checkpoint?: DirectoryCheckpoint; } -interface PendingReplicaChanges extends ReplicaRowChanges { - directoryReplacements: Map>; +interface PendingReplicaChanges { + upserts: StructuredReplicaUpsert[]; + deletes: ReplicaRowKey[]; + baselines: Set; } function applyDirectoryRow( @@ -825,9 +874,10 @@ export class ReplicaCache { private readonly storedRows = new Map>(); private readonly hostBytes = new Map(); private readonly hostWriteOrder = new Map(); - private pendingUpserts = new Map(); + private pendingUpserts = new Map(); private pendingDeletes = new Map(); - private pendingDirectoryReplacements = new Map>(); + private pendingBaselines = new Set(); + private readonly invalidatedHosts = new Set(); private readonly maxBytes: number; private totalBytes = 0; private persistTimer: ReturnType | null = null; @@ -1019,39 +1069,65 @@ export class ReplicaCache { }); } - commitDirectory( + commitDirectoryMutations( serverId: string, - directory: { - agents: Map; - workspaces: Map; - projects: Map; - checkpoint?: DirectoryCheckpoint; - }, + mutations: readonly DirectoryReplicaMutation[], + checkpoint?: DirectoryCheckpoint, ): void { if (!this.activeServerIds.has(serverId)) return; + if (this.invalidatedHosts.has(serverId)) return; this.advanceHostRevision(serverId); - this.clearPendingDirectoryChanges(serverId); - const desiredRows: ReplicaRow[] = []; - for (const agent of directory.agents.values()) { - desiredRows.push(this.entityRow(serverId, "agent", agent.id, serializeAgent(agent))); + for (const mutation of mutations) { + if (mutation.type === "delete") { + this.queueEntityDelete(serverId, mutation.kind, mutation.id); + } else { + this.queueUpsert({ + serverId, + kind: mutation.kind, + id: mutation.id, + value: mutation.value, + } as StructuredReplicaUpsert); + } } - for (const workspace of directory.workspaces.values()) { - desiredRows.push( - this.entityRow(serverId, "workspace", workspace.id, serializeWorkspace(workspace)), - ); + if (checkpoint) { + this.queueUpsert({ + serverId, + kind: "checkpoint", + id: REPLICA_SINGLETON_ROW_ID, + value: checkpoint, + }); } - for (const project of directory.projects.values()) { - desiredRows.push( - this.entityRow(serverId, "project", project.projectId, serializeProject(project)), - ); + this.schedulePersist(); + } + + replaceDirectoryBaseline(serverId: string, directory: CachedDirectory): void { + if (!this.activeServerIds.has(serverId)) return; + this.advanceHostRevision(serverId); + // Replacing the directory must not discard independently accepted timeline changes. + for (const [key, row] of this.pendingUpserts) { + if (row.serverId === serverId && row.kind !== "timeline") this.pendingUpserts.delete(key); + } + for (const [key, row] of this.pendingDeletes) { + if (row.serverId === serverId && row.kind !== "timeline") this.pendingDeletes.delete(key); + } + this.pendingBaselines.add(serverId); + for (const [id, value] of directory.agents) { + this.queueUpsert({ serverId, kind: "agent", id, value }); + } + for (const [id, value] of directory.workspaces) { + this.queueUpsert({ serverId, kind: "workspace", id, value }); + } + for (const [id, value] of directory.projects) { + this.queueUpsert({ serverId, kind: "project", id, value }); } if (directory.checkpoint) { - desiredRows.push( - this.entityRow(serverId, "checkpoint", REPLICA_SINGLETON_ROW_ID, directory.checkpoint), - ); + this.queueUpsert({ + serverId, + kind: "checkpoint", + id: REPLICA_SINGLETON_ROW_ID, + value: directory.checkpoint, + }); } - for (const row of desiredRows) this.queueUpsert(row); - this.pendingDirectoryReplacements.set(serverId, new Set(desiredRows.map((row) => rowKey(row)))); this.schedulePersist(); } @@ -1059,9 +1135,7 @@ export class ReplicaCache { if (!this.activeServerIds.has(serverId)) return; if (timeline.agentId !== agentId) throw new Error("Timeline cache key does not match payload"); this.advanceHostRevision(serverId); - const stored = serializeTimeline(timeline); - if (stored) this.queueEntityUpsert(serverId, "timeline", agentId, stored); - else this.queueEntityDelete(serverId, "timeline", agentId); + this.queueUpsert({ serverId, kind: "timeline", id: agentId, value: timeline }); this.schedulePersist(); } @@ -1075,6 +1149,7 @@ export class ReplicaCache { for (const serverId of removed) { this.removeStoredHost(serverId); this.dropPendingHostChanges(serverId); + this.invalidatedHosts.delete(serverId); this.queueOperation(() => this.rowStore.deleteHost(serverId)); } } @@ -1124,12 +1199,15 @@ export class ReplicaCache { try { await this.prepareStore(); await this.ensureStoredIndex(); - const changes = this.materializeDirectoryReplacements(pending); - const boundedChanges = await this.fitChangesToBudget(changes); + const changes = this.materializePendingChanges(pending); + const { changes: boundedChanges, evicted } = await this.fitChangesToBudget(changes); if (boundedChanges.upserts.length > 0 || boundedChanges.deletes.length > 0) { await this.rowStore.apply(boundedChanges); this.applyStoredChanges(boundedChanges); } + for (const serverId of pending.baselines) { + if (!evicted.has(serverId)) this.invalidatedHosts.delete(serverId); + } } catch { this.restorePendingChanges(pending); if (this.hasPendingChanges()) this.schedulePersist(); @@ -1140,39 +1218,11 @@ export class ReplicaCache { await write; } - private queueEntityUpsert( - serverId: string, - kind: ReplicaRowKind, - id: string, - value: unknown, - ): void { - this.queueUpsert(this.entityRow(serverId, kind, id, value)); - } - - private entityRow( - serverId: string, - kind: ReplicaRowKind, - id: string, - value: unknown, - ): ReplicaRow { - return { serverId, kind, id, payload: JSON.stringify(value) }; - } - - private clearPendingDirectoryChanges(serverId: string): void { - const isDirectoryRow = (row: ReplicaRowKey) => row.kind !== "timeline"; - for (const [key, row] of this.pendingUpserts) { - if (row.serverId === serverId && isDirectoryRow(row)) this.pendingUpserts.delete(key); - } - for (const [key, row] of this.pendingDeletes) { - if (row.serverId === serverId && isDirectoryRow(row)) this.pendingDeletes.delete(key); - } - } - private queueEntityDelete(serverId: string, kind: ReplicaRowKind, id: string): void { this.queueDelete({ serverId, kind, id }); } - private queueUpsert(row: ReplicaRow): void { + private queueUpsert(row: StructuredReplicaUpsert): void { const key = pendingRowKey(row); this.pendingDeletes.delete(key); this.pendingUpserts.set(key, row); @@ -1186,9 +1236,7 @@ export class ReplicaCache { private hasPendingChanges(): boolean { return ( - this.pendingUpserts.size > 0 || - this.pendingDeletes.size > 0 || - this.pendingDirectoryReplacements.size > 0 + this.pendingUpserts.size > 0 || this.pendingDeletes.size > 0 || this.pendingBaselines.size > 0 ); } @@ -1201,7 +1249,7 @@ export class ReplicaCache { } private hasPendingHostChanges(serverId: string): boolean { - if (this.pendingDirectoryReplacements.has(serverId)) return true; + if (this.pendingBaselines.has(serverId)) return true; for (const row of this.pendingUpserts.values()) { if (row.serverId === serverId) return true; } @@ -1219,31 +1267,66 @@ export class ReplicaCache { const changes = { upserts: [...this.pendingUpserts.values()], deletes: [...this.pendingDeletes.values()], - directoryReplacements: this.pendingDirectoryReplacements, + baselines: this.pendingBaselines, }; this.pendingUpserts = new Map(); this.pendingDeletes = new Map(); - this.pendingDirectoryReplacements = new Map(); + this.pendingBaselines = new Set(); return changes; } - private materializeDirectoryReplacements(pending: PendingReplicaChanges): ReplicaRowChanges { + private materializePendingChanges(pending: PendingReplicaChanges): ReplicaRowChanges { const deletes = new Map(pending.deletes.map((row) => [pendingRowKey(row), row])); - for (const [serverId, desiredRows] of pending.directoryReplacements) { + const upserts: ReplicaRow[] = []; + for (const upsert of pending.upserts) { + const row = this.materializeUpsert(upsert); + if (row) upserts.push(row); + else deletes.set(pendingRowKey(upsert), upsert); + } + for (const serverId of pending.baselines) { + const replacementKeys = new Set( + upserts.filter((row) => row.serverId === serverId && row.kind !== "timeline").map(rowKey), + ); for (const row of this.storedRows.get(serverId)?.values() ?? []) { - if (row.kind !== "timeline" && !desiredRows.has(rowKey(row))) { + if (row.kind !== "timeline" && !replacementKeys.has(rowKey(row))) { deletes.set(pendingRowKey(row), row); } } } - return { upserts: pending.upserts, deletes: [...deletes.values()] }; + return { upserts, deletes: [...deletes.values()] }; + } + + private materializeUpsert(upsert: StructuredReplicaUpsert): ReplicaRow | null { + let value: unknown; + switch (upsert.kind) { + case "agent": + value = serializeAgent(upsert.value); + break; + case "workspace": + value = serializeWorkspace(upsert.value); + break; + case "project": + value = serializeProject(upsert.value); + break; + case "timeline": + value = serializeTimeline(upsert.value); + if (!value) return null; + break; + case "checkpoint": + value = upsert.value; + break; + } + return { + serverId: upsert.serverId, + kind: upsert.kind, + id: upsert.id, + payload: JSON.stringify(value), + }; } private restorePendingChanges(changes: PendingReplicaChanges): void { - for (const [serverId, desiredRows] of changes.directoryReplacements) { - if (this.activeServerIds.has(serverId) && !this.pendingDirectoryReplacements.has(serverId)) { - this.pendingDirectoryReplacements.set(serverId, desiredRows); - } + for (const serverId of changes.baselines) { + if (this.activeServerIds.has(serverId)) this.pendingBaselines.add(serverId); } for (const key of changes.deletes) { const pendingKey = pendingRowKey(key); @@ -1306,7 +1389,9 @@ export class ReplicaCache { this.hostWriteOrder.set(serverId, true); } - private async fitChangesToBudget(changes: ReplicaRowChanges): Promise { + private async fitChangesToBudget( + changes: ReplicaRowChanges, + ): Promise<{ changes: ReplicaRowChanges; evicted: Set }> { const touchedServerIds = new Set(); for (const key of changes.deletes) touchedServerIds.add(key.serverId); for (const row of changes.upserts) touchedServerIds.add(row.serverId); @@ -1342,15 +1427,17 @@ export class ReplicaCache { projectedTotal -= bytes; projectedBytes.delete(serverId); evicted.add(serverId); + this.invalidatedHosts.add(serverId); if (this.storedRows.has(serverId)) await this.rowStore.deleteHost(serverId); this.removeStoredHost(serverId); - // The next accepted owner commit must rebuild the complete host replica. Retaining the - // directory replacement marker ensures its checkpoint and rows stay atomic. } return { - upserts: changes.upserts.filter((row) => !evicted.has(row.serverId)), - deletes: changes.deletes.filter((key) => !evicted.has(key.serverId)), + changes: { + upserts: changes.upserts.filter((row) => !evicted.has(row.serverId)), + deletes: changes.deletes.filter((key) => !evicted.has(key.serverId)), + }, + evicted, }; } @@ -1362,7 +1449,7 @@ export class ReplicaCache { } private dropPendingHostChanges(serverId: string): void { - this.pendingDirectoryReplacements.delete(serverId); + this.pendingBaselines.delete(serverId); for (const [key, row] of this.pendingUpserts) { if (row.serverId === serverId) this.pendingUpserts.delete(key); } @@ -1373,18 +1460,16 @@ export class ReplicaCache { private renamePendingHostChanges(oldServerId: string, newServerId: string): void { const changes = this.drainPendingChanges(); - for (const [serverId, desiredRows] of changes.directoryReplacements) { - this.pendingDirectoryReplacements.set( - serverId === oldServerId ? newServerId : serverId, - desiredRows, - ); - } for (const key of changes.deletes) { this.queueDelete(key.serverId === oldServerId ? { ...key, serverId: newServerId } : key); } for (const row of changes.upserts) { this.queueUpsert(row.serverId === oldServerId ? { ...row, serverId: newServerId } : row); } + for (const serverId of changes.baselines) { + this.pendingBaselines.add(serverId === oldServerId ? newServerId : serverId); + } + if (this.invalidatedHosts.delete(oldServerId)) this.invalidatedHosts.add(newServerId); } private prepareStore(): Promise { diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index ac84be35f8..9096ae9e94 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -1,3 +1,4 @@ +import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react"; import type { ReactElement, RefObject } from "react"; import { useTranslation } from "react-i18next"; @@ -53,7 +54,7 @@ import { navigateToWorkspace, useLastWorkspaceSelection, } from "@/stores/navigation-active-workspace-store"; -import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store"; +import { normalizeWorkspaceDescriptor, type WorkspaceDescriptor } from "@/stores/session-store"; import { useWorkspace } from "@/stores/session-store-hooks"; import { buildNewWorkspaceDraftKey, generateDraftId } from "@/stores/draft-keys"; import { useOpenAddProject } from "@/hooks/use-open-add-project"; @@ -1555,7 +1556,12 @@ export function NewWorkspaceScreen({ const insets = useSafeAreaInsets(); const isCompact = useIsCompactFormFactor(); const toast = useToast(); - const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces); + const mergeWorkspaces = useCallback( + (targetServerId: string, workspaces: Iterable) => { + getHostRuntimeStore().acceptWorkspaceSnapshots(targetServerId, Array.from(workspaces)); + }, + [], + ); const { allHosts, selectedServerId, diff --git a/packages/app/src/stores/session-store.test.ts b/packages/app/src/stores/session-store.test.ts index 3475f4779c..c53eae4c49 100644 --- a/packages/app/src/stores/session-store.test.ts +++ b/packages/app/src/stores/session-store.test.ts @@ -8,9 +8,57 @@ import { selectAgentTurnPresentation, selectAgentTimelineState, useSessionStore, + type Agent, type WorkspaceDescriptor, } from "./session-store"; import type { StreamItem } from "../types/stream"; +import { reduceTurnLiveness, type TurnLivenessTransition } from "@/timeline/turn-liveness"; + +function createTestAgent(agentId: string): Agent { + return { + serverId: "test-server", + id: agentId, + provider: "codex", + status: "idle", + turn: { phase: "idle", cancellationRequestId: null }, + createdAt: new Date(0), + updatedAt: new Date(0), + lastUserMessageAt: null, + lastActivityAt: new Date(0), + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: false, + supportsMcpServers: false, + supportsReasoningStream: false, + supportsToolInvocations: false, + }, + currentModeId: null, + availableModes: [], + pendingPermissions: [], + persistence: null, + title: null, + cwd: "/repo", + model: null, + parentAgentId: null, + labels: {}, + }; +} + +function applyTestTurn( + serverId: string, + agentId: string, + transition: TurnLivenessTransition | readonly TurnLivenessTransition[], +): void { + useSessionStore.getState().setAgents(serverId, (agents) => { + const agent = agents.get(agentId) ?? createTestAgent(agentId); + const transitions = Array.isArray(transition) ? transition : [transition]; + const turn = transitions.reduce(reduceTurnLiveness, agent.turn); + const next = new Map(agents); + next.set(agentId, { ...agent, status: turn.phase === "open" ? "running" : "idle", turn }); + return next; + }); +} function createWorkspace( input: Partial & Pick, @@ -222,22 +270,25 @@ describe("agent timeline state", () => { it("stores turn liveness transitions without duplicating their policy", () => { initializeTestSession(); const store = useSessionStore.getState(); - store.applyAgentTurnLiveness("test-server", "agent-1", { + applyTestTurn("test-server", "agent-1", { type: "snapshot", activeTurn: { turnId: "turn-1", startedAt: null }, }); - expect(store.getSession("test-server")?.agentTurnLiveness.get("agent-1")).toEqual({ + expect(store.getSession("test-server")?.agents.get("agent-1")?.turn).toEqual({ phase: "open", turnId: "turn-1", startedAt: null, cancellationRequestId: null, }); - store.applyAgentTurnLiveness("test-server", "agent-1", { + applyTestTurn("test-server", "agent-1", { type: "snapshot", activeTurn: null, }); - expect(store.getSession("test-server")?.agentTurnLiveness.has("agent-1")).toBe(false); + expect(store.getSession("test-server")?.agents.get("agent-1")?.turn).toEqual({ + phase: "idle", + cancellationRequestId: null, + }); }); }); @@ -267,27 +318,27 @@ describe("message submission ordering", () => { return; } if (step === "stream-open") { - store.applyAgentTurnLiveness("test-server", agentId, { + applyTestTurn("test-server", agentId, { type: "stream_open", turn: { turnId: "turn-1", startedAt }, }); return; } if (step === "snapshot-open") { - store.applyAgentTurnLiveness("test-server", agentId, { + applyTestTurn("test-server", agentId, { type: "snapshot", activeTurn: { turnId: "turn-1", startedAt }, }); return; } if (step === "snapshot-idle") { - store.applyAgentTurnLiveness("test-server", agentId, { + applyTestTurn("test-server", agentId, { type: "snapshot", activeTurn: null, }); return; } - store.applyAgentTurnLiveness("test-server", agentId, { + applyTestTurn("test-server", agentId, { type: "stream_close", turnId: "turn-1", }); @@ -322,7 +373,7 @@ describe("message submission ordering", () => { store.setAgentStreamState("test-server", agentId, { acknowledgedClientMessageIds: [clientMessageId], }); - store.applyAgentTurnLiveness("test-server", agentId, [ + applyTestTurn("test-server", agentId, [ { type: "stream_close", turnId: "turn-1" }, { type: "snapshot", activeTurn: null }, ]); diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index c2328a5b62..3f1ae6ffcf 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -54,12 +54,10 @@ import { type WorkspaceAgentActivity, } from "@/utils/workspace-agent-activity"; import { - applyTurnLivenessTransition, resolveTurnPresentation, TURN_LIVENESS_IDLE, type TurnLiveness, type TurnPresentation, - type TurnLivenessTransition, } from "@/timeline/turn-liveness"; export interface AgentRuntimeInfo { @@ -76,7 +74,7 @@ export interface Agent { id: string; provider: AgentProvider; status: AgentLifecycleStatus; - activeTurn: { turnId: string | null; startedAt: Date | null } | null; + turn: TurnLiveness; createdAt: Date; updatedAt: Date; lastUserMessageAt: Date | null; @@ -332,7 +330,9 @@ export function selectAgentTurnPresentation( agentId: string, ): TurnPresentation { return resolveTurnPresentation( - session?.agentTurnLiveness.get(agentId) ?? TURN_LIVENESS_IDLE, + session?.agents.get(agentId)?.turn ?? + session?.agentDetails.get(agentId)?.turn ?? + TURN_LIVENESS_IDLE, getActiveMessageSubmissions(session?.messageSubmissions.get(agentId)).length > 0, ); } @@ -387,7 +387,6 @@ export interface SessionState { agentStreamTail: Map; agentStreamHead: Map; agentTasks: Map; - agentTurnLiveness: Map; messageSubmissions: Map; agentTimelineCursor: Map; agentTimelineHasOlder: Map; @@ -476,14 +475,6 @@ interface SessionStoreActions { taskSnapshot?: TodoEntry[]; }, ) => void; - applyAgentTurnLiveness: ( - serverId: string, - agentId: string, - transition: TurnLivenessTransition | readonly TurnLivenessTransition[], - ) => void; - beginAgentCancellation: (serverId: string, agentId: string) => number; - settleAgentCancellation: (serverId: string, agentId: string, requestId: number) => void; - clearAgentTurnLiveness: (serverId: string) => void; beginAgentMessageSubmission: ( serverId: string, agentId: string, @@ -644,7 +635,6 @@ function createInitialSessionState( agentStreamTail: new Map(), agentStreamHead: new Map(), agentTasks: new Map(), - agentTurnLiveness: new Map(), messageSubmissions: new Map(), agentTimelineCursor: new Map(), agentTimelineHasOlder: new Map(), @@ -711,7 +701,6 @@ function isSessionServerInfoUnchanged(input: { export const useSessionStore = create()( subscribeWithSelector((set, get) => { - let nextCancellationRequestId = 0; const commitActivityUpdates: AgentLastActivityCommitter = (updates) => { set((prev) => { let nextActivity: Map | null = null; @@ -1058,57 +1047,6 @@ export const useSessionStore = create()( }); }, - applyAgentTurnLiveness: (serverId, agentId, transition) => { - set((prev) => { - const session = prev.sessions[serverId]; - if (!session) return prev; - const agentTurnLiveness = applyTurnLivenessTransition( - session.agentTurnLiveness, - agentId, - transition, - ); - if (agentTurnLiveness === session.agentTurnLiveness) return prev; - return { - ...prev, - sessions: { - ...prev.sessions, - [serverId]: { ...session, agentTurnLiveness }, - }, - }; - }); - }, - - beginAgentCancellation: (serverId, agentId) => { - nextCancellationRequestId += 1; - const requestId = nextCancellationRequestId; - get().applyAgentTurnLiveness(serverId, agentId, { - type: "cancellation_started", - requestId, - }); - return requestId; - }, - - settleAgentCancellation: (serverId, agentId, requestId) => { - get().applyAgentTurnLiveness(serverId, agentId, { - type: "cancellation_settled", - requestId, - }); - }, - - clearAgentTurnLiveness: (serverId) => { - set((prev) => { - const session = prev.sessions[serverId]; - if (!session || session.agentTurnLiveness.size === 0) return prev; - return { - ...prev, - sessions: { - ...prev.sessions, - [serverId]: { ...session, agentTurnLiveness: new Map() }, - }, - }; - }); - }, - beginAgentMessageSubmission: (serverId, agentId, message) => { set((prev) => { const session = prev.sessions[serverId]; @@ -1924,6 +1862,7 @@ export const useSessionStore = create()( serverId, title: agent.title ?? null, status: agent.status, + turn: agent.turn, lastActivityAt, cwd: agent.cwd, provider: agent.provider, diff --git a/packages/app/src/stores/workspace-subagents-integration.test.ts b/packages/app/src/stores/workspace-subagents-integration.test.ts index 29925efd42..5f04a39bd9 100644 --- a/packages/app/src/stores/workspace-subagents-integration.test.ts +++ b/packages/app/src/stores/workspace-subagents-integration.test.ts @@ -37,7 +37,7 @@ const AGENT_DEFAULTS: Agent = { id: "agent", provider: "codex", status: "idle", - activeTurn: null, + turn: { phase: "idle", cancellationRequestId: null }, createdAt: AGENT_TIMESTAMP, updatedAt: AGENT_TIMESTAMP, lastUserMessageAt: null, diff --git a/packages/app/src/subagents/archive-finished.test.ts b/packages/app/src/subagents/archive-finished.test.ts index 42a3e51ca6..5c1615d96c 100644 --- a/packages/app/src/subagents/archive-finished.test.ts +++ b/packages/app/src/subagents/archive-finished.test.ts @@ -11,6 +11,10 @@ function paseo(id: string, status: PaseoSubagentRow["status"] = "idle"): PaseoSu description: null, subtitle: null, status, + turn: + status === "running" + ? { phase: "open", turnId: null, startedAt: null, cancellationRequestId: null } + : { phase: "idle", cancellationRequestId: null }, requiresAttention: false, createdAt: new Date(), }; diff --git a/packages/app/src/subagents/select.test.ts b/packages/app/src/subagents/select.test.ts index 068210233a..6835260ca7 100644 --- a/packages/app/src/subagents/select.test.ts +++ b/packages/app/src/subagents/select.test.ts @@ -13,7 +13,7 @@ const AGENT_DEFAULTS: Agent = { id: "agent", provider: "codex", status: "idle", - activeTurn: null, + turn: { phase: "idle", cancellationRequestId: null }, createdAt: AGENT_TIMESTAMP, updatedAt: AGENT_TIMESTAMP, lastUserMessageAt: null, @@ -316,6 +316,7 @@ describe("selectSubagentsForParent", () => { description: null, subtitle: null, status: "running", + turn: { phase: "idle", cancellationRequestId: null }, requiresAttention: true, createdAt, }, @@ -330,6 +331,7 @@ describe("selectSubagentsForParent", () => { "status", "subtitle", "title", + "turn", ]); expect(rows[0]).not.toHaveProperty("onOpen"); expect(rows[0]).not.toHaveProperty("model"); diff --git a/packages/app/src/subagents/select.ts b/packages/app/src/subagents/select.ts index b9e1883705..3bb3bddcb6 100644 --- a/packages/app/src/subagents/select.ts +++ b/packages/app/src/subagents/select.ts @@ -15,6 +15,7 @@ export interface PaseoSubagentRow { description: null; subtitle: null; status: Agent["status"]; + turn: Agent["turn"]; requiresAttention: Agent["requiresAttention"]; createdAt: Agent["createdAt"]; } @@ -60,6 +61,7 @@ function toSubagentRow(agent: Agent): SubagentRow { description: null, subtitle: null, status: agent.status, + turn: agent.turn, requiresAttention: agent.requiresAttention, createdAt: agent.createdAt, }; diff --git a/packages/app/src/subagents/track-presentation.test.ts b/packages/app/src/subagents/track-presentation.test.ts index 516e2fafb7..df009583be 100644 --- a/packages/app/src/subagents/track-presentation.test.ts +++ b/packages/app/src/subagents/track-presentation.test.ts @@ -19,6 +19,11 @@ function row( description: null, subtitle: null, status: overrides.status ?? "idle", + turn: + overrides.turn ?? + (overrides.status === "running" + ? { phase: "open", turnId: null, startedAt: null, cancellationRequestId: null } + : { phase: "idle", cancellationRequestId: null }), requiresAttention: overrides.requiresAttention ?? false, createdAt: overrides.createdAt ?? new Date("2026-04-20T00:00:00.000Z"), }; diff --git a/packages/app/src/subagents/track-presentation.ts b/packages/app/src/subagents/track-presentation.ts index 1e2ddbb8d8..d95082ca39 100644 --- a/packages/app/src/subagents/track-presentation.ts +++ b/packages/app/src/subagents/track-presentation.ts @@ -7,7 +7,10 @@ import { isFinishedSubagent } from "./archive-finished"; import { providerSubagentLifecycleStatus } from "./provider-store"; function presentationStatus(row: SubagentRow) { - if (row.kind === "paseo") return row.status; + if (row.kind === "paseo") { + if (row.turn.phase === "open") return "running"; + return row.status === "running" ? "idle" : row.status; + } return providerSubagentLifecycleStatus(row.status); } diff --git a/packages/app/src/test/seed-session.ts b/packages/app/src/test/seed-session.ts index 23848acd24..d483e01c5d 100644 --- a/packages/app/src/test/seed-session.ts +++ b/packages/app/src/test/seed-session.ts @@ -1,3 +1,5 @@ +import { getHostRuntimeStore } from "@/runtime/host-runtime"; +import { defaultHostAppearance } from "@/hosts/appearance"; import { useSessionStore, type ProjectDescriptor, @@ -39,3 +41,32 @@ export function seedSessionWorkspaces( store.setProjects(serverId, byProjectId.values()); store.setWorkspaces(serverId, workspaces); } + +export function seedSessionHosts(serverIds: readonly string[]): void { + getHostRuntimeStore().syncHosts( + serverIds.map((serverId) => ({ + serverId, + label: serverId, + appearance: defaultHostAppearance(), + lifecycle: {}, + connections: [], + preferredConnectionId: null, + createdAt: "2026-09-01T00:00:00.000Z", + updatedAt: "2026-09-01T00:00:00.000Z", + })), + ); +} + +export function seedRuntimeWorkspaces( + serverId: string, + workspaces: Map, +): void { + const runtime = getHostRuntimeStore(); + for (const id of useSessionStore.getState().sessions[serverId]?.workspaces.keys() ?? []) { + if (!workspaces.has(id)) runtime.removeWorkspaceSnapshot(serverId, id); + } + for (const workspace of workspaces.values()) { + runtime.acceptProjectSnapshot(serverId, projectFromWorkspace(workspace)); + } + runtime.acceptWorkspaceSnapshots(serverId, [...workspaces.values()]); +} diff --git a/packages/app/src/timeline/replica.test.ts b/packages/app/src/timeline/replica.test.ts index daaadd8c08..53d4710347 100644 --- a/packages/app/src/timeline/replica.test.ts +++ b/packages/app/src/timeline/replica.test.ts @@ -173,6 +173,59 @@ describe("viewed timeline persistence", () => { owner.dispose(); }); + it("reopens painted live mutations without persisting authoritative coverage", async () => { + useSessionStore.getState().initializeSession(SERVER_ID, null); + let durable: CachedTimeline | undefined = cachedTimeline(); + let pending: CachedTimeline | undefined; + const storage: TimelineReplicaStorage = { + readTimeline: async () => durable, + commitTimeline: (_serverId, _agentId, timeline) => { + pending = timeline; + }, + }; + const first = createOwner(storage); + first.replaceVisibleAgentIds("test", [AGENT_ID]); + await expect + .poll(() => + selectAgentTimelineState(useSessionStore.getState().sessions[SERVER_ID], AGENT_ID), + ) + .toMatchObject({ status: "painted" }); + + first.enqueueStreamEvent(AGENT_ID, { + event: { + type: "timeline", + provider: "codex", + item: { type: "assistant_message", text: "live", messageId: "live" }, + } as AgentStreamEventPayload, + seq: 5, + epoch: "epoch-1", + timestamp: new Date("2026-08-26T10:00:01.000Z"), + }); + first.flushStreamAgent(AGENT_ID); + + expect(pending).toMatchObject({ range: null, hasOlder: false }); + expect(pending?.items.map((entry) => entry.id)).toEqual(["cached", expect.any(String)]); + durable = pending; + first.dispose(); + useSessionStore.getState().clearSession(SERVER_ID); + useSessionStore.getState().initializeSession(SERVER_ID, null); + + const reopened = createOwner(storage); + reopened.replaceVisibleAgentIds("test", [AGENT_ID]); + await expect + .poll(() => + selectAgentTimelineState(useSessionStore.getState().sessions[SERVER_ID], AGENT_ID), + ) + .toMatchObject({ + status: "painted", + items: [ + expect.objectContaining({ id: "cached" }), + expect.objectContaining({ text: "live" }), + ], + }); + reopened.dispose(); + }); + it("does not let a late cache read overwrite newer network state", async () => { useSessionStore.getState().initializeSession(SERVER_ID, null); let release!: (value: CachedTimeline) => void; diff --git a/packages/app/src/timeline/viewed-timeline-sync.ts b/packages/app/src/timeline/viewed-timeline-sync.ts index d6bd3b2a20..323676739b 100644 --- a/packages/app/src/timeline/viewed-timeline-sync.ts +++ b/packages/app/src/timeline/viewed-timeline-sync.ts @@ -141,13 +141,13 @@ class TimelineReplicaOwner implements TimelineReplica { timelineUpdated(agentId: string): void { const session = useSessionStore.getState().sessions[this.serverId]; const timeline = selectAgentTimelineState(session, agentId); - if (timeline.status !== "synced") return; - this.cachedRanges.delete(agentId); + if (timeline.status === "cold") return; + if (timeline.status === "synced") this.cachedRanges.delete(agentId); this.storage.commitTimeline(this.serverId, agentId, { agentId, items: [...timeline.items, ...(session?.agentStreamHead.get(agentId) ?? [])], - range: timeline.range, - hasOlder: timeline.older === "available", + range: timeline.status === "synced" ? timeline.range : null, + hasOlder: timeline.status === "synced" && timeline.older === "available", }); } } @@ -250,7 +250,7 @@ function finalizeProcessedTimeline(input: { .clearByAgent({ serverId: input.serverId, agentId: input.agentId }); const session = useSessionStore.getState().sessions[input.serverId]; const agent = session?.agents.get(input.agentId) ?? session?.agentDetails.get(input.agentId); - if (agent && agent.status !== "running") input.drainQueuedAgentMessage(input.agentId); + if (agent && agent.turn.phase === "idle") input.drainQueuedAgentMessage(input.agentId); } if (input.result.initResolution === "resolve") resolveInitDeferred(input.initKey); } diff --git a/packages/app/src/types/agent-directory.ts b/packages/app/src/types/agent-directory.ts index 19beea5ecb..8ac1870a6f 100644 --- a/packages/app/src/types/agent-directory.ts +++ b/packages/app/src/types/agent-directory.ts @@ -6,6 +6,7 @@ export type AgentDirectoryEntry = Pick< | "serverId" | "title" | "status" + | "turn" | "lastActivityAt" | "cwd" | "workspaceId" diff --git a/packages/app/src/utils/agent-attention.test.ts b/packages/app/src/utils/agent-attention.test.ts index b9a0d8e6be..ac5a6f84f3 100644 --- a/packages/app/src/utils/agent-attention.test.ts +++ b/packages/app/src/utils/agent-attention.test.ts @@ -34,7 +34,7 @@ function createAgent(input: Partial & Pick): Agent { attentionReason: null, attentionTimestamp: null, ...rest, - activeTurn: rest.activeTurn ?? null, + turn: rest.turn ?? { phase: "idle", cancellationRequestId: null }, }; } diff --git a/packages/app/src/utils/agent-directory-reconciliation.test.ts b/packages/app/src/utils/agent-directory-reconciliation.test.ts index bf1e0a53ef..85e311345b 100644 --- a/packages/app/src/utils/agent-directory-reconciliation.test.ts +++ b/packages/app/src/utils/agent-directory-reconciliation.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from "vitest"; import type { FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client"; import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; -import type { Agent } from "@/stores/session-store"; import { reconcileAgentDirectory } from "./agent-directory-reconciliation"; function snapshot(id: string, status: AgentSnapshotPayload["status"]): AgentSnapshotPayload { @@ -50,28 +49,9 @@ function entry(id: string, status: AgentSnapshotPayload["status"]): FetchAgentsE }; } -function replica(id: string, status: Agent["status"]): Agent { - return { - ...snapshot(id, status), - activeTurn: null, - serverId: "server", - createdAt: new Date("2026-07-12T10:00:00.000Z"), - updatedAt: new Date("2026-07-12T10:00:00.000Z"), - lastActivityAt: new Date("2026-07-12T10:00:00.000Z"), - lastUserMessageAt: null, - attentionTimestamp: null, - archivedAt: null, - parentAgentId: null, - }; -} - describe("agent directory reconciliation", () => { - it("reports snapshot-only and buffered running transitions exactly once", () => { + it("preserves snapshot and buffered protocol status without interpreting liveness", () => { const result = reconcileAgentDirectory({ - previous: new Map([ - ["snapshot", replica("snapshot", "running")], - ["buffered", replica("buffered", "running")], - ]), snapshot: [entry("snapshot", "idle"), entry("buffered", "running")], deltas: [ { @@ -87,8 +67,7 @@ describe("agent directory reconciliation", () => { ], }); - expect(result.stoppedRunningAgentIds).toEqual(["snapshot", "buffered"]); - expect(result.entries.map(({ agent }) => [agent.id, agent.status])).toEqual([ + expect(result.map(({ agent }) => [agent.id, agent.status])).toEqual([ ["snapshot", "idle"], ["buffered", "idle"], ]); @@ -96,7 +75,6 @@ describe("agent directory reconciliation", () => { it("preserves ordered upserts and removals received after page one", () => { const result = reconcileAgentDirectory({ - previous: new Map(), snapshot: [entry("updated", "idle"), entry("removed", "idle")], deltas: [ { @@ -108,9 +86,7 @@ describe("agent directory reconciliation", () => { ], }); - expect(result.entries.map(({ agent }) => [agent.id, agent.title])).toEqual([ - ["updated", "live"], - ]); + expect(result.map(({ agent }) => [agent.id, agent.title])).toEqual([["updated", "live"]]); }); it("keeps newer page metadata when a stale buffered upsert arrives", () => { @@ -120,7 +96,6 @@ describe("agent directory reconciliation", () => { projectName: "stale project", }; const result = reconcileAgentDirectory({ - previous: new Map([["agent", replica("agent", "running")]]), snapshot: [ { ...entry("agent", "running"), @@ -145,16 +120,14 @@ describe("agent directory reconciliation", () => { }); expect({ - title: result.entries[0]?.agent.title, - status: result.entries[0]?.agent.status, - projectName: result.entries[0]?.project.projectName, - stopped: result.stoppedRunningAgentIds, - }).toEqual({ title: "newer page", status: "running", projectName: "repo", stopped: [] }); + title: result[0]?.agent.title, + status: result[0]?.agent.status, + projectName: result[0]?.project.projectName, + }).toEqual({ title: "newer page", status: "running", projectName: "repo" }); }); it("clears a snapshot stop when a newer buffered upsert is running", () => { const result = reconcileAgentDirectory({ - previous: new Map([["agent", replica("agent", "running")]]), snapshot: [entry("agent", "idle")], deltas: [ { @@ -168,13 +141,11 @@ describe("agent directory reconciliation", () => { ], }); - expect(result.entries[0]?.agent.status).toBe("running"); - expect(result.stoppedRunningAgentIds).toEqual([]); + expect(result[0]?.agent.status).toBe("running"); }); it("accepts usage from a stale buffered upsert without regressing metadata", () => { const result = reconcileAgentDirectory({ - previous: new Map(), snapshot: [ { ...entry("agent", "idle"), @@ -201,9 +172,9 @@ describe("agent directory reconciliation", () => { }); expect({ - title: result.entries[0]?.agent.title, - status: result.entries[0]?.agent.status, - usage: result.entries[0]?.agent.lastUsage, + title: result[0]?.agent.title, + status: result[0]?.agent.status, + usage: result[0]?.agent.lastUsage, }).toEqual({ title: "newer page", status: "idle", @@ -213,7 +184,6 @@ describe("agent directory reconciliation", () => { it("preserves usage when a stale buffered upsert omits it", () => { const result = reconcileAgentDirectory({ - previous: new Map(), snapshot: [ { ...entry("agent", "idle"), @@ -236,6 +206,6 @@ describe("agent directory reconciliation", () => { ], }); - expect(result.entries[0]?.agent.lastUsage).toEqual({ inputTokens: 10, outputTokens: 5 }); + expect(result[0]?.agent.lastUsage).toEqual({ inputTokens: 10, outputTokens: 5 }); }); }); diff --git a/packages/app/src/utils/agent-directory-reconciliation.ts b/packages/app/src/utils/agent-directory-reconciliation.ts index 86130f5d2a..fa34f911bd 100644 --- a/packages/app/src/utils/agent-directory-reconciliation.ts +++ b/packages/app/src/utils/agent-directory-reconciliation.ts @@ -1,39 +1,20 @@ import type { FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client"; -import type { Agent } from "@/stores/session-store"; import type { AgentDirectoryDelta } from "./agent-directory-sync"; import { acceptAgentDirectoryUpdate } from "./agent-directory-update-policy"; export function reconcileAgentDirectory(input: { - previous: ReadonlyMap; snapshot: FetchAgentsEntry[]; deltas: readonly AgentDirectoryDelta[]; -}): { entries: FetchAgentsEntry[]; stoppedRunningAgentIds: string[] } { +}): FetchAgentsEntry[] { const entries = new Map(input.snapshot.map((entry) => [entry.agent.id, entry])); - const statuses = new Map(Array.from(input.previous, ([id, agent]) => [id, agent.status])); - const stoppedRunningAgentIds = new Set(); - - for (const entry of input.snapshot) { - if (statuses.get(entry.agent.id) === "running" && entry.agent.status !== "running") { - stoppedRunningAgentIds.add(entry.agent.id); - } - statuses.set(entry.agent.id, entry.agent.status); - } for (const delta of input.deltas) { if (delta.kind === "remove") { entries.delete(delta.agentId); - statuses.delete(delta.agentId); - stoppedRunningAgentIds.delete(delta.agentId); continue; } const previousEntry = entries.get(delta.agent.id); const acceptedAgent = acceptAgentDirectoryUpdate(previousEntry?.agent, delta.agent); - if (acceptedAgent.status === "running") { - stoppedRunningAgentIds.delete(delta.agent.id); - } else if (statuses.get(delta.agent.id) === "running") { - stoppedRunningAgentIds.add(delta.agent.id); - } - statuses.set(delta.agent.id, acceptedAgent.status); const previousProject = previousEntry?.project; const acceptedProject = acceptedAgent === delta.agent ? (delta.project ?? previousProject) : previousProject; @@ -55,8 +36,5 @@ export function reconcileAgentDirectory(input: { }); } - return { - entries: Array.from(entries.values()), - stoppedRunningAgentIds: Array.from(stoppedRunningAgentIds), - }; + return Array.from(entries.values()); } diff --git a/packages/app/src/utils/agent-directory-sync.test.ts b/packages/app/src/utils/agent-directory-sync.test.ts index 71fb4cc350..c8e2479894 100644 --- a/packages/app/src/utils/agent-directory-sync.test.ts +++ b/packages/app/src/utils/agent-directory-sync.test.ts @@ -8,7 +8,7 @@ import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { isAgentArchiving, setAgentArchiving } from "@/hooks/use-archive-agent"; import { queryClient } from "@/data/query-client"; import { createUserMessage } from "@/types/stream"; -import { applyAgentDirectoryDelta, replaceFetchedAgentDirectory } from "./agent-directory-sync"; +import { AgentStoreProjection } from "@/runtime/directory-sync/internal/agent-store"; function createAgentPayload( input: Partial> & { @@ -79,79 +79,18 @@ function beginPendingSubmission(serverId: string, agentId: string): string { return clientMessageId; } -function applyAgentStatus(input: { - serverId: string; - agentId: string; - status: AgentSnapshotPayload["status"]; - updatedAt: string; - lastUserMessageAt?: string; -}): void { - const agent = createAgentPayload({ - id: input.agentId, - status: input.status, - updatedAt: input.updatedAt, - lastUserMessageAt: input.lastUserMessageAt ?? null, - }); - applyAgentDirectoryDelta({ - serverId: input.serverId, - delta: { kind: "upsert", agent, project: createEntry(agent).project }, - }); -} - -describe("turn liveness authority", () => { - it("normalizes old-daemon status into the shared activity replica", () => { - const serverId = "server-turn-liveness"; - const agentId = "agent-1"; - const startedAt = "2026-07-27T10:00:01.000Z"; - const store = useSessionStore.getState(); - store.initializeSession(serverId, null as unknown as DaemonClient); - applyAgentStatus({ serverId, agentId, status: "idle", updatedAt: startedAt }); - - applyAgentStatus({ - serverId, - agentId, - status: "running", - updatedAt: "2026-07-27T10:00:02.000Z", - lastUserMessageAt: startedAt, - }); - expect(store.getSession(serverId)?.agentTurnLiveness.get(agentId)).toEqual({ - phase: "open", - turnId: null, - startedAt: new Date(startedAt), - cancellationRequestId: null, - }); - - applyAgentStatus({ - serverId, - agentId, - status: "idle", - updatedAt: "2026-07-27T10:00:03.000Z", - }); - - expect(store.getSession(serverId)?.agentTurnLiveness.has(agentId)).toBe(false); - store.clearSession(serverId); - }); -}); - describe("message submission authority", () => { it("does not settle a submission from an unrelated running transition", () => { const serverId = "server-running-is-not-submission-ack"; const agentId = "agent-1"; const store = useSessionStore.getState(); store.initializeSession(serverId, null as unknown as DaemonClient); - applyAgentStatus({ - serverId, - agentId, - status: "idle", - updatedAt: "2026-07-27T10:00:00.000Z", - }); const clientMessageId = beginPendingSubmission(serverId, agentId); - - applyAgentStatus({ - serverId, - agentId, - status: "running", - updatedAt: "2026-07-27T10:00:01.000Z", + const agent = createAgentPayload({ id: agentId, status: "running" }); + new AgentStoreProjection(serverId).applyDelta({ + kind: "upsert", + agent, + project: createEntry(agent).project, }); expect(useSessionStore.getState().sessions[serverId]?.messageSubmissions.get(agentId)).toEqual([ @@ -169,12 +108,6 @@ describe("message submission authority", () => { const agentId = "agent-1"; const store = useSessionStore.getState(); store.initializeSession(serverId, null as unknown as DaemonClient); - applyAgentStatus({ - serverId, - agentId, - status: "idle", - updatedAt: "2026-07-27T10:00:00.000Z", - }); const clientMessageId = beginPendingSubmission(serverId, agentId); store.setAgentStreamState(serverId, agentId, { tail: [ @@ -213,10 +146,9 @@ describe("replaceFetchedAgentDirectory", () => { store.initializeSession(serverId, null as unknown as DaemonClient); store.setInitializingAgents(serverId, new Map([["agent", true]])); - replaceFetchedAgentDirectory({ - serverId, - entries: [createEntry(createAgentPayload({ id: "agent" }))], - }); + new AgentStoreProjection(serverId).replaceFetched([ + createEntry(createAgentPayload({ id: "agent" })), + ]); expect(useSessionStore.getState().sessions[serverId]?.initializingAgents.get("agent")).toBe( true, @@ -229,29 +161,24 @@ describe("replaceFetchedAgentDirectory", () => { const store = useSessionStore.getState(); store.initializeSession(serverId, null as unknown as DaemonClient); - replaceFetchedAgentDirectory({ - serverId, - entries: [ - createEntry( - createAgentPayload({ - id: "child-1", - labels: { [PARENT_AGENT_ID_LABEL]: "parent-a" }, - }), - ), - ], - }); + const projection = new AgentStoreProjection(serverId); + projection.replaceFetched([ + createEntry( + createAgentPayload({ + id: "child-1", + labels: { [PARENT_AGENT_ID_LABEL]: "parent-a" }, + }), + ), + ]); - replaceFetchedAgentDirectory({ - serverId, - entries: [ - createEntry( - createAgentPayload({ - id: "child-1", - labels: { [PARENT_AGENT_ID_LABEL]: "parent-b" }, - }), - ), - ], - }); + projection.replaceFetched([ + createEntry( + createAgentPayload({ + id: "child-1", + labels: { [PARENT_AGENT_ID_LABEL]: "parent-b" }, + }), + ), + ]); expect( useSessionStore.getState().sessions[serverId]?.agents.get("child-1")?.parentAgentId, @@ -286,7 +213,7 @@ describe("replaceFetchedAgentDirectory", () => { store.setInitializingAgents(serverId, new Map([[agentId, true]])); setAgentArchiving({ queryClient, serverId, agentId, isArchiving: true }); - applyAgentDirectoryDelta({ serverId, delta: { kind: "remove", agentId } }); + new AgentStoreProjection(serverId).applyDelta({ kind: "remove", agentId }); const session = useSessionStore.getState().sessions[serverId]; expect({ @@ -346,28 +273,27 @@ describe("replaceFetchedAgentDirectory", () => { lastUsage: { inputTokens: 10, outputTokens: 5 }, pendingPermissions: [permission("current-permission")], }); - applyAgentDirectoryDelta({ - serverId, - delta: { kind: "upsert", agent: current, project: createEntry(current).project }, + const projection = new AgentStoreProjection(serverId); + projection.applyDelta({ + kind: "upsert", + agent: current, + project: createEntry(current).project, }); store.flushAgentLastActivity(); setAgentArchiving({ queryClient, serverId, agentId, isArchiving: true }); - const staleResult = applyAgentDirectoryDelta({ - serverId, - delta: { - kind: "upsert", - agent: { - ...current, - title: "stale", - status: "idle", - updatedAt: "2026-07-12T10:00:00.000Z", - lastUsage: { inputTokens: 20, outputTokens: 8 }, - pendingPermissions: [permission("stale-permission")], - archivedAt: "2026-07-12T10:00:00.000Z", - }, - project: createEntry(current).project, + const staleResult = projection.applyDelta({ + kind: "upsert", + agent: { + ...current, + title: "stale", + status: "idle", + updatedAt: "2026-07-12T10:00:00.000Z", + lastUsage: { inputTokens: 20, outputTokens: 8 }, + pendingPermissions: [permission("stale-permission")], + archivedAt: "2026-07-12T10:00:00.000Z", }, + project: createEntry(current).project, }); store.flushAgentLastActivity(); diff --git a/packages/app/src/utils/agent-directory-sync.ts b/packages/app/src/utils/agent-directory-sync.ts index 2d81158c81..d700a0f510 100644 --- a/packages/app/src/utils/agent-directory-sync.ts +++ b/packages/app/src/utils/agent-directory-sync.ts @@ -1,15 +1,8 @@ -import equal from "fast-deep-equal"; import type { FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client"; -import { type Agent, useSessionStore } from "@/stores/session-store"; +import type { Agent } from "@/stores/session-store"; import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { resolveProjectPlacement } from "@/utils/project-placement"; 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< @@ -17,128 +10,6 @@ export type AgentDirectoryDelta = Extract< { type: "agent_update" } >["payload"]; -export function applyAgentDirectoryDelta(input: { serverId: string; delta: AgentDirectoryDelta }): { - agentId: string; - stoppedRunning: boolean; -} { - if (input.delta.kind === "remove") { - removeAgentDirectoryReplica(input.serverId, input.delta.agentId); - return { agentId: input.delta.agentId, stoppedRunning: false }; - } - return upsertAgentDirectoryReplica(input.serverId, input.delta); -} - -type AgentUpsertDelta = Extract; - -function upsertAgentDirectoryReplica( - serverId: string, - delta: AgentUpsertDelta, -): { agentId: string; stoppedRunning: boolean } { - const normalized = normalizeAgentSnapshot(delta.agent, serverId); - const session = useSessionStore.getState().sessions[serverId]; - const previousAgent = - session?.agents.get(normalized.id) ?? session?.agentDetails.get(normalized.id); - const legacyWorkspaceId = - previousAgent?.workspaceId ?? - Array.from(session?.workspaces.values() ?? []).find( - (workspace) => - session?.serverInfo?.features?.workspaceMultiplicity !== true && - workspace.workspaceDirectory === normalized.cwd, - )?.id; - const agent: Agent = { - ...normalized, - workspaceId: normalized.workspaceId ?? legacyWorkspaceId, - projectPlacement: - resolveProjectPlacement({ projectPlacement: delta.project, cwd: normalized.cwd }) ?? - previousAgent?.projectPlacement, - }; - const acceptedAgent = upsertAgentReplica(serverId, agent); - if (acceptedAgent.archivedAt) { - clearArchiveAgentPending({ queryClient, serverId, agentId: acceptedAgent.id }); - } - replaceAgentPendingPermissions(serverId, acceptedAgent); - useSessionStore.getState().setAgentLastActivity(acceptedAgent.id, acceptedAgent.lastActivityAt); - return { - agentId: acceptedAgent.id, - stoppedRunning: previousAgent?.status === "running" && acceptedAgent.status !== "running", - }; -} - -export function upsertAgentReplica(serverId: string, agent: Agent): Agent { - let acceptedAgent = agent; - useSessionStore.getState().setAgents(serverId, (current) => { - const currentAgent = current.get(agent.id); - acceptedAgent = acceptAgentDirectoryUpdate(currentAgent, agent); - if (acceptedAgent === currentAgent) return current; - const next = new Map(current); - next.set(agent.id, acceptedAgent); - return next; - }); - applyAgentTurnSnapshot(serverId, acceptedAgent); - return acceptedAgent; -} - -function applyAgentTurnSnapshot(serverId: string, agent: Agent): void { - useSessionStore.getState().applyAgentTurnLiveness(serverId, agent.id, { - type: "snapshot", - activeTurn: agent.activeTurn, - }); -} - -export function replaceAgentPendingPermissions(serverId: string, agent: Agent): void { - const pendingPermissions = new Map( - useSessionStore.getState().sessions[serverId]?.pendingPermissions, - ); - for (const [key, pending] of pendingPermissions) { - if (pending.agentId === agent.id) pendingPermissions.delete(key); - } - for (const request of agent.pendingPermissions) { - const key = derivePendingPermissionKey(agent.id, request); - pendingPermissions.set(key, { key, agentId: agent.id, request }); - } - useSessionStore.getState().setPendingPermissions(serverId, pendingPermissions); -} - -export function removeAgentDirectoryReplica(serverId: string, agentId: string): void { - const store = useSessionStore.getState(); - clearArchiveAgentPending({ queryClient, serverId, agentId }); - const removeKey = (current: Map): Map => { - if (!current.has(agentId)) return current; - const next = new Map(current); - next.delete(agentId); - return next; - }; - store.setAgents(serverId, removeKey); - store.setAgentDetails(serverId, removeKey); - store.setQueuedMessages(serverId, removeKey); - store.setAgentTimelineCursor(serverId, removeKey); - store.setInitializingAgents(serverId, removeKey); - store.setPendingPermissions(serverId, (current) => { - const next = new Map(current); - for (const [key, pending] of next) { - if (pending.agentId === agentId) next.delete(key); - } - return next.size === current.size ? current : next; - }); - store.setAgentAuthoritativeHistoryApplied(serverId, agentId, false); - store.setAgentStreamTail(serverId, removeKey); - store.clearAgentStreamHead(serverId, agentId); - store.applyAgentTurnLiveness(serverId, agentId, { type: "destructive_close" }); - 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 { key: string; agentId: string; @@ -175,49 +46,3 @@ export function buildAgentDirectoryState(input: { return { agents, pendingPermissions }; } - -export function replaceFetchedAgentDirectory(input: { - serverId: string; - entries: FetchAgentsEntry[]; -}): { agents: Map } { - const { agents: fetchedAgents, pendingPermissions } = buildAgentDirectoryState(input); - const store = useSessionStore.getState(); - for (const agent of fetchedAgents.values()) { - if (agent.archivedAt) { - clearArchiveAgentPending({ queryClient, serverId: input.serverId, agentId: agent.id }); - } - } - - const currentAgents = store.sessions[input.serverId]?.agents ?? new Map(); - const agents = new Map(); - for (const [agentId, fetchedAgent] of fetchedAgents) { - const current = currentAgents.get(agentId); - agents.set(agentId, current && equal(current, fetchedAgent) ? current : fetchedAgent); - } - - store.setAgents(input.serverId, agents); - for (const agent of agents.values()) { - applyAgentTurnSnapshot(input.serverId, agent); - } - store.setAgentDetails(input.serverId, (prev) => { - let next: Map | null = null; - for (const agentId of agents.keys()) { - if (!prev.has(agentId)) { - continue; - } - next ??= new Map(prev); - next.delete(agentId); - } - return next ?? prev; - }); - - const lastActivityByAgentId = new Map(); - for (const agent of agents.values()) { - lastActivityByAgentId.set(agent.id, agent.lastActivityAt); - } - store.setAgentLastActivityBatch(lastActivityByAgentId); - - store.setPendingPermissions(input.serverId, new Map(pendingPermissions)); - store.setHasHydratedAgents(input.serverId, true); - return { agents }; -} diff --git a/packages/app/src/utils/agent-grouping.test.ts b/packages/app/src/utils/agent-grouping.test.ts index 7df88ada97..f380dc1118 100644 --- a/packages/app/src/utils/agent-grouping.test.ts +++ b/packages/app/src/utils/agent-grouping.test.ts @@ -10,6 +10,14 @@ function makeAgent(overrides: Partial = {}): AggregatedAgent { serverLabel: (overrides as { serverLabel?: string }).serverLabel ?? "server", title: overrides.title ?? null, status: overrides.status ?? ("running" as AggregatedAgent["status"]), + turn: + overrides.turn ?? + ({ + phase: "open", + turnId: null, + startedAt: null, + cancellationRequestId: null, + } as const), lastActivityAt: overrides.lastActivityAt ?? now, cwd: overrides.cwd ?? "/tmp/repo", provider: overrides.provider ?? ("openai" as AggregatedAgent["provider"]), @@ -63,6 +71,28 @@ describe("deriveProjectDisplayName", () => { }); describe("groupAgents", () => { + it("uses turn liveness instead of protocol status for active grouping", () => { + const old = new Date("2020-01-01T00:00:00.000Z"); + const open = makeAgent({ id: "open", status: "idle", lastActivityAt: old }); + const idle = makeAgent({ + id: "idle", + status: "running", + lastActivityAt: old, + turn: { phase: "idle", cancellationRequestId: null }, + }); + + const grouped = groupAgents([open, idle]); + + const activeIds = grouped.activeGroups + .flatMap((group) => group.agents) + .map((agent) => agent.id); + const inactiveIds = grouped.inactiveGroups + .flatMap((group) => group.agents) + .map((agent) => agent.id); + expect(activeIds).toEqual(["open"]); + expect(inactiveIds).toEqual(["idle"]); + }); + it("groups active agents by remote URL when available", () => { const agents = [ makeAgent({ id: "a1", cwd: "/Users/me/dev/paseo" }), diff --git a/packages/app/src/utils/agent-grouping.ts b/packages/app/src/utils/agent-grouping.ts index a741a81d39..eaca2d1511 100644 --- a/packages/app/src/utils/agent-grouping.ts +++ b/packages/app/src/utils/agent-grouping.ts @@ -264,7 +264,9 @@ const MAX_INACTIVE_PER_PROJECT = 5; */ function isAgentTrulyActive(agent: AggregatedAgent): boolean { return ( - agent.status === "running" || agent.requiresAttention || (agent.pendingPermissionCount ?? 0) > 0 + agent.turn.phase === "open" || + agent.requiresAttention || + (agent.pendingPermissionCount ?? 0) > 0 ); } diff --git a/packages/app/src/utils/agent-snapshots.test.ts b/packages/app/src/utils/agent-snapshots.test.ts index 334e36d9f2..e1be8c5a0c 100644 --- a/packages/app/src/utils/agent-snapshots.test.ts +++ b/packages/app/src/utils/agent-snapshots.test.ts @@ -57,15 +57,25 @@ describe("normalizeAgentSnapshot", () => { activeTurn: { turnId: "turn-1", startedAt }, }), "server-1", - ).activeTurn, - ).toEqual({ turnId: "turn-1", startedAt: new Date(startedAt) }); + ).turn, + ).toEqual({ + phase: "open", + turnId: "turn-1", + startedAt: new Date(startedAt), + cancellationRequestId: null, + }); expect( normalizeAgentSnapshot( createSnapshot({ status: "running", lastUserMessageAt: startedAt }), "server-1", - ).activeTurn, - ).toEqual({ turnId: null, startedAt: new Date(startedAt) }); + ).turn, + ).toEqual({ + phase: "open", + turnId: null, + startedAt: new Date(startedAt), + cancellationRequestId: null, + }); }); it("derives parentAgentId from the parent label while preserving labels", () => { diff --git a/packages/app/src/utils/agent-snapshots.ts b/packages/app/src/utils/agent-snapshots.ts index a035a054a2..c1c70c640e 100644 --- a/packages/app/src/utils/agent-snapshots.ts +++ b/packages/app/src/utils/agent-snapshots.ts @@ -1,7 +1,11 @@ import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types"; import { getParentAgentIdFromLabels } from "@getpaseo/protocol/agent-labels"; -import type { ActiveTurnIdentity } from "@/timeline/turn-liveness"; +import { + TURN_LIVENESS_IDLE, + type ActiveTurnIdentity, + type TurnLiveness, +} from "@/timeline/turn-liveness"; import type { Agent } from "@/stores/session-store"; function normalizeActiveTurn( @@ -18,13 +22,23 @@ function normalizeActiveTurn( return snapshot.status === "running" ? { turnId: null, startedAt: lastUserMessageAt } : null; } +function normalizeTurn( + snapshot: AgentSnapshotPayload, + lastUserMessageAt: Date | null, +): TurnLiveness { + const activeTurn = normalizeActiveTurn(snapshot, lastUserMessageAt); + return activeTurn + ? { phase: "open", ...activeTurn, cancellationRequestId: null } + : TURN_LIVENESS_IDLE; +} + function projectActiveTurn(agent: Agent): Pick { - if (agent.activeTurn === null) return { activeTurn: null }; - if (agent.activeTurn.turnId === null) return {}; + if (agent.turn.phase === "idle") return { activeTurn: null }; + if (agent.turn.turnId === null) return {}; return { activeTurn: { - turnId: agent.activeTurn.turnId, - startedAt: agent.activeTurn.startedAt?.toISOString() ?? null, + turnId: agent.turn.turnId, + startedAt: agent.turn.startedAt?.toISOString() ?? null, }, }; } @@ -88,14 +102,14 @@ export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId: // COMPAT(agentTurnIdentity): added in v0.2.6, remove after 2027-01-31 once daemon floor >= v0.2.6. // Old daemons expose only status. Normalize that legacy signal once so the rest // of the app consumes one activity shape. - const activeTurn = normalizeActiveTurn(snapshot, lastUserMessageAt); + const turn = normalizeTurn(snapshot, lastUserMessageAt); return { serverId, id: snapshot.id, provider: snapshot.provider, status: snapshot.status, - activeTurn, + turn, createdAt, updatedAt, lastUserMessageAt, diff --git a/packages/app/src/utils/workspace-agent-activity.test.ts b/packages/app/src/utils/workspace-agent-activity.test.ts index 09ada95695..7ccca095f1 100644 --- a/packages/app/src/utils/workspace-agent-activity.test.ts +++ b/packages/app/src/utils/workspace-agent-activity.test.ts @@ -6,6 +6,7 @@ function agent(input: { id: string; workspaceId?: string; status?: Agent["status"]; + turn?: Agent["turn"]; updatedAt: string; attentionTimestamp?: string | null; requiresAttention?: boolean; @@ -19,7 +20,16 @@ function agent(input: { id: input.id, provider: "codex", status: input.status ?? "idle", - activeTurn: input.status === "running" ? { turnId: "turn-1", startedAt: null } : null, + turn: + input.turn ?? + (input.status === "running" + ? { + phase: "open", + turnId: "turn-1", + startedAt: null, + cancellationRequestId: null, + } + : { phase: "idle", cancellationRequestId: null }), createdAt: new Date("2026-01-01T00:00:00.000Z"), updatedAt: new Date(input.updatedAt), lastUserMessageAt: null, @@ -56,6 +66,41 @@ function agent(input: { } describe("workspace agent activity index", () => { + it("uses turn liveness for running while preserving protocol lifecycle states", () => { + const result = buildWorkspaceAgentActivityIndex( + new Map([ + [ + "open", + agent({ + id: "open", + workspaceId: "workspace-open", + status: "idle", + turn: { + phase: "open", + turnId: null, + startedAt: null, + cancellationRequestId: null, + }, + updatedAt: "2026-01-01T00:00:00.000Z", + }), + ], + [ + "idle-error", + agent({ + id: "idle-error", + workspaceId: "workspace-error", + status: "error", + turn: { phase: "idle", cancellationRequestId: null }, + updatedAt: "2026-01-01T00:00:00.000Z", + }), + ], + ]), + ); + + expect(result.get("workspace-open")?.status).toBe("running"); + expect(result.get("workspace-error")?.status).toBe("failed"); + }); + it("keeps the latest active root agent for each workspace", () => { const index = buildWorkspaceAgentActivityIndex( new Map([ diff --git a/packages/app/src/utils/workspace-agent-activity.ts b/packages/app/src/utils/workspace-agent-activity.ts index 3e4355ee3a..69f3c23da4 100644 --- a/packages/app/src/utils/workspace-agent-activity.ts +++ b/packages/app/src/utils/workspace-agent-activity.ts @@ -8,6 +8,11 @@ export interface WorkspaceAgentActivity { enteredAt: Date | null; } +function workspaceAgentStatus(agent: Agent): Agent["status"] { + if (agent.turn.phase === "open") return "running"; + return agent.status === "running" ? "idle" : agent.status; +} + export function buildWorkspaceAgentActivityIndex( agents: ReadonlyMap, previous?: ReadonlyMap, @@ -29,7 +34,7 @@ export function buildWorkspaceAgentActivityIndex( latestActivityAtByWorkspaceId.set(agent.workspaceId, enteredAt); const status = deriveSidebarStateBucket({ - status: agent.status, + status: workspaceAgentStatus(agent), pendingPermissionCount: agent.pendingPermissions.length, requiresAttention: agent.requiresAttention, attentionReason: agent.attentionReason, diff --git a/packages/app/src/workspace-tabs/agent-visibility.test.ts b/packages/app/src/workspace-tabs/agent-visibility.test.ts index 8488c8f515..bece625a89 100644 --- a/packages/app/src/workspace-tabs/agent-visibility.test.ts +++ b/packages/app/src/workspace-tabs/agent-visibility.test.ts @@ -23,7 +23,7 @@ function makeAgent(input: { id: input.id, provider: "codex", status: "idle", - activeTurn: null, + turn: { phase: "idle", cancellationRequestId: null }, createdAt, updatedAt: createdAt, lastUserMessageAt: null, diff --git a/packages/app/src/workspace/legacy-daemon-workspaces.test.ts b/packages/app/src/workspace/legacy-daemon-workspaces.test.ts index 7f50c4b57a..50dc1a8ca1 100644 --- a/packages/app/src/workspace/legacy-daemon-workspaces.test.ts +++ b/packages/app/src/workspace/legacy-daemon-workspaces.test.ts @@ -5,7 +5,6 @@ import { deriveWorkspaceAgentVisibility } from "@/workspace-tabs/agent-visibilit import { buildWorkspaceStructureProjects } from "@/projects/workspace-structure"; import { applyLegacyDaemonWorkspaceOwnership, - backfillLegacyDaemonWorkspaceDirectoryIfEmpty, buildLegacyDaemonWorkspaceSnapshot, } from "./legacy-daemon-workspaces"; @@ -197,47 +196,4 @@ describe("buildLegacyDaemonWorkspaceSnapshot", () => { expect(stampedUpdate.workspaceId).toBe("/repo/app"); expect(visibility.activeAgentIds).toEqual(new Set(["agent-running"])); }); - - it("does not backfill path-backed workspaces after hydration is cancelled", async () => { - const store = useSessionStore.getState(); - store.initializeSession(SERVER_ID, null as unknown as DaemonClient); - store.updateSessionServerInfo(SERVER_ID, { - serverId: SERVER_ID, - hostname: null, - version: "0.1.96", - }); - let cancelled = false; - let didFetchAgents = false; - const client: Pick = { - fetchAgents: async () => { - didFetchAgents = true; - cancelled = true; - return { - requestId: "req_cancelled_backfill", - subscriptionId: null, - entries: [legacyAgent({ id: "agent-cancelled", cwd: "/repo/app" })], - pageInfo: { - nextCursor: null, - prevCursor: null, - hasMore: false, - }, - }; - }, - }; - - const didBackfill = await backfillLegacyDaemonWorkspaceDirectoryIfEmpty({ - client, - serverId: SERVER_ID, - workspaces: new Map(), - projects: new Map(), - isCancelled: () => cancelled, - }); - - const session = useSessionStore.getState().sessions[SERVER_ID]; - expect(didFetchAgents).toBe(true); - expect(didBackfill).toBe(true); - expect(session?.agents.size).toBe(0); - expect(session?.workspaces.size).toBe(0); - expect(session?.hasHydratedWorkspaces).toBe(false); - }); }); diff --git a/packages/app/src/workspace/legacy-daemon-workspaces.ts b/packages/app/src/workspace/legacy-daemon-workspaces.ts index 6de82a3f3e..8303cfadec 100644 --- a/packages/app/src/workspace/legacy-daemon-workspaces.ts +++ b/packages/app/src/workspace/legacy-daemon-workspaces.ts @@ -7,17 +7,13 @@ import { deriveAgentStateBucket, getWorkspaceStateBucketPriority, } from "@getpaseo/protocol/agent-state-bucket"; -import type { - Agent, - DaemonServerInfo, - ProjectDescriptor, - WorkspaceDescriptor, -} from "@/stores/session-store"; -import { useSessionStore } from "@/stores/session-store"; import { - buildAgentDirectoryState, - replaceFetchedAgentDirectory, -} from "@/utils/agent-directory-sync"; + useSessionStore, + type Agent, + type DaemonServerInfo, + type WorkspaceDescriptor, +} from "@/stores/session-store"; +import { buildAgentDirectoryState } from "@/utils/agent-directory-sync"; import { normalizeWorkspacePath } from "@/utils/workspace-identity"; export interface LegacyDaemonWorkspaceSnapshot { @@ -25,23 +21,11 @@ export interface LegacyDaemonWorkspaceSnapshot { workspaces: Map; } -export interface LegacyDaemonWorkspaceFetchResult extends LegacyDaemonWorkspaceSnapshot { - subscriptionId: string | null; -} - interface LegacyDaemonWorkspaceDirectoryReadResult { entries: FetchAgentsEntry[]; subscriptionId: string | null; } -interface LegacyDaemonWorkspaceBackfillInput { - client: Pick; - serverId: string; - workspaces: ReadonlyMap; - projects: ReadonlyMap; - isCancelled?: () => boolean; -} - const LEGACY_AGENT_DIRECTORY_SORT: NonNullable = [ { key: "updated_at", direction: "desc" }, ]; @@ -82,50 +66,6 @@ function shouldBackfillLegacyDaemonWorkspaceDirectory( return serverInfo?.features?.workspaceMultiplicity !== true; } -export async function fetchLegacyDaemonWorkspaceDirectory(input: { - client: Pick; - serverId: string; - subscribe?: FetchAgentsOptions["subscribe"]; - page?: FetchAgentsOptions["page"]; -}): Promise { - const directory = await readLegacyDaemonWorkspaceDirectory(input); - if (!directory) { - throw new Error("Legacy daemon workspace directory fetch was cancelled."); - } - const snapshot = replaceLegacyDaemonWorkspaceDirectory({ - serverId: input.serverId, - entries: directory.entries, - }); - return { ...snapshot, subscriptionId: directory.subscriptionId }; -} - -export async function backfillLegacyDaemonWorkspaceDirectoryIfEmpty( - input: LegacyDaemonWorkspaceBackfillInput, -): Promise { - if (input.workspaces.size > 0 || input.projects.size > 0) { - return false; - } - const serverInfo = useSessionStore.getState().sessions[input.serverId]?.serverInfo; - if (!shouldBackfillLegacyDaemonWorkspaceDirectory(serverInfo)) { - return false; - } - if (input.isCancelled?.()) { - return true; - } - const directory = await readLegacyDaemonWorkspaceDirectory({ - client: input.client, - isCancelled: input.isCancelled, - }); - if (!directory || input.isCancelled?.()) { - return true; - } - replaceLegacyDaemonWorkspaceDirectory({ - serverId: input.serverId, - entries: directory.entries, - }); - return true; -} - export async function readLegacyDaemonWorkspaceDirectory(input: { client: Pick; subscribe?: FetchAgentsOptions["subscribe"]; @@ -182,10 +122,11 @@ export function applyLegacyDaemonWorkspaceOwnership(input: { } const existingAgent = - session?.agents.get(input.agent.id) ?? session?.agentDetails.get(input.agent.id) ?? null; + session?.agents.get(input.agent.id) ?? session?.agentDetails.get(input.agent.id); + const workspaces = session?.workspaces; const workspaceId = existingAgent?.workspaceId ?? - resolveLegacyWorkspaceIdFromAgent(input.agent, session?.workspaces) ?? + resolveLegacyWorkspaceIdFromAgent(input.agent, workspaces) ?? null; if (!workspaceId) { return input.agent; @@ -198,37 +139,6 @@ export function applyLegacyDaemonWorkspaceOwnership(input: { }; } -export function replaceLegacyDaemonWorkspaceDirectory(input: { - serverId: string; - entries: FetchAgentsEntry[]; -}): LegacyDaemonWorkspaceSnapshot { - const entries = stampLegacyWorkspaceIds(input.entries); - const { agents } = replaceFetchedAgentDirectory({ - serverId: input.serverId, - entries, - }); - const workspaces = buildLegacyWorkspaces(entries); - const store = useSessionStore.getState(); - store.setWorkspaces(input.serverId, workspaces); - store.setProjects( - input.serverId, - Array.from(workspaces.values(), legacyProjectDescriptorFromWorkspace), - ); - store.setHasHydratedWorkspaces(input.serverId, true); - return { agents, workspaces }; -} - -function legacyProjectDescriptorFromWorkspace(workspace: WorkspaceDescriptor): ProjectDescriptor { - return { - projectId: workspace.projectId, - projectKey: null, - projectDisplayName: workspace.projectDisplayName, - projectCustomName: workspace.projectCustomName ?? null, - projectRootPath: workspace.projectRootPath, - projectKind: workspace.projectKind, - }; -} - function readFetchAgentsHasMore( pageInfo: Awaited>["pageInfo"], ): boolean { @@ -369,7 +279,7 @@ function resolveLegacyWorkspaceId(entry: FetchAgentsEntry): string { function resolveLegacyWorkspaceIdFromAgent( agent: Agent, - workspaces: Map | undefined, + workspaces: ReadonlyMap | undefined, ): string | null { const cwd = normalizeWorkspacePath(agent.cwd); if (!cwd) { diff --git a/packages/app/src/workspace/workspace-archive.test.ts b/packages/app/src/workspace/workspace-archive.test.ts index b4db600e60..e7fe660f3b 100644 --- a/packages/app/src/workspace/workspace-archive.test.ts +++ b/packages/app/src/workspace/workspace-archive.test.ts @@ -1,3 +1,5 @@ +import { seedSessionHosts } from "@/test/seed-session"; +import { getHostRuntimeStore } from "@/runtime/host-runtime"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { @@ -85,10 +87,12 @@ function storedWorkspace(id: string): WorkspaceDescriptor | undefined { } beforeEach(() => { + seedSessionHosts([SERVER_ID, SECOND_SERVER_ID]); useSessionStore.getState().initializeSession(SERVER_ID, {} as DaemonClient); }); afterEach(() => { + seedSessionHosts([]); clearWorkspaceArchivePending({ serverId: SERVER_ID, workspaceId: "workspace-1" }); clearWorkspaceArchivePending({ serverId: SERVER_ID, workspaceId: "workspace-2" }); clearWorkspaceArchivePending({ serverId: SECOND_SERVER_ID, workspaceId: "workspace-1" }); @@ -99,7 +103,7 @@ afterEach(() => { describe("archiveWorkspaceOptimistically", () => { it("hides the workspace and marks the archive pending while the daemon call runs", async () => { const archived = workspace(); - useSessionStore.getState().mergeWorkspaces(SERVER_ID, [archived]); + getHostRuntimeStore().acceptWorkspaceSnapshots(SERVER_ID, [archived]); const releaseArchive = deferred(); const client = createClient(vi.fn(async () => releaseArchive.promise)); @@ -124,7 +128,7 @@ describe("archiveWorkspaceOptimistically", () => { it("restores the workspace and clears pending state when the daemon rejects the archive", async () => { const archived = workspace(); - useSessionStore.getState().mergeWorkspaces(SERVER_ID, [archived]); + getHostRuntimeStore().acceptWorkspaceSnapshots(SERVER_ID, [archived]); const client = createClient( vi.fn(async () => archivePayload({ workspaceId: archived.id, error: "nope" })), ); @@ -154,7 +158,7 @@ describe("archiveWorkspacesOptimistically", () => { workspaceDirectory: "/repo/project/workspace-2", name: "workspace-2", }); - useSessionStore.getState().mergeWorkspaces(SERVER_ID, [first, second]); + getHostRuntimeStore().acceptWorkspaceSnapshots(SERVER_ID, [first, second]); const client = createClient( vi.fn(async (workspaceId) => archivePayload({ @@ -183,8 +187,8 @@ describe("archiveWorkspacesOptimistically", () => { name: "workspace-2", }); useSessionStore.getState().initializeSession(SECOND_SERVER_ID, {} as DaemonClient); - useSessionStore.getState().mergeWorkspaces(SERVER_ID, [first]); - useSessionStore.getState().mergeWorkspaces(SECOND_SERVER_ID, [second]); + getHostRuntimeStore().acceptWorkspaceSnapshots(SERVER_ID, [first]); + getHostRuntimeStore().acceptWorkspaceSnapshots(SECOND_SERVER_ID, [second]); const archivedByServer = new Map(); const clientFor = (serverId: string) => diff --git a/packages/app/src/workspace/workspace-archive.ts b/packages/app/src/workspace/workspace-archive.ts index 0bb4bcf5f9..a4e5a00e80 100644 --- a/packages/app/src/workspace/workspace-archive.ts +++ b/packages/app/src/workspace/workspace-archive.ts @@ -1,3 +1,4 @@ +import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { clearWorkspaceArchivePending, markWorkspaceArchivePending, @@ -50,7 +51,7 @@ function hideWorkspaceOptimistically( serverId: workspace.serverId, workspaceId: workspace.workspaceId, }); - useSessionStore.getState().removeWorkspace(workspace.serverId, workspace.workspaceId); + getHostRuntimeStore().removeWorkspaceSnapshot(workspace.serverId, workspace.workspaceId); return { workspace: snapshot }; } @@ -64,7 +65,7 @@ function restoreOptimisticallyHiddenWorkspace(input: { workspaceId: input.workspaceId, }); if (input.snapshot.workspace) { - useSessionStore.getState().mergeWorkspaces(input.serverId, [input.snapshot.workspace]); + getHostRuntimeStore().acceptWorkspaceSnapshots(input.serverId, [input.snapshot.workspace]); } } diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index b3d79b023b..da173431fe 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -945,6 +945,87 @@ test("does not reconnect after close when ensureConnected is called", async () = expect(client.getConnectionState().status).toBe("disposed"); }); +test("ensureConnected reconnects immediately without leaving the scheduled retry armed", async () => { + useHeartbeatClock(); + try { + const first = createMockTransport(); + const second = createMockTransport(); + const third = createMockTransport(); + const transports = [first, second, third]; + let transportIndex = 0; + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_immediate_reconnect", + reconnect: { enabled: true, baseDelayMs: 1_500, maxDelayMs: 1_500 }, + transportFactory: () => { + const transport = transports[transportIndex]; + if (!transport) throw new Error("unexpected extra reconnect"); + transportIndex += 1; + return transport.transport; + }, + }); + clients.push(client); + + const initialConnect = client.connect(); + first.triggerOpen(); + await initialConnect; + first.triggerClose({ code: 1001, reason: "app resumed" }); + expect(client.getConnectionState().status).toBe("disconnected"); + + client.ensureConnected(); + expect(client.getConnectionState().status).toBe("connecting"); + expect(transportIndex).toBe(2); + + second.triggerOpen(); + expect(client.getConnectionState().status).toBe("connected"); + await vi.advanceTimersByTimeAsync(1_500); + + expect(client.getConnectionState().status).toBe("connected"); + expect(transportIndex).toBe(2); + } finally { + vi.useRealTimers(); + } +}); + +test("disabling reconnect cancels a pending retry until explicitly resumed", async () => { + useHeartbeatClock(); + try { + const first = createMockTransport(); + const second = createMockTransport(); + const transports = [first, second]; + let transportIndex = 0; + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_paused_reconnect", + reconnect: { enabled: true, baseDelayMs: 1_500, maxDelayMs: 1_500 }, + transportFactory: () => { + const transport = transports[transportIndex]; + if (!transport) throw new Error("unexpected extra reconnect"); + transportIndex += 1; + return transport.transport; + }, + }); + clients.push(client); + + const initialConnect = client.connect(); + first.triggerOpen(); + await initialConnect; + first.triggerClose({ code: 1001, reason: "app backgrounded" }); + + client.setReconnectEnabled(false); + await vi.advanceTimersByTimeAsync(1_500); + expect(client.getConnectionState().status).toBe("disconnected"); + expect(transportIndex).toBe(1); + + client.setReconnectEnabled(true); + client.ensureConnected(); + expect(client.getConnectionState().status).toBe("connecting"); + expect(transportIndex).toBe(2); + } finally { + vi.useRealTimers(); + } +}); + test("keeps the transport connected when a session RPC ping times out", async () => { const logger = createMockLogger(); const mock = createMockTransport(); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 4b3e4d7e3c..8121b6e91c 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -1402,6 +1402,14 @@ export class DaemonClient { ) { return; } + if (this.reconnectTimeout) { + clearTimeout(this.reconnectTimeout); + this.reconnectTimeout = null; + } + if (this.connectPromise) { + this.attemptConnect(); + return; + } void this.connect(); } @@ -5962,6 +5970,10 @@ export class DaemonClient { setReconnectEnabled(enabled: boolean): void { this.config = { ...this.config, reconnect: { ...this.config.reconnect, enabled } }; + if (!enabled && this.reconnectTimeout) { + clearTimeout(this.reconnectTimeout); + this.reconnectTimeout = null; + } } private scheduleReconnect(input?: {