Lifetime totals
+<%= format_int(@payload.lifetime_totals.total_tokens) %>
++ <%= format_runtime_seconds(lifetime_runtime_seconds(@payload, @now)) %> / <%= format_int(@payload.lifetime_totals.sessions) %> sessions +
+diff --git a/SPEC.md b/SPEC.md
index 0c5d5308d0..8389e30c9d 100644
--- a/SPEC.md
+++ b/SPEC.md
@@ -1309,6 +1309,14 @@ SHOULD return:
- `output_tokens`
- `total_tokens`
- `seconds_running` (aggregate runtime seconds as of snapshot time, including active sessions)
+- `lifetime_totals` (optional durable totals across restarts)
+ - `input_tokens`
+ - `output_tokens`
+ - `total_tokens`
+ - `runtime_seconds`
+ - `sessions`
+ - `runs`
+- `recent_sessions` (optional durable list of recently completed Codex sessions)
- `rate_limits` (latest coding-agent rate limit payload, if available)
RECOMMENDED snapshot error modes:
@@ -1339,6 +1347,9 @@ Token accounting rules:
- Do not treat generic `usage` maps as cumulative totals unless the event type defines them that
way.
- Accumulate aggregate totals in orchestrator state.
+- Implementations MAY persist run and session statistics to embedded local storage for durable
+ observability across process restarts.
+- Durable stores SHOULD tolerate missing/deleted databases by recreating empty history.
Runtime accounting:
@@ -1452,6 +1463,29 @@ Minimum endpoints:
"total_tokens": 7400,
"seconds_running": 1834.2
},
+ "lifetime_totals": {
+ "input_tokens": 12000,
+ "output_tokens": 4200,
+ "total_tokens": 16200,
+ "runtime_seconds": 4810,
+ "sessions": 8,
+ "runs": 3
+ },
+ "recent_sessions": [
+ {
+ "issue_id": "abc123",
+ "identifier": "MT-649",
+ "started_at": "2026-02-24T20:10:12Z",
+ "completed_at": "2026-02-24T20:14:59Z",
+ "turns": 2,
+ "input_tokens": 1200,
+ "output_tokens": 800,
+ "total_tokens": 2000,
+ "runtime_seconds": 287,
+ "final_state": "Done",
+ "model": "gpt-5.5"
+ }
+ ],
"rate_limits": null
}
```
diff --git a/elixir/README.md b/elixir/README.md
index 42d24a6911..1855efd21a 100644
--- a/elixir/README.md
+++ b/elixir/README.md
@@ -115,6 +115,11 @@ Optional flags:
- `--logs-root` tells Symphony to write logs under a different directory (default: `./log`)
- `--port` also starts the Phoenix observability service (default: disabled)
+Symphony writes durable run and Codex session statistics to
+`~/.local/share/symphony/stats.db`. The SQLite database stores per-run token/runtime aggregates and
+the last completed Codex sessions. Deleting the database resets the dashboard history without
+affecting orchestration.
+
The `WORKFLOW.md` file uses YAML front matter for configuration, plus a Markdown body used as the
Codex session prompt.
@@ -221,6 +226,13 @@ The observability UI now runs on a minimal Phoenix stack:
- Bandit as the HTTP server
- Phoenix dependency static assets for the LiveView client bootstrap
+The dashboard and `/api/v1/state` include durable statistics from `stats.db`:
+
+- `lifetime_totals` reports input/output/total tokens, runtime seconds, sessions, and run count
+ across Symphony restarts.
+- `recent_sessions` lists the last 20 completed Codex sessions with issue, token, runtime, turn,
+ final state, and model fields.
+
## Project Layout
- `lib/`: application code and Mix tasks
diff --git a/elixir/lib/symphony_elixir/orchestrator.ex b/elixir/lib/symphony_elixir/orchestrator.ex
index 19806ebae2..db58432c16 100644
--- a/elixir/lib/symphony_elixir/orchestrator.ex
+++ b/elixir/lib/symphony_elixir/orchestrator.ex
@@ -7,7 +7,7 @@ defmodule SymphonyElixir.Orchestrator do
require Logger
import Bitwise, only: [<<<: 2]
- alias SymphonyElixir.{AgentRunner, Config, StatusDashboard, Tracker, Workspace}
+ alias SymphonyElixir.{AgentRunner, Config, Stats, StatusDashboard, Tracker, Workspace}
alias SymphonyElixir.GitHub.Adapter, as: GitHubAdapter
alias SymphonyElixir.Linear.Issue
@@ -34,13 +34,17 @@ defmodule SymphonyElixir.Orchestrator do
:poll_check_in_progress,
:tick_timer_ref,
:tick_token,
+ :stats_run_id,
+ :stats_started_at,
running: %{},
completed: MapSet.new(),
claimed: MapSet.new(),
blocked: %{},
retry_attempts: %{},
codex_totals: nil,
- codex_rate_limits: nil
+ codex_rate_limits: nil,
+ peak_concurrent_agents: 0,
+ sessions_launched: 0
]
end
@@ -52,11 +56,15 @@ defmodule SymphonyElixir.Orchestrator do
@impl true
def init(_opts) do
+ Process.flag(:trap_exit, true)
now_ms = System.monotonic_time(:millisecond)
+ now = DateTime.utc_now()
config = Config.settings!()
case validate_tracker_startup(config.tracker) do
:ok ->
+ stats_run_id = Stats.start_run(%{started_at: now})
+
state = %State{
poll_interval_ms: config.polling.interval_ms,
max_concurrent_agents: config.agent.max_concurrent_agents,
@@ -64,6 +72,8 @@ defmodule SymphonyElixir.Orchestrator do
poll_check_in_progress: false,
tick_timer_ref: nil,
tick_token: nil,
+ stats_run_id: stats_run_id,
+ stats_started_at: now,
codex_totals: @empty_codex_totals,
codex_rate_limits: nil
}
@@ -101,6 +111,8 @@ defmodule SymphonyElixir.Orchestrator do
def handle_info({:tick, _tick_token}, state), do: {:noreply, state}
+ def handle_info({:EXIT, _pid, reason}, state), do: {:stop, reason, state}
+
def handle_info(:tick, state) do
state = refresh_runtime_config(state)
@@ -181,6 +193,7 @@ defmodule SymphonyElixir.Orchestrator do
state
|> apply_codex_token_delta(token_delta)
|> apply_codex_rate_limits(update)
+ |> persist_stats_run()
notify_dashboard()
{:noreply, %{state | running: Map.put(running, issue_id, updated_running_entry)}}
@@ -253,6 +266,21 @@ defmodule SymphonyElixir.Orchestrator do
})
end
+ @impl true
+ def terminate(reason, %State{} = state) do
+ now = DateTime.utc_now()
+
+ state =
+ Enum.reduce(state.running, state, fn {_issue_id, running_entry}, state_acc ->
+ record_session_completion_totals(state_acc, running_entry, now, "stopped")
+ end)
+
+ finish_stats_run(state, now, reason)
+ :ok
+ end
+
+ def terminate(_reason, _state), do: :ok
+
defp maybe_dispatch(%State{} = state) do
state =
state
@@ -979,16 +1007,21 @@ defmodule SymphonyElixir.Orchestrator do
codex_last_reported_total_tokens: 0,
turn_count: 0,
retry_attempt: normalize_retry_attempt(attempt),
+ model: Stats.model_from_command(Config.settings!().codex.command),
started_at: DateTime.utc_now()
})
- %{
+ updated_state = %{
state
| running: running,
claimed: MapSet.put(state.claimed, issue.id),
- retry_attempts: Map.delete(state.retry_attempts, issue.id)
+ retry_attempts: Map.delete(state.retry_attempts, issue.id),
+ peak_concurrent_agents: max(state.peak_concurrent_agents, map_size(running)),
+ sessions_launched: state.sessions_launched + 1
}
+ persist_stats_run(updated_state)
+
{:error, reason} ->
Logger.error("Unable to spawn agent for #{issue_context(issue)}: #{inspect(reason)}")
next_attempt = if is_integer(attempt), do: attempt + 1, else: nil
@@ -1627,7 +1660,22 @@ defmodule SymphonyElixir.Orchestrator do
end
defp record_session_completion_totals(state, running_entry) when is_map(running_entry) do
- runtime_seconds = running_seconds(running_entry.started_at, DateTime.utc_now())
+ record_session_completion_totals(state, running_entry, DateTime.utc_now(), nil)
+ end
+
+ defp record_session_completion_totals(state, _running_entry), do: state
+
+ defp record_session_completion_totals(state, running_entry, completed_at, final_state_override)
+ when is_map(running_entry) do
+ runtime_seconds = running_seconds(running_entry.started_at, completed_at)
+
+ record_stats_session(
+ state,
+ running_entry,
+ completed_at,
+ runtime_seconds,
+ final_state_override
+ )
codex_totals =
apply_token_delta(
@@ -1641,9 +1689,83 @@ defmodule SymphonyElixir.Orchestrator do
)
%{state | codex_totals: codex_totals}
+ |> persist_stats_run()
+ end
+
+ defp record_stats_session(state, running_entry, completed_at, runtime_seconds, final_state_override) do
+ if stats_session_recordable?(running_entry) do
+ Stats.record_session(%{
+ run_id: state.stats_run_id,
+ issue_id: running_entry_issue_id(running_entry),
+ identifier: Map.get(running_entry, :identifier),
+ started_at: Map.get(running_entry, :started_at),
+ completed_at: completed_at,
+ turns: Map.get(running_entry, :turn_count, 0),
+ input_tokens: Map.get(running_entry, :codex_input_tokens, 0),
+ output_tokens: Map.get(running_entry, :codex_output_tokens, 0),
+ total_tokens: Map.get(running_entry, :codex_total_tokens, 0),
+ runtime_seconds: runtime_seconds,
+ final_state: final_state_override || running_entry_final_state(running_entry),
+ model: Map.get(running_entry, :model)
+ })
+ end
end
- defp record_session_completion_totals(state, _running_entry), do: state
+ defp stats_session_recordable?(running_entry) do
+ match?(%DateTime{}, Map.get(running_entry, :started_at)) and
+ (is_binary(Map.get(running_entry, :session_id)) or Map.get(running_entry, :turn_count, 0) > 0 or
+ Enum.any?(
+ [
+ Map.get(running_entry, :codex_input_tokens, 0),
+ Map.get(running_entry, :codex_output_tokens, 0),
+ Map.get(running_entry, :codex_total_tokens, 0)
+ ],
+ &(&1 > 0)
+ ))
+ end
+
+ defp running_entry_issue_id(%{issue: %Issue{id: issue_id}}) when is_binary(issue_id), do: issue_id
+ defp running_entry_issue_id(%{issue_id: issue_id}) when is_binary(issue_id), do: issue_id
+ defp running_entry_issue_id(_running_entry), do: nil
+
+ defp running_entry_final_state(%{issue: %Issue{state: state}}) when is_binary(state), do: state
+ defp running_entry_final_state(%{state: state}) when is_binary(state), do: state
+ defp running_entry_final_state(_running_entry), do: nil
+
+ defp persist_stats_run(%State{} = state) do
+ Stats.update_run(state.stats_run_id, stats_run_attrs(state))
+ state
+ end
+
+ defp finish_stats_run(%State{} = state, %DateTime{} = stopped_at, reason) do
+ Stats.finish_run(
+ state.stats_run_id,
+ stats_run_attrs(state, %{
+ stopped_at: stopped_at,
+ restart_reason: restart_reason(reason)
+ })
+ )
+ end
+
+ defp stats_run_attrs(%State{} = state, extra \\ %{}) do
+ codex_totals = state.codex_totals || @empty_codex_totals
+
+ %{
+ started_at: state.stats_started_at,
+ peak_concurrent_agents: state.peak_concurrent_agents,
+ sessions_launched: state.sessions_launched,
+ input_tokens: Map.get(codex_totals, :input_tokens, 0),
+ output_tokens: Map.get(codex_totals, :output_tokens, 0),
+ total_tokens: Map.get(codex_totals, :total_tokens, 0),
+ runtime_seconds: Map.get(codex_totals, :seconds_running, 0)
+ }
+ |> Map.merge(extra)
+ end
+
+ defp restart_reason(:normal), do: "normal"
+ defp restart_reason(:shutdown), do: "shutdown"
+ defp restart_reason({:shutdown, reason}), do: "shutdown: #{inspect(reason)}"
+ defp restart_reason(reason), do: inspect(reason)
defp refresh_runtime_config(%State{} = state) do
config = Config.settings!()
diff --git a/elixir/lib/symphony_elixir/stats.ex b/elixir/lib/symphony_elixir/stats.ex
new file mode 100644
index 0000000000..943f156710
--- /dev/null
+++ b/elixir/lib/symphony_elixir/stats.ex
@@ -0,0 +1,266 @@
+defmodule SymphonyElixir.Stats do
+ @moduledoc """
+ Durable Symphony run and Codex session statistics.
+ """
+
+ require Logger
+
+ alias SymphonyElixir.Stats.SQLite
+
+ @default_limit 20
+ @empty_lifetime_totals %{
+ input_tokens: 0,
+ output_tokens: 0,
+ total_tokens: 0,
+ runtime_seconds: 0,
+ sessions: 0,
+ runs: 0
+ }
+
+ @type lifetime_totals :: %{
+ input_tokens: non_neg_integer(),
+ output_tokens: non_neg_integer(),
+ total_tokens: non_neg_integer(),
+ runtime_seconds: non_neg_integer(),
+ sessions: non_neg_integer(),
+ runs: non_neg_integer()
+ }
+
+ @type recent_session :: %{
+ id: integer(),
+ run_id: integer() | nil,
+ issue_id: String.t() | nil,
+ identifier: String.t() | nil,
+ started_at: String.t() | nil,
+ completed_at: String.t() | nil,
+ turns: non_neg_integer(),
+ input_tokens: non_neg_integer(),
+ output_tokens: non_neg_integer(),
+ total_tokens: non_neg_integer(),
+ runtime_seconds: non_neg_integer(),
+ final_state: String.t() | nil,
+ model: String.t() | nil
+ }
+
+ @spec db_path() :: Path.t()
+ def db_path do
+ case Application.get_env(:symphony_elixir, :stats_db_file) do
+ path when is_binary(path) and path != "" -> Path.expand(path)
+ _ -> default_db_path()
+ end
+ end
+
+ @spec default_db_path() :: Path.t()
+ def default_db_path do
+ data_home =
+ case System.get_env("XDG_DATA_HOME") do
+ path when is_binary(path) and path != "" -> Path.expand(path)
+ _ -> Path.expand("~/.local/share")
+ end
+
+ Path.join([data_home, "symphony", "stats.db"])
+ end
+
+ @spec start_run(map(), keyword()) :: integer() | nil
+ def start_run(attrs \\ %{}, opts \\ []) when is_map(attrs) do
+ case adapter(opts).start_run(path(opts), normalize_run_attrs(attrs)) do
+ {:ok, id} when is_integer(id) ->
+ id
+
+ {:error, reason} ->
+ log_failure("start stats run", reason)
+ nil
+ end
+ end
+
+ @spec update_run(integer() | nil, map()) :: :ok
+ def update_run(run_id, attrs), do: update_run(run_id, attrs, [])
+
+ @spec update_run(integer() | nil, map(), keyword()) :: :ok
+ def update_run(nil, _attrs, _opts), do: :ok
+
+ def update_run(run_id, attrs, opts) when is_integer(run_id) and is_map(attrs) do
+ case adapter(opts).update_run(path(opts), run_id, normalize_run_attrs(attrs)) do
+ :ok ->
+ :ok
+
+ {:error, reason} ->
+ log_failure("update stats run", reason)
+ :ok
+ end
+ end
+
+ @spec finish_run(integer() | nil, map(), keyword()) :: :ok
+ def finish_run(run_id, attrs, opts \\ []) do
+ update_run(run_id, attrs, opts)
+ end
+
+ @spec record_session(map(), keyword()) :: :ok
+ def record_session(attrs, opts \\ []) when is_map(attrs) do
+ case adapter(opts).record_session(path(opts), normalize_session_attrs(attrs)) do
+ {:ok, _id} ->
+ :ok
+
+ {:error, reason} ->
+ log_failure("record stats session", reason)
+ :ok
+ end
+ end
+
+ @spec lifetime_totals(keyword()) :: lifetime_totals()
+ def lifetime_totals(opts \\ []) do
+ case adapter(opts).lifetime_totals(path(opts)) do
+ {:ok, totals} ->
+ normalize_lifetime_totals(totals)
+
+ {:error, reason} ->
+ log_failure("read lifetime stats", reason)
+ @empty_lifetime_totals
+ end
+ end
+
+ @spec recent_sessions(non_neg_integer(), keyword()) :: [recent_session()]
+ def recent_sessions(limit \\ @default_limit, opts \\ []) do
+ limit = normalize_limit(limit)
+
+ case adapter(opts).recent_sessions(path(opts), limit) do
+ {:ok, sessions} ->
+ Enum.map(sessions, &normalize_recent_session/1)
+
+ {:error, reason} ->
+ log_failure("read recent stats sessions", reason)
+ []
+ end
+ end
+
+ @spec model_from_command(String.t() | nil) :: String.t() | nil
+ def model_from_command(command) when is_binary(command) do
+ model_from_flag(command) || model_from_config(command)
+ end
+
+ def model_from_command(_command), do: nil
+
+ defp adapter(opts), do: Keyword.get(opts, :adapter, SQLite)
+ defp path(opts), do: Keyword.get(opts, :db_path, db_path())
+
+ defp normalize_run_attrs(attrs) do
+ %{
+ started_at: iso8601(value(attrs, :started_at)),
+ stopped_at: iso8601(value(attrs, :stopped_at)),
+ restart_reason: string_value(value(attrs, :restart_reason)),
+ peak_concurrent_agents: integer_value(value(attrs, :peak_concurrent_agents), 0),
+ sessions_launched: integer_value(value(attrs, :sessions_launched), 0),
+ input_tokens: integer_value(value(attrs, :input_tokens), 0),
+ output_tokens: integer_value(value(attrs, :output_tokens), 0),
+ total_tokens: integer_value(value(attrs, :total_tokens), 0),
+ runtime_seconds: integer_value(value(attrs, :runtime_seconds), 0)
+ }
+ end
+
+ defp normalize_session_attrs(attrs) do
+ %{
+ run_id: nullable_integer_value(value(attrs, :run_id)),
+ issue_id: string_value(value(attrs, :issue_id)),
+ identifier: string_value(value(attrs, :identifier)),
+ started_at: iso8601(value(attrs, :started_at)),
+ completed_at: iso8601(value(attrs, :completed_at)),
+ turns: integer_value(value(attrs, :turns), 0),
+ input_tokens: integer_value(value(attrs, :input_tokens), 0),
+ output_tokens: integer_value(value(attrs, :output_tokens), 0),
+ total_tokens: integer_value(value(attrs, :total_tokens), 0),
+ runtime_seconds: integer_value(value(attrs, :runtime_seconds), 0),
+ final_state: string_value(value(attrs, :final_state)),
+ model: string_value(value(attrs, :model))
+ }
+ end
+
+ defp normalize_lifetime_totals(totals) when is_map(totals) do
+ %{
+ input_tokens: integer_value(value(totals, :input_tokens), 0),
+ output_tokens: integer_value(value(totals, :output_tokens), 0),
+ total_tokens: integer_value(value(totals, :total_tokens), 0),
+ runtime_seconds: integer_value(value(totals, :runtime_seconds), 0),
+ sessions: integer_value(value(totals, :sessions), 0),
+ runs: integer_value(value(totals, :runs), 0)
+ }
+ end
+
+ defp normalize_recent_session(session) when is_map(session) do
+ %{
+ id: integer_value(value(session, :id), 0),
+ run_id: nullable_integer_value(value(session, :run_id)),
+ issue_id: string_value(value(session, :issue_id)),
+ identifier: string_value(value(session, :identifier)),
+ started_at: string_value(value(session, :started_at)),
+ completed_at: string_value(value(session, :completed_at)),
+ turns: integer_value(value(session, :turns), 0),
+ input_tokens: integer_value(value(session, :input_tokens), 0),
+ output_tokens: integer_value(value(session, :output_tokens), 0),
+ total_tokens: integer_value(value(session, :total_tokens), 0),
+ runtime_seconds: integer_value(value(session, :runtime_seconds), 0),
+ final_state: string_value(value(session, :final_state)),
+ model: string_value(value(session, :model))
+ }
+ end
+
+ defp normalize_limit(limit) when is_integer(limit) and limit > 0, do: limit
+ defp normalize_limit(_limit), do: @default_limit
+
+ defp value(map, key) when is_map(map), do: Map.get(map, key) || Map.get(map, Atom.to_string(key))
+
+ defp iso8601(%DateTime{} = datetime) do
+ datetime
+ |> DateTime.truncate(:second)
+ |> DateTime.to_iso8601()
+ end
+
+ defp iso8601(value) when is_binary(value), do: string_value(value)
+ defp iso8601(_value), do: nil
+
+ defp nullable_integer_value(nil), do: nil
+ defp nullable_integer_value(value), do: integer_value(value, 0)
+
+ defp integer_value(value, _default) when is_integer(value), do: max(value, 0)
+
+ defp integer_value(value, default) when is_binary(value) do
+ case Integer.parse(String.trim(value)) do
+ {parsed, ""} -> max(parsed, 0)
+ _ -> default
+ end
+ end
+
+ defp integer_value(_value, default), do: default
+
+ defp string_value(nil), do: nil
+
+ defp string_value(value) when is_binary(value) do
+ value
+ |> String.trim()
+ |> case do
+ "" -> nil
+ trimmed -> trimmed
+ end
+ end
+
+ defp string_value(value) when is_atom(value), do: Atom.to_string(value)
+ defp string_value(value) when is_integer(value), do: Integer.to_string(value)
+ defp string_value(_value), do: nil
+
+ defp model_from_flag(command) do
+ case Regex.run(~r/(?:^|\s)--model(?:=|\s+)(?:"([^"]+)"|'([^']+)'|([^\s'"]+))/, command) do
+ [_match | captures] -> Enum.find_value(captures, &string_value/1)
+ _ -> nil
+ end
+ end
+
+ defp model_from_config(command) do
+ case Regex.run(~r/model\s*=\s*\\?["']?(?
Lifetime totals
+<%= format_int(@payload.lifetime_totals.total_tokens) %>
++ <%= format_runtime_seconds(lifetime_runtime_seconds(@payload, @now)) %> / <%= format_int(@payload.lifetime_totals.sessions) %> sessions +
+Runtime
<%= format_runtime_seconds(total_runtime_seconds(@payload, @now)) %>
-Total Codex runtime across completed and active sessions.
++ This run / all time: <%= format_runtime_seconds(total_runtime_seconds(@payload, @now)) %> / <%= format_runtime_seconds(lifetime_runtime_seconds(@payload, @now)) %> +
<%= pretty_value(@payload.rate_limits) %>+
Last completed Codex sessions across Symphony runs.
+No completed sessions recorded.
+ <% else %> +| Issue | +Completed | +Runtime / turns | +Tokens | +State | +Model | +
|---|---|---|---|---|---|
| + <%= session.identifier || session.issue_id || "n/a" %> + | +<%= session.completed_at || "n/a" %> | +<%= format_stored_runtime_and_turns(session.runtime_seconds, session.turns) %> | +
+
+ Total: <%= format_int(session.total_tokens) %>
+ In <%= format_int(session.input_tokens) %> / Out <%= format_int(session.output_tokens) %>
+
+ |
+ + + <%= session.final_state || "completed" %> + + | +<%= session.model || "n/a" %> | +