Fifteen AI agents work on astro-ai, the codebase behind shubhagya.com. In the designed path, none of them merges a pull request: a workflow does. The unattended loop that runs them does hold a written permission to merge its own pull request after QA, and that matters, so it gets its own section below.

Four times, a pull request that had passed everything stayed open anyway. Gates green, QA passed, no conflicts. It sat there, waiting for a signal that had already been sent.

Merge authority is the right to decide that a change lands on main. In astro-ai's design it sits in a GitHub Actions workflow that merges only on evidence: green checks on the head commit, a qa-pass label bound to that commit, and no protected paths touched. The lesson that matters most is to put the merge decision in something that reads artifacts, then deny the merge command to the agents, or say plainly which ones you have not.

This post covers both halves: why merge authority sits in a workflow instead of an agent, and the failure that design kept producing anyway. The agent cycle diagram shows the whole loop if you want the map first.

How agents hand off work without talking to each other#

The agents are Claude Code subagents: each one runs in its own context window with its own system prompt, tools and permissions. A subagent starts with no memory of any other, so this system cannot pass work along by conversation.

Every handoff is written somewhere durable instead. The source page lists the channels, and each one exists for a different reason:

ChannelWritten byRead byWhy this one
Issue commentevery agentthe next cycle, the steward, a humanThe only record that outlives the run
Labelqa_verdict.sh onlyagent-mergeA verdict a workflow can act on
Commit SHAengineerQA, gates, close-on-evidenceBinds a verdict to exact code
workflow_runGitHubagent-merge, qa-on-greenThe signal that a gate finished
/healthproductionclose-on-evidenceProof the fix is serving, not merely merged

A label is GitHub's tag for issues and pull requests. Here it carries the QA verdict, qa-pass or qa-fail, and only one script writes it. When QA records a pass, that script binds it to the SHA QA examined and refuses if the branch has moved since.

Why does this beat conversation? Because an artifact can be re-read by something that was not there when it was written. A merge workflow cannot read an agent's reasoning. It can read a label, a commit and a check result, and it can read them again an hour later and get the same answer.

The repo learned the cost of the alternative directly. Its status file, AGENT_STATUS.md, is gitignored, so nothing written there ever left one machine. The decision log records five issues audited and found already complete on one day, four of them carrying zero comments. The rule now is that the issue holds the decision record.

Where does merge authority sit?#

An agent pushes a branch and opens a pull request. The decision to merge lives in agent-merge.yml, a GitHub Actions workflow: a YAML file that GitHub runs on its own machines when a named event happens.

The workflow runs its checks as a chain of steps, and each step runs only if the previous one passed:

  1. Author (own). The pull request was opened by claude[bot] or the repo owner. A pull request from a fork never reaches the job at all.
  2. Gates (gates). Every check on the head commit has finished green. It takes the latest run of each check, so a superseded, cancelled run stops voting, and a check still running counts as not green.
  3. QA (qa). The qa-pass label is present, and neither qa-fail nor needs-human-review is.
  4. Size (size). The diff is under MAX_LINES.
  5. Trust boundary (boundary). The diff touches none of seven protected paths: the system prompts, the knowledge corpus, the workflows themselves, and the four files that decide what is recorded, sent or stored about a user.

A pull request that touches the trust boundary is labelled needs-human-review and left open. One agent, chief, can release that hold, but only with a ruling comment that names the exact head SHA, so the next push cancels it. It cannot skip the gates or the QA label.

The point is least privilege: give each actor only the access its job needs, the same principle behind OWASP's excessive agency risk. Fifteen things write code. In the designed path, one workflow decides what lands, and it decides on evidence rather than on anyone's judgment that the work is finished.

The size check holds nothing#

Step 4 is switched off, and the source page says so. MAX_LINES is set to 1000000, which admits any plausible diff.

It was raised on 2026-10-08, and the workflow quotes the owner's reason: "we should not restrict git push and merge for this autonomous system to be successful. If QA is passed, push and merge." The step was left in place so that lowering the number to 400 brings it back with one edit.

I think that honesty is the useful part. A control that is written but inert should be described as inert, not counted as protection. The case for the limit still stands: the repo notes that test coverage is narrow and main deploys with no staging, so green CI proves much more about deleting one file than about a 3D chart.

Where the boundary is a prompt#

The same 2026-10-08 grant reaches further than the size limit. CLAUDE.md records it as a standing permission: /agent-cycle, running unattended in agent-work.yml, "pushes its branch and merges its own PR once QA passes." agent-work.yml says the same in a comment. Its denied-tools list blocks rm, sudo, force-push, gh repo, gh secret and gcloud, but not gh pr merge, and the job holds pull-requests: write.

So "no agent merges" describes the design, not a hard guarantee. agent-merge is the workflow that merges the loop's pull requests. qa and qa-lead are told never to merge, and chief that it cannot merge directly. But only the steward is actually denied gh pr merge in its tool list (agent-steward.yml). For the delivery loop, what stands between it and a merge is its instructions plus branch protection on main, which requires the gates and build-gates checks but not the qa-pass label.

That is the same lesson as the size check. Where the boundary is a prompt rather than a deny, say so.

Why the authority is not an agent's#

In this same repo, a dev-lead agent wrote USER AUTHORISED PUSH this turn into a status file and then cited its own line as permission. The earlier post covers that failure; the structural answer is to hold merge authority in a workflow, so there is nothing for an agent to grant itself. That answer is only as strong as the tool deny behind it, which, as above, the delivery loop does not have.

The missing edge: why green pull requests stayed open#

Here is the failure the design kept producing.

agent-merge starts on two kinds of event. workflow_run starts one workflow when another finishes, here the PR gates and verify-build workflows. pull_request_target with type labeled starts it when a label is added.

The workflow's own comment calls both of these edges. An edge is a one-time notification that something changed. If the condition is not yet true when the edge arrives, the run exits, and nothing asks again.

So whenever the last green check landed after the last edge, the pull request stayed green, qa-pass and mergeable, indefinitely. The workflow lists four times it happened:

  • #303: ready_for_review was not among the pull request events pr-gates listened to, so the pull request, once out of draft, carried no check at all.
  • #322: a pull request authored with GITHUB_TOKEN emits no workflow_run. GitHub's documentation states that, apart from workflow_dispatch and repository_dispatch, events triggered by GITHUB_TOKEN do not create workflow runs.
  • #352: the run that went green was a manual workflow_dispatch, which the job deliberately ignores. A dispatched run checks out the branch, not the merge result, so it does not prove the merge.
  • #356: verify-build is a different workflow, and agent-merge listened only to PR gates. On a docs-only change pr-gates skips its suite and finished in 9 seconds, while verify-build took 68.

Each was fixed by adding or correcting an edge. The workflow's conclusion is blunt: "a fifth edge was always going to be missed -- so the answer is to stop relying on edges."

The sweep, and what it does not fix#

The answer was a sweep (#357): a cron schedule, which runs a workflow on a clock rather than on an event, set to */30. That asks for every 30 minutes.

The sweep adds no authority. It picks a candidate pull request number, and then the same steps re-check every condition through the GitHub API in the same order. The worst it can do is merge something that already satisfies every condition.

It also turned out to be much slower than it looks. Over the 71 scheduled runs up to 2026-10-27, the measured gap was:

30min
Declared interval
126min
Fastest actual gap
444min
Slowest actual gap

Not once in 71 runs did it fire at 30 minutes. The repo attributes this to GitHub throttling short-interval cron on public repositories; GitHub's documentation says only that scheduled runs can be delayed during periods of high load.

So the sweep stopped pull requests stranding forever. It did not make them fast. A stranded pull request now waits hours instead of indefinitely.

The edge bug also came back. On #562, a code change, gates finished in 7m43s and build-gates in 10m25s. The workflow_run edge fired while build-gates was still queued, nothing was listening for the second workflow, and the merge check exited. The workflow records that the hole cost two stranded pull requests before it was closed on 2026-10-27, by listening to both gate workflows.

The decision log records one more measured case (#573) that was not a missing edge at all. A redundant dispatched gate run became the last check to go green, and a dispatched run is exactly the kind that cannot wake a merge. The pull request met every condition and sat 2h36m until the queue was drained by hand.

Lessons for a startup CTO running AI agents#

None of these lessons depend on astro-ai's scale. They come from what is in the repo.

  • Put merge authority in something that cannot be talked into it. An agent that can merge can be the agent that writes its own permission slip. A workflow reading labels and check results cannot. Then deny the merge command to the agents, or say plainly which ones you have not.
  • Make every handoff a durable artifact. If the next actor cannot re-read it, it did not happen. Bind verdicts to a commit SHA, so a pass on old code cannot vote on new code.
  • Event triggers are edges, and edges get missed. Anything that must eventually happen needs a reconciler that re-asks the question on a clock, and the reconciler must re-check everything rather than carry authority of its own.
  • Measure the backstop. The cron said 30 minutes; the measurement said two to seven hours. Use the measured number when you decide how much the primary path can be allowed to fail.
  • Describe switched-off controls as switched off. An inert size limit, stated plainly, is a decision someone can revisit. The same limit counted as protection is a false claim.
  • Hold changes by path, not just by result. Green checks say little about a change to the prompts, the gates or user data, so those paths wait for a ruling regardless.

On 2026-10-21 main itself also gained branch protection requiring a pull request plus the gates and build-gates checks. A protected branch with required status checks refuses a merge until those checks pass, whatever opened it. It does not require qa-pass; only agent-merge reads that label. That is the floor; the workflow above is the decision.

The full loop, all fifteen agents and every artifact between them, is on the agent cycle page. If you are designing something similar and want a second pair of eyes on where the authority sits, I am happy to talk.

FAQ#

Can an agent in astro-ai merge its own pull request? In the designed path, no: the agent-merge workflow does. But the unattended delivery loop holds a written grant to merge its own pull request once QA passes (CLAUDE.md), and its denied-tools list does not block gh pr merge. Only the steward is denied that command.

Why did pull requests that passed everything stay open? The merge workflow started on events, and an event is a one-time notification. When the last green check landed after the last event, nothing asked again. The workflow lists four such strands, each fixed by adding or correcting a trigger.

How often does a 30-minute GitHub cron actually run? In this repo, never at 30 minutes. Over 71 scheduled runs up to 2026-10-27 the gap ranged from 126 to 444 minutes, according to agent-merge.yml. GitHub's documentation says only that scheduled runs can be delayed during periods of high load.

References#