diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py index 83f68d18..56951672 100644 --- a/backend/app/api/tasks.py +++ b/backend/app/api/tasks.py @@ -1061,9 +1061,7 @@ async def update_task( board_id=board_id, previous_status=previous_status, previous_assigned=previous_assigned, - status_requested=( - requested_status is not None and requested_status != previous_status - ), + status_requested=(requested_status is not None and requested_status != previous_status), updates=updates, comment=comment, depends_on_task_ids=depends_on_task_ids, @@ -1678,6 +1676,18 @@ async def _apply_non_lead_agent_task_rules( ): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) if "status" in update.updates: + only_lead_can_change_status = ( + await session.exec( + select(col(Board.only_lead_can_change_status)).where( + col(Board.id) == update.board_id, + ), + ) + ).first() + if only_lead_can_change_status: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only board leads can change task status.", + ) status_value = _required_status_value(update.updates["status"]) if status_value != "inbox": dep_ids = await _task_dep_ids( diff --git a/backend/app/models/boards.py b/backend/app/models/boards.py index 286252c5..f478bb34 100644 --- a/backend/app/models/boards.py +++ b/backend/app/models/boards.py @@ -42,5 +42,6 @@ class Board(TenantScoped, table=True): require_approval_for_done: bool = Field(default=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) 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 584523b3..cd9fbd40 100644 --- a/backend/app/schemas/boards.py +++ b/backend/app/schemas/boards.py @@ -32,6 +32,7 @@ class BoardBase(SQLModel): require_approval_for_done: bool = True require_review_before_done: bool = False block_status_changes_with_pending_approval: bool = False + only_lead_can_change_status: bool = False class BoardCreate(BoardBase): @@ -74,6 +75,7 @@ class BoardUpdate(SQLModel): require_approval_for_done: bool | None = None require_review_before_done: bool | None = None block_status_changes_with_pending_approval: bool | None = None + only_lead_can_change_status: bool | None = None @model_validator(mode="after") def validate_gateway_id(self) -> Self: diff --git a/backend/migrations/versions/1a7b2c3d4e5f_add_board_lead_only_status_change_rule.py b/backend/migrations/versions/1a7b2c3d4e5f_add_board_lead_only_status_change_rule.py new file mode 100644 index 00000000..8f1c5b81 --- /dev/null +++ b/backend/migrations/versions/1a7b2c3d4e5f_add_board_lead_only_status_change_rule.py @@ -0,0 +1,43 @@ +"""add lead-only status change board rule + +Revision ID: 1a7b2c3d4e5f +Revises: c2e9f1a6d4b8 +Create Date: 2026-02-13 00:00:00.000000 + +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "1a7b2c3d4e5f" +down_revision = "fa6e83f8d9a1" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + board_columns = {column["name"] for column in inspector.get_columns("boards")} + if "only_lead_can_change_status" not in board_columns: + op.add_column( + "boards", + sa.Column( + "only_lead_can_change_status", + sa.Boolean(), + nullable=False, + server_default=sa.false(), + ), + ) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + board_columns = {column["name"] for column in inspector.get_columns("boards")} + if "only_lead_can_change_status" in board_columns: + op.drop_column("boards", "only_lead_can_change_status") diff --git a/backend/templates/AGENTS.md b/backend/templates/AGENTS.md index 73e8d0d7..f537b970 100644 --- a/backend/templates/AGENTS.md +++ b/backend/templates/AGENTS.md @@ -87,7 +87,7 @@ If you create cron jobs, track them in memory and delete them when no longer nee ## Collaboration (mandatory) - You are one of multiple agents on a board. Act like a team, not a silo. -- The assigned agent is the DRI for a task. Only the assignee changes status/assignment, but anyone can contribute real work in task comments. +- The assigned agent is the DRI for a task. Anyone can contribute real work in task comments. - Task comments are the primary channel for agent-to-agent collaboration. - Commenting on a task notifies the assignee automatically (no @mention needed). - Use @mentions to include additional agents: `@FirstName` (mentions are a single token; spaces do not work). diff --git a/backend/templates/HEARTBEAT_AGENT.md b/backend/templates/HEARTBEAT_AGENT.md index 3bd332c0..d27895b9 100644 --- a/backend/templates/HEARTBEAT_AGENT.md +++ b/backend/templates/HEARTBEAT_AGENT.md @@ -66,7 +66,6 @@ jq -r ' ## Task mentions - If you receive TASK MENTION or are @mentioned in a task, reply in that task. -- If you are not assigned, do not change task status or assignment. - If a non-lead peer posts a task update and you are not mentioned, only reply when you add net-new value. ## Board chat messages diff --git a/backend/tests/test_board_schema.py b/backend/tests/test_board_schema.py index 35e60ed4..109921bb 100644 --- a/backend/tests/test_board_schema.py +++ b/backend/tests/test_board_schema.py @@ -87,15 +87,18 @@ def test_board_rule_toggles_have_expected_defaults() -> None: assert created.require_approval_for_done is True 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 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, ) 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 def test_onboarding_confirm_requires_goal_fields() -> None: diff --git a/backend/tests/test_tasks_done_approval_gate.py b/backend/tests/test_tasks_done_approval_gate.py index 308ac97e..d9b9c4f1 100644 --- a/backend/tests/test_tasks_done_approval_gate.py +++ b/backend/tests/test_tasks_done_approval_gate.py @@ -18,7 +18,7 @@ from app.models.boards import Board from app.models.gateways import Gateway from app.models.organizations import Organization from app.models.tasks import Task -from app.schemas.tasks import TaskUpdate +from app.schemas.tasks import TaskRead, TaskUpdate async def _make_engine() -> AsyncEngine: @@ -39,6 +39,8 @@ async def _seed_board_task_and_agent( require_approval_for_done: bool = True, require_review_before_done: bool = False, block_status_changes_with_pending_approval: bool = False, + only_lead_can_change_status: bool = False, + agent_is_board_lead: bool = False, ) -> tuple[Board, Task, Agent]: organization_id = uuid4() gateway = Gateway( @@ -57,6 +59,7 @@ async def _seed_board_task_and_agent( require_approval_for_done=require_approval_for_done, require_review_before_done=require_review_before_done, block_status_changes_with_pending_approval=block_status_changes_with_pending_approval, + only_lead_can_change_status=only_lead_can_change_status, ) task = Task(id=uuid4(), board_id=board.id, title="Task", status=task_status) agent = Agent( @@ -65,7 +68,7 @@ async def _seed_board_task_and_agent( gateway_id=gateway.id, name="agent", status="online", - is_board_lead=False, + is_board_lead=agent_is_board_lead, ) session.add(Organization(id=organization_id, name=f"org-{organization_id}")) @@ -97,8 +100,8 @@ async def _update_task_status( task: Task, agent: Agent, status: Literal["inbox", "in_progress", "review", "done"], -) -> None: - await tasks_api.update_task( +) -> TaskRead: + return await tasks_api.update_task( payload=TaskUpdate(status=status), task=task, session=session, @@ -356,6 +359,81 @@ async def test_update_task_allows_status_change_with_pending_approval_when_toggl await engine.dispose() +@pytest.mark.asyncio +async def test_update_task_rejects_non_lead_status_change_when_only_lead_rule_enabled() -> None: + engine = await _make_engine() + try: + async with await _make_session(engine) as session: + _board, task, agent = await _seed_board_task_and_agent( + session, + task_status="inbox", + require_approval_for_done=False, + only_lead_can_change_status=True, + ) + + with pytest.raises(HTTPException) as exc: + await _update_task_status( + session, + task=task, + agent=agent, + status="in_progress", + ) + + assert exc.value.status_code == 403 + finally: + await engine.dispose() + + +@pytest.mark.asyncio +async def test_update_task_allows_non_lead_status_change_when_only_lead_rule_disabled() -> None: + engine = await _make_engine() + try: + async with await _make_session(engine) as session: + _board, task, agent = await _seed_board_task_and_agent( + session, + task_status="inbox", + require_approval_for_done=False, + only_lead_can_change_status=False, + ) + + updated = await _update_task_status( + session, + task=task, + agent=agent, + status="in_progress", + ) + + assert updated.status == "in_progress" + finally: + await engine.dispose() + + +@pytest.mark.asyncio +async def test_update_task_lead_can_still_change_status_when_only_lead_rule_enabled() -> None: + engine = await _make_engine() + try: + async with await _make_session(engine) as session: + _board, task, lead_agent = await _seed_board_task_and_agent( + session, + task_status="review", + require_approval_for_done=False, + require_review_before_done=False, + only_lead_can_change_status=True, + agent_is_board_lead=True, + ) + + updated = await tasks_api.update_task( + payload=TaskUpdate(status="inbox"), + task=task, + session=session, + actor=ActorContext(actor_type="agent", agent=lead_agent), + ) + + assert updated.status == "inbox" + finally: + await engine.dispose() + + @pytest.mark.asyncio async def test_update_task_allows_dependency_change_with_pending_approval() -> None: engine = await _make_engine() diff --git a/frontend/src/app/boards/[boardId]/edit/page.tsx b/frontend/src/app/boards/[boardId]/edit/page.tsx index 97112cb3..74f9daf5 100644 --- a/frontend/src/app/boards/[boardId]/edit/page.tsx +++ b/frontend/src/app/boards/[boardId]/edit/page.tsx @@ -231,6 +231,9 @@ export default function EditBoardPage() { blockStatusChangesWithPendingApproval, setBlockStatusChangesWithPendingApproval, ] = useState(undefined); + const [onlyLeadCanChangeStatus, setOnlyLeadCanChangeStatus] = useState< + boolean | undefined + >(undefined); const [successMetrics, setSuccessMetrics] = useState( undefined, ); @@ -425,6 +428,8 @@ export default function EditBoardPage() { blockStatusChangesWithPendingApproval ?? baseBoard?.block_status_changes_with_pending_approval ?? false; + const resolvedOnlyLeadCanChangeStatus = + onlyLeadCanChangeStatus ?? baseBoard?.only_lead_can_change_status ?? false; const resolvedSuccessMetrics = successMetrics ?? (baseBoard?.success_metrics @@ -498,6 +503,7 @@ export default function EditBoardPage() { setBlockStatusChangesWithPendingApproval( updated.block_status_changes_with_pending_approval ?? false, ); + setOnlyLeadCanChangeStatus(updated.only_lead_can_change_status ?? false); setSuccessMetrics( updated.success_metrics ? JSON.stringify(updated.success_metrics, null, 2) @@ -559,6 +565,7 @@ export default function EditBoardPage() { require_review_before_done: resolvedRequireReviewBeforeDone, block_status_changes_with_pending_approval: resolvedBlockStatusChangesWithPendingApproval, + only_lead_can_change_status: resolvedOnlyLeadCanChangeStatus, success_metrics: resolvedBoardType === "general" ? null : parsedMetrics, target_date: resolvedBoardType === "general" @@ -924,6 +931,41 @@ export default function EditBoardPage() { +
+ + + + Only lead can change status + + + Restrict status changes to the board lead. + + +
{gateways.length === 0 ? (