用 MCP 拉取任务:认领与幂等提交实践
作者用约 180 行 Python 服务和 SQLite,复现聊天端通过 MCP 主动认领任务、提交结果及重复提交的流程。实现包含租约与幂等重放,但客户端由脚本模拟,未实际接入 Claude 或 ChatGPT。
AI 深度解读
把任务交给 Claude 对话,难点在于:没人打开对话、发起一轮交互,它就不会来取任务。osFoundry 的这套 MCP 工作流因此更像一个由人启动的任务队列,必须应对重复领取、对话中断、结果重发和取消。
作者用约 180 行 Python 加 SQLite 做了复现,验证这些边界该怎么处理。文章最有用的结论是:接入 AI 对话后,领取权限、重试和状态转换仍然要由服务端兜住。但复现没有使用 Claude 二进制或账号,实验结果不能直接算作产品实测。
对话不会自动醒来,任务就得允许长时间等待
osFoundry 的连接对话任务有六种状态:queued | claimed | completed | failed | cancelled | expired。管理界面位于 apps/web/src/screens/settings/ConnectedChatTasks.tsx,任务创建后等待宿主对话领取,24 小时后过期。
对 Claude 自定义连接器,原文描述的是请求式交互:对话能调用工具,服务端却没有通用事件入口替它启动新一轮。ChatGPT 的 Work 对话存在 MCP Events 订阅路径,但也需要先订阅,不能把它当成所有对话都具备的能力。
所以,等待 6 小时未必是故障,领取后关闭标签页也属于正常情况。
这里有两种不同的「查看任务」。管理页面使用 setInterval(..., 15000),每 15 秒刷新一次,每个持续打开的页面每天产生 5,760 次查询;宿主对话则是用户请求时读取一次,并不持续轮询。
创建任务时,前端通过 crypto.randomUUID() 生成 idempotency_key。只要任务文案没变,组件就复用同一个键,让双击和超时重试对应同一次创建。文件上传工具 upload_linked_chat_artifact 也要求重试复用 artifact_id。
一次业务操作的身份,应当跨越多次网络请求保持稳定。
领取任务,为什么不能先查再改
先查到 status == 'queued',再更新为 claimed,看起来很自然,却给并发请求留下了空隙:两个请求都可能先判断任务可领,然后依次写入。
作者为每个任务安排 8 个线程,用 threading.Barrier 同步发起 HTTP 领取,共测试 200 个任务。朴素版本通过 --naive 1 开启;人为插入的 2 ms 间隔用于模拟网络往返或 ORM 开销。即使不插间隔,作者仍观察到约一半或更多测试出现竞争,比例受机器负载影响。
SQLite 会串行处理写入,但它无法撤回两个请求此前已经作出的判断。复现默认改为条件更新,把判断和修改交给同一条 SQL:
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' 和随后的 rowcount == 1:只有真正更新到那一行,请求才获得领取资格。
工具 claim_linked_chat_task 还要处理重复调用。同一连接再次领取,应返回已有的 claim;其他连接拿着相同任务 ID 请求,则不返回任务,连是否存在也不透露。作者认为,同一对话连续触发两轮交互,比 8 路并发更贴近日常使用。
对话消失和结果重发,需要两套处理
作者给复现增加了租约:超过 lease_until 仍未完成,任务就退回 queued。这不是原文已经确认的 osFoundry 连接对话能力。产品公开信息涉及任务的 24 小时过期,以及桌面分发器等待连接对话时的 30 分钟执行限制,不能混为一谈。
实验把租约设为 1 秒,A 领取后停止响应,B 每 100 ms 尝试领取。B 失败 10 次,在 1.064 秒时接手,比租约结束晚 64 ms。随后 A 用旧 claim_id 提交,被服务端拒绝。
这里的 claim ID 充当隔离令牌(fencing token):提交时必须证明自己仍持有当前领取权。否则旧对话迟到的答案可能覆盖新领取者的结果。
结果重发是另一回事。submit_linked_chat_result 可能已经写入成功,只是响应丢了。复现将 sha256(status + result) 存入任务行:同一 claim 重交相同内容,返回成功并标记 replayed: true;内容不同则拒绝。作者连续提交相同结果 4 次,第 5 次修改内容,检查两种路径。原文还提到,osFoundry 的 Graph 中继完成流程采用相同规则。
取消也必须进入状态判断。任务变为 cancelled 后,后续提交应被拒绝,工具说明则提前告诉模型这个限制,减少无效重试。
租约却不一定适合每个团队。太短会抢走仍在执行的任务,太长会让关闭的对话占住任务;只有一个连接能领取时,自动释放也没人接手,明确的过期时间和 Cancel 按钮可能更合适。
我的判断
我认同这篇对任务协议的处理:条件领取、重复调用返回成功、拒绝旧 claim,都能对应具体故障。它也提醒我们,模型会重复调用工具,接口不能依赖「它应该只调用一次」。
性能部分同样有启发。作者放入 10,000 条历史任务,覆盖 500 个连接,对一个连接查询 2,000 次;又将空收件箱查询重复测试 3 轮。初版服务端约慢 9 倍,原因是每次读取都执行两条过期清理 UPDATE,即使没有匹配行,也会获取 SQLite 写锁。先做只读检查,有待清理记录再写,才把差距压到测量误差附近。
但这些数字有明确边界:环境是 Apple M1、Python 3.9.6、SQLite 3.43.2 的 WAL 模式,以及 127.0.0.1 上的 ThreadingHTTPServer。同时运行的渲染任务让负载均值达到 8~22,作者建议主要看 p50,尾部数据噪声较大。
脚本轮询实验还以 0.2~1.2 秒间隔投入 40 个任务,观察到平均领取延迟接近轮询周期的一半。这不能证明真实 Claude 对话的响应速度。对话读取的额外成本还包括上下文:复现格式中,空列表为 87 字节,每个待处理任务增加约 210 字节;字节数也不能直接当作 token 数。
这篇验证的是本地任务协议的部分边界,真实宿主交互和生产规模仍缺少对照数据。
能拿来做什么
- 给前端任务提交页设计重试时,让同一次操作复用
idempotency_key,双击和超时重发都沿用它。 - 用 Node 实现 MCP 任务服务时,将领取条件写进数据库更新,并在提交时校验当前
claim_id,同时为相同结果重发返回成功。 - 引入租约前,先确认任务释放后是否有其他执行者接手;只有一个对话能处理时,优先把过期和取消做清楚。
- 检查任务列表接口是否每次查询都触发清理写入,并在工具说明中写明取消、旧 claim 和重复提交分别会得到什么结果。