Configuration reference¶
All settings load from a .env file (which one) or the process environment.
../.env.example is the basic template: REPOS and the few other settings a first run needs, for
one repository or several (basic setup). ../.env.example.advanced carries
optional operational overrides and illustrative examples for opt-in settings, then the orchestrator-developer settings
in a section of their own; ../tests/repository/test_env_templates.py
holds that split and parses every REPOS value the basic template writes. This page and the two beside it are the
source of truth — every setting and every default lives on one of them, and both .env.example* files keep their
inline comments terse and link back for the full rationale.
The orchestrator is deliberately stateless: every setting here selects backends and budgets at startup, or names files/paths outside the repo. Per-issue state lives in the issue's pinned JSON comment on GitHub.
Two companion pages carry what is read on its own rather than scanned for a value:
configuration/observability.md— the sink paths and retention windows, the analytics database URL, skill-trigger tracking, the dashboard read mode, and the dashboard quickstart.configuration/operations.md— package installation, upgrades and rollback, continuous integration, documentation publishing, run modes, the systemd user service, and applying.envchanges.configuration/snapshot-capability-check.md— the disposable-repository check that proves a production token and its rulesets can create, fetch, verify, and delete the late split's snapshot refs, and what each failure means.
Each of their sections keeps a one-paragraph pointer at its place below, so a link written against this reference still lands on the answer.
Where .env is read¶
One .env file is read at startup, chosen by where the running package sits, and a value already in the process
environment wins over the file:
- Installed package (every layout other than a source checkout) — reads
~/.config/chipping-orchestrator/.env, and never a.envbeside the package in its environment'ssite-packagesor in the launch directory. - Developer source checkout —
.envat the checkout's root, and no other. A checkout without one reads none. The package counts as running from a source checkout when its root — the directory holding theorchestrator/package, which an editable install leaves inside the checkout — is the top of a git checkout, an ordinary clone or a linked worktree, by the same check every target passes, and carries apyproject.tomlnamingchipping-orchestrator. Neither a repository enclosing that root nor the launch directory counts.
Source-checkout setup and launch commands are in the developer guide.
The file's directory is only where settings come from: it is never a target repository, and the worktree root is derived from the targets rather than from it. Both locations refuse the token keys (GitHub Personal Access Token).
Basic setup¶
Install with pipx install chipping-orchestrator, then follow the
package setup instructions to download .env.example and
.env.example.advanced from the installed version's release tag. On first setup, copy the basic template to
~/.config/chipping-orchestrator/.env and set an absolute LOG_DIR outside the pipx environment. Keep an existing
file when upgrading or rolling back. The orchestrator's own repository does not need to be cloned;
source setup is for developers.
Then edit the copy. REPOS names what to manage, one owner/name|target_root|base_branch entry per repository
(syntax): the repository on GitHub, the absolute path to the top of an existing local clone of it, and
the branch its pull requests target. One repository is one entry:
REPOS=acme/api|/home/alice/src/acme-api|main
Several repositories are several entries, separated by ; because the .env loader reads each value from one line:
REPOS=acme/api|/home/alice/src/acme-api|main;acme/web|/home/alice/src/acme-web|master|upstream|2
The second entry also sets both optional fields: upstream is the git remote in that clone that points at acme/web,
and 2 lets two acme/web issues run at once. Set ALLOWED_ISSUE_AUTHORS (required) and HITL_HANDLE
beside it, and give every entry a token — a file at ~/.config/<owner>/<name>/token each, or one GITHUB_TOKEN for
all of them (resolution order). Worktrees go under wt-orchestrator beside the first
entry's clone unless WORKTREES_DIR says otherwise (workspace).
Nothing else is needed. ../.env.example.advanced holds optional operational settings,
each documented below, and closes with the orchestrator-developer settings REPO, TARGET_REPO_ROOT, BASE_BRANCH,
and REMOTE_NAME. Those are for working on the orchestrator itself from its source checkout, against its own
repository by default (developer fallback), and are ignored whenever REPOS
is set.
Package installation, upgrades, and rollback¶
Install the published PyPI package with pipx install chipping-orchestrator. For unconstrained, unpinned installs,
upgrade with pipx upgrade chipping-orchestrator after stopping the poller and reviewing release notes. Confirm the
installed version matches the reviewed release before restarting. Constrained installs upgrade by repeating the
pinned procedure with the new release's
constraints. The operator instructions cover
version-matched template downloads, checks, backups, upgrades, rollback, optional runtime constraints, and migration
from v0.13.0's dedicated venv. Installed packages use ~/.config/chipping-orchestrator/.env and the explicit REPOS
targets above. Review release notes and compare both versions' templates before changing settings: local overrides,
changed defaults, and validation rules can affect both the upgrade and a later rollback. Package changes are initiated
by the operator.
The v0.13.0 upgrade notes cover the required author allowlist, installed targets, Poetry migration, worktree paths, and the report and verification contracts when upgrading from v0.12.0.
Required¶
GITHUB_TOKEN— default (required, env-only — not read from.env). fine-grained personal access token. A token written into.envis ignored with a warning at startup.REPOS— default (unset), required for an installed package. the repositories to manage, oneowner/name|target_root|base_branchentry each (syntax), everytarget_roota local git checkout. Unset or blank, an installed package exits with status 1 before any GitHub call; only a source checkout falls back to the developer settings.ORCHESTRATOR_TOKEN_FILE— default~/.config/<owner>/<repo>/tokenfor each configured repository. path to one personal access token file that answers for every repository (used whenGITHUB_TOKENis not in env)HITL_HANDLE— defaultgeserdugarov. comma-separated GitHub logins to @-mention when a human is neededALLOWED_ISSUE_AUTHORS— default (required). comma-separated GitHub logins the orchestrator takes work and comments from; what it gates is under agent roles. While it names nobody — unset, empty, or left empty once blanks, commas, and leading@are stripped, such as,,or@— every launch mode,--onceincluded, exits with status 1 before any GitHub call or agent launch, printingALLOWED_ISSUE_AUTHORS must contain at least one GitHub login. Configure it before starting the orchestrator, for example: ALLOWED_ISSUE_AUTHORS=alice,bob
GitHub Personal Access Token¶
GITHUB_TOKEN is the fine-grained personal access token the orchestrator uses for every GitHub call. Required scopes on
the target repository:
- Contents — read/write (worktree branches and squash commits)
- Issues — read/write (label transitions, pinned-state comments,
HITL_HANDLE@-mentions) - Pull requests — read/write (opening PRs and posting PR comments; the orchestrator never merges PRs)
- Metadata — read-only (issue / PR enumeration)
Create the personal access token at https://github.com/settings/personal-access-tokens.
The token is deliberately NOT loaded from .env. The implementer agent runs in a sibling worktree with sandbox bypass,
so anything readable inside REPO_ROOT (including .env) is recoverable by a prompt-injected agent via a relative-path
read like cat ../chipping-orchestrator/.env. GITHUB_TOKEN (and the aliases GH_TOKEN, GITHUB_PAT,
GH_ENTERPRISE_TOKEN, GITHUB_ENTERPRISE_TOKEN, GIT_TOKEN) found in either .env location
is logged-and-skipped at startup.
Token resolution order, for each configured repository — every REPOS entry, or the developer fallback's REPO when
REPOS is unset:
GITHUB_TOKENexported in the orchestrator's launch environment, for every repository alike.ORCHESTRATOR_TOKEN_FILE, when set: one file for every repository alike.- The file at
~/.config/<owner>/<repo>/token, path derived from that repository's ownowner/name. AREPOS-only setup therefore needs one file per entry and noREPO. Pick a path the agent worktree cannot reach via known relatives, andchmod 600it.
The GitHub client and the git fetch and push each resolve their repository's token when they need it. Startup also resolves every configured repository's token once, so the stderr and command output the orchestrator surfaces mask each of them, file-backed ones included.
Target repository¶
REPOS names the repositories to manage, one entry each, for a single repository as for several, and an installed
package requires it (basic setup). The single-repo quartet REPO / TARGET_REPO_ROOT /
BASE_BRANCH / REMOTE_NAME is the developer fallback, kept in the developer section of
../.env.example.advanced: read only while REPOS is unset and the package runs from a
source checkout, and ignored whenever REPOS is set
(which targets a start selects).
REPOS— default (unset). the managed repositories, entries separated by newlines or;REPO— defaultchippingway/chipping-orchestrator. developer fallback:owner/nameof the single repo to manageTARGET_REPO_ROOT— defaultREPO_ROOT(self-bootstrap). developer fallback: path to the local clone ofREPO— worktrees aregit worktree add-ed from hereBASE_BRANCH— defaultmain. developer fallback: branch PRs targetREMOTE_NAME— defaultorigin. developer fallback: git remote inTARGET_REPO_ROOTthat points atREPOon GitHub
REPOS syntax¶
Each entry is owner/name|target_root|base_branch — the repository on GitHub, the path to the top of its local clone,
and the branch its pull requests target — with two optional trailing fields:
- fourth
|remote_name— defaults toorigin; - fifth
|parallel_limit— defaults toMAX_PARALLEL_ISSUES_PER_REPO. Positional: to overrideparallel_limityou must also write theremote_name(useoriginexplicitly to keep the default).
Write target_root as an absolute path: ~ and $HOME are not expanded, and a relative path is read against the
launch directory. Entries are separated by ; or newlines, and blank entries and entries starting with # are
skipped. The .env loader reads each value from a single line, so a .env separates them with ;; a value exported
in the process environment may use either:
REPOS=acme/api|/srv/clones/acme-api|main;acme/web|/srv/clones/acme-web|master|private|2
Validation happens at import — a malformed entry, empty owner/name, empty base branch, empty remote_name, a
non-integer or non-positive parallel_limit, or a duplicate slug aborts startup with a clear error. Once every entry
has parsed, each target_root has to be a git checkout (target checks).
Each repo can have its own personal access token at ~/.config/<owner>/<repo>/token, or a single GITHUB_TOKEN or
ORCHESTRATOR_TOKEN_FILE covering every listed repo. Worktrees are namespaced WORKTREES_DIR/<owner>__<name>/issue-N
under the one shared root and PR branches are
namespaced orchestrator/<owner>__<name>/issue-N, so two repos with the same issue number cannot collide on disk or on
the branch ref — important when several REPOS entries share a target_root (e.g. one local clone with multiple
remotes), where git would otherwise refuse to check the same orchestrator/issue-N ref out in two worktrees. In-flight
issues whose pinned branch was set before this change keep using the legacy orchestrator/issue-N name; fresh issues
take the namespaced form.
Slugs whose repo name contains .lock, .., or a trailing . (all rejected by git check-ref-format) get an extra
__h<16-hex> suffix on the branch segment so two distinct slugs that would otherwise collapse to the same form (e.g.
owner/foo.lock and owner/foo_lock) stay on distinct branches. The worktree directory keeps the readable
<owner>__<name> form because filesystems tolerate these characters; only the branch ref carries the hash.
Developer fallback and target checks¶
Which targets a start selects depends on where the running package sits (source or installed):
REPOSset — its entries, in either layout. The developer settings are ignored, so a leftoverTARGET_REPO_ROOTthat is not a checkout does not stop the start.REPOSunset or blank, source checkout — the developer fallback: one repository,REPO, cloned atTARGET_REPO_ROOT, which defaults to the orchestrator's own checkout.REPOSunset or blank, installed package — none. The start exits with status 1 on an error that beginsorchestrator: REPOS is unset, and an installed package has no default target.REPO,TARGET_REPO_ROOT, the package's location, and the launch directory are never a target there, and the user-location.envnames targets only through theREPOSit carries.
Every selected target then has to be a directory git itself opens as the top of a working tree: an ordinary clone,
or a linked worktree whose .git file names its git directory. The answer is git's own, because whether git opens a
checkout depends on rules that change from one git version to the next. That covers the gitdir: format, a valid
HEAD, a config git can parse (continued lines included), a repository format and extensions this git supports, and a
core.bare that leaves a working tree. Startup runs this in each target, read-only:
git --git-dir=<target>/.git rev-parse --is-bare-repository --show-toplevel
It uses the launch environment the way the orchestrator runs a plain git command, minus the variables that would point
git at another repository (GIT_DIR, GIT_WORK_TREE, and the like). Git is handed the target's own .git rather than
left to discover one, so a repository around the target is never asked, even one whose core.worktree names the target.
A directory without a .git of its own is only asked whether it is itself a bare repository, which decides how its
refusal reads. Naming the repository that way skips the one check git makes only on discovery — that the checkout
belongs to the user running git, or that safe.directory trusts it — so a target that passes is asked once more, with
git rev-parse --show-toplevel run inside it. With its own .git already proved whole, that discovery stops there, and
it refuses exactly the checkouts git would refuse every later command in. A target passes when git names that directory
as the top of its working tree and opens it from inside. The configuration still imports nothing from the git layer: it
starts git as a program, so git has to be on PATH.
A missing path, a file, a plain directory (one inside a checkout, or one a surrounding repository claims as its work
tree, included), a bare repository (a clone configured bare included), a checkout another user owns that no
safe.directory entry trusts, and anything else git refuses to open are refused. A refusal exits with status 1 and
names every unusable target at once, one line each, with the setting it came from, its path, and its repository; for a
target git refuses, the first line of git's own error follows in parentheses:
orchestrator: REPOS target /srv/clones/acme-web for 'acme/web' does not exist; point it at a local clone or linked worktree of acme/web
Both checks run while the configuration is imported, after REPOS has parsed and before a launch in any mode connects
or touches anything: no GitHub client is connected, no label is bootstrapped, nothing is polled, and no maintenance
pass runs until every selected target has passed.
Agent roles¶
The first token of each role spec selects the backend (codex / claude / agy); remaining tokens are forwarded
as backend-CLI args (model, reasoning effort, etc.). See
workflow/command-specs.md for the spec format, in-flight session lock, and full
examples.
DEV_AGENT— defaultclaude. implementer command specREVIEW_AGENT— defaultcodex. reviewer command specDECOMPOSE_AGENT— defaultclaude. decomposer command spec (validated even whenDECOMPOSE=off); also drives thequestionanddiscussionstagesDECOMPOSE— defaulton. enable thedecomposingstage;offreverts to the legacy "no label →workflow:implementing" pickup, and sends an issue already sitting onworkflow:decomposingthe same way through that stage's own handler (once any half-finished split above it is settled) rather than spawning another decomposer. It also gates the late size gate below: with the switch off a clean committed candidate is published unmeasured — pushed from the checkout as it stands, with no reading taken, since the lease that push carries is what answers a remote somebody else moved. The one road it does not save that reading on is the one with no push behind it: a squash recovery that finds an interrupted rewrite never ran drops the record and hands the branch back unchanged, so it reads the pull request whatever this switch says rather than handingworkflow:documentinga branch whose publication has left. What the switch decides in both directions is what ENTERS a decomposition, never what is already in one — a live late generation is adjudicated, cancelled, cleaned up, and restarted with the switch either way, and a candidate this issue already has a recorded generation for goes on being measured, because turning the switch off must not publish work nobody adjudicated. What it does not gate at all is the two operator-applied conversation labels:questionanddiscussionstill run on the decomposer's spec withDECOMPOSE=off, which is why that spec is validated either wayCODEX_BIN— defaultcodex. executable launched when a role's first token iscodex; override only ifcodexis not on$PATHCLAUDE_BIN— defaultclaude. executable launched when a role's first token isclaude; override only ifclaudeis not on$PATHAGY_BIN— defaultagy. executable launched for the Antigravity backend; override if it is not on$PATHALLOWED_ISSUE_AUTHORS— default (required; see above). comma-separated GitHub logins; only auto-pick-up unlabeled issues from those authors — the one path that runs an outsider's issue anyway is the restart of a cancelled late split, authorized by an operator removingrejected, a write only a repository's own people may make (configuration/operations.md) — and the per-tick sweep labels open PRs from anyone outside the list withworkflow:community_contributionand @-mentionsHITL_HANDLEonce per PR (bot-authored PRs such as Dependabot are excluded viauser.type == "Bot"). It is also a comment trust boundary: comments from authors outside the list stay visible on GitHub but are dropped from the conversation text fed to every agent prompt (implement / review / documentation / decompose / question / discussion / conflict, the awaiting-human resumes, and thein_review/fixingPR-feedback loop), from the base-sync auto-rebase retry-unpark signal, and from theuser_content_hashdrift signal, so an outsider on a public repo cannot inject workflow-driving instructions into an agent, resume an awaiting-human session, retry a parked auto-rebase, reset the review-round cap via/orchestrator add-review-rounds, widen a spent lifetime agent-run allowance via/orchestrator add-agent-runs N, publish an oversized committed candidate unsplit via/orchestrator authorize-oversized <commit>, routein_reviewtoworkflow:fixing(or set its pending-fix bookmark), or shift the hash to re-trigger drift. Login comparison is case-insensitive, and on these prompt / resume / PR-feedback surfaces a Bot/App login is gated like any other author — excluded unless its login is on the list. A separateuser.type == "Bot"structural check, independent of the allowlist, covers theuser_content_hashdrift hash and the community-contribution PR sweep. One prompt keeps something the allowlist would drop: thediscussionstage rebuilds the whole conversation for a round with no session to resume, and retains the orchestrator's own posted comments there — by the ids it recorded when it posted them — so a deployment that lists its humans and not the token's account still hands that agent both halves of the thread. No third-party comment is ever retained. Seestate-machine/delivery-stages.mdfor the full drift-hash filter list andsecurity.mdfor the trust-boundary rationale
Cadence and budgets¶
POLL_INTERVAL— default60. seconds between polling ticksCLOSED_ISSUE_SWEEP_EVERY_N_TICKS— default1. how many ticks apart the closed-issue recovery sweep runs (see GitHub rate limits below). The sweep issues oneGET …/issues?state=closed&labels=<L>per non-terminal workflow label the repository actually carries — the eight recovery labels plus the four cleanup ones,workflow:decomposingandworkflow:umbrellawhere a late adjudication runs, andworkflow:readyandworkflow:blockedwhere an interrupted cancellation can be left — per repo; across many repos at a shortPOLL_INTERVALthat fixed cost dominates request volume and is the main driver of GitHub primary rate-limit (5000 req/hour/PAT) exhaustion.1runs it every tick (unchanged behavior). Raise it (e.g.4–5) on multi-repo deployments; the only cost is that an externally-merged/closed issue may take up toN-1extra ticks to finalize todone. The latency-sensitive open-issue poll always runs every tick. A closeddiscussionis the one issue the sweep keeps costing: with no plan PR published it waits the sameN-1ticks to finalize torejected, and with one still open it deliberately keeps itsdiscussionlabel so the sweep goes on yielding it every pass until the humans decide that pull request — a verdictN>1therefore picks up that many ticks later as well. A closed snapshot owner behaves the same way: it keeps itsworkflow:decomposing/workflow:umbrellalabel, so the cleanup sweep revisits it every pass until its obligation ledger is fully reconciled, andN>1stretches how long a superseded branch or an unreclaimed snapshot ref survives by that many ticks.DEPENDENCY_POLL_EVERY_N_TICKS— default5. how many ticks apart an openworkflow:blocked/workflow:umbrellaissue is dispatched (see GitHub rate limits below). Both handlers are dependency walks that spawn nothing, but each pass reads every child, so a repo holding waiting parents and children spends that read fan-out every tick while nothing downstream has moved. Counted on the same polls asCLOSED_ISSUE_SWEEP_EVERY_N_TICKS— the first poll is due and every Nth after it — and on the ticks between, those issues are dropped before the family / fan-out partition: never submitted, and no worker client is minted for them. The cost is latency of up toNticks: a child whose dependencies aredonewaits that long to be activated, a parent whose children all landed waits that long to complete, and drift on either is detected that much later.workflow:decomposing, unlabeled pickup, fan-out work, and a closed owner's cleanup keep their own cadence, and a due tick still serializes the whole family bucket.1dispatches them every tick. Must be a positive integer; an invalid value aborts at startup.AGENT_TIMEOUT— default1800. wall-clock cap per agent invocation, secondsREVIEW_TIMEOUT— default (=AGENT_TIMEOUT). wall-clock cap per reviewer invocation, secondsSHUTDOWN_GRACE_SECONDS— default30. seconds after SIGTERM/SIGINT before the loop force-terminates in-flight agent subprocesses and hard-exits (128 + signum). Keep below the systemd unit'sTimeoutStopSec(90s) sosystemctl restartsees a clean stop instead of escalating to SIGKILL — without it the drain waits on in-flight workers bounded only byAGENT_TIMEOUT(1800s) or a blocking GitHub retry/backoff, which overruns the stop deadline.TERMINAL_ARTIFACT_CLEANUP_INTERVAL_SECONDS— default86400(a day). how long apart the terminal-artifact maintenance passes run: the bounded reclamation of the worktrees and branches of issues this orchestrator has finished with. With noTERMINAL_ARTIFACT_CLEANUP_WINDOWset, the polling loop fits one in between passes (never inside a tick) once this long has elapsed on its own monotonic clock; a set window replaces this cadence for the polling loop, andchipping-orchestrator --cleanup-terminal-artifactsruns one on demand regardless of either. Must be>= 1: a zero or negative interval would put a host-wide teardown between every pair of polling passes, each one holding scheduler admission closed while it proved the host quiet. Nothing is persisted, so a restart costs at most one extra pass — a repeated pass reads the host again and reports whatever is already gone as done. Raising it only delays how long a finished issue's checkout survives; the pass never blocks workflow progress either way, since a host it cannot prove quiet defers the whole pass. The setting bounds the polling process's own passes; the one-shot mode below runs when asked and defers whenever a polling process is live on the host. What the pass finds, what it deletes and in what order, what each per-candidate result means, and what an interrupted pass needs afterwards (nothing) are inconfiguration/operations.md#reclaiming-a-finished-issues-artifacts; the bounded record each candidate earns on the analytics sink isobservability/event-streams.md#terminal_artifact_cleanup-records.TERMINAL_ARTIFACT_CLEANUP_WINDOW— default unset (disabled). the local wall-clock window the polling process's own terminal-artifact maintenance passes are scheduled in, spelled as 24-hourHH:MM-HH:MM: each endpoint is exactly two-digit hours00–23and minutes00–59, joined by one-with no spaces inside (whitespace around the whole value is ignored). The start is inclusive and the end exclusive; an end earlier than the start crosses midnight (23:00-01:00), and identical endpoints name no window and are rejected. For example:
dotenv
TERMINAL_ARTIFACT_CLEANUP_WINDOW=03:00-05:00
TERMINAL_ARTIFACT_CLEANUP_TIMEZONE=Asia/Novosibirsk
A set window takes precedence over TERMINAL_ARTIFACT_CLEANUP_INTERVAL_SECONDS for automatic scheduling and
requires TERMINAL_ARTIFACT_CLEANUP_TIMEZONE; --cleanup-terminal-artifacts still runs whenever asked. The polling
loop attempts a pass at its first boundary between polling passes inside the window, reads the window again once
the pass has drained its workers and taken the host lock, and spends it as that pass starts, however the pass then
ends. A deferral before that point is retried no sooner than 15 elapsed minutes later; a window missed is not caught
up by day. Each window is named by the local date it opened on, so neither midnight nor a repeated daylight-saving
hour owes a second pass, and the state is in memory only, so a restart inside the window may repeat that night's
pass. The full semantics are in
configuration/operations.md#when-it-runs. Unset or
blank — including a key left behind with its value removed — disables the window and falls back to the interval with
its own default and validation, and the timezone is then not read at all. A malformed window or identical endpoints
abort at startup with an error naming this setting.
- TERMINAL_ARTIFACT_CLEANUP_TIMEZONE — no default; required while TERMINAL_ARTIFACT_CLEANUP_WINDOW is set. the
IANA timezone the window's clock is read in, such as Asia/Novosibirsk or UTC, resolved once at startup through
Python's standard zoneinfo against the host's time zone database. While a window is set, a blank value or a name
that does not resolve aborts at startup with an error naming this setting; with the window unset or blank it is
ignored, whatever it holds.
- MAX_REVIEW_ROUNDS — default 3. review/fix iterations before parking on awaiting_human
- MAX_CONFLICT_ROUNDS — default 3. auto-conflict-resolution rounds before parking on awaiting_human
- MAX_RETRIES_PER_DAY — default 3. fresh implementer spawns per issue per 24h window (0 = unbounded, and an
unbounded budget keeps no counters — it drops the window it finds, so turning it back on opens a fresh one). An
exhausted budget parks the issue as retry_cap, and that park outlives both the window elapsing and a later change
to this setting: the notice asked for a human, and neither the clock nor a wider cap is one. Where a stage routes
the park to a resume a reply on the thread lifts it; on the three roads that spend this budget — the initial
decomposition, the late size adjudication running under the same workflow:decomposing label, and
workflow:implementing — only a trusted /orchestrator continue does, and the issue holds everything it carries
until one arrives: the manifest and the children on the first, the frozen candidate and the pull request it stands
under on the second, the developer's commits and their pull request on the third. Either
renewal buys exactly one
spawn, recorded on the issue as the attempt itself, so moving this setting between the word and the spawn neither
swallows that attempt nor multiplies it
- DEV_SESSION_MAX_RESUMES — default 10. resumes of one dev session before it is retired and respawned fresh from
durable state (issue body + recent comments + the committed branch), so a growing --resume transcript cannot creep
into a Prompt is too long context overflow. 0 = resume forever. The reactive overflow handler still recovers a
session that blows the window in fewer resumes.
- MAX_AGENT_RUNS_PER_ISSUE — default 50. lifetime ceiling on the agent runs one issue may spend: every agent run
charged to that issue, in every role and at every stage — decomposer, developer, reviewer, and the conversation
agents — not only the implementer spawns MAX_RETRIES_PER_DAY meters. What it counts is a process start rather than
a tick, so budget it that way: a reply-driven resume is a run exactly as a fresh spawn is, whichever stage makes it,
and one tick spends two where a developer resume lands on a transcript the backend has lost and the fresh retry
behind it recovers the session. It is the backstop under the per-stage budgets rather than one more of them: each
of those bounds how often a single road may be retried, and an issue that walks enough of those roads in turn spends
more than any one of them ever sees. Unlike the daily retry budget
this one never reopens — a lifetime total is spent once, and no clock returns it. 0 = unlimited. A negative or
non-integer value aborts at import, like the parallelism caps: a ceiling under zero is one no run could ever come
in under. What the setting carries is the ceiling itself — validated at import and published on
orchestrator.config — and the accounting it is judged against is the per-issue ledger on
workflow/engine/run_ledger.py: the allowance in force (this
setting, unless the issue records one of its own), the monotonic agent_runs_used count seeded and floored by the
issue_agent_runs meter, and the reserved / started phases of the launch currently holding a charge, matched
to it by the request fingerprint recorded beside them. That
count goes on running while this setting is 0, so turning the ceiling on reads a real lifetime total rather than
zero. The charge is taken before the spawn — by
workflow/engine/run_circuit.py, immediately around the one
low-level run_agent call every role goes through — so a run that crashed, timed out, was killed mid-flight, or came
back to an issue you had just paused is still spent, its outcome discarded but its compute already gone. That is
also why this count can sit above the issue_agent_runs figure the terminal receipt reports,
since that one records only the runs whose usage parsed. A launch whose charge could not be written invokes no
process at all. Where an issue that has spent it all STOPS is the park on
workflow/engine/run_limit.py: awaiting_human with a stable
agent_run_limit reason, a notice recorded before it is posted and said once per park, and a hold in the
dispatcher that keeps such an issue off every stage handler — behind only an authorized restart and a cancelled
cycle's own cleanup, and stepping aside for work that has ENDED so a terminal arc still finishes: a closed issue,
and a recorded pull request that has merged or been closed, which the issue's own flag cannot show (a settled
discussion plan on workflow:implementing excepted, since merging a design licenses the build rather than
ending it). Unlike the daily budget nothing here reopens on a clock: what lifts that park is one trusted
/orchestrator add-agent-runs N on the thread, bounded per
command by MAX_RUNS_PER_COMMAND (50) in
workflow/engine/run_grant_request.py and answered by
workflow/engine/run_grant.py, which records
agent_run_allowance = used + N on the issue and so takes it off this setting for good. That park is handed the
ledger reading rather than taking one, so it quotes the numbers a refusal was made on — and no stage handler turns
an agent run away against any of it, so nothing hands it one. What each of those four durable steps looks like from
outside is the agent_run_budget record
workflow/engine/run_budget.py writes to both observability sinks
— one per charge taken, spawn paid for, lifetime ended, and ceiling widened, each carrying this setting beside the
allowance actually in force, so a refusal can be read against the number it was made on
(observability/event-streams.md).
- MAX_ADDED_LINES — default 4000. size ceiling a pull request may publish under, counted in the
textual lines it adds: the frozen remote base commit against the exact committed
candidate commit, across every path. It is applied to the first publication and to every dev fix pushed onto a
pull request the remote already carries, and in both cases to that same cumulative pair — so a branch cannot
be grown past the ceiling one small fix at a time.
Binary content contributes nothing (git has no lines to report for it), a
moved file counts where it lands (rename detection is off, so relocating work cannot buy a smaller number), and
there is no exemption for lockfiles, generated code, migrations, snapshots, golden fixtures, i18n catalogs,
notebooks, or vendored trees — an exemption list is a bypass anybody can move work into, and the number has to be
reproducible from the diff a reviewer opens. What decides which paths have lines, and how many, is pinned to the
commit rather than to the checkout — attributes are read from the candidate's own tree, so an uncommitted
.gitattributes cannot make textual work report as binary, and the diff algorithm is named, so the same two
commits cannot count differently on two hosts — and a reading that cannot be pinned (an info/attributes file, a
diff driver in the repository's config) is a typed failure, never a count of zero. The
comparison is strictly greater than: a candidate landing exactly on the configured value publishes the way it
always has, and only one past it is oversized. Positive integer, validated at import like the parallelism caps —
0 or a negative would call every candidate oversized. Global on purpose; a per-repository override waits on
telemetry that shows repository skew. Where it is spent is the one seam every clean committed candidate publishes
through: a candidate at or below it pushes and opens its pull request as always, one past it is held unpublished
and routed to workflow:decomposing for adjudication, and one that could not be measured parks rather than
publishing. It is spent at every seam that publishes a committed candidate, not just the first: the implementing
stage's own push that opens the pull request, and the ten that push onto one the remote already carries -- the
dev-fix publication and the no-feedback bounce behind it, both validating recoveries, the three conflict
publications, the base sync's auto-rebase and its crash recovery, the final docs pass, and the squash on approval.
There the count is what the pull request would COME TO rather than what one push adds, so a branch cannot be grown
past the ceiling a commit at a time. It is also what a proposed CHILD is sized against once a candidate reaches
adjudication: the late decomposer is told the ceiling its own candidate was measured at, every child of a split
declares the lines it will add across all of its paths, and one declared at or above that ceiling is refused, since a
split whose children are each still oversized is not a split. That declaration is kept rather than spent on the
refusal: it is recorded with the verdict, so a tick that crashed before the children existed does not pay for a
second adjudication to recover it, and it is stated on the child issue the slice becomes — over every path that
child commits, tests, documentation and generated files included — so whoever picks the child up reads the size it
was proposed at. A child of a manifest recorded before budgets were asked for states none. So lowering the value
narrows what an adjudication may propose as well as what may publish — what finally decides a child is the
cumulative measurement of its own diff, on the value in force when that child is measured. See
state-machine/delivery-stages.md
and workflow/roles.md
- ORCHESTRATOR_BASE_BRANCH — default main. base branch of the orchestrator's own repo, used by the self-update
path. The self-restart guard fetches it only when the orchestrator runs from its own
source checkout; an installed package never fetches it and never restarts for a merge to it
- SQUASH_ON_APPROVAL — default on. after the reviewer emits VERDICT: APPROVED, squash the dev's commits on the
PR branch into a single subject-only commit and force-push with lease. The subject reuses the dev's first commit
subject when it carries a reusable <prefix>: form (Conventional or repo-local such as event:/career:);
otherwise it is synthesized with a prefix inferred from recent base-branch history. Either one is then normalized
to the references PR_REF_IN_SUBJECT describes — the pull request's kept exactly once, the tracked issue's own
dropped — and that normalization is also what puts a one-commit branch through the same rewrite whenever it
would write that branch's subject differently. off leaves the per-step commit history
intact and rewrites none of the developer's commits, for the reference or anything else (useful when
downstream tooling depends on that history). What the switch decides is whether a new rewrite
is made: one an earlier tick already made is finished either way, because the commits it replaced are off the branch
and the remote either carries the object that replaced them or does not, so an install that flips the switch off
between the rewrite and the push does not abandon reviewer-approved work off the pull request. An issue with no
squash recorded costs such an install nothing at all — no probe, no reading, and no write. Parsed as a boolean:
1 / true / on / yes enable, anything else disables.
- PR_REF_IN_SUBJECT — default on. end the subject of each commit the orchestrator publishes onto a pull request
with a reference to that pull request — exactly one space, (#, the number, and ) — so a rebase-merged main
shows <subject> (#N) and github.com renders the number as a link to the pull request. The commits it covers are
the ones that land on main in the default configuration: the approval squash, a branch carrying a single commit
included, and the documenting pass's docs: commit. With SQUASH_ON_APPROVAL=off the dev's own commits are not
rewritten for it, while the docs: commit still is, since that one is published by the orchestrator. The suffix is
idempotent: a subject already ending in (#N) for the same pull request is left as it is, so a second approval
round, a retried tick, or a recovered commit never doubles it, and a suffix naming somebody else's number is left
where it stands — with the tracked issue's own number the exception both publishers below drop. It is a plain
reference, never a closing keyword such as Fixes #N, so GitHub does not treat the
number as an issue to close. off suffixes nothing, leaves a single-commit branch unrewritten, and does not amend
the docs: commit. Turn it off on a target repo that lands pull requests with GitHub's Squash and merge and its
default commit message: GitHub appends its own (#N) to the squash commit title, so a single-commit pull request
would carry the number twice. The approval squash and the documenting pass both read it. On the squash, the reused
first-commit subject and the synthesized one alike end in the reference to the pull request the reviewer approved —
or to the one the pinned comment records, when the recovery of an unfinished squash rewrites the branch afresh — and
the tracked issue's own reference goes with the same normalization, so a developer subject that copied the issue
number out of recent history, and the <subject> (#issue) (#PR) a developer commit and an earlier publication each
wrote half of, both land as <subject> (#PR). off leaves that message exactly as it was selected — nothing
appended and nothing stripped — while a rewrite the recovery finishes keeps the subject it was committed under. A
branch of one commit is decided by this switch alone: on, a subject the normalization would write differently
— one missing the reference, and one still carrying the tracked issue's beside it — is rewritten, through the same
reset --soft, hardened commit, size gate, and --force-with-lease push a collapse goes through, reported as one
commit replaced and announced by no :package: notice; a subject that already reads as the normalization writes it,
and every one-commit branch with the switch off, is left exactly as the developer committed it. On the docs
pass, the docs: commit is amended in place before every road that publishes
it, keeping its author, tree, and body, and its subject is normalized against the tracked issue as well as the pull
request: the request's own body is what links that issue, so a subject the docs agent left ending in the issue's
number — alone, or beside a reference an earlier publication had already appended — comes out ending in the pull
request's reference and nothing else. A commit whose subject already reads that way is published unamended, and one
whose amendment fails is never published without the reference — the issue parks subject_amend_failed with the
commit still on the branch. Parsed as a boolean:
1 / true / on / yes enable, anything else disables.
- EXPOSE_TRACKED_REPOS — default on. tell working agents about the other repos this orchestrator tracks (slug,
local target_root, base branch) for cross-repo reference. Inert for single-repo hosts — the awareness block is
emitted only when more than one repo is configured, so a default deployment sees zero added prompt tokens. The
disclosed data is operator-configured and non-secret (no tokens, no remote URLs), and write-containment is unchanged.
off forces the disclosure off globally. Parsed as a boolean: 1 / true / on / yes enable, anything else
disables.
GitHub rate limits¶
A single fine-grained PAT gets 5000 REST requests/hour (the GitHub primary rate limit). Each tick spends a roughly
fixed number of GET /repos/… requests per repo, independent of how much real work the repo has:
- the open-issue poll (
list_pollable_issues): oneGET …/issuesrequest per page of open issues, pull requests included — each listed row is told apart from a pull request by what its page carried, so an open issue adds no detail read of its own, - the closed-issue sweep: one
GET …/issues?state=closed&labels=<L>per non-terminal workflow label (10 today — the six PR-carrying stages plusquestionanddiscussion, each of which has a terminal a closed issue may still owe, andworkflow:decomposing/workflow:umbrella, whose closed issues are swept for snapshot cleanup only), and - the community-contribution PR sweep: 1
GET …/pullsrequest.
On top of that floor comes work that grows with what the repo holds rather than with the tick. The part priced here
is the open dependency walks: each open workflow:blocked / workflow:umbrella issue reads every child it records,
so that cost scales with the number of waiting parents and their children, is zero on a repo holding none, and is
spent only on the ticks DEPENDENCY_POLL_EVERY_N_TICKS makes due.
With R repos at POLL_INTERVAL seconds, the floor is roughly R × (10 + sweep) × 3600 / POLL_INTERVAL
requests/hour even when every repo is idle. Past ~5–6 repos at the 60s default this exceeds 5000/hour: the budget is
spent partway into each hour, GitHub starts returning 403: Forbidden with an X-RateLimit-Reset in the future, and
PyGithub's GithubRetry sleeps (uninterruptibly) until the reset — typically a ~15–18 minute stall every hour, on
the hour. The signature in orchestrator.log is repeated github.GithubRetry: … failed with 403: Forbidden followed
by Setting next backoff to <hundreds-to-1000+>s.
Two built-in mitigations reduce the floor without touching POLL_INTERVAL:
- Workflow-label objects are cached per repo client. They are immutable after
ensure_workflow_labels, so the closed sweep no longer re-fetches them every tick — eliminating 10GET …/labels/<name>requests per repo per tick automatically (the eight terminal-arc states, plusdecomposingandumbrella, whose closed issues the sweep also yields for cleanup only). - A pre-namespace label the repository does not have is asked for rarely. The sweep looks up each swept state
under both its
workflow:-namespaced name and its pre-namespace one, so an issue still carrying the old label is not stranded (seestate-machine/labels-and-state.md). A lookup that comes back 404 cannot be cached as a Label object, so it is instead thrown away for 20 sweeps before being asked again — on a repository the bootstrap rename already reached, the seven legacy spellings therefore cost sevenGET …/labels/<name>requests every twentieth sweep and nothing in between. A 403 is never treated this way: rate-limit exhaustion is not an answer about whether the label exists, so it is retried on the next sweep. CLOSED_ISSUE_SWEEP_EVERY_N_TICKSbatches the closed-issue recovery sweep to once every N ticks (see the list above). AtN=4–5the closed-label queries are amortized down by the same factor while the open-issue poll stays every tick.DEPENDENCY_POLL_EVERY_N_TICKS(default5) works on the variable dependency-walk cost above rather than on the floor: it dispatches openworkflow:blocked/workflow:umbrellaissues once every N ticks, on the same polls the closed sweep counts, so the child reads their walks spend are divided by that factor. The price is up toNticks before a child is activated, a parent completes, or drift on either is detected;1walks them every tick.
If you still approach the cap, the remaining levers are operator-side: raise POLL_INTERVAL, split repos across more
than one PAT (one token file per slug under ~/.config/<owner>/<repo>/token), or reduce the number of tracked repos.
Check current consumption with curl -H "Authorization: Bearer $TOKEN" https://api.github.com/rate_limit.
Local verification gate¶
VERIFY_COMMANDS is also what a reviewer's approval has to vouch for: the reviewer is told the configured commands,
and an approval reaches this gate only where the verification it declared — commands it ran on the reviewed head, or
the current evidence it reused — has settled on the pull request, passed, and lists every configured command exactly
as written; anything short of that parks under reviewer_unverified instead (see
the reviewer verification contract). An empty setting
requires no particular command: only that the declared evidence lists at least one command, every one exiting 0.
Commands declared beside the configured ones are allowed, and refuse the approval only where one did not exit 0.
When the reviewer agent emits VERDICT: APPROVED, _handle_validating runs the configured VERIFY_COMMANDS in the
per-issue worktree before posting the approval comment, squashing, seeding watermarks, or relabeling to
workflow:documenting. A clean run advances the issue as usual; any failure parks the issue on workflow:validating
with awaiting_human=True and a typed park_reason, so an operator can fix the breakage and resume. The park lands
only while the subject the reviewer approved still stands behind the gate and behind the park's own notice: a push, an
issue or report edit, or a later report landing while the commands ran makes the failure one about work nobody
reviewed, so the run is recorded without a park and the next tick's fresh reviewer answers the head as it stands. An
empty VERIFY_COMMANDS advances too, but its result is an explicit not_run rather than evidence that anything
passed.
What the approval rests on is the reviewer's settled verification. A squash that publishes another head carries
evidence onto it only where both commits read as the tested tree and the verification context these two settings mint
is unchanged, and the evidence it carries is the gate's own run where that run passed whole on the approved head:
published as orchestrator-executed evidence with its own transcript, naming the approved head as the commit that ran
and the squashed head as an equivalent-tree target. With an empty VERIFY_COMMANDS the gate runs nothing, and the
reviewer's evidence is what is carried; so it is when an interrupted squash is finished on a later tick, which runs no
gate. That carry copies the reviewer's artifact, and until it settles that artifact has to still say what was copied:
editing or deleting it meanwhile -- before a retried publication included -- refuses the carry. A squash that
rewrites nothing publishes nothing from the gate. Changing VERIFY_COMMANDS or VERIFY_TIMEOUT between an
approval and its squash -- while the squash runs, or before a refused relabel behind it is retried --
refuses the carry: the evidence is invalidated and the squash handoff dropped, and the head goes back to a fresh
reviewer (see _handle_validating).
The same commands verify a head an automatic base rebase published, where no current evidence carries onto it -- an
equal full tree under these two settings -- and a reviewer was already handed a report of the rebased head: a pass is
recorded as orchestrator-executed evidence of that head, and its artifact posted and settled, before it is routed to
workflow:validating, and a failure is
recorded on the pinned comment and posted on the pull request once, with the failing command and its output's tail,
before the route. Without that review -- the usual case,
since the rebased head's report refresh has not run yet -- nothing runs: evidence the rebase moved past is
invalidated, and the fresh reviewer runs the verification itself (see
base-rewrite evidence).
An orchestrator that stops after a rebased head's push and before its run is recorded runs the commands again when it
recovers that push on a later tick; a run already recorded is never run again, and one whose configuration,
requirements, review, or base moved in between is set aside for the fresh reviewer instead. A base that advances while
the commands run leaves their result unrecorded, and the head is rebased again before anything routes it.
A run records the commit and full tree it tested, both read before the first command; the exact ordered
VERIFY_COMMANDS; each attempted command's outcome, exit code, and redacted, bounded output; VERIFY_TIMEOUT; and a
context revision digested from those commands and that timeout. The worktree has to read clean before the first
command and after each one, and HEAD and its tree have to read unchanged after each one; a reading that fails refuses
exactly as a change would.
The verify gate is the first gate after the reviewer agent — it catches regressions locally so an obviously-broken
branch never reaches in_review. GitHub CI still runs against the PR; the human merging the PR is the consumer of CI's
verdict, since the orchestrator never merges from in_review itself.
Secret stripping¶
The verify shell shares the agent's environment filter (agents.environment.filter_agent_env, called with
allow_provider_auth=False). Stripped from the verify environment:
- GitHub-token aliases (
GITHUB_TOKEN,GH_TOKEN, …). - Secret-shaped vars: anything matching
*_TOKEN/*_KEY/*_SECRET/*_PASSWORD/*_PAT/*_CREDENTIAL, plus the bare namesTOKEN/KEY/SECRET/PASSWORD/PAT/CREDENTIAL. - Credential-file locators:
*_TOKEN_FILE,*_KEY_FILE,*_SECRET_FILE,*_PASSWORD_FILE,*_CREDENTIAL_FILE,*_CREDENTIALS,*_CREDENTIALS_FILE, plus bareTOKEN_FILE/CREDENTIALS/CREDENTIALS_FILE. Explicitly coversORCHESTRATOR_TOKEN_FILE,GOOGLE_APPLICATION_CREDENTIALS,AWS_SHARED_CREDENTIALS_FILE. - Write-credential locators:
SSH_AUTH_SOCK,SSH_ASKPASS,GIT_ASKPASS,GIT_SSH_COMMAND. - The agent's own provider-auth keys:
ANTHROPIC_API_KEY,ANTHROPIC_AUTH_TOKEN,CLAUDE_CODE_OAUTH_TOKEN,OPENAI_API_KEY(stricter than the agent-subprocess case — a verify command runs operator-configured shell against agent-produced code, and a hostile dependency reading$ANTHROPIC_API_KEYwould gain billable access to the operator's model account).
The shared filter removes VIRTUAL_ENV and its bin directory from PATH in agent and verify environments for every
managed repository, so worktree commands cannot select the orchestrator's virtualenv through either. It preserves
Conda activation markers and their PATH entries for Conda-based target repositories. Their setup and verification
commands must select the intended environment.
Do not embed secret literals in VERIFY_COMMANDS. Verify failures park awaiting_human with the offending command
string published verbatim in the GitHub issue comment -- a rebased head's failure is posted the same way on the pull
request -- and every approving reviewer declares each configured command
exactly as written, with the output it quotes, in a verification artifact the orchestrator publishes on the pull request
— kept in its hidden payload, out of the rendered thread but in the comment's source for anybody who reads it — so an
inline ANTHROPIC_API_KEY=sk-… pytest entry would leak the literal secret on the first failure or the first approval.
If a verify command legitimately needs a secret-shaped var, load it from disk inside a wrapper script and reference the
script from VERIFY_COMMANDS — VERIFY_COMMANDS=./scripts/run-verify.sh where the script reads the value from a file
outside the worktree (~/.config/<provider>/key) and exports it before running tests.
Settings¶
VERIFY_COMMANDS— default (empty — no verification). Ordered shell commands run sequentially in the per-issue worktree onVERDICT: APPROVEDand on a rebased head no evidence carries onto, and the commands every approving reviewer has to declare. Entries are separated by;or newlines; blank lines and#-comment lines are skipped. Each entry runs via the shell so quoting, pipes, and&&work; stdout and stderr are merged into one captured block.VERIFY_TIMEOUT— default600. Per-command wall-clock cap in seconds. A single slow command parks withverify_timeout. Ignored whenVERIFY_COMMANDSis empty.
Failure modes and park_reason tokens¶
The park comment names the failing command, its exit code (or timeout), and a redacted / truncated tail (the last
4096 UTF-8 bytes, dropping any character the cut lands inside) of the captured output; a run refused before any
command ran names the reading that refused it instead. Output is redacted via config.credentials.redact_secrets
before truncation so a secret straddling the cut cannot leak a partial value. park_reason is set to one of:
verify_failed— Command exited non-zero, or the commit and tree to verify could not be read (no command ran).verify_timeout— Command exceededVERIFY_TIMEOUT.verify_dirty— The worktree could not be proven clean: it carried uncommitted changes before the first command (no command ran), a command exited 0 but left uncommitted changes, or git could not read the status. Handing off a dirty tree would advertise the PR as ready for human merge with state the dev never committed.verify_head_changed— Command exited 0, tree clean, but the command movedHEAD(e.g. rangit commiton its own). The subsequent squash + force-push would otherwise publish an unreviewed commit; the park comment surfaces the before / after SHAs.verify_tree_changed— Command exited 0 and leftHEADon the tested commit, but its tree no longer read as the tree recorded before the run; the park comment surfaces the before / after tree ids.
Examples¶
# single command
VERIFY_COMMANDS=python3 -m pytest -q
# multiple commands (semicolon-separated because the .env loader cannot
# represent newlines inside a value)
VERIFY_COMMANDS=python3 -m pytest -q;ruff check .
# raise the per-command cap to 20 min for a slow test suite
VERIFY_TIMEOUT=1200
When exporting in a shell instead of .env, prefer one command per line — the parser accepts both ; and newlines as
separators.
Parallel processing¶
Each polling tick advances issues concurrently along two axes:
- Across repos. When
REPOSlists more than one entry,runtime.ticks.run_tickfans the per-repoworkflow.engine.tick.tick(gh, spec)calls out across aThreadPoolExecutor(one worker per repo). A single configured repository — oneREPOSentry, or the developer fallback — stays in-thread. - Within a repo. Per-issue handlers are dispatched to a long-lived
IssueScheduler. Fan-out issues (workflow:ready/workflow:implementing/workflow:documenting/workflow:validating/in_review/workflow:fixing/workflow:resolving_conflict/question/discussion) are submitted one callable per issue. Family-aware issues (workflow:decomposing/workflow:blocked/workflow:umbrella/ unlabeled pickup) are folded into ONE bucket submit per repo that drains them sequentially. An openworkflow:blocked/workflow:umbrellaissue joins only on the ticksDEPENDENCY_POLL_EVERY_N_TICKSmakes due; on the ticks between, classification drops it before this split.
The two caps below are the levers:
MAX_PARALLEL_ISSUES_PER_REPO— default1. per-repo cap on concurrent in-flight per-issue handlers. EachREPOSentry can override via its fifth pipe-separated field. Must be a positive integer.MAX_PARALLEL_ISSUES_GLOBAL— default3. global cap across all configured repos. Must be a positive integer; raise only with the CPU / memory headroom to run that many agent CLIs at once. No-agent family buckets (workflow:blocked/workflow:umbrella) are cap-exempt and run on a dedicated executor.WORKFLOW_TRANSITION_GUARD— defaultwarn. governs the workflow-label transition-legality check inset_workflow_labelagainst the declaredALLOWED_TRANSITIONStable (seestate-machine/labels-and-state.md).warnlogs an illegal transition and proceeds;enforceraises;offdisables it. The label typo guard is always strict regardless of this setting. One write is exempt even underenforce: the late size gate putting a label back where a human moved it from, which is a repair of a move the transition graph never declared an edge for — enforcing there would strand the issue under the wrong label for as long as the guard stayed on. Invalid values abort at startup.
Both caps are enforced by a single IssueScheduler (orchestrator/scheduler/) built once at startup and threaded
through every workflow.engine.tick.tick call. New callers may pass a frozen SubmissionRequest; the historical
submit(repo_slug, issue_number, fn, *, ...) positional/all-keyword API remains supported. A submit is skipped this
tick (and retried next pass) when:
- the
(repo_slug, issue_number)pair is already in flight (duplicate-active gate), - the global or per-repo cap is reached,
- another family worker on the same repo is already in flight (family mutex).
Those gates answer for one process. An admitted worker also skips its issue, doing nothing for it, when another
poller on the same host holds the issue's writer claim — keyed on the repository's numeric GitHub id, not on any name
it goes by — and the next pass retries it; a close the poll read for the issue is kept in this process's latch, tied
to a late cycle only by a read that proves the close ended it. See
configuration/operations.md#running-more-than-one-poller.
No-agent bucket exemption. When every family-aware issue in this tick's bucket runs a no-agent handler —
workflow:blocked or workflow:umbrella, both pure label / dep-graph walks — the dispatcher submits the bucket as
cap-exempt: it does not consume cap slots and runs on a dedicated executor pool. This keeps a cheap-polling parent
from being starved by ordinary implementation work; in particular a blocked parent waiting on its own children would
otherwise deadlock those children for the only per-repo slot under the default parallel_limit=1. The family mutex
still applies. A bucket containing workflow:decomposing (spawns the decomposer agent) or an unlabeled-pickup issue
stays cap-counted. A CLOSED issue on workflow:decomposing / workflow:umbrella never joins the bucket: its handler
is the cleanup sweep over a snapshot owner's obligation ledger rather than the stage its label names, so it fans out
with its own cap_exempt=True submit and cannot be starved by whatever else the bucket holds. On the ticks
DEPENDENCY_POLL_EVERY_N_TICKS skips, an open workflow:blocked / workflow:umbrella issue is dropped before the
split, so a bucket that would have held nothing else is not submitted and mints no worker client.
Family vs fan-out labels:
- Family-aware (
workflow:decomposing,workflow:blocked,workflow:umbrella, unlabeled): read and write cross-issue state (parent ↔ child) and must never run two at a time on the same repo. - Fan-out (
workflow:ready,workflow:implementing,workflow:documenting,workflow:validating,in_review,workflow:fixing,workflow:resolving_conflict,question,discussion): only touch per-issue state; fan out concurrently up to the caps.
The pre-tick base refresh (_refresh_base_and_worktrees) is scheduler-aware: per-issue worktrees whose handler is
currently in flight are skipped this tick, so a base advance cannot rebase a pre-PR worktree under a still-running
agent. The skip is conditional on active state. Every other worktree is synced under its issue's writer claim, so one
another poller on the host is dispatching or refreshing is skipped too, with nothing read or written, and synced on a
later tick.
shutdown(wait=True) runs on process exit (normal --once return, SIGINT / SIGTERM, or self-modifying-merge
restart) so any in-flight workers complete cleanly. The signal handler also calls scheduler.shutdown(wait=False)
synchronously the instant the signal lands, so the submit path is closed mid-tick.
Maintenance barrier. scheduler.maintenance_barrier(timeout=...) is the one hold that closes admission without
closing the scheduler: both admission paths refuse for its duration — submit with reason=maintenance, and
track_active by handing back claimed=False, which the family bucket already treats as "another worker has it" and
retries next pass — and the work already admitted is then waited out within the bound it was given. Nothing running
is cancelled or hurried. The hold answers True only when every counted worker and every tracked claim is gone
while it keeps new ones out; a bound that expires, a shutdown that started, a scheduler already closed, or a hold
somebody else has answers False, and the caller does nothing. Admission is given back around the body whatever
the body did, so a pass that raised cannot leave a host refusing work. The one caller is the terminal-artifact
maintenance pass (runtime.artifacts, cadence TERMINAL_ARTIFACT_CLEANUP_INTERVAL_SECONDS or, where set,
TERMINAL_ARTIFACT_CLEANUP_WINDOW).
The barrier is bounded by what a scheduler knows, and two things sit around it for what it does not. A granted hold
is a snapshot, so the pass re-reads state.running, scheduler.is_closed() and its own host-hold budget twice
per candidate — before it is looked at and again as the last thing before its first mutation, since the readings
in between (the issue, its pull requests, the remote) are where a candidate's seconds go — and it keeps the
scheduler's per-candidate is_active check underneath as defense in depth.
Another process is outside all of it: its workers are in its own scheduler, so runtime.exclusion takes a flock
claim on WORKTREES_DIR/.artifact-maintenance.lock. Every polling run holds it shared for its whole life, and
every pass holds it exclusively while it acts — the one-shot mode from the moment it has connected, a polling
run's own recurring pass by handing its presence over and taking it back — so no process reclaims while another
could be submitting, including a second daemon on the same host. The handover happens inside the barrier: a
presence says work may be running in the process holding it, so it may only be given up once that process has closed
admission and drained, and it has to be back before admission reopens. A pass refused the claim defers whole; a
poller wanting it waits without a deadline, because polling through a teardown is never safe, and what bounds that
wait is the pass giving the host back at a candidate boundary once it has held it for 120s — the one host scan in
front of the candidates included — plus the candidate in hand. Only a lock somebody holds is waited for at all:
one that does not work (no flock on that filesystem, a full lock table) is not contention, so it is reported
instead and leaves a poller polling with no claim and a pass declining to act.
That is what makes running the one-shot mode on the same host as
the daemon safe: it declines to run rather than trusting per-candidate gates against work it cannot see.
runtime.ticks.run_tick calls scheduler.reap() exactly once per polling pass (right before
retention.prune_with_retention_logging()) so worker failure-completion records drain before the next iteration.
_dispatch_via_scheduler deliberately does NOT reap.
Non-positive or non-integer values for either cap (or for a per-entry parallel_limit) abort startup with a clear
error.
Workspace and agent identity¶
WORKTREES_DIR— defaultwt-orchestratorbeside the first configured target, in the parent directory of the firstREPOSentry'starget_root, or ofTARGET_REPO_ROOTwhenREPOSis unset (../wt-orchestratorbeside the orchestrator checkout). where per-issue git worktrees are created, one root for every configured repository; layout isWORKTREES_DIR/<owner>__<name>/issue-N. Symlinks stay as spelled: a target named through one keeps its root beside that spelling.TARGET_REPO_ROOTis taken exactly as written, while aREPOStarget is first made absolute against the launch directory, so a relative.entry still puts the root beside that checkout rather than inside it. The default never depends on where the package is installed or where.envwas read from, but it does follow the order ofREPOS: set it explicitly before reordering entries on a host with issues in flight, and on a host whose pollers list their repositories differently, since every poller on one host has to resolve the same root — seeconfiguration/operations.md#running-more-than-one-pollerLOG_DIR— default<REPO_ROOT>/logs. directoryruntime/logs.pyattaches itsFileHandlerunder (orchestrator.log, rotated ~10 MiB × 5). Also the default parent forANALYTICS_LOG_PATH(LOG_DIR/analytics.jsonl). In a source checkout this is<checkout>/logs; in an installed package it is inside the Python environment'ssite-packages, where recreating or uninstalling the environment can delete logs. Package setup sets an absoluteLOG_DIRoutside that environment.AGENT_GIT_NAME— defaultchipping-orchestrator.GIT_AUTHOR_NAME/GIT_COMMITTER_NAMEinjected into agent spawns, and the name on the commits the orchestrator creates itself: the approval squash is authored and committed under it, and the documenting pass'sPR_REF_IN_SUBJECTreplacement of thedocs:commit is committed under it while keeping that commit's own authorAGENT_GIT_EMAIL— defaultchipping-orchestrator@users.noreply.github.com.GIT_AUTHOR_EMAIL/GIT_COMMITTER_EMAILinjected into agent spawns, and the email on those same orchestrator-created commits
In-review behavior¶
The orchestrator is permanently manual-merge-only: humans click Merge. _handle_in_review routes fresh PR feedback to
workflow:fixing, pings the HITL handles once per head SHA when the PR is mergeable and the current head completed
the reviewer-approved final-docs handoff (or carries a real GitHub APPROVED review), and parks awaiting human
attention for an unmergeable PR. Ahead of all three it asks whether a requirements edit has already retired the
approval this label stands on — an edit's resume still owing the pull request its developer report, or the marker a
hand-back left when its own relabel did not land — and sends such an issue back to workflow:validating for a fresh
review, so no ready ping goes out on an approval the edit ended.
IN_REVIEW_DEBOUNCE_SECONDS— default600. quiet window thefixingstage honours before resuming the dev on PR feedback. Newer comments arriving while already labeledworkflow:fixingreset the window._handle_in_reviewitself routes fresh feedback to the fixing loop immediately and does NOT apply the debounce.
Observability¶
Every sink path, retention window, and observability switch — EVENT_LOG_PATH, ANALYTICS_LOG_PATH,
ANALYTICS_RETENTION_DAYS, ANALYTICS_DB_URL, TRAJECTORY_LOG_PATH, TRAJECTORY_RETENTION_DAYS,
TRACK_SKILL_TRIGGERS, and DASHBOARD_PARALLEL_READS — is documented in
configuration/observability.md, together with where each is parsed and which
process reads it. What the sinks record, and how the database, dashboards, and usage parser read them back, is on the
pages observability.md maps.
Analytics dashboard quickstart¶
Analytics sync runs directly from the installed pipx environment; commands and scheduling are in the
operator workflow. The Streamlit pages need a source checkout
and optional dashboard dependencies; setup and launch commands are in the
developer guide. The pipeline and its settings are described in
configuration/observability.md#analytics-dashboard-quickstart.
Continuous integration¶
../.github/workflows/ci.yml runs ruff check orchestrator tests,
flake8 orchestrator tests --select=WPS, pytest with an informational missing-line coverage report, poetry build,
and launches of chipping-orchestrator --help from the wheel and sdist in fresh environments with their declared
runtime dependencies, as separate mandatory steps for every push to main and every pull request. The whole set
runs on Python 3.12, 3.13, and 3.14 — the versions a run proves, out of the
open-ended range requires-python = ">=3.12" admits — under a 20-minute job timeout. A second push to a pull request
cancels the run its earlier push started, while a run on main is cancelled by nothing, queued or started. Dependabot
opens weekly workflow:dependencies update PRs. The coverage report has no minimum threshold.
../.github/workflows/vulnerability-scan.yml adds the standing half of
dependency scanning: on a weekly schedule and on workflow_dispatch it audits every version pinned in
../poetry.lock — not what a PR changes — and fails when a published advisory names one.
../.github/workflows/scorecard.yml grades the repo's supply-chain posture with
OpenSSF Scorecard weekly, on every push to main, and on demand, publishing the results the README badge and the
public viewer read and uploading the SARIF to code scanning.
../.github/workflows/codeql.yml analyzes the Python source on pushes to main,
pull requests targeting main, and a weekly schedule, then uploads the results to code scanning. Every uses: in
every workflow names a full commit SHA with its release in a trailing comment, and Dependabot's github-actions
updates rewrite that pair. The lint contract the Ruff run enforces — its own defaults plus E501, with no baseline
of this repo's own — the per-file lint scopes, the repository-wide 120-column target, workflow token permissions, the
rules an inline # noqa may name, the order imports are sorted into, the commit-SHA pins, the job timeouts, the
run-cancellation rule, the three interpreters, the packaging smoke checks, the dependency review, and how the scheduled
scans work are in
configuration/operations.md#continuous-integration.
Publishing the documentation¶
../mkdocs.yml builds this reference set into a static site at
https://chippingway.github.io/chipping-orchestrator/. The optional docs dependency group provides MkDocs, and
../.github/workflows/docs.yml checks every pull request and deploys updates from
main to GitHub Pages. Local preview commands, the link and anchor rules, the description front matter a page
summarizes itself with, and the one-time repository settings are in
configuration/operations.md#publishing-the-documentation.
Run modes¶
The installed chipping-orchestrator command polls continuously. Add --once for a single tick or --log-level DEBUG
for verbose logs; see run modes. Use --cleanup-terminal-artifacts for a
maintenance-only run that reclaims eligible worktrees and branches, locally and on the remote, without polling or
writing workflow state; see the
cleanup runbook.
Launcher dependency refresh¶
The source wrapper's dependency refresh applies to developer checkouts. Its fingerprints, timeout behavior, optional groups, and environment isolation requirements are in the developer guide. Published packages are upgraded explicitly with pipx.
Running under systemd (user service)¶
For persistent operation, use a systemd user service supervising the command installed by pipx. The unit file,
the loginctl enable-linger that boot-time start requires, and the day-to-day systemctl --user commands are in
configuration/operations.md#running-under-systemd-user-service.
Applying .env changes¶
~/.config/chipping-orchestrator/.env is read once, when chipping-orchestrator starts, so most edits take effect
on the next process start. There is no signal to make a running process re-read configuration. Which restart is safe,
what each launch style needs, and when each individual setting takes effect are in
configuration/operations.md#applying-env-changes.
What survives a restart¶
Per-issue progress lives in the issue's pinned JSON comment on GitHub and in the per-issue worktree, so restarting
between ticks loses nothing. The two hazards that are not covered by that — a live codex / claude / agy child,
and the agent spec pinned into an in-flight session — are in
configuration/operations.md#what-survives-a-restart.
Control labels¶
backlog— Apply to an issue (typically at creation) to keep the orchestrator from picking it up. The dispatcher skips the issue entirely while the label is present; remove the label to release the issue for processing.paused— Same hard skip asbacklog, but intended for an already in-flight issue: apply it to freeze processing (no handler runs, no worktree is rebased, no PR-stage relabel) without discarding the issue's state, and remove it to resume where it left off. Removing the label is the whole resume action, honored on the next poll; there is no un-pause command, and/orchestrator continueis unrelated — it retries a specificawaiting_humansession-failure park (agent_silent/agent_timeout/agent_execution_failed) across the dev stages (implementing,documenting,validating,fixing,resolving_conflict), and renews a spent spawn budget on aretry_cap-parkedworkflow:decomposingorworkflow:implementingissue, but never apausedhold. Applyingpausedwhile a developer agent is mid-run also takes effect: every stage that resumes a dev agent (implementing,validating,documenting,in_review,fixing,resolving_conflict) re-reads the label after the run returns, before any post-agent side effect, and discards the result rather than pushing, opening a PR, relabeling, advancing watermarks, or posting comments, so the committed work stays on the branch and republishes once the label is removed. The two conversation stages honor it on the same terms: apausedthat lands while aquestionordiscussionround is running suppresses every disposition below it, so nothing is posted, parked, folded into the usage counters, or written to pinned state. A discussion round that had just committed the confirmed plan keeps that commit on its branch and has it published by the tick after the label comes off, classified against the round anchor the stage wrote before the spawn.workflow:community_contribution— Applied automatically (not by an operator) by the per-tick open-PR sweep: any open PR whose author is outsideALLOWED_ISSUE_AUTHORSis labeled andHITL_HANDLEis @-mentioned once per PR so a human reviews the community-submitted work. Bot authors (Dependabot, Renovate, CI bots) are skipped. It carries theworkflow:prefix — unlike the two controls above — because the orchestrator writes it itself; a PR still carrying the pre-namespace spelling counts as already labeled, so the migration cannot cost it a second HITL ping.