KittyClaw工单系统每30秒轮询看板,发现目标列新工单后自动触发Claude执行。对比事件驱动,轮询方案在容错、断线恢复上有决定性优势,并详细记录了5个核心设计决策。
在上一篇文章中,我介绍了 KittyClaw 如何从被动看板转变为主动编排器。创建一张工单,三十秒后 Claude 就开始处理了。本文深入探讨这中间发生了什么。
我会追踪一张工单从创建到执行的全过程,停在五个关键设计决策上——正是这些决策让系统健壮,或者说,在系统还不够健壮时拯救了它。
本文记录了 KittyClaw,一款位于 Ekioo 代理舰队研发核心的看板编排器。继 Bloomii(建设性新闻媒体)和 Kalceo(面向建筑承包商的监管 B2B SaaS)之后,KittyClaw 驱动着这些项目在生产环境中运行的 AI 代理。
工单创建时,不会立即发生任何事情。KittyClaw 不会"响应"创建事件——它每 30 秒查看一次看板,然后决定做什么。
{
"trigger": {
"type": "ticketInColumn",
"columns": ["Todo", "InProgress"],
"assigneeSlug": "programmer",
"seconds": 30
}
}
有一天晚上我曾在轮询和事件之间犹豫。事件更"干净"(无延迟、无 CPU 浪费)。但我发现轮询有三个决定性优势:
它能扛住任何故障。 引擎崩溃、重启、错过事件——下一次 tick,它就能追上。
它天然自带速率限制。 即使我一次创建 50 张工单,分发也会在 30 秒窗口内完成。不存在惊群效应。
它和我正在替换的旧 dispatcher.mjs 是同一套模型。 零回归风险。
对于罕见事件(git 提交、Done 转换),我有专门的触发器(gitCommit、statusChange),分别以 60 秒或 30 秒轮询。对于你不能承受遗漏的事件,我有 boardIdle 和 agentInactivity 来监控活动的缺失——这用事件模型是不可能表达的。
经验法则:在独立开发系统中,轮询和 webhook 之间犹豫时,选轮询。你用日志文件调试、睡 30 秒,而不是 ngrok 加上重试栈。
并非所有被触发器匹配的工单都会被分发。在触发器和动作之间,有一系列需要通过的条件:
{
"conditions": [
{ "type": "minDescriptionLength", "length": 50 },
{ "type": "labels", "labels": ["release"] }
]
}
例如在 HolybotsRisingApps 上,发布自动化只在工单带有 release 标签且属于 app:borne 或 app:gamemaster 时才会触发。开发者只会接受描述至少 50 字符的工单——这是为了防止在制作人梳理工单之前,一张仓促写下的"修 bug"工单就被分发出去了。
条件是编码看板质量策略的正确位置。它们不替代人类判断,但能消灭我之前 80% 的无效代理启动场景。
当分发触发时,KittyClaw 会启动一个 claude 进程并附带一个提示词。提示词比你想象的简单得多:
{contents of skills/programmer.md}
Focus on ticket #42: {ticket title}
就这样。没有描述、没有评论、没有子工单、没有仓库上下文。
为什么这么少?因为代理已经有了获取这些的工具。它可以调用 KittyClaw API(GET /api/projects/{slug}/tickets/{id})、读取评论、@提及、关联工单、子工单,按需获取。而且它运行在项目的 WorkspacePath 内——可以 git log、读文件、做自己的调研。
你塞进提示词的每一条上下文,都是每次运行都要支付 token 费用的上下文,包括不需要它的时候。让代理按需获取要便宜 10 倍、新鲜 10 倍。
技能文件(skills/programmer.md)包含了工艺规则:如何读工单、如何发评论、如何处理子工单、何时提请审查。它是角色设定——稳定、在仓库中版本化、编辑时无需触碰 KittyClaw 代码。
如果我在十分钟后把同一个代理重新分发给同一张工单,我不希望它从零开始。它必须记住上次读取、做了什么、得到了什么结论。
Claude Code 原生支持 --resume <session-id>。KittyClaw 接入其中:
var existingSessionId = _sessions.GetSessionId(workspace, agentName, ticketId);
var sessionId = existingSessionId ?? Guid.NewGuid().ToString();
var isResume = existingSessionId is not null;
关键是(workspace, agent, ticket)这三个维度。每个组合有自己独立的持久会话,存储在 .agents/channel/dispatch-state.json 中。当代理回到那张工单时,它是 --resume 模式,提示词变成:
The owner has posted feedback on ticket #42: {title}
Read ALL owner comments on this ticket and address them.
无需重新发送技能——它在会话里。你只需告诉它:"回来,有新输入,看一下"。
一个花了我一个早上的教训:会话必须能扛住 KittyClaw 重启。我一开始存在内存里,然后 dotnet watch 把它们全清了。之后所有内容立即持久化到磁盘,用的是旧 dispatcher.mjs 用的同一个 JSON 文件。附赠:我的项目如果还在 dispatcher.mjs 上运行,可以无感迁移而不丢失会话。
真实问题:如果 programmer 和 3d-artist 同时在同一个仓库上运行,会发生冲突。两个进程同时编辑文件,git status 搞不清谁做了什么。
解决方案:并发组。
{
"actions": [{
"type": "runClaudeSkill",
"skillFile": "skills/programmer.md",
"concurrencyGroup": "code",
"mutuallyExclusiveWith": ["producer-commit"]
}]
}
concurrencyGroup:每个组最多一个活跃运行。所有触碰代码的代理都在 "code" 里——programmer、3d-artist、technical-artist、qa-tester、documentalist、code-janitor。一次只能有一个。
mutuallyExclusiveWith:当一个运行活跃时,它会阻塞列出的组。HolybotsRisingApps 的 producer-commit(提交/创建 PR 表示工单完成)会阻塞 code、producer 和 publisher——因为它需要一个稳定的仓库才能干净地提交。
隐性去重:同一(agent, ticket)上不会有两次同时运行。如果你在运行期间发了一条评论,它会在下一次轮询时被拾取,不会并行。
这三条规则消除我以前用旧 JS 分发器时 100% 的文件冲突问题。不是 JS 不好——而是因为并发规则淹没在 if/else 代码里,而不是声明式的。
最后一个部分,可选但珍贵:
{
"dailyBudgetUsd": 70
}
KittyClaw 追踪运行成本(通过 Claude Code 原生输出的 cost-log.jsonl)。一旦当日累计超过阈值,所有非 CEO 分发都被阻断。如果有监督代理的话,只有它还能运行,决定接下来怎么做。
在正常情况下我从未触达过这个阈值。但有一次因为两个代理互相在评论里来回 ping pong,形成无限循环,触发了预算。第二天早上醒来,账单可控,也学到了教训。没有预算的话,后果可能严重得多。
当我创建一张工单时,实际按这个顺序发生:
t = 0s:我通过 UI 或 API 创建工单。状态 Todo,指派人 programmer。
t ≈ 15s:下一次 ticketInColumn tick(每 30s 轮询)发现这张工单。
t ≈ 15s:条件被评估(描述长度、标签)。全部通过。
t ≈ 15s:moveTicketStatus 动作将工单翻转为 InProgress。
t ≈ 15s:引擎检查并发组。code 上没人——放行。
t ≈ 15s:会话查找。(workspace, programmer, #42)没有现有会话——创建新会话。
t ≈ 16s:启动 claude 进程,参数 --print --verbose --output-format stream-json --session-id <uuid>,提示词 = 技能 + 焦点。
t ≈ 17s:流事件(assistant、tool_use、tool_result)流入运行面板,从工单处可见。
t ≈ N 分钟:Claude 完成了。它发了评论,可能创建了子工单,可能把工单移到了 OwnerReview。运行被记录,包含成本。
t = N + 30 分钟静默:如果工单移到 Done 并保持安静 30 分钟,评估器唤醒并审查。
这条流水线和我之前用 dispatcher.mjs 在 JS 里实现的总体相同。但:
它是声明式的(automations.json)而非命令式(代码)。
它与看板集成(从工单处可见运行)。
它扛得住崩溃(会话和状态持久化)。
它适用于所有项目(一个引擎,N 份配置)。
我添加自动化越多,就越意识到正确的粒度既不是"代码一切"也不是"配置一切"。而是协作:
代码中的简单通用引擎。 少数几种触发器类型、动作类型、并发和会话。
声明式的按项目配置。 策略所在:谁在何时分发、谁有什么预算、谁阻塞谁。
项目仓库中版本化的 Markdown 技能。 角色设定和工艺所在。
引擎不懂电子游戏、文档或 CI。技能不懂轮询或并发组。配置将两者绑定。
这种分离使得新增一个代理变得微不足道:写一个技能,往 automations.json 加 10 行,上线。无需部署,无需重启——POST /api/projects/{slug}/automations/reload 端点热加载。
在 Aekan 上,13 个代理运行在这套系统上。在 HolybotsRisingApps 上,6 个——包括一个特定的发布工作流(publisher 由标签控制,producer-commit 与其他所有互斥)。在 Lain 上,15 个,活跃的日预算和一个在空闲时唤醒的 CEO。
每个项目都有自己的规则。引擎不变。
也许这就是对 harness(驾驭系统)的正确定义:一种通用基础设施,各个项目在上面编写自己的 choreography(编舞)。
引擎在 KittyClaw.Core/Automation/。从 dispatcher.mjs 迁移的指南在 docs/automation-migration.md。
讨论、提问、分享你自己的代理编排模式——Discord:
要采取进一步行动,你可以考虑屏蔽此人和/或举报滥用