From 6271e973a6734c480649ee072eb95b9de50ac050 Mon Sep 17 00:00:00 2001 From: Amp YOLO Date: Sat, 16 May 2026 23:08:46 +0000 Subject: [PATCH] queue+heartbeat: distinguish blocked from done (fix the false-done bug) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- lib/symbiont/heartbeat.ex | 61 +++++++++++++++++++++++++++++++++++- lib/symbiont/queue.ex | 19 ++++++++++- test/symbiont/queue_test.exs | 13 ++++++++ 3 files changed, 91 insertions(+), 2 deletions(-) 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")