Skip to content

Commit 53ba5de

Browse files
authored
fix(chat): 恢复/重试前从 DB 重建请求历史,修复前台服务期限崩溃 (#13)
Resume and retry trusted the in-memory agentHistory while a fresh send rebuilds it from the DB. After a mid-turn interruption the memory tail can lag the authoritative store, so continuing picked up stale context. Both paths now rebuild from Room before deciding what to send. AgentForegroundService.applyForeground swallowed a failed startForeground() and let the caller stopSelf(). The ~5s foreground deadline armed by startForegroundService() is not lifted by stopSelf(), so the destroyed service still tripped ForegroundServiceDidNotStartInTimeException. Drop out of the foreground state explicitly so the deadline is retracted. Co-authored-by: tall-1997 <278631348+tall-1997@users.noreply.github.com>
1 parent 5040957 commit 53ba5de

3 files changed

Lines changed: 90 additions & 50 deletions

File tree

‎src/android/app/src/main/java/com/openminis/app/service/AgentForegroundService.kt‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,29 @@ class AgentForegroundService : Service() {
375375
true
376376
} catch (t: Throwable) {
377377
Log.w(TAG, "applyForeground failed: ${t.message}")
378+
// The service was launched with startForegroundService(), so the
379+
// system has a hard ~5s deadline for a successful startForeground().
380+
// That deadline is queued the moment the START call lands — it is
381+
// NOT lifted by stopSelf(), which only tears down the component.
382+
// If startForeground() threw (a forked SystemUI rejecting the
383+
// notification, a missing channel after restore, a transient
384+
// DeadObject), swallowing the error and just returning false leaves
385+
// the deadline armed against a service that will never anchor: the
386+
// caller's stopSelf() destroys it, and the system then fires
387+
// ForegroundServiceDidNotStartInTimeException and kills the app.
388+
// Dropping out of the foreground state explicitly is the only
389+
// call that retracts that pending requirement — it tells the
390+
// ActivityManager "this service is no longer expected to go
391+
// foreground", so the already-destroyed component has nothing left
392+
// to answer for.
393+
try {
394+
androidx.core.app.ServiceCompat.stopForeground(
395+
this,
396+
androidx.core.app.ServiceCompat.STOP_FOREGROUND_REMOVE,
397+
)
398+
} catch (t2: Throwable) {
399+
Log.w(TAG, "stopForeground after applyForeground failure also failed: ${t2.message}")
400+
}
378401
false
379402
}
380403
}

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

Lines changed: 32 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -43,33 +43,42 @@ fun ChatViewModel.resume() {
4343
// behind the input bar.
4444
_forceScrollToBottom.tryEmit(Unit)
4545

46-
// If history ends with assistant (Case 2: text-cancel committed a
47-
// partial assistant turn), append a continue reminder as a user
48-
// message. If it ends with user tool_result (Case 1), it's already
49-
// a valid starting point for the next API call — no reminder needed.
50-
val historyEndsWithAssistant =
51-
agentHistory.lastOrNull()?.role == LLMMessage.Role.ASSISTANT
52-
if (historyEndsWithAssistant) {
53-
val reminder =
54-
"<system-reminder>The user stopped the previous response but now wants to continue. Pick up exactly where you left off.</system-reminder>"
55-
val parts = listOf<AgentContentPart>(AgentContentPart.Text(reminder))
56-
appendBoundedHistory(
57-
LLMMessage(
58-
role = LLMMessage.Role.USER,
59-
content = reminder,
60-
contentParts = parts,
46+
viewModelScope.launch {
47+
// Rebuild agentHistory from the DB before continuing — a fresh
48+
// sendMessage() does exactly this (rebuildAgentHistoryFromDatabase),
49+
// which is why a normal send never carries stale context while a
50+
// Resume could. The in-memory agentHistory lags the DB whenever the
51+
// process was killed mid-turn and the ViewModel was restored with a
52+
// partial in-memory tail, or any path committed rows the in-memory
53+
// list missed. The DB is the authoritative store; trusting memory
54+
// here is how a resume picks up an old, pre-interruption context.
55+
rebuildAgentHistoryFromDatabase(activeSessionId)
56+
// If history ends with assistant (Case 2: text-cancel committed a
57+
// partial assistant turn), append a continue reminder as a user
58+
// message. If it ends with user tool_result (Case 1), it's already
59+
// a valid starting point for the next API call — no reminder needed.
60+
val historyEndsWithAssistant =
61+
agentHistory.lastOrNull()?.role == LLMMessage.Role.ASSISTANT
62+
if (historyEndsWithAssistant) {
63+
val reminder =
64+
"<system-reminder>The user stopped the previous response but now wants to continue. Pick up exactly where you left off.</system-reminder>"
65+
val parts = listOf<AgentContentPart>(AgentContentPart.Text(reminder))
66+
appendBoundedHistory(
67+
LLMMessage(
68+
role = LLMMessage.Role.USER,
69+
content = reminder,
70+
contentParts = parts,
71+
)
6172
)
62-
)
63-
viewModelScope.launch(Dispatchers.IO) {
64-
val partsJson = """[{"type":"text","value":${escapeJson(reminder)}}]"""
65-
val persisted = chatRepository.appendMessage(activeSessionId, "user", partsJson)
66-
withContext(Dispatchers.Main.immediate) {
67-
notePersistedUiRow(null, persisted.id)
73+
launch(Dispatchers.IO) {
74+
val partsJson = """[{"type":"text","value":${escapeJson(reminder)}}]"""
75+
val persisted = chatRepository.appendMessage(activeSessionId, "user", partsJson)
76+
withContext(Dispatchers.Main.immediate) {
77+
notePersistedUiRow(null, persisted.id)
78+
}
6879
}
6980
}
70-
}
7181

72-
viewModelScope.launch {
7382
val baseSystemPrompt = buildSystemPrompt()
7483
val systemPrompt =
7584
if ((provider as? com.openminis.app.provider.anthropic.AnthropicProvider)?.isOAuth == true) {

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

Lines changed: 35 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -72,33 +72,6 @@ fun ChatViewModel.retryLast() {
7272
// Case A too — the row is deleted moments later regardless.
7373
clearPersistedLastAssistantError()
7474

75-
// 2. Pop ONLY a trailing assistant entry from agentHistory (mirrors
76-
// iOS retry() :2107-2109). If the tail is already user(tool_result),
77-
// the next-turn LLM call errored — leave history alone.
78-
val poppedAssistant = if (agentHistory.lastOrNull()?.role == LLMMessage.Role.ASSISTANT) {
79-
val last = agentHistory.removeAt(agentHistory.size - 1)
80-
last
81-
} else null
82-
83-
// 3. GC orphaned tool_result parts whose tool_use is gone (mirrors
84-
// iOS retry() :2114-2128). Walks backward so removeAt is safe.
85-
val liveToolUseIds = agentHistory.flatMap { m ->
86-
m.contentParts.filterIsInstance<AgentContentPart.ToolUse>().map { it.id }
87-
}.toSet()
88-
for (i in agentHistory.indices.reversed()) {
89-
val m = agentHistory[i]
90-
if (m.role != LLMMessage.Role.USER) continue
91-
val cleanedParts = m.contentParts.filter { p ->
92-
p !is AgentContentPart.ToolResult || p.id in liveToolUseIds
93-
}
94-
when {
95-
cleanedParts.isEmpty() && m.contentParts.isNotEmpty() ->
96-
agentHistory.removeAt(i)
97-
cleanedParts.size < m.contentParts.size ->
98-
agentHistory[i] = m.copy(contentParts = cleanedParts)
99-
}
100-
}
101-
10275
val initialProvider = currentProvider ?: return
10376
var provider: LLMProvider = initialProvider
10477
_error.value = null
@@ -112,6 +85,41 @@ fun ChatViewModel.retryLast() {
11285
try {
11386
val sid = realSessionId.takeIf { it.isNotEmpty() } ?: sessionId
11487

88+
// Rebuild agentHistory from the DB before deciding what to pop —
89+
// a fresh sendMessage() does the same (rebuildAgentHistoryFromDatabase),
90+
// so a normal send never carries stale context while a retry that
91+
// trusted memory could. In-memory agentHistory lags the DB after a
92+
// mid-turn process kill or any commit path memory missed; the DB is
93+
// authoritative. Rebuild FIRST, then run the pop/GC steps below on
94+
// the rebuilt (authoritative) history.
95+
rebuildAgentHistoryFromDatabase(sid)
96+
97+
// 2. Pop ONLY a trailing assistant entry from agentHistory (mirrors
98+
// iOS retry() :2107-2109). If the tail is already user(tool_result),
99+
// the next-turn LLM call errored — leave history alone.
100+
val poppedAssistant = if (agentHistory.lastOrNull()?.role == LLMMessage.Role.ASSISTANT) {
101+
agentHistory.removeAt(agentHistory.size - 1)
102+
} else null
103+
104+
// 3. GC orphaned tool_result parts whose tool_use is gone (mirrors
105+
// iOS retry() :2114-2128). Walks backward so removeAt is safe.
106+
val liveToolUseIds = agentHistory.flatMap { m ->
107+
m.contentParts.filterIsInstance<AgentContentPart.ToolUse>().map { it.id }
108+
}.toSet()
109+
for (i in agentHistory.indices.reversed()) {
110+
val m = agentHistory[i]
111+
if (m.role != LLMMessage.Role.USER) continue
112+
val cleanedParts = m.contentParts.filter { p ->
113+
p !is AgentContentPart.ToolResult || p.id in liveToolUseIds
114+
}
115+
when {
116+
cleanedParts.isEmpty() && m.contentParts.isNotEmpty() ->
117+
agentHistory.removeAt(i)
118+
cleanedParts.size < m.contentParts.size ->
119+
agentHistory[i] = m.copy(contentParts = cleanedParts)
120+
}
121+
}
122+
115123
// T258: only sync the DB when step 2 popped a trailing assistant
116124
// entry from agentHistory. In that case the persisted partial-
117125
// assistant row would resurrect the failed turn on next session

0 commit comments

Comments
 (0)