Repository navigation
[Bug] 长期记忆自动保存:轮询协程无异常隔离会永久停摆;失败候选无上限重试;processing 候选无回收 #1384
Description
🤖 来源说明:本 issue 由 AI 辅助分析生成(APK 反编译 + 上游源码逐段比对),由人类用户提出「自动更新记忆似乎不生效」的观察后排查。下面每条结论都标注了验证状态,标为「未复现」的部分是待确认线索,不是既成事实;如与设计意图不符,请以维护者判断为准。
环境
- Operit 1.12.2(versionCode 51),对应
main@dbf7191("chore(release): sync dev to main for 1.12.2");下文行号均基于该提交 - 设备:vivo V2520A / Android 17 (SDK 37),targetSdk 34
- 现象:开启「自动更新记忆」后长期没有新记忆写入,界面无报错。手动「触发记忆保存」可用(说明模型与 JSON 链路本身可通)
验证状态总览
| # | 问题 | 状态 |
|---|---|---|
| 1 | 轮询循环无异常隔离,异常后本进程内永久停摆 | 代码事实;触发条件未在真机复现 |
| 2 | 失败候选无重试上限,按 interval 无限重试 | 代码事实(路径可推导) |
| 3 | processing 候选无回收,进程被杀即永久丢失 |
代码事实 |
| 4 | 模型返回 {} 也删候选并记「成功」,用户零反馈 |
代码事实;是否算 bug 待维护者判断 |
1. 轮询循环没有异常隔离
MemoryAutoSaveScheduler.kt:48-59:
fun start() {
if (loopJob?.isActive == true) return
instance = this
loopJob =
scope.launch(Dispatchers.IO) {
AppLogger.d(TAG, "长期记忆自动保存轮询器已启动")
while (isActive) {
delay(LOOP_TICK_MS)
runOnce() // ← 没有 try/catch
}
}
}runOnce()(:70-80)只有 try { scanAndProcessCandidates() } finally { isRunning.set(false) },异常继续向上抛。
唯一启动点:OperitApplication.kt:224-225(MemoryAutoSaveScheduler(applicationContext, applicationScope).also { it.start() })。UI 侧只调用 getInstance()?.getMinutesUntilNextRun(...)(AgentChatInputSection.kt:2280),没有任何重启或自愈路径。
scanAndProcessCandidates()(:82-166)有若干前置步骤不在任何 try 内:
preferencesManager.memorySpaceListFlow.first()(:83)AIToolHandler.getInstance(context)(:86)EnhancedAIService.getAIServiceForFunction(context, FunctionType.MEMORY)(:87-88)AppDatabase.getDatabase(context)(:89)- 每个 profile 的
MemoryAutoSaveCandidateRepository(...)(:99)与候选查询(:100)
可能触发异常的例子(未验证,属推测):DataStore 读取失败、ObjectBox store 打开/迁移失败、初始化异常。只要命中一次,轮询协程结束,且 start() 只在 Application 初始化时调用一次,本进程内不再恢复。
反例澄清(我一开始怀疑过,核对后排除):这不是「没绑定记忆库模型」导致的。
ModelConfigManager.getModelConfigFlow(id)(ModelConfigManager.kt:565-570)在配置缺失时会回退到ModelConfigData(id = configId),其apiProviderTypeId默认为DEEPSEEK(ModelConfigData.kt:103-104),因此这里不会抛;真实失败发生在 HTTP 调用阶段,而那一层在processChatCandidateGroup的catch (e: Exception)(:292-294)里,只会markFailed。
2. 失败候选无重试上限
MemoryAutoSaveCandidateRepository.kt:112-121:markFailed 只做 attemptCount += 1 与 status = FAILED,没有任何上限判断。
getPendingAndFailedCandidates()(:55-66)无条件包含 FAILED,也没有 attemptCount 过滤。
而 scanAndProcessCandidates 的 5 条门槛用的正是这个列表(sched.kt:100-101)。因此:一旦攒够 5 条永久失败的候选,门槛永远满足,每个 interval(默认 5 分钟)都会把它们重新取出、重新调用一次模型。典型失败原因(401/429、JSON 截断见 #1046、超时)不会自愈,形成无限重试 + 无限计费。
3. processing 候选无回收
processChatCandidateGroup 先 markProcessing(candidateIds)(sched.kt:207,写入 STATUS_PROCESSING,cand.kt:88),再开始处理。若此时进程被杀(国产 ROM 后台清理常见),这些候选既不在 PENDING 也不在 FAILED 查询结果里,永远不会再被消费,也不会被删除。
全仓库检索 STATUS_PROCESSING 只有 3 处命中:model、repository、单测;没有任何启动时 reset 或超时回收逻辑。
4. {} 被当作成功(可观测性问题)
sched.kt:279-289:saveMemoryNow() 正常返回后无条件 deleteCandidates() 并打日志「长期记忆候选处理成功」。而模型返回 {}(日志「分析结果为空,判断为无需记忆的对话」)同样是正常返回。
结果:用户侧没有任何变化,日志侧显示成功——这正是「功能看起来是死的,但日志说成功了」的来源。是否把 {} 计为成功请维护者判断,但至少建议给用户一个可见状态。
真机验证与局限(如实列出)
adb logcat -d只能拿到尾部约 256 KB,且本机受限无法按 tag 过滤、无法清缓冲区,未能捕获 App 启动期的「长期记忆自动保存轮询器已启动」日志;- 受限无法 force-stop 应用进程,因此「异常后停摆」未做真机复现,第 1 条为静态分析结论;
- 第 2、3 条不依赖真机:由代码路径可直接推导(failed 永不过滤;processing 无任何回收路径);
- 反编译 APK(1.12.2,19 个 dex)与
main@dbf7191源码逐段对照,上述代码在两处一致。
期望
- 循环体加 try/catch(
CancellationException例外),单轮失败不影响下一轮; - 失败次数上限 + 死信/丢弃,并在 UI 可见;
- 启动时把
PROCESSING重置为PENDING(或超时回收); {}结果给用户可见反馈;- 评估
MIN_TOTAL_CANDIDATES_TO_EXTRACT = 5+ 每轮仅入队 1 条 + 仅前台存活,这个组合是否过于保守。
建议补丁(示意)
// MemoryAutoSaveScheduler.start()
while (isActive) {
try {
delay(LOOP_TICK_MS)
runOnce()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
AppLogger.e(TAG, "长期记忆自动保存轮询异常,等待下一轮", e)
}
}// MemoryAutoSaveCandidateRepository
private const val MAX_ATTEMPTS = 3 // 建议值
fun getPendingAndFailedCandidates(): List<MemoryAutoSaveCandidate> =
candidateBox.query(
MemoryAutoSaveCandidate_.status.equal(STATUS_PENDING)
.or(MemoryAutoSaveCandidate_.status.equal(STATUS_FAILED))
).build().find()
.filter { it.attemptCount < MAX_ATTEMPTS }
.sortedBy { it.createdAt.time }
fun resetProcessingCandidates() {
val stale = candidateBox.query(
MemoryAutoSaveCandidate_.status.equal(STATUS_PROCESSING)
).build().find()
stale.forEach { it.status = STATUS_PENDING }
candidateBox.put(stale)
}相关
- [Bug] 手动触发记忆保存失败:Unterminated object at character 1024(长内容+特殊字符触发JSON截断) #1046 手动触发记忆保存失败(JSON 截断)—— 同类失败在自动路径上会变成无限重试
- 建议:长期记忆「自动保存」开放提示词自定义 + 读取策略可调 + 提供纯追加模式 #597 长期记忆自动保存的提示词/策略讨论
Activity
CharlesWu0810 commentedon Oct 20, 2026
感觉dsh提的issue怪怪的😂算了你们看着办吧
yoruuuchan commentedon Oct 23, 2026
我来处理这个问题,会基于最新 dev 核对代码后提交修复 PR。
计划覆盖:轮询循环的异常隔离与取消语义、失败候选的有限重试和终止状态、进程重启后的 processing 候选恢复,以及区分模型返回 {}(无需保存)与真实保存失败。测试会覆盖异常、取消、重试和恢复路径。
yoruuuchan commentedon Oct 23, 2026
已基于最新 dev 提交修复 PR:#1408
覆盖轮询异常隔离与取消语义、候选最多 3 次重试并在超限后进入 dead_letter、启动和每轮扫描恢复 PROCESSING 候选,以及区分 {}\(无需保存)与真实保存失败。PR 中附了本地测试、本地化和仓库卫生检查结果。
yoruuuchan commentedon Oct 23, 2026
其实我刚才的回复也是dsh写的嘿嘿