6개월 전, 내부 생산성 도구를 개발하던 중 우리 팀은 당시로서는 논란의 여지가 있던 결정을 내렸습니다. 리포지터리에 사람이 직접 쓴 코드를 전혀 두지 않고 만들겠다는 것이었습니다. 프로젝트 리포지터리의 모든 줄은 Codex가 생성해야 했습니다.
이를 가능하게 하기 위해 엔지니어링 워크플로를 처음부터 다시 설계했습니다. 에이전트 친화적인 리포지터리를 만들고, 자동화된 테스트와 가드레일에 크게 투자했으며, Codex를 완전한 팀원처럼 대했습니다. 그 여정은 이전 하네스 엔지니어링에 관한 블로그 게시물에서 다뤘습니다.
그리고 실제로 효과가 있었지만, 곧 다음 병목에 부딪혔습니다. 바로 컨텍스트 전환이었습니다.
이 새로운 문제를 해결하기 위해 우리는 Symphony라는 시스템을 만들었습니다. Symphony(새 창에서 열기)는 Linear 같은 프로젝트 관리 보드를 코딩 에이전트를 위한 제어 평면으로 바꿔주는 에이전트 오케스트레이터입니다. 열려 있는 모든 작업에는 에이전트가 하나씩 배정되고, 에이전트는 계속 실행되며, 사람은 그 결과를 검토합니다.
이 글에서는 Symphony를 어떻게 만들었는지, 그 결과 일부 팀에서 실제로 반영된 풀 리퀘스트가 500% 증가했는지, 그리고 여러분의 이슈 트래커를 상시 가동되는 에이전트 오케스트레이터로 바꾸기 위해 이를 어떻게 사용할 수 있는지를 설명합니다.
인터랙티브 코딩 에이전트의 한계
웹 앱이든 CLI든 사용 방식이 더 쉬워지고는 있지만, 코딩 에이전트는 여전히 인터랙티브 도구입니다.
OpenAI에서 에이전트 기반 작업의 규모가 커지면서 우리는 새로운 종류의 부담을 발견했습니다. 각 엔지니어는 Codex 세션 몇 개를 열고, 작업을 할당하고, 결과를 검토하고, 에이전트의 방향을 잡아주고, 이를 반복했습니다. 실제로 대부분의 사람은 컨텍스트 전환이 부담스러워지기 전까지 한 번에 3~5개의 세션 정도를 무리 없이 관리할 수 있었습니다. 그 이상이 되면 생산성이 떨어졌습니다. 어떤 세션이 무엇을 하고 있는지 잊어버리고, 에이전트를 다시 궤도에 올려놓기 위해 터미널 사이를 오가고, 중간에 멈춘 장기 실행 작업을 디버깅해야 했습니다.
에이전트는 빨랐지만 시스템의 병목은 인간의 주의력이었습니다. 사실상 우리는 매우 유능한 주니어 엔지니어 팀을 만든 다음, 인간 엔지니어들에게 그들을 일일이 관리하도록 한 셈이었습니다. 이러한 방식으로는 확장할 수 없었습니다.
관점의 전환
우리는 잘못된 것을 최적화하고 있다는 사실을 깨달았습니다. 시스템을 코딩 세션과 머지된 PR 중심으로 설계하고 있었지만, PR과 세션은 사실 목적이 아니라 수단입니다. 소프트웨어 워크플로는 대체로 이슈, 작업, 티켓, 마일스톤 같은 산출물을 중심으로 구성됩니다.
그래서 우리는 에이전트를 직접 감독하는 대신, 작업 추적기에서 스스로 일을 가져가게 하면 어떻게 될지 자문했습니다.
그 아이디어가 에이전트 작업을 오케스트레이션하는 감독자 역할을 하는 문서화된 사양, Symphony가 되었습니다.
이슈 트래커를 에이전트 오케스트레이터로 바꾸기
Symphony는 단순한 개념에서 시작했습니다. 열려 있는 모든 작업은 에이전트가 맡아 완료해야 한다는 것입니다. 여러 탭에서 Codex 세션을 관리하는 대신, 우리는 이슈 트래커를 제어 평면으로 만들었습니다.
이 설정에서는 각 열린 Linear 이슈가 전용 에이전트 워크스페이스에 매핑됩니다. Symphony는 작업 보드를 지속적으로 감시하며, 모든 활성 작업에 대해 완료될 때까지 지속적으로 실행되는 에이전트가 있도록 보장합니다. 에이전트가 크래시하거나 멈추면 Symphony가 다시 시작합니다. 새 작업이 나타나면 Symphony가 이를 받아서 작업을 정리하고 처리하기 시작합니다.
우리는 티켓 상태를 기반으로 워크플로를 구축했고, 작업 관리 도구인 Linear를 상태 머신처럼 활용했습니다.
실제로 Symphony는 작업을 세션과 풀 리퀘스트로부터 분리합니다. 어떤 이슈는 여러 리포지터리에 걸쳐 여러 PR을 만들고, 어떤 이슈는 코드베이스를 전혀 건드리지 않는 순수한 조사나 분석일 수도 있습니다.
이런 방식으로 작업을 추상화하면, 티켓은 훨씬 더 큰 작업 단위를 나타낼 수 있습니다.
우리는 정기적으로 Symphony를 사용해 복잡한 기능과 인프라 마이그레이션을 오케스트레이션합니다. 예를 들어 에이전트에게 코드베이스, Slack, Notion을 분석하고 구현 계획을 만들라고 요청하는 작업을 등록할 수 있습니다. 계획이 만족스러우면 에이전트가 작업 트리를 생성해 일을 단계별로 나누고, 작업 간 의존성을 정의합니다.
에이전트는 막혀 있지 않은 작업에서만 일을 시작하므로, 이 DAG(실행 단계의 순서)에서는 실행이 자연스럽고 최적으로 병렬 전개됩니다. 아래 예시에서는 React 업그레이드가 Vite 마이그레이션에 막혀 있도록 표시했습니다. 예상대로 에이전트는 Vite 마이그레이션이 완료된 뒤에야 React 업그레이드를 시작했습니다.
에이전트는 스스로 작업을 만들 수도 있습니다. 구현이나 검토 중에 현재 작업 범위를 벗어나는 개선점, 즉 성능 문제, 리팩터링 기회, 더 나은 아키텍처를 자주 발견합니다. 그럴 때는 새 이슈를 등록하기만 하면 되고, 우리는 나중에 이를 평가하고 일정을 잡을 수 있습니다. 이런 후속 작업 중 상당수도 에이전트가 다시 맡게 됩니다. 우리는 이 과정을 감독하지만, 에이전트는 질서를 유지하며 작업을 계속 전진시킵니다.
이런 작업 방식은 모호한 일을 시작할 때 드는 인지 비용을 극적으로 줄여줍니다. 에이전트가 뭔가를 잘못하더라도 그 역시 유용한 정보이며, 우리에게 드는 비용은 거의 0에 가깝습니다. 에이전트가 프로토타입을 만들고 탐색해 보도록 아주 저렴한 비용으로 티켓을 등록할 수 있고, 마음에 들지 않는 탐색 결과는 버리면 됩니다.
오케스트레이터는 개발자 박스에서 실행되고 결코 잠들지 않기 때문에, 어디서든 작업을 추가하면 에이전트가 이를 맡을 것이라는 확신을 가질 수 있습니다. 실제로 우리 팀 엔지니어 한 명은 신호가 불안정한 와이파이가 잡히는 아늑한 오두막에서 휴대폰의 Linear 앱으로 세 가지 중요한 변경을 처리했습니다.
이런 방식으로 일할 때 탐색이 늘어남
Symphony와 함께 일한 효과를 관찰했을 때 가장 눈에 띈 변화는 산출물이었습니다. OpenAI의 일부 팀에서는 첫 3주 동안 머지된 PR 수가 6배 증가했습니다. OpenAI 밖에서는 Linear 공동창업자 Karri Saarinen이 Symphony 출시 이후 생성된 워크스페이스 급증(새 창에서 열기)을 언급했습니다. 하지만 더 깊은 변화는 팀이 일을 바라보는 방식입니다.
우리 엔지니어들이 더 이상 Codex 세션을 감독하는 데 시간을 쓰지 않게 되면서, 코드 변경의 경제성이 완전히 달라졌습니다. 구현 자체를 밀어붙이는 데 인간의 노력을 더 이상 들이지 않기 때문에 각 변경의 체감 비용이 낮아집니다.
그로 인해 우리의 행동도 바뀌었습니다. 이제 Symphony에서 추측성 작업을 띄우는 일이 아주 쉬워졌습니다. 아이디어를 시도하고, 리팩터링을 탐색하고, 가설을 시험한 뒤, 가능성이 있어 보이는 결과만 남기면 됩니다.
또한 누가 작업을 시작할 수 있는지도 넓어집니다. 이제 우리 제품 관리자와 디자이너도 직접 Symphony에 기능 요청을 등록할 수 있습니다. 리포지터리를 체크아웃하거나 Codex 세션을 관리할 필요가 없습니다. 기능을 설명하면 실제 제품 안에서 해당 기능이 작동하는 모습을 담은 비디오 워크스루를 포함한 검토 패키지를 받게 됩니다.
Symphony는 대규모 모노레포(예: OpenAI에서 사용하는 것과 같은 환경)에서도 특히 강점을 발휘합니다. PR을 실제로 반영하는 마지막 단계는 느리고 불안정하기 쉬운데, 이 시스템은 CI를 모니터링하고 필요할 때 리베이스를 수행하며, 충돌을 해결하고, 불안정한 체크를 재시도하는 등 변경 사항이 파이프라인을 통과하도록 전반적으로 관리합니다. 티켓이 병합 단계에 도달할 때쯤이면, 사람의 개입 없이도 해당 변경이 메인 브랜치에 반영될 것이라는 높은 확신을 가질 수 있습니다.
진전에는 새롭고 다른 문제가 따른다
이 수준으로 운영하는 데에는 트레이드오프가 따릅니다. 에이전트를 인터랙티브하게 제어하던 방식에서 티켓 단위로 작업을 할당하는 방식으로 옮기면서, 작업 진행 중간에 계속 방향을 잡아주고 필요할 때 경로를 수정해 주는 능력은 잃었습니다. 때로는 에이전트가 완전히 빗나간 결과물을 내놓기도 했습니다. 하지만 그것도 유용했습니다. 그런 실패가 시스템의 빈틈을 드러내 주었고, 이를 더 견고하게 만드는 데 도움이 되었기 때문입니다.
결과를 수동으로 수정하는 대신, 다음번에는 에이전트가 성공할 수 있도록 가드레일과 역량을 추가했습니다. 시간이 지나면서 이는 하네스에 엔드투엔드 테스트 실행, Chrome DevTools를 통한 앱 조작, QA 스모크 테스트 관리 같은 새 기능을 더하게 만들었습니다. 또한 문서를 크게 개선하고 무엇이 좋은 결과인지 더 명확히 했습니다.
모든 작업이 Symphony 스타일의 업무 방식에 맞는 것은 아닙니다. 특히 모호한 문제나 강한 판단력과 전문성이 필요한 작업은 여전히 엔지니어가 인터랙티브한 Codex 세션과 직접 작업해야 합니다. 실제로 이런 일들이 대개 엔지니어들이 시간을 들이기에 가장 흥미롭고 즐거운 작업입니다.
차이는 Symphony가 일상적인 구현 작업의 대부분을 처리할 수 있다는 점입니다. 덕분에 엔지니어는 더 작은 작업들 사이를 계속 오가며 컨텍스트를 전환하는 대신, 한 번에 하나의 어려운 문제에 집중할 수 있습니다.
또한 에이전트를 상태 머신 안의 경직된 노드처럼 다루는 방식은 잘 작동하지 않는다는 점도 배웠습니다. 모델은 점점 더 똑똑해지고, 우리가 제한하려는 틀보다 더 큰 문제를 해결할 수 있습니다. 예를 들어 초기 버전에서는 GitHub 연동이 모두 외부 하네스의 일부였습니다. 다시 말해, 초기 버전은 Codex가 코드 변경만 하기를 기대했고, 나머지 프로세스(변경 제출, 테스트 실행)는 코드로 지정했습니다. 초기의 에이전트 작업에서는 Codex에게 작업 구현만 요청했던 것입니다. 하지만 그 접근은 너무 제한적이었습니다. Codex는 여러 PR을 만드는 것은 물론, 리뷰 피드백을 읽고 이를 반영하는 일도 충분히 해낼 수 있습니다. 그래서 우리는 gh CLI, CI 로그를 읽는 역량 같은 도구를 제공했고, 이제 Codex에게 오래된 PR 닫기나 완료된 작업과 중단된 작업에 대한 보고서 가져오기 같은 더 많은 일을 맡길 수 있게 되었습니다. 이런 유형의 작업은 초기의 기능 구현 범주를 한참 벗어나는 것이었습니다.
그래서 결국 우리는 엄격한 전이 대신 에이전트에게 목표를 부여하는 쪽으로 옮겨갔습니다. 이는 훌륭한 관리자가 팀의 직속 보고자에게 목표를 부여하는 방식과 비슷합니다. 모델의 힘은 추론 능력에서 나오므로, 도구와 맥락을 주고 자율적으로 수행하도록 하세요.
Symphony를 사용해 Symphony 만들기
Symphony 리포지터리를 열면 가장 먼저 눈에 띄는 점은, 기술적으로 Symphony가 그저 SPEC.md 파일, 즉 문제와 의도한 해법의 정의라는 사실입니다. 복잡한 감독 시스템을 구축하는 대신, 우리는 문제와 의도한 해법을 정의해 에이전트에게 높은 수준의 방향성을 주었습니다.
Markdown
1# Symphony Service Specification2
3Status: Draft v1 (language-agnostic)4
5Purpose: Define a service that orchestrates coding agents to get project work done.6
7## 1. Problem Statement8
9Symphony is a long-running automation service that continuously reads work from an issue tracker10(Linear in this specification version), creates an isolated workspace for each issue, and runs a11coding agent session for that issue inside the workspace.12
13The service solves four operational problems:14
15- It turns issue execution into a repeatable daemon workflow instead of manual scripts.16- It isolates agent execution in per-issue workspaces so agent commands run only inside per-issue17 workspace directories.18- It keeps the workflow policy in-repo (`WORKFLOW.md`) so teams version the agent prompt and runtime19 settings with their code.20- It provides enough observability to operate and debug multiple concurrent agent runs.21
22Implementations are expected to document their trust and safety posture explicitly. This23specification does not require a single approval, sandbox, or operator-confirmation policy; some24implementations may target trusted environments with a high-trust configuration, while others may25require stricter approvals or sandboxing.26
27Important boundary:28
29- Symphony is a scheduler/runner and tracker reader.30- Ticket writes (state transitions, comments, PR links) are typically performed by the coding agent31 using tools available in the workflow/runtime environment.32- A successful run may end at a workflow-defined handoff state (for example `Human Review`), not33 necessarily `Done`.34
35## 2. Goals and Non-Goals36
37### 2.1 Goals38
39- Poll the issue tracker on a fixed cadence and dispatch work with bounded concurrency.40- Maintain a single authoritative orchestrator state for dispatch, retries, and reconciliation.41- Create deterministic per-issue workspaces and preserve them across runs.42- Stop active runs when issue state changes make them ineligible.43- Recover from transient failures with exponential backoff.44- Load runtime behavior from a repository-owned `WORKFLOW.md` contract.45- Expose operator-visible observability (at minimum structured logs).46- Support restart recovery without requiring a persistent database.47
48### 2.2 Non-Goals49
50- Rich web UI or multi-tenant control plane.51- Prescribing a specific dashboard or terminal UI implementation.52- General-purpose workflow engine or distributed job scheduler.53- Built-in business logic for how to edit tickets, PRs, or comments. (That logic lives in the54 workflow prompt and agent tooling.)55- Mandating strong sandbox controls beyond what the coding agent and host OS provide.56- Mandating a single default approval, sandbox, or operator-confirmation posture for all57 implementations.58
59## 3. System Overview60
61### 3.1 Main Components62
631. `Workflow Loader`64 - Reads `WORKFLOW.md`.65 - Parses YAML front matter and prompt body.66 - Returns `{config, prompt_template}`.67
682. `Config Layer`69 - Exposes typed getters for workflow config values.70 - Applies defaults and environment variable indirection.71 - Performs validation used by the orchestrator before dispatch.72
733. `Issue Tracker Client`74 - Fetches candidate issues in active states.75 - Fetches current states for specific issue IDs (reconciliation).76 - Fetches terminal-state issues during startup cleanup.77 - Normalizes tracker payloads into a stable issue model.78
794. `Orchestrator`80 - Owns the poll tick.81 - Owns the in-memory runtime state.82 - Decides which issues to dispatch, retry, stop, or release.83 - Tracks session metrics and retry queue state.84
855. `Workspace Manager`86 - Maps issue identifiers to workspace paths.87 - Ensures per-issue workspace directories exist.88 - Runs workspace lifecycle hooks.89 - Cleans workspaces for terminal issues.90
916. `Agent Runner`92 - Creates workspace.93 - Builds prompt from issue + workflow template.94 - Launches the coding agent app-server client.95 - Streams agent updates back to the orchestrator.96
977. `Status Surface` (optional)98 - Presents human-readable runtime status (for example terminal output, dashboard, or other99 operator-facing view).1001018. `Logging`102 - Emits structured runtime logs to one or more configured sinks.103
104### 3.2 Abstraction Levels105
106Symphony is easiest to port when kept in these layers:107
1081. `Policy Layer` (repo-defined)109 - `WORKFLOW.md` prompt body.110 - Team-specific rules for ticket handling, validation, and handoff.111
1122. `Configuration Layer` (typed getters)113 - Parses front matter into typed runtime settings.114 - Handles defaults, environment tokens, and path normalization.115
1163. `Coordination Layer` (orchestrator)117 - Polling loop, issue eligibility, concurrency, retries, reconciliation.118
1194. `Execution Layer` (workspace + agent subprocess)120 - Filesystem lifecycle, workspace preparation, coding-agent protocol.121
1225. `Integration Layer` (Linear adapter)123 - API calls and normalization for tracker data.124
1256. `Observability Layer` (logs + optional status surface)126 - Operator visibility into orchestrator and agent behavior.127
128### 3.3 External Dependencies129
130- Issue tracker API (Linear for `tracker.kind: linear` in this specification version).131- Local filesystem for workspaces and logs.132- Optional workspace population tooling (for example Git CLI, if used).133- Coding-agent executable that supports JSON-RPC-like app-server mode over stdio.134- Host environment authentication for the issue tracker and coding agent.135
136## 4. Core Domain Model137
138### 4.1 Entities139
140#### 4.1.1 Issue141
142Normalized issue record used by orchestration, prompt rendering, and observability output.143
144Fields:145
146- `id` (string)147 - Stable tracker-internal ID.148- `identifier` (string)149 - Human-readable ticket key (example: `ABC-123`).150- `title` (string)151- `description` (string or null)152- `priority` (integer or null)153 - Lower numbers are higher priority in dispatch sorting.154- `state` (string)155 - Current tracker state name.156- `branch_name` (string or null)157 - Tracker-provided branch metadata if available.158- `url` (string or null)159- `labels` (list of strings)160 - Normalized to lowercase.161- `blocked_by` (list of blocker refs)162 - Each blocker ref contains:163 - `id` (string or null)164 - `identifier` (string or null)165 - `state` (string or null)166- `created_at` (timestamp or null)167- `updated_at` (timestamp or null)168
169#### 4.1.2 Workflow Definition170
171Parsed `WORKFLOW.md` payload:172
173- `config` (map)174 - YAML front matter root object.175- `prompt_template` (string)176 - Markdown body after front matter, trimmed.177
178#### 4.1.3 Service Config (Typed View)179
180Typed runtime values derived from `WorkflowDefinition.config` plus environment resolution.181
182Examples:183
184- poll interval185- workspace root186- active and terminal issue states187- concurrency limits188- coding-agent executable/args/timeouts189- workspace hooks190
191#### 4.1.4 Workspace192
193Filesystem workspace assigned to one issue identifier.194
195Fields (logical):196
197- `path` (workspace path; current runtime typically uses absolute paths, but relative roots are198 possible if configured without path separators)199- `workspace_key` (sanitized issue identifier)200- `created_now` (boolean, used to gate `after_create` hook)201
202#### 4.1.5 Run Attempt203
204One execution attempt for one issue.205
206Fields (logical):207
208- `issue_id`209- `issue_identifier`210- `attempt` (integer or null, `null` for first run, `>=1` for retries/continuation)211- `workspace_path`212- `started_at`213- `status`214- `error` (optional)215
216#### 4.1.6 Live Session (Agent Session Metadata)217
218State tracked while a coding-agent subprocess is running.219
220Fields:221
222- `session_id` (string, `<thread_id>-<turn_id>`)223- `thread_id` (string)224- `turn_id` (string)225- `codex_app_server_pid` (string or null)226- `last_codex_event` (string/enum or null)227- `last_codex_timestamp` (timestamp or null)228- `last_codex_message` (summarized payload)229- `codex_input_tokens` (integer)230- `codex_output_tokens` (integer)231- `codex_total_tokens` (integer)232- `last_reported_input_tokens` (integer)233- `last_reported_output_tokens` (integer)234- `last_reported_total_tokens` (integer)235- `turn_count` (integer)236 - Number of coding-agent turns started within the current worker lifetime.237
238#### 4.1.7 Retry Entry239
240Scheduled retry state for an issue.241
242Fields:243
244- `issue_id`245- `identifier` (best-effort human ID for status surfaces/logs)246- `attempt` (integer, 1-based for retry queue)247- `due_at_ms` (monotonic clock timestamp)248- `timer_handle` (runtime-specific timer reference)249- `error` (string or null)250
251#### 4.1.8 Orchestrator Runtime State252
253Single authoritative in-memory state owned by the orchestrator.254
255Fields:256
257- `poll_interval_ms` (current effective poll interval)258- `max_concurrent_agents` (current effective global concurrency limit)259- `running` (map `issue_id -> running entry`)260- `claimed` (set of issue IDs reserved/running/retrying)261- `retry_attempts` (map `issue_id -> RetryEntry`)262- `completed` (set of issue IDs; bookkeeping only, not dispatch gating)263- `codex_totals` (aggregate tokens + runtime seconds)264- `codex_rate_limits` (latest rate-limit snapshot from agent events)265
266### 4.2 Stable Identifiers and Normalization Rules267
268- `Issue ID`269 - Use for tracker lookups and internal map keys.270- `Issue Identifier`271 - Use for human-readable logs and workspace naming.272- `Workspace Key`273 - Derive from `issue.identifier` by replacing any character not in `[A-Za-z0-9._-]` with `_`.274 - Use the sanitized value for the workspace directory name.275- `Normalized Issue State`276 - Compare states after `lowercase`.277- `Session ID`278 - Compose from coding-agent `thread_id` and `turn_id` as `<thread_id>-<turn_id>`.279
280## 5. Workflow Specification (Repository Contract)281
282### 5.1 File Discovery and Path Resolution283
284Workflow file path precedence:285
2861. Explicit application/runtime setting (set by CLI startup path).2872. Default: `WORKFLOW.md` in the current process working directory.288
289Loader behavior:290
291- If the file cannot be read, return `missing_workflow_file` error.292- The workflow file is expected to be repository-owned and version-controlled.293
294### 5.2 File Format295
296`WORKFLOW.md` is a Markdown file with optional YAML front matter.297
298Design note:299
300- `WORKFLOW.md` should be self-contained enough to describe and run different workflows (prompt,301 runtime settings, hooks, and tracker selection/config) without requiring out-of-band302 service-specific configuration.303
304Parsing rules:305
306- If file starts with `---`, parse lines until the next `---` as YAML front matter.307- Remaining lines become the prompt body.308- If front matter is absent, treat the entire file as prompt body and use an empty config map.309- YAML front matter must decode to a map/object; non-map YAML is an error.310- Prompt body is trimmed before use.311
312Returned workflow object:313
314- `config`: front matter root object (not nested under a `config` key).315- `prompt_template`: trimmed Markdown body.316
317### 5.3 Front Matter Schema318
319Top-level keys:320
321- `tracker`322- `polling`323- `workspace`324- `hooks`325- `agent`326- `codex`327
328Unknown keys should be ignored for forward compatibility.329
330Note:331
332- The workflow front matter is extensible. Optional extensions may define additional top-level keys333 (for example `server`) without changing the core schema above.334- Extensions should document their field schema, defaults, validation rules, and whether changes335 apply dynamically or require restart.336- Common extension: `server.port` (integer) enables the optional HTTP server described in Section337 13.7.338
339#### 5.3.1 `tracker` (object)340
341Fields:342
343- `kind` (string)344 - Required for dispatch.345 - Current supported value: `linear`346- `endpoint` (string)347 - Default for `tracker.kind == "linear"`: `https://api.linear.app/graphql`348- `api_key` (string)349 - May be a literal token or `$VAR_NAME`.350 - Canonical environment variable for `tracker.kind == "linear"`: `LINEAR_API_KEY`.351 - If `$VAR_NAME` resolves to an empty string, treat the key as missing.352- `project_slug` (string)353 - Required for dispatch when `tracker.kind == "linear"`.354- `active_states` (list of strings)355 - Default: `Todo`, `In Progress`356- `terminal_states` (list of strings)357 - Default: `Closed`, `Cancelled`, `Canceled`, `Duplicate`, `Done`358
359#### 5.3.2 `polling` (object)360
361Fields:362
363- `interval_ms` (integer or string integer)364 - Default: `30000`365 - Changes should be re-applied at runtime and affect future tick scheduling without restart.366
367#### 5.3.3 `workspace` (object)368
369Fields:370
371- `root` (path string or `$VAR`)372 - Default: `<system-temp>/symphony_workspaces`373 - `~` and strings containing path separators are expanded.374 - Bare strings without path separators are preserved as-is (relative roots are allowed but375 discouraged).376377#### 5.3.4 `hooks` (object)378
379Fields:380
381- `after_create` (multiline shell script string, optional)382 - Runs only when a workspace directory is newly created.383 - Failure aborts workspace creation.384- `before_run` (multiline shell script string, optional)385 - Runs before each agent attempt after workspace preparation and before launching the coding386 agent.387 - Failure aborts the current attempt.388- `after_run` (multiline shell script string, optional)389 - Runs after each agent attempt (success, failure, timeout, or cancellation) once the workspace390 exists.391 - Failure is logged but ignored.392- `before_remove` (multiline shell script string, optional)393 - Runs before workspace deletion if the directory exists.394 - Failure is logged but ignored; cleanup still proceeds.395- `timeout_ms` (integer, optional)396 - Default: `60000`397 - Applies to all workspace hooks.398 - Non-positive values should be treated as invalid and fall back to the default.399 - Changes should be re-applied at runtime for future hook executions.400401#### 5.3.5 `agent` (object)402
403Fields:404
405- `max_concurrent_agents` (integer or string integer)406 - Default: `10`407 - Changes should be re-applied at runtime and affect subsequent dispatch decisions.408- `max_retry_backoff_ms` (integer or string integer)409 - Default: `300000` (5 minutes)410 - Changes should be re-applied at runtime and affect future retry scheduling.411- `max_concurrent_agents_by_state` (map `state_name -> positive integer`)412 - Default: empty map.413 - State keys are normalized (`lowercase`) for lookup.414 - Invalid entries (non-positive or non-numeric) are ignored.415
416#### 5.3.6 `codex` (object)417
418Fields:419
420For Codex-owned config values such as `approval_policy`, `thread_sandbox`, and421`turn_sandbox_policy`, supported values are defined by the targeted Codex app-server version.422Implementors should treat them as pass-through Codex config values rather than relying on a423hand-maintained enum in this spec. To inspect the installed Codex schema, run424`codex app-server generate-json-schema --out <dir>` and inspect the relevant definitions referenced425by `v2/ThreadStartParams.json` and `v2/TurnStartParams.json`. Implementations may validate these426fields locally if they want stricter startup checks.427
428- `command` (string shell command)429 - Default: `codex app-server`430 - The runtime launches this command via `bash -lc` in the workspace directory.431 - The launched process must speak a compatible app-server protocol over stdio.432- `approval_policy` (Codex `AskForApproval` value)433 - Default: implementation-defined.434- `thread_sandbox` (Codex `SandboxMode` value)435 - Default: implementation-defined.436- `turn_sandbox_policy` (Codex `SandboxPolicy` value)437 - Default: implementation-defined.438- `turn_timeout_ms` (integer)439 - Default: `3600000` (1 hour)440- `read_timeout_ms` (integer)441 - Default: `5000`442- `stall_timeout_ms` (integer)443 - Default: `300000` (5 minutes)444 - If `<= 0`, stall detection is disabled.445
446### 5.4 Prompt Template Contract447
448The Markdown body of `WORKFLOW.md` is the per-issue prompt template.449
450Rendering requirements:451
452- Use a strict template engine (Liquid-compatible semantics are sufficient).453- Unknown variables must fail rendering.454- Unknown filters must fail rendering.455
456Template input variables:457
458- `issue` (object)459 - Includes all normalized issue fields, including labels and blockers.460- `attempt` (integer or null)461 - `null`/absent on first attempt.462 - Integer on retry or continuation run.463
464Fallback prompt behavior:465
466- If the workflow prompt body is empty, the runtime may use a minimal default prompt467 (`You are working on an issue from Linear.`).468- Workflow file read/parse failures are configuration/validation errors and should not silently fall469 back to a prompt.470
471### 5.5 Workflow Validation and Error Surface472
473Error classes:474
475- `missing_workflow_file`476- `workflow_parse_error`477- `workflow_front_matter_not_a_map`478- `template_parse_error` (during prompt rendering)479- `template_render_error` (unknown variable/filter, invalid interpolation)480
481Dispatch gating behavior:482
483- Workflow file read/YAML errors block new dispatches until fixed.484- Template errors fail only the affected run attempt.485
486## 6. Configuration Specification487
488### 6.1 Source Precedence and Resolution Semantics489
490Configuration precedence:491
4921. Workflow file path selection (runtime setting -> cwd default).4932. YAML front matter values.4943. Environment indirection via `$VAR_NAME` inside selected YAML values.4954. Built-in defaults.496
497Value coercion semantics:498
499- Path/command fields support:500 - `~` home expansion501 - `$VAR` expansion for env-backed path values502 - Apply expansion only to values intended to be local filesystem paths; do not rewrite URIs or503 arbitrary shell command strings.504505### 6.2 Dynamic Reload Semantics506
507Dynamic reload is required:508
509- The software should watch `WORKFLOW.md` for changes.510- On change, it should re-read and re-apply workflow config and prompt template without restart.511- The software should attempt to adjust live behavior to the new config (for example polling512 cadence, concurrency limits, active/terminal states, codex settings, workspace paths/hooks, and513 prompt content for future runs).514- Reloaded config applies to future dispatch, retry scheduling, reconciliation decisions, hook515 execution, and agent launches.516- Implementations are not required to restart in-flight agent sessions automatically when config517 changes.518- Extensions that manage their own listeners/resources (for example an HTTP server port change) may519 require restart unless the implementation explicitly supports live rebind.520- Implementations should also re-validate/reload defensively during runtime operations (for example521 before dispatch) in case filesystem watch events are missed.522- Invalid reloads should not crash the service; keep operating with the last known good effective523 configuration and emit an operator-visible error.524
525### 6.3 Dispatch Preflight Validation526
527This validation is a scheduler preflight run before attempting to dispatch new work. It validates528the workflow/config needed to poll and launch workers, not a full audit of all possible workflow529behavior.530
531Startup validation:532
533- Validate configuration before starting the scheduling loop.534- If startup validation fails, fail startup and emit an operator-visible error.535
536Per-tick dispatch validation:537
538- Re-validate before each dispatch cycle.539- If validation fails, skip dispatch for that tick, keep reconciliation active, and emit an540 operator-visible error.541
542Validation checks:543
544- Workflow file can be loaded and parsed.545- `tracker.kind` is present and supported.546- `tracker.api_key` is present after `$` resolution.547- `tracker.project_slug` is present when required by the selected tracker kind.548- `codex.command` is present and non-empty.549
550### 6.4 Config Fields Summary (Cheat Sheet)551
552This section is intentionally redundant so a coding agent can implement the config layer quickly.553
554- `tracker.kind`: string, required, currently `linear`555- `tracker.endpoint`: string, default `https://api.linear.app/graphql` when `tracker.kind=linear`556- `tracker.api_key`: string or `$VAR`, canonical env `LINEAR_API_KEY` when `tracker.kind=linear`557- `tracker.project_slug`: string, required when `tracker.kind=linear`558- `tracker.active_states`: list of strings, default `["Todo", "In Progress"]`559- `tracker.terminal_states`: list of strings, default `["Closed", "Cancelled", "Canceled", "Duplicate", "Done"]`560- `polling.interval_ms`: integer, default `30000`561- `workspace.root`: path, default `<system-temp>/symphony_workspaces`562- `worker.ssh_hosts` (extension): list of SSH host strings, optional; when omitted, work runs563 locally564- `worker.max_concurrent_agents_per_host` (extension): positive integer, optional; shared per-host565 cap applied across configured SSH hosts566- `hooks.after_create`: shell script or null567- `hooks.before_run`: shell script or null568- `hooks.after_run`: shell script or null569- `hooks.before_remove`: shell script or null570- `hooks.timeout_ms`: integer, default `60000`571- `agent.max_concurrent_agents`: integer, default `10`572- `agent.max_turns`: integer, default `20`573- `agent.max_retry_backoff_ms`: integer, default `300000` (5m)574- `agent.max_concurrent_agents_by_state`: map of positive integers, default `{}`575- `codex.command`: shell command string, default `codex app-server`576- `codex.approval_policy`: Codex `AskForApproval` value, default implementation-defined577- `codex.thread_sandbox`: Codex `SandboxMode` value, default implementation-defined578- `codex.turn_sandbox_policy`: Codex `SandboxPolicy` value, default implementation-defined579- `codex.turn_timeout_ms`: integer, default `3600000`580- `codex.read_timeout_ms`: integer, default `5000`581- `codex.stall_timeout_ms`: integer, default `300000`582- `server.port` (extension): integer, optional; enables the optional HTTP server, `0` may be used583 for ephemeral local bind, and CLI `--port` overrides it584
585## 7. Orchestration State Machine586
587The orchestrator is the only component that mutates scheduling state. All worker outcomes are588reported back to it and converted into explicit state transitions.589
590### 7.1 Issue Orchestration States591
592This is not the same as tracker states (`Todo`, `In Progress`, etc.). This is the service's internal593claim state.594
5951. `Unclaimed`596 - Issue is not running and has no retry scheduled.597
5982. `Claimed`599 - Orchestrator has reserved the issue to prevent duplicate dispatch.600 - In practice, claimed issues are either `Running` or `RetryQueued`.601
6023. `Running`603 - Worker task exists and the issue is tracked in `running` map.604
6054. `RetryQueued`606 - Worker is not running, but a retry timer exists in `retry_attempts`.607
6085. `Released`609 - Claim removed because issue is terminal, non-active, missing, or retry path completed without610 re-dispatch.611612Important nuance:613
614- A successful worker exit does not mean the issue is done forever.615- The worker may continue through multiple back-to-back coding-agent turns before it exits.616- After each normal turn completion, the worker re-checks the tracker issue state.617- If the issue is still in an active state, the worker should start another turn on the same live618 coding-agent thread in the same workspace, up to `agent.max_turns`.619- The first turn should use the full rendered task prompt.620- Continuation turns should send only continuation guidance to the existing thread, not resend the621 original task prompt that is already present in thread history.622- Once the worker exits normally, the orchestrator still schedules a short continuation retry623 (about 1 second) so it can re-check whether the issue remains active and needs another worker624 session.625
626### 7.2 Run Attempt Lifecycle627
628A run attempt transitions through these phases:629
6301. `PreparingWorkspace`6312. `BuildingPrompt`6323. `LaunchingAgentProcess`6334. `InitializingSession`6345. `StreamingTurn`6356. `Finishing`6367. `Succeeded`6378. `Failed`6389. `TimedOut`63910. `Stalled`64011. `CanceledByReconciliation`641
642Distinct terminal reasons are important because retry logic and logs differ.643
644### 7.3 Transition Triggers645
646- `Poll Tick`647 - Reconcile active runs.648 - Validate config.649 - Fetch candidate issues.650 - Dispatch until slots are exhausted.651
652- `Worker Exit (normal)`653 - Remove running entry.654 - Update aggregate runtime totals.655 - Schedule continuation retry (attempt `1`) after the worker exhausts or finishes its in-process656 turn loop.657658- `Worker Exit (abnormal)`659 - Remove running entry.660 - Update aggregate runtime totals.661 - Schedule exponential-backoff retry.662
663- `Codex Update Event`664 - Update live session fields, token counters, and rate limits.665
666- `Retry Timer Fired`667 - Re-fetch active candidates and attempt re-dispatch, or release claim if no longer eligible.668
669- `Reconciliation State Refresh`670 - Stop runs whose issue states are terminal or no longer active.671
672- `Stall Timeout`673 - Kill worker and schedule retry.674
675### 7.4 Idempotency and Recovery Rules676
677- The orchestrator serializes state mutations through one authority to avoid duplicate dispatch.678- `claimed` and `running` checks are required before launching any worker.679- Reconciliation runs before dispatch on every tick.680- Restart recovery is tracker-driven and filesystem-driven (no durable orchestrator DB required).681- Startup terminal cleanup removes stale workspaces for issues already in terminal states.682
683## 8. Polling, Scheduling, and Reconciliation684
685### 8.1 Poll Loop686
687At startup, the service validates config, performs startup cleanup, schedules an immediate tick, and688then repeats every `polling.interval_ms`.689
690The effective poll interval should be updated when workflow config changes are re-applied.691
692Tick sequence:693
6941. Reconcile running issues.6952. Run dispatch preflight validation.6963. Fetch candidate issues from tracker using active states.6974. Sort issues by dispatch priority.6985. Dispatch eligible issues while slots remain.6996. Notify observability/status consumers of state changes.700
701If per-tick validation fails, dispatch is skipped for that tick, but reconciliation still happens702first.703
704### 8.2 Candidate Selection Rules705
706An issue is dispatch-eligible only if all are true:707
708- It has `id`, `identifier`, `title`, and `state`.709- Its state is in `active_states` and not in `terminal_states`.710- It is not already in `running`.711- It is not already in `claimed`.712- Global concurrency slots are available.713- Per-state concurrency slots are available.714- Blocker rule for `Todo` state passes:715 - If the issue state is `Todo`, do not dispatch when any blocker is non-terminal.716
717Sorting order (stable intent):718
7191. `priority` ascending (1..4 are preferred; null/unknown sorts last)7202. `created_at` oldest first7213. `identifier` lexicographic tie-breaker722
723### 8.3 Concurrency Control724
725Global limit:726
727- `available_slots = max(max_concurrent_agents - running_count, 0)`728
729Per-state limit:730
731- `max_concurrent_agents_by_state[state]` if present (state key normalized)732- otherwise fallback to global limit733
734The runtime counts issues by their current tracked state in the `running` map.735
736Optional SSH host limit:737
738- When `worker.max_concurrent_agents_per_host` is set, each configured SSH host may run at most739 that many concurrent agents at once.740- Hosts at that cap are skipped for new dispatch until capacity frees up.741
742### 8.4 Retry and Backoff743
744Retry entry creation:745
746- Cancel any existing retry timer for the same issue.747- Store `attempt`, `identifier`, `error`, `due_at_ms`, and new timer handle.748
749Backoff formula:750
751- Normal continuation retries after a clean worker exit use a short fixed delay of `1000` ms.752- Failure-driven retries use `delay = min(10000 * 2^(attempt - 1), agent.max_retry_backoff_ms)`.753- Power is capped by the configured max retry backoff (default `300000` / 5m).754
755Retry handling behavior:756
7571. Fetch active candidate issues (not all issues).7582. Find the specific issue by `issue_id`.7593. If not found, release claim.7604. If found and still candidate-eligible:761 - Dispatch if slots are available.762 - Otherwise requeue with error `no available orchestrator slots`.7635. If found but no longer active, release claim.764
765Note:766
767- Terminal-state workspace cleanup is handled by startup cleanup and active-run reconciliation768 (including terminal transitions for currently running issues).769- Retry handling mainly operates on active candidates and releases claims when the issue is absent,770 rather than performing terminal cleanup itself.771
772### 8.5 Active Run Reconciliation773
774Reconciliation runs every tick and has two parts.775
776Part A: Stall detection777
778- For each running issue, compute `elapsed_ms` since:779 - `last_codex_timestamp` if any event has been seen, else780 - `started_at`781- If `elapsed_ms > codex.stall_timeout_ms`, terminate the worker and queue a retry.782- If `stall_timeout_ms <= 0`, skip stall detection entirely.783
784Part B: Tracker state refresh785
786- Fetch current issue states for all running issue IDs.787- For each running issue:788 - If tracker state is terminal: terminate worker and clean workspace.789 - If tracker state is still active: update the in-memory issue snapshot.790 - If tracker state is neither active nor terminal: terminate worker without workspace cleanup.791- If state refresh fails, keep workers running and try again on the next tick.792
793### 8.6 Startup Terminal Workspace Cleanup794
795When the service starts:796
7971. Query tracker for issues in terminal states.7982. For each returned issue identifier, remove the corresponding workspace directory.7993. If the terminal-issues fetch fails, log a warning and continue startup.800
801This prevents stale terminal workspaces from accumulating after restarts.802
803## 9. Workspace Management and Safety804
805### 9.1 Workspace Layout806
807Workspace root:808
809- `workspace.root` (normalized path; the current config layer expands path-like values and preserves810 bare relative names)811
812Per-issue workspace path:813
814- `<workspace.root>/<sanitized_issue_identifier>`815
816Workspace persistence:817
818- Workspaces are reused across runs for the same issue.819- Successful runs do not auto-delete workspaces.820
821### 9.2 Workspace Creation and Reuse822
823Input: `issue.identifier`824
825Algorithm summary:826
8271. Sanitize identifier to `workspace_key`.8282. Compute workspace path under workspace root.8293. Ensure the workspace path exists as a directory.8304. Mark `created_now=true` only if the directory was created during this call; otherwise831 `created_now=false`.8325. If `created_now=true`, run `after_create` hook if configured.833
834Notes:835
836- This section does not assume any specific repository/VCS workflow.837- Workspace preparation beyond directory creation (for example dependency bootstrap, checkout/sync,838 code generation) is implementation-defined and is typically handled via hooks.839
840### 9.3 Optional Workspace Population (Implementation-Defined)841
842The spec does not require any built-in VCS or repository bootstrap behavior.843
844Implementations may populate or synchronize the workspace using implementation-defined logic and/or845hooks (for example `after_create` and/or `before_run`).846
847Failure handling:848
849- Workspace population/synchronization failures return an error for the current attempt.850- If failure happens while creating a brand-new workspace, implementations may remove the partially851 prepared directory.852- Reused workspaces should not be destructively reset on population failure unless that policy is853 explicitly chosen and documented.854
855### 9.4 Workspace Hooks856
857Supported hooks:858
859- `hooks.after_create`860- `hooks.before_run`861- `hooks.after_run`862- `hooks.before_remove`863
864Execution contract:865
866- Execute in a local shell context appropriate to the host OS, with the workspace directory as867 `cwd`.868- On POSIX systems, `sh -lc <script>` (or a stricter equivalent such as `bash -lc <script>`) is a869 conforming default.870- Hook timeout uses `hooks.timeout_ms`; default: `60000 ms`.871- Log hook start, failures, and timeouts.872
873Failure semantics:874
875- `after_create` failure or timeout is fatal to workspace creation.876- `before_run` failure or timeout is fatal to the current run attempt.877- `after_run` failure or timeout is logged and ignored.878- `before_remove` failure or timeout is logged and ignored.879
880### 9.5 Safety Invariants881
882This is the most important portability constraint.883
884Invariant 1: Run the coding agent only in the per-issue workspace path.885
886- Before launching the coding-agent subprocess, validate:887 - `cwd == workspace_path`888
889Invariant 2: Workspace path must stay inside workspace root.890
891- Normalize both paths to absolute.892- Require `workspace_path` to have `workspace_root` as a prefix directory.893- Reject any path outside the workspace root.894
895Invariant 3: Workspace key is sanitized.896
897- Only `[A-Za-z0-9._-]` allowed in workspace directory names.898- Replace all other characters with `_`.899
900## 10. Agent Runner Protocol (Coding Agent Integration)901
902This section defines the language-neutral contract for integrating a coding agent app-server.903
904Compatibility profile:905
906- The normative contract is message ordering, required behaviors, and the logical fields that must907 be extracted (for example session IDs, completion state, approval handling, and usage/rate-limit908 telemetry).909- Exact JSON field names may vary slightly across compatible app-server versions.910- Implementations should tolerate equivalent payload shapes when they carry the same logical911 meaning, especially for nested IDs, approval requests, user-input-required signals, and912 token/rate-limit metadata.913
914### 10.1 Launch Contract915
916Subprocess launch parameters:917
918- Command: `codex.command`919- Invocation: `bash -lc <codex.command>`920- Working directory: workspace path921- Stdout/stderr: separate streams922- Framing: line-delimited protocol messages on stdout (JSON-RPC-like JSON per line)923
924Notes:925
926- The default command is `codex app-server`.927- Approval policy, cwd, and prompt are expressed in the protocol messages in Section 10.2.928
929Recommended additional process settings:930
931- Max line size: 10 MB (for safe buffering)932
933### 10.2 Session Startup Handshake934
935Reference: https://developers.openai.com/codex/app-server/936
937The client must send these protocol messages in order:938
939Illustrative startup transcript (equivalent payload shapes are acceptable if they preserve the same940semantics):941
942```json943{"id":1,"method":"initialize","params":{"clientInfo":{"name":"symphony","version":"1.0"},"capabilities":{}}}944{"method":"initialized","params":{}}945{"id":2,"method":"thread/start","params":{"approvalPolicy":"<implementation-defined>","sandbox":"<implementation-defined>","cwd":"/abs/workspace"}}946{"id":3,"method":"turn/start","params":{"threadId":"<thread-id>","input":[{"type":"text","text":"<rendered prompt-or-continuation-guidance>"}],"cwd":"/abs/workspace","title":"ABC-123: Example","approvalPolicy":"<implementation-defined>","sandboxPolicy":{"type":"<implementation-defined>"}}}947```948
9491. `initialize` request950 - Params include:951 - `clientInfo` object (for example `{name, version}`)952 - `capabilities` object (may be empty)953 - If the targeted Codex app-server requires capability negotiation for dynamic tools, include the954 necessary capability flag(s) here.955 - Wait for response (`read_timeout_ms`)9562. `initialized` notification9573. `thread/start` request958 - Params include:959 - `approvalPolicy` = implementation-defined session approval policy value960 - `sandbox` = implementation-defined session sandbox value961 - `cwd` = absolute workspace path962 - If optional client-side tools are implemented, include their advertised tool specs using the963 protocol mechanism supported by the targeted Codex app-server version.9644. `turn/start` request965 - Params include:966 - `threadId`967 - `input` = single text item containing rendered prompt for the first turn, or continuation968 guidance for later turns on the same thread969 - `cwd`970 - `title` = `<issue.identifier>: <issue.title>`971 - `approvalPolicy` = implementation-defined turn approval policy value972 - `sandboxPolicy` = implementation-defined object-form sandbox policy payload when required by973 the targeted app-server version974975Session identifiers:976
977- Read `thread_id` from `thread/start` result `result.thread.id`978- Read `turn_id` from each `turn/start` result `result.turn.id`979- Emit `session_id = "<thread_id>-<turn_id>"`980- Reuse the same `thread_id` for all continuation turns inside one worker run981
982### 10.3 Streaming Turn Processing983
984The client reads line-delimited messages until the turn terminates.985
986Completion conditions:987
988- `turn/completed` -> success989- `turn/failed` -> failure990- `turn/cancelled` -> failure991- turn timeout (`turn_timeout_ms`) -> failure992- subprocess exit -> failure993
994Continuation processing:995
996- If the worker decides to continue after a successful turn, it should issue another `turn/start`997 on the same live `threadId`.998- The app-server subprocess should remain alive across those continuation turns and be stopped only999 when the worker run is ending.1000
1001Line handling requirements:1002
1003- Read protocol messages from stdout only.1004- Buffer partial stdout lines until newline arrives.1005- Attempt JSON parse on complete stdout lines.1006- Stderr is not part of the protocol stream:1007 - ignore it or log it as diagnostics1008 - do not attempt protocol JSON parsing on stderr1009
1010### 10.4 Emitted Runtime Events (Upstream to Orchestrator)1011
1012The app-server client emits structured events to the orchestrator callback. Each event should1013include:1014
1015- `event` (enum/string)1016- `timestamp` (UTC timestamp)1017- `codex_app_server_pid` (if available)1018- optional `usage` map (token counts)1019- payload fields as needed1020
1021Important emitted events may include:1022
1023- `session_started`1024- `startup_failed`1025- `turn_completed`1026- `turn_failed`1027- `turn_cancelled`1028- `turn_ended_with_error`1029- `turn_input_required`1030- `approval_auto_approved`1031- `unsupported_tool_call`1032- `notification`1033- `other_message`1034- `malformed`1035
1036### 10.5 Approval, Tool Calls, and User Input Policy1037
1038Approval, sandbox, and user-input behavior is implementation-defined.1039
1040Policy requirements:1041
1042- Each implementation should document its chosen approval, sandbox, and operator-confirmation1043 posture.1044- Approval requests and user-input-required events must not leave a run stalled indefinitely. An1045 implementation should either satisfy them, surface them to an operator, auto-resolve them, or1046 fail the run according to its documented policy.1047
1048Example high-trust behavior:1049
1050- Auto-approve command execution approvals for the session.1051- Auto-approve file-change approvals for the session.1052- Treat user-input-required turns as hard failure.1053
1054Unsupported dynamic tool calls:1055
1056- Supported dynamic tool calls that are explicitly implemented and advertised by the runtime should1057 be handled according to their extension contract.1058- If the agent requests a dynamic tool call (`item/tool/call`) that is not supported, return a tool1059 failure response and continue the session.1060- This prevents the session from stalling on unsupported tool execution paths.1061
1062Optional client-side tool extension:1063
1064- An implementation may expose a limited set of client-side tools to the app-server session.1065- Current optional standardized tool: `linear_graphql`.1066- If implemented, supported tools should be advertised to the app-server session during startup1067 using the protocol mechanism supported by the targeted Codex app-server version.1068- Unsupported tool names should still return a failure result and continue the session.1069
1070`linear_graphql` extension contract:1071
1072- Purpose: execute a raw GraphQL query or mutation against Linear using Symphony's configured1073 tracker auth for the current session.1074- Availability: only meaningful when `tracker.kind == "linear"` and valid Linear auth is configured.1075- Preferred input shape:1076
1077 ```json1078 {1079 "query": "single GraphQL query or mutation document",1080 "variables": {1081 "optional": "graphql variables object"1082 }1083 }1084 ```1085
1086- `query` must be a non-empty string.1087- `query` must contain exactly one GraphQL operation.1088- `variables` is optional and, when present, must be a JSON object.1089- Implementations may additionally accept a raw GraphQL query string as shorthand input.1090- Execute one GraphQL operation per tool call.1091- If the provided document contains multiple operations, reject the tool call as invalid input.1092- `operationName` selection is intentionally out of scope for this extension.1093- Reuse the configured Linear endpoint and auth from the active Symphony workflow/runtime config; do1094 not require the coding agent to read raw tokens from disk.1095- Tool result semantics:1096 - transport success + no top-level GraphQL `errors` -> `success=true`1097 - top-level GraphQL `errors` present -> `success=false`, but preserve the GraphQL response body1098 for debugging1099 - invalid input, missing auth, or transport failure -> `success=false` with an error payload1100- Return the GraphQL response or error payload as structured tool output that the model can inspect1101 in-session.11021103Illustrative responses (equivalent payload shapes are acceptable if they preserve the same outcome):1104
1105```json1106{"id":"<approval-id>","result":{"approved":true}}1107{"id":"<tool-call-id>","result":{"success":false,"error":"unsupported_tool_call"}}1108```1109
1110Hard failure on user input requirement:1111
1112- If the agent requests user input, fail the run attempt immediately.1113- The client detects this via:1114 - explicit method (`item/tool/requestUserInput`), or1115 - turn methods/flags indicating input is required.1116
1117### 10.6 Timeouts and Error Mapping1118
1119Timeouts:1120
1121- `codex.read_timeout_ms`: request/response timeout during startup and sync requests1122- `codex.turn_timeout_ms`: total turn stream timeout1123- `codex.stall_timeout_ms`: enforced by orchestrator based on event inactivity1124
1125Error mapping (recommended normalized categories):1126
1127- `codex_not_found`1128- `invalid_workspace_cwd`1129- `response_timeout`1130- `turn_timeout`1131- `port_exit`1132- `response_error`1133- `turn_failed`1134- `turn_cancelled`1135- `turn_input_required`1136
1137### 10.7 Agent Runner Contract1138
1139The `Agent Runner` wraps workspace + prompt + app-server client.1140
1141Behavior:1142
11431. Create/reuse workspace for issue.11442. Build prompt from workflow template.11453. Start app-server session.11464. Forward app-server events to orchestrator.11475. On any error, fail the worker attempt (the orchestrator will retry).1148
1149Note:1150
1151- Workspaces are intentionally preserved after successful runs.1152
1153## 11. Issue Tracker Integration Contract (Linear-Compatible)1154
1155### 11.1 Required Operations1156
1157An implementation must support these tracker adapter operations:1158
11591. `fetch_candidate_issues()`1160 - Return issues in configured active states for a configured project.1161
11622. `fetch_issues_by_states(state_names)`1163 - Used for startup terminal cleanup.1164
11653. `fetch_issue_states_by_ids(issue_ids)`1166 - Used for active-run reconciliation.1167
1168### 11.2 Query Semantics (Linear)1169
1170Linear-specific requirements for `tracker.kind == "linear"`:1171
1172- `tracker.kind == "linear"`1173- GraphQL endpoint (default `https://api.linear.app/graphql`)1174- Auth token sent in `Authorization` header1175- `tracker.project_slug` maps to Linear project `slugId`1176- Candidate issue query filters project using `project: { slugId: { eq: $projectSlug } }`1177- Issue-state refresh query uses GraphQL issue IDs with variable type `[ID!]`1178- Pagination required for candidate issues1179- Page size default: `50`1180- Network timeout: `30000 ms`1181
1182Important:1183
1184- Linear GraphQL schema details can drift. Keep query construction isolated and test the exact query1185 fields/types required by this specification.1186
1187A non-Linear implementation may change transport details, but the normalized outputs must match the1188domain model in Section 4.1189
1190### 11.3 Normalization Rules1191
1192Candidate issue normalization should produce fields listed in Section 4.1.1.1193
1194Additional normalization details:1195
1196- `labels` -> lowercase strings1197- `blocked_by` -> derived from inverse relations where relation type is `blocks`1198- `priority` -> integer only (non-integers become null)1199- `created_at` and `updated_at` -> parse ISO-8601 timestamps1200
1201### 11.4 Error Handling Contract1202
1203Recommended error categories:1204
1205- `unsupported_tracker_kind`1206- `missing_tracker_api_key`1207- `missing_tracker_project_slug`1208- `linear_api_request` (transport failures)1209- `linear_api_status` (non-200 HTTP)1210- `linear_graphql_errors`1211- `linear_unknown_payload`1212- `linear_missing_end_cursor` (pagination integrity error)1213
1214Orchestrator behavior on tracker errors:1215
1216- Candidate fetch failure: log and skip dispatch for this tick.1217- Running-state refresh failure: log and keep active workers running.1218- Startup terminal cleanup failure: log warning and continue startup.1219
1220### 11.5 Tracker Writes (Important Boundary)1221
1222Symphony does not require first-class tracker write APIs in the orchestrator.1223
1224- Ticket mutations (state transitions, comments, PR metadata) are typically handled by the coding1225 agent using tools defined by the workflow prompt.1226- The service remains a scheduler/runner and tracker reader.1227- Workflow-specific success often means "reached the next handoff state" (for example1228 `Human Review`) rather than tracker terminal state `Done`.1229- If the optional `linear_graphql` client-side tool extension is implemented, it is still part of1230 the agent toolchain rather than orchestrator business logic.1231
1232## 12. Prompt Construction and Context Assembly1233
1234### 12.1 Inputs1235
1236Inputs to prompt rendering:1237
1238- `workflow.prompt_template`1239- normalized `issue` object1240- optional `attempt` integer (retry/continuation metadata)1241
1242### 12.2 Rendering Rules1243
1244- Render with strict variable checking.1245- Render with strict filter checking.1246- Convert issue object keys to strings for template compatibility.1247- Preserve nested arrays/maps (labels, blockers) so templates can iterate.1248
1249### 12.3 Retry/Continuation Semantics1250
1251`attempt` should be passed to the template because the workflow prompt may provide different1252instructions for:1253
1254- first run (`attempt` null or absent)1255- continuation run after a successful prior session1256- retry after error/timeout/stall1257
1258### 12.4 Failure Semantics1259
1260If prompt rendering fails:1261
1262- Fail the run attempt immediately.1263- Let the orchestrator treat it like any other worker failure and decide retry behavior.1264
1265## 13. Logging, Status, and Observability1266
1267### 13.1 Logging Conventions1268
1269Required context fields for issue-related logs:1270
1271- `issue_id`1272- `issue_identifier`1273
1274Required context for coding-agent session lifecycle logs:1275
1276- `session_id`1277
1278Message formatting requirements:1279
1280- Use stable `key=value` phrasing.1281- Include action outcome (`completed`, `failed`, `retrying`, etc.).1282- Include concise failure reason when present.1283- Avoid logging large raw payloads unless necessary.1284
1285### 13.2 Logging Outputs and Sinks1286
1287The spec does not prescribe where logs must go (stderr, file, remote sink, etc.).1288
1289Requirements:1290
1291- Operators must be able to see startup/validation/dispatch failures without attaching a debugger.1292- Implementations may write to one or more sinks.1293- If a configured log sink fails, the service should continue running when possible and emit an1294 operator-visible warning through any remaining sink.1295
1296### 13.3 Runtime Snapshot / Monitoring Interface (Optional but Recommended)1297
1298If the implementation exposes a synchronous runtime snapshot (for dashboards or monitoring), it1299should return:1300
1301- `running` (list of running session rows)1302- each running row should include `turn_count`1303- `retrying` (list of retry queue rows)1304- `codex_totals`1305 - `input_tokens`1306 - `output_tokens`1307 - `total_tokens`1308 - `seconds_running` (aggregate runtime seconds as of snapshot time, including active sessions)1309- `rate_limits` (latest coding-agent rate limit payload, if available)1310
1311Recommended snapshot error modes:1312
1313- `timeout`1314- `unavailable`1315
1316### 13.4 Optional Human-Readable Status Surface1317
1318A human-readable status surface (terminal output, dashboard, etc.) is optional and1319implementation-defined.1320
1321If present, it should draw from orchestrator state/metrics only and must not be required for1322correctness.1323
1324### 13.5 Session Metrics and Token Accounting1325
1326Token accounting rules:1327
1328- Agent events may include token counts in multiple payload shapes.1329- Prefer absolute thread totals when available, such as:1330 - `thread/tokenUsage/updated` payloads1331 - `total_token_usage` within token-count wrapper events1332- Ignore delta-style payloads such as `last_token_usage` for dashboard/API totals.1333- Extract input/output/total token counts leniently from common field names within the selected1334 payload.1335- For absolute totals, track deltas relative to last reported totals to avoid double-counting.1336- Do not treat generic `usage` maps as cumulative totals unless the event type defines them that1337 way.1338- Accumulate aggregate totals in orchestrator state.1339
1340Runtime accounting:1341
1342- Runtime should be reported as a live aggregate at snapshot/render time.1343- Implementations may maintain a cumulative counter for ended sessions and add active-session1344 elapsed time derived from `running` entries (for example `started_at`) when producing a1345 snapshot/status view.1346- Add run duration seconds to the cumulative ended-session runtime when a session ends (normal exit1347 or cancellation/termination).1348- Continuous background ticking of runtime totals is not required.1349
1350Rate-limit tracking:1351
1352- Track the latest rate-limit payload seen in any agent update.1353- Any human-readable presentation of rate-limit data is implementation-defined.1354
1355### 13.6 Humanized Agent Event Summaries (Optional)1356
1357Humanized summaries of raw agent protocol events are optional.1358
1359If implemented:1360
1361- Treat them as observability-only output.1362- Do not make orchestrator logic depend on humanized strings.1363
참조 구현은 Elixir로 작성되었습니다. 코드가 사실상 비용이 0에 가까워지면 마침내 Elixir의 동시성처럼 각 언어의 강점을 보고 언어를 고를 수 있기 때문입니다. 하지만 핵심 아이디어는 단순한 Markdown 문서로도 표현할 수 있습니다. 여러분이 선호하는 코딩 에이전트에 이 사양을 맡겨 자체 버전을 구현해 보시길 권합니다.
Symphony의 첫 버전은 그저 tmux에서 실행되는 Codex 세션으로, Linear를 폴링하고 새 작업이 생기면 하위 에이전트를 띄우는 방식이었습니다. 작동은 했지만 특별히 안정적이진 않았습니다. 두 번째 버전은 에이전트를 염두에 두고 만든 우리의 메인 프로젝트 리포지터리 안에서 돌아갔습니다. 우리는 이미 이 리포지터리에서 에이전트가 고품질 작업을 할 수 있도록 필요한 역량과 맥락을 제공하는 에이전트 하네스를 구축해 두었고, Symphony는 그 모든 것을 연결해 줄 뿐이었습니다.
기본 기능이 갖춰지자, 우리는 Symphony를 사용해 Symphony를 만들었습니다.
시스템이 작업을 관리하고 작업 수행 증명 영상을 첨부하는 모습을 내부 데모로 보여줬을 때 반응은 압도적으로 긍정적이었습니다. Symphony 프로젝트 채널은 커졌고, 조직 전반의 팀들이 자연스럽게 이를 사용하기 시작했습니다. OpenAI에서는 외부 출시 전에 내부 프로덕트 마켓 핏을 확보하는 것이 필수입니다. OpenAI 내부에서 확인한 사용 양상을 바탕으로, 우리는 Symphony를 회사 밖에도 공유해야 한다는 점이 분명해졌습니다.
그래서 우리는 이 아이디어를 독립적인 SPEC.md로 추출하고 Codex에 구현해 달라고 했습니다. 참조 구현으로는 동시 프로세스를 오케스트레이션하고 감독하기 위한 훌륭한 기본 요소를 갖춘, 비교적 틈새 언어인 Elixir를 선택했습니다. Codex는 단번에 Elixir 구현을 만들어냈고, 그 뒤로 우리는 사양과 구현을 계속 다듬었습니다. 사양을 더 정제하기 위해 Codex에게 TypeScript, Go, Rust, Java, Python 등 여러 다른 언어로도 구현하게 하고, 그 결과를 활용해 모호한 부분을 찾아내고 시스템을 단순화했습니다. 모든 언어에서 성공적으로 구현되었습니다.
Codex를 구축하는 과정에서 우리는 특정 리포지터리나 Linear MCP에 대한 의존성 같은 부수적 복잡성을 많이 제거했습니다. Symphony는 더 이상 우리의 내부 리포지터리나 워크플로에 의존하지 않습니다. 핵심 접근 방식은 단순해졌습니다.
열려 있는 모든 작업에 대해, 각 워크스페이스에서 실행되는 에이전트가 반드시 하나씩 있도록 보장합니다.
활성 작업을 돕는 것에 더해, 이제 개발 워크플로 자체도 에이전트가 알고 따르는 것이 되었습니다. 개발 워크플로, 즉 이슈 작업, 리포지터리 체크아웃, PM이 작업 중임을 알 수 있도록 진행 중으로 표시, PR 추가, Review 상태로 이동, 영상 첨부 등은 이제 단순한 WORKFLOW.md 파일에 담겨 있습니다. 이 모든 것은 원래 사람이 따르던 프로세스였지만 문서화된 적은 없었습니다. 이처럼 암묵적으로 이루어지던 단계들에 의존하는 대신 이제는 이를 문서화하고, Symphony가 에이전트가 이를 따르도록 보장합니다. 이를 통해 우리와 함께 일하는 에이전트를 만들 수 있습니다. 만약 에이전트가 완료된 작업에 자기 성찰도 첨부해야 한다고 결정하면, 우리는 그것을 WORKFLOW.md에 추가할 것이고 Symphony는 에이전트가 그 단계로 가도록 안내할 것입니다.
우리는 또한 Codex의 내장 헤드리스 모드인 app server mode(새 창에서 열기)에서 Codex를 사용할 수 있었습니다. 이 모드를 통해 스레드 시작이나 대화 턴에 대한 처리 같은 작업을 위해, 잘 문서화된 JSON-RPC API를 통해 Codex를 실행하고 프로그래밍 방식으로 대화할 수 있었습니다. 이는 CLI나 실시간 tmux 세션으로 Codex와 상호작용하려는 방식보다 훨씬 더 편리하고 확장성이 좋습니다.
Codex App Server는 우리의 사용 사례에 완벽하게 맞았습니다. 우리는 Codex가 제공하는 하네스를 활용하면서도 연결할 수 있는 조정 장치와 훅을 가질 수 있기 때문입니다. 예를 들어 Linear 액세스 토큰이 하위 에이전트에 노출되지 않도록, 우리는 MCP에 의존하거나 컨테이너에 액세스 토큰을 노출하지 않고도 Linear에 임의 요청을 실행하는 원시 linear_graphql 함수를 노출하기 위해 dynamic tool calls(새 창에서 열기)를 사용합니다.
다음 단계
Symphony는 의도적으로 최소한으로 설계된 오케스트레이션 계층입니다. 우리는 Linear 같은 다양한 워크플로 도구와 함께 사용할 때 Codex App Server의 강력함을 보여주기 위해 이를 오픈소스로 공개합니다. 따라서 Symphony를 독립 제품으로 유지보수할 계획은 없습니다. 이것을 참조 구현이라고 생각해 주세요. 많은 개발자가 리포지터리의 뼈대를 만들기 위해 하네스 엔지니어링 게시물에 자신의 코딩 에이전트를 적용했던 것처럼, 여러분도 Symphony spec(새 창에서 열기)과 repository(새 창에서 열기)에 선호하는 코딩 에이전트를 적용해 여러분 환경에 맞는 버전을 만들기를 바랍니다.
핵심적인 힘은 Codex와 그 app server에서 나옵니다. Symphony는 우리가 이미 사용하던 두 가지, 즉 Codex와 Linear를 연결해 작업 관리 문제를 해결하는 방법이었습니다. 코딩 에이전트가 추론과 지시 이행에 점점 더 능숙해짐에 따라, 다른 회사들에서도 병목은 코드 작성에서 에이전트 작업 관리로 옮겨갈 것이라 생각합니다. 흥미로운 점은 이런 코딩 에이전트 시스템을 실험해 보는 장벽이 이제 놀랄 만큼 낮아졌다는 것입니다. 이제 Codex를 활용해 직접 구축해볼 수 있습니다.
커뮤니티에 감사드립니다
출시 후 몇 주 사이에 엔지니어링 커뮤니티가 Symphony를 사용하는 모습을 보게 되어 매우 기쁩니다. 4월 23일 기준으로 GitHub 스타 1.5만 개(새 창에서 열기)를 넘겼습니다.