goal-task-acceptance.test.js 44.2 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
/**
 * 27-P3C 批 1 的验收清单里,能在单测里真跑的那几条(§八 验收 4 / 5 / 6 / 10 / 11 / 12b)。
 *
 * 与 `company-goal-task-routes.test.js` 的分工:那份验的是**不起 run** 的一半(列表 / 详情 /
 * 改 / 归档);这份专门走**起 run 那条路** —— 存量入口 `POST /:cid/tasks` 的兼容性、
 * 起 run 之后才生效的 task 级描述冻结、以及写侧读侧是否落在同一个 run 目录上。
 *
 * ⚠️ 先设 env 再 require(§八 验收 12b 的警示块):`graph-engine.js` / `graph-run-sse.js`
 * 都在**模块加载时**快照 `GRAPH_RUNS_DIR`,先 require 再设 env 会让它们拿着 null 往仓库
 * `data/companies/` 里写 —— 而且不报错。
 *
 * 公司实体用 `CompanyService.prototype.get` 打桩:`company-service` 的 DATA_DIR 是硬编码的
 * 仓库 `data/companies/`,不认任何 env,真建一家公司就等于往用户仓库里落目录。
 * agent 一律不真调:org node 是 subgraph 节点,规划走 `setPlanWorkGraph` 注入的假规划。
 */

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

const TEST_DB_PATH = '/tmp/test-goal-task-acceptance.sqlite';
const DB_FILES = [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`];
// 这几个模块在加载时快照 GRAPH_DB_PATH / GRAPH_RUNS_DIR,必须先设 env 再 require
const MODULES = [
  '../../services/graph-db',
  '../../services/org-graph-service',
  '../../services/work-graph-db',
  '../../services/graph-run-sse',
  '../../services/graph-engine',
  '../../services/goal-contract',
  '../../routes/company',
];

const ORG_NODES = ['org-planner', 'org-coder', 'org-validator'];
const ORG_EDGES = [
  { from: 'org-planner', to: 'org-coder', condition: 'success' },
  { from: 'org-coder', to: 'org-validator', condition: 'success' },
];

let wdb;
let engine;
let orgSvc;
let goalContract;
let runsDir;
let server;
let origCompanyGet;

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

let seq = 0;
const companies = [];
/** 每个用例一家独立公司:active root run 的唯一索引是按 company 约束的 */
function newCompany() {
  const cid = `acc-co-${++seq}`;
  companies.push(cid);
  expect(orgSvc.writeGraph(cid, { nodes: ORG_NODES, edges: ORG_EDGES }).ok).toBe(true);
  return cid;
}

/**
 * `OkrService` 的 DATA_DIR 是硬编码的仓库 `data/`,不认 `GRAPH_RUNS_DIR` 也不认任何 env,
 * 而它的构造函数就 mkdir —— 打一次 `GET /:cid/okr` 就在用户仓库里留一个
 * `data/companies/{cid}/okr/`。测试自己收拾干净,别把残留留给下一个人。
 * 只删本文件造出来的前缀目录,且必须确认里面没有 `company.json`(真公司一定有这个文件)。
 */
function cleanupLeakedCompanyDirs() {
  const root = path.resolve(__dirname, '../../../data/companies');
  for (const cid of companies) {
    const dir = path.join(root, cid);
    if (!fs.existsSync(dir) || fs.existsSync(path.join(dir, 'company.json'))) continue;
    try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* 删不掉就留着,不让清理反过来炸测试 */ }
  }
}

describe('27-P3C 批 1 验收(§八 4 / 5 / 6 / 10 / 11 / 12b)', () => {
  beforeAll(async () => {
    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-acceptance-runs-'));
    process.env.GRAPH_RUNS_DIR = runsDir;
    for (const m of MODULES) delete require.cache[require.resolve(m)];

    wdb = require('../../services/work-graph-db');
    orgSvc = require('../../services/org-graph-service');
    engine = require('../../services/graph-engine');
    goalContract = require('../../services/goal-contract');

    // 公司实体:只有 POST /:cid/tasks 的存在性检查在读它
    const { CompanyService } = require('../../services/company-service');
    origCompanyGet = CompanyService.prototype.get;
    CompanyService.prototype.get = function (companyId) {
      return { id: companyId, name: companyId, status: 'running' };
    };

    const express = require('express');
    const app = express();
    app.use(express.json());
    app.use('/api/company', require('../../routes/company'));
    // 验收 11:OKR 只剩这一个挂载点,/api/goals 那条别名已退役
    app.use('/api/okr', require('../../routes/okr'));

    await new Promise((resolve) => { server = app.listen(0, resolve); });
  });

  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(runsDir, { recursive: true, force: true }); } catch { /* ignore */ }
    cleanupLeakedCompanyDirs();
    delete process.env.GRAPH_DB_PATH;
    delete process.env.GRAPH_RUNS_DIR;
    for (const m of MODULES) delete require.cache[require.resolve(m)];
  });

  // ──────────────────────────────────────────────────────────────
  // 验收 4 · 存量调用方一行不用改
  // ──────────────────────────────────────────────────────────────

  it('POST /:cid/tasks 不带 goal_id 仍 201,且自动建出一个 goal(验收 4)', async () => {
    const cid = newCompany();
    const [status, body] = await call('POST', `/api/company/${cid}/tasks`, { task: '做个官网' });

    expect(status).toBe(201);
    // 存量响应字段一个都不能少 —— DispatchTaskButton 与外部 agent 的 curl 直接读它们
    expect(body).toMatchObject({ success: true, companyId: cid, task: '做个官网', executionMode: 'manual' });
    expect(body.runId).toMatch(/^run_/);
    expect(body.links.next).toBe(`/api/graph-runs/${body.runId}/next`);

    // 新增的那一个字段:goal 真的建出来了,描述与 task 同源(它就是验收边界)
    expect(body.goalId).toMatch(/^G-[0-9a-z]+-[0-9a-f]{4}$/);
    const goal = wdb.getGoal(body.goalId);
    expect(goal.description).toBe('做个官网');
    expect(goal.company_id).toBe(cid);
    // 下达即冻结:dispatched_at 就是 goal 级描述冻结点(§四.4)
    expect(goal.dispatched_at).toBeTruthy();
    expect(goal.status).toBe('active');

    const tasks = wdb.listGoalTasks(goal.id);
    expect(tasks.map((t) => t.id)).toEqual([body.taskId]);
    expect(tasks[0].current_run_id).toBe(body.runId);
    // task id 带 4 位后缀,与 /task/dispatch 那套同毫秒会撞号的 `T-{base36}` 肉眼可分(§九.5)
    expect(body.taskId).toMatch(/^T-[0-9a-z]+-[0-9a-f]{4}$/);
  });

  // ──────────────────────────────────────────────────────────────
  // 验收 12b · 写侧与读侧落在同一个 run 目录(路径缺陷 A)
  // ──────────────────────────────────────────────────────────────

  it('GRAPH_RUNS_DIR 覆盖下,task-goal.json 的写侧与读侧是同一个路径(验收 12b)', async () => {
    const cid = newCompany();
    const description = '把首页的加载时间压到 1 秒以内';
    const [status, body] = await call('POST', `/api/company/${cid}/tasks`, { task: description });
    expect(status).toBe(201);

    // 写侧:落在临时目录里,仓库 data/companies/ 一个字节都不该多
    const written = path.join(runsDir, body.runId, 'artifacts', 'task-goal.json');
    expect(fs.existsSync(written)).toBe(true);
    expect(JSON.parse(fs.readFileSync(written, 'utf-8')).task).toBe(description);
    expect(fs.existsSync(path.join(__dirname, '../../../data/companies', cid, 'runs', body.runId))).toBe(false);

    // 读侧之一:goal-contract 的 resolveRunDir(验收 12c 修的就是它这一半)
    expect(path.join(goalContract.resolveRunDir(cid, body.runId), 'artifacts', 'task-goal.json')).toBe(written);

    // 读侧之二 —— 真正会咬人的那个:引擎拼提示词时读得回真实描述。
    // describeTask 没有导出,所以从 subgraph 节点的规划入口把 ctx.task 抓出来:
    // 读不到文件时它会**静默**退化成兜底文案「执行图节点任务」,agent 不知道要干什么却照样烧预算。
    let plannedTask = null;
    engine.setPlanWorkGraph(async (ctx) => {
      plannedTask = ctx.task;
      const db = wdb.getDb();
      const now = new Date().toISOString();
      db.prepare(`
        INSERT OR IGNORE INTO system_agents
          (id, name, scope, owner_company_id, source_kind, legacy_path, status, created_at, updated_at)
        VALUES ('acc-worker', 'acc-worker', 'public', NULL, 'global_agent', 'system/agents/acc-worker', 'active', ?, ?)
      `).run(now, now);
      const g = wdb.createWorkGraph({ companyId: cid, nodes: ['acc-worker'], edges: [] });
      expect(g.errors || []).toEqual([]);
      return { workGraphId: g.id };
    });
    try {
      await engine.nextStep(body.runId);
    } finally {
      engine.setPlanWorkGraph(null);
    }

    expect(plannedTask).toBeTruthy();
    expect(plannedTask.startsWith(description)).toBe(true);
    expect(plannedTask).not.toContain('执行图节点任务');
  });

  // ──────────────────────────────────────────────────────────────
  // 缺陷 F1 / F2 · run 收敛之后到底发生了什么
  // ──────────────────────────────────────────────────────────────

  it('root run 转终态 → task 回写 + goal 收敛 + OKR 恰好写一次(缺陷 F1 / F2)', async () => {
    const cid = newCompany();
    // OkrService 的 DATA_DIR 是硬编码的仓库 data/,真写会往用户仓库里落 objective 文件;
    // 这里只需要证明「写了、且只写了一次」,打桩 updateNode 即可(构造函数那次 mkdir 由
    // cleanupLeakedCompanyDirs 收拾)。
    const { OkrService } = require('../../services/okr-service');
    const origUpdateNode = OkrService.prototype.updateNode;
    const okrCalls = [];
    OkrService.prototype.updateNode = function (objectiveId, nodeId, updates) {
      okrCalls.push({ companyId: this.companyId, objectiveId, nodeId, status: updates.status });
      return { id: nodeId };
    };

    try {
      const [status, body] = await call('POST', `/api/company/${cid}/tasks`, {
        task: '把注册转化率提到 20%',
        okr_node_id: 'O-001.1',
      });
      expect(status).toBe(201);
      expect(wdb.getTask(body.taskId).status).toBe('running');
      // §四.7 禁令一:度量目标只挂在 goal 上,run 上一律不带 —— 否则 N 个 task 反复覆写同一节点
      expect(wdb.getRun(body.runId).okr_node_id).toBeFalsy();
      expect(wdb.getGoal(body.goalId).okr_node_id).toBe('O-001.1');

      // 直接推唯一漏斗。stopRun / 预算耗尽 → blocked 都不走 settleRun,
      // 钩子挂在 transitionRun 上才覆盖得全(§五.1.1)。
      const sse = require('../../services/graph-run-sse');
      expect(sse.transitionRun(body.runId, 'running', 'completed', {}, { summary: '用例直推终态' })).toBeTruthy();

      const task = wdb.getTask(body.taskId);
      expect(task.status).toBe('completed');   // 修之前这里永远是 running
      expect(task.finished_at).toBeTruthy();
      expect(wdb.getGoal(body.goalId).status).toBe('done');
      expect(okrCalls).toEqual([
        { companyId: cid, objectiveId: 'O-001', nodeId: 'O-001.1', status: 'done' },
      ]);

      // 幂等:再收敛一次不产生第二次 OKR 写(并行完成的多个 task 会同时走到这里)
      const taskSettle = require('../../services/task-settle');
      expect(taskSettle.settleRootRun(wdb.getRun(body.runId), 'completed')).toBeNull();
      expect(okrCalls).toHaveLength(1);

      // F1 的直接后果:归档不再永久 409(修之前 task 卡在 running,而 hint 给的 stop
      // 对一条 completed 的 run 会抛 InvalidTransitionError —— 自救路径本身是死路)
      const [del, archived] = await call('DELETE', `/api/company/${cid}/goals/${body.goalId}`);
      expect(del).toBe(200);
      // 只看 200 不够:归档要真落到行上,否则下一次列表照样把它画出来(「历史任务看不见」的反面)
      expect(archived.goal.archivedAt).toBeTruthy();
      expect(wdb.getGoal(body.goalId).archived_at).toBeTruthy();
      expect(wdb.getTask(body.taskId).archived_at).toBeTruthy();
    } finally {
      OkrService.prototype.updateNode = origUpdateNode;
    }
  });

  it('人工停止 → task 记 cancelled、goal 判 failed(stopped 不是「执行失败」,但也不是达成)', async () => {
    const cid = newCompany();
    const [status, body] = await call('POST', `/api/company/${cid}/tasks`, { task: '一个会被停掉的任务' });
    expect(status).toBe(201);

    const sse = require('../../services/graph-run-sse');
    sse.transitionRun(body.runId, 'running', 'stopped', {}, { summary: '用例模拟人工停止' });

    const task = wdb.getTask(body.taskId);
    expect(task.status).toBe('cancelled');
    // finished_at 是「这条 task 什么时候不再动了」的唯一时间戳:只改 status 不写它,
    // 列表上这条任务会显示成「已取消,但还没有结束时间」,耗时统计里也永远缺一条
    expect(task.finished_at).toBeTruthy();
    expect(wdb.getGoal(body.goalId).status).toBe('failed');
  });

  it('预算耗尽 → blocked → 人加预算恢复 → 跑完:task=completed / goal=done / OKR 恰好写一次(第二轮复核 缺陷 1 / 2)', async () => {
    // stopped 与 blocked 是 §五.1.1 点名的两条最常漏的路径(`stopRun` 的注释自己写着
    // 「不经过 settleRun」,预算耗尽走的是 suspendRunTree → transitionRun(blocked))。
    //
    // ⚠️ 这条用例**必须走完整条恢复链**。第一轮只验到「转 blocked 的那一瞬间」,于是
    // 「blocked 被当成终态」这个错误口径全绿通过,实跑出来的却是:加完预算 run 跑到 completed,
    // 而 `settleTaskFromRun` 的终态闸门命中 0 行、`settleGoalStatus` 因 goal 已 failed 也命中 0 行,
    // 两次静默 no-op → run=completed / task=blocked / goal=failed **永久不一致**。
    // 断言序列就是那条链本身:挂起 → 恢复 → 收敛。
    const { OkrService } = require('../../services/okr-service');
    const origUpdateNode = OkrService.prototype.updateNode;
    const okrCalls = [];
    OkrService.prototype.updateNode = function (objectiveId, nodeId, updates) {
      okrCalls.push({ nodeId, status: updates.status });
      return { id: nodeId };
    };

    try {
      const cid = newCompany();
      const [status, body] = await call('POST', `/api/company/${cid}/tasks`, {
        task: '一个会烧完预算的任务',
        okr_node_id: 'O-002.1',
      });
      expect(status).toBe(201);

      const sse = require('../../services/graph-run-sse');

      // ① 预算耗尽:run → blocked
      expect(sse.transitionRun(body.runId, 'running', 'blocked', {}, { summary: '用例模拟预算耗尽' })).toBeTruthy();
      // task 行要看得见「卡住了」—— 这是界面上唯一的信号
      expect(wdb.getTask(body.taskId).status).toBe('blocked');
      // 但 goal 不许收敛:挂起等人加预算 ≠ 需求失败。判 failed 会写 OKR、唤醒上级、
      // 并把 goal 锁进终态,人加完预算跑完也再改不回 done(缺陷 2:误报 + 白烧一轮 LLM)
      expect(wdb.getGoal(body.goalId).status).toBe('active');
      expect(okrCalls).toEqual([]);

      // ② 人加完预算恢复:blocked → running 是 RUN_TRANSITIONS 里的合法转换
      //    (`POST /api/graph-runs/:id/start` 与 `reviveParentFromChild` 走的都是这条)
      expect(sse.transitionRun(body.runId, 'blocked', 'running', {}, { summary: '用例模拟加预算恢复' })).toBeTruthy();
      expect(wdb.getRun(body.runId).run_status).toBe('running');
      // 恢复期间 task 行仍停在 blocked:RUN_TO_TASK_STATUS 只在收敛时回写,没有 running 这一档。
      // 这半截不一致正是下面那条归档守卫要挡的东西
      expect(wdb.getTask(body.taskId).status).toBe('blocked');

      // ③ 真跑完
      expect(sse.transitionRun(body.runId, 'running', 'completed', {}, { summary: '用例直推终态' })).toBeTruthy();

      const task = wdb.getTask(body.taskId);
      expect(task.status).toBe('completed');   // 修之前这里永远钉死在 blocked
      expect(task.finished_at).toBeTruthy();
      expect(wdb.getGoal(body.goalId).status).toBe('done');
      expect(okrCalls).toEqual([{ nodeId: 'O-002.1', status: 'done' }]);

      // 收敛完了才归档得掉
      const [del] = await call('DELETE', `/api/company/${cid}/goals/${body.goalId}`);
      expect(del).toBe(200);
    } finally {
      OkrService.prototype.updateNode = origUpdateNode;
    }
  });

  it('blocked 期间不许归档:run 随时会被恢复,藏起来就等于让它在界面外继续烧钱', async () => {
    // 守卫改宽之后(task 不再因 blocked 进终态)必须同步补上这一条:
    // 只看 `task.status === 'running'` 的旧判据,会把「挂起等预算」和「恢复后 run 已 running
    // 但 task 行还停在 blocked」这两种状态一起放行。
    const cid = newCompany();
    const [status, body] = await call('POST', `/api/company/${cid}/tasks`, { task: '一个卡住的任务' });
    expect(status).toBe(201);

    const sse = require('../../services/graph-run-sse');
    sse.transitionRun(body.runId, 'running', 'blocked', {}, { summary: '用例模拟预算耗尽' });

    const [del, err] = await call('DELETE', `/api/company/${cid}/goals/${body.goalId}`);
    expect(del).toBe(409);
    expect(err.runningTasks).toEqual([body.taskId]);
    // 拒绝还不够,得给出走得通的自救路径:blocked → stopped 是合法转换
    expect(err.hint).toContain(`/api/graph-runs/${body.runId}/stop`);
    // 单条 task 的归档口径必须与 goal 级一致,否则绕一下就穿了
    const [delTask] = await call('DELETE', `/api/company/${cid}/tasks/${body.taskId}`);
    expect(delTask).toBe(409);

    // 恢复成 running 之后 task 行还停在 blocked —— 这正是复核抓到的「守卫过松」现场
    sse.transitionRun(body.runId, 'blocked', 'running', {}, { summary: '用例模拟加预算恢复' });
    expect(wdb.getTask(body.taskId).status).toBe('blocked');
    const [stillBlocked] = await call('DELETE', `/api/company/${cid}/goals/${body.goalId}`);
    expect(stillBlocked).toBe(409);

    // 真停掉之后才放行(stopped 不在 run 的活跃取值域里)
    sse.transitionRun(body.runId, 'running', 'stopped', {}, { summary: '用例模拟人工停止' });
    const [ok] = await call('DELETE', `/api/company/${cid}/goals/${body.goalId}`);
    expect(ok).toBe(200);
  });

  it('goal 下有一条 failed 时收敛为 failed,OKR 节点在最后一条终态时才写、且只写一次(缺陷 F2)', async () => {
    const cid = newCompany();
    const { OkrService } = require('../../services/okr-service');
    const origUpdateNode = OkrService.prototype.updateNode;
    const okrCalls = [];
    OkrService.prototype.updateNode = function (objectiveId, nodeId, updates) {
      okrCalls.push({ nodeId, status: updates.status });
      return { id: nodeId };
    };

    try {
      const [created, goal] = await call('POST', `/api/company/${cid}/goals`, {
        description: '把注册流程重做一遍',
        okr_node_id: 'O-002.1',
        tasks: [{ description: '改后端接口' }, { description: '改前端表单' }],
      });
      expect(created).toBe(201);
      const [t1, t2] = goal.tasks;

      const sse = require('../../services/graph-run-sse');

      // 第一条失败
      const [d1, r1] = await call('POST', `/api/company/${cid}/goals/${goal.goalId}/dispatch`);
      expect(d1).toBe(201);
      expect(r1.started.taskId).toBe(t1.taskId);
      sse.transitionRun(r1.started.runId, 'running', 'failed', {}, { summary: '用例模拟执行失败' });
      expect(wdb.getTask(t1.taskId).status).toBe('failed');
      // 还有一条 draft 没跑,goal 不许提前收敛 —— 提前写 OKR 就是「一条失败的尾巴把节点翻掉」,
      // 而后面那条可能还会成功。§四.7 禁令一挪走 run 级 okr_node_id 要挡的就是这个
      expect(wdb.getGoal(goal.goalId).status).toBe('active');
      expect(okrCalls).toEqual([]);

      // 第二条成功;至此全部 task 到终态 → goal 收敛
      const [d2, r2] = await call('POST', `/api/company/${cid}/goals/${goal.goalId}/dispatch`);
      expect(d2).toBe(201);
      expect(r2.started.taskId).toBe(t2.taskId);
      sse.transitionRun(r2.started.runId, 'running', 'completed', {}, { summary: '用例直推终态' });

      expect(wdb.getTask(t2.taskId).status).toBe('completed');
      // 判词口径:全部 completed 才 done。「最后一条成功了就算达成」会让半截需求被判合格
      expect(wdb.getGoal(goal.goalId).status).toBe('failed');
      expect(okrCalls).toEqual([{ nodeId: 'O-002.1', status: 'failed' }]);
    } finally {
      OkrService.prototype.updateNode = origUpdateNode;
    }
  });

  it('goal 带 report_to 时只汇报、不再自己写 OKR(同一个节点不许写两次,缺陷 F2)', async () => {
    // reportGoalOutcome 是 either/or:`/api/agent/task/report` 那条链路自己会更新 OKR 节点,
    // 这边再写一次就是同一个节点被写两遍(两次的 summary 还不一样,后写的赢)。
    // 「先写 OKR 再汇报」是最自然的错法,所以两条分支都得有人盯着。
    const cid = newCompany();
    const { OkrService } = require('../../services/okr-service');
    const origUpdateNode = OkrService.prototype.updateNode;
    let okrWrites = 0;
    OkrService.prototype.updateNode = function (objectiveId, nodeId) { okrWrites += 1; return { id: nodeId }; };

    const [status, body] = await call('POST', `/api/company/${cid}/tasks`, {
      task: '给上级交一份周报',
      okr_node_id: 'O-003.2',
      report_to: 'ceo',
    });
    expect(status).toBe(201);
    expect(wdb.getGoal(body.goalId).report_to).toBe('ceo');

    // fetch 只在这一小段里换掉:call() 自己也用它,换久了整个用例都没法发请求
    const origFetch = globalThis.fetch;
    const posted = [];
    globalThis.fetch = (url, init) => {
      posted.push({ url: String(url), body: JSON.parse(init.body) });
      return Promise.resolve({ ok: true, status: 200 });
    };
    try {
      const sse = require('../../services/graph-run-sse');
      sse.transitionRun(body.runId, 'running', 'completed', {}, { summary: '用例直推终态' });
    } finally {
      globalThis.fetch = origFetch;
      OkrService.prototype.updateNode = origUpdateNode;
    }

    expect(posted).toHaveLength(1);
    expect(posted[0].url).toContain('/api/agent/task/report');
    // 汇报的是 **goal id**:上级 agent 靠它把 goal 级验收和 run 级「这次跑成什么样」分开
    expect(posted[0].body).toMatchObject({ taskId: body.goalId, to: 'ceo', status: 'done', nodeId: 'O-003.2' });
    expect(okrWrites).toBe(0);
  });

  // ──────────────────────────────────────────────────────────────
  // 缺陷 F3 / F6 · dispatch 那条路上被丢掉和被违反的两个约定
  // ──────────────────────────────────────────────────────────────

  it('草稿上申报的 budget 能活到 dispatch,run 拿到的不是默认额度(缺陷 F3)', async () => {
    const cid = newCompany();
    const budget = { total_agent_calls: 3, total_tokens: 5000000, wallclock_ms: 60000, spent: { agent_calls: 0, tokens: 0 } };

    // 建 goal(草稿)→ 之后 dispatch,中间隔着一整个请求:请求体活不到那时候,
    // 所以 budget 必须落在 tasks.budget_json 上,dispatch 再读回来
    const [s1, goal] = await call('POST', `/api/company/${cid}/goals`, { description: '一件预算很紧的活', budget });
    expect(s1).toBe(201);
    expect(JSON.parse(wdb.getTask(goal.tasks[0].taskId).budget_json)).toEqual(budget);

    const [s2, dispatched] = await call('POST', `/api/company/${cid}/goals/${goal.goalId}/dispatch`);
    expect(s2).toBe(201);
    // 修之前这里是 DEFAULT_TASK_BUDGET(100 次调用):用户填的 3 次被静默丢弃,
    // 他只会看到任务跑到一半 blocked,而没有任何地方说过预算被换掉了
    expect(JSON.parse(wdb.getRun(dispatched.started.runId).budget_json)).toEqual(budget);

    // 本次 dispatch 的 body 仍然覆盖草稿上的申报值(优先级:body > 草稿 > 默认)
    const cid2 = newCompany();
    const [, goal2] = await call('POST', `/api/company/${cid2}/goals`, { description: '同样带预算', budget });
    const override = { total_agent_calls: 9, spent: { agent_calls: 0, tokens: 0 } };
    const [s3, d2] = await call('POST', `/api/company/${cid2}/goals/${goal2.goalId}/dispatch`, { budget: override });
    expect(s3).toBe(201);
    expect(JSON.parse(wdb.getRun(d2.started.runId).budget_json)).toEqual(override);
  });

  it('POST /tasks 撞唯一索引存成草稿后,dispatch 不带 body 仍用当初申报的 budget(缺陷 F3 的原始现场)', async () => {
    // 这条走的是 hint 亲口推荐的那条路:「任务已存为草稿,等当前任务收敛后 dispatch」。
    // 它只产生在**唯一索引**那条竞态路径上 —— 端点开头的 activeRootRun 预检查会在建 goal/task
    // **之前**就 409,那条路上根本没有草稿行。所以这里必须把竞态窗口造出来:
    // 让 createTask 返回后、launchTaskRun 起 run 前,另一条 active root run 冒出来。
    // (不造窗口就只能验 POST /goals → dispatch 那条主路径,而复核点名的正是这条 hint 路径。)
    const cid = newCompany();
    const budget = { total_agent_calls: 2, total_tokens: 1000, wallclock_ms: 30000, spent: { agent_calls: 0, tokens: 0 } };
    const db = wdb.getDb();
    const intruderId = `run_intruder_${cid}`;
    const origCreateTask = wdb.createTask;
    wdb.createTask = (args) => {
      const row = origCreateTask(args);
      const now = new Date().toISOString();
      // 抢在同一家公司插一条 running 的 root run:随后的 markRunReady(planning → ready)
      // 会撞 idx_graph_runs_one_active_root
      db.prepare(`INSERT INTO graph_runs (id, graph_kind, graph_id, company_id, root_run_id, run_status, created_at, updated_at)
                  VALUES (?, 'org', ?, ?, ?, 'running', ?, ?)`).run(intruderId, cid, cid, intruderId, now, now);
      return row;
    };

    let status;
    let conflict;
    try {
      [status, conflict] = await call('POST', `/api/company/${cid}/tasks`, { task: '一件排在后面的活', budget });
    } finally {
      wdb.createTask = origCreateTask;
    }

    expect(status).toBe(409);
    // taskId + 「dispatch」这两条一起把路径钉死:端点开头的预检查 409 回的是 activeTaskId
    // 且 hint 让你去 stop,两者都对不上 —— 断言写松了会验成另一条根本没有草稿的路径
    expect(conflict.taskId).toBeTruthy();
    expect(conflict.hint).toContain('dispatch');
    const draft = wdb.getTask(conflict.taskId);
    expect(draft.status).toBe('draft');
    // 请求体到这里就没了:不落库的话下面那次 dispatch 只能拿默认额度,而用户以为自己填的算数
    expect(JSON.parse(draft.budget_json)).toEqual(budget);

    // 前一条任务收敛了,按 hint 下达
    db.prepare(`UPDATE graph_runs SET run_status = 'completed' WHERE id = ?`).run(intruderId);
    const [ok, dispatched] = await call('POST', `/api/company/${cid}/goals/${conflict.goalId}/dispatch`);
    expect(ok).toBe(201);
    expect(dispatched.started.taskId).toBe(conflict.taskId);

    const runBudget = JSON.parse(wdb.getRun(dispatched.started.runId).budget_json);
    expect(runBudget).toEqual(budget);
    expect(runBudget.total_agent_calls).not.toBe(100); // DEFAULT_TASK_BUDGET 的值,钉住「不是默认额度」
  });

  it('正向依赖(第 1 条依赖第 2 条)不再把 goal 卡死:dispatch 起的是跑得动的那条(第二轮复核 缺陷 4)', async () => {
    const cid = newCompany();
    const [status, goal] = await call('POST', `/api/company/${cid}/goals`, {
      description: '上线一个服务',
      tasks: [
        { description: '部署到测试环境', depends_on: [2] }, // seq 最小的这条反而依赖后一条
        { description: '构建镜像' },
      ],
    });
    expect(status).toBe(201);
    const [deploy, build] = goal.tasks;
    expect(deploy.dependsOn).toEqual([build.taskId]);

    // 修之前两版都不对:
    //   v0 恒取 drafts[0] = 部署,直接起 run —— 收下了依赖又无声违反它;
    //   v1 恒取 drafts[0] 且有阻塞就 409 —— 这个 goal 通过 API 就**永远下达不了**
    //      (validateDependsOn 只拒环、不拒正向引用,而 dispatch 从不换人挑)。
    const [ok, res1] = await call('POST', `/api/company/${cid}/goals/${goal.goalId}/dispatch`);
    expect(ok).toBe(201);
    expect(res1.started.taskId).toBe(build.taskId); // 跳过被挡住的部署,起构建
    expect(res1.pendingTasks).toEqual([deploy.taskId]); // 被跳过的那条如实留在待下达里
    expect(wdb.getTask(deploy.taskId).status).toBe('draft'); // 依赖仍是硬约束,一条都没抢跑

    // 构建跑完 → 部署的上游就绪,再 dispatch 一次就轮到它
    const sse = require('../../services/graph-run-sse');
    sse.transitionRun(res1.started.runId, 'running', 'completed', {}, { summary: '用例直推终态' });
    expect(wdb.getTask(build.taskId).status).toBe('completed');

    const [ok2, res2] = await call('POST', `/api/company/${cid}/goals/${goal.goalId}/dispatch`);
    expect(ok2).toBe(201);
    expect(res2.started.taskId).toBe(deploy.taskId);
  });

  it('一条都跑不动时才 409,点名最先考察的那条和挡着它的人(缺陷 F6)', async () => {
    const cid = newCompany();
    const [status, goal] = await call('POST', `/api/company/${cid}/goals`, {
      description: '发一个版本',
      tasks: [
        { description: '构建镜像' },
        { description: '部署到测试环境', depends_on: [1] },
        { description: '跑冒烟用例', depends_on: [2] },
      ],
    });
    expect(status).toBe(201);
    const [build, deploy, smoke] = goal.tasks;

    // 构建失败了(不是 completed,也不再是 draft)→ 剩下两条 draft 谁都跑不动:
    // 部署等着 failed 的构建,冒烟等着还是 draft 的部署。
    wdb.updateTask(build.taskId, { status: 'failed' });

    const [blocked, err] = await call('POST', `/api/company/${cid}/goals/${goal.goalId}/dispatch`);
    expect(blocked).toBe(409);
    expect(err.taskId).toBe(deploy.taskId); // 按 seq 最先考察的那条
    expect(err.blockedBy).toEqual([build.taskId]);
    expect(err.hint).toContain(deploy.taskId); // 得告诉用户去哪儿改,不然只知道被拒
    expect(wdb.getTask(deploy.taskId).status).toBe('draft');
    expect(wdb.getTask(smoke.taskId).status).toBe('draft');
  });

  it('409 hint 里的 seq 自救路径真的通:PATCH seq 能换下达顺序,非整数 400(缺陷 4)', async () => {
    // 修之前 hint 写着「顺序不对就改依赖或 seq:PATCH …/tasks/{id}」,而那个 PATCH 从不读
    // body.seq —— 用户照着做完拿到 200(假确认),再 dispatch 还是原来那条。
    const cid = newCompany();
    const [status, goal] = await call('POST', `/api/company/${cid}/goals`, {
      description: '写两篇稿子',
      tasks: [{ description: '写初稿' }, { description: '写终稿' }],
    });
    expect(status).toBe(201);
    const [first, second] = goal.tasks;

    const [bad] = await call('PATCH', `/api/company/${cid}/tasks/${second.taskId}`, { seq: 'abc' });
    expect(bad).toBe(400); // seq 直接进 ORDER BY,非整数必须挡在信任边界上

    const [okPatch, patched] = await call('PATCH', `/api/company/${cid}/tasks/${second.taskId}`, { seq: 0 });
    expect(okPatch).toBe(200);
    expect(patched.seq).toBe(0);

    const [okDispatch, res] = await call('POST', `/api/company/${cid}/goals/${goal.goalId}/dispatch`);
    expect(okDispatch).toBe(201);
    expect(res.started.taskId).toBe(second.taskId); // 顺序真的换了,不是回个 200 就完事
    expect(res.pendingTasks).toEqual([first.taskId]);
  });

  it('auto 模式收敛只唤醒上级一次,且 from 是交付者不是 human(第二轮复核 缺陷 3)', async () => {
    // 修之前有两条汇报打同一个 POST /api/agent/task/report:goal 级(task-settle)+ run 级
    // (company.js 挂在 auto driver 上的 reportRunOutcome)。批 1 是 1 goal : 1 task,
    // 于是同一件事把上级叫醒两次(那个端点第 5 步会 setImmediate 自调 /api/agent/{to}/chat)。
    const cid = newCompany();
    const engineMod = require('../../services/graph-engine');
    const origStartRun = engineMod.startRun;
    // auto 的 driver 会真跑整张图(分钟级、要调 agent),这里换成一个手动兑现的 promise:
    // 关键是它必须在 run 已经收敛**之后**才 resolve —— 那正是真实时序,也正是双唤醒的现场。
    let resolveDriver;
    const driver = new Promise((r) => { resolveDriver = r; });
    engineMod.startRun = (runId, opts) => {
      origStartRun(runId, { ...opts, mode: 'manual' }); // 只标 running + 点亮入口节点
      return { driver };
    };

    let created;
    try {
      const [status, body] = await call('POST', `/api/company/${cid}/tasks`, {
        task: '把周报发出去',
        mode: 'auto',
        report_to: 'ceo',
        okr_node_id: 'O-004.1',
      });
      expect(status).toBe(201);
      created = body;
    } finally {
      engineMod.startRun = origStartRun;
    }

    const origFetch = globalThis.fetch;
    const posted = [];
    globalThis.fetch = (url, init) => {
      posted.push({ url: String(url), body: JSON.parse(init.body) });
      return Promise.resolve({ ok: true, status: 200 });
    };
    try {
      const sse = require('../../services/graph-run-sse');
      sse.transitionRun(created.runId, 'running', 'completed', {}, { summary: '用例直推终态' });
      resolveDriver();
      // 让 driver 的 then 链(如果还有的话)跑完再数:不 flush 就等于没验
      await new Promise((r) => setImmediate(r));
      await new Promise((r) => setImmediate(r));
    } finally {
      globalThis.fetch = origFetch;
    }

    expect(posted).toHaveLength(1);
    expect(posted[0].body.taskId).toBe(created.goalId); // 留下的是 goal 级那条
    // from = 交付者(org graph 末节点),不是下单人。created_by 默认就是字符串 'human',
    // 下游拿它去 readStateFile('human','todo.md') 全部落空且不报错。
    expect(posted[0].body.from).toBe(`${cid}:org-validator`);
    expect(posted[0].body.from).not.toBe('human');
  });

  // ──────────────────────────────────────────────────────────────
  // 验收 5 · 描述冻结(goal 与 task 各一个冻结点)
  // ──────────────────────────────────────────────────────────────

  it('已下达的 goal 改 description → 409 + hint,改 title → 200(验收 5)', async () => {
    const cid = newCompany();
    const [, created] = await call('POST', `/api/company/${cid}/tasks`, { task: '写一版 landing page' });

    const [conflict, err] = await call('PATCH', `/api/company/${cid}/goals/${created.goalId}`, {
      description: '换个需求',
    });
    expect(conflict).toBe(409);
    expect(err.frozenFields).toEqual(['description']);
    expect(err.hint).toBeTruthy(); // 只说「不许改」而不说「怎么办」,用户就卡在这儿了
    expect(wdb.getGoal(created.goalId).description).toBe('写一版 landing page');

    // title 只是列表上的标签,不进任何 agent 的上下文,冻结之后照样能改
    const [ok, patched] = await call('PATCH', `/api/company/${cid}/goals/${created.goalId}`, { title: '改个名' });
    expect(ok).toBe(200);
    expect(patched.title).toBe('改个名');
  });

  it('task 同理:queued_at 一非空就拒改 description,title 仍可改(验收 5)', async () => {
    const cid = newCompany();
    const [, created] = await call('POST', `/api/company/${cid}/tasks`, { task: '接入支付' });
    // 起 run 那一刻描述已经写进 task-goal.json 被 agent 读走了,再改就是篡改审计输入
    expect(wdb.getTask(created.taskId).queued_at).toBeTruthy();

    const [conflict, err] = await call('PATCH', `/api/company/${cid}/tasks/${created.taskId}`, {
      description: '改成接入退款',
    });
    expect(conflict).toBe(409);
    expect(err.frozenFields).toEqual(['description']);
    expect(wdb.getTask(created.taskId).description).toBe('接入支付');

    const [ok] = await call('PATCH', `/api/company/${cid}/tasks/${created.taskId}`, { title: '支付' });
    expect(ok).toBe(200);
  });

  // ──────────────────────────────────────────────────────────────
  // 验收 6 · depends_on 成环
  // ──────────────────────────────────────────────────────────────

  it('depends_on 写成环 → 400,且库里没有半条脏数据(验收 6)', async () => {
    const cid = newCompany();
    const goal = wdb.createGoal({ companyId: cid, title: '双周迭代', description: '双周迭代' });
    const t1 = wdb.createTask({ goalId: goal.id, title: 'T1', description: '先做 T1' });
    const t2 = wdb.createTask({ goalId: goal.id, title: 'T2', description: '再做 T2' });

    const [ok] = await call('PATCH', `/api/company/${cid}/tasks/${t2.id}`, { depends_on: [t1.id] });
    expect(ok).toBe(200);

    // T1 反过来依赖 T2 就闭环了。环不是「跑起来才发现」的问题:调度器会把两条都判成
    // 「上游没完成」,于是谁都不出队 —— 表现是任务静默躺着,界面上跟正常排队一模一样。
    const [status, err] = await call('PATCH', `/api/company/${cid}/tasks/${t1.id}`, { depends_on: [t2.id] });
    expect(status).toBe(400);
    expect(err.error).toMatch(/环/);

    // 「建完再查一次」才算验完:校验必须发生在写之前,半条脏数据都不许留
    expect(JSON.parse(wdb.getTask(t1.id).depends_on)).toEqual([]);
    expect(JSON.parse(wdb.getTask(t2.id).depends_on)).toEqual([t1.id]);
  });

  // ──────────────────────────────────────────────────────────────
  // 验收 10 · 列表端点不 N+1
  // ──────────────────────────────────────────────────────────────

  it('5 goal × 3 task 的列表只发一次 graph_run_nodes 查询(验收 10)', async () => {
    const cid = newCompany();
    const db = wdb.getDb();
    const now = new Date().toISOString();
    for (let g = 0; g < 5; g++) {
      const goal = wdb.createGoal({ companyId: cid, title: `G${g}`, description: `目标 ${g}` });
      for (let t = 0; t < 3; t++) {
        const task = wdb.createTask({ goalId: goal.id, title: `T${g}-${t}`, description: `活 ${g}-${t}` });
        // 每条 task 挂一条 run + 2 个节点行:没有 run 的 task 走不到进度查询,挡不住 N+1。
        // run_status 用 completed —— idx_graph_runs_one_active_root 只许一条活跃 root run,
        // 而这里要的是 15 条历史 run 同时出现在列表上(那正是 N+1 最疼的形态)。
        const runId = `run_n1_${g}_${t}`;
        db.prepare(`INSERT INTO graph_runs (id, graph_kind, graph_id, company_id, root_run_id, run_status, created_at, updated_at)
                    VALUES (?, 'org', ?, ?, ?, 'completed', ?, ?)`).run(runId, cid, cid, runId, now, now);
        for (const [i, st] of ['success', 'pending'].entries()) {
          db.prepare(`INSERT INTO graph_run_nodes (id, run_id, def_node_id, status, created_at, updated_at)
                      VALUES (?, ?, ?, ?, ?, ?)`).run(`${runId}_n${i}`, runId, `d${i}`, st, now, now);
        }
        wdb.updateTask(task.id, { currentRunId: runId });
      }
    }

    // 数的是 prepare 而不是 statement 执行:work-graph-db 每次调用都现 prepare 一条,
    // 改成逐行查进度必然体现为 15 次。
    const origPrepare = db.prepare.bind(db);
    let nodeQueries = 0;
    db.prepare = (sql) => {
      if (/graph_run_nodes/.test(sql)) nodeQueries++;
      return origPrepare(sql);
    };
    try {
      const [status, body] = await call('GET', `/api/company/${cid}/goals?withTasks=1`);
      expect(status).toBe(200);
      expect(body.goals).toHaveLength(5);
      expect(body.goals.flatMap((g) => g.tasks)).toHaveLength(15);
      // 顺带把口径钉住:只数 prepare 次数的话,一条恒返回空的查询也是 1 次
      expect(body.goals[0].tasks[0].progress).toEqual({ done: 1, total: 2 });
      expect(nodeQueries).toBe(1);

      nodeQueries = 0;
      const [taskStatus, taskBody] = await call('GET', `/api/company/${cid}/tasks`);
      expect(taskStatus).toBe(200);
      expect(taskBody.tasks).toHaveLength(15);
      expect(nodeQueries).toBe(1);
    } finally {
      delete db.prepare; // 还原成原型上的那个,别把桩留给同进程的后续用例
    }
  });

  // ──────────────────────────────────────────────────────────────
  // 验收 11 · /goals 的 OKR 别名退役
  // ──────────────────────────────────────────────────────────────

  it('/:cid/goals 归 goal 两级模型,/:cid/okr 仍是 OKR(验收 11)', async () => {
    const cid = newCompany();
    const goal = wdb.createGoal({ companyId: cid, title: '别名退役', description: '别名退役' });

    // 别名还在的话这条会被 OkrService 接走,返回 objective 数组而不是 { goals: [...] }
    const [status, body] = await call('GET', `/api/company/${cid}/goals`);
    expect(status).toBe(200);
    expect(body.goals.map((g) => g.goalId)).toEqual([goal.id]);

    const [okrStatus, okrBody] = await call('GET', `/api/company/${cid}/okr`);
    expect(okrStatus).toBe(200);
    expect(Array.isArray(okrBody)).toBe(true); // OkrService.list() 的形状,没被 goal 模型顶掉
  });

  it('两个 server 入口都不再挂 /api/goals 别名(验收 11)', () => {
    // 这条是源码级断言:别名的注册点在 index.js / agent-server.js,路由测试够不着。
    // 漏掉任何一个入口,agent-server.js 那条路上 /api/goals 就仍指向 OKR ——
    // 而两个入口的差异恰恰是最不容易被人想起来跑一遍的地方。
    for (const entry of ['../../index.js', '../../agent-server.js']) {
      const src = fs.readFileSync(path.resolve(__dirname, entry), 'utf-8');
      expect(src).not.toMatch(/app\.use\(\s*['"]\/api\/goals['"]/);
      expect(src).toMatch(/app\.use\(\s*['"]\/api\/okr['"]/);
    }
  });
});