Commit bd0f36848872f972b8d2f512621e0513b1e8afc5

Authored by yibo
1 parent 60d1ebb2

fix(task-settle): 补开机全量僵尸 goal 扫描 + 周期性兜底 (T-mt622nil)

resumePendingGoalValidations 只补「收敛钩子跑过、卡在验收/汇报半路」的情形;
若钩子压根没被触发过(进程重启/异常吞掉/事件丢失),task 早到终态但 goal
永远停在 active,只能靠人工 OKR 巡检发现(e2e-demo 公司 G-mt2r62ia-73af 卡
了 48 小时)。新增 sweepStuckActiveGoals 开机全量扫描一次,并启动 5 分钟
周期性 startZombieGoalSweeper 兜底,把发现窗口从「天」压到「分钟」。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
server/index.js
... ... @@ -157,8 +157,20 @@ async function start() {
157 157 resumeIncompleteTasks().catch(err => console.error('[Startup] 恢复任务失败:', err.message));
158 158 // 27-P3C:goal 级验收窗口里被打断的那些(status 已终态、判词还是占位值)。
159 159 // 和上面同属「中断任务恢复」,也同样要等 HTTP 就绪——它收尾时会打自家 /api/agent/task/report。
160   - require('./services/task-settle').resumePendingGoalValidations()
  160 + const taskSettle = require('./services/task-settle');
  161 + taskSettle.resumePendingGoalValidations()
161 162 .catch(err => console.error('[Startup] 重推 goal 验收失败:', err.message));
  163 + // T-mt622nil:上面那条只补「收敛钩子已经跑过、卡在验收/汇报半路」的。更前面那一步——
  164 + // 钩子压根没被触发过(进程重启 / 异常吞掉 / 事件丢失)——task 早到终态但 goal 永远停在
  165 + // active,只能靠人工 OKR 巡检才发现(e2e-demo 公司 G-mt2r62ia-73af 卡了 48 小时)。
  166 + // 开机补一次全量扫描,再起周期性心跳兜底(同 wallclock 看门狗的形状)。
  167 + try {
  168 + const swept = taskSettle.sweepStuckActiveGoals();
  169 + if (swept) console.log(`[Startup] 🧟 僵尸 goal 扫描:开机收口 ${swept} 条`);
  170 + taskSettle.startZombieGoalSweeper();
  171 + } catch (err) {
  172 + console.error('[Startup] 僵尸 goal 扫描启动失败:', err.message);
  173 + }
162 174 // 27-P3C 批 3:任务队列。进程重启会丢掉所有在途的 setImmediate 唤醒
163 175 // (队列钩子挂在 graph-run-sse.transitionRun 上,那是进程内的),
164 176 // 于是重启后队里的任务会一直躺着 —— 界面上和「正在排队」一模一样,正是本批要消灭的症状。
... ...
server/services/task-settle.js
... ... @@ -565,10 +565,75 @@ async function resumePendingGoalValidations() {
565 565 return done;
566 566 }
567 567  
  568 +/**
  569 + * 找出「task 全到终态、但 goal 压根没跟着收口」的僵尸 goal,复用 `settleGoalIfAllTasksDone` 收口
  570 + * (T-mt622nil:e2e-demo 公司 G-mt2r62ia-73af 唯一 task 2026-08-21T18:49 就 completed,
  571 + * goal 卡在 active 超 48 小时,直到人工 OKR 巡检才发现)。
  572 + *
  573 + * 和 `resumePendingGoalValidations` 的分工是**互补不重叠**:那个函数补的是「收敛钩子已经跑过,
  574 + * 卡在验收/汇报半路」——判据是 goal **已经**落终态(`verdict`/`reported_at` 是占位形态)。
  575 + * 这里补的是更前面那一步:收敛钩子**压根没被触发过**(进程重启 / 异常吞掉 / 事件丢失),
  576 + * 判据是 goal 还停在非终态——两边按 `GOAL_TERMINAL_STATUS` 是否命中互斥,不会对同一条 goal 重复处理。
  577 + *
  578 + * 不自己判"是否该收口":`settleGoalIfAllTasksDone` 内部已经做了「task 是否全部终态」的判断和
  579 + * CAS 闸门,这里直接调用即可——tasks 为空、或还有未终态的 task,它会自己返回 null(no-op),
  580 + * 不需要在扫描侧重复这层逻辑。
  581 + *
  582 + * @returns {Promise<number>} 本次真正收口了几条
  583 + */
  584 +function sweepStuckActiveGoals() {
  585 + const wdb = require('./work-graph-db');
  586 + const rows = wdb.getDb()
  587 + .prepare(`
  588 + SELECT id FROM goals
  589 + WHERE status NOT IN (${wdb.GOAL_TERMINAL_STATUS.map((s) => `'${s}'`).join(', ')})
  590 + AND archived_at IS NULL
  591 + ORDER BY updated_at ASC
  592 + LIMIT @limit
  593 + `)
  594 + .all({ limit: MAX_RESUME_PER_BOOT });
  595 +
  596 + if (!rows.length) return 0;
  597 +
  598 + let settled = 0;
  599 + for (const row of rows) {
  600 + try {
  601 + if (settleGoalIfAllTasksDone(row.id)) settled += 1;
  602 + } catch (e) {
  603 + // 一条扫描失败不该拖垮其余的;它下一轮扫描还会被扫到
  604 + console.error(`[TaskSettle] goal ${row.id} 僵尸收口失败: ${e.message}`);
  605 + }
  606 + }
  607 + if (settled) console.log(`[TaskSettle] 🧟 僵尸 goal 扫描:${rows.length} 条候选中收口 ${settled} 条`);
  608 + return settled;
  609 +}
  610 +
  611 +/** 僵尸扫描的心跳间隔。48 小时才被人工巡检发现是本次要修的症状,5 分钟足够把窗口从"天"压到"分钟" */
  612 +const ZOMBIE_SWEEP_INTERVAL_MS = Number(process.env.GOAL_ZOMBIE_SWEEP_MS) || 5 * 60 * 1000;
  613 +let zombieSweepTimer = null;
  614 +
  615 +/** 启动周期性僵尸扫描(unref,不拖着进程不退出)。幂等:重复调用只返回已有的计时器 */
  616 +function startZombieGoalSweeper(intervalMs = ZOMBIE_SWEEP_INTERVAL_MS) {
  617 + if (zombieSweepTimer) return zombieSweepTimer;
  618 + zombieSweepTimer = setInterval(() => {
  619 + try { sweepStuckActiveGoals(); } catch (e) { console.error(`[TaskSettle] 僵尸 goal 扫描异常: ${e.message}`); }
  620 + }, intervalMs);
  621 + if (typeof zombieSweepTimer.unref === 'function') zombieSweepTimer.unref();
  622 + return zombieSweepTimer;
  623 +}
  624 +
  625 +function stopZombieGoalSweeper() {
  626 + if (zombieSweepTimer) clearInterval(zombieSweepTimer);
  627 + zombieSweepTimer = null;
  628 +}
  629 +
568 630 module.exports = {
569 631 settleRootRun,
570 632 settleGoalIfAllTasksDone,
571 633 resumePendingGoalValidations,
  634 + sweepStuckActiveGoals,
  635 + startZombieGoalSweeper,
  636 + stopZombieGoalSweeper,
572 637 RUN_TO_TASK_STATUS,
573 638 // 占位值不再是常量而是「前缀 + 唯一 token」,所以对外给的是前缀和判定函数:
574 639 // 任何地方想认出「这条还没有结论」,都必须走 isPendingVerdict,别再去比字符串相等。
... ...
server/tests/unit/goal-task-acceptance.test.js
... ... @@ -743,6 +743,37 @@ describe(&#39;27-P3C 批 1 验收(§八 4 / 5 / 6 / 10 / 11 / 12b)&#39;, () =&gt; {
743 743 }
744 744 });
745 745  
  746 + it('僵尸扫描:settle 钩子压根没触发过的 goal(task 早终态、goal 仍 active)会被补收口(T-mt622nil)', async () => {
  747 + const cid = newCompany();
  748 + const settle = require('../../services/task-settle');
  749 +
  750 + // 与 stuckGoal() 不同:那个模拟的是钩子跑过、卡在验收半路(CAS 已写 done)。
  751 + // 这里模拟更前面一步——钩子压根没被触发过:task 早到终态,goal 却还停在 dispatch 后的 active,
  752 + // 从来没人调过 settleGoalIfAllTasksDone(对应真实事故 G-mt2r62ia-73af:唯一 task completed
  753 + // 两天后,goal 仍是 active,直到人工 OKR 巡检才发现)。
  754 + const goal = wdb.createGoal({ companyId: cid, title: '单任务目标', description: '唯一一条 task' });
  755 + const t = wdb.createTask({ goalId: goal.id, companyId: cid, title: 'T', description: 'T' });
  756 + wdb.updateTask(t.id, { status: 'completed' });
  757 + wdb.updateGoal(goal.id, { status: 'active', dispatchedAt: new Date().toISOString() });
  758 + expect(wdb.getGoal(goal.id).status).toBe('active');
  759 +
  760 + // 无关扰动:别的 goal 还在正常跑(task 未终态),扫描不该碰它
  761 + const running = wdb.createGoal({ companyId: cid, title: '还在跑', description: '还没完成' });
  762 + wdb.createTask({ goalId: running.id, companyId: cid, title: 'R', description: 'R' });
  763 + wdb.updateGoal(running.id, { status: 'active', dispatchedAt: new Date().toISOString() });
  764 +
  765 + const settled = await settle.sweepStuckActiveGoals();
  766 + expect(settled).toBe(1);
  767 +
  768 + // 单 task 的 goal 跳过二次验收,直接采用该 task 的结论
  769 + expect(wdb.getGoal(goal.id).status).toBe('done');
  770 + // 未完成的那条不该被误收口
  771 + expect(wdb.getGoal(running.id).status).toBe('active');
  772 +
  773 + // 幂等:再扫一次不会重复收口(已是终态,不再命中扫描的 WHERE)
  774 + expect(await settle.sweepStuckActiveGoals()).toBe(0);
  775 + });
  776 +
746 777 it('汇报环节同步抛出时只丢一行日志,不变成 unhandledRejection 杀进程(缺陷 3)', async () => {
747 778 // reportGoalOutcome 有两条同步抛出面(terminalAgentId 读库、OkrService.updateNode 的 fs 写)。
748 779 // 批 1 走同步路径,异常由 graph-run-sse 的 try/catch 兜住;这条路跑在 setImmediate 的 async
... ...