APSer-Atelier 的技术架构与选型决策机制
APSer-Atelier 是一个单机部署的控制面加活动账本。本文按真实代码与账本数据,讲它的系统边界、数据模型与两套状态机、受管派发与交互式会话两条写入链路各自的机制和覆盖面、并发与一致性、失败恢复、安全边界、技术选型,以及技术决定怎样登记、拍板和复议。
先说结论
- APSer-Atelier 是我们做制造业深度定制交付时使用的可持续交付底座,工程上是一个单机部署的控制面加一本活动账本:一个 Python 后端进程、一个 SQLite 库、一个 React 操作台。它登记每一件事的状态、谁持有哪个仓库的写入权、待人拍板的事项和每一步的验证记录;代码、文档和决策记录仍以各项目自己的 Git 仓库为准。
- 代码进入项目仓库有两条链路。受管派发链路:Atelier 自己启动无人值守的 Codex 或 Claude 进程,在仓库外的隔离目录里写,由父进程验证和复审后直接提交到主干。交互式会话链路:我们本人在场的 Claude Code 或 Codex 会话,直接在项目工作目录里修改和提交。
- 两条链路的覆盖面相差很大。从 8 月 10 日受管链路第一次运行到 9 月 30 日,我们在 12 个主要代码仓库里共提交 2651 次,经受管链路落地的是 119 次,约 4.5%;其余绝大部分来自交互式会话。
- 两条链路的约束强度也不同。受管链路的每道检查失败即拒绝(fail-closed:检查本身出错或结果不确定,也按不通过处理),执行状态迁移由数据库触发器把关。交互式链路靠钩子:规则命中时拦下,钩子自身出错时放行(fail-open),约束的是流程纪律;其中只有发布前检查失败即拒绝。
- 两条链路在同一个仓库、同一条主干上汇合。一致性靠两件事:活动账本里「同一仓库同一时间只有一个写入者」的互斥,以及落地时对主干引用做比较并交换(compare-and-swap:只有引用仍指向预期的旧值时才更新)。
- 技术决定先登记为待拍板事项,每个选项写明代价;影响架构的再整理成决策记录,写明否掉的备选和复议条件。238 条关键约束以标记注释写在代码旁,由脚本逐条核对。
《APSer-Atelier 的几条原则》讲做事的原则。本文从工程角度讲系统本身:边界与组件、数据模型与状态机、两条写入链路、并发与一致性、失败与恢复、安全边界、选型,以及技术决定怎样做和复议。读者设定为做软件工程的同行和技术负责人。
文中的机制、默认开关和数字,都按本文更新时(2026 年 9 月 30 日)的代码、配置和账本核对过;默认关闭或只在某条链路生效的,写明条件。出于安全考虑,不写主机、地址、端口和凭据。
一、Atelier 在交付里管哪一段?
我们的交付里有三个角色。我们提需求、拍板、在真实环境验收;Claude Code、Codex 等 AI 编程工具作为执行器写代码;APSer-Atelier 作为主控负责组织:接住意图、排队、分配写入权、调度执行器、核对结果、留下证据。APSer-Aps、APSer-One 与客户定制项目的开发,都在这套组织方式下进行。
系统上下文如下。项目仓库是唯一的事实来源,Atelier 只记录活动;两条写入链路在第二节展开:
受管链路
操作台 → 预检 → 执行器进程(隔离目录)
→ 封存 · 验证 · 复审 → 比较并交换落地 → 项目仓库主干
交互链路
会话(人在场)→ 写前钩子向 Atelier 查询写入权
→ git commit → 项目仓库主干
汇合点
Atelier 账本:同一仓库同一时间只有一个写入者
项目仓库:同一条主干
| 组件 | 职责 | 实现 |
|---|---|---|
| 活动账本 | 一件事、运行、待拍板、写入者、验证与交付记录 | SQLite,WAL 模式;库内 81 张表,含只读保留的旧表 |
| 接口与操作台 | 新建任务、预检并启动、答复待拍板、守卫与发布检查总览 | FastAPI,路由层 320 个接口声明;React + TypeScript 操作台 |
| 受管派发引擎 | 预检、隔离写入、封存、验证、复审、落地、交付 | 同一进程内的服务模块 |
| 执行器适配 | Codex、Claude 的固定启动参数、选择与熔断 | 同上 |
| 守卫 | 交互式会话钩子、git 钩子、发布检查 | 共享脚本,各仓库只写一行转调 |
| 模型网关 | 本地模型的知识问答与本地判断 | 统一网关,限并发、有界重试、统一记账 |
部署形态是一个模块化单体:后端服务、核心、路由三层共 193 个 Python 模块,约 12.6 万行;后端测试文件 288 个。进程由 PM2 托管,只监听本机回环地址,经私有组网的 HTTPS 入口访问。停机预留 45 秒,其中 HTTP 请求排空上限 5 秒,其余时间留给在跑的受管执行收尾。
它不运行交付给您的系统,您的系统不依赖它就能独立运行和维护;它不以保存客户资料为目的;代码与运行服务只供我们自己交付使用,不开源,也不作为多租户服务提供。
二、两条写入链路,各覆盖多少?
先把两条链路的差别列清楚。后文把受管派发链路简称「受管链路」,把交互式会话链路简称「交互链路」。
| 项 | 受管链路 | 交互链路 |
|---|---|---|
| 写入者 | Atelier 启动的 Codex / Claude 进程,无人值守 | 我们在场的 Claude Code / Codex 会话 |
| 写在哪里 | 仓库外的私有隔离目录,目录里没有 .git | 项目自己的工作目录 |
| 怎样提交 | 父进程用 git commit-tree 造提交,git update-ref 带旧值更新主干引用 | 普通的 git commit |
| 谁来核对 | 父进程重跑检查;中高风险候选另由另一家执行器只读复审 | 会话内自测,我们本人验收 |
| 约束方式 | 失败即拒绝;关键状态由数据库触发器把关 | 钩子,异常放行;发布前检查失败即拒绝 |
| 实际占比 | 2651 次提交中的 119 次,约 4.5% | 其余绝大部分 |
占比按 Git 历史统计:在 12 个主要代码仓库里,取 8 月 10 日以来进入主干的提交,逐个核对受管链路账本里记录的提交号。
分项目看差别更明显。APSer-Aps 是我们的主战场,同期 537 次提交里经受管链路的只有 2 次;Atelier 自身的仓库 1243 次里有 61 次。
交互链路占主体,原因在两边的成本结构:
- 受管链路每一轮都要先规划、冻结计划,再写、再由父进程跑测试和复审。单次计划声明超过 3 个文件、触及契约路径或含删除,就判为高风险,只能人工启动。
- 9 月下旬的一次独立评估列出了它的吞吐瓶颈:预检规则的词表、声明范围逐字比对、无关提交触发重新规划、同一仓库串行加全量验证、执行器用量额度。
- 交互链路里人在场,可以当场判断业务口径,一次改动可以跨很多文件,方向可以随时调整。制造业定制交付里,需要当场判断业务口径的工作占多数。
9 月 29 日我们为此拍过一次板:产品仓库的任务可以进受管队列,是否走受管链路按任务决定,不强制;交互式会话照常使用,同仓单写入者对两边同样生效。
两条链路最终写的是同一个仓库、同一条主干。这一点决定了后面讲的一致性设计:协调点在活动账本的写入者互斥和主干引用的比较并交换,不在任何一条链路内部。
三、数据模型:一件事和两套状态机
因果链
对象按「一件事怎样被做完」组织:
意图(人提出)
└ 一件事:从提出到验收的完整记录,调度与交接的单位
└ 运行:一次执行尝试,带单调递增的轮次号
└ 执行轮:某个执行器的一次具体进程,可失败、可换执行器
└ 候选改动 · 验证记录 · 复审记录 · 交付记录 · 交接点
运行和执行轮都可以失败、重来、换执行器,一件事本身不变。交互链路里我们本人「持有」一件事时,同样登记在这张表里,只是没有运行和执行轮。
一件事的表结构节选:
CREATE TABLE work_threads (
id TEXT PRIMARY KEY,
status TEXT, -- 8 个状态,见下表
wip_slot TEXT, -- deep / shallow:占哪一类执行名额
projects TEXT, -- 涉及的仓库,JSON 数组,可以跨仓
next_action TEXT, -- 交接点:下一步做什么
next_action_confirmed INTEGER, -- 下一步是否已由人确认
trigger_cond TEXT, -- 交接点:什么条件下接着做
blocked_by TEXT, -- 正在等哪一条跨项目请求
completion_summary TEXT, -- 完成摘要
verification TEXT, -- 验证结果
requested_delivery_stage TEXT -- landed / pushed / activated / live_accepted
);
一件事的状态机:规则写在应用层的唯一写入口
| 当前状态 | 可以迁到 |
|---|---|
| 排队 | 进行中、停放、完成、取消 |
| 停放 | 排队、取消 |
| 进行中 | 等拍板、验证中、受阻、排队、取消 |
| 等拍板 | 进行中、受阻、取消 |
| 验证中 | 进行中、受阻、完成、取消 |
| 受阻 | 排队、进行中、取消 |
| 完成、取消 | 终态,无出边 |
终态集合由这张表派生,全系统判断终态复用同一个常量。唯一的例外是受管返修:一件事已完成、它绑定的那次运行被复审打回时,只允许这次运行把它精确地重新打开,完成摘要与验证结果同时清空,须重新取得。
进入「进行中」前要过四项检查:下一步非空且已由人确认;没有在等的跨项目请求;没有相关的未答复待拍板;名额与同仓写入者(见第六节)。标记完成必须同时写完成摘要和验证结果。
这些规则写在应用层,原因是它们要跨表判断:名额要数别的一件事,写入者要查同仓的其他记录,待拍板要查另一张表。它们放在唯一的写入函数里,在同一个立即写事务(SQLite 的 BEGIN IMMEDIATE,事务开始即取得写锁)内完成。这条约束只对经这个入口写入的数据成立,早期的 31 条旧记录(多数在 7 月)没有完成摘要,不追溯。
执行轮的状态机:规则写在数据库触发器里
执行轮有 14 个状态,合法迁移由一个 BEFORE UPDATE 触发器强制,任何写入路径都绕不过:
reserved → prepared → spawning → executing → validating → patch_ready
→ commit_prepared → applying → committed
validating 还可以到 no_changes / waiting_decision
各阶段可到 blocked / cancelled;spawning 之后的阶段可到 ambiguous
ambiguous 只能到 blocked 或 cancelled
放在触发器里,是因为这张表有多个写入方:执行引擎、重启恢复、交付对账。状态写错一次的后果很具体:一次已经改过主干的执行被当成「还没开始」,重启后再跑一遍。ambiguous 表示「项目主干可能已被改动、结果又无法确认」,它只能被收口,不能被重试。
旁路对象
| 对象 | 作用 | 关键约束 |
|---|---|---|
| 待拍板 | 需要人决定的有界选择 | 选项是 {key, label, cost} 数组,每个选项写明代价;答复连同时间、作答者、途径入账 |
| 停车场 | 暂不处理的事项、跨项目请求 | 跨项目请求带来源项目、来源运行和「是否要等」;要等时来源的一件事写入 blocked_by,启动检查据此拒绝 |
| 诉求账 | 一个会话里人提出的每条要求 | 标记完成必须挂结果引用,否则接口返回 422 |
| 线程事件 | 一件事的追加式事件流 | 与状态迁移在同一事务里写入 |
待拍板答复后,续跑要交还给原来那次运行。这里要防的是重复作答和重复启动:
BEGIN IMMEDIATE
待拍板 open → answered(比较并交换,第二次作答失败)
绑定了运行的:续跑状态写 pending;选择跳过的写 blocked
追加一条线程事件
COMMIT
之后只有一个专用函数能把 pending 改为 starting,同时签发只能用一次的内部凭证。只有能证明「启动还没尝试、回滚全部成功」时才写 retryable;其余不确定的情形保留 starting 并阻断新的受管启动,交给人处理,不自动重放。
四、受管链路:一次改动怎样从预检走到主干
受管链路要解决的问题是:让一个无人值守的进程改代码,同时保证它改不到声明以外的东西、它的「测过了」不被直接采信、落地时不覆盖别人的改动。下面按顺序讲每一步防什么、怎么做、代价是什么。
第一步:预检,先冻结一份声明计划
要防的场景:执行器顺手改了依赖清单或部署配置,事后很难从一大段改动里分辨哪些是任务要求的。
做法:每次派出(首次派出、追加指令、待拍板后的续跑)都先启动一个新的规划进程。它以只读沙箱运行、不读用户配置、不续接会话,按固定 JSON Schema 输出计划:改哪些文件、每个文件是新建、修改还是删除、会跑哪些检查、有哪些仓库外效果、还有哪些不确定项,超时 180 秒。计划连同请求和仓库快照的哈希写入预检表,之后不可修改;同一个幂等键(重复提交只生效一次的请求标识)重试时复用原记录,同一个键换了请求内容则拒绝。
风险分级是确定性的,由路径规则和计划内容判定,不调用模型:
| 等级 | 条件 | 去向 |
|---|---|---|
| LOW | 只改文档和测试 | 可以自动启动 |
| MEDIUM | 有界的一般代码改动 | 可以自动启动;候选须经复审 |
| HIGH | 契约路径、超过 3 个文件、含删除、受限的构建依赖下载 | 只能人工启动;候选须经复审 |
| UNKNOWN | 计划声明范围不完整或有未决项 | 阻断,改清任务描述后重新规划 |
计划或指令里声明了推送、部署、生产环境、凭据等外部效果的,直接以策略拒绝。
代价在判定层。外部效果的识别靠文本规则,偏保守,任务描述需要写清限定语。「自动启动」由一个功能开关控制,代码默认关闭,由我们按阶段打开或关闭,本文更新时处于关闭状态。
第二步:在隔离目录里写
要防的场景:执行器进程如果看得到 .git,就能改钩子、改主干引用、改仓库配置;如果和人在同一个工作目录里写,两边会互相覆盖。
做法:父进程在仓库外建一个权限 0700 的私有目录,按当前 HEAD 的原始对象导出一份文件目录,它只是一个普通目录,不挂在 Git 仓库上,里面没有任何 Git 元数据。计划内的文件在项目工作目录里有未提交改动,直接拒绝。Git 过滤器与属性、特殊文件、未知扩展属性、大小写与 Unicode 规范化冲突,任何一项命中即拒绝。
执行器的启动参数是固定契约。Codex 以关闭钩子、忽略用户配置、忽略规则、禁用网页搜索、禁用子代理的参数启动,环境变量只继承最小集合,网络默认关闭,只对计划里声明的 npm 源开放;Claude 以受限模式、空的设置来源、严格的工具配置启动。这几个参数由测试用例钉住,删掉任何一个,对应用例失败,父进程验证就会拒绝这次候选。
第三步:封存与比对
要防的场景:执行器主进程退出后,逃逸的子进程还可能继续写;父进程如果边读边比,比对的是一棵还在变的树。
做法:执行器绑定在一个进程组里。进程组退出后,父进程以目录描述符加 O_NOFOLLOW 把整棵树完整读两遍,两遍一致才写出一份不可变的封存快照,此后生成补丁、候选和提交都只读这份快照。读取时不采信 .gitignore,也不采信执行器自报的改动清单。
随后比对「实际改了什么」和「声明要改什么」。多改了没声明的文件、改了文件权限位、内容无法校验,一律拒绝;实际风险比计划高,也拒绝。比对通过后生成候选:补丁、候选提交对象和一份权限 0600 的候选清单。
少做声明里的某一项在当前实现里是合法的。这个取舍已经出过问题,写在第十一节。
第四步:父进程验证
要防的场景:执行器报告「测试全部通过」,实际只跑了一部分,或者改动测试配置让一部分用例不再被收集。
做法:验证由父进程在一份物化的候选树上执行,执行器的自述不算数。检查项包括:git diff --check,按提交范围的密钥扫描,候选改到的测试文件先跑、失败即短路,通过后再跑全套,按项目登记的构建命令。Python 项目的候选改到测试收集配置时,父进程先在基线树上只收集不执行、数出用例数,候选实际执行的用例比基线少即判失败。目标仓库有变更记录文件的,候选里必须真的改了它;改到项目入口说明文件时,用平台正在运行的检查脚本复核体量预算,不采信候选树里自带的那一份,防止候选连同检查一起放宽。
验证记录只保存命令、退出码、产物哈希、执行者和时间,从不保存输出正文:
CREATE TABLE dispatch_verification_receipts (
execution_id TEXT NOT NULL UNIQUE,
run_id TEXT NOT NULL,
candidate_sha256 TEXT NOT NULL,
candidate_commit_sha TEXT NOT NULL,
base_head TEXT NOT NULL,
target_ref TEXT NOT NULL,
artifact_sha256 TEXT NOT NULL,
policy_sha256 TEXT NOT NULL, -- 验证策略实现的哈希
status TEXT NOT NULL CHECK(status IN ('passed','invalidated'))
);
-- 触发器:绑定字段写入后不可改,invalidated 不能改回;
-- 每项检查的明细行不可更新、不可删除。
第五步:跨家族只读复审
要防的场景:同一家模型写、同一家模型审,容易共享同一类盲点。
做法:MEDIUM 和 HIGH 候选在落地前交给另一家执行器做只读复审:Codex 写的由 Claude 审,Claude 写的由 Codex 审,同家自审被禁止。复审者只拿到冻结候选的交接包:哈希、基线、相对路径、检查项编号,拿不到写权限、仓库所有权和凭据。结论是 go 或 no_go,发现项一次性写入,结算后数据库拒绝追加。这道门目前处于强制模式,no_go 的候选不能落地。
第六步:落地到主干
要防的场景:候选生成期间,有人在交互链路里往同一条主干提交了新改动;直接覆盖会丢掉那次提交。
做法:只有验证记录与这一次的一件事、运行、执行轮、基线、主干、候选全部对得上,才进入 applying。候选提交的父提交就是主干当时的 HEAD,落地就是把主干指针前移一格,用两条 Git 底层命令完成:
git commit-tree <候选树> -p <基线提交>
# 用候选树直接造提交对象,不经过工作区
git update-ref -m "Atelier controlled local commit" <主干> <候选提交> <基线提交>
# 第三个参数是旧值:主干此刻仍指向基线才移动,否则失败
第二条命令就是一次比较并交换。落地时禁用钩子、提交签名和全局与系统级 Git 配置;前后都核对 HEAD、主干引用和树对象;工作区里与本次无关的已暂存、未暂存和未跟踪改动原样保留。主干已被移动,就以 target_stale 拒绝,任务回到队列按新的 HEAD 重新规划。
第七步:交付阶段
受管链路本身不推送、不部署。推送、激活和线上真实验收由按项目登记的交付流程接着做,每个阶段单独留一条记录:landed、pushed、activated、live_accepted,前一个不能冒充后一个。只有在代码里登记了发布与回滚步骤的仓库才会走完四个阶段,目前是 5 个真实仓库(含 Atelier 自身);其余仓库停在本地主干提交。发布后检查不通过,走登记好的回滚步骤。
验证或复审被拒的候选,在原来的一件事上返修;同一件事的候选连续 15 次不收敛即停止,交给人处理。
五、交互链路:人在场时靠什么约束?
交互链路是主力,但它的进程不归 Atelier 启动:会话跑在我们的终端或桌面应用里,直接写工作目录、直接执行 git commit。Atelier 能介入的只有两类挂载点:执行器提供的钩子(收到消息时、调用工具前、一轮结束时),以及 Git 的钩子(提交前、写提交说明时、推送前)。
| 机制 | 挂在哪里 | 防什么 | 出错时 |
|---|---|---|---|
| 写前持有 | 调用写文件工具、执行 git commit 前 | 两个写入者同时改一个仓库 | 放行 |
| 诉求账 | 一轮结束时;接口侧 | 人提的要求没登记、没做完就被标完成 | 钩子放行;接口拒绝 |
| 提交检查 | Git pre-commit、commit-msg | 漏写变更记录、不在主干上提交、暂存区含密钥、缺署名 | 放行 |
| 发布门 | Git pre-push、打上线标签前 | 未经检查就推送或标记上线 | 拒绝 |
写前持有。 会话第一次写某个仓库前,钩子向 Atelier 查询:本会话是否持有一件包含该仓库的「进行中」的事。没有就拦下,并给出挂上一件事的命令。持有走的是第三节同一个状态迁移入口,因此和受管链路共用同仓单写入者的判定:交互会话持有了某个仓库,受管链路进不去;受管执行在跑,交互会话也持有不上。交互会话的持有不占受管执行名额,这是 9 月 28 日拍板的口径。这道门对 Claude Code 的交互式会话和 Codex 的交互式会话都已挂上。
它的边界要说清楚:持有者标签由会话自报;Atelier 不可达、令牌缺失或钩子异常时放行;另有写明原因的豁免。所以它是流程纪律,挡得住疏忽,挡不住有意绕过。
提交检查。 所有仓库转调同一份共享钩子:暂存区必须包含变更记录;只在主干上提交;项目入口说明文件的体量不超预算;暂存的前端新增行不含硬编码颜色;暂存区密钥扫描;提交说明必须带署名行。这些检查都有写在命令里的逃生门,用于确需跳过的场合。
发布门。 这是交互链路里唯一失败即拒绝的一道。仓库声明了发布检查清单,推送前和打上线标签前就必须跑通:没跑完、报错、超时、结果不合格式都算不通过。检查结果登记回 Atelier。目前在代码里列为必检的只有官网一个仓库,推送前检查已记录 82 次。
为什么大部分钩子选择放行。 钩子依赖外部进程:Atelier 服务、Python 解释器、执行器的钩子加载机制。如果全部设成失败即拒绝,Atelier 自身的一次故障就会放大成所有项目停工。代价是可能静默失效,所以要配上可观测性:现在守卫页按实际挂载列出全部守卫(Claude Code 钩子 20 项、Git 钩子 6 项、Codex 侧 8 项),与登记清单逐条比对漂移,并直接显示近 7 天的拦截和报错次数。
两条链路的守卫是分开的。受管进程以固定参数启动,不加载交互会话的任何钩子;受管落地走底层命令,Git 也不在这条路径上运行钩子。所以受管链路在父进程验证里重新实现了对应检查,第四节写到的变更记录和入口说明文件复核就是这样来的。
交互会话里还可以派出子代理,它们与主会话共用同一个工作目录和同一组钩子,Atelier 不单独隔离;同一仓库内的多个子代理,靠我们按文件划分、串行派出来避免冲突。
六、并发与一致性怎样保证?
写入者互斥(single-writer)。 同一个仓库在任一时刻只允许一个写入者,受管执行和交互会话的持有都算。判定在进入「进行中」的迁移里完成:查询其他占用名额的一件事,与本次涉及的仓库有交集即以 repo_writer_busy 拒绝。
立即写事务。 SQLite 的 BEGIN IMMEDIATE 在事务开始时就取得写锁,保证「检查」和「写入」之间不会插入另一个写事务。所有让一件事进入「进行中」的路径,都在一个这样的事务里同时检查四件事:总名额、分类名额、同仓写入者、相关的未答复待拍板。每一次真正启动执行器进程,也在这样一个事务之内完成:事务先证明「此刻没有与本次运行相关的待拍板」,再启动进程。
名额。 受管执行的名额写在配置里:总数 5,深度工作 4,轻量 1,每个仓库最多 1 个写入者。只有受管的一件事占名额。
主干引用的比较并交换。 这是两条链路汇合处的最后一道。典型场景:受管候选以提交 A 为基线生成,期间交互会话在主干上提交了 B;落地时 update-ref 的旧值 A 与当前值 B 不符,命令失败,候选作废并按 B 重新规划。
单实例。 后端先绑定监听端口,再对数据库对应的锁文件取得操作系统级独占锁,然后建立唯一的状态库连接,做一次只读的完整性检查和对象一致性扫描,恢复能证明已经退出的执行,最后才开放派发入口。锁文件里的进程号只用于诊断,从不据此判断锁是否有效,也不据此结束进程。
持久化。 状态库用 WAL 模式(Write-Ahead Logging,先写日志再合并回主文件,读不阻塞写),同步级别为 NORMAL:掉电或系统崩溃时,最后几个已提交的事务可能回滚,但数据库保持一致 [2]。
幂等。 预检按幂等键去重;待拍板答复、续跑领取都是比较并交换;仓库写入者账本的领取与释放按代次比较并交换;交付记录在数据库层按「执行轮 + 阶段」唯一。
七、失败与恢复
进程崩溃。 每个受管执行轮记录后端的代际号、进程号、启动时间,每 5 秒一次心跳,所有状态迁移都要求代际号与心跳序号一致。后端重启后,只有能证明原进程组已经不存在、且项目主干没被改动的执行,才会被收口为「受阻」;心跳过期、进程号被系统复用、正处于 applying 或 ambiguous 的,一律保留为阻断项,交给人处理。
验证或复审失败。 留在原来的一件事、原来的运行里返修,下一轮可以在上一轮被拒的候选之上继续改,基线仍锚定原 HEAD。连续 15 次仍不收敛即停止。
交付失败。 对登记了交付流程的仓库,发布后检查不通过就执行登记好的回滚步骤;推送失败停在 landed,不记 pushed。
数据备份。 状态库每天备份一次:用 SQLite 的在线备份接口生成包含已提交 WAL 的一致快照,在临时区跑完整性检查后原子发布,记录 MD5,保留 30 天。较大的表结构迁移前,另存一份整库快照。恢复演练把备份还原到一个 0700 的临时目录重新检查,结束后清除,不碰现场库。凭据类文件从不进备份。
八、安全边界在哪里?
入口。 后端只监听本机回环地址,操作台经私有组网的 HTTPS 入口访问。浏览器会话用 __Host- 前缀、HttpOnly、SameSite=Strict 的 Cookie,库里只存令牌的哈希;会写数据的请求必须带防跨站请求头,并校验来源与 Sec-Fetch-Site。
执行器权限。 规划进程在项目目录上以只读沙箱运行,不读用户配置,环境变量里的令牌、密钥类变量先被剔除;写入进程只看得到隔离目录,没有 Git 元数据,网络默认关闭;复审进程只读;写入和复审进程都不加载个人配置和钩子。推送、部署和凭据都不经执行器之手。
数据闭集。 Atelier 对持久数据采用显式闭集、默认拒绝。能存的是活动标识与关系、调度状态、人工控制记录、哈希与验证元数据、可重建的索引元数据、完成调度所需的短文本(标题、开发意图、下一步、触发条件)、项目登记信息、指向项目仓库已提交文件的知识指针、用量计数。客户资料、合同、外部项目的进度与决定副本、凭据、对话原文和工具输出默认拒绝。
实际落地的情况照实写。通用事实与记忆的写入口由应用层和数据库触发器双层强制;Codex 会话的索引表有 36 万多行,只存事件类别、角色、工具名、偏移和哈希,正文列全部为空;本地模型的判断依据写到本机一个被 Git 忽略、权限 0600 的文件里,库里只存指针和哈希。派出记录会保存本次任务说明和一行结果摘要,属于「开发意图」这一类短文本,平均一千多字。
触发器防什么。 不可改的触发器防的是应用代码写错和经接口的篡改,防不住本机有文件权限的人直接改库文件。这一层靠单用户机器、文件权限和备份哈希兜底。
九、技术栈为什么这样选?
| 层 | 选择 | 理由 | 放弃的备选 |
|---|---|---|---|
| 整体结构 | 模块化单体,按故障域预留拆分点 | 拆服务的主要价值是团队之间解耦,一个人用不上;服务发现、链路追踪、多仓库管理的成本却是实在的 | 微服务,4 月的第一份架构决策记录否掉 |
| 后端 | Python + FastAPI | 调模型、跑检查脚本、操作 Git 在同一种语言里完成,检查脚本和服务共用模块 | — |
| 存储 | SQLite,WAL,操作系统级单实例锁 | 单机单用户;事务、外键、CHECK 约束、触发器齐全,不变量可以写进库;在线备份接口现成;零运维 | 独立数据库服务:多一个要维护的进程,并发收益用不上 |
| 约束放哪层 | 多写入方的状态放触发器;跨表判断放唯一写入口 | 触发器绕不过但表达力有限;跨表检查要在一个事务里读多张表 | 全放应用层:多写入方时总有一条路径漏检 |
| 落地方式 | commit-tree + 带旧值的 update-ref | 不经过工作区、不触发钩子、失败时主干原样不动 | 在工作区 git apply 再 git commit:会碰到人的未提交改动,还会运行交互链路的钩子 |
| 前端 | React + TypeScript + Vite + Tailwind + Radix | 前后端数据结构写错,在改代码时就能被类型检查发现 | — |
| 模型调用 | 统一网关,逐步收拢 | 模型调用慢且不稳定,是独立的故障域,集中后可以限流、重试、记账 | 各模块直连:限流与重试散在各处 |
| 知识问答 | 人工审定、互相链接的概念页 + 代码检索 | 可溯源;向量检索在因果类问题上容易自信地答错 | 向量检索,在执行层直接禁用 |
| 配置读写 | 往返保留注释的 YAML 解析 | 操作台保存配置时不能把注释整段删掉 | 普通 YAML 读写,曾把注释删光 |
十、技术上的决定怎样做,又怎样复议?
从待拍板到决策记录。 需要人选择的事项先登记为待拍板:问题、有界的选项、每个选项的代价,一般标出推荐项。我们在操作台上作答,答复连同时间和途径入账。影响架构方向或工作方式的,再整理成一份决策记录。这借鉴了软件行业公开的架构决策记录(ADR)做法:用一份简短文档记下一项决定的背景、决定本身、状态和后果,被推翻时旧记录保留并标明已被取代 [1]。
我们的模板固定为:元信息(精确到分钟的时间、场合、范围、影响项目、状态)、背景、备选方案表(每个方案的杠杆位置、代价、风险)、一句话的决定、影响范围(执行项拆到一次提交的粒度)、反例预防、复议条件。目前 Atelier 自己的仓库里有 42 份决策记录、11 份提案,统一索引的状态只能取 8 个固定值。
两道机器门。
- 决策目录只增不删:提交前和推送前,一个失败即拒绝的脚本逐个检查新文件是否带齐机器可读的元信息;删除、改名、改文件类型一律拒绝。记录里引用的拍板编号必须在库里有答复,作答时间与登记时间同一秒的也拒绝。脚本注释写明:这个时间差只是拒绝依据,证明不了作答人是谁。
- 红线写进代码:最关键的约束以标记注释写在对应模块旁,附锚点词;确定性脚本按清单逐条核对,缺标记或缺锚点即报错。清单现有 238 条,脚本同时挂在提交钩子和受管验证里。
复议。 整体取代旧记录的,旧记录在索引里标为已取代;只推翻其中一条的,新记录写明精确覆盖哪一条,旧记录其余条款继续有效。三个例子:
- 名额 4 改 5。 旧配置总名额 4、深度名额也是 4,四路深度工作占满以后,轻量那一路先撞上总名额被拒,轻量名额存在却永远拿不到。代码前一天已经改好;9 月 10 日的记录补上依据,只覆盖总名额这一条,并要求配置契约测试锁定 5 / 4 / 1 / 1。
- 项目知识问答依据什么。 三个方案:由程序按规则路由到人工审定的概念页(选定);让模型自己决定查不查(否:回到靠模型自觉);每次两种都查再综合(否:慢近一倍,噪声大)。落地时在执行层禁用向量检索工具,调用即拒。复议条件写明:规则路由经常答非所问,或者引用后答案质量没有实测改善,就重新评估。
- 产品仓库要不要进受管队列。 这是第二节那次拍板。原规则是 Atelier 对产品仓库只做观测和记录;评估数据显示产品仓库几乎全部走交互链路之后,改为「可以进、不强制」,同时写明回退办法:再拍一次恢复原条款,已在队列里的任务按普通事项搁置或取消,不影响已落地的提交。
十一、已知局限与踩过的坑
| 现象 | 根因 | 现状 |
|---|---|---|
| 受管提交新增的文档没登记,主检出随后过不了提交检查 | commit-tree 与 update-ref 是底层命令,不运行提交钩子 | 落地前由父进程补齐文档登记;其余钩子检查在父进程验证里逐项对应 |
| 交互会话持有某仓库期间,自动启动仍对该仓库的排队任务做规划 | 自动启动在规划前只查受管写入者 | 规划前先查同仓写入者,有则不规划 |
| 改动 Codex 钩子配置后,对应钩子不再运行,也不报错 | Codex 只运行信任哈希与当前内容一致的钩子 | 守卫页显示每条钩子的信任状态 |
| 轻量名额存在却永远拿不到 | 总名额与深度名额同为 4,总名额先判 | 总名额改为 5,契约测试锁定 |
| 本地模型服务两处配置各写一遍超时,调长超时的补丁不生效 | 同一份配置有两个出处 | 收口到唯一一处 |
还有两条结构性的局限。第一,受管链路单次计划超过 3 个文件就要人工启动,大一点的改动天然流向交互链路,这正是它只占约 4.5% 的主要原因之一。第二,交互链路的约束以放行为默认,它能证明的是「规则挂上了、没被触发」,不能证明「没有人绕过」;需要留下可核对证据的环节,目前只有受管链路和发布门做得到。
以下是我们的看法
以下内容是我们的观点,供您参考。
第一,先弄清每条机制覆盖多大的面。 一条机制再严密,只覆盖 4.5% 的提交,就只能对这 4.5% 作保证。描述一个系统时,把「在哪条链路上、默认开没开、占多少」和机制本身一起写出来,读者才能判断它到底保证了什么。
第二,不变量尽量写在靠下的层。 状态迁移触发器、CHECK 约束、不可改的记录、带旧值的主干更新,都不依赖某段应用代码、某个模型或某个人记得。写不进底层的跨表规则,就收口到唯一的写入口。
第三,执行器的自述不算证据。 候选由父进程封存和比对,验证由父进程重跑,复审换另一家,落地用比较并交换,这几件事都不经执行器之手。
第四,放行式的守卫要配上可观测性。 选择异常放行是为了不让基础设施故障拖垮开发,代价是静默失效;报错计数和挂载漂移比对,是这个选择必须附带的部分。
第五,评估交付方时,您可以问几个问题: 改动在进入主干之前由谁核对,核对结果能否被篡改?这套核对覆盖了多大比例的改动?落地失败时主干是否保持原样?两路改动会不会互相覆盖?关键技术决定记在哪里、比较过哪些备选?
如果您关心交付方的工程做法,欢迎预约交流,我们可以对照您的项目聊聊这套方式适不适合。