Hive的多机 AI Agent 协同看板实战:跨机留言、任务搁置、计划跟踪的三个设计取舍
多机 AI Agent 协同看板实战:跨机留言、任务搁置、计划跟踪的三个设计取舍
一套「git 即队列」的多机 Agent 任务总线,最近四天给它的 Web 看板连加了三个能力。
这篇文章不复述功能清单,而是讲清楚每个功能背后「为什么这么做、以及刻意不做什么」。
最后附一次/api/plans404 的完整排查过程——它比功能本身更有普适价值。
一、先交代一下背景
这套系统(内部叫 Hive)解决的问题是:让几台机器上的多个 AI Agent 协同干活,而不用架一个真正的任务队列服务。
它的核心约定非常朴素:
- 协调仓库里只存三样东西:任务卡
tasks/、收件箱inbox/、结果results/; - 领取任务 = 向协调仓库 push 一个独占分支
claim/<task>,push 被拒就说明被别人抢走了; - 结果 = push 分支
res/<task>; - 所有 worker 都是「拉取式」的,只要能
git fetch/push,就能工作,不需要被 SSH 进来。
看板是这套系统的观测面:一个零依赖的静态页 + Python 标准库 HTTP 服务器(http.server),没有 npm、没有构建、没有 CDN、不请求任何外部域名。它把 hive status --json 的快照渲染成六列:
待领取 → 进行中 → 待合并 → 异常 → 最近完成 → 已关闭
这套「零依赖」的约束贯穿了后面所有设计:能用一个 JSON 文件解决的,就不引入数据库;能用 http.server 解决的,就不引入 Web 框架。
四天里加了四件事,我按时间顺序说。
二、跨机留言:Agent 之间的「喊话」通道
2.1 问题不是「怎么聊天」,而是「为什么不能用 git 聊」
第一个念头当然是复用现有设施:既然任务卡走 git,留言也提交到仓库里不就行了?
不行,有两个硬伤:
- 会被冲掉。worker 的 worktree、MCP 的独占 clone,每轮开头都会
git reset --hard origin/main。写进仓里的留言,下一轮就没了。 - 撑不起秒级。留言要的是「我说完你 1~2 秒内能看到」,
git push+ 轮询做不到,而且会把协调仓的提交历史搅成一堆噪声。
所以留言走的是集群内存 + 本地追加文件,而不是 git。
2.2 存储形态:一行一条 JSON,每个节点一份
每个节点维护 ~/.hive/cluster/chat.jsonl,追加写(append-only),内存里保留最近 2000 条。follower 每 2 秒向主节点增量拉取。
有一条容易被忽略的设计:主节点不可达时,留言本地留存。看板照样能看、能发,等这台机器当上主节点时再服务别人。这比「集群挂了留言功能就报错」友好得多——毕竟集群抖动是常态,而「我现在想留句话」是刚需。
2.3 频道设计:三种作用域,一个正则
CHAT_SCOPE_RE = re.compile(r"^(hall|task:T-\d{3,6}|agent:[A-Za-z0-9._-]{1,64})$")
CHAT_KINDS = ("note", "question", "answer")
CHAT_MAX_TEXT = 8000
只有三种频道:
| 频道 | 用途 |
|---|---|
hall | 大厅,所有人可见 |
task:T-1023 | 某张卡的讨论线程 |
agent:名字 | 私信 |
用正则而不是自由字符串,是因为这个值会被拼进日志和前端 DOM;允许任意字符串,就得在三个地方分别做转义和校验。收口在入口,比在每个出口防守便宜。
类型只有 note / question / answer 三种。没有做「已读回执」「@提醒」这些——在 Agent 场景里,它们几乎不会被正确使用,反而增加状态。
2.4 同一个能力,三个入口
这是我认为这套设计里最值得抄的一点:留言不是「看板的功能」,而是「集群的能力」,看板只是其中一个前端。
- 看板:右下角浮标 → 面板,选频道、选类型、写内容;
- CLI:
hive chat post / list / wait,支持--task、--agent、--scope、--kind、--unread --mark-read、--json; - MCP:
hive_chat_post/hive_chat_read,Agent 自己就能发和读。
其中 hive chat wait 是个长轮询:Agent 干到一半想知道「有没有人给我留话」,用它比反复 list 省事。未读水位记在 ~/.hive/chat-seen.json,每台机器各记各的——不放在协调仓里,否则又多一个会冲突的文件。
还有三处「顺手接上」:
hive_claim领取任务时,把这张卡的留言回在chat字段里,同时落一份到 worktree 的.hive/chat.md——Agent 一上手就知道前情;hive prompt渲染派发指令时,自动把该卡留言附在末尾(最多 10 条);- 看板任务卡和详情抽屉里都有「讨论 n」入口,点一下直接进那张卡的线程。
2.5 顺手修的坑:复制派发指令要「状态感知」
留言做完之后,发现了一个更早就存在的糟糕体验:同一个「复制派发指令」按钮,不管卡是什么状态,复制出来的都是「请领取任务」。
结果是:一张卡已经做完、正等人合并,有人点了复制,粘给主控 Agent,主控就去「领取」了——而它其实该做的是 hive land。
所以把复制文案按运行时状态分了三档,判定口径与看板分列严格对齐:
| mode | 触发条件 | 复制出来的内容 |
|---|---|---|
merge | result_status=done 且未 close | 合并指令:结果分支 res/<tid>、head、执行 Agent、hive land <tid> --apply、冲突处理 |
abnormal | failed / blocked / 租约过期 | 派发指令 + 溯源块(此前领取 Agent、时间、异常摘要) |
claim | 其余 | 与旧行为逐字一致 |
按钮文案也跟着变(待合并卡显示「复制合并指令」)。关键点:claim 分支必须逐字不变——已经形成肌肉记忆的文案,改了就是纯损失。新逻辑只在需要的分支上生效。
三、搁置:给「不做」一个正式状态
3.1 现实里有一类卡,既不该排队也不该删
总有些卡当前确实做不了:等上游 SDK 发版、等一台 arm64 机器、等某个运维答复。
三种「自然」处理都不对:
- 打回
ready:白占一个名额,还会干扰 Agent 领取(领了又卡住,浪费一次租约); - 挂在异常列:太显眼。异常是「出问题了,要人管」,而这类卡是「情况已知,暂时封存」;
- 直接删:删了就真忘了。
于是加了第五个状态 shelved(搁置)。它的定位一句话:不能忘,但大部分时候可以忽略。
3.2 搁置时释放什么,是个判断题
shelved 不只是改个字段,它要释放全部运行时资源:
- 删掉
claim/、res/、status/远端分支; - 清掉
results/<tid>.json归档; - 清掉
inbox/*/<tid>.md分派信号; - 释放本机与集群租约;
- 卡面写
shelved_at/shelved_from/shelve_reason。
还有一步容易漏:推进 fence。租约用 fencing token 防脑裂,如果只是删掉 claim 而不推进 fence,一个还在跑的老 worker 醒来写结果,仍然能写进来。搁置意味着「这张卡的这一轮作废」,所以 fence 必须前进,让旧 worker 的写入一律失败。
3.3 三个出口
搁置不是终点,是暂停。所以卡面备注里直接写着三条命令:
hive unshelve T-1031 # 放回待领取
hive unshelve T-1031 --spawn-new --title "按新接口重做" # 以它为模板另立一张新卡
hive close T-1031 # 废弃
第二种 --spawn-new 值得一提:原卡保持搁置,另派生一张 ready 新卡,继承 repo/base/priority/verify/depends_on。场景是「需求变了,但这张卡的历史和结论还有价值,不能删」。
3.4 为什么它不做第 7 列
这是整个功能里最花时间的一个决定。
六列已经把横向空间压得很紧,再加一列,活跃卡的可读性直接下降。更重要的是语义:搁置卡跟活跃卡不是平级的——它不该和「待领取」抢注意力。
最后的排版是两处按钮,平时只占一行:
- 顶栏统计条末尾一枚低调的「搁置 N」(计数常驻,这是「别忘了」的部分);
- 看板上方一条默认收起的横条,点开才展开卡片列表,多列网格紧凑排布。
同时明确几条边界:
- 搁置卡不计入「共 N 个任务」的活跃统计(混进去会让盘子看着虚高);
- 不进异常列(它不是异常);
- 但仍参与搜索——搜索命中搁置卡时,折叠态会显示「有匹配」,免得以为卡丢了。
最后这条是我踩过的:一开始搁置卡被排除出搜索域,结果「我明明记得有张卡等发版」就是搜不到,只能手动翻。
四、任务计划:把一组任务当一个项目
4.1 缺的是一层聚合
前三个功能做完,看板能回答「某张卡现在什么状态」,但回答不了「这次改造做完了没」。
一次改造往往拆成 5~8 张卡,在看板六列里是散开的:有的在待领取、有的在跑、有的已合并。没人能一眼算出「5 张做完 3 张」。
于是加了「计划」:一条计划关联一组任务 ID,实时算完成率。
4.2 Tab 复用:新功能的正确插入方式
搁置已经占了「看板上方一条横条」这个位置。计划也放这里,做成同一区域的 Tab 切换。
这里有个细节决定了体验好坏:点当前 Tab 时,仍然是原来的展开/收起;点另一个 Tab 才切换。
为什么?因为搁置的交互已经形成了肌肉记忆——「点一下展开、再点一下收起」。如果改成「点一下就切走」,用户会以为搁置功能坏了。
实现上 aria-selected 互斥,两个面板各自 hidden,切换时不销毁对方的数据。原有的搁置计数、搜索命中提示、三个出口按钮全部保持不变——旧测试一行没改,原样通过。
4.3 字段与状态机
id(P-9001) / prefix / title / description / status / pinned
task_ids[] / created_by / created_at / updated_at / completed_at
状态四个:active(进行中)、completed(已完成)、closed(已关闭)、deleted(已删除)。
删除是软删除:只改 status + deleted_at,记录留在文件里,?include_deleted=1 仍能查到。理由是「计划」承载的是决策上下文,删掉就再也说不清「当初为什么立这个计划」。
4.4 排序规则只在服务端实现一份
进行中:置顶在前 → updated_at 倒序 → id 倒序兜底
已完成/已关闭:completed_at / closed_at 倒序
三个容易做错的地方:
- 置顶只对进行中生效。计划一旦完成/关闭,
pinned自动置 false——否则「置顶的已完成计划」会挤在已完成区最前面,毫无意义。 - 时间只精确到秒,同一秒内的多条更新会并列。所以加了
id倒序兜底(P-9007排在P-9003前),否则顺序会随列表顺序漂移,测试也不稳定。 - 规则只在服务端写一遍。前端只做「分组 + 渲染」,不重新排序。前后端各写一套排序,早晚会漂移,而且漂移了没人发现。
4.5 进度不落库:这是关键决定
一个诱人的做法是:在计划记录里存 done 和 rate,任务状态变化时去更新它。
我没这么做。进度每次读取时,按当前任务快照现算。
原因很直接:落库就一定会不一致。status.json 是 hive status --json 每 20 秒重生成的派生快照,计划里那份计数是另一份派生数据。两者一旦不同步,用户看到的就是「快照明明更新了,计划还显示 3/8」——而这种 bug 极难发现,因为没有任何报错。
现算的代价是一次遍历。计划的量级是几十条,每条最多几十个任务号,完全可以忽略。
顺带解决了一个隐性需求:关联任务在看板快照里找不到时怎么办?
不能静默算成「未完成」。单独进 progress.missing,卡片上直接写「快照里找不到 N 个关联任务:T-1005, T-1006」。因为「计划里写了 5 张卡,但看板只认识 3 张」这件事本身就是要让人看见的信号——可能是任务号写错,也可能是任务被归档了。
4.6 自动完成,与它带来的一个矛盾
规则很简单:关联任务全部完成 且 计划开了 auto_complete → 自动标为已完成。每张计划可以单独关掉这条规则。
但「手动完成」打破了它。用户可以在一张卡只做完 1/2 的时候,直接点「完成」。
第一版实现就撞上了这个矛盾:卡片上同时出现绿色徽标「已完成」和进度条「1/2 已完成 · 50%」——看起来像数据错了。
最后的处理是把话说清楚,而不是禁止操作:
- 徽标写「手动完成」(并带 tooltip 说明还剩几个任务);
- 进度文案从「1/2 已完成」改成「1/2 关联任务完成」——避免和徽标争同一个词「已完成」;
- 下面加一行提示:「手动标记完成,仍有 1 个关联任务未完成」。
这类「两个正确的事实看起来互相矛盾」的情况,我的经验是:别改数据,改文案。禁止手动完成会更糟——现实里就是会有「计划先收口、剩一张卡单独跟」的情况。
4.7 权限与审计
完全复用现有的 token 模型,没有新造一套:
- 可写:创建者本人,或
controller/admin角色; - 只读:普通成员(worker);
- 删除额外要求
confirm=true(前端二次确认)或 controller 身份。
所有写操作逐条进 audit.log(JSON Lines,append-only):plan.create / update / complete / close / delete / pin / auto-complete / chat.plan-cmd。
审计单独一个文件而不是混进 status.json,因为后者是每 20 秒覆盖一次的派生快照,留不住历史。
4.8 Agent 怎么操作计划?靠留言指令
这是本次最有意思的一块。Agent 没有浏览器,它只有留言通道。
所以计划必须能被「说」出来:
/plan create 前缀 标题
@command:plan create 前缀=PAY 标题=支付二期 关联=T-1005,T-1006,T-1007
@command:plan update P-9001 标题=… 描述=… 关联=T-1001,T-1002 置顶=on
@command:plan complete P-9001
@command:plan close P-9001
@command:plan delete P-9001
@command:plan pin | unpin P-9001
两套语法是刻意的:
/plan create PAY 支付改造—— 人手习惯,标题允许带空格;@command:plan create 前缀=PAY 标题=… 关联=…—— Agent 习惯,字段显式,一条命令就能把被跟踪的任务组带上。
第二条正好回答了一个需求:「Agent 发布一组任务时,能否同时建一个跟踪计划?」——能,而且是一条指令的事。
几个细节:
- 解析失败必须回用法。不是「格式不对」四个字,而是把完整的
USAGE带示例回给发送方。Agent 看到示例能自己改对。 - 删除只给 controller。因为留言通道里没有「二次确认」这一步,而删除是不可逆的(虽然软删)。创建者想删,请看板上来点。
- 执行结果会以
📋 …系统留言的形式回贴到原频道,同时写审计。这样即使发起的人关了页面,频道里也留得下这次操作的痕迹。
4.9 两条来源:看板输入框,和集群里的留言
这里有个坑:Agent 用的是 hive chat post 或 MCP 工具,消息直接进集群,根本不经过看板的输入框。
而计划数据只在本机(和 settings.json、audit.log 一样属于看板运行态,不跨机同步)。
所以加了第二条通路:拥有计划文件的这台看板,在轮询留言时补执行。
但直接「每次拉全部留言然后执行」会炸——历史聊天里可能有人随手写过 /plan …,一装上就全部重放一遍。所以:
- 增量游标:只问「上次之后」的留言;
- 首次运行只定位,不执行历史;
- 消息 id 账本(存在
plans.json的applied里):同一条只执行一次,轮询不重跑。
身份取留言的 from 字段,按本机 token 表反查角色。查不到就按普通成员处理——失败要偏向安全。
五、一次 404 的复盘:页面是新的,后端是旧的
功能上线后,点「计划」报错:
计划加载失败:未找到资源: /api/plans
重试也没用。
5.1 排查
先直接打接口:
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:<看板端口>/api/plans
# 404
代码是提交过的,语法也没问题。于是看进程:
ps -o pid,lstart,cmd -p <看板进程PID>
# PID STARTED CMD
# 2417 Thu Nov 5 00:07:33 2026 python3 serve.py --status ... --port <看板端口>
00:07 启动的,而改动是 02:31 提交的。
根因清楚了,而且是个很典型的陷阱:
index.html/app.js/style.css是静态文件,每次请求重读——所以浏览器拿到的是带「计划」Tab 的新前端;serve.py是常驻进程,不会热重载——它内存里压根没有/api/plans这个路由。
于是严格「换了一半」:新页面去调旧后端。现象看着像「功能没实现」,其实代码早就写好了。
5.2 为什么难发现
三个原因叠在一起:
- 页面没有 JS 报错(fetch 拿到了合法的 404 JSON);
- 其他接口全 200(
/api/status、/api/config、/api/cluster、/api/chat都正常); - 旧进程的日志里只会不断重复同一行 404,没有「我版本旧了」这种信息。
5.3 修复:重启,并加一条自检
重启用仓库自带的控制脚本(带 PID 文件、日志、幂等启停):
PORT=<看板端口> sh scripts/dashboard-service.sh restart
但只重启不解决问题——下次改了 serve.py 忘了重启,还会踩同一个坑。所以顺手加了一条代码新鲜度自检:
GET /api/config 的 system 段新增 service:
{
"started_at": "2026-11-05T14:43:42+00:00",
"stale_modules": [],
"restart_hint": ""
}
实现很轻——比对进程启动时刻与 serve.py / plan_store.py 的 mtime:
SERVE_STARTED_AT = time.time()
def service_freshness():
stale = []
for name in ("serve.py", "plan_store.py"):
try:
mtime = (BASE_DIR / name).stat().st_mtime
except OSError:
continue
if mtime > SERVE_STARTED_AT + 1: # +1s 容忍同一次保存内的写入
stale.append(name)
return {
"started_at": "...",
"stale_modules": stale,
"restart_hint": ("改了 %s 但进程还是旧的:运行 "
"`sh scripts/dashboard-service.sh restart`" % "、".join(stale))
if stale else "",
}
前端拉到非空的 restart_hint 时,作为一条横幅提示出来(只提示一次)。
这里刻意没做哈希校验。目的不是完整性校验,而是给一句「照着敲就能修」的命令。判据越简单越可靠,+1s 的容差是为了容忍同一次保存里的多次写入。
六、几条可迁移的经验
按我认为的复用价值排序:
1. 「零依赖」是个强约束,但能省掉大量决策
JSON 文件 + os.replace 原子写 + 进程内 RLock,就足够撑住「几十条计划、单机读写」的场景。引入 SQLite 或 ORM,收益接近零,成本是整个部署链路变复杂(这个看板要能在 FreeBSD / Linux / Windows 上直接 python serve.py 跑起来)。
原子写的模式值得记住:
tmp = p.with_name(p.name + ".tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
os.replace(tmp, p) # 同目录 rename,POSIX 上原子
2. 派生数据不要落库
进度从快照现算,不存计数。落库 = 制造第二个真相源 = 制造一个不会报错的不一致 bug。
3. 新功能插入旧区域时,「点当前项仍是原行为」
Tab 是最容易破坏肌肉记忆的控件。点已选中的 Tab 保持原来的展开/收起,点别的才切换——一个细节,省掉一堆「搁置坏了」的困惑。
4. 文案矛盾优先于数据矛盾
「已完成」+ 50% 进度条,改文案(手动完成 / 关联任务完成),不要改数据、也不要禁止操作。
5. 失败信息要能直接照做
解析失败回完整用法和示例;代码过期回具体命令 sh scripts/dashboard-service.sh restart。不是「格式错误」,不是「服务异常」。
6. 面向 Agent 的接口,要考虑「它没有浏览器」
这就是为什么计划必须支持留言指令,也是为什么留言要做 CLI + MCP + 看板三个入口。Agent 的可达性,决定了功能对 Agent 是否真的存在。
7. 常驻进程 + 静态资源的组合,天生有版本错位风险
凡是「静态文件每请求重读、服务端代码常驻内存」的架构,都该有一条「代码是否比进程新」的自检。这类问题的特征是:局部 404、其余全绿、无异常日志。
七、数字小结
四天、四个面向看板的提交:
| 提交 | 内容 | 规模 |
|---|---|---|
| 跨机留言 + 派发指令状态感知 | 集群存储、CLI/MCP/看板三入口、mode 分档 | +2178 行 |
任务搁置 shelved | 新状态、资源释放、折叠面板 | +1363 行 |
| 任务计划 | plan_store.py + /api/plans + 计划面板 | +3248 行 |
| 404 复盘修复 | 代码新鲜度自检 | +42 行 |
测试这块:
- 计划相关 73 个后端用例(状态机、权限、排序、进度、自动完成、指令解析、HTTP 接口、集群入站、持久化);
- 62 条 jsdom 断言(Tab 切换、分区、进度、按钮可用性、二次确认取消后不发请求、中文逗号分隔的任务号解析);
- 原有搁置面板的 UI 测试一行未改,原样通过——这是「没影响老功能」最硬的证据。
结语
回头看,这三个功能(留言、搁置、计划)其实在回答同一个问题的三个层次:
- 留言:人和 Agent、Agent 和 Agent 之间,怎么把「我现在的理解」说出去;
- 搁置:一张卡暂时做不了,怎么体面地放下而不遗忘;
- 计划:一组卡做完了没,怎么从「卡」上升到「项目」。
而那次 404 提醒的是另一件事:功能写对只是开始,运行态的一致性是独立的一课。代码提交了、测试通过了、接口写好了,用户点下去仍然可能是 404——因为跑着的那个进程,还停在几个小时前。
更多推荐
所有评论(0)