Operations
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 templatesystemd/[email protected] is deployed as TWO
instances, [email protected] and [email protected]. 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 two independent
Runner instances can run concurrently. Each tick’s service instance:
- Fast-forwards the code first (
ExecStartPre, outside the Python process):git fetch origin main && git merge --ff-only origin/main. A dirty checkout, a failed fetch or a non-fast-forwardable state fails the preflight: the service does not start and the reason lands in the systemd journal (fail fast). A currently running long task is never hot-updated or killed — while one service instance is active, systemd ignores further starts of THAT instance, and the next real start picks up the latest code. Because two instances may runExecStartPrein the same tick, the fetch + fast-forward is wrapped in a short-livedflockon the shared state-dir lock file (base-sync.lock, the Python-side sync takes the SAME lock): the main worktree is never written concurrently. 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(acquire lock → fetch → release, fail fast on lock or fetch errors), and the implement/review prompts instruct Pi to run the base-freshness fetch asflock <BASE_SYNC_LOCK> git fetch origin <base_branch>(the lock path is rendered into both prompts from the configuredrepo_dir). Two concurrent Runners — or a Runner and a Pi session — therefore never race the shared ref (nocannot lock ref ... is at <X> but expected <Y>failures). - Runs one tick: resume an opened PR (review/fix/merge) or claim one
ai-readyIssue, then exit. Before any slot or claim the tick also runs the pre-start git transport check (Issue #114): the deployment checkout’s configuredoriginremote must be SSH for the first configured source repo andgit ls-remote <ssh-url>must exit 0 (SSH 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 HTTPS fallback (git data operations, including.github/workflows/*.ymlpushes, always go over SSH; GitHub API operations stay on theghtoken).
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 next timer
trigger’s ExecStartPre fast-forwards the checkout, 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 the two timer instances — 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 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 (muyan-pilot.service /
muyan-pilot.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.
muyan-pilot install-units --config muyan-pilot.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);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;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: ...);run_failed— the full scene plus the reason (pi_exit_N,timeout_...s,upstream_dead_stale_...swhen a frozenmodel_waitpastPI_MODEL_WAIT_DEAD_SECONDS(default 600 s) declares the upstream model dead, oridle_recovery_stale_...swhen the session stayed idle forPI_IDLE_RECOVERY_CYCLES(default 3) consecutive idle windows and the Runner kills the Pi session itself).
ai-blocked path.
The CLI (muyan-pilot)
The official entry is the installed muyan-pilot console script, as an
editable uv tool install (the official local deployment — the
tool env imports muyan_pilot from the clone dir, so the
ExecStartPre checkout sync is picked up by the next CLI process
automatically and ordinary source/template changes need no reinstall or
upgrade; uv tool install --force --reinstall --editable --python /usr/bin/python3 <clone dir> — see Getting started).
muyan-pilot doctor reports the consistency (cli_source: clean or
cli_source: DRIFT with the structured cli_source_drift line: actual
import path, expected repo_dir, the exact editable reinstall command),
and muyan-pilot setup verifies or reinstalls it. The
direct-execution entry of muyan_pilot.py stays a development/
compatibility path.
--config or the
MUYAN_PILOT_CONFIG environment variable (default
muyan-pilot.toml). status and session are debug attachments — the
journal and GitHub remain the normal observability path.
Worktrees and base freshness
Each claim freezesorigin/<base_branch> (fetched first) and creates the
task worktree and feature branch from that exact SHA — never from the
main worktree’s current HEAD. Branch and worktree names carry the run id
(e.g. .worktrees/<...>-issue-14-e07383c2), so a retried Issue gets a
new independent run and the old scene is preserved. .worktrees/ is
gitignored.
The task worktree shares the deployment checkout’s single origin
remote (a git worktree add worktree inherits the main repository’s
remote configuration), so the git transport is configured once on the
checkout and every worktree inherits it: new bootstrap worktrees have
an SSH git remote -v by construction, and their fetch/push —
including .github/workflows/*.yml — go over SSH (Issue #114).
Before creating the PR, the implementer re-fetches the base: if
origin/<base_branch> advanced, it merges the latest base into the task
branch, resolves conflicts manually, reruns the full test suite, and only
then pushes. The Runner verifies with
git merge-base --is-ancestor origin/<base_branch> HEAD and rejects a
delivery whose head does not contain the latest remote base.
Failure recovery
The run artifacts (plan, test log, session JSONL) stay in the task
worktree as the local record; GitHub carries the delivery record.
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>/.muyan-pilot/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. The two timer instances may start two Runner instances in the
same tick; they compete for the SAME slots — the capacity is
max_concurrency (with the default 1 the behavior is exactly the
pre-#149 one), never the instance count.