# 协议 整个系统是一个循环,三个口子。 ``` 事件 领活 来源 ─────────▶ 中台 ◀───────── 接单程序 ── Agent ▲ │ 回答 │ │ 卡片 │ ▼ 通道 ◀──▶ 你 ``` **中台的内核不主动连接任何东西。** 来源、接单程序、通道都是它们自己连进来:内核不用知道它们在哪、用什么语言写、是哪家的产品,也不保管它们的凭据;你的电脑不用开放任何端口。协议就是下面这份 HTTP + JSON,写一个新插件只要会发 HTTP 请求。 一台电脑上用时,一条命令把三样都跑在一个进程里,它们之间照样走这份协议。 **中台没有界面。** 你和它打交道靠通道和命令行。想要一个页面或大屏,用「本人」一节的几个请求自己做,它和命令行是同等的客户端。 ## 六个词 | 词 | 是什么 | | -------- | ------------------------------------------------------------------------------------------------------------------------ | | **事件** | 发生了一件事:来源、编号、类型、对象、一句话、详情、回原处的链接。时间到了是事件,你点了卡片也是事件。 | | **规则** | 写在 `rules.json` 里:什么事件,交给哪个 Agent,在哪个目录,用哪个 Skill,什么时候告诉你。 | | **事项** | 同一条规则下同一个对象的事。事件归进事项,卡片、到期复查、会话都挂在事项上。规则没写 `parallel` 时,整条规则是一个事项。 | | **一轮** | 一次执行:领一批事件,交一份回执,然后结束。一轮会结束,事项会继续。 | | **回执** | Agent 的最后一条消息。它想让中台做的事全写在这里,这一轮结束时在一个事务里一次生效。 | | **卡片** | 给你看的东西。带选项的是提问,你选了就是一条新事件,事项接着往前走;不带选项的是通知。 | ## 六条原则 1. **注意力最贵。** 默认安静。没事不说话,能自己做的自己做完,只有规则写明要先问的事、确实需要本人解除的阻塞才打断你。 2. **一切都是事件。** 外部系统、时间、到期复查、你的决定,走的是同一条路。 3. **一切都连进来。** 内核没有对外的请求。凭据留在用它的地方,出事的范围也留在那里。 4. **Agent 就是一个进程。** 给它提示词,它打印最后一条消息。Claude Code、Codex、任何命令行 Agent、一段脚本、一条 CI 流水线都一样,不需要 SDK。要启动远端工作的进程,负责跟到真正做完才退出。 5. **一轮不等人。** 要问你就结束这一轮,收到回答后再开始处理,尽量接着原来的会话。等你的时候不占进程、不花 Token,重启也不丢。 6. **规则才是指令。** 事件决定不了交给谁、在哪个目录、用哪个 Skill、给什么权限,这些只由规则决定。别人写的话不进提示词,只放进标明“是资料”的文件。这挡不住所有提示注入,但把最坏的结果限制在规则给定的范围里。 ## 规则 ```jsonc { "timezone": "Asia/Shanghai", // 定时和“今天”用的时区,默认系统时区 "askFirst": ["删数据", "停服", "花钱", "对外发消息"], // 只有这几类事 Agent 才先问你 "sources": { "shop": { "name": "shop" }, "github": { "name": "GitHub", "adapter": "github" } }, "agents": { "claude": { "name": "Claude Code", "worker": "mac-mini", "use": "claude", "model": "…", "effort": "high", "access": "edit", "program": "…", // 可选:内置插件启动这个程序,代替自动找到的那个,比如设好环境变量再 exec claude 的脚本 }, "cursor": { "name": "Cursor", "worker": "mac-mini", "use": "command", "command": ["agent", "-p", "--trust", "{prompt}"], }, }, "channels": { "feishu": { "name": "飞书", "answerers": ["ou_xxx"] } }, "rules": [ { "name": "处理告警", "when": { "source": "shop", "types": ["alert.*"] }, // 或 { "cron": "0 9 * * *" } "to": "claude", "cwd": "~/code/shop", "skill": "run-ops", "prompt": "按其中……一节处理。", "group": "ops", // 同一组一次一轮,默认就是规则名 "parallel": 3, // 写了就按对象分事项:同一对象一次一轮,这一组最多 3 轮,还受接单程序的 worker.capacity 限制(默认 2) "resume": true, // 同一事项的新事件接着上一轮的会话,默认每次开新会话 "tell": "needed", // always 每轮做成都发(没事可做、没跑成不发)/ needed 只在有卡片时(默认)/ never 只压住回执里的通知,提问和中台自己的告警照发 "emits": { "source": "shop", "types": ["pr.*"] }, // 回执里能发什么事件,不写就不能发 "channels": ["feishu"], // 这条规则的卡片和通知只发到这几个通道,不写就发到所有通道 }, ], "elsewhere": [ { "name": "每周清理", "source": "shop", "to": "claude", "cron": "0 6 * * 0", "where": "Claude 应用的定时任务", "cwd": "~/code/shop", "skill": "weekly-sweep", "prompt": "按其中“回答来了”一节处理。", // 可选:要问你时写,回答后在这里照这个接着做 }, ], } ``` `cwd` 由接单电脑解释,可用 Unix 绝对路径、Windows 盘符或 UNC 路径、`~/` 或 `~\` 开头的家目录路径;中台与接单电脑可以用不同操作系统。接受 Windows 路径是兼容的能力扩展,原有 `/v1`、规则与插件用法不变。 `elsewhere` 是在别处定时跑的任务:中台不启动它们,只在 `agent-autoloop status` 里列出来,它们要找你时用 `POST /v1/tell`。写了 `cwd`(还要写 `skill` 或 `prompt`)的也能问你:卡片带选项,你答了,中台像规则的一轮那样叫醒 `to`,在 `cwd` 照 `skill`、`prompt` 接着做,这一轮的组就是任务名,一次一轮,不接定时任务自己的会话。规则写错了,文件改动不生效,旧规则继续跑,并推一张卡片告诉你错在哪。 写了 `resume`,一轮开始时如果事项的上一轮做完了(包括没事可做)、留下了会话,就接着那个会话;会话越接越长,Token 和费用也会跟着涨。这几种情况开新会话:上一轮被停掉了(你停的,或连续失败停下的),接着它多半还是同样的结果;上一轮是别的 Agent 跑的;上一轮没有会话。没写 `parallel` 时整条规则是一个事项,所有事件接同一个会话;事件不带 `subject` 时也都算同一个事项。回答和到期的复查仍回到提问和约定时的会话,重派的也留在自己的会话。 不管写没写 `resume`,一轮要接的会话里如果还有别的轮次没结束,或者没了音信、可能还在跑,这一轮就开新会话,不让两个进程开同一个会话;回答和复查的内容照样带给它。 排队和等待重试的任务在领取时一起采用最新的 Agent、目录、提示词和权限;换了 Agent 就不带旧 Agent 的会话。已经领取的任务保留领取时的 Job 配置和接单程序,重复 claim、心跳、停止和重启恢复都不随规则更新换人。事件已经归属的事项不重新拆分或合并,回答和到期的复查回到提问和约定时的事项,规则后来去掉 `parallel` 也一样;回执中的事件许可和通知方式仍按提交时的规则核验。 ## 来源:把世界变成事件 ``` POST /v1/events Authorization: Bearer <来源的密钥> { "id": "12:new", "type": "feedback.new", "subject": "#12", "summary": "…", "detail": "…", "link": "https://…" } → 200 { "event": 34, "duplicate": false, "routed": ["处理意见"] } ``` - `id` 和 `type` 必填。同一来源同一 `id` 只记一次,所以来源尽管重发。 - `subject` 只能是标识符(字母、数字和 `# . _ : / @ -`),`link` 只能是纯 ASCII 网址,因为它们会进提示词。`summary`、`detail` 不进提示词。 - 没有规则匹配的事件也留着,`agent-autoloop status` 看得到。 - 字段上限:`id` 200 个字、`subject` 100、`summary` 200、`detail` 4000、`link` 500,`type` 64 个字符。 三种别的进法,到中台都是同一种事件: - **定时**:规则写 `cron`。错过的(比如电脑合盖了)醒来只补最近的一次。重复的时刻(夏令时)只跑第一次,不存在的时刻跳过。 - **别人家的 Webhook**:`POST /v1/in/<来源>`。来源配了 `adapter`,就交给 `plugins//source.ts` 里的函数验签、翻译成事件,它只翻译,不发请求。验签用的秘密放在 `secrets.env` 的 `AGENT_AUTOLOOP_SECRET_<来源>`(来源名转大写,`-` 换成 `_`,改完重启中台),不进 `rules.json`。 - **轮询**:不用连公网,没新东西不花 Token。规则定时叫一段脚本(也是一个 Agent),脚本在回执的 `events` 里交回新事件。事件的 `id` 要稳定,读的范围可以重叠,中台会去重;脚本要在回执交出去之后才推进自己的游标,或者干脆不留游标。 来源或 `elsewhere` 任务有话要直接告诉你(不用 Agent 处理): ``` POST /v1/tell { "key": "weekly-44", "text": "…" } Authorization: Bearer <来源的密钥> POST /v1/tell { "task": "每周清理", "key": "x", "text": "…" } Authorization: Bearer <接单程序的密钥> → 200 { "card": 56, "duplicate": false } ``` 这样说的是仅告知的通知(`fyi`),没有按钮,同一个 `key` 只发一次。写了 `cwd` 的 `elsewhere` 任务要你拍板时,用 `ask` 代替 `key` 和 `text`,内容和回执里的 `ask` 一样: ``` POST /v1/tell { "task": "每周清理", "ask": { "title", "text", "options": ["…", "…"], "recommend": 0, "about": "…", "key": "…" } } Authorization: Bearer <接单程序的密钥> → 200 { "card": 57, "duplicate": false } ``` 同一个 `key`(不写就按标题和内容)的卡片还开着就不再发。你答了,中台叫醒这个任务的 Agent 在 `cwd` 接着做,回答在这一轮的 `answers` 里;做完卡片改成已执行。没写 `cwd` 的任务问你返回 400。老中台不认识 `ask`,会返回 400:中台和接单程序分开部署时先升中台。 ## Agent:领活、干活、交回执 每台电脑上有一个接单程序,它只往外连。一台接单程序可以服务多个 Agent。 ``` GET /v1/work?claim=<这次领活的编号>&wait=50&running=7,8&capacity=1 Authorization: Bearer <接单程序的密钥> → 200 { "job": null | Job, "cancel": [7] } ``` - 没活时最多等 `wait` 秒,新活一来立即返回。`running` 是手上在跑的,同时也是心跳。`capacity=0` 只报到、不领活。 - 手上有活时可以每秒来问活、查取消。心跳最多一分钟记进日志一次,手上有几轮都只记一条;通道领卡片也一样。所以 `/v1/state` 的 `seenAt` 可能比最后一次来的时间早不到一分钟,“5 分钟没报”“10 分钟没连上”也可能因此提前不到一分钟判断;`online` 留了这一分钟的余量,记录后 3 分钟内都算在线。 - 响应丢了,用同一个 `claim` 再来一次,拿回的是同一份活。 - `cancel` 是要停掉的。Unix 接单程序先向整个进程组发停止信号,10 秒后仍有进程运行就强制结束;Windows 每轮运行在独立 Job Object 中,停止时结束整个子进程树。确认进程已停止后才交 `outcome: "cancelled"`,这个事项才放开。停止期间重启接单程序也继续这一步。要停的这一轮交回 `failed` 也算停了,不再重试。 ```jsonc // Job:一轮的全部。token 只对这一轮的这一次执行有效。 { "run": 12, "token": "…", "attempt": 1, "rule": { "name": "处理告警", "cwd": "~/code/shop", "skill": "run-ops", "prompt": "按其中……处理。" }, "agent": { "id": "claude", "name": "Claude Code", "use": "claude", "model": "…", "effort": "high", "access": "edit" }, "session": null, // 尽量接着的会话:重派时是上一次的,回答来了是提问那一轮的,规则写了 resume 时是这个事项上一轮的。接不上就开新的,所以它只是优化 "askFirst": ["删数据", "…"], "context": { // 接单程序把它写成文件交给 Agent,都是资料 "events": [{ "id", "source", "sourceName", "type", "subject", "summary", "detail", "link", "receivedAt" }], "answers": [{ "card", "title", "options", "choice", "text", "about", "by", "at" }], // 这一轮要接着做的决定 "timers": [{ "key", "about", "at" }], // 这一轮到期的复查 "waiting": [{ "key", "about", "at" }], // 这个事项还没到期的复查 "open": [{ "card", "title", "text", "at" }], // 同来源同组还没收尾的卡片 "decided": [{ "card", "title", "choice", "at" }] // 这个事项定过的事,只对当时那件事有效 } } ``` ``` POST /v1/runs/12/started Authorization: Bearer { "session": "…", "link": "claude://…" } POST /v1/runs/12/result Authorization: Bearer Result → 200 { "rejected": [{ "card": 9, "reason": "不在本轮核对范围" }] } ``` ```jsonc // Result { "outcome": "done" | "nothing" | "failed" | "cancelled", "summary": "一句话", "error": "没跑成的原因", "session": "…", "usage": { "input": 0, "output": 0, "cached": 0, "cost": 0 }, // 下面就是回执,一个事务里一次生效 "ask": [{ "title", "text", "options": ["…", "…"], "recommend": 0, "about": "留给接着干的自己", "key": "这件事的固定名字" }], "notice": [{ "title", "text", "key", "fyi": true }], "resolve": [{ "card": 3, "reason": "在哪核实了已解决" }], "later": [{ "key": "…", "at": "2026-11-05T09:00:00+08:00", "about": "到时候查什么" }, { "key": "…", "cancel": true }], "events": [{ "id", "type", "subject", "summary", "detail", "link" }] } ``` - 结果可以重放:响应丢了,同一个 token 再交一次同样的内容,得到同样的确认;内容不同返回 409。 - `failed` 会重派,所以它只带 `notice`,其余的不生效;本人要停的这一轮交 `failed` 不再重派,`notice` 也不生效。 - 需要本人恢复连接、登录或身份验证时,用 `done` 结束本轮并交 `ask`,不交 `failed`。`done` 表示本轮结束,未完成的事项由卡片继续跟进,不代表网页已经验收。同一阻塞已有 `open` 卡片时不重复提问,不用 `later` 定时重试;`waiting` 里同一阻塞已有的复查用 `cancel` 取消。保留已完成业务,`about` 写清恢复后只做哪些剩余步骤。 - `usage` 由接单程序上报本次执行的用量,包含这次执行的回执重写;每一项都可以没有:`input`、`output`、`cached` 是 Token 数,`cost` 是美元。中台把同一轮各次执行的已报数值相加,重复提交不重复计费;没有上报的字段保持缺省,不当成零。`agent-autoloop status` 按完成时间汇总今天和最近 7 天已结束的轮次,没上报的轮数如实写出来。 - 有的 Agent 只给会话累计值(Codex 的 Token、Claude Code 的费用)。接单程序减去这次启动前的累计值再上报,所以这里总是本次的量;没有记录的续接不报这几项,详见[插件说明](plugins.md#agent-插件)。 - `ask` 的 `key` 范围是这个事项:同一个 `key` 的卡片还开着就不再发。关掉之后再问是新的一次。 - `notice` 可选填布尔字段 `fyi`:`true` 表示「仅告知」,卡片创建时就是 `closed`,仍会在创建后的 24 小时内投递,飞书显示灰卡、不带「已处理」按钮;不会进入后续轮次的 `open`,点击也不会产生回答事件。省略或填 `false` 时,通知带「已处理」按钮,也能被 `resolve` 结案。它一创建就结案了,所以同一个 `key` 下一轮再写还会再发一张;同一轮重试只发一次。规则写了 `tell: "never"` 时照样不发。老中台不认识这个字段,会拒收整份回执:中台和接单程序分开部署时先升中台。 - `resolve` 只能结同来源、同组、这一轮领活前就有的卡片,越界的写进 `rejected`,不影响这一轮的结果。 - `later` 的 `key` 范围是这个事项,同一个 `key` 再写一次就是改时间。 - `events` 的来源由规则的 `emits` 决定,回执改不了。 ### Agent 只需要做一件事 接单程序把提示词交给 Agent,Agent 的最后一条消息就是回执: ```` 第一行一句话结论。可自动重试的临时失败写「没跑成:原因」,需要本人恢复的阻塞写「待你处理:原因」并交 ask,没事可做写「没事可做:原因」。其余结束本轮,事项由回执继续跟进。 ```agent-autoloop { "ask": [ … ], "notice": [ … ], "resolve": [ … ], "later": [ … ], "events": [ … ] } ``` ```` 代码块可有可无。没有 `agent-autoloop` 代码块、最后一个 `json` 代码块里却只有回执字段的,算回执标错了,按下面重写;普通的 `json` 不受影响。Agent 不需要装任何东西、不需要连中台,所以沙箱里的、云端的 Agent 也能接。 回执写错不重做业务:接单程序接着原会话,只让它把最后一条消息重写一遍,最多两次;没有会话的 Agent(比如 `command`)不重写,直接走下面这句。还是读不懂,或重写进程启动失败、报错、超时、没有输出,就保留原业务消息第一行的结局,回执作废,推一张卡片告诉你原文在哪(每一轮的输出在这一轮结束后留 14 天);补回执失败不会把已完成的业务变成自动重试。 ### 接一个新 Agent 规则里写一行,任何能打印最后一条消息的命令都行,退出码不是 0 就算没跑成: ```json { "name": "Hermes", "worker": "mac-mini", "use": "command", "command": ["hermes", "chat", "-Q", "-q", "{prompt}"] } ``` 想要会话接续、打开会话的链接、用量统计,就在 `plugins/<名字>/agent.ts` 写一个文件(见 `plugins/claude/agent.ts`): ```ts export default { command: (run) => [...], // 这一轮的命令行,run.resume 是要接着的会话 read: (output) => ({ text, session, usage, finished, started }), // 从输出里读出最后一条消息和会话 link: (session) => "…", // 可选:打开这个会话的链接 }; ``` `started` 表示 Agent 已经收下这一轮的提示词,无论后来跑完、出错还是进程没了都要带上。带了它,这一轮再出错就算没跑成,留在原会话由中台重派;不带,接着旧会话跑时的失败都当成接不上,开新会话把提示词再跑一遍。`finished` 表示这一轮真的结束了,过 30 秒还不退出就停掉它;后台任务还没跑完、Agent 还要回来接着干时不算。 专用插件的 `access` 三档,翻译成各自 CLI 的权限:`read` 只读,`edit` 只改工作目录(默认),`full` 完整权限、不审批。只读不等于读不到密钥,要隔离密钥请用系统账号或容器。`command` 只传命令参数,不替任意程序限制权限。 浏览器由 Agent 的运行环境提供。Codex 使用后台 `codex exec`,不能假定桌面应用的浏览器连接自动可用;使用该轮实际可用的浏览器工具,或规则、Skill 明确配置的入口。入口只处理网页步骤并接续自己的会话,业务和回执仍由主任务负责。中台不附带浏览器桥接,不安装或授权浏览器工具;接入验收见[插件说明](plugins.md#浏览器接入)。 ## 通道:把卡片送到你面前 通道是一个往外连的小程序。 ``` GET /v1/cards/next?wait=50 Authorization: Bearer <通道的密钥> → 200 { "card": null | Card } // Card:这张卡片现在的样子。内容创建后不变,只有状态会变 { "id": 56, "version": 2, "ref": null, "kind": "ask", "state": "open", "title": "…", "text": "…", "options": ["…", "…"], "recommend": 0, "choice": null, "answer": null, "by": null, "result": null, "link": null } ``` - `ref` 是空的就发一条新的;不是空的就把那条改成现在的样子,改不了就直接报告。 - 发完报告:`POST /v1/cards/56/delivered { "version": 2, "ref": "om_xxx" }`。没报告的过一分钟会再给你。**支持幂等键的平台,发新消息只用卡片编号,不带版本**(飞书改不动已经结束的卡片时另发一条,这一条才带版本)。平台不支持时无法保证去重,Webhook 模板可能在报告丢失后重发。 - 发不出去:`POST /v1/cards/56/failed { "version": 2, "error": "…" }`。第一次发送隔得越来越久地重试,一小时后放弃(卡片还开着,`agent-autoloop status` 看得到);通道一直没来领不算在内。 - 还没送达就已经解决的卡片不再打扰你;仅告知的 `fyi` 通知在创建后的 24 小时内仍可投递。 - 你点了:`POST /v1/cards/56/answer { "choice": 1, "by": "ou_xxx", "ref": "om_xxx" }`。只有第一次算数,`by` 要在这个通道的 `answerers` 里,`ref` 要和送达的那条一致。普通通知有一个“已处理”按钮,点了就结案;也可以带 `text` 写一句处理说明,结案时存进卡片的 `answer`,卡面和 `/v1/state` 都看得到。仅告知的 `fyi` 通知没有按钮。 最小的通道只要十几行:领卡片、发文字和 `link`、报告。`link` 只给配了 `"links": true` 的通道,其他通道这里是 `null`。它打开一个不跑脚本的普通表单页,这张卡片的文字和按钮都在上面,谁拿到链接谁就能答,从卡片创建起 7 天内有效,过期后也不再显示卡片内容,适合只会发文字的群机器人、邮件。能发按钮、能收回调的通道(比如飞书)用按钮,身份由平台核验,更安全。一台电脑上用时 `link` 是本机地址,手机打不开,要手机批请把中台放到有公网地址的机器上,并配好 `publicUrl`。 ## 本人:看全局、回答、结案、停止 ``` GET /v1/state Authorization: Bearer <本人的密钥> → 200 { "seq", "totals", "cards", "recentCards", "agents", "channels", "sources", "lines", "elsewhere", "runs", "nextBefore", "hasMore", "unrouted", "spending", "timers", "now", "timezone" } POST /v1/cards/56/answer { "choice": 1 } 或 { "text": "别的想法" } POST /v1/cards/56/resolve { "reason": "不用决定了,因为……" } POST /v1/runs/12/cancel {} ``` `agent-autoloop status`、`answer`、`resolve`、`stop` 就是这几个请求。 整份状态在同一个快照里读出,`seq` 是那时的日志序号:自己做看板的,先取状态,再从 `GET /v1/stream?after=` 接着读,不漏也不重。`cards`、`recentCards` 在这里多带两项:`lane` 是卡片所在的事项(写了 `parallel` 的规则是事件的对象,别的规则是空字符串),`about` 是提问时留给接着干的那一轮的说明。通道和卡片页拿不到这两项。旧客户端忽略这些字段即可。 ### 暂停、恢复、立即运行 ``` POST /v1/rules/巡检/pause { "reason": "上游在修", "until": "2026-11-07T09:00:00+08:00" } 两项都可以不写 → 200 { "changed": true, "rule": "巡检", "paused": { "by": "owner", "reason": "上游在修", "since": "…", "until": "…" } } POST /v1/rules/巡检/resume {} → 200 { "changed": true, "rule": "巡检" } POST /v1/rules/巡检/run { "subject": "#12", "reason": "看一眼" } 两项都可以不写 → 200 { "event": 88, "run": 41 } ``` 只接受本人的密钥。规则名按 URL 编码放进路径,请求体可以为空;不认识的字段返回 400。没有这条规则返回 404 `没有这条规则:<名字>`;`elsewhere` 里的任务返回 400,因为中台不启动它。 暂停和恢复是日志里的事实(种类 `pauseRule`、`resumeRule`),不改 `rules.json`;重启、从日志重建投影后照样有效。`by` 是所用本人密钥的名字。重复暂停只更新原因和 `until`,`since` 保留第一次的时间;原因和 `until` 都没变时 `changed` 为 false,也不追加事实。`until` 要带时区、晚于现在;到了这个时间中台自动恢复,记一条 `tick` 事实。 规则暂停中被移出 `rules.json` 也照样能恢复。 暂停期间: - **定时规则**:到点直接跳过,不记事件,不积压。`lines[].skippedAt` 是最近一次跳过的时刻。恢复(手动或到了 `until`)后不补跑暂停期间错过的时刻,从恢复的那一刻起的下一个时刻照常跑;中台在暂停期间睡着了也一样。 - **事件规则**:事件照常记下,但这条规则不接、不派活;恢复后也不补接。事件的 `paused` 记着因为暂停没接它的规则,`/v1/events` 的应答多一个 `paused` 列表,`routed` 只列真正接了的规则。最近 20 条这样的事件在 `/v1/state` 的 `skipped` 里。这类事件不算“没有规则接的事件”。同一事件匹配的其他规则照常接。不补接是因为暂停常常是因为下游坏了或者要止损,事后一次性涌进来的活比漏掉的更难收拾;需要时用立即运行补一轮。 - **已经排队的、等重试的、暂停期间收到回答或到期复查的这一轮**:留在队列里不派出去,恢复后照常领取,不丢回答。已经在跑的这一轮不受影响,要停用 `stop`。 立即运行走正常派活:记一条 `run.manual` 事件(定时规则的来源是 `schedule`,事件规则用它的 `when.source`),`summary` 是“<密钥名> 手动运行”,`detail` 是 `reason`;`subject` 决定 `parallel` 规则的事项。同一事项已经有排队的一轮时并进那一轮,并取消它的重试等待。这一轮的 `runs[].manual` 为 true。**暂停中的规则不能立即运行**,返回 409 `规则「…」暂停中,先恢复再手动运行`:暂停的意思是这条规则一件活都不派,手动运行也不例外,这样“暂停了就不会再花钱、不会再对外动作”这一条没有例外。要试跑就先恢复再运行。 `/v1/state` 里每条规则(`lines[]`)多了 `paused`(生效中的暂停:`by`、`reason`、`since`、`until`,没暂停是 `null`)和 `skippedAt`;顶层多了 `pauses`(所有生效中的暂停,每项带规则名 `name`)和 `skipped`。旧客户端忽略这些字段即可。 命令行:`agent-autoloop pause <规则> [--reason 原因] [--until 时间]`、`resume <规则>`、`run <规则> [--subject 对象] [--reason 说明]`;`status` 列出暂停中的规则。 `agent-autoloop check [文件]` 只读文件、校验规则,不打开中台数据库,也不把文件载入中台;不写文件名就查 `rules.json`。可以先校验一份草稿再换上去。`/v1/state` 一次给最近 60 轮,`?before=` 翻更早的。 `/v1/state` 的 `spending.today`、`spending.week` 独立于分页,按规则时区的今天和过去 7 × 24 小时的完成时间汇总。每项含 `rounds`、`tokens`、`cost`、`missingTokens`、`missingCost`,后两项是没报对应用量的轮数;从未开始就取消的轮次不计入。 首页还会返回尚未到期的 `timers`,每项包含 `rule`、`lane`、`key`、`about` 和 `at`;翻历史页时该字段为空。本人客户端可据此区分正常等待复查与已经没有后续动作的事项。 安装与卸载不改变 HTTP 协议。`agent-autoloop --version` 显示版本与提交;`agent-autoloop uninstall` 确认后移除自身程序、链接和服务,始终保留规则、密钥和记录;`--yes` 只跳过确认。 ## 失败时会怎样 | 出了什么事 | 会怎样 | | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 来源把同一条事件发了两遍 | 只记一次 | | 领活的响应丢了 | 同一个 `claim` 拿回同一份活 | | 交结果的响应丢了 | 重放得到同样的确认 | | Agent 没跑成 | 5 分钟后重派,每次翻倍,最长 1 小时;期间来的同一事项的事件并进这一轮;连续第 3 次告诉你;连续第 10 次不再重试,这一轮记为 `cancelled` 并发一张通知卡片,正在跑时新来的事件另算一轮;这一轮结束或被停掉时,说它会再试的卡片一起关掉 | | 需要本人恢复浏览器连接、登录或验证 | 本轮结束,保留已完成业务和待验收项,用问题卡片等回答,不自动重派 | | 原会话接不上 | 开新会话,靠资料文件接着干 | | 接上了原会话,干到一半断了 | 算没跑成,留在原会话按上面的节奏重派,不开新会话把业务重做一遍 | | 回执写错 | 只重写回执,不重做业务;没有会话的 Agent 回执作废,推一张卡片 | | Agent 插件读不懂自己的输出 | 还在跑的接着跑;结束或进程没了后算没跑成,留在原会话重派 | | 接单程序或通道停了,或插件有没接住的错误 | 记日志,停了的 30 秒后重启;中台和其他部分照常 | | 别的进程占着数据库太久 | 这次到期先不做,5 秒后再试,中台不退出;这期间写坏的规则文件也会告诉你 | | 日志写不出去(输出管道断了、磁盘满) | 进程退出,由系统服务拉起 | | 接单程序重启 | 在跑的 Agent 不受影响,回来接着盯 | | 接单程序在线,但 5 分钟没报手上的某一轮 | 标成“不确定”,放开这一组,告诉你;它后来交的结果照样收 | | 接单程序整台不见了 | 活留着不放开,10 分钟后告诉你一次;你关掉卡片后这些活不再提醒,有新的活才再说。回来后卡片自动收尾,下次再不见再告诉你 | | 中台重启 | 全在 SQLite 里,大家自动重连 | | 定时错过了 | 醒来只补最近一次 | | 你点了两次,或者两边同时点 | 只算第一次 | | 提问之后问题自己好了 | 后面的 Agent 核实后在 `resolve` 里结掉,卡片自动改成“不用决定了” | | 规则写错了 | 旧规则继续跑,告诉你错在哪 | | 规则删掉或改名了 | 还没领走的和之后没跑成的这一轮记为 `cancelled`,不再派发,发一张通知卡片;已经在跑的跑完照常收结果 | | 规则暂停了 | 到点跳过、事件不接,都不积压;排队的留着;恢复后不补跑,从下一个时刻照常跑 | | 暂停写了 `until`,中台到点时没开着 | 醒来就恢复,暂停期间的时刻不补 | | 规则文件被删、移走或读不到 | 旧规则继续跑,告诉你一次;放回去就接着用。从没载入过规则的新中台读不到就不启动 | 这是“至少一次”的系统:Agent 可能被叫两次,所以 Skill 里有副作用的动作要先核实当前状态。 ## 谁能做什么 | 凭据 | 能做 | 不能做 | | ---------------- | ----------------------------------------------------------- | -------------------------------------- | | 来源的密钥 | 以自己的名义发事件、发消息 | 冒充别的来源,领活 | | 接单程序的密钥 | 领交给它的活;以交给它的 `elsewhere` 任务的名义发消息、提问 | 碰别人的活,发事件 | | 一轮的 token | 报告这一轮的这一次执行 | 碰别的轮次 | | 通道的密钥 | 领卡片、报告、转达 `answerers` 的回答 | 其他一切 | | 带权限的卡片链接 | 回答这一张卡片 | 其他一切 | | 本人的密钥 | 看全部、回答、结案、停止,暂停、恢复、立即运行规则 | 发事件,领活,改规则(规则只在文件里) | 普通访问密钥只保存 SHA-256;本机主密钥另存 `master.key`。接单程序不把中台密钥放进 Agent 的提示词或资料文件,文件系统的访问范围由 Agent 权限决定。 ## 日志与确定性 每次被接受的输入产生一批事实,什么都没改变的(重复的事件、距上次不到一分钟的心跳)不产生;`commit` 在 SQLite WAL + FULL 的同一个事务里完成判断、追加日志并让 `evolve` 更新视图,提交完成才应答。规则版本、密钥哈希、领活、回执和回答都在日志里;日志从不存轮次 token、卡片链接 token 或中台主密钥。主密钥由外壳保存在本机文件,轮次 token 和卡片链接用 HMAC 分别推导。重复 claim 重新签发 token,上一份立即失效。 `decide(view, input, now, environment)` 不改传入视图和输入。`evolve(view, facts)` 是唯一的持久投影写入者;时钟、规则文件、随机材料、网络和单一定时器由外壳提供。事实包含版本、序号、时间、种类和规范化记录变更,重放不重新运行规则。视图通过 SQLite 索引按需读取;一次判断只保留本次改动,不复制整表,不在 JavaScript 中常驻全部历史。规则这一行最大、每轮排队都要问,所以一个视图只解析一次;有了新事实就是新视图,不会读到旧规则。日志、投影、日志游标、编号高水位及派生索引一起提交;删掉记录也不复用其编号。正常启动直接打开投影。外壳按决定存储内容的文件(`kernel/evolve.ts`、`kernel/credentials.ts`)和主密钥算格式指纹,别的文件改动不算,查询视图每次启动重新建;旧版没有游标或指纹不匹配时,在同一事务内逐条读取日志、重建投影和索引,不能覆盖期间的新提交,不一次把日志读进内存。首次升级可能需要等待重建;不改旧事实,不需要数据迁移。模拟使用同一套投影和判断实现,数据库放在内存中。 Node 请求转换层只把 Node 的请求变成 Request;文件、SQLite 连接、监听和定时器也由外壳提供。 `GET /v1/stream?after=42` 仅接受本人的密钥,返回 SSE:`id` 是日志序号,`event: fact`,`data` 是事实 JSON。断线用 `Last-Event-ID` 或 `after` 从下一条继续;从 0 读全量,按订阅者读取速度逐条发送,不预先缓存全部日志。不给订阅者发送密钥哈希或能力凭据。 `GET /v1/runs/42/why` 仅接受本人的密钥,返回与本轮事件、每次执行的规则版本、领取、回执、卡片回答有关的事实。命令行 `agent-autoloop why 42` 使用同一接口,时间按本机时区显示为 `MM-DD HH:mm:ss`,默认显示事件、规则版本、领取者、起止时间、会话、回执、回答和接续轮次的时间线,不打印规则正文;`agent-autoloop why 42 --json` 输出原始事实。内部回答和定时事件遇到已存在的编号时按序加后缀,保持来源与编号唯一,不覆盖来源的事件。 ## 一致性和复现 插件入口统一为 `plugins/<名字>/agent.ts`、`channel.ts` 或 `source.ts`,先找安装目录,再找引擎内置目录。`agent-autoloop add ` 将插件仓库克隆到安装目录的 `plugins/<仓库名>`,仓库根目录直接放入口文件,不自动更新,不执行安装脚本。目录名就是插件名:仓库名转成小写,只能用小写字母、数字和 `-`,不符合的会被拒绝。插件是本人安装的可信代码,事件不能指定插件路径。 事件接受可选的 `hops`,非负安全整数,缺省为 0。回执事件强制使用本轮所有事件最大 hops 加 1;提问的回答和 later 定时延续原深度。超过 8 的事件仍记录但不派发,并产生通知;重复提交不重复通知。这是中台内处理链的限制,外部来源直接发出的新事件可以从 0 开始。旧日志没有 hops 时按 0 读取。 `DELETE /v1/keys//<名字>` 只接受本人密钥;名字按 URL 编码传入,返回 `{ok:true, changed}`:要删的密钥本来就不存在时 `changed` 为 false,也不追加事实。`agent-autoloop key remove <种类> <名字>` 调用正在运行的中台,删除立即唤醒并拒绝该密钥的长轮询,关闭日志订阅。已领取的一轮继续用独立的轮次 token 交结果。 `protocol/schema.json` 使用 JSON Schema 2020-12,定义事件、回执、任务、卡片和日志的格式。`agent-autoloop conformance <测试地址> --config <文件>` 走真实 HTTP;文件包含 `keys`(source、worker、channel、owner 四把测试密钥)、能匹配测试规则的 `eventType` 和通道允许的 `answerer`。规则要能把该来源和类型交给该接单程序,`parallel` 至少为 2,通道要限制回答人。检查会写入事件和卡片,只在专用测试中台运行;同一个测试中台可以连续检查。各来源的签名格式由对应插件的测试检查。 文字上限按 Unicode 码点计算:卡片回答的 `text` 最多 4000 字、`by` 最多 500 字、`ref` 最多 500 字,通道回报的消息编号最多 500 字。`usage` 必须是数值非负且有限的对象,不能是数组;`later.at` 必须是真实日期的 ISO 日期时间,带 `Z` 或时区偏移。回执先核验整体,再执行。 `npm test` 包含种子 24301 的 200000 步模拟,以及两台接单程序、两条来源事件、一张问题卡片的全部 8! 种动作顺序。模拟每 128 步换一个独立场景,每个场景结束前停止故障并把工作处理完;每次状态转换都检查不变量和完整日志重放。定时事件由真实到期表触发,恢复后检查到期任务没有遗留。它证明这些有限场景,不代表任意大小状态空间已被穷尽。单独复现:`node sim/random.ts 24301 200000`、`node sim/enumerate.ts`。 `npm run test:nightly` 做 1000 次真进程 SIGKILL、`sim/mutate.ts` 里全部的内核变异和性能实测。崩溃用真 SQLite WAL + FULL,核对已确认序号、完整性、视图、重放和重复提交;它测进程退出,不模拟整机断电。漏过任何一处变异都算失败,名字直接输出,不能用语法错误算检出。CI 每天定时跑一次强杀和变异,性能实测的数字跟机器有关,只在本地跑。`node sim/crash.ts 1000 12648430` 和 `node sim/mutate.ts` 可分别复现。 模拟的正确性检查必须通过;模拟速度只是开发效率的观察指标。性能实测使用逐条 HTTP 写入,事务落盘后才应答。空闲 RSS 和千连接负载 RSS 分开测,实测数字见 README。大历史量(结果见[历史增长实测](#历史增长实测))用 `node scripts/history.ts 1000 10000 100000` 验证真实业务、HTTP 响应、慢速日志订阅、强杀重试和完整重建;每个场景含两轮任务、提问、投递、回答和定时复查,逐笔 WAL + FULL 提交。请求走真实 HTTP,每类操作和状态汇总都测 30 次取中位数;持续写入、空闲及请求负载内存分别报告,不手动触发 GC。 排队很深时的开销用 `BENCH_QUEUE=5000 node scripts/benchmark.ts --busy` 单独量:规则约 4.4 KB,1000 个长轮询等着,排队 5000 轮(`BENCH_QUEUE` 可改),报告被挡下的心跳 p50 和 p99、每次心跳的 CPU、这段时间写了多少事实,以及收事件、领活、交结果和领不到活的中位数。`npm run benchmark` 也带这一项。 ### ACP 和 MCP `use: "acp"` 配合 `command: ["某个 ACP 程序", "启动参数"]` 使用 stdio ACP v1,ACP Agent 必须写 `command`,没写时这一轮算没跑成,原因里写着缺什么。桥接先初始化,再新建或接着旧会话:Agent 声明了 `loadSession` 就用 `session/load`,只声明 `sessionCapabilities.resume` 就用 `session/resume`。加载时重放的历史不算这一轮的回复,思考和工具输出不进回执。提示词发出去就算这一轮开始了,之后再断就留在原会话重派。接着旧会话时上一次可能已经干了一半,只有 Agent 明确拒绝这个会话(回了错误,或两种恢复都不支持)才开新会话;程序起不来、初始化出错、加载中途断了都留在原会话重派。会话正被别的程序打开(错误的 `data.reason` 是 `thread_active_writer`)也不算拒绝:这一轮算没跑成,原因是 Agent 自己的话,留在原会话过一会儿重派,连续第 3 次的卡片会带上这句话,告诉你要关掉什么。其他错误都算拒绝,不看错误码:官方 Codex 适配器对真不存在的会话回的是 -32603。只有 `end_turn` 且有最后一条文字消息才算完成:有 `messageId` 就按它分消息,没有时思考、工具调用和做完或失败的工具更新都算分界。一轮没完成时桥接马上交代原因,Agent 过 30 秒还不走就连同进程组一起停掉。消息只按换行分帧,读不懂的行算这一轮没跑成。会话编号交给中台保存。没有声明文件读写、终端或交互登录能力,Agent 用自己的工具;需要登录时先用它自己的命令行登录。 权限只选一次性的选项:`read` 允许结构化的读和搜索,`edit` 再允许工作目录内的编辑(核对符号链接,经过找不到目标的链接算在目录外),`full` 允许其他操作。权限请求只带工具调用编号时,按这次调用之前说过的种类和路径判断,请求自己带的字段优先。未提供相应选项就取消。权限依赖 Agent 遵守 ACP,不提供额外的系统隔离;规范允许 Agent 不询问就执行,这种操作 `access` 拦不住,要用 Agent 自己的模式限制。官方 Codex 适配器给的拒绝选项(`reject_once`)只有 `cancel`,选了它整轮以 `stopReason: cancelled` 结束,算没跑成,由中台重派;`read`、`edit` 档下要跑命令的请求就是这样回来的,所以 Agent 非跑命令不可时,给 `full` 或用它自己的模式。`model`、`effort` 不生效。核对依据:[初始化](https://agentclientprotocol.com/protocol/v1/initialization)、[会话](https://agentclientprotocol.com/protocol/v1/session-setup)、[工具权限](https://agentclientprotocol.com/protocol/v1/tool-calls)。 `agent-autoloop mcp` 提供 stdio MCP,固定支持 [2025-11-25](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle) 版,不支持 2026-07-28 的无握手协议。工具为 `status`、`why`、`answer`、`resolve`、`stop`、`emit`、`tell`;按本机已有的本人或来源密钥展示工具。它调用同样的 HTTP 接口,不直接改数据库;密钥留在本机 `config.json`。MCP 客户端把程序设为 `node`,参数设为引擎的绝对路径 `bin/agent-autoloop.ts` 和 `mcp`,环境里指定 `AGENT_AUTOLOOP_HOME`。标准输出只写 JSON-RPC。消息只按换行分帧;每个工具调用各自回复,慢调用不挡 `ping`。它不区分调用方,都按本人记录(`answer` 记成本人,`emit` 不带层数),不要配进接单程序启动的 CLI 的用户级配置,否则一轮能自己回答自己的卡片。 ### 通道模板与命令行示例 Webhook 通道的 `config.json` 配置使用 `use: "webhook"`、`url` 和 `platform`,后者可选 `dingtalk`、`wecom`、`slack`、`discord`。不写 platform 时可用 `body` 自定义模板,字符串里的 `{text}` 替换为卡片内容。四个模板对照官方文档:[钉钉](https://open.dingtalk.com/document/orgapp/custom-bot-send-message-type)、[企微](https://developer.work.weixin.qq.com/document/path/91770)、[Slack](https://docs.slack.dev/messaging/sending-messages-using-incoming-webhooks/)、[Discord](https://discord.com/developers/docs/resources/webhook#execute-webhook)。企微在标题后显示创建卡片的规则名,把普通网址显示为“打开链接”,把无选项通知的结案地址显示为“点击处理”,不再另列“1. 已处理”;卡片里写的链接标记和尖括号换成全角照字显示,不会被画成链接、标签或提及。钉钉和企微检查响应的 `errcode`,Slack 检查 `ok`;Discord 禁止文字触发提及。超过企微 2048 字节或 Discord 2000 字的卡片会报告失败,不截断问题或回答链接。Webhook 本身没有幂等保证,消息发送成功但报告丢失时可能重发。 桌面通知 `desktop` 是新安装默认打开的通道:macOS 用 `osascript`,Linux 用 `notify-send`,Windows 用 PowerShell 的系统托盘通知。通知标题是“Agent 问你一个问题”,正文是卡片标题和“运行 agent-autoloop status 查看”;Unix 的文字通过 `--` 后的参数传入;Windows 把 JSON 数据编码后交给固定脚本解码,不把文字当 PowerShell 执行。没有图形会话时不调用通知程序,报告 `skipped-<卡片编号>`,不反复打扰;卡片仍可通过命令行查看和回答。有图形会话但通知命令不存在或失败时报告 `failed`,由中台安排重试。卡片更新只报告,不重复弹出。 `examples/worker.sh` 和 `examples/channel.sh` 只用 shell 内置命令、curl、jq。设置 `AGENT_AUTOLOOP_URL`、`AGENT_AUTOLOOP_KEY` 后运行;`AGENT_AUTOLOOP_ONCE=1` 只领一次。接单示例交回 `AGENT_AUTOLOOP_RECEIPT` 中的 JSON,默认交回完成;通道示例把卡片输出到终端,再报告送达。它们是短任务示例,不适合长任务;长任务使用 `worker/` 的心跳、停止和恢复。`tests/examples.test.ts` 在通过协议一致性检查的临时中台上实际运行两个脚本,覆盖提问、送达、回答、继续和错误密钥。 ## 业务场景模板 `examples/recipes/` 提供七套场景。反馈处理、网站巡检和飞书周报与业务摘要默认使用飞书本人私聊与 `Asia/Shanghai`;都是规则、通道和 `command` 的组合。 反馈来源只负责唤醒同一事项,当前业务状态和授权从可信接口重新读取。网站检查以本规则未关闭的异常通知卡片作为故障记录:首次失败发通知及排查事件,持续失败安静,恢复结案并发恢复事件;手动关闭未恢复卡片后仍失败会重新报告。飞书采集完整读取后写私有快照并发事件,摘要核对快照编号,旧事件不使用新资料;采集与摘要同组串行。只有本人身份可读的数据参与汇总,不把不可见当成未提交。 浏览器交接模板是主任务对已安装入口的参数调用,不带浏览器驱动。可信配置固定命令、目标与允许操作,续办使用入口实际返回的 Codex 会话编号;网页内容和外部事件不能改这些范围。连接、登录或验证受阻时由主任务 `ask` 并结束本轮,回答后只继续剩余网页步骤,浏览器助手不再次委派或独立结案。 巡检和飞书摘要的脚本使用本地假 HTTP 和假飞书 CLI 验证,不调用真实业务数据、不发真消息。公开样例不含内部地址、账号或业务记录;用户替换后的资料不应提交到仓库。 ## 结构与实测 主机外壳支持 Windows、macOS、Linux,内核不区分操作系统。Windows 安装用 `install.ps1`,常驻用当前用户登录后的计划任务,由 wscript 隐藏启动,退出 10 秒后重启;每轮由独立 Node 监督进程保存输出和退出码,PowerShell 建立 Job Object 管住后代,接单程序重启后读原有记录继续核对。Windows 数据目录用 ACL;Unix 保留权限模式、登录 shell、进程组和 launchd/systemd。 一条只追加的事实日志是业务状态的来源;视图可重建,重放不重新执行规则。cron、later、重试、租约和离线判断共用到期表,外壳只安排下一次到期的定时器。只有改了东西的输入和到期叫醒的那一次才重算它:什么都没改变的输入(比如被挡下的心跳)不读排队,也不写事实;只随时间变化的部分都有自己的到期时间,定时器到了自然重算。接口是 HTTP + JSON;专用 CLI、MCP、来源、接单程序和通道都使用同一组接口。 | 目录 | 职责 | | -------------------------------------------------------- | ----------------------------------------------------------------------- | | `kernel/` | 确定性决策、演进、日志、到期、规则匹配、请求处理、卡片表单 | | `host/` | Node HTTP、文件与 SQLite 连接、时钟、配置、状态汇总、后台服务和插件加载 | | `protocol/` | 类型、JSON Schema、输入校验、HTTP 客户端与一致性检查 | | `worker/` | 接单、Agent 进程、心跳与停止、回执读取和提交 | | `cli/`、`bin/` | 用户命令、MCP 与命令行启动入口 | | `plugins/` | Agent、事件、通道和交付插件,统一发现入口 | | `examples/recipes/` | 完整场景的规则、任务 Skill 与启用说明 | | `examples/plugins/`、`examples/skills/`、`examples/*.sh` | 最小插件、独立任务手册和 curl/jq 示例 | | `skills/agent-autoloop/` | 中台的正式配置与操作手册,供用户和 Agent 使用 | | `docs/` | 通信协议、插件开发说明与架构图 | | `tests/` | 日常行为测试及测试辅助代码 | | `sim/` | 固定种子模拟、有界穷举、强杀与变异 | | `scripts/`、`.github/workflows/` | 性能与真机验证、安装冒烟和跨平台 CI | 现成场景与内置插件的入口见 [README](../README.md#拿来即用的场景模板),开发模板见[写插件](plugins.md)。插件文件名仍使用协议中的 `source.ts`,对应面向用户的“事件插件”。 `npm run benchmark` 打印冷启动、空闲和千连接内存、逐条落盘写入、派活 p99、排队很深时被挡下的心跳 p99 和模拟吞吐,不设达标线。数字在 README,是 Apple M5 Pro 上的本地实测,不代表服务器容量;不通过改变采样时间或主动触发 GC 降低读数。日志只追加,不压缩,磁盘占用仍随历史增长。常驻内存不保存全部历史,待处理工作及返回结果仍会占用内存;统计总量仍需遍历数据库索引。没有总览页、工作流画布、Agent 记忆、模型路由、多租户或插件市场。 ### 历史增长实测 Apple M5 Pro、24 GB、Node 24.21.0,用 `node scripts/history.ts 1000 10000 100000` 复现: | 已完成任务轮数(约) | 事实条数(约) | 正常启动 | 空闲 RSS | 领活 HTTP 中位数 | 状态汇总 | | -------------------- | -------------: | -------: | -------: | ---------------: | -------: | | 2,000 | 11,000 | 84.7 ms | 59.1 MB | 0.89 ms | 11.2 ms | | 20,000 | 110,000 | 83.2 ms | 58.9 MB | 0.84 ms | 83.6 ms | | 200,000 | 1,100,000 | 99.8 ms | 59.1 MB | 0.82 ms | 927.1 ms | - 110 万条事实时,收事件、去重、交结果、心跳、空闲通道轮询和 `why` 的请求中位数为 0.39–0.69 ms;进程被强杀后正常恢复约 101 ms。 - 内存:持续写入历史时最高约 282 MB;业务请求加一个慢速日志订阅约 75 MB。 - 会随历史变大的:状态汇总耗时,以及日志和索引占的磁盘。 - 格式变化后首次启动要从日志全量重建:110 万条用时 43.1 秒,峰值约 141 MB,重建前后数据一致。平时重启不重放。 - 20 万步模拟(观察项):93.8 秒,2131 步/秒。 ## 交付插件 交付是 `plugins/delivery` 的普通 Agent 进程,内核不认识它。项目仍用 `ops/delivery.json`,标签仍是 `deliver`、`deliver-attended`、`deliver-review`、`ask-owner`,提交状态仍为 `delivery`。审查状态默认 `agent-review`,由 `review.status` 修改;命令由 `review.command` 修改。设置格式、规则与 Ship 示例见[配置手册](../skills/agent-autoloop/SKILL.md#交付流水线)。 来源只给事件,仓库与执行命令由中台规则固定。内置 `poll.ts` 读取待处理标签,以“仓库、事项、标签记录、当前提交”生成稳定事件编号,不另存游标;某个事项读不到标签记录时这一段记 0,不影响其他事项。无标签(已合并的也一样)、关闭且未合并、fork 或非 main 目标不启动交付。每个 PR 一个事项,并发由规则 `parallel` 和接单程序容量控制;进程失败使用中台的 5 分钟起重试和连续失败通知,不另建队列、槽位、失败计数或通知服务。 | 步骤 | 必须满足 | | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 读取设置 | 用这一轮审查所对的 main 的固定提交读项目设置和审查要点,不读 PR 里的版本;main 前进后重来一轮就重新读 | | 同步 | PR 已包含 main;缺少时请求 GitHub 同步,在本轮内等到同步后的提交出现再继续,15 分钟内没出现才结束这一轮,由中台重试 | | 审查 | 裸 Git 对象转文字差异;只审分支自己的提交,排除干净合入 main 的变化,保留冲突解决;普通内部文档可略过模型;分支里有三个及以上父提交的章鱼合并时,审查看不到它带来的改动,直接报问题,要求改成普通合并 | | 审查进程 | 不检出 PR,不执行其中的代码、测试、设置或 hooks;默认 Codex 忽略用户配置和记忆,只读独立快照 | | 完整检查 | 草稿先转 ready,转之前记下最新一次检查,只接受比它新的(工作流触发类型要带 `ready_for_review`);只接受当前提交最新一次 pull_request 工作流和指定 job 都完成且成功,草稿或 skipped job 不算;矩阵 job(名字像 `check (ubuntu-latest)`)每一项都要成功 | | 合并 | 再读 PR head、目标分支、main 和标签;head 变化或目标分支不再是 main 就结束这一轮,由中台重试;main 前进就在本轮回到同步,最多 3 轮;检查通过后才写可配置的审查状态,按已审查 SHA 合并 | | 自动合并与响应丢失 | 先关闭已有自动合并并读回确认;合并响应丢失先看是否已经合并,不盲目再提交;清标签失败也读回确认是否已移除;已合并的 PR 最后去掉 `deliver`,没合并的最后去掉 `deliver-review`,只去掉一部分时重试能接着做完,也不会把只审查的 PR 合并 | | 发布 | 等一个覆盖合并提交的 Ship 成功,再核对健康版本;健康 HTTP 200 不能覆盖 Ship 失败或尚未完成 | | 后续发布 | 新版覆盖且确实上线可以完成旧交付;已经完成的交付不因后来的失败发布倒退 | `deliver-review` 审查通过就结束并移除交付标签,不代表已检查、合并或上线;在等待检查期间加上这个标签,合并前也会停在只审查,不写允许合并的审查状态。审查发现问题时 `delivery` 和审查状态都写 failure,交回问题;通知卡片按提交分开,上一张没关也不会吞掉新提交的问题;`deliver-attended` 不额外产生通知卡片。审查状态是 failure 的提交,标签还留着时再来事件也不再审查、不改状态,这一轮直接结束,要推新提交才会重审;这条记录看审查状态,因为失败的一轮会把错误写到 `delivery` 上。等待检查期间撤回 `deliver`,这一轮结束,`delivery` 写 success,说明里写明已撤回、未合并或发布。普通 issue 的 `ask-owner` 只将可读正文变成问题卡片,去掉代码块和本机路径,答复后移除标签。不向 GitHub 发评论,不建立 PR 说明里的多轮审查账本,也不支持用“已处理”文字豁免发现的问题;新提交重新审查。 带 `deliver` 标签的已合并 PR 直接继续跟踪 Ship 和健康检查,标签已去掉的不再跟踪、不写状态;审查期间被别人合并的也接着跟踪;跟踪失败时这一轮写“没跑成”,由中台照常重试。默认最多跟踪 30 分钟,超时交给中台重试;没有健康设置的项目明确报告只完成合并。发布范围对比**健康版本到目标提交的整个差异**:只有普通内部文档变化可跳过,可执行位、符号链接、重命名到外部路径、未知历史都要求发布;后来只有文档变化不能让尚未完成的代码发布被跳过。健康接口报短提交号时先向 GitHub 换成完整号,换不到就算要发布。 Ship 使用本引擎仓库的 `plugins/delivery/action.yml`,项目固定到发布后的完整提交。Action 只输出 `release` 和 `superseded`,项目已有发布步骤根据输出运行。它与交付进程共用 `project.ts` 的范围与健康判断,不在项目里长期复制工具。 普通健康 URL 只允许 HTTPS。测试时必须显式传 `--test-local-health`,Action 同样要 `test-local-health: 'true'`,才接受 `http://127.0.0.1`;不接受其他 HTTP 地址。GitHub 合并规则由项目维护者设置。 ## 版本与兼容 版本号只写在 tag 上(`v1.2.3`),不进任何文件。`agent-autoloop --version` 读当前提交上的 tag,不在发布的提交上就显示“未发布”。 | 这一版 | 版本号怎么变 | | -------------------------------------- | ------------------------- | | 老用法不改就跑不了 | 第一位加一:1.8.3 → 2.0.0 | | 这一周的第一版(北京时间周一零点起算) | 第二位加一:1.8.3 → 1.9.0 | | 同一周里之后的每一版,急修也算 | 第三位加一:1.8.3 → 1.8.4 | 从上往下看,先对上哪条就按哪条加。第二位、第三位只说明这是哪一周的第几版,新能力和修复都可能在里面;能不能放心升只看第一位:第一位没变,老用法就照样能跑。周一零点刚过发的急修,也算这一周的第一版。 老用法指这些: - `/v1` 接口的路径、字段含义和鉴权; - 插件约定(`plugins/<名字>/{agent,channel,source}.ts`); - `rules.json`; - 文档写了的命令和参数; - 交付约定(`ops/delivery.json`、四个标签、`delivery` 状态); - 旧版本留下的数据,新版本能接着用。 内部代码、文档没写的字段和提示文字不算。新增字段不算破坏,所以插件和客户端要忽略不认识的字段。要去掉一个东西,先在一版里标成废弃、写进发布说明,到下一个第一位版本才真删。 `/v1` 单独算:只有 HTTP 接口不兼容时才变,软件到 2.0 不等于协议到 `/v2`。发出去的版本不再改,有问题就发下一个号。 常规改动一天最多发一版;崩溃、丢数据、安全问题、装不上的修复随时发。只改文档、测试、CI 的不发。大改动可以先发试用版(`v1.2.0-rc.1`),安装和 `update` 不会装到它。