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.
This commit is contained in:
Amp YOLO 2026-05-16 23:08:46 +00:00
parent e70916102c
commit 6271e973a6
3 changed files with 91 additions and 2 deletions

View File

@ -113,7 +113,16 @@ defmodule Symbiont.Heartbeat do
Task.Supervisor.start_child(Symbiont.TaskSupervisor, fn -> Task.Supervisor.start_child(Symbiont.TaskSupervisor, fn ->
case Symbiont.Router.route_and_execute(task["task"]) do case Symbiont.Router.route_and_execute(task["task"]) do
{:ok, result} -> {:ok, result} ->
Symbiont.Queue.complete(task["id"], result[: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)
else
Symbiont.Queue.complete(task["id"], text)
end
{:error, reason} -> {:error, reason} ->
Symbiont.Queue.fail(task["id"], inspect(reason)) Symbiont.Queue.fail(task["id"], inspect(reason))
@ -124,6 +133,56 @@ defmodule Symbiont.Heartbeat do
length(tasks) length(tasks)
end 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 defp schedule_next(interval) do
Process.send_after(self(), :tick, interval) Process.send_after(self(), :tick, interval)
end end

View File

@ -2,7 +2,7 @@ defmodule Symbiont.Queue do
@moduledoc """ @moduledoc """
Persistent task queue backed by JSONL file. Persistent task queue backed by JSONL file.
Tasks flow through states: pending processing done | failed Tasks flow through states: pending processing done | failed | blocked
The queue is durable survives restarts via the JSONL file. The queue is durable survives restarts via the JSONL file.
""" """
use GenServer use GenServer
@ -37,6 +37,16 @@ defmodule Symbiont.Queue do
GenServer.cast(__MODULE__, {:fail, task_id, reason}) GenServer.cast(__MODULE__, {:fail, task_id, reason})
end 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)." @doc "Get the current queue size (pending tasks only)."
def size do def size do
GenServer.call(__MODULE__, :size) GenServer.call(__MODULE__, :size)
@ -126,6 +136,13 @@ defmodule Symbiont.Queue do
{:noreply, %{state | tasks: new_tasks}} {:noreply, %{state | tasks: new_tasks}}
end 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 -- # -- Private --
defp load_tasks(path) do defp load_tasks(path) do

View File

@ -74,6 +74,19 @@ defmodule Symbiont.QueueTest do
assert failed["result"] == "something broke" assert failed["result"] == "something broke"
end 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 test "list filters by status" do
{:ok, _} = Symbiont.Queue.enqueue("Pending 1") {:ok, _} = Symbiont.Queue.enqueue("Pending 1")
{:ok, _} = Symbiont.Queue.enqueue("To complete") {:ok, _} = Symbiont.Queue.enqueue("To complete")