diff --git a/README.md b/README.md index d8a075ca6..53f25c39d 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,46 @@ codex: - `server.port` or CLI `--port` enables the optional Phoenix LiveView dashboard and JSON API at `/`, `/api/v1/state`, `/api/v1/`, and `/api/v1/refresh`. +### Issue filtering + +Symphony can target issues by **project slug** or **team key**, and optionally filter by **label**. + +`tracker.team_key` is an alternative to `tracker.project_slug`. When both are set, `team_key` takes +priority. Use the team's short key from Linear (e.g. `RVR`, `ENG`). + +`tracker.labels` is a comma-separated list of Linear labels. When set, only issues carrying at least +one of the listed labels are dispatched. Label matching is case-insensitive. + +```yaml +tracker: + kind: linear + team_key: RVR + labels: "symphony" +``` + +### Dispatch states vs active states + +By default, `tracker.active_states` controls both which issues are eligible for new agent dispatch +and which states keep already-running agents alive. If you want agents to stay alive through later +pipeline states (like code review or staging) without picking up new issues in those states, use +`tracker.dispatch_states` to restrict where new agents are created. + +When `dispatch_states` is set, only issues in those states get new agents. `active_states` continues +to govern agent lifecycle — running agents won't be stopped when their issue moves to a state in +`active_states` but not in `dispatch_states`. + +When `dispatch_states` is not set, it defaults to `active_states` (the original behavior). + +```yaml +tracker: + dispatch_states: "Todo, In Progress" + active_states: "Todo, In Progress, Code Review, On Staging" +``` + +In this example, agents are only dispatched to issues in "Todo" or "In Progress". But if an agent is +already working on an issue and that issue moves to "Code Review" or "On Staging", the agent keeps +running. Once the issue leaves all active states (or hits a terminal state), the agent is stopped. + ## Web dashboard The observability UI runs on a minimal Phoenix stack: diff --git a/lib/symphony_elixir/config.ex b/lib/symphony_elixir/config.ex index 29a31c312..839903ec2 100644 --- a/lib/symphony_elixir/config.ex +++ b/lib/symphony_elixir/config.ex @@ -56,6 +56,7 @@ defmodule SymphonyElixir.Config do team_key: [type: {:or, [:string, nil]}, default: nil], assignee: [type: {:or, [:string, nil]}, default: nil], labels: [type: {:list, :string}, default: []], + dispatch_states: [type: {:list, :string}, default: []], active_states: [ type: {:list, :string}, default: @default_active_states @@ -221,6 +222,11 @@ defmodule SymphonyElixir.Config do get_in(validated_workflow_options(), [:tracker, :labels]) end + @spec linear_dispatch_states() :: [String.t()] + def linear_dispatch_states do + get_in(validated_workflow_options(), [:tracker, :dispatch_states]) + end + @spec linear_active_states() :: [String.t()] def linear_active_states do get_in(validated_workflow_options(), [:tracker, :active_states]) @@ -477,6 +483,7 @@ defmodule SymphonyElixir.Config do |> put_if_present(:project_slug, scalar_string_value(Map.get(section, "project_slug"))) |> put_if_present(:team_key, scalar_string_value(Map.get(section, "team_key"))) |> put_if_present(:labels, csv_value(Map.get(section, "labels"))) + |> put_if_present(:dispatch_states, csv_value(Map.get(section, "dispatch_states"))) |> put_if_present(:active_states, csv_value(Map.get(section, "active_states"))) |> put_if_present(:terminal_states, csv_value(Map.get(section, "terminal_states"))) end diff --git a/lib/symphony_elixir/orchestrator.ex b/lib/symphony_elixir/orchestrator.ex index eb123e8ec..cffd7520b 100644 --- a/lib/symphony_elixir/orchestrator.ex +++ b/lib/symphony_elixir/orchestrator.ex @@ -270,7 +270,7 @@ defmodule SymphonyElixir.Orchestrator do @doc false @spec should_dispatch_issue_for_test(Issue.t(), term()) :: boolean() def should_dispatch_issue_for_test(%Issue{} = issue, %State{} = state) do - should_dispatch_issue?(issue, state, active_state_set(), terminal_state_set(), label_filter_set()) + should_dispatch_issue?(issue, state, dispatch_state_set(), terminal_state_set(), label_filter_set()) end @doc false @@ -436,14 +436,14 @@ defmodule SymphonyElixir.Orchestrator do defp terminate_task(_pid), do: :ok defp choose_issues(issues, state) do - active_states = active_state_set() + dispatch_states = dispatch_state_set() terminal_states = terminal_state_set() label_filter = label_filter_set() issues |> sort_issues_for_dispatch() |> Enum.reduce(state, fn issue, state_acc -> - if should_dispatch_issue?(issue, state_acc, active_states, terminal_states, label_filter) do + if should_dispatch_issue?(issue, state_acc, dispatch_states, terminal_states, label_filter) do dispatch_issue(state_acc, issue) else state_acc @@ -595,6 +595,17 @@ defmodule SymphonyElixir.Orchestrator do |> MapSet.new() end + defp dispatch_state_set do + case Config.linear_dispatch_states() do + [] -> active_state_set() + states when is_list(states) -> + states + |> Enum.map(&normalize_issue_state/1) + |> Enum.filter(&(&1 != "")) + |> MapSet.new() + end + end + defp label_filter_set do Config.linear_labels() |> Enum.map(&normalize_issue_state/1) @@ -1133,7 +1144,7 @@ defmodule SymphonyElixir.Orchestrator do end defp retry_candidate_issue?(%Issue{} = issue, terminal_states) do - candidate_issue?(issue, active_state_set(), terminal_states) and + candidate_issue?(issue, dispatch_state_set(), terminal_states) and !todo_issue_blocked_by_non_terminal?(issue, terminal_states) end