Commit f14c9673dde68296a0148ccfd3fb55a28b311610

Authored by yibo
1 parent 910a6c63

fix(task-settle): 僵尸 goal 扫描漏看 goal 自己被 archived 的情形 (T-mt67uotn)

sweepStuckActiveGoals 候选查询多加了 archived_at IS NULL,把「goal 被归档但 status
从未跟着收口成终态」这类形状(G-mt1qwgt5-6b77:唯一 task cancelled+archived 时 goal
的 archived_at 被打上同一时刻,status 却留在 active)永久排除在扫描之外。去掉该过滤:
settleGoalIfAllTasksDone 对空 task 列表/存在非终态 task 都是 no-op,不会误收口合法归档
但确实没收敛的 goal。同时手动收口了 e2e-demo 库里唯一命中该形态的 G-mt1qwgt5-6b77。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
server/services/task-settle.js
... ... @@ -591,11 +591,15 @@ async function resumePendingGoalValidations() {
591 591 */
592 592 function sweepStuckActiveGoals() {
593 593 const wdb = require('./work-graph-db');
  594 + // 故意不过滤 archived_at:goal 被归档只代表「从界面收起来」,不代表 status 跟着收口了
  595 + // (T-mt67uotn:G-mt1qwgt5-6b77 唯一 task 被 cancelled+archived 时 goal 也跟着 archived_at
  596 + // 打上了同一时刻,但 status 从未同步写成 failed,永久停在 active 却再也扫不到)。
  597 + // 混进来的「合法归档但确实没收敛」的候选(比如用户在 planning 阶段就归档、tasks 还是空的)
  598 + // 不会被误收口:settleGoalIfAllTasksDone 对 tasks.length===0 或存在非终态 task 都是 no-op。
594 599 const rows = wdb.getDb()
595 600 .prepare(`
596 601 SELECT id FROM goals
597 602 WHERE status NOT IN (${wdb.GOAL_TERMINAL_STATUS.map((s) => `'${s}'`).join(', ')})
598   - AND archived_at IS NULL
599 603 ORDER BY updated_at ASC
600 604 LIMIT @limit
601 605 `)
... ...
server/tests/unit/goal-task-acceptance.test.js
... ... @@ -795,6 +795,32 @@ describe(&#39;27-P3C 批 1 验收(§八 4 / 5 / 6 / 10 / 11 / 12b)&#39;, () =&gt; {
795 795 expect(await settle.sweepStuckActiveGoals()).toBe(0);
796 796 });
797 797  
  798 + it('僵尸扫描:goal 自己也被 archived 但 status 从未跟着收口,照样能被扫到(T-mt67uotn)', async () => {
  799 + const cid = newCompany();
  800 + const settle = require('../../services/task-settle');
  801 +
  802 + // 复现 G-mt1qwgt5-6b77:task 被 cancelled+archived 时,goal 的 archived_at 被同一时刻打上,
  803 + // 但 status 从没人写成 failed —— 候选查询原来的 `archived_at IS NULL` 把它挡在扫描之外。
  804 + const goal = wdb.createGoal({ companyId: cid, title: '连 goal 自己都被归档了', description: 'archived_at 非空但 status 未收口' });
  805 + const t = wdb.createTask({ goalId: goal.id, companyId: cid, title: 'T', description: 'T' });
  806 + wdb.updateTask(t.id, { status: 'cancelled', archivedAt: new Date().toISOString() });
  807 + wdb.updateGoal(goal.id, { status: 'active', dispatchedAt: new Date().toISOString(), archivedAt: new Date().toISOString() });
  808 + expect(wdb.getGoal(goal.id).status).toBe('active');
  809 + expect(wdb.getGoal(goal.id).archived_at).toBeTruthy();
  810 +
  811 + // 无关扰动:一条 planning 阶段就被用户归档、还没长出 task 的 goal 不该被误收口
  812 + // (settleGoalIfAllTasksDone 对 tasks.length===0 是 no-op,但候选查询混进来也不该炸)。
  813 + const empty = wdb.createGoal({ companyId: cid, title: '规划阶段就被归档', description: '还没有 task' });
  814 + wdb.updateGoal(empty.id, { status: 'planning', archivedAt: new Date().toISOString() });
  815 +
  816 + const settled = await settle.sweepStuckActiveGoals();
  817 + expect(settled).toBe(1);
  818 + expect(wdb.getGoal(goal.id).status).toBe('failed');
  819 + expect(wdb.getGoal(empty.id).status).toBe('planning'); // 没被误收口
  820 +
  821 + expect(await settle.sweepStuckActiveGoals()).toBe(0);
  822 + });
  823 +
798 824 it('汇报环节同步抛出时只丢一行日志,不变成 unhandledRejection 杀进程(缺陷 3)', async () => {
799 825 // reportGoalOutcome 有两条同步抛出面(terminalAgentId 读库、OkrService.updateNode 的 fs 写)。
800 826 // 批 1 走同步路径,异常由 graph-run-sse 的 try/catch 兜住;这条路跑在 setImmediate 的 async
... ...