一句话核心
在使用 OpenCode 派发后台 subagent 时有个经典痛点:如果我打断了后台任务,前台的 primary agent 就看不到 subagent 的输出。我在排查这个问题的过程中发现了一个反直觉的事实——subagent 的输出从来没有丢过,它一直在 OpenCode 的 SQLite 数据库里,只是前台的 agent 没去查。
于是我把这个发现做成了一键恢复的技能:
背景:后台任务协议
OpenCode(以及基于它的 OhMyOpenCode)的后台任务有一个标准协议:
launch bg_xxx → 结束回合 → 等 <system-reminder> → background_output(bg_xxx) 取回结果这个协议在不被干扰时工作得很好。但一旦打断,链路就断了:
- 回合被 abort——新回合的上下文里没有上一回合派发的
bg_任务 ID; - 通知协议断裂——primary agent 不知道”有结果待取”;
- 即使拿到 ID 调
background_output,被取消的任务也只返回元数据(状态表 + 原始 prompt),没有内容。
结果就是:后台 subagent 明明跑出了东西(甚至在你打断前已经产出了一大段),前台却表现为”看不见”。
调查:输出到底存哪了
直觉告诉我:UI 上能看到 subagent 的输出,说明它一定被持久化了。顺着这个思路,我在 ~/.local/share/opencode/ 里找到了主存储 opencode.db(一个 11GB 的 SQLite)。
数据结构非常清晰:
| 表 | 作用 |
|---|---|
session | 所有会话;parent_id 列把 subagent 挂到主会话下 |
message | 每个会话的消息 |
part | 每条消息的每个部分:text / reasoning / tool / step-start / step-finish |
关键在 part 表:工具调用(type=tool)的完整输出存在 state.output 里,即使命令被用户中止,中止元数据也在。我实测了一个被打断的 bash 调用:
{
"type": "tool",
"tool": "bash",
"state": {
"status": "completed",
"input": { "command": "sleep 30" },
"output": "(no output)\n\n<shell_metadata>\nUser aborted the command\n</shell_metadata>"
}
}所以真相是:打断不丢数据,一切都在库里。真正的问题是前台 agent 不知道去哪查、用什么 ID 查。
顺带挖出的 opencode task 机制
在开发这个技能的过程中,我读了 OpenCode 1.18.25 的源码(packages/opencode/src/tool/task.ts),发现两种派发模式有完全不同的打断语义——这也解释了之前观察到的所有”矛盾”。
前台 vs 后台:两种模式
前台 run_in_background=false | 后台 run_in_background=true | |
|---|---|---|
| 行为 | 阻塞等待 subagent 完成,返回最终结果 | 立即返回,完成后 system-reminder 通知 |
| 打断前台时 subagent | 被取消 | 继续运行 |
| 取消机制 | ctx.abort → ops.cancel(subagent) | 无 abort 连线 |
前台模式的核心源码——这就是”打断前台也会打断 subagent”的原因:
// 前台等待期间,前台 abort 信号直接取消 subagent
function onAbort() {
runCancel.fork(cancel)
}
// 释放阶段:若前台被中断,显式取消任务
if (Exit.hasInterrupts(exit))
yield* Effect.all([cancel, background.cancel(nextSession.id)])而后台模式没有这套连线——subagent 是解耦的独立任务(BackgroundJob),前台中断与它无关,它会一直跑到完成。要真正停掉后台任务,只能走 UI 的取消按钮或 background.cancel。
注:
bg_任务 ID 和background_output/background_cancel工具是 oh-my-openagent 插件层在 OpenCode 原生BackgroundJob(job ID 即 session ID)之上的包装。后台模式需要OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true。
实测对照
我做了四组对照实验验证这套语义:
| 派发模式 | 前台中断后 | 最终结果 |
|---|---|---|
| 后台 · tick 循环(180 tick) | 无人打断 | 跑完 180 tick(3m3s) |
| 后台 · 长作文(8000 字) | 打断前台 | 继续跑完 10,860 字 |
| 后台 · tick 循环(300 tick) | 打断前台 | 继续跑完 300 tick(5m4s) |
| 前台 · 长作文(8000 字) | 打断前台 | 立即停住,断在句子中间,部分输出完整可读 |
最有力的一次是前台模式的长作文测试:作文断在”人类对秩序的需要植根于存在”——一个未写完的句子,证明打断确实在写作中途掐断了它,而前面的约 1600 字全部可以通过技能完整读回。
解决方案:恢复技能
理解了存储结构,解决方案就很简单了:不需要预先记住任务 ID——subagent 会话通过 parent_id 天然挂在主会话下,打断后按 parent_id 一查就能枚举出自己派发的全部 subagent。
-- 1. 枚举我的所有 subagent
SELECT id, title FROM session WHERE parent_id = '<我的会话ID>';
-- 2. 读某个 subagent 的完整输出(含工具调用)
SELECT data FROM part WHERE session_id = '<subagent ID>' ORDER BY time_created;技能封装了四个命令:
S=~/.agents/skills/recovering-subagent-output/scripts/recover-subagent.py
python3 $S list # 当前会话的全部 subagent + 状态标记
python3 $S read <session_id> # 完整 transcript,含每次工具调用的 stdout/stderr
python3 $S search <关键字> # 按标题找会话
python3 $S --session <parent_id> list # 指定父会话状态检测逻辑:工具输出含 User aborted → aborted(被中止);会话无 step-finish 收尾 → cut-off(中途截止);正常结束 → completed。
使用场景
- 打断后台任务后捞回部分输出——前台 agent 在全新回合(没有
bg_ID)里,用list枚举 →read拉回打断前的全部内容; - 审计 subagent 干了什么——任何 subagent 会话的完整工具调用记录(含命令和输出)都在库里,可离线排查;
- 多任务协调——派发多个后台任务后按
parent_id统一枚举、逐个取回,不依赖完成通知; - 调试 OpenCode 自身行为——
part表里存了 reasoning、step 生命周期,可用于理解 Agent 的决策过程。
小结
这个技能的开发过程本身也是一次”Agent 调试 Agent”的实践:问题出在 agent 的上下文协议上,答案藏在 agent 自己的数据存储里,最终用一把 SQLite 查询把它变成了一键恢复的能力。如果你也遇到过”后台任务输出不见了”的困惑,希望这个技能和这份 task 机制分析能帮到你。
仓库地址:7emotions/recovering-subagent-output,MIT 协议,欢迎使用和提 issue。