Commit 910a6c631f95e4f5d8581819a5ec317384f93ab5
1 parent
2237621d
fix(task-settle): 僵尸 goal 收口漏看 cancelled+archived 的 task (T-mt664821)
settleGoalIfAllTasksDone 用 listGoalTasks 默认口径(includeArchived: false)取 task 列表,task 若同时被 cancelled+archived 会被过滤掉, tasks.length 提前归零直接 return null,goal 永远收不了口——僵尸扫描等 于没扫到。实证:G-mt0a6upq-a98e / G-mt0a6upq-52ee 两条 goal 卡在 active 超 17 小时未被收口,两条 task 均已 cancelled+archived。 改成 includeArchived: true 取全量后再按语义过滤:draft 任务归档(人删 掉的需求)继续不参与判定,但 cancelled/completed/failed 之后再归档 (结果已发生只是从界面收起)必须照样计入,否则会反过来破坏"归档掉的 草稿不参与判定"这条既有行为(拆分产出可删的验收用例钉住的就是这个)。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Showing
2 changed files
with
30 additions
and
1 deletions
server/services/task-settle.js
| @@ -232,7 +232,15 @@ function settleRootRun(run, to) { | @@ -232,7 +232,15 @@ function settleRootRun(run, to) { | ||
| 232 | */ | 232 | */ |
| 233 | function settleGoalIfAllTasksDone(goalId) { | 233 | function settleGoalIfAllTasksDone(goalId) { |
| 234 | const wdb = require('./work-graph-db'); | 234 | const wdb = require('./work-graph-db'); |
| 235 | - const tasks = wdb.listGoalTasks(goalId); | 235 | + // 默认口径(includeArchived: false)是给 UI 列表用的「别显示归档项」,不是「这条 task 不存在了」。 |
| 236 | + // 但归档并不都一个意思:draft 任务归档 = 人删掉了这条需求(DELETE /tasks 的 taskHoldsRun 闸门 | ||
| 237 | + // 保证非终态里能被归档的只剩 draft),这类不该参与判定(下面那条「拆分产出可删」的用例钉住的 | ||
| 238 | + // 就是这个);而 cancelled/completed/failed 之后再归档 = 结果已经发生,只是从界面收起来, | ||
| 239 | + // 必须照样计入——否则用默认口径直接漏看这条 task,tasks.length 提前掉到 0 就 return null, | ||
| 240 | + // goal 永远收不了口(T-mt664821:G-mt0a6upq-a98e / G-mt0a6upq-52ee 卡在 active 超 17 小时 | ||
| 241 | + // 未被僵尸扫描收口,两条 task 都是 cancelled+archived)。 | ||
| 242 | + const tasks = wdb.listGoalTasks(goalId, { includeArchived: true }) | ||
| 243 | + .filter((t) => !t.archived_at || wdb.TASK_TERMINAL_STATUS.includes(t.status)); | ||
| 236 | if (!tasks.length) return null; | 244 | if (!tasks.length) return null; |
| 237 | if (tasks.some((t) => !wdb.TASK_TERMINAL_STATUS.includes(t.status))) return null; | 245 | if (tasks.some((t) => !wdb.TASK_TERMINAL_STATUS.includes(t.status))) return null; |
| 238 | 246 |
server/tests/unit/goal-task-acceptance.test.js
| @@ -774,6 +774,27 @@ describe('27-P3C 批 1 验收(§八 4 / 5 / 6 / 10 / 11 / 12b)', () => { | @@ -774,6 +774,27 @@ describe('27-P3C 批 1 验收(§八 4 / 5 / 6 / 10 / 11 / 12b)', () => { | ||
| 774 | expect(await settle.sweepStuckActiveGoals()).toBe(0); | 774 | expect(await settle.sweepStuckActiveGoals()).toBe(0); |
| 775 | }); | 775 | }); |
| 776 | 776 | ||
| 777 | + it('僵尸扫描:task 被同时 cancelled+archived 也能收口,不会因 listGoalTasks 默认过滤归档项而误判 tasks.length===0(T-mt664821)', async () => { | ||
| 778 | + const cid = newCompany(); | ||
| 779 | + const settle = require('../../services/task-settle'); | ||
| 780 | + | ||
| 781 | + const goal = wdb.createGoal({ companyId: cid, title: '被取消并归档的目标', description: '人工取消后归档' }); | ||
| 782 | + const t = wdb.createTask({ goalId: goal.id, companyId: cid, title: 'T', description: 'T' }); | ||
| 783 | + wdb.updateTask(t.id, { status: 'cancelled', archivedAt: new Date().toISOString() }); | ||
| 784 | + wdb.updateGoal(goal.id, { status: 'active', dispatchedAt: new Date().toISOString() }); | ||
| 785 | + expect(wdb.getGoal(goal.id).status).toBe('active'); | ||
| 786 | + | ||
| 787 | + // 默认口径(includeArchived: false)会把这条 task 过滤掉,看见空列表——回归防线 | ||
| 788 | + expect(wdb.listGoalTasks(goal.id)).toHaveLength(0); | ||
| 789 | + expect(wdb.listGoalTasks(goal.id, { includeArchived: true })).toHaveLength(1); | ||
| 790 | + | ||
| 791 | + const settled = await settle.sweepStuckActiveGoals(); | ||
| 792 | + expect(settled).toBe(1); | ||
| 793 | + expect(wdb.getGoal(goal.id).status).toBe('failed'); // 唯一 task 是 cancelled,判词跟着它走 | ||
| 794 | + | ||
| 795 | + expect(await settle.sweepStuckActiveGoals()).toBe(0); | ||
| 796 | + }); | ||
| 797 | + | ||
| 777 | it('汇报环节同步抛出时只丢一行日志,不变成 unhandledRejection 杀进程(缺陷 3)', async () => { | 798 | it('汇报环节同步抛出时只丢一行日志,不变成 unhandledRejection 杀进程(缺陷 3)', async () => { |
| 778 | // reportGoalOutcome 有两条同步抛出面(terminalAgentId 读库、OkrService.updateNode 的 fs 写)。 | 799 | // reportGoalOutcome 有两条同步抛出面(terminalAgentId 读库、OkrService.updateNode 的 fs 写)。 |
| 779 | // 批 1 走同步路径,异常由 graph-run-sse 的 try/catch 兜住;这条路跑在 setImmediate 的 async | 800 | // 批 1 走同步路径,异常由 graph-run-sse 的 try/catch 兜住;这条路跑在 setImmediate 的 async |