company-goal-task-routes.test.js
42.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
/**
* goal / task 两级模型的路由层(27-P3C §六,批 1)。
*
* 这里只验**不需要真起 run** 的那一半:列表 / 详情 / 改 / 归档,外加两条闸门的 HTTP 翻译
* (描述冻结 → 409、depends_on 非法 → 400 且库里不留脏数据)。起 run 的那条路要真公司 +
* 真 org graph + Claude CLI,属于 §八 验收清单里在浏览器上点的部分,不在单测射程内。
*
* 进度那条断言(2/3)是本文件的重点:验收 10 点名要挡的就是「列表页逐行查进度」的 N+1,
* 路由这边走的是 taskProgress 的一条 GROUP BY —— 结果对不对必须有人盯着,
* 否则改成逐行查也一样是绿的。
*/
const fs = require('fs');
const os = require('os');
const path = require('path');
const TEST_DB_PATH = '/tmp/test-company-goal-task-routes.sqlite';
const DB_FILES = [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`];
// 这几个模块在加载时快照 GRAPH_DB_PATH / GRAPH_RUNS_DIR,必须先设 env 再 require(见 §八 验收 12b)
const MODULES = ['../../services/graph-db', '../../services/work-graph-db', '../../routes/company'];
const CID = 'route-test-co';
let wdb;
let server;
let base;
let origin;
let origCompanyGet;
/** 一次请求 → [status, json](没有 supertest,express 起在随机端口上用 fetch 打) */
async function call(method, url, body) {
const r = await fetch(base + url, {
method,
headers: { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
return [r.status, await r.json()];
}
/** 同上,但公司 id 自己指定(验「打错公司 id → 404」那条要打到别的公司上) */
async function callCompany(cid, method, url) {
const r = await fetch(`${origin}/api/company/${cid}${url}`, { method });
return [r.status, await r.json()];
}
describe('company goal/task routes(27-P3C §六)', () => {
let goal; let t1; let t2;
beforeAll(async () => {
for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch { /* 本来就没有 */ } }
process.env.GRAPH_DB_PATH = TEST_DB_PATH;
// 产物根挪到临时目录:不设的话任何走到默认路径的写入都会往仓库 data/companies/ 里堆
process.env.GRAPH_RUNS_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'p3c-routes-'));
for (const m of MODULES) delete require.cache[require.resolve(m)];
wdb = require('../../services/work-graph-db');
// 公司实体:`company-service` 的 DATA_DIR 是硬编码的仓库 data/companies/,不认任何 env,
// 真建一家公司就是往用户仓库里落目录。这里只让 CID 这一家存在 —— 别的 id 一律 null,
// 「打错公司 id → 404」(缺陷 F9)那条断言要靠它。
const { CompanyService } = require('../../services/company-service');
origCompanyGet = CompanyService.prototype.get;
CompanyService.prototype.get = (companyId) =>
(companyId === CID ? { id: CID, name: CID, status: 'running' } : null);
const express = require('express');
const app = express();
app.use(express.json());
app.use('/api/company', require('../../routes/company'));
await new Promise((resolve) => {
server = app.listen(0, resolve);
});
origin = `http://127.0.0.1:${server.address().port}`;
base = `${origin}/api/company/${CID}`;
goal = wdb.createGoal({ companyId: CID, title: '做个官网', description: '做个官网,含首页和后台' });
t1 = wdb.createTask({ goalId: goal.id, title: '首页', description: '写首页' });
t2 = wdb.createTask({ goalId: goal.id, title: '后台', description: '写后台', dependsOn: [t1.id] });
// 给 t1 挂一条 run + 3 个节点行(2 success / 1 pending),用来验进度口径
const db = wdb.getDb();
const now = new Date().toISOString();
db.prepare(`INSERT INTO graph_runs (id, graph_kind, graph_id, company_id, root_run_id, run_status, created_at, updated_at)
VALUES ('run_rt','org',?,?, 'run_rt','running',?,?)`).run(CID, CID, now, now);
['success', 'success', 'pending'].forEach((status, i) => {
db.prepare(`INSERT INTO graph_run_nodes (id, run_id, def_node_id, status, created_at, updated_at)
VALUES (?, 'run_rt', ?, ?, ?, ?)`).run(`rn_rt${i}`, `d${i}`, status, now, now);
});
wdb.updateTask(t1.id, { currentRunId: 'run_rt', status: 'running' });
});
afterAll(() => {
if (server) server.close();
if (origCompanyGet) require('../../services/company-service').CompanyService.prototype.get = origCompanyGet;
for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch { /* 已经删了 */ } }
try { fs.rmSync(process.env.GRAPH_RUNS_DIR, { recursive: true, force: true }); } catch { /* ignore */ }
delete process.env.GRAPH_DB_PATH;
delete process.env.GRAPH_RUNS_DIR;
for (const m of MODULES) delete require.cache[require.resolve(m)];
});
it('GET /goals?withTasks=1 一次带出两级树,进度走一条 GROUP BY', async () => {
const [status, body] = await call('GET', '/goals?withTasks=1');
expect(status).toBe(200);
expect(body.goals).toHaveLength(1);
const tasks = body.goals[0].tasks;
expect(tasks.map((t) => t.taskId)).toEqual([t1.id, t2.id]); // 树的顺序是 seq,不是队列位次
expect(tasks[0].progress).toEqual({ done: 2, total: 3 });
// 没有 run 的 task 是 null 而不是 0/0:前端据此画「未开始」,画成 0/0 会像是「跑了但没进展」
expect(tasks[1].progress).toBeNull();
expect(tasks[1].dependsOn).toEqual([t1.id]);
});
it('GET /tasks 支持按 status 过滤,且默认不含已归档', async () => {
const [status, body] = await call('GET', '/tasks?status=running');
expect(status).toBe(200);
expect(body.tasks.map((t) => t.taskId)).toEqual([t1.id]);
});
it('GET /goals/:goalId 带出每条 task 的当前 run 摘要', async () => {
const [status, body] = await call('GET', `/goals/${goal.id}`);
expect(status).toBe(200);
expect(body.tasks[0].run).toMatchObject({ runId: 'run_rt', runStatus: 'running' });
expect(body.tasks[1].run).toBeNull();
});
it('不属于本公司 / 不存在的 goal 一律 404', async () => {
const [status] = await call('GET', '/goals/G-nope');
expect(status).toBe(404);
});
it('描述冻结:未下达可改,dispatched_at 一非空就 409 + hint(§四.4)', async () => {
const [ok, before] = await call('PATCH', `/goals/${goal.id}`, { description: '换个需求' });
expect(ok).toBe(200);
expect(before.description).toBe('换个需求');
wdb.updateGoal(goal.id, { dispatchedAt: new Date().toISOString() });
const [conflict, body] = await call('PATCH', `/goals/${goal.id}`, { description: '再换一次' });
expect(conflict).toBe(409);
expect(body.frozenFields).toEqual(['description']);
expect(body.hint).toBeTruthy(); // 「取消后新建」这句必须给出来,否则用户只知道被拒不知道该怎么办
// title 是列表上的标签、不进 agent 上下文,冻结之后仍可改
const [stillOk] = await call('PATCH', `/goals/${goal.id}`, { title: '改个名' });
expect(stillOk).toBe(200);
});
it('depends_on 非法 → 400,且库里不留半条脏数据(§六.3)', async () => {
const [status, body] = await call('PATCH', `/tasks/${t2.id}`, { depends_on: [t2.id] });
expect(status).toBe(400);
expect(body.error).toMatch(/依赖自己/);
expect(JSON.parse(wdb.getTask(t2.id).depends_on)).toEqual([t1.id]);
});
it('跑起来之后不许改依赖(判据在数据层,路由只负责翻成 409)', async () => {
const [status] = await call('PATCH', `/tasks/${t1.id}`, { depends_on: [] });
expect(status).toBe(409);
});
it('归档:正在跑的 task 拦下来并指出该先停哪条 run', async () => {
const [status, body] = await call('DELETE', `/goals/${goal.id}`);
expect(status).toBe(409);
expect(body.runningTasks).toEqual([t1.id]);
expect(body.hint).toContain('run_rt');
});
it('归档只是隐藏:默认列表不出现,includeArchived=1 查得回来', async () => {
const [status] = await call('DELETE', `/tasks/${t2.id}`);
expect(status).toBe(200);
const [, plain] = await call('GET', '/tasks');
expect(plain.tasks.map((t) => t.taskId)).toEqual([t1.id]);
const [, all] = await call('GET', '/tasks?includeArchived=1');
expect(all.tasks).toHaveLength(2);
});
it('OKR 的 /goals 别名已退役:这条路径下只剩 goal 两级模型(§六.1)', async () => {
// 别名还在的话,GET /goals 会被 OkrService 接走、返回 objective 数组而不是 { goals: [...] }
const [status, body] = await call('GET', '/goals');
expect(status).toBe(200);
expect(Array.isArray(body.goals)).toBe(true);
expect(body.goals[0].goalId).toBe(goal.id);
});
// ──────────────────────────────────────────────────────────────
// 复核缺陷 F3 / F4 / F5 / F7 / F9
// ──────────────────────────────────────────────────────────────
it('打错公司 id 的两个新 GET 端点回 404,不拿空列表冒充「没有目标」(缺陷 F9)', async () => {
for (const p of ['/goals', '/tasks']) {
const [status, body] = await callCompany('route-test-c0', 'GET', p);
expect(status).toBe(404);
expect(body.error).toMatch(/不存在/);
}
});
it('建 goal 时 tasks[].depends_on 可按 seq 引用同批兄弟,响应回真 id(缺陷 F5)', async () => {
const [status, body] = await call('POST', '/goals', {
description: '上线一个服务',
tasks: [
{ description: '构建镜像' },
{ description: '部署到测试环境', depends_on: [1] }, // 1 = 本次第 1 条的 seq
],
});
expect(status).toBe(201);
const [build, deploy] = body.tasks;
expect(deploy.seq).toBe(2);
// 翻译成真 id 才算数:回 [1] 或 [] 都会让批 3 的调度器把这条依赖当不存在
expect(deploy.dependsOn).toEqual([build.taskId]);
expect(JSON.parse(wdb.getTask(deploy.taskId).depends_on)).toEqual([build.taskId]);
// 环仍然要被数据层挡住(seq 引用不是绕过校验的后门)
const [cyclic, err] = await call('POST', '/goals', {
description: '互相依赖',
tasks: [
{ description: 'A', depends_on: [2] },
{ description: 'B', depends_on: [1] },
],
});
expect(cyclic).toBe(400);
expect(err.error).toMatch(/环/);
});
it('tasks[k] 非法 → 400,且 goals / tasks 两张表都不留残行(缺陷 F4)', async () => {
const before = wdb.listCompanyGoals(CID, { includeArchived: true }).length;
// task 也要数:只数 goal 的话,「goal 回滚了但第 0 条 task 已提交」这种半回滚是绿的
const beforeTasks = wdb.listCompanyTasks(CID, { includeArchived: true }).length;
const [status, body] = await call('POST', '/goals', {
description: '两条活,第二条写错了',
tasks: [{ description: '合法的第一条' }, { description: '第二条', mode: 'AUTO' }],
});
expect(status).toBe(400);
expect(body.error).toMatch(/tasks\[1\]\.mode/);
// 修之前:goal 已提交、第 0 条 task 已 INSERT,客户端改完重发就是第二个 goal,
// 第一个孤儿 goal 带着孤儿 task 永远躺在列表里
expect(wdb.listCompanyGoals(CID, { includeArchived: true }).length).toBe(before);
expect(wdb.listCompanyTasks(CID, { includeArchived: true }).length).toBe(beforeTasks);
});
it('建 goal 时申报的 budget 落库到 task 行(缺陷 F3 的写侧)', async () => {
const budget = { total_agent_calls: 7, total_tokens: 123456 };
const [status, body] = await call('POST', '/goals', { description: '带预算的需求', budget, split: false });
expect(status).toBe(201);
// 只有写侧没有读侧等于没写;读侧(dispatch 起 run 时用它)在 goal-task-acceptance.test.js 里验
expect(JSON.parse(wdb.getTask(body.tasks[0].taskId).budget_json)).toEqual(budget);
// 也要回得出来:查不到自己填的额度,用户就只能等任务 blocked 才发现不对
expect(body.tasks[0].budget).toEqual(budget);
});
it('PATCH 的 mode 走同一道信任边界:非法值 400 而不是 500 泄 SQLite 原文(缺陷 F7)', async () => {
const [, created] = await call('POST', '/goals', { description: '改 mode 用的 goal', split: false });
const taskId = created.tasks[0].taskId;
const [bad, err] = await call('PATCH', `/tasks/${taskId}`, { mode: 'AUTO' });
expect(bad).toBe(400); // 修之前:直送 DB 撞 CHECK 约束 → 500 + "CHECK constraint failed: …"
expect(err.error).toMatch(/manual/);
// null 既不是「没传」也不是合法值:修之前被 `!== undefined` 过滤掉 → 200 但什么都没改
const [nulled] = await call('PATCH', `/tasks/${taskId}`, { mode: null });
expect(nulled).toBe(400);
const [ok, patched] = await call('PATCH', `/tasks/${taskId}`, { mode: 'auto' });
expect(ok).toBe(200);
expect(patched.mode).toBe('auto');
});
it('goal 级 mode 是每条 task 的兜底,task 自己写的优先(三级继承,本轮复核缺陷 2)', async () => {
const [status, body] = await call('POST', '/goals', {
description: '整批都要自己跑的需求',
mode: 'auto',
split: false,
tasks: [{ description: '继承 goal 的 auto' }, { description: '自己压回手动', mode: 'manual' }],
});
expect(status).toBe(201);
// 继承这一层是批 2 加的(此前显式 tasks[] 恒 manual),是**行为变化**,所以必须有用例钉住:
// 没有它,谁把这行改回 `'manual'` 都照样全绿。保留继承的理由是反过来更荒谬 ——
// 用户在表单上把整个 goal 选成 auto,建出来的 task 却一条都不会自己跑。
expect(body.tasks.map((t) => t.mode)).toEqual(['auto', 'manual']);
// 三级都没给才落到最终兜底 manual(别让「默认自动跑」这种事悄悄发生)
const [, plain] = await call('POST', '/goals', {
description: '没声明 mode 的需求',
split: false,
tasks: [{ description: '一件事' }],
});
expect(plain.tasks[0].mode).toBe('manual');
});
// ═══════════════════════════════════════
// 批 2 · 自动拆分(27-P3C §四.9)
// ═══════════════════════════════════════
//
// 拆分本身是一次真 LLM 调用,单测一律走 setPlanTasks 注入 —— 真起 Claude CLI 既慢又要钱,
// 而且产出不确定,测出来的绿是运气。这里验的是**链路**:状态怎么推、失败怎么降级、账怎么记。
const tp = () => require('../../services/task-planning');
/** 轮询到 goal 离开 planning 为止(拆分是后台 setImmediate,响应先回) */
async function waitSplitDone(goalId, tries = 100) {
for (let i = 0; i < tries; i += 1) {
const g = wdb.getGoal(goalId);
if (g && g.status !== 'planning') return g;
await new Promise((r) => setTimeout(r, 10));
}
throw new Error(`goal ${goalId} 一直停在 planning —— 这正是 §四.9 约束 2 要消灭的那个卡死态`);
}
it('默认 split:true → 202 + planning,后台拆完落 N 条 draft task 并记 planning_tokens', async () => {
const seen = [];
tp().setPlanTasks(async (ctx) => {
seen.push(ctx.goal.id);
return {
specs: [
{ seq: 1, title: '写后端', description: '实现后端接口', depends_on: [] },
{ seq: 2, title: '写教程', description: '基于后端接口写上手教程', depends_on: [1] },
],
tokens: 4242,
};
});
try {
const [status, body] = await call('POST', '/goals', { description: '做个工具,含后端和教程' });
// 202 而不是 201:这次调用**没有**创造出完整的资源,task 还在路上
expect(status).toBe(202);
expect(body.split).toBe(true);
expect(body.status).toBe('planning');
expect(body.tasks).toEqual([]); // 空数组而不是缺字段:调用方才分得清「还没拆」和「拆出 0 条」
const g = await waitSplitDone(body.goalId);
expect(seen).toEqual([body.goalId]);
expect(g.status).toBe('draft'); // 产出一律 draft,不自动入队(§四.9)
expect(g.planning_tokens).toBe(4242); // 不记这一笔,goal 层的花费就是隐形的
expect(g.planning_error).toBeNull();
const tasks = wdb.listGoalTasks(body.goalId);
expect(tasks.map((t) => t.status)).toEqual(['draft', 'draft']);
expect(tasks.map((t) => t.created_by)).toEqual(['task-planning', 'task-planning']);
// seq 引用必须翻成真 id:回 [1] 或 [] 都会让批 3 的调度器把这条依赖当不存在
expect(JSON.parse(tasks[1].depends_on)).toEqual([tasks[0].id]);
} finally {
tp().setPlanTasks(null);
}
});
it('三轮拆不出来 → 降级成 1 条同描述 task + planning_error,绝不卡在 planning(验收 20)', async () => {
tp().setPlanTasks(async () => {
const e = new Error('task-planning 连续 3 轮未产出合法任务 DAG');
e.tokens = 999; // 失败的那几轮是真花了钱的
throw e;
});
try {
const [status, body] = await call('POST', '/goals', { description: '一个拆不动的需求' });
expect(status).toBe(202);
const g = await waitSplitDone(body.goalId);
expect(g.status).toBe('draft');
expect(g.planning_error).toMatch(/3 轮/);
expect(g.planning_tokens).toBe(999); // 抛出路径上的 token 一样要记账
const tasks = wdb.listGoalTasks(body.goalId);
expect(tasks).toHaveLength(1);
expect(tasks[0].description).toBe('一个拆不动的需求'); // 同描述
expect(tasks[0].seq).toBe(1);
expect(JSON.parse(tasks[0].depends_on)).toEqual([]);
// 降级出来的这条不是它拆的,记成 task-planning 会让「拆分质量」统计从一开始就是脏的
expect(tasks[0].created_by).toBe('human');
// 界面要看得见:goalView 得把这个标记透出去,否则降级后的 goal 与「本来就一条」无从区分
const [, view] = await call('GET', `/goals/${body.goalId}`);
expect(view.planningError).toMatch(/3 轮/);
} finally {
tp().setPlanTasks(null);
}
});
it('split:false 与显式 tasks 都不调 agent —— 存量调用方不该突然多花一次 LLM 调用', async () => {
let called = 0;
tp().setPlanTasks(async () => { called += 1; throw new Error('不该被调到'); });
try {
const [a] = await call('POST', '/goals', { description: '同步建的 goal', split: false });
expect(a).toBe(201);
const [b] = await call('POST', '/goals', {
description: '自带拆分结果',
tasks: [{ description: '第一件' }, { description: '第二件', depends_on: [1] }],
});
expect(b).toBe(201); // 传了 tasks = 拆分结果已经在请求体里,再调一次纯浪费
expect(called).toBe(0);
} finally {
tp().setPlanTasks(null);
}
});
it('写入前校验:rule 取值域与本体 agent.md 那张「被打回来怎么办」的表一致', () => {
const { validatePlannedTasks, extractPlannedTasks } = tp();
const ok = validatePlannedTasks([{ seq: 1, title: 'a', description: 'A', depends_on: [] }]);
expect(ok.ok).toBe(true);
expect(ok.specs[0]).toEqual({ seq: 1, title: 'a', description: 'A', depends_on: [] });
const rules = (raw) => validatePlannedTasks(raw).errors.map((e) => e.rule);
// seq 与数组顺序错位:错位后 depends_on 引的是另一条 task,而那条错误依赖完全合法,
// 落库后没有任何人再能发现它 —— 所以必须在这里挡住
expect(rules([{ seq: 2, description: 'A', depends_on: [] }])).toContain('schema');
expect(rules([{ seq: 1, description: 'A', depends_on: ['T-abc'] }])).toContain('reference');
expect(rules([{ seq: 1, description: 'A', depends_on: [1] }])).toContain('cycle');
expect(rules([
{ seq: 1, description: 'A', depends_on: [2] },
{ seq: 2, description: 'B', depends_on: [1] },
])).toContain('cycle');
expect(rules(Array.from({ length: 9 }, (_, i) => ({ seq: i + 1, description: 'x', depends_on: [] }))))
.toContain('limit');
expect(rules([{ seq: 1, description: 'A', depends_on: [], mode: 'auto' }])).toContain('schema');
// 正向引用(第 1 条依赖第 2 条)不成环,是合法输入 —— 批 1 缺陷 4 就是被它咬的
expect(validatePlannedTasks([
{ seq: 1, description: 'A', depends_on: [2] },
{ seq: 2, description: 'B', depends_on: [] },
]).ok).toBe(true);
// 取**最后一个** JSON 块:正文里举的反例常常也是合法 JSON,从前往后会认错那一份
const out = '反例:```json\n[{"seq":1,"description":"错的"}]\n```\n最终产出:\n```json\n[{"seq":1,"title":"t","description":"对的","depends_on":[]}]\n```';
expect(extractPlannedTasks(out)[0].description).toBe('对的');
expect(extractPlannedTasks('什么都没有')).toBeNull();
});
// ═══════════════════════════════════════
// 批 2 · 默认实现本身(不走 setPlanTasks 注入)
// ═══════════════════════════════════════
//
// 上面几条全用注入桩验「链路」,代价是**默认实现整段被跳过**:轮次上限、errors 回喂、
// token 累加这三件事只活在 defaultPlanTasks 里,注入桩绿了它们照样可以是坏的。
// 所以这几条只把最底下那次 `AgentManager.callAgent` 换掉,中间的循环真跑。
/**
* 换掉 `AgentManager.callAgent`(绝不真起 Claude CLI),顺手挡住 `CompanyService.hire`:
* `resolvePlanningAgentName` 查不到雇佣记录就会按需雇一次,而 hire 会往用户仓库
* `data/companies/` 里落目录 —— 单测不该在别人的工作区里留东西。
*
* @param {(string|Error)[]} outputs 逐轮的假回复;轮数超出时一直用最后一条
* @returns {{prompts: string[], metas: object[], restore: () => void}}
*/
function stubAgent(outputs, usage = { input: 100, output: 20 }) {
const AgentManager = require('../../src/core/agent-manager');
const { CompanyService } = require('../../services/company-service');
const origCall = AgentManager.prototype.callAgent;
const origHire = CompanyService.prototype.hire;
const prompts = [];
const metas = [];
AgentManager.prototype.callAgent = async function stubCallAgent(agentName, prompt, options) {
prompts.push(prompt);
metas.push({ agentName, metadata: (options && options.metadata) || null });
// usage 只有这一条来源(`session/result`),planning_tokens 的账全靠它
if (options && options.onAcpEvent) options.onAcpEvent({ method: 'session/result', params: { usage } });
const out = outputs[Math.min(prompts.length - 1, outputs.length - 1)];
if (out instanceof Error) throw out;
return { output: out };
};
CompanyService.prototype.hire = () => ({ status: 'stubbed-by-test' });
return {
prompts,
metas,
restore() {
AgentManager.prototype.callAgent = origCall;
CompanyService.prototype.hire = origHire;
},
};
}
it('默认实现:连续 3 轮非法产出 → 轮次用满、errors 原样回喂、已烧 token 挂在 error 上(验收 20 / 21)', async () => {
const stub = stubAgent(['我觉得可以分三步做,但我不打算写 JSON']);
try {
const goal = wdb.createGoal({ companyId: CID, title: '拆不动', description: '一个拆不动的需求' });
let err = null;
try {
await tp().planTasks({ companyId: CID, goal });
} catch (e) {
err = e;
}
expect(err).toBeTruthy();
expect(err.message).toMatch(/连续 3 轮/);
expect(stub.prompts).toHaveLength(3); // 不是 1 轮就放弃,也不是无限重试到把钱烧光
expect(err.planErrors.map((x) => x.rule)).toEqual(['output']);
// 抛出路径上的 token 一样是真花掉的。不挂在 error 上,这半边花费在账上直接蒸发
expect(err.tokens).toBe(3 * 120);
// 第 1 轮不带回喂段;第 2 轮起必须把上一轮的 errors 原样贴回去 —— 不贴的话
// agent 不知道错在哪,三轮就是同一个错误重复三次,等于只试了一轮还花了三份钱
expect(stub.prompts[0]).not.toContain('上一轮没通过');
expect(stub.prompts[1]).toContain('上一轮没通过');
expect(stub.prompts[1]).toContain('"rule": "output"'); // rule 名要能对上 agent.md 那张表
expect(stub.prompts[2]).toContain('第 3 轮');
// agent.md Step 1 点名的三项输入,一项都不能少(缺哪项它就自己编哪项)
for (const h of ['① goal 描述', '② 公司 org graph 摘要', '③ 可用角色清单']) {
expect(stub.prompts[0]).toContain(h);
}
// 写入边界段头是 PromptBuilder 判分叉的**精确字面量**:出现在任务描述里会让坐标声明
// 不再给交付物归宿,而本体正文里的 $W 正是从那一行读的 —— 两边同时落空且不报错
expect(stub.prompts[0]).not.toContain('[写入边界');
// taskId 是运行态目录的第三层键。不传的话所有公司所有拆分叠进 _global/_no-task 一个坑
expect(stub.metas[0].metadata.taskId).toBe(goal.id);
expect(stub.metas[0].agentName).toBe('task-planning'); // 没雇过就用裸名,不该拼出脏地址
} finally {
stub.restore();
}
});
it('默认实现拆不出来时,POST /goals 端到端照样把 goal 推离 planning 并记账(验收 20)', async () => {
const stub = stubAgent(['没有 JSON,只有废话']);
try {
const [status, body] = await call('POST', '/goals', { description: '端到端拆不动的需求' });
expect(status).toBe(202);
const g = await waitSplitDone(body.goalId);
expect(g.status).toBe('draft'); // 绝不卡在 planning —— 那是个没人会把它推走的死态
expect(g.planning_error).toMatch(/连续 3 轮/);
expect(g.planning_tokens).toBe(3 * 120);
const tasks = wdb.listGoalTasks(body.goalId);
expect(tasks).toHaveLength(1);
expect(tasks[0].description).toBe('端到端拆不动的需求');
expect(tasks[0].created_by).toBe('human'); // 这条不是它拆的
} finally {
stub.restore();
}
});
it('默认实现:第 1 轮 seq 错位被打回、第 2 轮改对 → 只调 2 次,被打回那轮的 token 照样记账', async () => {
// 这条守的是「回喂到底有没有用」:只验失败路径的话,一个永远返回同一份 errors 的实现也是绿的。
const bad = '```json\n[{"seq":2,"title":"a","description":"A","depends_on":[]}]\n```';
const good = '最终产出:\n```json\n[{"seq":1,"title":"a","description":"A","depends_on":[]},'
+ '{"seq":2,"title":"b","description":"B","depends_on":[1]}]\n```';
const stub = stubAgent([bad, good]);
try {
const [status, body] = await call('POST', '/goals', { description: '第二轮才交对的需求' });
expect(status).toBe(202);
const g = await waitSplitDone(body.goalId);
expect(stub.prompts).toHaveLength(2); // 过了就停,不会把三轮跑满
expect(stub.prompts[1]).toContain('"rule": "schema"'); // seq 与数组下标错位
expect(g.planning_error).toBeNull();
expect(g.planning_tokens).toBe(2 * 120); // 被打回的那一轮一样花了钱
const tasks = wdb.listGoalTasks(body.goalId);
expect(tasks.map((t) => t.created_by)).toEqual(['task-planning', 'task-planning']);
expect(JSON.parse(tasks[1].depends_on)).toEqual([tasks[0].id]); // seq 引用翻成了真 id
} finally {
stub.restore();
}
});
// ═══════════════════════════════════════
// 批 2 · 对抗复核缺陷 1 / 4 / 5 / 6:拆分那几十秒里 goal 被别人动了怎么办
// ═══════════════════════════════════════
//
// 这四条的共同现场是同一个:`planning` 是个**真会持续几十秒**的状态,而这期间 goal
// 完全可能被别的请求推走(表单自己推荐的逃生口、左栏的归档)。此前拆分收工是一条
// **无条件** UPDATE,谁最后写谁说了算 —— 而最后写的恰好是那个已经过时了几十秒的后台任务。
/** 一个能由用例决定何时放行的假拆分:拿到 gate 才返回,好在拆分「进行中」时做手脚 */
function gatedPlan(result) {
let release;
const gate = new Promise((r) => { release = r; });
tp().setPlanTasks(async () => {
await gate;
if (result instanceof Error) throw result;
return result;
});
return release;
}
/** 轮询到 planning_tokens 记上为止(放弃落库那条路不改 status,waitSplitDone 等不到) */
async function waitTokens(goalId, tokens, tries = 100) {
for (let i = 0; i < tries; i += 1) {
const g = wdb.getGoal(goalId);
if (g && g.planning_tokens === tokens) return g;
await new Promise((r) => setTimeout(r, 10));
}
throw new Error(`goal ${goalId} 的 planning_tokens 一直没记上(当前 ${wdb.getGoal(goalId).planning_tokens})`);
}
it('拆分期间走了逃生口 → 收工时放弃落库,不把正在跑的 goal 写回草稿(缺陷 1)', async () => {
const release = gatedPlan({
specs: [
{ seq: 1, title: '孤儿一', description: '不该被插进去的一', depends_on: [] },
{ seq: 2, title: '孤儿二', description: '不该被插进去的二', depends_on: [1] },
],
tokens: 777,
});
try {
const [status, body] = await call('POST', '/goals', { description: '一个会被抢走的需求' });
expect(status).toBe(202);
const gid = body.goalId;
// 逃生口 `POST /:cid/tasks {goal_id}` 的两步(这里不真起 run:那要真 org graph,
// 见文件头。抢走 goal 的是 markGoalDispatched 这一句,run 起没起不改变判据)
const escaped = wdb.createTask({ goalId: gid, title: '单任务', description: '一个会被抢走的需求' });
wdb.updateGoal(gid, { status: 'active', dispatchedAt: new Date().toISOString() });
release();
const g = await waitTokens(gid, 777);
// 写回 draft = 一个正在烧钱跑 run 的 goal 在界面上变成「草稿」,
// 而多插的那 N 条 draft 让 settleGoalIfAllTasksDone 永远看不到「全部终态」→ 永不收敛
expect(g.status).toBe('active');
expect(g.dispatched_at).toBeTruthy();
expect(g.planning_error).toBeNull(); // 拆分没失败,别在界面上说它失败了
expect(wdb.listGoalTasks(gid).map((t) => t.id)).toEqual([escaped.id]); // 一条孤儿都没留
} finally {
tp().setPlanTasks(null);
}
});
it('降级用的是收工那一刻的描述,不是拆分开始时的旧快照(本轮复核缺陷 1)', async () => {
const err = new Error('task-planning 连续 3 轮未产出合法任务 DAG');
err.tokens = 21;
const release = gatedPlan(err);
try {
const [status, body] = await call('POST', '/goals', { description: '旧的需求原文' });
expect(status).toBe(202);
const gid = body.goalId;
// 拆分期间改 description 是**被允许的**:goal 还在 planning、dispatched_at 还空,
// 冻结规则(§四.4)此刻还没生效,所以这是一条正常用户路径,不是竞态滥用
const [pt, patched] = await call('PATCH', `/goals/${gid}`, { description: '改过的需求原文' });
expect(pt).toBe(200);
expect(patched.description).toBe('改过的需求原文');
release();
const g = await waitSplitDone(gid);
expect(g.description).toBe('改过的需求原文');
// 用函数顶部那份旧快照的话:goal 上写着新需求、它唯一的那条 task 写着旧需求,
// 而两段文字都是验收边界(org-validator 判的就是这段描述)—— 一份目标两个口径
expect(wdb.listGoalTasks(gid).map((t) => t.description)).toEqual(['改过的需求原文']);
} finally {
tp().setPlanTasks(null);
}
});
it('拆分产出的 seq 以 goal 现有最大号为基准偏移,依赖引用跟着偏移(缺陷 4)', async () => {
const release = gatedPlan({
specs: [
{ seq: 1, title: '甲', description: '先做甲', depends_on: [] },
{ seq: 2, title: '乙', description: '基于甲做乙', depends_on: [1] },
],
tokens: 5,
});
try {
const [, body] = await call('POST', '/goals', { description: '会撞号的需求' });
const gid = body.goalId;
// 逃生口撞上唯一索引那条路:task 建出来当草稿,但 409 在 markGoalDispatched 之前就返回了 ——
// goal **仍停在 planning**,所以拆分收工时条件更新会命中,产出照插。seq 就是在这里撞的。
const first = wdb.createTask({ goalId: gid, title: '先手', description: '先手任务' });
expect(first.seq).toBe(1);
release();
const g = await waitSplitDone(gid);
expect(g.status).toBe('draft');
const tasks = wdb.listGoalTasks(gid);
// 1,1,2 的话:列表里两行都显示 #1,依赖 chip 的「依赖 #1」有两个同名候选,
// dispatch 挑「第一条跑得动的 draft」顺序不定 —— 不报错,只是从此对不上号
expect(tasks.map((t) => t.seq)).toEqual([1, 2, 3]);
const jia = tasks.find((t) => t.seq === 2);
const yi = tasks.find((t) => t.seq === 3);
expect(yi.title).toBe('乙');
// 翻译表用的必须是偏移后的号:用原号会翻不出来,depends_on 里留一个字面量 "1"
expect(JSON.parse(yi.depends_on)).toEqual([jia.id]);
expect(JSON.parse(tasks[0].depends_on)).toEqual([]);
} finally {
tp().setPlanTasks(null);
}
});
it('拆分期间不许归档:DELETE 直接 409,硬归档时收工也不插孤儿(缺陷 5)', async () => {
const release = gatedPlan({
specs: [{ seq: 1, title: '孤儿', description: '归档后不该被插进去', depends_on: [] }],
tokens: 42,
});
try {
const [, body] = await call('POST', '/goals', { description: '会被归档的需求' });
const gid = body.goalId;
// 此刻 task 列表是空的,「有 task 攥着 run 就 409」那道闸门必过 —— 所以要单独挡
const [st, err] = await call('DELETE', `/goals/${gid}`);
expect(st).toBe(409);
expect(err.error).toMatch(/正在自动拆分/);
expect(err.hint).toMatch(/逃生口/);
// 端点挡住了,但库里那一行仍可能被别的写入方盖上 archived_at(归档不改 status)。
// 条件更新的 `archived_at IS NULL` 是第二道:插进去的 task 会归属一个左栏看不见的 goal。
wdb.updateGoal(gid, { archivedAt: new Date().toISOString() });
release();
await waitTokens(gid, 42); // 账照记:那几轮 LLM 是真花了钱的
expect(wdb.listGoalTasks(gid, { includeArchived: true })).toEqual([]);
} finally {
tp().setPlanTasks(null);
}
});
it('拆分超过总时限 → 按降级路径收工,绝不卡在 planning(缺陷 6 / 验收 20)', async () => {
const orig = process.env.GOAL_SPLIT_TIMEOUT_MS;
process.env.GOAL_SPLIT_TIMEOUT_MS = '30';
// 永不 resolve = 真实世界里那条「callAgent 的 idle timeout 30 分钟 × 3 轮」的路:
// 只要还有流数据进来,idle timer 永远不触发,服务端此前对此没有任何时限
tp().setPlanTasks(() => new Promise(() => {}));
try {
const [status, body] = await call('POST', '/goals', { description: '一个拆到天荒地老的需求' });
expect(status).toBe(202);
const g = await waitSplitDone(body.goalId);
expect(g.status).toBe('draft');
expect(g.planning_error).toMatch(/总时限/);
const tasks = wdb.listGoalTasks(body.goalId);
expect(tasks.map((t) => t.description)).toEqual(['一个拆到天荒地老的需求']);
// 降级出来的这条不是 task-planning 拆的,记成它的产出会让「拆分质量」统计从一开始就是脏的
expect(tasks[0].created_by).toBe('human');
} finally {
if (orig === undefined) delete process.env.GOAL_SPLIT_TIMEOUT_MS;
else process.env.GOAL_SPLIT_TIMEOUT_MS = orig;
tp().setPlanTasks(null);
}
});
it('task-planning 本体过得了毕业闸门,不会停在 draft(§七 / 验收 24)', () => {
const { missingBodyDefinition } = require('../../services/agent-locator');
const { isUngraduatedStatus, isNonOrgAgentType } = require('../../services/graph-service');
const bodyRoot = path.resolve(__dirname, '../../../system/agents');
const cfg = JSON.parse(fs.readFileSync(path.join(bodyRoot, 'task-planning/config.json'), 'utf-8'));
// 「缺」有三种形态:读不到 / 空白 / 仍带脚手架哨兵。少一份 soul.md 本体就永远停在 draft,
// 而 hire() 报的理由是 body_not_ready —— 症状离原因很远,只能靠这里挡
expect(missingBodyDefinition('task-planning')).toEqual([]);
// status 不是封闭取值域,判据的唯一持有者是 isUngraduatedStatus
//(自己写 `!== 'active'` 会把库里 36 个合法本体全判成没毕业,且报的理由还是错的)
expect(isUngraduatedStatus(cfg.status)).toBe(false);
// meta-planning 命中 NON_ORG_AGENT_TYPES = 永不作为 org 节点混进用户的组织图
expect(isNonOrgAgentType(cfg.type)).toBe(true);
// seedAgents 扫的是目录(不以 _/. 开头 + 有 agent.md),registry 只做 enrich;
// 但 §七 要求登记,缺了它 benchmark_score 一列永远是 null 且没人知道为什么
const registry = JSON.parse(fs.readFileSync(path.join(bodyRoot, '_registry.json'), 'utf-8'));
expect(registry.some((e) => e && e.id === 'task-planning')).toBe(true);
});
// ──────────────────────────────────────────────────────────────
// 批 3 对抗复核 F1:blocked 的任务必须有走得通的出路
//
// 这四条盯的是同一件事的四个面:改完依赖有没有**当场**重算、放行是不是真的放得掉、
// 走投无路时还剩什么、以及 409 里那句 hint 指的地方存不存在。
// 它们全部是「接口回 200 / 回了 hint,但什么都没发生 / 那条路根本不通」的静默形态。
// ──────────────────────────────────────────────────────────────
describe('强制放行与保底出路(复核 F1)', () => {
/** 一条已经 failed 的上游 + 一条排队中的下游;下游的依赖留给各条用例自己接 */
function pair(tag) {
const dead = wdb.createTask({ goalId: goal.id, title: `死上游-${tag}`, description: '已经失败的上游' });
wdb.updateTask(dead.id, { status: 'failed' });
const down = wdb.createTask({ goalId: goal.id, title: `下游-${tag}`, description: '等上游' });
wdb.updateTask(down.id, { status: 'queued', queuedAt: new Date().toISOString() });
return { dead, down };
}
it('PATCH depends_on 当场重算队列:新依赖已经死了 → 响应里就是 blocked,不是过期的 queued', async () => {
const { dead, down } = pair('a');
const [status, body] = await call('PATCH', `/tasks/${down.id}`, { depends_on: [dead.id] });
expect(status).toBe(200);
// 少了 PATCH 里那次 tick,这里回的是 'queued':库里要等 60s 兜底扫描才变 blocked,
// 而界面拿的就是这个响应 —— 用户看到的是「改了没反应」
expect(body.status).toBe('blocked');
expect(body.blockedReason).toBe(`${wdb.UPSTREAM_BLOCK_PREFIX}${dead.id}`);
});
it('强制放行:删掉那条死依赖 → 200,且当场回到队列(reason 一起清掉)', async () => {
const { dead, down } = pair('b');
await call('PATCH', `/tasks/${down.id}`, { depends_on: [dead.id] }); // 先卡住
const [status, body] = await call('PATCH', `/tasks/${down.id}`, { depends_on: [] });
expect(status).toBe(200);
// 这一条就是前端「强制放行」按钮打的那一枪。它此前必然 409(数据层闸门只认 draft/queued),
// 按钮却一定是亮的 —— 「不给能点却必然 4xx 的入口」当时只是一句注释
expect(body.status).toBe('queued');
// blocked_reason 由 requeueDerivedBlockedTask 和 status 在**同一条**条件更新里清掉。
// 谁抢在 tick 前面单独把它清成 NULL,那条 UPDATE 就再也命中不了,任务永远卡在 blocked
expect(body.blockedReason).toBeFalsy();
expect(wdb.getTask(down.id).blocked_reason).toBeFalsy();
});
it('blocked 的 409 hint 不许指向不存在的行为(旧版是 POST /api/graph-runs/null/stop)', async () => {
const { dead, down } = pair('c');
await call('PATCH', `/tasks/${down.id}`, { depends_on: [dead.id] });
const [status, body] = await call('POST', `/tasks/${down.id}/rerun`);
expect(status).toBe(409); // 重跑仍要挡:taskHoldsRun 把 blocked 一律算成攥着 run
expect(body.hint).not.toMatch(/graph-runs\/(null|undefined)\//);
// 给得出的下一步必须真的能走:这条 task 根本没有 run,能动它的只有 cancel
expect(body.hint).toContain(`/tasks/${down.id}/cancel`);
});
it('保底出路:上游已归档/删号时,取消一定走得通(不再是两条 409 互相指着对方)', async () => {
const { dead, down } = pair('d');
await call('PATCH', `/tasks/${down.id}`, { depends_on: [dead.id] });
expect(wdb.getTask(down.id).status).toBe('blocked');
const [status, body] = await call('POST', `/tasks/${down.id}/cancel`);
expect(status).toBe(200);
expect(body.status).toBe('cancelled');
// 已经取消了还挂着「上游未完成」,列表上会一直显示一个假原因
expect(wdb.getTask(down.id).blocked_reason).toBeFalsy();
});
});
});