Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions lib/harness/capability_score.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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}))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Wire dispatch default into cron poller

This only updates the no-data CapabilityScore.recommend fallback; autonomous roadmap polling does not go through this path. I checked lib/harness/cron/roadmap_poller.ex, where task_agent/1 still maps a missing assignee to the module attribute @default_agent :claude, so an rmap ready task with assignee: nil will still enqueue Claude regardless of the new setting/UI. Please route that missing-assignee branch through Config.get({:dispatch, :default_agent}) too, or share one helper, so the setting actually protects cron-dispatched unassigned work.

Useful? React with 👍 / 👎.

selected = Enum.find(rows, &(&1.agent == fallback_agent)) || hd(rows)

%{
Expand Down
12 changes: 12 additions & 0 deletions lib/harness/config.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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),
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion lib/harness/config/entry.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
69 changes: 68 additions & 1 deletion lib/harness/dashboard/settings_live.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -340,6 +350,32 @@ defmodule Harness.Dashboard.SettingsLive do
</ul>
</section>

<section class="setting-card">
<h2 class="setting-section-title">Dispatch default</h2>
<p class="setting-desc">
The implementer agent an <strong>unassigned</strong> task routes to when capability
scores have no data yet (the <code>recommend</code> no-data fallback). Defaults to a
cheap agent so unassigned work doesn't silently spend Claude tokens — Claude stays
available on the separate <strong>reviewer</strong> axis above.
</p>
<form id="dispatch-default-form" class="reviewer-form" phx-submit="set_default_agent">
<div class="project-id">
<span class="project-name">Unassigned → implementer</span>
<span class="pill" data-state="on">{@dispatch.current}</span>
</div>
<select name="agent" aria-label="Default dispatch agent">
<option
:for={agent <- @dispatch.agents}
value={to_string(agent)}
selected={agent == @dispatch.current}
>
{agent}
</option>
</select>
<button type="submit" class="btn-save">Save</button>
</form>
</section>

<Components.config_form entries={@config_edit} />

<Components.config_inspector sections={@config} />
Expand Down Expand Up @@ -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 `<select>` renders. `:agent`-typed config is steered
# by this dedicated card, so it never reaches the number-input `config_form`.
@spec dispatch_state() :: %{current: atom(), agents: [atom()]}
defp dispatch_state do
%{current: Config.get({:dispatch, :default_agent}), agents: Config.dispatch_agents()}
end

# The editable-config view-model: the `ui_editable?` schema subset, each row
Expand All @@ -391,7 +436,9 @@ defmodule Harness.Dashboard.SettingsLive do
# `String.to_atom` on request input.
@spec config_edit_state() :: [map()]
defp config_edit_state do
Enum.map(Config.editable_entries(), fn entry ->
Config.editable_entries()
|> Enum.reject(&(&1.type == :agent))
|> Enum.map(fn entry ->
%{
id: config_id(entry.key),
label: entry.label,
Expand Down Expand Up @@ -561,6 +608,26 @@ defmodule Harness.Dashboard.SettingsLive do
defp reviewer_atom(""), do: {:ok, nil}
defp reviewer_atom(name), do: agent_atom(name)

# Maps the dispatch-default select's string value to an agent atom against the
# closed `Config.dispatch_agents/0` set (never String.to_atom on request input).
# Unlike `agent_atom/1`, the source is the validation set, not the installed
# registry — the default may name an adapter not currently registered.
@spec dispatch_agent_atom(String.t()) :: {:ok, atom()} | :error
defp dispatch_agent_atom(name) do
case Enum.find(Config.dispatch_agents(), &(Atom.to_string(&1) == name)) do
nil -> :error
agent -> {:ok, agent}
end
end

@spec persist_default_agent(atom()) :: {:ok | :error, String.t()}
defp persist_default_agent(agent) do
case Config.put({:dispatch, :default_agent}, agent, "dashboard") do
:ok -> {:ok, "Default dispatch agent set to #{agent}."}
{:error, _reason} -> {:error, "Could not save default dispatch agent."}
end
end

@spec reviewer_options() :: [map()]
defp reviewer_options do
Enum.map(AgentRegistry.agents(), fn {agent, _module} ->
Expand Down
17 changes: 17 additions & 0 deletions test/harness/capability_score_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ defmodule Harness.CapabilityScoreTest do
alias Harness.Batch.AgentEvaluation.Comparison
alias Harness.Batch.AgentEvaluation.Entry
alias Harness.CapabilityScore
alias Harness.Config
alias Harness.ResultStore
alias Harness.ResultStore.File, as: FileStore
alias Harness.Run.Result, as: RunResult
Expand Down Expand Up @@ -272,6 +273,22 @@ defmodule Harness.CapabilityScoreTest do
assert recommendation.strategy == :fallback_no_data
assert Enum.all?(recommendation.ranked, &(&1.measurement == :unmeasured))
end

test "with no :fallback_agent opt, falls back to the configured default dispatch agent (not :claude)",
%{store: store} do
configured = Config.get({:dispatch, :default_agent})

assert {:ok, recommendation} =
CapabilityScore.recommend(:otp,
agents: [:claude, :codex, :cursor],
reference_time: @reference_time,
result_store: store
)

assert recommendation.strategy == :fallback_no_data
assert recommendation.agent == configured
assert configured == :codex, "schema default keeps Claude tokens for the reviewer axis"
end
end

defp comparison(task_id, entries) do
Expand Down
29 changes: 28 additions & 1 deletion test/harness/config_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ defmodule Harness.ConfigTest do
setup do
prior_run = Application.get_env(:harness, :run)
prior_dashboard = Application.get_env(:harness, :dashboard)
prior_dispatch = Application.get_env(:harness, :dispatch)
prior_store = Application.get_env(:harness, :config_settings)

# Isolate the persistence file to a throwaway root.
Expand All @@ -26,6 +27,7 @@ defmodule Harness.ConfigTest do
on_exit(fn ->
restore(:run, prior_run)
restore(:dashboard, prior_dashboard)
restore(:dispatch, prior_dispatch)
restore(:config_settings, prior_store)
File.rm_rf(root)
end)
Expand All @@ -35,7 +37,7 @@ defmodule Harness.ConfigTest do

describe "schema/0" do
test "every entry is a well-formed Entry with a known type" do
types = ~w(duration_ms integer boolean string path float atom_list)a
types = ~w(duration_ms integer boolean string path float atom_list agent)a

for %Entry{} = entry <- Config.schema() do
assert is_binary(entry.section)
Expand Down Expand Up @@ -116,6 +118,31 @@ defmodule Harness.ConfigTest do
# The live value is unchanged — applies only on the next boot via load_into_env/0.
assert Config.get({:dashboard, :port}) == 4018
end

test "accepts an :agent-typed value in the implementer set and hot-applies it" do
assert :ok = Config.put({:dispatch, :default_agent}, :cursor, "test")
assert Config.get({:dispatch, :default_agent}) == :cursor
end

test "rejects an agent outside the implementer set without mutating" do
Application.put_env(:harness, :dispatch, default_agent: :codex)
assert {:error, :invalid_value} = Config.put({:dispatch, :default_agent}, :droid, "test")
assert {:error, :invalid_value} = Config.put({:dispatch, :default_agent}, :human, "test")
assert Config.get({:dispatch, :default_agent}) == :codex
end
end

describe "dispatch default agent" do
test "schema default is :codex — unassigned work avoids spending Claude tokens" do
assert Config.get({:dispatch, :default_agent}) == :codex
end

test "dispatch_agents/0 is the implementer set, excluding :human" do
agents = Config.dispatch_agents()
assert :claude in agents and :codex in agents and :cursor in agents
refute :human in agents
refute :droid in agents
end
end

describe "load_into_env/0" do
Expand Down
38 changes: 38 additions & 0 deletions test/harness/dashboard/settings_live_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ defmodule Harness.Dashboard.SettingsLiveTest do
prior_reviewer = Application.get_env(:harness, :agent_reviewer_ineligible)
prior_landing = Application.get_env(:harness, :landing_overrides)
prior_run = Application.get_env(:harness, :run)
prior_dispatch = Application.get_env(:harness, :dispatch)
prior_config_store = Application.get_env(:harness, :config_settings)

# Isolate the Harness.Config persistence file to a throwaway root.
Expand All @@ -52,6 +53,7 @@ defmodule Harness.Dashboard.SettingsLiveTest do
restore_env(:agent_reviewer_ineligible, prior_reviewer)
restore_env(:landing_overrides, prior_landing)
restore_env(:run, prior_run)
restore_env(:dispatch, prior_dispatch)
restore_env(:config_settings, prior_config_store)
File.rm_rf(config_root)
end)
Expand Down Expand Up @@ -258,6 +260,42 @@ defmodule Harness.Dashboard.SettingsLiveTest do
assert Config.get({:run, :lifetime_timeout}) == 5_400_000
end

test "renders the Dispatch default card seeded with the configured agent (Task 207)", %{conn: conn} do
Application.put_env(:harness, :dispatch, default_agent: :codex)

{:ok, _view, html} = live(conn, "/harness/settings")

assert html =~ "Dispatch default"
assert html =~ "Unassigned →"
end

test "choosing a dispatch default persists through Harness.Config and confirms (Task 207)", %{conn: conn} do
Application.put_env(:harness, :dispatch, default_agent: :codex)

{:ok, view, _html} = live(conn, "/harness/settings")

html =
view
|> form("#dispatch-default-form", %{agent: "cursor"})
|> render_submit()

assert html =~ "Default dispatch agent set to cursor."
assert Config.get({:dispatch, :default_agent}) == :cursor
end

test "an unknown dispatch agent is rejected with an error notice (Task 207)", %{conn: conn} do
Application.put_env(:harness, :dispatch, default_agent: :codex)

{:ok, view, _html} = live(conn, "/harness/settings")

# The select only offers valid agents, so push the event directly to exercise
# the handler's rejection of a crafted out-of-set value.
html = render_hook(view, "set_default_agent", %{"agent" => "droid"})

assert html =~ "Unknown dispatch agent."
assert Config.get({:dispatch, :default_agent}) == :codex
end

test "renders the Landing card with a per-project policy control", %{conn: conn, project: project} do
{:ok, _view, html} = live(conn, "/harness/settings")

Expand Down