From bf2511098f79eae2c4d638c132bfd458aae26f6e Mon Sep 17 00:00:00 2001 From: sallyom Date: Wed, 6 May 2026 21:31:07 -0400 Subject: [PATCH] fix: persist rotated gateway session files Signed-off-by: sallyom --- CHANGELOG.md | 1 + src/auto-reply/reply/session.test.ts | 30 ++++++ src/config/sessions/paths.ts | 68 ++++++++++++ src/config/sessions/sessions.test.ts | 52 +++++++++ src/gateway/server-methods/agent.test.ts | 132 +++++++++++++++++++++++ src/gateway/server-methods/agent.ts | 13 +++ 6 files changed, 296 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 03a7b0d5d9b..393a5583a1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/src/auto-reply/reply/session.test.ts b/src/auto-reply/reply/session.test.ts index 461ebef2f58..781a5373fe0 100644 --- a/src/auto-reply/reply/session.test.ts +++ b/src/auto-reply/reply/session.test.ts @@ -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-"); diff --git a/src/config/sessions/paths.ts b/src/config/sessions/paths.ts index 6141d368255..64475e01895 100644 --- a/src/config/sessions/paths.ts +++ b/src/config/sessions/paths.ts @@ -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 }, diff --git a/src/config/sessions/sessions.test.ts b/src/config/sessions/sessions.test.ts index 6525be9754d..78f1f6d28e1 100644 --- a/src/config/sessions/sessions.test.ts +++ b/src/config/sessions/sessions.test.ts @@ -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", diff --git a/src/gateway/server-methods/agent.test.ts b/src/gateway/server-methods/agent.test.ts index 6376479445a..a5c1f0d8684 100644 --- a/src/gateway/server-methods/agent.test.ts +++ b/src/gateway/server-methods/agent.test.ts @@ -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 | undefined; + mocks.updateSessionStore.mockImplementation(async (_path, updater) => { + const store: Record = { + [loaded.canonicalKey]: structuredClone(loaded.entry), + }; + const result = await updater(store); + capturedEntry = result as Record; + 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 | undefined; + mocks.updateSessionStore.mockImplementation(async (_path, updater) => { + const store: Record = { + [loaded.canonicalKey]: structuredClone(loaded.entry), + }; + const result = await updater(store); + capturedEntry = result as Record; + 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(); } diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index f1041ce527b..eefbea283f9 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -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) {