fix(ui): retry session group catalog loads (#106169)

This commit is contained in:
Peter Steinberger
2026-07-13 01:29:45 -07:00
committed by GitHub
parent 98e3f729bc
commit bae913ba0c
3 changed files with 250 additions and 15 deletions
+41
View File
@@ -145,6 +145,44 @@ describeControlUiE2e("Control UI session management mocked Gateway E2E", () => {
await server?.close();
});
it("recovers an empty group catalog after a transient load failure", async () => {
const context = await browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const gateway = await installMockGateway(page, {
deferredMethods: ["sessions.groups.list"],
featureMethods: ["chat.metadata", "chat.startup", "sessions.groups.list"],
methodResponses: {
"sessions.list": sessionsListResponse([]),
},
sessionGroups: ["Recovered group"],
});
try {
await page.goto(`${server.baseUrl}chat`);
await gateway.waitForRequest("sessions.groups.list");
await gateway.rejectDeferred("sessions.groups.list", {
code: "UNAVAILABLE",
message: "temporary catalog failure",
retryable: true,
});
await expect
.poll(async () => (await gateway.getRequests("sessions.groups.list")).length, {
timeout: 10_000,
})
.toBe(2);
await page.locator('[data-session-section="category:Recovered group"]').waitFor({
state: "visible",
});
} finally {
await context.close();
}
});
it("manages sessions through the sidebar groups and command palette", async () => {
const baseTime = Date.parse("2026-07-01T16:00:00.000Z");
const context = await browser.newContext({
@@ -697,6 +735,7 @@ describeControlUiE2e("Control UI session management mocked Gateway E2E", () => {
},
"sessions.patch": {},
},
featureMethods: ["chat.metadata", "chat.startup", "sessions.groups.list"],
sessionKey: "agent:main:main",
sessionGroups: ["Apps", "Research"],
});
@@ -803,6 +842,7 @@ describeControlUiE2e("Control UI session management mocked Gateway E2E", () => {
"sessions.list": sessionsListResponse(sessions),
"sessions.patch": {},
},
featureMethods: ["chat.metadata", "chat.startup", "sessions.groups.list"],
sessionKey: "agent:main:session-0",
sessionGroups: ["Alpha", "Beta"],
});
@@ -937,6 +977,7 @@ describeControlUiE2e("Control UI session management mocked Gateway E2E", () => {
methodResponses: {
"sessions.list": sessionsListResponse([]),
},
featureMethods: ["chat.metadata", "chat.startup", "sessions.groups.list"],
sessionKey: "agent:main:main",
// Stored-but-empty catalog groups stay visible as sections/move targets.
sessionGroups: ["First group"],
+130 -5
View File
@@ -1,5 +1,10 @@
import { describe, expect, it, vi } from "vitest";
import type { GatewayBrowserClient, GatewayEventFrame, GatewayHelloOk } from "../../api/gateway.ts";
import {
GatewayRequestError,
type GatewayBrowserClient,
type GatewayEventFrame,
type GatewayHelloOk,
} from "../../api/gateway.ts";
import type { SessionsListResult } from "../../api/types.ts";
import { createSessionCapability, reconcileSessionRunTerminal } from "./index.ts";
@@ -15,13 +20,15 @@ function sessionsResult(sessions: SessionsListResult["sessions"], ts: number): S
function deferred<T>() {
let resolve: (value: T) => void = () => undefined;
const promise = new Promise<T>((next) => {
let reject: (error: unknown) => void = () => undefined;
const promise = new Promise<T>((next, fail) => {
resolve = next;
reject = fail;
});
return { promise, resolve };
return { promise, reject, resolve };
}
function createGatewayHarness(client: GatewayBrowserClient) {
function createGatewayHarness(client: GatewayBrowserClient, featureMethods?: string[]) {
let snapshot: {
client: GatewayBrowserClient | null;
connected: boolean;
@@ -33,7 +40,10 @@ function createGatewayHarness(client: GatewayBrowserClient) {
connected: true,
sessionKey: "agent:main:main",
assistantAgentId: "main",
hello: null,
hello:
featureMethods === undefined
? null
: ({ features: { methods: featureMethods } } as GatewayHelloOk),
};
const listeners = new Set<(next: typeof snapshot) => void>();
const eventListeners = new Set<(event: GatewayEventFrame) => void>();
@@ -82,6 +92,121 @@ function sessionChangedEvent(key: string): GatewayEventFrame {
}
describe("createSessionCapability", () => {
it("allows an advertised group catalog load to be retried after failure", async () => {
let groupsCalls = 0;
const request = vi.fn(async (method: string) => {
if (method !== "sessions.groups.list") {
throw new Error(`Unexpected request: ${method}`);
}
groupsCalls += 1;
if (groupsCalls === 1) {
throw new Error("temporary catalog failure");
}
return { groups: [{ name: "Research" }] };
});
const client = { request } as unknown as GatewayBrowserClient;
const { gateway } = createGatewayHarness(client, ["sessions.groups.list"]);
const sessions = createSessionCapability(gateway);
await sessions.groupsLoad();
expect(sessions.state.groups).toEqual([]);
await sessions.groupsLoad();
expect(groupsCalls).toBe(2);
expect(sessions.state.groups).toEqual(["Research"]);
sessions.dispose();
});
it("automatically retries an explicitly retryable group catalog failure", async () => {
let groupsCalls = 0;
const request = vi.fn(async (method: string) => {
if (method !== "sessions.groups.list") {
throw new Error(`Unexpected request: ${method}`);
}
groupsCalls += 1;
if (groupsCalls === 1) {
throw new GatewayRequestError({
code: "UNAVAILABLE",
message: "temporary catalog failure",
retryable: true,
retryAfterMs: 100,
});
}
return { groups: [{ name: "Recovered" }] };
});
const client = { request } as unknown as GatewayBrowserClient;
const { gateway } = createGatewayHarness(client, ["sessions.groups.list"]);
const sessions = createSessionCapability(gateway);
await sessions.groupsLoad();
await vi.waitFor(() => expect(sessions.state.groups).toEqual(["Recovered"]));
expect(groupsCalls).toBe(2);
sessions.dispose();
});
it("keeps the legacy group catalog probe one-shot without feature metadata", async () => {
const request = vi.fn(async () => {
throw new Error("unknown method");
});
const client = { request } as unknown as GatewayBrowserClient;
const { gateway } = createGatewayHarness(client);
const sessions = createSessionCapability(gateway);
await sessions.groupsLoad();
await sessions.groupsLoad();
expect(request).toHaveBeenCalledOnce();
sessions.dispose();
});
it("does not probe for a group catalog when the method is explicitly absent", async () => {
const request = vi.fn();
const client = { request } as unknown as GatewayBrowserClient;
const { gateway } = createGatewayHarness(client, []);
const sessions = createSessionCapability(gateway);
await sessions.groupsLoad();
await sessions.groupsLoad();
expect(request).not.toHaveBeenCalled();
expect(sessions.state.groups).toEqual([]);
sessions.dispose();
});
it("ignores an older group load failure after an event-driven load succeeds", async () => {
const firstGroups = deferred<{ groups: Array<{ name: string }> }>();
const currentGroups = deferred<{ groups: Array<{ name: string }> }>();
let groupsCalls = 0;
const request = vi.fn(async (method: string) => {
if (method === "sessions.groups.list") {
groupsCalls += 1;
return await (groupsCalls === 1 ? firstGroups.promise : currentGroups.promise);
}
if (method === "sessions.list") {
return sessionsResult([], 1);
}
throw new Error(`Unexpected request: ${method}`);
});
const client = { request } as unknown as GatewayBrowserClient;
const { gateway, emitEvent } = createGatewayHarness(client, ["sessions.groups.list"]);
const sessions = createSessionCapability(gateway);
const firstLoad = sessions.groupsLoad();
await vi.waitFor(() => expect(groupsCalls).toBe(1));
emitEvent({ type: "event", event: "sessions.changed", payload: { reason: "groups" } });
await vi.waitFor(() => expect(groupsCalls).toBe(2));
currentGroups.resolve({ groups: [{ name: "Current" }] });
await vi.waitFor(() => expect(sessions.state.groups).toEqual(["Current"]));
firstGroups.reject(new Error("stale catalog failure"));
await firstLoad;
await sessions.groupsLoad();
expect(groupsCalls).toBe(2);
expect(sessions.state.groups).toEqual(["Current"]);
sessions.dispose();
});
it("keeps a session when sessions.delete reports no deletion", async () => {
const key = "agent:main:missing";
const request = vi.fn(async (method: string) => {
+79 -10
View File
@@ -1,4 +1,9 @@
import type { GatewayBrowserClient, GatewayEventFrame, GatewayHelloOk } from "../../api/gateway.ts";
import {
GatewayRequestError,
type GatewayBrowserClient,
type GatewayEventFrame,
type GatewayHelloOk,
} from "../../api/gateway.ts";
import type {
FastMode,
GatewaySessionRow,
@@ -14,6 +19,7 @@ import type {
SessionWorkspaceSetResult,
} from "../../api/types.ts";
import { getSafeLocalStorage } from "../../local-storage.ts";
import { isGatewayMethodAdvertised } from "../gateway-methods.ts";
import { isSessionRunActive } from "../session-run-state.ts";
import {
requestSessionCreate,
@@ -230,7 +236,7 @@ export type SessionCapability = {
checkpointId: string,
options?: { agentId?: string | null },
) => Promise<SessionsCompactionRestoreResult>;
/** Loads the gateway-owned group catalog once per connection. */
/** Loads the gateway-owned group catalog, coalescing successful connection attempts. */
groupsLoad: () => Promise<void>;
/** Replaces the gateway-owned group catalog (order included). */
groupsPut: (names: readonly string[]) => Promise<void>;
@@ -839,7 +845,37 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil
(await createResult(params))?.key ?? null;
const LEGACY_GROUPS_STORAGE_KEY = "openclaw:sessions:custom-groups";
const GROUPS_LIST_METHOD = "sessions.groups.list";
const GROUPS_RETRY_DEFAULT_MS = 500;
const GROUPS_RETRY_MIN_MS = 100;
const GROUPS_RETRY_MAX_MS = 30_000;
let groupsLoadedEpoch = -1;
let groupsLoadGeneration = 0;
let groupsRetryTimer: ReturnType<typeof globalThis.setTimeout> | null = null;
const clearGroupsRetry = () => {
if (groupsRetryTimer !== null) {
globalThis.clearTimeout(groupsRetryTimer);
groupsRetryTimer = null;
}
};
const invalidateGroupsLoad = () => {
groupsLoadedEpoch = -1;
groupsLoadGeneration += 1;
clearGroupsRetry();
};
const groupsRetryDelayMs = (error: unknown): number | null => {
if (!(error instanceof GatewayRequestError) || !error.retryable) {
return null;
}
const requested =
typeof error.retryAfterMs === "number" && Number.isFinite(error.retryAfterMs)
? error.retryAfterMs
: GROUPS_RETRY_DEFAULT_MS;
return Math.min(Math.max(requested, GROUPS_RETRY_MIN_MS), GROUPS_RETRY_MAX_MS);
};
const readGroupNames = (payload: unknown): string[] => {
const groups = (payload as { groups?: Array<{ name?: unknown }> } | null)?.groups;
@@ -877,10 +913,14 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil
}
};
const loadGroups = async (scope: SessionConnectionScope) => {
const loadGroups = async (
scope: SessionConnectionScope,
generation: number,
advertised: boolean | null,
) => {
try {
const listed = await scope.client.request("sessions.groups.list", {});
if (!isCurrentConnection(scope)) {
const listed = await scope.client.request(GROUPS_LIST_METHOD, {});
if (!isCurrentConnection(scope) || generation !== groupsLoadGeneration) {
return;
}
let names = readGroupNames(listed);
@@ -888,7 +928,7 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil
const legacy = readLegacyStoredGroups();
if (names.length === 0 && legacy.length > 0) {
const put = await scope.client.request("sessions.groups.put", { names: legacy });
if (!isCurrentConnection(scope)) {
if (!isCurrentConnection(scope) || generation !== groupsLoadGeneration) {
return;
}
names = readGroupNames(put);
@@ -901,8 +941,28 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil
}
}
publishGroups(names);
} catch {
// Older gateways without the groups RPC keep observed-category grouping.
} catch (error) {
if (
!isCurrentConnection(scope) ||
generation !== groupsLoadGeneration ||
advertised !== true
) {
// Gateways without feature metadata retain the legacy one-shot probe.
return;
}
groupsLoadedEpoch = -1;
const retryDelayMs = groupsRetryDelayMs(error);
if (retryDelayMs === null) {
return;
}
// The attempt token prevents an older rejection from reviving a retry
// after a newer event-driven catalog load has already succeeded.
groupsRetryTimer = globalThis.setTimeout(() => {
groupsRetryTimer = null;
if (isCurrentConnection(scope) && generation === groupsLoadGeneration) {
void groupsLoad();
}
}, retryDelayMs);
}
};
@@ -912,8 +972,15 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil
if (!scope || groupsLoadedEpoch === scope.epoch) {
return;
}
const advertised = isGatewayMethodAdvertised(gateway.snapshot, GROUPS_LIST_METHOD);
clearGroupsRetry();
const generation = ++groupsLoadGeneration;
groupsLoadedEpoch = scope.epoch;
await loadGroups(scope);
if (advertised === false) {
publishGroups([]);
return;
}
await loadGroups(scope, generation, advertised);
};
const groupsPut = async (names: readonly string[]) => {
@@ -1316,6 +1383,7 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil
connectionConnected = next.connected;
if (connectionChanged) {
connectionEpoch += 1;
invalidateGroupsLoad();
inFlight = null;
queuedRefresh = null;
rollbackPendingModelPatches();
@@ -1373,7 +1441,7 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil
// reason straight off the payload instead of the parsed row info.
const eventReason = (event.payload as { reason?: unknown } | null)?.reason;
if (eventReason === "groups") {
groupsLoadedEpoch = -1;
invalidateGroupsLoad();
void groupsLoad();
}
const hasActiveRun = reconciled.hasActiveRun ?? eventInfo?.hasActiveRun;
@@ -1443,6 +1511,7 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil
dispose() {
disposed = true;
connectionEpoch += 1;
invalidateGroupsLoad();
connectionConnected = false;
inFlight = null;
queuedRefresh = null;