Chapter 79 — Project 6: an overnight agent that opens real pull requests
The brief
Your team's service has 140 dependencies. Every week some publish new versions; every month one of those has a breaking change nobody has time to handle, so the backlog grows until a security fix forces a painful week of upgrades. Bots that bump versions exist, but they stop at "tests failed".
You'll build an agent that runs every night, takes one outdated
dependency at a time, upgrades it, fixes whatever breaks, proves it with
the test suite, and opens a pull request for a human to review in the
morning. It never merges, never touches production, and never pushes to
main.
This project uses every chapter in this part:
| Piece | Chapter |
|---|---|
| AGENTS.md + a skill for "how we upgrade dependencies" | 78 |
| A harness: permissions, post-hooks, sandbox | 74 |
| An outer loop: one dependency per fresh session, verified by the harness | 75 |
| An eval set of past upgrades | 76 |
| A scheduled, sandboxed, observable deployment with a human gate | 77 |
What it must NOT do (write this first, always)
- push to
main, merge anything, or change CI configuration - have network access beyond the package registry and your git host
- have any production credentials (it gets a token that can only push branches and open PRs on this one repo)
- delete, skip, or weaken tests (
@skip,xfail, lowering coverage limits) - spend more than $15 a night or run more than 3 hours
The architecture
02:00 cron (CI scheduler)
|
v
CI job in a fresh container (no secrets except: model API key,
| repo-scoped PR token)
|
+-- 1. plan.py list outdated deps -> features.json
| (one task per dependency, smallest risk first)
|
+-- 2. outer_loop.py (Ch 75) for each task, fresh agent session:
| agent reads AGENTS.md + skill "dependency-upgrade"
| bumps version, reads changelog, fixes code
| HARNESS verifies: lint + type-check + full test suite
| + "no test files weakened" check
| PASS -> harness commits on branch deps/<name>-<version>
| FAIL x3 -> logged as "needs human", branch discarded
|
+-- 3. open_prs.py one PR per passing branch, body = progress notes
| + changelog links + the agent's own risk summary
|
+-- 4. traces + cost -> dashboard; summary posted to team chat
|
09:00 humans review PRs <-- the approval gate
Step 1 — Project rules and the skill
AGENTS.md (in the repo, used by humans' agents too):
## Test
- `make lint typecheck test` must all pass. That is the definition of done.
## Rules
- Never modify files under tests/ to make a failing test pass. If a test is
genuinely wrong because the library changed behaviour, STOP and explain in
progress.md instead.
- Never edit .github/ or Makefile.
skills/dependency-upgrade/SKILL.md:
---
name: dependency-upgrade
description: Upgrade one dependency and fix breakages. Use for any task that
says "upgrade <package>".
---
1. Read the package's changelog / release notes between the current and
target version (the URL is in the task). List breaking changes first.
2. Bump ONLY that package (and its required peers) in the lockfile.
3. Run `make test`. For each failure, find the changelog entry that explains
it, and fix OUR code to match the new API. Prefer the library's
recommended migration over workarounds.
4. Search for usages of any deprecated API mentioned in the changelog, even
if tests pass, and migrate them.
5. In progress.md write: breaking changes found, files changed, anything a
reviewer should look at closely, and your risk rating (low/med/high) with
one sentence of reasoning.
Step 2 — Plan: turn outdated dependencies into tasks
# plan.py -- one task per outdated dependency, patch/minor before major
import json, subprocess
out = subprocess.run(["pip", "list", "--outdated", "--format=json"],
capture_output=True, text=True, check=True).stdout
deps = json.loads(out)
def risk(d): # semver distance: patch < minor < major
cur, new = d["version"].split("."), d["latest_version"].split(".")
return 2 if cur[0] != new[0] else 1 if cur[1:2] != new[1:2] else 0
tasks = []
for d in sorted(deps, key=risk)[:8]: # at most 8 a night
tasks.append({
"id": f"deps-{d['name']}-{d['latest_version']}",
"desc": (f"upgrade {d['name']} from {d['version']} to "
f"{d['latest_version']} (use the dependency-upgrade skill; "
f"changelog: https://pypi.org/project/{d['name']}/)"),
"verify": "make lint typecheck test && python check_tests_untouched.py",
"passes": False,
})
json.dump(tasks, open("features.json", "w"), indent=2)
print(f"{len(tasks)} tasks planned")
(Use your ecosystem's equivalent: npm outdated --json, go list -m -u -json all,
cargo outdated, and so on.)
Step 3 — The extra verifier: tests must not be weakened
The most common cheat in long-running coding agents is making the tests easier. Make it impossible to get a PASS that way:
# check_tests_untouched.py -- fail if this attempt weakened the test suite
import re, subprocess, sys
# compare against main, so changes the agent already COMMITTED are included
diff = subprocess.run(["git", "diff", "main", "--", "tests/"],
capture_output=True, text=True).stdout
deleted_tests = re.findall(r"^-\s*def test_", diff, re.M)
new_skips = re.findall(r"^\+.*(@pytest\.mark\.(skip|xfail)|pytest\.skip\()", diff, re.M)
if deleted_tests or new_skips:
print(f"tests weakened: {len(deleted_tests)} removed, {len(new_skips)} skips added")
sys.exit(1)
Step 4 — Run the outer loop in the sandbox
Reuse outer_loop.py from Chapter 75, with features.json, progress.md,
and STOP in .gitignore, and one addition. Before
each task, create the branch, so each passing task's harness commit lands on
its own branch:
# in main(), right after picking `task`:
sh(f"git checkout -q main && git checkout -q -B {task['id']}")
Set the budgets for the night: MAX_ITERATIONS = 24 (8 tasks × 3 attempts),
MAX_HOURS = 3, and a provider-side spend limit on the API key as a second
fence.
Step 5 — Open the PRs (the human gate)
# open_prs.py -- one PR per verified branch; humans decide
import json, subprocess
for t in json.load(open("features.json")):
if not t["passes"]:
continue
notes = subprocess.run(["grep", t["id"], "progress.md"],
capture_output=True, text=True).stdout
body = (f"Automated upgrade, verified by `{t['verify']}`.\n\n"
f"### Agent notes\n{notes}\n\n"
f"Review the risk rating and changed files before merging.")
subprocess.run(["git", "push", "-q", "origin", t["id"]], check=True)
subprocess.run(["gh", "pr", "create", "--head", t["id"], "--base", "main",
"--title", t["desc"].split(" (")[0],
"--body", body, "--label", "agent"], check=True)
Note that the harness pushes, not the agent. The agent's own permissions
deny git push (Chapter 74); only this small, deterministic script, which
pushes branches the harness verified, has the token.
Step 6 — Schedule it (CI as the runtime)
Any CI system with a scheduler works. A GitHub Actions sketch:
# .github/workflows/nightly-deps-agent.yml
name: nightly-deps-agent
on:
schedule: [{cron: "0 2 * * 1-5"}] # 02:00 UTC, weekdays
workflow_dispatch: {} # manual run button
concurrency: deps-agent # never two runs at once
jobs:
run:
runs-on: ubuntu-latest
timeout-minutes: 200 # hard wall-clock limit
permissions: {contents: write, pull-requests: write} # nothing else
steps:
- uses: actions/checkout@v4
with: {fetch-depth: 0}
- run: pip install -r requirements-dev.txt
- run: npm install -g @anthropic-ai/claude-code # or your agent CLI
- run: python plan.py
- run: python outer_loop.py claude -p
env:
ANTHROPIC_API_KEY: ${{ secrets.DEPS_AGENT_API_KEY }} # spend-capped key
- run: python open_prs.py
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/upload-artifact@v4 # the morning report
with: {name: agent-logs, path: "progress.md\nfeatures.json"}
The CI runner is the sandbox here: a fresh VM per run, destroyed afterwards, with exactly two secrets. For stricter isolation (egress allowlists, microVMs), run the same scripts on your own runners (Chapter 77).
Step 7 — Evaluate before you trust it
Before turning on the schedule, build an eval set from history (Chapter 76): find 15 past dependency upgrades in your git log that needed code changes. For each one, check out the commit before the upgrade, run the agent on that single task 3 times, and grade:
- outcome: does the full suite pass, and do the hidden tests that the human added in the real upgrade commit pass?
- rules: no test files weakened, no files outside the allowed paths changed
- cost and steps per task
You'll get a pass^3 per upgrade type. Typical findings: patch and minor bumps pass reliably; major versions with big API changes fail often. That's fine, because those become "needs human" in the morning report, which is still useful: the agent has already read the changelog and listed the breaking changes.
Step 8 — Watch it in production
Each morning's summary (posted to team chat by a final CI step) should show:
Nightly deps agent -- 2026-10-03
tasks: 8 verified PRs: 5 needs human: 2 skipped (budget): 1
cost: $6.40 (limit $15) wall time: 1h52m
PRs: #4190 requests 2.32->2.33 (low) #4191 pydantic 2.9->2.11 (med) ...
needs human: sqlalchemy 1.4->2.0 (major; 41 call sites use Query API,
see progress.md)
Track over weeks: PR merge rate (the true success metric), time reviewers spend per PR, cost per merged PR, and reverted agent PRs (should be ~0). If reviewers start rubber-stamping, slow the agent down; the human gate only works if humans actually review.
What you've built
A real, deployable long-running agent with the shape most production coding agents share: scheduled trigger → sandboxed runtime → outer loop of fresh sessions → harness-verified work → human approval gate → observability. Swap the task source and verifier, and the same skeleton becomes a flaky-test fixer, a lint-debt burner, a docs updater, or a security-advisory patcher.
Extensions to try
- Add a reviewer sub-agent: after a PASS, a second agent with a fresh context reviews the diff against the changelog and must approve before the harness commits.
- Run tasks in parallel in separate sandboxes, with a lock file so two agents never upgrade packages that depend on each other at the same time.
- Export OpenTelemetry traces for each session and build the cost-per- merged-PR dashboard.
- Feed reviewer comments on merged PRs back into the skill: every "please
don't do X" becomes a line in
SKILL.md.
Check yourself
- Why does the harness, not the agent, push branches and open PRs?
- What does
check_tests_untouched.pydefend against, and why is it part ofverifyrather than a prompt instruction? - Why is the eval set built from past upgrades, and what do the hidden tests add?
- Which metric is the real measure of success for this agent, and why isn't it "PRs opened"?
Further reading
- Docs: your coding agent's headless / CI mode documentation (for example
Claude Code's GitHub Actions integration or
codex exec). - Article: "Effective harnesses for long-running agents" — Anthropic (Nov 2025), again; Step 4 is a direct application.
- Docs: GitHub Actions security hardening guide: least-privilege tokens and secrets in CI.
End of Part 20 — Milestone check
- I can draw the anatomy of an agent harness and say which failure each layer prevents
- I've added a permission policy and a post-tool hook to an agent
- I can name the four nested loops and explain why fresh contexts help long tasks
- I've run an outer loop where the harness, not the agent, verifies and commits
- I can explain pass@k vs pass^k and compute both
- I can explain durable execution and why tools also need idempotency keys
- I know what AGENTS.md, Skills, MCP, and A2A are each for
- I've scheduled an agent whose output goes through a human approval gate