Skip to main content
Operate Orbi through its timers and journal, and use its documented diagnostics and recovery commands when a run needs attention. Normal operation is fully automatic: the timer triggers a tick, the tick does at most one thing, and progress is published to the journal and GitHub by itself. You never need a status command, polling, or supervision in the normal path — the commands below are for first verification, troubleshooting and recovery.

The timers

The timer template systemd/[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:
  1. Syncs the engine source first (ExecStartPre, outside the Runner process). Two steps, both under a short-lived flock on 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-side freeze_base / verify_pr / merge_gate / confirm_merged fetches go through fetch_base_ref, and the implement/review prompts instruct Pi to run the base-freshness fetch as flock <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 the deploy_home path at install time (defaulting to repo_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 --version succeeds 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: python3 when the system Python satisfies the >= 3.14 floor, otherwise 3.14 provisioned by uv — the SAME rule orbi setup applies, Issue #861). This is the only place that can act when the console script cannot even import 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-source brings the deployment home to the configured engine source update channel. The channel is the host/deploy-only config key engine_source_track in the deploy home’s orbi.toml (a repository’s .github/orbi.toml can never carry it; see Engine source update channel below for the modes and failure lines). The default main channel keeps the exact pre-#535 behavior: git fetch --no-auto-maintenance origin main && git merge --ff-only origin/main — fast-forward onto origin/main. Every fetch passes --no-auto-maintenance so 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 structured engine_source_synced engine_source_track=... resolved=... head=... line. When the channel is anything but the plain main track (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.
  2. Runs one tick: resume an opened PR (the review/fix/merge step) or claim one ai-ready Issue, 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 configured origin remote must match the configured transport (orbi.toml git_transport: ssh — the default, [email protected]:owner/repo.git — or https, https://github.com/<repo>.git with the gh credential helper) for the first configured source repo and git ls-remote <expected-url> must exit 0 (the transport reachable and authenticated). A broken transport logs the structured transport_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 the gh token). 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/main by default — never the delivery target’s base_branch) — see Runner source freshness and the defense layers below.

Engine source update channel

Issue #535 replaces the hardcoded “always follow origin/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):
  • release vs tag: — 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 (main changes 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.sh copies src/orbi/example_config.toml, which ships engine_source_track = "release" — new deployments run the latest release, not the main intermediate state. Orbi’s own dogfood deployment pins the main channel 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 (or release/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 with git -C <deploy_home> stash && systemctl --user start [email protected] (and stash pop later), or discard them with git -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 a FAILED line with the same reason when the channel cannot be resolved locally).
  • GitHub rejects fetching unadvertised SHAs (not our ref), so the sha: 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_branch keeps 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 the ExecStartPre 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__ → the src/orbi package directory, never the WorkingDirectory) to its checkout with git rev-parse --show-toplevel, and requires that checkout’s HEAD to equal the configured engine source channel’s expected commit: the fetched refs/remotes/origin/<branch> head for the main/branch: tracks, the resolved release/tag commit for the release/tag: tracks, or the exact sha: 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 locked release/tag: version itself. A sha: 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 the ExecStartPre sync, 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 a reason= 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. The fix is 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.
The bootstrap boundary (why there are layers). A self-check inside the runner can only protect runner versions that CONTAIN the self-check: a runner old enough to predate it cannot check itself, and the in-runner unit_drift comparison runs inside the very process that may be frozen. The layering is therefore strict, from the outside in:
  1. The ExecStartPre preflight (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 even import orbi is repaired here, before anything depends on it. Keep the installed units current (the unit_drift self-heal of install-units and the pre-start check) so this preflight always exists.
  2. 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 editable uv 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:
Check for a service that was already running before changing files:
The commands above do not delete the checkout, task worktrees, 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

  1. 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.
  2. Confirm the new deploy_home is a persistent directory, clone or move the Orbi checkout there, and update its orbi.toml. Keep repo_dir pointed at the delivery checkout; changing deploy_home must not move or delete that checkout, its worktrees, or its .orbi/ state.
  3. From the new deploy home, install the units and enable the desired timer count:
    max_concurrency = N enables [email protected] through [email protected] (at most MAX_RUNNER_INSTANCES = 5); e.g. max_concurrency = 2 also enables [email protected]. Verify the result with systemctl --user list-timers 'orbi@*.timer'.
  4. If the new deployment uses the same unit_name as 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 changed unit_name, only after the new deployment is verified remove the old unit files. Disable the old timers first as shown above, then run systemctl --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.
If 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:
Before removing anything, inspect the paths and confirm that you are deleting only the user-systemd unit files. This cleanup does not remove the Orbi checkout, the configured 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:
Stable 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). The phase is the startup sub-phase while the first response is outstanding: session_pending (Pi is spawned but has not created its session file yet) and request_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’s model_change record says otherwise), and the elapsed time since the spawn (process_spawned additionally carries the pid). provider_config_loaded is 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 at starting is locatable to its startup phase from these lines plus the progress comment’s phase alone; 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 distinguishable reason= — 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 frozen model_wait killed before any response — the hung first request), model_wait_swallowed and idle_recovery_stale (the existing kill paths). The existing run_failed scene line and the raised failure are unchanged — startup_failed is 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) and previous_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 is resume_continue_failed instead: fail fast, the Issue goes ai-blocked with the reason — never a silent fresh redo;
  • 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 frozen model_wait is confirmed SWALLOWED by the /slots probe (Issue #233): the model_wait_probe_url endpoint reports that EVERY slot is idle (is_processing=false) for the sustained model_wait_probe_seconds grace (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|false and reason=swallowed_model_request. It fires FAST (well before model_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, the model_wait_dead bound still applies) and a slot that is processing resets the idle window (a slow model is not a swallow); disabled when model_wait_probe_url is unset;
  • model_wait_dead — one WARNING plus the kill of the Pi session when a frozen model_wait (the newest event is a tool result, no new session events) crosses the configured model_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 and reason=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 coreutils timeout <seconds> ... wrapper still inside its deadline is a legitimate long-running command, not a hang. The Runner logs one pi_idle_wait line per stall (run id, pid, cmdline, deadline) and reports recovery=wait in 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 at PI_IDLE_WAIT_MAX_SECONDS (default 3600 s) since the wait began, so a declared deadline longer than the cap escalates anyway — never by PI_IDLE_RECOVERY_CYCLES idle 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 retry limit (5) and the session; the slot is held for the whole wait (no new Issue is claimed while this run waits). The attempt count 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 Issue ai-ready after the quota window resets; the terminal clears the persisted counter, so the requeued attempt starts with a full budget), marked reason=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_...s when the /slots probe confirms every slot idle for the sustained grace (Issue #233) and the Runner kills the Pi session fast, model_wait_dead_stale_...s when a frozen model_wait past the configured model_wait_dead_seconds (default 1800 s, Issue #228) declares the model request hung, idle_recovery_stale_...s when the session stayed idle for PI_IDLE_RECOVERY_CYCLES (default 3) consecutive idle windows with no descendant inside its timeout deadline (Issue #1089: an active wait pauses the counter) and the Runner kills the Pi session itself, or provider_rate_limited when a provider 429 survived every in-run backoff retry (Issue #321)).

Event reference

Every structured journal event is emitted through the single emission point journal.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.
All commands accept the config via --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 its max_concurrency for concurrency.
  • source_pool: INCOMPLETE config=... reason=... fix=... — a discovered config is missing, unreadable or malformed. Fix or remove the unit’s ORBI_CONFIG reference; doctor never reports all-clear while a pool is unreadable.
Boundary: only installed units visible to the current user are detected. Hand-started ticks, other Unix users and remote machines are out of scope, so the one-deployment-per-pool rule still applies.

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).
The 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:
A closed target is refused while 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 with auto_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.md when the Milestone has none (the version_file comes from the version_file config key, else the detection; when neither resolves, the step fails and the receipt names the fix instead of guessing pyproject.toml; a missing template falls back to the built-in minimal field set and says so in the ticket), then land active_milestone — in the repository .github/orbi.toml when 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.
No model call is involved: the command is deterministic from the comment, the candidate list and the repository state. The event kinds it logs are 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:
  1. Define a CommandSpec in the command’s own module (/milestone is the first registrant) and call register_command(spec). Registration is the validation: a duplicate name, a verb another command already owns, or a permission level outside PERMISSION_LEVELS fails at import time.
  2. Register the journal event kinds <name>_command_applied and <name>_command_failed in JOURNAL_EVENTS (src/orbi/journal.py) and add their rows to the event reference above; event() refuses an unregistered kind.
  3. Add the command’s row to this table (and the Chinese page): the binding test tests/test_ticket_command.py reads both tables and fails on drift.
  4. Make sure the command’s module is imported before tickets are processed (orbi/milestone.py is the module that owns the call site): register_command runs at import time. The single process_commands(...) call already passes the whole TICKET_COMMANDS registry, 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 reports no_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 same old= 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 /milestone release-ticket step cannot resolve version_file, the notice gets the version_file_unresolved receipt naming the fix; set version_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 orbi tick and the add, suggest, status, doctor, setup, install-units, session, milestone set, check, and sync-engine-source subcommands, including --config and the ORBI_CONFIG environment variable.
  • Suggestions: the document orbi suggest --json prints (repo, base_sha, session_dir and up to three suggestions), the kept run directory <deploy_home>/.orbi/suggest/<run_id>/ and the skill integrations/claude-plugin/skills/suggest-ai-ready-issues/ that produces it (Issue #1576); the orbi setup offer 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/env file loaded by the systemd EnvironmentFile.
  • Deployment: the systemd/[email protected] and systemd/[email protected] templates and their [email protected] instances (N = max_concurrency, at most MAX_RUNNER_INSTANCES = 5).
  • In-ticket commands: the /milestone <title> comment on a milestone confirmation Issue (Issue #1290).
  • Labels and lifecycle: labels.toml together with src/orbi/delivery_labels.py, and the ai-ready, ai-in-progress, ai-pr-opened, ai-fix-needed, ai-merged, and ai-blocked state machine.
  • Observability: journal lines begin with [run_id] and use stable event names such as run_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 visible run_id=<run_id>; PR bodies also require Fixes #<N>.

Worktrees and base freshness

Each claim freezes origin/<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 its repo_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 its orbi/<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):
Root cause. The per-run agent dir materialization (Issue #303) makes the Runner’s Pi load the user-level plugins declared in ~/.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.