﻿---
title: "Multica 里 @ 一个 Agent 之后：通信链路与需求上下文的注入"
description: "在 Multica 的 issue 下面 @ 一个 agent，190 毫秒后，本机上的 Claude Code 就在读这个 issue 的需求描述了。顺着这条消息往下追：@ 在服务端变成了什么，平台和本机之间到底走的是哪条连接、上面传了什么，daemon 领到的任务包里有什么、没有什么，issue 里的需求是推给 CLI 的还是 CLI 自己拉的，用的又是谁的凭证，agent 正在干活时再 @ 它一次会发生什么；再往后，隔一会儿再 @ 它，接上的是哪一段会话，换一个 agent 来接手，它又怎么知道前一个做到了哪。"
pubDate: 2026-10-23
tags: ["AI", "Agent", "Multica", "源码分析", "架构"]
hero: "./hero.svg"
refs:
  - id: src-mention
    title: "server/internal/util/mention.go"
    url: https://github.com/multica-ai/multica/blob/main/server/internal/util/mention.go
    site: Multica 源码
    note: "本文读的是 2026-10-23 的提交 `dbdbb29`；`MentionRe` 正则和 `ParseMentions`"
  - id: src-comment
    title: "server/internal/handler/comment.go"
    url: https://github.com/multica-ai/multica/blob/main/server/internal/handler/comment.go
    site: Multica 源码
    note: "`computeCommentAgentTriggers` 决定一条评论叫醒谁；`steerCommentAgentTriggers` 处理插话"
  - id: src-enqueue
    title: "server/internal/service/task.go"
    url: https://github.com/multica-ai/multica/blob/main/server/internal/service/task.go
    site: Multica 源码
    note: "`enqueueMentionTaskWithCommentPlan` 建任务行；`notifyTaskAvailable` 先作废空结果缓存、再发唤醒"
  - id: src-claim-sql
    title: "server/pkg/db/queries/agent.sql"
    url: https://github.com/multica-ai/multica/blob/main/server/pkg/db/queries/agent.sql
    site: Multica 源码
    note: "`ClaimAgentTask`：runtime 在线、同一 (issue, agent) 没有进行中的任务才可领取，`FOR UPDATE SKIP LOCKED`"
  - id: src-hub
    title: "server/internal/daemonws/hub.go"
    url: https://github.com/multica-ai/multica/blob/main/server/internal/daemonws/hub.go
    site: Multica 源码
    note: "daemon WebSocket 的服务端；`taskWakeupFrame` 只放 runtime_id 和 task_id；`handleRPCFrame` 处理 `daemon:rpc_request`"
  - id: src-notifier
    title: "server/internal/daemonws/notifier.go"
    url: https://github.com/multica-ai/multica/blob/main/server/internal/daemonws/notifier.go
    site: Multica 源码
    note: "多台 server 时经 Redis relay 广播唤醒，由持有该 daemon 连接的节点送达"
  - id: src-rpc
    title: "server/internal/handler/daemon_rpc.go"
    url: https://github.com/multica-ai/multica/blob/main/server/internal/handler/daemon_rpc.go
    site: Multica 源码
    note: "`tasks.claim` RPC 在进程内伪造一个 HTTP 请求，复用 `ClaimTasksByRuntime`"
  - id: src-wsrpc
    title: "server/internal/daemon/wsrpc.go"
    url: https://github.com/multica-ai/multica/blob/main/server/internal/daemon/wsrpc.go
    site: Multica 源码
    note: "`errWSRPCUncertain`：请求已发出、连接断了，结果未知，不许改走 HTTP"
  - id: src-daemon
    title: "server/internal/daemon/daemon.go"
    url: https://github.com/multica-ai/multica/blob/main/server/internal/daemon/daemon.go
    site: Multica 源码
    note: "`pollLoop` / `runBatchPoller` / `taskClaimPollInterval`；`taskMulticaEnvironment` 给 CLI 的环境变量"
  - id: src-config
    title: "server/internal/daemon/config.go"
    url: https://github.com/multica-ai/multica/blob/main/server/internal/daemon/config.go
    site: Multica 源码
    note: "`DefaultPollInterval = 30s`、`DefaultWSClaimPollInterval = 3m`、`DefaultHeartbeatInterval = 15s`"
  - id: src-claim
    title: "server/internal/handler/daemon.go"
    url: https://github.com/multica-ai/multica/blob/main/server/internal/handler/daemon.go
    site: Multica 源码
    note: "`buildClaimedTaskResponse` 组任务包：现查 agent、嵌入触发评论、算 issue 变化，签发 `mat_` 令牌"
  - id: src-prompt
    title: "server/internal/daemon/prompt.go"
    url: https://github.com/multica-ai/multica/blob/main/server/internal/daemon/prompt.go
    site: Multica 源码
    note: "`buildCommentPrompt`；`perTurnContextBlocks` 把每次都变的块追加到末尾，避开 prompt cache 前缀（MUL-5377）"
  - id: src-brief
    title: "server/internal/daemon/execenv/runtime_config_sections.go"
    url: https://github.com/multica-ai/multica/blob/main/server/internal/daemon/execenv/runtime_config_sections.go
    collect: "github-multica-ai-multica-server-internal-daemon"
    site: Multica 源码
    note: "写进工作目录的 CLAUDE.md / AGENTS.md 运行时简报，各节标题都在这里"
  - id: src-issue-state
    title: "server/internal/daemon/execenv/issue_state_instructions.go"
    url: https://github.com/multica-ai/multica/blob/main/server/internal/daemon/execenv/issue_state_instructions.go
    site: Multica 源码
    note: "`BuildIssueStateHint`：续接会话时告诉 agent issue 有没有变（MUL-7344）"
  - id: src-claude
    title: "server/pkg/agent/claude.go"
    url: https://github.com/multica-ai/multica/blob/main/server/pkg/agent/claude.go
    collect: "github-multica-ai-multica-server-pkg-agent-claud"
    site: Multica 源码
    note: "`buildClaudeArgs` 的固定参数；`buildClaudeInput` 把 prompt 包成 stream-json 的 user 消息"
  - id: src-supplement
    title: "server/pkg/agent/claude_supplement.go"
    url: https://github.com/multica-ai/multica/blob/main/server/pkg/agent/claude_supplement.go
    site: Multica 源码
    note: "借 Claude 的 SDK hooks 在回合中途插入 `additionalContext`"
  - id: src-task-supplement
    title: "server/internal/daemon/task_supplement.go"
    url: https://github.com/multica-ai/multica/blob/main/server/internal/daemon/task_supplement.go
    site: Multica 源码
    note: "daemon 侧插话循环：收到提示 → claim → 注入 → ack；`[ADDITIONAL GUIDANCE]` 文案"
  - id: src-token
    title: "server/internal/auth/jwt.go"
    url: https://github.com/multica-ai/multica/blob/main/server/internal/auth/jwt.go
    site: Multica 源码
    note: "`GenerateAgentTaskToken`：`mat_` + 40 位十六进制，绑定 (agent_id, task_id)"
  - id: src-auth-mw
    title: "server/internal/middleware/auth.go"
    url: https://github.com/multica-ai/multica/blob/main/server/internal/middleware/auth.go
    site: Multica 源码
    note: "`mat_` 分支用令牌记录覆盖 X-User-ID / X-Agent-ID / X-Task-ID / X-Workspace-ID"
  - id: src-actor
    title: "server/internal/handler/actor_guards.go"
    url: https://github.com/multica-ai/multica/blob/main/server/internal/handler/actor_guards.go
    site: Multica 源码
    note: "`RequireHumanActor` 只挂在计费这类“仅限人”的路由上，issue 和 chat 路由放行 agent"
  - id: src-pending-index
    title: "server/migrations/452_agent_task_pending_thread_unique.up.sql"
    url: https://github.com/multica-ai/multica/blob/main/server/migrations/452_agent_task_pending_thread_unique.up.sql
    site: Multica 源码
    note: "排队中任务的唯一键是 (issue, agent, 评论线程)，同一线程的追问并进同一轮"
  - id: src-execenv
    title: "server/internal/daemon/execenv/execenv.go"
    url: https://github.com/multica-ai/multica/blob/main/server/internal/daemon/execenv/execenv.go
    site: Multica 源码
    note: "`PredictRootDir` 决定工作目录路径；`Reuse` 在目录还在时复用，否则退回新建"
  - id: src-repocache
    title: "server/internal/daemon/repocache/cache.go"
    url: https://github.com/multica-ai/multica/blob/main/server/internal/daemon/repocache/cache.go
    site: Multica 源码
    note: "每个任务的代码检出在自己的分支 `agent/{agent 名}/{任务}` 上"
  - id: claude-sessions
    title: "How Claude Code works · Work with sessions"
    url: https://code.claude.com/docs/en/how-claude-code-works
    site: Claude Code 文档
    note: "会话记录存在 `~/.claude/projects/` 下，“Each Claude Code conversation is a session tied to your current directory”"
  - id: cli-doc
    title: "CLI and Agent Daemon Guide"
    url: https://github.com/multica-ai/multica/blob/main/CLI_AND_DAEMON.md
    collect: "github-multica-ai-multica-cli-and-daemon-md"
    site: GitHub · multica-ai/multica
  - id: claude-hooks
    title: "Hooks reference · Add context for Claude"
    url: https://code.claude.com/docs/en/hooks
    site: Claude Code 文档
    note: "Multica 的插话实现在源码注释里引用的就是这一节"
  - id: local-agent
    title: "把本地 Agent 变成平台上的 Bot：架构、现状与安全风险"
    url: https://twwy.net/blog/local-agent-as-bot/
    site: twwy.net
    note: "为什么 agent 要留在本地、为什么由本机主动连出去、多个任务怎么共用一个目录"
---

在 Multica 的一个 issue 下面写了一条评论，@ 了一个叫 reviewer 的 agent。评论框里看到的是一个蓝色的 @reviewer，存进数据库的却是这样一行 Markdown：

```text
[@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 仓库里[^cli-doc]。为了看清每一步，我在本地起了一套：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 之后的秒数）：

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 640" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="mam-seq-title mam-seq-desc">
<title id="mam-seq-title">一条 @ 从评论到 agent 回复的全部通信</title>
<desc id="mam-seq-desc">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 删除令牌。</desc>
<defs>
<marker id="mam-seq-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker>
<marker id="mam-seq-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="640" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="412" height="560" rx="8"/>
<text class="t-zone" x="36" y="44">平台</text>
<rect class="dd-zone" x="440" y="16" width="344" height="560" rx="8"/>
<text class="t-zone" x="460" y="44">我的电脑</text>
<path class="dd-edge dd-dash" d="M140 104 V556"/>
<path class="dd-edge dd-dash" d="M340 104 V556"/>
<path class="dd-edge dd-dash" d="M560 104 V556"/>
<path class="dd-edge dd-dash" d="M712 104 V556"/>
<path class="dd-edge" d="M140 140 H332" marker-end="url(#mam-seq-arrow)"/>
<path class="dd-edge" d="M340 180 H552" marker-end="url(#mam-seq-arrow)"/>
<path class="dd-edge" d="M560 220 H348" marker-end="url(#mam-seq-arrow)"/>
<path class="dd-edge-acc" d="M340 260 H552" marker-end="url(#mam-seq-arrow-acc)"/>
<path class="dd-edge" d="M560 300 H348" marker-end="url(#mam-seq-arrow)"/>
<path class="dd-edge-acc" d="M560 340 H704" marker-end="url(#mam-seq-arrow-acc)"/>
<path class="dd-edge-acc" d="M712 380 H348" marker-end="url(#mam-seq-arrow-acc)"/>
<path class="dd-edge" d="M712 420 H348" marker-end="url(#mam-seq-arrow)"/>
<path class="dd-edge" d="M712 460 H568" marker-end="url(#mam-seq-arrow)"/>
<path class="dd-edge" d="M560 460 H348" marker-end="url(#mam-seq-arrow)"/>
<path class="dd-edge-acc" d="M712 500 H348" marker-end="url(#mam-seq-arrow-acc)"/>
<path class="dd-edge" d="M560 540 H348" marker-end="url(#mam-seq-arrow)"/>
<rect class="dd-card" x="80" y="56" width="120" height="48" rx="6"/>
<text class="t-name" x="140" y="76" text-anchor="middle">我</text>
<text class="t-note" x="140" y="94" text-anchor="middle">网页或 multica CLI</text>
<rect class="dd-card" x="270" y="56" width="140" height="48" rx="6"/>
<text class="t-name" x="340" y="76" text-anchor="middle">Multica server</text>
<text class="t-note" x="340" y="94" text-anchor="middle">:8080 · PostgreSQL</text>
<rect class="dd-card" x="490" y="56" width="140" height="48" rx="6"/>
<text class="t-name" x="560" y="76" text-anchor="middle">multica daemon</text>
<text class="t-note" x="560" y="94" text-anchor="middle">常驻后台</text>
<rect class="dd-card" x="648" y="56" width="128" height="48" rx="6"/>
<text class="t-name" x="712" y="76" text-anchor="middle">claude -p</text>
<text class="t-note" x="712" y="94" text-anchor="middle">daemon 的子进程</text>
<text class="t-mono" x="28" y="144">12.326</text>
<text class="t-mono" x="160" y="132">POST …/comments</text>
<text class="t-mono" x="28" y="184">12.374</text>
<text class="t-mono" x="358" y="172">WS · task_available</text>
<text class="t-mono" x="28" y="224">12.375</text>
<text class="t-mono" x="358" y="212">WS · rpc tasks.claim</text>
<text class="t-mono" x="28" y="264">12.412</text>
<text class="t-mono" x="358" y="252">WS · 任务包 + <tspan class="t-acc">mat_…</tspan></text>
<text class="t-mono" x="28" y="304">12.458</text>
<text class="t-mono" x="358" y="292">POST …/start</text>
<text class="t-mono" x="28" y="344">≈12.47</text>
<text class="t-note" x="576" y="332">起进程 · 写 stdin</text>
<text class="t-mono" x="28" y="384">12.518</text>
<text class="t-mono" x="358" y="372">GET /issues/DEMO-1 · <tspan class="t-acc">mat_</tspan></text>
<text class="t-mono" x="28" y="424">12.533</text>
<text class="t-mono" x="358" y="412">GET …/comments?roots_only</text>
<text class="t-mono" x="28" y="464">12.540</text>
<text class="t-mono" x="358" y="452">POST …/messages</text>
<text class="t-mono" x="582" y="452">stream-json</text>
<text class="t-mono" x="28" y="504">15.574</text>
<text class="t-mono" x="358" y="492">comment add --parent · <tspan class="t-acc">mat_</tspan></text>
<text class="t-mono" x="28" y="544">16.161</text>
<text class="t-mono" x="358" y="532">POST …/complete（删 <tspan class="t-acc">mat_</tspan>）</text>
<text class="t-say" x="36" y="604">平台推给电脑的只有“有活了”和一个任务包；需求描述是 agent 在 12.518 自己拿着 mat_ 拉回来的。</text>
<text class="t-say" x="36" y="624">daemon 与 server 之间的前四步都走同一条 WebSocket，之后的上报才改用普通 HTTP。</text>
</svg>
</div>
<figcaption>图 1 · 一条 @ 引起的全部通信：WebSocket 只负责叫醒和领取，需求本体由 agent 自己拉取</figcaption>
</figure>

图里有一条线索值得跟着看：强调色标出的 `mat_…`。它在领取任务时由服务端签发，跟着任务包到了 daemon，daemon 把它放进 `claude` 进程的环境变量，agent 之后读 issue、发回复用的都是它，任务结束时服务端把它删掉。

这张图也透露了整个设计的骨架：**平台和电脑之间推送的东西极少，大部分内容是“拉”过来的。** WebSocket 上只说“有活了”，daemon 去领一个任务包；任务包里也只有身份、规则和这一回合的起因，issue 的需求描述要等 agent 自己去拉。后面几节就分别回答：推的为什么这么少，推了哪些，拉的又是怎么拉的。

先从源头说起：**那行 `mention://` 链接，在服务端变成了什么？**

## 二、从评论到任务

### 识别 @

服务端不认显示名，只认链接里的 uuid。评论保存后，`ParseMentions` 用一条正则把正文里所有 `[@名字](mention://类型/id)` 抠出来，类型有 `member`、`agent`、`squad`、`issue`、`all` 五种[^src-mention]：

```text
\[@?(.+?)\]\(mention://(member|agent|squad|issue|all)/([0-9a-fA-F-]+|all)\)
```

所以 @ 的语义是在编辑器里就定下来的：你在下拉框里选中的是哪个 agent，链接里就是它的 id，改了名字也不会认错。

拿到 mention 之后，`computeCommentAgentTriggers` 决定这条评论要叫醒谁[^src-comment]。显式 @ 一个 agent 优先级最高；@ 一个小队（squad）叫醒的是队长；回复某个 agent 发的评论，即使不写 @，也会叫醒那个 agent。这一步还会检查调用权限（私有 agent 只有所有者能叫）和归属，记下这次是“人直接叫的”还是“另一个 agent 转派的”。

### 只写 id 的任务行

决定叫醒 reviewer 之后，服务端在 `agent_task_queue` 表里插一行[^src-enqueue]。这一行长什么样，直接决定了后面传什么：

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 348" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="mam-row-title mam-row-desc">
<title id="mam-row-title">一条评论变成一行任务</title>
<desc id="mam-row-desc">左边是 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 指令都没有复制进这一行。</desc>
<defs>
<marker id="mam-row-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="348" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="360" height="316" rx="8"/>
<text class="t-zone" x="36" y="44">comment 表</text>
<rect class="dd-zone" x="424" y="16" width="360" height="316" rx="8"/>
<text class="t-zone" x="444" y="44">agent_task_queue 表</text>
<path class="dd-edge-acc" d="M360 136 H392 Q400 136 400 144 V164 Q400 172 408 172 H432" marker-end="url(#mam-row-arrow-acc)"/>
<rect class="dd-card" x="32" y="64" width="328" height="162" rx="6"/>
<path class="dd-card-head" d="M38 64 H354 Q360 64 360 70 V96 H32 V70 Q32 64 38 64 Z"/>
<text class="t-name" x="44" y="85">comment</text>
<text class="t-mono" x="348" y="85" text-anchor="end">01a1690e-1277…</text>
<text class="t-mono" x="44" y="113">content</text>
<line class="dd-hair" x1="32" y1="122" x2="360" y2="122"/>
<text class="t-val" x="44" y="139">[@reviewer](mention://agent/<tspan class="t-acc">c620e7c8…</tspan>)</text>
<text class="t-val" x="44" y="158">帮忙看下这个 Safari 的问题，先别改代码…</text>
<line class="dd-hair" x1="32" y1="174" x2="360" y2="174"/>
<text class="t-mono" x="44" y="191">author_type</text>
<text class="t-val" x="348" y="191" text-anchor="end">member</text>
<line class="dd-hair" x1="32" y1="200" x2="360" y2="200"/>
<text class="t-mono" x="44" y="217">issue_id</text>
<text class="t-val" x="348" y="217" text-anchor="end">DEMO-1</text>
<text class="t-note" x="44" y="256">ParseMentions → 权限、归属检查</text>
<text class="t-note" x="44" y="276">→ CreateAgentTask → 发唤醒</text>
<rect class="dd-card" x="440" y="64" width="328" height="188" rx="6"/>
<path class="dd-card-head" d="M446 64 H762 Q768 64 768 70 V96 H440 V70 Q440 64 446 64 Z"/>
<text class="t-name" x="452" y="85">task</text>
<text class="t-mono" x="756" y="85" text-anchor="end">01a1690e-128a…</text>
<text class="t-mono" x="452" y="113">agent_id</text>
<text class="t-val" x="756" y="113" text-anchor="end"><tspan class="t-acc">c620e7c8…</tspan></text>
<line class="dd-hair" x1="440" y1="122" x2="768" y2="122"/>
<text class="t-mono" x="452" y="139">runtime_id</text>
<text class="t-val" x="756" y="139" text-anchor="end">7e5fdbe6…</text>
<line class="dd-hair" x1="440" y1="148" x2="768" y2="148"/>
<text class="t-mono" x="452" y="165">issue_id</text>
<text class="t-val" x="756" y="165" text-anchor="end">01a1690d-fc37…</text>
<line class="dd-hair" x1="440" y1="174" x2="768" y2="174"/>
<text class="t-mono" x="452" y="191">trigger_comment_id</text>
<text class="t-val" x="756" y="191" text-anchor="end">01a1690e-1277…</text>
<line class="dd-hair" x1="440" y1="200" x2="768" y2="200"/>
<text class="t-mono" x="452" y="217">status</text>
<text class="t-val" x="756" y="217" text-anchor="end">queued</text>
<line class="dd-hair" x1="440" y1="226" x2="768" y2="226"/>
<text class="t-mono" x="452" y="243">priority</text>
<text class="t-val" x="756" y="243" text-anchor="end">3（high）</text>
<rect class="dd-derived" x="440" y="264" width="328" height="52" rx="6"/>
<text class="t-note" x="452" y="284">issue 标题和描述 · 评论历史 · agent 指令</text>
<text class="t-say" x="452" y="304">虚线里的都没复制进来，领取时才现查</text>
</svg>
</div>
<figcaption>图 2 · 一条 @ 在服务端只变成一行 id：要叫谁、在哪台机器、哪个 issue、哪条评论</figcaption>
</figure>

这一行里没有 issue 的标题和描述，没有 agent 的指令，连评论正文也没有，只有 `trigger_comment_id` 指回原评论。任务可能要在队列里等：电脑可能关着，同一个 agent 可能正在干别的。等到真正开工时，agent 的指令可能已经被改过，评论可能被编辑过，所以这里只记“是谁、为什么”，内容一律等领取时现查。

### 同一个 agent，同一个 issue，一次只跑一个

领取任务的那条 SQL 有两个条件很关键[^src-claim-sql]：runtime 必须在线、最近有心跳；同一个 agent 在同一个 issue 上，不能已经有一个 `dispatched` 或 `running` 的任务。后一条让同一个 agent 在一个 issue 上严格串行，不同的 agent 可以并行。

我把 daemon 停掉，连发两条 @，库里就躺着两行 `queued`；daemon 一上线，两轮依次跑完，第二轮的命令行里多了 `--resume`，接着第一轮的会话继续聊。电脑关机时 @ 它不会丢，只是排队。

任务行写好之后，服务端要通知那台电脑。**它和电脑之间到底是一条什么样的连接？**

## 三、通信层：一条连出去的 WebSocket

### 连接由 daemon 发起

daemon 启动后做的事情从代理里看得很清楚：先用登录时拿到的个人令牌（`mul_` 开头）调几个 HTTP 接口，拉工作区列表，注册本机检测到的 CLI（这里是 claude），然后主动发起一条 WebSocket：

```text
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，也不需要开端口（为什么非得这样，另一篇文章里讲过[^local-agent]）。连上以后，平台要找这台电脑，就只能等这条连接上有它的消息。

### 唤醒帧只有两个 id

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

```json
{"type":"daemon:task_available","payload":{"runtime_id":"7e5fdbe6-…","task_id":"01a1690e-128a-70bd-8fb7-0c551377e193"}}
```

就这么多。服务端组这一帧的函数只放了 runtime_id 和 task_id 两个字段[^src-hub]。

**为什么不把整个任务直接推过去？** 最直接的想法是：反正连接是通的，服务端把任务内容整包推下去，daemon 收到就开工，还能省一次往返。

问题在于“推下去”不等于“对方收到了”。WebSocket 断线、daemon 正在重启、发送缓冲满了，推出去的任务都可能落空，服务端还得另做确认、重发、去重。更麻烦的是一台电脑上可能同时有好几个任务能跑，也可能一个都跑不动（并发槽满了），该领多少只有 daemon 自己知道。

所以 Multica 把它拆成两步：服务端只发一个“你那有活了”的提示，丢了也无所谓；daemon 收到提示后按自己的空闲槽位去**领取**，领取是一次有请求、有响应的事务，数据库里用 `FOR UPDATE SKIP LOCKED` 保证一个任务只被一个人领走。服务端有好几台时也一样，唤醒经 Redis 广播到所有节点，只有手上握着这台 daemon 连接的那一台把它送出去，送重了 daemon 按事件 id 去重[^src-notifier]。

### 领取也走这条 WebSocket

1 毫秒后，daemon 在同一条连接上回了一帧，这次是一个 RPC 请求：

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 452" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="mam-ws-title mam-ws-desc">
<title id="mam-ws-title">WebSocket 上的四种帧和它们的 HTTP 兜底</title>
<desc id="mam-ws-desc">左边是 /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 秒才被领走。</desc>
<defs>
<marker id="mam-ws-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="452" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="536" height="420" rx="8"/>
<text class="t-zone" x="36" y="44">WebSocket · GET /api/daemon/ws</text>
<rect class="dd-derived" x="568" y="16" width="216" height="420" rx="8"/>
<text class="t-zone" x="588" y="44">WebSocket 不通时</text>
<rect class="dd-card" x="32" y="64" width="504" height="64" rx="6"/>
<text class="t-name" x="44" y="86">daemon → server · 每 15 s</text>
<text class="t-small" x="44" y="108">{"type":"daemon:heartbeat","payload":{"runtime_id":"7e5fdbe6…"}}</text>
<text class="t-note" x="524" y="86" text-anchor="end">回 heartbeat_ack</text>
<rect class="dd-card" x="32" y="140" width="504" height="64" rx="6"/>
<text class="t-name" x="44" y="162">server → daemon · 12.374</text>
<text class="t-small" x="44" y="184">{"type":"daemon:task_available","payload":{"runtime_id":"7e5fdbe6…",</text>
<text class="t-small" x="44" y="198">  "task_id":"<tspan class="t-acc">01a1690e-128a…</tspan>"}}</text>
<rect class="dd-card" x="32" y="216" width="504" height="78" rx="6"/>
<text class="t-name" x="44" y="238">daemon → server · 12.375</text>
<text class="t-small" x="44" y="260">{"type":"daemon:rpc_request","payload":{"request_id":"ec16b106…",</text>
<text class="t-small" x="44" y="274">  "method":"tasks.claim","body":{"max_tasks":20,"runtime_ids":[…]},</text>
<text class="t-small" x="44" y="288">  "timeout_ms":5000}}</text>
<rect class="dd-card" x="32" y="306" width="504" height="78" rx="6"/>
<text class="t-name" x="44" y="328">server → daemon · 12.412</text>
<text class="t-small" x="44" y="350">{"type":"daemon:rpc_response","payload":{"request_id":"ec16b106…",</text>
<text class="t-small" x="44" y="364">  "status":200,"body":{"tasks":[{"id":"<tspan class="t-acc">01a1690e-128a…</tspan>",</text>
<text class="t-small" x="44" y="378">  "agent":{…},"trigger_comment_content":"…","auth_token":"mat_…"}]}}}</text>
<text class="t-say" x="44" y="416">唤醒只带 id；同一个 id 在领取的响应里才带出内容。</text>
<rect class="dd-card" x="584" y="64" width="184" height="64" rx="6"/>
<text class="t-mono" x="596" y="88">POST /api/daemon/</text>
<text class="t-mono" x="596" y="106">heartbeat · 15 s</text>
<rect class="dd-card" x="584" y="216" width="184" height="78" rx="6"/>
<text class="t-mono" x="596" y="240">POST /api/daemon/</text>
<text class="t-mono" x="596" y="258">tasks/claim</text>
<text class="t-note" x="596" y="280">每 30 s 轮询一次</text>
<path class="dd-edge dd-dash" d="M536 255 H576" marker-end="url(#mam-ws-arrow)"/>
<text class="t-say" x="584" y="332">实测拒掉 WebSocket：</text>
<text class="t-say" x="584" y="352">评论后 15.2 s 才被领走</text>
<text class="t-note" x="584" y="384">重连退避 4 → 8 → 16 → 30 s</text>
</svg>
</div>
<figcaption>图 3 · 同一条 WebSocket 上既有服务端的提示，也有 daemon 的请求；它不通时，同样的事退回 HTTP 轮询来做</figcaption>
</figure>

`tasks.claim` 本来是一个 HTTP 接口 `POST /api/daemon/tasks/claim`。走 WebSocket 时，服务端在进程里伪造一个同样的 HTTP 请求，交给同一个处理函数，鉴权、组包、记账全都复用[^src-rpc]。好处是省掉一次 HTTP 建连，而且领取和提示走的是同一条已经认证过的连接。

这里有个细节说明了设计者最在意什么。RPC 超时、连接不在、发送缓冲满了，daemon 都会改走 HTTP 再领一次；唯独“请求已经发出去，连接却断了”这种情况不许改走 HTTP，因为服务端可能已经把任务标成了 `dispatched`，再领一次就成了重复领取[^src-wsrpc]。宁可让这个任务等服务端的超时回收，也不冒一个任务跑两遍的险。

### 连接断了怎么办

提示可以丢，是因为背后还有轮询兜底。daemon 每 15 秒发一次心跳；没有新任务时，它也会定期主动领一次。WebSocket 健康、服务端又支持调度提示时，这个兜底轮询放宽到最长 3 分钟；否则是 30 秒[^src-config][^src-daemon]。

我让代理拒掉 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 内容）。拆开看，服务端在领取的这一刻现查了不少东西[^src-claim]：

```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 运行时自己拉取的那一份：

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 520" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="mam-ctx-title mam-ctx-desc">
<title id="mam-ctx-title">agent 拿到上下文的三个地方</title>
<desc id="mam-ctx-desc">第一列是 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 返回评论。</desc>
<defs>
<marker id="mam-ctx-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="520" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="256" height="488" rx="8"/>
<text class="t-zone" x="32" y="44">① 启动前写好</text>
<rect class="dd-zone" x="280" y="16" width="248" height="488" rx="8"/>
<text class="t-zone" x="296" y="44">② stdin 第一条消息</text>
<rect class="dd-zone" x="536" y="16" width="248" height="488" rx="8"/>
<text class="t-zone" x="552" y="44">③ 运行中自己拉</text>
<path class="dd-edge-acc" d="M256 452 H264 Q272 452 272 444 V100 Q272 92 280 92 H536 Q544 92 544 100 V112" marker-end="url(#mam-ctx-arrow-acc)"/>
<rect class="dd-card" x="28" y="60" width="232" height="240" rx="6"/>
<path class="dd-card-head" d="M34 60 H254 Q260 60 260 66 V92 H28 V66 Q28 60 34 60 Z"/>
<text class="t-name" x="40" y="81">CLAUDE.md</text>
<text class="t-mono" x="248" y="81" text-anchor="end">17 KB</text>
<text class="t-small" x="40" y="112"># Multica Agent Runtime</text>
<text class="t-small" x="40" y="132">## Agent Identity</text>
<text class="t-small" x="40" y="150">  你是代码审查 agent。回答</text>
<text class="t-small" x="40" y="166">  用中文，先读 issue 再动手。</text>
<text class="t-small" x="40" y="186">## Available Commands</text>
<text class="t-small" x="40" y="206">### Workflow</text>
<text class="t-small" x="40" y="224">  1. multica issue get</text>
<text class="t-small" x="40" y="240">  2. comment list，分两次读</text>
<text class="t-small" x="40" y="256">  4. comment add 回复</text>
<text class="t-small" x="40" y="276">## Mentions · ## Output …</text>
<rect class="dd-card" x="28" y="312" width="232" height="52" rx="6"/>
<text class="t-mono" x="40" y="334">.claude/skills/</text>
<text class="t-note" x="40" y="353">multica-platform · 8 个文件</text>
<rect class="dd-card" x="28" y="376" width="232" height="112" rx="6"/>
<path class="dd-card-head" d="M34 376 H254 Q260 376 260 382 V408 H28 V382 Q28 376 34 376 Z"/>
<text class="t-name" x="40" y="397">环境变量</text>
<text class="t-small" x="40" y="428">MULTICA_SERVER_URL=…</text>
<text class="t-small" x="40" y="446">MULTICA_TASK_ID=01a1690e…</text>
<text class="t-small" x="40" y="464">MULTICA_TOKEN=<tspan class="t-acc">mat_…</tspan></text>
<text class="t-small" x="40" y="480">TMPDIR=/tmp/multica-task-…</text>
<rect class="dd-card" x="292" y="112" width="224" height="300" rx="6"/>
<path class="dd-card-head" d="M298 112 H510 Q516 112 516 118 V144 H292 V118 Q292 112 298 112 Z"/>
<text class="t-name" x="304" y="133">"type": "user"</text>
<text class="t-small" x="304" y="164">Your assigned issue ID is:</text>
<text class="t-small" x="304" y="180">01a1690d-fc37-…</text>
<text class="t-small" x="304" y="204">[NEW COMMENT] A user just</text>
<text class="t-small" x="304" y="220">left a new comment…</text>
<text class="t-small" x="304" y="236">&gt; [@reviewer](…) 帮忙看下</text>
<text class="t-small" x="304" y="252">  这个 Safari 的问题…</text>
<text class="t-small" x="304" y="276">Start by running</text>
<text class="t-small" x="304" y="292">multica issue get 01a1690d…</text>
<text class="t-small" x="304" y="316">Post your reply as a comment:</text>
<text class="t-small" x="304" y="332">… comment add 01a1690d…</text>
<text class="t-small" x="304" y="348">  --parent 01a1690e-1277…</text>
<text class="t-small" x="304" y="372">## On Behalf Of</text>
<text class="t-small" x="304" y="388">demo (demo@example.com)</text>
<text class="t-say" x="292" y="440">起因、现成的命令、代谁干活；</text>
<text class="t-say" x="292" y="460">没有需求本身。</text>
<rect class="dd-card" x="548" y="112" width="224" height="186" rx="6"/>
<path class="dd-card-head" d="M554 112 H766 Q772 112 772 118 V144 H548 V118 Q548 112 554 112 Z"/>
<text class="t-name" x="560" y="133">issue get</text>
<text class="t-mono" x="760" y="133" text-anchor="end"><tspan class="t-acc">mat_</tspan></text>
<text class="t-small" x="560" y="164">"title": "Safari 登录按钮</text>
<text class="t-small" x="560" y="180">  无响应",</text>
<text class="t-small" x="560" y="200">"description": "登录页在</text>
<text class="t-small" x="560" y="216">  Safari 上点「提交」没反应。</text>
<text class="t-small" x="560" y="232">  验收标准：</text>
<text class="t-small" x="560" y="248">  1. Safari 17 能正常提交</text>
<text class="t-small" x="560" y="264">  2. 补一个 e2e 用例 …",</text>
<text class="t-small" x="560" y="284">"status": "todo", …</text>
<rect class="dd-card" x="548" y="310" width="224" height="100" rx="6"/>
<path class="dd-card-head" d="M554 310 H766 Q772 310 772 316 V342 H548 V316 Q548 310 554 310 Z"/>
<text class="t-name" x="560" y="331">comment list</text>
<text class="t-mono" x="760" y="331" text-anchor="end"><tspan class="t-acc">mat_</tspan></text>
<text class="t-small" x="560" y="362">--roots-only --summary</text>
<text class="t-small" x="560" y="380">每个线程的根评论 +</text>
<text class="t-small" x="560" y="396">reply_count、最后活跃时间</text>
<text class="t-say" x="548" y="440">需求本体只在这一列出现。</text>
</svg>
</div>
<figcaption>图 4 · 身份和规则在启动前写进工作目录，这一回合的起因写进 stdin，需求本体由 agent 带着 mat_ 自己拉</figcaption>
</figure>

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

daemon 为这个任务准备好工作目录，在里面写一个 17 KB 的 `CLAUDE.md`（Codex 等其它 CLI 写成 `AGENTS.md`），再把技能文件放进 `.claude/skills/`[^src-brief]。Claude Code 启动时会自动读取当前目录的 `CLAUDE.md`，所以这些内容不需要通过命令行传。

`CLAUDE.md` 里装的是不随回合变化的东西：你是谁（agent 的名字、id，以及我在网页上给 reviewer 写的那句指令，原样贴在 `## Agent Identity` 下面）；能用哪些命令；每一回合该怎么干活。最后这部分是一个五步的工作流：先 `multica issue get` 读 issue，再分两次读评论（先扫各线程的摘要，再展开相关的线程），产出属于 issue 本身的工作就把状态改成进行中，结果必须以评论回复，退出前确认状态。

环境变量里最重要的是 `MULTICA_TOKEN`，它就是任务包里的那枚 `mat_` 令牌；另有服务器地址、任务 id、agent id 和一个任务私有的临时目录[^src-daemon]。

### 第二层：stdin 里的这一回合

然后 daemon 起进程。`claude` 的参数是固定的一串[^src-claude]：

```text
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 消息的末尾[^src-prompt]。

### 第三层：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 容易忽略它[^src-claim]。

### 续接会话时少读一次

reviewer 在 DEMO-1 上第二次被 @ 时，任务包带上了上一轮的会话 id，命令行多了 `--resume fake-session-135812`。这时再让 agent 从头读一遍 issue 就是浪费，因为它的记忆里已经有了。服务端会把这次领取时 issue 的标题和描述，与上一轮领取时记下的快照比一比，第二轮 prompt 里那句“去读 issue”就变成了[^src-issue-state]：

```text
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 的东西按路径归一下类：

<figure class="dd dd-figure dt-figure">
<div class="dt-panel">
<div class="dt-title" id="mam-ctx-table-title">一条 @ 带进 agent 的东西，各走哪条路</div>
<div class="dt-scroll">
<table class="dt" aria-labelledby="mam-ctx-table-title">
<colgroup><col style="width:30%"><col><col style="width:26%"></colgroup>
<thead>
<tr><th scope="col">内容</th><th scope="col">怎么到 agent</th><th scope="col">谁来取</th></tr>
</thead>
<tbody>
<tr><th scope="row">agent 指令、技能</th><td><code>CLAUDE.md</code> 的 Agent Identity、<code>.claude/skills/</code></td><td rowspan="2">daemon 启动前写好</td></tr>
<tr><th scope="row">凭证、任务 id</th><td>环境变量 <code>MULTICA_TOKEN</code>、<code>MULTICA_TASK_ID</code></td></tr>
<tr><th scope="row">触发评论原文</th><td>stdin 消息的 <code>[NEW COMMENT]</code></td><td rowspan="3">daemon 写进 stdin</td></tr>
<tr><th scope="row">代谁干活</th><td>stdin 消息末尾的 <code>## On Behalf Of</code></td></tr>
<tr><th scope="row">上一轮的会话</th><td><code>--resume</code> 参数，外加 issue 有没有变的判断</td></tr>
<tr class="dt-group"><th colspan="3" scope="rowgroup">任务包里没有，agent 自己拉</th></tr>
<tr><th scope="row">issue 描述、验收标准</th><td class="mono acc">multica issue get</td><td rowspan="2" class="acc">agent 带着 mat_ 请求</td></tr>
<tr><th scope="row">评论历史</th><td class="mono acc">multica issue comment list</td></tr>
</tbody>
</table>
</div>
<p class="dt-note">前五行是推的，变化少或者只属于这一回合；后两行是拉的，内容多、会变。</p>
</div>
<figcaption>图 5 · 身份、起因和凭证推给 agent，需求和讨论由 agent 自己拉</figcaption>
</figure>

agent 拉需求、发回复都靠那枚 `mat_` 令牌。**它是谁的身份，能拿来做什么？**

## 六、拉取的凭证

daemon 自己登录用的是个人令牌 `mul_`（浏览器登录时创建，默认 90 天有效）。它没有把这个令牌交给 agent，交出去的是服务端在领取时现签的一枚：`mat_` 加 40 位十六进制随机数，库里只存它的 SHA-256，绑定这个任务、这个 agent、这个工作区和 runtime 的所有者，24 小时过期[^src-token]。

带着它的请求进到服务端，鉴权中间件会用令牌记录**覆盖**请求头里的用户、agent、任务和工作区，并打上 `X-Actor-Source: task_token`[^src-auth-mw]。所以 agent 发的评论，作者显示为 reviewer 而不是我；agent 也没法在请求头里冒充别的 agent。

任务一结束（完成或失败），服务端就删掉这枚令牌。我拿第一轮的令牌在任务结束后再请求一次，返回 `401 {"error":"invalid token"}`。

需要说清楚的是它的范围。它绑定的是“工作区 + 所有者身份”，不是“这一个 issue”。我让假 agent 在运行中多做了两件事：读同一工作区里另一个毫不相干的 issue，成功了；用 `multica agent update` 改自己的指令，也成功了（随后改了回来）。源码里明确只给计费这类路由挂了“仅限人”的检查，issue 和 chat 路由都允许 agent 访问[^src-actor]。也就是说，一个被 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 hooks[^src-supplement][^claude-hooks]：daemon 启动 `claude` 时，在 stdin 里先于 prompt 发了一条 `control_request`，注册了 `PreToolUse`、`PostToolUse`、`UserPromptSubmit`、`Stop` 等几个钩子。之后 Claude 每次要调用工具，都会先问 daemon 一句“这个钩子你有什么要说的”，daemon 就在回答里夹带插话：

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 420" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="mam-steer-title mam-steer-desc">
<title id="mam-steer-title">一条插话怎么进到正在运行的回合</title>
<desc id="mam-steer-desc">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。</desc>
<defs>
<marker id="mam-steer-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker>
<marker id="mam-steer-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="420" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="412" height="388" rx="8"/>
<text class="t-zone" x="36" y="44">平台</text>
<rect class="dd-zone" x="440" y="16" width="344" height="388" rx="8"/>
<text class="t-zone" x="460" y="44">我的电脑</text>
<path class="dd-edge dd-dash" d="M140 104 V388"/>
<path class="dd-edge dd-dash" d="M340 104 V388"/>
<path class="dd-edge dd-dash" d="M560 104 V388"/>
<path class="dd-edge dd-dash" d="M712 104 V388"/>
<path class="dd-edge-acc" d="M140 140 H332" marker-end="url(#mam-steer-arrow-acc)"/>
<path class="dd-edge" d="M340 180 H552" marker-end="url(#mam-steer-arrow)"/>
<path class="dd-edge" d="M560 220 H348" marker-end="url(#mam-steer-arrow)"/>
<path class="dd-edge-acc" d="M340 248 H552" marker-end="url(#mam-steer-arrow-acc)"/>
<path class="dd-edge" d="M712 284 H568" marker-end="url(#mam-steer-arrow)"/>
<path class="dd-edge-acc" d="M560 324 H704" marker-end="url(#mam-steer-arrow-acc)"/>
<path class="dd-edge" d="M560 368 H348" marker-end="url(#mam-steer-arrow)"/>
<rect class="dd-card" x="80" y="56" width="120" height="48" rx="6"/>
<text class="t-name" x="140" y="76" text-anchor="middle">我</text>
<text class="t-note" x="140" y="94" text-anchor="middle">再发一条评论</text>
<rect class="dd-card" x="270" y="56" width="140" height="48" rx="6"/>
<text class="t-name" x="340" y="76" text-anchor="middle">Multica server</text>
<text class="t-note" x="340" y="94" text-anchor="middle">不建新任务</text>
<rect class="dd-card" x="490" y="56" width="140" height="48" rx="6"/>
<text class="t-name" x="560" y="76" text-anchor="middle">multica daemon</text>
<text class="t-note" x="560" y="94" text-anchor="middle">持有 stdin</text>
<rect class="dd-card" x="648" y="56" width="128" height="48" rx="6"/>
<text class="t-name" x="712" y="76" text-anchor="middle">claude -p</text>
<text class="t-note" x="712" y="94" text-anchor="middle">回合进行中</text>
<text class="t-mono" x="150" y="132">steer_task_ids</text>
<text class="t-mono" x="28" y="184">53.367</text>
<text class="t-mono" x="358" y="172">WS · supplement_available</text>
<text class="t-mono" x="28" y="224">53.372</text>
<text class="t-mono" x="358" y="212">POST …/supplements/claim</text>
<text class="t-mono" x="358" y="240"><tspan class="t-acc">"插一句：复现时记得关掉…"</tspan></text>
<text class="t-mono" x="576" y="276">PreToolUse hook</text>
<text class="t-mono" x="576" y="316"><tspan class="t-acc">additionalContext</tspan></text>
<text class="t-mono" x="28" y="372">53.494</text>
<text class="t-mono" x="358" y="360">POST …/ack · delivered</text>
</svg>
</div>
<figcaption>图 6 · 插话没有新开回合：daemon 等 Claude 下一次调用工具前问钩子时，把它作为附加上下文夹带进去</figcaption>
</figure>

`additionalContext` 里的文字是 daemon 包装过的：先说明“人类 demo 在你工作时补充了指导”，要求把它当作同一个任务的补充，不要替换原目标，不要单独回复一句“收到”，然后才是插话原文[^src-task-supplement]。如果 Claude 正好要结束这一回合，`Stop` 钩子会用 `decision: block` 把它拦下来，带着补充继续干。实测从服务端发出提示到 daemon 回报“已送达”，用了 127 毫秒；只是插话真正进到模型眼前，要等它下一次调用工具。

这条路径的形状和第一次 @ 完全一样：WebSocket 上只有一个提示，内容靠 daemon 去领，领完再回报。

插话只管“正在跑的这一轮”。更常见的情况是一轮跑完了，过一会儿我再 @ 它一次。前面说第二轮“带着 `--resume` 接上了刚才的会话”，**接上的到底是哪一段，什么时候接不上？**

## 八、再 @ 它一次：接上的是哪一段

为了把后面两个问题看清楚，我新建了一个 issue DEMO-3（“Safari 下表单提交后页面刷新”），又在同一台电脑上加了第二个 agent，叫 fixer。假 `claude` 也改了一下：reviewer 每跑一轮，就在工作目录里的 `progress.md` 追加一行“定位到 onSubmit 缺 preventDefault，修了一半”，再把同样的进度发成评论；每一轮开始时，它都先看一眼目录里有没有这个文件。

### 每一轮都是新进程，会话靠 `--resume` 接

先 @ reviewer 一次，等它跑完，再 @ 一次。两轮的命令行和工作目录是这样的：

```text
第一轮  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 上**最近一次已经结束的任务，取它的会话和目录[^src-claim][^src-claim-sql]。键只有 agent 和 issue，跟这次是被 @ 的、被指派的，还是在哪个评论线程里被叫到的都没关系。所以在同一个 issue 下，不管在哪条评论里 @ reviewer，它接上的都是同一段对话。

为什么目录也要交过来？因为 Claude Code 的会话是跟目录绑在一起的：会话记录存在 `~/.claude/projects/` 下按目录区分的文件里，换个目录，`--resume` 就找不到它了[^claude-sessions]。工作目录的路径由工作区、issue 编号和第一轮的任务 id 拼成，后面的轮次复用它[^src-execenv]。daemon 在启动前会再确认一次：复用到的目录和服务端给的是不是同一个，不是就把会话 id 丢掉，按新会话启动。

### 什么时候接不上

“最近一次结束的任务”并不总能接。源码里能找到这几种情况：

<figure class="dd dd-figure dt-figure">
<div class="dt-panel">
<div class="dt-title" id="mam-resume-table-title">第二次 @ 时，会话和工作目录各怎么处理</div>
<div class="dt-scroll">
<table class="dt" aria-labelledby="mam-resume-table-title">
<colgroup><col style="width:32%"><col><col style="width:22%"></colgroup>
<thead>
<tr><th scope="col">情况</th><th scope="col">会话</th><th scope="col">工作目录</th></tr>
</thead>
<tbody>
<tr><th scope="row">同一台电脑，上一轮正常结束</th><td class="good"><code>--resume</code> 接上</td><td rowspan="2">复用</td></tr>
<tr><th scope="row">上一轮失败，但失败和对话无关</th><td class="good">照样接上</td></tr>
<tr><th scope="row">上一轮失败在对话本身：轮数用尽、上下文溢出、请求被模型拒绝（400）等</th><td class="warn">跳过这段，往前找更早一段还能用的</td><td>复用</td></tr>
<tr><th scope="row">agent 换到了另一台电脑</th><td class="warn">不接</td><td>照给，多半已不存在</td></tr>
<tr><th scope="row">目录已被清理（issue 关闭 24 小时后）</th><td class="bad">新会话，prompt 里说明接不上</td><td class="bad">新建</td></tr>
<tr class="dt-group"><th colspan="3" scope="rowgroup">手动操作</th></tr>
<tr><th scope="row">在网页上对某一轮点“重跑”</th><td>接<strong>那一轮</strong>的会话，不是最近一轮</td><td rowspan="2">复用那一轮的</td></tr>
<tr><th scope="row">失败后系统自动重试，且要求干净开始</th><td>新会话</td></tr>
</tbody>
</table>
</div>
<p class="dt-note">会话和目录分开判断：会话有一点不可靠就放弃，目录能复用就复用，代码改了一半的状态尽量不丢。</p>
</div>
<figcaption>图 7 · 再 @ 一次，默认接上同一个 agent 在这个 issue 上最近一段还能用的会话</figcaption>
</figure>

第三行值得多说一句。一段对话如果是因为“上下文太长”或者“模型拒绝了这段历史”而失败的，再 `--resume` 它只会原样再失败一次，所以服务端把这些失败原因列成了一张黑名单，遇到就跳过这段会话[^src-claim-sql]。而“接不上”时，agent 不会被蒙在鼓里：prompt 里会多一段说明，告诉它上一轮的上下文这次没能带过来，该重新读的要重新读。

### 接上之后，服务端还补了什么

接上会话只是让 agent 记得上一轮自己看过什么。这中间 issue 可能被改过，别人可能又发了评论。第五节提过 issue 有没有变的判断，评论这边是同一个思路：服务端以被接上的那一轮的**开始时间**为锚点，数一数之后这个 issue 上所有线程里新增了多少条评论，不算 agent 自己的，也不算这次的触发评论。有就在 prompt 里给一条现成的命令：

```text
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 里写着：

```text
[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，评论线程）[^src-pending-index]。同一个线程里已经有一个在排队的任务，新的 @ 就并进它，作为“这一轮也要处理的较早评论”原文列出来。换成另起一条顶层评论去 @，就是另一个线程，我连发三条，reviewer 就依次跑了三轮，每一轮接着前一轮的会话。

同一个 agent 的“记忆”就是这样一段一段续下去的。**可要是换一个 agent 来接手呢？**

## 九、换一个 agent：它怎么知道前面做到哪了

### 什么都不继承

reviewer 跑完两轮之后，我发了一条新评论：“@fixer 接着 reviewer 的进度把修复做完并跑测试”。fixer 这一轮：

```text
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` 和最后活跃时间去判断。

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 420" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="mam-relay-title mam-relay-desc">
<title id="mam-relay-title">两个 agent 之间只隔着评论</title>
<desc id="mam-relay-desc">左边是 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 的进度。</desc>
<defs>
<marker id="mam-relay-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="420" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="248" height="388" rx="8"/>
<text class="t-zone" x="32" y="44">reviewer 的第二轮</text>
<rect class="dd-zone" x="280" y="16" width="240" height="388" rx="8"/>
<text class="t-zone" x="296" y="44">DEMO-3 的评论</text>
<rect class="dd-zone" x="536" y="16" width="248" height="388" rx="8"/>
<text class="t-zone" x="552" y="44">fixer 的第一轮</text>
<path class="dd-edge-acc" d="M252 330 H264 Q272 330 272 322 V174 Q272 166 280 166 H304" marker-end="url(#mam-relay-arrow-acc)"/>
<path class="dd-edge-acc" d="M508 166 H520 Q528 166 528 174 V342 Q528 350 536 350 H544" marker-end="url(#mam-relay-arrow-acc)"/>
<rect class="dd-card" x="28" y="64" width="224" height="84" rx="6"/>
<path class="dd-card-head" d="M34 64 H246 Q252 64 252 70 V96 H28 V70 Q28 64 34 64 Z"/>
<text class="t-name" x="40" y="85">claude</text>
<text class="t-mono" x="240" y="85" text-anchor="end">pid 新的</text>
<text class="t-small" x="40" y="116">--resume fake-session-220349</text>
<text class="t-small" x="40" y="136">cwd demo-3-66371cba076d/workdir</text>
<rect class="dd-derived" x="28" y="164" width="224" height="80" rx="6"/>
<text class="t-mono" x="40" y="188">progress.md</text>
<text class="t-small" x="40" y="210">定位到 form.tsx onSubmit</text>
<text class="t-small" x="40" y="228">缺 preventDefault，修了一半</text>
<text class="t-say" x="28" y="272">虚线：只在 reviewer 的工作目录里，</text>
<text class="t-say" x="28" y="292">别的 agent 读不到。</text>
<rect class="dd-card" x="28" y="312" width="224" height="36" rx="6"/>
<text class="t-mono" x="40" y="335">comment add --parent …</text>
<rect class="dd-card" x="292" y="64" width="216" height="52" rx="6"/>
<text class="t-small" x="304" y="84">demo · 22:03:49</text>
<text class="t-small" x="304" y="104">@reviewer 先看下 Safari 的问题</text>
<rect class="dd-card" x="304" y="124" width="204" height="84" rx="6"/>
<text class="t-small" x="316" y="144">reviewer · 回复 · 22:03:53</text>
<text class="t-small t-acc" x="316" y="164">进度：问题在 form.tsx 的</text>
<text class="t-small t-acc" x="316" y="180">onSubmit 缺 preventDefault</text>
<text class="t-small t-acc" x="316" y="196">改动只在本机，还没提交</text>
<rect class="dd-card" x="292" y="224" width="216" height="52" rx="6"/>
<text class="t-small" x="304" y="244">demo · 22:04:13</text>
<text class="t-small" x="304" y="264">@fixer 接着 reviewer 的进度…</text>
<text class="t-say" x="292" y="312">根评论都是人写的，</text>
<text class="t-say" x="292" y="332">agent 的进度在回复里。</text>
<rect class="dd-card" x="548" y="64" width="224" height="84" rx="6"/>
<path class="dd-card-head" d="M554 64 H766 Q772 64 772 70 V96 H548 V70 Q548 64 554 64 Z"/>
<text class="t-name" x="560" y="85">claude</text>
<text class="t-mono" x="760" y="85" text-anchor="end">新会话</text>
<text class="t-small" x="560" y="116">没有 --resume</text>
<text class="t-small" x="560" y="136">cwd demo-3-2989e6c10953/workdir</text>
<rect class="dd-card" x="548" y="160" width="224" height="36" rx="6"/>
<text class="t-mono" x="560" y="183">progress.md</text>
<text class="t-note" x="760" y="183" text-anchor="end">不存在</text>
<rect class="dd-card" x="548" y="212" width="224" height="84" rx="6"/>
<path class="dd-card-head" d="M554 212 H766 Q772 212 772 218 V244 H548 V218 Q548 212 554 212 Z"/>
<text class="t-name" x="560" y="233">comment list --roots-only</text>
<text class="t-small" x="560" y="264">3 条根评论，都是 demo 的</text>
<text class="t-small" x="560" y="282">第一条 "reply_count": 1</text>
<rect class="dd-card" x="548" y="312" width="224" height="76" rx="6"/>
<path class="dd-card-head" d="M554 312 H766 Q772 312 772 318 V344 H548 V318 Q548 312 554 312 Z"/>
<text class="t-name" x="560" y="333">comment list --thread</text>
<text class="t-small t-acc" x="560" y="364">进度：问题在 form.tsx 的 …</text>
<text class="t-small" x="560" y="380">展开第一个线程才读到</text>
</svg>
</div>
<figcaption>图 8 · 两个 agent 的会话和工作目录互不相通，前一个的进度只有发成评论，后一个才读得到</figcaption>
</figure>

代码也是一样。如果 issue 关联了代码仓库，每个任务会在自己的工作目录里检出一份，切到自己的分支 `agent/{agent 名}/{任务}`，起点是仓库的默认分支[^src-repocache]。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 的第一句变成了：

```text
[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` 的规则也提醒了这一点：@ 别人是派活，要给“具体的新子任务”，而不是打个招呼[^src-brief]。

## 小结

回到开头那条 `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 和环境变量存下来的脚本。调研笔记、原始报文和这两个小工具都放在了项目资料里。

## 参考资料
