From 79977e3c6503943663909a9e9b7f2a8a8cce7eb3 Mon Sep 17 00:00:00 2001 From: "E.FU" Date: Fri, 5 Jun 2026 10:48:42 +0800 Subject: [PATCH] =?UTF-8?q?harness:=20configurable=20default=20dispatch=20?= =?UTF-8?q?agent=20=E2=80=94=20unassigned=20tasks=20route=20to=20codex,=20?= =?UTF-8?q?not=20claude=20(Task=20207)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/harness/capability_score.ex | 8 ++- lib/harness/config.ex | 12 ++++ lib/harness/config/entry.ex | 2 +- lib/harness/dashboard/settings_live.ex | 69 ++++++++++++++++++- test/harness/capability_score_test.exs | 17 +++++ test/harness/config_test.exs | 29 +++++++- test/harness/dashboard/settings_live_test.exs | 38 ++++++++++ 7 files changed, 170 insertions(+), 5 deletions(-) diff --git a/lib/harness/capability_score.ex b/lib/harness/capability_score.ex index 9c65e284..c6566470 100644 --- a/lib/harness/capability_score.ex +++ b/lib/harness/capability_score.ex @@ -35,6 +35,7 @@ defmodule Harness.CapabilityScore do alias Harness.Batch.AgentEvaluation.Comparison alias Harness.Batch.AgentEvaluation.Entry alias Harness.CapabilityDomain + alias Harness.Config alias Harness.ResultStore alias Harness.Run.Review alias Harness.TokenUsage @@ -48,7 +49,6 @@ defmodule Harness.CapabilityScore do @freshness_window_days 30 @seconds_per_day 86_400 @stale_discount 0.5 - @fallback_agent :claude @type freshness :: :fresh | :stale @@ -324,9 +324,13 @@ defmodule Harness.CapabilityScore do end end + # No measured scores for this domain: route to the operator-configured default + # dispatch agent (`{:dispatch, :default_agent}`, default :codex — keeping + # precious Claude tokens for the reviewer axis), not a hardcoded :claude. An + # explicit `:fallback_agent` opt still wins (tests, deliberate overrides). @spec fallback_recommendation(CapabilityDomain.t(), [map()], keyword()) :: map() defp fallback_recommendation(domain, rows, opts) do - fallback_agent = Keyword.get(opts, :fallback_agent, @fallback_agent) + fallback_agent = Keyword.get(opts, :fallback_agent, Config.get({:dispatch, :default_agent})) selected = Enum.find(rows, &(&1.agent == fallback_agent)) || hd(rows) %{ diff --git a/lib/harness/config.ex b/lib/harness/config.ex index e238c554..829a7989 100644 --- a/lib/harness/config.ex +++ b/lib/harness/config.ex @@ -47,6 +47,12 @@ defmodule Harness.Config do @filename "config_settings.term" @store_key :config + # The implementer agents an unassigned task may default-route to — the closed + # set the `:agent`-typed `{:dispatch, :default_agent}` key validates against and + # the dashboard select renders. Mirrors `Harness.Roadmap`'s `@valid_agents` + # (minus `:human`, which is never an autonomous dispatch target). + @implementer_agents [:claude, :codex, :cursor, :grok, :antigravity, :pi] + @doc """ The full declarative config schema — one `Entry` per operator-relevant key. @@ -79,6 +85,7 @@ defmodule Harness.Config do ), e("Cron polling", "enabled", {:cron_polling, :enabled}, false, :boolean), e("Cron polling", "schedule", {:cron_polling, :schedule}, "0 */2 * * *", :string, restart_required?: true), + e("Dispatch", "default_agent", {:dispatch, :default_agent}, :codex, :agent, ui_editable?: true), e("Notifications", "sinks", :notification_sinks, [], :atom_list), e("Paths", "chat_store root", {:chat_store, :root}, Path.expand("~/.harness/chats"), :path), e("Paths", "project cache_root", {:project, :cache_root}, Path.expand("~/_DATA/harness/projects"), :path), @@ -101,6 +108,10 @@ defmodule Harness.Config do @spec editable_entries() :: [Entry.t()] def editable_entries, do: Enum.filter(schema(), & &1.ui_editable?) + @doc "The implementer agents an unassigned task may default-route to — the dashboard select's option source and the `:agent`-type validation set." + @spec dispatch_agents() :: [atom()] + def dispatch_agents, do: @implementer_agents + @doc """ Resolves a schema key's effective value from app env, falling back to the schema default. Raises on an unknown key (a typo is a programming error, not a @@ -176,6 +187,7 @@ defmodule Harness.Config do @spec validate(Entry.t(), term()) :: :ok | {:error, :invalid_value} defp validate(%Entry{type: :duration_ms}, value) when is_nil(value) or (is_integer(value) and value >= 0), do: :ok defp validate(%Entry{type: :integer}, value) when is_integer(value) and value > 0, do: :ok + defp validate(%Entry{type: :agent}, value) when value in @implementer_agents, do: :ok defp validate(_entry, _value), do: {:error, :invalid_value} @spec read_env(Entry.key(), term()) :: term() diff --git a/lib/harness/config/entry.ex b/lib/harness/config/entry.ex index 9d3d7c80..2889a1c5 100644 --- a/lib/harness/config/entry.ex +++ b/lib/harness/config/entry.ex @@ -24,7 +24,7 @@ defmodule Harness.Config.Entry do @type key :: {atom() | module(), atom()} | atom() @typedoc "The value's kind — drives inspector formatting and editable-input parsing/validation." - @type value_type :: :duration_ms | :integer | :boolean | :string | :path | :float | :atom_list + @type value_type :: :duration_ms | :integer | :boolean | :string | :path | :float | :atom_list | :agent @type t :: %__MODULE__{ section: String.t(), diff --git a/lib/harness/dashboard/settings_live.ex b/lib/harness/dashboard/settings_live.ex index 4e6cdbb7..a9e67747 100644 --- a/lib/harness/dashboard/settings_live.ex +++ b/lib/harness/dashboard/settings_live.ex @@ -135,6 +135,16 @@ defmodule Harness.Dashboard.SettingsLive do {:noreply, socket |> assign(:notice, notice) |> refresh()} end + def handle_event("set_default_agent", %{"agent" => raw}, socket) do + notice = + case dispatch_agent_atom(raw) do + {:ok, agent} -> persist_default_agent(agent) + :error -> {:error, "Unknown dispatch agent."} + end + + {:noreply, socket |> assign(:notice, notice) |> refresh()} + end + def handle_event("dispatch_now", _params, socket) do {:noreply, assign(socket, :notice, dispatch_now())} end @@ -340,6 +350,32 @@ defmodule Harness.Dashboard.SettingsLive do +
+

Dispatch default

+

+ The implementer agent an unassigned task routes to when capability + scores have no data yet (the recommend no-data fallback). Defaults to a + cheap agent so unassigned work doesn't silently spend Claude tokens — Claude stays + available on the separate reviewer axis above. +

+
+
+ Unassigned → implementer + {@dispatch.current} +
+ + +
+
+ @@ -383,6 +419,15 @@ defmodule Harness.Dashboard.SettingsLive do |> assign(:reviewers, reviewer_state(projects)) |> assign(:config, ConfigInspector.resolve()) |> assign(:config_edit, config_edit_state()) + |> assign(:dispatch, dispatch_state()) + end + + # The dispatch-default view-model: the configured no-data fallback agent plus + # the closed option set the `