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
32 changes: 29 additions & 3 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -1306,6 +1306,12 @@ should return:
- `output_tokens`
- `total_tokens`
- `seconds_running` (aggregate runtime seconds as of snapshot time, including active sessions)
- `token_usage` (optional durable token summary across completed and active sessions)
- `input_tokens`
- `output_tokens`
- `total_tokens`
- `issue_count`
- `session_count`
- `rate_limits` (latest coding-agent rate limit payload, if available)

Recommended snapshot error modes:
Expand Down Expand Up @@ -1336,6 +1342,10 @@ 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 also persist append-only token observations for durable per-issue
observability. If they do, summarize by taking the high-water cumulative totals per
`(issue_identifier, session_id)` and then summing those session totals; do not sum every observed
event.

Runtime accounting:

Expand Down Expand Up @@ -1445,6 +1455,13 @@ Minimum endpoints:
"total_tokens": 7400,
"seconds_running": 1834.2
},
"token_usage": {
"input_tokens": 5000,
"output_tokens": 2400,
"total_tokens": 7400,
"issue_count": 2,
"session_count": 3
},
"rate_limits": null
}
```
Expand Down Expand Up @@ -1498,12 +1515,21 @@ Minimum endpoints:
}
],
"last_error": null,
"tracked": {}
"tracked": {},
"token_usage": {
"input_tokens": 1200,
"output_tokens": 800,
"total_tokens": 2000,
"session_count": 1
}
}
```

- If the issue is unknown to the current in-memory state, return `404` with an error response (for
example `{\"error\":{\"code\":\"issue_not_found\",\"message\":\"...\"}}`).
- If the issue is unknown to the current in-memory state but exists in a durable token ledger, an
implementation may return an inactive issue payload with token usage.
- If the issue is unknown to both the current in-memory state and durable observability state,
return `404` with an error response (for example
`{\"error\":{\"code\":\"issue_not_found\",\"message\":\"...\"}}`).

- `POST /api/v1/refresh`
- Queues an immediate tracker poll + reconciliation cycle (best-effort trigger; implementations
Expand Down
12 changes: 12 additions & 0 deletions elixir/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,12 @@ 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 also writes durable Codex token usage observations to `token_usage.jsonl` next to the
configured log file. With the default log path, this is `./log/token_usage.jsonl`; with
`--logs-root`, it follows the same log root. The ledger stores cumulative high-water token totals
per issue/session so completed tickets can still be inspected after the in-memory dashboard state
has moved on.

The `WORKFLOW.md` file uses YAML front matter for configuration, plus a Markdown body used as the
Codex session prompt.

Expand Down Expand Up @@ -160,6 +166,12 @@ 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 JSON API includes durable token summaries from `token_usage.jsonl`:

- `/api/v1/state` includes `token_usage` totals plus issue/session counts.
- `/api/v1/<issue_identifier>` can return `status: "inactive"` with `token_usage` for a completed
or otherwise inactive issue that is no longer present in the live running/retry state.

## Project Layout

- `lib/`: application code and Mix tasks
Expand Down
13 changes: 13 additions & 0 deletions elixir/docs/token_accounting.md
Original file line number Diff line number Diff line change
Expand Up @@ -285,11 +285,23 @@ That is a strong signal for Symphony:
- use absolute totals as the main accounting surface
- ignore last/delta values for totals

## Durable Per-Issue Ledger

The Elixir reference implementation persists token observations to `token_usage.jsonl` next to the
configured log file. Each line is an append-only JSON object containing the issue identifier, Codex
session id, source event, final/non-final marker, and cumulative input/output/total token values.

The ledger is summarized by taking the maximum observed totals per `(issue_identifier, session_id)`
and then summing those session high-water marks. This makes repeated live updates, retries, and
final snapshots safe to append without double-counting. The ledger remains observability data only:
it is not a billing surface and does not apply pricing or model-specific cost rules.

## Recommended Symphony Documentation Contract

If Symphony documents token reporting externally, the contract should be:

- Live token totals come from Codex thread-scoped cumulative usage.
- Durable per-issue totals come from high-water cumulative totals per Codex session.
- Incremental usage may also be emitted, but Symphony does not use it for totals.
- Turn-completed usage is event-specific and should not be assumed to be a fresh additive increment.
- Reporting is thread-based, and multiple turns can occur on one thread.
Expand All @@ -300,5 +312,6 @@ If Symphony documents token reporting externally, the contract should be:
- Fallback to `info.total_token_usage`
- Ignore `last` for totals
- Key totals by `thread_id`
- Persist high-water totals by `(issue_identifier, session_id)`
- Do not classify generic `usage` by field name alone
- Do not double-count turn-completed usage after live updates
49 changes: 48 additions & 1 deletion elixir/lib/symphony_elixir/orchestrator.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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, StatusDashboard, TokenUsageLedger, Tracker, Workspace}
alias SymphonyElixir.Linear.Issue

@continuation_retry_delay_ms 1_000
Expand Down Expand Up @@ -190,6 +190,7 @@ defmodule SymphonyElixir.Orchestrator do

running_entry ->
{updated_running_entry, token_delta} = integrate_codex_update(running_entry, update)
:ok = append_token_usage_observation(issue_id, updated_running_entry, update, false)

state =
state
Expand Down Expand Up @@ -1275,6 +1276,7 @@ defmodule SymphonyElixir.Orchestrator do
end

defp record_session_completion_totals(state, running_entry) when is_map(running_entry) do
:ok = append_token_usage_observation(running_entry_issue_id(running_entry), running_entry, nil, true)
runtime_seconds = running_seconds(running_entry.started_at, DateTime.utc_now())

codex_totals =
Expand All @@ -1293,6 +1295,51 @@ defmodule SymphonyElixir.Orchestrator do

defp record_session_completion_totals(state, _running_entry), do: state

defp append_token_usage_observation(issue_id, running_entry, update, final?) when is_map(running_entry) do
if token_usage_observation?(running_entry) do
TokenUsageLedger.append_observation(%{
observed_at: DateTime.utc_now(),
final: final?,
issue_id: issue_id,
issue_identifier: Map.get(running_entry, :identifier),
session_id: Map.get(running_entry, :session_id),
worker_host: Map.get(running_entry, :worker_host),
workspace_path: Map.get(running_entry, :workspace_path),
turn_count: 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),
source_event: token_usage_source_event(update, final?)
})
end

:ok
end

defp token_usage_observation?(running_entry) do
is_binary(Map.get(running_entry, :session_id)) and
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 token_usage_source_event(_update, true), do: :session_final

defp token_usage_source_event(%{event: event}, _final?), do: event

defp token_usage_source_event(_update, _final?), do: nil

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 refresh_runtime_config(%State{} = state) do
config = Config.settings!()

Expand Down
Loading
Loading