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
543 changes: 334 additions & 209 deletions SPEC.md

Large diffs are not rendered by default.

71 changes: 62 additions & 9 deletions elixir/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,23 +13,25 @@ This directory contains the current Elixir/OTP implementation of Symphony, based

## How it works

1. Polls Linear for candidate work
1. Polls the configured tracker for candidate work (the included production adapter is Linear)
2. Creates a workspace per issue
3. Launches Codex in [App Server mode](https://developers.openai.com/codex/app-server/) inside the
workspace
4. Sends a workflow prompt to Codex
5. Keeps Codex working on the issue until the work is done

During app-server sessions, Symphony also serves a client-side `linear_graphql` tool so that repo
skills can make raw Linear GraphQL calls.
During app-server sessions, the selected tracker adapter may advertise provider-native tools. The
included Linear adapter serves `linear_graphql` so repo skills can make raw Linear GraphQL calls.
Symphony executes that tool with its configured auth and removes `LINEAR_API_KEY` from the Codex
child environment, so the agent does not need a second tracker login.

If a claimed issue moves to a terminal state (`Done`, `Closed`, `Cancelled`, or `Duplicate`),
Symphony stops the active agent for that issue and cleans up matching workspaces.

If Codex reports that operator input, approval, or MCP elicitation is required, Symphony keeps the
issue claimed and exposes it as blocked in the runtime state, JSON API, and dashboard. Blocked
entries are in memory only; restarting the orchestrator clears that blocked map, so any still-active
Linear issue can become a dispatch candidate again after restart.
tracker issue can become a dispatch candidate again after restart.

## How to use it

Expand Down Expand Up @@ -74,7 +76,7 @@ mise exec -- ./bin/symphony ./WORKFLOW.md

Symphony ships self-contained executables built with
[Burrito](https://github.com/burrito-elixir/burrito). They embed Erlang/OTP, Elixir, and Symphony,
but still expect `codex`, `git`, and Linear credentials on the target machine.
but still expect `codex`, `git`, and the selected tracker credentials on the target machine.

Supported release targets:

Expand Down Expand Up @@ -117,7 +119,8 @@ Minimal example:
---
tracker:
kind: linear
project_slug: "..."
provider:
project_slug: "..."
workspace:
root: ~/code/workspaces
hooks:
Expand All @@ -130,14 +133,17 @@ codex:
command: codex app-server
---

You are working on a Linear issue {{ issue.identifier }}.
You are working on an issue from the configured tracker {{ issue.identifier }}.

Title: {{ issue.title }} Body: {{ issue.description }}
```

Notes:

- If a value is missing, defaults are used.
- `tracker.kind` selects an adapter. Adapter-owned endpoint, scope, and auth settings belong under
`tracker.provider`; the current Linear adapter still accepts the older flat `endpoint`,
`api_key`, `project_slug`, and `assignee` aliases for compatibility.
- `tracker.required_labels` is optional. When set, an issue must have every
configured label to dispatch or continue running. Label matching ignores
case and surrounding whitespace. A blank configured label matches no issue.
Expand All @@ -161,15 +167,20 @@ Notes:
`git clone ... .` there, along with any other setup commands you need.
- If a hook needs `mise exec` inside a freshly cloned workspace, trust the repo config and fetch
the project dependencies in `hooks.after_create` before invoking `mise` later from other hooks.
- `tracker.api_key` reads from `LINEAR_API_KEY` when unset or when value is `$LINEAR_API_KEY`.
- For the Linear adapter, `tracker.provider.api_key` reads from `LINEAR_API_KEY` when unset or
when value is `$LINEAR_API_KEY`. The legacy flat `tracker.api_key` alias behaves the same way.
- Do not put a literal tracker token in a repo-owned `WORKFLOW.md` if Codex can read that
workspace. Use `$VAR`/host-side secret references so Symphony can keep the token out of the
child environment.
- For path values, `~` is expanded to the home directory.
- For env-backed path values, use `$VAR`. `workspace.root` resolves `$VAR` before path handling,
while `codex.command` stays a shell command string and any `$VAR` expansion there happens in the
launched shell.

```yaml
tracker:
api_key: $LINEAR_API_KEY
provider:
api_key: $LINEAR_API_KEY
workspace:
root: $SYMPHONY_WORKSPACE_ROOT
hooks:
Expand All @@ -185,6 +196,48 @@ 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`.

### Linear adapter profile

- Config: use `tracker.kind: linear` with `tracker.provider.endpoint` (default
`https://api.linear.app/graphql`), `api_key` (defaults to `LINEAR_API_KEY` and accepts
`$VAR`), required `project_slug`, and optional `assignee` (a Linear user ID or `me`,
defaulting to `LINEAR_ASSIGNEE`).
The legacy flat `tracker.endpoint`, `api_key`, `project_slug`, and `assignee` aliases remain
supported. `required_labels`, `active_states`, and `terminal_states` stay under `tracker`.
- Scope and paging: candidate reads filter the configured project slug and requested state names,
following Linear pages of 50. ID refreshes are also project-scoped and batch up to 50 IDs. Empty
state/ID lists return `{:ok, []}` without a Linear request.
- Identity and normalization: `issue.id` is the Linear issue ID and `issue.native_ref` is currently
`nil`. Records missing a nonblank ID, identifier, title, or state are dropped from candidate
pages and fail ID refreshes. State keeps Linear's spelling; integer priorities are preserved and
other priority values become `nil`; RFC 3339 timestamps are parsed and unusable timestamps become
`nil`. Labels are trimmed, lowercased, deduplicated, and blanks are dropped; blockers come from
inverse `blocks` relations.
- Dispatchability: the adapter marks an issue dispatchable only when optional assignee routing
matches and a `Todo` issue has no non-terminal blocker. The generic scheduler then applies
active/terminal states, required labels, claims, retries, and concurrency.
- Tool: the Linear adapter advertises `linear_graphql`, accepting either a raw query string or an
object with nonblank `query` and optional object `variables`. Symphony executes it host-side
with the session-bound endpoint/token and strips declared token environment variables from the
Codex child. `project_slug` scopes scheduler reads, not raw tool calls; the tool can access
whatever the configured Linear token can access.
- Responsibility and errors: `linear_graphql` adds no idempotency key, retry, scope guard, or
rate-limit policy, so workflows own idempotent mutations and handling provider errors. Read/config
failures use `{:error, :missing_linear_api_token}`, `{:error, :missing_linear_project_slug}`,
`{:error, :invalid_linear_endpoint}`, `{:error, :invalid_linear_assignee}`,
`{:error, :missing_linear_viewer_identity}`, `{:error, {:linear_api_status, status}}`,
`{:error, {:linear_api_request, reason}}`, `{:error, {:linear_graphql_errors, errors}}`,
`{:error, :linear_unknown_payload}`, or `{:error, :linear_missing_end_cursor}`. Tool results
are maps with `"success"`, JSON-string `"output"`, and text `"contentItems"`; invalid
arguments, missing auth, and transport failures return `"success" => false` with
`{"error": {"message": ...}}`, while top-level GraphQL errors preserve the response body with
`"success" => false`.
For portable reporting, map missing/invalid token, project, endpoint, assignee, or viewer errors
to `tracker_config` or `tracker_auth`, request failures to `tracker_transport`, non-200 responses to
`tracker_response` (`429` is `tracker_rate_limited`), GraphQL/unknown payload failures to
`tracker_payload`, and missing cursors to `tracker_pagination`; logs and tool responses carry the
human-readable provider detail.

## Web dashboard

The observability UI now runs on a minimal Phoenix stack:
Expand Down
15 changes: 8 additions & 7 deletions elixir/WORKFLOW.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
---
tracker:
kind: linear
project_slug: "symphony-0c79b11b75ea"
provider:
project_slug: "symphony-0c79b11b75ea"
required_labels: []
active_states:
- Todo
Expand Down Expand Up @@ -41,12 +42,12 @@ codex:
You are working on a Linear ticket `{{ issue.identifier }}`

{% if attempt %}
Continuation context:
Follow-up context:

- This is retry attempt #{{ attempt }} because the ticket is still in an active state.
- This is follow-up attempt #{{ attempt }}. It may be a normal continuation or a retry after a failure.
- Resume from the current workspace state instead of restarting from scratch.
- Do not repeat already-completed investigation or validation unless needed for new code changes.
- Do not end the turn while the issue remains in an active state unless you are blocked by missing required permissions/secrets.
- Do not end the turn while the work item remains in an active state unless you are blocked by missing required access.
{% endif %}

Issue context:
Expand All @@ -65,15 +66,15 @@ No description provided.

Instructions:

1. This is an unattended orchestration session. Never ask a human to perform follow-up actions.
2. Only stop early for a true blocker (missing required auth/permissions/secrets). If blocked, record it in the workpad and move the issue according to workflow.
1. This is an unattended orchestration session. Do not ask a human to perform follow-up actions.
2. Only stop early for a true external blocker (missing required tools, auth, permissions, or secrets). If blocked, record it in the workpad and move the issue according to the workflow.
3. Final message must report completed actions and blockers only. Do not include "next steps for user".

Work only in the provided repository copy. Do not touch any other path.

## Prerequisite: Linear MCP or `linear_graphql` tool is available

The agent should be able to talk to Linear, either via a configured Linear MCP server or injected `linear_graphql` tool. If none are present, stop and ask the user to configure Linear.
The agent should be able to talk to Linear, either via a configured Linear MCP server or injected `linear_graphql` tool. If neither is present, treat that as blocked access: record it in the workpad and move the issue according to the workflow instead of asking a user to configure Linear.

## Default posture

Expand Down
2 changes: 1 addition & 1 deletion elixir/lib/mix/tasks/workspace.before_remove.ex
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ defmodule Mix.Tasks.Workspace.BeforeRemove do
end

defp closing_comment(branch) do
"Closing because the Linear issue for branch #{branch} entered a terminal state without merge."
"Closing because the tracker issue for branch #{branch} entered a terminal state without merge."
end

defp format_output(""), do: ""
Expand Down
9 changes: 5 additions & 4 deletions elixir/lib/symphony_elixir/agent_runner.ex
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
defmodule SymphonyElixir.AgentRunner do
@moduledoc """
Executes a single Linear issue in its workspace with Codex.
Executes a single tracker work item in its workspace with Codex.
"""

require Logger
alias SymphonyElixir.Codex.AppServer
alias SymphonyElixir.{Config, Linear.Issue, PromptBuilder, Tracker, Workspace}
alias SymphonyElixir.{Config, PromptBuilder, Tracker, Workspace}
alias SymphonyElixir.Tracker.Issue

@type worker_host :: String.t() | nil

Expand Down Expand Up @@ -86,7 +87,7 @@ defmodule SymphonyElixir.AgentRunner do

defp run_codex_turns(workspace, issue, codex_update_recipient, opts, worker_host) do
max_turns = Keyword.get(opts, :max_turns, Config.settings!().agent.max_turns)
issue_state_fetcher = Keyword.get(opts, :issue_state_fetcher, &Tracker.fetch_issue_states_by_ids/1)
issue_state_fetcher = Keyword.get(opts, :issue_state_fetcher, &Tracker.fetch_issues_by_ids/1)

with {:ok, session} <- AppServer.start_session(workspace, worker_host: worker_host) do
try do
Expand Down Expand Up @@ -144,7 +145,7 @@ defmodule SymphonyElixir.AgentRunner do
"""
Continuation guidance:

- The previous Codex turn completed normally, but the Linear issue is still in an active state.
- The previous Codex turn completed normally, but the tracker work item is still in an active state.
- This is continuation turn ##{turn_number} of #{max_turns} for the current agent run.
- Resume from the current workspace and workpad state instead of restarting from scratch.
- The original task instructions and prior turn context are already present in this thread, so do not restate them before acting.
Expand Down
71 changes: 56 additions & 15 deletions elixir/lib/symphony_elixir/codex/app_server.ex
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ defmodule SymphonyElixir.Codex.AppServer do
turn_sandbox_policy: map(),
thread_id: String.t(),
workspace: Path.t(),
worker_host: String.t() | nil
worker_host: String.t() | nil,
dynamic_tool_binding: map()
}

@spec run(Path.t(), String.t(), map(), keyword()) :: {:ok, map()} | {:error, term()}
Expand All @@ -39,13 +40,15 @@ defmodule SymphonyElixir.Codex.AppServer do
@spec start_session(Path.t(), keyword()) :: {:ok, session()} | {:error, term()}
def start_session(workspace, opts \\ []) do
worker_host = Keyword.get(opts, :worker_host)
dynamic_tool_binding = DynamicTool.bind()

with {:ok, expanded_workspace} <- validate_workspace_cwd(workspace, worker_host),
{:ok, port} <- start_port(expanded_workspace, worker_host) do
{:ok, port} <- start_port(expanded_workspace, worker_host, dynamic_tool_binding) do
metadata = port_metadata(port, worker_host)

with {:ok, session_policies} <- session_policies(expanded_workspace, worker_host),
{:ok, thread_id} <- do_start_session(port, expanded_workspace, session_policies) do
{:ok, thread_id} <-
do_start_session(port, expanded_workspace, session_policies, dynamic_tool_binding) do
{:ok,
%{
port: port,
Expand All @@ -56,7 +59,8 @@ defmodule SymphonyElixir.Codex.AppServer do
turn_sandbox_policy: session_policies.turn_sandbox_policy,
thread_id: thread_id,
workspace: expanded_workspace,
worker_host: worker_host
worker_host: worker_host,
dynamic_tool_binding: dynamic_tool_binding
}}
else
{:error, reason} ->
Expand All @@ -75,7 +79,8 @@ defmodule SymphonyElixir.Codex.AppServer do
auto_approve_requests: auto_approve_requests,
turn_sandbox_policy: turn_sandbox_policy,
thread_id: thread_id,
workspace: workspace
workspace: workspace,
dynamic_tool_binding: dynamic_tool_binding
},
prompt,
issue,
Expand All @@ -85,7 +90,7 @@ defmodule SymphonyElixir.Codex.AppServer do

tool_executor =
Keyword.get(opts, :tool_executor, fn tool, arguments ->
DynamicTool.execute(tool, arguments)
DynamicTool.execute(tool, arguments, dynamic_tool_binding, issue: issue)
end)

case start_turn(port, thread_id, prompt, issue, workspace, approval_policy, turn_sandbox_policy) do
Expand Down Expand Up @@ -186,7 +191,7 @@ defmodule SymphonyElixir.Codex.AppServer do
end
end

defp start_port(workspace, nil) do
defp start_port(workspace, nil, dynamic_tool_binding) do
executable = System.find_executable("bash")

if is_nil(executable) do
Expand All @@ -199,8 +204,9 @@ defmodule SymphonyElixir.Codex.AppServer do
:binary,
:exit_status,
:stderr_to_stdout,
args: [~c"-lc", String.to_charlist(Config.settings!().codex.command)],
args: [~c"-lc", String.to_charlist(local_launch_command(dynamic_tool_binding))],
cd: String.to_charlist(workspace),
env: tracker_secret_port_env(dynamic_tool_binding),
line: @port_line_bytes
]
)
Expand All @@ -209,19 +215,49 @@ defmodule SymphonyElixir.Codex.AppServer do
end
end

defp start_port(workspace, worker_host) when is_binary(worker_host) do
remote_command = remote_launch_command(workspace)
defp start_port(workspace, worker_host, dynamic_tool_binding) when is_binary(worker_host) do
remote_command = remote_launch_command(workspace, dynamic_tool_binding)
SSH.start_port(worker_host, remote_command, line: @port_line_bytes)
end

defp remote_launch_command(workspace) when is_binary(workspace) do
defp local_launch_command(dynamic_tool_binding) do
[
tracker_secret_unset_command(dynamic_tool_binding),
"exec #{Config.settings!().codex.command}"
]
|> Enum.reject(&is_nil/1)
|> Enum.join(" && ")
end

defp remote_launch_command(workspace, dynamic_tool_binding) when is_binary(workspace) do
[
"cd #{shell_escape(workspace)}",
tracker_secret_unset_command(dynamic_tool_binding),
"exec #{Config.settings!().codex.command}"
]
|> Enum.reject(&is_nil/1)
|> Enum.join(" && ")
end

defp tracker_secret_port_env(dynamic_tool_binding) do
dynamic_tool_binding.secret_environment_names
|> valid_environment_names()
|> Enum.map(fn name -> {String.to_charlist(name), false} end)
end

defp tracker_secret_unset_command(dynamic_tool_binding) do
case dynamic_tool_binding.secret_environment_names |> valid_environment_names() do
[] -> nil
names -> "unset " <> Enum.join(names, " ")
end
end

defp valid_environment_names(names) do
Enum.filter(names, fn name ->
is_binary(name) and String.match?(name, ~r/^[A-Za-z_][A-Za-z0-9_]*$/)
end)
end

defp port_metadata(port, worker_host) when is_port(port) do
base_metadata =
case :erlang.port_info(port, :os_pid) do
Expand Down Expand Up @@ -270,22 +306,27 @@ defmodule SymphonyElixir.Codex.AppServer do
Config.codex_runtime_settings(workspace, remote: true)
end

defp do_start_session(port, workspace, session_policies) do
defp do_start_session(port, workspace, session_policies, dynamic_tool_binding) do
case send_initialize(port) do
:ok -> start_thread(port, workspace, session_policies)
:ok -> start_thread(port, workspace, session_policies, dynamic_tool_binding)
{:error, reason} -> {:error, reason}
end
end

defp start_thread(port, workspace, %{approval_policy: approval_policy, thread_sandbox: thread_sandbox}) do
defp start_thread(
port,
workspace,
%{approval_policy: approval_policy, thread_sandbox: thread_sandbox},
dynamic_tool_binding
) do
send_message(port, %{
"method" => "thread/start",
"id" => @thread_start_id,
"params" => %{
"approvalPolicy" => approval_policy,
"sandbox" => thread_sandbox,
"cwd" => workspace,
"dynamicTools" => DynamicTool.tool_specs()
"dynamicTools" => dynamic_tool_binding.tool_specs
}
})

Expand Down
Loading
Loading