Skip to content

Commit 4c351a6

Browse files
committed
fix(snapshot): latch on the captured verdict, not the retained session snapshot
Review P2 on #1590: the latch seam read a diff capture's verdict back from session.snapshot, but an empty ref-scoped capture deliberately retains the previous stored snapshot (shouldKeepCurrentSnapshot) — so a deferred capture could consult a retained healthy verdict, clearing the latch and omitting the one-shot warning. The daemon snapshot backend now fills a per-request CapturedSnapshotQuality slot on every capture, and the seam latches on that just-captured verdict for both snapshot and diff. New production-path regression: an empty ref-scoped diff over a retained healthy snapshot with a deferred capture warns once (verified red against the previous seam).
1 parent 35ecde7 commit 4c351a6

3 files changed

Lines changed: 96 additions & 21 deletions

File tree

src/daemon/__tests__/snapshot-quality-latch.test.ts

Lines changed: 66 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
applyRecoveredWarningLatch,
1212
resolveRecoveredWarningLatch,
1313
} from '../snapshot-quality-latch.ts';
14-
import { dispatchSnapshotViaRuntime } from '../snapshot-runtime.ts';
14+
import { dispatchSnapshotDiffViaRuntime, dispatchSnapshotViaRuntime } from '../snapshot-runtime.ts';
1515
import { SessionStore } from '../session-store.ts';
1616
import type { SessionState } from '../types.ts';
1717

@@ -121,7 +121,8 @@ test('internal observation responses neither consume nor clear the latch', () =>
121121

122122
const internal = applyRecoveredWarningLatch({
123123
session,
124-
data: { snapshotQuality: deferredVerdict() },
124+
data: {},
125+
verdict: deferredVerdict(),
125126
internalObservation: true,
126127
});
127128
expect(internal.warnings).toBeUndefined();
@@ -130,17 +131,23 @@ test('internal observation responses neither consume nor clear the latch', () =>
130131
session.recoveredSnapshotWarningLatch = { appBundleId: 'com.example.app' };
131132
applyRecoveredWarningLatch({
132133
session,
133-
data: { snapshotQuality: { state: 'healthy', backend: 'tree' } },
134+
data: {},
135+
verdict: { state: 'healthy', backend: 'tree' },
134136
internalObservation: true,
135137
});
136138
expect(session.recoveredSnapshotWarningLatch).toEqual({ appBundleId: 'com.example.app' });
137139
});
138140

139141
test('sessionless responses pass through unchanged', () => {
140-
const data = { snapshotQuality: deferredVerdict() };
141-
expect(applyRecoveredWarningLatch({ session: undefined, data, internalObservation: false })).toBe(
142-
data,
143-
);
142+
const data = {};
143+
expect(
144+
applyRecoveredWarningLatch({
145+
session: undefined,
146+
data,
147+
verdict: deferredVerdict(),
148+
internalObservation: false,
149+
}),
150+
).toBe(data);
144151
});
145152

146153
function scenario() {
@@ -152,7 +159,7 @@ function scenario() {
152159
return { sessionStore, sessionName, logPath: path.join(root, 'daemon.log') };
153160
}
154161

155-
function seedCapture(verdict: SnapshotQualityVerdict) {
162+
function seedCapture(verdict: SnapshotQualityVerdict, label = 'Continue') {
156163
dispatchCommandMock.mockResolvedValue({
157164
backend: 'xctest',
158165
truncated: false,
@@ -162,7 +169,7 @@ function seedCapture(verdict: SnapshotQualityVerdict) {
162169
index: 0,
163170
depth: 0,
164171
type: 'Button',
165-
label: 'Continue',
172+
label,
166173
rect: { x: 0, y: 0, width: 100, height: 44 },
167174
hittable: true,
168175
},
@@ -236,6 +243,56 @@ test('a healthy public capture re-arms the one-shot warning', async () => {
236243
expect(responseWarnings(rearmed).filter((line) => line === FULL_WARNING)).toHaveLength(1);
237244
});
238245

246+
test('an empty ref-scoped diff latches on the captured verdict, not the retained snapshot', async () => {
247+
const input = scenario();
248+
// The stored snapshot is healthy and carries the ref the diff will scope to;
249+
// the fresh capture is deferred and contains no node matching that scope, so
250+
// the empty scoped result deliberately retains the stored snapshot
251+
// (`shouldKeepCurrentSnapshot`) — a seam reading the verdict back from the
252+
// session would consult the retained healthy verdict and omit the warning.
253+
const session = input.sessionStore.get(input.sessionName)!;
254+
session.snapshot = {
255+
createdAt: Date.now(),
256+
snapshotQuality: { state: 'healthy', backend: 'tree' },
257+
nodes: [
258+
{
259+
index: 0,
260+
depth: 0,
261+
type: 'Button',
262+
ref: 'e1',
263+
label: 'Continue',
264+
rect: { x: 0, y: 0, width: 100, height: 44 },
265+
hittable: true,
266+
},
267+
],
268+
};
269+
// The deferred capture holds no node labeled 'Continue', so the '@e1' scope
270+
// resolves to zero nodes and the retention path runs.
271+
seedCapture(deferredVerdict(), 'Something else');
272+
273+
const diff = await dispatchSnapshotDiffViaRuntime({
274+
req: {
275+
command: 'diff',
276+
positionals: [],
277+
token: 't',
278+
session: input.sessionName,
279+
flags: { snapshotScope: '@e1' },
280+
},
281+
sessionName: input.sessionName,
282+
logPath: input.logPath,
283+
sessionStore: input.sessionStore,
284+
});
285+
286+
// The retention actually happened: the stored snapshot (and its healthy
287+
// verdict) survived the empty scoped capture.
288+
const retained = input.sessionStore.get(input.sessionName)?.snapshot;
289+
expect(retained?.nodes[0]?.label).toBe('Continue');
290+
expect(retained?.snapshotQuality?.state).toBe('healthy');
291+
292+
expect(responseWarnings(diff).filter((line) => line === FULL_WARNING)).toHaveLength(1);
293+
expect(storedLatch(input)).toEqual({ appBundleId: 'com.example.app' });
294+
});
295+
239296
test('a genuine recovered render keeps later deferred captures quiet without doubling', async () => {
240297
const input = scenario();
241298
seedCapture(genuineRecoveredVerdict());

src/daemon/snapshot-quality-latch.ts

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
11
import type { SnapshotQualityVerdict } from '@agent-device/kernel/snapshot';
2-
import {
3-
readSnapshotQualityVerdict,
4-
recoveredSnapshotQualityWarning,
5-
} from '../snapshot/snapshot-quality.ts';
2+
import { recoveredSnapshotQualityWarning } from '../snapshot/snapshot-quality.ts';
63
import type { DaemonResponseData, SessionState } from './types.ts';
74

85
type RecoveredWarningLatch = NonNullable<SessionState['recoveredSnapshotWarningLatch']>;
@@ -55,6 +52,16 @@ export function resolveRecoveredWarningLatch(params: {
5552
return { warning: recoveredSnapshotQualityWarning(verdict.backend), latch: { appBundleId } };
5653
}
5754

55+
/**
56+
* The verdict slot the daemon snapshot backend fills on every capture, so the
57+
* latch seam sees the verdict of THE capture that produced this response.
58+
* Reading it back from `session.snapshot` instead would be wrong: an empty
59+
* ref-scoped capture deliberately retains the previous stored snapshot
60+
* (`shouldKeepCurrentSnapshot`), so a deferred capture could consult a retained
61+
* healthy verdict — clearing the latch and omitting the one-shot warning.
62+
*/
63+
export type CapturedSnapshotQuality = { value?: SnapshotQualityVerdict };
64+
5865
/**
5966
* Applies the latch to a user-facing snapshot/diff response: updates the
6067
* session's latch state and prepends the full recovered warning when this is
@@ -65,15 +72,12 @@ export function resolveRecoveredWarningLatch(params: {
6572
export function applyRecoveredWarningLatch(params: {
6673
session: SessionState | undefined;
6774
data: DaemonResponseData;
75+
/** The just-captured verdict (`CapturedSnapshotQuality`), never a stored one. */
76+
verdict: SnapshotQualityVerdict | undefined;
6877
internalObservation: boolean;
6978
}): DaemonResponseData {
70-
const { session, data, internalObservation } = params;
79+
const { session, data, verdict, internalObservation } = params;
7180
if (internalObservation || !session) return data;
72-
// The snapshot command carries the capture's verdict in the response; diff
73-
// publishes only warnings, so its verdict is read from the session snapshot
74-
// the capture just stored.
75-
const verdict =
76-
readSnapshotQualityVerdict(data.snapshotQuality) ?? session.snapshot?.snapshotQuality;
7781
const decision = resolveRecoveredWarningLatch({
7882
verdict,
7983
appBundleId: session.appBundleId,

src/daemon/snapshot-runtime.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@ import {
1818
withSessionlessRunnerCleanup,
1919
} from './handlers/snapshot-session.ts';
2020
import { activateCompleteRefFrame } from './ref-frame.ts';
21-
import { applyRecoveredWarningLatch } from './snapshot-quality-latch.ts';
21+
import {
22+
applyRecoveredWarningLatch,
23+
type CapturedSnapshotQuality,
24+
} from './snapshot-quality-latch.ts';
2225
import { createDaemonRuntimePolicy } from './runtime-policy.ts';
2326
import { createDaemonRuntimeSessionStore } from './runtime-session.ts';
2427
import { isInteractiveObservation } from './session-action-recorder.ts';
@@ -145,6 +148,7 @@ async function dispatchSnapshotRuntimeCommand(
145148
if (iosAppSessionGuard) return iosAppSessionGuard;
146149

147150
return await withSessionlessRunnerCleanup(session, device, async () => {
151+
const capturedQuality: CapturedSnapshotQuality = {};
148152
const runtime = createSnapshotRuntime({
149153
req,
150154
sessionName,
@@ -153,6 +157,7 @@ async function dispatchSnapshotRuntimeCommand(
153157
session,
154158
device,
155159
snapshotScope: resolvedScope.scope,
160+
capturedQuality,
156161
});
157162
let result: Awaited<ReturnType<SnapshotRuntimeCommandParams['execute']>>;
158163
try {
@@ -184,6 +189,7 @@ async function dispatchSnapshotRuntimeCommand(
184189
data: applyRecoveredWarningLatch({
185190
session: sessionStore.get(sessionName),
186191
data: result.data,
192+
verdict: capturedQuality.value,
187193
internalObservation: req.internal?.observationOnly === true,
188194
}),
189195
};
@@ -212,6 +218,7 @@ function createSnapshotRuntime(params: {
212218
session: SessionState | undefined;
213219
device: SessionState['device'];
214220
snapshotScope: string | undefined;
221+
capturedQuality: CapturedSnapshotQuality;
215222
}) {
216223
const { req, sessionName, logPath, sessionStore, session, device, snapshotScope } = params;
217224
return createAgentDevice({
@@ -221,6 +228,7 @@ function createSnapshotRuntime(params: {
221228
session,
222229
device,
223230
snapshotScope,
231+
capturedQuality: params.capturedQuality,
224232
}),
225233
...createDaemonRuntimePolicy('snapshot'),
226234
sessions: createDaemonRuntimeSessionStore({
@@ -328,6 +336,7 @@ function createDaemonSnapshotBackend(params: {
328336
session: SessionState | undefined;
329337
device: SessionState['device'];
330338
snapshotScope: string | undefined;
339+
capturedQuality: CapturedSnapshotQuality;
331340
}): AgentDeviceBackend {
332341
const { req, logPath, session, device, snapshotScope } = params;
333342
return {
@@ -341,10 +350,15 @@ function createDaemonSnapshotBackend(params: {
341350
logPath,
342351
snapshotScope,
343352
});
353+
const annotations = snapshotCaptureAnnotationsFrom(capture);
354+
// Feed the latch seam the capture's own verdict: the stored session
355+
// snapshot is not a substitute (an empty ref-scoped capture retains the
356+
// previous snapshot, and diff never publishes the verdict).
357+
params.capturedQuality.value = annotations.quality;
344358
const snapshotDiagnostics = summarizeSnapshotDiagnostics(session);
345359
return {
346360
snapshot: capture.snapshot,
347-
...snapshotCaptureAnnotationsFrom(capture),
361+
...annotations,
348362
...(snapshotDiagnostics ? { snapshotDiagnostics } : {}),
349363
appName: session?.appBundleId ? (session.appName ?? session.appBundleId) : undefined,
350364
appBundleId: session?.appBundleId,

0 commit comments

Comments
 (0)