# Deploy Supervisor 蓝绿实例自动回滚机制设计 **日期**: 2026-07-30(同日更新:完成 Supervisor 概念改名消歧,见下方命名说明) **性质**: 详细设计,回应用户"自动回滚机制现在就设计一下,省得到 PH5 阶段有那个阶段的问题"。落地 `self-development-feasibility.md` 缺口 2(应用变更+生效)与缺口 3(用户更正版:不要求人工评审,要求 supervisor 自动监督回滚)。 **依赖**: `docs/supervisor-design-comparison.md`(方案选型结论:复用 graphloop 隔离/manifest/apply 库,见 `supervisor/isolation/`;Deploy Supervisor 自身决策逻辑必须是确定性代码,不接入 LLM)。 **状态**: 设计已回填进正式 SDD(`01-M`/`03-C`/`04-P`/`05`/`06`/`sdd-mvcp-contract.json`),代码尚未实现(`supervisor/isolation/` 除外,那是通用库不是本机制专属)。 **命名说明(2026-07-30)**:`graphlemon_architecture.md` 里已经有一个"Supervisor(调度子 Agent)"概念(Coder Work Graph 内部的常驻监控节点,负责监控 Work Node 执行状态、触发 reflection/重新调度)。本文档描述的是完全不同的另一个组件——一个**独立于 Graph Engine 之外**、专门负责 graphlemon 自我修改后蓝绿部署与自动回滚的进程。为避免和架构文档里的既有概念混淆,本文档统一称之为 **Deploy Supervisor(部署监督进程)**,所有标识符前缀使用 `c_deploy_supervisor_*`,与既有的 `Supervisor(调度子 Agent)`概念完全区分开:两者职责不同(一个管 Work Node 执行进度,一个管整个 graphlemon 进程的部署/回滚)、进程边界不同(一个在 Graph Engine 进程内,一个在 Graph Engine 进程外)、生命周期不同(一个随 Work Graph 任务存在,一个是常驻的独立进程)。 ## 1. 触发范围 本机制**只在** `target_folder_path == graphlemon 平台自身安装路径`(self-target 场景,`self-development-feasibility.md` 缺口 1 已定义的判断条件)且 Validator 对该次修改判定 PASS 之后触发。改别的 target project 时,走既有的"worktree 合并回主分支"流程即可,不涉及蓝绿实例——因为改的代码不是正在跑的这个进程本身。 触发时点:Validator PASS → worktree 分支 merge 回 graphlemon 自身仓库主分支(既有机制,`self-development-feasibility.md` 步骤 6)→ **Deploy Supervisor 从这里接管**,直到新代码安全生效或安全回滚为止。 ## 2. 组件与进程边界 三个独立操作系统进程,任何一个崩溃不影响另外两个: 1. **Live 实例**:当前正在服务的 graphlemon 后端(Graph Engine + 控制台 API),监听固定端口 `LIVE_PORT`,使用固定路径的生产 SQLite 库 `PROD_DB_PATH`。 2. **Candidate 实例**:本次自我修改合并后的新版本,Deploy Supervisor 拉起来做验证,监听临时端口,使用**一次性 DB 副本**,与生产 DB 完全隔离,验证期间无论 Candidate 干什么都不影响 Live。 3. **Deploy Supervisor 进程**:独立于以上两者的确定性 Python 脚本(stdlib only,无 LLM 调用),是本设计的主体。职责:拉起/健康检查/切换/监控/回滚 Candidate 与 Live,自身不做任何"业务判断"(比如不判断代码写得好不好),只做"这几个信号是否符合预设阈值"这种算术判断。 Deploy Supervisor 与 Live/Candidate 之间没有共享内存/共享失败域——这正是用户"第三方 agent"要求的字面落地:Deploy Supervisor 挂了不会拖死 Live,Live 挂了 Deploy Supervisor 还能观察到并处理。 ## 3. 数据模型(已回填 `01-M-模型设计.md`) 两个新实体: ### `deployment` | 字段 | 类型 | 说明 | |---|---|---| | `deployment_id` | string, PK | | | `git_commit_hash` | string | 本次候选版本对应的 commit | | `previous_commit_hash` | string | 回滚目标(上一个 `status=finalized` 的 deployment 的 `git_commit_hash`) | | `status` | enum | 见第 5 节状态机 | | `candidate_port` | int, nullable | 健康检查阶段临时端口 | | `candidate_db_path` | string, nullable | 一次性 DB 副本路径,验证结束后清理 | | `started_at` / `cutover_at` / `monitoring_closed_at` / `rollback_at` | timestamp, nullable | | | `rollback_reason` | string, nullable | 见第 6 节信号类型 | | `previous_instance_dir` | string | 保留的上一个已知良好版本 checkout 目录,直到本次 deployment 监控窗口正常关闭才允许清理 | ### `deploy_health_signal` | 字段 | 类型 | 说明 | |---|---|---| | `signal_id` | string, PK | | | `deployment_id` | string, FK | | | `sampled_at` | timestamp | | | `signal_type` | enum: `process_alive` \| `http_health` \| `canary_task` \| `validator_approval_rate` | | | `status` | enum: `ok` \| `degraded` \| `fail` | | | `detail` | string | 供人工事后排查,不参与自动判断 | ## 4. Cutover 机制(部署新版本) 1. Deploy Supervisor 拉取合并后的主分支到全新目录(不是原地 `git pull`,避免影响可能仍在读取源码的 Live 进程),生成 `deployment` 记录,`status=staged`。 2. 复制 `PROD_DB_PATH` 到 `candidate_db_path`(一次性副本,用完即删,绝不让 Candidate 直接碰生产库)。 3. 用 `supervisor/isolation/isolated_workspace.isolate_workspace()` 家族同款隔离原则拉起 Candidate 进程:`subprocess.Popen`,`shell=False`,固定 argv 形态,监听 `candidate_port`(生产端口 +1,或探测一个空闲端口),指向 `candidate_db_path`。`status=candidate_starting`。 4. 跑健康检查(见第 6 节),`status=health_check_running`。 - 全部通过 → `status=health_check_passed`,进入 5。 - 任一失败/超时 → `status=health_check_failed` → 走**回滚路径 A**(第 7.1 节)。 5. **Cutover 本体**(本地单用户桌面应用,不需要处理并发外部流量/负载均衡,这一条假设需要用户确认,见第 9 节开放问题): - Live 停止接受新的 Org Node 触发(不接受新 `c_org_node_trigger` 调用),但允许已在执行的 `work_node_execution` 走到可安全中断的检查点(已有的持久化恢复机制保证中断后可恢复,不强求"优雅排空")。 - Live 进程退出。 - Candidate 关闭一次性 DB 副本连接,改连 `PROD_DB_PATH`,改绑 `LIVE_PORT`。 - Candidate 成为新的 Live。`status=live`,`cutover_at=now`。 - 原来的 Live checkout 目录保留为 `previous_instance_dir`,**不删除**,直到监控窗口正常结束。 ## 5. 完整状态机 ``` staged -> candidate_starting -> health_check_running -> health_check_failed [回滚路径 A:见 7.1,不影响 Live,直接结束] -> health_check_passed -> cutover_in_progress -> live -> monitoring (进入监控窗口) -> anomaly_detected -> rollback_in_progress -> rolled_back [回滚路径 B:见 7.2,Live 换回旧版本] -> rollback_failed -> blocked_pending_human [兜底,见 7.3] -> monitoring_window_clean -> finalized (旧 previous_instance_dir 可清理) ``` `finalized` 是唯一的"安全终态";`blocked_pending_human` 是唯一需要人工介入的终态,且触发条件严格限定为"自动回滚机制本身失败",不是"代码写得不好"——这与用户对缺口 3 的更正(不要求人工评审代码内容)不冲突,是不同层面的安全网。 ## 6. 健康检查 / 监控信号(全部确定性,不接入任何 LLM 判断) ### Pre-cutover 健康检查(`health_check_running`,有界超时,全部通过才能 cutover) | 检查项 | 方法 | 判定 | |---|---|---| | `process_alive` | 启动后轮询 `os.kill(pid, 0)`,超时 `HEALTH_CHECK_STARTUP_TIMEOUT` | 超时未存活 = fail | | `http_health` | 请求 Candidate 的 `/api/health`,超时 `HEALTH_CHECK_HTTP_TIMEOUT` | 非 200 或超时 = fail | | `canary_task` | 对 Candidate 跑一个固定的、结果可预期的最小任务(例如创建一个临时 project + 一个已知会通过的 trivial task,断言最终 `task.status` 与预期字段一致),超时 `HEALTH_CHECK_CANARY_TIMEOUT` | 结果不符/超时/异常 = fail | 任一 fail 立即进入回滚路径 A,不重试(重试属于"给坏版本第二次机会",不符合 fail-closed 原则;要重试整个自我修改,走 Planner 重新出一版)。 ### Post-cutover 监控(`monitoring`,窗口内持续采样) | 信号 | 方法 | 判定阈值(MVP 固定值,不做用户可配) | |---|---|---| | `process_alive` | 每 `MONITOR_SAMPLE_INTERVAL` 探活 | 连续崩溃/重启 ≥ `MAX_CRASH_COUNT` 次 / `CRASH_WINDOW` 内 = anomaly | | `http_health` | 同上频率请求 `/api/health` | 连续 `MAX_CONSECUTIVE_HTTP_FAIL` 次失败 = anomaly | | `canary_task` | 每 `CANARY_INTERVAL` 跑一次固定 canary 任务 | 单次失败即 anomaly(canary 本身就是"已知应该过"的任务,失败没有灰色地带) | | `validator_approval_rate` | 从 `execution_event`/`work_node_execution` 表按滚动窗口统计 Validator 节点 `approved` 占比,与 deployment 之前的历史基线比较 | 偏离基线超过 `VALIDATOR_RATE_DEVIATION_THRESHOLD`(绝对值,如 ±30 个百分点)= anomaly。**这一条直接对应用户点名的场景**:"改坏了的 validator"——如果新版本的 Validator 变得异常宽松(approve 率骤升)或异常严格(approve 率骤降到几乎全部拒绝),都会被这条信号捕获,且判断是纯算术,不需要另一个 LLM 来"评价 Validator 判得对不对" | 监控窗口:`MONITORING_WINDOW_HOURS`(固定值,如 24h)或 `MONITORING_MIN_TASK_COUNT`(固定值,如 10 个真实任务)先到者为准,之后无 anomaly 则 `monitoring_window_clean → finalized`。 ## 7. 回滚路径 ### 7.1 路径 A:Pre-cutover 回滚(健康检查未过) Live 从未被动过,回滚成本最低: 1. 终止 Candidate 进程(`SIGTERM`,参考 `hermes_watchdog.py` 的终止哲学)。 2. 删除 `candidate_db_path` 一次性副本。 3. `git revert` 本次合并 commit(不是 `reset`/force-push,保留历史可追溯,这一步复用 `workspace_security.validate_subprocess_command()` 的 argv 校验)。 4. 发一个 `bug_record`,`root_cause_category=core_engine`,把健康检查失败的具体信号/日志作为 evidence 挂上去,供下一轮 Planner/Coder 重新尝试时参考。 5. `status=health_check_failed`,记录结束,Live 全程未受影响,调度立即可以继续处理下一个任务。 ### 7.2 路径 B:Post-cutover 回滚(监控期间检测到 anomaly) Live 已经是 Candidate,需要真正"换回去": 1. 记录触发回滚的信号快照(哪个 signal_type、哪次采样、什么值)到 `deployment.rollback_reason`。 2. 当前 Live(即出问题的新版本)停止接受新触发,同样允许在途节点走到可恢复检查点。 3. 用 `previous_instance_dir`(第 4 步保留的上一版本 checkout)重新拉起进程,绑定 `LIVE_PORT`,连接 `PROD_DB_PATH`(生产库这次全程没有被换过,只是换了跑在它前面的代码进程,数据不受影响——这是 blue-green 相对"原地改代码"最大的安全收益)。 4. 对"复活的旧版本"跑一遍 7.1 同款的三项健康检查,确认旧版本本身没问题(正常情况下必过,因为它是上一次已经 `finalized` 的版本)。 5. 通过 → `status=rolled_back`,当前 Live = 旧版本,本次自我修改的 commit 在主分支上被 `git revert`,发 `bug_record`(同路径 A 第 4 步,evidence 换成监控期间的异常信号),调度恢复正常。 6. 不通过(极端情况:连旧版本都起不来,比如磁盘满了这类环境问题)→ 第 7.3 节。 ### 7.3 兜底:回滚本身失败 `status=rollback_failed → blocked_pending_human`。这是本设计里唯一保留的人工介入点,且明确限定触发条件仅为"自动恢复机制本身失效"(旧版本也起不来、DB 不可写这类基础设施问题),不是"代码判断谁对谁错"。控制台需要能清楚展示这个区别,避免用户以为又回到了"需要人工评审代码改得好不好"的老路。 ## 8. 与既有 SDD 的对接点(2026-07-30 已回填,见第二次更新记录) - `01-M-模型设计.md`:新增 `deployment`、`deploy_health_signal` 两个实体(第 3 节)。**已完成。** - `03-C-控制与接口.md`:新增 `c_deploy_supervisor_stage` / `c_deploy_supervisor_health_check` / `c_deploy_supervisor_cutover` / `c_deploy_supervisor_monitor` / `c_deploy_supervisor_rollback` 五个控制操作;`c_org_loop_route` 扩展 `root_cause_category=core_engine` 的路由分支。**已完成。** - `04-P-流程与状态流转.md`:新增 `p_deploy_supervisor_blue_green` 流程,状态转移表照搬第 5 节。**已完成。** - `05-实施约束与依赖.md`:新增本机制的 MVP 固定参数表(第 9 节),比照现有 `p_usage_limit_wait` 参数(5→10→20→30min backoff、8h/9d 超时)"MVP 阶段硬编码、不做用户可配"的先例,登记同一张表里;新增 self-target worktree 无条件强制规则。**已完成。** - `06-验收与测试锚点.md`:新增 REQ-15,覆盖:健康检查全过正常 cutover、健康检查失败路径 A、监控期 anomaly 触发路径 B、回滚本身失败兜底到 `blocked_pending_human` 四类 happy/failure 用例。**已完成。** - `sdd-mvcp-contract.json`:同步以上四类变更,重新跑 `jsonschema.validate()`。**已完成。** - `self-development-feasibility.md`:缺口 2/3 的"推荐方案"段落指向本文档。**已完成(2026-07-30 更新记录)。** ## 9. MVP 固定参数(草案值,需要在实现前用真实测量校准,参考 A-1 的验证方法论) | 参数 | 草案值 | 说明 | |---|---|---| | `HEALTH_CHECK_STARTUP_TIMEOUT` | 30s | Candidate 进程从拉起到能响应探活 | | `HEALTH_CHECK_HTTP_TIMEOUT` | 10s | 单次 `/api/health` 请求 | | `HEALTH_CHECK_CANARY_TIMEOUT` | 120s | 一次最小 canary 任务从提交到出结果 | | `MONITOR_SAMPLE_INTERVAL` | 5min | process/http 采样间隔 | | `CANARY_INTERVAL` | 1h | 监控期 canary 任务运行间隔 | | `MAX_CRASH_COUNT` / `CRASH_WINDOW` | 2 次 / 10min | 判定进程反复崩溃 | | `MAX_CONSECUTIVE_HTTP_FAIL` | 3 次 | 判定健康端点持续不可用 | | `VALIDATOR_RATE_DEVIATION_THRESHOLD` | ±30 个百分点 | 判定 Validator 行为异常 | | `MONITORING_WINDOW_HOURS` | 24h | 监控窗口时长上限 | | `MONITORING_MIN_TASK_COUNT` | 10 个真实任务 | 监控窗口任务数上限,先到者结束 | 以上数值全部是工程经验草案,不是实测结果——和 `05-实施约束与依赖.md` §5.4.2 现有的额度等待退避参数一样,需要在真正实现并跑几轮真实自我修改后重新校准,不建议直接当最终值写进 contract.json 的强约束里(建议标注为"可调,需实测校准"而不是"硬编码不可变",这一点和额度等待参数刻意标注为"MVP-fixed"略有不同,因为额度等待的时间线是 Anthropic 平台文档定义的外部事实,而这里的阈值是纯粹的工程经验值)。 ## 10. 未覆盖 / 开放问题(留给实现阶段决定,不阻塞本设计定稿) 1. **单用户桌面场景假设**:第 4 节 cutover 假设不需要处理并发外部流量/连接排空,只有 Cowork 控制台这一个使用者,断线可重连。如果未来 graphlemon 要支持多用户/多终端同时连接,这里的"直接切端口"简化模型需要重新评估。 2. **`candidate_db_path` 一次性副本的体积/耗时**:如果生产 DB 随着长期使用变得很大,每次 cutover 前整库复制可能变慢,需要的话可以后续改成"复制 schema + 近期数据子集"而不是整库,MVP 阶段先用整库复制,简单可靠优先。 3. **`git revert` 冲突**:如果本次自我修改之后主分支又有新的(比如另一个并发自我修改任务的)提交,revert 可能冲突,需要 Deploy Supervisor 能识别 revert 失败并转 `blocked_pending_human`(不在当前状态机图里显式画出,应该并入 7.3 兜底路径,而不是新开一个分支——这样能保持状态机简单)。