# 在途任务复原循环——详细设计 **日期**: 2026-08-06 **性质**: SDD 层面设计文档,对应 `docs/done/scheduler-task-reconciliation-requirements.md`。 **状态**: ✅ 已实现(`src/task_reconciliation.py` + `src/backend_server.py::_start_reconciliation_loop` + `src/task_execution.py` 的 in-flight 互斥锁)。**2026-08-08**:随需求文档一起从 `docs/todo/scheduler-production-polling-loop-design.md` 拆分移入 `docs/done/`(原文档同时覆盖已完成 的 FR-2 和仍是草案的 FR-1,拆分后本文档只保留 FR-2 部分,FR-1 独立见 `docs/done/scheduler-new-goal-dispatch-requirements.md`)。 ## 1. 整体方案 拆成三层,分别对应需求文档"开放问题"里跟 FR-2 相关的几条: 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. 开放问题回答 | 问题 | 本设计的答案 | |---|---| | 轮询周期 | 300s(`_DEFAULT_RECONCILIATION_INTERVAL_SECONDS`),可通过 `--reconciliation-interval-seconds` 覆盖 | | "一段时间没有新进展"的判定信号与阈值 | 用 `execution_event` 该 task 最近一条记录的 `created_at`(无记录则退回 `task.created_at`),**不用** `task.updated_at`——阶段执行期间 `task.updated_at` 本来就不变,这正是这次事故排查时的真实盲点。阈值统一 1800s,不区分阶段(见下方"已知简化") | | 载体形式 | `backend_server.py::main()` 内置守护线程,不是独立进程。理由:只是"帮已存在的任务把该调用的函数再调用一次",复用的是 `task_execution` 现有的进程内 in-flight 互斥锁,做成独立进程反而需要额外的跨进程协调;这与 Deploy Supervisor"独立进程、无共享失败域"哲学的适用场景不同——Deploy Supervisor 独立是因为它需要在 `backend_server` 本身挂掉时还能做健康检查/回滚,这里不需要这个属性 | | 互斥机制 | 进程内 `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 先用一个保守默认值 | ## 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) - **单一全局阈值,不分阶段**。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. 验收标准核对 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. ✅ `--disable-reconciliation-loop` 提供独立开关。 ## 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. 关系 跟"新 Goal 自动派发"(`docs/done/scheduler-new-goal-dispatch-requirements.md`)是独立、正交的 两个机制——前者只是让已经存在的任务继续跑完,后者是让还没跑过的 `queued` Goal 自动开始跑,两者 不共享循环、不共享判断逻辑,也不应该被合并成一个"万能调度循环"。在后者被单独评审确认前, `queued` 状态的新 Goal 仍然需要人工触发 `run_goal_to_completion`/`scheduler.run_scheduler_once()`。