mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-04 23:44:41 +00:00
Restore live sessions immediately after app resume (#4160)
* perf(app): restore live sessions immediately after resume Foregrounding now reconnects every host without waiting for scheduled backoff, while cached turns, timelines, agents, and workspaces remain internally consistent until authoritative catch-up arrives. Replica persistence is keyed and incremental so ordinary updates avoid whole-directory serialization. * fix(app): preserve turn ownership through session updates Stream turn closure now wakes queued-message delivery, while same-turn snapshots retain local cancellation state. Offline liveness remains visible until an authoritative close arrives. * fix(app): preserve pending chat cache writes and trim resume refactor Directory baseline replacement must preserve independently accepted timeline changes until the deferred flush. Keep the reconnect and persistence fixes while removing the session-data facade and broad read-hook migration. * fix(app): initialize reconnect policy from current app state Initialize visibility before host bootstrap so a background mount cannot leave reconnect enabled. Seed sidebar fixtures through the workspace owner so cache hydration preserves their rows. * test(app): verify submission settlement across reconnect Keep submission acknowledgement separate from turn liveness: disconnect preserves the running turn until authoritative catch-up. Wait for confirmed running chrome before exercising queued cancellation so the alternate-send shortcut cannot race turn startup. * docs: remove cache implementation recap
This commit is contained in:
+2
-2
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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<boolean> {
|
||||
}
|
||||
|
||||
function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
||||
useEffect(() => {
|
||||
const store = getHostRuntimeStore();
|
||||
return bindHostRuntimeAppState(store, AppState);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const store = getHostRuntimeStore();
|
||||
const daemonStartService = getDaemonStartService({ store });
|
||||
|
||||
@@ -53,7 +53,7 @@ function createAgent(overrides: Partial<Agent> = {}): Agent {
|
||||
parentAgentId: null,
|
||||
labels: {},
|
||||
...overrides,
|
||||
activeTurn: overrides.activeTurn ?? null,
|
||||
turn: overrides.turn ?? { phase: "idle", cancellationRequestId: null },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ReturnType<typeof getHostRuntimeStore>["acceptProjectSnapshot"]>[1],
|
||||
) => {
|
||||
getHostRuntimeStore().acceptProjectSnapshot(targetServerId, project);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const setHasHydratedWorkspaces = useSessionStore((store) => store.setHasHydratedWorkspaces);
|
||||
const inputRef = useRef<EditingTextInputHandle>(null);
|
||||
const submissionInFlightRef = useRef(false);
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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<WorkspaceDescriptor>) => {
|
||||
getHostRuntimeStore().acceptWorkspaceSnapshots(targetServerId, Array.from(workspaces));
|
||||
},
|
||||
[],
|
||||
);
|
||||
const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces);
|
||||
const setAgents = useSessionStore((state) => state.setAgents);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [createdWorkspace, setCreatedWorkspace] = useState<ReturnType<
|
||||
typeof normalizeWorkspaceDescriptor
|
||||
@@ -341,17 +350,13 @@ export function WorkspaceSetupDialog() {
|
||||
return;
|
||||
}
|
||||
|
||||
setAgents(serverId, (previous) => {
|
||||
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,
|
||||
|
||||
@@ -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" })]]),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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> = {}): 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", () => {
|
||||
|
||||
@@ -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<typeof getStoredAgentSnapshot> },
|
||||
): 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 {
|
||||
|
||||
@@ -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<ReturnType<typeof getHostRuntimeStore>["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<ReturnType<typeof getHostRuntimeStore>["acceptProjectSnapshot"]>[1],
|
||||
) => {
|
||||
getHostRuntimeStore().acceptProjectSnapshot(targetServerId, project);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces);
|
||||
|
||||
return useCallback(
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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<typeof AppState, "currentState" | "addEventListener">,
|
||||
): () => void {
|
||||
const subscription = appState.addEventListener("change", (state) => {
|
||||
store.setAppVisible(state === "active");
|
||||
});
|
||||
store.setAppVisible(appState.currentState === "active");
|
||||
return () => subscription.remove();
|
||||
}
|
||||
|
||||
@@ -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<typeof normalizeAgentSnapshot>[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],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<string, number>();
|
||||
private readonly members = new Set<string>();
|
||||
private readonly pendingCacheReads = new Set<string>();
|
||||
private readonly storeProjection: AgentStoreProjection;
|
||||
|
||||
constructor(
|
||||
private readonly serverId: string,
|
||||
private readonly onStoppedRunning: (agentId: string) => void,
|
||||
) {}
|
||||
private readonly persist: (mutations: readonly DirectoryReplicaMutation[]) => void,
|
||||
) {
|
||||
this.storeProjection = new AgentStoreProjection(serverId);
|
||||
}
|
||||
|
||||
captureTimeline(agentId: string): AgentLifecycleToken {
|
||||
return { agentId, version: this.lifecycleVersions.get(agentId) ?? 0 };
|
||||
}
|
||||
|
||||
snapshot(): Map<string, Agent> {
|
||||
return this.storeProjection.snapshot();
|
||||
}
|
||||
|
||||
captureCache(agentId: string): AgentLifecycleToken {
|
||||
this.pendingCacheReads.add(agentId);
|
||||
return this.captureTimeline(agentId);
|
||||
}
|
||||
|
||||
commitCached(agents: Map<string, Agent>): 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<string, Agent> {
|
||||
const previous = useSessionStore.getState().sessions[this.serverId]?.agents ?? new Map();
|
||||
const reconciled = reconcileAgentDirectory({ previous, snapshot: entries, deltas });
|
||||
const nextIds = new Set(reconciled.entries.map((entry) => entry.agent.id));
|
||||
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<string, Agent> {
|
||||
const previous = useSessionStore.getState().sessions[this.serverId]?.agents ?? new Map();
|
||||
const previous = this.storeProjection.snapshot();
|
||||
const merged = new Map<string, FetchAgentsEntry>();
|
||||
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 {
|
||||
|
||||
@@ -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<DirectoryReplicaMutation, { kind: "workspace"; type: "upsert" }> =>
|
||||
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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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<FetchAgentsOptions["sort"]> = [
|
||||
@@ -69,6 +71,36 @@ interface AgentPageInfo {
|
||||
afterCursor?: string | null;
|
||||
}
|
||||
|
||||
function applyWorkspaceSnapshotPage(
|
||||
snapshot: WorkspaceDirectorySnapshot,
|
||||
payload: Awaited<ReturnType<DaemonClient["fetchWorkspaces"]>>,
|
||||
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<Agent | undefined>;
|
||||
readWorkspace(serverId: string, workspaceId: string): Promise<CachedWorkspace | undefined>;
|
||||
readDirectory(serverId: string): Promise<CachedDirectory>;
|
||||
commitDirectory(
|
||||
commitDirectoryMutations(
|
||||
serverId: string,
|
||||
directory: {
|
||||
agents: Map<string, Agent>;
|
||||
workspaces: Map<string, WorkspaceDescriptor>;
|
||||
projects: Map<string, ProjectDescriptor>;
|
||||
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<void> {
|
||||
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<DaemonClient["fetchAgentTimeline"]>[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<string>,
|
||||
projectIds: Set<string>,
|
||||
): 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 {
|
||||
|
||||
@@ -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<string, Agent> {
|
||||
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<string, Agent>): Map<string, Agent> {
|
||||
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<string, Agent> {
|
||||
const { agents: fetched, pendingPermissions } = buildAgentDirectoryState({
|
||||
serverId: this.serverId,
|
||||
entries,
|
||||
});
|
||||
const store = useSessionStore.getState();
|
||||
const current = store.sessions[this.serverId]?.agents ?? new Map<string, Agent>();
|
||||
const agents = new Map<string, Agent>();
|
||||
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<string, Agent> | 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 = <T>(current: Map<string, T>): Map<string, T> => {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<SessionOutboundMessage, { type: "workspace_update" | "project.update" }>["payload"]
|
||||
| { kind: "script_status"; update: ScriptStatusUpdateMessage["payload"] };
|
||||
type ProjectDirectoryDelta = Extract<SessionOutboundMessage, { type: "project.update" }>["payload"];
|
||||
|
||||
export interface WorkspaceDirectorySnapshot {
|
||||
workspaces: Map<string, WorkspaceDescriptor>;
|
||||
@@ -28,67 +28,33 @@ export interface WorkspaceDirectorySnapshot {
|
||||
syncCursors?: Partial<
|
||||
Record<"projects" | "workspaces", { generation: string; afterSeq: number }>
|
||||
>;
|
||||
}
|
||||
|
||||
function patchWorkspaceScripts(
|
||||
workspaces: Map<string, WorkspaceDescriptor>,
|
||||
update: ScriptStatusUpdateMessage["payload"],
|
||||
): Map<string, WorkspaceDescriptor> {
|
||||
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<Record<"projects" | "workspaces", "snapshot" | "changes">>;
|
||||
touchedWorkspaceIds?: Set<string>;
|
||||
touchedProjectIds?: Set<string>;
|
||||
}
|
||||
|
||||
export class WorkspaceDirectoryReplica {
|
||||
private workspaces = new Map<string, WorkspaceDescriptor>();
|
||||
private projects = new Map<string, ProjectDescriptor>();
|
||||
private workspaceIdsByProject = new Map<string, Set<string>>();
|
||||
|
||||
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<string, WorkspaceDescriptor>;
|
||||
projects: Map<string, ProjectDescriptor>;
|
||||
}): 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<string, WorkspaceDescriptor>();
|
||||
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<SessionOutboundMessage, { type: "project.update" }>["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<SessionOutboundMessage, { type: "workspace_update" }>["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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<DaemonClient["fetchAgents"]>> | ReturnType<DaemonClient["fetchAgents"]>
|
||||
@@ -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<void>();
|
||||
@@ -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),
|
||||
|
||||
@@ -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<void> | 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<string, number>();
|
||||
private queuedAgentDrainInFlight = new Set<string>();
|
||||
private directorySyncByServer = new Map<string, DirectorySync>();
|
||||
private nextCancellationRequestId = 0;
|
||||
private timelineReplicaByServer = new Map<string, TimelineReplica>();
|
||||
private configuredOverrideBootstrapInFlight: Promise<void> | null = null;
|
||||
private bootPromise: Promise<void> | 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<void> {
|
||||
if (serverId) {
|
||||
return this.controllers.get(serverId)?.runProbeCycleNow() ?? Promise.resolve();
|
||||
|
||||
@@ -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<typeof directory>,
|
||||
): 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);
|
||||
|
||||
@@ -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<string, Agent>;
|
||||
workspaces: Map<string, WorkspaceDescriptor>;
|
||||
projects: Map<string, ProjectDescriptor>;
|
||||
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<void>;
|
||||
}
|
||||
|
||||
export interface CachedDirectory {
|
||||
agents: Map<string, Agent>;
|
||||
workspaces: Map<string, WorkspaceDescriptor>;
|
||||
projects: Map<string, ProjectDescriptor>;
|
||||
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<StoredAgent["turn"]> {
|
||||
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<string, Set<string>>;
|
||||
interface PendingReplicaChanges {
|
||||
upserts: StructuredReplicaUpsert[];
|
||||
deletes: ReplicaRowKey[];
|
||||
baselines: Set<string>;
|
||||
}
|
||||
|
||||
function applyDirectoryRow(
|
||||
@@ -825,9 +874,10 @@ export class ReplicaCache {
|
||||
private readonly storedRows = new Map<string, Map<string, ReplicaRow>>();
|
||||
private readonly hostBytes = new Map<string, number>();
|
||||
private readonly hostWriteOrder = new Map<string, true>();
|
||||
private pendingUpserts = new Map<string, ReplicaRow>();
|
||||
private pendingUpserts = new Map<string, StructuredReplicaUpsert>();
|
||||
private pendingDeletes = new Map<string, ReplicaRowKey>();
|
||||
private pendingDirectoryReplacements = new Map<string, Set<string>>();
|
||||
private pendingBaselines = new Set<string>();
|
||||
private readonly invalidatedHosts = new Set<string>();
|
||||
private readonly maxBytes: number;
|
||||
private totalBytes = 0;
|
||||
private persistTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -1019,39 +1069,65 @@ export class ReplicaCache {
|
||||
});
|
||||
}
|
||||
|
||||
commitDirectory(
|
||||
commitDirectoryMutations(
|
||||
serverId: string,
|
||||
directory: {
|
||||
agents: Map<string, Agent>;
|
||||
workspaces: Map<string, WorkspaceDescriptor>;
|
||||
projects: Map<string, ProjectDescriptor>;
|
||||
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<ReplicaRowChanges> {
|
||||
private async fitChangesToBudget(
|
||||
changes: ReplicaRowChanges,
|
||||
): Promise<{ changes: ReplicaRowChanges; evicted: Set<string> }> {
|
||||
const touchedServerIds = new Set<string>();
|
||||
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<void> {
|
||||
|
||||
@@ -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<WorkspaceDescriptor>) => {
|
||||
getHostRuntimeStore().acceptWorkspaceSnapshots(targetServerId, Array.from(workspaces));
|
||||
},
|
||||
[],
|
||||
);
|
||||
const {
|
||||
allHosts,
|
||||
selectedServerId,
|
||||
|
||||
@@ -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<WorkspaceDescriptor> & Pick<WorkspaceDescriptor, "id">,
|
||||
@@ -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 },
|
||||
]);
|
||||
|
||||
@@ -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<string, StreamItem[]>;
|
||||
agentStreamHead: Map<string, StreamItem[]>;
|
||||
agentTasks: Map<string, TodoEntry[]>;
|
||||
agentTurnLiveness: Map<string, TurnLiveness>;
|
||||
messageSubmissions: Map<string, MessageSubmissionRecord[]>;
|
||||
agentTimelineCursor: Map<string, AgentTimelineCursorState>;
|
||||
agentTimelineHasOlder: Map<string, boolean>;
|
||||
@@ -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<SessionStore>()(
|
||||
subscribeWithSelector((set, get) => {
|
||||
let nextCancellationRequestId = 0;
|
||||
const commitActivityUpdates: AgentLastActivityCommitter = (updates) => {
|
||||
set((prev) => {
|
||||
let nextActivity: Map<string, Date> | null = null;
|
||||
@@ -1058,57 +1047,6 @@ export const useSessionStore = create<SessionStore>()(
|
||||
});
|
||||
},
|
||||
|
||||
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<SessionStore>()(
|
||||
serverId,
|
||||
title: agent.title ?? null,
|
||||
status: agent.status,
|
||||
turn: agent.turn,
|
||||
lastActivityAt,
|
||||
cwd: agent.cwd,
|
||||
provider: agent.provider,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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"),
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, WorkspaceDescriptor>,
|
||||
): 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()]);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export type AgentDirectoryEntry = Pick<
|
||||
| "serverId"
|
||||
| "title"
|
||||
| "status"
|
||||
| "turn"
|
||||
| "lastActivityAt"
|
||||
| "cwd"
|
||||
| "workspaceId"
|
||||
|
||||
@@ -34,7 +34,7 @@ function createAgent(input: Partial<Agent> & Pick<Agent, "id">): Agent {
|
||||
attentionReason: null,
|
||||
attentionTimestamp: null,
|
||||
...rest,
|
||||
activeTurn: rest.activeTurn ?? null,
|
||||
turn: rest.turn ?? { phase: "idle", cancellationRequestId: null },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, Agent>;
|
||||
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<string>();
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -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<Omit<AgentSnapshotPayload, "labels">> & {
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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<AgentDirectoryDelta, { kind: "upsert" }>;
|
||||
|
||||
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 = <T>(current: Map<string, T>): Map<string, T> => {
|
||||
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<string, Agent> } {
|
||||
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<string, Agent>();
|
||||
const agents = new Map<string, Agent>();
|
||||
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<string, Agent> | 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<string, Date>();
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -10,6 +10,14 @@ function makeAgent(overrides: Partial<AggregatedAgent> = {}): 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" }),
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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<AgentSnapshotPayload, "activeTurn"> {
|
||||
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,
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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<string, Agent>,
|
||||
previous?: ReadonlyMap<string, WorkspaceAgentActivity>,
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<DaemonClient, "fetchAgents"> = {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, WorkspaceDescriptor>;
|
||||
}
|
||||
|
||||
export interface LegacyDaemonWorkspaceFetchResult extends LegacyDaemonWorkspaceSnapshot {
|
||||
subscriptionId: string | null;
|
||||
}
|
||||
|
||||
interface LegacyDaemonWorkspaceDirectoryReadResult {
|
||||
entries: FetchAgentsEntry[];
|
||||
subscriptionId: string | null;
|
||||
}
|
||||
|
||||
interface LegacyDaemonWorkspaceBackfillInput {
|
||||
client: Pick<DaemonClient, "fetchAgents">;
|
||||
serverId: string;
|
||||
workspaces: ReadonlyMap<unknown, unknown>;
|
||||
projects: ReadonlyMap<unknown, unknown>;
|
||||
isCancelled?: () => boolean;
|
||||
}
|
||||
|
||||
const LEGACY_AGENT_DIRECTORY_SORT: NonNullable<FetchAgentsOptions["sort"]> = [
|
||||
{ key: "updated_at", direction: "desc" },
|
||||
];
|
||||
@@ -82,50 +66,6 @@ function shouldBackfillLegacyDaemonWorkspaceDirectory(
|
||||
return serverInfo?.features?.workspaceMultiplicity !== true;
|
||||
}
|
||||
|
||||
export async function fetchLegacyDaemonWorkspaceDirectory(input: {
|
||||
client: Pick<DaemonClient, "fetchAgents">;
|
||||
serverId: string;
|
||||
subscribe?: FetchAgentsOptions["subscribe"];
|
||||
page?: FetchAgentsOptions["page"];
|
||||
}): Promise<LegacyDaemonWorkspaceFetchResult> {
|
||||
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<boolean> {
|
||||
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<DaemonClient, "fetchAgents">;
|
||||
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<ReturnType<DaemonClient["fetchAgents"]>>["pageInfo"],
|
||||
): boolean {
|
||||
@@ -369,7 +279,7 @@ function resolveLegacyWorkspaceId(entry: FetchAgentsEntry): string {
|
||||
|
||||
function resolveLegacyWorkspaceIdFromAgent(
|
||||
agent: Agent,
|
||||
workspaces: Map<string, WorkspaceDescriptor> | undefined,
|
||||
workspaces: ReadonlyMap<string, WorkspaceDescriptor> | undefined,
|
||||
): string | null {
|
||||
const cwd = normalizeWorkspacePath(agent.cwd);
|
||||
if (!cwd) {
|
||||
|
||||
@@ -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<ArchiveWorkspacePayload>();
|
||||
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<string, string[]>();
|
||||
const clientFor = (serverId: string) =>
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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?: {
|
||||
|
||||
Reference in New Issue
Block a user