Autonomous Loop
The always-on pipeline that turns triaged GitHub issues into merged-ready PRs with no human in the loop until review time. It runs on the Claude Agent VM as a systemd daemon, paces itself by Claude budget (not by clock), and covers the full PR lifecycle: pick → implement → PR → CI green → manual verification → and, after a human reviews, fixing review comments and merge conflicts autonomously.
systemd (agent-daemon.service, Restart=always)
└─ agent-daemon bash loop: budget gate → fire → sleep plan
└─ agent-run one fire: reset to master, run claude --print "/afk-pickup"
└─ /afk-pickup ONE unit of work per fire, in priority order:
1. /pr-reconcile an open PR needs attention (conflict / AFK:revise)
/deps-land … or the picked PR is a Dependabot PR (reason `dependabot`)
2. resume an AFK:paused issue (usage ran out mid-run)
3. new pick next eligible `AFK` issue from Project #1
└─ /afk-dispatch implementer/reviewer/verifier TDD team
└─ PR open → /pr-watch (CI babysitter) → /pr-review (one-time code review) → manual verification
The daemon (pacing)
scripts/claude-agent/agent-daemon is a supervisor loop, installed as
infra/systemd/agent-daemon.service on the VM. Each pass:
- Sensor — the Usage Sensor (
lib/usage-sensor.sh) reads the account's REAL utilization — session (5-hour) and weekly limits, the same numbers the Claude usage UI shows — from the OAuth usage endpoint, using the token Claude Code already maintains in~/.claude/.credentials.json. If the endpoint is unreachable it falls back to the localccusage blocks --jsontime-proxy (Budget Gate,lib/budget-gate.sh), which only knows the percent of time left in an inferred window. The journal line carriessensor=oauth|ccusage-fallback|degraded. - Decision — fire when
remainPct ≥ BUDGET_GATE_MIN_PCT(default 25), whereremainPctis 100 minus the binding (worst) limit's utilization — a spent weekly wall blocks firing even in a fresh session window, andresetAtis the binding limit's true reset. The gate is plan-agnostic: upgrade the Claude plan and the same threshold just trips more often. - Action — run
agent-run(one/afk-pickupfire). After a clean run the loop re-checks the gate immediately, so the backlog drains within a window. - Schedule — the Sleep Planner (
lib/sleep-planner.sh) sleeps to the window reset and polls after waking.
agent-run markers steer the exceptions:
| Marker on stdout | Meaning | Daemon reaction |
|---|---|---|
AGENT_RUN_NO_WORK=1 |
queue empty or lock skip | chunked sleep with the Work Probe (below) — wakes early if work appears |
AGENT_RUN_RESET_AT=<iso> |
usage exhausted mid-run (issue paused) | sleep to that reset |
| non-zero exit, no marker | genuine failure | probe-sleep (wakeable), deaf after AGENT_DAEMON_FAIL_CAP (default 3) consecutive failures — bounded retry, no hot-loop |
The Work Probe (early wake on new work)
A no-work sleep used to be deaf until the window reset — observed live
2026-07-10: a human merge 52 seconds after a no-work fire conflicted an open
PR, which then waited ~4 hours for the reset. Now the daemon sleeps in
WORK_PROBE_INTERVAL chunks (default 300s) and runs lib/work-probe.sh
between chunks — a pure gh sweep, zero Claude cost. Wake rules:
- lock held (
AFK:in-progressanywhere) → never wake; a fire would just skip. Agherror on the lock read fails safe as "locked". - reconcile candidate (same
pr_triage_pickthe fire runs) or aAFK:pausedissue → wake unconditionally; afk-pickup deterministically acts on both. - new pick candidates (open
AFKissues with no state label) → wake only when the candidate set differs from the baseline captured when the fire reported no work — an issue afk-pickup already declined (open blocker, not in the project) cannot wake-loop the daemon; a genuinely new issue wakes it once.
The Exhaustion Classifier (lib/exhaustion-classifier.sh) tells an
out-of-gas event apart from a real failure: exhaustion pauses the issue
(wip: freeze commit, AFK:in-progress → AFK:paused, branch kept) and is
never a failure; the next window resumes it (cap 3 resumes, then
AFK:failed).
One fire = one unit of work
agent-run hard-resets the checkout to origin/master (anything worth
keeping is already committed on its own branch), then runs
claude --permission-mode bypassPermissions --print "/afk-pickup" with the
background-task ceiling lifted (CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS=0 — a
team dispatch routinely runs past the 600s default).
/afk-pickup then does exactly one of the following, in priority order:
- Reconcile an open PR needing attention (§1.2) — see below. A human waiting on their own review outranks everything.
- Resume an
AFK:pausedissue (§1.5) — in-flight work finishes before new work starts; the partial branch is preserved, never reset. - Pick the next eligible
AFKissue (§2) — must be in GitHub Project #1, highest Priority (P0>P1>P2) then oldest, every nativeblockedBydependency closed, and carrying no assignee other than the daemon itself so it never races a human. Dispatches a full TDD team via/afk-dispatch, opens a PR with the issue's Acceptance Criteria as a## Manual verificationchecklist, then drives the verification tail (next section). - Resolve a wayfinder Decision ticket (§2b) — the same pick, but the
winner carries a
wayfinder:research/wayfinder:tasklabel, so the picker returns verdictpick-wayfinderand the fire routes to/afk-resolveinstead of a team: claim, research against primary sources, persist todocs/research/<map-slug>/<ticket-slug>.md, open adocs(research):PR, green it with/pr-watch, merge it through the docs-only gate, comment the answer, close the ticket, append the Map's Decisions so far, and graduate at most three new tickets out of the fog. No product code, no review or verification tail — the gate is the whole bar. A crash fails the ticket (AFK:failed); running out of gas mid-resolve drops the lock, deletes the pushedresearch/<slug>branch and applies no label, so the next tick restarts it.
AFK:in-progress is the repo-wide single-flight lock: any open issue holding
it makes every other fire skip.
The verification tail (every PR, every push)
After a PR opens (and after any later push to it), the same tail runs before the fire may exit:
/pr-watch— polls CI every 60s (45 min cap per round); on red, spawns an implementer to fix and pushesfix(ci):commits (cap 10 rounds); on exhaustion converts the PR to draft +AFK:checks-failed./pr-review— the one-time autonomous code review (afk-pickup §6a.1b), gated by a<!-- pr-review-done -->marker comment so it runs exactly once in a PR's life and every later tail pass skips it. Two parallel review axes — correctness (built-in/code-reviewat medium effort) and spec (diff vs. the issue's Acceptance Criteria + parent PRD: missing requirements, scope creep, spec mismatches) — post findings as inline review threads marked<!-- pr-review-bot -->/ 🤖. The reviewer never fixes its own findings: when it posted any, it appliesAFK:reviseand the fire ends there — the next fire's triage routes the PR into/pr-reconcile, whose comment loop fixes the threads, replies in-thread, resolves them, drops the label, and re-runs this whole tail. Zero findings → straight on to manual verification. Best-effort by design: never pushes, never drafts the PR.- Manual verification sweep — afk-pickup §6a.2 delegates the round to the
/verify-prharness: it parses the PR's## Manual verificationchecklist, boots a hermetic per-PR stack, spawns themanual-verifieragent to exercise each unchecked item live in a real headful browser / Electron / hermetic Mongo, ticks the boxes it proved, posts one evidence comment per round (### Manual verification — round <M>/3), and tears the stack down. Failures — and deployed-env deferrals that demand a tagged<!-- post-deploy: … -->spec the PR does not yet carry — loop an implementer (fix(manual):commits, cap 3 rounds) and re-enter pr-watch, since a push stales CI. A justified DEFER (real hardware, human observation, or a deployed-env item whose demanded spec is already present) does not loop; it stays unticked for the human.
PR reconcile (the post-review loop)
Before this existed, the flow dead-ended the moment a human left review
comments or master moved under an open PR. Now afk-pickup's §1.2 runs a
cheap gh triage (zero Claude cost when nothing needs attention) using
lib/pr-triage.sh:
A PR needs attention when it is ours (open, not draft, head
feat/issue-<N>, agent-authored) AND either:
- it carries
AFK:revise— a human reviewed it and explicitly handed it back (this is the human→agent signal; apply it after leaving inline review comments), or/pr-reviewposted 🤖 findings and applied the label itself (agent→agent hand-back, same machinery), or - its mergeable state is
CONFLICTING— master moved under it (auto, no label needed), or - it is bot-incomplete — no conflict, no label, but the bot tail never
finished: the one-time review marker (
<!-- pr-review-done -->) and/or anyManual verification — … roundcomment is missing because a prior fire died mid-tail. Detected bypr_triage_enrich(onegh pr view --json comments,filesper otherwise-clean agent PR — the same call carries the docs-only signal below; fails safe toward "complete"), or - it is docs-only — every file it changes lives under
docs/research/(a research PR). It carries no code risk and never earns review/verify rounds, so it is not reconciled at all:lib/docs-only-gate.shre-checks the rule against the real diff (three-dot,--no-renames), refuses unless CI is actually green (an empty check list is not green — nothing ran), and hands §1.2 the exact admin squash-merge command pinned to the head sha (--match-head-commit). The gate only decides; §1.2 runs the merge, so the one call site that can land a commit on master stays in the skill. If the gate refuses — a non-docs path, red/pending/absent checks, a sha that moved — the PR falls back to the normalincompletereconcile path.
Ours covers both harness branch shapes: feat/issue-<N> (slice PRs) and
research/<ticket-slug> (/afk-resolve research PRs — the docs-only ones).
The ticket number comes from the branch, else the PR title's (#N), else it
is null and the reconcile runs without an issue lock.
AFK:revise outranks plain conflicts, which outrank docs-merge, which
outranks incomplete; oldest first within rank. Every Dependabot PR ranks
below all of those, under reason dependabot (or conflict when its branch
needs a rebase); within that block security bumps come before version bumps,
then oldest, one per fire. Parked PRs (AFK:revise-failed /
AFK:rebase-failed / AFK:deps-failed) and drafts are skipped.
This gives the pipeline its ordering invariant: outstanding agent PRs are
finished before any new AFK issue is picked — while any PR still needs
bot work (CI fix, one-time review, verification round, conflict, revise
threads), every fire goes to that PR and §2 never runs. A bot-complete PR
that merely awaits a human merge blocks nothing (work-ahead stays).
A master push leaves every open PR's mergeable state UNKNOWN for a few
seconds while GitHub recomputes it asynchronously. The triage scan
(pr_triage_scan) re-lists while any agent-shaped PR is still UNKNOWN (up
to ~2 min) rather than skipping a conflict the fire lands seconds after a
merge; the work probe re-checks every 5 minutes as the backstop.
The picked PR goes to /pr-reconcile (.claude/skills/pr-reconcile/),
which is fresh and stateless — context is rebuilt from the issue, the diff,
and the review threads (no session resume). Per fire:
- Rebase phase (first, only when CONFLICTING; cap 1 attempt) — rebase
onto
origin/mastervialib/rebase-driver.sh; an implementer resolves conflict stops in place; publish withgit push --force-with-lease— the only sanctioned force-push in the entire system, and the lease guarantees a concurrent human push is never clobbered (push refused instead). Unresolvable →AFK:rebase-failed+ PR comment, parked. - Comment phase (only with
AFK:revise; cap 3 rounds) — enumerate unresolved review threads vialib/thread-reconciler.sh, spawn an implementer to address them, commitfix(review): round <R>, plain push, then reply in-threadfixed in <sha>: <what changed>and resolve each addressed thread. All threads done → dropAFK:revise. Exhaustion/disputes → in-thread "human triage" replies +AFK:revise-failed, parked. - Verification tail — any push staled everything, so the full pr-watch + manual-verification tail re-runs, re-verifying every checklist item including previously ticked ones.
During a reconcile the backing issue's label flips
AFK:done → AFK:in-progress (reusing the single-flight lock) and is
restored on exit — even after a crash (agent-run restores AFK:done,
never AFK:failed, for a crashed reconcile: the issue's work was already
done).
Human workflow
- Review an agent PR. Want changes? Leave inline review comments (not
just a top-level comment) and apply the
AFK:reviselabel. - Or do nothing: if master drifts and the PR conflicts, the next fire rebases it automatically.
- The agent pushes fixes, replies to each comment with what changed, resolves the threads, drops the label, and re-greens CI + manual verification.
- Fix missed the point? Re-open the thread and re-apply
AFK:revise. AFK:rebase-failed/AFK:revise-failedon a PR means the agent gave up — it is parked for you and will not be re-picked until the label is removed.
Label taxonomy
| Label | On | Meaning |
|---|---|---|
AFK |
issue | eligible for autonomous pickup (must also be in Project #1) |
AFK:in-progress |
issue | single-flight lock — a fire is working it (or reconciling its PR) |
AFK:paused |
issue | usage ran out mid-run; branch kept; resumes next window |
AFK:done |
issue | implemented; PR open/merged |
AFK:failed |
issue | dispatch failed or resume cap hit; human triage |
AFK:checks-failed |
PR | CI or manual verification could not be brought to pass (fix loops exhausted); PR is drafted |
AFK:revise |
PR | hand-back: address the unresolved review comments — applied by a human review or by /pr-review (its 🤖 findings) |
AFK:revise-failed |
PR | revise loop exhausted (3 rounds) or disputed; parked |
AFK:rebase-failed |
PR | auto-rebase failed (conflicts unresolvable or lease refused); parked |
AFK:deps-failed |
PR | Dependabot PR: verify/fix loop exhausted (3 attempts); PR is drafted and never re-picked |
The Dependabot lane (/deps-land)
A Dependabot PR — authored by the Dependabot app on a dependabot/ branch,
with no backing issue — is picked under reason dependabot and goes to
/deps-land (.claude/skills/deps-land/) instead of /pr-reconcile. One
fire drives one bump to one terminal outcome: merged, HITL, deps-failed or
superseded.
Per fire, re-reading PR state before every step: retitle (fix(deps): for a
security bump, so release-please cuts a patch release) → inject the Bot-PR
checklist (scripts/verify-pr/bot-pr-checklist.md) → Tier A
(/pr-watch --bot: every check green) → Tier B (one /verify-pr round with
the screenshot tour forced; any FAIL or any deferral is a failure) → the
deps gate (lib/deps-gate.sh), whose approving verdict afk-pickup §1.2
merges at the same call site as docs-merge. Fixes are bounded at 3 attempts
in total, counted from sha-keyed marker comments (lib/deps-lane.sh) so a
crashed fire resumes and a force-push invalidates every earlier verdict.
Major bumps are verified the same way but never merged by machine: the lane
adds HITL and hands off with both tier verdicts and a pointer to the tour; an
ordinary GitHub review approval lets a later fire re-gate and land it.
Exhaustion drafts the PR with AFK:deps-failed. A conflicting bot branch gets
@dependabot rebase, or the rebase driver once the lane has pushed commits of
its own.
Testing
Every deep module is a sourceable bash lib with a sibling .test.sh
(stub-injected GH_BIN/GIT_BIN/CLAUDE_BIN/clock; the rebase driver runs
against real throwaway git repositories because rebase/lease semantics are the
behavior under test). CI runs all suites (scripts/claude-agent/**.test.sh)
on every PR.
| Module | Job |
|---|---|
lib/usage-sensor.sh |
fire-vs-wait from real OAuth session/weekly utilization |
lib/budget-gate.sh |
fallback fire-vs-wait from ccusage window time % |
lib/sleep-planner.sh |
sleep-to-reset + post-wake poll plan |
lib/exhaustion-classifier.sh |
OK / EXHAUSTED / FAILED + reset scrape |
lib/pause-resume.sh |
resume vs fail (cap) for paused issues |
lib/pr-triage.sh |
which PR needs reconciling (ours-filter, rank, order) |
lib/docs-only-gate.sh |
docs-only verdict + the pinned admin squash-merge command |
lib/work-probe.sh |
mid-sleep "did work appear?" scan + wake decision |
lib/thread-reconciler.sh |
unresolved-thread enum, in-thread reply, resolve |
lib/review-poster.sh |
render/post marked inline review comments, agent-thread filter, done-marker |
lib/rebase-driver.sh |
rebase / continue / abort / force-with-lease push |
lib/deps-lane.sh |
Dependabot lane text transforms: security retitle, checklist inject, sha-keyed markers |
Operational notes
- Deploying loop changes — skills and libs are read fresh each fire
(
agent-runresets toorigin/master), so merging to master is the deploy. Onlyagent-daemon/budget-gate/sleep-plannerchanges need a daemon reload:kill -9 <MainPID>(sudo-free;Restart=alwaysrespawns with the new code). - Host env drop-in —
~/.config/agent-daemon/env(systemdEnvironmentFile) survives the per-fire reset; it carriesCLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS=0and anyGH_TOKEN.WORK_PROBE_INTERVAL=<secs>tunes the mid-sleep probe cadence (default 300);AGENT_DAEMON_FAIL_CAP=<n>the consecutive-failure cap before the failure path sleeps deaf to the reset (default 3). - Stuck lock — a crashed fire normally cleans up after itself
(
fail_inflight); if not:gh issue edit <N> --remove-label AFK:in-progress. - Pause the whole loop —
sudo systemctl stop agent-daemon(start again to resume). Individual work items are parked by their labels instead;AFK:pausedis the per-issue out-of-gas state, not a loop switch.
Related
- Agent Teams overview — the dispatch/roles system a fire drives
- Dispatch —
/afk-dispatchplaybook and hooks - Claude Agent VM — host setup
- Skills:
.claude/skills/afk-pickup/,pr-watch/,pr-review/,pr-reconcile/,deps-land/,afk-dispatch/,afk-resolve/— the authoritative playbooks