BRD:supervisor/deploy_stage.py 并发部署冲突的诊断化处理
1. 背景与问题定义
graphlemon 的 Deploy Supervisor(supervisor/)在自举(self-hosting)场景下,会把针对 graphlemon 自身仓库
的代码变更打包成一次部署(deployment 行),由 supervisor/deploy_stage.stage_deployment() 负责"拉起候选
实例"这一步:检出新提交到独立 worktree、拷贝生产数据库、在临时端口上启动候选进程。这是蓝绿部署链路
(stage → health-check → cutover → monitor → finalize/rollback)里唯一一个会创建新部署记录的入口,一旦并发
触发两次,会产生两个互相踩踏的候选实例,属于高风险类别(生产事故 / 不可逆的部署状态错乱)。
原始任务描述(来自 docs/todo/ 及既往 gap-analysis)断言"stage_deployment() 只检查上一次成功落地的部署,
完全不检查当前是否已有进行中的部署,因此会静默产生第二个冲突的候选实例"。经对当前代码逐行核实,这一断言
不成立,需要在此明确记录并纠偏(SOUL.md 原则 4:不得把猜测当事实,事实与预想冲突时以事实为准):
src/schema.py(deployment表定义)已经存在ux_deployment_active部分唯一索引(U-034 引入, 2026-08-02,非本次新增):
CREATE UNIQUE INDEX ux_deployment_active ON deployment((1))
WHERE status NOT IN ('finalized', 'rolled_back', 'blocked_pending_human');
即数据库层面已经保证"同一时刻最多一条非终态部署记录",由 SQLite 在 INSERT 语句级别原子化生效,不存在
TOCTOU(检查后再写入之间的)竞态窗口。
stage_deployment()(supervisor/deploy_stage.py:148-176)把INSERT INTO deployment ...放在所有具有 真实副作用的操作(git worktree add、生产库拷贝、Popen启动候选进程)之前执行并立即commit()。 也就是说,如果此时已存在未到终态的部署记录,冲突会在任何副作用发生前就终止函数执行——不会产生"第二个 冲突的候选实例",这个具体后果本身已经被现有实现规避。supervisor/test_deploy_stage.py::StageDeploymentHappyPathTest.test_only_one_non_terminal_deployment_allowed(171-180 行)已经用真实 sqlite 断言了"第二次调用会被拒绝"这一行为,不是完全没有测试覆盖。
因此,"完全没有互斥检查"这个业务问题已经不存在。但核实过程中发现了两处真实、边界清楚、仍值得作为独立 bug 修复交付的差距,二者都属于"并发冲突被正确拒绝,但拒绝的方式和覆盖不够可靠/可诊断"这一类问题,而不是 "未被拒绝":
- G1(诊断信息缺失,可运维性问题):冲突发生时,
stage_deployment()让底层sqlite3.IntegrityError原样向上抛出。调用方(当前是测试代码,未来是 Deploy Supervisor 的轮询循环和任何手工触发路径)拿到的是一个 不携带deployment_id、当前占用者状态等诊断信息的通用底层异常,且与同模块CheckoutFailedError、同包deploy_cutover.CutoverFailedError、deploy_rollback.RollbackFailedError这类"具名异常 + 携带诊断字段" 的既有约定不一致。出了并发冲突之后,值班人员/日志只能看到一串 SQLite 内部错误文本,无法直接判断"是谁在 占用、占用到什么状态",需要额外查库才能定位——这是一个真实的可运维性缺口,而不是功能性缺口。 - G2(回归测试覆盖不完整,防回归问题):
ux_deployment_active索引的WHERE子句显式把finalized/rolled_back/blocked_pending_human三个状态列为"允许发起新部署"的终态,但现有测试只间接 覆盖了finalized这一条路径(BootstrapPreviousTest.test_prior_finalized_deployment_supplies_previous_fields)。rolled_back、blocked_pending_human这两个同样被索引显式放行的终态没有对应的回归测试。这意味着如果未来 有人不小心改动了这条WHERE子句、或者在stage_deployment()里加了额外的状态判断逻辑,测试套件不会 发现"本该允许发起新部署的终态被误挡住了"这类回归。
以及贯穿全程的门禁:
- G3:
src/与supervisor/两个独立unittest discover套件必须保持全绿——修复动作不得引入新的失败 或破坏既有 560+ 测试。
2. 干系人与使用场景
- Deploy Supervisor 自身的轮询循环(
supervisor/deploy_supervisor.py,未来接线stage_deployment()的调用方):需要能程序化区分"这次失败是因为已有部署在进行中"和"这次失败是其他真实错误(如 checkout 失败、 磁盘满)",以便做出不同的后续动作(等待重试 vs. 上报阻塞)。 - 值班人 / 事后排查者:在自举流程出现异常时,需要从异常信息本身直接读出"是谁占用了这次部署",而不是
额外去查
deployment表。 - 未来维护
stage_deployment()或ux_deployment_active索引定义的开发者(人类或 Coder Agent):需要 一组明确的回归测试作为"这三个终态必须放行新部署"这一约束的可执行文档,防止未来改动悄悄破坏该约束。
3. 目标(Goals)
- 当
stage_deployment()因ux_deployment_active冲突而无法发起新部署时,调用方能拿到一个具名、语义 明确、携带诊断字段(本次被拒绝的git_commit_hash、当前占用中的deployment_id与status)的异常, 而不是原始的底层数据库异常。 - 把"
finalized/rolled_back/blocked_pending_human三个终态都允许发起新部署"这一约束,从"隐式依赖 SQLWHERE子句不被改错"变成"由可执行的回归测试显式锁定"。 - 修复过程不破坏任何既有行为:
CheckoutFailedError的既有触发路径与语义、stage_deployment()现有的 返回值结构和调用契约,均保持不变。 src/与supervisor/两个unittest discover套件全部通过,退出码为 0。
4. 非目标(Non-Goals)
- 不重新设计或改变
ux_deployment_active索引本身的终态集合定义(finalized/rolled_back/blocked_pending_human)——这是 01-M 1.15 既定约束,本次不涉及业务语义变更。 - 不引入应用层的额外锁(文件锁、进程内内存锁、乐观锁重试等)——数据库唯一约束已经是并发正确性的单一事实 来源且已经过原子化验证,重复加锁只会增加不必要的复杂度和新的一致性风险。
- 不改变
stage_deployment()现有的返回值结构({deployment_id, status, candidate_port, candidate_pid, candidate_instance_dir})。 - 不处理
health_check_failed、rollback_failed是否也应该被视为终态、允许发起新部署——这两个状态目前 仍在ux_deployment_active的"非终态"一侧(即仍会阻止新部署发起),这是否合理需要业务侧判断,属于状态 机语义变更,不在本次 bug 修复范围内(详见第 7 节范围外事项)。 - 不改动
deploy_supervisor.py轮询循环本身如何消费这个异常——本次只交付异常的产生方(deploy_stage.py) 和对应测试;调用方是否/如何区分处理该异常留给后续任务。
5. 范围(Scope)
仅涉及两个文件:
supervisor/deploy_stage.py:新增一个具名异常类(携带诊断字段),并在触发ux_deployment_active冲突的那一次INSERT周围新增try/except,把底层数据库异常转译为该具名异常后抛出。supervisor/test_deploy_stage.py:收紧现有并发冲突测试的断言(从笼统的assertRaises(Exception)改为 断言具名异常类型及其诊断字段),并新增rolled_back、blocked_pending_human两条终态放行路径的回归 测试。
不涉及 src/schema.py(不改 schema)、不涉及 deploy_cutover.py/deploy_rollback.py/deploy_health_check.py
等同包其他模块、不涉及新增外部依赖。
6. 已知事实 / 设计决策 / 待验证假设 / 阻塞性未知
已知事实(均来自对当前代码的直接核实,非猜测):
ux_deployment_active唯一索引已存在并在INSERT语句级别原子化生效(src/schema.py:357-362)。stage_deployment()的INSERT(supervisor/deploy_stage.py:170-176)先于所有具有真实副作用的调用 (_checkout_candidate/_copy_prod_db/_launch_candidate,178 行起)执行;冲突发生时不会产生候选 worktree、候选数据库拷贝或候选进程等任何副作用。- 现有测试
test_only_one_non_terminal_deployment_allowed(supervisor/test_deploy_stage.py:171-180)用self.assertRaises(Exception)承接冲突异常,未指定异常类型也未校验诊断字段——这正是 G1 要收紧的具体 测试点。 - 索引
WHERE子句排除(即"允许发起新部署")的终态集合是{'finalized', 'rolled_back', 'blocked_pending_human'};health_check_failed与rollback_failed不在该集合中,即当前设计下仍被 视为"非终态、会继续阻止新部署发起"的状态。
设计决策(PL 基于本仓库既有约定作出,非新的产品/业务决策,供 SDD 阶段延续):
- 新异常应沿用模块内
CheckoutFailedError的命名与文档风格:具名、放在模块顶部、docstring 说明触发场景 与语义,与同包CutoverFailedError/RollbackFailedError的既有约定保持一致。 - 转译逻辑必须能区分"因
ux_deployment_active冲突导致的插入失败"与"其他类型的数据库错误"(如 schema 不匹配、连接已关闭等意外情况),不能把所有sqlite3.IntegrityError一律归类为并发冲突而掩盖其他真实 错误。具体判别手段(例如检查错误信息是否指向ux_deployment_active,或其他等价可靠方式)留给 SDD/实现 阶段选择,唯一的硬约束是"不得误判"。
待验证假设:无——本次范围内的全部行为均可通过单元测试直接、确定性地验证,不依赖运行期观测或线上数据。
阻塞性未知:无——不需要产品、业务或外部决策即可完成本次修复。
7. 风险与依赖
- 风险等级:低。 改动范围局限在单一模块新增一个异常类 + 一处
try/except转译逻辑,加上测试文件补充 用例;不触及 schema、不触及部署状态机的语义定义、不改变任何模块对外的调用签名或返回值结构。 - 依赖:无新增外部依赖,转译逻辑沿用 Python 标准库
sqlite3的异常体系。 - 不涉及权限、鉴权、支付、不可逆数据变更等更高风险类别本身(部署系统虽然高风险,但本次改动不触碰其状态 机语义或副作用触发顺序,只改"冲突发生后异常长什么样"这一件事),无需额外审批门槛。
8. 范围外后续事项(供后续任务参考,不在本次交付范围内)
health_check_failed/rollback_failed是否也应该被纳入ux_deployment_active的终态集合、允许在这两 个状态下发起新部署——需要业务侧判断"健康检查失败/回滚失败之后,是否应该允许立即重新尝试部署",涉及 01-M/04-P 状态机语义变更,不是本次 bug 修复的范围,留待独立任务讨论。deploy_supervisor.py轮询循环如何消费本次新增的具名异常(例如是否需要针对ConcurrentDeploymentError做专门的等待重试逻辑)——本次只保证异常"存在且可诊断",调用方的消费策略留给后续任务。