Commit bd0f36848872f972b8d2f512621e0513b1e8afc5
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>
Showing
3 changed files
with
109 additions
and
1 deletions
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('27-P3C 批 1 验收(§八 4 / 5 / 6 / 10 / 11 / 12b)', () => { |
| 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 | ... | ... |