用 SQLite 重建 MCP 任务认领与幂等提交
作者用约 180 行 Python 和本地 SQLite 重建 osFoundry 向关联聊天移交任务的调用过程,演示任务认领、结果提交及重复提交响应。工具名称来自真实连接器,但服务端、租约字段和重放标记均为重建实现,未使用真实 Claude 客户端验证。
AI 深度解读
把任务交给 Claude 对话,最麻烦的可能不是模型会不会做,而是它什么时候开始做、关掉页面后怎么办,以及结果提交两次会不会出错。osFoundry 的这套交接机制,处理的就是这些问题:服务端保存任务,聊天端主动领取,再带着领取凭证交回结果。
原作者用约 180 行 Python 和本地 SQLite 重建了这条链路,测试了并发领取、超时接管和重复提交。结论很实用:聊天窗口也可以充当 worker,但服务端必须把重复调用、中途消失和迟到结果当成正常情况。 不过,重建实验的能力不能全部算到 osFoundry 产品头上。
没有推送时,真正调度任务的是人
常规后台 worker 可以持续监听队列,Claude 对话却未必有这个条件。按原文描述,osFoundry 将 Claude 自定义连接器视为请求驱动的接口:活跃对话可以调用工具,服务端没有通用触发器替用户开启一轮对话。
ChatGPT 的部分 Work 对话可以订阅 MCP Events。只有订阅生效时,面板才提示事件订阅已启用;否则,用户仍需打开宿主对话来启动任务。因此,队列等待六小时不一定代表系统故障,也可能只是没人打开聊天窗口。
任务状态包括 queued | claimed | completed | failed | cancelled | expired,任务在 24 小时后过期。网页面板通过 setInterval(..., 15000) 每 15 秒刷新,聊天端则是用户要求检查时才读取一次,两者不能混为一谈。
创建任务也要处理重复操作。前端用 crypto.randomUUID() 生成 idempotency_key,只要目标文本没变,就保留同一把键。双击按钮或请求超时后的重试,仍表示同一次创建意图。文件上传工具 upload_linked_chat_artifact 则要求重试时保持 artifact_id 稳定。
这里最可借鉴的是:幂等键应当跟随一次操作意图,而不是每发一次 HTTP 请求就重新生成。
领取任务,为什么不能先查再改
两个请求同时读取任务,都看到 queued,接着各自写成 claimed,就可能都认为自己领取成功。SQLite 会串行处理写入,却无法撤回两个读取者已经作出的判断。
作者为每个任务同时释放八个线程,通过 HTTP 调用 claim_linked_chat_task,共测试 200 个任务。朴素实现由 --naive 1 启用;作者还插入 2 ms 间隔,模拟网络或 ORM 带来的时间窗口。即使没有这段间隔,竞争仍在约半数或更多测试中出现,比例随机器负载变化。
解决办法是把状态检查和写入合成一个条件更新:
cur = c.execute("UPDATE tasks SET status='claimed', claim_id=?, lease_until=?, updated_at=? "
"WHERE id=? AND chat_link_id=? AND status='queued'",
(cid, now + self.lease, now, task_id, link))
if cur.rowcount == 1:
return {"claim_id": cid, "lease_until": now + self.lease}
关键是 WHERE 中的 status='queued',以及更新后的 cur.rowcount == 1。能否领取由数据库这次写入决定,应用层提前查询不能替代它。
同一聊天重复领取时,还应返回已有 claim_id。用户催一次、模型重复调用一次,都不该凭空产生第二份执行资格。不同链接请求同一任务 ID 时,原文示例返回 not claimable: missing,连任务是否存在都不确认。
提交阶段也需要同样严格。submit_linked_chat_result 必须校验当前 claim_id,并要求任务仍为 claimed;取消之后的提交应被拒绝。
聊天消失和响应丢失,需要两种不同的补救
聊天领取后消失,可以考虑租约(lease):超过 lease_until 还未完成,就把任务放回队列。但这一点来自作者的重建实验。原文能确认的 osFoundry 文档规则,是任务 24 小时过期,以及桌面 dispatcher 等待关联聊天时有 30 分钟执行上限,不能据此认定产品具备相同的逐次领取租约。
实验设置 1 秒租约,让 A 领取后沉默,B 每 100 ms 尝试领取。B 在 1.064 秒后接管,此前失败 10 次;A 随后提交旧结果,被拒绝,B 提交成功。
这里的 claim_id 承担隔离令牌(fencing token)的作用:它证明提交者持有的是当前这一轮领取资格。提交的 WHERE 条件若不检查它,A 的迟到结果就可能覆盖 B。
租约并非越短越好。模型仍在生成时过期,会错杀正常执行;设得太长,关闭的标签页又会长期占着任务。只有存在其他执行者能够接手,租约才真正提供故障恢复价值。
另一种问题是结果已经落库,但成功响应在途中丢失。此时重试应该成功,而不能提示任务已完成,让模型误以为需要修改结果再交一次。
重建版本保存 sha256(status + result)。同一 claim 携带相同哈希重试,返回成功;已完成任务收到不同结果,则拒绝。作者测试了四次相同提交,再提交第五次不同结果。原文还指出,osFoundry 的 Graph relay 对完成回报采用相同的重放规则,但这不能直接证明关联聊天接口内部使用了相同哈希实现。
轮询便宜不便宜,要看它有没有偷偷写数据库
面板每 15 秒刷新一次,一个全天打开的面板会产生 5,760 次请求。作者在 10,000 条历史任务、500 个链接的数据集上,对单个链接执行 2,000 次 localhost HTTP 读取,发现问题出在过期清理。
重建版本采用惰性过期:读取、领取和提交之前,先清理到期记录。初版每次读取都执行两个 UPDATE,即使没有记录需要更新,也会获取 SQLite 写锁。原文报告,初版在服务端约慢九倍。
作者又做了三轮、每轮 2,000 次空轮询的测试。先做只读检查,确认存在到期记录后再写入,开销就接近不清理时的波动范围。单加索引解决不了获取写锁的问题。
这些数字有明确边界:Apple M1、Python 3.9.6、SQLite 3.43.2、WAL 模式、绑定 127.0.0.1 的 ThreadingHTTPServer。测试时机器还在做渲染,load average 为 8 至 22。作者建议参考 p50,把尾部延迟视为噪声较大;提供的正文也没有列出完整延迟表,不能据此推算线上容量。
脚本轮询另有一组测试:40 个任务以 0.2 至 1.2 秒间隔到达,平均领取等待时间接近轮询周期的一半。聊天端更需要控制上下文开销:实验格式中,空收件箱为 87 字节,每条排队任务约再增加 210 字节。这是序列化体积,不是 token 数,也不是固定费用。
我的判断
这篇最扎实的部分,是把数据库状态约束与 AI 工具调用连起来。模型可能重复调用、用户可能催促、成功响应可能丢失,这些都不适合交给提示词兜底。条件更新、稳定幂等键和完成结果重放,应该由服务端保证。
需要保留问号的是实验外推。作者没有使用 Claude 二进制或账号,聊天端是 Python 脚本;它验证了重建服务器面对这些请求时的行为,没有验证真实宿主的调度和重试表现。
与常驻队列消费者相比,这套方式适合接受人工启动、等待时间不确定的任务。若业务要求无人值守和稳定启动延迟,就需要能持续运行的执行端。给聊天窗口加租约,不能让它自动醒来。
工具描述仍然有价值。提前告诉模型取消任务不能完成,可以减少无效重试;真正挡住错误提交的,还是服务端状态检查。
能拿来做什么
- 在前端任务面板里,把
idempotency_key保留到任务目标发生变化,并展示等待、领取、取消和过期状态,让用户知道下一步是否需要打开聊天窗口。 - 在 Node 服务实现领取接口时,使用数据库条件更新,并让重复领取返回已有凭证;提交时同时校验任务状态与当前
claim_id。 - 为提交接口设计成功重放规则,相同凭证和相同结果重试返回成功,修改后的结果明确拒绝;在工具描述里说明取消、过期等不可重试条件。
- 先确认任务是否有其他执行者可以接手,再决定是否引入租约;轮询读取中的过期清理,则先检查是否确有到期记录,避免空轮询持续争抢写锁。