feat(boards): add max_agents field to board models and enforce limits
This commit is contained in:
@@ -43,5 +43,6 @@ class Board(TenantScoped, table=True):
|
|||||||
require_review_before_done: bool = Field(default=False)
|
require_review_before_done: bool = Field(default=False)
|
||||||
block_status_changes_with_pending_approval: bool = Field(default=False)
|
block_status_changes_with_pending_approval: bool = Field(default=False)
|
||||||
only_lead_can_change_status: bool = Field(default=False)
|
only_lead_can_change_status: bool = Field(default=False)
|
||||||
|
max_agents: int = Field(default=1)
|
||||||
created_at: datetime = Field(default_factory=utcnow)
|
created_at: datetime = Field(default_factory=utcnow)
|
||||||
updated_at: datetime = Field(default_factory=utcnow)
|
updated_at: datetime = Field(default_factory=utcnow)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from typing import Self
|
|||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from pydantic import model_validator
|
from pydantic import model_validator
|
||||||
from sqlmodel import SQLModel
|
from sqlmodel import Field, SQLModel
|
||||||
|
|
||||||
_ERR_GOAL_FIELDS_REQUIRED = "Confirmed goal boards require objective and success_metrics"
|
_ERR_GOAL_FIELDS_REQUIRED = "Confirmed goal boards require objective and success_metrics"
|
||||||
_ERR_GATEWAY_REQUIRED = "gateway_id is required"
|
_ERR_GATEWAY_REQUIRED = "gateway_id is required"
|
||||||
@@ -33,6 +33,7 @@ class BoardBase(SQLModel):
|
|||||||
require_review_before_done: bool = False
|
require_review_before_done: bool = False
|
||||||
block_status_changes_with_pending_approval: bool = False
|
block_status_changes_with_pending_approval: bool = False
|
||||||
only_lead_can_change_status: bool = False
|
only_lead_can_change_status: bool = False
|
||||||
|
max_agents: int = Field(default=1, ge=0)
|
||||||
|
|
||||||
|
|
||||||
class BoardCreate(BoardBase):
|
class BoardCreate(BoardBase):
|
||||||
@@ -76,6 +77,7 @@ class BoardUpdate(SQLModel):
|
|||||||
require_review_before_done: bool | None = None
|
require_review_before_done: bool | None = None
|
||||||
block_status_changes_with_pending_approval: bool | None = None
|
block_status_changes_with_pending_approval: bool | None = None
|
||||||
only_lead_can_change_status: bool | None = None
|
only_lead_can_change_status: bool | None = None
|
||||||
|
max_agents: int | None = Field(default=None, ge=0)
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def validate_gateway_id(self) -> Self:
|
def validate_gateway_id(self) -> Self:
|
||||||
|
|||||||
@@ -922,6 +922,49 @@ class AgentLifecycleService(OpenClawDBService):
|
|||||||
|
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
async def count_non_lead_agents_for_board(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
board_id: UUID,
|
||||||
|
) -> int:
|
||||||
|
"""Count board-scoped non-lead agents for spawn limit checks."""
|
||||||
|
statement = (
|
||||||
|
select(func.count(col(Agent.id)))
|
||||||
|
.where(col(Agent.board_id) == board_id)
|
||||||
|
.where(col(Agent.is_board_lead).is_(False))
|
||||||
|
)
|
||||||
|
count = (await self.session.exec(statement)).one()
|
||||||
|
return int(count or 0)
|
||||||
|
|
||||||
|
async def enforce_board_spawn_limit_for_lead(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
board: Board,
|
||||||
|
actor: ActorContextLike,
|
||||||
|
) -> None:
|
||||||
|
"""Enforce `board.max_agents` when creation is requested by a lead agent.
|
||||||
|
|
||||||
|
The cap excludes the board lead itself.
|
||||||
|
"""
|
||||||
|
if actor.actor_type != "agent":
|
||||||
|
return
|
||||||
|
if actor.agent is None or not actor.agent.is_board_lead:
|
||||||
|
return
|
||||||
|
|
||||||
|
worker_count = await self.count_non_lead_agents_for_board(board_id=board.id)
|
||||||
|
if worker_count < board.max_agents:
|
||||||
|
return
|
||||||
|
|
||||||
|
noun = "agent" if board.max_agents == 1 else "agents"
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=(
|
||||||
|
"Board worker-agent limit reached: "
|
||||||
|
f"max_agents={board.max_agents} (excluding the lead); "
|
||||||
|
f"cannot create more than {board.max_agents} {noun}."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
async def ensure_unique_agent_name(
|
async def ensure_unique_agent_name(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -1484,6 +1527,7 @@ class AgentLifecycleService(OpenClawDBService):
|
|||||||
user=actor.user if actor.actor_type == "user" else None,
|
user=actor.user if actor.actor_type == "user" else None,
|
||||||
write=actor.actor_type == "user",
|
write=actor.actor_type == "user",
|
||||||
)
|
)
|
||||||
|
await self.enforce_board_spawn_limit_for_lead(board=board, actor=actor)
|
||||||
gateway, _client_config = await self.require_gateway(board)
|
gateway, _client_config = await self.require_gateway(board)
|
||||||
data = payload.model_dump()
|
data = payload.model_dump()
|
||||||
data["gateway_id"] = gateway.id
|
data["gateway_id"] = gateway.id
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""Add max_agents field to boards.
|
||||||
|
|
||||||
|
Revision ID: 4c1f5e2a7b9d
|
||||||
|
Revises: c9d7e9b6a4f2
|
||||||
|
Create Date: 2026-02-14 00:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "4c1f5e2a7b9d"
|
||||||
|
down_revision = "c9d7e9b6a4f2"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Add required boards.max_agents column with a safe backfill default."""
|
||||||
|
op.add_column(
|
||||||
|
"boards",
|
||||||
|
sa.Column(
|
||||||
|
"max_agents",
|
||||||
|
sa.Integer(),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("1"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.alter_column("boards", "max_agents", server_default=None)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Remove boards.max_agents column."""
|
||||||
|
op.drop_column("boards", "max_agents")
|
||||||
68
backend/tests/test_agent_create_limits.py
Normal file
68
backend/tests/test_agent_create_limits.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# ruff: noqa: S101
|
||||||
|
"""Unit tests for board worker-agent spawn limits."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
|
||||||
|
import app.services.openclaw.provisioning_db as agent_service
|
||||||
|
from app.schemas.agents import AgentCreate
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _FakeSession:
|
||||||
|
async def exec(self, *_args: object, **_kwargs: object) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _BoardStub:
|
||||||
|
id: UUID
|
||||||
|
gateway_id: UUID
|
||||||
|
max_agents: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _AgentStub:
|
||||||
|
id: UUID
|
||||||
|
board_id: UUID | None
|
||||||
|
is_board_lead: bool
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_agent_as_lead_enforces_board_max_agents(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
service = agent_service.AgentLifecycleService(_FakeSession()) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
board_id = uuid4()
|
||||||
|
board = _BoardStub(id=board_id, gateway_id=uuid4(), max_agents=1)
|
||||||
|
lead = _AgentStub(id=uuid4(), board_id=board_id, is_board_lead=True)
|
||||||
|
actor = SimpleNamespace(actor_type="agent", user=None, agent=lead)
|
||||||
|
payload = AgentCreate(name="Worker Agent", board_id=board_id)
|
||||||
|
|
||||||
|
async def _fake_require_board(*_args: object, **_kwargs: object) -> _BoardStub:
|
||||||
|
return board
|
||||||
|
|
||||||
|
async def _fake_count_non_lead_agents_for_board(*, board_id: UUID) -> int:
|
||||||
|
assert board_id == board.id
|
||||||
|
return 1
|
||||||
|
|
||||||
|
monkeypatch.setattr(service, "require_board", _fake_require_board)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
service,
|
||||||
|
"count_non_lead_agents_for_board",
|
||||||
|
_fake_count_non_lead_agents_for_board,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await service.create_agent(payload=payload, actor=actor) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
assert exc_info.value.status_code == status.HTTP_409_CONFLICT
|
||||||
|
assert "excluding the lead" in str(exc_info.value.detail)
|
||||||
|
assert "max_agents=1" in str(exc_info.value.detail)
|
||||||
@@ -88,17 +88,35 @@ def test_board_rule_toggles_have_expected_defaults() -> None:
|
|||||||
assert created.require_review_before_done is False
|
assert created.require_review_before_done is False
|
||||||
assert created.block_status_changes_with_pending_approval is False
|
assert created.block_status_changes_with_pending_approval is False
|
||||||
assert created.only_lead_can_change_status is False
|
assert created.only_lead_can_change_status is False
|
||||||
|
assert created.max_agents == 1
|
||||||
|
|
||||||
updated = BoardUpdate(
|
updated = BoardUpdate(
|
||||||
require_approval_for_done=False,
|
require_approval_for_done=False,
|
||||||
require_review_before_done=True,
|
require_review_before_done=True,
|
||||||
block_status_changes_with_pending_approval=True,
|
block_status_changes_with_pending_approval=True,
|
||||||
only_lead_can_change_status=True,
|
only_lead_can_change_status=True,
|
||||||
|
max_agents=3,
|
||||||
)
|
)
|
||||||
assert updated.require_approval_for_done is False
|
assert updated.require_approval_for_done is False
|
||||||
assert updated.require_review_before_done is True
|
assert updated.require_review_before_done is True
|
||||||
assert updated.block_status_changes_with_pending_approval is True
|
assert updated.block_status_changes_with_pending_approval is True
|
||||||
assert updated.only_lead_can_change_status is True
|
assert updated.only_lead_can_change_status is True
|
||||||
|
assert updated.max_agents == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_board_max_agents_must_be_non_negative() -> None:
|
||||||
|
"""Board max_agents should reject negative values."""
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
BoardCreate(
|
||||||
|
name="Ops Board",
|
||||||
|
slug="ops-board",
|
||||||
|
description="Operations workflow board.",
|
||||||
|
gateway_id=uuid4(),
|
||||||
|
max_agents=-1,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
BoardUpdate(max_agents=-1)
|
||||||
|
|
||||||
|
|
||||||
def test_onboarding_confirm_requires_goal_fields() -> None:
|
def test_onboarding_confirm_requires_goal_fields() -> None:
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ export interface BoardCreate {
|
|||||||
gateway_id?: string | null;
|
gateway_id?: string | null;
|
||||||
goal_confirmed?: boolean;
|
goal_confirmed?: boolean;
|
||||||
goal_source?: string | null;
|
goal_source?: string | null;
|
||||||
|
/** @minimum 0 */
|
||||||
|
max_agents?: number;
|
||||||
name: string;
|
name: string;
|
||||||
objective?: string | null;
|
objective?: string | null;
|
||||||
only_lead_can_change_status?: boolean;
|
only_lead_can_change_status?: boolean;
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ export interface BoardRead {
|
|||||||
goal_confirmed?: boolean;
|
goal_confirmed?: boolean;
|
||||||
goal_source?: string | null;
|
goal_source?: string | null;
|
||||||
id: string;
|
id: string;
|
||||||
|
/** @minimum 0 */
|
||||||
|
max_agents?: number;
|
||||||
name: string;
|
name: string;
|
||||||
objective?: string | null;
|
objective?: string | null;
|
||||||
only_lead_can_change_status?: boolean;
|
only_lead_can_change_status?: boolean;
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export interface BoardUpdate {
|
|||||||
gateway_id?: string | null;
|
gateway_id?: string | null;
|
||||||
goal_confirmed?: boolean | null;
|
goal_confirmed?: boolean | null;
|
||||||
goal_source?: string | null;
|
goal_source?: string | null;
|
||||||
|
max_agents?: number | null;
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
objective?: string | null;
|
objective?: string | null;
|
||||||
only_lead_can_change_status?: boolean | null;
|
only_lead_can_change_status?: boolean | null;
|
||||||
|
|||||||
@@ -237,6 +237,7 @@ export default function EditBoardPage() {
|
|||||||
const [onlyLeadCanChangeStatus, setOnlyLeadCanChangeStatus] = useState<
|
const [onlyLeadCanChangeStatus, setOnlyLeadCanChangeStatus] = useState<
|
||||||
boolean | undefined
|
boolean | undefined
|
||||||
>(undefined);
|
>(undefined);
|
||||||
|
const [maxAgents, setMaxAgents] = useState<number | undefined>(undefined);
|
||||||
const [successMetrics, setSuccessMetrics] = useState<string | undefined>(
|
const [successMetrics, setSuccessMetrics] = useState<string | undefined>(
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
@@ -433,6 +434,7 @@ export default function EditBoardPage() {
|
|||||||
false;
|
false;
|
||||||
const resolvedOnlyLeadCanChangeStatus =
|
const resolvedOnlyLeadCanChangeStatus =
|
||||||
onlyLeadCanChangeStatus ?? baseBoard?.only_lead_can_change_status ?? false;
|
onlyLeadCanChangeStatus ?? baseBoard?.only_lead_can_change_status ?? false;
|
||||||
|
const resolvedMaxAgents = maxAgents ?? baseBoard?.max_agents ?? 1;
|
||||||
const resolvedSuccessMetrics =
|
const resolvedSuccessMetrics =
|
||||||
successMetrics ??
|
successMetrics ??
|
||||||
(baseBoard?.success_metrics
|
(baseBoard?.success_metrics
|
||||||
@@ -507,6 +509,7 @@ export default function EditBoardPage() {
|
|||||||
updated.block_status_changes_with_pending_approval ?? false,
|
updated.block_status_changes_with_pending_approval ?? false,
|
||||||
);
|
);
|
||||||
setOnlyLeadCanChangeStatus(updated.only_lead_can_change_status ?? false);
|
setOnlyLeadCanChangeStatus(updated.only_lead_can_change_status ?? false);
|
||||||
|
setMaxAgents(updated.max_agents ?? 1);
|
||||||
setSuccessMetrics(
|
setSuccessMetrics(
|
||||||
updated.success_metrics
|
updated.success_metrics
|
||||||
? JSON.stringify(updated.success_metrics, null, 2)
|
? JSON.stringify(updated.success_metrics, null, 2)
|
||||||
@@ -535,6 +538,10 @@ export default function EditBoardPage() {
|
|||||||
setError("Board description is required.");
|
setError("Board description is required.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!Number.isInteger(resolvedMaxAgents) || resolvedMaxAgents < 0) {
|
||||||
|
setError("Max worker agents must be a non-negative integer.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setError(null);
|
setError(null);
|
||||||
setMetricsError(null);
|
setMetricsError(null);
|
||||||
@@ -569,6 +576,7 @@ export default function EditBoardPage() {
|
|||||||
block_status_changes_with_pending_approval:
|
block_status_changes_with_pending_approval:
|
||||||
resolvedBlockStatusChangesWithPendingApproval,
|
resolvedBlockStatusChangesWithPendingApproval,
|
||||||
only_lead_can_change_status: resolvedOnlyLeadCanChangeStatus,
|
only_lead_can_change_status: resolvedOnlyLeadCanChangeStatus,
|
||||||
|
max_agents: resolvedMaxAgents,
|
||||||
success_metrics: resolvedBoardType === "general" ? null : parsedMetrics,
|
success_metrics: resolvedBoardType === "general" ? null : parsedMetrics,
|
||||||
target_date:
|
target_date:
|
||||||
resolvedBoardType === "general"
|
resolvedBoardType === "general"
|
||||||
@@ -733,6 +741,26 @@ export default function EditBoardPage() {
|
|||||||
<SelectItem value="general">General</SelectItem>
|
<SelectItem value="general">General</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
<div className="space-y-2 pt-1">
|
||||||
|
<label className="text-sm font-medium text-slate-900">
|
||||||
|
Max worker agents
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
value={resolvedMaxAgents}
|
||||||
|
onChange={(event) => {
|
||||||
|
const next = Number.parseInt(event.target.value, 10);
|
||||||
|
if (Number.isNaN(next)) {
|
||||||
|
setMaxAgents(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMaxAgents(Math.max(0, next));
|
||||||
|
}}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-slate-900">
|
<label className="text-sm font-medium text-slate-900">
|
||||||
|
|||||||
Reference in New Issue
Block a user