fix(coding-agent): stream large session files

closes #5231
This commit is contained in:
Mario Zechner
2026-05-30 21:21:42 +02:00
parent dbb9911a54
commit 3911d6f5cd
3 changed files with 130 additions and 74 deletions

View File

@@ -2,6 +2,10 @@
## [Unreleased] ## [Unreleased]
### Fixed
- Fixed opening and listing very large JSONL session files by reading session entries line-by-line instead of materializing the full file as one string ([#5231](https://github.com/earendil-works/pi/issues/5231)).
## [0.78.0] - 2026-05-29 ## [0.78.0] - 2026-05-29
### New Features ### New Features

View File

@@ -4,17 +4,19 @@ import { randomUUID } from "crypto";
import { import {
appendFileSync, appendFileSync,
closeSync, closeSync,
createReadStream,
existsSync, existsSync,
mkdirSync, mkdirSync,
openSync, openSync,
readdirSync, readdirSync,
readFileSync,
readSync, readSync,
statSync, statSync,
writeFileSync, writeFileSync,
} from "fs"; } from "fs";
import { readdir, readFile, stat } from "fs/promises"; import { readdir, stat } from "fs/promises";
import { join, resolve } from "path"; import { join, resolve } from "path";
import { createInterface } from "readline";
import { StringDecoder } from "string_decoder";
import { getAgentDir as getDefaultAgentDir, getSessionsDir } from "../config.ts"; import { getAgentDir as getDefaultAgentDir, getSessionsDir } from "../config.ts";
import { normalizePath, resolvePath } from "../utils/paths.ts"; import { normalizePath, resolvePath } from "../utils/paths.ts";
import { import {
@@ -448,29 +450,57 @@ export function getDefaultSessionDir(cwd: string, agentDir: string = getDefaultA
return sessionDir; return sessionDir;
} }
const SESSION_READ_BUFFER_SIZE = 1024 * 1024;
function parseSessionEntryLine(line: string): FileEntry | null {
if (!line.trim()) return null;
try {
return JSON.parse(line) as FileEntry;
} catch {
// Skip malformed lines
return null;
}
}
/** Exported for testing */ /** Exported for testing */
export function loadEntriesFromFile(filePath: string): FileEntry[] { export function loadEntriesFromFile(filePath: string): FileEntry[] {
const resolvedFilePath = normalizePath(filePath); const resolvedFilePath = normalizePath(filePath);
if (!existsSync(resolvedFilePath)) return []; if (!existsSync(resolvedFilePath)) return [];
const content = readFileSync(resolvedFilePath, "utf8");
const entries: FileEntry[] = []; const entries: FileEntry[] = [];
const lines = content.trim().split("\n"); const fd = openSync(resolvedFilePath, "r");
try {
const decoder = new StringDecoder("utf8");
const buffer = Buffer.allocUnsafe(SESSION_READ_BUFFER_SIZE);
let pending = "";
for (const line of lines) { while (true) {
if (!line.trim()) continue; const bytesRead = readSync(fd, buffer, 0, buffer.length, null);
try { if (bytesRead === 0) break;
const entry = JSON.parse(line) as FileEntry;
entries.push(entry); pending += decoder.write(buffer.subarray(0, bytesRead));
} catch { let lineStart = 0;
// Skip malformed lines let newlineIndex = pending.indexOf("\n", lineStart);
while (newlineIndex !== -1) {
const entry = parseSessionEntryLine(pending.slice(lineStart, newlineIndex));
if (entry) entries.push(entry);
lineStart = newlineIndex + 1;
newlineIndex = pending.indexOf("\n", lineStart);
}
pending = pending.slice(lineStart);
} }
pending += decoder.end();
const finalEntry = parseSessionEntryLine(pending);
if (finalEntry) entries.push(finalEntry);
} finally {
closeSync(fd);
} }
// Validate session header // Validate session header
if (entries.length === 0) return entries; if (entries.length === 0) return entries;
const header = entries[0]; const header = entries[0];
if (header.type !== "session" || typeof (header as any).id !== "string") { if (header.type !== "session" || typeof (header as { id?: unknown }).id !== "string") {
return []; return [];
} }
@@ -542,80 +572,59 @@ function extractTextContent(message: Message): string {
.join(" "); .join(" ");
} }
function getLastActivityTime(entries: FileEntry[]): number | undefined { function getMessageActivityTime(entry: SessionMessageEntry): number | undefined {
let lastActivityTime: number | undefined; const message = entry.message;
if (!isMessageWithContent(message)) return undefined;
if (message.role !== "user" && message.role !== "assistant") return undefined;
for (const entry of entries) { const msgTimestamp = (message as { timestamp?: number }).timestamp;
if (entry.type !== "message") continue; if (typeof msgTimestamp === "number") {
return msgTimestamp;
const message = (entry as SessionMessageEntry).message;
if (!isMessageWithContent(message)) continue;
if (message.role !== "user" && message.role !== "assistant") continue;
const msgTimestamp = (message as { timestamp?: number }).timestamp;
if (typeof msgTimestamp === "number") {
lastActivityTime = Math.max(lastActivityTime ?? 0, msgTimestamp);
continue;
}
const entryTimestamp = (entry as SessionEntryBase).timestamp;
if (typeof entryTimestamp === "string") {
const t = new Date(entryTimestamp).getTime();
if (!Number.isNaN(t)) {
lastActivityTime = Math.max(lastActivityTime ?? 0, t);
}
}
} }
return lastActivityTime; const t = new Date(entry.timestamp).getTime();
} return Number.isNaN(t) ? undefined : t;
function getSessionModifiedDate(entries: FileEntry[], header: SessionHeader, statsMtime: Date): Date {
const lastActivityTime = getLastActivityTime(entries);
if (typeof lastActivityTime === "number" && lastActivityTime > 0) {
return new Date(lastActivityTime);
}
const headerTime = typeof header.timestamp === "string" ? new Date(header.timestamp).getTime() : NaN;
return !Number.isNaN(headerTime) ? new Date(headerTime) : statsMtime;
} }
async function buildSessionInfo(filePath: string): Promise<SessionInfo | null> { async function buildSessionInfo(filePath: string): Promise<SessionInfo | null> {
try { try {
const content = await readFile(filePath, "utf8");
const entries: FileEntry[] = [];
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
}
}
if (entries.length === 0) return null;
const header = entries[0];
if (header.type !== "session") return null;
const stats = await stat(filePath); const stats = await stat(filePath);
let header: SessionHeader | null = null;
let messageCount = 0; let messageCount = 0;
let firstMessage = ""; let firstMessage = "";
const allMessages: string[] = []; const allMessages: string[] = [];
let name: string | undefined; let name: string | undefined;
let lastActivityTime: number | undefined;
const rl = createInterface({
input: createReadStream(filePath, { encoding: "utf8" }),
crlfDelay: Infinity,
});
for await (const line of rl) {
const entry = parseSessionEntryLine(line);
if (!entry) continue;
if (!header) {
if (entry.type !== "session") return null;
header = entry;
continue;
}
for (const entry of entries) {
// Extract session name (use latest, including explicit clears) // Extract session name (use latest, including explicit clears)
if (entry.type === "session_info") { if (entry.type === "session_info") {
const infoEntry = entry as SessionInfoEntry; name = entry.name?.trim() || undefined;
name = infoEntry.name?.trim() || undefined;
} }
if (entry.type !== "message") continue; if (entry.type !== "message") continue;
messageCount++; messageCount++;
const message = (entry as SessionMessageEntry).message; const activityTime = getMessageActivityTime(entry);
if (typeof activityTime === "number") {
lastActivityTime = Math.max(lastActivityTime ?? 0, activityTime);
}
const message = entry.message;
if (!isMessageWithContent(message)) continue; if (!isMessageWithContent(message)) continue;
if (message.role !== "user" && message.role !== "assistant") continue; if (message.role !== "user" && message.role !== "assistant") continue;
@@ -628,18 +637,25 @@ async function buildSessionInfo(filePath: string): Promise<SessionInfo | null> {
} }
} }
const cwd = typeof (header as SessionHeader).cwd === "string" ? (header as SessionHeader).cwd : ""; if (!header) return null;
const parentSessionPath = (header as SessionHeader).parentSession;
const modified = getSessionModifiedDate(entries, header as SessionHeader, stats.mtime); const cwd = typeof header.cwd === "string" ? header.cwd : "";
const parentSessionPath = header.parentSession;
const headerTime = typeof header.timestamp === "string" ? new Date(header.timestamp).getTime() : NaN;
const modified =
typeof lastActivityTime === "number" && lastActivityTime > 0
? new Date(lastActivityTime)
: !Number.isNaN(headerTime)
? new Date(headerTime)
: stats.mtime;
return { return {
path: filePath, path: filePath,
id: (header as SessionHeader).id, id: header.id,
cwd, cwd,
name, name,
parentSessionPath, parentSessionPath,
created: new Date((header as SessionHeader).timestamp), created: new Date(header.timestamp),
modified, modified,
messageCount, messageCount,
firstMessage: firstMessage || "(no messages)", firstMessage: firstMessage || "(no messages)",
@@ -855,8 +871,14 @@ export class SessionManager {
private _rewriteFile(): void { private _rewriteFile(): void {
if (!this.persist || !this.sessionFile) return; if (!this.persist || !this.sessionFile) return;
const content = `${this.fileEntries.map((e) => JSON.stringify(e)).join("\n")}\n`; const fd = openSync(this.sessionFile, "w");
writeFileSync(this.sessionFile, content); try {
for (const entry of this.fileEntries) {
writeFileSync(fd, `${JSON.stringify(entry)}\n`);
}
} finally {
closeSync(fd);
}
} }
isPersisted(): boolean { isPersisted(): boolean {

View File

@@ -1,4 +1,5 @@
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "fs"; import { constants as bufferConstants } from "buffer";
import { appendFileSync, closeSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync, writeSync } from "fs";
import { tmpdir } from "os"; import { tmpdir } from "os";
import { join } from "path"; import { join } from "path";
import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it } from "vitest";
@@ -63,6 +64,35 @@ describe("loadEntriesFromFile", () => {
const entries = loadEntriesFromFile(file); const entries = loadEntriesFromFile(file);
expect(entries).toHaveLength(2); expect(entries).toHaveLength(2);
}); });
it("opens session files larger than Node's max string length", () => {
const file = join(tempDir, "large.jsonl");
writeFileSync(
file,
'{"type":"session","version":3,"id":"abc","timestamp":"2025-01-01T00:00:00Z","cwd":"/tmp"}\n',
);
const fd = openSync(file, "r+");
try {
const newline = Buffer.from("\n");
const stride = 16 * 1024 * 1024;
for (let offset = stride; offset <= bufferConstants.MAX_STRING_LENGTH + stride; offset += stride) {
writeSync(fd, newline, 0, newline.length, offset);
}
} finally {
closeSync(fd);
}
appendFileSync(
file,
'{"type":"message","id":"1","parentId":null,"timestamp":"2025-01-01T00:00:01Z","message":{"role":"user","content":"hi","timestamp":1}}\n',
);
const sessionManager = SessionManager.open(file, tempDir);
expect(sessionManager.getSessionId()).toBe("abc");
expect(sessionManager.getEntries()).toHaveLength(1);
expect(sessionManager.buildSessionContext().messages).toEqual([{ role: "user", content: "hi", timestamp: 1 }]);
});
}); });
describe("findMostRecentSession", () => { describe("findMostRecentSession", () => {