company-goal-task-routes.test.js 42.4 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 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820
/**
 * goal / task 两级模型的路由层(27-P3C §六,批 1)。
 *
 * 这里只验**不需要真起 run** 的那一半:列表 / 详情 / 改 / 归档,外加两条闸门的 HTTP 翻译
 * (描述冻结 → 409、depends_on 非法 → 400 且库里不留脏数据)。起 run 的那条路要真公司 +
 * 真 org graph + Claude CLI,属于 §八 验收清单里在浏览器上点的部分,不在单测射程内。
 *
 * 进度那条断言(2/3)是本文件的重点:验收 10 点名要挡的就是「列表页逐行查进度」的 N+1,
 * 路由这边走的是 taskProgress 的一条 GROUP BY —— 结果对不对必须有人盯着,
 * 否则改成逐行查也一样是绿的。
 */

const fs = require('fs');
const os = require('os');
const path = require('path');

const TEST_DB_PATH = '/tmp/test-company-goal-task-routes.sqlite';
const DB_FILES = [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`];
// 这几个模块在加载时快照 GRAPH_DB_PATH / GRAPH_RUNS_DIR,必须先设 env 再 require(见 §八 验收 12b)
const MODULES = ['../../services/graph-db', '../../services/work-graph-db', '../../routes/company'];

const CID = 'route-test-co';

let wdb;
let server;
let base;
let origin;
let origCompanyGet;

/** 一次请求 → [status, json](没有 supertest,express 起在随机端口上用 fetch 打) */
async function call(method, url, body) {
  const r = await fetch(base + url, {
    method,
    headers: { 'Content-Type': 'application/json' },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  return [r.status, await r.json()];
}

/** 同上,但公司 id 自己指定(验「打错公司 id → 404」那条要打到别的公司上) */
async function callCompany(cid, method, url) {
  const r = await fetch(`${origin}/api/company/${cid}${url}`, { method });
  return [r.status, await r.json()];
}

describe('company goal/task routes(27-P3C §六)', () => {
  let goal; let t1; let t2;

  beforeAll(async () => {
    for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch { /* 本来就没有 */ } }
    process.env.GRAPH_DB_PATH = TEST_DB_PATH;
    // 产物根挪到临时目录:不设的话任何走到默认路径的写入都会往仓库 data/companies/ 里堆
    process.env.GRAPH_RUNS_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'p3c-routes-'));
    for (const m of MODULES) delete require.cache[require.resolve(m)];

    wdb = require('../../services/work-graph-db');

    // 公司实体:`company-service` 的 DATA_DIR 是硬编码的仓库 data/companies/,不认任何 env,
    // 真建一家公司就是往用户仓库里落目录。这里只让 CID 这一家存在 —— 别的 id 一律 null,
    // 「打错公司 id → 404」(缺陷 F9)那条断言要靠它。
    const { CompanyService } = require('../../services/company-service');
    origCompanyGet = CompanyService.prototype.get;
    CompanyService.prototype.get = (companyId) =>
      (companyId === CID ? { id: CID, name: CID, status: 'running' } : null);

    const express = require('express');
    const app = express();
    app.use(express.json());
    app.use('/api/company', require('../../routes/company'));

    await new Promise((resolve) => {
      server = app.listen(0, resolve);
    });
    origin = `http://127.0.0.1:${server.address().port}`;
    base = `${origin}/api/company/${CID}`;

    goal = wdb.createGoal({ companyId: CID, title: '做个官网', description: '做个官网,含首页和后台' });
    t1 = wdb.createTask({ goalId: goal.id, title: '首页', description: '写首页' });
    t2 = wdb.createTask({ goalId: goal.id, title: '后台', description: '写后台', dependsOn: [t1.id] });

    // 给 t1 挂一条 run + 3 个节点行(2 success / 1 pending),用来验进度口径
    const db = wdb.getDb();
    const now = new Date().toISOString();
    db.prepare(`INSERT INTO graph_runs (id, graph_kind, graph_id, company_id, root_run_id, run_status, created_at, updated_at)
                VALUES ('run_rt','org',?,?, 'run_rt','running',?,?)`).run(CID, CID, now, now);
    ['success', 'success', 'pending'].forEach((status, i) => {
      db.prepare(`INSERT INTO graph_run_nodes (id, run_id, def_node_id, status, created_at, updated_at)
                  VALUES (?, 'run_rt', ?, ?, ?, ?)`).run(`rn_rt${i}`, `d${i}`, status, now, now);
    });
    wdb.updateTask(t1.id, { currentRunId: 'run_rt', status: 'running' });
  });

  afterAll(() => {
    if (server) server.close();
    if (origCompanyGet) require('../../services/company-service').CompanyService.prototype.get = origCompanyGet;
    for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch { /* 已经删了 */ } }
    try { fs.rmSync(process.env.GRAPH_RUNS_DIR, { recursive: true, force: true }); } catch { /* ignore */ }
    delete process.env.GRAPH_DB_PATH;
    delete process.env.GRAPH_RUNS_DIR;
    for (const m of MODULES) delete require.cache[require.resolve(m)];
  });

  it('GET /goals?withTasks=1 一次带出两级树,进度走一条 GROUP BY', async () => {
    const [status, body] = await call('GET', '/goals?withTasks=1');
    expect(status).toBe(200);
    expect(body.goals).toHaveLength(1);

    const tasks = body.goals[0].tasks;
    expect(tasks.map((t) => t.taskId)).toEqual([t1.id, t2.id]); // 树的顺序是 seq,不是队列位次
    expect(tasks[0].progress).toEqual({ done: 2, total: 3 });
    // 没有 run 的 task 是 null 而不是 0/0:前端据此画「未开始」,画成 0/0 会像是「跑了但没进展」
    expect(tasks[1].progress).toBeNull();
    expect(tasks[1].dependsOn).toEqual([t1.id]);
  });

  it('GET /tasks 支持按 status 过滤,且默认不含已归档', async () => {
    const [status, body] = await call('GET', '/tasks?status=running');
    expect(status).toBe(200);
    expect(body.tasks.map((t) => t.taskId)).toEqual([t1.id]);
  });

  it('GET /goals/:goalId 带出每条 task 的当前 run 摘要', async () => {
    const [status, body] = await call('GET', `/goals/${goal.id}`);
    expect(status).toBe(200);
    expect(body.tasks[0].run).toMatchObject({ runId: 'run_rt', runStatus: 'running' });
    expect(body.tasks[1].run).toBeNull();
  });

  it('不属于本公司 / 不存在的 goal 一律 404', async () => {
    const [status] = await call('GET', '/goals/G-nope');
    expect(status).toBe(404);
  });

  it('描述冻结:未下达可改,dispatched_at 一非空就 409 + hint(§四.4)', async () => {
    const [ok, before] = await call('PATCH', `/goals/${goal.id}`, { description: '换个需求' });
    expect(ok).toBe(200);
    expect(before.description).toBe('换个需求');

    wdb.updateGoal(goal.id, { dispatchedAt: new Date().toISOString() });
    const [conflict, body] = await call('PATCH', `/goals/${goal.id}`, { description: '再换一次' });
    expect(conflict).toBe(409);
    expect(body.frozenFields).toEqual(['description']);
    expect(body.hint).toBeTruthy(); // 「取消后新建」这句必须给出来,否则用户只知道被拒不知道该怎么办

    // title 是列表上的标签、不进 agent 上下文,冻结之后仍可改
    const [stillOk] = await call('PATCH', `/goals/${goal.id}`, { title: '改个名' });
    expect(stillOk).toBe(200);
  });

  it('depends_on 非法 → 400,且库里不留半条脏数据(§六.3)', async () => {
    const [status, body] = await call('PATCH', `/tasks/${t2.id}`, { depends_on: [t2.id] });
    expect(status).toBe(400);
    expect(body.error).toMatch(/依赖自己/);
    expect(JSON.parse(wdb.getTask(t2.id).depends_on)).toEqual([t1.id]);
  });

  it('跑起来之后不许改依赖(判据在数据层,路由只负责翻成 409)', async () => {
    const [status] = await call('PATCH', `/tasks/${t1.id}`, { depends_on: [] });
    expect(status).toBe(409);
  });

  it('归档:正在跑的 task 拦下来并指出该先停哪条 run', async () => {
    const [status, body] = await call('DELETE', `/goals/${goal.id}`);
    expect(status).toBe(409);
    expect(body.runningTasks).toEqual([t1.id]);
    expect(body.hint).toContain('run_rt');
  });

  it('归档只是隐藏:默认列表不出现,includeArchived=1 查得回来', async () => {
    const [status] = await call('DELETE', `/tasks/${t2.id}`);
    expect(status).toBe(200);

    const [, plain] = await call('GET', '/tasks');
    expect(plain.tasks.map((t) => t.taskId)).toEqual([t1.id]);

    const [, all] = await call('GET', '/tasks?includeArchived=1');
    expect(all.tasks).toHaveLength(2);
  });

  it('OKR 的 /goals 别名已退役:这条路径下只剩 goal 两级模型(§六.1)', async () => {
    // 别名还在的话,GET /goals 会被 OkrService 接走、返回 objective 数组而不是 { goals: [...] }
    const [status, body] = await call('GET', '/goals');
    expect(status).toBe(200);
    expect(Array.isArray(body.goals)).toBe(true);
    expect(body.goals[0].goalId).toBe(goal.id);
  });

  // ──────────────────────────────────────────────────────────────
  // 复核缺陷 F3 / F4 / F5 / F7 / F9
  // ──────────────────────────────────────────────────────────────

  it('打错公司 id 的两个新 GET 端点回 404,不拿空列表冒充「没有目标」(缺陷 F9)', async () => {
    for (const p of ['/goals', '/tasks']) {
      const [status, body] = await callCompany('route-test-c0', 'GET', p);
      expect(status).toBe(404);
      expect(body.error).toMatch(/不存在/);
    }
  });

  it('建 goal 时 tasks[].depends_on 可按 seq 引用同批兄弟,响应回真 id(缺陷 F5)', async () => {
    const [status, body] = await call('POST', '/goals', {
      description: '上线一个服务',
      tasks: [
        { description: '构建镜像' },
        { description: '部署到测试环境', depends_on: [1] }, // 1 = 本次第 1 条的 seq
      ],
    });
    expect(status).toBe(201);

    const [build, deploy] = body.tasks;
    expect(deploy.seq).toBe(2);
    // 翻译成真 id 才算数:回 [1] 或 [] 都会让批 3 的调度器把这条依赖当不存在
    expect(deploy.dependsOn).toEqual([build.taskId]);
    expect(JSON.parse(wdb.getTask(deploy.taskId).depends_on)).toEqual([build.taskId]);

    // 环仍然要被数据层挡住(seq 引用不是绕过校验的后门)
    const [cyclic, err] = await call('POST', '/goals', {
      description: '互相依赖',
      tasks: [
        { description: 'A', depends_on: [2] },
        { description: 'B', depends_on: [1] },
      ],
    });
    expect(cyclic).toBe(400);
    expect(err.error).toMatch(/环/);
  });

  it('tasks[k] 非法 → 400,且 goals / tasks 两张表都不留残行(缺陷 F4)', async () => {
    const before = wdb.listCompanyGoals(CID, { includeArchived: true }).length;
    // task 也要数:只数 goal 的话,「goal 回滚了但第 0 条 task 已提交」这种半回滚是绿的
    const beforeTasks = wdb.listCompanyTasks(CID, { includeArchived: true }).length;

    const [status, body] = await call('POST', '/goals', {
      description: '两条活,第二条写错了',
      tasks: [{ description: '合法的第一条' }, { description: '第二条', mode: 'AUTO' }],
    });
    expect(status).toBe(400);
    expect(body.error).toMatch(/tasks\[1\]\.mode/);

    // 修之前:goal 已提交、第 0 条 task 已 INSERT,客户端改完重发就是第二个 goal,
    // 第一个孤儿 goal 带着孤儿 task 永远躺在列表里
    expect(wdb.listCompanyGoals(CID, { includeArchived: true }).length).toBe(before);
    expect(wdb.listCompanyTasks(CID, { includeArchived: true }).length).toBe(beforeTasks);
  });

  it('建 goal 时申报的 budget 落库到 task 行(缺陷 F3 的写侧)', async () => {
    const budget = { total_agent_calls: 7, total_tokens: 123456 };
    const [status, body] = await call('POST', '/goals', { description: '带预算的需求', budget, split: false });
    expect(status).toBe(201);
    // 只有写侧没有读侧等于没写;读侧(dispatch 起 run 时用它)在 goal-task-acceptance.test.js 里验
    expect(JSON.parse(wdb.getTask(body.tasks[0].taskId).budget_json)).toEqual(budget);
    // 也要回得出来:查不到自己填的额度,用户就只能等任务 blocked 才发现不对
    expect(body.tasks[0].budget).toEqual(budget);
  });

  it('PATCH 的 mode 走同一道信任边界:非法值 400 而不是 500 泄 SQLite 原文(缺陷 F7)', async () => {
    const [, created] = await call('POST', '/goals', { description: '改 mode 用的 goal', split: false });
    const taskId = created.tasks[0].taskId;

    const [bad, err] = await call('PATCH', `/tasks/${taskId}`, { mode: 'AUTO' });
    expect(bad).toBe(400); // 修之前:直送 DB 撞 CHECK 约束 → 500 + "CHECK constraint failed: …"
    expect(err.error).toMatch(/manual/);

    // null 既不是「没传」也不是合法值:修之前被 `!== undefined` 过滤掉 → 200 但什么都没改
    const [nulled] = await call('PATCH', `/tasks/${taskId}`, { mode: null });
    expect(nulled).toBe(400);

    const [ok, patched] = await call('PATCH', `/tasks/${taskId}`, { mode: 'auto' });
    expect(ok).toBe(200);
    expect(patched.mode).toBe('auto');
  });

  it('goal 级 mode 是每条 task 的兜底,task 自己写的优先(三级继承,本轮复核缺陷 2)', async () => {
    const [status, body] = await call('POST', '/goals', {
      description: '整批都要自己跑的需求',
      mode: 'auto',
      split: false,
      tasks: [{ description: '继承 goal 的 auto' }, { description: '自己压回手动', mode: 'manual' }],
    });
    expect(status).toBe(201);
    // 继承这一层是批 2 加的(此前显式 tasks[] 恒 manual),是**行为变化**,所以必须有用例钉住:
    // 没有它,谁把这行改回 `'manual'` 都照样全绿。保留继承的理由是反过来更荒谬 ——
    // 用户在表单上把整个 goal 选成 auto,建出来的 task 却一条都不会自己跑。
    expect(body.tasks.map((t) => t.mode)).toEqual(['auto', 'manual']);

    // 三级都没给才落到最终兜底 manual(别让「默认自动跑」这种事悄悄发生)
    const [, plain] = await call('POST', '/goals', {
      description: '没声明 mode 的需求',
      split: false,
      tasks: [{ description: '一件事' }],
    });
    expect(plain.tasks[0].mode).toBe('manual');
  });

  // ═══════════════════════════════════════
  //  批 2 · 自动拆分(27-P3C §四.9)
  // ═══════════════════════════════════════
  //
  // 拆分本身是一次真 LLM 调用,单测一律走 setPlanTasks 注入 —— 真起 Claude CLI 既慢又要钱,
  // 而且产出不确定,测出来的绿是运气。这里验的是**链路**:状态怎么推、失败怎么降级、账怎么记。

  const tp = () => require('../../services/task-planning');

  /** 轮询到 goal 离开 planning 为止(拆分是后台 setImmediate,响应先回) */
  async function waitSplitDone(goalId, tries = 100) {
    for (let i = 0; i < tries; i += 1) {
      const g = wdb.getGoal(goalId);
      if (g && g.status !== 'planning') return g;
      await new Promise((r) => setTimeout(r, 10));
    }
    throw new Error(`goal ${goalId} 一直停在 planning —— 这正是 §四.9 约束 2 要消灭的那个卡死态`);
  }

  it('默认 split:true → 202 + planning,后台拆完落 N 条 draft task 并记 planning_tokens', async () => {
    const seen = [];
    tp().setPlanTasks(async (ctx) => {
      seen.push(ctx.goal.id);
      return {
        specs: [
          { seq: 1, title: '写后端', description: '实现后端接口', depends_on: [] },
          { seq: 2, title: '写教程', description: '基于后端接口写上手教程', depends_on: [1] },
        ],
        tokens: 4242,
      };
    });
    try {
      const [status, body] = await call('POST', '/goals', { description: '做个工具,含后端和教程' });
      // 202 而不是 201:这次调用**没有**创造出完整的资源,task 还在路上
      expect(status).toBe(202);
      expect(body.split).toBe(true);
      expect(body.status).toBe('planning');
      expect(body.tasks).toEqual([]); // 空数组而不是缺字段:调用方才分得清「还没拆」和「拆出 0 条」

      const g = await waitSplitDone(body.goalId);
      expect(seen).toEqual([body.goalId]);
      expect(g.status).toBe('draft'); // 产出一律 draft,不自动入队(§四.9)
      expect(g.planning_tokens).toBe(4242); // 不记这一笔,goal 层的花费就是隐形的
      expect(g.planning_error).toBeNull();

      const tasks = wdb.listGoalTasks(body.goalId);
      expect(tasks.map((t) => t.status)).toEqual(['draft', 'draft']);
      expect(tasks.map((t) => t.created_by)).toEqual(['task-planning', 'task-planning']);
      // seq 引用必须翻成真 id:回 [1] 或 [] 都会让批 3 的调度器把这条依赖当不存在
      expect(JSON.parse(tasks[1].depends_on)).toEqual([tasks[0].id]);
    } finally {
      tp().setPlanTasks(null);
    }
  });

  it('三轮拆不出来 → 降级成 1 条同描述 task + planning_error,绝不卡在 planning(验收 20)', async () => {
    tp().setPlanTasks(async () => {
      const e = new Error('task-planning 连续 3 轮未产出合法任务 DAG');
      e.tokens = 999; // 失败的那几轮是真花了钱的
      throw e;
    });
    try {
      const [status, body] = await call('POST', '/goals', { description: '一个拆不动的需求' });
      expect(status).toBe(202);

      const g = await waitSplitDone(body.goalId);
      expect(g.status).toBe('draft');
      expect(g.planning_error).toMatch(/3 轮/);
      expect(g.planning_tokens).toBe(999); // 抛出路径上的 token 一样要记账

      const tasks = wdb.listGoalTasks(body.goalId);
      expect(tasks).toHaveLength(1);
      expect(tasks[0].description).toBe('一个拆不动的需求'); // 同描述
      expect(tasks[0].seq).toBe(1);
      expect(JSON.parse(tasks[0].depends_on)).toEqual([]);
      // 降级出来的这条不是它拆的,记成 task-planning 会让「拆分质量」统计从一开始就是脏的
      expect(tasks[0].created_by).toBe('human');

      // 界面要看得见:goalView 得把这个标记透出去,否则降级后的 goal 与「本来就一条」无从区分
      const [, view] = await call('GET', `/goals/${body.goalId}`);
      expect(view.planningError).toMatch(/3 轮/);
    } finally {
      tp().setPlanTasks(null);
    }
  });

  it('split:false 与显式 tasks 都不调 agent —— 存量调用方不该突然多花一次 LLM 调用', async () => {
    let called = 0;
    tp().setPlanTasks(async () => { called += 1; throw new Error('不该被调到'); });
    try {
      const [a] = await call('POST', '/goals', { description: '同步建的 goal', split: false });
      expect(a).toBe(201);
      const [b] = await call('POST', '/goals', {
        description: '自带拆分结果',
        tasks: [{ description: '第一件' }, { description: '第二件', depends_on: [1] }],
      });
      expect(b).toBe(201); // 传了 tasks = 拆分结果已经在请求体里,再调一次纯浪费
      expect(called).toBe(0);
    } finally {
      tp().setPlanTasks(null);
    }
  });

  it('写入前校验:rule 取值域与本体 agent.md 那张「被打回来怎么办」的表一致', () => {
    const { validatePlannedTasks, extractPlannedTasks } = tp();

    const ok = validatePlannedTasks([{ seq: 1, title: 'a', description: 'A', depends_on: [] }]);
    expect(ok.ok).toBe(true);
    expect(ok.specs[0]).toEqual({ seq: 1, title: 'a', description: 'A', depends_on: [] });

    const rules = (raw) => validatePlannedTasks(raw).errors.map((e) => e.rule);
    // seq 与数组顺序错位:错位后 depends_on 引的是另一条 task,而那条错误依赖完全合法,
    // 落库后没有任何人再能发现它 —— 所以必须在这里挡住
    expect(rules([{ seq: 2, description: 'A', depends_on: [] }])).toContain('schema');
    expect(rules([{ seq: 1, description: 'A', depends_on: ['T-abc'] }])).toContain('reference');
    expect(rules([{ seq: 1, description: 'A', depends_on: [1] }])).toContain('cycle');
    expect(rules([
      { seq: 1, description: 'A', depends_on: [2] },
      { seq: 2, description: 'B', depends_on: [1] },
    ])).toContain('cycle');
    expect(rules(Array.from({ length: 9 }, (_, i) => ({ seq: i + 1, description: 'x', depends_on: [] }))))
      .toContain('limit');
    expect(rules([{ seq: 1, description: 'A', depends_on: [], mode: 'auto' }])).toContain('schema');

    // 正向引用(第 1 条依赖第 2 条)不成环,是合法输入 —— 批 1 缺陷 4 就是被它咬的
    expect(validatePlannedTasks([
      { seq: 1, description: 'A', depends_on: [2] },
      { seq: 2, description: 'B', depends_on: [] },
    ]).ok).toBe(true);

    // 取**最后一个** JSON 块:正文里举的反例常常也是合法 JSON,从前往后会认错那一份
    const out = '反例:```json\n[{"seq":1,"description":"错的"}]\n```\n最终产出:\n```json\n[{"seq":1,"title":"t","description":"对的","depends_on":[]}]\n```';
    expect(extractPlannedTasks(out)[0].description).toBe('对的');
    expect(extractPlannedTasks('什么都没有')).toBeNull();
  });

  // ═══════════════════════════════════════
  //  批 2 · 默认实现本身(不走 setPlanTasks 注入)
  // ═══════════════════════════════════════
  //
  // 上面几条全用注入桩验「链路」,代价是**默认实现整段被跳过**:轮次上限、errors 回喂、
  // token 累加这三件事只活在 defaultPlanTasks 里,注入桩绿了它们照样可以是坏的。
  // 所以这几条只把最底下那次 `AgentManager.callAgent` 换掉,中间的循环真跑。

  /**
   * 换掉 `AgentManager.callAgent`(绝不真起 Claude CLI),顺手挡住 `CompanyService.hire`:
   * `resolvePlanningAgentName` 查不到雇佣记录就会按需雇一次,而 hire 会往用户仓库
   * `data/companies/` 里落目录 —— 单测不该在别人的工作区里留东西。
   *
   * @param {(string|Error)[]} outputs 逐轮的假回复;轮数超出时一直用最后一条
   * @returns {{prompts: string[], metas: object[], restore: () => void}}
   */
  function stubAgent(outputs, usage = { input: 100, output: 20 }) {
    const AgentManager = require('../../src/core/agent-manager');
    const { CompanyService } = require('../../services/company-service');
    const origCall = AgentManager.prototype.callAgent;
    const origHire = CompanyService.prototype.hire;
    const prompts = [];
    const metas = [];
    AgentManager.prototype.callAgent = async function stubCallAgent(agentName, prompt, options) {
      prompts.push(prompt);
      metas.push({ agentName, metadata: (options && options.metadata) || null });
      // usage 只有这一条来源(`session/result`),planning_tokens 的账全靠它
      if (options && options.onAcpEvent) options.onAcpEvent({ method: 'session/result', params: { usage } });
      const out = outputs[Math.min(prompts.length - 1, outputs.length - 1)];
      if (out instanceof Error) throw out;
      return { output: out };
    };
    CompanyService.prototype.hire = () => ({ status: 'stubbed-by-test' });
    return {
      prompts,
      metas,
      restore() {
        AgentManager.prototype.callAgent = origCall;
        CompanyService.prototype.hire = origHire;
      },
    };
  }

  it('默认实现:连续 3 轮非法产出 → 轮次用满、errors 原样回喂、已烧 token 挂在 error 上(验收 20 / 21)', async () => {
    const stub = stubAgent(['我觉得可以分三步做,但我不打算写 JSON']);
    try {
      const goal = wdb.createGoal({ companyId: CID, title: '拆不动', description: '一个拆不动的需求' });

      let err = null;
      try {
        await tp().planTasks({ companyId: CID, goal });
      } catch (e) {
        err = e;
      }

      expect(err).toBeTruthy();
      expect(err.message).toMatch(/连续 3 轮/);
      expect(stub.prompts).toHaveLength(3); // 不是 1 轮就放弃,也不是无限重试到把钱烧光
      expect(err.planErrors.map((x) => x.rule)).toEqual(['output']);
      // 抛出路径上的 token 一样是真花掉的。不挂在 error 上,这半边花费在账上直接蒸发
      expect(err.tokens).toBe(3 * 120);

      // 第 1 轮不带回喂段;第 2 轮起必须把上一轮的 errors 原样贴回去 —— 不贴的话
      // agent 不知道错在哪,三轮就是同一个错误重复三次,等于只试了一轮还花了三份钱
      expect(stub.prompts[0]).not.toContain('上一轮没通过');
      expect(stub.prompts[1]).toContain('上一轮没通过');
      expect(stub.prompts[1]).toContain('"rule": "output"'); // rule 名要能对上 agent.md 那张表
      expect(stub.prompts[2]).toContain('第 3 轮');

      // agent.md Step 1 点名的三项输入,一项都不能少(缺哪项它就自己编哪项)
      for (const h of ['① goal 描述', '② 公司 org graph 摘要', '③ 可用角色清单']) {
        expect(stub.prompts[0]).toContain(h);
      }
      // 写入边界段头是 PromptBuilder 判分叉的**精确字面量**:出现在任务描述里会让坐标声明
      // 不再给交付物归宿,而本体正文里的 $W 正是从那一行读的 —— 两边同时落空且不报错
      expect(stub.prompts[0]).not.toContain('[写入边界');
      // taskId 是运行态目录的第三层键。不传的话所有公司所有拆分叠进 _global/_no-task 一个坑
      expect(stub.metas[0].metadata.taskId).toBe(goal.id);
      expect(stub.metas[0].agentName).toBe('task-planning'); // 没雇过就用裸名,不该拼出脏地址
    } finally {
      stub.restore();
    }
  });

  it('默认实现拆不出来时,POST /goals 端到端照样把 goal 推离 planning 并记账(验收 20)', async () => {
    const stub = stubAgent(['没有 JSON,只有废话']);
    try {
      const [status, body] = await call('POST', '/goals', { description: '端到端拆不动的需求' });
      expect(status).toBe(202);

      const g = await waitSplitDone(body.goalId);
      expect(g.status).toBe('draft'); // 绝不卡在 planning —— 那是个没人会把它推走的死态
      expect(g.planning_error).toMatch(/连续 3 轮/);
      expect(g.planning_tokens).toBe(3 * 120);

      const tasks = wdb.listGoalTasks(body.goalId);
      expect(tasks).toHaveLength(1);
      expect(tasks[0].description).toBe('端到端拆不动的需求');
      expect(tasks[0].created_by).toBe('human'); // 这条不是它拆的
    } finally {
      stub.restore();
    }
  });

  it('默认实现:第 1 轮 seq 错位被打回、第 2 轮改对 → 只调 2 次,被打回那轮的 token 照样记账', async () => {
    // 这条守的是「回喂到底有没有用」:只验失败路径的话,一个永远返回同一份 errors 的实现也是绿的。
    const bad = '```json\n[{"seq":2,"title":"a","description":"A","depends_on":[]}]\n```';
    const good = '最终产出:\n```json\n[{"seq":1,"title":"a","description":"A","depends_on":[]},'
      + '{"seq":2,"title":"b","description":"B","depends_on":[1]}]\n```';
    const stub = stubAgent([bad, good]);
    try {
      const [status, body] = await call('POST', '/goals', { description: '第二轮才交对的需求' });
      expect(status).toBe(202);

      const g = await waitSplitDone(body.goalId);
      expect(stub.prompts).toHaveLength(2); // 过了就停,不会把三轮跑满
      expect(stub.prompts[1]).toContain('"rule": "schema"'); // seq 与数组下标错位
      expect(g.planning_error).toBeNull();
      expect(g.planning_tokens).toBe(2 * 120); // 被打回的那一轮一样花了钱

      const tasks = wdb.listGoalTasks(body.goalId);
      expect(tasks.map((t) => t.created_by)).toEqual(['task-planning', 'task-planning']);
      expect(JSON.parse(tasks[1].depends_on)).toEqual([tasks[0].id]); // seq 引用翻成了真 id
    } finally {
      stub.restore();
    }
  });


  // ═══════════════════════════════════════
  //  批 2 · 对抗复核缺陷 1 / 4 / 5 / 6:拆分那几十秒里 goal 被别人动了怎么办
  // ═══════════════════════════════════════
  //
  // 这四条的共同现场是同一个:`planning` 是个**真会持续几十秒**的状态,而这期间 goal
  // 完全可能被别的请求推走(表单自己推荐的逃生口、左栏的归档)。此前拆分收工是一条
  // **无条件** UPDATE,谁最后写谁说了算 —— 而最后写的恰好是那个已经过时了几十秒的后台任务。

  /** 一个能由用例决定何时放行的假拆分:拿到 gate 才返回,好在拆分「进行中」时做手脚 */
  function gatedPlan(result) {
    let release;
    const gate = new Promise((r) => { release = r; });
    tp().setPlanTasks(async () => {
      await gate;
      if (result instanceof Error) throw result;
      return result;
    });
    return release;
  }

  /** 轮询到 planning_tokens 记上为止(放弃落库那条路不改 status,waitSplitDone 等不到) */
  async function waitTokens(goalId, tokens, tries = 100) {
    for (let i = 0; i < tries; i += 1) {
      const g = wdb.getGoal(goalId);
      if (g && g.planning_tokens === tokens) return g;
      await new Promise((r) => setTimeout(r, 10));
    }
    throw new Error(`goal ${goalId} 的 planning_tokens 一直没记上(当前 ${wdb.getGoal(goalId).planning_tokens})`);
  }

  it('拆分期间走了逃生口 → 收工时放弃落库,不把正在跑的 goal 写回草稿(缺陷 1)', async () => {
    const release = gatedPlan({
      specs: [
        { seq: 1, title: '孤儿一', description: '不该被插进去的一', depends_on: [] },
        { seq: 2, title: '孤儿二', description: '不该被插进去的二', depends_on: [1] },
      ],
      tokens: 777,
    });
    try {
      const [status, body] = await call('POST', '/goals', { description: '一个会被抢走的需求' });
      expect(status).toBe(202);
      const gid = body.goalId;

      // 逃生口 `POST /:cid/tasks {goal_id}` 的两步(这里不真起 run:那要真 org graph,
      // 见文件头。抢走 goal 的是 markGoalDispatched 这一句,run 起没起不改变判据)
      const escaped = wdb.createTask({ goalId: gid, title: '单任务', description: '一个会被抢走的需求' });
      wdb.updateGoal(gid, { status: 'active', dispatchedAt: new Date().toISOString() });

      release();
      const g = await waitTokens(gid, 777);
      // 写回 draft = 一个正在烧钱跑 run 的 goal 在界面上变成「草稿」,
      // 而多插的那 N 条 draft 让 settleGoalIfAllTasksDone 永远看不到「全部终态」→ 永不收敛
      expect(g.status).toBe('active');
      expect(g.dispatched_at).toBeTruthy();
      expect(g.planning_error).toBeNull(); // 拆分没失败,别在界面上说它失败了
      expect(wdb.listGoalTasks(gid).map((t) => t.id)).toEqual([escaped.id]); // 一条孤儿都没留
    } finally {
      tp().setPlanTasks(null);
    }
  });

  it('降级用的是收工那一刻的描述,不是拆分开始时的旧快照(本轮复核缺陷 1)', async () => {
    const err = new Error('task-planning 连续 3 轮未产出合法任务 DAG');
    err.tokens = 21;
    const release = gatedPlan(err);
    try {
      const [status, body] = await call('POST', '/goals', { description: '旧的需求原文' });
      expect(status).toBe(202);
      const gid = body.goalId;

      // 拆分期间改 description 是**被允许的**:goal 还在 planning、dispatched_at 还空,
      // 冻结规则(§四.4)此刻还没生效,所以这是一条正常用户路径,不是竞态滥用
      const [pt, patched] = await call('PATCH', `/goals/${gid}`, { description: '改过的需求原文' });
      expect(pt).toBe(200);
      expect(patched.description).toBe('改过的需求原文');

      release();
      const g = await waitSplitDone(gid);
      expect(g.description).toBe('改过的需求原文');
      // 用函数顶部那份旧快照的话:goal 上写着新需求、它唯一的那条 task 写着旧需求,
      // 而两段文字都是验收边界(org-validator 判的就是这段描述)—— 一份目标两个口径
      expect(wdb.listGoalTasks(gid).map((t) => t.description)).toEqual(['改过的需求原文']);
    } finally {
      tp().setPlanTasks(null);
    }
  });

  it('拆分产出的 seq 以 goal 现有最大号为基准偏移,依赖引用跟着偏移(缺陷 4)', async () => {
    const release = gatedPlan({
      specs: [
        { seq: 1, title: '甲', description: '先做甲', depends_on: [] },
        { seq: 2, title: '乙', description: '基于甲做乙', depends_on: [1] },
      ],
      tokens: 5,
    });
    try {
      const [, body] = await call('POST', '/goals', { description: '会撞号的需求' });
      const gid = body.goalId;

      // 逃生口撞上唯一索引那条路:task 建出来当草稿,但 409 在 markGoalDispatched 之前就返回了 ——
      // goal **仍停在 planning**,所以拆分收工时条件更新会命中,产出照插。seq 就是在这里撞的。
      const first = wdb.createTask({ goalId: gid, title: '先手', description: '先手任务' });
      expect(first.seq).toBe(1);

      release();
      const g = await waitSplitDone(gid);
      expect(g.status).toBe('draft');

      const tasks = wdb.listGoalTasks(gid);
      // 1,1,2 的话:列表里两行都显示 #1,依赖 chip 的「依赖 #1」有两个同名候选,
      // dispatch 挑「第一条跑得动的 draft」顺序不定 —— 不报错,只是从此对不上号
      expect(tasks.map((t) => t.seq)).toEqual([1, 2, 3]);
      const jia = tasks.find((t) => t.seq === 2);
      const yi = tasks.find((t) => t.seq === 3);
      expect(yi.title).toBe('乙');
      // 翻译表用的必须是偏移后的号:用原号会翻不出来,depends_on 里留一个字面量 "1"
      expect(JSON.parse(yi.depends_on)).toEqual([jia.id]);
      expect(JSON.parse(tasks[0].depends_on)).toEqual([]);
    } finally {
      tp().setPlanTasks(null);
    }
  });

  it('拆分期间不许归档:DELETE 直接 409,硬归档时收工也不插孤儿(缺陷 5)', async () => {
    const release = gatedPlan({
      specs: [{ seq: 1, title: '孤儿', description: '归档后不该被插进去', depends_on: [] }],
      tokens: 42,
    });
    try {
      const [, body] = await call('POST', '/goals', { description: '会被归档的需求' });
      const gid = body.goalId;

      // 此刻 task 列表是空的,「有 task 攥着 run 就 409」那道闸门必过 —— 所以要单独挡
      const [st, err] = await call('DELETE', `/goals/${gid}`);
      expect(st).toBe(409);
      expect(err.error).toMatch(/正在自动拆分/);
      expect(err.hint).toMatch(/逃生口/);

      // 端点挡住了,但库里那一行仍可能被别的写入方盖上 archived_at(归档不改 status)。
      // 条件更新的 `archived_at IS NULL` 是第二道:插进去的 task 会归属一个左栏看不见的 goal。
      wdb.updateGoal(gid, { archivedAt: new Date().toISOString() });
      release();
      await waitTokens(gid, 42); // 账照记:那几轮 LLM 是真花了钱的
      expect(wdb.listGoalTasks(gid, { includeArchived: true })).toEqual([]);
    } finally {
      tp().setPlanTasks(null);
    }
  });

  it('拆分超过总时限 → 按降级路径收工,绝不卡在 planning(缺陷 6 / 验收 20)', async () => {
    const orig = process.env.GOAL_SPLIT_TIMEOUT_MS;
    process.env.GOAL_SPLIT_TIMEOUT_MS = '30';
    // 永不 resolve = 真实世界里那条「callAgent 的 idle timeout 30 分钟 × 3 轮」的路:
    // 只要还有流数据进来,idle timer 永远不触发,服务端此前对此没有任何时限
    tp().setPlanTasks(() => new Promise(() => {}));
    try {
      const [status, body] = await call('POST', '/goals', { description: '一个拆到天荒地老的需求' });
      expect(status).toBe(202);

      const g = await waitSplitDone(body.goalId);
      expect(g.status).toBe('draft');
      expect(g.planning_error).toMatch(/总时限/);

      const tasks = wdb.listGoalTasks(body.goalId);
      expect(tasks.map((t) => t.description)).toEqual(['一个拆到天荒地老的需求']);
      // 降级出来的这条不是 task-planning 拆的,记成它的产出会让「拆分质量」统计从一开始就是脏的
      expect(tasks[0].created_by).toBe('human');
    } finally {
      if (orig === undefined) delete process.env.GOAL_SPLIT_TIMEOUT_MS;
      else process.env.GOAL_SPLIT_TIMEOUT_MS = orig;
      tp().setPlanTasks(null);
    }
  });

  it('task-planning 本体过得了毕业闸门,不会停在 draft(§七 / 验收 24)', () => {
    const { missingBodyDefinition } = require('../../services/agent-locator');
    const { isUngraduatedStatus, isNonOrgAgentType } = require('../../services/graph-service');
    const bodyRoot = path.resolve(__dirname, '../../../system/agents');
    const cfg = JSON.parse(fs.readFileSync(path.join(bodyRoot, 'task-planning/config.json'), 'utf-8'));

    // 「缺」有三种形态:读不到 / 空白 / 仍带脚手架哨兵。少一份 soul.md 本体就永远停在 draft,
    // 而 hire() 报的理由是 body_not_ready —— 症状离原因很远,只能靠这里挡
    expect(missingBodyDefinition('task-planning')).toEqual([]);
    // status 不是封闭取值域,判据的唯一持有者是 isUngraduatedStatus
    //(自己写 `!== 'active'` 会把库里 36 个合法本体全判成没毕业,且报的理由还是错的)
    expect(isUngraduatedStatus(cfg.status)).toBe(false);
    // meta-planning 命中 NON_ORG_AGENT_TYPES = 永不作为 org 节点混进用户的组织图
    expect(isNonOrgAgentType(cfg.type)).toBe(true);
    // seedAgents 扫的是目录(不以 _/. 开头 + 有 agent.md),registry 只做 enrich;
    // 但 §七 要求登记,缺了它 benchmark_score 一列永远是 null 且没人知道为什么
    const registry = JSON.parse(fs.readFileSync(path.join(bodyRoot, '_registry.json'), 'utf-8'));
    expect(registry.some((e) => e && e.id === 'task-planning')).toBe(true);
  });

  // ──────────────────────────────────────────────────────────────
  // 批 3 对抗复核 F1:blocked 的任务必须有走得通的出路
  //
  // 这四条盯的是同一件事的四个面:改完依赖有没有**当场**重算、放行是不是真的放得掉、
  // 走投无路时还剩什么、以及 409 里那句 hint 指的地方存不存在。
  // 它们全部是「接口回 200 / 回了 hint,但什么都没发生 / 那条路根本不通」的静默形态。
  // ──────────────────────────────────────────────────────────────
  describe('强制放行与保底出路(复核 F1)', () => {
    /** 一条已经 failed 的上游 + 一条排队中的下游;下游的依赖留给各条用例自己接 */
    function pair(tag) {
      const dead = wdb.createTask({ goalId: goal.id, title: `死上游-${tag}`, description: '已经失败的上游' });
      wdb.updateTask(dead.id, { status: 'failed' });
      const down = wdb.createTask({ goalId: goal.id, title: `下游-${tag}`, description: '等上游' });
      wdb.updateTask(down.id, { status: 'queued', queuedAt: new Date().toISOString() });
      return { dead, down };
    }

    it('PATCH depends_on 当场重算队列:新依赖已经死了 → 响应里就是 blocked,不是过期的 queued', async () => {
      const { dead, down } = pair('a');

      const [status, body] = await call('PATCH', `/tasks/${down.id}`, { depends_on: [dead.id] });
      expect(status).toBe(200);
      // 少了 PATCH 里那次 tick,这里回的是 'queued':库里要等 60s 兜底扫描才变 blocked,
      // 而界面拿的就是这个响应 —— 用户看到的是「改了没反应」
      expect(body.status).toBe('blocked');
      expect(body.blockedReason).toBe(`${wdb.UPSTREAM_BLOCK_PREFIX}${dead.id}`);
    });

    it('强制放行:删掉那条死依赖 → 200,且当场回到队列(reason 一起清掉)', async () => {
      const { dead, down } = pair('b');
      await call('PATCH', `/tasks/${down.id}`, { depends_on: [dead.id] }); // 先卡住

      const [status, body] = await call('PATCH', `/tasks/${down.id}`, { depends_on: [] });
      expect(status).toBe(200);
      // 这一条就是前端「强制放行」按钮打的那一枪。它此前必然 409(数据层闸门只认 draft/queued),
      // 按钮却一定是亮的 —— 「不给能点却必然 4xx 的入口」当时只是一句注释
      expect(body.status).toBe('queued');
      // blocked_reason 由 requeueDerivedBlockedTask 和 status 在**同一条**条件更新里清掉。
      // 谁抢在 tick 前面单独把它清成 NULL,那条 UPDATE 就再也命中不了,任务永远卡在 blocked
      expect(body.blockedReason).toBeFalsy();
      expect(wdb.getTask(down.id).blocked_reason).toBeFalsy();
    });

    it('blocked 的 409 hint 不许指向不存在的行为(旧版是 POST /api/graph-runs/null/stop)', async () => {
      const { dead, down } = pair('c');
      await call('PATCH', `/tasks/${down.id}`, { depends_on: [dead.id] });

      const [status, body] = await call('POST', `/tasks/${down.id}/rerun`);
      expect(status).toBe(409); // 重跑仍要挡:taskHoldsRun 把 blocked 一律算成攥着 run
      expect(body.hint).not.toMatch(/graph-runs\/(null|undefined)\//);
      // 给得出的下一步必须真的能走:这条 task 根本没有 run,能动它的只有 cancel
      expect(body.hint).toContain(`/tasks/${down.id}/cancel`);
    });

    it('保底出路:上游已归档/删号时,取消一定走得通(不再是两条 409 互相指着对方)', async () => {
      const { dead, down } = pair('d');
      await call('PATCH', `/tasks/${down.id}`, { depends_on: [dead.id] });
      expect(wdb.getTask(down.id).status).toBe('blocked');

      const [status, body] = await call('POST', `/tasks/${down.id}/cancel`);
      expect(status).toBe(200);
      expect(body.status).toBe('cancelled');
      // 已经取消了还挂着「上游未完成」,列表上会一直显示一个假原因
      expect(wdb.getTask(down.id).blocked_reason).toBeFalsy();
    });
  });
});