towerops/lib/towerops/workers/stale_agent_worker.ex
mayor 3fff0db784
Add comprehensive typespecs to agent-related modules
Adds @spec annotations to all public functions in agent-related modules for
better static analysis and documentation.

Changes:
- Added 17 typespecs to Towerops.Agents context module
  - All CRUD operations (create, read, update, delete, revoke)
  - Assignment management functions
  - Agent token resolution and lookup functions
  - PubSub broadcast function
- Added 9 typespecs to Towerops.Agents.Stats module
  - Agent health and metrics statistics
  - Device assignment breakdowns
  - Latency analysis and reassignment candidate detection
- Added typespec to Towerops.Workers.StaleAgentWorker
  - find_stale_agents/0 function
- Preserved existing typespecs in AgentChannel (socket types)

Benefits:
- Improved code documentation with clear function signatures
- Better static analysis via dialyzer
- Clearer API contracts for all agent management functions
- Type-safe UUID handling throughout agent system
2026-02-06 09:33:44 -06:00

108 lines
3.1 KiB
Elixir

defmodule Towerops.Workers.StaleAgentWorker do
@moduledoc """
Oban worker that periodically checks for stale agents.
Runs every minute via Oban.Plugins.Cron to:
1. Find agents that haven't sent a heartbeat in over 10 minutes
2. Log warnings for operators
3. Broadcast alerts via PubSub for UI updates
Agents are considered stale if:
- They are enabled
- They have checked in at least once (last_seen_at is not nil)
- They haven't checked in for more than 10 minutes
"""
use Oban.Worker, queue: :maintenance
import Ecto.Query
alias Towerops.Agents.AgentToken
alias Towerops.Repo
require Logger
# Consider agents stale if not seen in 10 minutes
@stale_threshold_minutes 10
# Consider agents "newly stale" if not seen in 10-15 minutes (for warning logs)
@newly_stale_threshold_minutes 15
@impl Oban.Worker
def perform(%Oban.Job{}) do
stale_agents = find_stale_agents()
_ =
if !Enum.empty?(stale_agents) do
process_stale_agents(stale_agents)
end
:ok
end
defp process_stale_agents(stale_agents) do
# Separate newly stale (10-15 min) from long-term stale (>15 min)
{newly_stale, long_term_stale} =
Enum.split_with(stale_agents, &newly_stale?/1)
# Log warnings for newly stale agents
log_newly_stale_agents(newly_stale)
# Log debug for long-term stale agents (reduce log noise)
Enum.each(long_term_stale, fn agent -> log_stale_agent(agent, :debug) end)
# Broadcast all stale agents for UI updates
_ = Phoenix.PubSub.broadcast(Towerops.PubSub, "agents:health", {:agents_stale, stale_agents})
end
defp log_newly_stale_agents([]), do: :ok
defp log_newly_stale_agents(newly_stale) do
Enum.each(newly_stale, fn agent -> log_stale_agent(agent, :warning) end)
Logger.warning("Detected #{length(newly_stale)} newly stale agent(s)")
end
@doc """
Returns a list of stale agent tokens.
Public for testing and manual inspection.
"""
@spec find_stale_agents() :: [AgentToken.t()]
def find_stale_agents do
cutoff = DateTime.add(DateTime.utc_now(), -@stale_threshold_minutes, :minute)
AgentToken
|> where([t], t.enabled == true)
|> where([t], not is_nil(t.last_seen_at))
|> where([t], t.last_seen_at < ^cutoff)
|> preload(:organization)
|> Repo.all()
end
defp newly_stale?(agent) do
newly_stale_cutoff = DateTime.add(DateTime.utc_now(), -@newly_stale_threshold_minutes, :minute)
DateTime.after?(agent.last_seen_at, newly_stale_cutoff)
end
defp log_stale_agent(agent, level) do
minutes_since_seen =
DateTime.utc_now()
|> DateTime.diff(agent.last_seen_at, :second)
|> div(60)
message = "Agent '#{agent.name}' is stale - last seen #{minutes_since_seen} minutes ago"
metadata = [
agent_token_id: agent.id,
agent_name: agent.name,
organization_id: agent.organization_id,
last_seen_at: agent.last_seen_at,
last_ip: agent.last_ip,
minutes_since_seen: minutes_since_seen
]
case level do
:warning -> Logger.warning(message, metadata)
:debug -> Logger.debug(message, metadata)
end
end
end