Skip to content

[Bug] 会话异常中断后不可续跑:轮次被标记为 interrupted 却从不进入恢复队列(37 条 interrupted / 0 条可续跑投影 / 17 次启动恢复数全为 0) #90

Description

@libra-sys

一句话摘要

Qoder 已经实现了续跑所需的全部零件——CLI 的 --resume、中断恢复队列表 pending_turn_projections、轮次状态字段(含 interrupted 取值)、启动时的 recoveredInterruptedTurnCount 打点——但本机可查的全部历史显示:被明确标记为 interrupted 的 37 条轮次,没有一条进入过恢复队列,重启后也没有恢复过任何一轮。其中 2 条自 2026-06-19 起一直停在 interrupted,跨越此后所有次启动。会话历史本身是完整落库的(单会话最多 393 条消息),所以这不是"数据丢了",而是恢复链路没接上

环境

Qoder 桌面版 0.4.1(同机曾有 0.3.4 / 0.3.3,见 I:\qoder\.qoder-versions\
Agent CLI cli_version: 1.1.57
Node v24.18.0
OS Windows 11 25H2(build 10.0.26200),x64
数据目录 %APPDATA%\com.qoder.app.stable
Run 日志 ~/.qoder/logs/runs/,本机留存 1116 个 run,跨度 2026-09-18 → 2026-09-23
采集时刻 2026-09-23 21:50 (+08:00),以下命令均为只读

期望 vs 实际

期望:turn 执行中被杀死(worker 崩溃、App 重启、自动更新重启)后,该轮次进入恢复队列,重启时自动续跑,或至少向用户提供"继续这一轮"的入口。

实际:轮次被写成 interrupted 终态就停在那里。恢复队列 0 行,重启恢复计数 0,用户侧只能从头重跑。

取证 A:状态机知道"这一轮被打断了",但恢复队列为空

import { DatabaseSync } from 'node:sqlite';

const buf  = new DatabaseSync('%APPDATA%/com.qoder.app.stable/chat-session-turn-payload-buffer.sqlite', { readOnly: true });
const main = new DatabaseSync('%APPDATA%/com.qoder.app.stable/main.sqlite', { readOnly: true });

buf.prepare('select count(*) n from pending_turn_projections').get();        // { n: 0 }   ← 恢复队列
buf.prepare('select count(*) n from active_turn_projection_items').get();    // { n: 6 }   ← 正在流的会话确有写入
main.prepare('select count(*) n from chat_session_turn_states').get();       // { n: 0 }
main.prepare('select count(*) n from chat_sessions').get();                  // { n: 93 }
main.prepare('select count(*) n from chat_session_messages').get();          // { n: 3887 }
main.prepare('select status, count(*) n from chat_session_messages group by status order by n desc').all();
// [ { status: 'completed', n: 3804 }, { status: 'failed', n: 46 }, { status: 'interrupted', n: 37 } ]

要点:

  • pending_turn_projections 的 schema 本身就是为续跑准备的:
    session_id, turn_id, message_id, source, message_json, message_status, state, attempt_count, created_at, updated_at —— 现在 0 行
  • 同一时刻 chat_session_messages 里有 37 条 status='interrupted':App 自己判定并记录了"这一轮是被打断的",但没有任何一行投影进恢复队列。
  • 同库的 active_turn_projection_items 有 6 行,可排除"连错库 / 写入路径不通"这类误判。

取证 B:17 个会话的最后一轮至今停在非终态

select s.session_id, m.status, s.model, s.updated_at
from chat_sessions s
join (select session_id, status,
             row_number() over (partition by session_id order by sequence desc) rn
      from chat_session_messages) m
  on m.session_id = s.session_id and m.rn = 1
where m.status in ('interrupted','failed')
order by s.updated_at desc;

返回 17 行(会话标题已去除,避免暴露本地项目名):

session_id 前缀 末轮状态 最后更新 (UTC) 模型通道
27fc0bfb interrupted 2026-09-23 13:06 BYOK
cb28e5eb failed 2026-09-23 13:03 BYOK
4726c108 failed 2026-09-23 12:45 BYOK
8b6a786b failed 2026-09-23 12:06 BYOK
a7725915 interrupted 2026-09-22 10:17 BYOK
2fa8e715 interrupted 2026-09-22 08:44 官方 qfmodel
eeb6aeec 8361ed71 88cd72ac 6ac4c466 f01266b0 21c36be6 9ba76c02 failed 2026-09-22 08:43 官方 qfmodel(7 个会话同一时刻)
691d1ef1 interrupted 2026-09-22 00:58 BYOK
5ff997c2 interrupted 2026-09-20 13:32 官方 qfmodel
57b64f75 interrupted 2026-06-19 16:07 auto
9072e5a1 interrupted 2026-06-19 16:06 auto

最后两行是本 issue 最硬的证据:这两条 interrupted 距今约 3 个月,期间本机至少启动 17 次桌面实例,它们从未被恢复、也从未被清理。

取证 C:17 次启动,恢复计数全为 0

grep -ho 'Execution queue initialization completed {"recoveredInterruptedTurnCount":[0-9]*}' \
  "$APPDATA/com.qoder.app.stable/logs/"*/main.log | sort | uniq -c
# 17 Execution queue initialization completed {"recoveredInterruptedTurnCount":0}

覆盖实例(目录名前缀,UTC):

20260918-054551  20260918-054603  20260918-113857  20260918-131843  20260918-143028
20260921-032052  20260921-032139  20260921-111428  20260922-020345  20260922-090645
20260922-153631  20260923-032124  20260923-081713  20260923-082127  20260923-101045
20260923-130542  20260923-131410

同一窗口内有 103 个会话以 process.exiting exit_code=1 结束,其中 16 个是运行 ≥20 分钟的长会话(见取证 D)。也就是说"该入队的时机"至少出现过 16 次,队列却始终 0 行、恢复数始终 0。

取证 D:≥20 分钟被异常终止的长会话(run_id 清单)

~/.qoder/logs/runs/<run_id>/manifest.json 的 argv 带 --model,据此区分通道;qodercli.log 末行均为 exit_code=1

run_id 模型通道 存活 终止时刻 (+08:00) 退出原因
2026-09-20T21-32-24-286+08-00-fsi4wa-p18116 官方 qfmodel 20min 09-20 21:52:56 fatal_error
2026-09-21T11-23-13-567+08-00-bkdmv1-p24280 官方 qfmodel 275min 09-21 15:58:33 fatal_error
2026-09-21T19-20-22-155+08-00-gsn1ai-p8408 官方 qfmodel 41min 09-21 20:02:06 fatal_error
2026-09-21T19-28-31-979+08-00-qzyit4-p8408 BYOK 34min 09-21 20:02:25 fatal_error
2026-09-21T22-26-47-192+08-00-0fswgb-p8408 官方 qfmodel 636min 09-22 09:02:49 fatal_error
2026-09-21T22-34-48-859+08-00-0xakfu-p8408 官方 qfmodel 628min 09-22 09:02:49 uncaught_exception(worker runtime main()
2026-09-22T07-52-27-633+08-00-tnuuek-p8408 官方 qfmodel 24min 09-22 08:16:32 fatal_error
2026-09-22T10-05-26-971+08-00-8i59u4-p25008 官方 qfmodel 399min 09-22 16:44:18 fatal_error
2026-09-22T10-13-34-321+08-00-z1b88n-p25008 BYOK 77min 09-22 11:30:55 fatal_error
2026-09-22T13-23-48-448+08-00-p0r7fr-p25008 BYOK 31min 09-22 13:54:27 fatal_error
2026-09-22T17-07-34-598+08-00-59z4rv-p9504 BYOK 24min 09-22 17:32:01 fatal_error
2026-09-23T18-16-28-168+08-00-laibcn-p21636 BYOK 37min 09-23 18:53:14 fatal_error
2026-09-23T19-06-01-268+08-00-t1zyse-p21636 BYOK 20min 09-23 19:26:17 fatal_error
2026-09-23T20-06-44-652+08-00-crptze-p21636 BYOK 35min 09-23 20:42:27 fatal_error
2026-09-23T20-22-40-696+08-00-d7gfy5-p21636 BYOK 43min 09-23 21:05:39 fatal_error(紧随 0.4.1 自动更新重启)
2026-09-23T20-38-31-583+08-00-s211aq-p21636 BYOK 27min 09-23 21:05:39 fatal_error(同一次重启,同一秒)

典型收尾(...d7gfy5-p21636/qodercli.log):

2026-09-23T21:05:38.466+08:00 INFO  debug.message [HeadlessSession] stdio permission channel closed; failing closed in-flight permission asks
2026-09-23T21:05:39.573+08:00 WARN  process.exiting exit_code=1 reason="fatal_error" message="Headless session exited with error" source="gemini.exitHeadlessMode" uptime_ms=2578877

把 16 个终止时刻与取证 C 的实例启动时刻做 ±3 分钟对齐:只有 09-23 21:05 的 2 例(同一次自动更新重启)能对上,其余 14 例终止时桌面进程一直存活。所以"只有自动更新会杀会话"不成立,worker 自身失败路径同样会丢弃轮次。

取证 E:能力齐备,只是未接通

对 1116 个 run 的 manifest.json 统计 argv:

  • --resume80 次 —— CLI 层的续跑入口确实实现了;
  • --continue:0 次;
  • --no-session-persistence255 次(23%) —— 这类 run 从设计上不可续跑。想确认:后台/长任务是否也会落到这个开关上?如果是,它本身是一条独立成因。

取证 F:用 Qoder 自己的会话 API 读,历史和工具状态都在,但活动轮次是空的

调用 mcp__builtin__read_chat_session(即产品自带的会话读取接口,非我手工解析文件)读取取证 B 中的 5ff997c2

{
  "sessionId": "5ff997c2-...",
  "runtimeState": "cold",
  "activeTurnId": null,
  "queuedTurnCount": 0,
  "pendingInteractionCount": 0,
  "revision": 0,
  "messages": [
    { "role": "user",      "status": "completed",   "text": "(原文已省略:含本地项目名)" },
    { "role": "assistant", "status": "interrupted",
      "tools": [ { "name": "Glob", "status": "completed" },
                 { "name": "Bash", "status": "failed" } ] }
  ]
}

对照结论:

  • 会话本身没有丢runtimeState: cold(可再唤醒)、消息与每个工具调用的终态都还在。
  • 但那一轮没有任何续跑凭据activeTurnId: nullqueuedTurnCount: 0revision: 0(注意 chat_session_turn_states 表里正好有个 revision 列,而全表 0 行)。
  • 也就是说"恢复"所需的一切材料都齐了(历史 + 已完成到哪一步 + 哪个工具失败),只差把这轮重新挂回执行队列。用户此时只能重发一遍请求,而模型对"我刚才已经 Glob 到哪、哪个 Bash 失败了"没有任何凭据可依。

历史数据是在的(所以修复成本应该不高)

session 存活 DB 内消息数 status 分布 recap
c21a6f37 636min 73 completed 70 / interrupted 3
47ed4c11 628min 34 completed 29 / failed 5
01c060d6 275min 393 completed 382 / failed 1 / …
4726c108 43min 44 completed 37 / failed 2 / …

消息、序列号(max(sequence) 与条数一致)、recap 都在,16 个会话的 run 级转录文件也全部在盘。缺的就是"最后那一轮的可续跑投影"。

最小复现

  1. 新建会话,安排一个持续 ≥20 分钟的任务(例如让工具循环 sleep)。
  2. 任务进行中二选一:A. 强杀桌面进程(或等一次自动更新重启);B. 不动,等 worker 自身 exit_code=1
  3. 重启 Qoder。
  4. 查三处:recoveredInterruptedTurnCount 是否为 0;pending_turn_projections 是否出现该 turn_idchat_session_messages 里那一轮是否只是被写成 interrupted 就结束。

本机结果:第 4 步三项依次为 0 / 无 / 是

尚未证实(不想把没验证的写成结论)

  1. pending_turn_projections = 0 行 分不清"从未写入"与"正常收尾时被清空"(run 日志里找不到任何 projection 写入/清理痕迹,相关关键字 0 条命中)。但取证 B 里 2026-06-19 那两条 interrupted 至今 3 个月未被恢复、队列仍空,倾向于"入队这一步根本没发生"。请从代码侧确认。
  2. 我没有实测"重新发一条消息给这些会话,能否带回原有上下文继续",也没测 CLI 层手工 --resume <session_id> 的效果(前者会写入你的真实会话、后者需要真实模型调用与工具执行,代价与副作用都较高,计划作为补充实验更新在本 issue 下)。取证 F 只证明了"续跑所需的凭据没有被挂上",不证明"任何形式的手动续跑都不可能"。
  3. UI 是否有恢复入口:未验证。
  4. 取证 D 那 16 例各自的直接死因,日志未记 reason 字段(仅见死前 1.4–3.8 秒一次 submitMessage() done reason=error)。本 issue 不对死因归因,只主张"死亡之后不可恢复"这一件事。

影响

长任务是 Qoder 主打能力之一(后台会话、/goal、定时唤醒会话)。当前状态下中断代价是整轮作废,且用户不会收到"这一轮没有被保存"的提示。本机 6 天窗口内 16 个长会话累计 runHeadlessSession 存活 2351 分钟(约 39.2 小时) 作废(注:墙钟时长,含轮次间等待,非纯计算时间)。

建议的最小修复面

  1. turn 被判定中断时向 pending_turn_projections 落一行(schema 里的 state / attempt_count 看起来正为此准备);
  2. 启动时把 status='interrupted' 且无投影的行纳入恢复或提示路径,而不是静默停在终态;
  3. 对走 --no-session-persistence 的长任务补一条日志,明示该 run 不可续跑。

附件

  • 上述 16 个 run_idqodercli.log + manifest.json
  • 17 个实例的 main.log
  • 采集脚本(本报告全部数字由脚本产出,只读、无副作用)

这些日志仅通过官方私密渠道提供(应用内"问题反馈"或官方邮箱),不会上传到公开评论区。 原因是日志原文包含进程命令行参数(可能含第三方服务 token)、本机绝对路径与完整会话内容。任何人都可以用本文列出的只读命令在本地复算出同样的数字,无需依赖我的附件。

隐私处理

已去除:账号 ID、API key、会话标题(含本地项目路径)。BYOK 仅保留 byok(sensenova-6.8-flash-lite) 形式标识,profile_id 在正文中省略。

Activity

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