p3c-recovery-blind-spots.test.js 25.3 KB
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464
/**
 * Unit Tests — 批 3 三处修复里**还没有人钉过的那几格**(27-P3C 批 3 对抗复核 F1 / F2 / F3)
 *
 * `graph-run-slot-recovery.test.js` 已经钉住了「人点启动 → 退回队列 → 自己被接回来」和
 * 「retryNode 干净拒绝」;这个文件补的是同一批修复里**没有测试看着**的那几条路径,
 * 每一条都对应一种「写不进去但不报错 / 注释承诺了却看不见」的老病:
 *
 *   F1-C `reviveParentFromChild` 撞坑满 —— 它挂在救援方那条 run 的 `runAuto(...).catch` 下面,
 *        抛出去只会被吞成一行 console。所以「不静默」必须是**可查的痕迹**:timeline 事件、
 *        task 行上的原因、以及退得回队列时的自愈。三种结局各钉一条。
 *   F1-B `completed` + **有 task** 的 run —— 已有用例用的是裸 run(没 task),走的是
 *        `requeueBlockedRootRun` 第一道 `!run.task_id` 闸。有 task 的那条走的是另一道
 *        (`run_status !== 'blocked'`),而它才是「对一条跑完的任务点重试」的真实形态。
 *   F2   `isUntouchedNode` 的五个合取项 —— 已有用例只动了 `attempt`。剩下三个
 *        (agent_run_id / child_run_id / output_ref)任何一个漏进 DELETE 的 WHERE,
 *        丢的都是审计轨迹,而且**丢了不报错**。外加白名单对 `completed` 同样成立。
 *   F3   `sweep()` 的取值域 —— 「一条 queued 都没有、只剩 blocked」的公司是它扩宽取值域的
 *        全部理由。旧口径下这家公司在重启后再也不会被 tick,上游修好了下游也永远解不开。
 *   强制放行 —— 数据层那道 `DEPENDS_ON_EDITABLE_STATUS` 闸门 + 下一次 tick 真的起跑,
 *        路由层的端到端用例在另一个文件,这里钉的是它依赖的那两块地基。
 *
 * 隔离手法与 graph-run-slot-recovery.test.js 一致:先设 GRAPH_DB_PATH / GRAPH_RUNS_DIR 再
 * require(graph-run-sse 在模块加载时快照 GRAPH_RUNS_DIR,反过来会往仓库 data/ 里写且不报错)。
 * agent 一律走注入的假 executor,全程不起 Claude CLI。
 */
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';

describe('批 3 恢复路径的盲区', () => {
  let wdb;
  let engine;
  let sse;
  let scheduler;
  let runsDir;
  const TEST_DB_PATH = '/tmp/test-p3c-recovery-blind-spots.sqlite';
  const DB_FILES = [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`];
  const MODULES = [
    '../../services/graph-db',
    '../../services/graph-service',
    '../../services/org-graph-service',
    '../../services/work-graph-db',
    '../../services/graph-run-sse',
    '../../services/graph-engine',
    '../../services/task-settle',
    '../../services/task-scheduler',
  ];
  /** 挂起的 executor promise,afterAll 统一放掉,免得留下永不 settle 的 driver */
  const pendingResolvers = [];

  beforeAll(() => {
    for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch { /* 本来就没有 */ } }
    process.env.GRAPH_DB_PATH = TEST_DB_PATH;
    runsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'p3c-blind-runs-'));
    process.env.GRAPH_RUNS_DIR = runsDir;
    for (const m of MODULES) delete require.cache[require.resolve(m)];
    wdb = require('../../services/work-graph-db');
    sse = require('../../services/graph-run-sse');
    engine = require('../../services/graph-engine');
    scheduler = require('../../services/task-scheduler');
  });

  /** advanceOne 的 ensureProjectRoot 认的是仓库里的 data/companies/,只删本文件造的那些 */
  function cleanupLeakedCompanyDirs() {
    const root = path.resolve(__dirname, '../../../data/companies');
    if (!fs.existsSync(root)) return;
    for (const cid of fs.readdirSync(root)) {
      if (!cid.startsWith('p3cblind-co-')) continue;
      const dir = path.join(root, cid);
      if (fs.existsSync(path.join(dir, 'company.json'))) continue; // 那是真公司
      try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* 删不掉就留着 */ }
    }
  }

  afterAll(async () => {
    for (const r of pendingResolvers) r();
    await new Promise((r) => setTimeout(r, 100));
    scheduler.stopSweeper();
    cleanupLeakedCompanyDirs();
    for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch { /* 已经删了 */ } }
    try { fs.rmSync(runsDir, { recursive: true, force: true }); } catch { /* 同上 */ }
    delete process.env.GRAPH_DB_PATH;
    delete process.env.GRAPH_RUNS_DIR;
  });

  beforeEach(() => {
    const db = wdb.getDb();
    db.pragma('foreign_keys = OFF');
    db.exec(`
      DELETE FROM graph_run_loop_records;
      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');
    engine.setAgentExecutor(null);
    engine.setPlanWorkGraph(null);
  });

  let seq = 0;
  const nextCompany = () => `p3cblind-co-${++seq}`;

  function ensureAgents(ids) {
    const now = new Date().toISOString();
    const ins = wdb.getDb().prepare(`
      INSERT OR IGNORE INTO system_agents
        (id, name, scope, owner_company_id, legacy_path, status, created_at, updated_at)
      VALUES (?, ?, 'public', NULL, ?, 'active', ?, ?)
    `);
    for (const id of new Set(ids)) ins.run(id, id, `system/agents/${id}`, now, now);
  }

  function buildGraph(companyId, nodes = ['solo']) {
    ensureAgents(nodes);
    const edges = nodes.slice(1).map((to, i) => ({ from: nodes[i], to }));
    const g = wdb.createWorkGraph({ companyId, nodes, edges });
    expect(g.errors || []).toEqual([]);
    return g.id;
  }

  const newGoal = (companyId) => wdb.createGoal({ companyId, title: 'g', description: 'g' }).id;

  /**
   * 把公司的那个坑占满。直接写 running 而不是真跑一条 run:这里要的只是「坑上躺着人」,
   * 而准入守卫数的正是 ACTIVE_ROOT_RUN_STATUS 里的 root run。
   */
  function occupySlot(companyId) {
    const run = wdb.createRun({ graphKind: 'work', graphId: buildGraph(companyId, ['holder']), companyId });
    wdb.getDb().prepare(`UPDATE graph_runs SET run_status = 'running' WHERE id = ?`).run(run.id);
    return run.id;
  }

  const nodeByName = (runId, name) => (wdb.getRunState(runId).nodes || []).find((n) => n.name === name);

  const timeline = (runId) => fs.readFileSync(sse.timelinePath(runId), 'utf8')
    .split('\n').filter(Boolean).map((l) => JSON.parse(l));

  /**
   * 造出「父 run 一个 subgraph 节点 + 一张会失败的子图」,父图入口节点没有 fail 出边
   * (27-P3B 界面走查里走到 blocked 的正是这个形状,见 graph-engine-revive-parent.test.js)。
   * @param {object} [opt]
   * @param {boolean} [opt.withTask] 给父 run 挂一条 task(决定坑满时退不退得回队列)
   * @param {boolean} [opt.archiveTask] task 建完就归档(退不回队列,但 task 行上仍要留痕)
   */
  function buildParentWithFailingChild({ withTask = false, archiveTask = false } = {}) {
    const companyId = nextCompany();
    ensureAgents(['org-planner', 'wg-only']);
    const childG = wdb.createWorkGraph({ companyId, nodes: ['wg-only'], edges: [] });
    const parentG = wdb.createWorkGraph({
      companyId,
      nodes: [{ nodeName: 'org-planner', systemAgentId: 'org-planner', nodeKind: 'subgraph' }],
      edges: [],
    });
    expect(childG.errors || []).toEqual([]);
    expect(parentG.errors || []).toEqual([]);

    let goalId = null;
    let taskId = null;
    if (withTask) {
      goalId = newGoal(companyId);
      taskId = wdb.createTask({
        goalId, companyId, title: 'parent-task', description: '父 run 对应的任务', executionMode: 'auto',
      }).id;
    }
    const parentRun = wdb.createRun({
      graphKind: 'work', graphId: parentG.id, companyId, goalId, taskId, executionMode: 'manual',
    });
    if (taskId) {
      // current_run_id 必须指着这条 run:task-settle / requeue / 留痕的 WHERE 全靠它认亲
      wdb.updateTask(taskId, { status: 'running', currentRunId: parentRun.id, queuedAt: new Date().toISOString() });
      if (archiveTask) wdb.updateTask(taskId, { archivedAt: new Date().toISOString() });
    }

    engine.markRunReady(parentRun.id);
    engine.startRun(parentRun.id, { mode: 'manual' });
    engine.setPlanWorkGraph(() => ({ workGraphId: childG.id }));
    engine.setAgentExecutor(async () => ({ status: 'fail', failReason: null, output: '子节点失败' }));
    return { companyId, parentRunId: parentRun.id, taskId };
  }

  /** 推到「子 run 失败、父节点 blocked、父 run blocked」 */
  async function driveToBlocked(parentRunId) {
    for (let i = 0; i < 12; i += 1) {
      const r = await engine.advanceTree(parentRunId);
      if (!r || r.ok === false) break;
    }
    const parent = nodeByName(parentRunId, 'org-planner');
    expect(parent.status).toBe('blocked');
    expect(wdb.getRun(parentRunId).run_status).toBe('blocked');
    return parent;
  }

  /** 把子 run 修好并跑到 completed —— 这一跳会触发 propagateToParent → reviveParentFromChild */
  async function rescueChild(childRunId) {
    engine.setAgentExecutor(async () => ({ status: 'success', output: '修好了' }));
    const childNode = wdb.getRunState(childRunId).nodes[0];
    engine.retryNode(childRunId, childNode.id);
    for (let i = 0; i < 6; i += 1) {
      const r = await engine.advanceOne(childRunId);
      if (!r || r.ok === false) break;
    }
    expect(wdb.getRun(childRunId).run_status).toBe('completed');
  }

  // ────────────────────────────────────────────────────────────
  // F1-C:reviveParentFromChild 撞坑满
  // ────────────────────────────────────────────────────────────

  it('F1-C 救援撞坑满且退不回队列(裸 run)→ timeline 上留得下痕迹,父节点一个字都不动', async () => {
    const { companyId, parentRunId } = buildParentWithFailingChild();
    const blockedNode = await driveToBlocked(parentRunId);
    const holderRunId = occupySlot(companyId);

    await rescueChild(blockedNode.childRunId);

    // 父节点/父 run 保持原样:救不回来就别留半截状态(节点 success 而 run 还 blocked 是死局)
    const after = nodeByName(parentRunId, 'org-planner');
    expect(after.status).toBe('blocked');
    expect(after.attempt).toBe(blockedNode.attempt);
    expect(wdb.getRun(parentRunId).run_status).toBe('blocked');

    // 痕迹:本函数挂在 runAuto(...).catch 底下,没有事件就等于这件事从没发生过
    const trace = timeline(parentRunId)
      .find((l) => l.kind === 'node.error' && l.payload && l.payload.error_type === 'RunSlotBusyError');
    expect(trace, 'reviveParentFromChild 坑满时必须发事件,否则控制台上什么都看不出来').toBeTruthy();
    expect(trace.payload.held_by).toEqual([holderRunId]);
    expect(trace.payload.child_run_id).toBe(blockedNode.childRunId);
    expect(trace.payload.company_id).toBe(companyId);
    // ref 必须挂在那个卡住的节点上:控制台的事件流是按 ref 归位的,挂错地方等于发进真空
    expect(trace.ref).toBe(blockedNode.id);
  });

  it('F1-C 退不回队列但 task 还在时,原因写到 task 行上,且刻意不带 upstream_failed: 前缀', async () => {
    // 归档过的 task 退不回队列(requeue 的 WHERE 有 archived_at IS NULL),但留痕照旧要有
    const { companyId, parentRunId, taskId } = buildParentWithFailingChild({ withTask: true, archiveTask: true });
    const blockedNode = await driveToBlocked(parentRunId);
    const holderRunId = occupySlot(companyId);

    await rescueChild(blockedNode.childRunId);

    const task = wdb.getTask(taskId);
    expect(task.status).toBe('blocked');           // 父 run 转 blocked 时由 task-settle 写的
    expect(task.blocked_reason).toContain('revive_slot_busy:');
    expect(task.blocked_reason).toContain(holderRunId);
    // 带上 upstream_failed: 前缀就会被调度器当成「推导出来的 blocked」自动推回 queued,
    // 而这条 run 还等着人处置 —— 那会让任务在 queued/blocked 之间抖动却没人真的在跑
    expect(task.blocked_reason.startsWith(wdb.UPSTREAM_BLOCK_PREFIX)).toBe(false);
    expect(nodeByName(parentRunId, 'org-planner').status).toBe('blocked');
  });

  it('F1-C 退得回队列时链路自愈:父 run 与 task 一起回 queued,节点照常复活', async () => {
    const { companyId, parentRunId, taskId } = buildParentWithFailingChild({ withTask: true });
    const blockedNode = await driveToBlocked(parentRunId);
    expect(wdb.getTask(taskId).status).toBe('blocked');
    const holderRunId = occupySlot(companyId);

    await rescueChild(blockedNode.childRunId);

    // 两行必须一起动:只动一半的两种死法见 requeueBlockedRootRun 的注释
    expect(wdb.getRun(parentRunId).run_status).toBe('queued');
    expect(wdb.getTask(taskId).status).toBe('queued');
    expect(wdb.getTask(taskId).blocked_reason).toBeNull();
    // 节点照常复活 —— 调度器把这条 run 出队时链路接着往下走,救援没白做
    expect(nodeByName(parentRunId, 'org-planner').status).toBe('success');

    // requeueBlockedRootRun 不走 transitionRun(它要把 task 那行包进同一个事务),
    // 事件得由 admitOrRequeue 自己补。少了它就是「状态变了却没有事件」,控制台不会重拉快照。
    const ev = timeline(parentRunId)
      .find((l) => l.kind === 'run.status' && l.payload && l.payload.requeued === true);
    expect(ev, '退回队列必须补一条 run.status 事件').toBeTruthy();
    expect(ev.payload.to).toBe('queued');
    expect(ev.payload.held_by).toEqual([holderRunId]);

    // 「调度器把它出队时链路接着往下走」不能只写在注释里:坑一空就得真的收敛掉,
    // 否则救回来的成果只是换了个姿势卡住(这次卡在 queued 上,界面上还显示「排队中」)。
    sse.transitionRun(holderRunId, 'running', 'completed', {}, { summary: '用例直推终态,腾出坑' });
    for (let i = 0; i < 40 && wdb.getRun(parentRunId).run_status === 'queued'; i += 1) {
      await new Promise((r) => setTimeout(r, 10));
    }
    expect(wdb.getRun(parentRunId).run_status).toBe('completed');
    expect(wdb.getTask(taskId).status).toBe('completed');
    // 已经 success 的那个 subgraph 节点不许被重新点亮:那等于把刚救回来的子 run 再跑一遍
    expect(nodeByName(parentRunId, 'org-planner').status).toBe('success');
  });

  // ────────────────────────────────────────────────────────────
  // F1-B:completed + 有 task 的 run 走的是另一道闸
  // ────────────────────────────────────────────────────────────

  it('F1-B 对一条已跑完的任务点重试撞坑满 → 干净拒绝:节点、attempt、task 全都没动', async () => {
    const companyId = nextCompany();
    const goalId = newGoal(companyId);
    const holderRunId = occupySlot(companyId);

    // 一条正常收敛过的任务:run completed、task completed(`current_run_id` 指着它)
    const task = wdb.createTask({ goalId, companyId, title: 'done', description: '跑完了的任务' });
    const run = wdb.createRun({
      graphKind: 'work', graphId: buildGraph(companyId, ['z1']), companyId, goalId, taskId: task.id,
    });
    const db = wdb.getDb();
    db.prepare(`UPDATE graph_runs SET run_status = 'completed' WHERE id = ?`).run(run.id);
    wdb.updateTask(task.id, { status: 'completed', currentRunId: run.id });
    const node = wdb.getRunState(run.id).nodes[0];
    db.prepare(`UPDATE graph_run_nodes SET status = 'fail' WHERE id = ?`).run(node.id);

    let caught = null;
    try { engine.retryNode(run.id, node.id); } catch (err) { caught = err; }
    expect(caught).toBeInstanceOf(wdb.RunSlotBusyError);
    expect(caught.heldBy).toEqual([holderRunId]);

    // completed 的 run 退不回队列(把终态任务塞回队列是批 4 的 rerun 语义,不是这里的事)
    expect(wdb.getRun(run.id).run_status).toBe('completed');
    expect(wdb.getTask(task.id).status).toBe('completed');
    const after = wdb.getRunNode(node.id);
    expect(after.status).toBe('fail');
    expect(after.attempt).toBe(0);
  });

  // ────────────────────────────────────────────────────────────
  // F2:白名单 + isUntouchedNode 的其余三个合取项
  // ────────────────────────────────────────────────────────────

  it('F2 白名单不是「非 running 就放行」:completed 的 run 同样一行不删', () => {
    const companyId = nextCompany();
    const graphId = buildGraph(companyId, ['c1', 'c2']);
    const run = wdb.createRun({ graphKind: 'work', graphId, companyId });
    const db = wdb.getDb();
    db.prepare(`UPDATE graph_runs SET run_status = 'completed' WHERE id = ?`).run(run.id);
    db.prepare(`DELETE FROM work_graph_edges WHERE work_graph_id = ?`).run(graphId);
    db.prepare(`DELETE FROM work_graph_nodes WHERE work_graph_id = ? AND node_name = 'c2'`).run(graphId);

    expect(wdb.syncRunNodesToDef(run.id)).toBeNull();
    expect(wdb.getRunState(run.id).nodes.length).toBe(2); // 历史 run 的节点行是审计轨迹,不许被改图删掉
  });

  it('F2 「没跑过」的判据是五个合取项:agent_run_id / child_run_id / output_ref 任一非空都留着', () => {
    const companyId = nextCompany();
    const goalId = newGoal(companyId);
    const graphId = buildGraph(companyId, ['k0', 'k1', 'k2', 'k3']);
    const task = wdb.createTask({ goalId, companyId, title: 'q', description: 'q', executionMode: 'auto' });
    const run = wdb.createRun({
      graphKind: 'work', graphId, companyId, goalId, taskId: task.id, runStatus: 'queued',
    });

    // 三种「跑过但被写回 pending」的痕迹各造一条(回退重置 / reopenChildRun 都会留下这种行)
    const db = wdb.getDb();
    // child_run_id 有外键,得指着一条真存在的子 run(queued 不占坑,不影响别的断言)
    const childRun = wdb.createRun({
      graphKind: 'work', graphId, companyId, runStatus: 'queued',
    });
    const nodes = Object.fromEntries(wdb.getRunState(run.id).nodes.map((n) => [n.name, n]));
    db.prepare(`UPDATE graph_run_nodes SET status='pending', agent_run_id='ar_x' WHERE id=?`).run(nodes.k1.id);
    db.prepare(`UPDATE graph_run_nodes SET status='pending', child_run_id=? WHERE id=?`).run(childRun.id, nodes.k2.id);
    db.prepare(`UPDATE graph_run_nodes SET status='pending', output_ref='out/x.md' WHERE id=?`).run(nodes.k3.id);

    // 排队期间「重新规划组织」:定义里只剩 k0
    db.prepare(`DELETE FROM work_graph_edges WHERE work_graph_id = ?`).run(graphId);
    db.prepare(`DELETE FROM work_graph_nodes WHERE work_graph_id = ? AND node_name != 'k0'`).run(graphId);

    const diff = wdb.syncRunNodesToDef(run.id);
    expect(diff.removed).toEqual([]);                      // 一条都不许删
    expect(diff.kept.sort()).toEqual([nodes.k1, nodes.k2, nodes.k3].map((n) => n.defNodeId).sort());
    for (const n of [nodes.k1, nodes.k2, nodes.k3]) expect(wdb.getRunNode(n.id)).toBeTruthy();
  });

  // ────────────────────────────────────────────────────────────
  // F3:sweep 的取值域
  // ────────────────────────────────────────────────────────────

  it('F3 一条 queued 都没有、只剩 blocked(upstream_failed:*) 的公司,sweep 仍要捡起来', () => {
    const companyId = nextCompany();
    const goalId = newGoal(companyId);
    // 上游死了(进程重启前 A 就失败了),下游只剩这一条 blocked —— 队里空无一人
    const up = wdb.createTask({ goalId, companyId, title: 'A', description: '上游' });
    wdb.updateTask(up.id, { status: 'failed' });
    const down = wdb.createTask({ goalId, companyId, title: 'B', description: '下游', dependsOn: [up.id] });
    wdb.updateTask(down.id, { status: 'blocked', blockedReason: `${wdb.UPSTREAM_BLOCK_PREFIX}${up.id}` });

    // 前提:旧口径(只扫 status='queued')下这家公司根本进不了扫描结果
    const queued = wdb.listCompanyTasks(companyId, { status: 'queued' });
    expect(queued.length).toBe(0);

    expect(scheduler.sweep().map((r) => r.companyId)).toContain(companyId);
    expect(wdb.getTask(down.id).status).toBe('blocked');   // 上游还是死的,这一轮不该动它

    // 人把上游 rerun 好了 → 下一次 sweep 自己把下游放回队列(blocked 是推导结果,不落死)
    wdb.updateTask(up.id, { status: 'completed' });
    const results = scheduler.sweep();
    expect(results.find((r) => r.companyId === companyId).requeued).toContain(down.id);
    expect(wdb.getTask(down.id).status).toBe('queued');
    expect(wdb.getTask(down.id).blocked_reason).toBeNull();
  });

  // ────────────────────────────────────────────────────────────
  // 强制放行:数据层闸门 + 下一次 tick 真的起跑
  // ────────────────────────────────────────────────────────────

  it('强制放行:blocked 的 task 能改 depends_on,删掉死上游后下一次 tick 就起跑', async () => {
    const companyId = nextCompany();
    const goalId = newGoal(companyId);
    // executor 永不 resolve:起跑之后 run 稳稳停在 running,断言不受异步驱动影响
    engine.setAgentExecutor(() => new Promise((resolve) => {
      pendingResolvers.push(() => resolve({ status: 'success' }));
    }));

    const up = wdb.createTask({ goalId, companyId, title: 'A', description: '上游' });
    wdb.updateTask(up.id, { status: 'cancelled' });         // 上游被人取消了,永远等不到 completed
    const down = wdb.createTask({
      goalId, companyId, title: 'B', description: '下游', executionMode: 'auto', dependsOn: [up.id],
    });
    const run = wdb.createRun({
      graphKind: 'work', graphId: buildGraph(companyId), companyId, goalId, taskId: down.id, runStatus: 'queued',
    });
    wdb.updateTask(down.id, { status: 'queued', currentRunId: run.id, queuedAt: new Date().toISOString() });

    scheduler.tick(companyId);                              // 依赖推导:queued → blocked
    expect(wdb.getTask(down.id).status).toBe('blocked');
    expect(wdb.getTask(down.id).blocked_reason).toBe(`${wdb.UPSTREAM_BLOCK_PREFIX}${up.id}`);

    // 闸门放行 blocked:挡掉它,界面上那个「强制放行」按钮就必然 4xx,这条任务再无自救出路
    expect(() => wdb.updateTask(down.id, { dependsOn: [] })).not.toThrow();

    const res = scheduler.tick(companyId);
    expect(res.requeued).toContain(down.id);
    expect(res.started.map((s) => s.taskId)).toContain(down.id);
    expect(wdb.getTask(down.id).status).toBe('running');
    expect(wdb.getTask(down.id).blocked_reason).toBeNull();
    expect(wdb.getRun(run.id).run_status).toBe('running');

    // 收尾:放掉挂起的 executor 并等驱动真的收工。不等的话 beforeEach 会在它跑到一半时清库,
    // 驱动随后打一行「graph_run_nodes 不存在」的异常 —— 与被测行为无关的噪声,别留给下一个人查。
    for (const r of pendingResolvers.splice(0)) r();
    await new Promise((r) => setTimeout(r, 60));
  });

  it('强制放行**不许**顺手清 blocked_reason:清了那条回队列的条件更新就永远命中不了', () => {
    const companyId = nextCompany();
    const goalId = newGoal(companyId);
    const up = wdb.createTask({ goalId, companyId, title: 'A', description: '上游' });
    wdb.updateTask(up.id, { status: 'failed' });
    const down = wdb.createTask({
      goalId, companyId, title: 'B', description: '下游', executionMode: 'manual', dependsOn: [up.id],
    });
    wdb.updateTask(down.id, { status: 'blocked', blockedReason: `${wdb.UPSTREAM_BLOCK_PREFIX}${up.id}` });

    // 「改依赖 + 顺手把 reason 清成 NULL」——看着更干净,实际是死锁:
    // requeueDerivedBlockedTask 的 WHERE 里有 blocked_reason LIKE 'upstream_failed:%'
    wdb.updateTask(down.id, { dependsOn: [], blockedReason: null });
    scheduler.tick(companyId);
    expect(wdb.getTask(down.id).status).toBe('blocked');    // 卡死,且全程不报错
    expect(wdb.requeueDerivedBlockedTask(down.id)).toBe(0);

    // 反过来:reason 留着的那条就能被推回队列 —— 差别只在这一列
    wdb.updateTask(down.id, { blockedReason: `${wdb.UPSTREAM_BLOCK_PREFIX}${up.id}` });
    scheduler.tick(companyId);
    expect(wdb.getTask(down.id).status).toBe('queued');
  });
});