插画:左侧人形与右侧机器人隔着中央看板协作,看板上分布着蓝色与橙色的任务卡片
AI 协作CLISQLiteMCP开源项目

我给 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 需要什么接口 —— 也许该加个工具。