Ghost in the Runtime · Part 6Your AI Says the Bug Is Fixed, but the Test Never Failed: Two Checks Before It Ships
8 min read

Your AI Says the Bug Is Fixed, but the Test Never Failed: Two Checks Before It Ships

Rerunning old tests misses a new test that never failed and a reshaped public function.

Two ways an agent can look done. Its new test passes, but it was written after the fix, so it never failed. Or it adds a theme keyword to two public functions that have 5 callers outside the diff. The behavior gate lets both through. R1 and R2 reject both before the commit exists: each exits 1, which refuses the commit. In the summary table, a cross means a cheat got through.Under bug test sit never red, hangs, and rigged; under public function, reshaped and removed; under private helper, past a dashed line, reshaped. Prompt rule has five crosses, so 5 slip through. Behavior gate, which runs existing tests, has three crosses and n/a under hangs and rigged, so 3 slip. Adding R1, red first, and R2, same shape, gives five checks, private helper allowed, and 0 slip. bug test public function private helper never red hangs rigged reshaped removed reshaped slip through Prompt rule 5 Behavior gate runs existing tests n/a n/a 3 + R1: red first + R2: same shape allowed 0 Two ways an agent can look done. Its new test passes, but it was written after the fix, so it never failed. Or it adds a theme keyword to two public functions that have 5 callers outside the diff. The behavior gate lets both through. R1 and R2 reject both before the commit exists: each exits 1, which refuses the commit. In the summary table, a cross means a cheat got through.Under bug test sit never red, hangs, and rigged; under public function, reshaped and removed; under private helper, past a dashed line, reshaped. Prompt rule has five crosses, so 5 slip through. Behavior gate, which runs existing tests, has three crosses and n/a under hangs and rigged, so 3 slip. Adding R1, red first, and R2, same shape, gives five checks, private helper allowed, and 0 slip. Prompt rule Behavior gate + R1 + R2 bug test R1 never red hangs n/a rigged n/a public function R2 reshaped removed private helper reshaped allowed slip through 5 3 0 R1: red first. R2: same shape.

The diff looks done

Step 1: inside the diff are the fix and a new test, and the new test passes. Outside the diff are 5 callers that the diff does not touch. No gate has run yet. inside the diff the fix new test pass outside the diff 5 callers

Move 1: the test never failed

Step 2: the new test was written after the fix. Before the fix it never ran; after the fix it is green. The behavior gate does not have it in its baseline, so it lets it through. before the fix after the fix new test never run green behavior gate not in its baseline lets it through

Move 2: public functions change shape

Step 3: render_html_to_png was (html,output_path)d0 at HEAD and is (html,output_path,theme=)d1 now. It and build_carousel have 5 callers outside the diff. The old tests still pass, so the behavior gate lets it through. render_html_to_png at HEAD (html,output_path)d0 now (html,output_path,theme=)d1 5 callers outside the diff of it and build_carousel behavior gate old tests still pass lets it through

R1 and R2 reject both

Step 4: R1, red first, wants the test to fail before the fix; this test exits 0, so R1 rejects it with exit 1. R2, same shape, wants public signatures to match HEAD; d0 to d1 is rejected with exit 1. R1: red first test must fail before the fix exits 0: rejected exit 1 R2: same shape public signatures match HEAD d0 → d1: rejected exit 1
1 of 4
Figure 1. Two ways an agent can look done. Its new test passes, but it was written after the fix, so it never failed. Or it adds a theme keyword to two public functions that have 5 callers outside the diff. The behavior gate lets both through. R1 and R2 reject both before the commit exists: each exits 1, which refuses the commit. In the summary table, a cross means a cheat got through.

Two read-only git gates run before the commit exists. One rejects a bug test that already passes without the fix; the other rejects a public function reshaped to suit the one caller in the diff.

Listen to the audio overview(12:45)Fenrir Studio Voice
0:00
12:45

An AI Agent A program that calls a language model in a loop and lets it use tools, such as search or code. that fixes a bug has two cheap ways to look done. First, it can write the Regression test A test that checks an old feature still works after a change. When it fails, something broke. after the fix. Then the test passes on its first run, and nothing shows that it ever failed without the fix. Second, it can reshape a Public function A function other files may call. In Python, its name does not start with an underscore. to suit the one Caller Code that calls a function. If the function changes shape, the caller can break. in the Diff The lines a change adds, removes or edits, compared with the code before it.. Then the callers outside the diff break when they run. In both cases, every test in the diff passes. So does the behavior gate from Part 5. That gate re-runs the tests that existed before the change. It rejects the commit when one of them was edited or fails. In this part I add two read-only rules to the same Pre-commit hook A script Git runs before it saves a commit. If the script fails, Git refuses the commit., one for each move.

PART 01

Two moves the behavior gate does not see

01

The Friday commit that did not land

On a Friday afternoon, I asked the agent to stamp a theme tag into every PNG that my social-card renderer writes. Its first patch added a theme keyword to render_html_to_png and to build_carousel. Those two functions have five callers outside the diff. Every test in the diff passed. Then the pre-commit hook printed one line per function and Exit code The number a program returns when it ends. 0 means success; any other number, such as 1, means failure., which means it refused the commit. In a two-file scratch repo, the line reads:

git commit, rejected
TXT
[R2-fence] render.py: 'render_html_to_png' signature changed (html,output_path)d0 -> (html,output_path,theme=)d1
exit=1

The line gives three things: the function's name, its shape at HEAD Git's name for the latest commit on the current branch., and its shape now. The agent read the line and kept the Signature A function's name and its list of parameters. A call must match it.. It moved the theme into module state instead, and that passed. The patch I kept, below, uses a Delegate A function that only calls another function and returns its result..

02

What the behavior gate does not see

The test that was green from the start. The agent fixes the bug first. Then it writes the regression test, and the test is Green A test that passes. Test tools print a pass in green. on its first run. Nothing shows that it failed before the fix. The behavior gate keeps a hash of the old tests, its pinned baseline, and a new test is not in it.

The fix by reshaping. To satisfy one call site, the agent adds a keyword, renames a method, or inlines a public helper. The tests in the diff pass. The callers outside the diff break when they call the function.

PART 02

The rules

03

R1: the test must be red first

R1 says the test must be Red A test that fails. Test tools print a failure in red. first: it must fail before the fix. It checks the Reproducer A test that shows the bug. It fails while the bug is there and passes once it is fixed., the test that shows the bug. I run it like this: repro_fence.py red --cmd "python3 -m pytest tests/test_x.py::test_bug -q". The script runs the reproducer on the current tree, with shell=False and a hard timeout. It passes only when two things are true: pytest A common Python test runner. It exits 1 when a test fails and 0 when all tests pass. exits 1, and its JUnit report An XML file a test runner writes. It lists each test and whether it passed or failed. shows that each named test failed. Every other exit code is rejected with a reason. Among them: 0 means the test passed, 2 means a collection error, 4 means a test id that does not exist, 124 means a timeout, and 127 means a missing binary:

repro_fence.py, excerpt
PYTHON
REJECTED_EXIT_REASONS: dict[int, str] = {
    0: "exited 0 on the current tree; a passing reproducer proves nothing",
    2: "exited 2 (interrupted or collection error); the test never ran",
    3: "exited 3 (pytest internal error); the test never ran",
    4: "exited 4 (usage error or unknown test id); the test never ran",
    5: "exited 5 (no tests collected); the test never ran",
    124: "timed out; a hang is not a reproduction",
    127: "binary not found or not executable",
}

def check_reproducer(repo: Path, argv: list[str], timeout: int) -> list[Violation]:
    """R1: pytest exits 1 on the current tree and every named test failed."""
    with tempfile.TemporaryDirectory() as tmp:
        report_xml = Path(tmp, "r1.xml")
        extra = [f"--junitxml={report_xml}", f"--rootdir={repo}", "-p", "no:cacheprovider"]
        code = run_argv([*argv, *extra], repo, timeout)
        cases = junit_cases(report_xml)
    if code != 1:
        reason = REJECTED_EXIT_REASONS.get(code, f"exited {code}; only exit 1 (tests failed) is red")
        if code == 124:
            reason = f"timed out after {timeout}s; a hang is not a reproduction"
        return [Violation("R1-repro", f"reproducer {shlex.join(argv)} {reason}")]
    return [Violation("R1-repro", f"reproducer exited 1 but {test_id} {why}")
            for test_id in _test_ids(argv) if (why := _not_failed(test_id, cases))]

I gamed the first version within a day. pytest test_x.py || true exits 0, so it is rejected. But sh -c "pytest test_x.py; exit 1" exits 1 and sails through. A list of banned tokens loses the same way, to bash -lc, a pipe into grep, or python3 -c "assert 0". So R1 has a second half, R1-shape, and it is an Allowlist A list of the only things allowed. Anything not on the list is rejected.. The command must start with pytest or python3 -m pytest. After that, it may hold only path::test ids and the flags -q, -v and -x. A command that picks its own exit code cannot prove the bug.

04

R2: the public surface keeps its shape

R2 says the public surface keeps its shape. The command is repro_fence.py fence --rev HEAD --file a.py --file b.py. For each file, it reads two versions with git show: the one at rev and the one in the Index Git's staging area. It holds the exact file versions the next commit will save.. It parses both with Python's AST Abstract syntax tree. The structure a parser builds from source code, such as functions, classes and signatures. module. Then it compares the public symbols by a normalized signature, not by their source text. So a docstring edit passes, and a changed parameter list does not:

repro_fence.py, excerpt
PYTHON
def signature_of(node: ast.FunctionDef | ast.AsyncFunctionDef) -> str:
    """Normalized shape: `[@decorator ][async ](name,name=,/,*va,name,**kw)dN`.

    `=` marks a parameter with a default and N counts them, so appending
    `extra=None` or moving a default changes the shape. So do `async` and
    decorators such as `@property`, which change how callers call it.
    """
    spec = node.args
    positional = spec.posonlyargs + spec.args
    first_default = len(positional) - len(spec.defaults)
    names = [a.arg + ("=" if i >= first_default else "") for i, a in enumerate(positional)]
    if spec.posonlyargs:
        names.insert(len(spec.posonlyargs), "/")
    if spec.vararg is not None:
        names.append("*" + spec.vararg.arg)
    elif spec.kwonlyargs:
        names.append("*")
    names.extend(a.arg + ("" if d is None else "=") for a, d in zip(spec.kwonlyargs, spec.kw_defaults))
    if spec.kwarg is not None:
        names.append("**" + spec.kwarg.arg)
    defaults = len(spec.defaults) + sum(1 for d in spec.kw_defaults if d is not None)
    prefix = "".join(f"@{ast.unparse(d)} " for d in node.decorator_list)
    if isinstance(node, ast.AsyncFunctionDef):
        prefix += "async "
    return f"{prefix}({','.join(names)})d{defaults}"

An = marks each parameter that has a Default The value a parameter takes when the caller leaves it out, as in extra=None., and dN counts them. So appending extra=None changes both the name list and the count, from d0 to d1. Moving a default to another parameter is a change too. A name that is at HEAD but absent now is a removed symbol. A name in both versions with a different shape is a changed signature. Names with a leading underscore are private in Python, so the fence skips them, except dunders such as __init__. New public symbols are free. A file the parser does not know prints R2-skip.

repro_fence.py, excerpt
PYTHON
def diff_symbols(rel: str, before: dict[str, str], after: dict[str, str]) -> list[Violation]:
    """Public symbols that vanished or changed shape between two sources."""
    out: list[Violation] = []
    for name, sig in sorted(before.items()):
        if name not in after:
            out.append(Violation("R2-fence", f"{rel}: public symbol '{name}' was removed"))
        elif after[name] != sig:
            out.append(
                Violation("R2-fence", f"{rel}: '{name}' signature changed {sig} -> {after[name]}")
            )
    return out

The fence reads the staged copy of each file. So an unstaged edit cannot make a staged one look safe. The hook also hands it deleted and renamed files. A rev that is not a commit, a git error, or a file found on neither side makes it reject. On the three-file scratch repo, the run takes 0.15 s.

PART 03

The fix

05

The patch that passes the fence

The renderer needs the theme, and the signature cannot change. The agent's first answer, the one in the video below, was a module-level setter. It passes the fence. But two handlers that render at once Race condition A bug where the result depends on which of two processes happens to finish first. on that shared theme. And a handler that skips the setter inherits the last caller's theme. The patch I kept adds a new public function and has the old one delegate to it: one public function added, zero changed.

render.py
PYTHON
def render_html_to_png_with(html, output_path, *, theme="paper"):
    """Writes html to output_path as a PNG stamped with theme."""
    ...
    stamp_png(output_path, theme)
    return output_path


def render_html_to_png(html, output_path):
    """Writes html to output_path as a PNG."""
    return render_html_to_png_with(html, output_path)   # same signature as HEAD

A handler that needs a theme calls render_html_to_png_with(html, path, theme=theme). The old signature is byte-identical to HEAD. The theme travels with each call. The five callers outside the diff were not touched. The commit lands.

Two weeks behind the rule taught me four gotchas. First, a keyword default is still a signature change, even a keyword-only one. The delegate above is the way through. Second, a top-level def test_* is public, so renaming a test is rejected. Third, inlining a public helper counts as a removed symbol. Fourth, one honest False positive A check flags something as wrong when it is in fact fine.: a deliberate CLI change, audit_social_bundle()d0 -> (slug)d0. The shape that passed read sys.argv[1] inside the unchanged body. That is the cost of the rule.

the-repro-fenceScreen recording
Agent turn in Antigravity: R2 rejects the added keyword, the setter patch lands, then R1 rejects a green reproducer and a shell-shaped one
Show the shell command (reproduce locally)
git add -- render.py && git commit -m 'render: stamp the theme'
06

What each rule can see

Here are six moves in one scratch repo, against three rules. Every cell is an exit code read off the terminal.

MoveBehavior gate (part 5)R1: red firstR2: same shape
Regression test written green after the fixMissed, not in the pinned baselineCaught, exit 1 on code 0Not its job
Reproducer that hangsNot its jobCaught, timed out after 2 sNot its job
|| true appended to the reproducerNot its jobCaught, R1-shape, before anything runsNot its job
Public keyword added to satisfy one callerMissed while pinned tests passNot its jobCaught, d0 -> d1
Public helper inlined awayMissed until a pinned test imports itNot its jobCaught, removed symbol
Private _helper reshapedMissedNot its jobAllowed by design, exit 0

The last row is on purpose. A fence on private names would turn every refactor into an override. And an override I type on every commit stops being a gate. The underscore is the contract.

PART 04

The boundary

07

What these gates cannot see

R2 sees Python only. A .ts or .sh file prints R2-skip and passes.

R2 reads names and arity, not types or semantics. Arity is the number of parameters. A function that keeps its parameter list but changes what it promises to return still passes. Catching that is the behavior gate's job.

R1 proves the test fails now, not that it fails for the right reason. A test that fails because of its own typo is still red. A failed import or a missing test id is rejected. The rest is on the author.

Three papers this year measured the problem from outside. TDAD (Mar 2026) cut regressions on SWE-bench Verified A benchmark of real GitHub issues from Python projects. A model must write a patch that fixes each one. from 6.08% to 1.82%. It did this with a code-to-test map at commit time. Test-first prompt instructions alone pushed regressions to 9.94%. How Coding Agents Fail Their Users (May 2026) read 20,574 real sessions. In them, 91.49% of resolutions needed a user correction. DEPBENCH (Aug 2026) set 203 upgrade tasks with hidden signature changes. The best configuration solved 104. The fence is that last problem run backwards.

08

Same rule, different reader

Part 5, the behavior gate, put the pinned tests at the commit boundary. This part adds the contract on either side of the fix: red before it, same shape after it. The two rules are 430 lines of Standard library The modules that ship with Python itself, so nothing extra needs installing. Python, with 33 tests.

The rule behind the Friday rejection had been a sentence in the System prompt Fixed instructions at the start of every request. They tell the model its role and its rules. for months. The agent read it at turn one. The hook read the staged files at commit time and printed the diff. Same rule, different reader, and only one returns an exit code.

Primary research and documentation

First published 19 Sep 2026 · last revised 9 Oct 2026 · 9 revisions

Hand this to your agent

You

Read this essay by Ibrahim Ulukaya, https://ulukaya.dev/posts/the-repro-fence, and look at my project. Does each bug fix in my history come with a test that failed before the fix, and did any fix reshape a public function for one caller? Name the file and line for each finding.

Cited by
  1. Your AI Fixed the Bug and Every Test Passed, but the Tests Skipped the Fix

    ……avior Gate, re-runs the pinned tests at commit time. My Part 6 post, The Repro Fence, checks two things: a bug test fails before the fix, and……