From d4283d242c314aa22ec913edf6acdbb847019b06 Mon Sep 17 00:00:00 2001 From: can1357 Date: Sun, 5 Jul 2026 12:16:33 +0200 Subject: [PATCH] fix(session): preserve explicit sessions with consumed drafts --- .../src/session/session-manager.ts | 46 +++++++++++++++++-- .../session-manager/close-drop-empty.test.ts | 20 ++++++++ 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/packages/coding-agent/src/session/session-manager.ts b/packages/coding-agent/src/session/session-manager.ts index 5fb4861b4..9a511fdac 100644 --- a/packages/coding-agent/src/session/session-manager.ts +++ b/packages/coding-agent/src/session/session-manager.ts @@ -73,6 +73,7 @@ import { import { type SessionTitleUpdate, serializeTitleSlot } from "./session-title-slot"; const JSONL_SUFFIX_LENGTH = ".jsonl".length; +const DRAFT_ONLY_SESSION_MARKER = ".draft-only-session"; const SUPERSEDED_COMPACTION_SUMMARY = "[Superseded compaction summary elided after a newer compaction]"; const SUPERSEDED_COMPACTION_SHORT_SUMMARY = "Superseded compaction elided"; @@ -827,6 +828,32 @@ export class SessionManager { return artifactsDir ? path.join(artifactsDir, "draft.txt") : null; } + #draftOnlySessionMarkerPath(): string | null { + const artifactsDir = this.getArtifactsDir(); + return artifactsDir ? path.join(artifactsDir, DRAFT_ONLY_SESSION_MARKER) : null; + } + + #hasDraftOnlySessionMarker(): boolean { + const markerPath = this.#draftOnlySessionMarkerPath(); + return markerPath !== null && this.#storage.existsSync(markerPath); + } + + async #writeDraftOnlySessionMarker(): Promise { + const markerPath = this.#draftOnlySessionMarkerPath(); + if (!markerPath) return; + await this.#storage.writeText(markerPath, ""); + } + + async #clearDraftOnlySessionMarker(): Promise { + const markerPath = this.#draftOnlySessionMarkerPath(); + if (!markerPath) return; + try { + await this.#storage.unlink(markerPath); + } catch (err) { + if (!isEnoent(err)) throw err; + } + } + #artifactManagerForSession(): ArtifactManager | null { if (this.#adoptedArtifactManager) return this.#adoptedArtifactManager; @@ -1173,10 +1200,17 @@ export class SessionManager { async #dropIfEmptyAndNoDraft(): Promise { if (!this.#draftOnlySessionCleanupArmed) return; const sessionFile = this.#sessionFile; - if (!sessionFile || !this.#storage.existsSync(sessionFile)) return; - if (!this.#entries.every(isDraftOnlyMetadataEntry)) return; + if (!sessionFile || !this.#storage.existsSync(sessionFile)) { + this.#draftOnlySessionCleanupArmed = false; + return; + } const draftPath = this.#draftPath(); if (draftPath && this.#storage.existsSync(draftPath)) return; + if (!this.#entries.every(isDraftOnlyMetadataEntry)) { + await this.#clearDraftOnlySessionMarker(); + this.#draftOnlySessionCleanupArmed = false; + return; + } try { await this.#storage.deleteSessionWithArtifacts(sessionFile); this.#fileIsCurrent = false; @@ -1299,7 +1333,10 @@ export class SessionManager { this.#entries.every(isDraftOnlyMetadataEntry); // Force the header onto disk so resume can find the file this draft attaches to. await this.ensureOnDisk(); - if (draftWillMaterializeMetadataOnlyFile) this.#draftOnlySessionCleanupArmed = true; + if (draftWillMaterializeMetadataOnlyFile) { + await this.#writeDraftOnlySessionMarker(); + this.#draftOnlySessionCleanupArmed = true; + } await this.#storage.writeText(draftPath, text); } @@ -1320,7 +1357,8 @@ export class SessionManager { } catch (err) { if (!isEnoent(err)) throw err; } - if (this.#entries.every(isDraftOnlyMetadataEntry)) this.#draftOnlySessionCleanupArmed = true; + if (this.#entries.every(isDraftOnlyMetadataEntry) && this.#hasDraftOnlySessionMarker()) + this.#draftOnlySessionCleanupArmed = true; return draft; } diff --git a/packages/coding-agent/test/session-manager/close-drop-empty.test.ts b/packages/coding-agent/test/session-manager/close-drop-empty.test.ts index 86d267f0e..a504dfb0b 100644 --- a/packages/coding-agent/test/session-manager/close-drop-empty.test.ts +++ b/packages/coding-agent/test/session-manager/close-drop-empty.test.ts @@ -126,6 +126,26 @@ describe("SessionManager close() drops empty metadata-only sessions", () => { expect(await fileExists(sessionFile)).toBe(true); }); + it("keeps an explicitly ensured empty session after its draft is consumed on resume", async () => { + using tempDir = TempDir.createSync("@pi-session-close-keep-explicit-resumed-draft-"); + const firstRun = SessionManager.create(tempDir.path(), tempDir.path()); + await firstRun.ensureOnDisk(); + await firstRun.saveDraft("resume me"); + + const sessionFile = firstRun.getSessionFile(); + if (!sessionFile) throw new Error("Expected persistent session file"); + await firstRun.close(); + expect(await fileExists(sessionFile)).toBe(true); + + const resumed = SessionManager.create(tempDir.path(), tempDir.path()); + await resumed.setSessionFile(sessionFile); + expect(await resumed.consumeDraft()).toBe("resume me"); + await resumed.saveDraft(""); + await resumed.close(); + + expect(await fileExists(sessionFile)).toBe(true); + }); + it("keeps a handoff custom message even before the next user turn", async () => { using tempDir = TempDir.createSync("@pi-session-close-keep-handoff-"); const session = SessionManager.create(tempDir.path(), tempDir.path());