Files
claude-plugins/symphony/CLAUDE.md
movq ee4e0b236e fix(symphony): correct runtime model — long-lived session, with multi-session support
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>
2026-04-29 21:51:23 -05:00

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. Loads WORKFLOW.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 contract symphony-init copies into the repo.
  • templates/state.schema.json — shape of the on-disk orchestrator state file.

User flow

  1. First time: /symphony-init in the project root. Interview + write.
  2. Steady state: /symphony-tick is fired by a /schedule entry inside the long-lived orchestrator session (also set up by init unless the user declines).
  3. 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.
  4. 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:

  1. List active issues — return id, identifier, title, description, state, labels, priority, branch_name, url, updated_at. Filtered by a list of "active" state names.
  2. Get one issue's current state — for reconciliation.
  3. Transition an issue's state — used by the worker, not the orchestrator.
  4. 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:

  1. TaskList / TaskGet — fast path, authoritative for liveness within this session.
  2. .symphony/state.json + worker heartbeats — durable record across sessions and restarts. Authoritative when TaskList doesn'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 via TaskGet; 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.

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.kind is a free string; the project CLAUDE.md provides the integration. No built-in Linear GraphQL client.
  • No streaming agent telemetry. Agent returns one final message; per-turn token counts and last_codex_event are not surfaced.
  • No codex.* knobs. Sandbox/approval policies are Codex-runtime concepts; Claude Code applies its own permission model.
  • before_run / after_run hooks run inside the worker, not the orchestrator. Failure handling matches the spec (before_run aborts the attempt; after_run logs).