diff --git a/lib/symbiont/heartbeat.ex b/lib/symbiont/heartbeat.ex index e2be874..ed2eb66 100644 --- a/lib/symbiont/heartbeat.ex +++ b/lib/symbiont/heartbeat.ex @@ -113,7 +113,16 @@ defmodule Symbiont.Heartbeat do Task.Supervisor.start_child(Symbiont.TaskSupervisor, fn -> case Symbiont.Router.route_and_execute(task["task"]) do {: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} -> Symbiont.Queue.fail(task["id"], inspect(reason)) @@ -124,6 +133,56 @@ defmodule Symbiont.Heartbeat do 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 diff --git a/lib/symbiont/queue.ex b/lib/symbiont/queue.ex index 4067628..81e6eef 100644 --- a/lib/symbiont/queue.ex +++ b/lib/symbiont/queue.ex @@ -2,7 +2,7 @@ defmodule Symbiont.Queue do @moduledoc """ 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. """ use GenServer @@ -37,6 +37,16 @@ defmodule Symbiont.Queue 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) @@ -126,6 +136,13 @@ defmodule Symbiont.Queue do {: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 diff --git a/test/symbiont/queue_test.exs b/test/symbiont/queue_test.exs index 9a90f24..4355e93 100644 --- a/test/symbiont/queue_test.exs +++ b/test/symbiont/queue_test.exs @@ -74,6 +74,19 @@ defmodule Symbiont.QueueTest do 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")