pi-mono/packages/coding-agent/test/session-manager/file-operations.test.ts
robopipi[bot] 807a5c051f
fix(coding-agent): keep session memory in line with the file when a write fails (#11486)
When writing a session entry failed (ENOSPC, EBUSY, ETIMEDOUT on network
drives), `SessionManager._appendEntry` had already added the entry to
memory and made it the current leaf. The process kept running, and the
next entry written to disk pointed to a parent that was not in the file.
On resume, pi follows the history only back to that gap: everything
before it disappears. In #11460 that also dropped the first system
message, which declares the tools, so resume turned off every built-in
tool.

`_appendEntry` now undoes the in-memory append if writing fails, then
rethrows. The error reaches the caller as before; later entries chain to
the last entry actually on disk.

Reports of this problem:
- #11460: ENOSPC; resume dropped all built-in tools.
- #10166: failure injected with EBUSY; the next entry on disk points to
a missing parent.
- #10661: ETIMEDOUT on Windows + NFS; resumed sessions showed only the
last few messages. That report also describes NUL holes in the file,
which this change does not address.

**Testing**
- Two tests in `test/session-manager/file-operations.test.ts` make
writes fail with real file permissions: a read-only session file (failed
append) and a read-only session directory (failed first file creation).
They check that the entry is not kept, the leaf is unchanged, the next
entry chains to the last written one, and the reopened session's branch
contains both entries. Both fail without the change and pass with it.
- `test/session-manager/` and `test/default-tools-setting.test.ts` pass
(140 tests).
- `npm run check` passes.

Fixes #11460

Requested by @davidbrai

Co-authored-by: triagent <triagent@localhost>
2026-10-27 15:58:08 +01:00

513 lines
18 KiB
TypeScript

import { constants as bufferConstants } from "buffer";
import {
appendFileSync,
chmodSync,
closeSync,
existsSync,
mkdirSync,
mkdtempSync,
openSync,
readFileSync,
rmSync,
writeFileSync,
writeSync,
} from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { findMostRecentSession, loadEntriesFromFile, SessionManager } from "../../src/core/session-manager.ts";
import { assistantMsg, readSessionFileRoles, userMsg } from "../utilities.ts";
const HEADER_SCAN_LIMIT_BYTES = 1024 * 1024;
describe("loadEntriesFromFile", () => {
let tempDir: string;
beforeEach(() => {
tempDir = join(tmpdir(), `session-test-${Date.now()}`);
mkdirSync(tempDir, { recursive: true });
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
function writeSessionHeader(file: string, cwd: string, id: string, prefix = ""): void {
writeFileSync(
file,
`${prefix}${JSON.stringify({
type: "session",
version: 3,
id,
timestamp: "2025-01-01T00:00:00Z",
cwd,
})}\n`,
);
}
it("returns empty array for non-existent file", () => {
const entries = loadEntriesFromFile(join(tempDir, "nonexistent.jsonl"));
expect(entries).toEqual([]);
});
it("returns empty array for empty file", () => {
const file = join(tempDir, "empty.jsonl");
writeFileSync(file, "");
expect(loadEntriesFromFile(file)).toEqual([]);
});
it("returns empty array for file without valid session header", () => {
const file = join(tempDir, "no-header.jsonl");
writeFileSync(file, '{"type":"message","id":"1"}\n');
expect(loadEntriesFromFile(file)).toEqual([]);
});
it("returns empty array for malformed JSON", () => {
const file = join(tempDir, "malformed.jsonl");
writeFileSync(file, "not json\n");
expect(loadEntriesFromFile(file)).toEqual([]);
});
it("loads valid session file", () => {
const file = join(tempDir, "valid.jsonl");
writeFileSync(
file,
'{"type":"session","id":"abc","timestamp":"2025-01-01T00:00:00Z","cwd":"/tmp"}\n' +
'{"type":"message","id":"1","parentId":null,"timestamp":"2025-01-01T00:00:01Z","message":{"role":"user","content":"hi","timestamp":1}}\n',
);
const entries = loadEntriesFromFile(file);
expect(entries).toHaveLength(2);
expect(entries[0].type).toBe("session");
expect(entries[1].type).toBe("message");
});
it("skips malformed lines but keeps valid ones", () => {
const file = join(tempDir, "mixed.jsonl");
writeFileSync(
file,
'{"type":"session","id":"abc","timestamp":"2025-01-01T00:00:00Z","cwd":"/tmp"}\n' +
"not valid json\n" +
'{"type":"message","id":"1","parentId":null,"timestamp":"2025-01-01T00:00:01Z","message":{"role":"user","content":"hi","timestamp":1}}\n',
);
const entries = loadEntriesFromFile(file);
expect(entries).toHaveLength(2);
});
it("adds a newline after an unterminated valid record", () => {
const file = join(tempDir, "unterminated.jsonl");
const content =
'{"type":"session","id":"abc","timestamp":"2025-01-01T00:00:00Z","cwd":"/tmp"}\n' +
'{"type":"message","id":"1","parentId":null,"timestamp":"2025-01-01T00:00:01Z","message":{"role":"user","content":"hi","timestamp":1}}';
writeFileSync(file, content);
expect(loadEntriesFromFile(file)).toHaveLength(2);
expect(readFileSync(file, "utf8")).toBe(`${content}\n`);
});
it("adds a newline after an unterminated malformed final fragment", () => {
const file = join(tempDir, "malformed-tail.jsonl");
const content =
'{"type":"session","id":"abc","timestamp":"2025-01-01T00:00:00Z","cwd":"/tmp"}\n' + '{"type":"message"';
writeFileSync(file, content);
expect(loadEntriesFromFile(file)).toHaveLength(1);
expect(readFileSync(file, "utf8")).toBe(`${content}\n`);
});
it("does not modify an unterminated non-session file", () => {
const file = join(tempDir, "invalid.jsonl");
const content = '{"type":"message","id":"1"}';
writeFileSync(file, content);
expect(loadEntriesFromFile(file)).toEqual([]);
expect(readFileSync(file, "utf8")).toBe(content);
});
it.each([
["leading blank lines", "\n \n", "leading-blank"],
["leading malformed lines", "not json\n{broken json\n", "leading-malformed"],
["a multi-buffer header", "", "a".repeat(8192)],
])("reads cwd from a session with %s", (_description, prefix, sessionId) => {
const file = join(tempDir, "header.jsonl");
const storedCwd = join(tempDir, "stored-project");
writeSessionHeader(file, storedCwd, sessionId, prefix);
const sessionManager = SessionManager.open(file, tempDir);
expect(sessionManager.getSessionId()).toBe(sessionId);
expect(sessionManager.getCwd()).toBe(storedCwd);
});
it("opens compatible sessions beyond the discovery scan limit", () => {
const storedCwd = join(tempDir, "stored-project");
const overrideCwd = join(tempDir, "override-project");
const cases = [
{ name: "large-header", id: "a".repeat(HEADER_SCAN_LIMIT_BYTES + 1), prefix: "" },
{
name: "large-prefix",
id: "large-prefix",
prefix: `${"x".repeat(HEADER_SCAN_LIMIT_BYTES + 1)}\n`,
},
];
for (const { name, id, prefix } of cases) {
const file = join(tempDir, `${name}.jsonl`);
writeSessionHeader(file, storedCwd, id, prefix);
for (const cwdOverride of [undefined, overrideCwd]) {
const sessionManager = SessionManager.open(file, tempDir, cwdOverride);
expect(sessionManager.getSessionId()).toBe(id);
expect(sessionManager.getCwd()).toBe(cwdOverride ?? storedCwd);
}
}
});
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", () => {
let tempDir: string;
beforeEach(() => {
tempDir = join(tmpdir(), `session-test-${Date.now()}`);
mkdirSync(tempDir, { recursive: true });
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
it("returns null for empty directory", () => {
expect(findMostRecentSession(tempDir)).toBeNull();
});
it("returns null for non-existent directory", () => {
expect(findMostRecentSession(join(tempDir, "nonexistent"))).toBeNull();
});
it("ignores non-jsonl files", () => {
writeFileSync(join(tempDir, "file.txt"), "hello");
writeFileSync(join(tempDir, "file.json"), "{}");
expect(findMostRecentSession(tempDir)).toBeNull();
});
it("ignores jsonl files without valid session header", () => {
writeFileSync(join(tempDir, "invalid.jsonl"), '{"type":"message"}\n');
expect(findMostRecentSession(tempDir)).toBeNull();
});
it("returns single valid session file", () => {
const file = join(tempDir, "session.jsonl");
writeFileSync(file, '{"type":"session","id":"abc","timestamp":"2025-01-01T00:00:00Z","cwd":"/tmp"}\n');
expect(findMostRecentSession(tempDir)).toBe(file);
});
it("returns most recently modified session", async () => {
const file1 = join(tempDir, "older.jsonl");
const file2 = join(tempDir, "newer.jsonl");
writeFileSync(file1, '{"type":"session","id":"old","timestamp":"2025-01-01T00:00:00Z","cwd":"/tmp"}\n');
// Small delay to ensure different mtime
await new Promise((r) => setTimeout(r, 10));
writeFileSync(file2, '{"type":"session","id":"new","timestamp":"2025-01-01T00:00:00Z","cwd":"/tmp"}\n');
expect(findMostRecentSession(tempDir)).toBe(file2);
});
it("skips invalid files and returns valid one", async () => {
const invalid = join(tempDir, "invalid.jsonl");
const valid = join(tempDir, "valid.jsonl");
writeFileSync(invalid, '{"type":"not-session"}\n');
await new Promise((r) => setTimeout(r, 10));
writeFileSync(valid, '{"type":"session","id":"abc","timestamp":"2025-01-01T00:00:00Z","cwd":"/tmp"}\n');
expect(findMostRecentSession(tempDir)).toBe(valid);
});
it("skips oversized corrupt files and returns a valid session", () => {
const invalid = join(tempDir, "oversized.jsonl");
const valid = join(tempDir, "valid.jsonl");
writeFileSync(invalid, "x".repeat(HEADER_SCAN_LIMIT_BYTES + 1));
writeFileSync(valid, '{"type":"session","id":"abc","timestamp":"2025-01-01T00:00:00Z","cwd":"/tmp"}\n');
expect(findMostRecentSession(tempDir)).toBe(valid);
});
it("filters most recent session by cwd", async () => {
const projectA = join(tempDir, "project-a");
const projectB = join(tempDir, "project-b");
const fileA = join(tempDir, "a.jsonl");
const fileB = join(tempDir, "b.jsonl");
writeFileSync(
fileA,
`${JSON.stringify({ type: "session", id: "a", timestamp: "2025-01-01T00:00:00Z", cwd: projectA })}\n`,
);
await new Promise((r) => setTimeout(r, 10));
writeFileSync(
fileB,
`${JSON.stringify({ type: "session", id: "b", timestamp: "2025-01-01T00:00:00Z", cwd: projectB })}\n`,
);
expect(findMostRecentSession(tempDir, projectA)).toBe(fileA);
expect(findMostRecentSession(tempDir, projectB)).toBe(fileB);
});
});
describe("SessionManager custom flat session directory", () => {
let tempDir: string;
let projectA: string;
let projectB: string;
beforeEach(() => {
tempDir = join(tmpdir(), `session-test-${Date.now()}`);
projectA = join(tempDir, "project-a");
projectB = join(tempDir, "project-b");
mkdirSync(projectA, { recursive: true });
mkdirSync(projectB, { recursive: true });
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
function createPersistedSession(cwd: string, label: string): string {
const session = SessionManager.create(cwd, tempDir);
session.appendMessage({ role: "user", content: label, timestamp: Date.now() });
session.appendMessage({
role: "assistant",
content: [{ type: "text", text: `reply to ${label}` }],
api: "anthropic-messages",
provider: "anthropic",
model: "test",
usage: {
input: 1,
output: 1,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 2,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: Date.now(),
});
const sessionFile = session.getSessionFile();
if (!sessionFile) {
throw new Error("Expected persisted session file");
}
return sessionFile;
}
it("scopes current-folder APIs by cwd while listing all flat sessions", async () => {
const sessionA = createPersistedSession(projectA, "from A");
await new Promise((r) => setTimeout(r, 10));
const sessionB = createPersistedSession(projectB, "from B");
const currentA = await SessionManager.list(projectA, tempDir);
expect(currentA.map((session) => session.path)).toEqual([sessionA]);
const all = await SessionManager.listAll(tempDir);
expect(new Set(all.map((session) => session.path))).toEqual(new Set([sessionA, sessionB]));
const continuedA = SessionManager.continueRecent(projectA, tempDir);
expect(continuedA.getSessionFile()).toBe(sessionA);
});
it("rejects a cancelled session listing", async () => {
createPersistedSession(projectA, "from A");
createPersistedSession(projectB, "from B");
const controller = new AbortController();
const listing = SessionManager.listAll(
tempDir,
(_loaded, _total, partialSessions) => {
if (partialSessions) controller.abort();
},
controller.signal,
);
await expect(listing).rejects.toMatchObject({ name: "AbortError" });
await expect(SessionManager.listAll(undefined, controller.signal)).rejects.toMatchObject({ name: "AbortError" });
});
});
describe("SessionManager.setSessionFile with corrupted files", () => {
let tempDir: string;
beforeEach(() => {
tempDir = join(tmpdir(), `session-test-${Date.now()}`);
mkdirSync(tempDir, { recursive: true });
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
it("truncates and rewrites empty file with valid header", () => {
const emptyFile = join(tempDir, "empty.jsonl");
writeFileSync(emptyFile, "");
const sm = SessionManager.open(emptyFile, tempDir);
// Should have created a new session with valid header
expect(sm.getSessionId()).toBeTruthy();
expect(sm.getHeader()).toBeTruthy();
expect(sm.getHeader()?.type).toBe("session");
// File should now contain a valid header
const content = readFileSync(emptyFile, "utf-8");
const lines = content.trim().split("\n").filter(Boolean);
expect(lines.length).toBe(1);
const header = JSON.parse(lines[0]);
expect(header.type).toBe("session");
expect(header.id).toBe(sm.getSessionId());
});
it("throws and preserves non-empty file without valid header", () => {
const noHeaderFile = join(tempDir, "no-header.jsonl");
const originalContent =
'{"type":"message","id":"abc","parentId":"orphaned","timestamp":"2025-01-01T00:00:00Z","message":{"role":"assistant","content":"test"}}\n';
writeFileSync(noHeaderFile, originalContent);
expect(() => SessionManager.open(noHeaderFile, tempDir)).toThrow(
`Session file is not a valid pi session: ${noHeaderFile}`,
);
expect(readFileSync(noHeaderFile, "utf-8")).toBe(originalContent);
});
it("throws and preserves non-session JSONL files", () => {
const nonSessionFile = join(tempDir, "not-a-session.log");
const originalContent = '{"type":"event","data":"not a session"}\n';
writeFileSync(nonSessionFile, originalContent);
expect(() => SessionManager.open(nonSessionFile, tempDir)).toThrow(
`Session file is not a valid pi session: ${nonSessionFile}`,
);
expect(readFileSync(nonSessionFile, "utf-8")).toBe(originalContent);
});
it("preserves explicit session file path when recovering from corrupted file", () => {
const explicitPath = join(tempDir, "my-session.jsonl");
writeFileSync(explicitPath, "");
const sm = SessionManager.open(explicitPath, tempDir);
// The session file path should be preserved
expect(sm.getSessionFile()).toBe(explicitPath);
});
it("subsequent loads of initialized empty file work correctly", () => {
const emptyFile = join(tempDir, "empty.jsonl");
writeFileSync(emptyFile, "");
const sm1 = SessionManager.open(emptyFile, tempDir);
const sessionId = sm1.getSessionId();
const sm2 = SessionManager.open(emptyFile, tempDir);
expect(sm2.getSessionId()).toBe(sessionId);
expect(sm2.getHeader()?.type).toBe("session");
});
});
describe("SessionManager session file creation", () => {
let tempDir: string;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), "pi-session-persist-"));
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
it("does not create a file for a session with only setup entries", () => {
const session = SessionManager.create(tempDir, tempDir);
session.appendModelChange("anthropic", "claude-sonnet-4-5");
session.appendThinkingLevelChange("off");
expect(existsSync(session.getSessionFile()!)).toBe(false);
});
// #10000: the first prompt must survive a first turn that never produces an assistant message
it("creates the file when the first user message is appended", () => {
const session = SessionManager.create(tempDir, tempDir);
session.appendModelChange("anthropic", "claude-sonnet-4-5");
session.appendMessage(userMsg("first question"));
const file = session.getSessionFile()!;
expect(readSessionFileRoles(file)).toEqual(["session", "model_change", "user"]);
expect(SessionManager.open(file, tempDir).buildSessionContext().messages).toHaveLength(1);
});
it("appends later entries to the file without rewriting earlier ones", () => {
const session = SessionManager.create(tempDir, tempDir);
session.appendMessage(userMsg("first question"));
session.appendCustomEntry("preset-state", { name: "plan" });
session.appendMessage(assistantMsg("first answer"));
expect(readSessionFileRoles(session.getSessionFile()!)).toEqual(["session", "user", "custom", "assistant"]);
});
// #10166, #10661, #11460: a failed write must not leave later entries pointing to a missing parent
it("does not keep an entry in memory when writing it fails", () => {
const session = SessionManager.create(tempDir, tempDir);
const firstId = session.appendMessage(userMsg("first question"));
const file = session.getSessionFile()!;
chmodSync(file, 0o444);
expect(() => session.appendMessage(assistantMsg("lost answer"))).toThrow();
expect(session.getLeafId()).toBe(firstId);
expect(session.getEntries().map((entry) => entry.id)).toEqual([firstId]);
chmodSync(file, 0o644);
const secondId = session.appendMessage(assistantMsg("second answer"));
expect(session.getEntry(secondId)?.parentId).toBe(firstId);
const reopened = SessionManager.open(file, tempDir);
expect(reopened.getBranch().map((entry) => entry.id)).toEqual([firstId, secondId]);
});
it("does not keep an entry in memory when creating the file fails", () => {
const sessionDir = join(tempDir, "sessions");
mkdirSync(sessionDir);
const session = SessionManager.create(tempDir, sessionDir);
const modelChangeId = session.appendModelChange("anthropic", "claude-sonnet-4-5");
chmodSync(sessionDir, 0o555);
try {
expect(() => session.appendMessage(userMsg("first question"))).toThrow();
} finally {
chmodSync(sessionDir, 0o755);
}
expect(session.getLeafId()).toBe(modelChangeId);
expect(session.getEntries().map((entry) => entry.id)).toEqual([modelChangeId]);
session.appendMessage(userMsg("second question"));
expect(readSessionFileRoles(session.getSessionFile()!)).toEqual(["session", "model_change", "user"]);
});
});