Commit febab504baad159a0506b556e5ecd8e40569e409

Authored by graphlemon
1 parent 265752a1

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

.graphlemon/runs/911861fe-446a-4bab-8b69-b4872b92d616/benchmark.md
1   -The graphlemon Work Node (planner) workflow completed. It produced a Benchmark artifact for the goal "修复 supervisor/deploy_stage.py 并发部署互斥锁问题" (fix missing concurrency mutex in stage_deployment()):
  1 +# Benchmark:`supervisor/deploy_stage.py` 并发部署冲突诊断化处理
2 2  
3   -- Scope: new `ConcurrentDeploymentError` exception + try/except around the existing `INSERT INTO deployment` in supervisor/deploy_stage.py; corresponding test tightening/additions in supervisor/test_deploy_stage.py. No schema change, no new dependency.
4   -- Key finding: ux_deployment_active's actual exclusion set is only finalized/rolled_back/blocked_pending_human — health_check_failed and rollback_failed are NOT excluded, so those must still block a new deployment.
5   -- 9 test scenarios defined: happy path (no prior row), three terminal-state pass-throughs, the conflicting in-flight case (with exception attribute assertions), two failure-boundary statuses, a no-side-effects check, and a full regression run of both src and supervisor test suites.
  3 +对应 PRD:《`supervisor/deploy_stage.stage_deployment()` 并发部署冲突的诊断化处理》(FR-1~FR-5, AC-1~AC-6)。
  4 +对应 SDD:《`supervisor/deploy_stage.py` 并发部署冲突诊断化处理》 §2~§6。
6 5  
7   -No repo files changed (git status clean) — this was a pure Planner artifact-generation step; the Benchmark content is returned as this node's structured output for the next stage (Coder) to consume.
8 6 \ No newline at end of file
  7 +测试文件:`supervisor/test_deploy_stage.py`。所有用例使用真实 sqlite3 内存/临时文件数据库 +
  8 +真实 git worktree(沿用既有 `DeployStageTestBase` 约定),仅 mock `deploy_stage._launch_candidate`
  9 +(避免真的 fork 子进程监听端口)。禁止 mock `sqlite3`、`conn.execute`、`_is_concurrent_deployment_conflict`
  10 +本身——并发冲突必须由真实的 `ux_deployment_active` 唯一索引触发,不能靠 mock 伪造。
  11 +
  12 +## 0. 公共前置(Test Fixture)
  13 +
  14 +- `self.conn`:真实 sqlite3 连接,已执行 `src/schema.py` 建表 DDL(含 `ux_deployment_active` 索引)。
  15 +- `self.repo_dir`:临时 git 仓库,至少有 `commit_v1`(初始提交)与 `commit_v2`(后续提交)两个真实 commit hash。
  16 +- `self.prod_db_path`:临时文件路径,指向一个已初始化 schema 的 sqlite 文件(`_checkout_candidate`
  17 + / `_launch_candidate` 需要它存在)。
  18 +- 每个测试类结束时清理临时目录(`tearDown` 中 `shutil.rmtree`),不留孤儿 worktree。
  19 +
  20 +## 1. Happy Path 场景
  21 +
  22 +### T-01:无历史部署记录,首次 staging 正常放行
  23 +
  24 +- **输入**:`self.conn` 中 `deployment` 表为空(无任何行)。调用
  25 + `deploy_stage.stage_deployment(self.conn, self.commit_v2, repo_root=self.repo_dir, prod_db_path=self.prod_db_path, port=9001)`,
  26 + `_launch_candidate` 被 mock 为返回 `pid=111` 的对象,不真实启动子进程。
  27 +- **行为**:`INSERT` 语句一次性成功提交(不进入 `except sqlite3.IntegrityError` 分支);
  28 + `_checkout_candidate` 使用真实 `git worktree add` 检出 `commit_v2`;成功后该行 `UPDATE`
  29 + 为 `status = 'candidate_starting'`。
  30 +- **期望输出(可观察断言)**:
  31 + 1. 调用不抛出任何异常。
  32 + 2. 返回值是一个 dict/mapping,`result["status"] == "candidate_starting"`,
  33 + `result["git_commit_hash"] == self.commit_v2`,`result["deployment_id"]` 非空字符串。
  34 + 3. `SELECT COUNT(*) FROM deployment` 结果为 `1`。
  35 + 4. `SELECT status FROM deployment WHERE deployment_id = ?` 返回 `"candidate_starting"`。
  36 +
  37 +### T-02:已存在一条 `finalized` 状态的历史部署,新 staging 正常放行(AC-2,既有回归测试,不改)
  38 +
  39 +- **输入**:先插入一行 `deployment_id='dep-old', git_commit_hash='commit-old', status='finalized'`
  40 + (模拟上一次成功落地的部署),再调用 `stage_deployment(self.conn, self.commit_v2, ...)`。
  41 +- **行为**:`ux_deployment_active` 索引条件不包含 `finalized` 状态,`INSERT` 不冲突,一次性成功。
  42 +- **期望输出**:
  43 + 1. 不抛出异常;`result["status"] == "candidate_starting"`。
  44 + 2. `SELECT COUNT(*) FROM deployment` 结果为 `2`(旧的 `finalized` 行 + 新行都保留)。
  45 + 3. 新行的 `previous_commit_hash == 'commit-old'`(既有 bootstrap-from-previous-finalized 行为不回归,
  46 + 由既有测试 `BootstrapPreviousTest.test_prior_finalized_deployment_supplies_previous_fields` 覆盖,
  47 + 此处仅确认不与本次改动冲突)。
  48 +
  49 +### T-03:已存在一条 `rolled_back` 状态的历史部署,新 staging 正常放行(AC-3,新增)
  50 +
  51 +- **输入**:`_seed_deployment("rolled_back")` 插入一行 `deployment_id='dep-prior', git_commit_hash='commit-prior', status='rolled_back'`。
  52 + 调用 `stage_deployment(self.conn, self.commit_v2, repo_root=self.repo_dir, prod_db_path=self.prod_db_path, port=9101)`,
  53 + `_launch_candidate` mock 返回 `pid=111`。
  54 +- **行为**:`ux_deployment_active` 索引不约束 `rolled_back` 状态,`INSERT` 一次性成功。
  55 +- **期望输出**:
  56 + 1. 不抛出 `ConcurrentDeploymentError`,也不抛出其他异常。
  57 + 2. `result["status"] == "candidate_starting"`。
  58 + 3. `SELECT status FROM deployment WHERE deployment_id = ?` (新插入的行)返回 `"candidate_starting"`。
  59 + 4. `SELECT COUNT(*) FROM deployment` 结果为 `2`(两行并存,旧行状态仍是 `rolled_back`,未被改动)。
  60 +
  61 +### T-04:已存在一条 `blocked_pending_human` 状态的历史部署,新 staging 正常放行(AC-4,新增)
  62 +
  63 +- **输入**:`_seed_deployment("blocked_pending_human")` 插入一行 `status='blocked_pending_human'`。
  64 + 调用 `stage_deployment(self.conn, self.commit_v2, repo_root=self.repo_dir, prod_db_path=self.prod_db_path, port=9102)`。
  65 +- **行为**:同 T-03,`ux_deployment_active` 索引不约束该状态。
  66 +- **期望输出**:
  67 + 1. 不抛出异常。
  68 + 2. `result["status"] == "candidate_starting"`。
  69 + 3. `SELECT status FROM deployment WHERE deployment_id = ?`(新行)返回 `"candidate_starting"`。
  70 + 4. `SELECT COUNT(*) FROM deployment` 结果为 `2`;旧行 `status` 仍为 `"blocked_pending_human"`(未被覆盖或删除)。
  71 +
  72 +## 2. 边界场景
  73 +
  74 +### T-05:并发冲突后,`deployment` 表行数不增长(AC-1 的行数不变式,附属于 T-06 但单独可断言)
  75 +
  76 +- **输入**:延续 T-06 的两次调用序列。
  77 +- **行为**:第二次 `INSERT` 触发 `IntegrityError`,`except` 分支执行 `conn.rollback()`,未提交任何新行。
  78 +- **期望输出**:冲突发生后立即执行 `SELECT COUNT(*) FROM deployment`,结果仍为 `1`
  79 + (只有第一次调用成功插入的那一行),不会因为失败的 `INSERT` 尝试而残留半提交的行或脏行。
  80 +
  81 +### T-06:非 `ux_deployment_active` 触发的 `IntegrityError` 不应被误判为并发冲突(`_is_concurrent_deployment_conflict` 判别边界)
  82 +
  83 +- **输入**:直接单元测试 `deploy_stage._is_concurrent_deployment_conflict(...)`,不经过完整
  84 + `stage_deployment()` 流程:
  85 + - 用例 A:构造一个 `sqlite3.IntegrityError("UNIQUE constraint failed: index 'ux_deployment_active'")`,
  86 + 传入函数。
  87 + - 用例 B:构造一个 `sqlite3.IntegrityError("NOT NULL constraint failed: deployment.git_commit_hash")`,
  88 + 传入函数。
  89 + - 用例 C:构造一个 `sqlite3.IntegrityError("UNIQUE constraint failed: index 'ux_some_other_index'")`,
  90 + 传入函数(同为 UNIQUE 冲突,但索引名不同)。
  91 +- **期望输出**:
  92 + - 用例 A:返回 `True`。
  93 + - 用例 B:返回 `False`。
  94 + - 用例 C:返回 `False`(精确匹配索引名字符串,不是宽泛匹配 "UNIQUE constraint failed")。
  95 +
  96 +### T-07:`_find_blocking_deployment` 在多条非终态行存在时返回最新一条
  97 +
  98 +- **输入**:连续插入两行非终态部署(绕过 `stage_deployment()` 直接用 `conn.execute` 插入,
  99 + 借此模拟索引本应阻止但测试内部直接构造历史数据的边界情况):
  100 + `deployment_id='dep-a', status='staged'`(先插入),`deployment_id='dep-b', status='health_check_running'`
  101 + (后插入,rowid 更大)。之后调用 `deploy_stage._find_blocking_deployment(self.conn)`。
  102 +- **期望输出**:返回 `('dep-b', 'health_check_running')`(按 `rowid DESC LIMIT 1`,取最后插入的一条),
  103 + 不是 `dep-a`。
  104 +
  105 +### T-08:`_find_blocking_deployment` 在查询本身失败时返回 `None` 而不抛异常
  106 +
  107 +- **输入**:传入一个已被关闭(`conn.close()`)的 sqlite3 连接对象给 `_find_blocking_deployment`。
  108 +- **期望输出**:函数捕获内部 `sqlite3.Error`(连接已关闭时执行 `execute` 会抛出
  109 + `sqlite3.ProgrammingError`,是 `sqlite3.Error` 子类),返回 `None`,不向调用方传播异常。
  110 +
  111 +## 3. 失败/异常场景(核心场景,对应 AC-1)
  112 +
  113 +### T-09:已有一条进行中(`candidate_starting`)部署时,第二次 staging 被拒绝,抛出具名异常且携带诊断字段(AC-1,改造后的 `test_only_one_non_terminal_deployment_allowed`)
  114 +
  115 +- **输入**:
  116 + 1. 第一次调用:`first = deploy_stage.stage_deployment(self.conn, self.commit_v2, repo_root=self.repo_dir, prod_db_path=self.prod_db_path, port=9001)`,
  117 + `_launch_candidate` mock 返回 `pid=1`。该调用成功,行状态被更新为 `"candidate_starting"`。
  118 + 2. 第二次调用(同一个 `self.conn`,模拟并发触发):
  119 + `deploy_stage.stage_deployment(self.conn, self.commit_v1, repo_root=self.repo_dir, prod_db_path=self.prod_db_path, port=9002)`,
  120 + 同样 mock `_launch_candidate`(不应被调用到,因为应在 `INSERT` 阶段就失败)。
  121 +- **行为**:第二次调用的 `INSERT` 触发 `ux_deployment_active` 唯一索引冲突,进入
  122 + `except sqlite3.IntegrityError` 分支,`_is_concurrent_deployment_conflict` 返回 `True`,
  123 + `_find_blocking_deployment` 查到第一次调用留下的那一行,最终 `raise ConcurrentDeploymentError(...)`。
  124 +- **期望输出(用 `assertRaises` + 异常实例属性断言,不能只断言异常类型)**:
  125 + 1. 抛出的异常类型是 `deploy_stage.ConcurrentDeploymentError`(不是裸 `sqlite3.IntegrityError`)。
  126 + 2. `err.git_commit_hash == self.commit_v1`(本次被拒绝调用的 commit,不是占用中的那个)。
  127 + 3. `err.blocking_deployment_id == first["deployment_id"]`(第一次调用返回的 deployment_id)。
  128 + 4. `err.blocking_status == "candidate_starting"`。
  129 + 5. `str(err)` 包含子串 `self.commit_v1`、`first["deployment_id"]`、`"candidate_starting"` 三者
  130 + (消息可读性检查,确认三个字段确实被拼进了错误消息,不只是存在于属性上)。
  131 + 6. 第二次调用中 `mock_launch`(`_launch_candidate`)**未被调用**(`mock_launch.assert_not_called()`)——
  132 + 证明失败发生在 `INSERT` 阶段,尚未进入 checkout/launch 流程,避免产生孤儿 worktree 或孤儿子进程。
  133 + 7. `SELECT COUNT(*) FROM deployment` 结果为 `1`(不新增冲突的第二行)。
  134 +
  135 +### T-10:已有一条 `staged`(尚未进入 checkout)状态的部署时,第二次 staging 同样被拒绝
  136 +
  137 +- **输入**:绕过完整 `stage_deployment()`,直接 `conn.execute` 插入一行
  138 + `deployment_id='dep-x', git_commit_hash='commit-x', status='staged'` 并 `commit()`(模拟第一次调用
  139 + 在 `INSERT` 成功、但尚未走到后续 `UPDATE ... candidate_starting` 之前发生的时间窗口)。
  140 + 之后调用 `deploy_stage.stage_deployment(self.conn, self.commit_v2, repo_root=self.repo_dir, prod_db_path=self.prod_db_path, port=9003)`。
  141 +- **行为**:同 T-09,`ux_deployment_active` 索引同样约束 `staged` 状态(不仅约束 `candidate_starting`),
  142 + `INSERT` 冲突。
  143 +- **期望输出**:
  144 + 1. 抛出 `deploy_stage.ConcurrentDeploymentError`。
  145 + 2. `err.blocking_deployment_id == "dep-x"`,`err.blocking_status == "staged"`。
  146 + 3. `SELECT COUNT(*) FROM deployment` 结果为 `1`。
  147 +
  148 +### T-11:并发冲突后,`self.conn` 处于干净可用状态,可以立即执行后续只读查询(`conn.rollback()` 效果验证)
  149 +
  150 +- **输入**:紧接 T-09 的第二次调用(捕获异常后),在同一个 `except`/`with self.assertRaises` 块外,
  151 + 立即对 `self.conn` 执行一次普通只读查询:`self.conn.execute("SELECT 1").fetchone()`。
  152 +- **期望输出**:查询正常返回 `(1,)`,不抛出 `sqlite3.OperationalError`(例如 "cannot start a
  153 + transaction within a transaction" 或连接被挂起的错误),证明 `except` 分支里的 `conn.rollback()`
  154 + 确实清理了因 `IntegrityError` 而悬挂的隐式事务。
  155 +
  156 +### T-12:`CheckoutFailedError` 路径不受本次改动影响(AC-5,既有回归测试,不改,仅确认覆盖不丢失)
  157 +
  158 +- **输入**:`INSERT` 成功(无并发冲突),`_checkout_candidate` 内部触发的 `git worktree add`
  159 + 通过 mock `subprocess.run`(或既有测试里对 `_checkout_candidate` 依赖的 subprocess 调用)
  160 + 使其抛出 `subprocess.CalledProcessError`。
  161 +- **行为**:`stage_deployment()` 捕获该错误,转译为既有的 `deploy_stage.CheckoutFailedError`;
  162 + 本次改动新增的 `try/except sqlite3.IntegrityError` 只包裹 `INSERT` 语句本身,不包裹
  163 + `_checkout_candidate` 调用,因此该分支的行为应与改动前逐字节一致。
  164 +- **期望输出**:
  165 + 1. 抛出的异常类型是 `deploy_stage.CheckoutFailedError`(不是 `ConcurrentDeploymentError`,
  166 + 两者互不干扰)。
  167 + 2. 对应的 `deployment` 行 `status` 被更新为 `"blocked_pending_human"`(既有行为,不回归)。
  168 +
  169 +## 4. 覆盖矩阵(PRD 验收标准 -> 本文档场景)
  170 +
  171 +| PRD 验收标准 | 覆盖场景 |
  172 +|---|---|
  173 +| AC-1(并发冲突:具名异常 + 诊断字段 + 不新增行 + 未进入 launch) | T-09、T-10、T-05 |
  174 +| AC-2(`finalized` 放行,既有行为不回归) | T-02 |
  175 +| AC-3(`rolled_back` 放行) | T-03 |
  176 +| AC-4(`blocked_pending_human` 放行) | T-04 |
  177 +| AC-5(`CheckoutFailedError` 路径不回归) | T-12 |
  178 +| AC-6(全量测试全绿) | 见下方「全量回归」 |
  179 +| (SDD 补充)判别函数正确性 | T-06 |
  180 +| (SDD 补充)诊断查询健壮性 | T-07、T-08 |
  181 +| (SDD 补充)连接状态清理 | T-11 |
  182 +| (Happy path 基线,不属于本次改动但必须不回归) | T-01 |
  183 +
  184 +## 5. 全量回归(AC-6)
  185 +
  186 +本次改动完成后,以下两条命令必须均以退出码 `0` 结束,不允许有任何 `FAIL`/`ERROR`:
  187 +
  188 +```bash
  189 +python3 -m unittest discover -s src
  190 +python3 -m unittest discover -s supervisor -p "test_*.py"
  191 +```
  192 +
  193 +第二条命令必须完整跑完 `supervisor/test_deploy_stage.py` 中本文档列出的全部 12 个场景
  194 +(T-01~T-12),以及 `supervisor/` 下其余既有测试文件(`test_deploy_cutover.py`、
  195 +`test_deploy_health_check.py`、`test_deploy_monitor.py`、`test_deploy_rollback.py`、
  196 +`test_self_hosting_e2e.py` 等)——本次改动不修改这些文件,但它们间接依赖 `deploy_stage`
  197 +模块的公开接口(如 `stage_deployment` 的返回值结构、`CheckoutFailedError` 的存在),
  198 +必须确认未被意外破坏。
9 199 \ No newline at end of file
... ...