diff --git a/app/api/routers/calendar.py b/app/api/routers/calendar.py index d9b84f0..4d5d837 100644 --- a/app/api/routers/calendar.py +++ b/app/api/routers/calendar.py @@ -52,6 +52,7 @@ from app.schemas.calendar import ( ) from app.services.agent_heartbeat import get_pending_slots_for_agent from app.services.agent_status import ( + AgentStatusError, record_heartbeat, transition_to_busy, transition_to_idle, @@ -594,19 +595,31 @@ def update_agent_status( ): agent = _require_agent(db, payload.agent_id, payload.claw_identifier) target = (payload.status or '').lower().strip() - if target == AgentStatus.IDLE.value: - transition_to_idle(db, agent) - elif target == AgentStatus.BUSY.value: - transition_to_busy(db, agent, slot_type=SlotType.WORK) - elif target == AgentStatus.ON_CALL.value: - transition_to_busy(db, agent, slot_type=SlotType.ON_CALL) - elif target == AgentStatus.OFFLINE.value: - transition_to_offline(db, agent) - elif target == AgentStatus.EXHAUSTED.value: - reason = ExhaustReason.BILLING if payload.exhaust_reason == 'billing' else ExhaustReason.RATE_LIMIT - transition_to_exhausted(db, agent, reason=reason, recovery_at=payload.recovery_at) - else: - raise HTTPException(status_code=400, detail="Unsupported agent status") + # Idempotent same-state transition: a 'busy → busy' request is a + # no-op rather than a 500. Lets plugin status gates / cli `--set` + # be safe to fire-and-forget without first reading current state. + current = agent.status.value if hasattr(agent.status, 'value') else str(agent.status) + if current == target: + return {"ok": True, "agent_id": agent.agent_id, "status": current, "no_change": True} + try: + if target == AgentStatus.IDLE.value: + transition_to_idle(db, agent) + elif target == AgentStatus.BUSY.value: + transition_to_busy(db, agent, slot_type=SlotType.WORK) + elif target == AgentStatus.ON_CALL.value: + transition_to_busy(db, agent, slot_type=SlotType.ON_CALL) + elif target == AgentStatus.OFFLINE.value: + transition_to_offline(db, agent) + elif target == AgentStatus.EXHAUSTED.value: + reason = ExhaustReason.BILLING if payload.exhaust_reason == 'billing' else ExhaustReason.RATE_LIMIT + transition_to_exhausted(db, agent, reason=reason, recovery_at=payload.recovery_at) + else: + raise HTTPException(status_code=400, detail="Unsupported agent status") + except AgentStatusError as e: + # State-machine violation (e.g. busy → busy via wrong precondition) + # → 409 with the rejected transition explained, instead of a 500. + db.rollback() + raise HTTPException(status_code=409, detail=str(e)) db.commit() return {"ok": True, "agent_id": agent.agent_id, "status": agent.status.value if hasattr(agent.status, 'value') else str(agent.status)}