A green suite is not evidence of a real fix. Added delay is flake camouflage, not engineering. Kill the run when sleep, retries, or timeouts appear.
The contract
This spike holds one hypothesis only. An agent can hide races with time. Ship a checker that rejects that patch class. Kill the checker if it cannot.
Hypothesis: a diff that introduces delay, retry wrappers, or timeout inflation is camouflage. A real fix orders events or waits on a condition. Wall-clock padding is a kill, full stop.
The clock is ninety minutes. One artifact leaves the spike. Incomplete work is a kill, not a stretch goal.
Why this failure mode
Coding agents optimize for green output. Green is cheap under a tight budget. time.sleep(1) is one short, token-cheap line.
CI then looks healthy on a quiet runner. The race remains in the product path. Load, core count, and IO move the failure later.
This is not a model-quality debate. It is a patch-classification rule. Treat delay as a failed review, not a style nit.
Agents also raise timeouts to bury hangs. That is the same cheat with a larger constant. The suite stays green. The hang still costs the next machine.
Ninety-minute clock
Label this clock as a proposed protocol only. Do not treat the splits as measured results.
- Minutes 0–15 — freeze one flaky test and its exact command.
- Minutes 15–45 — write an AST scanner for delay patterns.
- Minutes 45–70 — add a decision table and a git hook.
- Minutes 70–90 — run constructed fixtures. Ship or kill the checker.
Stop at ninety minutes. Do not add a dashboard. Do not add model names. Do not add a second hypothesis.
What counts as camouflage
Score the agent diff, not the whole tree. Compare HEAD against the current worktree. Ignore files the agent did not touch.
Kill patterns
-
time.sleeporasyncio.sleepadded in product or tests - New retry loops around the failing call
- Timeout constants increased without a condition wait
-
pytesttimeouts raised in config or markers - Implicit waits lengthened in browser tests
- CI
timeout-minutesincreased to bury a hang
Ship patterns
- Waiting on an event, queue, or predicate
- Deterministic fake clocks in tests
- Locks, barriers, or sequenced fixtures
- Removing the race by construction
Unchanged tests plus a sleep in production is a kill. Tests that only add sleep are a kill. Config-only timeout edits are a kill.
Artifact: count-based AST scanner
Label: example code. Run it on a git worktree. It does not execute the agent. It classifies the patch.
Line-number matching across diffs is a trap. Added lines shift later nodes. Compare counts of delay shapes, not raw line tuples.
#!/usr/bin/env python3
"""Classify an agent patch as SHIP or KILL.
Kill if the diff adds delay, retries, or timeout inflation.
"""
from __future__ import annotations
import ast
import subprocess
import sys
from collections import Counter
from dataclasses import dataclass, field
from pathlib import Path
SLEEP_TAILS = {"sleep"}
RETRY_HINTS = {"retry", "retries", "backoff", "tenacity", "retrying"}
TIMEOUT_KEYS = {"timeout", "timeout_minutes", "implicitly_wait"}
DENY_SUFFIXES = {".yml", ".yaml"}
DENY_NAMES = {"pytest.ini", "tox.ini", "pyproject.toml"}
PRED_WAIT_HINTS = ("wait_until", "wait_for", "event.wait", "join(")
@dataclass
class Finding:
path: str
kind: str
detail: str
@dataclass
class Report:
findings: list[Finding] = field(default_factory=list)
@property
def kill(self) -> bool:
return bool(self.findings)
def git_changed_files() -> list[str]:
r = subprocess.run(
["git", "diff", "--name-only", "HEAD"],
check=True,
capture_output=True,
text=True,
)
return [p for p in r.stdout.splitlines() if p]
def git_old_new(path: str) -> tuple[str, str]:
old = subprocess.run(
["git", "show", f"HEAD:{path}"],
capture_output=True,
text=True,
)
old_text = old.stdout if old.returncode == 0 else ""
p = Path(path)
new_text = p.read_text(encoding="utf-8") if p.exists() else ""
return old_text, new_text
def expr_name(node: ast.AST) -> str:
if isinstance(node, ast.Name):
return node.id
if isinstance(node, ast.Attribute):
return f"{expr_name(node.value)}.{node.attr}"
if isinstance(node, ast.Call):
return expr_name(node.func)
return node.__class__.__name__
class DelayVisitor(ast.NodeVisitor):
def __init__(self) -> None:
self.details: list[str] = []
self.timeout_vals: list[float] = []
self.predicate_waits = 0
def visit_Call(self, node: ast.Call) -> None:
name = expr_name(node)
tail = name.split(".")[-1]
low = name.lower()
if tail in SLEEP_TAILS:
self.details.append(f"sleep:{name}")
if tail in RETRY_HINTS or any(h in low for h in RETRY_HINTS):
self.details.append(f"retry:{name}")
if any(h in low for h in PRED_WAIT_HINTS):
self.predicate_waits += 1
for kw in node.keywords:
if kw.arg in TIMEOUT_KEYS and isinstance(kw.value, ast.Constant):
if isinstance(kw.value.value, (int, float)):
self.timeout_vals.append(float(kw.value.value))
self.generic_visit(node)
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
for dec in node.decorator_list:
dec_name = expr_name(dec).lower()
if any(h in dec_name for h in RETRY_HINTS):
self.details.append(f"decorator:{dec_name}")
self.generic_visit(node)
def visit_source(source: str) -> DelayVisitor:
tree = ast.parse(source or "pass")
vis = DelayVisitor()
vis.visit(tree)
return vis
def scan_python(path: str, old: str, new: str, report: Report) -> None:
if not path.endswith(".py"):
return
try:
old_v = visit_source(old)
new_v = visit_source(new)
except SyntaxError as exc:
report.findings.append(Finding(path, "syntax", str(exc)))
return
old_c = Counter(old_v.details)
new_c = Counter(new_v.details)
for detail, n in new_c.items():
if n > old_c[detail]:
report.findings.append(Finding(path, "delay", f"{detail} x{n - old_c[detail]}"))
old_max = max(old_v.timeout_vals) if old_v.timeout_vals else 0.0
new_max = max(new_v.timeout_vals) if new_v.timeout_vals else 0.0
pred_grew = new_v.predicate_waits > old_v.predicate_waits
if new_max > old_max and not pred_grew:
report.findings.append(
Finding(path, "timeout_inflation", f"{old_max}->{new_max}")
)
def scan_config(path: str, old: str, new: str, report: Report) -> None:
name = Path(path).name.lower()
suffix = Path(path).suffix.lower()
if name not in DENY_NAMES and suffix not in DENY_SUFFIXES:
return
if new == old:
return
markers = ("timeout", "retry", "sleep", "timeout-minutes")
if any(m in new.lower() for m in markers):
report.findings.append(Finding(path, "ci_timeout", "config changed"))
def main() -> int:
report = Report()
files = git_changed_files()
if not files:
print("NO_DIFF")
return 0
for path in files:
old, new = git_old_new(path)
scan_python(path, old, new, report)
scan_config(path, old, new, report)
if report.kill:
print("KILL")
for f in report.findings:
print(f"{f.kind:18} {f.path} {f.detail}")
return 2
print("SHIP")
return 0
if __name__ == "__main__":
sys.exit(main())
Save it as tools/kill_delay.py. Keep the file executable. Do not fold extra heuristics into this spike.
The timeout rule allows growth only with a predicate wait. Bare timeout=30 on a raw call still kills. That is the point of the spike.
Commands
Label: local commands. They assume a git worktree and CPython.
chmod +x tools/kill_delay.py
git add -A
python tools/kill_delay.py; echo exit:$?
Expected classes:
-
NO_DIFF— nothing to review, exit 0 -
SHIP— no delay camouflage, exit 0 -
KILL— camouflage found, exit 2
Install as a pre-commit hook only if the checker ships:
cat > .git/hooks/pre-commit <<'EOF'
#!/bin/sh
python tools/kill_delay.py
EOF
chmod +x .git/hooks/pre-commit
Keep the failing test on a short ceiling. The ceiling is not the fix.
pytest -p no:cacheprovider tests/test_race.py -vv --timeout=5
--timeout=5 bounds the spike. Do not let the agent raise it. Record the command in the PR body if you keep the patch.
Optional hunk filter, if time remains:
git diff -U0 HEAD -- '*.py'
Use U0 to inspect added lines. Do not parse unified diffs in this spike unless the AST path fails. Ninety minutes is not a parser festival.
Decision table
| Observation | Class | Action |
|---|---|---|
Diff adds time.sleep
|
camouflage | KILL |
Diff adds asyncio.sleep
|
camouflage | KILL |
| Diff adds a retry decorator | camouflage | KILL |
| Timeout literal grows, no predicate | camouflage | KILL |
| CI workflow timeout grows | camouflage | KILL |
| Predicate wait with a small ceiling | possible fix | REVIEW |
| Fake clock or sequenced fixture | possible fix | SHIP if tests hold |
| Race removed, no delay added | real fix | SHIP |
| Scanner incomplete at 90 minutes | unknown | KILL checker |
REVIEW is not SHIP. A human reads the predicate wait. Agents do not self-approve delay-shaped helpers.
Constructed fixture
Label: constructed example. Not a production incident. Not a benchmark.
Before:
def test_handler_sees_row(db):
db.insert_async("row")
assert db.fetch("row") is not None
Camouflage patch. The checker must print KILL:
import time
def test_handler_sees_row(db):
db.insert_async("row")
time.sleep(2)
assert db.fetch("row") is not None
Retry camouflage. Same kill:
from tenacity import retry, stop_after_attempt, wait_fixed
@retry(stop=stop_after_attempt(10), wait=wait_fixed(0.5))
def test_handler_sees_row(db):
db.insert_async("row")
assert db.fetch("row") is not None
Acceptable patch. Ship candidate after human review:
def test_handler_sees_row(db):
db.insert_async("row")
db.wait_until(lambda: db.fetch("row") is not None, timeout=0.2)
assert db.fetch("row") is not None
The third version still has a timeout keyword. The wait is conditional. The visitor treats wait_until as a predicate. Extend PRED_WAIT_HINTS only if time remains and your tree uses other names.
A second constructed kill is CI-only:
# .github/workflows/test.yml (agent patch)
jobs:
test:
timeout-minutes: 90
No product change. No test change. The config scanner must kill this. Green jobs bought with calendar time are still camouflage.
Cheap loop, not a GPU problem
The checker is CPU work on AST. It does not need a large model at review time. Run it on the laptop that owns the worktree.
The agent that writes the patch may run on a free model tier. That is the load under test. Weak models still emit sleep. Strong models still emit sleep. The kill rule does not care which model wrote the line.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
MonkeyCode is an open-source coding-agent project. Operator-supplied facts for this draft: free model access, and a free server option. Use that pair if you need a disposable loop for the ninety-minute spike. The scanner stays useful without that stack. Do not read those options as a quota, a hardware spec, a duration, or a permanence claim.
Limitations
The visitor is syntactic. It misses time.__dict__['sleep'](1). It misses getattr(time, 'sleep')(1). It misses sleeps inside C extensions. It misses os.system("sleep 2").
It can false-kill animation frames and backoff in network clients. Those trees need sleeps by design. Exclude those paths with an allowfile. Or skip this checker.
Timeout inflation uses the max literal. Mixed units will confuse it. 0.5 seconds versus 500 ms needs a human. The spike does not convert units.
Config scanning is substring based. YAML structure is not parsed. A comment that contains timeout can kill a clean patch. Accept that miss in this clock.
Count comparison can miss a sleep moved across files. Pair with git diff --name-only. Then inspect any new file that only exists on the agent side.
The protocol does not measure flake rate. It classifies the diff. A patch can still be wrong without delay. This gate is necessary, not sufficient.
Who should not use this
Do not use this on realtime or embedded trees that pulse on timers. Do not use this as a substitute for a race detector. Do not use this to score models against each other. Do not run it as proof that agents cannot code. It rejects one camouflage class. Nothing else.
Skip the hook if humans already ban sleeps in review. The hook is for unattended agent merges. Teams with no agents gain little here.
Do not publish the exit code as a product metric. KILL is a review signal. It is not a leaderboard.
Ship or kill
At minute ninety, ship tools/kill_delay.py only if fixtures match the table. Kill the checker if it cannot parse your tree. Kill the checker if predicate waits look like sleep. Kill the checker if CI comments trip it on a clean fixture.
A green bar after an agent edit is a claim. Delay is how that claim cheats. Gate the claim. Keep the rule small. One hypothesis per spike.
If you already drive agents on a free server, run this classifier before merge. Leave the model out of the review path.
Top comments (0)