A machine that runs reviews unattended: the supervisor (review-supervisor.py)
and the cron jobs around it, reviewing every labelled PR around the clock and
publishing the results. How the supervisor works is in
supervisor-design.md; this is how to set a box up and
keep it running.
The reference deployment is a dedicated box: 32 cores, 30G RAM, a few hundred GB free, Ubuntu under WSL2. Cores and RAM matter because a run starts up to eight passes per model at once, several of which build and fly SITL.
Run data and scripts live under ~/review, which is $REVIEW_ROOT:
| path | what |
|---|---|
~/review/bin/ |
the scripts from runner/bin/ in this repo |
~/review/etc/ |
local.conf, routing.json, the crontab |
~/review/data/ |
the store and all scratch. $REVIEW_DATA |
~/review/data/runs/ |
one directory per run, its attempts (worktree, evidence, result) inside |
~/review/data/results/, outbox/, receipts/, membership/ |
accepted reviews, owed deliveries, delivered ones, page rows |
~/review/data/references/ |
the reference clones passes check out from, one per repository |
~/review/data/locks |
the one lock file every run, pass and drain shares. Never replace it |
~/review/repositories/ |
base clones of every reviewed repo, refreshed nightly. $REVIEW_REPOS |
~/review/cache/ |
caches passes share: virtualenvs, uv, pip, npm |
~/review/work/ |
the directory a run starts in, so no base checkout is its working directory; a run refuses to start without it |
~/review/logs/ |
run logs, 30-day retention. $REVIEW_LOGS |
$REVIEW_AUTH is ~/review.auth/ - one directory per account and a symlink per
role, never in git. It is a sibling of ~/review/ rather than a directory
inside it, because the reviewing agent is started with --add-dir $REVIEW_ROOT.
A box set up before that changed moves across with
runner/bin/migrate-auth-root.sh (--dry-run first), which renames the
directory and adds the new deny rule without removing existing protections.
Pause runs (pause-runs.sh) across the migration and deployment. The migrator has its own
$HOME/review.auth default and does not source the deployed environment; for a
custom location pass --auth-root /absolute/path explicitly. It validates every
account before moving, refuses links whose meaning would change, and resumes
unfinished settings updates if the directory has already moved. A completed
migration can be run again safely. Inspect a refusal before releasing the lock.
Historical Codex quota deltas recorded under the old home paths stay blank after the move. Run logs are preserved: a vanished home does not prove ownership of another directory's usage history.
review-env.sh points TMPDIR into $REVIEW_DATA/tmp. On a box where /tmp is
a tmpfs, a bare mktemp -d there fills RAM and takes the machine down with it —
this has happened, so the redirection is not optional.
git clone https://github.com/ArduPilot/APReview.git ~/APReview
mkdir -p ~/review/etc
# bin is a symlink into the checkout, not a copy: `git pull` is then the whole
# deploy step, and the scripts find repos.json beside themselves. A copied bin
# has no checkout to resolve back to, and clone-repos.sh will say so and stop.
ln -s ~/APReview/runner/bin ~/review/bin
cp ~/APReview/runner/etc/crontab.reviewprs ~/review/etc/
cp ~/APReview/runner/etc/local.conf.example ~/review/etc/local.conf # then edit
echo '{"schema": 1, "repositories": [], "labels": [], "modes": ["all"]}' \
> ~/review/etc/routing.json # everything to the supervisor
~/review/bin/clone-ardupilot.sh # base clone with submodules
~/review/bin/clone-repos.sh # everything in repos.json, submodules included
~/review/bin/base-build.sh # proves the toolchain, warms ccache
loginctl enable-linger "$USER" # passes run as user units; see belowThen seed a reference clone under ~/review/data/references/ for every repository
and every ArduPilot-owned submodule, as described in
Supervisor operations: a run refuses to start while one is
missing. Set REVIEW_GITHUB_WRITES=1 and GH_TOKEN in local.conf once you want it
to post. For the accounts, see Accounts and copy
runner/etc/policy.json.example to ~/review.auth/policy.json.
/reviewprs, the single-session slash command, is not used by the runner. Link
commands/reviewprs.md into ~/.claude/commands/ only to review by hand.
Before enabling cron, merge these settings into settings.json in every Claude
account directory a role can select (including ~/.claude if using the default
fallback). Preserve existing settings and deny entries:
{
"permissions": {
"defaultMode": "auto",
"deny": [
"Bash(git push)",
"Bash(git push:*)",
"Read(~/review.auth/**)"
]
}
}For a custom REVIEW_AUTH, review-auth.sh login claude <account> prints the
exact absolute rule. The pre-flight accepts Read(~/path/**) or
Read(//absolute/path/**) covering the auth directory or an ancestor. Bare Read
removes that tool but does not deny the path to other readers such as Grep.
Dot and parent-directory segments are refused: resolving a filesystem path does
not prove that the CLI's pattern matches it. A single leading slash is relative to the settings
source, not the filesystem root. Other glob forms are refused if coverage cannot
be established. These rules are guardrails; arbitrary shell readers can still
reach credentials under the same uid.
On an existing box, update all Claude accounts before pulling this change:
the bin symlink makes that pull the deploy, and missing rules abort every slot.
Then enable cron:
crontab ~/review/etc/crontab.reviewprslocal.conf is where the site-specific settings go: the rsync destination, its
credentials, the public URL reports appear at, the posting token and the alert
address. It is never committed. Accounts
are not in it - they are the directories and role links under ~/review.auth/,
described below.
Running reviews:
bin/run-reviewprs.sh <mode>— the cron entry point.followup|all|rsync| any label. Picks the accounts, runs the pre-flights, freezes the run's configuration (review-run-config.py) and starts the supervisor.bin/review-now.sh <mode|PR>— the same, by hand; a PR asked for by name is reviewed even if it was reviewed recently.--abort <run>stops a run.bin/review-supervisor.py— one run: discovery (review_discovery.py), claims, passes, acceptance and delivery.bin/review-guardian.py— owns one pass: its unit, cgroup, timeout and terminal record. Started by the supervisor, never by hand.bin/review-heavy.sh— what a pass wraps a build or SITL run in, for a build slot;--netnsadds a private network namespace throughnetns-run.sh, so parallel SITL keeps the default TCP ports instead of needing--uds(which changes SITL UART timing and produces false findings).bin/run-reviewprs.sh --resume <run>,bin/review-resume.py— continue an unfinished run from its own record.bin/review-outbox.sh— drains owed deliveries every minute: pages, comments, board.bin/post-comments.py— the comment rules delivery follows: post, edit, or deprecate and repost.bin/review-retire.py— takes a PR off a page that can never be published.bin/pause-runs.sh— stops new admissions; delivery carries on.bin/review-route.py,bin/review-handoff.py— which path owns a target, and the move from the legacy runner.
Accounts and quota:
bin/review-auth.sh— sign accounts in, point roles at them, show what each resolves to.bin/quota.py,bin/accounts.py— what each account has left, and which one a role would run on.bin/check-auth.py— mailsREVIEW_ALERT_MAILwhen a role's account cannot run; see When an account stops working.bin/claude-usage-probe.sh— samples the real usage meter, tagged by account.
Keeping the box healthy:
bin/review-env.sh— source this first. Sets$REVIEW_*,TMPDIR,CCACHE_DIR, the toolchain PATHs,GIT_CONFIG_GLOBAL, the publish variables, and readslocal.conf.bin/review-gc.py— removes build trees, evidence and old runs once nothing can need them.bin/reap-orphans.sh— kills processes a run left behind, and reconciles supervisor attempts by their recorded identity.bin/refresh-repos.sh— nightly refresh of the base clones, lock-guarded.bin/clone-ardupilot.sh,bin/clone-repos.sh— the base clones, submodules included.bin/base-build.sh— SITL + a ChibiOS board; proves the toolchain and warms ccache.bin/make-runs-page.py,bin/publish-runs-page.sh— the runs dashboard. The publisher never fails its caller; what goes wrong (build, upload, lock busy, killed at the timeout) is inlogs/runs-page.log.bin/project-sync.py— the results board; see below.bin/review-context.py,bin/review-baseline.py— what fills each pass's context, and per-run pass counts and minutes.bin/review-pilot.py— freezes, runs and scores a paired quality pilot (quality-pilot.md).
Never replace data/locks. Every lock is a kernel lock on a byte range of that
one file: a PR, a page, a provider's session slots, an account, a build slot, the
reference refresh, a run, the board, pause. They die with their holder and vanish
on reboot, so nothing needs cleaning up after a crash, but a new file with the same
name is a new set of locks, and two runs would each hold "the" lock on one PR. A
stale header written inside it is not a stale lock.
Linger must be on. Each pass runs in its own systemd-run --user unit with a
payload cgroup beneath it, outside the supervisor's cgroup, so a dying supervisor
does not take running passes with it and an out-of-memory kill takes one pass, not
the terminal it was started from. User units die with the user manager when the
last login session ends unless loginctl enable-linger is set. Each unit is
throttled (MemoryHigh) at 80% of RAM divided by the build slots and killed
(MemoryMax) past 40% of RAM.
Every pass gets a two-level working directory. The checkout is
runs/<run>/attempts/<attempt>/wt, a detached worktree of the reference clone, so
autotest.py's ../buildlogs — and the autotest.lck in it — stays inside that
attempt's own directory. Sibling checkouts sharing a parent serialise on one lock,
however well their networks are isolated.
Never --uds. AF_UNIX changes the SITL anti-lag throttle from 1024 to 65536
bytes of outqueue, so tests behave differently from what the PR author sees. The
Claude passes' prompts say so (the Codex passes run sandboxed with no network and
fly no SITL); a pass wraps the test in review-heavy.sh --netns instead — and
wraps the test, not itself: inside the namespace lo is the only interface, so a
CLI started there cannot reach its own model API.
Reference clones are full clones. Discovery fetches each PR's head into the
reference clone, and a pass gets a worktree of it, so nothing is downloaded twice. A
partial clone (--filter) is refused when a pass is prepared.
Each account is a directory under ~/review.auth, and a symlink per role says
which account that role uses. Nothing here is in git — these hold credentials.
auth/claude-ardupilot/ a CLAUDE_CONFIG_DIR auth/claude-default -> claude-ardupilot
auth/claude-personal/ auth/claude-rsync -> claude-personal
auth/codex-work/ a CODEX_HOME auth/codex-default -> codex-work
auth/codex-personal/ auth/codex-rsync -> codex-personal
default covers ArduPilot work; rsync covers the non-ArduPilot target, which
is reviewed on a personal subscription so the project's quota is not spent on it.
review-auth.sh status # what every role resolves to
review-auth.sh list # the accounts available
review-auth.sh login claude personal # how to sign one in
review-auth.sh use claude default personal # move a role to another accountSwitching account is repointing a symlink. When the ArduPilot subscription
runs out of weekly quota, use claude default personal moves the whole workload
across and the next run picks it up; use claude default ardupilot moves it back.
Nothing is edited and no run is interrupted.
A directory may record the address it is meant to hold, in a file called
ACCOUNT — an address or an id, nothing else, so a token put there by mistake is
refused rather than echoed into a run log. A run refuses to start if:
- the role has no link, and it is not
default(a missingclaude-rsyncmust not quietly become the project's subscription); - the link dangles, resolves outside
auth/, or points somewhere other users can read; - the directory holds no credentials;
- its
ACCOUNTdisagrees with what it is signed in as; - the directory's own record and the CLI disagree about the address — two local records agreeing proves nothing about which subscription pays;
- the Codex directory authenticates with an API key. An account id left in
auth.jsonby an earlier subscription login still matchesACCOUNT, but the key is what gets billed; auth/itself is writable by other users, or not yours — private account directories protect nothing if anyone can repoint the links that choose between them;- an
ACCOUNTexists but cannot be read, or the signed-in address cannot be determined to check it against. A constraint must not disappear because reading it failed.
An inherited credential or provider selector decides the account whatever the
role says, so before anything else the run clears every ANTHROPIC_* and
OPENAI_* variable and every CLAUDE_*/CODEX_* one whose name contains
TOKEN, KEY, AUTH, SECRET, CREDENTIAL, CONFIG_DIR, STORAGE,
BASE_URL, HOME or USE_, anywhere in the name, and prints what it cleared -
names only. Naming them one at a time missed CLAUDE_SECURESTORAGE_CONFIG_DIR,
which points the CLI at another credential store while the selected directory
still reports its own address; matching only the end of the name missed
CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR. It clears rather than refuses because
a manual run from a terminal inside Claude Code carries CLAUDE_CODE_* variables
that are not credentials.
A variable declared readonly survives unset, and bash reports that only on
stderr - so the sweep checks each name is really gone afterwards and the run
refuses if one is not, rather than printing it as cleared.
No sweep can be proved complete, so the run also asks the CLI what it actually
did. claude auth status --json reports authMethod, apiProvider and
configDirectory: the run refuses anything but a claude.ai login on the
firstParty provider reading the directory the role selected. A token, a cloud
provider or another config directory all show up there whatever the environment
looked like. All three are required: a CLI too old to report them must be
upgraded. Empty, null, sentinel or control-bearing fields are invalid, not
missing metadata. The CLI must also exit successfully; a JSON body from a failed
command is not proof of authentication. Paths are preserved without trimming
whitespace.
Codex's auth.json must contain subscription tokens and a valid account id;
an id left behind without tokens is not a login. Unsupported authentication
modes are refused. The runner and status share the check of config.toml:
- inference endpoints must use HTTPS on an OpenAI host, with no userinfo, nonstandard port, query or fragment;
- provider credentials, auth commands, headers and query overrides are refused, including inline bearer tokens;
- model providers must be
openai, and the credential store must befileif explicitly selected. A keyring can authenticate somebody other than the account recorded in the checkedauth.json; - unused custom provider definitions are allowed; selecting one in any config
layer or profile is refused. Overrides of the
openaiprovider are checked; - every table is walked, not only the ones that route inference:
[otel]takes an exporter endpoint and its own authorization header.mcp_serversis the exception - an MCP server's endpoint and headers authenticate that server, and a third-party host there is the point of it; - an unreadable, malformed or dangling config is refused. An absent one is fine;
config.tomlis one layer of several./etc/codex/config.toml,managed_config.tomlandrequirements.toml, and the<name>.config.tomlthat--profilemerges, are all checked, and the refusal names the layer.
The refusal names the setting, including in status, without printing its value.
Reported identities are validated before they reach logs.
One layer is deliberately not checked here: Codex also merges project-local configuration from the tree it runs in, and a run works in checkouts of other people's pull requests. Nothing in this pre-flight can settle that - the checkout does not exist yet when it runs - so it needs its own answer in the part of the system that prepares those trees, not here.
Record the account id for Codex and the address for Claude — that is what each tool reports, and the runner compares like with like.
Both tools are checked, not just Claude: a missing auth.json used to surface
only when the validation pool failed, well into a run.
The reviewing agent is started with --add-dir $REVIEW_ROOT and reads other
people's pull requests. The accounts used to live inside that directory, which
is why they no longer do: $REVIEW_AUTH is outside the granted review tree.
Relative paths such as ../review.auth can still reach it. This reduces exposure
without providing containment - the agent runs as the same user, so
file modes stop nothing and a shell reader is not bound by a tool rule. The
permission pre-flight therefore still requires a deny rule covering
$REVIEW_AUTH, alongside the git push denials, and refuses to start without
one; the Install block above gives the exact settings. Note that a rule written
for the old layout - Read(~/review/**) - no longer covers the accounts, and is
refused. The migrator preserves that rule and adds protection for the new root.
A refusal is written to the run log and shows on the
dashboard: these checks used to run before the log was opened, so under cron a
refused run said nothing anywhere and the slot simply went quiet.
status clears the same variables before it asks, and then makes exactly the
judgements the runner makes - including a variable it could not clear, a partial
answer from the CLI, and a redirected Codex config - so a role that reads as
healthy there is one a run will accept. That includes the fallback below: a
default role with no link is reported as the tool's own account, with the same
checks applied, rather than as "not set".
Two things worth knowing. An account directory keeps its address in
<dir>/.claude.json, but only if it was created by claude auth login under
CLAUDE_CONFIG_DIR; the tool's own ~/.claude keeps it elsewhere, so a run
whose role resolves there leaves the variable unset rather than setting it to the
same path, which would make claude auth status report no address at all. That
applies only when the path is genuinely that directory: if ~/.claude is itself
a symlink the variable is pinned to what it resolves to now, so repointing it
mid-run cannot move the work to another account. A missing default link uses
the tool's own directory; every other missing role is refused.
Switching takes a per-role lock. Two switches of the same role could otherwise
interleave, and a switch that turned out to be invalid would revert over one that
had already succeeded. If a revert cannot be made, use says so and names the
account the role has been left on rather than reporting success.
runner/bin/quota.py asks each account what quota it has, and records it. The
selector below reads those records.
quota.py a table of every account
quota.py --json the same as records
quota.py --record append them to $REVIEW_LOGS/quota.jsonl
The dashboard is published, so the identities in it are masked - an address
keeps its domain and an account id its first eight characters, which is enough
to tell the right account from the wrong one and not enough to reuse. The whole
value is on the box, from review-auth.sh status or quota.py.
Cron records a reading for every account hourly, at :05, and the runs dashboard has a Quotas section showing the newest one per account: what is left, the windows behind that figure, when the soonest rolls over, and how old the reading is. The page reads the recorded file - it is rebuilt every ten minutes, and asking an account for its quota starts the CLI, which is how runs and probes come to contend for the OAuth refresh.
Both CLIs publish structured figures, which is worth knowing because the hourly probe still reads Claude's by regex over English prose:
| tool | where | windows |
|---|---|---|
| claude | claude -p /usage --output-format stream-json, usage_report.rate_limits |
session (5h) and weekly, plus per-model |
| codex | codex app-server, account/rateLimits/read |
whatever the plan has - a Pro account reports weekly only |
Two things the structured answers settle that the old sources could not. Codex's
is a live query rather than the last figure a session rollout happened to
record, and it carries ordinaryUsageAllowed and a credit balance - which is
what decides whether running out means stopping or starting to cost money.
Claude's arrives as a window list with reset times rather than a sentence.
free_pct is what is left of the worst window that gates ordinary work.
Per-model windows are recorded but do not count towards it: a spent model is not
an account that cannot start. The app-server shuts down when its stdin closes,
so the query has to hold the pipe open - writing the request and closing it
loses the reply.
A run refuses to start on an account that is signed out, which is safe and
silent: on 2026-10-09 ~/.claude lost its login at 18:05, the 18:13 all run
logged status=wrong-claude-account, and nothing else said so.
bin/check-auth.py runs from cron every three hours and mails
REVIEW_ALERT_MAIL (set in etc/local.conf) when a role cannot run. It reads
two things, and opens no session of its own - using an account can refresh
its token, and two refreshes at once can sign it out:
review-auth.sh status, the sign-in checks a run makes before it starts: a row that is not signed in, or that runs will refuse, is a problem.- the newest hourly probe in
quota.jsonlfor each account a role uses. The probe really uses the account, so a login the server no longer accepts shows up there while it still looks signed in locally. A busy lease, or a reading older than three hours, is not counted.
The mail names each role, why, and the command that signs its directory in
again. It is sent on every check that finds a problem, so it repeats until the
account works. check-auth.py --dry-run prints it instead; the log is
logs/auth-check.log.
It goes out through the local sendmail, from REVIEW_ALERT_FROM when that is
set. Gmail refuses mail that passes neither SPF nor DKIM (550 5.7.26), which is
all mail sent straight from a home address, so the box needs a relay that signs
for the sender's domain. The reference box relays through the site's own mail
server, which already trusts the LAN and DKIM-signs for its domain:
sudo postconf -e "relayhost = [<mail server>]"
sudo systemctl reload postfix
with REVIEW_ALERT_FROM an address in that domain. Without such a server,
relay through smtp.gmail.com:587 with SASL and an app password instead.
runner/bin/accounts.py decides which account a role runs on, from what the
accounts have left.
accounts.py what every role would select, and why
accounts.py --role rsync one role
accounts.py --record append the decisions to $REVIEW_LOGS/select.jsonl
accounts.py --live ask the accounts rather than using a recent reading
accounts.py --select --role R the answer a run acts on: "tool<TAB>name<TAB>dir"
per line, the walk on stderr, exit 3 for nothing
usable
The policy is $REVIEW_AUTH/policy.json, copied from
runner/etc/policy.json.example - an ordered list of account names per tool and
role. Order is priority, and the list is also the whole of what that role may
reach. That second part is the one that matters: rsync reviews a project the
ArduPilot subscription does not pay for, so its lists name only the accounts
that may pay for it, and running out of its own cannot fall through to one that
may not. A missing or invalid policy is refused rather than defaulted.
An account is used when more than min_free_pct is left. Unknown is not spare -
a reading that failed, or one carrying no figure, is skipped rather than tried,
because an unattended run cannot check the guess. Nor is credit: an account past
its included allowance (ordinaryUsageAllowed false) is passed over however
healthy its window looks, because these runs may stop but may not start billing.
Figures come from the hourly recording while it is younger than fresh_minutes,
and from the account itself otherwise: asking starts the CLI, and that is what
makes two processes contend for the OAuth refresh below.
run-reviewprs.sh selects once per run, and the run's configuration freezes the
choice: a resumed run spends the account it started on, even if a role link or the
quota has moved since.
Every check that follows - signed in, subscription login, first-party provider,
the directory the CLI actually read, the ACCOUNT record, Codex's config.toml
- runs against whatever was selected, so nothing is validated any less than when
the role links decided alone. The runner also re-checks the chosen directory
against the same containment and ownership rules a role link must satisfy
(
account_dir_ok), rather than takingaccounts.py's word for the path.
With every listed account spent, the run still starts, but starts no
inference: it logs quota: inference deferred; supervisor will drain deliveries, recovers unfinished work and delivers what is owed, and leaves the
reviews to the next run. Reviewing without the Codex passes was the alternative,
and a review is not worth having without them.
Three ways out of the selector, and they are different:
| situation | what happens |
|---|---|
no policy.json |
the feature is not configured; the role links decide, as before |
policy.json unreadable or invalid |
status=account-policy-error, the run stops |
| policy fine, every account spent | inference deferred; delivery and recovery go ahead |
The second is not the first. A policy that is meant to be in force and is not would leave a run choosing its own account, which is the one thing this must never do.
claude takes a lock in its config directory while it refreshes the OAuth
token, and leaves it behind if the refresh fails - reporting the failure as its
output and exiting zero. Anything that starts next finds it and dies with
"another Claude Code process is refreshing it".
A run clears a stale one before it reads the quotas, and again once its accounts
are chosen and checked. The second clear is the one that matters: between them
the run takes its own usage reading, and claude -p /usage is exactly the sort
of short invocation that meets an expired token, fails to refresh, and leaves the
lock a few seconds before the first pass needs it. A lock is only removed when no
live claude is using that directory. That was the 2026-09-19 19:47
followup and three others in the five days before it - each one a whole slot
lost, three hours apart.
The probe says so now when it comes back with nothing, which it did not: a
non-zero exit was swallowed by || exit 0, and a reply carrying prose rather
than a meter simply recorded nothing. Either way the run log showed no probe at
all, so the only sign was a reading missing from the history.
A review of a generator change can execute its output rather than read it.
bin/install-gen-toolchains.sh installs the compilers; this section is the part
that is not guessable - what each language needs beyond its own compiler.
pymavlink's own CI generates and never compiles, so an output that does not build is not something upstream would have caught.
The definitions are not in the pymavlink checkout (message_definitions is a
submodule that is not fetched). They are in the mavlink clone:
D=$HOME/review/repositories/upstream-mavlink/message_definitions/v1.0
export PYTHONPATH=$HOME/review/repositories
cd $HOME/review/repositories/pymavlink
python3 -m pymavlink.tools.mavgen --lang=<L> --wire-protocol=2.0 -o <out> $D/<dialect>.xml
| language | compiler | builds |
|---|---|---|
| C, C++11 | gcc, g++ | yes |
| Python, JavaScript | interpreters | yes |
| Lua, WLua | luac5.4 -p |
yes |
| TypeScript | tsc --noEmit |
no (see below) |
| Java | javac |
v1 and v2 |
| Ada | gprbuild, then run obj/test |
v1 and v2, minimal and standard |
| CS | dotnet build -f netstandard2.0 |
v1 and v2 |
| ObjC | gcc -x objective-c + GNUstep |
v1 140/144, v2 228/237 (see below) |
| Swift | swiftc |
v1 only (see below) |
| Spin2 | flexspin -2 |
v1 and v2 |
Java needs nothing special:
javac -nowarn -d build $(find com -name '*.java')
Ada builds the test project the generator writes, and the binary runs:
cd <out>/tests && gprbuild test.gpr && ./obj/test
CS is an SDK-style project with NuGet PackageReferences, so the build needs
network the first time. net461 builds too, via the reference-assemblies
package; -f netstandard2.0 alone is enough to compile-check the generated code.
ObjC needs three things the generated tree does not say:
- the C output as well, on the include path: the ObjC is a wrapper over it
and
MVMessage.himportsmavlink.h - every dialect subdirectory as a
-I, becauseMVMavlink.himports each dialect's aggregate header by bare name - Foundation forced in, because no generated header imports it - the code
uses
NSObject,NSDataandNSStringand assumes a prefix header, the way Xcode supplies one
INC="-I."; for d in <out>/*/; do INC="$INC -I$d"; done
gcc -c -x objective-c $(gnustep-config --objc-flags) -include Foundation/Foundation.h \
$INC -I<c-out> -I<c-out>/<dialect> <file>.m -o <file>.o
Swift has no build file; compile the sources as a module:
cd <out> && swiftc -emit-module -module-name MAVLink -o /tmp/m.swiftmodule $(find . -name '*.swift')
Spin2 emits one file, named after --output rather than placed in it, so
-o <out> writes <out>.spin2. -2 selects the P2, and Lib has to be on the
include path or nothing resolves:
flexspin -2 -L /opt/flexspin/Lib -o /tmp/out.binary <out>.spin2
TypeScript writes into <out>/enums and <out>/messages without creating
them, so generation exits 1 with no message unless they exist already. Typecheck
with its own tsconfig.json and no file arguments - naming files on the command
line makes tsc stop at TS5112 before checking anything, which looks exactly
like success:
mkdir -p <out>/enums <out>/messages
cd <out> && tsc --noEmit --skipLibCheck
All six are in master, none is new, and none is reachable without a compiler.
- ObjC, any field with a multi-line description.
mavgen_objc.py:269emits//! ${description}- a one-line comment for text that is not one line, so every line after the first lands in the header as code.MISSION_COUNT'sopaque_idproducesThis field is used when...where a declaration belongs. Eight ofcommon.xml's generated files will not compile:MISSION_COUNT,MISSION_ACK,HOME_POSITION,RADIO_RC_CHANNELS,STORAGE_INFORMATION,AUTOPILOT_VERSION, and the two aggregates that import them. The template line dates from the original generator in 2013; the descriptions grew multi-line later. - ObjC, a field called
description.OPEN_DRONE_ID_SELF_IDhas one, and the accessor collides withNSObject's own-description:redefinition of '-[MVMessageOpenDroneIdSelfId description]'. Nothing in the generator reserves the names the base class already uses. - Swift generates MAVLink 2 that cannot compile.
mavgen_swift.py:148writespublic static let id = UInt8(${id}). Message IDs are 8-bit in v1 and 24-bit in v2, so every v2 message numbered above 255 fails withinteger literal '12900' overflows when stored into 'UInt8'- nine of them incommon.xml, all OpenDroneID. The file has no reference towire_protocol_versionat all: the generator is v1-only and says nothing when asked for v2.test_generate_all.shruns--lang='Swift' --wire-protocol=2.0and passes, because it only generates. - TypeScript has the same multi-line description leak as ObjC. The comment
is
// ${description}on one line, so everything after the first line ofMAV_CMD_DO_FIGURE_EIGHT's description becomes TypeScript. 1761 errors acrossmav-cmd,mav-frame,mav-ftp-err,mav-protocol-capability,mav-standard-mode,autopilot-versionandstorage-information. - TypeScript does not create its own output directories.
generate()opens<out>/enums/...withoutmkdir, so a first run into a fresh directory dies withFileNotFoundErrorthat mavgen reports only as exit 1. - Ada,
common.xmlandardupilotmega.xml.mavgen_ada.py:459assertstypes_size[f.enum] == f.type_lengthand raisesDifferent size for one enum, so generation stops with a traceback before writing anything.minimalandstandardgenerate, build and pass their generated test. Whethergenerator/Ada/v2/test.shever passed depends on the definitions it was run against - the assert is about the XML, not the generator - but against the current upstream definitions it stops on its second dialect.
github.com/orgs/ArduPilot/projects/33 —
every open PR carrying a trigger label that this system has posted a verdict on,
with a sortable Result column (ACCEPT, COMMENT, REQUEST CHANGES) and a
PR Author column. Not Author: that name is reserved, and
createProjectV2Field refuses it with "Name cannot have a reserved value".
bin/project-sync.py makes the board match reality. It takes no arguments and
is told nothing by the run that calls it: it asks GitHub what is labelled, open
and reviewed, and adds, relabels or removes rows to suit. The same command is
therefore correct at the end of a review and from cron a quarter of an hour
later.
project-sync.py what it does on the two triggers below
project-sync.py --dry-run say what would change, change nothing
project-sync.py --show the verdict it reads for each PR, and from where
project-sync.py --prune-only only remove what no longer belongs
Two triggers, both calling bin/project-sync.sh, which cannot fail its caller:
- the EXIT trap of every run, beside the dashboard publish
*/15 * * * *, which is what takes a merged or closed PR off — nothing tells us a PR closed, it simply stops coming back from the search
The posted comment, not the published report. Under the legacy runner each label's report page was overwritten by the next run of that label, so it only ever covered the most recent sweep: measured on 2026-09-26, the reports held a verdict for 35 of the 168 open labelled PRs, against 166 from the comments. Where both had one they disagreed ten times, the report being the stale one. Pages are now rendered from the store and keep every PR, but the comment is still what the PR's author and reviewers see, so it stays the source.
apreview_verdict.py reads it from line 2 of the comment, directly under the
AI-generated marker line:
**Automated review note — AI-generated (Claude+Codex), validated against the live diff.** Please sanity-check before acting.
**Verdict: REQUEST CHANGES**
That line is the whole rule. It is there for the reader first - the conclusion
before the reasoning, where some reports had made it hard to find - and the
board simply reads the same line. post-comments.py refuses a body without it,
unless the plan says the entry is a no-verdict note (a "your code moved"
followup, which leaves the last stated verdict standing). The invisible
<!-- apreview: verdict=... --> marker a few days of comments carried is still
read as a fallback; nothing else is.
It was not always so. Until 2026-09-28 the verdict was recovered from prose, in
seven phrasings surveyed across the open labelled PRs, with anchors, hedge
words and CommonMark stripping to keep "if tests pass, the verdict moves to
ACCEPT" from reading as ACCEPT. Two adversarial reviews each found a fresh way
to read the wrong verdict out of it. verdict-backfill.py is where that parser
retired to: it read every open labelled PR's newest live comment once, put the
line on it (six needed a person to decide), and remains for any comment written
the old way.
The verdict is the newest comment that states one, not the newest comment. A followup note says only that the author's code moved and carries no verdict; treating that as "no review" would drop a reviewed PR off the board. Superseded comments are skipped, so a stale verdict folded into a deprecation cannot outrank a live followup that has none.
A comment counts as ours only if it carries the AI-generated marker and was
posted by one of REVIEW_COMMENT_ACCOUNTS. Author alone is not enough:
commenting moved from a person's account to the bot, so the older reviews are
authored by someone who also writes ordinary comments, and "I would request
changes here" is not a verdict.
A field exists whether or not a view shows it. Creating Result set a value on
all 166 rows and the board displayed none of them, because a new field is not
added to views that already exist - so the sync configures the views too. It
adds what is missing to what a view already shows rather than replacing the set,
so a column someone arranged by hand survives, and a view already showing all of
ours is left untouched.
Column order cannot be chosen. visibleFieldIds is documented as ordered, but
asking for Result, Title, Repository returns Title, Repository, Result:
columns follow the order the fields were created in, so Result sits last.
Not the bot. review-env.sh exports GH_TOKEN so reviews are posted as
AP-Review, and that token is deliberately narrow — public_repo and nothing
else, which cannot even read a project. project-sync.sh unsets it and falls
back to the box's own gh login: writing to a project needs the project
scope, and granting that to the commenting token would widen what a compromised
review run can reach, for the sake of a board whose items carry no author
anyway.
The box login needs the scope once:
gh auth refresh -s project,read:projectUntil then the sync exits 2 and says so; nothing else is affected.
A trigger label is what brings a PR to the board: the label search is how new
reviews are found. Losing the label does not take it off. DevCallEU and
DevCallTopic come off after the call, the PR is still open and still
reviewed, and followups still land on it, so it stays until it is merged or
closed. The candidates are therefore the labelled PRs plus everything already
on the board.
A row the label search did not return is a question, not an answer. Search is capped at 1000 results, its index lags, an org can answer empty, and a transient failure looks the same as "everything merged". So each such row is asked about directly, by the PR's node id - a repository renamed or transferred and its old name reused can put a different, closed PR at the same address. Open: the row stays, and its verdict is re-read from the PR's own comments, so a followup review reaches the board with or without the label. Closed or merged: the row goes. No answer: the row stays as it is.
That check is the protection. The count threshold behind it is an alarm for
something systematically wrong: more than 25 removals, or more than a quarter
of the board, or the last row off a board that had more than one, is refused
with exit 3 and nothing changed. --force-prune overrides it.
Numbers are sent with -F, not -f. -f makes every value a String and
Int! refuses a String, which the caller reads as "cannot tell" - safe, and
useless: it made every check answer unknown, so nothing was ever removed.
Comments are read newest-first and paged backwards until a verdict is found, up to five pages, and not past a cursor that fails to advance. A PR busy enough to push its review out of the newest hundred would otherwise look unreviewed, and lose its row.
Nothing is written before the safety check - not the view, and not the two fields either. A refused sweep that had already created a column was printing "Nothing was changed" untruthfully.
One sync at a time: project-sync.sh holds a lock, because the cron sweep and a
run's exit path can fire together and interleave - the first reads the board,
the second adds a row, the first deletes it as "not in my search". Every gh
call has a deadline, since the sync can run from a run's exit path.
Called from a legacy run's exit trap, the wrapper inherits fd 9 already open on
the run lock. It closes that copy before opening its own lock, and if the sync lock
cannot be opened it logs and skips: otherwise flock would be asked about the
inherited run lock, say yes, and the sync would run with no lock at all. The
lock descriptor is then handed to python, which does the work and so holds the
lock for as long as it runs - a wrapper killed under it must not free the lock
for the next sweep while the first is still writing. Search, board and comment
paging each stop on a cursor that fails to advance.
Reports go out with rsync, either to an rsync daemon (rsync://user@host plus a
password file) or over ssh (host:path, no auth option). Set REVIEW_PUBLISH,
RSYNC_AUTH and REVIEW_PUBLIC_URL in local.conf; comments link to the pages
under REVIEW_PUBLIC_URL. With REVIEW_PUBLISH unset, a run keeps its report local
and says so.
Pages are rendered from the store, not from a run, so a page always shows every PR that belongs on it, whichever run reviewed it last. Each is a delivery in the outbox: a failed upload retries hourly without end, and the comment that links to a page waits until the page is up. What goes where:
| page | what |
|---|---|
DevCallReviews/<label>/devcall_pr_reviews.html |
each label's PRs; DevCallReviews/<YYYY_MM_DD>/<label>/ keeps a dated copy, for a dev-call label the date of its next call |
DevCallReviews/followups/<stamp>/devcall_pr_reviews.html |
one followup run's re-reviews |
<REVIEW_RETAINED_PREFIX>/<owner>/<repo>/<n>/index.html |
a PR reviewed by name (default prefix PRReviews) |
UserReviews/<author>.html |
an author sweep |
RsyncReviews/index.html |
the rsync target |
DevCallReviews/runs.html |
the runs dashboard |
REVIEW_PUBLIC_URL is always used as <url>/<Module>/..., so moving the whole
tree is a change to that one variable and nothing in the code.
Since 2026-09-26 the reports are served from
firmware.ardupilot.org/Tools/APReview,
not from uav.tridgell.net:
REVIEW_PUBLISH="rsync://reviews@autotest.ardupilot.org"
RSYNC_AUTH="--password-file=$HOME/review/etc/rsync.password.autotest"
REVIEW_PUBLIC_URL="https://firmware.ardupilot.org/Tools/APReview"
Three rsyncd modules on that host — DevCallReviews, RsyncReviews and
UserReviews, the same names fjall used, so only the host changed. They write
into /home/autotest/APM/buildlogs/binaries/Tools/APReview/<module>, as
uid = autotest. That path is under the tree the site serves:
/home/autotest/APM is a symlink to /storage/autotest/APM, which is what
firmware.ardupilot.org publishes.
Each module carries hosts allow = fjall.tridgell.net, which is the review
box's egress address — its own hosts allow rather than relying on the file's
global one, so the review credential cannot be used from anywhere else.
Old links are not broken. /home/tridge/UAV-web/.htaccess on fjall carries:
RedirectMatch 301 ^/(DevCallReviews|RsyncReviews|UserReviews)(/.*)?$ \
https://firmware.ardupilot.org/Tools/APReview/$1$2Permanent, because those URLs are in thousands of review comments already posted
on GitHub and cannot be rewritten. The path after the module name is carried
over unchanged, so deep links and their #pr<number> anchors still land in the
right place. The old files are deliberately left in place underneath; nothing
serves them now, but they are the fallback if the new site has to be rebuilt.
$REVIEW_ROOT/etc/routing.json says which targets the supervisor owns; the rest
go to the legacy prompt-driven runner. The reference box has been fully cut over
since 2026-09-29: its routing names the mode all, which hands everything to the
supervisor. A missing file means everything is legacy-owned, and an invalid one
stops admission. The three arrays list supervisor-owned canonical repositories,
labels and modes; repository ownership applies to manual URLs, bare and
qualified PR references, followup, author and all-label discovery. Query it
without starting a run:
. "$HOME/review/bin/review-env.sh"
python3 "$REVIEW_ROOT/bin/review-route.py" rsync
python3 "$REVIEW_ROOT/bin/review-route.py" 'RsyncProject/rsync#123'
"$REVIEW_ROOT/bin/run-reviewprs.sh" rsync --dry-runOn a box where both paths still own something, the prompt's
review-route.py --filter old step is mandatory for every candidate source,
including manifests and board rows, and new discovery applies the inverse
boundary in code. Use @author for author mode. Do not transfer a mutable PR
label as a substitute for the drained repository handoff.
The shared lock file is $REVIEW_DATA/locks. Never replace or unlink it:
ownership is on its inode, with separate OFD byte regions for PRs, pages,
providers, accounts, reference refresh, runs, board and pause. A stale advisory
header is not a stale kernel lock. New runs do not take
$REVIEW_ROOT/etc/reviewprs.lock; old runs still do. pause-runs.sh 60 takes
both pause and the old lock (pause is immediate even while an old run finishes).
pause-runs.sh status and pause-runs.sh resume inspect/signal the recorded
process identity. Delivery continues while admission is paused.
Check linger and the user-systemd backend on a new box:
loginctl enable-linger "$USER"
loginctl show-user "$USER" -p Linger
systemctl --user list-units 'review-attempt-*'Guardian units run independently of controllers and own the PR/provider/account
descriptors and delegated payload cgroups. A controller exit does not kill
another run or its surviving attempts. reap-orphans.sh still kills the old
runner's unregistered leaks by path, since nothing registers them, but skips
anything the supervisor owns (a review-attempt- cgroup or REVIEW_JOB_DIR
in the environment) and reconciles those by recorded identity instead. The
path scan retires with the old runner. Keep new full reference clones under REVIEW_NEW_REPOS
(default $REVIEW_DATA/references) while old mutable clones still exist.
The wrapper freezes account homes (without secrets), model pins, effort,
permissions, granted directories, endpoints, repositories, limits and admission
deadline settings. Codex needs a model pin in the selected account's
config.toml or REVIEW_CODEX_MODEL; its configured sandbox and effort are
also frozen. Up to REVIEW_ACCOUNT_SLOTS (default 8) review
sessions share one login, matching REVIEW_POOL_SIZE (default 8) passes per
provider; concurrent sessions, including a simultaneous token refresh, were
tested safe on 2026-09-30. Builds stay at REVIEW_HEAVY_SIZE (4). Each
attempt's unit is throttled (MemoryHigh) at 80% of RAM divided by the build
slots and killed (MemoryMax) past 40% of RAM; on blu6 that is about 6G and
12G. Sessions hold numbered slots,
not the account key, so quota probes and admission are never blocked by a
running review. Exhausted quota defers inference but still
starts recovery and drains deliveries. GitHub writes in the new adapter need
explicit REVIEW_GITHUB_WRITES=1 in local.conf; installation does not enable it.
"$REVIEW_ROOT/bin/run-reviewprs.sh" --resume "$REVIEW_DATA/runs/<run>"
"$REVIEW_ROOT/bin/review-now.sh" --abort '<run>'
"$REVIEW_ROOT/bin/review-outbox.sh"A publish entry whose page can never exist again (a renamed prefix, a missing rsync module) exhausts its attempts and then blocks the comment behind it, because verification repairs the same intent. Take the PR off that page:
python3 "$REVIEW_ROOT/bin/review-retire.py" --data "$REVIEW_DATA" \
--reason 'PRReviews module never existed' \
'page:review/PRReviews/ardupilot/mavproxy/1764/index.html' 'pr:ardupilot/mavproxy#1764'That marks the membership row removed and unreachable and writes the superseded receipts delivery would have written, so the next drain posts the comment and later generations do not inherit the page.
Resume uses the frozen run, even after role symlinks or models change, and refuses a target transferred back to old ownership. Abort durably records the request before signalling verified boot/PID/start-time identities. It never waits for the run lock; after a bounded grace it escalates those identities and starts bounded cleanup. Accepted results and debts survive. Repeat abort or resume after a cleanup timeout. The outbox drain is bounded and does no inference; each entry uses its own frozen delivery settings.
For the rsync canary, use the exact mirror, dry-run, handoff and rollback
commands in the migration section.
The handoff pauses both paths itself; do not first take a separate manual pause
and then wait on your own pause. It waits for old jobs and new attempts/debts,
imports previous sections, scrubs shared pages, publishes them under page
locks and only then switches routing. Both directions are idempotent and have
--dry-run. A pending publication journal must be completed before reversing.
The full cutover is review-handoff.py new --full --pages <mirror>, with a
--dry-run first; it needs a reference clone for every repository under
$REVIEW_DATA/references (seed them from the old base clones with
git clone --reference <old> --dissociate --recurse-submodules), and one
under its basename for every ArduPilot-owned submodule of ardupilot, which
discovery sweeps without a repos.json entry (seed each from the checkout
inside the ardupilot reference clone: git clone --reference <its git dir> --dissociate <url> <basename>). A run refuses to start while one is
missing. Afterwards
the existing cron lines route to the supervisor by themselves. The new cron
alternatives are commented out in runner/etc/crontab.reviewprs; replace the
appropriate old line explicitly, never enable both.
When a run is stuck, start with runs/<run>/summary.json, then its
attempts/<attempt>/status.json, job.json, payload.log, launch.json and
the unit journal. The dashboard shows PR states, delivery debt, provider,
heartbeat and session-attributed usage. A silent log or old heartbeat does
not prove death: boot/PID/start time and cgroup state decide liveness. Check
outbox/*.json for a dependency, sending/uncertain write or retry deadline;
check receipts before retrying a post. Check account/quota and pool ownership
before assuming a queued PR needs another run. Dashboard generation and rsync
replacement share page:review/DevCallReviews/runs.html.