@ 一下,190 毫秒后本机的 agent 开始读需求 左边是一条 issue 评论,里面 @reviewer 帮忙看下 Safari 的问题,下面写着 190 ms 和一行 multica issue get DEMO-1;右边一台服务器和一台笔记本之间连着一根线,线上只挂着一个小信封,另一条虚线上一份需求文档正被笔记本拉过去。 DEMO-1 · 新评论 @reviewer 帮忙看下 Safari 的问题 190 ms 发出评论到 agent 读到需求 $ multica issue get DEMO-1

在 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 之后的秒数):

一条 @ 从评论到 agent 回复的全部通信 12.326 用户发评论;12.374 server 经 WebSocket 推 daemon:task_available;12.375 daemon 在同一条 WebSocket 上发 tasks.claim 请求;12.412 server 回任务包和 mat_ 令牌;12.458 daemon 调 start;约 12.47 daemon 写好工作目录、起 claude 进程并把 prompt 写进 stdin;12.518 claude 用 mat_ 令牌请求 issue 详情;12.533 拉评论列表;12.540 daemon 把 stream-json 输出转成 messages 上报;15.574 claude 用 mat_ 发回复评论;16.161 daemon 报 complete,server 删除令牌。 平台 我的电脑 我 网页或 multica CLI Multica server :8080 · PostgreSQL multica daemon 常驻后台 claude -p daemon 的子进程 12.326 POST …/comments 12.374 WS · task_available 12.375 WS · rpc tasks.claim 12.412 WS · 任务包 + mat_… 12.458 POST …/start ≈12.47 起进程 · 写 stdin 12.518 GET /issues/DEMO-1 · mat_ 12.533 GET …/comments?roots_only 12.540 POST …/messages stream-json 15.574 comment add --parent · mat_ 16.161 POST …/complete(删 mat_) 平台推给电脑的只有“有活了”和一个任务包;需求描述是 agent 在 12.518 自己拿着 mat_ 拉回来的。 daemon 与 server 之间的前四步都走同一条 WebSocket,之后的上报才改用普通 HTTP。
图 1 · 一条 @ 引起的全部通信:WebSocket 只负责叫醒和领取,需求本体由 agent 自己拉取

图里有一条线索值得跟着看:强调色标出的 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。这一行长什么样,直接决定了后面传什么:

一条评论变成一行任务 左边是 comment 表里的评论,content 字段里有 mention://agent/c620e7c8 链接;经过 ParseMentions 和 CreateAgentTask,右边 agent_task_queue 里多了一行,agent_id 是 c620e7c8,另有 runtime_id、issue_id、trigger_comment_id、status queued、priority 3。issue 标题和描述、评论历史、agent 指令都没有复制进这一行。 comment 表 agent_task_queue 表 comment 01a1690e-1277… content [@reviewer](mention://agent/c620e7c8…) 帮忙看下这个 Safari 的问题,先别改代码… author_type member issue_id DEMO-1 ParseMentions → 权限、归属检查 → CreateAgentTask → 发唤醒 task 01a1690e-128a… agent_id c620e7c8… runtime_id 7e5fdbe6… issue_id 01a1690d-fc37… trigger_comment_id 01a1690e-1277… status queued priority 3(high) issue 标题和描述 · 评论历史 · agent 指令 虚线里的都没复制进来,领取时才现查
图 2 · 一条 @ 在服务端只变成一行 id:要叫谁、在哪台机器、哪个 issue、哪条评论

这一行里没有 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-594b3633d49e
Authorization: Bearer mul_…
X-Client-Capabilities: skill-bundles-v1,coalesced-comments-v1,…,rpc-v1,…,claim-poll-hints-v1

连接由电脑发起,所以电脑不需要公网 IP,也不需要开端口(为什么非得这样,另一篇文章里讲过6)。连上以后,平台要找这台电脑,就只能等这条连接上有它的消息。

唤醒帧只有两个 id

发出评论请求后 48 毫秒,WebSocket 上出现了第一帧:

JSON
{"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 请求:

WebSocket 上的四种帧和它们的 HTTP 兜底 左边是 /api/daemon/ws 上实测的帧:daemon 每 15 秒发 heartbeat、server 回 heartbeat_ack;server 发 task_available 只带 runtime_id 和 task_id 01a1690e-128a;daemon 发 rpc_request,method 为 tasks.claim,timeout_ms 5000;server 回 rpc_response,body 里 tasks 数组的第一项 id 就是 01a1690e-128a。右边虚线区域是 WebSocket 不通时的兜底:HTTP POST /api/daemon/heartbeat 每 15 秒、POST /api/daemon/tasks/claim 每 30 秒,实测评论后 15.2 秒才被领走。 WebSocket · GET /api/daemon/ws WebSocket 不通时 daemon → server · 每 15 s {"type":"daemon:heartbeat","payload":{"runtime_id":"7e5fdbe6…"}} 回 heartbeat_ack server → daemon · 12.374 {"type":"daemon:task_available","payload":{"runtime_id":"7e5fdbe6…", "task_id":"01a1690e-128a…"}} daemon → server · 12.375 {"type":"daemon:rpc_request","payload":{"request_id":"ec16b106…", "method":"tasks.claim","body":{"max_tasks":20,"runtime_ids":[…]}, "timeout_ms":5000}} server → daemon · 12.412 {"type":"daemon:rpc_response","payload":{"request_id":"ec16b106…", "status":200,"body":{"tasks":[{"id":"01a1690e-128a…", "agent":{…},"trigger_comment_content":"…","auth_token":"mat_…"}]}}} 唤醒只带 id;同一个 id 在领取的响应里才带出内容。 POST /api/daemon/ heartbeat · 15 s POST /api/daemon/ tasks/claim 每 30 s 轮询一次 实测拒掉 WebSocket: 评论后 15.2 s 才被领走 重连退避 4 → 8 → 16 → 30 s
图 3 · 同一条 WebSocket 上既有服务端的提示,也有 daemon 的请求;它不通时,同样的事退回 HTTP 轮询来做

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:

JSON
{
"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 运行时自己拉取的那一份:

agent 拿到上下文的三个地方 第一列是 daemon 启动前写好的:工作目录里 17 KB 的 CLAUDE.md,包含 Agent Identity 和 agent 指令、Available Commands、Workflow 的五步;.claude/skills/multica-platform 下 8 个文件;环境变量 MULTICA_TOKEN=mat_、MULTICA_TASK_ID、MULTICA_SERVER_URL。第二列是 stdin 的第一条 user 消息:issue id、NEW COMMENT 引用触发评论、Start by running multica issue get、回复命令带 --parent、On Behalf Of demo。第三列是 agent 运行中带着 mat_ 令牌自己拉取的:multica issue get 返回标题和描述、验收标准,multica issue comment list 返回评论。 ① 启动前写好 ② stdin 第一条消息 ③ 运行中自己拉 CLAUDE.md 17 KB # Multica Agent Runtime ## Agent Identity 你是代码审查 agent。回答 用中文,先读 issue 再动手。 ## Available Commands ### Workflow 1. multica issue get 2. comment list,分两次读 4. comment add 回复 ## Mentions · ## Output … .claude/skills/ multica-platform · 8 个文件 环境变量 MULTICA_SERVER_URL=… MULTICA_TASK_ID=01a1690e… MULTICA_TOKEN=mat_… TMPDIR=/tmp/multica-task-… "type": "user" Your assigned issue ID is: 01a1690d-fc37-… [NEW COMMENT] A user just left a new comment… > [@reviewer](…) 帮忙看下 这个 Safari 的问题… Start by running multica issue get 01a1690d… Post your reply as a comment: … comment add 01a1690d… --parent 01a1690e-1277… ## On Behalf Of demo (demo@example.com) 起因、现成的命令、代谁干活; 没有需求本身。 issue get mat_ "title": "Safari 登录按钮 无响应", "description": "登录页在 Safari 上点「提交」没反应。 验收标准: 1. Safari 17 能正常提交 2. 补一个 e2e 用例 …", "status": "todo", … comment list mat_ --roots-only --summary 每个线程的根评论 + reply_count、最后活跃时间 需求本体只在这一列出现。
图 4 · 身份和规则在启动前写进工作目录,这一回合的起因写进 stdin,需求本体由 agent 带着 mat_ 自己拉

第一层:写进工作目录的身份和规则

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 your
resumed context, and re-read with `multica issue get …` only if resumed memory is not enough.

评论也一样,服务端数过上一轮之后有没有新评论,没有就直说“没有其它新评论”,省掉一次扫描。拉取还是默认的做法,推过来的只是“不用拉了”这个判断。

把一条 @ 带进 agent 的东西按路径归一下类:

一条 @ 带进 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 getagent 带着 mat_ 请求
评论历史multica issue comment list

前五行是推的,变化少或者只属于这一回合;后两行是拉的,内容多、会变。

图 5 · 身份、起因和凭证推给 agent,需求和讨论由 agent 自己拉

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 就在回答里夹带插话:

一条插话怎么进到正在运行的回合 53.367 我发评论并带上 steer_task_ids,server 经 WebSocket 推 task_supplement_available;53.372 daemon 调 supplements/claim 拿到作者和原文;claude 下一次要调用工具时发来 PreToolUse 的 hook_callback;daemon 在 control_response 里返回 additionalContext,内容以 [ADDITIONAL GUIDANCE] Human demo 开头,附上插话原文;53.494 daemon 调 ack,delivered 为 true。 平台 我的电脑 我 再发一条评论 Multica server 不建新任务 multica daemon 持有 stdin claude -p 回合进行中 steer_task_ids 53.367 WS · supplement_available 53.372 POST …/supplements/claim "插一句:复现时记得关掉…" PreToolUse hook additionalContext 53.494 POST …/ack · delivered
图 6 · 插话没有新开回合:daemon 等 Claude 下一次调用工具前问钩子时,把它作为附加上下文夹带进去

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 里说明接不上新建
手动操作
在网页上对某一轮点“重跑”接那一轮的会话,不是最近一轮复用那一轮的
失败后系统自动重试,且要求干净开始新会话

会话和目录分开判断:会话有一点不可靠就放弃,目录能复用就复用,代码改了一半的状态尽量不丢。

图 7 · 再 @ 一次,默认接上同一个 agent 在这个 issue 上最近一段还能用的会话

第三行值得多说一句。一段对话如果是因为“上下文太长”或者“模型拒绝了这段历史”而失败的,再 --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 server
computed this delta, and reading it is the scan workflow step 2 requires. Read exactly
those 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 and
address 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/workdir
progress.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 的进度在哪?在第一条下面的回复里,扫描结果只显示为:

JSON
{ "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 和最后活跃时间去判断。

两个 agent 之间只隔着评论 左边是 reviewer 的第二轮:带 --resume fake-session-220349,工作目录 demo-3-66371cba076d,里面的 progress.md 写着修了一半,这个文件只在它的目录里。它用 comment add --parent 把进度发成回复。中间是 DEMO-3 的评论:根评论都是用户 demo 写的,reviewer 的进度是第一个线程下的回复。右边是 fixer 的第一轮:没有 --resume,工作目录 demo-3-2989e6c10953,没有 progress.md;comment list --roots-only 只看到三条根评论和 reply_count 1,要用 --thread 展开才读到 reviewer 的进度。 reviewer 的第二轮 DEMO-3 的评论 fixer 的第一轮 claude pid 新的 --resume fake-session-220349 cwd demo-3-66371cba076d/workdir progress.md 定位到 form.tsx onSubmit 缺 preventDefault,修了一半 虚线:只在 reviewer 的工作目录里, 别的 agent 读不到。 comment add --parent … demo · 22:03:49 @reviewer 先看下 Safari 的问题 reviewer · 回复 · 22:03:53 进度:问题在 form.tsx 的 onSubmit 缺 preventDefault 改动只在本机,还没提交 demo · 22:04:13 @fixer 接着 reviewer 的进度… 根评论都是人写的, agent 的进度在回复里。 claude 新会话 没有 --resume cwd demo-3-2989e6c10953/workdir progress.md 不存在 comment list --roots-only 3 条根评论,都是 demo 的 第一条 "reply_count": 1 comment list --thread 进度:问题在 form.tsx 的 … 展开第一个线程才读到
图 8 · 两个 agent 的会话和工作目录互不相通,前一个的进度只有发成评论,后一个才读得到

代码也是一样。如果 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 毫秒里经过了前六个问题,后两个是它之后的事:

  1. @ 在服务端变成了什么? 一行只有 id 的任务:叫哪个 agent、在哪台机器、哪个 issue、哪条评论。内容一律等开工时现查;同一个 agent 在同一个 issue 上一次只跑一个。
  2. 走的是哪个通信层? daemon 主动连出的一条 WebSocket。上面只有心跳、“有活了”的提示,以及 daemon 发起的 tasks.claim 请求;它不通时,同样的事退回 HTTP,每 30 秒轮询一次。
  3. 为什么不直接推任务? 推出去的东西可能落空,该领几个也只有本机知道。所以提示可以丢,领取是一次带确认的事务,结果不明时宁可不重领。
  4. 需求上下文怎么进来? 分三层:身份和规则写进工作目录的 CLAUDE.md 和技能目录;这一回合的起因(触发评论原文、现成的命令、代谁干活)写进 stdin;issue 描述和评论历史不在任务包里,由 agent 按工作流自己用 multica issue get 和 comment list 拉。续接会话时,服务端会告诉它“没变,不用重读”。
  5. 拉取用谁的身份? 每个任务一枚 mat_ 令牌,以 agent 的名义、所有者的权限访问整个工作区,任务结束即删。
  6. 运行中再 @ 一次呢? 默认排队,下一轮续接会话;指定插进当前这一轮时,借 Claude 的工具钩子把话作为附加上下文塞进去。
  7. 隔一会儿再 @,接上的是什么? 每轮都是新的 claude 进程。服务端按“这个 agent、这个 issue”找最近一段还能用的会话,连同它的工作目录一起交给 daemon,用 --resume 接上;换了电脑、目录被清掉、上一轮把对话本身弄坏了,就开新会话并在 prompt 里说明。同一个线程里排队时追加的 @ 会并成一轮。
  8. 换一个 agent 来接手呢? 会话和工作目录都不共享,也没有专门的交接通道。后来的 agent 只能从 issue 和评论里读到前一个的进度,前一个本机上没提交的改动它看不到。所以要让前一个把结论写进评论、把代码推到分支,或者干脆由它在评论里 @ 下一个。

如果想自己看一遍,最省事的办法就是本文的做法:用 multica daemon start --foreground 跑 daemon,在它和服务端之间放一个记录报文的代理,再把 PATH 里的 claude 换成一个把 stdin 和环境变量存下来的脚本。调研笔记、原始报文和这两个小工具都放在了项目资料里。

参考资料

Footnotes

  1. CLI and Agent Daemon GuideGitHub · multica-ai/multica收藏github.comhttps://github.com/multica-ai/multica/blob/main/CLI_AND_DAEMON.md ↩

  2. server/internal/util/mention.goMultica 源码github.com ↗本文读的是 2026-10-23 的提交 dbdbb29;MentionRe 正则和 ParseMentionshttps://github.com/multica-ai/multica/blob/main/server/internal/util/mention.go ↩

  3. server/internal/handler/comment.goMultica 源码github.com ↗computeCommentAgentTriggers 决定一条评论叫醒谁;steerCommentAgentTriggers 处理插话https://github.com/multica-ai/multica/blob/main/server/internal/handler/comment.go ↩

  4. server/internal/service/task.goMultica 源码github.com ↗enqueueMentionTaskWithCommentPlan 建任务行;notifyTaskAvailable 先作废空结果缓存、再发唤醒https://github.com/multica-ai/multica/blob/main/server/internal/service/task.go ↩

  5. server/pkg/db/queries/agent.sqlMultica 源码github.com ↗ClaimAgentTask:runtime 在线、同一 (issue, agent) 没有进行中的任务才可领取,FOR UPDATE SKIP LOCKEDhttps://github.com/multica-ai/multica/blob/main/server/pkg/db/queries/agent.sql ↩ ↩2 ↩3

  6. 把本地 Agent 变成平台上的 Bot:架构、现状与安全风险twwy.nettwwy.net ↗为什么 agent 要留在本地、为什么由本机主动连出去、多个任务怎么共用一个目录https://twwy.net/blog/local-agent-as-bot/ ↩

  7. server/internal/daemonws/hub.goMultica 源码github.com ↗daemon WebSocket 的服务端;taskWakeupFrame 只放 runtime_id 和 task_id;handleRPCFrame 处理 daemon:rpc_requesthttps://github.com/multica-ai/multica/blob/main/server/internal/daemonws/hub.go ↩

  8. server/internal/daemonws/notifier.goMultica 源码github.com ↗多台 server 时经 Redis relay 广播唤醒,由持有该 daemon 连接的节点送达https://github.com/multica-ai/multica/blob/main/server/internal/daemonws/notifier.go ↩

  9. server/internal/handler/daemon_rpc.goMultica 源码github.com ↗tasks.claim RPC 在进程内伪造一个 HTTP 请求,复用 ClaimTasksByRuntimehttps://github.com/multica-ai/multica/blob/main/server/internal/handler/daemon_rpc.go ↩

  10. server/internal/daemon/wsrpc.goMultica 源码github.com ↗errWSRPCUncertain:请求已发出、连接断了,结果未知,不许改走 HTTPhttps://github.com/multica-ai/multica/blob/main/server/internal/daemon/wsrpc.go ↩

  11. server/internal/daemon/config.goMultica 源码github.com ↗DefaultPollInterval = 30s、DefaultWSClaimPollInterval = 3m、DefaultHeartbeatInterval = 15shttps://github.com/multica-ai/multica/blob/main/server/internal/daemon/config.go ↩

  12. server/internal/daemon/daemon.goMultica 源码github.com ↗pollLoop / runBatchPoller / taskClaimPollInterval;taskMulticaEnvironment 给 CLI 的环境变量https://github.com/multica-ai/multica/blob/main/server/internal/daemon/daemon.go ↩ ↩2

  13. server/internal/handler/daemon.goMultica 源码github.com ↗buildClaimedTaskResponse 组任务包:现查 agent、嵌入触发评论、算 issue 变化,签发 mat_ 令牌https://github.com/multica-ai/multica/blob/main/server/internal/handler/daemon.go ↩ ↩2 ↩3

  14. server/internal/daemon/execenv/runtime_config_sections.goMultica 源码收藏github.com写进工作目录的 CLAUDE.md / AGENTS.md 运行时简报,各节标题都在这里https://github.com/multica-ai/multica/blob/main/server/internal/daemon/execenv/runtime_config_sections.go ↩ ↩2

  15. server/pkg/agent/claude.goMultica 源码收藏github.combuildClaudeArgs 的固定参数;buildClaudeInput 把 prompt 包成 stream-json 的 user 消息https://github.com/multica-ai/multica/blob/main/server/pkg/agent/claude.go ↩

  16. server/internal/daemon/prompt.goMultica 源码github.com ↗buildCommentPrompt;perTurnContextBlocks 把每次都变的块追加到末尾,避开 prompt cache 前缀(MUL-5377)https://github.com/multica-ai/multica/blob/main/server/internal/daemon/prompt.go ↩

  17. server/internal/daemon/execenv/issue_state_instructions.goMultica 源码github.com ↗BuildIssueStateHint:续接会话时告诉 agent issue 有没有变(MUL-7344)https://github.com/multica-ai/multica/blob/main/server/internal/daemon/execenv/issue_state_instructions.go ↩

  18. server/internal/auth/jwt.goMultica 源码github.com ↗GenerateAgentTaskToken:mat_ + 40 位十六进制,绑定 (agent_id, task_id)https://github.com/multica-ai/multica/blob/main/server/internal/auth/jwt.go ↩

  19. server/internal/middleware/auth.goMultica 源码github.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 ↩

  20. server/internal/handler/actor_guards.goMultica 源码github.com ↗RequireHumanActor 只挂在计费这类“仅限人”的路由上,issue 和 chat 路由放行 agenthttps://github.com/multica-ai/multica/blob/main/server/internal/handler/actor_guards.go ↩

  21. server/pkg/agent/claude_supplement.goMultica 源码github.com ↗借 Claude 的 SDK hooks 在回合中途插入 additionalContexthttps://github.com/multica-ai/multica/blob/main/server/pkg/agent/claude_supplement.go ↩

  22. Hooks reference · Add context for ClaudeClaude Code 文档code.claude.com ↗Multica 的插话实现在源码注释里引用的就是这一节https://code.claude.com/docs/en/hooks ↩

  23. server/internal/daemon/task_supplement.goMultica 源码github.com ↗daemon 侧插话循环:收到提示 → claim → 注入 → ack;[ADDITIONAL GUIDANCE] 文案https://github.com/multica-ai/multica/blob/main/server/internal/daemon/task_supplement.go ↩

  24. How Claude Code works · Work with sessionsClaude Code 文档code.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 ↩

  25. server/internal/daemon/execenv/execenv.goMultica 源码github.com ↗PredictRootDir 决定工作目录路径;Reuse 在目录还在时复用,否则退回新建https://github.com/multica-ai/multica/blob/main/server/internal/daemon/execenv/execenv.go ↩

  26. server/migrations/452_agent_task_pending_thread_unique.up.sqlMultica 源码github.com ↗排队中任务的唯一键是 (issue, agent, 评论线程),同一线程的追问并进同一轮https://github.com/multica-ai/multica/blob/main/server/migrations/452_agent_task_pending_thread_unique.up.sql ↩

  27. server/internal/daemon/repocache/cache.goMultica 源码github.com ↗每个任务的代码检出在自己的分支 agent/{agent 名}/{任务} 上https://github.com/multica-ai/multica/blob/main/server/internal/daemon/repocache/cache.go ↩