Skip to content
Open
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
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,21 @@ All notable changes to this fork are documented in this file.
- New project management API endpoints under `/api/v1/projects/*`.
- New tracker adapters and routing for GitHub and GitLab.
- Multi-project orchestration runtime with per-project polling/backoff/build execution.
- Full-agent workspace cloning and prompt generation for GitHub/GitLab issue runs.
- Dashboard sections for discovered and monitored projects.
- Regression tests for project registry and project discovery.
- Regression tests for multi-project workspace and full-agent execution.

### Changed
- Updated root and Elixir README to describe fork-specific behavior and setup.
- Extended observability payloads to include optional project context fields.
- Fixed build command timeout handling to use task-based timeouts instead of invalid `System.cmd/3`
options.
- Added automatic trigger-label removal after successful or failed runs to avoid comment spam and
retry loops.

### Notes
- Legacy Linear single-workflow path remains available.
- `build_only` mode is fully wired.
- `full_agent` mode exists as configuration and currently follows the same build pipeline in this fork revision.
- `full_agent` mode now runs a real Codex workflow and posts the generated issue comment back to
the tracker.
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,11 @@ Implemented additions:
Notes:

- Legacy Linear-based single-workflow mode remains available.
- In the current fork state, `build_only` is fully wired. `full_agent` is present as a mode and
config path and currently executes the same build job pipeline.
- `build_only` runs configured build/test commands and posts the result back to the tracked issue.
- `full_agent` now launches a real Codex issue run in an isolated workspace clone and posts the
generated issue reply back to GitHub or GitLab.
- Trigger labels such as `todo` are removed automatically after a run finishes to avoid retry
loops and comment spam.

## Running Symphony

Expand Down
23 changes: 21 additions & 2 deletions elixir/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,10 +83,29 @@ mise exec -- ./bin/symphony ./WORKFLOW.md --port 4100 --i-understand-that-this-w
### Fork status and current limitations

- `build_only` mode is fully implemented (poll -> build commands -> post result comment).
- `full_agent` is available as a project mode/config value and currently runs through the same
build job path in this fork revision.
- `full_agent` mode is fully wired for GitHub and GitLab issue polling:
- Symphony clones the tracked repository into an isolated workspace
- launches Codex in that workspace
- instructs Codex to write the exact issue reply into `.symphony/issue_comment.md`
- then posts that markdown back to the source issue
- Trigger labels such as `todo` are removed automatically after each run to prevent repeated
re-processing of the same issue.
- The existing Linear single-workflow path remains available for legacy usage.

### Typical GitHub flow in this fork

1. Add a monitored project for your repository.
2. Set `mode` to `full_agent`.
3. Add a trigger label filter such as `todo`.
4. Open an issue and apply the `todo` label.
5. Symphony polls the issue, runs Codex in an isolated workspace clone, then posts the generated
markdown reply back to the issue.

For GitHub, Symphony can authenticate with either:

- `GITHUB_TOKEN` in the Symphony process environment
- or the local GitHub CLI session via `gh auth token`

## How to use it

1. Make sure your codebase is set up to work well with agents: see
Expand Down
75 changes: 75 additions & 0 deletions elixir/lib/symphony_elixir/multi_project_agent_runner.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
defmodule SymphonyElixir.MultiProjectAgentRunner do
@moduledoc """
Executes a multi-project full-agent run in an isolated workspace clone.
"""

require Logger

alias SymphonyElixir.Codex.AppServer
alias SymphonyElixir.{MultiProjectPromptBuilder, MultiProjectWorkspace, Project}
alias SymphonyElixir.Tracker.Issue

@spec run(Project.t(), Issue.t(), keyword()) ::
{:ok, %{summary: String.t(), comment_body: String.t(), workspace: String.t()}} | {:error, term()}
def run(%Project{} = project, %Issue{} = issue, opts \\ []) do
app_server_module = Keyword.get(opts, :app_server_module, AppServer)
app_server_opts = Keyword.get(opts, :app_server_opts, [])
workspace_opts = Keyword.get(opts, :workspace_opts, [])

with {:ok, workspace} <- MultiProjectWorkspace.create_for_issue(project, issue, workspace_opts),
prompt <- build_prompt(project, issue, workspace),
{:ok, _result} <-
app_server_module.run(
workspace.path,
prompt,
issue,
Keyword.merge(app_server_opts, issue_comment_path: workspace.comment_file)
),
{:ok, comment_body} <- read_issue_comment(workspace.comment_file) do
{:ok,
%{
summary: summarize_comment(comment_body),
comment_body: comment_body,
workspace: workspace.path
}}
else
{:error, reason} ->
Logger.error("full-agent run failed project_id=#{project.id} issue_id=#{issue.id} reason=#{inspect(reason)}")

{:error, reason}
end
end

@spec build_prompt(Project.t(), Issue.t(), MultiProjectWorkspace.workspace_info()) :: String.t()
defp build_prompt(%Project{} = project, %Issue{} = issue, workspace) do
MultiProjectPromptBuilder.build_prompt(project, issue, workspace.path, comment_file: workspace.comment_file)
end

@spec read_issue_comment(String.t()) :: {:ok, String.t()} | {:error, term()}
defp read_issue_comment(path) when is_binary(path) do
case File.read(path) do
{:ok, body} ->
trimmed = String.trim(body)

if trimmed == "" do
{:error, :missing_agent_issue_comment}
else
{:ok, trimmed}
end

{:error, reason} ->
{:error, {:missing_agent_issue_comment, reason}}
end
end

@spec summarize_comment(String.t()) :: String.t()
defp summarize_comment(comment_body) do
comment_body
|> String.split("\n", trim: true)
|> List.first()
|> case do
nil -> "agent completed"
line -> String.slice(line, 0, 120)
end
end
end
115 changes: 95 additions & 20 deletions elixir/lib/symphony_elixir/multi_project_orchestrator.ex
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ defmodule SymphonyElixir.MultiProjectOrchestrator do
use GenServer
require Logger

alias SymphonyElixir.{Config, Project, ProjectRegistry, Tracker}
alias SymphonyElixir.{Config, MultiProjectAgentRunner, Project, ProjectRegistry, Tracker}
alias SymphonyElixir.Tracker.Issue

@default_retry_after_ms 60_000
Expand Down Expand Up @@ -275,13 +275,28 @@ defmodule SymphonyElixir.MultiProjectOrchestrator do
end

@spec run_issue_job(Project.t(), Issue.t()) :: {:ok, String.t()} | {:error, term()}
defp run_issue_job(%Project{mode: "full_agent"} = project, %Issue{} = issue) do
with {:ok, agent_result} <- MultiProjectAgentRunner.run(project, issue),
:ok <- post_full_agent_comment(project, issue, agent_result.comment_body) do
maybe_clear_trigger_labels(project, issue)
{:ok, agent_result.summary}
else
{:error, reason} ->
_ = post_failure_comment(project, issue, reason)
maybe_clear_trigger_labels(project, issue)
{:error, reason}
end
end

defp run_issue_job(%Project{} = project, %Issue{} = issue) do
with {:ok, build_result} <- run_build(project),
:ok <- post_result_comment(project, issue, build_result) do
maybe_clear_trigger_labels(project, issue)
{:ok, build_result.summary}
else
{:error, reason} ->
_ = post_failure_comment(project, issue, reason)
maybe_clear_trigger_labels(project, issue)
{:error, reason}
end
end
Expand All @@ -293,6 +308,21 @@ defmodule SymphonyElixir.MultiProjectOrchestrator do
build_result(commands, build_working_dir(project), timeout_ms)
end

@spec run_command_for_test(String.t(), String.t(), non_neg_integer()) :: {:ok, String.t()} | {:error, term()}
def run_command_for_test(command, working_dir, timeout_ms) do
run_command(command, working_dir, timeout_ms)
end

@spec issue_signature_for_test(Issue.t()) :: String.t()
def issue_signature_for_test(%Issue{} = issue) do
issue_signature(issue)
end

@spec track_issues_for_test(map(), [Issue.t()]) :: map()
def track_issues_for_test(existing, issues) when is_map(existing) and is_list(issues) do
track_issues(existing, issues)
end

defp build_result([], _working_dir, _timeout_ms) do
{:ok, %{summary: "no build commands configured", output: ""}}
end
Expand Down Expand Up @@ -325,20 +355,29 @@ defmodule SymphonyElixir.MultiProjectOrchestrator do

@spec run_command(String.t(), String.t(), non_neg_integer()) :: {:ok, String.t()} | {:error, term()}
defp run_command(command, working_dir, timeout_ms) do
{output, status} =
System.cmd("bash", ["-lc", command],
cd: working_dir,
stderr_to_stdout: true,
timeout: timeout_ms
)

if status == 0 do
{:ok, output}
else
{:error, {:exit_status, status, truncate_output(output)}}
task =
Task.async(fn ->
try do
case System.cmd("bash", ["-lc", command], cd: working_dir, stderr_to_stdout: true) do
{output, 0} ->
{:ok, output}

{output, status} ->
{:error, {:exit_status, status, truncate_output(output)}}
end
rescue
error -> {:error, {:command_error, error}}
end
end)

case Task.yield(task, timeout_ms) do
{:ok, result} ->
result

nil ->
_ = Task.shutdown(task, :brutal_kill)
{:error, {:command_timeout, timeout_ms}}
end
rescue
error -> {:error, {:command_error, error}}
end

@spec post_result_comment(
Expand Down Expand Up @@ -381,6 +420,14 @@ defmodule SymphonyElixir.MultiProjectOrchestrator do
Tracker.Router.create_comment(project, issue.id, body)
end

@spec post_full_agent_comment(Project.t(), Issue.t(), String.t()) :: :ok | {:error, term()}
defp post_full_agent_comment(project, issue, body)
when is_binary(body) do
Logger.info("posting full-agent issue comment project_id=#{project.id} issue_id=#{issue.id} bytes=#{byte_size(body)}")

Tracker.Router.create_comment(project, issue.id, body)
end

@spec filter_candidate_issues([Issue.t()], Project.t()) :: [Issue.t()]
defp filter_candidate_issues(issues, %Project{} = project) do
active_states = project |> Project.active_states() |> Enum.map(&normalize_state/1) |> MapSet.new()
Expand Down Expand Up @@ -411,17 +458,45 @@ defmodule SymphonyElixir.MultiProjectOrchestrator do
end)
end

@spec maybe_clear_trigger_labels(Project.t(), Issue.t()) :: :ok
defp maybe_clear_trigger_labels(%Project{} = project, %Issue{} = issue) do
include_labels = MapSet.new(Project.labels_include(project))

if MapSet.size(include_labels) == 0 do
:ok
else
remaining_labels =
issue.labels
|> Enum.reject(&MapSet.member?(include_labels, String.downcase(&1)))

case Tracker.Router.replace_labels(project, issue.id, remaining_labels) do
:ok ->
:ok

{:error, reason} ->
Logger.warning("failed to clear trigger labels project_id=#{project.id} issue_id=#{issue.id} reason=#{inspect(reason)}")

:ok
end
end
end

@spec track_issues(map(), [Issue.t()]) :: map()
defp track_issues(existing, issues) when is_map(existing) do
Enum.reduce(issues, existing, fn issue, acc ->
Map.put(acc, issue.id, issue_signature(issue))
end)
defp track_issues(_existing, issues) do
Map.new(issues, fn issue -> {issue.id, issue_signature(issue)} end)
end

@spec issue_signature(Issue.t()) :: String.t()
defp issue_signature(%Issue{} = issue) do
updated = iso8601(issue.updated_at) || ""
updated <> "|" <> normalize_state(issue.state)
[
normalize_state(issue.state),
Atom.to_string(issue.source || :issue),
issue.title || "",
issue.description || "",
issue.branch_name || "",
issue.labels |> Enum.map(&String.downcase/1) |> Enum.sort() |> Enum.join(",")
]
|> Enum.join("|")
end

@spec put_project_state(State.t(), String.t(), map()) :: State.t()
Expand Down
Loading