qwen-code Daemon 会话恢复:ask_user_question 挂起问题跨重启还原(--restore-ask-user-question)设计解析
【免费下载链接】qwen-codeAn open-source AI coding agent that lives in your terminal.项目地址: https://gitcode.com/GitHub_Trending/qw/qwen-code
ask_user_question是 qwen-code 中模型在执行过程中向用户提问的 HITL(Human-in-the-Loop)机制,其提问状态保存在 daemon 进程内存中。一旦 daemon 重启,session/load与session/resume会把会话末尾未回答的问题当作"失败的工具调用"关闭(orphan repair +finalizeDangling),导致用户与模型之间的实时问答被静默丢弃。本设计文档围绕--restore-ask-user-question开关展开,讲述如何在加载/恢复会话时把尾部未回答的ask_user_question用新的 requestId重新挂起(re-hang),让原有权限投票路由继续提交答案、真实函数响应回到模型、同一轮对话无缝续跑。读完本文,你将掌握该功能的资格判定规则、开关配置方式、五步运行时调用链,以及它与continueLastTurn、orphan repair、transcript replay 等既有机制的边界。
问题背景:内存态 HITL 与重启后的孤儿问题
在 daemon 架构下,ask_user_question的整个"提问—等待—回答"生命周期都是进程内存态的:daemon 通过 bridge 向客户端发起权限请求,等待用户通过POST /session/:id/permission/:requestId提交投票。其核心矛盾在于:
- 旧
requestId在进程重启后不复存在,因此重启后无法再用原来的requestId完成那一次权限请求; session/load与session/resume在重建会话时会执行orphan repair(孤儿工具调用修复),把尾部悬挂的functionCall合成一条"失败的工具结果";- transcript replay 的
finalizeDangling会把该轮标记为已终结,UI 上表现为提问被静默关闭。
结果是:用户还没回答的问题,在 daemon 重启后被当作一次工具崩溃处理,HITL 交互彻底丢失。requestId不会被持久化,这一设计决策贯穿整个方案——恢复永远以"新 requestId、新超时时钟"开始,而不是尝试找回旧的权限状态。
方案目标与核心行为
当--restore-ask-user-question开启(默认关闭)且客户端加载或恢复该会话时,系统执行以下四项行为:
- 不合成失败工具结果:不再为该尾部
ask_user_question伪造一条错误函数响应; - 以新 requestId 重新发起
requestPermission:超时时钟从零开始,旧的内存态 requestId 被彻底抛弃; - 状态对外恢复可见:
GET /session/:id/status重新返回isWaitingForUserQuestion: true以及pendingInteractions,客户端 UI 可据此重新渲染提问卡片; - 投票路由复用:用户通过既有的
POST /session/:id/permission/:requestId权限投票路由提交答案,bridge 把真实函数响应写回模型,同一轮对话继续执行,而不是开启新的一轮。
需要特别强调的是:daemon 启动时不会扫描或自动恢复等待中的会话。恢复是"加载/恢复动作的副作用",只在客户端主动 load/resume 且满足全部资格条件时发生。
资格判定:什么情况下可以恢复
恢复不是无条件的。设计文档列出了一组严格的门槛条件,且这些条件与源码中的纯历史扫描逻辑一一对应(见 ask-user-question-restore.ts):
| 条件 | 说明 |
|---|---|
| 配置开关为 true | --restore-ask-user-question必须显式开启 |
历史最后一条是model轮 | 只有模型最后发出、尚未收到响应的工具调用才可能"悬挂" |
该轮所有带 id 的functionCall都是ask_user_question | 遍历last.parts,只要发现任一非 AUQ 的functionCall(如run_shell_command)立即返回不可恢复 |
参数解析为有效AskUserQuestionParams | 结构校验,非法参数 fail-closed 降级为失败工具结果 |
| 无混合悬挂工具 | bash + question这类混合批次整体留在 orphan repair 路径 |
| live 会话无进行中的 prompt | 已在等待时不得二次恢复,避免重复挂起 |
| load/resume 请求携带附加 client id | 没有 client id 就无人能回答重新挂起的问题;keepalive、boot rehydrate、子会话恢复均不带 |
会话不是本次调用中branchSession创建的 fork | fork 恢复不挂起 |
主会话限定:只有主会话(main session)可以运行ask_user_question,fork 与 subagent 本身就无法使用该工具,因此恢复同样只在主会话路径生效。
资格判定如何映射到源码
资格扫描是一个纯历史扫描(pure history scan),不构造工具实例,入口函数为findRestorableAskUserQuestion与restorableAskUserQuestionCallIds(ask-user-question-restore.ts):
export function findRestorableAskUserQuestion( last: Content | undefined, ): RestorableAskUserQuestion | undefined { if (last?.role !== 'model') return undefined; const functionCalls: FunctionCall[] = []; for (const part of last.parts ?? []) { const fc = part.functionCall; if (!fc?.id) continue; if (fc.name !== ToolNames.ASK_USER_QUESTION) return undefined; if (!parseAskUserQuestionParams(fc.args)) return undefined; functionCalls.push(fc); } if (functionCalls.length === 0) return undefined; return { functionCalls }; }该函数只取历史最后一条记录(如chat.peekLastHistoryEntry()),避免对整个历史做structuredClone——后者还会丢失normalizeModelToolCallIds附加在 Symbol key 上的 provider 工具调用 id。结构校验函数parseAskUserQuestionParams与AskUserQuestionTool.validateToolParams保持一致:questions数量必须在 1–4 之间、每题的question与header必须是非空字符串、options数量在 2–4 之间、每个 option 的label与description必须非空、multiSelect若存在必须是布尔值(askUserQuestion.ts)。
测试覆盖了这些边界(ask-user-question-restore.test.ts):
- 合法负载可恢复,返回 call id 集合;
- 混合悬挂工具(
run_shell_command+ask_user_question同一轮)不可恢复; - 无悬挂 model 轮(纯文本或已有 functionResponse)不可恢复;
- 非法参数 fail-closed:例如只提供一个 option 的题目,
findRestorableAskUserQuestion返回undefined,降级到失败工具结果路径(测试注释明确写了这一设计意图)。
开关配置:serve 与 ACP child 双入口
开关在 CLI 顶层选项与 serve 子命令中均有定义,默认值全部为false:
- serve 侧(serve.ts):
qwen serve --restore-ask-user-question- ACP child 侧(须与
--acp组合):
qwen --acp --restore-ask-user-question选项本体定义在 top-level-options.ts:type: 'boolean',default: false,描述为"在 daemon 会话 load/resume 时重新挂起尾部未回答的 ask_user_question,而不是合成失败工具结果"。
重要约束:child 侧标志仅在 ACP 模式下生效
CLI 配置测试明确锁定了这一行为(config.test.ts):
qwen --acp --restore-ask-user-question→getRestoreAskUserQuestion()为true;- 仅
qwen --restore-ask-user-question(无--acp)→ 仍为false(被忽略); qwen --acp不带标志 → 默认false。
设计文档解释了原因:在纯 TUI 模式下没有任何通道能重新挂起问题,若跳过 load 时的 orphan repair,恢复后的会话会被永久卡死(wedge)。因此 child 侧标志只在 ACP 模式下有意义。v1 版本不提供 settings.json key,也不提供 capability tag,只有命令行开关这一种配置途径。
从 serve 到 ACP child 的标志透传
当 serve daemon 需要 spawn 一个qwen --acp子进程(bridge 默认 spawn 或注入的 channel factory)时,标志通过 acp-child-extra-args.ts 透传:
export function acpChildExtraArgs(opts: { experimentalLsp?: boolean; restoreAskUserQuestion?: boolean; }): string[] | undefined { const extraArgs = [ ...(opts.experimentalLsp === true ? ['--experimental-lsp'] : []), ...(opts.restoreAskUserQuestion === true ? ['--restore-ask-user-question'] : []), ]; return extraArgs.length > 0 ? extraArgs : undefined; }对应的测试(acp-child-extra-args.test.ts、run-qwen-serve.test.ts、server-acp-child-extra-args.test.ts)验证了--restore-ask-user-question与--experimental-lsp一起合并转发到 ACP child,且 fast-path 上也保留该标志(fast-path.ts)。
为什么不能复用continueLastTurn
一个直觉方案是让用户通过通用的"继续上一轮"来回答,但设计文档明确否定了这条路:
- 通用的 continue 机制会把悬挂的
functionCall分类为interrupted_turn(中断轮),然后合成一条错误函数响应——把问题恢复成"工具崩溃"; - 若照此处理,HITL 交互就彻底丢失了。
因此恢复(restore)是一条独立被跟踪的 prompt 路径:当开关开启且尾部问题可恢复时,continueLastTurn会主动拒绝(返回accepted: false, interruption: none),而不是用合成失败去回答恢复的问题。也就是说,恢复与 continue 在调度入口处就分道扬镳,绝不允许两条路径互相污染。
运行时五步调用链
设计文档将恢复的完整生命周期拆成五步,每一步都有明确的源码落点:
第 1 步:startChat 选择性跳过 orphan repair
startChat在初始化历史时执行orphan_tool_use_repair(client.ts),其注释解释了背景:进程在processStreamResponse推送 partial tool_use 与 React 调度器提交 tool_result 之间崩溃时,会在 JSONL 中留下悬挂的model[functionCall],若不修复,resume 后的首次 API 调用会因tool_use_id ... corresponding tool_use报 400。
恢复路径的关键在于:当getRestoreAskUserQuestion()或getPreserveRestorableAskUserQuestion()为真时,先通过restorableAskUserQuestionCallIds(chat.peekLastHistoryEntry())算出可恢复的 call id 集合,再以preserveCallIds参数跳过对这些 call 的修复。而被抑制的恢复(无 client、fork 恢复)则与 replay finalization 保持步调一致地修复 Gemini 历史——通过Config.suppressRestorableAskUserQuestionPreservation()把 preserve 标志关掉(config.ts)。
此外,per-send 内联修复通道始终会关闭悬挂调用:一个普通 prompt 若先于恢复 prompt 到达,绝不能发送model[functionCall] → user[text]的形状(Anthropic 兼容 provider 会拒绝)。而恢复自身发送的是真实 functionResponse,因此该通道在恢复路径上是 no-op。
第 2 步:transcript replay 跳过 finalize
Transcript replay 的finalize()会跳过这些 call id,使 UI 保持 in-progress 状态。skip ids 有两个来源:
- live chat 已初始化时,来自
chat.peekLastHistoryEntry()的实时扫描; - 冷批量加载(
historyReplay: 'response'在startChat之前运行)时,来自 transcript 尾部的lastHistoryContentFromRecords——该函数从记录数组末尾向前跳过system类型记录,返回最后一条 API 可见内容(ask-user-question-restore.ts)。
skip 与 re-hang 必须保持步调一致:当 daemon 已知会拒绝恢复(无附加 client、fork 恢复)时,child 绑定的请求会携带qwen.daemon.suppressRestoreAskUserQuestion,child 既不提示也不跳过,replay 照常把问题 finalize 为失败。只读的qwen/session/loadUpdates始终 finalize,从不 re-hang。
第 3 步:child load/resume_meta携带恢复提示
当 argv 开关开启且会话符合资格时,child 的 load/resume_meta会包含qwen.daemon.restoreAskUserQuestion。默认关闭的路径永远不会调用恢复专用的 Session helper。这里的preserveRestorableAskUserQuestion字段(config.ts)默认跟随restoreAskUserQuestion,并在会真正 re-hang 的 load/resume 中保持打开。
第 4 步:bridge 准入与空闲闸门
bridge 只有在同时看到提示和 daemon 开关时,才准入一个带该 meta 的 trackedsendPrompt(准入方式与 continue 相同)。安全边界包括:
- 外部
POST /prompt无法伪造/夹带该 meta; - 准入还要求入口在触发时处于空闲状态:
promptActive、pendingPromptCount、goalTurnActive必须全部清零; - 准入失败只记日志并被吞掉——恢复是成功 load 的 best-effort 副作用,不阻塞加载本身。
第 5 步:Session.prompt() 重建工具并写回真实响应
Session.prompt()依次完成:重建工具 →requestPermission→ Submit 把真实函数响应写回 → 模型在同一轮继续执行。细节包括:
- 恢复续跑跳过 file-history 快照(它们不是用户轮次);
- 挂起的 worktree / recovered-agent 通知附加到回答后的消息上,且只有在该消息落地后才清除;
- Cancel 会持久化一次 decline(live decline 处理);
- 无人值守终止(
timeout、session_closed、abort)对整个批次不持久化任何东西:调用保持悬挂在 transcript 中,后续 load 可以再次 re-hang——这正是"恢复可重试"的容错设计。
状态可见性与投票路由的复用
恢复完成后,会话重新进入等待状态。会话列表路由把 live 状态投影到响应中(routes/session.ts):
isWaitingForUserQuestion: session.isWaitingForUserQuestion ?? false,GET /session/:id/status同样恢复isWaitingForUserQuestion与pendingInteractions的可见性(standalone-session-service.ts 对 live 状态做了投影)。
投票路由本身无需任何改动——POST /session/:id/permission/:requestId(routes/permission.ts)解析请求体与 client id 头,通过respondToSessionPermission把答案交给 bridge。权限策略由PermissionMediator按first-responder(首个有效投票胜出,当前默认)、designated(仅发起者可答)、consensus(N-of-M 仲裁)、local-only(仅回环客户端)四种契约裁决(permission.ts)。恢复只是以新 requestId 重新进入这套既有流程,因此权限审计环与旧 requestId 一样不跨重启持久化。
边界与明确不做的事(Out of scope)
设计文档划定了严格的边界,防止功能蔓延:
- 不做daemon 启动时的自动恢复(boot-time auto-resume)——恢复永远绑定客户端 load/resume 动作;
- 不做exec/edit 或其他非 AUQ 权限类型的恢复;
- 不做AUQ 与其他悬挂工具混合批次的恢复——混合批次整体走 orphan repair;
- 不改变通用
continueLastTurn的合成失败语义; - 不持久化旧
requestId或 permission audit ring。
小结
--restore-ask-user-question是 qwen-code daemon 为 HITL 交互补齐的最后一块拼图:它让"daemon 重启"从一次静默丢失用户问题的故障,变成一次可重试的挂起恢复。整个方案的工程要点可归纳为:
- 纯历史扫描判资格:
findRestorableAskUserQuestion只读最后一条 model 轮,零成本、fail-closed; - 双通道步调一致:orphan repair 的 preserve 与 replay 的 finalize skip 必须锁步,任何一侧失衡都会造成历史形状损坏或 UI 状态错乱;
- 准入严格:client id 必须存在、会话必须空闲、外部
POST /prompt无法夹带 meta; - 可重试容错:无人值守终止不持久化任何东西,调用保持悬挂,下次 load 可再次恢复。
若需在真实环境中启用,请在 daemon 侧qwen serve --restore-ask-user-question与 ACP child 侧qwen --acp --restore-ask-user-question同时配置,且牢记该标志在纯 TUI 模式下会被忽略——这是保证恢复会话不被卡死的设计底线。
【免费下载链接】qwen-codeAn open-source AI coding agent that lives in your terminal.项目地址: https://gitcode.com/GitHub_Trending/qw/qwen-code
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考