symbiont_ex/lib/symbiont/queue.ex
Amp YOLO 6271e973a6 queue+heartbeat: distinguish blocked from done (fix the false-done bug)
Long-standing complement to the 767ab4d dispatcher fix. Even with
permissions unblocked, the model can still self-report obstacles
(refusals, missing services, runtime errors) by returning prose
like I need permission... or could you approve.... The
previous heartbeat marked any successful CLI call as done
regardless of what the text said, so blocked tasks vanished from
visibility and identical intents re-queued day after day.

Changes:

- Queue: new block/2 client function + handle_cast({:block, ...})
  mirroring the existing complete/fail pair. Adds a third terminal
  state blocked alongside done/failed. Persists the model output
  text as result so callers (sitrep, reflection) can see what the
  obstacle actually was.

- Heartbeat.process_queue: on dispatcher success, classify the
  result string with blocked_result?/1. The heuristic is
  conservative — only explicit opening-position self-reports in
  the first 400 chars (I need permission, I am unable to,
  permission denied, blocked on writes, etc.). Anything else
  defaults to done. Routes through Queue.block instead of
  Queue.complete when matched, with a warning log.

- Tests: added block marks a task as blocked test alongside
  existing fail test in queue_test.exs. 9/9 queue tests + 39/39
  full suite green.

Pending tasks counter (Queue.size/0) still counts only pending,
so blocked tasks do not re-trigger Heartbeat processing — they
sit terminal until something explicitly re-queues the intent.
This is the right behavior: avoid the loop, surface the obstacle.
2026-05-16 23:08:46 +00:00

195 lines
5.0 KiB
Elixir

defmodule Symbiont.Queue do
@moduledoc """
Persistent task queue backed by JSONL file.
Tasks flow through states: pending → processing → done | failed | blocked
The queue is durable — survives restarts via the JSONL file.
"""
use GenServer
require Logger
defstruct [:path, tasks: []]
# -- Client API --
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@doc "Add a task to the queue. Returns the task ID."
def enqueue(task_text, priority \\ "normal") do
GenServer.call(__MODULE__, {:enqueue, task_text, priority})
end
@doc "Take up to `n` pending tasks for processing."
def take(n \\ 1) do
GenServer.call(__MODULE__, {:take, n})
end
@doc "Mark a task as completed."
def complete(task_id, result \\ nil) do
GenServer.cast(__MODULE__, {:complete, task_id, result})
end
@doc "Mark a task as failed."
def fail(task_id, reason \\ nil) do
GenServer.cast(__MODULE__, {:fail, task_id, reason})
end
@doc """
Mark a task as blocked — it ran to completion but the model
self-reported an unresolved obstacle (e.g. permission, missing
service, refusal). Distinct from `done` so callers can surface
these for human attention without re-queuing the same intent.
"""
def block(task_id, result \\ nil) do
GenServer.cast(__MODULE__, {:block, task_id, result})
end
@doc "Get the current queue size (pending tasks only)."
def size do
GenServer.call(__MODULE__, :size)
end
@doc "List all tasks (optionally filtered by status)."
def list(status \\ nil) do
GenServer.call(__MODULE__, {:list, status})
end
# -- Server Callbacks --
@impl true
def init(opts) do
data_dir = Keyword.fetch!(opts, :data_dir)
path = Path.join(data_dir, "queue.jsonl")
unless File.exists?(path), do: File.write!(path, "")
tasks = load_tasks(path)
Logger.info("Queue loaded: #{length(tasks)} tasks (#{count_pending(tasks)} pending)")
{:ok, %__MODULE__{path: path, tasks: tasks}}
end
@impl true
def handle_call({:enqueue, task_text, priority}, _from, state) do
task = %{
"id" => generate_id(),
"task" => task_text,
"priority" => priority,
"status" => "pending",
"created_at" => DateTime.utc_now() |> DateTime.to_iso8601()
}
new_tasks = state.tasks ++ [task]
persist!(state.path, new_tasks)
{:reply, {:ok, task["id"]}, %{state | tasks: new_tasks}}
end
@impl true
def handle_call({:take, n}, _from, state) do
{to_process, rest} =
state.tasks
|> Enum.split_with(&(&1["status"] == "pending"))
taken = Enum.take(to_process, n)
remaining_pending = Enum.drop(to_process, n)
updated_taken =
Enum.map(taken, &Map.put(&1, "status", "processing"))
new_tasks = updated_taken ++ remaining_pending ++ rest
persist!(state.path, new_tasks)
{:reply, updated_taken, %{state | tasks: new_tasks}}
end
@impl true
def handle_call(:size, _from, state) do
{:reply, count_pending(state.tasks), state}
end
@impl true
def handle_call({:list, nil}, _from, state) do
{:reply, state.tasks, state}
end
@impl true
def handle_call({:list, status}, _from, state) do
filtered = Enum.filter(state.tasks, &(&1["status"] == to_string(status)))
{:reply, filtered, state}
end
@impl true
def handle_cast({:complete, task_id, result}, state) do
new_tasks = update_task_status(state.tasks, task_id, "done", result)
persist!(state.path, new_tasks)
{:noreply, %{state | tasks: new_tasks}}
end
@impl true
def handle_cast({:fail, task_id, reason}, state) do
new_tasks = update_task_status(state.tasks, task_id, "failed", reason)
persist!(state.path, new_tasks)
{:noreply, %{state | tasks: new_tasks}}
end
@impl true
def handle_cast({:block, task_id, result}, state) do
new_tasks = update_task_status(state.tasks, task_id, "blocked", result)
persist!(state.path, new_tasks)
{:noreply, %{state | tasks: new_tasks}}
end
# -- Private --
defp load_tasks(path) do
path
|> File.stream!()
|> Stream.reject(&(&1 in ["", "\n"]))
|> Enum.map(fn line ->
case Jason.decode(String.trim(line)) do
{:ok, task} -> task
{:error, _} -> nil
end
end)
|> Enum.reject(&is_nil/1)
end
defp persist!(path, tasks) do
content =
tasks
|> Enum.map(&(Jason.encode!(&1) <> "\n"))
|> Enum.join()
File.write!(path, content)
end
defp update_task_status(tasks, task_id, status, extra) do
Enum.map(tasks, fn task ->
if task["id"] == task_id do
task
|> Map.put("status", status)
|> Map.put("completed_at", DateTime.utc_now() |> DateTime.to_iso8601())
|> then(fn t ->
if extra, do: Map.put(t, "result", extra), else: t
end)
else
task
end
end)
end
defp count_pending(tasks) do
Enum.count(tasks, &(&1["status"] == "pending"))
end
defp generate_id do
ts = System.system_time(:second)
rand = :rand.uniform(9999)
"task-#{ts}-#{rand}"
end
end