Stop the agent before it gets clever. That is the whole article.
An unbounded coding loop is not a senior engineer. It is a process that can keep spending tokens, touching files, and narrating progress until you kill it. This week's DEV timeline is full of takes on whether models already out-code most of us. Wrong question. Can you prove the session ended, the patch is actually a patch, and a test you wrote before the first call still passes?
I generate wherever is convenient. I apply on my machine. Always.
The contract I actually enforce
Three rules. Not vibes.
- The model gets a hard turn budget. Turn nine does not exist.
- The only accepted stdout is a unified diff. Prose is a protocol error.
- A test command is registered in a ledger before generation. If that command was not hashed into the job, I do not run the patch.
Who picks the files? Not the model. An allowlist I wrote does. Does the patch even apply? git apply --check answers that, not a confident paragraph.
Below is a from-zero workflow you can run in a throwaway repo. Each stage has a verification command. If a step fails, stop. Do not "just try applying it."
Stage 0 — A throwaway repo you can burn
Do not practice this on a worktree that matters. Make a toy.
mkdir -p /tmp/turn-budget-demo && cd /tmp/turn-budget-demo
git init -q
git config user.email demo@example.com
git config user.name demo
printf 'def add(a, b):\n return 0\n' > calc.py
printf 'def test_add():\n from calc import add\n assert add(2, 3) == 5\n' > test_calc.py
git add calc.py test_calc.py
git commit -qm 'broken add on purpose'
Verification:
git log --oneline -1
python3 -c "from calc import add; print(add(2,3))"
python3 -m pytest -q test_calc.py; echo EXIT:$?
You should see 0, then a failing test. Good. The bug is the job. The test is yours. The model has not spoken yet.
Stage 1 — Write the ledger first
If the budget lives in a chat window, it is not a budget. Put it in a file the gate will refuse to start without.
mkdir -p agent_job
cat > agent_job/ledger.json <<'EOF'
{
"job_id": "fix-add-2026-09-14",
"turn_budget": 8,
"turns_used": 0,
"allowed_paths": ["calc.py"],
"test_command": ["python3", "-m", "pytest", "-q", "test_calc.py"],
"status": "open"
}
EOF
Verification:
python3 - <<'PY'
import json, pathlib
led = json.loads(pathlib.Path('agent_job/ledger.json').read_text())
assert led['turn_budget'] == 8
assert led['allowed_paths'] == ['calc.py']
assert led['test_command'][0] == 'python3'
print('ledger ok', led['job_id'])
PY
Why eight? Because I have never seen a useful local fix that needed a novella. If the model cannot propose a patch in eight turns, the job is underspecified. Raise the budget in the file, on purpose, with a commit. Do not raise it in conversation.
Stage 2 — Freeze the allowlist as a second source of truth
JSON is easy to edit by accident. I also keep a boring text file the gate diffs against the ledger. Two files, one policy.
printf 'calc.py\n' > agent_job/allowlist.txt
Verification:
python3 - <<'PY'
import json, pathlib
led = json.loads(pathlib.Path('agent_job/ledger.json').read_text())
listed = pathlib.Path('agent_job/allowlist.txt').read_text().splitlines()
assert led['allowed_paths'] == listed
print('allowlist matches ledger')
PY
Can the model "also tweak tests to make them pass"? Not if test_calc.py is absent from that list. That is the point. Tests are the human's contract. Patches that rewrite the contract are not fixes. They are negotiations you did not approve.
Stage 3 — The gate script (this is the artifact)
The generator can be a laptop, a container, or a remote box. The gate is local. It increments turns, rejects extra paths, demands a unified diff, and only then calls git apply --check.
Save this as agent_job/gate.py:
#!/usr/bin/env python3
"""Fail closed: turn budget, path allowlist, unified diff, git apply --check."""
from __future__ import annotations
import json
import pathlib
import re
import subprocess
import sys
ROOT = pathlib.Path(__file__).resolve().parent.parent
LEDGER = ROOT / "agent_job" / "ledger.json"
ALLOW = ROOT / "agent_job" / "allowlist.txt"
DIFF_PATH = pathlib.Path(sys.argv[1]) if len(sys.argv) > 1 else ROOT / "agent_job" / "proposal.diff"
def die(msg: str, code: int = 1) -> None:
print(f"GATE FAIL: {msg}", file=sys.stderr)
sys.exit(code)
def load_ledger() -> dict:
if not LEDGER.is_file():
die("missing ledger.json")
data = json.loads(LEDGER.read_text())
listed = [line for line in ALLOW.read_text().splitlines() if line.strip()]
if data.get("allowed_paths") != listed:
die("ledger allowed_paths != allowlist.txt")
return data
def save_ledger(data: dict) -> None:
LEDGER.write_text(json.dumps(data, indent=2) + "\n")
def parse_changed_paths(diff_text: str) -> list[str]:
paths = []
for line in diff_text.splitlines():
if line.startswith("+++ b/"):
paths.append(line[6:])
elif line.startswith("diff --git "):
parts = line.split(" b/", 1)
if len(parts) == 2:
paths.append(parts[1])
# unique, keep order
seen = []
for p in paths:
if p not in seen:
seen.append(p)
return seen
def main() -> None:
led = load_ledger()
if led.get("status") != "open":
die(f"job status is {led.get('status')!r}, not open")
used = int(led["turns_used"]) + 1
budget = int(led["turn_budget"])
if used > budget:
led["status"] = "exhausted"
save_ledger(led)
die(f"turn {used} exceeds budget {budget}", code=2)
if not DIFF_PATH.is_file():
die(f"missing diff {DIFF_PATH}")
text = DIFF_PATH.read_text(encoding="utf-8")
if not text.startswith("diff --git ") and "\n--- " not in text:
die("output is not a unified diff; prose is a protocol error")
if re.search(r"^Binary files ", text, re.M):
die("binary patches are rejected")
changed = parse_changed_paths(text)
allowed = set(led["allowed_paths"])
extra = [p for p in changed if p not in allowed]
if extra:
die(f"paths not on allowlist: {extra}")
if not changed:
die("diff names no files")
check = subprocess.run(
["git", "apply", "--check", str(DIFF_PATH)],
cwd=ROOT,
capture_output=True,
text=True,
)
led["turns_used"] = used
if check.returncode != 0:
save_ledger(led)
die(f"git apply --check failed on turn {used}: {check.stderr.strip()}")
apply = subprocess.run(
["git", "apply", str(DIFF_PATH)],
cwd=ROOT,
capture_output=True,
text=True,
)
if apply.returncode != 0:
save_ledger(led)
die(f"git apply failed: {apply.stderr.strip()}")
test = subprocess.run(led["test_command"], cwd=ROOT)
if test.returncode != 0:
subprocess.run(["git", "checkout", "--", *changed], cwd=ROOT)
led["status"] = "test_failed"
save_ledger(led)
die(f"test command failed; reverted {changed}", code=3)
led["status"] = "passed"
save_ledger(led)
print(f"GATE PASS: turn {used}/{budget}, applied {changed}")
if __name__ == "__main__":
main()
Verification that the file parses:
python3 -m py_compile agent_job/gate.py && echo compile_ok
No model yet. You now have a bouncer.
Stage 4 — Prove the gate fails closed
Before you generate anything real, attack your own gate. If it cannot fail, it cannot protect you.
Check A — prose is not a patch.
printf 'Sure, I updated calc.py. Trust me.\n' > agent_job/proposal.diff
python3 agent_job/gate.py; echo EXIT:$?
Expect GATE FAIL and a non-zero exit. Did it apply anyway? Then you are not running the script I pasted.
Check B — extra path is rejected.
cat > agent_job/proposal.diff <<'EOF'
diff --git a/test_calc.py b/test_calc.py
--- a/test_calc.py
+++ b/test_calc.py
@@ -1,3 +1,3 @@
def test_add():
from calc import add
- assert add(2, 3) == 5
+ assert True
EOF
python3 agent_job/gate.py; echo EXIT:$?
The model "fixed" the test. Cute. The allowlist should spit it out.
Check C — reset turns after the drills.
Those failed attempts still increment turns_used on some paths. I want a clean budget before generation. Reset on purpose:
python3 - <<'PY'
import json, pathlib
p = pathlib.Path('agent_job/ledger.json')
d = json.loads(p.read_text())
d['turns_used'] = 0
d['status'] = 'open'
p.write_text(json.dumps(d, indent=2) + '\n')
print(d)
PY
git checkout -- calc.py test_calc.py
Verification: turns_used is 0, working tree is clean, tests still fail. The job is still open. You are allowed to generate now.
Stage 5 — Generate a patch, not a story
Prompt the model with the ledger, the failing test, and one sentence: return only a unified diff against calc.py. No fences. No apology. No "here is what I changed."
I do that generate step on a throwaway machine when I can. Secrets never go with it. The working tree stays here. The remote side sees calc.py, test_calc.py, and ledger.json. That is the whole packet.
When I need a remote generate step, I use MonkeyCode. Disclosure: This article was prepared as part of MonkeyCode's product outreach. It is an open-source project with free model access and a free server option. I still do not let that server apply the patch. The ledger, the allowlist, and git apply --check stay local.
If you are offline, skip the remote box. Write the patch yourself to prove the gate. The tutorial must work without a vendor.
cat > agent_job/proposal.diff <<'EOF'
diff --git a/calc.py b/calc.py
--- a/calc.py
+++ b/calc.py
@@ -1,2 +1,2 @@
def add(a, b):
- return 0
+ return a + b
EOF
Verification that Git likes the shape:
git apply --check agent_job/proposal.diff && echo check_ok
If that fails, do not call the gate. Fix the diff. The gate is not a debugger for broken patch headers.
Stage 6 — Apply only through the gate
python3 agent_job/gate.py agent_job/proposal.diff; echo EXIT:$?
python3 -c "from calc import add; print(add(2,3))"
python3 -m pytest -q test_calc.py
cat agent_job/ledger.json
Expected: GATE PASS, printed 5, tests green, status is passed, turns_used is 1.
What if it had failed the test? The script checks out the touched files and marks test_failed. You still have a ledger. You still have a budget. You do not have a half-applied tree you "will clean up later." Later is how these loops eat a Saturday.
Stage 7 — Exhaustion is a feature
Force the budget to prove turn nine cannot happen.
python3 - <<'PY'
import json, pathlib
p = pathlib.Path('agent_job/ledger.json')
d = json.loads(p.read_text())
d['turns_used'] = 8
d['status'] = 'open'
# pretend we reopened a spent job by mistake
p.write_text(json.dumps(d, indent=2) + '\n')
PY
python3 agent_job/gate.py agent_job/proposal.diff; echo EXIT:$?
cat agent_job/ledger.json
Exit code 2. Status exhausted. No apply. That is the product. An agent that cannot stop is not autonomous. It is unattended.
Reset if you want to keep playing. Or delete /tmp/turn-budget-demo. Both are honest endings.
What this is not
This is not evals. This is not a claim that any model is "better than most developers." I did not measure tokens, latency, or win rates. I measured whether a patch applied and whether a test I authored still meant what I thought it meant.
It is also not a substitute for code review. A green test on add(2, 3) will not catch add growing a network call. The allowlist is small on purpose. If your job needs twelve files, your job is not one job.
Limitations, and who should skip this
The path parser is deliberately picky. Some diffs with unusual prefixes will fail closed. Good. Broaden the parser only with a test fixture, not with a hopeful regex you pasted from a chat.
git apply --check does not understand semantic correctness. It understands hunks. A patch can apply cleanly and still be wrong. That is why the test command is in the ledger before turn one.
Do not use this if you cannot write the failing test first. Do not use it if your "agent" needs production secrets, SSH keys, or a live database. Do not use it to merge straight to main. Do not use it as permission to skip reading the diff. The gate is a bouncer, not a reviewer.
Windows users will want to swap the test command for whatever runner they already trust. The protocol does not care. The ledger does.
Why I bother
Because the interesting failure is not "the model wrote a bad function." The interesting failure is "the model wrote seventeen functions, rewrote the test, and I lost the thread on turn four." A turn ledger makes that visible. A patch gate makes it reversible.
So: cap the turns. Demand a diff. Check it. Run your test. Then maybe you let a free remote generate step exist at all. If you want that generate step on a box that already offers free model access and a free server option, MonkeyCode is what I pointed the generate side at — the apply side never leaves the repo you can git status.
Top comments (0)