mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
fix directory bootstrap reconciliation edge cases
This commit is contained in:
@@ -76,6 +76,7 @@ import {
|
||||
backfillLegacyDaemonWorkspaceDirectoryIfEmpty,
|
||||
} from "@/workspace/legacy-daemon-workspaces";
|
||||
import { useProviderSubagentStore } from "@/subagents/provider-store";
|
||||
import { revalidateSessionAfterResume } from "@/contexts/session-resume-revalidation";
|
||||
|
||||
// Re-export types from session-store and draft-store for backward compatibility
|
||||
export type { DraftInput } from "@/stores/draft-store";
|
||||
@@ -90,8 +91,6 @@ export type {
|
||||
AgentFileExplorerState,
|
||||
} from "@/stores/session-store";
|
||||
|
||||
const HISTORY_STALE_AFTER_MS = 60_000;
|
||||
|
||||
function hasAgentUsageChanged(
|
||||
incomingUsage: Agent["lastUsage"] | undefined,
|
||||
currentUsage: Agent["lastUsage"] | undefined,
|
||||
@@ -755,12 +754,20 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
|
||||
const handleAppResumed = useCallback(
|
||||
(awayMs: number) => {
|
||||
if (awayMs < HISTORY_STALE_AFTER_MS) {
|
||||
return;
|
||||
}
|
||||
bumpHistorySyncGeneration(serverId);
|
||||
void revalidateSessionAfterResume({
|
||||
awayMs,
|
||||
serverId,
|
||||
bumpHistorySyncGeneration,
|
||||
refreshAgentDirectory: () => getHostRuntimeStore().refreshAgentDirectory({ serverId }),
|
||||
hydrateWorkspaces,
|
||||
}).catch((error) => {
|
||||
console.error("[SessionProvider] resume revalidation failed", {
|
||||
serverId,
|
||||
error: toErrorMessage(error),
|
||||
});
|
||||
});
|
||||
},
|
||||
[bumpHistorySyncGeneration, serverId],
|
||||
[bumpHistorySyncGeneration, hydrateWorkspaces, serverId],
|
||||
);
|
||||
|
||||
// Client activity tracking (heartbeat, push token registration)
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
revalidateSessionAfterResume,
|
||||
SESSION_STALE_AFTER_MS,
|
||||
} from "./session-resume-revalidation";
|
||||
|
||||
describe("session resume revalidation", () => {
|
||||
it("refreshes both directories and timeline history after a stale resume", async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
const revalidated = await revalidateSessionAfterResume({
|
||||
awayMs: SESSION_STALE_AFTER_MS,
|
||||
serverId: "server",
|
||||
bumpHistorySyncGeneration: (serverId) => calls.push(`history:${serverId}`),
|
||||
refreshAgentDirectory: async () => calls.push("agents"),
|
||||
hydrateWorkspaces: async () => {
|
||||
calls.push("workspaces");
|
||||
},
|
||||
});
|
||||
|
||||
expect(revalidated).toBe(true);
|
||||
expect(calls).toEqual(["history:server", "agents", "workspaces"]);
|
||||
});
|
||||
|
||||
it("does nothing after a brief background interval", async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
const revalidated = await revalidateSessionAfterResume({
|
||||
awayMs: SESSION_STALE_AFTER_MS - 1,
|
||||
serverId: "server",
|
||||
bumpHistorySyncGeneration: () => calls.push("history"),
|
||||
refreshAgentDirectory: async () => calls.push("agents"),
|
||||
hydrateWorkspaces: async () => {
|
||||
calls.push("workspaces");
|
||||
},
|
||||
});
|
||||
|
||||
expect(revalidated).toBe(false);
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
});
|
||||
17
packages/app/src/contexts/session-resume-revalidation.ts
Normal file
17
packages/app/src/contexts/session-resume-revalidation.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export const SESSION_STALE_AFTER_MS = 60_000;
|
||||
|
||||
export async function revalidateSessionAfterResume(input: {
|
||||
awayMs: number;
|
||||
serverId: string;
|
||||
bumpHistorySyncGeneration: (serverId: string) => void;
|
||||
refreshAgentDirectory: () => Promise<unknown>;
|
||||
hydrateWorkspaces: () => Promise<void>;
|
||||
}): Promise<boolean> {
|
||||
if (input.awayMs < SESSION_STALE_AFTER_MS) {
|
||||
return false;
|
||||
}
|
||||
|
||||
input.bumpHistorySyncGeneration(input.serverId);
|
||||
await Promise.all([input.refreshAgentDirectory(), input.hydrateWorkspaces()]);
|
||||
return true;
|
||||
}
|
||||
@@ -151,6 +151,26 @@ describe("agent directory reconciliation", () => {
|
||||
}).toEqual({ title: "newer page", status: "running", projectName: "repo", stopped: [] });
|
||||
});
|
||||
|
||||
it("clears a snapshot stop when a newer buffered upsert is running", () => {
|
||||
const result = reconcileAgentDirectory({
|
||||
previous: new Map([["agent", replica("agent", "running")]]),
|
||||
snapshot: [entry("agent", "idle")],
|
||||
deltas: [
|
||||
{
|
||||
kind: "upsert",
|
||||
agent: {
|
||||
...snapshot("agent", "running"),
|
||||
updatedAt: "2026-07-12T11:00:00.000Z",
|
||||
},
|
||||
project: entry("agent", "running").project,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.entries[0]?.agent.status).toBe("running");
|
||||
expect(result.stoppedRunningAgentIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("accepts usage from a stale buffered upsert without regressing metadata", () => {
|
||||
const result = reconcileAgentDirectory({
|
||||
previous: new Map(),
|
||||
|
||||
@@ -28,7 +28,9 @@ export function reconcileAgentDirectory(input: {
|
||||
}
|
||||
const previousEntry = entries.get(delta.agent.id);
|
||||
const acceptedAgent = acceptAgentDirectoryUpdate(previousEntry?.agent, delta.agent);
|
||||
if (statuses.get(delta.agent.id) === "running" && acceptedAgent.status !== "running") {
|
||||
if (acceptedAgent.status === "running") {
|
||||
stoppedRunningAgentIds.delete(delta.agent.id);
|
||||
} else if (statuses.get(delta.agent.id) === "running") {
|
||||
stoppedRunningAgentIds.add(delta.agent.id);
|
||||
}
|
||||
statuses.set(delta.agent.id, acceptedAgent.status);
|
||||
|
||||
@@ -4067,6 +4067,8 @@ export class Session {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const workspaceId = payload.kind === "upsert" ? payload.workspace.id : payload.id;
|
||||
subscription.lastEmittedByWorkspaceId.set(workspaceId, payload);
|
||||
this.emit({
|
||||
type: "workspace_update",
|
||||
payload,
|
||||
|
||||
@@ -6568,6 +6568,64 @@ test("subscribed fetch_workspaces includes git enrichment in the initial snapsho
|
||||
);
|
||||
});
|
||||
|
||||
test("a workspace leaving a filtered subscription after bootstrap emits a removal", async () => {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const session = asTestSession(
|
||||
createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) }),
|
||||
);
|
||||
const descriptor = {
|
||||
id: "ws-buffered",
|
||||
projectId: "proj-buffered",
|
||||
projectDisplayName: "repo",
|
||||
projectRootPath: REPO_CWD,
|
||||
workspaceDirectory: REPO_CWD,
|
||||
projectKind: "git" as const,
|
||||
workspaceKind: "local_checkout" as const,
|
||||
name: "repo work",
|
||||
status: "done" as const,
|
||||
activityAt: null,
|
||||
diffStat: null,
|
||||
};
|
||||
let currentDescriptor: typeof descriptor | null = descriptor;
|
||||
let finishListing: (result: ListFetchResult) => void = () => {};
|
||||
const listing = new Promise<ListFetchResult>((resolve) => {
|
||||
finishListing = resolve;
|
||||
});
|
||||
session.listFetchWorkspacesEntries = async () => listing;
|
||||
session.buildWorkspaceDescriptorMap = async () =>
|
||||
new Map(currentDescriptor ? [[currentDescriptor.id, currentDescriptor]] : []);
|
||||
session.reconcileAndEmitWorkspaceUpdates = async () => undefined;
|
||||
|
||||
const bootstrap = session.handleMessage({
|
||||
type: "fetch_workspaces_request",
|
||||
requestId: "req-buffered-filter",
|
||||
filter: { query: "repo" },
|
||||
subscribe: { subscriptionId: "sub-buffered-filter" },
|
||||
});
|
||||
await session.emitWorkspaceUpdatesForWorkspaceIds([descriptor.id], { skipReconcile: true });
|
||||
finishListing({
|
||||
entries: [],
|
||||
emptyProjects: [],
|
||||
pageInfo: { nextCursor: null, prevCursor: null, hasMore: false },
|
||||
});
|
||||
await bootstrap;
|
||||
|
||||
expect(filterByType(emitted, "workspace_update")).toEqual([
|
||||
{ type: "workspace_update", payload: { kind: "upsert", workspace: descriptor } },
|
||||
]);
|
||||
|
||||
emitted.length = 0;
|
||||
currentDescriptor = { ...descriptor, name: "other work" };
|
||||
await session.emitWorkspaceUpdatesForWorkspaceIds([descriptor.id], { skipReconcile: true });
|
||||
|
||||
expect(filterByType(emitted, "workspace_update")).toEqual([
|
||||
{
|
||||
type: "workspace_update",
|
||||
payload: { kind: "remove", id: descriptor.id },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("project.rename.request stores customName and emits an updated workspace descriptor", async () => {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const session = asTestSession(
|
||||
|
||||
Reference in New Issue
Block a user