mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-17 08:40:46 +00:00
fix: persist rotated gateway session files
Signed-off-by: sallyom <somalley@redhat.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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-");
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user