@nutteen/conductor
v0.1.0
Published
Conductor: a graph-engineering orchestrator for issue-tracker workflows
Readme
Conductor
A graph-engineering orchestrator for issue-tracker workflows.
Conductor dispatches tracker tickets to CLI coding agents, the same as Symphony does. The difference is what happens inside one dispatch. Symphony runs one agent session against one 400-line prose prompt and can only observe the tracker's status field afterwards. Conductor runs a declared graph: named nodes with named agents, edges with machine-evaluated predicates, an explicitly write-scoped shared state object, and a cursor that survives a restart.
That buys four things Symphony cannot express:
- Different agents for different steps. The builder and the checker are different
models with different tool scopes — and can be different providers — because a node
binds to a named profile rather than the flow having one global
agent.provider. - Deterministic steps. Validation is a
kind: shellnode overscripts/check.sh, not a prompt asking the agent to run tests and report honestly. - Bounded maker–checker loops. A rejection routes back to the builder with the reasons
in shared state; each loop edge carries its own budget, and an exhausted one escalates to
a human instead of spinning. A run that ends that way — or that exits through an
on_erroredge — reportsescalated, notcompleted, so it is counted as a failure and the ticket is given up on afteragent.max_attemptsof them. - The runtime moves the ticket.
tracker_writes: conductormakes each edge'son_enter.tracker_statusan actual write, so a finished run leaves the ticket where the graph says it should be rather than hoping a prompt did it. - Replayable runs. Every routing decision is an
edge_takenline in the JSONL, with the predicate that fired. You can read what the run decided instead of inferring it.
apps/symphony is frozen and untouched. The two run side by side behind disjoint label
gates until Conductor has proven itself.
Commands
# Validate a graph without touching a ticket. The errors are the schema documentation.
conductor graph validate conductor-runner/workflows/IST.md
# Run one flow in the foreground.
conductor run conductor-runner/workflows/IST.md --format human
# Managed background: one detached process per flow.
conductor --config conductor-runner/flows.yaml start
conductor status
conductor logs IST -f
conductor control-tower
conductor graph IST --issue IST-42 # this ticket's cursor, history and state
conductor stop --allRuntime directory precedence: --runtime-dir > config runtime_dir >
CONDUCTOR_RUNTIME_DIR > ~/.conductor.
Ticket-scope precedence: CLI --assignee/--label > flows.yaml > workflow front matter
default assignee
me.
Log volume
CONDUCTOR_LOG_LEVEL is debug | info | warn | error, defaulting to info.
Everything the control tower reconstructs a run from is emitted at info, so the default
costs no observability. debug adds a poll tick every interval and a per-turn line naming
the binary, model and flags:
CONDUCTOR_LOG_LEVEL=debug conductor run conductor-runner/workflows/IST.md --format humanThe prompt body is never logged, at any level. It is ~7 KB per turn on the IST graph — 165 KB per attempt, more with OKF context enabled — and it is reproducible from the workflow files, so the log records the argument's size instead:
codex … '--model' 'gpt-5.5' '--sandbox' 'danger-full-access' 'exec' '--json' <7000 chars elided>Subprocess stderr is capped at 500 characters before it becomes an error message, because
that message is propagated into node_failed, the run event and the retry log — three
copies of whatever a crashing CLI decided to print.
What stays config, and what needs a rebuild
A primary design goal, not a side effect. Workflow files live outside the npm package and the loader hot-reloads them.
| Change | Config only? | Where |
|---|---|---|
| Add/remove a node, rewire an edge, change the entry point | Yes — hot-reloaded | graph: front matter |
| Change a node's agent, model, tools, max_turns, timeouts | Yes — hot-reloaded | graph.nodes[] / agents: |
| Edit any node's prompt | Yes — hot-reloaded | nodes/<name>.md |
| Add tracker statuses (refinement, spec_review, …) | Yes | tracker.*_states + edge predicates |
| Add a deterministic, non-LLM step (tests, lint, a script) | Yes | a kind: shell node |
| Add a new CLI agent (gemini, aider, cursor-agent …) | Yes, if it's command + argv/stdin + stdout | a provider: command profile |
| Add a whole new kind of work (e.g. a BA story-writing flow) | Yes — a new flow | flows.yaml + a new workflows/<NAME>/ dir |
| A provider with genuinely odd wire behaviour | No — one new file | src/agents/<name>.ts + one registry line |
| A privileged builtin needing tracker/forge access | No — one new file | src/graph/builtins/ |
| Change the predicate grammar or the state tier model | No | src/graph/* |
Hot-reload semantics, stated because they are easy to assume wrong: a reloaded graph applies to newly dispatched issues. A ticket already mid-graph finishes on the config it started with. Restarting the flow picks up the new graph and resumes from the checkpoint.
Layout
conductor-runner/
flows.yaml
TEMPLATE.md # annotated full-schema reference
workflows/
IST.md # front matter + graph: block, short body
IST/
_shared.md # includable partial — files prefixed _ are not nodes
plan.md implement.md review.md land.mdOnly kind: agent nodes have a prompt file; check_plan, checks, handoff and
merge_pr are builtin or shell nodes and have none.
workflows/<FLOW>.md carries the config and the graph in YAML front matter, plus a short
Markdown body that is prepended to every node prompt as the flow-wide contract.
nodes_dir defaults to a sibling directory named for the file stem.
The graph
graph:
version: 1
entry: __start__ # the entry sentinel, or a declared node
nodes_dir: ./IST # default: sibling dir named for the file stem
max_parallel_nodes: 1 # frontier width. Fan-out is correct at 1; width buys wall-clock
max_node_visits: 30 # global fuse per attempt
max_loop_iterations: 3 # default budget for a back edge
tracker_writes: conductor # agent | conductor — the shipped IST flow uses conductor
state: { ... }
nodes: [ ... ]
edges: [ ... ]state: is the write-authority table
Mandatory. Every key declares its type, its writers, and its initial value.
state:
review_verdict: { type: enum, values: [pass, reject, unknown], writers: [review], initial: unknown }
reject_reasons: { type: list, writers: [review], initial: [] }
pr: { type: object, writers: [handoff, merge_pr], initial: null }
branch: { type: string, writers: [__runtime__], initial: null }
revisions: { type: counter, writers: [__runtime__], initial: 0 }Types: string, number, boolean, object, list, enum (needs values), counter.
Three tiers, and the tier boundary is the write boundary.
- Runtime-owned (
writers: [__runtime__]) — agents can never write these.branchis read fromgit rev-parse --abbrev-ref HEADrather than trusted from the agent;revisionsis maintained for prompts ("revision 2") and is deliberately not load-bearing for routing. It counts back edges only — the edges that actually repeat work — so one maker-checker cycle is one revision. It is not "ofmax_loop_iterations": two loops budgeted at 3 each can legitimately take this to 6. - Node-declared — written only by the nodes named in
writers, only via that node's declared result. - Deliberately not in state — the workpad, the diff, file contents. Those live in the tracker and the workspace. Hard cap: serialized state < 8 KB.
This is what gives the reviewer independent authority. review writes review_verdict and
reject_reasons and nothing else, so it has no mechanism to advance the work itself —
it cannot set branch or pr. That is enforced by write-scoping, not by asking a prompt
nicely. (The reviewer's read-only allowed_tools is a second, softer layer; Bash is in
that list because it needs git diff, so the state scoping is the real guarantee.)
How a node writes: a fenced result block
```conductor-result
{"review_verdict": "reject", "reject_reasons": ["no test covers the 404 path"]}
```The last such block in the agent's final message is parsed, validated against the
node's writes: allowlist, each key's writers, the declared type, and the 8 KB cap — and
only then committed. A node that fails, times out, or emits a malformed block commits
nothing. Failure isolation is enforced rather than hoped for.
A file (.conductor/out/<node>.json) was considered and rejected for a decisive reason:
the reviewer node has read-only tools and cannot write files at all. The fenced block also
lands in the transcript, so the control tower can surface it.
A node that declares writes: must emit a block, or it fails. A review node that
never says pass or reject must not be read as a silent pass. A node with no writes:
emits {} to signal it is done.
Tracker observation is the fallback, and the robustness floor. Conductor re-reads
ist issue view and git rev-parse after every node. A graph whose edges reference only
tracker.* depends on zero agent compliance and is exactly as robust as Symphony —
that is the recommended starting point, with result-block state as an upgrade.
nodes: — three kinds
nodes:
- { name: check_plan, kind: builtin, builtin: has_plan, marker: "## Conductor Workpad", writes: [has_plan] }
- { name: implement, agent: builder, max_turns: 12 }
- name: checks # deterministic: no model, no tokens, no budget
kind: shell
run: bash scripts/check.sh api && bash scripts/check.sh architecture
writes: [checks_passed]
result: { from: exit_code, key: checks_passed }
timeout_ms: 900000
- { name: handoff, kind: builtin, builtin: publish_handoff, writes: [pr], on_error: continue }kind: agent (the default) needs nodes/<name>.md. Node config may live in that file's
front matter as well as in the graph block; the graph block wins on conflict, so
topology and the fields you want visible next to the edges stay there.
kind: shell runs through bash -lc in the repository directory. Three result forms:
| result: | Writes |
|---|---|
| { from: exit_code, key: k } | k = true when the command exited 0. Always a boolean — a shell node's exit status is a verdict. |
| { from: stdout, format: text, key: k } | k = trimmed stdout. Non-zero exit fails the node. |
| { from: stdout, format: json } | The last JSON object on stdout, merged as writes and validated exactly like an agent's block. |
Add output_key: k2 to either of the first two forms to also capture the run's output
into k2 — the tail of stdout+stderr, capped by output_max_chars (default 3000) so
a chatty failure cannot approach the 8 KB shared-state cap. The tail rather than the head,
because build tools print the failure last. k2 must be listed in the node's writes:
exactly as key must; capturing output is a state write, not a side channel around the
write-authority table.
Without it a red gate records only checks_passed: false, and the next builder turn pays
a full model turn to rediscover a failure the runtime already had in hand. This is the
"deterministic gather, LLM judge" split applied to the one place it was missing.
Shell nodes hold the issue's dispatch slot but consume no session, tokens or budget, and
still respect the run's AbortController and their own timeout_ms.
kind: builtin is for the steps that need tracker or forge access: publish_handoff,
merge_pr, has_plan and record_validation. Anything that needs neither is a shell
node, and therefore config.
noop is the exception that proves it — the one builtin with no privileges at all,
Airflow's EmptyOperator. It exists because a fan-out region needs a declared join for
its branches to converge on and that join is often a pure structural point. The obvious
alternative, kind: shell, run: "true", spawns a login shell to do nothing and — because
a join usually has no on_error edge — turns an EMFILE under concurrency into a
whole-run error. Having no subprocess is the entire feature.
has_plan is the small one, and the argument for it is the argument against agent nodes
generally. It answers one boolean from one ist issue view call — does any comment on
the issue start with marker? — and writes it to the key named by writes_key
(default has_plan). Both are node options, so the engine never learns what a workpad
is, the same way merge_pr knows only method and delete_branch.
It replaced an agent at that position, which spent a model turn ($0.42 across
IST-135's three dispatches, 10% of the ticket) producing a six-field route_decision of
which exactly one field was read by exactly one edge — and that field, pr_attached,
answered the wrong question. A PR is evidence that work happened, not that planning
did, so a human who opened a PR before labelling the ticket made the graph skip plan
entirely and the builder plan its own work.
Note what is not part of the argument any more: "something has to sit at entry".
That was true when entry had to name a declared node, and it made has_plan look
partly load-bearing for structural reasons. __start__ removed the structural need
(see "The entry sentinel" below), so check_plan now exists purely because the graph
wants its answer — and because a deterministic lookup beats a model turn for a question
with a factual answer. It is also no longer on the free path: a ticket the tracker
status alone can route never reaches it.
A shell node was the alternative and is worse here: it would mean bash-in-YAML with a
Liquid-interpolated, tracker-controlled {{ issue.identifier }}, plus a jq dependency
and hand-rolled error suppression to guarantee exit 0.
Every runtime failure degrades to false — unreachable tracker, malformed JSON,
unknown issue. false routes to whichever edge handles "no plan yet", which is the safe
direction: re-planning a planned ticket costs one reconcile pass, while skipping plan
when no plan exists is the defect this replaced. builtinFailed would strand the ticket
on the on_error edge instead of either. Only a missing marker — a misconfiguration
the loader cannot catch, since it cannot check option semantics — fails the node.
Known trade-off: a ticket reopened after its PR merged reads as planned and resumes
at implement against a plan describing finished work. It needs a human reversing a
completed ticket, and such a ticket still walks implement → checks → review, where the
reviewer can reject it into the maker-checker loop. If it becomes real, the fix is
additive — a second builtin answering "is the branch's PR merged", and an edge
when: "state.has_plan && !state.pr_merged" — rather than growing has_plan back into
the classification grab-bag it replaced.
record_validation is the other small one, and it exists because of a gap the first live
run made obvious. IST's proof gate needs a PASSED validation run before a ticket may enter
a proof_gate_enabled status — and Conductor already knew the answer deterministically,
because checks is a shell node whose exit code cannot be argued with, and then threw it
away. Every domain concept is a node option (validation_type, passed_key,
summary_key, command), so the engine learns nothing about IST.
The two rejected alternatives both hide a tracker side effect where the workflow file
cannot see it: extending shell-node result: teaches the one tracker-free node executor
the ist CLI, and folding it into publish_handoff runs it only on a pass — so a red run
records nothing at exactly the moment a human is being asked to look.
Same failure rule as has_plan: every runtime failure degrades to
validation_recorded: false, which an edge can guard on. Only a misconfigured
passed_key fails the node, because a silent false there is indistinguishable from a
red gate.
on_error: continue makes a node failure route along the normal edges — the declared
form of Symphony's swallowed catch around handoff publishing. It is also the reason the
one edge that asks for a proof-gated status has to carry a when: a failed handoff
still takes the normal edge, so an unguarded on_enter: { tracker_status: review } would
ask for a status the gate must refuse, and the ticket would sit in in_progress with
nothing for a human to see. The shipped IST flow guards it on
state.validation_recorded && state.pr.proof_recorded and falls back to rework, which
is unrefusable.
edges: — first match wins, in declaration order
edges:
- { from: __start__, to: land, when: "tracker.state == 'merging'" }
- { from: __start__, to: __end__, when: "tracker.state in ['review','backlog']" }
- { from: __start__, to: check_plan } # default: MUST be last
- { from: check_plan, to: implement, when: "state.has_plan" }
- { from: check_plan, to: plan }
- { from: checks, to: review, when: "state.checks_passed" }
- { from: checks, to: implement, when: "!state.checks_passed", max_traversals: 3 }
- { from: checks, to: __end__, on_enter: { tracker_status: rework } }
- { from: implement, to: __end__, on_error: true, on_enter: { tracker_status: rework } }on_error: true edges are a separate list, considered only when the node failed. That
is why a merge_pr graph can declare its on_error edge after its default without the
error edge shadowing it.
__end__ deliberately borrows LangGraph's END naming — see "Why hand-rolled" below.
The entry sentinel
entry: __start____start__ is the other half of that borrowing, and it exists for a measurable reason.
Edges are declared from: <node>, so something has to sit at entry. When that
something was a real node, its body ran before its edges were evaluated — so a
merging ticket paid for an entry node's work and then took the very first edge, which
never looked at the answer.
With the sentinel the entry resolves by routing alone: no node body, no visit count, no
node_started, and no charge against max_node_visits. It still emits edge_taken, so
every routing decision stays greppable in the JSONL. The rules are narrow on purpose:
- edges may be declared
from: __start__ - nothing may target it (
to: __start__is a load error), which is what keeps it outside every cycle - no node may be named it
node.*andupstream.*are load errors on an edge leaving it — nothing has run, so such a predicate is null-safelyfalseforever rather than an error you would notice.state,tracker,issue,configandgraphare all fully available.
The practical effect on the reference graph: the two status-first edges are now free, and
check_plan is reached only when the tracker status did not already decide.
Predicates
A tiny total, side-effect-free evaluator (~350 LOC with the parser). ||, &&, !,
== != > >= < <=, in, matches, parenthesisation, string/number/boolean/null/array
literals, and dotted paths resolved against seven roots:
state, tracker, issue, node, upstream, config, graph.
Not eval, not a JS sandbox, and deliberately not Liquid despite Liquid already being
a dependency: Liquid renders to a string (the truthiness of "" vs "false" is a
footgun) and reports errors at render time, once per tick inside a live run, rather than at
load.
Two rules worth stating outright:
- An unknown path is a load-time error. Every legal prefix is derivable from
graph.stateplus a fixed schema, so a typo'd edge condition cannot ship. - Null-safety:
state.x.ywherexisnullevaluatesfalserather than throwing, because a node that never ran must route to its default edge, not crash the attempt. - Truthiness is explicit, not inherited from JavaScript:
null,undefined,false,0,""and[]are false. An empty array being false is what makesstate.reject_reasonsread naturally as "were there any".
LLM judgment is a node; edges stay deterministic. An edge predicate that called a model
would be an unobservable, unbillable, non-reproducible decision in the one place that must
be debuggable at 3am from a log. Where routing genuinely needs judgment, that judgment is a
node writing a state key and the edge is a plain predicate over it: review decides
pass/reject and review_verdict routes. The decision lands in the log with a cost attached,
and no new runtime concept is needed.
The corollary bites harder than it looks: a node that is not exercising judgment should
not be a model at all. IST's entry node was an agent for three months because it sat
where a decision was made, not because deciding needed a model — see has_plan above.
Maker–checker loops
The shape the graph is built around. The reference IST graph contains two:
- Judgment checker —
implement → review → implement. A different model, read-only tools, write-scoped to a verdict.reject_reasonsis shared state andimplement.mdrenders{{ state.reject_reasons }}, so the maker sees exactly what was flagged. - Deterministic checker —
implement → checks → implement. The checker isscripts/check.sh: no model, no tokens, and it cannot be argued out of a failure.
Bounding is the part that matters:
- Cycles are found statically at load (Tarjan SCC), and back edges — the edges that
actually repeat work, found by a DFS from
entry— are the ones that carry a budget. Forward legs of the same cycle stay unbounded on purpose: bounding both would makechecks → implement's third rejection also exhaustimplement → checks, killing the run one round before the loop it was meant to bound. - Each loop edge has its own
max_traversals, counted per edge. Two loops never share a budget. - Load-time validation rejects a cycle with no exit edge, and a cycle with no bounded back edge. You cannot ship a workflow that spins forever.
- When a loop edge's budget is spent the runtime skips it and routing falls through to
the next match — so an unresolvable disagreement escalates to a human instead of burning
budget. The attempt then ends in the
escalatedoutcome, naming the edges that gave up, and the ticket is re-dispatched at mostagent.max_attemptstimes before Conductor gives up on it durably. Anon_erroredge into__end__ends the same way: the walk reached the exit because a node failed and the graph had nowhere else to send it, which is not the same fact as finishing the job. (Only edges into__end__count — anon_erroredge back into the graph is a declared recovery path, and taking one and then finishing still reportscompleted.) Before that outcome existed, a spent budget was byte-identical to a cleancompleted; the checkpoint was deleted at__end__, wipingstate.traversals, and the next dispatch replayed the identical doomed loop with a fresh budget, forever. The predicate is evaluated before the budget for exactly this reason: a skip only counts as an escalation when the spent budget actually changed a routing decision, not when the edge would not have fired anyway. graph.max_node_visitsis the global fuse behind all of it.
Load-time validation rejects
An unknown to target; a node unreachable from entry; a cycle with no exit or no bounded
back edge; a when referencing an undeclared state key or unknown root; a writes: entry
the node is not a declared writer of; a write to a runtime-owned key; duplicate node names;
a node with no outgoing or no default edge; a default edge that is not last; a missing
nodes/<name>.md; an unknown agent: profile; an unknown builtin:; an invalid regex in
a matches clause; a result.key not listed in the node's writes:; a non-string
on_enter.tracker_status (which would otherwise make the whole on_enter silently
vanish); and the __start__ sentinel misused — declared as a node, named by a to:, or
read from via node.* / upstream.* on an edge leaving it.
conductor graph validate <file> prints them all at once.
agents: — named profiles
agents:
builder:
provider: codex
command: "codex -c shell_environment_policy.inherit=all -c model_reasoning_effort=xhigh"
model: gpt-5.5
sandbox: danger-full-access
ask_for_approval: never
skip_git_repo_check: true
turn_timeout_ms: 3600000
stall_timeout_ms: 300000
reports_cost: false # current codex builds report no cost; say so explicitly
reviewer:
provider: claude
command: claude
model: claude-opus-5
max_turns: 200 # --max-turns INSIDE one CLI call, not graph turns
allowed_tools: [Read, Grep, Glob, Bash]
fast: # "fast" = the shape of the work, not the model tier
provider: claude
command: claude
model: claude-sonnet-5
max_turns: 40
stall_timeout_ms: 180000 # the stall clock measures silence, not slownessEach profile is a raw map; the AgentClient for that provider narrows its own block.
ServiceConfig knows nothing about Claude or Codex, which is what makes a new provider a
new file rather than an edit to types.ts. createAgentClient throws on an unknown
provider rather than defaulting — the name is a blind cast from YAML, so a typo that
slipped past validation must not silently run claude on a codex machine.
Two behaviours worth knowing, both fixed on the way over from Symphony:
- Claude models are settable. Symphony's
buildClaudeArgsnever passed--model, so the control tower reported the literal stringclaudefor every run. Per-node model selection is the point of the wholeagents:block. cost_usdisnumber | null, never a silent0. Symphony collapsed "provider did not report cost" into "cost was zero", which made a configuredper_run_usdbudget inert on a codex flow. A budget set against a profile whosereports_costis false now earns a warning.
provider: command — a new CLI agent is config
Most CLI coding agents share one shape: a shell command, a prompt on argv or stdin, JSON or JSONL on stdout, optionally a resume flag.
agents:
gemini:
provider: command
command: gemini
prompt_via: argv # argv | stdin
args: ["--output-format", "json", "-p", "{{ prompt }}"]
resume_args: ["--session", "{{ session_id }}"]
parse:
format: json # json | jsonl | text
session_id: sessionId # dotted paths into the parsed payload
tokens_input: usage.promptTokens
tokens_output: usage.candidatesTokens
cost_usd: null # explicit null ⇒ reports_cost: false, so budgets warn
text: response
error_when: errorThe parse selectors resolve through the same dotted-path accessor the edge predicates
use — one implementation, no JSONPath dependency.
claude and codex stay hand-written on purpose. Their quirks are not expressible
declaratively without making this schema baroque: codex's -p means profile while the
prompt is positional, its usage arrives via recursive structural sniffing across nested
JSONL events with additive-vs-overwrite semantics, and claude needs an out-of-band mtime
heartbeat on ~/.claude/projects because it buffers stdout until exit.
Adding a genuinely odd provider is one file exporting a factory plus one line in the
src/agents/index.ts registry. registerAgentProvider(name, factory) does it at runtime;
tests/agents/index.test.ts proves a third provider needs no change to the executor, the
loader, or ServiceConfig.
The determinism boundary
The LLM writes code and makes judgments; anything with a side effect outside the workspace is deterministic code.
| Operation | Who | Mechanism |
|---|---|---|
| git push | Runtime | publish_handoff builtin |
| PR create, or reuse if one exists | Runtime | an existing PR is never rewritten — a human may have edited it |
| PR labels | Runtime | best-effort, returns { failed } |
| Proof artifact + ticket comment | Runtime | ist issue artifact / ist issue comment |
| Branch name | Runtime | git rev-parse --abbrev-ref HEAD — read, never trusted |
| Protected-branch refusal | Runtime | checked before the forge is contacted at all |
| Validation / tests / lint / build | Runtime | kind: shell node |
| Every routing decision | Runtime | the predicate evaluator |
| "Does this ticket already have a plan?" | Runtime | has_plan builtin — one tracker call, no model turn |
| Recording the validation verdict on the ticket | Runtime | record_validation builtin — the shell gate's own exit code |
| What gets committed to shared state | Runtime | writes: allowlist + schema + 8 KB cap, transactional |
| Tracker status transitions | Runtime (under tracker_writes: conductor) | on_enter.tracker_status |
| PR merge | Runtime | merge_pr, pinned to a verified head commit |
| Writing code | LLM | implement |
| Plan / workpad | LLM | plan |
| Pass/reject verdict | LLM | review — judgment, write-scoped to a verdict + reasons |
Symphony's IST.md Step 4 had the agent do the merge in prose: find the PR with
gh pr list --head, inspect state,mergeable,reviewDecision,statusCheckRollup, re-check
it, then gh pr merge --squash --delete-branch --match-head-commit "$HEAD_SHA". Ten steps
of high-consequence, near-zero-judgment work — and --match-head-commit is exactly the
kind of safety invariant you do not want an LLM to remember to include. Here the gating
conditions are edge predicates over ForgeClient.getPullRequestStatus, visible in the
workflow file and replayable from the log, and the head-commit pin is a runtime invariant
asserted on the recorded argv in tests/forge/github.test.ts.
publish_handoff is one builtin, not eight. The steps are not independently useful or
retryable — you cannot re-run addLabels without a PR — and each bail-out carries a
hand-written ticket comment that is the operator diagnostic.
Checkpoints
<runtime_dir>/graph/<FLOW>/<identifier>.json, tmp+rename after every node, holding
schema_version, cursor, tracker_state_at_write, state, node_history,
traversals, node_visits.
Resuming the cursor and state is high value: a crash during review otherwise restarts
at attempt 1 and re-burns plan and implement, the most expensive nodes.
The session_id is deliberately NOT resumed across a process restart.
claude --resume <sid> and codex exec resume <sid> read the agent's own store, which may
have rotated; a failed resume surfaces as a confusing "malformed output". A fresh session at
the resumed node with the state object as context is strictly better, because the state
carries the plan and the verdict forward.
Three guards, and the tracker always wins. The checkpoint is discarded and the graph
re-enters at entry if schema_version is unknown, if tracker_state_at_write differs
from the freshly-fetched state, or if the workspace directory is gone. So: checkpoint says
implement but the tracker says merging ⇒ tracker wins, re-enter at __start__, let it
route to land.
A separate file rather than the JSONL because the JSONL is append-only and the control tower replays it; the JSON is the fast resume path and the JSONL stays the audit log.
Workspace markers
Two dotfiles at the root of every per-issue workspace, both Conductor's:
| Marker | Written | Means |
|---|---|---|
| .conductor-owner | before after_create | which flow created this directory, so another flow's startup sweep will not rm -rf it — a guard that has to hold over a provision still in progress |
| .conductor-provisioned | after after_create exits 0 | provisioning finished |
"The directory exists" was never evidence that anything was cloned into it. A directory
without the sentinel is provisioned again on the next dispatch, and a failed
after_create takes the directory with it — otherwise the failure is permanent, because
nothing would ever re-run the hook there and every later dispatch hands an agent a broken
tree. (A pre-sentinel workspace containing a .git is treated as provisioned, so upgrading
does not delete a checkout that may hold uncommitted work.)
Graph position vs. tracker status
- The graph owns its cursor. The tracker has no
reviewingstatus, and adding one would couple internals to a shared human-facing board. - The tracker is the source of truth for eligibility and termination.
- The bridge is one declared, one-directional projection:
edges[].on_enter.tracker_status. tracker_writes: conductoris what the IST flow ships with. The runtime performs every declared transition itself. Underagent— still available, and the default for a flow that omits the key —on_enter.tracker_statusis an assertion instead: Conductor logs a warning when the tracker is not in the declared status after the node. That is a free diagnostic and an incremental path for a new flow, but it means nothing moves the ticket, which is exactly the gap the first full live run exposed.
What makes conductor safe on a proof-gated project. IST allows at most one
proof_gate_enabled status per project, and IST's is review — so exactly one edge in the
graph can ever be refused, and in_progress, rework, merging and done cannot. Two
runtime facts guard that one edge: record_validation posts the deterministic gate's
verdict so the PASSED validation run the gate requires actually exists, and
publish_handoff reports pr.proof_recorded — the artifact POST's outcome, not the
intent to make it. Both are observed, neither is self-reported.
A refusal keeps the run alive. It cannot fail the node: the write happens on the
edge, after the node completed, after its writes committed, and after selectEdge
already chose the destination. It deliberately does not fail the run either — that would
mean up to max_attempts full re-walks of the graph to reach the same refusal, and a
refusal is expected: it means a human must supply something. Instead the runtime
differentiates the log (a gate refusal is a warn naming the missing requirements; a
broken tracker is an error), emits a tracker_write_refused event, and posts one ticket
comment naming what is missing — on the ticket, because that is where the human who must
attach a screenshot is working.
requires_visual_proof remains an honour-system dependency on the agent running
ist issue upload-proof. The runtime has no fact for it, so it cannot be guarded on an
edge without trusting a self-reported key; the refusal comment names
missing_visual_proof when it bites.
Statuses are checked against the flow's own vocabulary — the union of active_states,
terminal_states, blocker_guard_states, rework_states and handoff.states — at
startup and on every hot reload. As warnings, not load errors: the check needs
buildConfig's defaults applied, and a typo'd status must not brick the daemon.
Concurrency, budget, failure isolation
The graph is a layer inside the issue slot; it does not change the outer accounting.
Across flows — independent detached processes.
Across issues —
agent.max_concurrent_agentscounts issues. One graph = one slot regardless of node count. The scarce resource is the per-issue workspace (a full clone), not the process. Node-granular counting would allow 10 issues × 3 nodes = 30 concurrent agent processes on a laptop.Within an issue —
max_parallel_nodes, the width of the frontier's admission semaphore. Fan-out semantics are correct and deterministic at 1; width only changes wall-clock.Branch-level fan-out is for read-mostly work. The frontier is a graph-level abstraction over a workspace that is still one flat clone: copy-on-branch protects
state.valuesand nothing else, so every branch shares the samerepoDir, git HEAD,node_modulesand host Postgres. The loader derives each node's repo-write capability from its agent profile's tool grant and rejects a region with more than one write-capable branch — grounded rather than asserted, because it reads what the CLI is actually invoked with. But it is a capability check, not a sandbox:Bashon an agent node and anykind: shellnode'srun:stay open by design, each carrying a warning rather than an error, because closing them would ban the two headline use cases (parallel read-only reviewers, and independent validation suites). A region's join is gated on its branches' outcomes byjoin_when, which defaults toall_successexactly as Airflow'sTriggerRuledoes — so a failed branch fails the region rather than letting the join aggregate the survivors. Anything that writes code fans out at the ticket level instead, where the orchestrator already gives every child its own clone, branch and PR. Per-branchgit worktreeisolation is the follow-on that would lift this.Across child tickets — the second fan-out axis, and the one that is safe for work that writes code. A subtask is a real issue with its own key, so each child is an ordinary dispatch with its own clone, walk, branch and PR;
max_concurrent_agentsbounds how many run at once, and the blocker guard holds the parent until they land. The graph does not drive this — the poll loop already does — so the only thing the engine adds is the fields to route on (issue.parent,issue.subtasks,issue.subtask_progress) and a dispatch-time detail fetch to populate them. Seeconductor-runner/TEMPLATE.md, "Ticket-level fan-out".Within a node — a turn loop bounded by
node.max_turns.
Budgets: cost_budget.per_node_usd, per_run_usd (accumulated across every node of an
attempt), and per_process_usd (halts all dispatch). Raise Symphony's numbers: an
implement ⇄ checks ⇄ review cycle at max_loop_iterations: 3 is up to 4× Symphony's
per-attempt spend, × max_attempts: 3.
cost_budget.enabled: false switches all three off in one word while leaving the numbers
in the file. It defaults to true, because the failure mode of the other default is an
unbounded overnight spend caused by a missing key. Enforcement goes through
activeCostBudget(config), never config.cost_budget directly — one accessor so a cap
added later cannot quietly ignore the flag.
Stall detection is per node, and never before the first one. RunningEntry.current_nodes
maps each executing node to its own stall timeout and the effective timeout is the max —
max, not min, so a legitimately long implement is not killed because a sibling router has a
one-minute timeout. Shell and builtin nodes heartbeat too: every byte a subprocess prints
resets the clock, so node.timeout_ms bounds the work rather than the reaper doing it by
accident.
A run that has not emitted node_started is judged against 2 × hooks.timeout_ms instead
(never below the default). Everything before the first node — createWorkspace,
after_create, before_run — emits no events, so the reaper was policing that window with
DEFAULT_STALL_TIMEOUT_MS (300000) whatever the flow configured; a hooks.timeout_ms above
that had its own hook killed at five minutes, with cleanup: false, while the hook's
subprocess kept running — which is how a half-provisioned workspace gets manufactured. Twice
the hook budget is the smallest number that cannot fire during a legitimate provision, and
it still reaps the one unbounded thing in that window: the OKF context fetch.
Retry stays attempt-granular, but giving up is per ticket. A failed node routes through
its on_error edge; if none matches, the attempt returns error and the orchestrator's
retry machinery takes over — with a checkpoint, so attempt N+1 resumes at the failed node.
Retrying inside the graph and in the orchestrator would multiply max_attempts × node
retries.
attempt cannot bound re-dispatch, though: it is a function argument and the poll tick
always dispatches with attempt = 1, so it only ever bounded retries inside one chain.
OrchestratorRuntimeState.failure_counts counts failed dispatches per issue, across
chains, and agent.max_attempts is checked against that.
Giving up is durable when tracker.failure_status is set. state.completed is a Set
in one process's memory: a ticket Conductor had given up on ("max attempts (3) reached") was
dispatched again five minutes later by the next daemon. On exhaustion Conductor writes the
configured status — outside active_states — and comments on the ticket with the failure
and the log path. The tracker is the only store that survives a restart.
AbortController is issue-granular: one per run, passed to every node, killing the active
subprocess and breaking the executor between nodes. The executor awaits in-flight node
promises before returning, and shutdown() awaits every run's promise — without that,
process.exit(0) in cli.ts can kill a subprocess mid-push.
Running alongside Symphony
Both daemons can run on the same project. Two things must be disjoint:
- The label gate. Symphony dispatches
[symphony]; Conductor dispatches[conductor]. This handles dispatch. workspace.root. Symphony's startup cleanup callsfetchIssuesByStates(terminal_states)without assignee scoping — an explicit design choice there — andrm -rfs the matching workspaces. Conductor must use a different root than~/symphony-workspaces/IST, or a Symphony restart could delete Conductor's working trees. This handles the filesystem.
The runtime directories also differ by default (~/.conductor vs ~/.symphony), so
conductor status and symphony status never report each other's flows.
Why the executor is hand-rolled
Conductor's node bodies are spawn() of a CLI whose stdout is fully buffered until
exit. Against that, most of what @langchain/langgraph sells is worth zero: streaming,
MessagesAnnotation, tool binding and createReactAgent all assume an LLM API client, and
there is none — no BaseMessage, no model call, no tool schema. interrupt() looks like
the perfect fit for the review state and is not: that interrupt lasts hours to days and
spans process restarts, and the tracker status field is already both the interrupt store
and the resume token. RetryPolicy is actively harmful, because retry lives one layer out
with exponential backoff, stall detection and release-on-reassign — a second layer would
multiply max_attempts × node retries.
Measured cost: @langchain/langgraph closes over 19 transitive packages including
langsmith, a telemetry surface that phones home unless explicitly disabled, on a machine
whose flows run codex --sandbox danger-full-access. Conductor's own closure is 2 direct
/ 4 total (js-yaml→argparse, liquidjs→commander).
The decision stays reversible by design. GraphSpec is a plain serializable data
structure — no functions, no classes, no closures — so compileToLangGraph(spec): StateGraph
can be written later without touching the schema, the node files, or any node body.
__end__ deliberately borrows LangGraph's END naming so a port stays mechanical.
(apps/agent-orchestrator does use LangGraph — in Python, server-hosted, driving an
interactive AG-UI chat agent. Different deployment shape, different node bodies.)
Development
bash scripts/check.sh conductor # lint + typecheck + build + tests
npm run test --workspace apps/conductor -- tests/graph/executor.test.tsTests use the three isolation styles the monorepo already uses, and most of the new surface is style 1 — which is the point of designing the graph as data.
- Pure, no mocks:
graph/routing,graph/loader,graph/state,config,tracker,orchestrator,control-tower,cli. - Injected seams (preferred):
graph/executor.test.tsis the highest-leverage file — the executor takes an injectedNodeRunner, so the whole scheduler is exercised with a literal fake and zero subprocess mocking. Same foragents/*(argv and parse are pure functions),forge/*(recordingCommandRunner/ injectedHttpTransport),graph/shell-node,graph/builtins,graph/node-runner(injectedspawnTurn). vi.mock, kept at the edges only:agents/process.test.ts, where the subprocess boundary itself has to be faked to be tested at all.
Cross-layer: tests/ist-workflow.test.ts loads the live conductor-runner/workflows/IST.md,
asserts the graph shape, and renders every node prompt against a sample issue — in both
the populated and the empty state, so the {% if %} branches are both exercised.
tests/example-template.test.ts does the same for examples/conductor-template/.
Preserve the house convention: non-obvious assertions carry a comment explaining why the alternative is wrong.
