refactor: share session id resolution logic

This commit is contained in:
Gustavo Madeira Santana
2026-03-12 16:38:29 +00:00
parent 7c0d8c9e9a
commit 52ebbf5188
7 changed files with 155 additions and 51 deletions

View File

@@ -118,6 +118,11 @@ Session stores live under the state directory (default `~/.openclaw`):
You can override the store path via `session.store` and `{agentId}` templating. You can override the store path via `session.store` and `{agentId}` templating.
Gateway and ACP session discovery also scans disk-backed agent stores under the
default `agents/` root and under templated `session.store` roots. Discovered
stores must stay inside that resolved agent root and use a regular
`sessions.json` file. Symlinks and out-of-root paths are ignored.
## WebChat behavior ## WebChat behavior
WebChat attaches to the **selected agent** and defaults to the agents main WebChat attaches to the **selected agent** and defaults to the agents main

View File

@@ -24,6 +24,12 @@ Scope selection:
- `--all-agents`: aggregate all configured agent stores - `--all-agents`: aggregate all configured agent stores
- `--store <path>`: explicit store path (cannot be combined with `--agent` or `--all-agents`) - `--store <path>`: explicit store path (cannot be combined with `--agent` or `--all-agents`)
`openclaw sessions --all-agents` reads configured agent stores. Gateway and ACP
session discovery are broader: they also include disk-only stores found under
the default `agents/` root or a templated `session.store` root. Those
discovered stores must resolve to regular `sessions.json` files inside the
agent root; symlinks and out-of-root paths are skipped.
JSON examples: JSON examples:
`openclaw sessions --all-agents --json`: `openclaw sessions --all-agents --json`:

View File

@@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest";
const loadSessionStoreMock = vi.fn(); const loadSessionStoreMock = vi.fn();
const updateSessionStoreMock = vi.fn(); const updateSessionStoreMock = vi.fn();
const callGatewayMock = vi.fn(); const callGatewayMock = vi.fn();
const loadCombinedSessionStoreForGatewayMock = vi.fn();
const createMockConfig = () => ({ const createMockConfig = () => ({
session: { mainKey: "main", scope: "per-sender" }, session: { mainKey: "main", scope: "per-sender" },
@@ -42,6 +43,15 @@ vi.mock("../gateway/call.js", () => ({
callGateway: (opts: unknown) => callGatewayMock(opts), callGateway: (opts: unknown) => callGatewayMock(opts),
})); }));
vi.mock("../gateway/session-utils.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../gateway/session-utils.js")>();
return {
...actual,
loadCombinedSessionStoreForGateway: (cfg: unknown) =>
loadCombinedSessionStoreForGatewayMock(cfg),
};
});
vi.mock("../config/config.js", async (importOriginal) => { vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>(); const actual = await importOriginal<typeof import("../config/config.js")>();
return { return {
@@ -95,7 +105,12 @@ function resetSessionStore(store: Record<string, unknown>) {
loadSessionStoreMock.mockClear(); loadSessionStoreMock.mockClear();
updateSessionStoreMock.mockClear(); updateSessionStoreMock.mockClear();
callGatewayMock.mockClear(); callGatewayMock.mockClear();
loadCombinedSessionStoreForGatewayMock.mockClear();
loadSessionStoreMock.mockReturnValue(store); loadSessionStoreMock.mockReturnValue(store);
loadCombinedSessionStoreForGatewayMock.mockReturnValue({
storePath: "(multiple)",
store,
});
callGatewayMock.mockResolvedValue({}); callGatewayMock.mockResolvedValue({});
mockConfig = createMockConfig(); mockConfig = createMockConfig();
} }
@@ -161,6 +176,30 @@ describe("session_status tool", () => {
expect(details.sessionKey).toBe("agent:main:main"); expect(details.sessionKey).toBe("agent:main:main");
}); });
it("resolves duplicate sessionId inputs deterministically", async () => {
resetSessionStore({
"agent:main:main": {
sessionId: "current",
updatedAt: 10,
},
"agent:main:other": {
sessionId: "run-dup",
updatedAt: 999,
},
"agent:main:acp:run-dup": {
sessionId: "run-dup",
updatedAt: 100,
},
});
const tool = getSessionStatusTool();
const result = await tool.execute("call-dup", { sessionKey: "run-dup" });
const details = result.details as { ok?: boolean; sessionKey?: string };
expect(details.ok).toBe(true);
expect(details.sessionKey).toBe("agent:main:acp:run-dup");
});
it("uses non-standard session keys without sessionId resolution", async () => { it("uses non-standard session keys without sessionId resolution", async () => {
resetSessionStore({ resetSessionStore({
"temp:slug-generator": { "temp:slug-generator": {

View File

@@ -23,6 +23,7 @@ import {
resolveAgentIdFromSessionKey, resolveAgentIdFromSessionKey,
} from "../../routing/session-key.js"; } from "../../routing/session-key.js";
import { applyModelOverrideToSessionEntry } from "../../sessions/model-overrides.js"; import { applyModelOverrideToSessionEntry } from "../../sessions/model-overrides.js";
import { resolvePreferredSessionKeyForSessionIdMatches } from "../../sessions/session-id-resolution.js";
import { resolveAgentDir } from "../agent-scope.js"; import { resolveAgentDir } from "../agent-scope.js";
import { formatUserTime, resolveUserTimeFormat, resolveUserTimezone } from "../date-time.js"; import { formatUserTime, resolveUserTimeFormat, resolveUserTimezone } from "../date-time.js";
import { resolveModelAuthLabel } from "../model-auth-label.js"; import { resolveModelAuthLabel } from "../model-auth-label.js";
@@ -100,16 +101,12 @@ function resolveSessionKeyFromSessionId(params: {
return null; return null;
} }
const { store } = loadCombinedSessionStoreForGateway(params.cfg); const { store } = loadCombinedSessionStoreForGateway(params.cfg);
const match = Object.entries(store).find(([key, entry]) => { const matches = Object.entries(store).filter(
if (entry?.sessionId !== trimmed) { (entry): entry is [string, SessionEntry] =>
return false; entry[1]?.sessionId === trimmed &&
} (!params.agentId || resolveAgentIdFromSessionKey(entry[0]) === params.agentId),
if (!params.agentId) { );
return true; return resolvePreferredSessionKeyForSessionIdMatches(matches, trimmed) ?? null;
}
return resolveAgentIdFromSessionKey(key) === params.agentId;
});
return match?.[0] ?? null;
} }
async function resolveModelOverride(params: { async function resolveModelOverride(params: {

View File

@@ -60,6 +60,7 @@ function shouldSkipDiscoveredAgentDirName(dirName: string, agentId: string): boo
function resolveValidatedDiscoveredStorePathSync(params: { function resolveValidatedDiscoveredStorePathSync(params: {
sessionsDir: string; sessionsDir: string;
agentsRoot: string; agentsRoot: string;
realAgentsRoot?: string;
}): string | undefined { }): string | undefined {
const storePath = path.join(params.sessionsDir, "sessions.json"); const storePath = path.join(params.sessionsDir, "sessions.json");
try { try {
@@ -68,7 +69,7 @@ function resolveValidatedDiscoveredStorePathSync(params: {
return undefined; return undefined;
} }
const realStorePath = fsSync.realpathSync(storePath); const realStorePath = fsSync.realpathSync(storePath);
const realAgentsRoot = fsSync.realpathSync(params.agentsRoot); const realAgentsRoot = params.realAgentsRoot ?? fsSync.realpathSync(params.agentsRoot);
return isWithinRoot(realStorePath, realAgentsRoot) ? realStorePath : undefined; return isWithinRoot(realStorePath, realAgentsRoot) ? realStorePath : undefined;
} catch (err) { } catch (err) {
if (shouldSkipDiscoveryError(err)) { if (shouldSkipDiscoveryError(err)) {
@@ -81,6 +82,7 @@ function resolveValidatedDiscoveredStorePathSync(params: {
async function resolveValidatedDiscoveredStorePath(params: { async function resolveValidatedDiscoveredStorePath(params: {
sessionsDir: string; sessionsDir: string;
agentsRoot: string; agentsRoot: string;
realAgentsRoot?: string;
}): Promise<string | undefined> { }): Promise<string | undefined> {
const storePath = path.join(params.sessionsDir, "sessions.json"); const storePath = path.join(params.sessionsDir, "sessions.json");
try { try {
@@ -88,10 +90,8 @@ async function resolveValidatedDiscoveredStorePath(params: {
if (stat.isSymbolicLink() || !stat.isFile()) { if (stat.isSymbolicLink() || !stat.isFile()) {
return undefined; return undefined;
} }
const [realStorePath, realAgentsRoot] = await Promise.all([ const realStorePath = await fs.realpath(storePath);
fs.realpath(storePath), const realAgentsRoot = params.realAgentsRoot ?? (await fs.realpath(params.agentsRoot));
fs.realpath(params.agentsRoot),
]);
return isWithinRoot(realStorePath, realAgentsRoot) ? realStorePath : undefined; return isWithinRoot(realStorePath, realAgentsRoot) ? realStorePath : undefined;
} catch (err) { } catch (err) {
if (shouldSkipDiscoveryError(err)) { if (shouldSkipDiscoveryError(err)) {
@@ -146,23 +146,50 @@ export function resolveAllAgentSessionStoreTargetsSync(
): SessionStoreTarget[] { ): SessionStoreTarget[] {
const env = params.env ?? process.env; const env = params.env ?? process.env;
const { configuredTargets, agentsRoots } = resolveSessionStoreDiscoveryState(cfg, env); const { configuredTargets, agentsRoots } = resolveSessionStoreDiscoveryState(cfg, env);
const realAgentsRoots = new Map<string, string>();
const getRealAgentsRoot = (agentsRoot: string): string | undefined => {
const cached = realAgentsRoots.get(agentsRoot);
if (cached !== undefined) {
return cached;
}
try {
const realAgentsRoot = fsSync.realpathSync(agentsRoot);
realAgentsRoots.set(agentsRoot, realAgentsRoot);
return realAgentsRoot;
} catch (err) {
if (shouldSkipDiscoveryError(err)) {
return undefined;
}
throw err;
}
};
const validatedConfiguredTargets = configuredTargets.flatMap((target) => { const validatedConfiguredTargets = configuredTargets.flatMap((target) => {
const agentsRoot = resolveAgentsDirFromSessionStorePath(target.storePath); const agentsRoot = resolveAgentsDirFromSessionStorePath(target.storePath);
if (!agentsRoot) { if (!agentsRoot) {
return [target]; return [target];
} }
const realAgentsRoot = getRealAgentsRoot(agentsRoot);
if (!realAgentsRoot) {
return [];
}
const validatedStorePath = resolveValidatedDiscoveredStorePathSync({ const validatedStorePath = resolveValidatedDiscoveredStorePathSync({
sessionsDir: path.dirname(target.storePath), sessionsDir: path.dirname(target.storePath),
agentsRoot, agentsRoot,
realAgentsRoot,
}); });
return validatedStorePath ? [{ ...target, storePath: validatedStorePath }] : []; return validatedStorePath ? [{ ...target, storePath: validatedStorePath }] : [];
}); });
const discoveredTargets = agentsRoots.flatMap((agentsDir) => { const discoveredTargets = agentsRoots.flatMap((agentsDir) => {
try { try {
const realAgentsRoot = getRealAgentsRoot(agentsDir);
if (!realAgentsRoot) {
return [];
}
return resolveAgentSessionDirsFromAgentsDirSync(agentsDir).flatMap((sessionsDir) => { return resolveAgentSessionDirsFromAgentsDirSync(agentsDir).flatMap((sessionsDir) => {
const validatedStorePath = resolveValidatedDiscoveredStorePathSync({ const validatedStorePath = resolveValidatedDiscoveredStorePathSync({
sessionsDir, sessionsDir,
agentsRoot: agentsDir, agentsRoot: agentsDir,
realAgentsRoot,
}); });
const target = validatedStorePath const target = validatedStorePath
? toDiscoveredSessionStoreTarget(sessionsDir, validatedStorePath) ? toDiscoveredSessionStoreTarget(sessionsDir, validatedStorePath)
@@ -185,6 +212,23 @@ export async function resolveAllAgentSessionStoreTargets(
): Promise<SessionStoreTarget[]> { ): Promise<SessionStoreTarget[]> {
const env = params.env ?? process.env; const env = params.env ?? process.env;
const { configuredTargets, agentsRoots } = resolveSessionStoreDiscoveryState(cfg, env); const { configuredTargets, agentsRoots } = resolveSessionStoreDiscoveryState(cfg, env);
const realAgentsRoots = new Map<string, string>();
const getRealAgentsRoot = async (agentsRoot: string): Promise<string | undefined> => {
const cached = realAgentsRoots.get(agentsRoot);
if (cached !== undefined) {
return cached;
}
try {
const realAgentsRoot = await fs.realpath(agentsRoot);
realAgentsRoots.set(agentsRoot, realAgentsRoot);
return realAgentsRoot;
} catch (err) {
if (shouldSkipDiscoveryError(err)) {
return undefined;
}
throw err;
}
};
const validatedConfiguredTargets = ( const validatedConfiguredTargets = (
await Promise.all( await Promise.all(
configuredTargets.map(async (target) => { configuredTargets.map(async (target) => {
@@ -192,9 +236,14 @@ export async function resolveAllAgentSessionStoreTargets(
if (!agentsRoot) { if (!agentsRoot) {
return target; return target;
} }
const realAgentsRoot = await getRealAgentsRoot(agentsRoot);
if (!realAgentsRoot) {
return undefined;
}
const validatedStorePath = await resolveValidatedDiscoveredStorePath({ const validatedStorePath = await resolveValidatedDiscoveredStorePath({
sessionsDir: path.dirname(target.storePath), sessionsDir: path.dirname(target.storePath),
agentsRoot, agentsRoot,
realAgentsRoot,
}); });
return validatedStorePath ? { ...target, storePath: validatedStorePath } : undefined; return validatedStorePath ? { ...target, storePath: validatedStorePath } : undefined;
}), }),
@@ -205,6 +254,10 @@ export async function resolveAllAgentSessionStoreTargets(
await Promise.all( await Promise.all(
agentsRoots.map(async (agentsDir) => { agentsRoots.map(async (agentsDir) => {
try { try {
const realAgentsRoot = await getRealAgentsRoot(agentsDir);
if (!realAgentsRoot) {
return [];
}
const sessionsDirs = await resolveAgentSessionDirsFromAgentsDir(agentsDir); const sessionsDirs = await resolveAgentSessionDirsFromAgentsDir(agentsDir);
return ( return (
await Promise.all( await Promise.all(
@@ -212,6 +265,7 @@ export async function resolveAllAgentSessionStoreTargets(
const validatedStorePath = await resolveValidatedDiscoveredStorePath({ const validatedStorePath = await resolveValidatedDiscoveredStorePath({
sessionsDir, sessionsDir,
agentsRoot: agentsDir, agentsRoot: agentsDir,
realAgentsRoot,
}); });
return validatedStorePath return validatedStorePath
? toDiscoveredSessionStoreTarget(sessionsDir, validatedStorePath) ? toDiscoveredSessionStoreTarget(sessionsDir, validatedStorePath)

View File

@@ -2,6 +2,7 @@ import { loadConfig } from "../config/config.js";
import type { SessionEntry } from "../config/sessions.js"; import type { SessionEntry } from "../config/sessions.js";
import { getAgentRunContext, registerAgentRunContext } from "../infra/agent-events.js"; import { getAgentRunContext, registerAgentRunContext } from "../infra/agent-events.js";
import { toAgentRequestSessionKey } from "../routing/session-key.js"; import { toAgentRequestSessionKey } from "../routing/session-key.js";
import { resolvePreferredSessionKeyForSessionIdMatches } from "../sessions/session-id-resolution.js";
import { loadCombinedSessionStoreForGateway } from "./session-utils.js"; import { loadCombinedSessionStoreForGateway } from "./session-utils.js";
const RUN_LOOKUP_CACHE_LIMIT = 256; const RUN_LOOKUP_CACHE_LIMIT = 256;
@@ -33,41 +34,6 @@ function setResolvedSessionKeyCache(runId: string, sessionKey: string | null): v
}); });
} }
function resolvePreferredRunStoreKey(
matches: Array<[string, SessionEntry]>,
runId: string,
): string | undefined {
if (matches.length === 0) {
return undefined;
}
if (matches.length === 1) {
return matches[0][0];
}
const loweredRunId = runId.trim().toLowerCase();
const structuralMatches = matches.filter(([storeKey]) => {
const requestKey = toAgentRequestSessionKey(storeKey)?.toLowerCase();
return (
storeKey.toLowerCase().endsWith(`:${loweredRunId}`) ||
requestKey === loweredRunId ||
requestKey?.endsWith(`:${loweredRunId}`) === true
);
});
if (structuralMatches.length === 1) {
return structuralMatches[0][0];
}
const sortedMatches = [...matches].toSorted(
(a, b) => (b[1]?.updatedAt ?? 0) - (a[1]?.updatedAt ?? 0),
);
const [freshest, secondFreshest] = sortedMatches;
if ((freshest?.[1]?.updatedAt ?? 0) > (secondFreshest?.[1]?.updatedAt ?? 0)) {
return freshest?.[0];
}
return undefined;
}
export function resolveSessionKeyForRun(runId: string) { export function resolveSessionKeyForRun(runId: string) {
const cached = getAgentRunContext(runId)?.sessionKey; const cached = getAgentRunContext(runId)?.sessionKey;
if (cached) { if (cached) {
@@ -88,7 +54,7 @@ export function resolveSessionKeyForRun(runId: string) {
const matches = Object.entries(store).filter( const matches = Object.entries(store).filter(
(entry): entry is [string, SessionEntry] => entry[1]?.sessionId === runId, (entry): entry is [string, SessionEntry] => entry[1]?.sessionId === runId,
); );
const storeKey = resolvePreferredRunStoreKey(matches, runId); const storeKey = resolvePreferredSessionKeyForSessionIdMatches(matches, runId);
if (storeKey) { if (storeKey) {
const sessionKey = toAgentRequestSessionKey(storeKey) ?? storeKey; const sessionKey = toAgentRequestSessionKey(storeKey) ?? storeKey;
registerAgentRunContext(runId, { sessionKey }); registerAgentRunContext(runId, { sessionKey });

View File

@@ -0,0 +1,37 @@
import type { SessionEntry } from "../config/sessions.js";
import { toAgentRequestSessionKey } from "../routing/session-key.js";
export function resolvePreferredSessionKeyForSessionIdMatches(
matches: Array<[string, SessionEntry]>,
sessionId: string,
): string | undefined {
if (matches.length === 0) {
return undefined;
}
if (matches.length === 1) {
return matches[0][0];
}
const loweredSessionId = sessionId.trim().toLowerCase();
const structuralMatches = matches.filter(([storeKey]) => {
const requestKey = toAgentRequestSessionKey(storeKey)?.toLowerCase();
return (
storeKey.toLowerCase().endsWith(`:${loweredSessionId}`) ||
requestKey === loweredSessionId ||
requestKey?.endsWith(`:${loweredSessionId}`) === true
);
});
if (structuralMatches.length === 1) {
return structuralMatches[0][0];
}
const sortedMatches = [...matches].toSorted(
(a, b) => (b[1]?.updatedAt ?? 0) - (a[1]?.updatedAt ?? 0),
);
const [freshest, secondFreshest] = sortedMatches;
if ((freshest?.[1]?.updatedAt ?? 0) > (secondFreshest?.[1]?.updatedAt ?? 0)) {
return freshest?.[0];
}
return undefined;
}