symbiont_ex/test/symbiont/queue_test.exs
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

130 lines
3.6 KiB
Elixir

defmodule Symbiont.QueueTest do
use ExUnit.Case, async: false
import Symbiont.TestHelpers
@moduletag :capture_log
setup do
tmp_dir = Path.join(System.tmp_dir!(), "symbiont_queue_test_#{:rand.uniform(999_999)}")
File.mkdir_p!(tmp_dir)
safe_stop(Symbiont.Queue)
{:ok, _pid} = Symbiont.Queue.start_link(data_dir: tmp_dir)
on_exit(fn ->
safe_stop(Symbiont.Queue)
File.rm_rf!(tmp_dir)
end)
%{tmp_dir: tmp_dir}
end
test "starts with empty queue" do
assert Symbiont.Queue.size() == 0
assert Symbiont.Queue.list() == []
end
test "enqueue adds tasks and returns IDs" do
{:ok, id1} = Symbiont.Queue.enqueue("Task one")
{:ok, id2} = Symbiont.Queue.enqueue("Task two", "high")
assert is_binary(id1)
assert is_binary(id2)
assert id1 != id2
assert Symbiont.Queue.size() == 2
end
test "take returns pending tasks and marks them as processing" do
{:ok, _} = Symbiont.Queue.enqueue("Task A")
{:ok, _} = Symbiont.Queue.enqueue("Task B")
{:ok, _} = Symbiont.Queue.enqueue("Task C")
taken = Symbiont.Queue.take(2)
assert length(taken) == 2
assert Enum.all?(taken, &(&1["status"] == "processing"))
# Only 1 pending remains
assert Symbiont.Queue.size() == 1
end
test "complete marks a task as done" do
{:ok, id} = Symbiont.Queue.enqueue("Complete me")
[task] = Symbiont.Queue.take(1)
assert task["id"] == id
Symbiont.Queue.complete(id, "All done!")
Process.sleep(50)
tasks = Symbiont.Queue.list()
done = Enum.find(tasks, &(&1["id"] == id))
assert done["status"] == "done"
assert done["result"] == "All done!"
end
test "fail marks a task as failed" do
{:ok, id} = Symbiont.Queue.enqueue("Fail me")
_taken = Symbiont.Queue.take(1)
Symbiont.Queue.fail(id, "something broke")
Process.sleep(50)
tasks = Symbiont.Queue.list()
failed = Enum.find(tasks, &(&1["id"] == id))
assert failed["status"] == "failed"
assert failed["result"] == "something broke"
end
test "block marks a task as blocked and preserves the result text" do
{:ok, id} = Symbiont.Queue.enqueue("Block me")
_taken = Symbiont.Queue.take(1)
Symbiont.Queue.block(id, "I need permission to write /data/foo")
Process.sleep(50)
tasks = Symbiont.Queue.list()
blocked = Enum.find(tasks, &(&1["id"] == id))
assert blocked["status"] == "blocked"
assert blocked["result"] == "I need permission to write /data/foo"
end
test "list filters by status" do
{:ok, _} = Symbiont.Queue.enqueue("Pending 1")
{:ok, _} = Symbiont.Queue.enqueue("To complete")
# Take one task (first pending)
[taken] = Symbiont.Queue.take(1)
# Complete the taken task
Symbiont.Queue.complete(taken["id"], "done")
Process.sleep(50)
pending = Symbiont.Queue.list("pending")
assert length(pending) == 1
done = Symbiont.Queue.list("done")
assert length(done) == 1
end
test "queue persists to JSONL file", %{tmp_dir: tmp_dir} do
{:ok, _} = Symbiont.Queue.enqueue("Persistent task")
path = Path.join(tmp_dir, "queue.jsonl")
content = File.read!(path)
assert String.contains?(content, "Persistent task")
assert String.contains?(content, "pending")
end
test "queue loads tasks from file on restart", %{tmp_dir: tmp_dir} do
{:ok, _} = Symbiont.Queue.enqueue("Survivor task")
assert Symbiont.Queue.size() == 1
# Stop and restart
GenServer.stop(Symbiont.Queue)
{:ok, _} = Symbiont.Queue.start_link(data_dir: tmp_dir)
assert Symbiont.Queue.size() == 1
[task] = Symbiont.Queue.list("pending")
assert task["task"] == "Survivor task"
end
end