scheduler-task-reconciliation-requirements.md 9.51 KB

在途任务复原循环——需求文档

日期: 2026-08-06 性质: 需求文档(BRD/PRD 层面),不含详细技术设计。回应一次真实事故的根因排查——任务 3605272e-fe33-48cc-b3f3-e0c0ba26f970 在 validating 状态卡死 15+ 小时,execution_event 表在此期间零记录,work_graph_run 里从未出现 Validator 的运行记录。 状态: ✅ 已实现(2026-08-06)。2026-08-08 拆分:原文档 scheduler-production-polling-loop-requirements.md 同时覆盖 FR-1(新 Goal 自动派发,仍是草案)和 FR-2(本文档,已实现),两者状态不同、混在一份文档里不利于归档——本文档是拆分后只保留 FR-2(已完成部分)的版本,移入 docs/done/;FR-1 独立留在 docs/todo/scheduler-new-goal-dispatch-requirements.md。设计文档见 docs/done/scheduler-task-reconciliation-design.md。

0. 实现记录(2026-08-06)

  • src/task_reconciliation.py(新增):find_stale_resumable_tasks()(纯查询,判定信号是 execution_event 里该任务最近一条记录的 MAX(created_at),没有则退回 task.created_at——不是 task.updated_at,理由见模块 docstring 和 FR-3)+ reconcile_once()(扫描后逐个 dispatch,单个候选 dispatch 失败不影响其余候选,呼应 04-P"调度器继续执行队列中其他任务")。
  • src/task_execution.py:新增进程内 _in_flight_task_ids(threading.Lock 保护的内存 set)+ is_in_flight()/_try_claim()/_release(),start_task_execution_in_background() 接线为"已在途则立即 TaskExecutionError,不重复派发"——这正是 FR-5 之外一条隐含但必须有的约束(防止复原循环和人工点 Start 竞争同一个任务)。
  • src/backend_server.py:新增 _start_reconciliation_loop(),只在 main()(真实 CLI 入口)里启动,不放进 create_server()——后者被几乎每个 test_backend_server.py 测试实例化,放进去会让每次跑测试都触发真实的后台任务派发。新增 CLI 参数 --reconciliation-interval-seconds(默认 300)、--disable-reconciliation-loop。
  • stale_after_seconds 默认 1800(30 分钟)——比这套代码里最长的单节点默认超时(Validator 自己的 Work Graph timeout=1200)留了充分余量,正常还在跑的节点不会被误判。
  • 实现过程中真实抓到并修了两个问题,不是纸面设计就想到的:
    1. 测试污染:_in_flight_task_ids 是进程级全局状态,第一版新增测试里有一个没有完整 mock 掉后续真实调用路径,导致被测线程超出 join(timeout=10) 仍在跑,"task-1" 这个在测试文件里被大量复用的字面量 task_id 永久卡在 in-flight 集合里,导致全量跑 discover -s src 时(而不是单独跑这个文件时)另外 3 个无关测试跟着炸——已修正 mock 完整性,并在 StartTaskExecutionInBackgroundTest 加了防御性 setUp/tearDown 兜底清理。
    2. 生产副作用:Deploy Supervisor 的候选进程(supervisor/deploy_stage.py::_launch_candidate)启动的 backend_server.py 实例,--db-path 指向的是生产库的一份拷贝——默认开启复原循环会让候选进程一启动就对着这份拷贝库真的派发 claude -p 调用,这在候选还没通过健康检查、Deploy Supervisor 还没决定要不要采用它之前完全不该发生。已在 _launch_candidate 加 --disable-reconciliation-loop,并补了对应的代码注释说明原因。
  • python3 -m unittest discover -s src(635 tests)+ discover -s supervisor -p "test_*.py"(125 tests)全绿,零回归。

1. 背景

排查这次真实事故发现两层原因:

  1. 生产后端进程从来没有接入过任何轮询循环。docs/implementation-gap-analysis.md 第 6 条早就记录了这个缺口:"给某个常驻进程(backend_server.py 或独立进程)接入 scheduler.run_scheduler_once() 的轮询循环",评级"高",并且明确建议"改变系统自己的自主权边界,建议单独一批、额外人工审查 diff,不要和其他 gap 混跑"。这次事故是这条缺口第一次以真实用户可见的方式发作。
  2. 即使真的接上 scheduler.run_scheduler_once(),也不会自动修好这次事故这类问题——这是排查过程中新发现的、比原缺口描述更精确的一点。run_scheduler_once(db_path, goals, max_workers=None) 的入参 goals 是 [(target_folder_path, goal_text), ...],语义是"这一轮要新建并跑完这些 Goal",内部通过 task_create.create_task() 每次都新建一个 task 行。它解决的是"新 Goal 怎么被自动拿去跑"(这是 FR-1 的范围,独立文档跟踪),不是"一个已经存在、卡在某个中间状态的 task_id 怎么被自动接回去继续跑"——后者需要的是 task_execution.run_or_resume_task_lifecycle(按 task_id 恢复),是完全不同的另一个函数、另一条代码路径。这次事故属于第二类:任务本身已经存在、状态是 validating(task_execution._RESUMABLE_STATUSES 里合法的可恢复状态之一),只是没有任何东西在合适的时机去调用恢复逻辑。

这次事故本身的具体触发点(validating 状态本来就无法通过 run_or_resume_task_lifecycle 恢复)已经单独修复(见 src/task_execution.py 的 _RESUMABLE_STATUSES/request_pause 改动,2026-08-06)——这是"人工点 Resume 现在能生效了",不等于"以后不会再有任务卡死没人管"。本需求要解决的是后者。

2. 目标

在途任务复原(这次事故的真正根因):任何已经开始执行、但因为驱动它的那次调用提前退出(进程重启、脚本运行完毕、连接断开等任何原因)而卡在某个可恢复中间状态(task_execution._RESUMABLE_STATUSES:queued/planning_brd_prd_sdd/coding/validating)、且已经一段时间没有新进展的任务,需要被自动重新接回去继续跑,而不是无限期地停在原地、既不算 blocked_pending_human(触发不了人工介入通知),也没有任何东西在真的干活。

3. 非目标

  1. 不要求解决方案是常驻进程/独立进程/backend_server.py 内置线程三选一中的哪一个。具体载体形式留给设计阶段,本文档只描述行为需求。
  2. 不重新设计 run_or_resume_task_lifecycle 本身。它的行为已经是对的,本需求要解决的是"谁、在什么时机去调用它"这一层,不是重写它的内部逻辑。
  3. 不要求第一版就有一套精细的"任务卡死"判定算法(比如区分"真的卡死"和"合理地在等真实的 claude -p 长耗时调用")。第一版可以用一个保守、简单的阈值(比如"最近一条 execution_event 距今超过 N 分钟,且没有已知的进行中标记"),不需要一次做到完美。

4. 功能需求

# 需求描述 优先级
FR-2 需要一个独立的机制,定期扫描 task 表里状态属于 task_execution._RESUMABLE_STATUSES 且"已经一段时间没有新进展"的任务,对每一个调用 task_execution.run_or_resume_task_lifecycle(或 start_task_execution_in_background)——是这次事故的直接对应修复。 必须
FR-3 "已经一段时间没有新进展"的判定,需要能区分"真的没人管"和"确实在真实执行中,只是这一步本来就耗时"——不能简单靠 task.status 停留时间判断(比如 Validator 自己的 Work Graph 跑一次可能就要接近 timeout=1200 秒),需要参考更细粒度的信号(比如该任务关联的 work_node_execution/execution_event 最近一次活动时间,而不是 task.updated_at——task.updated_at 在阶段内部执行期间根本不会变,这正是这次事故排查时的真实盲点)。 必须
FR-5 不能引入新的"共享一个 sqlite 连接跨线程"风险——复用 scheduler.py 模块文档里已经验证过的模式(每个 worker 各自开自己的连接 + busy_timeout),不要重新踩 U-027 已经用真实 SIGSEGV 验证过的坑。 必须

5. 验收标准

  1. 人为构造一个"任务卡在 validating,很久没有新进展"的场景(复现这次事故的条件),验证机制能在设定的检测周期内把它重新接回去执行,不需要人工干预。
  2. 人为构造一个"任务正常在跑,Validator 的 Work Graph 正在执行中,还没超过合理耗时"的场景,验证机制不会误判成"卡死"、不会对一个正常在跑的任务重复触发 run_or_resume_task_lifecycle(这会导致同一个任务被并发跑两次,需要明确防止)。

6. 约束与假设

  • 复用现有的 task_execution._RESUMABLE_STATUSES(已扩展为包含 validating)作为判断"这个任务原则上可以恢复"的依据,不重新定义一套判断标准。
  • 要避免和「人工点 Resume」竞争——如果一个任务已经在被 start_task_execution_in_background 的某个后台线程处理,扫描不能对同一个 task_id 重复触发第二次执行。具体的互斥机制(数据库标记、内存锁、还是别的方式)留给设计阶段。

7. 风险

  • 看起来风险较小(只是让已经存在的任务继续跑完,不是凭空发起新工作),但仍然是自主行为的扩展——需要在设计阶段想清楚"防误判"(FR-3/验收标准 2)没做好的后果:一个只是耗时较长、并没有真的卡死的任务被提前打断重跑,可能造成重复的 API 花费或者更糟的状态不一致。