feat: 编排器跨项目全局并发上限闸(tsk_erQHNYdn6i9g)
新增用户级设置项 globalConcurrency(settings 表,独立于新建项目默认值 来源 concurrency),缺省 0 = 不限,向后兼容无需 DB 迁移。 - store: UserSettings 加 globalConcurrency 字段 + 默认 0;新增 globalInflightCount()(跨所有项目 started run 总数,与 inflightTaskIds 同口径) - orchestrator.claimTick: 叠加全局闸,与 per-project 闸串联(都过才领); 轮初查一次全局在途、轮内手动 ++,达上限即 return 跨所有项目停止领取 - api: PUT /api/settings 支持 globalConcurrency(空/null 归一为 0) - web: 侧栏「全局设置」入口 + 模态,编辑/回显/校验(≥0 整数) - test: 新增全局闸达上限跨项目阻止 / 未达上限正常领取 / 边界 / 轮初短路 用例 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -443,6 +443,69 @@ test('auto-easy 不做规划:medium(speccing) 不被领取', () => {
|
||||
store.close();
|
||||
});
|
||||
|
||||
// ───────────────────────── 全局并发闸(跨项目在途总数上限)─────────────────────────
|
||||
|
||||
/** 单 store 建两个 active / auto-easy 项目,各塞 n 个 easy 任务,per-project 并发足够大(不成为瓶颈)。 */
|
||||
function setupTwoProjects(n = 2, concurrency = 5): { store: Store; p1: string; p2: string } {
|
||||
const store = new Store(':memory:');
|
||||
const p1 = store.createProject({ name: 'g1', repoPath: '/tmp/g1-' + Math.random(), autonomy: 'auto-easy', concurrency });
|
||||
const p2 = store.createProject({ name: 'g2', repoPath: '/tmp/g2-' + Math.random(), autonomy: 'auto-easy', concurrency });
|
||||
for (let i = 0; i < n; i++) {
|
||||
store.createTask({ projectId: p1.id, title: `p1-${i}`, complexity: 'easy' });
|
||||
store.createTask({ projectId: p2.id, title: `p2-${i}`, complexity: 'easy' });
|
||||
}
|
||||
return { store, p1: p1.id, p2: p2.id };
|
||||
}
|
||||
|
||||
test('全局闸:globalConcurrency=1 达上限 → 跨所有项目只领一个(per-project 闸都没满也阻止)', () => {
|
||||
const { store } = setupTwoProjects(2, 5);
|
||||
store.putSettings({ globalConcurrency: 1 });
|
||||
const state = freshState();
|
||||
const { deps } = mockDeps(state);
|
||||
createOrchestrator(store, noopLog, deps).claimTick();
|
||||
assert.equal(state.spawned.length, 1, '全局闸=1:两项目共 4 个可领任务,本轮只放一个');
|
||||
store.close();
|
||||
});
|
||||
|
||||
test('全局闸:globalConcurrency=0(缺省)不限 → 两项目任务全领(不误伤)', () => {
|
||||
const { store, p1, p2 } = setupTwoProjects(2, 5);
|
||||
// 不设 globalConcurrency(默认 0)
|
||||
assert.equal(store.getSettings().globalConcurrency, 0);
|
||||
const state = freshState();
|
||||
const { deps } = mockDeps(state);
|
||||
createOrchestrator(store, noopLog, deps).claimTick();
|
||||
assert.equal(state.spawned.length, 4, '不限:两项目各 2 个共 4 个全部领取');
|
||||
assert.equal(store.listTasks(p1).filter((t) => t.status === 'executing').length, 2);
|
||||
assert.equal(store.listTasks(p2).filter((t) => t.status === 'executing').length, 2);
|
||||
store.close();
|
||||
});
|
||||
|
||||
test('全局闸:globalConcurrency=3 两项目共 4 任务 → 一轮恰好领 3(边界)', () => {
|
||||
const { store } = setupTwoProjects(2, 5);
|
||||
store.putSettings({ globalConcurrency: 3 });
|
||||
const state = freshState();
|
||||
const { deps } = mockDeps(state);
|
||||
createOrchestrator(store, noopLog, deps).claimTick();
|
||||
assert.equal(state.spawned.length, 3, '全局闸=3:达上限即跨项目停止,恰好领 3 个');
|
||||
store.close();
|
||||
});
|
||||
|
||||
test('全局闸:在途已达上限 → 整轮直接不领(轮初短路)', () => {
|
||||
const { store, p1 } = setupTwoProjects(2, 5);
|
||||
store.putSettings({ globalConcurrency: 1 });
|
||||
const state = freshState();
|
||||
const { deps } = mockDeps(state);
|
||||
const orch = createOrchestrator(store, noopLog, deps);
|
||||
orch.claimTick();
|
||||
assert.equal(state.spawned.length, 1);
|
||||
assert.equal(store.globalInflightCount(), 1, '已有 1 个在途 started run');
|
||||
// 再来一轮:全局在途已=1=cap → 不再领
|
||||
orch.claimTick();
|
||||
assert.equal(state.spawned.length, 1, '全局在途已达上限 → 整轮不领');
|
||||
assert.ok(store.listTasks(p1).length >= 0);
|
||||
store.close();
|
||||
});
|
||||
|
||||
test('并发闸用 inflight:1 个 executing + 1 个 planner 占满 concurrency=2', () => {
|
||||
const { store, projectId } = setup('auto-approved', 2);
|
||||
const e = store.createTask({ projectId, title: 'easy', complexity: 'easy' }); // → ready
|
||||
|
||||
Reference in New Issue
Block a user