goal-task-acceptance.test.js 92.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 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 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 1253 1254 1255 1256 1257 1258 1259 1260 1261 1262 1263 1264 1265 1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 1276 1277 1278 1279 1280 1281 1282 1283 1284 1285 1286 1287 1288 1289 1290 1291 1292 1293 1294 1295 1296 1297 1298 1299 1300 1301 1302 1303 1304 1305 1306 1307 1308 1309 1310 1311 1312 1313 1314 1315 1316 1317 1318 1319 1320 1321 1322 1323 1324 1325 1326 1327 1328 1329 1330 1331 1332 1333 1334 1335 1336 1337 1338 1339 1340 1341 1342 1343 1344 1345 1346 1347 1348 1349 1350 1351 1352 1353 1354 1355 1356 1357 1358 1359 1360 1361 1362 1363 1364 1365 1366 1367 1368 1369 1370 1371 1372 1373 1374 1375 1376 1377 1378 1379 1380 1381 1382 1383 1384 1385 1386 1387 1388 1389 1390 1391 1392 1393 1394 1395 1396 1397 1398 1399 1400 1401 1402 1403 1404 1405 1406 1407 1408 1409 1410 1411 1412 1413 1414 1415 1416 1417 1418 1419 1420 1421 1422 1423 1424 1425 1426 1427 1428 1429 1430 1431 1432 1433 1434 1435 1436 1437 1438 1439 1440 1441 1442 1443 1444 1445 1446 1447 1448 1449 1450 1451 1452 1453 1454 1455 1456 1457 1458 1459 1460 1461 1462 1463 1464 1465 1466 1467 1468 1469 1470 1471 1472 1473 1474 1475 1476 1477 1478 1479 1480 1481 1482 1483 1484 1485 1486 1487 1488 1489 1490 1491 1492 1493 1494 1495 1496 1497 1498 1499 1500 1501 1502 1503 1504 1505 1506 1507 1508 1509 1510 1511 1512 1513 1514 1515 1516 1517 1518 1519 1520 1521 1522 1523 1524 1525 1526 1527 1528 1529 1530 1531 1532 1533 1534 1535 1536 1537 1538 1539 1540 1541 1542 1543 1544 1545 1546 1547 1548 1549 1550 1551 1552 1553 1554 1555 1556 1557 1558 1559 1560 1561 1562 1563 1564 1565 1566 1567 1568 1569 1570 1571 1572 1573 1574 1575 1576 1577 1578 1579 1580 1581 1582 1583 1584 1585 1586 1587 1588 1589 1590 1591 1592 1593 1594 1595 1596 1597 1598 1599 1600 1601 1602 1603 1604 1605 1606 1607 1608 1609 1610 1611 1612 1613 1614 1615 1616 1617 1618 1619 1620 1621 1622 1623 1624 1625 1626 1627 1628 1629 1630 1631 1632 1633 1634 1635 1636 1637 1638 1639 1640 1641 1642 1643 1644 1645 1646 1647 1648 1649 1650 1651 1652 1653 1654 1655 1656 1657 1658 1659 1660 1661 1662
/**
 * 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}$/);
  });

  // ──────────────────────────────────────────────────────────────
  // T-mt5dkvjm · assignee_agent_id 不再是永远的 NULL
  // ──────────────────────────────────────────────────────────────

  it('POST /:cid/tasks 入队后 assignee_agent_id 不是 NULL —— COO 心跳巡检靠 to 字段判「有没有人接」,一直是 null 会被误报成孤儿任务(T-mt5dkvjm)', async () => {
    const cid = newCompany();
    const [status, body] = await call('POST', `/api/company/${cid}/tasks`, { task: '起个图跑任务' });
    expect(status).toBe(201);

    // 入队当下就该有占位 assignee —— 这正是 task-service.getAggregatedTasks 回给 COO 的 `to` 字段
    expect(wdb.getTask(body.taskId).assignee_agent_id).toBe(wdb.GRAPH_TAKEOVER_ASSIGNEE);

    // run 收敛之后也不能被冲回 null:settleTaskFromRun 的 COALESCE 只在为空时兜底,不覆盖已有值
    const sse = require('../../services/graph-run-sse');
    expect(sse.transitionRun(body.runId, 'running', 'completed', {}, { summary: '用例直推终态' })).toBeTruthy();
    const settled = wdb.getTask(body.taskId);
    expect(settled.status).toBe('completed');
    expect(settled.assignee_agent_id).toBe(wdb.GRAPH_TAKEOVER_ASSIGNEE);
  });

  // ──────────────────────────────────────────────────────────────
  // 验收 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, legacy_path, status, created_at, updated_at)
        VALUES ('acc-worker', 'acc-worker', 'public', NULL, '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');
      // 界面上那句「卡在哪」的小字(真实链路里由 describeRunBlock + noteTaskBlockedReason 写)
      expect(wdb.noteTaskBlockedReason(body.taskId, body.runId, '节点 xxx 卡住(budget_exhausted)')).toBe(1);
      expect(wdb.getTask(body.taskId).blocked_reason).toBeTruthy();
      // 但 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();
      // 33 号文档同批修的另一个真实 bug:settleTaskFromRun 只写 status,不清 blocked_reason,
      // 于是任务列表在「已完成」绿色状态旁边,还会一直挂着上一次卡住时留下的那句红字
      // ——真实回归任务 T-mt2r6fz0-125b 复现过(org-coder 恢复跑完后,界面仍显示
      // 「节点 org-coder 卡住(missing_required_input)」)。
      expect(task.blocked_reason).toBeNull();
      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 收敛。
      // 批 3 起**不能再调一次 dispatch**:那一次已经把 goal 下所有 draft 一起入队了(§六.2),
      // 现在 t2 是 queued 而不是 draft。而它是 manual,调度器故意不自动出队(§五.2bis),
      // 所以由人点「开始」把它从队里拉起来 —— 这正是那个端点存在的理由。
      const [d2, r2] = await call('POST', `/api/company/${cid}/tasks/${t2.taskId}/start`);
      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;
    }
  });

  // ──────────────────────────────────────────────────────────────
  // §四.10 · goal 级验收(org-validator 在 goal 层再调一次)
  // ──────────────────────────────────────────────────────────────

  /** 异步验收跑在 setImmediate 里;等到条件成立再断言,别靠数固定几个 tick */
  async function waitFor(pred, label) {
    for (let i = 0; i < 50; i += 1) {
      if (pred()) return;
      await new Promise((r) => setImmediate(r));
    }
    throw new Error(`等不到:${label}`);
  }

  /**
   * 等一个**最终**判词。
   *
   * 不能直接等 `verdict` 非空:CAS 抢到收敛权时会同步认领一个占位判词(`VERDICT_PENDING_PREFIX` 打头,
   * 见 task-settle 常量处 —— 没有它,验收窗口里一重启就静默丢一次验收,且在库里和
   * 「1 条 task 跳过验收」完全同形)。等非空就会在占位值上立刻返回,后面对判词内容的断言
   * 变成一场竞态。所以判据是「非空**且**不是占位值」。
   */
  async function waitVerdict(goalId, label = 'goal 级验收回写') {
    const { isPendingVerdict } = require('../../services/task-settle');
    await waitFor(() => {
      const v = wdb.getGoal(goalId).verdict;
      return v && !isPendingVerdict(v);
    }, label);
  }

  /** 建一个两条 task 的 goal,逐条 dispatch → 推到指定终态。返回建出来的 goal 视图 */
  async function runTwoTaskGoal(cid, [s1, s2], extra = {}) {
    const [created, goal] = await call('POST', `/api/company/${cid}/goals`, {
      description: '把注册流程重做一遍:后端发验证码,前端收验证码',
      tasks: [{ description: '后端加发验证码的接口' }, { description: '前端表单接上验证码' }],
      ...extra,
    });
    expect(created).toBe(201);
    const sse = require('../../services/graph-run-sse');
    // 批 3:一次 dispatch 把两条都入队,队头那条立刻起跑
    const [d1, r1] = await call('POST', `/api/company/${cid}/goals/${goal.goalId}/dispatch`);
    expect(d1).toBe(201);
    sse.transitionRun(r1.started.runId, 'running', s1, {}, { summary: '用例直推终态' });

    // 第二条是 manual,调度器不碰它(§五.2bis),由人点「开始」
    const second = goal.tasks.find((t) => t.taskId !== r1.started.taskId);
    const [d2, r2] = await call('POST', `/api/company/${cid}/tasks/${second.taskId}/start`);
    expect(d2).toBe(201);
    sse.transitionRun(r2.started.runId, 'running', s2, {}, { summary: '用例直推终态' });
    return goal;
  }

  it('≥2 条 task 全 completed → 调一次 org-validator;判 fail 时 goal 翻成 failed,OKR 写的是最终结论', async () => {
    // 这条用例守的是 §四.10 的整个理由:每条 task 都按自己的描述做完了,不等于拼起来是人要的东西。
    // 也守住约束 5 —— OKR / report_to 必须拿**验收后**的状态,验收前就汇报等于对外说了个会被改的结论。
    const cid = newCompany();
    const validation = require('../../services/goal-validation');
    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 };
    };

    const seen = [];
    validation.setValidateGoal(async (ctx) => {
      seen.push(ctx);
      return { pass: false, verdict: '后端发的是 6 位码,前端只收 4 位,两条 task 各自都对,合起来跑不通', tokens: 1234 };
    });

    try {
      const goal = await runTwoTaskGoal(cid, ['completed', 'completed'], { okr_node_id: 'O-010.1' });

      await waitVerdict(goal.goalId);
      const row = wdb.getGoal(goal.goalId);

      expect(seen).toHaveLength(1); // 一个 goal 只验一次:CAS 之后才调,不是每条 task 各调一次
      expect(seen[0].goal.id).toBe(goal.goalId);
      expect(seen[0].tasks.map((t) => t.status)).toEqual(['completed', 'completed']);
      expect(row.status).toBe('failed'); // CAS 先占位写 done,验收判 fail 后改写
      expect(row.verdict).toContain('前端只收 4 位');
      expect(row.validation_tokens).toBe(1234); // 验收烧的 token 记在 goal 上(没有 run 的预算池可扣)
      // 汇报只有一次,且带的是最终状态;验收前抢跑就会看到一条 done
      expect(okrCalls).toEqual([{ nodeId: 'O-010.1', status: 'failed' }]);

      // 幂等:收敛权已被拿走,再推一次不会二次验收(org-validator 是真花钱的)
      const settle = require('../../services/task-settle');
      expect(settle.settleGoalIfAllTasksDone(goal.goalId)).toBeNull();
      expect(seen).toHaveLength(1);
    } finally {
      validation.setValidateGoal(null);
      OkrService.prototype.updateNode = origUpdateNode;
    }
  });

  it('有一条 task 没 completed → 直接 failed,不调 org-validator(显然没交付,省一次调用)', async () => {
    const cid = newCompany();
    const validation = require('../../services/goal-validation');
    const seen = [];
    validation.setValidateGoal(async (ctx) => { seen.push(ctx); return { pass: true }; });

    try {
      const goal = await runTwoTaskGoal(cid, ['failed', 'completed']);
      await new Promise((r) => setImmediate(r));
      expect(wdb.getGoal(goal.goalId).status).toBe('failed');
      expect(seen).toEqual([]);
    } finally {
      validation.setValidateGoal(null);
    }
  });

  it('只有 1 条 task 时跳过 goal 级验收(两级验收问的是同一件事,再调一次纯浪费)', async () => {
    const cid = newCompany();
    const validation = require('../../services/goal-validation');
    const seen = [];
    validation.setValidateGoal(async (ctx) => { seen.push(ctx); return { pass: false, verdict: '不该被调到' }; });

    try {
      const [status, body] = await call('POST', `/api/company/${cid}/tasks`, { task: '把周报发出去' });
      expect(status).toBe(201);
      require('../../services/graph-run-sse')
        .transitionRun(body.runId, 'running', 'completed', {}, { summary: '用例直推终态' });
      await new Promise((r) => setImmediate(r));

      expect(seen).toEqual([]);
      expect(wdb.getGoal(body.goalId).status).toBe('done');
    } finally {
      validation.setValidateGoal(null);
    }
  });

  it('验收本身炸了 → 保守判 done 且在 verdict 里写明没验过,绝不把 goal 卡在半路(约束 4)', async () => {
    // 「读不到判词就判 fail」是最自然的错法,而 failed 是终态、会写 OKR、会唤醒上级 ——
    // 误杀一次真交付不可撤销。反过来 done 至少有「全部 task 都 completed」这个既成事实兜着。
    const cid = newCompany();
    const validation = require('../../services/goal-validation');
    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 };
    };
    validation.setValidateGoal(async () => {
      const e = new Error('org-validator 起不来');
      e.tokens = 77; // 炸之前已经烧掉的那部分,照样要记账
      throw e;
    });

    try {
      const goal = await runTwoTaskGoal(cid, ['completed', 'completed'], { okr_node_id: 'O-011.1' });
      await waitVerdict(goal.goalId, '兜底判词回写');

      const row = wdb.getGoal(goal.goalId);
      expect(row.status).toBe('done');
      expect(row.verdict).toContain('验收未执行');
      expect(row.validation_tokens).toBe(77);
      expect(okrCalls).toEqual([{ nodeId: 'O-011.1', status: 'done' }]); // 卡不住:照样汇报
    } finally {
      validation.setValidateGoal(null);
      OkrService.prototype.updateNode = origUpdateNode;
    }
  });

  // ──────────────────────────────────────────────────────────────
  // 第三轮复核 缺陷 2 / 3 · 验收窗口内重启不丢、汇报炸了不杀进程
  // ──────────────────────────────────────────────────────────────

  /**
   * 造一个「另一个进程留下的」占位判词。
   * 占位值现在是「前缀 + 唯一 token」而不是一个常量 —— 常量形态下认领语句的 WHERE 里必然
   * 含有它自己要写的值,闸门当场失效(见 task-settle 常量处那段)。这里的 token 故意与
   * 本进程的 PROC_TAG 无关:模拟的就是别的实例写下的那一个。
   */
  let stuckSeq = 0;
  const foreignPending = () =>
    `${require('../../services/task-settle').VERDICT_PENDING_PREFIX}otherproc-${++stuckSeq}`;

  /** 把一条 goal 摆成「CAS 已写终态、验收还没回来」的形状 —— 也就是重启前一瞬间的库 */
  function stuckGoal(cid, verdict = foreignPending()) {
    const goal = wdb.createGoal({ companyId: cid, title: '两件事', description: '两件事合起来才算数' });
    const a = wdb.createTask({ goalId: goal.id, companyId: cid, title: 'A', description: 'A' });
    const b = wdb.createTask({ goalId: goal.id, companyId: cid, title: 'B', description: 'B' });
    wdb.updateTask(a.id, { status: 'completed' });
    wdb.updateTask(b.id, { status: 'completed' });
    expect(wdb.settleGoalStatus(goal.id, 'done')).toBe(1);
    return wdb.updateGoal(goal.id, { verdict });
  }

  it('CAS 同步写占位判词:验收在跑 ≠ 验收丢了 ≠ 本来就没验,三者在库里分得开(缺陷 2)', async () => {
    // 占位值存在的全部理由:`verdict IS NULL` 同时也是「1 条 task 跳过验收」那条合法路径的形态。
    // 不写占位,窗口里一重启就没有任何查询能把「丢了」从「本来就没验」里挑出来。
    const cid = newCompany();
    const settle = require('../../services/task-settle');
    const validation = require('../../services/goal-validation');
    let release;
    validation.setValidateGoal(() => new Promise((r) => { release = r; })); // 卡住不回,模拟验收在跑

    try {
      const goal = wdb.createGoal({ companyId: cid, title: '两件事', description: '两件事合起来才算数' });
      const a = wdb.createTask({ goalId: goal.id, companyId: cid, title: 'A', description: 'A' });
      const b = wdb.createTask({ goalId: goal.id, companyId: cid, title: 'B', description: 'B' });
      wdb.updateTask(a.id, { status: 'completed' });
      wdb.updateTask(b.id, { status: 'completed' });

      expect(settle.settleGoalIfAllTasksDone(goal.id)).toBe('done');
      // 同步可见:占位值和 CAS 在同一个 tick 里落库,不是等验收起来才写
      const row = wdb.getGoal(goal.id);
      expect(row.status).toBe('done');
      expect(settle.isPendingVerdict(row.verdict)).toBe(true);

      // 验收本体是在 setImmediate 里才被调到的,先让出一次,release 才拿得到
      await waitFor(() => release, '验收本体被调起');
      release({ pass: true, verdict: '合起来对上了', tokens: 9 });
      await waitVerdict(goal.id);
      expect(wdb.getGoal(goal.id).verdict).toBe('合起来对上了'); // 真判词覆盖占位值
    } finally {
      validation.setValidateGoal(null);
    }
  });

  it('启动重推:窗口内重启丢掉的验收会被补跑,且补跑本身幂等(缺陷 2)', async () => {
    const cid = newCompany();
    const settle = require('../../services/task-settle');
    const validation = require('../../services/goal-validation');
    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 };
    };

    const seen = [];
    validation.setValidateGoal(async (ctx) => {
      seen.push(ctx.goal.id);
      return { pass: false, verdict: '补跑后判 fail:少了退款流程', tokens: 42 };
    });

    try {
      const goal = stuckGoal(cid);
      wdb.updateGoal(goal.id, { okrNodeId: 'O-099.1' });

      expect(await settle.resumePendingGoalValidations()).toBeGreaterThanOrEqual(1);

      const row = wdb.getGoal(goal.id);
      expect(seen).toContain(goal.id);
      expect(row.status).toBe('failed');       // 补跑判 fail → 终态被改写成真结论
      expect(row.verdict).toBe('补跑后判 fail:少了退款流程');
      expect(row.validation_tokens).toBe(42);
      expect(okrCalls).toContainEqual({ nodeId: 'O-099.1', status: 'failed' }); // 补的不只是判词,还有那次没做的汇报

      // 幂等:占位值已被真判词覆盖,再推一次连认领都拿不到,不会二次烧钱 / 二次汇报
      const before = seen.length;
      await settle.resumePendingGoalValidations();
      expect(seen.length).toBe(before);
      expect(okrCalls.filter((c) => c.nodeId === 'O-099.1')).toHaveLength(1);
    } finally {
      validation.setValidateGoal(null);
      OkrService.prototype.updateNode = origUpdateNode;
    }
  });

  it('重推不覆盖别处已定案的判词(人已经在界面上看到那句话了)', async () => {
    const cid = newCompany();
    const settle = require('../../services/task-settle');
    const validation = require('../../services/goal-validation');
    const { OkrService } = require('../../services/okr-service');
    const origUpdateNode = OkrService.prototype.updateNode;
    const okrCalls = [];
    OkrService.prototype.updateNode = function (o, nodeId, u) { okrCalls.push(nodeId); return { id: nodeId }; };

    let target;
    validation.setValidateGoal(async (ctx) => {
      // 认领之后、回写之前的那几十秒里,别人(人工 / 另一个进程)把这条定了案
      if (ctx.goal.id === target) wdb.updateGoal(target, { status: 'done', verdict: '人工判 pass' });
      return { pass: false, verdict: '重推的结论,不该落库', tokens: 5 };
    });

    try {
      const goal = stuckGoal(cid);
      wdb.updateGoal(goal.id, { okrNodeId: 'O-098.1' });
      target = goal.id;

      await settle.resumePendingGoalValidations();

      const row = wdb.getGoal(goal.id);
      expect(row.verdict).toBe('人工判 pass');
      // ⚠️ 第四轮改动:判词输了,但**账要记** —— 这一次 org-validator 是真跑了、token 是真烧了。
      // 原断言钉住的正是「输家的钱静默蒸发」那个缺陷本身(与 settleGoalPlanning 那条
      // 「没命中也补记账」的分支自相矛盾)。现在改成断言它被记上。
      expect(row.validation_tokens).toBe(5);
      expect(okrCalls).not.toContain('O-098.1'); // 也不能再汇报一次:OKR 写两次、上级叫醒两次都不可重放
    } finally {
      validation.setValidateGoal(null);
      OkrService.prototype.updateNode = origUpdateNode;
    }
  });

  it('汇报环节同步抛出时只丢一行日志,不变成 unhandledRejection 杀进程(缺陷 3)', async () => {
    // reportGoalOutcome 有两条同步抛出面(terminalAgentId 读库、OkrService.updateNode 的 fs 写)。
    // 批 1 走同步路径,异常由 graph-run-sse 的 try/catch 兜住;这条路跑在 setImmediate 的 async
    // 函数里,同一个异常会逃成 unhandledRejection —— Node ≥15 默认直接杀掉整个服务。
    const cid = newCompany();
    const settle = require('../../services/task-settle');
    const validation = require('../../services/goal-validation');
    const { OkrService } = require('../../services/okr-service');
    const origUpdateNode = OkrService.prototype.updateNode;
    OkrService.prototype.updateNode = function () { throw new Error('ENOENT: OKR 目录被删了'); };
    validation.setValidateGoal(async () => ({ pass: true, verdict: '合起来对上了', tokens: 1 }));

    const rejections = [];
    const onRejection = (e) => rejections.push(e);
    process.on('unhandledRejection', onRejection);

    try {
      const goal = wdb.createGoal({ companyId: cid, title: '两件事', description: '两件事合起来才算数' });
      wdb.updateGoal(goal.id, { okrNodeId: 'O-097.1' });
      const a = wdb.createTask({ goalId: goal.id, companyId: cid, title: 'A', description: 'A' });
      const b = wdb.createTask({ goalId: goal.id, companyId: cid, title: 'B', description: 'B' });
      wdb.updateTask(a.id, { status: 'completed' });
      wdb.updateTask(b.id, { status: 'completed' });

      settle.settleGoalIfAllTasksDone(goal.id);
      await waitVerdict(goal.id);
      // 判词照样落了库(汇报炸在回写之后),进程也没被一个逃逸的 rejection 掀翻
      expect(wdb.getGoal(goal.id).verdict).toBe('合起来对上了');
      await new Promise((r) => setTimeout(r, 20)); // 给 unhandledRejection 事件一个真到达的机会
      expect(rejections).toEqual([]);
    } finally {
      process.off('unhandledRejection', onRejection);
      validation.setValidateGoal(null);
      OkrService.prototype.updateNode = origUpdateNode;
    }
  });

  // ──────────────────────────────────────────────────────────────
  // 第二轮复核(批 2)· 多实例并发下的认领与回写,以及「汇报丢了」的可查性
  // ──────────────────────────────────────────────────────────────

  /**
   * 三个用例守的是同一件事:goal 收敛带着两个**不可重放**的副作用(写一次 OKR 节点、
   * 唤醒一次上级 agent,后者真起一轮 LLM)。它们各盯一层闸门:
   *   ① 认领 —— 唯一 token + WHERE 只认上一个态(上一版把自己要写的值也放进了 WHERE,
   *      changes 恒为 1,闸门当场失效);
   *   ② in-flight —— 本进程还在飞的验收不是窟窿,重推不许把它当「丢了」抢走;
   *   ③ 回写 —— `WHERE verdict = @claim` 决出唯一赢家,输的一个副作用都不做。
   * 三层缺一层,实测的结果就是复核给出的那三个数字:验收调 2 次、OKR 写 2 条、token 记 100。
   */

  /** 装一副 OKR 打桩,返回 [收集数组, 还原函数](真 updateNode 会往仓库 data/ 里落文件) */
  function stubOkr() {
    const { OkrService } = require('../../services/okr-service');
    const orig = OkrService.prototype.updateNode;
    const calls = [];
    OkrService.prototype.updateNode = function (objectiveId, nodeId, updates) {
      calls.push(`${nodeId}:${updates.status}`);
      return { id: nodeId };
    };
    return [calls, () => { OkrService.prototype.updateNode = orig; }];
  }

  it('两个扫描者拿着同一份快照认领同一条 goal:只有一个赢,验收 / OKR / token 都只发生一次', async () => {
    // 复核实跑复现的就是这个形态:A、B 两个实例几乎同时 SELECT 出同一批占位值的 goal,
    // 然后各自 UPDATE 去认领。上一版的认领是
    //   `SET verdict = RESUMING WHERE verdict IN (PENDING, RESUMING)`
    // —— **写进去的值自己也在 WHERE 里**,已被 A 认领的行照样匹配 B 的条件,changes 恒为 1。
    // 于是 org-validator 被调 2 次、同一个 OKR 节点写 2 条、validation_tokens 记成两倍。
    const cid = newCompany();
    const settle = require('../../services/task-settle');
    const validation = require('../../services/goal-validation');
    const [okrCalls, restoreOkr] = stubOkr();
    const seen = [];
    validation.setValidateGoal(async (ctx) => {
      seen.push(ctx.goal.id);
      return { pass: true, verdict: '合起来对上了', tokens: 50 };
    });

    try {
      const goal = stuckGoal(cid);
      wdb.updateGoal(goal.id, { okrNodeId: 'O-096.1' });
      const snapshot = wdb.getGoal(goal.id).verdict; // 两个实例手里的那份快照

      // 实例 A:走完整条重推
      expect(await settle.resumePendingGoalValidations()).toBeGreaterThanOrEqual(1);

      // 实例 B:它的 SELECT 早于 A 的认领,手里还是那份旧快照 —— 跨进程时 inFlight 帮不上忙
      // (那是进程内的知识),守门的只有这一句 SQL 本身。
      const bClaim = wdb.claimGoalSlot(goal.id, 'verdict', {
        from: snapshot,
        to: `${settle.VERDICT_PENDING_PREFIX}instB-1`,
      });
      expect(bClaim).toBe(0); // 修之前这里是 1

      // B 认领不到,于是它这一轮什么都不做;再扫一次也只会看到 A 写下的真判词
      await settle.resumePendingGoalValidations();

      const row = wdb.getGoal(goal.id);
      expect(seen).toEqual([goal.id]);                              // org-validator 只起一次
      expect(okrCalls.filter((c) => c.startsWith('O-096.1'))).toEqual(['O-096.1:done']); // OKR 只写一条
      expect(row.validation_tokens).toBe(50);                        // token 不双记(不是 100)
      expect(row.verdict).toBe('合起来对上了');
      expect(row.reported_at).toBeTruthy();
    } finally {
      validation.setValidateGoal(null);
      restoreOkr();
    }
  });

  it('主路径验收在飞时撞上启动重推:本进程认得出「这条还活着」,不当窟窿抢走', async () => {
    // 上一版的判据是一句只对单进程成立的断言:「没有任何 in-flight 验收能活过一次重启,
    // 所以此刻挂着占位值的全是丢掉的」。pm2 cluster / 滚动重启下,B 起来时 A 还活着、
    // 验收还在飞 —— 那些 goal 的 verdict 正是占位值,会被整批当成「丢了」抢走。
    // 同进程这一半必须彻底堵死(跨进程那一半由回写决胜兜底,见下一条)。
    const cid = newCompany();
    const settle = require('../../services/task-settle');
    const validation = require('../../services/goal-validation');
    const [okrCalls, restoreOkr] = stubOkr();
    const seen = [];
    let release;
    validation.setValidateGoal(async (ctx) => {
      seen.push(ctx.goal.id);
      return new Promise((r) => { release = r; }); // 卡住,模拟分钟级的 LLM 调用
    });

    try {
      const goal = wdb.createGoal({ companyId: cid, title: '两件事', description: '两件事合起来才算数' });
      wdb.updateGoal(goal.id, { okrNodeId: 'O-094.1' });
      const a = wdb.createTask({ goalId: goal.id, companyId: cid, title: 'A', description: 'A' });
      const b = wdb.createTask({ goalId: goal.id, companyId: cid, title: 'B', description: 'B' });
      wdb.updateTask(a.id, { status: 'completed' });
      wdb.updateTask(b.id, { status: 'completed' });

      // 主路径:CAS → 认领占位判词 → 验收挂在 setImmediate 上
      expect(settle.settleGoalIfAllTasksDone(goal.id)).toBe('done');
      await waitFor(() => release, '主路径的验收本体被调起');
      const claimed = wdb.getGoal(goal.id).verdict;
      expect(settle.isPendingVerdict(claimed)).toBe(true);

      // 重推撞上来:这条还在飞,不许碰
      await settle.resumePendingGoalValidations();
      expect(seen).toEqual([goal.id]);                       // 没有第二次 org-validator
      expect(wdb.getGoal(goal.id).verdict).toBe(claimed);    // 占位 token 没被换掉

      release({ pass: true, verdict: '合起来对上了', tokens: 50 });
      await waitVerdict(goal.id);

      const row = wdb.getGoal(goal.id);
      expect(seen).toEqual([goal.id]);
      expect(row.validation_tokens).toBe(50);
      expect(okrCalls.filter((c) => c.startsWith('O-094.1'))).toEqual(['O-094.1:done']);

      // 收敛完再扫一遍:判词已定案、汇报已盖戳,连窟窿都不是了
      await settle.resumePendingGoalValidations();
      expect(seen).toEqual([goal.id]);
      expect(okrCalls.filter((c) => c.startsWith('O-094.1'))).toEqual(['O-094.1:done']);
    } finally {
      validation.setValidateGoal(null);
      restoreOkr();
    }
  });

  it('跨进程被抢走时靠回写决胜:输的那一方判词写不进去,也绝不汇报', async () => {
    // 跨进程判活要租约 + 心跳(另排),所以真有别的实例把在飞的这条抢走时,多烧的那一次
    // org-validator 是拦不住的。拦得住的是**副作用**:回写是 `WHERE verdict = @claim` 的
    // 条件更新,抢跑者换掉了 token,主路径的 changes = 0 —— 判词不覆盖、token 不记、不汇报。
    // 上一版这里是「getGoal 读一下 + updateGoal 写回去」的 check-then-act,读写之间隔着
    // 一整轮事件循环,跨进程根本不是原子的。
    const cid = newCompany();
    const settle = require('../../services/task-settle');
    const validation = require('../../services/goal-validation');
    const [okrCalls, restoreOkr] = stubOkr();
    const seen = [];
    let release;
    validation.setValidateGoal(async (ctx) => {
      seen.push(ctx.goal.id);
      if (seen.length === 1) return new Promise((r) => { release = r; });
      return { pass: false, verdict: '抢跑者的结论:少了退款流程', tokens: 30 };
    });

    try {
      const goal = wdb.createGoal({ companyId: cid, title: '两件事', description: '两件事合起来才算数' });
      wdb.updateGoal(goal.id, { okrNodeId: 'O-093.1' });
      const a = wdb.createTask({ goalId: goal.id, companyId: cid, title: 'A', description: 'A' });
      const b = wdb.createTask({ goalId: goal.id, companyId: cid, title: 'B', description: 'B' });
      wdb.updateTask(a.id, { status: 'completed' });
      wdb.updateTask(b.id, { status: 'completed' });

      expect(settle.settleGoalIfAllTasksDone(goal.id)).toBe('done');
      await waitFor(() => release, '主路径的验收本体被调起');
      const claimed = wdb.getGoal(goal.id).verdict;

      // 另一个实例把它抢走(它没有本进程的 inFlight 这份知识)
      const stolen = `${settle.VERDICT_PENDING_PREFIX}instB-2`;
      expect(wdb.claimGoalSlot(goal.id, 'verdict', { from: claimed, to: stolen })).toBe(1);

      // 主路径这才拿到结论 —— 回写必须整段落空
      release({ pass: true, verdict: '主路径的结论,不该落库', tokens: 50 });
      await new Promise((r) => setTimeout(r, 20));
      const mid = wdb.getGoal(goal.id);
      expect(mid.verdict).toBe(stolen);        // 没被覆盖
      // ⚠️ 第四轮改动:判词输了,但**账要记** —— 这一次 org-validator 是真跑了、token 是真烧了。
      // 原断言钉住的正是「输家的钱静默蒸发」那个缺陷本身(与 settleGoalPlanning 那条
      // 「没命中也补记账」的分支自相矛盾)。现在改成断言它被记上。
      expect(mid.validation_tokens).toBe(50);   // 输家的 token 现在记账(原为 0 = 静默蒸发)
      expect(mid.reported_at).toBeFalsy();     // **一次都没汇报**:汇报不可重放,宁可等抢跑者报
      expect(okrCalls.filter((c) => c.startsWith('O-093.1'))).toEqual([]);

      // 抢跑者跑完自己那一轮:这时候才轮到唯一的一次汇报
      await settle.resumePendingGoalValidations();
      const row = wdb.getGoal(goal.id);
      expect(row.verdict).toBe('抢跑者的结论:少了退款流程');
      // 总账 = 输家 50 + 赢家 30。两次 org-validator 是真跑了两次(跨进程抢跑拦不住多烧一次,
      // 设计已明写认账),拦住的是**不可重放的副作用**:判词只写一次、汇报只发一次。
      expect(row.validation_tokens).toBe(80);
      expect(row.status).toBe('failed');

      expect(okrCalls.filter((c) => c.startsWith('O-093.1'))).toEqual(['O-093.1:failed']);
    } finally {
      validation.setValidateGoal(null);
      restoreOkr();
    }
  });

  it('汇报丢了在库里查得出来,启动重推只补汇报、不重跑验收(半修残留)', async () => {
    // 判词一落库,这条 goal 就永远退出了**验收**重推的扫描面(那边只认占位判词),
    // 而紧接着的汇报原来是发射后不管的 fetch。进程在这几十毫秒到几秒里被杀、或端点回 5xx,
    // OKR 永不写、上级永不醒,**而库里这条 goal 长得和一次完美收敛一模一样**。
    // reported_at 就是把这两者分开的那一格。
    const cid = newCompany();
    const settle = require('../../services/task-settle');
    const validation = require('../../services/goal-validation');
    const seen = [];
    validation.setValidateGoal(async (ctx) => { seen.push(ctx.goal.id); return { pass: true, verdict: '不该被调到' }; });

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

    const origFetch = globalThis.fetch;
    const posted = [];
    let httpOk = false;
    globalThis.fetch = (url, init) => {
      posted.push(JSON.parse(init.body));
      return Promise.resolve({ ok: httpOk, status: httpOk ? 200 : 500 });
    };
    try {
      require('../../services/graph-run-sse')
        .transitionRun(body.runId, 'running', 'completed', {}, { summary: '用例直推终态' });
      await waitFor(() => posted.length >= 1, '第一次汇报打出去');
      // 等这次汇报**真的结束**(拿到 500 才算完)。还没拿到响应之前它是「在飞」而不是「窟窿」,
      // 重推会正确地跳过它 —— 这里模拟的是进程死后重启,那时候没有任何东西还在飞。
      await new Promise((r) => setTimeout(r, 10));

      // 可查性:这条 goal 只有 1 条 task(跳过验收 → verdict 恒为 NULL,那是合法形态),
      // 唯一能把「汇报丢了」认出来的就是 reported_at 上停着的占位值
      const row = wdb.getGoal(body.goalId);
      expect(row.status).toBe('done');
      expect(row.verdict).toBeFalsy();
      expect(String(row.reported_at).startsWith(settle.REPORT_PENDING_PREFIX)).toBe(true);

      // 把占位值**做旧**再重推 —— 上面那句注释说的就是这件事:「模拟的是进程死后重启」。
      // 认领带租约(CLAIM_LEASE_MS)之后,活着的认领不许抢、过期的才准抢;死掉的那个进程
      // 留下的占位值在重启时必然已经过期。不做旧就等于在模拟「另一个实例正在汇报」,
      // 那时候正确行为恰恰是**跳过**(第三轮复核实跑复现的跨进程重复汇报就是这么来的)。
      const stale = String(wdb.getGoal(body.goalId).reported_at).replace(/@\d+$/, `@${Date.now() - 3600_000}`);
      wdb.getDb().prepare('UPDATE goals SET reported_at = ? WHERE id = ?').run(stale, body.goalId);

      // 重推:补的是**汇报**那一步,一次 org-validator 都不该起(重跑验收 = 白烧一轮 LLM,
      // 还可能给出和库里那句不一样的判词)
      httpOk = true;
      await settle.resumePendingGoalValidations();
      expect(seen).toEqual([]);
      expect(posted).toHaveLength(2);
      expect(posted[1].taskId).toBe(body.goalId);
      expect(posted[1].status).toBe('done');
      // 报成了才盖真时间戳;从此这条不再是窟窿
      expect(wdb.getGoal(body.goalId).reported_at).toMatch(/^\d{4}-\d{2}-\d{2}T/);

      await settle.resumePendingGoalValidations();
      expect(posted).toHaveLength(2); // 绝不重复汇报
    } finally {
      globalThis.fetch = origFetch;
      validation.setValidateGoal(null);
    }
  });

  it('判词解析:取最后一个 JSON 块、认得 audit_verdict、读不出结论时返回 null', () => {
    const { extractAuditVerdict } = require('../../services/goal-validation');

    // 本体正文和分析过程里常常引用判决样例,从前往后取会认错那一份
    const twoBlocks = '前面举个例子:\n```json\n{"audit_verdict":"pass"}\n```\n结论:\n'
      + '```json\n{"audit_verdict":"fail","verdict":"少了退款流程"}\n```';
    expect(extractAuditVerdict(twoBlocks)).toEqual({ pass: false, verdict: '少了退款流程' });

    // JSON 写坏了(尾逗号)但结论还在原文里:捞结论、判词退回摘要,好过整次验收作废
    const broken = '```json\n{"audit_verdict": "pass", "verdict": "都对上了",}\n```';
    expect(extractAuditVerdict(broken).pass).toBe(true);

    // 连结论都没有 → null,调用方走保守收敛。绝不能默认判 fail
    expect(extractAuditVerdict('我觉得还行吧')).toBeNull();
    expect(extractAuditVerdict('')).toBeNull();
  });

  it('goal 级验收的提示词必须自带「不在任何 run 里」的覆盖声明,否则本体会照 run 级流程误杀(§四.10)', () => {
    // org-validator/agent.md 的六步工作流整个建立在 run 级寻址上(runs/<rootRunId>/、
    // acceptance.json、goal-contract.js run --run-id)。goal 级调用里那些路径一个都不存在,
    // 不明说它会按「契约不全 → input_underspecified」立刻判 fail —— 一次正常交付被误杀,
    // 而且理由看上去很有道理。这段声明是整条链路上最容易被人当客套话删掉的东西。
    const { buildGoalValidationPrompt } = require('../../services/goal-validation');
    const cid = newCompany();
    const goal = wdb.createGoal({ companyId: cid, title: '注册流程', description: '注册要能发验证码并校验' });
    const t1 = wdb.createTask({ goalId: goal.id, companyId: cid, title: '后端', description: '发验证码接口' });
    const t2 = wdb.createTask({ goalId: goal.id, companyId: cid, title: '前端', description: '表单校验验证码' });

    const empty = buildGoalValidationPrompt({ companyId: cid, goal, tasks: [t1, t2] });
    expect(empty).toContain('本次调用不在任何 graph run 里');
    expect(empty).toContain('input_underspecified'); // 点名那条误判路径,不是泛泛说「注意」
    expect(empty).toContain('注册要能发验证码并校验'); // ① 验收边界 = 需求原文
    expect(empty).toContain('发验证码接口'); // ② 每条 task 的描述
    // 写入边界段头出现在这里会翻转 PromptBuilder 的分叉(同 task-planning 那条)
    expect(empty).not.toContain('[写入边界');
    // 空索引是一条真信号而不是「这段就别写了」:不写出来,agent 会拿 task 描述当产出想象
    expect(empty).toContain('产物索引为空');

    // 有产物时给的必须是绝对路径:agent 的 cwd 在 system/agents/org-validator/{cid}/{goalId}/,
    // 裸相对路径它一个都打不开,而且不报错
    wdb.recordFile({ companyId: cid, taskId: t1.id, relPath: 'tasks/x/code/sms.js', kind: 'code' });
    const withFiles = buildGoalValidationPrompt({ companyId: cid, goal, tasks: [t1, t2] });
    expect(withFiles).not.toContain('产物索引为空');
    expect(withFiles).toMatch(/- \[task \d+\] code:\//);
  });

  it('并行收敛只有一个赢家:CAS 在验收之前,org-validator 恰好被调一次(§四.10)', async () => {
    // 上面那条验的是「验收跑完之后再推一次不会二次验收」,挡不住真正要防的那个形态:
    // N 条 task 在**同一个 tick 里**各自收敛,各自都算出「全都到终态了」。闸门放在验收之后的话
    // 这里会烧 N 份 org-validator 的钱,而其中 N-1 份的结论最后连库都写不进去。
    const cid = newCompany();
    const validation = require('../../services/goal-validation');
    const settle = require('../../services/task-settle');
    const seen = [];
    validation.setValidateGoal(async (ctx) => { seen.push(ctx.goal.id); return { pass: true, verdict: '两条合起来对上了', tokens: 5 }; });

    try {
      const goal = wdb.createGoal({ companyId: cid, title: '两件事', description: '两件事合起来才算数' });
      const a = wdb.createTask({ goalId: goal.id, companyId: cid, title: 'A', description: 'A' });
      const b = wdb.createTask({ goalId: goal.id, companyId: cid, title: 'B', description: 'B' });
      wdb.updateTask(a.id, { status: 'completed' });
      wdb.updateTask(b.id, { status: 'completed' });

      // 同步连调两次 = 两条 task 的收敛钩子挤在同一个 tick 上(真实形态就是这样:
      // 钩子是 transitionRun 尾巴上的同步调用,异步的只有验收本身)
      const r1 = settle.settleGoalIfAllTasksDone(goal.id);
      const r2 = settle.settleGoalIfAllTasksDone(goal.id);
      expect([r1, r2]).toEqual(['done', null]); // 'done' 是 CAS 占位值,验收判 fail 时会被改写

      await waitVerdict(goal.id);
      expect(seen).toEqual([goal.id]);
      expect(wdb.getGoal(goal.id).validation_tokens).toBe(5);
    } finally {
      validation.setValidateGoal(null);
    }
  });

  // ──────────────────────────────────────────────────────────────
  // 验收 19 / 22 · 拆分产出是草稿清单,人确认后才按依赖顺序跑
  // ──────────────────────────────────────────────────────────────

  /** 轮询到 goal 离开 planning(拆分是后台 setImmediate,202 先回) */
  async function waitSplitDone(goalId, tries = 200) {
    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('拆分产出全是 draft、可改可删,「全部下达」按依赖顺序一条条起(验收 19)', async () => {
    // 拆分不自动下达是这条的全部重点:产出是**草稿清单**,人看过改过才开跑。
    // 直接入队的话,一次拆错就是 N 条真跑起来的 run,每条都在烧钱。
    const cid = newCompany();
    const tp = require('../../services/task-planning');
    const validation = require('../../services/goal-validation');
    const sse = require('../../services/graph-run-sse');
    validation.setValidateGoal(async () => ({ pass: true, verdict: '合起来对上了', tokens: 3 }));
    tp.setPlanTasks(async () => ({
      specs: [
        { seq: 1, title: '后端', description: '实现发验证码的接口', depends_on: [] },
        { seq: 2, title: '前端', description: '注册表单接上验证码', depends_on: [1] },
        { seq: 3, title: '文档', description: '写接口文档', depends_on: [1] },
      ],
      tokens: 321,
    }));

    try {
      const [status, body] = await call('POST', `/api/company/${cid}/goals`, { description: '把注册流程重做一遍' });
      expect(status).toBe(202);
      const goalId = body.goalId;
      const g = await waitSplitDone(goalId);
      expect(g.planning_tokens).toBe(321); // 验收 21:goal 层的花费不再隐形

      let tasks = wdb.listGoalTasks(goalId);
      expect(tasks.map((t) => t.status)).toEqual(['draft', 'draft', 'draft']);
      expect(tasks.map((t) => t.current_run_id)).toEqual([null, null, null]); // 一条 run 都没起
      const [t1, t2, t3] = tasks;
      expect(JSON.parse(t2.depends_on)).toEqual([t1.id]);

      // 可改:draft 上 queued_at 还是空的,描述这类冻结字段随便改
      const [patched, view] = await call('PATCH', `/api/company/${cid}/tasks/${t2.id}`, {
        description: '注册表单接上验证码,并加倒计时',
      });
      expect(patched).toBe(200);
      expect(view.description).toContain('倒计时');

      // 可删:归档只是隐藏,行还在
      const [deleted] = await call('DELETE', `/api/company/${cid}/tasks/${t3.id}`);
      expect(deleted).toBe(200);
      expect(wdb.listGoalTasks(goalId).map((t) => t.id)).toEqual([t1.id, t2.id]);

      // 「全部下达」:批 3 起它把 goal 下**所有** draft 一起推进队列(§六.2),
      // 起跑的仍然是跑得动的那条 —— 依赖没就绪不再是拒绝的理由,是「先排着」
      const [d1, r1] = await call('POST', `/api/company/${cid}/goals/${goalId}/dispatch`);
      expect(d1).toBe(201);
      expect(r1.started.taskId).toBe(t1.id);
      // 两条都入了队;t2 没起跑,如实带着它的队列位次回来
      expect(r1.queued.map((q) => q.taskId).sort()).toEqual([t1.id, t2.id].sort());
      expect(r1.queued.find((q) => q.taskId === t2.id).queuePosition).toBe(1);
      expect(wdb.getTask(t2.id).status).toBe('queued');
      expect(wdb.getGoal(goalId).status).toBe('active'); // 首条下达 = goal 的描述冻结点

      // t1 没跑完之前 t2 起不来:现在挡它的是**依赖**(上游没 completed),
      // 而不是「公司已有任务在跑」那条已经被批 3 删掉的 409。报的是谁挡的它
      const [d2, e2] = await call('POST', `/api/company/${cid}/tasks/${t2.id}/start`);
      expect(d2).toBe(409);
      expect(e2.blockedBy).toEqual([t1.id]);

      sse.transitionRun(r1.started.runId, 'running', 'completed', {}, { summary: '用例直推终态' });
      expect(wdb.getTask(t1.id).status).toBe('completed');

      // 上游 completed 了,下游才轮到。manual 不参与自动出队,所以由人点「开始」
      const [d3, r3] = await call('POST', `/api/company/${cid}/tasks/${t2.id}/start`);
      expect(d3).toBe(201);
      expect(r3.started.taskId).toBe(t2.id);
      sse.transitionRun(r3.started.runId, 'running', 'completed', {}, { summary: '用例直推终态' });

      // 两条都完了 → goal 收敛,归档掉的那条不参与判定(它已经不在需求里了)
      await waitVerdict(goalId);
      expect(wdb.getGoal(goalId).status).toBe('done');
    } finally {
      tp.setPlanTasks(null);
      validation.setValidateGoal(null);
    }
  });

  it('存量 POST /:cid/tasks 与 split:false 一次 LLM 都不调(验收 22)', async () => {
    // 计数不能只数注入桩:链路绕过 planTasks 直接 new AgentManager 也一样是花钱,
    // 所以把最底下那层 callAgent 一并挡住 —— 它被调到就说明有人偷偷起了 CLI。
    const cid = newCompany();
    const AgentManager = require('../../src/core/agent-manager');
    const tp = require('../../services/task-planning');
    const origCall = AgentManager.prototype.callAgent;
    let llm = 0;
    let planned = 0;
    AgentManager.prototype.callAgent = async function stubCallAgent() {
      llm += 1;
      throw new Error('这条路径不该起 agent');
    };
    tp.setPlanTasks(async () => { planned += 1; throw new Error('不该被调到'); });

    try {
      const [a, ta] = await call('POST', `/api/company/${cid}/tasks`, { task: '把周报发出去' });
      expect(a).toBe(201);
      // 起完 run 再收掉,免得占着唯一索引让下一条 409(这里验的是花不花钱,不是并发约束)
      require('../../services/graph-run-sse')
        .transitionRun(ta.runId, 'running', 'completed', {}, { summary: '用例直推终态' });

      const [b] = await call('POST', `/api/company/${cid}/goals`, { description: '同步建的 goal', split: false });
      expect(b).toBe(201);

      await new Promise((r) => setImmediate(r)); // 给后台路径一个开口,真调了就会被数到
      expect({ llm, planned }).toEqual({ llm: 0, planned: 0 });
    } finally {
      AgentManager.prototype.callAgent = origCall;
      tp.setPlanTasks(null);
    }
  });

  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 那条路上被丢掉和被违反的两个约定
  // ──────────────────────────────────────────────────────────────

  /**
   * 只比「申报的额度」,剥掉 spent。
   *
   * spent 是**运行期账本**:run 一进 running,墙钟就往里写 active_ms / started_at
   * (见 budget-wallclock-active.test.js)。整个对象 deepEqual 会让这条用例在账本
   * 新增字段时红,而它守的是「用户填的额度没被静默换成默认值」—— 跟账本长什么样无关。
   * 账本本身另有断言:起跑那一刻还没花钱,agent_calls / tokens 必须仍是 0。
   */
  const limitsOf = (o) => { const { spent, ...limits } = typeof o === 'string' ? JSON.parse(o) : o; return limits; };
  const spentOf = (o) => (typeof o === 'string' ? JSON.parse(o) : o).spent || {};

  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, split: false });
    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,而没有任何地方说过预算被换掉了
    const dispatchedBudget = wdb.getRun(dispatched.started.runId).budget_json;
    expect(limitsOf(dispatchedBudget)).toEqual(limitsOf(budget));
    expect(spentOf(dispatchedBudget)).toMatchObject({ agent_calls: 0, tokens: 0 }); // 起跑那一刻还没花钱

    // 本次 dispatch 的 body 仍然覆盖草稿上的申报值(优先级:body > 草稿 > 默认)
    const cid2 = newCompany();
    const [, goal2] = await call('POST', `/api/company/${cid2}/goals`, { description: '同样带预算', budget, split: false });
    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(limitsOf(wdb.getRun(d2.started.runId).budget_json)).toEqual(limitsOf(override));
  });

  it('坑被占着时 POST /tasks 不再 409 而是入队,申报的 budget 一路带到出队(批 3 · 缺陷 F3 的现场)', async () => {
    // **这条用例本身就是批 3 的分界线。** 批 1/2 时它验的是「撞唯一索引 → 409 + 存成草稿 +
    // hint 让你稍后 dispatch」。批 3 把那条 409 删了:坑被占着不再是错误,是排队。
    // 保住的不变式没变 —— 用户填的 budget 必须一路带到真正起跑那一刻,
    // 不落库的话他只会看到任务跑到一半 blocked,而没有任何地方说过预算被换掉了。
    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 now = new Date().toISOString();
    // 先把这家公司唯一的执行坑占掉(一条 running 的 root run)
    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);

    const [status, body] = await call('POST', `/api/company/${cid}/tasks`, { task: '一件排在后面的活', budget });

    // 201,不是 409 —— 这就是「409 消失」的地方
    expect(status).toBe(201);
    expect(body.status).toBe('queued');
    expect(body.queuePosition).toBe(1); // 排第一位:告诉用户他在等什么,而不是让他猜
    const queuedTask = wdb.getTask(body.taskId);
    expect(queuedTask.status).toBe('queued');
    // 入队即建 run(queued 的 run 不占唯一索引的坑),budget 当场就挂在它身上
    const queuedRun = wdb.getRun(body.runId);
    expect(queuedRun.run_status).toBe('queued');
    expect(JSON.parse(queuedRun.budget_json)).toEqual(budget);
    // 申报值同样落在 task 行上:/enqueue 与 /rerun 读的是它
    expect(JSON.parse(queuedTask.budget_json)).toEqual(budget);

    // 前一条任务收敛,坑空出来 → 人点「开始」,起的还是同一条 run、同一份预算
    db.prepare(`UPDATE graph_runs SET run_status = 'completed' WHERE id = ?`).run(intruderId);
    const [ok, started] = await call('POST', `/api/company/${cid}/tasks/${body.taskId}/start`);
    expect(ok).toBe(201);
    expect(started.started.runId).toBe(body.runId); // 不是新起一条,是把排着的那条拉起来
    const runBudget = JSON.parse(wdb.getRun(body.runId).budget_json);
    expect(limitsOf(runBudget)).toEqual(limitsOf(budget));
    expect(spentOf(runBudget)).toMatchObject({ agent_calls: 0, tokens: 0 });
    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); // 跳过被挡住的部署,起构建
    // 批 3:被挡住的那条不再留在 draft 干等下一次 dispatch,而是**进队排着**
    expect(res1.queued.map((q) => q.taskId).sort()).toEqual([build.taskId, deploy.taskId].sort());
    expect(wdb.getTask(deploy.taskId).status).toBe('queued'); // 依赖仍是硬约束,一条都没抢跑

    // 构建跑完 → 部署的上游就绪,人点「开始」就轮到它(manual 不参与自动出队)
    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}/tasks/${deploy.taskId}/start`);
    expect(ok2).toBe(201);
    expect(res2.started.taskId).toBe(deploy.taskId);
  });

  it('一条都跑不动时不再 409,而是全部入队并就地推导成 blocked,级联一次算到底(批 3 · §五.1.2)', async () => {
    // 批 1/2 这里是 409「一条都跑不动」。批 3 把它换成了排队 + 依赖推导,理由是 409 之后
    // 那两条 task 仍是 draft,界面上和「还没下达」一模一样;而 blocked + blocked_reason
    // 说得出**是谁挡着它**,`⋯` 菜单据此给出「重跑上游 / 强制放行」两条出路。
    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 的上游,冒烟又等着部署
    wdb.updateTask(build.taskId, { status: 'failed' });

    const [ok, res] = await call('POST', `/api/company/${cid}/goals/${goal.goalId}/dispatch`);
    expect(ok).toBe(201);
    expect(res.started).toBeUndefined(); // 谁都跑不动,一条 run 都没起

    const { UPSTREAM_BLOCK_PREFIX } = wdb;
    // 部署:直接被 failed 的上游挡住
    const d = wdb.getTask(deploy.taskId);
    expect(d.status).toBe('blocked');
    expect(d.blocked_reason).toBe(`${UPSTREAM_BLOCK_PREFIX}${build.taskId}`);
    // 冒烟:级联一次算到底 —— blocked 同样不是 completed,所以它也永远等不到
    const sm = wdb.getTask(smoke.taskId);
    expect(sm.status).toBe('blocked');
    expect(sm.blocked_reason).toBe(`${UPSTREAM_BLOCK_PREFIX}${deploy.taskId}`);

    // **级联要解得开**:blocked 只是每轮 tick 推导出来的,不落成不可逆状态。
    // 上游恢复 → 下一次 tick 把两条依次放回队列(这是「注释承诺的防线真的存在」那条)
    wdb.updateTask(build.taskId, { status: 'completed' });
    require('../../services/task-scheduler').tick(cid);
    expect(wdb.getTask(deploy.taskId).status).toBe('queued');
    expect(wdb.getTask(deploy.taskId).blocked_reason).toBeNull();
    require('../../services/task-scheduler').tick(cid);
    expect(wdb.getTask(smoke.taskId).status).toBe('queued');
  });

  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 就完事
    // 另一条也入了队(批 3:dispatch 把 goal 下所有 draft 一起推进队列),排在第 1 位
    expect(res.queued.map((q) => q.taskId).sort()).toEqual([first.taskId, second.taskId].sort());
    expect(res.queued.find((q) => q.taskId === first.taskId).queuePosition).toBe(1);
  });

  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 —— 活跃 root run 是要占公司执行坑的(批 4 前还有一条同名唯一索引),
        // 而这里要的是 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['"]/);
    }
  });
});