From 910a6c631f95e4f5d8581819a5ec317384f93ab5 Mon Sep 17 00:00:00 2001 From: yibo Date: Mon, 24 Aug 2026 02:59:07 +0800 Subject: [PATCH] fix(task-settle): 僵尸 goal 收口漏看 cancelled+archived 的 task (T-mt664821) --- server/services/task-settle.js | 10 +++++++++- server/tests/unit/goal-task-acceptance.test.js | 21 +++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/server/services/task-settle.js b/server/services/task-settle.js index 387d3c3..1735e54 100644 --- a/server/services/task-settle.js +++ b/server/services/task-settle.js @@ -232,7 +232,15 @@ function settleRootRun(run, to) { */ function settleGoalIfAllTasksDone(goalId) { const wdb = require('./work-graph-db'); - const tasks = wdb.listGoalTasks(goalId); + // 默认口径(includeArchived: false)是给 UI 列表用的「别显示归档项」,不是「这条 task 不存在了」。 + // 但归档并不都一个意思:draft 任务归档 = 人删掉了这条需求(DELETE /tasks 的 taskHoldsRun 闸门 + // 保证非终态里能被归档的只剩 draft),这类不该参与判定(下面那条「拆分产出可删」的用例钉住的 + // 就是这个);而 cancelled/completed/failed 之后再归档 = 结果已经发生,只是从界面收起来, + // 必须照样计入——否则用默认口径直接漏看这条 task,tasks.length 提前掉到 0 就 return null, + // goal 永远收不了口(T-mt664821:G-mt0a6upq-a98e / G-mt0a6upq-52ee 卡在 active 超 17 小时 + // 未被僵尸扫描收口,两条 task 都是 cancelled+archived)。 + const tasks = wdb.listGoalTasks(goalId, { includeArchived: true }) + .filter((t) => !t.archived_at || wdb.TASK_TERMINAL_STATUS.includes(t.status)); if (!tasks.length) return null; if (tasks.some((t) => !wdb.TASK_TERMINAL_STATUS.includes(t.status))) return null; diff --git a/server/tests/unit/goal-task-acceptance.test.js b/server/tests/unit/goal-task-acceptance.test.js index 72d278b..84310a4 100644 --- a/server/tests/unit/goal-task-acceptance.test.js +++ b/server/tests/unit/goal-task-acceptance.test.js @@ -774,6 +774,27 @@ describe('27-P3C 批 1 验收(§八 4 / 5 / 6 / 10 / 11 / 12b)', () => { expect(await settle.sweepStuckActiveGoals()).toBe(0); }); + it('僵尸扫描:task 被同时 cancelled+archived 也能收口,不会因 listGoalTasks 默认过滤归档项而误判 tasks.length===0(T-mt664821)', async () => { + const cid = newCompany(); + const settle = require('../../services/task-settle'); + + const goal = wdb.createGoal({ companyId: cid, title: '被取消并归档的目标', description: '人工取消后归档' }); + const t = wdb.createTask({ goalId: goal.id, companyId: cid, title: 'T', description: 'T' }); + wdb.updateTask(t.id, { status: 'cancelled', archivedAt: new Date().toISOString() }); + wdb.updateGoal(goal.id, { status: 'active', dispatchedAt: new Date().toISOString() }); + expect(wdb.getGoal(goal.id).status).toBe('active'); + + // 默认口径(includeArchived: false)会把这条 task 过滤掉,看见空列表——回归防线 + expect(wdb.listGoalTasks(goal.id)).toHaveLength(0); + expect(wdb.listGoalTasks(goal.id, { includeArchived: true })).toHaveLength(1); + + const settled = await settle.sweepStuckActiveGoals(); + expect(settled).toBe(1); + expect(wdb.getGoal(goal.id).status).toBe('failed'); // 唯一 task 是 cancelled,判词跟着它走 + + 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