task-decomposition-phase4.test.js
16.8 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
/**
* Unit Tests — 46 号文档 Phase 4:预算均分 + 父子终态汇总 + 同父子任务独立并发坑位
*
* 覆盖三块新机制:
* 1. `materializeDecomposedSubtasks` 把父任务预算按步数均分给每条子任务
* 2. `markTaskDecomposed` / `settleDecomposedParentStatus` 的 CAS 语义
* 3. `updateRunStatus` 里新增的「同父 decomposed_work 子任务最多 3 并行」坑位,
* 与公司级 `max_parallel_tasks` 完全独立
* 4. `task-settle.settleParentTaskIfAllChildrenDone`(父子终态汇总)
*
* 隔离手法同 `task-decomposition-dispatch.test.js`:先设 GRAPH_DB_PATH 再 require。
*/
import {
describe, it, expect, beforeAll, afterAll, beforeEach,
} from 'vitest';
import fs from 'fs';
const TEST_DB_PATH = '/tmp/test-task-decomp-phase4.sqlite';
const DB_FILES = [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`];
const MODULES = [
'../../services/graph-db',
'../../services/org-graph-service',
'../../services/work-graph-db',
'../../services/graph-run-sse',
'../../services/task-settle',
];
const MIN_ACCEPTANCE = { dimensions: [{ name: 'done', weight: 1, pass_threshold: 80 }] };
describe('46 号文档 Phase 4', () => {
let wdb;
let settle;
beforeAll(() => {
for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} }
process.env.GRAPH_DB_PATH = TEST_DB_PATH;
for (const m of MODULES) delete require.cache[require.resolve(m)];
wdb = require('../../services/work-graph-db');
settle = require('../../services/task-settle');
});
afterAll(() => {
for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} }
delete process.env.GRAPH_DB_PATH;
for (const m of MODULES) delete require.cache[require.resolve(m)];
});
beforeEach(() => {
const db = wdb.getDb();
db.pragma('foreign_keys = OFF');
db.exec(`
DELETE FROM graph_run_nodes;
DELETE FROM graph_runs;
DELETE FROM tasks;
DELETE FROM goals;
DELETE FROM system_agents;
`);
db.pragma('foreign_keys = ON');
});
let seq = 0;
function nextCompany() { return `p4-co-${++seq}`; }
// ── 1. 预算均分 ──────────────────────────────────────────────────────
describe('materializeDecomposedSubtasks:预算均分', () => {
it('父任务有 budget_json 时,按步数均分给每条子任务', () => {
const companyId = nextCompany();
const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' });
const parent = wdb.createTask({
goalId: goal.id, title: 'p', description: 'd',
budgetJson: { total_agent_calls: 100, total_tokens: 5000000, wallclock_ms: 7200000 },
});
const steps = [
{ seq: 1, title: '第一步', depends_on_hint: [], acceptance: MIN_ACCEPTANCE },
{ seq: 2, title: '第二步', depends_on_hint: [], acceptance: MIN_ACCEPTANCE },
{ seq: 3, title: '第三步', depends_on_hint: [], acceptance: MIN_ACCEPTANCE },
{ seq: 4, title: '第四步', depends_on_hint: [], acceptance: MIN_ACCEPTANCE },
];
const ids = wdb.materializeDecomposedSubtasks({ task: wdb.getTask(parent.id), steps });
expect(ids).toHaveLength(4);
for (const id of ids) {
const budget = JSON.parse(wdb.getTask(id).budget_json);
expect(budget.total_agent_calls).toBe(25); // 100 / 4
expect(budget.total_tokens).toBe(1250000); // 5000000 / 4
expect(budget.wallclock_ms).toBe(1800000); // 7200000 / 4
expect(budget.spent).toEqual({ agent_calls: 0, tokens: 0 });
}
});
it('父任务没有 budget_json 时,退到 DEFAULT_TASK_BUDGET 的形状再均分', () => {
const companyId = nextCompany();
const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' });
const parent = wdb.createTask({ goalId: goal.id, title: 'p', description: 'd' });
const steps = [
{ seq: 1, title: '一步', depends_on_hint: [], acceptance: MIN_ACCEPTANCE },
{ seq: 2, title: '二步', depends_on_hint: [], acceptance: MIN_ACCEPTANCE },
];
const ids = wdb.materializeDecomposedSubtasks({ task: wdb.getTask(parent.id), steps });
expect(ids).toHaveLength(2);
const budget = JSON.parse(wdb.getTask(ids[0]).budget_json);
expect(budget.total_agent_calls).toBe(50); // 100(默认)/ 2
});
it('均分结果保底至少 1,不会因为步数多而算出 0', () => {
const companyId = nextCompany();
const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' });
const parent = wdb.createTask({
goalId: goal.id, title: 'p', description: 'd',
budgetJson: { total_agent_calls: 1, total_tokens: 1, wallclock_ms: 1 },
});
const steps = [
{ seq: 1, title: '一步', depends_on_hint: [], acceptance: MIN_ACCEPTANCE },
{ seq: 2, title: '二步', depends_on_hint: [], acceptance: MIN_ACCEPTANCE },
{ seq: 3, title: '三步', depends_on_hint: [], acceptance: MIN_ACCEPTANCE },
];
const ids = wdb.materializeDecomposedSubtasks({ task: wdb.getTask(parent.id), steps });
for (const id of ids) {
const budget = JSON.parse(wdb.getTask(id).budget_json);
expect(budget.total_agent_calls).toBeGreaterThanOrEqual(1);
expect(budget.total_tokens).toBeGreaterThanOrEqual(1);
expect(budget.wallclock_ms).toBeGreaterThanOrEqual(1);
}
});
});
// ── 2. markTaskDecomposed / settleDecomposedParentStatus ────────────
describe('markTaskDecomposed / settleDecomposedParentStatus:CAS 语义', () => {
it('从 queued 转:成功后 status 变 decomposed', () => {
const companyId = nextCompany();
const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' });
const task = wdb.createTask({ goalId: goal.id, title: 't', description: 'd' });
wdb.updateTask(task.id, { status: 'queued' });
expect(wdb.markTaskDecomposed(task.id)).toBe(true);
expect(wdb.getTask(task.id).status).toBe('decomposed');
// 已经是 decomposed 了,重复调用拿不到锁
expect(wdb.markTaskDecomposed(task.id)).toBe(false);
});
// 真实回归纠正过的行为:task-scheduler 抢坑几乎是瞬间的(毫秒级),而 subtask-planning
// 是真实 LLM 调用(数十秒),等它拆完回来时父任务几乎必然已经是 running 而不是 queued
// 了——这不是小概率分支,是最常见的那条路径,必须能从 running 接管,否则拆分结果
// 几乎每次都接管失败、白白物化出一堆没人认领的孤儿子任务(真实回归当场撞见过一次)。
it('从 running 转:真实场景里最常见的路径,也能成功接管', () => {
const companyId = nextCompany();
const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' });
const task = wdb.createTask({ goalId: goal.id, title: 't', description: 'd' });
wdb.updateTask(task.id, { status: 'running' });
expect(wdb.markTaskDecomposed(task.id)).toBe(true);
expect(wdb.getTask(task.id).status).toBe('decomposed');
});
it('从 blocked 转:同样还没到终态,可以接管', () => {
const companyId = nextCompany();
const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' });
const task = wdb.createTask({ goalId: goal.id, title: 't', description: 'd' });
wdb.updateTask(task.id, { status: 'blocked' });
expect(wdb.markTaskDecomposed(task.id)).toBe(true);
expect(wdb.getTask(task.id).status).toBe('decomposed');
});
it.each(['completed', 'failed', 'cancelled'])(
'父任务已经是终态(%s)时天然拿不到锁(真赢了竞态的情形,不覆盖)',
(terminalStatus) => {
const companyId = nextCompany();
const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' });
const task = wdb.createTask({ goalId: goal.id, title: 't', description: 'd' });
wdb.updateTask(task.id, { status: terminalStatus }); // 模拟父 run 已经先跑完/失败/取消了
expect(wdb.markTaskDecomposed(task.id)).toBe(false);
expect(wdb.getTask(task.id).status).toBe(terminalStatus); // 没被覆盖
},
);
it('settleDecomposedParentStatus 只能从 decomposed 转终态,CAS 挡重复结算', () => {
const companyId = nextCompany();
const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' });
const task = wdb.createTask({ goalId: goal.id, title: 't', description: 'd' });
wdb.updateTask(task.id, { status: 'decomposed' });
expect(wdb.settleDecomposedParentStatus(task.id, 'completed')).toBe(true);
expect(wdb.getTask(task.id).status).toBe('completed');
expect(wdb.getTask(task.id).finished_at).toBeTruthy();
// 已经结算过,第二次调用(哪怕传别的状态)拿不到锁,不会覆盖
expect(wdb.settleDecomposedParentStatus(task.id, 'failed')).toBe(false);
expect(wdb.getTask(task.id).status).toBe('completed');
});
it('settleDecomposedParentStatus 只接受 completed/failed,别的值直接抛', () => {
expect(() => wdb.settleDecomposedParentStatus('whatever-id', 'blocked')).toThrow(/只能是/);
});
});
// ── 3. 同父 decomposed_work 子任务的独立并发坑位(上限 3) ──────────
describe('updateRunStatus:同父 decomposed_work 子任务并发上限 3', () => {
function makeChild(companyId, parentId, i) {
return wdb.createTask({
goalId: wdb.getTask(parentId).goal_id, companyId, title: `子任务 ${i}`, description: 'd',
parentTaskId: parentId, subtaskKind: 'decomposed_work', acceptanceContract: MIN_ACCEPTANCE,
});
}
it('前 3 个同父子任务的 run 都能进 ready,第 4 个撞 RunSlotBusyError(scope=decomposed-sibling)', () => {
const companyId = nextCompany();
const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' });
const parent = wdb.createTask({ goalId: goal.id, companyId, title: 'p', description: 'd' });
const runs = [];
for (let i = 1; i <= 4; i += 1) {
const child = makeChild(companyId, parent.id, i);
const run = wdb.createRun({
graphKind: 'work', graphId: 'placeholder', companyId, taskId: child.id, runStatus: 'queued',
});
runs.push(wdb.getRun(run.id));
}
for (let i = 0; i < 3; i += 1) {
expect(wdb.updateRunStatus(runs[i].id, 'queued', 'ready')).toBe(1);
}
let caught = null;
try {
wdb.updateRunStatus(runs[3].id, 'queued', 'ready');
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(wdb.RunSlotBusyError);
expect(caught.scope).toBe('decomposed-sibling');
expect(caught.max).toBe(wdb.DECOMPOSED_SIBLING_CONCURRENCY);
expect(caught.heldBy).toHaveLength(3);
// 第 4 条依旧是 queued(没被误占)
expect(wdb.getRun(runs[3].id).run_status).toBe('queued');
});
it('decomposed_work 子任务的 run 不计入公司级 countActiveRootRuns', () => {
const companyId = nextCompany();
const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' });
const parent = wdb.createTask({ goalId: goal.id, companyId, title: 'p', description: 'd' });
const child = makeChild(companyId, parent.id, 1);
const run = wdb.createRun({
graphKind: 'work', graphId: 'placeholder', companyId, taskId: child.id, runStatus: 'queued',
});
wdb.updateRunStatus(run.id, 'queued', 'ready');
expect(wdb.countActiveRootRuns(companyId)).toBe(0);
});
it('公司级坑位(默认 1)被普通任务占满时,decomposed_work 子任务仍能独立进坑(互不占用)', () => {
const companyId = nextCompany();
const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' });
// 一条普通任务占满公司级坑位(默认 max_parallel_tasks=1)
const ordinary = wdb.createTask({ goalId: goal.id, companyId, title: 'o', description: 'd' });
const ordinaryRun = wdb.createRun({
graphKind: 'org', graphId: companyId, companyId, taskId: ordinary.id, runStatus: 'queued',
});
expect(wdb.updateRunStatus(ordinaryRun.id, 'queued', 'ready')).toBe(1);
expect(wdb.countActiveRootRuns(companyId)).toBe(1);
// decomposed_work 子任务照样能进坑——不共用这个池子
const parent = wdb.createTask({ goalId: goal.id, companyId, title: 'p', description: 'd' });
const child = makeChild(companyId, parent.id, 1);
const childRun = wdb.createRun({
graphKind: 'work', graphId: 'placeholder', companyId, taskId: child.id, runStatus: 'queued',
});
expect(wdb.updateRunStatus(childRun.id, 'queued', 'ready')).toBe(1);
});
it('不同父任务的 decomposed_work 子任务各自独立计数,互不占用彼此的 3 个坑位', () => {
const companyId = nextCompany();
const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' });
const parentA = wdb.createTask({ goalId: goal.id, companyId, title: 'pa', description: 'd' });
const parentB = wdb.createTask({ goalId: goal.id, companyId, title: 'pb', description: 'd' });
// parentA 占满 3 个坑位
for (let i = 1; i <= 3; i += 1) {
const child = makeChild(companyId, parentA.id, i);
const run = wdb.createRun({
graphKind: 'work', graphId: 'placeholder', companyId, taskId: child.id, runStatus: 'queued',
});
expect(wdb.updateRunStatus(run.id, 'queued', 'ready')).toBe(1);
}
// parentB 的第一个子任务不受影响
const childB = makeChild(companyId, parentB.id, 1);
const runB = wdb.createRun({
graphKind: 'work', graphId: 'placeholder', companyId, taskId: childB.id, runStatus: 'queued',
});
expect(wdb.updateRunStatus(runB.id, 'queued', 'ready')).toBe(1);
});
});
// ── 4. settleParentTaskIfAllChildrenDone(父子终态汇总) ────────────
describe('task-settle.settleParentTaskIfAllChildrenDone', () => {
function setupParentWithChildren(statuses) {
const companyId = nextCompany();
const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' });
const parent = wdb.createTask({ goalId: goal.id, companyId, title: 'p', description: 'd' });
wdb.updateTask(parent.id, { status: 'decomposed' });
const children = statuses.map((status, i) => {
const c = wdb.createTask({
goalId: goal.id, companyId, title: `c${i}`, description: 'd',
parentTaskId: parent.id, subtaskKind: 'decomposed_work', acceptanceContract: MIN_ACCEPTANCE,
});
wdb.updateTask(c.id, { status });
return c;
});
return { companyId, goal, parent, children };
}
it('还有子任务未到终态时返回 null,父任务保持 decomposed', () => {
const { parent } = setupParentWithChildren(['completed', 'running']);
expect(settle.settleParentTaskIfAllChildrenDone(parent.id)).toBeNull();
expect(wdb.getTask(parent.id).status).toBe('decomposed');
});
it('全部子任务 completed → 父任务结算成 completed', async () => {
const { parent, goal } = setupParentWithChildren(['completed', 'completed']);
expect(settle.settleParentTaskIfAllChildrenDone(parent.id)).toBe('completed');
expect(wdb.getTask(parent.id).status).toBe('completed');
// goal 收敛的 CAS 占位值也应该已经写下去(不再是 active)——见函数注释里
// "父任务转终态不会自动触发 goal 收敛,必须补一次调用" 那段
expect(wdb.getGoal(goal.id).status).not.toBe('active');
// 让可能的异步验收(若被触发)跑完,避免测试进程留下悬空的 unhandled work
await new Promise((r) => setImmediate(r));
});
it('有子任务 failed → 父任务结算成 failed', async () => {
const { parent } = setupParentWithChildren(['completed', 'failed']);
expect(settle.settleParentTaskIfAllChildrenDone(parent.id)).toBe('failed');
expect(wdb.getTask(parent.id).status).toBe('failed');
await new Promise((r) => setImmediate(r));
});
it('没有子任务时返回 null', () => {
const companyId = nextCompany();
const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' });
const parent = wdb.createTask({ goalId: goal.id, companyId, title: 'p', description: 'd' });
expect(settle.settleParentTaskIfAllChildrenDone(parent.id)).toBeNull();
});
it('并发调用只有一次生效(CAS):第二次调用返回 null', async () => {
const { parent } = setupParentWithChildren(['completed']);
expect(settle.settleParentTaskIfAllChildrenDone(parent.id)).toBe('completed');
expect(settle.settleParentTaskIfAllChildrenDone(parent.id)).toBeNull();
await new Promise((r) => setImmediate(r));
});
});
});