diff --git a/elixir/lib/symphony_elixir/orchestrator.ex b/elixir/lib/symphony_elixir/orchestrator.ex index 393ec7812d..079a6c2d7c 100644 --- a/elixir/lib/symphony_elixir/orchestrator.ex +++ b/elixir/lib/symphony_elixir/orchestrator.ex @@ -893,7 +893,12 @@ defmodule SymphonyElixir.Orchestrator do defp refresh_running_issue_state(%State{} = state, %Issue{} = issue) do case Map.get(state.running, issue.id) do %{issue: _} = running_entry -> - %{state | running: Map.put(state.running, issue.id, %{running_entry | issue: issue})} + updated_entry = + running_entry + |> Map.merge(issue_entry_fields(issue)) + |> Map.put(:issue, issue) + + %{state | running: Map.put(state.running, issue.id, updated_entry)} _ -> state @@ -903,7 +908,12 @@ defmodule SymphonyElixir.Orchestrator do defp refresh_blocked_issue_state(%State{} = state, %Issue{} = issue) do case Map.get(state.blocked, issue.id) do %{issue: _} = blocked_entry -> - %{state | blocked: Map.put(state.blocked, issue.id, %{blocked_entry | issue: issue})} + updated_entry = + blocked_entry + |> Map.merge(issue_entry_fields(issue)) + |> Map.put(:issue, issue) + + %{state | blocked: Map.put(state.blocked, issue.id, updated_entry)} _ -> state @@ -1113,19 +1123,21 @@ defmodule SymphonyElixir.Orchestrator do end defp block_issue_from_entry(%State{} = state, issue_id, running_entry, error) do - blocked_entry = %{ - issue_id: issue_id, - identifier: Map.get(running_entry, :identifier, issue_id), - issue: Map.get(running_entry, :issue), - worker_host: Map.get(running_entry, :worker_host), - workspace_path: Map.get(running_entry, :workspace_path), - session_id: running_entry_session_id(running_entry), - error: error, - blocked_at: DateTime.utc_now(), - last_codex_message: Map.get(running_entry, :last_codex_message), - last_codex_event: Map.get(running_entry, :last_codex_event), - last_codex_timestamp: Map.get(running_entry, :last_codex_timestamp) - } + issue_fields = issue_entry_fields(running_entry) + + blocked_entry = + issue_fields + |> Map.put(:issue_id, issue_id) + |> Map.put(:identifier, issue_fields.identifier || issue_id) + |> Map.put(:issue, Map.get(running_entry, :issue)) + |> Map.put(:worker_host, Map.get(running_entry, :worker_host)) + |> Map.put(:workspace_path, Map.get(running_entry, :workspace_path)) + |> Map.put(:session_id, running_entry_session_id(running_entry)) + |> Map.put(:error, error) + |> Map.put(:blocked_at, DateTime.utc_now()) + |> Map.put(:last_codex_message, Map.get(running_entry, :last_codex_message)) + |> Map.put(:last_codex_event, Map.get(running_entry, :last_codex_event)) + |> Map.put(:last_codex_timestamp, Map.get(running_entry, :last_codex_timestamp)) %{ state @@ -1374,35 +1386,39 @@ defmodule SymphonyElixir.Orchestrator do end) do {:ok, pid} -> ref = Process.monitor(pid) + issue_fields = issue_entry_fields(issue) Logger.info("Dispatching issue to agent: #{issue_context(issue)} pid=#{inspect(pid)} attempt=#{inspect(attempt)} worker_host=#{worker_host || "local"}") running = - Map.put(state.running, issue.id, %{ - pid: pid, - ref: ref, - identifier: issue.identifier, - issue: issue, - worker_host: worker_host, - workspace_path: nil, - claim_head_sha: nil, - claim_head_sha_error: nil, - session_id: nil, - last_codex_message: nil, - last_codex_timestamp: nil, - last_codex_event: nil, - codex_app_server_pid: nil, - codex_input_tokens: 0, - codex_output_tokens: 0, - codex_total_tokens: 0, - codex_last_reported_input_tokens: 0, - codex_last_reported_output_tokens: 0, - codex_last_reported_total_tokens: 0, - turn_count: 0, - retry_attempt: normalize_retry_attempt(attempt), - model: Stats.model_for_issue(Config.settings!().codex.command, issue), - started_at: DateTime.utc_now() - }) + Map.put( + state.running, + issue.id, + Map.merge(issue_fields, %{ + pid: pid, + ref: ref, + issue: issue, + worker_host: worker_host, + workspace_path: nil, + claim_head_sha: nil, + claim_head_sha_error: nil, + session_id: nil, + last_codex_message: nil, + last_codex_timestamp: nil, + last_codex_event: nil, + codex_app_server_pid: nil, + codex_input_tokens: 0, + codex_output_tokens: 0, + codex_total_tokens: 0, + codex_last_reported_input_tokens: 0, + codex_last_reported_output_tokens: 0, + codex_last_reported_total_tokens: 0, + turn_count: 0, + retry_attempt: normalize_retry_attempt(attempt), + model: Stats.model_for_issue(Config.settings!().codex.command, issue), + started_at: DateTime.utc_now() + }) + ) updated_state = %{ state @@ -1467,13 +1483,10 @@ defmodule SymphonyElixir.Orchestrator do old_timer = Map.get(previous_retry, :timer_ref) retry_token = make_ref() due_at_ms = System.monotonic_time(:millisecond) + delay_ms - identifier = pick_retry_identifier(issue_id, previous_retry, metadata) + issue_fields = retry_issue_fields(issue_id, previous_retry, metadata) error = pick_retry_error(previous_retry, metadata) - title = pick_retry_field(previous_retry, metadata, :title) - description = pick_retry_field(previous_retry, metadata, :description) worker_host = pick_retry_worker_host(previous_retry, metadata) workspace_path = pick_retry_workspace_path(previous_retry, metadata) - url = pick_retry_url(previous_retry, metadata) if is_reference(old_timer) do Process.cancel_timer(old_timer) @@ -1483,39 +1496,36 @@ defmodule SymphonyElixir.Orchestrator do error_suffix = if is_binary(error), do: " error=#{error}", else: "" - Logger.warning("Retrying issue_id=#{issue_id} issue_identifier=#{identifier} in #{delay_ms}ms (attempt #{next_attempt})#{error_suffix}") + Logger.warning("Retrying issue_id=#{issue_id} issue_identifier=#{issue_fields.identifier} in #{delay_ms}ms (attempt #{next_attempt})#{error_suffix}") %{ state | retry_attempts: - Map.put(state.retry_attempts, issue_id, %{ - attempt: next_attempt, - timer_ref: timer_ref, - retry_token: retry_token, - due_at_ms: due_at_ms, - identifier: identifier, - url: url, - title: title, - description: description, - error: error, - worker_host: worker_host, - workspace_path: workspace_path - }) + Map.put( + state.retry_attempts, + issue_id, + Map.merge(issue_fields, %{ + attempt: next_attempt, + timer_ref: timer_ref, + retry_token: retry_token, + due_at_ms: due_at_ms, + error: error, + worker_host: worker_host, + workspace_path: workspace_path + }) + ) } end defp pop_retry_attempt_state(%State{} = state, issue_id, retry_token) when is_reference(retry_token) do case Map.get(state.retry_attempts, issue_id) do %{attempt: attempt, retry_token: ^retry_token} = retry_entry -> - metadata = %{ - identifier: Map.get(retry_entry, :identifier), - url: Map.get(retry_entry, :url), - title: Map.get(retry_entry, :title), - description: Map.get(retry_entry, :description), - error: Map.get(retry_entry, :error), - worker_host: Map.get(retry_entry, :worker_host), - workspace_path: Map.get(retry_entry, :workspace_path) - } + metadata = + retry_entry + |> issue_entry_fields() + |> Map.put(:error, Map.get(retry_entry, :error)) + |> Map.put(:worker_host, Map.get(retry_entry, :worker_host)) + |> Map.put(:workspace_path, Map.get(retry_entry, :workspace_path)) {:ok, attempt, metadata, %{state | retry_attempts: Map.delete(state.retry_attempts, issue_id)}} @@ -1676,17 +1686,13 @@ defmodule SymphonyElixir.Orchestrator do end defp retry_entry_from_retry(%Issue{} = issue, attempt, metadata, now_ms) do - %{ - attempt: attempt, - due_at_ms: now_ms, - identifier: metadata[:identifier] || issue.identifier, - url: entry_issue_url(metadata) || issue.url, - title: metadata[:title] || issue.title, - description: metadata[:description] || issue.description, - error: metadata[:error], - worker_host: metadata[:worker_host], - workspace_path: metadata[:workspace_path] - } + metadata + |> merge_issue_entry_fields(issue) + |> Map.put(:attempt, attempt) + |> Map.put(:due_at_ms, now_ms) + |> Map.put(:error, metadata[:error]) + |> Map.put(:worker_host, metadata[:worker_host]) + |> Map.put(:workspace_path, metadata[:workspace_path]) end defp release_issue_claim(%State{} = state, issue_id) do @@ -1741,22 +1747,10 @@ defmodule SymphonyElixir.Orchestrator do end end - defp pick_retry_identifier(issue_id, previous_retry, metadata) do - metadata[:identifier] || Map.get(previous_retry, :identifier) || issue_id - end - - defp pick_retry_url(previous_retry, metadata) do - entry_issue_url(metadata) || entry_issue_url(previous_retry) - end - defp pick_retry_error(previous_retry, metadata) do metadata[:error] || Map.get(previous_retry, :error) end - defp pick_retry_field(previous_retry, metadata, field) do - Map.get(metadata, field) || Map.get(previous_retry, field) - end - defp pick_retry_worker_host(previous_retry, metadata) do metadata[:worker_host] || Map.get(previous_retry, :worker_host) end @@ -1766,35 +1760,58 @@ defmodule SymphonyElixir.Orchestrator do end defp retry_metadata_from_running_entry(running_entry, overrides) when is_map(running_entry) do - %{ - identifier: Map.get(running_entry, :identifier), - url: issue_url_from_running_entry(running_entry), - title: issue_title_from_running_entry(running_entry), - description: issue_description_from_running_entry(running_entry), - worker_host: Map.get(running_entry, :worker_host), - workspace_path: Map.get(running_entry, :workspace_path) - } + running_entry + |> issue_entry_fields() + |> Map.put(:worker_host, Map.get(running_entry, :worker_host)) + |> Map.put(:workspace_path, Map.get(running_entry, :workspace_path)) |> Map.merge(overrides) end defp retry_metadata_from_issue(%Issue{} = issue, overrides) when is_map(overrides) do overrides - |> Map.merge(%{ + |> Map.merge(issue_entry_fields(issue)) + end + + defp retry_issue_fields(issue_id, previous_retry, metadata) do + issue_fields = merge_issue_entry_fields(metadata, previous_retry) + %{issue_fields | identifier: issue_fields.identifier || issue_id} + end + + defp merge_issue_entry_fields(primary, fallback) do + primary_fields = issue_entry_fields(primary) + fallback_fields = issue_entry_fields(fallback) + + %{ + identifier: primary_fields.identifier || fallback_fields.identifier, + url: primary_fields.url || fallback_fields.url, + title: primary_fields.title || fallback_fields.title, + description: primary_fields.description || fallback_fields.description + } + end + + defp issue_entry_fields(%Issue{} = issue) do + %{ identifier: issue.identifier, url: issue.url, title: issue.title, description: issue.description - }) + } end - defp issue_url_from_running_entry(%{issue: %Issue{url: url}}), do: url - defp issue_url_from_running_entry(_running_entry), do: nil - - defp issue_title_from_running_entry(%{issue: %Issue{title: title}}), do: title - defp issue_title_from_running_entry(_running_entry), do: nil + defp issue_entry_fields(entry) when is_map(entry) do + issue_fields = + case Map.get(entry, :issue) do + %Issue{} = issue -> issue_entry_fields(issue) + _ -> %{identifier: nil, url: nil, title: nil, description: nil} + end - defp issue_description_from_running_entry(%{issue: %Issue{description: description}}), do: description - defp issue_description_from_running_entry(_running_entry), do: nil + %{ + identifier: issue_fields.identifier || Map.get(entry, :identifier), + url: issue_fields.url || entry_issue_url(entry), + title: issue_fields.title || Map.get(entry, :title), + description: issue_fields.description || Map.get(entry, :description) + } + end defp maybe_put_runtime_value(running_entry, _key, nil), do: running_entry @@ -1944,13 +1961,14 @@ defmodule SymphonyElixir.Orchestrator do |> Enum.map(fn {issue_id, metadata} -> task_key = diff_shortstat_task_key(issue_id, metadata) diff_shortstat = Map.get(state.diff_shortstats, task_key, @empty_diff_shortstat) + issue_fields = issue_entry_fields(metadata) %{ issue_id: issue_id, - identifier: metadata.identifier, - url: issue_url_from_metadata(metadata), - title: issue_title_from_metadata(metadata), - description: issue_description_from_metadata(metadata), + identifier: issue_fields.identifier, + url: issue_fields.url, + title: issue_fields.title, + description: issue_fields.description, state: metadata.issue.state, worker_host: Map.get(metadata, :worker_host), workspace_path: Map.get(metadata, :workspace_path), @@ -1975,14 +1993,16 @@ defmodule SymphonyElixir.Orchestrator do retrying = state.retry_attempts |> Enum.map(fn {issue_id, %{attempt: attempt, due_at_ms: due_at_ms} = retry} -> + issue_fields = issue_entry_fields(retry) + %{ issue_id: issue_id, attempt: attempt, due_in_ms: max(0, due_at_ms - now_ms), - identifier: Map.get(retry, :identifier), - url: entry_issue_url(retry), - title: Map.get(retry, :title), - description: Map.get(retry, :description), + identifier: issue_fields.identifier, + url: issue_fields.url, + title: issue_fields.title, + description: issue_fields.description, error: Map.get(retry, :error), worker_host: Map.get(retry, :worker_host), workspace_path: Map.get(retry, :workspace_path) @@ -1992,12 +2012,14 @@ defmodule SymphonyElixir.Orchestrator do blocked = state.blocked |> Enum.map(fn {issue_id, metadata} -> + issue_fields = issue_entry_fields(metadata) + %{ issue_id: issue_id, - identifier: Map.get(metadata, :identifier), - url: issue_url_from_metadata(metadata), - title: issue_title_from_metadata(metadata), - description: issue_description_from_metadata(metadata), + identifier: issue_fields.identifier, + url: issue_fields.url, + title: issue_fields.title, + description: issue_fields.description, state: blocked_issue_state(metadata), worker_host: Map.get(metadata, :worker_host), workspace_path: Map.get(metadata, :workspace_path), @@ -2147,15 +2169,6 @@ defmodule SymphonyElixir.Orchestrator do defp blocked_issue_state(%{issue: %Issue{state: state}}), do: state defp blocked_issue_state(_metadata), do: nil - defp issue_url_from_metadata(%{issue: %Issue{url: url}}), do: url - defp issue_url_from_metadata(metadata), do: Map.get(metadata, :url) - - defp issue_title_from_metadata(%{issue: %Issue{title: title}}), do: title - defp issue_title_from_metadata(metadata), do: Map.get(metadata, :title) - - defp issue_description_from_metadata(%{issue: %Issue{description: description}}), do: description - defp issue_description_from_metadata(metadata), do: Map.get(metadata, :description) - defp integrate_codex_update(running_entry, %{event: event, timestamp: timestamp} = update) do token_delta = extract_token_delta(running_entry, update) codex_input_tokens = Map.get(running_entry, :codex_input_tokens, 0) diff --git a/elixir/test/symphony_elixir/orchestrator_status_test.exs b/elixir/test/symphony_elixir/orchestrator_status_test.exs index c48d9d4e03..f8630dea25 100644 --- a/elixir/test/symphony_elixir/orchestrator_status_test.exs +++ b/elixir/test/symphony_elixir/orchestrator_status_test.exs @@ -178,6 +178,110 @@ defmodule SymphonyElixir.OrchestratorStatusTest do } end + test "presenter payload keeps tracker issue urls for running and retrying rows" do + write_workflow_file!(Workflow.workflow_file_path(), + tracker_kind: "memory", + tracker_api_token: nil, + tracker_active_states: ["In Progress"], + max_concurrent_agents: 1 + ) + + running_issue = %Issue{ + id: "issue-url-running", + identifier: "MT-URL-RUN", + title: "Running URL regression", + description: "Running row keeps its issue URL", + state: "In Progress", + url: "https://github.com/digitaldrywood/symphony/issues/93" + } + + retry_issue = %Issue{ + id: "issue-url-retry", + identifier: "MT-URL-RETRY", + title: "Retry URL regression", + description: "Retry row keeps its issue URL", + state: "In Progress", + url: "https://github.com/digitaldrywood/symphony/issues/94" + } + + Application.put_env(:symphony_elixir, :memory_tracker_issues, [running_issue, retry_issue]) + + orchestrator_name = Module.concat(__MODULE__, :IssueUrlPayloadOrchestrator) + {:ok, pid} = Orchestrator.start_link(name: orchestrator_name) + + on_exit(fn -> + stop_orchestrator(pid) + end) + + retry_token = make_ref() + now_ms = System.monotonic_time(:millisecond) + started_at = DateTime.utc_now() + initial_state = :sys.get_state(pid) + + running_entry = %{ + pid: self(), + ref: make_ref(), + identifier: running_issue.identifier, + issue: %{running_issue | url: nil, title: nil, description: nil}, + worker_host: nil, + workspace_path: nil, + session_id: "thread-url-running-turn-1", + turn_count: 0, + last_codex_message: nil, + last_codex_timestamp: nil, + last_codex_event: nil, + codex_app_server_pid: nil, + codex_input_tokens: 0, + codex_output_tokens: 0, + codex_total_tokens: 0, + codex_last_reported_input_tokens: 0, + codex_last_reported_output_tokens: 0, + codex_last_reported_total_tokens: 0, + started_at: started_at + } + + :sys.replace_state(pid, fn _ -> + %{ + initial_state + | running: %{running_issue.id => running_entry}, + claimed: MapSet.new([running_issue.id, retry_issue.id]), + retry_attempts: %{ + retry_issue.id => %{ + attempt: 1, + due_at_ms: now_ms, + retry_token: retry_token, + identifier: retry_issue.identifier, + error: "retry scheduled" + } + } + } + end) + + send(pid, :run_poll_cycle) + + assert %{running: [%{url: "https://github.com/digitaldrywood/symphony/issues/93"}]} = + wait_for_snapshot( + pid, + fn %{running: [entry]} -> entry.url == running_issue.url end, + 1_000 + ) + + send(pid, {:retry_issue, retry_issue.id, retry_token}) + + assert %{retrying: [%{url: "https://github.com/digitaldrywood/symphony/issues/94"}]} = + wait_for_snapshot( + pid, + fn %{retrying: [entry]} -> entry.url == retry_issue.url end, + 1_000 + ) + + payload = SymphonyElixirWeb.Presenter.state_payload(orchestrator_name, 1_000) + + assert [%{issue_url: "https://github.com/digitaldrywood/symphony/issues/93"}] = payload.running + assert [%{issue_url: "https://github.com/digitaldrywood/symphony/issues/94"}] = payload.retrying + assert Enum.all?(payload.running ++ payload.retrying, &is_binary(&1.issue_url)) + end + test "orchestrator snapshot tracks codex thread totals and app-server pid" do issue_id = "issue-usage-snapshot" diff --git a/elixir/test/symphony_elixir_web/dashboard_live_test.exs b/elixir/test/symphony_elixir_web/dashboard_live_test.exs new file mode 100644 index 0000000000..0d76d49da2 --- /dev/null +++ b/elixir/test/symphony_elixir_web/dashboard_live_test.exs @@ -0,0 +1,145 @@ +defmodule SymphonyElixirWeb.DashboardLiveTest do + use SymphonyElixir.TestSupport + + import Phoenix.ConnTest + import Phoenix.LiveViewTest + + @endpoint SymphonyElixirWeb.Endpoint + + defmodule StaticOrchestrator do + use GenServer + + def start_link(opts) do + name = Keyword.fetch!(opts, :name) + GenServer.start_link(__MODULE__, opts, name: name) + end + + def init(opts), do: {:ok, opts} + + def handle_call(:snapshot, _from, state) do + {:reply, Keyword.fetch!(state, :snapshot), state} + end + + def handle_call(:request_refresh, _from, state) do + {:reply, :unavailable, state} + end + end + + setup do + endpoint_config = Application.get_env(:symphony_elixir, SymphonyElixirWeb.Endpoint, []) + + on_exit(fn -> + Application.put_env(:symphony_elixir, SymphonyElixirWeb.Endpoint, endpoint_config) + end) + + :ok + end + + test "renders issue URL controls for every dashboard row with a URL" do + running_url = "https://github.com/digitaldrywood/symphony/issues/93" + retry_url = "https://github.com/digitaldrywood/symphony/issues/94" + recent_url = "https://github.com/digitaldrywood/symphony/issues/95" + + SymphonyElixir.Stats.record_session(%{ + run_id: nil, + issue_id: "issue-recent-url", + identifier: "MT-RECENT-URL", + issue_url: recent_url, + started_at: ~U[2026-05-22 10:00:00Z], + completed_at: ~U[2026-05-22 10:03:00Z], + turns: 1, + input_tokens: 12, + output_tokens: 6, + total_tokens: 18, + runtime_seconds: 180, + final_state: "Done", + model: "gpt-5.5" + }) + + orchestrator_name = Module.concat(__MODULE__, :IssueUrlControlsOrchestrator) + + {:ok, _pid} = + StaticOrchestrator.start_link( + name: orchestrator_name, + snapshot: %{ + running: [ + %{ + issue_id: "issue-running-url", + identifier: "MT-RUNNING-URL", + url: running_url, + title: "Running URL controls", + description: "Running row issue URL controls", + state: "In Progress", + session_id: "thread-running-url", + turn_count: 2, + codex_app_server_pid: nil, + last_codex_message: nil, + last_codex_timestamp: nil, + last_codex_event: nil, + diff_added: 0, + diff_removed: 0, + diff_files: 0, + diff_status: "ok", + codex_input_tokens: 10, + codex_output_tokens: 5, + codex_total_tokens: 15, + started_at: DateTime.utc_now() + } + ], + retrying: [ + %{ + issue_id: "issue-retry-url", + identifier: "MT-RETRY-URL", + url: retry_url, + title: "Retry URL controls", + description: "Retry row issue URL controls", + attempt: 2, + due_in_ms: 2_000, + error: "retry scheduled" + } + ], + blocked: [], + codex_totals: %{input_tokens: 10, output_tokens: 5, total_tokens: 15, seconds_running: 30}, + rate_limits: nil + } + ) + + start_test_endpoint(orchestrator: orchestrator_name, snapshot_timeout_ms: 50) + + {:ok, _view, html} = live(build_conn(), "/") + document = Floki.parse_document!(html) + expected_urls = Enum.sort([running_url, retry_url, recent_url]) + + copy_urls = + document + |> Floki.find("button.issue-copy") + |> Enum.map(&tag_attr(&1, "data-copy")) + |> Enum.sort() + + external_urls = + document + |> Floki.find("a.issue-external") + |> Enum.map(&tag_attr(&1, "href")) + |> Enum.sort() + + assert copy_urls == expected_urls + assert external_urls == expected_urls + end + + defp start_test_endpoint(overrides) do + endpoint_config = + :symphony_elixir + |> Application.get_env(SymphonyElixirWeb.Endpoint, []) + |> Keyword.merge(server: false, secret_key_base: String.duplicate("s", 64)) + |> Keyword.merge(overrides) + + Application.put_env(:symphony_elixir, SymphonyElixirWeb.Endpoint, endpoint_config) + start_supervised!({SymphonyElixirWeb.Endpoint, []}) + end + + defp tag_attr({_tag, attrs, _children}, attr_name) do + attrs + |> Enum.into(%{}) + |> Map.get(attr_name) + end +end