Skip to content

Commit dc65fa4

Browse files
perf(gateway): cache session list resolver lookups
1 parent e554bf7 commit dc65fa4

4 files changed

Lines changed: 157 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ Docs: https://docs.openclaw.ai
7777
- CLI/migrate: add bulk on/off and skip controls to interactive Codex skill migration, leaving conflicting skill copies unchecked by default. (#77597) Thanks @kevinslin.
7878
- OpenAI/Codex media: advertise Codex audio transcription in runtime and manifest metadata and route active Codex chat models to the OpenAI transcription default instead of sending chat model ids to audio transcription. Thanks @vincentkoc.
7979
- Models/auth: add `openclaw models auth list [--provider <id>] [--json]` so users can inspect saved per-agent auth profiles without dumping secrets or hitting the old “too many arguments” path. Thanks @vincentkoc.
80+
- Gateway/sessions: extend the per-call sessions-list `rowContext` cache with memoization for `resolveSessionDisplayModelIdentityRef`, thinking metadata, and `resolveModelCostConfig` so deterministic per-row resolvers run once per unique `(provider, model[, agentId])` tuple instead of once per session. Cuts CPU on `sessions.list` for stores with many sessions sharing a small set of model tuples; behavior is unchanged for callers that pass no `rowContext`. Thanks @rolandrscheel.
8081
- Cron CLI: add `openclaw cron list --agent <id>`, normalize the requested agent id, and include jobs without a stored agent id under the configured default agent while keeping `cron list` unfiltered when no agent is supplied. Fixes #77118. Thanks @zhanggttry.
8182
- Status: show compact Gateway process uptime and host system uptime in `/status`, making restart and host-lifetime checks visible from chat. Thanks @vincentkoc.
8283
- Discord/status: add degraded Discord transport and gateway event-loop starvation signals to `openclaw channels status`, `openclaw status --deep`, and fetch-timeout logs so intermittent socket resets do not look like a healthy running channel. (#76327) Thanks @joshavant.

scripts/github/real-behavior-proof-policy.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,8 @@ export function hasProofOverride(labels) {
112112
}
113113

114114
export function extractRealBehaviorProofSection(body = "") {
115+
// Normalize CRLF → LF so regexes and section slicing see GitHub web-editor PR
116+
// bodies the same way as locally-authored Markdown.
115117
const normalizedBody = normalizeLineEndings(body);
116118
const headingRegex = /^#{2,6}\s+real behavior proof\b[^\n]*$/gim;
117119
const match = headingRegex.exec(normalizedBody);
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import path from "node:path";
2+
import { describe, test, expect, vi } from "vitest";
3+
import * as thinking from "../auto-reply/thinking.js";
4+
import type { OpenClawConfig } from "../config/config.js";
5+
import { resetConfigRuntimeState, setRuntimeConfigSnapshot } from "../config/config.js";
6+
import type { SessionEntry } from "../config/sessions.js";
7+
import { createEmptyPluginRegistry } from "../plugins/registry-empty.js";
8+
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js";
9+
import { withStateDirEnv } from "../test-helpers/state-dir-env.js";
10+
import * as usageFormat from "../utils/usage-format.js";
11+
import { listSessionsFromStore } from "./session-utils.js";
12+
13+
/**
14+
* Regression smoke for the per-list rowContext resolver cache. The bug we are
15+
* guarding against is O(rows) scaling of deterministic resolvers whose results
16+
* only depend on `(provider, model[, agentId])`: with N sessions sharing K
17+
* unique model tuples, the cached path must perform at most O(K) underlying
18+
* resolver calls -- not O(N).
19+
*
20+
* We assert call counts directly instead of a wall-time bound because shared
21+
* CI runners cannot give a stable wall-time signal, and call-count regressions
22+
* are the actual scaling failure mode we care about.
23+
*/
24+
describe("listSessionsFromStore resolver cache", () => {
25+
test("collapses non-lightweight per-row resolver work to O(unique provider/model tuples)", async () => {
26+
await withStateDirEnv("openclaw-perf-", async ({ stateDir }) => {
27+
resetPluginRuntimeStateForTest();
28+
setActivePluginRegistry(createEmptyPluginRegistry());
29+
const cfg: OpenClawConfig = {
30+
agents: {
31+
defaults: { model: { primary: "google-vertex/gemini-3-flash-preview" } },
32+
},
33+
} as OpenClawConfig;
34+
resetConfigRuntimeState();
35+
setRuntimeConfigSnapshot(cfg);
36+
37+
const tuples: Array<{ modelProvider: string; model: string }> = [
38+
{ modelProvider: "google-vertex", model: "gemini-3-flash-preview" },
39+
{ modelProvider: "openai", model: "gpt-5" },
40+
{ modelProvider: "anthropic", model: "claude-opus-4-7" },
41+
{ modelProvider: "openrouter", model: "z-ai/glm-5" },
42+
{ modelProvider: "google", model: "gemini-2.5-pro" },
43+
];
44+
45+
const store: Record<string, SessionEntry> = {};
46+
const now = Date.now();
47+
const rowCount = 30;
48+
for (let i = 0; i < rowCount; i++) {
49+
const tuple = tuples[i % tuples.length];
50+
store[`agent:default:webchat:dm:${i}`] = {
51+
updatedAt: now - i,
52+
modelProvider: tuple.modelProvider,
53+
model: tuple.model,
54+
inputTokens: 100,
55+
outputTokens: 50,
56+
} as SessionEntry;
57+
}
58+
59+
const thinkingSpy = vi.spyOn(thinking, "listThinkingLevelOptions");
60+
const costSpy = vi.spyOn(usageFormat, "resolveModelCostConfig");
61+
try {
62+
const result = listSessionsFromStore({
63+
cfg,
64+
storePath: path.join(stateDir, "sessions.json"),
65+
store,
66+
// sessions.list bounds responses to 100 rows by default; the perf
67+
// smoke explicitly opts into the full set so the non-lightweight
68+
// row builder exercises the display-identity, thinking-default, and
69+
// model-cost caches at scale.
70+
opts: { limit: rowCount },
71+
});
72+
expect(result.sessions.length).toBe(rowCount);
73+
74+
// The cache keys on rowContext are (provider, model) or
75+
// (agentId, provider, model). With K=5 unique tuples we must see at
76+
// most a small constant number of resolver calls, not O(N=30). A
77+
// pre-cache regression would scale linearly and easily exceed the
78+
// threshold below.
79+
const cacheCallCeiling = tuples.length * 4;
80+
expect(thinkingSpy.mock.calls.length).toBeLessThanOrEqual(cacheCallCeiling);
81+
expect(costSpy.mock.calls.length).toBeLessThanOrEqual(cacheCallCeiling);
82+
} finally {
83+
thinkingSpy.mockRestore();
84+
costSpy.mockRestore();
85+
}
86+
});
87+
});
88+
});

src/gateway/session-utils.ts

Lines changed: 66 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ import {
7979
normalizeOptionalLowercaseString,
8080
} from "../shared/string-coerce.js";
8181
import { normalizeSessionDeliveryFields } from "../utils/delivery-context.shared.js";
82+
import type { ModelCostConfig } from "../utils/usage-format.js";
8283
import { estimateUsageCost, resolveModelCostConfig } from "../utils/usage-format.js";
8384
import {
8485
canonicalizeSpawnedByForAgent,
@@ -295,6 +296,24 @@ function buildCompactionCheckpointPreview(
295296
};
296297
}
297298

299+
function resolveModelCostConfigCached(
300+
provider: string | undefined,
301+
model: string | undefined,
302+
cfg: OpenClawConfig,
303+
rowContext?: SessionListRowContext,
304+
): ModelCostConfig | undefined {
305+
if (!rowContext) {
306+
return resolveModelCostConfig({ provider, model, config: cfg });
307+
}
308+
const key = createSessionRowModelCacheKey(provider, model);
309+
if (rowContext.modelCostConfigByModelRef.has(key)) {
310+
return rowContext.modelCostConfigByModelRef.get(key);
311+
}
312+
const value = resolveModelCostConfig({ provider, model, config: cfg });
313+
rowContext.modelCostConfigByModelRef.set(key, value);
314+
return value;
315+
}
316+
298317
function resolveEstimatedSessionCostUsd(params: {
299318
cfg: OpenClawConfig;
300319
provider?: string;
@@ -304,6 +323,7 @@ function resolveEstimatedSessionCostUsd(params: {
304323
"estimatedCostUsd" | "inputTokens" | "outputTokens" | "cacheRead" | "cacheWrite"
305324
>;
306325
explicitCostUsd?: number;
326+
rowContext?: SessionListRowContext;
307327
}): number | undefined {
308328
const explicitCostUsd = resolveNonNegativeNumber(
309329
params.explicitCostUsd ?? params.entry?.estimatedCostUsd,
@@ -323,11 +343,12 @@ function resolveEstimatedSessionCostUsd(params: {
323343
) {
324344
return undefined;
325345
}
326-
const cost = resolveModelCostConfig({
327-
provider: params.provider,
328-
model: params.model,
329-
config: params.cfg,
330-
});
346+
const cost = resolveModelCostConfigCached(
347+
params.provider,
348+
params.model,
349+
params.cfg,
350+
params.rowContext,
351+
);
331352
if (!cost) {
332353
return undefined;
333354
}
@@ -373,13 +394,19 @@ type SessionListRowContext = {
373394
subagentRuns: ReturnType<typeof buildSubagentRunReadIndex>;
374395
storeChildSessionsByKey: Map<string, string[]>;
375396
selectedModelByOverrideRef: Map<string, ReturnType<typeof resolveSessionModelRef>>;
397+
// Per-list memoization for deterministic resolvers that scale linearly with
398+
// session count but only depend on (provider, model[, agentId]). Sessions
399+
// in a single list typically share a small set of those tuples, so caching
400+
// here collapses the work to O(unique tuples) per call.
376401
thinkingMetadataByModelRef: Map<
377402
string,
378403
{
379404
levels: ReturnType<typeof listThinkingLevelOptions>;
380405
defaultLevel: ReturnType<typeof resolveGatewaySessionThinkingDefault>;
381406
}
382407
>;
408+
displayModelIdentityByKey: Map<string, { provider?: string; model?: string }>;
409+
modelCostConfigByModelRef: Map<string, ModelCostConfig | undefined>;
383410
};
384411

385412
function resolveRuntimeChildSessionKeys(
@@ -498,6 +525,8 @@ function buildSessionListRowContext(params: {
498525
storeChildSessionsByKey: buildStoreChildSessionIndex(params.store, params.now, subagentRuns),
499526
selectedModelByOverrideRef: new Map(),
500527
thinkingMetadataByModelRef: new Map(),
528+
displayModelIdentityByKey: new Map(),
529+
modelCostConfigByModelRef: new Map(),
501530
};
502531
}
503532

@@ -623,6 +652,7 @@ function resolveTranscriptUsageFallback(params: {
623652
fallbackProvider?: string;
624653
fallbackModel?: string;
625654
maxTranscriptBytes?: number;
655+
rowContext?: SessionListRowContext;
626656
}): {
627657
estimatedCostUsd?: number;
628658
totalTokens?: number;
@@ -669,6 +699,7 @@ function resolveTranscriptUsageFallback(params: {
669699
cacheRead: snapshot.cacheRead,
670700
cacheWrite: snapshot.cacheWrite,
671701
},
702+
rowContext: params.rowContext,
672703
});
673704
return {
674705
modelProvider,
@@ -1508,6 +1539,30 @@ export function resolveSessionModelIdentityRef(
15081539
return { provider: resolved.provider, model: resolved.model };
15091540
}
15101541

1542+
function resolveSessionDisplayModelIdentityRefCached(params: {
1543+
cfg: OpenClawConfig;
1544+
agentId: string;
1545+
provider?: string;
1546+
model?: string;
1547+
rowContext?: SessionListRowContext;
1548+
}): { provider?: string; model?: string } {
1549+
const ctx = params.rowContext;
1550+
if (!ctx) {
1551+
return resolveSessionDisplayModelIdentityRef(params);
1552+
}
1553+
const key = `${params.agentId}\u0000${createSessionRowModelCacheKey(
1554+
params.provider,
1555+
params.model,
1556+
)}`;
1557+
const cached = ctx.displayModelIdentityByKey.get(key);
1558+
if (cached) {
1559+
return cached;
1560+
}
1561+
const value = resolveSessionDisplayModelIdentityRef(params);
1562+
ctx.displayModelIdentityByKey.set(key, value);
1563+
return value;
1564+
}
1565+
15111566
export function resolveSessionDisplayModelIdentityRef(params: {
15121567
cfg: OpenClawConfig;
15131568
agentId: string;
@@ -1671,6 +1726,7 @@ export function buildGatewaySessionRow(params: {
16711726
provider: resolvedModel.provider,
16721727
model: resolvedModel.model ?? DEFAULT_MODEL,
16731728
entry,
1729+
rowContext,
16741730
}) === undefined;
16751731
const transcriptUsage =
16761732
!skipTranscriptUsage &&
@@ -1683,6 +1739,7 @@ export function buildGatewaySessionRow(params: {
16831739
fallbackProvider: resolvedModel.provider,
16841740
fallbackModel: resolvedModel.model ?? DEFAULT_MODEL,
16851741
maxTranscriptBytes: params.transcriptUsageMaxBytes,
1742+
rowContext: params.rowContext,
16861743
})
16871744
: null;
16881745
const preferLiveSubagentModelIdentity =
@@ -1722,11 +1779,12 @@ export function buildGatewaySessionRow(params: {
17221779
const selectedOrRuntimeModel = selectedModel?.model ?? model;
17231780
const rowModelIdentity = lightweight
17241781
? { provider: selectedOrRuntimeModelProvider, model: selectedOrRuntimeModel }
1725-
: resolveSessionDisplayModelIdentityRef({
1782+
: resolveSessionDisplayModelIdentityRefCached({
17261783
cfg,
17271784
agentId: sessionAgentId,
17281785
provider: selectedOrRuntimeModelProvider,
17291786
model: selectedOrRuntimeModel,
1787+
rowContext: params.rowContext,
17301788
});
17311789
const rowModelProvider = rowModelIdentity.provider;
17321790
const rowModel = rowModelIdentity.model;
@@ -1744,6 +1802,7 @@ export function buildGatewaySessionRow(params: {
17441802
provider: rowModelProvider,
17451803
model: rowModel,
17461804
entry,
1805+
rowContext: params.rowContext,
17471806
}) ?? resolveNonNegativeNumber(transcriptUsage?.estimatedCostUsd));
17481807
const contextTokens = lightweight
17491808
? resolvePositiveNumber(entry?.contextTokens)
@@ -1815,7 +1874,7 @@ export function buildGatewaySessionRow(params: {
18151874
abortedLastRun: entry?.abortedLastRun,
18161875
thinkingLevel: entry?.thinkingLevel,
18171876
thinkingLevels,
1818-
thinkingOptions: thinkingLevels?.map((level) => level.label),
1877+
thinkingOptions: thinkingLevels.map((level) => level.label),
18191878
thinkingDefault,
18201879
fastMode: entry?.fastMode,
18211880
verboseLevel: entry?.verboseLevel,

0 commit comments

Comments
 (0)