Autonomous cycle
SkillAI & modelsRun AL Runner development unattended, a never-idle loop that keeps exactly one agent working at a time, in a fixed priority order, across both the runner and the corpus repository. Starts with a preflight that refuses to run on a box that would produce wrong answers, paces itself against a weekly token budget rather than filling each window, and routes anything needing human judgement to a queue instead of guessing. Use when starting an unattended session; not needed for interactive work.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Autonomous cycle skill
What this skill tells your AI
The instructions your AI receives, as published by stefanmaron/businesscentral.al.runner in .claude/skills/autonomous-cycle/SKILL.md and read by ahel’s review.
What the loop is for
The runner exists to behave exactly like Business Central. Tests are not overhead and not a box to tick — they are the only thing that establishes that claim, and only the corpus establishes it against a real service tier. A runner-local test that passes proves the runner agrees with itself; it says nothing about BC.
So the measure of a good cycle is not how many issues it closed. It is whether the proof that the runner matches BC got larger and stayed green. The corpus is a ratchet: every behaviour pinned there is validated on real BC on every push and can never silently regress afterwards. As long as the corpus stays green and the runner stays green against the corpus, the runner is doing its job — and anything not pinned there is a claim nobody is checking.
Read every priority below through that lens. A fix that closes an issue and adds no upstream coverage has moved a number; a fix that lands with a corpus test has moved the guarantee.
The test for whether a fix owes a corpus test is wider than "is this a claim about BC?":
wherever it is possible to red-test something with AL tests, that should add tests to the
corpus — that is, wherever the fix can be proven by AL running against a real service tier. A
BC-behaviour claim is the common case, not the whole rule. The service-tier clause is what keeps
the rule legal: runner-only claims are red-testable in AL too — out-of-scope reasons, AL-output
cache HIT/MISS, provisioning-gap messages, exit codes — and they stay in tests/runner-extras/,
because the corpus does not know about AL Runner and must stay that way
(al-language-submodule.md).
The runner fix is still the priority, and the work never stalls on the corpus. Open the corpus PR and keep the runner fix moving — implemented, pushed, reviewed — rather than idling until the corpus legs report; priority 3's merge bar decides when it may merge, and nothing before that waits.
Working unattended
This runs without anyone watching. That changes what matters: not throughput, but never producing confident wrong work. An unattended loop that files issues from a poisoned cache, or merges on a stale verdict, does not just waste a window — it fills the backlog with plausible garbage that a human then has to unpick.
So the loop is deliberately narrow: one agent working at a time, in a fixed priority order, behind a preflight that can stop everything.
Why one agent at a time
Measured on 2026-09-05, a coordinator ran nine agents in parallel for about ninety minutes. Throughput was real, but every finding of lasting value came from depth, not parallelism: 4,905 Base App members measured to pin an identifier-mangling rule to exactly seven names; the discovery that every Base App report's lifecycle triggers were silently empty; a cache-poisoning diagnosis narrowed to a derived tier whose key has no term for what it was derived from.
In the same session at least four confident conclusions were wrong and were caught only because a human pushed back or because something was re-measured. Parallelism multiplies that risk. Serial work with a review step does not.
Exactly one implementation agent runs, plus one reviewer alongside it. That pairing is the default and the human changes it at session start, not the loop. One implementer produces at most one PR at a time, so a single reviewer always outpaces it and review cannot fall behind by construction. When the implementer returns, you act on it and start the next; when the reviewer returns, you start the next reviewer rather than a second implementer.
Reviewing is not overhead, and it is where queues actually stall
The default is one implementation agent and one reviewer. Not a ratio to compute — a baseline to start from, changed only by the human at session start. One implementer produces at most one PR at a time and one reviewer clears roughly four an hour, so review cannot fall behind by construction, and the pile-up this section describes never begins.
The measured throughput below is what to scale by when a human raises the concurrency, not a license to raise it. At six implementation agents you need roughly two reviewers to hold steady; work that out from the numbers rather than adding implementers because slots are free.
When a coordinator does run several agents at once — the attended mode this skill's serial rule does not cover — the thing that breaks first is review, not implementation. Measured across one attended session on 2026-09-06:
| agent | work | wall | rate |
|---|---|---|---|
| reviewer (runner PRs) | 6 PRs in one pass | 93 min | ~15.6 min/PR |
| reviewer (corpus PRs) | 3 PRs in one pass | 64 min | ~21 min/PR |
| implementation agent | 1 PR each | 35-85 min | ~1 PR/hour |
So one reviewer sustains roughly 4 PRs/hour, and six implementation agents produce 5-6. The queue grows by arithmetic, not by anyone choosing badly. The balancing ratio is about one reviewer per four implementation agents, and a coordinator that spawns implementation agents whenever a slot frees will fall behind indefinitely without ever making an obvious mistake.
Count an open unreviewed ready PR against the concurrency budget, exactly like an unfinished implementation (a draft is the claim of an implementation already counted). A coordinator running 6 implementation agents with 6 unreviewed PRs is running at 12, not 6, and should stop starting new work. This is the accounting that makes priority 3 below fire on its own instead of needing to be remembered — the priority order already puts "a PR is waiting on review" above "an issue is ready to work", and it still got skipped, because starting an implementation agent feels like progress and starting a reviewer feels like overhead.
Review in batches of three or four, not one at a time and not six or more. Both extremes were measured:
- Batches that are too large go stale. A batch of six took 93 minutes; during it, three PRs from the original brief merged and two of the remaining six had their head SHA move. Two of six verdicts came back "no verdict on current head" — a third of the batch wasted. Review takes ~15 min/PR and heads move every 30-60 min under load, so the batch has to finish inside the window in which its subjects hold still.
- Batches of one lose the findings that matter most. The single most valuable result in that session was cross-PR: two PRs bumped the same submodule pin to different revisions (a shape #3737 removed by dropping the pin), conflicting three ways, and the reviewer worked out which had to merge first because its revision was an ancestor of the other. A one-PR-at-a-time reviewer cannot see that, and neither can the coordinator, who is not reading the diffs.
Arming is defined once, in the orchestrating-a-session skill under "A reviewer that
approves a PR arms auto-merge": the command, the conditions, the head to record. Run that
list.
Keep one reviewer continuously alive rather than spawning one when a queue becomes visible. Reactive spawning is what produces the pile-up: by the time the queue is obvious it is already several PRs deep, and the batch needed to clear it is large enough to go stale. Start the replacement when a reviewer returns.
A review verdict must name the head SHA it was made against. Heads move under a review in
minutes when other loops and outside contributors are pushing. A verdict without a SHA cannot be
checked for staleness, and merging on a stale one has already nearly merged a commit whose CI was
red. Re-read the head immediately before merging and pass --match-head-commit, so the merge
refuses rather than silently taking something else. The SHA is the head in the verdict line
(.claude/agents/reviewer.md, "The verdict line"); the arming list compares it.
These numbers come from a single session and review time varies with PR size. Re-measure with
tools/agent-cost.py before treating the ratio as fixed.
Preflight — run this first, every time, and stop if it fails
A fresh or drifted box does not announce that it is broken; it produces numbers that look fine. Every check below exists because its absence has silently corrupted a result.
Run tools/preflight.py --agent-id <AGENT-ID>. It is this section, executable, with a
real exit code — 0 all passed, 1 something failed and this box would produce untrustworthy
results, 2 warnings under --strict, 3 it could not complete. --json for a box profile,
--reap to remove worktrees whose PR is merged and whose tree is clean, --with-corpus to
include step 1.
--agent-id is what lets the branch-ownership check answer at all — with no identity it
can only WARN (#3746), so every documented invocation carries it. It travels as an argument
and not as an exported AL_RUNNER_AGENT_ID because shell state does not survive between tool
calls (impl-agent.md): an export is gone by the next command and the check drops back to
WARN without saying so. From the main checkout there is nothing to compare and the check
PASSes; it earns its keep from a worktree, which is where impl-agent.md runs it.
The prose below stays as the specification and the reasoning; the script is how it actually gets run, because a check a busy coordinator can decline is not a check — one skipped step 5 across an evening of ~20 agents and filled a 7.7 GB tmpfs, after which every shell on the box failed without naming the cause.
Two things about the verdicts it produces, because both change what "stop" means:
- Exit 3 now also means the running copy of
preflight.pyis stale —origin/mainmoved that file after this checkout branched, so it would report out of an older rulebook without saying so (a copy predating #2936 calls a healthy box unable to push). It prints the remedy; runorigin/main's copy. Nothing about the box was probed, so it is not a verdict about the box. - The
checkoutcheck reports how far the tree it is measuring is behindorigin/main, for both the checkout preflight is running from and the main checkout, and WARNs past 25 commits or a branch point 12 hours old. Anything read out of a checkout is as old as that checkout: a coordinator once diagnosed a repository-wide CI breakage from a tree 40+ commits behind, in which a tool still carried a constantorigin/mainhad already renamed, and misdirected four agents before the tree was the suspect. - A single network failure is not a verdict either. The push probe retries a transport
failure and reports
1 of 3 attemptsas a WARN rather than failing the box; a genuine refusal — permission, authentication, no such repository — still FAILs on the first attempt and is never retried (#3076). Thegithubcheck WARNs when the token has noworkflowscope, which is not a reason to stop: it means pull requests touching.github/workflows/need a human to merge them, and the loop must not route them differently (#3192).
-
Known-good baseline — run the corpus.
tests/al-languageis green or it is not, which makes it the right health check: a box that cannot run it clean is a box whose results cannot be trusted. Check it out first (tools/corpus-checkout.py) — it is resolved per run and not committed (#3737), and the SHA it prints is what any local corpus result is about.Do not gate on a Microsoft bucket's pass count. There is no green there — it is a number that rises as the runner improves, so equality-gating on it would halt the loop on its first success, and recording "whatever this box last saw" would ratify drift instead of catching it.
Run it against the shared cache the work will actually use, not a private one. The failure this catches is a cache left inconsistent by a killed run, which once cost 76% of passing tests with no error and an unchanged exit code — a private cache is blind to exactly that.
The verdict is the numbers, not the exit code.
preflight.py --with-corpusenumerates the corpus apps the way CI does, then fails on any test that failed, any lost suite, a missing summary, a timeout, and on the run's own summary disagreeing with its per-bundle PASS lines. A non-zero exit adds a failure; it can never grant a pass. It used to be the only thing checked, which made the check blind to the one failure it is named for (#3357).It no longer compares against a checked-in expected count: with the corpus resolved per run, a committed number would go stale on every upstream merge (#3675). CI holds that comparison instead — each leg counts what it ran and compares against the last count a
mainrun recorded, naming both corpus SHAs on a drop.If it does not reproduce: stop, notify, and open an issue. Everything downstream is untrusted until it does.
-
Push works.
git ls-remote origin HEAD. Push auth fails silently when it is routed through an interactive credential agent. -
Commits work. Either signing succeeds, or signing is off. A locked signing agent makes
git commithang forever rather than fail — an unattended loop simply stops there. -
GitHub permissions. Probe what this account can actually do — merge, label, assign, close. Do not assume, and do not branch behaviour on who is running: the loop behaves identically for everyone. A missing permission is a precondition failure to report, not a second mode to implement.
-
Headroom. Read free RAM and disk, and derive worker and job counts from them. Never hardcode. Set
MemoryHighbelowMemoryMaxon any long run so a cgroup throttles before the kernel's global OOM killer starts choosing victims elsewhere on the machine. -
Claim a label slot, do not just derive one. The account gives you a namespace; a slot number makes you unique within it, because one person may run several loops at once.
Derive the namespace from the account the loop is logged in as —
gh api user --jq .login— since logins are already unique, so a tag derived from one inherits that for free. Uniqueness only comes back into question when you abbreviate, and an abbreviation is what makes a readable label, so verify rather than assume: list theagent:labels already on the repository and confirm your intended namespace does not appear on issues or PRs belonging to someone else. On a clash pick another and re-check, or stop and report it.Then take the lowest free slot —
<tag>-1,<tag>-2, … — by asking the repository, which is the only state two loops share:- A slot is taken if any open issue or PR carries that label and is still being worked.
- A slot is free if its label exists but nothing open carries it, or it does not exist.
- Claim the lowest free one, then re-read. Two loops starting together will pick the same slot; if another loop's work appeared under yours, take the next and re-check. Same compare-and-swap as issue claiming, and for the same reason.
Keep your label on whatever you are working, so a loop starting up can see the slot is live. Between units you hold nothing, so a concurrent startup may pick the same slot — that is what the re-read above is for. Do not read the assignee as the lock that makes up for it: where every loop pushes as one account, the assignee cannot say which loop holds an issue, and an open PR carrying
Closes #Nis the signal that decides it (#2891). Do not try to judge whether someone else's open work is "still being worked" either: you cannot tell a dead box from a contributor who is asleep, and the design refuses that judgement elsewhere for the same reason.Note the slot is bookkeeping, not safety. The incident it is often credited with preventing —
impl-69, 82 worktrees, 10 GB — was caused by nothing ever deleting a worktree. Preflight's stale-worktree check is the actual fix for that.Use that identity in labels and branch names; worktrees add the issue number and scratch, cache and clone paths add the issue and a session token (
.claude/agents/impl-agent.md, "Namespace every path you write to"). Several loops can then run under one account, and several accounts against one repository, without ever writing the same name.The existing
agent: impl-Nconvention is the counter-example worth avoiding: a global counter with no owner, which drifted toimpl-69while leaving 82 worktrees and 10 GB of disk behind. Slots numbered inside an account namespace cannot drift that way — they are reclaimed by the next loop that starts, instead of incremented forever. -
No stale worktree for this identity, and no leftover scratch directories from a killed run.
-
Code-navigation tooling answers correctly — the C# language server behind
tools/lsp-query.py,graphify, and the bc-decompiler MCP server. Not "is it installed": each of these fails by producing a confidently wrong answer rather than an error, so the check is whether it returns a known-good answer that the checkout is verified to contain.tools/lsp-query.pyexits 2 when the server never answered. Read as "nothing calls this", that is a false negative wearing the shape of a finding..mcp.jsonchanges need a session restart, so "configured" and "usable right now" are different states and only the second is worth anything. Preflight establishes the second by speaking MCP to the server directly; a session still has to restart before it can call it. How to install and register the server, and why a context needs provisioned artifacts first, is inCLAUDE.md§ "Code navigation" (2c) — this step verifies, it does not install.
Each probe also validates its own fixture against the working tree first, so a renamed symbol reports as "this check's probe drifted" rather than as a broken language server.
Repair the graph, do not report it.
graphify updateandgraphify queryboth default tographify-out/graph.jsonrelative to the current directory, and two things follow:- A stale graph is rebuilt, not classified. It costs about two seconds warm, and telling a human to run a two-second command is asking a person to do a script's job — in an unattended loop there is no person to ask.
- A stray
graphify-out/outsideAlRunner/is deleted. It is gitignored derived data, and while it exists a query run from the repository root reads it instead. Measured: an 18-day-old root copy answeredNo matching nodes found.— exit 0 — for a symbol that exists, while the correct graph returned 11 nodes. A rebuild underAlRunner/never touches it, so the two diverge indefinitely; that is the 13-day-stale incidentdocs/incidents/CLAUDE.md.mdrecords. Both repairs are reported rather than folded into a silent PASS.
Severity: WARN, with one exception. A tool that is absent or unusable does not halt a cycle — each degrades to a documented fallback (
rg,tools/context-pack.py) that still produces correct work, so it makes an agent slower rather than wrong, and--strictis where zero tolerance belongs. The exception is a stray graph that could not be deleted: that is not a missing tool, it is a tool confidently answering wrong, with no fallback and no rebuild that fixes it. That one FAILs. Keeping FAIL for "this box produces wrong answers" is what keeps FAIL worth reading.
Print the preflight as a readable report — one line per check, PASS or FAIL with the reason and the command that produced it. That report is what a new contributor reads to find out whether their box is set up correctly, so write it for a person who has never run this before, not as a log line. A FAIL should say what to do about it.
Record the result. If a later cycle behaves oddly, the first question is whether the box drifted since.
Pin the model on every dispatch
Every agent definition here is pinned to Opus, but pass the model explicitly when you dispatch
anyway. Frontmatter is easy to overlook and a default is silent: impl-agent and
orchestrator sat on model: sonnet for a long time, so an unattended loop would have run its
implementation and its merge decisions on the smaller model without anything saying so.
The coordinator loop itself, and every agent it spawns — implementation, review, triage — run on Opus, at high reasoning effort where the harness exposes it. This work is diagnosis: today's findings came from decompiling BC to pin an identifier rule to seven names out of 4,905, and from separating a cascade of 47 failures into one defect. That is not throughput work, and the cheaper model is a false economy when a wrong diagnosis becomes a filed issue nobody can trust.
If you cannot confirm what the running model is, say so in the cycle's report rather than assuming the pin took.
The box profile
The preflight measures the machine and records what it found. Everything downstream reads that instead of re-measuring or guessing, and it is what makes the same skill work on someone else's hardware without being told anything about it.
Write it to a gitignored file — it describes this box and this loop, not the repository, and
it must never be committed or shared. Suggested .claude/autonomous-state.json, already ignored.
What belongs in it:
- The machine — total and available RAM, free disk, core count.
- What those imply — the worker and job counts derived from them, so later cycles do not re-derive numbers inconsistently. Roughly 1.1 GB per worker without test data and ~2.3 GB with it, but derive from what you measured, not from those figures.
- The identity — the account, the label namespace and the slot claimed at startup.
- The baseline — which bucket was run, the expected count and the count observed, with a timestamp. That is the record that says this box was healthy at a known moment.
- The preflight verdict — every check with PASS or FAIL, so a later cycle behaving oddly can be compared against a known-good starting state.
- Pacing observations — how much budget a cycle actually consumed, so the gap between cycles can be tuned from evidence rather than guessed.
Keep two files, because they have opposite lifetimes:
- The measured profile is rewritten at every startup. A box changes — disks fill, other work starts, an artifact set goes stale — and a stale measurement is worse than none because it looks authoritative.
- The cycle log is append-only and survives restarts. Without it, three things the design depends on are impossible: knowing when the baseline last passed (so it can run on an interval instead of every cycle), comparing an odd cycle against a known-good starting state, and detecting "the same failure several cycles running" — which is one of the three conditions that is supposed to notify a human, and is undetectable if each startup erases the evidence.
Record per cycle: what it worked, the outcome, the failure signature if any, and what it consumed.
Priority order
Work the first item that applies. Re-evaluate from the top after every completed unit of work —
a merge can turn main red, which outranks everything you were about to do.
mainis red. Nothing else matters. Read the failing log (never re-run a failed job — it destroys the log), diagnose, fix.
Shortened here. Read the whole file on GitHub.
Signals
- GitHub stars
- 36
- Forks
- 13
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
autonomous-cycle- Source
- github.com/stefanmaron/businesscentral.al.runner