系统逻辑图 · 九层流程图
全中文 · 每个技术名词都解释 · 每个数字都有代码出处
整页缩放 100%

这套系统是怎么运转的

这份图要回答的是一件事:我在网页上敲一句话,到最后看到答案,中间经过了谁、谁做了什么决定、什么东西被存在哪里。看懂了,你提的下一个需求就知道该落在哪一层、会撞上哪条物理约束。

顺序是从「一句话的旅程」往外扩:先看单条消息怎么走通(第一层),再看为什么多件事能同时干、代价是什么(第二层),再看对话记忆到底住在哪(第三层),再看有哪些闸门在拦着不出事(第四层),最后如实交代哪些东西还只是设计稿、一行代码都没有(第五层)。

读法说明:每层一张流程图(第九层三张),一屏内看完;图下最多三行小注点出这张图的题眼;再往下是名词解释,每条讲三句话——是什么(物理上是个什么东西)、起什么作用不要它会怎样。图里只放短标签,说明句和代码出处一律不进方框,全部收在每层那个「展开…」折叠区里(出处形如 services/orchestrator/server.mjs:2714,冒号后面是行号,可自行核对)。凡拿不准的地方写成「待核」,没有编造。

图形约定(十一张图统一):矩形=步骤或组件;菱形=判断;带箭头的线=流向(绿=循环、=回流、=否决或拒绝);虚线框=分区或泳道;小圆=起止。全部是内嵌 SVG,零外链、断网也能看。

第二轮追加的三层(第六 / 七 / 八层)回答的是另一个问题:一件事怎么从「我想要个东西」变成一份可开工的合同,这份合同又怎么被派出去、跑完、验完、上线,以及每次派活之前要不要复用上一次的对话。前五层讲机器怎么跑,后三层讲人和 AI 之间的交接规则——两者一样是硬约束,撞上了同样会出事。

第九层回答的是最落地的一个问题:这些层反复提到的文件路径,在磁盘上到底是哪些、少了哪个就跑不起来;那几处都被叫做「记忆」的东西,又分别跟着什么走。它是前八层的实物对照——前面讲机制,这一层给出机制落在哪个文件、哪张表、哪几个字段上。

本稿不做无限画布、不做点击下钻。右上角可整页缩放(70%–160%);较宽的图可以在图里按住左右拖动。

后三层里有一个反复出现的区分,先在这里说明白,因为它决定了「这条规矩靠不靠得住」:硬闸=有代码在拦,你绕不过去;软闸=界面上拦一下,改一行请求就能绕过;只是文档=写在文档或角色卡里、进 AI 的提示词,但没有任何程序会检查,全靠自觉。本图凡涉及一条规矩,都标了它是哪一种——这一点比规矩本身更重要。

事实层部分核过每个数字、字段、行号都标了代码出处,并用工具从代码机械反推做过交叉校准(纠正了 9 处「文档说 X、代码实际是 Y」)。稿内仍有 3 处标「待核」徽章——那些是找不到出处、明确没有冒充事实的地方。 需求层不适用本稿不是功能需求稿。目的是把现有系统讲明白(用户原话:「我要知道逻辑,学习系统,才能让后面的需求合理」),所以没有「为什么存在 / 数据来源 / 不做会怎样 / 相邻边界」四要素场景表。 警示因此不要拿这份稿子当开发依据——它解释的是已有系统,不是要做的新东西。 设计层就是这一页9 层内容 + 11 张内嵌 SVG 流程图 + 83 条名词解释。单栏阅读稿,零缩放、无设备框;不是交互原型 验收层无层间对照矩阵需求层不适用,矩阵无从对照。待澄清 = 稿内那 3 处「待核」徽章(沙箱挡写 git 元数据无出处 / Status 三格式的第三种是哪种 / 非空 linked_run_id 为 0 未复测),不另立清单。
第一层

一句话从你手里到 AI 手里,再回到你眼前

四个角色接力:网页(你看到的界面)→ 调度台(在云服务器上排队记账)→ 常驻程序(在你自己 Mac 上,主动来领活)→ 命令行 AI(真正干活的那个)。关键点是:调度台从来不主动去找你的 Mac,永远是你的 Mac 每两秒来问一次「有活吗」

图 1 · 一句话的完整旅程(横向读;绿色=每 2 秒的轮询循环,蓝色=事件回流)
云服务器 你的 Mac 网页 一个 HTML 文件 台账 一个 JSON 文件 调度台 单进程排队记账 队列 排队中 常驻程序 一直开着的进程 命令行 AI claude / codex 发消息 建任务 交出去 起进程 循环:每 2 秒来问一次「有活吗」 每次改动整份重写 AI 每走一步 事件按顺序回传(失败重试 4 次) 存一份 + 实时推一份 你看着它一行行长出来

绿色那条循环箭头是这一层的题眼:调度台从不主动联系你的 Mac,永远是常驻程序每 2 秒自己回头问一次「有活吗」。

蓝色是回流,四跳原路返回:AI 每走一步 → 常驻程序 → 调度台(存一份 + 进程内实时推送)→ 网页 → 你看见。

两个虚线框是分区:左边整块在云服务器上,右边整块在你自己的 Mac 上——你的代码从不离开本机

展开这九步的逐条细节、四条挑选规则、命令行参数与代码出处(含心跳、占用扫描两条辅助通道)
图 1 · 一条消息的完整旅程(九步,从上往下读)
1
网页
你在浏览器里输入一句话并发送
整个前端是一个 HTML 文件,一万五千多行,托管在 Vercel 上。它把请求发给「同源」的 /api/…,由托管平台的转写规则替换成真正的后端地址——这样浏览器就不认为这是跨站请求,省掉一轮跨站预检。
apps/web/index.html(共 15297 行) apps/web/index.html:5311-5332 resolveApiBase apps/web/vercel.json rewrites /api/:path* → api.workbench.heyyys1.com
发一个网络请求 POST /sessions/<会话号>/messages,请求里带你这句话的原文
2
调度台
调度台先做三道检查,再落两条记录
  • 这条会话正忙吗:如果这条会话已经有一个任务在跑,直接回 409「会话正在被使用」,并把三个选项给你(等一等 / 排队 / 先停掉),什么都不写,不会留半条脏数据。
  • 引擎有没有被锁:见第三层,这里会强制把本轮用的 AI 拨回原来那个。
  • 检查通过后落两条记录:一条消息(你说的话),一条任务(run),任务的初始状态是 queued(排队中)。
services/orchestrator/server.mjs:4723 消息端点 services/orchestrator/server.mjs:4810-4827 会话忙碌闸 services/orchestrator/server.mjs:1740-1761 createRun,初始状态 queued
写在哪:不是数据库,是一个 JSON 文件。本地默认 workbench-data/store.json,线上是 /opt/personal-workbench/workbench-data/store.json。这个文件里一共 15 类东西(任务、事件、工人、产物、会话、消息、连接器状态、待排期需求、附件、工作区、记忆、编排状态、界面偏好、自主循环、任务卡)。
services/orchestrator/storage.mjs:29-66 磁盘结构 scripts/cc-deploy-api.mjs:72 线上路径
任务进了队列,调度台的活到这里就干完了——它不会去联系你的 Mac
3
你 Mac 上的常驻程序
每 2 秒来问一次「有活吗」——这叫领活
常驻程序是个一直开着的 Node 进程,死循环里每隔 2000 毫秒 发一次 POST /workers/claim。另有一条独立的心跳,每 15000 毫秒 报一次「我还活着 + 我这边两个 AI 的健康状况」。
workers/mac-worker/worker.mjs:33 轮询 2000ms workers/mac-worker/worker.mjs:34 心跳 15000ms workers/mac-worker/worker.mjs:1296-1321 主循环 services/orchestrator/server.mjs:3320 claim 端点
调度台按四条挑选规则决定给不给、给哪一个
4
调度台
挑一个任务交出去(四条规则,一个函数说完)
整套挑选逻辑只有一个函数 claimableRunsForWorker,「能领哪些活」和「该起几个工人」都从它派生,所以两者不可能对不上:
  • 状态得对queued(排队中)或 waiting_for_worker(等工人);waiting_for_rate_limit(等额度恢复)的必须已经到点了才算。没到点的、时间无效的,对「能领的活」和「该起几个工人」同时隐身——所以系统不需要为它专门跑一个定时器。
  • 最老的先走:按创建时间正序。
  • 同一条会话,只能有一个在跑:某会话已有任务处于 running,这条会话名下其它排队任务全部被隐藏。这是第二层「同会话串行」的物理闸门。
  • 能力得覆盖:这个任务需要的能力标签,工人必须每一条都声明过(例如 claude-code.executeproject:personal-workbench)。
命中之后:把任务改成 running、写上工人号、记开始时间、认领代号 +1。这几步之间没有任何等待,所以两个工人同时来抢也不可能抢到同一个(原因见第二层)。
services/orchestrator/storage.mjs:367-401 claimableRunsForWorker services/orchestrator/server.mjs:2714-2739 claimRun workers/mac-worker/worker.mjs:97-105 工人声明的能力标签
顺手做的一件事:每次有人来领活,调度台先跑一遍「回收」——凡是 running 状态但开始时间超过 300000 毫秒(5 分钟)、且它名下那个工人已经不在线的任务,一律打回「等工人」重新排队。工人还在线并且确实正拿着这个任务的,不动。
services/orchestrator/server.mjs:162 超时 300000ms services/orchestrator/server.mjs:2694-2712 reapStaleRunningRuns
交出去的不只是任务本体,还顺路挂了四样东西:角色卡、这个角色的记忆、编排状态、以及「去哪个工作副本里干」
5
你 Mac 上的常驻程序
决定「在哪个目录里干活」
四种落点,优先级从高到低:① 收编来的原生会话 → 就在用户自己那个目录里干;② 家目录型角色(如聊天位)→ 固定的 agents/<角色>/;③ 普通会话任务 → 为这条会话单独建一个旁路工作副本(第二层详述);④ 都不满足 → 主仓根目录。所有路径都必须落在白名单目录里,否则直接报错不干。
services/orchestrator/server.mjs:703-743 runDispatchContext workers/mac-worker/worker.mjs:1188-1211 目录决策 workers/mac-worker/worker.mjs:1243-1249 isAllowedCwd 白名单
在那个目录里起一个命令行进程
6
干活的命令行 AI
真正干活的是 claude 或 codex 命令行,参数是写死的
两套命令,都由一个纯函数拼出来(所以测试能逐字锁住它,防止有人偷偷加危险参数):
  • claudeclaude --print [--resume <原生会话号>] --output-format stream-json --verbose --permission-mode acceptEdits --add-dir <工作目录> -- <你的原话>。明文禁止三个参数:--dangerously-skip-permissions--continue--fork-session
  • codexcodex exec --json --sandbox <沙箱模式> -c approval_policy="…" --skip-git-repo-check --output-last-message <文件> -C <工作目录> <你的原话>--ephemeral 绝对禁止出现,因为它会跳过会话记录文件,事后就再也接不回去了。
两个命令都要求 AI 把过程逐行吐成 JSON,这样常驻程序才能一行一行读、一行一行往上报。
workers/mac-worker/runner-claude-code.mjs:45-60 buildClaudeArgs workers/mac-worker/runner-claude-code.mjs:28-33 禁用参数 workers/mac-worker/runner-codex.mjs:38-73 buildCodexArgs workers/mac-worker/runner-codex.mjs:34-37 禁 --ephemeral
AI 每吐一行,常驻程序就往上报一条事件
7
你 Mac 上的常驻程序
事件按顺序排队上报,失败重试四次
所有事件走同一个队列,严格保持发生顺序(不并发发),逐条 POST /runs/<任务号>/events。单条最多试 4 次,间隔 250 → 500 → 1000 毫秒翻倍等待。四次都失败就不再硬撑,把错误暴露出来让整个任务显性失败——不允许「悄悄少了几条事件却报成功」。
workers/mac-worker/worker.mjs:413-450 事件队列 + 重试
调度台收到事件,一边存进文件,一边立刻推给正在看的浏览器
8
调度台
两条出口:存下来 + 实时推送
事件既写进那个 JSON 文件(事后能翻),也在调度台进程内部广播给所有正在盯着这个任务的浏览器连接(GET /runs/<任务号>/stream)。浏览器连上时先拿一份快照(任务本体 + 已有事件 + 消息 + 产物),之后增量收;任务已经结束的话直接给快照然后关连接。
services/orchestrator/server.mjs:3134-3167 SSE 推送端点 services/orchestrator/server.mjs:218 进程内订阅者表 services/orchestrator/storage.mjs:584-589 事件落盘
AI 进程退出,常驻程序上报终态
9
你 Mac 上的常驻程序 → 调度台
终态上报,带一个「认领代号」防重放
上报 POST /workers/<工人号>/results,带最终答案、产物、以及本次认领代号。调度台在做任何一件事(存产物、绑原生会话、写消息、触发后续钩子)之前先验代号:状态必须还是 running、代号必须等于当前代号、且这个代号还没被别的上报用过。验不过就当审计记录收下(回 200),但不产生任何业务副作用。这样一个掉线又复活的旧工人,不可能把一个已经被别人接手的任务给「改回去」。
workers/mac-worker/worker.mjs:1001 / 1041 / 1114 三处上报 services/orchestrator/server.mjs:3394-3419 代号围栏
图 1b · 两条辅助通道(不在主链上,但一直在跑)

心跳(每 15 秒)

常驻程序上报:我的编号 / 名字 / 能力标签 / 当前在跑哪个任务 / 两个 AI 的健康状况 / 本机有哪些命令行会话正被人手动占用(每次全量替换,所以不会留下过期的占用记录)。

调度台在回复里搭车捎两样东西回去:① cancel_run_id——你在网页上点了「停止」,就靠这个字段让常驻程序去杀掉 AI 进程;② 待读取的对话需求清单(第三层)。

  • 在线判定:心跳在 45 秒内算在线
  • 心跳在 45 秒~5 分钟之间算「刚刚失联」
  • 超过 5 分钟算离线
workers/mac-worker/worker.mjs:302-326 heartbeat services/orchestrator/server.mjs:3264-3318 心跳端点 packages/contracts/index.mjs:59 在线窗口 45000ms packages/contracts/index.mjs:67 失联窗口 300000ms

占用扫描(每 5 秒)

常驻程序每 5000 毫秒 只读地扫一遍本机进程表,看有没有人正在终端里手动跑 claude/codex 的续聊。一旦和上次结果不同,立刻补发一次心跳而不是等满 15 秒——这样网页那边最多 11 秒就能知道「这条会话此刻被人在终端占着,别去动它」。

它会按进程号排除掉自己刚起的那个 AI 子进程,避免「自己干活把自己锁了」。

workers/mac-worker/worker.mjs:328-346 扫描 + 提前心跳 workers/mac-worker/cli-usage.mjs

第一层名词解释

15 条
网页前端apps/web/index.html
是什么
物理上就是一个 HTML 文件,一万五千多行,样式和脚本全写在里面,没有打包、没有外部依赖。放在 Vercel 上,浏览器一次下载完。
起什么作用
你能看到和点到的全部界面:会话列表、对话区、任务中心、系统页。
不要它会怎样
只能靠命令行操作系统。本项目明确规定网页是主界面,命令行只用于开发和排障。
Vercel(网页托管平台)
是什么
一家托管静态网页和函数的云服务。你把文件推上去,它给你一个全球可访问的网址。
起什么作用
放前端那个 HTML 文件,绑定域名 workbench.heyyys1.com;顺便做「同源转写」,把 /api/… 转到真正的后端。
不要它会怎样
得自己弄一台机器装网页服务器和证书。设计稿站 mockup.heyyys1.com 是另一个独立的 Vercel 项目,和生产站互不影响。
同源转写apps/web/vercel.json rewrites
是什么
托管平台上的一条规则:浏览器请求 /api/xxx,平台悄悄转发到 https://api.workbench.heyyys1.com/xxx。浏览器全程以为自己在跟同一个网站说话。
起什么作用
免掉浏览器的跨站安全预检(那是一次额外往返),也不用在后端配一堆跨站许可。
不要它会怎样
前端得直连后端域名,每个请求前多一次预检往返,还得维护跨站白名单。代码里保留了直连地址作为本地开发的回退。
调度台services/orchestrator/server.mjs(6135 行)
是什么
一个 Node 进程,跑在一台阿里云轻量应用服务器上(Ubuntu 24.04,泰国曼谷区),由系统服务管理器守着,监听 3001 端口。它是单进程的。
起什么作用
唯一的记账人和排队人:收消息、建任务、发任务、收事件、判身份、算视图。它自己不干 AI 的活,也不碰你的代码。
不要它会怎样
浏览器就得直连你的 Mac。但你的 Mac 在家用网络里、公网进不来(这一点在设计文档里核实过),所以必须有一台公网上的中间人。
台账workbench-data/store.json
是什么
字面意义上的一个 JSON 文本文件,不是数据库。里面 15 个大字典(任务、事件、工人、产物、会话、消息……)。每次改动都把整个文件重新写一遍:先写临时文件,再改名覆盖。
起什么作用
让整个系统重启后还记得事。所有写操作都收口在一个文件模块里,将来换成真数据库只改那一个文件。
不要它会怎样
重启即失忆。代价见第四层那个 92MB 的真实事故——「整份重写」这个选择在数据变大后会直接卡死生产。
会话session,编号形如 sess_ + 十六进制
是什么
台账里的一行,代表「一段持续的对话」。它下面挂着若干条消息和若干次派活。
起什么作用
它是这套系统的隔离单位:一条会话独占一个代码分支和一个旁路工作副本(第二层)。同时也是记忆的挂点(第三层)。
不要它会怎样
所有对话和所有改动会混在一起,两件不相干的事会互相覆盖对方改的文件。
任务(一次派活)run
是什么
台账里的一行,代表「让某个 AI 用某套参数在某个目录里跑一次」。有 8 种状态:排队中、等工人、等额度、进行中、等审批、完成、失败、已取消。
起什么作用
它是排队、领活、超时回收、事件归属、终态上报的共同主键。你在界面上看到的每张卡背后都是它。
不要它会怎样
没有东西可以排队和追踪,也无法把「哪些事件属于哪次执行」分清楚。
常驻程序(工人)workers/mac-worker/worker.mjs(1332 行)
是什么
跑在你自己 Mac 上的一个 Node 进程,一直开着。它是纯出站的——只主动往外发请求,不监听任何端口。
起什么作用
它是「云上的调度台」和「你本机的 AI 命令行、你的代码仓库、你的登录凭证」之间唯一的桥。凭证和源码从不离开你的 Mac
不要它会怎样
要让 AI 改你本机的代码,就得把代码和凭证上传到云端。这套系统的整个设计前提就是不这么做。
领活(拉取式派发)POST /workers/claim
是什么
不是调度台推给工人,而是工人每 2 秒问一次「有我能干的活吗」。有就带回一个任务,没有就回一个空响应(204)。
起什么作用
因为你的 Mac 在公网上根本连不到,只有它主动出去才行。副作用是好事:工人自己掉线不影响任何人,恢复后接着问就行。
不要它会怎样
调度台得能反向连到你的 Mac——那需要公网入口或穿透隧道,等于把你的电脑暴露出去。
能力标签capabilities
是什么
工人每次上报时带的一串字符串,例如 claude-code.executecodex.executeconnector.authlocal_fs.writeproject:personal-workbench
起什么作用
让调度台只把「这个工人干得了」的任务给它。任务要求的每一条标签都必须被声明过,缺一条就不给。
不要它会怎样
一个没装 codex 的工人会领到 codex 任务,当场失败;一个没接某个项目的工人会领到那个项目的活,找不到目录。
心跳POST /workers/heartbeat,每 15000ms
是什么
一个定时发的「我还活着」请求,附带工人的自我状态。
起什么作用
三件事:① 系统页面上显示「有几个工人在线」;② 超时回收判断「这个工人是真死了还是在忙」;③ 搭车传指令——调度台在回复里塞「该取消哪个任务」和「该去读哪条对话」。
不要它会怎样
分不清工人是慢还是死了。要么误杀正在跑的任务,要么让掉线工人手里的任务永远卡在「进行中」。
命令行 AI(干活的那个)runner:claude-code / codex-cli
是什么
你在终端里直接能敲的那两个命令:claudecodex。它们各自会读工作目录里的规则文件、调用模型、改文件、跑命令。
起什么作用
真正的执行者。系统只负责「起进程、给参数、读它吐的 JSON」,不重新实现任何 AI 能力。
不要它会怎样
就得自己接模型接口、自己实现工具调用和权限控制。项目的硬规定是不引入模型接口密钥,就走本机命令行。名册里还留了第三个 kimi-cli 的登记,代码里只有登记,没有适配。
事件event,存在 store.json 的 events[任务号] 数组里
是什么
一条一条的过程记录:任务已创建、已被领走、已开始、AI 说了什么、读了什么文件、出错了、已完成。
起什么作用
你在界面上看到的「过程时间线」就是它。也是事后复盘唯一的凭据。
不要它会怎样
只能看到「成功/失败」两个字,出问题无从下手。反过来,正是它没有上限造成了第四层那次生产卡死。
实时推送流SSE,GET /runs/<任务号>/stream
是什么
一条不关闭的 HTTP 连接,服务端有新内容就往里写一行,浏览器边收边渲染。为了防中间设备把闲置连接掐掉,服务端会定期发一个心跳包。
起什么作用
让你看到 AI 正在做什么,而不是干等一个最终结果。这个广播只存在于调度台进程内存里。
不要它会怎样
只能让浏览器不停轮询「有新事件了吗」,要么慢要么费流量。调度台重启会断连——但断了重连就能从快照恢复,不丢数据。
认领代号claim_generation
是什么
任务上的一个整数。每被领走一次就 +1。工人上报终态时必须带上自己那次的代号。
起什么作用
防「过期的上报」。一个任务被超时回收、又被另一个工人接手后,原来那个工人如果复活并上报,它的代号已经过期,调度台收下当审计但不执行任何业务动作
不要它会怎样
一个掉线复活的旧工人可以把已经被别人做完的任务改成失败,或者把产物重复写一遍。
承接
第一层讲的是一条消息。但真实用法里你会同时开好几件事——那就必须回答:凭什么它们不互相踩?答案不在代码风格里,在物理隔离:每条会话有自己的一份代码副本。这就是第二层。
第二层

为什么能并行,以及并行的代价

隔离单位是会话,不是任务、不是项目。每条会话独占一个代码分支 + 一份旁路工作副本。因此:同一条会话内部必然串行,不同会话之间才是真并行。代价有一条最坑人:工作副本是建的时候从主干切一刀,之后再也不会自动跟上主干

图 2 · 并行靠泳道,串行靠泳道内的纵向队列(三条会话=三条泳道)
主干 main 创建当天各切一份,之后不再跟随主干 会话 A 独立分支 session/A 旁路副本 -wt/A run 队列:一个接一个 run 1 run 2 run 3 会话 B 独立分支 session/B 旁路副本 -wt/B run 队列:一个接一个 run 1 run 2 会话 C 独立分支 session/C 旁路副本 -wt/C run 队列:一个接一个 run 1 run 2 run 3 逐文件合并回主干 代价:两条泳道改了同一个文件 后合的整份盖掉先合的,且没有冲突提示

三条并列虚线泳道=三条会话真并行:各自一个分支、一份旁路副本,改文件互相看不见,不需要任何锁。

泳道内部那串纵向箭头=同一条会话必然串行:领活层一看到这条会话已有一个在跑,就把它名下其余的全藏起来。

底部汇聚处的红框是并行的真实代价——合并不是 git merge,是按改动清单逐文件拷贝,撞同一个文件不会报冲突

展开工作副本怎么建(四步)、同会话的两道串行闸、监工只扩不缩,以及「并行安全靠单线程而非锁」那条不变量
图 2a · 旁路工作副本是怎么来的(第一次派活时建,之后一直复用)
1
调度台算
算出「该用哪个分支、从哪切」——只算名字,不碰磁盘
分支名固定是 session/<会话号>,起点默认是 main。调度台本机没有代码仓库,所以它只能传分支名字,不能传具体版本号;具体解析交给工人。
这个结果不写进任务行,只挂在「领活」的响应里搭车过去。
packages/contracts/index.mjs:260-263 sessionBranchName packages/contracts/index.mjs:268 起点默认 main packages/contracts/index.mjs:275-288 computeWorktreeRef
四种情况不建工作副本(直接返回空):① 连接器授权那类杂务任务;② 收编来的原生会话(要留在用户自己的目录里,挪走了续聊就接不上);③ 家目录型角色(挪走了 claude 按目录存的会话记录会碎掉);④ 没绑项目或没绑会话的老任务。也就是说不是每条任务都进工作副本
分支名和起点搭在领活响应里下发
2
你 Mac 上真正动磁盘
建目录:主仓的兄弟目录,不在仓库里面
路径规则:主仓叫 <父目录>/personal-workbench,副本就放在 <父目录>/personal-workbench-wt/<会话号>刻意放在仓库外面,这样主仓的 git status 永远是干净的,回收副本时也绝不会碰到主仓。
workers/mac-worker/worktree.mjs:27-29 worktreeRootFor workers/mac-worker/worktree.mjs:34-36 worktreePathFor
建之前先清理上次崩溃留下的残骸
3
你 Mac 上真正动磁盘
幂等地确保副本存在(能复用就复用,脏了就重建)
  • 先跑一次 git worktree prune,清掉上次崩溃留下的登记残骸。
  • 比对路径时用真实路径比(macOS 会把 /var 解析成 /private/var,直接比字符串会误判成不存在而白重建一次)。
  • 已存在且分支正确 → 复用,这条会话历次派活的改动就自然累积在一起。
  • 登记了但分支不对、或者只剩个孤儿目录 → 强制拆掉重建。
  • 分支已存在 → git worktree add <目录> <分支>;分支不存在 → git worktree add -b <分支> <目录> <起点>,起点用 mainmain 都没有才退回 HEAD
workers/mac-worker/worktree.mjs:129-172 ensureSessionWorktree workers/mac-worker/worktree.mjs:74-83 真实路径比对
⚠ 这里是最坑人的一条:「从起点切一刀」只在分支第一次创建时发生。之后每次派活走的都是「复用」分支,代码里没有任何 rebase / merge / pull。所以一条开了三天的会话,它的工作副本停留在三天前的主干上,主干后来的改动它完全看不见。
副本是空壳,装不起来——所以要补两样东西
4
你 Mac 上真正动磁盘
把「不进版本库的东西」软链进副本
新副本里没有依赖包、也没有密钥文件(这些都被版本库忽略了),AI 一进去就跑不起来。所以工人把主仓的 node_modules 和两个环境变量文件做成软链接指过去。软链是幂等的:已经对了就不动,坏了就换掉,源文件不存在就跳过(不报错)。
workers/mac-worker/worker.mjs:79-91 要软链哪几个 workers/mac-worker/worktree.mjs:107-123 linkSharedPaths
图 2b · 同一条会话 vs 两条会话:一个必然排队,一个真同时跑

同一条会话里的两次派活

必然串行 · 两道闸
  • 第一道(写入时):你在这条会话已经有任务在跑时又发一句话,调度台回 409「会话正在被使用」,并给三个选项——等一等(前端自己轮询)、排队(照样建任务,靠第二道闸串起来)、先停掉(先取消当前任务再建新的)。拒绝时什么都不写
  • 第二道(领活时):挑活函数会先算出「哪些会话已经有任务在 running」,这些会话名下其它排队任务对所有工人隐身。就算你绕过第一道闸硬塞十个任务进去,也只会一个一个被领走。
  • 为什么必须这样:它们共用同一份工作副本、同一个分支。两个 AI 同时在一个目录里改文件,一定互相覆盖。
services/orchestrator/server.mjs:4810-4827 写入闸 services/orchestrator/storage.mjs:382-386 算出忙碌会话 services/orchestrator/storage.mjs:396-400 隐身规则

两条不同会话

真并行 · 各自一份副本
  • 两条会话 → 两个分支 → 两个物理目录,改文件互不可见,不需要任何锁
  • 要真同时跑,还得有两个工人进程——因为每个工人进程都有一句「我手上有活就不再领」的自我限制。多进程是靠「监工」拉起来的(图 2c)。
  • 并行的真实代价:两条会话改了同一个文件,各自都能提交成功,冲突推迟到合并时才爆。所以「多个轮次改同一个文件必须排先后」是物理约束,不是团队习惯。
workers/mac-worker/worker.mjs:1305 「手上有活就不领」 packages/contracts/index.mjs:251-256 隔离单位 = 会话
图 2c · 监工怎么决定起几个工人(只会变多,不会变少)
1
监工进程
开机先起「下限」个工人
下限默认 1,上限默认 3(都能用环境变量改;命令行显式写了 --slots N 的话,这个 N 就是上限,手动设定优先)。每个工人是一个独立的 worker.mjs 进程,编号是「基础编号 + -s + 序号」,例如 mac-worker-s1mac-worker-s2
workers/mac-worker/supervisor.mjs:122-125 下限默认 1 workers/mac-worker/supervisor.mjs:111-118 上限默认 3 workers/mac-worker/supervisor.mjs:70-72 编号规则
编号必须各不相同,这不是为了好看:台账按编号存工人,两个进程共用一个编号会互相覆盖对方的「当前在跑哪个任务」和心跳时间。
每 5000 毫秒问一次「该有几个」
2
调度台算这个数
想要的数量 = 正在忙的工人数 + 现在能领的会话数
这是个纯读接口 GET /workers/scale-signal,绝不碰任务状态。它算「能领的会话数」时用的是和领活完全同一个函数,所以「说该有几个工人」和「实际能领到几个活」不可能对不上。
注意它数的是会话不是任务:同一条会话里排十个任务也只需要一个工人(因为串行),所以十个任务折叠成 1。这是刻意保守——宁可少起,也不要起一堆闲着没活干的进程。
services/orchestrator/server.mjs:3359-3391 scale-signal services/orchestrator/storage.mjs:562-581 countClaimableSessionsForWorker
监工把这个数夹在 [下限, 上限] 之间,然后只做加法
3
监工进程
只扩不缩——这一点和 GM 的说法不一样
代码里写得很直白:只会新起进程,绝不会因为闲下来而关掉一个。「回收/缩容」被明确标注为本轮范围之外(需要改工人本体才能安全排空)。所以一天里最忙的那一刻起到了 3 个工人,之后哪怕没活了,也还是 3 个进程挂着。
另外:工人异常退出会被自动重启,间隔按 1 秒起翻倍、最多 30 秒,并且永不放弃——一个一直起不来的槽位会持续刷警告日志,是个响的故障,不是静默的。查询数量的接口失败也不会缩容,只是「保持现状」。
workers/mac-worker/supervisor.mjs:167-172 明示只扩不缩 workers/mac-worker/supervisor.mjs:236-245 scaleTo 只做加法 workers/mac-worker/supervisor.mjs:196-203 重启退避 workers/mac-worker/supervisor.mjs:249-258 查询失败保持现状

并行安全靠的是一个语言特性,不是锁

两个工人进程同一毫秒来领活,为什么不会领到同一个任务?因为调度台是单进程单线程的,而「找到一个排队任务」和「把它改成进行中」这两步之间没有任何等待动作。这在 JavaScript 里意味着这段代码会被一口气执行完,中途插不进第二个请求。整套并行方案就建立在这条不变量上,代码里一个锁都没加。

反过来说这是个约束:哪天调度台被改成多进程(比如为了扛并发起两个副本),这条保证立刻失效,抢同一个任务就会真的发生。所以「调度台单进程」在现在这套设计里不是偶然,是前提。workers/mac-worker/supervisor.mjs:54-60 该不变量的书面记录services/orchestrator/server.mjs:2719-2732 无 await 的临界区

第二层名词解释

7 条
分支git branch,这里固定叫 session/<会话号>
是什么
版本库里的一条独立改动线。打个比方:像一本书的另存副本,你在副本上改,原书不动。但比喻到此为止——真实机制是「一个指向某次提交的可移动指针」,改动记录在提交里,不是复制整本书。
起什么作用
让每条会话的改动有自己的归属,可以单独审阅、单独合并、单独丢弃。
不要它会怎样
所有会话的改动堆在同一条线上,无法分辨哪个改动是哪件事干的,也没法只回退其中一件。
旁路工作副本git worktree
是什么
git 自带的功能:同一个版本库可以同时在磁盘上摊开成好几个目录,每个目录停在不同分支。物理上是多个真实文件目录,共享同一份底层版本历史(不是复制两份历史)。
起什么作用
让两条会话真的各有一份可以随便改的文件,互相看不见。这是「并行」在物理层面的实现方式。
不要它会怎样
只能靠不停切分支来复用一个目录——那就必须严格串行,任何两件事都不能同时做。
从主干切一刀fork point,代码里的 base 参数
是什么
新分支创建那一刻,以主干当时的状态为起点。之后主干继续往前走,这个分支原地不动
起什么作用
保证一条会话的工作环境是稳定的,不会因为别人合并了什么而突然变化。
不要它会怎样
——恰恰相反,问题出在「不要更新它会怎样」:会话开得越久,副本离主干越远。如果你交办的任务依赖某个刚合并进主干的改动,而这条会话的副本是那之前建的,任务会在一个看不见那个改动的旧世界里执行。派活前要核对起点里有没有包含目标改动。
软链接symlink
是什么
一个特殊的文件,内容只是「另一个路径」。打开它等于打开那个路径的真东西。不是复制。
起什么作用
让每个新副本不必重新装一遍几百兆的依赖包,也不必复制密钥文件——直接指回主仓那一份。
不要它会怎样
每建一个副本都要装一次依赖(慢且占空间),密钥要么复制多份(扩大泄漏面)要么没有(任务起不来)。代价是这几个路径在所有副本间是共享的,改了一处等于改了所有处。
槽位slot,一个槽位 = 一个工人进程
是什么
监工管理的一个位置,对应一个真实的操作系统进程。序号从 0 开始且连续,某个进程崩了会用同一个序号重新拉起来。
起什么作用
并发能力就等于槽位数。界面上把它们聚合显示成「一个工人在并行跑多个会话」,但物理上是多个独立进程。
不要它会怎样
只有一个工人进程,那么无论有多少条互不相干的会话,都只能一件一件做。
按需扩容autoscale,默认开启
是什么
监工每 5 秒问调度台「按现在的活量该有几个工人」,比现有的多就补起进程。用 --fixed 或环境变量可以关掉,退回「固定起 N 个」。
起什么作用
没活的时候只占一个进程,活多的时候自动铺开,不用人手动去起。
不要它会怎样
要么常开三个进程白占内存,要么忙的时候要人手动加。注意现在只扩不缩,所以「省内存」这个好处只在没冲过高峰的时候成立。
单线程事件循环Node.js 的执行模型
是什么
Node 里所有请求排在一个队列上,一次只跑一段代码。只有遇到「等待」(等网络、等磁盘)时才让出去处理下一个。
起什么作用
本系统靠它免费拿到了「领活」的原子性——没有等待动作的那段代码不会被打断,所以不需要加锁。
不要它会怎样
就得自己加锁或用数据库事务。它的另一面很致命:任何一段耗时的同步代码会把整个服务卡住,第四层那次 92MB 事故就是这么发生的。
承接
第二层解释了代码怎么隔离。但还有个更要紧的问题:对话本身存在哪?如果对话也存在那个 JSON 台账里,第四层那次事故会反复发生。这套系统的答案很不寻常——对话的真相不在服务器上,在你自己的 Mac 上。这就是第三层。
第三层

记忆住在哪

三个地方,各管一段,刻意不合并:① 对话正文的真相在你自己 Mac 上的原生会话文件;② 台账里只有网页自己那份气泡记录和元数据;③ 网页看到的原生对话,是一份调度台内存里的短命缓存,不落盘。三条硬约束跟着来:为什么不抄进台账、引擎一旦绑定不能换、终态任务的记录会被定时清掉。

图 3 · 记忆分两个存储区;中间是按需读取,下方是引擎锁判断与两道定时闸
你的 Mac · 真相源 claude 原生会话 按工作目录分开存 codex 原生会话 按日期存 对话正文的唯一答案在这里,我们只读、从不改写 目录一挪,claude 就找不到这条会话——续聊直接断 这也是「收编会话」「家目录角色」不建工作副本的原因 云服务器 · 台账 台账 store.json 会话行 只存指针,不存正文 2 小时 清事件 7 天 删整行 默认关着,只有线上打开 按需下发 回传切好的轮次 只进内存缓存 15 分钟就没 每次派活,先拿这条指针去判 引擎一致? 续上原来那条 记忆接得上 新原生会话 · 空记忆 那侧记忆从零开始 实测:一条会话 31 个 run 跨了 4 个原生 id

左右两个虚线框是两个不同的存储区:对话正文的真相在你 Mac 上的原生会话文件里,云端台账只存一个指针(原生会话号 + 哪个 AI)。

中间那对蓝箭头是按需读取——调度台不主动联系工人,需求搭在领活/心跳的回复里下发;读回来的内容只进内存缓存,永不落盘(防第二个真相源)。

下方菱形是引擎锁:不一致就走红色出口,开一条空记忆的新会话;台账下面那两个虚线方块是定时闸(2 小时清事件 / 7 天删整行,默认关、线上开)。

展开三个存储地各存什么、网页按需拉取那五步、为什么不把对话抄进台账,以及两条会咬人的约束
图 3a · 记忆的三个地方(谁是真相、谁是副本、谁会消失)

① 原生会话文件(真相源)

在你的 Mac 上 · 长期保存

claude~/.claude/projects/<按工作目录编码的目录>/<会话 uuid>.jsonl——按工作目录分开存,所以目录一挪,续聊就找不到了。

codex~/.codex/sessions/<年>/<月>/<日>/rollout-<时间戳>-<会话 id>.jsonl——全局按日期存,和工作目录无关。

这是两个命令行 AI 自己写的文件,我们只读、从不改。它就是「这条对话到底说了什么」的唯一答案。

workers/mac-worker/transcript-sync.mjs:33-42 定位入口 workers/mac-worker/transcript-sync.mjs:44-56 claude 定位 workers/mac-worker/transcript-sync.mjs:58-85 codex 定位

② 台账里的消息与元数据

在云服务器上 · 会被定时清理

存的是:网页自己那份气泡(你发的话、系统提示行)、会话的标题/角色/项目绑定、任务行、事件流、产物指针。

刻意不存的是:原生对话正文。它只有指针(原生会话号 + 是哪个 AI)。

这层里的事件流和任务行会被清掉(见图 3c),产物集合不被清理。

services/orchestrator/storage.mjs:29-66 台账结构

③ 展示缓存(给网页看的那份)

在调度台内存里 · 15 分钟就没

这个模块刻意不引用任何磁盘或台账:需求登记和解析好的对话轮次只活在进程内存的两张表里。进程一重启就是彻底冷启动——这是故意的,为的是保证磁盘上永远不会出现第二份对话真相。

上限写死四条:单条会话缓存 ≤ 1.5 MB(超了从最老的轮次开始丢)、最多缓存 8 条会话(按最近使用淘汰)、缓存活 15 分钟、需求登记活 30 秒

浏览器来读只能刷新「最近用过」的排序,不能延长过期时间——只有工人推来新数据才会重设 15 分钟。

services/orchestrator/transcript-channel.mjs:1-11 不落盘 + 四个上限 services/orchestrator/transcript-channel.mjs:212-215 只有新数据能续期 services/orchestrator/transcript-channel.mjs:266-269 读取不续期
图 3b · 网页怎么看到你 Mac 上那份对话(按需拉动,五步)
1
网页
你打开一条会话 → 登记一个「我要看」的需求
需求里三样东西:会话号 + 原生会话号 + 是哪个 AI。有效期只有 30 秒,你不看了它自己就过期了。
services/orchestrator/transcript-channel.mjs:8 有效期 30000msservices/orchestrator/transcript-channel.mjs:123-134 registerDemand
调度台不主动联系工人——它把需求清单塞进「领活」和「心跳」的回复里搭车
2
调度台
搭车下发(2 秒一次的领活 / 15 秒一次的心跳)
这是复用第一层「取消任务」那个搭车先例。有活的时候顺着领活响应下去(2 秒级),没活的时候原本会回一个空响应(204,按规范不能带内容),所以这里改成回一个 {run: null, 需求清单} 的信封——只有在真有需求时才这么做。
services/orchestrator/server.mjs:3335-3345 空响应改信封services/orchestrator/server.mjs:3317 心跳里搭车
工人拿到需求,去磁盘上找那个文件
3
你 Mac 上的常驻程序
字节位置只读新增的那一段,在本机就截断和脱敏
  • 不接受调用方传任意路径:会话号必须长得像一个标准 uuid,路径只能由代码自己按规则拼出来,并且必须落在允许的根目录下。
  • 记住上次读到第几个字节,下次只读后面新增的,不重读整个文件。默认窗口 256 KB,单次读上限 2 MB,单行上限 2 MB
  • 解析、截断、脱敏全在你的 Mac 上做,上行的只是「已经切好、已经打码」的对话轮次,上限 512 KB
  • 同一个会话号在多个目录下都命中时,取修改时间最新的那个,并如实上报「有异常」而不是悄悄挑一个。
workers/mac-worker/transcript-sync.mjs:12-17 四个上限 workers/mac-worker/transcript-sync.mjs:26-42 不接受任意路径 workers/mac-worker/transcript-sync.mjs:44-56 多命中取最新并报异常
为什么非要在本机做:被否决的方案里有一条是「工人上传原始字节、服务器来解析」。实测存在 1.44 MB 的单行,原样过网线会占带宽、还要让那台小服务器承担解析和原文内存;服务器一重启全部重来。所以定死在工人侧做。
上传 POST /workers/transcript-sync —— 但要先过隐私闸
4
隐私闸
没人在看,就不许上传
上传时逐字比对:这条会话有没有还没过期的需求登记,且需求里的会话号、原生会话号、是哪个 AI 三项完全一致。任一项不符,分别返回「需要先登记」「登记已过期」「登记不匹配」。
还有一道:目前只放行 claude。其它 AI 的上传直接回 409「不支持这个 AI 的对话读取」——codex 的定位代码已经写好了,但服务端这道闸没开,所以codex 的原生对话现在在网页上看不到
services/orchestrator/transcript-channel.mjs:148-165 checkActiveDemand services/orchestrator/server.mjs:3216-3221 只放行 claude services/orchestrator/server.mjs:3228 隐私闸注释
进内存缓存,浏览器轮询来读
5
网页
GET /sessions/<会话号>/transcript
合并规则也有讲究:增量上来的内容里可能包含「同一轮还没说完的后半段」,所以按轮次的键原地替换而不是追加,避免同一轮被显示成两遍。如果检测到字节位置倒退(文件被重写了)或者绑定的原生会话变了,就整份丢弃重来,绝不把旧身份的对话展示给新身份。
services/orchestrator/server.mjs:4685 读取端点 services/orchestrator/transcript-channel.mjs:55-76 按轮次键原地替换 services/orchestrator/transcript-channel.mjs:186-193 倒退/换绑即整份重置

为什么不把对话抄进台账(这是用户明令否决的)

选通道时列了五个方案,「把对话搬进台账」被判为禁止,理由两条写在文档里:① 用户明令否决——那会造出第二个真相源;② 它是第四层那次膨胀事故的复发路径。

「第二真相源」不是个抽象说法,它的具体后果是:同一段对话在你 Mac 上和服务器上各有一份,两份一旦不一致,没有任何办法判断谁对。而不一致是必然的——你在终端里直接续聊,服务器那份根本不知道。所以宁可让服务器上那份是「会过期的缓存」,也不让它变成「另一份档案」。docs/88-native-transcript-as-truth-contract.md:175 选项 E 判定

图 3c · 两条会咬人的约束:引擎锁 与 定时清理

引擎锁:一条会话绑了哪个 AI,就一直是它

什么时候锁上:这条会话同时有了「原生会话号」和「绑定的是哪个 AI」两个值之后。

行为不是拒绝,是强制拨回:你在界面上选了另一个 AI 并发消息,调度台照样受理,但把本轮用的 AI 拨回原来那个,并往会话里插一条可见的说明:「该会话已绑定 xxx 原生会话;换引擎会开一条新的空记忆会话,故本轮仍按 xxx 执行;要换引擎请新建会话。」

为什么要这样:续聊靠的是「原生会话号」,而这个号是某个 AI 自己的。换成另一个 AI,续聊解析结果为空,就会开一条全新的、什么都不记得的原生会话。宁可强行拨回并明说,也不让实际行为悄悄偏离你在界面上看到的。

services/orchestrator/server.mjs:4855-4872 引擎锁

定时清理:终态任务的记录会消失

两个时限,只对已结束(完成/失败/取消/错误/超时)的任务生效,正在跑的永远不碰

  • 结束超过 2 小时清空它的事件流,保留任务行和最终答案;
  • 结束超过 7 天整行删除,事件也一起删;
  • 任务行已经没了的孤儿事件数组,一并清掉。

但它默认是关着的:只有环境变量 WORKBENCH_STORE_PRUNE=1(或代码里显式传参)才启用。线上是部署脚本写进服务配置里的,所以生产开、本地和测试关——测试要靠「不清理」来验证审计留痕。启用后:启动时扫一次,之后每 10 分钟 扫一次。

对你的直接影响:任何结论如果只活在某次任务的事件流里,两小时后就没了;只活在任务行里,七天后就没了。所以结论必须另存成产物或文档——产物集合不在清理范围内。

services/orchestrator/storage.mjs:425 事件保留 2 小时 services/orchestrator/storage.mjs:426 任务保留 7 天 services/orchestrator/storage.mjs:415-416 默认关闭的开关 scripts/cc-deploy-api.mjs:77 线上打开它 services/orchestrator/storage.mjs:491-499 启动一次 + 每 10 分钟

第三层名词解释

10 条
原生会话文件native transcript,.jsonl
是什么
claude 和 codex 命令行自己写在你 Mac 家目录下的纯文本文件,一行一条 JSON(这种格式叫 jsonl,「每行一个 JSON」)。可以直接用文本编辑器打开看。
起什么作用
它是这条对话的唯一真相,也是「续聊」能接上的物理依据——命令行靠会话号回读这个文件。
不要它会怎样
命令行 AI 自己就没有记忆,每次都从零开始。这套系统也就没法做「在网页上接着你在终端里聊的那条对话」。
真相源 / 第二真相源single source of truth
是什么
「同一件事实,只允许有一个权威存放点」这条纪律。同一份事实被存两处、各自能被独立修改,就叫制造了第二真相源。
起什么作用
保证「不一致」这种问题从源头上不存在,而不是靠事后同步去弥补。
不要它会怎样
两处不一致时无法判断谁对。这套系统里已经吃过这个教训,所以对话正文只允许放在你的 Mac 上,服务器上那份是明确会过期的缓存。
按需登记demand,有效期 30 秒
是什么
一条「现在有人正在看这条会话」的短命记录,存在调度台内存里。
起什么作用
两个用处:① 告诉工人该去读哪个文件;② 当隐私闸——没有它,工人的上传一律被拒。没人看的时候,整条通道零流量。
不要它会怎样
要么工人无条件一直上传所有绑定过的会话(越积越多,白白把隐私内容传出去),要么网页根本看不到原生对话。
有效期 / 过期时间TTL
是什么
给一条数据配的「到点自动作废」的时间。本系统里出现四次:需求登记 30 秒、展示缓存 15 分钟、临时访问凭证 1 小时、事件保留 2 小时。
起什么作用
让「该消失的东西自己消失」,不依赖任何人记得去删。对隐私数据尤其重要。
不要它会怎样
缓存和凭证会无限期堆着。凭证不过期意味着泄露一次就永久有效;缓存不过期意味着服务器内存里一直躺着你的对话内容。
增量读取(按字节位置)offset tail
是什么
记住「上次读到这个文件的第几个字节」,下次从那里往后读。不是每次读整个文件。
起什么作用
对话文件会长到几十兆,每次全读会很慢也很费。增量读让一次刷新只处理新增的几 KB。
不要它会怎样
每 5 秒重读一遍几十兆的文件,磁盘和 CPU 都吃不消。代价是要处理「文件被从头重写」的情况——所以有一条「位置倒退就整份重置」的规则。
脱敏 / 截断redact / cap
是什么
脱敏 = 把疑似密钥、token 之类的字段值替换掉;截断 = 超过长度上限的文本直接切掉后面并加省略号。两件事都在你的 Mac 上做完才上传。
起什么作用
保证「离开你电脑的那份数据」既不含凭证,也不会大到把服务器压垮。
不要它会怎样
AI 过程里出现的密钥会被原样传到服务器并存进内存;一个 1.44 MB 的长行会直接过网线打到那台小服务器上。
最近最少使用淘汰LRU,这里上限 8 条会话
是什么
缓存满了的时候,扔掉「最久没被碰过」的那一条。实现方式是每次访问就把该条挪到队尾,满了就删队首。
起什么作用
让服务器内存占用有硬上限(最多 8 条会话 × 每条 1.5 MB),同时尽量保住你最近在看的那几条。
不要它会怎样
缓存条数没上限,开过的会话越多内存越大,最终把那台小服务器撑爆。
续聊resume,命令行的 --resume / exec resume
是什么
启动命令行 AI 时带上一个已有的会话号,让它接着那条对话往下说,而不是开新的。
起什么作用
这是「AI 记得上次聊了什么」的唯一机制。这套系统的连续性靠它 + 结构化文件,靠自己抄一份对话历史塞进提示词。
不要它会怎样
每次派活 AI 都从零开始,得靠把历史拼进提示词——那既贵又必然失真。
引擎锁
是什么
一条会话一旦绑定了某个 AI 的原生会话,后续派活就强制用那个 AI,并在会话里插一条说明。不是禁止发消息。
起什么作用
防止「换个 AI 就悄悄开了一条空记忆新会话」——那种情况下界面看起来一切正常,但 AI 其实什么都不记得了。
不要它会怎样
你换一次 AI,这条会话的记忆就静默清零,而界面不会告诉你。要真换 AI,正确做法是新建一条会话
定时清理retention prune,默认关闭
是什么
调度台启动时和之后每 10 分钟跑一次的扫描,按年龄删掉已结束任务的事件流和任务行。
起什么作用
让台账文件的大小有上界。它是第四层那次生产事故的直接修复措施。
不要它会怎样
事件无上限累积 → 台账文件持续膨胀 → 每次写盘越来越慢 → 整个服务卡死(这已经真实发生过一次)。它的代价就是「结论必须另存」。
承接
到这里,系统怎么跑、东西存在哪都说清楚了。剩下的问题是:这么多能改文件、能起进程、能写台账的地方,凭什么不出事?答案是四类闸门 + 一次真实的教训。这就是第四层。
第四层

什么在拦着不出事

五道防线:发版前的门禁(跑几百个检查)、写入白名单(不认识的字段直接丢)、容量红线(一次真实的生产卡死换来的)、身份分层(三种凭证,各自只能干各自的事)、部署链路(三条固定路径,不许手工操作)。

图 4 · 闸门串联:左边这串代码真的在拦,右边这串有条件、或者根本没在拦
硬闸:服务端代码在拦,绕不过 软闸:有条件,或只有浏览器在拦 只是文档:零程序检查,全靠自觉 ① 代码真的在拦 身份凭证三分 写入字段白名单 发版门禁 214 + 84 部署三步串联 ② 有条件 · 或根本没在拦 保留策略(默认关) 线上才开 遗留问题勾选门 改请求即绕 八要素齐全 没有程序会拦 验收只属用户 没有程序会拦

边框样式就是这道闸的强度:粗实线=服务端代码在拦;虚线=有条件或只有浏览器在拦;点线=只写在文档和角色卡里,没有任何程序检查。

右侧那些短箭头是每道闸的拒绝出口。注意右列下面两道——它们的出口是灰色虚箭头、通向「没有程序会拦」,这是全图最该记住的一处。

左列四道都是实拦:凭证不对回 401、不在白名单的字段写的时候就被丢掉、门禁失败即停、部署三步用 && 串联,任一步失败就地停住。

展开两道发版门禁的覆盖面、写入白名单的九类数据、92MB 生产卡死事故、三种凭证的边界,与三条部署路径
图 4a · 发版前的门禁:两个固定命令

npm run cc:verify —— 完整门禁

  • 语法检查:把仓库里所有 .mjs / .js 文件(214 个,已排除依赖包、私密目录、构建产物、台账目录)逐个跑一遍 node --check。这只检查「能不能被解析」,不执行代码。
  • 逐项检查:接着跑 84 个子检查命令,包括 JSON 格式校验、端到端冒烟、以及一大批 cc:test:*(纯逻辑单测)和 cc:probe:*(起一个真服务器打接口)。
  • 全程不读密钥、不部署、不碰真实原生会话文件
scripts/cc-verify.mjs:6-38 收集 + 语法检查 scripts/cc-verify.mjs:42-193 84 个子检查

npm run cc:qa —— 回归门禁

  • 60 个步骤,是完整门禁的一个子集,专门守住用户直接看得见的行为:任务中心抽屉、无障碍、未读状态、输入框的回车与中文输入法、事件解析、移动端、设计 token、并行相关的工作副本与监工逻辑等。
  • 项目规矩:每一轮产品开发在宣布完成前必须过这道门
  • 同样不部署、不读密钥。
scripts/cc-qa.mjs:20-81 60 个步骤 scripts/cc-qa.mjs:3-18 用途说明
图 4b · 写入白名单:不在名单上的字段,写的时候就被丢掉

怎么做的

每一类数据都有一份「允许写哪些字段」的集合,写入时逐个字段判断,不在集合里就跳过。目前有九类:待排期需求、任务卡、附件、连接器状态、工作区、角色记忆、编排状态、界面偏好、自主循环。台账代码里有十几处这样的过滤点。

services/orchestrator/storage.mjs:75 / :106 / :138 / :148 / :165 / :185 六个集合定义 services/orchestrator/storage.mjs:756 / :770 / :812 / :874 / :935 / :1013 / :1057 / :1117 过滤点

为什么必须白名单(两个具体后果)

  • 防台账被吹胖:不过滤的话,浏览器可以往任意一行塞任意字段。那个 JSON 文件每次改动都要整份重写,行越胖越慢。
  • 防密钥被顺手存下来:代码注释写得很直接——凡是「工人的标准输出、命令行返回体、长得像 token 的值」都在写入边界被丢掉,这样密钥不可能因为一次疏忽被持久化
  • 还有一条相关纪律:任务卡上从不存执行字段(状态、用哪个 AI、哪个工人、进度)。这些一律从关联的任务实时算出来。原因是没有乐观锁的情况下,一份冗余副本必然会和真相漂移。
services/orchestrator/storage.mjs:68-72 防密钥持久化 services/orchestrator/storage.mjs:99-102 执行字段不落库

容量红线:一次已确诊的生产卡死(2026-07-24)

现场数字(代码注释里逐条记着,标注为「已用数据确认」):台账文件 92 MB326 个任务里塞了 71286 条事件。

怎么死的:每次改动都要把整个 92 MB 同步序列化并写盘,一次约 2 秒;而工人每约 2 秒 就会触发一次写。于是那条单线程的事件循环几乎 100% 被占满——所有请求(连健康检查 /healthz 都算)被拖到大约 60 秒,连 ssh 登录进程都抢不到 CPU。

根因判定:不是「格式化输出太占地方」,而是事件流没有上限。所以修法是加保留策略(第三层图 3c),不是压缩格式。

现在的三道缓解:① 紧凑 JSON(不缩进);② 先写临时文件再改名覆盖,保证读到的永远是完整文件;③ 同一时刻的多次写合并成一次(用微任务队列),并且单次写超过 50 毫秒 就打一条慢日志,带上字节数、任务数、事件数。services/orchestrator/storage.mjs:403-414 事故记录services/orchestrator/storage.mjs:467-486 合并写 + 原子写 + 慢日志

图 4c · 身份分层:三种凭证,能干的事完全不同

① 操作者凭证

怎么带Authorization: Bearer <操作者令牌>

能干什么:几乎所有业务接口——建任务、改会话、管待排期需求、看系统页、读产物。你和总控用的都是这个。

范围:最大。它是这套系统的「万能钥匙」,所以它只存在 .private 里,不进版本库、不进日志。

services/orchestrator/server.mjs:398-402 requireOperator

② 工人凭证

怎么带X-Worker-Token 请求头(也接受同值的 Bearer)。

能干什么:只有工人那几个接口——领活、心跳、报事件、报结果、上传对话、查该起几个工人。

为什么单列一种:工人跑在你自己 Mac 上,风险画像和「人在浏览器里操作」完全不同。它拿不到操作者专属的那些接口。默认值上它可以回退到操作者令牌,但接口分组是硬分开的。

services/orchestrator/server.mjs:404-408 requireWorkerservices/orchestrator/server.mjs:147-148 两个令牌

③ 单会话临时凭证

长什么样wsat_ 加 48 位十六进制随机串(不可猜)。

能干什么只能读写它绑定的那一条会话。A 会话的凭证碰不到 B 会话,操作者专属接口一个也碰不到。

时效:默认 1 小时过期;只存在调度台内存里,服务一重启全部失效(要用就重新签一个)。过期的会在被使用时顺手删掉,所以那张表不会无限长大。

用途:让另一端(比如终端里重新接上来的命令行)能继续给某条会话下指令,而不必拿到万能钥匙。

services/orchestrator/server.mjs:410-412 签发 services/orchestrator/server.mjs:427-437 requireSessionAccess services/orchestrator/server.mjs:191 有效期 3600000ms services/orchestrator/server.mjs:226 重启即失效
图 4d · 部署链路:三条固定路径,一条聚合命令
1
后端
npm run cc:deploy:api → 阿里云那台服务器
用 rsync 把三个目录(调度台代码、共享契约、名册)同步到 /opt/personal-workbench,环境变量文件单独同步;然后写系统服务定义、重载、重启服务 personal-workbench-api;最后在服务器本机 curl 一次 /healthz 自检——不通就当部署失败。服务监听 3001 端口,定时清理开关在这里被写成 1。
scripts/cc-deploy-api.mjs:107-113 rsync scripts/cc-deploy-api.mjs:135-139 重启 + 自检 scripts/cc-deploy-api.mjs:67 / :77 端口与清理开关
前后端各自独立部署,互不阻塞
2
前端
npm run cc:deploy:web → Vercel 生产环境
发的是 apps/web 目录(Vercel 项目里配的根目录就是它),绑定 workbench.heyyys1.com。发之前会先对着本地文件校一遍页面标记——因为曾经出现过「标记漂移只能在发上去之后才发现」的情况(2026-07-26 红过一次)。
infra/inventory.json:58-68 Vercel 项目 scripts/cc-verify.mjs:156-158 先校标记再部署
设计稿走完全独立的第三条路,不碰生产站
3
设计稿站
npm run cc:deploy:mockup → 独立 Vercel 项目
另一个 Vercel 项目 personal-workbench-mockup,绑 mockup.heyyys1.com,源是 design/mockups刻意与生产站隔离。项目硬规矩:所有设计稿一律发到这一个域名下,不许为单个稿子新开子域名。目前 inventory 里这个项目的状态标的是 pending
scripts/cc-deploy-mockup.mjs:15-22 隔离说明 infra/inventory.json:69-76 mockup 项目(status: pending)
一条命令串起来:先过门禁,过了才发
4
聚合命令
npm run cc:deploy = 门禁 && 后端 && 前端
三步用 && 串联,任意一步失败就地停住,不会出现「测试没过但已经发上去了」。
package.json:148
域名与访问控制由 Cloudflare 管
5
域名层
Cloudflare 管 DNS + 访问控制
三条记录:api.workbench 是 A 记录直指服务器 IP、不走代理workbenchmockup 都是 CNAME 指向 Vercel。访问控制(Cloudflare Access)已启用,白名单是一个邮箱。非密事实全部记在 infra/inventory.json 里,密钥另存在不进版本库的目录。
infra/inventory.json:10-56 Cloudflare 配置infra/inventory.json:105-111 非密真源声明

第四层名词解释

10 条
门禁gate,这里是 cc:verify / cc:qa
是什么
一串固定的检查命令,任何一条失败整个命令就以失败退出。它就是个脚本,不是什么平台。
起什么作用
把「我觉得没问题」变成「机器验过没问题」。项目规矩是每轮开发宣布完成前必须过。
不要它会怎样
每次改动都靠人肉回归。这个仓库里前端是一个一万五千行的单文件,人眼根本盯不住互相影响。
语法检查node --check
是什么
让 Node 只解析一个文件、不执行它,看语法有没有错。
起什么作用
最便宜的一道网:花几毫秒挡住「少个括号导致整个服务起不来」这类事故。
不要它会怎样
一个打错的字符可能一路发到生产,直到服务启动失败才发现。注意它只管语法,不管逻辑对不对。
单测 与 接口探针cc:test:* / cc:probe:*
是什么
cc:test:* 是纯逻辑检查,不联网不动磁盘;cc:probe:*真起一个本地调度台进程,用临时台账打真接口,验完关掉。
起什么作用
前者保证算法和契约不漂;后者保证接口的鉴权、状态流转、持久化真的对。
不要它会怎样
纯逻辑测过了但接口拼错、鉴权漏了、字段没落库——这类问题只能到线上才发现。
写入白名单*_FIELDS 集合
是什么
一份「这类数据只允许有这些字段」的清单。写入时逐字段比对,不在清单里的直接跳过——不是报错,是静默丢弃
起什么作用
两件事:防止外部往数据行里塞任意字段把文件吹胖;防止密钥形状的值被顺手存下来。
不要它会怎样
任何能调接口的人都能给数据行加字段。在这套「整份重写一个 JSON 文件」的存储上,这直接连着第四层那次卡死。
事件循环被阻塞event loop blocking
是什么
Node 一次只跑一段代码。某段代码同步跑了 2 秒,这 2 秒里所有其它请求都在排队,一个也处理不了。
起什么作用
——它不是个功能,是个必须避开的坑。理解它才能理解为什么「文件变大」会变成「整个服务挂掉」而不是「稍微变慢」。
不要它会怎样
不理解它,就会写出「同步写一个大文件」这种代码,然后在生产上遇到连健康检查都超时、连 ssh 都登不上的现象。
原子写先写 .tmp 再改名
是什么
不直接改目标文件,而是写一个临时文件,写完用「改名」把它覆盖上去。改名在文件系统层面是一步完成的。
起什么作用
保证任何时刻读这个文件,读到的都是一份完整的旧版或完整的新版,绝不会读到写了一半的
不要它会怎样
写盘中途断电或崩溃,台账文件就成了一个坏掉的 JSON,整个系统起不来。
凭证 / 令牌token
是什么
一串随机字符串,请求里带上它就代表「我是谁」。物理上就是一个密码,只不过不是人记的。
起什么作用
让接口能判断「你有没有资格干这件事」。本系统三种凭证对应三种资格范围。
不要它会怎样
任何知道网址的人都能建任务、读你的对话、改你的台账。纪律:凭证只存在不进版本库的目录,绝不出现在日志里。
系统服务管理器systemd,服务名 personal-workbench-api
是什么
Linux 自带的进程管家。你给它一份定义(用哪个命令启动、工作目录在哪、环境变量文件在哪),它负责开机自启、崩溃重启。
起什么作用
让调度台变成一个「一直在」的服务,而不是某个终端窗口里的临时进程。
不要它会怎样
服务器重启或进程崩了就没人拉起来,整个系统离线,而且没人知道。
rsync(增量同步)
是什么
一个通过 ssh 把本地目录同步到远端的命令,只传有变化的部分。加上 --delete 会把远端多出来的文件删掉,让两边严格一致。
起什么作用
部署后端就是「把三个目录同步过去 + 重启服务」,简单到可以完全脚本化。
不要它会怎样
要么手工 scp(容易漏文件、容易留下上一版的残余),要么上一整套构建流水线。--delete 是关键:没有它,删掉的旧文件会一直留在服务器上。
Cloudflare Access(访问控制)
是什么
域名前面的一道登录墙:先验你是不是白名单里的那个邮箱,通过了才让请求到达后面的服务。
起什么作用
让这套私人系统不至于「知道网址就能用」。
不要它会怎样
只剩凭证一道防线。代价是自动化脚本要访问被保护的页面时会撞上这道墙——这已经是踩过的坑,所以部署校验改成了「读部署产物」而不是「访问线上页面」。
承接
最后一层最短,但对提需求最要紧:把「已经跑着的」和「只画在纸上的」分清楚。把设计稿当成已完成的功能去引用,是这套系统里最容易犯、后果最贵的错。
第五层

还没建成的部分(如实标出,不美化)

三种状态严格区分:已上线(生产在跑、有测试守着)、在建 / 只通了一半(代码在但闸没开,或明确划在范围外)、只有设计(文档写了很多,代码一行没有)。

已上线生产在跑,有门禁守着 在建 / 通了一半代码在,但没全通 只有设计零行代码
图 5 · 现状全景:三种填充=三种状态,每格第二行是判定依据
已上线 生产在跑,有门禁守着 整条派活链路 第一层那九步全部在跑 每会话一份工作副本 门禁项 cc:test:worktree 按需扩容(只扩不缩) 门禁项 cc:test:supervisor claude 原生对话可读 门禁项 cc:test:transcript-* 待排期需求 · 连接器 · 附件 各有集合 + 接口 + 门禁项 任务中心看板 docs/69 · cc:test:task-center 在建 · 只通了一半 代码在,但路没走通 codex 原生对话可读 定位已就位,服务端闸没开 工人缩容(用完关掉) 代码注释:本轮范围之外 设计稿站 名册里状态是 pending 第三个命令行 AI(kimi) 只有登记,分发表里没有它 只有设计 文档写了很多,零行代码 事项卡 / 结论卡 搜 contract_card 命中 0 处 原生会话为唯一主键 planned,澄清未清零不得派 飞书只做信息仓 刻意不做,只留域名白名单 名字像,完全不是一回事 最容易搞错的一处:右栏「事项卡」一行代码都没有,左栏「任务中心看板」早就上线了——下一个需求别建在错的那个上面

填充与边框=状态:绿实线=已上线(生产在跑、有门禁守着);黄实线=在建(代码在但路没走通);灰点线=只有设计(零行代码)。

每格第二行是判定依据,不是印象:已上线的都指得出对应门禁项,只有设计的都给得出「搜某字段命中 0 处」这类证据。

底部那条红虚线连着两个最容易搞混的格子——这是本层唯一一条跨区连线,画出来就是为了防止下一个需求建错地方。

展开每一条的完整判定依据与出处(已上线 6 条、在建 4 条、只有设计 3 条)
图 5 · 三态看板(每一条都给了判定依据)

已上线

能在生产上用,且有对应门禁

整条派活链路
第一层那九步全部是运行中的代码:网页 → 调度台 → 领活 → 命令行 AI → 事件回流 → 终态上报。
每会话一份旁路工作副本
第二层全部落地,门禁里有 cc:test:worktree 守着。
按需扩容(只扩)
监工 + 数量信号接口都在跑,门禁里有 cc:test:supervisorcc:test:scale-signal
claude 原生对话在网页上可读
第三层那条按需拉动通道(docs/88 轮 1)已上线,门禁里有 cc:test:transcript-parsecc:test:transcript-channel
任务中心看板(注意别和「事项卡」混)
台账里有独立的 tasks 集合、有 /tasks 系列接口、有可编辑字段白名单,门禁里有 cc:test:task-center这是 docs/69 的「任务卡看板」,不是下面那个「事项卡」。
待排期需求 / 连接器授权 / 附件 / 总控自主循环
都有台账集合 + 接口 + 门禁项(cc:test:backlog / connectors / attachments / gm-loop 等)。

在建 / 只通了一半

代码在,但路没走通或明确划在范围外

codex 原生对话在网页上可读
工人侧的文件定位代码已经写好了(按年/月/日目录倒序找 rollout-*-<id>.jsonl),但调度台那道闸没开:非 claude 的上传直接回 409「不支持这个 AI」。所以功能实际不可用。workers/mac-worker/transcript-sync.mjs:58-85 定位已就位services/orchestrator/server.mjs:3216-3221 服务端未放行
工人缩容(用完自动关掉)
代码注释里明确写着本轮范围之外,理由是安全排空需要改工人本体。现状:只扩不缩。workers/mac-worker/supervisor.mjs:167-172
设计稿站
部署脚本和域名规划都在,但 inventory 里这个项目的状态是 pending(生产站是 deployed)。infra/inventory.json:69-76
第三个命令行 AI(kimi)
名册里有 registry/runners/kimi-cli.yaml 这份登记,但工人的分发表里只有 claude 和 codex 两个适配。项目文档也是这么定的:先只做 codex,另两个只留登记。workers/mac-worker/worker.mjs:108-116 分发表只有两个

只有设计(零行代码)

文档写了很多,代码一行都没有

事项卡 / 结论卡(docs/90 + docs/92)
判定依据:在 services / workers / packages / apps / scripts / registry 里搜 contract_card命中 0 处。文档里那些字段名(contract_cards / items[] / decisions[] / outcome / 分级门 / tracks[])全都只存在于文档。
docs/92 自己写的状态就是「草案,尚未批准开工」,还要先跑一个证伪实验并满足六条前提。
⚠ 最容易搞错的一点:左边那个「任务中心看板」已经上线了,两者名字像但完全不是一回事。
统一数据源:以原生会话为唯一主键(docs/89)
文档头上写着 Status: planned,并且明确「澄清未清零(四个问题阻塞),未打 reviewed,不得派发」。调度台里也确实没有「枚举本机会话清单」的接口。
要做的事是:网页的会话列表直接由你 Mac 上的原生会话枚举出来,主键从 sess_* 换成原生会话号。文档实测:现存 189 条会话里有 175 条没有原生身份,会被划进「历史 · 未纳管」分区,如实标明而不假装。
飞书:只做信息仓,不耦合业务
这是刻意不做,不是没做完。核实结果:调度台里和飞书相关的只有一处——文档链接的域名白名单feishu.cn / larksuite.com / feishu.net);另有一条连接器授权路径,且凭证只留在你的 Mac 上;归档是个独立脚本。
docs/90 把「飞书本轮不动,但把『不是真相源』写死」列为已拍板事项;docs/92 进一步把文档、飞书、记忆一律降为镜像或指针services/orchestrator/server.mjs:2449 域名白名单workers/mac-worker/connector-auth.mjs 凭证不出本机

第五层名词解释

4 条
三态:已上线 / 在建 / 只有设计
是什么
本图的判定标准:已上线=生产在跑且有门禁项;在建=代码存在但链路没通或明确划在范围外;只有设计=在代码目录里搜关键字命中 0 处。
起什么作用
提需求时先看清目标落在哪一态。基于「只有设计」的东西去派活,会得到一个建立在不存在的字段上的方案。
不要它会怎样
文档写得越详细越容易被当成已完成。这个仓库里同一个决策曾经记在 5 处、对「落地没有」给出 3 种互相矛盾的答案。
产物artifact
是什么
一次派活留下的可引用东西:一份文档预览、一个链接、一份报告。存的是元数据和指针(种类、路径、简短预览),不是把整个文件塞进台账。
起什么作用
它是唯一不被定时清理碰的那一层,所以「结论要能三周后还查得到」只能靠它,不能靠事件流。
不要它会怎样
所有结论只活在会被清掉的事件流和任务行里:两小时后过程没了,七天后连任务行都没了。
契约文档docs/ 下按序号编排的那些 .md
是什么
每一轮开发前先写的一份文档,写清目标、硬需求、被否决的方案及理由、验收标准。它们不是事后总结,是事前定死
起什么作用
让「为什么当初这么选」留下依据。第三层那张「五个通道方案,四个被否」的表就出自这里。
不要它会怎样
同一个坑会重复踩,被否过的方案会被重新提出来。但要注意:文档里写了 ≠ 代码里有。判断有没有落地,必须去代码里搜。
镜像 / 指针 vs 真相源
是什么
真相源=唯一权威的那一份;镜像=为方便阅读复制出来的副本,允许过期;指针=只存一个地址,内容还在原处。
起什么作用
这是这套系统的核心纪律:飞书、文档、记忆文件全部被明确降为镜像或指针,不允许成为第二个权威。
不要它会怎样
同一件事在五个地方各存一份且都能改,最后没人知道哪份对——这已经真实发生过,也正是「事项卡」这个设计想解决的问题。
第六层

契约层:一个需求怎么变成「可开工的合同」

在任何 AI 动手之前,这件事得先被写成一份八项内容齐全的任务书,并且被明确标记为「已评审」。这一层最反直觉的一句话是:「八项缺一项就不许派发」这条规矩,代码里一行都没实现——真正会拦人的只有一个布尔状态。图上把这个区分逐条标了出来。

图 6 · 需求 → 合同 → 可开工:唯一的硬闸是那个菱形,澄清是一条回到它的循环
循环:澄清完成 → 回到评审门重判一次 落成待排期行 默认「未评审」 PM 出提案 不是契约 TechLead 写契约 八要素 · 零程序校验 已评审? 建会话 + 建 run dev 可以开工 拒派 400 零副作用:什么都不写 还有待定项 开澄清会话 不改状态 · 不起 run 标记为已评审 并清空待定项 点线=只是文档:没有任何程序检查八要素齐不齐 粗实线=硬闸:这是整层唯一一道真的会拦人的闸

左边三格是写合同:需求落行(默认「未评审」)→ PM 出提案 → TechLead 转成八要素契约。中间两格是点线——那些规矩只写在角色卡里,没有程序检查。

菱形是整层唯一的硬闸:评审状态不等于「已通过」就直接回 400,而且拒在读请求体、找会话、建任务之前,一点痕迹都不留。

下方那条绕回菱形的蓝箭头是循环:待定项没清零 → 开一场澄清(不起任务)→ 拍板后标记已评审并清空待定项 → 回到评审门重判。

展开六道关卡的逐条细节、八要素零程序校验的搜索证据、澄清两个端点,以及 OQ 编号跨 18 份文档歧义那个缺陷
图 6 · 从「我想要个东西」到「dev 可以开工」(六道关卡,从上往下读;每道关卡右上角标了它是硬闸、软闸,还是只有文档)
1
你提一句需求,先落成「待排期需求」一行硬闸
需求不是直接开工的,先变成台账里的一行,叫待排期需求(代码里叫 backlog item)。这一行创建时自带一个评审状态,默认是 pending_review(未评审)——不是「已通过」。也就是说,一个刚落进来的需求天生就是派不动的,必须有人显式把它拨到「已评审」。
services/orchestrator/server.mjs:5261-5284 POST /backlog 建行 services/orchestrator/server.mjs:5272 review_status 缺省取默认值 packages/contracts/index.mjs:220 默认 = pending_review
评审状态只有三档(不是两档,这一点在界面上有分支):pending_review(还没人看)、needs_clarification(看了,但有会改变范围的待定项没定)、reviewed(可以派了)。
packages/contracts/index.mjs:212-216
需求交给产品研究角色去研究「到底想做什么」
2
产品研究角色(PM)
PM 出的是「提案」,不是合同只是文档
PM 这个角色的职责在它的角色卡里写得很死:研究用户需求、竞品和市场,做轻量数据分析,然后在 docs/ 下起草候选方案文档给你挑。一份方案必须交代四件事:问题是什么收集到的证据最小可用范围还没定的待定项
  • 它明确不许干四件事:改产品代码、派任务、部署、代点验收(角色卡 forbidden_actions 逐条列出);能改的文件被限定为「只有文档」(local_change: docs_only);能跑的命令只有一条 npm run cc:status
  • 它有一条强制的提问格式:凡是要你拍板的选择题,每一项都必须给(1)业务背景与为什么重要、(2)每个选项的利弊、(3)每个选项的业务影响,外加一个带理由的推荐项——不许只丢一串问题
  • 它被明令按用户价值判断需求,不许按实现成本判断:实现贵不贵是开发的事,不许因为看着难做就把需求砍掉、缩小或改形。
  • 写完之后,把你选中的那份方案交给技术负责人角色——角色卡原文:「Hands the chosen proposal to tech-lead-agent, who turns it into an armed round contract」。
registry/agents/product-research-agent.yaml role 段 registry/agents/product-research-agent.yaml permissions / forbidden_actions / output_contract
这些约束是哪一种闸?角色卡里的 permissionsforbidden_actionsallowed_scripts 全都只是被拼进 AI 的提示词,没有任何程序在执行时校验它们。真正会拦住命令的是命令行工具自己的白名单(第四层讲过)。所以这一格标「只是文档」——它靠的是 AI 听话。
选中的方案交给技术负责人角色,由它转写成八要素契约
3
技术负责人角色(TechLead)
把提案转写成「八要素契约」——这是 dev 唯一会读到的东西只是文档
技术负责人的角色卡写明它负责「drafts the round contract(hard requirements、allowed scripts、forbidden actions、acceptance criteria)」,并且决定串行还是并行派发、派完复核汇报与 QA 结论、把轮次状态最多推进到「待用户验收」。它同样不许亲自写产品代码、不许部署、不许代点验收。
registry/agents/tech-lead-agent.yaml role 段 registry/agents/tech-lead-agent.yaml playbooks: docs/agent-playbooks/round-dispatch.md
八要素是哪八项(清单出处:docs/agent-playbooks/round-dispatch.md:9-12,原文是「round 文档必须包含:…缺一项则先补文档,不派发」):
  • Objective(这一轮到底要做成什么)——不写,AI 会自己发挥。
  • Current Baseline(先读哪些文件)——不写,AI 会凭印象改,改在错的位置上。
  • Hard Requirements(硬性要求,逐条)——不写,验收时没有对照物,只能吵。
  • Out Of Scope(明确不做什么)——不写,AI 会顺手改一大片,然后跟别人的活撞车。
  • Required Commands(必须跑哪些命令)——不写,没人跑测试。
  • Forbidden Actions(禁止动作)——不写,会出现擅自部署、擅自 git add -A 这类事故。
  • Agent Acceptance(AI 自己能验的部分)——不写,AI 交活时没有自检清单。
  • User Acceptance(只有你能验的部分)——不写,会出现「AI 说做完了」但你一看根本不是那回事。

如实交代:「缺一项不许派发」这条规矩,没有任何程序在拦

这是本层最该记住的一句。我在仓库里逐项搜过:

  • 搜中文「八要素」:services/packages/workers/apps/registry/ 全部 0 处命中scripts/ 只有 1 处,是一句种子文本描述(不是校验);剩下 4 处都在设计稿的示意文案里。文档里出现 23 次、分布在 20 份文档——全是人写给人看的
  • 搜八个字段的英文名(Objective / Current Baseline / Hard Requirements / …):整个代码区只命中 1 处,是一行注释
  • 结论:契约文档在派发时是被当成一整段纯文本灌进任务的(第七层第 1、2 步),服务端不解析、不检查结构。少写一项,系统照样把活派出去。

那到底什么在拦?只有下一格那个「已评审」标记——它检查的是一个布尔状态,不检查这八项在不在。也就是说:一份只写了两项的烂契约,只要有人把它标成「已评审」,就能顺利开工。这不是设计缺陷的猜测,是代码事实。

grep 八要素 → services/ packages/ workers/ apps/ registry/ = 0 scripts/lib/brain/data.mjs:91(种子文本,非校验) scripts/cc-test-task-center.mjs:29(唯一命中的英文字段名,是注释)
契约写完,需要有人把这一行拨到「已评审」;否则下一步一定被拒
4
评审门(真闸)
未评审一律拒派,而且是「零副作用」地拒硬闸
派发入口是 POST /backlog/<需求号>/dispatch。它的第一件事不是干活,是先查评审状态:只要不等于 reviewed,直接回 400「未通过评审」。
  • 顺序被刻意排在最前面:这道检查发生在读请求体之前、解析会话之前、创建任何任务之前。代码注释写明用意是「保证零副作用(item 仍 backlog、无新 session、无新 run)」。这很重要——否则一次被拒的派发会留下一条空会话和一个孤儿任务。
  • 还有一道更早的检查:这一行的状态必须是「待排期」,已派过或已关闭的会回 409,并把它已经关联的任务号一起告诉你,防止你重复派同一件事。
  • needs_clarification(需澄清)天然落进被拒分支——因为它不等于 reviewed。代码注释明确说这是故意的,不给它单开一条判断。
services/orchestrator/server.mjs:5311 dispatch 路由 services/orchestrator/server.mjs:5319-5325 非「待排期」→ 409 services/orchestrator/server.mjs:5330-5336 未评审 → 400,零副作用 packages/contracts/index.mjs:207-211 注释说明这道守卫的用意
通过之后它才开始干活:解析目标会话(复用你指定的,或按需求标题新建一条)→ 定 runner → 建消息 + 建任务 → 把这一行改成「已派发」并写上关联的任务号 → 回 202。
services/orchestrator/server.mjs:5337-5418
如果待定项还没清零,走澄清支路——澄清不等于派活
5
澄清支路
「与 PM 澄清」是一条独立支路:起讨论,但不起任务硬闸
  • 开澄清POST /backlog/<需求号>/clarify。它新建一条挂在 PM 名下的会话,塞进一条开场消息——内容是需求标题 + 需求描述 + 待澄清问题清单(逐条编号)+ 一句「请就以上待定项与我澄清,先不要开始实现」。然后把这条会话号记回需求那一行。
    关键:它不改评审状态、不写关联任务号、不起任何任务(代码注释原文:「澄清 ≠ 派实现」)。需求仍然躺在「待排期」列里。
  • 不会重复开:如果这一行已经有一条没被删的澄清会话,直接把那条返回给你,不再新起一条。
  • 澄清完了怎么收POST /backlog/<需求号>/resolve-clarification。它把评审状态拨到 reviewed并且把待澄清问题清单清空——因为「澄清完成 = 待定项已被拍板解决」,不清空的话下一步会撞上界面上那道「还有遗留问题」的确认门。PM 的结论文字(review_note)保留。
  • 前置条件是硬的:这一行必须真的开过澄清会话(有 clarify_session_id),否则回 409「从未澄清过」。代码注释解释了为什么用这个字段而不是猜文档标题:「item 与澄清产物的可靠绑定是 item.clarify_session_id——不靠文档标题猜」。
services/orchestrator/server.mjs:5424 clarify 路由 services/orchestrator/server.mjs:5433-5443 已有澄清会话则复用 services/orchestrator/server.mjs:5465-5482 开场消息的拼法 services/orchestrator/server.mjs:5486-5489 只写 clarify_session_id services/orchestrator/server.mjs:5506 resolve-clarification 路由 services/orchestrator/server.mjs:5514-5520 未澄清过 → 409 services/orchestrator/server.mjs:5539-5542 拨 reviewed + 清空待澄清清单
还有一道界面上的软闸:一个需求即使已经是 reviewed,只要待澄清清单还没清零,网页上的「推进」按钮就要你显式勾选一下「不阻塞」才启用。这是防止「评审通过了但遗留问题被忘掉」。它只活在浏览器里——改一行请求就能绕过,所以是软闸。
apps/web/index.html:12863-12871 遗留问题勾选门 apps/web/index.html:12878-12890 需澄清态的两个出口按钮
契约进 git,头部写一行状态;之后靠这行状态回答「这一轮到哪了」
6
状态行(Status 行)
契约文档头部那行状态:一个已经坏掉的机制半坏
是干什么的:每份契约文档头部有一行 Status: …,写这一轮走到哪了(草案 / 契约冻结 / 待派 dev / 已完成待你验收 / 卡住)。有一条命令负责批量改它:node scripts/workbench-set-round-status.mjs --status "…" --docs a.md,b.md --table 排期表.md --rows 行关键字,同时改文档头部那行、和排期表里对应行的状态格。
scripts/workbench-set-round-status.mjs:1-18 用途与用法 scripts/workbench-set-round-status.mjs:37-52 改文档头部那行 scripts/workbench-set-round-status.mjs:54-80 改排期表的状态格
它对最新一批契约已经失效了,这是实测:
  • 脚本只认行首的英文 Status:(判断语句就是「这一行是不是以 Status: 开头」),找不到就打印 NOT FOUND 并以退出码 2 结束。
  • docs/82 / 83 / 84 / 85 / 88 / 89 这批新契约改用了引用块写法> 作者:tech-lead-agent(…)。状态:契约冻结,待派 dev。——既不在行首(前面有 >),用的也不是英文 Status: 而是中文「状态:」。脚本一条都改不到
  • 实测分布:行首 Status: 的文档 50 份,用引用块「状态:」的 10 份
  • 这件事已经被知道,并且决定不修docs/90:143 原文「Status 行三格式分叉不修,让新机制绕开它」。(那份文档说的是「三种格式」,但没有逐条列出它数的是哪三种;我实测到的是文档头两种写法,第三种按脚本设计推测应是排期表里的状态格——这一条我标「待核」,不替它编。)
网页还会主动把这行剥掉:任务卡和详情的标题在渲染前先过一道 stripStatusLine,把文档前导区Status: 行丢掉(空行和 # 标题行保留、仍算前导区;一碰到第一段实质正文就停止剔除)。
为什么要剥:契约文档是被整篇当作任务描述灌进任务的,卡片标题如果直接截前几十个字,你看到的全是「Status: 契约冻结,待派 dev」这种噪音,看不出这一轮到底在做什么。
顺带一个真实后果:引用块写法的文档反而剥不掉——> 开头不匹配那条规则,而且它会直接终止「前导区」。所以那批新契约的卡片标题上会挂着「作者:tech-lead-agent(本轮 run)。状态:…」。同一个改动同时打坏了改状态的脚本和清理标题的前端。
apps/web/index.html:12343-12360 stripStatusLine apps/web/index.html:12363-12365 objectiveSummary = 剥状态行 + 截断
图 6b · 澄清项(OQ)为什么要有,以及它现在坏在哪

为什么必须有澄清项

不要它的代价最大

澄清项(代码字段 open_questions,文档里写成 OQ-1OQ-2…)=会改变做法或范围、但还没拍板的问题

  • 它是 PM 的必交产物,不是可选项:PM 角色卡的 output_contract.requiredopen_questions 和 summary、evidence、output_artifacts 并列,四项都必须交。
  • 它有强制格式:每一项都得给业务背景、每个选项的利弊、每个选项的业务影响、加一个带理由的推荐——所以你看到的不是一串问句,是一份可以直接打勾的选择题。
  • 不要它会怎样:待定项不拍板,AI 就按它猜的做。做完你才发现方向不对,这一轮整轮返工——而返工时它那侧的记忆已经被自己的错误结论污染了(第八层判据 1)。
registry/agents/product-research-agent.yaml output_contract.required registry/agents/product-research-agent.yaml MANDATORY choice format 段

真实缺陷:OQ-1 不是全局编号

已知,未修

同一个编号 OQ-1 在不同文档里指的是完全不同的问题。实测:18 份文档里都有 OQ-1

文档OQ-1 在那里指的是
docs/68agent 卡片能不能拖动、拖了要不要记住
docs/74右栏「当前任务」面板留不留
docs/82续聊时还要不要重新注入身份与规则
docs/84「收编本地会话」这个接口该做成什么形态
docs/85旁路工作副本的会话,续聊目录从哪来
docs/86新组件库的视觉基准照搬原站还是映射本站
docs/89原生会话清单存在哪个文件里

后果不是理论上的:用户 2026-08-13 拍板的那条 OQ-1=B只活在 docs/86 一个文件里——文件内出现 7 次、文件外 0 次。后来靠 docs/91 的长期约束清单第 C5 条把它抢救成全局约束,才没丢。

修法已提出,零行代码:给决策发全局唯一编号(形如 dec_xxxx)来根治重号。这只是提案,没有落地。

grep -rl OQ-1 docs/ = 18 份(实测) docs/90:11 写的是「10 份文档」——那是写方案当天的计数 docs/90:10 OQ-1=B 文内 7 次、文外 0 次 docs/91:30 C5 把它救成全局约束 docs/90-r0-pm-outline.md:355 dec_* 提议

第六层名词解释

9 条
待排期需求backlog item,编号形如 bkl_ + 十六进制
是什么
台账里的一行,代表「一件你提了、但还没开工的事」。带标题、描述、优先级(p0/p1/p2)、负责角色、评审状态、待澄清清单、澄清会话号、来源文档链接。
起什么作用
它是需求的唯一身份。派活之后这一行会记住它派出去的那个任务号,所以「这件事到哪了」有一个准确答案,不用在聊天记录里翻。
不要它会怎样
需求只存在于对话里,同一件事会被重复派两遍,也没有任何地方能拦住「还没定就开工」。
八要素契约docs/agent-playbooks/round-dispatch.md:9-12
是什么
一份 Markdown 文档,必须写齐八项:目标、当前基线(先读哪些文件)、硬性要求、明确不做什么、必须跑的命令、禁止动作、AI 自验标准、你来验的标准。
起什么作用
它就是派活时灌给 AI 的全文——AI 看不到别的东西。派发命令的 --objective-file 参数读的就是这个文件本身,一字不改地送进去。
不要它会怎样
AI 只能凭一句话发挥:改错位置、顺手改一大片撞别人的车、没有验收对照物。但要如实说:「缺一项不许派发」只是文档规范,代码里 0 处校验,全靠写的人自觉。
评审标记review_status:pending_review / needs_clarification / reviewed
是什么
待排期需求那一行上的一个字段,三档取值。新建的行默认「未评审」。
起什么作用
这是整个契约层唯一一道真正会拦人的闸:不等于 reviewed,派发接口在读请求体之前就回 400。
不要它会怎样
任何人(包括自动循环)都能把一份没人看过的需求直接派给 AI 开工。反过来也要清楚它的局限:它只检查这一个状态,不检查契约写得全不全
澄清项open_questions;文档里写成 OQ-1、OQ-2…
是什么
一个字符串数组,每条是一个「会改变范围、还没拍板」的问题。上限 20 条、每条 500 字。
起什么作用
把「还没定的事」显式摊在台面上,而不是让 AI 猜。它同时是界面上那道提醒门的输入:清单没清零,推进按钮要你手动勾选确认。
不要它会怎样
待定项被当成已定项做掉,整轮返工。现存缺陷:编号只在单份文档内唯一,OQ-1 在 18 份文档里含义各不相同,跨文档引用会指错。
澄清会话clarify_session_id
是什么
需求那一行上记的一个会话号,指向「为了搞清这些待定项而专门起的一场对话」。开澄清时新建,之后这一行就一直认它。
起什么作用
它是「这个需求澄清过没有」的可靠依据——收口接口就靠这个字段判断,代码注释明写「不靠文档标题猜」。也保证同一个需求不会被重复开出好几场澄清。
不要它会怎样
只能靠文档标题或人的记忆去对应「哪场讨论是为哪个需求开的」,对错了就把结论写到别的需求上。
零副作用地拒绝services/orchestrator/server.mjs:5330-5336
是什么
一种写法上的纪律:把「拒不拒」的判断排在所有会改动数据的动作之前——读请求体之前、找会话之前、建任务之前。
起什么作用
一次被拒的派发不留任何痕迹:需求还在待排期列、没有新会话、没有孤儿任务。代码注释是逐字这么写的。
不要它会怎样
每次误点派发都会攒下一条空会话和一个建了又用不上的任务,台账越攒越脏——而这个系统的台账是一个整份重写的 JSON 文件(第一层),脏数据是有真实代价的。
状态行契约文档头部的 Status: 行
是什么
契约文档头部一行纯文本,人写给人看的进度。有一条命令批量改它,同时也改排期表对应行。
起什么作用
让「这一轮到哪了」在 git 里有一份可追溯的答案,不依赖谁的记忆。
不要它会怎样
进度只活在对话里,换个人接手就丢。但现状是它半坏:改状态的命令只认行首英文 Status:(50 份文档符合),最新一批 10 份契约改用了引用块 +「状态:」,脚本改不到;网页剥噪音的规则也同样漏掉这批。docs/90:143 已明确决定不修。
提案 与 契约(两种不同的产物)
是什么
提案=PM 的产物,回答「该做什么、为什么、最小范围、还有什么没定」。契约=技术负责人的产物,回答「怎么做、不许做什么、跑哪些命令、怎么算过」。
起什么作用
把「要不要做」和「怎么做」分成两个人两份文档,好让前者不被实现成本带跑——PM 角色卡里有一条明令:不许因为看着难做就砍需求。
不要它会怎样
需求讨论和技术方案混在一份文档里,最后变成「因为难做所以不做」。要如实说:这条分工在实践中被破过——docs/86 头部写着「撰写:pm-agent」,那份八要素契约是 PM 写的,不是技术负责人。角色卡是提示词,不是程序。
硬闸 / 软闸 / 只是文档
是什么
三种强度完全不同的「规矩」。硬闸=服务端有代码在拦(例:未评审拒派)。软闸=只有浏览器在拦(例:遗留问题勾选门),改一行请求就能绕过。只是文档=写在文档或角色卡里、拼进 AI 的提示词,没有任何程序检查(例:八要素齐全、验收只属用户、角色的禁止动作清单)。
起什么作用
让你在提新需求时知道「这条规矩现在靠得住吗」。想让一条规矩真正生效,得让它变成硬闸,光写进文档不算。
不要它会怎样
会误以为文档里写了就等于系统会拦,然后在某次疲惫或某个自动循环里被绕过——本图后两层里的几处事故都是这么来的。
接下一层
契约齐了、标记也拨到「已评审」了,才轮到第七层:这份契约怎么变成一个真的在你 Mac 上跑起来的任务,跑完又怎么一路走到线上。第七层的第 1、2 步就是把这份契约文档整篇读进去当任务描述——所以契约写得好不好,直接决定 AI 干得对不对。
第七层

派 run 的完整链路:从「我写任务书」到「生产上线」

第一层讲的是「一句话」怎么走完一圈(九步,机器视角)。这一层讲的是一轮正式开发怎么走完一圈(十二步,人机协作视角):多出来的是写任务书独立复核合并回主干部署你来验收这五件只有人参与的事。每一步都标了真实命令、接口和文件。

图 7 · 一轮开发的端到端流水(含忙态三选项分叉、多槽位并行、每 2 秒循环、事件回流、三处陷阱)
写任务书 =那份契约全文 派发命令 定引擎 · 客户端守卫 服务端建 run 引擎锁强制回正 会话正忙? 忙 → 409 + 三个选项给你选 等一等 排队 先停掉 回流:事件按序回传 + 实时推给浏览器 不忙 → 落队列 queued 排队 没有超时机制 工人槽位 · 可并行 工人 s1 工人 s2 工人 s3(上限 3) 循环:每 2 秒领一次活 建副本? 旁路会话副本 原目录 / 家目录 命令行 AI 真正干活 跑完 → 上报终态 终态上报 带改动清单 · 用量 GM 独立复核 15 道门,不信汇报 逐文件合并 不是 git merge 部署上线 生产标记逐条断言 你来验收 服务端不校验主体 陷阱① 没有工人在线 这个 run 会无限等 陷阱②:跑完 2 小时清事件、7 天整行删除 陷阱③:合并 / 部署 / git 收尾按契约必须由 GM 在 AI 之外代做

一条主流水从左上走到右下。中间有三处结构:菱形「会话正忙?」扇出三个选项(等 / 排队 / 停)、虚线泳道里三个并行工人槽位、以及从槽位绕回去的每 2 秒循环

蓝色那条长箭头是事件回流:AI 每走一步,事件按序回到调度台,再实时推给你的浏览器——所以你能看着它一行行长出来。

三个红框是陷阱,挂在它们真正发生的那一步旁边:队列旁边那个最要命——没有工人在线就没有超时把它踢出来,会一直等下去

展开这十二步的逐条细节:三级引擎解析、派发守卫、忙态三选项、四种工作目录、终态白名单字段、15 道门、逐文件合并的事故由来、三条部署通道与三处陷阱的完整出处
图 7 · 一轮开发的端到端流水(十二步,从上往下读)
1
总控(GM)
写任务书 —— 任务书就是第六层那份契约文档本身
这里没有「另写一份任务书」这回事。派发命令的 --objective-file 参数直接读那份契约文档,整篇读进来、去掉首尾空白、一字不改地当作这次任务的描述。文档命名有约定:docs/NN-<主题>-roundN-<内容>.md
docs/agent-playbooks/round-dispatch.md:26「objective 文件就是 round 文档本身」 scripts/workbench-dispatch-round.mjs:60-63 readFileSync + trim
为什么这一步存在:AI 那侧看不到这个仓库里的任何别的东西(除了它自己会去读的文件)。它收到的就是这一段文本。任务书写漏一项,AI 不会来问你——它会自己发挥。
总控在自己的终端里跑一条派发命令
2
派发命令(在总控本机跑)
一条命令做四件事:定引擎、拦冲突、建会话、发消息
node scripts/workbench-dispatch-round.mjs --title "…"|--session <会话号> --agent <角色> --objective-file docs/NN-….md
  • ① 定用哪个 AI(runner),三级优先级:--runner 显式指定 > 该角色在 registry/agents/<角色>.yaml 里写的 default_runner > 最后兜底 claude-code。注释写明:registry 读不到(文件缺失、字段缺失)时静默回退,绝不因为默认值解析失败而挡住派发。
  • ② 派发守卫(下面单独讲):先 GET /runs?limit=100 拉最近 100 个任务,算出「还没跑完的有哪些」,再决定拦不拦。拦了就退出码 2。
  • ③ 建或复用会话:给了 --titlePOST /sessions 新建一条(带标题、项目、默认引擎、绑定角色);给了 --session → 先 GET 确认这条会话在,然后复用。二者必须给一个,都不给直接报错退出。
  • ④ 发消息POST /sessions/<会话号>/messages,正文就是整篇契约。返回里如果没有任务,退出码 3。
scripts/workbench-dispatch-round.mjs:39-57 引擎三级解析 scripts/workbench-dispatch-round.mjs:76-90 守卫调用 + 退出码 2 scripts/workbench-dispatch-round.mjs:103-113 建/复用会话 scripts/workbench-dispatch-round.mjs:117-128 发消息
派发守卫 evaluateDispatchGuard 到底拦什么(这是理解第八层的钥匙):
  • 新建会话(--title)→ 永远放行。一条新会话不可能有正在跑的任务,而且它会拿到自己的工作副本,所以它跟别的会话天然并行。
  • 复用会话(--session)→ 只在「这条会话自己」还有没跑完的任务时拒绝。同一条会话的多个任务必须串行,因为它们共用同一个工作副本和同一个分支。
  • 别的会话在忙,完全不管。注释里写了这条规则的来历:守卫以前是「全局只要有活跃任务就拒」,那样等于从第一道门就掐死了跨会话并行,后来改成按会话划范围。
  • --force 是逃生口,两种情况都能强行放过。
  • 关键限定:这是一道客户端软闸——它跑在总控自己的终端里。直接绕过这条命令去发请求,它拦不住你。服务端的对应闸在下一步。
scripts/lib/dispatch-guard.mjs:20-31 判定逻辑(纯函数,可单测) scripts/lib/dispatch-guard.mjs:3-18 规则由来的注释
--dry-run:只打印计划(复用还是新建、用哪个引擎、哪个项目、任务书多少字、前 200 字预览)然后退出,一个写请求都不发。派高风险轮次之前先跑这个。
--on-busy queue:透传成请求里的 on_busy 字段。含义是——这条会话已经有任务在跑时不报错,改为排队。注释里特别澄清了一句容易误解的话:领活层保证同会话严格串行(共用一个工作副本、改动按序累积),所以「排队」不等于「并行」。另一个取值 stop 会先取消正在跑的那个,属破坏性操作,必须显式指定。
scripts/workbench-dispatch-round.mjs:92-99 dry-run scripts/workbench-dispatch-round.mjs:121-124 on-busy 注释原文
POST /sessions/<会话号>/messages,正文=整篇契约
3
调度台
服务端建任务:三道判断决定「用哪个 AI、要不要拒」硬闸
  • 忙碌闸:这条会话已有活跃任务且请求里没带 on_busy → 回 409「该 session 正在被使用」,附上活跃任务号和三个选项 ["wait","queue","stop"]什么都不写。带了 queue → 直接往下走;带了 stop → 先取消活跃那个再走。注释解释了为什么要在写入边界上再拦一次:另一端(命令行、第二个浏览器标签)可能不知道已经有任务在跑,不能悄悄多派一个。
  • 定引擎pickRunnerId(请求里的值, 这条会话记住的默认值 ?? 全局默认)。这个函数只认白名单里的两个(codex-cli / claude-code),两个都不是就回全局默认 claude-code——注释明写「不再硬编码回退到 codex-cli」。
  • 引擎锁强制回正:如果这条会话的「原生会话号」和「绑定引擎」两个值同时有值,那么任何跟绑定引擎不同的请求都会被拨回绑定那个,同时往会话里插一条你能看见的说明消息(原文:「该会话已绑定 X 原生会话;换引擎会开一条新的空记忆会话,故本轮仍按 X 执行;要换引擎请新建会话。」)。注意它不是拒绝请求——消息照落、任务照建,只是引擎被改回去了。为什么这么设计,见第八层判据 3。
  • 然后落两条记录:一条消息、一条任务,任务初始状态 queued。有一条不变量在这里守着:任务的提示词 === 你这条消息的原文,不许注入身份、历史或规则。
services/orchestrator/server.mjs:4723 消息端点 services/orchestrator/server.mjs:4809-4827 忙碌闸与三个选项 services/orchestrator/server.mjs:107-111 pickRunnerId 白名单 services/orchestrator/server.mjs:4849-4852 引擎解析 services/orchestrator/server.mjs:4857-4872 引擎锁强制回正 services/orchestrator/server.mjs:4877-4878 零拼接不变量注释
任务落进队列,状态 queued(排队中)
4
队列
任务在队列里等着 —— 没有超时,会一直等
调度台的活到这里就结束了,它不会去联系你的 Mac(第一层已讲)。任务就躺在队列里等人来领。这里没有任何超时机制把它踢出来——这直接导致下面「陷阱①」那个坑。
你 Mac 上的常驻程序每 2 秒来问一次 POST /workers/claim
5
你 Mac 上的常驻程序
领活:四条挑选规则(跟第一层同一个函数)
挑选逻辑全在一个函数 claimableRunsForWorker 里:状态可领(排队中 / 等工人 / 等额度且已到点)+最老的先走(按创建时间正序)+同一条会话已有一个在跑,就把这条会话名下其它排队任务全部隐藏任务要的能力标签,工人必须每一条都声明过
services/orchestrator/storage.mjs:367-401 claimableRunsForWorker workers/mac-worker/worker.mjs:33 每 2000 毫秒问一次 services/orchestrator/server.mjs:3320 claim 端点
第三条就是「排队 ≠ 并行」的物理实现。你用 --on-busy queue 塞进同一条会话的第二个任务,会一直被这条规则藏着,直到第一个跑完为止。这是好事:它们共用同一个工作副本,同时跑一定互相踩。
领到了,工人先要决定「在哪个目录里跑」
6
工人
准备工作副本 —— 四种情况,按优先级从高到低(不是三种)
  • ① 收编来的原生会话(任务带 native_cwd)→ 就在那条会话自带的目录里跑,优先级最高。原因:命令行 AI 是按目录存对话记录的,把目录挪走,续聊直接找不到那条会话、绑定就断了。目录不是可读目录、或不在白名单里 → 直接失败并报错,不静默换目录
  • ② 家目录型角色(任务带 agent_home,例如常驻聊天座席)→ 在 <项目根>/agents/<角色号>/ 这个固定不动的目录里跑。原因同上:让这个角色的对话记录始终堆在同一个地方,命令行上 claude --resume 也能看到同一批会话。目录不存在 → 报错并告诉你该跑哪条命令补,不降级到一个没有身份的目录
  • ③ 实现型任务(任务带 worktree_ref)→ 在旁路工作副本里跑:路径 <项目父目录>/<项目名>-wt/<会话号>,挂着分支 session/<会话号>。这个旁路刻意放在仓库外面,好让主仓的 git status 保持干净、回收时也永远碰不到主仓。
  • ④ 都不满足 → 退回项目根目录本身(老任务、没绑项目或没绑会话的任务)。
workers/mac-worker/worker.mjs:1146-1211 resolveWorkspaceDir 四态 workers/mac-worker/worktree.mjs:20-33 旁路根目录与路径规则 workers/mac-worker/worktree.mjs:126-175 ensureSessionWorktree packages/contracts/index.mjs:275-288 服务端算「该不该给工作副本」
四种情况都要再过一次目录白名单isAllowedCwd),越界一律抛错。服务端那份白名单是第一道闸,工人这道是第二道、物理闸——同一件事拦两次是故意的。
工作副本是怎么建出来的:分支已经存在就直接挂上复用(这条会话历次任务的改动就这样累积在一起);分支是新的才从主干 main 切一份(连 main 都找不到就用当前 HEAD)。崩溃残留(登记了但目录没了 / 目录在但分支不对)会被强制回收后重建。顺便把主仓的依赖目录和密钥文件软链进去,否则新副本里连依赖都没装、跑不起来。
目录定了,启动那个命令行 AI
7
命令行 AI
干活,过程按序回流 —— 存一份 + 实时推一份
AI 每做一步(读文件、改文件、跑命令、思考),工人就把这一步作为一条事件发回来:POST /runs/<任务号>/events。调度台收下之后做两件事:存进台账,以及在进程内广播给所有正连着 GET /runs/<任务号>/stream 的浏览器——你在网页上看到过程一行行长出来,就是这条推送。这条推送只活在内存里,服务重启会断,重连时靠快照补齐。
services/orchestrator/server.mjs:3110 事件端点 services/orchestrator/server.mjs:3134 实时推送端点
跑完(或失败),工人上报终态
8
工人
上报终态:POST /workers/<工人号>/results,带一批只有工人才知道的事实
终态只有两个:完成 或 失败。除了最终答案,这一跳还落了一批白名单字段(只认这些,别的忽略):
  • changed_files:这次到底改了哪些文件,格式是 git 的机器可读状态行(形如 M docs/x.md?? a/b.css)。第 10 步的合并完全靠它,所以它是整条链上最关键的一个字段。
  • diff_stat(改了多少行的汇总)、tests_run(跑了哪些测试)——让任务中心能显示这些,不必再去问工人一次。
  • workspace_dir真实的执行目录(工作副本型任务就是那个旁路路径)。这是「下次续聊该在哪个目录里 resume」唯一缺的原料。
  • usage:这次烧了多少 token,只落原始数字;折算成美元是读的时候再算的(价格表在服务端另一处),所以改价格不用回填历史数据。
  • native_session_id:这次真正产生/续上的那条原生会话号。第一次上报会把它到会话上,连引擎一起记——第八层判据 3 的引擎锁就是从这里开始生效的。
services/orchestrator/server.mjs:3395 results 路由 services/orchestrator/server.mjs:3598-3612 白名单字段落库 services/orchestrator/server.mjs:3620-3634 首次绑定原生会话
AI 说「我做完了」——这句话不算数
9
总控(GM)
独立复核:不信汇报,自己把门禁重跑一遍
npm run cc:qa:agent —— 它不是「另一个 AI 来审」,而是把项目内固定的脚本和探针聚合成一个入口,回答一个是非题:这一轮的自动验收链过没过?15 道门
  • 契约 2 道(配置文件格式检查、最小闭环冒烟)
  • 界面结构 6 道(任务抽屉、对话过程、结果面板、任务操作按钮、手机端、输入框快捷键)
  • 解析器 1 道 + 产物收集 1 道
  • 接口探针 5 道(任务、产物、复核、项目绑定、事件顺序)
  • 生产站 1 组(只打公开端点、不带令牌:网页返回 200 + 各轮的标记字串在不在、健康检查)
判定规则:本地门全绿、且能打到的生产标记没有回归 → PASS;站点打不通记「跳过」不算失败;能打通但标记缺失=真实回归 → FAIL
package.json:130 cc:qa:agent docs/agent-playbooks/qa-agent.md:20-31 覆盖面与判定 docs/agent-playbooks/round-dispatch.md:36-38 收口第 2-3 步:逐条核对 + QA 必须 PASS
为什么不能信汇报:汇报是 AI 自己写的一段文字,它可以真心以为自己做完了。复核的形态是重跑脚本 + 目检截图——docs/86 头部那行状态就是一次真实记录:「GM独立复核14/14通过(verify脚本重跑+截图目检)」。
复核过了,才把改动搬回主仓
10
主仓
合并回主干 —— 不是 git merge,是逐文件拷贝
命令:npm run cc:merge:run -- <任务号> [--dry-run] [--allow <路径前缀>]…
  • 它先读这个任务的汇报,从里面拿到会话号(定位工作副本)和 changed_files 清单
  • 然后只处理清单里列出的路径:新增/修改/改名/复制/类型变更/未跟踪 → 从工作副本拷到主仓;删除 → 从主仓删掉。清单外的文件一律不碰。
  • --allow <前缀> 可以把可合并路径锁死(可重复给)。给了就只允许这些前缀,越界文件一律拒绝并以非 0 退出——防止某一轮越界改到别人的目录。
  • --dry-run 只打印计划。
scripts/cc-merge-run.mjs:1-17 用途 / 事故 / 用法 scripts/cc-merge-run.mjs:37-80 读汇报、解析 changed_files
为什么是这个形态(脚本头部逐字记着原因):总控此前用 cp -R <工作副本>/<目录>/. <主仓>/<目录>/ 整目录覆盖,导致并行任务互相盖掉成果,一天内发作四次,其中一次把批注层的两行引用整段删除。开发阶段那几组轮次都是并行的,整份覆盖会直接吃掉别人的工作。所以现在改成「只搬这个任务自己声明改过的文件」。
这也解释了第八层判据 2 为什么是否决权:两条会话同时改同一个文件,各自的清单里都有它,后合的那次会整份盖掉先合的那次——而且不会有任何冲突提示,因为这不是 git 合并。
进了主仓,才能上线
11
部署(三条互不相干的通道)
后端、前端、静态站,各走各的路
  • npm run cc:deploy:api —— 把后端文件同步到那台云服务器的 /opt/personal-workbench,然后重启系统服务 personal-workbench-api。登录走固定密钥 + 严格的主机指纹校验(StrictHostKeyChecking=yes)。它顺便还负责一件事:把「清理开关」写进线上环境变量——所以第四层那套保留策略只在线上是开着的(见下面陷阱②)。
  • npm run cc:deploy:web —— 把前端那个 HTML 目录拷到一个没有 git 的临时目录再发布。为什么要绕这一下:从仓库里直接跑发布命令会无限挂住(注释里写明了)。发完之后逐条断言生产标记:会话对话界面、任务中心、过程时间线、结果面板、任务操作按钮…缺任何一条就抛错。它还刻意拒绝一种情况:拿不到可直连的发布地址时大声失败,而不是拿一个登录页去对标记(否则会报出「标记缺失」这种误导人的错)。
  • 静态站 / 设计稿 —— 仓内有 cc:deploy:mockupcc:deploy:intro;用户点名的展示站通道是仓外的 publish-kit:npx --yes github:layyyback/publish-kit publish <目录> --to <子域>。要如实说一句:这个仓库里 0 处引用 publish-kit——发布是仓外动作,由总控或你执行,开发任务不部署。
scripts/cc-deploy-api.mjs:10-11 目标目录与服务名 scripts/cc-deploy-api.mjs:77 写入清理开关 scripts/cc-deploy-web.mjs:35-39 为什么要拷到无 git 临时目录 scripts/cc-deploy-web.mjs:180-190 拿不到直连地址就大声失败 scripts/cc-deploy-web.mjs:233 / :248 / :253 / :259 / :266 逐条标记断言 package.json:135-140 三条部署通道 docs/86:116 publish-kit 命令;docs/86:76 仓内 0 处引用
上线了,最后一步只有你能做
12
验收 —— 规矩是「只有你能点」,但服务端并没有校验是谁点的只是文档
规矩本身:任何自动链路最多只能把状态推进到「已完成,待用户验收」;「已验收」只能由你手动确认。这条写在总控手册里,也写在长期约束清单第 C3 条,每个角色卡的禁止动作里都列着「不许代点验收」。
  • 接口POST /runs/<任务号>/review,动作取三个值之一:accept(验收通过)、block(打回,可带说明)、clear(清掉标记)。落一条复核记录并写一条事件。
  • 但它只做一件校验:请求带的令牌等不等于那个固定的「操作员令牌」。而总控手里拿的就是同一个令牌。
  • 我搜过:accepted_byuser_acceptance 这类「记下是谁点的」字段,服务端 0 处写入。也就是说服务端分不清这一下是你点的还是总控点的。
  • 那什么在拦?只有提示词。角色卡的 forbidden_actions 里那条 mark_user_acceptance 是拼进 AI 提示词的一行文字,没有 gate
services/orchestrator/server.mjs:2952-2977 review 端点三个动作 services/orchestrator/server.mjs:2955 → :398-402 只比对操作员令牌 docs/91:28 C3;docs/agent-playbooks/workspace-orchestrator.md:55 registry/agents/*.yaml forbidden_actions: mark_user_acceptance

陷阱① 没有工人在线时,任务会无限等下去

现象:派发成功、界面上能看到这个任务,但它永远停在「排队中 / 等工人」,没有任何超时会把它踢出来。因为调度台从不主动去找工人(第一层第 3 步),没人来领活就没人来领活。

怎么提前发现:健康检查接口会返回当前在线工人数 workers_onlinenpm run cc:status 会直接打出来。派发手册里也写了这条:worker 离线时派发会停在「等工人」,可以照常派发但必须在汇报里说明。长期约束清单第 C8 条把它列为硬规矩:派发前必核 worker 在线

services/orchestrator/server.mjs:2811 /healthz 返回 workers_online scripts/cc-status.mjs:48 打印在线工人数 docs/agent-playbooks/round-dispatch.md:14-15 docs/91:33 C8

陷阱② 跑完的任务,2 小时后过程记录被清、7 天后整行删除

两个数字:终态任务超过 2 小时 → 它那一整串过程事件被清空(任务行本身和最终答案保留);超过 7 天 → 整行连同事件一起删除。正在跑的任务永远不碰。启动时扫一遍,之后周期性再扫。

一个必须知道的限定:这套清理默认是关着的,只有环境变量 WORKBENCH_STORE_PRUNE=1 才启用——线上由部署脚本写入,本地和测试环境是关的(测试要靠「不清理」来验审计留痕)。

为什么必须有它:真实事故——线上台账文件长到 92MB71,286 条事件、326 个任务;每次落盘都同步把整个 92MB 序列化重写一遍(约 2 秒),而工人那边每约 2 秒就会触发一次写 → 事件循环几乎 100% 被堵住 → 连健康检查都要 60 秒才回,ssh 服务被 CPU 饿死。根因是那条无上限的事件流。

对你的实际影响7 天前的任务详情在线上是真的没了。要留证据(比如某次事故的完整过程),得在 7 天内自己抄出来。

services/orchestrator/storage.mjs:403-412 事故记录(原始数据) services/orchestrator/storage.mjs:415-416 默认关,靠环境变量开 services/orchestrator/storage.mjs:424-425 两小时 / 七天两个常数 services/orchestrator/storage.mjs:488-499 启动扫 + 周期扫 scripts/cc-deploy-api.mjs:77 线上写入开关 docs/91:32 C7 store 膨胀红线

陷阱③ 沙箱会挡住 AI 的本地命令 —— 这一条我按代码与文档更正了措辞

能找到出处的事实(三处,都实读过):

  • 开发任务的沙箱没有浏览器、open 命令被禁——所以「渲染页面 + 截图」这件事根本不能在普通开发任务里做,必须落到你 Mac 上那个常驻程序(它声明了本地渲染能力)。
  • 某一轮里,连 awk、重定向写临时文件、甚至 node --check 语法检查都被拦了,临时目录和工作目录内都不行。那一轮的交付说明里直接写了「任何点不出来的交互请报给我修」。
  • 还有一轮记录:ls 被沙箱拦截(用读文件的工具能读,只是命令不行)。

我没能核实的部分:「沙箱专门挡住 AI 写 git 元数据」这个说法,我在整个仓库里找不到任何出处——待核,不替它编。

真正成立的对应事实是「契约级」的,不是沙箱级的:无人值守轮次的契约里明写「dev 禁擅自部署 / 重启,收尾由 GM 执行」,禁止动作清单里也列着「禁止亲自部署 / 重启」。所以合并、部署、git 收口这些事由总控在 AI 之外做——原因是规矩这么定的,不是因为沙箱拦不住。这两个原因导致同样的结果,但含义完全不同:前者可以改,后者改不了。

docs/65:87 沙箱无浏览器、open 被禁 docs/77:385 与 :621 awk / 重定向 / node --check 被拦 docs/30:93 ls 被沙箱拦截 docs/84:6 与 docs/83:6、docs/83:287 收尾由 GM 执行

第七层名词解释

11 条
任务书objective / --objective-file
是什么
就是那份契约 Markdown 文件本身。派发命令把它整篇读进来当作这次任务的描述文本,一字不改。
起什么作用
它是 AI 收到的全部输入。系统有一条硬不变量:任务的提示词 === 你这条消息的原文,不许在中间偷偷注入身份、历史或规则。
不要它会怎样
只能给 AI 一句话,它自己发挥。也没有任何东西能当验收的对照物——「做完了没」变成一场辩论。
派发守卫scripts/lib/dispatch-guard.mjs
是什么
派发命令里的一个纯函数:拿最近 100 个任务,算出「还没跑完的有哪些」,再按会话划范围决定拦不拦。
起什么作用
把「同一条会话必须串行」这条物理约束提前到派发那一刻,让你在按回车之前就知道会撞。新建会话永远放行,别的会话在忙完全不管——这是刻意的,否则跨会话并行从第一道门就死了。
不要它会怎样
你会往一条正在跑的会话里再塞一个任务,然后两个任务在同一个工作副本里互相踩。但要如实说:这是客户端软闸,跑在总控自己的终端里,绕过这条命令直接发请求它拦不住——服务端的对应闸是那个 409。
排队(--on-busy queue)
是什么
派发时的一个选项。这条会话已有任务在跑时,不报错,而是把新任务落进队列。另两个取值:wait(客户端自己等)、stop(先取消正在跑的那个,破坏性)。
起什么作用
让你可以一次把连续几轮排好,不用守着一轮结束再手动派下一轮。
不要它会怎样
只能盯着屏幕等。最容易误解的一点:排队不等于并行——领活层看到这条会话已有一个在跑,就把其余的全藏起来,所以它们严格一个接一个,改动按顺序累积在同一个工作副本里。
引擎锁强制回正services/orchestrator/server.mjs:4857-4872
是什么
一段服务端逻辑:会话的「原生会话号」和「绑定引擎」同时有值时,任何要求换引擎的请求都被拨回绑定那个,并往会话里插一条你看得见的说明消息。
起什么作用
防止你无意中把这条会话的记忆清零(换引擎 = 那侧从零开始,见第八层判据 3)。它不是拒绝请求——消息照落、任务照建,只是引擎被改回去,并且明确告诉你为什么。
不要它会怎样
在界面上顺手把引擎从 A 切到 B,下一轮 AI 就完全不记得前面聊了什么,而你以为它记得——这条线反复「重切阶段 / 写交接文档 / 返工」的根因就在这。
能力标签capabilities,例如 claude-code.execute、project:personal-workbench
是什么
工人在心跳里声明的一串字符串,表示「我这台机器能干什么」。任务也带一串「我需要什么」。
起什么作用
领活时按「工人必须每一条都声明过」来筛。将来接第二台机器(或云端工人)时,靠它把活分对。
不要它会怎样
一个需要本机浏览器的任务会被派给一台没浏览器的机器,跑到一半才失败。
工作副本(旁路)<项目父目录>/<项目名>-wt/<会话号>
是什么
用 git 的 worktree 功能开出来的另一份完整代码目录,挂着分支 session/<会话号>,放在主仓外面的兄弟目录里。一条会话一个。
起什么作用
让两条会话可以同时改代码而不互相看见。放在仓库外面是为了让主仓的 git status 始终干净、回收副本时永远碰不到主仓。
不要它会怎样
所有会话在同一个目录里改同一份文件,任何两件事都不能同时做。代价见第八层判据 4:副本是创建当天从主干切的,之后不会自动跟随主干。
改动清单changed_files
是什么
任务终态时上报的一个数组,每项是 git 的机器可读状态行:两个字符的状态码 + 文件路径(改名写成「旧 -> 新」)。
起什么作用
它是合并回主仓的唯一依据——合并脚本只搬这个清单里的文件,清单外一律不碰。也是任务卡上「改了哪些文件」的数据来源。
不要它会怎样
只能整目录覆盖,而那已经造成过真实事故:一天内四次并行任务互相盖掉成果,其中一次删掉了别人两行代码。
逐文件合并npm run cc:merge:run -- <任务号>
是什么
把一个任务的成果从它的工作副本搬回主仓的命令。不是 git merge:它按改动清单一个文件一个文件地拷贝或删除。
起什么作用
避免整目录覆盖吃掉别人的工作。--allow <前缀> 还能把可搬路径锁死,越界拒绝并非 0 退出。
不要它会怎样
回到整目录覆盖那个事故形态。但也要知道它的代价:因为不是 git 合并,没有冲突提示——两条会话都改了同一个文件时,后搬的那次会静静地整份盖掉先搬的那次。
生产标记deploy marker
是什么
前端页面里几个特定的字串(某个界面独有的类名或文案)。部署脚本发完之后把线上页面抓下来,逐条检查这些字串在不在。
起什么作用
把「发布成功」从「平台说 OK」升级成「我真的在线上页面里看到了这一轮该有的东西」。缺任何一条就抛错。
不要它会怎样
只能看托管平台的绿灯,而绿灯只代表文件传上去了,不代表内容对。一个已被处理的坑:拿不到可直连的发布地址时脚本会大声失败,而不是拿一个登录页去对标记——否则会报出「标记缺失」这种把人带偏的错。
15 道验收门npm run cc:qa:agent
是什么
一个聚合入口,把项目内固定的脚本和探针一次跑完,写一份结构化报告。不是「另一个 AI 来审」。
起什么作用
回答一个是非题:这一轮的自动验收链过没过、能不能交给你做主观验收了。判定:本地全绿 + 能打到的生产标记无回归 → PASS;站点打不通记跳过;能打通但标记缺失 = 真实回归 → FAIL。
不要它会怎样
只能信 AI 的自述。而 AI 可以真心以为自己做完了——它没跑过那些测试。
token 用量usage(只落原始数字,美元读时算)
是什么
任务终态时上报的一组原始计数(输入多少 token、输出多少、缓存命中多少)。不存美元金额。
起什么作用
让「这一轮花了多少」可核算。折算成美元是读的时候按当前价格表算的。
不要它会怎样
花钱是一笔糊涂账。为什么不存美元:价格会变——存了金额,改一次价就得回填全部历史数据;只存原始数字,改价格立即对全部历史生效。
接下一层
第七层第 2 步那个「新建会话还是复用会话」的选择,看着只是一个命令行参数,实际上是这套系统里最容易出事的一个决定——它同时牵动工作副本、代码分支、AI 那侧的记忆,还有第 10 步那个没有冲突提示的合并。第八层把它拆成四条判据。
第八层

session 继承还是新建:四条判据与它们的来历

派活时只有一个选择:--session <旧会话号>(复用)还是 --title "新标题"(新建)。看着像个小参数,实际上它同时决定了AI 记不记得前面聊的事改动落在哪个分支会不会跟别人撞车它看到的代码是哪一天的。四条判据里第一条是唯一的正向理由,后三条都是否决权——只要任何一条否决,就必须新建。

图 8 · 四个菱形串成的判定链:第一个是正向理由,后三个是否决权,右边全部通向「新建」
要派一轮活了:复用旧会话,还是新建? ① 要不要旧记忆 唯一的正向理由 要旧记忆吗? 无关的新事 ② 会撞同一文件? 否决权 · 物理约束 会撞同一文件? 后合的整份盖掉 ③ 要换引擎吗? 否决权 · 服务端强制 要换引擎吗? 换引擎=记忆归零 ④ 副本还新鲜? 否决权 · 没人会提醒你 副本还新鲜? 看不到新增的文件 复用旧会话 四条全过才轮到它 新建一条会话 代价只有一个:背景重讲一遍 记忆从零,但干净 三条否决的共同点:失败全是静默的 不报错、不提示冲突,只是做出来的东西不对 所以:拿不准就新建

四个菱形串成一条判定链:第 1 个是唯一的正向理由(要不要旧记忆),后 3 个都是否决权——任何一条成立,「我想让它记得」这个愿望立刻作废,不再往下问。

右边四条出口汇聚到同一个结论。红色那三条是否决,各自的理由写在箭头旁:撞文件会被静默盖掉、换引擎记忆归零、副本陈旧就看不到新增的文件。

只有一路走到底才是复用。拿不准就新建——新建的代价只是重讲一遍背景,误判复用的代价是别人的成果被盖掉,或者 AI 按看不见的旧文件白做一轮。

展开四条判据各自的机制、为什么需要、不要它会怎样,以及引擎锁与副本新鲜度那两组可复核的实测证据
图 8 · 判定流程(从上往下依次问;任何一条否决,结论立刻变成「新建」,不再往下问)
1
起点判据
要不要旧记忆?靠人判断
是什么:问一句「这件事是不是上一件事的延续」。是 → 倾向复用;跟前面完全无关的新事 → 直接新建。
  • 起什么作用:复用会话=AI 那侧的原生对话能接上,你不用把背景、已经踩过的坑、已经定下的口径重讲一遍。这是复用唯一的好处,但它非常大——重讲一遍的成本不只是打字,是「你会漏讲某一条,然后它按错的做」。
  • 不要它会怎样:每一轮都从零开始。上一轮好不容易谈清的口径,这一轮又要谈;上一轮它踩过的坑,这一轮它会再踩一次。
  • 反过来也要判:如果上一轮结论是错的、或者中途被推翻过好几次,那条会话的记忆已经被污染了——这时候「有旧记忆」是负资产,宁可新建一条干净的。
工作区 CLAUDE.md「接需求时的三步判断」第 2 条 session 继承判断 docs/agent-playbooks/round-dispatch.md:20-21 复用 / 新建的原则
这一条常被下面三条否决——也就是说,「我想让它记得」经常不算数。往下依次问三个否决项,任何一个成立,结论就是新建。
想复用?先过第一道否决
2
否决判据
会不会撞同一个文件?否决权物理约束
结论先说:两条会话如果都要改同一个文件(最典型的是前端那个一万五千行的 apps/web/index.html),不许并行,必须排先后。而排先后的正确做法是塞进同一条会话排队,不是开两条会话同时跑。
  • 物理原因(这不是规矩,是机制):每条会话独占一个代码分支 session/<会话号>,并且独占一份旁路工作副本 <项目父目录>/<项目名>-wt/<会话号>。两条会话就是两份各自完整的代码目录,各自在自己那份里把同一个文件改了一遍。它们互相看不见对方改了什么。
  • 为什么合回去一定出事:合并不是 git merge(第七层第 10 步),是按各自的改动清单逐文件拷回主仓。两份清单里都有这个文件,后搬的那次会整份盖掉先搬的那次,而且没有任何冲突提示——git 合并至少会红着脸告诉你冲突了,逐文件拷贝不会。
  • 真实事故:这正是那个「一天内发作四次」的事故形态,其中一次把批注层的两行引用整段删除。合并脚本头部逐字记着这件事。
packages/contracts/index.mjs:260-263 分支命名 session/<会话号> packages/contracts/index.mjs:268 默认从主干 main 切 packages/contracts/index.mjs:275-288 什么情况才给工作副本 workers/mac-worker/worktree.mjs:20-33 旁路根目录(刻意放在仓库外) scripts/cc-merge-run.mjs:4-9 事故原文 工作区 CLAUDE.md「物理约束核对」:多个 round 改同一文件必须排先后
好消息:同一条会话内部天然串行,不需要人去排。两道机制在守:
  • 领活层:某条会话已有任务在跑时,这条会话名下其它排队任务全部被隐藏——严格一个接一个。
  • 派发层:复用会话时,那条会话还有活跃任务就直接拒(退出码 2)。
所以「怕撞文件」的正解是:把这几轮都塞进同一条会话,用 --on-busy queue 排队。它们会顺序执行、改动累积在同一份工作副本里,根本不存在两份要合。
services/orchestrator/storage.mjs:367-401 同会话已有 running 就隐藏其余 scripts/lib/dispatch-guard.mjs:20-31 复用会话时的拒绝
不撞文件?再过第二道否决
3
否决判据
要不要换引擎?否决权服务端强制
结论先说换引擎 = 必然开一条空记忆的新原生会话。所以如果你这一轮想换一个 AI 来做(比如 Claude 用量到顶了想换 Codex),那么「复用旧会话保留记忆」这个愿望在物理上就不成立——只能新建。
  • 锁是怎么形成的:一条会话第一次跑完,服务端把这次真正产生的「原生会话号」连同用的引擎一起绑到会话上(第七层第 8 步)。之后这两个值同时有值,锁就生效了。
  • 锁怎么执行:任何跟绑定引擎不同的请求,被强制拨回绑定那个,并往会话里插一条你看得见的说明。不是拒绝请求——消息照落、任务照建。
  • 为什么必须锁:两个命令行 AI 各自用自己的格式存对话记录,互相读不了对方的。换引擎之后系统去找「能续上的原生会话」时会得到空,于是开一条全新的——那侧记忆从零开始
  • 不要这道锁会怎样:你在界面上顺手换个引擎,下一轮 AI 完全不记得前面聊的事,而你以为它记得。它会重新问你已经答过的问题、重新做你已经否掉的方案。
services/orchestrator/server.mjs:4857-4872 强制回正 + 可见说明 services/orchestrator/server.mjs:3620-3634 首次绑定原生会话与引擎 docs/91:26 长期约束 C1「一条会话锁死一个引擎」(2026-08-15 拍板)

判据 3 的真实证据(可自己复核)

这不是推理,是实测出来的:会话 sess_299b637d8fa4 的 31 个任务,跨了 4 个不同的原生会话 id

  • 08-13 16:54 → 08-14 05:40 用的是 codex 的 019ffb70
  • 08-14 10:58 变成了 claude 的 b6cedb9d
  • 08-14 15:32 又变回 codex 的 01a000e7

规律:每一次换引擎,都必然开一条新的原生会话,那侧记忆从零开始。这条线反复出现的「重切阶段 / 写交接文档 / 返工」,根因就是这个。

而且已经排除了另一种解释:核实过任务的提示词与派发出去的任务书逐字节一致——所以不是 AI 装傻或偷懒,是记忆真断了。这一点很重要:如果误判成「AI 不好好干」,你会去改任务书措辞,而那完全没用。

docs/91:36-42 C1 的根因段(实测数据原文)
不换引擎?还有第三道否决,也是最阴的一道
4
否决判据
这条会话的工作副本,还新鲜吗?否决权物理约束
结论先说:工作副本是这条会话第一次派活那天从主干切下来的,之后永远不会自动跟随主干。所以一条放了几周的老会话,里面的 AI 看到的「主干」是它出生那天的主干——你昨天刚写好的文件,它根本看不到
  • 机制:建工作副本时,如果这条会话的分支已经存在,就直接挂上复用(好让历次改动累积);只有分支是全新的才从主干切一份。
  • 关键的「没有」:我在整个工作副本模块里搜过 mergepullrebase——0 处命中。也就是说没有任何代码会去把主干的新变化拉进老副本。
  • 不要这个机制(即每次都跟主干)会怎样:那样会把别人正在改的半成品拉进你的副本,反而制造第 2 条那种撞车。所以「不自动跟随」本身是合理选择——问题在于没人提醒你它已经过期了
workers/mac-worker/worktree.mjs:158-174 分支存在则复用,全新才从主干切 workers/mac-worker/worktree.mjs 全文搜 merge/pull/rebase = 0 处

判据 4 的真实证据(本机实测,今天就能复核)

今天这台机器上一共有 159 条会话工作副本。主干当前停在 f2aef57(2026-08-18)。其中:

  • 7 条副本还停在 9e90abe —— 那是 2026-07-19 的提交,落后主干 152 个提交
  • 9e90abe 那个版本里:design/DESIGN.md 的第一行写的是「Workbench 设计系统 DESIGN.md(v1)」,而且里面声明的真源文件是 design/tokens.css
  • 更直接的一条:design/tokens.micro.css 在那个版本里根本不存在(git 的原话:「路径 design/tokens.micro.css 在磁盘上,但是不在 9e90abe 中」)。这个文件是 2026-08-15 才随 v2 规范一起加进来的。

所以那个事故是这样发生的:让设计角色「按刚定稿的 DESIGN v2 出稿」,但它那条老会话的副本里躺着的是 v1、v2 的 token 文件根本不存在。它读不到新规范,只能拿手边的旧稿凑——它没有说谎,它是真的看不到

怎么避免:派活给一条老会话之前,先确认它的副本里包含这一轮要读的那些文件(尤其是最近才新增的文件)。不包含就新建一条会话——新会话会从当前主干切,一定是最新的。

git worktree list = 159 条 session/ 副本(实测) git log 9e90abe..f2aef57 = 152 个提交(实测) git show 9e90abe:design/DESIGN.md 首行 =「(v1)」(实测) git cat-file -e 9e90abe:design/tokens.micro.css → 不存在(实测)
四条都过了,才是真的可以复用
5
结论
四条的合成结果
  • 复用(--session <旧会话号>:要旧记忆 + 不撞别人的文件 + 不换引擎 + 副本里有这一轮要读的文件。四条全中才行。
  • 新建(--title "…":其余任何情况。新建的代价只有一个——得把背景重讲一遍;而误判复用的代价是别人的成果被静默盖掉、或者 AI 按看不见的旧文件瞎做一轮。拿不准就新建。
  • 要连着做几轮又怕撞文件:塞进同一条会话排队(--on-busy queue),不要开两条并行。
图 8b · 这四条现在记在哪(如实标注:只有两条进了受管清单)

已进 docs/91 长期约束清单

git 跟踪,换人接手会读到
  • 判据 3 引擎锁 = 清单第 C1 条(docs/91:26),并且下面附了那段 31 个任务跨 4 个原生 id 的实测证据(:36-42)。
  • 另有一条相关的:派发前必核 worker 在线 = 第 C8 条(docs/91:33),对应第七层陷阱①。

docs/91 是「总控长期约束的唯一落点」,进 git、不会被任何进程覆盖——这份文件本身就是为了解决一次事故才建的:当时总控为了记一条长期指令,手写了一个伪装成平台产物的文件,而那个文件会在下一次运行时被无条件覆盖,那条指令即将静默消失。

docs/91:1-20 这份文件的由来 docs/91:26 C1;docs/91:33 C8

还没进清单的两条

只活在工作区规则与总控记忆里
  • 判据 2「会不会撞同一文件」:只写在工作区的 CLAUDE.md(「物理约束核对」那一段)和总控自己的记忆里。docs/91 的 C1–C9 里没有这一条。
  • 判据 4「工作副本新鲜度」哪里都没有正式记录——不在 docs/91,也不在 CLAUDE.md。它目前只存在于总控的记忆和这次实测里。

为什么这是个问题:这两条都是否决权级别的判据,而且都会造成静默损失(成果被盖掉、AI 按旧文件瞎做)——恰恰是最需要写进受管清单的两条。按 docs/91 自己定的规矩,长期约束一律该写进那份文件。

(这是本图对现状的如实标注,不是本图在提议改动——本稿只讲系统怎么运转,不提改动方案。)

docs/91:26-34 C1–C9 全部九条,无「撞文件」「副本新鲜度」 工作区 CLAUDE.md「接需求时的三步判断」第 3 条

第八层名词解释

7 条
否决权判据
是什么
一种判断的排法:先问一个正向理由(要不要旧记忆),再依次问三个「有没有硬障碍」。任何一个障碍成立,正向理由立刻作废,不再往下问。
起什么作用
防止「我很想让它记得」这种主观愿望压过物理约束。三条否决全是机制层面的事实,不是偏好——愿望再强也改不了它们。
不要它会怎样
会按「记忆最重要」一路复用下去,然后撞在合并、引擎锁、或者陈旧副本上,而且这三种失败都是静默的:没有报错、没有冲突提示,只有做出来的东西不对。
会话分支session/<会话号>
是什么
一条 git 分支,名字就是 session/ 加上会话号。一条会话一条分支,全局唯一(会话号本身唯一),所以多个项目之间也不会撞名。
起什么作用
它是「隔离单位=会话」这句话的物理落点。这条会话历次任务的改动都堆在这一条分支上。
不要它会怎样
所有会话在同一条分支上改,任何两件事都不能同时做。代价:分支之间的差异最终要靠人来合,而合的方式是逐文件拷贝、没有冲突提示。
原生会话native session,一个 UUID
是什么
命令行 AI 自己存在你 Mac 上的那份对话记录,有它自己的编号。跟这套系统的「会话」是两个不同的东西:一条系统会话指向一条原生会话。
起什么作用
AI 的真实记忆住在这里(第三层讲过)。系统会话只是记着「我绑的是哪一条」。
不要它会怎样
续聊只能靠把历史拼进提示词,而这套系统有一条明确的反对意见(零拼接不变量):提示词必须等于你这条消息的原文。关键限制:两个 AI 各存各的格式,互相读不了——这就是引擎锁存在的全部原因。
引擎锁docs/91 长期约束 C1
是什么
一条会话跑完第一个任务之后,用的那个 AI 就被绑死了。之后任何换引擎的请求都被拨回来,并插一条可见说明。
起什么作用
让你不会无意中把这条会话的记忆清零。它的口号是「记忆跟着事项走,不跟引擎」。
不要它会怎样
下一轮 AI 从零开始,而你以为它记得。实测证据:一条会话的 31 个任务跨了 4 个原生 id,每次换引擎那侧记忆归零——而任务书逐字节没变,所以问题不在任务书。
副本新鲜度(fork 时刻)
是什么
指一份工作副本是哪一天从主干切下来的。分支已存在就一直复用那份,从不重新切;只有全新分支才从当前主干切。
起什么作用
让这条会话历次任务的改动稳定累积,不会被主干上别人的半成品打乱。
不要它会怎样
反过来看代价更重要:老会话看到的是老代码。实测本机 159 条副本里有 7 条还停在 2026-07-19、落后主干 152 个提交,那个版本里连 design/tokens.micro.css 都不存在。派活前不核这一点,AI 就会「按它看得见的东西」交一份你没要的稿子。
静默失败
是什么
指一类不报错的失败:程序正常退出、界面一切正常、AI 也说做完了,但结果是错的。
起什么作用
——它没有作用,它是这一层要防的东西。本层三条否决判据对应的三种失败全是静默的:成果被逐文件拷贝静静盖掉、记忆断了但 AI 照样自信作答、副本陈旧但读文件不会报「这个文件是旧的」。
不认识它会怎样
你会一直在「AI 为什么不听话」这个方向上找原因(改措辞、加强调、换模型),而真正的原因在物理层,改提示词一点用都没有。
受管清单docs/91-gm-standing-instructions.md
是什么
一份 git 跟踪的 Markdown,专门装「用户拍板过、今天仍在生效」的长期约束。目前九条(C1–C9),每条都带出处和拍板日期。
起什么作用
换人、换 AI、换引擎接手时,先读这一节就能知道有哪些不能碰的红线。它明确规定:长期约束一律写进这里,禁止手写或伪造平台自动生成的产物
不要它会怎样
约束只活在某条会话的记忆里,换引擎就丢。这份文件本身就是被一次事故催生的:当时为了记一条长期指令,手写了一个伪装成平台产物的文件,而那个文件下一次运行就会被无条件覆盖。如实标注:本层四条判据里只有引擎锁进了这份清单,撞文件与副本新鲜度还没进。
第九层

文件目录与关键数据结构

前八层反复提到文件路径,但只解释了名词。这一层回答两件具体的事:把这套系统跑起来,最少需要哪些文件、每个文件干什么;以及那些被叫做「记忆」和「需求」的东西,在磁盘上到底长什么样。目录树只画跑得起来所必需的路径,无关文件不画。

图 9a · 最小可跑集:三个进程各自需要哪些路径(颜色=哪个进程要它;少了带色的那些就跑不起来)
调度台要它 常驻程序要它 前端要它 三者共用 不在仓库里(在你机器的家目录) 工作区根 · codexworkspace/ codexworkspace/ 一台机器上所有项目的父目录。这一层不是任何项目的一部分,但两样东西住在这里。 .claude/skills/ 技能包(本稿用的 prototype-schema 就在这里)。放工作区级=跨项目复用,不随某个仓走。 .claude/settings.local.json 权限白名单:哪些命令允许自动跑、哪些路径禁读。它是本机唯一真正拦得住命令的闸。 agents/<角色>/AGENTS.md 角色身份卡(另有同目录 CLAUDE.md)。AI 进到这个目录自己去读,不靠往提示词里注入——这就是「零拼接」。 agents/<角色>/memory.md 角色记忆,运行时从台账物化出来;`.gitignore:24` 写着 `agents/*/memory.md`,不入库。 项目仓 · personal-workbench/ package.json 三个进程入口都写在这里:`api:dev`(:17)、`mac-worker:dev`(:19)、`mac-worker:slots`(:20)。 services/orchestrator/ 调度台:排队、记账、发活。它是唯一写台账的人,自己不干 AI 的活、不碰你的代码。 server.mjs 进程入口。线上由 systemd 起:`ExecStart=/usr/bin/node <远程目录>/services/orchestrator/server.mjs`(`cc-deploy-api.mjs:124`)。 storage.mjs 台账读写:15 个集合、九类字段白名单、保留策略都在这一个文件里。 workers/mac-worker/ 常驻程序:跑在你自己 Mac 上,每 2 秒去领活、起命令行 AI。两个部署脚本都不同步它 worker.mjs 单进程入口(`mac-worker:dev`)。加 `--once` 只跑一次就退出,用来排障。 supervisor.mjs 班组长(`mac-worker:slots`):按「该起几个工人」拉起多个 worker。只扩不缩(第二层)。 packages/contracts/ 共享契约:实测扫过三个入口的相对引用,唯一的跨目录依赖就是这里,别无其它。 index.mjs 枚举与判定:run 的 8 个状态、分支命名、该不该给工作副本、backlog 评审三态。 rate-limit.mjs 额度耗尽后的重试策略(等多久、最多几次)。两侧都要按同一套算,所以放共享包。 registry/ 名册(yaml):`agents/` 角色卡 · `runners/` 三个引擎 · `workers/` 两个工人 · `providers/` · `workflows/`。 apps/web/index.html 前端:一个 HTML 文件。部署时只上传它 + `vercel.json` + `package.json` 三个文件。 scripts/ 固定入口脚本:部署(`cc:deploy:*`)、门禁(`cc:verify`/`cc:qa`)、派活(`wb:dispatch`)。不许手搓命令。 infra/inventory.json 非密基础设施事实:域名、VPS、Vercel 项目、DNS。密钥另存在不进版本库的目录。 workbench-data/store.json 台账。默认路径由 `server.mjs:120` 算出;线上被指到 `<远程目录>/workbench-data/store.json`(`cc-deploy-api.mjs:72`)。 该目录 `.gitignore` 是 `*`,数据不入库。 家目录 · ~/ (不在仓库里,跟着这台机器) ~/.claude/projects/<槽>/memory/ 总控(GM)的 CLI 记忆目录:41 条 .md + 1 个 MEMORY.md 索引(GM 实测)。槽由启动目录决定,不同目录各一份(图 9c)。 ~/.claude/projects/<cwd槽>/*.jsonl claude 的原生会话记录,按工作目录分槽。目录一挪,续聊就找不到这条会话。 ~/.codex/sessions/ codex 的原生会话记录,按年/月/日分目录,与工作目录无关 这三个都不在版本库里:换一台机器,仓库能 clone,但这三样从零开始 —— 记忆和对话都不会跟着走

怎么看「少了哪个就跑不起来」:紫色是调度台的、青色是常驻程序的、蓝色是前端的、黄色三者共用。黄色那几行是最不能少的——尤其 packages/contracts/,它是三个入口唯一的跨目录依赖。

依赖面实测很干净:三个进程入口的相对引用只指向自己同目录 + ../../packages/contracts,没有别的跨目录依赖。所以「最小可跑集」不是估的,是扫出来的。

灰色虚线那三行不在仓库里。这一点最容易踩:clone 一份仓库并不会带上记忆和对话记录,它们跟着这台机器的家目录

图 9b · 四处「记忆」的区别(用户最容易混的一处:它们跟着的东西完全不同)
① GM 的 CLI 记忆 41 条 .md + MEMORY.md 住在哪 ~/.claude/projects/<槽>/memory/ <槽>=启动时的工作目录(图 9c) 跟着谁 跟着人 + 启动目录 同一个人换目录启动=换一份 谁能写 总控自己写(Write 工具) 会不会没 不会被清理;但换机器 =从零,仓库带不走 ② agent_memory 台账里的一个集合 · 9 字段 住在哪 store.json 的 agent_memory 跟着谁 跟着角色(按 agent_id 分) 谁能写 只有调度台,且过 9 字段白名单 会不会没 不在保留策略清理范围内; 但物化出的 memory.md 会被覆盖 ③ 长期约束清单 C1–C14 十四条,入 git 住在哪 docs/91-gm-standing-…md 跟着谁 跟着项目(版本库里) 谁能写 人手写;禁止伪造平台产物 会不会没 不会。它就是为「会被进程 无条件覆盖」而建的落点 ④ 原生会话文件 不是「记忆」,是原始记录 住在哪 ~/.claude/projects/ 与 ~/.codex/sessions/ 跟着谁 跟着这条对话 谁能写 命令行 AI 自己写;我们只读 会不会没 换引擎=换一条新的、空的 (第三层引擎锁就是防这个) agents/<角色>/memory.md 派活时物化 · 不入库 · 下次覆盖 ← ② 是真相,这个文件只是给 AI 读的一份副本

一句话分清:① 跟着人 + 启动目录、② 跟着角色、③ 跟着项目、④ 跟着这条对话。问「为什么它忘了」之前,先确定问的是哪一处。

④ 严格说不是记忆,是对话的原始记录——系统只读、从不改写(第三层)。把它当记忆去「清理」或「搬运」,就会撞上引擎锁那类问题。

② 与它下面那个 memory.md 的关系是真相与副本:台账里那 9 个字段是真相,文件只是派活时物化出来给 AI 自己读的一份,会被下次运行无条件覆盖。

图 9c · 上图 ① 的补充:CLI 记忆不是一份,是六份——按启动目录分槽,互相看不见(GM 实测)
启动时的工作目录 决定读哪一个槽 槽名的算法 启动目录的绝对路径, / 全换成 - 例:从 /Users/linoyeung/ Documents/codexworkspace 启动 → 落在第 1 行那个槽 ~/.claude/projects/ 下实测有六个槽(下面只写槽名,前缀省略) -Users-linoyeung-Documents-codexworkspace 41 条 ← 本会话在这槽 -Users-linoyeung-Documents-codexworkspace-personal-workbench 25 条 最后改于 2026-08-11 -Users-linoyeung-Documents-CodeProject-AgentPlatfrom 6 条 -Users-linoyeung-Documents-CodeProject-----demo 3 条 -Users-linoyeung-Documents-codexworkspace-open-design 2 条 -Users-linoyeung-botmux-sandbox 0 条 六个槽 互相看不见 1 · 从 personal-workbench/ 启动的会话,读到的是那 25 条看不到本槽的 41 条;反过来也一样。 2 · 所以「跨会话必须知道的结论」只写进 CLI 记忆目录是不够的——它只在写下它的那一个槽里有效。 3 · docs/91-gm-standing-instructions.md(入 git)是唯一能跨槽共享的长期记忆载体——这就是它存在的理由。

槽名怎么来的:把启动时的工作目录绝对路径里的 / 全换成 -。从 /Users/linoyeung/Documents/codexworkspace 启动,就落在 -Users-linoyeung-Documents-codexworkspace 这个槽——也就是本会话读到的那 41 条。

那 25 条最后修改于 2026-08-11(已经沉了),其中四条至今有效,已并入 docs/91C11–C14。这是「跨槽共享只能靠入 git 的文件」的一个现成例子。

关键数据结构 · 记忆类

2 张表 + 真实样例
表 1 · GM 的 CLI 记忆:单条 .md 的前置元数据(样例逐字取自真实文件)
--- name: one-session-one-engine description: "架构硬决定(用户2026-08-15):一条会话锁死一个引擎,换引擎=明确新建会话;记忆跟着事项走(合约卡)不跟着引擎" metadata: node_type: memory type: project originSessionId: 32b47fe9-8604-4955-aaa6-459649dd16f3 modified: 2026-08-15T03:15:55.938Z --- 正文:用 [[wikilink]] 互链其它记忆条目 出处:~/.claude/projects/<工作区槽>/memory/one-session-one-engine.md(逐字,未改)
字段它是干什么的
name这条记忆的短名,同时是文件名(去掉 .md)。[[wikilink]] 靠它互链。
description一句话摘要。召回时先读它判断相关性,所以它决定这条记忆会不会被想起来。
metadata.node_type固定 memory。标明这是记忆节点,不是别的图谱节点。
metadata.type分类。GM 实测该目录只出现三种:project(在做的事)/ feedback(用户给的工作方式纠正)/ reference(外部资源指针)。
metadata.originSessionId这条记忆是在哪次对话里写下的。用来回溯「当时为什么这么记」。
metadata.modified最后修改时间(UTC,带 Z)。时区写法本身是个识别线索:平台产物只产 UTC,写成 +08:00 的就是人手写伪造的。
正文事实本体。feedback/project 类要跟 Why(为什么)与 How to apply(怎么用)两行。
另一份:MEMORY.md 索引(每个会话开场被加载)
- [绝不为讨好而骗用户](never-lie-to-please.md) — 硬规矩(用户2026-07-27命令每次加载):核心需求没做第一句说没做… - [Agent目录与UI三层结构](agent-dir-and-ui-hierarchy.md) — Agent实体化落地形态:新增 codexworkspace/agents/<role>/ 家目录… 出处:同目录 MEMORY.md(逐字取前两行)。扁平列表,每行「- [标题](文件名.md) — 一句话钩子」。

为什么要分成「索引 + 一条一文件」两层:索引每次开场全量加载,所以必须短;正文按需才读。索引里那句钩子写得好不好,直接决定这条记忆会不会被想起来。

表 2 · 台账里的 agent_memory:9 个字段就是全集(封闭白名单)
字段它是干什么的
id这行记忆的编号。
agent_id属于哪个角色。这就是「记忆跟着角色走」的落点。
scope作用范围(个人 / 项目 / 工作区 / 某角色自己)。角色卡里的 memory_scopes 声明它能读写哪些范围。
project_id跨项目隔离:A 项目的角色记忆不会漏到 B 项目。
workspace_id工作区级隔离(可空)。
key这条记忆的键名,用来定位与覆盖。
content记忆正文。
created_at / updated_at建立与最后更新时间,由台账写入,不由调用方控制。

「封闭白名单」的意思:写入时逐个字段判断,不在这 9 个里的键直接丢掉。所以这张表就是字段全集,不是「目前已知」。services/orchestrator/storage.mjs:185-195 定义storage.mjs:812-813 写入边界

不在保留策略的清理范围内(那套只清终态 run 的事件与行)。但它物化出来agents/<角色>/memory.md 会被下一次运行覆盖,且不入版本库。

关键数据结构 · 需求管理类

3 张表 + 1 条已知断链
表 3 · 待排期需求 backlog_items:15 个字段(封闭白名单,即全集)
字段它是干什么的
id · title · description编号、标题、需求描述(描述上限 4000 字)。
assignee_agent_id负责这条需求的角色。
priority优先级,只能是 p0/p1/p2,默认 p2
review_status整个契约层唯一真会拦人的字段:三档 pending_review/needs_clarification/reviewed,新建默认未评审;不等于 reviewed 就拒派。
review_notePM 的评审结论文字。
open_questions待澄清项清单(最多 20 条、每条 500 字)。没清零时界面上要你显式勾选才让推进。
clarify_session_id为这条需求专门开的澄清会话。只由澄清端点内部写,不进 PATCH 白名单——所以它是「澄清过没有」的可靠依据,不靠猜文档标题。
status流转状态:backlog(待排期)/dispatched(已派)/closed。只有待排期的能派。
source_doc · doc_url来源文档路径与外链(飞书链接走这里,飞书只做信息仓)。
linked_run_id首次派发时由服务端回写的单向指针,指向那次任务。
created_at · updated_at建立与最后更新时间。

出处 services/orchestrator/storage.mjs:75-94。注意 review_note/open_questions 可经 PATCH 写,clarify_session_id 不行——这个区别写在代码注释里,是刻意的。

表 4 · 编排状态 orchestration_state:13 个字段(每个项目一行)
字段它是干什么的
id · project_id · created_at身份三件套,建后不可改(连 PATCH 也改不了)。
owner_agent_id这份编排状态归哪个角色(总控)。
workspace_id预留、可空。当前一个项目一行,将来按工作区拆分时才用。
active_goal当前在推的目标。
backlog_refs · session_refs关联的需求编号与会话编号数组。只存字符串编号,写入时不做存在性校验——指向一个已删的行也不会报错。
pending_acceptance等你验收的轮次清单,每项只留 round_id/summary/status 三个键。
open_questions编排层的待澄清项。
updated_by · updated_at谁改的、什么时候改的;时间由台账写,不由调用方控制。
public_view_token只读公开视图的不可猜凭证。建行时铸一次,之后永不可改,也不能从外部 PATCH。

出处 packages/contracts/orchestration-state.mjs:30-48(字段集):54-59(不可改字段)。它不是长期约束清单——那份在 docs/91,入 git、人手写。

表 5 · 契约文档的状态行、run 的保留策略、以及一条已知断链
东西形态与出处已知问题
契约文档docs/NN-<主题>-roundN-<内容>.md,头部一行 Status: …;派活时整篇当任务描述灌给 AI。格式三分叉:行首英文 Status: 50 份、引用块中文「状态:」10 份;写回脚本只认行首英文,对新契约已失效。第三种是哪种待核
run 的事件流终态 run 结束超 2 小时 → 清空事件流,保留任务行与最终答案。storage.mjs:425默认关闭,只有 WORKBENCH_STORE_PRUNE=1 才启用(线上开、本地关)storage.mjs:415-416
run 的整行终态 run 结束超 7 天 → 整行删除,事件一起删。storage.mjs:426结论只活在事件流里的,两小时后就没了;只活在任务行里的,七天后就没了。结论必须另存成产物或文档
需求 ↔ 任务只有 backlog 侧的单向 linked_run_id结构性断链:在 services/+schemas/+packages/ 里搜 backlog_id命中 0 处——run 上没有回指需求的字段。至于「非空 linked_run_id 有 0 条」,那是 docs/90:7-10 的记录,本轮没有独立复测(隔离工作仓没有生产台账)待核

为什么断链要单独标出来:它意味着「这个需求派出去的那次任务跑成什么样了」只能从需求那一侧单向查,反过来拿着一个任务问「它是为哪条需求跑的」查不到

第九层名词解释

10 条
最小可跑集
是什么
把这套系统跑起来所必需的那些路径,去掉文档、设计稿、测试、历史产物之后剩下的部分。本层图 9a 画的就是它。
起什么作用
让你知道「动哪个文件会让整套东西起不来」。也让换机器部署时清楚要带什么。
不要它会怎样
面对一个上千文件的仓库,分不清哪些是骨架、哪些是围绕骨架长出来的东西,提需求时容易要求改动其实不能动的地方。
工作区根codexworkspace/
是什么
一台机器上所有项目的父目录。它本身不是任何项目,但两样东西住在这一层:.claude/(技能包与权限白名单)和 agents/(角色家目录)。
起什么作用
让技能包和角色身份跨项目复用,不必在每个仓里各放一份。
不要它会怎样
每个项目各自维护一份角色卡与技能包,改一处要同步好几遍,很快就漂移。
工作区级配置.claude/settings.local.json
是什么
一个 JSON 文件,列出哪些命令允许自动执行、哪些路径禁止读取(如 .private/**.env*)。
起什么作用
本机唯一真正拦得住命令的闸。角色卡里的权限声明只是拼进提示词的文字(第四层「只是文档」那一类),真闸在这里。
不要它会怎样
AI 能跑任意命令、读任意文件。反过来,它配得太严也会让本该自动的步骤卡住——本稿上一轮就因为它不放行 node <外部脚本> 而跑不了校验命令。
角色家目录agents/<角色>/
是什么
每个角色一间的固定目录,里面有 AGENTS.mdCLAUDE.md(身份卡,入 git),以及运行时物化出来的 memory.md(不入 git)。
起什么作用
AI 进到这个目录自己去读身份,不靠往提示词里注入——这就是「零拼接」不变量的实现方式。也让命令行上直接 cd 进去就等于以该角色对话。
不要它会怎样
身份只能靠拼提示词,而拼接会污染「任务的提示词等于你这条消息的原文」这条不变量,之后就分不清 AI 是按你的话做的还是按注入的话做的。
共享契约包packages/contracts/
是什么
调度台与常驻程序共用的一小包代码:枚举(run 的 8 个状态)、命名规则(分支名)、判定函数(该不该给工作副本)、额度重试策略。
起什么作用
让两侧对同一件事的判断不可能对不上。实测扫过三个进程入口的相对引用,它是唯一的跨目录依赖
不要它会怎样
同一套状态名在两边各写一份,早晚一边加了状态另一边不认,出现「工人说在跑、调度台说没这个状态」这类对不上的故障。
台账文件workbench-data/store.json
是什么
一个 JSON 文本文件,15 个集合全在里面。默认路径由 server.mjs:120 算出(可用环境变量覆盖),线上被部署脚本指到远程目录下。
起什么作用
让整套系统重启后还记得事。所有写入都收口在一个模块里,将来换真数据库只改那一处。
不要它会怎样
重启即失忆。它所在的目录 .gitignore*,所以数据从不入库——这也意味着 clone 一份仓库拿不到任何业务数据。
GM 的 CLI 记忆目录~/.claude/projects/<槽>/memory/
是什么
总控自己的记忆:一条一个 .md 文件(GM 实测本槽 41 条),外加一个 MEMORY.md 扁平索引。每条有前置元数据,正文用 [[wikilink]] 互链。<槽> 不是固定的一个:它是启动时的工作目录绝对路径把 / 换成 -(例:从 /Users/linoyeung/Documents/codexworkspace 启动 → 槽 -Users-linoyeung-Documents-codexworkspace)。实测本机有六个槽,见图 9c。
起什么作用
让跨会话的经验不丢:索引每次开场被加载,正文按需读。
不要它会怎样
每次新会话都要重讲一遍工作方式与踩过的坑。但要清楚它跟着的是「人 + 启动目录」,不是项目:换一台机器就是从零,仓库带不走;同一个人从不同目录启动,读到的也是不同的记忆——六个槽互相看不见。所以要跨会话必守的结论,只写进这里不够,得写进入 git 的 docs/91-gm-standing-instructions.md
角色记忆store 里的 agent_memory(9 字段)
是什么
台账里的一个集合,9 个字段就是全集(封闭白名单,名单外的键写入时被丢掉)。派活时被物化成角色家目录里的 memory.md
起什么作用
让记忆跟着角色走,并且按项目、工作区隔离。
不要它会怎样
角色的经验只能塞进任务书或提示词,一换会话就丢。最容易混的一点:它和上面那个 GM 的 CLI 记忆是两回事——一个跟着角色、住在台账,一个跟着「人 + 启动目录」、住在家目录且按启动目录分成互不可见的六个槽
长期约束清单docs/91-gm-standing-instructions.md
是什么
一份 git 跟踪的 Markdown,装「用户拍板过、今天仍在生效」的长期约束(十四条 C1–C14,其中 C11–C14 是从另一个记忆槽并进来的),每条带出处与拍板日期。
起什么作用
让长期约束跟着项目走:换人、换 AI、换引擎接手先读这一节。
不要它会怎样
约束只活在某条会话的记忆里,换引擎就丢。这份文件本身就是被一次事故催生的:当时为了记一条长期指令,手写了一个伪装成平台产物的文件,而那种文件下一次运行就会被无条件覆盖。
原生会话文件~/.claude/projects/<cwd槽>/*.jsonl · ~/.codex/sessions/
是什么
命令行 AI 自己写在你机器上的对话记录。claude 按工作目录分槽存,codex 按年/月/日存、与工作目录无关。
起什么作用
它是「这条对话到底说了什么」的唯一真相。台账里只存一个指针(原生会话号 + 哪个 AI)。
不要它会怎样
就得把历史抄进台账,那会造出第二个真相源(已被明令否决)。它严格说不是「记忆」,是原始记录——我们只读、从不改写;换引擎会开一条新的空的,这就是引擎锁存在的全部原因。
附录

纠正记录:交办骨架里与代码不符的地方

画这份图的规矩是「凡与代码不符,以代码为准并在图上标注」。以下 19 条都已按代码更正,正文里也都标了出处。①–⑨ 来自第一轮(前五层),⑩–⑲ 来自第二轮追加的三层(第六 / 七 / 八层)。

① 骨架说:「槽位怎么自动扩缩」
更正:只扩,不缩。代码里把「回收 / 排空」明确写为本轮范围之外,扩容函数只做加法、永不杀进程。下限默认 1,上限默认 3,每 5 秒问一次。workers/mac-worker/supervisor.mjs:167-172workers/mac-worker/supervisor.mjs:236-245
② 骨架说:「保留策略:终态 run 超 2 小时清事件、超 7 天整行删除」
更正:两个数字对,但漏了一道开关——这套清理默认是关着的。只有 WORKBENCH_STORE_PRUNE=1(或代码显式传参)才启用;线上由部署脚本写入,本地和测试环境是关的(测试要靠"不清理"来验审计留痕)。services/orchestrator/storage.mjs:415-416scripts/cc-deploy-api.mjs:77
③ 骨架说:「权限分层(operator / worker / agent 三种身份)」
更正:第三种不是 agent,代码里没有 agent 令牌这个东西。第三种是单会话临时凭证wsat_ 前缀,只对绑定的那一条会话有效,默认 1 小时过期,存内存重启即失效)。搜 AGENT_TOKEN 命中 0 处。services/orchestrator/server.mjs:410-437services/orchestrator/server.mjs:191
④ 骨架说:「引擎锁:一条会话绑定后不能换引擎」
更正:结论对,机制不是拒绝,是「强制拨回 + 插一条可见说明」。请求照样受理、消息照样落、任务照样建,只是本轮用的 AI 被拨回绑定那个,并往会话里写一条说明消息。触发条件是「原生会话号」和「绑定的 AI」两个值同时有值。services/orchestrator/server.mjs:4855-4872
⑤ 骨架说:「web 通过 worker 按需读取(claude 在 ~/.claude/projects,codex 在 ~/.codex/sessions)」
更正:两个路径都对,但目前只有 claude 真的通。codex 的文件定位代码已就位,可是调度台对非 claude 的上传直接回 409「不支持这个 AI 的对话读取」——注释写明「现在接受它的数据等于偷偷预实现下一轮范围」。services/orchestrator/server.mjs:3216-3221
⑥ 骨架说:「每条 session 独占一个 git 分支与旁路工作副本」
更正:有四种情况不建工作副本。① 连接器授权那类杂务任务;② 收编来的原生会话(留在用户自己的目录,挪走续聊就断);③ 家目录型角色(挪走会把 claude 按目录存的会话记录打碎);④ 没绑项目或没绑会话的老任务。这四种都退回主仓根目录或各自的固定目录。packages/contracts/index.mjs:275-288
⑦ 骨架说:「事件怎么回流(/runs/:id/events)」
更正:这只是「存下来」那一跳,漏了「你能实时看到」那一跳。调度台收到事件后还会在进程内广播给所有连着 GET /runs/:id/stream 的浏览器;这条推送只活在内存里,重启会断,重连时靠快照恢复。services/orchestrator/server.mjs:3134-3167services/orchestrator/server.mjs:218
⑧ 骨架说:「事项卡(docs/90/92,零行代码)」
核实成立(搜 contract_card 命中 0 处),但这个说法会误导:仓库里已经上线了一个 tasks 集合 + /tasks 接口 + 可编辑字段白名单的「任务卡看板」(docs/69)。两者名字像、完全不同物。本图在第五层把这一点单独标了出来,避免下一个需求建在错的那个上面。
⑨ 代码注释自身的一处不一致(不是骨架的错,但按规矩要标)
台账那段事故记录写的是「worker heartbeats fire one every ~2s」,但工人的心跳间隔实际是 15000 毫秒;每 2000 毫秒 一次的是领活轮询(而领活也会更新工人行、进而触发写盘,所以"每约 2 秒一次写"这个结论本身是对的,只是把它归给了心跳)。services/orchestrator/storage.mjs:406 注释措辞workers/mac-worker/worker.mjs:33-34 两个真实间隔
⑩ 骨架说:「谁写契约:PM 角色(registry/agents/product-research-agent.yaml)」
更正:按 registry,写契约的是技术负责人角色,不是 PM。PM 的角色卡明写它只出提案(问题 / 证据 / 最小范围 / 待定项),然后「Hands the chosen proposal to tech-lead-agent, who turns it into an armed round contract」;技术负责人的角色卡才写「drafts the round contract(hard requirements、allowed scripts、forbidden actions、acceptance criteria)」。但这条分工在实践中被破过docs/86 头部逐字写着「撰写:pm-agent」——那份八要素契约确实是 PM 写的。角色卡是拼进提示词的文字,不是程序。registry/agents/product-research-agent.yaml role 段registry/agents/tech-lead-agent.yaml role 段docs/86:5
⑪ 骨架说:「评审门的硬校验在 services/orchestrator/server.mjs 约 4749-4756」
更正:行号错了,那一段是「会话消息端点」的访问校验区,跟评审无关。评审门实际在 :5330-5336(派发路由本身在 :5311)。附带补一条骨架没提但很重要的事实:这道检查被刻意排在读请求体、解析会话、创建任何任务之前,注释原文是「保证零副作用(item 仍 backlog、无新 session、无新 run)」——所以一次被拒的派发不会留下空会话或孤儿任务。services/orchestrator/server.mjs:5311 / :5330-5336
⑫ 骨架说:OQ-1 这个编号在 10 份文档里含义各不相同」
更正:结论完全成立,但数字过时了——实测是 18 份,不是 10 份。「10 份」是 docs/90:11 的原话,那是写那份方案当天的计数;今天 grep -rl 'OQ-1' docs/ 命中 18 份。也就是说这个缺陷在被记录之后还在继续扩大。正文图 6b 里列了 7 份的具体含义供核对。grep -rl OQ-1 docs/ = 18(实测)docs/90:11 原文「10 份文档」
⑬ 骨架说:Status: 行已对最新契约失效(只认行首 Status:,而 docs/84/85 改用了引用块写法)」
更正:结论成立,但失效的范围和原因都被说小了。①不只 84/85,是 82 / 83 / 84 / 85 / 88 / 89 这一批;实测全库行首 Status: 的 50 份、用引用块的 10 份。②失效不只因为「在引用块里」——那批文档用的是中文「状态:」,连英文 Status: 这个词都没有,所以哪怕脚本放宽到允许前导 > 也照样改不到。③docs/90:143 把它叫「Status 行格式分叉」并明确决定不修,但没有逐条列出它数的是哪三种;我只实测到文档头的两种,第三种按脚本设计推测应是排期表里的状态格——这一条标「待核」,没有替它编scripts/workbench-set-round-status.mjs:41 只认行首实测 50 份行首 / 10 份引用块docs/90:143
⑭ 骨架说:「前端会主动剥掉它(stripStatusLine)」
核实成立,但要补一个反直觉的后果:那批新契约反而剥不掉。剥除规则是「前导区里以(可含空白的)Status: 开头的行」——引用块写法以 > 开头,既不匹配这条规则,还会直接终止「前导区」。所以 docs/82-89 那批的任务卡标题上会挂着「作者:tech-lead-agent(本轮 run)。状态:…」。同一个写法改动,同时打坏了改状态的脚本和清标题的前端。apps/web/index.html:12343-12360
⑮ 骨架说:「worker 准备工作副本(worktree / 家目录 / 收编原生目录 —— 三态)」
更正:worker 侧实际是四态,而且优先级顺序跟骨架列的相反。真实顺序(从高到低):① 收编来的原生目录(native_cwd优先级最高)→ ② 家目录型角色(agents/<角色号>/)→ ③ 旁路工作副本(worktree_ref)→ ④ 都不满足则退回项目根目录(第四态骨架没提)。前两态优先于工作副本是有原因的:命令行 AI 按目录存对话记录,挪目录等于让续聊失效。workers/mac-worker/worker.mjs:1146-1211 resolveWorkspaceDir
⑯ 骨架说:「10 合并到主干」
更正:这一步不是 git 合并。命令是 npm run cc:merge:run -- <任务号>,它按这个任务自己上报的 changed_files 清单逐文件从工作副本拷回主仓(删除项则删掉),清单外一律不碰。为什么这么做:脚本头部逐字记着事故——此前用 cp -R 整目录覆盖,并行任务互相盖掉成果,一天内发作四次,其中一次删除了批注层两行引用。代价必须一起说:因为不是 git 合并,没有冲突提示——两条会话都改过同一个文件时,后搬的会静静整份盖掉先搬的。这正是第八层判据 2 拥有否决权的原因。scripts/cc-merge-run.mjs:1-17
⑰ 骨架说:「用户验收(只有用户能点,服务端校验主体)」
更正:前半句是规矩,后半句不成立——服务端没有任何主体校验。接口是 POST /runs/:id/review {action:"accept"},它唯一做的校验是「请求带的令牌等不等于那个固定的操作员令牌」,而总控手里拿的就是同一个令牌。搜 accepted_by / user_acceptance 这类记录主体的字段,服务端0 处写入——服务端分不清这一下是用户点的还是总控点的。真正的约束只在提示词层:docs/91:28 C3、总控手册 :55、以及各角色卡 forbidden_actions 里那条 mark_user_acceptance顺带指出:docs/91 C3 那句「服务端校验主体」本身就与代码不符,这份长期约束清单里记着一条做不到的实现声明。services/orchestrator/server.mjs:2952-2977 / :398-402docs/91:28
⑱ 骨架说:「陷阱③ 沙箱可能挡住 agent 写 git 元数据(要 GM 在沙箱外代做)」
更正:「挡写 git 元数据」这一条在整个仓库里找不到任何出处,标「待核」,没有替它编。能核实的沙箱限制是另外三条:无浏览器、open 被禁(docs/65:87);某一轮连 awk、重定向写临时文件、甚至 node --check 都被拦(docs/77:385:621);ls 被拦(docs/30:93)。而「总控在 AI 之外代做收尾」的真实原因是契约级明令,不是沙箱:无人值守轮次的契约写着「dev 禁擅自部署 / 重启,收尾由 GM 执行」。这个区分很要紧:契约是人定的、可以改;沙箱是拦死的、改不了。docs/65:87 / docs/77:385,621 / docs/30:93docs/83:6,287 / docs/84:6
⑲ 骨架说:「标注这四条判据现在记在哪(GM 记忆 + docs/91 长期约束清单)」
更正:docs/91 只收了其中一条。逐条核过 C1–C9:判据 3 引擎锁=C1(:26,还附了 31 个任务跨 4 个原生 id 的实测证据 :36-42);判据 2「会不会撞同一文件」不在 C1–C9 里,只写在工作区 CLAUDE.md 的「物理约束核对」段;判据 4「工作副本新鲜度」哪里都没有正式记录——不在 docs/91,也不在 CLAUDE.md。偏偏这两条都是否决权级、且失败是静默的。docs/91:26-34 C1–C9 全九条
附 · 骨架点名要求核实的一项:「八要素到底有程序校验还是仅口头约束」
核实结论:仅文档规范,0 处程序校验。搜中文「八要素」:services/packages/workers/apps/registry/ 全部 0 处scripts/ 仅 1 处是种子文本描述;其余 4 处在设计稿文案里。搜八个英文字段名,整个代码区只命中 1 行注释。契约文档在派发时是被当成一整段纯文本灌进任务的,服务端不解析、不检查结构——少写一项,系统照样把活派出去。唯一会拦人的是评审标记,而它检查的是一个布尔状态,不检查这八项在不在。grep 八要素 → services/ packages/ workers/ apps/ registry/ = 0scripts/cc-test-task-center.mjs:29(唯一命中的英文字段名,是注释)