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:
wangjia
2026-06-29 19:11:42 +08:00
parent 1c62059424
commit 7884182d2b
6 changed files with 131 additions and 1 deletions
+30
View File
@@ -132,6 +132,10 @@ const I18N = {
toastNameRequired: '名称与仓库路径必填',
toastTaskNotFound: '任务不在当前项目',
toastConcInt: '最大并发必须是 ≥1 的整数',
globalSettings: '全局设置', globalConc: '全局并发上限',
globalConcHint: '跨所有项目的在途任务总数上限;0 = 不限。',
globalConcInt: '全局并发上限必须是 ≥0 的整数(0 = 不限)',
toastSettingsSaved: '全局设置已保存',
toastRejectRequired: '驳回意见不能为空',
reorderSaved: '项目顺序已保存',
cancelConfirm: '确定取消任务「{title}」{hint}?\n取消后任务进入归档,不影响子任务执行。',
@@ -274,6 +278,10 @@ const I18N = {
toastNameRequired: 'Name and repo path are required',
toastTaskNotFound: 'Task not found in current project',
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',
reorderSaved: 'Project order saved',
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;
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': {
const p = $('#newTaskPanel');
p.hidden = !p.hidden;
@@ -2234,6 +2253,16 @@ $('#newProjectForm').addEventListener('submit', (ev) => {
}, 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) => {
ev.preventDefault();
const f = ev.target;
@@ -2268,6 +2297,7 @@ document.addEventListener('keydown', (ev) => {
const am = $('#archiveModalRoot');
if (!am.hidden) { am.hidden = true; am.innerHTML = ''; return; }
if (S.previewId) { S.previewId = null; S.previewReject = false; renderPreview(); return; }
if (!$('#settingsRoot').hidden) { $('#settingsRoot').hidden = true; return; }
$('#modalRoot').hidden = true;
}
});