Commit 5084c2efe80aa8aa253809f88f89fd5e89193be5

Authored by graphlemon
1 parent 1564dd77

graphlemon: brd artifact for task 911861fe-446a-4bab-8b69-b4872b92d616

.graphlemon/runs/911861fe-446a-4bab-8b69-b4872b92d616/brd.md
... ... @@ -2,126 +2,73 @@
2 2  
3 3 ## 1. 背景与问题定义
4 4  
5   -graphlemon 的 Deploy Supervisor(`supervisor/`)在自举(self-hosting)场景下,会把针对 graphlemon 自身仓库
6   -的代码变更打包成一次部署(`deployment` 行),由 `supervisor/deploy_stage.stage_deployment()` 负责"拉起候选
7   -实例"这一步:检出新提交到独立 worktree、拷贝生产数据库、在临时端口上启动候选进程。这是蓝绿部署链路
8   -(stage → health-check → cutover → monitor → finalize/rollback)里唯一一个会创建新部署记录的入口,一旦并发
9   -触发两次,会产生两个互相踩踏的候选实例,属于高风险类别(生产事故 / 不可逆的部署状态错乱)。
  5 +graphlemon 的 Deploy Supervisor(`supervisor/`)在自举(self-hosting)场景下,会把针对 graphlemon 自身仓库的代码变更打包成一次部署(`deployment` 行),由 `supervisor/deploy_stage.stage_deployment()` 负责“拉起候选实例”这一步:检出新提交到独立 worktree、拷贝生产数据库、在临时端口上启动候选进程。这是蓝绿部署链路(stage → health-check → cutover → monitor → finalize/rollback)里唯一一个会创建新部署记录的入口,一旦并发触发两次,会产生两个互相踩踏的候选实例,属于高风险类别(生产事故 / 不可逆的部署状态错乱)。
10 6  
11   -原始任务描述(来自 `docs/todo/` 及既往 gap-analysis)断言"`stage_deployment()` 只检查上一次成功落地的部署,
12   -完全不检查当前是否已有进行中的部署,因此会静默产生第二个冲突的候选实例"。**经对当前代码逐行核实,这一断言
13   -不成立**,需要在此明确记录并纠偏(SOUL.md 原则 4:不得把猜测当事实,事实与预想冲突时以事实为准):
  7 +原始任务描述(来自 `docs/todo/` 及既往 gap-analysis)断言“`stage_deployment()` 只检查上一次成功落地的部署,完全不检查当前是否已有进行中的部署,因此会静默产生第二个冲突的候选实例”。**经对当前代码逐行核实,这一断言不成立**,需要在此明确记录并纠偏(SOUL.md 原则 4:不得把猜测当事实,事实与预想冲突时以事实为准):
14 8  
15   -- `src/schema.py`(`deployment` 表定义)已经存在 `ux_deployment_active` 部分唯一索引(U-034 引入,
16   - 2026-08-02,非本次新增):
  9 +- `src/schema.py`(`deployment` 表定义)已经存在 `ux_deployment_active` 部分唯一索引(U-034 引入,2026-08-02,非本次新增):
17 10  
18 11 ```sql
19 12 CREATE UNIQUE INDEX ux_deployment_active ON deployment((1))
20 13 WHERE status NOT IN ('finalized', 'rolled_back', 'blocked_pending_human');
21 14 ```
22 15  
23   - 即数据库层面已经保证"同一时刻最多一条非终态部署记录",由 SQLite 在 `INSERT` 语句级别原子化生效,不存在
24   - TOCTOU(检查后再写入之间的)竞态窗口。
25   -- `stage_deployment()`(`supervisor/deploy_stage.py:148-176`)把 `INSERT INTO deployment ...` 放在所有具有
26   - 真实副作用的操作(`git worktree add`、生产库拷贝、`Popen` 启动候选进程)**之前**执行并立即 `commit()`。
27   - 也就是说,如果此时已存在未到终态的部署记录,冲突会在任何副作用发生前就终止函数执行——不会产生"第二个
28   - 冲突的候选实例",这个具体后果本身已经被现有实现规避。
29   -- `supervisor/test_deploy_stage.py::StageDeploymentHappyPathTest.test_only_one_non_terminal_deployment_allowed`
30   - (171-180 行)已经用真实 sqlite 断言了"第二次调用会被拒绝"这一行为,不是完全没有测试覆盖。
31   -
32   -因此,"完全没有互斥检查"这个业务问题已经不存在。但核实过程中发现了两处真实、边界清楚、仍值得作为独立
33   -bug 修复交付的差距,二者都属于"并发冲突被正确拒绝,但拒绝的方式和覆盖不够可靠/可诊断"这一类问题,而不是
34   -"未被拒绝":
35   -
36   -- **G1(诊断信息缺失,可运维性问题)**:冲突发生时,`stage_deployment()` 让底层 `sqlite3.IntegrityError`
37   - 原样向上抛出。调用方(当前是测试代码,未来是 Deploy Supervisor 的轮询循环和任何手工触发路径)拿到的是一个
38   - 不携带 `deployment_id`、当前占用者状态等诊断信息的通用底层异常,且与同模块 `CheckoutFailedError`、同包
39   - `deploy_cutover.CutoverFailedError`、`deploy_rollback.RollbackFailedError` 这类"具名异常 + 携带诊断字段"
40   - 的既有约定不一致。出了并发冲突之后,值班人员/日志只能看到一串 SQLite 内部错误文本,无法直接判断"是谁在
41   - 占用、占用到什么状态",需要额外查库才能定位——这是一个真实的可运维性缺口,而不是功能性缺口。
42   -- **G2(回归测试覆盖不完整,防回归问题)**:`ux_deployment_active` 索引的 `WHERE` 子句显式把
43   - `finalized`/`rolled_back`/`blocked_pending_human` 三个状态列为"允许发起新部署"的终态,但现有测试只间接
44   - 覆盖了 `finalized` 这一条路径(`BootstrapPreviousTest.test_prior_finalized_deployment_supplies_previous_fields`)。
45   - `rolled_back`、`blocked_pending_human` 这两个同样被索引显式放行的终态没有对应的回归测试。这意味着如果未来
46   - 有人不小心改动了这条 `WHERE` 子句、或者在 `stage_deployment()` 里加了额外的状态判断逻辑,测试套件不会
47   - 发现"本该允许发起新部署的终态被误挡住了"这类回归。
  16 + 即数据库层面已经保证“同一时刻最多一条非终态部署记录”,由 SQLite 在 `INSERT` 语句级别原子化生效,不存在 TOCTOU(检查后再写入之间的)竞态窗口。
  17 +- `stage_deployment()`(`supervisor/deploy_stage.py:148-176`)把 `INSERT INTO deployment ...` 放在所有具有真实副作用的操作(`git worktree add`、生产库拷贝、`Popen` 启动候选进程)**之前**执行并立即 `commit()`。也就是说,如果此时已存在未到终态的部署记录,冲突会在任何副作用发生前就终止函数执行——不会产生“第二个冲突的候选实例”,这个具体后果本身已经被现有实现规避。
  18 +- `supervisor/test_deploy_stage.py::StageDeploymentHappyPathTest.test_only_one_non_terminal_deployment_allowed`(171-180 行)已经用真实 sqlite 断言了“第二次调用会被拒绝”这一行为,不是完全没有测试覆盖。
  19 +
  20 +因此,“完全没有互斥检查”这个业务问题已经不存在。但核实过程中发现了两处真实、边界清楚、仍值得作为独立 bug 修复交付的差距,二者都属于“并发冲突被正确拒绝,但拒绝的方式和覆盖不够可靠/可诊断”这一类问题,而不是“未被拒绝”:
  21 +
  22 +- **G1(诊断信息缺失,可运维性问题)**:冲突发生时,`stage_deployment()` 让底层 `sqlite3.IntegrityError` 原样向上抛出。调用方(当前是测试代码,未来是 Deploy Supervisor 的轮询循环和任何手工触发路径)拿到的是一个不携带 `deployment_id`、当前占用者状态等诊断信息的通用底层异常,且与同模块 `CheckoutFailedError`、同包 `deploy_cutover.CutoverFailedError`、`deploy_rollback.RollbackFailedError` 这类“具名异常 + 携带诊断字段”的既有约定不一致。出了并发冲突之后,值班人员/日志只能看到一串 SQLite 内部错误文本,无法直接判断“是谁在占用、占用到什么状态”,需要额外查库才能定位——这是一个真实的可运维性缺口,而不是功能性缺口。
  23 +- **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()` 里加了额外的状态判断逻辑,测试套件不会发现“本该允许发起新部署的终态被误挡住了”这类回归。
48 24  
49 25 以及贯穿全程的门禁:
50 26  
51   -- **G3**:`src/` 与 `supervisor/` 两个独立 `unittest discover` 套件必须保持全绿——修复动作不得引入新的失败
52   - 或破坏既有 560+ 测试。
  27 +- **G3**:`src/` 与 `supervisor/` 两个独立 `unittest discover` 套件必须保持全绿——修复动作不得引入新的失败或破坏既有 560+ 测试。
53 28  
54 29 ## 2. 干系人与使用场景
55 30  
56   -- **Deploy Supervisor 自身的轮询循环**(`supervisor/deploy_supervisor.py`,未来接线 `stage_deployment()`
57   - 的调用方):需要能程序化区分"这次失败是因为已有部署在进行中"和"这次失败是其他真实错误(如 checkout 失败、
58   - 磁盘满)",以便做出不同的后续动作(等待重试 vs. 上报阻塞)。
59   -- **值班人 / 事后排查者**:在自举流程出现异常时,需要从异常信息本身直接读出"是谁占用了这次部署",而不是
60   - 额外去查 `deployment` 表。
61   -- **未来维护 `stage_deployment()` 或 `ux_deployment_active` 索引定义的开发者**(人类或 Coder Agent):需要
62   - 一组明确的回归测试作为"这三个终态必须放行新部署"这一约束的可执行文档,防止未来改动悄悄破坏该约束。
  31 +- **Deploy Supervisor 自身的轮询循环**(`supervisor/deploy_supervisor.py`,未来接线 `stage_deployment()` 的调用方):需要能程序化区分“这次失败是因为已有部署在进行中”和“这次失败是其他真实错误(如 checkout 失败、磁盘满)”,以便做出不同的后续动作(等待重试 vs. 上报阻塞)。
  32 +- **值班人 / 事后排查者**:在自举流程出现异常时,需要从异常信息本身直接读出“是谁占用了这次部署”,而不是额外去查 `deployment` 表。
  33 +- **未来维护 `stage_deployment()` 或 `ux_deployment_active` 索引定义的开发者**(人类或 Coder Agent):需要一组明确的回归测试作为“这三个终态必须放行新部署”这一约束的可执行文档,防止未来改动悄悄破坏该约束。
63 34  
64 35 ## 3. 目标(Goals)
65 36  
66   -1. 当 `stage_deployment()` 因 `ux_deployment_active` 冲突而无法发起新部署时,调用方能拿到一个具名、语义
67   - 明确、携带诊断字段(本次被拒绝的 `git_commit_hash`、当前占用中的 `deployment_id` 与 `status`)的异常,
68   - 而不是原始的底层数据库异常。
69   -2. 把"`finalized`/`rolled_back`/`blocked_pending_human` 三个终态都允许发起新部署"这一约束,从"隐式依赖
70   - SQL `WHERE` 子句不被改错"变成"由可执行的回归测试显式锁定"。
71   -3. 修复过程不破坏任何既有行为:`CheckoutFailedError` 的既有触发路径与语义、`stage_deployment()` 现有的
72   - 返回值结构和调用契约,均保持不变。
  37 +1. 当 `stage_deployment()` 因 `ux_deployment_active` 冲突而无法发起新部署时,调用方能拿到一个具名、语义明确、携带诊断字段(本次被拒绝的 `git_commit_hash`、当前占用中的 `deployment_id` 与 `status`)的异常,而不是原始的底层数据库异常。
  38 +2. 把“`finalized`/`rolled_back`/`blocked_pending_human` 三个终态都允许发起新部署”这一约束,从“隐式依赖 SQL `WHERE` 子句不被改错”变成“由可执行的回归测试显式锁定”。
  39 +3. 修复过程不破坏任何既有行为:`CheckoutFailedError` 的既有触发路径与语义、`stage_deployment()` 现有的返回值结构和调用契约,均保持不变。
73 40 4. `src/` 与 `supervisor/` 两个 `unittest discover` 套件全部通过,退出码为 0。
74 41  
75 42 ## 4. 非目标(Non-Goals)
76 43  
77   -- 不重新设计或改变 `ux_deployment_active` 索引本身的终态集合定义(`finalized`/`rolled_back`/
78   - `blocked_pending_human`)——这是 01-M 1.15 既定约束,本次不涉及业务语义变更。
79   -- 不引入应用层的额外锁(文件锁、进程内内存锁、乐观锁重试等)——数据库唯一约束已经是并发正确性的单一事实
80   - 来源且已经过原子化验证,重复加锁只会增加不必要的复杂度和新的一致性风险。
81   -- 不改变 `stage_deployment()` 现有的返回值结构(`{deployment_id, status, candidate_port, candidate_pid,
82   - candidate_instance_dir}`)。
83   -- 不处理 `health_check_failed`、`rollback_failed` 是否也应该被视为终态、允许发起新部署——这两个状态目前
84   - 仍在 `ux_deployment_active` 的"非终态"一侧(即仍会阻止新部署发起),这是否合理需要业务侧判断,属于状态
85   - 机语义变更,不在本次 bug 修复范围内(详见第 7 节范围外事项)。
86   -- 不改动 `deploy_supervisor.py` 轮询循环本身如何消费这个异常——本次只交付异常的产生方(`deploy_stage.py`)
87   - 和对应测试;调用方是否/如何区分处理该异常留给后续任务。
  44 +- 不重新设计或改变 `ux_deployment_active` 索引本身的终态集合定义(`finalized`/`rolled_back`/`blocked_pending_human`)——这是 01-M 1.15 既定约束,本次不涉及业务语义变更。
  45 +- 不引入应用层的额外锁(文件锁、进程内内存锁、乐观锁重试等)——数据库唯一约束已经是并发正确性的单一事实来源且已经过原子化验证,重复加锁只会增加不必要的复杂度和新的一致性风险。
  46 +- 不改变 `stage_deployment()` 现有的返回值结构(`{deployment_id, status, candidate_port, candidate_pid, candidate_instance_dir}`)。
  47 +- 不处理 `health_check_failed`、`rollback_failed` 是否也应该被视为终态、允许发起新部署——这两个状态目前仍在 `ux_deployment_active` 的“非终态”一侧(即仍会阻止新部署发起),这是否合理需要业务侧判断,属于状态机语义变更,不在本次 bug 修复范围内(详见第 7 节范围外事项)。
  48 +- 不改动 `deploy_supervisor.py` 轮询循环本身如何消费这个异常——本次只交付异常的产生方(`deploy_stage.py`)和对应测试;调用方是否/如何区分处理该异常留给后续任务。
88 49  
89 50 ## 5. 范围(Scope)
90 51  
91 52 仅涉及两个文件:
92 53  
93   -- `supervisor/deploy_stage.py`:新增一个具名异常类(携带诊断字段),并在触发 `ux_deployment_active`
94   - 冲突的那一次 `INSERT` 周围新增 `try/except`,把底层数据库异常转译为该具名异常后抛出。
95   -- `supervisor/test_deploy_stage.py`:收紧现有并发冲突测试的断言(从笼统的 `assertRaises(Exception)` 改为
96   - 断言具名异常类型及其诊断字段),并新增 `rolled_back`、`blocked_pending_human` 两条终态放行路径的回归
97   - 测试。
  54 +- `supervisor/deploy_stage.py`:新增一个具名异常类(携带诊断字段),并在触发 `ux_deployment_active` 冲突的那一次 `INSERT` 周围新增 `try/except`,把底层数据库异常转译为该具名异常后抛出。
  55 +- `supervisor/test_deploy_stage.py`:收紧现有并发冲突测试的断言(从笼统的 `assertRaises(Exception)` 改为断言具名异常类型及其诊断字段),并新增 `rolled_back`、`blocked_pending_human` 两条终态放行路径的回归测试。
98 56  
99   -不涉及 `src/schema.py`(不改 schema)、不涉及 `deploy_cutover.py`/`deploy_rollback.py`/`deploy_health_check.py`
100   -等同包其他模块、不涉及新增外部依赖。
  57 +不涉及 `src/schema.py`(不改 schema)、不涉及 `deploy_cutover.py`/`deploy_rollback.py`/`deploy_health_check.py` 等同包其他模块、不涉及新增外部依赖。
101 58  
102 59 ## 6. 已知事实 / 设计决策 / 待验证假设 / 阻塞性未知
103 60  
104 61 **已知事实**(均来自对当前代码的直接核实,非猜测):
105 62  
106 63 - `ux_deployment_active` 唯一索引已存在并在 `INSERT` 语句级别原子化生效(`src/schema.py:357-362`)。
107   -- `stage_deployment()` 的 `INSERT`(`supervisor/deploy_stage.py:170-176`)先于所有具有真实副作用的调用
108   - (`_checkout_candidate`/`_copy_prod_db`/`_launch_candidate`,178 行起)执行;冲突发生时不会产生候选
109   - worktree、候选数据库拷贝或候选进程等任何副作用。
110   -- 现有测试 `test_only_one_non_terminal_deployment_allowed`(`supervisor/test_deploy_stage.py:171-180`)用
111   - `self.assertRaises(Exception)` 承接冲突异常,未指定异常类型也未校验诊断字段——这正是 G1 要收紧的具体
112   - 测试点。
113   -- 索引 `WHERE` 子句排除(即"允许发起新部署")的终态集合是 `{'finalized', 'rolled_back',
114   - 'blocked_pending_human'}`;`health_check_failed` 与 `rollback_failed` 不在该集合中,即当前设计下仍被
115   - 视为"非终态、会继续阻止新部署发起"的状态。
  64 +- `stage_deployment()` 的 `INSERT`(`supervisor/deploy_stage.py:170-176`)先于所有具有真实副作用的调用(`_checkout_candidate`/`_copy_prod_db`/`_launch_candidate`,178 行起)执行;冲突发生时不会产生候选 worktree、候选数据库拷贝或候选进程等任何副作用。
  65 +- 现有测试 `test_only_one_non_terminal_deployment_allowed`(`supervisor/test_deploy_stage.py:171-180`)用 `self.assertRaises(Exception)` 承接冲突异常,未指定异常类型也未校验诊断字段——这正是 G1 要收紧的具体测试点。
  66 +- 索引 `WHERE` 子句排除(即“允许发起新部署”)的终态集合是 `{'finalized', 'rolled_back', 'blocked_pending_human'}`;`health_check_failed` 与 `rollback_failed` 不在该集合中,即当前设计下仍被视为“非终态、会继续阻止新部署发起”的状态。
116 67  
117 68 **设计决策**(PL 基于本仓库既有约定作出,非新的产品/业务决策,供 SDD 阶段延续):
118 69  
119   -- 新异常应沿用模块内 `CheckoutFailedError` 的命名与文档风格:具名、放在模块顶部、docstring 说明触发场景
120   - 与语义,与同包 `CutoverFailedError`/`RollbackFailedError` 的既有约定保持一致。
121   -- 转译逻辑必须能区分"因 `ux_deployment_active` 冲突导致的插入失败"与"其他类型的数据库错误"(如 schema
122   - 不匹配、连接已关闭等意外情况),不能把所有 `sqlite3.IntegrityError` 一律归类为并发冲突而掩盖其他真实
123   - 错误。具体判别手段(例如检查错误信息是否指向 `ux_deployment_active`,或其他等价可靠方式)留给 SDD/实现
124   - 阶段选择,唯一的硬约束是"不得误判"。
  70 +- 新异常应沿用模块内 `CheckoutFailedError` 的命名与文档风格:具名、放在模块顶部、docstring 说明触发场景与语义,与同包 `CutoverFailedError`/`RollbackFailedError` 的既有约定保持一致。
  71 +- 转译逻辑必须能区分“因 `ux_deployment_active` 冲突导致的插入失败”与“其他类型的数据库错误”(如 schema 不匹配、连接已关闭等意外情况),不能把所有 `sqlite3.IntegrityError` 一律归类为并发冲突而掩盖其他真实错误。具体判别手段(例如检查错误信息是否指向 `ux_deployment_active`,或其他等价可靠方式)留给 SDD/实现阶段选择,唯一的硬约束是“不得误判”。
125 72  
126 73 **待验证假设**:无——本次范围内的全部行为均可通过单元测试直接、确定性地验证,不依赖运行期观测或线上数据。
127 74  
... ... @@ -129,16 +76,11 @@ bug 修复交付的差距,二者都属于"并发冲突被正确拒绝,但拒
129 76  
130 77 ## 7. 风险与依赖
131 78  
132   -- **风险等级:低。** 改动范围局限在单一模块新增一个异常类 + 一处 `try/except` 转译逻辑,加上测试文件补充
133   - 用例;不触及 schema、不触及部署状态机的语义定义、不改变任何模块对外的调用签名或返回值结构。
  79 +- **风险等级:低。** 改动范围局限在单一模块新增一个异常类 + 一处 `try/except` 转译逻辑,加上测试文件补充用例;不触及 schema、不触及部署状态机的语义定义、不改变任何模块对外的调用签名或返回值结构。
134 80 - **依赖:无新增外部依赖**,转译逻辑沿用 Python 标准库 `sqlite3` 的异常体系。
135   -- 不涉及权限、鉴权、支付、不可逆数据变更等更高风险类别本身(部署系统虽然高风险,但本次改动不触碰其状态
136   - 机语义或副作用触发顺序,只改"冲突发生后异常长什么样"这一件事),无需额外审批门槛。
  81 +- 不涉及权限、鉴权、支付、不可逆数据变更等更高风险类别本身(部署系统虽然高风险,但本次改动不触碰其状态机语义或副作用触发顺序,只改“冲突发生后异常长什么样”这一件事),无需额外审批门槛。
137 82  
138 83 ## 8. 范围外后续事项(供后续任务参考,不在本次交付范围内)
139 84  
140   -- `health_check_failed`/`rollback_failed` 是否也应该被纳入 `ux_deployment_active` 的终态集合、允许在这两
141   - 个状态下发起新部署——需要业务侧判断"健康检查失败/回滚失败之后,是否应该允许立即重新尝试部署",涉及
142   - 01-M/04-P 状态机语义变更,不是本次 bug 修复的范围,留待独立任务讨论。
143   -- `deploy_supervisor.py` 轮询循环如何消费本次新增的具名异常(例如是否需要针对 `ConcurrentDeploymentError`
144   - 做专门的等待重试逻辑)——本次只保证异常"存在且可诊断",调用方的消费策略留给后续任务。
  85 +- `health_check_failed`/`rollback_failed` 是否也应该被纳入 `ux_deployment_active` 的终态集合、允许在这两个状态下发起新部署——需要业务侧判断“健康检查失败/回滚失败之后,是否应该允许立即重新尝试部署”,涉及 01-M/04-P 状态机语义变更,不是本次 bug 修复的范围,留待独立任务讨论。
  86 +- `deploy_supervisor.py` 轮询循环如何消费本次新增的具名异常(例如是否需要针对 `ConcurrentDeploymentError` 做专门的等待重试逻辑)——本次只保证异常“存在且可诊断”,调用方的消费策略留给后续任务。
... ...