scheduler-production-polling-loop-requirements.md 12.3 KB

生产环境调度/复原循环——需求文档

日期: 2026-08-06 性质: 需求文档(BRD/PRD 层面),不含详细技术设计。回应一次真实事故的根因排查——任务 3605272e-fe33-48cc-b3f3-e0c0ba26f970 在 validating 状态卡死 15+ 小时,execution_event 表在此期间零记录,work_graph_run 里从未出现 Validator 的运行记录。 状态: FR-2(在途任务复原)✅ 已实现(2026-08-06)。FR-1(新 Goal 自动派发)仍是草案,按第 3 节非目标/第 7 节风险的既定判断,未与 FR-2 一起做——它改变系统自主权边界,需要单独评审决策。

0. FR-2 实现记录(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 怎么被自动拿去跑",不是"一个已经存在、卡在某个中间状态的 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. 目标

把生产环境需要的"持续推进任务"能力,拆成两个明确区分、可以分开排期的子问题:

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

3. 非目标

  1. 不在本文档里裁定"要不要做""什么时候做"这个更大的问题。docs/implementation-gap-analysis.md 已经把这条标注为"改变系统自己的自主权边界"——是否现在就投入、要不要额外的人工评审门槛,是需要单独确认的决策,本文档只负责把需求本身讲清楚,供决策时参考。
  2. 不要求解决方案是常驻进程/独立进程/backend_server.py 内置线程三选一中的哪一个。具体载体形式留给设计阶段,本文档只描述行为需求。
  3. 不重新设计 run_scheduler_once/run_goal_to_completion/run_or_resume_task_lifecycle 这三个已经存在且经过测试的函数本身。它们的行为已经是对的,本需求要解决的是"谁、在什么时机去调用它们"这一层,不是重写它们的内部逻辑。
  4. 不要求第一版就有一套精细的"任务卡死"判定算法(比如区分"真的卡死"和"合理地在等真实的 claude -p 长耗时调用")。第一版可以用一个保守、简单的阈值(比如"updated_at/最近一条 execution_event 距今超过 N 分钟,且没有已知的进行中标记"),不需要一次做到完美。

4. 功能需求

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

5. 验收标准

  1. 人为构造一个"任务卡在 validating,很久没有新进展"的场景(复现这次事故的条件),验证 FR-2 的机制能在设定的检测周期内把它重新接回去执行,不需要人工干预。
  2. 人为构造一个"任务正常在跑,Validator 的 Work Graph 正在执行中,还没超过合理耗时"的场景,验证 FR-2 的机制不会误判成"卡死"、不会对一个正常在跑的任务重复触发 run_or_resume_task_lifecycle(这会导致同一个任务被并发跑两次,需要明确防止)。
  3. 新建一个 queued 状态的 Goal,不手动点 Start,验证 FR-1 的机制能在设定周期内自动派发执行。
  4. FR-1 和 FR-2 分别单独关闭时,各自的行为符合预期(关 FR-1 不影响 FR-2 正常工作,反之亦然)。

6. 约束与假设

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

7. 风险

  • FR-1(新 Goal 自动派发)是这次事故排查之前就已经被 implementation-gap-analysis.md 标注为"改变系统自主权边界"的决策,本文档不试图淡化这一点——它意味着系统会在没有人显式点击"Start"的情况下自主开始消耗真实 API 调用、真实修改代码。这条风险原样保留,需要在决策阶段被认真对待。
  • FR-2(在途任务复原)看起来风险更小(只是让已经存在的任务继续跑完,不是凭空发起新工作),但仍然是自主行为的扩展——需要在设计阶段想清楚"防误判"(FR-3/验收标准 2)没做好的后果:一个只是耗时较长、并没有真的卡死的任务被提前打断重跑,可能造成重复的 API 花费或者更糟的状态不一致。
  • 两个机制如果实现成同一个循环、共享同一套判断逻辑,容易在后续维护中被混为一谈——按第 2 节"目标"明确拆开成两个独立问题,是为了避免这种混淆,设计阶段应该保持这个拆分,不要图省事合并成一个"万能调度循环"。

8. 开放问题(留给详细设计阶段回答)

  • [ ] FR-1/FR-2 各自的轮询周期定多少。
  • [ ] FR-3"已经一段时间没有新进展"的具体判定信号和阈值——参考哪张表的哪个时间戳字段,阈值怎么定(是否要按 task 当前所处的具体阶段给不同阈值,比如 Coder 阶段允许的空闲时间可能比 Validator 阶段更长)。
  • [ ] FR-1/FR-2 的载体形式:backend_server.py 内置后台线程,还是独立进程(类似 Deploy Supervisor 的"独立进程、无共享失败域"哲学)。
  • [ ] FR-2 的互斥机制具体怎么实现,防止同一个任务被并发重复触发。
  • [ ] 这两个机制要不要暴露成可配置项(管理后台开关/环境变量),还是先硬编码一个保守的默认行为。