Commit f14c9673dde68296a0148ccfd3fb55a28b311610
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>
Showing
2 changed files
with
31 additions
and
1 deletions
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('27-P3C 批 1 验收(§八 4 / 5 / 6 / 10 / 11 / 12b)', () => { |
| 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 | ... | ... |