Merge PR #8598: fix(session): repair torn JSONL appends (@roboomp)
# Conflicts: # packages/coding-agent/src/session/session-loader.ts # packages/coding-agent/src/session/session-manager.ts # packages/coding-agent/test/session-loader-stream.test.ts
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { Agent } from "@oh-my-pi/pi-agent-core";
|
||||
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
|
||||
@@ -127,4 +128,24 @@ describe("InteractiveMode LSP startup welcome banner", () => {
|
||||
} satisfies LspStartupEvent);
|
||||
expect(showWarningSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces a sanitized warning when session persistence fails", async () => {
|
||||
await mode.init();
|
||||
await session.sessionManager.ensureOnDisk();
|
||||
const showWarning = vi.spyOn(mode, "showWarning").mockImplementation(() => {});
|
||||
const writeFailure = vi.spyOn(fs, "writeSync").mockImplementation(() => {
|
||||
throw Object.assign(new Error("ENOSPC:\tdisk full\n\u001b[31mretry later\u001b[0m"), { code: "ENOSPC" });
|
||||
});
|
||||
session.sessionManager.appendCustomEntry("persistence-failure-probe", {});
|
||||
|
||||
expect(showWarning).toHaveBeenCalledTimes(1);
|
||||
const warning = showWarning.mock.calls[0]?.[0] ?? "";
|
||||
expect(warning).toContain("Session persistence failed: ENOSPC:");
|
||||
expect(warning).toContain("Unsaved entries remain in memory");
|
||||
expect(warning).not.toContain("\t");
|
||||
expect(warning).not.toContain("\n");
|
||||
expect(warning).not.toContain("\u001b");
|
||||
writeFailure.mockRestore();
|
||||
session.sessionManager.appendCustomEntry("persistence-recovery-probe", {});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -59,18 +59,14 @@ class ExitFaultStorage extends FileSessionStorage {
|
||||
return { started: started.promise, release: release.resolve };
|
||||
}
|
||||
|
||||
override async readTextSlices(
|
||||
filePath: string,
|
||||
prefixBytes: number,
|
||||
suffixBytes: number,
|
||||
): Promise<[string, string]> {
|
||||
override async readText(filePath: string): Promise<string> {
|
||||
const gate = this.#readGate;
|
||||
if (gate?.filePath === filePath) {
|
||||
this.#readGate = undefined;
|
||||
gate.started.resolve();
|
||||
await gate.release.promise;
|
||||
}
|
||||
return super.readTextSlices(filePath, prefixBytes, suffixBytes);
|
||||
return super.readText(filePath);
|
||||
}
|
||||
|
||||
override async writeTextAtomic(filePath: string, content: string, options?: WriteTextAtomicOptions): Promise<void> {
|
||||
|
||||
@@ -94,6 +94,7 @@ describe("loadEntriesFromFileStream (Bun.JSONL parity)", () => {
|
||||
expect(titleSlot?.title).toBe("Visitor");
|
||||
expect(entryIds(visited)).toEqual(["s1", "m1", "m2"]);
|
||||
expect(visited[0]).toMatchObject({ title: "Visitor", titleSource: "user" });
|
||||
expect((await sessionLoader.loadEntriesFromFileStream(file)).malformedRecords).toBe(1);
|
||||
});
|
||||
|
||||
it("visits a large journal before reading its tail", async () => {
|
||||
@@ -199,6 +200,7 @@ describe("loadEntriesFromFileStream (Bun.JSONL parity)", () => {
|
||||
expect(entryTypes(stream.entries)).toEqual(["session", "message", "message"]);
|
||||
const ids = messageIds(stream.entries);
|
||||
expect(ids).toEqual(["m1", "m2"]); // valid entries kept in order, malformed skipped
|
||||
expect(stream.malformedRecords).toBe(1);
|
||||
});
|
||||
|
||||
it("matches parseSessionContent when there is no title slot (header is the first line)", async () => {
|
||||
@@ -249,5 +251,6 @@ describe("loadEntriesFromFileStream (Bun.JSONL parity)", () => {
|
||||
const stream = await sessionLoader.loadEntriesFromFileStream(missing);
|
||||
expect(stream.entries).toEqual([]);
|
||||
expect(stream.titleSlot).toBeUndefined();
|
||||
expect(stream.malformedRecords).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -198,6 +198,24 @@ describe("SessionManager JSONL software-crash durability", () => {
|
||||
expect(toolResult).toBeDefined();
|
||||
});
|
||||
|
||||
it("rewrites a malformed resumed tail before appending another entry", async () => {
|
||||
const cwd = makeTempDir("@pi-malformed-tail-cwd-");
|
||||
const manager = SessionManager.create(cwd, path.join(cwd, "sessions"));
|
||||
manager.appendMessage(assistantMessage("seed"));
|
||||
const sessionFile = manager.getSessionFile();
|
||||
if (!sessionFile) throw new Error("Expected session file");
|
||||
await manager.close();
|
||||
|
||||
fs.appendFileSync(sessionFile, '{"type":"message","id":"torn","message":{"role":"user","content":"lost');
|
||||
const resumed = await SessionManager.open(sessionFile);
|
||||
resumed.appendMessage({ role: "user", content: "after resume", timestamp: Date.now() });
|
||||
|
||||
const entries = readJsonl(sessionFile);
|
||||
expect(entries.map(entryKind)).toEqual(["session", "assistant", "user"]);
|
||||
expect(messageContent(entries[2] ?? {})).toBe("after resume");
|
||||
await resumed.close();
|
||||
});
|
||||
|
||||
it("keeps pre-assistant sessions out of history during shutdown", async () => {
|
||||
const cwd = makeTempDir("@pi-empty-session-cwd-");
|
||||
const sessionDir = path.join(cwd, "sessions");
|
||||
@@ -307,10 +325,7 @@ describe("SessionManager JSONL software-crash durability", () => {
|
||||
expect(afterKinds).toEqual(crashKinds);
|
||||
});
|
||||
|
||||
it("latches the first hot-path write failure before append returns", () => {
|
||||
// H2: async append + discarded Promise meant ENOSPC/EIO only latched after a
|
||||
// later flush/append. appendSync must latch `#diskFailure` on the first call
|
||||
// without throwing through unguarded turn-loop callers.
|
||||
it("alerts once and retries all in-memory entries after a transient write failure", () => {
|
||||
const cwd = makeTempDir("@pi-write-fail-cwd-");
|
||||
const sessionDir = path.join(cwd, "sessions");
|
||||
const manager = SessionManager.create(cwd, sessionDir);
|
||||
@@ -320,39 +335,33 @@ describe("SessionManager JSONL software-crash durability", () => {
|
||||
manager.appendMessage(assistantMessage("seed"));
|
||||
manager.appendMessage({ role: "user", content: "ok-user", timestamp: Date.now() });
|
||||
|
||||
let failWrites = true;
|
||||
const origWrite = fs.writeSync.bind(fs) as typeof fs.writeSync;
|
||||
const writeSpy = spyOn(fs, "writeSync").mockImplementation(((...args: Parameters<typeof fs.writeSync>) => {
|
||||
if (failWrites) {
|
||||
const err = new Error("ENOSPC: no space left on device") as NodeJS.ErrnoException;
|
||||
err.code = "ENOSPC";
|
||||
throw err;
|
||||
}
|
||||
return origWrite(...args);
|
||||
}) as typeof fs.writeSync);
|
||||
const writeSpy = spyOn(fs, "writeSync").mockImplementation(() => {
|
||||
throw Object.assign(new Error("ENOSPC: no space left on device"), { code: "ENOSPC" });
|
||||
});
|
||||
const failures: Error[] = [];
|
||||
manager.onPersistenceError(error => {
|
||||
failures.push(error);
|
||||
});
|
||||
|
||||
try {
|
||||
let threw = false;
|
||||
try {
|
||||
manager.appendMessage({ role: "user", content: "should-fail-user", timestamp: Date.now() });
|
||||
} catch {
|
||||
threw = true;
|
||||
}
|
||||
// Unguarded turn-loop contract: appendMessage itself must not throw.
|
||||
expect(threw).toBe(false);
|
||||
|
||||
// Failure is latched before return — flushSync / next append surface it.
|
||||
expect(() =>
|
||||
manager.appendMessage({ role: "user", content: "failed-user", timestamp: Date.now() }),
|
||||
).not.toThrow();
|
||||
expect(() => manager.flushSync()).toThrow("ENOSPC");
|
||||
expect(() => manager.appendMessage({ role: "user", content: "next-user", timestamp: Date.now() })).toThrow(
|
||||
"ENOSPC",
|
||||
);
|
||||
expect(failures).toHaveLength(1);
|
||||
|
||||
const users = parseJsonlLenient<Record<string, unknown>>(fs.readFileSync(sessionFile, "utf8"))
|
||||
writeSpy.mockRestore();
|
||||
expect(() =>
|
||||
manager.appendMessage({ role: "user", content: "recovered-user", timestamp: Date.now() }),
|
||||
).not.toThrow();
|
||||
expect(() => manager.flushSync()).not.toThrow();
|
||||
|
||||
const users = readJsonl(sessionFile)
|
||||
.filter(entry => entry.type === "message" && messageRole(entry) === "user")
|
||||
.map(entry => messageContent(entry));
|
||||
expect(users).toEqual(["ok-user"]);
|
||||
expect(users).toEqual(["ok-user", "failed-user", "recovered-user"]);
|
||||
expect(failures).toHaveLength(1);
|
||||
} finally {
|
||||
failWrites = false;
|
||||
writeSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -150,6 +150,24 @@ describe("FileSessionStorage writer", () => {
|
||||
await expect(writer.append("two\n")).rejects.toThrow("disk full");
|
||||
await expect(writer.close()).rejects.toThrow("disk full");
|
||||
});
|
||||
|
||||
it("rolls back bytes from a partial append before surfacing the error", () => {
|
||||
const sessionPath = path.join(tempDir, "partial-append.jsonl");
|
||||
fs.writeFileSync(sessionPath, "complete\n");
|
||||
const writer = storage.openWriter(sessionPath);
|
||||
vi.spyOn(fs, "writeSync")
|
||||
.mockImplementationOnce(() => {
|
||||
fs.appendFileSync(sessionPath, "par");
|
||||
return 3;
|
||||
})
|
||||
.mockImplementation(() => {
|
||||
throw Object.assign(new Error("ENOSPC: no space left on device"), { code: "ENOSPC" });
|
||||
});
|
||||
const appendSync = writer.appendSync?.bind(writer);
|
||||
if (!appendSync) throw new Error("File writer must expose appendSync");
|
||||
expect(() => appendSync("partial entry\n")).toThrow("ENOSPC");
|
||||
expect(fs.readFileSync(sessionPath, "utf8")).toBe("complete\n");
|
||||
});
|
||||
});
|
||||
|
||||
describe("FileSessionStorage.deleteSessionWithArtifacts", () => {
|
||||
|
||||
Reference in New Issue
Block a user