Skip to content

[Bug] 长期记忆自动保存:轮询协程无异常隔离会永久停摆;失败候选无上限重试;processing 候选无回收 #1384

Open

Description

@CharlesWu0810

🤖 来源说明:本 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)
}

相关

Activity

  1. CharlesWu0810 commented on Oct 20, 2026

    @CharlesWu0810
    Author

    感觉dsh提的issue怪怪的😂算了你们看着办吧

  2. yoruuuchan commented on Oct 23, 2026

    @yoruuuchan
    Contributor

    我来处理这个问题,会基于最新 dev 核对代码后提交修复 PR。

    计划覆盖:轮询循环的异常隔离与取消语义、失败候选的有限重试和终止状态、进程重启后的 processing 候选恢复,以及区分模型返回 {}(无需保存)与真实保存失败。测试会覆盖异常、取消、重试和恢复路径。

  3. yoruuuchan commented on Oct 23, 2026

    @yoruuuchan
    Contributor

    已基于最新 dev 提交修复 PR:#1408

    覆盖轮询异常隔离与取消语义、候选最多 3 次重试并在超限后进入 dead_letter、启动和每轮扫描恢复 PROCESSING 候选,以及区分 {}\(无需保存)与真实保存失败。PR 中附了本地测试、本地化和仓库卫生检查结果。

  4. yoruuuchan commented on Oct 23, 2026

    @yoruuuchan
    Contributor
    1. 感觉dsh提的issue怪怪的😂算了你们看着办吧

    其实我刚才的回复也是dsh写的嘿嘿

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions