Skip to content

fix: stop the slow-snapshot warning from firing on a single cold start - #1628

Merged
thymikee merged 4 commits into
mainfrom
fix/slow-snapshot-warning-cold-start
Aug 6, 2026
Merged

thymikee merged 4 commits into
mainfrom
fix/slow-snapshot-warning-cold-start

Conversation

@thymikee

@thymikee thymikee commented Aug 6, 2026

Copy link
Copy Markdown
Member

Why

The session's first capture folds one-time startup (runner launch, helper install) into its duration sample, and nearest-rank p95 over a small sample set is its largest one or two values. Net effect: one 12s runner cold-start produced

Warning: ios snapshots are slow in this run: p95 12417ms over 1 captures. Possible causes: device load, app or dev server stuck, helper fallback, or stale daemon.

on a session whose steady-state captures ran 520ms. The hint blames device load / stale daemon — inviting exactly the agent restart spirals the warning exists to prevent. Observed on every AppControlBench run (bench = fresh daemon + runner per run, so every run opens with a cold sample); bsky-30's "p95 9830ms over 24 captures" was two outliers over a fast run.

What

The warning now judges only warm captures (first sample excluded) and only once at least three exist. Displayed stats still cover every sample — the cold start stays visible as maxMs. The replay/merge path (mergeSnapshotDiagnostics, order-less reconstructed samples) is unchanged.

Regression tests: a 12.4s cold start over fast warm captures stays silent; chronically slow warm captures still warn (and the count excludes the cold sample); two samples alone never warn.

The session's first capture folds one-time startup (runner launch,
helper install) into its duration, and nearest-rank p95 over a small
sample set equals its largest one or two values — so one 12s cold start
produced 'snapshots are slow in this run: p95 12417ms over 1 captures'
with hints blaming device load or a stale daemon, inviting exactly the
restart spirals the hints exist to prevent (observed on every
AppControlBench run).

The warning now judges only warm captures (first sample excluded) and
only once at least three exist; the displayed stats still cover every
sample, so the cold start remains visible as maxMs.
@github-actions

github-actions Bot commented Aug 6, 2026 •

Copy link
Copy Markdown
Contributor

Size Report

Metric Base Current Diff
JS raw 1.98 MB 1.98 MB +508 B
JS gzip 634.5 kB 634.6 kB +125 B
npm tarball 764.5 kB 764.7 kB +121 B
npm unpacked 2.68 MB 2.68 MB +508 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 27.4 ms 27.6 ms +0.2 ms
CLI --help 65.3 ms 65.6 ms +0.2 ms

Top changed chunks:

Chunk Raw diff Gzip diff
dist/src/screenshot-result.js +508 B +125 B

@thymikee

thymikee commented Aug 6, 2026

Copy link
Copy Markdown
Member Author

Thermo-nuclear quality review

Verdict: the behavior change is right and well-evidenced, but it landed at the wrong layer — the policy now lives in one of the four paths that produce this summary, and the other three silently disagree with it. There's a code-judo move here that fixes all of them with a smaller diff than the current one.

1. The policy is forked: 3 of 4 summary-producing paths keep the old rule

summarizeSnapshotDiagnostics got the warm-judging rule, but it has exactly one production caller (src/daemon/snapshot-runtime.ts:358, the interactive session path). The replay handlers build the same user-facing summary from the same kind of ordered live samples via summarizeSnapshotTimingSamples, which still warns on any p95 ≥ 1500ms over any sample count:

  • src/daemon/handlers/session-replay-runtime.ts:210 — samples accumulated across the replay run by packages/ad-replay/src/internal/step-loop.ts:136
  • src/daemon/handlers/session-replay-runtime-failure.ts:39
  • src/daemon/handlers/session-replay-maestro-response.ts:76 — literally session.snapshotDiagnostics.samples.slice(snapshotStart), the same array the fixed path judges

The PR body's own trigger scenario — fresh daemon + runner, first capture folds startup — is exactly how replay runs execute in bench/CI. The false positive this PR kills still fires verbatim on every cold-started replay result, with the same restart-inviting hint text. Fixing one hand-picked call site is the structural problem: nothing enumerates "the paths that judge ordered live samples," so nothing forces them to agree, and today they don't.

2. The judo move: push the policy one level down and revert the fork

The diff currently un-delegates summarizeSnapshotDiagnostics from summarizeSnapshotTimingSamples and inlines a third copy of the { stats, ...(p95 ≥ threshold ? { warning } : {}) } assembly (there were already two: summarizeSnapshotTimingSamples and mergeSnapshotDiagnostics). The same code one level down deletes all of that:

export function summarizeSnapshotTimingSamples(
  samples: SnapshotTimingSample[],
): SnapshotDiagnosticsSummary | undefined {
  if (samples.length === 0) return undefined;
  // The first capture folds one-time startup (runner launch, helper install),
  // and nearest-rank p95 over a small run is just its max — so the warning
  // judges warm captures only, and only once enough exist to mean anything.
  // Displayed stats still cover every sample.
  const warm = samples.slice(1);
  const judged =
    warm.length >= MIN_WARM_SAMPLE_COUNT ? buildSnapshotTimingStats(warm) : undefined;
  return toSummary(buildSnapshotTimingStats(samples), judged);
}

function toSummary(
  stats: SnapshotTimingStats,
  judged: SnapshotTimingStats | undefined,
): SnapshotDiagnosticsSummary {
  return {
    stats,
    ...(judged && judged.p95Ms >= SLOW_SNAPSHOT_P95_WARNING_MS
      ? { warning: formatSlowSnapshotWarning(judged) }
      : {}),
  };
}

summarizeSnapshotDiagnostics goes back to being the one-line delegate it was before this PR, the three replay paths are fixed for free, and the policy has a single home.

One honest caveat: for the maestro path with snapshotStart > 0, the first sliced sample isn't the session cold start, so this drops one legitimate warm sample from judgment there. That's strictly conservative — it can only make the warning marginally less sensitive, never produce a new false positive — and it's the right trade against keeping the policy forked.

3. mergeSnapshotDiagnostics resurrects the exact warning this PR suppresses

Declaring the merge path out of scope doesn't make it neutral — it makes the two layers contradict each other. samplesFromStats reconstructs each run as [p50 × (n−2), p95, max], so a run's cold start comes back twice (it is both the p95 and the max of a small run), and merge re-judges the aggregate from scratch. Concretely: three replay tests, each one 12.4s cold start + five ~520ms warm captures, each correctly silent under the new policy (warm p95 540ms) — the merged suite report emits p95 12400ms over 18 captures (nearest-rank index 17 of 18 lands on a cold value; verified numerically). Every constituent says "fine," the suite says "degraded," and the suite is wrong for precisely the reason stated in this PR's description.

The clean rule that falls out of the restructure above: only the layer that has ordered samples ever judges; merge only aggregates. Merge keeps building merged display stats but derives its warning from its inputs:

...(summaries.some((s) => s?.warning) ? { warning: formatSlowSnapshotWarning(stats) } : {})

That deletes merge's independent judgment (the second pre-existing copy of the assembly) rather than trying to make lossy reconstructed samples support a policy they can't express. The one thing this gives up — detecting chronic slowness that only shows cross-run when every individual run is below the min-count — wasn't reliably detectable from reconstructed samples anyway, since they can't distinguish chronic-warm-slow from cold-heavy.

4. Tests assert only at the layer the PR edited

The three regression tests are good, but they all pin summarizeSnapshotDiagnostics. Nothing pins the replay path (where the bug still lives) or the merge path (where it resurrects). With the restructure: move the cold-start tests to summarizeSnapshotTimingSamples (the session path inherits them through the delegate), and add one merge test asserting that summaries which individually declined to warn don't produce a merged warning.

Nits

  • The six-line comment in summarizeSnapshotDiagnostics narrates the incident ("together they made a single cold start read as 'device degraded' with restart-inviting hints"). That history is PR-body material — it's already there, verbatim. The code comment should state only the invariant the code can't show (first capture folds startup; small-sample nearest-rank p95 = max; judge warm only).
  • MIN_WARNING_SAMPLE_COUNT counts warm samples — a reader of the name will misread it as a floor on total captures (the real floor is 4). MIN_WARM_SAMPLE_COUNT says what it gates.

File-size and boundary checks: clean (195-line contracts module, no new casts/optionality, threshold constants stay private).

Happy to approve once the policy is single-homed — the fix itself is exactly right, and the restructure is a net-negative diff against what's here now.


Deep review pass generated with Claude Code.

@thymikee

thymikee commented Aug 6, 2026

Copy link
Copy Markdown
Member Author

Review at exact head dcd082eb: not ready.

P1: the warm-only policy is applied only by summarizeSnapshotDiagnostics(session). Native replay success/failure and Maestro response/error still call unchanged summarizeSnapshotTimingSamples, while replay-suite aggregation reconstructs full stats through unchanged mergeSnapshotDiagnostics; those production paths can still emit the exact one-cold-capture p95 … over 1 captures warning this PR claims to remove. Make warning eligibility explicit and carry/merge sufficient warm evidence, or fail closed on aggregate warnings without temporal provenance; add native replay, Maestro, and merged-suite regressions.

P2: MIN_WARNING_SAMPLE_COUNT = 3 does not establish the stated chronic-vs-noise rule because nearest-rank p95 remains the maximum through 19 samples; one warm outlier among three still triggers the restart-inviting warning. Use an outlier-resistant/quorum policy or a sufficient sample threshold and cover the one-warm-outlier case.

The direct-session tests are red-before meaningful, but only cover one entrypoint. Do not apply ready-for-human.

Push the warm-judging rule down into summarizeSnapshotTimingSamples so
the interactive session path and all three replay handler paths share
one policy, and summarizeSnapshotDiagnostics returns to a one-line
delegate. Merge no longer judges slowness from lossy order-less
reconstructed samples (a run's cold start comes back as both its p95
and max): it aggregates display stats and carries a warning only when a
constituent run judged one itself. MIN_WARNING_SAMPLE_COUNT renamed to
MIN_WARM_SAMPLE_COUNT — it gates warm samples, not total captures.

Cold-start regression tests move to the shared layer; suite-aggregation
tests now pin that individually-silent runs merge silent.
@thymikee

thymikee commented Aug 6, 2026

Copy link
Copy Markdown
Member Author

All four points taken in d7ad212:

1+2. Policy single-homed via the judo move: summarizeSnapshotTimingSamples now owns warm-judging; summarizeSnapshotDiagnostics is back to a one-line delegate, so the three replay handler paths inherit the fix for free, and the third copy of the summary assembly is gone (net-negative diff vs the previous commit, as advertised). The maestro snapshotStart > 0 caveat is accepted as strictly conservative.

  1. Merge only aggregates: warning derives from constituents (summaries.some(s => s?.warning)), its independent judgment deleted. Your three-runs-merge-to-false-positive scenario is now pinned by the suite-aggregation tests: individually-silent runs merge silent.

  2. Tests moved to the layer that owns the policy: cold-start cases assert summarizeSnapshotTimingSamples directly; the session path keeps its delegate coverage; replay tests exercise the warning through 4 warm samples; merge has both directions (silent constituents → silent merge, warned constituent → warned merge).

Nits: comment trimmed to the invariant only; constant renamed MIN_WARM_SAMPLE_COUNT.

@thymikee

thymikee commented Aug 6, 2026

Copy link
Copy Markdown
Member Author

Re-review at 65b3c84f: the prior path-policy P1 is fixed—direct session, native replay success/failure, and Maestro now converge on summarizeSnapshotTimingSamples, and merge no longer invents warnings from individually silent runs. Two blockers remain.

P1: merge gates on summaries.some(warning) but formats the warning from reconstructed aggregate stats; one warned slow constituent plus enough fast summaries can yield merged p95 below 1500ms while output still says snapshots are slow … p95 100ms. Preserve truthful constituent context or define an aggregate-specific gate/message and add a low-aggregate/one-warning regression.

P2: MIN_WARM_SAMPLE_COUNT = 3 is unchanged; nearest-rank p95 is the maximum through 19 samples, so [cold, 520, 520, 12400] still emits the same restart-inviting warning from one warm outlier. Use a quorum/outlier-resistant rule or sufficient sample floor, and add a one-warm-outlier regression.

CI is green, but the prior P2 was not addressed; do not apply ready-for-human.

…bout warned runs (review)

A single warm outlier could still fire the warning (nearest-rank p95 is
the maximum through nineteen samples): chronic now additionally requires
at least two slow warm captures. And the merged warning formatted its
number from the reconstructed aggregate, so one slow run among many fast
ones produced 'slow: p95 <fast number>' — the merged message now reports
how many runs warned and the worst warned run's own p95, never the
aggregate. Regressions for both: one-warm-outlier stays silent; a slow
run merged with many fast ones warns with the slow run's number while
the aggregate p95 sits below the threshold.
@thymikee

thymikee commented Aug 6, 2026

Copy link
Copy Markdown
Member Author

Both blockers addressed in 3e71969:

P1 — the merged warning no longer formats from the reconstructed aggregate: it reports slow in {k} of {n} runs (worst run p95 {x}ms), where x is the worst warned constituent's own p95. Regression pins your exact scenario: one slow warned run merged with many fast ones produces a warning carrying the slow run's 2900ms while the merged aggregate p95 sits at 100ms — and the test asserts the aggregate number does not appear.

P2 — chronic now requires a quorum: at least MIN_SLOW_WARM_SAMPLES = 2 warm captures at/over the threshold in addition to the warm p95 gate, so nearest-rank-equals-max can never fire from one outlier. Your [cold, 520, 520, 12400] case is the new one-warm-outlier regression and stays silent; genuinely chronic runs (4× ~2.6s warm) still warn.

Full affected suite green (diagnostics, suite-aggregation, maestro replay, serialization, capture).

@thymikee

thymikee commented Aug 6, 2026

Copy link
Copy Markdown
Member Author

Re-reviewed exact head 3e719696: both prior code blockers are fixed. summarizeSnapshotTimingSamples now needs ≥3 warm captures, warm p95 over threshold, and ≥2 slow warm captures, so one warm outlier stays silent; direct session and replay/Maestro paths inherit the shared rule. Merge no longer quotes reconstructed aggregate p95 as evidence—it reports warned-run count and the worst warned constituent, with a non-vacuous regression for a fast aggregate plus one warned run. CI is green and there are no code findings.

Readiness still needs exact-head live evidence under docs/agents/pull-requests.md: run a fresh simulator/device session showing the first startup-heavy capture plus subsequent fast warm captures does not emit the restart-inviting warning, while diagnostics still report full stats (and, ideally, a sustained-slow run still warns). The current tests inject timing samples and do not observe the changed device-facing warning path. No ready-for-human label until that evidence is attached.

@thymikee

thymikee commented Aug 6, 2026

Copy link
Copy Markdown
Member Author

Exact-head live evidence (3e71969, iOS Simulator)

Env: iPhone 17 Pro sim (6044A251, iOS 26.2), fresh isolated AGENT_DEVICE_STATE_DIR, daemon cold (clean:daemon), sim cold-booted, CLI run from this head via node --experimental-strip-types src/bin.ts.

Leg 1 — startup-heavy first capture + fast warm captures stays silent, stats stay complete

open com.apple.Preferences then 5× snapshot --json. First capture folds the runner launch (12,772 ms); every warm capture is ~131 ms:

response count p50Ms p95Ms maxMs warning
snap 1 1 12772 12772 12772 none
snap 2 2 131 12772 12772 none
snap 3 3 131 12772 12772 none
snap 4 4 131 12772 12772 none
snap 5 5 131 12772 12772 none
  • No snapshotDiagnostics.warning on any of the 5 responses — including snap 5, where the warm quorum (4 warm samples) exists and judges the warm set fast. Pre-fix main warns here from snap 1 on (p95 >= 1500 over all samples, cold spike = p95).
  • Displayed stats still cover every sample: count grows 1→5 and maxMs keeps the 12,772 ms cold spike visible, so the diagnostics don't hide the startup cost — they just stop blaming the device for it.

Second confirmation on a different app (Safari, Wikipedia article, --raw): first capture 2,495 ms, warm 218 ms — same shape, silent on all 5 responses.

Leg 2 — sustained-slow attempt

Tried to produce a genuine sustained-slow run on this rig: heavy Safari pages (268-node raw trees, warm 267 ms), plus captures under 14× yes CPU load (warm still ~218 ms). Healthy warm captures sit ~10× under the 1,500 ms threshold, so the warning is not reachable on a healthy simulator — which is the point of the fix: only a warm quorum of ≥2 slow captures among ≥3 warm samples can fire it. The sustained-slow and merged-run warning branches are pinned by unit regressions in src/__tests__/snapshot-diagnostics.test.ts (warm-quorum fire/non-fire, single-warm-outlier silence, merged message speaking about warned runs with a non-vacuous fast-aggregate case).

Raw JSON responses for both legs retained locally (snap-1..5.json per leg) — can attach if wanted.

@thymikee
thymikee merged commit f93b259 into main Aug 6, 2026
31 checks passed
@thymikee
thymikee deleted the fix/slow-snapshot-warning-cold-start branch August 6, 2026 11:52
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-08-06 11:52 UTC

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant