Commit 910a6c631f95e4f5d8581819a5ec317384f93ab5

Authored by yibo
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>
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(&#39;27-P3C 批 1 验收(§八 4 / 5 / 6 / 10 / 11 / 12b)&#39;, () =&gt; { @@ -774,6 +774,27 @@ describe(&#39;27-P3C 批 1 验收(§八 4 / 5 / 6 / 10 / 11 / 12b)&#39;, () =&gt; {
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