用一次多代理 workflow 的真实记录,讲清 fork 会复制什么、面板上的名字是谁写的、输出怎么对上任务。
我在 Claude Code 桌面端用 Workflow 工具跑一个多代理任务:先写代码,再找一个新代理审,再修一次,最后合进分支。跑到一半,我 fork 了一个新会话,然后冒出三个问题:
- fork 之后,正在跑的 workflow 会跟过来吗?
- 面板上那些名字,是后台有个小模型帮忙起的吗?
- 面板里的输出,是怎么和每一条任务对上的?
我查了本机的文件和会话记录。文中标 核实 的是我在本机文件里亲眼看到的;标 推断 的是看现象猜的,程序源码我看不到。
先说结论
- fork 只复制对话。正在跑的 workflow 不会跟过来,它留在原来那个会话里。代码目录也不复制,两个会话改的是同一份文件。side chat(
btw:)在磁盘上和 fork 一样,也是一个抄了对话的新会话。 - 名字不是小模型起的。workflow 卡片上的名字,是写脚本的模型写进脚本的。Finished 列表里的每一条,是跑命令的模型自己填的一句说明。
- 输出靠一个编号挂上去。每次调用工具都有一个 id,结果带着同一个 id 回来。后台命令还会多一个输出文件,所以面板能点开看;前台命令没有这个文件,面板就显示 "No output captured"。
一张图看懂这些记录放在哪
可以把它想成一个文件柜:一个项目一个抽屉,一个会话一个文件夹,里面再分小袋子。
发现一:fork 出来的会话复制了什么
看到什么
比喻:复印了一本笔记,换了一个新本子,但人还坐在同一张桌子前。笔记是对话记录,新本子是新的会话文件夹,桌子是代码目录。
- 核实 会话记录放在
~/.claude/projects/<项目路径转成短横线>/,按工作目录分开,例如-Users-apple-Desktop-work-rndmaster。 - 核实 每个会话有一个
<会话id>.jsonl(对话记录),加一个同名文件夹。 - 核实 原会话的文件夹里有
workflows/scripts/*.js(每次 workflow 的脚本),还有subagents/workflows/<run id>/。后者放着journal.jsonl和一堆agent-<id>.jsonl。 - 核实 fork 出来的新会话只复制了对话记录。它的文件夹里只有
subagents/和custom-title.json,没有workflows/。 - 核实 新会话有自己的草稿目录:
/private/tmp/claude-501/<项目>/<会话id>/scratchpad。 - 核实 代码目录不复制。两个会话改的是磁盘上同一份文件。
为什么
正在跑的 workflow 活在原会话那个进程里。fork 复制的是「说过的话」,不是「正在干的活」。
实例
我在新会话里试着接着跑(resume)那个 workflow,报错:
journal … is not on disk
可就在那时,原会话里的 fix:09 代理还在往自己的记录里写东西,30 秒前刚写过一行。也就是说,活没停,只是新会话找不到它的账本。
这时如果两边都去动同一个 worktree,就是两个人在同一张桌子上改同一张纸。
side chat 也是同一回事
桌面端还有一种「side chat」,标题以 btw: 开头。它和 fork 在磁盘上没有区别:都没有单独的接口,都只是一个新的会话 id,配一个新的 .jsonl 和同名文件夹。
- 核实 side chat 的标题记在
<会话id>/custom-title.json的customTitle里,例如{"customTitle":"btw: side chat 在文件路径里是什么接口"}。fork 的标题也记在这里,例如"Open IM 分析 (fork)"。 - 核实 行和行之间照样用
parentUuid串成链。行的type有user、assistant、attachment、api-request等。
比喻:把整本笔记从第一页起原样复印,再在复印本后面接着写。真名可以叫「全量复印 + 在分叉点之后续写」。我把三份文件逐行对了一遍:
| 会话 | 标题 | 和原会话逐行相同 | 第一条自己的行 |
|---|---|---|---|
原会话 f1b44925-… | 共 3,646 行 | — | |
fork 86ce7ad8-… | Open IM 分析 (fork) | 第 1–3,536 行 | 第 3,538 行 |
side chat 74f37069-… | btw: side chat 在文件路径里是什么接口 | 第 1–3,646 行 | 第 3,650 行 |
- 核实 从第 1 行开始抄,一字不差。抄来的行还带着原会话的
sessionId。side chat 一直抄到原会话的最后一行;fork 开得更早,抄到第 3,536 行。 - 核实 两者的第一条自己的行,都是
type=custom-title,也就是先记下自己的标题。中间夹着一两行没有sessionId的记录(例如bridge-session)。 - 核实 抄的是全量,不是从摘要开始。原会话中间压缩过 3 次(第 1,227、1,960、3,179 行是
compact_boundary),压缩之前的旧行也一起抄了。 - 推断 但模型看到的不是全量。恢复会话时,只加载最近一次压缩摘要之后的内容。所以 side chat 用起来像一个「新上下文」:它记得最近的工作,更早的只剩摘要。这一点从磁盘上看不出来,是根据压缩的设计推的。
所以 side chat 和 fork 的机制相同,区别只有两处:从哪一行分叉,标题前面有没有 btw:。它们都只抄对话,不抄原会话正在跑的 workflow、后台任务和它们的 journal,也就接不上原会话的 workflow。行号是我写这篇时数的,各个会话后来还在继续写。
发现二:面板上的名字是谁写的
看到什么
比喻:每个干活的人进门前,自己在登记本上写一句「我要去做什么」。没有专门的秘书替他们写。
| 面板上的位置 | 例子 | 从哪来 | 谁写的 |
|---|---|---|---|
| workflow 卡片标题 | im-multi-device-ticket-09 | 脚本里的 meta.name | 写脚本的模型 |
| 卡片下面那段描述 | Implement IM multi-device ticket 09 … | meta.description | 写脚本的模型 |
| 阶段名 | Implement / Verify / Fix | meta.phases 和 phase() | 写脚本的模型 |
| 代理标签 | impl:09 | agent() 的 label 参数 | 写脚本的模型 |
| Finished 列表的每一条 | Red check for lazy claw_peek | Bash 工具的 description 字段 | 跑这条命令的模型 |
为什么
- 核实 前四行都能在
workflows/scripts/*.js里原样找到。 - 核实 Bash 工具每次调用都必须填
description。工具说明要求用主动语态,大约 5–10 个词。面板上的那一行,就是这个字段原样显示。
所以面板上的字,不是事后有人看了日志再总结的,而是干活的模型在动手之前自己写下的。
未知 对话流里还有一种折叠起来的一行小结,例如 "Created ticket 09 worktree from feat"。它是怎么生成的,从模型这边看不到,这里不下结论。
发现三:输出怎么和任务对上
看到什么
比喻:寄快递时贴一张单号,回件上印着同一个单号。拿两个单号一对,就知道哪个回件属于哪次寄件。
真名叫 tool_use id。子代理的 .jsonl 是一行一条 JSON。下面是 agent-a6ecd6ce8367a0647.jsonl 里挨着的两行(核实,命令和 id 有删节):
第 374 行 type=assistant
{"type":"tool_use","name":"Bash","id":"toolu_01JnowgQXg…",
"input":{"description":"Red check for never-reaches-gate drop",
"command":"cd …/im-md-09 && sed -i '' 's/…/if false \&\& …/' …"}}
第 375 行 type=user
{"type":"tool_result","tool_use_id":"toolu_01JnowgQXg…", "content":
"--- FAIL: TestIMDispatch_RedriveThatNeverReachesGateIsDropped (0.02s)
… want the row dropped without another redrive"}
- 第 375 行的
tool_use_id和第 374 行的id一样,这就是「单号」。 - 这是一次「先去掉修复,看测试会不会变红」的红测。测试确实红了,输出也确实存在。
- 每行还带着
agentId、cwd、gitBranch、isSidechain,以及parentUuid(指向上一行,把各行串成一条链)。
为什么面板上看不到
同一条命令,jsonl 里有输出,面板上却没有。区别在于它是前台跑的还是后台跑的。
- 核实 后台命令(
run_in_background: true)多一条线。它跑完时的通知里有这几样:
输出写进了单独的<task-id>b3q1esfqv</task-id> <tool-use-id>toolu_01NNGh…</tool-use-id> <output-file>…/tasks/b3q1esfqv.output</output-file> <status>failed</status>.output文件,面板可以去读。 - 核实 前台命令没有这个文件。输出只交回给跑它的那个模型,记在它自己的 jsonl 里。
- 推断 所以面板对前台命令显示 "No output captured"。依据是:我看到的标着 No output captured 的条目,正好都是前台命令。面板的源码我看不到。
那个 "Failed" 不是真出错
截图里 "Build and start peer imB on feat" 标成 Failed。它跑的是 dev.sh peer,这个命令会一直挂着跟日志。测完以后按进程号把它停掉,命令就以 exit 1 结束,面板于是记成 Failed。这里的 Failed 只说明退出码不是 0,不说明活没干成。
给使用者的建议
- fork 之后,两边别同时动同一个 worktree。fork 只复制对话,代码目录是共用的。正在跑的 workflow 也只在原会话里。
- 想在面板里看到输出,就用后台命令。后台命令有自己的
.output文件,面板能点开。 - 红测的输出要去 agent jsonl 里找。到
subagents/workflows/<run id>/agent-<id>.jsonl,用面板上那句 description 搜到tool_use,再看下一行tool_use_id相同的tool_result。 - Failed 先看是不是自己停的。长期挂着的命令被手动停掉,也会显示 Failed。
一句话:面板上的名字是干活的模型自己写的,输出靠 tool_use id 挂回去,后台命令才有面板能读的输出文件,而 fork 只复印对话,不复印正在跑的活和代码目录。