mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
fix(ui): retry session group catalog loads (#106169)
This commit is contained in:
committed by
GitHub
parent
98e3f729bc
commit
bae913ba0c
@@ -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"],
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user