Compare commits
11 Commits
master
...
abhi1693/f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e6a933c3f | ||
|
|
6a1e92cda6 | ||
|
|
eba090a3d3 | ||
|
|
cbd3339138 | ||
|
|
e99cdfc51a | ||
|
|
faa96d71b1 | ||
|
|
a4d3c40d11 | ||
|
|
f27a8817cb | ||
|
|
1047a28f3c | ||
|
|
02b1709f3b | ||
|
|
2a3b1022c2 |
@@ -28,6 +28,10 @@ from app.models.agents import Agent
|
|||||||
from app.models.board_groups import BoardGroup
|
from app.models.board_groups import BoardGroup
|
||||||
from app.models.boards import Board
|
from app.models.boards import Board
|
||||||
from app.models.gateways import Gateway
|
from app.models.gateways import Gateway
|
||||||
|
from app.schemas.auto_heartbeat_governor import (
|
||||||
|
AutoHeartbeatGovernorPolicyRead,
|
||||||
|
AutoHeartbeatGovernorPolicyUpdate,
|
||||||
|
)
|
||||||
from app.schemas.boards import BoardCreate, BoardRead, BoardUpdate
|
from app.schemas.boards import BoardCreate, BoardRead, BoardUpdate
|
||||||
from app.schemas.common import OkResponse
|
from app.schemas.common import OkResponse
|
||||||
from app.schemas.pagination import DefaultLimitOffsetPage
|
from app.schemas.pagination import DefaultLimitOffsetPage
|
||||||
@@ -102,6 +106,15 @@ def _board_update_message(
|
|||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _reject_null_governor_policy_fields(updates: dict[str, object]) -> None:
|
||||||
|
null_fields = sorted(field_name for field_name, value in updates.items() if value is None)
|
||||||
|
if null_fields:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail=f"{', '.join(null_fields)} cannot be null",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _require_gateway_main_agent(session: AsyncSession, gateway: Gateway) -> None:
|
async def _require_gateway_main_agent(session: AsyncSession, gateway: Gateway) -> None:
|
||||||
main_agent = (
|
main_agent = (
|
||||||
await Agent.objects.filter_by(gateway_id=gateway.id)
|
await Agent.objects.filter_by(gateway_id=gateway.id)
|
||||||
@@ -498,6 +511,50 @@ def get_board(
|
|||||||
return board
|
return board
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{board_id}/auto-heartbeat-governor-policy",
|
||||||
|
response_model=AutoHeartbeatGovernorPolicyRead,
|
||||||
|
)
|
||||||
|
def get_auto_heartbeat_governor_policy(
|
||||||
|
board: Board = BOARD_USER_READ_DEP,
|
||||||
|
) -> AutoHeartbeatGovernorPolicyRead:
|
||||||
|
"""Get board-scoped auto heartbeat governor policy."""
|
||||||
|
return AutoHeartbeatGovernorPolicyRead(
|
||||||
|
enabled=bool(board.auto_heartbeat_governor_enabled),
|
||||||
|
ladder=list(board.auto_heartbeat_governor_ladder or []),
|
||||||
|
lead_cap_every=str(board.auto_heartbeat_governor_lead_cap_every),
|
||||||
|
activity_trigger_type=str(board.auto_heartbeat_governor_activity_trigger_type),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/{board_id}/auto-heartbeat-governor-policy",
|
||||||
|
response_model=AutoHeartbeatGovernorPolicyRead,
|
||||||
|
)
|
||||||
|
async def update_auto_heartbeat_governor_policy(
|
||||||
|
payload: AutoHeartbeatGovernorPolicyUpdate,
|
||||||
|
session: AsyncSession = SESSION_DEP,
|
||||||
|
board: Board = BOARD_USER_WRITE_DEP,
|
||||||
|
) -> AutoHeartbeatGovernorPolicyRead:
|
||||||
|
"""Patch board-scoped auto heartbeat governor policy."""
|
||||||
|
updates = payload.model_dump(exclude_unset=True)
|
||||||
|
_reject_null_governor_policy_fields(updates)
|
||||||
|
if "enabled" in updates:
|
||||||
|
board.auto_heartbeat_governor_enabled = bool(updates["enabled"])
|
||||||
|
if "ladder" in updates:
|
||||||
|
board.auto_heartbeat_governor_ladder = list(updates["ladder"])
|
||||||
|
if "lead_cap_every" in updates:
|
||||||
|
board.auto_heartbeat_governor_lead_cap_every = str(updates["lead_cap_every"])
|
||||||
|
if "activity_trigger_type" in updates:
|
||||||
|
trigger = updates["activity_trigger_type"]
|
||||||
|
board.auto_heartbeat_governor_activity_trigger_type = (
|
||||||
|
trigger.value if hasattr(trigger, "value") else str(trigger)
|
||||||
|
)
|
||||||
|
board.updated_at = utcnow()
|
||||||
|
await crud.save(session, board)
|
||||||
|
return get_auto_heartbeat_governor_policy(board)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{board_id}/snapshot", response_model=BoardSnapshot)
|
@router.get("/{board_id}/snapshot", response_model=BoardSnapshot)
|
||||||
async def get_board_snapshot(
|
async def get_board_snapshot(
|
||||||
board: Board = BOARD_ACTOR_READ_DEP,
|
board: Board = BOARD_ACTOR_READ_DEP,
|
||||||
|
|||||||
@@ -84,6 +84,10 @@ class Settings(BaseSettings):
|
|||||||
# OpenClaw gateway runtime compatibility
|
# OpenClaw gateway runtime compatibility
|
||||||
gateway_min_version: str = "2026.02.9"
|
gateway_min_version: str = "2026.02.9"
|
||||||
|
|
||||||
|
# Auto heartbeat governor
|
||||||
|
auto_heartbeat_governor_enabled: bool = False
|
||||||
|
auto_heartbeat_governor_interval_seconds: int = 300
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
log_level: str = "INFO"
|
log_level: str = "INFO"
|
||||||
log_format: str = "text"
|
log_format: str = "text"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
@@ -439,15 +440,31 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
|||||||
settings.db_auto_migrate,
|
settings.db_auto_migrate,
|
||||||
)
|
)
|
||||||
await init_db()
|
await init_db()
|
||||||
|
|
||||||
|
governor_task = None
|
||||||
if settings.rate_limit_backend == RateLimitBackend.REDIS:
|
if settings.rate_limit_backend == RateLimitBackend.REDIS:
|
||||||
validate_rate_limit_redis(settings.rate_limit_redis_url)
|
validate_rate_limit_redis(settings.rate_limit_redis_url)
|
||||||
logger.info("app.lifecycle.rate_limit backend=redis")
|
logger.info("app.lifecycle.rate_limit backend=redis")
|
||||||
else:
|
else:
|
||||||
logger.info("app.lifecycle.rate_limit backend=memory")
|
logger.info("app.lifecycle.rate_limit backend=memory")
|
||||||
|
if settings.auto_heartbeat_governor_enabled:
|
||||||
|
from app.services.auto_heartbeat_governor import governor_loop
|
||||||
|
|
||||||
|
governor_task = asyncio.create_task(governor_loop())
|
||||||
|
logger.info(
|
||||||
|
"app.auto_heartbeat.enabled",
|
||||||
|
extra={"interval_seconds": settings.auto_heartbeat_governor_interval_seconds},
|
||||||
|
)
|
||||||
logger.info("app.lifecycle.started")
|
logger.info("app.lifecycle.started")
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
|
if governor_task is not None:
|
||||||
|
governor_task.cancel()
|
||||||
|
try:
|
||||||
|
await governor_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
logger.info("app.lifecycle.stopped")
|
logger.info("app.lifecycle.stopped")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -49,5 +49,12 @@ class Agent(QueryModel, table=True):
|
|||||||
checkin_deadline_at: datetime | None = Field(default=None)
|
checkin_deadline_at: datetime | None = Field(default=None)
|
||||||
last_provision_error: str | None = Field(default=None, sa_column=Column(Text))
|
last_provision_error: str | None = Field(default=None, sa_column=Column(Text))
|
||||||
is_board_lead: bool = Field(default=False, index=True)
|
is_board_lead: bool = Field(default=False, index=True)
|
||||||
|
|
||||||
|
# Auto heartbeat governor state (Mission Control managed)
|
||||||
|
auto_heartbeat_enabled: bool = Field(default=True, index=True)
|
||||||
|
auto_heartbeat_step: int = Field(default=0)
|
||||||
|
auto_heartbeat_off: bool = Field(default=False, index=True)
|
||||||
|
auto_heartbeat_last_active_at: datetime | None = Field(default=None)
|
||||||
|
|
||||||
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)
|
||||||
|
|||||||
@@ -45,5 +45,15 @@ class Board(TenantScoped, table=True):
|
|||||||
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)
|
max_agents: int = Field(default=1)
|
||||||
|
|
||||||
|
# Auto heartbeat governor policy (board-scoped).
|
||||||
|
auto_heartbeat_governor_enabled: bool = Field(default=True)
|
||||||
|
auto_heartbeat_governor_ladder: list[str] = Field(
|
||||||
|
default_factory=lambda: ["10m", "30m", "1h", "3h", "6h"],
|
||||||
|
sa_column=Column(JSON),
|
||||||
|
)
|
||||||
|
auto_heartbeat_governor_lead_cap_every: str = Field(default="1h")
|
||||||
|
auto_heartbeat_governor_activity_trigger_type: str = Field(default="B")
|
||||||
|
|
||||||
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)
|
||||||
|
|||||||
@@ -175,6 +175,10 @@ class AgentUpdate(SQLModel):
|
|||||||
description="Optional heartbeat policy override.",
|
description="Optional heartbeat policy override.",
|
||||||
examples=[{"interval_seconds": 45}],
|
examples=[{"interval_seconds": 45}],
|
||||||
)
|
)
|
||||||
|
auto_heartbeat_enabled: bool | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="If false, Mission Control's auto heartbeat governor will not manage this agent.",
|
||||||
|
)
|
||||||
identity_profile: dict[str, Any] | None = Field(
|
identity_profile: dict[str, Any] | None = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="Optional identity profile update values.",
|
description="Optional identity profile update values.",
|
||||||
@@ -236,6 +240,22 @@ class AgentRead(AgentBase):
|
|||||||
default=False,
|
default=False,
|
||||||
description="Whether this agent is the primary gateway agent.",
|
description="Whether this agent is the primary gateway agent.",
|
||||||
)
|
)
|
||||||
|
auto_heartbeat_enabled: bool = Field(
|
||||||
|
default=True,
|
||||||
|
description="Whether Mission Control's auto heartbeat governor is allowed to manage this agent.",
|
||||||
|
)
|
||||||
|
auto_heartbeat_step: int = Field(
|
||||||
|
default=0,
|
||||||
|
description="Current backoff ladder step maintained by the governor.",
|
||||||
|
)
|
||||||
|
auto_heartbeat_off: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description="Whether the governor has currently set this agent to fully-off (unset heartbeat).",
|
||||||
|
)
|
||||||
|
auto_heartbeat_last_active_at: datetime | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Last time the governor considered this agent active.",
|
||||||
|
)
|
||||||
openclaw_session_id: str | None = Field(
|
openclaw_session_id: str | None = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="Optional openclaw session token.",
|
description="Optional openclaw session token.",
|
||||||
|
|||||||
122
backend/app/schemas/auto_heartbeat_governor.py
Normal file
122
backend/app/schemas/auto_heartbeat_governor.py
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
"""Schemas for auto heartbeat governor policy configuration."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||||
|
|
||||||
|
|
||||||
|
class ActivityTriggerType(str, Enum):
|
||||||
|
"""Which events count as 'activity' for resetting the backoff ladder."""
|
||||||
|
|
||||||
|
A = "A" # board chat only
|
||||||
|
B = "B" # board chat OR has_work (assigned in-progress/review)
|
||||||
|
|
||||||
|
|
||||||
|
DurationStr = Annotated[
|
||||||
|
str,
|
||||||
|
Field(
|
||||||
|
description="Duration string like 30s, 5m, 1h, 1d (no disabled).",
|
||||||
|
examples=["10m", "1h"],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_duration(value: str) -> str:
|
||||||
|
value = (value or "").strip()
|
||||||
|
if not value:
|
||||||
|
raise ValueError("duration must be non-empty")
|
||||||
|
if value.lower() == "disabled":
|
||||||
|
raise ValueError('duration cannot be "disabled"')
|
||||||
|
# Simple format: integer + unit.
|
||||||
|
# Keep permissive for future; server-side logic still treats these as opaque.
|
||||||
|
if not re.match(r"^\d+\s*[smhd]$", value, flags=re.IGNORECASE):
|
||||||
|
raise ValueError("duration must match ^\\d+[smhd]$")
|
||||||
|
return value.replace(" ", "")
|
||||||
|
|
||||||
|
|
||||||
|
class AutoHeartbeatGovernorPolicyBase(BaseModel):
|
||||||
|
enabled: bool = Field(
|
||||||
|
default=True,
|
||||||
|
description="If false, the governor will not manage heartbeats for this board.",
|
||||||
|
)
|
||||||
|
ladder: list[DurationStr] = Field(
|
||||||
|
default_factory=lambda: ["10m", "30m", "1h", "3h", "6h"],
|
||||||
|
description="Backoff ladder values (non-leads).",
|
||||||
|
)
|
||||||
|
lead_cap_every: DurationStr = Field(
|
||||||
|
default="1h",
|
||||||
|
description="Max backoff interval for leads.",
|
||||||
|
)
|
||||||
|
activity_trigger_type: ActivityTriggerType = Field(
|
||||||
|
default=ActivityTriggerType.B,
|
||||||
|
description="A = board chat only; B = board chat OR assigned work.",
|
||||||
|
)
|
||||||
|
|
||||||
|
@field_validator("ladder", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _normalize_ladder(cls, value: object) -> object:
|
||||||
|
# Accept comma-separated strings from UI forms.
|
||||||
|
if isinstance(value, str):
|
||||||
|
parts = [part.strip() for part in value.split(",")]
|
||||||
|
return [p for p in parts if p]
|
||||||
|
return value
|
||||||
|
|
||||||
|
@field_validator("ladder")
|
||||||
|
@classmethod
|
||||||
|
def _validate_ladder(cls, ladder: list[str]) -> list[str]:
|
||||||
|
if not ladder:
|
||||||
|
raise ValueError("ladder must have at least one value")
|
||||||
|
normalized: list[str] = []
|
||||||
|
for item in ladder:
|
||||||
|
normalized.append(_validate_duration(str(item)))
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
@field_validator("lead_cap_every")
|
||||||
|
@classmethod
|
||||||
|
def _validate_lead_cap(cls, value: str) -> str:
|
||||||
|
return _validate_duration(value)
|
||||||
|
|
||||||
|
|
||||||
|
class AutoHeartbeatGovernorPolicyRead(AutoHeartbeatGovernorPolicyBase):
|
||||||
|
"""Read model for board-scoped governor policy."""
|
||||||
|
|
||||||
|
|
||||||
|
class AutoHeartbeatGovernorPolicyUpdate(BaseModel):
|
||||||
|
"""Patch model for board-scoped governor policy."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
enabled: bool | None = None
|
||||||
|
ladder: list[DurationStr] | str | None = None
|
||||||
|
lead_cap_every: DurationStr | None = None
|
||||||
|
activity_trigger_type: ActivityTriggerType | None = None
|
||||||
|
|
||||||
|
@field_validator("ladder", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _normalize_ladder(cls, value: object) -> object:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, str):
|
||||||
|
parts = [part.strip() for part in value.split(",")]
|
||||||
|
return [p for p in parts if p]
|
||||||
|
return value
|
||||||
|
|
||||||
|
@field_validator("ladder")
|
||||||
|
@classmethod
|
||||||
|
def _validate_ladder(cls, ladder: list[str] | None) -> list[str] | None:
|
||||||
|
if ladder is None:
|
||||||
|
return None
|
||||||
|
if not ladder:
|
||||||
|
raise ValueError("ladder must have at least one value")
|
||||||
|
return [_validate_duration(str(item)) for item in ladder]
|
||||||
|
|
||||||
|
@field_validator("lead_cap_every")
|
||||||
|
@classmethod
|
||||||
|
def _validate_lead_cap(cls, value: str | None) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return _validate_duration(value)
|
||||||
382
backend/app/services/auto_heartbeat_governor.py
Normal file
382
backend/app/services/auto_heartbeat_governor.py
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
"""Auto-idle heartbeat governor.
|
||||||
|
|
||||||
|
Goal: reduce background model usage by dynamically backing off agent heartbeats when boards are idle,
|
||||||
|
while keeping agents responsive when there is activity.
|
||||||
|
|
||||||
|
Design notes:
|
||||||
|
- Runs periodically (every N seconds) from the backend process.
|
||||||
|
- Uses a DB advisory lock to ensure only one governor instance runs at a time.
|
||||||
|
- Activity trigger (per Tes spec): ANY new board chat counts as activity.
|
||||||
|
- Leads never go fully off; they cap at 1h.
|
||||||
|
- "Fully off" is implemented by unsetting the agent heartbeat in the gateway config (not by using
|
||||||
|
an invalid value like every="disabled").
|
||||||
|
|
||||||
|
This module only decides and applies desired heartbeats. It does not wake sessions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlmodel import col, select
|
||||||
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.logging import get_logger
|
||||||
|
from app.core.time import utcnow
|
||||||
|
from app.db.session import async_session_maker
|
||||||
|
from app.models.agents import Agent
|
||||||
|
from app.models.boards import Board
|
||||||
|
from app.models.gateways import Gateway
|
||||||
|
from app.services.openclaw.gateway_rpc import GatewayConfig as GatewayClientConfig
|
||||||
|
from app.services.openclaw.internal.agent_key import agent_key as _agent_key
|
||||||
|
from app.services.openclaw.provisioning import (
|
||||||
|
OpenClawGatewayControlPlane,
|
||||||
|
_workspace_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
# Governor defaults; board policy may override backoff behaviour.
|
||||||
|
DEFAULT_ACTIVE_EVERY = "5m"
|
||||||
|
DEFAULT_LADDER: list[str] = ["10m", "30m", "1h", "3h", "6h"]
|
||||||
|
DEFAULT_LEAD_CAP_EVERY = "1h"
|
||||||
|
ACTIVE_WINDOW = timedelta(minutes=60)
|
||||||
|
|
||||||
|
# Postgres advisory lock key (2x int32). Keep stable.
|
||||||
|
_ADVISORY_LOCK_KEY_1 = 424242
|
||||||
|
_ADVISORY_LOCK_KEY_2 = 1701
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DesiredHeartbeat:
|
||||||
|
every: str | None # None means "off" (unset heartbeat in gateway config)
|
||||||
|
step: int
|
||||||
|
off: bool
|
||||||
|
|
||||||
|
|
||||||
|
def _is_active(*, now: datetime, last_chat_at: datetime | None, has_work: bool) -> bool:
|
||||||
|
if has_work:
|
||||||
|
return True
|
||||||
|
if last_chat_at is None:
|
||||||
|
return False
|
||||||
|
return (now - last_chat_at) <= ACTIVE_WINDOW
|
||||||
|
|
||||||
|
|
||||||
|
def compute_desired_heartbeat(
|
||||||
|
*,
|
||||||
|
is_lead: bool,
|
||||||
|
active: bool,
|
||||||
|
step: int,
|
||||||
|
active_every: str = DEFAULT_ACTIVE_EVERY,
|
||||||
|
ladder: list[str] | None = None,
|
||||||
|
lead_cap_every: str = DEFAULT_LEAD_CAP_EVERY,
|
||||||
|
) -> DesiredHeartbeat:
|
||||||
|
"""Return desired heartbeat for an agent.
|
||||||
|
|
||||||
|
step: governor-managed backoff index.
|
||||||
|
0 means just became active.
|
||||||
|
1..len(LADDER) means ladder index-1.
|
||||||
|
len(LADDER)+1 means off (for non-leads).
|
||||||
|
"""
|
||||||
|
|
||||||
|
ladder = list(ladder or DEFAULT_LADDER)
|
||||||
|
|
||||||
|
if active:
|
||||||
|
return DesiredHeartbeat(every=active_every, step=0, off=False)
|
||||||
|
|
||||||
|
# If idle, advance one rung.
|
||||||
|
next_step = max(1, int(step) + 1)
|
||||||
|
|
||||||
|
if is_lead:
|
||||||
|
# Leads never go fully off; cap at lead_cap_every.
|
||||||
|
if next_step <= len(ladder):
|
||||||
|
next_every = ladder[next_step - 1]
|
||||||
|
else:
|
||||||
|
next_every = lead_cap_every
|
||||||
|
return DesiredHeartbeat(every=next_every, step=min(next_step, len(ladder)), off=False)
|
||||||
|
|
||||||
|
# Non-leads can go fully off after max backoff.
|
||||||
|
if next_step <= len(ladder):
|
||||||
|
return DesiredHeartbeat(every=ladder[next_step - 1], step=next_step, off=False)
|
||||||
|
|
||||||
|
return DesiredHeartbeat(every=None, step=len(ladder) + 1, off=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def _acquire_lock(session: AsyncSession) -> bool:
|
||||||
|
result = await session.execute(
|
||||||
|
text("SELECT pg_try_advisory_lock(:k1, :k2)"),
|
||||||
|
params={"k1": _ADVISORY_LOCK_KEY_1, "k2": _ADVISORY_LOCK_KEY_2},
|
||||||
|
)
|
||||||
|
row = result.first()
|
||||||
|
return bool(row and row[0])
|
||||||
|
|
||||||
|
|
||||||
|
async def _release_lock(session: AsyncSession) -> None:
|
||||||
|
await session.execute(
|
||||||
|
text("SELECT pg_advisory_unlock(:k1, :k2)"),
|
||||||
|
params={"k1": _ADVISORY_LOCK_KEY_1, "k2": _ADVISORY_LOCK_KEY_2},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _latest_chat_by_board(session: AsyncSession) -> dict[UUID, datetime]:
|
||||||
|
# Only chat memory items.
|
||||||
|
rows = await session.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT board_id, MAX(created_at) AS last_chat_at
|
||||||
|
FROM board_memory
|
||||||
|
WHERE is_chat = true
|
||||||
|
GROUP BY board_id
|
||||||
|
""",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
result: dict[UUID, datetime] = {}
|
||||||
|
for board_id, last_chat_at in rows.all():
|
||||||
|
if board_id and last_chat_at:
|
||||||
|
result[board_id] = last_chat_at
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def _has_work_map(session: AsyncSession) -> dict[UUID, bool]:
|
||||||
|
# Work = tasks assigned to the agent that are in_progress or review.
|
||||||
|
# Map by agent_id.
|
||||||
|
rows = await session.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT assigned_agent_id, COUNT(*)
|
||||||
|
FROM tasks
|
||||||
|
WHERE assigned_agent_id IS NOT NULL
|
||||||
|
AND status IN ('in_progress', 'review')
|
||||||
|
GROUP BY assigned_agent_id
|
||||||
|
""",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
result: dict[UUID, bool] = {}
|
||||||
|
for agent_id, count in rows.all():
|
||||||
|
if agent_id:
|
||||||
|
result[agent_id] = bool(count and int(count) > 0)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_heartbeat_config(agent: Agent, every: str) -> dict[str, Any]:
|
||||||
|
merged: dict[str, Any] = {
|
||||||
|
"every": every,
|
||||||
|
"target": "last",
|
||||||
|
"includeReasoning": False,
|
||||||
|
}
|
||||||
|
if isinstance(agent.heartbeat_config, dict):
|
||||||
|
merged.update({k: v for k, v in agent.heartbeat_config.items() if k != "every"})
|
||||||
|
merged["every"] = every
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
async def _patch_gateway(
|
||||||
|
*,
|
||||||
|
gateway: Gateway,
|
||||||
|
agent_entries: list[tuple[str, str, dict[str, Any] | None]],
|
||||||
|
) -> None:
|
||||||
|
control_plane = OpenClawGatewayControlPlane(
|
||||||
|
GatewayClientConfig(url=gateway.url or "", token=gateway.token),
|
||||||
|
)
|
||||||
|
# patch_agent_heartbeats supports None heartbeat entries after our patch.
|
||||||
|
await control_plane.patch_agent_heartbeats(agent_entries)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_governor_once() -> None:
|
||||||
|
if not getattr(settings, "auto_heartbeat_governor_enabled", False):
|
||||||
|
return
|
||||||
|
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
locked = await _acquire_lock(session)
|
||||||
|
if not locked:
|
||||||
|
logger.debug("auto_heartbeat.skip_locked")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
now = utcnow()
|
||||||
|
agents = (
|
||||||
|
await session.exec(
|
||||||
|
select(Agent).where(
|
||||||
|
col(Agent.auto_heartbeat_enabled).is_(True),
|
||||||
|
col(Agent.gateway_id).is_not(None),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
if not agents:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Batch compute activity signals.
|
||||||
|
chat_by_board = await _latest_chat_by_board(session)
|
||||||
|
has_work_by_agent = await _has_work_map(session)
|
||||||
|
|
||||||
|
# Load board policies referenced by these agents.
|
||||||
|
board_ids = {a.board_id for a in agents if a.board_id}
|
||||||
|
boards = (
|
||||||
|
(await session.exec(select(Board).where(col(Board.id).in_(board_ids)))).all()
|
||||||
|
if board_ids
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
board_by_id = {b.id: b for b in boards}
|
||||||
|
|
||||||
|
# Load gateways referenced by these agents.
|
||||||
|
gateway_ids = {a.gateway_id for a in agents if a.gateway_id}
|
||||||
|
gateways = (
|
||||||
|
await session.exec(select(Gateway).where(col(Gateway.id).in_(gateway_ids)))
|
||||||
|
).all()
|
||||||
|
gateway_by_id = {g.id: g for g in gateways}
|
||||||
|
|
||||||
|
# Group patches per gateway.
|
||||||
|
patches_by_gateway: dict[UUID, list[tuple[str, str, dict[str, Any] | None]]] = {}
|
||||||
|
changed = 0
|
||||||
|
|
||||||
|
for agent in agents:
|
||||||
|
if not agent.auto_heartbeat_enabled:
|
||||||
|
continue
|
||||||
|
gateway = gateway_by_id.get(agent.gateway_id)
|
||||||
|
if gateway is None or not gateway.url or not gateway.workspace_root:
|
||||||
|
continue
|
||||||
|
|
||||||
|
board = board_by_id.get(agent.board_id) if agent.board_id else None
|
||||||
|
if board is not None and not bool(board.auto_heartbeat_governor_enabled):
|
||||||
|
continue
|
||||||
|
|
||||||
|
last_chat_at = None
|
||||||
|
if agent.board_id:
|
||||||
|
last_chat_at = chat_by_board.get(agent.board_id)
|
||||||
|
|
||||||
|
has_work = has_work_by_agent.get(agent.id, False)
|
||||||
|
trigger = (
|
||||||
|
str(getattr(board, "auto_heartbeat_governor_activity_trigger_type", "B"))
|
||||||
|
if board is not None
|
||||||
|
else "B"
|
||||||
|
)
|
||||||
|
active = _is_active(
|
||||||
|
now=now,
|
||||||
|
last_chat_at=last_chat_at,
|
||||||
|
has_work=(has_work if trigger != "A" else False),
|
||||||
|
)
|
||||||
|
|
||||||
|
ladder = (
|
||||||
|
list(getattr(board, "auto_heartbeat_governor_ladder", None) or [])
|
||||||
|
if board is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if not ladder:
|
||||||
|
ladder = None
|
||||||
|
lead_cap = (
|
||||||
|
str(
|
||||||
|
getattr(
|
||||||
|
board, "auto_heartbeat_governor_lead_cap_every", DEFAULT_LEAD_CAP_EVERY
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if board is not None
|
||||||
|
else DEFAULT_LEAD_CAP_EVERY
|
||||||
|
)
|
||||||
|
|
||||||
|
desired = compute_desired_heartbeat(
|
||||||
|
is_lead=bool(agent.is_board_lead),
|
||||||
|
active=active,
|
||||||
|
step=int(agent.auto_heartbeat_step or 0),
|
||||||
|
ladder=ladder,
|
||||||
|
lead_cap_every=lead_cap,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Determine if we need to update DB state.
|
||||||
|
needs_db = (
|
||||||
|
bool(agent.auto_heartbeat_off) != desired.off
|
||||||
|
or int(agent.auto_heartbeat_step or 0) != desired.step
|
||||||
|
)
|
||||||
|
|
||||||
|
# Determine desired heartbeat payload.
|
||||||
|
agent_id = _agent_key(agent)
|
||||||
|
workspace_path = _workspace_path(agent, gateway.workspace_root)
|
||||||
|
|
||||||
|
heartbeat_payload: dict[str, Any] | None
|
||||||
|
if desired.every is None:
|
||||||
|
heartbeat_payload = None
|
||||||
|
else:
|
||||||
|
heartbeat_payload = _merge_heartbeat_config(agent, desired.every)
|
||||||
|
|
||||||
|
# Compare with current (only best-effort; gateway patch is idempotent).
|
||||||
|
# If agent is off, patch regardless (None removes heartbeat).
|
||||||
|
if desired.every is None:
|
||||||
|
patches_by_gateway.setdefault(gateway.id, []).append(
|
||||||
|
(agent_id, workspace_path, None),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Only patch when 'every' differs or we were previously off.
|
||||||
|
current_every = None
|
||||||
|
if isinstance(agent.heartbeat_config, dict):
|
||||||
|
current_every = agent.heartbeat_config.get("every")
|
||||||
|
if current_every != desired.every or bool(agent.auto_heartbeat_off):
|
||||||
|
patches_by_gateway.setdefault(gateway.id, []).append(
|
||||||
|
(agent_id, workspace_path, heartbeat_payload),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Keep heartbeat_config in sync for non-off entries.
|
||||||
|
needs_heartbeat_config_update = (
|
||||||
|
desired.every is not None and agent.heartbeat_config != heartbeat_payload
|
||||||
|
)
|
||||||
|
|
||||||
|
if needs_db or needs_heartbeat_config_update:
|
||||||
|
agent.auto_heartbeat_step = desired.step
|
||||||
|
agent.auto_heartbeat_off = desired.off
|
||||||
|
if active and needs_db:
|
||||||
|
agent.auto_heartbeat_last_active_at = now
|
||||||
|
if needs_heartbeat_config_update:
|
||||||
|
agent.heartbeat_config = heartbeat_payload
|
||||||
|
agent.updated_at = now
|
||||||
|
session.add(agent)
|
||||||
|
changed += 1
|
||||||
|
|
||||||
|
if changed:
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
# Apply patches gateway-by-gateway.
|
||||||
|
for gateway_id, entries in patches_by_gateway.items():
|
||||||
|
gateway = gateway_by_id.get(gateway_id)
|
||||||
|
if gateway is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
await _patch_gateway(gateway=gateway, agent_entries=entries)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"auto_heartbeat.patch_failed",
|
||||||
|
extra={"gateway_id": str(gateway_id), "error": str(exc)},
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"auto_heartbeat.run_complete",
|
||||||
|
extra={
|
||||||
|
"agents": len(agents),
|
||||||
|
"db_changed": changed,
|
||||||
|
"gateways": len(patches_by_gateway),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
await _release_lock(session)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("auto_heartbeat.unlock_failed")
|
||||||
|
|
||||||
|
|
||||||
|
async def governor_loop() -> None:
|
||||||
|
"""Run the governor forever."""
|
||||||
|
interval = getattr(settings, "auto_heartbeat_governor_interval_seconds", 300)
|
||||||
|
interval = max(30, int(interval))
|
||||||
|
logger.info(
|
||||||
|
"auto_heartbeat.loop_start",
|
||||||
|
extra={"interval_seconds": interval},
|
||||||
|
)
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await run_governor_once()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("auto_heartbeat.loop_error")
|
||||||
|
await asyncio.sleep(interval)
|
||||||
@@ -544,7 +544,7 @@ class GatewayControlPlane(ABC):
|
|||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def patch_agent_heartbeats(
|
async def patch_agent_heartbeats(
|
||||||
self,
|
self,
|
||||||
entries: list[tuple[str, str, dict[str, Any]]],
|
entries: list[tuple[str, str, dict[str, Any] | None]],
|
||||||
) -> None:
|
) -> None:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
@@ -684,7 +684,7 @@ class OpenClawGatewayControlPlane(GatewayControlPlane):
|
|||||||
|
|
||||||
async def patch_agent_heartbeats(
|
async def patch_agent_heartbeats(
|
||||||
self,
|
self,
|
||||||
entries: list[tuple[str, str, dict[str, Any]]],
|
entries: list[tuple[str, str, dict[str, Any] | None]],
|
||||||
) -> None:
|
) -> None:
|
||||||
base_hash, raw_list, config_data = await _gateway_config_agent_list(self._config)
|
base_hash, raw_list, config_data = await _gateway_config_agent_list(self._config)
|
||||||
entry_by_id = _heartbeat_entry_map(entries)
|
entry_by_id = _heartbeat_entry_map(entries)
|
||||||
@@ -732,8 +732,8 @@ async def _gateway_config_agent_list(
|
|||||||
|
|
||||||
|
|
||||||
def _heartbeat_entry_map(
|
def _heartbeat_entry_map(
|
||||||
entries: list[tuple[str, str, dict[str, Any]]],
|
entries: list[tuple[str, str, dict[str, Any] | None]],
|
||||||
) -> dict[str, tuple[str, dict[str, Any]]]:
|
) -> dict[str, tuple[str, dict[str, Any] | None]]:
|
||||||
return {
|
return {
|
||||||
agent_id: (workspace_path, heartbeat) for agent_id, workspace_path, heartbeat in entries
|
agent_id: (workspace_path, heartbeat) for agent_id, workspace_path, heartbeat in entries
|
||||||
}
|
}
|
||||||
@@ -741,7 +741,7 @@ def _heartbeat_entry_map(
|
|||||||
|
|
||||||
def _updated_agent_list(
|
def _updated_agent_list(
|
||||||
raw_list: list[object],
|
raw_list: list[object],
|
||||||
entry_by_id: dict[str, tuple[str, dict[str, Any]]],
|
entry_by_id: dict[str, tuple[str, dict[str, Any] | None]],
|
||||||
) -> list[object]:
|
) -> list[object]:
|
||||||
updated_ids: set[str] = set()
|
updated_ids: set[str] = set()
|
||||||
new_list: list[object] = []
|
new_list: list[object] = []
|
||||||
@@ -758,16 +758,20 @@ def _updated_agent_list(
|
|||||||
workspace_path, heartbeat = entry_by_id[agent_id]
|
workspace_path, heartbeat = entry_by_id[agent_id]
|
||||||
new_entry = dict(raw_entry)
|
new_entry = dict(raw_entry)
|
||||||
new_entry["workspace"] = workspace_path
|
new_entry["workspace"] = workspace_path
|
||||||
new_entry["heartbeat"] = heartbeat
|
if heartbeat is None:
|
||||||
|
new_entry.pop("heartbeat", None)
|
||||||
|
else:
|
||||||
|
new_entry["heartbeat"] = heartbeat
|
||||||
new_list.append(new_entry)
|
new_list.append(new_entry)
|
||||||
updated_ids.add(agent_id)
|
updated_ids.add(agent_id)
|
||||||
|
|
||||||
for agent_id, (workspace_path, heartbeat) in entry_by_id.items():
|
for agent_id, (workspace_path, heartbeat) in entry_by_id.items():
|
||||||
if agent_id in updated_ids:
|
if agent_id in updated_ids:
|
||||||
continue
|
continue
|
||||||
new_list.append(
|
entry: dict[str, Any] = {"id": agent_id, "workspace": workspace_path}
|
||||||
{"id": agent_id, "workspace": workspace_path, "heartbeat": heartbeat},
|
if heartbeat is not None:
|
||||||
)
|
entry["heartbeat"] = heartbeat
|
||||||
|
new_list.append(entry)
|
||||||
|
|
||||||
return new_list
|
return new_list
|
||||||
|
|
||||||
@@ -1073,7 +1077,7 @@ def _control_plane_for_gateway(gateway: Gateway) -> OpenClawGatewayControlPlane:
|
|||||||
async def _patch_gateway_agent_heartbeats(
|
async def _patch_gateway_agent_heartbeats(
|
||||||
gateway: Gateway,
|
gateway: Gateway,
|
||||||
*,
|
*,
|
||||||
entries: list[tuple[str, str, dict[str, Any]]],
|
entries: list[tuple[str, str, dict[str, Any] | None]],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Patch multiple agent heartbeat configs in a single gateway config.patch call.
|
"""Patch multiple agent heartbeat configs in a single gateway config.patch call.
|
||||||
|
|
||||||
@@ -1113,7 +1117,7 @@ class OpenClawGatewayProvisioner:
|
|||||||
if not gateway.workspace_root:
|
if not gateway.workspace_root:
|
||||||
msg = "gateway workspace_root is required"
|
msg = "gateway workspace_root is required"
|
||||||
raise OpenClawGatewayError(msg)
|
raise OpenClawGatewayError(msg)
|
||||||
entries: list[tuple[str, str, dict[str, Any]]] = []
|
entries: list[tuple[str, str, dict[str, Any] | None]] = []
|
||||||
for agent in agents:
|
for agent in agents:
|
||||||
agent_id = _agent_key(agent)
|
agent_id = _agent_key(agent)
|
||||||
workspace_path = _workspace_path(agent, gateway.workspace_root)
|
workspace_path = _workspace_path(agent, gateway.workspace_root)
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
"""add auto heartbeat governor schema
|
||||||
|
|
||||||
|
Revision ID: e474bac07e41
|
||||||
|
Revises: a9b1c2d3e4f7
|
||||||
|
Create Date: 2026-03-08 00:23:13.457926
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "e474bac07e41"
|
||||||
|
down_revision = "a9b1c2d3e4f7"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"agents",
|
||||||
|
sa.Column(
|
||||||
|
"auto_heartbeat_enabled",
|
||||||
|
sa.Boolean(),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("true"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"agents",
|
||||||
|
sa.Column(
|
||||||
|
"auto_heartbeat_step",
|
||||||
|
sa.Integer(),
|
||||||
|
nullable=False,
|
||||||
|
server_default="0",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"agents",
|
||||||
|
sa.Column(
|
||||||
|
"auto_heartbeat_off",
|
||||||
|
sa.Boolean(),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("false"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"agents",
|
||||||
|
sa.Column("auto_heartbeat_last_active_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_agents_auto_heartbeat_enabled"),
|
||||||
|
"agents",
|
||||||
|
["auto_heartbeat_enabled"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_agents_auto_heartbeat_off"),
|
||||||
|
"agents",
|
||||||
|
["auto_heartbeat_off"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.alter_column("agents", "auto_heartbeat_enabled", server_default=None)
|
||||||
|
op.alter_column("agents", "auto_heartbeat_step", server_default=None)
|
||||||
|
op.alter_column("agents", "auto_heartbeat_off", server_default=None)
|
||||||
|
|
||||||
|
op.add_column(
|
||||||
|
"boards",
|
||||||
|
sa.Column(
|
||||||
|
"auto_heartbeat_governor_enabled",
|
||||||
|
sa.Boolean(),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("true"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"boards",
|
||||||
|
sa.Column(
|
||||||
|
"auto_heartbeat_governor_ladder",
|
||||||
|
sa.JSON(),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("'[\"10m\", \"30m\", \"1h\", \"3h\", \"6h\"]'"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"boards",
|
||||||
|
sa.Column(
|
||||||
|
"auto_heartbeat_governor_lead_cap_every",
|
||||||
|
sa.String(),
|
||||||
|
nullable=False,
|
||||||
|
server_default="1h",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"boards",
|
||||||
|
sa.Column(
|
||||||
|
"auto_heartbeat_governor_activity_trigger_type",
|
||||||
|
sa.String(),
|
||||||
|
nullable=False,
|
||||||
|
server_default="B",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.alter_column("boards", "auto_heartbeat_governor_enabled", server_default=None)
|
||||||
|
op.alter_column("boards", "auto_heartbeat_governor_ladder", server_default=None)
|
||||||
|
op.alter_column("boards", "auto_heartbeat_governor_lead_cap_every", server_default=None)
|
||||||
|
op.alter_column("boards", "auto_heartbeat_governor_activity_trigger_type", server_default=None)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("boards", "auto_heartbeat_governor_activity_trigger_type")
|
||||||
|
op.drop_column("boards", "auto_heartbeat_governor_lead_cap_every")
|
||||||
|
op.drop_column("boards", "auto_heartbeat_governor_ladder")
|
||||||
|
op.drop_column("boards", "auto_heartbeat_governor_enabled")
|
||||||
|
|
||||||
|
op.drop_index(op.f("ix_agents_auto_heartbeat_off"), table_name="agents")
|
||||||
|
op.drop_index(op.f("ix_agents_auto_heartbeat_enabled"), table_name="agents")
|
||||||
|
op.drop_column("agents", "auto_heartbeat_last_active_at")
|
||||||
|
op.drop_column("agents", "auto_heartbeat_off")
|
||||||
|
op.drop_column("agents", "auto_heartbeat_step")
|
||||||
|
op.drop_column("agents", "auto_heartbeat_enabled")
|
||||||
42
backend/tests/test_auto_heartbeat_governor_policy.py
Normal file
42
backend/tests/test_auto_heartbeat_governor_policy.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.services.auto_heartbeat_governor import compute_desired_heartbeat
|
||||||
|
|
||||||
|
|
||||||
|
def test_policy_active_resets_to_5m() -> None:
|
||||||
|
desired = compute_desired_heartbeat(is_lead=False, active=True, step=3)
|
||||||
|
assert desired.every == "5m"
|
||||||
|
assert desired.step == 0
|
||||||
|
assert desired.off is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_policy_backoff_steps_non_lead() -> None:
|
||||||
|
# idle step 0 -> 10m
|
||||||
|
d1 = compute_desired_heartbeat(is_lead=False, active=False, step=0)
|
||||||
|
assert d1.every == "10m"
|
||||||
|
assert d1.off is False
|
||||||
|
|
||||||
|
# keep idling up the ladder
|
||||||
|
d2 = compute_desired_heartbeat(is_lead=False, active=False, step=d1.step)
|
||||||
|
assert d2.every == "30m"
|
||||||
|
|
||||||
|
d3 = compute_desired_heartbeat(is_lead=False, active=False, step=d2.step)
|
||||||
|
assert d3.every == "1h"
|
||||||
|
|
||||||
|
d4 = compute_desired_heartbeat(is_lead=False, active=False, step=d3.step)
|
||||||
|
assert d4.every == "3h"
|
||||||
|
|
||||||
|
d5 = compute_desired_heartbeat(is_lead=False, active=False, step=d4.step)
|
||||||
|
assert d5.every == "6h"
|
||||||
|
|
||||||
|
# next step goes fully off
|
||||||
|
d6 = compute_desired_heartbeat(is_lead=False, active=False, step=d5.step)
|
||||||
|
assert d6.every is None
|
||||||
|
assert d6.off is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_policy_lead_caps_at_1h_never_off() -> None:
|
||||||
|
# step beyond ladder should still cap at 1h
|
||||||
|
d = compute_desired_heartbeat(is_lead=True, active=False, step=999)
|
||||||
|
assert d.every == "1h"
|
||||||
|
assert d.off is False
|
||||||
199
backend/tests/test_auto_heartbeat_governor_policy_api.py
Normal file
199
backend/tests/test_auto_heartbeat_governor_policy_api.py
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
# ruff: noqa: INP001
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import APIRouter, Depends, FastAPI
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine
|
||||||
|
from sqlmodel import SQLModel
|
||||||
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
|
from app.api.boards import router as boards_router
|
||||||
|
from app.api.deps import get_board_for_user_read, get_board_for_user_write
|
||||||
|
from app.db.session import get_session
|
||||||
|
from app.models.boards import Board
|
||||||
|
from app.models.gateways import Gateway
|
||||||
|
from app.models.organizations import Organization
|
||||||
|
|
||||||
|
|
||||||
|
async def _make_engine() -> AsyncEngine:
|
||||||
|
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||||
|
async with engine.connect() as conn, conn.begin():
|
||||||
|
await conn.run_sync(SQLModel.metadata.create_all)
|
||||||
|
return engine
|
||||||
|
|
||||||
|
|
||||||
|
def _build_test_app(
|
||||||
|
session_maker: async_sessionmaker[AsyncSession],
|
||||||
|
board_id: UUID,
|
||||||
|
) -> FastAPI:
|
||||||
|
app = FastAPI()
|
||||||
|
api_v1 = APIRouter(prefix="/api/v1")
|
||||||
|
api_v1.include_router(boards_router)
|
||||||
|
app.include_router(api_v1)
|
||||||
|
|
||||||
|
async def _override_get_session() -> AsyncSession:
|
||||||
|
async with session_maker() as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
|
async def _override_board_read(
|
||||||
|
board_id: UUID,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> Board:
|
||||||
|
board = await Board.objects.by_id(board_id).first(session)
|
||||||
|
assert board is not None
|
||||||
|
return board
|
||||||
|
|
||||||
|
app.dependency_overrides[get_session] = _override_get_session
|
||||||
|
app.dependency_overrides[get_board_for_user_read] = _override_board_read
|
||||||
|
app.dependency_overrides[get_board_for_user_write] = _override_board_read
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_board(session: AsyncSession) -> Board:
|
||||||
|
organization_id = uuid4()
|
||||||
|
gateway_id = uuid4()
|
||||||
|
board_id = uuid4()
|
||||||
|
|
||||||
|
session.add(Organization(id=organization_id, name=f"org-{organization_id}"))
|
||||||
|
session.add(
|
||||||
|
Gateway(
|
||||||
|
id=gateway_id,
|
||||||
|
organization_id=organization_id,
|
||||||
|
name="gateway",
|
||||||
|
url="https://gateway.example.local",
|
||||||
|
token="gw-token",
|
||||||
|
workspace_root="/tmp",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
board = Board(
|
||||||
|
id=board_id,
|
||||||
|
organization_id=organization_id,
|
||||||
|
gateway_id=gateway_id,
|
||||||
|
name="Board",
|
||||||
|
slug="board",
|
||||||
|
description="desc",
|
||||||
|
)
|
||||||
|
session.add(board)
|
||||||
|
await session.commit()
|
||||||
|
return board
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_and_patch_policy_round_trip() -> None:
|
||||||
|
engine = await _make_engine()
|
||||||
|
session_maker = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
|
||||||
|
|
||||||
|
async with session_maker() as session:
|
||||||
|
board = await _seed_board(session)
|
||||||
|
|
||||||
|
app = _build_test_app(session_maker, board.id)
|
||||||
|
|
||||||
|
async with AsyncClient(
|
||||||
|
transport=ASGITransport(app=app),
|
||||||
|
base_url="http://test",
|
||||||
|
) as client:
|
||||||
|
resp = await client.get(f"/api/v1/boards/{board.id}/auto-heartbeat-governor-policy")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["enabled"] is True
|
||||||
|
assert data["activity_trigger_type"] == "B"
|
||||||
|
assert data["lead_cap_every"] == "1h"
|
||||||
|
assert data["ladder"] == ["10m", "30m", "1h", "3h", "6h"]
|
||||||
|
|
||||||
|
patch = {
|
||||||
|
"enabled": False,
|
||||||
|
"activity_trigger_type": "A",
|
||||||
|
"ladder": ["15m", "45m"],
|
||||||
|
"lead_cap_every": "2h",
|
||||||
|
}
|
||||||
|
resp = await client.patch(
|
||||||
|
f"/api/v1/boards/{board.id}/auto-heartbeat-governor-policy",
|
||||||
|
json=patch,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
updated = resp.json()
|
||||||
|
assert updated["enabled"] is False
|
||||||
|
assert updated["activity_trigger_type"] == "A"
|
||||||
|
assert updated["ladder"] == ["15m", "45m"]
|
||||||
|
assert updated["lead_cap_every"] == "2h"
|
||||||
|
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_policy_validation_rejects_disabled_duration() -> None:
|
||||||
|
engine = await _make_engine()
|
||||||
|
session_maker = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
|
||||||
|
|
||||||
|
async with session_maker() as session:
|
||||||
|
board = await _seed_board(session)
|
||||||
|
|
||||||
|
app = _build_test_app(session_maker, board.id)
|
||||||
|
|
||||||
|
async with AsyncClient(
|
||||||
|
transport=ASGITransport(app=app),
|
||||||
|
base_url="http://test",
|
||||||
|
) as client:
|
||||||
|
resp = await client.patch(
|
||||||
|
f"/api/v1/boards/{board.id}/auto-heartbeat-governor-policy",
|
||||||
|
json={"lead_cap_every": "disabled"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_policy_validation_rejects_nulls_and_unknown_fields() -> None:
|
||||||
|
engine = await _make_engine()
|
||||||
|
session_maker = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
|
||||||
|
|
||||||
|
async with session_maker() as session:
|
||||||
|
board = await _seed_board(session)
|
||||||
|
|
||||||
|
app = _build_test_app(session_maker, board.id)
|
||||||
|
|
||||||
|
async with AsyncClient(
|
||||||
|
transport=ASGITransport(app=app),
|
||||||
|
base_url="http://test",
|
||||||
|
) as client:
|
||||||
|
null_resp = await client.patch(
|
||||||
|
f"/api/v1/boards/{board.id}/auto-heartbeat-governor-policy",
|
||||||
|
json={"lead_cap_every": None},
|
||||||
|
)
|
||||||
|
assert null_resp.status_code == 422
|
||||||
|
|
||||||
|
extra_resp = await client.patch(
|
||||||
|
f"/api/v1/boards/{board.id}/auto-heartbeat-governor-policy",
|
||||||
|
json={"run_interval_seconds": 600},
|
||||||
|
)
|
||||||
|
assert extra_resp.status_code == 422
|
||||||
|
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_policy_validation_rejects_empty_ladder() -> None:
|
||||||
|
engine = await _make_engine()
|
||||||
|
session_maker = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
|
||||||
|
|
||||||
|
async with session_maker() as session:
|
||||||
|
board = await _seed_board(session)
|
||||||
|
|
||||||
|
app = _build_test_app(session_maker, board.id)
|
||||||
|
|
||||||
|
async with AsyncClient(
|
||||||
|
transport=ASGITransport(app=app),
|
||||||
|
base_url="http://test",
|
||||||
|
) as client:
|
||||||
|
resp = await client.patch(
|
||||||
|
f"/api/v1/boards/{board.id}/auto-heartbeat-governor-policy",
|
||||||
|
json={"ladder": []},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
await engine.dispose()
|
||||||
@@ -7,9 +7,9 @@ import { useParams, useRouter, useSearchParams } from "next/navigation";
|
|||||||
|
|
||||||
import { useAuth } from "@/auth/clerk";
|
import { useAuth } from "@/auth/clerk";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { ApiError } from "@/api/mutator";
|
import { ApiError, customFetch } from "@/api/mutator";
|
||||||
import {
|
import {
|
||||||
type getBoardApiV1BoardsBoardIdGetResponse,
|
type getBoardApiV1BoardsBoardIdGetResponse,
|
||||||
useGetBoardApiV1BoardsBoardIdGet,
|
useGetBoardApiV1BoardsBoardIdGet,
|
||||||
@@ -69,6 +69,21 @@ const slugify = (value: string) =>
|
|||||||
|
|
||||||
const LEAD_AGENT_VALUE = "__lead_agent__";
|
const LEAD_AGENT_VALUE = "__lead_agent__";
|
||||||
|
|
||||||
|
type GovernorActivityTriggerType = "A" | "B";
|
||||||
|
|
||||||
|
type AutoHeartbeatGovernorPolicy = {
|
||||||
|
enabled: boolean;
|
||||||
|
ladder: string[];
|
||||||
|
lead_cap_every: string;
|
||||||
|
activity_trigger_type: GovernorActivityTriggerType;
|
||||||
|
};
|
||||||
|
|
||||||
|
const governorPolicyQueryKey = (boardId: string) => [
|
||||||
|
"boards",
|
||||||
|
boardId,
|
||||||
|
"auto-heartbeat-governor-policy",
|
||||||
|
] as const;
|
||||||
|
|
||||||
type WebhookCardProps = {
|
type WebhookCardProps = {
|
||||||
webhook: BoardWebhookRead;
|
webhook: BoardWebhookRead;
|
||||||
agents: AgentRead[];
|
agents: AgentRead[];
|
||||||
@@ -314,6 +329,16 @@ export default function EditBoardPage() {
|
|||||||
const [webhookError, setWebhookError] = useState<string | null>(null);
|
const [webhookError, setWebhookError] = useState<string | null>(null);
|
||||||
const [copiedWebhookId, setCopiedWebhookId] = useState<string | null>(null);
|
const [copiedWebhookId, setCopiedWebhookId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [governorPolicyDraft, setGovernorPolicyDraft] = useState<
|
||||||
|
AutoHeartbeatGovernorPolicy | undefined
|
||||||
|
>(undefined);
|
||||||
|
const [governorPolicyError, setGovernorPolicyError] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [governorPolicySaveSuccess, setGovernorPolicySaveSuccess] = useState<
|
||||||
|
string | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
const onboardingParam = searchParams.get("onboarding");
|
const onboardingParam = searchParams.get("onboarding");
|
||||||
const searchParamsString = searchParams.toString();
|
const searchParamsString = searchParams.toString();
|
||||||
const shouldAutoOpenOnboarding =
|
const shouldAutoOpenOnboarding =
|
||||||
@@ -422,6 +447,57 @@ export default function EditBoardPage() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const governorPolicyQuery = useQuery({
|
||||||
|
queryKey: boardId ? governorPolicyQueryKey(boardId) : [],
|
||||||
|
enabled: Boolean(isSignedIn && isAdmin && boardId),
|
||||||
|
retry: false,
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!boardId) return null;
|
||||||
|
const resp = await customFetch<{ data: AutoHeartbeatGovernorPolicy }>(
|
||||||
|
`/api/v1/boards/${boardId}/auto-heartbeat-governor-policy`,
|
||||||
|
{ method: "GET" },
|
||||||
|
);
|
||||||
|
return resp.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentGovernorPolicy =
|
||||||
|
governorPolicyDraft ?? governorPolicyQuery.data ?? undefined;
|
||||||
|
|
||||||
|
const saveGovernorPolicyMutation = useMutation({
|
||||||
|
mutationFn: async (policy: Partial<AutoHeartbeatGovernorPolicy>) => {
|
||||||
|
if (!boardId) throw new Error("Missing board id");
|
||||||
|
const resp = await customFetch<{ data: AutoHeartbeatGovernorPolicy }>(
|
||||||
|
`/api/v1/boards/${boardId}/auto-heartbeat-governor-policy`,
|
||||||
|
{
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(policy),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return resp.data;
|
||||||
|
},
|
||||||
|
onSuccess: async (data) => {
|
||||||
|
if (!boardId) return;
|
||||||
|
setGovernorPolicyError(null);
|
||||||
|
setGovernorPolicySaveSuccess("Governor policy saved.");
|
||||||
|
setGovernorPolicyDraft(data);
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: governorPolicyQueryKey(boardId),
|
||||||
|
});
|
||||||
|
window.setTimeout(() => setGovernorPolicySaveSuccess(null), 2500);
|
||||||
|
},
|
||||||
|
onError: (err: unknown) => {
|
||||||
|
const message =
|
||||||
|
err instanceof ApiError
|
||||||
|
? err.message
|
||||||
|
: err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "Unable to save governor policy.";
|
||||||
|
setGovernorPolicySaveSuccess(null);
|
||||||
|
setGovernorPolicyError(message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const updateBoardMutation = useUpdateBoardApiV1BoardsBoardIdPatch<ApiError>({
|
const updateBoardMutation = useUpdateBoardApiV1BoardsBoardIdPatch<ApiError>({
|
||||||
mutation: {
|
mutation: {
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
@@ -543,12 +619,16 @@ export default function EditBoardPage() {
|
|||||||
gatewaysQuery.isLoading ||
|
gatewaysQuery.isLoading ||
|
||||||
groupsQuery.isLoading ||
|
groupsQuery.isLoading ||
|
||||||
boardQuery.isLoading ||
|
boardQuery.isLoading ||
|
||||||
|
governorPolicyQuery.isLoading ||
|
||||||
updateBoardMutation.isPending;
|
updateBoardMutation.isPending;
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
error ??
|
error ??
|
||||||
gatewaysQuery.error?.message ??
|
gatewaysQuery.error?.message ??
|
||||||
groupsQuery.error?.message ??
|
groupsQuery.error?.message ??
|
||||||
boardQuery.error?.message ??
|
boardQuery.error?.message ??
|
||||||
|
(governorPolicyQuery.error instanceof Error
|
||||||
|
? governorPolicyQuery.error.message
|
||||||
|
: null) ??
|
||||||
null;
|
null;
|
||||||
const webhookErrorMessage =
|
const webhookErrorMessage =
|
||||||
webhookError ??
|
webhookError ??
|
||||||
@@ -1130,6 +1210,176 @@ export default function EditBoardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-4 border-t border-slate-200 pt-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-semibold text-slate-900">
|
||||||
|
Auto heartbeat governor
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-slate-600">
|
||||||
|
Controls how Mission Control automatically backs off agent
|
||||||
|
heartbeats when this board is idle.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{governorPolicySaveSuccess ? (
|
||||||
|
<p className="text-sm text-emerald-600">
|
||||||
|
{governorPolicySaveSuccess}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{governorPolicyError ? (
|
||||||
|
<p className="text-sm text-red-500">{governorPolicyError}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{currentGovernorPolicy ? (
|
||||||
|
<div className="space-y-4 rounded-lg border border-slate-200 px-4 py-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={currentGovernorPolicy.enabled}
|
||||||
|
aria-label="Enable auto heartbeat governor"
|
||||||
|
onClick={() => {
|
||||||
|
setGovernorPolicySaveSuccess(null);
|
||||||
|
setGovernorPolicyError(null);
|
||||||
|
setGovernorPolicyDraft({
|
||||||
|
...currentGovernorPolicy,
|
||||||
|
enabled: !currentGovernorPolicy.enabled,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
disabled={isLoading || saveGovernorPolicyMutation.isPending}
|
||||||
|
className={`mt-0.5 inline-flex h-6 w-11 shrink-0 items-center rounded-full border transition ${
|
||||||
|
currentGovernorPolicy.enabled
|
||||||
|
? "border-emerald-600 bg-emerald-600"
|
||||||
|
: "border-slate-300 bg-slate-200"
|
||||||
|
} ${
|
||||||
|
isLoading || saveGovernorPolicyMutation.isPending
|
||||||
|
? "cursor-not-allowed opacity-60"
|
||||||
|
: "cursor-pointer"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-5 w-5 rounded-full bg-white shadow-sm transition ${
|
||||||
|
currentGovernorPolicy.enabled
|
||||||
|
? "translate-x-5"
|
||||||
|
: "translate-x-0.5"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<span className="space-y-1">
|
||||||
|
<span className="block text-sm font-medium text-slate-900">
|
||||||
|
Enabled
|
||||||
|
</span>
|
||||||
|
<span className="block text-xs text-slate-600">
|
||||||
|
If disabled, the governor will not manage agent heartbeats
|
||||||
|
for this board.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-900">
|
||||||
|
Activity trigger type
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
value={currentGovernorPolicy.activity_trigger_type}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setGovernorPolicyDraft({
|
||||||
|
...currentGovernorPolicy,
|
||||||
|
activity_trigger_type: value as GovernorActivityTriggerType,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
disabled={isLoading || saveGovernorPolicyMutation.isPending}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select trigger" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="B">B — chat or assigned work</SelectItem>
|
||||||
|
<SelectItem value="A">A — chat only</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 md:col-span-2">
|
||||||
|
<label className="text-sm font-medium text-slate-900">
|
||||||
|
Ladder values
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={currentGovernorPolicy.ladder.join(", ")}
|
||||||
|
onChange={(event) => {
|
||||||
|
const ladder = event.target.value
|
||||||
|
.split(",")
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
setGovernorPolicyDraft({
|
||||||
|
...currentGovernorPolicy,
|
||||||
|
ladder,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
placeholder="10m, 30m, 1h, 3h, 6h"
|
||||||
|
disabled={isLoading || saveGovernorPolicyMutation.isPending}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
Comma-separated durations (e.g. 10m, 1h). Non-leads go
|
||||||
|
fully off after the last rung.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-900">
|
||||||
|
Lead cap
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={currentGovernorPolicy.lead_cap_every}
|
||||||
|
onChange={(event) =>
|
||||||
|
setGovernorPolicyDraft({
|
||||||
|
...currentGovernorPolicy,
|
||||||
|
lead_cap_every: event.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="1h"
|
||||||
|
disabled={isLoading || saveGovernorPolicyMutation.isPending}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
Leads never go fully off; they cap at this value.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (!currentGovernorPolicy) return;
|
||||||
|
setGovernorPolicyError(null);
|
||||||
|
setGovernorPolicySaveSuccess(null);
|
||||||
|
saveGovernorPolicyMutation.mutate({
|
||||||
|
enabled: currentGovernorPolicy.enabled,
|
||||||
|
ladder: currentGovernorPolicy.ladder,
|
||||||
|
lead_cap_every: currentGovernorPolicy.lead_cap_every,
|
||||||
|
activity_trigger_type:
|
||||||
|
currentGovernorPolicy.activity_trigger_type,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
disabled={
|
||||||
|
isLoading ||
|
||||||
|
saveGovernorPolicyMutation.isPending ||
|
||||||
|
!currentGovernorPolicy.ladder.length ||
|
||||||
|
!currentGovernorPolicy.lead_cap_every.trim()
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{saveGovernorPolicyMutation.isPending ? "Saving…" : "Save governor policy"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
|
||||||
|
Loading governor policy…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
{gateways.length === 0 ? (
|
{gateways.length === 0 ? (
|
||||||
<div className="rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
|
<div className="rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
|
||||||
<p>
|
<p>
|
||||||
|
|||||||
Reference in New Issue
Block a user