Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/web/src/i18n/locales/en-US/admin.json
Original file line number Diff line number Diff line change
Expand Up @@ -1425,8 +1425,8 @@
},
"sandboxSize": {
"hint": "Changes take effect after the current sandbox is released.",
"standard": "Standard (4 vCPU / 8 GiB)",
"xl": "XL (4 vCPU / 50 GiB)"
"standard": "Standard (2 vCPU / 4 GiB)",
"xl": "Large (4 vCPU / 8 GiB)"
},
"emptyModel": {
"title": "No usable Model yet",
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/i18n/locales/zh-CN/admin.json
Original file line number Diff line number Diff line change
Expand Up @@ -1425,8 +1425,8 @@
},
"sandboxSize": {
"hint": "改完规格后,等当前沙盒被释放才会切换到新规格。",
"standard": "标准 (4 vCPU / 8 GiB)",
"xl": "XL (4 vCPU / 50 GiB)"
"standard": "标准 (2 vCPU / 4 GiB)",
"xl": "大规格 (4 vCPU / 8 GiB)"
},
"emptyModel": {
"title": "还没有可用 Model",
Expand Down
69 changes: 53 additions & 16 deletions server/cmd/server/sandbox_docker.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,11 @@ func agentDaemonWSURLFromBase(base string) string {
// - AGENT_DAEMON_SANDBOX_DOCKER_IMAGE — local image tag to run.
// - AGENT_DAEMON_SANDBOX_DOCKER_NETWORK — optional docker network to join
// (use the compose network when the server runs as a compose service).
// - AGENT_DAEMON_SANDBOX_DOCKER_MEMORY / _CPUS — optional `docker run`
// resource caps; unset = built-in default (4g / 2 CPU). Set to
// 0/unlimited/none to remove the cap.
// - AGENT_DAEMON_SANDBOX_DOCKER_MEMORY / _CPUS — optional global
// `docker run` resource caps for every sandbox size. Unset =
// size-specific defaults below. Set to 0/unlimited/none to remove the cap.
// - AGENT_DAEMON_SANDBOX_DOCKER_STANDARD_MEMORY / _STANDARD_CPUS and
// _XL_MEMORY / _XL_CPUS — optional per-size overrides.
// - AGENT_DAEMON_SANDBOX_DOCKER_PIDS_LIMIT — optional pids cap; unset = no
// cap (docker default).
func buildDockerAgentDaemonSandboxProvider(
Expand Down Expand Up @@ -140,7 +142,7 @@ func buildDockerAgentDaemonSandboxProvider(
Binder: binder,
Bindings: dbStore,
Template: image,
Templates: map[string]string{"standard": image},
Templates: map[string]string{"standard": image, "xl": image},
DefaultSize: "standard",
ServerURL: serverURL,
OwnerChecker: dbStore,
Expand All @@ -162,30 +164,65 @@ func buildDockerAgentDaemonSandboxProvider(
return provider
}

// Built-in sandbox caps used when the operator sets no override: a
// conservative default stops one runaway sandbox starving the host (raise it
// with the env var, or disable it with 0/unlimited). PidsLimit has no default
// — a low pids cap breaks parallel builds (`make -j`, `go test ./...`).
// Built-in sandbox caps used when the operator sets no override. PidsLimit has
// no default — a low pids cap breaks parallel builds (`make -j`, `go test ./...`).
const (
defaultDockerMemory = "4g"
defaultDockerCPUs = "2"
defaultDockerStandardMemory = "4g"
defaultDockerStandardCPUs = "2"
defaultDockerXLMemory = "8g"
defaultDockerXLCPUs = "4"
)

// dockerClientFromEnv builds the docker sandbox client, resolving the
// AGENT_DAEMON_SANDBOX_DOCKER_{MEMORY,CPUS,PIDS_LIMIT} caps: memory and cpus
// fall back to the built-in default when unset, pids stays off; see
// resolveDockerLimit for the 0/unlimited escape hatch.
func dockerClientFromEnv(env func(string) string, image, network string, hostGateway bool) *dockersandbox.Client {
standardLimits, xlLimits := dockerLimitsFromEnv(env)
return &dockersandbox.Client{
Image: image,
Network: network,
HostGateway: hostGateway,
Memory: resolveDockerLimit(env("AGENT_DAEMON_SANDBOX_DOCKER_MEMORY"), defaultDockerMemory),
CPUs: resolveDockerLimit(env("AGENT_DAEMON_SANDBOX_DOCKER_CPUS"), defaultDockerCPUs),
PidsLimit: resolveDockerLimit(env("AGENT_DAEMON_SANDBOX_DOCKER_PIDS_LIMIT"), ""),
Image: image,
Network: network,
HostGateway: hostGateway,
Memory: standardLimits.Memory,
CPUs: standardLimits.CPUs,
PidsLimit: standardLimits.PidsLimit,
LimitsBySize: map[string]dockersandbox.ResourceLimits{"standard": standardLimits, "xl": xlLimits},
}
}

func dockerLimitsFromEnv(env func(string) string) (standard dockersandbox.ResourceLimits, xl dockersandbox.ResourceLimits) {
globalMemory := strings.TrimSpace(env("AGENT_DAEMON_SANDBOX_DOCKER_MEMORY"))
globalCPUs := strings.TrimSpace(env("AGENT_DAEMON_SANDBOX_DOCKER_CPUS"))
pidsLimit := resolveDockerLimit(env("AGENT_DAEMON_SANDBOX_DOCKER_PIDS_LIMIT"), "")

standardMemoryDefault := defaultDockerStandardMemory
xlMemoryDefault := defaultDockerXLMemory
if globalMemory != "" {
resolved := resolveDockerLimit(globalMemory, "")
standardMemoryDefault = resolved
xlMemoryDefault = resolved
}
standardCPUsDefault := defaultDockerStandardCPUs
xlCPUsDefault := defaultDockerXLCPUs
if globalCPUs != "" {
resolved := resolveDockerLimit(globalCPUs, "")
standardCPUsDefault = resolved
xlCPUsDefault = resolved
}

standard = dockersandbox.ResourceLimits{
Memory: resolveDockerLimit(env("AGENT_DAEMON_SANDBOX_DOCKER_STANDARD_MEMORY"), standardMemoryDefault),
CPUs: resolveDockerLimit(env("AGENT_DAEMON_SANDBOX_DOCKER_STANDARD_CPUS"), standardCPUsDefault),
PidsLimit: pidsLimit,
}
xl = dockersandbox.ResourceLimits{
Memory: resolveDockerLimit(env("AGENT_DAEMON_SANDBOX_DOCKER_XL_MEMORY"), xlMemoryDefault),
CPUs: resolveDockerLimit(env("AGENT_DAEMON_SANDBOX_DOCKER_XL_CPUS"), xlCPUsDefault),
PidsLimit: pidsLimit,
}
return standard, xl
}

// resolveDockerLimit resolves one resource cap: empty env → built-in default;
// "0"/"unlimited"/"none" (case-insensitive) → "" so Create omits the flag
// (docker's unbounded default); otherwise the literal value, passed through
Expand Down
30 changes: 27 additions & 3 deletions server/cmd/server/sandbox_docker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,21 +41,45 @@ func TestDockerClientFromEnvReadsResourceLimits(t *testing.T) {
if c.Memory != "2g" || c.CPUs != "1.5" || c.PidsLimit != "512" {
t.Fatalf("resource limits not wired: %+v", c)
}
if c.LimitsBySize["standard"].Memory != "2g" || c.LimitsBySize["xl"].Memory != "2g" {
t.Fatalf("global memory override should apply to every size: %+v", c.LimitsBySize)
}
}

func TestDockerClientFromEnvAppliesBuiltInDefaults(t *testing.T) {
// With the env unset the operator still gets a safe built-in cap (2 CPU /
// 4GB) so one runaway sandbox can't starve the host. PidsLimit stays unset:
// a low pids cap is a classic build-breaker (make -j, go test ./...).
// With the env unset the operator gets the smaller advertised standard size.
// PidsLimit stays unset: a low pids cap is a classic build-breaker
// (make -j, go test ./...).
c := dockerClientFromEnv(func(string) string { return "" }, "img", "", false)
if c.CPUs != "2" || c.Memory != "4g" {
t.Fatalf("expected default 2 CPU / 4g, got cpus=%q memory=%q", c.CPUs, c.Memory)
}
if xl := c.LimitsBySize["xl"]; xl.CPUs != "4" || xl.Memory != "8g" {
t.Fatalf("expected xl default 4 CPU / 8g, got %+v", xl)
}
if c.PidsLimit != "" {
t.Fatalf("expected pids-limit unset by default, got %q", c.PidsLimit)
}
}

func TestDockerClientFromEnvReadsPerSizeOverrides(t *testing.T) {
env := func(k string) string {
return map[string]string{
"AGENT_DAEMON_SANDBOX_DOCKER_STANDARD_MEMORY": "6g",
"AGENT_DAEMON_SANDBOX_DOCKER_STANDARD_CPUS": "3",
"AGENT_DAEMON_SANDBOX_DOCKER_XL_MEMORY": "64g",
"AGENT_DAEMON_SANDBOX_DOCKER_XL_CPUS": "6",
}[k]
}
c := dockerClientFromEnv(env, "img", "", false)
if got := c.LimitsBySize["standard"]; got.Memory != "6g" || got.CPUs != "3" {
t.Fatalf("standard override not wired: %+v", got)
}
if got := c.LimitsBySize["xl"]; got.Memory != "64g" || got.CPUs != "6" {
t.Fatalf("xl override not wired: %+v", got)
}
}

func TestDockerClientFromEnvEscapeHatchDisablesDefault(t *testing.T) {
// An operator who wants docker's unbounded default back sets the env to
// 0/unlimited/none (case-insensitive, trimmed); the flag is then omitted
Expand Down
1 change: 1 addition & 0 deletions server/internal/connector/agentdaemon/sandbox_provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -630,6 +630,7 @@ func (p *E2BSandboxProvider) coldStart(ctx context.Context, in connector.PromptI
"parsar.agent_id": in.AgentID,
"parsar.device_id": deviceID,
"parsar.sandbox_kind": "agent_daemon_claude_code",
"parsar.sandbox_size": resolvedSize,
},
})
if err != nil {
Expand Down
1 change: 1 addition & 0 deletions server/internal/dev/routes_agents.go
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,7 @@ func createAgent(runtimeStore RuntimeStore, agentDaemonSandbox AgentDaemonSandbo
deviceID, err := agentDaemonSandbox.Acquire(ctx, connector.PromptInput{
AgentID: paID,
WorkspaceID: workspaceID,
AgentConfig: result.Agent.Config,
})
if err != nil {
log.Bg().Warn("eager sandbox acquire failed",
Expand Down
102 changes: 55 additions & 47 deletions server/internal/dev/sandbox_admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ import (

"github.com/MiniMax-AI-Dev/parsar/internal/obs/log"

"github.com/go-chi/chi/v5"
"github.com/MiniMax-AI-Dev/parsar/server/internal/connector"
"github.com/MiniMax-AI-Dev/parsar/server/internal/store"
"github.com/go-chi/chi/v5"
)

// SandboxBindingStore is the data-layer dependency for admin sandbox
Expand All @@ -45,17 +45,17 @@ type sandboxAdminDeps struct {

// sandboxStatusResponse is the GET response shape.
type sandboxStatusResponse struct {
BindingID string `json:"binding_id"`
WorkspaceID string `json:"workspace_id"`
AgentID *string `json:"agent_id"`
Name *string `json:"name,omitempty"`
SandboxID string `json:"sandbox_id"`
TemplateID string `json:"template_id"`
Status string `json:"status"`
StatusKind string `json:"status_kind"`
CreatedAt time.Time `json:"created_at"`
LastActiveAt time.Time `json:"last_active_at"`
KilledAt *time.Time `json:"killed_at,omitempty"`
BindingID string `json:"binding_id"`
WorkspaceID string `json:"workspace_id"`
AgentID *string `json:"agent_id"`
Name *string `json:"name,omitempty"`
SandboxID string `json:"sandbox_id"`
TemplateID string `json:"template_id"`
Status string `json:"status"`
StatusKind string `json:"status_kind"`
CreatedAt time.Time `json:"created_at"`
LastActiveAt time.Time `json:"last_active_at"`
KilledAt *time.Time `json:"killed_at,omitempty"`
// ExpiresAt is fetched live from the e2b control plane. Nil when
// the binding isn't in this pod's cache or the lookup failed.
ExpiresAt *time.Time `json:"expires_at,omitempty"`
Expand Down Expand Up @@ -243,9 +243,9 @@ func rebuildSandbox(deps sandboxAdminDeps, runtimeStore RuntimeStore, daemonMgr
}
if !found {
writeJSON(w, http.StatusNotFound, map[string]string{
"error": "no active sandbox binding to rebuild",
"workspace_id": workspaceID,
"agent_id": agentID,
"error": "no active sandbox binding to rebuild",
"workspace_id": workspaceID,
"agent_id": agentID,
})
return
}
Expand Down Expand Up @@ -295,9 +295,9 @@ func rebuildSandbox(deps sandboxAdminDeps, runtimeStore RuntimeStore, daemonMgr
}
if runtimeStore != nil {
if _, bindErr := runtimeStore.SetAgentRuntime(ctx, store.SetAgentRuntimeInput{
WorkspaceID: workspaceID,
AgentID: agentID,
RuntimeID: deviceID,
WorkspaceID: workspaceID,
AgentID: agentID,
RuntimeID: deviceID,
}); bindErr != nil {
log.Bg().Error("sandbox rebuild: re-acquired but runtime_id persist failed",
"agent_id", agentID,
Expand Down Expand Up @@ -362,9 +362,9 @@ func renewSandbox(deps sandboxAdminDeps, daemonMgr AgentDaemonSandboxManager) ht
}
if !found {
writeJSON(w, http.StatusNotFound, map[string]string{
"error": "no active sandbox binding to renew",
"workspace_id": workspaceID,
"agent_id": agentID,
"error": "no active sandbox binding to renew",
"workspace_id": workspaceID,
"agent_id": agentID,
})
return
}
Expand All @@ -381,9 +381,9 @@ func renewSandbox(deps sandboxAdminDeps, daemonMgr AgentDaemonSandboxManager) ht
// This pod's cache doesn't own the binding; UI poll will
// pick up the new owner within ~15s.
writeJSON(w, http.StatusConflict, map[string]string{
"error": "sandbox not owned by this pod; refresh and retry",
"workspace_id": workspaceID,
"agent_id": agentID,
"error": "sandbox not owned by this pod; refresh and retry",
"workspace_id": workspaceID,
"agent_id": agentID,
})
return
}
Expand Down Expand Up @@ -437,12 +437,19 @@ func acquireSandbox(deps sandboxAdminDeps, runtimeStore RuntimeStore, provider A
if _, found, _ := deps.store.GetActiveSandboxBindingForAgent(
r.Context(), workspaceID, agentID); found {
writeJSON(w, http.StatusOK, map[string]string{
"status": "already_bound",
"status": "already_bound",
"agent_id": agentID,
})
return
}
}
var agentConfig map[string]any
if detail, detailErr := deps.store.GetAgentDetail(r.Context(), agentID); detailErr == nil {
agentConfig = detail.Config
} else {
log.Bg().Warn("sandbox acquire (manual): load agent config failed; acquire will use default sandbox size",
"agent_id", agentID, "err", detailErr)
}
// Fire-and-forget: return 202 immediately. On success, persist
// the new deviceID to agents.runtime_id.
go func() {
Expand All @@ -451,6 +458,7 @@ func acquireSandbox(deps sandboxAdminDeps, runtimeStore RuntimeStore, provider A
deviceID, err := provider.Acquire(ctx, connector.PromptInput{
AgentID: agentID,
WorkspaceID: workspaceID,
AgentConfig: agentConfig,
})
if err != nil {
log.Bg().Warn("sandbox acquire (manual) failed",
Expand All @@ -459,9 +467,9 @@ func acquireSandbox(deps sandboxAdminDeps, runtimeStore RuntimeStore, provider A
}
if runtimeStore != nil {
if _, bindErr := runtimeStore.SetAgentRuntime(ctx, store.SetAgentRuntimeInput{
WorkspaceID: workspaceID,
AgentID: agentID,
RuntimeID: deviceID,
WorkspaceID: workspaceID,
AgentID: agentID,
RuntimeID: deviceID,
}); bindErr != nil {
log.Bg().Error("sandbox acquire (manual) succeeded but runtime_id persist failed",
"agent_id", agentID,
Expand All @@ -474,7 +482,7 @@ func acquireSandbox(deps sandboxAdminDeps, runtimeStore RuntimeStore, provider A
"agent_id", agentID, "device_id", deviceID)
}()
writeJSON(w, http.StatusAccepted, map[string]string{
"status": "provisioning",
"status": "provisioning",
"agent_id": agentID,
})
}
Expand All @@ -501,9 +509,9 @@ func performLifecycleAction(w http.ResponseWriter, r *http.Request, deps sandbox
}
if !found {
writeJSON(w, http.StatusNotFound, map[string]string{
"error": "no active sandbox binding to act on",
"workspace_id": workspaceID,
"agent_id": agentID,
"error": "no active sandbox binding to act on",
"workspace_id": workspaceID,
"agent_id": agentID,
})
return
}
Expand Down Expand Up @@ -531,9 +539,9 @@ func performLifecycleAction(w http.ResponseWriter, r *http.Request, deps sandbox
// next dispatch experience — never block the kill response on it.
if runtimeStore != nil {
if _, clearErr := runtimeStore.SetAgentRuntime(r.Context(), store.SetAgentRuntimeInput{
WorkspaceID: workspaceID,
AgentID: agentID,
RuntimeID: "",
WorkspaceID: workspaceID,
AgentID: agentID,
RuntimeID: "",
}); clearErr != nil {
log.Bg().Warn("sandbox kill: clear agent runtime_id failed (next dispatch may target dead device)",
"agent_id", agentID, "err", clearErr)
Expand All @@ -559,18 +567,18 @@ func toSandboxStatusResponse(b store.SandboxBindingRead) sandboxStatusResponse {
kind = "terminal"
}
return sandboxStatusResponse{
BindingID: b.ID,
WorkspaceID: b.WorkspaceID,
AgentID: b.AgentID,
Name: b.Name,
SandboxID: b.SandboxID,
TemplateID: b.TemplateID,
Status: b.Status,
StatusKind: kind,
CreatedAt: b.CreatedAt,
LastActiveAt: b.LastActiveAt,
KilledAt: b.KilledAt,
Metadata: b.Metadata,
BindingID: b.ID,
WorkspaceID: b.WorkspaceID,
AgentID: b.AgentID,
Name: b.Name,
SandboxID: b.SandboxID,
TemplateID: b.TemplateID,
Status: b.Status,
StatusKind: kind,
CreatedAt: b.CreatedAt,
LastActiveAt: b.LastActiveAt,
KilledAt: b.KilledAt,
Metadata: b.Metadata,
}
}

Expand Down
Loading