
我给 AI 造了个工具:TaskToAgent 的设计取舍
TaskToAgent 是我最近做的项目:一个任务看板,人和 AI agent 写在同一块板上。
在这之前我已经有好几个 AI 协作的项目了。搭看板的时候我发现一个问题 —— 这些项目的”任务状态”全在我脑子里或者散在各处。哪个做完了、哪个卡住了、哪个是 AI 正在做的,我自己经常搞不清。
于是决定做一个。
这篇不讲功能清单(README 里有),讲几个设计时真正卡住我的地方。
第一件事:给人看,还是给 agent 用
一开始我以为是两件事:做一个给人看的看板,再给 agent 留个接口。
做到一半发现不是。
如果人用鼠标拖卡片、agent 用命令行改数据库,那这两套逻辑迟早会打架。人在界面上把任务拖到”已完成”,agent 那边的计数怎么算?agent 认领了任务,界面上那块卡片显示什么?
所以我把它当成一件事来做:同一块板子,人和 agent 都是参与者,用的是同一套状态机、同一份数据、同一个审计流水。
这个决定连带出了一系列具体要求:
- 所有命令都支持
--json,统一返回{"ok":true,"data":...} - 用退出码表达结果,不让 agent 解析文本
- 所有写操作记审计流水,谁在什么时候做了什么都能查
- 界面把认领人和租约显示出来,人能看到”这条 AI 正在做”
那个 --json 和退出码的设计,是我从踩过的坑里学的。
退出码:让 agent 不用读人话
早期版本 task claim 被别人持有时,是返回一段中文错误:
错误:任务 #12 已被 agent-worker 认领
问题在于,agent 要判断”是否被占用”,就得去解析这段中文。改个措辞,agent 就瞎了。
现在改成像这样:
| 退出码 | 含义 | 建议动作 |
|---|---|---|
| 0 | 成功 | 继续 |
| 1 | 参数或用法错误 | 修正命令,别重试 |
| 2 | 找不到任务 | 检查 ID |
| 3 | 冲突(被他人持有) | 换一条,或等待租约到期 |
agent 只需要判断 $? == 3,完全不需要知道错误文案是什么。
这个思路其实是”别让机器去读人写的东西”。人读的文案可以随便改、可以加解释;机器读的必须是稳定的契约。我给 agent 写提示词的时候也一样 —— 给它的指令必须可判定,不能是”酌情处理”。
原子认领:多个 agent 抢同一条
我跑 AI 协作项目的时候经常同时开几个窗口,认领任务时会撞车。
问题出在 SQLite 上。最初我的写法是”先查有没有人认领,再更新”:
// 有竞态:两个进程可能同时查到「没人认领」
const row = db.get("SELECT * FROM tasks WHERE id = ?", id);
if (row.agent) return CONFLICT;
db.run("UPDATE tasks SET agent = ? WHERE id = ?", agent, id);
两个 agent 同时跑完 SELECT,都看到”没人认领”,然后都去 UPDATE。结果两个都以为自己拿到了。
正确做法是让数据库自己保证原子性 —— 一条语句搞定,判断和更新在同一个事务里:
UPDATE tasks
SET agent = :agent, status = 'doing', lease_until = :until
WHERE id = :id
AND (agent IS NULL OR lease_until < :now)
然后看影响行数:0 就说明被别人抢了,返回冲突;1 就是抢到了。
我一开始没想到这层。 是 AI 写的代码,我验收的时候问了一句”两个进程同时执行会怎样”,它自己也发现问题在 SELECT 和 UPDATE 之间。那时候我就明白了 —— 我验收 AI 代码最该问的不是”这段对不对”,而是”两个同时跑会怎样”。
租约:AI 会中途死掉
认领之后必须有个”到期时间”,默认 30 分钟。
原因很直接:AI 会崩。 进程被杀、网络断了、模型卡住超时 —— 都可能。如果认领是永久的,这条任务就永远卡在”进行中”,谁也动不了。
所以有租约,也有心跳:
t2a task next --lease 30m # 取任务,自动认领,租约 30 分钟
t2a task heartbeat 12 --lease 30m # 正在做的任务定期续租
t2a task release 12 # 主动放弃,回到待办
长任务每十几分钟 heartbeat 一次。租约到期自动回收回待办,并在审计流水里留一条系统日志 —— 这样你回头能看到”这条任务被自动回收过”,而不是凭空消失。
heartbeat 还有个细节:未认领时它等价于 claim。因为 agent 的生命周期里,”我开始做了”和”我还在做”往往是同一个命令,容错一下省事。
依赖 DAG:等 A 完成才能做 B
有些任务天然有顺序。”写完需求文档”必须先于”按文档实现”。
支持这个要处理两件事:
一是加边的时候检测环。 加依赖前先查一下”如果 A 依赖 B,B 的依赖链里会不会绕回 A”。不检测的话,一个手滑就能造出一个死循环,然后 ready 命令永远返回空,你完全不知道为什么。
二是 ready 只列真正可开工的。 不只是状态是”待办”,还得是所有前置任务都已完成。
t2a task ready # 只看,不认领
t2a task next # 取一个可开工的并认领
ready 和 next 分开是有意的。前者是”我先看看有什么能做的”,不该产生副作用;后者才是”给我一条”,会认领。
很多工具把这两个混在一起,然后你只是想看一眼却把任务抢走了。
零依赖:一个刻意的限制
这个项目只用 Node 内置模块,不需要 npm install,构建产物也随仓库提交了。
这个决定一开始让我犹豫 —— 用一个现成的 ORM 写 CRUD 显然快得多。选零依赖的理由是:
我用的最多的场景是”让 agent 临时拉起来跑一下”。 如果每次都要 npm install,那用它就多了一步环境准备,多了一个”依赖装不上”的可能。
代价是所有东西都手写:建表、参数校验、HTTP 路由、SQL 拼装。我不介意,因为我要的是”拿起来就能跑”。
顺带一个坑要提醒:存储用的是 Node 内置的 node:sqlite,要求 Node >= 22.13。22.12 实测会报 ERR_UNKNOWN_BUILTIN_MODULE。
所以我加了 t2a doctor:
t2a doctor
# 检查 Node 版本 / 数据库 / 网页服务 / 前端产物
跑不起来先跑这个,别猜。它是我加过的最省事的命令。
界面:两种展现方式
看板是七列:灵感区 → 待办 → 进行中 → 阻塞 → 待验收 → 已完成 → 存档。
界面语言(中/英)、主题(浅/深/跟随系统)、任务列表展现形式都收在设置菜单里。
最后这个我一开始没做,只做了竖版看板(状态列并排)。后来发现窄屏下很难用 —— 手机上七列并排,每列挤得只剩标题。
于是加了横版分组:状态在左边,任务整行铺开,右侧一个箭头就地展开详情。手机上这个顺手得多。
这两种形态的取舍挺有意思:竖版适合宽屏横向对比状态,一眼看”哪列堆得最多”;横版适合窄屏逐行处理。我没法替用户选,所以做成可切换的设置项。
界面语言的选择存在浏览器本地(t2a-lang),只影响这一台设备,命令行输出仍是中文。这个行为我一开始觉得别扭,后来觉得挺对 —— 命令行是给 agent 和脚本用的,界面是给人看的,本来就不该绑定。
还有个 MCP server
除了 CLI,它还能以 MCP server 模式跑(stdio JSON-RPC),暴露 22 个工具。
t2a mcp
完整约定写在 AGENT.md 里。
这个功能对”已经用 MCP 方式驱动 agent”的人是刚需 —— 支持 MCP 的话不用再去拼命令。但说实话我自己平时还是用 CLI,因为命令行能直接看到输出,出问题好排查。
给 AI 造工具这件事,最反直觉的一点是:你必须把”人类能读”和”机器能读”当成两套东西来设计。
人类读的部分可以随意 —— 措辞能改、能加解释、出错信息可以写得很详细。机器读的部分必须是稳定契约 —— 退出码、JSON 结构、字段名,改了就是 breaking change。
一开始我把它们混在一起写,结果两边都不好。拆开之后反而简单了:--json 给人看的随便写,给 agent 读的那份结构定死,别动。
装不装得上
git clone [email protected]:isnotry/TaskToAgent.git
cd TaskToAgent
npm run web # 网页看板,默认 http://127.0.0.1:3979
node bin/t2a help # 命令行
数据落在 ~/.t2a/t2a.db,不联网、不登录、无账号。命令行主命令是 t2a,旧命令 taskcli 保留兼容。
源码在 isnotry/TaskToAgent,MIT 许可。这是我目前做的项目里最常打开的一个,因为它管着其他项目。
如果你也在让 agent 帮忙干活,欢迎来试试,或者告诉我你的 agent 需要什么接口 —— 也许该加个工具。