task-blocked-reason.test.js 6.09 KB
/**
 * 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('');
  });
});