fix(app): paint conversations immediately after restart (#3907)

* fix(app): paint cached conversations before reconnect

Cold startup restored every saved host before publishing any session, so a valid cached chat was blocked behind unrelated directory validation and network-only readiness. Directory and timeline owners now resolve demand through typed storage and authoritative reconciliation while consumers remain source-agnostic.

* fix(app): stabilize demand-driven cache restore

Create host projections before network availability, route cached workspace and timeline preparation through their owners, and coalesce directory reconciliation per online connection. Update browser cache fixtures for per-agent timeline rows.

* fix(app): resume cached timeline through owner

* fix(app): surface timeline owner load errors
This commit is contained in:
Mohamed Boudra
2026-08-26 22:55:48 +02:00
committed by GitHub
parent 46b401eac6
commit 0ad69d3eb0
51 changed files with 3056 additions and 2413 deletions
+17 -13
View File
@@ -104,8 +104,8 @@ Cross-platform React Native app that connects to one or more daemons.
- Expo Router navigation (`/h/[serverId]/workspace/[workspaceId]`, `/h/[serverId]/agent/[agentId]`, etc.). The `workspaceId` URL segment is an opaque workspace id, not a directly meaningful filesystem path.
- `HostRuntimeController` manages saved host connections, reconnection, and per-host runtime state
- `runtime/replica-cache` keeps the complete project, workspace, and active-agent directory plus one short focused timeline tail in a row store. It restores before navigation becomes ready and leaves remote hydration flags false.
- `runtime/directory-sync` owns directory reconciliation. On reconnect it passes the persisted per-entity cursor through `project.list`, `fetch_workspaces`, and `fetch_agents`; the daemon returns each entity's latest projection when its sequence is newer, plus tombstones.
- `runtime/replica-cache` is typed storage behind the directory and timeline owners. It never observes or mutates `SessionStore`.
- `runtime/directory-sync` owns directory cache selection and network reconciliation. On demand it paints accepted rows for one host, then passes the persisted per-entity cursor through `project.list`, `fetch_workspaces`, and `fetch_agents`; the daemon returns each entity's latest projection when its sequence is newer, plus tombstones.
- `workspace-labels` owns one sequenced catalog replica per connected host, the deterministic cross-host projection that surfaces spanning hosts use (the filter page, the manager), and the per-host resolution a workspace row's chips use. Two hosts may give one name different colors, so a row resolves against its own host's catalog and a merged answer would be wrong there. Catalogs never synchronize between hosts; assignment creates a missing definition only on the target host. On the daemon, catalog and assignment rewrites share a journaled commit boundary. Startup recovery completes that commit before workspace or catalog publication.
- `SessionContext` wraps the daemon client for the active session
- Composer UI and submit/draft behavior live in `packages/app/src/composer/`; screens and panels should integrate it from there instead of dropping composer internals into `components/`, `hooks/`, or `screens/workspace/`
@@ -113,17 +113,21 @@ Cross-platform React Native app that connects to one or more daemons.
- Timeline sync correctness is documented in [docs/timeline-sync.md](timeline-sync.md): live streams are for immediacy, `fetch_agent_timeline_request` is authoritative, and catch-up is paged but complete.
- Voice features: dictation (STT) and voice agent (realtime)
The replica cache paints stale data immediately while the host connects. Directory cursors are
reconciliation checkpoints; cached entities remain non-authoritative until the daemon answers.
Pending permission requests are not restored from it. The row store is not encrypted, so the cached
timeline tail may contain source code, prompts, and tool output; encrypted-at-rest storage is a
separate product/security decision. Agents, workspaces, and projects have individual rows; the
focused timeline tail and directory checkpoint each have one row per host. Writes serialize only
rows whose in-memory identities changed. Restore validates every row before hydration; one invalid
row clears the cache and starts cold. The 32 MiB byte budget is tracked incrementally from row
payload lengths and evicts whole hosts in least-recently-written order. After restart, that order is
an approximation rebuilt from the row store's `readAll()` order. Browser and Electron builds use
IndexedDB. Native builds use expo-sqlite.
Consumers request directory or timeline data without choosing memory, cache, or network. The owner
publishes an accepted cache hit and then reconciles it over the existing network path. A miss or an
invalid row uses that same path. Offline demand still publishes an accepted cache hit and defers
network reconciliation. Cache loading is demand-driven: opening a chat reads its agent row and
focused timeline row, plus the workspace and project rows needed by the route; opening a directory
reads directory rows for that host and establishes its live subscription when connected. Accepted
cached rows satisfy the same consumer-readiness projection as network rows. Host registry startup and
host connection do not create directory demand or install replicas. The directory owner retains
declared surface demand and re-establishes network reconciliation after reconnect; React does not
track connection generations. Late cache reads cannot replace state already advanced by live or
authoritative network data. Owners explicitly persist accepted commits; directory rows and their
checkpoint share one storage transaction. See
[data-model.md](data-model.md#replica-row-store)
for the storage shape and [timeline-sync.md](timeline-sync.md#client-replica-lifetime) for timeline
resume behavior.
The three directory entity types have independent monotonic sequences and share one daemon
generation. The daemon retains only the latest projection per entity and bounded tombstones, not an
+20
View File
@@ -521,6 +521,26 @@ Right-sidebar client state splits on whether it is determined by the directory o
- **Directory-backed** (shared by same-`cwd` workspaces): keyed by `(serverId, cwd)`. Git status/diff, GitHub PR status, PR timeline, file preview content. These are TanStack Query caches, not persisted stores.
- **Workspace-owned** (independent per workspace): keyed by `workspaceId`, with `cwd` used only as a fallback when no `workspaceId` is present. Review draft comments (`@paseo:review-draft-store`), diff-mode overrides (in-memory), workspace composer attachments, and file-explorer nav/expand state. The `workspaceId` part of these keys is **opaque** — never parse it back into a path.
### Replica row store
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.
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
host, kinds, and ids. Opening a cached workspace uses exact workspace and project keys rather than a
directory scan. One invalid row is deleted and returned as a miss without affecting other rows.
An invalid directory row and its affected checkpoint cursor are repaired in one transaction, so a
later launch cannot accept a checkpoint for a partial baseline. Directory changes and their
checkpoint are also applied in one transaction.
The cache is capped at 32 MiB and evicts whole hosts in least-recently-written order. Budget
bookkeeping may scan opaque row sizes during a deferred write, never during host registry startup or
before a requested cache row can paint. The row store is not encrypted. A cached timeline can contain
source code, prompts, and tool output; encrypted-at-rest storage is a separate security decision.
### Draft Store
**AsyncStorage key:** `paseo-drafts` (version 2)
+12 -10
View File
@@ -90,26 +90,28 @@ history start; a response requested from a pre-replacement range is stale and is
## Client replica lifetime
The host runtime owns each session replica for as long as the host remains registered. React
providers attach message handlers and UI integrations to that replica, but mounting or unmounting a
provider must not create or clear it. A provider can remount during Fast Refresh or ordinary UI
recomposition while the runtime still owns the same directory snapshot and timeline cursors.
The session projection remains host-scoped for as long as the host is registered. The viewed-timeline
owner wraps cached preparation, network catch-up, accepted timeline application, and persistence
behind one interface. React supplies transport and projection operations without selecting a cache
path or issuing a separate persistence notification.
Removing the host from the registry is the destructive boundary: it stops the runtime and clears the
session and host-scoped setup state together.
The durable replica cache persists synchronization authority only when it can store the complete
current canonical window losslessly. The stored range describes those exact items: `startSeq` drives
older pagination and `endSeq` drives forward catch-up. Restore paints the items immediately, requests
`after endSeq`, and requests `before startSeq` when the user loads older history.
The timeline owner asks durable replica storage for an agent when that agent becomes visible. An
accepted row paints immediately before subscription acknowledgement or timeline fetch. The stored
range describes those exact items: `startSeq` drives older pagination and `endSeq` drives forward
catch-up. The owner requests `after endSeq`, and requests `before startSeq` when the user loads older
history. Code outside the owner does not distinguish cached and network timelines.
The first resume request is bounded. If it reports more newer history, fetch one latest bounded tail
instead of replaying every missed page. Live gap recovery still pages forward until current.
If the canonical window exceeds the cache item limit, contains a discontiguous retained range, has a
live head, or includes presentation data the cache cannot encode losslessly, persistence drops the
range and keeps a display-only tail. Restore then uses the ordinary bounded `tail` bootstrap. Never
slice items while retaining the pre-slice range; that falsely certifies discarded source rows.
range and keeps a display-only tail. Never slice items while retaining the pre-slice range; that
falsely certifies discarded source rows. A display-only row paints without granting synchronization
authority, so the owner uses the ordinary bounded `tail` bootstrap.
Live rows received between cache paint and catch-up stay in the separate live head and reconcile with
the authoritative range through the existing forward-page path. The cache does not persist sync
@@ -26,15 +26,35 @@ async function expectUserMessageVisible(page: Page, text: string): Promise<void>
await expect(userMessage(page, text)).toBeVisible();
}
async function rewriteCachedMessageAsLegacyRow(page: Page, prompt: string): Promise<void> {
async function rewriteCachedMessageAsLegacyRow(
page: Page,
input: { prompt: string; agentId: string; workspaceId: string },
): Promise<void> {
await expect
.poll(async () => {
const cache = await readReplicaCache(page);
if (!cache) return false;
for (const host of cache.hosts ?? []) {
for (const item of host.timeline?.items ?? []) {
if (item.kind === "user_message" && item.text === prompt && item.messageId) {
return true;
const hasAgent = host.agents.some(
(agent) =>
agent.snapshot &&
typeof agent.snapshot === "object" &&
Reflect.get(agent.snapshot, "id") === input.agentId,
);
const hasWorkspace = host.workspaces.some(
(workspace) => workspace.id === input.workspaceId,
);
if (!hasAgent || !hasWorkspace) continue;
for (const timeline of host.timelines) {
for (const item of timeline.items ?? []) {
if (
timeline.agentId === input.agentId &&
item.kind === "user_message" &&
item.text === input.prompt &&
item.messageId
) {
return true;
}
}
}
}
@@ -45,8 +65,8 @@ async function rewriteCachedMessageAsLegacyRow(page: Page, prompt: string): Prom
const cache = await readReplicaCache(page);
if (!cache) throw new Error("Replica cache was not persisted");
const cachedMessage = cache.hosts
?.flatMap((host) => host.timeline?.items ?? [])
.find((item) => item.kind === "user_message" && item.text === prompt);
?.flatMap((host) => host.timelines.flatMap((timeline) => timeline.items ?? []))
.find((item) => item.kind === "user_message" && item.text === input.prompt);
if (!cachedMessage) throw new Error("Cached user message was not found");
delete cachedMessage.messageId;
await writeReplicaCache(page, cache);
@@ -61,7 +81,7 @@ async function waitForCurrentSubmissionExcludedFromCache(
const cache = await readReplicaCache(page);
if (!cache) return false;
return !cache.hosts
?.flatMap((host) => host.timeline?.items ?? [])
?.flatMap((host) => host.timelines.flatMap((timeline) => timeline.items ?? []))
.some(
(item) =>
item.kind === "user_message" &&
@@ -79,7 +99,7 @@ async function waitForCachedMessageWithoutProviderId(page: Page, prompt: string)
const cache = await readReplicaCache(page);
if (!cache) return false;
return cache.hosts
?.flatMap((host) => host.timeline?.items ?? [])
?.flatMap((host) => host.timelines.flatMap((timeline) => timeline.items ?? []))
.some(
(item) =>
item.kind === "user_message" && item.text === prompt && item.messageId === undefined,
@@ -131,14 +151,18 @@ test.describe("Rewind sheet", () => {
await openAgentRoute(page, session);
await expectUserMessageVisible(page, prompt);
await gate.drop();
await rewriteCachedMessageAsLegacyRow(page, prompt);
await rewriteCachedMessageAsLegacyRow(page, {
prompt,
agentId: session.agentId,
workspaceId: session.workspaceId,
});
await page.reload();
await waitForCachedMessageWithoutProviderId(page, prompt);
const restoredMessage = userMessage(page, prompt);
await expect(restoredMessage).toBeVisible();
await restoredMessage.hover();
await expect(restoredMessage.getByTestId("rewind-menu-trigger")).toHaveCount(0);
await waitForCachedMessageWithoutProviderId(page, prompt);
} finally {
gate.restore();
await session.cleanup();
@@ -18,7 +18,7 @@ interface ReplicaCacheHostRecord {
agents: Array<Record<string, unknown>>;
workspaces: Array<Record<string, unknown>>;
projects: Array<Record<string, unknown>>;
timeline?: {
timelines: Array<{
agentId?: string;
items?: Array<Record<string, unknown>>;
range?: {
@@ -26,7 +26,7 @@ interface ReplicaCacheHostRecord {
epoch?: string;
startSeq?: number;
} | null;
} | null;
}>;
directorySync?: unknown;
}
@@ -83,7 +83,7 @@ function assembleCache(rows: ReplicaRowRecord[]): ReplicaCacheRecord {
agents: [],
workspaces: [],
projects: [],
timeline: null,
timelines: [],
};
const payload = JSON.parse(row.payload) as Record<string, unknown>;
switch (row.kind) {
@@ -97,7 +97,7 @@ function assembleCache(rows: ReplicaRowRecord[]): ReplicaCacheRecord {
host.projects.push(payload);
break;
case "timeline":
host.timeline = payload;
host.timelines.push(payload);
break;
case "checkpoint":
host.directorySync = payload;
@@ -147,12 +147,13 @@ export async function writeReplicaCache(page: Page, value: ReplicaCacheRecord):
});
}
}
if (host.timeline) {
for (const timeline of host.timelines) {
if (typeof timeline.agentId !== "string") continue;
rows.push({
serverId: host.serverId,
kind: "timeline",
id: SINGLETON_ID,
payload: JSON.stringify(host.timeline),
id: timeline.agentId,
payload: JSON.stringify(timeline),
});
}
if (host.directorySync !== undefined) {
@@ -293,9 +293,9 @@ export async function reloadAgentTimelineFromPersistedReplica(
await expect
.poll(async () => {
const cache = await readReplicaCache(page);
const timeline = cache?.hosts?.find(
(host) => host.timeline?.agentId === agent.agentId,
)?.timeline;
const timeline = cache?.hosts
?.flatMap((host) => host.timelines)
.find((candidate) => candidate.agentId === agent.agentId);
return timeline?.items?.length === 50;
})
.toBe(true);
@@ -311,7 +311,9 @@ export async function waitForPersistedCanonicalTimelineRange(
const readRange = async () => {
const cache = await readReplicaCache(page);
if (cache?.version !== 6) return null;
const range = cache.hosts?.find((host) => host.timeline?.agentId === agentId)?.timeline?.range;
const range = cache.hosts
?.flatMap((host) => host.timelines)
.find((timeline) => timeline.agentId === agentId)?.range;
if (
typeof range?.epoch !== "string" ||
typeof range.startSeq !== "number" ||
+1 -10
View File
@@ -961,18 +961,9 @@ function RootProviders({ children }: { children: ReactNode }) {
);
}
function recordUserActivity(): void {
getHostRuntimeStore().recordUserActivity();
}
function RootAppTree() {
return (
<GestureHandlerRootView
style={flexStyle}
onTouchStart={recordUserActivity}
onTouchEnd={recordUserActivity}
onTouchCancel={recordUserActivity}
>
<GestureHandlerRootView style={flexStyle}>
<View style={layoutStyles.surfaceFill}>
<RootProviders>
<RuntimeProviders>
@@ -38,6 +38,12 @@ function HostAgentReadyRouteContent() {
}
return state.sessions[serverId]?.agents?.get(agentId)?.workspaceId ?? null;
});
useEffect(() => {
if (!serverId || !agentId || agentWorkspaceId) return;
void getHostRuntimeStore()
.prepareAgentRoute(serverId, agentId)
.catch(() => undefined);
}, [agentId, agentWorkspaceId, serverId]);
const shouldLookupAgent = Boolean(
serverId && agentId && client && connectionStatus === "online" && !agentWorkspaceId,
);
@@ -138,7 +138,7 @@ function useBuiltInRows(open: boolean): {
agents: CommandCenterAgentResult[];
} {
const { t } = useTranslation();
const { agents } = useAggregatedAgents();
const { agents } = useAggregatedAgents({ demand: open });
const { projects } = useProjects({ enabled: open });
const showHost = useHosts().length > 1;
@@ -31,7 +31,6 @@ import {
type SidebarProjectEntry,
} from "@/hooks/use-sidebar-workspaces-list";
import { useSidebarWorkspacesList } from "@/hooks/use-sidebar-workspaces-list";
import { patchWorkspaceScripts } from "@/contexts/session-workspace-scripts";
import {
getHostRuntimeStore,
type HostRuntimeController,
@@ -404,35 +403,6 @@ describe("sidebar workspace render isolation", () => {
});
});
it("does not re-render for a deep-equal scripts patch", async () => {
const counts: RenderCounts = {
frame: 0,
headers: {},
rows: {},
projectSelection: {},
rowSelection: {},
};
({ root, container } = await renderProbe(counts));
const applyRunningScript = (current: Parameters<typeof patchWorkspaceScripts>[0]) =>
patchWorkspaceScripts(current, {
workspaceId: "a-main",
scripts: [{ ...runningScript }],
});
act(() => {
useSessionStore.getState().setWorkspaces(SERVER_ID, applyRunningScript);
});
expect(counts).toEqual({
frame: 0,
headers: {},
rows: {},
projectSelection: {},
rowSelection: {},
});
});
it("updates active selection probes from the active workspace route", async () => {
const counts: RenderCounts = {
frame: 0,
@@ -54,7 +54,7 @@ export function SidebarModelProvider({
active?: boolean;
children: ReactNode;
}) {
const list = useSidebarWorkspacesList();
const list = useSidebarWorkspacesList({ enabled: active });
const groupMode = useSidebarViewStore((state) => state.groupMode);
const labelFilter = useSidebarViewStore((state) => state.labelFilter);
const projectFilters = useSidebarViewStore((state) => state.projectFilters);
@@ -1,92 +0,0 @@
import { describe, expect, it } from "vitest";
import type { WorkspaceScriptPayload } from "@getpaseo/protocol/messages";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { patchWorkspaceScripts } from "./session-workspace-scripts";
function workspace(input: {
id: string;
workspaceDirectory?: string;
scripts?: WorkspaceDescriptor["scripts"];
}): WorkspaceDescriptor {
return {
id: input.id,
projectId: "project-1",
projectDisplayName: "Project 1",
projectRootPath: "/repo",
workspaceDirectory: input.workspaceDirectory ?? "/repo/main",
projectKind: "git",
workspaceKind: "checkout",
name: "main",
status: "running",
archivingAt: null,
statusEnteredAt: null,
diffStat: null,
scripts: input.scripts ?? [],
};
}
const runningScript: WorkspaceScriptPayload = {
scriptName: "web",
type: "service",
hostname: "web.paseo.localhost",
port: 3000,
proxyUrl: "http://web.paseo.localhost:6767",
lifecycle: "running",
health: "healthy",
exitCode: null,
terminalId: null,
};
describe("patchWorkspaceScripts", () => {
it("patches only the matching workspace scripts", () => {
const other = workspace({ id: "ws-other", workspaceDirectory: "/repo/other", scripts: [] });
const current = new Map<string, WorkspaceDescriptor>([
["ws-main", workspace({ id: "ws-main", workspaceDirectory: "/repo/main", scripts: [] })],
[other.id, other],
]);
const next = patchWorkspaceScripts(current, {
workspaceId: "ws-main",
scripts: [runningScript],
});
expect(next).not.toBe(current);
expect(next.get("ws-main")?.scripts).toEqual([runningScript]);
expect(next.get("ws-other")).toBe(other);
});
it("patches the matching workspace when the map key differs from the workspace id", () => {
const current = new Map<string, WorkspaceDescriptor>([
[
"workspace-record-42",
workspace({
id: "ws-main",
workspaceDirectory: "C:\\repo\\main\\",
scripts: [],
}),
],
]);
const next = patchWorkspaceScripts(current, {
workspaceId: "ws-main",
scripts: [runningScript],
});
expect(next).not.toBe(current);
expect(next.get("workspace-record-42")?.scripts).toEqual([runningScript]);
});
it("ignores updates for unknown workspaces", () => {
const current = new Map<string, WorkspaceDescriptor>([
["ws-main", workspace({ id: "ws-main", workspaceDirectory: "/repo/main", scripts: [] })],
]);
const next = patchWorkspaceScripts(current, {
workspaceId: "ws-missing",
scripts: [runningScript],
});
expect(next).toBe(current);
expect(next.get("ws-main")?.scripts).toEqual([]);
});
});
+19 -247
View File
@@ -11,23 +11,13 @@ import {
refreshAgentInitializationTimeout,
} from "@/hooks/use-agent-initialization";
import type { StreamItem } from "@/types/stream";
import {
createSessionAgentStreamReducerQueue,
deriveAgentStreamTurnLiveness,
processTimelineResponse,
type ProcessTimelineResponseOutput,
type TimelineReducerSideEffect,
} from "@/timeline/session-stream-reducers";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import {
isTimelineResumeSnapshotAuthoritative,
planTimelineTailFetch,
} from "@/timeline/timeline-sync-plan";
import { deriveAgentStreamTurnLiveness } from "@/timeline/session-stream-reducers";
import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan";
import { requestTimelineReplacement } from "@/timeline/timeline-replacement";
import {
createViewedTimelineSync,
type TimelineDeliveryMode,
type ViewedTimelineSync,
type TimelineResponsePayload,
type ViewedTimelineOwner,
} from "@/timeline/viewed-timeline-sync";
import type { AgentAttachment, SessionOutboundMessage } from "@getpaseo/protocol/messages";
import { parseServerInfoStatusPayload } from "@getpaseo/protocol/messages";
@@ -57,14 +47,11 @@ import {
getInitKey,
getInitDeferred,
createInitDeferred,
resolveInitDeferred,
rejectInitDeferred,
} from "@/utils/agent-initialization";
import { encodeImages } from "@/utils/encode-images";
import { derivePendingPermissionKey } from "@/utils/agent-snapshots";
import { getSendingClientMessageIds } from "@/composer/submission/model";
import type { AttachmentMetadata } from "@/attachments/types";
import { patchWorkspaceScripts } from "@/contexts/session-workspace-scripts";
import { useToast } from "@/contexts/toast-context";
import { toErrorMessage } from "@/utils/error-messages";
import { showProviderNoticeToast } from "@/utils/provider-notice-toast";
@@ -72,11 +59,6 @@ import { applyCheckoutStatusUpdateFromEvent } from "@/git/checkout-status-cache"
import { useProviderSubagentStore } from "@/subagents/provider-store";
import { revalidateSessionAfterResume } from "@/contexts/session-resume-revalidation";
type TimelineResponsePayload = Extract<
SessionOutboundMessage,
{ type: "fetch_agent_timeline_response" }
>["payload"];
function consumeForcedTimelineTailReplacement(
payload: TimelineResponsePayload,
replacements: Set<string>,
@@ -212,86 +194,6 @@ type WorkspaceSetupProgressPayload = Extract<
{ type: "workspace_setup_progress" }
>["payload"];
type SessionStoreActions = ReturnType<typeof useSessionStore.getState>;
type SetInitializingAgents = SessionStoreActions["setInitializingAgents"];
function clearAgentInitializingFlag(
setInitializingAgents: SetInitializingAgents,
serverId: string,
agentId: string,
): void {
setInitializingAgents(serverId, (prev) => {
if (prev.get(agentId) !== true) {
return prev;
}
const next = new Map(prev);
next.set(agentId, false);
return next;
});
}
function handleTimelineError(input: {
result: ProcessTimelineResponseOutput;
agentId: string;
initKey: string;
serverId: string;
setInitializingAgents: SetInitializingAgents;
}): void {
const { result, agentId, initKey, serverId, setInitializingAgents } = input;
if (result.clearInitializing) {
clearAgentInitializingFlag(setInitializingAgents, serverId, agentId);
}
if (result.initResolution === "reject" && result.error) {
rejectInitDeferred(initKey, new Error(result.error));
}
}
function executeTimelineSideEffects(input: {
sideEffects: TimelineReducerSideEffect[];
agentId: string;
recoverTimelineGap: (agentId: string, cursor: { epoch: string; endSeq: number }) => void;
}): void {
const { sideEffects, agentId, recoverTimelineGap } = input;
for (const effect of sideEffects) {
if (effect.type === "catch_up") {
recoverTimelineGap(agentId, effect.cursor);
}
}
}
function finalizeTimelineApplication(input: {
result: ProcessTimelineResponseOutput;
agentId: string;
initKey: string;
serverId: string;
shouldMarkAuthoritativeHistoryApplied: boolean;
setInitializingAgents: SetInitializingAgents;
}): void {
const {
result,
agentId,
initKey,
serverId,
shouldMarkAuthoritativeHistoryApplied,
setInitializingAgents,
} = input;
if (result.clearInitializing) {
clearAgentInitializingFlag(setInitializingAgents, serverId, agentId);
}
if (shouldMarkAuthoritativeHistoryApplied) {
useCreateFlowStore.getState().clearByAgent({ serverId, agentId });
const session = useSessionStore.getState().sessions[serverId];
const agent = session?.agents.get(agentId) ?? session?.agentDetails.get(agentId);
if (agent && agent.status !== "running") {
getHostRuntimeStore().drainQueuedAgentMessage(serverId, agentId);
}
}
if (result.initResolution === "resolve") {
resolveInitDeferred(initKey);
}
}
function notifyVoiceAbortFailure(
data: Extract<SessionOutboundMessage, { type: "activity_log" }>["payload"],
notifyError: (message: string) => void,
@@ -337,22 +239,12 @@ 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 setAgentStreamState = useSessionStore((state) => state.setAgentStreamState);
const applyAgentTurnLiveness = useSessionStore((state) => state.applyAgentTurnLiveness);
const clearAgentTurnLiveness = useSessionStore((state) => state.clearAgentTurnLiveness);
const clearAgentStreamHead = useSessionStore((state) => state.clearAgentStreamHead);
const setAgentTimelineCursor = useSessionStore((state) => state.setAgentTimelineCursor);
const setAgentTimelineHasNewer = useSessionStore((state) => state.setAgentTimelineHasNewer);
const setInitializingAgents = useSessionStore((state) => state.setInitializingAgents);
const bumpHistorySyncGeneration = useSessionStore((state) => state.bumpHistorySyncGeneration);
const markAgentHistorySynchronized = useSessionStore(
(state) => state.markAgentHistorySynchronized,
);
const applyAgentTimelineResponseState = useSessionStore(
(state) => state.applyAgentTimelineResponseState,
);
const setAgents = useSessionStore((state) => state.setAgents);
const setWorkspaces = useSessionStore((state) => state.setWorkspaces);
const flushAgentLastActivity = useSessionStore((state) => state.flushAgentLastActivity);
const setPendingPermissions = useSessionStore((state) => state.setPendingPermissions);
const updateSessionServerInfo = useSessionStore((state) => state.updateSessionServerInfo);
@@ -370,7 +262,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
const attentionNotifiedRef = useRef<Map<string, number>>(new Map());
const appStateRef = useRef(AppState.currentState);
const forcedTimelineTailReplacements = useRef(new Set<string>());
const viewedTimelineSyncRef = useRef<ViewedTimelineSync | null>(null);
const viewedTimelineSyncRef = useRef<ViewedTimelineOwner | null>(null);
const audioOutputBuffersRef = useRef<Map<string, BufferedAudioChunk[]>>(new Map());
const activeAudioGroupsRef = useRef<Set<string>>(new Set());
const isAppVisible = useAppVisible();
@@ -389,20 +281,12 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
viewedTimelineSyncRef.current?.setActive(isAppVisible);
}, [isAppVisible]);
const recoverTimelineGap = useCallback(
(agentId: string, cursor: { epoch: string; endSeq: number }) => {
viewedTimelineSyncRef.current?.recoverGap(agentId, cursor);
},
[],
);
const handleAppResumed = useCallback(
(awayMs: number) => {
void revalidateSessionAfterResume({
awayMs,
serverId,
bumpHistorySyncGeneration,
refreshDirectories: () => getHostRuntimeStore().refreshDirectories(serverId),
});
},
[bumpHistorySyncGeneration, serverId],
@@ -413,7 +297,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
client,
focusedAgentId,
focusedTerminalId,
onUserActivity: () => getHostRuntimeStore().recordUserActivity(),
onAppResumed: handleAppResumed,
});
useEffect(() => startPushNotifications({ client, serverId }), [client, serverId]);
@@ -553,118 +436,22 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
[serverId, upsertWorkspaceSetupProgress],
);
const applyTimelineResponse = useCallback(
(receivedPayload: TimelineResponsePayload) => {
const payload = consumeForcedTimelineTailReplacement(
receivedPayload,
forcedTimelineTailReplacements.current,
);
const agentId = payload.agentId;
const initKey = getInitKey(serverId, agentId);
const shouldMarkAuthoritativeHistoryApplied = isTimelineResumeSnapshotAuthoritative({
direction: payload.direction,
hasNewer: payload.hasNewer,
error: payload.error,
});
// Read current store state
const session = useSessionStore.getState().sessions[serverId];
const isInitializing = session?.initializingAgents.get(agentId) === true;
const activeInitDeferred = getInitDeferred(initKey);
const hasActiveInitDeferred = Boolean(activeInitDeferred);
const timeline = selectAgentTimelineState(session, agentId);
const currentCursor =
timeline.status === "synced" ? (timeline.range ?? undefined) : undefined;
const currentTail = timeline.status === "cold" ? [] : timeline.items;
const currentHead = session?.agentStreamHead.get(agentId) ?? [];
const sendingClientMessageIds = getSendingClientMessageIds(
session?.messageSubmissions.get(agentId),
);
// Call pure reducer
const result = processTimelineResponse({
payload,
currentTail,
currentHead,
currentCursor,
isInitializing,
hasActiveInitDeferred,
initRequestDirection: activeInitDeferred?.requestDirection ?? "tail",
sendingClientMessageIds,
});
if (result.error) {
handleTimelineError({
result,
agentId,
initKey,
serverId,
setInitializingAgents,
});
return;
}
if (result.commit === "discard") {
if (result.acknowledgedClientMessageIds.length > 0) {
setAgentStreamState(serverId, agentId, {
acknowledgedClientMessageIds: result.acknowledgedClientMessageIds,
});
}
if (payload.direction !== "before") {
setAgentTimelineHasNewer(serverId, (current) => {
const next = new Map(current);
next.set(agentId, payload.hasNewer);
return next;
});
}
markAgentHistorySynchronized(serverId, agentId);
} else {
applyAgentTimelineResponseState(serverId, agentId, {
items: result.tail,
head: result.head,
range: result.cursorChanged ? (result.cursor ?? null) : (currentCursor ?? null),
older: result.older,
newer:
payload.direction === "before"
? timeline.status === "synced" && timeline.newer === "available"
: payload.hasNewer,
synchronized: shouldMarkAuthoritativeHistoryApplied,
acknowledgedClientMessageIds: result.acknowledgedClientMessageIds,
});
}
executeTimelineSideEffects({
sideEffects: result.sideEffects,
agentId,
recoverTimelineGap,
});
finalizeTimelineApplication({
result,
agentId,
initKey,
serverId,
shouldMarkAuthoritativeHistoryApplied,
setInitializingAgents,
});
},
[
applyAgentTimelineResponseState,
markAgentHistorySynchronized,
recoverTimelineGap,
serverId,
setAgentStreamState,
setAgentTimelineHasNewer,
setInitializingAgents,
],
);
const applyTimelineResponse = useCallback((receivedPayload: TimelineResponsePayload) => {
const payload = consumeForcedTimelineTailReplacement(
receivedPayload,
forcedTimelineTailReplacements.current,
);
const owner = viewedTimelineSyncRef.current;
if (!owner) throw new Error("Viewed timeline owner is unavailable");
owner.applyTimelineResponse(payload);
}, []);
useEffect(() => {
const setAgentInitializing = createSetAgentInitializing(serverId, setInitializingAgents);
const initialDeliveryMode = getTimelineDeliveryMode(
client.getLastServerInfoMessage()?.features?.selectiveAgentTimeline,
);
const sync = createViewedTimelineSync({
const sync = getHostRuntimeStore().createViewedTimelineOwner(serverId, {
initialDeliveryMode,
setSubscription: (agentIds) => client.setAgentTimelineSubscription(agentIds),
readCursor: (agentId) => {
@@ -745,12 +532,8 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
// Daemon message handlers - directly update Zustand store
useEffect(() => {
const agentStreamReducerQueue = createSessionAgentStreamReducerQueue({
serverId,
setAgentStreamState,
setAgentTimelineCursor,
recoverTimelineGap,
});
const owner = viewedTimelineSyncRef.current;
if (!owner) throw new Error("Viewed timeline owner is unavailable");
const unsubAgentStream = client.on("agent_stream", (message) => {
if (message.type !== "agent_stream") return;
@@ -771,7 +554,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
if (turnLiveness.length > 0) {
applyAgentTurnLiveness(serverId, agentId, turnLiveness);
}
agentStreamReducerQueue.enqueue(agentId, {
owner.enqueueStreamEvent(agentId, {
event: streamEvent,
seq,
epoch,
@@ -791,7 +574,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
const unsubAgentTimeline = client.on("fetch_agent_timeline_response", (message) => {
if (message.type !== "fetch_agent_timeline_response") return;
agentStreamReducerQueue.flushAgent(message.payload.agentId);
owner.flushStreamAgent(message.payload.agentId);
applyTimelineResponse(message.payload);
});
@@ -813,11 +596,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
useProviderSubagentStore.getState().applyUpdate(serverId, message.payload);
});
const unsubScriptStatusUpdate = client.on("script_status_update", (message) => {
if (message.type !== "script_status_update") return;
setWorkspaces(serverId, (prev) => patchWorkspaceScripts(prev, message.payload));
});
const unsubCheckoutStatusUpdate = client.on("checkout_status_update", (message) => {
if (message.type !== "checkout_status_update") return;
applyCheckoutStatusUpdateFromEvent({ queryClient, serverId, message });
@@ -1008,7 +786,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
unsubAgentTimeline();
unsubProviderSubagentUpdate();
unsubAgentAttention();
unsubScriptStatusUpdate();
unsubCheckoutStatusUpdate();
unsubWorkspaceSetupProgress();
unsubWorkspaceSetupStatusResponse();
@@ -1020,7 +797,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
unsubTranscription();
unsubVoiceInputState();
unsubTerminalAttention();
agentStreamReducerQueue.dispose({ flush: true });
};
}, [
client,
@@ -1029,16 +805,12 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
setIsPlayingAudio,
setAgentStreamTail,
setAgentStreamHead,
setAgentStreamState,
applyAgentTurnLiveness,
clearAgentStreamHead,
setAgentTimelineCursor,
setInitializingAgents,
setAgents,
setWorkspaces,
setPendingPermissions,
notifyAgentAttention,
recoverTimelineGap,
applyWorkspaceSetupProgress,
applyTimelineResponse,
updateSessionServerInfo,
@@ -5,18 +5,17 @@ import {
} from "./session-resume-revalidation";
describe("session resume revalidation", () => {
it("refreshes both directories and timeline history after a stale resume", async () => {
it("refreshes demanded timeline history without manufacturing directory demand", async () => {
const calls: string[] = [];
const revalidated = await revalidateSessionAfterResume({
awayMs: SESSION_STALE_AFTER_MS,
serverId: "server",
bumpHistorySyncGeneration: (serverId) => calls.push(`history:${serverId}`),
refreshDirectories: async () => calls.push("directories"),
});
expect(revalidated).toBe(true);
expect(calls).toEqual(["history:server", "directories"]);
expect(calls).toEqual(["history:server"]);
});
it("does nothing after a brief background interval", async () => {
@@ -26,27 +25,9 @@ describe("session resume revalidation", () => {
awayMs: SESSION_STALE_AFTER_MS - 1,
serverId: "server",
bumpHistorySyncGeneration: () => calls.push("history"),
refreshDirectories: async () => calls.push("directories"),
});
expect(revalidated).toBe(false);
expect(calls).toEqual([]);
});
it("defers stale resume revalidation while the host is disconnected", async () => {
const calls: string[] = [];
const revalidated = await revalidateSessionAfterResume({
awayMs: SESSION_STALE_AFTER_MS,
serverId: "server",
bumpHistorySyncGeneration: (serverId) => calls.push(`history:${serverId}`),
refreshDirectories: async () => {
calls.push("directories");
throw new Error("Host server is not connected");
},
});
expect(revalidated).toBe(false);
expect(calls).toEqual(["history:server", "directories"]);
});
});
@@ -4,17 +4,11 @@ export async function revalidateSessionAfterResume(input: {
awayMs: number;
serverId: string;
bumpHistorySyncGeneration: (serverId: string) => void;
refreshDirectories: () => Promise<unknown>;
}): Promise<boolean> {
if (input.awayMs < SESSION_STALE_AFTER_MS) {
return false;
}
try {
input.bumpHistorySyncGeneration(input.serverId);
await input.refreshDirectories();
return true;
} catch {
return false;
}
input.bumpHistorySyncGeneration(input.serverId);
return true;
}
@@ -1,33 +0,0 @@
import equal from "fast-deep-equal";
import type { ScriptStatusUpdateMessage } from "@getpaseo/protocol/messages";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
export 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) {
return workspaces;
}
if (equal(existing.scripts, update.scripts)) {
return workspaces;
}
const next = new Map(workspaces);
next.set(workspaceKey, {
...existing,
scripts: update.scripts.map((s) => ({ ...s })),
});
return next;
}
@@ -68,26 +68,12 @@ function buildTracker(
initialFocusedTerminalId: overrides.initialFocusedTerminalId ?? null,
initialAppVisible: overrides.initialAppVisible ?? true,
now: clock.now,
onUserActivity: overrides.onUserActivity ?? (() => undefined),
onAppResumed: overrides.onAppResumed,
});
return { tracker, client, clock };
}
describe("client activity tracker", () => {
it("notifies local consumers about user activity", () => {
let notifications = 0;
const { tracker } = buildTracker({
onUserActivity: () => {
notifications += 1;
},
});
tracker.recordUserActivity();
expect(notifications).toBe(1);
});
it("includes the latest user-activity time in the next heartbeat", () => {
const { tracker, client, clock } = buildTracker();
@@ -23,7 +23,6 @@ export interface ClientActivityTrackerInput {
initialFocusedTerminalId: string | null;
initialAppVisible: boolean;
now: () => number;
onUserActivity: () => void;
onAppResumed?: (awayMs: number) => void;
}
@@ -40,7 +39,7 @@ export interface ClientActivityTracker {
export function createClientActivityTracker(
input: ClientActivityTrackerInput,
): ClientActivityTracker {
const { client, deviceType, now, onUserActivity, onAppResumed } = input;
const { client, deviceType, now, onAppResumed } = input;
let lastActivityAtMs = now();
let appVisible = input.initialAppVisible;
let appVisibilityChangedAtMs = now();
@@ -63,7 +62,6 @@ export function createClientActivityTracker(
function recordUserActivity(): void {
lastActivityAtMs = now();
onUserActivity();
}
function maybeSendImmediateHeartbeat(): void {
@@ -114,23 +114,22 @@ describe("ensureAgentIsInitialized", () => {
it("requests a bounded projected tail after restoring painted replica items", () => {
const client = new FakeDaemonClient();
const runtime = new FakeTimelineRuntime();
useSessionStore.getState().restoreSessionReplica(serverId, {
agents: new Map(),
workspaces: new Map(),
projects: new Map(),
timeline: {
agentId,
items: [
{
kind: "assistant_message",
id: "painted-item",
text: "Painted before hydration",
timestamp: new Date("2026-07-27T10:00:00.000Z"),
},
],
range: null,
hasOlder: false,
},
useSessionStore.getState().initializeSession(serverId, null);
useSessionStore.getState().applyAgentTimelineResponseState(serverId, agentId, {
items: [
{
kind: "assistant_message",
id: "painted-item",
text: "Painted before hydration",
timestamp: new Date("2026-07-27T10:00:00.000Z"),
},
],
head: [],
range: null,
older: "none",
newer: false,
synchronized: false,
acknowledgedClientMessageIds: [],
});
void ensureAgentIsInitialized({
@@ -157,24 +156,23 @@ describe("ensureAgentIsInitialized", () => {
it("catches up after the restored canonical replica range", () => {
const client = new FakeDaemonClient();
const runtime = new FakeTimelineRuntime();
useSessionStore.getState().restoreSessionReplica(serverId, {
agents: new Map(),
workspaces: new Map(),
projects: new Map(),
timeline: {
agentId,
items: [
{
kind: "assistant_message",
id: "canonical-item",
text: "Canonical before restart",
timelineCursor: { epoch: "epoch-1", seq: 12 },
timestamp: new Date("2026-07-27T10:00:00.000Z"),
},
],
range: { epoch: "epoch-1", startSeq: 10, endSeq: 12 },
hasOlder: true,
},
useSessionStore.getState().initializeSession(serverId, null);
useSessionStore.getState().applyAgentTimelineResponseState(serverId, agentId, {
items: [
{
kind: "assistant_message",
id: "canonical-item",
text: "Canonical before restart",
timelineCursor: { epoch: "epoch-1", seq: 12 },
timestamp: new Date("2026-07-27T10:00:00.000Z"),
},
],
head: [],
range: { epoch: "epoch-1", startSeq: 10, endSeq: 12 },
older: "available",
newer: false,
synchronized: true,
acknowledgedClientMessageIds: [],
});
void ensureAgentIsInitialized({
@@ -66,6 +66,7 @@ function createBaseInput(): AgentScreenMachineInput {
isHistorySyncing: false,
needsAuthoritativeSync: false,
visibilityCatchUpStatus: "ready",
visibilityCatchUpError: null,
hasHydratedHistoryBefore: false,
};
}
@@ -248,6 +249,24 @@ describe("deriveAgentScreenViewState", () => {
expectSyncErrorSync(ready);
});
it("shows the owner error when the first timeline load fails", () => {
const result = deriveAgentScreenViewState({
input: {
...createBaseInput(),
agent: createAgent("agent-1"),
visibilityCatchUpStatus: "error",
visibilityCatchUpError: "already has an active writer",
},
memory: createBaseMemory(),
});
expect(result.state).toEqual({
tag: "error",
message: "already has an active writer",
});
expect(result.memory.hadInitialSyncFailure).toBe(true);
});
it("keeps sync errors non-blocking once the screen was ready", () => {
const memory = createBaseMemory({
hasRenderedReady: true,
@@ -49,6 +49,7 @@ export interface AgentScreenMachineInput {
isHistorySyncing: boolean;
needsAuthoritativeSync: boolean;
visibilityCatchUpStatus: ViewedTimelineStatus;
visibilityCatchUpError: string | null;
continuity: AgentScreenContinuity;
hasHydratedHistoryBefore: boolean;
}
@@ -117,6 +118,13 @@ function updateInitialSyncFailureMemory(args: {
if (args.input.missingAgentState.kind === "error" && !args.input.hasHydratedHistoryBefore) {
args.nextMemory.hadInitialSyncFailure = true;
}
if (
args.input.visibilityCatchUpStatus === "error" &&
args.input.visibilityCatchUpError &&
!args.input.hasHydratedHistoryBefore
) {
args.nextMemory.hadInitialSyncFailure = true;
}
}
function shouldUseOptimisticCreateFlowAgent(input: AgentScreenMachineInput): boolean {
@@ -236,6 +244,21 @@ export function deriveAgentScreenViewState({
};
}
if (
input.visibilityCatchUpStatus === "error" &&
input.visibilityCatchUpError &&
!input.hasHydratedHistoryBefore &&
!nextMemory.hasRenderedReady
) {
return {
state: {
tag: "error",
message: input.visibilityCatchUpError,
},
memory: nextMemory,
};
}
if (candidateAgent && shouldBlockReadyState) {
return {
state: {
@@ -1,4 +1,4 @@
import { useMemo, useCallback, useRef, useSyncExternalStore } from "react";
import { useMemo, useCallback, useEffect, useRef, useSyncExternalStore } from "react";
import equal from "fast-deep-equal";
import { useShallow } from "zustand/shallow";
import { useSessionStore } from "@/stores/session-store";
@@ -21,10 +21,18 @@ export interface AggregatedAgentsResult {
export function useAggregatedAgents(options?: {
includeArchived?: boolean;
demand?: boolean;
}): AggregatedAgentsResult {
const daemons = useHosts();
const runtime = getHostRuntimeStore();
const includeArchived = options?.includeArchived ?? false;
const demand = options?.demand ?? true;
const serverIds = useMemo(() => daemons.map((daemon) => daemon.serverId), [daemons]);
useEffect(() => {
if (!demand) return;
const releases = serverIds.map((serverId) => runtime.acquireDirectoryDemand(serverId));
return () => releases.forEach((release) => release());
}, [demand, runtime, serverIds]);
const runtimeVersion = useSyncExternalStore(
(onStoreChange) => runtime.subscribeAll(onStoreChange),
() => runtime.getVersion(),
@@ -42,8 +50,9 @@ export function useAggregatedAgents(options?: {
);
const refreshAll = useCallback(() => {
runtime.refreshAllAgentDirectories();
}, [runtime]);
if (!demand) return;
for (const serverId of serverIds) void runtime.refreshDirectories(serverId);
}, [demand, runtime, serverIds]);
// Keyed by "serverId:agentId" — reuse the previous AggregatedAgent object when
// none of its fields changed, so downstream memo/shallow comparisons can bail early.
@@ -15,7 +15,6 @@ interface ClientActivityOptions {
client: DaemonClient;
focusedAgentId: string | null;
focusedTerminalId: string | null;
onUserActivity: () => void;
onAppResumed?: (awayMs: number) => void;
}
@@ -29,7 +28,6 @@ export function useClientActivity({
client,
focusedAgentId,
focusedTerminalId,
onUserActivity,
onAppResumed,
}: ClientActivityOptions): void {
const onAppResumedRef = useRef(onAppResumed);
@@ -44,7 +42,6 @@ export function useClientActivity({
initialFocusedTerminalId: focusedTerminalId,
initialAppVisible: AppState.currentState === "active",
now: () => Date.now(),
onUserActivity,
onAppResumed: (awayMs) => onAppResumedRef.current?.(awayMs),
});
}
+1 -1
View File
@@ -94,7 +94,7 @@ async function updateMacDockBadge(count?: number) {
}
export function useFaviconStatus() {
const { agents } = useAggregatedAgents();
const { agents } = useAggregatedAgents({ demand: !isNative });
const workspaceStatuses = useWorkspaceStatusesForBadges();
const [colorScheme, setColorScheme] = useState<ColorScheme>(getSystemColorScheme);
const lastDockBadgeCountRef = useRef<number | undefined>(undefined);
+6 -2
View File
@@ -1,4 +1,4 @@
import { useCallback, useMemo, useRef, useSyncExternalStore } from "react";
import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from "react";
import equal from "fast-deep-equal";
import { useStoreWithEqualityFn } from "zustand/traditional";
import {
@@ -168,6 +168,10 @@ export function useProjects(options: UseProjectsOptions = {}): UseProjectsResult
() => (enabled ? hosts.map((host) => host.serverId) : []),
[enabled, hosts],
);
useEffect(() => {
const releases = serverIds.map((serverId) => runtime.acquireDirectoryDemand(serverId));
return () => releases.forEach((release) => release());
}, [runtime, serverIds]);
const replicaSelector = useMemo(
() => selectProjectHostReplicas(hosts, enabled),
[enabled, hosts],
@@ -180,7 +184,7 @@ export function useProjects(options: UseProjectsOptions = {}): UseProjectsResult
);
const refetch = useCallback(() => {
if (!enabled) return;
runtime.refreshAllAgentDirectories({ serverIds });
for (const serverId of serverIds) void runtime.refreshDirectories(serverId);
}, [enabled, runtime, serverIds]);
return {
@@ -2,10 +2,7 @@ import { useCallback, useEffect, useMemo } from "react";
import { useStoreWithEqualityFn } from "zustand/traditional";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { useSessionStore } from "@/stores/session-store";
import {
useHydratedWorkspaceServerIds,
useWorkspaceDirectoryServerIds,
} from "@/stores/session-store-hooks";
import { useWorkspaceDirectoryServerIds } from "@/stores/session-store-hooks";
import { workspaceEqualityFns } from "@/stores/session-store-hooks/selectors";
import { useHostProjects } from "@/projects/host-projects";
import { getHostRuntimeStore, useHostRegistryLoaded, useHosts } from "@/runtime/host-runtime";
@@ -124,6 +121,11 @@ export function useSidebarWorkspacesList(options?: {
}
return matched;
}, [allServerIds, hostFilters, hostRegistryLoaded]);
useEffect(() => {
if (!isActive) return;
const releases = serverIds.map((serverId) => runtime.acquireDirectoryDemand(serverId));
return () => releases.forEach((release) => release());
}, [isActive, runtime, serverIds]);
useEffect(() => {
if (!hostRegistryLoaded) {
@@ -134,7 +136,6 @@ export function useSidebarWorkspacesList(options?: {
const persistedProjectOrder = useSidebarOrderStore((state) => state.projectOrder ?? EMPTY_ORDER);
const hydratedServerIds = useHydratedWorkspaceServerIds(serverIds);
const directoryServerIds = useWorkspaceDirectoryServerIds(serverIds);
const hostProjects = useHostProjects(directoryServerIds);
@@ -175,8 +176,6 @@ export function useSidebarWorkspacesList(options?: {
const refreshAll = useCallback(() => {
if (!isActive) return;
for (const serverId of serverIds) {
const snapshot = runtime.getSnapshot(serverId);
if (snapshot?.connectionStatus !== "online") continue;
void runtime.refreshDirectories(serverId).catch((error) => {
console.error("[WorkspaceFetch][sidebar-refresh] failed", {
serverId,
@@ -189,7 +188,7 @@ export function useSidebarWorkspacesList(options?: {
const loadingState = deriveSidebarLoadingState({
isActive,
serverIds,
hydratedServerIds,
hydratedServerIds: directoryServerIds,
hasProjects: projects.length > 0,
});
+32 -9
View File
@@ -48,7 +48,6 @@ import {
} from "@/constants/layout";
import { isWeb } from "@/constants/platform";
import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear";
import { useAgentInitialization } from "@/hooks/use-agent-initialization";
import { useAgentInputDraft, type AgentInputDraft } from "@/composer/draft/input-draft";
import {
type AgentScreenAgent,
@@ -99,6 +98,7 @@ import { useSettings } from "@/hooks/use-settings";
import type { Theme } from "@/styles/theme";
import type { PendingPermission } from "@/types/shared";
import type { StreamItem, TodoEntry } from "@/types/stream";
import type { ViewedTimelineStatus, ViewedTimelineUiBridge } from "@/timeline/viewed-timeline-sync";
import { useArchiveFinishedSubagents, useSubagentsForParent } from "@/subagents";
import { getInitDeferred, getInitKey } from "@/utils/agent-initialization";
import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots";
@@ -143,6 +143,15 @@ function resolveChatAgentFromSession(
return session?.agents?.get(agentId) ?? session?.agentDetails?.get(agentId) ?? null;
}
function readViewedTimelineError(input: {
agentId: string | undefined;
status: ViewedTimelineStatus;
sync: ViewedTimelineUiBridge | null;
}): string | null {
if (!input.agentId || input.status !== "error" || !input.sync) return null;
return input.sync.getAgentTimelineError(input.agentId);
}
const EMPTY_CHAT_AGENT_STATE: ChatAgentSelectedState = {
serverId: null,
id: null,
@@ -847,14 +856,15 @@ function ChatAgentContent({
readTimelineStatus,
);
const visibilityCatchUpStatus = isPaneVisible ? timelineStatus : "ready";
const visibilityCatchUpError = readViewedTimelineError({
agentId,
status: visibilityCatchUpStatus,
sync: viewedTimelineSync,
});
const hasActiveCreateHandoff = useCreateFlowStore((state) =>
findActiveCreateHandoff({ pendingByDraftId: state.pendingByDraftId, serverId, agentId }),
);
const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId]));
const { ensureAgentIsInitialized } = useAgentInitialization({
serverId,
client: hasSession ? client : null,
});
const [missingAgentState, setMissingAgentState] = useState<AgentScreenMissingState>({
kind: "idle",
});
@@ -1007,6 +1017,7 @@ function ChatAgentContent({
isHistorySyncing,
needsAuthoritativeSync,
visibilityCatchUpStatus,
visibilityCatchUpError,
continuity,
hasHydratedHistoryBefore,
},
@@ -1049,7 +1060,11 @@ function ChatAgentContent({
streamViewRef.current?.scrollToBottom("rewind");
}, []);
const retryAgentLoad = useCallback(() => setMissingAgentState({ kind: "idle" }), []);
const retryAgentLoad = useCallback(() => {
setMissingAgentState({ kind: "idle" });
if (!agentId || !viewedTimelineSync) return;
viewedTimelineSync.retryVisibleAgentTimeline(agentId);
}, [agentId, viewedTimelineSync]);
const retryTimelineSync = useCallback(() => {
if (!agentId || !viewedTimelineSync) return;
@@ -1078,7 +1093,14 @@ function ChatAgentContent({
}
return;
}
if (!client || !isPaneVisible || !isConnected || !hasSession) {
if (
!client ||
!viewedTimelineSync ||
!isPaneVisible ||
!isConnected ||
!hasSession ||
visibilityCatchUpStatus !== "ready"
) {
return;
}
if (
@@ -1092,7 +1114,7 @@ function ChatAgentContent({
setMissingAgentState({ kind: "resolving" });
const attemptToken = ++initAttemptTokenRef.current;
ensureAgentIsInitialized(agentId)
Promise.resolve()
.then(async () => {
if (attemptToken !== initAttemptTokenRef.current) {
return;
@@ -1137,12 +1159,13 @@ function ChatAgentContent({
hasAppliedAuthoritativeHistory,
agentId,
client,
ensureAgentIsInitialized,
hasSession,
isConnected,
isPaneVisible,
missingAgentState.kind,
serverId,
viewedTimelineSync,
visibilityCatchUpStatus,
]);
const animatedContentStyle = useMemo(
@@ -51,6 +51,21 @@ function entry(agent: AgentSnapshotPayload): FetchAgentsEntry {
}
describe("AgentDirectoryReplica", () => {
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);
expect(replica.submitTimelineAgent(replica.captureTimeline("agent"), payload("network"))).toBe(
true,
);
expect(useSessionStore.getState().sessions[serverId]?.agents.get("agent")?.title).toBe(
"network",
);
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();
@@ -29,6 +29,7 @@ export interface AgentLifecycleToken {
export class AgentDirectoryReplica {
private readonly lifecycleVersions = new Map<string, number>();
private readonly members = new Set<string>();
private readonly pendingCacheReads = new Set<string>();
constructor(
private readonly serverId: string,
@@ -39,11 +40,37 @@ export class AgentDirectoryReplica {
return { agentId, version: this.lifecycleVersions.get(agentId) ?? 0 };
}
captureCache(agentId: string): AgentLifecycleToken {
this.pendingCacheReads.add(agentId);
return this.captureTimeline(agentId);
}
commitCached(agents: Map<string, Agent>): void {
this.members.clear();
for (const [agentId, agent] of agents) {
this.members.add(agentId);
useSessionStore.getState().setAgentLastActivity(agentId, agent.lastActivityAt);
}
useSessionStore.getState().setAgents(this.serverId, agents);
}
commitCachedAgent(token: AgentLifecycleToken, agent: Agent): boolean {
this.pendingCacheReads.delete(token.agentId);
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);
return true;
}
submitTimelineAgent(token: AgentLifecycleToken, payload: AgentSnapshotPayload): boolean {
if (
!this.members.has(token.agentId) ||
token.version !== (this.lifecycleVersions.get(token.agentId) ?? 0)
) {
if (token.version !== (this.lifecycleVersions.get(token.agentId) ?? 0)) {
return false;
}
const existing = useSessionStore.getState().sessions[this.serverId]?.agents.get(token.agentId);
@@ -56,6 +83,7 @@ export class AgentDirectoryReplica {
projectPlacement: timelineAgent.projectPlacement ?? existing?.projectPlacement,
};
const accepted = upsertAgentReplica(this.serverId, normalized);
this.members.add(accepted.id);
replaceAgentPendingPermissions(this.serverId, accepted);
useSessionStore.getState().setAgentLastActivity(accepted.id, accepted.lastActivityAt);
if (accepted.archivedAt) {
@@ -84,6 +112,9 @@ export class AgentDirectoryReplica {
const previous = useSessionStore.getState().sessions[this.serverId]?.agents ?? new Map();
const reconciled = reconcileAgentDirectory({ previous, snapshot: entries, deltas });
const nextIds = new Set(reconciled.entries.map((entry) => entry.agent.id));
for (const agentId of this.pendingCacheReads) {
if (!nextIds.has(agentId)) this.advance(agentId);
}
for (const agentId of this.members) {
if (!nextIds.has(agentId)) this.advance(agentId);
}
@@ -94,6 +125,7 @@ export class AgentDirectoryReplica {
if (!nextIds.has(agentId)) removeAgentDirectoryReplica(this.serverId, agentId);
}
this.members.clear();
this.pendingCacheReads.clear();
for (const agentId of nextIds) this.members.add(agentId);
const { agents } = replaceFetchedAgentDirectory({
serverId: this.serverId,
@@ -1,19 +1,33 @@
import { afterEach, describe, expect, it } from "vitest";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
import { normalizeProjectDescriptor, useSessionStore } from "@/stores/session-store";
import { DirectoryRefreshSupersededError, DirectorySync } from "./index";
import {
normalizeProjectDescriptor,
normalizeWorkspaceDescriptor,
useSessionStore,
} 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 {
DirectoryRefreshSupersededError,
DirectorySync,
type DirectoryCheckpointStorage,
} from "./index";
type WorkspaceFetchResult = Awaited<ReturnType<DaemonClient["fetchWorkspaces"]>>;
type ProjectListResult = Awaited<ReturnType<DaemonClient["listProjects"]>>;
type AgentFetchResult = Awaited<ReturnType<DaemonClient["fetchAgents"]>>;
class FakeDirectoryClient {
fetchAgentsCalls = 0;
lastAgentOptions: unknown;
fetchWorkspacesCalls = 0;
lastWorkspaceOptions: unknown;
listProjectsCalls = 0;
lastProjectOptions: unknown;
projectResult: ProjectListResult | null = null;
private pendingAgentFetch: Promise<AgentFetchResult> | null = null;
private pendingWorkspaceFetch: Promise<WorkspaceFetchResult> | null = null;
private readonly handlers = new Map<
SessionOutboundMessage["type"],
@@ -45,9 +59,22 @@ class FakeDirectoryClient {
return complete;
}
holdAgentFetch(): (result: AgentFetchResult) => void {
let complete!: (result: AgentFetchResult) => void;
this.pendingAgentFetch = new Promise((resolve) => {
complete = resolve;
});
return complete;
}
async fetchAgents(options?: unknown): Promise<Awaited<ReturnType<DaemonClient["fetchAgents"]>>> {
this.fetchAgentsCalls += 1;
this.lastAgentOptions = options;
if (this.pendingAgentFetch) {
const pending = this.pendingAgentFetch;
this.pendingAgentFetch = null;
return pending;
}
return {
requestId: "agents",
entries: [],
@@ -55,8 +82,9 @@ class FakeDirectoryClient {
};
}
async fetchWorkspaces(): Promise<WorkspaceFetchResult> {
async fetchWorkspaces(options?: unknown): Promise<WorkspaceFetchResult> {
this.fetchWorkspacesCalls += 1;
this.lastWorkspaceOptions = options;
if (this.pendingWorkspaceFetch) {
const pending = this.pendingWorkspaceFetch;
this.pendingWorkspaceFetch = null;
@@ -87,6 +115,10 @@ class FakeDirectoryClient {
],
};
}
getLastServerInfoMessage(): null {
return null;
}
}
const serverIds = new Set<string>();
@@ -111,12 +143,649 @@ function createDirectory(serverId: string): {
return { client, directory };
}
function createAgent(serverId: string, id: string) {
return {
...normalizeAgentSnapshot(
{
id,
provider: "codex",
cwd: "/repo",
model: null,
createdAt: "2026-08-26T00:00:00.000Z",
updatedAt: "2026-08-26T00:00:00.000Z",
lastUserMessageAt: null,
status: "idle",
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: null,
title: "Cached",
labels: {},
},
serverId,
),
projectPlacement: null,
};
}
afterEach(() => {
for (const serverId of serverIds) useSessionStore.getState().clearSession(serverId);
serverIds.clear();
});
describe("DirectorySync session readiness", () => {
it("paints a demanded cached directory while offline", async () => {
const serverId = "offline-cached-directory";
serverIds.add(serverId);
const client = new FakeDirectoryClient();
const cachedWorkspace = normalizeWorkspaceDescriptor({
id: "cached-workspace",
projectId: "cached-project",
projectDisplayName: "Cached project",
projectRootPath: "/repo/cached",
workspaceDirectory: "/repo/cached",
projectKind: "git",
workspaceKind: "local_checkout",
name: "cached",
status: "done",
statusEnteredAt: null,
activityAt: null,
archivingAt: null,
diffStat: null,
scripts: [],
});
const cachedProject = normalizeProjectDescriptor({
projectId: "cached-project",
projectDisplayName: "Cached project",
projectRootPath: "/repo/cached",
projectKind: "git",
});
const directory = new DirectorySync(
serverId,
{
onAgentStoppedRunning: () => undefined,
markAgentLoading: () => undefined,
markAgentReady: () => undefined,
markAgentError: () => undefined,
},
{
readAgent: async () => undefined,
readWorkspace: async () => undefined,
readDirectory: async () => ({
agents: new Map(),
workspaces: new Map([[cachedWorkspace.id, cachedWorkspace]]),
projects: new Map([[cachedProject.projectId, cachedProject]]),
}),
commitDirectory: () => undefined,
},
);
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient, 1);
await directory.refreshAll();
expect(
useSessionStore.getState().sessions[serverId]?.workspaces.get(cachedWorkspace.id),
).toEqual(cachedWorkspace);
expect(selectWorkspaceDirectoryServerIds(useSessionStore.getState(), [serverId])).toEqual([
serverId,
]);
expect(client.fetchAgentsCalls).toBe(0);
expect(client.fetchWorkspacesCalls).toBe(0);
directory.dispose();
});
it("prepares a cached agent workspace route without sidebar demand", async () => {
const serverId = "cached-agent-route";
serverIds.add(serverId);
const client = new FakeDirectoryClient();
const cachedAgent = createAgent(serverId, "agent-1");
const cachedWorkspace = normalizeWorkspaceDescriptor({
id: "workspace-1",
projectId: "project-1",
projectDisplayName: "Paseo",
projectRootPath: "/repo",
workspaceDirectory: "/repo",
projectKind: "git",
workspaceKind: "local_checkout",
name: "main",
status: "done",
statusEnteredAt: null,
activityAt: null,
archivingAt: null,
diffStat: null,
scripts: [],
});
cachedAgent.workspaceId = cachedWorkspace.id;
const directory = new DirectorySync(
serverId,
{
onAgentStoppedRunning: () => undefined,
markAgentLoading: () => undefined,
markAgentReady: () => undefined,
markAgentError: () => undefined,
},
{
readAgent: async () => cachedAgent,
readWorkspace: async () => ({ workspace: cachedWorkspace }),
readDirectory: async () => ({
agents: new Map(),
workspaces: new Map(),
projects: new Map(),
}),
commitDirectory: () => undefined,
},
);
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient, 1);
await directory.prepareAgentRoute(cachedAgent.id);
const session = useSessionStore.getState().sessions[serverId];
expect(session?.hasHydratedAgents).toBe(false);
expect(session?.hasWorkspaceDirectorySnapshot).toBe(false);
expect(session?.agents.get(cachedAgent.id)).toEqual(cachedAgent);
expect(session?.workspaces.get(cachedWorkspace.id)).toEqual(cachedWorkspace);
expect(client.fetchAgentsCalls).toBe(0);
expect(client.fetchWorkspacesCalls).toBe(0);
directory.dispose();
});
it("persists accepted script status updates through the directory owner", () => {
const serverId = "script-status-owner";
serverIds.add(serverId);
const client = new FakeDirectoryClient();
const commits: CachedDirectory[] = [];
const directory = new DirectorySync(
serverId,
{
onAgentStoppedRunning: () => undefined,
markAgentLoading: () => undefined,
markAgentReady: () => undefined,
markAgentError: () => undefined,
},
{
readAgent: async () => undefined,
readWorkspace: async () => undefined,
readDirectory: async () => ({
agents: new Map(),
workspaces: new Map(),
projects: new Map(),
}),
commitDirectory: (_serverId, value) => commits.push(value),
},
);
directory.connectionChanged({
client: client as unknown as DaemonClient,
status: "online",
source: { clientGeneration: 1, connectionEpoch: 1 },
});
const workspace = normalizeWorkspaceDescriptor({
id: "workspace-1",
projectId: "project-1",
projectDisplayName: "Paseo",
projectRootPath: "/repo",
workspaceDirectory: "/repo",
projectKind: "git",
workspaceKind: "local_checkout",
name: "main",
status: "running",
statusEnteredAt: null,
activityAt: null,
archivingAt: null,
diffStat: null,
scripts: [],
});
const store = useSessionStore.getState();
store.initializeSession(serverId, client as unknown as DaemonClient, 1);
store.setWorkspaces(serverId, new Map([[workspace.id, workspace]]));
client.emit({
type: "script_status_update",
payload: {
workspaceId: workspace.id,
scripts: [
{
scriptName: "web",
type: "service",
hostname: "web.paseo.localhost",
port: 3000,
proxyUrl: "http://web.paseo.localhost:6767",
lifecycle: "running",
health: "healthy",
exitCode: null,
terminalId: null,
},
],
},
});
expect(
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");
directory.dispose();
});
it("subscribes to live agent updates when the directory is demanded", async () => {
const serverId = "demanded-directory-subscription";
const { client, directory } = createDirectory(serverId);
const store = useSessionStore.getState();
store.initializeSession(serverId, client as unknown as DaemonClient, 1);
store.updateSessionServerInfo(serverId, {
serverId,
hostname: null,
version: "test",
features: { workspaceMultiplicity: true },
});
await directory.refreshAll();
expect(client.lastAgentOptions).toMatchObject({ subscribe: {} });
directory.dispose();
});
it("coalesces overlapping route and full-directory demand", async () => {
const serverId = "coalesced-directory-demand";
const { client, directory } = createDirectory(serverId);
const releaseAgents = client.holdAgentFetch();
const releaseWorkspaces = client.holdWorkspaceFetch();
const store = useSessionStore.getState();
store.initializeSession(serverId, client as unknown as DaemonClient, 1);
store.updateSessionServerInfo(serverId, {
serverId,
hostname: null,
version: "test",
features: { workspaceMultiplicity: true },
});
directory.setAgentRouteDemand(["agent-1"]);
directory.setDemand({}, true);
await expect.poll(() => client.fetchAgentsCalls).toBe(1);
await expect.poll(() => client.fetchWorkspacesCalls).toBe(1);
releaseAgents({
requestId: "agents",
entries: [],
pageInfo: { hasMore: false, nextCursor: null, prevCursor: null },
});
releaseWorkspaces({
requestId: "workspaces",
entries: [],
emptyProjects: [],
pageInfo: { hasMore: false, nextCursor: null, prevCursor: null },
});
await directory.refreshDemand();
expect(client.fetchAgentsCalls).toBe(1);
expect(client.fetchWorkspacesCalls).toBe(1);
directory.dispose();
});
it("publishes cached directory data before the authoritative request completes", async () => {
const serverId = "cached-directory";
serverIds.add(serverId);
const client = new FakeDirectoryClient();
const releaseNetwork = client.holdWorkspaceFetch();
let cacheReads = 0;
const cachedWorkspace = normalizeWorkspaceDescriptor({
id: "cached-workspace",
projectId: "cached-project",
projectDisplayName: "Cached project",
projectRootPath: "/repo/cached",
workspaceDirectory: "/repo/cached",
projectKind: "git",
workspaceKind: "local_checkout",
name: "cached",
status: "done",
statusEnteredAt: null,
activityAt: null,
archivingAt: null,
diffStat: null,
scripts: [],
});
const cachedProject = normalizeProjectDescriptor({
projectId: "cached-project",
projectDisplayName: "Cached project",
projectRootPath: "/repo/cached",
projectKind: "git",
});
const directory = new DirectorySync(
serverId,
{
onAgentStoppedRunning: () => undefined,
markAgentLoading: () => undefined,
markAgentReady: () => undefined,
markAgentError: () => undefined,
},
{
readAgent: async () => undefined,
readWorkspace: async () => undefined,
readDirectory: async () => {
cacheReads += 1;
return {
agents: new Map(),
workspaces: new Map([[cachedWorkspace.id, cachedWorkspace]]),
projects: new Map([[cachedProject.projectId, cachedProject]]),
};
},
commitDirectory: () => undefined,
},
);
directory.connectionChanged({
client: client as unknown as DaemonClient,
status: "online",
source: { clientGeneration: 1, connectionEpoch: 1 },
});
const store = useSessionStore.getState();
store.initializeSession(serverId, client as unknown as DaemonClient, 1);
store.updateSessionServerInfo(serverId, {
serverId,
hostname: null,
version: "test",
features: { workspaceMultiplicity: true },
});
const refresh = directory.refreshWorkspaces();
await expect.poll(() => client.fetchWorkspacesCalls).toBe(1);
expect(cacheReads).toBe(1);
expect(
useSessionStore.getState().sessions[serverId]?.workspaces.get("cached-workspace")?.name,
).toBe("cached");
releaseNetwork({
requestId: "workspaces",
entries: [],
emptyProjects: [],
pageInfo: { hasMore: false, nextCursor: null, prevCursor: null },
});
await refresh;
expect(useSessionStore.getState().sessions[serverId]?.workspaces.size).toBe(0);
directory.dispose();
});
it("reconciles workspace changes on top of the accepted cached baseline", async () => {
const serverId = "cached-workspace-changes";
serverIds.add(serverId);
const client = new FakeDirectoryClient();
const cachedWorkspace = normalizeWorkspaceDescriptor({
id: "cached-workspace",
projectId: "cached-project",
projectDisplayName: "Cached project",
projectRootPath: "/repo/cached",
workspaceDirectory: "/repo/cached",
projectKind: "git",
workspaceKind: "local_checkout",
name: "cached",
status: "done",
statusEnteredAt: null,
activityAt: null,
archivingAt: null,
diffStat: null,
scripts: [],
});
const cachedProject = normalizeProjectDescriptor({
projectId: "cached-project",
projectDisplayName: "Cached project",
projectRootPath: "/repo/cached",
projectKind: "git",
});
const releaseNetwork = client.holdWorkspaceFetch();
const directory = new DirectorySync(
serverId,
{
onAgentStoppedRunning: () => undefined,
markAgentLoading: () => undefined,
markAgentReady: () => undefined,
markAgentError: () => undefined,
},
{
readAgent: async () => undefined,
readWorkspace: async () => undefined,
readDirectory: async () => ({
agents: new Map(),
workspaces: new Map([[cachedWorkspace.id, cachedWorkspace]]),
projects: new Map([[cachedProject.projectId, cachedProject]]),
checkpoint: { workspaces: { generation: "g", afterSeq: 7 } },
}),
commitDirectory: () => undefined,
},
);
directory.connectionChanged({
client: client as unknown as DaemonClient,
status: "online",
source: { clientGeneration: 1, connectionEpoch: 1 },
});
const store = useSessionStore.getState();
store.initializeSession(serverId, client as unknown as DaemonClient, 1);
store.updateSessionServerInfo(serverId, {
serverId,
hostname: null,
version: "test",
features: { workspaceMultiplicity: true, directorySync: true },
});
const refresh = directory.refreshWorkspaces();
await expect.poll(() => client.fetchWorkspacesCalls).toBe(1);
releaseNetwork({
requestId: "workspaces",
entries: [],
emptyProjects: [],
pageInfo: { hasMore: false, nextCursor: null, prevCursor: null },
sync: { generation: "g", headSeq: 7, mode: "changes", removals: [] },
});
await refresh;
expect(client.lastWorkspaceOptions).toMatchObject({
sync: { generation: "g", afterSeq: 7 },
});
expect(useSessionStore.getState().sessions[serverId]?.workspaces.has(cachedWorkspace.id)).toBe(
true,
);
directory.dispose();
});
it("does not use a cached checkpoint when the corresponding cache read loses its race", async () => {
const serverId = "late-directory-cache";
serverIds.add(serverId);
const client = new FakeDirectoryClient();
let releaseCache!: (
value: Awaited<ReturnType<DirectoryCheckpointStorage["readDirectory"]>>,
) => void;
const cacheRead = new Promise<Awaited<ReturnType<DirectoryCheckpointStorage["readDirectory"]>>>(
(resolve) => {
releaseCache = resolve;
},
);
const directory = new DirectorySync(
serverId,
{
onAgentStoppedRunning: () => undefined,
markAgentLoading: () => undefined,
markAgentReady: () => undefined,
markAgentError: () => undefined,
},
{
readAgent: async () => undefined,
readWorkspace: async () => undefined,
readDirectory: () => cacheRead,
commitDirectory: () => undefined,
},
);
directory.connectionChanged({
client: client as unknown as DaemonClient,
status: "online",
source: { clientGeneration: 1, connectionEpoch: 1 },
});
const store = useSessionStore.getState();
store.initializeSession(serverId, client as unknown as DaemonClient, 1);
store.updateSessionServerInfo(serverId, {
serverId,
hostname: null,
version: "test",
features: { workspaceMultiplicity: true, directorySync: true },
});
const refresh = directory.refreshWorkspaces();
await Promise.resolve();
client.emit({
type: "workspace_update",
payload: { kind: "remove", id: "deleted-live" },
});
releaseCache({
agents: new Map(),
workspaces: new Map(),
projects: new Map(),
checkpoint: { workspaces: { generation: "stale", afterSeq: 99 } },
});
await refresh;
expect(client.lastWorkspaceOptions).not.toHaveProperty("sync.generation");
directory.dispose();
});
it("does not resurrect an agent deleted while its targeted cache row is loading", async () => {
const serverId = "late-agent-cache";
serverIds.add(serverId);
const client = new FakeDirectoryClient();
const cachedAgent = {
...normalizeAgentSnapshot(
{
id: "agent-1",
provider: "codex",
cwd: "/repo",
model: null,
createdAt: "2026-08-26T00:00:00.000Z",
updatedAt: "2026-08-26T00:00:00.000Z",
lastUserMessageAt: null,
status: "idle",
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: null,
title: "Cached",
labels: {},
},
serverId,
),
projectPlacement: null,
};
let releaseAgent!: (agent: typeof cachedAgent) => void;
const agentRead = new Promise<typeof cachedAgent>((resolve) => {
releaseAgent = resolve;
});
const directory = new DirectorySync(
serverId,
{
onAgentStoppedRunning: () => undefined,
markAgentLoading: () => undefined,
markAgentReady: () => undefined,
markAgentError: () => undefined,
},
{
readAgent: () => agentRead,
readWorkspace: async () => undefined,
readDirectory: async () => ({
agents: new Map(),
workspaces: new Map(),
projects: new Map(),
}),
commitDirectory: () => undefined,
},
);
directory.connectionChanged({
client: client as unknown as DaemonClient,
status: "online",
source: { clientGeneration: 1, connectionEpoch: 1 },
});
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient, 1);
const load = directory.loadCachedAgent(cachedAgent.id);
client.emit({
type: "agent_deleted",
payload: { agentId: cachedAgent.id, requestId: "delete-live" },
});
releaseAgent(cachedAgent);
await load;
expect(useSessionStore.getState().sessions[serverId]?.agents.has(cachedAgent.id)).toBe(false);
directory.dispose();
});
it("does not resurrect an agent removed by an authoritative snapshot while cache loads", async () => {
const serverId = "late-agent-authoritative-snapshot";
serverIds.add(serverId);
const client = new FakeDirectoryClient();
const cachedAgent = createAgent(serverId, "agent-1");
const releaseNetwork = client.holdAgentFetch();
let releaseAgent!: (agent: typeof cachedAgent) => void;
const agentRead = new Promise<typeof cachedAgent>((resolve) => {
releaseAgent = resolve;
});
const directory = new DirectorySync(
serverId,
{
onAgentStoppedRunning: () => undefined,
markAgentLoading: () => undefined,
markAgentReady: () => undefined,
markAgentError: () => undefined,
},
{
readAgent: () => agentRead,
readWorkspace: async () => undefined,
readDirectory: async () => ({
agents: new Map(),
workspaces: new Map(),
projects: new Map(),
}),
commitDirectory: () => undefined,
},
);
directory.connectionChanged({
client: client as unknown as DaemonClient,
status: "online",
source: { clientGeneration: 1, connectionEpoch: 1 },
});
const store = useSessionStore.getState();
store.initializeSession(serverId, client as unknown as DaemonClient, 1);
store.updateSessionServerInfo(serverId, {
serverId,
hostname: null,
version: "test",
features: { directorySync: true, workspaceMultiplicity: true },
});
const refresh = directory.refreshAgents();
const load = directory.loadCachedAgent(cachedAgent.id);
releaseNetwork({
requestId: "authoritative-snapshot",
entries: [],
pageInfo: { hasMore: false, nextCursor: null, prevCursor: null },
});
await refresh;
releaseAgent(cachedAgent);
await load;
expect(useSessionStore.getState().sessions[serverId]?.agents.has(cachedAgent.id)).toBe(false);
directory.dispose();
});
it("uses the saved agent sequence while bounding the bootstrap page", async () => {
const serverId = "agent-list-sequence-page";
serverIds.add(serverId);
@@ -130,8 +799,15 @@ describe("DirectorySync session readiness", () => {
markAgentError: () => undefined,
},
{
readDirectoryCheckpoint: () => ({ agents: { generation: "generation", afterSeq: 12 } }),
writeDirectoryCheckpoint: () => undefined,
readAgent: async () => undefined,
readWorkspace: async () => undefined,
readDirectory: async () => ({
agents: new Map(),
workspaces: new Map(),
projects: new Map(),
checkpoint: { agents: { generation: "generation", afterSeq: 12 } },
}),
commitDirectory: () => undefined,
},
);
directory.connectionChanged({
@@ -216,27 +892,7 @@ describe("DirectorySync session readiness", () => {
serverIds.add(serverId);
const client = new FakeDirectoryClient();
const writes: unknown[] = [];
const directory = new DirectorySync(
serverId,
{
onAgentStoppedRunning: () => undefined,
markAgentLoading: () => undefined,
markAgentReady: () => undefined,
markAgentError: () => undefined,
},
{
readDirectoryCheckpoint: () => ({ projects: { generation: "generation", afterSeq: 4 } }),
writeDirectoryCheckpoint: (_serverId, checkpoint) => writes.push(checkpoint),
},
);
directory.connectionChanged({
client: client as unknown as DaemonClient,
status: "online",
source: { clientGeneration: 1, connectionEpoch: 1 },
});
const store = useSessionStore.getState();
store.initializeSession(serverId, client as unknown as DaemonClient, 1);
store.setProjects(serverId, [
const cachedProjects = [
normalizeProjectDescriptor({
projectId: "project-1",
projectDisplayName: "Old name",
@@ -249,7 +905,34 @@ describe("DirectorySync session readiness", () => {
projectRootPath: "/repo/two",
projectKind: "git",
}),
]);
];
const directory = new DirectorySync(
serverId,
{
onAgentStoppedRunning: () => undefined,
markAgentLoading: () => undefined,
markAgentReady: () => undefined,
markAgentError: () => undefined,
},
{
readAgent: async () => undefined,
readWorkspace: async () => undefined,
readDirectory: async () => ({
agents: new Map(),
workspaces: new Map(),
projects: new Map(cachedProjects.map((project) => [project.projectId, project])),
checkpoint: { projects: { generation: "generation", afterSeq: 4 } },
}),
commitDirectory: (_serverId, value) => writes.push(value.checkpoint),
},
);
directory.connectionChanged({
client: client as unknown as DaemonClient,
status: "online",
source: { clientGeneration: 1, connectionEpoch: 1 },
});
const store = useSessionStore.getState();
store.initializeSession(serverId, client as unknown as DaemonClient, 1);
store.updateSessionServerInfo(serverId, {
serverId,
hostname: null,
+281 -42
View File
@@ -31,6 +31,12 @@ import {
type DirectoryTransaction,
} from "./transaction";
import { workspaceLabels } from "@/workspace-labels";
import type {
CachedDirectory,
CachedWorkspace,
DirectoryCheckpoint,
DirectoryCursor,
} from "@/runtime/replica-cache";
const PAGE_LIMIT = 200;
const AGENT_SORT: NonNullable<FetchAgentsOptions["sort"]> = [
@@ -63,17 +69,6 @@ interface AgentPageInfo {
afterCursor?: string | null;
}
interface DirectoryCursor {
generation: string;
afterSeq: number;
}
interface DirectoryCursors {
projects?: DirectoryCursor;
workspaces?: DirectoryCursor;
agents?: DirectoryCursor;
}
export interface DirectoryConnection {
client: DaemonClient | null;
status: "online" | "offline";
@@ -81,8 +76,18 @@ export interface DirectoryConnection {
}
export interface DirectoryCheckpointStorage {
readDirectoryCheckpoint(serverId: string): unknown;
writeDirectoryCheckpoint(serverId: string, checkpoint: unknown): void;
readAgent(serverId: string, agentId: string): Promise<Agent | undefined>;
readWorkspace(serverId: string, workspaceId: string): Promise<CachedWorkspace | undefined>;
readDirectory(serverId: string): Promise<CachedDirectory>;
commitDirectory(
serverId: string,
directory: {
agents: Map<string, Agent>;
workspaces: Map<string, WorkspaceDescriptor>;
projects: Map<string, ProjectDescriptor>;
checkpoint?: DirectoryCheckpoint;
},
): void;
}
export interface RefreshAgentDirectoryInput {
@@ -114,6 +119,15 @@ export class DirectorySync {
};
private unsubscribe: (() => void) | null = null;
private readonly abortSessionWaits = new Set<() => void>();
private cacheLoad: Promise<void> | null = null;
private cacheAccepted = false;
private revision = 0;
private workspaceRevision = 0;
private readonly routeDemandIds = new Set<string>();
private readonly fullDemandSources = new Set<object>();
private demandRefresh: Promise<void> | null = null;
private satisfiedDemandSource: DirectorySourceToken | null = null;
private cursors: DirectoryCheckpoint = {};
constructor(
private readonly serverId: string,
@@ -148,60 +162,222 @@ export class DirectorySync {
if (!connection.client || connection.status !== "online") return true;
const client = connection.client;
const source = connection.source;
this.workspaceTransactions.begin(source, () => ({
workspaces: new Map(),
projects: new Map(),
}));
const subscriptions = [
client.on("agent_update", (message) => {
if (message.type !== "agent_update" || !this.isCurrent(client, source)) return;
this.revision += 1;
const recorded = this.agentTransactions.record(source, message.payload);
if (!recorded) {
this.agents.applyDelta(message.payload);
this.noteLiveCursor("agents", message.payload);
this.persistDirectory();
}
}),
client.on("workspace_update", (message) => {
if (message.type !== "workspace_update" || !this.isCurrent(client, source)) return;
this.revision += 1;
this.workspaceRevision += 1;
const recorded = this.workspaceTransactions.record(source, message.payload);
if (!recorded) {
this.workspaces.applyDelta(message.payload);
this.noteLiveCursor("workspaces", message.payload);
this.persistDirectory();
}
}),
client.on("project.update", (message) => {
if (message.type !== "project.update" || !this.isCurrent(client, source)) return;
this.revision += 1;
this.workspaceRevision += 1;
const recorded = this.workspaceTransactions.record(source, message.payload);
if (!recorded) {
this.workspaces.applyDelta(message.payload);
this.noteLiveCursor("projects", message.payload);
this.persistDirectory();
}
}),
client.on("script_status_update", (message) => {
if (message.type !== "script_status_update" || !this.isCurrent(client, source)) return;
this.revision += 1;
this.workspaceRevision += 1;
const delta: WorkspaceDirectoryDelta = {
kind: "script_status",
update: message.payload,
};
const recorded = this.workspaceTransactions.record(source, delta);
if (!recorded) {
this.workspaces.applyDelta(delta);
this.persistDirectory();
}
}),
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();
}
}),
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.unsubscribe = () => {
for (const unsubscribe of subscriptions) unsubscribe();
};
if (this.hasDemand()) void this.requestDemandRefresh().catch(() => undefined);
return true;
}
setDemand(source: object, demanded: boolean): void {
const wasDemanded = this.fullDemandSources.size > 0;
if (demanded) this.fullDemandSources.add(source);
else this.fullDemandSources.delete(source);
if (!wasDemanded && this.fullDemandSources.size > 0) {
void this.loadCachedDirectory().catch(() => undefined);
if (this.getOnlineConnection()) void this.requestDemandRefresh().catch(() => undefined);
}
}
setAgentRouteDemand(agentIds: readonly string[]): void {
const next = new Set(agentIds);
if (
next.size === this.routeDemandIds.size &&
[...next].every((agentId) => this.routeDemandIds.has(agentId))
) {
return;
}
this.routeDemandIds.clear();
for (const agentId of next) this.routeDemandIds.add(agentId);
if (this.routeDemandIds.size > 0 && this.getOnlineConnection()) {
void this.requestDemandRefresh().catch(() => undefined);
}
}
dispose(): void {
this.flushAbortedTransactions();
this.abortPendingSessionWaits();
this.unsubscribe?.();
this.unsubscribe = null;
this.fullDemandSources.clear();
this.routeDemandIds.clear();
workspaceLabels.disconnect(this.serverId);
}
private hasDemand(): boolean {
return this.fullDemandSources.size > 0 || this.routeDemandIds.size > 0;
}
private requestDemandRefresh(force = false): Promise<void> {
if (this.demandRefresh) return this.demandRefresh;
if (!this.hasDemand()) return Promise.resolve();
if (!this.getOnlineConnection()) {
return this.fullDemandSources.size > 0 ? this.loadCachedDirectory() : Promise.resolve();
}
const source = this.connection.source;
if (
!force &&
this.satisfiedDemandSource?.clientGeneration === source.clientGeneration &&
this.satisfiedDemandSource.connectionEpoch === source.connectionEpoch
) {
return Promise.resolve();
}
const refresh =
this.fullDemandSources.size > 0
? this.refreshAll()
: Promise.all([
this.refreshAgentsInternal({ subscribe: {} }, false),
this.refreshWorkspacesInternal({ subscribe: true }, false),
]).then(() => undefined);
this.demandRefresh = refresh
.then(() => {
this.satisfiedDemandSource = source;
return undefined;
})
.finally(() => {
this.demandRefresh = null;
const current = this.connection.source;
if (
this.hasDemand() &&
(current.clientGeneration !== source.clientGeneration ||
current.connectionEpoch !== source.connectionEpoch)
) {
void this.requestDemandRefresh().catch(() => undefined);
}
});
return this.demandRefresh;
}
refreshDemand(): Promise<void> {
return this.requestDemandRefresh(true);
}
async loadCachedAgent(agentId: string): Promise<void> {
if (!this.checkpoints) return;
if (useSessionStore.getState().sessions[this.serverId]?.agents.has(agentId)) return;
const token = this.agents.captureCache(agentId);
const agent = await this.checkpoints.readAgent(this.serverId, agentId);
if (!agent) return;
const session = useSessionStore.getState().sessions[this.serverId];
if (!session || session.agents.has(agentId)) return;
this.agents.commitCachedAgent(token, agent);
}
async prepareAgentRoute(agentId: string): Promise<void> {
await this.loadCachedAgent(agentId);
const agent = useSessionStore.getState().sessions[this.serverId]?.agents.get(agentId);
if (agent?.workspaceId) await this.loadCachedWorkspace(agent.workspaceId);
}
async prepareWorkspaceRoute(workspaceId: string): Promise<void> {
await this.loadCachedWorkspace(workspaceId);
}
private async loadCachedWorkspace(workspaceId: string): Promise<void> {
if (!this.checkpoints) return;
if (useSessionStore.getState().sessions[this.serverId]?.workspaces.has(workspaceId)) return;
const revision = this.workspaceRevision;
const cached = await this.checkpoints.readWorkspace(this.serverId, workspaceId);
if (!cached) return;
if (this.workspaceRevision !== revision) return;
const session = useSessionStore.getState().sessions[this.serverId];
if (!session) return;
this.workspaces.commitCachedWorkspace(cached.workspace, cached.project);
}
private loadCachedDirectory(): Promise<void> {
const checkpoints = this.checkpoints;
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;
}
this.agents.commitCached(cached.agents);
this.workspaces.commitCached(cached);
this.cursors = cached.checkpoint ?? {};
this.cacheAccepted = true;
})();
return this.cacheLoad;
}
async fetchTimeline(
agentId: string,
request: Parameters<DaemonClient["fetchAgentTimeline"]>[1],
@@ -209,14 +385,36 @@ export class DirectorySync {
const { client } = this.requireOnline();
const token = this.agents.captureTimeline(agentId);
const page = await fetchAgentTimelineOnce(client, agentId, request);
if (page.agent) this.agents.submitTimelineAgent(token, page.agent);
if (page.agent && this.agents.submitTimelineAgent(token, page.agent)) {
this.revision += 1;
this.persistDirectory();
}
return page;
}
async refreshAgents(
input: RefreshAgentDirectoryInput = {},
): Promise<RefreshAgentDirectoryResult> {
const { client, source } = this.requireOnline();
return this.refreshAgentsInternal(input, true);
}
private async refreshAgentsInternal(
input: RefreshAgentDirectoryInput,
loadDirectoryCache: boolean,
): Promise<RefreshAgentDirectoryResult> {
if (loadDirectoryCache) {
this.primeAgentTransactionForCacheLoad();
await this.loadCachedDirectory();
}
const onlineConnection = this.getOnlineConnection();
if (!onlineConnection) {
this.callbacks.markAgentReady();
return {
agents: new Map(useSessionStore.getState().sessions[this.serverId]?.agents),
subscriptionId: null,
};
}
const { client, source } = onlineConnection;
const transaction = this.agentTransactions.begin(source, () => ({
entries: [],
subscriptionId: null,
@@ -270,8 +468,10 @@ export class DirectorySync {
: delta,
)
: completion.deltas;
this.revision += 1;
const agents = this.commitAgentSnapshot(completion.snapshot, deltas);
this.persistAgentCursors(completion.snapshot, completion.deltas);
this.persistDirectory();
this.callbacks.markAgentReady();
return { agents, subscriptionId: completion.snapshot.subscriptionId };
} catch (error) {
@@ -285,7 +485,17 @@ export class DirectorySync {
}
async refreshWorkspaces(input?: { subscribe?: boolean }): Promise<void> {
const { client, source } = this.requireOnline();
return this.refreshWorkspacesInternal(input, true);
}
private async refreshWorkspacesInternal(
input: { subscribe?: boolean } | undefined,
loadDirectoryCache: boolean,
): Promise<void> {
if (loadDirectoryCache) await this.loadCachedDirectory();
const onlineConnection = this.getOnlineConnection();
if (!onlineConnection) return;
const { client, source } = onlineConnection;
const transaction = this.workspaceTransactions.begin(source, () => ({
workspaces: new Map(useSessionStore.getState().sessions[this.serverId]?.workspaces),
projects: new Map(useSessionStore.getState().sessions[this.serverId]?.projects),
@@ -337,7 +547,9 @@ export class DirectorySync {
...(supportsDirectorySync ? { sync: this.readCursors().workspaces ?? {} } : {}),
});
this.assertWorkspaceTransactionCurrent(client, source, transaction);
if (payload.sync?.mode === "snapshot") transaction.snapshot.workspaces.clear();
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);
@@ -371,8 +583,11 @@ export class DirectorySync {
}
async refreshAll(): Promise<void> {
await Promise.all([this.refreshAgents(), this.refreshWorkspaces({ subscribe: true })]);
await this.connectWorkspaceLabels();
await Promise.all([
this.refreshAgents({ subscribe: {} }),
this.refreshWorkspaces({ subscribe: true }),
]);
if (this.getOnlineConnection()) await this.connectWorkspaceLabels();
}
async connectWorkspaceLabels(): Promise<void> {
@@ -436,12 +651,29 @@ export class DirectorySync {
}
private requireOnline(): { client: DaemonClient; source: DirectorySourceToken } {
if (!this.connection.client || this.connection.status !== "online") {
const connection = this.getOnlineConnection();
if (!connection) {
throw new Error(`Host ${this.serverId} is not connected`);
}
return connection;
}
private getOnlineConnection(): { client: DaemonClient; source: DirectorySourceToken } | null {
if (!this.connection.client || this.connection.status !== "online") return null;
return { client: this.connection.client, source: this.connection.source };
}
private primeAgentTransactionForCacheLoad(): void {
const connection = this.getOnlineConnection();
if (!connection) return;
this.agentTransactions.begin(connection.source, () => ({
entries: [],
subscriptionId: null,
legacy: false,
syncRemovals: [],
}));
}
private supportsDirectorySync(): boolean {
return (
useSessionStore.getState().sessions[this.serverId]?.serverInfo?.features?.directorySync ===
@@ -476,7 +708,7 @@ export class DirectorySync {
supportsDirectorySync ? { sync: this.readCursors().projects ?? {} } : undefined,
);
this.assertWorkspaceTransactionCurrent(client, source, transaction);
if (payload.sync?.mode === "snapshot") transaction.snapshot.projects.clear();
if (payload.sync?.mode !== "changes") transaction.snapshot.projects.clear();
for (const entry of payload.projects) {
const project = normalizeProjectDescriptor(entry);
transaction.snapshot.projects.set(project.projectId, project);
@@ -504,14 +736,30 @@ export class DirectorySync {
if (completion.kind === "stale") {
throw new DirectoryRefreshSupersededError("workspace completion was superseded");
}
this.revision += 1;
this.workspaceRevision += 1;
this.workspaces.commitSnapshot(completion.snapshot, completion.deltas);
for (const [entity, cursor] of Object.entries(completion.snapshot.syncCursors ?? {})) {
if (cursor) this.writeCursor(entity as "projects" | "workspaces", cursor);
}
for (const delta of completion.deltas) {
if (delta.kind === "script_status") continue;
const entity = "projectId" in delta || "project" in delta ? "projects" : "workspaces";
this.noteLiveCursor(entity, delta);
}
this.persistDirectory();
}
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 recordAgentSync(
@@ -525,7 +773,7 @@ export class DirectorySync {
}
private noteLiveCursor(
entity: keyof DirectoryCursors,
entity: keyof DirectoryCheckpoint,
payload: { generation?: string; seq?: number },
): void {
if (!payload.generation || payload.seq === undefined) return;
@@ -535,27 +783,18 @@ export class DirectorySync {
});
}
private readCursors(): DirectoryCursors {
const value = this.checkpoints?.readDirectoryCheckpoint(this.serverId);
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
const cursors: DirectoryCursors = {};
for (const entity of ["projects", "workspaces", "agents"] as const) {
const cursor = (value as Record<string, unknown>)[entity];
if (!cursor || typeof cursor !== "object" || Array.isArray(cursor)) continue;
const { generation, afterSeq } = cursor as Record<string, unknown>;
if (typeof generation === "string" && typeof afterSeq === "number" && afterSeq >= 0) {
cursors[entity] = { generation, afterSeq };
}
}
return cursors;
private readCursors(): DirectoryCheckpoint {
return this.cursors;
}
private writeCursor(entity: keyof DirectoryCursors, cursor: DirectoryCursor): void {
if (!this.checkpoints) return;
private writeCursor(
entity: keyof DirectoryCheckpoint,
cursor: { generation: string; afterSeq: number },
): void {
const current = this.readCursors();
const previous = current[entity];
if (previous?.generation === cursor.generation && previous.afterSeq >= cursor.afterSeq) return;
this.checkpoints.writeDirectoryCheckpoint(this.serverId, { ...current, [entity]: cursor });
this.cursors = { ...current, [entity]: cursor };
}
private isCurrent(client: DaemonClient, source: DirectorySourceToken): boolean {
@@ -1,4 +1,8 @@
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
import equal from "fast-deep-equal";
import type {
ScriptStatusUpdateMessage,
SessionOutboundMessage,
} from "@getpaseo/protocol/messages";
import {
normalizeProjectDescriptor,
normalizeWorkspaceDescriptor,
@@ -11,11 +15,11 @@ import {
clearWorkspaceArchivePending,
shouldSuppressWorkspaceForLocalArchive,
} from "@/contexts/session-workspace-upserts";
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
export type WorkspaceDirectoryDelta = Extract<
SessionOutboundMessage,
{ type: "workspace_update" | "project.update" }
>["payload"];
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 {
@@ -26,6 +30,25 @@ export interface WorkspaceDirectorySnapshot {
>;
}
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,
@@ -61,6 +84,24 @@ export class WorkspaceDirectoryReplica {
this.commit(state, delta.kind === "remove" && "id" in delta ? [delta.id] : []);
}
commitCached(input: {
workspaces: Map<string, WorkspaceDescriptor>;
projects: Map<string, ProjectDescriptor>;
}): void {
this.commit(input, []);
useSessionStore.getState().setHasWorkspaceDirectorySnapshot(this.serverId, true);
}
commitCachedWorkspace(
workspace: WorkspaceDescriptor,
project: ProjectDescriptor | undefined,
): void {
const snapshot = this.read();
snapshot.workspaces.set(workspace.id, workspace);
if (project) snapshot.projects.set(project.projectId, project);
this.commit(snapshot, []);
}
commitSnapshot(
snapshot: WorkspaceDirectorySnapshot,
deltas: readonly WorkspaceDirectoryDelta[],
@@ -84,7 +125,7 @@ export class WorkspaceDirectoryReplica {
snapshot: WorkspaceDirectorySnapshot,
deltas: readonly WorkspaceDirectoryDelta[],
): WorkspaceDirectorySnapshot {
const workspaces = new Map(snapshot.workspaces);
let workspaces = new Map(snapshot.workspaces);
const projects = new Map(snapshot.projects);
for (const [workspaceId, workspace] of workspaces) {
if (shouldSuppressWorkspaceForLocalArchive({ serverId: this.serverId, workspace })) {
@@ -92,6 +133,10 @@ export class WorkspaceDirectoryReplica {
}
}
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;
+128 -78
View File
@@ -21,7 +21,6 @@ import {
type HostRuntimeControllerDeps,
type HostRuntimeStorage,
} from "./host-runtime";
import { ReplicaCache } from "./replica-cache";
import type { ReplicaRow, ReplicaRowStore } from "./replica-cache/row-store";
class FakeDaemonClient {
@@ -115,6 +114,10 @@ class FakeDaemonClient {
return this.state;
}
getLastServerInfoMessage(): null {
return null;
}
subscribeConnectionStatus(listener: (status: ConnectionState) => void): () => void {
this.listeners.add(listener);
listener(this.state);
@@ -255,11 +258,11 @@ class Deferred<T> {
}
}
async function waitForDirectoryReady(store: HostRuntimeStore, serverId: string): Promise<void> {
if (store.getSnapshot(serverId)?.agentDirectoryStatus === "ready") return;
async function waitForHostOnline(store: HostRuntimeStore, serverId: string): Promise<void> {
if (store.getSnapshot(serverId)?.connectionStatus === "online") return;
await new Promise<void>((resolve) => {
const unsubscribe = store.subscribe(serverId, () => {
if (store.getSnapshot(serverId)?.agentDirectoryStatus !== "ready") return;
if (store.getSnapshot(serverId)?.connectionStatus !== "online") return;
unsubscribe();
resolve();
});
@@ -458,6 +461,16 @@ function createMemoryReplicaRowStore(): ReplicaRowStore {
`${row.serverId}:${row.kind}:${row.id}`;
return {
open: async () => undefined,
read: async (serverId, kinds, ids) => {
const acceptedKinds = new Set(kinds);
const acceptedIds = ids ? new Set(ids) : null;
return [...rows.values()].filter(
(row) =>
row.serverId === serverId &&
acceptedKinds.has(row.kind) &&
(!acceptedIds || acceptedIds.has(row.id)),
);
},
readAll: async () => {
const hosts = new Map<string, ReplicaRow[]>();
for (const row of rows.values()) {
@@ -1470,46 +1483,21 @@ describe("HostRuntimeStore", () => {
expect(store.getHosts()).toEqual([]);
});
it("restores the display replica before declaring the host registry loaded", async () => {
it("loads the host registry without scanning or installing replica rows", async () => {
const host = makeHost();
const storage = createMemoryHostRuntimeStorage();
const replicaRowStore = createMemoryReplicaRowStore();
const backingStore = createMemoryReplicaRowStore();
let fullScans = 0;
const replicaRowStore: ReplicaRowStore = {
...backingStore,
readAll: async () => {
fullScans += 1;
return backingStore.readAll();
},
};
await storage.setItem("@paseo:daemon-registry", JSON.stringify([host]));
await storage.setItem("@paseo:e2e", "1");
const cachedAgent = replicaAgent(
makeFetchAgentsEntry({
id: "cached-agent",
cwd: "/repo/paseo",
updatedAt: "2026-07-18T08:00:00.000Z",
title: "Cached agent",
}).agent,
host.serverId,
);
const session = useSessionStore.getState();
session.initializeSession(host.serverId, null);
session.setAgents(host.serverId, new Map([[cachedAgent.id, cachedAgent]]));
session.setFocusedAgentId(host.serverId, cachedAgent.id);
session.setAgentStreamTail(
host.serverId,
new Map([
[
cachedAgent.id,
[
{
kind: "assistant_message",
id: "cached-message",
text: "Already painted",
timestamp: new Date("2026-07-18T08:01:00.000Z"),
},
],
],
]),
);
const cache = new ReplicaCache(replicaRowStore, { clearLegacyCache: async () => undefined });
cache.setHosts([host.serverId]);
await cache.flush();
session.clearSession(host.serverId);
const store = new HostRuntimeStore({
storage,
@@ -1528,13 +1516,14 @@ describe("HostRuntimeStore", () => {
store.boot();
await registryLoaded;
const restored = useSessionStore.getState().sessions[host.serverId];
expect(restored?.agents.get(cachedAgent.id)?.title).toBe("Cached agent");
expect(restored?.agentStreamTail.get(cachedAgent.id)?.[0]).toMatchObject({
text: "Already painted",
timestamp: new Date("2026-07-18T08:01:00.000Z"),
expect(fullScans).toBe(0);
expect(useSessionStore.getState().sessions[host.serverId]).toMatchObject({
client: null,
hasHydratedAgents: false,
hasHydratedWorkspaces: false,
});
expect(restored?.hasHydratedAgents).toBe(false);
expect(useSessionStore.getState().sessions[host.serverId]?.agents.size).toBe(0);
expect(useSessionStore.getState().sessions[host.serverId]?.workspaces.size).toBe(0);
store.syncHosts([]);
session.clearSession(host.serverId);
@@ -1753,8 +1742,7 @@ describe("HostRuntimeStore", () => {
});
store.syncHosts([host]);
await fakeClient.waitForFetches(1);
await waitForDirectoryReady(store, host.serverId);
await waitForHostOnline(store, host.serverId);
await vi.advanceTimersByTimeAsync(1_500);
const outageStartedAt = Date.now();
@@ -1771,7 +1759,7 @@ describe("HostRuntimeStore", () => {
store.syncHosts([]);
});
it("bootstraps agent directory subscription when host transitions online", async () => {
it("loads an agent directory only after a consumer requests it", async () => {
const host = makeHost({
connections: [
{
@@ -1798,16 +1786,25 @@ describe("HostRuntimeStore", () => {
useSessionStore
.getState()
.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
useSessionStore.getState().updateSessionServerInfo(host.serverId, {
serverId: host.serverId,
hostname: null,
version: "test",
features: { workspaceMultiplicity: false },
});
store.syncHosts([host]);
await waitForHostOnline(store, host.serverId);
expect(fakeClient.fetchAgentsCalls).toHaveLength(0);
const releaseDemand = store.acquireDirectoryDemand(host.serverId);
await fakeClient.waitForFetches(1);
await waitForDirectoryReady(store, host.serverId);
await vi.waitFor(() => {
expect(store.getSnapshot(host.serverId)?.agentDirectoryStatus).toBe("ready");
});
expect(fakeClient.fetchAgentsCalls).toHaveLength(1);
expect(fakeClient.fetchAgentsCalls[0]).toEqual({
scope: "active",
sort: [{ key: "updated_at", direction: "desc" }],
subscribe: { subscriptionId: "app:srv_test" },
subscribe: {},
page: { limit: 200 },
});
@@ -1815,13 +1812,18 @@ describe("HostRuntimeStore", () => {
expect(snapshot?.agentDirectoryStatus).toBe("ready");
expect(snapshot?.hasEverLoadedAgentDirectory).toBe(true);
fakeClient.setConnectionState({ status: "disconnected", reason: "transport closed" });
fakeClient.setConnectionState({ status: "connected" });
await fakeClient.waitForFetches(2);
expect(fakeClient.fetchAgentsCalls[1]).toMatchObject({ subscribe: {} });
await store.refreshAgentDirectory({ serverId: host.serverId });
expect(fakeClient.fetchAgentsCalls[1]).toEqual({
scope: "active",
expect(fakeClient.fetchAgentsCalls[2]).toEqual({
sort: [{ key: "updated_at", direction: "desc" }],
page: { limit: 200 },
});
releaseDemand();
store.syncHosts([]);
useSessionStore.getState().clearSession(host.serverId);
});
@@ -1852,9 +1854,14 @@ describe("HostRuntimeStore", () => {
});
store.syncHosts([host]);
await waitForHostOnline(store, host.serverId);
const load = store.refreshAgentDirectory({
serverId: host.serverId,
subscribe: { subscriptionId: "app:srv_no_session" },
page: { limit: 200 },
});
await fakeClient.waitForFetches(1);
await waitForDirectoryReady(store, host.serverId);
await load;
const session = useSessionStore.getState().sessions[host.serverId];
expect(session?.client).toBe(fakeClient);
@@ -1917,9 +1924,14 @@ describe("HostRuntimeStore", () => {
version: "0.1.96",
});
store.syncHosts([host]);
await waitForHostOnline(store, host.serverId);
const load = store.refreshAgentDirectory({
serverId: host.serverId,
subscribe: { subscriptionId: "app:srv_legacy_workspace_daemon" },
page: { limit: 200 },
});
await fakeClient.waitForFetches(1);
await waitForDirectoryReady(store, host.serverId);
await load;
expect(fakeClient.fetchAgentsCalls).toEqual([
{
@@ -2010,6 +2022,12 @@ describe("HostRuntimeStore", () => {
]),
);
store.syncHosts([host]);
await waitForHostOnline(store, host.serverId);
const load = store.refreshAgentDirectory({
serverId: host.serverId,
subscribe: { subscriptionId: `app:${host.serverId}` },
page: { limit: 200 },
});
await fakeClient.waitForFetches(2);
fakeClient.agentUpdate({
kind: "upsert",
@@ -2017,7 +2035,7 @@ describe("HostRuntimeStore", () => {
project: bufferedAgent.project,
});
pageTwo.resolve(makeFetchAgentsPayload({ entries: [] }));
await waitForDirectoryReady(store, host.serverId);
await load;
await fakeClient.waitForSentMessages(2);
expect(fakeClient.sentAgentMessages.map(([agentId, text]) => [agentId, text])).toEqual([
@@ -2094,9 +2112,14 @@ describe("HostRuntimeStore", () => {
.getState()
.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
store.syncHosts([host]);
await waitForHostOnline(store, host.serverId);
const load = store.refreshAgentDirectory({
serverId: host.serverId,
subscribe: { subscriptionId: "app:srv_paged" },
page: { limit: 200 },
});
await fakeClient.waitForFetches(2);
await waitForDirectoryReady(store, host.serverId);
await load;
expect(fakeClient.fetchAgentsCalls).toHaveLength(2);
expect(fakeClient.fetchAgentsCalls[0]).toEqual({
@@ -2179,6 +2202,12 @@ describe("HostRuntimeStore", () => {
new Map([[recoveredAgent.agent.id, { epoch: "epoch", startSeq: 10, endSeq: 20 }]]),
);
store.syncHosts([host]);
await waitForHostOnline(store, host.serverId);
const load = store.refreshAgentDirectory({
serverId: host.serverId,
subscribe: { subscriptionId: "app:srv_paged_delta" },
page: { limit: 200 },
});
await fakeClient.waitForFetches(2);
fakeClient.agentUpdate({
@@ -2202,7 +2231,7 @@ describe("HostRuntimeStore", () => {
],
}),
);
await waitForDirectoryReady(store, host.serverId);
await load;
expect(
Array.from(useSessionStore.getState().sessions[host.serverId]?.agents.values() ?? []).map(
@@ -2264,6 +2293,11 @@ describe("HostRuntimeStore", () => {
store.syncHosts([host]);
await fakeClient.waitForAgentUpdates();
const load = store.refreshAgentDirectory({
serverId: host.serverId,
subscribe: { subscriptionId: `app:${host.serverId}` },
page: { limit: 200 },
});
fakeClient.agentUpdate({
kind: "upsert",
agent: { ...snapshotEntry.agent, title: "before-session" },
@@ -2275,7 +2309,7 @@ describe("HostRuntimeStore", () => {
version: "test",
});
await fakeClient.waitForFetches(1);
await waitForDirectoryReady(store, host.serverId);
await load;
expect(
useSessionStore.getState().sessions[host.serverId]?.agents.get("agent-pre-session")?.title,
@@ -2308,8 +2342,8 @@ describe("HostRuntimeStore", () => {
.getState()
.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
store.syncHosts([host]);
await fakeClient.waitForFetches(1);
await waitForDirectoryReady(store, host.serverId);
await waitForHostOnline(store, host.serverId);
await store.refreshAgentDirectory({ serverId: host.serverId });
const olderPage = new Deferred<Awaited<ReturnType<DaemonClient["fetchAgents"]>>>();
fakeClient.fetchAgentsResponses.push(olderPage.promise);
const olderRefresh = store.refreshAgentDirectory({ serverId: host.serverId });
@@ -2376,8 +2410,8 @@ describe("HostRuntimeStore", () => {
.getState()
.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
store.syncHosts([host]);
await fakeClient.waitForFetches(1);
await waitForDirectoryReady(store, host.serverId);
await waitForHostOnline(store, host.serverId);
await store.refreshAgentDirectory({ serverId: host.serverId });
const olderPage = new Deferred<Awaited<ReturnType<DaemonClient["fetchAgents"]>>>();
fakeClient.fetchAgentsResponses.push(olderPage.promise);
@@ -2435,8 +2469,8 @@ describe("HostRuntimeStore", () => {
const sessionStore = useSessionStore.getState();
sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
store.syncHosts([host]);
await fakeClient.waitForFetches(1);
await waitForDirectoryReady(store, host.serverId);
await waitForHostOnline(store, host.serverId);
await store.refreshAgentDirectory({ serverId: host.serverId });
const stalePage = new Deferred<Awaited<ReturnType<DaemonClient["fetchAgents"]>>>();
fakeClient.fetchAgentsResponses.push(stalePage.promise);
@@ -2533,6 +2567,8 @@ describe("HostRuntimeStore", () => {
]),
);
store.syncHosts([host]);
await waitForHostOnline(store, host.serverId);
const load = store.refreshAgentDirectory({ serverId: host.serverId });
await fakeClient.waitForFetches(2);
fakeClient.agentUpdate({
kind: "upsert",
@@ -2540,7 +2576,7 @@ describe("HostRuntimeStore", () => {
project: bufferedAgent.project,
});
pageTwo.resolve(makeFetchAgentsPayload({ entries: [] }));
await waitForDirectoryReady(store, host.serverId);
await load;
await fakeClient.waitForSentMessages(2);
expect(fakeClient.sentAgentMessages.map(([agentId, text]) => [agentId, text])).toEqual([
@@ -2833,6 +2869,8 @@ describe("HostRuntimeStore", () => {
isArchiving: true,
});
store.syncHosts([host]);
await waitForHostOnline(store, host.serverId);
const load = store.refreshAgentDirectory({ serverId: host.serverId });
await fakeClient.waitForFetches(2);
fakeClient.agentUpdate({
kind: "upsert",
@@ -2848,7 +2886,7 @@ describe("HostRuntimeStore", () => {
project: base.project,
});
pageTwo.resolve(makeFetchAgentsPayload({ entries: [] }));
await waitForDirectoryReady(store, host.serverId);
await load;
sessionStore.flushAgentLastActivity();
const state = useSessionStore.getState();
@@ -2881,7 +2919,7 @@ describe("HostRuntimeStore", () => {
useSessionStore.getState().clearSession(host.serverId);
});
it("re-subscribes agent directory updates after reconnect", async () => {
it("re-subscribes agent directory updates when the surface requests after reconnect", async () => {
const host = makeHost({
serverId: "srv_resubscribe",
connections: [
@@ -2910,9 +2948,13 @@ describe("HostRuntimeStore", () => {
.getState()
.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
store.syncHosts([host]);
await waitForHostOnline(store, host.serverId);
await store.refreshAgentDirectory({
serverId: host.serverId,
subscribe: { subscriptionId: "app:srv_resubscribe" },
page: { limit: 200 },
});
await fakeClient.waitForFetches(1);
await waitForDirectoryReady(store, host.serverId);
fakeClient.setConnectionState({ status: "connected" });
await Promise.resolve();
@@ -2924,9 +2966,13 @@ describe("HostRuntimeStore", () => {
reason: "client_closed",
});
fakeClient.setConnectionState({ status: "connected" });
await waitForHostOnline(store, host.serverId);
await store.refreshAgentDirectory({
serverId: host.serverId,
subscribe: { subscriptionId: "app:srv_resubscribe" },
page: { limit: 200 },
});
await fakeClient.waitForFetches(2);
await waitForDirectoryReady(store, host.serverId);
expect(fakeClient.fetchAgentsCalls).toEqual([
{
@@ -3010,9 +3056,13 @@ describe("HostRuntimeStore", () => {
});
store.syncHosts([host]);
await waitForHostOnline(store, host.serverId);
await store.refreshAgentDirectory({
serverId: host.serverId,
subscribe: { subscriptionId: "app:srv_archived_rehydrate" },
page: { limit: 200 },
});
await fakeClient.waitForFetches(1);
await waitForDirectoryReady(store, host.serverId);
expect(useSessionStore.getState().sessions[host.serverId]?.agents.has("agent-archived")).toBe(
false,
+89 -92
View File
@@ -62,6 +62,13 @@ import { DirectorySync, type RefreshAgentDirectoryResult } from "@/runtime/direc
import { ReplicaCache } from "@/runtime/replica-cache";
import type { ReplicaRowStore } from "@/runtime/replica-cache/row-store";
import { createReplicaRowStore } from "@/runtime/replica-cache/row-store-factory";
import {
createTimelineReplica,
createViewedTimelineOwner,
type TimelineReplica,
type ViewedTimelineOwner,
type ViewedTimelineOwnerPorts,
} from "@/timeline/viewed-timeline-sync";
import { projectIconCache } from "@/projects/icon-cache";
import { nativePerformanceTrace } from "@/performance/native-trace";
import { revokePushNotifications } from "@/push-notifications";
@@ -187,7 +194,6 @@ const PROBE_MAX_BACKOFF_MS = 30_000;
const PROBE_INACTIVE_WHILE_ONLINE_MS = 120_000;
const ADAPTIVE_SWITCH_THRESHOLD_MS = 40;
const ADAPTIVE_SWITCH_CONSECUTIVE_PROBES = 3;
const DEFAULT_AGENT_DIRECTORY_PAGE_LIMIT = 200;
const CONFIGURED_OVERRIDE_BOOTSTRAP_RETRY_MS = 1_000;
function toActiveConnection(connection: HostConnection): ActiveConnection {
@@ -1362,9 +1368,9 @@ export class HostRuntimeStore {
private deps: HostRuntimeControllerDeps;
private lastConnectionStatusByServer = new Map<string, HostRuntimeConnectionStatus>();
private connectionStatusStartedAtByServer = new Map<string, number>();
private directoryBootstrapInFlight = new Map<string, Promise<void>>();
private queuedAgentDrainInFlight = new Set<string>();
private directorySyncByServer = new Map<string, DirectorySync>();
private timelineReplicaByServer = new Map<string, TimelineReplica>();
private configuredOverrideBootstrapInFlight: Promise<void> | null = null;
private bootPromise: Promise<void> | null = null;
private storage: HostRuntimeStorage;
@@ -1393,10 +1399,6 @@ export class HostRuntimeStore {
return this.hostRegistryStatus;
}
recordUserActivity(): void {
this.replicaCache.recordUserActivity();
}
subscribeHostList(listener: () => void): () => void {
this.hostListListeners.add(listener);
return () => {
@@ -1483,12 +1485,11 @@ export class HostRuntimeStore {
this.hosts = profiles;
this.replicaCache.setHosts(profiles.map((profile) => profile.serverId));
projectIconCache.setHosts(profiles.map((profile) => profile.serverId));
await Promise.all([this.replicaCache.restore(), projectIconCache.restore()]);
await projectIconCache.restore();
this.syncHosts(profiles);
} catch (error) {
console.error("[HostRuntime] Failed to load host registry from storage", error);
} finally {
this.replicaCache.start();
this.hostRegistryStatus = "ready";
this.emitHostList();
if (shouldPersistHosts) {
@@ -1618,11 +1619,11 @@ export class HostRuntimeStore {
rekeyMap(this.controllers, oldServerId, newServerId);
rekeyMap(this.lastConnectionStatusByServer, oldServerId, newServerId);
rekeyMap(this.connectionStatusStartedAtByServer, oldServerId, newServerId);
rekeyMap(this.directoryBootstrapInFlight, oldServerId, newServerId);
this.replicaCache.reconcileServerId(oldServerId, newServerId);
projectIconCache.reconcileServerId(oldServerId, newServerId);
this.directorySyncByServer.get(oldServerId)?.dispose();
this.directorySyncByServer.delete(oldServerId);
this.timelineReplicaByServer.delete(oldServerId);
const directory = new DirectorySync(
newServerId,
{
@@ -1634,6 +1635,14 @@ export class HostRuntimeStore {
this.replicaCache,
);
this.directorySyncByServer.set(newServerId, directory);
this.timelineReplicaByServer.set(
newServerId,
createTimelineReplica({
serverId: newServerId,
storage: this.replicaCache,
prepareAgent: (agentId) => directory.prepareAgentRoute(agentId),
}),
);
controller.adoptReconciledServerId(newServerId);
const snapshot = controller.getSnapshot();
this.clearHostReplica(oldServerId);
@@ -1994,9 +2003,9 @@ export class HostRuntimeStore {
this.controllers.delete(serverId);
this.lastConnectionStatusByServer.delete(serverId);
this.connectionStatusStartedAtByServer.delete(serverId);
this.directoryBootstrapInFlight.delete(serverId);
this.directorySyncByServer.get(serverId)?.dispose();
this.directorySyncByServer.delete(serverId);
this.timelineReplicaByServer.delete(serverId);
this.clearHostReplica(serverId);
void controller.stop();
this.emit(serverId);
@@ -2020,19 +2029,25 @@ export class HostRuntimeStore {
onReconcileServerId: (oldId, newId) => this.reconcileServerId(oldId, newId),
});
this.controllers.set(host.serverId, controller);
this.directorySyncByServer.set(
useSessionStore.getState().initializeSession(host.serverId, null);
const directory = new DirectorySync(
host.serverId,
new DirectorySync(
host.serverId,
{
onAgentStoppedRunning: (agentId) =>
this.drainQueuedAgentMessage(host.serverId, agentId),
markAgentLoading: () => controller.markAgentDirectorySyncLoading(),
markAgentReady: () => controller.markAgentDirectorySyncReady(),
markAgentError: (error) => controller.markAgentDirectorySyncError(error),
},
this.replicaCache,
),
{
onAgentStoppedRunning: (agentId) => this.drainQueuedAgentMessage(host.serverId, agentId),
markAgentLoading: () => controller.markAgentDirectorySyncLoading(),
markAgentReady: () => controller.markAgentDirectorySyncReady(),
markAgentError: (error) => controller.markAgentDirectorySyncError(error),
},
this.replicaCache,
);
this.directorySyncByServer.set(host.serverId, directory);
this.timelineReplicaByServer.set(
host.serverId,
createTimelineReplica({
serverId: host.serverId,
storage: this.replicaCache,
prepareAgent: (agentId) => directory.prepareAgentRoute(agentId),
}),
);
const initialSnapshot = controller.getSnapshot();
this.lastConnectionStatusByServer.set(host.serverId, initialSnapshot.connectionStatus);
@@ -2040,7 +2055,7 @@ export class HostRuntimeStore {
controller.subscribe(() => {
const snapshot = controller.getSnapshot();
this.syncSessionReplica(snapshot.serverId, snapshot);
this.maybeAutoBootstrapDirectories(snapshot.serverId);
this.syncDirectoryConnection(snapshot.serverId);
this.emit(snapshot.serverId);
});
void controller
@@ -2073,25 +2088,23 @@ export class HostRuntimeStore {
useWorkspaceSetupStore.getState().clearServer(serverId);
}
private maybeAutoBootstrapDirectories(serverId: string): void {
private syncDirectoryConnection(serverId: string): void {
const controller = this.controllers.get(serverId);
if (!controller) {
this.lastConnectionStatusByServer.delete(serverId);
this.connectionStatusStartedAtByServer.delete(serverId);
this.directoryBootstrapInFlight.delete(serverId);
return;
}
const snapshot = controller.getSnapshot();
const directory = this.directorySyncByServer.get(serverId);
const directorySourceChanged =
directory?.connectionChanged({
client: snapshot.client,
status: snapshot.connectionStatus === "online" ? "online" : "offline",
source: {
clientGeneration: snapshot.clientGeneration,
connectionEpoch: snapshot.connectionEpoch,
},
}) ?? false;
directory?.connectionChanged({
client: snapshot.client,
status: snapshot.connectionStatus === "online" ? "online" : "offline",
source: {
clientGeneration: snapshot.clientGeneration,
connectionEpoch: snapshot.connectionEpoch,
},
});
const previousStatus = this.lastConnectionStatusByServer.get(serverId);
const statusChanged = previousStatus !== snapshot.connectionStatus;
const isUnavailable =
@@ -2113,55 +2126,6 @@ export class HostRuntimeStore {
invalidateServerDataQueriesAfterReconnect({ queryClient, serverId });
void queryClient.invalidateQueries({ queryKey: schedulesQueryBaseKey });
}
// Runtime owns directory bootstrap policy, including reconnect and delayed
// session initialization races.
if (snapshot.connectionStatus !== "online") {
return;
}
if (!didTransitionOnline && snapshot.hasEverLoadedAgentDirectory) {
return;
}
if (this.directoryBootstrapInFlight.has(serverId) && !directorySourceChanged) {
return;
}
const bootstrap = Promise.resolve()
.then(() =>
Promise.all([
this.refreshAgentDirectory({
serverId,
subscribe: { subscriptionId: `app:${serverId}` },
page: { limit: DEFAULT_AGENT_DIRECTORY_PAGE_LIMIT },
}).catch((error) => {
console.error("[HostRuntime] agent directory bootstrap failed", {
serverId,
error: toErrorMessage(error),
});
}),
this.refreshWorkspaceDirectory({ serverId, subscribe: true }).catch((error) => {
console.error("[HostRuntime] workspace directory bootstrap failed", {
serverId,
error: toErrorMessage(error),
});
}),
]).then(() =>
directory?.connectWorkspaceLabels().catch((error) => {
console.error("[HostRuntime] workspace label bootstrap failed", {
serverId,
error: toErrorMessage(error),
});
}),
),
)
.finally(() => {
const inFlight = this.directoryBootstrapInFlight.get(serverId);
if (inFlight === bootstrap) {
this.directoryBootstrapInFlight.delete(serverId);
}
});
this.directoryBootstrapInFlight.set(serverId, bootstrap);
}
drainQueuedAgentMessage(serverId: string, agentId: string): void {
@@ -2300,7 +2264,33 @@ export class HostRuntimeStore {
async refreshDirectories(serverId: string): Promise<void> {
const directory = this.directorySyncByServer.get(serverId);
if (!directory) throw new Error(`Unknown host runtime for serverId ${serverId}`);
await directory.refreshAll();
await directory.refreshDemand();
}
acquireDirectoryDemand(serverId: string): () => void {
const directory = this.directorySyncByServer.get(serverId);
if (!directory) return () => undefined;
const source = {};
directory.setDemand(source, true);
return () => directory.setDemand(source, false);
}
async prepareAgentRoute(serverId: string, agentId: string): Promise<void> {
const directory = this.directorySyncByServer.get(serverId);
if (!directory) throw new Error(`Unknown host runtime for serverId ${serverId}`);
await directory.prepareAgentRoute(agentId);
}
async prepareWorkspaceRoute(serverId: string, workspaceId: string): Promise<void> {
const directory = this.directorySyncByServer.get(serverId);
if (!directory) throw new Error(`Unknown host runtime for serverId ${serverId}`);
await directory.prepareWorkspaceRoute(workspaceId);
}
async prepareAgentTimeline(serverId: string, agentId: string): Promise<void> {
const replica = this.timelineReplicaByServer.get(serverId);
if (!replica) throw new Error(`Unknown host runtime for serverId ${serverId}`);
await replica.prepare(agentId);
}
fetchAgentTimeline(
@@ -2313,14 +2303,21 @@ export class HostRuntimeStore {
return directory.fetchTimeline(agentId, request);
}
refreshAllAgentDirectories(input?: { serverIds?: string[] }): void {
const targetServerIds = input?.serverIds ? new Set(input.serverIds) : null;
for (const [serverId] of this.controllers) {
if (targetServerIds && !targetServerIds.has(serverId)) {
continue;
}
void this.refreshAgentDirectory({ serverId }).catch(() => undefined);
}
createViewedTimelineOwner(
serverId: string,
ports: ViewedTimelineOwnerPorts,
): ViewedTimelineOwner {
const directory = this.directorySyncByServer.get(serverId);
if (!directory) throw new Error(`Unknown host runtime for serverId ${serverId}`);
const replica = this.timelineReplicaByServer.get(serverId);
if (!replica) throw new Error(`Unknown host runtime for serverId ${serverId}`);
return createViewedTimelineOwner({
serverId,
replica,
replaceDemandedAgentIds: (agentIds) => directory.setAgentRouteDemand(agentIds),
drainQueuedAgentMessage: (agentId) => this.drainQueuedAgentMessage(serverId, agentId),
ports,
});
}
private emit(serverId: string): void {
@@ -1,63 +0,0 @@
import { describe, expect, it } from "vitest";
import type { StreamItem } from "@/types/stream";
import { diffMap, diffReplicaInput, type ReplicaInput } from "./diff";
function input(overrides: Partial<ReplicaInput> = {}): ReplicaInput {
return {
agents: new Map(),
workspaces: new Map(),
projects: new Map(),
focusedAgentId: undefined,
timelineItems: undefined,
timelineRange: null,
timelineHasOlder: false,
...overrides,
};
}
describe("diffReplicaInput", () => {
it("upserts only values whose map identity changed and deletes missing ids", () => {
const retained = { value: "retained" };
const previousValue = { value: "previous" };
const changed = { value: "changed" };
const removed = { value: "removed" };
const previous = new Map([
["retained", retained],
["changed", previousValue],
["removed", removed],
]);
const next = new Map([
["retained", retained],
["changed", changed],
]);
expect(diffMap(previous, next)).toEqual({
upserts: [changed],
deletes: ["removed"],
});
});
it("does not rewrite the timeline when its inputs retain identity", () => {
const timelineItems: StreamItem[] = [];
const range = { epoch: "epoch", startSeq: 1, endSeq: 1 };
const previous = input({
focusedAgentId: "agent",
timelineItems,
timelineRange: range,
});
const next = input({
focusedAgentId: "agent",
timelineItems,
timelineRange: range,
});
expect(diffReplicaInput(previous, next).timelineChanged).toBe(false);
});
it("rewrites the timeline when one of its inputs changes identity", () => {
const previous = input({ focusedAgentId: "agent", timelineItems: [] });
const next = input({ focusedAgentId: "agent", timelineItems: [] });
expect(diffReplicaInput(previous, next).timelineChanged).toBe(true);
});
});
@@ -1,77 +0,0 @@
import type {
Agent,
ProjectDescriptor,
SessionReplicaTimeline,
WorkspaceDescriptor,
} from "@/stores/session-store";
import type { StreamItem } from "@/types/stream";
export interface ReplicaInput {
agents: ReadonlyMap<string, Agent>;
workspaces: ReadonlyMap<string, WorkspaceDescriptor>;
projects: ReadonlyMap<string, ProjectDescriptor>;
focusedAgentId: string | undefined;
timelineItems: StreamItem[] | undefined;
timelineRange: SessionReplicaTimeline["range"];
timelineHasOlder: boolean;
}
interface EntityChanges<Entity> {
upserts: Entity[];
deletes: string[];
}
export interface ReplicaInputChanges {
agents: EntityChanges<Agent>;
workspaces: EntityChanges<WorkspaceDescriptor>;
projects: EntityChanges<ProjectDescriptor>;
timelineChanged: boolean;
}
export function diffMap<Entity>(
previous: ReadonlyMap<string, Entity> | undefined,
next: ReadonlyMap<string, Entity>,
): EntityChanges<Entity> {
if (previous === next) return { upserts: [], deletes: [] };
const upserts: Entity[] = [];
const deletes: string[] = [];
for (const [id, value] of next) {
if (previous?.get(id) !== value) upserts.push(value);
}
if (previous) {
for (const id of previous.keys()) {
if (!next.has(id)) deletes.push(id);
}
}
return { upserts, deletes };
}
export function diffReplicaInput(
previous: ReplicaInput | undefined,
next: ReplicaInput,
): ReplicaInputChanges {
const timelineChanged =
previous === undefined ||
previous.focusedAgentId !== next.focusedAgentId ||
previous.timelineItems !== next.timelineItems ||
previous.timelineRange !== next.timelineRange ||
previous.timelineHasOlder !== next.timelineHasOlder;
return {
agents: diffMap(previous?.agents, next.agents),
workspaces: diffMap(previous?.workspaces, next.workspaces),
projects: diffMap(previous?.projects, next.projects),
timelineChanged,
};
}
export function hasReplicaInputChanges(changes: ReplicaInputChanges): boolean {
return (
changes.agents.upserts.length > 0 ||
changes.agents.deletes.length > 0 ||
changes.workspaces.upserts.length > 0 ||
changes.workspaces.deletes.length > 0 ||
changes.projects.upserts.length > 0 ||
changes.projects.deletes.length > 0 ||
changes.timelineChanged
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -107,6 +107,28 @@ export function createSqliteReplicaRowStore(
return Array.from(hosts, ([serverId, rows]) => ({ serverId, rows }));
}
async function read(
serverId: string,
kinds: readonly ReplicaRow["kind"][],
ids?: readonly string[],
): Promise<ReplicaRow[]> {
if (kinds.length === 0 || ids?.length === 0) return [];
const placeholders = kinds.map(() => "?").join(", ");
const idClause = ids ? ` AND id IN (${ids.map(() => "?").join(", ")})` : "";
const storedRows = await getConnection().all<StoredRow>(
`SELECT server_id, kind, id, payload FROM rows
WHERE server_id = ? AND kind IN (${placeholders})${idClause}
ORDER BY kind, id`,
[serverId, ...kinds, ...(ids ?? [])],
);
return storedRows.map((row) => ({
serverId: row.server_id,
kind: row.kind,
id: row.id,
payload: row.payload,
}));
}
async function apply(changes: ReplicaRowChanges): Promise<void> {
await getConnection().transaction(async (transaction) => {
for (const key of changes.deletes) {
@@ -147,5 +169,5 @@ export function createSqliteReplicaRowStore(
await getConnection().run("DELETE FROM rows");
}
return { open, readAll, apply, deleteHost, renameHost, clear };
return { open, read, readAll, apply, deleteHost, renameHost, clear };
}
@@ -55,6 +55,22 @@ export function runReplicaRowStoreContract(
]);
});
it("reads only requested kinds for one host", async () => {
await store.apply({
upserts: [
row("server-a", "agent", "agent-1", "agent"),
row("server-a", "timeline", "singleton", "timeline"),
row("server-b", "agent", "agent-2", "other host"),
],
deletes: [],
});
expect(await store.read("server-a", ["timeline"])).toEqual([
row("server-a", "timeline", "singleton", "timeline"),
]);
expect(await store.read("server-a", ["agent"], ["missing"])).toEqual([]);
});
it("overwrites an existing row on upsert", async () => {
await store.apply({
upserts: [row("server-a", "agent", "agent-1", "old")],
@@ -24,6 +24,11 @@ export interface ReplicaHostRows {
export interface ReplicaRowStore {
open(): Promise<void>;
read(
serverId: string,
kinds: readonly ReplicaRowKind[],
ids?: readonly string[],
): Promise<ReplicaRow[]>;
readAll(): Promise<ReplicaHostRows[]>;
apply(changes: ReplicaRowChanges): Promise<void>;
deleteHost(serverId: string): Promise<void>;
@@ -1,4 +1,9 @@
import { IDBKeyRange as FakeIDBKeyRange, indexedDB as fakeIndexedDb } from "fake-indexeddb";
import {
IDBKeyRange as FakeIDBKeyRange,
IDBObjectStore as FakeIDBObjectStore,
indexedDB as fakeIndexedDb,
} from "fake-indexeddb";
import { expect, it, vi } from "vitest";
import { runReplicaRowStoreContract } from "./row-store.contract";
import { createIndexedDbReplicaRowStore } from "./row-store.web";
@@ -18,3 +23,29 @@ runReplicaRowStoreContract("IndexedDB", async () => {
},
};
});
it("uses exact IndexedDB keys for targeted rows instead of scanning the host", async () => {
const store = createIndexedDbReplicaRowStore({
databaseName: `replica-row-store-targeted-${databaseSequence++}`,
schemaVersion: 1,
});
await store.open();
await store.apply({
upserts: [
{ serverId: "server-a", kind: "agent", id: "agent-1", payload: "agent" },
{ serverId: "server-a", kind: "workspace", id: "workspace-1", payload: "workspace" },
],
deletes: [],
});
const get = vi.spyOn(FakeIDBObjectStore.prototype, "get");
const getAll = vi.spyOn(FakeIDBObjectStore.prototype, "getAll");
expect(await store.read("server-a", ["agent"], ["agent-1"])).toEqual([
{ serverId: "server-a", kind: "agent", id: "agent-1", payload: "agent" },
]);
expect(get).toHaveBeenCalledWith(["server-a", "agent", "agent-1"]);
expect(getAll).not.toHaveBeenCalled();
get.mockRestore();
getAll.mockRestore();
});
@@ -136,6 +136,38 @@ export function createIndexedDbReplicaRowStore(
return Array.from(hosts, ([serverId, hostRows]) => ({ serverId, rows: hostRows }));
}
async function read(
serverId: string,
kinds: readonly ReplicaRow["kind"][],
ids?: readonly string[],
): Promise<ReplicaRow[]> {
if (kinds.length === 0 || ids?.length === 0) return [];
const transaction = getDatabase().transaction(ROWS_STORE, "readonly");
const completion = transactionComplete(transaction);
const store = transaction.objectStore(ROWS_STORE);
const requests = ids
? kinds.flatMap((kind) =>
ids.map(async (id) => {
const row = await requestResult<ReplicaRow | undefined>(
store.get([serverId, kind, id]),
);
return row ? [row] : [];
}),
)
: kinds.map((kind) =>
requestResult<ReplicaRow[]>(
store.getAll(IDBKeyRange.bound([serverId, kind], [serverId, kind, []])),
),
);
const rows = (await Promise.all(requests)).flat();
await completion;
return rows.sort((left, right) =>
left.kind === right.kind
? left.id.localeCompare(right.id)
: left.kind.localeCompare(right.kind),
);
}
async function apply(changes: ReplicaRowChanges): Promise<void> {
await runTransaction(getDatabase(), ROWS_STORE, async (transaction) => {
const rows = transaction.objectStore(ROWS_STORE);
@@ -175,7 +207,7 @@ export function createIndexedDbReplicaRowStore(
});
}
return { open, readAll, apply, deleteHost, renameHost, clear };
return { open, read, readAll, apply, deleteHost, renameHost, clear };
}
export function createReplicaRowStore(): ReplicaRowStore {
@@ -1106,7 +1106,7 @@ function renderWorkspaceContent(input: RenderWorkspaceContentInput): React.React
</View>
);
}
if (!hasHydratedAgents || !hasLoadedTerminals) {
if (!activeTabDescriptor && (!hasHydratedAgents || !hasLoadedTerminals)) {
return (
<View style={styles.emptyState}>
<ThemedLoadingSpinner uniProps={mutedColorMapping} />
@@ -1545,6 +1545,12 @@ function WorkspaceScreenContent({
[workspaceId],
);
const workspaceDescriptor = useWorkspace(normalizedServerId, normalizedWorkspaceId);
useEffect(() => {
if (!normalizedServerId || !normalizedWorkspaceId || workspaceDescriptor) return;
void getHostRuntimeStore()
.prepareWorkspaceRoute(normalizedServerId, normalizedWorkspaceId)
.catch(() => undefined);
}, [normalizedServerId, normalizedWorkspaceId, workspaceDescriptor]);
const workspaceScripts = getWorkspaceScripts(workspaceDescriptor);
const { handleRetryHost, handleManageHost, handleDismissMissingWorkspace } =
useWorkspaceRouteActions(normalizedServerId);
@@ -1907,6 +1913,13 @@ function WorkspaceScreenContent({
routeFocused: isRouteFocused,
focusedPaneOnly: syncFocusedPaneOnly,
});
useEffect(() => {
for (const agentId of visibleAgentIds) {
void getHostRuntimeStore()
.prepareAgentTimeline(normalizedServerId, agentId)
.catch(() => undefined);
}
}, [normalizedServerId, visibleAgentIds]);
useLayoutEffect(() => {
if (!persistenceKey || !viewedTimelineSync) {
return;
@@ -135,14 +135,11 @@ afterEach(() => {
describe("workspace replica authority", () => {
it("publishes a restored directory while keeping remote hydration false", () => {
const cachedWorkspace = createWorkspace({ id: "cached-workspace" });
useSessionStore.getState().restoreSessionReplica(SERVER_ID, {
agents: new Map(),
workspaces: new Map([[cachedWorkspace.id, cachedWorkspace]]),
projects: new Map([
[cachedWorkspace.projectId, projectDescriptorFromTestWorkspace(cachedWorkspace)],
]),
timeline: null,
});
const store = useSessionStore.getState();
store.initializeSession(SERVER_ID, null);
store.setWorkspaces(SERVER_ID, new Map([[cachedWorkspace.id, cachedWorkspace]]));
store.setProjects(SERVER_ID, [projectDescriptorFromTestWorkspace(cachedWorkspace)]);
store.setHasWorkspaceDirectorySnapshot(SERVER_ID, true);
const cachedSession = useSessionStore.getState().sessions[SERVER_ID];
if (!cachedSession) throw new Error("expected initialized session");
@@ -11,7 +11,6 @@ import {
type WorkspaceDescriptor,
} from "./session-store";
import type { StreamItem } from "../types/stream";
import { patchWorkspaceScripts } from "../contexts/session-workspace-scripts";
function createWorkspace(
input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescriptor, "id">,
@@ -750,29 +749,3 @@ describe("removeWorkspace", () => {
expect(after.workspaces).toBe(before.workspaces);
});
});
describe("patchWorkspaceScripts", () => {
it("preserves workspace entry identity when scripts are content-equal", () => {
const script = {
scriptName: "web",
type: "service" as const,
hostname: "web.paseo.localhost",
port: 3000,
proxyUrl: "http://web.paseo.localhost:6767",
lifecycle: "running" as const,
health: "healthy" as const,
exitCode: null,
terminalId: null,
};
const workspace = createWorkspace({ id: "/repo/main", scripts: [script] });
const current = new Map([[workspace.id, workspace]]);
const next = patchWorkspaceScripts(current, {
workspaceId: workspace.id,
scripts: [{ ...script }],
});
expect(next).toBe(current);
expect(next.get(workspace.id)).toBe(workspace);
});
});
+15 -67
View File
@@ -298,20 +298,6 @@ export interface AgentTimelineCursorState {
}>;
}
export interface SessionReplicaTimeline {
agentId: string;
items: StreamItem[];
range: AgentTimelineCursorState | null;
hasOlder: boolean;
}
export interface SessionReplica {
agents: Map<string, Agent>;
workspaces: Map<string, WorkspaceDescriptor>;
projects: Map<string, ProjectDescriptor>;
timeline: SessionReplicaTimeline | null;
}
export type AgentTimelineState =
| { status: "cold" }
| { status: "painted"; items: StreamItem[] }
@@ -454,7 +440,6 @@ interface SessionStoreActions {
client: DaemonClient | null,
clientGeneration?: number,
) => void;
restoreSessionReplica: (serverId: string, replica: SessionReplica) => void;
clearSession: (serverId: string) => void;
getSession: (serverId: string) => SessionState | undefined;
updateSessionClient: (serverId: string, client: DaemonClient, clientGeneration?: number) => void;
@@ -628,6 +613,7 @@ interface SessionStoreActions {
// Hydration
setHasHydratedAgents: (serverId: string, hydrated: boolean) => void;
setHasHydratedWorkspaces: (serverId: string, hydrated: boolean) => void;
setHasWorkspaceDirectorySnapshot: (serverId: string, available: boolean) => void;
// Agent directory (derived from agents)
getAgentDirectory: (serverId: string) => AgentDirectoryEntry[] | undefined;
@@ -770,58 +756,6 @@ export const useSessionStore = create<SessionStore>()(
});
},
restoreSessionReplica: (serverId, replica) => {
set((prev) => {
if (prev.sessions[serverId]) {
return prev;
}
const session = createInitialSessionState(serverId, null);
const timeline = replica.timeline;
const agentStreamTail = new Map<string, StreamItem[]>();
const agentTasks = new Map<string, TodoEntry[]>();
if (timeline) {
agentStreamTail.set(timeline.agentId, timeline.items);
const tasks = latestTasksFromStream(timeline.items);
if (tasks.length > 0) agentTasks.set(timeline.agentId, tasks);
}
const agentTimelineCursor = new Map<string, AgentTimelineCursorState>();
const agentTimelineHasOlder = new Map<string, boolean>();
const agentTimelineHasNewer = new Map<string, boolean>();
const agentAuthoritativeHistoryApplied = new Map<string, boolean>();
if (timeline?.range) {
agentTimelineCursor.set(timeline.agentId, timeline.range);
agentTimelineHasOlder.set(timeline.agentId, timeline.hasOlder);
agentTimelineHasNewer.set(timeline.agentId, false);
agentAuthoritativeHistoryApplied.set(timeline.agentId, true);
}
const agentLastActivity = new Map(prev.agentLastActivity);
for (const agent of replica.agents.values()) {
agentLastActivity.set(agent.id, agent.lastActivityAt);
}
return {
...prev,
sessions: {
...prev.sessions,
[serverId]: {
...session,
agents: replica.agents,
workspaceAgentActivity: buildWorkspaceAgentActivityIndex(replica.agents),
workspaces: replica.workspaces,
projects: replica.projects,
hasWorkspaceDirectorySnapshot: true,
agentStreamTail,
agentTasks,
agentTimelineCursor,
agentTimelineHasOlder,
agentTimelineHasNewer,
agentAuthoritativeHistoryApplied,
},
},
agentLastActivity,
};
});
},
clearSession: (serverId) => {
agentLastActivityCoalescer.flushNow();
set((prev) => {
@@ -1959,6 +1893,20 @@ export const useSessionStore = create<SessionStore>()(
});
},
setHasWorkspaceDirectorySnapshot: (serverId, available) => {
set((prev) => {
const session = prev.sessions[serverId];
if (!session || session.hasWorkspaceDirectorySnapshot === available) return prev;
return {
...prev,
sessions: {
...prev.sessions,
[serverId]: { ...session, hasWorkspaceDirectorySnapshot: available },
},
};
});
},
// Agent directory - derived from agents (computed on-demand)
getAgentDirectory: (serverId) => {
const state = get();
+230
View File
@@ -0,0 +1,230 @@
import { afterEach, describe, expect, it } from "vitest";
import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages";
import type { CachedTimeline } from "@/runtime/replica-cache";
import { selectAgentTimelineState, useSessionStore } from "@/stores/session-store";
import type { StreamItem } from "@/types/stream";
import {
createTimelineReplica,
createViewedTimelineOwner,
type TimelineReplicaStorage,
type ViewedTimelineOwner,
} from "./viewed-timeline-sync";
const SERVER_ID = "timeline-replica-host";
const AGENT_ID = "agent-1";
function item(id: string, text: string, seq: number): StreamItem {
return {
kind: "assistant_message",
id,
text,
timestamp: new Date("2026-08-26T10:00:00.000Z"),
timelineCursor: { epoch: "epoch-1", seq },
};
}
function cachedTimeline(): CachedTimeline {
return {
agentId: AGENT_ID,
items: [item("cached", "cached", 4)],
range: { epoch: "epoch-1", startSeq: 1, endSeq: 4 },
hasOlder: true,
};
}
function createOwner(storage: TimelineReplicaStorage): ViewedTimelineOwner {
const replica = createTimelineReplica({
serverId: SERVER_ID,
storage,
prepareAgent: async () => undefined,
});
return createViewedTimelineOwner({
serverId: SERVER_ID,
replica,
replaceDemandedAgentIds: () => undefined,
drainQueuedAgentMessage: () => undefined,
ports: {
initialDeliveryMode: "legacy",
setSubscription: async () => undefined,
readCursor: () => undefined,
fetchPage: async () => ({ hasNewer: false, endCursor: null }),
fetchLatestTail: async () => ({ hasNewer: false, endCursor: null }),
reportError: () => undefined,
schedule: () => () => undefined,
},
});
}
function applySynced(agentId: string, seq: number): void {
useSessionStore.getState().applyAgentTimelineResponseState(SERVER_ID, agentId, {
items: [item(`network-${agentId}`, "network", seq)],
head: [],
range: { epoch: "epoch-1", startSeq: 1, endSeq: seq },
older: "available",
newer: false,
synchronized: true,
acknowledgedClientMessageIds: [],
});
}
afterEach(() => useSessionStore.getState().clearSession(SERVER_ID));
describe("viewed timeline persistence", () => {
it("shares an in-flight cache preparation with the viewed owner", async () => {
useSessionStore.getState().initializeSession(SERVER_ID, null);
let release!: (value: CachedTimeline) => void;
let reads = 0;
const read = new Promise<CachedTimeline>((resolve) => {
release = resolve;
});
const replica = createTimelineReplica({
serverId: SERVER_ID,
storage: {
readTimeline: () => {
reads += 1;
return read;
},
commitTimeline: () => undefined,
},
prepareAgent: async () => undefined,
});
const routePreparation = replica.prepare(AGENT_ID);
const ownerPreparation = replica.prepare(AGENT_ID);
release(cachedTimeline());
await Promise.all([routePreparation, ownerPreparation]);
expect(reads).toBe(1);
expect(replica.readCursor(AGENT_ID)).toEqual({ epoch: "epoch-1", endSeq: 4 });
});
it("paints cached history without claiming authoritative synchronization", async () => {
useSessionStore.getState().initializeSession(SERVER_ID, null);
const owner = createOwner({
readTimeline: async () => cachedTimeline(),
commitTimeline: () => undefined,
});
owner.replaceVisibleAgentIds("test", [AGENT_ID]);
await expect
.poll(() =>
selectAgentTimelineState(useSessionStore.getState().sessions[SERVER_ID], AGENT_ID),
)
.toEqual({ status: "painted", items: cachedTimeline().items });
owner.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;
const read = new Promise<CachedTimeline>((resolve) => {
release = resolve;
});
const owner = createOwner({
readTimeline: () => read,
commitTimeline: () => undefined,
});
owner.replaceVisibleAgentIds("test", [AGENT_ID]);
applySynced(AGENT_ID, 8);
release(cachedTimeline());
await expect
.poll(() =>
selectAgentTimelineState(useSessionStore.getState().sessions[SERVER_ID], AGENT_ID),
)
.toMatchObject({ status: "synced", range: { endSeq: 8 } });
owner.dispose();
});
it("persists accepted live stream commits through the owner", () => {
useSessionStore.getState().initializeSession(SERVER_ID, null);
applySynced(AGENT_ID, 8);
const commits: CachedTimeline[] = [];
const owner = createOwner({
readTimeline: async () => undefined,
commitTimeline: (_serverId, _agentId, timeline) => commits.push(timeline),
});
owner.enqueueStreamEvent(AGENT_ID, {
event: {
type: "timeline",
provider: "codex",
item: { type: "assistant_message", text: "live", messageId: "live" },
} as AgentStreamEventPayload,
seq: 9,
epoch: "epoch-1",
timestamp: new Date("2026-08-26T10:00:01.000Z"),
});
owner.flushStreamAgent(AGENT_ID);
expect(commits.at(-1)?.items.at(-1)).toMatchObject({ text: "live" });
expect(commits.at(-1)?.range?.endSeq).toBe(9);
owner.dispose();
});
it("applies and persists authoritative pages inside the owner", () => {
useSessionStore.getState().initializeSession(SERVER_ID, null);
const keys: string[] = [];
const owner = createOwner({
readTimeline: async () => undefined,
commitTimeline: (_serverId, agentId) => keys.push(agentId),
});
owner.applyTimelineResponse({
requestId: "page-1",
agentId: AGENT_ID,
agent: null,
direction: "tail",
projection: "projected",
reset: false,
epoch: "epoch-1",
window: { minSeq: 1, maxSeq: 0, nextSeq: 1 },
startCursor: null,
endCursor: null,
entries: [],
error: null,
hasNewer: false,
hasOlder: false,
staleCursor: false,
gap: false,
});
expect(
selectAgentTimelineState(useSessionStore.getState().sessions[SERVER_ID], AGENT_ID),
).toMatchObject({ status: "synced" });
expect(keys).toEqual([AGENT_ID]);
owner.dispose();
});
it("persists demanded agents independently", () => {
useSessionStore.getState().initializeSession(SERVER_ID, null);
const keys: string[] = [];
const owner = createOwner({
readTimeline: async () => undefined,
commitTimeline: (_serverId, agentId) => keys.push(agentId),
});
applySynced(AGENT_ID, 8);
applySynced("agent-2", 3);
for (const [agentId, seq] of [
[AGENT_ID, 9],
["agent-2", 4],
] as const) {
owner.enqueueStreamEvent(agentId, {
event: {
type: "timeline",
provider: "codex",
item: { type: "assistant_message", text: agentId, messageId: `live-${agentId}` },
} as AgentStreamEventPayload,
seq,
epoch: "epoch-1",
timestamp: new Date("2026-08-26T10:00:01.000Z"),
});
owner.flushStreamAgent(agentId);
}
expect(keys).toEqual([AGENT_ID, "agent-2"]);
owner.dispose();
});
});
@@ -1833,6 +1833,7 @@ export interface CreateSessionAgentStreamReducerQueueInput {
state: (prev: Map<string, TimelineCursor>) => Map<string, TimelineCursor>,
) => void;
recoverTimelineGap: (agentId: string, cursor: { epoch: string; endSeq: number }) => void;
onCommitted?: (agentId: string) => void;
}
interface ScheduledReducerFlush {
@@ -1886,7 +1887,8 @@ function cancelAgentStreamReducerFlush(id: number) {
export function createSessionAgentStreamReducerQueue(
input: CreateSessionAgentStreamReducerQueueInput,
): AgentStreamReducerQueue {
const { serverId, setAgentStreamState, setAgentTimelineCursor, recoverTimelineGap } = input;
const { serverId, setAgentStreamState, setAgentTimelineCursor, recoverTimelineGap, onCommitted } =
input;
return createAgentStreamReducerQueue({
getSnapshot: (agentId) => {
@@ -1945,6 +1947,7 @@ export function createSessionAgentStreamReducerQueue(
return next;
});
}
onCommitted?.(agentId);
},
handleSideEffects: (agentId, sideEffects) => {
for (const effect of sideEffects) {
@@ -34,7 +34,15 @@ interface TimelineFetch {
class TimelineWorld {
readonly errors: string[] = [];
readonly cursors = new Map<string, { epoch: string; endSeq: number }>();
readonly cacheRequests: string[] = [];
cacheGate: Deferred<void> | null = null;
readonly sync = createViewedTimelineSync({
replaceDemandedAgentIds: () => undefined,
prepare: async (agentId) => {
this.cacheRequests.push(agentId);
this.cacheRequestWaiters.shift()?.(agentId);
await this.cacheGate?.promise;
},
initialDeliveryMode: "selective",
setSubscription: async (agentIds) => {
const result = deferred<void>();
@@ -100,12 +108,23 @@ class TimelineWorld {
resolve(fetch: TimelineFetch): void;
}> = [];
private readonly errorWaiters: Array<(message: string) => void> = [];
private readonly cacheRequestWaiters: Array<(agentId: string) => void> = [];
private readonly scheduled: Array<{ task: () => void; delayMs: number }> = [];
private readonly retryWaiters: Array<{
delayMs: number;
resolve(retry: () => void): void;
}> = [];
get pendingFetchCount(): number {
return this.fetches.length;
}
nextCacheRequest(): Promise<string> {
const request = this.cacheRequests.at(-1);
if (request) return Promise.resolve(request);
return new Promise((resolve) => this.cacheRequestWaiters.push(resolve));
}
private fetchTimeline(
agentId: string,
request: ProjectedTimelineForwardFetchPlan,
@@ -197,6 +216,31 @@ test("uses a tail fetch when an agent becomes visible", async () => {
fetch.respond({ hasNewer: false });
});
test("loads the cache before choosing the authoritative network request", async () => {
const world = new TimelineWorld();
world.cacheGate = deferred<void>();
world.sync.setConnected(true);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
const membership = await world.nextMembership();
membership.succeed();
const cacheRequest = await world.nextCacheRequest();
expect(cacheRequest).toBe("agent-a");
expect(world.pendingFetchCount).toBe(0);
world.cursors.set("agent-a", { epoch: "cached-epoch", endSeq: 17 });
world.cacheGate.resolve();
const fetch = await world.nextFetch("agent-a");
expect(fetch.request).toEqual({
direction: "after",
cursor: { epoch: "cached-epoch", seq: 17 },
limit: 40,
projection: "projected",
});
fetch.respond({ hasNewer: false });
});
test("catches up after the restored cursor when an agent becomes visible", async () => {
const world = new TimelineWorld();
world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 42 });
@@ -3,14 +3,277 @@ import {
planTimelineResumeFetch,
type ProjectedTimelineForwardFetchPlan,
} from "./timeline-sync-plan";
import type { CachedTimeline } from "@/runtime/replica-cache";
import {
selectAgentTimelineState,
useSessionStore,
type AgentTimelineCursorState,
type AgentTimelineState,
} from "@/stores/session-store";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
import { getSendingClientMessageIds } from "@/composer/submission/model";
import {
getInitDeferred,
getInitKey,
rejectInitDeferred,
resolveInitDeferred,
} from "@/utils/agent-initialization";
import {
createSessionAgentStreamReducerQueue,
type AgentStreamReducerEvent,
type ProcessTimelineResponseOutput,
processTimelineResponse,
} from "./session-stream-reducers";
import { isTimelineResumeSnapshotAuthoritative } from "./timeline-sync-plan";
interface TimelinePageResult {
export interface TimelineReplicaStorage {
readTimeline(serverId: string, agentId: string): Promise<CachedTimeline | undefined>;
commitTimeline(serverId: string, agentId: string, timeline: CachedTimeline): void;
}
async function prepareCachedTimeline(input: {
serverId: string;
agentId: string;
storage: TimelineReplicaStorage;
prepareAgent: (agentId: string) => Promise<void>;
}): Promise<CachedTimeline | undefined> {
const before = useSessionStore.getState().sessions[input.serverId];
const beforeTimeline = selectAgentTimelineState(before, input.agentId);
if (beforeTimeline.status === "synced") return undefined;
const beforeHead = before?.agentStreamHead.get(input.agentId);
await input.prepareAgent(input.agentId);
const stored = await input.storage.readTimeline(input.serverId, input.agentId);
if (!stored) return undefined;
const session = useSessionStore.getState().sessions[input.serverId];
const currentTimeline = selectAgentTimelineState(session, input.agentId);
if (session?.agentStreamHead.get(input.agentId) !== beforeHead) return undefined;
if (beforeTimeline.status === "painted") {
return currentTimeline.status === "painted" && currentTimeline.items === beforeTimeline.items
? stored
: undefined;
}
if (currentTimeline.status !== "cold") return undefined;
useSessionStore.getState().applyAgentTimelineResponseState(input.serverId, input.agentId, {
items: stored.items,
head: [],
range: stored.range,
older: stored.hasOlder ? "available" : "none",
newer: false,
synchronized: false,
acknowledgedClientMessageIds: [],
});
return stored;
}
export interface TimelineReplica {
prepare(agentId: string): Promise<void>;
readCursor(agentId: string): { epoch: string; endSeq: number } | undefined;
timelineUpdated(agentId: string): void;
}
class TimelineReplicaOwner implements TimelineReplica {
private readonly cachedCursors = new Map<string, { epoch: string; endSeq: number }>();
private readonly preparations = new Map<string, Promise<void>>();
constructor(
private readonly serverId: string,
private readonly storage: TimelineReplicaStorage,
private readonly prepareAgent: (agentId: string) => Promise<void>,
) {}
async prepare(agentId: string): Promise<void> {
const existing = this.preparations.get(agentId);
if (existing) return existing;
const preparation = this.load(agentId).finally(() => {
if (this.preparations.get(agentId) === preparation) {
this.preparations.delete(agentId);
}
});
this.preparations.set(agentId, preparation);
return preparation;
}
private async load(agentId: string): Promise<void> {
const stored = await prepareCachedTimeline({
serverId: this.serverId,
agentId,
storage: this.storage,
prepareAgent: this.prepareAgent,
});
if (!stored) return;
if (stored.range) {
this.cachedCursors.set(agentId, {
epoch: stored.range.epoch,
endSeq: stored.range.endSeq,
});
}
}
readCursor(agentId: string): { epoch: string; endSeq: number } | undefined {
return this.cachedCursors.get(agentId);
}
timelineUpdated(agentId: string): void {
const session = useSessionStore.getState().sessions[this.serverId];
const timeline = selectAgentTimelineState(session, agentId);
if (timeline.status !== "synced") return;
this.cachedCursors.delete(agentId);
this.storage.commitTimeline(this.serverId, agentId, {
agentId,
items: [...timeline.items, ...(session?.agentStreamHead.get(agentId) ?? [])],
range: timeline.range,
hasOlder: timeline.older === "available",
});
}
}
export function createTimelineReplica(input: {
serverId: string;
storage: TimelineReplicaStorage;
prepareAgent: (agentId: string) => Promise<void>;
}): TimelineReplica {
return new TimelineReplicaOwner(input.serverId, input.storage, input.prepareAgent);
}
export interface TimelinePageResult {
hasNewer: boolean;
endCursor: { epoch: string; seq: number } | null;
}
interface ViewedTimelineSyncPorts {
export type TimelineResponsePayload = Extract<
SessionOutboundMessage,
{ type: "fetch_agent_timeline_response" }
>["payload"];
function clearAgentInitializingFlag(serverId: string, agentId: string): void {
useSessionStore.getState().setInitializingAgents(serverId, (previous) => {
if (previous.get(agentId) !== true) return previous;
const next = new Map(previous);
next.set(agentId, false);
return next;
});
}
function commitProcessedTimeline(input: {
serverId: string;
payload: TimelineResponsePayload;
result: ProcessTimelineResponseOutput;
timeline: AgentTimelineState;
currentCursor: AgentTimelineCursorState | undefined;
synchronized: boolean;
}): void {
const { serverId, payload, result, timeline, currentCursor, synchronized } = input;
const agentId = payload.agentId;
const store = useSessionStore.getState();
if (result.commit !== "discard") {
store.applyAgentTimelineResponseState(serverId, agentId, {
items: result.tail,
head: result.head,
range: result.cursorChanged ? (result.cursor ?? null) : (currentCursor ?? null),
older: result.older,
newer:
payload.direction === "before"
? timeline.status === "synced" && timeline.newer === "available"
: payload.hasNewer,
synchronized,
acknowledgedClientMessageIds: result.acknowledgedClientMessageIds,
});
return;
}
if (result.acknowledgedClientMessageIds.length > 0) {
store.setAgentStreamState(serverId, agentId, {
acknowledgedClientMessageIds: result.acknowledgedClientMessageIds,
});
}
if (payload.direction !== "before") {
store.setAgentTimelineHasNewer(serverId, (current) => {
const next = new Map(current);
next.set(agentId, payload.hasNewer);
return next;
});
}
store.markAgentHistorySynchronized(serverId, agentId);
}
function finalizeProcessedTimeline(input: {
serverId: string;
agentId: string;
initKey: string;
result: ProcessTimelineResponseOutput;
synchronized: boolean;
recoverGap: (agentId: string, cursor: { epoch: string; endSeq: number }) => void;
drainQueuedAgentMessage: (agentId: string) => void;
}): void {
for (const effect of input.result.sideEffects) {
if (effect.type === "catch_up") input.recoverGap(input.agentId, effect.cursor);
}
if (input.result.clearInitializing) {
clearAgentInitializingFlag(input.serverId, input.agentId);
}
if (input.synchronized) {
useCreateFlowStore
.getState()
.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 (input.result.initResolution === "resolve") resolveInitDeferred(input.initKey);
}
function applyAuthoritativeTimelineResponse(input: {
serverId: string;
payload: TimelineResponsePayload;
recoverGap: (agentId: string, cursor: { epoch: string; endSeq: number }) => void;
drainQueuedAgentMessage: (agentId: string) => void;
}): boolean {
const { serverId, payload } = input;
const agentId = payload.agentId;
const initKey = getInitKey(serverId, agentId);
const session = useSessionStore.getState().sessions[serverId];
const timeline = selectAgentTimelineState(session, agentId);
const activeInitDeferred = getInitDeferred(initKey);
const currentCursor = timeline.status === "synced" ? (timeline.range ?? undefined) : undefined;
const result = processTimelineResponse({
payload,
currentTail: timeline.status === "cold" ? [] : timeline.items,
currentHead: session?.agentStreamHead.get(agentId) ?? [],
currentCursor,
isInitializing: session?.initializingAgents.get(agentId) === true,
hasActiveInitDeferred: Boolean(activeInitDeferred),
initRequestDirection: activeInitDeferred?.requestDirection ?? "tail",
sendingClientMessageIds: getSendingClientMessageIds(session?.messageSubmissions.get(agentId)),
});
if (result.error) {
if (result.clearInitializing) clearAgentInitializingFlag(serverId, agentId);
if (result.initResolution === "reject") rejectInitDeferred(initKey, new Error(result.error));
return false;
}
const synchronized = isTimelineResumeSnapshotAuthoritative({
direction: payload.direction,
hasNewer: payload.hasNewer,
error: payload.error,
});
commitProcessedTimeline({ serverId, payload, result, timeline, currentCursor, synchronized });
finalizeProcessedTimeline({
serverId,
agentId,
initKey,
result,
synchronized,
recoverGap: input.recoverGap,
drainQueuedAgentMessage: input.drainQueuedAgentMessage,
});
return true;
}
export interface ViewedTimelineSyncPorts {
initialDeliveryMode: TimelineDeliveryMode;
prepare(agentId: string): Promise<void>;
replaceDemandedAgentIds(agentIds: string[]): void;
setSubscription(agentIds: string[]): Promise<void>;
readCursor(agentId: string): { epoch: string; endSeq: number } | undefined;
fetchPage(
@@ -29,6 +292,7 @@ export interface ViewedTimelineUiBridge {
replaceVisibleAgentIds(sourceId: string, agentIds: string[]): void;
subscribe(listener: () => void): () => void;
getAgentTimelineStatus(agentId: string): ViewedTimelineStatus;
getAgentTimelineError(agentId: string): string | null;
retryVisibleAgentTimeline(agentId: string): void;
}
@@ -40,6 +304,61 @@ export interface ViewedTimelineSync extends ViewedTimelineUiBridge {
dispose(): void;
}
export type ViewedTimelineOwnerPorts = Omit<
ViewedTimelineSyncPorts,
"prepare" | "replaceDemandedAgentIds"
>;
export interface ViewedTimelineOwner extends ViewedTimelineSync {
applyTimelineResponse(payload: TimelineResponsePayload): void;
enqueueStreamEvent(agentId: string, event: AgentStreamReducerEvent): void;
flushStreamAgent(agentId: string): void;
}
export function createViewedTimelineOwner(input: {
serverId: string;
replica: TimelineReplica;
replaceDemandedAgentIds: (agentIds: string[]) => void;
drainQueuedAgentMessage: (agentId: string) => void;
ports: ViewedTimelineOwnerPorts;
}): ViewedTimelineOwner {
const sync = createViewedTimelineSync({
...input.ports,
prepare: (agentId) => input.replica.prepare(agentId),
readCursor: (agentId) => input.replica.readCursor(agentId) ?? input.ports.readCursor(agentId),
replaceDemandedAgentIds: input.replaceDemandedAgentIds,
});
const streamQueue = createSessionAgentStreamReducerQueue({
serverId: input.serverId,
setAgentStreamState: (...args) => useSessionStore.getState().setAgentStreamState(...args),
setAgentTimelineCursor: (...args) => useSessionStore.getState().setAgentTimelineCursor(...args),
recoverTimelineGap: (agentId, cursor) => sync.recoverGap(agentId, cursor),
onCommitted: (agentId) => input.replica.timelineUpdated(agentId),
});
return {
...sync,
applyTimelineResponse(payload) {
const accepted = applyAuthoritativeTimelineResponse({
serverId: input.serverId,
payload,
recoverGap: (agentId, cursor) => sync.recoverGap(agentId, cursor),
drainQueuedAgentMessage: input.drainQueuedAgentMessage,
});
if (accepted) input.replica.timelineUpdated(payload.agentId);
},
enqueueStreamEvent(agentId, event) {
streamQueue.enqueue(agentId, event);
},
flushStreamAgent(agentId) {
streamQueue.flushAgent(agentId);
},
dispose() {
streamQueue.dispose({ flush: true });
sync.dispose();
},
};
}
const RETRY_DELAY_MS = 1_000;
const MAX_RETRY_DELAY_MS = 30_000;
const VIEWED_TIMELINE_HOT_AGENT_LIMIT = 5;
@@ -111,10 +430,12 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
// Acknowledgement and tail completion are the only drain points.
const pendingCatchUps = new Map<string, ProjectedTimelineForwardFetchPlan>();
const visibilityCatchUpPending = new Set<string>();
const visibilityCatchUpErrors = new Set<string>();
const visibilityCatchUpErrors = new Map<string, string>();
// User-initiated retries only. Background retries stay silent; a retry the user asked for
// owes them a pending state until it settles.
const manualRetries = new Set<string>();
const loadedCache = new Set<string>();
const cacheLoads = new Map<string, Promise<void>>();
const listeners = new Set<() => void>();
let active = true;
let connected = false;
@@ -170,13 +491,16 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
if (wasPending || hadError || wasRetrying) notifyListeners();
};
const setVisibilityCatchUpError = (agentIds: string[]) => {
const setVisibilityCatchUpError = (agentIds: string[], error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
let changed = false;
for (const agentId of agentIds) {
if (manualRetries.delete(agentId)) changed = true;
if (!visibilityCatchUpPending.delete(agentId)) continue;
visibilityCatchUpErrors.add(agentId);
changed = true;
if (visibilityCatchUpPending.delete(agentId)) changed = true;
if (visibilityCatchUpErrors.get(agentId) !== message) {
visibilityCatchUpErrors.set(agentId, message);
changed = true;
}
}
if (changed) notifyListeners();
};
@@ -239,12 +563,26 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
cancelRetry,
retryDelayMs: nextRetryDelayMs,
});
setVisibilityCatchUpError([agentId]);
setVisibilityCatchUpError([agentId], error);
ports.reportError(error);
}
}
};
const ensureCacheLoaded = (agentId: string): void => {
if (loadedCache.has(agentId) || cacheLoads.has(agentId)) return;
const load = ports
.prepare(agentId)
.catch((error) => ports.reportError(error))
.finally(() => {
cacheLoads.delete(agentId);
loadedCache.add(agentId);
const pending = pendingCatchUps.get(agentId);
startCatchUp(agentId, { request: pending, supersede: Boolean(pending) });
});
cacheLoads.set(agentId, load);
};
const startCatchUp = (
agentId: string,
options: {
@@ -257,6 +595,11 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
if (request) pendingCatchUps.set(agentId, request);
return;
}
if (!loadedCache.has(agentId)) {
if (request) pendingCatchUps.set(agentId, request);
ensureCacheLoaded(agentId);
return;
}
const nextRequest = request ?? planTimelineResumeFetch(ports.readCursor(agentId));
const current = catchUps.get(agentId);
const decision = decideCatchUp({ current, request: nextRequest, supersede });
@@ -307,7 +650,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
await ports.setSubscription(requested);
} catch (error) {
membershipNeedsRetry = true;
setVisibilityCatchUpError(requested);
setVisibilityCatchUpError(requested, error);
cancelMembershipRetry?.();
const nextRetryDelayMs = getNextRetryDelayMs(membershipRetryDelayMs);
cancelMembershipRetry = ports.schedule(() => {
@@ -417,11 +760,13 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
visibilityCatchUpPending.add(agentId);
visibilityCatchUpErrors.delete(agentId);
manualRetries.delete(agentId);
ensureCacheLoaded(agentId);
}
}
cancelMembershipRetry?.();
cancelMembershipRetry = null;
desired = nextDesired;
ports.replaceDemandedAgentIds(desired);
membershipGeneration += 1;
notifyListeners();
if (deliveryMode === "legacy") {
@@ -455,6 +800,9 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
if (!isDesired(agentId) || visibilityCatchUpPending.has(agentId)) return "pending";
return "ready";
},
getAgentTimelineError(agentId) {
return visibilityCatchUpErrors.get(agentId) ?? null;
},
replaceVisibleAgentIds(sourceId, agentIds) {
const normalized = normalizeAgentIds(agentIds);
if (normalized.length === 0) sources.delete(sourceId);
@@ -501,6 +849,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
const visible = active ? visibleAgentIds() : [];
recentlyViewedAgentIds = visible;
desired = visible;
ports.replaceDemandedAgentIds(desired);
visibilityCatchUpPending.clear();
visibilityCatchUpErrors.clear();
manualRetries.clear();
@@ -527,8 +876,11 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
membershipGeneration += 1;
for (const agentId of desired) cancelCatchUp(agentId);
desired = [];
ports.replaceDemandedAgentIds([]);
acknowledged = [];
recentlyViewedAgentIds = [];
loadedCache.clear();
cacheLoads.clear();
visibilityCatchUpPending.clear();
visibilityCatchUpErrors.clear();
manualRetries.clear();