feat(admin): Agent 并发 - 后台可调同时对话上限并展示 Agent 服务状态
新增运行策略 agentMaxSessions(默认 30,范围 1-64),保存后同时下发到内嵌进程闸门与独立 yingce-agent 的 /v1/limit,降低上限不会中断已开始的对话。系统性能页展示 Agent 服务健康、进行中/排队数量,并可直接修改上限。
This commit is contained in:
@@ -222,27 +222,83 @@ func RuntimeDir() (string, error) {
|
|||||||
return "", errors.New("Agent runtime files are missing; set CANVAS_PI_RUNTIME_DIR")
|
return "", errors.New("Agent runtime files are missing; set CANVAS_PI_RUNTIME_DIR")
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
const (
|
||||||
agentSlotsOnce sync.Once
|
defaultAgentProcessLimit = 30
|
||||||
agentSlots chan struct{}
|
maxAgentProcessLimit = 64
|
||||||
)
|
)
|
||||||
|
|
||||||
func acquireAgentProcess(ctx context.Context) (func(), error) {
|
type agentProcessGate struct {
|
||||||
agentSlotsOnce.Do(func() {
|
mu sync.Mutex
|
||||||
limit := 8
|
limit int
|
||||||
if raw := strings.TrimSpace(os.Getenv("CANVAS_AGENT_MAX_PROCESSES")); raw != "" {
|
active int
|
||||||
if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 && parsed <= 1000 {
|
wake chan struct{}
|
||||||
limit = parsed
|
}
|
||||||
}
|
|
||||||
|
var processGate = newAgentProcessGate()
|
||||||
|
|
||||||
|
func newAgentProcessGate() *agentProcessGate {
|
||||||
|
limit := defaultAgentProcessLimit
|
||||||
|
if raw := strings.TrimSpace(os.Getenv("CANVAS_AGENT_MAX_PROCESSES")); raw != "" {
|
||||||
|
if parsed, err := strconv.Atoi(raw); err == nil {
|
||||||
|
limit = clampAgentProcessLimit(parsed)
|
||||||
}
|
}
|
||||||
agentSlots = make(chan struct{}, limit)
|
|
||||||
})
|
|
||||||
select {
|
|
||||||
case agentSlots <- struct{}{}:
|
|
||||||
return func() { <-agentSlots }, nil
|
|
||||||
case <-ctx.Done():
|
|
||||||
return nil, ctx.Err()
|
|
||||||
}
|
}
|
||||||
|
return &agentProcessGate{limit: limit, wake: make(chan struct{})}
|
||||||
|
}
|
||||||
|
|
||||||
|
func clampAgentProcessLimit(value int) int {
|
||||||
|
if value < 1 {
|
||||||
|
return defaultAgentProcessLimit
|
||||||
|
}
|
||||||
|
if value > maxAgentProcessLimit {
|
||||||
|
return maxAgentProcessLimit
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetProcessLimit changes how many embedded Agent processes may run at once.
|
||||||
|
// Lowering the limit does not stop a process that has already started.
|
||||||
|
func SetProcessLimit(value int) (active int, limit int) {
|
||||||
|
processGate.mu.Lock()
|
||||||
|
defer processGate.mu.Unlock()
|
||||||
|
processGate.limit = clampAgentProcessLimit(value)
|
||||||
|
close(processGate.wake)
|
||||||
|
processGate.wake = make(chan struct{})
|
||||||
|
return processGate.active, processGate.limit
|
||||||
|
}
|
||||||
|
|
||||||
|
func ProcessUsage() (active int, limit int) {
|
||||||
|
processGate.mu.Lock()
|
||||||
|
defer processGate.mu.Unlock()
|
||||||
|
return processGate.active, processGate.limit
|
||||||
|
}
|
||||||
|
|
||||||
|
func acquireAgentProcess(ctx context.Context) (func(), error) {
|
||||||
|
for {
|
||||||
|
processGate.mu.Lock()
|
||||||
|
if processGate.active < processGate.limit {
|
||||||
|
processGate.active++
|
||||||
|
processGate.mu.Unlock()
|
||||||
|
return releaseAgentProcess, nil
|
||||||
|
}
|
||||||
|
wake := processGate.wake
|
||||||
|
processGate.mu.Unlock()
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
case <-wake:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func releaseAgentProcess() {
|
||||||
|
processGate.mu.Lock()
|
||||||
|
if processGate.active > 0 {
|
||||||
|
processGate.active--
|
||||||
|
}
|
||||||
|
close(processGate.wake)
|
||||||
|
processGate.wake = make(chan struct{})
|
||||||
|
processGate.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func nodeMemoryMB() string {
|
func nodeMemoryMB() string {
|
||||||
|
|||||||
@@ -24,6 +24,25 @@ func fakeRuntime(t *testing.T, script string) {
|
|||||||
t.Setenv("CANVAS_PI_RUNTIME_DIR", dir)
|
t.Setenv("CANVAS_PI_RUNTIME_DIR", dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSetProcessLimitDoesNotStopActiveWork(t *testing.T) {
|
||||||
|
t.Cleanup(func() { SetProcessLimit(defaultAgentProcessLimit) })
|
||||||
|
release, err := acquireAgentProcess(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
active, limit := SetProcessLimit(1)
|
||||||
|
if active != 1 || limit != 1 {
|
||||||
|
t.Fatalf("active=%d limit=%d, want active 1 and limit 1", active, limit)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
if _, err := acquireAgentProcess(ctx); err == nil {
|
||||||
|
t.Fatal("lowered limit still admitted another process")
|
||||||
|
}
|
||||||
|
release()
|
||||||
|
SetProcessLimit(defaultAgentProcessLimit)
|
||||||
|
}
|
||||||
|
|
||||||
func noopBridge() Bridge {
|
func noopBridge() Bridge {
|
||||||
handler := func(context.Context, map[string]json.RawMessage) (any, error) { return map[string]any{"ok": true}, nil }
|
handler := func(context.Context, map[string]json.RawMessage) (any, error) { return map[string]any{"ok": true}, nil }
|
||||||
return Bridge{Model: handler, Tool: handler, Event: handler}
|
return Bridge{Model: handler, Tool: handler, Event: handler}
|
||||||
|
|||||||
@@ -14,6 +14,68 @@ import (
|
|||||||
agentruntime "infinite-canvas/backend/internal/agent/runtime"
|
agentruntime "infinite-canvas/backend/internal/agent/runtime"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type Status struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Active int `json:"active"`
|
||||||
|
Limit int `json:"limit"`
|
||||||
|
Queued int `json:"queued"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func Inspect(ctx context.Context, endpoint string) (Status, error) {
|
||||||
|
endpoint = strings.TrimRight(strings.TrimSpace(endpoint), "/")
|
||||||
|
if endpoint == "" {
|
||||||
|
return Status{}, fmt.Errorf("YINGCE_AGENT_URL is empty")
|
||||||
|
}
|
||||||
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint+"/healthz", nil)
|
||||||
|
if err != nil {
|
||||||
|
return Status{}, err
|
||||||
|
}
|
||||||
|
response, err := remoteClient.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return Status{}, err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if response.StatusCode != http.StatusOK {
|
||||||
|
return Status{}, fmt.Errorf("yingce-agent health returned HTTP %d", response.StatusCode)
|
||||||
|
}
|
||||||
|
var status Status
|
||||||
|
if err := json.NewDecoder(io.LimitReader(response.Body, 4096)).Decode(&status); err != nil {
|
||||||
|
return Status{}, err
|
||||||
|
}
|
||||||
|
return status, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetLimit(ctx context.Context, endpoint, serviceToken string, limit int) error {
|
||||||
|
endpoint = strings.TrimRight(strings.TrimSpace(endpoint), "/")
|
||||||
|
serviceToken = strings.TrimSpace(serviceToken)
|
||||||
|
if endpoint == "" {
|
||||||
|
return fmt.Errorf("YINGCE_AGENT_URL is empty")
|
||||||
|
}
|
||||||
|
if len(serviceToken) < 32 {
|
||||||
|
return fmt.Errorf("YINGCE_AGENT_TOKEN must contain at least 32 characters")
|
||||||
|
}
|
||||||
|
payload, err := json.Marshal(map[string]int{"maxSessions": limit})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
request, err := http.NewRequestWithContext(ctx, http.MethodPut, endpoint+"/v1/limit", bytes.NewReader(payload))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
request.Header.Set("Authorization", "Bearer "+serviceToken)
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
response, err := remoteClient.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if response.StatusCode != http.StatusOK {
|
||||||
|
detail, _ := io.ReadAll(io.LimitReader(response.Body, 1024))
|
||||||
|
return fmt.Errorf("yingce-agent limit returned HTTP %d: %s", response.StatusCode, strings.TrimSpace(string(detail)))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Run executes one Agent session on the independent yingce-agent service.
|
// Run executes one Agent session on the independent yingce-agent service.
|
||||||
// The Go process keeps the bridge, billing, and tool execution. A configured
|
// The Go process keeps the bridge, billing, and tool execution. A configured
|
||||||
// remote endpoint never falls back to an embedded Node process: doing so can
|
// remote endpoint never falls back to an embedded Node process: doing so can
|
||||||
|
|||||||
@@ -116,4 +116,14 @@ if (!response.ok) {
|
|||||||
if !called {
|
if !called {
|
||||||
t.Fatal("remote runtime did not call the Go tool bridge")
|
t.Fatal("remote runtime did not call the Go tool bridge")
|
||||||
}
|
}
|
||||||
|
if err := SetLimit(ctx, endpoint, token, 16); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
status, err := Inspect(ctx, endpoint)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if status.Limit != 16 {
|
||||||
|
t.Fatalf("limit = %d, want 16", status.Limit)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,14 +3,18 @@ package app
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
agentruntime "infinite-canvas/backend/internal/agent/runtime"
|
||||||
|
"infinite-canvas/backend/internal/agent/yingceagent"
|
||||||
"infinite-canvas/backend/internal/buildinfo"
|
"infinite-canvas/backend/internal/buildinfo"
|
||||||
"infinite-canvas/backend/internal/model"
|
"infinite-canvas/backend/internal/model"
|
||||||
|
"infinite-canvas/backend/internal/platform"
|
||||||
"infinite-canvas/backend/internal/repository"
|
"infinite-canvas/backend/internal/repository"
|
||||||
|
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
@@ -28,9 +32,23 @@ type AdminSystemPerformance struct {
|
|||||||
Disk SystemPerformanceDisk `json:"disk"`
|
Disk SystemPerformanceDisk `json:"disk"`
|
||||||
Database repository.DatabaseRuntimeStats `json:"database"`
|
Database repository.DatabaseRuntimeStats `json:"database"`
|
||||||
Redis SystemPerformanceRedis `json:"redis"`
|
Redis SystemPerformanceRedis `json:"redis"`
|
||||||
|
Agents []SystemPerformanceAgent `json:"agents"`
|
||||||
Build buildinfo.Info `json:"build"`
|
Build buildinfo.Info `json:"build"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SystemPerformanceAgent struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
Endpoint string `json:"endpoint"`
|
||||||
|
Healthy bool `json:"healthy"`
|
||||||
|
Active int `json:"active"`
|
||||||
|
Limit int `json:"limit"`
|
||||||
|
Queued int `json:"queued"`
|
||||||
|
ConfiguredLimit int `json:"configuredLimit"`
|
||||||
|
StatusMessage string `json:"statusMessage,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type SystemPerformanceHost struct {
|
type SystemPerformanceHost struct {
|
||||||
Hostname string `json:"hostname"`
|
Hostname string `json:"hostname"`
|
||||||
OS string `json:"os"`
|
OS string `json:"os"`
|
||||||
@@ -149,17 +167,98 @@ func (s *Service) AdminSystemPerformance(ctx context.Context, actor *model.User)
|
|||||||
disk := collectSystemDisk(s.dataDir)
|
disk := collectSystemDisk(s.dataDir)
|
||||||
databaseStats, databaseErr := s.repo.DatabaseRuntimeStats(ctx)
|
databaseStats, databaseErr := s.repo.DatabaseRuntimeStats(ctx)
|
||||||
redisStats := s.collectRedisPerformance(ctx)
|
redisStats := s.collectRedisPerformance(ctx)
|
||||||
|
agents := s.collectAgentServices(ctx)
|
||||||
status := "healthy"
|
status := "healthy"
|
||||||
if databaseErr != nil || !databaseStats.Connected || !disk.Available || !disk.Writable || s.ValidateRuntime() != nil || (redisStats.Configured && !redisStats.Connected) {
|
if databaseErr != nil || !databaseStats.Connected || !disk.Available || !disk.Writable || s.ValidateRuntime() != nil || (redisStats.Configured && !redisStats.Connected) || agentServicesDegraded(agents) {
|
||||||
status = "degraded"
|
status = "degraded"
|
||||||
}
|
}
|
||||||
return &AdminSystemPerformance{
|
return &AdminSystemPerformance{
|
||||||
CollectedAt: time.Now(), Status: status,
|
CollectedAt: time.Now(), Status: status,
|
||||||
Host: SystemPerformanceHost{Hostname: hostname, OS: runtime.GOOS, Arch: runtime.GOARCH, CPUCores: runtime.NumCPU(), GOMAXPROCS: runtime.GOMAXPROCS(0), ProcessID: os.Getpid(), UptimeSeconds: int64(time.Since(systemProcessStartedAt).Seconds()), Goroutines: runtime.NumGoroutine(), ActiveWorkerTasks: s.ActiveWorkerTasks(), LoadAverage: load, LoadAverageAvailable: loadAvailable},
|
Host: SystemPerformanceHost{Hostname: hostname, OS: runtime.GOOS, Arch: runtime.GOARCH, CPUCores: runtime.NumCPU(), GOMAXPROCS: runtime.GOMAXPROCS(0), ProcessID: os.Getpid(), UptimeSeconds: int64(time.Since(systemProcessStartedAt).Seconds()), Goroutines: runtime.NumGoroutine(), ActiveWorkerTasks: s.ActiveWorkerTasks(), LoadAverage: load, LoadAverageAvailable: loadAvailable},
|
||||||
Memory: memory, Disk: disk, Database: databaseStats, Redis: redisStats, Build: buildinfo.Current(),
|
Memory: memory, Disk: disk, Database: databaseStats, Redis: redisStats, Agents: agents, Build: buildinfo.Current(),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AgentSessionLimitRequest struct {
|
||||||
|
MaxSessions int `json:"maxSessions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) UpdateAgentSessionLimit(ctx context.Context, actor *model.User, maxSessions int) (*SystemPerformanceAgent, error) {
|
||||||
|
if err := s.RequireAdmin(actor); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if maxSessions < platform.MinRuntimeAgentSessions || maxSessions > platform.MaxRuntimeAgentSessions {
|
||||||
|
return nil, BadAuthRequest(fmt.Sprintf("Agent 同时对话上限必须是 %d-%d 的整数", platform.MinRuntimeAgentSessions, platform.MaxRuntimeAgentSessions))
|
||||||
|
}
|
||||||
|
policy, err := s.RuntimePolicy()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
policy.Task.AgentMaxSessions = maxSessions
|
||||||
|
if _, err := s.UpdateRuntimePolicySetting(actor, policy); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.applyAgentSessionLimit(ctx, maxSessions); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
agents := s.collectAgentServices(ctx)
|
||||||
|
if len(agents) == 0 {
|
||||||
|
return nil, fmt.Errorf("Agent 服务状态不可用")
|
||||||
|
}
|
||||||
|
return &agents[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) applyAgentSessionLimit(ctx context.Context, maxSessions int) error {
|
||||||
|
agentruntime.SetProcessLimit(maxSessions)
|
||||||
|
endpoint := strings.TrimSpace(os.Getenv("YINGCE_AGENT_URL"))
|
||||||
|
if endpoint == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return yingceagent.SetLimit(ctx, endpoint, os.Getenv("YINGCE_AGENT_TOKEN"), maxSessions)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) collectAgentServices(ctx context.Context) []SystemPerformanceAgent {
|
||||||
|
configured := platform.DefaultRuntimeAgentSessions
|
||||||
|
if policy, err := s.RuntimePolicy(); err == nil && policy.Task.AgentMaxSessions > 0 {
|
||||||
|
configured = policy.Task.AgentMaxSessions
|
||||||
|
}
|
||||||
|
endpoint := strings.TrimSpace(os.Getenv("YINGCE_AGENT_URL"))
|
||||||
|
if endpoint == "" {
|
||||||
|
active, limit := agentruntime.ProcessUsage()
|
||||||
|
return []SystemPerformanceAgent{{
|
||||||
|
ID: "embedded", Name: "画布 Agent", Mode: "embedded", Endpoint: "本进程",
|
||||||
|
Healthy: true, Active: active, Limit: limit, ConfiguredLimit: configured,
|
||||||
|
StatusMessage: "本地开发由后端进程直接启动,不单独占用容器。",
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
service := SystemPerformanceAgent{
|
||||||
|
ID: "yingce-agent", Name: "画布 Agent", Mode: "remote", Endpoint: endpoint,
|
||||||
|
ConfiguredLimit: configured,
|
||||||
|
}
|
||||||
|
status, err := yingceagent.Inspect(ctx, endpoint)
|
||||||
|
if err != nil {
|
||||||
|
service.StatusMessage = "独立 Agent 服务不可达。正在进行的对话会失败,不会自动改回本进程执行。"
|
||||||
|
return []SystemPerformanceAgent{service}
|
||||||
|
}
|
||||||
|
service.Healthy = status.OK
|
||||||
|
service.Active = status.Active
|
||||||
|
service.Limit = status.Limit
|
||||||
|
service.Queued = status.Queued
|
||||||
|
if status.Limit != configured {
|
||||||
|
service.StatusMessage = "运行中的上限与已保存配置不一致,重新保存一次即可应用。"
|
||||||
|
}
|
||||||
|
return []SystemPerformanceAgent{service}
|
||||||
|
}
|
||||||
|
|
||||||
|
func agentServicesDegraded(agents []SystemPerformanceAgent) bool {
|
||||||
|
for _, agent := range agents {
|
||||||
|
if !agent.Healthy {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func collectSystemMemory() SystemPerformanceMemory {
|
func collectSystemMemory() SystemPerformanceMemory {
|
||||||
var stats runtime.MemStats
|
var stats runtime.MemStats
|
||||||
runtime.ReadMemStats(&stats)
|
runtime.ReadMemStats(&stats)
|
||||||
|
|||||||
@@ -161,6 +161,7 @@ func TestRuntimePolicyBackfillsAgentStepLimitsForLegacyJSON(t *testing.T) {
|
|||||||
task := value["task"].(map[string]any)
|
task := value["task"].(map[string]any)
|
||||||
delete(task, "agentStepMaxOutputTokens")
|
delete(task, "agentStepMaxOutputTokens")
|
||||||
delete(task, "agentStepTimeoutSeconds")
|
delete(task, "agentStepTimeoutSeconds")
|
||||||
|
delete(task, "agentMaxSessions")
|
||||||
trimmed, err := json.Marshal(value)
|
trimmed, err := json.Marshal(value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -176,6 +177,9 @@ func TestRuntimePolicyBackfillsAgentStepLimitsForLegacyJSON(t *testing.T) {
|
|||||||
if effective.Task.AgentStepMaxOutputTokens != platform.DefaultRuntimeAgentStepOutputTokens {
|
if effective.Task.AgentStepMaxOutputTokens != platform.DefaultRuntimeAgentStepOutputTokens {
|
||||||
t.Fatalf("legacy json 未回填单步输出上限: %d", effective.Task.AgentStepMaxOutputTokens)
|
t.Fatalf("legacy json 未回填单步输出上限: %d", effective.Task.AgentStepMaxOutputTokens)
|
||||||
}
|
}
|
||||||
|
if effective.Task.AgentMaxSessions != platform.DefaultRuntimeAgentSessions {
|
||||||
|
t.Fatalf("legacy json 未回填同时对话上限: %d", effective.Task.AgentMaxSessions)
|
||||||
|
}
|
||||||
if effective.Task.AgentStepTimeoutSeconds != platform.DefaultRuntimeAgentStepTimeout {
|
if effective.Task.AgentStepTimeoutSeconds != platform.DefaultRuntimeAgentStepTimeout {
|
||||||
t.Fatalf("legacy json 未回填单步超时: %d", effective.Task.AgentStepTimeoutSeconds)
|
t.Fatalf("legacy json 未回填单步超时: %d", effective.Task.AgentStepTimeoutSeconds)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -166,6 +167,7 @@ func (s *Service) StartWorker() {
|
|||||||
s.startResourceDeletionWorker(ctx)
|
s.startResourceDeletionWorker(ctx)
|
||||||
s.startSkillSyncWorker(ctx)
|
s.startSkillSyncWorker(ctx)
|
||||||
s.startPaymentWorker(ctx)
|
s.startPaymentWorker(ctx)
|
||||||
|
go s.syncAgentSessionLimit()
|
||||||
s.runWorkerLoop(func(ctx context.Context) {
|
s.runWorkerLoop(func(ctx context.Context) {
|
||||||
ticker := time.NewTicker(3 * time.Second)
|
ticker := time.NewTicker(3 * time.Second)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
@@ -180,6 +182,19 @@ func (s *Service) StartWorker() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) syncAgentSessionLimit() {
|
||||||
|
policy, err := s.RuntimePolicy()
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("agent session limit load failed", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := s.applyAgentSessionLimit(ctx, policy.Task.AgentMaxSessions); err != nil {
|
||||||
|
slog.Warn("agent session limit apply failed", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) BeginDrain() { s.backgroundWorkers().BeginDrain() }
|
func (s *Service) BeginDrain() { s.backgroundWorkers().BeginDrain() }
|
||||||
|
|
||||||
func (s *Service) StopWorker(ctx context.Context) error { return s.backgroundWorkers().Stop(ctx) }
|
func (s *Service) StopWorker(ctx context.Context) error { return s.backgroundWorkers().Stop(ctx) }
|
||||||
|
|||||||
@@ -23,6 +23,26 @@ func RegisterAdminSystemPerformanceRoutes(r *gin.RouterGroup, svc *service.Servi
|
|||||||
ok(c, result)
|
ok(c, result)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
r.PUT("/admin/system-performance/agent-limit", func(c *gin.Context) {
|
||||||
|
actor, err := currentUser(c, svc)
|
||||||
|
if err != nil {
|
||||||
|
failService(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 4<<10)
|
||||||
|
var request service.AgentSessionLimitRequest
|
||||||
|
if err := c.ShouldBindJSON(&request); err != nil {
|
||||||
|
failService(c, service.BadAuthRequest("Agent 并发请求无效"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := svc.UpdateAgentSessionLimit(c.Request.Context(), actor, request.MaxSessions)
|
||||||
|
if err != nil {
|
||||||
|
failService(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ok(c, result)
|
||||||
|
})
|
||||||
|
|
||||||
r.POST("/admin/system-performance/cache/clear", func(c *gin.Context) {
|
r.POST("/admin/system-performance/cache/clear", func(c *gin.Context) {
|
||||||
actor, err := currentUser(c, svc)
|
actor, err := currentUser(c, svc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ func TestAdminSystemPerformanceRoutesAreRegistered(t *testing.T) {
|
|||||||
RegisterAdminSystemPerformanceRoutes(router.Group("/api"), &service.Service{})
|
RegisterAdminSystemPerformanceRoutes(router.Group("/api"), &service.Service{})
|
||||||
wanted := map[string]bool{
|
wanted := map[string]bool{
|
||||||
"GET /api/admin/system-performance": false,
|
"GET /api/admin/system-performance": false,
|
||||||
|
"PUT /api/admin/system-performance/agent-limit": false,
|
||||||
"POST /api/admin/system-performance/cache/clear": false,
|
"POST /api/admin/system-performance/cache/clear": false,
|
||||||
}
|
}
|
||||||
for _, route := range router.Routes() {
|
for _, route := range router.Routes() {
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ const (
|
|||||||
MinRuntimeAgentStepTimeoutSeconds = 30
|
MinRuntimeAgentStepTimeoutSeconds = 30
|
||||||
MaxRuntimeAgentStepTimeoutSeconds = 3_600
|
MaxRuntimeAgentStepTimeoutSeconds = 3_600
|
||||||
DefaultRuntimeAgentStepTimeout = 0
|
DefaultRuntimeAgentStepTimeout = 0
|
||||||
|
MinRuntimeAgentSessions = 1
|
||||||
|
MaxRuntimeAgentSessions = 64
|
||||||
|
DefaultRuntimeAgentSessions = 30
|
||||||
)
|
)
|
||||||
|
|
||||||
// agentStepMaxOutputTokensDefault 读取部署级覆盖并按同一套范围夹取;环境变量无法表达"不限制"(0),
|
// agentStepMaxOutputTokensDefault 读取部署级覆盖并按同一套范围夹取;环境变量无法表达"不限制"(0),
|
||||||
@@ -90,6 +93,8 @@ type RuntimeTaskPolicy struct {
|
|||||||
AgentStepMaxOutputTokens int `json:"agentStepMaxOutputTokens"`
|
AgentStepMaxOutputTokens int `json:"agentStepMaxOutputTokens"`
|
||||||
// AgentStepTimeoutSeconds 是画布 Agent 单步模型调用的秒级墙钟;0 表示沿用文本任务超时。
|
// AgentStepTimeoutSeconds 是画布 Agent 单步模型调用的秒级墙钟;0 表示沿用文本任务超时。
|
||||||
AgentStepTimeoutSeconds int `json:"agentStepTimeoutSeconds"`
|
AgentStepTimeoutSeconds int `json:"agentStepTimeoutSeconds"`
|
||||||
|
// AgentMaxSessions 是同时进行中的画布 Agent 轮次上限。审批等待不占用名额。
|
||||||
|
AgentMaxSessions int `json:"agentMaxSessions"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type RuntimeRequestPolicy struct {
|
type RuntimeRequestPolicy struct {
|
||||||
@@ -173,6 +178,7 @@ func DefaultRuntimePolicy() RuntimePolicySetting {
|
|||||||
DefaultTimeoutMinutes: 10,
|
DefaultTimeoutMinutes: 10,
|
||||||
AgentStepMaxOutputTokens: agentStepMaxOutputTokensDefault(),
|
AgentStepMaxOutputTokens: agentStepMaxOutputTokensDefault(),
|
||||||
AgentStepTimeoutSeconds: agentStepTimeoutSecondsDefault(),
|
AgentStepTimeoutSeconds: agentStepTimeoutSecondsDefault(),
|
||||||
|
AgentMaxSessions: DefaultRuntimeAgentSessions,
|
||||||
},
|
},
|
||||||
Request: RuntimeRequestPolicy{
|
Request: RuntimeRequestPolicy{
|
||||||
TaskCreatePerMinute: 30,
|
TaskCreatePerMinute: 30,
|
||||||
@@ -213,6 +219,7 @@ func selfUseRuntimePolicy() RuntimePolicySetting {
|
|||||||
AudioTimeoutMinutes: maxRuntimeTimeoutMinutes, VideoTimeoutMinutes: maxRuntimeTimeoutMinutes,
|
AudioTimeoutMinutes: maxRuntimeTimeoutMinutes, VideoTimeoutMinutes: maxRuntimeTimeoutMinutes,
|
||||||
StoryboardTimeoutMinutes: maxRuntimeTimeoutMinutes, DefaultTimeoutMinutes: maxRuntimeTimeoutMinutes,
|
StoryboardTimeoutMinutes: maxRuntimeTimeoutMinutes, DefaultTimeoutMinutes: maxRuntimeTimeoutMinutes,
|
||||||
AgentStepMaxOutputTokens: MaxRuntimeAgentStepOutputTokens, AgentStepTimeoutSeconds: MaxRuntimeAgentStepTimeoutSeconds,
|
AgentStepMaxOutputTokens: MaxRuntimeAgentStepOutputTokens, AgentStepTimeoutSeconds: MaxRuntimeAgentStepTimeoutSeconds,
|
||||||
|
AgentMaxSessions: MaxRuntimeAgentSessions,
|
||||||
}
|
}
|
||||||
value.Request = RuntimeRequestPolicy{
|
value.Request = RuntimeRequestPolicy{
|
||||||
TaskCreatePerMinute: maxRuntimeRate,
|
TaskCreatePerMinute: maxRuntimeRate,
|
||||||
@@ -437,6 +444,9 @@ func validateRuntimePolicy(value RuntimePolicySetting) error {
|
|||||||
if task.AgentStepTimeoutSeconds != 0 && (task.AgentStepTimeoutSeconds < MinRuntimeAgentStepTimeoutSeconds || task.AgentStepTimeoutSeconds > MaxRuntimeAgentStepTimeoutSeconds) {
|
if task.AgentStepTimeoutSeconds != 0 && (task.AgentStepTimeoutSeconds < MinRuntimeAgentStepTimeoutSeconds || task.AgentStepTimeoutSeconds > MaxRuntimeAgentStepTimeoutSeconds) {
|
||||||
return kernel.BadAuthRequest(fmt.Sprintf("Agent 单步超时必须是 0 或 %d-%d 秒的整数 (0 表示沿用文本任务超时)", MinRuntimeAgentStepTimeoutSeconds, MaxRuntimeAgentStepTimeoutSeconds))
|
return kernel.BadAuthRequest(fmt.Sprintf("Agent 单步超时必须是 0 或 %d-%d 秒的整数 (0 表示沿用文本任务超时)", MinRuntimeAgentStepTimeoutSeconds, MaxRuntimeAgentStepTimeoutSeconds))
|
||||||
}
|
}
|
||||||
|
if task.AgentMaxSessions < MinRuntimeAgentSessions || task.AgentMaxSessions > MaxRuntimeAgentSessions {
|
||||||
|
return kernel.BadAuthRequest(fmt.Sprintf("Agent 同时对话上限必须是 %d-%d 的整数", MinRuntimeAgentSessions, MaxRuntimeAgentSessions))
|
||||||
|
}
|
||||||
request := value.Request
|
request := value.Request
|
||||||
for label, item := range map[string]int{
|
for label, item := range map[string]int{
|
||||||
"任务创建频控": request.TaskCreatePerMinute,
|
"任务创建频控": request.TaskCreatePerMinute,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ type (
|
|||||||
AdminAuditPage = app.AdminAuditPage
|
AdminAuditPage = app.AdminAuditPage
|
||||||
AdminCacheClearGroupResult = app.AdminCacheClearGroupResult
|
AdminCacheClearGroupResult = app.AdminCacheClearGroupResult
|
||||||
AdminCacheClearRequest = app.AdminCacheClearRequest
|
AdminCacheClearRequest = app.AdminCacheClearRequest
|
||||||
|
AgentSessionLimitRequest = app.AgentSessionLimitRequest
|
||||||
AdminCacheClearResult = app.AdminCacheClearResult
|
AdminCacheClearResult = app.AdminCacheClearResult
|
||||||
AdminChannelModelFetchResult = app.AdminChannelModelFetchResult
|
AdminChannelModelFetchResult = app.AdminChannelModelFetchResult
|
||||||
AdminChannelModelImportRequest = app.AdminChannelModelImportRequest
|
AdminChannelModelImportRequest = app.AdminChannelModelImportRequest
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ services:
|
|||||||
image: ${CANVAS_YINGCE_AGENT_IMAGE:?请先配置 CANVAS_YINGCE_AGENT_IMAGE(必须包含镜像 digest)}
|
image: ${CANVAS_YINGCE_AGENT_IMAGE:?请先配置 CANVAS_YINGCE_AGENT_IMAGE(必须包含镜像 digest)}
|
||||||
environment:
|
environment:
|
||||||
YINGCE_AGENT_TOKEN: ${YINGCE_AGENT_TOKEN:?请先配置 YINGCE_AGENT_TOKEN}
|
YINGCE_AGENT_TOKEN: ${YINGCE_AGENT_TOKEN:?请先配置 YINGCE_AGENT_TOKEN}
|
||||||
MAX_CONCURRENT_SESSIONS: ${YINGCE_AGENT_MAX_SESSIONS:-8}
|
MAX_CONCURRENT_SESSIONS: ${YINGCE_AGENT_MAX_SESSIONS:-30}
|
||||||
NODE_MAX_OLD_SPACE_SIZE: ${YINGCE_AGENT_NODE_MEMORY_MB:-512}
|
NODE_MAX_OLD_SPACE_SIZE: ${YINGCE_AGENT_NODE_MEMORY_MB:-512}
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8081/healthz"]
|
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8081/healthz"]
|
||||||
|
|||||||
@@ -77,6 +77,14 @@ const agentFields: PolicyField[] = [
|
|||||||
min: 0,
|
min: 0,
|
||||||
max: 3_600,
|
max: 3_600,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
group: "task",
|
||||||
|
name: "agentMaxSessions",
|
||||||
|
label: "同时对话上限",
|
||||||
|
extra: "同时进行中的画布 Agent 轮次,新安装默认 30。审批等待不占名额,降低上限不会中断已开始的对话。",
|
||||||
|
unit: "个",
|
||||||
|
max: 64,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const rateFields: PolicyField[] = [
|
const rateFields: PolicyField[] = [
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { App, Button, Progress, Skeleton, Switch } from "antd";
|
import { App, Button, InputNumber, Progress, Skeleton, Switch } from "antd";
|
||||||
import { Activity, AlertTriangle, Cpu, Database, Gauge, HardDrive, MemoryStick, RefreshCw, Server, ShieldCheck, Trash2, Wifi } from "lucide-react";
|
import { Activity, AlertTriangle, Bot, Cpu, Database, Gauge, HardDrive, MemoryStick, RefreshCw, Server, ShieldCheck, Trash2, Wifi } from "lucide-react";
|
||||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||||
|
|
||||||
import { clearRuntimeCache, getSystemPerformance, type SystemPerformance } from "@/services/api/system-performance";
|
import { clearRuntimeCache, getSystemPerformance, updateAgentSessionLimit, type SystemPerformance } from "@/services/api/system-performance";
|
||||||
import { AdminPageFrame } from "../components/admin-shell";
|
import { AdminPageFrame } from "../components/admin-shell";
|
||||||
import { AdminStatusBadge, SettingsSectionCard } from "../components/admin-ui";
|
import { AdminStatusBadge, SettingsSectionCard } from "../components/admin-ui";
|
||||||
|
|
||||||
@@ -61,6 +61,8 @@ export default function SystemPerformancePage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const [clearing, setClearing] = useState(false);
|
const [clearing, setClearing] = useState(false);
|
||||||
|
const [savingAgentLimit, setSavingAgentLimit] = useState(false);
|
||||||
|
const [agentLimitDraft, setAgentLimitDraft] = useState<number | null>(null);
|
||||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||||
const [loadError, setLoadError] = useState("");
|
const [loadError, setLoadError] = useState("");
|
||||||
const mountedRef = useRef(true);
|
const mountedRef = useRef(true);
|
||||||
@@ -149,7 +151,7 @@ export default function SystemPerformancePage() {
|
|||||||
return (
|
return (
|
||||||
<AdminPageFrame
|
<AdminPageFrame
|
||||||
title="系统性能"
|
title="系统性能"
|
||||||
description="紧凑查看主机、数据库、Redis 和安全运行时缓存;敏感连接信息不会在此展示。"
|
description="紧凑查看主机、数据库、Redis、画布 Agent 和安全运行时缓存;敏感连接信息不会在此展示。"
|
||||||
scroll
|
scroll
|
||||||
actions={<><label className="admin-performance-auto"><Switch size="small" checked={autoRefresh} onChange={setAutoRefresh} /><span>15 秒刷新</span></label><Button icon={<RefreshCw className="size-4" />} loading={refreshing} onClick={() => void load(false)}>刷新</Button></>}
|
actions={<><label className="admin-performance-auto"><Switch size="small" checked={autoRefresh} onChange={setAutoRefresh} /><span>15 秒刷新</span></label><Button icon={<RefreshCw className="size-4" />} loading={refreshing} onClick={() => void load(false)}>刷新</Button></>}
|
||||||
>
|
>
|
||||||
@@ -213,6 +215,30 @@ export default function SystemPerformancePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{(data.agents || []).map((agent) => {
|
||||||
|
const draft = agentLimitDraft ?? agent.configuredLimit;
|
||||||
|
return <SettingsSectionCard key={agent.id} layout="stacked" icon={<Bot className="size-4" />} title="画布 Agent 服务" description="同时进行中的对话占用一个名额。审批等待不占用。降低上限不会中断已经开始的对话,也不会在这台机器上再启动容器。" status={{ label: agent.healthy ? "服务可连接" : "服务不可达", color: agent.healthy ? "success" : "error" }} footer={<><span className="admin-performance-cache-note"><Gauge className="size-3.5" />新安装默认 30,一般不用再改</span><span style={{ display: "flex", gap: 8, alignItems: "center" }}><InputNumber min={1} max={64} precision={0} value={draft} onChange={(value) => setAgentLimitDraft(typeof value === "number" ? value : draft)} /><Button type="primary" loading={savingAgentLimit} disabled={draft === agent.configuredLimit} onClick={() => {
|
||||||
|
setSavingAgentLimit(true);
|
||||||
|
void updateAgentSessionLimit(draft).then(() => {
|
||||||
|
message.success("已应用 Agent 同时对话上限");
|
||||||
|
setAgentLimitDraft(null);
|
||||||
|
return load(false);
|
||||||
|
}).catch((error: unknown) => {
|
||||||
|
message.error(error instanceof Error ? error.message : "保存 Agent 上限失败");
|
||||||
|
}).finally(() => setSavingAgentLimit(false));
|
||||||
|
}}>保存上限</Button></span></>}>
|
||||||
|
<FactGrid>
|
||||||
|
<Fact label="服务" value={agent.name} />
|
||||||
|
<Fact label="运行位置" value={agent.mode === "remote" ? "独立服务" : "后端进程"} />
|
||||||
|
<Fact label="地址" value={agent.endpoint} mono />
|
||||||
|
<Fact label="进行中 / 上限" value={`${formatNumber(agent.active)} / ${formatNumber(agent.limit)}`} />
|
||||||
|
<Fact label="排队" value={formatNumber(agent.queued)} />
|
||||||
|
<Fact label="已保存上限" value={formatNumber(agent.configuredLimit)} />
|
||||||
|
<Fact label="说明" value={agent.statusMessage || "同一台机器提高上限即可,不需要再启动容器。"} />
|
||||||
|
</FactGrid>
|
||||||
|
</SettingsSectionCard>;
|
||||||
|
})}
|
||||||
|
|
||||||
<SettingsSectionCard layout="stacked" icon={<ShieldCheck className="size-4" />} title="缓存维护" description="只清理可安全重建的运行时状态,保留业务数据与活动并发租约。" status={<AdminStatusBadge label="白名单清理" tone="success" />} footer={<><span className="admin-performance-cache-note"><Gauge className="size-3.5" />清理后频控与线路健康状态会重新计算</span><Button danger icon={<Trash2 className="size-4" />} loading={clearing} onClick={requestClear}>清理运行时缓存</Button></>}>
|
<SettingsSectionCard layout="stacked" icon={<ShieldCheck className="size-4" />} title="缓存维护" description="只清理可安全重建的运行时状态,保留业务数据与活动并发租约。" status={<AdminStatusBadge label="白名单清理" tone="success" />} footer={<><span className="admin-performance-cache-note"><Gauge className="size-3.5" />清理后频控与线路健康状态会重新计算</span><Button danger icon={<Trash2 className="size-4" />} loading={clearing} onClick={requestClear}>清理运行时缓存</Button></>}>
|
||||||
<div className="admin-performance-cache-groups">
|
<div className="admin-performance-cache-groups">
|
||||||
{(data?.redis.cacheGroups || []).map((group) => <div key={group.id}><span>{group.label}</span><strong>{formatNumber(group.keys)}</strong><small>keys</small></div>)}
|
{(data?.redis.cacheGroups || []).map((group) => <div key={group.id}><span>{group.label}</span><strong>{formatNumber(group.keys)}</strong><small>keys</small></div>)}
|
||||||
|
|||||||
@@ -350,6 +350,8 @@ export type RuntimeTaskPolicy = {
|
|||||||
agentStepMaxOutputTokens: number;
|
agentStepMaxOutputTokens: number;
|
||||||
/** 画布 Agent 单步模型调用的秒级墙钟;0 表示沿用文本任务超时。 */
|
/** 画布 Agent 单步模型调用的秒级墙钟;0 表示沿用文本任务超时。 */
|
||||||
agentStepTimeoutSeconds: number;
|
agentStepTimeoutSeconds: number;
|
||||||
|
/** 同时进行中的画布 Agent 轮次上限。审批等待不占用名额。 */
|
||||||
|
agentMaxSessions: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RuntimeRequestPolicy = {
|
export type RuntimeRequestPolicy = {
|
||||||
|
|||||||
@@ -105,9 +105,23 @@ export type SystemPerformance = {
|
|||||||
cacheGroups: SystemPerformanceCacheGroup[];
|
cacheGroups: SystemPerformanceCacheGroup[];
|
||||||
statusMessage?: string;
|
statusMessage?: string;
|
||||||
};
|
};
|
||||||
|
agents: SystemPerformanceAgent[];
|
||||||
build: { version: string; commit: string; buildTime: string; goVersion: string };
|
build: { version: string; commit: string; buildTime: string; goVersion: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SystemPerformanceAgent = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
mode: "remote" | "embedded";
|
||||||
|
endpoint: string;
|
||||||
|
healthy: boolean;
|
||||||
|
active: number;
|
||||||
|
limit: number;
|
||||||
|
queued: number;
|
||||||
|
configuredLimit: number;
|
||||||
|
statusMessage?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type RuntimeCacheClearResult = {
|
export type RuntimeCacheClearResult = {
|
||||||
scope: "runtime";
|
scope: "runtime";
|
||||||
deletedKeys: number;
|
deletedKeys: number;
|
||||||
@@ -120,6 +134,10 @@ export function getSystemPerformance(signal?: AbortSignal) {
|
|||||||
return http.get<SystemPerformance>("/admin/system-performance", { signal });
|
return http.get<SystemPerformance>("/admin/system-performance", { signal });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function updateAgentSessionLimit(maxSessions: number) {
|
||||||
|
return http.put<SystemPerformanceAgent>("/admin/system-performance/agent-limit", { maxSessions });
|
||||||
|
}
|
||||||
|
|
||||||
export function clearRuntimeCache() {
|
export function clearRuntimeCache() {
|
||||||
return http.post<RuntimeCacheClearResult>("/admin/system-performance/cache/clear", { scope: "runtime" });
|
return http.post<RuntimeCacheClearResult>("/admin/system-performance/cache/clear", { scope: "runtime" });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { createServer } from "node:http";
|
|||||||
import { dirname } from "node:path";
|
import { dirname } from "node:path";
|
||||||
|
|
||||||
const token = process.env.YINGCE_AGENT_TOKEN || "";
|
const token = process.env.YINGCE_AGENT_TOKEN || "";
|
||||||
const maxSessions = positiveInt(process.env.MAX_CONCURRENT_SESSIONS, 8, 1000);
|
let maxSessions = positiveInt(process.env.MAX_CONCURRENT_SESSIONS, 30, 64);
|
||||||
const memoryMB = positiveInt(process.env.NODE_MAX_OLD_SPACE_SIZE, 512, 8192);
|
const memoryMB = positiveInt(process.env.NODE_MAX_OLD_SPACE_SIZE, 512, 8192);
|
||||||
const runtimePath = process.env.YINGCE_AGENT_RUNTIME || "/app/agent-runtime/agent-runtime.mjs";
|
const runtimePath = process.env.YINGCE_AGENT_RUNTIME || "/app/agent-runtime/agent-runtime.mjs";
|
||||||
const port = Number(process.env.PORT || 8081);
|
const port = Number(process.env.PORT || 8081);
|
||||||
@@ -48,13 +48,21 @@ function acquire(request) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function admitWaiters() {
|
||||||
|
while (waiters.length > 0 && active < maxSessions) {
|
||||||
|
active += 1;
|
||||||
|
waiters.shift().resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function release() {
|
function release() {
|
||||||
const waiter = waiters.shift();
|
if (waiters.length > 0 && active <= maxSessions) {
|
||||||
if (waiter) {
|
waiters.shift().resolve();
|
||||||
waiter.resolve();
|
admitWaiters();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
active = Math.max(0, active - 1);
|
active = Math.max(0, active - 1);
|
||||||
|
admitWaiters();
|
||||||
}
|
}
|
||||||
|
|
||||||
function readBody(request) {
|
function readBody(request) {
|
||||||
@@ -85,7 +93,27 @@ function writeRuntimeError(response, message) {
|
|||||||
const server = createServer(async (request, response) => {
|
const server = createServer(async (request, response) => {
|
||||||
if (request.method === "GET" && request.url === "/healthz") {
|
if (request.method === "GET" && request.url === "/healthz") {
|
||||||
response.writeHead(200, { "content-type": "application/json" });
|
response.writeHead(200, { "content-type": "application/json" });
|
||||||
response.end(JSON.stringify({ ok: true, active, limit: maxSessions }));
|
response.end(JSON.stringify({ ok: true, active, limit: maxSessions, queued: waiters.length }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (request.method === "PUT" && request.url === "/v1/limit") {
|
||||||
|
if (!authorized(request)) {
|
||||||
|
response.writeHead(401, { "content-type": "application/json" });
|
||||||
|
response.end(JSON.stringify({ error: "unauthorized" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const body = JSON.parse((await readBody(request)).toString("utf8"));
|
||||||
|
const next = positiveInt(body.maxSessions, 0, 64);
|
||||||
|
if (!next) throw new Error("maxSessions must be an integer from 1 to 64");
|
||||||
|
maxSessions = next;
|
||||||
|
admitWaiters();
|
||||||
|
response.writeHead(200, { "content-type": "application/json" });
|
||||||
|
response.end(JSON.stringify({ ok: true, active, limit: maxSessions, queued: waiters.length }));
|
||||||
|
} catch (error) {
|
||||||
|
response.writeHead(400, { "content-type": "application/json" });
|
||||||
|
response.end(JSON.stringify({ error: error instanceof Error ? error.message : "invalid limit" }));
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (request.method !== "POST" || request.url !== "/v1/runs") {
|
if (request.method !== "POST" || request.url !== "/v1/runs") {
|
||||||
|
|||||||
Reference in New Issue
Block a user