Article
按了 Esc 没反应:一个侧问怎么会锁死主任务
主任务正在跑,你想起一个题外话,敲 /btw 问一句。侧问结束,面板关掉,你想接着按 Esc 停掉主任务。
按了,没反应。
这是 blade-code v0.10.164 到 v0.10.167 之间处理的三个问题之一。三个修复加起来,生产代码新增 26 行,测试新增 2072 行。
说实话,这个比例乍看不太成比例,所以值得写一篇说明它为什么成立。
之前那篇《Claude Code 的 /btw》讲的是侧问怎么隔离:不写进对话历史、不占主任务的 context、不打断当前工作。这一篇讲隔离之后的事——侧问借走的那部分执行权,怎么在取消和失败的时候准确还回去。
侧问借走的到底是什么
在 blade-code 里,每个 Agent 持有一个 active-operation gate。chatStream() 在任务准入之前取得一份 lease,然后把组合出来的 AbortSignal 传进 provider streaming、工具执行、compaction、hooks 和 turn finalization。
也就是说,“这个任务可以被打断”这件事本身是一个被授予的对象,而不是一个全局标志位。侧问从主任务旁边借走的正是这个东西:它要能自己被打断,但它的取消不能顺手把主任务也带走。
docs/reference/runtime-shutdown.md 把整个关闭顺序写成了所有权边界:
关闭新工作入口
-> 中止 active work
-> 等待 terminal persistence
-> 释放 Session 资源
-> 停止 transport 与进程服务
顺序本身就是契约:先让工作停下来,再释放资源。 反过来做,就是让还在跑的东西拿着已经失效的所有权继续写副作用。
这三个修复,全都是这条顺序在侧问这条支线上的具体化。
取消侧问之后,Escape 为什么失灵了
a27f86e24b 只改了 6 行,改的是 TUI 的 useMainInput:
// 改动前
const abortCalledRef = useRef<boolean>(false);
useEffect(() => {
if (isProcessing) {
abortCalledRef.current = false;
}
}, [isProcessing]);
// 改动后
const abortCalledRef = useRef<boolean>(false);
const sideConversation = useSideConversation();
const sideAbortTarget =
sideConversation?.status === 'loading' ? sideConversation.requestId : undefined;
useEffect(() => {
abortCalledRef.current = false;
}, [isProcessing, sideAbortTarget]);
abortCalledRef 是一个防重复的闩锁:按一次 Esc 取消之后置位,避免同一次取消被按出好几次。旧代码试图在 isProcessing 变化时把它清掉。
问题在于取消侧问不会复位这个闩锁。这里还有一个容易忽略的细节:isProcessing 其实是两个条件的析取:主任务在跑,或者侧问在 loading。两条路径都不复位:主任务仍然忙碌时,这个值压根不变,effect 不重跑;主任务本来就空闲时,值虽然会变,但旧代码里 if (isProcessing) 的守卫让复位变成一次空操作。
结果是闩锁一直停在置位状态,下一次 Esc 被当成重复取消丢掉。侧问面板关了,主任务却再也停不下来。
新代码做了两件事。
第一,把“当前有没有一个正在 loading 的侧问”加进依赖,这样侧问出现、消失、被替换都会重跑 effect,闩锁随之复位。
第二,加进依赖的不是一个“忙不忙”的布尔值,而是侧问的 requestId:
sideConversation?.status === 'loading' ? sideConversation.requestId : undefined
这个选择决定了语义的精度。如果是布尔值,那么“从一个侧问切换到另一个侧问”期间 true 不变,effect 不重跑,新侧问同样按不动。用 requestId 就不一样:
- 同一个侧问重复按
Esc:requestId没变,闩锁保持置位,重复取消被去重; - 换了一个侧问:
requestId变了,闩锁复位,新目标可以取消; - 侧问结束:值变回
undefined,闩锁复位,Esc重新交还给主任务。
契约文档里那两句话说的就是这个:“重复取消按当前目标去重,不再把侧边请求和主轮次当成同一个忙碌阶段;替换侧边请求也会重新允许取消。”
旧实现把“忙”当成了唯一的状态维度,所以只要还忙,就认为目标没变。而这里其实有两个可以独立变化的目标:主轮次和侧问。去重或许应该按目标做,而不是按忙碌做。

对应到测试,describe('useMainInput cancellation ownership') 下面五个用例基本就是这个状态机的完整定义:
allows Escape to stop the main turn after dismissing its side question
allows Escape for a replacement side question while the main turn stays busy
allows Escape for the main turn when a side request settles without disappearing
deduplicates repeated Escape for the same side request across rerenders
deduplicates the main turn and rearms when a new busy period begins
五个用例、五个分支,没有一个是“看起来正常”就能覆盖的。
侧问的取消信号,不该跟着浏览器连接走
866c848a12 处理的是 Web 端,改动同样很小。侧问原来把自己挂在客户端连接上:
const result = await runtime.askSideQuestion(parsed.data.question, {
signal: c.req.raw.signal,
});
c.req.raw.signal 是这条 HTTP 请求自己的 signal:用户刷新页面、关掉标签、网络抖动,它都会 abort。用它当侧问的取消信号,等于让浏览器连接的健康状况决定服务端任务的生死。
修复把 admission lease 的 signal 透传出来。withAdmission 不再只是执行一个闭包,而是把 lease 自己的 signal 交给它:
// 改动前
return withAdmission(next);
// 改动后
return withAdmission((signal) => {
c.set('requestSignal', signal);
return next();
}, c.req.raw.signal);
侧问随后读的是这份 requestSignal,而不再直接读连接的 signal。提交信息把这层区分写得很直白:把 admission lease 信号转发给请求自己拥有的侧问,同时让主运行不受客户端断线影响。
这里的关键是两种中断被分开了:
| 中断来源 | 应该影响谁 |
|---|---|
| 客户端连接断开 | 只影响这次请求的准入,不该牵连主运行 |
| 侧问自己要被取消 | 影响这个侧问的 lease |
| 主任务被取消 | 影响主任务的 lease,并带动它下面的工作 |
用连接 signal 的写法把前两者混成了一个。而当服务端开始 shutdown 时,这个混淆会变得很危险:关闭流程要求“不再接纳新工作 → 中止 active work”,可如果侧问的 signal 早就随着某个断开的连接 abort 了,它就可能在所有权还没轮到它的时候就先死了;反过来,如果连接一直活着,它又可能拖过 drain 阶段。

preparation 失败时,另一个 promise 还在跑
第三个修复 74bb8fcb2e 只有 8 行,但它是三个里最典型的并发问题。
侧问在调用 provider 之前要做两件互不依赖的准备:读上下文文件、构建 system prompt。原来它们并行跑,然后一起 await:
const [messages, builtPrompt] = await Promise.all([
this.loadModelContext(),
buildSystemPrompt({ /* ... */ }),
]);
Promise.all 的语义是首个 rejection 立刻抛出。这时另一个 promise 并没有被取消,它还在跑。而在上层,抛出异常意味着 preparation 阶段结束,接下来就是释放执行器和会话租约。
于是出现这样一个窗口:所有权已经还回去了,一个没人等的 preparation 还在继续跑。
修复把它改成先记住这两个 promise,失败时先排空再抛:
const preparation = [
this.loadModelContext(),
buildSystemPrompt({ /* ... */ }),
] as const;
const [messages, builtPrompt] = await Promise.all(preparation).catch(
async (error: unknown) => {
await Promise.allSettled(preparation);
throw error;
}
);
Promise.allSettled 等两个都落地,不论成功还是失败,然后重新抛出最初那个异常。异常内容没变,调用方看到的行为没变,唯一变的是抛出时机:从“第一个失败时”推迟到“两个都停下来之后”。
这就是 drain failed side preparation before releasing ownership 这个提交信息的含义。文档里对应的条款是:
侧边上下文与系统提示并行准备。一项失败时,保留首个异常,但仍等待已启动的另一项结束后才释放执行器和会话租约;失败请求不会调用 Provider。
同一份契约还把边界写在了旁边:
上下文文件读取仍等待自身结束,再释放 Runtime 所有权;本修复不承诺中断任意阻塞的文件系统调用或 Runtime 初始化。
这一句是更早一个修复(554cff4828,处理 MCP 目录等待那次)留下的,74bb8fcb2e 紧接着在它后面补上了并行准备那一条。两句话放在一起,把“我们保证了什么”和“我们没保证什么”分开了。Promise.allSettled 能保证的是“等它结束”,不是“让它结束”。如果那个文件读取卡在一个不可中断的 IO 上,所有权依然要等到它返回。写清楚这一点,比让读者以为 drain 是万能的要好。
2072 行测试在测什么
回到开头那个比例。三个修复的测试分布是:
| 提交 | 生产代码 | 测试代码 |
|---|---|---|
866c848a12 Web 侧问取消 | session.ts +12/-5 | trajectory +479,session-routes +306 |
a27f86e24b Escape 重新武装 | useMainInput.ts +6/-6 | runner +483,trajectory +157,useMainInput +187,command-input +1 |
74bb8fcb2e preparation 排空 | SessionRuntime.ts +8/-2 | trajectory +307,session-runtime +152 |
生产代码新增 26 行,测试新增 2072 行。这个比例之所以成立,是因为这些测试锁的是时序,而时序读代码是读不出来的。
这些 bug 没有一个能靠“调用函数然后断言返回值”发现,它们平时不显山露水,只在特定时序下才现形。Escape 失灵需要:主任务在跑、侧问在 loading、用户按 Esc 取消侧问、面板关闭、再按一次 Esc,五步里的状态组合。preparation 排空需要让其中一个 promise 失败、另一个慢到能观察到它还在跑。
所以测试用了两种不一样的手段。
一种是单元级的显式状态推演。useMainInput.test.tsx 里那 187 行做的是构造侧问和主任务的各种状态组合,包括“侧问结束但请求对象没有消失”这种真实代码里很容易漏掉的形状。
另一种是真实进程轨迹。sideConversationCancellationRunner.ts 这 483 行是一个可以以两种 surface 启动的驱动:
const Input = Type.Object({
surface: Type.Union([Type.Literal('pty'), Type.Literal('acp')]),
cliEntry: Type.String(),
workspace: Type.String(),
// ...
holdFile: Type.String(),
releaseFile: Type.String(),
traceFile: Type.String(),
pidFile: Type.String(),
marker: Type.String(),
});
注意 holdFile / releaseFile / pidFile 这几个字段。它用文件当握手信号,把真实进程卡在指定的时刻:让被测进程写一个文件表示“我已经进到 preparation 了”,测试进程看到之后注入取消,再写另一个文件放行。这样并发窗口就不是靠 setTimeout 猜出来的,而是被精确地停在那个瞬间。
它同时验证 PTY 和 ACP 两种 surface,并且用 captureProcessIdentity / processIdentityMatches 校验进程身份、扫描进程输出里是否泄露了密钥。这些都不是“断言一个返回值”能覆盖的东西。
代价是很实在的:这类测试跑得慢,依赖真实 provider(在 integration/real-api/ 下),还要处理命名管道只在部分系统可用的问题,所以 FIFO 场景有平台限制,确定性单测另外覆盖两侧失败和迟到失败。毕竟真实进程测试,本来就没有纯单测那么轻省。
用 26 行换 2072 行,换来的是这 26 行在几十种时序组合下都不会把所有权还早,跟实现是否聪明关系不大。
文档先写,代码后跟
这三次修复里,docs/reference/runtime-shutdown.md 的中英两版每次都被一起改,包括 74bb8fcb2e 那次只加了 5 行文档。
其中一段是这次新增的:
侧边提问在准备前及上下文准备完成后检查取消,不会把已经取消的请求送入 Provider。
另外一段把 TUI 的行为定死在文字里:
主任务运行中提问
/btw时,第一次Esc只取消侧边提问;侧边面板关闭后,下一次Esc可以停止主任务。
我越来越觉得这份文档比实现更值得先写。原因很直接:“第一次 Esc 取消谁、第二次 Esc 取消谁”这种事,在代码里散落在几个 effect 和闩锁之间,只有写成一句人话,才判断得出它对不对。 上面那个 bug 之所以能藏住,或许就是因为没有人先把它写成一句话。一旦写出来,就会立刻发现“取消侧问之后按 Esc 应该有用”和“代码只在 isProcessing 变化时复位”是对不上的。
还留着的一个口子
三个修复里,只有第三个真正改了并发行为(Promise.all 换成先排空再抛),另外两个改的都是状态的归属:Escape 属于谁、取消信号属于谁。26 行里大部分只是把这层归属关系写对——而这三个 bug 也全都出在归属上,不在算法上。
2072 行测试锁的正是这层归属关系,免得它被别的改动悄悄改回去。
至于那个卡在不可中断 IO 上的读取该由谁负责,目前还没有答案。契约里写的是「等它结束」,如果它永远不结束,所有权就一直不还。这个口子暂时留着,因为还没找到更好的做法。
Keep Reading