Skip to content
Merged
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
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<issue_identifier>`, 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:
Expand Down
7 changes: 7 additions & 0 deletions lib/symphony_elixir/config.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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])
Expand Down Expand Up @@ -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
Expand Down
19 changes: 15 additions & 4 deletions lib/symphony_elixir/orchestrator.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand Down
Loading