Skip to content

llj0824/symphony

 
 

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

4 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Symphony

Autonomous work orchestration for coding agents. Symphony polls Linear for issues, creates isolated workspaces, and runs Codex sessions to get work done — without human supervision.

Warning

Symphony is a low-key engineering preview for testing in trusted environments.

Symphony demo video preview


Table of Contents


How It Works

  1. Poll — Orchestrator fetches issues in active states from Linear on a fixed cadence (default 5s)
  2. Dispatch — Eligible issues are sorted by priority, assigned workspace directories, and handed to Agent Runners
  3. Execute — Each Agent Runner launches Codex in app-server mode, renders the WORKFLOW.md prompt template, and runs turns until the issue moves to a terminal state
  4. Observe — Real-time dashboards (terminal ANSI + Phoenix LiveView) and a JSON API surface token usage, rate limits, and per-issue session state
  5. Recover — Exponential backoff retries, stall detection, and reconciliation loops handle failures without operator intervention

Project Layout

symphony/
├── SPEC.md                          # Language-agnostic service specification (the contract)
├── README.md
├── LICENSE                          # Apache 2.0
├── NOTICE
│
├── .github/
│   ├── workflows/
│   │   ├── make-all.yml             # CI: format + lint + test + dialyzer
│   │   └── pr-description-lint.yml  # Enforces PR body format
│   ├── pull_request_template.md
│   └── media/                       # Demo video + screenshots
│
├── .codex/
│   ├── worktree_init.sh             # Git worktree bootstrap for Codex
│   └── skills/                      # Agent skills (invoked by Codex during execution)
│       ├── commit/SKILL.md          # Create logical commits
│       ├── push/SKILL.md            # Push branch to remote
│       ├── pull/SKILL.md            # Sync with origin/main
│       ├── land/SKILL.md            # Merge PR (retry loop until landed)
│       ├── linear/SKILL.md          # Raw Linear GraphQL operations
│       └── debug/SKILL.md           # Troubleshooting utilities
│
└── elixir/                          # Reference implementation (Elixir/OTP)
    ├── mix.exs                      # Project definition + deps
    ├── mix.lock
    ├── Makefile                     # `make all` = format + lint + coverage + dialyzer
    ├── WORKFLOW.md                  # Default workflow: YAML config + Jinja2 prompt template
    ├── AGENTS.md                    # Codex agent instructions for contributing
    ├── mise.toml                    # Elixir/Erlang version pinning
    │
    ├── config/
    │   └── config.exs               # Phoenix + app config (Logger, JSON, endpoint)
    │
    ├── docs/
    │   ├── token_accounting.md      # How token totals are tracked (critical: use absolute, not deltas)
    │   └── logging.md               # Structured log format and conventions
    │
    ├── lib/
    │   ├── symphony_elixir.ex       # Application entry point — starts Orchestrator, WorkflowStore, Supervisor
    │   │
    │   ├── symphony_elixir/
    │   │   ├── orchestrator.ex      # Core brain: poll loop, dispatch, retry queue, reconciliation, state
    │   │   ├── agent_runner.ex      # Per-issue task: workspace setup → Codex turns → cleanup
    │   │   ├── workflow.ex          # Parses WORKFLOW.md (YAML front matter + Jinja2 prompt body)
    │   │   ├── workflow_store.ex    # GenServer: caches + hot-reloads WORKFLOW.md on file change
    │   │   ├── config.ex            # Typed config getters with NimbleOptions validation
    │   │   ├── workspace.ex         # Filesystem workspace lifecycle (create, hooks, remove, cleanup)
    │   │   ├── prompt_builder.ex    # Renders Jinja2 template with issue context variables
    │   │   ├── cli.ex               # CLI entry point (escript) — parses args, starts app
    │   │   ├── tracker.ex           # Behaviour definition for issue tracker adapters
    │   │   ├── log_file.ex          # Per-issue log file writer
    │   │   ├── status_dashboard.ex  # Terminal ANSI dashboard: sparklines, tables, token graphs
    │   │   ├── http_server.ex       # Starts Phoenix endpoint (optional, via config)
    │   │   ├── specs_check.ex       # Validates implementation against SPEC.md requirements
    │   │   │
    │   │   ├── tracker/
    │   │   │   └── memory.ex        # In-memory tracker for testing (no Linear dependency)
    │   │   │
    │   │   ├── linear/              # Linear issue tracker integration
    │   │   │   ├── adapter.ex       # Implements Tracker behaviour — fetch, comment, update state
    │   │   │   ├── client.ex        # GraphQL HTTP client with pagination (batch size 50)
    │   │   │   └── issue.ex         # Issue struct: normalize Linear API → internal model
    │   │   │
    │   │   └── codex/               # Codex app-server integration
    │   │       ├── app_server.ex    # JSON-RPC 2.0 over stdio: init → thread → turns → stop
    │   │       └── dynamic_tool.ex  # Runtime-injected tools (linear_graphql)
    │   │
    │   └── symphony_elixir_web/     # Phoenix web layer (observability dashboard)
    │       ├── endpoint.ex          # Cowboy/Bandit HTTP endpoint
    │       ├── router.ex            # Routes: / (LiveView), /api/v1/* (JSON API)
    │       ├── presenter.ex         # Converts orchestrator state → JSON/template data
    │       ├── observability_pubsub.ex  # PubSub for real-time dashboard updates
    │       ├── static_assets.ex     # Serves dashboard CSS
    │       ├── error_json.ex
    │       ├── error_html.ex
    │       ├── components/
    │       │   └── layouts.ex       # HTML layout for LiveView
    │       ├── live/
    │       │   └── dashboard_live.ex  # Real-time Phoenix LiveView dashboard
    │       └── controllers/
    │           ├── observability_api_controller.ex  # JSON API: GET /state, POST /refresh, GET /:id
    │           └── static_asset_controller.ex
    │
    ├── priv/
    │   └── static/
    │       └── dashboard.css        # Dashboard styles
    │
    └── test/                        # Comprehensive test suite
        ├── test_helper.exs
        ├── support/
        │   ├── test_support.exs     # Shared test helpers
        │   └── snapshot_support.exs # Snapshot testing for dashboard output
        ├── fixtures/                # Golden-file snapshots for status dashboard
        └── symphony_elixir/         # Unit + integration tests per module

Architecture Overview

Symphony follows a single-orchestrator, multi-worker pattern built on Elixir/OTP's fault-tolerant supervision trees.

Abstraction Layers (from SPEC.md):

Layer Responsibility Key Modules
Policy Repo-defined workflow rules WORKFLOW.md
Configuration Typed runtime settings Config, Workflow, WorkflowStore
Coordination Polling, dispatch, retry, reconciliation Orchestrator
Execution Workspace lifecycle + agent subprocess AgentRunner, Workspace, AppServer
Integration External API normalization Linear.Adapter, Linear.Client
Observability Operator visibility StatusDashboard, DashboardLive, API controller

Schemas

Issue Model

The normalized issue record used across orchestration, prompt rendering, and observability.

%Issue{
  id:             string        # Linear internal UUID
  identifier:     string        # Human-readable key, e.g. "PROJ-123"
  title:          string
  description:    string | nil
  priority:       1..4 | nil    # Lower = higher priority in dispatch sorting
  state:          string        # "Todo", "In Progress", "Done", etc.
  branch_name:    string | nil  # Git branch from Linear
  url:            string | nil  # Linear issue URL
  assignee_id:    string | nil
  labels:         [string]      # Normalized to lowercase
  blocked_by:     [%{type, issue: %{id, identifier, state}}]
  created_at:     DateTime | nil
  updated_at:     DateTime | nil
}

Orchestrator State

In-memory GenServer state — no database required.

%State{
  poll_interval_ms:          integer         # Polling cadence (default 5000)
  max_concurrent_agents:     integer         # Global concurrency cap (default 10)
  running:                   %{issue_id => RunningEntry}
  completed:                 MapSet<issue_id>
  claimed:                   MapSet<issue_id>
  retry_attempts:            %{issue_id => RetryEntry}
  codex_totals:              %{input_tokens, output_tokens, total_tokens, seconds_running}
  codex_rate_limits:         %{...}         # Extracted from Codex token usage events
}

%RunningEntry{
  pid:                  pid             # Task process
  ref:                  reference       # Monitor ref
  identifier:          string          # "PROJ-123"
  issue:               %Issue{}
  session_id:          string | nil    # "thread-{id}-turn-{n}"
  turn_count:          integer
  retry_attempt:       integer | nil
  started_at:          DateTime
  codex_input_tokens:  integer
  codex_output_tokens: integer
  codex_total_tokens:  integer
  last_codex_event:    atom | nil
  last_codex_message:  string | nil
  last_codex_timestamp: DateTime | nil
}

%RetryEntry{
  attempt:     integer
  timer_ref:   reference
  due_at_ms:   integer
  identifier:  string
  error:       string | nil
}

Configuration Schema

Defined in WORKFLOW.md YAML front matter, validated by NimbleOptions:

tracker:
  kind: "linear" | "memory"           # Adapter selection
  endpoint: "https://api.linear.app/graphql"
  api_key: "$LINEAR_API_KEY"           # Env var indirection
  project_slug: "my-project"
  assignee: "optional-user-filter"
  active_states: ["Todo", "In Progress"]
  terminal_states: ["Done", "Closed", "Cancelled", "Duplicate"]

polling:
  interval_ms: 5000

workspace:
  root: "~/code/symphony-workspaces"   # Expands ~ and $ENV

agent:
  max_concurrent_agents: 10
  max_turns: 20                        # Turns per agent session
  max_retry_backoff_ms: 300000         # 5 min cap on exponential backoff
  max_concurrent_agents_by_state:      # Optional per-state limits
    "Todo": 5
    "In Progress": 8

codex:
  command: "codex app-server"
  approval_policy: "never" | "on-request" | {reject: {...}}
  thread_sandbox: "workspace-write"
  turn_sandbox_policy:
    type: "workspaceWrite"
    root: "/path/to/workspace"
  turn_timeout_ms: 3600000             # 1 hour
  read_timeout_ms: 5000
  stall_timeout_ms: 300000             # 5 min stall → kill

hooks:
  after_create: "git clone ..."        # Shell cmd after workspace created
  before_run: "npm install"            # Shell cmd before each agent run
  after_run: "cleanup.sh"
  before_remove: "backup.sh"
  timeout_ms: 60000

server:
  port: 4000                           # nil = disabled
  host: "127.0.0.1"

observability:
  dashboard_enabled: true
  refresh_ms: 1000
  render_interval_ms: 16

Codex App-Server Protocol (JSON-RPC)

Communication with Codex uses JSON-RPC 2.0 over stdio:

SESSION LIFECYCLE:
  → initialize             (capabilities exchange)
  ← initialized
  → thread/start           (sandbox policy, cwd, dynamic tools)
  ← thread_id

PER-TURN:
  → turn/start             (prompt, issue metadata, workspace path)
  ← notifications...       (progress, tool calls, token usage)
  ← turn/completed

  Tool call flow:
    ← request: {method: "tool/call", params: {name: "linear_graphql", arguments: {...}}}
    → response: {result: {success: true, contentItems: [...]}}

TOKEN REPORTING:
  ← thread/tokenUsage/updated   (absolute totals per thread — NOT deltas)

SESSION END:
  → close port

Dynamic Tools Injected:

Tool Input Output
linear_graphql {query: string, variables?: object} Linear GraphQL API response

Observability API

Phoenix-based JSON API at http://localhost:4000:

Endpoint Method Description
/api/v1/state GET Full orchestrator snapshot (running, retrying, tokens, rate limits)
/api/v1/refresh POST Trigger immediate poll cycle (returns 202)
/api/v1/:issue_identifier GET Detailed state for a specific issue

Response shape for GET /api/v1/state:

{
  "generated_at": "2025-03-05T12:00:00Z",
  "counts": {"running": 3, "retrying": 1},
  "running": [{
    "issue_id": "uuid",
    "issue_identifier": "PROJ-123",
    "state": "In Progress",
    "session_id": "thread-abc-turn-1",
    "turn_count": 2,
    "last_event": "message_received",
    "started_at": "...",
    "tokens": {"input": 5000, "output": 2000, "total": 7000}
  }],
  "retrying": [{
    "issue_id": "uuid",
    "issue_identifier": "PROJ-124",
    "attempt": 3,
    "due_at": "...",
    "error": "agent exited: timeout"
  }],
  "codex_totals": {"input_tokens": 50000, "output_tokens": 20000, "total_tokens": 70000},
  "rate_limits": {}
}

Web Dashboard: Real-time Phoenix LiveView at / with PubSub-driven updates (same data as terminal dashboard).


Diagrams

System Context Diagram

Shows Symphony's place in the broader ecosystem.

graph TB
    Engineer["Engineer
    (manages work via Linear)"]

    Symphony["Symphony Service
    (Elixir/OTP)"]

    Linear["Linear
    (Issue Tracker)"]

    Codex["Codex
    (AI Coding Agent)"]

    Git["Git Remote
    (GitHub/GitLab)"]

    Filesystem["Local Filesystem
    (Workspaces)"]

    Engineer -->|creates/reviews issues| Linear
    Symphony -->|polls issues, reads state| Linear
    Symphony -->|spawns app-server sessions| Codex
    Codex -->|creates commits, PRs| Git
    Codex -->|updates issues, comments| Linear
    Symphony -->|creates/manages workspaces| Filesystem
    Codex -->|reads/writes code in workspace| Filesystem
    Engineer -->|monitors via dashboard| Symphony
Loading

Component Architecture Diagram

Internal module relationships within the Elixir implementation.

graph TB
    subgraph "Symphony Application"
        CLI["CLI
        (escript entry)"]

        subgraph "Coordination Layer"
            Orch["Orchestrator
            (GenServer)
            poll loop, dispatch,
            retry queue, reconciliation"]
        end

        subgraph "Configuration Layer"
            WFStore["WorkflowStore
            (GenServer)
            hot-reload on change"]
            WF["Workflow
            parse YAML + Jinja2"]
            Config["Config
            typed getters,
            NimbleOptions validation"]
        end

        subgraph "Execution Layer"
            TaskSup["TaskSupervisor"]
            AR["AgentRunner
            (per-issue Task)"]
            WS["Workspace
            create, hooks, cleanup"]
            PB["PromptBuilder
            Jinja2 rendering"]

            subgraph "Codex Integration"
                AS["AppServer
                JSON-RPC 2.0 / stdio"]
                DT["DynamicTool
                linear_graphql"]
            end
        end

        subgraph "Integration Layer"
            Tracker["Tracker Behaviour"]
            LA["Linear.Adapter"]
            LC["Linear.Client
            GraphQL + pagination"]
            TM["Tracker.Memory
            (test only)"]
        end

        subgraph "Observability Layer"
            SD["StatusDashboard
            terminal ANSI UI"]
            HTTP["HttpServer
            (Phoenix)"]
            DL["DashboardLive
            (LiveView)"]
            API["ObservabilityAPI
            JSON endpoints"]
            PS["PubSub"]
            Pres["Presenter
            state → view data"]
        end
    end

    CLI --> Orch
    Orch --> WFStore
    WFStore --> WF
    WF --> Config
    Orch --> TaskSup
    TaskSup --> AR
    AR --> WS
    AR --> PB
    AR --> AS
    AS --> DT
    Orch --> Tracker
    Tracker --> LA
    Tracker --> TM
    LA --> LC
    DT --> LC
    Orch --> SD
    Orch --> PS
    PS --> DL
    HTTP --> DL
    HTTP --> API
    API --> Pres
    DL --> Pres
Loading

Orchestrator Poll Cycle (Sequence Diagram)

What happens every 5 seconds inside the orchestrator.

sequenceDiagram
    participant Timer as Tick Timer
    participant Orch as Orchestrator
    participant Linear as Linear API
    participant TaskSup as TaskSupervisor
    participant AR as AgentRunner

    Timer->>Orch: :tick
    Orch->>Orch: schedule :run_poll_cycle

    Note over Orch: Reconciliation Phase
    Orch->>Linear: fetch_issue_states_by_ids(running_ids)
    Linear-->>Orch: current states
    Orch->>Orch: stop tasks for terminal/unassigned issues
    Orch->>Orch: kill stalled tasks (> stall_timeout)

    Note over Orch: Dispatch Phase
    Orch->>Linear: fetch_candidate_issues(active_states)
    Linear-->>Orch: candidate issues
    Orch->>Orch: sort by priority + created_at

    loop For each eligible issue
        Orch->>Orch: check: not running, not claimed, slots available, not blocked
        Orch->>TaskSup: start_child(AgentRunner)
        TaskSup->>AR: spawn Task
        AR-->>Orch: {:codex_worker_update, ...} (ongoing)
    end

    Note over Orch: Retry Phase
    Orch->>Orch: check retry_queue for due entries
    loop For each due retry
        Orch->>TaskSup: start_child(AgentRunner, retry_attempt: N)
    end

    Note over Orch: Agent Exit Handling
    AR-->>Orch: {:DOWN, ref, :process, pid, reason}
    alt Normal exit
        Orch->>Orch: schedule continuation check (1s)
    else Error exit
        Orch->>Orch: exponential backoff → retry_queue
    end
Loading

Agent Runner Execution (Sequence Diagram)

The lifecycle of a single issue being worked on.

sequenceDiagram
    participant Orch as Orchestrator
    participant AR as AgentRunner
    participant WS as Workspace
    participant PB as PromptBuilder
    participant Codex as Codex AppServer
    participant Linear as Linear API
    participant DT as DynamicTool

    Orch->>AR: run(issue, opts)

    AR->>WS: create_for_issue(issue)
    WS->>WS: mkdir workspace dir
    WS->>WS: run after_create hook (e.g. git clone)
    WS-->>AR: workspace_path

    AR->>WS: run before_run hook

    AR->>Codex: initialize (open port)
    Codex-->>AR: capabilities
    AR->>Codex: thread/start (sandbox, cwd, tools)
    Codex-->>AR: thread_id

    loop Turn 1..max_turns
        alt Turn 1
            AR->>PB: build_prompt(issue, attempt)
            PB-->>AR: rendered Jinja2 prompt
        else Turn N > 1
            AR->>AR: build continuation prompt
        end

        AR->>Codex: turn/start (prompt)

        loop Codex execution
            Codex->>DT: tool/call: linear_graphql
            DT->>Linear: GraphQL request
            Linear-->>DT: response
            DT-->>Codex: tool result
        end

        Codex-->>AR: turn/completed
        Codex-->>AR: thread/tokenUsage/updated
        AR-->>Orch: {:codex_worker_update, tokens}

        AR->>Linear: fetch issue state
        alt Still in active state
            AR->>AR: continue to next turn
        else Terminal state
            AR->>AR: break loop
        end
    end

    AR->>Codex: close port
    AR->>WS: run after_run hook
    AR-->>Orch: exit (normal)
Loading

Data Flow Diagram

How data moves through the system from external sources to outputs.

graph LR
    subgraph "External Inputs"
        LI["Linear Issues
        (GraphQL API)"]
        WMD["WORKFLOW.md
        (YAML + Jinja2)"]
        ENV["Environment
        ($LINEAR_API_KEY, etc.)"]
    end

    subgraph "Processing"
        WFL["Workflow Loader
        parse + validate"]
        CFG["Config Layer
        typed getters"]
        POLL["Poll Cycle
        fetch + filter + sort"]
        DISP["Dispatcher
        eligibility + slots"]
        EXEC["Agent Execution
        workspace + Codex turns"]
    end

    subgraph "External Outputs"
        FS["Filesystem
        workspace dirs + logs"]
        GIT["Git Commits + PRs"]
        LO["Linear Updates
        state changes, comments"]
    end

    subgraph "Observability Outputs"
        TERM["Terminal Dashboard
        ANSI sparklines + tables"]
        WEB["Web Dashboard
        Phoenix LiveView"]
        JAPI["JSON API
        /api/v1/state"]
    end

    WMD --> WFL
    ENV --> CFG
    WFL --> CFG
    LI --> POLL
    CFG --> POLL
    POLL --> DISP
    DISP --> EXEC
    EXEC --> FS
    EXEC --> GIT
    EXEC --> LO
    EXEC -->|token events| TERM
    EXEC -->|PubSub| WEB
    EXEC -->|state snapshot| JAPI
Loading

Issue State Machine

How Symphony treats Linear issue states (as defined in WORKFLOW.md).

stateDiagram-v2
    [*] --> Backlog: created
    Backlog --> Todo: triaged

    state "Active States (Symphony picks up)" as active {
        Todo --> InProgress: agent dispatched
        InProgress --> HumanReview: PR ready
        HumanReview --> Merging: human approves
        Merging --> InProgress: merge conflict
        HumanReview --> Rework: feedback
        Rework --> InProgress: agent re-executes
    }

    state "Terminal States (Symphony stops)" as terminal {
        Done
        Closed
        Cancelled
        Duplicate
    }

    InProgress --> Done: work complete + merged
    Merging --> Done: PR landed
    Todo --> Cancelled: cancelled
    InProgress --> Cancelled: cancelled
    HumanReview --> Closed: rejected
    Todo --> Duplicate: duplicate found

    note right of active
        Symphony only dispatches agents
        for issues in active states.
        Default: Todo, In Progress
    end note

    note right of terminal
        Agent tasks are killed when
        issue enters terminal state.
    end note
Loading

Running Symphony

Requirements

Symphony works best in codebases that have adopted harness engineering.

Option 1: Make your own

Tell your favorite coding agent to build Symphony in any language:

Implement Symphony according to the following spec: https://github.com/openai/symphony/blob/main/SPEC.md

Option 2: Use the Elixir reference implementation

See elixir/README.md for setup instructions, or ask your coding agent:

Set up Symphony for my repository based on https://github.com/openai/symphony/blob/main/elixir/README.md


License

This project is licensed under the Apache License 2.0.

About

Symphony turns project work into isolated, autonomous implementation runs, allowing teams to manage work instead of supervising coding agents.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages

  • Elixir 94.9%
  • Python 3.5%
  • CSS 1.4%
  • Other 0.2%