Reusable GitHub Actions workflow for autonomous AI-driven issue resolution.
Label any GitHub issue ready-for-agent → an agent clones the repo in a worktree, reads the issue, writes a fix, passes CI, opens a PR, and labels it agent-review. Zero human intervention required.
Issue labeled "ready-for-agent"
→ caller repo's agent-dispatch.yml fires
→ calls neupsh/adlc/.github/workflows/agent-issue.yml
→ self-hosted runner picks up the job
→ claude agent works in an isolated worktree
→ commits (GPG-signed), pushes, opens PR
→ issue labeled "agent-review"
Prerequisites
- A Linux machine to host the self-hosted runner (kept on;
--serviceenables lingering so it survives logout). - A GitHub repo you can add Actions secrets and a self-hosted runner to.
- A GPG key for signed commits — the agent signs every commit. Create one with
gpg --full-generate-key; get its ID viagpg --list-secret-keys --keyid-format=long. - A Claude subscription (the agent runs the
claudeCLI, not the metered API). gitand Node.js on the runner box.install.shinstalls Claude Code,gh, andjqwhere it can.
# Get a runner registration token from:
# https://github.com/<owner>/<repo>/settings/actions/runners/new
git clone https://github.com/neupsh/adlc
cd adlc
# Register + install as a persistent user-level systemd service.
# Defaults to 1 runner (one job at a time). Add e.g. --runners 2 to allow more
# concurrency — but each runner is a concurrent agent job, so leave headroom if
# other repos share this machine.
./scripts/install.sh \
--repo your-org/your-repo \
--label linux \
--token <RUNNER_REG_TOKEN> \
--serviceFor IBKR-dependent tests, use --label ibkr and run on the machine with IB Gateway.
--runners N is the concurrency cap for this repo on the machine: GitHub dispatches
one job per runner instance and queues the rest, so no more than N agent jobs
ever run at once and no labeled issue is dropped — extras wait for a free runner.
It defaults to 1 (one job at a time). Bump it only if the box has spare capacity;
each extra runner is another concurrent agent process competing for CPU/RAM, and if
other repos register their own runners here, total machine load is the sum across all
of them.
Re-running install.sh reconciles to the requested count: lowering --runners (or
upgrading from an older single-runner install) disables the now-stale services so
concurrency never exceeds the cap. Those runners stay registered in GitHub as offline
— run uninstall.sh --repo … --token <remove-token> to fully deregister them.
Concurrent jobs never collide on disk — this holds across repos too, since two repos' runners on one machine can fire jobs at the same time:
- Each runner instance has its own directory and
_workcheckout. - Each job runs the agent in a per-issue git worktree under
RUNNER_TEMP(agent/issue-<n>branch), created at job start and removed at the end. - The generated prompt and run log also live under
RUNNER_TEMP, which is unique per runner and wiped each job — so two jobs on the same box can't clobber them. - A per-issue
concurrencygroup ensures the same issue never runs twice at once.
./scripts/install-dispatcher.sh \
--repo-path /path/to/your-repo \
--label linux # must match the runner label aboveCommit and push .github/workflows/agent-dispatch.yml.
Settings → Secrets and variables → Actions:
| Secret | Value |
|---|---|
GPG_PRIVATE_KEY |
gpg --armor --export-secret-keys <KEY_ID> |
GPG_KEY_ID |
Your signing key ID |
GPG_PASSPHRASE |
GPG passphrase |
Optional — add these two for a branded bot identity (agent actions show as your app instead of github-actions[bot]):
| Secret | Value |
|---|---|
ADLC_APP_ID |
Your GitHub App's numeric App ID |
ADLC_APP_PRIVATE_KEY |
The app's full .pem private key (BEGIN/END lines included) |
Two groups: lifecycle labels (required — the workflow moves issues through them) and
taxonomy labels (optional — they pick the prompt and model). Create all of them
(run inside your repo clone, or add --repo your-org/your-repo to each):
gh label create ready-for-agent -c 0075ca -d "Ready for agentic pickup"
gh label create agent-coding -c e4e669 -d "Agent is actively coding"
gh label create agent-review -c d93f0b -d "Agent PR is in review"
gh label create agent-failed -c b60205 -d "Agent run failed"
gh label create type:initiative -c 0075ca -d "Decompose into epics (architect, no code)"
gh label create type:epic -c 7057ff -d "Spec + break into stories (architect, no code)"
gh label create type:story -c d4c5f9 -d "Implement the change + open a PR (default)"
gh label create type:bug -c d73a4a -d "Diagnose + minimal fix + regression test"
gh label create model:opus -c e4e669 -d "Force Opus for this issue"
gh label create model:sonnet -c cfd3d7 -d "Force Sonnet for this issue"
gh label create auto-merge -c 0e8a16 -d "Merge the PR automatically once a review approves it"agent-deferred-ratelimit (spend/rate-limit breaker tripped — not a failure, auto-requeues)
is created on first use by scripts/spend-guard.sh; you don't need to pre-create it.
How the taxonomy routes a run (resolved at job start from the issue's labels):
| Label | What the agent does | Model |
|---|---|---|
(no type: label) → type:story |
Implement the change, open a PR | model input (default claude-sonnet-4-6) |
type:bug |
Diagnose → minimal fix + regression test → PR | same default |
type:epic |
Architect mode: write a spec, open type:story issues. No code |
claude-opus-4-8 |
type:initiative |
Architect mode: propose an ordered list of epics. No code | claude-opus-4-8 |
model:opus / model:sonnet |
(any type) override the model for this issue | per label |
claude auth login # uses your subscription, not API pricingLabel any issue ready-for-agent. The agent fires within seconds. Add a type: label
to route it (default is type:story); add model:opus/model:sonnet to override the
model. The issue moves ready-for-agent → agent-coding → agent-review (PR opened) or
agent-failed.
Two reviewers feed the same coder agent: an automatic AI pass, then you.
AI review (automatic). Right after the coder opens the PR — in the same run — an
AI reviewer (Opus) judges the diff. If it requests changes, the coder fixes them and
the reviewer looks again, up to review_rounds rounds (default 3). This runs
inline because a GITHUB_TOKEN-authored review can't trigger another workflow. Outcome:
- Approved → if the issue has
auto-merge, the PR is merged; otherwise it waits for you. - Didn't converge in N rounds → it stops and hands off to you at
agent-review. - Set
review_rounds: "0"in your dispatcher to disable the AI reviewer.
An AI reviewing the same pipeline's AI-written code is correlated — treat it as a strong first-pass lint, not a substitute for your review.
Your review. Submit a "Request changes" review or comment /fix on the PR,
and the coder agent picks up all feedback (review body, inline comments, conversation
comments) and revises the PR in place (same branch, no new PR). Re-review; repeat
until you Approve.
- A plain comment without
/fixand without a formal "Request changes" review does not fire a revise./fixonly works from a repo collaborator. - Each revise round commits (signed) and pushes to the PR branch.
- With the
auto-mergelabel, your Approve then merges the PR (see below).
On-demand AI review. Comment /review on any PR (collaborators only) and the AI
reviewer runs the same review→fix loop on it now — useful for PRs that predate the
issue run, or to re-trigger a review after changes. It's the subscription-priced,
loop-driving equivalent of @claude review (the official Code Review is advisory-only
and API-billed).
By default every PR stops at agent-review and waits for a human — that gate is the
safe default and stays the default. To let a specific PR finish the loop on its own,
opt it in with the auto-merge label:
- Add
auto-mergeto the issue (alongsideready-for-agent) and it's copied onto the PR when the agent opens it. To opt in after the PR exists, addauto-mergedirectly to the PR. - It merges (squash, branch deleted) when either the inline AI reviewer approves (during the coder run) or a human approves the PR. Unlabeled PRs are never touched.
So a labeled issue can go all the way hands-off: agent writes → AI reviews → approves → merges, with no human in the loop. Leave the label off and it always stops for your review.
Mechanics: the AI-approval merge happens inline in the coder's run; the human-approval
merge runs on a GitHub-hosted runner via the pull_request_review event.
Note: a human
/fixcomment or review fires the agent because those events are human-authored. GitHub does not re-trigger workflows forGITHUB_TOKEN-authored events, which is exactly why the AI review→fix→merge loop runs inline (one job) rather than as separate event-triggered workflows. (With the optional GitHub App, agent-authored events do trigger workflows — the dispatcher'suser.type != 'Bot'guards keep that from looping.)
If the Claude subscription's monthly spend limit (or a rate limit) is hit mid-run, the
pipeline used to keep dispatching every other ready-for-agent issue behind it — each one
failed its first model call in a few seconds and got stamped agent-failed, a label
reserved for the agent genuinely failing to solve the issue. One real limit hit could
poison an entire backlog that then needed manual re-labeling.
How it works:
- Every
agent-issue.ymlrun classifies its output log for a known limit signal (scripts/spend-guard.sh classify). A hit defers the issue instead of failing it:ready-for-agent/agent-codingare removed,agent-deferred-ratelimitis added, and a comment explains why + when it'll auto-requeue. - The same run also trips a circuit breaker — a small JSON file at
~/.adlc/breaker.jsonon the runner box. Every subsequentagent-issue.ymlrun checks it before checkout/GPG/worktree setup and defers immediately if it's tripped, so a poisoned run costs the rest of the queue a few seconds each instead of a real attempt. - The breaker is machine-local by design: every repo's self-hosted runner on one box shares one Anthropic account, so the limit is machine-wide already — a local file needs no token and is automatically the right scope. If you run adlc across multiple boxes on separate accounts, each has its own independent breaker.
- Auto-requeue: a per-repo hourly cron (
agent-requeue-cron.yml, generated byinstall-dispatcher.shor added manually) calls the reusableagent-requeue.yml. Once the breaker's cooldown has passed, it clears the breaker and releases the single oldestagent-deferred-ratelimitissue back toready-for-agent— never more than one per tick, and never while another issue is alreadyagent-coding. This must run on the same self-hosted box as the dispatcher (the breaker file lives there).
There's no reliable "remaining budget" API for a subscription spend limit, so the cooldown is a conservative guess (next UTC midnight for a spend limit; a short fixed window for a 429), not a verified reset — worst case after a guess expires is one more wasted dispatch, which re-trips the breaker and re-defers. That's the deliberate trade-off: never a cascade, at most an occasional single retry.
Manually checking / clearing the breaker (on the runner box):
bash scripts/spend-guard.sh status # healthy / tripped-until / expired
bash scripts/spend-guard.sh clear # force-clear (e.g. you know the limit already reset)Everything here fails open: a missing jq, an unreadable breaker file, or an
unexpected error is treated as healthy / not-a-limit rather than blocking a good issue —
these scripts load from adlc's main unpinned into every consumer repo, so a bug here
must never become a new way to fail issues that would otherwise have succeeded.
By default every agent action — commits' push, the PR, comments, merges, issue
closes — is performed with the built-in GITHUB_TOKEN and shows up as
github-actions[bot]. Commits themselves still show your verified Agent Coder
GPG identity; only the actor on pushes/PRs/comments/merges is the bot.
To rebrand that actor as your own app (custom name + avatar), register a GitHub
App and add two secrets. It's free, opt-in, and falls back cleanly: with no
app secrets present, everything behaves exactly as before. As a bonus, an App token
isn't subject to GITHUB_TOKEN's anti-recursion rule, so GitHub's native
linked-issue auto-close on merge starts working too (the manual close stays as a
guarded backstop).
Cost & privacy: GitHub Apps are free with higher API rate limits than tokens. The app's private key can mint tokens for every repo it's installed on, so scope the install to specific repos and keep permissions minimal. No webhook is needed — the app is only used to mint a short-lived, repo-scoped token at job start.
- Register the app — GitHub → Settings → Developer settings → GitHub Apps →
New GitHub App. (Use your org's developer settings if you want org-level
secrets later — see multi-repo below.)
- Name: whatever you want the bot to be called (e.g.
myproj-agent). This is what shows on comments asmyproj-agent[bot]. - Homepage URL: anything (your repo URL is fine).
- Webhook: uncheck “Active” — not needed.
- Repository permissions:
- Contents: Read and write (push, merge, delete branch)
- Pull requests: Read and write (create, comment, merge)
- Issues: Read and write (comment, close, label)
- Metadata: Read-only (mandatory, auto-selected)
- Leave everything else untouched → Create GitHub App.
- Name: whatever you want the bot to be called (e.g.
- Grab the App ID — on the app's page, copy the numeric App ID.
- Generate a private key — same page → Private keys → Generate a private
key. A
.pemdownloads. This isADLC_APP_PRIVATE_KEY(the whole file, including the-----BEGIN…/-----END…lines). - Install the app — app page → Install App → install it on the account/org that owns your repos → choose Only select repositories and pick the ones using adlc. (Registering ≠ installing — the app does nothing until installed.)
- Add the secrets — in each repo (or once at org level), add:
ADLC_APP_ID= the numeric App IDADLC_APP_PRIVATE_KEY= the full.pemcontents
- Refresh the dispatcher — regenerate it so the bot-recursion guards land
(re-run
install-dispatcher.sh, or pull the twouser.type != 'Bot'conditions andsecrets: inheriton theauto-mergejob into your existingagent-dispatch.yml). This matters only with the app on: app-authored events can trigger workflows, and the guards stop the inline reviewer's own comments from looping back. Without the app, the old dispatcher is unaffected.
That's it — the next agent run uses the app. Remove the two secrets to instantly revert
to github-actions[bot].
One app covers many repos — you do not register one per repo:
- Register one app, install it on all the repos you want (step 4, select them).
- Personal account: there's no shared secret store, so add the same two secrets
(
ADLC_APP_ID,ADLC_APP_PRIVATE_KEY) to each repo. Same values everywhere. - Many repos? Use a free org. Create a (free) GitHub Organization, register the app under the org's developer settings, then set the two values once as organization secrets (Org → Settings → Secrets and variables → Actions) scoped to the repos that need them. Every repo inherits them — set once, not N times.
The minted token is always scoped to just the repo the run is in, so one shared app across many repos stays least-privilege per run.
Create this file in your repo root. The agent reads it automatically:
## Build
- `cargo check` must show 0 warnings, 0 errors
- `cargo test -p <affected-crate>` must pass
## Commit scopes
core, api, cli, web, data
## Never touch
.env, secrets/, credentials/Override in your agent-dispatch.yml:
with:
build_check_cmd: "npm run lint && npm run build"
build_test_cmd: "npm test"
project_conventions: |
- TypeScript strict mode, no `any`
- Run `npm run lint` before committing#!/usr/bin/env bash
# .adlc/build.sh check|test
case "$1" in
check) cargo check ;;
test) cargo test -p affected-crate ;;
esacServices are named agentic-runner-<org>-<repo>-<N>, one per instance:
# Check status of all instances for a repo
systemctl --user list-units 'agentic-runner-your-org-your-repo-*'
# Restart instance #2
systemctl --user restart agentic-runner-your-org-your-repo-2
# Uninstall — auto-discovers and removes every instance (and any legacy install)
./scripts/uninstall.sh --repo your-org/your-repo --token <REMOVE_TOKEN>
# Remove token: https://github.com/<owner>/<repo>/settings/actions/runnersUse different runner labels to route jobs to the right machine:
| Label | Use case |
|---|---|
linux |
General coding tasks (default) |
ibkr |
Tests that need IB Gateway running locally |
prod |
Deploy jobs on the production server |
Register multiple runners on the same machine with different labels:
./scripts/install.sh --repo your-org/your-repo --label ibkr --token <TOKEN> --serviceRegister a runner per repo on the same machine. Each gets its own service:
./scripts/install.sh --repo your-org/project-a --label linux --token <TOKEN_A> --service
./scripts/install.sh --repo your-org/project-b --label linux --token <TOKEN_B> --serviceFor org-level runners (share one runner across all repos), create a GitHub Organization and register at the org level — the install script's --repo can accept <org> directly once you have an org runner token.