ARTICLE · 1121415
multica 源码 08:AI 重试三遍,为什么建不出重复的 issue?
agent 的"手"长什么样——stdout 契约、任务令牌、防重试三道工序
我在工作区里养了几个 agent:写代码的、画图的、做内容的。它们每天接任务、写代码、发评论、传附件,干完活还会把任务状态改成"待验收"。
有个问题我一直没细想:agent 的"手"是什么?它自己只会生成文本,怎么在平台上"动手"?
答案是一个命令行工具:multica。agent 的全部平台能力——建 issue、发评论、改状态、传文件——都是靠在 shell 里调它完成的。
学习项目里有个子任务,专门拆它的源码。这篇是拆完的笔记,先给结论:multica 是我见过的、少数把"用户是 AI"当真来设计的 CLI。
一个二进制,三个角色
先说底座。CLI 源码在 monorepo 里:命令层是 server/cmd/multica/,约 30 个 cmd_*.go 文件;共享的 HTTP 客户端、配置、错误分类在 server/internal/cli/。
daemon 也在这里:server/internal/daemon/,和 CLI 是同一个二进制。
daemon start --foreground 会 re-exec 自己变成后台进程;还有个隐藏的 preparation-helper 模式,负责给 agent 准备运行环境。
命令树用 cobra 组织,分三组:CORE(issue、project、agent 这些)、RUNTIME(daemon、runtime)、ADDITIONAL(auth、config、update 这些)。
最克制的地方:全局 flag 只有 4 个——--server-url、--workspace-id、--profile、--debug,复杂度全部收进子命令。光 issue 一个命令就挂着 20 多个子命令:list、create、assign、comment、timeline、rerun、search……连 help 的排版都在模仿 gh。
stdout 是数据,stderr 是人话
人用 CLI 看的是人话,agent 用 CLI 读的是字节流。两种东西混在一个流里,agent 就得猜。
multica 的约定很硬:stdout 只放数据,一切确认、警告、进度提示走 stderr。
拿 issue comment add 举例:成功后那句 "Comment added to issue ZOO-245" 打印在 stderr,stdout 里只有评论的 JSON。multica … --output json | jq 这条管道永远是干净的。
退出码是一份可以编程依赖的合同:0 成功;1 一般错误,含 409、429、5xx;2 网络问题;3 鉴权失败;4 没找到;5 参数校验没过。
省 token 的细节也没漏:--compact 裁掉 JSON 里的簿记字段;查询命令默认输出 table 给人看,变更命令默认输出 json 给 agent 读。
还有个不起眼的好设计:请求头固定声明 stable_attachment_urls,让服务端返回稳定的附件路径。为的是 agent 的 prompt 缓存不被无谓打爆。
我把这套东西总结成一句话:stdout 的每个字节,都是按 API 的标准设计出来的。
错误信息是行动指令
整份源码分析里,我认为最值得抄走的是错误文案这一节。
每条错误都带下一步动作。401 会直接告诉你 Run multica login,agent 不用思考,照做就行。
但有两处例外,处理得很刻意。
409 冲突时,文案刻意不给"刷新后重试"这类通用建议。背景是一次真实事故:有 agent 拿到这句话,机械地重试了几个小时(#6264)。与其给一句正确但会被无限执行的废话,宁可不说。
任务态拿到 401,文案反而说得极满:到此为止,不许回退去用用户凭据(#7522)。
错误处理做到这个程度,已经从健壮性变成了对 agent 行为的塑造。
agent 借不到宿主身份
Agent 跑在你的机器上、用你的 daemon。它凭什么拿不到你的身份?这是我看源码前最大的疑问。
令牌分三个前缀。mul_ 是用户 PAT,浏览器 OAuth 换发,90 天有效;mcn_ 给 Cloud Node;mat_ 是任务作用域令牌,daemon 派生任务时注入,只属于这一个任务。
关键在配置隔离。任务态下,MULTICA_TASK_CONFIG_ROOT 把整个配置根切到任务私有目录(权限 0700)。agent 想隐式读到宿主的全局 PAT,那条路径根本不存在。
解析链上没有后门:resolveToken 在任务态直接返回空,绝不回退去读用户 config(cmd_auth.go:73-87)。
我最欣赏的是 fail-closed 标记。daemon 会在工作目录写一个 daemon_task_context.json,哪怕环境变量全丢了,进程向上找到这个标记,同样不会回退用宿主 PAT。
纯本地命令(login、auth、config、update)在任务态直接被拒。agent 连改宿主机登录态的入口都没有。

层层收窄,而且每一层都不指望 agent 自觉。
重试三遍,issue 不会建出三个
Agent 最危险的习性是重试:命令失败就再来一遍。如果第一遍其实已经成功,平台就多出一个重复的 issue。
multica 没做客户端幂等键,防重复靠三道工序。
第一道,服务端守卫。创建同名活跃 issue 会返回 409 active_duplicate_issue,想绕过必须显式加 --allow-duplicate。
第二道,顺序设计。附件在发出 POST 之前先做校验,坏路径直接中止,重试没有副作用。
issue 建成之后附件上传失败,只降级为警告,退出码保持 0。非零退出码会诱发整条命令重试,重复创建多半就是这么来的。
第三道,干脆没有通用自动重试。所有请求单发,全 CLI 唯一的网络重试,是解析指派人名字时的一个只读 GET。

这套设计背后是一个具体的坑:#4182。inline --content 会被 shell 反引号静默改写;heredoc 的终结符带了尾 token,会把后面的 flag 吞掉。
于是 CLI 侧长出机制防御:--content、--content-stdin、--content-file 三个通道严格互斥,文件路径必须落在工作目录内,越界要显式授权。
daemon 侧则把正确姿势直接写进注入给 agent 的指令:用 --content-file,发完评论删掉临时文件,并有测试固化。
daemon 不是代理
拆源码之前我的猜测:daemon 是本地代理,所有命令先过它,再到服务端。
完全不是。issue、project、comment、attachment 这些业务命令,全部直连服务端 REST。agent 在任务里跑也一样直连,只是凭据换成了 mat_。
daemon 只在两类调用里出现:管理自己的生命周期(start、stop、status),以及 repo checkout——唯一经过 daemon 的业务命令,因为要在本机落代码。
它常驻后台,本地只开一个 127.0.0.1:19514 的 HTTP 端口,三个端点:查健康、关机、检出仓库。和服务端之间靠 REST 领任务,加一条 WebSocket 做唤醒和心跳。
它真正的工作是当保姆:探测本机 24 种 agent CLI、注册 runtime、给每个任务建隔离工作目录、spawn agent 进程并注入令牌,然后是心跳、GC、自更新。

仓库检出值得记一笔:bare clone 做缓存,每个任务的工作目录是它的 git worktree,分支名带任务 ID。
同 workdir 重复检出会先 reset --hard 再切新分支,上一任务未提交的内容随之丢弃。
别照单全收
这些设计有前提。抄之前,先对照自己的场景。
前提一,multica 控制着两端。409 守卫要服务端配合,任务态隔离要 daemon 注入环境变量。只控制 CLI 一端的工具,最核心的几层抄不动。
前提二,agent 是它的头等用户。整个平台就是为跑 agent 而生的,错误文案和退出码才值得做到这个精度。如果你的 CLI 只是偶尔被 agent 调用,收益未必盖得住成本。
前提三,无自动重试对人也是代价。网络抖一下,人也得手动重跑。源码里超时从 15 秒调到 30 秒,为的是大陆链路的 TLS 握手加往返延迟,算是真实使用换来的补丁。
如果只带走一件事
从这份分析里,我选这条带走:把踩过的坑固化成代码和指令。
三个编号可以作证:#4182 变成了三通道互斥,#6264 变成了 409 的沉默,#7522 变成了 401 的明确叫停。每个编号背后是一次真实事故,而事故的终点不是复盘文档,是防御性的代码。
转型做 agent 开发,最需要换位的可能就在这里:你写下的每一段代码,都是和一类非人类用户之间的契约。stdout、退出码、错误文案、令牌作用域,全是条款。
multica 的答案,是把每个条款都当真。
这篇对你有启发的话,留言聊聊:你在做的工具里,给 agent 留了哪些"机器友好"的接口?