在 Multica 的一个 issue 下面写了一条评论,@ 了一个叫 reviewer 的 agent。评论框里看到的是一个蓝色的 @reviewer,存进数据库的却是这样一行 Markdown:
[@reviewer](mention://agent/c620e7c8-ca66-480d-8752-61252eb216bb) 帮忙看下这个 Safari 的问题,先别改代码,给个定位思路这个 agent 绑在我电脑上的 Claude Code 上。从发出这条评论算起,约 190 毫秒后,电脑上被拉起来的 claude 进程就执行了一条 multica issue get,读到了 issue 里写的需求:“登录页在 Safari 上点「提交」没反应。验收标准:1. Safari 17 能正常提交;2. 补一个 e2e 用例”。又过了 3 秒,评论下面多了一条 reviewer 的回复。
这台电脑没有公网 IP,Claude Code 也是个交互式的命令行工具。那么这条评论是经过什么通道到我电脑上的?路上传过去的是这条评论本身,还是别的什么?issue 的描述我一个字没写进评论里,agent 又是从哪知道的?
下面顺着这一条 @,一段一段往下追。
实验环境
Multica 是开源的,服务端、daemon 和 multica 命令行都在同一个 Go 仓库里1。为了看清每一步,我在本地起了一套:PostgreSQL 16 和 Redis,server 跑在 8080 端口;multica daemon start --foreground 作为“我的电脑”;两者之间插了一个记录所有 HTTP 请求和 WebSocket 帧的小代理。本机的 claude 换成了一个假的同名脚本,它把自己收到的命令行参数、环境变量、工作目录里的文件和标准输入全部存下来,然后像真的 agent 一样调用 multica issue get 读 issue、用 multica issue comment add 回复。
文中的时间戳、报文和 prompt 都来自这套环境的记录,id 是真实生成的,令牌已打码。源码读的是 2026-10-23 的提交 dbdbb29。
一、一条 @ 的全程
先把整条链路摆出来。下面这张图按时间从上往下,列出了这条 @ 引起的每一次通信,左边是毫秒级的时间戳(13:58:12 之后的秒数):
图里有一条线索值得跟着看:强调色标出的 mat_…。它在领取任务时由服务端签发,跟着任务包到了 daemon,daemon 把它放进 claude 进程的环境变量,agent 之后读 issue、发回复用的都是它,任务结束时服务端把它删掉。
这张图也透露了整个设计的骨架:平台和电脑之间推送的东西极少,大部分内容是“拉”过来的。 WebSocket 上只说“有活了”,daemon 去领一个任务包;任务包里也只有身份、规则和这一回合的起因,issue 的需求描述要等 agent 自己去拉。后面几节就分别回答:推的为什么这么少,推了哪些,拉的又是怎么拉的。
先从源头说起:那行 mention:// 链接,在服务端变成了什么?
二、从评论到任务
识别 @
服务端不认显示名,只认链接里的 uuid。评论保存后,ParseMentions 用一条正则把正文里所有 [@名字](mention://类型/id) 抠出来,类型有 member、agent、squad、issue、all 五种2:
\[@?(.+?)\]\(mention://(member|agent|squad|issue|all)/([0-9a-fA-F-]+|all)\)所以 @ 的语义是在编辑器里就定下来的:你在下拉框里选中的是哪个 agent,链接里就是它的 id,改了名字也不会认错。
拿到 mention 之后,computeCommentAgentTriggers 决定这条评论要叫醒谁3。显式 @ 一个 agent 优先级最高;@ 一个小队(squad)叫醒的是队长;回复某个 agent 发的评论,即使不写 @,也会叫醒那个 agent。这一步还会检查调用权限(私有 agent 只有所有者能叫)和归属,记下这次是“人直接叫的”还是“另一个 agent 转派的”。
只写 id 的任务行
决定叫醒 reviewer 之后,服务端在 agent_task_queue 表里插一行4。这一行长什么样,直接决定了后面传什么:
这一行里没有 issue 的标题和描述,没有 agent 的指令,连评论正文也没有,只有 trigger_comment_id 指回原评论。任务可能要在队列里等:电脑可能关着,同一个 agent 可能正在干别的。等到真正开工时,agent 的指令可能已经被改过,评论可能被编辑过,所以这里只记“是谁、为什么”,内容一律等领取时现查。
同一个 agent,同一个 issue,一次只跑一个
领取任务的那条 SQL 有两个条件很关键5:runtime 必须在线、最近有心跳;同一个 agent 在同一个 issue 上,不能已经有一个 dispatched 或 running 的任务。后一条让同一个 agent 在一个 issue 上严格串行,不同的 agent 可以并行。
我把 daemon 停掉,连发两条 @,库里就躺着两行 queued;daemon 一上线,两轮依次跑完,第二轮的命令行里多了 --resume,接着第一轮的会话继续聊。电脑关机时 @ 它不会丢,只是排队。
任务行写好之后,服务端要通知那台电脑。它和电脑之间到底是一条什么样的连接?
三、通信层:一条连出去的 WebSocket
连接由 daemon 发起
daemon 启动后做的事情从代理里看得很清楚:先用登录时拿到的个人令牌(mul_ 开头)调几个 HTTP 接口,拉工作区列表,注册本机检测到的 CLI(这里是 claude),然后主动发起一条 WebSocket:
GET /api/daemon/ws?runtime_ids=7e5fdbe6-c744-405f-9a76-594b3633d49eAuthorization: Bearer mul_…X-Client-Capabilities: skill-bundles-v1,coalesced-comments-v1,…,rpc-v1,…,claim-poll-hints-v1连接由电脑发起,所以电脑不需要公网 IP,也不需要开端口(为什么非得这样,另一篇文章里讲过6)。连上以后,平台要找这台电脑,就只能等这条连接上有它的消息。
唤醒帧只有两个 id
发出评论请求后 48 毫秒,WebSocket 上出现了第一帧:
{"type":"daemon:task_available","payload":{"runtime_id":"7e5fdbe6-…","task_id":"01a1690e-128a-70bd-8fb7-0c551377e193"}}就这么多。服务端组这一帧的函数只放了 runtime_id 和 task_id 两个字段7。
为什么不把整个任务直接推过去? 最直接的想法是:反正连接是通的,服务端把任务内容整包推下去,daemon 收到就开工,还能省一次往返。
问题在于“推下去”不等于“对方收到了”。WebSocket 断线、daemon 正在重启、发送缓冲满了,推出去的任务都可能落空,服务端还得另做确认、重发、去重。更麻烦的是一台电脑上可能同时有好几个任务能跑,也可能一个都跑不动(并发槽满了),该领多少只有 daemon 自己知道。
所以 Multica 把它拆成两步:服务端只发一个“你那有活了”的提示,丢了也无所谓;daemon 收到提示后按自己的空闲槽位去领取,领取是一次有请求、有响应的事务,数据库里用 FOR UPDATE SKIP LOCKED 保证一个任务只被一个人领走。服务端有好几台时也一样,唤醒经 Redis 广播到所有节点,只有手上握着这台 daemon 连接的那一台把它送出去,送重了 daemon 按事件 id 去重8。
领取也走这条 WebSocket
1 毫秒后,daemon 在同一条连接上回了一帧,这次是一个 RPC 请求:
tasks.claim 本来是一个 HTTP 接口 POST /api/daemon/tasks/claim。走 WebSocket 时,服务端在进程里伪造一个同样的 HTTP 请求,交给同一个处理函数,鉴权、组包、记账全都复用9。好处是省掉一次 HTTP 建连,而且领取和提示走的是同一条已经认证过的连接。
这里有个细节说明了设计者最在意什么。RPC 超时、连接不在、发送缓冲满了,daemon 都会改走 HTTP 再领一次;唯独“请求已经发出去,连接却断了”这种情况不许改走 HTTP,因为服务端可能已经把任务标成了 dispatched,再领一次就成了重复领取10。宁可让这个任务等服务端的超时回收,也不冒一个任务跑两遍的险。
连接断了怎么办
提示可以丢,是因为背后还有轮询兜底。daemon 每 15 秒发一次心跳;没有新任务时,它也会定期主动领一次。WebSocket 健康、服务端又支持调度提示时,这个兜底轮询放宽到最长 3 分钟;否则是 30 秒1112。
我让代理拒掉 WebSocket 握手再试:daemon 日志里出现 websocket: bad handshake,重连间隔从 4 秒一路退到 30 秒,心跳也改成了 HTTP。这时再 @ reviewer,评论在 14:05:49.279 落库,HTTP 轮询在 14:06:04.434 才把它领走,慢了 15 秒;最坏要等满一个 30 秒周期。链路没断,只是从“立刻”降级成了“等一会儿”。
daemon 领到了任务包。这个包里到底装了什么?
四、任务包里有什么,没有什么
领取的响应里,任务包是一段约 2 KB 的 JSON(不算 skill 内容)。拆开看,服务端在领取的这一刻现查了不少东西13:
{ "id": "01a1690e-128a-70bd-8fb7-0c551377e193", "issue_id": "01a1690d-fc37-7641-b321-0a756d7337fd", "issue_identifier": "DEMO-1", "thread_name": "Safari 登录按钮无响应", "issue_status": "todo", "agent": { "name": "reviewer", "instructions": "你是代码审查 agent。回答用中文,先读 issue 再动手。", "skill_refs": [{"name": "multica-platform", "size_bytes": 126499}] }, "trigger_comment_id": "01a1690e-1277-717d-95b1-cd33edaea63f", "trigger_comment_content": "[@reviewer](mention://agent/c620e7c8-…) 帮忙看下这个 Safari 的问题,先别改代码,给个定位思路", "trigger_author_type": "member", "trigger_author_name": "demo", "initiator_name": "demo", "initiator_email": "demo@example.com", "auth_token": "mat_…"}有 agent 的指令和技能,有触发这次运行的那条评论原文和作者,有“代谁干活”,有 issue 的编号、标题和状态,还有一枚 mat_ 开头的令牌。如果这个 agent 在这个 issue 上跑过,还会带上次的会话 id 和工作目录;如果 issue 属于某个项目,还有项目的仓库和资源。
但是没有 issue 的描述。 “登录页在 Safari 上点「提交」没反应”和那两条验收标准,整个任务包里一个字都没有。标题倒是有,不过它叫 thread_name,daemon 拿它给会话起名字用。
这就回到了开头的问题。需求明明写在 issue 里,它是怎么进到 CLI 里的?
五、上下文的三层
daemon 拿到任务包以后,开工前要准备三样东西:一个工作目录,一组环境变量,一条写进 claude 标准输入的消息。上下文就分布在这三处,再加上 agent 运行时自己拉取的那一份:
第一层:写进工作目录的身份和规则
daemon 为这个任务准备好工作目录,在里面写一个 17 KB 的 CLAUDE.md(Codex 等其它 CLI 写成 AGENTS.md),再把技能文件放进 .claude/skills/14。Claude Code 启动时会自动读取当前目录的 CLAUDE.md,所以这些内容不需要通过命令行传。
CLAUDE.md 里装的是不随回合变化的东西:你是谁(agent 的名字、id,以及我在网页上给 reviewer 写的那句指令,原样贴在 ## Agent Identity 下面);能用哪些命令;每一回合该怎么干活。最后这部分是一个五步的工作流:先 multica issue get 读 issue,再分两次读评论(先扫各线程的摘要,再展开相关的线程),产出属于 issue 本身的工作就把状态改成进行中,结果必须以评论回复,退出前确认状态。
环境变量里最重要的是 MULTICA_TOKEN,它就是任务包里的那枚 mat_ 令牌;另有服务器地址、任务 id、agent id 和一个任务私有的临时目录12。
第二层:stdin 里的这一回合
然后 daemon 起进程。claude 的参数是固定的一串15:
claude -p --output-format stream-json --input-format stream-json --verbose \ --permission-mode bypassPermissions --disallowedTools AskUserQuestion-p 加上两个 stream-json 让它变成一个读写 JSON 行的无头进程;AskUserQuestion 被禁掉,因为没有界面能显示它的提问,想问人就得去评论里问。prompt 不放在参数里,而是作为第一条 {"type":"user"} 消息写进 stdin(图 4 第二列)。它告诉 agent 三件事:你被分配到哪个 issue;是哪条评论把你叫起来的,评论原文直接引用在 [NEW COMMENT] 下面;以及读 issue、读这条线程、回复这条评论的现成命令,回复命令里的 --parent 已经填好了触发评论的 id。末尾还有一段 ## On Behalf Of,说明这次是替 demo 干活。
为什么同样是“上下文”,有的写进 CLAUDE.md,有的写进 stdin?源码注释给了原因:Claude Code 把 CLAUDE.md 放在整段对话的最前面,后面的历史都可以命中 prompt 缓存;如果把“代谁干活”这类每次都变的内容也写进去,每次续接会话,整段历史的缓存都会失效。所以每回合都变的块一律追加在 stdin 消息的末尾16。
第三层:agent 自己拉的需求
prompt 里那句 Start by running multica issue get 01a1690d-… --output json 是关键。agent 照做,multica 命令行从环境变量里取出 mat_ 令牌和服务器地址,发出 GET /api/issues/01a1690d-…,这才拿到了 issue 的描述和验收标准;接着按工作流第二步拉评论列表。图 1 里 12.518 和 12.533 那两行就是这两次请求。
为什么不把描述直接塞进 prompt? 第一次尝试的想法很自然:任务包里都现查了 agent 指令,顺手把 issue 描述和评论也查出来,拼进 prompt,agent 省一次调用。
这么做有几个问题。一是长度,issue 描述可能很长,评论可能有上百条,大部分与这一回合无关,全塞进去既费 token,又会把真正要回答的那条评论淹没。二是时效,任务可能排了很久的队,agent 跑起来以后别人还可能改 issue,现拉的才是最新的。三是 agent 本来就得会用 multica 命令行,它还要改状态、发评论、建子 issue,读 issue 只是其中最简单的一个。
所以 Multica 选择了只推“起因”,需求让 agent 自己拉,并且用工作流把“先读 issue、再读评论”写成必做的步骤。唯一被直接嵌进 prompt 的是触发评论本身,源码注释的解释是:工作目录会被复用,里面可能留着上一轮的输出文件,不把这条新评论摆在眼前,agent 容易忽略它13。
续接会话时少读一次
reviewer 在 DEMO-1 上第二次被 @ 时,任务包带上了上一轮的会话 id,命令行多了 --resume fake-session-135812。这时再让 agent 从头读一遍 issue 就是浪费,因为它的记忆里已经有了。服务端会把这次领取时 issue 的标题和描述,与上一轮领取时记下的快照比一比,第二轮 prompt 里那句“去读 issue”就变成了17:
The issue is unchanged since your last run — the server compared title and description(status: todo; assignee: unassigned). That answers workflow step 1: continue from yourresumed context, and re-read with `multica issue get …` only if resumed memory is not enough.评论也一样,服务端数过上一轮之后有没有新评论,没有就直说“没有其它新评论”,省掉一次扫描。拉取还是默认的做法,推过来的只是“不用拉了”这个判断。
把一条 @ 带进 agent 的东西按路径归一下类:
| 内容 | 怎么到 agent | 谁来取 |
|---|---|---|
| agent 指令、技能 | CLAUDE.md 的 Agent Identity、.claude/skills/ | daemon 启动前写好 |
| 凭证、任务 id | 环境变量 MULTICA_TOKEN、MULTICA_TASK_ID | |
| 触发评论原文 | stdin 消息的 [NEW COMMENT] | daemon 写进 stdin |
| 代谁干活 | stdin 消息末尾的 ## On Behalf Of | |
| 上一轮的会话 | --resume 参数,外加 issue 有没有变的判断 | |
| 任务包里没有,agent 自己拉 | ||
| issue 描述、验收标准 | multica issue get | agent 带着 mat_ 请求 |
| 评论历史 | multica issue comment list | |
前五行是推的,变化少或者只属于这一回合;后两行是拉的,内容多、会变。
agent 拉需求、发回复都靠那枚 mat_ 令牌。它是谁的身份,能拿来做什么?
六、拉取的凭证
daemon 自己登录用的是个人令牌 mul_(浏览器登录时创建,默认 90 天有效)。它没有把这个令牌交给 agent,交出去的是服务端在领取时现签的一枚:mat_ 加 40 位十六进制随机数,库里只存它的 SHA-256,绑定这个任务、这个 agent、这个工作区和 runtime 的所有者,24 小时过期18。
带着它的请求进到服务端,鉴权中间件会用令牌记录覆盖请求头里的用户、agent、任务和工作区,并打上 X-Actor-Source: task_token19。所以 agent 发的评论,作者显示为 reviewer 而不是我;agent 也没法在请求头里冒充别的 agent。
任务一结束(完成或失败),服务端就删掉这枚令牌。我拿第一轮的令牌在任务结束后再请求一次,返回 401 {"error":"invalid token"}。
需要说清楚的是它的范围。它绑定的是“工作区 + 所有者身份”,不是“这一个 issue”。我让假 agent 在运行中多做了两件事:读同一工作区里另一个毫不相干的 issue,成功了;用 multica agent update 改自己的指令,也成功了(随后改了回来)。源码里明确只给计费这类路由挂了“仅限人”的检查,issue 和 chat 路由都允许 agent 访问20。也就是说,一个被 prompt 注入带偏的 agent,在任务存活的那段时间里,能以所有者的身份读写整个工作区。这和 Multica 的定位是一致的,agent 本来就要能建子 issue、给别的 agent 派活;但在一个人人都能 @ 它的群里,这个范围值得心里有数。
到这里,一条 @ 的单程已经走完了。最后还剩一个开头没问、但用起来马上会遇到的问题:agent 正在干活的时候,我又 @ 了它一次,会怎样?
七、运行中的第二条消息
默认:排队等下一轮
第二节说过,同一个 agent 在同一个 issue 上只能有一个任务在跑。我在第一轮还在跑时又 @ 了一次:服务端照常建了新任务、发了唤醒,daemon 也去领了,但领取返回空;等第一轮在 13:59:39.5 结束,20 毫秒内第二个任务就被领走,带着 --resume 接上了刚才的会话。
这样不会丢消息,但补充说明要等上一轮做完才看得到。如果我只是想补一句“只看 Safari 17”,等它把一整轮做完再回头,就晚了。
插话:借 hook 把话塞进当前回合
所以 Multica 还提供了另一种发法:评论请求里带上 steer_task_ids,指明要插进哪一轮正在跑的任务(网页上对应的应该是“发给正在运行的这一轮”的选项,这一点是我从接口推断的)。这时服务端不再建新任务,而是把评论绑成这个任务的一条补充消息,响应里的结果从 queued 变成了 steered。
问题在于 Claude Code 的 stream-json 协议里,没有“给当前回合补一句话”的消息类型。再写一条 {"type":"user"} 进 stdin,会变成一个新的回合。Multica 的办法是借用 Claude 的 SDK hooks2122:daemon 启动 claude 时,在 stdin 里先于 prompt 发了一条 control_request,注册了 PreToolUse、PostToolUse、UserPromptSubmit、Stop 等几个钩子。之后 Claude 每次要调用工具,都会先问 daemon 一句“这个钩子你有什么要说的”,daemon 就在回答里夹带插话:
additionalContext 里的文字是 daemon 包装过的:先说明“人类 demo 在你工作时补充了指导”,要求把它当作同一个任务的补充,不要替换原目标,不要单独回复一句“收到”,然后才是插话原文23。如果 Claude 正好要结束这一回合,Stop 钩子会用 decision: block 把它拦下来,带着补充继续干。实测从服务端发出提示到 daemon 回报“已送达”,用了 127 毫秒;只是插话真正进到模型眼前,要等它下一次调用工具。
这条路径的形状和第一次 @ 完全一样:WebSocket 上只有一个提示,内容靠 daemon 去领,领完再回报。
插话只管“正在跑的这一轮”。更常见的情况是一轮跑完了,过一会儿我再 @ 它一次。前面说第二轮“带着 --resume 接上了刚才的会话”,接上的到底是哪一段,什么时候接不上?
八、再 @ 它一次:接上的是哪一段
为了把后面两个问题看清楚,我新建了一个 issue DEMO-3(“Safari 下表单提交后页面刷新”),又在同一台电脑上加了第二个 agent,叫 fixer。假 claude 也改了一下:reviewer 每跑一轮,就在工作目录里的 progress.md 追加一行“定位到 onSubmit 缺 preventDefault,修了一半”,再把同样的进度发成评论;每一轮开始时,它都先看一眼目录里有没有这个文件。
每一轮都是新进程,会话靠 --resume 接
先 @ reviewer 一次,等它跑完,再 @ 一次。两轮的命令行和工作目录是这样的:
第一轮 argv: claude -p --output-format stream-json … (没有 --resume) cwd: …/multica_workspaces/demo-df92c6681b10/demo-3-66371cba076d/workdir progress.md exists: False第二轮 argv: claude -p --output-format stream-json … --resume fake-session-220349 cwd: …/multica_workspaces/demo-df92c6681b10/demo-3-66371cba076d/workdir progress.md exists: True reviewer run 220349: 定位到 Safari 下 form.tsx onSubmit 没 preventDefault;修复写了一半…第一轮的进程早就退出了,第二轮是 daemon 新拉起来的另一个 claude。它能“记得”上一轮,靠的是两样东西一起交过来:上一轮的会话 id,和上一轮的工作目录。
这两样都是服务端在领取时查出来的。任务包里的 prior_session_id 和 prior_work_dir 来自一条查询:找这个 agent 在这个 issue 上最近一次已经结束的任务,取它的会话和目录135。键只有 agent 和 issue,跟这次是被 @ 的、被指派的,还是在哪个评论线程里被叫到的都没关系。所以在同一个 issue 下,不管在哪条评论里 @ reviewer,它接上的都是同一段对话。
为什么目录也要交过来?因为 Claude Code 的会话是跟目录绑在一起的:会话记录存在 ~/.claude/projects/ 下按目录区分的文件里,换个目录,--resume 就找不到它了24。工作目录的路径由工作区、issue 编号和第一轮的任务 id 拼成,后面的轮次复用它25。daemon 在启动前会再确认一次:复用到的目录和服务端给的是不是同一个,不是就把会话 id 丢掉,按新会话启动。
什么时候接不上
“最近一次结束的任务”并不总能接。源码里能找到这几种情况:
| 情况 | 会话 | 工作目录 |
|---|---|---|
| 同一台电脑,上一轮正常结束 | --resume 接上 | 复用 |
| 上一轮失败,但失败和对话无关 | 照样接上 | |
| 上一轮失败在对话本身:轮数用尽、上下文溢出、请求被模型拒绝(400)等 | 跳过这段,往前找更早一段还能用的 | 复用 |
| agent 换到了另一台电脑 | 不接 | 照给,多半已不存在 |
| 目录已被清理(issue 关闭 24 小时后) | 新会话,prompt 里说明接不上 | 新建 |
| 手动操作 | ||
| 在网页上对某一轮点“重跑” | 接那一轮的会话,不是最近一轮 | 复用那一轮的 |
| 失败后系统自动重试,且要求干净开始 | 新会话 | |
会话和目录分开判断:会话有一点不可靠就放弃,目录能复用就复用,代码改了一半的状态尽量不丢。
第三行值得多说一句。一段对话如果是因为“上下文太长”或者“模型拒绝了这段历史”而失败的,再 --resume 它只会原样再失败一次,所以服务端把这些失败原因列成了一张黑名单,遇到就跳过这段会话5。而“接不上”时,agent 不会被蒙在鼓里:prompt 里会多一段说明,告诉它上一轮的上下文这次没能带过来,该重新读的要重新读。
接上之后,服务端还补了什么
接上会话只是让 agent 记得上一轮自己看过什么。这中间 issue 可能被改过,别人可能又发了评论。第五节提过 issue 有没有变的判断,评论这边是同一个思路:服务端以被接上的那一轮的开始时间为锚点,数一数之后这个 issue 上所有线程里新增了多少条评论,不算 agent 自己的,也不算这次的触发评论。有就在 prompt 里给一条现成的命令:
1 new comment(s) on this issue since your last run, across all threads — the servercomputed this delta, and reading it is the scan workflow step 2 requires. Read exactlythose comments with `multica issue comment list … --since 2026-10-23T22:06:08Z --compact`锚点选开始时间而不是结束时间,是因为一轮可能跑很久,跑的过程中发的评论 agent 不一定看到过。
连着 @ 好几次:同一个线程会并成一轮
最后是“连续 @”最字面的那种情况:reviewer 还在跑,我在同一条评论下面接连回了两句,都带着 @reviewer。
结果是只多跑了一轮,这一轮的 prompt 里写着:
[NEW COMMENT] A user just left a new comment. Focus on THIS comment …> @reviewer 同一线程追问二:只看 Windows
This run also covers 1 earlier comment(s) posted before it started — you must read andaddress them too, not just the one above. …- comment 01a16acd-d854-… (demo, 2026-10-23T22:07:17Z) [thread 01a16acd-d44f-…]: > @reviewer 同一线程追问一:Edge 版本 120数据库里给排队中的任务加了一个唯一约束,键是(issue,agent,评论线程)26。同一个线程里已经有一个在排队的任务,新的 @ 就并进它,作为“这一轮也要处理的较早评论”原文列出来。换成另起一条顶层评论去 @,就是另一个线程,我连发三条,reviewer 就依次跑了三轮,每一轮接着前一轮的会话。
同一个 agent 的“记忆”就是这样一段一段续下去的。可要是换一个 agent 来接手呢?
九、换一个 agent:它怎么知道前面做到哪了
什么都不继承
reviewer 跑完两轮之后,我发了一条新评论:“@fixer 接着 reviewer 的进度把修复做完并跑测试”。fixer 这一轮:
argv: claude -p --output-format stream-json … (没有 --resume)cwd: …/multica_workspaces/demo-df92c6681b10/demo-3-2989e6c10953/workdirprogress.md exists: False会话是新的,因为上一节那条查询的键里有 agent,fixer 在 DEMO-3 上还没有过任何会话。工作目录也是新的,和 reviewer 的 demo-3-66371cba076d 是两个目录,reviewer 留下的 progress.md 不在这里。prompt 里也没有一个字提到 reviewer,和任何一次首轮一样,只是叫它先 multica issue get。
Multica 里没有专门的“交接”通道:没有共享目录,没有共享会话,任务包里也没有“上一个 agent 的进度”这一栏。两个 agent 之间唯一共用的,是 issue 本身:标题、描述、状态,和下面的评论。
进度只在评论里,而且在回复里
所以 fixer 能不能接上,全看它读评论读到了什么。按工作流,它第一步扫的是每个线程的根评论(--roots-only --summary)。这次扫到的是三条根评论,全是我写的:两条 @reviewer、一条 @fixer。reviewer 的进度在哪?在第一条下面的回复里,扫描结果只显示为:
{ "content": "@reviewer 先看下 Safari 的问题在哪", "reply_count": 1, "last_activity_at": "2026-10-23T22:03:53Z", … }要看到“进度(reviewer):Safari 问题定位在 form.tsx 的 onSubmit,缺 preventDefault”这句话,fixer 得再用 --thread 把这个线程展开。工作流写着“只展开有关的线程”,有没有关,靠 agent 从 reply_count 和最后活跃时间去判断。
代码也是一样。如果 issue 关联了代码仓库,每个任务会在自己的工作目录里检出一份,切到自己的分支 agent/{agent 名}/{任务},起点是仓库的默认分支27。reviewer 在本机改了一半、没提交没推送的代码,fixer 那份检出里根本没有。要接着改,得 reviewer 先把分支推上去、在评论里写明分支名,fixer 再按名字检出。这一点我是从源码读出来的,没有搭仓库实测。
同时 @ 两个:各看各的
如果在一条评论里同时 @ reviewer 和 fixer,两个任务在 21 毫秒内先后开跑,前面说过串行只针对同一个 agent,不同 agent 可以并行。它们各自一个目录、一段会话,谁也不等谁。
并行时还有一个时间差。我先 @ reviewer,两秒后 @ fixer:fixer 在 22:06:06 读评论时,reviewer 这一轮还没跑完,它的回复 22:06:08 才发出来,fixer 读到的列表里就没有这条。后一个 agent 能看到的,只是它读评论那一刻前一个已经发出来的东西。
不过等 fixer 下一次被 @、接上自己的会话时,上一节那个“新评论数”就派上用场了。它的 prompt 里写着“2 new comment(s) on this issue since your last run, across all threads”,这个数只排除 fixer 自己的评论,reviewer 在这期间发的进度正好算在里面,--since 那条命令会把它们读出来。也就是说,对续接的 agent,服务端会把别的 agent 新写的东西指给它看;对首次接手的 agent,就只能靠它自己扫一遍。
让前一个 agent 直接叫下一个
既然评论是唯一的通道,最直接的交接就是让前一个 agent 在评论里 @ 下一个。我让 reviewer 在回复开头带上 [@fixer](mention://agent/60c6d969-…),约 100 毫秒后 fixer 就开跑了,prompt 的第一句变成了:
[NEW COMMENT] Another agent (reviewer) just left a new comment. Focus on THIS comment —do not confuse it with previous ones:
> @fixer 进度(reviewer):Safari 问题定位在 form.tsx 的 onSubmit,缺 preventDefault。…这是第二节那条路径的原样重复:agent 发的评论和人发的一样会被解析出 @,只是 prompt 里标明了作者是另一个 agent。这样交接内容直接进了 stdin,不用 fixer 自己去翻线程。代价是 reviewer 写在这条评论里的就是 fixer 拿到的全部,没写进去的,它就不知道。写进 CLAUDE.md 的规则也提醒了这一点:@ 别人是派活,要给“具体的新子任务”,而不是打个招呼14。
小结
回到开头那条 mention://agent/c620e7c8…,它在 190 毫秒里经过了前六个问题,后两个是它之后的事:
- @ 在服务端变成了什么? 一行只有 id 的任务:叫哪个 agent、在哪台机器、哪个 issue、哪条评论。内容一律等开工时现查;同一个 agent 在同一个 issue 上一次只跑一个。
- 走的是哪个通信层? daemon 主动连出的一条 WebSocket。上面只有心跳、“有活了”的提示,以及 daemon 发起的
tasks.claim请求;它不通时,同样的事退回 HTTP,每 30 秒轮询一次。 - 为什么不直接推任务? 推出去的东西可能落空,该领几个也只有本机知道。所以提示可以丢,领取是一次带确认的事务,结果不明时宁可不重领。
- 需求上下文怎么进来? 分三层:身份和规则写进工作目录的
CLAUDE.md和技能目录;这一回合的起因(触发评论原文、现成的命令、代谁干活)写进 stdin;issue 描述和评论历史不在任务包里,由 agent 按工作流自己用multica issue get和comment list拉。续接会话时,服务端会告诉它“没变,不用重读”。 - 拉取用谁的身份? 每个任务一枚
mat_令牌,以 agent 的名义、所有者的权限访问整个工作区,任务结束即删。 - 运行中再 @ 一次呢? 默认排队,下一轮续接会话;指定插进当前这一轮时,借 Claude 的工具钩子把话作为附加上下文塞进去。
- 隔一会儿再 @,接上的是什么? 每轮都是新的
claude进程。服务端按“这个 agent、这个 issue”找最近一段还能用的会话,连同它的工作目录一起交给 daemon,用--resume接上;换了电脑、目录被清掉、上一轮把对话本身弄坏了,就开新会话并在 prompt 里说明。同一个线程里排队时追加的 @ 会并成一轮。 - 换一个 agent 来接手呢? 会话和工作目录都不共享,也没有专门的交接通道。后来的 agent 只能从 issue 和评论里读到前一个的进度,前一个本机上没提交的改动它看不到。所以要让前一个把结论写进评论、把代码推到分支,或者干脆由它在评论里 @ 下一个。
如果想自己看一遍,最省事的办法就是本文的做法:用 multica daemon start --foreground 跑 daemon,在它和服务端之间放一个记录报文的代理,再把 PATH 里的 claude 换成一个把 stdin 和环境变量存下来的脚本。调研笔记、原始报文和这两个小工具都放在了项目资料里。
参考资料
Footnotes
-
CLI and Agent Daemon Guide收藏github.comhttps://github.com/multica-ai/multica/blob/main/CLI_AND_DAEMON.md ↩
-
server/internal/util/mention.gogithub.com ↗本文读的是 2026-10-23 的提交
dbdbb29;MentionRe正则和ParseMentionshttps://github.com/multica-ai/multica/blob/main/server/internal/util/mention.go ↩ -
server/internal/handler/comment.gogithub.com ↗
computeCommentAgentTriggers决定一条评论叫醒谁;steerCommentAgentTriggers处理插话https://github.com/multica-ai/multica/blob/main/server/internal/handler/comment.go ↩ -
server/internal/service/task.gogithub.com ↗
enqueueMentionTaskWithCommentPlan建任务行;notifyTaskAvailable先作废空结果缓存、再发唤醒https://github.com/multica-ai/multica/blob/main/server/internal/service/task.go ↩ -
server/pkg/db/queries/agent.sqlgithub.com ↗
ClaimAgentTask:runtime 在线、同一 (issue, agent) 没有进行中的任务才可领取,FOR UPDATE SKIP LOCKEDhttps://github.com/multica-ai/multica/blob/main/server/pkg/db/queries/agent.sql ↩ ↩2 ↩3 -
把本地 Agent 变成平台上的 Bot:架构、现状与安全风险twwy.net ↗为什么 agent 要留在本地、为什么由本机主动连出去、多个任务怎么共用一个目录https://twwy.net/blog/local-agent-as-bot/ ↩
-
server/internal/daemonws/hub.gogithub.com ↗daemon WebSocket 的服务端;
taskWakeupFrame只放 runtime_id 和 task_id;handleRPCFrame处理daemon:rpc_requesthttps://github.com/multica-ai/multica/blob/main/server/internal/daemonws/hub.go ↩ -
server/internal/daemonws/notifier.gogithub.com ↗多台 server 时经 Redis relay 广播唤醒,由持有该 daemon 连接的节点送达https://github.com/multica-ai/multica/blob/main/server/internal/daemonws/notifier.go ↩
-
server/internal/handler/daemon_rpc.gogithub.com ↗
tasks.claimRPC 在进程内伪造一个 HTTP 请求,复用ClaimTasksByRuntimehttps://github.com/multica-ai/multica/blob/main/server/internal/handler/daemon_rpc.go ↩ -
server/internal/daemon/wsrpc.gogithub.com ↗
errWSRPCUncertain:请求已发出、连接断了,结果未知,不许改走 HTTPhttps://github.com/multica-ai/multica/blob/main/server/internal/daemon/wsrpc.go ↩ -
server/internal/daemon/config.gogithub.com ↗
DefaultPollInterval = 30s、DefaultWSClaimPollInterval = 3m、DefaultHeartbeatInterval = 15shttps://github.com/multica-ai/multica/blob/main/server/internal/daemon/config.go ↩ -
server/internal/daemon/daemon.gogithub.com ↗
pollLoop/runBatchPoller/taskClaimPollInterval;taskMulticaEnvironment给 CLI 的环境变量https://github.com/multica-ai/multica/blob/main/server/internal/daemon/daemon.go ↩ ↩2 -
server/internal/handler/daemon.gogithub.com ↗
buildClaimedTaskResponse组任务包:现查 agent、嵌入触发评论、算 issue 变化,签发mat_令牌https://github.com/multica-ai/multica/blob/main/server/internal/handler/daemon.go ↩ ↩2 ↩3 -
server/internal/daemon/execenv/runtime_config_sections.go收藏github.com写进工作目录的 CLAUDE.md / AGENTS.md 运行时简报,各节标题都在这里https://github.com/multica-ai/multica/blob/main/server/internal/daemon/execenv/runtime_config_sections.go ↩ ↩2
-
server/pkg/agent/claude.go收藏github.com
buildClaudeArgs的固定参数;buildClaudeInput把 prompt 包成 stream-json 的 user 消息https://github.com/multica-ai/multica/blob/main/server/pkg/agent/claude.go ↩ -
server/internal/daemon/prompt.gogithub.com ↗
buildCommentPrompt;perTurnContextBlocks把每次都变的块追加到末尾,避开 prompt cache 前缀(MUL-5377)https://github.com/multica-ai/multica/blob/main/server/internal/daemon/prompt.go ↩ -
server/internal/daemon/execenv/issue_state_instructions.gogithub.com ↗
BuildIssueStateHint:续接会话时告诉 agent issue 有没有变(MUL-7344)https://github.com/multica-ai/multica/blob/main/server/internal/daemon/execenv/issue_state_instructions.go ↩ -
server/internal/auth/jwt.gogithub.com ↗
GenerateAgentTaskToken:mat_+ 40 位十六进制,绑定 (agent_id, task_id)https://github.com/multica-ai/multica/blob/main/server/internal/auth/jwt.go ↩ -
server/internal/middleware/auth.gogithub.com ↗
mat_分支用令牌记录覆盖 X-User-ID / X-Agent-ID / X-Task-ID / X-Workspace-IDhttps://github.com/multica-ai/multica/blob/main/server/internal/middleware/auth.go ↩ -
server/internal/handler/actor_guards.gogithub.com ↗
RequireHumanActor只挂在计费这类“仅限人”的路由上,issue 和 chat 路由放行 agenthttps://github.com/multica-ai/multica/blob/main/server/internal/handler/actor_guards.go ↩ -
server/pkg/agent/claude_supplement.gogithub.com ↗借 Claude 的 SDK hooks 在回合中途插入
additionalContexthttps://github.com/multica-ai/multica/blob/main/server/pkg/agent/claude_supplement.go ↩ -
Hooks reference · Add context for Claudecode.claude.com ↗Multica 的插话实现在源码注释里引用的就是这一节https://code.claude.com/docs/en/hooks ↩
-
server/internal/daemon/task_supplement.gogithub.com ↗daemon 侧插话循环:收到提示 → claim → 注入 → ack;
[ADDITIONAL GUIDANCE]文案https://github.com/multica-ai/multica/blob/main/server/internal/daemon/task_supplement.go ↩ -
How Claude Code works · Work with sessionscode.claude.com ↗会话记录存在
~/.claude/projects/下,“Each Claude Code conversation is a session tied to your current directory”https://code.claude.com/docs/en/how-claude-code-works ↩ -
server/internal/daemon/execenv/execenv.gogithub.com ↗
PredictRootDir决定工作目录路径;Reuse在目录还在时复用,否则退回新建https://github.com/multica-ai/multica/blob/main/server/internal/daemon/execenv/execenv.go ↩ -
server/migrations/452_agent_task_pending_thread_unique.up.sqlgithub.com ↗排队中任务的唯一键是 (issue, agent, 评论线程),同一线程的追问并进同一轮https://github.com/multica-ai/multica/blob/main/server/migrations/452_agent_task_pending_thread_unique.up.sql ↩
-
server/internal/daemon/repocache/cache.gogithub.com ↗每个任务的代码检出在自己的分支
agent/{agent 名}/{任务}上https://github.com/multica-ai/multica/blob/main/server/internal/daemon/repocache/cache.go ↩