From f14c9673dde68296a0148ccfd3fb55a28b311610 Mon Sep 17 00:00:00 2001 From: yibo Date: Mon, 24 Aug 2026 03:47:19 +0800 Subject: [PATCH] fix(task-settle): 僵尸 goal 扫描漏看 goal 自己被 archived 的情形 (T-mt67uotn) --- server/services/task-settle.js | 6 +++++- server/tests/unit/goal-task-acceptance.test.js | 26 ++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/server/services/task-settle.js b/server/services/task-settle.js index 1735e54..b05fd89 100644 --- a/server/services/task-settle.js +++ b/server/services/task-settle.js @@ -591,11 +591,15 @@ async function resumePendingGoalValidations() { */ function sweepStuckActiveGoals() { const wdb = require('./work-graph-db'); + // 故意不过滤 archived_at:goal 被归档只代表「从界面收起来」,不代表 status 跟着收口了 + // (T-mt67uotn:G-mt1qwgt5-6b77 唯一 task 被 cancelled+archived 时 goal 也跟着 archived_at + // 打上了同一时刻,但 status 从未同步写成 failed,永久停在 active 却再也扫不到)。 + // 混进来的「合法归档但确实没收敛」的候选(比如用户在 planning 阶段就归档、tasks 还是空的) + // 不会被误收口:settleGoalIfAllTasksDone 对 tasks.length===0 或存在非终态 task 都是 no-op。 const rows = wdb.getDb() .prepare(` SELECT id FROM goals WHERE status NOT IN (${wdb.GOAL_TERMINAL_STATUS.map((s) => `'${s}'`).join(', ')}) - AND archived_at IS NULL ORDER BY updated_at ASC LIMIT @limit `) diff --git a/server/tests/unit/goal-task-acceptance.test.js b/server/tests/unit/goal-task-acceptance.test.js index 84310a4..72a5773 100644 --- a/server/tests/unit/goal-task-acceptance.test.js +++ b/server/tests/unit/goal-task-acceptance.test.js @@ -795,6 +795,32 @@ describe('27-P3C 批 1 验收(§八 4 / 5 / 6 / 10 / 11 / 12b)', () => { expect(await settle.sweepStuckActiveGoals()).toBe(0); }); + it('僵尸扫描:goal 自己也被 archived 但 status 从未跟着收口,照样能被扫到(T-mt67uotn)', async () => { + const cid = newCompany(); + const settle = require('../../services/task-settle'); + + // 复现 G-mt1qwgt5-6b77:task 被 cancelled+archived 时,goal 的 archived_at 被同一时刻打上, + // 但 status 从没人写成 failed —— 候选查询原来的 `archived_at IS NULL` 把它挡在扫描之外。 + const goal = wdb.createGoal({ companyId: cid, title: '连 goal 自己都被归档了', description: 'archived_at 非空但 status 未收口' }); + 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(), archivedAt: new Date().toISOString() }); + expect(wdb.getGoal(goal.id).status).toBe('active'); + expect(wdb.getGoal(goal.id).archived_at).toBeTruthy(); + + // 无关扰动:一条 planning 阶段就被用户归档、还没长出 task 的 goal 不该被误收口 + // (settleGoalIfAllTasksDone 对 tasks.length===0 是 no-op,但候选查询混进来也不该炸)。 + const empty = wdb.createGoal({ companyId: cid, title: '规划阶段就被归档', description: '还没有 task' }); + wdb.updateGoal(empty.id, { status: 'planning', archivedAt: new Date().toISOString() }); + + const settled = await settle.sweepStuckActiveGoals(); + expect(settled).toBe(1); + expect(wdb.getGoal(goal.id).status).toBe('failed'); + expect(wdb.getGoal(empty.id).status).toBe('planning'); // 没被误收口 + + 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