Skip to content

Automation & Agents: how CI is put together

DisMech runs 27 GitHub Actions workflows. Most repositories this size have CI that builds and tests; this one also has automation that curates — scanning literature, opening pull requests, reviewing them, and merging them. That is unusual enough to deserve an explanation of the shape rather than a per-workflow manual.

This page is the map. It explains why the automation is arranged the way it is. Counts and sampled statistics below were measured 2026-08-05 and will drift — they are given to make the reasoning concrete, and none of the arguments depend on the exact numbers holding. For the individual knobs, see Cron Profiles (cadence), Agent Model Config (which model), and Page Build (full vs incremental rendering).

If you are here because a bot did something to your PR and you want to know why, skip to Review states and merge state.


Agent traces in Langfuse

All 14 Claude workflows send session traces to Langfuse using the Claude Observability Plugin. This includes the scanners, PR review and shepherd, editorial review, compliance, mention responders, and issue triage, summarization, and deduplication. The plugin records prompts, model generations, token usage, and tool inputs and outputs from the Claude transcript. Captured text is subject to the plugin's size limits; a trace is not a guarantee of an unabridged tool result.

The workflows use the same repository secrets as PR Shepherd: LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY, and LANGFUSE_BASE_URL. These should point to the ai4curation all-traces project. The ALLTRACES_LANGFUSE_* prefix used in local environments is not the GitHub secret name. Keys are passed through the environment of the agent step; missing keys disable the hook's export. Each runner installs uv so the hook can load its Python dependencies.

CC_LANGFUSE_TRACE_TAGS identifies the repository, workflow file stem, run ID, run attempt, and job. For example, filter on workflow:curation-scanner to inspect that scanner; the run:<id> tag maps to https://github.com/monarch-initiative/dismech/actions/runs/<id>. The curation scanner also adds tier:<effort> to distinguish its matrix jobs within the same run (for example, tier:high_effort). The upstream Claude action installs the plugin through its marketplace inputs. The CLI-based mention responder and local Claude action install it before starting the agent. These explicit installation steps are non-blocking: a marketplace or plugin-install failure leaves the agent running without tracing.

PR Shepherd keeps show_full_output: false. Other workflows retain their existing GitHub log and artifact settings while Langfuse coverage is established. Retiring their direct collection in agent-watcher is a separate cutover after verifying traces from successful runs.

The stance: agent-forward, with a human window rather than a sign-off

Design decision §7 states it: DisMech is agent-forward. Most curation is performed by AI agents, initiated either by humans or by scheduled workflows. Assume issue and PR content is AI-generated unless marked otherwise.

The consequence people underestimate: at this volume, a human cannot be the throughput bottleneck on every change. Roughly 200 PRs are open at any time. So the human role is not "approve each diff" — it is "set the policy the automation enforces, and intervene where it flags uncertainty."

Be precise about what "reviewed" means here. The automated reviewer is itself a model: claude-code-review.yml mints an ai4c-reviewer GitHub App token and has it run gh pr review --approve. So reviewDecision == APPROVED — the load-bearing condition in the auto-merge criteria — is normally produced by an LLM, not a person. For an agent-authored curation PR, that closes an author → approve → merge loop with no human in it.

That is deliberate at this repo's curation volume, but it means the human control is not a sign-off gate. It is the 3-day delay and the assignee check — see the auto-merge criteria. Those two otherwise arbitrary-looking conditions exist precisely to be the window in which a human can notice and intervene; everything else in the predicate is a correctness check that a machine can evaluate.

That only works if the automation is trustworthy in a specific way, which is the next section.

The organising principle: deterministic gates around model judgement

The single most important pattern in this repo's CI:

Models judge. Deterministic code decides.

An LLM is excellent at reading a paper, spotting a bundled mechanistic claim, or noticing that a snippet doesn't support its citation. It is not a thing you want holding merge rights on the strength of its own say-so. So wherever an action is consequential and reversible only with effort, there is a deterministic component that gates it — plain code applying a fixed predicate to observable state, producing the same answer every run.

Worked examples, each a place where the two halves sit side by side:

Workflow The model's job (judgement) The deterministic gate (decision)
pr-shepherd Agent step reads stuck PRs, resolves review comments, pushes fixes Closing sweep merges only PRs passing a fixed predicate (scripts/auto_merge_ready_prs.py)
claude-code-review Agent reviews the diff, writes findings, and submits the approving review (none on this path — see below)
Curation generally Agent writes the YAML and picks the evidence The validation stack — schema, term, and reference validators — decides whether it's admissible
generate-pages — classify_page_build.py decides full vs incremental, defaulting to full on anything unrecognised
close-fork-prs — Closes every fork PR. "No model is involved" (its own header comment)

The review row is the honest exception, and worth stating plainly in a page arguing this principle. Branch protection does gate the merge, but the approval it requires is produced by the reviewing model itself, so on that path the "deterministic decides" half is satisfied by model judgement. The real deterministic gates on a curation PR are the validation stack and the merge sweep's fixed predicate — not the review requirement. Wherever this page says a change is gated, that is what is doing the work.

The validation stack is the load-bearing instance. Agents hallucinate PMIDs, snippets, and ontology IDs; the answer is not "prompt the agent harder" but linkml-reference-validator checking each snippet is a literal substring of a cached abstract, and linkml-term-validator checking each CURIE against OAK. The agent proposes; the validator disposes. Everything in CLAUDE.md about never hand-writing references_cache/ or cache/**/*.csv follows from this: a cache that can be written by the thing it is meant to check is not a check at all. It makes validation circular.

Two corollaries worth internalising:

  • Fail closed. Unrecognised input escalates to the expensive-but-safe path. The page-build classifier treats any path it doesn't recognise as "full rebuild". The merge sweep skips anything it cannot positively verify.
  • Deterministic parts get unit tests; judgement parts get guardrails. You can pin a predicate with a test. You cannot unit-test a model's opinion, so those get bounded scope, explicit allow-lists, and a human escalation path.

What the workflows do, by role

Grouped by what they're for, which is more useful than alphabetically.

Ordinary CI — main.yaml (build, test, lint, schema/term/reference validation; the test (3.13) job is the only required status check on main), test-linkml-rc3.yml (forward-compatibility against a linkml release candidate), deploy-docs.yaml.

Curation discovery — scheduled scanners that find work and open issues or PRs: curation-scanner, literature-scan, preprint-scan, knowledge-gap-scan, discussion-scanner. These are the top of the funnel.

PR lifecycle — claude-code-review (automated review on every PR), post-review-agent (acts on editorial review comments), pr-shepherd (unsticks stalled unassigned PRs by any author, repairs additive cache conflicts, and deterministically merges ready PRs through one common closing controller).

Interactive agents — claude.yml and dragon-ai.yml respond to @-mentions on issues, PRs, and review comments (dragon-ai.yml is summoned as @ai4c-agent; the file keeps its old name). claude-issue-triage and claude-issue-summarize process new issues.

Derived artifacts — generate-pages, generate-grouping-pages, generate-project-pages render HTML; warm-reference-cache pre-resolves publisher PDFs.

Scheduled housekeeping — weekly-compliance opens a periodic compliance-fix PR, which enters the ordinary review and PR-shepherd closing path; sync-epic-checkboxes reconciles a tracking issue against the KB.

Releases — kgx-release, mondo-emc-release, pypi-publish, triggered by published GitHub releases.

Security gates — close-fork-prs, untrusted-comment-guard. See below.

Configuration lives in one place, not 27

Two cross-cutting properties were originally copy-pasted into every workflow, which meant a repo-wide change was an N-file edit that drifted:

  • Cadence → .github/cron-profiles.yaml. Named profiles (slow/medium/fast/fast-weekend) applied with just cron-profile <name>.
  • Model → .github/agent-config.yaml. Single-model workflows resolve AGENT_MODEL via a composite action; the curation scanner resolves its matrix and passes each row's matrix.model. Family aliases follow new releases, and every managed agent uses the shared setup-claude-code action to install the latest CLI. See model and runtime maintenance.

Do not hand-edit the cron: lines or add a --model flag to a workflow. A test (tests/test_agent_config.py) enforces the model rule. Edit the config.

The generalisable point: when a property is shared across many workflows, make it data with a single source of truth and a test that catches re-hardcoding — otherwise it silently diverges.

Trust boundaries

Agentic CI has a threat model ordinary CI does not: PR content is model input. A title, body, comment, or diff can carry text crafted to redirect an agent that reads it (prompt injection). Three mechanisms address this:

  1. No fork PRs. close-fork-prs closes them at the door. Two reasons, per its header comment: fork-authored content is the injection entry point, and fork-triggered workflows don't receive repo secrets, so they can't be AI-reviewed anyway. Contributors push branches to origin instead — see CONTRIBUTING.md.
  2. Comment gating. untrusted-comment-guard minimizes risky comments from untrusted authors via a shared trust gate, so agents don't read them.
  3. In-prompt guardrails. Agent prompts instruct the model to treat all PR content as untrusted data, never as instructions — and to report it if PR content appears to be giving it orders. pr-shepherd's prompt is the fullest worked example.

Bot identity. Two GitHub Apps appear in timelines, with different permissions, and it is worth being able to tell them apart:

  • ai4c-agent — the write identity. Scheduled agents (curation scanners, pr-shepherd) push branches, comment, and merge as this app.
  • ai4c-reviewer — the review identity used by claude-code-review to submit review states. An ai4c-reviewer[bot] approval in a PR timeline is a model's verdict, per the section above.

Scheduled agents authenticate as an App rather than using the built-in GITHUB_TOKEN. This is deliberate: a push made with GITHUB_TOKEN does not trigger further workflows (GitHub's loop prevention), so a fix pushed by an agent would never get re-reviewed. An App token does trigger them. Note App installation tokens expire after 1 hour, which matters in long-running jobs.

Derived artifacts are written by exactly one workflow

pages/, dashboard/, app/data.js, pathographs/, and elements/ are generated. They live in git, but only generate-pages writes them, in its own auto/generate-pages PR. A hand-authored PR must never include them — you'd conflict with the bot and with every other curation PR. See Page Build.

The same logic covers references_cache/ and cache/**/*.csv: committed, because CI validation must be deterministic, but written only by their generating tool.


Recovering failed review Actions

pr-shepherd runs scripts/retry_failed_reviews.py in an independent retry-reviews job on every scheduled or manual run. It uses no model, has its own retry budget, and still runs if the agent job is skipped or fails. Human authorship, human assignment, draft status and PR age do not exclude a review retry. Assignment remains a hold on automatic merging, not reviewing.

The controller discovers failed/timed-out claude-code-review.yml runs within GitHub's 30-day rerun window and invokes gh run rerun RUN_ID --failed. It also checks the review checks attached to open PRs' current head commits. If a failed run is absent from the Actions census, that second lookup recovers it after verifying the workflow, PR association, and head commit. Recovered runs pass the same retry guards and budget; the summary lists them separately and reports how many distinct runs the Actions census returned. Errors reading current checks are reported and make the job fail rather than silently claiming a complete sweep. A specific-PR request limits this cross-check to that PR. It never creates a replacement dispatch, changes a branch, or alters a review. Cancelled and intentionally skipped runs, missing reviews with no existing run, and successful workflows that forgot to post a verdict are separate recovery cases. Generated PRs whose review was deliberately skipped are not retried.

Before retrying, it checks the latest run attempt, that the PR is open, that a PR-event run still matches the current head, and that no newer/active review exists. For a manual- or comment-triggered run it also checks that the reviewer has not already posted an approval or changes-requested verdict for that head; an old changes-requested verdict does not block recovery of a failed review of a new commit. A failed PR-event run on the current head is the PR's own claude-review check, so an existing verdict does not block retrying it, and only a newer PR-event run on that head supersedes it: a manual or comment review runs on main, attaches no check to the PR head, and cannot turn that check green. Manual-triggered workflows retain their PR input and use their existing gh pr checkout step to review the current branch.

Backoff is measured from the latest attempt's completion: 1 hour after the first failure, 6 after the second, then 24 hours. review_retry_delay_hours sets a minimum delay and max_review_retries caps retries per sweep (default 5; 0 disables recovery). The common dry_run and optional pr_number inputs apply. Setting review_retry_delay_hours to 0 explicitly bypasses the backoff. The retry cap and backoff bound repeated usage-limit failures; this does not automatically repair credentials or detect an account-wide quota reset.

Review run names include the PR number. Older manual runs without that metadata are resolved from the existing guard/environment log output. Unresolvable failed runs are reported and deferred; an unresolvable active run defers the sweep to avoid duplicating a review already underway. Every retry/defer/skip appears in the Actions summary, without PR comment spam. API errors do not authorize retries. Discovery subdivides time ranges above the Actions API's 1000-result limit rather than silently dropping older failures.

The summary leads with the number of rerun requests accepted and the configured limit, then links to the restarted PRs and workflow runs. Accepted means GitHub accepted the restart request, not that the review completed or passed. PRs deferred at the limit have not been queued; the next sweep reconsiders them with fresh checks. Historical skips and PR-lookup diagnostics are in expandable sections. Diagnostic counts are messages, not unique PRs: a run can have both a lookup error and a deferral. Dry runs explicitly say that no requests were issued.

just review-retry-preview

Failed issue-opened workflows

The same job then reruns failed issue-opened workflows: issue triage (claude-issue-triage.yml), issue summarize (claude-issue-summarize.yml) and dedupe (claude-dedupe-issues.yml). These are the ones with no other recovery: the issues: opened event cannot fire again for an issue that already exists, so an issue opened while the shared agent account is rate-limited, or while a credential is failing, otherwise gets no triage at all, and nothing records that it is missing (#13150).

Each of the three workflows names its runs Triage issue #N, `Summarize issue

NorDedupe issue #N`, and the sweep reads the issue number from that name

only, never from an issue title. Runs from before the run name was added are not retried; the summary gives one count per workflow rather than listing them. For each workflow and issue, only the latest run is considered. It is rerun with gh run rerun RUN_ID --failed when it failed or timed out, the issue is still open, no newer or active run of that workflow exists for the issue, and the backoff above has elapsed. The budget is max_review_retries, counted separately from review retries and per run rather than per issue, and dry_run applies. A pr_number request skips this pass.

A rerun repeats the whole failed job, including anything it did before it failed. For triage this is harmless, because applying the same labels again changes nothing. A summarize run that posted its comment and then failed would post a second one, so the summarize prompt first reads the issue's comments and stops if github-actions[bot] has already summarized the issue.

The triage and summarize workflows also accept a manual issue_number dispatch, like dedupe, so an issue can be re-triaged without finding its original run:

gh workflow run claude-issue-triage.yml -f issue_number=13101
gh workflow run claude-issue-summarize.yml -f issue_number=13101

Review states and merge state

The vocabulary GitHub uses here is genuinely confusing, and it is the vocabulary the merge sweep's criteria are written in. Three different fields are involved.

1. Individual review state

Each submitted review has its own state. States actually observed in this repo (last 40 PRs): CHANGES_REQUESTED ×46, APPROVED ×33, DISMISSED ×15. GitHub also defines COMMENTED (feedback without a verdict) and PENDING (a started, unsubmitted review).

2. reviewDecision — the aggregate

The PR's overall verdict, computed from the individual reviews plus branch protection: APPROVED, CHANGES_REQUESTED, REVIEW_REQUIRED, or null.

This is the one automation keys on. The distinction that trips people up: an individual review being APPROVED does not mean reviewDecision is APPROVED — a later CHANGES_REQUESTED from anyone overrides it.

3. What DISMISSED actually means

A dismissed review is one that has been explicitly set aside so it no longer counts toward reviewDecision. It is not deleted and not withdrawn by its author — it stays in the timeline with its state changed to DISMISSED, so the history remains auditable.

Two ways it happens:

  • Automatically, on a new push. main here is protected with dismiss_stale_reviews: true, so pushing any commit to a PR dismisses the existing approvals. The reasoning: an approval is a statement about specific code, so new code invalidates it.
  • Manually, by someone with write access, via "Dismiss review".

This is why you see DISMISSED so often in this repo (15 of the last ~94 reviews): agents push fix commits onto reviewed PRs constantly, and each push dismisses the standing approval, which then triggers a fresh review.

A worked example — PR #8018, from its own reviews API:

Time (UTC) Event
13:47:19 review submitted (approving)
13:49:18 commit 9fd68a7 pushed
13:54:34 fresh approving review of the new commit

The 13:47:19 review now reads DISMISSED. The push invalidated it; the automated reviewer then re-reviewed the new head commit and approved that.

Why this matters for auto-merge: it is the guarantee that the merge sweep cannot merge a commit nobody reviewed. Approve commit A → push commit B → the approval is dismissed → reviewDecision reverts from APPROVED → the sweep skips it. The guarantee lives in a repository setting, not in the repo, which is tracked as issue #8020.

4. mergeable and mergeStateStatus

Separate from review, and separate from each other:

  • mergeable — can git merge it? MERGEABLE, CONFLICTING, or UNKNOWN.
  • mergeStateStatus — may it be merged now, per all rules? The useful values: CLEAN (nothing blocking), BLOCKED (a required review or check is missing), BEHIND (base has moved and the branch must update), DIRTY (conflicts), UNSTABLE (a non-required check failed), DRAFT, UNKNOWN.

BLOCKED is the one people misread: it usually means "approved, but a required check hasn't finished", not that anything is wrong.

UNKNOWN is normal, not an error. GitHub computes mergeability lazily, per PR — asking for it is what starts the computation. Sampling 60 open PRs in this repo returned UNKNOWN for 58 of them on first ask. Any tool reading these fields in bulk must expect UNKNOWN and re-ask per PR; treating it as "not mergeable" means never merging anything. Both this and a GraphQL cost issue are why the merge sweep is split into a cheap list pass and a per-PR verification pass.

The auto-merge criteria, in this vocabulary

pr-shepherd's closing sweep merges a PR only when all hold:

reviewDecision == APPROVED · no human assignees · mergeable == MERGEABLE · mergeStateStatus == CLEAN · every status check passing (stricter than CLEAN, which only covers required checks) · created more than 3 days ago · targeting main · fewer than --ejection-strike-limit (default 2) failed_checks removals from the merge queue since the head commit was last written. Author identity, head-branch prefix, draft status, and exact ancestry with current main are not eligibility criteria. A known bot/agent assignee is routing metadata rather than a human hold.

That last criterion is the ejection hold. A queue ejection is otherwise invisible to eligibility: the PR stays open and approved, so the next sweep re-enqueues it, it fails again, and the loop repeats — #9852 went round three times in fifteen hours, failing every speculative stack behind it each time (#10988). A single ejection is deliberately not a hold, because ejection does not imply fault: a PR ahead in the stack can poison it, and a third-party outage can fail it. What triggers the hold is repetition against unchanged content. The count is keyed on the head commit's committedDate, so any push resets it to zero, and a lookup failure fails open. The first sweep that holds a given head posts one comment on the PR naming the strike count and how to clear it, deduplicated by a hidden marker keyed on the head SHA. It is a comment rather than a CHANGES_REQUESTED review because a review would outlive the push that clears the hold.

A hold with no trace on the PR page is the cache-row hold. When a merge queue is active, a candidate that adds a row to a cache/<prefix>/*.csv term cache already added by a PR enqueued earlier in the same sweep is skipped for that sweep, because the two would conflict in the queue and the second would be ejected. Only multi-field rows (curie,label,retrieved_at) count; a bare-CURIE enum-cache row is identical in both PRs and merges cleanly. The hold is within-run and lasts exactly one sweep; it does not apply in direct-merge mode, and --no-conflict-batching disables it. Its only trace is a SKIP line naming the PR that holds the row. A failed changed-files lookup fails open.

Draft state is metadata, not a hold. An otherwise eligible draft is marked ready immediately before a complete re-read of the merge guards. If the attempt does not merge, its original draft state is restored.

Inactive PR assignments

The independent assignment-inactivity job releases abandoned assignment holds without asking a model to judge whether a PR is active. It runs on every shepherd schedule, including controller-only hours, and covers every open assigned PR, including human-authored PRs and drafts.

  1. After seven days without a comment or commit from a current assignee, post one reminder tagging both the author and the assignees.
  2. At fourteen days since the last assignee activity, remove the assignment if the reminder is still unanswered. The reminder does not start a new clock.
  3. Any current assignee's comment or commit resets the clock. A later week of inactivity starts a new reminder cycle, and unassignment becomes due fourteen days after that new activity.

PR comments (including edits), inline review comments, submitted reviews, and commits whose author or committer GitHub identity matches a current assignee count as activity. Commit activity uses the timestamp of the matching identity: a bot rebasing an old assignee-authored commit does not refresh the owner's clock. Comments from other people, CI, and the reminder itself do not extend a hold. For several assignees, activity from any one keeps the joint assignment alive. Adding, removing, or re-adding an assignee starts a new ownership period, so an old reminder cannot remove a new assignment.

The reminder is recorded in a machine-readable footer on the bot's comment; only reminders posted by the verified ai4c-agent[bot] identity are accepted. This avoids duplicate reminders on repeated sweeps. A PR that was already inactive for fourteen days receives a reminder first and can be unassigned on the next sweep if no assignee responds. The current schedule is hourly, so the existing overdue backlog may get only about an hour's notice; there is no extra grace period measured from the reminder. A copied marker in someone else's comment cannot authorize unassignment.

The job reads all pages of assignment events, comments, reviews, and PR commits, then rereads them immediately before either write. Closed PRs, new activity, changed assignment or head, and incomplete or unavailable history prevent the write. GitHub's PR-commit endpoint is capped at 250 commits; larger PRs are deferred rather than being judged inactive from partial history. The job uses trusted default-branch Python with no PR checkout or dependencies. Its separate App token has only pull-request write permission and is used only to post the reminder or remove the inspected assignees. Runs serialize with one another.

max_assignment_actions is a shared budget of 10 reminders and releases per run; 0 disables the job's actions. dry_run and pr_number also apply. To preview locally:

uv run python scripts/expire_pr_assignments.py --repo monarch-initiative/dismech --dry-run

The summary distinguishes assigned PRs found, PRs inspected, actions, deferrals, and errors. Changed or incomplete histories are reported as deferrals without failing the job. API and other unexpected failures fail the job; CLI errors include the exit code without exposing command arguments or response bodies. Releasing an assignment does not close or merge the PR or change reviews. It makes an otherwise eligible PR available for repair regardless of its author; the repair jobs' remaining lifecycle and branch-ownership guards still apply.

Tending abandoned PRs and repairing cache conflicts

The shepherd owns eligible abandoned code and documentation PRs as well as curation PRs. Python changes in src/, scripts/, and tests/ are ordinary repair work: diagnose the review finding or CI failure, make a focused fix, and run the relevant tests. Earlier shepherd comments calling those files "outside curation scope" do not block recovery. The shortlist puts unresolved CHANGES_REQUESTED reviews first, then conflicts under any review state, then approved red/blocked PRs and missing reviews. Within each group it considers the oldest updated PR first. An approved, clean branch belongs to the closer even when behind main; freshness alone must not trigger another push and review.

Author identity does not affect repair eligibility. The shepherd may repair unassigned PRs from humans or bots, including a human-authored claude/ branch. The shortlist requires an open PR targeting main with no assignees and a head in this repository, outside the separately managed auto/ lanes. GitHub's isCrossRepository must be explicitly false; fork heads and missing or unknown head-repository metadata are excluded. The agent rechecks this before checking out or executing PR code and before pushing. Assignment is the deterministic active-work hold. For unassigned PRs, the agent assesses recent activity and discussion to avoid duplicating an ongoing repair; there is no fixed PR-age cutoff for repair. Earlier comments refusing human-authored work do not block recovery.

That tending restriction is intentionally narrower than deterministic merge eligibility. The LLM lane may decline to edit an assigned or auto/ PR while the fixed controller can still merge it after the waiting period; mutation authority and merge eligibility are separate policies.

The independent repair-caches job runs trusted main code before the agent shortlist is built, including during controller-only hourly runs. Its default budget is three repairs, adjustable with max_cache_repairs; 0 disables it. dry_run and pr_number apply to this job too. It observes the same assignment, lifecycle, and head-repository guards as the agent regardless of author: isCrossRepository must be explicitly false. It also defers while checks reported on the PR are unfinished. It never marks ready, approves, or merges a PR into main.

Agent tending waits for this run's cache job. Cache sweeps serialize with cancel-in-progress: false, so an earlier sweep can add queueing time before this run's own repair job, whose execution is limited to 20 minutes. A delayed shepherd may therefore be waiting on cache repair, not stalled in the agent. The agent still runs after a failed or timed-out repair job; cancellation stops it.

The first supported formats are exactly cache/<prefix>/terms.csv and cache/enums/*.csv. A generated directory name alone is not authorization to discard content. The job only unions unchanged source rows plus additions: every ancestor row must remain identical on both branches, and a CURIE present on both sides must have identical fields, including timestamps. The existing structural validator checks the CSVs, then serialization is checked by parsing it back. Labels, timestamps and memberships are never invented. Reference markdown, hierarchy caches, other generated artifacts, competing values, deletions, mode changes, renames, and mixed source conflicts remain agent work. Root-level cache JSON changes are rejected using tree metadata before merge planning, so the retired dataset cache is never opened or changed. A deletion already on trusted main is carried forward if the PR left the file unchanged or also deleted it. PRs that modified it still need their delete/edit conflict resolved by keeping the deletion.

The controller does not check out PR files or run their code, generators, dependencies, or tests. Git computes the ordinary merge in its object database; a private index replaces only supported conflicting CSV blobs. All remaining paths and modes must exactly match Git's merge result. Conflicts must match the same path in the sole ancestor and both tips, and case-colliding results are rejected. Only after the entire plan succeeds can it create a two-parent merge commit, with the original PR head first and the inspected main tip second.

Before publication it rechecks assignment, state, branch, head repository and SHA, checks, and main. Publication uses an explicit expected-head lease and verifies that the new commit fast-forwards that head. The lease is compare-and-swap; the ancestry check forbids history rewriting. This also rejects a concurrent rewind or branch deletion, which an ordinary push would miss. Tests exercise forward-update, rewind, and deletion races against real disposable remotes. The writer token is exposed only to this final Git push; all discovery and planning subprocesses receive the read token. Every result is recorded in the run summary. The push starts fresh CI and review. A deferred repair remains eligible for the shepherd; deterministic refusal is not abandonment.

Preview without changing any remote PR (Git 2.38+ and Python 3.12+):

python scripts/repair_generated_cache_conflicts.py --repo monarch-initiative/dismech --dry-run

The closing controller runs on a fresh runner, separate from the LLM job, and uses a read-only token for discovery plus a dedicated write token only for its fixed transitions. The controller runs hourly; under the active slow cron profile, the costlier agent tending job runs every four hours on a separate concurrency lane (hourly in the faster profiles) and receives a ranked shortlist capped at three times its action budget. The merge controller enqueues up to its configured budget when a queue is active, or directly merges at most one PR per run otherwise. Before each request it performs the final PR-state read and pins the operation to the verified head SHA. The active queue tests the latest-main combination as a temporary merge group. If that queue is disabled, the repo's loose required-check policy intentionally permits the already-green PR to merge directly; disabling the queue does not reactivate an ancestry requirement.

This runner split protects the deterministic job from process-level changes made during the LLM run. It is not a complete GitHub capability boundary: the agent's own App token needs contents-write access to repair branches, which also permits merge operations. Its no-merge rule remains prompt-enforced until a separate identity, broker, or repository ruleset can enforce that distinction.

The final head pin prevents merging a commit that changed after verification. The controller does not chase current main; doing so would dismiss approvals and restart CI without being required by branch protection or queue admission.

The merge_group CI half of that project is in place (#10168): main.yaml declares the trigger, and merge-group runs execute the full suite — every path-filter gate that scopes an ordinary PR run is forced on for queue builds, except the two changed-file KB validations discussed below, because path filtering is the specific reason the #9538 cross-file enum incompatibility stayed invisible until the branch was updated, and that class of break is exactly what a queue build exists to catch. The dorny/paths-filter step itself still runs on merge-group refs (supported since its v4.0.1, with base/ref defaulting to the event's commit hashes): its changed-file lists drive the changed-disorder and changed-comorbidity validations — the two steps that stay filter-gated rather than forced, since forcing them would run them with empty file lists — so those validate the KB files actually entering main, in the merged context where a co-queued PR may have changed cache/** or references_cache/**. tests/test_merge_group_ci.py pins the trigger, the filter's event coverage and its v4.0.1 version floor, the no-suppression rule, and the never-forced rule for the two file-scoped steps. The queue ruleset is now active on main with ALLGREEN, five speculative build slots, and a one-PR merge limit. Accurate enqueue reporting and queued-PR reselection remain controller follow-ups in #10604.

Merge-integrity verification. Passing tests are not evidence of correct history: in the April 2026 GitHub merge-queue incident (#2034), squash merges of multi-PR merge groups silently reverted other PRs' changes and CI stayed green — GitHub learned of the bug from customer reports, because it corrupted content, not availability. The verify-merge-integrity workflow is the local monitoring that closes that gap: on every push to main it recomputes, for each squash commit, the tree that merging the PR's head into the previous tip should have produced (git merge-tree --write-tree), compares it to the tree actually pushed, and on mismatch fails red and opens (or comments on) a merge-integrity-labelled issue. Merge commits, rebase merges, and direct pushes are reported as skipped, not judged — see scripts/verify_merge_integrity.py for the exact contract, including the two known false-positive sources (a PR branch reused after merge, and rename- detection divergence between local merge-ort and GitHub's merge machinery): a MISMATCH alarm warrants comparing the trees by hand before declaring an incident, especially in the workflow's first weeks. This must be live before a merge queue with build concurrency above 1 is enabled on main, because speculative merge groups contain multiple PRs — the structural shape of the incident — and it is cheap insurance against non-queue merge anomalies in the meantime.

Merge-queue break-glass. A required merge queue removes the ordinary escape hatch — the merge button becomes "add to queue", and a red or wedged required check on main (the #5074 shape) blocks every entry. The queue is managed as a repository ruleset, and pausing it is one admin API call each way:

# find the ruleset carrying the merge_queue rule (don't rely on its name)
gh api repos/monarch-initiative/dismech/rulesets --jq '.[] | "\(.id) \(.name)"'
gh api repos/monarch-initiative/dismech/rulesets/<id> \
  --jq '[.rules[].type]'   # confirm it includes "merge_queue"
# pause (PRs merge normally again; queue state is abandoned)
gh api -X PUT repos/monarch-initiative/dismech/rulesets/<id> -f enforcement=disabled
# resume
gh api -X PUT repos/monarch-initiative/dismech/rulesets/<id> -f enforcement=active

Pause when main's required check is red for a reason no queued PR can fix, when the queue itself is misbehaving, or when verify-merge-integrity has flagged a mismatch and history needs auditing before more merges land. While paused, branch protection's other rules (required check, review) still apply; what is lost is only current-main integration testing, so treat a pause as an incident state to exit, not a convenience. Note that the queue rule also blocks direct pushes to main while active — a deliberate side effect that also covers resetting a branch the rule targets.

GitHub auto-merge is a separate server-side path and does not execute these controller guards. Ordinary agents must not arm it, and weekly-compliance PRs use the common controller rather than a separate merge path. The separately managed auto/ workflows own their own merge path. A maintainer can still enable it manually as an explicit override.

Those auto/ workflows mint a short-lived ai4c-agent token after their long build phase, leave checkout credentials unpersisted, and use the App specifically for the three writes that advance the lane: branch push, PR creation, and gh pr merge --auto. The late mint avoids the App token's one-hour lifetime expiring during a full page build. The identity is load-bearing twice. App-authenticated branch pushes and PR creation start the required pull_request checks; the built-in token instead leaves these bot runs at action_required. App-attributed merges then trigger the push workflows on the new main SHA, providing the normal post-merge test (3.13) result. Each lane also removes an existing auto-merge request before re-enabling it, because merely issuing --auto again can preserve the previous request's enabledBy actor. A workflow test enumerates the six lanes and protects all three App-authenticated writes.

This applies to all authors, humans included.

Two of those criteria are the human control, and the rest are machine checks. Approval, mergeability, and green checks are all things a machine evaluates — and since the approving reviewer is itself a model, they contain no human judgement at all. What remains is:

  • more than 3 days old — a standing window in which any human can look at any queued PR before it merges;
  • not human-assigned — the per-PR veto. Assigning a PR to a human marks it as somebody's active work, and the sweep never touches it. Bot/agent assignment remains eligible.

So "a human is in the loop" here means a human has a guaranteed opportunity to intervene, not a human signed off. Read the 3-day delay as the price paid for that opportunity rather than as an arbitrary cooling-off period.

To stop a PR being auto-merged, assign it to a human or leave a CHANGES_REQUESTED review. Draft status does not block it.

There are two more holds, which nobody chooses: the ejection hold and the cache-row hold above. Unlike assignment and CHANGES_REQUESTED, they leave no label, review, or assignee. The ejection hold announces itself with one 🐑 PR Shepherd comment on the PR per held head; the cache-row hold's only trace is a SKIP line, inside the run summary's collapsed Skipped N near-miss PR(s) block, naming the PR holding the contended cache row. So an approved, green, days-old PR that is not merging and has neither a human assignee nor a requested-changes review has one of these two explanations: look for the hold comment first, then the run summary. A push clears the ejection hold; the cache-row hold clears itself on the next sweep.

Preview what the next sweep would do, read-only: just auto-merge-preview.


What assigning an issue actually does

Two different systems attach meaning to assignment, and they mean opposite things. Knowing which one you are invoking is the whole game.

GitHub's agent assignment (a platform feature)

GitHub itself offers "Assign agent to issue" — currently a Preview feature, reached from the issue's assignee control. It opens a dialog with an optional prompt, a target repository, and an agent selector; assigning dispatches a coding agent (copilot-swe-agent, assignable wherever the organisation has the Copilot coding agent enabled) to work the issue and open a pull request.

There, assignment is the dispatch. It is a GitHub product feature, so none of the Actions machinery on this page applies to it — no if: gate, no cron-profiles.yaml cadence, no AGENT_MODEL resolution, and none of the trust boundaries above.

It is not, however, unaware of this repo. .github/copilot-instructions.md is a symlink to ../CLAUDE.md (and AGENTS.md points there too), so a dispatched coding agent receives the same curation rulebook every other agent here works from — the evidence SOP, the never-hand-write-the-caches rule, the whole thing. Because it is a symlink rather than a copy, it cannot drift out of date.

What it does not inherit is the Claude Code-specific tooling: the skills in .claude/skills/ and the deep-research providers, which need local API keys. So it is well-suited to bounded, well-specified work and poorly suited to new disease curation. Its output is still gated the same way as anything else — the validation stack and claude-code-review do not care which agent wrote the YAML, which is the point of models judge, deterministic code decides.

DisMech's own agents (this repo's workflows)

The workflow fleet described on this page is mention-driven. None of it listens for assignment:

Agent Trigger Who may fire it
claude.yml @claude in the issue body/title, or in an issue/PR/review comment Author of the issue or comment must be OWNER/MEMBER/COLLABORATOR
dragon-ai.yml @ai4c-agent please … as ordinary prose, or the legacy @dragon-ai-agent please … — ignored inside code spans and fenced blocks, so documenting the keyword doesn't fire it. Both are text keywords, not accounts: the agent runs as the ai4c-agent GitHub App, which cannot be @-mentioned. The handles live in .github/scripts/agent-mention.js, shared with the comment trust gate Must be listed in .github/ai-controllers.json

dragon-ai.yml once supported assignment dispatch and dropped it — its header records the retirement of the machine account and of the programmatic assigner (stale-pr-reassign) that drove it. The dragon-ai-agent account still exists and still appears in the assignee picker, but assigning to it does nothing: the workflow does not list assigned among its trigger types. pr-shepherd's prompt carries the matching rule for agents — never assign dragon-ai-agent just to trigger work.

claude.yml does list issues: [assigned] among its trigger types, which looks like an exception but isn't: its if: still requires @claude in the body or title. Assigning an issue that already mentions @claude re-fires it; assigning one that doesn't mention it does nothing. That path also gates on the issue author's association rather than the assigner's, so assigning an externally-authored issue cannot turn untrusted issue text into an agent trigger — the same trust boundary as above.

To this repo's automation, an assignee means "claimed"

curation-scanner selects with is:open is:issue no:assignee and is explicitly "restricted to items with no human / non-agent assignee, so the scanner never steps on work a person has already claimed." Assignment on an issue is therefore the same claim signal as assignment on a PR, where it vetoes the auto-merge sweep.

To DisMech's own workflows an assignee means "somebody has this" — never "an agent should pick this up." To get their attention, mention the agent.

The two systems collide, quietly. Assigning an issue to GitHub's coding agent gives that issue an assignee, so it simultaneously removes the issue from curation-scanner's queue. That is usually the outcome you want — two agents should not work the same issue — but nothing announces it, and it is the reason the distinction on this page matters in practice rather than only in theory.

Finally, two automations need no prompting at all: claude-issue-triage and claude-issue-summarize both fire on issues: [opened]. If a bot commented on your issue moments after you filed it and you did nothing, that was one of these.


See also