在途任务复原循环——详细设计
日期: 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/todo/scheduler-new-goal-dispatch-requirements.md)。
1. 整体方案
拆成三层,分别对应需求文档"开放问题"里跟 FR-2 相关的几条:
- 候选发现(
task_reconciliation.find_stale_resumable_tasks):只读查询,扫描task.status IN task_execution._RESUMABLE_STATUSES且"最近一次活动早于阈值"的任务。 - 一次调度扫描(
task_reconciliation.reconcile_once):调用发现函数,对每个候选调用task_execution.start_task_execution_in_background,单个候选派发失败不影响其余候选 (复用 04-P "调度器继续执行队列中其他任务" 的既有原则)。 - 载体(
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. 验收标准核对
- ✅
test_finds_task_with_no_activity_at_all/test_stale_event_still_flagged覆盖"卡住的 任务被重新捡起" - ✅
test_excludes_task_with_recent_event/test_uses_most_recent_event_not_earliest覆盖 "正常在跑的任务不会被误判"——但这两个测试验证的是"有新鲜execution_event"这一路径,没有 单独构造"一次长达接近 1200s 的阻塞调用期间完全没有任何新事件"的场景。这属于用阈值余量 (1800 vs 1200)做保证、而非用事件覆盖做保证,测试没有专门验证这条时间余量假设本身——留作 后续如果要收紧阈值时的一个真实回归风险点。 - ✅
--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/todo/scheduler-new-goal-dispatch-requirements.md)是独立、正交的
两个机制——前者只是让已经存在的任务继续跑完,后者是让还没跑过的 queued Goal 自动开始跑,两者
不共享循环、不共享判断逻辑,也不应该被合并成一个"万能调度循环"。在后者被单独评审确认前,
queued 状态的新 Goal 仍然需要人工触发 run_goal_to_completion/scheduler.run_scheduler_once()。