diff --git a/docs/development-plan.md b/docs/development-plan.md index 0440ad1..b1a0a2f 100644 --- a/docs/development-plan.md +++ b/docs/development-plan.md @@ -404,6 +404,7 @@ PH2-S1/S2/S3/S4 之间除标注的依赖外相互独立,可并行推进。 - **U-042 [Risk Gate]** 自举闭环端到端验收:真实复现 `self-development-feasibility.md` 第 3 节的 9 步场景(修复 graphlemon 自己 `p_org_task_lifecycle` 状态机里的一个真实植入 bug),走完整"部署→健康检查→cutover→监控→finalize"或"检测异常→自动回滚"两条路径中至少一条 - 依赖:U-035, U-041 - Done:全程无需人工评审代码内容;参照 graphloop `RUN_REAL_AGENT_E2E=1` 的先例,真实拉起两个 graphlemon 实例的端到端用例默认跳过,需要显式环境变量开启,日常回归只跑 mock 版本 + - **真实压测(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` 终态在下一次压测里变得可行。 - **实现补充(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 只提供验收场景自己的编排入口,不代表以后正式的常驻发现循环长这样,避免把验收脚本和生产实现搞混。 - **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 仓库验证过了。 - **真实版本,已建好但没有执行**:`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 问过用户——这次实现结束后立刻会问。 diff --git a/supervisor/self_hosting_e2e.py b/supervisor/self_hosting_e2e.py index 32967af..6e8a433 100644 --- a/supervisor/self_hosting_e2e.py +++ b/supervisor/self_hosting_e2e.py @@ -34,6 +34,8 @@ class SelfHostingLoopError(Exception): def run_self_hosting_closed_loop( db_path, goal_text, target_folder_path=None, prod_db_path=None, poll_interval=5.0, monitor_timeout_cycles=None, sleep=time.sleep, now=time.time, + monitor_window_hours=deploy_monitor.MONITORING_WINDOW_HOURS, + monitor_min_task_count=deploy_monitor.MONITORING_MIN_TASK_COUNT, ): """Returns {"stage", "outcome", ...}. `stage` names how far the scenario got (`task_lifecycle` / `classification` / `health_check` / `cutover` / @@ -41,7 +43,17 @@ def run_self_hosting_closed_loop( doesn't reach 'done', or doesn't get classified core_engine, is a valid early-return (not every self-target task is a core-engine change -- see U-035), not a scenario failure. - """ + + `monitor_window_hours`/`monitor_min_task_count` default to deploy_monitor's + real production constants (24h / 10 real tasks in the monitored project -- + NOT the disposable per-cycle canary project `check_canary_task` creates, + which is a different project_id each cycle and never counts toward this). + A real run (2026-08-03) reached 'cutover' and then correctly sat in + 'monitor' for the full real production window -- neither waiting 24 + real hours nor submitting 10 real Goals against the monitored project is + practical for an interactive acceptance run, so these are exposed as + overridable, same as `monitor_timeout_cycles` already is, rather than + leaving the real scenario impossible to actually finish observing.""" target_folder_path = target_folder_path or paths.repo_root() prod_db_path = prod_db_path or paths.db_path() if not paths.is_self_target(target_folder_path): @@ -104,6 +116,7 @@ def run_self_hosting_closed_loop( cycle_result = deploy_monitor.run_monitor_cycle( conn, staged["deployment_id"], cutover_result["live_pid"], cutover_result["live_port"], project_id, baseline_rate, cutover_at, now=now, + window_hours=monitor_window_hours, min_task_count=monitor_min_task_count, ) if cycle_result["status"] == "monitoring_window_clean": conn.execute( diff --git a/supervisor/test_self_hosting_e2e.py b/supervisor/test_self_hosting_e2e.py index cc5a2b6..580b278 100644 --- a/supervisor/test_self_hosting_e2e.py +++ b/supervisor/test_self_hosting_e2e.py @@ -154,10 +154,21 @@ class MonitorReachesFinalizedTest(_RealDbTestBase): "status": "monitoring_window_clean", "latest_signal": {"signal_type": "process_alive", "status": "ok", "detail": None}, }) as mock_monitor, \ patch.object(deploy_rollback, "rollback_path_b") as mock_rb_b: - result = self_hosting_e2e.run_self_hosting_closed_loop(self.db_path, "goal", target_folder_path=paths.repo_root()) + result = self_hosting_e2e.run_self_hosting_closed_loop( + self.db_path, "goal", target_folder_path=paths.repo_root(), + monitor_window_hours=0.001, monitor_min_task_count=1, + ) self.assertEqual(result, {"stage": "monitor", "outcome": "finalized", "deployment_id": "dep-1"}) mock_monitor.assert_called_once() + # 2026-08-03: a real run reached 'cutover' and then couldn't + # practically finish observing the monitor stage -- the real + # production window is 24h/10 real tasks (deploy_monitor's own + # constants), impractical to wait out interactively. These must be + # overridable and actually reach run_monitor_cycle, not silently + # stuck at the real production defaults. + self.assertEqual(mock_monitor.call_args.kwargs["window_hours"], 0.001) + self.assertEqual(mock_monitor.call_args.kwargs["min_task_count"], 1) mock_rb_b.assert_not_called() conn = schema.connect(self.db_path)