feat(sandbox): raise per-user execution capacity
This commit is contained in:
parent
11108f31c2
commit
7b541a2256
|
|
@ -78,7 +78,7 @@ sandbox:
|
||||||
idle_ttl_seconds: 600 # 普通容器无 exec 活动 10 分钟回收
|
idle_ttl_seconds: 600 # 普通容器无 exec 活动 10 分钟回收
|
||||||
max_active_execs: 6 # 宿主硬上限;前后台统一计数
|
max_active_execs: 6 # 宿主硬上限;前后台统一计数
|
||||||
max_background_execs: 4
|
max_background_execs: 4
|
||||||
max_active_execs_per_user: 2
|
max_active_execs_per_user: 3
|
||||||
min_mem_available: 1g # 低于阈值暂停新放行,不杀已运行任务
|
min_mem_available: 1g # 低于阈值暂停新放行,不杀已运行任务
|
||||||
# 容器 DNS server 显式配置(docker run --dns,容器 /etc/resolv.conf 直接写,
|
# 容器 DNS server 显式配置(docker run --dns,容器 /etc/resolv.conf 直接写,
|
||||||
# 绕过 docker daemon 上游 DNS 探测路径;腾讯云轻量 / 部分云上 daemon 探测
|
# 绕过 docker daemon 上游 DNS 探测路径;腾讯云轻量 / 部分云上 daemon 探测
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ class ExecCtx:
|
||||||
task_id: UUID
|
task_id: UUID
|
||||||
working_dir: Path
|
working_dir: Path
|
||||||
cancel_check: Optional[Callable[[], bool]] = None
|
cancel_check: Optional[Callable[[], bool]] = None
|
||||||
|
event_emit: Optional[Callable[[dict], None]] = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
|
||||||
|
|
@ -163,7 +163,13 @@ class DockerExecutor(Executor):
|
||||||
|
|
||||||
def _call_heavy_foreground(self, name: str, args: Dict[str, Any], ctx: ExecCtx) -> ToolResult:
|
def _call_heavy_foreground(self, name: str, args: Dict[str, Any], ctx: ExecCtx) -> ToolResult:
|
||||||
"""前台重型工具先排共享槽;排队不进入命令 timeout。"""
|
"""前台重型工具先排共享槽;排队不进入命令 timeout。"""
|
||||||
with self.pool.capacity.foreground(str(self.user_id), ctx.cancel_check) as admitted:
|
def notify(payload: dict) -> None:
|
||||||
|
if ctx.event_emit is not None:
|
||||||
|
ctx.event_emit({"type": "execution_queue", **payload})
|
||||||
|
|
||||||
|
with self.pool.capacity.foreground(
|
||||||
|
str(self.user_id), ctx.cancel_check, notify
|
||||||
|
) as admitted:
|
||||||
if not admitted:
|
if not admitted:
|
||||||
return ToolResult(content="[Error] command cancelled by user while queued", exit_code=130)
|
return ToolResult(content="[Error] command cancelled by user while queued", exit_code=130)
|
||||||
self.pool.exec_started(self.user_id)
|
self.pool.exec_started(self.user_id)
|
||||||
|
|
|
||||||
|
|
@ -772,6 +772,7 @@ class AgentLoop:
|
||||||
task_id=self.session.task_id,
|
task_id=self.session.task_id,
|
||||||
working_dir=self.working_dir,
|
working_dir=self.working_dir,
|
||||||
cancel_check=self.cancel_check,
|
cancel_check=self.cancel_check,
|
||||||
|
event_emit=self._emit,
|
||||||
)
|
)
|
||||||
tool_started_at = time.time()
|
tool_started_at = time.time()
|
||||||
tool_result = self.executor.call_tool(name, args, ctx)
|
tool_result = self.executor.call_tool(name, args, ctx)
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import json
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager, suppress
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable, Dict, Iterator, Optional
|
from typing import Callable, Dict, Iterator, Optional
|
||||||
|
|
||||||
|
|
@ -18,7 +18,7 @@ from core.file_store import atomic_write_text, interprocess_file_lock
|
||||||
|
|
||||||
DEFAULT_MAX_ACTIVE_EXECS = 6
|
DEFAULT_MAX_ACTIVE_EXECS = 6
|
||||||
DEFAULT_MAX_BACKGROUND_EXECS = 4
|
DEFAULT_MAX_BACKGROUND_EXECS = 4
|
||||||
DEFAULT_MAX_ACTIVE_EXECS_PER_USER = 2
|
DEFAULT_MAX_ACTIVE_EXECS_PER_USER = 3
|
||||||
DEFAULT_MIN_MEM_AVAILABLE_BYTES = 1024 ** 3
|
DEFAULT_MIN_MEM_AVAILABLE_BYTES = 1024 ** 3
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -123,13 +123,21 @@ class ExecCapacity:
|
||||||
self._write(state)
|
self._write(state)
|
||||||
return lease_id
|
return lease_id
|
||||||
|
|
||||||
def acquire_foreground(self, user_id: str, cancel_check: Optional[Callable[[], bool]] = None) -> Optional[str]:
|
def acquire_foreground(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
cancel_check: Optional[Callable[[], bool]] = None,
|
||||||
|
wait_notify: Optional[Callable[[dict], None]] = None,
|
||||||
|
) -> Optional[str]:
|
||||||
ticket = uuid.uuid4().hex
|
ticket = uuid.uuid4().hex
|
||||||
|
wait_notified = False
|
||||||
with interprocess_file_lock(self.lock_path, timeout_seconds=None):
|
with interprocess_file_lock(self.lock_path, timeout_seconds=None):
|
||||||
state = self._read(); self._prune(state)
|
state = self._read(); self._prune(state)
|
||||||
state["foreground_queue"].append({"ticket": ticket, "user_id": str(user_id), "created_ts": time.time(), "owner_pid": os.getpid()})
|
state["foreground_queue"].append({"ticket": ticket, "user_id": str(user_id), "created_ts": time.time(), "owner_pid": os.getpid()})
|
||||||
self._write(state)
|
self._write(state)
|
||||||
while True:
|
while True:
|
||||||
|
admitted = False
|
||||||
|
notification = None
|
||||||
with interprocess_file_lock(self.lock_path, timeout_seconds=None):
|
with interprocess_file_lock(self.lock_path, timeout_seconds=None):
|
||||||
state = self._read(); self._prune(state)
|
state = self._read(); self._prune(state)
|
||||||
queue = state["foreground_queue"]
|
queue = state["foreground_queue"]
|
||||||
|
|
@ -138,8 +146,41 @@ class ExecCapacity:
|
||||||
queue[:] = [q for q in queue if q.get("ticket") != ticket]
|
queue[:] = [q for q in queue if q.get("ticket") != ticket]
|
||||||
state["leases"][ticket] = {"lease_id": ticket, "user_id": str(user_id), "kind": "foreground", "owner_pid": os.getpid(), "started_ts": time.time()}
|
state["leases"][ticket] = {"lease_id": ticket, "user_id": str(user_id), "kind": "foreground", "owner_pid": os.getpid(), "started_ts": time.time()}
|
||||||
self._write(state)
|
self._write(state)
|
||||||
return ticket
|
admitted = True
|
||||||
self._write(state)
|
else:
|
||||||
|
self._write(state)
|
||||||
|
if not admitted and not wait_notified and wait_notify is not None:
|
||||||
|
leases = list(state["leases"].values())
|
||||||
|
user_running = sum(
|
||||||
|
1 for lease in leases
|
||||||
|
if lease.get("user_id") == str(user_id)
|
||||||
|
)
|
||||||
|
available = mem_available_bytes()
|
||||||
|
if user_running >= self.max_per_user:
|
||||||
|
reason = "per_user_limit"
|
||||||
|
elif len(leases) >= self.max_active:
|
||||||
|
reason = "global_limit"
|
||||||
|
elif available is not None and available < self.min_mem_available:
|
||||||
|
reason = "memory_pressure"
|
||||||
|
else:
|
||||||
|
reason = "queue_order"
|
||||||
|
notification = {
|
||||||
|
"state": "waiting",
|
||||||
|
"reason": reason,
|
||||||
|
"user_running": user_running,
|
||||||
|
"user_limit": self.max_per_user,
|
||||||
|
"global_running": len(leases),
|
||||||
|
"global_limit": self.max_active,
|
||||||
|
}
|
||||||
|
wait_notified = True
|
||||||
|
if admitted:
|
||||||
|
if wait_notified and wait_notify is not None:
|
||||||
|
with suppress(Exception):
|
||||||
|
wait_notify({"state": "admitted"})
|
||||||
|
return ticket
|
||||||
|
if notification is not None and wait_notify is not None:
|
||||||
|
with suppress(Exception):
|
||||||
|
wait_notify(notification)
|
||||||
if cancel_check is not None and cancel_check():
|
if cancel_check is not None and cancel_check():
|
||||||
self.cancel_waiter(ticket)
|
self.cancel_waiter(ticket)
|
||||||
return None
|
return None
|
||||||
|
|
@ -189,8 +230,13 @@ class ExecCapacity:
|
||||||
self._write(state)
|
self._write(state)
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def foreground(self, user_id: str, cancel_check: Optional[Callable[[], bool]] = None) -> Iterator[bool]:
|
def foreground(
|
||||||
lease = self.acquire_foreground(user_id, cancel_check)
|
self,
|
||||||
|
user_id: str,
|
||||||
|
cancel_check: Optional[Callable[[], bool]] = None,
|
||||||
|
wait_notify: Optional[Callable[[dict], None]] = None,
|
||||||
|
) -> Iterator[bool]:
|
||||||
|
lease = self.acquire_foreground(user_id, cancel_check, wait_notify)
|
||||||
try:
|
try:
|
||||||
yield lease is not None
|
yield lease is not None
|
||||||
finally:
|
finally:
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ Loop 不直接 print,改 emit({type, ...})。Sink 决定怎么呈现。
|
||||||
llm_end {type, prompt_tokens, completion_tokens, elapsed}
|
llm_end {type, prompt_tokens, completion_tokens, elapsed}
|
||||||
text {type, content} —— assistant 文字段(整段,非流式)
|
text {type, content} —— assistant 文字段(整段,非流式)
|
||||||
tool_call {type, name, args, args_preview}
|
tool_call {type, name, args, args_preview}
|
||||||
|
execution_queue {type, state, reason, ...} —— 前台重型工具等待/放行
|
||||||
tool_result {type, name, result, preview, truncated}
|
tool_result {type, name, result, preview, truncated}
|
||||||
done {type} —— 一次 run 全部结束
|
done {type} —— 一次 run 全部结束
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,15 @@ test("composer uses one Codex-style action button for stop and queued send", ()
|
||||||
assert.match(chat, /removeQueuedMessage\([\s\S]*streamSse\(r\.events_url, run\)/);
|
assert.match(chat, /removeQueuedMessage\([\s\S]*streamSse\(r\.events_url, run\)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("heavy execution queue exposes the blocking reason", () => {
|
||||||
|
const chat = readFileSync(new URL("../web/static/js/chat.js", import.meta.url), "utf8");
|
||||||
|
assert.match(chat, /t === "execution_queue"/);
|
||||||
|
assert.match(chat, /等待执行容量(当前用户/);
|
||||||
|
assert.match(chat, /等待执行容量(整机/);
|
||||||
|
assert.match(chat, /等待执行容量(宿主内存压力)/);
|
||||||
|
assert.match(chat, /dataset\.runningLabel/);
|
||||||
|
});
|
||||||
|
|
||||||
test("automatic title polling starts before a long-running response finishes", () => {
|
test("automatic title polling starts before a long-running response finishes", () => {
|
||||||
const chat = readFileSync(new URL("../web/static/js/chat.js", import.meta.url), "utf8");
|
const chat = readFileSync(new URL("../web/static/js/chat.js", import.meta.url), "utf8");
|
||||||
const sendMessage = chat.indexOf("async function sendMessage(");
|
const sendMessage = chat.indexOf("async function sendMessage(");
|
||||||
|
|
|
||||||
|
|
@ -38,9 +38,13 @@ class FakePool:
|
||||||
self.mark_active_calls = []
|
self.mark_active_calls = []
|
||||||
self.active = 0
|
self.active = 0
|
||||||
self.capacity = self
|
self.capacity = self
|
||||||
|
self.capacity_notices = []
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def foreground(self, user_id, cancel_check=None):
|
def foreground(self, user_id, cancel_check=None, wait_notify=None):
|
||||||
|
if wait_notify is not None:
|
||||||
|
for notice in self.capacity_notices:
|
||||||
|
wait_notify(notice)
|
||||||
yield True
|
yield True
|
||||||
|
|
||||||
def exec_started(self, user_id):
|
def exec_started(self, user_id):
|
||||||
|
|
@ -174,6 +178,27 @@ class TestShellExec(unittest.TestCase):
|
||||||
self.assertEqual(pool.ensure_calls, [executor.user_id])
|
self.assertEqual(pool.ensure_calls, [executor.user_id])
|
||||||
self.assertEqual(pool.mark_active_calls, [executor.user_id])
|
self.assertEqual(pool.mark_active_calls, [executor.user_id])
|
||||||
|
|
||||||
|
def test_shell_forwards_capacity_queue_events(self):
|
||||||
|
executor, pool, _ = make_executor()
|
||||||
|
pool.capacity_notices = [
|
||||||
|
{"state": "waiting", "reason": "per_user_limit"},
|
||||||
|
{"state": "admitted"},
|
||||||
|
]
|
||||||
|
events = []
|
||||||
|
ctx = make_ctx(executor)
|
||||||
|
ctx.event_emit = events.append
|
||||||
|
proc = MagicMock()
|
||||||
|
proc.communicate.return_value = ("ok\n", "")
|
||||||
|
proc.returncode = 0
|
||||||
|
|
||||||
|
with patch("core.executor_docker.subprocess.Popen", return_value=proc):
|
||||||
|
executor.call_tool("shell", {"command": "echo ok"}, ctx)
|
||||||
|
|
||||||
|
self.assertEqual(events, [
|
||||||
|
{"type": "execution_queue", "state": "waiting", "reason": "per_user_limit"},
|
||||||
|
{"type": "execution_queue", "state": "admitted"},
|
||||||
|
])
|
||||||
|
|
||||||
def test_shell_bad_args(self):
|
def test_shell_bad_args(self):
|
||||||
executor, _, _ = make_executor()
|
executor, _, _ = make_executor()
|
||||||
ctx = make_ctx(executor)
|
ctx = make_ctx(executor)
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ class CapacityTests(unittest.TestCase):
|
||||||
with tempfile.TemporaryDirectory() as td:
|
with tempfile.TemporaryDirectory() as td:
|
||||||
cap = ExecCapacity(Path(td), {"max_active_execs": 99, "max_background_execs": 99,
|
cap = ExecCapacity(Path(td), {"max_active_execs": 99, "max_background_execs": 99,
|
||||||
"max_active_execs_per_user": 99})
|
"max_active_execs_per_user": 99})
|
||||||
self.assertEqual((cap.max_active, cap.max_background, cap.max_per_user), (6, 4, 2))
|
self.assertEqual((cap.max_active, cap.max_background, cap.max_per_user), (6, 4, 3))
|
||||||
|
|
||||||
def test_global_per_user_and_background_limits(self):
|
def test_global_per_user_and_background_limits(self):
|
||||||
with tempfile.TemporaryDirectory() as td, patch("core.sandbox.capacity.mem_available_bytes", return_value=10**12):
|
with tempfile.TemporaryDirectory() as td, patch("core.sandbox.capacity.mem_available_bytes", return_value=10**12):
|
||||||
|
|
@ -60,6 +60,36 @@ class CapacityTests(unittest.TestCase):
|
||||||
self.assertEqual(two.snapshot()["foreground_queued"], 0)
|
self.assertEqual(two.snapshot()["foreground_queued"], 0)
|
||||||
one.release(lease)
|
one.release(lease)
|
||||||
|
|
||||||
|
def test_foreground_queue_reports_limit_and_admission(self):
|
||||||
|
with tempfile.TemporaryDirectory() as td, patch("core.sandbox.capacity.mem_available_bytes", return_value=10**12):
|
||||||
|
cap = ExecCapacity(Path(td), {"max_active_execs": 2, "max_active_execs_per_user": 1})
|
||||||
|
held = cap.try_acquire("u1", "background", lease_id="held")
|
||||||
|
notices = []
|
||||||
|
result = []
|
||||||
|
t = threading.Thread(
|
||||||
|
target=lambda: result.append(cap.acquire_foreground("u1", wait_notify=notices.append))
|
||||||
|
)
|
||||||
|
t.start(); time.sleep(.15)
|
||||||
|
self.assertEqual(notices, [{
|
||||||
|
"state": "waiting", "reason": "per_user_limit",
|
||||||
|
"user_running": 1, "user_limit": 1,
|
||||||
|
"global_running": 1, "global_limit": 2,
|
||||||
|
}])
|
||||||
|
cap.release(held); t.join(2)
|
||||||
|
self.assertEqual(len(result), 1)
|
||||||
|
self.assertIsNotNone(result[0])
|
||||||
|
self.assertEqual(notices[-1], {"state": "admitted"})
|
||||||
|
cap.release(result[0])
|
||||||
|
|
||||||
|
def test_immediate_foreground_admission_does_not_report_queue(self):
|
||||||
|
with tempfile.TemporaryDirectory() as td, patch("core.sandbox.capacity.mem_available_bytes", return_value=10**12):
|
||||||
|
cap = ExecCapacity(Path(td), {"max_active_execs": 1})
|
||||||
|
notices = []
|
||||||
|
lease = cap.acquire_foreground("u1", wait_notify=notices.append)
|
||||||
|
self.assertIsNotNone(lease)
|
||||||
|
self.assertEqual(notices, [])
|
||||||
|
cap.release(lease)
|
||||||
|
|
||||||
def test_memory_pressure_pauses_new_admission(self):
|
def test_memory_pressure_pauses_new_admission(self):
|
||||||
with tempfile.TemporaryDirectory() as td, patch("core.sandbox.capacity.mem_available_bytes", return_value=100):
|
with tempfile.TemporaryDirectory() as td, patch("core.sandbox.capacity.mem_available_bytes", return_value=100):
|
||||||
cap = ExecCapacity(Path(td), {"min_mem_available": "1g"})
|
cap = ExecCapacity(Path(td), {"min_mem_available": "1g"})
|
||||||
|
|
|
||||||
|
|
@ -3483,6 +3483,27 @@ function handleSseEvent(ev, asstCard, ctx) {
|
||||||
if (!ctx.runId && snapshot.run_id) ctx.runId = snapshot.run_id;
|
if (!ctx.runId && snapshot.run_id) ctx.runId = snapshot.run_id;
|
||||||
ctx.progressSteps = cloneProgressSteps(snapshot.steps);
|
ctx.progressSteps = cloneProgressSteps(snapshot.steps);
|
||||||
setTaskProgress(ctx.taskId, ctx.progressSteps);
|
setTaskProgress(ctx.taskId, ctx.progressSteps);
|
||||||
|
} else if (t === "execution_queue") {
|
||||||
|
const det = ctx.runningToolEl;
|
||||||
|
if (!det) return;
|
||||||
|
const labelEl = det.querySelector(".tool-label");
|
||||||
|
if (!labelEl) return;
|
||||||
|
const queue = ev.data || {};
|
||||||
|
if (queue.state === "waiting") {
|
||||||
|
det.classList.add("waiting");
|
||||||
|
if (queue.reason === "per_user_limit") {
|
||||||
|
labelEl.textContent = `等待执行容量(当前用户 ${Number(queue.user_running) || 0}/${Number(queue.user_limit) || 0})`;
|
||||||
|
} else if (queue.reason === "global_limit") {
|
||||||
|
labelEl.textContent = `等待执行容量(整机 ${Number(queue.global_running) || 0}/${Number(queue.global_limit) || 0})`;
|
||||||
|
} else if (queue.reason === "memory_pressure") {
|
||||||
|
labelEl.textContent = "等待执行容量(宿主内存压力)";
|
||||||
|
} else {
|
||||||
|
labelEl.textContent = "等待执行容量(前方任务优先)";
|
||||||
|
}
|
||||||
|
} else if (queue.state === "admitted") {
|
||||||
|
det.classList.remove("waiting");
|
||||||
|
labelEl.textContent = det.dataset.runningLabel || labelEl.textContent;
|
||||||
|
}
|
||||||
} else if (t === "tool_call") {
|
} else if (t === "tool_call") {
|
||||||
const fn = (ev.data && ev.data.name) || "?";
|
const fn = (ev.data && ev.data.name) || "?";
|
||||||
const args = (ev.data && ev.data.args) || "";
|
const args = (ev.data && ev.data.args) || "";
|
||||||
|
|
@ -3506,6 +3527,7 @@ function handleSseEvent(ev, asstCard, ctx) {
|
||||||
const label = toolActivityLabel(fn, args);
|
const label = toolActivityLabel(fn, args);
|
||||||
const det = document.createElement("details");
|
const det = document.createElement("details");
|
||||||
det.className = "tool-call activity running";
|
det.className = "tool-call activity running";
|
||||||
|
det.dataset.runningLabel = label;
|
||||||
det.innerHTML = `<summary><span class="tool-state" aria-hidden="true">…</span><span class="tool-label">${escapeHtml(label)}</span><span class="tool-elapsed"></span></summary><pre>${escapeHtml(argsStr)}</pre>`;
|
det.innerHTML = `<summary><span class="tool-state" aria-hidden="true">…</span><span class="tool-label">${escapeHtml(label)}</span><span class="tool-elapsed"></span></summary><pre>${escapeHtml(argsStr)}</pre>`;
|
||||||
asstCard.appendChild(det);
|
asstCard.appendChild(det);
|
||||||
// 运行态:spinner + 跳秒,tool_result 到达时定格(区分"在执行"和"卡死")
|
// 运行态:spinner + 跳秒,tool_result 到达时定格(区分"在执行"和"卡死")
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue