# 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,非本次新增): ```sql 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) 1. 当 `stage_deployment()` 因 `ux_deployment_active` 冲突而无法发起新部署时,调用方能拿到一个具名、语义 明确、携带诊断字段(本次被拒绝的 `git_commit_hash`、当前占用中的 `deployment_id` 与 `status`)的异常, 而不是原始的底层数据库异常。 2. 把"`finalized`/`rolled_back`/`blocked_pending_human` 三个终态都允许发起新部署"这一约束,从"隐式依赖 SQL `WHERE` 子句不被改错"变成"由可执行的回归测试显式锁定"。 3. 修复过程不破坏任何既有行为:`CheckoutFailedError` 的既有触发路径与语义、`stage_deployment()` 现有的 返回值结构和调用契约,均保持不变。 4. `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` 做专门的等待重试逻辑)——本次只保证异常"存在且可诊断",调用方的消费策略留给后续任务。