/** * Unit Tests — blocked 的 task 必须自己说得出为什么卡住 * * 背景:run 收敛成 blocked 时 `settleRootRun` 把 task 也写成 blocked,但**只写状态不写原因**。 * 症状是纯视觉的、不报错:任务列表那一行只剩一个橙色的「挂起」, * 而 `GoalTaskPane` 的红色原因行读的是 `tasks.blocked_reason` —— 空的就整行不渲染。 * 于是「为什么卡住」在任务列表上无法回答,人必须逐层点进 org 图 → work 图 → 节点详情。 * * 钉三件事: * 1. blocked 回写时,原因落到了 task 行上,且带得出「哪个节点 + 什么 fail_reason」; * 2. 原因**绝不以 `UPSTREAM_BLOCK_PREFIX` 开头** —— 带了就会被 task-scheduler 当成 * 「依赖推导出来的 blocked」自动推回 queued,而这类 blocked 本来就等着人来处置 * (work-graph-db.noteTaskBlockedReason 的 ⚠️ 就是这一条,这里把它变成会红的判据); * 3. completed / failed 那些回写**不碰** blocked_reason(它只服务于「要人动手」这一种)。 * * 隔离手法与 graph-run-slot-recovery.test.js 一致:先设 GRAPH_DB_PATH 再 require。 */ import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; import fs from 'fs'; describe('blocked task 的原因回写', () => { let wdb; let settle; const TEST_DB_PATH = '/tmp/test-blocked-reason.sqlite'; const files = [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]; beforeAll(() => { for (const f of files) { try { fs.unlinkSync(f); } catch { /* 没有就算了 */ } } process.env.GRAPH_DB_PATH = TEST_DB_PATH; for (const m of [ '../../services/graph-db', '../../services/org-graph-service', '../../services/work-graph-db', '../../services/graph-run-sse', '../../services/task-settle', ]) { delete require.cache[require.resolve(m)]; } wdb = require('../../services/work-graph-db'); settle = require('../../services/task-settle'); }); afterAll(() => { for (const f of files) { try { fs.unlinkSync(f); } catch { /* 同上 */ } } delete process.env.GRAPH_DB_PATH; }); beforeEach(() => { const db = wdb.getDb(); db.pragma('foreign_keys = OFF'); db.exec(` DELETE FROM graph_run_nodes; DELETE FROM graph_runs; DELETE FROM tasks; DELETE FROM goals; DELETE FROM work_graph_edges; DELETE FROM work_graph_nodes; DELETE FROM work_graphs; DELETE FROM system_agents; `); db.pragma('foreign_keys = ON'); }); let seq = 0; /** * 造出「一条 root run 正卡在某个节点上」这个局面。 * 直接写 graph_run_nodes 的状态列:本文件验的是回写那一跳,不是引擎怎么走到 blocked 的。 */ function scene({ nodeStatus = 'blocked', failReason = 'boundary_escape', note = null } = {}) { const companyId = `blockedreason-co-${++seq}`; const now = new Date().toISOString(); const db = wdb.getDb(); db.prepare(` INSERT OR IGNORE INTO system_agents (id, name, scope, owner_company_id, legacy_path, status, created_at, updated_at) VALUES ('sdd-planning', 'sdd-planning', 'public', NULL, 'system/agents/sdd-planning', 'active', ?, ?) `).run(now, now); const graph = wdb.createWorkGraph({ companyId, nodes: ['sdd-planning'], edges: [] }); expect(graph.errors || []).toEqual([]); const goal = wdb.createGoal({ companyId, title: '做一个官网', description: '测试用' }); const task = wdb.createTask({ goalId: goal.id, companyId, title: '做一个官网', description: '测试用', executionMode: 'manual', }); const run = wdb.createRun({ graphKind: 'work', graphId: graph.id, companyId, goalId: goal.id, taskId: task.id, executionMode: 'manual', runStatus: 'running', }); wdb.syncRunNodesToDef(run.id); wdb.updateTask(task.id, { status: 'running', currentRunId: run.id, startedAt: now }); const nodeRow = db.prepare(`SELECT id FROM graph_run_nodes WHERE run_id = ?`).get(run.id); db.prepare(` UPDATE graph_run_nodes SET status = ?, fail_reason = ?, remediation_note = ?, updated_at = ? WHERE id = ? `).run(nodeStatus, failReason, note, now, nodeRow.id); return { taskId: task.id, run: wdb.getRun(run.id) }; } it('① blocked 回写会把「卡在哪个节点、什么 fail_reason」写到 task 行上', () => { const { taskId, run } = scene({ failReason: 'boundary_escape' }); expect(settle.settleRootRun(run, 'blocked')).toEqual({ task: 'blocked', goal: null }); const task = wdb.getTask(taskId); expect(task.status).toBe('blocked'); // 节点名与 fail_reason 都要带得出来 —— 少一个,人在任务列表上就还得点进去才知道去哪看 expect(task.blocked_reason).toContain('sdd-planning'); expect(task.blocked_reason).toContain('boundary_escape'); }); it('② 原因绝不以 upstream_failed: 开头(否则会被调度器自动推回 queued)', () => { const { taskId, run } = scene(); settle.settleRootRun(run, 'blocked'); expect(wdb.getTask(taskId).blocked_reason.startsWith('upstream_failed:')).toBe(false); }); it('③ fail_reason 为 null 是合法常态(走兜底边),此时仍要写出原因而不是留白', () => { const { taskId, run } = scene({ failReason: null }); settle.settleRootRun(run, 'blocked'); const reason = wdb.getTask(taskId).blocked_reason; expect(reason).toBeTruthy(); expect(reason).toContain('sdd-planning'); }); it('④ 判词太长要截断 —— task 行是一条小字,不是日志窗口', () => { const { taskId, run } = scene({ note: '现象:'.padEnd(600, '啰') }); settle.settleRootRun(run, 'blocked'); expect(wdb.getTask(taskId).blocked_reason.length).toBeLessThan(300); }); it('⑤ completed 回写不碰 blocked_reason', () => { const { taskId, run } = scene({ nodeStatus: 'success', failReason: null }); settle.settleRootRun(run, 'completed'); const task = wdb.getTask(taskId); expect(task.status).toBe('completed'); expect(task.blocked_reason || '').toBe(''); }); });