SPEC.md
Status: Draft v1 (language-agnostic)
Purpose: Define a service that orchestrates coding agents to get project work done.
Symphony is a long-running automation service that continuously reads work from an issue tracker (Linear in this specification version), creates an isolated workspace for each issue, and runs a coding agent session for that issue inside the workspace.
The service solves four operational problems:
WORKFLOW.md) so teams version the agent prompt and runtime
settings with their code.Implementations are expected to document their trust and safety posture explicitly. This specification does not require a single approval, sandbox, or operator-confirmation policy; some implementations may target trusted environments with a high-trust configuration, while others may require stricter approvals or sandboxing.
Important boundary:
Human Review), not
necessarily Done.WORKFLOW.md contract.Workflow Loader
WORKFLOW.md.{config, prompt_template}.Config Layer
Issue Tracker Client
Orchestrator
Workspace Manager
Agent Runner
Status Surface (optional)
Logging
Symphony is easiest to port when kept in these layers:
Policy Layer (repo-defined)
WORKFLOW.md prompt body.Configuration Layer (typed getters)
Coordination Layer (orchestrator)
Execution Layer (workspace + agent subprocess)
Integration Layer (Linear adapter)
Observability Layer (logs + optional status surface)
tracker.kind: linear in this specification version).Normalized issue record used by orchestration, prompt rendering, and observability output.
Fields:
id (string)
identifier (string)
ABC-123).title (string)description (string or null)priority (integer or null)
state (string)
branch_name (string or null)
url (string or null)labels (list of strings)
blocked_by (list of blocker refs)
id (string or null)identifier (string or null)state (string or null)created_at (timestamp or null)updated_at (timestamp or null)Parsed WORKFLOW.md payload:
config (map)
prompt_template (string)
Typed runtime values derived from WorkflowDefinition.config plus environment resolution.
Examples:
Filesystem workspace assigned to one issue identifier.
Fields (logical):
path (workspace path; current runtime typically uses absolute paths, but relative roots are
possible if configured without path separators)workspace_key (sanitized issue identifier)created_now (boolean, used to gate after_create hook)One execution attempt for one issue.
Fields (logical):
issue_idissue_identifierattempt (integer or null, null for first run, >=1 for retries/continuation)workspace_pathstarted_atstatuserror (optional)State tracked while a coding-agent subprocess is running.
Fields:
session_id (string, <thread_id>-<turn_id>)thread_id (string)turn_id (string)codex_app_server_pid (string or null)last_codex_event (string/enum or null)last_codex_timestamp (timestamp or null)last_codex_message (summarized payload)codex_input_tokens (integer)codex_output_tokens (integer)codex_total_tokens (integer)last_reported_input_tokens (integer)last_reported_output_tokens (integer)last_reported_total_tokens (integer)turn_count (integer)
Scheduled retry state for an issue.
Fields:
issue_ididentifier (best-effort human ID for status surfaces/logs)attempt (integer, 1-based for retry queue)due_at_ms (monotonic clock timestamp)timer_handle (runtime-specific timer reference)error (string or null)Single authoritative in-memory state owned by the orchestrator.
Fields:
poll_interval_ms (current effective poll interval)max_concurrent_agents (current effective global concurrency limit)running (map issue_id -> running entry)claimed (set of issue IDs reserved/running/retrying)retry_attempts (map issue_id -> RetryEntry)completed (set of issue IDs; bookkeeping only, not dispatch gating)codex_totals (aggregate tokens + runtime seconds)codex_rate_limits (latest rate-limit snapshot from agent events)Issue ID
Issue Identifier
Workspace Key
issue.identifier by replacing any character not in [A-Za-z0-9._-] with _.Normalized Issue State
lowercase.Session ID
thread_id and turn_id as <thread_id>-<turn_id>.Workflow file path precedence:
WORKFLOW.md in the current process working directory.Loader behavior:
missing_workflow_file error.WORKFLOW.md is a Markdown file with optional YAML front matter.
Design note:
WORKFLOW.md should be self-contained enough to describe and run different workflows (prompt,
runtime settings, hooks, and tracker selection/config) without requiring out-of-band
service-specific configuration.Parsing rules:
---, parse lines until the next --- as YAML front matter.Returned workflow object:
config: front matter root object (not nested under a config key).prompt_template: trimmed Markdown body.Top-level keys:
trackerpollingworkspacehooksagentcodexUnknown keys should be ignored for forward compatibility.
Note:
server) without changing the core schema above.server.port (integer) enables the optional HTTP server described in Section
13.7.tracker (object)Fields:
kind (string)
linearendpoint (string)
tracker.kind == "linear": https://api.linear.app/graphqlapi_key (string)
$VAR_NAME.tracker.kind == "linear": LINEAR_API_KEY.$VAR_NAME resolves to an empty string, treat the key as missing.project_slug (string)
tracker.kind == "linear".active_states (list of strings)
Todo, In Progressterminal_states (list of strings)
Closed, Cancelled, Canceled, Duplicate, Donepolling (object)Fields:
interval_ms (integer or string integer)
30000workspace (object)Fields:
root (path string or $VAR)
/symphony_workspaces~ and strings containing path separators are expanded.hooks (object)Fields:
after_create (multiline shell script string, optional)
before_run (multiline shell script string, optional)
after_run (multiline shell script string, optional)
before_remove (multiline shell script string, optional)
timeout_ms (integer, optional)
60000agent (object)Fields:
max_concurrent_agents (integer or string integer)
10max_retry_backoff_ms (integer or string integer)
300000 (5 minutes)max_concurrent_agents_by_state (map state_name -> positive integer)
lowercase) for lookup.codex (object)Fields:
For Codex-owned config values such as approval_policy, thread_sandbox, and
turn_sandbox_policy, supported values are defined by the targeted Codex app-server version.
Implementors should treat them as pass-through Codex config values rather than relying on a
hand-maintained enum in this spec. To inspect the installed Codex schema, run
codex app-server generate-json-schema --out <dir> and inspect the relevant definitions referenced
by v2/ThreadStartParams.json and v2/TurnStartParams.json. Implementations may validate these
fields locally if they want stricter startup checks.
command (string shell command)
codex app-serverbash -lc in the workspace directory.approval_policy (Codex AskForApproval value)
thread_sandbox (Codex SandboxMode value)
turn_sandbox_policy (Codex SandboxPolicy value)
turn_timeout_ms (integer)
3600000 (1 hour)read_timeout_ms (integer)
5000stall_timeout_ms (integer)
300000 (5 minutes)<= 0, stall detection is disabled.The Markdown body of WORKFLOW.md is the per-issue prompt template.
Rendering requirements:
Template input variables:
issue (object)
attempt (integer or null)
null/absent on first attempt.Fallback prompt behavior:
You are working on an issue from Linear.).Error classes:
missing_workflow_fileworkflow_parse_errorworkflow_front_matter_not_a_maptemplate_parse_error (during prompt rendering)template_render_error (unknown variable/filter, invalid interpolation)Dispatch gating behavior:
Configuration precedence:
$VAR_NAME inside selected YAML values.Value coercion semantics:
~ home expansion$VAR expansion for env-backed path valuesDynamic reload is required:
WORKFLOW.md for changes.This validation is a scheduler preflight run before attempting to dispatch new work. It validates the workflow/config needed to poll and launch workers, not a full audit of all possible workflow behavior.
Startup validation:
Per-tick dispatch validation:
Validation checks:
tracker.kind is present and supported.tracker.api_key is present after $ resolution.tracker.project_slug is present when required by the selected tracker kind.codex.command is present and non-empty.This section is intentionally redundant so a coding agent can implement the config layer quickly.
tracker.kind: string, required, currently lineartracker.endpoint: string, default https://api.linear.app/graphql when tracker.kind=lineartracker.api_key: string or $VAR, canonical env LINEAR_API_KEY when tracker.kind=lineartracker.project_slug: string, required when tracker.kind=lineartracker.active_states: list of strings, default ["Todo", "In Progress"]tracker.terminal_states: list of strings, default ["Closed", "Cancelled", "Canceled", "Duplicate", "Done"]polling.interval_ms: integer, default 30000workspace.root: path, default /symphony_workspacesworker.ssh_hosts (extension): list of SSH host strings, optional; when omitted, work runs
locallyworker.max_concurrent_agents_per_host (extension): positive integer, optional; shared per-host
cap applied across configured SSH hostshooks.after_create: shell script or nullhooks.before_run: shell script or nullhooks.after_run: shell script or nullhooks.before_remove: shell script or nullhooks.timeout_ms: integer, default 60000agent.max_concurrent_agents: integer, default 10agent.max_turns: integer, default 20agent.max_retry_backoff_ms: integer, default 300000 (5m)agent.max_concurrent_agents_by_state: map of positive integers, default {}codex.command: shell command string, default codex app-servercodex.approval_policy: Codex AskForApproval value, default implementation-definedcodex.thread_sandbox: Codex SandboxMode value, default implementation-definedcodex.turn_sandbox_policy: Codex SandboxPolicy value, default implementation-definedcodex.turn_timeout_ms: integer, default 3600000codex.read_timeout_ms: integer, default 5000codex.stall_timeout_ms: integer, default 300000server.port (extension): integer, optional; enables the optional HTTP server, 0 may be used
for ephemeral local bind, and CLI --port overrides itThe orchestrator is the only component that mutates scheduling state. All worker outcomes are reported back to it and converted into explicit state transitions.
This is not the same as tracker states (Todo, In Progress, etc.). This is the service's internal
claim state.
Unclaimed
Claimed
Running or RetryQueued.Running
running map.RetryQueued
retry_attempts.Released
Important nuance:
agent.max_turns.A run attempt transitions through these phases:
PreparingWorkspaceBuildingPromptLaunchingAgentProcessInitializingSessionStreamingTurnFinishingSucceededFailedTimedOutStalledCanceledByReconciliationDistinct terminal reasons are important because retry logic and logs differ.
Poll Tick
Worker Exit (normal)
1) after the worker exhausts or finishes its in-process
turn loop.Worker Exit (abnormal)
Codex Update Event
Retry Timer Fired
Reconciliation State Refresh
Stall Timeout
claimed and running checks are required before launching any worker.At startup, the service validates config, performs startup cleanup, schedules an immediate tick, and
then repeats every polling.interval_ms.
The effective poll interval should be updated when workflow config changes are re-applied.
Tick sequence:
If per-tick validation fails, dispatch is skipped for that tick, but reconciliation still happens first.
An issue is dispatch-eligible only if all are true:
id, identifier, title, and state.active_states and not in terminal_states.running.claimed.Todo state passes:
Todo, do not dispatch when any blocker is non-terminal.Sorting order (stable intent):
priority ascending (1..4 are preferred; null/unknown sorts last)created_at oldest firstidentifier lexicographic tie-breakerGlobal limit:
available_slots = max(max_concurrent_agents - running_count, 0)Per-state limit:
max_concurrent_agents_by_state[state] if present (state key normalized)The runtime counts issues by their current tracked state in the running map.
Optional SSH host limit:
worker.max_concurrent_agents_per_host is set, each configured SSH host may run at most
that many concurrent agents at once.Retry entry creation:
attempt, identifier, error, due_at_ms, and new timer handle.Backoff formula:
1000 ms.delay = min(10000 * 2^(attempt - 1), agent.max_retry_backoff_ms).300000 / 5m).Retry handling behavior:
issue_id.no available orchestrator slots.Note:
Reconciliation runs every tick and has two parts.
Part A: Stall detection
elapsed_ms since:
last_codex_timestamp if any event has been seen, elsestarted_atelapsed_ms > codex.stall_timeout_ms, terminate the worker and queue a retry.stall_timeout_ms <= 0, skip stall detection entirely.Part B: Tracker state refresh
When the service starts:
This prevents stale terminal workspaces from accumulating after restarts.
Workspace root:
workspace.root (normalized path; the current config layer expands path-like values and preserves
bare relative names)Per-issue workspace path:
<workspace.root>/<sanitized_issue_identifier>Workspace persistence:
Input: issue.identifier
Algorithm summary:
workspace_key.created_now=true only if the directory was created during this call; otherwise
created_now=false.created_now=true, run after_create hook if configured.Notes:
The spec does not require any built-in VCS or repository bootstrap behavior.
Implementations may populate or synchronize the workspace using implementation-defined logic and/or
hooks (for example after_create and/or before_run).
Failure handling:
Supported hooks:
hooks.after_createhooks.before_runhooks.after_runhooks.before_removeExecution contract:
cwd.sh -lc <script> (or a stricter equivalent such as bash -lc <script>) is a
conforming default.hooks.timeout_ms; default: 60000 ms.Failure semantics:
after_create failure or timeout is fatal to workspace creation.before_run failure or timeout is fatal to the current run attempt.after_run failure or timeout is logged and ignored.before_remove failure or timeout is logged and ignored.This is the most important portability constraint.
Invariant 1: Run the coding agent only in the per-issue workspace path.
cwd == workspace_pathInvariant 2: Workspace path must stay inside workspace root.
workspace_path to have workspace_root as a prefix directory.Invariant 3: Workspace key is sanitized.
[A-Za-z0-9._-] allowed in workspace directory names._.This section defines the language-neutral contract for integrating a coding agent app-server.
Compatibility profile:
Subprocess launch parameters:
codex.commandbash -lc <codex.command>Notes:
codex app-server.Recommended additional process settings:
Reference: https://developers.openai.com/codex/app-server/
The client must send these protocol messages in order:
Illustrative startup transcript (equivalent payload shapes are acceptable if they preserve the same semantics):
{"id":1,"method":"initialize","params":{"clientInfo":{"name":"symphony","version":"1.0"},"capabilities":{}}}
{"method":"initialized","params":{}}
{"id":2,"method":"thread/start","params":{"approvalPolicy":"<implementation-defined>","sandbox":"<implementation-defined>","cwd":"/abs/workspace"}}
{"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>"}}}
initialize request
clientInfo object (for example {name, version})capabilities object (may be empty)read_timeout_ms)initialized notificationthread/start request
approvalPolicy = implementation-defined session approval policy valuesandbox = implementation-defined session sandbox valuecwd = absolute workspace pathturn/start request
threadIdinput = single text item containing rendered prompt for the first turn, or continuation
guidance for later turns on the same threadcwdtitle = <issue.identifier>: <issue.title>approvalPolicy = implementation-defined turn approval policy valuesandboxPolicy = implementation-defined object-form sandbox policy payload when required by
the targeted app-server versionSession identifiers:
thread_id from thread/start result result.thread.idturn_id from each turn/start result result.turn.idsession_id = "<thread_id>-<turn_id>"thread_id for all continuation turns inside one worker runThe client reads line-delimited messages until the turn terminates.
Completion conditions:
turn/completed -> successturn/failed -> failureturn/cancelled -> failureturn_timeout_ms) -> failureContinuation processing:
turn/start
on the same live threadId.Line handling requirements:
The app-server client emits structured events to the orchestrator callback. Each event should include:
event (enum/string)timestamp (UTC timestamp)codex_app_server_pid (if available)usage map (token counts)Important emitted events may include:
session_startedstartup_failedturn_completedturn_failedturn_cancelledturn_ended_with_errorturn_input_requiredapproval_auto_approvedunsupported_tool_callnotificationother_messagemalformedApproval, sandbox, and user-input behavior is implementation-defined.
Policy requirements:
Example high-trust behavior:
Unsupported dynamic tool calls:
item/tool/call) that is not supported, return a tool
failure response and continue the session.Optional client-side tool extension:
linear_graphql.linear_graphql extension contract:
Purpose: execute a raw GraphQL query or mutation against Linear using Symphony's configured tracker auth for the current session.
Availability: only meaningful when tracker.kind == "linear" and valid Linear auth is configured.
Preferred input shape:
{
"query": "single GraphQL query or mutation document",
"variables": {
"optional": "graphql variables object"
}
}
query must be a non-empty string.
query must contain exactly one GraphQL operation.
variables is optional and, when present, must be a JSON object.
Implementations may additionally accept a raw GraphQL query string as shorthand input.
Execute one GraphQL operation per tool call.
If the provided document contains multiple operations, reject the tool call as invalid input.
operationName selection is intentionally out of scope for this extension.
Reuse the configured Linear endpoint and auth from the active Symphony workflow/runtime config; do not require the coding agent to read raw tokens from disk.
Tool result semantics:
errors -> success=trueerrors present -> success=false, but preserve the GraphQL response body
for debuggingsuccess=false with an error payloadReturn the GraphQL response or error payload as structured tool output that the model can inspect in-session.
Illustrative responses (equivalent payload shapes are acceptable if they preserve the same outcome):
{"id":"<approval-id>","result":{"approved":true}}
{"id":"<tool-call-id>","result":{"success":false,"error":"unsupported_tool_call"}}
Hard failure on user input requirement:
item/tool/requestUserInput), orTimeouts:
codex.read_timeout_ms: request/response timeout during startup and sync requestscodex.turn_timeout_ms: total turn stream timeoutcodex.stall_timeout_ms: enforced by orchestrator based on event inactivityError mapping (recommended normalized categories):
codex_not_foundinvalid_workspace_cwdresponse_timeoutturn_timeoutport_exitresponse_errorturn_failedturn_cancelledturn_input_requiredThe Agent Runner wraps workspace + prompt + app-server client.
Behavior:
Note:
An implementation must support these tracker adapter operations:
fetch_candidate_issues()
fetch_issues_by_states(state_names)
fetch_issue_states_by_ids(issue_ids)
Linear-specific requirements for tracker.kind == "linear":
tracker.kind == "linear"https://api.linear.app/graphql)Authorization headertracker.project_slug maps to Linear project slugIdproject: { slugId: { eq: $projectSlug } }[ID!]5030000 msImportant:
A non-Linear implementation may change transport details, but the normalized outputs must match the domain model in Section 4.
Candidate issue normalization should produce fields listed in Section 4.1.1.
Additional normalization details:
labels -> lowercase stringsblocked_by -> derived from inverse relations where relation type is blockspriority -> integer only (non-integers become null)created_at and updated_at -> parse ISO-8601 timestampsRecommended error categories:
unsupported_tracker_kindmissing_tracker_api_keymissing_tracker_project_sluglinear_api_request (transport failures)linear_api_status (non-200 HTTP)linear_graphql_errorslinear_unknown_payloadlinear_missing_end_cursor (pagination integrity error)Orchestrator behavior on tracker errors:
Symphony does not require first-class tracker write APIs in the orchestrator.
Human Review) rather than tracker terminal state Done.linear_graphql client-side tool extension is implemented, it is still part of
the agent toolchain rather than orchestrator business logic.Inputs to prompt rendering:
workflow.prompt_templateissue objectattempt integer (retry/continuation metadata)attempt should be passed to the template because the workflow prompt may provide different
instructions for:
attempt null or absent)If prompt rendering fails:
Required context fields for issue-related logs:
issue_idissue_identifierRequired context for coding-agent session lifecycle logs:
session_idMessage formatting requirements:
key=value phrasing.completed, failed, retrying, etc.).The spec does not prescribe where logs must go (stderr, file, remote sink, etc.).
Requirements:
If the implementation exposes a synchronous runtime snapshot (for dashboards or monitoring), it should return:
running (list of running session rows)turn_countretrying (list of retry queue rows)codex_totals
input_tokensoutput_tokenstotal_tokensseconds_running (aggregate runtime seconds as of snapshot time, including active sessions)rate_limits (latest coding-agent rate limit payload, if available)Recommended snapshot error modes:
timeoutunavailableA human-readable status surface (terminal output, dashboard, etc.) is optional and implementation-defined.
If present, it should draw from orchestrator state/metrics only and must not be required for correctness.
Token accounting rules:
thread/tokenUsage/updated payloadstotal_token_usage within token-count wrapper eventslast_token_usage for dashboard/API totals.usage maps as cumulative totals unless the event type defines them that
way.Runtime accounting:
running entries (for example started_at) when producing a
snapshot/status view.Rate-limit tracking:
Humanized summaries of raw agent protocol events are optional.
If implemented:
This section defines an optional HTTP interface for observability and operational control.
If implemented:
Enablement (extension):
--port argument is provided.server.port is present in WORKFLOW.md front matter.server.port is extension configuration and is intentionally not part of the core front-matter
schema in Section 5.3.--port overrides server.port when both are present.server.port must be an integer. Positive values bind that port. 0 may be used to request an
ephemeral port for local development and tests.127.0.0.1 or host equivalent) unless explicitly
configured otherwise.server.port) do not need to hot-rebind;
restart-required behavior is conformant./)/./api/v1/*)Provide a JSON REST API under /api/v1/* for current runtime state and operational debugging.
Minimum endpoints:
GET /api/v1/state
Returns a summary view of the current system state (running sessions, retry queue/delays, aggregate token/runtime totals, latest rate limits, and any additional tracked summary fields).
Suggested response shape:
{
"generated_at": "2026-02-24T20:15:30Z",
"counts": {
"running": 2,
"retrying": 1
},
"running": [
{
"issue_id": "abc123",
"issue_identifier": "MT-649",
"state": "In Progress",
"session_id": "thread-1-turn-1",
"turn_count": 7,
"last_event": "turn_completed",
"last_message": "",
"started_at": "2026-02-24T20:10:12Z",
"last_event_at": "2026-02-24T20:14:59Z",
"tokens": {
"input_tokens": 1200,
"output_tokens": 800,
"total_tokens": 2000
}
}
],
"retrying": [
{
"issue_id": "def456",
"issue_identifier": "MT-650",
"attempt": 3,
"due_at": "2026-02-24T20:16:00Z",
"error": "no available orchestrator slots"
}
],
"codex_totals": {
"input_tokens": 5000,
"output_tokens": 2400,
"total_tokens": 7400,
"seconds_running": 1834.2
},
"rate_limits": null
}
GET /api/v1/<issue_identifier>
Returns issue-specific runtime/debug details for the identified issue, including any information the implementation tracks that is useful for debugging.
Suggested response shape:
{
"issue_identifier": "MT-649",
"issue_id": "abc123",
"status": "running",
"workspace": {
"path": "/tmp/symphony_workspaces/MT-649"
},
"attempts": {
"restart_count": 1,
"current_retry_attempt": 2
},
"running": {
"session_id": "thread-1-turn-1",
"turn_count": 7,
"state": "In Progress",
"started_at": "2026-02-24T20:10:12Z",
"last_event": "notification",
"last_message": "Working on tests",
"last_event_at": "2026-02-24T20:14:59Z",
"tokens": {
"input_tokens": 1200,
"output_tokens": 800,
"total_tokens": 2000
}
},
"retry": null,
"logs": {
"codex_session_logs": [
{
"label": "latest",
"path": "/var/log/symphony/codex/MT-649/latest.log",
"url": null
}
]
},
"recent_events": [
{
"at": "2026-02-24T20:14:59Z",
"event": "notification",
"message": "Working on tests"
}
],
"last_error": null,
"tracked": {}
}
If the issue is unknown to the current in-memory state, return 404 with an error response (for
example {\"error\":{\"code\":\"issue_not_found\",\"message\":\"...\"}}).
POST /api/v1/refresh
Queues an immediate tracker poll + reconciliation cycle (best-effort trigger; implementations may coalesce repeated requests).
Suggested request body: empty body or {}.
Suggested response (202 Accepted) shape:
{
"queued": true,
"coalesced": false,
"requested_at": "2026-02-24T20:15:30Z",
"operations": ["poll", "reconcile"]
}
API design notes:
/refresh.405 Method Not Allowed.{"error":{"code":"...","message":"..."}}.Workflow/Config Failures
WORKFLOW.mdWorkspace Failures
Agent Session Failures
Tracker Failures
Observability Failures
Dispatch validation failures:
Worker failures:
Tracker candidate-fetch failures:
Reconciliation state-refresh failures:
Dashboard/log failures:
Current design is intentionally in-memory for scheduler state.
After restart:
Operators can control behavior by:
WORKFLOW.md (prompt and most runtime settings).WORKFLOW.md changes should be detected and re-applied automatically without restart.Each implementation defines its own trust boundary.
Operational safety requirements:
Mandatory:
Recommended additional hardening for ports:
$VAR indirection in workflow config.Workspace hooks are arbitrary shell scripts from WORKFLOW.md.
Implications:
Running Codex agents against repositories, issue trackers, and other inputs that may contain sensitive data or externally-controlled content can be dangerous. A permissive deployment can lead to data leaks, destructive mutations, or full machine compromise if the agent is induced to execute harmful commands or use overly-powerful integrations.
Implementations should explicitly evaluate their own risk profile and harden the execution harness where appropriate. This specification intentionally does not mandate a single hardening posture, but ports should not assume that tracker data, repository contents, prompt inputs, or tool arguments are fully trustworthy just because they originate inside a normal workflow.
Possible hardening measures include:
linear_graphql tool so it can only read or mutate data inside the
intended project scope, rather than exposing general workspace-wide tracker access.The correct controls are deployment-specific, but implementations should document them clearly and treat harness hardening as part of the core safety model rather than an optional afterthought.
function start_service():
configure_logging()
start_observability_outputs()
start_workflow_watch(on_change=reload_and_reapply_workflow)
state = {
poll_interval_ms: get_config_poll_interval_ms(),
max_concurrent_agents: get_config_max_concurrent_agents(),
running: {},
claimed: set(),
retry_attempts: {},
completed: set(),
codex_totals: {input_tokens: 0, output_tokens: 0, total_tokens: 0, seconds_running: 0},
codex_rate_limits: null
}
validation = validate_dispatch_config()
if validation is not ok:
log_validation_error(validation)
fail_startup(validation)
startup_terminal_workspace_cleanup()
schedule_tick(delay_ms=0)
event_loop(state)
on_tick(state):
state = reconcile_running_issues(state)
validation = validate_dispatch_config()
if validation is not ok:
log_validation_error(validation)
notify_observers()
schedule_tick(state.poll_interval_ms)
return state
issues = tracker.fetch_candidate_issues()
if issues failed:
log_tracker_error()
notify_observers()
schedule_tick(state.poll_interval_ms)
return state
for issue in sort_for_dispatch(issues):
if no_available_slots(state):
break
if should_dispatch(issue, state):
state = dispatch_issue(issue, state, attempt=null)
notify_observers()
schedule_tick(state.poll_interval_ms)
return state
function reconcile_running_issues(state):
state = reconcile_stalled_runs(state)
running_ids = keys(state.running)
if running_ids is empty:
return state
refreshed = tracker.fetch_issue_states_by_ids(running_ids)
if refreshed failed:
log_debug("keep workers running")
return state
for issue in refreshed:
if issue.state in terminal_states:
state = terminate_running_issue(state, issue.id, cleanup_workspace=true)
else if issue.state in active_states:
state.running[issue.id].issue = issue
else:
state = terminate_running_issue(state, issue.id, cleanup_workspace=false)
return state
function dispatch_issue(issue, state, attempt):
worker = spawn_worker(
fn -> run_agent_attempt(issue, attempt, parent_orchestrator_pid) end
)
if worker spawn failed:
return schedule_retry(state, issue.id, next_attempt(attempt), {
identifier: issue.identifier,
error: "failed to spawn agent"
})
state.running[issue.id] = {
worker_handle,
monitor_handle,
identifier: issue.identifier,
issue,
session_id: null,
codex_app_server_pid: null,
last_codex_message: null,
last_codex_event: null,
last_codex_timestamp: null,
codex_input_tokens: 0,
codex_output_tokens: 0,
codex_total_tokens: 0,
last_reported_input_tokens: 0,
last_reported_output_tokens: 0,
last_reported_total_tokens: 0,
retry_attempt: normalize_attempt(attempt),
started_at: now_utc()
}
state.claimed.add(issue.id)
state.retry_attempts.remove(issue.id)
return state
function run_agent_attempt(issue, attempt, orchestrator_channel):
workspace = workspace_manager.create_for_issue(issue.identifier)
if workspace failed:
fail_worker("workspace error")
if run_hook("before_run", workspace.path) failed:
fail_worker("before_run hook error")
session = app_server.start_session(workspace=workspace.path)
if session failed:
run_hook_best_effort("after_run", workspace.path)
fail_worker("agent session startup error")
max_turns = config.agent.max_turns
turn_number = 1
while true:
prompt = build_turn_prompt(workflow_template, issue, attempt, turn_number, max_turns)
if prompt failed:
app_server.stop_session(session)
run_hook_best_effort("after_run", workspace.path)
fail_worker("prompt error")
turn_result = app_server.run_turn(
session=session,
prompt=prompt,
issue=issue,
on_message=(msg) -> send(orchestrator_channel, {codex_update, issue.id, msg})
)
if turn_result failed:
app_server.stop_session(session)
run_hook_best_effort("after_run", workspace.path)
fail_worker("agent turn error")
refreshed_issue = tracker.fetch_issue_states_by_ids([issue.id])
if refreshed_issue failed:
app_server.stop_session(session)
run_hook_best_effort("after_run", workspace.path)
fail_worker("issue state refresh error")
issue = refreshed_issue[0] or issue
if issue.state is not active:
break
if turn_number >= max_turns:
break
turn_number = turn_number + 1
app_server.stop_session(session)
run_hook_best_effort("after_run", workspace.path)
exit_normal()
on_worker_exit(issue_id, reason, state):
running_entry = state.running.remove(issue_id)
state = add_runtime_seconds_to_totals(state, running_entry)
if reason == normal:
state.completed.add(issue_id) # bookkeeping only
state = schedule_retry(state, issue_id, 1, {
identifier: running_entry.identifier,
delay_type: continuation
})
else:
state = schedule_retry(state, issue_id, next_attempt_from(running_entry), {
identifier: running_entry.identifier,
error: format("worker exited: %reason")
})
notify_observers()
return state
on_retry_timer(issue_id, state):
retry_entry = state.retry_attempts.pop(issue_id)
if missing:
return state
candidates = tracker.fetch_candidate_issues()
if fetch failed:
return schedule_retry(state, issue_id, retry_entry.attempt + 1, {
identifier: retry_entry.identifier,
error: "retry poll failed"
})
issue = find_by_id(candidates, issue_id)
if issue is null:
state.claimed.remove(issue_id)
return state
if available_slots(state) == 0:
return schedule_retry(state, issue_id, retry_entry.attempt + 1, {
identifier: issue.identifier,
error: "no available orchestrator slots"
})
return dispatch_issue(issue, state, attempt=retry_entry.attempt)
A conforming implementation should include tests that cover the behaviors defined in this specification.
Validation profiles:
Core Conformance: deterministic tests required for all conforming implementations.Extension Conformance: required only for optional features that an implementation chooses to
ship.Real Integration Profile: environment-dependent smoke/integration checks recommended before
production use.Unless otherwise noted, Sections 17.1 through 17.7 are Core Conformance. Bullets that begin with
If ... is implemented are Extension Conformance.
WORKFLOW.md when no explicit runtime path is providedWORKFLOW.md returns typed errortracker.kind validation enforces currently supported kind (linear)tracker.api_key works (including $VAR indirection)$VAR resolution works for tracker API key and path values~ path expansion workscodex.command is preserved as a shell command stringissue and attempttmp, .elixir_ls) are removed during prepafter_create hook runs only on new workspace creationbefore_run hook runs before each attempt and failure/timeouts abort the current attemptafter_run hook runs after each attempt and failure/timeouts are logged and ignoredbefore_remove hook runs on cleanup and failures/timeouts are ignoredslugId)fetch_issues_by_states([]) returns empty without API callblocks[ID!]) as specified in Section 11.2Todo issue with non-terminal blockers is not eligibleTodo issue with terminal blockers is eligibleagent.max_retry_backoff_msbash -lc <codex.command>initialize, initialized, thread/start, turn/startinitialize includes client identity/capabilities payload required by the targeted Codex
app-server protocolthread/start and turn/start parse nested IDs and emit session_startedlinear_graphql client-side tool extension is implemented:
query / variables inputs execute against configured Linear autherrors produce success=false while preserving the GraphQL bodypath-to-WORKFLOW.md)./WORKFLOW.md when no workflow path argument is provided./WORKFLOW.mdThese checks are recommended for production readiness and may be skipped in CI when credentials, network access, or external service permissions are unavailable.
LINEAR_API_KEY or a
documented local bootstrap mechanism (for example ~/.linear_api_key).Use the same validation profiles as Section 17:
Core ConformanceExtension ConformanceReal Integration ProfileWORKFLOW.md loader with YAML front matter + prompt body split$ resolutionWORKFLOW.md watch/reload/re-apply for config and promptafter_create, before_run, after_run, before_remove)hooks.timeout_ms, default 60000)codex.command, default codex app-server)issue and attempt variablesagent.max_retry_backoff_ms, default 5m)issue_id, issue_identifier, and session_id--port over server.port, uses a safe default bind host, and
exposes the baseline endpoints/error semantics in Section 13.7 if shipped.linear_graphql client-side tool extension exposes raw Linear GraphQL access through the
app-server session using configured Symphony auth.Real Integration Profile from Section 17.8 with valid credentials and network access.This appendix describes a common extension profile in which Symphony keeps one central orchestrator but executes worker runs on one or more remote hosts over SSH.
worker.ssh_hosts provides the candidate SSH destinations for remote execution.workspace.root is interpreted on the remote host, not on the orchestrator host.worker.max_concurrent_agents_per_host is an optional shared per-host cap across configured SSH
hosts.