mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-09 03:22:40 +00:00
fix(sessions): preserve corrupt-header transcripts
Fixes #89037. Co-authored-by: Charles <charles-openclaw@9bcfae.inboxapi.ai>
This commit is contained in:
@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { SessionManager } from "../sessions/session-manager.js";
|
||||
import { prepareSessionManagerForRun } from "./session-manager-init.js";
|
||||
|
||||
const tempPaths: string[] = [];
|
||||
@@ -146,4 +147,119 @@ describe("prepareSessionManagerForRun", () => {
|
||||
);
|
||||
expect(JSON.parse(assistantLine ?? "{}")).toEqual(assistantEntry);
|
||||
});
|
||||
|
||||
it("does not truncate an existing transcript with a corrupted header", async () => {
|
||||
const sessionFile = await makeTempFile();
|
||||
const originalTranscript =
|
||||
[
|
||||
'{"type":"session","id":"broken"',
|
||||
JSON.stringify({
|
||||
type: "message",
|
||||
id: "user-1",
|
||||
parentId: null,
|
||||
timestamp: "2026-05-27T00:00:01.000Z",
|
||||
message: { role: "user", content: "persisted prompt" },
|
||||
}),
|
||||
].join("\n") + "\n";
|
||||
await fs.writeFile(sessionFile, originalTranscript, "utf-8");
|
||||
const sessionManager = {
|
||||
sessionId: "fresh-session",
|
||||
cwd: "/srv/openclaw/main",
|
||||
flushed: true,
|
||||
fileEntries: [
|
||||
{
|
||||
type: "session",
|
||||
id: "fresh-session",
|
||||
cwd: "/srv/openclaw/main",
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
message: { role: "user" },
|
||||
},
|
||||
],
|
||||
byId: new Map([["user-1", {}]]),
|
||||
labelsById: new Map(),
|
||||
leafId: "user-1",
|
||||
};
|
||||
|
||||
await expect(
|
||||
prepareSessionManagerForRun({
|
||||
sessionManager,
|
||||
sessionFile,
|
||||
hadSessionFile: true,
|
||||
sessionId: "new-session",
|
||||
cwd: "/tmp/task-repo",
|
||||
}),
|
||||
).rejects.toThrow("Refusing to reset session transcript with unreadable header");
|
||||
|
||||
expect(await fs.readFile(sessionFile, "utf-8")).toBe(originalTranscript);
|
||||
expect(sessionManager.fileEntries).toEqual([
|
||||
{
|
||||
type: "session",
|
||||
id: "fresh-session",
|
||||
cwd: "/srv/openclaw/main",
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
message: { role: "user" },
|
||||
},
|
||||
]);
|
||||
expect(sessionManager.flushed).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps recovered user-only transcripts through open and run preparation", async () => {
|
||||
const sessionFile = await makeTempFile();
|
||||
const userEntry = {
|
||||
type: "message",
|
||||
id: "user-1",
|
||||
parentId: null,
|
||||
timestamp: "2026-05-27T00:00:01.000Z",
|
||||
message: { role: "user", content: "persisted prompt" },
|
||||
};
|
||||
await fs.writeFile(
|
||||
sessionFile,
|
||||
['{"type":"session","id":"broken"', JSON.stringify(userEntry)].join("\n") + "\n",
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const sessionManager = SessionManager.open(sessionFile, path.dirname(sessionFile), "/old/cwd");
|
||||
|
||||
await prepareSessionManagerForRun({
|
||||
sessionManager,
|
||||
sessionFile,
|
||||
hadSessionFile: true,
|
||||
sessionId: "new-session",
|
||||
cwd: "/tmp/task-repo",
|
||||
});
|
||||
sessionManager.appendMessage({
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "response" }],
|
||||
api: "messages",
|
||||
provider: "anthropic",
|
||||
model: "sonnet-4.6",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
const entries = (await fs.readFile(sessionFile, "utf-8"))
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map(
|
||||
(line) => JSON.parse(line) as { type: string; id?: string; message?: { role?: string } },
|
||||
);
|
||||
expect(entries.map((entry) => entry.type)).toEqual(["session", "message", "message"]);
|
||||
expect(entries[0]).toEqual(
|
||||
expect.objectContaining({ type: "session", id: "new-session", cwd: "/tmp/task-repo" }),
|
||||
);
|
||||
expect(entries[1]).toEqual(userEntry);
|
||||
expect(entries[2]?.message?.role).toBe("assistant");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,30 @@ import { serializeJsonlLine, writeJsonlLines } from "../../config/sessions/trans
|
||||
type SessionHeaderEntry = { type: "session"; id?: string; cwd?: string };
|
||||
type SessionMessageEntry = { type: "message"; message?: { role?: string } };
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
async function assertExistingHeaderIsReadable(sessionFile: string): Promise<void> {
|
||||
const content = await fs.readFile(sessionFile, "utf-8");
|
||||
const firstLine = content.split("\n").find((line) => line.trim());
|
||||
if (!firstLine) {
|
||||
return;
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(firstLine);
|
||||
} catch (error) {
|
||||
throw new Error(`Refusing to reset session transcript with unreadable header: ${sessionFile}`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
if (!isRecord(parsed) || parsed.type !== "session") {
|
||||
throw new Error(`Refusing to reset session transcript with invalid header: ${sessionFile}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* session runtime SessionManager persistence quirk:
|
||||
* - If the file exists but has no assistant message, SessionManager marks itself `flushed=true`
|
||||
@@ -29,6 +53,7 @@ export async function prepareSessionManagerForRun(params: {
|
||||
byId?: Map<string, unknown>;
|
||||
labelsById?: Map<string, unknown>;
|
||||
leafId?: string | null;
|
||||
wasRecoveredFromCorruptHeader?: () => boolean;
|
||||
};
|
||||
|
||||
const header = sm.fileEntries.find((e): e is SessionHeaderEntry => e.type === "session");
|
||||
@@ -45,7 +70,20 @@ export async function prepareSessionManagerForRun(params: {
|
||||
}
|
||||
|
||||
if (params.hadSessionFile && header && !hasAssistant) {
|
||||
if (sm.wasRecoveredFromCorruptHeader?.()) {
|
||||
header.id = params.sessionId;
|
||||
header.cwd = params.cwd;
|
||||
sm.sessionId = params.sessionId;
|
||||
sm.cwd = params.cwd;
|
||||
await writeJsonlLines(params.sessionFile, sm.fileEntries.map(serializeJsonlLine), {
|
||||
mode: 0o600,
|
||||
});
|
||||
sm.flushed = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset file so the first assistant flush includes header+user+assistant in order.
|
||||
await assertExistingHeaderIsReadable(params.sessionFile);
|
||||
await fs.writeFile(params.sessionFile, "", "utf-8");
|
||||
header.id = params.sessionId;
|
||||
header.cwd = params.cwd;
|
||||
|
||||
108
src/agents/sessions/session-manager.test.ts
Normal file
108
src/agents/sessions/session-manager.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { SessionManager } from "./session-manager.js";
|
||||
|
||||
const tempPaths: string[] = [];
|
||||
|
||||
async function makeTempDir(): Promise<string> {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-session-manager-"));
|
||||
tempPaths.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
describe("SessionManager.open", () => {
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
tempPaths.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
it("recovers a corrupted first-line header without truncating later messages", async () => {
|
||||
const dir = await makeTempDir();
|
||||
const sessionFile = path.join(dir, "session.jsonl");
|
||||
const originalHeader = {
|
||||
type: "session",
|
||||
version: 3,
|
||||
id: "original-session",
|
||||
timestamp: "2026-05-27T00:00:00.000Z",
|
||||
cwd: "/srv/openclaw/main",
|
||||
};
|
||||
const userEntry = {
|
||||
type: "message",
|
||||
id: "user-1",
|
||||
parentId: null,
|
||||
timestamp: "2026-05-27T00:00:01.000Z",
|
||||
message: { role: "user", content: "important question" },
|
||||
};
|
||||
const assistantEntry = {
|
||||
type: "message",
|
||||
id: "assistant-1",
|
||||
parentId: "user-1",
|
||||
timestamp: "2026-05-27T00:00:02.000Z",
|
||||
message: { role: "assistant", content: "important answer" },
|
||||
};
|
||||
const originalTranscript =
|
||||
[
|
||||
JSON.stringify(originalHeader).slice(0, 30),
|
||||
JSON.stringify(userEntry),
|
||||
JSON.stringify(assistantEntry),
|
||||
].join("\n") + "\n";
|
||||
await fs.writeFile(sessionFile, originalTranscript, "utf8");
|
||||
if (process.platform !== "win32") {
|
||||
await fs.chmod(sessionFile, 0o600);
|
||||
}
|
||||
|
||||
const sessionManager = SessionManager.open(sessionFile, dir, "/tmp/task-repo");
|
||||
|
||||
expect(sessionManager.getEntries()).toEqual([userEntry, assistantEntry]);
|
||||
expect(await fs.readFile(sessionFile, "utf8")).toContain("important question");
|
||||
expect(await fs.readFile(sessionFile, "utf8")).toContain("important answer");
|
||||
await expect(fs.readFile(sessionFile, "utf8")).resolves.not.toBe(originalTranscript);
|
||||
|
||||
const backupFiles = (await fs.readdir(dir)).filter((file) => file.includes(".corrupt-"));
|
||||
expect(backupFiles).toHaveLength(1);
|
||||
await expect(fs.readFile(path.join(dir, backupFiles[0] ?? ""), "utf8")).resolves.toBe(
|
||||
originalTranscript,
|
||||
);
|
||||
if (process.platform !== "win32") {
|
||||
const backupStat = await fs.stat(path.join(dir, backupFiles[0] ?? ""));
|
||||
expect(backupStat.mode & 0o777).toBe(0o600);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not duplicate the header after recovering a header-only corrupt file", async () => {
|
||||
const dir = await makeTempDir();
|
||||
const sessionFile = path.join(dir, "session.jsonl");
|
||||
await fs.writeFile(sessionFile, '{"type":"session","version":3,"id":"sess', "utf8");
|
||||
|
||||
const sessionManager = SessionManager.open(sessionFile, dir, "/tmp/task-repo");
|
||||
sessionManager.appendMessage({ role: "user", content: "hello", timestamp: Date.now() });
|
||||
sessionManager.appendMessage({
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "hi" }],
|
||||
api: "messages",
|
||||
provider: "anthropic",
|
||||
model: "sonnet-4.6",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
const entries = (await fs.readFile(sessionFile, "utf8"))
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => JSON.parse(line) as { type: string });
|
||||
|
||||
expect(entries.map((entry) => entry.type)).toEqual(["session", "message", "message"]);
|
||||
expect(entries.filter((entry) => entry.type === "session")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
closeSync,
|
||||
chmodSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
openSync,
|
||||
@@ -8,11 +9,11 @@ import {
|
||||
readFileSync,
|
||||
readSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { readdir, readFile, stat } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
import {
|
||||
appendJsonlEntriesSync,
|
||||
appendJsonlEntrySync,
|
||||
writeJsonlEntriesSync,
|
||||
} from "../../config/sessions/transcript-jsonl.js";
|
||||
@@ -387,6 +388,21 @@ export function loadEntriesFromFile(filePath: string): FileEntry[] {
|
||||
}
|
||||
|
||||
const content = readFileSync(filePath, "utf8");
|
||||
const entries = parseJsonlEntries(content);
|
||||
|
||||
// Validate session header
|
||||
if (entries.length === 0) {
|
||||
return entries;
|
||||
}
|
||||
const header = entries[0];
|
||||
if (header.type !== "session" || typeof (header as { id?: unknown }).id !== "string") {
|
||||
return [];
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
function parseJsonlEntries(content: string): FileEntry[] {
|
||||
const entries: FileEntry[] = [];
|
||||
const lines = content.trim().split("\n");
|
||||
|
||||
@@ -402,16 +418,46 @@ export function loadEntriesFromFile(filePath: string): FileEntry[] {
|
||||
}
|
||||
}
|
||||
|
||||
// Validate session header
|
||||
if (entries.length === 0) {
|
||||
return entries;
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function hasReadableSessionHeader(entries: FileEntry[]): boolean {
|
||||
const header = entries[0];
|
||||
if (header.type !== "session" || typeof (header as { id?: unknown }).id !== "string") {
|
||||
return [];
|
||||
return header?.type === "session" && typeof (header as { id?: unknown }).id === "string";
|
||||
}
|
||||
|
||||
function buildCorruptSessionBackupPath(filePath: string): string {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
return `${filePath}.corrupt-${timestamp}-${randomUUID().slice(0, 8)}.jsonl`;
|
||||
}
|
||||
|
||||
function recoverCorruptSessionEntries(filePath: string, cwd: string): FileEntry[] | null {
|
||||
const content = readFileSync(filePath, "utf8");
|
||||
if (content.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return entries;
|
||||
const parsedEntries = parseJsonlEntries(content);
|
||||
const recoveredHeader = parsedEntries.find(
|
||||
(entry): entry is SessionHeader =>
|
||||
entry.type === "session" && typeof (entry as { id?: unknown }).id === "string",
|
||||
);
|
||||
const header: SessionHeader =
|
||||
recoveredHeader ??
|
||||
({
|
||||
type: "session",
|
||||
version: CURRENT_SESSION_VERSION,
|
||||
id: createSessionId(),
|
||||
timestamp: new Date().toISOString(),
|
||||
cwd,
|
||||
} satisfies SessionHeader);
|
||||
const recoveredEntries = parsedEntries.filter((entry) => entry.type !== "session");
|
||||
|
||||
const backupPath = buildCorruptSessionBackupPath(filePath);
|
||||
const backupMode = statSync(filePath).mode & 0o777;
|
||||
writeFileSync(backupPath, content, { encoding: "utf8", mode: backupMode || 0o600 });
|
||||
chmodSync(backupPath, backupMode || 0o600);
|
||||
return [header, ...recoveredEntries];
|
||||
}
|
||||
|
||||
function isValidSessionFile(filePath: string): boolean {
|
||||
@@ -695,6 +741,7 @@ export class SessionManager {
|
||||
private labelsById: Map<string, string> = new Map();
|
||||
private labelTimestampsById: Map<string, string> = new Map();
|
||||
private leafId: string | null = null;
|
||||
private recoveredCorruptHeader = false;
|
||||
|
||||
private constructor(
|
||||
cwd: string,
|
||||
@@ -719,12 +766,25 @@ export class SessionManager {
|
||||
/** Switch to a different session file (used for resume and branching) */
|
||||
setSessionFile(sessionFile: string): void {
|
||||
this.sessionFile = resolve(sessionFile);
|
||||
this.recoveredCorruptHeader = false;
|
||||
if (existsSync(this.sessionFile)) {
|
||||
this.fileEntries = loadEntriesFromFile(this.sessionFile);
|
||||
|
||||
// If file was empty or corrupted (no valid header), truncate and start fresh
|
||||
// to avoid appending messages without a session header (which breaks the session)
|
||||
if (this.fileEntries.length === 0) {
|
||||
const recoveredEntries = recoverCorruptSessionEntries(this.sessionFile, this.cwd);
|
||||
if (recoveredEntries && hasReadableSessionHeader(recoveredEntries)) {
|
||||
this.fileEntries = recoveredEntries;
|
||||
const header = this.fileEntries.find((e) => e.type === "session");
|
||||
this.sessionId = header?.id ?? createSessionId();
|
||||
this.buildIndex();
|
||||
this.rewriteFile();
|
||||
this.recoveredCorruptHeader = true;
|
||||
this.flushed = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const explicitPath = this.sessionFile;
|
||||
this.newSession();
|
||||
this.sessionFile = explicitPath;
|
||||
@@ -750,6 +810,7 @@ export class SessionManager {
|
||||
}
|
||||
|
||||
newSession(options?: NewSessionOptions): string | undefined {
|
||||
this.recoveredCorruptHeader = false;
|
||||
this.sessionId = options?.id ?? createSessionId();
|
||||
const timestamp = new Date().toISOString();
|
||||
const header: SessionHeader = {
|
||||
@@ -819,6 +880,10 @@ export class SessionManager {
|
||||
return this.sessionId;
|
||||
}
|
||||
|
||||
wasRecoveredFromCorruptHeader(): boolean {
|
||||
return this.recoveredCorruptHeader;
|
||||
}
|
||||
|
||||
getSessionFile(): string | undefined {
|
||||
return this.sessionFile;
|
||||
}
|
||||
@@ -838,7 +903,7 @@ export class SessionManager {
|
||||
}
|
||||
|
||||
if (!this.flushed) {
|
||||
appendJsonlEntriesSync(this.sessionFile, this.fileEntries);
|
||||
writeJsonlEntriesSync(this.sessionFile, this.fileEntries);
|
||||
this.flushed = true;
|
||||
} else {
|
||||
appendJsonlEntrySync(this.sessionFile, entry);
|
||||
|
||||
Reference in New Issue
Block a user