feat: enhance agent heartbeat handling and validate onboarding goal fields

This commit is contained in:
Abhimanyu Saharan
2026-02-06 11:50:14 +05:30
parent 814c041785
commit 7c569c150d
7 changed files with 78 additions and 13 deletions

View File

@@ -20,7 +20,7 @@ from app.models.agents import Agent
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.models.tasks import Task from app.models.tasks import Task
from app.schemas.agents import AgentCreate, AgentHeartbeatCreate, AgentNudge, AgentRead from app.schemas.agents import AgentCreate, AgentHeartbeat, AgentHeartbeatCreate, AgentNudge, AgentRead
from app.schemas.approvals import ApprovalCreate, ApprovalRead from app.schemas.approvals import ApprovalCreate, ApprovalRead
from app.schemas.board_memory import BoardMemoryCreate, BoardMemoryRead from app.schemas.board_memory import BoardMemoryCreate, BoardMemoryRead
from app.schemas.board_onboarding import BoardOnboardingRead from app.schemas.board_onboarding import BoardOnboardingRead
@@ -376,14 +376,10 @@ async def agent_heartbeat(
session: Session = Depends(get_session), session: Session = Depends(get_session),
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context), agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
) -> AgentRead: ) -> AgentRead:
if agent_ctx.agent.name != payload.name: # Heartbeats must apply to the authenticated agent; agent names are not unique.
payload = AgentHeartbeatCreate( return agents_api.heartbeat_agent( # type: ignore[attr-defined]
name=agent_ctx.agent.name, agent_id=str(agent_ctx.agent.id),
status=payload.status, payload=AgentHeartbeat(status=payload.status),
board_id=payload.board_id,
)
return await agents_api.heartbeat_or_create_agent( # type: ignore[attr-defined]
payload=payload,
session=session, session=session,
actor=_actor(agent_ctx), actor=_actor(agent_ctx),
) )

View File

@@ -588,7 +588,7 @@ def heartbeat_agent(
payload: AgentHeartbeat, payload: AgentHeartbeat,
session: Session = Depends(get_session), session: Session = Depends(get_session),
actor: ActorContext = Depends(require_admin_or_agent), actor: ActorContext = Depends(require_admin_or_agent),
) -> Agent: ) -> AgentRead:
agent = session.get(Agent, agent_id) agent = session.get(Agent, agent_id)
if agent is None: if agent is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
@@ -613,8 +613,20 @@ async def heartbeat_or_create_agent(
payload: AgentHeartbeatCreate, payload: AgentHeartbeatCreate,
session: Session = Depends(get_session), session: Session = Depends(get_session),
actor: ActorContext = Depends(require_admin_or_agent), actor: ActorContext = Depends(require_admin_or_agent),
) -> Agent: ) -> AgentRead:
agent = session.exec(select(Agent).where(Agent.name == payload.name)).first() # Agent tokens must heartbeat their authenticated agent record. Names are not unique.
if actor.actor_type == "agent" and actor.agent:
return heartbeat_agent(
agent_id=str(actor.agent.id),
payload=AgentHeartbeat(status=payload.status),
session=session,
actor=actor,
)
statement = select(Agent).where(Agent.name == payload.name)
if payload.board_id is not None:
statement = statement.where(Agent.board_id == payload.board_id)
agent = session.exec(statement).first()
if agent is None: if agent is None:
if actor.actor_type == "agent": if actor.actor_type == "agent":
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)

View File

@@ -544,6 +544,20 @@ def update_task(
) )
session.commit() session.commit()
session.refresh(task) session.refresh(task)
if task.assigned_agent_id and task.assigned_agent_id != previous_assigned:
if actor.actor_type == "agent" and actor.agent and task.assigned_agent_id == actor.agent.id:
return task
assigned_agent = session.get(Agent, task.assigned_agent_id)
if assigned_agent:
board = session.get(Board, task.board_id) if task.board_id else None
if board:
_notify_agent_on_task_assign(
session=session,
board=board,
task=task,
agent=assigned_agent,
)
return task return task
if actor.actor_type == "agent": if actor.actor_type == "agent":
if actor.agent and actor.agent.board_id and task.board_id: if actor.agent and actor.agent.board_id and task.board_id:

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from uuid import UUID from uuid import UUID
from pydantic import model_validator
from sqlmodel import SQLModel from sqlmodel import SQLModel
@@ -21,6 +22,13 @@ class BoardOnboardingConfirm(SQLModel):
success_metrics: dict[str, object] | None = None success_metrics: dict[str, object] | None = None
target_date: datetime | None = None target_date: datetime | None = None
@model_validator(mode="after")
def validate_goal_fields(self):
if self.board_type == "goal":
if not self.objective or not self.success_metrics:
raise ValueError("Confirmed goal boards require objective and success_metrics")
return self
class BoardOnboardingRead(SQLModel): class BoardOnboardingRead(SQLModel):
id: UUID id: UUID

View File

@@ -1,5 +1,6 @@
import pytest import pytest
from app.schemas.board_onboarding import BoardOnboardingConfirm
from app.schemas.boards import BoardCreate from app.schemas.boards import BoardCreate
@@ -28,3 +29,22 @@ def test_goal_board_allows_missing_objective_before_confirmation():
def test_general_board_allows_missing_objective(): def test_general_board_allows_missing_objective():
BoardCreate(name="General", slug="general", board_type="general") BoardCreate(name="General", slug="general", board_type="general")
def test_onboarding_confirm_requires_goal_fields():
with pytest.raises(ValueError):
BoardOnboardingConfirm(board_type="goal")
with pytest.raises(ValueError):
BoardOnboardingConfirm(board_type="goal", objective="Ship onboarding")
with pytest.raises(ValueError):
BoardOnboardingConfirm(board_type="goal", success_metrics={"emails": 3})
BoardOnboardingConfirm(
board_type="goal",
objective="Ship onboarding",
success_metrics={"emails": 3},
)
BoardOnboardingConfirm(board_type="general")

View File

@@ -2066,6 +2066,15 @@ export default function BoardDetailPage() {
<Textarea <Textarea
value={chatInput} value={chatInput}
onChange={(event) => setChatInput(event.target.value)} onChange={(event) => setChatInput(event.target.value)}
onKeyDown={(event) => {
if (event.key !== "Enter") return;
if (event.nativeEvent.isComposing) return;
if (event.shiftKey) return;
event.preventDefault();
if (isChatSending) return;
if (!chatInput.trim()) return;
void handleSendChat();
}}
placeholder="Message the board lead. Tag agents with @name." placeholder="Message the board lead. Tag agents with @name."
className="min-h-[120px]" className="min-h-[120px]"
/> />

View File

@@ -202,9 +202,9 @@ export function BoardOnboardingChat({
const submitAnswer = useCallback(() => { const submitAnswer = useCallback(() => {
const trimmedOther = otherText.trim(); const trimmedOther = otherText.trim();
if (selectedOptions.length === 0 && !trimmedOther) return;
const answer = const answer =
selectedOptions.length > 0 ? selectedOptions.join(", ") : "Other"; selectedOptions.length > 0 ? selectedOptions.join(", ") : "Other";
if (!answer && !trimmedOther) return;
void handleAnswer(answer, trimmedOther || undefined); void handleAnswer(answer, trimmedOther || undefined);
}, [handleAnswer, otherText, selectedOptions]); }, [handleAnswer, otherText, selectedOptions]);
@@ -294,6 +294,12 @@ export function BoardOnboardingChat({
placeholder="Other..." placeholder="Other..."
value={otherText} value={otherText}
onChange={(event) => setOtherText(event.target.value)} onChange={(event) => setOtherText(event.target.value)}
onKeyDown={(event) => {
if (event.key !== "Enter") return;
event.preventDefault();
if (loading) return;
submitAnswer();
}}
/> />
<Button <Button
variant="outline" variant="outline"