diff --git a/README.md b/README.md index 30eb0c31..c31a10c3 100644 --- a/README.md +++ b/README.md @@ -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 投递、流程的时限和核实、每天的汇总、各类周期清理、 口径比对的补比和自动召回的续发、接手不在了的进程留下的行,都只在当班的进程里做;其余进程待命,当班的没了待命的接班(平稳关停的马上 diff --git a/backend/app/cluster/slots.py b/backend/app/cluster/slots.py index 06e2312f..cf870f7d 100644 --- a/backend/app/cluster/slots.py +++ b/backend/app/cluster/slots.py @@ -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"] diff --git a/backend/tests/test_cluster.py b/backend/tests/test_cluster.py index e0f6f34d..3fb648a9 100644 --- a/backend/tests/test_cluster.py +++ b/backend/tests/test_cluster.py @@ -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: diff --git a/backend/tests/test_multiprocess_e2e.py b/backend/tests/test_multiprocess_e2e.py index 09469f96..d66c7f16 100644 --- a/backend/tests/test_multiprocess_e2e.py +++ b/backend/tests/test_multiprocess_e2e.py @@ -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 = ( diff --git a/frontend/src/lib/termsConcurrency.ts b/frontend/src/lib/termsConcurrency.ts index 335f1b8c..0b192c6c 100644 --- a/frontend/src/lib/termsConcurrency.ts +++ b/frontend/src/lib/termsConcurrency.ts @@ -6,6 +6,10 @@ export const CONCURRENCY_TEXT = { title: '运行并发', hint: '全站同时执行的运行按来源分四类,各有上限和优先级:名额满了时先给优先级高的一类,已经在执行的不打断。多进程部署时几个进程合起来算', maxTotal: '全站同时运行的上限', + reserve: '给交互留底', + reserveHint: '全站名额里永远留给交互类的个数,应用、定时、系统用不到这一部分:有人问数据、试运行时总有名额。至多全站上限减 1', + borrow: (n: number) => `应用、定时、系统合起来最多借用 ${n} 个名额(全站上限减留底):没人问数据时评测可以同时多开几题`, + limitHint: '各类的上限可以调小;应用、定时、系统再大也借不到留底那一部分', limit: '上限', priority: '优先级', priorityHint: '数字大的先拿到空出来的名额(0 到 100)', diff --git a/frontend/src/pages/ConcurrencySettings.tsx b/frontend/src/pages/ConcurrencySettings.tsx index fbcfc18a..7bcc18f7 100644 --- a/frontend/src/pages/ConcurrencySettings.tsx +++ b/frontend/src/pages/ConcurrencySettings.tsx @@ -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; priorities: Record } +interface Draft { total: string; reserve: string; limits: Record; priorities: Record } type Now = Partial> const draftOf = (group: Record | 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, priorities: Object.fromEntries(CONCURRENCY_CLASSES.map((k) => [k, String(group?.classes?.[k]?.priority ?? '')])) as Record, }) 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) => { setError(null) setDraft({ ...cur, ...patch, limits: { ...cur.limits, ...(patch.limits ?? {}) }, @@ -89,12 +94,21 @@ export function ConcurrencySettingsSection() {
- - {(p) => ( - edit({ total: e.target.value })} data-concurrency-setting="max_total" /> - )} - +
+ + {(p) => ( + edit({ total: e.target.value })} data-concurrency-setting="max_total" /> + )} + + + {(p) => ( + edit({ reserve: e.target.value })} data-concurrency-setting="interactive_reserve" /> + )} + +
+ {borrow != null &&

{T.borrow(borrow)}

}
{CONCURRENCY_CLASSES.map((k) => { const info = T.classes[k] @@ -106,7 +120,7 @@ export function ConcurrencySettingsSection() {
{info.hint}
{live &&
{T.now(live.running ?? 0, live.waiting ?? 0)}
}
- + {(p) => ( edit({ limits: { [k]: e.target.value } as Record })}