diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index 57f9655ba..89431f2a3 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -1189,10 +1189,20 @@ export class SessionManager { } private _appendEntry(entry: SessionEntry): void { + const previousLeafId = this.leafId; this.fileEntries.push(entry); this.byId.set(entry.id, entry); this.leafId = entry.id; - this._persist(entry); + try { + this._persist(entry); + } catch (error) { + // Keep memory in line with the file: an entry that was not written must not become the + // parent of later entries, or the file would hold entries pointing to a missing parent. + this.fileEntries.pop(); + this.byId.delete(entry.id); + this.leafId = previousLeafId; + throw error; + } } /** Append a message as child of current leaf, then advance leaf. Returns entry id. diff --git a/packages/coding-agent/test/session-manager/file-operations.test.ts b/packages/coding-agent/test/session-manager/file-operations.test.ts index 8353ccdb7..ac6441490 100644 --- a/packages/coding-agent/test/session-manager/file-operations.test.ts +++ b/packages/coding-agent/test/session-manager/file-operations.test.ts @@ -1,6 +1,7 @@ import { constants as bufferConstants } from "buffer"; import { appendFileSync, + chmodSync, closeSync, existsSync, mkdirSync, @@ -471,4 +472,42 @@ describe("SessionManager session file creation", () => { 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"]); + }); });