diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a08ef00e0f..d2d289d6e57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,7 @@ Docs: https://docs.openclaw.ai - Telegram/model picker: make inline model button selections persist the chosen session model correctly, clear overrides when selecting the configured default, and include effective fallback models in `/models` button validation. (#40105) Thanks @avirweb. - Mattermost/reply media delivery: pass agent-scoped `mediaLocalRoots` through shared reply delivery so allowed local files upload correctly from button, slash-command, and model-picker replies. (#44021) Thanks @LyleLiu666. - Plugins/env-scoped roots: fix plugin discovery/load caches and provenance tracking so same-process `HOME`/`OPENCLAW_HOME` changes no longer reuse stale plugin state or misreport `~/...` plugins as untracked. (#44046) thanks @gumadeiras. +- Gateway/session discovery: discover disk-only and retired ACP session stores under custom templated `session.store` roots so ACP reconciliation, session-id/session-label targeting, and run-id fallback keep working after restart. (#44176) thanks @gumadeiras. ## 2026.3.11 diff --git a/docs/channels/channel-routing.md b/docs/channels/channel-routing.md index 2d824359311..63c5806ebae 100644 --- a/docs/channels/channel-routing.md +++ b/docs/channels/channel-routing.md @@ -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. +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 attaches to the **selected agent** and defaults to the agent’s main diff --git a/docs/cli/sessions.md b/docs/cli/sessions.md index 4ed5ace54ee..b8c1ebfac6f 100644 --- a/docs/cli/sessions.md +++ b/docs/cli/sessions.md @@ -24,6 +24,12 @@ Scope selection: - `--all-agents`: aggregate all configured agent stores - `--store `: 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: `openclaw sessions --all-agents --json`: diff --git a/docs/tools/acp-agents.md b/docs/tools/acp-agents.md index 65a320f1c52..d8ac5b5f7d3 100644 --- a/docs/tools/acp-agents.md +++ b/docs/tools/acp-agents.md @@ -421,6 +421,8 @@ Some controls depend on backend capabilities. If a backend does not support a co | `/acp doctor` | Backend health, capabilities, actionable fixes. | `/acp doctor` | | `/acp install` | Print deterministic install and enable steps. | `/acp install` | +`/acp sessions` reads the store for the current bound or requester session. Commands that accept `session-key`, `session-id`, or `session-label` tokens resolve targets through gateway session discovery, including custom per-agent `session.store` roots. + ## Runtime options mapping `/acp` has convenience commands and a generic setter. diff --git a/src/acp/runtime/session-meta.test.ts b/src/acp/runtime/session-meta.test.ts new file mode 100644 index 00000000000..f9a0f399f81 --- /dev/null +++ b/src/acp/runtime/session-meta.test.ts @@ -0,0 +1,69 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; + +const hoisted = vi.hoisted(() => { + const resolveAllAgentSessionStoreTargetsMock = vi.fn(); + const loadSessionStoreMock = vi.fn(); + return { + resolveAllAgentSessionStoreTargetsMock, + loadSessionStoreMock, + }; +}); + +vi.mock("../../config/sessions.js", async () => { + const actual = await vi.importActual( + "../../config/sessions.js", + ); + return { + ...actual, + resolveAllAgentSessionStoreTargets: (cfg: OpenClawConfig, opts: unknown) => + hoisted.resolveAllAgentSessionStoreTargetsMock(cfg, opts), + loadSessionStore: (storePath: string) => hoisted.loadSessionStoreMock(storePath), + }; +}); + +const { listAcpSessionEntries } = await import("./session-meta.js"); + +describe("listAcpSessionEntries", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("reads ACP sessions from resolved configured store targets", async () => { + const cfg = { + session: { + store: "/custom/sessions/{agentId}.json", + }, + } as OpenClawConfig; + hoisted.resolveAllAgentSessionStoreTargetsMock.mockResolvedValue([ + { + agentId: "ops", + storePath: "/custom/sessions/ops.json", + }, + ]); + hoisted.loadSessionStoreMock.mockReturnValue({ + "agent:ops:acp:s1": { + updatedAt: 123, + acp: { + backend: "acpx", + agent: "ops", + mode: "persistent", + state: "idle", + }, + }, + }); + + const entries = await listAcpSessionEntries({ cfg }); + + expect(hoisted.resolveAllAgentSessionStoreTargetsMock).toHaveBeenCalledWith(cfg, undefined); + expect(hoisted.loadSessionStoreMock).toHaveBeenCalledWith("/custom/sessions/ops.json"); + expect(entries).toEqual([ + expect.objectContaining({ + cfg, + storePath: "/custom/sessions/ops.json", + sessionKey: "agent:ops:acp:s1", + storeSessionKey: "agent:ops:acp:s1", + }), + ]); + }); +}); diff --git a/src/acp/runtime/session-meta.ts b/src/acp/runtime/session-meta.ts index fd4a5813f9b..ff48d1e1ce6 100644 --- a/src/acp/runtime/session-meta.ts +++ b/src/acp/runtime/session-meta.ts @@ -1,9 +1,11 @@ -import path from "node:path"; -import { resolveAgentSessionDirs } from "../../agents/session-dirs.js"; import type { OpenClawConfig } from "../../config/config.js"; import { loadConfig } from "../../config/config.js"; -import { resolveStateDir } from "../../config/paths.js"; -import { loadSessionStore, resolveStorePath, updateSessionStore } from "../../config/sessions.js"; +import { + loadSessionStore, + resolveAllAgentSessionStoreTargets, + resolveStorePath, + updateSessionStore, +} from "../../config/sessions.js"; import { mergeSessionEntry, type SessionAcpMeta, @@ -88,14 +90,17 @@ export function readAcpSessionEntry(params: { export async function listAcpSessionEntries(params: { cfg?: OpenClawConfig; + env?: NodeJS.ProcessEnv; }): Promise { const cfg = params.cfg ?? loadConfig(); - const stateDir = resolveStateDir(process.env); - const sessionDirs = await resolveAgentSessionDirs(stateDir); + const storeTargets = await resolveAllAgentSessionStoreTargets( + cfg, + params.env ? { env: params.env } : undefined, + ); const entries: AcpSessionStoreEntry[] = []; - for (const sessionsDir of sessionDirs) { - const storePath = path.join(sessionsDir, "sessions.json"); + for (const target of storeTargets) { + const storePath = target.storePath; let store: Record; try { store = loadSessionStore(storePath); diff --git a/src/agents/openclaw-tools.session-status.test.ts b/src/agents/openclaw-tools.session-status.test.ts index 193deb6304f..8b2d9fc467f 100644 --- a/src/agents/openclaw-tools.session-status.test.ts +++ b/src/agents/openclaw-tools.session-status.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest"; const loadSessionStoreMock = vi.fn(); const updateSessionStoreMock = vi.fn(); const callGatewayMock = vi.fn(); +const loadCombinedSessionStoreForGatewayMock = vi.fn(); const createMockConfig = () => ({ session: { mainKey: "main", scope: "per-sender" }, @@ -42,6 +43,15 @@ vi.mock("../gateway/call.js", () => ({ callGateway: (opts: unknown) => callGatewayMock(opts), })); +vi.mock("../gateway/session-utils.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadCombinedSessionStoreForGateway: (cfg: unknown) => + loadCombinedSessionStoreForGatewayMock(cfg), + }; +}); + vi.mock("../config/config.js", async (importOriginal) => { const actual = await importOriginal(); return { @@ -95,7 +105,12 @@ function resetSessionStore(store: Record) { loadSessionStoreMock.mockClear(); updateSessionStoreMock.mockClear(); callGatewayMock.mockClear(); + loadCombinedSessionStoreForGatewayMock.mockClear(); loadSessionStoreMock.mockReturnValue(store); + loadCombinedSessionStoreForGatewayMock.mockReturnValue({ + storePath: "(multiple)", + store, + }); callGatewayMock.mockResolvedValue({}); mockConfig = createMockConfig(); } @@ -161,6 +176,30 @@ describe("session_status tool", () => { 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 () => { resetSessionStore({ "temp:slug-generator": { diff --git a/src/agents/session-dirs.ts b/src/agents/session-dirs.ts index 1985dcf608a..90f42cdebb9 100644 --- a/src/agents/session-dirs.ts +++ b/src/agents/session-dirs.ts @@ -1,9 +1,15 @@ -import type { Dirent } from "node:fs"; +import fsSync, { type Dirent } from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; -export async function resolveAgentSessionDirs(stateDir: string): Promise { - const agentsDir = path.join(stateDir, "agents"); +function mapAgentSessionDirs(agentsDir: string, entries: Dirent[]): string[] { + return entries + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join(agentsDir, entry.name, "sessions")) + .toSorted((a, b) => a.localeCompare(b)); +} + +export async function resolveAgentSessionDirsFromAgentsDir(agentsDir: string): Promise { let entries: Dirent[] = []; try { entries = await fs.readdir(agentsDir, { withFileTypes: true }); @@ -15,8 +21,24 @@ export async function resolveAgentSessionDirs(stateDir: string): Promise entry.isDirectory()) - .map((entry) => path.join(agentsDir, entry.name, "sessions")) - .toSorted((a, b) => a.localeCompare(b)); + return mapAgentSessionDirs(agentsDir, entries); +} + +export function resolveAgentSessionDirsFromAgentsDirSync(agentsDir: string): string[] { + let entries: Dirent[] = []; + try { + entries = fsSync.readdirSync(agentsDir, { withFileTypes: true }); + } catch (err) { + const code = (err as { code?: string }).code; + if (code === "ENOENT") { + return []; + } + throw err; + } + + return mapAgentSessionDirs(agentsDir, entries); +} + +export async function resolveAgentSessionDirs(stateDir: string): Promise { + return await resolveAgentSessionDirsFromAgentsDir(path.join(stateDir, "agents")); } diff --git a/src/agents/tools/session-status-tool.ts b/src/agents/tools/session-status-tool.ts index aa9e0cac17b..132b470fd2f 100644 --- a/src/agents/tools/session-status-tool.ts +++ b/src/agents/tools/session-status-tool.ts @@ -23,6 +23,7 @@ import { resolveAgentIdFromSessionKey, } from "../../routing/session-key.js"; import { applyModelOverrideToSessionEntry } from "../../sessions/model-overrides.js"; +import { resolvePreferredSessionKeyForSessionIdMatches } from "../../sessions/session-id-resolution.js"; import { resolveAgentDir } from "../agent-scope.js"; import { formatUserTime, resolveUserTimeFormat, resolveUserTimezone } from "../date-time.js"; import { resolveModelAuthLabel } from "../model-auth-label.js"; @@ -100,16 +101,12 @@ function resolveSessionKeyFromSessionId(params: { return null; } const { store } = loadCombinedSessionStoreForGateway(params.cfg); - const match = Object.entries(store).find(([key, entry]) => { - if (entry?.sessionId !== trimmed) { - return false; - } - if (!params.agentId) { - return true; - } - return resolveAgentIdFromSessionKey(key) === params.agentId; - }); - return match?.[0] ?? null; + const matches = Object.entries(store).filter( + (entry): entry is [string, SessionEntry] => + entry[1]?.sessionId === trimmed && + (!params.agentId || resolveAgentIdFromSessionKey(entry[0]) === params.agentId), + ); + return resolvePreferredSessionKeyForSessionIdMatches(matches, trimmed) ?? null; } async function resolveModelOverride(params: { diff --git a/src/commands/session-store-targets.test.ts b/src/commands/session-store-targets.test.ts index 62ccab8d3cd..3f3a87b09db 100644 --- a/src/commands/session-store-targets.test.ts +++ b/src/commands/session-store-targets.test.ts @@ -1,17 +1,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { resolveSessionStoreTargets } from "./session-store-targets.js"; -const resolveStorePathMock = vi.hoisted(() => vi.fn()); -const resolveDefaultAgentIdMock = vi.hoisted(() => vi.fn()); -const listAgentIdsMock = vi.hoisted(() => vi.fn()); +const resolveSessionStoreTargetsMock = vi.hoisted(() => vi.fn()); vi.mock("../config/sessions.js", () => ({ - resolveStorePath: resolveStorePathMock, -})); - -vi.mock("../agents/agent-scope.js", () => ({ - resolveDefaultAgentId: resolveDefaultAgentIdMock, - listAgentIds: listAgentIdsMock, + resolveSessionStoreTargets: resolveSessionStoreTargetsMock, })); describe("resolveSessionStoreTargets", () => { @@ -19,61 +12,14 @@ describe("resolveSessionStoreTargets", () => { vi.clearAllMocks(); }); - it("resolves the default agent store when no selector is provided", () => { - resolveDefaultAgentIdMock.mockReturnValue("main"); - resolveStorePathMock.mockReturnValue("/tmp/main-sessions.json"); + it("delegates session store target resolution to the shared config helper", () => { + resolveSessionStoreTargetsMock.mockReturnValue([ + { agentId: "main", storePath: "/tmp/main-sessions.json" }, + ]); const targets = resolveSessionStoreTargets({}, {}); expect(targets).toEqual([{ agentId: "main", storePath: "/tmp/main-sessions.json" }]); - expect(resolveStorePathMock).toHaveBeenCalledWith(undefined, { agentId: "main" }); - }); - - it("resolves all configured agent stores", () => { - listAgentIdsMock.mockReturnValue(["main", "work"]); - resolveStorePathMock - .mockReturnValueOnce("/tmp/main-sessions.json") - .mockReturnValueOnce("/tmp/work-sessions.json"); - - const targets = resolveSessionStoreTargets( - { - session: { store: "~/.openclaw/agents/{agentId}/sessions/sessions.json" }, - }, - { allAgents: true }, - ); - - expect(targets).toEqual([ - { agentId: "main", storePath: "/tmp/main-sessions.json" }, - { agentId: "work", storePath: "/tmp/work-sessions.json" }, - ]); - }); - - it("dedupes shared store paths for --all-agents", () => { - listAgentIdsMock.mockReturnValue(["main", "work"]); - resolveStorePathMock.mockReturnValue("/tmp/shared-sessions.json"); - - const targets = resolveSessionStoreTargets( - { - session: { store: "/tmp/shared-sessions.json" }, - }, - { allAgents: true }, - ); - - expect(targets).toEqual([{ agentId: "main", storePath: "/tmp/shared-sessions.json" }]); - expect(resolveStorePathMock).toHaveBeenCalledTimes(2); - }); - - it("rejects unknown agent ids", () => { - listAgentIdsMock.mockReturnValue(["main", "work"]); - expect(() => resolveSessionStoreTargets({}, { agent: "ghost" })).toThrow(/Unknown agent id/); - }); - - it("rejects conflicting selectors", () => { - expect(() => resolveSessionStoreTargets({}, { agent: "main", allAgents: true })).toThrow( - /cannot be used together/i, - ); - expect(() => - resolveSessionStoreTargets({}, { store: "/tmp/sessions.json", allAgents: true }), - ).toThrow(/cannot be combined/i); + expect(resolveSessionStoreTargetsMock).toHaveBeenCalledWith({}, {}); }); }); diff --git a/src/commands/session-store-targets.ts b/src/commands/session-store-targets.ts index c9e91006e53..c01197c6f88 100644 --- a/src/commands/session-store-targets.ts +++ b/src/commands/session-store-targets.ts @@ -1,84 +1,11 @@ -import { listAgentIds, resolveDefaultAgentId } from "../agents/agent-scope.js"; -import { resolveStorePath } from "../config/sessions.js"; +import { + resolveSessionStoreTargets, + type SessionStoreSelectionOptions, + type SessionStoreTarget, +} from "../config/sessions.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { normalizeAgentId } from "../routing/session-key.js"; import type { RuntimeEnv } from "../runtime.js"; - -export type SessionStoreSelectionOptions = { - store?: string; - agent?: string; - allAgents?: boolean; -}; - -export type SessionStoreTarget = { - agentId: string; - storePath: string; -}; - -function dedupeTargetsByStorePath(targets: SessionStoreTarget[]): SessionStoreTarget[] { - const deduped = new Map(); - for (const target of targets) { - if (!deduped.has(target.storePath)) { - deduped.set(target.storePath, target); - } - } - return [...deduped.values()]; -} - -export function resolveSessionStoreTargets( - cfg: OpenClawConfig, - opts: SessionStoreSelectionOptions, -): SessionStoreTarget[] { - const defaultAgentId = resolveDefaultAgentId(cfg); - const hasAgent = Boolean(opts.agent?.trim()); - const allAgents = opts.allAgents === true; - if (hasAgent && allAgents) { - throw new Error("--agent and --all-agents cannot be used together"); - } - if (opts.store && (hasAgent || allAgents)) { - throw new Error("--store cannot be combined with --agent or --all-agents"); - } - - if (opts.store) { - return [ - { - agentId: defaultAgentId, - storePath: resolveStorePath(opts.store, { agentId: defaultAgentId }), - }, - ]; - } - - if (allAgents) { - const targets = listAgentIds(cfg).map((agentId) => ({ - agentId, - storePath: resolveStorePath(cfg.session?.store, { agentId }), - })); - return dedupeTargetsByStorePath(targets); - } - - if (hasAgent) { - const knownAgents = listAgentIds(cfg); - const requested = normalizeAgentId(opts.agent ?? ""); - if (!knownAgents.includes(requested)) { - throw new Error( - `Unknown agent id "${opts.agent}". Use "openclaw agents list" to see configured agents.`, - ); - } - return [ - { - agentId: requested, - storePath: resolveStorePath(cfg.session?.store, { agentId: requested }), - }, - ]; - } - - return [ - { - agentId: defaultAgentId, - storePath: resolveStorePath(cfg.session?.store, { agentId: defaultAgentId }), - }, - ]; -} +export { resolveSessionStoreTargets, type SessionStoreSelectionOptions, type SessionStoreTarget }; export function resolveSessionStoreTargetsOrExit(params: { cfg: OpenClawConfig; diff --git a/src/config/sessions.ts b/src/config/sessions.ts index 701870ec8a7..1a521836405 100644 --- a/src/config/sessions.ts +++ b/src/config/sessions.ts @@ -11,3 +11,4 @@ export * from "./sessions/transcript.js"; export * from "./sessions/session-file.js"; export * from "./sessions/delivery-info.js"; export * from "./sessions/disk-budget.js"; +export * from "./sessions/targets.js"; diff --git a/src/config/sessions/paths.ts b/src/config/sessions/paths.ts index 6112fd6d31c..1be7aec6299 100644 --- a/src/config/sessions/paths.ts +++ b/src/config/sessions/paths.ts @@ -276,19 +276,24 @@ export function resolveSessionFilePath( return resolveSessionTranscriptPathInDir(sessionId, sessionsDir); } -export function resolveStorePath(store?: string, opts?: { agentId?: string }) { +export function resolveStorePath( + store?: string, + opts?: { agentId?: string; env?: NodeJS.ProcessEnv }, +) { const agentId = normalizeAgentId(opts?.agentId ?? DEFAULT_AGENT_ID); + const env = opts?.env ?? process.env; + const homedir = () => resolveRequiredHomeDir(env, os.homedir); if (!store) { - return resolveDefaultSessionStorePath(agentId); + return path.join(resolveAgentSessionsDir(agentId, env, homedir), "sessions.json"); } if (store.includes("{agentId}")) { const expanded = store.replaceAll("{agentId}", agentId); if (expanded.startsWith("~")) { return path.resolve( expandHomePrefix(expanded, { - home: resolveRequiredHomeDir(process.env, os.homedir), - env: process.env, - homedir: os.homedir, + home: resolveRequiredHomeDir(env, homedir), + env, + homedir, }), ); } @@ -297,11 +302,28 @@ export function resolveStorePath(store?: string, opts?: { agentId?: string }) { if (store.startsWith("~")) { return path.resolve( expandHomePrefix(store, { - home: resolveRequiredHomeDir(process.env, os.homedir), - env: process.env, - homedir: os.homedir, + home: resolveRequiredHomeDir(env, homedir), + env, + homedir, }), ); } return path.resolve(store); } + +export function resolveAgentsDirFromSessionStorePath(storePath: string): string | undefined { + const candidateAbsPath = path.resolve(storePath); + if (path.basename(candidateAbsPath) !== "sessions.json") { + return undefined; + } + const sessionsDir = path.dirname(candidateAbsPath); + if (path.basename(sessionsDir) !== "sessions") { + return undefined; + } + const agentDir = path.dirname(sessionsDir); + const agentsDir = path.dirname(agentDir); + if (path.basename(agentsDir) !== "agents") { + return undefined; + } + return agentsDir; +} diff --git a/src/config/sessions/targets.test.ts b/src/config/sessions/targets.test.ts new file mode 100644 index 00000000000..aee55706572 --- /dev/null +++ b/src/config/sessions/targets.test.ts @@ -0,0 +1,385 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { withTempHome } from "../../../test/helpers/temp-home.js"; +import type { OpenClawConfig } from "../config.js"; +import { + resolveAllAgentSessionStoreTargets, + resolveAllAgentSessionStoreTargetsSync, + resolveSessionStoreTargets, +} from "./targets.js"; + +async function resolveRealStorePath(sessionsDir: string): Promise { + return await fs.realpath(path.join(sessionsDir, "sessions.json")); +} + +describe("resolveSessionStoreTargets", () => { + it("resolves all configured agent stores", () => { + const cfg: OpenClawConfig = { + session: { + store: "~/.openclaw/agents/{agentId}/sessions/sessions.json", + }, + agents: { + list: [{ id: "main", default: true }, { id: "work" }], + }, + }; + + const targets = resolveSessionStoreTargets(cfg, { allAgents: true }); + + expect(targets).toEqual([ + { + agentId: "main", + storePath: path.resolve( + path.join(process.env.HOME ?? "", ".openclaw/agents/main/sessions/sessions.json"), + ), + }, + { + agentId: "work", + storePath: path.resolve( + path.join(process.env.HOME ?? "", ".openclaw/agents/work/sessions/sessions.json"), + ), + }, + ]); + }); + + it("dedupes shared store paths for --all-agents", () => { + const cfg: OpenClawConfig = { + session: { + store: "/tmp/shared-sessions.json", + }, + agents: { + list: [{ id: "main", default: true }, { id: "work" }], + }, + }; + + expect(resolveSessionStoreTargets(cfg, { allAgents: true })).toEqual([ + { agentId: "main", storePath: path.resolve("/tmp/shared-sessions.json") }, + ]); + }); + + it("rejects unknown agent ids", () => { + const cfg: OpenClawConfig = { + agents: { + list: [{ id: "main", default: true }, { id: "work" }], + }, + }; + + expect(() => resolveSessionStoreTargets(cfg, { agent: "ghost" })).toThrow(/Unknown agent id/); + }); + + it("rejects conflicting selectors", () => { + expect(() => resolveSessionStoreTargets({}, { agent: "main", allAgents: true })).toThrow( + /cannot be used together/i, + ); + expect(() => + resolveSessionStoreTargets({}, { store: "/tmp/sessions.json", allAgents: true }), + ).toThrow(/cannot be combined/i); + }); +}); + +describe("resolveAllAgentSessionStoreTargets", () => { + it("includes discovered on-disk agent stores alongside configured targets", async () => { + await withTempHome(async (home) => { + const stateDir = path.join(home, ".openclaw"); + const opsSessionsDir = path.join(stateDir, "agents", "ops", "sessions"); + const retiredSessionsDir = path.join(stateDir, "agents", "retired", "sessions"); + await fs.mkdir(opsSessionsDir, { recursive: true }); + await fs.mkdir(retiredSessionsDir, { recursive: true }); + await fs.writeFile(path.join(opsSessionsDir, "sessions.json"), "{}", "utf8"); + await fs.writeFile(path.join(retiredSessionsDir, "sessions.json"), "{}", "utf8"); + + const cfg: OpenClawConfig = { + agents: { + list: [{ id: "ops", default: true }], + }, + }; + const opsStorePath = await resolveRealStorePath(opsSessionsDir); + const retiredStorePath = await resolveRealStorePath(retiredSessionsDir); + + const targets = await resolveAllAgentSessionStoreTargets(cfg, { env: process.env }); + + expect(targets).toEqual( + expect.arrayContaining([ + { + agentId: "ops", + storePath: opsStorePath, + }, + { + agentId: "retired", + storePath: retiredStorePath, + }, + ]), + ); + expect(targets.filter((target) => target.storePath === opsStorePath)).toHaveLength(1); + }); + }); + + it("discovers retired agent stores under a configured custom session root", async () => { + await withTempHome(async (home) => { + const customRoot = path.join(home, "custom-state"); + const opsSessionsDir = path.join(customRoot, "agents", "ops", "sessions"); + const retiredSessionsDir = path.join(customRoot, "agents", "retired", "sessions"); + await fs.mkdir(opsSessionsDir, { recursive: true }); + await fs.mkdir(retiredSessionsDir, { recursive: true }); + await fs.writeFile(path.join(opsSessionsDir, "sessions.json"), "{}", "utf8"); + await fs.writeFile(path.join(retiredSessionsDir, "sessions.json"), "{}", "utf8"); + + const cfg: OpenClawConfig = { + session: { + store: path.join(customRoot, "agents", "{agentId}", "sessions", "sessions.json"), + }, + agents: { + list: [{ id: "ops", default: true }], + }, + }; + const opsStorePath = await resolveRealStorePath(opsSessionsDir); + const retiredStorePath = await resolveRealStorePath(retiredSessionsDir); + + const targets = await resolveAllAgentSessionStoreTargets(cfg, { env: process.env }); + + expect(targets).toEqual( + expect.arrayContaining([ + { + agentId: "ops", + storePath: opsStorePath, + }, + { + agentId: "retired", + storePath: retiredStorePath, + }, + ]), + ); + expect(targets.filter((target) => target.storePath === opsStorePath)).toHaveLength(1); + }); + }); + + it("keeps the actual on-disk store path for discovered retired agents", async () => { + await withTempHome(async (home) => { + const customRoot = path.join(home, "custom-state"); + const opsSessionsDir = path.join(customRoot, "agents", "ops", "sessions"); + const retiredSessionsDir = path.join(customRoot, "agents", "Retired Agent", "sessions"); + await fs.mkdir(opsSessionsDir, { recursive: true }); + await fs.mkdir(retiredSessionsDir, { recursive: true }); + await fs.writeFile(path.join(opsSessionsDir, "sessions.json"), "{}", "utf8"); + await fs.writeFile(path.join(retiredSessionsDir, "sessions.json"), "{}", "utf8"); + + const cfg: OpenClawConfig = { + session: { + store: path.join(customRoot, "agents", "{agentId}", "sessions", "sessions.json"), + }, + agents: { + list: [{ id: "ops", default: true }], + }, + }; + const retiredStorePath = await resolveRealStorePath(retiredSessionsDir); + + const targets = await resolveAllAgentSessionStoreTargets(cfg, { env: process.env }); + + expect(targets).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + agentId: "retired-agent", + storePath: retiredStorePath, + }), + ]), + ); + }); + }); + + it("respects the caller env when resolving configured and discovered store roots", async () => { + await withTempHome(async (home) => { + const envStateDir = path.join(home, "env-state"); + const mainSessionsDir = path.join(envStateDir, "agents", "main", "sessions"); + const retiredSessionsDir = path.join(envStateDir, "agents", "retired", "sessions"); + await fs.mkdir(mainSessionsDir, { recursive: true }); + await fs.mkdir(retiredSessionsDir, { recursive: true }); + await fs.writeFile(path.join(mainSessionsDir, "sessions.json"), "{}", "utf8"); + await fs.writeFile(path.join(retiredSessionsDir, "sessions.json"), "{}", "utf8"); + + const env = { + ...process.env, + OPENCLAW_STATE_DIR: envStateDir, + }; + const cfg: OpenClawConfig = {}; + const mainStorePath = await resolveRealStorePath(mainSessionsDir); + const retiredStorePath = await resolveRealStorePath(retiredSessionsDir); + + const targets = await resolveAllAgentSessionStoreTargets(cfg, { env }); + + expect(targets).toEqual( + expect.arrayContaining([ + { + agentId: "main", + storePath: mainStorePath, + }, + { + agentId: "retired", + storePath: retiredStorePath, + }, + ]), + ); + }); + }); + + it("skips unreadable or invalid discovery roots when other roots are still readable", async () => { + await withTempHome(async (home) => { + const customRoot = path.join(home, "custom-state"); + await fs.mkdir(customRoot, { recursive: true }); + await fs.writeFile(path.join(customRoot, "agents"), "not-a-directory", "utf8"); + + const envStateDir = path.join(home, "env-state"); + const mainSessionsDir = path.join(envStateDir, "agents", "main", "sessions"); + const retiredSessionsDir = path.join(envStateDir, "agents", "retired", "sessions"); + await fs.mkdir(mainSessionsDir, { recursive: true }); + await fs.mkdir(retiredSessionsDir, { recursive: true }); + await fs.writeFile(path.join(mainSessionsDir, "sessions.json"), "{}", "utf8"); + await fs.writeFile(path.join(retiredSessionsDir, "sessions.json"), "{}", "utf8"); + + const cfg: OpenClawConfig = { + session: { + store: path.join(customRoot, "agents", "{agentId}", "sessions", "sessions.json"), + }, + agents: { + list: [{ id: "main", default: true }], + }, + }; + const env = { + ...process.env, + OPENCLAW_STATE_DIR: envStateDir, + }; + const retiredStorePath = await resolveRealStorePath(retiredSessionsDir); + + await expect(resolveAllAgentSessionStoreTargets(cfg, { env })).resolves.toEqual( + expect.arrayContaining([ + { + agentId: "retired", + storePath: retiredStorePath, + }, + ]), + ); + }); + }); + + it("skips symlinked discovered stores under templated agents roots", async () => { + await withTempHome(async (home) => { + if (process.platform === "win32") { + return; + } + const customRoot = path.join(home, "custom-state"); + const opsSessionsDir = path.join(customRoot, "agents", "ops", "sessions"); + const leakedFile = path.join(home, "outside.json"); + await fs.mkdir(opsSessionsDir, { recursive: true }); + await fs.writeFile(leakedFile, JSON.stringify({ leak: { secret: "x" } }), "utf8"); + await fs.symlink(leakedFile, path.join(opsSessionsDir, "sessions.json")); + + const cfg: OpenClawConfig = { + session: { + store: path.join(customRoot, "agents", "{agentId}", "sessions", "sessions.json"), + }, + agents: { + list: [{ id: "ops", default: true }], + }, + }; + + const targets = await resolveAllAgentSessionStoreTargets(cfg, { env: process.env }); + expect(targets).not.toContainEqual({ + agentId: "ops", + storePath: expect.stringContaining(path.join("ops", "sessions", "sessions.json")), + }); + }); + }); + + it("skips discovered directories that only normalize into the default main agent", async () => { + await withTempHome(async (home) => { + const stateDir = path.join(home, ".openclaw"); + const mainSessionsDir = path.join(stateDir, "agents", "main", "sessions"); + const junkSessionsDir = path.join(stateDir, "agents", "###", "sessions"); + await fs.mkdir(mainSessionsDir, { recursive: true }); + await fs.mkdir(junkSessionsDir, { recursive: true }); + await fs.writeFile(path.join(mainSessionsDir, "sessions.json"), "{}", "utf8"); + await fs.writeFile(path.join(junkSessionsDir, "sessions.json"), "{}", "utf8"); + + const cfg: OpenClawConfig = {}; + const mainStorePath = await resolveRealStorePath(mainSessionsDir); + const targets = await resolveAllAgentSessionStoreTargets(cfg, { env: process.env }); + + expect(targets).toContainEqual({ + agentId: "main", + storePath: mainStorePath, + }); + expect( + targets.some((target) => target.storePath === path.join(junkSessionsDir, "sessions.json")), + ).toBe(false); + }); + }); +}); + +describe("resolveAllAgentSessionStoreTargetsSync", () => { + it("skips unreadable or invalid discovery roots when other roots are still readable", async () => { + await withTempHome(async (home) => { + const customRoot = path.join(home, "custom-state"); + await fs.mkdir(customRoot, { recursive: true }); + await fs.writeFile(path.join(customRoot, "agents"), "not-a-directory", "utf8"); + + const envStateDir = path.join(home, "env-state"); + const mainSessionsDir = path.join(envStateDir, "agents", "main", "sessions"); + const retiredSessionsDir = path.join(envStateDir, "agents", "retired", "sessions"); + await fs.mkdir(mainSessionsDir, { recursive: true }); + await fs.mkdir(retiredSessionsDir, { recursive: true }); + await fs.writeFile(path.join(mainSessionsDir, "sessions.json"), "{}", "utf8"); + await fs.writeFile(path.join(retiredSessionsDir, "sessions.json"), "{}", "utf8"); + + const cfg: OpenClawConfig = { + session: { + store: path.join(customRoot, "agents", "{agentId}", "sessions", "sessions.json"), + }, + agents: { + list: [{ id: "main", default: true }], + }, + }; + const env = { + ...process.env, + OPENCLAW_STATE_DIR: envStateDir, + }; + const retiredStorePath = await resolveRealStorePath(retiredSessionsDir); + + expect(resolveAllAgentSessionStoreTargetsSync(cfg, { env })).toEqual( + expect.arrayContaining([ + { + agentId: "retired", + storePath: retiredStorePath, + }, + ]), + ); + }); + }); + + it("skips symlinked discovered stores under templated agents roots", async () => { + await withTempHome(async (home) => { + if (process.platform === "win32") { + return; + } + const customRoot = path.join(home, "custom-state"); + const opsSessionsDir = path.join(customRoot, "agents", "ops", "sessions"); + const leakedFile = path.join(home, "outside.json"); + await fs.mkdir(opsSessionsDir, { recursive: true }); + await fs.writeFile(leakedFile, JSON.stringify({ leak: { secret: "x" } }), "utf8"); + await fs.symlink(leakedFile, path.join(opsSessionsDir, "sessions.json")); + + const cfg: OpenClawConfig = { + session: { + store: path.join(customRoot, "agents", "{agentId}", "sessions", "sessions.json"), + }, + agents: { + list: [{ id: "ops", default: true }], + }, + }; + + const targets = resolveAllAgentSessionStoreTargetsSync(cfg, { env: process.env }); + expect(targets).not.toContainEqual({ + agentId: "ops", + storePath: expect.stringContaining(path.join("ops", "sessions", "sessions.json")), + }); + }); + }); +}); diff --git a/src/config/sessions/targets.ts b/src/config/sessions/targets.ts new file mode 100644 index 00000000000..0a676f98ddf --- /dev/null +++ b/src/config/sessions/targets.ts @@ -0,0 +1,344 @@ +import fsSync from "node:fs"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { listAgentIds, resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { + resolveAgentSessionDirsFromAgentsDir, + resolveAgentSessionDirsFromAgentsDirSync, +} from "../../agents/session-dirs.js"; +import { DEFAULT_AGENT_ID, normalizeAgentId } from "../../routing/session-key.js"; +import { resolveStateDir } from "../paths.js"; +import type { OpenClawConfig } from "../types.openclaw.js"; +import { resolveAgentsDirFromSessionStorePath, resolveStorePath } from "./paths.js"; + +export type SessionStoreSelectionOptions = { + store?: string; + agent?: string; + allAgents?: boolean; +}; + +export type SessionStoreTarget = { + agentId: string; + storePath: string; +}; + +const NON_FATAL_DISCOVERY_ERROR_CODES = new Set([ + "EACCES", + "ELOOP", + "ENOENT", + "ENOTDIR", + "EPERM", + "ESTALE", +]); + +function dedupeTargetsByStorePath(targets: SessionStoreTarget[]): SessionStoreTarget[] { + const deduped = new Map(); + for (const target of targets) { + if (!deduped.has(target.storePath)) { + deduped.set(target.storePath, target); + } + } + return [...deduped.values()]; +} + +function shouldSkipDiscoveryError(err: unknown): boolean { + const code = (err as NodeJS.ErrnoException | undefined)?.code; + return typeof code === "string" && NON_FATAL_DISCOVERY_ERROR_CODES.has(code); +} + +function isWithinRoot(realPath: string, realRoot: string): boolean { + return realPath === realRoot || realPath.startsWith(`${realRoot}${path.sep}`); +} + +function shouldSkipDiscoveredAgentDirName(dirName: string, agentId: string): boolean { + // Avoid collapsing arbitrary directory names like "###" into the default main agent. + // Human-friendly names like "Retired Agent" are still allowed because they normalize to + // a non-default stable id and preserve the intended retired-store discovery behavior. + return agentId === DEFAULT_AGENT_ID && dirName.trim().toLowerCase() !== DEFAULT_AGENT_ID; +} + +function resolveValidatedDiscoveredStorePathSync(params: { + sessionsDir: string; + agentsRoot: string; + realAgentsRoot?: string; +}): string | undefined { + const storePath = path.join(params.sessionsDir, "sessions.json"); + try { + const stat = fsSync.lstatSync(storePath); + if (stat.isSymbolicLink() || !stat.isFile()) { + return undefined; + } + const realStorePath = fsSync.realpathSync(storePath); + const realAgentsRoot = params.realAgentsRoot ?? fsSync.realpathSync(params.agentsRoot); + return isWithinRoot(realStorePath, realAgentsRoot) ? realStorePath : undefined; + } catch (err) { + if (shouldSkipDiscoveryError(err)) { + return undefined; + } + throw err; + } +} + +async function resolveValidatedDiscoveredStorePath(params: { + sessionsDir: string; + agentsRoot: string; + realAgentsRoot?: string; +}): Promise { + const storePath = path.join(params.sessionsDir, "sessions.json"); + try { + const stat = await fs.lstat(storePath); + if (stat.isSymbolicLink() || !stat.isFile()) { + return undefined; + } + const realStorePath = await fs.realpath(storePath); + const realAgentsRoot = params.realAgentsRoot ?? (await fs.realpath(params.agentsRoot)); + return isWithinRoot(realStorePath, realAgentsRoot) ? realStorePath : undefined; + } catch (err) { + if (shouldSkipDiscoveryError(err)) { + return undefined; + } + throw err; + } +} + +function resolveSessionStoreDiscoveryState( + cfg: OpenClawConfig, + env: NodeJS.ProcessEnv, +): { + configuredTargets: SessionStoreTarget[]; + agentsRoots: string[]; +} { + const configuredTargets = resolveSessionStoreTargets(cfg, { allAgents: true }, { env }); + const agentsRoots = new Set(); + for (const target of configuredTargets) { + const agentsDir = resolveAgentsDirFromSessionStorePath(target.storePath); + if (agentsDir) { + agentsRoots.add(agentsDir); + } + } + agentsRoots.add(path.join(resolveStateDir(env), "agents")); + return { + configuredTargets, + agentsRoots: [...agentsRoots], + }; +} + +function toDiscoveredSessionStoreTarget( + sessionsDir: string, + storePath: string, +): SessionStoreTarget | undefined { + const dirName = path.basename(path.dirname(sessionsDir)); + const agentId = normalizeAgentId(dirName); + if (shouldSkipDiscoveredAgentDirName(dirName, agentId)) { + return undefined; + } + return { + agentId, + // Keep the actual on-disk store path so retired/manual agent dirs remain discoverable + // even if their directory name no longer round-trips through normalizeAgentId(). + storePath, + }; +} + +export function resolveAllAgentSessionStoreTargetsSync( + cfg: OpenClawConfig, + params: { env?: NodeJS.ProcessEnv } = {}, +): SessionStoreTarget[] { + const env = params.env ?? process.env; + const { configuredTargets, agentsRoots } = resolveSessionStoreDiscoveryState(cfg, env); + const realAgentsRoots = new Map(); + 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 agentsRoot = resolveAgentsDirFromSessionStorePath(target.storePath); + if (!agentsRoot) { + return [target]; + } + const realAgentsRoot = getRealAgentsRoot(agentsRoot); + if (!realAgentsRoot) { + return []; + } + const validatedStorePath = resolveValidatedDiscoveredStorePathSync({ + sessionsDir: path.dirname(target.storePath), + agentsRoot, + realAgentsRoot, + }); + return validatedStorePath ? [{ ...target, storePath: validatedStorePath }] : []; + }); + const discoveredTargets = agentsRoots.flatMap((agentsDir) => { + try { + const realAgentsRoot = getRealAgentsRoot(agentsDir); + if (!realAgentsRoot) { + return []; + } + return resolveAgentSessionDirsFromAgentsDirSync(agentsDir).flatMap((sessionsDir) => { + const validatedStorePath = resolveValidatedDiscoveredStorePathSync({ + sessionsDir, + agentsRoot: agentsDir, + realAgentsRoot, + }); + const target = validatedStorePath + ? toDiscoveredSessionStoreTarget(sessionsDir, validatedStorePath) + : undefined; + return target ? [target] : []; + }); + } catch (err) { + if (shouldSkipDiscoveryError(err)) { + return []; + } + throw err; + } + }); + return dedupeTargetsByStorePath([...validatedConfiguredTargets, ...discoveredTargets]); +} + +export async function resolveAllAgentSessionStoreTargets( + cfg: OpenClawConfig, + params: { env?: NodeJS.ProcessEnv } = {}, +): Promise { + const env = params.env ?? process.env; + const { configuredTargets, agentsRoots } = resolveSessionStoreDiscoveryState(cfg, env); + const realAgentsRoots = new Map(); + const getRealAgentsRoot = async (agentsRoot: string): Promise => { + 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 = ( + await Promise.all( + configuredTargets.map(async (target) => { + const agentsRoot = resolveAgentsDirFromSessionStorePath(target.storePath); + if (!agentsRoot) { + return target; + } + const realAgentsRoot = await getRealAgentsRoot(agentsRoot); + if (!realAgentsRoot) { + return undefined; + } + const validatedStorePath = await resolveValidatedDiscoveredStorePath({ + sessionsDir: path.dirname(target.storePath), + agentsRoot, + realAgentsRoot, + }); + return validatedStorePath ? { ...target, storePath: validatedStorePath } : undefined; + }), + ) + ).filter((target): target is SessionStoreTarget => Boolean(target)); + + const discoveredTargets = ( + await Promise.all( + agentsRoots.map(async (agentsDir) => { + try { + const realAgentsRoot = await getRealAgentsRoot(agentsDir); + if (!realAgentsRoot) { + return []; + } + const sessionsDirs = await resolveAgentSessionDirsFromAgentsDir(agentsDir); + return ( + await Promise.all( + sessionsDirs.map(async (sessionsDir) => { + const validatedStorePath = await resolveValidatedDiscoveredStorePath({ + sessionsDir, + agentsRoot: agentsDir, + realAgentsRoot, + }); + return validatedStorePath + ? toDiscoveredSessionStoreTarget(sessionsDir, validatedStorePath) + : undefined; + }), + ) + ).filter((target): target is SessionStoreTarget => Boolean(target)); + } catch (err) { + if (shouldSkipDiscoveryError(err)) { + return []; + } + throw err; + } + }), + ) + ).flat(); + + return dedupeTargetsByStorePath([...validatedConfiguredTargets, ...discoveredTargets]); +} + +export function resolveSessionStoreTargets( + cfg: OpenClawConfig, + opts: SessionStoreSelectionOptions, + params: { env?: NodeJS.ProcessEnv } = {}, +): SessionStoreTarget[] { + const env = params.env ?? process.env; + const defaultAgentId = resolveDefaultAgentId(cfg); + const hasAgent = Boolean(opts.agent?.trim()); + const allAgents = opts.allAgents === true; + if (hasAgent && allAgents) { + throw new Error("--agent and --all-agents cannot be used together"); + } + if (opts.store && (hasAgent || allAgents)) { + throw new Error("--store cannot be combined with --agent or --all-agents"); + } + + if (opts.store) { + return [ + { + agentId: defaultAgentId, + storePath: resolveStorePath(opts.store, { agentId: defaultAgentId, env }), + }, + ]; + } + + if (allAgents) { + const targets = listAgentIds(cfg).map((agentId) => ({ + agentId, + storePath: resolveStorePath(cfg.session?.store, { agentId, env }), + })); + return dedupeTargetsByStorePath(targets); + } + + if (hasAgent) { + const knownAgents = listAgentIds(cfg); + const requested = normalizeAgentId(opts.agent ?? ""); + if (!knownAgents.includes(requested)) { + throw new Error( + `Unknown agent id "${opts.agent}". Use "openclaw agents list" to see configured agents.`, + ); + } + return [ + { + agentId: requested, + storePath: resolveStorePath(cfg.session?.store, { agentId: requested, env }), + }, + ]; + } + + return [ + { + agentId: defaultAgentId, + storePath: resolveStorePath(cfg.session?.store, { agentId: defaultAgentId, env }), + }, + ]; +} diff --git a/src/gateway/server-session-key.test.ts b/src/gateway/server-session-key.test.ts new file mode 100644 index 00000000000..b779921ae62 --- /dev/null +++ b/src/gateway/server-session-key.test.ts @@ -0,0 +1,104 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { resetAgentRunContextForTest } from "../infra/agent-events.js"; + +const hoisted = vi.hoisted(() => ({ + loadConfigMock: vi.fn<() => OpenClawConfig>(), + loadCombinedSessionStoreForGatewayMock: vi.fn(), +})); + +vi.mock("../config/config.js", () => ({ + loadConfig: () => hoisted.loadConfigMock(), +})); + +vi.mock("./session-utils.js", async () => { + const actual = await vi.importActual("./session-utils.js"); + return { + ...actual, + loadCombinedSessionStoreForGateway: (cfg: OpenClawConfig) => + hoisted.loadCombinedSessionStoreForGatewayMock(cfg), + }; +}); + +const { resolveSessionKeyForRun, resetResolvedSessionKeyForRunCacheForTest } = + await import("./server-session-key.js"); + +describe("resolveSessionKeyForRun", () => { + beforeEach(() => { + hoisted.loadConfigMock.mockReset(); + hoisted.loadCombinedSessionStoreForGatewayMock.mockReset(); + resetAgentRunContextForTest(); + resetResolvedSessionKeyForRunCacheForTest(); + }); + + afterEach(() => { + resetAgentRunContextForTest(); + resetResolvedSessionKeyForRunCacheForTest(); + }); + + it("resolves run ids from the combined gateway store and caches the result", () => { + const cfg: OpenClawConfig = { + session: { + store: "/custom/root/agents/{agentId}/sessions/sessions.json", + }, + }; + hoisted.loadConfigMock.mockReturnValue(cfg); + hoisted.loadCombinedSessionStoreForGatewayMock.mockReturnValue({ + storePath: "(multiple)", + store: { + "agent:retired:acp:run-1": { sessionId: "run-1", updatedAt: 123 }, + }, + }); + + expect(resolveSessionKeyForRun("run-1")).toBe("acp:run-1"); + expect(resolveSessionKeyForRun("run-1")).toBe("acp:run-1"); + expect(hoisted.loadCombinedSessionStoreForGatewayMock).toHaveBeenCalledTimes(1); + expect(hoisted.loadCombinedSessionStoreForGatewayMock).toHaveBeenCalledWith(cfg); + }); + + it("caches misses briefly before re-checking the combined store", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-12T15:00:00Z")); + hoisted.loadConfigMock.mockReturnValue({}); + hoisted.loadCombinedSessionStoreForGatewayMock.mockReturnValue({ + storePath: "(multiple)", + store: {}, + }); + + expect(resolveSessionKeyForRun("missing-run")).toBeUndefined(); + expect(resolveSessionKeyForRun("missing-run")).toBeUndefined(); + expect(hoisted.loadCombinedSessionStoreForGatewayMock).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(1_001); + + expect(resolveSessionKeyForRun("missing-run")).toBeUndefined(); + expect(hoisted.loadCombinedSessionStoreForGatewayMock).toHaveBeenCalledTimes(2); + vi.useRealTimers(); + }); + + it("prefers the structurally matching session key when duplicate session ids exist", () => { + hoisted.loadConfigMock.mockReturnValue({}); + hoisted.loadCombinedSessionStoreForGatewayMock.mockReturnValue({ + storePath: "(multiple)", + store: { + "agent:main:other": { sessionId: "run-dup", updatedAt: 999 }, + "agent:retired:acp:run-dup": { sessionId: "run-dup", updatedAt: 100 }, + }, + }); + + expect(resolveSessionKeyForRun("run-dup")).toBe("acp:run-dup"); + }); + + it("refuses ambiguous duplicate session ids without a clear best match", () => { + hoisted.loadConfigMock.mockReturnValue({}); + hoisted.loadCombinedSessionStoreForGatewayMock.mockReturnValue({ + storePath: "(multiple)", + store: { + "agent:main:first": { sessionId: "run-ambiguous", updatedAt: 100 }, + "agent:retired:second": { sessionId: "run-ambiguous", updatedAt: 100 }, + }, + }); + + expect(resolveSessionKeyForRun("run-ambiguous")).toBeUndefined(); + }); +}); diff --git a/src/gateway/server-session-key.ts b/src/gateway/server-session-key.ts index 4a9694f66bc..858a37edf13 100644 --- a/src/gateway/server-session-key.ts +++ b/src/gateway/server-session-key.ts @@ -1,22 +1,70 @@ import { loadConfig } from "../config/config.js"; -import { loadSessionStore, resolveStorePath } from "../config/sessions.js"; +import type { SessionEntry } from "../config/sessions.js"; import { getAgentRunContext, registerAgentRunContext } from "../infra/agent-events.js"; import { toAgentRequestSessionKey } from "../routing/session-key.js"; +import { resolvePreferredSessionKeyForSessionIdMatches } from "../sessions/session-id-resolution.js"; +import { loadCombinedSessionStoreForGateway } from "./session-utils.js"; + +const RUN_LOOKUP_CACHE_LIMIT = 256; +const RUN_LOOKUP_MISS_TTL_MS = 1_000; + +type RunLookupCacheEntry = { + sessionKey: string | null; + expiresAt: number | null; +}; + +const resolvedSessionKeyByRunId = new Map(); + +function setResolvedSessionKeyCache(runId: string, sessionKey: string | null): void { + if (!runId) { + return; + } + if ( + !resolvedSessionKeyByRunId.has(runId) && + resolvedSessionKeyByRunId.size >= RUN_LOOKUP_CACHE_LIMIT + ) { + const oldest = resolvedSessionKeyByRunId.keys().next().value; + if (oldest) { + resolvedSessionKeyByRunId.delete(oldest); + } + } + resolvedSessionKeyByRunId.set(runId, { + sessionKey, + expiresAt: sessionKey === null ? Date.now() + RUN_LOOKUP_MISS_TTL_MS : null, + }); +} export function resolveSessionKeyForRun(runId: string) { const cached = getAgentRunContext(runId)?.sessionKey; if (cached) { return cached; } + const cachedLookup = resolvedSessionKeyByRunId.get(runId); + if (cachedLookup !== undefined) { + if (cachedLookup.sessionKey !== null) { + return cachedLookup.sessionKey; + } + if ((cachedLookup.expiresAt ?? 0) > Date.now()) { + return undefined; + } + resolvedSessionKeyByRunId.delete(runId); + } const cfg = loadConfig(); - const storePath = resolveStorePath(cfg.session?.store); - const store = loadSessionStore(storePath); - const found = Object.entries(store).find(([, entry]) => entry?.sessionId === runId); - const storeKey = found?.[0]; + const { store } = loadCombinedSessionStoreForGateway(cfg); + const matches = Object.entries(store).filter( + (entry): entry is [string, SessionEntry] => entry[1]?.sessionId === runId, + ); + const storeKey = resolvePreferredSessionKeyForSessionIdMatches(matches, runId); if (storeKey) { const sessionKey = toAgentRequestSessionKey(storeKey) ?? storeKey; registerAgentRunContext(runId, { sessionKey }); + setResolvedSessionKeyCache(runId, sessionKey); return sessionKey; } + setResolvedSessionKeyCache(runId, null); return undefined; } + +export function resetResolvedSessionKeyForRunCacheForTest(): void { + resolvedSessionKeyByRunId.clear(); +} diff --git a/src/gateway/session-utils.test.ts b/src/gateway/session-utils.test.ts index 943aea46e90..796c20167bc 100644 --- a/src/gateway/session-utils.test.ts +++ b/src/gateway/session-utils.test.ts @@ -767,7 +767,8 @@ describe("listSessionsFromStore search", () => { describe("loadCombinedSessionStoreForGateway includes disk-only agents (#32804)", () => { test("ACP agent sessions are visible even when agents.list is configured", async () => { await withStateDirEnv("openclaw-acp-vis-", async ({ stateDir }) => { - const agentsDir = path.join(stateDir, "agents"); + const customRoot = path.join(stateDir, "custom-state"); + const agentsDir = path.join(customRoot, "agents"); const mainDir = path.join(agentsDir, "main", "sessions"); const codexDir = path.join(agentsDir, "codex", "sessions"); fs.mkdirSync(mainDir, { recursive: true }); @@ -792,7 +793,7 @@ describe("loadCombinedSessionStoreForGateway includes disk-only agents (#32804)" const cfg = { session: { mainKey: "main", - store: path.join(stateDir, "agents", "{agentId}", "sessions", "sessions.json"), + store: path.join(customRoot, "agents", "{agentId}", "sessions", "sessions.json"), }, agents: { list: [{ id: "main", default: true }], diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index e16777f4f2c..4d71c32246a 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -15,6 +15,7 @@ import { buildGroupDisplayName, canonicalizeMainSessionAlias, loadSessionStore, + resolveAllAgentSessionStoreTargetsSync, resolveAgentMainSessionKey, resolveFreshSessionTotalTokens, resolveMainSessionKey, @@ -585,10 +586,11 @@ export function loadCombinedSessionStoreForGateway(cfg: OpenClawConfig): { return { storePath, store: combined }; } - const agentIds = listConfiguredAgentIds(cfg); + const targets = resolveAllAgentSessionStoreTargetsSync(cfg); const combined: Record = {}; - for (const agentId of agentIds) { - const storePath = resolveStorePath(storeConfig, { agentId }); + for (const target of targets) { + const agentId = target.agentId; + const storePath = target.storePath; const store = loadSessionStore(storePath); for (const [key, entry] of Object.entries(store)) { const canonicalKey = canonicalizeSessionKeyForAgent(agentId, key); diff --git a/src/sessions/session-id-resolution.ts b/src/sessions/session-id-resolution.ts new file mode 100644 index 00000000000..f0cde40c2e1 --- /dev/null +++ b/src/sessions/session-id-resolution.ts @@ -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; +}