Skip to content

settle: quiet-window comparison has a 0ms margin whenever pollMs === quietMs (--settle-quiet 25-300) #1306

Description

@thymikee

Observation

The settle loop's "two identical captures span the quiet window" decision rides a 0ms margin by construction for every --settle-quiet value in [25, 300].

runStableCaptureLoop (src/commands/interaction/runtime/stable-capture.ts) derives its cadence from the quiet window:

const pollMs = Math.min(STABLE_POLL_INTERVAL_MS, Math.max(25, quietMs)); // 300 / 25 floor

so pollMs === quietMs for any quietMs in [25, 300]. Settling requires:

} else if (captures >= 2 && nowMs - quietSinceMs >= quietMs) {

quietSinceMs is stamped right after capture #1, so the gap measured at capture #2 is exactly one sleep(pollMs) plus the capture's own duration. With pollMs === quietMs that comparison is "roughly-quietMs >= quietMs" — decided by sub-millisecond noise.

Why it flips

sleep() falls back to setTimeout and now() to Date.now() (src/commands/runtime-common.ts:16-23). These read different clocks: libuv's timer fires off cached monotonic loop time, Date.now() is the wall clock. Measured on darwin/Node 26, 4000 samples of await setTimeout(25):

condition Date.now() delta of 24ms
idle 0.13% of calls
under CPU load 0.63% of calls (5x)

Instrumenting the loop with settleQuietMs: 25, sinceQuiet lands at 25, 26, 27, 28, 29 against a threshold of 25 — repeatedly exactly on the boundary.

Impact

Not a correctness bug. When the comparison loses, quietSinceMs is not reset (the tree is unchanged), so capture #3 sees ~2x the gap and settles. The cost is one wasted capture + poll on ~0.13–0.63% of settles in that quiet-window range. The default (quietMs: 500pollMs: 300) settles at capture 3 with a 100ms margin and is unaffected.

Proposed fix

Make the sleep deadline-aware rather than a fixed pollMs: sleep the remaining time to the quiet deadline (plus a ~1-2ms epsilon) instead of exactly pollMs. Capture #2 then lands deterministically past the window, settle costs 2 captures instead of an occasional 3, and the skew sensitivity disappears at the source.

Provenance

Surfaced while diagnosing the settle-observation.test.ts CI flake (settled: false on loaded runners, passing on rerun). That test failed because it scripted an exact capture count, turning the benign extra poll into a swallowed transcript exhaustion; the test fix is separate and touches no production code. Confirmed by forcing the undershoot (sleep(pollMs - 6)): the old fixture failed 58/60, matching CI's signature exactly.

This issue is the production half, filed separately because it changes production timing.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions