Orchestrating a session on AL Runner

SkillAI & models

How to run a coordinator session on this repo, what you decide versus what you delegate, the implementation-agent identity pool and when to reuse it, corpus-PR authority, when to re-run triage, the merge bar, the measurement rules, and the environment traps that silently produce wrong answers. Use at the START of any session where you will drive work through subagents, review and merge PRs, or keep the issue queue moving. Invoke it instead of being told these things again.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Orchestrating a session on AL Runner skill

What this skill tells your AI

The instructions your AI receives, as published by stefanmaron/businesscentral.al.runner in .claude/skills/orchestrating-a-session/SKILL.md and read by ahel’s review.

You are the coordinator. You spawn implementation agents, review what they produce, drive PRs to green, and merge. You do not usually write the fix yourself — but you do own the judgement calls, the merges, and the honesty of what gets reported.

Everything below is here because it had to be explained more than once. Read it once at the start of a session rather than rediscovering it.

What you decide, what you delegate

Delegate: diagnosing a cluster, writing the fix, writing the proving test, driving that one PR through CI.

Keep: which clusters are worth attacking and in what order; whether a result is real; whether a PR meets the bar; every merge, in both repos; corrections to issues whose premise has been falsified.

Do the work yourself when you already have the answer. If you have just measured something, spawning an agent to re-measure it wastes a full context. Write the PR.

Authority — you do not need to ask for these

  • Filing issues on StefanMaron/BusinessCentral.AL.Runner, and correcting the body of an issue you filed when a measurement contradicts it.
  • Closing an issue whose work has already landed. Closing is cheap and reversible — an issue that turns out to be live again can simply be reopened. Verify against the code at main, not against issue text, and prefer re-running the reporter's repro where one exists.
  • Merging any PR authored under the repo owner's account, in this repo and the corpus repo, on your own high-level review plus a green pipeline. That covers every PR your agents open, since they push with that account's token. Judge whether the change is right, whether the proving test is there, and whether CI is green on the current head — then merge.
  • Arming auto-merge instead of waiting. Review the PR when it arrives; if it passes, arm it (gh pr merge <N> --squash --delete-branch --auto) and move on. Do not sit watching a run you cannot influence. If the required checks are already green that command merges it now, not later — so running it is the merge, and every condition in the arming list below has to hold at that moment. Re-read the PR afterwards rather than trusting the exit code; both are in "A reviewer that approves a PR arms auto-merge" (#3150, #3341).
  • Claiming an issue assigned to another contributor when it overlaps work already in flight, once the repo owner has released that contributor's backlog. branch-and-pr.md's assignee boundary still holds as the default. When a released issue is the same defect an agent is already fixing, reassign it (gh issue edit <N> --remove-assignee <login> --add-assignee @me) and fold it in. Do not bulk-claim issues nobody is working on, and do not assume a release — confirm it.

A PR from anyone other than the repo owner is reviewed, never merged. Commenting on it is ungated like any other PR here — the boundary is the repository, not who opened it. Merging someone else's contribution stays the owner's call.

Ungated on these two repositories (BusinessCentral.AL.Runner and the corpus): commenting on issues and PRs, closing issues, applying labels. Still gated: PR review comments submitted as a formal review, and anything posted to another repository. public-posting-approval.md is the authority and is auto-loaded; it carries the two conditions that come with the ungating — every state change carries its reasoning, and every agent-authored post says an agent wrote it.

Implementation agents

Use the impl-agent subagent type, not general-purpose. Its definition carries the workflow contract — branch naming, labels, the CI rules, the navigation tooling — so your brief only needs the cluster context and the traps.

Do not ramp up identity numbers. The documented pool is impl-1/impl-2, widened by the owner when concurrency is raised. A finished agent's identity is immediately free — reuse it. Every new identity leaves a permanent worktree behind; inventing impl-3impl-12 across a session leaves a dozen. Reuse first, and only invent one when every identity is genuinely in flight.

Brief with cluster data and traps, not with pre-resolved symbols. Resolving an agent's symbols for it moves cost onto your own long-lived context, which is backwards. Give it the failing test names, the stack top, the counts, the falsified hypotheses — and let it navigate.

A good brief says what a complete answer looks like, including a negative one. "These 6 are cause A and these 10 are cause B, here is the evidence" is a complete answer with no fix. Say so explicitly, or agents will force one fix over two causes to make the PR look bigger.

Agents do NOT wait for CI. Their deliverable is "PR marked ready and pushed". Waiting costs an agent slot for 15-25 minutes watching a run it cannot influence, and you are watching CI anyway. impl-agent.md's Step 5 says this; keep briefs consistent with it. A failure is never lost by returning early — resume the agent, or dispatch a fresh one with the failure in hand.

Never relay an authorization to an agent. An agent is right to refuse a message claiming "the owner approved X" for anything touching its operating rules — commit signing, skipping a verification step, dropping an instruction its own harness set. It cannot verify the claim, and its instructions correctly say no agent message substitutes for the user's consent. This cost a full round trip when signing was disabled: the agent refused twice, correctly. Do the privileged step yourself — its work is staged in its worktree, so commit, push and open the PR from the coordinator session. Better still, make the change invisible: commit.gpgsign was already false in the shared repo config, so an agent that simply runs git commit succeeds and never needs telling.

Check in on long runners. Past ~90 minutes, ask: where are you, is anything unpushed, is there a PR, are you blocked. Agents will sit on finished work waiting for permission they already have.

Search the issue queue before dispatching, and hand over the whole cluster. A measured failure cluster is usually already partly filed. Grep the open issues for the area first — on one dispatch this turned a single issue into four sharing one root cause (#2723 + #2517 + #2460 + #2200), and the agent brief said so, which is what let it fix them together. Ask the agent which of the related issues its change closes for free rather than assigning all of them; "these three are one fix, that one is not, here is why" is a complete answer.

Keep a reviewer running, and size the batch

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.

Review is the step that stalls, and it stalls by arithmetic rather than by anyone deciding badly. Measured on 2026-09-06: a reviewer clears 6 PRs in 93 minutes (~15.6 min/PR) and 3 corpus PRs in 64 minutes (~21 min/PR), so one reviewer sustains about 4 PRs/hour. Implementation agents take 35-85 minutes and produce one PR each, so six of them produce 5-6 PRs/hour. One reviewer cannot keep up with six implementation agents. Budget roughly one reviewer per four implementation agents.

Treat an open unreviewed ready PR as unfinished work that counts against your concurrency budget (a draft is the claim of an implementation you already count). Six implementation agents plus six unreviewed PRs is twelve, not six. Without that accounting you will keep starting implementation agents whenever a slot frees, because starting one feels like progress and starting a reviewer feels like overhead - and the queue grows every hour.

Batch three or four PRs per reviewer. Larger batches go stale: a batch of six ran 93 minutes, during which three PRs from the brief merged and two heads moved, so a third of the verdicts came back "no verdict on current head". Smaller batches lose the cross-PR findings that are the reason to batch at all - the most valuable result that day was spotting that two PRs bumped the same submodule pin to different revisions and working out which had to merge first (a shape #3737 removed: there is no pin). A per-PR reviewer cannot see that, and neither can you.

A reviewer that approves a PR arms auto-merge on it immediately, in the same pass. Do not hand an approval back to the coordinator and wait for it to act — that round trip is where the verdict goes stale, and staleness is the main cost of reviewing in batches. The reviewer has just read the head SHA; it is the only actor that knows the verdict and the SHA are consistent at that instant.

gh pr merge <N> --repo <owner>/<repo> --squash --auto

Arming does NOT require a green verdict, and a brief that demands one is wrong. --auto exists for the not-yet-green case: it holds the PR at BLOCKED until every required check passes, and a red aggregate means no merge. So ci-wait.py exit 2 — checks still running, nothing failing — is a perfectly good moment to arm, and insisting on exit 0 forces a second round trip in which the verdict goes stale, which is the cost this whole section exists to avoid. Exit 0 or 2 arms; exit 1, 3 or 4 does not. Measured on PR #3959, where a reviewer armed at exit 2, said so prominently rather than quietly, and was right — the coordinator's brief had over-specified the condition, not the reviewer's judgement (#3961).

Arming at exit 2 is safe; it is not self-correcting. Re-read every armed PR's verdict on each sweep. --auto holds a red PR rather than merging it, so nothing breaks — but nothing tells you either, and an armed PR that goes red merges the instant a fix pushes on top, against a verdict nobody gave the new head. Measured on #3978: armed at exit 2 with 0 failing, two BC legs reported Failed: 2, Passed: 5506 twenty minutes later, and the coordinator found it only by sweeping. Disarm before dispatching the repair, then re-arm on a fresh verdict.

tools/armed-prs.py is that re-read, as one command with no arguments. It lists the armed set and reports only the PRs that are failing or whose verdict could not be read; a PR whose checks are still running is the ordinary armed state and stays quiet. Exit 0 nothing to do, 1 something is failing, 3 a verdict could not be read — which is not "fine", because nobody measured it. It reports and never disarms: the repair sequence above stays the coordinator's call. Timings that justify a tool over the sweep alone: two armed PRs sat red for 12 and 119 minutes on a night with nineteen armed at once (#4006).

Arm only when all of these hold. Any one missing means report it to the coordinator instead:

  • The PR is on a branch this loop owns. Check the branch prefix, never the author field — every loop running under one account reports that account as the author, and an outside contributor's PR is never merged by us.

  • No release run is in progress (publish.yml pushes a fast-forward; a merge during its ~40-minute run kills it).

  • git merge-tree --write-tree --messages origin/<base> origin/<branch> is clean.

  • Every Corpus-PR: line in the body names a merged corpus PR; a PR touching an AL-observable path with neither a Corpus-PR: nor a Corpus-NA: line is held (the linkage gate, bc-behavior-tests-go-upstream.md). The read is PR_BODY="$(gh pr view <N> --json body --jq .body)" python3 .github/scripts/corpus_pr_state.py, which answers one of NONE / MERGED / MERGEABLE / NOT-MERGEABLE / CLOSED-UNMERGED / UNREADABLE per cited corpus PR — tools/ci-wait.py prints the same line beside its verdict (#3674). Only NONE and MERGED arm. MERGEABLE is a corpus PR still to merge: merge it first, in this same step, then re-read. Anything else means reporting that corpus PR's number instead of arming.

    Having merged the corpus PR in this step, the gate's stored tick is now stale, and it refuses the merge you are about to make (#4206). The gate does not re-evaluate when the corpus PR moves, and a failing non-required check makes mergeStateStatus UNSTABLE, which enablePullRequestAutoMerge refuses — while ci-wait.py reports GREEN and prints corpus PR #N: MERGED beside it. It now also prints corpus gate: STALE when that is so; clear it before arming, which costs seconds and moves no head:

    tools/armed-prs.py --refire-stale-corpus-gate   # every armed PR whose gate is STALE
    

    Never an empty commit: a push restarts the BC matrix and re-arms auto-merge against a head nobody reviewed. And corpus gate: UNKNOWN is not a stale gate — nobody established what it is, so read that corpus PR by hand rather than re-firing.

  • No other PR in the same batch conflicts with it. Where two do — historically two submodule pin bumps to different revisions, say — arm only the one that must merge first and report the ordering.

  • The newest comment on the PR whose last line begins Verdict: reads Verdict: MERGE with a head equal to the PR's current head (gh pr view <N> --json headRefOid); any other line, or none, sends the PR back to its reviewer naming what is missing.

  • No commit is attributed to another real GitHub account. tools/pr-attribution.py <N> [<N>...] — the whole arming batch in one call. Such a commit makes the main ruleset's require_extra_approval_for_unattributed_changes refuse the merge until a human approves, with every check green and mergeable: MERGEABLE. It is not a check, so ci-wait.py cannot see it and reports GREEN (#3942). Exit 0 arms, 1 does not (report it and ask the owner), 3 does not either — the authors were not established, and an unmeasured thing is not an all-clear. The tool ignores an empty login and claude, because this loop's own commits carry both and they never block (measured: #3943 CLEAN with both, #3927 BLOCKED only once SShadowS appeared); keying on "not the pushing identity" false-positives on everything this loop writes. Never self-approve — that rule exists to put a human in front of exactly this change.

Record the head you armed against — it is the head in that verdict line. If the head moves afterwards, GitHub keeps auto-merge armed against the new head, which nobody has reviewed, and the head is what lets you notice.

One command, two outcomes — and on a green PR it MERGES. --auto is not "queue it for later":

  • required checks not yet green → auto-merge is armed, and the PR lands when they pass;
  • required checks already green → the PR merges on the spot.

gh picks between the two itself, before calling anything — its merge command carries a function named isImmediatelyMergeable for exactly this. Both outcomes are intended: if review approves and CI is green, the PR should merge.

So on a green PR, the approval decision IS the merge decision. There is no coordinator checkpoint after it, and nobody looks again. Every condition in the list above has to hold at the moment you run the command, because running it is the merge — not a request for one. Weigh the verdict accordingly rather than assuming a later sweep will catch a mistake.

But gh decides that from a cached status, so it can lose the race — verify by re-reading the PR, never by the exit code. gh picks the path once, at command start, from the mergeStateStatus it fetched: autoMerge: opts.AutoMergeEnable && !isImmediatelyMergeable(pr.MergeStateStatus) (pkg/cmd/pr/merge/merge.go). isImmediatelyMergeable is true for CLEAN, HAS_HOOKS and UNSTABLE only. GitHub's enum also has UNKNOWN — "the state cannot currently be determined", which is what a PR reads while GitHub recomputes mergeability, and gh has no constant for it at all. So a PR whose checks have just settled reads UNKNOWN, gh takes the arming path, and by the time the mutation lands GitHub has settled to CLEAN and refuses to arm what can already merge:

GraphQL: Pull request Pull request is in clean status (enablePullRequestAutoMerge)

The PR is then neither merged nor armed — measured twice, on #3336 and #3772, each landed afterwards by re-running the same command without --auto. Re-running without --auto is the fix; the state is genuinely clean, which is why it works.

The message is a GitHub API error, not a gh one: the phrase does not occur in the binary (gh 2.98.0). That much of #3127 was right. Its conclusion — that gh therefore cannot produce it, because it skips the mutation on a mergeable PR — does not follow, because "already mergeable" there means as cached at fetch time, and the gap between the two reads is the race. --auto on a settled-green PR still merges on the spot (#3095); that is not in question.

A second cause reaches the same exit-0-but-unarmed state, and its fix is the opposite one. The race above is a clean-status one, cleared by re-running without --auto. During an API degradation the call instead fails in transport — a bare GraphQL 502 or 500 — and gh still exits 0 with the PR neither merged nor armed. There the state is not clean, so dropping --auto would be wrong; a plain retry of the same command takes. Measured three times on 2026-09-13 while gh pr create was also failing with 502s and the REST endpoint was healthy.

Telling them apart is cheap when there is a message at all: the clean-status error names enablePullRequestAutoMerge, a transport error names an HTTP status. But a degraded endpoint also returns an empty body, which parses as nothing — measured minutes later on this same outage, where two gh api ... -X POST calls produced unexpected end of JSON input from the parser rather than any status from GitHub, and the third attempt succeeded. So the absence of a recognisable error is not evidence of the clean-status race.

When in doubt, retry the same command once and re-read; if it is the clean-status race, the retry fails the same way and then you drop --auto. Retrying is the safe default, because it cannot merge anything: the wrong guess costs one call, while dropping --auto on a PR that is not clean asks GitHub to merge on unpassed checks.

So the exit code is not the check — the PR's state is. After any gh pr merge, re-read it:

gh pr view <N> --repo StefanMaron/BusinessCentral.AL.Runner \
  --json state,mergedAt,autoMergeRequest \
  --jq '"state=\(.state) mergedAt=\(.mergedAt // "-") auto=\(.autoMergeRequest != null)"'
what it readsmeaning
state=MERGED, mergedAt setdone — record the SHA
state=OPEN, auto=truearmed; it lands when the checks pass
state=OPEN, auto=false, and the PR is greenthe race above — re-run without --auto

Read that exit code directly, and never through a pipe. gh pr merge does exit 1 on this refusal — verified on gh 2.98.0, and its error propagates unaltered from the mutation through merge() to the exit. The rc=0 reported alongside this failure on #3772 came from the measurement: cmd | tail yields tail's status, and out=$(cmd); echo "$out" yields echo's, so both print the GraphQL error and then report 0 (ci-verdicts.md §0). That is why the re-read above is the check and the exit code is only corroboration: one of them was misreported for three days, and it was not the one GitHub sends. See #3341.

Still read it: gh pr merge exits non-zero for real reasons too (Pull request #N is not mergeable: ...), and a loop that printed "armed" regardless of it once left four green PRs sitting unarmed. It is the second check, not the first.

When it arms rather than merges, arming is still not merging, and it does not replace the merge bar — it is the bar expressed as a standing instruction to GitHub, so a PR lands the moment its checks go green instead of at the coordinator's next sweep.

Start the next reviewer when one returns, not when a queue becomes visible. By the time a pile-up is obvious it is already too deep to clear in one fresh batch.

Require the head SHA in every verdict, and re-read it immediately before merging. Heads move within minutes when other loops and outside contributors push. Pass --match-head-commit so a merge refuses rather than quietly taking a commit nobody reviewed - a SHA in one brief had a red verdict attached by the time the review finished.

Pass the FULL 40-character SHA to --match-head-commit. An abbreviated one is rejected by the GraphQL layer (Could not coerce value "ca0f311d" to GitObjectID), and the failure wears the shape of a success: nothing merges, nothing is armed, and gh still leaves $? at 0 in a pipeline, so a loop reading the exit code reports "armed" about a PR that is untouched. The PR's own state is the check, as everywhere else on this page:

head=$(gh pr view <N> --repo <owner>/<repo> --json headRefOid --jq .headRefOid)
gh pr merge <N> --repo <owner>/<repo> --squash --delete-branch --auto --match-head-commit "$head"
gh pr view <N> --repo <owner>/<repo> --json state,autoMergeRequest   # re-read; this is the check

One session's sample, and review time scales with PR size. Re-measure with tools/agent-cost.py rather than treating the ratio as settled.

Triage

Run the triager subagent at the start of a cycle, and again whenever the open-issue count has grown by roughly 20 or the queue has visibly drifted. Sonnet is a fine fit.

The queue grows for a reason worth naming: issues get fixed by a PR that cites a different number, so nothing auto-closes them. Ask triage for three things — already-fixed issues with the commit that fixed each, duplicate clusters with a canonical, and status labels for the untriaged. Have it apply labels directly (mechanical) and close only confirmed duplicates (its closing rule: the exact prior issue or merged PR found, or a retry duplicate per .claude/agents/triager.md § D); every other closure comes back as a list for approval, with the comment it would carry.

The merge bar

Merge when all of:

  1. Every required context green on the PR's current head SHA. gh pr checks reports the newest completed run, which can predate the last push — confirm the SHA. Do not count legs. Since #3141 a pull request runs three BC legs (.github/pr-bc-versions.txt: 27.0, 27.5, 28.4), not eight, so a bar phrased as "all 8 legs green" would refuse a legitimate PR or send you hunting for legs that do not exist. The legs are not required contexts anyway — the aggregate BC test matrix passed is, and it fails when any leg of whatever matrix ran fails. The other five versions run on main via main-verdict-floor.yml, not on the PR — on a 30-minute cadence and again about ten minutes after a merge burst ends (#3679). tools/ci-wait.py prints that floor's newest verdict beside the PR's, so a red main a PR merely inherited is visible before you arm it.
  2. git merge-tree --write-tree --messages origin/main origin/<branch> is clean. mergeStateStatus: CLEAN only covers textual conflicts.
  3. The proving test exists, and the corpus-PR condition of the arming list above holds.

Shortened here. Read the whole file on GitHub.

Signals

GitHub stars
36
Forks
13
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
orchestrating-a-session
Source
github.com/stefanmaron/businesscentral.al.runner