diff --git a/packages/capture-kit/src/durable-capture/transitions.test.ts b/packages/capture-kit/src/durable-capture/transitions.test.ts index 0ff01c1062..d50c0e3c02 100644 --- a/packages/capture-kit/src/durable-capture/transitions.test.ts +++ b/packages/capture-kit/src/durable-capture/transitions.test.ts @@ -220,7 +220,7 @@ test('a disposal finish disposes a preserving kind’s material too', async () = }); }); -test.each(['rebuild', 'retire', 'token', 'generation'] as const)( +test.each(['rebuild', 'retire', 'handle', 'token', 'generation'] as const)( 'a held finish after %s clears only its matching lifetime, handle and fence', async (change) => { const context = makeDurableCaptureContext(); @@ -270,7 +270,12 @@ test.each(['rebuild', 'retire', 'token', 'generation'] as const)( context.sessionStore.update(ref, (current) => ({ ...current, name: 'updated', - capture: { ...active, envelope: { ...active.envelope, fence } }, + capture: { + ...active, + handle: + change === 'handle' ? makeDurableCaptureStartResult(context).handle : active.handle, + envelope: { ...active.envelope, fence }, + }, })); } const before = context.sessionStore.get(context.sessionName)!; diff --git a/scripts/layering/architecture-ownership.ts b/scripts/layering/architecture-ownership.ts index aa42c574f7..42704c05c6 100644 --- a/scripts/layering/architecture-ownership.ts +++ b/scripts/layering/architecture-ownership.ts @@ -73,6 +73,7 @@ const DAEMON_INTERACTION_FACADE = { exports: [ 'FindRouteInput', 'InteractionRouteInput', + 'bindInteractionSession', 'captureSnapshotForSession', 'createInteractionRuntime', 'finalizeTouchInteraction', diff --git a/scripts/layering/session-resource-ownership.test.ts b/scripts/layering/session-resource-ownership.test.ts index 8a1453e6cb..a85922db6c 100644 --- a/scripts/layering/session-resource-ownership.test.ts +++ b/scripts/layering/session-resource-ownership.test.ts @@ -32,6 +32,10 @@ test('session resources are constructed only by their durable domain owners', () sessionStore.update(ref, { perfCapture: perf }); sessionStore.update(ref, { screenRecording: recording });`, ], + [ + 'src/daemon/screen-recording-session-binding.ts', + `sessionStore.publish(address, { ...draft, screenRecording });`, + ], [ 'packages/capture-kit/src/capture-admission/audio-probe-session-resource.ts', `sessionStore.set(name, { ...session, audioProbe: audio });`, diff --git a/scripts/layering/session-resource-ownership.ts b/scripts/layering/session-resource-ownership.ts index f9274f53e8..d0a8a61d3b 100644 --- a/scripts/layering/session-resource-ownership.ts +++ b/scripts/layering/session-resource-ownership.ts @@ -30,6 +30,7 @@ const RESOURCE_OWNERS: Readonly>> = { audioProbe: new Set(['src/daemon/session-capture-binding.ts', 'src/daemon/session-state.ts']), screenRecording: new Set([ 'src/daemon/session-capture-binding.ts', + 'src/daemon/screen-recording-session-binding.ts', 'src/daemon/session-state.ts', ]), perfCapture: new Set(['src/daemon/session-capture-binding.ts', 'src/daemon/session-state.ts']), diff --git a/src/__tests__/test-utils/store-factory.ts b/src/__tests__/test-utils/store-factory.ts index 0c9d222497..c646a9b861 100644 --- a/src/__tests__/test-utils/store-factory.ts +++ b/src/__tests__/test-utils/store-factory.ts @@ -11,3 +11,14 @@ export function makeSessionStore(prefix = 'agent-device-test-'): SessionStore { export function makeStoredSessionRef(session: SessionState, address = session.name): SessionRef { return makeSessionStore().publish(address, session); } + +export function storeSessionForTest( + store: SessionStore, + session: SessionState, + address = session.name, +): SessionRef { + const ref = store.lookup(address); + if (!ref) return store.publish(address, session); + if (ref.session !== session) throw new Error('A different test session occupies this address'); + return ref; +} diff --git a/src/daemon/__tests__/android-owner-seam.test.ts b/src/daemon/__tests__/android-owner-seam.test.ts index f522365dfa..29fbe29836 100644 --- a/src/daemon/__tests__/android-owner-seam.test.ts +++ b/src/daemon/__tests__/android-owner-seam.test.ts @@ -62,11 +62,13 @@ test('provider-owned Android sessions bypass local observation and recovery', as }, ], }; + const sessionStore = new SessionStore('/tmp/provider-owned-android'); + const ref = sessionStore.publish(session.name, session); await expect( resolveDirectTouchReferenceFrameSafely({ - session, + ref, flags: undefined, - sessionStore: new SessionStore('/tmp/provider-owned-android'), + sessionStore, contextFromFlags: () => ({}), captureSnapshotForSession: async () => session.snapshot!, observation, diff --git a/src/daemon/__tests__/filesystem-boundary-faults.test.ts b/src/daemon/__tests__/filesystem-boundary-faults.test.ts index da432598cc..bf19ec91b4 100644 --- a/src/daemon/__tests__/filesystem-boundary-faults.test.ts +++ b/src/daemon/__tests__/filesystem-boundary-faults.test.ts @@ -1,3 +1,4 @@ +import { storeSessionForTest } from '../../__tests__/test-utils/store-factory.ts'; import assert from 'node:assert/strict'; import crypto from 'node:crypto'; import path from 'node:path'; @@ -158,7 +159,7 @@ function createSessionStoreFixture(root: string): FilesystemBoundaryFixture { return { targetPath, - run: async () => store.finalizeRepairTeardown(session), + run: async () => store.finalizeRepairTeardown(storeSessionForTest(store, session)), expected: 'return', verifyReturn: (_value, errno) => { const tombstone = store.readRepairTombstone(session.name); diff --git a/src/daemon/__tests__/generic-settle.test.ts b/src/daemon/__tests__/generic-settle.test.ts index fa0eb55985..5ae2c10d04 100644 --- a/src/daemon/__tests__/generic-settle.test.ts +++ b/src/daemon/__tests__/generic-settle.test.ts @@ -209,7 +209,8 @@ beforeEach(() => { mockCaptureSnapshotForSession.mockReset(); mockCaptureSnapshotForSession.mockImplementation( (...args: Parameters) => { - const [session, flags, sessionStore, _contextFromFlags, options] = args; + const [ref, flags, sessionStore, _contextFromFlags, options] = args; + const session = sessionStore.requireCurrent(ref); return emulateCaptureSnapshotForSession(session, flags, sessionStore, options); }, ); diff --git a/src/daemon/__tests__/replay-repair/session-replay-repair-acceptance.test.ts b/src/daemon/__tests__/replay-repair/session-replay-repair-acceptance.test.ts index 76b16120ff..1f9e7d44f2 100644 --- a/src/daemon/__tests__/replay-repair/session-replay-repair-acceptance.test.ts +++ b/src/daemon/__tests__/replay-repair/session-replay-repair-acceptance.test.ts @@ -1,3 +1,4 @@ +import { storeSessionForTest } from '../../../__tests__/test-utils/store-factory.ts'; /** * ADR 0012 decision 6 acceptance test: a healed sibling `.ad` produced by the * repair loop must replay end-to-end in a FRESH session, with every selector @@ -140,7 +141,7 @@ test('a healed script survives repair + fresh-session replay: self-contained ope // repair-armed write on the same explicit finalize signal `close // --save-script` sets). --- markRepairTransactionComplete(session); - sessionStore.writeSessionLog(session); + sessionStore.writeSessionLog(storeSessionForTest(sessionStore, session)); const healedPath = path.join(root, 'flow.healed.ad'); expect(fs.existsSync(healedPath)).toBe(true); const healedScript = fs.readFileSync(healedPath, 'utf8'); diff --git a/src/daemon/__tests__/replay-repair/session-replay-repair-empty-tail.test.ts b/src/daemon/__tests__/replay-repair/session-replay-repair-empty-tail.test.ts index ae1788f148..04dd93bed5 100644 --- a/src/daemon/__tests__/replay-repair/session-replay-repair-empty-tail.test.ts +++ b/src/daemon/__tests__/replay-repair/session-replay-repair-empty-tail.test.ts @@ -1,3 +1,4 @@ +import { storeSessionForTest } from '../../../__tests__/test-utils/store-factory.ts'; /** * ADR 0012 decision 6, R2/R3, extended per #1262: behaviors introduced * alongside the `resume.from` / `repairHint` agreement fix @@ -188,7 +189,7 @@ test('a record-and-heal divergence on the LAST step resumes with an empty tail a // --- Commit: the transaction is COMPLETE, so the healed script actually // publishes — the corrective press survives, "click" (never recorded) does // not. Proves the empty-tail resume did not lead to a discarded repair. --- - const writeResult = sessionStore.writeSessionLog(session); + const writeResult = sessionStore.writeSessionLog(storeSessionForTest(sessionStore, session)); expect(writeResult.written).toBe(true); const healedPath = path.join(root, 'flow.healed.ad'); expect(fs.existsSync(healedPath)).toBe(true); @@ -312,7 +313,7 @@ test('a manual divergence (unannotated action-failure) on the LAST step resumes // since a `manual` divergence never dispatched it) does not. Proves the // empty-tail resume did not lead to a discarded repair (the #1260 // discard-at-close trap, now also closed for `manual`). --- - const writeResult = sessionStore.writeSessionLog(session); + const writeResult = sessionStore.writeSessionLog(storeSessionForTest(sessionStore, session)); expect(writeResult.written).toBe(true); const healedPath = path.join(root, 'flow.healed.ad'); expect(fs.existsSync(healedPath)).toBe(true); @@ -436,7 +437,7 @@ test('a caution (identity-mismatch) divergence on the LAST step resumes with an // --- Commit: COMPLETE, so the healed script publishes the corrective // press; the pre-action "click" (never dispatched) does not appear. --- - const writeResult = sessionStore.writeSessionLog(session); + const writeResult = sessionStore.writeSessionLog(storeSessionForTest(sessionStore, session)); expect(writeResult.written).toBe(true); const healedPath = path.join(root, 'flow.healed.ad'); expect(fs.existsSync(healedPath)).toBe(true); diff --git a/src/daemon/__tests__/replay-repair/session-replay-repair-transaction-close-ordering.test.ts b/src/daemon/__tests__/replay-repair/session-replay-repair-transaction-close-ordering.test.ts index 3fdede4587..c4065cff2d 100644 --- a/src/daemon/__tests__/replay-repair/session-replay-repair-transaction-close-ordering.test.ts +++ b/src/daemon/__tests__/replay-repair/session-replay-repair-transaction-close-ordering.test.ts @@ -1,3 +1,4 @@ +import { storeSessionForTest } from '../../../__tests__/test-utils/store-factory.ts'; /** * ADR 0012 decision 6 repair-transaction close-ordering guarantees (BLOCKER 2/3 sequencing): the * platform close must run and succeed BEFORE the healed `.ad` commits (never claim a successful @@ -244,7 +245,7 @@ test('BLOCKER 3: a competing second writer never overwrites a COMPLETE artifact // Writer 1 commits a complete artifact at the default healed path. const first = makeCompleteRepairSession(sessionStore, `${sessionName}-1`, root); - const r1 = sessionStore.writeSessionLog(first); + const r1 = sessionStore.writeSessionLog(storeSessionForTest(sessionStore, first)); expect(r1.written).toBe(true); const committed = fs.readFileSync(healedPath, 'utf8'); expect(committed).toContain(HEAL_COMPLETE_SENTINEL); @@ -261,7 +262,7 @@ test('BLOCKER 3: a competing second writer never overwrites a COMPLETE artifact result: { selectorChain: ['id="different"'] }, targetEvidence: freshEvidence('different', 'Different'), }; - const r2 = sessionStore.writeSessionLog(second); + const r2 = sessionStore.writeSessionLog(storeSessionForTest(sessionStore, second)); expect(r2.written).toBe(false); expect(r2.written === false && r2.error?.message).toMatch(/already exists/); // The first writer's complete artifact is byte-for-byte intact. diff --git a/src/daemon/__tests__/replay-repair/session-replay-repair-transaction.test.ts b/src/daemon/__tests__/replay-repair/session-replay-repair-transaction.test.ts index 9cc56d45bd..3136669a5f 100644 --- a/src/daemon/__tests__/replay-repair/session-replay-repair-transaction.test.ts +++ b/src/daemon/__tests__/replay-repair/session-replay-repair-transaction.test.ts @@ -1,3 +1,4 @@ +import { storeSessionForTest } from '../../../__tests__/test-utils/store-factory.ts'; /** * ADR 0012 decision 6 "repair transaction" lifecycle fixes (Q1/Q2a/Q2b/Q2c): * proves the WHOLE chain end to end, at the layer these fixes actually live — @@ -304,7 +305,7 @@ test('C5a: an incomplete repair reaped by idle-reap leaves a tombstone (no heale // Idle-reap tears the still-incomplete repair session down: the writer commits // nothing (not complete) and a tombstone is left behind (the exact teardown // step daemon-runtime.ts's teardownDaemonSession runs). - sessionStore.finalizeRepairTeardown(session); + sessionStore.finalizeRepairTeardown(storeSessionForTest(sessionStore, session)); sessionStore.delete(sessionName); expect(fs.existsSync(path.join(root, 'flow.healed.ad'))).toBe(false); @@ -353,7 +354,7 @@ test('C5a/BLOCKER 3: teardown of a COMPLETE repair auto-commits a self-contained // Teardown (e.g. the client tearing down the ephemeral daemon after a clean // repair) auto-commits the completed transaction and leaves no tombstone. - sessionStore.finalizeRepairTeardown(session); + sessionStore.finalizeRepairTeardown(storeSessionForTest(sessionStore, session)); expect(fs.existsSync(path.join(root, 'flow.healed.ad'))).toBe(true); const healedScript = fs.readFileSync(path.join(root, 'flow.healed.ad'), 'utf8'); expect(healedScript).toContain(HEAL_COMPLETE_SENTINEL); @@ -398,7 +399,7 @@ test('BLOCKER 1: a --from continuation on a reaped session returns SESSION_NOT_F const digest = leg1Divergence.resume.planDigest; // Idle-reap tears the incomplete repair down, leaving a tombstone. - sessionStore.finalizeRepairTeardown(sessionStore.get(sessionName)!); + sessionStore.finalizeRepairTeardown(sessionStore.lookup(sessionName)!); sessionStore.delete(sessionName); expect(sessionStore.readRepairTombstone(sessionName)).toBeDefined(); diff --git a/src/daemon/__tests__/request-recording-health.test.ts b/src/daemon/__tests__/request-recording-health.test.ts index 73000c5efa..d2dd73d068 100644 --- a/src/daemon/__tests__/request-recording-health.test.ts +++ b/src/daemon/__tests__/request-recording-health.test.ts @@ -1,5 +1,11 @@ import { test, expect, vi, beforeEach } from 'vitest'; import type { SessionState } from '../session-state.ts'; +import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; +import { + createRequestExecutionScope, + prepareLockedRequestScope, +} from '../request-execution-scope.ts'; +import { LeaseRegistry } from '../lease-registry.ts'; import { makeTestScreenRecordingResource } from '../../__tests__/test-utils/screen-recording-live-handle.ts'; vi.mock('../../platform-runtime-apple-resources.ts', async (importOriginal) => ({ @@ -54,7 +60,9 @@ test('runner-backed iOS recordings still invalidate on runner restarts', async ( sessionId: 'runner-after', }); - await refreshRecordingHealth(session); + const store = makeSessionStore(); + const ref = store.publish(session.name, session); + await refreshRecordingHealth(store, ref); expect(mockObserveRunnerSession).toHaveBeenCalledWith('sim-1'); expect(session.screenRecording?.handle.inspect().invalidatedReason).toBe( @@ -78,7 +86,9 @@ test.each([ }); mockObserveRunnerSession.mockResolvedValue(snapshot); - await refreshRecordingHealth(session); + const store = makeSessionStore(); + const ref = store.publish(session.name, session); + await refreshRecordingHealth(store, ref); expect(session.screenRecording.handle.inspect().invalidatedReason).toBe(reason); }); @@ -91,9 +101,138 @@ test('a recording without a runner identity adopts the first live observation', }); mockObserveRunnerSession.mockResolvedValue({ alive: true, sessionId: 'runner-first' }); - await refreshRecordingHealth(session); + const store = makeSessionStore(); + const ref = store.publish(session.name, session); + await refreshRecordingHealth(store, ref); const recording = session.screenRecording.handle.inspect(); expect(recording.runnerSessionId).toBe('runner-first'); expect(recording.invalidatedReason).toBeUndefined(); }); + +test.each(['rebuild', 'retire', 'handle', 'token', 'generation'] as const)( + 'a held health observation respects the current lifetime and resource: %s', + async (change) => { + const store = makeSessionStore(); + const session = makeIosSimulatorSession(true); + const active = makeTestScreenRecordingResource(session, { + backend: 'runner AVAssetWriter', + showTouches: true, + runnerSessionId: 'runner-before', + }); + session.screenRecording = active; + const ref = store.publish('default', session); + let finish!: (value: { alive: boolean; sessionId: string }) => void; + mockObserveRunnerSession.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const observation = refreshRecordingHealth(store, ref); + expect(mockObserveRunnerSession).toHaveBeenCalledWith('sim-1'); + if (change === 'rebuild') { + store.update(ref, { appName: 'Intervening app' }); + } else if (change === 'retire') { + store.retire(ref); + store.publish('default', session); + } else { + const replacement = makeTestScreenRecordingResource(session, { + backend: 'runner AVAssetWriter', + showTouches: true, + runnerSessionId: 'successor-runner', + }); + store.update(ref, { + screenRecording: { + ...active, + handle: change === 'handle' ? replacement.handle : active.handle, + envelope: { + ...active.envelope, + fence: { + ...active.envelope.fence, + token: change === 'token' ? 'new-token' : active.envelope.fence.token, + generation: active.envelope.fence.generation + (change === 'generation' ? 1 : 0), + }, + }, + }, + }); + } + const current = store.get('default')!; + finish({ alive: true, sessionId: 'runner-after' }); + await observation; + expect(store.get('default')).toBe(current); + expect(active.handle.inspect().invalidatedReason).toBe( + change === 'rebuild' ? 'iOS runner session restarted during recording' : undefined, + ); + if (change === 'rebuild') expect(current.appName).toBe('Intervening app'); + else expect(current.screenRecording?.handle.inspect().invalidatedReason).toBeUndefined(); + }, +); + +test.each(['rebuild', 'retire'] as const)( + 'locked request preparation keeps its captured lifetime after runner observation: %s', + async (change) => { + const store = makeSessionStore(); + const session = makeIosSimulatorSession(true); + session.screenRecording = makeTestScreenRecordingResource(session, { + backend: 'runner AVAssetWriter', + showTouches: true, + runnerSessionId: 'runner-before', + }); + const ref = store.publish('default', session); + let observed!: () => void; + const started = new Promise((resolve) => { + observed = resolve; + }); + let finish!: (value: { alive: boolean; sessionId: string }) => void; + mockObserveRunnerSession.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + observed(); + }), + ); + await using scope = await createRequestExecutionScope({ + req: { token: 'token', session: 'default', command: 'snapshot', positionals: [] }, + sessionStore: store, + leaseRegistry: new LeaseRegistry(), + }); + const prepared = scope.runLocked(() => + prepareLockedRequestScope({ + scope, + sessionStore: store, + trackDownloadableArtifact: () => 'artifact', + }), + ); + const outcome = prepared.then( + (value) => ({ value }), + (error) => ({ error }), + ); + await started; + let current; + if (change === 'rebuild') current = store.update(ref, { appName: 'Latest app' }); + else { + store.retire(ref); + current = makeIosSimulatorSession(false); + store.publish('default', current); + store.setRuntimeHints('default', { metroPort: 8083 }); + } + finish({ alive: true, sessionId: 'runner-before' }); + const result = await outcome; + expect(store.get('default')).toBe(current); + if (change === 'rebuild') { + expect(result).toMatchObject({ + value: { type: 'scope', scope: { existingSession: current } }, + }); + expect(current.appName).toBe('Latest app'); + if ('value' in result && result.value.type === 'scope') { + store.retire(ref); + store.publish('default', { ...makeIosSimulatorSession(false), surface: 'app' }); + expect(result.value.scope.handlerContextFromFlags(undefined).surface).toBeUndefined(); + } + } else { + expect(result).toMatchObject({ error: { details: { reason: 'session_lifetime_ended' } } }); + expect(store.getRuntimeHints('default')).toEqual({ metroPort: 8083 }); + } + }, +); diff --git a/src/daemon/__tests__/request-router-idle-expired.test.ts b/src/daemon/__tests__/request-router-idle-expired.test.ts index 94c4f0e0ed..b69d2f9fa6 100644 --- a/src/daemon/__tests__/request-router-idle-expired.test.ts +++ b/src/daemon/__tests__/request-router-idle-expired.test.ts @@ -95,8 +95,7 @@ test('an expired marker that has aged out stops explaining the absence', async ( test('an abandoned repair transaction outranks the idle-expiry marker', async () => { const { sessionStore, handler } = makeHandler('agent-device-router-idle-vs-repair-'); - writeIdleMarker(sessionStore, 'repair-x'); - sessionStore.writeRepairTombstone({ + const ref = sessionStore.publish('repair-x', { name: 'repair-x', device: { platform: 'apple', id: 'sim-1', name: 'iPhone', kind: 'simulator', booted: true }, createdAt: Date.now(), @@ -110,6 +109,10 @@ test('an abandoned repair transaction outranks the idle-expiry marker', async () }, }); + sessionStore.writeRepairTombstone(ref); + sessionStore.retire(ref); + writeIdleMarker(sessionStore, 'repair-x'); + const response = await handler(closeRequest('repair-x')); expect(response.ok).toBe(false); diff --git a/src/daemon/__tests__/request-router-repair-expired.test.ts b/src/daemon/__tests__/request-router-repair-expired.test.ts index 0e9947be33..6b768101e8 100644 --- a/src/daemon/__tests__/request-router-repair-expired.test.ts +++ b/src/daemon/__tests__/request-router-repair-expired.test.ts @@ -18,6 +18,8 @@ import { createRequestHandler } from './test-device-runtime-gateway.ts'; import type { DaemonRequest } from '../daemon-request.ts'; import type { SessionState } from '../session-state.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; +import { resolveEffectiveSessionName } from '../session-routing.ts'; +import { scopeRequestSession } from '../request-admission.ts'; import { LeaseRegistry } from '../lease-registry.ts'; import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; import { inspectAdReplay } from '@agent-device/ad-replay'; @@ -62,7 +64,9 @@ test('a command that finds no session but hits a live repair tombstone gets REPA const { sessionStore, handler } = makeHandler('agent-device-router-repair-expired-'); // The repair session was reaped (idle-reap) leaving a tombstone; the store // has no live session by that name. - sessionStore.writeRepairTombstone(tombstonedSession('repair-x')); + const ref = sessionStore.publish('repair-x', tombstonedSession('repair-x')); + sessionStore.writeRepairTombstone(ref); + sessionStore.retire(ref); const response = await handler(closeRequest('repair-x')); @@ -89,10 +93,12 @@ test('without a tombstone, a missing session still returns a plain SESSION_NOT_F // never completed at all. test('a command hitting a commit-failure tombstone gets REPAIR_COMMIT_FAILED with the real cause, not a generic REPAIR_SESSION_EXPIRED', async () => { const { sessionStore, handler } = makeHandler('agent-device-router-commit-failed-'); - sessionStore.writeRepairTombstone(tombstonedSession('repair-commit-fail'), undefined, { + const ref = sessionStore.publish('repair-commit-fail', tombstonedSession('repair-commit-fail')); + sessionStore.writeRepairTombstone(ref, undefined, { code: 'COMMAND_FAILED', message: 'A prior healed script already exists at /flows/login.healed.ad; ...', }); + sessionStore.retire(ref); const response = await handler(closeRequest('repair-commit-fail')); @@ -108,7 +114,9 @@ test('a command hitting a commit-failure tombstone gets REPAIR_COMMIT_FAILED wit test('an expired tombstone does not shadow a missing session', async () => { const { sessionStore, handler } = makeHandler('agent-device-router-expired-tombstone-'); // TTL 0 => already stale. - sessionStore.writeRepairTombstone(tombstonedSession('repair-y'), 0); + const ref = sessionStore.publish('repair-y', tombstonedSession('repair-y')); + sessionStore.writeRepairTombstone(ref, 0); + sessionStore.retire(ref); const response = await handler(closeRequest('repair-y')); @@ -144,7 +152,9 @@ test('a replay --from continuation on a reaped repair session gets REPAIR_SESSIO }).planDigest; // The repair session was reaped, leaving a tombstone; no live session exists. - sessionStore.writeRepairTombstone(tombstonedSession('repair-from')); + const ref = sessionStore.publish('repair-from', tombstonedSession('repair-from')); + sessionStore.writeRepairTombstone(ref); + sessionStore.retire(ref); const response = await handler({ token: 'test-token', @@ -162,3 +172,45 @@ test('a replay --from continuation on a reaped repair session gets REPAIR_SESSIO fs.rmSync(root, { recursive: true, force: true }); }); + +test.each(['cwd', 'tenant'] as const)( + 'repair markers use the resolved %s address for expiry and commit failure', + async (scope) => { + for (const failedCommit of [false, true]) { + const { sessionStore, handler } = makeHandler(`router-repair-${scope}-`); + const req = closeRequest('default'); + req.meta = + scope === 'cwd' + ? { cwd: mkdtempForTestSync('repair-workspace-') } + : { tenantId: 'tenant-a', sessionIsolation: 'tenant' }; + const address = resolveEffectiveSessionName(scopeRequestSession(req), sessionStore, { + attachesToSession: false, + }); + expect(address).not.toBe('default'); + const ref = sessionStore.publish(address, tombstonedSession('default')); + sessionStore.writeRepairTombstone( + ref, + undefined, + failedCommit ? { code: 'EACCES', message: 'script publication denied' } : undefined, + ); + sessionStore.retire(ref); + const response = await handler(req); + expect(response).toMatchObject({ + ok: false, + error: { code: failedCommit ? 'REPAIR_COMMIT_FAILED' : 'REPAIR_SESSION_EXPIRED' }, + }); + if (!response.ok && failedCommit) + expect(response.error.message).toContain('script publication denied'); + } + }, +); + +test('a raw default repair marker cannot explain a different workspace session', async () => { + const { sessionStore, handler } = makeHandler('router-repair-workspace-isolation-'); + const ref = sessionStore.publish('default', tombstonedSession('default')); + sessionStore.writeRepairTombstone(ref); + sessionStore.retire(ref); + const req = closeRequest('default'); + req.meta = { cwd: mkdtempForTestSync('different-repair-workspace-') }; + expect(await handler(req)).toMatchObject({ ok: false, error: { code: 'SESSION_NOT_FOUND' } }); +}); diff --git a/src/daemon/__tests__/request-save-script-transports.test.ts b/src/daemon/__tests__/request-save-script-transports.test.ts index 730a37d25a..6516da75b3 100644 --- a/src/daemon/__tests__/request-save-script-transports.test.ts +++ b/src/daemon/__tests__/request-save-script-transports.test.ts @@ -1,3 +1,4 @@ +import { storeSessionForTest } from '../../__tests__/test-utils/store-factory.ts'; import { isSessionRecording } from '../session-script-publication-capability.ts'; import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/device-inventory-gateways.ts'; /** @@ -204,7 +205,9 @@ for (const [transport, send] of TRANSPORTS) { expect(isSessionRecording(session)).toBe(false); expect(session.scriptPublication).toBe(undefined); // No artifact: the write a later close/teardown would attempt publishes nothing. - expect(sessionStore.writeSessionLog(session)).toEqual({ written: false }); + expect(sessionStore.writeSessionLog(storeSessionForTest(sessionStore, session))).toEqual({ + written: false, + }); expect(listAdArtifacts(root)).toEqual([]); expect(fs.existsSync(path.join(root, 'forged.ad'))).toBe(false); @@ -295,7 +298,7 @@ test('an owner-armed session still records its target and publishes its script', expect(isSessionRecording(session)).toBe(true); expect(scriptTargetPath(session.scriptPublication ?? NO_SCRIPT_PUBLICATION)).toBe(target); - const result = sessionStore.writeSessionLog(session); + const result = sessionStore.writeSessionLog(storeSessionForTest(sessionStore, session)); expect(result).toEqual({ written: true, path: target, actionCount: 1 }); expect(fs.readFileSync(target, 'utf8')).toMatch(/^open /m); }); diff --git a/src/daemon/__tests__/runtime-session.test.ts b/src/daemon/__tests__/runtime-session.test.ts index 60426909d6..8c70b3e82f 100644 --- a/src/daemon/__tests__/runtime-session.test.ts +++ b/src/daemon/__tests__/runtime-session.test.ts @@ -1,5 +1,6 @@ import { test, expect } from 'vitest'; import fs from 'node:fs'; +import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; import { makeIosSession } from '../../__tests__/test-utils/session-factories.ts'; import { flushDiagnosticsToSessionFile, @@ -14,9 +15,12 @@ test('createDaemonRuntimeSessionStore hides non-matching sessions and scopes wri const tempHome = mkdtempForTestSync('agent-device-runtime-session-home-'); const session = makeIosSession('qa-ios'); const writes: CommandSessionRecord[] = []; + const sessionStore = makeSessionStore(); + const ref = sessionStore.publish(session.name, session); const store = createDaemonRuntimeSessionStore({ sessionName: 'qa-ios', - getSession: () => session, + sessionStore, + ref, recordOptions: { includeSnapshot: true }, setRecord: (record) => { writes.push(record); @@ -57,3 +61,35 @@ test('createDaemonRuntimeSessionStore hides non-matching sessions and scopes wri fs.rmSync(tempHome, { recursive: true, force: true }); } }); + +test('runtime projections follow rebuilds and reject writes after their lifetime ends', async () => { + const sessionStore = makeSessionStore(); + const address = 'cwd:runtime:default'; + const ref = sessionStore.publish(address, makeIosSession('default')); + const writes: string[] = []; + const runtime = createDaemonRuntimeSessionStore({ + sessionName: address, + sessionStore, + ref, + setRecord: (_record, current) => { + writes.push(current!.appName!); + }, + }); + sessionStore.update(ref, { appName: 'Rebuilt' }); + expect(await runtime.get(address)).toMatchObject({ appName: 'Rebuilt' }); + await runtime.set({ name: address }); + expect(writes).toEqual(['Rebuilt']); + sessionStore.retire(ref); + const successor = sessionStore.publish( + address, + makeIosSession('default', { appName: 'Successor' }), + ); + expect(await runtime.get(address)).toBeUndefined(); + expect(() => runtime.set({ name: address })).toThrowError( + expect.objectContaining({ + details: expect.objectContaining({ reason: 'session_lifetime_ended' }), + }), + ); + expect(writes).toEqual(['Rebuilt']); + expect(sessionStore.requireCurrent(successor)).toBe(successor.session); +}); diff --git a/src/daemon/__tests__/selector-capture-runtime.test.ts b/src/daemon/__tests__/selector-capture-runtime.test.ts index 9384dc7553..d90131a822 100644 --- a/src/daemon/__tests__/selector-capture-runtime.test.ts +++ b/src/daemon/__tests__/selector-capture-runtime.test.ts @@ -52,6 +52,7 @@ test('selector capture cache is keyed by scoped presentation options', async () })); const runtime = createSelectorCaptureRuntime({ + ref: sessionStore.lookup(sessionName), device: session.device, session, sessionStore, @@ -225,6 +226,7 @@ function proofRuntime(params: { const consumedSnapshot: { state?: SnapshotState } = {}; const captureProof: RequestCaptureProof = {}; const runtime = createSelectorCaptureRuntime({ + ref: sessionStore.lookup(params.sessionName), device: session.device, session, sessionStore, @@ -318,6 +320,7 @@ function makeCaptureRuntime(sessionName: string) { const session = makeIosSession(sessionName); sessionStore.set(sessionName, session); const runtime = createSelectorCaptureRuntime({ + ref: sessionStore.lookup(sessionName), device: session.device, session, sessionStore, @@ -333,3 +336,44 @@ function makeCaptureRuntime(sessionName: string) { }); return { runtime, sessionName, sessionStore }; } + +test('a held selector capture updates the matching rebuilt record without restoring its old fields', async () => { + const sessionStore = makeSessionStore(); + const address = 'cwd:selector-capture:default'; + const ref = sessionStore.publish(address, makeIosSession('default')); + let release!: () => void; + const held = new Promise((resolve) => { + release = resolve; + }); + boundCapture.mockImplementationOnce(async () => { + await held; + return { + backend: 'xctest', + producer: 'apple-runner', + nodes: [{ index: 0, type: 'Button', label: 'Late capture' }], + }; + }); + const runtime = createSelectorCaptureRuntime({ + ref, + device: ref.session.device, + session: ref.session, + sessionStore, + sessionName: address, + capture: boundCapture, + req: { token: 't', session: address, command: 'get', positionals: [], flags: {} }, + }); + const running = runtime.capture({ flags: {} }); + try { + await vi.waitFor(() => expect(boundCapture).toHaveBeenCalledOnce()); + sessionStore.update(ref, { appName: 'Intervening rebuild' }); + release(); + await running; + expect(sessionStore.requireCurrent(ref).appName).toBe('Intervening rebuild'); + expect(sessionStore.requireCurrent(ref).snapshot?.nodes[0]?.label).toBe('Late capture'); + expect(ref.session.snapshot).toBeUndefined(); + expect(sessionStore.get('default')).toBeUndefined(); + } finally { + release(); + await running.catch(() => {}); + } +}); diff --git a/src/daemon/__tests__/session-capture-binding.test.ts b/src/daemon/__tests__/session-capture-binding.test.ts index 2ad3ae5f78..9b6c9f586b 100644 --- a/src/daemon/__tests__/session-capture-binding.test.ts +++ b/src/daemon/__tests__/session-capture-binding.test.ts @@ -84,9 +84,10 @@ test('clearing an older handle or fence leaves a replacement capture intact', () sessionStore: store, finish: async () => ({ status: 'cleanup-pending', reason: 'cleanup-unconfirmed' }), }).screenRecording!; - store.update(ref, { screenRecording: replacement }); + const differentHandle = { ...active, handle: replacement.handle }; + store.update(ref, { screenRecording: differentHandle }); expect(binding.clear(active)).toBe('resource-changed'); - expect(binding.read()).toBe(replacement); + expect(binding.read()).toBe(differentHandle); for (const fence of [ { ...active.envelope.fence, token: 'next' }, { ...active.envelope.fence, generation: active.envelope.fence.generation + 1 }, diff --git a/src/daemon/__tests__/session-snapshot.test.ts b/src/daemon/__tests__/session-snapshot.test.ts index b0b64ef0f2..c7b6fa2458 100644 --- a/src/daemon/__tests__/session-snapshot.test.ts +++ b/src/daemon/__tests__/session-snapshot.test.ts @@ -5,7 +5,7 @@ import { markSessionPartialRefsIssued, resolveRefStalenessWarning, setSessionSnapshot, - setSnapshotLineage, + setCommandSnapshot, STALE_SNAPSHOT_REFS_WARNING, } from '../session-snapshot.ts'; import { @@ -179,7 +179,8 @@ test('a ref pinned before a diff keeps resolving: the diff advances the counter, // `diff` replaces the stored tree, so lineage advances the counter — but it passes // `issuesRefsToClient: false`, so it never reactivates the frame. const afterDiff: SessionState = { ...session }; - setSnapshotLineage(afterDiff, { + setCommandSnapshot(afterDiff, { + snapshot: afterDiff.snapshot!, scopeSource: undefined, keptCurrentSnapshot: false, previousGeneration: session.snapshotGeneration, @@ -211,7 +212,8 @@ test('keeping the current snapshot leaves the counter alone', () => { setSessionSnapshot(session, makeSnapshot()); const before = session.snapshotGeneration; - setSnapshotLineage(session, { + setCommandSnapshot(session, { + snapshot: session.snapshot!, scopeSource: undefined, keptCurrentSnapshot: true, previousGeneration: before, diff --git a/src/daemon/__tests__/session-store-lifetime.test.ts b/src/daemon/__tests__/session-store-lifetime.test.ts index d0e6a84cf0..ef1315fa6d 100644 --- a/src/daemon/__tests__/session-store-lifetime.test.ts +++ b/src/daemon/__tests__/session-store-lifetime.test.ts @@ -1,8 +1,15 @@ import assert from 'node:assert/strict'; +import fs from 'node:fs'; import { test } from 'vitest'; import { AppError } from '@agent-device/kernel/errors'; -import { makeSession } from '../../__tests__/test-utils/session-factories.ts'; -import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; +import { + makeSession, + makeRepairCompleteSession, + makeRepairArmedSession, + authoringPublication, +} from '../../__tests__/test-utils/session-factories.ts'; +import { makeSessionStore, storeSessionForTest } from '../../__tests__/test-utils/store-factory.ts'; +import { resolveRepairTombstonePath } from '../../session-repair-tombstone.ts'; const ADDRESS = 'cwd:worktree:default'; @@ -121,3 +128,84 @@ test('a ref from another store has no authority over the same address', () => { assert.equal(target.retire(foreign), false); assert.equal(target.requireCurrent(local), foreign.session); }); + +test('script writes use the latest matching record and refuse a retired lifetime', () => { + const store = makeSessionStore(); + const ref = store.publish(ADDRESS, makeSession('default')); + store.update(ref, { + scriptPublication: authoringPublication('armed'), + actions: [{ ts: 1, command: 'click', positionals: ['id="late-action"'], flags: {} }], + }); + const result = store.writeSessionLog(ref); + assert.equal(result.written, true); + if (result.written) assert.match(fs.readFileSync(result.path, 'utf8'), /late-action/); + store.retire(ref); + const successor = store.publish(ADDRESS, makeRepairCompleteSession('default')); + assert.throws(() => store.writeSessionLog(ref), ended); + store.finalizeRepairTeardown(ref); + const state = store.requireCurrent(successor).scriptPublication; + assert.equal(state?.kind, 'repair'); + if (state?.kind === 'repair') assert.equal(state.status, 'complete'); + assert.equal(successor.session.actions.length, 0); +}); + +test('repair tombstones follow the scoped address and cannot be written by a retired ref', () => { + const store = makeSessionStore(); + const ref = store.publish(ADDRESS, makeRepairArmedSession('default')); + store.update(ref, { + scriptPublication: { + kind: 'repair', + status: 'armed', + boundary: 0, + target: { kind: 'default', force: false }, + sourcePath: '/latest.ad', + }, + }); + store.writeRepairTombstone(ref); + assert.equal(store.readRepairTombstone(ADDRESS)?.owner, ADDRESS); + assert.equal(store.readRepairTombstone(ADDRESS)?.sourcePath, '/latest.ad'); + assert.equal(store.readRepairTombstone('default'), undefined); + store.retire(ref); + store.clearRepairTombstone(ADDRESS); + const successor = store.publish(ADDRESS, makeRepairArmedSession('default')); + store.writeRepairTombstone(ref); + assert.equal(store.readRepairTombstone(ADDRESS), undefined); + assert.equal(store.requireCurrent(successor), successor.session); +}); + +test('test session publication uses its explicit scoped address', () => { + const store = makeSessionStore(); + const session = makeSession('default'); + const ref = store.publish(ADDRESS, session); + const stored = storeSessionForTest(store, session, ADDRESS); + assert.equal(stored.lifetime, ref.lifetime); + assert.equal(stored.session, session); + assert.equal(store.get('default'), undefined); + assert.equal(store.listRefs().length, 1); +}); + +test('colliding artifact directories cannot share or clear another address\u2019s repair tombstone', () => { + const store = makeSessionStore(); + const collision = 'cwd_worktree_default'; + const ref = store.publish(ADDRESS, makeRepairArmedSession('default')); + assert.equal(store.resolveSessionDir(ADDRESS), store.resolveSessionDir(collision)); + store.writeRepairTombstone(ref); + assert.equal(store.readRepairTombstone(ADDRESS)?.owner, ADDRESS); + assert.equal(store.readRepairTombstone(collision), undefined); + store.clearRepairTombstone(collision); + assert.equal(store.readRepairTombstone(ADDRESS)?.owner, ADDRESS); + store.clearRepairTombstone(ADDRESS); + assert.equal(store.readRepairTombstone(ADDRESS), undefined); +}); + +test('tombstone cleanup retains malformed evidence and removes an expired owned marker', () => { + const store = makeSessionStore(); + const tombstonePath = resolveRepairTombstonePath(store.resolveSessionDir(ADDRESS)); + fs.mkdirSync(store.resolveSessionDir(ADDRESS), { recursive: true }); + fs.writeFileSync(tombstonePath, '{'); + store.clearRepairTombstone(ADDRESS); + assert.equal(fs.readFileSync(tombstonePath, 'utf8'), '{'); + fs.writeFileSync(tombstonePath, JSON.stringify({ owner: ADDRESS, expiresAt: 0, reapedAt: 0 })); + store.clearRepairTombstone(ADDRESS); + assert.equal(fs.existsSync(tombstonePath), false); +}); diff --git a/src/daemon/__tests__/session-store.test.ts b/src/daemon/__tests__/session-store.test.ts index 5eddff721d..0e00cfef09 100644 --- a/src/daemon/__tests__/session-store.test.ts +++ b/src/daemon/__tests__/session-store.test.ts @@ -1,3 +1,4 @@ +import { storeSessionForTest } from '../../__tests__/test-utils/store-factory.ts'; import { test } from 'vitest'; import assert from 'node:assert/strict'; import fs from 'node:fs'; @@ -83,7 +84,7 @@ function recordClose(store: SessionStore, session: SessionState): void { } function writeScript({ root, store, session }: SessionStoreFixture): string { - store.writeSessionLog(session); + store.writeSessionLog(storeSessionForTest(store, session)); return readWrittenSessionScript(root); } @@ -134,7 +135,7 @@ test('saveScript flag enables .ad session log writing', () => { recordOpen(store, session); recordClose(store, session); - store.writeSessionLog(session); + store.writeSessionLog(storeSessionForTest(store, session)); assert.equal(listSessionScriptFiles(root).length, 1); }); @@ -397,7 +398,7 @@ test('saveScript path writes session log to custom location', async () => { recordOpen(store, session, { platform: 'ios', saveScript: customPath }); recordClose(store, session); - store.writeSessionLog(session); + store.writeSessionLog(storeSessionForTest(store, session)); await store.flushEvents(session.name); assert.equal(fs.existsSync(customPath), true); assert.equal(fs.existsSync(store.resolveEventLogPath(session.name)), true); @@ -723,7 +724,7 @@ test('writeRepairTombstone/readRepairTombstone round-trips owner + source path', sourcePath: '/flows/login.ad', }); - store.writeRepairTombstone(session); + store.writeRepairTombstone(storeSessionForTest(store, session)); const tombstone = store.readRepairTombstone('default'); assert.ok(tombstone); assert.equal(tombstone?.owner, 'default'); @@ -736,7 +737,7 @@ test('readRepairTombstone returns undefined once the tombstone has expired', () const store = new SessionStore(path.join(root, 'sessions')); const session = makeSession('default'); // TTL 0 => expiresAt <= now => already stale. - store.writeRepairTombstone(session, 0); + store.writeRepairTombstone(storeSessionForTest(store, session), 0); assert.equal(store.readRepairTombstone('default'), undefined); }); @@ -744,7 +745,7 @@ test('clearRepairTombstone removes a tombstone (a fresh replay --save-script cle const root = mkdtempForTestSync('agent-device-tombstone-clear-'); const store = new SessionStore(path.join(root, 'sessions')); const session = makeSession('default'); - store.writeRepairTombstone(session); + store.writeRepairTombstone(storeSessionForTest(store, session)); assert.ok(store.readRepairTombstone('default')); store.clearRepairTombstone('default'); @@ -778,7 +779,7 @@ test('BLOCKER 2: finalizeRepairTeardown of a COMPLETE transaction whose commit F session.actions = [{ ts: 1, command: 'open', positionals: ['Demo'], flags: {} }]; // Idle-reap/shutdown teardown (never routes through close's handler). - store.finalizeRepairTeardown(session); + store.finalizeRepairTeardown(storeSessionForTest(store, session)); // The prior complete artifact is untouched — teardown's failed commit // never clobbers it. @@ -819,7 +820,7 @@ test('BLOCKER 3: finalizeRepairTeardown auto-commit records a terminal close, pr // The source plan's terminal `close` was already skipped-while-armed // (Fix 3) — `session.actions` never gained one. Idle-reap/shutdown teardown // must synthesize it itself before auto-committing. - store.finalizeRepairTeardown(session); + store.finalizeRepairTeardown(storeSessionForTest(store, session)); assert.equal( session.scriptPublication?.kind === 'repair' ? session.scriptPublication.status : undefined, diff --git a/src/daemon/__tests__/snapshot-command-runtime.test.ts b/src/daemon/__tests__/snapshot-command-runtime.test.ts index b43dafba60..2a17a1d6ea 100644 --- a/src/daemon/__tests__/snapshot-command-runtime.test.ts +++ b/src/daemon/__tests__/snapshot-command-runtime.test.ts @@ -85,3 +85,60 @@ for (const command of ['snapshot', 'diff snapshot'] as const) { } }); } + +for (const change of ['rebuild', 'replace'] as const) { + test(`snapshot completion respects a scoped lifetime after ${change}`, async () => { + const sessionStore = makeSessionStore(); + const address = 'cwd:snapshot-completion:default'; + const ref = sessionStore.publish( + address, + makeAndroidSession('default', { trace: { outPath: 'prior-trace', startedAt: 0 } }), + ); + const entered = deferred(); + const release = deferred(); + captureMock.mockImplementation(async () => { + entered.resolve(); + await release.promise; + return { + backend: 'uiautomator', + nodes: [{ index: 0, type: 'Button', label: 'Captured' }], + }; + }); + const running = dispatchSnapshotViaRuntime({ + req: { command: 'snapshot', positionals: [], token: 't', session: address }, + sessionName: address, + logPath: '/dev/null', + sessionStore, + ...snapshotRuntimeFixture(), + }); + const result = running.then( + (response) => ({ response }), + (error: unknown) => ({ error }), + ); + try { + await entered.promise; + if (change === 'rebuild') { + const trace = { outPath: 'intervening-trace', startedAt: 1 }; + sessionStore.update(ref, { trace }); + release.resolve(); + expect(await result).toMatchObject({ response: { ok: true } }); + const current = sessionStore.requireCurrent(ref); + expect(current.trace).toBe(trace); + expect(current.snapshot?.nodes[0]?.label).toBe('Captured'); + } else { + sessionStore.retire(ref); + const successor = sessionStore.publish(address, makeAndroidSession('default')); + release.resolve(); + expect(await result).toMatchObject({ + error: { details: { reason: 'session_lifetime_ended' } }, + }); + expect(sessionStore.requireCurrent(successor)).toBe(successor.session); + expect(successor.session.snapshot).toBeUndefined(); + } + expect(sessionStore.lookup('default')).toBeUndefined(); + } finally { + release.resolve(); + await result; + } + }); +} diff --git a/src/daemon/generic-settle.ts b/src/daemon/generic-settle.ts index e18ca7f965..70e1ead5d6 100644 --- a/src/daemon/generic-settle.ts +++ b/src/daemon/generic-settle.ts @@ -15,7 +15,7 @@ import type { BoundContextFromFlags } from './context.ts'; import { issueSettleRefs } from './session-snapshot.ts'; import type { SessionStore } from './session-store.ts'; import type { DaemonRequest, DaemonResponse } from './daemon-request.ts'; -import type { SessionState } from './session-state.ts'; +import type { SessionRef, SessionState } from './session-state.ts'; /** * `--settle` on the generic daemon route (#1638): `scroll` and `back` change @@ -55,6 +55,7 @@ export type GenericSettleObserver = () => Promise export type GenericSettlePlan = { response: DaemonResponse } | { observe?: GenericSettleObserver }; type GenericSettleContext = { + sessionRef: SessionRef | undefined; req: DaemonRequest; session: SessionState; sessionName: string; @@ -109,7 +110,7 @@ async function observeSettled( session: context.sessionName, requestId: context.req.meta?.requestId, }); - const refsGeneration = issueSettleRefs(context.session, observation); + const refsGeneration = issueSettleRefs(context.sessionRef, context.sessionStore, observation); return refsGeneration === undefined ? observation : { ...observation, refsGeneration }; } @@ -120,6 +121,7 @@ function createGenericSettleRuntime( return createInteractionRuntime({ req: context.req, sessionName: context.sessionName, + sessionRef: context.sessionRef, logPath: context.logPath, sessionStore: context.sessionStore, contextFromFlags: context.contextFromFlags, diff --git a/src/daemon/handlers/react-native.ts b/src/daemon/handlers/react-native.ts index fadabcab01..028fec887b 100644 --- a/src/daemon/handlers/react-native.ts +++ b/src/daemon/handlers/react-native.ts @@ -17,6 +17,7 @@ import { isSparseSnapshotQualityVerdict } from '@agent-device/capture-kit/snapsh import type { DaemonResponse } from '../daemon-request.ts'; import type { SessionState } from '../session-state.ts'; import { + bindInteractionSession, captureSnapshotForSession, finalizeTouchInteraction, type InteractionRouteInput, @@ -28,6 +29,7 @@ import { errorResponse, noActiveSessionError } from '@agent-device/kernel/contra export async function handleReactNativeCommands( params: InteractionRouteInput, ): Promise { + params = bindInteractionSession(params); const { req, sessionName, sessionStore } = params; if (req.command !== PUBLIC_COMMANDS.reactNative) return null; const parsed = parseReactNativeArgs(req.positionals ?? []); @@ -62,7 +64,7 @@ export async function handleReactNativeCommands( try { const snapshot = await captureSnapshotForSession( - session, + params.sessionRef!, req.flags, sessionStore, params.contextFromFlags, @@ -161,7 +163,7 @@ async function executeReactNativeOverlayDismiss( expireRefFrame(session); const data = await tapPoint(target.point); const actionFinishedAt = Date.now(); - const verification = await verifyReactNativeOverlayDismissal(params, session); + const verification = await verifyReactNativeOverlayDismissal(params); const responseData = stripUndefined({ ...readSnapshotNodesReferenceFrame(snapshot.nodes), ...data, @@ -192,17 +194,14 @@ async function executeReactNativeOverlayDismiss( }); } -async function verifyReactNativeOverlayDismissal( - params: InteractionRouteInput, - session: SessionState, -): Promise<{ +async function verifyReactNativeOverlayDismissal(params: InteractionRouteInput): Promise<{ verified: boolean; verificationWarning?: string; nextCommand?: string; }> { const { req, sessionStore } = params; const verificationSnapshot = await captureSnapshotForSession( - session, + params.sessionRef!, req.flags, sessionStore, params.contextFromFlags, diff --git a/src/daemon/handlers/session-script-publication.ts b/src/daemon/handlers/session-script-publication.ts index 3bc31f44c3..dc6826dac1 100644 --- a/src/daemon/handlers/session-script-publication.ts +++ b/src/daemon/handlers/session-script-publication.ts @@ -25,14 +25,15 @@ export function handleSessionScriptPublication(params: { ); } - const session = sessionStore.get(sessionName); - if (!session) { + const ref = sessionStore.lookup(sessionName); + if (!ref) { return failure( new AppError('SESSION_NOT_FOUND', `No active session "${sessionName}".`, { hint: 'Start a fresh journey with open --save-script[=], then retry.', }), ); } + const session = sessionStore.requireCurrent(ref); const eligibilityError = validatePublicationEligibility(session); if (eligibilityError) return failure(eligibilityError); @@ -42,7 +43,7 @@ export function handleSessionScriptPublication(params: { } retargetActivePublication(session, { explicitPath, liveForce: req.flags?.force }); - const result = sessionStore.writeSessionLog(session, { + const result = sessionStore.writeSessionLog(ref, { force: effectiveWriteForce(session, req.flags?.force), publication: 'active', }); diff --git a/src/daemon/interaction/index.ts b/src/daemon/interaction/index.ts index 219b6d1082..221f606136 100644 --- a/src/daemon/interaction/index.ts +++ b/src/daemon/interaction/index.ts @@ -1,3 +1,4 @@ +import { bindInteractionSession } from './internal/interaction-session.ts'; import type { Rect } from '@agent-device/kernel/snapshot'; import { buildRuntimeCaptureInput } from '../snapshot-runtime-capture-input.ts'; import { setSessionSnapshot } from '../session-snapshot.ts'; @@ -15,16 +16,18 @@ export type { FindRouteInput, InteractionRouteInput } from './internal/types.ts' export { refMutationAdmissionResponse } from './internal/interaction-ref-policy.ts'; export { finalizeTouchInteraction } from './internal/interaction-runtime.ts'; +export { bindInteractionSession }; export { readSettleRequest, settleFlagGuardResponse }; export const captureSnapshotForSession: CaptureSnapshotForSession = async ( - session, + ref, flags, sessionStore, contextFromFlags, options, ) => { + const session = sessionStore.requireCurrent(ref); return await captureInteractionSnapshot({ session, flags, @@ -59,12 +62,7 @@ export const captureSnapshotForSession: CaptureSnapshotForSession = async ( return snapshot; }, publishSnapshot: (snapshot) => { - setSessionSnapshot(session, snapshot); - // The store owns the key a session answers to, and for an implicitly scoped session that is - // `cwd::` while `session.name` is only `default`. Storing by the name - // published a second address for the same session, which an implicit request can then read - // as two sessions in one workspace. - sessionStore.set(sessionStore.resolveStoredSessionName(session), session); + setSessionSnapshot(sessionStore.requireCurrent(ref), snapshot); }, }); }; @@ -77,7 +75,7 @@ export function createInteractionRuntime( }, ) { return createInteractionRuntimeForRoute({ - ...params, + ...bindInteractionSession(params), captureSnapshotForSession: params.captureSnapshotForSession ?? captureSnapshotForSession, }); } @@ -87,9 +85,10 @@ export async function handleFindCommands(params: FindRouteInput) { } export async function handleInteractionCommands(params: InteractionRouteInput) { + const bound = bindInteractionSession(params); const module = await import('./internal/interaction.ts'); return await module.handleInteractionCommands({ - ...params, + ...bound, captureSnapshotForSession: params.captureSnapshotForSession ?? captureSnapshotForSession, }); } diff --git a/src/daemon/interaction/internal/__tests__/interaction-gesture-drag.test.ts b/src/daemon/interaction/internal/__tests__/interaction-gesture-drag.test.ts index 50eed0ed2b..593cffce83 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-gesture-drag.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-gesture-drag.test.ts @@ -11,9 +11,9 @@ import { handleInteractionCommands } from '../../index.ts'; import { gestureRuntimeBindingsFixture } from './gesture-runtime-bindings.fixtures.ts'; const contextFromFlags = () => ({}); -const captureSnapshotForSession = async ( - session: import('../../../session-state.ts').SessionState, -) => session.snapshot!; +const captureSnapshotForSession = async ({ + session, +}: import('../../../session-state.ts').SessionRef) => session.snapshot!; let gestures = gestureRuntimeBindingsFixture(); beforeEach(() => { diff --git a/src/daemon/interaction/internal/__tests__/interaction-ios-tap-outcome.test.ts b/src/daemon/interaction/internal/__tests__/interaction-ios-tap-outcome.test.ts index d357fb77db..d0163f192d 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-ios-tap-outcome.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-ios-tap-outcome.test.ts @@ -311,7 +311,7 @@ test('a producer or generation switch cannot corroborate a failed tap', async () command: 'click', requestId: undefined, flags: {}, - session, + ref: sessionStore.lookup(sessionName)!, sessionStore, contextFromFlags, captureSnapshotForSession: async () => after, @@ -343,7 +343,7 @@ test('a capture of a system surface cannot corroborate a tap taken against the a command: 'click', requestId: undefined, flags: {}, - session, + ref: sessionStore.lookup(sessionName)!, sessionStore, contextFromFlags, captureSnapshotForSession: async () => after, diff --git a/src/daemon/interaction/internal/__tests__/interaction-settle.test.ts b/src/daemon/interaction/internal/__tests__/interaction-settle.test.ts index 66acb3966c..48cc20728e 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-settle.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-settle.test.ts @@ -3,10 +3,16 @@ import { legacyDispatchCapture } from '../../../__tests__/legacy-snapshot-captur import { test, expect, vi, beforeEach } from 'vitest'; import { createInteractionRuntime, handleInteractionCommands } from '../../index.ts'; import type { SessionStore } from '../../../session-store.ts'; -import type { SessionState } from '../../../session-state.ts'; +import type { SessionRef, SessionState } from '../../../session-state.ts'; import { buildSnapshotState } from '@agent-device/capture-kit/snapshot-state'; import { setSessionSnapshot } from '../../../session-snapshot.ts'; -import { activateCompleteRefFrame, expireRefFrame, refFrameState } from '../../../ref-frame.ts'; +import { + activateCompleteRefFrame, + expireRefFrame, + refFrameState, + refFrameTree, +} from '../../../ref-frame.ts'; +import { captureSnapshotWithInteractor } from '../../../snapshot-interactor-capture.ts'; import { makeSessionStore } from '../../../../__tests__/test-utils/store-factory.ts'; import { makeIosSession } from '../../../../__tests__/test-utils/session-factories.ts'; import { @@ -28,6 +34,10 @@ import { // beyond a few poll ticks. const mockCaptureSnapshotForSession = vi.hoisted(() => vi.fn()); +vi.mock('../../../snapshot-interactor-capture.ts', () => ({ + captureSnapshotWithInteractor: vi.fn(), +})); +const nativeCapture = vi.mocked(captureSnapshotWithInteractor); const BEFORE_NODES = [ { index: 0, type: 'Application', rect: { x: 0, y: 0, width: 390, height: 844 } }, @@ -54,7 +64,7 @@ const AFTER_NODES = [ ]; async function emulateCaptureSnapshotForSession( - session: SessionState, + ref: SessionRef, flags: CommandFlags | undefined, sessionStore: SessionStore, contextFromFlags: ( @@ -64,6 +74,7 @@ async function emulateCaptureSnapshotForSession( ) => Record, options: { interactiveOnly: boolean }, ) { + const session = sessionStore.requireCurrent(ref); const effectiveFlags = { ...(flags ?? {}), snapshotInteractiveOnly: options.interactiveOnly }; const snapshotData = (await legacyDispatchCapture( session.device, @@ -73,8 +84,7 @@ async function emulateCaptureSnapshotForSession( contextFromFlags(effectiveFlags, session.appBundleId, session.trace?.outPath), )) as Parameters[0]; const snapshot = buildSnapshotState(snapshotData ?? {}, effectiveFlags); - setSessionSnapshot(session, snapshot); - sessionStore.set(session.name, session); + setSessionSnapshot(sessionStore.requireCurrent(ref), snapshot); return snapshot; } @@ -158,6 +168,7 @@ beforeEach(() => { }); mockCaptureSnapshotForSession.mockReset(); mockCaptureSnapshotForSession.mockImplementation(emulateCaptureSnapshotForSession); + nativeCapture.mockReset(); }); const SETTLE_FLAGS = { settle: true, settleQuietMs: 25, timeoutMs: 2_000 }; @@ -237,6 +248,65 @@ test('press --settle responds with the settled diff, refsGeneration, and activat expect(session.snapshot?.nodes.some((node) => node.label === 'Welcome!')).toBe(true); }); +test('held touch settle publishes refs into the current record of its scoped lifetime', async () => { + const sessionStore = makeSessionStore(); + const address = 'cwd:touch-settle:default'; + const seeded = seedSession(address, sessionStore); + seeded.name = 'default'; + const ref = sessionStore.lookup(address)!; + let enter!: () => void; + let release!: () => void; + const entered = new Promise((resolve) => { + enter = resolve; + }); + const held = new Promise((resolve) => { + release = resolve; + }); + let captures = 0; + nativeCapture.mockImplementation(async () => { + captures += 1; + if (captures === 2) { + enter(); + await held; + } + return { + nodes: captures === 1 ? BEFORE_NODES : AFTER_NODES, + backend: 'xctest', + producer: 'apple-runner', + }; + }); + const running = handleInteractionCommands({ + req: { + token: 't', + session: address, + command: 'press', + positionals: ['label=Continue'], + flags: { ...SETTLE_FLAGS }, + }, + sessionName: address, + sessionStore, + contextFromFlags, + ...getRuntimeBindings(), + }); + try { + await entered; + expect(refFrameState(ref.session)).toBe('expired'); + sessionStore.update(ref, { trace: { outPath: 'rebuilt-trace', startedAt: 1 } }); + release(); + const settle = expectOkData(await running).settle as SettlePayload; + const current = sessionStore.requireCurrent(ref); + expect(current.snapshot?.nodes.some((node) => node.label === 'Welcome!')).toBe(true); + expect(refFrameState(current)).toBe('active'); + expect(refFrameTree(current)).toBe(current.snapshot); + expect(settle.refsGeneration).toBe(current.snapshotGeneration); + expect(refFrameState(ref.session)).toBe('expired'); + expect(sessionStore.lookup('default')).toBeUndefined(); + } finally { + release(); + await running.catch(() => {}); + } +}); + const MODAL_BEFORE_NODES = [ { index: 0, type: 'Application', rect: { x: 0, y: 0, width: 390, height: 844 } }, { @@ -391,7 +461,7 @@ test('a stalled settle capture receives its deadline signal and leaves the inter let observedAbort = false; mockCaptureSnapshotForSession.mockImplementation( async ( - _session: SessionState, + _session: SessionRef, _flags: CommandFlags | undefined, _sessionStore: SessionStore, _contextFromFlags: typeof contextFromFlags, diff --git a/src/daemon/interaction/internal/__tests__/interaction-snapshot-scope.test.ts b/src/daemon/interaction/internal/__tests__/interaction-snapshot-scope.test.ts index f05fba4b4e..80c8203d44 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-snapshot-scope.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-snapshot-scope.test.ts @@ -13,11 +13,15 @@ import { captureSnapshotForSession } from '../../index.ts'; // capture that dropped it here would return the unscoped tree with nothing left to notice (#1832 // C2, adversarial review of PR #1846). -const captured = vi.hoisted(() => ({ options: [] as SnapshotOptions[] })); +const captured = vi.hoisted(() => ({ + options: [] as SnapshotOptions[], + held: undefined as Promise | undefined, +})); vi.mock('../../../snapshot-interactor-capture.ts', () => ({ captureSnapshotWithInteractor: vi.fn(async ({ options }: { options: SnapshotOptions }) => { captured.options.push(options); + await captured.held; const nodes = [ { index: 0, depth: 0, type: 'android.widget.FrameLayout', label: 'Root' }, { @@ -48,15 +52,16 @@ vi.mock('../../../snapshot-interactor-capture.ts', () => ({ afterEach(() => { captured.options.length = 0; + captured.held = undefined; }); test('interaction captures hand flags.snapshotScope to the Android platform and keep its scoped tree', async () => { const sessionStore = makeSessionStore('agent-device-interaction-scope-'); const session = makeAndroidSession('scope'); - sessionStore.set(session.name, session); + const ref = sessionStore.publish(session.name, session); const snapshot = await captureSnapshotForSession( - session, + ref, { snapshotScope: 'panel' }, sessionStore, (flags: CommandFlags | undefined, appBundleId?: string, traceLogPath?: string) => @@ -70,3 +75,48 @@ test('interaction captures hand flags.snapshotScope to the Android platform and 'Save', ]); }); + +for (const change of ['rebuild', 'replace'] as const) { + test(`a held interaction capture respects its scoped lifetime after ${change}`, async () => { + const sessionStore = makeSessionStore(); + const address = 'cwd:interaction-capture:default'; + const ref = sessionStore.publish(address, makeAndroidSession('default')); + let release!: () => void; + captured.held = new Promise((resolve) => { + release = resolve; + }); + const running = captureSnapshotForSession(ref, {}, sessionStore, () => ({}), { + interactiveOnly: true, + }); + const result = running.then( + (snapshot) => ({ snapshot }), + (error: unknown) => ({ error }), + ); + try { + await vi.waitFor(() => expect(captured.options).toHaveLength(1)); + if (change === 'rebuild') { + sessionStore.update(ref, { appName: 'Intervening rebuild' }); + release(); + expect(await result).toHaveProperty('snapshot'); + expect(sessionStore.requireCurrent(ref).appName).toBe('Intervening rebuild'); + expect(sessionStore.requireCurrent(ref).snapshot?.nodes).toHaveLength(4); + } else { + sessionStore.retire(ref); + const successor = sessionStore.publish( + address, + makeAndroidSession('default', { appName: 'Successor' }), + ); + release(); + expect(await result).toMatchObject({ + error: { details: { reason: 'session_lifetime_ended' } }, + }); + expect(sessionStore.requireCurrent(successor)).toBe(successor.session); + expect(successor.session.snapshot).toBeUndefined(); + } + expect(sessionStore.get('default')).toBeUndefined(); + } finally { + release(); + await result; + } + }); +} diff --git a/src/daemon/interaction/internal/find-target-capture.ts b/src/daemon/interaction/internal/find-target-capture.ts index e0b906f539..9160a93e85 100644 --- a/src/daemon/interaction/internal/find-target-capture.ts +++ b/src/daemon/interaction/internal/find-target-capture.ts @@ -5,7 +5,7 @@ import type { CaptureProvenance, RequestCaptureProof } from '../../capture-discl import { createSelectorCaptureRuntime } from '../../selector-capture-runtime.ts'; import { SessionStore } from '../../session-store.ts'; import type { DaemonRequest, DaemonResponse } from '../../daemon-request.ts'; -import type { SessionState } from '../../session-state.ts'; +import type { SessionRef, SessionState } from '../../session-state.ts'; import { errorResponse } from '@agent-device/kernel/contracts'; /** The tree a mutating find resolves its target against, plus what the capture disclosed. */ @@ -19,6 +19,7 @@ export type FindTargetTree = CaptureProvenance & */ export function createFindTargetCapture( params: Readonly<{ + ref: SessionRef; device: SessionState['device']; session: SessionState; req: DaemonRequest; @@ -37,6 +38,7 @@ export function createFindTargetCapture( ): () => Promise { const { device, session, req, logPath, locator, query, sessionStore, sessionName } = params; const captureRuntime = createSelectorCaptureRuntime({ + ref: params.ref, device, session, sessionStore, diff --git a/src/daemon/interaction/internal/find.ts b/src/daemon/interaction/internal/find.ts index 4b25bdfc64..b127ad8e70 100644 --- a/src/daemon/interaction/internal/find.ts +++ b/src/daemon/interaction/internal/find.ts @@ -14,7 +14,7 @@ import { } from '@agent-device/kernel/snapshot'; import { expireRefFrame } from '../../ref-frame.ts'; import type { DaemonInvokeFn, DaemonRequest, DaemonResponse } from '../../daemon-request.ts'; -import type { SessionState } from '../../session-state.ts'; +import type { SessionRef, SessionState } from '../../session-state.ts'; import { SessionStore } from '../../session-store.ts'; import { contextFromFlags } from '../../context.ts'; import { readCommandMessage, successText } from '@agent-device/kernel/success-text'; @@ -44,6 +44,7 @@ type FindContext = { logPath: string; sessionStore: SessionStore; invoke: DaemonInvokeFn; + sessionRef: SessionRef; session: SessionState; device: SessionState['device']; command: string; @@ -75,6 +76,7 @@ type ResolvedMatch = { export async function handleFindCommands(params: FindRouteInput): Promise { const { req, sessionName, logPath, sessionStore, invoke } = params; + const sessionRef = sessionStore.lookup(sessionName); const command = req.command; if (command !== 'find') return null; @@ -106,8 +108,8 @@ export async function handleFindCommands(params: FindRouteInput): Promise { + params = bindInteractionSession(params); return await dispatchGestureInteraction(params, 'gesture', async (session) => runGestureInteraction(params, session), ); @@ -156,6 +158,7 @@ function buildGestureOutcome( export async function dispatchSwipeViaRuntime( params: GestureHandlerParams, ): Promise { + params = bindInteractionSession(params); return await dispatchGestureInteraction(params, 'swipe', async (session) => { const input = readSwipeInput(params.req.input); // One bind for the whole series: `--count N` executes the bound operation N times under a diff --git a/src/daemon/interaction/internal/interaction-ios-tap-outcome.ts b/src/daemon/interaction/internal/interaction-ios-tap-outcome.ts index a9fc2df1b1..977114c8d6 100644 --- a/src/daemon/interaction/internal/interaction-ios-tap-outcome.ts +++ b/src/daemon/interaction/internal/interaction-ios-tap-outcome.ts @@ -11,7 +11,7 @@ import { getRequestSignal } from '@agent-device/host-kit/request'; import { isLocalIosRunnerSession } from '../../direct-ios-selector.ts'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import type { SessionStore } from '../../session-store.ts'; -import type { SessionState } from '../../session-state.ts'; +import type { SessionRef } from '../../session-state.ts'; import type { BoundContextFromFlags, CaptureSnapshotForSession } from './types.ts'; const XCTEST_RECORDED_FAILURE = 'XCTEST_RECORDED_FAILURE'; @@ -35,7 +35,7 @@ export type IosTapCorroborationParams = { command: string; requestId: string | undefined; flags: CommandFlags | undefined; - session: SessionState; + ref: SessionRef; sessionStore: SessionStore; contextFromFlags: BoundContextFromFlags; captureSnapshotForSession: CaptureSnapshotForSession; @@ -55,7 +55,7 @@ export async function corroborateIosTapFailure( params: IosTapCorroborationParams, ): Promise { if (!canCorroborateIosTapFailure(params)) return undefined; - const baseline = readCorroborationBaseline(params.session.snapshot); + const baseline = readCorroborationBaseline(params.ref.session.snapshot); if (!baseline) return undefined; const after = await captureCorroborationSnapshot( @@ -73,7 +73,7 @@ function canCorroborateIosTapFailure(params: IosTapCorroborationParams): boolean return ( isTapCommand(params.command) && asAppError(params.error).code === XCTEST_RECORDED_FAILURE && - isLocalIosRunnerSession(params.session, { skipPendingPostGestureStabilization: false }) + isLocalIosRunnerSession(params.ref.session, { skipPendingPostGestureStabilization: false }) ); } @@ -121,7 +121,7 @@ async function captureCorroborationSnapshot( try { const preferredBackend = preferredSnapshotBackendForVerdict(baselineVerdict); return await params.captureSnapshotForSession( - params.session, + params.ref, matchingCaptureFlags(params.flags, presentation), params.sessionStore, params.contextFromFlags, diff --git a/src/daemon/interaction/internal/interaction-runtime.ts b/src/daemon/interaction/internal/interaction-runtime.ts index 0ca084d4ad..cc146829b1 100644 --- a/src/daemon/interaction/internal/interaction-runtime.ts +++ b/src/daemon/interaction/internal/interaction-runtime.ts @@ -1,3 +1,4 @@ +import { bindInteractionSession } from './interaction-session.ts'; import { publicPlatformString } from '@agent-device/kernel/device'; import { AppError as KernelAppError } from '@agent-device/kernel/errors'; import type { @@ -41,8 +42,9 @@ export function createInteractionRuntimeForRoute( gestures?: BoundGestureExecutor; }, ) { - const session = params.sessionStore.get(params.sessionName); - if (!session) throw new KernelAppError('SESSION_NOT_FOUND', NO_ACTIVE_SESSION_MESSAGE); + const ref = bindInteractionSession(params).sessionRef; + if (!ref) throw new KernelAppError('SESSION_NOT_FOUND', NO_ACTIVE_SESSION_MESSAGE); + const session = params.sessionStore.requireCurrent(ref); return createInteractionAgentDevice({ requestId: params.req.meta?.requestId, flags: params.req.flags, @@ -50,7 +52,7 @@ export function createInteractionRuntimeForRoute( contextFromFlags: params.contextFromFlags, captureSnapshot: async (flags, options) => { const snapshot = await params.captureSnapshotForSession( - session, + ref, flags, params.sessionStore, params.contextFromFlags, @@ -60,18 +62,18 @@ export function createInteractionRuntimeForRoute( }, runtimeSessions: createDaemonRuntimeSessionStore({ sessionName: params.sessionName, - getSession: () => session, + sessionStore: params.sessionStore, + ref, recordOptions: { includeSnapshot: true, omitRefFrameSnapshot: params.req.internal?.findResolvedTarget !== undefined, }, - setRecord: (record) => { + setRecord: (record, current) => { if (!record.snapshot) return; - setSessionSnapshot(session, record.snapshot); - params.sessionStore.set(params.sessionName, session); + setSessionSnapshot(current!, record.snapshot); }, }), - expireRefFrame: () => expireRefFrame(session), + expireRefFrame: () => expireRefFrame(params.sessionStore.requireCurrent(ref)), confirmOffscreenTargetVisible: isLocalIosRunnerSession(session, { skipPendingPostGestureStabilization: false, }) diff --git a/src/daemon/interaction/internal/interaction-session.ts b/src/daemon/interaction/internal/interaction-session.ts new file mode 100644 index 0000000000..fa079cd281 --- /dev/null +++ b/src/daemon/interaction/internal/interaction-session.ts @@ -0,0 +1,12 @@ +import type { InteractionRouteInput } from './types.ts'; +import type { SessionRef } from '../../session-state.ts'; + +export function bindInteractionSession( + params: Params, +): Params & { sessionRef: SessionRef | undefined } { + return { + ...params, + sessionRef: + 'sessionRef' in params ? params.sessionRef : params.sessionStore.lookup(params.sessionName), + }; +} diff --git a/src/daemon/interaction/internal/interaction-touch-android-freshness.ts b/src/daemon/interaction/internal/interaction-touch-android-freshness.ts index 47d0c02b41..2704184b40 100644 --- a/src/daemon/interaction/internal/interaction-touch-android-freshness.ts +++ b/src/daemon/interaction/internal/interaction-touch-android-freshness.ts @@ -20,7 +20,7 @@ export async function refreshAndroidRefSnapshotIfFreshnessActive( session.snapshot?.comparisonSafe === true ? session.snapshot : undefined; try { await params.captureSnapshotForSession( - session, + params.sessionRef!, params.req.flags, params.sessionStore, params.contextFromFlags, diff --git a/src/daemon/interaction/internal/interaction-touch-direct-ios.ts b/src/daemon/interaction/internal/interaction-touch-direct-ios.ts index a6cce12d9f..46835feae8 100644 --- a/src/daemon/interaction/internal/interaction-touch-direct-ios.ts +++ b/src/daemon/interaction/internal/interaction-touch-direct-ios.ts @@ -129,7 +129,7 @@ async function buildDirectIosCorroboratedResponse(params: { command: handlerParams.req.command, requestId: handlerParams.req.meta?.requestId, flags: handlerParams.req.flags, - session, + ref: handlerParams.sessionRef!, sessionStore: handlerParams.sessionStore, contextFromFlags: handlerParams.contextFromFlags, captureSnapshotForSession: handlerParams.captureSnapshotForSession, diff --git a/src/daemon/interaction/internal/interaction-touch-fill.ts b/src/daemon/interaction/internal/interaction-touch-fill.ts index 16dd20e515..5d6523125a 100644 --- a/src/daemon/interaction/internal/interaction-touch-fill.ts +++ b/src/daemon/interaction/internal/interaction-touch-fill.ts @@ -1,9 +1,11 @@ +import { bindInteractionSession } from './interaction-session.ts'; import type { CommandFlags } from '@agent-device/contracts/command'; import type { FillCommandResult, InteractionTarget } from '@agent-device/contracts/interaction'; import { issueSettleRefs, resolveRefStalenessWarning } from '../../session-snapshot.ts'; import { readRefMutationFrame } from '../../ref-frame.ts'; import type { DaemonResponse } from '../../daemon-request.ts'; -import type { SessionState } from '../../session-state.ts'; +import type { SessionRef, SessionState } from '../../session-state.ts'; +import type { SessionStore } from '../../session-store.ts'; import { isSessionRecording } from '../../session-script-publication-capability.ts'; import { assertRecordedFillParameterization } from './interaction-recorded-input.ts'; import { readSettleRequest, settleFlagGuardResponse } from './interaction-flags.ts'; @@ -48,9 +50,10 @@ type AdmittedFill = { }; export async function dispatchFillViaRuntime(params: FillParams): Promise { + params = bindInteractionSession(params); const admission = await admitFill(params); if ('response' in admission) return admission.response; - const { session, parsedTarget, touchExecutor, staleRefsWarning } = admission.admitted; + const { parsedTarget, touchExecutor, staleRefsWarning } = admission.admitted; const { req, sessionName } = params; const replayTargetGuard = req.internal?.replayTargetGuard; @@ -77,7 +80,8 @@ export async function dispatchFillViaRuntime(params: FillParams): Promise buildFillResponsePayloads({ - session, + ref: params.sessionRef!, + sessionStore: params.sessionStore, result, text: parsedTarget.text, flags: req.flags, @@ -198,13 +202,15 @@ async function prepareFillRefTarget( } function buildFillResponsePayloads(params: { - session: SessionState; + ref: SessionRef; + sessionStore: SessionStore; result: FillCommandResult; text: string; flags: CommandFlags | undefined; staleRefsWarning: string | undefined; }): InteractionResponsePayloads { - const { session, result } = params; + const { result, ref, sessionStore } = params; + const session = sessionStore.requireCurrent(ref); const maestroFallback = maestroFallbackDisclosure( params.flags?.maestro?.allowNonHittableCoordinateFallback === true, result.backendResult, @@ -227,6 +233,6 @@ function buildFillResponsePayloads(params: { referenceFrame, extra: { text: params.text, ...maestroFallback.extra }, staleRefsWarning: params.staleRefsWarning, - settleRefsGeneration: issueSettleRefs(session, result.settle), + settleRefsGeneration: issueSettleRefs(ref, sessionStore, result.settle), }); } diff --git a/src/daemon/interaction/internal/interaction-touch-press.ts b/src/daemon/interaction/internal/interaction-touch-press.ts index cd6287edea..ac0b0e396b 100644 --- a/src/daemon/interaction/internal/interaction-touch-press.ts +++ b/src/daemon/interaction/internal/interaction-touch-press.ts @@ -1,3 +1,4 @@ +import { bindInteractionSession } from './interaction-session.ts'; import type { CommandFlags } from '@agent-device/contracts/command'; import type { InteractionTarget, @@ -37,6 +38,7 @@ export async function dispatchTargetedTouchViaRuntime( params: TargetedTouchParams, command: TargetedTouchCommand, ): Promise { + params = bindInteractionSession(params); const admission = await admitTargetedTouch(params, command); if ('response' in admission) return admission.response; const { admitted } = admission; diff --git a/src/daemon/interaction/internal/interaction-touch-reference-frame.ts b/src/daemon/interaction/internal/interaction-touch-reference-frame.ts index 0020fdb596..ecf4a54b22 100644 --- a/src/daemon/interaction/internal/interaction-touch-reference-frame.ts +++ b/src/daemon/interaction/internal/interaction-touch-reference-frame.ts @@ -4,20 +4,21 @@ import type { GestureReferenceFrame } from '@agent-device/contracts/scroll-gestu import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import type { SessionStore } from '../../session-store.ts'; import { getSnapshotReferenceFrame } from '@agent-device/capture-kit/touch-reference-frame'; -import type { SessionState } from '../../session-state.ts'; +import type { SessionRef } from '../../session-state.ts'; import type { BoundContextFromFlags, CaptureSnapshotForSession } from './types.ts'; import { isActiveProviderDevice } from '../../provider-device-admission.ts'; async function resolveDirectTouchReferenceFrame(params: { - session: SessionState; + ref: SessionRef; flags: CommandFlags | undefined; sessionStore: SessionStore; contextFromFlags: BoundContextFromFlags; captureSnapshotForSession: CaptureSnapshotForSession; observation?: AndroidObservationAdapter; }): Promise { - const { session, flags, sessionStore, contextFromFlags, captureSnapshotForSession, observation } = + const { ref, flags, sessionStore, contextFromFlags, captureSnapshotForSession, observation } = params; + const session = sessionStore.requireCurrent(ref); const recording = session.screenRecording?.handle; if (!recording) { return undefined; @@ -48,7 +49,7 @@ async function resolveDirectTouchReferenceFrame(params: { return snapshotFrame; } - const snapshot = await captureSnapshotForSession(session, flags, sessionStore, contextFromFlags, { + const snapshot = await captureSnapshotForSession(ref, flags, sessionStore, contextFromFlags, { interactiveOnly: true, }); const referenceFrame = getSnapshotReferenceFrame(snapshot); @@ -57,7 +58,7 @@ async function resolveDirectTouchReferenceFrame(params: { } export async function resolveDirectTouchReferenceFrameSafely(params: { - session: SessionState; + ref: SessionRef; flags: CommandFlags | undefined; sessionStore: SessionStore; contextFromFlags: BoundContextFromFlags; @@ -71,7 +72,7 @@ export async function resolveDirectTouchReferenceFrameSafely(params: { level: 'warn', phase: 'touch_reference_frame_resolve_failed', data: { - platform: params.session.device.platform, + platform: params.ref.session.device.platform, error: error instanceof Error ? error.message : String(error), }, }); diff --git a/src/daemon/interaction/internal/interaction-touch-response.ts b/src/daemon/interaction/internal/interaction-touch-response.ts index 116162a8cc..e7235e216e 100644 --- a/src/daemon/interaction/internal/interaction-touch-response.ts +++ b/src/daemon/interaction/internal/interaction-touch-response.ts @@ -294,24 +294,31 @@ export async function buildTargetedTouchResponsePayloads(params: { publicData?: Record; extra: Record; }): Promise { - const { params: handlerParams, session, result, publicData, extra } = params; + const { params: handlerParams, result, publicData, extra } = params; const referenceFrame = result.kind === 'point' ? await resolveDirectTouchReferenceFrameSafely({ - session, + ref: handlerParams.sessionRef!, flags: handlerParams.req.flags, sessionStore: handlerParams.sessionStore, contextFromFlags: handlerParams.contextFromFlags, captureSnapshotForSession: handlerParams.captureSnapshotForSession, observation: handlerParams.androidObservation, }) - : readSnapshotNodesReferenceFrame(session.snapshot?.nodes ?? []); + : readSnapshotNodesReferenceFrame( + handlerParams.sessionStore.requireCurrent(handlerParams.sessionRef!).snapshot?.nodes ?? + [], + ); return buildInteractionResponseData({ source: { kind: 'runtime', result, publicData }, referenceFrame, extra, staleRefsWarning: params.staleRefsWarning, - settleRefsGeneration: issueSettleRefs(session, result.settle), + settleRefsGeneration: issueSettleRefs( + handlerParams.sessionRef, + handlerParams.sessionStore, + result.settle, + ), }); } diff --git a/src/daemon/interaction/internal/interaction-touch-runtime.ts b/src/daemon/interaction/internal/interaction-touch-runtime.ts index 6bbc473006..aa7f4c400a 100644 --- a/src/daemon/interaction/internal/interaction-touch-runtime.ts +++ b/src/daemon/interaction/internal/interaction-touch-runtime.ts @@ -1,3 +1,4 @@ +import { bindInteractionSession } from './interaction-session.ts'; import type { FillCommandResult, InteractionTarget, @@ -67,8 +68,9 @@ export async function dispatchRuntimeInteraction< ): InteractionResponsePayloads | Promise; }, ): Promise { - const session = params.sessionStore.get(params.sessionName); - if (!session) return noActiveSessionError(); + params = bindInteractionSession(params); + if (!params.sessionRef) return noActiveSessionError(); + const session = params.sessionStore.requireCurrent(params.sessionRef); const runtime = createInteractionRuntimeForRoute({ ...params, touchExecutor: options.touchExecutor, @@ -159,7 +161,7 @@ async function buildRuntimeIosCorroboratedResponse(params: { command: params.handlerParams.req.command, requestId: params.handlerParams.req.meta?.requestId, flags: params.handlerParams.req.flags, - session: params.session, + ref: params.handlerParams.sessionRef!, sessionStore: params.handlerParams.sessionStore, contextFromFlags: params.handlerParams.contextFromFlags, captureSnapshotForSession: params.handlerParams.captureSnapshotForSession, diff --git a/src/daemon/interaction/internal/interaction.ts b/src/daemon/interaction/internal/interaction.ts index 1cc82a0a63..9906d71224 100644 --- a/src/daemon/interaction/internal/interaction.ts +++ b/src/daemon/interaction/internal/interaction.ts @@ -1,5 +1,5 @@ import type { DaemonResponse } from '../../daemon-request.ts'; -import type { SessionState } from '../../session-state.ts'; +import type { SessionRef, SessionState } from '../../session-state.ts'; import { type RequestCaptureProof, withCaptureDisclosures } from '../../capture-disclosure.ts'; import type { CaptureSnapshotForSession, InteractionRouteInput } from './types.ts'; import { dispatchFillViaRuntime } from './interaction-touch-fill.ts'; @@ -22,10 +22,14 @@ import { import { errorResponse, noActiveSessionError } from '@agent-device/kernel/contracts'; export async function handleInteractionCommands( - params: InteractionRouteInput & { captureSnapshotForSession: CaptureSnapshotForSession }, + params: InteractionRouteInput & { + sessionRef: SessionRef | undefined; + captureSnapshotForSession: CaptureSnapshotForSession; + }, ): Promise { const captureProof: RequestCaptureProof = {}; - const routed = { ...params, refSnapshotFlagGuardResponse, captureProof }; + const sessionRef = params.sessionRef; + const routed = { ...params, sessionRef, refSnapshotFlagGuardResponse, captureProof }; const response = await dispatchInteractionCommand(routed); return response ? withCaptureDisclosures({ response, consumedTree: captureProof, captureProof }) diff --git a/src/daemon/interaction/internal/types.ts b/src/daemon/interaction/internal/types.ts index 9236285c0d..9d50adc10b 100644 --- a/src/daemon/interaction/internal/types.ts +++ b/src/daemon/interaction/internal/types.ts @@ -8,7 +8,7 @@ import type { DeferredInteractionOutcomeMark } from '../../deferred-interaction- import type { RecordActionEntry } from '../../session-action-recorder.ts'; import type { BoundContextFromFlags } from '../../context.ts'; import type { DaemonInvokeFn, DaemonRequest, DaemonResponse } from '../../daemon-request.ts'; -import type { SessionState } from '../../session-state.ts'; +import type { SessionRef, SessionState } from '../../session-state.ts'; import type { BoundGestureExecutor } from '../../gesture-runtime.ts'; import type { BoundTouchExecutor } from '../../touch-runtime.ts'; import type { BoundSnapshotCapture } from '../../snapshot-runtime-binding.ts'; @@ -26,6 +26,7 @@ export type InteractionRouteInput = { sessionName: string; logPath?: string; sessionStore: SessionStore; + sessionRef?: SessionRef; captureSnapshotForSession?: CaptureSnapshotForSession; contextFromFlags: BoundContextFromFlags; inspectFacts?: InspectDeviceRuntimeFacts; @@ -49,7 +50,7 @@ export type FindRouteInput = { }; export type CaptureSnapshotForSession = ( - session: SessionState, + ref: SessionRef, flags: CommandFlags | undefined, sessionStore: SessionStore, contextFromFlags: BoundContextFromFlags, diff --git a/src/daemon/request-binding.ts b/src/daemon/request-binding.ts index 92cffd5cf0..1c030b53c1 100644 --- a/src/daemon/request-binding.ts +++ b/src/daemon/request-binding.ts @@ -64,10 +64,9 @@ export async function resolveRequestExecutionLockPlan(params: { export function prepareLockedRequestBinding(params: { req: DaemonRequest; - sessionName: string; - sessionStore: SessionStore; + existingRef: SessionRef | undefined; }): LockedRequestBinding { - const existingRef = params.sessionStore.lookup(params.sessionName); + const { existingRef } = params; return { req: applyRequestLockPolicy(params.req, existingRef), existingRef, diff --git a/src/daemon/request-execution-scope.ts b/src/daemon/request-execution-scope.ts index 4430b38d86..56423054cb 100644 --- a/src/daemon/request-execution-scope.ts +++ b/src/daemon/request-execution-scope.ts @@ -486,16 +486,15 @@ export async function prepareLockedRequestScope(params: { const { scope, sessionStore, trackDownloadableArtifact } = params; const logPath = scope.runnerLogPath; scope.throwIfCanceled(); - const seededSession = sessionStore.get(scope.sessionName); - if (seededSession) { - // Called under runLocked: refreshRecordingHealth may mutate session recording state. - await refreshRecordingHealth(seededSession); - sessionStore.set(scope.sessionName, seededSession); + const seededRef = sessionStore.lookup(scope.sessionName); + if (seededRef) { + await refreshRecordingHealth(sessionStore, seededRef); + scope.throwIfCanceled(); + sessionStore.requireCurrent(seededRef); } const binding = prepareLockedRequestBinding({ req: scope.req, - sessionName: scope.sessionName, - sessionStore, + existingRef: seededRef ? sessionStore.refresh(seededRef) : undefined, }); const lockedReq = binding.req; // `scope.sessionName` is the resolved store key, so `existingRef` carries the address every @@ -563,7 +562,10 @@ export async function prepareLockedRequestScope(params: { ({ ...contextFromFlags(flags, appBundleId, traceLogPath), // Handlers may update surface during the request, so read the current session state. - surface: sessionStore.get(scope.sessionName)?.surface, + surface: (seededRef + ? sessionStore.resolveCurrent(seededRef) + : sessionStore.get(scope.sessionName) + )?.surface, }) satisfies DaemonCommandContext, }, }; diff --git a/src/daemon/request-generic-dispatch.ts b/src/daemon/request-generic-dispatch.ts index 7f82f464d3..a3eaccfafe 100644 --- a/src/daemon/request-generic-dispatch.ts +++ b/src/daemon/request-generic-dispatch.ts @@ -4,7 +4,7 @@ import { commandSupportsSettleObservation } from '@agent-device/command-registry import type { SessionStore } from './session-store.ts'; import type { DaemonCommandContext } from './context.ts'; import type { DaemonRequest, DaemonResponse } from './daemon-request.ts'; -import type { SessionState } from './session-state.ts'; +import type { SessionRef, SessionState } from './session-state.ts'; import { ensureAndroidBlockingSystemDialogReady, recoverAndroidBlockingSystemDialog, @@ -75,6 +75,7 @@ export async function dispatchGenericCommand(params: { androidObservation?: AndroidObservationAdapter; }): Promise { const { req, session, logPath, sessionStore, contextFromFlags } = params; + const sessionRef = sessionStore.lookup(params.sessionName); const platformCommand = req.command; const commandReadiness = await ensureGenericCommandReady( @@ -86,6 +87,7 @@ export async function dispatchGenericCommand(params: { // #1638: freeze the settled diff's baseline before anything can mutate the // screen or the stored snapshot — including the Android dialog preflight. const settlePlan = await planGenericSettleObservation({ + sessionRef, req, session, sessionName: params.sessionName, @@ -234,6 +236,7 @@ function withReadinessWarnings( * without settle, and every non-settle generic leaf, load nothing. */ async function planGenericSettleObservation(params: { + sessionRef: SessionRef | undefined; req: DaemonRequest; session: SessionState; sessionName: string; diff --git a/src/daemon/request-recording-health.ts b/src/daemon/request-recording-health.ts index 399c005fbf..df3db9e55a 100644 --- a/src/daemon/request-recording-health.ts +++ b/src/daemon/request-recording-health.ts @@ -1,15 +1,19 @@ import { isIosFamily } from '@agent-device/kernel/device'; import { appleSessionObservation } from '../platform-runtime-apple-resources.ts'; -import type { SessionState } from './session-state.ts'; +import type { SessionRef, SessionState } from './session-state.ts'; +import type { SessionStore } from './session-store.ts'; -export async function refreshRecordingHealth(session: SessionState): Promise { +export async function refreshRecordingHealth(store: SessionStore, ref: SessionRef): Promise { + const session = store.requireCurrent(ref); if (!recordingRequiresRunnerHealth(session)) { return; } - const recording = session.screenRecording!.handle; - const state = recording.inspect(); + const resource = session.screenRecording!; + const recording = resource.handle; const snapshot = await appleSessionObservation.observeRunnerSession(session.device.id); + if (store.resolveCurrent(ref)?.screenRecording !== resource) return; + const state = recording.inspect(); if (!state.runnerSessionId) { if (snapshot?.alive) { recording.setRunnerSessionId(snapshot.sessionId); diff --git a/src/daemon/request-router.ts b/src/daemon/request-router.ts index b057bbca74..bf871c66e5 100644 --- a/src/daemon/request-router.ts +++ b/src/daemon/request-router.ts @@ -616,7 +616,9 @@ function repairExpiredIfTombstoned( sessionStore: SessionStore, ): DaemonError { if (error.code !== 'SESSION_NOT_FOUND') return error; - const tombstone = sessionStore.readRepairTombstone(req.session); + const address = resolveTombstoneSessionAddress(req, sessionStore); + if (address === undefined) return error; + const tombstone = sessionStore.readRepairTombstone(address); if (!tombstone) return error; const reRun = tombstone.sourcePath ? `re-run: replay ${tombstone.sourcePath} --save-script` @@ -659,22 +661,27 @@ function idleExpiredIfTombstoned( return normalizeError(sessionIdleExpiredError(tombstone.owner, tombstone)); } +function resolveTombstoneSessionAddress( + req: DaemonRequest, + sessionStore: SessionStore, +): string | undefined { + try { + return resolveEffectiveSessionName(scopeRequestSession(req), sessionStore, { + attachesToSession: false, + }); + } catch { + return undefined; + } +} + function readIdleExpiryTombstoneSafely( req: DaemonRequest, sessionStore: SessionStore, ): IdleSessionTombstone | undefined { + const address = resolveTombstoneSessionAddress(req, sessionStore); + if (address === undefined) return undefined; try { - // The address is resolved exactly as the request itself resolved it, tenant scope included: a - // tenant-isolated request keeps its sessions under `:`, so reading the raw name - // would miss this request's own marker and could instead surface another tenant's, reporting an - // unrelated device as the one this caller just lost. - const scopedReq = scopeRequestSession(req); - // `attachesToSession: false` is the inventory reading: it never refuses an ambiguous workspace, - // which is right here because this read is a question about an absent session, not a request to - // act through one. - return sessionStore.readIdleExpiryTombstone( - resolveEffectiveSessionName(scopedReq, sessionStore, { attachesToSession: false }), - ); + return sessionStore.readIdleExpiryTombstone(address); } catch { return undefined; } diff --git a/src/daemon/runtime-session.ts b/src/daemon/runtime-session.ts index 372c731ba3..f781c49fd3 100644 --- a/src/daemon/runtime-session.ts +++ b/src/daemon/runtime-session.ts @@ -1,7 +1,8 @@ import type { CommandSessionRecord, CommandSessionStore } from '../runtime-contract.ts'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import { refFrameTree } from './ref-frame.ts'; -import type { SessionState } from './session-state.ts'; +import type { SessionRef, SessionState } from './session-state.ts'; +import type { SessionStore } from './session-store.ts'; export type RuntimeSessionRecordOptions = { includeSnapshot?: boolean; @@ -44,16 +45,38 @@ function toRuntimeSessionRecord( }; } +export function createReadonlyRuntimeSessionStore( + sessionName: string, + session: SessionState, +): CommandSessionStore { + return { + get: (name) => + name === sessionName ? toRuntimeSessionRecord(session, sessionName) : undefined, + set: () => {}, + }; +} + export function createDaemonRuntimeSessionStore(params: { sessionName: string; - getSession: () => SessionState | undefined; + sessionStore: SessionStore; + ref: SessionRef | undefined; recordOptions?: RuntimeSessionRecordOptions; - setRecord: (record: CommandSessionRecord) => void; -}): CommandSessionStore { + setRecord: ( + record: CommandSessionRecord, + current: SessionState | undefined, + ref: SessionRef | undefined, + ) => SessionRef | void; +}): CommandSessionStore & { getRef(): SessionRef | undefined } { + let ref = params.ref; return { + getRef: () => (ref ? params.sessionStore.refresh(ref) : undefined), get: (name) => name === params.sessionName - ? toRuntimeSessionRecord(params.getSession(), params.sessionName, params.recordOptions) + ? toRuntimeSessionRecord( + ref ? params.sessionStore.resolveCurrent(ref) : undefined, + params.sessionName, + params.recordOptions, + ) : undefined, set: (record) => { if (record.name !== params.sessionName) { @@ -64,7 +87,9 @@ export function createDaemonRuntimeSessionStore(params: { }); return; } - params.setRecord(record); + const current = ref ? params.sessionStore.requireCurrent(ref) : undefined; + const published = params.setRecord(record, current, ref); + if (published) ref = published; }, }; } diff --git a/src/daemon/screenshot-runtime.ts b/src/daemon/screenshot-runtime.ts index f10f44ab42..b07e829607 100644 --- a/src/daemon/screenshot-runtime.ts +++ b/src/daemon/screenshot-runtime.ts @@ -29,7 +29,7 @@ import type { RecordedGenericRequest, ResolvedGenericExecution, } from './request-generic-dispatch.ts'; -import { createDaemonRuntimeSessionStore } from './runtime-session.ts'; +import { createReadonlyRuntimeSessionStore } from './runtime-session.ts'; import { assertScreenshotCropPolicy } from './screenshot-crop-target.ts'; import { buildScreenshotCropWarnings, cropScreenshotToSelector } from './screenshot-crop.ts'; import { annotateScreenshotWithRefs } from '@agent-device/capture-kit/screenshot-overlay'; @@ -121,12 +121,7 @@ export async function captureScreenshotArtifact( const runtime = createCommandSurfaceAgentDevice({ backend: createBoundScreenshotBackend(params), artifacts: createDaemonScreenshotArtifactAdapter(), - sessions: createDaemonRuntimeSessionStore({ - sessionName, - getSession: () => session, - recordOptions: { includeSnapshot: false }, - setRecord: () => {}, - }), + sessions: createReadonlyRuntimeSessionStore(sessionName, session), policy: localCommandPolicy(), }); diff --git a/src/daemon/selector-capture-runtime.ts b/src/daemon/selector-capture-runtime.ts index 18a2664970..2cfd92c54d 100644 --- a/src/daemon/selector-capture-runtime.ts +++ b/src/daemon/selector-capture-runtime.ts @@ -8,7 +8,7 @@ import { } from '@agent-device/kernel/snapshot'; import { isSparseSnapshotQualityVerdict } from '@agent-device/capture-kit/snapshot-quality-verdict'; import type { DaemonRequest } from './daemon-request.ts'; -import type { SessionState } from './session-state.ts'; +import type { SessionRef, SessionState } from './session-state.ts'; import { SessionStore } from './session-store.ts'; import { recordCaptureProof } from './capture-disclosure.ts'; import type { RequestCaptureProof } from './capture-disclosure.ts'; @@ -24,6 +24,7 @@ import { isLegacySparseIosInteractiveSnapshot } from '@agent-device/selectors/ab const SELECTOR_CAPTURE_CACHE_TTL_MS = 750; export type SelectorCaptureRuntimeParams = { + ref: SessionRef | undefined; device: SessionState['device']; session: SessionState | undefined; sessionStore: SessionStore; @@ -81,7 +82,7 @@ type SelectorCaptureRequest = { type SelectorCaptureResult = BackendSnapshotResult & { snapshot: SnapshotState }; export function createSelectorCaptureRuntime(params: SelectorCaptureRuntimeParams) { - const { session, sessionStore, sessionName } = params; + const { sessionStore, ref } = params; let lastSnapshotAt = 0; let lastSnapshotResult: SelectorCaptureResult | undefined; let lastSnapshotCacheKey: string | undefined; @@ -92,6 +93,7 @@ export function createSelectorCaptureRuntime(params: SelectorCaptureRuntimeParam }; const capture = async (request: SelectorCaptureRequest): Promise => { + const session = ref ? sessionStore.requireCurrent(ref) : undefined; const timestamp = Date.now(); const cacheKey = selectorCaptureCacheKey(request, params.req.flags?.out); const reusableLastSnapshot = readReusableLastSnapshot({ @@ -118,7 +120,7 @@ export function createSelectorCaptureRuntime(params: SelectorCaptureRuntimeParam const snapshot = await captureSelectorSnapshot({ params, request }); request.signal?.throwIfAborted(); const result = { snapshot }; - updateSessionSnapshot({ session, sessionStore, sessionName, snapshot }); + updateSessionSnapshot({ ref, sessionStore, snapshot }); lastSnapshotAt = timestamp; lastSnapshotResult = result; lastSnapshotCacheKey = cacheKey; @@ -324,13 +326,11 @@ function flagsForPresentation(request: SelectorCaptureRequest): CommandFlags | u } function updateSessionSnapshot(params: { - session: SessionState | undefined; + ref: SessionRef | undefined; sessionStore: SessionStore; - sessionName: string; snapshot: SnapshotState; }): void { - const { session, sessionStore, sessionName, snapshot } = params; - if (!session || isSparseSnapshotQualityVerdict(snapshot.snapshotQuality)) return; - setSessionSnapshot(session, snapshot); - sessionStore.set(sessionName, session); + const { ref, sessionStore, snapshot } = params; + if (!ref || isSparseSnapshotQualityVerdict(snapshot.snapshotQuality)) return; + setSessionSnapshot(sessionStore.requireCurrent(ref), snapshot); } diff --git a/src/daemon/selector-runtime-backend.test.ts b/src/daemon/selector-runtime-backend.test.ts index e17551b3f4..977f32607a 100644 --- a/src/daemon/selector-runtime-backend.test.ts +++ b/src/daemon/selector-runtime-backend.test.ts @@ -52,6 +52,7 @@ test('wait text passes its poll deadline signal to the Apple runner fast path', }), ); const runtime = createSelectorRuntimeForDevice({ + ref: sessionStore.lookup(sessionName), req: { token: 't', session: sessionName, @@ -112,6 +113,7 @@ test('daemon wait stable pins private-ax on emitted snapshot runner requests', a }, })); const runtime = createSelectorRuntimeForDevice({ + ref: sessionStore.lookup(sessionName), req: { token: 't', session: sessionName, diff --git a/src/daemon/selector-runtime-backend.ts b/src/daemon/selector-runtime-backend.ts index 4af5d63305..1e87c6c47f 100644 --- a/src/daemon/selector-runtime-backend.ts +++ b/src/daemon/selector-runtime-backend.ts @@ -15,7 +15,7 @@ import { setSessionSnapshot } from './session-snapshot.ts'; import { markSessionSnapshotOutdated } from './ref-frame.ts'; import { SessionStore } from './session-store.ts'; import type { DaemonRequest, DaemonResponse } from './daemon-request.ts'; -import type { SessionState } from './session-state.ts'; +import type { SessionRef, SessionState } from './session-state.ts'; import { createSelectorCaptureRuntime } from './selector-capture-runtime.ts'; import { buildRuntimeCaptureInput } from './snapshot-runtime-capture-input.ts'; import { @@ -51,6 +51,7 @@ export type SelectorRuntimeParams = { }; export type SelectorRuntimeDeviceParams = SelectorRuntimeParams & { + ref: SessionRef | undefined; session: SessionState | undefined; device: SessionState['device']; /** @@ -63,11 +64,20 @@ export type SelectorRuntimeDeviceParams = SelectorRuntimeParams & { }; type ResolvedSelectorRuntime = - | { ok: true; runtime: ReturnType } + | { + ok: true; + ref: SessionRef | undefined; + runtime: ReturnType; + } | { ok: false; response: DaemonResponse }; type ResolvedSelectorDevice = - | { ok: true; session: SessionState | undefined; device: SessionState['device'] } + | { + ok: true; + ref: SessionRef | undefined; + session: SessionState | undefined; + device: SessionState['device']; + } | { ok: false; response: DaemonResponse }; export function createSelectorRuntimeForDevice(params: SelectorRuntimeDeviceParams) { @@ -76,12 +86,12 @@ export function createSelectorRuntimeForDevice(params: SelectorRuntimeDevicePara ...createDaemonRuntimePolicy('selector commands', { plural: true }), sessions: createDaemonRuntimeSessionStore({ sessionName: params.sessionName, - getSession: () => params.session, + sessionStore: params.sessionStore, + ref: params.ref, recordOptions: { includeSnapshot: true }, - setRecord: (record) => { - if (!params.session || !record.snapshot) return; - setSessionSnapshot(params.session, record.snapshot); - params.sessionStore.set(params.sessionName, params.session); + setRecord: (record, current) => { + if (!current || !record.snapshot) return; + setSessionSnapshot(current, record.snapshot); }, }), signal: params.signal ?? getRequestSignal(params.req.meta?.requestId), @@ -95,10 +105,11 @@ async function resolveSelectorRuntimeDevice( ): Promise { params.consumedSnapshot ??= {}; params.captureProof ??= {}; - const session = params.sessionStore.get(params.sessionName); + const ref = params.sessionStore.lookup(params.sessionName); + const session = ref?.session; if (!session && requireSession) return { ok: false, response: noActiveSessionError() }; const device = session?.device ?? (await resolveTargetDevice(params.req.flags ?? {})); - return { ok: true, session, device }; + return { ok: true, ref, session, device }; } /** @@ -130,8 +141,10 @@ export async function createBoundSelectorRuntime( if (!bound.ok) return { ok: false, response: bound.response }; return { ok: true, + ref: resolved.ref, runtime: createSelectorRuntimeForDevice({ ...params, + ref: resolved.ref, session: resolved.session, device: resolved.device, bound: bound.operations, @@ -166,6 +179,7 @@ function createSelectorBackend(params: SelectorRuntimeDeviceParams): AgentDevice boundOperations === undefined ? undefined : createSelectorCaptureRuntime({ + ref: params.ref, device, session, sessionStore, diff --git a/src/daemon/selector-runtime.ts b/src/daemon/selector-runtime.ts index 13ea73daa6..4ad7685b04 100644 --- a/src/daemon/selector-runtime.ts +++ b/src/daemon/selector-runtime.ts @@ -85,13 +85,13 @@ export async function dispatchFindReadOnlyViaRuntime( .filter((ref): ref is string => typeof ref === 'string') : []; if (publishedRefs.length > 0) { - const session = params.sessionStore.get(params.sessionName); - if (session) { + const ref = resolvedRuntime.ref; + if (ref) { + const session = params.sessionStore.requireCurrent(ref); // ADR 0014: a read-only find publishes exactly the refs it returned — // one for single-match actions, every listed ref for `list` — so it // activates a PARTIAL frame authorizing exactly those ref bodies. markSessionPartialRefsIssued(session, publishedRefs); - params.sessionStore.set(params.sessionName, session); if (session.snapshotGeneration !== undefined) { return { ...data, refsGeneration: session.snapshotGeneration }; } diff --git a/src/daemon/server/daemon-runtime-snapshot-shutdown.test.ts b/src/daemon/server/daemon-runtime-snapshot-shutdown.test.ts new file mode 100644 index 0000000000..d5e0193395 --- /dev/null +++ b/src/daemon/server/daemon-runtime-snapshot-shutdown.test.ts @@ -0,0 +1,125 @@ +import { afterEach, expect, test, vi } from 'vitest'; +import { + clearRequestAbortRegistration, + markRequestCanceled, + registerRequestAbort, +} from '@agent-device/host-kit/request'; +import { ANDROID_EMULATOR } from '../../__tests__/test-utils/device-fixtures.ts'; +import { makeAndroidSession } from '../../__tests__/test-utils/session-factories.ts'; +import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; +import { legacyDispatchCapture } from '../__tests__/legacy-snapshot-capture-fixture.ts'; +import { snapshotRuntimeFixture } from '../__tests__/snapshot-runtime-fixture.ts'; +import { platformResourceCleanup } from '../../platform-runtime-resource-cleanup.ts'; +import { DAEMON_SESSION_TEARDOWN_TIMEOUT_MS } from '../session-teardown-budget.ts'; +import { dispatchSnapshotViaRuntime } from '../snapshot-runtime.ts'; +import { teardownDaemonSessionForShutdown } from './daemon-runtime.ts'; + +vi.mock('@agent-device/device-selection/dispatch-resolve', () => ({ + resolveTargetDevice: vi.fn(async () => ANDROID_EMULATOR), +})); +vi.mock('../device/device-ready.ts', () => ({ ensureDeviceReady: vi.fn(async () => {}) })); +vi.mock('../../platform-runtime-resource-cleanup.ts', () => ({ + platformResourceCleanup: { + stopSnapshotHelper: vi.fn(), + closeManagedBrowser: vi.fn(async () => {}), + cleanupSessionlessExecutionHost: vi.fn(async () => {}), + retainExecutionHostAfterClose: vi.fn(() => false), + }, +})); + +function deferred() { + let resolve!: () => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + legacyDispatchCapture.mockReset(); +}); + +for (const initialSession of ['published', 'draft'] as const) { + test(`bounded shutdown refuses a late ${initialSession} snapshot`, async () => { + const sessionStore = makeSessionStore(); + const address = 'cwd:shutdown-capture:default'; + const shutdownRef = sessionStore.publish( + initialSession === 'published' ? address : 'shutdown-owner', + makeAndroidSession('default'), + ); + const captureEntered = deferred(); + const releaseCapture = deferred(); + const cleanupEntered = deferred(); + const releaseCleanup = deferred(); + const registration = registerRequestAbort(`shutdown-snapshot-${initialSession}`)!; + legacyDispatchCapture.mockImplementation(async () => { + captureEntered.resolve(); + await releaseCapture.promise; + return { nodes: [], truncated: false, backend: 'uiautomator' }; + }); + vi.mocked(platformResourceCleanup.stopSnapshotHelper).mockImplementation(async () => { + cleanupEntered.resolve(); + await releaseCleanup.promise; + }); + const running = dispatchSnapshotViaRuntime({ + req: { + command: 'snapshot', + positionals: [], + token: 'test', + session: address, + meta: { requestId: registration.requestId }, + }, + sessionName: address, + logPath: '/dev/null', + sessionStore, + ...snapshotRuntimeFixture(registration.requestId), + platformResourceCleanup, + }); + const result = running.then( + (response) => ({ response }), + (error: unknown) => ({ error }), + ); + const stderr: string[] = []; + let teardown: Promise | undefined; + try { + await captureEntered.promise; + vi.useFakeTimers(); + sessionStore.closeAdmission(); + markRequestCanceled(registration.requestId); + teardown = teardownDaemonSessionForShutdown({ + ref: shutdownRef, + sessionStore, + stderr: { write: (chunk) => stderr.push(chunk) }, + }); + await cleanupEntered.promise; + await vi.advanceTimersByTimeAsync(DAEMON_SESSION_TEARDOWN_TIMEOUT_MS); + await teardown; + expect(stderr.join('')).toContain('Daemon session teardown timed out (default).'); + expect(sessionStore.resolveCurrent(shutdownRef)).toBeUndefined(); + expect(registration.controller.signal.aborted).toBe(true); + releaseCapture.resolve(); + expect(await result).toMatchObject({ + error: { + details: { + reason: + initialSession === 'published' ? 'session_lifetime_ended' : 'daemon_shutting_down', + }, + }, + }); + expect(sessionStore.lookup(address)).toBeUndefined(); + expect(sessionStore.lookup('default')).toBeUndefined(); + expect(platformResourceCleanup.stopSnapshotHelper).toHaveBeenCalledExactlyOnceWith( + ANDROID_EMULATOR, + ); + } finally { + releaseCapture.resolve(); + releaseCleanup.resolve(); + await result; + await teardown; + clearRequestAbortRegistration(registration); + vi.useRealTimers(); + } + }); +} diff --git a/src/daemon/server/daemon-runtime.ts b/src/daemon/server/daemon-runtime.ts index 6f19eb0cdb..a55bc8d05e 100644 --- a/src/daemon/server/daemon-runtime.ts +++ b/src/daemon/server/daemon-runtime.ts @@ -198,7 +198,7 @@ export async function teardownDaemonSessionForShutdown(params: { // ADR 0012 decision 6, R7 + commit semantics (C2/C5a): commit the healed // `.ad` iff the repair transaction completed, else leave a bounded // `REPAIR_SESSION_EXPIRED` tombstone for the reaped-before-finalize case. - sessionStore.finalizeRepairTeardown(session); + sessionStore.finalizeRepairTeardown(ref); await beforeDelete?.(session); if (teardownSucceeded) await afterSuccessfulTeardown?.(session); sessionStore.retire(ref); @@ -514,12 +514,10 @@ export async function startDaemonRuntime( // survive so the next pass can retry rather than leave a claim owned by a process that no longer // knows what it holds. So: resources, then the platform finalization that stops the execution host // and releases its lease, then the claim, cleared last, by the reaper. - const settleIdleExpiredSession = async ( - session: SessionState, - sessionName: string, - ): Promise => { - const ref = sessionStore.lookup(sessionName); - if (!ref) return; + const settleIdleExpiredSession = async (ref: SessionRef): Promise => { + const session = sessionStore.resolveCurrent(ref) ?? ref.session; + const sessionName = ref.address; + const runtimeHints = runtimeHintValues(sessionStore.getRuntimeHints(sessionName)); await teardownSessionResources({ appLog: 'run', ref, @@ -532,7 +530,7 @@ export async function startDaemonRuntime( scope: createDaemonRecoveryPlatformScope(), session, stateDir: baseDir, - runtimeHints: runtimeHintValues(sessionStore.getRuntimeHints(sessionName)), + runtimeHints, // The one caller that must say so: this daemon is staying alive, so there is no shutdown // phase a healthy runner could be deferred to. Taking the ordinary-close path stops the // runner and releases its lease instead of parking it until process exit. diff --git a/src/daemon/server/daemon-session-idle-expiry-scheduling.test.ts b/src/daemon/server/daemon-session-idle-expiry-scheduling.test.ts index 8aae5cd14b..4d1fa1051a 100644 --- a/src/daemon/server/daemon-session-idle-expiry-scheduling.test.ts +++ b/src/daemon/server/daemon-session-idle-expiry-scheduling.test.ts @@ -47,7 +47,7 @@ test('a session still inside its window survives a sweep that actually ran', asy sessionStore, idleExpiryMs: WINDOW_MS, executionLocks: new Map(), - settleSession: async (_session, sessionName) => { + settleSession: async ({ address: sessionName }) => { settledNames.push(sessionName); }, withinDiagnosticsScope: sweeps.withinDiagnosticsScope, @@ -300,7 +300,7 @@ test('a session that moved to another device is fenced by that device, not the s sessionStore, idleExpiryMs: WINDOW_MS, executionLocks: locks, - settleSession: async (session) => { + settleSession: async ({ session }) => { settledDeviceIds.push(session.device.id); }, withinDiagnosticsScope: sweeps.withinDiagnosticsScope, @@ -479,7 +479,7 @@ test('a daemon beginning to leave does not start settling the next session', asy sessionStore, idleExpiryMs: WINDOW_MS, executionLocks: new Map(), - settleSession: async (_session, sessionName) => { + settleSession: async ({ address: sessionName }) => { settledNames.push(sessionName); if (sessionName === 'first') await firstHeld; }, diff --git a/src/daemon/server/daemon-session-idle-expiry.test.ts b/src/daemon/server/daemon-session-idle-expiry.test.ts index 402da7d219..b27d64b898 100644 --- a/src/daemon/server/daemon-session-idle-expiry.test.ts +++ b/src/daemon/server/daemon-session-idle-expiry.test.ts @@ -197,8 +197,8 @@ test('a session shutdown finalized mid-settle is not also reported as expired', executionLocks: new Map(), // Shutdown takes no execution lock: it tears the whole session set down directly, so it is the // one remover that can finish while a settle is still running. - settleSession: async (_session, sessionName) => { - sessionStore.delete(sessionName); + settleSession: async (ref) => { + sessionStore.retire(ref); }, withinDiagnosticsScope: sweeps.withinDiagnosticsScope, now: () => NOW, @@ -267,7 +267,7 @@ test('a settled session of another kind is never touched by the sweep', async () executionLocks: new Map(), // Succeeds rather than throwing: a rejected settle leaves every record standing for retry, which // would let this pass read as a correct refusal even if the sweep had torn both sessions down. - settleSession: async (_session, sessionName) => { + settleSession: async ({ address: sessionName }) => { settledNames.push(sessionName); }, now: () => NOW, @@ -376,3 +376,49 @@ test('a committed expiry finalizes the repair transaction it ends', async () => 'committed', ); }); + +test('an idle settle cannot finalize or retire a replacement at its scoped address', async () => { + const { sessionStore } = makeFixture('agent-device-idle-expiry-lifetime-'); + idleClaimedSession(sessionStore); + const old = sessionStore.lookup('default')!; + sessionStore.retire(old); + const address = 'cwd:idle:default'; + const ref = sessionStore.publish(address, old.session); + const sweeps = createSweepBarrier(); + let release!: () => void; + const held = new Promise((resolve) => { + release = resolve; + }); + let entered = false; + const controller = createSessionIdleExpiry({ + sessionStore, + idleExpiryMs: WINDOW_MS, + executionLocks: new Map(), + settleSession: async () => { + entered = true; + await held; + }, + withinDiagnosticsScope: sweeps.withinDiagnosticsScope, + now: () => NOW, + }); + try { + controller.noteSessionsChanged(); + await waitFor(() => entered, 'the held idle settle'); + sessionStore.retire(ref); + const successor = sessionStore.publish(address, makeRepairCompleteSession('default')); + sessionStore.setRuntimeHints(address, { metroPort: 9090 }); + release(); + await sweeps.swept(); + assert.equal(sessionStore.requireCurrent(successor), successor.session); + assert.equal(successor.session.scriptPublication?.kind, 'repair'); + if (successor.session.scriptPublication?.kind === 'repair') { + assert.equal(successor.session.scriptPublication.status, 'complete'); + } + assert.equal(successor.session.actions.length, 0); + assert.equal(sessionStore.getRuntimeHints(address)?.metroPort, 9090); + assert.equal(sessionStore.readIdleExpiryTombstone(address), undefined); + } finally { + release(); + controller.cancel(); + } +}); diff --git a/src/daemon/server/daemon-session-idle-expiry.ts b/src/daemon/server/daemon-session-idle-expiry.ts index 0ffd0f33bf..158f63873b 100644 --- a/src/daemon/server/daemon-session-idle-expiry.ts +++ b/src/daemon/server/daemon-session-idle-expiry.ts @@ -11,11 +11,11 @@ import { sessionIdleDeadlineMs, type SessionIdleExpiryOutcome, } from '../session-idle-expiry.ts'; -import type { SessionState } from '../session-state.ts'; +import type { SessionRef, SessionState } from '../session-state.ts'; import type { SessionStore } from '../session-store.ts'; /** Settles one expired session's owned resources. Supplied by the runtime, which owns the seams. */ -export type IdleSessionSettler = (session: SessionState, sessionName: string) => Promise; +export type IdleSessionSettler = (ref: SessionRef) => Promise; /** The one outcome this reaper invents: the clear threw, so nothing about the claim is known. */ const CLAIM_CLEAR_FAILED = 'claim-clear-failed'; @@ -105,7 +105,7 @@ export function createSessionIdleExpiry(params: { // without taking any execution lock, so it is the one remover that can finalize a session out from // under a settle. A sweep already inside a settle cannot be recalled, and settles there because // stopping mid-teardown would strand a resource; what it must not do is write an idle-expiry - // marker over a shutdown's close, which `SessionStore.delete`'s answer detects. + // marker over a shutdown's close; retiring the captured lifetime detects that case. let closing = false; // Addresses with a settle in flight. A settle whose budget expired stopped being WAITED on, not // stopped: it keeps holding the session's execution lock until it actually finishes. Without this @@ -254,7 +254,7 @@ async function expireIdleSessions(params: IdleExpirySweepParams): Promise * that lands after the budget still counts. */ async function expireIdleSession( - params: IdleExpirySweepParams & { ref: { address: string; session: SessionState } }, + params: IdleExpirySweepParams & { ref: SessionRef }, ): Promise { const { address } = params.ref; // A release still in flight holds this session's locks, so queueing on them would have the sweep wait @@ -332,7 +332,7 @@ function budgetElapsedAfter(ms: number): Readonly<{ */ async function settleIdleSessionUnderLock( params: IdleExpirySweepParams & { - ref: { address: string; session: SessionState }; + ref: SessionRef; lockKeys: readonly RequestExecutionLockKey[]; }, ): Promise { @@ -349,7 +349,7 @@ async function settleIdleSessionUnderLock( if (params.closing()) return NOTHING_TO_RETRY; // Re-read under the locks rather than trusting the swept reference: the device may have moved, // and the lock keys were chosen from the pre-lock reading. - const settled = params.sessionStore.get(address); + const settled = params.sessionStore.resolveCurrent(params.ref); if (!settled) return NOTHING_TO_RETRY; if (settled.device.id !== session.device.id) return NOTHING_TO_RETRY; // Re-clocked here too: a command that admitted while this expiry was queuing has finished and @@ -357,8 +357,7 @@ async function settleIdleSessionUnderLock( const atMs = params.now(); if (!isSessionIdleExpired(settled, params.idleExpiryMs, atMs)) return NOTHING_TO_RETRY; const outcome = await settleExpiredSession({ - sessionName: address, - session: settled, + ref: params.sessionStore.refresh(params.ref), idleExpiryMs: params.idleExpiryMs, expiredAtMs: atMs, sessionStore: params.sessionStore, @@ -450,14 +449,14 @@ function rememberRetry( * successor owns the device now, and never blocks the expiry. */ async function settleExpiredSession(params: { - sessionName: string; - session: SessionState; + ref: SessionRef; idleExpiryMs: number; expiredAtMs: number; sessionStore: SessionStore; settleSession: IdleSessionSettler; }): Promise { - const { sessionName, session, idleExpiryMs, expiredAtMs } = params; + const { ref, idleExpiryMs, expiredAtMs } = params; + const { address: sessionName, session } = ref; const deviceKey = session.deviceClaim?.deviceKey; const identity = { session: sessionName, idleExpiryMs, ...(deviceKey ? { deviceKey } : {}) }; if (!(await releaseExpiredSessionResources(params, identity))) return undefined; @@ -477,21 +476,8 @@ async function settleExpiredSession(params: { // stamps COMMITTED onto the record, and a write onto an already-committed transaction is an // idempotent no-op. Finalizing a settle that is being held back would therefore mark a still-live // session's healed script as already published, and no later teardown would ever publish it. - params.sessionStore.finalizeRepairTeardown(session); - // `delete` reports whether a record was still here to remove. Every request-path remover — `close`, - // a replacing `open`, a lease-expiry teardown — removes a session from inside `runAdmitted`, which - // holds the same lock pair this settle holds, and a settle budget bounds only the sweep's WAIT and - // never these locks, so no request can reach this record while the release is running. Daemon - // shutdown is the one remover that takes no lock at all, and it is therefore the only way here. - // The session was ended by someone else, and that owner has already explained it; a marker written - // now would tell the next agent the session died of idleness when something else closed it. - // - // The finalize above already published this session's repair transaction, which is why "a failed - // settle changes nothing" is not this function's contract: publishing belongs to whoever ENDS the - // session, and shutdown ends it by finalizing the same live record, onto which this commit is an - // idempotent no-op. Deferring the finalize to below this guard would instead resolve the healed - // script's event-log directory by map identity, which a deleted record no longer answers correctly. - if (!params.sessionStore.delete(sessionName)) { + params.sessionStore.finalizeRepairTeardown(ref); + if (!params.sessionStore.retire(ref)) { emitDiagnostic({ level: 'info', phase: 'session_idle_expiry_superseded', @@ -528,11 +514,11 @@ async function settleExpiredSession(params: { * this step's diagnostic. */ async function releaseExpiredSessionResources( - params: { session: SessionState; sessionName: string; settleSession: IdleSessionSettler }, + params: { ref: SessionRef; settleSession: IdleSessionSettler }, identity: Readonly>, ): Promise { try { - await params.settleSession(params.session, params.sessionName); + await params.settleSession(params.ref); return true; } catch (error) { emitDiagnostic({ diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-close-lifecycle-runtime.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-close-lifecycle-runtime.test.ts index 7ca47b926c..05af3277fa 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-close-lifecycle-runtime.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-close-lifecycle-runtime.test.ts @@ -347,3 +347,35 @@ test('close expires the ref frame immediately before its admitted platform mutat expect(response?.ok).toBe(true); expect(mockDispatch).toHaveBeenCalledOnce(); }); + +test('app-only close expires the rebuilt record after admission, preserving the captured snapshot', async () => { + const sessionStore = makeSessionStore(); + const address = 'cwd:close:default'; + const session = makeSession('default', { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + booted: true, + }); + session.appBundleId = 'com.example.app'; + activateCompleteRefFrame(session); + const ref = sessionStore.publish(address, session); + mockInspectDeviceRuntimeFacts.mockImplementationOnce(async (candidate) => { + sessionStore.update(ref, { appName: 'Updated during admission' }); + return lifecycleRuntimeFacts(candidate); + }); + + const response = await close({ + sessionName: address, + sessionStore, + positionals: ['com.example.app'], + internal: { closeAppOnly: true }, + }); + + expect(response?.ok).toBe(true); + expect(refFrameState(sessionStore.requireCurrent(ref))).toBe('expired'); + expect(sessionStore.requireCurrent(ref).appName).toBe('Updated during admission'); + expect(refFrameState(ref.session)).toBe('active'); + expect(mockDispatch).toHaveBeenCalledOnce(); +}); diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-close-resource-cleanup.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-close-resource-cleanup.test.ts index e77972a7f3..41f5193bdf 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-close-resource-cleanup.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-close-resource-cleanup.test.ts @@ -259,3 +259,63 @@ test('close preserves the session and lease when provider release fails so it ca expect(sessionStore.get(sessionName)).toBeUndefined(); expect(leaseRegistry.listActiveLeases()).toHaveLength(0); }); + +test('close cannot retire a replacement session while its provider release waits', async () => { + const sessionStore = makeSessionStore(); + const leaseRegistry = new LeaseRegistry(); + const address = 'cwd:provider-close:default'; + const lease = leaseRegistry.allocateLease({ + tenantId: 'tenant-a', + runId: 'run-1', + leaseProvider: 'browserstack', + deviceKey: 'ios:bs-device', + clientId: 'client-a', + }); + const ref = sessionStore.publish(address, { + ...makeSession('default', WEB_DESKTOP_DEVICE), + lease: { + leaseId: lease.leaseId, + tenantId: lease.tenantId, + runId: lease.runId, + leaseBackend: lease.backend, + leaseProvider: lease.leaseProvider, + deviceKey: lease.deviceKey, + clientId: lease.clientId, + expiresAt: lease.expiresAt, + }, + }); + let release!: () => void; + const held = new Promise((resolve) => { + release = resolve; + }); + const providerRelease = vi.fn(async () => { + await held; + return { releasedBy: 'provider' }; + }); + const closing = handleSessionCommands({ + req: { token: 't', session: address, command: 'close', positionals: [], flags: {} }, + sessionName: address, + logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), + sessionStore, + leaseRegistry, + leaseLifecycleProvider: { release: providerRelease }, + invoke: noopInvoke, + }); + try { + await vi.waitFor(() => expect(providerRelease).toHaveBeenCalledOnce()); + sessionStore.retire(ref); + const successor = sessionStore.publish(address, makeSession('default', WEB_DESKTOP_DEVICE)); + sessionStore.setRuntimeHints(address, { metroPort: 9090 }); + release(); + expect(await closing).toMatchObject({ + ok: true, + data: { provider: { releasedBy: 'provider' } }, + }); + expect(sessionStore.requireCurrent(successor)).toBe(successor.session); + expect(sessionStore.getRuntimeHints(address)).toEqual({ metroPort: 9090 }); + expect(leaseRegistry.listActiveLeases()).toHaveLength(0); + } finally { + release(); + await closing.catch(() => {}); + } +}); diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-close-script.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-close-script.test.ts index 34acf3f61e..ad90ee5d3b 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-close-script.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-close-script.test.ts @@ -34,7 +34,7 @@ function setup(name: string, session = makeIosSession(name, { appBundleId: 'com. roots.push(root); const sessionsDir = path.join(root, 'sessions'); const sessionStore = new SessionStore(sessionsDir); - sessionStore.set(name, session); + const ref = sessionStore.publish(name, session); const req: DaemonRequest = { token: 'token', session: name, @@ -42,18 +42,18 @@ function setup(name: string, session = makeIosSession(name, { appBundleId: 'com. positionals: [], flags: {}, }; - return { req, session, sessionStore, sessionsDir }; + return { req, ref, session, sessionStore, sessionsDir }; } test('failed repair publication removes only its synthetic close before retry', () => { - const { req, session, sessionStore } = setup( + const { req, ref, session, sessionStore } = setup( 'repair', makeRepairCompleteSession('repair', { appBundleId: 'com.example.app' }), ); const failure = new AppError('COMMAND_FAILED', 'publish failed'); vi.spyOn(sessionStore, 'writeSessionLog').mockReturnValue({ written: false, error: failure }); - expect(commitRepairScriptBeforeClose(sessionStore, session, req)).toEqual({ + expect(commitRepairScriptBeforeClose(sessionStore, ref, req)).toEqual({ kind: 'failed', error: failure, }); @@ -92,7 +92,7 @@ test('repair close failure keeps normalized metadata and is explicitly retriable }); test('ordinary publication failure retains its close action after making the error non-retriable', () => { - const { req, session, sessionStore } = setup('ordinary'); + const { req, ref, session, sessionStore } = setup('ordinary'); const failure = new AppError('COMMAND_FAILED', 'target exists', { reason: 'target-exists', hint: 'Retry close.', @@ -103,7 +103,7 @@ test('ordinary publication failure retains its close action after making the err const result = finalizeOrdinaryCloseScript({ req: { ...req, flags: { saveScript: true } }, - session, + ref, sessionStore, platformCloseError: undefined, }); @@ -126,7 +126,7 @@ test('ordinary publication failure retains its close action after making the err // that promise: the plain-close teardown path must write nothing. test('#1533: bare close on an aborted authoring session writes no script', () => { - const { req, session, sessionStore, sessionsDir } = setup( + const { req, ref, sessionStore, sessionsDir } = setup( 'aborted', makeIosSession('aborted', { appBundleId: 'com.example.app', @@ -139,7 +139,7 @@ test('#1533: bare close on an aborted authoring session writes no script', () => expect( finalizeOrdinaryCloseScript({ req, - session, + ref, sessionStore, platformCloseError: undefined, }), @@ -150,7 +150,7 @@ test('#1533: bare close on an aborted authoring session writes no script', () => }); test('#1533: an ordinary armed authoring session still publishes on bare close', () => { - const { req, session, sessionStore, sessionsDir } = setup( + const { req, ref, sessionStore, sessionsDir } = setup( 'armed', makeIosSession('armed', { appBundleId: 'com.example.app', @@ -162,7 +162,7 @@ test('#1533: an ordinary armed authoring session still publishes on bare close', expect( finalizeOrdinaryCloseScript({ req, - session, + ref, sessionStore, platformCloseError: undefined, }), diff --git a/src/daemon/session-lifecycle/internal/session-close-lifecycle-teardown.ts b/src/daemon/session-lifecycle/internal/session-close-lifecycle-teardown.ts index 81eb0b82bd..202996c16d 100644 --- a/src/daemon/session-lifecycle/internal/session-close-lifecycle-teardown.ts +++ b/src/daemon/session-lifecycle/internal/session-close-lifecycle-teardown.ts @@ -47,7 +47,7 @@ export async function runSessionCloseTeardown(params: { dispatchTargetedPlatformClose: PlatformCloseDispatcher; finalizeOrdinaryCloseScript(input: { req: DaemonRequest; - session: SessionState; + ref: SessionRef; sessionStore: SessionStore; platformCloseError: unknown; }): Error | undefined; @@ -66,7 +66,7 @@ export async function runSessionCloseTeardown(params: { finalizeOrdinaryCloseScript, } = params; const { address: sessionName } = ref; - const session = sessionStore.requireCurrent(ref); + let session = sessionStore.requireCurrent(ref); const attemptCleanup = async ( step: string, run: () => Promise, @@ -91,6 +91,7 @@ export async function runSessionCloseTeardown(params: { attemptCleanup, params.platformResourceCleanup, ); + session = sessionStore.requireCurrent(ref); const platformCloseError = repairArmed ? undefined : await dispatchTargetedPlatformClose({ req, session, logPath, lifecycle }); @@ -119,7 +120,7 @@ export async function runSessionCloseTeardown(params: { ); const saveScriptError = repairArmed ? undefined - : finalizeOrdinaryCloseScript({ req, session, sessionStore, platformCloseError }); + : finalizeOrdinaryCloseScript({ req, ref, sessionStore, platformCloseError }); await attemptCleanup('materialized_paths', () => cleanupRetainedMaterializedPathsForSession(sessionName), ); diff --git a/src/daemon/session-lifecycle/internal/session-close-script.ts b/src/daemon/session-lifecycle/internal/session-close-script.ts index 47fbfc88f2..ec64319b86 100644 --- a/src/daemon/session-lifecycle/internal/session-close-script.ts +++ b/src/daemon/session-lifecycle/internal/session-close-script.ts @@ -3,7 +3,7 @@ import { successText } from '@agent-device/kernel/success-text'; import type { CommandFlags } from '@agent-device/contracts/command'; import type { SessionStore } from '../../session-store.ts'; import type { DaemonRequest, DaemonResponse } from '../../daemon-request.ts'; -import type { SessionState } from '../../session-state.ts'; +import type { SessionRef, SessionState } from '../../session-state.ts'; import { NO_SCRIPT_PUBLICATION, scriptTargetPath } from '../../session-script-publication-state.ts'; import { effectiveWriteForce, @@ -33,15 +33,16 @@ function recordCloseAction( export function commitRepairScriptBeforeClose( sessionStore: SessionStore, - session: SessionState, + ref: SessionRef, req: DaemonRequest, ): RepairCloseCommit { + const session = sessionStore.requireCurrent(ref); if (!isRepairArmedSession(session)) return { kind: 'not-armed' }; const actionsBeforeClose = session.actions.length; recordCloseAction(sessionStore, session, req); const alreadyPublished = isSessionScriptPublished(session); - const result = sessionStore.writeSessionLog(session, { + const result = sessionStore.writeSessionLog(ref, { force: effectiveWriteForce(session, req.flags?.force), }); if (result.written) return { kind: 'committed', path: result.path }; @@ -77,11 +78,12 @@ export function buildRetriableRepairCloseFailureResponse( export function finalizeOrdinaryCloseScript(params: { req: DaemonRequest; - session: SessionState; + ref: SessionRef; sessionStore: SessionStore; platformCloseError: unknown; }): AppError | undefined { - const { req, session, sessionStore, platformCloseError } = params; + const { req, ref, sessionStore, platformCloseError } = params; + const session = sessionStore.requireCurrent(ref); if (!platformCloseError) { recordCloseAction(sessionStore, session, req); } @@ -90,7 +92,7 @@ export function finalizeOrdinaryCloseScript(params: { // session default rather than the request's explicit path — the lifecycle armed by `open` is // what authorizes the write, and it is untouched by that failure. try { - const result = sessionStore.writeSessionLog(session, { + const result = sessionStore.writeSessionLog(ref, { force: effectiveWriteForce(session, req.flags?.force), }); if (result.written) markCloseGeneratedPublicationDone(session, result.path); diff --git a/src/daemon/session-lifecycle/internal/session-close.ts b/src/daemon/session-lifecycle/internal/session-close.ts index 9d2f95dd9b..7654c1bc0e 100644 --- a/src/daemon/session-lifecycle/internal/session-close.ts +++ b/src/daemon/session-lifecycle/internal/session-close.ts @@ -68,12 +68,13 @@ const shouldDispatchPlatformClose = (req: DaemonRequest, session: SessionState): async function prepareRepairClose(params: { req: DaemonRequest; - session: SessionState; + ref: SessionRef; logPath: string; sessionStore: SessionStore; lifecycle: CloseRuntime | CloseRuntimeWithRuntimeHintClear; }): Promise { - const { req, session, logPath, sessionStore, lifecycle } = params; + const { req, ref, logPath, sessionStore, lifecycle } = params; + const session = sessionStore.requireCurrent(ref); const repairArmed = isRepairArmedSession(session); const closeReceipt = JSON.stringify(req.positionals ?? []); if (repairArmed && !hasRepairPlatformCloseReceipt(session, closeReceipt)) { @@ -93,9 +94,9 @@ async function prepareRepairClose(params: { ), }; } - recordRepairPlatformClose(session, closeReceipt); + recordRepairPlatformClose(sessionStore.requireCurrent(ref), closeReceipt); } - const repairCommit = commitRepairScriptBeforeClose(sessionStore, session, req); + const repairCommit = commitRepairScriptBeforeClose(sessionStore, ref, req); if (repairCommit.kind === 'failed') { // Publication failure retains target, force, and the close receipt; the same-identity retry // skips close dispatch above. @@ -202,7 +203,7 @@ export async function handleSessionCloseCommands( bindDevice: params.bindDevice, }); } - const session = ref.session; + let session = sessionStore.requireCurrent(ref); assertTerminalRecordingCloseAllowed(req, session); const app = req.positionals?.[0]; if (req.internal?.closeAppOnly === true && !app) { @@ -225,6 +226,7 @@ export async function handleSessionCloseCommands( bindDevice: params.bindDevice, }); if (admission.type === 'response') return admission.response; + session = sessionStore.requireCurrent(ref); // Teardown can restore durable IME state, terminate an app, or shut down a target. All are // mutating leaves, so invalidate the frame before the first teardown phase, not after dispatch. expireRefFrame(session); @@ -239,7 +241,7 @@ export async function handleSessionCloseCommands( } const repair = await prepareRepairClose({ req, - session, + ref, logPath, sessionStore, lifecycle: admission.runtime, @@ -301,7 +303,7 @@ async function runCloseTeardownAndRelease(params: { lifecycle, clearRuntimeHints, } = params; - const { address: sessionName, session } = ref; + const { address: sessionName } = ref; const cleanupFailures: SessionCleanupFailure[] = []; const { platformCloseError, saveScriptError, shutdownResult } = await runSessionCloseTeardown({ req, @@ -316,12 +318,14 @@ async function runCloseTeardownAndRelease(params: { finalizeOrdinaryCloseScript, platformResourceCleanup: params.platformResourceCleanup, }); + let session = sessionStore.requireCurrent(ref); const leaseRelease = await releaseProviderLeaseForClose({ session, leaseRegistry, leaseLifecycleProvider, }); if (leaseRelease.response) return { kind: 'response', response: leaseRelease.response }; + session = sessionStore.resolveCurrent(ref) ?? session; const cleanupAggregate = closeCleanupError(sessionName, cleanupFailures); const deviceClaimBlockingError = platformCloseError ?? cleanupAggregate; if (deviceClaimBlockingError) { @@ -338,7 +342,7 @@ async function runCloseTeardownAndRelease(params: { } else { await clearDeviceClaim(session.deviceClaim); } - sessionStore.delete(sessionName); + sessionStore.retire(ref); if (deviceClaimBlockingError) throw deviceClaimBlockingError; if (saveScriptError) throw saveScriptError; return { kind: 'closed', providerData: leaseRelease.providerData, shutdownResult }; diff --git a/src/daemon/session-snapshot.ts b/src/daemon/session-snapshot.ts index 0f0bb71822..6e70a1b8dd 100644 --- a/src/daemon/session-snapshot.ts +++ b/src/daemon/session-snapshot.ts @@ -2,7 +2,8 @@ import { randomInt } from 'node:crypto'; import type { SettleObservation } from '@agent-device/contracts/interaction'; import type { SnapshotState } from '@agent-device/kernel/snapshot'; import { activatePartialRefFrame, refFrameEpoch, refFrameState } from './ref-frame.ts'; -import type { SessionState } from './session-state.ts'; +import type { SessionRef, SessionState } from './session-state.ts'; +import type { SessionStore } from './session-store.ts'; /** * Warning attached to a read of an `@ref` argument once the ref frame has @@ -16,8 +17,7 @@ export const STALE_SNAPSHOT_REFS_WARNING = /** * The single daemon-side write choke point for replacing a session's stored - * snapshot outside the snapshot/diff command (`buildNextSnapshotSession`, - * src/daemon/snapshot-runtime.ts). It advances the observation generation but + * snapshot outside the snapshot/diff command. It advances the observation generation but * does NOT touch the ref frame: replacing the latest observation is an * operational read, so it never expires, reactivates, or reindexes the * authorized frame (ADR 0014). Frame lifetime is owned solely by @@ -38,35 +38,20 @@ export function setSessionSnapshot(session: SessionState, snapshot: SnapshotStat } } -/** - * The same lineage rule, applied to a freshly BUILT record instead of the stored one. - * `snapshot-runtime.ts` constructs a new `SessionState` rather than mutating the one in the - * store, so it cannot go through `setSessionSnapshot` — but the invariant it has to honour is - * identical, and it is the invariant that matters: #1076 versioned refs require the observation - * counter to advance exactly when the stored tree is replaced, for `snapshot` and `diff` alike, - * and the scope source must describe the tree that actually ended up there. - * - * Advancing the counter is NOT the same as invalidating client refs, and the comment this - * replaced said otherwise — it claimed a diff leaves refs pinned to the previous generation - * "which is exactly what the pinned warning diagnoses". It does not: `diff` passes - * `issuesRefsToClient: false`, so it never reactivates the frame, and - * `resolveRefStalenessWarning` compares a pin against the frame EPOCH rather than this counter, - * precisely so a capture that bumped the counter cannot make a valid pin look stale. A ref - * pinned before a diff therefore keeps resolving, with no warning, by design (ADR 0014). - * `session-snapshot.test.ts` pins that outcome so the claim cannot drift back. - * - * Both fields move together, here, next to the writer they have to agree with. They used to be - * assigned at the call site, which is how the rule came to have two statements of itself in two - * modules. - */ -export function setSnapshotLineage( +/** Replaces a snapshot/diff observation and its scoped lineage without issuing client refs. */ +export function setCommandSnapshot( session: SessionState, params: { + snapshot: SnapshotState; scopeSource: SnapshotState | undefined; keptCurrentSnapshot: boolean; previousGeneration: number | undefined; }, ): void { + session.snapshot = params.snapshot; + if (params.snapshot.comparisonSafe === true) { + session.lastComparisonSafeSnapshot = params.snapshot; + } session.snapshotScopeSource = params.scopeSource; session.snapshotGeneration = params.keptCurrentSnapshot ? params.previousGeneration @@ -136,10 +121,12 @@ export function markSessionPartialRefsIssued(session: SessionState, refs: Iterab * rule has one implementation beside the partial-frame primitive it wraps. */ export function issueSettleRefs( - session: SessionState, + ref: SessionRef | undefined, + sessionStore: SessionStore, settle: SettleObservation | undefined, ): number | undefined { - if (!settle?.diff) return undefined; + if (!ref || !settle?.diff) return undefined; + const session = sessionStore.requireCurrent(ref); markSessionPartialRefsIssued(session, collectSettleIssuedRefBodies(settle)); return session.snapshotGeneration; } diff --git a/src/daemon/session-store.ts b/src/daemon/session-store.ts index 217b8b8dac..e6818d4071 100644 --- a/src/daemon/session-store.ts +++ b/src/daemon/session-store.ts @@ -12,6 +12,7 @@ import { } from './session-artifact-paths.ts'; import { readRepairTombstoneFile, + clearRepairTombstoneFile, resolveRepairTombstonePath, type RepairSessionTombstone, } from '../session-repair-tombstone.ts'; @@ -231,10 +232,8 @@ export class SessionStore { ); } - writeSessionLog( - session: SessionState, - options?: SessionScriptWriteOptions, - ): SessionScriptWriteResult { + writeSessionLog(ref: SessionRef, options?: SessionScriptWriteOptions): SessionScriptWriteResult { + const session = this.requireCurrent(ref); const result = this.scriptWriter.write(session, options); if (result.written) { emitDiagnostic({ @@ -273,22 +272,24 @@ export class SessionStore { * ordinary bounded `REPAIR_SESSION_EXPIRED` tombstone. A no-op for ordinary * (non-repair) sessions beyond the existing `writeSessionLog`. */ - finalizeRepairTeardown(session: SessionState): void { + finalizeRepairTeardown(ref: SessionRef): void { + const session = this.resolveCurrent(ref); + if (!session) return; this.recordRepairFinalizeCloseIfCommitting(session); // #1258: no live request here (idle-reap/daemon-shutdown teardown), so // the only source of `force` is whatever was persisted on the session at // arm time. - const result = this.writeSessionLog(session, { + const result = this.writeSessionLog(ref, { force: effectiveWriteForce(session, undefined), }); if (isUncommittedRepairSession(session)) { if (!result.written && result.error) { - this.writeRepairTombstone(session, REPAIR_TOMBSTONE_TTL_MS, { + this.writeRepairTombstone(ref, REPAIR_TOMBSTONE_TTL_MS, { code: String(result.error.code), message: result.error.message, }); } else { - this.writeRepairTombstone(session); + this.writeRepairTombstone(ref); } } } @@ -324,15 +325,17 @@ export class SessionStore { * teardown. */ writeRepairTombstone( - session: SessionState, + ref: SessionRef, ttlMs = REPAIR_TOMBSTONE_TTL_MS, commitFailure?: { code: string; message: string }, ): void { + const session = this.resolveCurrent(ref); + if (!session) return; try { - const dir = this.resolveSessionDir(session.name); + const dir = this.resolveSessionDir(ref.address); fs.mkdirSync(dir, { recursive: true }); const tombstone: RepairSessionTombstone = { - owner: session.name, + owner: ref.address, reapedAt: Date.now(), expiresAt: Date.now() + ttlMs, ...(repairSessionSourcePath(session) @@ -340,13 +343,13 @@ export class SessionStore { : {}), ...(commitFailure ? { commitFailure } : {}), }; - fs.writeFileSync(this.repairTombstonePath(session.name), `${JSON.stringify(tombstone)}\n`); + fs.writeFileSync(this.repairTombstonePath(ref.address), `${JSON.stringify(tombstone)}\n`); } catch (error) { emitDiagnostic({ level: 'warn', phase: 'repair_tombstone_write_failed', data: { - session: session.name, + session: ref.address, error: error instanceof Error ? error.message : String(error), }, }); @@ -355,14 +358,12 @@ export class SessionStore { /** Returns a non-expired repair tombstone for `sessionName`, or `undefined`. */ readRepairTombstone(sessionName: string): RepairSessionTombstone | undefined { - return readRepairTombstoneFile(this.repairTombstonePath(sessionName)); + return readRepairTombstoneFile(this.repairTombstonePath(sessionName), sessionName); } /** ADR 0012 R7 (C5a): a fresh `replay --save-script` on this key clears the tombstone. */ clearRepairTombstone(sessionName: string): void { - try { - fs.rmSync(this.repairTombstonePath(sessionName), { force: true }); - } catch {} + clearRepairTombstoneFile(this.repairTombstonePath(sessionName), sessionName); } /** diff --git a/src/daemon/snapshot-command-runtime.ts b/src/daemon/snapshot-command-runtime.ts index a83882e039..9cc847c758 100644 --- a/src/daemon/snapshot-command-runtime.ts +++ b/src/daemon/snapshot-command-runtime.ts @@ -13,7 +13,7 @@ import { createCommandSurfaceAgentDevice } from '../runtime-command-surface.ts'; import { getRequestSignal } from '@agent-device/host-kit/request'; import { maybeBuildAndroidSnapshotTimeoutFailure } from './android-snapshot-timeout-evidence.ts'; import { captureSnapshot } from './snapshot-capture.ts'; -import { buildSnapshotSession, withSessionlessRunnerCleanup } from './snapshot-session.ts'; +import { createSnapshotSession, withSessionlessRunnerCleanup } from './snapshot-session.ts'; import { resolveSessionScope } from './session-routing.ts'; import { activateCompleteRefFrame } from './ref-frame.ts'; import { @@ -23,15 +23,14 @@ import { import { createDaemonRuntimePolicy } from './runtime-policy.ts'; import { createDaemonRuntimeSessionStore } from './runtime-session.ts'; import { isInteractiveObservation } from './session-action-recorder.ts'; -import { setSnapshotLineage } from './session-snapshot.ts'; +import { setCommandSnapshot } from './session-snapshot.ts'; import { SessionStore } from './session-store.ts'; import { resolveBoundSnapshotCaptureRuntime, type SnapshotRuntimeRouteParams, } from './snapshot-runtime-binding.ts'; import type { DaemonRequest, DaemonResponse, DaemonResponseData } from './daemon-request.ts'; -import type { SessionState } from './session-state.ts'; -import type { SessionScope } from '@agent-device/contracts/session'; +import type { SessionRef, SessionState } from './session-state.ts'; export type SnapshotRuntimeRecord = | { kind: 'snapshot'; nodes: number; truncated: boolean | undefined } @@ -45,10 +44,11 @@ export type SnapshotRuntimeRecord = type SnapshotRuntimeCommandParams = SnapshotRuntimeRouteParams & { command: 'snapshot' | 'diff'; execute(params: { - runtime: ReturnType; + runtime: ReturnType['runtime']; sessionName: string; req: DaemonRequest; snapshotScope: string | undefined; + getSession(): SessionState | undefined; }): Promise<{ data: DaemonResponseData; record: SnapshotRuntimeRecord }>; }; @@ -58,27 +58,32 @@ export async function dispatchSnapshotRuntimeCommand( ): Promise { const capture = await resolveBoundSnapshotCaptureRuntime(params, params.command); if (!capture.ok) return capture.response; - const { session, device, snapshotScope } = capture; + const { ref, session, device, snapshotScope } = capture; return await withSessionlessRunnerCleanup( session, device, async () => { const { req, sessionName, logPath, sessionStore } = params; const capturedQuality: CapturedSnapshotQuality = {}; - const runtime = createSnapshotRuntime({ + const { runtime, sessions } = createSnapshotRuntime({ req, sessionName, logPath, sessionStore, + ref, session, device, snapshotScope, capturedQuality, captureSnapshotData: capture.captureSnapshot, }); + const getSession = () => { + const currentRef = sessions.getRef(); + return currentRef ? sessionStore.requireCurrent(currentRef) : undefined; + }; let result: Awaited>; try { - result = await params.execute({ runtime, sessionName, req, snapshotScope }); + result = await params.execute({ runtime, sessionName, req, snapshotScope, getSession }); } catch (error) { const timeoutResponse = await maybeBuildAndroidSnapshotTimeoutFailure({ error, @@ -92,14 +97,16 @@ export async function dispatchSnapshotRuntimeCommand( if (!timeoutResponse) throw error; return timeoutResponse; } + const current = getSession(); recordSnapshotRuntimeAction({ req, sessionName, sessionStore, + session: current, result: result.record, }); const data = applyRecoveredWarningLatch({ - session: sessionStore.get(sessionName), + session: current, data: result.data, verdict: capturedQuality.value, internalObservation: req.internal?.observationOnly === true, @@ -118,6 +125,7 @@ function createSnapshotRuntime(params: { sessionName: string; logPath: string; sessionStore: SessionStore; + ref: SessionRef | undefined; session: SessionState | undefined; device: SessionState['device']; snapshotScope: string | undefined; @@ -125,7 +133,48 @@ function createSnapshotRuntime(params: { captureSnapshotData: () => Promise; }) { const { req, sessionName, logPath, sessionStore, session, device, snapshotScope } = params; - return createCommandSurfaceAgentDevice({ + const sessions = createDaemonRuntimeSessionStore({ + sessionName, + sessionStore, + ref: params.ref, + recordOptions: { includeSnapshot: true }, + setRecord: (record, current, ref) => { + const snapshotRecord = assertSnapshotSessionRecord(record); + const keepCurrentSnapshot = shouldKeepCurrentSnapshot( + current, + snapshotRecord, + isRefScopedSnapshot(req), + ); + const snapshot = keepCurrentSnapshot ? current.snapshot : snapshotRecord.snapshot; + const nextSession: SessionState = + current ?? + createSnapshotSession({ + sessionName, + sessionScope: resolveSessionScope(req), + device, + snapshot, + appBundleId: record.appBundleId, + }); + nextSession.appName = record.appName ?? current?.appName; + setCommandSnapshot(nextSession, { + snapshot, + scopeSource: resolveNextSnapshotScopeSource({ + current, + keepCurrentSnapshot, + refScopedSnapshot: isRefScopedSnapshot(req), + }), + keptCurrentSnapshot: keepCurrentSnapshot, + previousGeneration: current?.snapshotGeneration, + }); + reactivateCompleteFrameIfIssuing( + nextSession, + keepCurrentSnapshot, + req.command === 'snapshot' && req.internal?.observationOnly !== true, + ); + return ref ?? sessionStore.publish(sessionName, nextSession); + }, + }); + const runtime = createCommandSurfaceAgentDevice({ backend: createDaemonSnapshotBackend({ req, logPath, @@ -137,65 +186,9 @@ function createSnapshotRuntime(params: { }), ...createDaemonRuntimePolicy('snapshot'), signal: getRequestSignal(req.meta?.requestId), - sessions: createDaemonRuntimeSessionStore({ - sessionName, - getSession: () => sessionStore.get(sessionName), - recordOptions: { includeSnapshot: true }, - setRecord: (record) => { - const snapshotRecord = assertSnapshotSessionRecord(record); - const current = sessionStore.get(sessionName); - sessionStore.set( - sessionName, - buildNextSnapshotSession({ - current, - sessionName, - sessionScope: resolveSessionScope(req), - device, - record: snapshotRecord, - refScopedSnapshot: isRefScopedSnapshot(req), - // Only snapshot publishes the complete stored tree. A diff refreshes the - // observation but leaves the client's existing ref authorization unchanged. - issuesRefsToClient: - req.command === 'snapshot' && req.internal?.observationOnly !== true, - }), - ); - }, - }), - }); -} - -function buildNextSnapshotSession(params: { - current: SessionState | undefined; - sessionName: string; - sessionScope: SessionScope; - device: SessionState['device']; - record: CommandSessionRecord & { snapshot: NonNullable }; - refScopedSnapshot: boolean; - issuesRefsToClient: boolean; -}): SessionState { - const { current, sessionName, sessionScope, device, record, refScopedSnapshot } = params; - const keepCurrentSnapshot = shouldKeepCurrentSnapshot(current, record, refScopedSnapshot); - const snapshot = keepCurrentSnapshot ? current.snapshot : record.snapshot; - const nextSession = buildSnapshotSession({ - session: current, - sessionName, - sessionScope, - device, - snapshot, - appBundleId: record.appBundleId, + sessions, }); - setSnapshotLineage(nextSession, { - scopeSource: resolveNextSnapshotScopeSource({ - current, - keepCurrentSnapshot, - refScopedSnapshot, - }), - keptCurrentSnapshot: keepCurrentSnapshot, - previousGeneration: current?.snapshotGeneration, - }); - reactivateCompleteFrameIfIssuing(nextSession, keepCurrentSnapshot, params.issuesRefsToClient); - if (record.appName) nextSession.appName = record.appName; - return nextSession; + return { runtime, sessions }; } function isRefScopedSnapshot(req: DaemonRequest): boolean { @@ -273,9 +266,10 @@ function recordSnapshotRuntimeAction(params: { req: DaemonRequest; sessionName: string; sessionStore: SessionStore; + session: SessionState | undefined; result: SnapshotRuntimeRecord; }): void { - const session = params.sessionStore.get(params.sessionName); + const session = params.session; if (!session) return; params.sessionStore.recordAction(session, { command: params.req.command, diff --git a/src/daemon/snapshot-runtime-binding.ts b/src/daemon/snapshot-runtime-binding.ts index 909e4734a4..f4df411463 100644 --- a/src/daemon/snapshot-runtime-binding.ts +++ b/src/daemon/snapshot-runtime-binding.ts @@ -19,7 +19,7 @@ import { import type { PlatformResourceCleanup } from './platform-resource-cleanup.ts'; import { SessionStore } from './session-store.ts'; import type { DaemonRequest, DaemonResponse } from './daemon-request.ts'; -import type { SessionState } from './session-state.ts'; +import type { SessionRef, SessionState } from './session-state.ts'; import { admitRuntimePlan, requireRuntimeBinding, @@ -51,6 +51,7 @@ export type SnapshotRuntimeRouteParams = { type ResolvedSnapshotCaptureRuntime = | Readonly<{ ok: true; + ref: SessionRef | undefined; session: SessionState | undefined; device: SessionState['device']; snapshotScope: string | undefined; @@ -134,7 +135,7 @@ export async function resolveBoundSnapshotCaptureRuntime( command: 'snapshot' | 'diff', ): Promise { const { req, sessionName, sessionStore } = params; - const { session, device } = await resolveSessionDevice(sessionStore, sessionName, req.flags); + const { ref, session, device } = await resolveSessionDevice(sessionStore, sessionName, req.flags); const resolvedScope = resolveSnapshotScope(req.flags?.snapshotScope, session); if (!resolvedScope.ok) return { ok: false, response: resolvedScope }; @@ -161,6 +162,7 @@ export async function resolveBoundSnapshotCaptureRuntime( }); return Object.freeze({ ok: true, + ref, session, device, snapshotScope: resolvedScope.scope, diff --git a/src/daemon/snapshot-runtime.ts b/src/daemon/snapshot-runtime.ts index b7cf97cf30..7475f43781 100644 --- a/src/daemon/snapshot-runtime.ts +++ b/src/daemon/snapshot-runtime.ts @@ -24,6 +24,7 @@ export async function dispatchSnapshotViaRuntime( sessionName: resolvedSessionName, req: request, snapshotScope, + getSession, }) => { const result = await agentRuntime.capture.snapshot({ session: resolvedSessionName, @@ -34,16 +35,13 @@ export async function dispatchSnapshotViaRuntime( // This request's own capture, read here rather than off the stored snapshot: a snapshot that // failed before capturing must not inherit the previous command's repair (#2682). if (result.targetActivation) captureProof.targetActivation ??= result.targetActivation; - const refsGeneration = publishedSnapshotGeneration( - request, - params.sessionStore.get(resolvedSessionName), - ); + const refsGeneration = publishedSnapshotGeneration(request, getSession()); const publicNodes = stripAndroidSystemChromeProvenance(result.nodes); const publicResult = copySnapshotClickabilityEvidence( result, publicNodes === result.nodes ? result : { ...result, nodes: publicNodes }, ); - const session = params.sessionStore.get(resolvedSessionName); + const session = getSession(); const fallbackScreenshot = await captureSparseFallbackScreenshot({ req: request, session, diff --git a/src/daemon/snapshot-session.ts b/src/daemon/snapshot-session.ts index 0322551409..ea80c1bf7d 100644 --- a/src/daemon/snapshot-session.ts +++ b/src/daemon/snapshot-session.ts @@ -11,9 +11,10 @@ export async function resolveSessionDevice( sessionName: string, flags: DaemonRequest['flags'], ) { - const session = sessionStore.get(sessionName); + const ref = sessionStore.lookup(sessionName); + const session = ref?.session; const device = session?.device ?? (await resolveTargetDevice(flags ?? {})); - return { session, device }; + return { ref, session, device }; } export async function withSessionlessRunnerCleanup( @@ -52,23 +53,14 @@ export function recordIfSession( }); } -export function buildSnapshotSession(params: { - session: SessionState | undefined; +export function createSnapshotSession(params: { sessionName: string; sessionScope: SessionScope; device: SessionState['device']; snapshot: SessionState['snapshot']; appBundleId?: string; }): SessionState { - const { session, sessionName, sessionScope, device, snapshot, appBundleId } = params; - if (session) { - return { - ...session, - snapshot, - lastComparisonSafeSnapshot: - snapshot?.comparisonSafe === true ? snapshot : session.lastComparisonSafeSnapshot, - }; - } + const { sessionName, sessionScope, device, snapshot, appBundleId } = params; return { name: sessionName, sessionScope, diff --git a/src/daemon/wait-runtime.ts b/src/daemon/wait-runtime.ts index 766f7df922..035b80aa42 100644 --- a/src/daemon/wait-runtime.ts +++ b/src/daemon/wait-runtime.ts @@ -22,7 +22,7 @@ import { } from './selector-runtime.ts'; import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from './request-runtime-binding.ts'; import type { DaemonRequest, DaemonResponse } from './daemon-request.ts'; -import type { SessionState } from './session-state.ts'; +import type { SessionRef, SessionState } from './session-state.ts'; import { maybeWaitTimeoutSurfaceResponse } from './wait-current-surface.ts'; import { withCaptureDisclosures } from './capture-disclosure.ts'; import { @@ -39,7 +39,7 @@ export async function dispatchWaitViaRuntime(params: DispatchWaitParams): Promis const parsedOrResponse = parseWaitRequest(req); if ('ok' in parsedOrResponse) return parsedOrResponse; const parsed = parsedOrResponse; - const { session, device } = await resolveSessionDevice(sessionStore, sessionName, req.flags); + const { ref, session, device } = await resolveSessionDevice(sessionStore, sessionName, req.flags); // ADR 0019: facts are the only support authority, through the selector family's one // admit-then-bind entry. A duration wait observes nothing, so it never asks for a binding — // exactly the cell legacy admission skipped by testing `parsed.kind !== 'sleep'`. @@ -58,7 +58,7 @@ export async function dispatchWaitViaRuntime(params: DispatchWaitParams): Promis // A pure sleep consumes no capture, so it never earns the system-surface disclosure below. if (parsed.kind === 'sleep') { return await executeWaitRequest( - params, + { ...params, ref }, waitParsed, session, device, @@ -75,7 +75,7 @@ export async function dispatchWaitViaRuntime(params: DispatchWaitParams): Promis device, () => executeWaitRequest( - params, + { ...params, ref }, waitParsed, session, device, @@ -146,7 +146,7 @@ function normalizeWaitPositionals( } async function executeWaitRequest( - params: DispatchWaitParams, + params: DispatchWaitParams & { ref: SessionRef | undefined }, parsed: Exclude, session: SessionState | undefined, device: SessionState['device'], @@ -157,6 +157,7 @@ async function executeWaitRequest( const { req, sessionName, sessionStore } = params; const runtime = createSelectorRuntimeForDevice({ ...params, + ref: params.ref, session, device, bound: waitOperations, diff --git a/src/session-repair-tombstone.ts b/src/session-repair-tombstone.ts index 59c2b60a9f..dedf854736 100644 --- a/src/session-repair-tombstone.ts +++ b/src/session-repair-tombstone.ts @@ -1,5 +1,6 @@ import path from 'node:path'; import fs from 'node:fs'; +import { AppError } from '@agent-device/kernel/errors'; /** * ADR 0012 decision 6, R7 (C5a): a reaped repair session leaves this bounded @@ -30,21 +31,64 @@ export function resolveRepairTombstonePath(sessionDir: string): string { } /** Parses/validates a tombstone file at `tombstonePath`; `undefined` if missing, malformed, or expired. */ -export function readRepairTombstoneFile(tombstonePath: string): RepairSessionTombstone | undefined { - let raw: string; +export function readRepairTombstoneFile( + tombstonePath: string, + owner: string, +): RepairSessionTombstone | undefined { try { - raw = fs.readFileSync(tombstonePath, 'utf8'); + const tombstone = readRepairTombstone(tombstonePath); + return tombstone?.owner === owner && tombstone.expiresAt > Date.now() ? tombstone : undefined; } catch { return undefined; } - let parsed: RepairSessionTombstone; +} + +/** Removes only a parseable marker belonging to the requested session, including expired markers. */ +export function clearRepairTombstoneFile(tombstonePath: string, owner: string): void { try { - parsed = JSON.parse(raw) as RepairSessionTombstone; - } catch { - return undefined; + if (readRepairTombstone(tombstonePath)?.owner === owner) { + fs.rmSync(tombstonePath, { force: true }); + } + } catch {} +} + +function readRepairTombstone(tombstonePath: string): RepairSessionTombstone | undefined { + let raw: string; + try { + raw = fs.readFileSync(tombstonePath, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; + throw error; + } + return parseRepairTombstone(raw, tombstonePath); +} + +function parseRepairTombstone(raw: string, tombstonePath: string): RepairSessionTombstone { + try { + const parsed = JSON.parse(raw) as RepairSessionTombstone; + if ( + !Number.isFinite(parsed?.expiresAt) || + typeof parsed?.owner !== 'string' || + !validRepairCommitFailure(parsed.commitFailure) + ) + throw new Error('Invalid repair tombstone fields'); + return parsed; + } catch (error) { + throw new AppError( + 'COMMAND_FAILED', + 'Repair evidence could not be inspected.', + { reason: 'repair_evidence_invalid', path: tombstonePath }, + error instanceof Error ? error : undefined, + ); } - if (typeof parsed?.expiresAt !== 'number' || parsed.expiresAt <= Date.now()) return undefined; - return parsed; +} + +function validRepairCommitFailure(value: unknown): boolean { + const failure = value as RepairSessionTombstone['commitFailure'] | null; + return ( + value === undefined || + (typeof failure?.code === 'string' && typeof failure?.message === 'string') + ); } /** @@ -54,6 +98,7 @@ export function readRepairTombstoneFile(tombstonePath: string): RepairSessionTom * CLIENT side of the daemon boundary (`cleanupDaemonAfterRequest` in * `daemon-client-lifecycle.ts`), which has no live `SessionStore`/session name * to key off of, only the filesystem path an owned ephemeral daemon was given. + * Unreadable or malformed evidence throws so cleanup retains the directory. * An owned ephemeral state dir services exactly one repair transaction at a * time, so the first match found is returned. * @@ -71,15 +116,16 @@ export function findUnrecoveredRepairCommitFailure(sessionsDir: string): let entries: fs.Dirent[]; try { entries = fs.readdirSync(sessionsDir, { withFileTypes: true }); - } catch { - return undefined; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; + throw error; } for (const entry of entries) { if (!entry.isDirectory()) continue; - const tombstone = readRepairTombstoneFile( + const tombstone = readRepairTombstone( resolveRepairTombstonePath(path.join(sessionsDir, entry.name)), ); - if (tombstone?.commitFailure) { + if (tombstone?.commitFailure && tombstone.expiresAt > Date.now()) { return { sessionName: entry.name, tombstone: { ...tombstone, commitFailure: tombstone.commitFailure },