From bd0f36848872f972b8d2f512621e0513b1e8afc5 Mon Sep 17 00:00:00 2001 From: yibo Date: Mon, 24 Aug 2026 01:08:16 +0800 Subject: [PATCH] fix(task-settle): 补开机全量僵尸 goal 扫描 + 周期性兜底 (T-mt622nil) --- server/index.js | 14 +++++++++++++- server/services/task-settle.js | 65 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ server/tests/unit/goal-task-acceptance.test.js | 31 +++++++++++++++++++++++++++++++ 3 files changed, 109 insertions(+), 1 deletion(-) diff --git a/server/index.js b/server/index.js index 8fd9044..6ed8fd2 100644 --- a/server/index.js +++ b/server/index.js @@ -157,8 +157,20 @@ async function start() { resumeIncompleteTasks().catch(err => console.error('[Startup] 恢复任务失败:', err.message)); // 27-P3C:goal 级验收窗口里被打断的那些(status 已终态、判词还是占位值)。 // 和上面同属「中断任务恢复」,也同样要等 HTTP 就绪——它收尾时会打自家 /api/agent/task/report。 - require('./services/task-settle').resumePendingGoalValidations() + const taskSettle = require('./services/task-settle'); + taskSettle.resumePendingGoalValidations() .catch(err => console.error('[Startup] 重推 goal 验收失败:', err.message)); + // T-mt622nil:上面那条只补「收敛钩子已经跑过、卡在验收/汇报半路」的。更前面那一步—— + // 钩子压根没被触发过(进程重启 / 异常吞掉 / 事件丢失)——task 早到终态但 goal 永远停在 + // active,只能靠人工 OKR 巡检才发现(e2e-demo 公司 G-mt2r62ia-73af 卡了 48 小时)。 + // 开机补一次全量扫描,再起周期性心跳兜底(同 wallclock 看门狗的形状)。 + try { + const swept = taskSettle.sweepStuckActiveGoals(); + if (swept) console.log(`[Startup] 🧟 僵尸 goal 扫描:开机收口 ${swept} 条`); + taskSettle.startZombieGoalSweeper(); + } catch (err) { + console.error('[Startup] 僵尸 goal 扫描启动失败:', err.message); + } // 27-P3C 批 3:任务队列。进程重启会丢掉所有在途的 setImmediate 唤醒 // (队列钩子挂在 graph-run-sse.transitionRun 上,那是进程内的), // 于是重启后队里的任务会一直躺着 —— 界面上和「正在排队」一模一样,正是本批要消灭的症状。 diff --git a/server/services/task-settle.js b/server/services/task-settle.js index 38a4739..387d3c3 100644 --- a/server/services/task-settle.js +++ b/server/services/task-settle.js @@ -565,10 +565,75 @@ async function resumePendingGoalValidations() { return done; } +/** + * 找出「task 全到终态、但 goal 压根没跟着收口」的僵尸 goal,复用 `settleGoalIfAllTasksDone` 收口 + * (T-mt622nil:e2e-demo 公司 G-mt2r62ia-73af 唯一 task 2026-08-21T18:49 就 completed, + * goal 卡在 active 超 48 小时,直到人工 OKR 巡检才发现)。 + * + * 和 `resumePendingGoalValidations` 的分工是**互补不重叠**:那个函数补的是「收敛钩子已经跑过, + * 卡在验收/汇报半路」——判据是 goal **已经**落终态(`verdict`/`reported_at` 是占位形态)。 + * 这里补的是更前面那一步:收敛钩子**压根没被触发过**(进程重启 / 异常吞掉 / 事件丢失), + * 判据是 goal 还停在非终态——两边按 `GOAL_TERMINAL_STATUS` 是否命中互斥,不会对同一条 goal 重复处理。 + * + * 不自己判"是否该收口":`settleGoalIfAllTasksDone` 内部已经做了「task 是否全部终态」的判断和 + * CAS 闸门,这里直接调用即可——tasks 为空、或还有未终态的 task,它会自己返回 null(no-op), + * 不需要在扫描侧重复这层逻辑。 + * + * @returns {Promise} 本次真正收口了几条 + */ +function sweepStuckActiveGoals() { + const wdb = require('./work-graph-db'); + const rows = wdb.getDb() + .prepare(` + SELECT id FROM goals + WHERE status NOT IN (${wdb.GOAL_TERMINAL_STATUS.map((s) => `'${s}'`).join(', ')}) + AND archived_at IS NULL + ORDER BY updated_at ASC + LIMIT @limit + `) + .all({ limit: MAX_RESUME_PER_BOOT }); + + if (!rows.length) return 0; + + let settled = 0; + for (const row of rows) { + try { + if (settleGoalIfAllTasksDone(row.id)) settled += 1; + } catch (e) { + // 一条扫描失败不该拖垮其余的;它下一轮扫描还会被扫到 + console.error(`[TaskSettle] goal ${row.id} 僵尸收口失败: ${e.message}`); + } + } + if (settled) console.log(`[TaskSettle] 🧟 僵尸 goal 扫描:${rows.length} 条候选中收口 ${settled} 条`); + return settled; +} + +/** 僵尸扫描的心跳间隔。48 小时才被人工巡检发现是本次要修的症状,5 分钟足够把窗口从"天"压到"分钟" */ +const ZOMBIE_SWEEP_INTERVAL_MS = Number(process.env.GOAL_ZOMBIE_SWEEP_MS) || 5 * 60 * 1000; +let zombieSweepTimer = null; + +/** 启动周期性僵尸扫描(unref,不拖着进程不退出)。幂等:重复调用只返回已有的计时器 */ +function startZombieGoalSweeper(intervalMs = ZOMBIE_SWEEP_INTERVAL_MS) { + if (zombieSweepTimer) return zombieSweepTimer; + zombieSweepTimer = setInterval(() => { + try { sweepStuckActiveGoals(); } catch (e) { console.error(`[TaskSettle] 僵尸 goal 扫描异常: ${e.message}`); } + }, intervalMs); + if (typeof zombieSweepTimer.unref === 'function') zombieSweepTimer.unref(); + return zombieSweepTimer; +} + +function stopZombieGoalSweeper() { + if (zombieSweepTimer) clearInterval(zombieSweepTimer); + zombieSweepTimer = null; +} + module.exports = { settleRootRun, settleGoalIfAllTasksDone, resumePendingGoalValidations, + sweepStuckActiveGoals, + startZombieGoalSweeper, + stopZombieGoalSweeper, RUN_TO_TASK_STATUS, // 占位值不再是常量而是「前缀 + 唯一 token」,所以对外给的是前缀和判定函数: // 任何地方想认出「这条还没有结论」,都必须走 isPendingVerdict,别再去比字符串相等。 diff --git a/server/tests/unit/goal-task-acceptance.test.js b/server/tests/unit/goal-task-acceptance.test.js index 392a732..72d278b 100644 --- a/server/tests/unit/goal-task-acceptance.test.js +++ b/server/tests/unit/goal-task-acceptance.test.js @@ -743,6 +743,37 @@ describe('27-P3C 批 1 验收(§八 4 / 5 / 6 / 10 / 11 / 12b)', () => { } }); + it('僵尸扫描:settle 钩子压根没触发过的 goal(task 早终态、goal 仍 active)会被补收口(T-mt622nil)', async () => { + const cid = newCompany(); + const settle = require('../../services/task-settle'); + + // 与 stuckGoal() 不同:那个模拟的是钩子跑过、卡在验收半路(CAS 已写 done)。 + // 这里模拟更前面一步——钩子压根没被触发过:task 早到终态,goal 却还停在 dispatch 后的 active, + // 从来没人调过 settleGoalIfAllTasksDone(对应真实事故 G-mt2r62ia-73af:唯一 task completed + // 两天后,goal 仍是 active,直到人工 OKR 巡检才发现)。 + const goal = wdb.createGoal({ companyId: cid, title: '单任务目标', description: '唯一一条 task' }); + const t = wdb.createTask({ goalId: goal.id, companyId: cid, title: 'T', description: 'T' }); + wdb.updateTask(t.id, { status: 'completed' }); + wdb.updateGoal(goal.id, { status: 'active', dispatchedAt: new Date().toISOString() }); + expect(wdb.getGoal(goal.id).status).toBe('active'); + + // 无关扰动:别的 goal 还在正常跑(task 未终态),扫描不该碰它 + const running = wdb.createGoal({ companyId: cid, title: '还在跑', description: '还没完成' }); + wdb.createTask({ goalId: running.id, companyId: cid, title: 'R', description: 'R' }); + wdb.updateGoal(running.id, { status: 'active', dispatchedAt: new Date().toISOString() }); + + const settled = await settle.sweepStuckActiveGoals(); + expect(settled).toBe(1); + + // 单 task 的 goal 跳过二次验收,直接采用该 task 的结论 + expect(wdb.getGoal(goal.id).status).toBe('done'); + // 未完成的那条不该被误收口 + expect(wdb.getGoal(running.id).status).toBe('active'); + + // 幂等:再扫一次不会重复收口(已是终态,不再命中扫描的 WHERE) + expect(await settle.sweepStuckActiveGoals()).toBe(0); + }); + it('汇报环节同步抛出时只丢一行日志,不变成 unhandledRejection 杀进程(缺陷 3)', async () => { // reportGoalOutcome 有两条同步抛出面(terminalAgentId 读库、OkrService.updateNode 的 fs 写)。 // 批 1 走同步路径,异常由 graph-run-sse 的 try/catch 兜住;这条路跑在 setImmediate 的 async -- libgit2 0.21.4