mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 08:50:43 +00:00
perf(gateway): trim session list hot path
This commit is contained in:
@@ -7,7 +7,10 @@ import { normalizeAgentId } from "../../routing/session-key.js";
|
||||
import type { OpenClawConfig } from "../types.openclaw.js";
|
||||
import { resolveStorePath } from "./paths.js";
|
||||
import { loadSessionStore } from "./store-load.js";
|
||||
import { resolveAllAgentSessionStoreTargetsSync } from "./targets.js";
|
||||
import {
|
||||
resolveAgentSessionStoreTargetsSync,
|
||||
resolveAllAgentSessionStoreTargetsSync,
|
||||
} from "./targets.js";
|
||||
import type { SessionEntry } from "./types.js";
|
||||
|
||||
function isStorePathTemplate(store?: string): boolean {
|
||||
@@ -25,20 +28,31 @@ function mergeSessionEntryIntoCombined(params: {
|
||||
const existing = combined[canonicalKey];
|
||||
|
||||
if (existing && (existing.updatedAt ?? 0) > (entry.updatedAt ?? 0)) {
|
||||
const spawnedBy = canonicalizeSpawnedByForAgent(
|
||||
cfg,
|
||||
agentId,
|
||||
existing.spawnedBy ?? entry.spawnedBy,
|
||||
);
|
||||
combined[canonicalKey] = {
|
||||
...entry,
|
||||
...existing,
|
||||
spawnedBy: canonicalizeSpawnedByForAgent(cfg, agentId, existing.spawnedBy ?? entry.spawnedBy),
|
||||
spawnedBy,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const spawnedBy = canonicalizeSpawnedByForAgent(
|
||||
cfg,
|
||||
agentId,
|
||||
entry.spawnedBy ?? existing?.spawnedBy,
|
||||
);
|
||||
if (!existing && entry.spawnedBy === spawnedBy) {
|
||||
combined[canonicalKey] = entry;
|
||||
} else {
|
||||
combined[canonicalKey] = {
|
||||
...existing,
|
||||
...entry,
|
||||
spawnedBy: canonicalizeSpawnedByForAgent(
|
||||
cfg,
|
||||
agentId,
|
||||
entry.spawnedBy ?? existing?.spawnedBy,
|
||||
),
|
||||
spawnedBy,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -77,9 +91,9 @@ export function loadCombinedSessionStoreForGateway(
|
||||
typeof opts.agentId === "string" && opts.agentId.trim()
|
||||
? normalizeAgentId(opts.agentId)
|
||||
: undefined;
|
||||
const targets = resolveAllAgentSessionStoreTargetsSync(cfg).filter(
|
||||
(target) => !requestedAgentId || normalizeAgentId(target.agentId) === requestedAgentId,
|
||||
);
|
||||
const targets = requestedAgentId
|
||||
? resolveAgentSessionStoreTargetsSync(cfg, requestedAgentId)
|
||||
: resolveAllAgentSessionStoreTargetsSync(cfg);
|
||||
const combined: Record<string, SessionEntry> = {};
|
||||
for (const target of targets) {
|
||||
const agentId = target.agentId;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config.js";
|
||||
import { resolveStorePath } from "./paths.js";
|
||||
import {
|
||||
resolveAgentSessionStoreTargetsSync,
|
||||
resolveAllAgentSessionStoreTargets,
|
||||
resolveAllAgentSessionStoreTargetsSync,
|
||||
resolveSessionStoreTargets,
|
||||
@@ -138,6 +139,38 @@ describe("resolveSessionStoreTargets", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveAgentSessionStoreTargetsSync", () => {
|
||||
it("resolves one requested agent store from the direct path", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const customRoot = path.join(home, "custom-state");
|
||||
const storePaths = await createAgentSessionStores(customRoot, ["main", "codex"]);
|
||||
const cfg = createCustomRootCfg(customRoot, "main");
|
||||
|
||||
expect(resolveAgentSessionStoreTargetsSync(cfg, "codex", { env: process.env })).toEqual([
|
||||
{
|
||||
agentId: "codex",
|
||||
storePath: storePaths.codex,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("finds discovered directories whose names normalize to the requested agent", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const customRoot = path.join(home, "custom-state");
|
||||
const storePaths = await createAgentSessionStores(customRoot, ["main", "Retired Agent"]);
|
||||
const cfg = createCustomRootCfg(customRoot, "main");
|
||||
|
||||
expect(
|
||||
resolveAgentSessionStoreTargetsSync(cfg, "retired-agent", { env: process.env }),
|
||||
).toContainEqual({
|
||||
agentId: "retired-agent",
|
||||
storePath: storePaths["Retired Agent"],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveAllAgentSessionStoreTargets", () => {
|
||||
it("includes discovered on-disk agent stores alongside configured targets", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
|
||||
@@ -209,6 +209,91 @@ export function resolveAllAgentSessionStoreTargetsSync(
|
||||
return dedupeTargetsByStorePath([...validatedConfiguredTargets, ...discoveredTargets]);
|
||||
}
|
||||
|
||||
export function resolveAgentSessionStoreTargetsSync(
|
||||
cfg: OpenClawConfig,
|
||||
agentId: string,
|
||||
params: { env?: NodeJS.ProcessEnv } = {},
|
||||
): SessionStoreTarget[] {
|
||||
const env = params.env ?? process.env;
|
||||
const requested = normalizeAgentId(agentId);
|
||||
const storePaths = new Set<string>([
|
||||
resolveStorePath(cfg.session?.store, { agentId: requested, env }),
|
||||
resolveStorePath(undefined, { agentId: requested, env }),
|
||||
]);
|
||||
const targets: SessionStoreTarget[] = [];
|
||||
const realAgentsRoots = new Map<string, string | undefined>();
|
||||
const getRealAgentsRoot = (agentsRoot: string): string | undefined => {
|
||||
if (realAgentsRoots.has(agentsRoot)) {
|
||||
return realAgentsRoots.get(agentsRoot);
|
||||
}
|
||||
try {
|
||||
const realAgentsRoot = fsSync.realpathSync.native(agentsRoot);
|
||||
realAgentsRoots.set(agentsRoot, realAgentsRoot);
|
||||
return realAgentsRoot;
|
||||
} catch (err) {
|
||||
if (shouldSkipDiscoveryError(err)) {
|
||||
realAgentsRoots.set(agentsRoot, undefined);
|
||||
return undefined;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
for (const storePath of storePaths) {
|
||||
const agentsRoot = resolveAgentsDirFromSessionStorePath(storePath);
|
||||
if (!agentsRoot) {
|
||||
targets.push({ agentId: requested, storePath });
|
||||
continue;
|
||||
}
|
||||
const realAgentsRoot = getRealAgentsRoot(agentsRoot);
|
||||
if (!realAgentsRoot) {
|
||||
continue;
|
||||
}
|
||||
const validatedStorePath = resolveValidatedDiscoveredStorePathSync({
|
||||
sessionsDir: path.dirname(storePath),
|
||||
agentsRoot,
|
||||
realAgentsRoot,
|
||||
});
|
||||
if (validatedStorePath) {
|
||||
targets.push({ agentId: requested, storePath: validatedStorePath });
|
||||
}
|
||||
}
|
||||
|
||||
const { agentsRoots } = resolveSessionStoreDiscoveryState(cfg, env);
|
||||
for (const agentsDir of agentsRoots) {
|
||||
try {
|
||||
const realAgentsRoot = getRealAgentsRoot(agentsDir);
|
||||
if (!realAgentsRoot) {
|
||||
continue;
|
||||
}
|
||||
for (const sessionsDir of resolveAgentSessionDirsFromAgentsDirSync(agentsDir)) {
|
||||
const target = toDiscoveredSessionStoreTarget(
|
||||
sessionsDir,
|
||||
path.join(sessionsDir, "sessions.json"),
|
||||
);
|
||||
if (!target || normalizeAgentId(target.agentId) !== requested) {
|
||||
continue;
|
||||
}
|
||||
const validatedStorePath = resolveValidatedDiscoveredStorePathSync({
|
||||
sessionsDir,
|
||||
agentsRoot: agentsDir,
|
||||
realAgentsRoot,
|
||||
});
|
||||
if (validatedStorePath) {
|
||||
targets.push({ ...target, storePath: validatedStorePath });
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (shouldSkipDiscoveryError(err)) {
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
return dedupeTargetsByStorePath(targets);
|
||||
}
|
||||
|
||||
export async function resolveAllAgentSessionStoreTargets(
|
||||
cfg: OpenClawConfig,
|
||||
params: { env?: NodeJS.ProcessEnv } = {},
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
resetSubagentRegistryForTests,
|
||||
} from "../agents/subagent-registry.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { SessionEntry } from "../config/sessions.js";
|
||||
import { loadSessionStore, type SessionEntry } from "../config/sessions.js";
|
||||
import { registerAgentRunContext, resetAgentRunContextForTest } from "../infra/agent-events.js";
|
||||
import { withStateDirEnv } from "../test-helpers/state-dir-env.js";
|
||||
import { withEnv } from "../test-utils/env.js";
|
||||
@@ -1208,4 +1208,42 @@ describe("loadCombinedSessionStoreForGateway includes disk-only agents (#32804)"
|
||||
readSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps canonical single-target entries by reference", async () => {
|
||||
await withStateDirEnv("openclaw-acp-canonical-", async ({ stateDir }) => {
|
||||
const customRoot = path.join(stateDir, "custom-state");
|
||||
const codexDir = path.join(customRoot, "agents", "codex", "sessions");
|
||||
fs.mkdirSync(codexDir, { recursive: true });
|
||||
|
||||
const codexStorePath = path.join(codexDir, "sessions.json");
|
||||
fs.writeFileSync(
|
||||
codexStorePath,
|
||||
JSON.stringify({
|
||||
"agent:codex:acp-task": {
|
||||
sessionId: "s-codex",
|
||||
updatedAt: 200,
|
||||
spawnedBy: "agent:codex:main",
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const cfg = {
|
||||
session: {
|
||||
mainKey: "main",
|
||||
store: path.join(customRoot, "agents", "{agentId}", "sessions", "sessions.json"),
|
||||
},
|
||||
agents: {
|
||||
list: [{ id: "codex", default: true }],
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const cachedStore = loadSessionStore(fs.realpathSync.native(codexStorePath), {
|
||||
clone: false,
|
||||
});
|
||||
const { store } = loadCombinedSessionStoreForGateway(cfg, { agentId: "codex" });
|
||||
|
||||
expect(store["agent:codex:acp-task"]).toBe(cachedStore["agent:codex:acp-task"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1217,6 +1217,30 @@ describe("listSessionsFromStore selected model display", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("uses bounded top-N selection for small limited lists", () => {
|
||||
const now = Date.now();
|
||||
const store: Record<string, SessionEntry> = {
|
||||
"agent:main:old": { sessionId: "old", updatedAt: now - 10_000 } as SessionEntry,
|
||||
"agent:main:newest": { sessionId: "newest", updatedAt: now } as SessionEntry,
|
||||
"agent:main:middle-a": { sessionId: "middle-a", updatedAt: now - 5_000 } as SessionEntry,
|
||||
"agent:main:middle-b": { sessionId: "middle-b", updatedAt: now - 5_000 } as SessionEntry,
|
||||
"agent:main:newer": { sessionId: "newer", updatedAt: now - 1_000 } as SessionEntry,
|
||||
};
|
||||
const result = listSessionsFromStore({
|
||||
cfg: createModelDefaultsConfig({ primary: "openai/gpt-5.4" }),
|
||||
storePath: "/tmp/sessions.json",
|
||||
store,
|
||||
opts: { limit: 4 },
|
||||
});
|
||||
|
||||
expect(result.sessions.map((session) => session.key)).toEqual([
|
||||
"agent:main:newest",
|
||||
"agent:main:newer",
|
||||
"agent:main:middle-a",
|
||||
"agent:main:middle-b",
|
||||
]);
|
||||
});
|
||||
|
||||
test("shows the selected override model even when a fallback runtime model exists", () => {
|
||||
const cfg = createModelDefaultsConfig({
|
||||
primary: "anthropic/claude-opus-4-6",
|
||||
|
||||
@@ -1758,6 +1758,54 @@ export function loadGatewaySessionRow(
|
||||
* avoiding excessive yielding overhead for small stores.
|
||||
*/
|
||||
const SESSIONS_LIST_YIELD_BATCH_SIZE = 10;
|
||||
const SESSIONS_LIST_TOP_N_LIMIT = 200;
|
||||
|
||||
type SessionEntryPair = [string, SessionEntry];
|
||||
|
||||
function compareSessionEntryPairsByUpdatedAt(a: SessionEntryPair, b: SessionEntryPair): number {
|
||||
return (b[1]?.updatedAt ?? 0) - (a[1]?.updatedAt ?? 0);
|
||||
}
|
||||
|
||||
function resolveSessionsListLimit(
|
||||
opts: import("./protocol/index.js").SessionsListParams,
|
||||
): number | undefined {
|
||||
if (typeof opts.limit !== "number" || !Number.isFinite(opts.limit)) {
|
||||
return undefined;
|
||||
}
|
||||
return Math.max(1, Math.floor(opts.limit));
|
||||
}
|
||||
|
||||
function selectNewestLimitedEntries(
|
||||
entries: SessionEntryPair[],
|
||||
limit: number,
|
||||
): SessionEntryPair[] {
|
||||
const selected: SessionEntryPair[] = [];
|
||||
for (const entry of entries) {
|
||||
const insertAt = selected.findIndex(
|
||||
(candidate) => compareSessionEntryPairsByUpdatedAt(entry, candidate) < 0,
|
||||
);
|
||||
if (insertAt >= 0) {
|
||||
selected.splice(insertAt, 0, entry);
|
||||
if (selected.length > limit) {
|
||||
selected.pop();
|
||||
}
|
||||
} else if (selected.length < limit) {
|
||||
selected.push(entry);
|
||||
}
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
function sortAndLimitSessionEntries(
|
||||
entries: SessionEntryPair[],
|
||||
limit: number | undefined,
|
||||
): SessionEntryPair[] {
|
||||
if (limit !== undefined && limit <= SESSIONS_LIST_TOP_N_LIMIT) {
|
||||
return selectNewestLimitedEntries(entries, limit);
|
||||
}
|
||||
const sorted = entries.toSorted(compareSessionEntryPairsByUpdatedAt);
|
||||
return limit === undefined ? sorted : sorted.slice(0, limit);
|
||||
}
|
||||
|
||||
export function filterAndSortSessionEntries(params: {
|
||||
store: Record<string, SessionEntry>;
|
||||
@@ -1829,8 +1877,7 @@ export function filterAndSortSessionEntries(params: {
|
||||
return true;
|
||||
}
|
||||
return entry?.label === label;
|
||||
})
|
||||
.toSorted((a, b) => (b[1]?.updatedAt ?? 0) - (a[1]?.updatedAt ?? 0));
|
||||
});
|
||||
|
||||
if (search) {
|
||||
entries = entries.filter(([key, entry]) => {
|
||||
@@ -1852,12 +1899,7 @@ export function filterAndSortSessionEntries(params: {
|
||||
entries = entries.filter(([, entry]) => (entry?.updatedAt ?? 0) >= cutoff);
|
||||
}
|
||||
|
||||
if (typeof opts.limit === "number" && Number.isFinite(opts.limit)) {
|
||||
const limit = Math.max(1, Math.floor(opts.limit));
|
||||
entries = entries.slice(0, limit);
|
||||
}
|
||||
|
||||
return entries;
|
||||
return sortAndLimitSessionEntries(entries, resolveSessionsListLimit(opts));
|
||||
}
|
||||
|
||||
export function listSessionsFromStore(params: {
|
||||
|
||||
Reference in New Issue
Block a user