Skip to content

Commit ce8d340

Browse files
authored
Merge pull request #11 from tall-1997/fix/session-loss-and-crash
fix(chat): session-loss ordinal mismatch + long-session render OOM + version restore
2 parents 37c31c5 + dbed3ae commit ce8d340

6 files changed

Lines changed: 322 additions & 54 deletions

File tree

‎src/android/app/src/main/java/com/openminis/app/data/db/ChatDao.kt‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -633,6 +633,19 @@ interface ChatDao {
633633
@Query("SELECT substr(COALESCE(preview, parts_json), 1, 65536) FROM messages WHERE id = :id")
634634
suspend fun messagePartsJson(id: String): String?
635635

636+
/**
637+
* [T-delete-anchor-by-id] Direct row-id → sort_order lookup. The ordinal
638+
* walk in visibleUserCutoff counted a DIFFERENT set of user rows than the
639+
* UI list (UI counts every user-role ChatMessage; the DB walk skips
640+
* toolResult-only user rows), so the resolved anchor could land dozens
641+
* of turns early and deleteMessagesAfter wiped the session tail
642+
* (incident 2026-10-14: 1386 rows archived at 01:16:12 from one retry).
643+
* Anchoring by the message id the user actually tapped removes the
644+
* two-counter mismatch entirely: the UI ChatMessage.id IS the DB row id.
645+
*/
646+
@Query("SELECT sort_order FROM messages WHERE id = :id")
647+
suspend fun sortOrderForMessageId(id: String): Int?
648+
636649
// [T-error-persist-android] Write/clear the terminal error sticker on a
637650
// specific message row by id. Used when the persisted DB id is known
638651
// (clear-on-retry via sourceDbIds).

‎src/android/app/src/main/java/com/openminis/app/data/db/MessageVersionDao.kt‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,17 @@ interface MessageVersionDao {
2626
@Query("SELECT DISTINCT message_id FROM message_versions WHERE session_id = :sessionId")
2727
suspend fun messageIdsWithVersions(sessionId: String): List<String>
2828

29+
/**
30+
* [T-msg-version-restore] All archived versions for a session ordered by
31+
* source creation time (oldest first). Powers the one-shot restore that
32+
* re-inserts rows lost to the 2026-10-14 ordinal-mismatch truncation.
33+
*/
34+
@Query(
35+
"SELECT * FROM message_versions WHERE session_id = :sessionId " +
36+
"ORDER BY source_created_at ASC, version_index ASC"
37+
)
38+
suspend fun versionsForSession(sessionId: String): List<MessageVersionEntity>
39+
2940
@Query("DELETE FROM message_versions WHERE session_id = :sessionId")
30-
suspend fun deleteForSession(sessionId: String)
41+
suspend fun deleteForSession(sessionId: String): Unit
3142
}

‎src/android/app/src/main/java/com/openminis/app/data/repository/ChatRepository.kt‎

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -849,6 +849,84 @@ class ChatRepository(
849849
archivedAt = nowMs,
850850
)
851851

852+
/**
853+
* [T-msg-version-restore] One-shot recovery for rows lost to the
854+
* 2026-10-14 ordinal-mismatch truncation (deleteMessagesAfter anchored
855+
* dozens of turns early; 1386 rows were archived then deleted from one
856+
* retry tap). Re-inserts every archived version whose source row no
857+
* longer exists in `messages`, at sort orders ABOVE the current tail so
858+
* the live conversation is untouched, and deletes the restored rows from
859+
* `message_versions` so a second call is a no-op.
860+
*
861+
* Only LATEST versions are restored (highest version_index per
862+
* message_id): earlier versions are the pre-edit history the archive
863+
* exists to keep, not rows to resurrect.
864+
*
865+
* Idempotent: rows whose message_id still exists in `messages` are
866+
* skipped (their live row already carries the content).
867+
*/
868+
suspend fun restoreArchivedVersions(sessionId: String): RestoreResult {
869+
val vdao = versionDao ?: return RestoreResult(0, 0, "versionDao unavailable")
870+
val all = runCatching { vdao.versionsForSession(sessionId) }.getOrNull()
871+
?: return RestoreResult(0, 0, "versionsForSession failed")
872+
if (all.isEmpty()) return RestoreResult(0, 0, null)
873+
874+
// Latest version per message_id.
875+
val latest = all.groupBy { it.messageId }
876+
.mapValues { (_, versions) -> versions.maxBy { it.versionIndex } }
877+
.values
878+
.sortedBy { it.sourceCreatedAt }
879+
880+
// Skip message_ids that still exist live.
881+
val live = dao.messageCountForSession(sessionId)
882+
val existingIds = if (live > 0) {
883+
runCatching { loadMessageIds(sessionId) }.getOrDefault(emptySet())
884+
} else emptySet()
885+
886+
val toRestore = latest.filter { it.messageId !in existingIds }
887+
if (toRestore.isEmpty()) return RestoreResult(0, all.size, null)
888+
889+
// Append above the current tail, preserving source order.
890+
var nextSort = dao.nextSortOrder(sessionId)
891+
val restored = mutableListOf<MessageEntity>()
892+
for (v in toRestore) {
893+
val row = MessageEntity(
894+
id = v.messageId,
895+
sessionId = sessionId,
896+
role = v.role,
897+
partsJson = v.partsJson ?: "[]",
898+
createdAt = v.sourceCreatedAt,
899+
sortOrder = nextSort,
900+
reasoningContent = v.reasoningContent,
901+
errorInfo = v.errorInfo,
902+
modelId = v.modelId,
903+
modelDisplayName = v.modelDisplayName,
904+
bodyRef = v.bodyRef,
905+
bodySha = v.bodySha,
906+
)
907+
dao.insertMessage(row)
908+
restored.add(row)
909+
nextSort++
910+
}
911+
// Remove the restored entries from the archive so a repeat call is a
912+
// no-op (the live row now carries the content).
913+
runCatching {
914+
vdao.deleteForSession(sessionId)
915+
}
916+
AppLogger.info(
917+
TAG,
918+
"[T-msg-version-restore] restored ${restored.size} rows to session " +
919+
"${sessionId.take(8)} (archive had ${all.size}, skipped ${latest.size - toRestore.size} live)",
920+
)
921+
return RestoreResult(restored.size, all.size, null)
922+
}
923+
924+
data class RestoreResult(
925+
val restoredCount: Int,
926+
val archiveCount: Int,
927+
val error: String?,
928+
)
929+
852930
/**
853931
* Rewrite a single message row's parts_json in place. Used by
854932
* [com.openminis.app.ui.chat.ChatViewModel.rerunFromToolBlock]'s block-
@@ -1364,8 +1442,19 @@ class ChatRepository(
13641442
* Upper bound for one authoritative context hydrate. The UI remains
13651443
* tail-windowed, but model context must be able to recover history
13661444
* beyond the old 400-row cache when a long session is reopened.
1445+
*
1446+
* [T-android-long-session-render-oom] Lowered from 4_000 to 500:
1447+
* the UI tail IS the initial render window (loadSession →
1448+
* toChatMessages → LazyColumn). At 4_000 a 1441-message session
1449+
* loaded ALL rows into the UI on open, and RenderThread/SKIA
1450+
* allocated 4.68 GB of native graphics memory in 11 s → Scudo
1451+
* map exhaustion → abort (2026-10-14 crash, tombstone_09/12).
1452+
* 500 rows keeps every real conversation fully loaded (the 95th
1453+
* percentile session is far smaller) while bounding the worst
1454+
* case; older history stays one tap away via loadOlderMessages
1455+
* turn-aligned paging, and the DB remains the full transcript.
13671456
*/
1368-
internal const val MAX_TAIL_MESSAGES = 4_000
1457+
internal const val MAX_TAIL_MESSAGES = 500
13691458

13701459
// Session-list preview only needs ~100 chars. Regex.replace on a
13711460
// 500 KB parts_json body was a leftover ICU Matcher.reset path.

‎src/android/app/src/main/java/com/openminis/app/ui/chat/ChatViewModel.kt‎

Lines changed: 53 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -4532,13 +4532,29 @@ class ChatViewModel(
45324532
try {
45334533
val sid = realSessionId.takeIf { it.isNotEmpty() } ?: sessionId
45344534

4535-
// Find the DB sort_order cutoff for this user message.
4536-
// UI visible user messages are the N-th user msg with actual text content.
4537-
// Count which visible user message this is (0-based).
4538-
val visibleUserIndex = messages.subList(0, index + 1).count { it.role == "user" } - 1
4539-
val cutoffSortOrder = visibleUserCutoff(sid, visibleUserIndex)?.let { it.sortOrder + 1 } ?: -1
4540-
if (cutoffSortOrder >= 0) {
4541-
chatRepository.deleteMessagesAfter(sid, cutoffSortOrder)
4535+
// [T-delete-anchor-by-id] Find the DB sort_order cutoff for this
4536+
// user message DIRECTLY by row id. The old ordinal walk
4537+
// (visibleUserIndex → visibleUserCutoff) counted a different set
4538+
// of user rows than the UI list — UI counts every user-role
4539+
// ChatMessage, the DB walk skips toolResult-only user rows — so
4540+
// the anchor could land dozens of turns early and
4541+
// deleteMessagesAfter wiped the whole session tail
4542+
// (2026-10-14 incident: 1386 rows lost from one retry tap).
4543+
// ChatMessage.id is the DB row id (toChatMessages maps
4544+
// entity.id 1:1), so this lookup is exact. sourceDbIds covers
4545+
// the merged-assistant case; a user message is never merged.
4546+
val anchorIds = listOf(message.id) + message.sourceDbIds
4547+
val anchorSortOrder = anchorIds.firstNotNullOfOrNull { anchorId ->
4548+
runCatching { chatRepository.dao.sortOrderForMessageId(anchorId) }.getOrNull()
4549+
}
4550+
if (anchorSortOrder != null) {
4551+
chatRepository.deleteMessagesAfter(sid, anchorSortOrder + 1)
4552+
} else {
4553+
AppLogger.warning(
4554+
TAG,
4555+
"retryFromMessage: anchor row ${message.id.take(8)} not found in DB — " +
4556+
"skipping DB truncation (UI already truncated)",
4557+
)
45424558
}
45434559

45444560
awaitBoundedHistoryRebuild(sid)
@@ -4558,9 +4574,15 @@ class ChatViewModel(
45584574
* [T-android-delete-from-here] Resolve the DB `sort_order` to cut at so
45594575
* that [index] and everything after it is removed.
45604576
*
4561-
* Anchoring is by visible-user-message ordinal rather than by row id
4562-
* because the UI list and the persisted rows are not 1:1 — see
4563-
* [deleteFromMessage]'s note on synthetic `<system-reminder>` rows.
4577+
* [T-delete-anchor-by-id] Anchoring is by the tapped message's DB row id,
4578+
* NOT by visible-user ordinal. The old ordinal walk (visibleUserIndex →
4579+
* visibleUserCutoff) counted a different set of user rows than the UI
4580+
* list — the UI counts every user-role ChatMessage while the DB walk
4581+
* skips toolResult-only user rows — so the anchor could land dozens of
4582+
* turns early and deleteMessagesAfter silently wiped the session tail
4583+
* (2026-10-14 incident: 1386 rows archived from one delete tap).
4584+
* ChatMessage.id is the DB row id, and sourceDbIds covers merged
4585+
* assistant bubbles, so the lookup is exact.
45644586
*
45654587
* When the target is an ASSISTANT message the cut is anchored to the user
45664588
* turn it belongs to: we find the last visible user message at or before
@@ -4574,10 +4596,22 @@ class ChatViewModel(
45744596
index: Int,
45754597
target: ChatMessage,
45764598
): Int {
4577-
val visibleUserIndex = messages.subList(0, index + 1).count { it.role == "user" } - 1
4578-
if (visibleUserIndex < 0) return 0
4579-
val anchor = visibleUserCutoff(sid, visibleUserIndex) ?: return -1
4580-
return if (target.role == "user") anchor.sortOrder else anchor.sortOrder + 1
4599+
// [T-delete-anchor-by-id] Resolve the anchor row id first: the target
4600+
// itself for a user message, the preceding visible user message for
4601+
// an assistant target (its whole turn goes).
4602+
val anchorMessage: ChatMessage? = if (target.role == "user") {
4603+
target
4604+
} else {
4605+
(index downTo 0).asSequence()
4606+
.map { messages[it] }
4607+
.firstOrNull { it.role == "user" && it.content.isNotBlank() }
4608+
}
4609+
val anchor = anchorMessage ?: return -1
4610+
val anchorIds = listOf(anchor.id) + anchor.sourceDbIds
4611+
val anchorSortOrder = anchorIds.firstNotNullOfOrNull { anchorId ->
4612+
runCatching { chatRepository.dao.sortOrderForMessageId(anchorId) }.getOrNull()
4613+
} ?: return -1
4614+
return if (target.role == "user") anchorSortOrder else anchorSortOrder + 1
45814615
}
45824616

45834617

@@ -5862,39 +5896,11 @@ class ChatViewModel(
58625896
}
58635897

58645898

5865-
internal suspend fun visibleUserCutoff(
5866-
sid: String,
5867-
visibleUserIndex: Int,
5868-
): com.openminis.app.data.db.MessageAnchorRow? {
5869-
if (visibleUserIndex < 0) return null
5870-
val total = chatRepository.dao.messageCountForSession(sid)
5871-
var offset = 0
5872-
var seen = 0
5873-
while (offset < total) {
5874-
val page = chatRepository.dao.loadMessageAnchorsPage(sid, offset, 100, 4_096)
5875-
if (page.isEmpty()) break
5876-
for (row in page) {
5877-
if (row.role != "user" || !anchorHeadHasVisibleUserText(row.headText.orEmpty())) continue
5878-
if (seen == visibleUserIndex) return row
5879-
seen++
5880-
}
5881-
offset += page.size
5882-
}
5883-
return null
5884-
}
5885-
5886-
private fun anchorHeadHasVisibleUserText(head: String): Boolean = try {
5887-
val arr = org.json.JSONArray(head)
5888-
(0 until arr.length()).any { i ->
5889-
val o = arr.optJSONObject(i) ?: return@any false
5890-
val value = o.optString("value", "")
5891-
o.optString("type") == "text" &&
5892-
stripAttachedFilesXml(value).isNotBlank() &&
5893-
!value.trimStart().startsWith("<system-reminder>")
5894-
}
5895-
} catch (_: Exception) {
5896-
true
5897-
}
5899+
// [T-delete-anchor-by-id] visibleUserCutoff / anchorHeadHasVisibleUserText
5900+
// were removed: the ordinal walk counted a different set of user rows
5901+
// than the UI list and caused the 2026-10-14 session-loss incident.
5902+
// Delete/retry/edit now anchor by DB row id via
5903+
// ChatDao.sortOrderForMessageId — see resolveDeleteCutoffSortOrder.
58985904

58995905

59005906
/**

‎src/android/app/src/main/java/com/openminis/app/ui/chat/ChatViewModelTruncateExt.kt‎

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,25 @@ internal suspend fun ChatViewModel.truncateBeforeEdit(messageId: String) {
3535
revokeMemoryWritesInDeletedMessages(deletedMessages)
3636

3737
val sid = realSessionId.takeIf { it.isNotEmpty() } ?: sessionId
38-
// Visible-user index of the *edited* message — count user turns
39-
// strictly before `index`, which is the 0-based ordinal of the
40-
// edited turn itself.
41-
val visibleUserIndex = messages.subList(0, index).count { it.role == "user" }
42-
val cutoffSortOrder = visibleUserCutoff(sid, visibleUserIndex)?.sortOrder ?: -1
38+
// [T-delete-anchor-by-id] Anchor the truncation by the edited message's
39+
// DB row id, NOT by visible-user ordinal. The old ordinal walk counted a
40+
// different set of user rows than the UI list (UI counts every user-role
41+
// ChatMessage; the DB walk skips toolResult-only user rows), so the cut
42+
// could land dozens of turns early and deleteMessagesAfter wiped the
43+
// session tail (2026-10-14 incident). ChatMessage.id IS the DB row id.
44+
val edited = messages[index]
45+
val anchorIds = listOf(edited.id) + edited.sourceDbIds
46+
val cutoffSortOrder = anchorIds.firstNotNullOfOrNull { anchorId ->
47+
runCatching { chatRepository.dao.sortOrderForMessageId(anchorId) }.getOrNull()
48+
} ?: -1
4349
if (cutoffSortOrder >= 0) {
4450
chatRepository.deleteMessagesAfter(sid, cutoffSortOrder)
51+
} else {
52+
AppLogger.warning(
53+
ChatViewModel.TAG_STREAM,
54+
"✏️ truncateBeforeEdit: anchor row ${edited.id.take(8)} not found in DB — " +
55+
"skipping DB truncation (UI already truncated)",
56+
)
4557
}
4658
val remaining = awaitBoundedHistoryRebuild(sid)
4759
AppLogger.info(

0 commit comments

Comments
 (0)