11 Commits

Author SHA1 Message Date
Abhimanyu Saharan
0e6a933c3f fix(governor): address PR review feedback
Reject null governor policy values, remove the unused per-board
cadence knob, and await governor shutdown cleanly. Also scope the
agent query to governor-managed rows and drop temporary migration
server defaults.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-03-08 01:07:10 +05:30
Abhimanyu Saharan
6a1e92cda6 fix(frontend): avoid governor draft sync effect
Replace the governor policy hydration effect with a derived current\npolicy value so the board edit page keeps the same draft behavior\nwithout violating the react-hooks set-state-in-effect lint rule.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-03-08 00:40:30 +05:30
Abhimanyu Saharan
eba090a3d3 fix(governor): satisfy backend CI checks
Align the cherry-picked governor code with the repository's lint and\ntype-check expectations. This fixes import ordering, formats the new\nservice, tightens session and heartbeat typing, and removes stale\nannotations so the backend CI job passes on current master.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-03-08 00:34:24 +05:30
Abhimanyu Saharan
cbd3339138 fix(migrations): squash governor schema changes
Collapse the cherry-picked governor schema changes into a single\nmigration on top of the current master head. This preserves the\nfeature while satisfying the one-migration-per-PR CI gate.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-03-08 00:29:33 +05:30
Abhimanyu Saharan
e99cdfc51a fix(migrations): Merge auto heartbeat governor heads
Add a merge revision so the cherry-picked governor migrations coexist\nwith the newer master migration chain. This keeps alembic upgrade\nhead working from the current repository state.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-03-08 00:23:58 +05:30
DevBot
faa96d71b1 fix(governor): apply reviewer fixes for dead code, import scope, and DB persistence
(cherry picked from commit 37c0a1ae41)
2026-03-08 00:22:09 +05:30
DevBot
a4d3c40d11 feat(ui): add board auto heartbeat governor settings section
(cherry picked from commit da741c0fd4)
2026-03-08 00:22:06 +05:30
DevBot
f27a8817cb test(governor): add board policy read/update API coverage
(cherry picked from commit 5061c6ccd7)
2026-03-08 00:22:04 +05:30
DevBot
1047a28f3c feat(governor): board-scoped auto heartbeat policy endpoints
(cherry picked from commit 3f0475e6e4)
2026-03-08 00:22:01 +05:30
DevBot
02b1709f3b fix: governor SQL table names
(cherry picked from commit 369105551d)
2026-03-08 00:21:57 +05:30
DevBot
2a3b1022c2 feat: auto heartbeat governor (elastic backoff)
(cherry picked from commit 2d1d691879)
2026-03-08 00:21:55 +05:30
13 changed files with 1250 additions and 13 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@@ -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,6 +758,9 @@ 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
if heartbeat is None:
new_entry.pop("heartbeat", None)
else:
new_entry["heartbeat"] = heartbeat new_entry["heartbeat"] = heartbeat
new_list.append(new_entry) new_list.append(new_entry)
updated_ids.add(agent_id) updated_ids.add(agent_id)
@@ -765,9 +768,10 @@ def _updated_agent_list(
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)

View File

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

View 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

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

View File

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