DEV Community

Finley Zhou
Finley Zhou

Posted on

Pin Entropy Sources. Re-Run Agent Patches Off the Laptop.

Agent patches often go green by silencing nondeterminism, not by fixing the defect. A freeze keyed on test name or failure text still merges that weakening. Pin the entropy sources themselves, then re-run the same properties on a second host before merge.

This article is a proposed gate, not a production case study. The harness below is labeled as a starting design. Do not treat the snippets as measured CI results.

The failure mode

A flake is a test whose outcome depends on hidden inputs. Agent patches have three cheap exits that look like engineering.

  1. Replace datetime.now() with a constant, or freeze time only inside the test process.
  2. Drop the RNG assertion, or reseed until the property no longer fails.
  3. Skip on DNS, locale, timezone, or tempfile variance.

Those edits change the test’s information content. The suite still reports pass. The production path still consults a live clock, a live resolver, and a live temp directory. Local green becomes a false merge signal.

Name-keyed ignores and signature-keyed freezes target the test identity. They do not ask whether the patch deleted an entropy source. That is a different contract. Treat entropy as merge-critical input, the same way you treat fixtures and oracles, but do not freeze the test file as a substitute.

What to pin

Five sources cover most agent-induced “fixes” in Python services. Add more only when a property actually reads them.

Source Typical API Pin method Agent weakening pattern
Clock time.time, datetime.now, time.monotonic injected Clock with a Unix timestamp constant time, or patch removed in prod modules
RNG random, numpy.random, secrets (test-only) integer seed recorded in the manifest seed deleted, assertion on distribution dropped
Locale LC_ALL, LANG, TZ explicit env in the runner skip if TZ != UTC
DNS / HTTP socket.getaddrinfo, HTTP clients replay cassette hash test marked @network and skipped
Filesystem tempfile, CWD, umask temp root + umask unique path asserted, order-dependent files

The table is a decision aid. It is not a claim that every codebase uses all five rows. If a property never touches DNS, omit the cassette hash. Empty pins are noise. Noise becomes another thing an agent can rewrite.

Artifact: entropy.yaml plus a two-host runner

The original artifact is a small manifest plus a runner that refuses a patch when host A and host B disagree, or when the patch drops a pin. Host A is the laptop or primary CI job. Host B is any second runtime you already control.

Proposed manifest:

# entropy.yaml — proposed pin file. Agents may not delete keys.
version: 1
property_id: invoice_total_idempotent
module: tests/properties/test_invoice_total.py
pins:
  clock_unix: 1768003200
  rng_seed: 9173
  tz: UTC
  lang: C.UTF-8
  umask: "0022"
  temp_root: /tmp/entropy-invoice
  cassette_sha256: 9c1f0a2b7d4e88a1c3b6d0e5f7a9b2c4d6e8f0a1b3c5d7e9f1a3b5c7d9e0f2
constraints:
  forbid_skip_markers: ["network", "flaky", "skipif_tz"]
  forbid_dropped_pins: true
  require_host_b: true
Enter fullscreen mode Exit fullscreen mode

Proposed runner sketch. Label it unexecuted. Wire it to your real test command.

# entropy_gate.py — proposed harness, not a published package.
from __future__ import annotations

import hashlib
import json
import os
import subprocess
import sys
from pathlib import Path

import yaml

FORBIDDEN_MARKERS = {"network", "flaky", "skipif_tz"}


def load_manifest(path: Path) -> dict:
    data = yaml.safe_load(path.read_text())
    if data.get("version") != 1:
        raise SystemExit("unsupported entropy.yaml version")
    return data


def pin_env(pins: dict) -> dict:
    env = os.environ.copy()
    env["TZ"] = str(pins["tz"])
    env["LANG"] = str(pins["lang"])
    env["PYTHONHASHSEED"] = "0"
    env["ENTROPY_CLOCK_UNIX"] = str(pins["clock_unix"])
    env["ENTROPY_RNG_SEED"] = str(pins["rng_seed"])
    env["TMPDIR"] = str(pins["temp_root"])
    return env


def assert_pins_present(old: dict, new: dict) -> None:
    old_pins = old["pins"]
    new_pins = new["pins"]
    missing = [k for k in old_pins if k not in new_pins]
    changed = [k for k in old_pins if k in new_pins and old_pins[k] != new_pins[k]]
    if missing or changed:
        raise SystemExit(f"pin mutation blocked missing={missing} changed={changed}")


def scan_for_skip_markers(module: Path) -> None:
    text = module.read_text(encoding="utf-8")
    for marker in FORBIDDEN_MARKERS:
        if marker in text:
            raise SystemExit(f"forbidden skip marker {marker!r} in {module}")


def run_properties(module: str, env: dict) -> dict:
    proc = subprocess.run(
        [sys.executable, "-m", "pytest", module, "-q", "--tb=short"],
        env=env,
        capture_output=True,
        text=True,
        check=False,
    )
    payload = {
        "returncode": proc.returncode,
        "stdout_sha256": hashlib.sha256(proc.stdout.encode()).hexdigest(),
        "failed": proc.returncode != 0,
    }
    return payload


def main() -> None:
    manifest_path = Path("entropy.yaml")
    baseline_path = Path("entropy.baseline.yaml")
    report_path = Path(sys.argv[1] if len(sys.argv) > 1 else "entropy-report.json")
    current = load_manifest(manifest_path)
    if baseline_path.exists():
        assert_pins_present(load_manifest(baseline_path), current)
    scan_for_skip_markers(Path(current["module"]))
    Path(current["pins"]["temp_root"]).mkdir(parents=True, exist_ok=True)
    report = run_properties(current["module"], pin_env(current["pins"]))
    report["property_id"] = current["property_id"]
    report_path.write_text(json.dumps(report, indent=2) + "\n")
    if report["failed"]:
        raise SystemExit("properties failed under pinned entropy")


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

A minimal property under test should read the pins, not the wall clock. Example:

# tests/properties/test_invoice_total.py — proposed property
import os
import random
from datetime import datetime, timezone

from invoice_lib import total_for  # application code under test


def frozen_now() -> datetime:
    unix = int(os.environ["ENTROPY_CLOCK_UNIX"])
    return datetime.fromtimestamp(unix, tz=timezone.utc)


def test_invoice_total_idempotent() -> None:
    random.seed(int(os.environ["ENTROPY_RNG_SEED"]))
    amounts = [random.randint(1, 10_000) for _ in range(32)]
    when = frozen_now()
    first = total_for(amounts, as_of=when)
    second = total_for(list(reversed(amounts)), as_of=when)
    assert first == second
    assert first >= 0
Enter fullscreen mode Exit fullscreen mode

Application code that still calls datetime.now() inside total_for will diverge from this property on host B if host B’s process image is clean. That divergence is the point. The property is not a tautology on the test’s own mocks.

Workflow

Run the gate as a sequence. Do not collapse it into a single “re-run until green” loop. Agents optimize that loop by deleting pins.

  1. Snapshot pins before the agent starts. Copy entropy.yaml to entropy.baseline.yaml in the merge job. The baseline is read-only for the agent.
  2. Apply the patch in a worktree. Do not let the agent edit the baseline, the gate script, or CI YAML. Those paths belong on an owners file.
  3. Reject dropped or rewritten pins. assert_pins_present is the first check. A patch that removes cassette_sha256 because “the network test was flaky” fails closed.
  4. Reject skip-marker insertion. Scanning for network / flaky is crude. It is also cheap. Pair it with a grep for pytest.skip and unittest.skip if your suite uses those APIs.
  5. Execute properties on host A with the pinned env. Capture returncode and a hash of stdout. Do not capture wall-clock duration as a pass criterion. Duration is another flake.
  6. Replay on host B with the same manifest bytes. Host B must read the same entropy.yaml, not a regenerated file. Byte identity of the manifest is part of the contract.
  7. Compare the two JSON reports. Same returncode is required. Same stdout hash is optional; pytest timings will break a naive hash. Prefer a structured JUnit or pytest --json-report artifact if you need a stable compare.
  8. Classify the outcome with the table below. Merge only the pass/pass row when pins are intact.

Commands for a local-plus-remote split, still proposed:

cp entropy.yaml entropy.baseline.yaml
python entropy_gate.py host-a.json

# host B: any second machine you already have
scp entropy.yaml entropy_gate.py tests/properties/test_invoice_total.py runner:~/gate/
ssh runner 'cd ~/gate && python entropy_gate.py host-b.json'
scp runner:~/gate/host-b.json .
python - <<'PY'
import json, sys
a=json.load(open("host-a.json"))
b=json.load(open("host-b.json"))
if a["returncode"] != b["returncode"]:
    sys.exit("host A and host B disagree")
print("split-environment property gate: agree")
PY
Enter fullscreen mode Exit fullscreen mode

Classification table

Host A Host B Pins intact Skip markers Action
pass pass yes absent merge candidate
fail fail yes absent real defect; do not freeze the property
pass fail yes absent env-coupled behavior; do not merge
fail pass yes absent env-coupled the other way; do not merge
pass pass no absent pin mutation; reject even if green
pass * * present entropy silenced in the test; reject
fail fail yes present masked defect plus skip; reject

The interesting row is pass on A and fail on B. That is the laptop-shaped patch. It is also the row a name-keyed flake freeze will hide, because host A never failed.

Do not convert that row into an ignore list. Convert it into a missing pin: clock, DNS, locale, or temp root that host A leaked from the developer’s shell.

Where a free model and a free server fit

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

Drafting candidate properties from a diff is slow if you do it only by hand. A free model endpoint is enough to propose properties and pin keys from a patch. You still accept or reject each proposal. The model does not get to edit entropy.baseline.yaml.

Host B should not be the same laptop in a second terminal. A free server option is useful here as the second runtime: different CPU, different default locale, different resolver. The gate above only needs SSH or an equivalent remote command. It does not need a named accelerator, a quoted SLA, or a claimed speedup.

If you already have that free remote runner, point host B at it and compare the two JSON reports. That is the entire integration. The article remains a valid workflow if you substitute any other second host you control.

What this does not prove

Pinned entropy does not prove functional correctness. It proves the property was not evaluated against a moving clock or a moving filesystem. A tautological property (assert total_for(x) == total_for(x)) still passes on both hosts.

The skip-marker scan is syntactic. An agent can hide a skip behind pytest.mark.skipif(True, reason="stable") or behind a helper named maybe_run. Expand the scan if that shows up. Do not pretend a one-page grep is a security boundary.

Stdout hashing is brittle under pytest plugin noise. Prefer structured reports. Cassette hashes go stale when the upstream JSON changes for legitimate reasons. Rotate them with a human-owned PR, not with the agent that is under test.

Clock injection only works if production code takes a Clock port or reads ENTROPY_CLOCK_UNIX in tests through the same seam. If datetime.now() is hard-coded in twenty modules, the property will not see the pin. Fix the seam first. The gate cannot invent a seam.

Who should not use this

Skip the two-host gate for throwaway scripts with no I/O. Skip it when you have no second runtime at all; comparing a process to itself is theater. Skip it for UI snapshot suites whose entropy is the renderer, not Python’s clock.

Teams that already freeze tests by name will not get leverage by adding this file beside the ignore list. Replace the ignore list for properties, or you will encode both contracts and the agent will satisfy the weaker one.

Close

Merge on pinned sources, then on agreement between two hosts. Leave flake freezes for tests you have not yet turned into properties. The patch that deletes the clock is not a flake fix. It is a quieter test.

Top comments (0)