diff --git a/src/config/sessions/targets.test.ts b/src/config/sessions/targets.test.ts index f67bc10f8b9..68f25567558 100644 --- a/src/config/sessions/targets.test.ts +++ b/src/config/sessions/targets.test.ts @@ -3,7 +3,11 @@ 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, resolveSessionStoreTargets } from "./targets.js"; +import { + resolveAllAgentSessionStoreTargets, + resolveAllAgentSessionStoreTargetsSync, + resolveSessionStoreTargets, +} from "./targets.js"; describe("resolveSessionStoreTargets", () => { it("resolves all configured agent stores", () => { @@ -209,4 +213,82 @@ describe("resolveAllAgentSessionStoreTargets", () => { ); }); }); + + 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, + }; + + await expect(resolveAllAgentSessionStoreTargets(cfg, { env })).resolves.toEqual( + expect.arrayContaining([ + { + agentId: "retired", + storePath: path.join(retiredSessionsDir, "sessions.json"), + }, + ]), + ); + }); + }); +}); + +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, + }; + + expect(resolveAllAgentSessionStoreTargetsSync(cfg, { env })).toEqual( + expect.arrayContaining([ + { + agentId: "retired", + storePath: path.join(retiredSessionsDir, "sessions.json"), + }, + ]), + ); + }); + }); }); diff --git a/src/config/sessions/targets.ts b/src/config/sessions/targets.ts index 659bd511a86..76b842d3dab 100644 --- a/src/config/sessions/targets.ts +++ b/src/config/sessions/targets.ts @@ -20,6 +20,15 @@ export type SessionStoreTarget = { 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) { @@ -30,6 +39,11 @@ function dedupeTargetsByStorePath(targets: SessionStoreTarget[]): SessionStoreTa 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 resolveSessionStoreDiscoveryState( cfg: OpenClawConfig, env: NodeJS.ProcessEnv, @@ -71,7 +85,16 @@ export function resolveAllAgentSessionStoreTargetsSync( const env = params.env ?? process.env; const { configuredTargets, agentsRoots } = resolveSessionStoreDiscoveryState(cfg, env); const discoveredTargets = toDiscoveredSessionStoreTargets( - agentsRoots.flatMap((agentsDir) => resolveAgentSessionDirsFromAgentsDirSync(agentsDir)), + agentsRoots.flatMap((agentsDir) => { + try { + return resolveAgentSessionDirsFromAgentsDirSync(agentsDir); + } catch (err) { + if (shouldSkipDiscoveryError(err)) { + return []; + } + throw err; + } + }), ); return dedupeTargetsByStorePath([...configuredTargets, ...discoveredTargets]); } @@ -84,7 +107,16 @@ export async function resolveAllAgentSessionStoreTargets( const { configuredTargets, agentsRoots } = resolveSessionStoreDiscoveryState(cfg, env); const discoveredDirs = await Promise.all( - agentsRoots.map((agentsDir) => resolveAgentSessionDirsFromAgentsDir(agentsDir)), + agentsRoots.map(async (agentsDir) => { + try { + return await resolveAgentSessionDirsFromAgentsDir(agentsDir); + } catch (err) { + if (shouldSkipDiscoveryError(err)) { + return []; + } + throw err; + } + }), ); const discoveredTargets = toDiscoveredSessionStoreTargets(discoveredDirs.flat()); return dedupeTargetsByStorePath([...configuredTargets, ...discoveredTargets]); diff --git a/src/gateway/server-session-key.test.ts b/src/gateway/server-session-key.test.ts index dfdec62f488..063a8bfe774 100644 --- a/src/gateway/server-session-key.test.ts +++ b/src/gateway/server-session-key.test.ts @@ -56,7 +56,9 @@ describe("resolveSessionKeyForRun", () => { expect(hoisted.loadCombinedSessionStoreForGatewayMock).toHaveBeenCalledWith(cfg); }); - it("caches misses so repeated lookups do not rebuild the combined store", () => { + 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)", @@ -66,5 +68,11 @@ describe("resolveSessionKeyForRun", () => { 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(); }); }); diff --git a/src/gateway/server-session-key.ts b/src/gateway/server-session-key.ts index aaa742acfa8..dd31a670c0f 100644 --- a/src/gateway/server-session-key.ts +++ b/src/gateway/server-session-key.ts @@ -4,7 +4,14 @@ import { toAgentRequestSessionKey } from "../routing/session-key.js"; import { loadCombinedSessionStoreForGateway } from "./session-utils.js"; const RUN_LOOKUP_CACHE_LIMIT = 256; -const resolvedSessionKeyByRunId = new Map(); +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) { @@ -19,7 +26,10 @@ function setResolvedSessionKeyCache(runId: string, sessionKey: string | null): v resolvedSessionKeyByRunId.delete(oldest); } } - resolvedSessionKeyByRunId.set(runId, sessionKey); + resolvedSessionKeyByRunId.set(runId, { + sessionKey, + expiresAt: sessionKey === null ? Date.now() + RUN_LOOKUP_MISS_TTL_MS : null, + }); } export function resolveSessionKeyForRun(runId: string) { @@ -29,7 +39,13 @@ export function resolveSessionKeyForRun(runId: string) { } const cachedLookup = resolvedSessionKeyByRunId.get(runId); if (cachedLookup !== undefined) { - return cachedLookup ?? undefined; + if (cachedLookup.sessionKey !== null) { + return cachedLookup.sessionKey; + } + if ((cachedLookup.expiresAt ?? 0) > Date.now()) { + return undefined; + } + resolvedSessionKeyByRunId.delete(runId); } const cfg = loadConfig(); const { store } = loadCombinedSessionStoreForGateway(cfg);