feat(boards): add max_agents field to board models and enforce limits

This commit is contained in:
Abhimanyu Saharan
2026-02-14 19:43:16 +05:30
parent d241455da6
commit ae711909ff
10 changed files with 204 additions and 1 deletions

View File

@@ -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)

View File

@@ -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:

View File

@@ -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

View File

@@ -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")

View 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)

View File

@@ -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:

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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">