@@ -55,8 +55,22 @@ open class MessageCacheStore private constructor(
5555 private val locks = java.util.concurrent.ConcurrentHashMap<String, Mutex>()
5656 private fun lockFor(sessionId: String): Mutex = locks.getOrPut(sessionId) { Mutex() }
5757
58+ /** Sessions whose cache file has been removed by [remove]. A teardown flush of an
59+ * observer (CacheWriter.flushOnTeardown) runs under NonCancellable and can race with
60+ * remove(): both serialize on [lockFor], but their relative order is nondeterministic.
61+ * If the flush's save lands after remove, it re-creates the deleted session's cache
62+ * file, defeating the deletion (a privacy/disk-leak with no background cleanup). The
63+ * tombstone lets a post-remove save detect the deletion and skip. Cleared by [load] so
64+ * re-opening a session (which won't happen for a truly deleted UUID, but is safe)
65+ * re-enables saving. Bounded by distinct deleted sessions, each a few bytes. */
66+ private val tombstones: MutableSet<String> =
67+ java.util.Collections.newSetFromMap(java.util.concurrent.ConcurrentHashMap())
68+
5869 /** Load the cached messages for [sessionId] under [profileId], or empty if none / unreadable. */
5970 open suspend fun load(profileId: String, sessionId: String): List<MessageWithParts> = lockFor(sessionId).withLock {
71+ // Re-opening a session re-enables saving: clear any tombstone so a fresh conversation
72+ // write isn't suppressed by a prior deletion of the same (reused) id.
73+ tombstones.remove(sessionId)
6074 withContext(Dispatchers.IO) {
6175 val file = fileFor(profileId, sessionId)?.takeIf { it.exists() } ?: return@withContext emptyList()
6276 runCatchingCancellable { OpencodeJson.decodeFromString(serializer, file.readText()) }
@@ -66,6 +80,10 @@ open class MessageCacheStore private constructor(
6680
6781 /** Persist [messages] for [sessionId] under [profileId] (an empty list deletes the file). */
6882 open suspend fun save(profileId: String, sessionId: String, messages: List<MessageWithParts>): Unit = lockFor(sessionId).withLock {
83+ // Skip if this session was deleted since the observer started: a teardown flush racing
84+ // after remove() must not re-create the file. (Held under the same per-session lock as
85+ // remove, so the tombstone is visible to a save that lost the race.)
86+ if (sessionId in tombstones) return@withLock
6987 withContext(Dispatchers.IO) {
7088 val file = fileFor(profileId, sessionId) ?: return@withContext
7189 runCatchingCancellable {
@@ -94,6 +112,9 @@ open class MessageCacheStore private constructor(
94112 * and the map is bounded by the number of distinct sessions touched. */
95113 open suspend fun remove(sessionId: String) {
96114 lockFor(sessionId).withLock {
115+ // Set the tombstone before deleting so a concurrent/loser save sees it and skips,
116+ // instead of re-creating the file we're about to delete.
117+ tombstones.add(sessionId)
97118 withContext(Dispatchers.IO) {
98119 runCatchingCancellable {
99120 val name = idRegex.replace(sessionId, "_") + ".json"