scheduler-task-reconciliation-design.md 7.1 KB

在途任务复原循环——详细设计

日期: 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()。