后台任务没有闹钟:嵌套 agent 里的「睡下等叫醒」为什么会把结果送错门

能被叫醒的才可以去睡;后台任务一睡,就再也没人来敲门了。
1. 「结束回合,等事件叫醒」是个好模式——在对话层⌗
agent 系统里有一类经典难题:派出去的活要很久才回来,主线程怎么等?
MaxClaw 对话层的答案是不等。task_start 派出一个后台任务后立刻返回,工具结果里的提示写得很直白:
后台运行中;结束时结果会以 task-result 事件送回并唤醒本线程。
不要查询、不要等待,接着做别的或结束本 turn。
要一次收齐多个任务,就用 task_wait:登记一个等待组,工具返回 terminal: true,框架在这里结束当前回合;组里最后一个任务结束时,一条 task-wait-done 事件唤醒线程一次。
这套设计的好处很实在:
| 做法 | 等待期间占着什么 | 进程重启时 |
|---|---|---|
| 回合内轮询 / sleep | 一个活着的 LLM 回合、上下文、超时预算 | 回合被打断,得靠续跑 |
| 结束回合 + 事件唤醒 | 什么都不占,只有一行等待登记 | 登记在库里,结果照样投递 |
对话线程有一个外部的「闹钟」:结果事件到了,框架会为它开新回合。所以它可以放心去睡。
问题出在:任务也能派任务。
2. 嵌套之后,三件事同时错位⌗
后台任务拥有完整的 agent loop 与全部工具,自然也能调用 task_start。设想一个负责审查的任务,想先派一个子任务去收集证据,再在此基础上下结论。修复前会发生这样的事:
修复前
对话 C ──task_start──▶ 任务 P(worker 回合,channelKey = C)
│
├─ task_start(子任务 K) → K 的 owner 记成 C ①
├─ task_wait([K]) → 在 C 上登记等待
│ 返回 terminal: true ②
└─ 回合结束,P 交卷(没有结论)
K 完成 ──task-result──▶ 唤醒 C ③
(C 收到一份它没派过的子结果,P 早已结束)
逐条看:
① 归属错位。 任务回合的 ToolContext.channelKey 是 owner 的——这是刻意的:确认门、shell 来源、附件投递都应该归到真正有人在看的那个对话。代码里的注释原话是「worker 回合的 channelKey 是 owner 的」。但 task_start 正是用 channelKey 来记录「这个子任务是谁派的」,于是 P 派的 K 被记在了 C 名下。
② 回合被提前收走。 task_wait 在 P 里照样返回 terminal: true。对一个对话线程,这表示「睡下等唤醒」;对一个后台任务,回合结束就是这一轮任务的终点,它的最后输出会作为 task-result 送回给发起方。没有任何东西会在 K 完成后为 P 再开一轮。
③ 结果漏到祖父。 K 的结果按 owner 投递,以 wake 模式唤醒 C。C 面对的是一份来路不明的中间产物,而真正需要它的 P 已经不在了。
三处都不是 bug 意义上的「写错了一行」,而是同一个模型被用到了不该用的层:「结束回合等唤醒」默认了等待者是能被唤醒的对话。
3. 修复的核心判断:等待方式由「谁会被唤醒」决定⌗
提交 157df46 没有给任务也造一个唤醒机制,而是承认一个事实:任务的回合本身就是它的生命,等待只能在回合里完成。
修复后
对话 C ──task_start──▶ 任务 P(worker 回合)
│
├─ task_start(K) → K 的 owner = subagent:P
├─ task_wait([K]) → 在本回合内阻塞
│ ⋮ (可被停止/重启打断)
│ K 完成 → 结果进 P 的 boundary 队列(不唤醒谁)
│ ⋮
├─ task_wait 返回 { status: complete, results: [...] }
└─ P 综合结论 ──task-result──▶ 唤醒 C(只有这一条)
具体落在四个地方。
3.1 归属:只对任务工具改写 channelKey⌗
skills/tasks/tools.ts 没有动 worker 回合的 channelKey,而是给整组任务工具包了一层:
export const tools = [startTool, messageTool, waitTool, statusTool, askTool, stopTool, mergeTool, removeTool].map(tool => ({
...tool,
async execute(params, context) {
if (context?.executionScope === 'worker') {
const generation = await getSpawnDeps().memory.threads.sessions.get(context.sessionId)
if (!generation) return failure(new Error('任务当前代不存在'))
context = { ...context, channelKey: generation.threadId }
}
return tool.execute(params, context)
},
}))
这个范围选得很克制。同一个回合里,「谁替人类做主」和「谁拥有这个子任务」是两个问题,答案不同:
| 关注点 | 应该归属到 | 由什么决定 |
|---|---|---|
| 确认门、附件投递、shell 来源 | 有人在看的对话 C | worker 回合原有的 channelKey |
| 子任务的 owner、等待组、结果投递 | 任务自己的线程 P | 任务工具外层改写后的 channelKey |
如果一刀切把 worker 回合的 channelKey 换成任务线程,确认卡片会弹到一个没人看的线程里——修了一个归属错位,造出另一个。只包任务工具,就只改了「所有权」这一维。
顺带,createIdentity 在 worker 里派子任务时,冻结的 Tool Host 取父任务本轮的 ctx.turn.toolHost,而不是再去解析祖父对话当前绑定的 host:子任务跟着父任务的机器走,不会因为对话那边切了 host 而漂移。
3.2 投递:任务 owner 一律走 boundary,不走 wake⌗
src/dispatch/task-results.ts 里投递模式多了一个条件:
mode: member || result.owner.startsWith('subagent:') ? 'boundary' : 'wake'
两种模式的语义:
| 模式 | 行为 | 适合谁 |
|---|---|---|
wake | 为 owner 开一个新回合 | 对话线程 |
boundary | 写进 owner 的 thread_pending_events 队列,在它下一个回合边界落成历史行 | 正在跑、不该被另开回合的线程 |
owner 是任务时,「开新回合」本身就是错的动作,所以统一走 boundary。另外,投递失败时对任务 owner 会把错误抛出去,而不是像对话那样只记一笔「丢失」:因为下一节的阻塞等待正是在等这次投递完成,吞掉错误就等于让等待者拿到一个假的「完成」。
3.3 等待:回合内阻塞,等的是「投递完成」而不是「任务结束」⌗
waitForTasks 在 owner 是 subagent: 时走一条新分支。关键是它等的对象:
const pendingDeliveries = new Map<string, Promise<void>>()
// attachBackgroundDelivery 里:
const delivery = done.then(async (...) => { /* 写 task-result 事件 */ })
pendingDeliveries.set(identity.id, delivery)
每个后台轮次的「结果投递」被保存成一个 promise。task_wait 等的是这个 promise,而不是轮询任务状态。区别在于时序:任务状态变成 succeeded 的那一刻,结果事件未必已经写进队列;等投递 promise 就保证返回时结果一定已经在队列里,读得到。
返回前,它从任务自己的 boundary 队列里取结果,并按 runId 过滤:
results: events.flatMap(event => {
const identity = identities.find(item => taskThreadIdOf(item.id) === event.sourceMeta.taskThreadId)
const task = tasks.find(item => item?.threadId === event.sourceMeta.taskThreadId)
if (event.kind !== 'task-result' || !identity || event.sourceMeta.runId !== task?.runId) return []
return [{ task: identity.id, body: event.body }]
}),
runId 过滤挡掉的是同一子任务上一轮遗留的旧结果。这些事件不会被 task_wait 消费掉,它们照常在 P 的下一个回合边界落成历史——所以子结果既作为工具返回值交给模型,也留在了任务自己的对话记录里。
返回值里没有 terminal。这是整个修复的那一个比特:模型拿到结果,继续干活。
3.4 中断:停的是等待,不是子任务⌗
阻塞等待最容易出事的地方是「等不下去的时候怎么办」。waitForDeliveries 挂在回合的 abortSignal 上:
const abort = () => reject(new Error('任务等待已中断;下级任务未被取消,可再次调用 task_wait。'))
停止、超时、重启前的 drain 都会触发这个 signal。等待立即失败返回,但子任务继续跑。父任务之后可以再调一次 task_wait:如果子任务那时已经结束、本进程里没有待完成的投递,就直接从队列里读结果;如果子任务显示还在跑、本进程里却没有它的轮次,则 fail-loud:
任务 <id> 没有可等待的本机轮次(running)
而不是登记一个永远不会兑现的等待。
4. 一个附带的收紧:空输出不算完成⌗
修复前有个漏网之鱼:任务回合如果调用过工具、最后输出为空,会被记为 completed。旧的判定只拦「0 次工具调用且只有框架上下文」的回合。第 2 节的场景正好命中这个洞——P 调用了 task_start 和 task_wait,然后回合以空输出结束,看起来像一次正常完成。
新的判定:
if (record.status === 'completed' && (!result.response.trim() || toolCallsStarted === 0 && isContextOnlyTaskResponse(result.response))) {
record.status = 'failed'
没有产出结果的任务就是失败,不论它中途调用了多少工具。这条让同类问题以后不会再静默通过:即使别的路径再让一个任务提前交白卷,发起方收到的也是 failed,而不是一份空的 succeeded。
5. 测试就是规格⌗
src/dispatch/task-runner.test.ts 新增三条,各自钉住一个承诺:
| 测试 | 钉住的行为 |
|---|---|
| a nested task waits in its own turn and receives its child result, not the grandparent | 子任务 owner 是 subagent:<父>;等待期间祖父对话收到 0 条投递;task_wait 返回值无 terminal、含子结果;父任务结束后祖父只收到 1 条,内容是父任务的综合结论;子结果同时在父任务的 boundary 队列里 |
| interrupting a nested wait leaves its child running and a later wait gets its failure | abort 后等待返回 success: false,子任务仍是 running;子任务失败后再等,拿到 complete 且结果里有失败原因;清掉进程内任务活动状态(模拟重启)后再等,结果依旧可读;全程祖父 0 条投递 |
| an empty tool-backed task is failed, not completed | 调用过工具但输出为空的任务记为 failed |
第一条测试里有个细节值得一提:它先把祖父对话设成「忙」,等父任务跑完再放开,然后断言祖父只收到一条。这正是在检验「子结果不会漏上去」——如果漏了,祖父空闲后会收到两条。
6. 代价与边界⌗
这个设计不是没有成本,值得写清楚:
| 取舍 | 说明 |
|---|---|
| 等待占着回合 | 父任务在等待期间持有一个活着的回合,受任务总时长上限约束(后台任务默认 2 小时)。子任务比这个更久,父任务会超时。对话层「睡下不占资源」的优点在任务层放弃了 |
| 投递 promise 在内存里 | pendingDeliveries 是进程内的 Map,initTaskRunner 启动时清空。重启后靠任务状态和 boundary 队列重建可读性;子任务仍在跑而本机没有其轮次时,选择报错而不是猜 |
| 只改任务工具的归属 | 依赖「任务相关工具都在 tasks skill 里」这个边界。以后如果别处新增按 channelKey 记录所有权的工具,需要同样处理 |
我认为第一条是正确的代价。另一条路是让任务也能「睡下等唤醒」:结束回合,子结果到了再为任务开新一轮。但那意味着一个任务的「结束」不再是结束——发起方无法区分「这一轮交卷了」和「这一轮暂停等子任务」,任务结果语义会被拆成两种,所有消费 task-result 的地方都要跟着改。相比之下,让任务在回合里老老实实地等,保住了「任务回合结束 = 产出一个结果」这个不变量。
7. 一句话⌗
「结束回合等唤醒」是一种只对拥有闹钟的人成立的等待方式。 对话线程有闹钟——框架会为到达的事件开回合;后台任务没有,它的回合结束就是交卷。所以嵌套 agent 里,任务必须拥有自己派出的子任务,并在当前回合内阻塞收齐结果;而确认、附件这些「替人做主」的事情,仍然归那个有人在看的对话。把这两种归属拆开,是这次修复真正做对的地方。