symbiont_ex/lib/symbiont/heartbeat.ex
Claude Opus 4.6 11a9026597 heartbeat: notify Michael by email when each queued task completes
Adds notify_task_complete/3 called after every queue task resolves
(done, blocked, or failed). Posts to Telepathy's POST /email endpoint.
Also adds :inets and :ssl to extra_applications so :httpc is available.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 21:21:49 +00:00

245 lines
6.6 KiB
Elixir

defmodule Symbiont.Heartbeat do
@moduledoc """
Periodic health check and queue processor.
Runs on a configurable interval (default: 5 minutes).
Each tick:
1. Checks system health (API responding, disk space, ledger writable)
2. Processes pending tasks from the queue
3. Logs a health snapshot to heartbeat.jsonl
"""
use GenServer
require Logger
# -- Client API --
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@doc "Trigger a heartbeat manually (useful for testing)."
def pulse do
GenServer.call(__MODULE__, :pulse, 60_000)
end
@doc "Get the last recorded health snapshot."
def last_snapshot do
GenServer.call(__MODULE__, :last_snapshot)
end
# -- Server Callbacks --
@impl true
def init(_opts) do
interval = Application.get_env(:symbiont, :heartbeat_interval_ms, 300_000)
data_dir = Application.get_env(:symbiont, :data_dir, "data")
heartbeat_path = Path.join(data_dir, "heartbeat.jsonl")
unless File.exists?(heartbeat_path), do: File.write!(heartbeat_path, "")
# Schedule first heartbeat after a short delay (let other services start)
Process.send_after(self(), :tick, 5_000)
state = %{
interval: interval,
heartbeat_path: heartbeat_path,
last_snapshot: nil,
started_at: DateTime.utc_now()
}
{:ok, state}
end
@impl true
def handle_info(:tick, state) do
snapshot = run_heartbeat(state)
schedule_next(state.interval)
{:noreply, %{state | last_snapshot: snapshot}}
end
@impl true
def handle_call(:pulse, _from, state) do
snapshot = run_heartbeat(state)
{:reply, snapshot, %{state | last_snapshot: snapshot}}
end
@impl true
def handle_call(:last_snapshot, _from, state) do
{:reply, state.last_snapshot, state}
end
# -- Private --
defp run_heartbeat(state) do
Logger.info("Heartbeat: running health check")
# 1. Check health
queue_size = Symbiont.Queue.size()
ledger_stats = Symbiont.Ledger.stats()
# 2. Process pending tasks
max_batch = Application.get_env(:symbiont, :max_queue_batch, 5)
tasks_processed = process_queue(max_batch)
# 3. Build snapshot
snapshot = %{
"timestamp" => DateTime.utc_now() |> DateTime.to_iso8601(),
"status" => "healthy",
"queue_size" => queue_size,
"tasks_processed" => tasks_processed,
"total_calls" => ledger_stats["total_calls"],
"total_cost" => ledger_stats["total_cost_estimated_usd"],
"uptime_seconds" =>
DateTime.diff(DateTime.utc_now(), state.started_at, :second)
}
# 4. Log snapshot
line = Jason.encode!(snapshot) <> "\n"
File.write!(state.heartbeat_path, line, [:append])
Logger.info(
"Heartbeat: queue=#{queue_size} processed=#{tasks_processed} " <>
"total_cost=$#{ledger_stats["total_cost_estimated_usd"]}"
)
snapshot
end
defp process_queue(max_batch) do
tasks = Symbiont.Queue.take(max_batch)
Enum.each(tasks, fn task ->
Task.Supervisor.start_child(Symbiont.TaskSupervisor, fn ->
case Symbiont.Router.route_and_execute(task["task"]) do
{:ok, result} ->
text = result[:result] || ""
if blocked_result?(text) do
Logger.warning(
"Heartbeat: task #{task["id"]} returned but self-reported blocked — marking blocked"
)
Symbiont.Queue.block(task["id"], text)
notify_task_complete(task, :blocked, text)
else
Symbiont.Queue.complete(task["id"], text)
notify_task_complete(task, :done, text)
end
{:error, reason} ->
Symbiont.Queue.fail(task["id"], inspect(reason))
notify_task_complete(task, :failed, inspect(reason))
end
end)
end)
length(tasks)
end
# Heuristic: the LLM ran successfully but its text indicates an
# unresolved obstacle rather than completed work. Without this the
# queue marks every CLI success as `done`, which is what produced
# the false-done loop fixed in 767ab4d (permission errors recorded
# as task results). The patterns below are conservative — only
# explicit, opening-position self-reports of blockage qualify.
defp blocked_result?(text) when is_binary(text) do
head =
text
|> String.trim_leading()
|> String.slice(0, 400)
|> String.downcase()
patterns = [
"i need permission",
"i need write permission",
"i need read permission",
"i need access",
"i don't have permission",
"i do not have permission",
"i don't have access",
"i do not have access",
"could you approve",
"could you grant",
"please approve",
"please grant",
"would you approve",
"would you grant",
"i'm blocked",
"i am blocked",
"i'm unable to",
"i am unable to",
"i cannot proceed",
"i can't proceed",
"permission denied",
"write permission is blocked",
"the edit tool keeps getting blocked",
"blocked on writes",
"blocked on permission",
"blocked on write",
"blocked on read",
"blocked by the permission system",
"blocked by the sandbox"
]
Enum.any?(patterns, &String.contains?(head, &1))
end
defp blocked_result?(_), do: false
defp schedule_next(interval) do
Process.send_after(self(), :tick, interval)
end
defp notify_task_complete(task, status, result) do
task_id = task["id"] || "unknown"
task_desc = task["task"] || ""
priority = task["priority"] || "normal"
subject =
case status do
:done -> "Task done [#{priority}]: #{String.slice(task_desc, 0, 60)}"
:blocked -> "Task blocked [#{priority}]: #{String.slice(task_desc, 0, 60)}"
:failed -> "Task failed [#{priority}]: #{String.slice(task_desc, 0, 60)}"
end
snippet = String.slice(result || "", 0, 800)
body = """
Task #{task_id} completed with status: #{status}
Description:
#{task_desc}
Result:
#{snippet}
Muse
"""
payload =
Jason.encode!(%{
to: "mdwyer@michaelmdwyer.com",
subject: subject,
body: body,
from_name: "Muse"
})
url = ~c"http://localhost:8114/email"
try do
:httpc.request(
:post,
{url, [], ~c"application/json", payload},
[{:timeout, 10_000}],
[]
)
Logger.info("Task notification sent: #{task_id}#{status}")
rescue
e -> Logger.warning("Task notification failed for #{task_id}: #{Exception.message(e)}")
catch
_, reason -> Logger.warning("Task notification failed for #{task_id}: #{inspect(reason)}")
end
end
end