The timers
The timer templatesystemd/[email protected] is deployed as N
instances, [email protected] through [email protected] (N =
max_concurrency, at most MAX_RUNNER_INSTANCES = 5 — Issue #827). Each fires
every 5 minutes, 24 hours a day (OnCalendar=*-*-* *:00/5,
AccuracySec=30s, Persistent=false — a missed tick is dropped, never
queued) and starts its OWN service instance
([email protected] → [email protected],
[email protected] → [email protected], …), so N independent
Runner instances can run concurrently. The instances do not all fire at
once: the template sets RandomizedDelaySec=240 with
FixedRandomDelay=true, so systemd spreads them by a delay derived from
the machine id, the user and the timer name (systemd.timer(5)), stable
across the timer’s firings and manager restarts. The pre-start drift
self-heal repairs the installed units but never changes a timer’s
enablement — a timer the operator disabled stays disabled. Each tick’s
service instance:
-
Syncs the engine source first (
ExecStartPre, outside the Runner process). Two steps, both under a short-livedflockon the shared state-dir lock file (base-sync.lock, the Python-side sync takes the SAME lock; two instances may start in the same tick, and since Issue #171 EVERY fetch that updates the shared remote-tracking ref runs under that same lock — the Runner-sidefreeze_base/verify_pr/merge_gate/confirm_mergedfetches go throughfetch_base_ref, and the implement/review prompts instruct Pi to run the base-freshness fetch asflock <BASE_SYNC_LOCK> git fetch origin <base_branch>, so two concurrent Runners — or a Runner and a Pi session — never race the shared ref). The checkout both steps act on is the deployment home — the{{ORBI_REPO_DIR}}placeholder renders thedeploy_homepath at install time (defaulting torepo_dir), so in the external single-repo mode (Issue #330) the sync, the lock and the CLI self-heal all act on the orbi source checkout, never on the delivery checkout (user repo X).- Step 1 — CLI self-heal (Issue #248): it probes the installed
console script (
orbi --versionsucceeds iff the package imports) and, on probe failure, runs the exact editable force-reinstall (uv tool install --force --reinstall --editable --python <interpreter> <repo_dir>—<interpreter>follows the compatible selection rule:python3when the system Python satisfies the>= 3.14floor, otherwise3.14provisioned by uv — the SAME ruleorbi setupapplies, Issue #861). This is the only place that can act when the console script cannot evenimport orbi— the #248 scene, where the src-layout migration of #168 left the installed editable finder stale and the #158 in-Runner refresh (which runs INSIDE the Runner) was unreachable. It runs BEFORE the sync because the sync subcommand needs a working CLI. A healthy CLI exits 0 and nothing else runs (no per-tick reinstall); a failing reinstall fails the preflight, the service does not start (fail fast, no fallback). - Step 2 — engine source sync (Issue #535):
orbi sync-engine-sourcebrings the deployment home to the configured engine source update channel. The channel is the host/deploy-only config keyengine_source_trackin the deploy home’sorbi.toml(a repository’s.github/orbi.tomlcan never carry it; see Engine source update channel below for the modes and failure lines). The defaultmainchannel keeps the exact pre-#535 behavior:git fetch --no-auto-maintenance origin main && git merge --ff-only origin/main— fast-forward ontoorigin/main. Every fetch passes--no-auto-maintenanceso it cannot detach a Git child beyond the preflight and shared-lock lifetime. A dirty checkout, a failed fetch, a missing tag/SHA or branch, a non-fast-forwardable branch, or any state that cannot be verified against the resolved commit fails the preflight: the service does not start and the reason lands in the systemd journal (fail closed). Each failure emits one structured, actionable line (deploy_home_dirty files=... fix=...,engine_source_unresolved reason=...,engine_source_not_fast_forwardable ...); on success the sync logs one structuredengine_source_synced engine_source_track=... resolved=... head=...line. When the channel is anything but the plainmaintrack (branch:/release/tag:/sha:), the Runner also skips its post-merge fast-forward of the deployment checkout: the channel owns that checkout, and the next tick’s step 2 re-applies it.
- Step 1 — CLI self-heal (Issue #248): it probes the installed
console script (
-
Runs one tick: resume an opened PR (the review/fix/merge step) or
claim one
ai-readyIssue, then exit. One tick performs AT MOST ONE delivery step and never sleeps waiting for GitHub (Issue #788): the implement tick ends when the PR opens and releases the slot; the review tick is the only phase holding the slot while a Pi runs; pending CI or an UNKNOWN mergeability journal one line and return — the next tick re-reads (the waiting primitive). Before any slot or claim the tick also runs the pre-start git transport check (Issue #114, #580): the deployment checkout’s configuredoriginremote must match the configured transport (orbi.tomlgit_transport:ssh— the default,[email protected]:owner/repo.git— orhttps,https://github.com/<repo>.gitwith theghcredential helper) for the first configured source repo andgit ls-remote <expected-url>must exit 0 (the transport reachable and authenticated). A broken transport logs the structuredtransport_check_failed ... reason=...line and fails the start — no slot, no claim, no label change, no fallback (in ssh mode explicitly no HTTPS fallback; GitHub API operations always stay on theghtoken). And before any slot or claim the tick runs the runner source freshness gate (Issue #525): it proves the code THIS process executes matches the configured engine source channel (Issue #535:engine_source_track,origin/mainby default — never the delivery target’sbase_branch) — see Runner source freshness and the defense layers below.
Engine source update channel
Issue #535 replaces the hardcoded “always followorigin/main” engine
update with one explicit channel. The host/deploy-only config key
engine_source_track lives in the deploy home’s orbi.toml — never in a
repository’s .github/orbi.toml (the runner rejects it there as a
host-only key):
releasevstag:— follow vs lock:release(alias:stable) follows the newest PUBLISHED version and advances to the next tag automatically when one ships — it changes only at release time (mainchanges with every commit).tag:<name>LOCKS one exact version and never moves until you edit the value, so a version change is always a deliberate operation — pin it when a deployment must stay reproducible; rollback is putting the previous tag back.- Installed vs updated: a fresh install via
install.shcopiessrc/orbi/example_config.toml, which shipsengine_source_track = "release"— new deployments run the latest release, not the main intermediate state. Orbi’s own dogfood deployment pins themainchannel explicitly. Any value change takes effect at the next service start (the ExecStartPre sync applies it); there is no daemon and no restart of a running Runner. - Rollback is editing the track back to the previous
tag:/sha:value (orrelease/main) — git state is the only store, nothing else to clean up. - Fail closed (the service does not start, each line carries a
fix=):deploy_home_dirty files=... fix=...— tracked local edits would block the sync; preserve them withgit -C <deploy_home> stash && systemctl --user start [email protected](andstash poplater), or discard them withgit -C <deploy_home> checkout -- ..engine_source_unresolved reason=tag_not_found|tag_conflict|sha_not_found|branch_ref_missing|no_official_release_tag— the channel cannot be resolved (fetch first, check the name, or publish the release tag).engine_source_not_fast_forwardable— the branch diverged locally (a deploy home never carries local commits).engine_source_unverified— the head could not be proven to match the resolved commit.
- Success evidence: one structured
engine_source_synced engine_source_track=... resolved=... head=...journal line per start. - doctor reports the channel read-only:
engine_source: track=... resolved=... head=...(or aFAILEDline with the same reason when the channel cannot be resolved locally). - GitHub rejects fetching unadvertised SHAs (
not our ref), so thesha:channel fetches the advertised refs once and verifies the commit locally; a SHA reachable only from an unmerged pull-request branch therefore fails closed — lock tags/releases instead. - The delivery target is untouched:
base_branchkeeps governing task worktrees and delivery PRs (the engine/delivery isolation is Issue #543’s subject).
Runner source freshness and the defense layers
The 2026-09-07 incident: an editable install resolved into an OLD issue worktree while theExecStartPre preflight kept the deployment home
fresh — the Runner executed stale code while every delivery LOOKED
up-to-date (fresh base_sha, stale engine), and a long-fixed bug
replayed in production. “The checkout gets synced” and “the process
executes that checkout” are different facts, so before any slot or
claim the tick proves the second one (Issue #525):
- Editable install (the official deployment): the gate resolves the
import source of the RUNNING process (
orbi.__file__→ thesrc/orbipackage directory, never the WorkingDirectory) to its checkout withgit rev-parse --show-toplevel, and requires that checkout’sHEADto equal the configured engine source channel’s expected commit: the fetchedrefs/remotes/origin/<branch>head for themain/branch:tracks, the resolved release/tag commit for therelease/tag:tracks, or the exactsha:commit. Worktrees share the deployment home’s refs, so the incident scene (an install bound to a stale worktree) is rejected here. - Non-editable install (uv tool / PyPI copy): the installed
distribution version (
importlib.metadata) must not be older than the channel’s release tag — the latest tag reachable from the tracked branch ref, or the lockedrelease/tag:version itself. Asha:lock cannot be mapped to a version and fails closed as unverifiable. Equality or newer passes. - Everything is a LOCAL git read (
rev-parse/describe/tag --list): the freshness of the fetched refs and tags is supplied by theExecStartPresync, so a fresh checkout costs zero network requests. Whatever cannot be PROVEN fresh (missing origin ref, no release tag, unresolvable import source) fails the same way with areason=field — never a silent pass. - A stale or unverifiable source logs the structured
runner_source_stale install=... source=... engine_source_track=... head=... origin_main=... allowed=false fix=...line and fails the start: no slot, no claim, no label change. Thefixis the exact editable force-reinstall against the deploy home.allow_stale_runner: true(getting-started) downgrades the SAME line to a warning for offline/restricted-network machines — it never skips the check.
unit_drift comparison runs inside the very process that may be frozen.
The layering is therefore strict, from the outside in:
- The
ExecStartPrepreflight (the CLI probe self-heal + the engine-source sync, Issue #535) is the OUTERMOST defense: it is executed by systemd before the Runner process exists — the probe step is pure shell around the installed console script and reinstalls it from the checkout, so a CLI that cannot evenimport orbiis repaired here, before anything depends on it. Keep the installed units current (theunit_driftself-heal ofinstall-unitsand the pre-start check) so this preflight always exists. - The runner source freshness gate (this section, Issue #525) is the in-runner invariant: from its first release onward, a runner whose code is stale refuses to pick up work instead of failing invisibly.
Stop, migrate, or remove a deployment
A deployment is more than the editableuv tool: user-level systemd units
keep starting timers from the configured deploy_home. If you move that
checkout, stop the old deployment before installing the new one. Keep
deploy_home on a persistent path (for example, under your home directory),
not /tmp or another directory that may be removed between boots.
Pause a deployment
To pause Orbi without deleting anything, first disable and stop every timer instance that setup may have enabled:orbi.toml,
or <repo_dir>/.orbi/ state. To resume the same deployment, leave the
installed units in place and enable the required timer again (or run
orbi setup --config <deploy_home>/orbi.toml).
Migrate to a new deploy home
- On the old deployment, run the pause commands above. Do this before removing or renaming the old directory; otherwise its enabled timers keep waking up and logging failures.
-
Confirm the new
deploy_homeis a persistent directory, clone or move the Orbi checkout there, and update itsorbi.toml. Keeprepo_dirpointed at the delivery checkout; changingdeploy_homemust not move or delete that checkout, its worktrees, or its.orbi/state. -
From the new deploy home, install the units and enable the desired timer
count:
max_concurrency = Nenables[email protected]through[email protected](at mostMAX_RUNNER_INSTANCES = 5); e.g.max_concurrency = 2also enables[email protected]. Verify the result withsystemctl --user list-timers 'orbi@*.timer'. -
If the new deployment uses the same
unit_nameas the old one, setup has already replaced the unit files; do not remove them, or you will remove the new deployment too. If you intentionally changedunit_name, only after the new deployment is verified remove the old unit files. Disable the old timers first as shown above, then runsystemctl --user daemon-reload. Do not remove a repo, worktree, or.orbi/directory as part of this unit cleanup unless you have separately confirmed that its data is disposable.
unit_name is configured, replace orbi@1/2 in these commands with the
corresponding configured name (for example, orbi-website@1/2).
Completely remove Orbi from this machine
First pause the deployment. For the default unit name, remove the installed templates only after both possible timers have been disabled:repo_dir, task worktrees, orbi.toml, or
<repo_dir>/.orbi/; delete those separately only after confirming they are
not needed. If this machine will no longer use the Orbi CLI at all, optionally
remove the editable tool as a separate final step:
uv tool uninstall orbi-cli (the tool is named after the PyPI
distribution orbi-cli, Issue #874) is not part of a pause or migration, and it does not
clean systemd units or repository state. There is no orbi uninstall or
orbi teardown command.
After a unit template changes (Issue #131, #142)
The unit templates (systemd/[email protected] and
systemd/[email protected]) are deployment config, not ordinary code
— but a template change needs NO human step after the merge. The templates
live in the deployment home (deploy_home, defaulting to repo_dir —
Issue #330), and the drift check compares the installed units against
THOSE templates. The next timer
trigger’s ExecStartPre syncs the deployment home checkout to the
configured engine source channel (Issue #535), and the pre-start
unit_drift check self-heals: it runs the SAME idempotent install
(copy both templates into the user unit directory, daemon-reload,
enable or disable timer instances through max_concurrency — it never
starts, stops or restarts a running Runner), re-verifies with the SAME hash check, and the tick
continues with one structured unit_drift result=auto_synced line per unit
(before and after sha256, deployed commit). There is no per-tick drift
loop until human intervention (the #131/#140 scene: every start failed
with unit_drift until a human ran the sync command). The same install
also migrates the pre-#149 non-templated units (orbi.service /
orbi.timer) away once: it stops the legacy timer (a timer stop
— never the service) and removes the legacy files, so the old
single-instance schedule cannot keep firing the old service.
orbi install-units --config orbi.toml stays the manual
entry (first setup, or an immediate sync). A drift the self-heal cannot
resolve — a failing install step, a missing template, a unit that
drifts again before the re-verify — still fails the start (structured
unit_drift line, non-zero exit, no slot, no claim): the hash check
and the fail-fast canary are unchanged.
Logs (journal)
The journal is the local record. Every line of a run starts with the run id prefix[<run_id>], so one grep reconstructs the full timeline:
key=value lines you will see:
run_start/run_end— the full scene (branch, worktree, session file) at start, and the result (PR URL, commit) at the end;activity/heartbeat/model_wait/resumed— live Pi activity while a session runs (phase, last action, elapsed, idle). Thephaseis the startup sub-phase while the first response is outstanding:session_pending(Pi is spawned but has not created its session file yet) andrequest_pending(the session exists, the first response has not arrived); after the first response the tool-based phase (pr,bash, …) applies as before;process_spawned/provider_config_loaded/session_created/first_request_started/first_response_received— the startup phase lines (Issue #176): one line per phase, in order, each carrying issue, role, the provider/model Pi selected (-until the session’smodel_changerecord says otherwise), and the elapsed time since the spawn (process_spawnedadditionally carries the pid).provider_config_loadedis logged by the Runner before the spawn (the provider file is loaded and materialized, or Pi’s own agent dir when unconfigured); the other four are logged by the streamer as the session JSONL crosses each milestone (the session record, the first user message — the first request goes out — and the first assistant message — the first response arrives). A run stuck atstartingis locatable to its startup phase from these lines plus the progress comment’sphasealone; only identifiers are logged — never a key, the prompt or model output;startup_failed— one line when the run fails BEFORE the first response (Issue #176): it says WHERE the startup was stuck (session_created=true|false,first_request=true|false) plus the distinguishablereason=—session_not_created(Pi exited before creating its session file),no_first_request(session exists, no first request),provider_rate_limited(a provider 429 — Issue #321),auth_failure/network_timeout(classified from Pi’s stderr — the class only, never the text),pi_exit_<N>(the early exit code),timeout(the run deadline),first_response_timeout(a frozenmodel_waitkilled before any response — the hung first request),model_wait_swallowedandidle_recovery_stale(the existing kill paths). The existingrun_failedscene line and the raised failure are unchanged —startup_failedis the extra observability, not a new failure path;resume_continue— one INFO when a resumed run continues the interrupted work (Issue #219): the worktree’s run state file (.orbi/run-state.json) verified the SAME run (issue number- repo NAME — stable across a repo rename, so the old worktree is
found even when the slug changed) and the worktree carries
uncommitted changes or a previous session. The line carries the
worktree,
changed_files(uncommitted paths),reused_runs(previous session files) andprevious_session(session id or-); the new session receives the resume context in its prompt (continue the existing work — never redo, never discard). When the same run cannot be verified (the state file is missing or corrupt) the line isresume_continue_failedinstead: fail fast, the Issue goesai-blockedwith the reason — never a silent fresh redo;
- repo NAME — stable across a repo rename, so the old worktree is
found even when the slug changed) and the worktree carries
uncommitted changes or a previous session. The line carries the
worktree,
pi_idle— one WARNING when there is no model/session activity for more than 5 minutes (PI_IDLE_WARN_SECONDS=300) and the model is not expected to reply; a slow active model (model_wait) never warns;model_wait_swallowed— one WARNING plus the kill of the Pi session when the frozenmodel_waitis confirmed SWALLOWED by the/slotsprobe (Issue #233): themodel_wait_probe_urlendpoint reports that EVERY slot is idle (is_processing=false) for the sustainedmodel_wait_probe_secondsgrace (default 60 s). The model request was accepted by the upstream but never scheduled into the slot — the model process is alive and the connection ESTABLISHED, but nothing is generating (the #231 scene). The line carries issue, role, idle seconds, the probe grace,action=kill_pi, session, run id,upstream_alive=true|falseandreason=swallowed_model_request. It fires FAST (well beforemodel_wait_dead_seconds) so a swallow is recovered in ~1 minute, not ~30. The probe is a pure bypass (Issue #79): a probe failure is inconclusive (no line, themodel_wait_deadbound still applies) and a slot that is processing resets the idle window (a slow model is not a swallow); disabled whenmodel_wait_probe_urlis unset;model_wait_dead— one WARNING plus the kill of the Pi session when a frozenmodel_wait(the newest event is a tool result, no new session events) crosses the configuredmodel_wait_dead_seconds(default 1800 s,PI_MODEL_WAIT_DEAD_SECONDS, Issue #228): the model request is HUNG — a live connection and an alive model service process are evidence on the line (upstream_alive=true|false), never a veto (process alive ≠ responding, Issue #218); the line carries issue, role, idle seconds, the actual configured threshold,action=kill_pi, session, run id andreason=hung_model_request. The threshold measures silence between COMPLETE session events (Pi does not stream token-level progress into the JSONL), never token-level model progress — a slow generation that keeps appending events never trips it (a slow local model, e.g. a 27B Q4 GGUF behind a llama-server with a 1200 s request timeout: ~57 tokens/s at 12K context on an RX 7900 XTX with all layers on GPU, well under 20 tokens/s on partial GPU offload or CPU-only, survives a 10-minute complete message under the default);pi_idle_wait— the grace line of the idle-stall recovery (Issue #181): a coreutilstimeout <seconds> ...wrapper still inside its deadline is a legitimate long-running command, not a hang. The Runner logs onepi_idle_waitline per stall (run id, pid, cmdline, deadline) and reportsrecovery=waitin the progress comment; one full idle window of grace passes before escalation, and every later window re-evaluates. While the wait is active it also pauses the session-kill exhaustion counter (Issue #1089): the kill is bounded by the wrapper’s own deadline — capped atPI_IDLE_WAIT_MAX_SECONDS(default 3600 s) since the wait began, so a declared deadline longer than the cap escalates anyway — never byPI_IDLE_RECOVERY_CYCLESidle windows;pi_idle_term/pi_idle_kill— the idle-stall recovery (Issue #94): while the session stays stalled the Runner SIGTERMs the Pi descendants that already existed before the idle window (the hung tools — ppid chain + start time from/proc/<pid>/stat, never a name guess; only Pi descendants are ever signaled), then SIGKILLs a target that survived; each line carries run id, pid, cmdline and result (sent/already_dead/no_target/failed: ...);pi_retry_429— one WARNING per provider rate-limit retry (Issue #321): Pi exited non-zero and its stderr carries a 429 marker (429/quota/RESOURCE_EXHAUSTED/retry in) — TRANSIENT throttling, not a task failure. The SAME Pi session is re-spawned in the same run after a backoff: the response’s own retry hint (retry in Ns/retryDelay: Ns) when present, else the default escalation (30 s doubling), both capped at 5 minutes. The line carries run id, issue, role,attempt,next_retry_in, the retrylimit(5) and the session; the slot is held for the whole wait (no new Issue is claimed while this run waits). Theattemptcount is the RUN’s, not the process’s (Issue #698): it is persisted in the worktree’s.orbi/run dir and keeps rising across runner restarts, so the limit actually reaches a terminal. After 5 backoff retries still end in a 429 exit (6 consecutive 429 exits) the delivery is TERMINAL in every phase —ai-blocked, never a recoverable resume — with the failure comment carrying the cumulative count and the repair (relabel the Issueai-readyafter the quota window resets; the terminal clears the persisted counter, so the requeued attempt starts with a full budget), markedreason=provider_rate_limited. Long-term quota exhaustion is NOT handled here (the #313 rotation semantics own it);run_failed— the full scene plus the reason (pi_exit_N,timeout_...s,model_wait_swallowed_idle_...swhen the/slotsprobe confirms every slot idle for the sustained grace (Issue #233) and the Runner kills the Pi session fast,model_wait_dead_stale_...swhen a frozenmodel_waitpast the configuredmodel_wait_dead_seconds(default 1800 s, Issue #228) declares the model request hung,idle_recovery_stale_...swhen the session stayed idle forPI_IDLE_RECOVERY_CYCLES(default 3) consecutive idle windows with no descendant inside itstimeoutdeadline (Issue #1089: an active wait pauses the counter) and the Runner kills the Pi session itself, orprovider_rate_limitedwhen a provider 429 survived every in-run backoff retry (Issue #321)).
Event reference
Every structured journal event is emitted through the single emission pointjournal.event() in src/orbi/journal.py; the event names are the
JOURNAL_EVENTS registry defined there. This table carries exactly that
registry — in both directions (a name only in code or only in docs fails
tests/test_journal_events.py), and the Prometheus exporter’s
KNOWN_KINDS are a subset of it:
The idle warning, the idle recovery and the hung-model-request kill are
log/health thresholds — they are not the 5-minute schedule and not a
business task timeout. The idle recovery never holds the slot forever:
a stalled session either resumes (the failure signal reached the model
and the first new event resets the recovery state) or the run fails
fast through the normal
ai-blocked path — after
PI_IDLE_RECOVERY_CYCLES idle windows for an ordinary stall, or at
the wrapper’s own deadline, capped at PI_IDLE_WAIT_MAX_SECONDS
(default 3600 s), plus the TERM/KILL windows, when the stall
is a bounded timeout tool that outlived it (Issue #1089).
The CLI (orbi)
The official entry is the installed orbi console script, as an
editable uv tool install — the why (the ExecStartPre checkout
sync, the reinstall-free source changes, the packaging-input refresh)
is defined in Getting started step 2.
orbi doctor reports the consistency (cli_source: clean or
cli_source: DRIFT with the structured cli_source_drift line: actual
import path, expected deploy_home, the exact editable reinstall command),
and orbi setup verifies or reinstalls it. The expected source is the
deployment home (deploy_home, defaulting to repo_dir,
Issue #330): in the external single-repo mode a clean cli_source means the
editable install imports from the orbi checkout, even though the
delivery checkout is a foreign repo X. The checkout root
carries no orbi.py: a flat file named like the package would
shadow the installed package for every process with the checkout root
on sys.path. The direct-execution entry python3 -m orbi.cli
runs the exact same code as the console script and stays a
development/compatibility path.
--config or the
ORBI_CONFIG environment variable (default
orbi.toml). status and session are debug attachments — the
journal and GitHub remain the normal observability path.
Overlapping task pools across deployments (Issue #1519)
A Runner has no cross-deployment lock: two differently named deployments that serve the same GitHub repository would compete for the same Issues.orbi doctor compares this deployment’s source_repos
with the other deployments discoverable from the Orbi units installed
for the current user (--installed-dir overrides the standard unit
directory). The comparison reads ONLY source_repos from those configs
— it never loads their .orbi/env, resolves their provider secrets or
makes GitHub/model calls. Config paths are canonicalized, so repeated
service/timer slots, symlink aliases and this deployment’s own config
count as ONE deployment, and GitHub repository identifiers compare
case-insensitively:
source_pool: healthy deployments=N— nothing else serves this pool.source_pool: FAILED repo=<owner/name> this_config=... other_config=... fix=...— both deployments serve the same task pool; choose ONE deployment for that pool and use itsmax_concurrencyfor concurrency.source_pool: INCOMPLETE config=... reason=... fix=...— a discovered config is missing, unreadable or malformed. Fix or remove the unit’sORBI_CONFIGreference; doctor never reports all-clear while a pool is unreadable.
Advance the active milestone manually
orbi milestone set <title> (Issue #895, #1306) is the human command in
the milestone lifecycle: when auto_next_milestone = false leaves the
advance pending, or
whenever a version switch should be a deliberate operator step, it
moves active_milestone to the given GitHub Milestone title — no
hand-editing the file. The value lands where the Runner actually reads
it from, precedence repository policy, then host config: when
.github/orbi.toml on the repository’s default branch declares
active_milestone (the per-key override the Runner reads), the command
rewrites that key there with a contents PUT; otherwise it rewrites the
host orbi.toml line. Only the active_milestone line is rewritten:
comments, blank lines and every other field stay byte-identical. The
title must exist as exactly ONE Milestone on the single configured
source repo (GitHub Milestone titles are not unique; a duplicate exact
title is a hard error).
wrote: line names the target: repo policy .github/orbi.toml (a
contents PUT on the repository’s default branch) or host config /path/to/orbi.toml. The Milestone list read is not the only network
call: the command also reads the repository policy, and when the policy
declares the key it commits the rewritten blob and never touches the
host config. The ORBI_ACTIVE_MILESTONE Actions variable is
published by the Runner’s next-tick preflight (the bypass contract), so
the command’s success is NOT the variable’s success: verify with the
printed gh api probe. Every failure exits non-zero with one
structured milestone_set_failed reason=... fix=... line on stderr and
leaves the target untouched — a missing or duplicated title, a GitHub
API or permission failure, neither source declaring the
active_milestone line (the field is never created implicitly), a
failed policy PUT, or an unwritable file all fail fast with the actual
reason:
auto_next_milestone is true (the
default, Issue #933): the idle path advances a closed active_milestone
to the newest open one on the next tick, so accepting it would report a
success the engine undoes one tick later. Reopen the milestone, or set
auto_next_milestone = false first. With auto_next_milestone = false
the closed target stays allowed — that is the confirmation flow this
command exists for.
Advance the active milestone from the confirmation Issue
The confirmation Issue a closed active Milestone produces withauto_next_milestone = false carries an in-ticket command (Issue #1290):
a comment whose own line is /milestone <title> runs the same three
steps a maintainer would, and a hosted tenant can send it without host
access or the CLI.
- The line is anchored at the line start, so the command works on any
line of a long comment and a
>quoted reply never triggers it; fenced code blocks (``` or ~~~) are stripped before matching, so documenting the command does not run it. - Only a title from the Issue’s candidate list is accepted, and the literal in the comment is the only version used — the command never infers the next version.
- The comment author needs write permission on the repository (OWNER/MEMBER/MAINTAINER/COLLABORATOR); a comment from anyone else, a title outside the candidates, and a malformed line all leave one readable reason comment on the Issue. The runner’s own comments never trigger it.
- The three steps are idempotent: create the Milestone (open) when it is
missing, open its release ticket from
.github/release-ticket-template.mdwhen the Milestone has none (theversion_filecomes from theversion_fileconfig key, else the detection; when neither resolves, the step fails and the receipt names the fix instead of guessingpyproject.toml; a missing template falls back to the built-in minimal field set and says so in the ticket), then landactive_milestone— in the repository.github/orbi.tomlwhen the active Milestone comes from there, otherwise in the deployment config. - A step that fails leaves the failure and the step name on the Issue and does not roll back the steps that already succeeded; the next tick retries the whole command idempotently.
milestone_command_applied and milestone_command_failed.
In-ticket commands
Every command a ticket can carry lives in one explicit registry,TICKET_COMMANDS (src/orbi/ticket_command.py). The shared layer owns the
parts a second command must not re-implement: the anchored matcher, the
permission gate, the fenced/quoted handling, last-occurrence-wins and the
receipts. A command supplies its verbs — never a regex — a declarative
permission level, a validate callback (its own business rule: it returns
a readable reason, or None when the argument is accepted) and an apply
callback. The dispatcher process_commands(...) is the only path to a
handler.
To add a command:
- Define a
CommandSpecin the command’s own module (/milestoneis the first registrant) and callregister_command(spec). Registration is the validation: a duplicate name, a verb another command already owns, or a permission level outsidePERMISSION_LEVELSfails at import time. - Register the journal event kinds
<name>_command_appliedand<name>_command_failedinJOURNAL_EVENTS(src/orbi/journal.py) and add their rows to the event reference above;event()refuses an unregistered kind. - Add the command’s row to this table (and the Chinese page): the binding
test
tests/test_ticket_command.pyreads both tables and fails on drift. - Make sure the command’s module is imported before tickets are
processed (
orbi/milestone.pyis the module that owns the call site):register_commandruns at import time. The singleprocess_commands(...)call already passes the wholeTICKET_COMMANDSregistry, so a registered command is dispatched without changing the call site.
A finished Milestone with no release ticket
Issue #856: when the active Milestone has no open Issue left but nobody has opened its release ticket, the engine waits silently — the queue is empty, each tick reportsno_ready_issue, and no one is told that a decision is
owed. With release_confirmation = true in the repository’s
.github/orbi.toml the idle path surfaces that state through the SAME
decision-notice mechanism as the advance wait: exactly one notice Issue,
carrying the same fingerprint (orbi-milestone-advance old=<active> candidates=<active>) and the same in-ticket /milestone <version> reply.
The engine never creates the release ticket by itself — the notice tells
the maintainer, and a trusted /milestone <active> comment runs the three
deterministic steps documented above.
- Idempotent: one open notice per finished Milestone, never one per tick.
- Kept open while the release ticket is missing; closed automatically once the wait is over — a release ticket for that Milestone exists, or the Milestone stops waiting (it got new work, or it closed after the release). The closing comment states which of the two happened.
- The pending state logs
active_milestone_release_pending old=<active> repo=<repo>, the sameold=field the advance notice logs. A failed notice write is a pure bypass (pending_milestone_issue_failed) and never fails the tick. - The wait is intentional, not a fault: the decision owed is “open the
release ticket for the finished Milestone”. Without the opt-in key (the
default
false) the behavior is unchanged from before #856. - If the
/milestonerelease-ticket step cannot resolveversion_file, the notice gets theversion_file_unresolvedreceipt naming the fix; setversion_file(or"none") and reply again.
Public surface
The following interfaces and persisted names are public surfaces. Changes to any of them must be called out in the release notes so downstream automation can migrate deliberately:- CLI: the bare
orbitick and theadd,suggest,status,doctor,setup,install-units,session,milestone set,check, andsync-engine-sourcesubcommands, including--configand theORBI_CONFIGenvironment variable. - Suggestions: the document
orbi suggest --jsonprints (repo,base_sha,session_dirand up to three suggestions), the kept run directory<deploy_home>/.orbi/suggest/<run_id>/and the skillintegrations/claude-plugin/skills/suggest-ai-ready-issues/that produces it (Issue #1576); theorbi setupoffer only runs on a terminal and prints the one-line hint otherwise. - Configuration: every key in the Configuration
reference is public surface, plus the gitignored
.orbi/envfile loaded by the systemdEnvironmentFile. - Deployment: the
systemd/[email protected]andsystemd/[email protected]templates and their[email protected]instances (N =max_concurrency, at mostMAX_RUNNER_INSTANCES = 5). - In-ticket commands: the
/milestone <title>comment on a milestone confirmation Issue (Issue #1290). - Labels and lifecycle:
labels.tomltogether withsrc/orbi/delivery_labels.py, and theai-ready,ai-in-progress,ai-pr-opened,ai-fix-needed,ai-merged, andai-blockedstate machine. - Observability: journal lines begin with
[run_id]and use stable event names such asrun_start,run_end,activity,heartbeat,resumed,run_failed, and the startup/recovery events documented in Logs (journal). The live Pi record is<worktree>/.pi-session/*.jsonl. - Delivery names and markers: the stable task branch is
orbi/<owner>-<repo>-issue-<N>; the run-scoped worktree directory is.worktrees/orbi-<owner>-<repo>-issue-<N>-<run_id>. Issue/PR comments and PR bodies use<!-- orbi:run=<run_id> -->and visiblerun_id=<run_id>; PR bodies also requireFixes #<N>.
Worktrees and base freshness
Each claim freezesorigin/<base_branch> (fetched first) and creates the
task worktree and stable Issue-based feature branch from that exact SHA —
never from the main worktree’s current HEAD. The branch is stable across
retries; the worktree, Pi session directory, and run artifacts carry the
run id, so a retried Issue gets a new independent run and the old scene is
preserved. .worktrees/ is gitignored.
The task worktree inherits the deployment checkout’s single origin
remote, so the git transport is configured once and every worktree
inherits it (Issue #114 — the two-channel boundary and the pre-start
check: item 2 of The timers above; the credentials: Security).
The agent stops at the committed delivery; the base freshness before the
PR is the Runner’s deterministic closeout (Issue #186, deliver_pr):
the Runner re-fetches origin/<base_branch> under the base-sync lock,
absorbs an advanced base with a plain git merge (a conflict is aborted
and the PR opens on the agent’s head — the existing review session
absorbs the base in-session), then pushes the task branch and creates the
PR. Before creating the PR the Runner reads the source Issue’s state
directly: an Issue a human closed while the delivery was in flight stops
the closeout — the pushed branch keeps the work, the closed Issue gets
one explanatory comment, and no PR is opened (Issue #746). The per-tick
orphan-PR sweep in Workflow covers the reverse order (the
Issue closed after the PR was opened) and reports the PR for a human
decision.
Task-worktree reclamation (Issue #760)
Worktrees used to only ever grow: every delivered run left itsrepo_dir/.worktrees/orbi-{slug}-issue-{N}-{run_id} directory behind and
nothing removed it. At every tick start — beside the pre-start checks,
before any slot or claim — the Runner runs one bounded, idempotent
reclamation pass (reclaim_released_worktrees): it considers ONLY
registered worktrees under repo_dir/.worktrees whose name matches the
orbi pattern for a CONFIGURED source repo, and removes one with
git worktree remove --force when ALL of the following hold: the Issue
is closed (one batched gh issue list --state closed per involved repo),
its closedAt is at least worktree_retain_hours (deploy home’s
orbi.toml, default 72) in the past, and it carries none of the
in-flight labels (ai-in-progress, ai-pr-opened, ai-fix-needed — a
human closing an in-flight Issue leaves the label, and the scene
survives). A worktree of this process’s bound run (by run id or by the
stop-scene path) is never a candidate, and a closed Issue is never
resumed, so a reclaimed scene is unreachable garbage, never recovery
state. Removals are ordered oldest-closed first and capped at 25 per
tick, so a backlog drains over ticks and no tick spends unbounded time.
The pass is a pure bypass: a GitHub read failure logs
worktree_reclaim_failed reason=... and removes nothing, a single
removal failure logs worktree_reclaim_failed path=... reason=... and
the pass continues, and a successful tick with removals logs the
structured worktree_reclaimed count=N freed=<bytes> line.
Remote delivery-branch reclamation (Issue #1479)
A merged delivery used to leave itsorbi/<slug>-issue-<n> branch on
the remote forever (255 such branches on 2026-09-28). At every tick
start, beside the worktree reclamation, the Runner runs one bounded,
idempotent pass (reclaim_merged_delivery_branches): it lists the
remote orbi/* heads (git ls-remote --heads origin 'orbi/*') and the
source repo’s MERGED PRs (gh pr list --state merged), and deletes a
branch only when its CURRENT remote oid equals a merged PR’s
headRefOid (gh api -X DELETE repos/<repo>/git/refs/heads/<ref>).
The oid match protects a reopened Issue’s new attempt: the delivery
branch name is fixed per Issue, so the new attempt pushes to the same
name while the old merged PR still reports it. A branch whose only PR
is OPEN, CLOSED-unmerged or absent is never in the merged map and is
never touched. Deletions are capped at 25 per tick, so a backlog drains
over ticks. The pass is a pure bypass: a read failure logs
branch_reclaim_failed reason=... (deleting nothing), a single delete
failure logs branch_reclaim_failed branch=... reason=... and the pass
continues, and a successful tick with deletions logs
branches_reclaimed count=N.
Failure recovery
The run artifacts (plan, test log, session JSONL) stay in the task
worktree as the local record; GitHub carries the delivery record.
A run stalls in session_pending before the first model response (Issue #311)
Symptom. The Runner spawns Pi, but the session never creates its
session file and never sends the first model request. The run sits in
session_pending, the idle-stall recovery kills it after 15 minutes
(idle_recovery_stale_15m), and the Issue ends ai-blocked — each
failure burns 15 minutes of the slot and needs a manual re-dispatch. It
hits online providers (reproduced with z.ai and google gemini); a local
model on 127.0.0.1 is not affected. The tell: the session JSONL path is
empty while the pi process is alive.
30-second forensics (on the hung run):
~/.pi/agent/settings.json packages. The pi-mcp-adapter plugin
initializes @napi-rs/keyring → the Secret Service
(org.freedesktop.secrets / kwalletd6) D-Bus call at startup; in the
systemd --user service context that call occasionally never returns,
and node hangs in poll_schedule_timeout before any model request goes
out.
Fix. Remove pi-mcp-adapter from ~/.pi/agent/settings.json
packages (verified: the same run resumes to session_created +
first_response in 15 seconds). The permanent fix is Issue #249 — orbi
declares its own extensions ([[pi_extensions]]) and starts Pi with
--no-extensions, so user-level plugins can never leak into an
unattended session. Upstream: report to pi-mcp-adapter (it must not
initialize the keyring when no MCP server is configured, or do it
lazily).
Concurrency
max_concurrency (default 1) bounds the number of concurrent deliveries
on the machine. A slot is an exclusive flock(2) lock on
<repo_dir>/.orbi/slots/slot-N, taken before any claim and held
for the whole delivery lifecycle (implement → review → merge); the kernel
releases it when the process exits, however it exits. A Runner that
cannot take a slot logs capacity_full and exits without claiming an
Issue. At max_concurrency = 2, both timer instances may start two Runner
instances in the same tick; at 1, only @1.timer is enabled. They compete
for the SAME slots. max_concurrency ranges from 1 to
MAX_RUNNER_INSTANCES = 5; the capacity and enabled timer count are both
max_concurrency.