Configuration
The conductor reads one JSON config, overlays a few environment variables, and fills every unset field with a default. conductor.example.json at the repo root is a complete, valid file to copy and trim.
Nothing here is required except the projects: a file with only a projects array and each project's tenant, project and repo_dir runs on defaults.
Where the file lives and how it is found
- **
conductor serve -config <path>** — an explicit path. It is used as-is and never relocates the state dir. - Workspace discovery — with no
-config, the conductor walks up from the working directory for aconductor.json. A config found this way makes its own folder the state dir, so two workspaces on one host never share state. - Default —
~/.switchyard-conductor/config.jsonwhen neither applies.
conductor doctor prints every resolved fact — the path, the state dir, each default — and prints no secret. **Read the config through doctor or one named field (jq -r '.state_dir' <config>), never by cat**: the file is what agents are pointed at to orient themselves, and anything printed into a transcript is outside what the conductor can redact or rotate. See the-config-is-read-through-doctor.
Credentials are references, never values
Five fields carry credentials — faultline_dsn, faultline_api_token, github_token, the key of each entry of callboard_forks, and rails.token — and every one of them holds a reference to where the secret is kept, never the secret itself. The loader refuses a config that holds one inline.
env:NAME— read from environment variableNAME.file:/path— read from a file (mode 600).~expands.
| field | sealed by | exported form that outranks it |
|---|---|---|
faultline_dsn | conductor faultline login | $FAULTLINE_DSN |
faultline_api_token | conductor faultline login | $FAULTLINE_API_TOKEN |
github_token | conductor github login | $GH_TOKEN |
each callboard_forks[].key | nothing — write the reference by hand (callboard_forks) | none |
rails.token | nothing — write the reference by hand (rails) | none |
Each refusal names the login that repairs that field — or, where no command writes the field, the sources that are supported — because a remedy that reports success and leaves the field as it was is worse than none. conductor faultline login also seals each project's own faultline_dsn. A bare Faultline collector address with no ingest key is not a secret and may be written inline; a GitHub token has no such harmless form — the whole value is the credential. See a-credential-the-conductor-holds-never and credentials-in-transcripts.
Top-level settings
| key | type | default | what it does |
|---|---|---|---|
base_url | string | https://ironway.work | The Ironway the conductor talks to. A host still configured with https://switchyard.work keeps working. |
product_name | string | — (Ironway) | The product name a brief, a posted comment and the dashboard use while the connected instance cannot say what it is called (PRD #942). The name normally comes from the instance's own GET /api/v1/instance, cached per instance; when that endpoint is down, unreachable or older than the route, this key is used, and when it is unset too the name is Ironway. Nothing fails over it: the tick continues. Restart-only. |
state_dir | path | ~/.switchyard-conductor (or the workspace folder) | Where sessions, events, ledgers, the install id and sealed secrets live. ~ expands. |
agent_prefix | string | conductor@<short-host>-<install-id>, minted once into state_dir/agent-prefix | Prepended to every agent name; the install id is what tells two same-named hosts apart. The default is stored on first start and reused, so a hostname the network later changes does not rename the agent (the loader warns instead); deleting the file takes the new name on the next start. |
dashboard_addr | host:port | 127.0.0.1:8095 | Where serve and conductor dashboard serve the local web UI, GET /health and the rest of the HTTP surface (the generated route index: http-api.md). Loopback only: a host that is not 127.0.0.0/8, ::1 or localhost (including 0.0.0.0, :: and an empty host) is refused at load, from the file or IRONWAY_CONDUCTOR_DASHBOARD_ADDR (was SWITCHYARD_CONDUCTOR_DASHBOARD_ADDR), and again at bind and for conductor dashboard -addr. |
launcher | string | claude -p --dangerously-skip-permissions | The command every role's session runs, the brief piped on stdin. |
launchers | map | — | Per-role override of launcher. See Launchers. |
model_launchers | map[string]string | — | A PRD's preferred model → the full launcher command line that runs it (PRD #1058). Consulted after escalate_to and before routing; an unserved preference falls through. See Model launchers. |
routing | []rule | — | The per-item routing table (PRD #812): an ORDERED list of {role, when, launcher} rules that pick which configured launcher ONE work item runs on, from signals the claim and the cut tree carry. See Routing. |
poll_seconds | int | 30 | How often the pool is swept for claimable work. The stream is latency; the poll is the guarantee. |
checkin_seconds | int | 60 | How often serve checks in with ironway.work for its remote control: registers this host, reports its running sessions, receives its tuning, its pause and any upgrade request (remote control). Floor 15. It is not the bound on how fast a tuning edit reaches this host: a role heartbeat's settings_version triggers a refetch of its own (PRD #664), so a lever moved in the browser arrives in a poll rather than in a check-in interval (settings reaching a running conductor). |
wake_window_seconds | int | 30 | Event-wake coalescing window: the first feed event ticks a role at once, the rest fold into one trailing tick. |
lease_seconds | int | 1800 | Claim/lease length a role heartbeats within. |
idle_backoff_seconds | int | 600 | After a role's run ends idle, do not respawn it for the project for this long. 0 disables. |
release_cooldown_seconds | int | 1800 | A bead whose newest handoff is a conductor release younger than this is skipped by claim-next. |
resume_ramp_seconds | int | 600 | The resume ramp (PRD #767): for this long after a pause is lifted or serve starts, the host starts at most resume_ramp_sessions new role sessions per tick across all projects; after it, nothing but the per-role caps bounds a tick. A host coming back meets every queue full at once, and before the ramp the first pass started its whole capacity — on 2026-10-07 that burst spent the shared 5,000-an-hour GitHub budget in about 45 minutes and the rest of the hour was 403s on every lane. It bounds STARTS, never what is already running, and it is never a refusal a person fixes: the row says the host is ramping and names the reset. 0 turns the ramp off; unset is the default. /health's resume_ramp reports whether one stands. |
resume_ramp_sessions | int | 4 | How many new role sessions one ramped tick may start, host-wide. Four is what a single project needs to move one pull request — a builder, a reviewer, a validator and a rework — so a ramped pass is a working host rather than a stalled one. The count is deliberately not divided between projects: the budget it protects is the account's, so the first project of a pass may spend all four and the next pass is a fresh four. Negative admits none. |
spawn_hold_on_host_load | bool | true | The spawn hold on host load (idea #257246, internal/orchestrator/loadhold.go): while the host is at or over patrol.load_per_core load per core AND more of its CPU is going to processes the conductor did not start than to its own sessions — the patrol's host-load finding, read once per pass — serve starts no new role session. It bounds STARTS only: a running session is never held or killed, and the session-free lanes (merge, shipper, post-merge, patrol) keep running. Load the conductor's own sessions make never engages it. Hysteresis: a held host is released only after two consecutive passes read it under 0.75× the threshold (or its CPU its own). A refused row reads host overloaded: …; /health's spawn_hold reports whether it stands and the last reading. The self-update pass reads the same hold: while it stands, a pass whose gate would be the local go test ./... is deferred rather than started and one the forge's verdict decides runs in full (PRD #1007, see update below). An unreadable load never holds. A quiet host pays one load-average read per pass and the whole-table ps only when that figure is over the threshold — but while a hold STANDS every pass reads the host in full, so read_at, load_per_core and the two core figures beside it are the reading the release was decided on and not a skipped pass's zeroes; and a pass that cannot read the host at all counts toward the release rather than leaving the hold as it was, since a hold only a successful reading can lift stands for good on exactly the host whose load breaks the reading (switchyard issue #6024). false turns it off. |
spawn_hold_on_preflight | bool | true | The spawn hold on a failing toolchain preflight (PRD #1006, internal/orchestrator/preflighthold.go): while a project's preflight cannot run — git --version first, then each command in the project's list, each under a 30-second timeout — serve starts no new role session and no swarm for that project. It is PER PROJECT, where the hold above it is per host: a broken Swift install stops the Swift project and says nothing about the Go one beside it. It bounds STARTS only: a running session is never held or killed, and the session-free lanes (merge, shipper, post-merge, patrol) keep running. A refused row reads toolchain preflight failing: … and names the command, its exit code and the last line it printed. A held project asks again at most once a minute and is released by the FIRST preflight that passes; a passing result stands for ten minutes before the project asks again. A preflight that could not be RUN at all — no checkout at repo_dir, no shell — never holds. false turns it off. See the toolchain preflight. |
spawn_hold_on_low_disk | bool | true | The spawn hold on low free disk (switchyard issue #6154, internal/orchestrator/diskhold.go): while free space on this host's state_dir is under low_disk_hold_gib, serve starts no new role session. It is the host-load hold's shape against a different reading, and it exists because the reclaim beside it is BOUNDED: the build-cache trim never crosses its minimum age, so a host whose whole cache is younger than that has nothing the trim may take and goes on falling — the case the GOCACHE wipe policy answers with fewer concurrent builds, which this is. It bounds STARTS only: a running session is never held or killed, and the session-free lanes (merge, shipper, post-merge, patrol, and the maintenance pass that reclaims) keep running. Hysteresis: a held host is released only after two consecutive passes read free space at or over 1.5× the line. A refused row reads host nearly full: … and names what it read, the line and the release line; /health's low_disk_hold reports whether it stands and the last reading. So does conductor doctor's disk line (idea #258530), a separate process that cannot see serve's memory: serve writes the same reading to <state_dir>/diskhold.json on its first pass, on every change and on every pass while held, and the line WARNS SPAWNS HELD for … with the reason. That warning follows serve's record and not the config file doctor loads, so a saved spawn_hold_on_low_disk: false that serve has not applied yet still reads held, with a note that the edit needs applying. When the held record's reading is over ten minutes old the line says no newer record is available and the hold cannot be confirmed: the serve may have stopped, or the filesystem may be refusing its writes of the record (serve keeps retrying them) while the hold itself is still live — /health is the live reading. Free space that cannot be read never holds, and a pass that cannot read it while a hold STANDS counts toward the release, for the reason spawn_hold_on_host_load does the same (switchyard issue #6024). false turns it off. |
low_disk_hold_gib | float | 3 | The free space the hold above engages under, in GiB, and the same line at which the build-cache trim is asked for an out-of-cadence reclaim (internal/orchestrator/lowdiskreclaim.go) — one setting saying both "stop starting work" and "reclaim now", at most once every ten minutes. 3 GiB sits deliberately between two figures: above doctor's own 1 GiB low-disk margin, which is one operation's last safe room and so the point the host is ALREADY failing at, and low enough that the RELEASE line it implies (4.5 GiB) is under where a conductor host ordinarily sits — a line set near a host's normal free space is a hold that engages on a bad hour and then cannot lift without a person. cache_trim.floor_gib (64) is no guide here: it is an early warning for a reclaim, not a line to stop a fleet at. A float, so a small host can be retuned in fractions of a gibibyte; negative is off, the retention convention. |
wait_allowance_seconds | int | 3600 | The whole budget of ONE build's off-turn waits (a WAITING: command the conductor runs for a builder between turns), apart from the 2h cap on its turns. Counted in a quiet host's time: over the load threshold the conductor narrows the go tool for the wait (GOFLAGS=-p=N, N a share of half the cores, never 1 on four cores or more) and runs it under this value stretched by cores/N, at most three times, charging the allowance at the same rate — so the hardest bound on one build is 2h + 3 × this. Raise it on a host whose suite is honestly longer than an hour; also IRONWAY_CONDUCTOR_WAIT_ALLOWANCE_SECONDS (was SWITCHYARD_CONDUCTOR_WAIT_ALLOWANCE_SECONDS). Read when a build is launched (builder). |
go_test_timeout_seconds | int | 1800 | The per-package go test -timeout this host declares for every process the conductor starts — the LLM sessions and whatever go test they run inside their own turn, their setup, a build's verify, the off-turn wait it runs for a builder, the contract lane's verify, and the rework, mechanical-rebase and post-merge verifies (brakeman.GoRun, which narrows those three under load and counts them in the host's go runs exactly as the builder's are) — exported as GOFLAGS=-timeout=<d> so go build/go vet ignore it and a project that verifies with something else is untouched. Armed into the process environment in the one config loader every command goes through, exactly as go_trimpath below is and for the same reason, so serve, the role commands and their children all key the test cache one way; a changed value takes effect on the next serve start. It reached the go runs the conductor makes and NOT the sessions between them until switchyard issue 6525, which is both halves of that fault: a builder's own in-turn go test ./... ran under the go tool's ten minutes — seven packages of this repository exited at 600.x s in one session's turn on a host whose same command passed in 8m23s quiet — and, because -timeout is in the test cache key, every in-turn run a session made to check its own work was cached under a second key and warmed nothing for the verify that decided the build. Unset takes thirty minutes: go test's own ten-minute default times the throttle ceiling (MaxThrottleStretch), since cores-over-width bounds what a narrowed tool can cost one test binary as much as the command around it. It is ON by default because the slowdown is the CONDUCTOR's doing, so a host nobody has tuned is where it has to be fixed; a longer deadline costs only later news about a test that hangs, never an unbounded wait, since the command around it is still cut at wait_allowance_seconds. A NEGATIVE value is the explicit opt-out and leaves the go tool's ten minutes exactly as they were. It is the half of a loaded host's fault that -p and wait_allowance_seconds cannot reach: both give the COMMAND more wall clock, while each test binary it starts still answers to its own ten minutes, and under load a package that takes two minutes idle takes more than ten. Applied at ANY load, unlike the -p beside it, because -timeout is part of the test cache key and a flag that came and went with the load would have a wake warm nothing for the verify after it. A -timeout already named in GOFLAGS or in the command itself wins and nothing is added. Declared here rather than in verify_command because that string is not necessarily what runs — switchyard's own project declaration replaces it; also IRONWAY_CONDUCTOR_GO_TEST_TIMEOUT_SECONDS (was SWITCHYARD_CONDUCTOR_GO_TEST_TIMEOUT_SECONDS). Read when a build is launched (builder). |
gate_mirror_max_age | int (seconds) | 600 | How recently the bound repository must have delivered a webhook for the SPAWN GATE to read switchyard's PR mirror at all. The gate is one pre-spawn decision per LLM lane (internal/spawngate): a review of a draft, a review of a head a verdict is already on record for, a review of a request NONE of the four attribution legs attributes (no prd_ids, no crit_labels, no issue_ids, review_requested false, and its bead names no PRD — spawngate.AttributedBy, PRD #848), a rework over a base branch that is itself red — each refused from state the conductor has already read, with no session started. Past this ceiling, and on a mirror the server has never seen a webhook for, the gate reads NO mirror-held fact and every lane spawns exactly as it did before the gate existed, with mirror_stale on the resulting run row so the fail-open is countable. The age is the SERVER's own age_seconds, never this host's clock against the delivery time, so a conductor out of sync with switchyard can neither invent nor hide staleness. It is far tighter than the thirty minutes the merge backlog's breakdown believes a mirror within, and deliberately: a breakdown half an hour behind under-reports a tile, while a GATE half an hour behind refuses work on state that has moved — a draft marked ready, a head pushed, a PRD attached — and a refusal is the one outcome a lane cannot take back. Zero or negative is the default and not "no ceiling": an operator who wants the gate off takes the mirror reader away, which is what a host whose switchyard does not serve the route already has. Read when a reviewer or rework lane starts, so a changed ceiling binds the next pass. Why: the spawn gate. |
go_trimpath | bool | true | Whether this host exports -trimpath into GOFLAGS for EVERY process the conductor starts — the LLM sessions, their setup and verify, the off-turn waits, the contract lane's verify and the rework, rebase and post-merge verifies — so that the Go build cache is shared across the worktrees the host runs at once. Without the flag cmd/go keys each compile on the package's absolute directory, so twenty checkouts of one repository hold twenty copies of every object; that is what turned a 57G cache over in under three hours on the host it was measured on. Armed into the process environment in the one config loader every command goes through, so serve and its role subprocesses, which inherit its environment, key the cache one way; a changed value takes effect on the next serve start. false is the opt-out, for a project whose tests find files through runtime.Caller and see a trimmed path under it. A GOFLAGS the operator already wrote -trimpath into, in either sense, is left as written. Also IRONWAY_CONDUCTOR_GO_TRIMPATH (was SWITCHYARD_CONDUCTOR_GO_TRIMPATH). Why: one build cache across worktrees. |
stall_after_seconds | int | 600 | A session with no output and no CPU for this long is flagged stalled. Negative never flags. It is also the window the wedge verdict stands across (wedge_detect below). |
silent_reap_seconds | int | 3600 | A live role session whose recorded output — its runner's and every session that runner started (a claude session's own transcript counts) — is older than this is reaped: killed, its bead released to the pool and its identity deregistered, in one pass. Keyed on output, never on the stalled flag, which counts CPU growth as life. It is the hard backstop; the wedge trigger below reaps a hung session far sooner. A session recorded under the retired dm role is exempt. 0 or negative never reaps; unset is the default. |
reap_max_per_pass | int | 2 | The most sessions one project's reap pass may kill, output-silence and wedge reaps together, longest-silent first; the rest wait for the next pass, so a threshold set wrong costs a visible trickle of reaps rather than the fleet in one tick. 0 or negative is the default. Every reap is one session.reaped feed line paired with a reaped/reaped_wedged run-ledger row. See the silent-session reaper. |
wedge_detect | bool | false | Turns the wedge trigger on. A supervised LLM session whose own transcript has not advanced across the stall window (stall_after_seconds) AND that held no open API socket in any sample across it is WEDGED: reaped at the stall window, its bead released like a lost lease, and the reclaim recorded on runs.jsonl and shown on /health as "hung — reclaimed". Never keyed on CPU rate or stdout — an API-bound claude reads 0-3% CPU and zero output bytes whether working or hung (DEC-0018); CPU only vetoes (a tree burning a tool's worth of CPU is working). Off by default until validated against the real workload, leaving the output-silence reap and the hard timeout as the only triggers. The retired wedge_cpu_floor_seconds is ignored. See the silent-session reaper. |
settings_shadow_apply | bool | false | Lets serve clear a shadowed role list itself (PRD #1076). A project entry that spells roles replaces its repository's .conductor/project.json list whole, so a lane the repository lists and the entry does not is never run. Each settings-sync pass finds those lanes; off, it only logs them (settings shadow: the entry's roles hide the repository's); on, it adds them to the entry through the dashboard's re-sync roles writer (projectadd.SetRoles) and reloads in-process, putting the file back if the reload is refused. Host-owned: a top-level key of this file, so a repository cannot turn it on. |
session_record_files | string | canonical | How many copies of its bytes each session record keeps. canonical — the default, and what unset means — keeps ONE: events.jsonl, with raw.log beside it on a pty session because the transcript is a lossy rendering of a terminal stream by design. output.log and transcript.txt are then VIEWS, rendered on read by one accessor in internal/session, so an old record that carries the files and a new one that does not read exactly the same — the files on a host are never rewritten, removed or migrated. all writes those two files as every record before PRD #827 carried them, and keeps a runner's output.log rather than removing it at the close. Why the setting exists: the record directory is a path, and a host's LAUNCHER or tooling may reach into it for a file BY NAME — a wrapper script that tails output.log, an editor or tail -f pointed at transcript.txt, anything outside the conductor that the accessor cannot stand in front of. The saving is bytes on that host's disk (three or four copies of the same bytes, about 2,500 records a day on the dev host, and the archive command then compressing all of them), and an operator who would rather spend those bytes than rewrite a script should not have to choose between the upgrade and their tools. Anything but these two values is refused when the config loads, naming the setting: a host that meant to keep its files must not find out from a missing file. The figure to read before and after is doctor's records line — the mean bytes per record over the last day, off the catalog — and conductor sessions list --sort bytes. |
worktree_retention_hours | int | 24 | An abandoned build's worktree — or a read tree a crashed validator or reviewer left under the same root (PRD #647) — is swept once idle and untouched this long; a SHARED read-<sha> tree (PRD #826) goes on this same path only when no reader still holds it (readtree.Reclaim), and is kept however long it is untouched while one does. Negative never sweeps. |
session_retention_days (alias sessions_retention_days) | int | 30 | A finished session record, and its sessions.db catalog rows, are removed this many days after it ended — never while an open bead (the project's pool) or an open pull request on the open-prs.json ledger refers to it; with no pool read every session naming a bead is kept. conductor gc prints each kept session and why. Zero, unset or negative keeps forever — the prune branch runs only on a positive retention, and each pass reports which way the gate went, naming this setting: serve logs it, gc prints it as its sessions: line, and doctor warns on its sessions check while nothing is being pruned (the running-session read). The two names disagreeing is refused. |
hostperf_interval_seconds | int | 30 | How often serve's host sampler appends a sample to <state_dir>/hostperf.jsonl while any session is live (the sampler). The cadence is the sample's own decision, not a ticker's: each pass reads this key after recording, so a change applies from the next reading with no restart. At or under 0 is UNSET and takes the default — a sampler that stops is a host nobody can read, so this is not an off switch. |
hostperf_idle_interval_seconds | int | 600 | The same sampler's cadence while no session is live. Twenty times the busy one, because an idle machine's load is the figure nobody needs at resolution and the fourteen hours a day a host runs nothing would otherwise be most of the file. At or under 0 is UNSET and takes the default. |
hostperf_retention_days | int | 30 | How much of hostperf.jsonl the hourly maintenance pass keeps; a sample older than this is dropped on the next pass, and the rewrite happens only when something aged out. Negative keeps every sample forever, as a negative session_retention_days keeps every record. |
brief_caps | object | {} | The byte cap per block of the brief every role renders (PRD #811): handoffs (default 6144), retry_guidance (2048), last_rejection (4096), context_pack (4096) and transcript_tail (8192), plus the handoff summariser's summarise_handoffs_after (2) and summary_max_bytes (2048). PRD #710 made the brief REPORT its size by block and nothing acted on it, so a bead on its fourth attempt read three whole handoffs before it read its criterion. Each key absent falls back to its default, so a host that configures nothing still renders a capped brief; an explicit **0 is UNCAPPED**, which is how a host gets its pre-trimmer briefs back without a rebuild, and setting all five to 0 reproduces today's briefs exactly. A NEGATIVE value is refused when the config loads, naming the block — there is no third meaning for one, and a cap read as "-1 bytes" would cut every brief down to its own cut note. The handoffs block is cut by WHOLE records, newest kept whole and the rest dropped oldest-first; every other block is cut at the tail on a line boundary; a cut block always ends with one line naming the bytes omitted and the session-record path holding the full text. launchers.summariser picks the cheap runtime that writes the one summary per handoff set. See the brief trimmer for what the caps actually cut, measured. |
cache_trim | object | {} | The Go build-cache trim's three thresholds, retunable on a live host: floor_gib (default 64) is the free space under which a pass reclaims at all, target_gib (default 96) the free space it reclaims UP TO and then stops at, and min_age_hours (default 3) the minimum age no disk pressure may override. The pass walks the cache oldest-mtime-first and deletes until the free-space probe reaches the target — age is the outcome, not the knob, because a fixed age cannot be set against churn that swings from nothing overnight to 15 GiB an hour (a 72-hour age matched ZERO files in a 57G cache here). The target must exceed the floor, and the gap between them is what stops the pass thrashing at one boundary; a pair that does not is refused with the reason on /health. min_age_hours under 1 would delete entries a live build is using by construction, since cmd/go refreshes an entry's mtime only once it is already an hour stale. Each key absent falls back to its default; negative is off. See the GOCACHE wipe policy. |
dm_poll_seconds | int | 10 | Was the Buzz DM role's own inbox cadence. The dm role is retired (PRD #654): serve starts no listener and conductor dm is no longer a command, so nothing spends it. Still accepted and still defaulted, so a config carrying it keeps loading; it decides nothing. |
dedup_interval_seconds | int | 21600 | Cadence of the dedup-scout pass (6h). |
idea_scout_interval_seconds | int | 3600 | Cadence of the idea-scout loop (1h). |
security_scout_interval_seconds | int | 21600 | Cadence of the security-scout pass (6h); a pass over an unchanged integration head is a no-op. Off unless a project lists the role (scouts). |
refactor_scout_interval_seconds | int | 86400 | Cadence of the refactor-scout pass (a day); same no-op and opt-in as the security scout. |
doc_scout_interval_seconds | int | 86400 | Cadence of the doc-scout pass (a day): the one cadence role that ACTS, correcting the documentation the merged changes made false and shipping ONE docs pull request per pass. Same no-op on an unchanged head, and OFF unless a project lists doc-scout in its roles. Governed by the BUILDER registry row, because it pushes (doc-scout). |
fresh_audit_interval_seconds | int | 604800 | Cadence of the fresh-audit pass (a week): a fresh, repo-only session answers the five fresh-session questions, Go verifies each evidence path at the audited commit and runs the structural checks, the legibility score lands on fresh-audit.jsonl and /health, and each gap is filed once as an idea. Runs only when the integration head moved since the last audit, and OFF unless a project lists fresh-audit in its roles. Governed by the TRIAGER registry row, because it files proposals (fresh-audit). |
runtime_evidence_interval_seconds | int | 21600 | Cadence of the runtime-evidence pass (6 h): pure Go, it reads the outstanding criteria, measures each needs_runtime or source-naming human-judged: criterion's declared source (GitHub Actions median over completed runs) and stages ONE human task per criterion with the value, sample size, threshold and confirm link — never a verdict. OFF unless a project lists runtime-evidence in its roles; governed by no registry row (runtime-evidence). |
yardmaster_interval_seconds | int | 1800 | Cadence of the yardmaster pass (half an hour): pure Go, no session; each pass reads the promotion, the open requests, the open PRD questions, the parked beads and the roster, records what an autonomous operator would do as proposed acts on the run ledger and /health, and — on the host holding the project's yardmaster lane lease — NUDGES stalled work through the verb of the lane that owns each act. It never merges. ON BY DEFAULT on every project, whether or not roles lists it; the opt-out is the project flag yardmaster: false, NOT this key — at or under 0 this is unset and takes the default, like every cadence key. Governed by the BUILDER registry row, because its acts push (yardmaster). |
yardmaster_nudge_cooldown | duration | 6h | How long a target the yardmaster nudged is left alone before any kind may nudge it again, and how long a parked rework attempt must have sat before retry-rework re-arms it. A Go duration; a value that is not one, or is not positive, is refused at load (yardmaster). |
yardmaster_review_stall | duration | 3h | How long a request's current head may wait with no finished review on it before the yardmaster proposes a rereview (and, under the lease, reopens the review bead through reopen-review). Measured from the head's last authored commit, or the request's opening when that is later (yardmaster). |
yardmaster_answer_veto | duration | 30m | How long an agent-asked question with a recommendation waits for a person before the opt-in answer-with-recommendation nudge may answer it with that recommendation. Measured from the later of the ask and the recommendation (the answer nudge). |
yardmaster_idle_window | duration | 30m | How long a project with claimable buildable beads may go without a builder start, and with no builder live, before /health carries its idle_with_work block — the claimable count, the last start and the reason serve recorded against the builder (capacity, pause, throttle, budget, hold), so "agents not going" reads as one answer (yardmaster). |
yardmaster_match_window | duration | 24h | How long after a yardmaster proposal an act on the same target still counts as an answer to it, when the record matches each proposal kind against what was then done (conductor yardmaster --project t/p --record --since 14d, /health's projects[].yardmaster.record, doctor's warning). A Go duration; a value that is not one, or is not positive, is refused at load (the record). |
yardmaster_min_agreement | float | 0.9 | The trailing by_person agreement doctor expects of a nudge kind that is switched on: a kind under it is named on every doctor run, with its record. Between 0 and 1; anything else is refused at load, naming the key. 0 is a value, not an absence — it means warn about no kind on this ground (enabling a kind). |
yardmaster_min_proposals | int | 20 | How many proposals a kind needs in the trailing 14 days before its agreement is read as evidence: a kind under it is named for having too thin a record, which is not the same finding as evidence against it. Not negative; a negative value is refused at load, naming the key. Neither threshold changes doctor's exit status (enabling a kind). |
yardmaster_shadow_min_heads | int | 5 | How many integration heads one check family must fail ALONE on — every other check and the post-merge verification passed on that head — before the yardmaster's gate pass proposes set-gate-checks for a project that sets no gate_checks, naming the families that passed beside it. A family is a rollup name up to its first / (roundhouse/, CI /). Only the opt-in propose-gate-checks nudge acts on it, by opening a held request editing the repository's gate_checks; nothing writes the config. At or under 0 is unset and takes the default. Host-owned (PRD #1076, the gate pass). |
retro_interval_seconds | int | 21600 | How often a retro project is checked for a due pass (6h); the pass is once per project-local day. |
post_merge_interval_seconds | int | 900 | How often a post_merge project's integration branch is re-checked. |
flake_min_events | int | 2 | The flake warden's bar (flake-warden, PRD #809): how many flake events inside flake_window make a Go test read flaky. A flake EVENT is a failure followed by a pass on the SAME head sha with no new commit between the two runs — the head sha is the tree, so nothing about the code changed and the test itself is the only thing that differed — and the register (flakes.json, per project) names both run refs. Two, because one event is what every flake looks like the first time and holding on it would hold the first genuine failure of a genuinely broken test. When EVERY failed test of a red CI check or a red verify is flaky the lane holds what it was about to spend and re-runs once; a second red on the same head spends a session as today. The warden never skips, disables or filters a test. |
flake_window | duration | 336h | How long one flake event counts for (14 days). A Go duration ("336h", "72h"); a value that is not one, or is not positive, is refused at load. Expiry is by this window at READ time rather than by a sweep, so an event ages out of the count on its own and the row keeps its history — which is also what stops a test somebody FIXED being held against the next red run for ever (flake-warden). |
flake_clear_passes | int | 20 | How many consecutive passes on DISTINCT heads take a test off the register (flake-warden). Distinct, because twenty re-runs of one tree are one tree; consecutive, so a failure empties the streak. Twenty distinct trees is about a day of merges, and a test that has passed twenty different trees in a row is not the test that was flaking. The clear leaves a ledger row on flakes.jsonl naming it. |
combination_interval | duration | 2h | Cadence of the combination lane (combination-lane, PRD #708): how often a project that lists combination in its roles may TRY the currently-mergeable pull requests together. A Go duration ("2h", "45m"); a value that is not one, or is not positive, is refused at load. It is a ceiling on staleness and not a rate — a pass that finds neither the integration head nor the mergeable set changed since the last run measures nothing, so lowering this costs one forge listing and not a test suite. The lane is OFF for every project that does not list the role, whatever this says, and it merges nothing ever. |
harness_read_interval | duration | 30m | Cadence of the harness-edit read (harness-read, switchyard PRD 689): how often a project that lists harness-read in its roles looks for an open harness-edit request whose head has no read. A Go duration; a value that is not one, or is not positive, is refused at load. A pass that finds every head read judges nothing. The role is OFF for every project that does not list it. |
harness_read_wall_clock | duration | 1h | The wall-clock limit on ONE request's harness-edit read: the base and head judgments of every regression-set case, per model. A read that runs out reports what it judged and says it stopped. |
harness_read_launchers | list of strings | the validator's launcher | The launchers a harness-edit read measures, one row per model each runs (the model is read off the launcher, as the run ledger reads it). |
config_watch | bool | false | Watch the config file and apply a change with no conductor refresh at all, through the same path and reported the same way, after a short debounce (an editor that writes the file three times in a second is ONE application). A file that does not load leaves the running config exactly where it was and is reported. See Applying a change to a running conductor. |
role_vocabulary | string | — (legacy) | Which vocabulary this file's role names and max_* keys are written in (PRD #988): legacy for the names the conductor carried before the rename, switchyard (or its new spelling ironway, which loads identically) for a file written in Ironway's own — builder, reviewer, validator, verifier, shaper, assistant. **Unset is legacy**, which is what keeps an in-place upgrade safe, and the marker exists for the one name whose MEANING it decides: validator is the Go contract lane on an unmarked config (now called verifier) and the LLM judgment lane on a marked one. Every other legacy max_* key means the lane that now bears its new name under either marker, and so does a legacy role name in roles under the LEGACY reading — but only there: the tick reads a MARKED file's role name as it stands (Project.Lane), so a marked config saying brakeman reaches no dispatch arm and the role is refused with unknown role in config. A marked config spells its roles the new way throughout; the new names already dispatch on an unmarked one too, with validator the one exception, since it is the contract lane there. See Role names. A value that is neither is refused at load rather than defaulted — read as legacy, a marker mistyped while migrating would quietly stop the judgment lane and double the contract lane. conductor doctor prints a role-names WARNING for a config whose roles still spell any of the replaced names, each one beside the name it is called now and with conductor config migrate-roles named as the rewrite; it never fails doctor, because an unmarked config is read under the legacy meanings and the host runs exactly as it ran before. |
transcript_dirs | []path | — | Extra directories doctor's credentials check scans for cleartext secrets, beside the session records, the launcher's store and the session catalog <state_dir>/sessions.db it reads anyway (PRD #580). A single "-" turns that scan off — the transcript walk and the catalog read both; the config half still runs. |
model_prices | map | — | Prices reported token usage for the daily spend cap. See Model prices. |
callboard_url | string | — | The Callboard whose published briefs this host's role sessions are served. A serve with no org key connects itself and mints one. |
callboard_signing_key | string | — | The org's Ed25519 public key, hex, that every Callboard check-in bundle must be signed by (PRD #793). It is a public key and not a credential, so it sits inline. Unset means trust on first use: the first bundle's key is pinned under <state_dir>/callboard-signing-key.json and every bundle after it must carry that key — a bundle signed by any other is not run, and the session is briefed from the embedded copy with fallback: signature. Set this (or remove the pin file) after a confirmed key rotation. |
callboard_disabled | bool | false | Turn Callboard OFF on this host: no self-connect, an exported key is IGNORED, callboard_forks is dropped, and every role is briefed from the embedded copy. |
callboard_forks | []fork | — | The Callboard forks serve keeps in step with the base it publishes. Empty — the default — runs no step. See callboard_forks. |
callboard_publish_org | string | — | Restart-only. The Callboard org a brief publish from this host may write into (switchyard issue #5578). Empty — the default — names none, and the target is whichever org Callboard resolves for the operator's Ironway login; the publish names it either way. Set, it REFUSES: a publish whose target is some other org writes nothing. See callboard_publish_org. |
rails | object | — | Restart-only. The host's Ironway Roundhouse server: reruns its builds from a base-ci finding, and reads the listed repositories' head checks without GitHub. Unset — the default — still sees its GitHub statuses. See rails. |
faultline_dsn | secret ref | — | Host-wide error-reporting DSN. $FAULTLINE_DSN always wins; empty everywhere leaves reporting off. |
faultline_api_token | secret ref | — | Read token for faults; separate from the DSN because it can read everything the project holds. |
github_token | secret ref | — | Restart-only. The GitHub account THIS CONDUCTOR acts as. Set, every gh process the conductor starts carries it as GH_TOKEN, so its pull requests, comments and merges are the conductor's and spend the conductor's own 5,000/hour budget rather than a person's. A reference only (env:NAME or file:/path) — conductor github login seals it and writes the reference, and says to RESTART serve rather than refresh — the identity is armed at process start and /health names the account it resolved then, so a reload reports this key as needing a restart rather than half-applying an identity. $GH_TOKEN outranks the field, and $GITHUB_TOKEN is deliberately not read. Role sessions are launched with neither variable. Unset is the supported default and changes nothing: every call runs as gh auth's active account, and conductor doctor warns that the host shares that person's account. See The GitHub API budget. |
projects | []project | — | The projects to supervise. See Per-project settings. |
Launchers
launchers overrides launcher per role. A value is either a bare command string or an object naming a transport, an escalate_to and/or alternates:
"launchers": {
"judge": { "command": "opencode run", "alternates": ["claude -p --dangerously-skip-permissions"] },
"reviewer": { "command": "claude -p --dangerously-skip-permissions", "transport": "pty" },
"brakeman": { "command": "codex exec", "escalate_to": "claude -p --model opus" }
}
transport is "" (the direct process runtime on pipes, the default) or pty (a pseudo-terminal, which also writes raw.log). Any other value fails the config to load. A per-role entry that carries only a transport still runs the global command.
escalate_to (PRD #487) is the command a claimed bead's session runs on the LAST attempt its claim's guardrail allows — the try whose failure parks the bead for a person — and on no other. It names what runs, never when: no setting moves escalation to an earlier attempt, and it fires at most once per bead (a last attempt whose earlier escalated try already failed is parked without a session). Only the builder resolves its launcher this way (through Config.LauncherForItem, which asks the escalation first and the routing table only when the claim is not the escalating attempt); every other role, and rework, ignores the field. conductor doctor fails its escalate check when a builder's escalate_to resolves to the validator's runtime label. The behaviour is in the launcher section of names, launchers, DeepSeek; why the last attempt and not an earlier one is in escalation-threshold.md.
alternates (PRD #638) is the ordered list of launchers this role falls through to when its primary cannot serve AT ALL — the API refusing the session for capacity, or a launcher that cannot start — each a full command line of the same shape as command. Empty, the default, is a role that waits out the retry window exactly as it did before the field existed. The fall-through is bounded and returns to command on its own, it never reroutes a session that ran and then failed, and the role currently serving on an alternate is on GET /health as serving_alternate: launcher-fallthrough.md.
The model a launcher declares (--model/-m, else the CLI's own default) is part of the runtime label every claim and verdict carries, and switchyard's judge_independence decides whether a validator on the builder's binary with another model is independent. doctor warns about a claude launcher that names no model. The label, the comparison and a recommended launchers block: runtime-labels.md.
Routing
routing picks a launcher per WORK ITEM, the direction launchers cannot. It is an ORDERED list of rules; the FIRST rule matching an item wins, and an absent table routes nothing — a host that declares none resolves every launcher exactly as it did before the field existed.
"routing": [
{"role": "pr-reviewer", "when": {"docs_only": true}, "launcher": "codex exec --model gpt-5.6-sol"},
{"role": "pr-reviewer", "when": {"diff_lines_max": 60, "files_max": 3}, "launcher": "codex exec --model gpt-5.6-sol"},
{"role": "brakeman", "when": {"attempt_max": 1, "artifacts_max": 1}, "launcher": "codex exec --model gpt-5.6-sol"}
]
A rule's when may name ONLY these signals, and every *_max is an inclusive ceiling: attempt_max, handoffs_max, artifacts_max and phase_shape (the builder, off its claim and its classified phase), and diff_lines_max, files_max and docs_only (the reviewer and the validator, off one diffstat of the tree they read). A rule naming a signal the item does not measure never fires, so an item nobody could size runs the role's own launcher.
A rule's launcher must be one the config already names — the global launcher, a role's command, its escalate_to, one of its alternates, or the built-in default. The loader refuses a rule naming anything else, a when naming an unknown signal, and a rule with no role or no launcher, each refusal naming the rule's index and the offending key.
Precedence is fixed: escalate_to on the last attempt the budget allows (the table is not consulted at all there), then the PRD's preferred model from model_launchers, then the first matching rule, then launchers. conductor doctor fails its routing check when a builder rule resolves to the validator's runtime label, and reports the table on a clean host. Every routed run names its rule on the run ledger and the feed as routing_rule, and runs.Summarize groups a window's runs by it on the Statistics page. The whole of it — the signals, the precedence, the rule that routing never changes a brief — is routing.md.
Model launchers
model_launchers (PRD #1058) maps a model name to a FULL launcher command line, the runtime a PRD runs on when it names that model as its preferred_model (served on the claim's prd_context):
"model_launchers": {
"claude-fable-5-1": "claude -p --dangerously-skip-permissions --model claude-fable-5-1"
}
The loader refuses an entry with an empty key, and one whose command declares a different model than its key (as runtime.Model reads --model/-m), each refusal naming the key. Absent, the default, no preference is served and every launcher resolves as before.
Config.LauncherForItem resolves in four rungs: escalate_to on the last attempt the claim's budget allows (the preference is ignored there), then the claim's preferred model from model_launchers, then the routing table, then the role's launcher. A preference the map does not serve FALLS THROUGH to the table and the role's launcher — nothing is refused or parked — and the run records preferred_model with preferred_model_served: false on its run-ledger row and feed line (a served one records true), so an operator can see which PRDs asked for a model this host cannot run; runs.Summarize groups a window's runs by preferred model with served and unserved counts on the Statistics page and in conductor spend. The builder and the swarm's sessions for a PRD resolve through it.
conductor doctor's model_launchers check fails when an entry's runtime label collides with the validator's under judge_independence's model mode, and reports each entry (key and runtime label) on a clean host. An entry picks the runtime and never changes a brief. The whole of it: routing.md.
merge_lane
The merge and promote lane's tunables. Note the shape: top-level merge_lane is this object; a project's merge_lane is a boolean that turns the lane on for that project.
The object is the one non-scalar key switchyard may serve as a machine setting (PRD #664): a served merge_lane applies onto Config.MergeLane, owner included, and a dotted merge_lane.owner is refused. merge_lane is a FLEET key (PRD #700): a served value wins over this machine's own conductor.json block (and over IRONWAY_CONDUCTOR_MERGE_LANE_OWNER (was SWITCHYARD_CONDUCTOR_MERGE_LANE_OWNER)), the local value is reported as overridden, and conductor doctor warns naming the key (machine-settings-live.md).
| key | type | default | what it does |
|---|---|---|---|
interval_seconds | int | 60 | How soon a newly-green reviewed PR is noticed and merged. |
promote_interval_seconds | int | 1800 | Cadence of the staging→main promotion PR. |
max_merges_per_pass | int | 0 (unbounded) | Throttle a busy lane; 1 restores the old one-merge-per-pass ceiling. Not a safety gate. |
hold_label | string | do-not-merge | A PR carrying this label is never merged. |
approve_marker | string | Verdict: APPROVE | The review-comment marker that approves a PR. |
reject_marker | string | Verdict: REQUEST CHANGES | The marker that requests changes. |
promote_label | string | issue-ref-exempt | Put on the promotion PR so the repo's issue-ref guard lets it through. |
owner | string | — | Agent prefix of the ONE host that runs the merge and promotion passes. Empty = every host with the lane on runs it (fine alone, a duplicate promotion PR in a fleet). A default-shaped owner also matches the same install id under a different hostname, so a renamed host keeps its lanes; conductor doctor warns until the value is updated. |
escalate_after | duration | 24h | A PR the merge pass has held on the SAME gate (checks failing, no finished review, conflicting…) for longer than this raises a PR escalation, whose prescalate-* bead a pr-fixer works (PRD #528). A draft or a hold_label PR is a person's own hold and never escalates, nor does a request held on human_merge_required — that gate is a declaration and not a fault, so a pr-fixer would be handed finished work with nothing to repair (PRD #822) — nor a persistent mergeable=unknown, which is GitHub's own cache. "off" turns it off; anything but a positive Go duration is refused at load. |
promotion_min_merges | int | 1 (per merge) | The release batcher's count trigger (PRD #814): a promotion opens, or a red one is re-pinned, once at least this many merges have landed on the integration branch since the release head. Unset is a count of ONE — the first merge meets it — so the lane promotes on every pass exactly as before, paying no extra forge call and reporting no batch; beside an age trigger an unset count is the count trigger off. Over one, the pass opens nothing and records promotion_batched with both counts. A merge COMMIT is the unit; a project that squashes has each commit counted as one landing. A negative count is refused by the loader. detail |
promotion_max_age_seconds | int | 0 (off) | The release batcher's age trigger: a promotion opens once the OLDEST unpromoted merge is older than this, whichever of the two triggers fires first. It is what keeps a quiet project from waiting for a count that never arrives. Zero is the trigger off; a negative is refused by the loader. detail |
promotion_bypass_label | string | hotfix | A request already merged onto the integration branch carrying this label promotes on the next pass whatever the batch says, with the pass report naming the label. It bypasses the BATCH and nothing else: a red or unreported integration head still holds the promotion, and so does --promote-now. Harmless on a project with the batcher off, where there is nothing to bypass. A label of nothing but spaces is refused by the loader, since it could never match. detail |
hold_harness_edits | bool | false | The merge pass asks switchyard for a harness-edit request's before/after read for the head it would merge and applies the project's hold mode: refuse holds a request with no read or a never-regress case turned red, warn records the gap and merges (PRD #689). An unreadable answer holds, so turn it on only once the host's switchyard serves GET {P}/harness-edit-reads. detail |
close_superseded | bool | true | The merge pass closes an open delivery THIS install owns once every criterion it declares (its branch's bead and each Delivers: line) is credited to a merged PR of the same PRD **and settled done**, with one comment naming each criterion and its credited PR, deleting no branch (PRD #534, #576). A criterion that is credited but carries a standing rejection, no verdict yet or any other status leaves the delivery open — credit is attribution, a verdict is acceptance — as does a criteria page the pass could not read. Unset is on; false is the off switch. When the lane may close a PR at all: the rule; why credit alone is not enough: credit is not a verdict. |
rework
Bounds the answer to a request_changes review, a merge conflict, a red CI run or unresolved review threads (PRD #768) on a conductor PR.
| key | type | default | what it does |
|---|---|---|---|
max_per_pr | int | 2 | How many rework sessions one PR may cost on each of its two budgets: the REVIEW ceiling, which a request_changes, a red CI run and a forge refusal for unresolved review threads (PRD #768) share, and a CONFLICT budget of the same size that only a merge conflict with the base spends (issue-4995: review findings must not spend the sessions a later conflict needs, nor a busy base the ones a reviewer is owed). A rival-delivery conflict spends none of them (PRD #534: a merged sibling of the same PRD changed the colliding files, so no session can resolve it and one decision task is raised instead). The merge lane also READS this ceiling: while a thread-blocked request still has an attempt left under it, the lane holds that request instead of escalating it to a person, because the threads are work this lane does. The per-project concurrency cap is max_reworks. |
interval_seconds | int | 300 | How often the forge is scanned for standing rework triggers on a project switchyard's PR mirror is not answering for. Where the mirror is fresh the scan's timer falls to the top-level rework_poll_seconds_fallback (default 900) and a pr.status_changed delivery wakes the scan at once for the one request it names, so this interval is what catches a dropped event stream rather than how the lane learns a run went red (PRD #813, Orchestrator.reworkInterval). The LONGER of the two always wins, so an operator who set a scan slower than the fallback keeps it. See The GitHub API budget. |
resolve_declined | bool | false | Resolve a review thread this lane DECLINED, as well as the ones it addressed (PRD #768). Off by default, and the default is the point: a declined thread is always replied to with its reason and the commit, but whether that answer SETTLES the thread is the reviewer's call — an install that closed its own declines would merge past every review it disagreed with. It is a narrowing on top of the author rule and never an override of it (crit:0354c9bc2384): it decides whether a BOT's decline may be closed, and a thread a PERSON opened is answered and left open for them under it either way — the setting is an install saying "my review bot's threads may be closed over a decline", and reading it as "close anyone's" would hand it the power to settle a maintainer's review. See internal/rework. |
mechanical_rebase | bool | true | PRD #644's MIDDLE rung: a conflicting PR is replayed by git rebase, verified, and pushed with NO model session, and a session is spent on the conflict only after that has failed. **On unless you set it to false** — the key exists to turn the rung OFF, and the default is the rung running. Two passes ask for it: the post-merge lane, which is the only pass that sees a merge land, and the rework lane itself, which asks before spending an attempt rather than waiting for somebody else's row. It also refuses itself, on any one of: no local clone (repo_dir), no verify_command (a replayed head nothing checked would be a force-push of an unverified tree), no resolved forge, or no install_id — the id is what keeps the pass off another host's delivery branches. Every refusal, this switch included, falls back to the OLD behaviour of answering the conflict with a session: the one outcome that must never happen is a conflicting PR nothing answers. A mechanical attempt spends no slot of the PR's max_per_pr ceiling; only the session rung does. See the conflict ladder and internal/rebase. |
adopt_abandoned_after_hours | int | 24 | How long the install that OWNS a conflicting pull request must have been off the project's roster — and the request itself untouched — before a conductor that still serves the project takes the conflict repair (issue-4994). A conductor branch is otherwise reworked only by the install that cut it, because two hosts force-pushing one head race each other; that rule fails one way, when the owning host has stopped, and on faultline PRs 184 and 197 it cost two days of a CONFLICTING request with no path to repair but a person hand-adding a Conductor-Repair: line. Unset is 24 hours (rework.DefaultAdoptionGrace): the supported default is that an abandoned conflict gets repaired. A NEGATIVE value turns adoption off, for an install that would rather a peer never pushed to one of its branches. The absence is read off GET {P}/agents?include=stale, the UNFILTERED roster — the live read drops exactly the rows the question is about — and an mcp row is not that install serving the project but a person with an editor open. A roster that cannot be read adopts NOTHING. The absence is measured on every movement the request can show — the newest commit on the head with MERGE commits counted, and the request's own creation time — so a branch a person merged the base into minutes ago, or a request opened minutes ago off an old branch, is inside its grace whatever its ordinary commits say. The waiver covers the CONFLICT trigger alone: a review finding or a red run on an adopted request is declined scan-adopted-conflict-only, because those are answered out of the owner's own context. Once the repair has worked the waiver stops being makeable and the lane keeps reading the adoption off its own ledger row for ACCOUNTING only, so the attempt it spent still gets CI's verdict and still settles when the request merges. See the conflict ladder and internal/rework. |
patrol
The host-wide patrol (patrol.md): one pass for the whole host that raises one deduped human task per fault and repairs nothing.
| key | type | default | what it does |
|---|---|---|---|
disabled | bool | false | Turns the patrol off on this host. |
interval_seconds | int | 900 | How often serve runs a pass. |
conflict_window_seconds | int | 21600 | How long a PR may stay unmergeable before it is a stranded-work finding. |
process_ceiling_seconds | int | 14400 | How long a session this host started may run before it is a runaway. |
budget_reserve | float | 0.10 | The fraction of a shared API budget below which the host is exhausting it. |
stuck_lane_passes | int | 3 | How many consecutive passes a lane must finish with the same failure reason before it is a stuck-lane finding rather than an idle lane. |
repeated_releases | int | 3 | How many times ONE work item must be handed back with the same normalized reason, inside a week, before it is a repeated-release finding (switchyard idea #215192). A separate lever from stuck_lane_passes because the two count different things: that one counts a LANE's consecutive passes, and a bead can repeat while the lane around it works normally. |
judge_launch_failures | int | 5 | How many validator sessions must fail with no model output inside the window below before this host is reported as unable to launch validators at all (judge-launch-failure, PRD #706). Forgiven and counted failures both count. |
load_per_core | float | 2.0 | The one-minute load average PER CORE at or above which this host is overloaded (host-load, switchyard issue #5081). Per core because load 8 is idle on one machine and drowning on another. The finding's second condition — more of the CPU spent by processes the conductor's own work does not account for than by the ones it does — is not configurable: it is what tells a busy host from a fault. The same threshold is where spawn_hold_on_host_load holds new sessions. |
judge_launch_window_seconds | int | 1800 | The window those failures are counted in, half an hour by default. One validator session that produces model output inside it clears the fault and withdraws the task. |
unserved_lane_window_seconds | int | 7200 | How long contract or judgment work may stand offered on a lane no role this host runs for its project serves before the patrol raises one unserved-lane task for that project and lane (PRD #1076). |
criterion_diagnosis
The criterion-diagnosis lane (PRD #671, criterion-diagnosis.md): the pass that reads each FAILED builder attempt from evidence — the attempt ledger, the handoff chain, the validator verdicts, the requests declaring the criterion, the branch and wip refs, the failing lines of the whole verify output and the host load the run was written under — before the next attempt starts, and records what it concluded through switchyard's criterion-diagnosis endpoint. It is ON by default, for the reason the stall lane is: before it existed the next attempt on a failed criterion started from a truncated verify tail, and a lane that has to be switched on is one nobody switches on. It is a READER: the one verdict that changes what a builder does is resume_from_commit, which starts the next worktree from a commit the gate has already checked against the bundle.
| key | type | default | what it does |
|---|---|---|---|
disabled | bool | false | Turns the lane off on this host. A host serving no project runs no pass either. |
interval_seconds | int | 600 | How often serve runs one project's pass, off the tick and never while that project's pass is still running. A failed attempt is diagnosed on the tick after it ends, so the cadence is close to the builder's own rhythm. |
window_seconds | int | 21600 | How far back a pass looks for undiagnosed attempts (six hours). An older attempt has been superseded or belongs to a bead nobody is working. |
max_per_pass | int | 4 | The most diagnosis sessions one pass may start; the rest wait for the next one. A host coming back from an outage has a ledger full of failures, and diagnosing them all at once costs more than the attempts did. |
timeout_seconds | int | 600 | Ceiling on ONE diagnosis session. It reads one bundle and writes one JSON result, so a session past this is not thinking. |
The session is the criterion-diagnose role: read-only, briefed from internal/instructions/roles/criterion-diagnose.md with the bundle and nothing else, and resolved through launcher/launchers like every other role.
stationmaster
The stall lane (PRD #651, stalled-work.md) and, under it, the inbox pass (inbox-pass.md). Both are ON by default: the stalls the lane finds were all invisible before it existed, and the inbox entries the pass resolves each waited on a person for a lookup the conductor could make.
| key | type | default | what it does |
|---|---|---|---|
disabled | bool | false | Turns the stall lane off on this host, and the inbox pass with it. |
interval_seconds | int | 900 | How often serve runs one project's stall pass. |
serve_log | string | (unset) | The file serve's own output is kept in, for the evidence bundle's serve.log source; unset, the bundle says the log is unconfigured. |
no_diagnose | bool | false | Skips the read-only diagnosis session, so every stall is raised as a plain escalation carrying its bundle. |
diagnose_timeout_seconds | int | 900 | Ceiling on one diagnosis session. |
inbox | object | (see below) | The inbox pass. |
stationmaster.inbox:
| key | type | default | what it does |
|---|---|---|---|
disabled | bool | false | Turns the inbox pass off while the stall lane keeps running. |
interval_seconds | int | (the stall lane's) | How often serve runs one project's inbox pass, off the tick and never while that project's pass is still running. |
resolve_stale | bool | true | Lets the pass retire a STALE wrong_base_pr — one whose request the forge reports merged or closed — through an action the server lists on that entry (PRD #1076, inbox-pass.md). On unless set to false: the entries it retires are settled by the forge's own record, so off they only waited for a person to retire them by hand. Set to false, a stale entry is classified and recorded in stationmaster-inbox.json as reported, and left for a person. Host-owned: a key of this file under stationmaster, so a repository cannot turn it off or on. |
The pass acts only on an action the server listed on that exact entry, re-read immediately before the write; everything else on the inbox is left for a person.
update
The self-update pass. On channel: "source" (the default) serve fetches the tracked ref and, on a new commit, builds it in a throwaway worktree, proves it, swaps bin/conductor and re-execs. On channel: "release" it needs no checkout and no Go toolchain: it downloads a published binary, verifies its sha256, swaps it in and restarts. Off unless enabled — a conductor that replaces itself is an operator's decision.
Both channels swap the same way: the outgoing binary is parked as <binary>.prev — the rollback point, which an operator moves back by hand — and the replacement arrives by ONE rename from a temp file in the same directory, so the binary path never holds a half-written file. Nothing that fails its gate is renamed anywhere near that path: on source the gate is the build/vet/test/smoke run, on release it is the sha256.
**The source test stage reads CI's verdict first (PRD #1007).** The target commit is a push to the tracked ref, and the forge's gate checks — the same gate_checks set every other lane on this host counts — already judged that exact sha on a clean runner. So the pass asks the forge before it runs anything: a rollup that is WHOLLY GREEN on the exact target commit stands in for go test ./..., and the outcome and the pass log then name the commit and the checks that decided it (local suite skipped — the forge's gate checks passed: green at <sha> (…)). It is asked after build and vet, so a commit that does not compile spends no forge read, and build, vet, smoke and check-config stay local and unconditional — nothing CI says about a commit says anything about the binary this host just linked from it.
A check that FAILED on that exact commit is the verdict's other half, and the pass acts on it rather than deferring: the clean runner has already run, on these bytes, the suite this host would spend timeout_seconds on, so **the update is refused without go test running at all**. The refusal lands at its own stage, ci, and names the checks that failed — update failed at stage ci (3.1s): the forge's gate checks failed on the target commit <sha> (CI / build & test); refusing to install it, and not running the local suite to learn the same thing — because a refusal reported at stage test would send an operator to read a local run that never happened. The outcome carries the same verdict (Outcome.CI, with failed naming the checks), and the pass logs it at WARN. A failure beside a run still in progress is still a failure: waiting cannot turn it green.
What remains no verdict at all is everything that judged nothing, and there the local suite decides exactly as before: a pending run, a commit nothing evaluated, a rollup of nothing but skips, a forge that cannot be read, and an answer about a different sha than the target — a red rollup read for a ref that has since moved refuses nothing, because it is not about the commit being built. A check the forge CANCELLED is in that list and not in the refusal (GitHub's CANCELLED and STALE, GitLab's canceled and canceling, which vcs reduces to a bad check so the merge lane holds, and which vcs.PRCheck.Cancelled marks as no evidence about the code it ran on, as rework's red-CI trigger and the base-fault pass already read it): a concurrency-group pre-emption or an operator stopping a hung job says nothing about whether the commit builds, and because the forge does not re-run a rollup for a fixed sha, refusing on one would hold this host on a stale binary every pass thereafter. It is no answer in the other direction too — it never finished, so it cannot make the gate green or stand in for the suite — and a cancelled check sitting beside a real failure changes nothing: the failure still refuses, naming only the check that failed. A host whose checkout has no forge remote, or whose provider cannot read a branch head's checks, asks nothing and runs the suite exactly as it always did; skip_tests drops the forge read with the suite.
The point is the failure all of this removes: a loaded host whose suite times out at timeout_seconds was failing its gate on the host's load rather than on the commit, retrying every interval_seconds, and holding serve on a stale build — 167 commits behind on 2026-10-21 — while spending a full uncached suite of CPU per attempt to learn nothing.
**trust_ci: false keeps the local-only gate, and unset is on.** The one case the gate cannot judge for itself is whether this project's CI is this project's suite — a fork whose gate checks are not this repository's, a ref CI does not run on — and that is a judgement about the forge, not about the commit, so update.trust_ci is where an operator makes it. Off, the pass runs exactly the gate it ran before any forge was consulted: no rollup is read at all, the local suite runs and decides alone, and the outcome names no CI verdict. Unset is ON, because the hosts this PRD is for are the loaded ones that configured nothing, and a switch they had to set would reach none of them.
The suite that is still local is not started into a held host (PRD #1007). Some passes reach the local go test ./... anyway: a commit CI has not judged yet, a forge that cannot be read, trust_ci off. That run is the largest thing a pass can start, and on the host this is all for it is the pass's whole failure mode — so serve hands its own spawn hold to the pass (internal/selfupdate/hostload.go). While spawn_hold_on_host_load STANDS — the host at or over patrol.load_per_core with more of its CPU going to work the conductor did not start than to its own sessions — a pass whose gate would be that suite is deferred rather than started, at its own stage, deferred, carrying the hold's own sentence: host overloaded: load 36.0 per core (threshold 2.0), …. It defers before the fetch and the build, not at the test stage, because a build spent on the way to a suite that will not run is spent for nothing either; a hold that engages DURING a pass still defers the suite at the test stage. A pass the forge's verdict decides runs in full under the same hold — green installs the commit, red refuses it at stage ci — because neither spends the host, and waiting for a quieter machine cannot make a failed commit installable.
A deferral is not a failure: nothing is refused, the binary is untouched, update carries no error, and the next pass at interval_seconds decides again — the hold has hysteresis of its own and lifts on the host's load easing. The stall detector's consecutive-failure run (PRD #651) counts it as no failure, and a run already standing is CLEARED by it, exactly as by any other pass that ends without an error: finishUpdate counts the run off Outcome.Err alone, so a deferred pass resets it rather than leaving it where it was. It is serve's hold and not the pass's, so a hand-run conductor update — an operator standing at the machine, who can see the load for themselves — is never deferred by it; a remote Upgrade now is, and answers refused with that same sentence (docs/remote-control.md).
**Where a release build comes from (PRD #797). switchyard is the version authority, and the release channel's origin is switchyard.work's public download** — the version-pinned binary at https://switchyard.work/api/v1/conductor/download/{version}/{os}/{arch}, the build switchyard publishes for this platform. **The latest GitHub Release of release.repo is its fallback**, and there is no third origin: a checkout is the source channel's origin and never this one's.
The addresses in this section are the pre-rename instance's, switchyard.work, because that is where conductor builds are published today: on 2026-10-26 https://ironway.work/api/v1/conductor/version named no build ({"pointer":null}), so a host whose base_url is ironway.work takes the GitHub Release fallback until builds are published there too.
GET /api/v1/conductor/version?os=&arch=&version= names the build this platform should run (URL and sha256) and returns switchyard's verdict on the running build; the URL it names is that version-pinned download on base_url's origin. A conductor sends its switchyard token with the fetch — attached only to requests whose scheme and host match the configured base URL, and stripped from any redirect that leaves that origin — and on that route the token is what makes it answer at all: **a tokenless GET of that route answers 401**.
That origin publishes ONE build and serves it over two routes on one origin, so do not read the authenticated one as the only way to it. The public install path takes the same build anonymously: https://switchyard.work/install/conductor.sh reads the same pointer — which needs no token either — and then fetches https://switchyard.work/download/conductor/{version}/{os}/{arch}, which answers with no credential at all. The two routes are not two builds: what that anonymous route returns verifies against the same sha256 the pointer publishes for that version, OS and arch, which is the check a conductor applies to its own fetch. Serving that download anonymously is switchyard's own to do, and switchyard PRD #795 has shipped it — on the /download/conductor/… route, not by opening the /api/v1/ one, whose 401 is that route's design and not a deployment still owed. So a machine with no switchyard token installs from that public route, from the GitHub Release or from source, and none of it changes which origin this channel updates from.
The fallback is reached when switchyard names no build — an older server, or nothing published. Then the latest GitHub Release of release.repo decides and its checksums.txt verifies the asset. Neither origin may skip that check: a pointer with no usable sha256 is never downloaded.
The out-of-date report runs **whether or not enabled is set**. serve asks the authority at startup and then every interval_seconds, logs one WARN per newly published build it is behind, and publishes the answer as /health's conductor_version and as a banner on the dashboard. A build switchyard could not judge (a local or dev build) is reported as undetermined and is never flagged. conductor update -check and conductor doctor print the same verdict.
Both of them end an outdated line with the remedy, and the remedy follows the channel. On any host that can run its own pass it is conductor update. On the source channel with no go on PATH it is the public install one-liner — curl -fsSL https://switchyard.work/install/conductor.sh | sh — because the source pass builds the tracked ref and has nothing to build it with, so naming conductor update there names a command that fails. That combination is not hypothetical: it is exactly what the one-liner produces, since channel is unset on a machine that configured nothing and therefore source. That one-liner's host is the conductor site switchyard PRD #796 builds, and it is not serving yet (switchyard PRD #796) — a GET of it answers 404 — so the install path live today is the https://switchyard.work/install/conductor.sh named above, which resolves the same pointer and installs the same build.
| key | type | default | what it does |
|---|---|---|---|
enabled | bool | false | Turn self-update on. |
channel | string | source | source builds the tracked ref from repo; release downloads a published, checksum-verified binary from switchyard.work's public download, falling back to the latest GitHub Release of release.repo when switchyard names no build. |
release.repo | string | outdoorsea/switchyard-conductor | Release channel: the GitHub repo whose latest release is the FALLBACK when switchyard names no build — never the origin, which is switchyard.work's public download. |
release.api | string | https://api.github.com | Release channel: the GitHub API base, for a test server or GitHub Enterprise. |
release.asset | string | — | Release channel: an extra substring the fallback's asset name must contain, when a release has more than one build per OS/arch. |
repo | path | the running binary's own checkout | The checkout to fetch and build. |
remote | string | origin | The remote to track. |
ref | string | main | The branch to follow — the release branch, what the promotion PR carries across. |
interval_seconds | int | 900 | How often the tracked ref (or release) is checked. Also the out-of-date report's cadence, whether or not enabled is set (never more often than once a minute). |
timeout_seconds | int | 1800 | Ceiling on one whole pass, and the test gate's go test -timeout: without it cmd/go gives each test binary its own 10-minute default, which a loaded host overruns in passing packages. A failed test gate names the failing packages, not the output's tail. |
skip_tests | bool | false | Skip the test gate before swapping (not recommended). |
trust_ci | bool | true | Source channel: let the forge's verdict on the exact target commit stand in for the local go test ./... (PRD #1007, internal/selfupdate/cigate.go). Unset is ON; false keeps the local-only gate, and off means the forge is never ASKED rather than asked and disbelieved. It is on by default where skip_tests above it is off because the two trade different things: skip_tests drops the suite and puts nothing in its place, while this replaces it with the same suite run by this repository's own CI on the same sha on a clean runner — and the hosts it is for are precisely the loaded ones that configured nothing. Turn it off when this project's gate checks are not this repository's suite (a fork's CI, a ref CI does not run on) — a judgement about the forge that nothing the pass can read settles. Build, vet, smoke and check-config stay local and unconditional either way. |
binary | path | <repo>/bin/conductor, else the running executable | The binary to swap. |
companions | []{name,command,binary} | — | Extra sh -c update commands run alongside (e.g. updating a companion tool), on the same cadence and only while enabled is on — and on every hand-run conductor update, enabled or not, unless it is given -no-companions; a companions.lock in the state dir keeps the two passes from running one updater at once. binary is the file the command replaces, whose change is what the log reports as an install; it defaults to the command's first word on PATH. A companion never restarts the conductor, and never restarts anything else either — see the Forge entry below. |
Keeping Forge current. Forge ships a forge update that answers current already at <v> as a cheap no-op and updated <old> → <new> when it installs, so it is a companion like any other:
"update": {"enabled": true, "companions": [
{"name": "forge", "command": "forge update", "binary": "/usr/local/bin/forge"}
]}
Name the installed forge path as binary (the same file forge.binary resolves to). Replacing that file reaches every NEW forge process — each join, heartbeat and leave — but not a running forge daemon, which keeps the build it was started from. That half is forge.restart_stale, below.
A missing companion fails nowhere — the tool just stays at the build copied onto the host, and forge.restart_stale then compares daemons against that stale binary and finds them current. So conductor doctor prints a companions line: for each fleet tool installed here that can update itself (doctor.SelfUpdating: switchyard-mcp, forge; faultline has no update verb yet), it warns when no companion's binary — binary, else the command's first word, by base name — is that tool, names the entry to add, and warns for all of them when update.enabled is off. The name is a log label and covers nothing.
Publishing releases to switchyard. .github/workflows/release.yml builds conductor_{linux,darwin}_{amd64,arm64} and checksums.txt on every push to main (a prerelease tagged with the short sha) and on every v* tag. It publishes them as a GitHub Release, then POSTs the release record (version, commit time, and each asset's os, arch, name, sha256 and size) to {IRONWAY_BASE_URL}/api/v1/conductor/releases (was SWITCHYARD_BASE_URL). That step needs no secret: it authenticates with the run's GitHub OIDC token (the job grants id-token: write), minted fresh for each run with the switchyard base URL as its audience. Ironway verifies GitHub's signature and that the token names this repository's release.yml on main or a v* tag, so nothing has to be created, stored or rotated. The step is continue-on-error: a switchyard that is down or not yet deployed leaves an annotation on the run and never fails the GitHub release. Optional settings:
- the repository variable
IRONWAY_BASE_URL(wasSWITCHYARD_BASE_URL) (defaulthttps://switchyard.work), which is also the OIDC audience. The workflow still reads the old name when the new one is unset, so a repository on the old name keeps publishing where it did. - the repository secret
IRONWAY_CONDUCTOR_PUBLISH_TOKEN(wasSWITCHYARD_CONDUCTOR_PUBLISH_TOKEN), a fallback sent only when the job cannot mint an OIDC token. It never overrides OIDC: a stale copy of it once made every publish a 401. The workflow still reads the old name when the new one is unset.
Hosts download the published binaries from switchyard with their own switchyard token; switchyard fetches the bytes from this repository's release through the GitHub connection of the software-factory/switchyard-conductor project, so no GitHub token has to be configured anywhere either.
forge
Whether roles run their sessions inside an Ironway Tower session. enabled is the host-wide default every project inherits; a project's own forge overrides it either way.
| key | type | default | what it does |
|---|---|---|---|
enabled | bool | false | Host-wide default for running inside Forge. |
binary | string | forge | The Forge binary. |
heartbeat_seconds | int | 30 | How often a joined session is renewed, inside Forge's 90s lease. |
keystore | absolute path, or file: + one | — | The keystore checkpoints are signed from. Read unprefixed from $FORGE_FILE_KEYSTORE too. The file: prefix is stripped; a path that is not absolute is refused at load, because forge resolves it inside the build's worktree and the key is committed with the build (#6678). |
required | bool | false | Whether an unreachable daemon is fatal. |
autostart | bool | false | Whether the conductor starts a missing daemon itself. |
restart_stale | bool | true | With autostart on, restart a daemon whose build differs from the installed binary once it has held no session for 2 minutes. Set false to keep running daemons on their build. |
Stale daemons. A daemon reports the build its PROCESS runs (version and revision in forge status -json), and conductor doctor's forge line prints it beside the binary's: daemon runs X, the binary's build, daemon runs X, binary is Y — stale, or daemon build unknown with the reason (a daemon too old to report it, or a binary with no readable vcs.revision). Unknown is never read as a match and never acted on.
On every project tick, serve restarts a stale daemon when ALL of these hold: the project runs in Forge, autostart is on (the conductor is the daemon's supervisor — a daemon an operator started by hand under autostart: false is never touched), restart_stale is not false, both builds are known and the daemon's differs from the installed binary, the daemon has reported zero sessions under the same revision and pid on every tick for 2 minutes (ForgeStaleIdleWindow, a constant), and the installed binary is KNOWN usable: its build info reads, and the verb probe both answered and found every verb the conductor runs. A build not known to work is refused, logged once per binary revision, and the working daemon kept.
That includes a probe that answered NOTHING. The probe asks the binary for each verb's usage and reads the verbs it does not know off what it printed, so a probe killed by its own 5s deadline — a loaded host, a binary that will not start — prints no unknown command and names no missing verb. Before issue 6583 that empty answer read as "every verb is there", which meant the same broken install was refused on a quiet host and, on a busy one, cost a working daemon: it was stopped and started again on a build nothing had checked. Such verbs come back on forge.Binary.Unprobed instead and refuse the restart just as a missing verb does, with the daemon that is already running left serving — it is this pass's only evidence of a build that works. A verdict is kept per installed binary (path, revision, size and mtime) so the probe's four execs are paid once rather than on every tick of every project for as long as a refused build stays installed; an answer that concluded nothing is never kept, so the next tick asks again, and a rebuilt binary is a new file and is asked about afresh. "Differs", not "older": revisions have no reliable order, so a daemon started from a newer or dev build is moved to the installed one too once idle.
The window exists because zero sessions is not proof of an idle tree: Forge drops a session whose 90s lease has expired from its answer, so a build whose heartbeats lapsed (a host that slept, a stalled conductor) reads as idle until its next beat rejoins. Two minutes is several 30s heartbeats plus a rejoin; any tick that sees a session, a new pid or a new revision starts the window over.
Once due, serve sends SIGTERM to the pid the daemon reports — re-checked, under the lock below, to be the pid it watched — waits up to 10s for it to exit (never SIGKILL), starts the daemon again on the installed binary, and logs one line naming both builds. A daemon with a live session is never stopped — under required that would fail an agent mid-build. Every conductor join and Ensure holds a lock beside the repository's git directory that the restart holds exclusively from its last probe to the new daemon's start, so no conductor role can join the daemon being stopped; a session joined by anything else in the few milliseconds between that probe and the signal is not covered.
teamclaude
Route every LLM session through a local TeamClaude proxy; fail-closed when the proxy is unreachable.
When enabled, serve reads GET /teamclaude/status once per tick and derives an availability verdict from it; disabled, the port is never dialled at all. While that verdict cannot serve a session, every LLM-backed role on every project is refused as teamclaude-paused and starts nothing — including on a single unreachable or unparseable read, which fails closed for that tick. Only a repeated failure (two in a row, the count persisted across a restart) or an explicit all-blocked verdict enters the durable pause recorded at <state_dir>/teamclaude-pause.json. Throughout, running sessions and their leases, the merge lane, the attach sweep, the session ledger, the verifier and the patrol carry on: only starting is gated. Availability is re-read every tick while paused and the first read that can serve clears the record whole, so the lanes resume on that tick with no restart.
The pause surfaces as a dashboard banner, as /health's top-level teamclaude object (enabled, paused, error, record, plus reason/since/for while paused), and as doctor's availability line — which warns and never fails, a spent pool being transient capacity rather than a misconfiguration. Each transition files exactly one host-wide feed event, teamclaude.paused or teamclaude.resumed, never one per tick. See internal/teamclaude.
| key | type | default | what it does |
|---|---|---|---|
enabled | bool | false | Turn the proxy routing and the availability gate on. |
port | int | 3456 | The local proxy port. |
mitm | bool | true when enabled | On, sessions get HTTPS_PROXY + NODE_EXTRA_CA_CERTS; off, ANTHROPIC_BASE_URL alone. |
account | string | — | Pins sessions to one account via TC_ACCT. |
roles | map[string]bool | — | Per-role opt-out of routing ({"judge": false}): no proxy env and no probe, which is how a non-Claude launcher stays off the Anthropic MITM. It does not exempt the role from the availability gate, which is host-wide. |
callboard_forks
The Callboard orgs that forked the base this host publishes, and which serve brings back up to that base right after it publishes it (PRD #775). Pure Go: no LLM session, no claim. Empty — the default — runs no step at all.
"callboard_forks": [
{"org": "software-factory", "key": "env:CALLBOARD_FORK_KEY_SF"},
{"org": "another-workspace", "key": "file:~/.switchyard-conductor/secrets/callboard-fork-another"}
]
| key | type | default | what it does |
|---|---|---|---|
org | string | — | The fork's Callboard org slug. Required, named once; the step confirms the key names this org with GET /api/v1/org before it writes anything, and refuses the fork on a mismatch. |
key | secret ref | — | Where that org's cbk_ key is kept — env:NAME or file:/path. Required. A key written inline is refused at load, naming the field: a cbk_ key opens a whole workspace, and this file is the one agents are pointed at to orient themselves. |
When the step runs: only after the release-gated base publish both ran and landed. A build that is not the release, an unreadable version pointer and a dev build publish no base, and a publish that did not land — a document Callboard refused, a role left unbound — published half of one; in all of those the step is skipped and nothing is pulled. A publish that LANDED and failed its post-publish CHECK is pulled over: that check says nothing about whether the base is whole, and gating on it left a fork 27 of 27 roles behind for two days (switchyard issue #5082). The failed check is reported instead — briefs-publish on the patrol, and the briefs line in conductor doctor. The one exception is per ROLE and not a skip: a role in the publish's unresolved — one the base does not serve as this build renders it — is withheld from the pull while every other role follows the base, since pulling it hands each fork the same divergence.
What it pulls: exactly the conductor's own roles (instructions.Roles()) that base_status reports behind with no local edit — the fork's text is an older base edition, verbatim, so taking the newest discards nothing.
What it never touches: a role the fork edited (diverged, or behind with no matched base edition), which is reported with both editions and left alone; unbound, missing, local-only and no-document, which are decisions and not drift; a role the conductor does not run; a role the base does not serve as this build renders it (the publish's unresolved); and any fork whose key names another org. It never forces a pull.
Each fork runs under its own two-minute timeout in the publish's background goroutine, so neither a slow nor a failing fork delays or fails serve's start. The result is callboard-forks.json under the state dir, read back by GET /health as callboard_forks and by conductor doctor, which prints one line per configured fork (tracking N · pulled M · edited K) and never calls Callboard itself for one.
With the list EMPTY the step says so once per serve start, and a callboard-fork-<org> file in this host's sealed store that no entry names makes that a warning naming the org: a fork somebody installed a key for and did not finish wiring up. conductor doctor prints the same state and how far behind it is (fork not configured — 27 of 27 conductor roles behind the base), which is the one fork reading that calls Callboard — GET /api/v1/org then base_status, never pull_base, since a fork with no entry has no record to read. The whole step is callboard-integration.
callboard_publish_org
The Callboard org a brief publish from this host may write into (switchyard issue #5578). Empty — the default — names none.
"callboard_publish_org": "switchyard"
Why the setting exists. A publish writes as the OPERATOR and never as the org key (PRD #794), and Callboard's document, snippet, role and binding routes carry no org at all — the org is read off the bearer. So the target of a publish was never chosen by anybody: it was whichever workspace Callboard resolves for the login auth.Resolve happened to find, and the cbk_ key in callboard.env has no say in it. On one host that login resolved to software-factory, a fork of switchyard: every release from 2026-10-06 landed in the fork, the base kept its 10-06 text, pointing IRONWAY_CONDUCTOR_CALLBOARD_TOKEN (was SWITCHYARD_CONDUCTOR_CALLBOARD_TOKEN) at the base's key changed nothing, and no reader said a word.
It is a HOLD, not a route. Naming an org here cannot send a write somewhere else — there is no route that takes one, which is the companion Callboard issue. What it does is refuse: the publish resolves the org the write WOULD land in (GET /api/v1/org under the same operator bearer every write presents) and, when that is not this slug, writes nothing — not a document, not a snippet, not a binding, and not even the read of a body that decides whether anything changed — and names both slugs with the remedy. To release to another org, publish from a host whose operator login is an operator of it.
Unset still says where it went. Every publish names the org it writes into, configured or not: conductor instructions publish prints publishing into callboard org <slug>, and serve's publish at start logs callboard briefs publish target. That half needed no setting, because the fault was a host that could not say which workspace its release reached.
conductor instructions publish --org <slug> is the same hold for one run, and it outranks this field in both directions — the narrower statement wins.
conductor doctor prints the standing on its own callboard-pub line: the org a publish writes into, and, when that org is a fork, how many of the conductor's roles read behind the base and how many read diverged (which a pull would discard). A warning, never a fail, and it never pulls — it is the drift this host is creating, which no other fork reading can see, since this host holds neither a callboard_forks entry nor a key file for it.
rails
Ironway Roundhouse is a self-hosted, Buildkite-shaped CI. It reports to GitHub as commit statuses — roundhouse/<pipeline> for a build, roundhouse/<pipeline>/<step> per step — and every lane already reads those through the forge's rollup with no configuration. This block adds the two things the rollup cannot give:
"rails": {
"url": "https://ci.example.com",
"token": "file:~/.switchyard-conductor/rails-token",
"native_checks": ["outdoorsea/switchyard-conductor"]
}
| key | type | default | what it does |
|---|---|---|---|
url | string | — | The server's base URL — the same base_url its status target URLs carry. Required. |
token | secret ref | — | An Ironway Roundhouse API token, as a reference only (env:NAME or file:/path); an inline one is refused, and no command writes it. Required. |
native_checks | []owner/name | — | Repositories whose branch-head checks are read from Ironway Roundhouse (GET /api/v1/checks) instead of GitHub GraphQL. List only a repository no other CI reports on: the native read sees Ironway Roundhouse's checks and nothing else. |
- Reruns. A base-ci patrol finding whose failing check links to a build under
urlnames its runrails-<build id>, and the "Re-run failed checks" remedy (and the flake warden's rerun) calls Ironway Roundhouse'sretry-failedfor it — the failed jobs only, as the forges' reruns do. Without this block an Ironway Roundhouse check has no run id, as any external CI's. - Native reads. For a listed repository
HeadChecksasks Ironway Roundhouse first, and uses its answer only when Ironway Roundhouse vouches for the head's SHA (it saw the push, or the caller named the SHA); anything else — an error, a guessed SHA — falls back to GitHub, exactly as before. Checks read either way carry the same names and verdicts, so one fault is one fault. - Failed test names. The flake warden reads WHICH tests a red
rails-<build id>run reported off the build itself: the jobs that ran and failed for themselves (GET /api/v1/builds/<id>), each of their logs (GET /api/v1/jobs/<id>/log) read for the runner's own--- FAIL:verdicts by the same reader that reads an Actions run's. A retried attempt, a soft failure (Rails posts one assuccess) and a job that never ran are not read. Anything short of a whole reading — no block configured, a server that serves neither route, a log it will not give or one over 16 MiB, a failed job whose log names no test — answers NO test, on which the warden holds nothing and the red goes to rework as it did before (flake-warden). This is the conductor half of the cutover gate below: what the required status has to carry before it may move, and the half a server that serves no readable log still holds. - The shadow comparison.
conductor rails-shadowcompares a week of a repository'sroundhouse/<pipeline>/<label>statuses with its Actions check runs head by head, naming every disagreement by label and whether the Rails build was head-only while the Actions run was the merge ref (PRD #1053, the shadow report). It needs NONE of this block to read the verdicts — both CIs' entries are on the head's rollup — and it uses the block for one thing: with a server armed it can ask which ref a build checked out (GET /api/v1/builds/<id>), and without one every line's merge-ref answer readsunknownrather than being assumed either way. Nothing it reports moves a required check; the cutover is a person's call. - The agent roster (
GET /api/v1/agents) is whatconductor doctor'sci-toolsline reads to name the hosts a job will run on: one ok/fail line per agent host, saying whether the tools a project'sfactory-uistep needs (nodeandpython3, read from the clone's ownci.yml) are there — probed on PATH for the host doctor runs on, and from the agent's declared tags for every other host. Without this block only this host is reported. It fails no host: seeinternal/doctor.
The cutover — the two stages, and what the required status carries. This repository's own CI is translated to Rails as the pipeline conductor: .swrails.yml runs every .github/workflows/ci.yml job the fleet can execute through one scripts/swrails-ci.sh (which refuses a step ci.yml does not define, so the two cannot drift silently), each step labelled with the Actions check name so Rails reports it as the commit status roundhouse/conductor/<label> and the two sides pair by label. The move is in two stages — the shape Ironway itself took, a shadow pipeline beside Actions before any required check moves — and only the first has shipped:
- The shadow stage, shipped. Both CIs build every head, and
conductor rails-shadowcompares a week of them label by label (the shadow report). Actions is still the gate: thestagingruleset's one required status check is Actions'build & test, which is also what the merge lane reads, and the required status has not moved — no ruleset on this repository names aroundhouse/context, and nothing in this repository changes that by itself, because a required check is a forge setting a person makes. (mainrequires no status check at all: it takes what the promotion carries across, which is why the promotion gate rather than a ruleset is what reads a head there.) - The required status, not shipped and gated rather than scheduled. The second stage makes
roundhouse/conductorthe required check onstaging, points this project'sgate_checksat it (roundhouse/, the prefix form, names the whole CI without listing its pipelines), lists this repository undernative_checks, and leaves Actions running only the jobs Rails cannot: every job needingcontents: write—release.yml's publishing job, on a push tomainand av*tag — andpromote-only-main.yml, whose checker runs only on a pull request targetingmain. It is taken after a week of the shadow stage's report, on a person's reading of every disagreement in it, and not before the four readings below hold. **native_checksis the one of those edits that is not symmetric**: the native read sees Ironway Roundhouse's checks and nothing else, and after the cutover Actions still reportshead-refon a pull request targetingmain— the gate that keeps a branch that is not a promotion out ofmain, which Rails cannot run and which the promotion's own lanes read off the rollup. Listing this repository would drop it from what they read, so the cutover'snative_checksentry is a separate call from its required check, not part of the same edit.
Stage 2, written down and not applied (read back 2026-10-26). A session was asked to take stage 2. It made none of the changes, because none of the preconditions hold. This host's config has no rails block at all (url unset, no native_checks), so no Rails server builds this repository. No shadow week exists. internal/flake now reads a rails-<build id> run's failed tests off the armed server as well as an Actions run's off gh run view --log-failed, so what that half of the gate still waits on is the server's log (SR-1052) and not the conductor's read of it. The staging-2 ruleset still requires only build & test, and staging-1 and Main Protection require no status check. Requiring roundhouse/conductor today would hold every staging merge on a status that nothing reports. When the gate above clears, a person applies these edits and reads each one back:
- Host configs. Add a
railsblock (url, andtokenas anenv:/file:reference) to every host config that serves this repository. After that, as the separate call described above, listoutdoorsea/switchyard-conductorundernative_checks. - Required status. In ruleset
staging-2, replace the required contextbuild & testwithroundhouse/conductor. In this project'sgate_checks, setroundhouse/. - **What
roundhouse/conductorthen carries.** It is the build-level status of theconductorpipeline. It is green only when every.swrails.ymlstep is green:build & test,race,integration,factory interactions & assets,gofmt,added code has a caller,CLAUDE.md budget,AGENTS.md budget,config keys,promote-only-main gateandrules index. Each step also reports asroundhouse/conductor/<label>. That covers the verdict. It also carries the run idrails-<build id>, but only on an armed host. It carries the failed test names once the server serves a build's failed-step log over the API (SR-1052): the warden's read of one is already here. - Actions trimmed. Delete
.github/workflows/ci.yml, whose jobsroundhouse/conductornow carries. Keeprelease.yml, whosepublish the release assetsjob needscontents: writeto publish a release. Keeppromote-only-main.ymlas well. That is the one exception to "onlycontents: write": it needs onlycontents: read, but it runs on a pull request targetingmain, which Rails cannot run, and it reports thehead-refcheck.
**The shadow is reporting, under roundhouse/ (read back 2026-10-29).** The Rails server now builds this repository and posts its statuses as roundhouse/conductor and roundhouse/conductor/<label>. It took the Ironway Roundhouse name, so no live context starts with rails/ any more. Every rails/ above that names this pipeline's status now reads roundhouse/, and conductor rails-shadow reads both prefixes. While stage 2 is not applied, those statuses are a shadow, and a shadow must not hold a merge. Before idea-261747, the merge lane counted every rollup entry under an unset gate_checks. A red roundhouse/conductor (a Pipeline upload exiting 127 on an agent host) then held every green, approved request on checks failing on any host that runs the lane. .conductor/project.json therefore lists roundhouse/ under advisory_checks. The statuses still count for rework, the stationmaster and the shadow report, and they never hold the merge. Applying stage 2 means removing that entry in the same edit that sets gate_checks.
What the required status carries. Naming a check required names the one artefact this repository's lanes reason from, and only the first of the four readings they take off it is the verdict:
- The verdict, off the head's status-check rollup. Rails carries this with no configuration at all — every lane already reads
roundhouse/<pipeline>androundhouse/<pipeline>/<step>through the forge — andgate_checksis what decides which rollup entries count for a project. - A run id, for the re-run. An Actions check run carries its own; a Rails commit status carries one only through its target URL, decoded as
rails-<build id>, and only on a host where this block is armed. So the precondition is therailsblock in EVERY host config that serves this repository rather than one: an unarmed host reads aroundhouse/conductorred as any external status with no run id, and the "Re-run failed checks" remedy has nothing to call. - The failed test names, for the flake warden — this is the gate.
internal/flakeholds a red out of rework only when it can name WHICH tests failed, keyed by package and test, which it reads out of the failing run's own log (verifyfail.FailedTests, overgh run view --log-failedfor an Actions run and over the Rails API for arails-<build id>one). The conductor half of this reading is now in place: a red Rails build's failed jobs and their logs are read for the runner's own verdicts (§rails above, the flake warden), so the run id no longer answers no tests by construction. What it answers is only as good as the log the server gives it, and a reading short of a whole one answers NO test — the same silence as before. While Actions is required every red is read off ITS run and the warden is unaffected; on the dayroundhouse/conductoris the required check, that silence would cost this repository its flake warden, and every flake on its own CI would buy a rework session whose whole output is "re-ran, green" (about 1.8M input tokens in the measured window — the flake warden). Rails SR-1052 is the server half the cutover still waits on: a build's failed-step log, readable over the API asgo test's own output, carrying both the--- FAIL: <test>lines and the per-packageFAIL\t<pkg>verdict lines the register keys by. Until a measured Rails red names its failed tests through that route, the gate holds and PRD #1053 with it. - The ref it built. Actions builds a pull request as the merge ref while Rails fetches the head (Rails pitch #1133), so a disagreement between them may be about the code or only about the ref. The shadow report measures it per head rather than assuming it either way, and reports a server that does not say as
unknown.
The block is armed at process start beside the GitHub identity, so a change needs a restart.
retro
The retro role's block (see prd-retro).
| key | type | default | what it does |
|---|---|---|---|
file_ideas | bool | false | Whether the retro may file ideas rather than only propose. |
max_findings | int | 8 | Cap on findings per pass. |
excerpts | int | 6 | How many log excerpts a pass reads. |
excerpt_bytes | int | 4000 | Byte cap per excerpt. |
attach
conductor agent attach, an operator's terminal on a pty session.
| key | type | default | what it does |
|---|---|---|---|
detach_keys | string | ctrl-] | The key sequence that detaches without ending the session: comma-separated keys in docker's ctrl-p,ctrl-q grammar. The -detach-keys flag overrides it; a value that does not parse fails the config to load. |
model_prices
Prices reported token usage for the registry's daily_spend_cap, in USD per million tokens by kind. A key matches a model exactly or as a prefix (claude-opus-5 covers both the dated claude-opus-5-20260115 and the context-tier claude-opus-5[1m] a launcher reports); the longest match wins. A model with no row is never capped — the conductor will not stop a role on a number it cannot compute — and the dashboard says so.
Price every model the host's launchers can reach, not just the default one: a per-role launcher names its own (launchers.judge = "opencode run"), and a single missing row leaves that model's whole registry role uncapped while the cap still reads as satisfied — daily_spend_cap reports $0.00 spent for an unpriced model and never fires.
"model_prices": {
"claude-opus-5": { "input": 5, "cache_creation": 6.25, "cache_read": 0.5, "output": 25 },
"claude-sonnet-5": { "input": 2, "cache_creation": 2.5, "cache_read": 0.2, "output": 10 },
"claude-haiku-4-5": { "input": 1, "cache_creation": 1.25, "cache_read": 0.1, "output": 5 },
"deepseek/deepseek-v4-pro": { "input": 0.28, "cache_read": 0.028, "output": 0.42 }
}
Per-project settings
Each entry of projects. Only tenant, project and repo_dir are required.
| key | type | default | what it does |
|---|---|---|---|
tenant | string | — | The Ironway tenant slug. Required. |
project | string | — | The Ironway project slug. Required. |
repo_dir | path | — | The local checkout. ~ expands. Required. |
dolt_data_dir | path | repo_dir | The Dolt data directory whose .dolt_dropped_databases bin the maintenance pass empties. See dropped databases. |
verify_command | string | — | The command the contract and post-merge lanes run (e.g. go build ./... && go test ./...). It runs with the checkout's own .venv/bin and node_modules/.bin first on PATH when the checkout holds them (below). |
default_base | string | the repo's default branch | The integration branch PRs target. |
setup_command | string | — | The project's setup_command as this host knows it, read for ONE purpose: deriving the preflight list below when the project declares none. What a lane actually RUNS as the setup is switchyard's own declaration (the bead claim's standard paths, or the briefing), so a stale copy here can only cost an extra version probe. |
preflight | []string | — (derived) | This project's toolchain preflight (PRD #1006): the commands serve runs, after git --version, to decide whether this host can build the project at all. The list is read for TWO decisions. Before work starts: while one of its commands fails, spawn_hold_on_preflight holds the project's new sessions and swarms. After work ends: a run whose failure names a toolchain that could not run — an exec that was not found, command not found, dyld: Symbol not found, Library not loaded — and whose project's preflight then fails is released as host_fault=toolchain, infra on the wire, so it spends no rework attempt or guardrail budget. Each entry is a shell line run in repo_dir under its own 30-second timeout, and each should be a version PROBE and not a build: the list is read on the host's clock, before any work is claimed and again after a hand-back, where a build would be minutes and could fail for reasons that are the work's. Unset or empty is DERIVED, not off: one probe per toolchain the project's own verify_command and setup_command name — go version for a go command, swift package --version for swift, node --version for npm, npx or node — and git alone when they name none. The way to hold no starts on the preflight at all is spawn_hold_on_preflight: false, which switches off the hold and not the fault reading. See the toolchain preflight and the host-fault table in the builder. |
vcs | string | ask Ironway | github or gitlab. |
gate_checks | []string | — (count the whole rollup) | The status-check contexts that GATE this project. Every conductor lane that asks "is this head green?" — the merge lane's per-PR gate, the promotion gate, the stationmaster's checks failing hold, rework's red-CI trigger — reaches its verdict from the head's status-check rollup, and by default lanes count every entry of the head's status-check rollup: GitHub Actions check runs, the self-hosted shadow runs and Ironway Roundhouse' commit statuses alike. This names which of them count, so a project can move its gate to one CI without every other entry on the head still deciding the answer. An entry is a commit-status context or a check-run name (workflow / job, as the rollup reduction composes it); **an entry ending in / is a prefix** and matches every context with that prefix, which is how rails/ names a whole CI without listing its pipelines, while any other entry matches exactly. Entries are trimmed and blank ones dropped (orchestrator.Project.Gates), and a list of nothing but blanks is read as no gate rather than as a gate that counts nothing. Unset or an empty list leaves every rollup entry counting exactly as before — which is why it is a plain list and not a pointer: there is nothing an operator could mean by [] that differs from leaving the key out. The matching is vcs.GateChecks.Counts, and it is applied in internal/vcs, where rollup entries are reduced: **the PR view path, the open-PR snapshot path and the branch-head read (vcs.CheckReader.HeadChecks) all consult the gate**, so a head read by ref and the same head read as a pull request carry the same checks and no lane filters its own way — the merge lane's per-PR gate and the stationmaster's checks failing hold (which keys on the gate reason the merge lane recorded) read it off a PRView, while the promotion gate, rework's red-CI trigger, the patrol base-CI pass, the revert guard, post-merge and task review read it off a branch head. On GitLab an entry names either GitLab's one folded pipeline check — the whole head pipeline, as before — or ONE JOB of it (pipeline / test, composed as the GitHub reduction composes workflow / job, so pipeline / is the prefix form for every job), kept or dropped by name on both its paths — a GitLab project can therefore move its gate to one job of its pipeline exactly as a GitHub project moves it to one context (issue 5661). The jobs are read only when the project named a gate at all: an unset gate counts every entry and the fold IS every job, so a project that configured nothing reads the one folded check it always read, at no extra API call — and is never held by an optional manual job that no gate named. A job the project declared allow_failure is counted GOOD whatever it ended as, because GitLab's own pipeline verdict ignores it, and a pipeline the forge calls green must not read as red or as pending forever here. Ironway Roundhouse' own head answer — which stands in for the rollup for a repository Rails builds alone — is filtered the same way. An entry the gate does not count is absent from the counters AND from the check list rather than discounted by each lane in turn, so it can make a head neither red, nor pending, nor green: a head carrying no entry the gate counts reads as having no checks, which the merge lane refuses (no checks) rather than merges, because the absence of CI is not CI. The two readings switchyard's mirror serves in place of a rollup cannot be filtered through this gate, so **a project with gate_checks set is not judged on it at all** and the lane reads the gated rollup from the forge instead, at the cost of one read on a promotion that is not already green: the promotion head's mirrored ci_conclusion, which synthesises ONE aggregate check from switchyard's webhook row rather than reducing a rollup, and the integration head's branch-head record (GET {P}/branch-checks), whose entries are check SUITES named by the app that ran them (GitHub Actions) rather than the workflow / job names a gate is written against. In both cases there is nothing in the reading for an entry of this list to match, so a gating context that is failing — or still going — while the mirrored word reads green would promote, or re-pin onto a head this gate calls red. conductor doctor prints a gate-checks line per project reporting the gate and how many of the rollup entries on the project's integration branch head it counts, and warning when it counts none — a gate naming a context the rollup does not carry leaves every lane reading that head as having no checks, which is a refusal. The gate is keyed by the project's forge slug, resolved at load from repo_dir's origin remote; a checkout whose remote cannot be read warns and arms no gate for that project, leaving its counts unchanged. |
advisory_checks | []string | — (discount nothing) | The rollup contexts that report on a request and never hold the merge lane (idea-261747) — a shadow runner such as roundhouse/, an informational scan — for a repository whose base branch requires no check, where the lane cannot narrow its hold to the required set (issue-6674) because there is none, and for a provider that cannot say which checks are required. Entries match as gate_checks entries do: a context or check-run name, and **an entry ending in / is a prefix**; trimmed, blanks dropped (orchestrator.Project.Advisory). Unlike gate_checks an advisory context is not dropped: it stays in every count rework, the stationmaster and a person read, and only two readings discount it: the merge gate's pending/failing hold (vcs.PRView.HoldingChecks), and the readings that decide the BASE is red — the patrol's base-CI pass, post-merge's base faults, basefault.Classify and doctor's base-guard (vcs.WithoutAdvisory), so a shadow runner red on the integration branch is not filed as a base fault the merge gate itself would wave through. base-guard still names how many advisory checks fail. It never outranks the base: where the required set was read on the head and names a check, that narrowing applies and the list changes nothing, a check the base requires is never discounted, and a request the forge calls BLOCKED keeps every check. An advisory context is not CI: a request carrying nothing else is refused as no checks. Armed at load keyed by the project's forge slug, resolved from repo_dir's origin as the gate is. Unset or empty discounts nothing. |
settings | object | — | Take this project's defaults from its repository. See Registering a project from its repository. |
generated_paths | []string | frontend/dist/**, **/*_templ.go, docs/openapi.json | The repository's COMMITTED build artefacts, as path globs in which ** spans directory separators (rebase.DefaultGeneratedPaths, internal/rebase/generated.go). It answers one question, for one lane: when a pull request's merge conflict collides only on paths this names, nothing anybody wrote collided, so the conflict is not two designs and there is nothing for a person to pick between. See a conflict in a committed build artefact. Unset or empty is DEFAULTED, not off — which is why it is a plain list and not a pointer, exactly as gate_checks is: there is nothing an operator could mean by [] that differs from leaving the key out. A project whose artefacts are elsewhere names its own list and gets exactly that list; a project that wants NO path treated as generated names one glob that matches nothing. A malformed glob matches nothing rather than deciding every path one way, because the list may arrive over the wire from .conductor/project.json. |
regenerate_command | string | — (none) | The one command that rebuilds everything generated_paths names, from the source as merged — npm run build && scripts/resolve_dist_merge.sh. It is the only command on a project entry this host never runs: it is quoted into a rework brief for the session to run in its own worktree, which is why a repository may declare it in .conductor/project.json where it may not declare verify_command or setup_command (commands stay the host's) — the conductor executes a verify itself with no model in the loop, while this is one line of prose a session reads beside the repository's own build scripts and instruction files. Declaring none is supported: the brief then says the project declares no command and tells the session to find the build script itself, rather than leaving it to assume one exists. |
roles | []string | ["brakeman","judge","pr-reviewer","rework"] | Which roles this project may start. See Roles. |
max_brakemen | int | 1 | Concurrent builders on the project, across every host: before spawning, serve counts the fresh builders on GET {P}/agents from every conductor host and starts only cap − fleet_live. An unreadable roster starts none. |
max_reviewers | int | max_brakemen | Concurrent reviewers, counted across the fleet like max_brakemen. Unset, it follows the configured max_brakemen, so review keeps pace with what the builders open. |
max_judges | int | max_brakemen | Concurrent judgment-lane runners, counted across the fleet like max_brakemen. Unset, it follows the configured max_brakemen. |
max_brakemen_here, max_reviewers_here, max_judges_here | int | 0 (no host bound) | Optional bound on how many of the project's slots THIS host fills. |
max_validators | int | max_brakemen | Concurrent contract-lane runners (conductor validate). A serve tick starts one per free slot the runnable queue can use, and each runner holds its own checkout (lanes/contract, contract-2, …, one per slot) for its life, so runners never reset each other's tree. |
max_verifiers | int | max_validators | The same contract-lane ceiling, spelled the way a config carrying role_vocabulary: switchyard spells it (PRD #988): under that marker max_validators is the JUDGMENT lane's cap, so this is the only key that names the contract lane. It is read ONLY under the marker — an unmarked config caps the lane with max_validators, which still binds it. |
max_reworks | int | 1 | Concurrent rework sessions (per-PR ceiling is rework.max_per_pr). |
max_pr_fixers | int | 1 | Concurrent pr-fixer sessions — the lane of last resort for a pull request the ordinary lanes have given up on (PRD #528). serve starts one runner per CLAIMABLE prescalate-* bead up to this cap; a bead the server withholds is waiting on a person's answer and is not demand. The role is off unless roles lists pr-fixer. |
max_issue_fixers | int | 1 | Concurrent issue-fixer sessions (PRD #714). Each one claims a batch of issue_batch_size approved issue-* beads, each on its own lease, and works them in ONE session with a worktree per issue. serve divides the CLAIMABLE issue-* depth by the batch, rounded up, and starts that many runners up to this cap; a withheld fix bead (its question still open), or one a live [issue N] human task carries (the lane handed its question or close to a person on a server with nowhere else to put it), is not demand. The role is off unless roles lists issue-fixer, and while it is listed the builder leaves issue-* beads to it. |
issue_batch_size | int | 3 | How many issue-* beads one issue-fixer pass claims (PRD #714); a value over 10 is clamped to 10. The pass also stops at the server's issue fix allowance. |
max_idea_builders | int | 1 | Concurrent idea-builder sessions (PRD #717) — the issue-fixer's engine on approved ideas (the idea build lane). Each one claims a batch of idea_batch_size approved idea-* beads, each on its own lease, and works them in ONE session with a worktree per idea. serve divides the CLAIMABLE idea-* depth by the batch, rounded up, and starts that many runners up to this cap; a withheld idea bead (its decision question still open), or one a live [idea N] human task carries (the lane handed its question, close or PRD draft to a person on a server with nowhere else to put it), is not demand. The role is off unless roles lists idea-builder, and while it is listed the builder leaves idea-* beads to it. A server older than switchyard PRD #716 mints no idea-* bead at all, so a host pointed at one reads a depth of zero and starts nothing. |
idea_batch_size | int | 2 | How many idea-* beads one idea-builder pass claims (PRD #717, issuefixer.DefaultIdeaBatchSize); a value over 5 is clamped to 5 (MaxIdeaBatchSize). Smaller than issue_batch_size on both counts deliberately: an issue names a defect and its fix is bounded, while an idea is a proposal the session must also SIZE — build it, question it, close it, or draft the PRD it really is — and a session asked to size five proposals at once sizes none of them well. |
max_directives | int | 1 | Concurrent @Conductor directive answerers. |
max_dms | int | 1 | Was the cap on Buzz DM listeners. The dm role is retired (PRD #654): serve starts none and conductor dm is no longer a command, so this caps nothing. Still accepted, so a config carrying it keeps loading. |
max_repliers | int | 1 | Concurrent conversational shapers. |
max_answerers | int | 1 | Concurrent PRD-question answerers. |
max_triagers | int | 1 | Concurrent issue triagers. |
max_swarm_width | int | 4 | Most concurrent sessions ONE swarm fans out to on this project. The width itself is the plan's independent roots, not its task count; this only bounds it, and 1 keeps every plan on the single-session lane. conductor swarm --cap can narrow it further, never raise it. See swarms. |
max_swarm_repairs | int | 1 | Repair attempts a swarm may spend on a FAILED integration verification, read beside the cap above. When every task merged and verify_command fails on the integration branch, the lane runs that many sessions in the integration worktree, verifying again after each, before the run ends. 0 is the pre-repair behaviour: the run ends at the first failed verification with no repair session. It bounds attempts INSIDE one run, not how many runs start, so no balance lever moves it. A repair may only make the integration verify — it may not remove a file a task committed, or edit verify_command or a gate script it names. See a swarm repair may only make the integration verify. |
swarm_alternation | bool | false | Alternate the width rule's answer against a counterfactual on this project (PRD #592). With it on, every second PRD the rule routes to the swarm lane — and that the entry rule admits — is built as ONE session instead, so the two shapes can be compared on the same kind of work; the route row's reason carries counterfactual: and the width the rule had computed. Off (the default) nothing about the route row changes: no arm, no prefix, no entry check, and no project alternates by surprise. The assignment is read from the run ledger's own entered route rows, never from a file, and the experiment ends itself at six delivered PRDs per arm or sixty days. See the alternation. |
max_open_prs | int | 0 (never holds) | Holds the builders while MORE than this many PRs are open against the integration branch. Over it the merge lane also orders its queue by clearability rather than by age, so the count falls as fast as the lane can make it fall; under it the lane keeps oldest-first. |
contract_fail_ceiling | int | validate default | Consecutive fail verdicts one criterion may take before the verifier raises a human task. |
doctor_contract_floor | float | 0.5 (one half) | The share of this project's APPROVED criteria that must carry a runnable or human-judged: verify_command before conductor doctor's contract-coverage recommendation stops advising. Below it the line reports missing naming the share; at or above it, adopted. Held to [0, 1]: a floor above one could never be cleared and one below zero could never be fallen below. 0 silences the line, which is why it is read as a pointer and 0 is a setting rather than an absent one. The line is ADVICE and can never fail doctor. See doctor's recommendations. |
doctor_recommendations | bool | true | Print conductor doctor's recommendations section for this project — the delivery practices it has and has not adopted, one line per practice with a remedy. false prints no such section for the project at all, including the verify-under-lease line: the section is ADVICE, and advice a project has decided against is noise on the one surface every host runs, so a project whose branches are not the conductor's shape or whose CI is not GitHub Actions can stop carrying unactionable lines on every run. Read BEFORE the reads the section needs, so a silenced project costs no switchyard request for it either. Silencing advice silences nothing else — doctor still reports the project and every check beside it — and no line of the section could ever have changed doctor's exit code anyway. To silence ONE line instead, use that practice's own setting (doctor_contract_floor: 0 is one). See doctor's recommendations. |
spin_ceiling | int | 5 | Consecutive no-progress build attempts (released or idle, no new head, no new delivery) one criterion may take before this host's builders stop re-pooling it. The hold is keyed on the criterion's evidence and lifts only when that moves: a new head or delivery, a person's handoff on the bead, or a re-scoped bead description. Raising it past the count lifts the hold too. |
beadless_criterion_age | duration | 24h | How long one of this project's OUTSTANDING criteria may have no bead in the claim pool before the patrol raises its PRD for a person — beadless-criteria, one task per PRD listing that PRD's criteria past the bar, updated in place as the list changes and withdrawn when none remain. Every beadless criterion is on /health's projects[].criteria.beadless with its age whatever this is set to; the bar decides only when a person is asked. A PROJECT setting and not a host one because switchyard mints beads for a project on its own cadence: a project whose PRDs are decomposed within the hour wants a tighter bar than one whose are approved in batches, which is also why it is in the shape a repository may set in its own .conductor/project.json. Refused at load when it is not a positive duration, naming the project and the key: a bar of 0 would raise a task for every criterion the moment a pass first saw it, and -1h or soon is an operator who meant something and would otherwise get the default in silence. It gates the PATROL TASK and nothing else: the undeclared-delivery pass beside it (PRD #861's credit leg) does not consult this key at all — it weighs every outstanding contract criterion on its own cadence, because running a criterion's own command costs the same whether the criterion has waited an hour or a month. See the criteria nothing is building and the credit leg. |
beadless_mint | bool | true | Whether this host ASKS switchyard to mint the beads the beadless pass found missing — POST {P}/prds/{id}/beads/missing, one call per PRD per pass with exactly the criteria past beadless_mint_after, right after a pass that read. The server decides from its own ledger what it mints (a criterion that has a bead by now, was delivered or scoped out comes back skipped), and the route is idempotent, so several hosts may run it with no lease. A switchyard without the route is backed off for 6h and the pass stays report-only; a PRD the route refuses (409: not approved/executing, or shelved) is left alone for 6h and its patrol notice stays the person's. false keeps the pass report-only: the patrol task at beadless_criterion_age is then the only remedy. Outcomes are on /health's projects[].criteria.beadless_mint. A PROJECT setting a repository may set in .conductor/project.json. See minting what the pass found. |
beadless_mint_after | duration | 1h | How long one of this project's OUTSTANDING criteria must have had no bead before the minter asks switchyard for one (beadless_mint on). Shorter than beadless_criterion_age's day on purpose: asking the server is cheap and idempotent while a person's notice is not — and not zero, because a PRD approved a minute ago may still have its own dispatch in flight. Refused at load when it is not a positive duration, naming the project and the key, as beadless_criterion_age is. A PROJECT setting a repository may set in .conductor/project.json. See beadless-criteria.md. |
patrol_host_fault_window | duration | 6h | How far back the patrol counts a run of this project's host_fault=verify_timeout or host_fault=wait_cap releases on ONE host when deciding whether to tell a person about the host — the two host-fault checks (PRD #860). The rows kept are the WIDEST stretch of the run this duration holds — the most releases any window of it has ever held, the newest such stretch where several tie — trimmed by the rows rather than by a clock, so a reading does not change under one. The widest stretch and not a cut at the newest release, because that cut FALLS as the fault recurs: a third release of the class more than this window after the second read as a run of one, took the fault below the bar and withdrew the standing notice saying it had cleared, while the newest row was itself the same fault again. Read this way a later uncleared release can only raise the count. It exists because a run only ENDS when a later build of this project gets THROUGH the phase that failed, which a project nobody has built since does not do: without a window, one release in March and one in October are a run of two and read as a condition of this host. A PROJECT setting and not a host one because how long a release stays evidence about the host is a property of how often the project is built, which is also why it is in the shape a repository may set in its own .conductor/project.json. Refused at load when it is not a positive duration, naming the project and the key: a window of 0 would keep only the newest release of a run and so never reach the bar of patrol.DefaultHostFaultReleases (2), leaving both checks silent on exactly the host they exist for, and -1h or soon is an operator who meant something and would otherwise get the default in silence. See the patrol. |
rejection_review | string | fix | What a builder does with a bead a validator REJECTED (its claim carries a failing last_rejection). fix: the brief tells the session to evaluate the rejection, reproduce the validator's scenario as a failing test first, then fix, and to ask with a QUESTION:/RECOMMEND: pair instead of rebuilding when it finds the rejection wrong. ask: the session only evaluates and recommends a fix as a decision question on the PRD; nothing it commits is published. Answering re-pools the bead, and that build makes the fix as decided. Any other value is refused at load. |
triage_batch_size | int | 10 | How many untriaged issues one triager pass claims (each on its own stake) and one session categorizes. 1 is one issue per session, the pre-batch behaviour; a value over 50 is clamped to 50. serve divides the claimable queue by it, rounded up, to decide how many triagers to start. |
prefix | string | the project's Ironway key, lowercased | Goes in front of every agent name for this project. |
merge_lane | bool | false | Turn on merging + promoting for this project. |
post_merge | bool | false | Verify the integration branch AS INTEGRATED after anything lands. Needs repo_dir and verify_command. |
draft_prs | bool | false | Open PRs as drafts. |
clone_setup_output | bool | false | On a host whose worktrees are cut as copy-on-write clones of the project's template tree (macOS on APFS, PRD #825), run the project's setup command in that template on each refresh that moves it, so every cloned checkout starts with the setup's output — dependency trees, generated files — and the lane's own setup runs on top of it instead of from nothing. Opt-in, because setup output can embed the template's absolute path. Inert on a host or volume that cannot clone. The template's setup run is bounded at 15 minutes (brakeman.templateSetupTimeout), because a refresh is a builder waiting to cut: a setup still running then is killed and logged, the template keeps the commit the refresh moved it to, and the setup is left to the lane's own run in the checkout — so the ceiling one true here puts on a template refresh is a quarter of an hour, not the setup's own worst case. Why and how: APFS clone checkouts. |
auto_balance | bool | false | Let the conductor move THIS project's runtime role caps itself when the queue goes lopsided: at or over max_open_prs with a draining lane (review, rework) holding work it can actually run, it lowers the work-creating lane ({brakeman: 0}); when the pool is deep and the PRs are draining, it relaxes back to the configured caps; and — the THIRD direction (PRD #546) — when the delivered-but-unjudged queue has outrun what the validators actually got through in the trailing 24 hours by judging_backlog_factor, it shifts toward judging by lowering the builders to judging_brakeman_floor (never to 0, because judging needs deliveries to judge). It writes only the balance.json marker and never the config, never raises a lane above its configured cap, and yields to an operator-set balance — a balance a person set from the page or by hand is authoritative and auto-balance neither changes nor clears it. Gated like a spawn: paused, throttled, offline, or either of the builder registry row's daily ceilings (daily_action_cap, daily_spend_cap) holds the pass and records a refusal under role auto-balance on the project's row. The judging direction is WITHHELD while nothing in the queue is reachable (PRD #703): on a tick whose shape would shift, the pass reads switchyard's contract_coverage and, when judge_reachable plus contract_bearing less broken_contract and unrunnable_contract (contracts no run clears; an older switchyard without unrunnable_contract has its smoke_gap subtracted instead, never both, and a count it does not send reads as 0) is 0 — every delivered-and-unvalidated criterion human-only, abandoned, unjudgeable or carrying an unrunnable contract — it takes no shift and records the reading that withheld it (nothing_judge_reachable), because lowering the builders would drain none of it; an unread coverage gates nothing and a shift already standing is left to its own expiry. See Runtime overrides and internal-balance. |
judging_backlog_factor | float | 1.5 | How far the delivered-but-unjudged queue may run ahead of the validators' trailing-24h throughput before auto_balance shifts the mix toward judging. A queue against a RATE, not a percentage, so it reads the same on a 2000-criterion project and a 20-criterion one. 0 turns the judging direction off and leaves the other two working — and because a direction that is not running may not be left holding builders, it also RELEASES a shift this pass had already left standing, on the next tick and whatever the other two directions decide that tick — ending the judging CLAIM and not whatever else the marker holds, so a PR-queue hold the sticky claim had been sharing that marker with keeps its builders-at-0 and its clock (there is no reading being taken, so nothing could ever prove the queue drained, and a shift nothing can lift is a hold on the builders forever); an unreadable queue or throughput maintains rather than shifting. A queue over the factor is not on its own a shift: nothing shifts while the project's contract_coverage reads NO criterion any validator lane can take (see auto_balance, PRD #703). The same factor is what CLEARS an expiry's human task — a queue measured at or under it withdraws the Judging shift expired task this install raised, carrying that reading as the cancel reason. |
judging_ratio_high | float | 0.30 | The trailing-7-day unjudged share at which auto_balance shifts the mix toward judging — the second of the direction's three conditions, and the one a RATE cannot give: a lane that has only just started judging has a fine throughput and a terrible backlog. The share is compared as the fraction it was measured as, so a reading of 3 in 10 is AT this mark and shifts. Never 0 or less, whatever this is set to: a share threshold of 0 is satisfied by every reading there is, including a project with nothing delivered at all, so it would be a permanent shift rather than a threshold — there is no 0 off switch here, because the whole direction is turned off in one place (judging_backlog_factor). |
judging_ratio_low | float | 0.15 | The trailing-7-day unjudged share at or below which auto_balance RELAXES the mix back — and only together with a queue at or under ONE times the throughput, on a complete reading. Set it well under judging_ratio_high: the GAP between the two is the deadband, and it is the whole reason there are two numbers. A single threshold sits exactly where a project hovers once the shift starts working, so the mix would flap tick by tick; anything measured between the two marks maintains instead. A low mark configured at or above the high mark collapses that deadband — the shift is asked first, so such a project shifts and the share never lets it back — and it is NOT clamped for you, because silently rewriting one setting to suit another would run the project at thresholds its config does not state. Never 0 or less: a relax mark of 0 is reachable only by a project with nothing unjudged whatsoever. |
judging_age_backstop_seconds | int | 172800 | How old this project's oldest unjudged delivery may get before auto_balance shifts toward judging on AGE alone — 48 hours, which is past any weekend. It is the third condition, and the one for the case neither the rate nor the share can see: a lane that has judged NOTHING has a throughput of zero, against which no queue is "behind", and a share that barely moves while the oldest delivery quietly ages. Compared at the reading's own precision, so "older than" trips at 172800s+1ns and not at 172800 exactly. Never 0 or less, whatever this is set to: a backstop of 0 shifts on any unjudged delivery the instant it lands. |
judging_brakeman_floor | int | 1 | How many builders a judging shift leaves running. Never 0, whatever this is set to: a judging shift must not stop the lane that produces the deliveries it is draining. Clamped down to the project's own max_brakemen, so the floor can never RAISE the lane. A lane whose effective builder cap is ALREADY at or under the floor is not shifted at all and no marker is written — which, on the supported default configuration (max_brakemen unset is one builder), is every project that has configured nothing else. |
judging_shift_max_seconds | int | 21600 | How long ONE judging shift may hold before auto_balance lifts it itself and raises a human task naming the queue, the measured throughput, the condition that shifted the mix and how long it held. A judging shift is the one direction that can be right indefinitely — a validator lane that is simply too small keeps its condition true forever — so it is bounded: six hours is long enough that an ordinary catch-up finishes under it and short enough that a lane which is NOT catching up reaches a person before the next working day. Measured from when the pass wrote the SHIFT — the judging claim's own since on the marker, preserved across re-affirmation and across a conductor restart, and not the marker's, which on an override that stood as a PR-queue hold first dates the hold. Never 0 or less, whatever this is set to: a shift that could hold forever is the unbounded throttle the expiry exists to remove. The task is deduped on the project and the condition THAT SHIFTED THE MIX (the shift's own, not the lifting tick's reading), so a queue that stays bad is raised ONCE and not once per shift. The bound is unconditional: a shift whose expiry cannot be reported (switchyard unreachable) is still lifted, and the task it owes is raised on the next tick that can — and a PR-queue hold on the same marker does not make it unexpirable, since what expires is the judging claim, leaving the hold in force. The cap is REPORTED on every tick it is reached on, including one whose own reading has just relaxed the shift: the relax clears the override and the task is raised beside it, because a project that ran at a lowered builder cap for the whole of its maximum is worth knowing about even when the queue came good at the end of it (the task says so rather than claiming the queue never drained). The direction is free to shift again on the next tick — the cap bounds one shift, not the behaviour. The task is raised under this install's own roster ref (<agent_prefix>/auto-balance, registered first because the server refuses an unknown one) and is WITHDRAWN once its cause is gone (PRD #703): a later tick that measures the unjudged queue at or under judging_backlog_factor × the trailing day's verdicts cancels the task it raised, with that reading as the cancel reason, so a queue that came good leaves nobody asking for judging capacity that is no longer wanted. Only a task read back as raised by that ref is withdrawn — one a person or another install raised is never the pass's to take back — the withdrawal is a cancel and never a completion, and a registration that fails raises under the account as before and withdraws nothing. |
shaper_wake | bool | false | Make THIS host the project's standing-shaper wake. Turn on for ONE host per project. |
classify_phases | bool | true | Let the builder classify an undecided PRD phase's build shape (and what it needs from outside itself) automatically. With it on, a phase with no row yet is admitted as ONE builder and its beads are held while a sibling is claimed or delivered elsewhere, so the first claim classifies the phase before a sibling starts; false makes no row the operator's standing decision to build per bead, and neither hold applies. A row that records needs holds the phase either way. |
yardmaster | bool | true | Run the yardmaster on this project (yardmaster). On by default: the loader resolves the role into roles for every project that leaves this unset or true, whether or not roles names it. false is the one opt-out: the role is absent from roles, no pass starts, no nudge runs, no /health row is reported, and it wins over a roles entry too (with a startup warning naming the contradiction). yardmaster_interval_seconds is not an opt-out: 0 there is unset and takes the default. This flag means "no pass at all"; a project that wants the proposals and no acts switches the kinds off in yardmaster_nudges. |
yardmaster_nudges | object | every kind on but three | The yardmaster's acts, switched per kind (PRD 833): a map of nudge kind to true/false — rereview, retry-rework, update-branch, route-base-fault, unblock-bead, requeue-bead, retire-roster, answer-with-recommendation, credit-declared-bead, propose-gate-checks. Every kind defaults on, so a kind the map does not name is on and false is the switch — except answer-with-recommendation, credit-declared-bead and propose-gate-checks, which are OFF until the map says true (the answer nudge, the gate nudge); all three are host-owned, and a repository's .conductor/project.json that names any of them is refused (PRD #1076, projectsettings.AuthorityKeys); a kind the conductor does not know is refused at load, naming it. A pass with every kind off takes no lane lease and only proposes. /health lists each project's kinds switched on (projects[].yardmaster.nudges) and doctor prints them with the last day's per-kind counts (yardmaster). |
review_attribution | object | every key true | The review lane's three WIDENING legs, switched per leg (PRD #848): a map carrying only the keys issue_linked, review_requested and ready_for_review. All default on, so a key the map does not name is on and false is the switch; a key the conductor does not know is refused at load, naming it and the ones that are known. issue_linked: false makes the row's issue_ids read as not attributed, review_requested: false does the same for the review-requested label, and ready_for_review: false does it for the ready leg, under which a non-draft request no other leg attributes is reviewed crediting nothing — so with ready_for_review off, a request no other leg attributes is refused unattributed_delivery before any session boots and held at the merge gate until somebody attributes it. The other two legs the gate attributes on (the review bead's own PRD, and the row's prd_ids/crit_labels) deliberately have NO key here: they are what the gate has always reviewed on, and a project that switched them off would refuse a verdict on every delivery its own builders open. conductor doctor prints the switches with the last week's reviews counted per leg on the review-attr line, so the cost of each widening is read rather than guessed. See the four ways into a review and internal-reviewer. |
report_timezone | IANA zone | resolved from Ironway, else UTC | The zone the project's day (and daily caps) roll over in. |
buzz | object | — | Bind the project's Buzz channel. See Buzz binding. |
faultline_dsn | secret ref | host-wide DSN | This project's own error-reporting DSN, so faults triage where the work lives. |
forge | bool | host-wide forge.enabled | Override whether this project's sessions run inside Forge. A tri-state: unset follows the host. |
text_budgets | array | — | Mark this project's PR-only TEXT-budget gates, arming the safe-slack auto-fix. See Text budgets. |
human_merge_paths | []string | — | Glob patterns whose requests a PERSON merges (PRD #822). A reviewed, green, mergeable request whose changed files match one of them is held on the merge lane's human_merge_required gate — asked LAST, after every other gate has passed — naming the matched paths, and raises ONE human task naming the request, those paths and the two ways out: a person merges it, or a person applies human_merge_label. The gate never raises a PR escalation (there is nothing for a pr-fixer to repair), a promotion request is exempt, and the gate fails CLOSED when the changed files cannot be read. ** matches any run of path segments, so internal/auth/** matches internal/auth/oauth/device.go; every other metacharacter is path.Match's, one segment at a time. A pattern that does not parse is dropped with a startup warning naming it while the rest apply. Meant for the repository's own .conductor/project.json. Unset, nothing is held and the lane is not asked. See the paths a person must merge. |
human_merge_label | string | merge-approved | The label a person puts on a request held by human_merge_paths to approve it. It approves the head the request had when the label was APPLIED and that head alone — read every pass off the FORGE's own records (vcs.LabelHeadReader: the latest label event, against the head branch's push history), never off the lane's next read, so a push landing between a person applying the label and the lane's next pass is not approved with it. A push after that re-holds the request, naming both heads; removing and re-applying the label is a fresh approval of the head it is applied on then. Every reading that cannot name a head — no label event, no push recorded before it, a refused read, a forge with no such history — fails CLOSED: the label approves nothing. See the paths a person must merge. |
Text budgets
A gate that measures a file's SIZE fails every open pull request at once and none of them can fix it — the fault is the base's. text_budgets names such gates so the conductor may answer one with a bounded whitespace trim instead of a repair item (PRD #500):
"text_budgets": [
{"check": "CI / claude-md-budget", "file": "CLAUDE.md", "max_bytes": 40960}
]
check is the check's name exactly as the rollup reports it, file is the one file it measures, and max_bytes is the ceiling it enforces. All three are required — a half-written marking fails the config to load rather than sitting there looking armed.
Marked, and only marked, a base fault on that check — or an unguarded base measured over the budget — may cost ONE trim pull request against the integration branch, on conductor/<install-id>/base-trim/<check>, whose only edits collapse runs of intra-line whitespace to a single space and strip trailing whitespace in that file. It is taken only when it actually lands the file under the budget; a trim that would not clear it opens nothing and the ordinary repair item is raised instead. Indentation, fenced blocks and line breaks are never touched, no other file is edited, the budget itself is never raised, and there is at most one open trim per gate — a trim that does not clear the fault escalates to the repair item rather than to a second trim.
Unset (the default) is no auto-fix at all: every base fault takes the repair item, exactly as it did before.
Roles
Implemented as sessions: brakeman, judge, pr-reviewer, rework, validator (the contract lane; opt-in), dedup-scout, idea-scout, security-scout and refactor-scout (propose-only, off unless listed — scouts), steward, retro, answerer, triager, replier, directive (the @Conductor answerer; needs a bound Buzz channel), pr-fixer (works an escalated pull request; off unless listed — internal/prfixer), swarm (builds one PRD's queued plan as several sessions on one branch — swarm), issue-fixer (a batch of issue-N beads → a verified fix PR, a question or a coded close — issue-fixer), idea-builder (the same engine on a batch of approved idea-N beads → a built PR, a question, a coded close, or a CHECKED PRD draft for an idea too large for one bead — the idea build lane), doc-scout (the one cadence role that pushes — doc-scout), fresh-audit (a weekly fresh-session measurement whose answers land on the Triage tab — fresh-audit), yardmaster (a pure-Go pass that proposes acts and, under its lane lease, nudges stalled work through the lanes that own it — never merges — yardmaster), meta (one VR-factory conversation turn, read-only tools — internal/meta), combination (trial-merges the held set and runs the suite over it — combination lane) and harness-read (measures an open harness-edit pull request against the regression set — harness-edit read). Every one of these is off unless listed — except yardmaster, which is ON by default, and is turned off per project with yardmaster: false rather than by leaving it unlisted (its acts switch off per kind in yardmaster_nudges). shipper runs in-process (pure Go, no LLM). dedup-scout, idea-scout, retro, doc-scout, fresh-audit, combination, harness-read and the two scouts are cadence-gated rather than queue-gated.
Some lanes run without being listed. The swarm assessment assesses each newly approved PRD and, through decompose, queues a plan for the swarm lane. A project that does not list swarm still gets those plans queued, and nothing builds them. The host-wide patrol is in no project's roles and runs on its own clock.
dm is retired (PRD #654). A project still listing it loads with one warning and starts no listener: the role polled the relay with a buzz-key PRD #586 stopped minting, and switchyard PRD #585 decrypts a direct message to the Conductor server-side and serves it as a directive the directive lane already claims, so a listener here could only double-answer that lane. Drop dm from roles and dm from the buzz block to clear the warning; nothing else changes.
Buzz binding
"buzz": {
"community_host": "software-factory.switchyard.work",
"channel_id": "abc123",
"events": "important",
"dm": true
}
| key | type | default | what it does |
|---|---|---|---|
community_host | string | — | The Buzz community. Both this and channel_id are required to post. |
channel_id | string | — | The NIP-29 group within the community. |
events | string | all | all posts every lifecycle event; important posts only what an operator would act on (lease losses, fail/refused verdicts, the rework ceiling, errors). |
dm | bool | false | Retired (PRD #654); read by nothing, and a config carrying it loads with one warning and starts no listener. It named the one project per community_host that read the community's direct messages, back when a host read them at all — the inbox belonged to the install's key rather than to a project, so two listeners answered every message twice, and the loader refused a config that did not name exactly one reader (dmReaders). That refusal is what made the dm role impossible to drop from a multi-project config, so both went together. A direct message to the Conductor is decrypted by Ironway (switchyard PRD #585) and served as a directive the directive lane already claims. conductor doctor does not grade it either: its buzz line used to fail a project on the shape the rule refused and tell the operator to SET the field, which is the opposite of what the retirement warning tells them, so that clause went with the rule. |
mentions | bool | unset | Retired (PRD #586); read by nothing, and a config carrying it loads unchanged. It named the ONE conductor host per community_host that read the room's @Conductor mentions and forwarded them into Ironway, and the loader refused a config where two projects on one community declared it. Ironway holds the community's subscription now and hands each mention to exactly one host as a directive over directives/claim — the same claim pool that hands out beads — so there is nothing for a config to declare and nothing for two hosts to race over. The cross-machine half went with it: no host stakes a conductor-mentions claim on the relay, and there is no mention-claims.json. conductor doctor's buzz line says who reads the community instead. |
The shared build directory (IRONWAY_CONDUCTOR_BUILD_DIR (was SWITCHYARD_CONDUCTOR_BUILD_DIR))
Every project has a build directory the conductor creates at <state_dir>/<tenant>/<project>/build/ (PRD #828). Its path is exported as IRONWAY_CONDUCTOR_BUILD_DIR (was SWITCHYARD_CONDUCTOR_BUILD_DIR) to every run the builder starts for the project (the setup, the LLM session, the off-turn waits and the verify) and to BOTH of the contract lane's runs — a criterion's verify_command, and the system-level run a criterion tagged [smoke] or [journey: <name>] owes after it, which goes through the same runner and so the same environment. It is not a config key: there is nothing to set, and a project that never reads the variable is unaffected.
It exists so that build output which does not depend on the branch can live outside the checkout. A checkout then holds source, and the session-end rule (a checkout whose commits are on origin leaves when its session ends) and the hourly sweep have little to remove. Whether a project uses it is the project's own choice, made in its setup_command and verify_command; the conductor creates and exports the directory and never writes into it. Many checkouts of one project run at once, so key anything that must not be shared off the checkout, as the examples do.
conductor doctor reports what the directory holds on the project's worktrees line, and says nothing about it while it is empty. conductor gc -build-dir empties every project's directory and keeps the directory itself; -dry-run measures without removing. The whole run is refused while a serve is up on the state dir (it can start a build there at any moment), and a project with any session running is refused, naming the sessions, because a running setup, session or verify may be reading or writing there; the other projects are still emptied, and the command exits non-zero while any project was refused. The hourly maintenance pass never empties it: doing so costs every checkout of the project its cached output, which is a person's call.
Go already shares one build cache across checkouts (-trimpath, see one build cache across worktrees); the directory is for what the cache does not hold, such as built binaries:
go build -o "$IRONWAY_CONDUCTOR_BUILD_DIR/bin/$(basename "$PWD")/" ./cmd/...
Node: point the package store at it, so each checkout links packages instead of downloading and unpacking its own copy:
pnpm install --store-dir "$IRONWAY_CONDUCTOR_BUILD_DIR/pnpm-store"
# or, with npm, share the download cache:
npm ci --cache "$IRONWAY_CONDUCTOR_BUILD_DIR/npm-cache"
Rust: Cargo's target directory is the largest thing a checkout grows. A target per checkout keeps builds from contending on Cargo's lock while still moving the bytes out of the tree, and sccache can share compiled crates across them:
export CARGO_TARGET_DIR="$IRONWAY_CONDUCTOR_BUILD_DIR/cargo-target/$(basename "$PWD")"
cargo test
The checkout's own tools on PATH
A contract is written the way its author runs it: a Python project whose setup_command builds a .venv declares pytest tests/test_recap.py, a Node project whose setup runs npm ci declares vitest run. A builder's verify script activates the venv or goes through npm run, which put the checkout's own tool directories first on PATH; a lane running the same line with sh -c does not, and pytest was exit 127 (command not found) on every contract run. So every run the conductor makes of a verify_command or a setup_command in a checkout — the contract lane, the post-merge check, the combination verify, the validator's issue-fix contract, and the setup every lane runs in a fresh worktree — prepends to PATH, in this order and each only while it exists as a directory in that checkout at run time:
<checkout>/.venv/bin
<checkout>/node_modules/.bin
The rest of PATH is left as it was. A checkout holding neither runs with PATH untouched, nothing outside the checkout is consulted, and nothing is activated or sourced: a venv at another path, or a tool the project installs elsewhere, is still the project's own verify_command to reach. It is not a config key. The LLM session's own environment is not touched by this; the session's brief tells it the project's standard paths. Where the rule lives: the contract lane.
The .noindex heavy roots and conductor gc -noindex
The state dir's heavy roots are real directories named <name>.noindex, each with a relative symlink at <name>: every project's worktrees, lanes and build, every sessions root (the host's and each project's), and session-archives. Spotlight skips any directory whose name ends in .noindex, so a macOS host does not index what the fleet writes, with no sudo and no operator step. Nothing in the config names the suffix and there is nothing to set: every path the conductor derives (the work root, the session roots, the build dir a session is handed) is the unsuffixed one and resolves through the link. serve makes a missing root this way on its start; it never renames one that exists. The layout is the same on Linux, where it costs nothing.
An install whose roots predate this has plain directories. conductor gc -noindex moves them: each plain root is renamed to <name>.noindex with the link left at the old name, every git checkout under a moved worktrees or lanes root gets git worktree repair (its clone records it by the path it resolves to), and any missing root is made suffixed. -dry-run lists what would move and what would be repaired. The whole migration is refused, naming each session, while any recorded session is running: a session holds paths under the roots it would move. It is also refused, naming its pid, while a serve is up on the state dir, idle or not, since serve can cut a checkout under a root mid-rename; a dry run still reports and says so. Stop serve, run it, start serve again. It exits non-zero when refused or when a root did not end up suffixed. conductor doctor's spotlight line lists the roots still plain. See Spotlight on a macOS host.
Registering a project from its repository
A repository can carry its own conductor defaults in .conductor/project.json: a project block in the same shape as a projects[] entry above.
{
"tenant": "acme",
"project": "widget",
"default_base": "staging",
"roles": ["brakeman", "judge", "pr-reviewer", "rework"],
"max_brakemen": 2
}
The file may not set verify_command or doc_scout.verify_command (PRD #627), nor preflight (PRD #1006) — projectsettings.CommandKeys: each names a command this host RUNS, and a repository that could set it could make every host that serves it run anything. A copy that sets any of the three is refused whole, so doctor -preflight fails and serve does not start. Set them in the host's conductor.json entry, which is laid over the copy; a project that declares no preflight still gets one, derived from the commands that entry carries.
conductor project add registers the project by name, from a checkout, from a clone URL, or — given no argument — from a pick on ironway.work (it opens the connect page, prints the URL when no browser opens, and waits for the pick):
conductor project add # pick it on ironway.work
conductor project add acme/widget # switchyard names the repository
conductor project add ~/src/widget # an existing checkout
conductor project add git@github.com:acme/widget.git # clones into ./widget
conductor project add <url> --into ~/GitHub/widget --project acme/widget --mode local
It copies the file to <state_dir>/<tenant>/<project>/settings.json and writes a conductor.json entry that holds only this host's facts:
{ "tenant": "acme", "project": "widget", "repo_dir": "/Users/me/src/widget",
"vcs": "github", "settings": {} }
Roles come from switchyard's Governance. For a NEW entry, add reads the project's role registry (GET {P}/roles, the read serve and doctor use) and writes roles as the conductor roles that serve each row switched on there, printing builder → brakeman, rework, swarm for each row it used. A row brings in the standard roles a fully configured project runs — builder: brakeman, rework, swarm; reviewer: pr-reviewer; validator: judge, validator; answerer: answerer; shaper: replier, meta; triager: triager, idea-scout, dedup-scout, retro; steward: steward; shipper: shipper — and never the lanes a project opts into one at a time (pr-fixer, issue-fixer, idea-builder, decompose, doc-scout, the two propose-only scouts, fresh-audit). directive is ungoverned and needs a bound Buzz channel, so it is added by hand. --roles a,b,c writes exactly those instead, without reading Governance. A settings file that declares roles keeps them: the entry writes none, so the file stays the source. If the registry cannot be read, or switches on nothing this host runs, the entry writes no roles, the default applies, and add says why in one line. An existing entry's roles are never rewritten.
The loader lays that entry over the copy, key by key. A key in the entry wins, including an explicit false or 0. Every key it leaves out comes from the repository. To override one setting on one host, add that key to the entry.
With no --base or --ref, add looks for the file on the remote's default branch, then on main, master, staging and develop, and uses the first branch that has it. A settings file merged to staging is found before it is promoted. A repository with no origin is read at HEAD. If no branch has the file, add registers the project anyway and prints the branch sync will watch.
add refuses a duplicate registration. It also refuses a settings file that sets a host-owned key — repo_dir, dolt_data_dir, faultline_dsn, settings or settings_shadow_apply (an authority knob, PRD #1076, which only an operator grants) — or a command key — verify_command, doc_scout.verify_command: the host owns those keys, and the credential rule above still applies. tenant and project in the file only name the project when --project is not given. If the resulting config does not load, add restores the config and removes the copy.
Ironway fills in what the command line and the repository leave out:
- A name alone (
add acme/widget, which a directory of that name overrides):addreadsGET {P}/repository, clones its https URL into<state_dir>/repos/<tenant>/<project>(or--into), and writes the project's integration branch asdefault_baseunless--baseor--refis given. A project with no connected repository, or a switchyard that predates the read, is refused with the remedy: connect one, or pass a checkout or URL. - A checkout that names no project:
addreadsGET /api/v1/scope/by-repo?remote=<origin>, which matches only projects the credential can reach. No match, or more than one, is refused and says so.
Neither lookup runs when the command line or the file already answers it, so registering a named local repository still needs no network.
The settings binding
| key | type | default | what it does |
|---|---|---|---|
mode | string | sync | sync: serve re-reads the file from source control and replaces the copy when it changed. local: the copy was taken once and is this host's to edit; nothing refreshes it. |
path | string | .conductor/project.json | The file inside the repository. |
ref | string | origin/<default_base> (origin/HEAD with no default_base) | The ref the file is read at. add pins HEAD for a repository with no origin. |
refresh_seconds | int | 21600 (6h) | How often a sync-mode copy is re-read. |
A sync reads a commit on the remote-tracking ref after git fetch, never the working tree. Only a merged change reaches the host, and an agent's uncommitted edit in the checkout never does. A commit whose file fails the checks above is refused, and the host keeps its last good copy. A failed sync is retried after 30 minutes. If the repository deletes the file, the copy is removed too. serve checks for due copies every 5 minutes and reloads itself when one changed.
conductor project list # each project, its mode, the ref@commit it was read at
conductor project sync [acme/widget] [--force]
conductor project mode acme/widget local # keep and edit the copy
conductor project mode acme/widget sync # follow the repository again (re-reads at once)
conductor project remove acme/widget # drop the entry (refused while a session of it runs); checkout and state dir kept
Environment overrides
A few keys can be set from IRONWAY_CONDUCTOR_* (was SWITCHYARD_CONDUCTOR_*) variables, which override the file: BASE_URL, STATE_DIR, CALLBOARD_URL, CALLBOARD_TOKEN, LAUNCHER, JUDGE_LAUNCHER, POLL_SECONDS, LEASE_SECONDS, RELEASE_COOLDOWN_SECONDS, AGENT_PREFIX, MERGE_LANE_OWNER (outranked by a merge_lane switchyard serves — a fleet key, see merge_lane), DM_POLL_SECONDS, DEDUP_INTERVAL_SECONDS, DASHBOARD_ADDR, and the FORGE_* and TEAMCLAUDE_* knobs. A one-project host can be given entirely from IRONWAY_CONDUCTOR_TENANT (was SWITCHYARD_CONDUCTOR_TENANT) / PROJECT / REPO_DIR (plus optional VERIFY_COMMAND, DEFAULT_BASE, VCS, BUZZ_COMMUNITY_HOST, BUZZ_CHANNEL_ID).
The two credential env vars use their unprefixed documented names — FAULTLINE_DSN, FAULTLINE_API_TOKEN, FORGE_FILE_KEYSTORE — because those are what the collector and Forge already document, and an exported DSN always outranks the file.
Applying a change to a running conductor
conductor refresh — or Reload config in the dashboard, which POSTs the loopback-only /reload — re-reads the config file and the IRONWAY_CONDUCTOR_* env, rebuilds the client if base_url changed, re-resolves each project against the new server, recomputes prefixes and drops removed projects. The swap itself happens inside the serve loop, and the call answers with the outcome of that swap (PRD #547) rather than with a bare 204.
It answered 204 until #547, and 204 meant only "the request was posted". On 2026-09-17 an operator raised max_judges (4→8 on one project, 4→6 on another) and max_validators and called POST /reload. The call returned 204, /health still reported judge=4/4 two minutes later, and the new caps appeared about five minutes on, when serve restarted itself for an unrelated update. The file had been right the whole time: the running process had either refused the swap or never applied it, and the answer could not say which.
There are four answers, and only the first reports the change as live:
| the answer | what happened | what it carries |
|---|---|---|
| applied | the loop swapped the new config in | every setting that CHANGED, with its old and its new value, per project — and the settings KEPT because they are not re-read while the process runs |
| refused | a check refused the whole config, and the one already running stayed | which check refused and its reason, and that the running config is unchanged. Not a 2xx, and conductor refresh exits non-zero |
| superseded | a second reload arrived before this one reached the loop | that a later edit is the one the loop took. The newest edit wins and the earlier caller is TOLD, never answered as though its own config had landed |
| queued, not yet applied | the loop did not answer within a bounded wait | exactly that. A loop busy with a long tick is neither a failure nor a success, so the answer claims neither — it never reports the change as applied |
What cannot change live. A few settings are read once, before the loop begins, and a reload KEEPS them rather than half-applying them: dashboard_addr (the listener is already bound) and state_dir (every session root, ledger, catalog and sealed-secret path was derived from it at start). They are named in the applied answer, on the kept side, so an edit that will need a restart says so when it is made rather than when somebody notices it did nothing. That set is ONE table in internal/orchestrator, walked by a test against Config's own fields: a setting added to the config and named in neither the live set nor the restart-only set fails the test, so a new key is classified by whoever adds it instead of defaulting silently to either side.
Is the file on disk the one running? GET /health carries the live config's loaded-at time and a digest of its content (config_loaded_at, config_digest), and the dashboard shows both. The digest moves on an applied reload and does not move on a refused one, so "is my edit live?" is a read: same digest as before the call and the running config is the old one, whatever the call said.
A reload never touches a running session. No session is killed, restarted or re-briefed by a swap. A changed cap is read by the next spawn decision, so a max_brakemen lowered below the number of live builders leaves every one of them to finish and gates only the next spawn — trimming a cap is "start fewer from here on", never "stop what is running", which is what conductor pause is for. detail
**A project registered while serve runs is on the event feed from the swap (PRD #627).** The feed watchers are started once, before the loop's select, over the projects the config held at startup — so a project added by conductor project add (which writes the file and then POSTs /reload) or by a settings sync used to be polled only until somebody restarted the process, and every role on it waited out a whole poll_seconds for work the feed had already announced. The swap now starts the watcher the new project is missing, on the same context serve cancels on its way out, so the stream opens within seconds and still stops with the process. It is idempotent by project: a reload that registered nothing starts nothing, which is what keeps config_watch — a reload per saved edit — from accumulating a watcher per save. Arming the feed itself is still a restart (base_url is restart-only), and a project that LEAVES the config keeps the watcher it has until the process ends.
Without a call at all. config_watch (above, default off) watches the config file and applies a saved change through this same path after a short debounce, reporting each application the way an explicit reload does. The debounce is why one save is one application rather than one per write, and a file that does not parse or does not load leaves the running config in place and is reported rather than applied. A file that reads as a save still in progress — it moved while it was read, or it is empty or its JSON ends early, what an os.WriteFile-style save looks like between its truncate and its write — is given up to two more debounces to finish before it is reported, so a slow save puts no spurious refusal on the log or the dashboard; a file that stays cut off is still refused, and a malformed one that is not cut off is refused at once.
Runtime overrides (not the config)
Per-role caps can be nudged at runtime from the dashboard's Balance panel or the loopback POST /balance endpoint. Those writes go FIRST to this machine's settings on switchyard (PRD #627, a round trip) and then to a balance.json marker in the project's state dir, and never the config file, so they survive a config reload and are cleared independently. A host with no credential, no install id or an unreachable switchyard answers 503 and writes nothing. A lever can only lower a lane to its configured cap; raising a ceiling is a config edit. See internal-balance.
What the page shows and moves (PRD #493). Each project row carries a Role caps section: one row per capped lane — every max_* cap the config carries, plus steward and swarm, whose caps are fixed at 1 in code — with the configured max beside any override in force and the effective cap the spawn gates actually read. GET /health carries the same per project as role_caps ([{role, configured, override, effective, auto}], always a list; override is absent when the balance names no number for that lane) and the override itself as balance ({set, summary, damaged, holds, auto}, plus since, reason and by when one is set). Each lane has a lever: a numeric control whose ceiling is the configured cap, POSTing {"project": …, "roles": {"<role>": n}, "merge": true} to /balance so moving one lane leaves the others as they stand. The panel's one-click shortcuts stay beside the levers — hold new work, favour review & rework, clear — each sending the whole shape (or clearing the marker) rather than merging one lane. The handler refuses any count above the configured cap with a 400, so no page write can raise a role above its ceiling; a lane the config does not cap is disabled on the page and named as a config edit.
Who set it. Every override records an owner: an operator (a lever, a preset, POST /balance, a hand-written marker) or the auto-balancer (auto_balance above). The row and /health badge the two apart (balance.auto, and auto per lane) and carry the reason for an auto move, e.g. favouring review & rework: 6 PR(s) open at the cap of 6 with 4 unreviewed and 1 in rework — holding new builds until they drain. Auto-balance clears only an override it set itself; an operator's stands until a person clears it — from the page or with conductor balance <project> -clear; removing balance.json does not, since the settings row on switchyard is the tuning of record.