在途任务复原循环——需求文档
日期: 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/done/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 Graphtimeout=1200)留了充分余量,正常还在跑的节点不会被误判。- 实现过程中真实抓到并修了两个问题,不是纸面设计就想到的:
- 测试污染:
_in_flight_task_ids是进程级全局状态,第一版新增测试里有一个没有完整 mock 掉后续真实调用路径,导致被测线程超出join(timeout=10)仍在跑,"task-1" 这个在测试文件里被大量复用的字面量 task_id 永久卡在 in-flight 集合里,导致全量跑discover -s src时(而不是单独跑这个文件时)另外 3 个无关测试跟着炸——已修正 mock 完整性,并在StartTaskExecutionInBackgroundTest加了防御性setUp/tearDown兜底清理。 - 生产副作用: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. 背景
排查这次真实事故发现两层原因:
- 生产后端进程从来没有接入过任何轮询循环。
docs/implementation-gap-analysis.md第 6 条早就记录了这个缺口:"给某个常驻进程(backend_server.py或独立进程)接入scheduler.run_scheduler_once()的轮询循环",评级"高",并且明确建议"改变系统自己的自主权边界,建议单独一批、额外人工审查 diff,不要和其他 gap 混跑"。这次事故是这条缺口第一次以真实用户可见的方式发作。 - 即使真的接上
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. 非目标
- 不要求解决方案是常驻进程/独立进程/
backend_server.py内置线程三选一中的哪一个。具体载体形式留给设计阶段,本文档只描述行为需求。 - 不重新设计
run_or_resume_task_lifecycle本身。它的行为已经是对的,本需求要解决的是"谁、在什么时机去调用它"这一层,不是重写它的内部逻辑。 - 不要求第一版就有一套精细的"任务卡死"判定算法(比如区分"真的卡死"和"合理地在等真实的
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. 验收标准
- 人为构造一个"任务卡在
validating,很久没有新进展"的场景(复现这次事故的条件),验证机制能在设定的检测周期内把它重新接回去执行,不需要人工干预。 - 人为构造一个"任务正常在跑,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 花费或者更糟的状态不一致。