Earlier framing assumed each /schedule-fired tick was a cold start needing on-disk reconciliation. The actual model is a long-lived orchestrator session where /schedule fires ticks within the running session — TaskList sees prior-tick tasks just fine. Reframed accordingly, while leaving the door open to other trigger sources (Discord channel, webhook, second user session) which DO need cross-session reconciliation. Reconciliation logic now has a fast path and a slow path: - Fast path: task in our TaskList → it's ours → reconcile via TaskGet. - Slow path: task NOT in our TaskList (another session, or our session restarted) → check the worker's heartbeat file. Fresh → another session owns it, leave alone. Stale or missing → consider abandoned, retry; do not call TaskStop on a task we don't own. Workers now write .symphony/heartbeats/<issue-id>.json on start, refresh it at heartbeat_interval_ms cadence (default 60s), and delete it on graceful exit. Stale heartbeats are how abandonment is detected across sessions and after crashes. Also added session_id to state.json running entries so cross-session reconcilers know who spawned what. WORKFLOW.md gains heartbeat_interval_ms and heartbeat_stale_ms knobs under agent: with documented defaults. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
7.4 KiB
symphony
Claude Code port of the openai/symphony orchestrator spec. A long-running, repo-owned automation that polls the project's issue tracker, spawns isolated worker agents for eligible issues, and reconciles state across ticks.
The execution layer is Claude Code's Agent tool instead of codex app-server. Everything
above that — workflow contract, polling, workspaces, retries, hooks — maps to existing
primitives.
Tracker-agnostic by design. Symphony's spec hardcoded Linear; this port does not. The plugin only knows the shape of an issue (id, title, state, etc.) and asks the consuming repo to provide the integration. See "Tracker contract" below.
Layout
commands/symphony-init.md— one-time interactive setup. Detects tracker, writes CLAUDE.md tracker section, drops WORKFLOW.md, optionally schedules the tick.commands/symphony-tick.md— slash command fired by/schedule. One tick = one pass. Silent and non-interactive; safe to fire on a cron.skills/symphony-init/SKILL.md— the init/interview logic.skills/symphony/SKILL.md— the dispatcher. LoadsWORKFLOW.md, queries the tracker via the project's integration, decides what to start/stop/retry, writes state.agents/symphony-worker.md— per-issue executor. Owns one issue, runs in an isolated worktree, hands off when done.templates/WORKFLOW.md— workflow contractsymphony-initcopies into the repo.templates/state.schema.json— shape of the on-disk orchestrator state file.
User flow
- First time:
/symphony-initin the project root. Interview + write. - Steady state:
/symphony-tickis fired by a/scheduleentry inside the long-lived orchestrator session (also set up by init unless the user declines). - Other triggers: ticks can also be fired by remote channels (Discord DM, webhook, another session). The reconciliation logic handles cross-session liveness via worker heartbeats.
- Re-config: re-run
/symphony-init; it detects drift and proposes updates.
Tracker contract
The plugin treats trackers as a capability the project provides, not something Symphony
adapts to. To use the plugin, the consuming repo's CLAUDE.md MUST document, for the
tracker named in WORKFLOW.md's tracker.kind, how to:
- List active issues — return id, identifier, title, description, state, labels, priority, branch_name, url, updated_at. Filtered by a list of "active" state names.
- Get one issue's current state — for reconciliation.
- Transition an issue's state — used by the worker, not the orchestrator.
- Comment on an issue — used by the worker.
Concretely, a project CLAUDE.md section looks like:
## Issue tracker
This project uses GitHub Issues. Symphony workers should:
- List: `gh issue list --state open --json number,title,body,labels,state,updatedAt`
- Get: `gh issue view <number> --json state,title,body,labels`
- Transition: `gh issue edit <number> --add-label "in-review"` (we use labels, not state)
- Comment: `gh issue comment <number> --body "..."`
Active label set: `ready`, `in-progress`. Terminal: closed issues.
…or for Gitea (tracker.kind: gitea), point at the gitea MCP. For the tracker CLI
(tracker.kind: tracker), point at the tracker-usage skill.
The skill and worker read these instructions when they need to talk to the tracker. If
the project CLAUDE.md is silent on the tracker, the orchestrator errors with
tracker_integration_missing and exits the tick.
Runtime model
The orchestrator runs inside a long-lived Claude Code session. /schedule doesn't
cold-start each tick — it sets the prompt for the running session. Tasks spawned in
tick N stay visible to TaskList / TaskGet in tick N+1 within that session, so the
session itself is the primary store of "what's running right now."
Symphony stays open to other trigger sources too: a Discord channel could DM the session and ask for a tick, a webhook hook could trigger one, and a separate user session could conceivably spawn tasks against the same project. To keep all three modes honest, two layers carry state:
TaskList/TaskGet— fast path, authoritative for liveness within this session..symphony/state.json+ worker heartbeats — durable record across sessions and restarts. Authoritative whenTaskListdoesn't know about a task ID.
Reconciliation logic
For each entry in state.json's running map:
- In our
TaskList→ it's our task; check status viaTaskGet; reconcile normally. - Not in our
TaskList→ another session may own it, or the session that spawned it exited. Read the worker's heartbeat file (.symphony/heartbeats/<issue-id>.json).- Heartbeat fresh (within
agent.heartbeat_stale_ms, default 5min) → leave alone; another session is owning it. - Heartbeat stale → consider abandoned; mark for retry, drop from
running. - No heartbeat → ditto.
- Heartbeat fresh (within
This keeps the single-session case simple (TaskList wins) while supporting multi-session and crash recovery cleanly.
State file shape
running— issue_id → { task_id, session_id, started_at, worktree_path }retry_attempts— issue_id → { attempt, due_at_ms, last_error }claimed— set of issue IDs reserved this tick (cleared on dispatch or release)completed— set of issue IDs (bookkeeping)
Worker heartbeats
Workers write .symphony/heartbeats/<issue-id>.json with { timestamp, status, task_id, session_id }
every minute (configurable via agent.heartbeat_interval_ms). On graceful exit they
remove the file. Stale heartbeats — and stale running entries with no heartbeat at
all — are how Symphony detects abandonment.
Mapping to the spec
| Symphony component | Claude Code mechanism |
|---|---|
| Polling daemon | /schedule firing /symphony-tick in a long-lived session |
| Workflow loader | Skill body — reads & parses WORKFLOW.md |
| Issue tracker client | Project CLAUDE.md instructions + whatever tools fit |
| Orchestrator | Skill, runs each tick within the same session |
| Workspace manager | EnterWorktree per worker |
| Agent runner | Agent tool with subagent_type: symphony-worker |
| Hooks | Bash steps inside the worker prompt |
| Concurrency cap | TaskList count + state.json cross-check |
| Retries with backoff | retry_attempts entries, due_at compared each tick |
| Liveness across sessions | Worker heartbeats in .symphony/heartbeats/ |
| Logging | .symphony/logs/<issue>-<ts>.log |
Known divergences from the spec
- Tracker is open, not Linear-only.
tracker.kindis a free string; the project CLAUDE.md provides the integration. No built-in Linear GraphQL client. - No streaming agent telemetry.
Agentreturns one final message; per-turn token counts andlast_codex_eventare not surfaced. - No
codex.*knobs. Sandbox/approval policies are Codex-runtime concepts; Claude Code applies its own permission model. before_run/after_runhooks run inside the worker, not the orchestrator. Failure handling matches the spec (before_run aborts the attempt; after_run logs).