Skip to content

Commit 10b3980

Browse files
feat(elixir): add ssh workers and live worker backends
Summary: - add SSH worker execution support, host selection, and remote workspace/app-server wiring for Symphony Elixir - add Docker-backed SSH workers plus explicit local and SSH live E2E coverage in the Elixir test suite - simplify workflow worker config and document the new E2E behavior Rationale: - let Symphony run Codex work on remote SSH hosts without hardcoding host-specific config into the repo - make the live test exercise both the baseline local path and the SSH worker path with disposable infrastructure by default - keep the test and config surface smaller by removing unnecessary normalization and Docker-specific overrides Tests: - make all - env -u SYMPHONY_LIVE_SSH_WORKER_HOSTS LINEAR_API_KEY="$(tr -d '\r\n' < ~/.linear_api_key)" SYMPHONY_RUN_LIVE_E2E=1 mix test test/symphony_elixir/live_e2e_test.exs Co-authored-by: Codex <codex@openai.com>
1 parent b1863e8 commit 10b3980

22 files changed

+1649
-317
lines changed

elixir/Makefile

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -34,16 +34,6 @@ dialyzer:
3434
$(MIX) dialyzer --format short
3535

3636
e2e:
37-
@if [ -z "$$LINEAR_API_KEY" ]; then \
38-
echo "LINEAR_API_KEY is required for \`make e2e\`."; \
39-
echo "Export it first, for example:"; \
40-
echo " export LINEAR_API_KEY=\$$(tr -d '\\r\\n' < ~/.linear_api_key)"; \
41-
exit 1; \
42-
fi
43-
@if ! command -v codex >/dev/null 2>&1; then \
44-
echo "\`codex\` must be on PATH for \`make e2e\`."; \
45-
exit 1; \
46-
fi
4737
SYMPHONY_RUN_LIVE_E2E=1 $(MIX) test test/symphony_elixir/live_e2e_test.exs
4838

4939
ci:

elixir/README.md

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -185,12 +185,23 @@ make e2e
185185
Optional environment variables:
186186

187187
- `SYMPHONY_LIVE_LINEAR_TEAM_KEY` defaults to `SYME2E`
188-
- `SYMPHONY_LIVE_CODEX_COMMAND` defaults to `codex app-server`
188+
- `SYMPHONY_LIVE_SSH_WORKER_HOSTS` uses those SSH hosts when set, as a comma-separated list
189189

190-
The live test creates a temporary Linear project and issue, writes a temporary `WORKFLOW.md`,
191-
runs a real agent turn, verifies the workspace side effect, requires Codex to comment on and close
192-
the Linear issue, then marks the project completed so the run remains visible in Linear.
193-
`make e2e` fails fast with a clear error if `LINEAR_API_KEY` is unset.
190+
`make e2e` runs two live scenarios:
191+
- one with a local worker
192+
- one with SSH workers
193+
194+
If `SYMPHONY_LIVE_SSH_WORKER_HOSTS` is unset, the SSH scenario uses `docker compose` to start two
195+
disposable SSH workers on `localhost:<port>`. The live test generates a temporary SSH keypair,
196+
mounts the host `~/.codex/auth.json` into each worker, verifies that Symphony can talk to them
197+
over real SSH, then runs the same orchestration flow against those worker addresses. This keeps
198+
the transport representative without depending on long-lived external machines.
199+
200+
Set `SYMPHONY_LIVE_SSH_WORKER_HOSTS` if you want `make e2e` to target real SSH hosts instead.
201+
202+
The live test creates a temporary Linear project and issue, writes a temporary `WORKFLOW.md`, runs
203+
a real agent turn, verifies the workspace side effect, requires Codex to comment on and close the
204+
Linear issue, then marks the project completed so the run remains visible in Linear.
194205

195206
## FAQ
196207

elixir/lib/symphony_elixir/agent_runner.ex

Lines changed: 88 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,28 +7,58 @@ defmodule SymphonyElixir.AgentRunner do
77
alias SymphonyElixir.Codex.AppServer
88
alias SymphonyElixir.{Config, Linear.Issue, PromptBuilder, Tracker, Workspace}
99

10+
@type worker_host :: String.t() | nil
11+
1012
@spec run(map(), pid() | nil, keyword()) :: :ok | no_return()
1113
def run(issue, codex_update_recipient \\ nil, opts \\ []) do
12-
Logger.info("Starting agent run for #{issue_context(issue)}")
14+
worker_hosts =
15+
candidate_worker_hosts(Keyword.get(opts, :worker_host), Config.settings!().worker.ssh_hosts)
16+
17+
Logger.info("Starting agent run for #{issue_context(issue)} worker_hosts=#{inspect(worker_hosts_for_log(worker_hosts))}")
18+
19+
case run_on_worker_hosts(issue, codex_update_recipient, opts, worker_hosts) do
20+
:ok ->
21+
:ok
22+
23+
{:error, reason} ->
24+
Logger.error("Agent run failed for #{issue_context(issue)}: #{inspect(reason)}")
25+
raise RuntimeError, "Agent run failed for #{issue_context(issue)}: #{inspect(reason)}"
26+
end
27+
end
1328

14-
case Workspace.create_for_issue(issue) do
29+
defp run_on_worker_hosts(issue, codex_update_recipient, opts, [worker_host | rest]) do
30+
case run_on_worker_host(issue, codex_update_recipient, opts, worker_host) do
31+
:ok ->
32+
:ok
33+
34+
{:error, reason} when rest != [] ->
35+
Logger.warning("Agent run failed for #{issue_context(issue)} worker_host=#{worker_host_for_log(worker_host)} reason=#{inspect(reason)}; trying next worker host")
36+
run_on_worker_hosts(issue, codex_update_recipient, opts, rest)
37+
38+
{:error, reason} ->
39+
{:error, reason}
40+
end
41+
end
42+
43+
defp run_on_worker_hosts(_issue, _codex_update_recipient, _opts, []), do: {:error, :no_worker_hosts_available}
44+
45+
defp run_on_worker_host(issue, codex_update_recipient, opts, worker_host) do
46+
Logger.info("Starting worker attempt for #{issue_context(issue)} worker_host=#{worker_host_for_log(worker_host)}")
47+
48+
case Workspace.create_for_issue(issue, worker_host) do
1549
{:ok, workspace} ->
50+
send_worker_runtime_info(codex_update_recipient, issue, worker_host, workspace)
51+
1652
try do
17-
with :ok <- Workspace.run_before_run_hook(workspace, issue),
18-
:ok <- run_codex_turns(workspace, issue, codex_update_recipient, opts) do
19-
:ok
20-
else
21-
{:error, reason} ->
22-
Logger.error("Agent run failed for #{issue_context(issue)}: #{inspect(reason)}")
23-
raise RuntimeError, "Agent run failed for #{issue_context(issue)}: #{inspect(reason)}"
53+
with :ok <- Workspace.run_before_run_hook(workspace, issue, worker_host) do
54+
run_codex_turns(workspace, issue, codex_update_recipient, opts, worker_host)
2455
end
2556
after
26-
Workspace.run_after_run_hook(workspace, issue)
57+
Workspace.run_after_run_hook(workspace, issue, worker_host)
2758
end
2859

2960
{:error, reason} ->
30-
Logger.error("Agent run failed for #{issue_context(issue)}: #{inspect(reason)}")
31-
raise RuntimeError, "Agent run failed for #{issue_context(issue)}: #{inspect(reason)}"
61+
{:error, reason}
3262
end
3363
end
3464

@@ -46,11 +76,27 @@ defmodule SymphonyElixir.AgentRunner do
4676

4777
defp send_codex_update(_recipient, _issue, _message), do: :ok
4878

49-
defp run_codex_turns(workspace, issue, codex_update_recipient, opts) do
79+
defp send_worker_runtime_info(recipient, %Issue{id: issue_id}, worker_host, workspace)
80+
when is_binary(issue_id) and is_pid(recipient) and is_binary(workspace) do
81+
send(
82+
recipient,
83+
{:worker_runtime_info, issue_id,
84+
%{
85+
worker_host: worker_host,
86+
workspace_path: workspace
87+
}}
88+
)
89+
90+
:ok
91+
end
92+
93+
defp send_worker_runtime_info(_recipient, _issue, _worker_host, _workspace), do: :ok
94+
95+
defp run_codex_turns(workspace, issue, codex_update_recipient, opts, worker_host) do
5096
max_turns = Keyword.get(opts, :max_turns, Config.settings!().agent.max_turns)
5197
issue_state_fetcher = Keyword.get(opts, :issue_state_fetcher, &Tracker.fetch_issue_states_by_ids/1)
5298

53-
with {:ok, session} <- AppServer.start_session(workspace) do
99+
with {:ok, session} <- AppServer.start_session(workspace, worker_host: worker_host) do
54100
try do
55101
do_run_codex_turns(session, workspace, issue, codex_update_recipient, opts, issue_state_fetcher, 1, max_turns)
56102
after
@@ -142,6 +188,34 @@ defmodule SymphonyElixir.AgentRunner do
142188

143189
defp active_issue_state?(_state_name), do: false
144190

191+
defp candidate_worker_hosts(nil, []), do: [nil]
192+
193+
defp candidate_worker_hosts(preferred_host, configured_hosts) when is_list(configured_hosts) do
194+
hosts =
195+
configured_hosts
196+
|> Enum.map(&String.trim/1)
197+
|> Enum.reject(&(&1 == ""))
198+
|> Enum.uniq()
199+
200+
case preferred_host do
201+
host when is_binary(host) and host != "" ->
202+
[host | Enum.reject(hosts, &(&1 == host))]
203+
204+
_ when hosts == [] ->
205+
[nil]
206+
207+
_ ->
208+
hosts
209+
end
210+
end
211+
212+
defp worker_hosts_for_log(worker_hosts) do
213+
Enum.map(worker_hosts, &worker_host_for_log/1)
214+
end
215+
216+
defp worker_host_for_log(nil), do: "local"
217+
defp worker_host_for_log(worker_host), do: worker_host
218+
145219
defp normalize_issue_state(state_name) when is_binary(state_name) do
146220
state_name
147221
|> String.trim()

0 commit comments

Comments
 (0)