Skip to content

Bug: resuming a session with a corrupted header line silently wipes the entire transcript (data loss) #89037

@yetval

Description

@yetval

Summary

Resuming a session whose first line (the header) is corrupted/partially written causes the entire on-disk transcript to be silently destroyed. The file is truncated to a single fresh session header (with a brand-new id), and every persisted user/assistant message is permanently lost.

This is reachable from any crash, partial write, or disk-full condition that leaves the header line truncated — the header is the first line written to the file, so an interrupted initial flush corrupts exactly the line this code path is fragile to.

  • Impact: data loss (full conversation history wiped on resume)
  • Affected commands: openclaw --session <file>, --continue, and any resume path through SessionManager.open() / setSessionFile()
  • Version audited: main @ 0b5be66e

Root cause

loadEntriesFromFile() (src/agents/sessions/session-manager.ts:384) silently skips unparseable lines, then validates only the first successfully parsed entry:

// src/agents/sessions/session-manager.ts ~384-415
const lines = content.trim().split("\n");
for (const line of lines) {
  if (!line.trim()) continue;
  try {
    entries.push(JSON.parse(line) as FileEntry);
  } catch {
    // Skip malformed lines   <-- a corrupt header line is dropped here
  }
}
if (entries.length === 0) return entries;
const header = entries[0];                       // now the first *message*, not the header
if (header.type !== "session" || typeof header.id !== "string") {
  return [];                                     // => treated as "empty/corrupt"
}

If the header line is corrupt it gets skipped, entries[0] becomes the first message, the header check fails, and the function returns [].

setSessionFile() then treats [] as empty/corrupt and overwrites the file:

// src/agents/sessions/session-manager.ts ~723-735
this.fileEntries = loadEntriesFromFile(this.sessionFile);
// If file was empty or corrupted (no valid header), truncate and start fresh
if (this.fileEntries.length === 0) {
  const explicitPath = this.sessionFile;
  this.newSession();
  this.sessionFile = explicitPath;
  this.rewriteFile();        // <-- truncates the file to a single fresh header
  this.flushed = true;
  return;
}

The intent ("recover from a genuinely empty file") is reasonable, but it cannot distinguish "empty file" from "intact conversation whose header line happens to be corrupt", and it destroys the latter.

Reproduction

Standalone vitest against pristine main:

import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { SessionManager } from "./session-manager.js";

describe("corrupt header line on resume wipes history", () => {
  it("truncates the whole transcript to a fresh header", () => {
    const dir = mkdtempSync(join(tmpdir(), "oc-F-"));
    const file = join(dir, "2026-01-01T00-00-00-000Z_sess.jsonl");
    const header = { type: "session", version: 3, id: "sess-corrupt", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/tmp/cwd" };
    const u = { type: "message", id: "u0000001", parentId: null, timestamp: "2026-01-01T00:00:01.000Z", message: { role: "user", content: [{ type: "text", text: "important question" }] } };
    const a = { type: "message", id: "a0000001", parentId: "u0000001", timestamp: "2026-01-01T00:00:02.000Z", message: { role: "assistant", content: [{ type: "text", text: "valuable answer" }] } };

    // Crash-truncated FIRST line (header), conversation otherwise intact:
    writeFileSync(file, `${JSON.stringify(header).slice(0, 30)}\n${JSON.stringify(u)}\n${JSON.stringify(a)}\n`);

    SessionManager.open(file);

    const text = readFileSync(file, "utf8");
    expect(text).toContain("important question"); // FAILS
    expect(text).toContain("valuable answer");     // FAILS
  });
});

Observed (main @ 0b5be66e)

before: 3 lines  [corrupt-header, user-msg, assistant-msg]
after:  1 line   [{"type":"session","version":3,"id":"019e8296-..."}]   // new id, both messages gone

Expected

Resuming must never destroy persisted messages. A file with a recoverable body should not be silently overwritten.

Suggested direction

  • Don't truncate when the file is non-empty-on-disk but only fails header validation. Options:
    • Back up the original file (e.g. *.corrupt-<ts>.jsonl) before any rewrite, so history is recoverable.
    • Attempt header recovery: if a type:"session" entry exists anywhere in the parsed entries, use it instead of requiring it at index 0; or synthesize a header and prepend it, preserving the existing message lines.
    • Only take the "fresh start" path when the raw file is genuinely empty (0 bytes / no parseable lines), not merely "no valid header at line 0".

Happy to send a PR with a fix + the regression test above.

Metadata

Metadata

Assignees

Labels

P0Emergency: data loss, security bypass, crash loop, or unusable core runtime.clawsweeper:fix-shape-clearClawSweeper found a clear likely implementation shape for this issue.clawsweeper:queueable-fixClawSweeper marked this issue as an existing queue_fix_pr work candidate.clawsweeper:source-reproClawSweeper found a high-confidence source-level issue reproduction.impact:data-lossCan lose, corrupt, or silently drop user/session/config data.impact:session-stateSession, memory, transcript, context, or agent state can drift or corrupt.issue-rating: 🦞 diamond lobsterVery strong issue quality with high-confidence source-level or clear reproduction.

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions