运行名额改成给交互留底、其余可借(用户拍板):交互留底缺省全站上限的 1/4、至少 1、至多全站上限减 1,应用、定时、系统三类合起来永远用不到留底;三类各自的上限缺省 = 全站上限 − 留底(设置里可调小),没人问数据时评测能开满;交互上限缺省 = 全站上限;等名额照旧按优先级、同级先来先得。存着的设置原样存、缺省按全站上限和留底现算;留底不小于全站上限时 422。设置页「运行并发」加「给交互留底」一项,写明留底和借用是什么,README 同步。补测试:缺省值、空闲时系统类借满到 全站 − 留底、留底不借给应用类、问数据一来马上拿到留底;多进程端到端加 5b(评测借满时问数据拿留底、不等评测跑完)

This commit is contained in:
ThearchyHelios
2026-10-19 12:53:28 +08:00
parent 319b787688
commit 6ed782a84b
6 changed files with 204 additions and 48 deletions
+7 -5
View File
@@ -1147,11 +1147,13 @@ AGENTLAB_WORKERS=4 …
**停止。** 运行、问数据的一轮、后台任务在别的进程上跑时,「停止」写一条给属主的请求(PostgreSQL 上 NOTIFY 叫醒它,最迟一拍心跳),
属主照本进程的规矩停、收尾(`run.cancelled` 带上是谁停的),界面上和单进程时一样;属主不在了的按上表接手。
**运行并发。** 每段执行(发起、审批之后接着跑、继续运行)开始前要一个名额,按来源分四类,各有全站上限和优先级,几个进程合起来算:
交互(问数据、画布试运行、手动发起的正式运行、蓝图试运行、个人应用刷新)、应用(应用入口、按需视图、钩子、接口)、定时(定时任务、
事件触发)、系统(评测、口径召回、对账、补算、按口径正式出具)。名额满了时先给优先级高的一类,已经在跑的不打断。缺省:全站上限取
`AGENTLAB_MAX_CONCURRENT_RUNS`(20),交互 = 全站上限、应用 2/5、定时 1/5、系统 1/5,优先级 40 / 30 / 20 / 10——评测再多也只占得到
系统那几个名额,不挤占问数据。在「设置 → 偏好设置 → 运行并发」里改(管理员),改了最迟几秒各进程按新的发。单进程时也照这套发名额。
**运行并发。** 每段执行(发起、审批之后接着跑、继续运行)开始前要一个名额,按来源分四类,几个进程合起来算:交互(问数据、画布
试运行、手动发起的正式运行、蓝图试运行、个人应用刷新)、应用(应用入口、按需视图、钩子、接口)、定时(定时任务、事件触发)、系统
(评测、口径召回、对账、补算、按口径正式出具)。全站上限取 `AGENTLAB_MAX_CONCURRENT_RUNS`(缺省 20),其中一部分是**给交互的留底**
(缺省全站上限的 1/4、至少 1、至多全站上限减 1):应用、定时、系统三类合起来永远用不到留底,有人问数据、试运行时总有名额;留底以外的
它们可以**借用**——三类各自的上限缺省就是「全站上限 − 留底」,没人问数据时评测也能开满,设置里可以调小。交互自己的上限缺省等于全站
上限。等名额时按优先级(缺省交互 40 > 应用 30 > 定时 20 > 系统 10,同级先来先得),已经在执行的不打断。在「设置 → 偏好设置 → 运行
并发」里改(管理员),改了最迟几秒各进程按新的发。单进程时也照这套发名额。
**调度当班。** 只有一个进程当班(库里的一份租约):定时认领、事件派发、Webhook 投递、流程的时限和核实、每天的汇总、各类周期清理、
口径比对的补比和自动召回的续发、接手不在了的进程留下的行,都只在当班的进程里做;其余进程待命,当班的没了待命的接班(平稳关停的马上
+61 -30
View File
@@ -10,12 +10,15 @@
定时 schedule:定时任务(含事件触发)
系统 system:评测、口径召回、对账、补算、按口径正式出具——服务端替人跑的
每类有全站的并发上限和优先级(设置里「运行并发」一组,管理员可调),另有全站总上限。名额按库里的计数发:等着的按
优先级高的先、同一优先级先来先得,只要它那一类没到上限、全站没到总上限就发给它。所以评测再多也只占得到系统那一类的
几个名额,问数据不用等它;全站满了的时候,空出来的名额先给交互。已经在跑的不抢。
每类有全站的并发上限和优先级(设置里「运行并发」一组,管理员可调),另有全站总上限,和一个**交互留底**:
缺省(总上限取 AGENTLAB_MAX_CONCURRENT_RUNS):交互 = 总上限、应用 2/5、定时 1/5、系统 1/5(至少各 1),优先级 40 / 30 / 20 / 10。
应用、定时、系统三类加起来不到总上限:交互总留得出名额。单进程时总上限和以前的信号量一样大,问数据、试跑不再被评测挤占。
- 留底:全站名额里永远留给交互类的那几个(缺省全站上限的 1/4、至少 1;至多全站上限减 1)。应用、定时、系统三类合起来
永远用不到这一部分——有人问数据、试跑时总有名额,不用等评测、召回跑完。
- 借用:没人问数据时,别的类可以借满到「全站上限 − 留底」(三类各自的上限缺省就是这个数,设置里可以调小)。评测照旧跑得快。
- 交互自己的上限缺省 = 全站上限:留底之外空着的名额它也能用。
等着的按优先级高的先、同一优先级先来先得(缺省交互 40 > 应用 30 > 定时 20 > 系统 10);已经在跑的不抢。单进程时总上限和以前的
信号量一样大。
**怎么发。** 要名额的一方先在本进程登记、再插一行「等着」,然后自己去发一次(grant:把所有等着的按规矩过一遍,谁够格就
改成「已发」);没轮到就等叫醒(本进程放名额时叫醒,PostgreSQL 上别的进程放名额时 NOTIFY slot 叫醒),另隔一会儿自己再
@@ -70,35 +73,47 @@ def class_of(origin_kind: str | None) -> str:
return ORIGIN_CLASS.get(origin_kind or "", "interactive")
def defaults(total: int | None = None) -> dict[str, Any]:
"""缺省的设置(总上限取 AGENTLAB_MAX_CONCURRENT_RUNS)。"""
def _reserve_of(total: int, reserve: int | None) -> int:
"""生效的交互留底:缺省全站上限的 1/4、至少 1;至多全站上限减 1(全站只有 1 个名额时没有留底,别的类才跑得起来)。"""
wanted = max(1, total // 4) if reserve is None else int(reserve)
return max(0, min(wanted, total - 1))
def defaults(total: int | None = None, reserve: int | None = None) -> dict[str, Any]:
"""缺省的设置(总上限取 AGENTLAB_MAX_CONCURRENT_RUNS):交互上限 = 全站上限,别的三类 = 全站上限 − 交互留底。"""
total = max(1, int(total if total is not None else settings.max_concurrent_runs))
share = {"interactive": total, "app": max(1, total * 2 // 5), "schedule": max(1, total // 5),
"system": max(1, total // 5)}
return {"max_total": total,
"classes": {k: {"limit": share[k], "priority": DEFAULT_PRIORITY[k]} for k in CLASSES}}
kept = _reserve_of(total, reserve)
others = max(1, total - kept)
return {"max_total": total, "interactive_reserve": kept,
"classes": {k: {"limit": total if k == "interactive" else others, "priority": DEFAULT_PRIORITY[k]}
for k in CLASSES}}
def _int_in(value: Any, low: int, high: int) -> bool:
return isinstance(value, int) and not isinstance(value, bool) and low <= value <= high
def values(stored: Any) -> dict[str, Any]:
"""存着的(可能缺项、存坏了)→ 生效的:缺的、坏的补缺省。"""
base = defaults()
out = {"max_total": base["max_total"], "classes": {k: dict(v) for k, v in base["classes"].items()}}
if not isinstance(stored, dict):
return out
total = stored.get("max_total")
if isinstance(total, int) and not isinstance(total, bool) and 1 <= total <= LIMIT_MAX:
out["max_total"] = total
"""存着的(可能缺项、存坏了)→ 生效的:缺的、坏的补缺省(缺省按存着的全站上限、留底算)。"""
stored = stored if isinstance(stored, dict) else {}
total = stored.get("max_total") if _int_in(stored.get("max_total"), 1, LIMIT_MAX) else None
reserve = stored.get("interactive_reserve") if _int_in(stored.get("interactive_reserve"), 0, LIMIT_MAX) else None
out = defaults(total, reserve)
classes = stored.get("classes") if isinstance(stored.get("classes"), dict) else {}
for k in CLASSES:
item = classes.get(k) if isinstance(classes.get(k), dict) else {}
limit, prio = item.get("limit"), item.get("priority")
if isinstance(limit, int) and not isinstance(limit, bool) and 1 <= limit <= LIMIT_MAX:
out["classes"][k]["limit"] = limit
if isinstance(prio, int) and not isinstance(prio, bool) and 0 <= prio <= PRIORITY_MAX:
out["classes"][k]["priority"] = prio
if _int_in(item.get("limit"), 1, LIMIT_MAX):
out["classes"][k]["limit"] = item["limit"]
if _int_in(item.get("priority"), 0, PRIORITY_MAX):
out["classes"][k]["priority"] = item["priority"]
return out
def borrowable(pol: dict[str, Any]) -> int:
"""应用、定时、系统三类合起来最多用几个名额:全站上限 − 交互留底。"""
return max(0, int(pol["max_total"]) - int(pol.get("interactive_reserve") or 0))
def problem(sent: Any) -> str | None:
"""设置接口写进来的这一组有没有问题(说明给人看)。只核对给了的项。"""
if not isinstance(sent, dict):
@@ -106,6 +121,11 @@ def problem(sent: Any) -> str | None:
total = sent.get("max_total")
if total is not None and (not isinstance(total, int) or isinstance(total, bool) or not 1 <= total <= LIMIT_MAX):
return f"全站同时运行的上限需要是 1 到 {LIMIT_MAX} 之间的整数"
reserve = sent.get("interactive_reserve")
if reserve is not None and not _int_in(reserve, 0, LIMIT_MAX):
return f"给交互留底的名额需要是 0 到 {LIMIT_MAX} 之间的整数"
if reserve is not None and isinstance(total, int) and total > 1 and reserve >= total:
return "给交互留底的名额要小于全站同时运行的上限:留底占满了,应用、定时、系统就一个都运行不了"
classes = sent.get("classes")
if classes is None:
return None
@@ -125,14 +145,18 @@ def problem(sent: Any) -> str | None:
def merged(saved: Any, sent: dict[str, Any]) -> dict[str, Any]:
"""只给了一部分的(只改一类的上限)补上存着的另几项。"""
out = values(saved)
if "max_total" in sent and sent["max_total"] is not None:
out["max_total"] = sent["max_total"]
"""只给了一部分的(只改一类的上限)补上存着的另几项。存的是原样(没给的类别上限不写,按全站上限、留底现算缺省)。"""
base = dict(saved) if isinstance(saved, dict) else {}
out: dict[str, Any] = {k: base[k] for k in ("max_total", "interactive_reserve") if k in base}
for key in ("max_total", "interactive_reserve"):
if sent.get(key) is not None:
out[key] = sent[key]
classes = {k: dict(v) for k, v in (base.get("classes") or {}).items() if k in CLASSES and isinstance(v, dict)}
for k, item in (sent.get("classes") or {}).items():
for field_ in ("limit", "priority"):
if item.get(field_) is not None:
out["classes"][k][field_] = item[field_]
classes.setdefault(k, {})[field_] = item[field_]
out["classes"] = classes
return out
@@ -219,6 +243,9 @@ async def grant() -> list[str]:
rows = [r for r in rows if r.run_id not in set(dead)]
used = Counter(r.klass for r in rows if r.granted)
total = sum(used.values())
# 交互以外的三类合起来用了几个:到了「全站上限 − 交互留底」就不再发给它们(留底永远给交互)
others = total - used["interactive"]
others_cap = borrowable(pol)
limits = {k: int(v["limit"]) for k, v in pol["classes"].items()}
granted: list[str] = []
waiting = sorted((r for r in rows if not r.granted),
@@ -229,8 +256,12 @@ async def grant() -> list[str]:
break
if used[row.klass] >= limits.get(row.klass, int(pol["max_total"])):
continue
if row.klass != "interactive" and others >= others_cap:
continue
used[row.klass] += 1
total += 1
if row.klass != "interactive":
others += 1
granted.append(row.run_id)
if granted:
await session.execute(update(RunSlot).where(RunSlot.run_id.in_(granted))
@@ -316,5 +347,5 @@ async def snapshot() -> dict[str, Any]:
return {"policy": await policy(), "classes": out}
__all__ = ["CLASSES", "CLASS_LABEL", "ORIGIN_CLASS", "SETTING_KEY", "class_of", "defaults", "forget_policy", "grant",
__all__ = ["CLASSES", "CLASS_LABEL", "ORIGIN_CLASS", "SETTING_KEY", "borrowable", "class_of", "defaults", "forget_policy", "grant",
"hold", "merged", "policy", "problem", "snapshot", "values", "wake_local"]
+59 -3
View File
@@ -47,9 +47,11 @@ async def _peer(owner: str = PEER, *, alive: bool = True) -> None:
state.forget()
def _policy(monkeypatch, total: int, **classes: tuple[int, int]) -> None:
value = slots.values({"max_total": total, "classes": {k: {"limit": lim, "priority": pri}
for k, (lim, pri) in classes.items()}})
def _policy(monkeypatch, total: int, reserve: int | None = None, **classes: tuple[int, int]) -> None:
stored: dict = {"max_total": total, "classes": {k: {"limit": lim, "priority": pri} for k, (lim, pri) in classes.items()}}
if reserve is not None:
stored["interactive_reserve"] = reserve
value = slots.values(stored)
async def fixed(*, fresh: bool = False): # noqa: ARG001
return value
@@ -117,6 +119,60 @@ async def test_a_class_at_its_cap_does_not_hold_up_the_others(monkeypatch):
await asyncio.wait_for(asyncio.gather(*tasks), timeout=10)
def test_the_defaults_keep_a_reserve_for_interactive_and_lend_the_rest():
pol = slots.defaults(20)
assert pol["interactive_reserve"] == 5 and pol["classes"]["interactive"]["limit"] == 20
assert {pol["classes"][k]["limit"] for k in ("app", "schedule", "system")} == {15}, "别的类缺省借满到 全站 − 留底"
assert slots.defaults(2)["interactive_reserve"] == 1 and slots.defaults(1)["interactive_reserve"] == 0, \
"至少留 1 个;全站只有 1 个名额时不留(别的类才跑得起来)"
assert slots.values({"max_total": 8})["classes"]["system"]["limit"] == 6, "缺省按存着的全站上限现算"
assert slots.problem({"max_total": 4, "interactive_reserve": 4}) is not None
async def _holders(names_origins: list[tuple[str, str]], got: list[str], gate: asyncio.Event) -> list[asyncio.Task]:
async def run(name: str, origin: str) -> None:
async with slots.hold(f"run-{name}", origin):
got.append(name)
await gate.wait()
tasks = []
for name, origin in names_origins:
tasks.append(asyncio.create_task(run(name, origin)))
await asyncio.sleep(0.05)
return tasks
async def test_idle_system_runs_borrow_all_but_the_interactive_reserve(monkeypatch):
"""全站 4 个、留底 1 个:没人问数据时评测能同时跑 3 个(全站 − 留底),第 4 个等着。"""
_policy(monkeypatch, 4, reserve=1)
got: list[str] = []
gate = asyncio.Event()
tasks = await _holders([(f"e{i}", "eval") for i in range(4)], got, gate)
await _until(lambda: len(got) == 3)
await asyncio.sleep(0.3)
assert len(got) == 3, "留底那一个不借给评测"
assert (await slots.snapshot())["classes"]["system"] == {"running": 3, "waiting": 1}
gate.set()
await asyncio.wait_for(asyncio.gather(*tasks), timeout=10)
async def test_interactive_takes_the_reserve_at_once_and_it_is_never_lent(monkeypatch):
"""评测借满了(3 个在跑),应用类的再来也借不到留底(全站还空着 1 个);问数据一来马上拿到留底,不用等评测跑完。"""
_policy(monkeypatch, 4, reserve=1)
got: list[str] = []
gate = asyncio.Event()
tasks = await _holders([("e1", "eval"), ("e2", "eval"), ("e3", "eval")], got, gate)
await _until(lambda: len(got) == 3)
tasks += await _holders([("app", "app_view")], got, gate)
await asyncio.sleep(0.3)
assert "app" not in got, "留底不借给应用类"
tasks += await _holders([("chat", "chat")], got, gate)
await _until(lambda: "chat" in got)
assert "app" not in got and set(got) == {"e1", "e2", "e3", "chat"}
gate.set()
await asyncio.wait_for(asyncio.gather(*tasks), timeout=10)
async def test_slots_held_by_a_process_that_is_gone_are_taken_back(monkeypatch):
_policy(monkeypatch, 1, interactive=(1, 40))
async with SessionLocal() as session:
+51 -2
View File
@@ -6,8 +6,9 @@
进程只执行一次;两边的事件流都按 seq 递增、不重不漏,封存核对得上;
3. 在 A 上跑的运行,从 B 停止:照本进程的停止办(run.cancelled 带上是谁停的);
4. 定时任务到点只触发一次(两个进程都起着调度循环,只有当班的做);
5. 评测和问数据并发时优先级生效:全站只给 1 个名额,评测的作答运行(系统类,由本测试进程作为第三个登记的工作进程以评测的
来源发起——工作进程里发起评测要真的去调模型建图)排着队时,后来的问数据运行(交互类,经 A 的接口发起)先拿到名额;
5. 评测和问数据并发时优先级生效:全站只给 1 个名额(没有留底),评测的作答运行(系统类,由本测试进程作为第三个登记的工作进程
以评测的来源发起——工作进程里发起评测要真的去调模型建图)排着队时,后来的问数据运行(交互类,经 A 的接口发起)先拿到名额;
全站 2 个、给交互留底 1 个:评测借满了别的名额、还有题排着,问数据一来马上拿到留底,不等评测跑完;
6. 每日花费上限跨进程不越过:两个进程同时往同一本账上记,记下的合计不超过上限;
7. 强杀 B:租约过期后,B 上在跑的运行被照实标中断(可从断点继续),停在审批上的运行照常能在 A 上批、接着跑完;B 当着调度班的
话 A 接班。
@@ -404,6 +405,54 @@ async def test_5_chat_goes_before_queued_evaluation_runs(workers, monkeypatch):
await client.put(f"{a.base}/api/settings", json={"values": {"concurrency": slots.defaults()}})
async def test_5b_the_interactive_reserve_is_there_when_evaluations_fill_the_rest(workers, monkeypatch):
"""全站 2 个名额、留底 1 个:评测(系统类)只借得到 1 个,第二题排着;问数据一来马上拿到留底,评测的第一题还在跑。"""
from app import cluster
from app.cluster import slots
from app.cluster.relay import relay
from app.core.config import settings
from app.db.base import SessionLocal
from app.db.models import Run
from app.engine.runner import run_manager
a = workers["A"]
async with await _client() as client:
put = await client.put(f"{a.base}/api/settings", json={"values": {"concurrency": {
"max_total": 2, "interactive_reserve": 1, "classes": {}}}})
assert put.status_code == 200, put.text
assert put.json()["concurrency"]["classes"]["system"]["limit"] == 1, "别的类缺省借满到 全站 − 留底"
slots.forget_policy()
monkeypatch.setattr(settings, "sandbox_backend", "local")
await cluster.start()
await relay.start()
await run_manager.setup()
try:
evals = [await run_manager.start(graph=slow_graph(3), input_payload={"question": f"评测 {i}"},
origin_kind="eval", origin={"kind": "eval"}) for i in range(2)]
async def first_running() -> Any:
async with SessionLocal() as session:
return (await session.get(Run, evals[0].id)).status == "running"
await _until(first_running, what="第一题的作答运行拿到名额")
chat_id = (await client.post(f"{a.base}/api/runs", json={
"graph": slow_graph(0.2), "input": {"question": "问数据"}, "origin": "chat"})).json()["id"]
async def all_done() -> Any:
async with SessionLocal() as session:
rows = [await session.get(Run, rid, populate_existing=True) for rid in [*[e.id for e in evals], chat_id]]
return rows if all(r.status in ("succeeded", "failed") for r in rows) else None
e1, e2, chat = await _until(all_done, timeout=60, what="都跑完")
assert chat.started_at < e1.finished_at, "问数据拿到的是留底:评测的第一题还没跑完它就开始了"
assert e2.started_at >= e1.finished_at, "评测的第二题借不到留底,等第一题跑完"
finally:
await run_manager.shutdown()
await relay.stop()
await cluster.stop()
await client.put(f"{a.base}/api/settings", json={"values": {"concurrency": slots.defaults()}})
def test_6_the_daily_cap_holds_across_processes(workers):
"""两个进程同时往评测那本账上各记 20 次、每次 0.1 美元,上限 1 美元:合计记下的正好 10 次、不超过上限。"""
script = (
+4
View File
@@ -6,6 +6,10 @@ export const CONCURRENCY_TEXT = {
title: '运行并发',
hint: '全站同时执行的运行按来源分四类,各有上限和优先级:名额满了时先给优先级高的一类,已经在执行的不打断。多进程部署时几个进程合起来算',
maxTotal: '全站同时运行的上限',
reserve: '给交互留底',
reserveHint: '全站名额里永远留给交互类的个数,应用、定时、系统用不到这一部分:有人问数据、试运行时总有名额。至多全站上限减 1',
borrow: (n: number) => `应用、定时、系统合起来最多借用 ${n} 个名额(全站上限减留底):没人问数据时评测可以同时多开几题`,
limitHint: '各类的上限可以调小;应用、定时、系统再大也借不到留底那一部分',
limit: '上限',
priority: '优先级',
priorityHint: '数字大的先拿到空出来的名额(0 到 100)',
+22 -8
View File
@@ -8,17 +8,19 @@ import { atLeast, usePrincipal } from '../store/auth'
const T = CONCURRENCY_TEXT
type Klass = (typeof CONCURRENCY_CLASSES)[number]
interface Draft { total: string; limits: Record<Klass, string>; priorities: Record<Klass, string> }
interface Draft { total: string; reserve: string; limits: Record<Klass, string>; priorities: Record<Klass, string> }
type Now = Partial<Record<Klass, { running?: number; waiting?: number }>>
const draftOf = (group: Record<string, any> | undefined): Draft => ({
total: group?.max_total == null ? '' : String(group.max_total),
reserve: group?.interactive_reserve == null ? '' : String(group.interactive_reserve),
limits: Object.fromEntries(CONCURRENCY_CLASSES.map((k) => [k, String(group?.classes?.[k]?.limit ?? '')])) as Record<Klass, string>,
priorities: Object.fromEntries(CONCURRENCY_CLASSES.map((k) => [k, String(group?.classes?.[k]?.priority ?? '')])) as Record<Klass, string>,
})
const changedCount = (a: Draft, b: Draft): number =>
(a.total.trim() !== b.total ? 1 : 0)
+ (a.reserve.trim() !== b.reserve ? 1 : 0)
+ CONCURRENCY_CLASSES.filter((k) => a.limits[k].trim() !== b.limits[k]).length
+ CONCURRENCY_CLASSES.filter((k) => a.priorities[k].trim() !== b.priorities[k]).length
@@ -62,6 +64,7 @@ export function ConcurrencySettingsSection() {
try {
const value = {
max_total: Number(cur.total.trim()),
interactive_reserve: Number(cur.reserve.trim()),
classes: Object.fromEntries(CONCURRENCY_CLASSES.map((k) => [k, {
limit: Number(cur.limits[k].trim()), priority: Number(cur.priorities[k].trim()),
}])),
@@ -80,6 +83,8 @@ export function ConcurrencySettingsSection() {
}
if (loadError || !cur) return null
// 借用的上限按存着的算(服务端生效的那份),改到一半的输入不跟着跳
const borrow = saved && saved.total !== '' && saved.reserve !== '' ? Math.max(0, Number(saved.total) - Number(saved.reserve)) : null
const edit = (patch: Partial<Draft>) => {
setError(null)
setDraft({ ...cur, ...patch, limits: { ...cur.limits, ...(patch.limits ?? {}) },
@@ -89,12 +94,21 @@ export function ConcurrencySettingsSection() {
<section data-concurrency-settings="">
<SectionBar title={T.title} hint={T.hint} />
<fieldset disabled={!admin || saving} className="m-0 min-w-0 space-y-3 border-0 p-0">
<Field label={T.maxTotal} htmlFor="pref-concurrency-total">
{(p) => (
<input {...p} className="field tnum max-w-40" inputMode="numeric" value={cur.total}
onChange={(e) => edit({ total: e.target.value })} data-concurrency-setting="max_total" />
)}
</Field>
<div className="grid grid-cols-2 gap-3 max-sm:grid-cols-1">
<Field label={T.maxTotal} htmlFor="pref-concurrency-total">
{(p) => (
<input {...p} className="field tnum max-w-40" inputMode="numeric" value={cur.total}
onChange={(e) => edit({ total: e.target.value })} data-concurrency-setting="max_total" />
)}
</Field>
<Field label={T.reserve} htmlFor="pref-concurrency-reserve" hint={T.reserveHint}>
{(p) => (
<input {...p} className="field tnum max-w-40" inputMode="numeric" value={cur.reserve}
onChange={(e) => edit({ reserve: e.target.value })} data-concurrency-setting="interactive_reserve" />
)}
</Field>
</div>
{borrow != null && <p className="text-2xs text-dim" data-concurrency-borrow="">{T.borrow(borrow)}</p>}
<div className="space-y-2">
{CONCURRENCY_CLASSES.map((k) => {
const info = T.classes[k]
@@ -106,7 +120,7 @@ export function ConcurrencySettingsSection() {
<div className="text-2xs leading-relaxed text-faint">{info.hint}</div>
{live && <div className="text-2xs text-dim" data-concurrency-now={k}>{T.now(live.running ?? 0, live.waiting ?? 0)}</div>}
</div>
<Field label={T.limit} htmlFor={`pref-concurrency-${k}-limit`}>
<Field label={T.limit} htmlFor={`pref-concurrency-${k}-limit`} hint={k === 'app' ? T.limitHint : undefined}>
{(p) => (
<input {...p} className="field tnum max-w-32" inputMode="numeric" value={cur.limits[k]}
onChange={(e) => edit({ limits: { [k]: e.target.value } as Record<Klass, string> })}