# 在途任务复原循环——需求文档 **日期**: 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 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 花费或者更糟的状态不一致。