DEV Community

Finley Zhou
Finley Zhou

Posted on

The Patch Reads the Wall Clock: A Time-Source Audit for Agent Patches

Most flaky tests in agent-authored patches are not mysterious. They read a clock somewhere in the call path, and the assertion compares two readings that were never taken at the same instant.

Freezing that test turns the suite green and deletes the only evidence you had. So audit the time source before the freeze register gets another row. The order matters: a time-dependent test cannot be judged by re-running it, and a frozen test cannot be re-judged at all.

Why the clock comes before the freeze

A rerun answers one question: does the failure repeat under this physical environment? A clock audit answers a better one: is there an input the test never controlled?

Agent patches are unusually prone to this. They add TTL checks, retry backoff, cache expiry, "created within the last N days" filters, and idempotency windows. Every one of those needs a clock, and an agent will read it from the ambient environment because the surrounding code already does.

The audit below has four gates. Each gate either produces a failing test — the outcome you actually want — or a named gap you can defend in review.

Gate 1: Scan the added lines for wall-clock reads

Scope the scan to the diff, not the repo. Pre-existing clock reads are somebody else's problem; clock reads your patch introduced are yours.

#!/usr/bin/env node
// clock-audit.mjs — list wall-clock reads on lines added by a patch.
// Usage: node clock-audit.mjs origin/main...HEAD
import { execFileSync } from "node:child_process";

const range = process.argv[2];
if (!range) {
  console.error("usage: node clock-audit.mjs <git-range>");
  process.exit(2);
}

const PATTERNS = [
  [/\bDate\.now\s*\(/, "js:Date.now"],
  [/\bnew\s+Date\s*\(\s*\)/, "js:new-Date"],
  [/\bperformance\.now\s*\(/, "js:performance.now"],
  [/\bdatetime\.(now|utcnow)\s*\(/, "py:datetime.now"],
  [/\btime\.(time|monotonic)\s*\(/, "py:time"],
  [/\btime\.(Now|Since)\s*\(/, "go:time.Now"],
  [/\bInstant\.now\s*\(|System\.currentTimeMillis\s*\(/, "jvm:now"],
  [/\b(setTimeout|setInterval|sleep|Sleep)\s*\(/, "delay"],
];

const diff = execFileSync("git", ["diff", "-U0", "--no-color", range], {
  encoding: "utf8",
  maxBuffer: 64 * 1024 * 1024,
});

let file = null;
let lineNo = 0;
const findings = [];

for (const line of diff.split("\n")) {
  if (line.startsWith("+++ b/")) { file = line.slice(6); continue; }
  const hunk = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
  if (hunk) { lineNo = Number(hunk[1]); continue; }
  if (line.startsWith("+") && !line.startsWith("+++")) {
    for (const [re, kind] of PATTERNS) {
      if (re.test(line)) {
        findings.push({ file, line: lineNo, kind, text: line.slice(1).trim() });
      }
    }
    lineNo++;
    continue;
  }
  if (line.startsWith(" ")) lineNo++;
}

const byKind = findings.reduce((acc, f) => ((acc[f.kind] = (acc[f.kind] ?? 0) + 1), acc), {});
console.log(JSON.stringify({ total: findings.length, byKind }, null, 2));
for (const f of findings) console.log(`${f.file}:${f.line}  ${f.kind}  ${f.text}`);
process.exit(findings.length ? 1 : 0);
Enter fullscreen mode Exit fullscreen mode

Read the output with two rules. A hit in production code is a dependency you have not injected. A hit in test code is a fixture you have not built. The delay kind is separate: a sleep in a test is a clock read that forgot to be honest about itself.

Static scans miss dynamic clock use — database now(), HTTP Date headers, filesystem mtimes. Treat a clean scan as "no evidence of a problem", never as "no problem".

Gate 2: Restate the property as a function of the clock

Once you find the read, the property must stop depending on when it runs. Make time an argument, then sweep it.

// grant.property.test.mjs — illustrative shape, not a shipped module.
import test from "node:test";
import assert from "node:assert/strict";

function makeClock(start = 0) {
  let t = start;
  return { now: () => t, advance: (ms) => (t += ms) };
}

function makeGrant(clock, ttlMs) {
  const issuedAt = clock.now();
  return { isExpired: () => clock.now() >= issuedAt + ttlMs };
}

test("p_live_before_ttl: no premature expiry", () => {
  const clock = makeClock(1_000);
  const grant = makeGrant(clock, 30_000);
  for (let elapsed = 1_000; elapsed < 30_000; elapsed += 1_000) {
    clock.advance(1_000);
    assert.equal(grant.isExpired(), false, `expired early at +${elapsed}ms`);
  }
});

test("p_boundary: expiry is inclusive at exactly ttl", () => {
  const clock = makeClock(0);
  const grant = makeGrant(clock, 30_000);
  clock.advance(29_999);
  assert.equal(grant.isExpired(), false);
  clock.advance(1);
  assert.equal(grant.isExpired(), true);
});
Enter fullscreen mode Exit fullscreen mode

Two properties, two distinct failures. The first catches an off-by-one in the comparison; the second pins the boundary so a refactor cannot quietly flip < to <=. Neither property can flake, because neither property asks the operating system what time it is.

Gate 3: Fixture matrix — timezone, epoch, repeats

Injecting the clock fixes your assertions. It does not fix code that formats, parses, or truncates timestamps. For that you need an environment matrix, run as a fixture rather than as a habit.

#!/usr/bin/env bash
set -euo pipefail
RUNS="${RUNS:-3}"
for tz in UTC America/Sao_Paulo Asia/Kathmandu Pacific/Chatham; do
  for run in $(seq 1 "$RUNS"); do
    echo "== TZ=$tz run=$run =="
    TZ="$tz" SOURCE_DATE_EPOCH=1700000000 \
      node --test --test-concurrency=1 ./test
  done
done
Enter fullscreen mode Exit fullscreen mode

Three notes on portability. TZ is honored by the C library and by every mainstream runtime, but the list of valid zones depends on the tzdata your image ships, so pin the image. SOURCE_DATE_EPOCH is a reproducible-builds convention, not a universal switch — it only helps if your code reads it. And if you want shuffled test order, use whatever shuffle/seed option your runner exposes; that flag name has changed across releases, so pin it in the script instead of trusting memory.

A matrix that fails in exactly one timezone is not a flaky test. It is a timezone bug that your laptop's locale was hiding.

Gate 4: The freeze record must carry the clock

Here is the rule that keeps the freeze from becoming a landfill: a test may be frozen only with a record that names its clock source and a re-entry condition. No clock named, no freeze.

{
  "test": "api/expiry.test.ts > expires after TTL",
  "reason": "asserts wall-clock TTL with no injection point",
  "clock": "Date.now",
  "owner": "finley",
  "opened": "2026-10-19",
  "expires": "2026-11-02",
  "reentry": "p_live_before_ttl and p_boundary pass with an injected clock"
}
Enter fullscreen mode Exit fullscreen mode

Validate it in CI with a short script: required keys present, expires no more than 21 days after opened, expires not in the past, reentry references a property name, and clock non-empty whenever reason mentions a flake. The value is not the schema. The value is that a freeze now has an owner and a date, so it expires in a diff review instead of in someone's memory.

Decision table

Symptom from the audit Action Freeze allowed?
Clock read in production code, no injection point Add the injection point; properties in Gate 2 No
Clock read only in the test's setup Replace with an injected fixture No
sleep used to wait for an async effect Wait on the effect, not on the clock No
Passes with injected clock, fails in one timezone Fix formatting/parsing; keep the matrix in CI No
Fails with injected clock and fixed epoch Real bug — the freeze would be a cover-up No
Third-party module reads the clock internally Freeze with clock + reentry naming the upstream issue Yes, dated

Five of six rows end in "no". That ratio is the point: the freeze register is a last resort with a paper trail, not a default response to red.

Where free model access and a free server actually fit

Two parts of this workflow are cheap, parallel, and disposable — which is where MonkeyCode's free model access and free server option are useful to me.

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

First, triage. The scanner in Gate 1 is deliberately noisy, and a model pass over its output to sort "needs injection" from "logging only" is work I do not want to pay per token for. Free model access makes that pass routine. The output is a proposal, never a verdict — a finding counts as resolved only when a property fails first and passes after injection.

Second, the matrix. Gate 3 multiplies zones by repeats, and that workload competes with everything else on a dev machine. Running the matrix on a free server keeps the loop local and the sweeps elsewhere. Be precise about what that buys you: a second environment, not a second opinion. If you need genuine portability evidence, you still need a machine you do not control the setup of.

Availability and quotas for both are set by the operator and can change; verify them at use time rather than trusting this paragraph.

Limitations and who should skip this

The static scan is a heuristic. It will miss clock reads behind helper functions, and it will flag performance.now() used purely for logging. Budget for false positives.

Injecting a clock changes timer semantics. If your code uses real backoff or real scheduling, a virtual clock can make a passing test that never exercises the timer path — test the timer wiring separately, or accept that the property covers arithmetic only.

Skip this entirely if your patches do not touch time, if your suite has no diff-scoped CI to attach the scanner to, or if you cannot name the owner of a freeze. In that last case the register becomes a place where tests go to be forgotten, which is worse than an honest red build.

If you try one thing this week: run the scanner against your last agent patch and count the added lines that read the wall clock. That count is your clock debt. Pay it before the freeze, not after.

Top comments (0)