# 生产环境调度/复原循环——详细设计(FR-2 部分) **日期**: 2026-08-06 **性质**: SDD 层面设计文档,对应 `scheduler-production-polling-loop-requirements.md`。 **范围**: 只覆盖该需求文档的 FR-2(在途任务复原)。FR-1(新 Goal 自动派发)按需求文档自身的 非目标 #1 和风险章节,仍然是一个未决的、需要单独确认的自主权边界决策,本文档不涉及,需求文档 本身也因此暂不移入 `docs/done/`。 **状态**: 已实现(`src/task_reconciliation.py` + `src/backend_server.py::_start_reconciliation_loop` + `src/task_execution.py` 的 in-flight 互斥锁),本文档是对已落地实现的设计记录,补齐 `docs/todo/` 两段式流程里"确认后产出 SDD 设计文档"这一步。 ## 1. 整体方案 拆成三层,分别对应需求文档 §8 的五个开放问题: 1. **候选发现**(`task_reconciliation.find_stale_resumable_tasks`):只读查询,扫描 `task.status IN task_execution._RESUMABLE_STATUSES` 且"最近一次活动早于阈值"的任务。 2. **一次调度扫描**(`task_reconciliation.reconcile_once`):调用发现函数,对每个候选调用 `task_execution.start_task_execution_in_background`,单个候选派发失败不影响其余候选 (复用 04-P "调度器继续执行队列中其他任务" 的既有原则)。 3. **载体**(`backend_server.py::_start_reconciliation_loop`):`main()` 专属的后台线程, `while True: reconcile_once(); sleep(interval)`,`--reconciliation-interval-seconds` (默认 300s)/`--disable-reconciliation-loop` 两个 CLI 开关。 ## 2. 开放问题回答 | 需求文档 §8 的问题 | 本设计的答案 | |---|---| | FR-2 轮询周期 | 300s(`_DEFAULT_RECONCILIATION_INTERVAL_SECONDS`),可通过 `--reconciliation-interval-seconds` 覆盖 | | "一段时间没有新进展"的判定信号与阈值 | 用 `execution_event` 该 task 最近一条记录的 `created_at`(无记录则退回 `task.created_at`),**不用** `task.updated_at`——阶段执行期间 `task.updated_at` 本来就不变,这正是这次事故排查时的真实盲点。阈值统一 1800s,不区分阶段(见下方"已知简化") | | FR-2 载体形式 | `backend_server.py::main()` 内置守护线程,不是独立进程。理由:FR-2 只是"帮已存在的任务把该调用的函数再调用一次",复用的是 `task_execution` 现有的进程内 in-flight 互斥锁,做成独立进程反而需要额外的跨进程协调;这与 Deploy Supervisor"独立进程、无共享失败域"哲学的适用场景不同——Deploy Supervisor 独立是因为它需要在 `backend_server` 本身挂掉时还能做健康检查/回滚,FR-2 不需要这个属性 | | FR-2 互斥机制 | 进程内 `threading.Lock` + `set`(`task_execution._in_flight_task_ids`),不是数据库锁。有意选择:只需要防止**同一进程内**两个线程(控制台点 Start 的线程 vs 本循环)竞争同一个 `task_id`;不需要跨进程/跨重启存活——重启后这个 set 清空为空集合本身就是正确状态(意味着"这个进程里没有任何东西正在跑"),不是 bug | | 是否暴露为可配置项 | 是:`--reconciliation-interval-seconds`、`--disable-reconciliation-loop`。硬编码的是阈值本身(`_DEFAULT_STALE_AFTER_SECONDS=1800`),未暴露成 CLI 参数——v1 先用一个保守默认值,符合需求文档非目标 #4 | ## 3. 竞态处理 `find_stale_resumable_tasks` 在扫描时检查 `is_in_flight()`,但扫描到派发之间存在窗口——这段 时间里同一个 `task_id` 可能被控制台"Start"按钮抢先派发。这不是需要额外加锁解决的问题: `start_task_execution_in_background` 内部的 `_try_claim` 本身是原子的(持锁检查+插入), 竞态发生时后到者会直接收到 `TaskExecutionError`,`reconcile_once` 捕获后跳过该候选、不影响 其余候选——测试 `test_one_dispatch_failure_does_not_block_others` 覆盖了这条路径。 ## 4. 已知简化(v1 天花板,非 bug) - **单一全局阈值,不分阶段**。需求文档 §8 提到"Coder 阶段可能比 Validator 阶段容忍更长空闲", v1 用统一的 1800s(比最长单次阻塞调用 Validator 自身 Work Graph 的 `timeout=1200s` 多 600s 余量)。升级路径:给 `_RESUMABLE_STATUSES` 里每个状态配一个独立阈值,按 `task.assigned_org_node`/`task.status` 查表。 - **候选扫描全表查询,无索引优化**。当前任务量级下(个位数到几十个并发 task)不是问题; 升级路径:如果任务量上升,给 `task.status`、`execution_event.task_id` 加索引。 ## 5. 可观测性修复(本次评审新增,2026-08-06) 原实现里 `_start_reconciliation_loop` 的 `except Exception: pass` 会让"每一轮都失败"和 "每一轮都没有候选"从外部看起来完全一样——对一个设计目标就是无人值守 7x24 运行的循环来说, 这种静默失败是最危险的一类。已改为 `traceback.print_exc()`,异常信息进 stderr(`main()` 是真实 CLI 入口,stderr 在部署环境下通常会被进程管理器捕获落盘)。这不引入新依赖,也不改变 "一次失败不杀死循环"的既有语义。 ## 6. 验收标准核对(对照需求文档 §5) 1. ✅ `test_finds_task_with_no_activity_at_all` / `test_stale_event_still_flagged` 覆盖"卡住的 任务被重新捡起" 2. ✅ `test_excludes_task_with_recent_event` / `test_uses_most_recent_event_not_earliest` 覆盖 "正常在跑的任务不会被误判"——但这两个测试验证的是"有新鲜 `execution_event`"这一路径,没有 单独构造"一次长达接近 1200s 的阻塞调用期间完全没有任何新事件"的场景。这属于用阈值余量 (1800 vs 1200)做保证、而非用事件覆盖做保证,测试没有专门验证这条时间余量假设本身——留作 后续如果要收紧阈值时的一个真实回归风险点。 3. FR-1(新建 `queued` Goal 自动派发)不适用——本设计明确不覆盖 FR-1。 4. ✅ `--disable-reconciliation-loop` 提供独立开关;FR-2 与 FR-1 天然独立(FR-1 尚未实现, 两者不存在耦合)。 ## 6.1 部署候选进程的联动修复(2026-08-06,与本设计同批) `supervisor/deploy_stage.py::_launch_candidate` 启动的候选 `backend_server` 进程,`db_path` 是生产库的一份拷贝(`_copy_prod_db`)——候选进程本身默认也会带上复原循环,扫到拷贝库里的 可恢复任务后会在 Deploy Supervisor 还没做健康检查/裁定之前就真的发起 `claude -p` 调用。已给 `_launch_candidate` 补上 `--disable-reconciliation-loop`,并在 `supervisor/test_deploy_stage.py::LaunchCandidateArgsTest` 加了一个轻量单测直接断言 argv 里有 这个 flag(之前只有一个真实拉起进程验证 HTTP 可用的重量级测试,不会对这类"忘加一个 flag"的 回归报错)。`supervisor/deploy_rollback.py`/`deploy_cutover.py` 里另外两处 `_launch_candidate` 调用点(Live/回滚路径)**不**需要这个 flag——它们启动的是真正要接管服务的实例,本来就应该有 复原循环。 ## 7. 后续(不在本次范围内) FR-1(新 Goal 自动派发)仍然是需求文档标注的"改变系统自主权边界"的决策,按该文档非目标 #1 的要求,需要单独确认是否要做、什么时候做,不与 FR-2 混跑。在那之前,`queued` 状态的新 Goal 仍然需要人工触发 `run_goal_to_completion`/`scheduler.run_scheduler_once()`。