fix(sessions): recover deleted chats without endless retries (#115974)

* fix(sessions): recover deleted chats without lifecycle races

* fix(sessions): narrow deleted route commit keys

* test(sessions): isolate deleted-chat recovery regressions

* fix(sessions): recover chats after agent removal

* fix(sessions): fence recreated session deletion races

* fix(sessions): preserve filtered active conversations

* test(sessions): preserve canonical main during chat recovery

* test(ui): follow canonical agent channels route
This commit is contained in:
Peter Steinberger
2026-07-29 13:22:29 -04:00
committed by GitHub
parent 9945fffc6d
commit b44890bf2d
8 changed files with 492 additions and 5 deletions

View File

@@ -127,6 +127,50 @@ test("concurrent sessions.create requests adopt one canonical keyed session", as
);
});
test("keyed sessions remain recoverable across overlapping create and delete waves", async () => {
const { storePath } = await createSessionStoreDir();
const key = "agent:main:dashboard:concurrent-lifecycle-waves";
for (let wave = 0; wave < 6; wave += 1) {
const operations = await Promise.all(
Array.from({ length: 12 }, (_, index) =>
index % 3 === 0
? directSessionReq<{ deleted: boolean }>("sessions.delete", {
key,
deleteTranscript: false,
})
: directSessionReq<{ key: string; sessionId: string }>("sessions.create", {
agentId: "main",
key,
}),
),
);
expect(
operations.every((result) => result.ok),
`lifecycle wave ${wave}`,
).toBe(true);
const recovered = await directSessionReq<{ key: string; sessionId: string }>(
"sessions.create",
{ agentId: "main", key },
);
expect(recovered.ok, `creation after lifecycle wave ${wave}`).toBe(true);
expect(recovered.payload?.key).toBe(key);
expect(loadSessionEntry({ sessionKey: key, storePath })?.sessionId).toBe(
recovered.payload?.sessionId,
);
const deleted = await directSessionReq<{ deleted: boolean }>("sessions.delete", {
key,
deleteTranscript: false,
});
expect(deleted.ok, `deletion after lifecycle wave ${wave}`).toBe(true);
expect(deleted.payload?.deleted).toBe(true);
expect(loadSessionEntry({ sessionKey: key, storePath })).toBeUndefined();
}
});
test("sessions.create keeps incognito rows process-local through list, spawn, reset, and delete", async () => {
const { storePath } = await createSessionStoreDir();
try {

View File

@@ -0,0 +1,205 @@
/* @vitest-environment jsdom */
import type { RouteLocation, RouterState } from "@openclaw/uirouter";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { RouteId } from "../app-routes.ts";
import { createStorageMock } from "../test-helpers/storage.ts";
import { selectShellRouteState } from "./app-host-route-state.ts";
import { resetAppHostTestGlobals } from "./app-host.test-support.ts";
import type { ApplicationContext } from "./context.ts";
import "./app-host.ts";
type DeletedSessionShell = {
runtime: { context: ApplicationContext };
activeSessionKey: string;
routeState: { routeId?: RouteId; location?: RouteLocation };
didConsiderNativeRouteRestore: boolean;
replaceChatWithCurrentSession: () => void;
updateRouteState: (state: ReturnType<typeof selectShellRouteState>) => void;
};
const mainKey = "agent:main:main";
const deletedKey = "agent:main:deleted-thread";
function createSessionRecoveryShell(params: {
activeSessionKey: string;
agentIds?: string[];
sessionKeys: string[];
deletedSessionKeys?: string[];
}) {
const replace = vi.fn();
const setSessionKey = vi.fn();
const shell = document.createElement("openclaw-app-shell") as unknown as DeletedSessionShell;
shell.runtime = {
context: {
basePath: "",
agents: {
state: {
agentsList: {
defaultId: "main",
mainKey: "main",
agents: (params.agentIds ?? ["main"]).map((id) => ({ id })),
},
},
},
agentSelection: { set: vi.fn(), state: { selectedId: "main" } },
gateway: {
setSessionKey,
snapshot: { client: null, hello: null, phase: "connected" },
},
sessions: {
state: {
deletedSessions: (params.deletedSessionKeys ?? []).map((key) => ({
agentId: "main",
key,
})),
result: { sessions: params.sessionKeys.map((key) => ({ key })) },
},
},
replace,
} as unknown as ApplicationContext,
};
shell.activeSessionKey = params.activeSessionKey;
shell.routeState = { routeId: "chat" };
return { replace, setSessionKey, shell };
}
afterEach(() => {
resetAppHostTestGlobals();
});
describe("OpenClaw shell deleted-session recovery", () => {
it("replaces an unresolvable session with the owning agent's main chat", () => {
const { replace, setSessionKey, shell } = createSessionRecoveryShell({
activeSessionKey: deletedKey,
sessionKeys: [mainKey],
});
shell.replaceChatWithCurrentSession();
expect(setSessionKey).toHaveBeenCalledExactlyOnceWith(mainKey);
expect(replace).toHaveBeenCalledExactlyOnceWith("chat", { pathname: "/chat/main" });
});
it("preserves the owning non-default agent when its session is deleted", () => {
const researchKey = "agent:research:main";
const { replace, setSessionKey, shell } = createSessionRecoveryShell({
activeSessionKey: "agent:research:deleted-thread",
agentIds: ["main", "research"],
sessionKeys: [mainKey, researchKey],
});
shell.replaceChatWithCurrentSession();
expect(setSessionKey).toHaveBeenCalledExactlyOnceWith(researchKey);
expect(replace).toHaveBeenCalledExactlyOnceWith("chat", { pathname: "/chat/research" });
});
it("recovers to a known agent when the deleted session's owner was removed", () => {
const { replace, setSessionKey, shell } = createSessionRecoveryShell({
activeSessionKey: "agent:retired:deleted-thread",
agentIds: ["main"],
sessionKeys: [mainKey],
});
shell.replaceChatWithCurrentSession();
expect(setSessionKey).toHaveBeenCalledExactlyOnceWith(mainKey);
expect(replace).toHaveBeenCalledExactlyOnceWith("chat", { pathname: "/chat/main" });
});
it("does not replace a main route with the same deleted main session", () => {
const { replace, setSessionKey, shell } = createSessionRecoveryShell({
activeSessionKey: mainKey,
deletedSessionKeys: [mainKey],
sessionKeys: [mainKey],
});
shell.replaceChatWithCurrentSession();
expect(setSessionKey).not.toHaveBeenCalled();
expect(replace).not.toHaveBeenCalled();
});
it("keeps a resolvable active session when a different chat route is not found", () => {
const existingKey = "agent:main:existing-thread";
const { replace, setSessionKey, shell } = createSessionRecoveryShell({
activeSessionKey: existingKey,
sessionKeys: [existingKey],
});
shell.replaceChatWithCurrentSession();
expect(setSessionKey).not.toHaveBeenCalled();
expect(replace).toHaveBeenCalledExactlyOnceWith("chat", {
pathname: "/chat/main/existing-thread",
});
});
it("keeps an active session outside the filtered list when another route fails", () => {
const existingKey = "agent:main:outside-window";
const { replace, setSessionKey, shell } = createSessionRecoveryShell({
activeSessionKey: existingKey,
sessionKeys: [mainKey],
});
shell.routeState = {
routeId: "chat",
location: { pathname: "/chat/main/unrelated-missing", search: "", hash: "" },
};
shell.replaceChatWithCurrentSession();
expect(setSessionKey).not.toHaveBeenCalled();
expect(replace).toHaveBeenCalledExactlyOnceWith("chat", {
pathname: "/chat/main/outside-window",
});
});
it("honors a deleted session event before its stale cached row is refreshed", () => {
const { replace, setSessionKey, shell } = createSessionRecoveryShell({
activeSessionKey: deletedKey,
deletedSessionKeys: [deletedKey],
sessionKeys: [deletedKey, mainKey],
});
shell.replaceChatWithCurrentSession();
expect(setSessionKey).toHaveBeenCalledExactlyOnceWith(mainKey);
expect(replace).toHaveBeenCalledExactlyOnceWith("chat", { pathname: "/chat/main" });
});
it("rejects a late route commit for a session already marked deleted", () => {
vi.stubGlobal("localStorage", createStorageMock());
const { replace, setSessionKey, shell } = createSessionRecoveryShell({
activeSessionKey: deletedKey,
deletedSessionKeys: [deletedKey],
sessionKeys: [mainKey],
});
shell.didConsiderNativeRouteRestore = true;
const location = {
pathname: "/chat/main/deleted-thread",
search: "",
hash: "",
} satisfies RouteLocation;
const routerState = {
location,
resolvedLocation: location,
status: "success",
matches: [
{
routeId: "chat",
location,
data: { kind: "session", sessionKey: deletedKey },
},
],
pendingMatches: [],
cachedMatches: [],
} as unknown as RouterState<RouteId>;
shell.updateRouteState(selectShellRouteState(routerState));
expect(shell.activeSessionKey).toBe(mainKey);
expect(setSessionKey).toHaveBeenCalledExactlyOnceWith(mainKey);
expect(replace).toHaveBeenCalledExactlyOnceWith("chat", { pathname: "/chat/main" });
});
});

View File

@@ -485,8 +485,8 @@ describe("OpenClaw shell route session commits", () => {
agents: {
state: { agentsList: { defaultId: "research", mainKey: "workspace" } },
},
agentSelection: { state: { selectedId: null } },
gateway: { snapshot },
agentSelection: { set: vi.fn(), state: { selectedId: null } },
gateway: { setSessionKey: vi.fn(), snapshot },
sessions: { state: { result: null } },
replace,
} as unknown as ApplicationContext,

View File

@@ -64,15 +64,19 @@ import { resolveAsciiShortcutKey } from "../lib/keyboard-shortcuts.ts";
import { isWorkboardEnabledInConfigSnapshot } from "../lib/plugin-activation.ts";
import { resolveSessionDisplayName } from "../lib/session-display.ts";
import {
findUiSessionRow,
resolveSessionPreferredFaceForKey,
resolveSessionNavigationAgentId,
sessionNavigationTarget,
} from "../lib/sessions/route-navigation.ts";
import {
buildAgentMainSessionKey,
isUiGlobalSessionKey,
normalizeAgentId,
parseAgentSessionKey,
resolveUiConfiguredMainKey,
resolveUiKnownSelectedGlobalAgentId,
uiSessionEventMatches,
} from "../lib/sessions/session-key.ts";
import { isTerminalAvailable } from "../lib/terminal-availability.ts";
import { OpenClawLightDomElement } from "../lit/openclaw-element.ts";
@@ -681,6 +685,7 @@ class OpenClawShell extends OpenClawLightDomElement {
.watch(
() => this.context?.sessions,
(sessions, notify) => sessions.subscribe(notify),
(sessions) => this.recoverDeletedActiveSession(sessions.state),
)
.watch(
() => this.context?.runtimeConfig,
@@ -860,6 +865,13 @@ class OpenClawShell extends OpenClawLightDomElement {
}
private readonly handleGatewayEvent = (event: GatewayEventFrame) => {
this.sidebarWorkboardRuntime?.handleGatewayEvent(event.event);
if (event.event === "sessions.changed") {
const context = this.context;
if (context) {
this.recoverDeletedActiveSession(context.sessions.state);
}
return;
}
if (event.event === "session.observer") {
const context = this.context;
if (context) {
@@ -1033,7 +1045,86 @@ class OpenClawShell extends OpenClawLightDomElement {
return;
}
const face = this.routeState.routeId === "dashboard" ? "dashboard" : "chat";
context.replace(face, this.chatNavigationOptions(face));
const sessionWasDeleted = (context.sessions.state.deletedSessions ?? []).some(
({ key, agentId }) =>
uiSessionEventMatches(
{
agentsList: context.agents.state.agentsList,
hello: context.gateway.snapshot.hello,
sessionKey,
},
key,
agentId,
),
);
// Session lists are filtered and windowed. Only a failed route for this
// active key, or an authoritative deletion, proves it needs replacement.
const activeSessionRow = findUiSessionRow(context, sessionKey);
const attemptedPathname = this.routeState.location?.pathname;
const activeRouteFailed =
!attemptedPathname ||
attemptedPathname === sessionNavigationTarget({ context, face, sessionKey }).options.pathname;
const parsedAgentId = parseAgentSessionKey(sessionKey)?.agentId;
const knownAgents = context.agents.state.agentsList?.agents;
// A parseable retired agent is not a navigable owner; never turn its
// deleted session into another permanently unresolvable chat route.
const replacementAgentId =
parsedAgentId &&
(!knownAgents || knownAgents.some((agent) => normalizeAgentId(agent.id) === parsedAgentId))
? parsedAgentId
: resolveSessionNavigationAgentId(context);
const replacementSessionKey =
!sessionWasDeleted && (activeSessionRow?.key || !activeRouteFailed)
? sessionKey
: buildAgentMainSessionKey({
agentId: replacementAgentId,
mainKey: resolveUiConfiguredMainKey({
agentsList: context.agents.state.agentsList,
hello: context.gateway.snapshot.hello,
}),
});
// Gateway rejects deletion of a live main session. If an orphaned event
// still names that fallback, replacing the same route would retry forever.
if (sessionWasDeleted && replacementSessionKey === sessionKey) {
return;
}
if (replacementSessionKey !== sessionKey) {
// Commit the replacement to both selection owners before navigating;
// otherwise a stale Gateway snapshot restores the deleted key and loops.
this.activeSessionKey = replacementSessionKey;
selectApplicationSession({
selection: context.agentSelection,
gateway: context.gateway,
sessionKey: replacementSessionKey,
});
}
context.replace(
face,
sessionNavigationTarget({ context, face, sessionKey: replacementSessionKey }).options,
);
}
private recoverDeletedActiveSession(sessionState: ApplicationContext["sessions"]["state"]) {
const context = this.context;
const routeId = this.routeState.routeId;
const sessionKey = this.activeSessionKey.trim();
if (!context || !routeId || !isSessionRouteId(routeId) || !sessionKey) {
return;
}
const selectedSessionDeleted = sessionState.deletedSessions.some(({ key, agentId }) =>
uiSessionEventMatches(
{
agentsList: context.agents.state.agentsList,
hello: context.gateway.snapshot.hello,
sessionKey,
},
key,
agentId,
),
);
if (selectedSessionDeleted) {
this.replaceChatWithCurrentSession();
}
}
private isSettingsTakeover(): boolean {
@@ -1725,8 +1816,27 @@ class OpenClawShell extends OpenClawLightDomElement {
}
}
if (!pendingDiffers) {
persistRoute(committedRouteId, committedPathname, committedSearch);
const committedSessionKey = routeState.committedSessionKey;
const committedSessionDeleted =
committedSessionKey !== undefined &&
(routeContext.sessions?.state.deletedSessions ?? []).some(({ key, agentId }) =>
uiSessionEventMatches(
{
agentsList: routeContext.agents.state.agentsList,
hello: routeContext.gateway.snapshot.hello,
sessionKey: committedSessionKey,
},
key,
agentId,
),
);
if (committedSessionDeleted) {
// An older route can commit after deletion recovery has started.
// Never let it persist or reselect the session we just retired.
this.replaceChatWithCurrentSession();
return;
}
persistRoute(committedRouteId, committedPathname, committedSearch);
if (committedSessionKey) {
this.activeSessionKey = committedSessionKey;
selectApplicationSession({

View File

@@ -95,8 +95,9 @@ describeControlUiE2e("Control UI Agents channel status", () => {
});
try {
const response = await page.goto(`${server.baseUrl}agents`);
const response = await page.goto(`${server.baseUrl}settings/agents/main/channels`);
expect(response?.status()).toBe(200);
await expect.poll(() => new URL(page.url()).pathname).toBe("/settings/agents/main/channels");
await page.getByRole("tab", { name: /^Channels/ }).click();
const discordRow = page.locator(".settings-row").filter({ hasText: "Discord" });

View File

@@ -1,8 +1,10 @@
import { expect, it } from "vitest";
import { expectRequestCountStable } from "./chat-flow.test-support.ts";
import {
activateMenuItem,
captureUiProof,
controlUiSessionPath,
controlUiSessionUrl,
createSessionManagementE2eSuite,
installMockGateway,
requireRecord,
@@ -312,6 +314,78 @@ suite.define(() => {
}
});
it("recovers a deleted active chat without repeatedly resolving its missing session", async () => {
const context = await suite.browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const routeErrors: string[] = [];
page.on("console", (message) => {
if (message.type() === "error" && message.text().includes("route")) {
routeErrors.push(message.text());
}
});
const deletedKey = "agent:main:deleted-thread";
const mainKey = "agent:main:main";
const updatedAt = Date.parse("2026-07-01T16:00:00.000Z");
const mainSession = sessionRow(mainKey, "Main", updatedAt);
const gateway = await installMockGateway(page, {
methodResponses: {
"sessions.list": sessionsListResponse([
mainSession,
sessionRow(deletedKey, "Deleted thread", updatedAt - 1_000),
]),
},
sessionKey: mainKey,
});
try {
await page.goto(controlUiSessionUrl(suite.server.baseUrl, deletedKey));
await page
.locator(".agent-chat__input textarea")
.waitFor({ state: "visible", timeout: 10_000 });
const requestsBeforeDeletion = (await gateway.getRequests("sessions.list")).length;
await gateway.setMethodResponse("sessions.list", sessionsListResponse([mainSession]));
await gateway.emitGatewayEvent("sessions.changed", {
agentId: "main",
reason: "delete",
sessionKey: deletedKey,
});
await expect
.poll(() => new URL(page.url()).pathname, { timeout: 15_000 })
.toBe(controlUiSessionPath(mainKey));
await page
.locator(".agent-chat__input textarea")
.waitFor({ state: "visible", timeout: 10_000 });
await expect
.poll(async () => (await gateway.getRequests("sessions.list")).length)
.toBeGreaterThan(requestsBeforeDeletion);
await expect
.poll(
async () => {
const count = (await gateway.getRequests("sessions.list")).length;
await new Promise((resolve) => {
setTimeout(resolve, 350);
});
return (await gateway.getRequests("sessions.list")).length - count;
},
{ timeout: 5_000 },
)
.toBe(0);
const settledRequestCount = (await gateway.getRequests("sessions.list")).length;
await expectRequestCountStable(gateway, "sessions.list", settledRequestCount);
expect(routeErrors).toEqual([]);
await captureUiProof(page, "deleted-active-session-fallback.png");
} finally {
await context.close();
}
});
it("keeps a session row when the Gateway reports no deletion", async () => {
const context = await suite.browser.newContext({
locale: "en-US",

View File

@@ -56,6 +56,40 @@ function createHarness(request: GatewayBrowserClient["request"]) {
}
describe("event-driven session list refresh", () => {
it("clears a recreated session's prior deletion before the debounced refresh", async () => {
vi.useFakeTimers();
const key = "agent:main:recreated-thread";
const request = vi.fn(async (method: string) => {
if (method !== "sessions.list") {
throw new Error(`Unexpected request: ${method}`);
}
return sessionsResult(1);
});
const { sessions, emitEvent } = createHarness(
request as unknown as GatewayBrowserClient["request"],
);
try {
await sessions.refresh({ force: true });
emitEvent({
type: "event",
event: "sessions.changed",
payload: { sessionKey: key, reason: "delete" },
});
expect(sessions.state.deletedSessions).toEqual([{ key, agentId: undefined }]);
emitEvent(sessionChangedEvent(key));
expect(sessions.state.deletedSessions).toEqual([]);
expect(request).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(SESSION_EVENT_REFRESH_DEBOUNCE_MS);
expect(request).toHaveBeenCalledTimes(2);
} finally {
sessions.dispose();
vi.useRealTimers();
}
});
it("debounces rapid session events into one trailing list refresh", async () => {
vi.useFakeTimers();
const request = vi.fn(async (method: string) => {

View File

@@ -55,6 +55,7 @@ import {
normalizeAgentId,
parseAgentSessionKey,
resolveUiSelectedGlobalAgentId,
uiSessionEventMatches,
uiSessionRowMatchesSelectedChat,
} from "./session-key.ts";
import { SwarmActivityTracker } from "./swarm-activity.ts";
@@ -1913,6 +1914,24 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil
{ key: reconciled.deletedKey, agentId: reconciled.agentId ?? undefined },
],
});
} else if ((eventReason === "create" || eventReason === "new") && eventInfo) {
const remainingDeletedSessions = state.deletedSessions.filter(
({ key, agentId }) =>
!uiSessionEventMatches(
{
assistantAgentId: agentId ?? gateway.snapshot.assistantAgentId,
hello: gateway.snapshot.hello,
sessionKey: key,
},
eventInfo.key,
eventInfo.agentId,
),
);
if (remainingDeletedSessions.length !== state.deletedSessions.length) {
// Gateway create events are ordered after the prior deletion. Retire
// only that generation's marker before the debounced list refresh.
publish({ ...state, deletedSessions: remainingDeletedSessions });
}
}
// Gateway lists are filtered and windowed. Events cannot preserve server
// membership or ordering, so the coalesced refresh remains canonical. Only