Skip to content

Commit 3aaf30f

Browse files
committed
perf(gateway): trim session list hot path
1 parent da4ac53 commit 3aaf30f

6 files changed

Lines changed: 255 additions & 19 deletions

File tree

src/config/sessions/combined-store-gateway.ts

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@ import { normalizeAgentId } from "../../routing/session-key.js";
77
import type { OpenClawConfig } from "../types.openclaw.js";
88
import { resolveStorePath } from "./paths.js";
99
import { loadSessionStore } from "./store-load.js";
10-
import { resolveAllAgentSessionStoreTargetsSync } from "./targets.js";
10+
import {
11+
resolveAgentSessionStoreTargetsSync,
12+
resolveAllAgentSessionStoreTargetsSync,
13+
} from "./targets.js";
1114
import type { SessionEntry } from "./types.js";
1215

1316
function isStorePathTemplate(store?: string): boolean {
@@ -25,20 +28,31 @@ function mergeSessionEntryIntoCombined(params: {
2528
const existing = combined[canonicalKey];
2629

2730
if (existing && (existing.updatedAt ?? 0) > (entry.updatedAt ?? 0)) {
31+
const spawnedBy = canonicalizeSpawnedByForAgent(
32+
cfg,
33+
agentId,
34+
existing.spawnedBy ?? entry.spawnedBy,
35+
);
2836
combined[canonicalKey] = {
2937
...entry,
3038
...existing,
31-
spawnedBy: canonicalizeSpawnedByForAgent(cfg, agentId, existing.spawnedBy ?? entry.spawnedBy),
39+
spawnedBy,
3240
};
41+
return;
42+
}
43+
44+
const spawnedBy = canonicalizeSpawnedByForAgent(
45+
cfg,
46+
agentId,
47+
entry.spawnedBy ?? existing?.spawnedBy,
48+
);
49+
if (!existing && entry.spawnedBy === spawnedBy) {
50+
combined[canonicalKey] = entry;
3351
} else {
3452
combined[canonicalKey] = {
3553
...existing,
3654
...entry,
37-
spawnedBy: canonicalizeSpawnedByForAgent(
38-
cfg,
39-
agentId,
40-
entry.spawnedBy ?? existing?.spawnedBy,
41-
),
55+
spawnedBy,
4256
};
4357
}
4458
}
@@ -77,9 +91,9 @@ export function loadCombinedSessionStoreForGateway(
7791
typeof opts.agentId === "string" && opts.agentId.trim()
7892
? normalizeAgentId(opts.agentId)
7993
: undefined;
80-
const targets = resolveAllAgentSessionStoreTargetsSync(cfg).filter(
81-
(target) => !requestedAgentId || normalizeAgentId(target.agentId) === requestedAgentId,
82-
);
94+
const targets = requestedAgentId
95+
? resolveAgentSessionStoreTargetsSync(cfg, requestedAgentId)
96+
: resolveAllAgentSessionStoreTargetsSync(cfg);
8397
const combined: Record<string, SessionEntry> = {};
8498
for (const target of targets) {
8599
const agentId = target.agentId;

src/config/sessions/targets.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { describe, expect, it } from "vitest";
66
import type { OpenClawConfig } from "../config.js";
77
import { resolveStorePath } from "./paths.js";
88
import {
9+
resolveAgentSessionStoreTargetsSync,
910
resolveAllAgentSessionStoreTargets,
1011
resolveAllAgentSessionStoreTargetsSync,
1112
resolveSessionStoreTargets,
@@ -138,6 +139,38 @@ describe("resolveSessionStoreTargets", () => {
138139
});
139140
});
140141

142+
describe("resolveAgentSessionStoreTargetsSync", () => {
143+
it("resolves one requested agent store from the direct path", async () => {
144+
await withTempHome(async (home) => {
145+
const customRoot = path.join(home, "custom-state");
146+
const storePaths = await createAgentSessionStores(customRoot, ["main", "codex"]);
147+
const cfg = createCustomRootCfg(customRoot, "main");
148+
149+
expect(resolveAgentSessionStoreTargetsSync(cfg, "codex", { env: process.env })).toEqual([
150+
{
151+
agentId: "codex",
152+
storePath: storePaths.codex,
153+
},
154+
]);
155+
});
156+
});
157+
158+
it("finds discovered directories whose names normalize to the requested agent", async () => {
159+
await withTempHome(async (home) => {
160+
const customRoot = path.join(home, "custom-state");
161+
const storePaths = await createAgentSessionStores(customRoot, ["main", "Retired Agent"]);
162+
const cfg = createCustomRootCfg(customRoot, "main");
163+
164+
expect(
165+
resolveAgentSessionStoreTargetsSync(cfg, "retired-agent", { env: process.env }),
166+
).toContainEqual({
167+
agentId: "retired-agent",
168+
storePath: storePaths["Retired Agent"],
169+
});
170+
});
171+
});
172+
});
173+
141174
describe("resolveAllAgentSessionStoreTargets", () => {
142175
it("includes discovered on-disk agent stores alongside configured targets", async () => {
143176
await withTempHome(async (home) => {

src/config/sessions/targets.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,91 @@ export function resolveAllAgentSessionStoreTargetsSync(
209209
return dedupeTargetsByStorePath([...validatedConfiguredTargets, ...discoveredTargets]);
210210
}
211211

212+
export function resolveAgentSessionStoreTargetsSync(
213+
cfg: OpenClawConfig,
214+
agentId: string,
215+
params: { env?: NodeJS.ProcessEnv } = {},
216+
): SessionStoreTarget[] {
217+
const env = params.env ?? process.env;
218+
const requested = normalizeAgentId(agentId);
219+
const storePaths = new Set<string>([
220+
resolveStorePath(cfg.session?.store, { agentId: requested, env }),
221+
resolveStorePath(undefined, { agentId: requested, env }),
222+
]);
223+
const targets: SessionStoreTarget[] = [];
224+
const realAgentsRoots = new Map<string, string | undefined>();
225+
const getRealAgentsRoot = (agentsRoot: string): string | undefined => {
226+
if (realAgentsRoots.has(agentsRoot)) {
227+
return realAgentsRoots.get(agentsRoot);
228+
}
229+
try {
230+
const realAgentsRoot = fsSync.realpathSync.native(agentsRoot);
231+
realAgentsRoots.set(agentsRoot, realAgentsRoot);
232+
return realAgentsRoot;
233+
} catch (err) {
234+
if (shouldSkipDiscoveryError(err)) {
235+
realAgentsRoots.set(agentsRoot, undefined);
236+
return undefined;
237+
}
238+
throw err;
239+
}
240+
};
241+
242+
for (const storePath of storePaths) {
243+
const agentsRoot = resolveAgentsDirFromSessionStorePath(storePath);
244+
if (!agentsRoot) {
245+
targets.push({ agentId: requested, storePath });
246+
continue;
247+
}
248+
const realAgentsRoot = getRealAgentsRoot(agentsRoot);
249+
if (!realAgentsRoot) {
250+
continue;
251+
}
252+
const validatedStorePath = resolveValidatedDiscoveredStorePathSync({
253+
sessionsDir: path.dirname(storePath),
254+
agentsRoot,
255+
realAgentsRoot,
256+
});
257+
if (validatedStorePath) {
258+
targets.push({ agentId: requested, storePath: validatedStorePath });
259+
}
260+
}
261+
262+
const { agentsRoots } = resolveSessionStoreDiscoveryState(cfg, env);
263+
for (const agentsDir of agentsRoots) {
264+
try {
265+
const realAgentsRoot = getRealAgentsRoot(agentsDir);
266+
if (!realAgentsRoot) {
267+
continue;
268+
}
269+
for (const sessionsDir of resolveAgentSessionDirsFromAgentsDirSync(agentsDir)) {
270+
const target = toDiscoveredSessionStoreTarget(
271+
sessionsDir,
272+
path.join(sessionsDir, "sessions.json"),
273+
);
274+
if (!target || normalizeAgentId(target.agentId) !== requested) {
275+
continue;
276+
}
277+
const validatedStorePath = resolveValidatedDiscoveredStorePathSync({
278+
sessionsDir,
279+
agentsRoot: agentsDir,
280+
realAgentsRoot,
281+
});
282+
if (validatedStorePath) {
283+
targets.push({ ...target, storePath: validatedStorePath });
284+
}
285+
}
286+
} catch (err) {
287+
if (shouldSkipDiscoveryError(err)) {
288+
continue;
289+
}
290+
throw err;
291+
}
292+
}
293+
294+
return dedupeTargetsByStorePath(targets);
295+
}
296+
212297
export async function resolveAllAgentSessionStoreTargets(
213298
cfg: OpenClawConfig,
214299
params: { env?: NodeJS.ProcessEnv } = {},

src/gateway/session-utils.subagent.test.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
resetSubagentRegistryForTests,
88
} from "../agents/subagent-registry.js";
99
import type { OpenClawConfig } from "../config/config.js";
10-
import type { SessionEntry } from "../config/sessions.js";
10+
import { loadSessionStore, type SessionEntry } from "../config/sessions.js";
1111
import { registerAgentRunContext, resetAgentRunContextForTest } from "../infra/agent-events.js";
1212
import { withStateDirEnv } from "../test-helpers/state-dir-env.js";
1313
import { withEnv } from "../test-utils/env.js";
@@ -1208,4 +1208,42 @@ describe("loadCombinedSessionStoreForGateway includes disk-only agents (#32804)"
12081208
readSpy.mockRestore();
12091209
});
12101210
});
1211+
1212+
test("keeps canonical single-target entries by reference", async () => {
1213+
await withStateDirEnv("openclaw-acp-canonical-", async ({ stateDir }) => {
1214+
const customRoot = path.join(stateDir, "custom-state");
1215+
const codexDir = path.join(customRoot, "agents", "codex", "sessions");
1216+
fs.mkdirSync(codexDir, { recursive: true });
1217+
1218+
const codexStorePath = path.join(codexDir, "sessions.json");
1219+
fs.writeFileSync(
1220+
codexStorePath,
1221+
JSON.stringify({
1222+
"agent:codex:acp-task": {
1223+
sessionId: "s-codex",
1224+
updatedAt: 200,
1225+
spawnedBy: "agent:codex:main",
1226+
},
1227+
}),
1228+
"utf8",
1229+
);
1230+
1231+
const cfg = {
1232+
session: {
1233+
mainKey: "main",
1234+
store: path.join(customRoot, "agents", "{agentId}", "sessions", "sessions.json"),
1235+
},
1236+
agents: {
1237+
list: [{ id: "codex", default: true }],
1238+
},
1239+
} as OpenClawConfig;
1240+
1241+
const cachedStore = loadSessionStore(fs.realpathSync.native(codexStorePath), {
1242+
clone: false,
1243+
});
1244+
const { store } = loadCombinedSessionStoreForGateway(cfg, { agentId: "codex" });
1245+
1246+
expect(store["agent:codex:acp-task"]).toBe(cachedStore["agent:codex:acp-task"]);
1247+
});
1248+
});
12111249
});

src/gateway/session-utils.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1217,6 +1217,30 @@ describe("listSessionsFromStore selected model display", () => {
12171217
}
12181218
});
12191219

1220+
test("uses bounded top-N selection for small limited lists", () => {
1221+
const now = Date.now();
1222+
const store: Record<string, SessionEntry> = {
1223+
"agent:main:old": { sessionId: "old", updatedAt: now - 10_000 } as SessionEntry,
1224+
"agent:main:newest": { sessionId: "newest", updatedAt: now } as SessionEntry,
1225+
"agent:main:middle-a": { sessionId: "middle-a", updatedAt: now - 5_000 } as SessionEntry,
1226+
"agent:main:middle-b": { sessionId: "middle-b", updatedAt: now - 5_000 } as SessionEntry,
1227+
"agent:main:newer": { sessionId: "newer", updatedAt: now - 1_000 } as SessionEntry,
1228+
};
1229+
const result = listSessionsFromStore({
1230+
cfg: createModelDefaultsConfig({ primary: "openai/gpt-5.4" }),
1231+
storePath: "/tmp/sessions.json",
1232+
store,
1233+
opts: { limit: 4 },
1234+
});
1235+
1236+
expect(result.sessions.map((session) => session.key)).toEqual([
1237+
"agent:main:newest",
1238+
"agent:main:newer",
1239+
"agent:main:middle-a",
1240+
"agent:main:middle-b",
1241+
]);
1242+
});
1243+
12201244
test("shows the selected override model even when a fallback runtime model exists", () => {
12211245
const cfg = createModelDefaultsConfig({
12221246
primary: "anthropic/claude-opus-4-6",

src/gateway/session-utils.ts

Lines changed: 50 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1758,6 +1758,54 @@ export function loadGatewaySessionRow(
17581758
* avoiding excessive yielding overhead for small stores.
17591759
*/
17601760
const SESSIONS_LIST_YIELD_BATCH_SIZE = 10;
1761+
const SESSIONS_LIST_TOP_N_LIMIT = 200;
1762+
1763+
type SessionEntryPair = [string, SessionEntry];
1764+
1765+
function compareSessionEntryPairsByUpdatedAt(a: SessionEntryPair, b: SessionEntryPair): number {
1766+
return (b[1]?.updatedAt ?? 0) - (a[1]?.updatedAt ?? 0);
1767+
}
1768+
1769+
function resolveSessionsListLimit(
1770+
opts: import("./protocol/index.js").SessionsListParams,
1771+
): number | undefined {
1772+
if (typeof opts.limit !== "number" || !Number.isFinite(opts.limit)) {
1773+
return undefined;
1774+
}
1775+
return Math.max(1, Math.floor(opts.limit));
1776+
}
1777+
1778+
function selectNewestLimitedEntries(
1779+
entries: SessionEntryPair[],
1780+
limit: number,
1781+
): SessionEntryPair[] {
1782+
const selected: SessionEntryPair[] = [];
1783+
for (const entry of entries) {
1784+
const insertAt = selected.findIndex(
1785+
(candidate) => compareSessionEntryPairsByUpdatedAt(entry, candidate) < 0,
1786+
);
1787+
if (insertAt >= 0) {
1788+
selected.splice(insertAt, 0, entry);
1789+
if (selected.length > limit) {
1790+
selected.pop();
1791+
}
1792+
} else if (selected.length < limit) {
1793+
selected.push(entry);
1794+
}
1795+
}
1796+
return selected;
1797+
}
1798+
1799+
function sortAndLimitSessionEntries(
1800+
entries: SessionEntryPair[],
1801+
limit: number | undefined,
1802+
): SessionEntryPair[] {
1803+
if (limit !== undefined && limit <= SESSIONS_LIST_TOP_N_LIMIT) {
1804+
return selectNewestLimitedEntries(entries, limit);
1805+
}
1806+
const sorted = entries.toSorted(compareSessionEntryPairsByUpdatedAt);
1807+
return limit === undefined ? sorted : sorted.slice(0, limit);
1808+
}
17611809

17621810
export function filterAndSortSessionEntries(params: {
17631811
store: Record<string, SessionEntry>;
@@ -1829,8 +1877,7 @@ export function filterAndSortSessionEntries(params: {
18291877
return true;
18301878
}
18311879
return entry?.label === label;
1832-
})
1833-
.toSorted((a, b) => (b[1]?.updatedAt ?? 0) - (a[1]?.updatedAt ?? 0));
1880+
});
18341881

18351882
if (search) {
18361883
entries = entries.filter(([key, entry]) => {
@@ -1852,12 +1899,7 @@ export function filterAndSortSessionEntries(params: {
18521899
entries = entries.filter(([, entry]) => (entry?.updatedAt ?? 0) >= cutoff);
18531900
}
18541901

1855-
if (typeof opts.limit === "number" && Number.isFinite(opts.limit)) {
1856-
const limit = Math.max(1, Math.floor(opts.limit));
1857-
entries = entries.slice(0, limit);
1858-
}
1859-
1860-
return entries;
1902+
return sortAndLimitSessionEntries(entries, resolveSessionsListLimit(opts));
18611903
}
18621904

18631905
export function listSessionsFromStore(params: {

0 commit comments

Comments
 (0)