From ae711909ffe7b387aff40582efaf280d3cdf5eb5 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Sat, 14 Feb 2026 19:43:16 +0530 Subject: [PATCH] feat(boards): add max_agents field to board models and enforce limits --- backend/app/models/boards.py | 1 + backend/app/schemas/boards.py | 4 +- .../app/services/openclaw/provisioning_db.py | 44 ++++++++++++ .../4c1f5e2a7b9d_add_boards_max_agents.py | 37 ++++++++++ backend/tests/test_agent_create_limits.py | 68 +++++++++++++++++++ backend/tests/test_board_schema.py | 18 +++++ .../src/api/generated/model/boardCreate.ts | 2 + frontend/src/api/generated/model/boardRead.ts | 2 + .../src/api/generated/model/boardUpdate.ts | 1 + .../src/app/boards/[boardId]/edit/page.tsx | 28 ++++++++ 10 files changed, 204 insertions(+), 1 deletion(-) create mode 100644 backend/migrations/versions/4c1f5e2a7b9d_add_boards_max_agents.py create mode 100644 backend/tests/test_agent_create_limits.py diff --git a/backend/app/models/boards.py b/backend/app/models/boards.py index f478bb34..5d26340b 100644 --- a/backend/app/models/boards.py +++ b/backend/app/models/boards.py @@ -43,5 +43,6 @@ class Board(TenantScoped, table=True): require_review_before_done: bool = Field(default=False) block_status_changes_with_pending_approval: 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) updated_at: datetime = Field(default_factory=utcnow) diff --git a/backend/app/schemas/boards.py b/backend/app/schemas/boards.py index cd9fbd40..c954a1d5 100644 --- a/backend/app/schemas/boards.py +++ b/backend/app/schemas/boards.py @@ -7,7 +7,7 @@ from typing import Self from uuid import UUID 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_GATEWAY_REQUIRED = "gateway_id is required" @@ -33,6 +33,7 @@ class BoardBase(SQLModel): require_review_before_done: bool = False block_status_changes_with_pending_approval: bool = False only_lead_can_change_status: bool = False + max_agents: int = Field(default=1, ge=0) class BoardCreate(BoardBase): @@ -76,6 +77,7 @@ class BoardUpdate(SQLModel): require_review_before_done: bool | None = None block_status_changes_with_pending_approval: bool | None = None only_lead_can_change_status: bool | None = None + max_agents: int | None = Field(default=None, ge=0) @model_validator(mode="after") def validate_gateway_id(self) -> Self: diff --git a/backend/app/services/openclaw/provisioning_db.py b/backend/app/services/openclaw/provisioning_db.py index 61b3bdd3..67f5394e 100644 --- a/backend/app/services/openclaw/provisioning_db.py +++ b/backend/app/services/openclaw/provisioning_db.py @@ -922,6 +922,49 @@ class AgentLifecycleService(OpenClawDBService): 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( self, *, @@ -1484,6 +1527,7 @@ class AgentLifecycleService(OpenClawDBService): user=actor.user if actor.actor_type == "user" else None, 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) data = payload.model_dump() data["gateway_id"] = gateway.id diff --git a/backend/migrations/versions/4c1f5e2a7b9d_add_boards_max_agents.py b/backend/migrations/versions/4c1f5e2a7b9d_add_boards_max_agents.py new file mode 100644 index 00000000..3e8d2e18 --- /dev/null +++ b/backend/migrations/versions/4c1f5e2a7b9d_add_boards_max_agents.py @@ -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") diff --git a/backend/tests/test_agent_create_limits.py b/backend/tests/test_agent_create_limits.py new file mode 100644 index 00000000..046b3025 --- /dev/null +++ b/backend/tests/test_agent_create_limits.py @@ -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) diff --git a/backend/tests/test_board_schema.py b/backend/tests/test_board_schema.py index 109921bb..f0aa4516 100644 --- a/backend/tests/test_board_schema.py +++ b/backend/tests/test_board_schema.py @@ -88,17 +88,35 @@ def test_board_rule_toggles_have_expected_defaults() -> None: assert created.require_review_before_done is False assert created.block_status_changes_with_pending_approval is False assert created.only_lead_can_change_status is False + assert created.max_agents == 1 updated = BoardUpdate( require_approval_for_done=False, require_review_before_done=True, block_status_changes_with_pending_approval=True, only_lead_can_change_status=True, + max_agents=3, ) assert updated.require_approval_for_done is False assert updated.require_review_before_done is True assert updated.block_status_changes_with_pending_approval 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: diff --git a/frontend/src/api/generated/model/boardCreate.ts b/frontend/src/api/generated/model/boardCreate.ts index ce400620..4aaf95ba 100644 --- a/frontend/src/api/generated/model/boardCreate.ts +++ b/frontend/src/api/generated/model/boardCreate.ts @@ -17,6 +17,8 @@ export interface BoardCreate { gateway_id?: string | null; goal_confirmed?: boolean; goal_source?: string | null; + /** @minimum 0 */ + max_agents?: number; name: string; objective?: string | null; only_lead_can_change_status?: boolean; diff --git a/frontend/src/api/generated/model/boardRead.ts b/frontend/src/api/generated/model/boardRead.ts index c5512de3..6b54056d 100644 --- a/frontend/src/api/generated/model/boardRead.ts +++ b/frontend/src/api/generated/model/boardRead.ts @@ -19,6 +19,8 @@ export interface BoardRead { goal_confirmed?: boolean; goal_source?: string | null; id: string; + /** @minimum 0 */ + max_agents?: number; name: string; objective?: string | null; only_lead_can_change_status?: boolean; diff --git a/frontend/src/api/generated/model/boardUpdate.ts b/frontend/src/api/generated/model/boardUpdate.ts index d322dcdf..42c106a0 100644 --- a/frontend/src/api/generated/model/boardUpdate.ts +++ b/frontend/src/api/generated/model/boardUpdate.ts @@ -17,6 +17,7 @@ export interface BoardUpdate { gateway_id?: string | null; goal_confirmed?: boolean | null; goal_source?: string | null; + max_agents?: number | null; name?: string | null; objective?: string | null; only_lead_can_change_status?: boolean | null; diff --git a/frontend/src/app/boards/[boardId]/edit/page.tsx b/frontend/src/app/boards/[boardId]/edit/page.tsx index a243b043..0f048020 100644 --- a/frontend/src/app/boards/[boardId]/edit/page.tsx +++ b/frontend/src/app/boards/[boardId]/edit/page.tsx @@ -237,6 +237,7 @@ export default function EditBoardPage() { const [onlyLeadCanChangeStatus, setOnlyLeadCanChangeStatus] = useState< boolean | undefined >(undefined); + const [maxAgents, setMaxAgents] = useState(undefined); const [successMetrics, setSuccessMetrics] = useState( undefined, ); @@ -433,6 +434,7 @@ export default function EditBoardPage() { false; const resolvedOnlyLeadCanChangeStatus = onlyLeadCanChangeStatus ?? baseBoard?.only_lead_can_change_status ?? false; + const resolvedMaxAgents = maxAgents ?? baseBoard?.max_agents ?? 1; const resolvedSuccessMetrics = successMetrics ?? (baseBoard?.success_metrics @@ -507,6 +509,7 @@ export default function EditBoardPage() { updated.block_status_changes_with_pending_approval ?? false, ); setOnlyLeadCanChangeStatus(updated.only_lead_can_change_status ?? false); + setMaxAgents(updated.max_agents ?? 1); setSuccessMetrics( updated.success_metrics ? JSON.stringify(updated.success_metrics, null, 2) @@ -535,6 +538,10 @@ export default function EditBoardPage() { setError("Board description is required."); return; } + if (!Number.isInteger(resolvedMaxAgents) || resolvedMaxAgents < 0) { + setError("Max worker agents must be a non-negative integer."); + return; + } setError(null); setMetricsError(null); @@ -569,6 +576,7 @@ export default function EditBoardPage() { block_status_changes_with_pending_approval: resolvedBlockStatusChangesWithPendingApproval, only_lead_can_change_status: resolvedOnlyLeadCanChangeStatus, + max_agents: resolvedMaxAgents, success_metrics: resolvedBoardType === "general" ? null : parsedMetrics, target_date: resolvedBoardType === "general" @@ -733,6 +741,26 @@ export default function EditBoardPage() { General +
+ + { + const next = Number.parseInt(event.target.value, 10); + if (Number.isNaN(next)) { + setMaxAgents(0); + return; + } + setMaxAgents(Math.max(0, next)); + }} + disabled={isLoading} + /> +