Gateway: harden session discovery fallbacks

This commit is contained in:
Gustavo Madeira Santana
2026-03-12 15:58:45 +00:00
parent 7a3fdff3da
commit d0307acd78
4 changed files with 145 additions and 7 deletions

View File

@@ -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"),
},
]),
);
});
});
});

View File

@@ -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<string, SessionStoreTarget>();
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]);

View File

@@ -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();
});
});

View File

@@ -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<string, string | null>();
const RUN_LOOKUP_MISS_TTL_MS = 1_000;
type RunLookupCacheEntry = {
sessionKey: string | null;
expiresAt: number | null;
};
const resolvedSessionKeyByRunId = new Map<string, RunLookupCacheEntry>();
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);