fix: persist rotated gateway session files

Signed-off-by: sallyom <somalley@redhat.com>
This commit is contained in:
sallyom
2026-05-06 21:31:07 -04:00
committed by Sally O'Malley
parent 69d446d178
commit bf2511098f
6 changed files with 296 additions and 0 deletions

View File

@@ -129,6 +129,7 @@ Docs: https://docs.openclaw.ai
- Telegram: honor `accessGroup:*` sender allowlists for DMs, groups, native commands, and callback authorization before applying Telegram's numeric sender-ID checks. Fixes #78660. Thanks @manugc.
- Agent delivery: report `deliverySucceeded=false` when outbound delivery returns no adapter result, so claimed/empty delivery paths no longer masquerade as successful sends. Fixes #78532. Thanks @joeyfrasier.
- Cron/isolated runs: fail implicit announce delivery before model execution when `delivery.channel=last` has no previous route, so recurring jobs do not spend tokens before hitting a permanent delivery-target error. Fixes #78608. Thanks @sallyom.
- Gateway/sessions: persist a new generated transcript file when daily gateway-agent session rollover changes the session id, while preserving custom transcript paths. Fixes #78607. Thanks @nailujac, @zerone0x, and @sallyom.
- Doctor/OpenAI Codex: revert the 2026.5.5 `doctor --fix` repair that rewrote valid `openai-codex/*` ChatGPT/Codex OAuth routes to `openai/*`, which could break OAuth-only GPT-5.5 setups or accidentally move users onto the OpenAI API-key route. If 2026.5.5 already changed your default model, run `openclaw models set openai-codex/gpt-5.5 && openclaw config validate` to switch the default agent back to the Codex OAuth PI route. Fixes #78407.
- Telegram: keep the polling watchdog tied to `getUpdates` liveness so unrelated outbound Bot API calls cannot mask a wedged inbound poller. Fixes #78422. Thanks @ai-hpc.
- Discord/groups: instruct group-chat agents to stay silent when a message is addressed to someone else, replying only when invited or correcting key facts. (#78615)

View File

@@ -1407,6 +1407,36 @@ describe("initSessionState reset policy", () => {
expect(result.sessionId).not.toBe(existingSessionId);
});
it("rotates sessionFile on daily reset when the stored path still points at the previous session id", async () => {
vi.setSystemTime(new Date(2026, 0, 18, 5, 0, 0));
const root = await makeCaseDir("openclaw-reset-rotate-session-file-");
const storePath = path.join(root, "sessions.json");
const sessionKey = "agent:main:whatsapp:dm:s-rotate";
const existingSessionId = "daily-rotate-old";
const oldSessionFile = path.join(root, `${existingSessionId}.jsonl`);
await writeSessionStoreFast(storePath, {
[sessionKey]: {
sessionId: existingSessionId,
updatedAt: new Date(2026, 0, 18, 3, 0, 0).getTime(),
sessionFile: oldSessionFile,
},
});
const cfg = { session: { store: storePath } } as OpenClawConfig;
const result = await initSessionState({
ctx: { Body: "hello", SessionKey: sessionKey },
cfg,
commandAuthorized: true,
});
expect(result.isNewSession).toBe(true);
expect(result.sessionId).not.toBe(existingSessionId);
expect(result.sessionEntry.sessionFile).toBeTruthy();
expect(path.basename(result.sessionEntry.sessionFile ?? "")).toBe(`${result.sessionId}.jsonl`);
expect(result.sessionEntry.sessionFile).not.toBe(oldSessionFile);
});
it("drains stale system events when idle rollover creates a new session", async () => {
vi.setSystemTime(new Date(2026, 0, 18, 5, 30, 0));
const root = await makeCaseDir("openclaw-reset-idle-system-events-");

View File

@@ -281,6 +281,74 @@ export function resolveSessionFilePath(
return resolveSessionTranscriptPathInDir(sessionId, sessionsDir);
}
const GENERATED_UUID_SESSION_FILE_RE =
/^([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})(-topic-.+)?\.jsonl$/i;
function resolveGeneratedSessionFileSuffix(
previousSessionId: string,
previousSessionFile: string,
): string | undefined {
const baseName = path.basename(previousSessionFile);
if (baseName === `${previousSessionId}.jsonl`) {
return ".jsonl";
}
const topicPrefix = `${previousSessionId}-topic-`;
if (baseName.startsWith(topicPrefix) && baseName.endsWith(".jsonl")) {
return baseName.slice(previousSessionId.length);
}
const generatedUuidMatch = GENERATED_UUID_SESSION_FILE_RE.exec(baseName);
if (generatedUuidMatch) {
return `${generatedUuidMatch[2] ?? ""}.jsonl`;
}
return undefined;
}
export function resolveRotatedGeneratedSessionFilePath(params: {
previousSessionId: string;
nextSessionId: string;
previousSessionFile?: string;
sessionsDir: string;
agentId?: string;
}): string | undefined {
const previousSessionFile = params.previousSessionFile?.trim();
if (!previousSessionFile || params.previousSessionId === params.nextSessionId) {
return undefined;
}
try {
resolvePathWithinSessionsDir(params.sessionsDir, previousSessionFile, {
agentId: params.agentId,
});
} catch {
if (!path.isAbsolute(previousSessionFile)) {
return undefined;
}
const relative = path.relative(
path.resolve(params.sessionsDir),
path.resolve(previousSessionFile),
);
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
return undefined;
}
}
const generatedSuffix = resolveGeneratedSessionFileSuffix(
params.previousSessionId,
previousSessionFile,
);
if (!generatedSuffix) {
return undefined;
}
const nextFileName = `${params.nextSessionId}${generatedSuffix}`;
try {
return resolvePathWithinSessionsDir(params.sessionsDir, nextFileName, {
agentId: params.agentId,
});
} catch {
return undefined;
}
}
export function resolveStorePath(
store?: string,
opts?: { agentId?: string; env?: NodeJS.ProcessEnv },

View File

@@ -11,6 +11,7 @@ import { resolveSessionLifecycleTimestamps } from "./lifecycle.js";
import {
resolveSessionFilePath,
resolveSessionFilePathOptions,
resolveRotatedGeneratedSessionFilePath,
resolveSessionTranscriptPathInDir,
validateSessionId,
} from "./paths.js";
@@ -52,6 +53,57 @@ describe("session path safety", () => {
expect(resolved).toBe(path.resolve(sessionsDir, "sess-1.jsonl"));
});
it("rotates generated transcript paths when session id changes", () => {
const sessionsDir = "/tmp/openclaw/agents/main/sessions";
const previousSessionFile = path.join(sessionsDir, "sess-1.jsonl");
const resolved = resolveRotatedGeneratedSessionFilePath({
previousSessionId: "sess-1",
nextSessionId: "sess-2",
previousSessionFile,
sessionsDir,
});
expect(resolved).toBe(path.resolve(sessionsDir, "sess-2.jsonl"));
});
it("rotates already-stale generated UUID transcript paths", () => {
const sessionsDir = "/tmp/openclaw/agents/main/sessions";
const staleSessionFile = path.join(sessionsDir, "685a51f7-7adf-48b1-89ca-d3ab86dd6e0f.jsonl");
const resolved = resolveRotatedGeneratedSessionFilePath({
previousSessionId: "63b16647-ea0c-4a22-808b-ce616326b445",
nextSessionId: "a8ea43fe-8729-4742-8db0-d4ab4522d5d1",
previousSessionFile: staleSessionFile,
sessionsDir,
});
expect(resolved).toBe(path.resolve(sessionsDir, "a8ea43fe-8729-4742-8db0-d4ab4522d5d1.jsonl"));
});
it("does not rotate custom transcript paths when session id changes", () => {
const sessionsDir = "/tmp/openclaw/agents/main/sessions";
const customPath = path.join(sessionsDir, "custom-owned-child-transcript.jsonl");
const resolved = resolveRotatedGeneratedSessionFilePath({
previousSessionId: "sess-1",
nextSessionId: "sess-2",
previousSessionFile: customPath,
sessionsDir,
});
expect(resolved).toBeUndefined();
});
it("keeps topic transcript paths when the persisted sessionFile matches the session id", () => {
const sessionsDir = "/tmp/openclaw/agents/main/sessions";
const topicPath = path.join(sessionsDir, "sess-1-topic-456.jsonl");
const resolved = resolveSessionFilePath("sess-1", { sessionFile: topicPath }, { sessionsDir });
expect(resolved).toBe(path.resolve(topicPath));
});
it("ignores multi-store sentinel paths when deriving session file options", () => {
expect(resolveSessionFilePathOptions({ agentId: "worker", storePath: "(multiple)" })).toEqual({
agentId: "worker",

View File

@@ -1770,6 +1770,7 @@ describe("gateway agent handler", () => {
updatedAt: now,
sessionStartedAt: now - 25 * 60 * 60_000,
lastInteractionAt: now - 25 * 60 * 60_000,
sessionFile: "/tmp/stale-session-id.jsonl",
},
{
session: {
@@ -1813,6 +1814,137 @@ describe("gateway agent handler", () => {
expect(call.sessionId).not.toBe("stale-session-id");
expect(capturedEntry?.sessionStartedAt).toBe(now);
expect(capturedEntry?.lastInteractionAt).toBe(now);
expect(capturedEntry?.sessionFile).toBeTruthy();
expect(capturedEntry?.sessionFile).not.toBe("/tmp/stale-session-id.jsonl");
expect(String(capturedEntry?.sessionFile)).toContain(
`${String(capturedEntry?.sessionId)}.jsonl`,
);
} finally {
vi.useRealTimers();
}
});
it("preserves custom transcript paths when stale gateway agent sessions roll", async () => {
const now = Date.parse("2026-04-25T12:00:00.000Z");
const customSessionFile = "/tmp/custom-owned-child-transcript.jsonl";
vi.useFakeTimers();
vi.setSystemTime(now);
try {
mocks.resolveExplicitAgentSessionKey.mockReturnValue("agent:main:main");
mockMainSessionEntry(
{
sessionId: "stale-session-id",
updatedAt: now,
sessionStartedAt: now - 25 * 60 * 60_000,
lastInteractionAt: now - 25 * 60 * 60_000,
sessionFile: customSessionFile,
},
{
session: {
reset: {
mode: "daily",
atHour: 4,
},
},
},
);
const loaded = mocks.loadSessionEntry();
let capturedEntry: Record<string, unknown> | undefined;
mocks.updateSessionStore.mockImplementation(async (_path, updater) => {
const store: Record<string, unknown> = {
[loaded.canonicalKey]: structuredClone(loaded.entry),
};
const result = await updater(store);
capturedEntry = result as Record<string, unknown>;
return result;
});
mocks.agentCommand.mockResolvedValue({
payloads: [{ text: "ok" }],
meta: { durationMs: 100 },
});
await invokeAgent(
{
message: "daily rollover",
agentId: "main",
sessionKey: "agent:main:main",
idempotencyKey: "daily-rollover-custom-session-file",
},
{ reqId: "daily-rollover-custom-session-file" },
);
const call = await waitForAgentCommandCall<{
sessionId?: string;
sessionKey?: string;
}>();
expect(call.sessionKey).toBe("agent:main:main");
expect(call.sessionId).not.toBe("stale-session-id");
expect(capturedEntry?.sessionFile).toBe(customSessionFile);
} finally {
vi.useRealTimers();
}
});
it("repairs already-stale generated transcript paths when gateway agent sessions roll", async () => {
const now = Date.parse("2026-05-06T12:00:00.000Z");
const alreadyStaleSessionFile = "/tmp/685a51f7-7adf-48b1-89ca-d3ab86dd6e0f.jsonl";
vi.useFakeTimers();
vi.setSystemTime(now);
try {
mocks.resolveExplicitAgentSessionKey.mockReturnValue("agent:main:main");
mockMainSessionEntry(
{
sessionId: "63b16647-ea0c-4a22-808b-ce616326b445",
updatedAt: now,
sessionStartedAt: now - 25 * 60 * 60_000,
lastInteractionAt: now - 25 * 60 * 60_000,
sessionFile: alreadyStaleSessionFile,
},
{
session: {
reset: {
mode: "daily",
atHour: 4,
},
},
},
);
const loaded = mocks.loadSessionEntry();
let capturedEntry: Record<string, unknown> | undefined;
mocks.updateSessionStore.mockImplementation(async (_path, updater) => {
const store: Record<string, unknown> = {
[loaded.canonicalKey]: structuredClone(loaded.entry),
};
const result = await updater(store);
capturedEntry = result as Record<string, unknown>;
return result;
});
mocks.agentCommand.mockResolvedValue({
payloads: [{ text: "ok" }],
meta: { durationMs: 100 },
});
await invokeAgent(
{
message: "daily rollover",
agentId: "main",
sessionKey: "agent:main:main",
idempotencyKey: "daily-rollover-already-stale-session-file",
},
{ reqId: "daily-rollover-already-stale-session-file" },
);
const call = await waitForAgentCommandCall<{
sessionId?: string;
sessionKey?: string;
}>();
expect(call.sessionKey).toBe("agent:main:main");
expect(call.sessionId).not.toBe("63b16647-ea0c-4a22-808b-ce616326b445");
expect(capturedEntry?.sessionFile).toBeTruthy();
expect(capturedEntry?.sessionFile).not.toBe(alreadyStaleSessionFile);
expect(String(capturedEntry?.sessionFile)).toContain(
`${String(capturedEntry?.sessionId)}.jsonl`,
);
} finally {
vi.useRealTimers();
}

View File

@@ -1,4 +1,5 @@
import { randomUUID } from "node:crypto";
import path from "node:path";
import {
listAgentIds,
resolveDefaultAgentId,
@@ -33,6 +34,7 @@ import {
resolveAgentIdFromSessionKey,
resolveExplicitAgentSessionKey,
resolveAgentMainSessionKey,
resolveRotatedGeneratedSessionFilePath,
resolveSessionLifecycleTimestamps,
resolveSessionResetPolicy,
resolveSessionResetType,
@@ -1028,6 +1030,16 @@ export const agentHandlers: GatewayRequestHandlers = {
const effectiveDeliveryFields = normalizeSessionDeliveryFields({
deliveryContext: effectiveDelivery,
});
const rotatedGeneratedSessionFile =
storePath && isNewSession && entry?.sessionId
? resolveRotatedGeneratedSessionFilePath({
previousSessionId: entry.sessionId,
nextSessionId: sessionId,
previousSessionFile: entry.sessionFile,
sessionsDir: path.dirname(storePath),
agentId: resolveAgentIdFromSessionKey(canonicalKey),
})
: undefined;
const nextEntryPatch: SessionEntry = {
sessionId,
updatedAt: now,
@@ -1067,6 +1079,7 @@ export const agentHandlers: GatewayRequestHandlers = {
cliSessionIds: entry?.cliSessionIds,
cliSessionBindings: entry?.cliSessionBindings,
claudeCliSessionId: entry?.claudeCliSessionId,
...(rotatedGeneratedSessionFile ? { sessionFile: rotatedGeneratedSessionFile } : {}),
};
sessionEntry = mergeSessionEntry(entry, nextEntryPatch);
if (request.deliver === true) {