Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 19 additions & 18 deletions docs/adr/0014-session-ref-frame-lifetime.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,9 @@ remain compatible with selector-based replay, and add no automatic capture or pe
## Decision

The terms introduced below are now the implemented model; the ref-frame vocabulary is promoted into
`CONTEXT.md`. The coarse `snapshotRefsStale` marker still backs read-only staleness warnings and is
removed once per-platform enforcement is confirmed by live evidence (migration step 8).
`CONTEXT.md`. The superseded coarse `snapshotRefsStale` marker has been removed (migration step 8):
read-only ref staleness is now derived from frame state — a plain ref warns once the frame has expired,
and a read-only capture no longer marks refs stale because it does not expire the frame.

### Ref frames are separate from operational observations

Expand All @@ -62,9 +63,9 @@ the emitted subset, including ancestors, siblings, overlays, and the viewport. T
the evidence tree, bounds partial authority. The frame and latest observation share immutable capture
data when they originate from the same capture; neither transition deep-copies the tree.

The existing `snapshotGeneration`/`snapshotRefsStale` implementation evolves behind one ref-frame
module. The public name `refsGeneration` and the `@e12~s42` grammar remain unchanged for wire
compatibility.
The existing `snapshotGeneration` implementation evolves behind one ref-frame module; the
`snapshotRefsStale` marker it originally paired with has since been removed (migration step 8). The
public name `refsGeneration` and the `@e12~s42` grammar remain unchanged for wire compatibility.

### Frame transitions

Expand Down Expand Up @@ -221,12 +222,12 @@ resolver rather than pretending all subcommands behave alike.
The registry is the exhaustive source of truth. The following table is non-exhaustive guidance for
classifying representative actions; it must not become a second prose registry:

| Effect | Commands/actions |
| --- | --- |
| `may-invalidate` | press, click, fill, longpress, type, focus, scroll, swipe, gesture, back, home, `tv-remote`, orientation, open/relaunch, trigger/push delivery, settings changes, install/reinstall, React Native overlay dismissal, and lifecycle operations that can replace the visible surface |
| Conditional resolver | keyboard status preserves while dismiss/return/input invalidate; alert get/wait preserve while accept/dismiss invalidate; find reads preserve while click/fill/focus/type delegate to their leaf mutation |
| `delegated` | batch, replay, and test/suite orchestrators; each nested leaf owns its transition |
| `preserve` | snapshots and other observation, assertion, screenshot, recording, trace, logs, events, network inspection, performance, inventory, capability, lease, and transport-management operations unless a selected subaction directly manipulates the visible surface |
| Effect | Commands/actions |
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `may-invalidate` | press, click, fill, longpress, type, focus, scroll, swipe, gesture, back, home, `tv-remote`, orientation, open/relaunch, trigger/push delivery, settings changes, install/reinstall, React Native overlay dismissal, and lifecycle operations that can replace the visible surface |
| Conditional resolver | keyboard status preserves while dismiss/return/input invalidate; alert get/wait preserve while accept/dismiss invalidate; find reads preserve while click/fill/focus/type delegate to their leaf mutation |
| `delegated` | batch, replay, and test/suite orchestrators; each nested leaf owns its transition |
| `preserve` | snapshots and other observation, assertion, screenshot, recording, trace, logs, events, network inspection, performance, inventory, capability, lease, and transport-management operations unless a selected subaction directly manipulates the visible surface |

Clipboard reads and writes preserve the frame because pasteboard state alone does not change element
identity. A later paste/type action is independently invalidating.
Expand Down Expand Up @@ -437,11 +438,12 @@ Each step lands green and independently useful:
evidence; promote the implemented vocabulary into `CONTEXT.md`; then remove the superseded coarse
stale marker.

Implementation status: steps 1–7 have landed — the ref-frame module and observation split (1), the
complete daemon classification and gate (2), the pre-side-effect seam at every leaf (3), correct
complete/partial publication with bounded scope, MCP pin retention, and pinned partial CLI text (4),
Android freshness decoupled from positional ref authorization (5), the cross-platform contract and
provider evidence (6), and fail-closed admission enforcement across platforms with typed reasons (7).
Implementation status: all migration steps have landed — the ref-frame module and observation split
(1), the complete daemon classification and gate (2), the pre-side-effect seam at every leaf (3),
correct complete/partial publication with bounded scope, MCP pin retention, and pinned partial CLI text
(4), Android freshness decoupled from positional ref authorization (5), the cross-platform contract and
provider evidence (6), fail-closed admission enforcement across platforms with typed reasons (7), and
the docs/vocabulary promotion plus removal of the superseded coarse `snapshotRefsStale` marker (8).
Fresh live evidence has exercised nearly every production seam — Apple runtime-ref, direct/native
selector, generation-pin, generic, and lifecycle paths; Android helper freshness (including proven
non-retarget) and Android existing-session relaunch; and a real provider-backed interaction plus
Expand All @@ -455,8 +457,7 @@ regressions (`android-system-dialog-ref-frame.test.ts` proves recovery expires t
tap; `interaction-android-recovery-abort.test.ts` proves an outstanding ref action then aborts with the
shared `ref_frame_expired` rejection and no dispatch), the seam is enforced in code identically to the
verified paths, and it is recorded here as a documented, accepted evidence gap rather than an open
release blocker. Every other enabled seam is proven on hardware, so step 8's removal of the superseded
coarse `snapshotRefsStale` marker is unblocked.
release blocker. Every other enabled seam is proven on hardware.

PR #1241 landed independently as a compatible transitional fix. It rejects a known iOS stale-marker
case before this full lifecycle is implemented; it does not own the architecture migration.
Expand Down
14 changes: 12 additions & 2 deletions src/commands/interaction/runtime/selector-read-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,27 @@ export type CapturedSnapshot = {

export type SelectorSnapshotOptions = SelectorSnapshotInput;

/**
* Resolve the snapshot a `@ref` READ binds against. ADR 0014: a ref resolves
* against the AUTHORIZED frame tree (`refFrameSnapshot`), never the latest
* operational observation — so an internal read-only capture that replaced the
* observation cannot let a plain `@eN` resolve a different element by positional
* coincidence. Missing frame evidence fails (the ref is simply not found in the
* retained tree) rather than falling through to a newer observation. Only used
* by ref reads; selector reads capture fresh through `captureSelectorSnapshot`.
*/
export async function requireSnapshotSession(
runtime: AgentDeviceRuntime,
requestedName: string | undefined,
): Promise<CapturedSnapshot> {
const sessionName = requestedName ?? 'default';
const session = await runtime.sessions.get(sessionName);
if (!session) throw new AppError('SESSION_NOT_FOUND', 'No active session. Run open first.');
if (!session.snapshot) {
const frameTree = session.refFrameSnapshot ?? session.snapshot;
if (!frameTree) {
throw new AppError('INVALID_ARGS', 'No snapshot in session. Run snapshot first.');
}
return { sessionName, session, snapshot: session.snapshot };
return { sessionName, session, snapshot: frameTree };
}

export async function captureSelectorSnapshot(
Expand Down
6 changes: 4 additions & 2 deletions src/commands/interaction/runtime/settle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,8 +322,10 @@ function resolveSettleHint(
// The settle loop itself captures with updateSession: false (a capture that
// later stalls must not race a session write past the response). The FINAL
// capture is stored so follow-up snapshots/selectors see the latest surface.
// Only settled captures issue a diff/ref payload; unsettled stored captures
// conservatively mark prior refs stale through the runtime session writer.
// Only settled captures issue a diff/ref payload; an unsettled stored capture
// replaces the observation without issuing refs — it does NOT touch the ref
// frame. Read staleness is frame-derived: the frame expires at a side-effect
// seam (ADR 0014), not because a fresh observation was stored here.
// Sparse-quality captures are not stored (mirroring captureSelectorSnapshot)
// and therefore issue no refs. The fetched session is returned alongside
// `stored` so the caller can read `appBundleId` for settle-chrome scoping
Expand Down
69 changes: 49 additions & 20 deletions src/daemon/__tests__/session-snapshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ test('setSessionSnapshot advances the generation on every tree replacement (#107
const seeded = session.snapshotGeneration!;
expect(seeded).toBeGreaterThanOrEqual(100_000);
expect(seeded).toBeLessThan(1_000_000);
expect(session.snapshotRefsStale).toBe(true);
// ADR 0014: replacing the observation does NOT touch the ref frame.
expect(session.refFrameState).toBeUndefined();

// Storing the SAME snapshot object again is not a replacement.
setSessionSnapshot(session, first);
Expand All @@ -51,7 +52,6 @@ test('a reopened session reseeds so pins from a previous lifetime do not silentl
// Reopen: a fresh session object restarts the counter with a NEW seed.
const secondLifetime = makeSession();
setSessionSnapshot(secondLifetime, makeSnapshot());
secondLifetime.snapshotRefsStale = false;

// Probabilistic, not identity-based: the seeds collide with ~1/900000
// probability (an accepted residual risk, documented on the field).
Expand All @@ -66,56 +66,85 @@ test('a reopened session reseeds so pins from a previous lifetime do not silentl
).toContain(`minted from snapshot s${oldGeneration}`);
});

test('resolveRefStalenessWarning: pinned-current clean, pinned-stale precise, plain coarse', () => {
test('resolveRefStalenessWarning: frame expiry is checked before the epoch (ADR 0014 evidence #17)', () => {
const session = makeSession();
session.snapshotGeneration = 15;
session.snapshotRefsStale = true;
session.refFrameGeneration = 15;

// Pinned to the stored generation: the pin proves the ref matches the tree,
// so the coarse marker is overruled.
// Expired frame: ANY read is stale, even a pin matching the epoch — a matching
// pin proves identity within the retained frame, not that the UI is current.
session.refFrameState = 'expired';
expect(resolveRefStalenessWarning({ session, ref: '@e37', mintedGeneration: 15 })).toBe(
STALE_SNAPSHOT_REFS_WARNING,
);
expect(resolveRefStalenessWarning({ session, ref: '@e37', mintedGeneration: undefined })).toBe(
STALE_SNAPSHOT_REFS_WARNING,
);

// Active frame: a pin matching the epoch and a plain ref are both clean; a pin
// from another epoch gets the precise generation warning.
session.refFrameState = 'active';
expect(
resolveRefStalenessWarning({ session, ref: '@e37', mintedGeneration: 15 }),
).toBeUndefined();

expect(
resolveRefStalenessWarning({ session, ref: '@e37', mintedGeneration: undefined }),
).toBeUndefined();
expect(resolveRefStalenessWarning({ session, ref: '@e37', mintedGeneration: 12 })).toBe(
'Ref @e37 was minted from snapshot s12 but the session tree is now s15 — re-run snapshot -i.',
"Ref @e37 was minted from snapshot s12 but the session's ref frame is now s15 — re-run snapshot -i.",
);
});

expect(resolveRefStalenessWarning({ session, ref: '@e37', mintedGeneration: undefined })).toBe(
STALE_SNAPSHOT_REFS_WARNING,
);
test('resolveRefStalenessWarning names the frozen frame epoch, not the bumped observation generation (ADR 0014)', () => {
const session = makeSession();
// A frame was issued at generation 15.
session.snapshotGeneration = 15;
session.refFrameGeneration = 15;
session.refFrameState = 'active';

// A read-only capture replaces the observation and advances the observation
// counter WITHOUT re-issuing the frame — the frame epoch stays frozen at 15.
setSessionSnapshot(session, makeSnapshot());
expect(session.snapshotGeneration).toBe(16);
expect(session.refFrameGeneration).toBe(15);

session.snapshotRefsStale = false;
// A pin matching the FROZEN frame epoch is clean, even though the observation
// generation has since advanced past it.
expect(
resolveRefStalenessWarning({ session, ref: '@e37', mintedGeneration: undefined }),
resolveRefStalenessWarning({ session, ref: '@e37', mintedGeneration: 15 }),
).toBeUndefined();

// A pin from another epoch names the frame epoch (s15), never the bumped
// observation generation (s16).
expect(resolveRefStalenessWarning({ session, ref: '@e37', mintedGeneration: 12 })).toBe(
"Ref @e37 was minted from snapshot s12 but the session's ref frame is now s15 — re-run snapshot -i.",
);
});

test('resolveRefStalenessWarning treats a missing stored generation as s0', () => {
const session = makeSession();
expect(resolveRefStalenessWarning({ session, ref: 'e2', mintedGeneration: 3 })).toBe(
'Ref @e2 was minted from snapshot s3 but the session tree is now s0 — re-run snapshot -i.',
"Ref @e2 was minted from snapshot s3 but the session's ref frame is now s0 — re-run snapshot -i.",
);
expect(resolveRefStalenessWarning({ session, ref: '@e2', mintedGeneration: 0 })).toBeUndefined();
});

test('markSessionPartialRefsIssued: an empty result leaves all state untouched (ADR 0014)', () => {
test('markSessionPartialRefsIssued: an empty result leaves all frame state untouched (ADR 0014)', () => {
const session = makeSession();
// A useful prior frame exists.
session.snapshotRefsStale = true;
session.refFrameState = 'active';
session.refFrameScope = new Set(['e1']);
session.refFrameGeneration = 7;

// An empty partial publication (no refs) must not supersede that authority or
// even clear the coarse marker.
// An empty partial publication (no refs) must not supersede that authority.
markSessionPartialRefsIssued(session, []);
expect(session.snapshotRefsStale).toBe(true);
expect(session.refFrameState).toBe('active');
expect(session.refFrameScope).toEqual(new Set(['e1']));
expect(session.refFrameGeneration).toBe(7);

// A non-empty result supersedes with exactly its bodies.
session.snapshotGeneration = 9;
markSessionPartialRefsIssued(session, ['@e5~s7', 'e6']);
expect(session.snapshotRefsStale).toBe(false);
expect(session.refFrameScope).toEqual(new Set(['e5', 'e6']));
expect(session.refFrameGeneration).toBe(9);
});
27 changes: 0 additions & 27 deletions src/daemon/handlers/__tests__/find.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -751,33 +751,6 @@ test('handleFindCommands wait captures fresh snapshots while polling', async ()
expect(mockDispatch).toHaveBeenCalledTimes(2);
});

test('handleFindCommands click re-issues a fresh ref and clears the stale-refs marker (#1076)', async () => {
const sessionName = 'default';
const session = makeSession(sessionName);
// As set by an earlier selector-resolution capture that replaced the tree.
session.snapshotRefsStale = true;

const { response, session: storedSession } = await runFindClickScenario({
positionals: ['Increment', 'click'],
nodes: [
{
index: 0,
type: 'Button',
label: 'Increment',
hittable: true,
rect: { x: 50, y: 0, width: 100, height: 100 },
depth: 0,
},
],
session,
});

expect(response.ok).toBe(true);
// The response returns a ref minted from the freshly stored snapshot, so
// the marker clears before the internal click @ref sub-invocation runs.
expect(storedSession.snapshotRefsStale).toBe(false);
});

test('handleFindCommands click omits refsGeneration — a mutating find never issues a pinnable ref (ADR 0014)', async () => {
const sessionName = 'default';
const session = makeSession(sessionName);
Expand Down
Loading
Loading