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:
@@ -137,6 +137,8 @@ export function buildServer(opts: ApiOptions): FastifyInstance {
|
|||||||
if (b.budgetUsd !== undefined) patch.budgetUsd = b.budgetUsd === null || b.budgetUsd === '' ? null : Number(b.budgetUsd);
|
if (b.budgetUsd !== undefined) patch.budgetUsd = b.budgetUsd === null || b.budgetUsd === '' ? null : Number(b.budgetUsd);
|
||||||
if (b.budgetPeriod !== undefined) patch.budgetPeriod = b.budgetPeriod as 'day' | 'month';
|
if (b.budgetPeriod !== undefined) patch.budgetPeriod = b.budgetPeriod as 'day' | 'month';
|
||||||
if (b.model !== undefined) patch.model = b.model === null || b.model === '' ? null : String(b.model);
|
if (b.model !== undefined) patch.model = b.model === null || b.model === '' ? null : String(b.model);
|
||||||
|
if (b.globalConcurrency !== undefined)
|
||||||
|
patch.globalConcurrency = b.globalConcurrency === null || b.globalConcurrency === '' ? 0 : Number(b.globalConcurrency);
|
||||||
return store.putSettings(patch);
|
return store.putSettings(patch);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -209,9 +209,16 @@ export function createOrchestrator(store: Store, log: OrchestratorLogger, deps:
|
|||||||
/**
|
/**
|
||||||
* 领取轮:对每个 active 且 autonomy≠manual 的项目,并发闸 = inflightTaskIds(有 started run 的任务,
|
* 领取轮:对每个 active 且 autonomy≠manual 的项目,并发闸 = inflightTaskIds(有 started run 的任务,
|
||||||
* executor + planner 通用)。在 active<concurrency 时按 claimable 领新任务并 spawn worker。
|
* executor + planner 通用)。在 active<concurrency 时按 claimable 领新任务并 spawn worker。
|
||||||
|
*
|
||||||
|
* 全局并发闸(settings.globalConcurrency,0/缺省=不限)与 per-project 闸【串联】:两闸都过才领。
|
||||||
|
* 全局在途总数(globalActive)轮初查一次、轮内手动 ++(claimOne 同步起新 started run),命中即 return——
|
||||||
|
* 达上限后任何项目都不应再领,故 return(跨所有项目停止)而非 break(只停当前项目)。
|
||||||
*/
|
*/
|
||||||
function claimTick(): void {
|
function claimTick(): void {
|
||||||
try {
|
try {
|
||||||
|
const cap = store.getSettings().globalConcurrency; // 0/缺省 = 不限
|
||||||
|
let globalActive = store.globalInflightCount(); // 轮初的全局在途总数
|
||||||
|
if (cap > 0 && globalActive >= cap) return; // 全局闸已满 → 整轮跨项目都不领
|
||||||
for (const p of store.listProjects()) {
|
for (const p of store.listProjects()) {
|
||||||
if (p.status !== 'active' || p.autonomy === 'manual') continue;
|
if (p.status !== 'active' || p.autonomy === 'manual') continue;
|
||||||
const inflight = store.inflightTaskIds(p.id);
|
const inflight = store.inflightTaskIds(p.id);
|
||||||
@@ -219,9 +226,11 @@ export function createOrchestrator(store: Store, log: OrchestratorLogger, deps:
|
|||||||
if (active >= p.concurrency) continue;
|
if (active >= p.concurrency) continue;
|
||||||
|
|
||||||
for (const { task, score } of claimable(p, inflight)) {
|
for (const { task, score } of claimable(p, inflight)) {
|
||||||
if (active >= p.concurrency) break;
|
if (active >= p.concurrency) break; // per-project 闸
|
||||||
|
if (cap > 0 && globalActive >= cap) return; // 全局闸:达上限即跨所有项目停止领取
|
||||||
claimOne(p, task, score);
|
claimOne(p, task, score);
|
||||||
active++;
|
active++;
|
||||||
|
globalActive++; // 本轮内手动累加(claimOne 已起新 started run)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -30,11 +30,14 @@ export interface UserSettings {
|
|||||||
budgetUsd: number | null;
|
budgetUsd: number | null;
|
||||||
budgetPeriod: 'day' | 'month';
|
budgetPeriod: 'day' | 'month';
|
||||||
model: string | null;
|
model: string | null;
|
||||||
|
/** 跨所有项目的在途 run 总数上限(全局并发闸);0 = 不限。与 concurrency(新建项目默认值)相互独立。 */
|
||||||
|
globalConcurrency: number;
|
||||||
}
|
}
|
||||||
const SETTINGS_DEFAULT: UserSettings = {
|
const SETTINGS_DEFAULT: UserSettings = {
|
||||||
autonomy: 'manual', concurrency: 1, maxRetries: 2, timeoutMs: 1_800_000,
|
autonomy: 'manual', concurrency: 1, maxRetries: 2, timeoutMs: 1_800_000,
|
||||||
autoApprovePlan: false, autoApproveExec: false,
|
autoApprovePlan: false, autoApproveExec: false,
|
||||||
budgetUsd: null, budgetPeriod: 'month', model: null,
|
budgetUsd: null, budgetPeriod: 'month', model: null,
|
||||||
|
globalConcurrency: 0,
|
||||||
};
|
};
|
||||||
const id = (prefix: string): string => `${prefix}_${nanoid(12)}`;
|
const id = (prefix: string): string => `${prefix}_${nanoid(12)}`;
|
||||||
|
|
||||||
@@ -918,6 +921,11 @@ export class Store {
|
|||||||
return new Set(rows.map((r) => r.tid));
|
return new Set(rows.map((r) => r.tid));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 跨所有项目的在途 run 总数(全局并发闸用;与 inflightTaskIds 同口径=started run,覆盖 executor + planner)。 */
|
||||||
|
globalInflightCount(): number {
|
||||||
|
return (this.db.prepare(`SELECT COUNT(*) AS n FROM runs WHERE status = 'started'`).get() as { n: number }).n;
|
||||||
|
}
|
||||||
|
|
||||||
/** 所有 started run + 其任务(reap / ingest / reconcile 通用,覆盖 executor + planner + 残留复审 run)。 */
|
/** 所有 started run + 其任务(reap / ingest / reconcile 通用,覆盖 executor + planner + 残留复审 run)。 */
|
||||||
liveRunsWithTask(): Array<{ task: Task; run: Run }> {
|
liveRunsWithTask(): Array<{ task: Task; run: Run }> {
|
||||||
const runs = this.db.prepare(`SELECT * FROM runs WHERE status = 'started' ORDER BY started_at`).all() as RunRow[];
|
const runs = this.db.prepare(`SELECT * FROM runs WHERE status = 'started' ORDER BY started_at`).all() as RunRow[];
|
||||||
|
|||||||
@@ -443,6 +443,69 @@ test('auto-easy 不做规划:medium(speccing) 不被领取', () => {
|
|||||||
store.close();
|
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', () => {
|
test('并发闸用 inflight:1 个 executing + 1 个 planner 占满 concurrency=2', () => {
|
||||||
const { store, projectId } = setup('auto-approved', 2);
|
const { store, projectId } = setup('auto-approved', 2);
|
||||||
const e = store.createTask({ projectId, title: 'easy', complexity: 'easy' }); // → ready
|
const e = store.createTask({ projectId, title: 'easy', complexity: 'easy' }); // → ready
|
||||||
|
|||||||
+30
@@ -132,6 +132,10 @@ const I18N = {
|
|||||||
toastNameRequired: '名称与仓库路径必填',
|
toastNameRequired: '名称与仓库路径必填',
|
||||||
toastTaskNotFound: '任务不在当前项目',
|
toastTaskNotFound: '任务不在当前项目',
|
||||||
toastConcInt: '最大并发必须是 ≥1 的整数',
|
toastConcInt: '最大并发必须是 ≥1 的整数',
|
||||||
|
globalSettings: '全局设置', globalConc: '全局并发上限',
|
||||||
|
globalConcHint: '跨所有项目的在途任务总数上限;0 = 不限。',
|
||||||
|
globalConcInt: '全局并发上限必须是 ≥0 的整数(0 = 不限)',
|
||||||
|
toastSettingsSaved: '全局设置已保存',
|
||||||
toastRejectRequired: '驳回意见不能为空',
|
toastRejectRequired: '驳回意见不能为空',
|
||||||
reorderSaved: '项目顺序已保存',
|
reorderSaved: '项目顺序已保存',
|
||||||
cancelConfirm: '确定取消任务「{title}」{hint}?\n取消后任务进入归档,不影响子任务执行。',
|
cancelConfirm: '确定取消任务「{title}」{hint}?\n取消后任务进入归档,不影响子任务执行。',
|
||||||
@@ -274,6 +278,10 @@ const I18N = {
|
|||||||
toastNameRequired: 'Name and repo path are required',
|
toastNameRequired: 'Name and repo path are required',
|
||||||
toastTaskNotFound: 'Task not found in current project',
|
toastTaskNotFound: 'Task not found in current project',
|
||||||
toastConcInt: 'Max concurrency must be an integer ≥1',
|
toastConcInt: 'Max concurrency must be an integer ≥1',
|
||||||
|
globalSettings: 'Settings', globalConc: 'Global concurrency cap',
|
||||||
|
globalConcHint: 'Max in-flight tasks across all projects; 0 = unlimited.',
|
||||||
|
globalConcInt: 'Global concurrency cap must be an integer ≥0 (0 = unlimited)',
|
||||||
|
toastSettingsSaved: 'Settings saved',
|
||||||
toastRejectRequired: 'Rejection feedback is required',
|
toastRejectRequired: 'Rejection feedback is required',
|
||||||
reorderSaved: 'Project order saved',
|
reorderSaved: 'Project order saved',
|
||||||
cancelConfirm: 'Cancel task "{title}"{hint}?\nThis moves the task to archive; subtasks are unaffected.',
|
cancelConfirm: 'Cancel task "{title}"{hint}?\nThis moves the task to archive; subtasks are unaffected.',
|
||||||
@@ -1752,6 +1760,17 @@ document.addEventListener('click', (ev) => {
|
|||||||
$('#modalRoot').hidden = true;
|
$('#modalRoot').hidden = true;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'open-settings':
|
||||||
|
api('/api/settings').then((s) => {
|
||||||
|
$('#setGlobalConc').value = s.globalConcurrency ?? 0;
|
||||||
|
$('#settingsRoot').hidden = false;
|
||||||
|
$('#setGlobalConc').focus();
|
||||||
|
}).catch((e) => toast(e.message));
|
||||||
|
break;
|
||||||
|
case 'close-settings':
|
||||||
|
$('#settingsRoot').hidden = true;
|
||||||
|
break;
|
||||||
|
|
||||||
case 'toggle-new-task': {
|
case 'toggle-new-task': {
|
||||||
const p = $('#newTaskPanel');
|
const p = $('#newTaskPanel');
|
||||||
p.hidden = !p.hidden;
|
p.hidden = !p.hidden;
|
||||||
@@ -2234,6 +2253,16 @@ $('#newProjectForm').addEventListener('submit', (ev) => {
|
|||||||
}, t('toastProjectCreated', { name }));
|
}, t('toastProjectCreated', { name }));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$('#settingsForm').addEventListener('submit', (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
const n = Number($('#setGlobalConc').value);
|
||||||
|
if (!Number.isInteger(n) || n < 0) { toast(t('globalConcInt')); return; }
|
||||||
|
act(async () => {
|
||||||
|
await api('/api/settings', { method: 'PUT', body: JSON.stringify({ globalConcurrency: n }) });
|
||||||
|
$('#settingsRoot').hidden = true;
|
||||||
|
}, t('toastSettingsSaved'));
|
||||||
|
});
|
||||||
|
|
||||||
$('#newTaskPanel').addEventListener('submit', (ev) => {
|
$('#newTaskPanel').addEventListener('submit', (ev) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
const f = ev.target;
|
const f = ev.target;
|
||||||
@@ -2268,6 +2297,7 @@ document.addEventListener('keydown', (ev) => {
|
|||||||
const am = $('#archiveModalRoot');
|
const am = $('#archiveModalRoot');
|
||||||
if (!am.hidden) { am.hidden = true; am.innerHTML = ''; return; }
|
if (!am.hidden) { am.hidden = true; am.innerHTML = ''; return; }
|
||||||
if (S.previewId) { S.previewId = null; S.previewReject = false; renderPreview(); return; }
|
if (S.previewId) { S.previewId = null; S.previewReject = false; renderPreview(); return; }
|
||||||
|
if (!$('#settingsRoot').hidden) { $('#settingsRoot').hidden = true; return; }
|
||||||
$('#modalRoot').hidden = true;
|
$('#modalRoot').hidden = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,6 +28,8 @@
|
|||||||
<div class="side-foot">
|
<div class="side-foot">
|
||||||
<span id="wsDot" class="ws-dot off"></span>
|
<span id="wsDot" class="ws-dot off"></span>
|
||||||
<span id="wsText" data-i18n="connecting">连接中…</span>
|
<span id="wsText" data-i18n="connecting">连接中…</span>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<button class="btn btn-ghost btn-xs" data-action="open-settings" data-i18n-title="globalSettings" title="全局设置"><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3" stroke="var(--icon-accent,#5fdd7d)"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1Z"/></svg> <span data-i18n="globalSettings">全局设置</span></button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -261,6 +263,22 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 全局设置 模态 -->
|
||||||
|
<div id="settingsRoot" class="modal-root" hidden>
|
||||||
|
<div class="modal-mask" data-action="close-settings"></div>
|
||||||
|
<form id="settingsForm" class="modal panel">
|
||||||
|
<div class="sec-head"><span class="head-mark">▍</span><span data-i18n="globalSettings">全局设置</span></div>
|
||||||
|
<label><span data-i18n="globalConc">全局并发上限</span>
|
||||||
|
<input id="setGlobalConc" type="number" min="0" step="1" value="0" autocomplete="off">
|
||||||
|
</label>
|
||||||
|
<p class="cfg-current" data-i18n="globalConcHint">跨所有项目的在途任务总数上限;0 = 不限。</p>
|
||||||
|
<div class="form-row form-actions">
|
||||||
|
<button type="submit" class="btn btn-solid" data-action="save-settings" data-i18n="save">保存</button>
|
||||||
|
<button type="button" class="btn" data-action="close-settings" data-i18n="cancel">取消</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="previewRoot" class="preview-root" hidden></div>
|
<div id="previewRoot" class="preview-root" hidden></div>
|
||||||
<div id="archiveModalRoot" class="preview-root" hidden></div>
|
<div id="archiveModalRoot" class="preview-root" hidden></div>
|
||||||
<div id="toastRoot" class="toast-root"></div>
|
<div id="toastRoot" class="toast-root"></div>
|
||||||
|
|||||||
Reference in New Issue
Block a user