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['"]/);
}
});
});