Commit 5098fbe4c922022869d12c85cb923227f8c7ac94
1 parent
efcf600c
U-042: expose monitor_window_hours/monitor_min_task_count as testing knobs
Attempt 13b was the first real run to get all the way through a real cutover -- task lifecycle, classification, staging, health check (fixed last commit), and a genuine blue-green cutover all succeeded for real. It correctly landed in 'monitor' and then correctly couldn't finish: the real production exit condition (deploy_monitor.MONITORING_WINDOW_HOURS=24h OR MONITORING_MIN_TASK_COUNT=10 real tasks in the monitored project, NOT the disposable per-cycle canary project) isn't practical to wait out in an interactive session. This isn't a bug -- those are sensible real defaults -- it's a testability gap: run_self_hosting_closed_loop already exposes monitor_timeout_cycles as an override knob for exactly this kind of situation, but never threaded the two constants that actually gate "monitoring_window_clean" through to run_monitor_cycle. Added monitor_window_hours/monitor_min_task_count params, defaulting to deploy_monitor's real constants (production behavior unchanged), threaded through to the actual run_monitor_cycle call. New assertion locks in that overrides actually reach it. Also documents the full run of 9 real bugs found and fixed across 13 attempts in development-plan.md's U-042 note. 564 tests green (441 src + 123 supervisor). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Showing
3 changed files
with
27 additions
and
2 deletions
docs/development-plan.md
| ... | ... | @@ -404,6 +404,7 @@ PH2-S1/S2/S3/S4 之间除标注的依赖外相互独立,可并行推进。 |
| 404 | 404 | - **U-042 [Risk Gate]** 自举闭环端到端验收:真实复现 `self-development-feasibility.md` 第 3 节的 9 步场景(修复 graphlemon 自己 `p_org_task_lifecycle` 状态机里的一个真实植入 bug),走完整"部署→健康检查→cutover→监控→finalize"或"检测异常→自动回滚"两条路径中至少一条 |
| 405 | 405 | - 依赖:U-035, U-041 |
| 406 | 406 | - Done:全程无需人工评审代码内容;参照 graphloop `RUN_REAL_AGENT_E2E=1` 的先例,真实拉起两个 graphlemon 实例的端到端用例默认跳过,需要显式环境变量开启,日常回归只跑 mock 版本 |
| 407 | + - **真实压测(2026-08-03,累计 13 次真实尝试,用户明确授权、逐次确认后执行)**:从 U-042 建成到本次压测之前,这条真实自举闭环从未真正跑过一次。逐次真实跑之后依次发现并修复了 9 个此前完全未知的真实缺口,每一个都只有真跑起来才会暴露:① graphlemon 自己的 `.claude/agents/*.md` 从未真正部署过(每个之前的真实测试都是对着一次性 target project 克隆做的,从没有自我 targeting 过);② `work_node_run.ensure_worktree_committed()` 缺失——Coder 编辑 worktree 后从不 commit,`merge_worktree` 空操作,真实改动被 `remove_worktree` 静默吞掉;③ `deploy_stage._copy_prod_db()` 没处理"第一次部署、prod db 压根不存在"这个引导期场景;④ `_check_prd_consistency()` 没做 self-target worktree 隔离,真实 memory 写入落在主仓库触发下一个节点合并失败;⑤ `_normalize_graph_manifest()` 没处理"LLM 完全不给 depends_on/edges"这种比已知情况更彻底的偏差;⑥ `ensure_worktree_committed()` 兜底只覆盖了 worktree 自身,没覆盖主仓库检出目录本身的漂移;⑦ 修⑥引入的新 bug——排除 pathspec 和 `.gitignore` 同时命中同一路径时 `git add -A` 直接报错;⑧ `deploy_rollback.rollback_path_a()` 从不清理候选的 worktree 检出目录,永久留在磁盘上;⑨ `check_http_health()` 单次尝试不轮询,候选进程刚 spawn、端口还没真正监听时立刻误判为不健康(这条修复涉及 03-C"不重试"策略的解释,跟用户确认后再动手,结论是"容忍启动延迟"和"给失败检查第二次机会"是两回事)。第 13 次真实压测第一次真正走完了完整链路(task→plan→code→merge→classify→stage→health_check→**真实 cutover**),只在 `monitor` 阶段因为真实生产参数是 24 小时或 10 个真实任务才关闭监控窗口(`deploy_monitor.MONITORING_WINDOW_HOURS`/`MONITORING_MIN_TASK_COUNT`)而没能在交互式会话里等到 `finalized`——不是 bug,是真实生产参数本来就不该被一次交互式验收跑等到;`run_self_hosting_closed_loop()` 补了 `monitor_window_hours`/`monitor_min_task_count` 两个可覆盖参数(和已有的 `monitor_timeout_cycles` 同一类"给验收跑用的旋钮",不影响真实生产默认值),使真正拿到一次 `finalized` 终态在下一次压测里变得可行。 | |
| 407 | 408 | - **实现补充(2026-08-02)**:新增 `supervisor/self_hosting_e2e.py`:`run_self_hosting_closed_loop()` 把这次会话建好的全部 PH5 模块(U-034~U-041)+ Graph Engine 自己的 `scheduler.run_goal_to_completion`(U-032)串成一条真实可调用的链路——真实 self-target Goal → Planner/Coder/Validator(真实 `claude` 调用,`scheduler` 内部已经走 U-035 的 self-target 强制 worktree)→ PASS 后走 U-035 记的 `core_engine` 审计 `bug_record` → `deploy_stage.stage_deployment` → `deploy_health_check.run_health_check` → 通过则 `deploy_cutover.cutover`,不通过直接 `deploy_rollback.rollback_path_a` 停在这一步 → 循环 `deploy_monitor.run_monitor_cycle` 直到 `monitoring_window_clean`(`finalized`)或 `anomaly_detected`(`deploy_rollback.rollback_path_b`)——**这正是本 Unit 要求的"两条路径至少走一条",实现上两条路径都是同一次真实调用可能走到的真实分支,不是分别写死两个脚本**。**明确不是**生产级 Deploy Supervisor 的最终 `poll_fn` 实现——真正独立轮询"有没有新的 self-target 合并等待部署"的发现机制是 U-036 留的钩子,本 Unit 只提供验收场景自己的编排入口,不代表以后正式的常驻发现循环长这样,避免把验收脚本和生产实现搞混。 |
| 408 | 409 | - **Mock 版本(日常回归)**:`test_self_hosting_e2e.py` 前 6 个测试类在每个已经独立测试过真实机制的模块边界上打桩(`scheduler.run_goal_to_completion`/`deploy_stage.stage_deployment`/`deploy_health_check.run_health_check`/`deploy_cutover.cutover`/`deploy_monitor.run_monitor_cycle`/`deploy_rollback.rollback_path_a`/`rollback_path_b`),只验证编排本身的分支/顺序逻辑对不对(任务没到 done 提前返回、不是 core_engine 提前返回、健康检查失败走 A 不碰 cutover、监控干净走 finalized、监控异常走 B),不重新证明任何一个模块自己的真实机制——那些已经在各自的测试文件里用真实进程/真实 git 仓库验证过了。 |
| 409 | 410 | - **真实版本,已建好但没有执行**:`RealSelfHostingClosedLoopTest`,字面复现 `self-development-feasibility.md` 例子本身("loop_round > 3 应该是 loop_round >= 3")。**用了一个和项目一贯的 `RUN_REAL_AGENT_E2E` 不同、单独命名的环境变量 `RUN_REAL_SELF_HOSTING_E2E`**——这是本项目目前为止影响面最大的一个动作,不只是花真钱调用 `claude`(那是 `RUN_REAL_AGENT_E2E` 覆盖的常规风险),而是真的可能替换掉正在跑的 graphlemon 进程本身、真的会在这个仓库自己的 git 历史上跑 `git revert`——用不同名字的环境变量,是为了不让"为了跑别的真实测试而设的 `RUN_REAL_AGENT_E2E=1`"意外带着触发这个。**没有替用户执行这个测试**:按本 Unit 开工前收到的明确指示,这类"影响范围超出本进程/本会话"的动作,写好、gate 好就停,真正执行前需要用 AskUserQuestion 问过用户——这次实现结束后立刻会问。 | ... | ... |
supervisor/self_hosting_e2e.py
| ... | ... | @@ -34,6 +34,8 @@ class SelfHostingLoopError(Exception): |
| 34 | 34 | def run_self_hosting_closed_loop( |
| 35 | 35 | db_path, goal_text, target_folder_path=None, prod_db_path=None, |
| 36 | 36 | poll_interval=5.0, monitor_timeout_cycles=None, sleep=time.sleep, now=time.time, |
| 37 | + monitor_window_hours=deploy_monitor.MONITORING_WINDOW_HOURS, | |
| 38 | + monitor_min_task_count=deploy_monitor.MONITORING_MIN_TASK_COUNT, | |
| 37 | 39 | ): |
| 38 | 40 | """Returns {"stage", "outcome", ...}. `stage` names how far the scenario |
| 39 | 41 | got (`task_lifecycle` / `classification` / `health_check` / `cutover` / |
| ... | ... | @@ -41,7 +43,17 @@ def run_self_hosting_closed_loop( |
| 41 | 43 | doesn't reach 'done', or doesn't get classified core_engine, is a valid |
| 42 | 44 | early-return (not every self-target task is a core-engine change -- see |
| 43 | 45 | U-035), not a scenario failure. |
| 44 | - """ | |
| 46 | + | |
| 47 | + `monitor_window_hours`/`monitor_min_task_count` default to deploy_monitor's | |
| 48 | + real production constants (24h / 10 real tasks in the monitored project -- | |
| 49 | + NOT the disposable per-cycle canary project `check_canary_task` creates, | |
| 50 | + which is a different project_id each cycle and never counts toward this). | |
| 51 | + A real run (2026-08-03) reached 'cutover' and then correctly sat in | |
| 52 | + 'monitor' for the full real production window -- neither waiting 24 | |
| 53 | + real hours nor submitting 10 real Goals against the monitored project is | |
| 54 | + practical for an interactive acceptance run, so these are exposed as | |
| 55 | + overridable, same as `monitor_timeout_cycles` already is, rather than | |
| 56 | + leaving the real scenario impossible to actually finish observing.""" | |
| 45 | 57 | target_folder_path = target_folder_path or paths.repo_root() |
| 46 | 58 | prod_db_path = prod_db_path or paths.db_path() |
| 47 | 59 | if not paths.is_self_target(target_folder_path): |
| ... | ... | @@ -104,6 +116,7 @@ def run_self_hosting_closed_loop( |
| 104 | 116 | cycle_result = deploy_monitor.run_monitor_cycle( |
| 105 | 117 | conn, staged["deployment_id"], cutover_result["live_pid"], cutover_result["live_port"], |
| 106 | 118 | project_id, baseline_rate, cutover_at, now=now, |
| 119 | + window_hours=monitor_window_hours, min_task_count=monitor_min_task_count, | |
| 107 | 120 | ) |
| 108 | 121 | if cycle_result["status"] == "monitoring_window_clean": |
| 109 | 122 | conn.execute( | ... | ... |
supervisor/test_self_hosting_e2e.py
| ... | ... | @@ -154,10 +154,21 @@ class MonitorReachesFinalizedTest(_RealDbTestBase): |
| 154 | 154 | "status": "monitoring_window_clean", "latest_signal": {"signal_type": "process_alive", "status": "ok", "detail": None}, |
| 155 | 155 | }) as mock_monitor, \ |
| 156 | 156 | patch.object(deploy_rollback, "rollback_path_b") as mock_rb_b: |
| 157 | - result = self_hosting_e2e.run_self_hosting_closed_loop(self.db_path, "goal", target_folder_path=paths.repo_root()) | |
| 157 | + result = self_hosting_e2e.run_self_hosting_closed_loop( | |
| 158 | + self.db_path, "goal", target_folder_path=paths.repo_root(), | |
| 159 | + monitor_window_hours=0.001, monitor_min_task_count=1, | |
| 160 | + ) | |
| 158 | 161 | |
| 159 | 162 | self.assertEqual(result, {"stage": "monitor", "outcome": "finalized", "deployment_id": "dep-1"}) |
| 160 | 163 | mock_monitor.assert_called_once() |
| 164 | + # 2026-08-03: a real run reached 'cutover' and then couldn't | |
| 165 | + # practically finish observing the monitor stage -- the real | |
| 166 | + # production window is 24h/10 real tasks (deploy_monitor's own | |
| 167 | + # constants), impractical to wait out interactively. These must be | |
| 168 | + # overridable and actually reach run_monitor_cycle, not silently | |
| 169 | + # stuck at the real production defaults. | |
| 170 | + self.assertEqual(mock_monitor.call_args.kwargs["window_hours"], 0.001) | |
| 171 | + self.assertEqual(mock_monitor.call_args.kwargs["min_task_count"], 1) | |
| 161 | 172 | mock_rb_b.assert_not_called() |
| 162 | 173 | |
| 163 | 174 | conn = schema.connect(self.db_path) | ... | ... |